死磕Java——volatile的理解

865 阅读8分钟

一、死磕Java——volatile的理解

1.1.JMM内存模型

理解volatile的相关知识前,先简单的认识一下JMM(Java Memory Model),JMMjdk5引入的一种jvm的一种规范,本身是一种抽象的概念,并不真实存在,它屏蔽了各种硬件和操作系统的访问差异,它的目的是为了解决由于多线程通过共享数据进行通信时,存在的本地内存数据不一致、编译器会对代码进行指令重排等问题。

JMM有关同步的规定:

  • 线程解锁前,必须把共享变量的值刷新回主内存;
  • 线程加锁前,必须读取主内存的最新值到自己的工作内存中;
  • 加锁和解锁使用的是同一把锁;

关于上述规定如下图解:

image-20190502153724126

**说明:**当我们在程序中new一个user对象的时候,这个对象就存在我们的主内存中,当多个线程操作主内存的name变量的时候,会先将user对象中的name属性进行拷贝一份到自己线程的工作内存中,自己修改自己工作内存中的属性后,再将修改后的属性值刷新回主内存,这就会存在一些问题,例如,一个线程写完,还没有写回到主内存,另一个线程先修改后写入到主内存,就会存在数据的丢失或者脏数据。所以,JMM就存在如下规定:

  • 可见性
  • 原子性
  • 有序性

1.2.Volatile关键字

volatilejava虚拟机提供的一种轻量级的同步机制,比较与synchronized。我们知道的事volatile的三大特性:

  • 可见性
  • 不保证原子性
  • 禁止指令重排

1.2.1.Volatile如何保证可见性

可见性就是当多个线程操作主内存的共享数据的时候,当其中一个线程修改了数据写回主内存的时候,回立刻通知其他线程,这就是线程的可见性。先看一个简单的例子:

class MyDataDemo {
    int num = 0;

    public void updateNum() {
        this.num = 60;
    }
}

public class VolatileDemo {

    public static void main(String[] args) {

        MyDataDemo myData = new MyDataDemo();

        new Thread(() -> {
            try {
                TimeUnit.SECONDS.sleep(3);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            myData.updateNum();
            System.out.println("num的值:" + myData.num);
        }, "子线程").start();

        while (myData.num == 0) {}
        System.out.println("程序执行结束");
    }
}

这是一个简单的示例程序,存在一个两个线程,一个子线程修改主内存的共享数据num的值,main线程使用while时时检测自己是否是道主内存的num的值是否被改变,运行程序程序执行结束并不会被打印,同时,程序也不会停止。这就是线程之间的不可见问题,解决方法就是可以添加volatile关键字,修改如下:

volatile int num = 0;

1.2.2.Volatile保证可见性的原理

Java程序生成汇编代码的时候,我们可以看见,当我们对添加了volatile关键字修饰的变量时候,会多出一条Lock前缀的的指令。我们知道的是cpu不直接与主内存进行数据交换,中间存在一个高速缓存区域,通常是一级缓存、二级缓存和三级缓存,而添加了volatile关键字进行操作时候,生成的Lock前缀的汇编指令主要有以下两个作用:

  • 将当前处理器缓存行的数据写回系统内存;
  • 这个写回内存的操作会使得其他CPU里缓存了该内存地址的数据无效;

Idea查看程序的汇编指令在VM启动参数配上-XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly即可;

参考:wiki.openjdk.java.net/display/Hot…

在多处理器下,为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议,每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器对这个数据进行修改操作的时候,会重新从系统内存中把数据读到处理器缓存里。

总结:Volatile通过缓存一致性保证可见性。

1.2.3.Volatile不保证原子性

**原子性:**也可以说是保持数据的完整一致性,也就是说当某一个线程操作每一个业务的时候,不能被其他线程打断,不可以被分割操作,即整体一致性,要么同时成功,要么同时失败。

class MyDataDemo {
    volatile int num = 0;

    public void addNum() {
        num++;
    }
}
public class VolatileDemo {

