Java多线程volatile底层原理详解

1.volatile的作用

1)保证线程间的可见性
2)防止指令重排

public class Test implements Runnable {
	boolean running = true;
	@Override
	public void run() {
		while(running){
		}
	}
	public static void main(String[] args) {
		final Test test = new Test();
		new Thread(test).start();
		try {
			Thread.sleep(1000);
		} catch (InterruptedException e) {
			e.printStackTrace();
		}
		new Thread(){
			public void run() {
				test.running = false;
				System.out.println("修改了running");
			};
		}.start();
	}

}

当第二个线程改变running的值后,第一个线程并不知道。
学习volatile之前,先了解一下java内存模型

2.Java内存模型(JMM:Java Momery Model)

java内存模型是一个规范,是根据CPU缓存模型来建立的,是一种符合内存模型规范的,屏蔽了各种硬件和操作系统的访问差异的,保证了Java程序在各种平台下对内存的访问都能保证效果一致的机制及规范。
Java内存模型规定了所有的变量都存储在主内存中,每条线程还有自己的工作内存,线程的工作内存中保存了该线程中是用到的变量的主内存副本拷贝,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存。不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量的传递均需要自己的工作内存和主存之间进行数据同步进行。

而JMM就作用于工作内存和主存之间数据同步过程。他规定了如何做数据同步以及什么时候做数据同步。
在这里插入图片描述
CPU缓存模型
总结一下,JMM是一种规范,目的是解决由于多线程通过共享内存进行通信时,存在的本地内存数据不一致、编译器会对代码指令重排序、处理器会对代码乱序执行等带来的问题。目的是保证并发编程场景中的原子性、可见性和有序性。

3.JMM原子操作

在这里插入图片描述了解了java内存模型,可以用原子操作分析上面代码
1.首先会执行一个read操作将主内存中的值读取出来
2.执行load操作将值副本写入到工作内存中
3.当前线程执行use操作将工作内存中的值拿出在经过执行引擎运算
4.将运算后的值进行assign操作写会到工作内存。
5.线程将当前工作内存中新的值存储回主内存,注意只是此处还没有写入到主内存的共享变量,主内存中的值还没有改变。
6.最后一步执行write操作将主内存中的值刷新到共享变量,到此处一个简单的流程就走完了。

4.JMM缓存不一致问题

根据上述操作,是没法保证多个线程间的数据一致性,那么怎么解决这个问题呢?不同的处理器解决这个问题的方式不一样,主要有两种
1)总线加锁
CPU从内存读取数据到告诉缓存,会在总线对这个数据加锁(lock操作),这样其他CPU没法去读或写这个数据,直到这个CPU使用完数据释放锁(unlock)之后,其他CPU才能读取该数据,会将我们的并行转换为串行,从而失去了多线程的意义,效率太低。

2)MESI缓存一致性协议
多个CPU从内存中读取同一个数据到各自的高速缓存,当其中某个CPU修改了缓存里的数据,该数据会马上同步回主内存,其他CPU通过总线嗅探机制可以感知到数据的变化从而将自己的缓存里的数据失效。。MESI缓存一致性协议是因特尔使用的,不同的CPU,可能不一样。

5.volatile可见性底层实现原理(字节码层面)

底层实现主要是通过汇编Lock前缀指令,它会锁定这块内存区域的缓存(缓存行锁定)并写回到主内存,此操作被称为“缓存锁定”,MESI缓存一致性协议机制会阻止同时修改被两个以上处理器缓存的内存区域数据。总的来说就是Lock指令会将当前处理器缓存行数据立即写回到系统内存从而保证多线程数据缓存的时效性。这个写回内存的操作同时会引起在其它CPU里缓存了该内存地址的数据失效(MESI协议)。
 下面这段话摘自《深入理解Java虚拟机》:
  “观察加入volatile关键字和没有加入volatile关键字时所生成的汇编代码发现,加入volatile关键字时,会多出一个lock前缀指令”
  lock前缀指令实际上相当于一个内存屏障(也成内存栅栏),内存屏障会提供3个功能:
  1)它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成;
  2)它会强制将对缓存的修改操作立即写入主存;
  3)如果是写操作,它会导致其他CPU中对应的缓存行无效。

6.volatile不能保证原子性

