volatile 二三事

volatile 的影响:可见性、有序性、原子性

可见性

可见性即对共享变量的修改立即对所有线程可见。
Java 内存模型规定每个线程都有自己的工作内存用于存储变量的副本,而且不能直接读取主内存或其他线程的工作内存。因此,一个线程对变量修改后其他线程若不刷新副本,将看不到变量被修改。以下面的代码为例,子线程很可能将永远不会停止

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
public class VisibilityTest implements Runnable {

//    volatile
    private boolean stop = false;

    public static void main(String[] args) throws InterruptedException {
        VisibilityTest test = new VisibilityTest();
        new Thread(test).start();
        Thread.sleep(200);
        test.stop = true;
        System.out.println(Thread.currentThread().getName() + " stop at " + System.currentTimeMillis());
    }

    @Override
    public void run() {
        while (!stop) {
            doSomething();
        }
        System.out.println(Thread.currentThread().getName() + " stop at " + System.currentTimeMillis());
    }

    public void doSomething() {

    }
}

有序性

Java 编译器在生成字节码时可能会改变指令顺序以提高性能,指令重排的过程仅保证单线程环境下的执行结果一致,而在多线程下可能就表现出乱序。因为存在有序性问题的代码往往都有可见性问题,并没有很好的示例代码来单独演示有序性…一个常见的例子是懒加载的单例模式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class LazySingleton {

//    volatile
    private static LazySingleton instance;

    private LazySingleton() {
    }

    public static LazySingleton getInstance() {
        if (instance == null) {
            synchronized (LazySingleton.class) {
                if (instance == null)
                    instance = new LazySingleton();
            }
        }
        return instance;
    }
}

对象的创建大致分为三个步骤:1.申请内存、2.初始化对象、3.将变量指向内存,经编译器指令重排后可能变成1.申请内存、2.将变量指向内存、3.初始化对象。当一个线程将内存分配给对象但还未完成初始化时,其他线程会认为这个对象已经完全创建好了,但访问时很有可能会产生异常。

double、long 的原子性

Java 没有强制要虚拟机保证 64 位数据类型的读写操作的原子性,也就是说虚拟机可以将 long 变量分两次写入数值。以下代码来自stackoverflow

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
31
32
public class WriteLongTest implements Runnable {

    private static long value = 0;
    private final long target;

    public WriteLongTest(long target) {
        this.target = target;
    }

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(new WriteLongTest(-1));
        Thread t2 = new Thread(new WriteLongTest(0));

        t1.start();
        t2.start();
        long val;
        while ((val = value) == -1 || val == 0) {
        }
        System.out.println(Long.toBinaryString(val));
        System.out.println(val);

        t1.interrupt();
        t2.interrupt();
    }

    @Override
    public void run() {
        while (!Thread.interrupted()) {
            value = target;
        }
    }
}

在 32 位的 OpenJDK8 得到以下输出

1
2
1111111111111111111111111111111100000000000000000000000000000000
-4294967296

volatile 关键字强制虚拟机保证 64 位数据类型的原子性。不过商用虚拟机基本都已经实现原子操作,不需要额外的声明

总结

《Java 并发编程实战》给出了 volatile 变量使用规则:

  1. 对变量的写入操作不依赖变量的当前值,或能确保只有单个线程更新变量的值
  2. 该变量不会与其他状态变量一起纳入不变性条件中
  3. 在访问变量时不需要加锁

一点解释:

  1. 如果对变量的写入依赖当前值,那么写入操作实际有三步:“读取-修改-写入”,而 volatile 不保证整个操作的原子性
  2. 如果为了使类的状态保持一致,在修改一个 volatile 变量还必须修改其他变量,那么这个操作依然有多个步骤
  3. 锁能保证可见性,不需要额外的 volatile 修饰

Ps:第一个示例中的 doSomething() 替换成 System.out.printf() 能正常终止进程,原因见这里 loop-doesnt-see-changed-value-without-a-print-statement


2018-06-05 更新
示例一的代码加 -Xint 参数强制虚拟机以解释方式运行也能正常终止,所以问题并不在可见性上…Jit 部分还需要我再学习一个

参考:
《深入理解 Java 虚拟机》
double-checked-locking-with-delay-initialization