原子性和可见性

原子性和可见性

原子性一般指原子操作,原子操作不能被线程调度机制中断,一旦操作开始,那么它一定可以在可能发生的上下文切换之前完成。Java语言规范规定了对基本对象(long和double除外)的读写操作是原子的。

不能将原子性和同步划等号!更不能使用原子性来代替同步,当你想使用原子性代替同步写出无锁代码时,思考 Brain Goetz 的建议:

If you can write a high-performance JVM for a modern microprocessor, then you are qualified to think about whether you can avoid synchronizing.

考虑如下几个操作:

1int x = 1;              // s1 原子操作
2boolean flag = flase;   // s2 原子操作
3int y = x;              // s3
4x++;                    // s4
5double d = 1.9d;        // s5

只有前2个操作是原子操作,后面的操作都不是原子操作。

对于s3来说,可以拆分为读取x的值和将y赋值两个操作,虽然这两个操作都是原子的,但是合起来就不是原子操作了;s4就更复杂了;对于double和long类型的变量, JMM(Java Memory Model)规定了对其的写操作分为2步,每步写入32位1,因此也不是原子的。

原子性的误用 #

查看一个误用原子性的例子:

 1public class AtomicTest implements Runnable {
 2    private int i = 0;
 3
 4    public int getValue() {
 5        // atomic operation
 6        return i;
 7    }
 8
 9    private synchronized void increment() {
10        i++;
11        i++;
12      	// equals to
13      	// i += 2;
14    }
15
16    @Override
17    public void run() {
18        while (true) increment();
19    }
20
21    public static void main(String[] args) throws InterruptedException {
22        AtomicTest at = new AtomicTest();
23        // 线程1
24        new Thread(at).start();
25        TimeUnit.MILLISECONDS.sleep(1);
26        while (true) {
27          	// the value can still be odd
28            int value = at.getValue();
29            if (value % 2 != 0) {
30                System.out.println(value);
31                System.exit(0);
32            }
33        }
34    }
35}
36/* output: (sample)
37145881
38*///:~

上例过分高估了原子性的能力,当另一个线程(mian线程)调用getValue()去访问共享变量时,尽管getValue()方法只有一个return语句,是原子性的,但还是获得了一个不希望的结果——奇数,为什么?虽然increment()方法是同步的,但是getValue()方法不需要锁即可访问共享域,此时的i可能在一个不稳定的中间状态

Java内存模型有如下约定2

  1. Java的域都储存在主存(即物理内存)中

  2. Java的工作线程有独立的内存(CPU缓存)

  3. 同步保证可见性

  4. 原子操作不保证可见性

依据上面的论断,尝试分析这个不稳定状态:increment()方法使用了同步,即increment()每次自增后都将变量i的结果写入主存;由于getValue()是无锁访问i,它可能获取的可能是increment()方法第一次自增的结果。

那么解决办法有:

  1. 同步getValue()方法;
  2. 将2步自增换成一步操作(并不能保证每次getValue()获取的都是期望值,只是不再出现奇数罢了);
  3. 使用原子类

Java SE 5 引入了java.util.concurrent.atomic包,里面提供了原子性变量类,这些类提供了一些原子性操作,实际应用的不多,但合理应用可以提升应用性能。

不要过分依赖原子类,就像不要过分依赖原子性一样。

谨慎使用原子类 #

可以使用AtomicInteger类对AtomicTest类进行优化,使其得到预期的结果:

 1public class AtomicClassTest implements Runnable {
 2    private AtomicInteger i = new AtomicInteger(0);
 3    public int getValue() {
 4        // atomic operation
 5        return i.get();
 6    }
 7
 8    /**
 9     * 无锁的原因不是因为原子性,而是因为有且只有一个原子操作
10     * 若此处使用
11     * <pre>
12     *     i.incrementAndGet();
13     *     i.incrementAndGet();
14     * </pre>
15     * 那么依旧和{@link AtomicTest}一样失败
16     */
17    private void increment() {
18        i.addAndGet(2);
19    }
20
21    @Override
22    public void run() {
23        while (true) increment();
24    }
25
26    public static void main(String[] args) throws InterruptedException {
27        AtomicClassTest act = new AtomicClassTest();
28        ExecutorService executor = Executors.newSingleThreadExecutor();
29        executor.execute(act);
30        ScheduledExecutorService s = Executors.newSingleThreadScheduledExecutor();
31        s.schedule(() -> {
32          	// 此方法不会主动退出
33            System.out.println("Aborting...");
34            executor.shutdown();
35            s.shutdown();
36            System.exit(0);
37        }, 5, TimeUnit.SECONDS);
38        while (true) {
39            int value = act.getValue();
40            // the value can still be odd
41            if (value % 2 != 0) {
42                System.out.println(value);
43                System.exit(0);
44            }
45        }
46    }
47}

上面的示例中,方法不用同步,获取到的i的值也不会是奇数。

思考这个问题,main线程每次读取的都是最新修改的i么?

不一定

因为原子性并不能保证可见性,main线程也并不能保证每次获取的都是最新的i值

可见性(volatile) #

在讨论原子性的时候,提到了原子操作并不能保证可见性。什么是可见性?可见性指的是一个变量被被线程修改后,另一个线程能够马上知道这一修改。

Java SE 5 提供了volatile关键字保证可见性,对volatail域的修改会马上写入到主存中,其他线程会的本地缓存会失效而从主存中去读取。

听起来不错,volatile似乎可以解决资源共享的问题,真的是这样么?

遗憾的是,volatile并不能保证原子性

 1public class VolatileIsNotAtomic {
 2
 3   // 将变量设置为volatile并不能保证并发安全
 4    private volatile int sum;
 5
 6    void increase() {
 7        sum++;
 8    }
 9
10    void multiThread2() throws InterruptedException {
11        for (int i = 0; i < 10; i++) {
12            Thread thread = new Thread(() -> {
13                for (int j = 0; j < 1000; j++) {
14                    increase();
15                }
16            });
17            thread.start();
18        }
19        Thread.sleep(3000);
20        System.out.println(sum);
21    }
22
23    public static void main(String[] args) throws InterruptedException {
24        VolatileIsNotAtomic va = new VolatileIsNotAtomic();
25        va.multiThread2();
26    }
27}
28/* output:(sample)
298806
30*///:~

上例中将域设置为volatile并不能解决多线程环境下的资源共享问题,原因在于,volatile只保证了可见性,没有保证共享资源的有序访问

volatile关键字的使用非常有限,当想使用volatile关键字的时候,需要仔细考量,因为其可能有潜在的多线程风险。

volatiile关键字最著名的应用是在双重检查( double-check-lock ) 单例中:

 1public class DoubleCheckSingleton {
 2    private static volatile DoubleCheckSingleton instance;
 3
 4    private DoubleCheckSingleton() {
 5    }
 6
 7    public static DoubleCheckSingleton getInstance() {
 8        if (instance == null) {
 9            synchronized (DoubleCheckSingleton.class) {
10                // the double check lock
11                if (instance == null) {
12                    instance = new DoubleCheckSingleton();
13                }
14            }
15        }
16        return instance;
17    }
18}

更详细的关于volatile关键字的介绍: Java内存模型与volatile关键字



  1. 写64位数据的需要2次独立的写入过程,每次写32位 ↩︎

  2. 不一定正确,还需要查阅资料进行确认 ↩︎