java多线程三个基础概念
原子性:原子性指的是一个操作或者是多个操作的集合,其结果要么全部成功,要么全部失败
可见性:可见性指的是多个线程访问同一个变量,当某个线程改变这个变量时,结果能被其他线程看到。每个线程有自己的工作内存区域,同时线程间也会共享一片内存区域。每次线程去读取变量的时候,首先到自己的工作内存区域去查询该变量,如果找不到才会去共享内存区域读取,读取后会在自己的工作内存里创建一个该变量的副本。相应地,线程对变量的操作也是先操作自己工作内存里变量副本,然后再找时机写回主内存。
有序性:有序性指的是程序的执行顺是按照代码的先后顺序执行的。

volatile不能保证原子性,如当两个线程都对volatile修饰的变量进行++操作,当线程1和线程2都进行++操作,线程1将counter值写回到主内存中,这时,线程2嗅探到counter值发生变化,工作内存中的counter值失效,就浪费了一次++的机会,从而出现最终结果小于预期结果。
在这里插入图片描述

7.指令重排

为了性能优化,JMM在不改变正确语义的前提下,会允许编译器和处理器对指令序列进行重排序,那如果想阻止重排序的话,可以添加内存屏障。单例设计模式中为了防止DCL在指令重排后导致线程不安全的情况,就使用了volatile来防止指令重排,volatile通过内存屏障实现了防止指令重排的目的。同时**lock前缀指令相当于一个内存屏障,它会告诉CPU和编译器先于这个命令的必须先执行,后于这个命令的必须后执行。内存屏障另一个作用是强制更新一次不同CPU的缓存。**例如,一个写屏障会把这个屏障前写入的数据刷新到缓存,这样任何试图读取该数据的线程将得到最新值,而不用考虑到底是被哪个cpu核心或者哪颗CPU执行的。

不同硬件实现内存屏障的方式不同,Java内存模型屏蔽了这种底层硬件平台的差异,由JVM来为不同的平台生成相应的机器码。JVM层级的内存屏障。
Java内存屏障主要有Load和Store两类:
对Load Barrier来说,在读指令前插入读屏障,可以让高速缓存中的数据失效,重新从主内存加载数据
对Store Barrier来说,在写指令之后插入写屏障,能让写入缓存的最新数据写回到主内存

java的内存屏障通常所谓的四种即LoadLoad,StoreStore,LoadStore,StoreLoad实际上也是上述两种的组合,完成一系列的屏障和数据同步功能。
LoadLoad屏障:对于这样的语句Load1; LoadLoad; Load2,在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕。
StoreStore屏障:对于这样的语句Store1; StoreStore; Store2,在Store2及后续写入操作执行前,保证Store1的写入操作对其它处理器可见。
LoadStore屏障:对于这样的语句Load1; LoadStore; Store2,在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕。
StoreLoad屏障:对于这样的语句Store1; StoreLoad; Load2,在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见。它的开销是四种屏障中最大的。在大多数处理器的实现中,这个屏障是个万能屏障,兼具其它三种内存屏障的功能

为了实现volatile的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。然而,对于编译 器来说,发现一个最优布置来最小化插入屏障的总数几乎不可能,为此,Java内存模型采取保守策略:
在每个volatile写操作的前面插入一个StoreStore屏障。
在每个volatile写操作的后面插入一个StoreLoad屏障。
在每个volatile读操作的后面插入一个LoadLoad屏障。
在每个volatile读操作的后面插入一个LoadStore屏障。
在这里插入图片描述在这里插入图片描述

8.volatile和synchronized的区别

volatile本质是告诉JVM当前变量在寄存器(工作内存)中是无效的,需要去主内存重新读取;synchronized是锁定当前变量,只有持有锁的线程才可以访问该变量,其他线程都被阻塞直到该线程的变量操作完成;
volatile仅仅能使用在变量级别;synchronized则可以使用在变量、方法和类级别;
volatile仅仅能实现变量修改的可见性,不能保证原子性;而synchronized则可以保证变量修改的可见性和原子性;
volatile不会造成线程的阻塞;synchronized可能会造成线程的阻塞;
volatile修饰的变量不会被编译器优化;synchronized修饰的变量可以被编译器优化。

总结

volatie的作用是保证线程间的可见性,和防止指令重排。JMM层面,保证线程间可见,主要是利用缓存一致性协议(硬件支持),当一个变量被修改后,立即写回主内存,并且其他线程通过嗅探机制发现变量被改变,这些线程工作空间中的该变量失效,需要重新读取,本质还是通过内存屏障实现的。防止指令重排,保证有序性,JMM层面,在volatile操作前后加入内存屏障。字节码层面,主要是通过汇编Lock前缀指令。

发布了11 篇原创文章 · 获赞 8 · 访问量 148

猜你喜欢

转载自blog.csdn.net/weixin_43691723/article/details/105480746