【并发】Java内存模型

2022/1/15 7:03:31

本文主要是介绍【并发】Java内存模型,对大家解决编程问题具有一定的参考价值,需要的程序猿们随着小编来一起学习吧!

《Java并发编程的艺术》读书笔记

通信与同步

并发编程,需要处理两个关键问题:

  • 线程之间如何通信
  • 线程之间如何同步

「通信」是指线程之间以何种机制来交换信息,线程之间的通信机制有两种:

  • 共享内存:线程之间共享程序的公共状态,通过写-读内存中的公共状态进行隐式通信
  • 消息传递:线程之间没有公共状态,线程之间必须通过发送消息来显式进行通信

「同步」是指程序中用于控制不同线程间操作发生相对顺序的机制

  • 若采用"共享内存"的通信机制,同步是显式进行的,程序员必须显式指定某个方法或某段代码需要在线程之间互斥执行
  • 若采用"消息传递"的通信机制,由于消息的发送必须在消息的接收之前,因此同步是隐式进行的

关于Java并发:采用的是共享内存模型,Java线程之间的通信总是隐式进行,整个通信过程对程序员完全透明,此外,它的同步显式进行的。

抽象结构

我们先约定下「共享变量」的含义:包括了实例字段、静态字段和构成数组对象的元素

不包括局部变量、方法参数、异常处理器参数,因为它们是线程私有的,不会被线程共享,不存在竞争问题,与并发的关系不大

Java内存模型的主要目的是定义程序中各种变量的访问规则,即关注在虚拟机中把变量值存储到内存和从内存中取出变量值这样的底层细节。JMM规定了所有的变量都存储在主内存中。每条线程还有自己的工作内存,线程的工作内存中保存了被该线程使用的变量的主内存副本,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存中的数据。不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成。线程、主内存、工作内存三者的交互关系如下:

image-20211102181820837

主内存主要对应于Java堆中的对象实例数据部分,而工作内存则对应于虚拟机栈中的部分区域。从更基础的层次上说,主内存直接对应于物理硬件的内存,工作内存可能会优先存储于寄存器和高速缓存中,因为程序运行时主要访问的是工作内存。

Java线程之间的通信由JMM控制,例如线程A要与线程B通信,要经历下面两个步骤

  1. 线程A把本地内存A中更新过的共享变量刷新到主内存中去
  2. 线程B到主内存中去读取线程A之前已更新过的共享变量

从整体来看,这两个步骤实质上是线程A在向线程B发送消息,而且这个通信过程必须要经过主内存。JMM通过控制主内存与每个线程的本地内存之间的交互,来为Java程序员提供内存可见性保证。

内存间交互操作

关于主内存与工作内存之间具体的交互协议,即一个变量如何从主内存拷贝到工作内存、如何从工作内存同步回主内存这一类的实现细节,JMM中定义了以下8种操作来完成。JVM实现时必须保证下面提及的每一种操作都是原子的、不可再分的(对于double和long类型的变量来说,load、store、read和write操作在某些平台上允许有例外)

  • lock(锁定):作用于主内存的变量,它把一个变量标识为一条线程独占的状态。
  • unlock(解锁):作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
  • read(读取):作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的load动作使用。
  • load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中。
  • use(使用):作用于工作内存的变量,它把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作。
  • assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
  • store(存储):作用于工作内存的变量,它把工作内存中一个变量的值传送到主内存中,以便随后的write操作使用。
  • write(写入):作用于主内存的变量,它把store操作从工作内存中得到的变量的值放入主内存的变量中。

image-20211201182843662

如果要把一个变量从主内存拷贝到工作内存,那就要按顺序执行read和load操作;如果要把变量从工作内存同步回主内存,就要按顺序执行store和write操作。注意,JMM只要求上述两个操作必须按顺序执行,但不要求是连续执行

