java内存模型 volatile和锁的内存语义

目录

volatile的内存语义

volatile的特性

volatile写-读建立的happens-before关系

volatile写-读的内存语义

 volatile内存语义的实现

 JSR-133为什么要增强volatile的内存语义

锁的内存语义

锁的释放-获取建立的happens-before关系

锁的释放和获取的内存语义

锁内存语义的实现

concurrent包的实现


volatile的内存语义

volatile的特性

理解volatile特性的一个好方法是把对volatile变量的单个读/写,看成是使用同一个锁对这 些单个读/写操作做了同步。下面通过具体的示例来说明,示例代码如下。

假设有多个线程分别调用上面程序的3个方法,这个程序在语义上和下面程序等价。

如上面示例程序所示,一个volatile变量的单个读/写橾作,与一个普通变量的读/写操作都 是使用同一个锁来同步,它们之间的执行效果相同。

锁的happens-before规则保证释放锁和获取锁的两个线程之间的内存可见性,这意味着对 一个volatile变量的读,总是能看到(任意线程)对这个volatile变量最后的写入。

锁的语义决定了临界区代码的执行具有原子性。这意味着,即使是64位的1ong型和double 型变量,只要它是volatile变量,对该变量的读/写就具有原子性。如果是多个volatile操作或类 似于volatile++这种复合操作,这些操作整体上不具有原子性。

简而言之,volatile变量自身具有下列特性。

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

volatile写-读建立的happens-before关系

上面讲的是volatile变量自身的特性,对程序员来说volatile对线程的内存可见性的影响 比volatile自身的特性更为重要,也更需要我们去关注。

从JSR-133开始(即从JDK5开始),volatile变量的写-读可以实现线程之间的通信。

从内存语义的角度来说,volatile的写-读与锁的释放-获取有相同的内存效果: volatile写和 锁的释放有相同的内存语义; volatile读与锁的获取有相同的内存语义。

请看下面使用volatile变量的示例代码。

假设线程A执行writer()方法之后,线程B执行reader()方法。根据happens-before规则,这个 过程建立的happens-before关系可以分为3类:

1)根据程序次序规则,1 happens-before 2;3 happens-before 4。

2)根据volatile规则,2 happens-before 3。

3)根据happens-before的传递性规则,1 happens-before 4。

上述happens-before关系的图形化表现形式如下。

在上图中,每一个箭头链接的两个节点,代表了一个happens-before关系。黑色箭头表示程 序顺序规则;橙色箭头表示volatile规则;蓝色箭头表示组合这些规则后提供的happens-before保 证。

这里A线程写一个volatile变量后,B线程读同一个volatile变量。A线程在写volatile变量之 前所有可见的共享变量,在B线程读同一个volatile变量后,将立即变得对B线程可见。

volatile写-读的内存语义

volatile写的内存语义如下。

当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值刷新到主内存。

以上面示例程序VolatileExample为例,假设线程A首先执行writer()方法,随后线程B执行 reader()方法,初始时两个线程的本地内存中的flag和a都是初始状态。图是线程A执行 volatile写后,共享变量的状态示意图。

如图所示,线程A在写flag变量后,本地内存A中被线程A更新过的两个共享变量的值 被刷新到主内存中。此时,本地内存A和主内存中的共享变量的值是一致的。

volatile读的内存语义如下。

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

图为线程B读同一个volatile变量后,共享变量的状态示意图。

如图所示,在读flag变量后,本地内存B包含的值已经被置为无效。此时,线程B必须从主 内存中读取共享变量。线程B的读取橾作将导致本地内存B与主内存中的共享变量的值变成一 致。

如果我们把volatile写和volatile读两个步骤综合起来看的话,

在读线程B读一个volatile变 量后,写线程A在写这个volatile变量之前所有可见的共享变量的值都将立即变得对读线程B可 见。

下面对volatile写和volatile读的内存语义做个总结。

线程A写一个volatile变量,实质上是线程A向接下来将要读这个volatile变量的某个线程 发出了(其对共享变量所做修改的)消息。

线程B读一个volatile变量,实质上是线程B接收了之前某个线程发出的(在写这个volatile 变量之前对共享变量所做修改的)消息。

线程A写一个volatile变量,随后线程B读这个volatile变量,这个过程实质上是线程A通过 主内存向线程B发送消息。

 volatile内存语义的实现

下面来看看JMM如何实现volatile写/读的内存语义。

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

