volatile

volatile

第一节 概述

1.1 背景

很多情况为了读写一两个实例域就用同步,这样会显得开销太大了。volatile关键字就是为实例域的同步访问提供了一种免锁机制,用volatile修饰域,就相当于告诉编译器和虚拟机此域极有可能会被另一个线程并发更新。

但使用volatile需要我们了解其技术细节、volatile与锁的区别等待,才能正确的使用它。

1.2 优点

相比synchronized的优点:

  • volatile的使用和执行成本更低,因为没有线程上下文切换和调度的开销

第二节 Java内存模型

Java内存模型机制保证了volatile修饰的字段对于所有线程来说看到的结果是一致的。

Java内存模型定义了程序中变量的访问规则,所有变量都存在主存中,每个线程有自己的工作内存,线程对变量的操作都在工作内存中进行,将结果写入主存,工作内存间不能互相访问。

Java内存模型只保证了基本读取和赋值是原子操作,实际编程中需要synchronized和Lock来实现原子性,通过加锁来保证同一时刻只有一个线程执行代码块。

普通变量被修改时不一定会即使更新到主存中,Java提供了volatile关键字来保证可见性,当一个变量被volatile修饰时,其值被修改会立即更新到主存中,当其他线程需要读取时,就读内存的新值。synchronized和Lock也可保证可见性,释放锁之前会把最新值写入主存。

Java内存模型允许对指令重排序,volatile关键字可以保证”一定”的有序性,synchronized和Lock也可保证有序性,单线程就一定”有序”结果正确。Java内存模型遵照先行发生原则(happens-before),规定了一些执行顺序的原则来保证一些有序,可查阅JVM相关内容了解。


第三节 volatile特性

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

3.1 实例

1
2
3
4
5
6
7
8
9
10
11
12
13
class VolatileFeaturesExample {       
volatile long vl = 0L; // 使用volatile声明64位的long型变量
public void set(long l) {
vl = l; // 单个volatile变量的写
}

public void getAndIncrement () {
vl++; // 复合(多个)volatile变量的读/写
}
public long get() {
return vl; // 单个volatile变量的读
}
}

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class VolatileFeaturesExample {       
long vl = 0L; // 64位的long型普通变量
public synchronized void set(long l) { // 对单个的普通变量的写用同一个锁同步
vl = l;
}

public void getAndIncrement () { // 普通方法调用
long temp = get(); // 调用已同步的读方法
temp += 1L; // 普通写操作
set(temp); // 调用已同步的写方法
}

public synchronized long get() { // 对单个的普通变量的读用同一个锁同步
return vl;
}
}

一个volatile变量的单个读/写操作,与一个普通变量的读/写操作都是使用同一个锁来同步,它们之间的执行效果相同。

锁的happens-before规则保证释放锁和获取锁的两个线程之间的内存可见性,这意味着对一个volatile变量的读,总是能看到(任意线程)对这个volatile变量最后的写入。锁的语义决定了临界区代码的执行具有原子性。这意味着,即使是64位的long型和double型变量,只要它是volatile变量,对该变量的读/写就具有原子性。如果是多个volatile操作或类似于volatile++这种复合操作,这些操作整体上不具有原子性。

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

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

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