JMM规定了在执行上述8种基本操作时必须满足如下规则:

  • 不允许read和load、store和write操作之一单独出现,即不允许一个变量从主内存读取了但工作内存不接受,或者工作内存发起回写了但主内存不接受的情况出现。
  • 不允许一个线程丢弃它最近的assign操作,即变量在工作内存中改变了之后必须把该变化同步回主内存。
  • 不允许一个线程无原因地(没有发生过任何assign操作)把数据从线程的工作内存同步回主内存中。
  • 一个新的变量只能在主内存中"诞生",不允许在工作内存中"诞生"一个新的变量,也就是说,工作内存中的一个变量在执行use前,需要先进行load操作,在执行store前,需要先执行assign操作。
  • 一个变量在同一个时刻只允许一条线程对其进行lock操作,但lock操作可以被同一条线程重复执行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才会被解锁。
  • 如果对一个变量执行lock操作,那将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行load或assign操作以初始化变量的值。
  • 如果一个变量事先没有被lock操作锁定,那就不允许对它执行unlock操作,也不允许去unlock一个被其他线程锁定的变量。
  • 对一个变量执行unlock操作之前,必须先把此变量同步回主内存中(执行store、write操作)。

这8种内存访问操作以及上述规则限定,再加上稍后会介绍的专门针对volatile的一些特殊规定,就已经能准确地描述出Java程序中哪些内存访问操作在并发下才是安全的。但是这种定义极为烦琐,对于我们,可以利用后面会介绍的happens-before原则,用来确定一个操作在并发环境下是否是安全的。

重排序

在执行程序时,为了提高性能,编译器处理器常常会对指令做重排序,重排序分3种类型

  • 编译器优化的重排序:编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序

  • 指令级并行的重排序:现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序

  • 内存系统的重排序:由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行

从Java源代码到最终实际执行的指令序列,会分别经历下面3种重排序

image-20211102191857382

重排序可能会导致多线程程序出现内存可见性问题。

JMM属于语言级的内存模型,它确保在不同的编译器和不同的处理器平台之上,通过禁止特定类型的编译器重排序和处理器重排序,为程序员提供一致的内存可见性保证。

Java编译器在生成指令序列的适当位置会插入特定类型的内存屏障指令,来禁止特定类型的处理器重排序,JMM把内存屏障指令分为4类,如下

image-20211107115854724

StoreLoad Barriers是一个全能型的屏障,它同时具有其他3个屏障的效果。现代的多处理器大多支持该屏障(其他类型的屏障不一定被所有处理器支持)。执行该屏障开销会很昂贵,因为当前处理器通常要把写缓冲区中的数据全部刷新到内存中。

重排序的好处:提高程序的并行度,提高程序的性能。具体可以查看文章 为什么要指令重排序?.

数据依赖性

如果两个操作访问同一个变量,且这两个操作中有一个为写操作,此时这两个操作之间就存在数据依赖性。数据依赖分为下列3种类型:

image-20211108113739843

上面3种情况,只要重排序两个操作的执行顺序,程序的执行结果就会被改变。编译器和处理器在重排序时,会遵守数据依赖性,编译器和处理器不会改变存在数据依赖关系的两个操作的执行顺序。

as-if-serial

as-if-serial语义的意思:不管怎么重排序,(单线程)程序的执行结果不能被改变。

为了遵守as-if-serial语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。但是,如果操作之间不存在数据依赖关系,这些操作就可能被编译器和处理器重排序。

as-if-serial语义把单线程程序保护了起来,为编写单线程程序的程序员创建了一个幻觉:单线程程序是按程序的顺序来执行的。as-if-serial语义使单线程程序员无需担心重排序会干扰他们,也无需担心内存可见性问题。

书P28的例子很棒!

happens-before

happens-before的概念用于阐述操作之间的内存可见性,被阐述的两个操作既可以是在一个线程之内,也可以是在不同线程之间,happens-before性质如下

  1. 如果一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前
  2. 两个操作之间存在happens-before关系,并不意味着Java平台的具体实现必须要按照happens-before关系指定的顺序来执行。如果重排序之后的执行结果,与按happens-before关系来执行的结果一致,那么这种重排序并不非法(也就是说,JMM允许这种重排序)
  • 上面的第1点是JMM对程序员的承诺。从程序员的角度来说,可以这样理解happens-before关系:如果A happens-before B,那么Java内存模型将向程序员保证——A操作的结果将对B可见,且A的执行顺序排在B之前。注意,这只是Java内存模型向程序员做出的保证!
  • 上面的第2点是JMM对编译器和处理器重排序的约束原则。正如前面所言,JMM其实是在遵循一个基本原则:只要不改变程序的执行结果(指的是单线程程序和正确同步的多线程程序),编译器和处理器怎么优化都行。JMM这么做的原因是:程序员对于这两个操作是否真的被重排序并不关心,程序员关心的是程序执行时的语义不能被改变(即执行结果不能被改变)。因此,happens-before关系本质上和as-if-serial语义是一回事。