举例来说第三行最后一个单元格的意思是:在程序中,当第一个橾作为普通变量的读或 写时,如果第二个操作为volatile写,则编译器不能重排序这两个操作。

从表我们可以看出。

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

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

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

上述内存屏障插入策略非常保守,但它可以保证在任意处理器平台,任意的程序中都能 得到正确的volatile内存语义。

下面是保守策略下,volatile写插入内存屏障后生成的指令序列示意图如图

图中的Store Store屏障可以保证在volatile写之前,其前面的所有普通写操作已经对任 意处理器可见了。这是因为Store Store屏障将保障上面所有的普通写在volatile写之前刷新到主内存。

这里比较有意思的是,volatile写后面的StoreLoad屏障。此屏障的作用是避免volatile写与 后面可能有的volatile读/写操作重排序。因为编译器常常无法准确判断在一个volatile写的后面 是否需要插入一个StoreLoad屏障(比如,一个volatile写之后方法立即return)。为了保证能正确 实现volatile的内存语义JMM在采取了保守策略在每个volatile写的后面,或者在每个volatile 读的前面插入一个StoreLoad屏障。从整体执行效率的角度考虑,JMM最终选择了在每个

volatile写的后面插入一个StoreLoad屏障。因为volatile写-读内存语义的常见使用模式是:一个 写线程写volatile变量,多个读线程读同一个volatile变量。当读线程的数量大大超过写线程时, 选择在volatile写之后插入StoreLoad屏障将带来可观的执行效率的提升。从这里可以看到JMM 在实现上的一个特点:首先确保正确性,然后再去追求执行效率。

下面是在保守策略下,volatile读插入内存屏障后生成的指令序列示意图如图

图中的LoadLoad屏障用来禁止处理器把上面的volatile读与下面的普通读重排序。

LoadStore屏障用来禁止处理器把上面的volatile读与下面的普通写重排序。

上述volatile写和volatile读的内存屏障插入策略非常保守。在实际执行时,只要不改变volatile写-读的内存语义,编译器可以根据具体情况省略不必要的屏障。下面通过具体的示例 代码进行说明。

针对readAndWriteO方法,编译器在生成字节码时可以做如下的优化。

注意,最后的StoreLoad屏障不能省略。因为第二个volatile写之后,方法立即return。此时编 译器可能无法准确断定后面是否会有volatile读或写,为了安全起见,编译器通常会在这里插 入一个StoreLoad屏障。

上面的优化针对任意处理器平台,由于不同的处理器有不同松紧度”的处理器内存模 型,内存屏障的插入还可以根据具体的处理器内存模型继续优化。以X86处理器为例,图中除最后的StoreLoad屏障外,其他的屏障都会被省略。

前面保守策略下的volatile读和写,在X86处理器平台可以优化成如图所示。

前文提到过X86处理器仅会对写-读操作做重排序。X86不会对读-读、读-写和写-写操作 做重排序,因此在X86处理器中会省略掉这3种操作类型对应的内存屏障。在X86中,JMM仅需 在volatile写后面插入一个StoreLoad屏障即可正确实现volatile写-读的内存语义。这意味着在 X86处理器中,volatile写的开销比volatile读的开销会大很多(因为执行StoreLoad屏障开销会比 较大)。

 JSR-133为什么要增强volatile的内存语义

在JSR-133之前的旧Java内存模型中,虽然不允许volatile变量之间重排序,但旧的Java内 存模型允许volatile变量与普通变量重排序。在旧的内存模型中,VolatileExample示例程序可能 被重排序成下列时序来执行,如图

在旧的内存模型中,当l和2之间没有数据依赖关系时,1和2之间就可能被重排序(3和4类 似)。其结果就是:读线程B执行4时,不一定能看到写线程A在执行1时对共享变量的修改。

因此,在旧的内存模型中,volatile的写-读没有锁的释放-获所具有的内存语义。为了提供 一种比锁更轻量级的线程之间通信的机制,JSR-133专家组决定增强volatile的内存语义:严格 限制编译器和处理器对volatile变量与普通变量的重排序,确保volatile的写-读和锁的释放-获 取具有相同的内存语义。从编译器重排序规则和处理器内存屏障插入策略来看,只要volatile 变量与普通变量之间的重排序可能会破坏volatile的内存语义,这种重排序就会被编译器重排 序规则和处理器内存屏障插入策略禁止。

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

