volatile
volatile的作用: 保证内存可见性 和 禁止指令重排序
Java内存模型JMM
Java内存模型(JavaMemoryModel)描述了Java程序中各种变量(线程共享变量)的访问规则,以及在JVM中将变量存储到内存和从内存中读取变量这样的底层细节
是java虚拟机规范中所定义的一种内存模型,Java内存模型是标准化的,屏蔽掉了底层不同计算机的区别。
JMM有以下规定
所有的共享变量都存储于主内存,这里所说的变量指的是实例变量和类变量,不包含局部变量,因为局部变量是线程私有的,因此不存在竞争问题。
每一个线程还存在自己的工作内存,线程的工作内存,保留了被线程使用的变量的工作副本。
线程对变量的所有的操作(读,取)都必须在工作内存中完成,而不能直接读写主内存中的变量。
不同线程之间也不能直接访问对方工作内存中的变量,线程间变量的值的传递需要通过主内存中转来完成。
存在可见性问题
解决方案:
-
加锁(锁本质上是锁定一块内存资源)。
因为某一个线程进入synchronized代码块前后,线程会获得锁,清空工作内存,从主内存拷贝共享变量最新的值到工作内存成为副本,执行代码,将修改后的副本的值刷新回主内存中,线程释放锁。
而获取不到锁的线程会阻塞等待,所以变量的值肯定一直都是最新的。
-
volatile修饰共享变量
每个线程操作数据的时候会把数据从主内存读取到自己的工作内存,如果他操作了数据并且修改了,那其他已经读取的线程的变量副本就会失效了,需要对数据进行操作又要再次去主内存中读取了。
volatile保证不同线程对共享变量操作的可见性,也就是说一个线程修改了volatile修饰的变量,当修改写回主内存时,另外一个线程立即看到最新的值。
缓存一致性协议
Intel的MESI: 当CPU写数据时,如果发现操作的变量是共享变量,即在其他CPU中也存在该变量的副本,会触发信号通知其他CPU将该变量的缓存行置为无效状态,因此当其他CPU需要读取这个变量时,发现自己缓存中缓存该变量的缓存行是无效的,那么它就会从内存重新读取
怎么发现数据是否失效呢?:
嗅探 每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己的缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器对这个数据进行修改操作的时候,会重新从系统内存中把数据读到处理器缓存里
嗅探的缺点: 总线风暴
由于volatile的MESI缓存一致性协议,需要不断的从主内存嗅探和CAS不断循环,无效交互会导致总线带宽达到峰值。
所以不要大量使用volatile,至于什么时候去使用volatile什么时候使用锁,根据场景区分。
禁止指令重排序
什么是重排序?
为了提高性能,编译器和处理器常常会对既定的代码执行顺序进行指令重排序。
重排序的类型有哪些呢?源码到最终执行会经过哪些重排序呢?
一般重排序可以分为如下三种:
- 编译器优化的重排序: 编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序
- 指令级并行的重排序: 现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序;
- 内存系统的重排序: 由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行的。
as-if-serial
不管怎么重排序,单线程下的执行结果不能被改变。
编译器、runtime和处理器都必须遵守as-if-serial语义。
为了遵守as-if-serial语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。 但是,如果操作之间不存在数据依赖关系,这些操作可能被编译器和处理器重排序。
volatile如何保证线程间可见和避免指令重排(即volatile的底层原理)
volatile可见性是由指令原子性保证的,在JMM中定义了8类原子性指令,比如write,store,read,load等。而volatile就要求write-store,load-read成为一个原子性操作,这样子可以确保在读取的时候都是从主内存读入,写入的时候会同步到主内存中(准确来说也是内存屏障),指令重排则是由内存屏障来保证的(一共有2个内存屏障,分别是编译器屏障和cpu屏障)
- 编译器屏障:阻止编译器重排,保证编译程序时在优化屏障之前的指令不会在优化屏障之后执行。
- cpu屏障:sfence保证写入,lfence保证读取,lock类似于锁的方式。java多执行了一个“load addl $0x0, (%esp)”操作,这个操作相当于一个lock指令,就是增加一个完全的内存屏障指令。(我理解的sfence和lfence中的s和l,分别代表store和load)
那volatile是怎么保证不会被执行重排序的呢?
内存屏障
内存屏障是一组处理器指令(前面的8个操作指令,比如write,store,read,load。),用于实现对内存操作的顺序限制
内存屏障(Memory Barrier),也被称为内存栅栏或内存栅障,是计算机编程中用来管理多线程并发操作的一种同步机制。它确保了在多核处理器或多线程环境中,共享内存中的数据能够正确同步和协调访问,以避免数据不一致性或竞争条件的问题。
java编译器会在生成指令系列时在适当的位置会插入内存屏障指令来禁止特定类型的处理器重排序
happens-before 先行发生
JMM可以通过happens-before关系向程序员提供跨线程的内存可见性保证(如果A线程的写操作a与B线程的读操作b之间存在happens-before关系,尽管a操作和b操作在不同的线程中执行,但JMM向程序员保证a操作将对b操作可见)。具体的定义为: 如果一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。 两个操作之间存在happens-before关系,并不意味着Java平台的具体实现必须要按照happens-before关系指定的顺序来执行。如果重排序之后的执行结果,与按happens-before关系来执行的结果一致,那么JMM允许这种重排序。
知识关联:
synchronized的指令严格遵守java happens-before规则,一个monitor exit指令之前必定有一个monitor enter
无法保证原子性
就是一次操作,要么完全成功,要么完全失败。
假设现在有N个线程对同一个变量进行累加也是没办法保证结果是对的,因为读写这个过程并不是原子性的。
要解决也简单,要么用原子类,比如AtomicInteger,要么加锁(记得关注Atomic的底层)。
现代计算机内存模型
cpu、高速缓存、一致性协议、主内存
面试
volatile作用
-
变量可见性
-
防止指令重排序
-
保障变量单次读写操作的原子性,但不能保证i++这种操作的原子性,因为本质是读写两次操作
i++ 操作通常不是原子的,因为它涉及读取 i 的当前值,增加它,然后将新值写回 i。在多线程环境下,如果多个线程同时尝试执行 i++ 操作,可能会导致竞态条件,破坏了原子性。(即不能单独保证多线程环境下的原子性)
单例模式
以上问题发生在② ,②看似只是一个创建对象的过程,然而它的实际执行却分为以下3步:
- 创建内存空间
- 在内存空间中初始化对象 Singleton
- 将内存地址赋值给 instance 对象(执行了此步骤,instance 就不等于 null 了)
如果此变量不加 volatile,那么线程 1 在执行到上述代码的第 ② 处时就可能会执行指令重排序,将原本上面的1、2、3 的步骤,重排为 1、3、2。但是特殊情况下,线程 1 在执行完第 3 步之后,如果此时来了一个线程 2 执行到上述代码的第 ① 处,判断 instance 对象已经不为 null,但此时线程 1 还未将对象实例化完,那么线程 2 将会得到一个被实例化“一半”的对象,从而导致程序执行出错,这就是为什么要给私有变量添加 volatile 的原因了。 要使以上单例模式变为线程安全的程序,需要给 instance 变量添加 volatile 修饰:
网站当前构建日期: 2024.12.02