happens-before语义的对比:

  • as-if-serial语义保证单线程内程序的执行结果不被改变,happens-before关系保证正确同步的多线程程序的执行结果不被改变。
  • as-if-serial语义给编写单线程程序的程序员创造了一个幻境:单线程程序是按程序的顺序来执行的。happens-before关系给编写正确同步的多线程程序的程序员创造了一个幻境:正确同步的多线程程序是按happens-before指定的顺序来执行的。

as-if-serial语义和happens-before这么做的目的,都是为了在不改变程序执行结果的前提下,尽可能地提高程序执行的并行度。

从程序员的角度来看,具有happens-before关系的两个操作,不会被重排序,但实际上,JMM把happens-before要求禁止的重排序分为了下面两类

  • 会改变程序执行结果的重排序
  • 不会改变程序执行结果的重排序

JMM对这两种不同性质的重排序,采取了不同的策略,如下

  • 对于会改变程序执行结果的重排序,JMM要求编译器和处理器必须禁止这种重排序
  • 对于不会改变程序执行结果的重排序,JMM对编译器和处理器不做要求(JMM允许这种重排序),编译器和处理器可以根据需要对其做重排序等优化措施

上面的做法,为程序员提供了足够强的内存可见性保证,在不改变程序执行结果的前提下,编译器和处理器可以做一些优化,这些优化提高了程序的执行效率。

happens-before规则

  • 程序顺序规则:一个线程中的每个操作,happens-before于该线程中的任意后续操作。
  • 监视器锁规则:对一个锁的解锁,happens-before于随后对这个锁的加锁。
  • volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的读。
  • 传递性:如果A happens-before B,且B happens-before C,那么A happens-before C。
  • start()规则:如果线程A执行操作ThreadB.start()(启动线程B),那么A线程的ThreadB.start()操作happens-before于线程B中的任意操作。
  • join()规则:如果线程A执行操作ThreadB.join()并成功返回,那么线程B中的任意操作happens-before于线程A从ThreadB.join()操作成功返回。

关于每个规则的解释与运用,参见书本P65

顺序一致性

顺序一致性内存模型是一个理想化的理论参考模型,它提供内存可见性保证,它有两大特性

  1. 一个线程中的所有操作必须按照程序的顺序来执行
  2. (不管程序是否同步)所有线程都只能看到一个单一的操作执行顺序。在顺序一致性内存模型中,每个操作都必须原子执行且立刻对所有线程可见

在顺序一致性内存模型中,一个多线程程序不管是不是同步的,所有线程都只能看到一个一致的整体执行顺序,这是因为「顺序一致性内存模型」的第2个特性「每个操作都必须原子执行且立刻对所有线程可见」。

在JMM中,正确同步的多线程程序的执行具有顺序一致性,即程序的执行结果与该程序在「顺序一致性内存模型」的执行结果相同;而未同步的程序在JMM中没有顺序一致性的保证,因此未同步程序的每个操作不一定立刻对所有的线程可见,导致所有线程看到的操作执行顺序也可能不一致,比如,一个线程把当前写过的数据缓存在本地内存中,在没有刷新到主内存之前,这个写操作仅对当前线程可见,从其他线程的角度来观察,会认为这个写操作根本没有被当前线程执行。只有当前线程把本地内存中写过的数据刷新到主内存之后,这个写操作才能对其他线程可见。在这种情况下,当前线程和其他线程看到的操作执行顺序将不一致。

那么,JMM如何区分一个程序是否正确同步呢?关键在于是否有数据竞争,JMM对数据竞争的定义如下

在一个线程中写一个变量,
在另一个线程读同一个变量,
而且写和读没有通过同步来排序。

若一个多线程程序没有数据竞争,那么就表明该多线程程序正确同步;若一个多线程程序有数据竞争,那么就表明该多线程程序未同步或者未正确同步

这里的同步是指广义上的同步,包括对常用同步原语(synchronized、volatile和final)的正确使用

一个例子

下面是一个正确同步的程序

class SynchronizedExample {
    int a = 0;
    boolean flag = false;

    public synchronized void writer() { // 获取锁
        a = 1;
        flag = true;
    } // 释放锁

    public synchronized void reader() { // 获取锁
        if (flag) {
            int i = a;
        } // 释放锁
    }
}

假设线程A执行write方法后,线程B执行reader方法,根据JMM规范,正确同步的多线程程序执行结果将与该程序在顺序一致性模型中的执行结果相同。该程序在两个内存模型中的执行时序对比图如下

