volatile是java虚拟机提供的轻量级的同步机制,即削弱版本的synchronized,不保证原子性 有三个特性:保证可见性、不保证原子性、禁止指令重排

一、保证可见性

什么是可见性?可见性已在 JMM 的介绍中讲过,这里不做过多的阐述。
验证可见性。线程”oneThread”对资源Data进行了值的修改,但是main线程并知道这件事情

  1. class Data {
  2. int age = 10;
  3. public void growUp() {
  4. this.age = 18;
  5. }
  6. }
  7. public class VolatileDemo {
  8. public static void main(String[] args) {
  9. Data data = new Data(); // 共享资源
  10. new Thread(() -> {
  11. System.out.println(Thread.currentThread().getName() + "-hello");
  12. try {
  13. TimeUnit.SECONDS.sleep(3);
  14. } catch (InterruptedException e) {
  15. e.printStackTrace();
  16. }
  17. data.growUp();
  18. System.out.println(Thread.currentThread().getName() + "-grow up:" + data.age);
  19. }, "oneThread").start();
  20. // 监听age值的是否被修改,如果一直是原值10,则继续循环
  21. while (data.age == 10) {
  22. }
  23. // age被修改后跳出循环并输出
  24. System.out.println(Thread.currentThread().getName() + ":" + data.age);
  25. }
  26. }
  27. 输出:
  28. oneThread-hello
  29. oneThread-grow up:18

上面那段代码中System.out.println(Thread.currentThread().getName() + “main:” + data.age);并没有被输出,因为共享资源age并没有加关键字volatile,此时的age并不具备可见性,当加了关键字volatile后,见如下代码。

  1. class Data {
  2. volatile int age = 10;
  3. public void growUp() {
  4. this.age = 18;
  5. }
  6. }
  7. ....main方法同上
  8. 输出:
  9. oneThread-hello
  10. oneThread-grow up:18
  11. main:18

由于age加了关键字volatile此时main线程收到了age变更的通知,这就具备了可见性

二、不保证原子性

1、原子性含义:不可分割,完整性,某个线程在做某个业务的时候,中间不可以被加塞或者被分割。需要整体完整,要么同时成功,要么同时失败。
2、volatile不保证原子性

2.1 不保证原子性实例

  1. class Data {
  2. // 加了volatile后即可保证可见性
  3. volatile int age = 10;
  4. // int age = 10;
  5. public void growUp() {
  6. this.age = 18;
  7. }
  8. // 此时age加了volatile 关键字,volatile不保证原子性
  9. public void addPlusPlus() {
  10. age++;
  11. }
  12. }
  13. public class AtomicityTest {
  14. public static void main(String[] args) {
  15. Data data = new Data();
  16. // 20个线程
  17. for (int i = 0; i < 20; i++) {
  18. new Thread(() -> {
  19. for (int j = 0; j < 1000; j++) {
  20. data.addPlusPlus();
  21. }
  22. }, "Thread-" + i).start();
  23. }
  24. // 默认有两个线程,一个是main线程,一个是GC线程
  25. while (Thread.activeCount() > 2) {
  26. // 使线程由执行态变成就绪态,让出cpu时间,在下一个线程执行的时候,此线程有可能被执行,也有可能不被执行
  27. Thread.yield();
  28. }
  29. System.out.println(Thread.currentThread().getName() + "-" + data.age);
  30. }
  31. }
  32. 输出:
  33. main-18838

多次执行发现值并不一定,可见volatile并不保证原子性,由此也可见age++在多线程环境下是非线程安全的。
分析age++为什么非线程安全

age++在底层是三步操作: 1、将主存中age的原始值拿到自己的工作空间, 2、在自己的工作空间中对age进行修改, 3、将修改后的age放回主存 这三步操作在多线程的情况下,当A线程修改age后放回主存并通知其他线程(使用了volatile关键字)的这一瞬间,线程B已经将自己修改的age放回主存了,即B还没来及收到主存发出的通知,B就将自己修改的age放回主存了。这就会造成age原来应该加两次的(AB线程各一次),实际上只加了一次。

为什么volatile不能保证原子性

修改volatile分为四步: 1、读取volatile变量到local; 2、修改变量值; 3、local值写回主存; 4、插入内存屏障,即lock指令,让其他线程可见。 这样就很容易看出来,前三步都是不安全的,取值和写回之间,不能保证没有其他线程修改。原子性需要锁来保证。 这也就是为什么,volatile只用来保证变量可见性,但不保证原子性。

2.2 如何解决原子性