上面讲的是volatile变量自身的特性,对程序员来说,volatile对线程的内存可见性的影响比volatile自身的特性更为重要,也更需要我们去关注。从JSR-133开始(即从JDK5开始),volatile变量的写-读可以实现线程之间的通信。

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class VolatileExample {      // volatile示例
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()方法。根据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关系

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


3.3 volatile写-读的内存语义

(1)写

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

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

共享变量的状态示意图

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

(2)读

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发送消息。

3.4 volatile内存语义的实现

Java内存模型提到过重排序分为编译器重排序处理器重排序。为了实现volatile内存语义,JMM会分别限制这两种类型的重排序类型。

下表是JMM针对编译器制定的volatile重排序规则表:

volatile重排序规则表

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

从表我们可以看出:

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

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

基于保守策略的JMM内存屏障插入策略:

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

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

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

指令序列示意图

图中的StoreStore屏障可以保证在volatile写之前,其前面的所有普通写操作已经对任意处理器可见了。这是因为StoreStore屏障将保障上面所有的普通写在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写-读的内存语义,编译器可以根据具体情况省略不必要的屏障。下面通过具体的示例代码进行说明。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class VolatileBarrierExample {       
int a;
volatile int v1 = 1;
volatile int v2 = 2;
void readAndWrite() {
int i = v1;   // 第一个volatile读
int j = v2;   // 第二个volatile读
a = i + j; // 普通写
v1 = i + 1;   // 第一个volatile写
v2 = j * 2;   // 第二个 volatile写
}

…       // 其他方法
}

针对readAndWrite()方法,编译器在生成字节码时可以做如下的优化:

指令序列示意图

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

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

指令序列示意图

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


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

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

线程执行时序图

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

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

由于volatile仅仅保证对单个volatile变量的读/写具有原子性,而锁的互斥执行的特性可以确保对整个临界区代码的执行具有原子性。在功能上,锁比volatile更强大;在可伸缩性和执行性能上,volatile更有优势。如果读者想在程序中用volatile代替锁,请一定谨慎,具体详情请参阅Brian Goetz的文章《Java理论与实践:正确使用Volatile变量》。


第四节 多线程范例

4.1 volatile保证可见性吗?

看下面多线程示例。

1
2
3
4
5
6
7
8
//线程1
boolean stop = false;
while(!stop){
doSomething();
}

//线程2
stop = true;

一般情况下线程1可以正常跳出循环,但当某种情况下线程2未将stop的更新写入主存转去做其他工作,就会导致线程1无限循环。

但若用volatile修饰stop变量,会强制使最新值立即写入主存,当线程2进行修改操作时,会使线程1变量stop的缓存无效,所以线程1再次读取stop值时要去主存中读取,所以就保证了多线程运行中线程1运行正确。

4.2 volatile保证原子性吗?

前面我们提到了volatile保证了可见性和”一定”的有序性,所以volatile会保证原子性吗?

根据前面的内容可以了解,volatile相当于在线程间进行通信,范围就是其修饰的变量。所以volatile修饰的变量,各个线程在每次使用变量的时候,都会读取变量修改后的最新值。而且volatile禁止指令重排序,这些特性导致volatile很容易被误用来进行原子性操作。

通过设计一个简单测试代码来观察volatile是否保证原子性,原子操作相关内容可以参考:原子操作的实现原理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
public class Test {
volatile int inc = 0;

public void increase() {//通过非原子性的读改写操作来测试volatile是否可以保证变量的原子性
inc++;
}

class IncThread extends Thread{
@Override
public void run() {
System.out.println(Thread.currentThread().toString() + " 读取" + inc);
for(int j=0;j<1000;j++) {
increase();
}
System.out.println(Thread.currentThread().toString() + " 最终" + inc);
};
}

public static void main(String[] args) {
final Test test = new Test();
for(int i=0;i<10;i++){
IncThread thread = test.new IncThread();
thread.start();
}
while(Thread.activeCount()>1) //保证前面的线程都执行完
Thread.yield();
System.out.println("执行完");
System.out.println(test.inc);
}
}

运行结果总是小于10000,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
Thread[Thread-5,5,main] 读取0
Thread[Thread-4,5,main] 读取0
Thread[Thread-5,5,main] 最终1000
Thread[Thread-4,5,main] 最终2000
Thread[Thread-9,5,main] 读取0
Thread[Thread-1,5,main] 读取0
Thread[Thread-9,5,main] 最终4000
Thread[Thread-1,5,main] 最终3559
Thread[Thread-3,5,main] 读取0
Thread[Thread-3,5,main] 最终5000
Thread[Thread-0,5,main] 读取0
Thread[Thread-8,5,main] 读取0
Thread[Thread-6,5,main] 读取0
Thread[Thread-8,5,main] 最终7124
Thread[Thread-0,5,main] 最终6000
Thread[Thread-2,5,main] 读取0
Thread[Thread-7,5,main] 读取0
Thread[Thread-2,5,main] 最终8933
Thread[Thread-7,5,main] 最终9933
Thread[Thread-6,5,main] 最终7933

因为自增操作不是原子操作,需要从内存中读值到缓存区,然后加1,再写入内存。假设某个时刻inc为10,线程1读取出10,但此时线程1被堵塞或切换了,线程2读取,仍取出10,执行后写入主存11。线程1恢复执行,加1为11,写入主存。

改写上述代码,加入synchronized或Lock或AtomicInteger都可以得到正确结果。

4.3 volatile保证有序性吗?

volatile能禁止指令重排序,所以一定程度上可以保证有序性,但其禁止指令重排序指的只是对于volatile变量其前面的指令都会先执行完,其后操作等变量执行完毕后才可执行,但对于两部分的指令没有什么限制。

4.4 volatile如何保证可见性?

首先为了提高处理速度,处理器并不和内存直接进行通信,而是将内存中数据读取到内部缓存中再进行操作。

但操作结果并不一定在什么时刻写回到内存中,如果对volatile声明的变量进行了写操作,JVM会向处理器发送一条Lock前缀的指令,将此变量所在的缓存行数据写回内存。

此时,其他处理器的缓存还是旧值,所以需要缓存一致性协议,每个处理器通过嗅探在总线上传播的数据来检查自己的缓存值是否过期,当检测到缓存行对应的内存地址被修改了,就会将当前处理器的缓存行设置为无效状态,当处理器对这个数据进行修改操作时,会重新从系统内存读到缓存中来。

  1. Lock前缀指令使处理器缓存写回内存,锁定缓存,通过缓存一致性机制确保修改是原子操作,阻止多个处理器同时修改缓存。
  2. 处理器的缓存写回内存会导致其他处理器的缓存无效

第五节 volatile使用优化

在Java 7之前的版本中可以通过追加字节使对象扩充到64字节来提高并发编程的效率,因为大部分处理器的高速缓存行是64字节宽,不支持部分填充缓存行,就意味着当队列的头节点和尾节点都不足64字节时,处理器会将它们读到同一个高速缓存行中,多处理器时每个处理器都会缓存同样的头节点和尾节点,当一个处理器视图修改头节点时会锁定整个缓存行,导致其他处理器也不能访问自己的高速缓存中的尾节点。对于队列来说,入对和出队操作需要不停的修改头节点和尾节点,所以这种情况会严重影响队列的入队和出队效率。

Doug Lea选择填满高速缓冲区的缓存行,避免两节点存储到同一个缓存行,使两个节点各自的修改不会相互锁定。

1
2
3
4
5
6
7
8
9
10
11
12
13
//Doug Lea早版本的LinkedTransferQueue部分代码
private transient final PaddedAtomicReference<QNode> head;//头节点
private transient final PaddedAtomicReference<QNode> tail;//尾节点
static final class PaddedAtomicReference <T> extends AtomicReference <T>{
//使用15个4字节的对象引用追加到64字节
Object p0,p1,...p9,pa,pb,pc,pd,pe;
...
}

public class AtomicReference <T> implements java.io.Serializable {
private volatile V value;
...
}

非64字节宽或比较新的版本无需再做此优化。


部分参考以下内容:

《Java虚拟机规范》

《Java并发编程的艺术》

http://www.cnblogs.com/paddix/p/5405678.html

http://www.cnblogs.com/paddix/p/5428507.html

若内容涉及侵权请及时告知,我会尽快修改和删除相关内容