image-20211112141947055

顺序一致性模型中,所有操作完全按程序的顺序串行执行;在JMM中,临界区内的代码可以重排序(但JMM不允许临界区内的代码"逸出"到临界区之外)。

JMM在具体实现上的基本方针为:在不改变(正确同步的)程序执行结果的前提下,尽可能地为编译器和处理器的优化打开方便之门。

Volatile的内存语义

理解volatile特性的一个好方法是把对volatile变量的单个读/写,看成是使用同一个锁对这些单个读/写操作做了同步。

volatile变量自身具有下列特性

  • 可见性:对一个volatile变量的读,总是能看到(任意线程)对这个volatile变量最后的写入。
  • 原子性:对任意单个volatile变量的读/写具有原子性,但类似于volatile++这种复合操作不具有原子性。

由于有「原子性」,所以即使是64位的long型和double型变量,它的读/写也有原子性。

volatile变量写-读内存语义

  • 当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值刷新到主内存。
  • 当读一个volatile变量时,JMM会把该线程对应的本地内存置为无效。线程接下来将从主内存中读取共享变量。

对于下面的程序

class VolatileExample {
    int a = 0;
    volatile boolean flag = false;

    public void writer() {
        a = 1;// 1
        flag = true;// 2
    }

    public void reader() {
        if (flag) {// 3
            int i = a;// 4
        }
    }
}

假设线程A首先执行writer()方法,随后线程B执行reader()方法。在读线程B读一个volatile变量后,写线程A在写这个volatile变量之前所有可见的共享变量的值都将立即变得对读线程B可见。

volatile变量写-读的内存语义可以实现「线程之间通信」

image-20211113173733881

内存语义的实现

重排序分为编译器重排序和处理器重排序。为了实现volatile内存语义,JMM会分别限制这两种类型的重排序类型,下面是JMM针对编译器制定的volatile重排序规则表

image-20211114170736533

从表中可以看出

  • 当第二个操作是volatile写时,不管第一个操作是什么,都不能重排序。这个规则确保volatile写之前的操作不会被编译器重排序到volatile写之后。
  • 当第一个操作是volatile读时,不管第二个操作是什么,都不能重排序。这个规则确保volatile读之后的操作不会被编译器重排序到volatile读之前。
  • 当第一个操作是volatile写,第二个操作是volatile读时,不能重排序。

回顾一下内存屏障的相关知识:Java编译器在生成指令序列的适当位置会插入特定类型的内存屏障指令,来禁止特定类型的处理器重排序,JMM把内存屏障指令分为4类,如下

image-20211107115854724

StoreLoad Barriers是一个全能型的屏障,它同时具有其他3个屏障的效果。

为了实现volatile的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。对于编译器来说,发现一个最优布置来最小化插入屏障的总数几乎不可能,为此,JMM采取保守策略,如下

  • 在每个volatile写操作的前面插入一个StoreStore屏障。
  • 在每个volatile写操作的后面插入一个StoreLoad屏障。
  • 在每个volatile读操作的后面插入一个LoadLoad屏障。
  • 在每个volatile读操作的后面插入一个LoadStore屏障。

在实际执行时,只要不改变volatile写-读的内存语义,编译器可以根据具体情况省略不必要的屏障。

volatile语义的增强

在 JSR-133 中,对volatile的内存语义进行了增强。

在旧的JMM中,虽然不允许volatile变量之间重排序,但是允许volatile变量与普通变量的重排序,所以在旧的JMM中,volatile的写-读没有锁的释放-获所具有的内存语义,为了提供一种比锁更轻量级的线程之间通信的机制,在 JSR-133 中,通过严格限制编译器和处理器对volatile变量与普通变量的重排序,对volatile的内存语义进行了增强,确保volatile的写-读和锁的释放-获取具有相同的内存语义。

在现在的JMM中,只要volatile变量与普通变量之间的重排序可能会破坏volatile的内存语义,这种重排序就会被编译器重排序规则和处理器内存屏障插入策略禁止。

volatile与锁的对比:由于volatile仅仅保证对单个volatile变量的读/写具有原子性,而锁的互斥执行的特性可以确保对整个临界区代码的执行具有原子性。在功能上,锁比volatile强大;在执行性能上,volatile更有优势。

锁的内存语义