2.2.1 加synchronized解决

  1. public class Data {
  2. // 加了volatile后即可保证可见性
  3. volatile int age = 10;
  4. // int age = 10;
  5. public void growUp() {
  6. this.age = 18;
  7. }
  8. // 此时age加了volatile 关键字,volatile不保证原子性,但可以用synchronized实现原子性
  9. public synchronized void addPlusPlus() {
  10. age++;
  11. }
  12. }

2.2.2 AtomicInteger

  1. AtomicInteger num = new AtomicInteger(0);
  2. public void addAtomic(){
  3. num.getAndIncrement();
  4. }

思考:为什么AtomicInteger可以保证原子性??? 待后续章节解释 https://www.yuque.com/wangchao-volk4/fdw9ek/xmkynl

三、禁止指令重排

计算机在执行程序的时候,为了提高性能,编译器和处理器会对指令做重排,一般分为以下三种。
image.png

  • 单线程环境里面确保程序最终执行结果和代码顺序执行的结果一致。
  • 处理器在进行重排时必须要考虑指令之间的数据依赖性
  • 多线程环境下线程交替执行,由于编译器优化重排的存在,两个线程中使用的变量能否保住一致性是无法确定的,结果无法预测。

    1、何为指令重排

    1. int x = 11;
    2. int y = 12;
    3. x = x+5;
    4. y = x * x;
    java代码时上面的顺序,但是在高并发情况下,顺序可以是 1234、2134、1324
    但是4不可能排到第一个执行,这就是数据依赖性
    image.png

    2、Volatile如何实现指令重排

    首先抛出一个概念:内存屏障(Memory Barrier)又称内存栅栏,是一个CPU指令,它的作用有两个: 一是保证特定的执行顺序,二是保证某些变量的内存可见性

volatile利用上面两个特性,实现了可见性和禁止指令重排
由于编译器和处理器都能执行指令重排优化。如果在指令间插入一条Memory Barrier则会告诉编译器和CPU,不管什么指令都不能和这条Memory Barrier指令重排,也就是说通过插入内存屏障禁止在内存屏障前后的指令执行重排优化。内存屏障另外一个作用就是强制刷出各种CPU的缓存数据,因此任何CPU上的线程都能读取到这些数据的最新版本。
image.png

四、使用Volatile的场景

常见的单例模式可能存在的风险,话不多说,一切见代码

  1. public class SingletonDemo2 {
  2. private static SingletonDemo2 singletonDemo = null;
  3. private SingletonDemo2() {
  4. // 对象常见的时候,构造器执行
  5. System.out.println(Thread.currentThread().getName() + "-我是构造器!");
  6. }
  7. // DCL (double check lock双端检索机制)
  8. // 这种写法有风险(指令重排),在高并发下出现的概率可能是千万分之一
  9. /**
  10. * 至于为什么会出现上述所说的情况,是因为某一个线程执行到第一次检测,读取到的instance不为null时,instance的引用对象可能没有完成初始化。
  11. * instance=new SingletonDemo();可以分为以下三个步骤;
  12. * 1、Memory = allocate() // 分配对象内存空间
  13. * 2、instance(Memory) // 初始化对象
  14. * 3、instance = Memory;设置instance指向刚分配的内存地址,此时instance!=null
  15. * 上面的三步的2、3并没有数据依赖关系,所以当指令重排的时候,步骤会变成1、3、2,此时
  16. * 当A线程走完第一步时,此时B线程走到下面代码的if(null == singletonDemo)就是false,则会直接返回null(因为此时实例并没有初始化完成)
  17. * @return
  18. */
  19. public static SingletonDemo2 getSingletonDemo() {
  20. if (null == singletonDemo) {
  21. synchronized (SingletonDemo2.class) {
  22. if (null == singletonDemo) {
  23. singletonDemo = new SingletonDemo2();
  24. }
  25. }
  26. }
  27. return singletonDemo;
  28. }
  29. public static void main(String[] args) {
  30. // 单线程环境下,只会创建一个对象,只会执行一次构造器
  31. // System.out.println(SingletonDemo.getSingletonDemo() == SingletonDemo.getSingletonDemo());
  32. // 多线程情况下,一切皆有肯
  33. for (int i = 0; i < 20; i++) {
  34. new Thread(() -> {
  35. SingletonDemo2.getSingletonDemo();
  36. }).start();
  37. }
  38. }
  39. }

如上所述会出现指令重排的风险,所以需要加上volatile

  1. private static volatile SingletonDemo3 singletonDemo = null;

至此,volatile避免指令重排,synchronized保证了原子性,完美。

五、总结

1、工作内存与主内存同步延迟现象导致的可见性问题,可以使用synchronized或volatile关键字解决,它们都可以使一个线程修改后的变量立即对其他线程可见。
2、对于指令重排导致的可见性问题和有序性问题,可以利用volatile关键字解决,因为volatile的另外一个作用就是禁止指令重排