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的值后,第一个线程并不知道。在running前加一个volatile,就可以保证线程间的可见性。可见性的意思是当一个线程修改一个共享变量时,另外一个线程能读到这个修改的值。
学习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缓存不一致问题

根据上述操作,是没法保证多个线程间的数据一致性,那么怎么解决这个问题呢?不同的处理器解决这个问题的方式不一样,主要有两种

4.1总线加锁

CPU从内存读取数据到告诉缓存,会在总线对这个数据加锁(lock操作),这样其他CPU没法去读或写这个数据,直到这个CPU使用完数据释放锁(unlock)之后,其他CPU才能读取该数据,会将我们的并行转换为串行,从而失去了多线程的意义,效率太低。

4.2 MESI缓存一致性协议

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

MESI(缓存一致性协议)
MESI是一种比较常用的缓存一致性协议,MESI表示缓存行的四种状态,分别是:
1、M(Modify) 表示共享数据只缓存在当前 CPU 缓存中,并且是被修改状态,也就是缓存的数据和主内存中的数据不一致
2、E(Exclusive) 表示缓存的独占状态,数据只缓存在当前CPU缓存中,并且没有被修改
3、S(Shared) 表示数据可能被多个 CPU 缓存,并且各个缓存中的数据和主内存数据一致
4、I(Invalid) 表示缓存已经失效
在 MESI 协议中,每个缓存的缓存控制器不仅知道自己的读写操作,而且也监听(snoop)其它CPU的读写操作。
对于 MESI 协议,从 CPU 读写角度来说会遵循以下原则:
CPU读请求:缓存处于 M、E、S 状态都可以被读取,I 状态CPU 只能从主存中读取数据
CPU写请求:缓存处于 M、E 状态才可以被写。对于S状态的写,需要将其他CPU中缓存行置为无效才行。

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

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

6.volatile不能保证原子性

java多线程三个基础概念

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

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

7.指令重排

我们知道在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排序。重排序分3种类型:
1)编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
2)指令级并行的重排序。现代处理器采用了指令级并行技术(Instruction-Level Parallelism,ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
3)内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。
从Java源代码到最终实际执行的指令序列,会分别经历下面3种重排序,如下图:
在这里插入图片描述

其中2和3属于处理器重排序。而这些重排序都可能会导致可见性问题(编译器和处理器在重排序时会遵守数据依赖性,编译器和处理器不会改变存在数据依赖关系的两个操作的执行顺序,编译器会遵守happens-before规则和as-if-serial语义)。

对于编译器,JMM的编译器重排序规则会禁止特定类型的编译器重排 序(不是所有的编译器重排序都要禁止)。对于处理器重排序,JMM的处理器重排序规则会要求Java编译器在生成指令序列时,插入特定类型的内存屏障(Memory Barriers,Intel称之为Memory Fence)指令,通过内存屏障指令来禁止特定类型的处理器重排序。JMM属于语言级的内存模型,它确保在不同的编译器和不同的处理器平台之上,通过禁止特定类型的编译器重排序和处理器重排序,为程序员提供一致的内存可见性保证。

8. 内存屏障

为了性能优化,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前缀指令。

  • 0
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值