锁除了让临界区互斥执行外,还可以让释放锁的线程向获取同一个锁的线程发送消息,「发送消息」的操作主要是依靠锁的内存语义来实现,锁的内存语义如下

  • 当线程释放锁时,JMM会把该线程对应的本地内存中的共享变量刷新到主内存中
  • 当线程获取锁时,JMM会把该线程对应的本地内存置为无效,从而使得被监视器保护的临界区代码必须从主内存中读取共享变量

对比锁释放-获取的内存语义与volatile写-读的内存语义可以看出:锁释放与volatile写有相同的内存语义;锁获取与volatile读有相同的内存语义。

下面对锁释放和锁获取的内存语义做个总结:

  • 线程A释放一个锁,实质上是线程A向接下来将要获取这个锁的某个线程发出了(线程A对共享变量所做修改的)消息。
  • 线程B获取一个锁,实质上是线程B接收了之前某个线程发出的(在释放这个锁之前对共享变量所做修改的)消息。
  • 线程A释放锁,随后线程B获取这个锁,这个过程实质上是线程A通过主内存向线程B发送消息。

内存语义的实现

ReentrantLock为例,分析锁内存语义的具体实现机制。

ReentrantLock的实现依赖于AQSAQS使用一个整型的volatile变量(命名为state)来维护同步状态。ReentrantLock分为公平锁和非公平锁,它们的内存语义如下:

  • 公平锁和非公平锁释放时,最后都要写一个volatile变量state
  • 公平锁获取时,首先会去读volatile变量。
  • 非公平锁获取时,首先会用CAS更新volatile变量,这个操作同时具有volatile读和volatile写的内存语义。

介绍:CAS

CAS是CompareAndSwap,比较并交换,它是一种思想,一种算法。

基本思想:在CAS机制中使用了3个基本操作数:内存地址V,旧的预期值A,修改后的新值B,更新一个变量时,只有当变量的预期值A与地址V中的实际值一致,才会将地址V对应的值修改为B。

CAS是原子操作,基于CPU提供的原子操作指令实现。

CAS 的特点是避免使用互斥锁,当多个线程同时使用 CAS 更新同一个变量时,只有其中一个线程能够操作成功,而其他线程都会更新失败。不过和同步互斥锁不同的是,更新失败的线程并不会被阻塞,而是被告知这次由于竞争而导致的操作失败,但还可以再次尝试,也就是自旋

查看Unsafe::compareAndSwapInt方法

    /**
     * Atomically updates Java variable to {@code x} if it is currently
     * holding {@code expected}.
     *
     * <p>This operation has memory semantics of a {@code volatile} read
     * and write.  Corresponds to C11 atomic_compare_exchange_strong.
     *
     * @return {@code true} if successful
     */
    @ForceInline
    public final boolean compareAndSwapInt(Object o, long offset,
                                           int expected,
                                           int x) {
        return theInternalUnsafe.compareAndSetInt(o, offset, expected, x);
    }

它的作用:如果当前状态值等于预期值,则以原子方式将同步状态设置为给定的更新值。此操作具有volatile读和写的内存语义

因为CAS同时实现了volatile读和volatile写的内存语义,意味着编译器不能对CASCAS前面和后面的任意内存操作重排序。

为什么CAS同时具有volatile读和写的内存语义?

CAS的实现原理:如果程序在多处理器上运行,那么cmpxchg指令会被加上lock前缀,lock前缀的作用

  1. 确保对内存的读-改-写操作原子执行。以前的处理器,带有lock前缀的指令在执行期间会锁住总线,使得其他处理器暂时无法通过总线访问内存。现在的处理器,使用缓存锁定来保证指令执行的原子性。降低lock前缀指令的执行开销。
  2. 禁止该指令,与之前和之后的读和写指令重排序。
  3. 把写缓冲区中的所有数据刷新到内存中。

上面的第2点和第3点所具有的内存屏障效果,足以同时实现volatile读和volatile写的内存语义

从对ReentrantLock的分析可以看出,锁释放-获取的内存语义的实现至少有下面两种方式

  • 利用volatile变量的写-读所具有的内存语义。
  • 利用CAS所附带的volatile读和volatile写的内存语义。

1.7版本AtomicInteger::getAndIncrement就利用了CAS自旋volatile变量进行值的更新:

    public final int getAndIncrement() {
        for (;;) {
            int current = get();
            int next = current + 1;
            if (compareAndSet(current, next))
                return current;
        }
    }