锁的内存语义

锁的释放-获取建立的happens-before关系

锁是Java并发编程中最重要的同步机制。锁除了让临界区互斥执行外,还可以让释放锁的 线程向获取同一个锁的线程发送消息。

下面是锁释放-获取的示例代码

假设线程A执行writerO方法,随后线程B执行readerO方法。根据happens-before规则,这个 过程包含的happens-before关系可以分为3类。

1)根据程序次序规则,1 happens-before 2,2 happens-before 3;4 happens-before 5,5 happens­before 6。

2)根据监视器锁规则,3 happens-before 4。

3)根据happens-before的传递性,2 happens-before 5。

上述happens-before关系的图形化表现形式如图

在图中,每一个箭头链接的两个节点,代表了一个happens-before关系。黑色箭头表示 程序顺序规则;橙色箭头表示监视器锁规则;蓝色箭头表示组合这些规则后提供的happens-before保证。

图表示在线程A释放了锁之后,随后线程B获取同一个锁。在上图中,2 happens-before 5。因此,线程A在释放锁之前所有可见的共享变量,在线程B获取同一个锁之后,将立刻变得对B线程可见。

锁的释放和获取的内存语义

当线程释放锁时JMM会把该线程对应的本地内存中的共享变量刷新到主内存中。以上 面的Monitor Example程序为例,A线程释放锁后,共享数据的状态示意图如图

当线程获取锁时,JMM会把该线程对应的本地内存置为无效。从而使得被监视器保护的 临界区代码必须从主内存中读取共享变量。图是锁获取的状态示意图

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

锁释放与volatile写有 相同的内存语义;锁获取与volatile读有相同的内存语义。

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

线程A释放一个锁,实质上是线程A向接下来将要获取这个锁的某个线程发出了(线程A 对共享变量所做修改的)消息。

线程B获取一个锁,实质上是线程B接收了之前某个线程发出的(在释放这个锁之前对共 享变量所做修改的)消息。

线程A释放锁,随后线程B获取这个锁,这个过程实质上是线程A通过主内存向线程B发 送消息。

锁内存语义的实现

本文将借助ReentrantLock的涌代码,来分析锁内存语义的具体实现机制。

在ReentrantLock中,调用lock()方法获取锁;调用unlock()方法释放锁。

ReentrantLock的实现依赖于Java同步器框架AbstractQueuedSynchronizer (本文简称之为 AQS)。AQS使用一个整型的volatile变量(命名为state)来维护同步状态,马上我们会看到,这 个volatile变量是ReentrantLock内存语义实现的关键。

图是ReentrantLock的类图(仅画出与本文相关的部分)。

ReentrantLock分为公平锁和非公平锁,我们首先分析公平锁。

使用公平锁时,加锁方法lock()调用轨迹如下。

1) ReentrantLock: lock()。

2) FairSync:lock()。

3) AbstractQueuedSynchronizer: acquire(int arg)。

4) ReentrantLock: try Acquire(int acquires)。

在第4步真正开始加锁,下面是该方法的代码。

 

从上面源代码中我们可以看出,加锁方法首先读volatile变量state。

在使用公平锁时解锁方法unlock()调用轨迹如下。

1) ReentrantLock:unlock()。

2) AbstractQueuedSynchronizer:release(int arg)。

3) Sync:tryRelease(int releases)。

在第3步真正开始释放锁,下面是该方法的代码。

从上面的源代码可以看出,在释放锁的最后写volatile变量state。

公平锁在释放锁的最后写volatile变量state, 在获取锁时首先读这个volatile变量。根据 volatile的happens-before规则,释放锁的线程在写volatile变量之前可见的共享变量,在获取锁 的线程读取同一个volatile变量后将立即变得对获取锁的线程可见。

现在我们来分析非公平锁的内存语义的实现。非公平锁的释放和公平锁完全一样,所以 这里仅仅分析非公平锁的获取。使用非公平锁时,加锁方法lock()调用轨迹如下。

1) ReentrantLock: lock()。

2) NonfairSync: lock()。

3) AbstractQueuedSynchronizer: compareAndSetState(int expect,int update)。

在第3步真正开始加锁,下面是该方法的源代码。

该方法以原子操作的方式更新state变量,本文把Java的compareAndSet()方法调用简称为 CAS。JDK文档对该方法的说明如下:如果当前状态值等于预期值,则以原子方式将同步状态 设置为给定的更新值。此操作具有volatile读和写的内存语义。

