谈谈对volatile关键字理解
前言
就volatile关键字而言,无论工作还是面试都是避免老生常谈的问题,但是研究volatile之前,首先需要了解JMM。文章会从JMM和volatile的之间的联系进行阐述。
1:JMM模型
1.1 JMM是什么
?? Java内存模型(即Java Memory Model,简称JMM),本身是一种抽象的概念,实际上并不存在,它描述的是一组规则或规范,通过这组规范定义了程序中各个变量(包括实例字段,静态字段和构成数组对象的元素)的访问方式
1.2JMM关于同步的规定
??所有的共享变量都存储于主内存,这里所说的变量指的是实例变量和类变量,不包含局部变量,因为局部变量是线程私有的,因此不存在竞争问题。每一个线程还存在自己的工作内存,线程的工作内存,保留了被线程使用的变量的工作副本。 ??由于JVM运行程序的实体是线程,而每个线程创建时JVM都会为其创建一个工作内存(有些地方称为栈空间),工作内存是每个线程的私有数据区域,而Java内存模型中规定所有变量都存储在主内存,主内存是共享内存区域,所有线程都可以访问,但线程对变量的操作(读取赋值等)必须在工作内存中进行,首先要将变量从主内存拷贝到自己的工作内存空间,然后对变量进行操作,操作完成后再将变量写会主内存,不能直接操作主内存中的变量,各个线程中的工作内存中存储着主内存中的变量副本拷贝,因此不同的线程间无法访问对方的工作内存,线程间的通信(传值)必须通过主内存来完成,其简要访问过程如下图: 上面提到了两个概念:主内存 和 工作内存 ?● 主内存:就是计算机的内存,也就是经常提到的8G内存,16G内存 ?●工作内存:但我们实例化 person对象,那么 age = 18 也是存储在主内存中 假如主内存中实例化的实例化一个person对象,设置 age 是18,而A线程在自己的工作内存中把age修改为25,其他就是通过JMM来感知到线程A修改了age的值。
1.3 Java内存模型保证缓存一致性
正上图所示,经过简化CPU与内存操作的简易图,实际上没有这么简单,为了理解方便,我们将三级缓存统一为CPU缓存(有些CPU只有二级缓存,有些CPU有三级缓存)。就目前计算机而言,一般拥有多个CPU并且每个CPU可能存在多个核心,多核是指在一枚处理器(CPU)中集成两个或多个完整的计算引擎(内核),这样就可以支持多任务并行执行,从多线程的调度来说,每个线程都会映射到各个CPU核心中并行运行。在CPU内部有一组CPU寄存器,寄存器是cpu直接访问和处理的数据,是一个临时放数据的空间。一般CPU都会从内存取数据到寄存器,然后进行处理,但由于内存的处理速度远远低于CPU,导致CPU在处理指令时往往花费很多时间在等待内存做准备工作,于是在寄存器和主内存间添加了CPU缓存,CPU缓存比较小,但访问速度比主内存快得多,如果CPU总是操作主内存中的同一址地的数据,很容易影响CPU执行速度,此时CPU缓存就可以把从内存提取的数据暂时保存起来,如果寄存器要取内存中同一位置的数据,直接从缓存中提取,无需直接从主内存取。需要注意的是,寄存器并不每次数据都可以从缓存中取得数据,万一不是同一个内存地址中的数据,那寄存器还必须直接绕过缓存从内存中取数据。所以并不每次都得到缓存中取数据,这种现象有个专业的名称叫做缓存的命中率,从缓存中取就命中,不从缓存中取从内存中取,就没命中,可见缓存命中率的高低也会影响CPU执行性能,这就是CPU、缓存以及主内存间的简要交互过程,总而言之当一个CPU需要访问主存时,会先读取一部分主存数据到CPU缓存(当然如果CPU缓存中存在需要的数据就会直接从缓存获取),进而在读取CPU缓存到寄存器,当CPU需要写数据到主存时,同样会先刷新寄存器中的数据到CPU缓存,然后再把数据刷新到主内存中。
2:Volatile的特性
volatile是Java虚拟机提供的轻量级的同步机制,在日常的单线程环境是应用不到的,经常用于多线程环境,有三大特性
?● 保证可见性 ?● 不保证原子性 ?● 禁止指令重排
2.1 保证可见性
2.1.1可见性定义(必须是原子类操作)
指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。
2.1.2 代码示例
public class VisibilityDemo {
public static void main(String args []) {
TestData testData = new TestData();
new Thread(() -> {
System.out.println("线程" + Thread.currentThread().getName() + "\t 执行");
try {
TimeUnit.SECONDS.sleep(3);
} catch (InterruptedException e) {
e.printStackTrace();
}
testData.addNum();
System.out.println("线程:" + Thread.currentThread().getName() + "修改的num的值:" + testData.num);
}, "AAA").start();
new Thread(()->{
while (testData.num == 0){}
System.out.println("线程:" + Thread.currentThread().getName() + "执行完毕!");
},"BBB").start();
}
}
class TestData{
int num = 0;
public void addNum(){
this.num = 88;
}
}
2.1.2 volatile通过JMM模型保证可见性
在多线程环境下,一个线程对共享变量的操作对其他线程是不可见的。volatile保证不同线程对共享变量操作的可见性,也就是说一个线程修改了volatile修饰的变量,当修改写回主内存时(遵循JMM模型),另外一个线程立即看到最新的值。 当然,synchronize和Lock都可以保证可见性。这里不对synchronize和Lock进行阐述。
2.1.3 volatile要谨慎使用
?●缓存一致性 通过1.3 Java内存模型如何保证内存一致性可知,CPU为了更高效的读取数据,他们一般不会存内存中读取数据,而是先从缓存中读取数据,为什么这里主线程中某个值被更改后,其它线程能马上知晓呢? 其实这里是用到了总线嗅探技术,在说嗅探技术之前,首先谈谈缓存一致性的问题,就是当多个处理器运算任务都涉及到同一块主内存区域的时候,将可能导致各自的缓存数据不一。为了解决缓存一致性的问题,需要各个处理器访问缓存时都遵循一些协议,在读写时要根据协议进行操作,这类协议主要有MSI、MESI等等。 ? ● MESI 当CPU写数据时,如果发现操作的变量是共享变量,即在其它CPU中也存在该变量的副本,会发出信号通知其它CPU将该内存变量的缓存行设置为无效,因此当其它CPU读取这个变量的时,发现自己缓存该变量的缓存行是无效的,那么它就会从内存中重新读取。 ? ● 总线风暴 总线嗅探技术有哪些缺点? 由于Volatile的MESI缓存一致性协议,需要不断的从主内存嗅探和CAS循环,无效的交互会导致总线带宽达到峰值。因此volatile关键字要谨慎使用。
2.2 不保证原子性
2.2.1 原子性定义
即一个操作或者多个操作 要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。即:在整个操作过程中不会被线程调度器中断的操作,都可认为是原子性。例如 a=1是原子性操作,但是i++ 之类操作就不是原子性操作** (下文中将从JVM字节码的角度解释i++为什么不是原子性)。Java中的原子性操作包括:** ● 除long和double之外的基本类型的赋值操作,且赋值必须是值赋给变量,变量之间的相互赋值不是原子性操作 ● 所有引用reference的赋值操作 ● java.concurrent.Atomic.* 包中所有类的一切操作
2.2.2 代码示例
public class NotAtomicDemo {
public static void main(String args []) {
DataTest dataTest = new DataTest();
for (int i = 0; i < 50; i++) {
new Thread(() -> {
for (int j = 0; j < 10000; j++) {
dataTest.addPlusPlus();
}
}, String.valueOf(i)).start();
}
while(Thread.activeCount() > 2) {
Thread.yield();
}
System.out.println(Thread.currentThread().getName() + "\t 最终执行结果: " + dataTest.number);
}
}
class DataTest {
volatile int number = 0;
public void addPlusPlus() {
number ++;
}
}
第一次运行结果: 第二次运行结果:
第三次运行结果: 以上三次结果均出现了丢数据的情况。
2.2.3 JVM字节码层面分析无法保证原子性
将以下代码中的number ++操作,转换为JVM字节码为例:
public class testOne {
public class T1 {
volatile int number = 0;
public void add() {
number++;
}
}
}
转换为JVM字节码后,
public void add1();
Code:
0: aload_0
1: dup
2: getfield #2
5: iconst_1
6: iadd
7: putfield #2
10: return
getfield指令:从主内存中获取值原始n的值,放到自己的工作内存中 iconst_1指令:执行加1操作 putfield指令:把累加后的n写会主内存 假设我们没有加任何的限制,在多线程环境下,代码中50个线程和可能会同时通过getfield指令,拿到主存中的 number的值,各自在自己的工作内存中进行加1操作,但他们并发进行 iadd 命令的时候,因为只能一个进行写,所以其它操作会被挂起,假设1线程,先进行了写操作,在写完后,volatile可以保证可见性,应该需要告诉其它两个线程,主内存的值已经被修改了,但是线程1执行写操作的同时,其它线程也会执行 iadd指令,也进行写入操作,这就造成了其他线程没有接受到主内存number的改变,从而覆盖了原来的值,出现写丢失,这样也就让最终的结果少于500000。
2.2.4 解决
● 方法一: 在addPlusPlus()方法上加上synchronized ,为什么加上synchronized就可以保证,这里就不做解释。但这种解决方式并不是很好,因为synchronized是重量级同步机制,解决这个问题用synchronized是不优雅的。 代码
class DataTest {
int number = 0;
public synchronized void addPlusPlus() {
number ++;
}
}
● **方法二 :**还可以使用JUC下面的原子包装类 代码
class DataTest {
volatile int number = 0;
public void addPlusPlus() {
number ++;
}
AtomicInteger atomicInteger = new AtomicInteger();
public void addAtomic() {
atomicInteger.getAndIncrement();
}
}
运行结果如下图所示:
2.3 禁止指令重排
2.3.1 指令重排相关说明
指令重排: 简单的说指令重排是指在程序执行过程中, 为了性能考虑, 编译器和CPU可能会对指令重新排序。其目的就是为了提高多核CPU的效率,一般有一下三种:
? ● 编译器优化的重排: 编译器在不改变单线程程序语义的前提下,可以重新排列指令的执行顺序; ? ● 指令并行的重排: 在多核CPU时代,多条指令会同时执行,如果这些指令不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序; ? ● 内存系统的重排: 由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行的。 源代码形成指令的过程:
2.3.2 指令重排举例说明
● 例一
public void testDataOne() {
int x = 11;
int y = 12;
x = x + 5;
y = x * x;
}
在单线程情况下,语句执行的顺序是1 2 3 4, 在多线程环境下,会出现指令重排,语句执行的顺序可能是 2 1 3 4,或者是1 3 2 4,可能还是1 2 3 4,但是不会出现 4 3 2 1的情况,因为在没有数据依赖性的情况下才可以进行指令重排(2.3.1中提到),即:语句4依赖于语句1中x的声明和语句2中的y的声明。
● 例二
public void testDataTwo() {
int a = 0;
int b = 0;
int x = 0;
int y = 0;
}
按正常没有进行重排的指令执行线程A和线程B,
此时x和y的结果都是 0;如果进行了一下指令的重排
线程1 | 线程 2 |
---|
b = 1; | a = 2; | x = a; | y = b; |
那么这时就会出现x = 2; y = 1的结果,为了避免这种结果,可以将以上的变量加volatile修饰。
2.3.2 volatile可以禁止指令重排的原因
Volatile实现禁止指令重排优化,从而避免了多线程环境下程序出现乱序执行的现象, 首先了解一个概念,内存屏障(Memory Barrier)又称内存栅栏,是一个CPU指令,它的作用有两个: ? ● 保证特定操作的顺序 ? ● 保证某些变量的内存可见性(利用该特性实现volatile的内存可见性) ??由于编译器和处理器都能执行指令重排的优化,如果在指令间插入一条Memory Barrier则会告诉编译器和CPU,不管什么指令都不能和这条Memory Barrier指令重排序,也就是说通过插入内存屏障禁止在内存屏障前后的指令执行重排序优化。 内存屏障另外一个作用是刷新出各种CPU的缓存数,因此任何CPU上的线程都能读取到这些数据的最新版本。 volatile写操作: 会在写前面和后面分别插入store屏障指令,具体如图所示:
**volatile读操作:**会在读操作后面插入Load屏障指令,具体如图所示: 也就是说:volatile可以禁止指令重排是过内存屏障实现的。
3:Volatile的应用
3.1 单例模式DCL
废话不多说,直接上代码
public class SingletonDemo {
private static volatile SingletonDemo instance=null;
private SingletonDemo(){
System.out.println(Thread.currentThread().getName()+"\t 构造方法");
}
public static SingletonDemo getInstance(){
if(instance==null){
synchronized (SingletonDemo.class){
if(instance==null){
instance=new SingletonDemo();
}
}
}
return instance;
}
public static void main(String[] args) {
for (int i = 1; i <=10; i++) {
new Thread(() ->{
SingletonDemo.getInstance();
},String.valueOf(i)).start();
}
}
}
3.2 volatile保证单例模式原因分析
volatile可以禁止指令重排,如果SingletonDemo不用volatile修饰,是有指令重排的存在的,DCL(双端检锁) 机制不一定线程安全,原因在于某一个线程在执行到第一次检测,读取到的instance不为null时,instance的引用对象可能没有完成初始化,看一下伪代码:
instance=new SingletonDem(); 可以分为以下步骤(伪代码)
memory=allocate();
instance(memory);
instance=memory;
步骤2和步骤3不存在数据依赖关系,而且无论重排前还是重排后程序执行的结果在单线程中并没有改变,因此这种重排优化是允许的。
memory=allocate();
instance=memory;
instance(memory);
但是指令重排只会保证串行语义的执行一致性(单线程) 并不会关心多线程间的语义一致性所以当一条线程访问instance不为null时,由于instance实例未必完成初始化,也就造成了线程安全问题。
总结
volatile虽然说的差不多了,有错误的地方欢迎大家批评指正,但是文章中提到了synchronized和Java中的Atomic,关于他们的使用应该注意什么以及他们的实现是如何实现的,我会后续写出来。 你知道的越多,你知道的越多,我是一名小小的程序员,咱们下期见。
|