其中get方法为

    public final int get() {
        return value;
    }

    private volatile int value;

所以AtomicInteger::getAndIncrement逻辑如下:

  1. compareAndSet方法首先判断当前值是否等于current;
  2. 如果当前值 = current ,说明AtomicInteger的值没有被其他线程修改;
  3. 如果当前值 != current,说明AtomicInteger的值被其他线程修改了,这时会再次进入循环重新比较;

getAndIncrement方法中,它的做法是:先获取到当前的 value 属性值,然后将 value1,赋值给一个局部的 next 变量,然而,这两步都是非线程安全的,但是内部有一个死循环,不断去做compareAndSet操作,直到成功为止,也就是修改的根本在compareAndSet方法里面。

compareAndSet方法中调用的是sun.misc.Unsafe.compareAndSwapInt(Object obj, long valueOffset, int expect, int update)方法。compareAndSwapInt 基于的是CPUCAS指令来实现的。所以基于 CAS 的操作可认为是无阻塞的,并且由于 CAS 操作是 CPU 原语,所以性能比较好。

value变量使用volatile修饰,就保证了线程间对value变量的可见性,某个线程对value的变量都可以及时地被其他线程看到。

AtomicInteger::getAndIncrement方法保证了他和其他函数对 value 值得更新都是有效的,他所利用的是基于冲突检测的乐观并发策略。 可以想象,这种乐观在线程数目非常多的情况下,失败的概率会指数型增加。

局限性

  1. ABA问题:当CPU1从缓存里面读到了数值A,另一个CPU2这时候也从缓存里面读到了A,然后将他主内存里面的值先修改成B,再将他修改成A,释放缓存锁,此时CPU1获取到缓存锁,去读主内存里面的值,发现还是A,判断相等修改新值,这在CPU1的线程里面看起来是没有任何改变,但实际上主内存里面这块地址的值已经有了一个A->B->A的改变,自从jdk1.5之后,加入了AtomicStampedReference类来防止这个问题,通过将引用和版本号作为一个tuple来防止ABA问题,那么修改结果就会变成1A->1B->2A,就能看到内存里面这个值的改变。
public class AtomicStampedReference<V> {
    private static class Pair<T> {
        final T reference;
        final int stamp;
        private Pair(T reference, int stamp) {
            this.reference = reference;
            this.stamp = stamp;
        }
        static <T> Pair<T> of(T reference, int stamp) {
            return new Pair<T>(reference, stamp);
        }
    }

    private volatile Pair<V> pair;
	...
}
  1. 自旋效率问题:因为在AtomicInteger类里面,其实是一个自旋的CAS,就是不断重复通过执行CAS指令,直到成功为止,当长时间进行CAS的自旋的时候,会引起CPU资源的大量消耗。
  2. 只能保证一个共享变量的原子操作:当只有一个共享变量进行CAS操作的时候,就可以进行自旋的CAS去进行原子操作,但是对多个共享变量进行CAS操作的时候,循环CAS无法保证原子性,这时候我们可以取巧将这两个变量放在一个对象里面,因为这时候锁定的就直接锁定了一个对象的缓存行(段),那么就可以完整的对这个对象及其引用做一个原子操作。java提供的AtomicReference类可以用来保证对象之间的原子性。

什么时候使用CAS+volatile?

什么时候使用CAS自旋volatile共享变量来完成共享变量的更新?

CAS通常比锁快得多(CAS通过硬件层次来体现,其效率和性能相比阻塞性的synchronizedlock来说更加的快),但是它取决于共享变量的争用程度,如果很多线程并发地对共享变量进行修改,那么线程可以会在自旋中进行无效地等待,降低了CPU的利用率,另外,要正确的使用CAS进行编程,其难度要高于使用锁。

可以这样,当同步锁的性能还不是系统性能瓶颈的时候,可以先考虑使用同步锁synchronizedlock,但是当同步锁的性能已经是系统瓶颈,那就要开始考虑使用CAS+volatile的非阻塞乐观锁的方式来降低同步锁带来的阻塞性能的问题。

Concurrent包的实现

volatile变量的读/写和CAS可以实现线程之间的通信,是整个concurrent包得以实现的基石

image-20211123215302982

final域的内存语义

对于final域,编译器和处理器要遵守两个重排序规则

  1. 在构造函数内对一个final域的写入,与随后把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。
  2. 初次读一个包含final域的对象的引用,与随后初次读这个final域,这两个操作之间不能重排序。