这里我们分别从编译器和处理器的角度来分析,CAS如何同时具有volatile读和volatile写 的内存语义。

前文我们提到过编译器不会对volatile读与volatile读后面的任意内存操作重排序;编译器不会对volatile写与volatile写前面的任意内存橾作重排序。组合这两个条件,意味着为了同 时实现volatile读和volatile写的内存语义编译器不能对CAS与CAS前面和后面的任意内存操 作重排序。

下面我们来分析在常见的intel X86处理器中,CAS是如何同时具有volatile读和volatile写 的内存语义的。

可以看到这是一个本地方法调用。这个本地方法在openjdk中依次调用的c++代码为:

unsafe.cpp, atomic.cpp和atomic一windows一x86.inline.hpp。这个本地方法的最终实现在openjdk的 如下位置: openjdk-7-fcs-src-b147-

27jun一2011 \openj dk\hotspot\src \os _ cpu\windows一x86\vm\atomic _windows_ x86.inline.hpp (对应于 Windows操作系统X86处理器)。下面是对应于intel X86处理器的源代码的片段。

如上面源代码所示,程序会根据当前处理器的类型来决定是否为cmpxchg指令添加lock前 缀。如果程序是在多处理器上运行,就为cmpxchg指令加上lock前缀(Lock Cmpxchg)。反之,如 果程序是在单处理器上运行,就省略lock前缀(单处理器自身会维护单处理器内的顺序一致性,不需要lock前缀提供的内存屏障效果)。

intel的手册对lock前缀的说明如下。

1)确保对内存的读-改-写操作原子执行。在Pentium&Pentium之前的处理器中,带有lock前 缀的指令在执行期间会锁住总线,使得其他处理器暂时无法通过总线访问内存。很显然,这会 带来昂贵的开销。从Pentium4、Intel Xeon及P6处理器开始,Intel使用缓存锁定(Cache Locking) 来保证指令执行的原子性。缓存锁定将大大降低lock前缀指令的执行开销。

2)禁止该指令,与之前和之后的读和写指令重排序。

3)把写缓冲区中的所有数据刷新到内存中。

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

经过上面的分析,现在我们终于能明白为什么JDK文档说CAS同时具有volatile读和 volatile写的内存语义了。

现在对公平锁和非公平锁的内存语义做个总结。

公平锁和非公平锁释放时,最后都要写一个volatile变量state。

公平锁获取时,首先会去读volatile变量。

非公平锁获取时,首先会用CAS更新volatile变量,这个操作同时具有volatile读和volatile 写的内存语义。

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

1)利用volatile变量的写-读所具有的内存语义。

2)利用CAS所附带的volatile读和volatile写的内存语义。

concurrent包的实现

由于Java的CAS同时具有volatile读和volatile写的内存语义,因此Java线程之间的通信现 在有了下面4种方式。

1)A线程写volatile变量,随后B线程读这个volatile变量。

2)A线程写volatile变量,随后B线程用CAS更新这个volatile变量。

3)A线程用CAS更新一个volatile变量,随后B线程用CAS更新这个volatile变量。

4)A线程用CAS更新一个volatile变量,随后B线程读这个volatile变量。

Java的CAS会使用现代处理器上提供的高效机器级别的原子指令,这些原子指令以原子 方式对内存执行读-改-写操作,这是在多处理器中实现同步的关键(从本质上来说,能够支持 原子性读-改-写指令的计算机,是顺序计算图灵机的异步等价机器,因此任何现代的多处理器 都会去支持某种能对内存执行原子性读-改-写操作的原子指令)。同时,volatile变量的读/写和 CAS可以实现线程之间的通信。把这些特性整合在一起,就形成了整个concurrent包得以实现 的基石。如果我们仔细分析concurrent包的源代码实现,会发现一个通用化的实现模式。

首先,声明共享变量为volatile。

然后,使用CAS的原子条件更新来实现线程之间的同步。

同时,配合以volatile的读/写和CAS所具有的volatile读和写的内存语义来实现线程之间的 通信。

AQS, 非阻塞数据结构和原子变量类(java.util.concurrent.atomic包中的类),这些concurrent 包中的基础类都是使用这种模式来实现的,而concurrent包中的高层类又是依赖于这些基础类 来实现的。从整体来看,concurrent包的实现示意图如

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值