【Java 多线程并发】 volatile
【Java 多线程并发】 volatile
Metadata
title: 【Java 多线程并发】 volatile
date: 2023-07-03 14:46
tags:
- 行动阶段/完成
- 主题场景/程序
- 笔记空间/KnowladgeSpace/ProgramSpace/BasicsSpace
- 细化主题/Java/多线程并发
categories:
- Java
keywords:
- Java/多线程并发
description: 【Java 多线程并发】 volatile
概述
volatile的原理
抽象上是 Load 屏障 和 Store 屏障
volatile实现是靠lock指令(汇编语言的LOCK指令) + 缓存一致性协议
JVM会向处理器发送一条Lock前缀的指令,将这个变量所在缓存行的数据写会到系统内存
- 可见性
- JVM会向处理器发送一条lock前缀的指令,将这个缓存中的变量回写到系统主存中。
- 每个处理会通过嗅探在总线上传播的数据来检查自己的缓存是否过期
- 有序性
- Lock前缀指令实际上相当于一个内存屏障(也成内存栅栏)
- 执行到内存屏障这句指令时,在它前面的操作已经全部完成。
lock 指令
lock指令的几个作用
- 锁总线
- lock后的写操作会向主内存中回写已修改的数据,同时让其它CPU相关缓存行(Cache Line)失效,从而重新从主存中加载最新的数据
- 不是内存屏障却能完成类似内存屏障的功能,阻止屏障两边的指令重排序。
缓存一致性协议 MESI协议
只有当缓存行处于E或者M状态时,处理器才能去写它
- 读操作:不做任何事情,把Cache中的数据读到寄存器
- 写操作:发出信号通知其他的CPU将该变量的Cache line置为无效,其他的CPU要访问这个变量的时候,只能从主内存中获取。
内存屏障(Memory Barrier)
- 在每个volatile写操作前插入StoreStore屏障,在写操作后插入StoreLoad屏障;
- 在每个volatile读操作后插入LoadLoad屏障,在读操作后插入LoadStore屏障;
volatile特性
- 保证可见性
- 保证有序性
- volatile不能保证原子性
适用条件
1.运算结果并不依赖变量的当前值,或者能够确保只有单一的线程会修改变量的值。
2.该变量没有包含在具有其他变量的不变式中
相关场景
- 状态标记量(开关模式)
- 一次性安全发布.双重检查锁定问题
引入
可见性问题:
public class ReaderAndUpdater {
final static int MAX=5;
static int init_value=0;
public static void main(String[] args) {
new Thread(()->{
int localValue=init_value;
while(localValue<MAX){
if(localValue!=init_value){
System.out.println("Reader:"+init_value);
localValue=init_value;
}
}
},"Reader").start();
new Thread(()->{
int localValue=init_value;
while(localValue<MAX){
System.out.println("updater:"+(++localValue));
init_value=localValue;
try {
TimeUnit.SECONDS.sleep(2);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
},"Updater").start();
}
}
一个读线程,一个写线程。有一个全局静态变量init_value,每一个线程还有一个自己的局部变量localValue。写线程将自己的局部变量localValue自增,然后赋值给全局静态变量init_value。读线程当发现自己的本地局部变量与全局静态变量值不相同,则读入最新的全局静态变量init_value,然后更新自己的本地局部变量。
运行结果:
updater:1
Reader:1
updater:2
updater:3
updater:4
updater:5
由结果可发现读线程感知不到写线程对init_value变量的更新,写线程读取到的全局静态变量一直没有被更新,还是旧值。这就出现了并发编程的可见性问题,读线程对写线程的数据修改结果不可见,使程序出现了问题。
重排序问题:
public class NoVisibility {
private static boolean ready;
private static int number;
private static class ReaderThread extends Thread {
@Override
public void run() {
while(!ready) {
Thread.yield();
}
System.out.println(number);
}
}
public static void main(String[] args) {
new ReaderThread().start();
number = 42;
ready = true;
}
}
NoVisibility可能会持续循环下去,因为读线程可能永远都看不到ready的值,这就是上面讲到的可见性问题。也有可能NoVisibility可能会输出0,因为读线程虽然看到了写入ready的值,但却没有看到之后写入number的值,在主线程对number和ready的赋值给颠倒了,这种现象被称为“重排序”。只要在某个线程中无法检测到重排序情况(即使在其他线程中可以明显地看到该线程中的重排序),那么就无法确保线程中的操作将按照程序中指定的顺序来执行。当主线程首先写入number,然后在没有同步的情况下写入ready,那么读线程看到的顺序可能与写入的顺序完全相反。
在没有同步的情况下,编译器、处理器以及运行时等都可能对操作的执行顺序进行一些意想不到的调整。在缺乏足够同步的多线程程序中,要想对内存操作的执行顺序进行判断,无法得到正确的结论。
前言
以上就是在并发编程中可能出现的问题,今天我们就在讲解一下使用volatile关键字来解决这些问题。
我们知道volatile关键字的作用是保证变量在多线程之间的可见性,它是java.util.concurrent包的核心,没有volatile就没有这么多的并发类给我们使用。
本文详细解读一下volatile关键字如何保证变量在多线程之间的可见性,在此之前,有必要讲解一下CPU缓存的相关知识,掌握这部分知识一定会让我们更好地理解volatile的原理,从而更好、更正确地地使用volatile关键字。
几个基本概念
内存可见性
在Java内存模型那一章我们介绍了JMM有一个主内存,每个线程有自己私有的工作内存,工作内存中保存了一些变量在主内存的拷贝。
内存可见性,指的是线程之间的可见性,当一个线程修改了共享变量时,另一个线程可以读取到这个修改后的值。
重排序
为优化程序性能,对原有的指令执行顺序进行优化重新排序。重排序可能发生在多个阶段,比如编译重排序、CPU重排序等。
happens-before规则
是一个给程序员使用的规则,只要程序员在写代码的时候遵循happens-before规则,JVM就能保证指令在多线程之间的顺序性符合程序员的预期。
CPU缓存
CPU多级缓存架构
CPU缓存的出现主要是为了解决CPU运算速度与内存读写速度不匹配的矛盾,因为CPU运算速度要比内存读写速度快得多,举个例子:
- 一次主内存的访问通常在几十到几百个时钟周期
- 一次L1高速缓存的读写只需要1~2个时钟周期
- 一次L2高速缓存的读写也只需要数十个时钟周期
这种访问速度的显著差异,导致CPU可能会花费很长时间等待数据到来或把数据写入内存。
基于此,现在CPU大多数情况下读写都不会直接访问内存(CPU都没有连接到内存的管脚),取而代之的是CPU缓存(cache),CPU缓存是位于CPU与内存之间的临时存储器,它的容量比内存小得多但是交换速度却比内存快得多。而缓存中的数据是内存中的一小部分数据,但这一小部分是短时间内CPU即将访问的,当CPU调用大量数据时,就可先从缓存中读取,从而加快读取速度。
按照读取顺序与CPU结合的紧密程度,CPU缓存可分为:
- 一级缓存:简称L1 Cache,位于CPU内核的旁边,是与CPU结合最为紧密的CPU缓存
- 二级缓存:简称L2 Cache,分内部和外部两种芯片,内部芯片二级缓存运行速度与主频相同,外部芯片二级缓存运行速度则只有主频的一半
- 三级缓存:简称L3 Cache,部分高端CPU才有
每一级缓存中所存储的数据全部都是下一级缓存中的一部分,这三种缓存的技术难度和制造成本是相对递减的,所以其容量也相对递增。
当CPU要读取一个数据时,首先从一级缓存中查找,如果没有再从二级缓存中查找,如果还是没有再从三级缓存中或内存中查找。一般来说每级缓存的命中率大概都有80%左右,也就是说全部数据量的80%都可以在一级缓存中找到,只剩下20%的总数据量才需要从二级缓存、三级缓存或内存中读取。
使用CPU缓存带来的问题
当系统运行时,CPU执行计算的过程如下:
- 程序以及数据被加载到主内存
- 指令和数据被加载到CPU缓存
- CPU执行指令,把结果写到高速缓存
- 高速缓存中的数据写回主内存
如果服务器是单核CPU,那么这些步骤不会有任何的问题,但是如果服务器是多核CPU,每个CPU都有自己独享的Cache,那么问题来了,以Intel Core i7处理器的高速缓存概念模型为例(图片摘自《深入理解计算机系统》):
试想下面一种情况:
- 核0读取了一个字节,根据局部性原理,它相邻的字节同样被被读入核0的缓存
- 核3做了上面同样的工作,这样核0与核3的缓存拥有同样的数据
- 核0修改了那个字节,被修改后,那个字节被写回核0的缓存,但是该信息并没有写回主存
- 核3访问该字节,由于核0并未将数据写回主存,数据不同步
为了解决这个问题,CPU制造商制定了一个规则(MESI):
当一个CPU修改缓存中的字节时,服务器中其他CPU会被通知,它们的缓存将视为无效,即将数据所对应的Cache Line置为无效。于是,在上面的情况下,核3发现自己的缓存中数据已无效,核0将立即把自己的数据写回主存,然后核3重新读取该数据。
反汇编Java字节码,查看汇编层面对volatile关键字做了什么
public class LazySingleton {
private static volatile LazySingleton instance = null;
public static LazySingleton getInstance() {
if (instance == null) {
instance = new LazySingleton();
}
return instance;
}
public static void main(String[] args) {
LazySingleton.getInstance();
}
}
代码生成的汇编指令为:
0x0000000002931351: lock add dword ptr [rsp],0h ;
*putstatic instance; - org.xrq.test.design.singleton.LazySingleton::getInstance@13 (line 14)
之所以定位到这两行是因为这里结尾写明了line 14,line 14即volatile变量instance赋值的地方。后面的add dword ptr [rsp],0h都是正常的汇编语句,意思是将双字节的栈指针寄存器+0,这里的关键就是add前面的lock指令,lock是汇编语言的命令,后面详细分析一下lock指令的作用和为什么加上lock指令后就能保证volatile关键字的内存可见性。
lock指令做了什么
这里的Lock是汇编语言指令,不是之前写过的Java内存模型指定的8中操作中的lock
lock指令的几个作用:
- 锁总线,其它CPU对内存的读写请求都会被阻塞,直到锁释放,不过实际后来的处理器都采用锁缓存替代锁总线(降低粒度,以前锁了总线,使总线只能被一个CPU独享,所有的CPU公用一条总线,那么所有的CPU就都不能使用,这样大大降低了吞吐量,影响效率。锁缓存行只是将该数据的缓存行锁住,让对其操作的CPU独占,其他的缓存行不受影响,其他CPU还能对别的数据进行操作),因为锁总线的开销比较大,锁总线期间其他CPU没法访问内存。(计算机组成原理中学的总线结构)(不管是锁总线还是锁缓存行,其根本目的就是使CPU对内存中某个数据的操作是独占的,在CPU1对一个数据操作时lock指令会锁总线或者通过缓存一致性协议来锁缓存行来使其他的CPU无法对该数据进行操作,使CPU1能独享给数据的操作权)
- lock后的写操作会向主内存中回写已修改的数据,同时让其它CPU相关缓存行(Cache Line)失效,从而重新从主存中加载最新的数据
- 不是内存屏障却能完成类似内存屏障的功能,阻止屏障两边的指令重排序。
以上可以看出lock指令就可以实现可见性和有序性。
第一条中写了由于效率问题,实际后来的处理器都采用锁缓存来替代锁总线,这种场景下多缓存的数据一致是通过缓存一致性协议来保证的,我们来看一下什么是缓存一致性协议。
缓存一致性协议
缓存行(Cache Line)的概念
缓存是分段(line)的,一个段对应一块存储空间,我们称之为缓存行(Cache Line),它是CPU缓存中可分配的最小存储单元,大小32字节、64字节、128字节不等,这与CPU架构有关,通常来说是64字节。当CPU看到一条读取内存的指令时,它会把内存地址传递给一级数据缓存,一级数据缓存会检查它是否有这个内存地址对应的缓存段,如果没有就把整个缓存段从内存(或更高一级的缓存)中加载进来。注意,这里说的是一次加载整个缓存段,这就是上面提过的局部性原理
缓存一致性协议有多种,但是日常处理的大多数计算机设备都属于”嗅探(snooping)“协议,它的基本思想是:
- 所有内存的传输都发生在一条共享的总线上,而所有的处理器都能看到这条总线:缓存本身是独立的,但是内存是共享资源,所有的内存访问都要经过仲裁(同一个指令周期中,只有一个CPU缓存可以读写内存)。
- CPU缓存不仅仅在做内存传输的时候才与总线打交道,而是不停在嗅探总线上发生的数据交换,跟踪其他缓存在做什么。所以当一个缓存代表它所属的处理器去读写内存时,其它处理器都会得到通知,它们以此来使自己的缓存保持同步。只要某个处理器一写内存,其它处理器马上知道这块内存在它们的缓存段中已失效。
MESI协议是当前最主流的缓存一致性协议,在MESI协议中,每个缓存行有4个状态,可用2个bit表示,它们分别是:
这里的I、S和M状态已经有了对应的概念:失效/未载入、干净以及脏的缓存段。所以这里新的知识点只有E状态,代表独占式访问,这个状态解决了”在我们开始修改某块内存之前,我们需要告诉其它处理器”这一问题:只有当缓存行处于E或者M状态时,处理器才能去写它,也就是说只有在这两种状态下,处理器是独占这个缓存行的。当处理器想写某个缓存行时,如果它没有独占权,它必须先发送一条”我要独占权”的请求给总线,这会通知其它处理器把它们拥有的同一缓存段的拷贝失效(如果有)。只有在获得独占权后,处理器才能开始修改数据—-并且此时这个处理器知道,这个缓存行只有一份拷贝,在我自己的缓存里,所以不会有任何冲突。
反之,如果有其它处理器想读取这个缓存行(马上能知道,因为一直在嗅探总线),独占或已修改的缓存行必须先回到”共享”状态。如果是已修改的缓存行,那么还要先把内容回写到内存中。
简单来说,MESI协议就是:
- 读操作:不做任何事情,把Cache中的数据读到寄存器
- 写操作:发出信号通知其他的CPU将该变量的Cache line置为无效,其他的CPU要访问这个变量的时候,只能从主内存中获取。
内存屏障(Memory Barrier)
Memory barrier 能够让 CPU 或编译器在内存访问上有序。一个 Memory barrier 之前的内存访问操作必定先于Memory barrier之后的操作完成。
Memory barrier是一种CPU指令,用于控制特定条件下的重排序和内存可见性问题。Java编译器也会根据内存屏障的规则禁止重排序。
有的处理器的重排序规则较严,无需内存屏障也能很好的工作,Java编译器会在这种情况下不放置内存屏障。
Memory Barrier可以被分为以下几种类型:
- 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语义中的内存屏障
在每个volatile写操作前插入StoreStore屏障,在写操作后插入StoreLoad屏障;
在每个volatile读操作后插入LoadLoad屏障,在读操作后插入LoadStore屏障;
volatile读:在每个volatile读后面分别插入LoadLoad屏障及LoadStore屏障(根据volatile重排序规则第一条),如下图所示
LoadLoad屏障的作用:禁止上面的所有普通读操作和上面的volatile读操作进行重排序。
LoadStore屏障的作用:禁止下面的普通写和上面的volatile读进行重排序。
- volatile写:在每个volatile写前面插入一个StoreStore屏障(为满足volatile重排序规则第二条),在每个volatile写后面插入一个StoreLoad屏障(为满足volatile重排序规则第三条),如下图所示
StoreStore屏障的作用:禁止上面的普通写和下面的volatile写重排序
StoreLoad屏障的作用:防止上面的volatile写与下面可能出现的volatile读/写重排序
final语义中的内存屏障
- 新建对象过程中,构造体中对final域的初始化写入(StoreStore屏障)和这个对象赋值给其他引用变量,这两个操作不能重排序;
- 初次读包含final域的对象引用和读取这个final域(LoadLoad屏障),这两个操作不能重排序;
汇编指令LOCK与内存屏障之间的关系?
LOCK汇编命令使相应的机器码指令中添加了相关内存屏障指令,也就是说汇编层面LOCK指令的功能是通过CPU层面的内存屏障机器码实现的。
volatile的原理
由lock指令回看volatile变量读写。可以知道volatile实际是靠lock指令(这是汇编语言的LOCK指令,不是JMM中的lock操作,JMM中的lock操作是加锁,它是synchronized的实现基础)为基础来实现的。
相信有了上面对于lock的解释,以及对CPU多级缓存架构以及JAVA内存模型的理解,volatile关键字的实现原理应该是一目了然了。由上面JMM的结构图可知,工作内存Work Memory其实就是对CPU寄存器和高速缓存的抽象,或者说每个线程的工作内存也可以简单理解为CPU寄存器和高速缓存。
那么当写两条线程Thread-A与Threab-B同时操作主存中的一个volatile变量i时
- Thread-A写了变量i,那么:
- Thread-A发出LOCK#指令
- 发出的LOCK#指令锁总线(或锁缓存行),同时让Thread-B高速缓存中的缓存行内容失效
- Thread-A向主存回写最新修改的i
- Thread-B读取变量i,那么:
- Thread-B发现对应地址的缓存行被锁了,等待锁的释放,缓存一致性协议会保证它读取到最新的值,也就是当锁释放之后Thread-B发现对应的Cache Line已经失效了,只能去主内存中读取最新的值。
由此可以看出,volatile关键字的读和普通变量的读取相比基本没差别,差别主要还是在变量的写操作上。
当对volatile变量进行写操作的时候,JVM会向处理器发送一条lock前缀的指令,将这个缓存中的变量回写到系统主存中。
但是就算写回到内存,如果其他处理器缓存的值还是旧的,再执行计算操作就会有问题,所以在多处理器下,为了保证各个处理器的缓存是一致的,就会实现上面所讲的缓存一致性协议
缓存一致性协议:每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器要对这个数据进行修改操作的时候,会强制重新从系统内存里把数据读到处理器缓存里。
所以,如果一个变量被volatile所修饰的话,在每次数据变化之后,其值都会被强制刷入主存。而其他处理器的缓存由于遵守了缓存一致性协议,也会把这个变量的值从主存加载到自己的缓存中。这就保证了一个volatile在并发编程中,其值在多个缓存中是可见的。
总结:
volatile可以保证线程可见性且提供了一定的有序性,但是无法保证原子性。在JVM底层volatile是采用“内存屏障”来实现的。观察加入volatile关键字和没有加入volatile关键字时所生成的汇编代码发现,加入volatile关键字时,会多出一个lock前缀指令,lock前缀指令实际上相当于一个内存屏障(也称内存栅栏),内存屏障会提供3个功能:
- 它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成;(保证有序性)
- 它会强制将对缓存的修改操作立即写入主存;(保证可见性)
- 如果是写操作,它会导致其他CPU中对应的缓存行无效。(保证可见性)
源码溯源
JVM会向处理器发送一条lock前缀的指令,将这个缓存中的变量回写到系统主存中。
Java代码层面
volatile用来修饰Java变量 的代码示例
public class TestVolatile {
public static volatile int counter = 1;
public static void main(String[] args){
counter = 2;
System.out.println(counter);
}
}
字节码层面
通过javac TestVolatile.java将类编译为class文件,再通过javap -v TestVolatile.class命令反编译查看字节码文件。
可以看到,修饰counter字段的public、static、volatile关键字,在字节码层面分别是以下访问标志: ACC_PUBLIC, ACC_STATIC, ACC_VOLATILE
volatile在字节码层面,就是使用访问标志:ACC_VOLATILE来表示,供后续操作此变量时判断访问标志是否为ACC_VOLATILE,来决定是否遵循volatile的语义处理。
JVM源码层面
- openjdk8根路径/hotspot/src/share/vm/interpreter路径下的bytecodeInterpreter.cpp文件中,处理putstatic和putfield指令的代码:
CASE(_putfield):
CASE(_putstatic):
{
// .... 省略若干行
// ....
// Now store the result 现在要开始存储结果了
// ConstantPoolCacheEntry* cache; -- cache是常量池缓存实例
// cache->is_volatile() -- 判断是否有volatile访问标志修饰
int field_offset = cache->f2_as_index();
if (cache->is_volatile()) { // ****重点判断逻辑****
// volatile变量的赋值逻辑
if (tos_type == itos) {
obj->release_int_field_put(field_offset, STACK_INT(-1));
} else if (tos_type == atos) {// 对象类型赋值
VERIFY_OOP(STACK_OBJECT(-1));
obj->release_obj_field_put(field_offset, STACK_OBJECT(-1));
OrderAccess::release_store(&BYTE_MAP_BASE[(uintptr_t)obj >> CardTableModRefBS::card_shift], 0);
} else if (tos_type == btos) {// byte类型赋值
obj->release_byte_field_put(field_offset, STACK_INT(-1));
} else if (tos_type == ltos) {// long类型赋值
obj->release_long_field_put(field_offset, STACK_LONG(-1));
} else if (tos_type == ctos) {// char类型赋值
obj->release_char_field_put(field_offset, STACK_INT(-1));
} else if (tos_type == stos) {// short类型赋值
obj->release_short_field_put(field_offset, STACK_INT(-1));
} else if (tos_type == ftos) {// float类型赋值
obj->release_float_field_put(field_offset, STACK_FLOAT(-1));
} else {// double类型赋值
obj->release_double_field_put(field_offset, STACK_DOUBLE(-1));
}
// *** 写完值后的storeload屏障 ***
OrderAccess::storeload();
} else {
// 非volatile变量的赋值逻辑
if (tos_type == itos) {
obj->int_field_put(field_offset, STACK_INT(-1));
} else if (tos_type == atos) {
VERIFY_OOP(STACK_OBJECT(-1));
obj->obj_field_put(field_offset, STACK_OBJECT(-1));
OrderAccess::release_store(&BYTE_MAP_BASE[(uintptr_t)obj >> CardTableModRefBS::card_shift], 0);
} else if (tos_type == btos) {
obj->byte_field_put(field_offset, STACK_INT(-1));
} else if (tos_type == ltos) {
obj->long_field_put(field_offset, STACK_LONG(-1));
} else if (tos_type == ctos) {
obj->char_field_put(field_offset, STACK_INT(-1));
} else if (tos_type == stos) {
obj->short_field_put(field_offset, STACK_INT(-1));
} else if (tos_type == ftos) {
obj->float_field_put(field_offset, STACK_FLOAT(-1));
} else {
obj->double_field_put(field_offset, STACK_DOUBLE(-1));
}
}
UPDATE_PC_AND_TOS_AND_CONTINUE(3, count);
}
- 重点判断逻辑cache->is_volatile()方法,调用的是openjdk8根路径/hotspot/src/share/vm/utilities路径下的accessFlags.hpp文件中的方法,用来判断访问标记是否为volatile修饰。
// Java access flags
bool is_public () const { return (_flags & JVM_ACC_PUBLIC ) != 0; }
bool is_private () const { return (_flags & JVM_ACC_PRIVATE ) != 0; }
bool is_protected () const { return (_flags & JVM_ACC_PROTECTED ) != 0; }
bool is_static () const { return (_flags & JVM_ACC_STATIC ) != 0; }
bool is_final () const { return (_flags & JVM_ACC_FINAL ) != 0; }
bool is_synchronized() const { return (_flags & JVM_ACC_SYNCHRONIZED) != 0; }
bool is_super () const { return (_flags & JVM_ACC_SUPER ) != 0; }
// 是否volatile修饰
bool is_volatile () const { return (_flags & JVM_ACC_VOLATILE ) != 0; }
bool is_transient () const { return (_flags & JVM_ACC_TRANSIENT ) != 0; }
bool is_native () const { return (_flags & JVM_ACC_NATIVE ) != 0; }
bool is_interface () const { return (_flags & JVM_ACC_INTERFACE ) != 0; }
bool is_abstract () const { return (_flags & JVM_ACC_ABSTRACT ) != 0; }
bool is_strict () const { return (_flags & JVM_ACC_STRICT ) != 0; }
- 下面一系列的 if…else… 对
tos_type
字段的判断处理,是针对 java 基本类型和引用类型的赋值处理。如:
obj->release_byte_field_put(field_offset, STACK_INT(-1));
对 byte 类型的赋值处理,调用的是openjdk8根路径/hotspot/src/share/vm/oops
路径下的oop.inline.hpp
文件中的方法:
// load操作调用的方法
inline jbyte oopDesc::byte_field_acquire(int offset) const
{ return OrderAccess::load_acquire(byte_field_addr(offset)); }
// store操作调用的方法
inline void oopDesc::release_byte_field_put(int offset, jbyte contents)
{ OrderAccess::release_store(byte_field_addr(offset), contents); }
赋值的操作又被包装了一层,又调用的 OrderAccess::release_store 方法。
- OrderAccess 是定义在
openjdk8根路径/hotspot/src/share/vm/runtime
路径下的orderAccess.hpp
头文件下的方法,具体的实现是根据不同的操作系统和不同的 cpu 架构,有不同的实现。
强烈建议大家读一遍orderAccess.hpp
文件中 30-240 行的注释!!!你就会发现本文 1.2 章所介绍内容的来源,也是网上各种雷同文章的来源。
orderAccess_linux_x86.inline.hpp
是 linux 系统下 x86 架构的实现:
可以从上面看到,到 c++ 的实现层面,又使用 c++ 中的 volatile 关键字,用来修饰变量,通常用于建立语言级别的 memory barrier。在《C++ Programming Language》一书中对 volatile 修饰词的解释:
A volatile specifier is a hint to a compiler that an object may change its value in ways not specified by the language so that aggressive optimizations must be avoided.
含义就是:
- volatile 修饰的类型变量表示可以被某些编译器未知的因素更改(如:操作系统,硬件或者其他线程等)
- 使用 volatile 变量时,避免激进的优化。即:系统总是重新从内存读取数据,即使它前面的指令刚从内存中读取被缓存,防止出现未知更改和主内存中不一致
- 步骤 3 中对变量赋完值后,程序又回到了 2.3.1 小章中第一段代码中一系列的 if…else… 对
tos_type
字段的判断处理之后。有一行关键的代码:OrderAccess::storeload(); 即:只要 volatile 变量赋值完成后,都会走这段代码逻辑。
它依然是声明在orderAccess.hpp
头文件中,在不同操作系统或 cpu 架构下有不同的实现。orderAccess_linux_x86.inline.hpp
是 linux 系统下 x86 架构的实现:
代码lock; addl $0,0(%%rsp)
其中的 addl $0,0(%%rsp)
是把寄存器的值加 0,相当于一个空操作(之所以用它,不用空操作专用指令 nop,是因为 lock 前缀不允许配合 nop 指令使用)
lock 前缀,会保证某个处理器对共享内存(一般是缓存行 cacheline,这里记住缓存行概念,后续重点介绍)的独占使用。它将本处理器缓存写入内存,该写入操作会引起其他处理器或内核对应的缓存失效。通过独占内存、使其他处理器缓存失效,达到了 “指令重排序无法越过内存屏障” 的作用
汇编层面
又看到了lock addl $0x0,(%rsp)
指令,熟悉的配方熟悉的味道,和上面 2.3 章中的步骤 5 一摸一样,其实这里就是步骤 5 中代码的体现。
volatile的用法
volatile通常被比喻成”轻量级的synchronized”,也是Java并发编程中比较重要的一个关键字。和synchronized不同,volatile是一个变量修饰符,只能用来修饰变量。无法修饰方法及代码块等。
volatile的用法比较简单,只需要在声明一个可能被多线程同时访问的变量时,使用volatile修饰就可以了。
volatile特性
让其他线程能够马上感知到某一线程多某个变量的修改(两种作用对应的实现原理见总结):
- 保证可见性
- 保证有序性
- volatile不能保证原子性
线程写volatile变量的过程:
- 改变线程工作内存中volatile变量副本的值
- 将改变的副本的值从工作内存中刷新到主内存中
线程读volatile变量的过程:
- 从主内存中读取volatile变量的最新值到工作内存中
- 从工作内存中读取volatile变量的副本
保证可见性
保证不同线程对某个变量完成操作后结果及时可见,即该共享变量一旦改变所有线程立即可见
- Code
- 不加volatile,没有可见性,程序无法停止
- 加了volatile,保证可见性,程序可以停止
public class VolatileSeeDemo {
/**
* t1 -------come in
* main 修改完成
* t1 -------flag被设置为false,程序停止
*/
static volatile boolean flag = true;
public static void main(String[] args) {
new Thread(() -> {
System.out.println(Thread.currentThread().getName() + "\t-------come in");
while (flag) {
}
System.out.println(Thread.currentThread().getName() + "\t-------flag被设置为false,程序停止");
}, "t1").start();
try {
TimeUnit.SECONDS.sleep(2);
} catch (InterruptedException e) {
e.printStackTrace();
}
//更新flag值
flag = false;
System.out.println(Thread.currentThread().getName() + "\t 修改完成");
}
}
- volatile变量的读写过程(了解即可)
没有原子性
volatile变量的符合操作不具有原子性
- 对于voaltile变量具备可见性,JVM只是保证从主内存加载到线程工作内存的值是最新的,也仅仅是数据加载时是最新的。但是多线程环境下,“数据计算”和“数据赋值”操作可能多次出现,若数据在加载之后,若主内存volatile修饰变量发生修改之后,线程工作内存的操作将会作废去读主内存最新值,操作出现写丢失问题。即各线程私有内存和主内存公共内存中变量不同步,进而导致数据不一致。由此可见volatile解决的是变量读时的可见性问题,但无法保证原子性,对于多线程修改主内存共享变量的场景必须加锁同步。
- 至于怎么去理解这个写丢失的问题,就是再将数据读取到本地内存到写回主内存中有三个步骤:数据加载—->数据计算—->数据赋值,如果第二个线程在第一个线程读取旧值与写回新值期间读取共享变量的值,那么第二个线程将会与第一个线程一起看到同一个值,并执行自己的操作,一旦其中一个线程对volatile修饰的变量先行完成操作刷回主内存后,另一个线程会作废自己的操作,然后重新去读取最新的值再进行操作,这样的话,它自身的那一次操作就丢失了,这就造成了 线程安全失败,因此,这个问题需要使用synchronized修饰以保证线程安全性。
- 结论:volatile变量不适合参与到依赖当前值的运算,如i++,i=i+1之类的,通常用来保存某个状态的boolean值或者int值,也正是由于volatile变量只能保证可见性,在不符合以下规则的运算场景中,我们仍然要通过加锁来保证原子性:
- 运算结果并不依赖变量的当前值,或者能够确保只有单一的线程修改变量的值
- 变量不需要与其他的状态变量共同参与不变约束
- 面试回答为什么不具备原子性:举例i++的例子,在字节码文件中,i++分为三部,间隙期间不同步非原子操作
- 对于volatile变量,JVM只是保证从主内存加载到线程工作内存的值是最新的,也就是数据加载时是最新的,如果第二个线程在第一个线程读取旧值和写回新值期间读取i的域值,也就造成了线程安全问题。
指令禁重排
- 在每一个volatile写操作前面插入一个StoreStore屏障—>StoreStore屏障可以保证在volatile写之前,其前面所有的普通写操作都已经刷新到主内存中。
- 在每一个volatile写操作后面插入一个StoreLoad屏障—>StoreLoad屏障的作用是避免volatile写与后面可能有的volatile读/写操作重排序
- 在每一个volatile读操作后面插入一个LoadLoad屏障—>LoadLoad屏障用来禁止处理器把上面的volatile读与下面的普通读重排序
- 在每一个volatile读操作后面插入一个LoadStore屏障—>LoadTore屏障用来禁止处理器把上面的volatile读与下面的普通写重排序
- 案例说明(volatile读写前或后加了屏障保证有序性):
如何正确使用volatile
- 单一赋值可以,但是含复合运算赋值不可以(i++之类的)
- volatile int a = 10;
- volatile boolean flag = true;
- 状态标志,判断业务是否结束
- 作为一个布尔状态标志,用于指示发生了一个重要的一次性事件,例如完成初始化或任务结束
- 开销较低的读,写锁策略
- 当读远多于写,结合使用内部锁和volatile变量来减少同步的开销
- 原理是:利用volatile保证读操作的可见性,利用synchronized保证符合操作的原子性
- DCL双端锁的发布
- 问题描述:首先设定一个加锁的单例模式场景
- 在单线程环境下(或者说正常情况下),在“问题代码处”,会执行以下操作,保证能获取到已完成初始化的实例:
- 隐患:在多线程环境下,在“问题代码处”,会执行以下操作,由于重排序导致2,3乱序,后果就是其他线程得到的是null而不是完成初始化的对象,其中第3步中实例化分多步执行(分配内存空间、初始化对象、将对象指向分配的内存空间),某些编译器为了性能原因,会将第二步和第三步重排序,这样某个线程肯能会获得一个未完全初始化的实例:
- 多线程下的解决方案:加volatile修饰
- 问题描述:首先设定一个加锁的单例模式场景
Volatile的适用场景
使用volatile修饰的变量最好满足以下条件:
1.运算结果并不依赖变量的当前值,或者能够确保只有单一的线程会修改变量的值。
- 不满足:num++、count = count * 5
- 满足:boolean值变量,记录温度变化的变量等等
2.该变量没有包含在具有其他变量的不变式中
- 不满足:low < up
如果满足以上的条件的任意一个,就可以不用synchronized,用volatile就可以。一般的应用场景很多会不满足其中一个,所以volatile的使用没有synchronized这么广泛。
这里举几个比较经典的场景:
状态标记量(开关模式),就是前面引入中重排序问题的例子.
public class ShutDowsnDemmo extends Thread{
private volatile boolean started=false;
@Override
void run() {
while(started){
dowork();
}
}
public void shutdown(){
started=false;
}
}
状态标记两可以用作某种操作的开关,一个作业线程在关闭状态无法执行,它的开关标记是一个volatile修饰的变量,另一个线程修改该变量值,作业线程就能立刻感知到开关被修改,就能进入运行状态。
一次性安全发布.双重检查锁定问题(单例模式的双重检查 double-checked-locking DCL).
public class Singleton {
private volatile static Singleton instance;
public static Singleton getInstance(){
if(instance==null){
synchronized (Singleton.class){
instance=new Singleton();
}
}
return instance;
}
}
- 独立观察.如果系统需要使用最后登录的人员的名字,这个场景就很适合.
- 开销较低的“读-写锁”策略.当读操作远远大于写操作,可以结合使用锁和volatile来提升性能.
Volatile不适用的场景
不满足前面所讲的Volatile适用场景的条件的话,就说明需要保证原子性,就需要加锁(使用synchronized、lock或者java.util.concurrent中的Atomic原子类)来保证并发中的原子性。
volatile不适合复合操作
例如,inc++不是一个原子性操作,可以由读取、加、赋值3步组成,所以结果并不能达到10000。
public class Test {
public volatile int inc = 0;
public void increase() {
inc++;
}
public static void main(String[] args) {
final Test test = new Test();
for(int i=0;i<10;i++){
new Thread(){
public void run() {
for(int j=0;j<1000;j++)
test.increase();
};
}.start();
}
while(Thread.activeCount()>1) //保证前面的线程都执行完
Thread.yield();
System.out.println(test.inc);
}
}
解决方法:
- 采用synchronized
- 采用Lock
- 采用java并发包中的原子操作类,原子操作类是通过CAS循环的方式来保证其原子性的
volatile与synchronized的区别
使用上的区别
Volatile只能修饰变量,使用范围较小;synchronized可以修饰方法和语句块,作用域可以是对象或者类,适用范围更广
对原子性的保证
synchronized可以保证原子性,Volatile不能保证原子性
对可见性的保证
都可以保证可见性,但实现原理不同
Volatile对变量加了lock,synchronized使用monitorEnter和monitorexit monitor JVM
对有序性的保证
Volatile能保证有序,synchronized可以保证有序性,但是代价(重量级)并发退化到串行
性能上的区别
synchronized是靠加锁实现的,引起阻塞
volatile是靠Lock指令实现的,不需要加锁,不会引起阻塞
性能上volatile比synchronized要好,volatile 的读性能消耗与普通变量几乎相同,但是写操作稍慢,因为它需要在本地代码中插入许多内存屏障指令来保证处理器不发生乱序执行。但是volatile的性能比synchronized加锁好很多。
本章最后的小总结
volatile是轻量级同步机制,与synchronized相比,他的开销更小一些,同时安全性也有所降低,在一些特定的场景下使用它可以在完成并发目标的基础上有一些性能上的优势.但是同时也会带来一些安全上的问题,且比较难以排查,使用时需要谨慎.volatile并不能保证操作的原子性,想要保证原子性请使用synchronized关键字加锁.
volatile可见性
volatile关键字保证可见性: 对一个被volatile关键字修饰的变量
1 写操作的话,这个变量的最新值会立即刷新回到主内存中
2 读操作的话,总是能够读取到这个变量的最新值,也就是这个变量最后被修改的值
3 当某个线程收到通知,去读取volatile修饰的变量的值的时候,线程私有工作内存的数据失效,需要重新回到主内存中去读取最新的数据。
volatile没有原子性
volatile禁重排
凭什么我们Java写了一个volatile关键字,系统底层加入内存屏障?两者的关系如何勾搭?
内存屏障是什么?
内存屏障能干吗?
- 阻止屏障两边的指令重排序
- 写操作时加入屏障,强制将线程私有工作内存的数据刷回主物理内存
- 读操作时加入屏障,线程私有工作内存的数据失效,重新回到主物理内存中获取最新值
内存屏障四大指令
3句话总结
- volatile写之前的操作,都禁止重排序到volatile之后
- volatile读之后的操作,都禁止重排序到volatile之前
- volatile写之后volatile读,禁止重排序