---自己的理解

第1点:由下面的写final域的重排序规则可以得出,编译器会在final域的写之后、构造函数return之前,插入StoreStore屏障,因为final域的写和引用赋值都是store操作,在屏障的作用下,它们不会被重排序

第2点:由下面的读final域的重排序规则可以得出

写final域的重排序规则:禁止把final域的写重排序到构造函数之外。这个规则的实现包含下面2个方面

  1. JMM禁止编译器把final域的写重排序到构造函数之外
  2. 编译器会在final域的写之后,构造函数return之前,插入一个StoreStore屏障。这个屏障禁止处理器把final域的写重排序到构造函数之外

写final域的重排序规则可以确保:在对象引用为任意线程可见之前,对象的final域已经被正确初始化过了,而普通域不具有这个保障。其实,要得到这个效果,还需要一个保证:在构造函数内部,不能让这个被构造对象的引用为其他线程所见,也就是对象引用不能在构造函数中"逸出"

读final域的重排序规则:在一个线程中,初次读对象引用与初次读该对象包含的final域,JMM禁止处理器重排序这两个操作(注意,这个规则仅仅针对处理器)。编译器会在读final域操作的前面插入一个LoadLoad屏障

初次读对象引用与初次读该对象包含的final域,这两个操作之间存在间接依赖关系。由于编译器遵守间接依赖关系,因此编译器不会重排序这两个操作。大多数处理器也会遵守间接依赖,也不会重排序这两个操作。但有少数处理器允许对存在间接依赖关系的操作做重排序,这个规则就是专门用来针对这种处理器的。

读final域的重排序规则可以确保:在读一个对象的final域之前,一定会先读包含这个final域的对象的引用。

如果final域是引用类型:

对于引用类型,写final域的重排序规则对编译器和处理器增加了如下约束:在构造函数内对一个final引用的对象的成员域的写入,与随后在构造函数外把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序

JSR-133对旧内存模型的修补

JSR-133对JDK 5之前的旧内存模型的修补主要有两个

  • 增强volatile的内存语义
  • 增强final的内存语义

并发三大特性(原子 可见 有序)

Java内存模型是围绕着在并发过程中如何处理原子性、可见性和有序性这三个特征来建立的,我们逐个来看一下哪些操作实现了这三个特性

  1. 原子性:由Java内存模型来直接保证的原子性变量操作包括read、load、assign、use、store和write这六个,我们大致可以认为,基本数据类型的访问、读写都是具备原子性的。如果应用场景需要一个更大范围的原子性保证,可以使用synchronizedsynchronized块之间的操作具备原子性

  2. 可见性:可见性就是指当一个线程修改了共享变量的值时,其他线程能够立即得知这个修改。Java内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值这种依赖主内存作为传递媒介的方式来实现可见性的。在多线程环境下通过volatile关键字保证变量的可见性,除此之外还能通过synchronized和final关键字实现可见性。

    • volatile:volatile的特殊规则保证了新值能立即同步到主内存,以及每次使用前立即从主内存刷新
    • synchronized:由"对一个变量执行unlock操作之前,必须先把此变量同步回主内存中(执行store、write操作)"这条规则获得的
    • final:被final修饰的字段在构造器中一旦被初始化完成,并且构造器没有把"this"的引用传递出去,那么在其他线程中就能看见final字段的值
  3. 有序性:Java语言提供了volatile和synchronized两个关键字来保证线程之间操作的有序性

    • volatile:本身就包含了禁止指令重排序的语义
    • synchronized:由"一个变量在同一个时刻只允许一条线程对其进行lock操作"这条规则获得的,这个规则决定了持有同一个锁的两个同步块只能串行地进入

Java程序中天然的有序性可以总结为一句话:如果在本线程内观察,所有的操作都是有序的;如果在一个线程中观察另一个线程,所有的操作都是无序的。前半句是指"线程内表现为串行的语义"(Within-Thread As-If-Serial Semantics),后半句是指"指令重排序"现象和“工作内存与主内存同步延迟”现象。

参考

  1. 《Java并发编程的艺术》
  2. 《深入理解Java虚拟机 第3版》
  3. CAS自旋volatile变量 - 博客园。
  4. java多线程编程之volatile和CAS - 简书。


这篇关于【并发】Java内存模型的文章就介绍到这儿,希望我们推荐的文章对大家有所帮助,也希望大家多多支持为之网!


扫一扫关注最新编程教程