1.什么是底层原理?
从java代码->CPU指令
步骤
1.编写Java代码,是*.java文件
2.通过javac编译后,根据*.java文件生成对应的*.class文件,这个文件里面是字节码.
3.JVM根据平台的不同,根据需求将字节码解释为不同的机械指令
到不同的CPU上执行.
4.机器指令
可以直接在CPU上执行,也就是最终的的程序执行
结果
根据第3点可知,JVM会将相同的字节码,根据平台支持和规范的不同翻译为不同的机器指令,所以如果单单如此,无法保证并发安全的效果一致.
解决
通过定义一套规范、原则(JMM),来统一操作,避免CPU异同带来的执行结果不稳定性.实现同一套代码,在各个平台执行几乎都是一样的效果.
2.JVM内存结构 VS Java内存模型 VS Java对象模型
JVM内存结构也就是JVM的运行时数据区域.
分为五大部分:
堆(线程共有):在虚拟机启动时创建,Java世界几乎所有对象实例都在这里分配内存.且是运行时动态分配,不用事先告诉编译器,可通过参数配置(如最大值,最小值).
方法区(线程共有):存储类信息和常量信息
程序计数器(线程私有):可以看做当前线程执行字节码的行号指示器.它是程序控制流的指示器,分支(if-else,switch-case)、循环(while,for)、跳转(continue,break,return,标签:_continue标签)、异常处理(try-catch-finally)、线程恢复等基础功能都需要依赖这个计数器来完成.
虚拟机栈(线程私有):存在局部变量表存储基本数据类型和对象的引用,编译时确定大小(变量槽数量),运行时大小不会改变.
除此之外,还有操作数栈,动态链接和方法出口等信息.
每一个方法被调用直至执行完毕的过程,就是虚拟机栈帧入栈到出栈的过程.
本地方法栈(线程私有):与虚拟机栈相似,区别只是虚拟机栈为虚拟机运行java方法(也就是字节码)服务,本地方法栈则是为运行虚拟机使用本地(Native)方法服务.
对于本地方法,<<Java虚拟机规范>>对本地方法使用的语言、使用方式与数据结构没有任何强制规定.
Java内存模型
前言
JMM也叫Java memory model,是Java内存模型的缩写,是一组规范
需要各个JVM的实现来遵守JMM规范,以便于开发者可以利用这些规范,更方便的开发多线程程序.
如果没有这个规范,代码在各个JVM多种规则的重排序实现优化后,将导致在不同虚拟机上运行结果不同
为什么需要Java内存模型
如C语言
不存在内存模型的概念,这导致执行结果依赖于处理器,相同的保护措施(一套代码)在不同的处理器有时会出现不一样的结果,这带来的后果就是无法保证并发安全
因此就需要一个标准,来让多线程运行的结果可预期
是工具类和关键字的原理
volatile、synchronized、Lock等的原理都是JMM
如果没有JMM,则需要我们自己指定什么时候使用内存栅栏(工作内存和主内存拷贝和同步)等.
最重要的三点
重排序、可见性、原子性
Java对象模型
指代Java通过new关键字生成的对象在JVM虚拟机中的数据结构
分为两大块:
1.第一部分是头部,用于存储JVM操作这个对象需要的数据,不包含具体的Java代码定义的数据部分.
头部也分两块,第一部分称为Mark Word,存储对象自身运行时数据.
第二部分存储这个对象的类型指针(klass),用于确定这个对象是哪个类的实例,存储这个类的元数据(instanceKlass,存储在MetaSpace).
指针中存在一个java_mirror字段,用于定位这个实例的Java类对应的Class实例,作为存储在方法区的这个类各种数据的访问入口,给Java代码用来进行反射操作.类变量(static 变量)也存储在这个对象中(JDK8及之后).
2.第二部分是身体,存储java对象的成员变量.
3.JMM是什么
是一组规范,需要各个JVM的实现来遵守JMM规范,以便于开发者可以利用这些规范,更方便的开发多线程程序
如果没有这个JMM(Java内存模型)规范,各个VM实现不同,优化(重排序)的考量不同,导致相同代码在不同的VM上运行结果不一.
4.重排序(非有序性)
展示
定义:指令执行的顺序和java文件中代码书写的顺序不同.
触发条件:只有在程序最终执行结果与在严格串行环境下执行结果相同,才能发生重排序
原因:而Java语言规范要求JVM在线程中维护一种类似串行的语义:只有在程序最终执行结果与在严格串行环境下执行结果相同,才能发生重排序(参考<<Java并发编程实战16.1什么是内存模型,为什么需要它>>)
通过两个线程交替执行,对4个变量赋值,根据赋值的时机不同,可能得到以下三种情况
public class OutOfOrderExecutionOrigin {
private static int x, y;
private static int a, b;
public static void main(String[] args) throws InterruptedException {
Thread first = new Thread(OutOfOrderExecutionOrigin::command);
Thread second = new Thread(OutOfOrderExecutionOrigin::command2);
second.start(); first.start();
first.join();second.join();
System.out.println("x:" + x + ",y:" + y);
}
public static void command() {
a = 1;
x = b;
}
public static void command2() {
b = 1;
y = a;
}
}
结果
可能执行顺序b=1
->y=a
->a=1
->x=b
x:1,y:0
可能执行顺序a=1
->x=b
->b=1
->y=a
x:0,y:1
可能执行顺序a=1
->x=b
->b=1
->y=a
x:1,y:1
但当重排序发生时,因为代码的乱序执行可能会产生x=0,y=0
的情况
/**
* 重排序
* "直到某个条件达到才停止"
*/
public class OutOfOrderExecution {
private static volatile int count = 0;
private static ExecutorService service = Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors());
public static void main(String[] args) throws InterruptedException {
service.submit(OutOfOrderExecution::all);
System.out.println(count);
}
private static void all() {
try {
CountDownLatch countDownLatch = new CountDownLatch(2);
CyclicBarrier cyclicBarrier = new CyclicBarrier(2);
Args args = new Args();
Thread first = new Thread(() -> {
OutOfOrderExecution.command1(args, countDownLatch, cyclicBarrier);
});
Thread second = new Thread(() -> {
OutOfOrderExecution.command2(args, countDownLatch, cyclicBarrier);
});
first.start();
second.start();
countDownLatch.await();
System.out.println("第" + count + "次结果:" + "x=" + args.x + " ,y=" + args.y);
if (/*(args.x == 1 && args.y == 1) ||*/ (args.x == 0 && args.y == 0)) {
service.shutdown();
} else {
service.submit(OutOfOrderExecution::all);
}
count++;
} catch (Exception e) {
e.printStackTrace();
}
}
private static void command1(Args args, CountDownLatch countDownLatch, CyclicBarrier cyclicBarrier) {
try {
cyclicBarrier.await();
} catch (Exception e) {
e.printStackTrace();
}
args.a = 1;
args.x = args.b;
countDownLatch.countDown();
}
private static void command2(Args args, CountDownLatch countDownLatch, CyclicBarrier cyclicBarrier) {
try {
cyclicBarrier.await();
} catch (Exception e) {
e.printStackTrace();
}
args.b = 1;
args.y = args.a;
countDownLatch.countDown();
}
static class Args {
int x, y, a, b;
}
}
结果 可能执行顺序x=b
->b=1
–>y=a
->a=1
第157364次结果:x=0 ,y=0
好处
优化前
优化后
可以很明显的看到减少了重复的指令调用,提高了处理速度
三种情况
编译器优化
包括JVM,JIT编译器等
编译器在编译时发现可以把多个相同的操作合并,且合并重排后操作在单线程严格串行情况下的执行结果不变,就会进行重排
CPU指令重排
和编译器执行逻辑类似,通过乱序的作用达到提高效率的目的,只不过针对的操作更底层一些
内存的"重排序"
并不是真的重排,而是因为JMM中主存和线程本地缓存的存在导致线程A的修改线程B无法实时看到,引出的可见性问题,使得执行结果看上去好像操作重排序了.
5.可见性
代码演示分析
由于两个线程都存在线程本地缓存,一个线程可能无法及时看到另一个线程的改变/看不全,导致结果的错误
public class Visibility {
int a=1;
int b=2;
public static void main(String[] args) {
for (;;) {
Visibility visibility = new Visibility();
//change
new Thread(()->{
visibility.a=3;
visibility.b=visibility.a;
}).start();
//print
new Thread(()->{
System.out.println("a:"+visibility.a+",b:"+visibility.b);
}).start();
}
}
}
在这份代码中,由于线程执行顺序的问题,可能的结果分别为
print()执行完毕后change()才执行.结果输出为a:1,b:2
change()执行完毕后print()才执行.结果输出为a:3,b:3
change()执行到visibility.a=3;后print()执行并执行完毕.结果输出为a:3,b:2
但由于可见性的问题,可能change()执行完毕,但print()并没有感知到change()造成的全部改变,a修改的结果还没有同步过来,导致结果可能为
a:1,b:3
案例分析
通过volatile处理
为什么会有可见性问题
寄存器的功能是存储二进制代码,它是由具有存储功能的触发器组合起来构成的。
现代的L1 cache分为指令cache和数据cache,L2 cache也是每个CPU独占的了,而L3 cache则是每个插槽上的所有核心共享(一个插槽可能有多个核心),内存为所有核心共享
可以看到当CPU核心操作完数据后,并不是立刻返回RAM(主内存)的,修改的结果可能暂时留在cache中存在,这样当core1修改后,core4如果在多级cache都没有命中的情况下读取RAM,但这时core1修改的值还在L1 cache中,那么就会造成可见性问题
这也是为什么需要JMM的原因,通过一套规范来定义cache中的数据什么时候必须刷新到缓存中,来避免因不同CPU架构/性能差异导致的相同代码结算结果存在差异
总结
CPU的多级缓存,导致读的数据过期
因为CPU和RAM(主内存)的硬件执行效率差距过大.为了屏蔽两者间的执行效率问题,避免高速的CPU被低速的RAM拖慢执行效率,添加了作为高速缓存的Cache层
线程间对共享变量的可见性问题并不是直接由多核处理引发的,而是由于多缓存.
如果所有核心共用一个缓存,那也就不存在内存可见性问题了
每个核心都会将自己需要的数据读取到独占缓存中,数据修改后也是写入到缓存,然后当缓存满了或触发主动刷入指令后刷入到主内存中.所以有的核心会读取到过期的一个过期的值
JMM的主内存和工作内存
Java作为高级语言,屏蔽了这些底层细节,通过JMM定义了一套读写内存数据的规范
.虽然因此不用再关心高速缓存(cache层)的问题,但是,JMM也抽象出了一套本体内存和主内存的概念
这个本地内存并不是指一块真正分配给每个线程的内存,而是JMM对于CPU架构中寄存器(registers)加上Cache层(L1、L2)的一个抽象
两者的关系
JMM有如下规定
1.所有变量都存储在主内存中,同时每个线程也有自己独占的工作内存,工作内存中的变量内容是主内存的拷贝
2.线程不能直接读写主内存中的变量,只能操作自己工作内存中的变量,然后再同步到主内存中.
3.主内存由多个线程共享,但线程间不共享工作内存,如果线程间需要通信,必须借助主内存中转完成
所有共享变量都是存储在主内存中,每个线程都有自己的本地内存,而且线程读写共享数据也是通过本地内存交互的,所以才导致了可见性问题.
Volatile关键字并不是让每个线程直接在主内存中操作共享变量,而是在每次从工作内存
使用这个变量前都会先刷新工作内存
(从主内存中读取),同时在变量每次写完后直接刷新工作内存
中的缓存结果到主内存中.
Happens-Before原则
A_Happens-Before_B并不是指的A一定执行在B之前,而是如果A执行在B之前,由于happens-before规则的保障哪怕两个操作发生在不同的线程中B也一定能够感知到A的执行结果
什么是happens-before
happens-before规则是用来解决可见性问题的:在时间上,动作A发生在动作B之前,B保证能看到A,这就是Happens-before
public class Visibility {
volatile int a=1;
volatile int b=2;
public static void main(String[] args) {
for (;;) {
Visibility visibility = new Visibility();
//change
new Thread(()->{
visibility.a=3;
visibility.b=visibility.a;
}).start();
//print
new Thread(()->{
System.out.println("a:"+visibility.a+",b:"+visibility.b);
}).start();
}
}
}
通过volatile可以确保如果print()执行于change()完成后,则change()执行的结果print()一定能感知到
什么不是Happens-before
两个线程间没有相互配合的机制,导致线程A执行的结果的赋值在时间上虽然先于B的读取,却并不能保证总被线程B看到,这就不具备happens-before
public class Visibility {
int a = 1;
int b = 2;
public static void main(String[] args) {
for (; ; ) {
Visibility visibility = new Visibility();
//change
new Thread(() -> {
visibility.a = 3;
visibility.b = visibility.a;
}).start();
//print
new Thread(() -> {
System.out.println("a:" + visibility.a + ",b:" + visibility.b);
}).start();
}
}
}
在这种情况下,哪怕print()在change()执行完毕后再执行,也不一定能打印出a:3,b:3
,而是可能因感知不完全出现上面的a:1,b:3
情况
happens-before的规则有哪些?
A_Happens-Before_B并不是指的A一定执行在B之前,而是如果A执行在B之前,由于happens-before规则的保障哪怕两个操作发生在不同的线程中B也一定能够感知到A的执行结果
1.单线程规则
2.锁操作(synchronized和lock)
3.volatile变量
4.线程启动
5.线程join
6.传递性
7.中断
8.构造方法
9.工具类的happens-before原则
1.线程安全容器的get一定能看到在此之前的put等存入动作
2.CountDownLatch
3.Semaphore
4.Future
5.线程池
6.CyclicBarrier
1.单线程
2.synchronized和lock
3.volatile
写volatile变量发生在read语句之前,则读volatile变量语句,一定能读到之前写的结果
4.线程启动
线程A中B.start()的之前的所有statement的操作,在线程B执行run()中的语句都能感知到哪些statement操作的结果
5.线程join
6.传递性:如果存在happens-before(A,B)且happens-before(B,C),则一定存在happens-before(A,C)
7.中断:A线程被B线程调用A.interrupt,那么B中的A.interrupt一定happens-before
B中的A.interrupt语句之后的检测中断(isInterrupt())语句或者A中的catch(InterruptedException e){statement}.
8.构造方法:对象构造方法的最后一行指令happens-before与finalize()的第一行指令
9.工具类的happens-before
A_Happens-Before_B并不是指的A一定执行在B之前,而是如果A执行在B之前,由于happens-before规则的保障哪怕两个操作发生在不同的线程中B也一定能够感知到A的执行结果
1.线程安全容器的get一定能看到在此之前的put等存入动作
2.countDownLatch countDownLatch.countDown() happens-before countDownLatch.countDown()
根据上述规则对之前的代码进行修改
public class Visibility {
int a = 1;
volatile int b = 2;
public static void main(String[] args) {
for (; ; ) {
Visibility visibility = new Visibility();
//change
new Thread(() -> {
visibility.a = 3;
visibility.b = visibility.a;
}).start();
//print
new Thread(() -> {
System.out.println(",b:" + visibility.b + "a:" + visibility.a);
}).start();
}
}
}
可以看到现在只将b声明为volatitle变量并将b的读操作放在a的读操作之前,就可以达到a和b都声明为volatile的效果
也就是如果b的写操作先于b的读操作之前进行,由于happens-before的单线程规则,可得出a=3
happens-before b的写操作,b的读操作先于a的读操作.则根据happens-before的传递性规则,存在a=3先于a的读操作
所以只要当b的写操作在b的读操作之前执行,print()就一定可以感知到change()的所有改变
volatile变量的happens-before规则的实现原理
是由于每次读之前都会先刷新工作内存的数据(从主内存中读最新的),每次写之后都会同步工作内存的结果到主内存.
volatile变量的happens-before传递性规则的实现原理
volatile变量赋值操作编译出的汇编代码会在最后添加一个lock addl $0x0,(%esp)
指令重排序是指处理器采用了允许将多条指令,不按程序规定的顺序分开发送给各个相应的电路单元进行处理。但并不是说指令任意重排,处理器必须能正确处理指令依赖情况,保障程序能得出正确的执行结果.
例如指令1将A地址的值加1,指令2将A地址值乘10,指令3将B地址值加1 指令1和2的顺序不能变,因为(x+1)10和x10+1的结果是两个不同的值,但是指令3因为没有依赖关系,可以任意重排到两者值前或之间.
而这个lock指令会将本处理器的缓存写入到内存中,也会让别的处理器或者内核无效化其的缓存。(实践发现,只要有volatile变量操作执行过,汇编代码哪怕没有lock(除赋值外的其它的volatile变量操作汇编代码没有lock,赋值操作包括x++),依旧会产生和有lock汇编代码运行下相同的效果)
使得这个指令前所有指令都与这个指令产生了依赖(执行到这个指令时将之前指令操作的缓存结果写入内存),意味着执行完后,所有之前的操作都已执行完毕(因此不会将当前指令对应的代码重排序到之前的所有操作之前或之间)。
所以这个指令后的所有指令都不能重排到这个指令前(因为lock屏障之前的指令对于之后的指令是已经执行完的,形成了之后的指令依赖之前指令的结果的依赖)。
synchronized关键字happens-before规则及其传递性规则的实现原理
同步块的可见性是由"对于一个变量执行unlock(通过monitorexit调用)操作之前,必须先把此变量同步回主内存中(执行store、write操作)"这条规则来实现的.(和volatile一样同样是对当前工作内存进行同步)
这是JMM定义的规则,详情参见**<<深入理解Java虚拟机:JVM高级特性与最佳实践(第3版) - 周志明(2019)>>**的12.3.2章节
这样只要是同一个锁的synchronized代码块退出前所作的所有操作,对于之后获得锁的代码块代码一定能感知到.
6.volatile
前言
volatile的功能
可见性:通过使用前刷新缓存,写后立刻同步缓存结果到内存中保证
重排序优化的前提就是单一线程下修改顺序不会影响执行结果
禁止重排序:通过插入内存屏障操作使得前面的代码和内存屏障操作存在依赖来保证
volatile是一种同步机制,比synchronized和Lock相关类更轻量.因为volatile不会涉及到上下文切换等开销很大的行为,同时读写也是无锁的,没有获取锁和释放锁的开销
如果一个变量被修饰为volatile,那么JVM就知道了这个变量可能会被并发修改,会作一些优化处理,例如禁止一定范围的重排序
volatile开销小,也就意味着功能相对较少.对比synchronized来说,volatile无法做到对变量的原子性保护,因此仅在有限的场景下发挥作用
不适用
1.a++
不用volatile的情况
public class NoVolatileAutoIncrement {
static int a = 0;
public static void main(String[] args) throws InterruptedException {
Thread first = new Thread(NoVolatileAutoIncrement::increment);
Thread second = new Thread(NoVolatileAutoIncrement::increment);
first.start();second.start();
first.join();second.join();
System.out.println(a);
}
public static void increment() {
for (int i = 0; i < 10000; i++)
a++;
}
}
结果
第一次
14181
第二次
14783
第三次
17379
可以看到由于多线程之间自增操作的非原子性,会出现有线程拿着过期值计算后覆盖掉其它线程计算的值的情况
添加volatile
public class VolatileAutoIncrement {
static volatile int a = 0;
public static void main(String[] args) throws InterruptedException {
for (int i=0;i<4;i++) {
Thread first = new Thread(VolatileAutoIncrement::increment);
Thread second = new Thread(VolatileAutoIncrement::increment);
first.start();second.start();
first.join();second.join();
System.out.println(a);
a=0;
}
}
public static void increment() {
for (int j = 0; j < 10000; j++) a++;
}
}
结果
12537
14049
12933
14931
可以看到没什么区别,同样无法计算出正确的结果.
原因
这是因为a++
哪怕在字节码层面也不是一个原子操作,而是由多个操作复合而成,所以哪怕通过volatile去除了可能的缓存的干扰,依旧无法达到正确的效果
javap -v -c VolatileAutoIncrement.class
public static void increment();
descriptor: ()V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=0, args_size=0
0: getstatic #7 // Field a:I
3: iconst_1
4: iadd
5: putstatic #7 // Field a:I
8: return
LineNumberTable:
line 18: 0
line 19: 8
2.变量需要与其它的状态变量共同参与不变约束
表达式使用了两个及以上的变量,就无法保证正确性,也就是说不变式会失效
//if(表达式)//也可以叫不变式
if(a&b&c)
这种情况下,哪怕a,b,c
三者任意一个为volatile变量,也可能因为其它变量存在线程安全的修改情况,导致最终判断结果有误
3.访问变量时,因为其它原因需要加锁
如果需要添加锁的话,就没必要使用volatile关键字了,锁内的代码会保证原子性,同时在unlock和lock时都会刷新缓存以保证可见性,对于少量重排序的情况,可以通过强逻辑依赖禁止.
例如在这种情况下,由于没有依赖,可能因为重排序的原因导致在数据没有插入或者插入失败的情况下,日志就已经打印了
insertNewData(data);
logDiskInfo(data);
修改一下,使得相互依赖,这样就不用担心重排序的问题了,因为重排序优化的前提就是单一线程下修改顺序不会影响结果
if(insertNewData(data)){
logDiskInfo(data);
}
boolean insertNewData(Object data){
//正常执行
return true;
//执行出错
return false;
}
适用场景
根据上述情况可以知道,由于volatile只能保证可见性(和禁止重排序),所以在除了下述情况外在需要确保原子性的场景中依旧需要额外的加锁.
运算结果并不依赖变量的当前值,或者能够保证只有单一线程修改变量的值
volatile boolean shutdownRequested;
public void shutdown() {
shutdownRequested = true;
}
public void doWork() {
while (!shutdownRequested) {
// 代码业务逻辑
}
}
作为触发器实现轻量级的同步
因为volatile变量的读写都会将当前工作内存刷新,所以哪怕是非volatile的变量也会在volatile变量读写导致的刷新中一起刷新.
在volatile变量写后,在同一工作内存中的其它非volatile变量缓存结果也会刷新到主内存中.
在volatile变量读后,在同一工作内存中的其它非volatile变量缓存结果会失效再使用时也会重新从主内存中读取.
禁止指令重排序
Map configOptions;
char[] configText;
// 此变量必须定义为volatile
volatile boolean initialized = false;
//假设以下代码在线程A中执行
//模拟读取配置信息,当读取完成后
//将initialized设置为true,通知其它线程配置可用
configOptions = new HashMap();
configText = readConfigFile(fileName);
processConfigOptions(configText, configOptions);
initialized =true;
//假设以下代码在线程B中执行
//等待initialized为true,代表线程A已经把配置信息初始化完成
while(!initialized){
sleep();
}
//使用线程A中初始化好的配置信息
doSomethingWithConfig();
volatile变量的写操作会比正常变量写操作在汇编代码层面多添加一个用于添加内存屏障的操作,用来保证后面的指令不会被重排序到前面,也就是当initialized =true;时,前面的所有代码一定已经执行完成.
避免initialized =true;被重排序到 processConfigOptions(configText, configOptions);
之前,导致线程B提前苏醒,使用错误的信息.
作用总结
volatile属性的读写都是无锁的,它不能替代synchronized,因为它没有提供原子性和互斥性.且因为无锁,不需要花费时间获取和释放锁,所以是低成本的
volatile只能定义在变量上
可以用于作触发器和不依赖当前值的赋值操作
通过volatile的写操作附加的内存屏障,禁止当前写操作及之前所有语句的重排序优化
可见性使得不同线程位于同一volatile变量写操作之后的同一volatile读操作获取的一定是最新值
volatile可以使得long和double的赋值是原子的.
7.原子性
什么是原子性
一系列的操作,要么全部执行成功,要么全部执行不成功,不会出现部分成功部分失败的情况,是不可分割的.
i++不是原子性的
通过synchronized实现原子性,因为synchronized代码块确保了只有获取锁的那个线程能够操作这个代码块,这样只要多个线程操作这个代码块时使用的是同一把锁,就可以确保每次只有一个线程在操作这块代码不被其它线程干扰.
Java中的原子操作
除了long和double之外的基本数据类型(byte,short,int,float,char,boolean)的访问、读写都是具备原子性的(<<深入理解Java虚拟机:JVM高级特性与最佳实践(第3版)- 周志明(2019)>> 12.3.5原子性、可见性与有序性)
所有reference的赋值操作,不管是32位还是64位机器(例如引用变量a赋值为引用变量x)
java.concurrent.Atomic.*包中所有类的原子操作
long和double的原子性
17.7. Non-Atomic Treatment of double
and long
原文
For the purposes of the Java programming language memory model, a single write to a non-volatile
long
ordouble
value is treated as two separate writes: one to each 32-bit half. This can result in a situation where a thread sees the first 32 bits of a 64-bit value from one write, and the second 32 bits from another write.Writes and reads of volatile
long
anddouble
values are always atomic.Writes to and reads of references are always atomic, regardless of whether they are implemented as 32-bit or 64-bit values.
Some implementations may find it convenient to divide a single write action on a 64-bit
long
ordouble
value into two write actions on adjacent 32-bit values. For efficiency’s sake, this behavior is implementation-specific; an implementation of the Java Virtual Machine is free to perform writes tolong
anddouble
values atomically or in two parts.Implementations of the Java Virtual Machine are encouraged to avoid splitting 64-bit values where possible. Programmers are encouraged to declare shared 64-bit values as
volatile
or synchronize their programs correctly to avoid possible complications.
译文
为了Java编程语言内存模型的目的,对 non-volatile long或doubule值的单次写入被视为两次单独的写入:对每一半32位进行一次写入。这可能会导致一个线程从一次写中看到64位值的前32位,而从另一次写中看到第二个32位。
对volatile lone和double值的写和读总是原子性的。
对references 的写和读总是原子的,不管它们是实现为32位还是64位值。
一些实现可能会发现,将一个64位long值或double值的写操作分为两个相邻32位值的写操作是很方便的。为了提高效率,这种行为是特定于实现的;Java虚拟机的实现可以自由地原子地或分两部分执行对long值和double值的写入。
我们鼓励Java虚拟机的实现尽可能避免拆分64位值。鼓励程序员将共享的64位值声明为volatile,或正确地同步他们的程序以避免可能的并发症。
实际情况
不过这种读取到“半个变量”的情况是非常罕见的,经过实际测试[ 1] ,在目前主流平台下商用的64位Java虚拟机中并不会出现非原子性访问行为,但是对于32位的Java虚拟机,譬如比较常用的32位X86平台下的HotSpot虚拟机,对long类型的数据确实存在非原子性访问的风险。
从JDK9起,HotSpot增加了1个实验性的参数-XX:+AlwaysAtomicAccesses (这是JEP 188对Java内存模型更新的一部分内容)来约束虚拟机对所有数据类型进行原子性的访问。
而针对double类型,由于现代中央处理器中一般都包含专门用于处理浮点数据的浮点运算器(Floating Point Unit, FPU),用来专门处理单、双精度的浮点数据,所以哪怕是32位虚拟机中通常也不会出现非原子性访问的问题,实际测试也证实了这一点。笔者的看法是,在实际开发中,除非该数据有明确可知的线程竞争,否则我们在编写代码时一般不需要因为这个原因刻意把用到的long和double变量专门声明为volatile。
—<<深入理解Java虚拟机:JVM高级特性与最佳实践(第3版)- 周志明(2019)>>12.3.4
原子操作+原子操作!=原子操作
简单将多个原子操作组合在一起,不能确保整体依旧具备原子性,在两个原子操作之前可能出现其它线程的干扰