    public static void main(String[] args) {
        MyDataDemo data = new MyDataDemo();
        for(int i = 1; i <= 20; i++) {
            new Thread(() -> {
                for (int j = 1; j < 1000; j++) {
                    data.addNum();
                }
            }, "当前子线程为线程" + String.valueOf(i)).start();
        }
        // 等待所有线程执行结束
        while (Thread.activeCount() > 2) {
            Thread.yield();
        }
        System.out.println("最终结果:" + data.num);
    }
}

上述代码就是在共享数据前添加了volatile关键字,当时,打印的最终结果几乎很难为20000,这就很充分的说明了volatile并不能保证数据的原子性,这里的num++操作,虽然只有一行代码,但是实际是三步操作,这也是为什么i++在多线程下是非线程安全的。

1.2.4.为什么Volatile不保证原子性

可以参考JMM模型的那一张图,就是主内存中存在一个num = 0,当其中一个线程将其修改为1,然后将其写回主内存的时候,就被挂起了,另外一个线程也将主内存的num = 0修改为1,然后写入后,之前的线程被唤醒,快速的写入主内存,覆盖了已经写入的1,造成了数据丢失操作,两次操作最终结果应该为2,但是为1,这就是为什么会造成数据丢失。再来看i++对应的字节码

image-20190502175617528

简单翻译一下字节码的操作:

  • aload_0:从局部变量表的相应位置装载一个对象引用到操作数栈的栈顶;
  • dup:复制栈顶元素;
  • getfield:先获得原始值;
  • iadd:进行+1操作;
  • putfield:再把累加后的值写回主内存操作;

1.2.5.解决Volatile不保证原子性的问题

使用AtomicInteger来保证原子性,有关AtomicInteger的详细知识,后面在死磕,官方文档截图如下:

image-20190502182016318

修改之前的不保证原子性的代码如下:

class MyDataDemo {
    AtomicInteger atomicInteger = new AtomicInteger();

    public void addAtomicInteger() {
        atomicInteger.getAndIncrement();
    }
}
public class VolatileDemo {

    public static void main(String[] args) {
        MyDataDemo data = new MyDataDemo();
        for(int i = 1; i <= 20; i++) {
            new Thread(() -> {
                for (int j = 1; j <= 1000; j++) {
                    data.addAtomicInteger();
                }
            }, "当前子线程为线程" + String.valueOf(i)).start();
        }
        // 等待所有线程执行结束
        while (Thread.activeCount() > 2) {
            Thread.yield();
        }
        System.out.println("最终结果:" + data.atomicInteger);
    }
}

1.2.6.Volatile的禁止指令重排序

首先,假如写了如下代码

carbon

在程序中,我们觉得是会依次顺序执行,但是在计算机在执行程序的时候,为了提高性能,编译器和和处理器通常会对指令进行指令重排序,可能执行顺序为:2—1—3—4,也可能是:1—3—2—4,一般分为下面三种:

image-20190502184808400

虽然处理器会对指令进行重排,但是同时也会遵守一些规则,例如上述代码不可能重排后将第四句代码第一个执行,所以,单线程下确保程序的最终执行结果和顺序执行结一致,这就是处理器在进行指令重排序时候必须考虑的就是指令之间的数据依赖性

但是,在多线程环境下,由于编译器重排的存在,两个线程使用的变量能否保证一致性无法确定,所以结果就无法一致。在看一个示例:

http://image.luokangyuan.com/2019-05-02-113323.png

在多线程环境下,第一种就是顺序执行init方法,先将num进行赋值操作,在执行update方法,结果:num为6,但是存在编译器重排,那么可能先执行falg = true;再执行num = 1;,最终num为5;

1.2.7.Volatile禁止指令重排序的原理

前面说到了volatile禁止指令重排优化,从而避免在多线程环境下出现结果错乱的现象。这是因为在volatile会在指令之间插入一条内存屏障指令,通过内存屏障指令告诉CPU和编译器不管什么指令,都不进行指令重新排序。也就说说通过插入的内存屏障禁止在内存屏障前后的指令执行指令重新排序优化

什么是内存屏障

内存屏障是一个CPU指令,他的作用有两个:

  • 保证特定操作的执行顺序;
  • 保证某些变量的内存可见性;

将上述代码修改为:

volatile int num = 0;

volatile boolean falg = false;

这样就保证执行init方法的时候一定是先执行num = 1;再执行falg = true;,就避免的了结果出错的现象。

1.3.Volatile的单例模式

public class SingletonDemo {

    private static volatile SingletonDemo instance = null;

    private SingletonDemo(){};

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