当前位置:网站首页>JVM内存模型之深究模型特征
JVM内存模型之深究模型特征
2022-08-01 07:38:00 【a_ittle_pan】
起笔
“人生当自强,人的一生,总会遇见挫折磨难,但人生没有过不去的坎,走过了,便是一种收获,便会让自己成长起来”
参考书籍:“深入理解Java虚拟机”
Double与Long变量的特殊规则
Java内存模型要求lock、unlock、read、load、assign、use、store、write这8个操作都具有原子性,但是对于64位的数据类型(long和double),在模型中特别定义了一条相对宽松的 规定:允许虚拟机将没有被volatile修饰的64位数据的读写操作划分为两次32位的操作来进行,即允许虚拟机实现选择可以不保证64位数据类型的load、store、read和write这4个操作的原子性,这点就是所谓的long和double的非原子性协定(Nonatomic Treatment ofdouble and long Variables)
–这里思考为什么64位的数据类型就可以不用保证其原子性,从操作系统和机组的方面去思考一下,你会有新的收获。
原子性、可见性与有序性
第一篇内存模型的文章中我提到这三种特性但没有细说,此章节主要来讲解,Java内存模型在并发过程中如何处理原子性、可见性和有序性:
原子性(Atomicity)
由Java内存模型来直接保证的原子性变量操作包括read、load、 assign、use、store和write,我们大致可以认为基本数据类型的访问读写是具备原子性的(例外就是long和double的非原子性协定)如果应用场景需要一个更大范围的原子性保证(经常会遇到),Java内存模型还提供了 lock和unlock操作来满足这种需求,尽管虚拟机未把lock和unlock操作直接开放给用户使用, 但是却提供了更高层次的字节码指令monitorenter和monitorexit来隐式地使用这两个操作,这两个字节码指令反映到Java代码中就是同步块——synchronized关键字,因此在synchronized块之间的操作也具备原子性
public class Demo1 {
public static void main(String[] args) {
synchronized (Demo1.class){
System.out.println("我被锁住了ing...");
}
}
}
public static void main(java.lang.String[]);
Code:
0: ldc #2 // class com/disaster/synchronize/Demo1
2: dup
3: astore_1
4: monitorenter
5: getstatic #3 // Field java/lang/System.out:Ljava/io/PrintStream;
8: ldc #4 // String 我被锁住了ing...
10: invokevirtual #5 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
13: aload_1
14: monitorexit
15: goto 23
18: astore_2
19: aload_1
20: monitorexit
21: aload_2
22: athrow
23: return
Exception table:
from to target type
5 15 18 any
18 21 18 any
}
可见性(Visibility)
可见性是指当一个线程修改了共享变量的值,其他线程能够立即得知这个修改。上文在讲解volatile变量的时候我们已详细讨论过这一点。Java内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值这种依赖主内存作 为传递媒介的方式来实现可见性的,无论是普通变量还是volatile变量都是如此,普通变量与 volatile变量的区别是,volatile的特殊规则保证了新值能立即同步到主内存,以及每次使用前立即从主内存刷新。因此,可以说volatile保证了多线程操作时变量的可见性,而普通变量则不能保证这一点
除了volatile之外,Java还有两个关键字能实现可见性,即synchronized和final。同步块的可见性是由“对一个变量执行unlock操作之前,必须先把此变量同步回主内存中(执行store、 write操作)”这条规则获得的,而final关键字的可见性是指:被final修饰的字段在构造器中一旦初始化完成,并且构造器没有把“this”的引用传递出去(this引用逃逸是一件很危险的事情,其他线程有可能通过这个引用访问到“初始化了一半”的对象),那在其他线程中就能看见final字段的值。如下代码,变量i与j都具备可见性,它们无须同步就能被其他线程正确访问
final修饰的变量是不可变的,所谓的不可变就是它的值只要赋值之后就永远都是那个值,所以我们只要保证在它赋值的过程是线程安全的,那么就可以保证在赋值之后的变量可见性,在多线程下这个final修饰的变量的值不会变,不管什么时候、哪个线程、工作内存中还是主内存中读到的值是一致的。
public class Demo1 {
private final Integer i;
private static final Integer j;
static {
j = 1;
}
public Demo1(Integer i) {
this.i = i;
}
}
有序性(Ordering)
Java内存模型的有序性在前面讲解volatile时也详细地讨论过 了,Java程序中天然的有序性可以总结为一句话:如果在本线程内观察,所有的操作都是有 序的;如果在一个线程中观察另一个线程,所有的操作都是无序的。前半句是指“线程内表 现为串行的语义”(Within-Thread As-If-Serial Semantics),后半句是指“指令重排序”现象 和“工作内存与主内存同步延迟”现象
Java语言提供了volatile和synchronized两个关键字来保证线程之间操作的有序性,volatile 关键字本身就包含了禁止指令重排序的语义,而synchronized则是由“一个变量在同一个时刻 只允许一条线程对其进行lock操作”这条规则获得的,这条规则决定了持有同一个锁的两个同 步块只能串行地进入
讲解完这三种特性,再用一种通俗一点的语言进行一个总结:
- 原子性:保证指令不会受到线程上下文切换的影响(线程切换时保证数据的一致)
- 可见性:保证指令不会受 cpu 缓存的影响(由于CPU三级缓存导致的并发时变量的不可见)
- 一致性:保证指令不会受 cpu 指令并行优化的影响(保证物理层面、软件层面的优化后的执行过程不受影响)
其实synchronized虽然不能禁止指令重排,但是它通过“一个变量在同一个时刻只允许一条线程对其进行lock操作”这条规则能保证有序性,这条规则决定了持有同一个锁的两个同 步块只能串行地进入,也就是说被synchronized持有锁之后,只能有一个线程能访问到共享变量,因为此时的共享变量永远是在单线程下执行, 那么多线程下由于指令重排导致的安全问题就可以迎刃而解。
先行发生原则(Happens-Before)
如果Java内存模型中所有的有序性都仅仅靠volatile和synchronized来完成,那么有一些操 作将会变得很烦琐,但是我们在编写Java并发代码的时候并没有感觉到这一点,这是因为 Java语言中有一个“先行发生”(happens-before)的原则。这个原则非常重要,它是判断数据是否存在竞争、线程是否安全的主要依据,依靠这个原则,我们可以通过几条规则一揽子地 解决并发环境下两个操作之间是否可能存在冲突的所有问题。
那么先行发生原则指的是什么呢?
先行发生是Java内存模型中定义的两项操作之间的顺序关系,如果说操作A先行发生于操作B,其实就是说在发生操作B之前,操作A产 生的影响能被操作B观察到,“影响”包括修改了内存中共享变量的值、发送了消息、调用了方法等。
举个例子说明:
import java.time.Instant;
import java.util.concurrent.CountDownLatch;
public class Demo1 {
private static Integer i = null;
private static Integer k = null;
public static void main(String[] args) throws InterruptedException {
example1();
// example2();
}
public static void example1() {
i = 1;
CountDownLatch latch = new CountDownLatch(0);
B worker1 = new B("B", latch);
worker1.run();
}
public static void example2() throws InterruptedException {
i = 1;
CountDownLatch latch = new CountDownLatch(1);
B b = new B("B", latch);
A a = new A("C", latch);
b.start();
a.start();
Thread.sleep(10);//simulation of some actual work
System.out.println("-----------------------------------------------");
System.out.println(" Now release the latch:");
System.out.println("-----------------------------------------------");
latch.countDown();
}
static class B extends Thread {
private CountDownLatch latch;
public B(String name, CountDownLatch latch) {
this.latch = latch;
setName(name);
}
@Override
public void run() {
try {
System.out.printf("[ %s ] created, blocked by the latch...\n", getName());
latch.await();
System.out.printf("[ %s ] starts at: %s\n", getName(), Instant.now());
Demo1.k = Demo1.i;;
System.out.println(Demo1.i);
} catch (InterruptedException e) {
// handle exception
}
}
}
static class A extends Thread {
private CountDownLatch latch;
public A(String name, CountDownLatch latch) {
this.latch = latch;
setName(name);
}
@Override
public void run() {
try {
System.out.printf("[ %s ] created, blocked by the latch...\n", getName());
latch.await();
System.out.printf("[ %s ] starts at: %s\n", getName(), Instant.now());
Demo1.i = 2;
} catch (InterruptedException e) {
// handle exception
}
}
}
}
example1: 假设线程A(main线程中执行)中的操作“i=1”先行发生于线程B的操作“j=i”,那么可以确定在线程B的操作 执行后,变量j的值一定等于1,得出这个结论的依据有两个:一是根据先行发生原则,“i=1”的结果可以被观察到;二是线程C还没“登场”,线程A操作结束之后没有其他线程会修改变量i的值。
example2: 现在再来考虑线程C,我们依然保持线程A和线程B之间的先行发生关系,而线程C出现在线程A和线程B的操作之间,但是线程C与线程B没有先行发生关系,那j 的值会是多少呢?答案是不确定!1和2都有可能,因为线程C对变量i的影响可能会被线程B 观察到,也可能不会,这时候线程B就存在读取到过期数据的风险,不具备多线程安全性。
下面是Java内存模型下一些“天然的”先行发生关系,这些先行发生关系无须任何同步器协助就已经存在,可以在编码中直接使用。如果两个操作之间的关系不在此列,并且无法从下列规则推导出来的话,它们就没有顺序性保障,虚拟机可以对它们随意地进行重排序。
- 程序次序规则(Program Order Rule):在一个线程内,按照程序代码顺序,书写在前面的操作先行发生于书写在后面的操作。准确地说,应该是控制流顺序而不是程序代码顺序, 因为要考虑分支、循环等结构。
- 管程锁定规则(Monitor Lock Rule):一个unlock操作先行发生于后面对同一个锁的lock 操作。这里必须强调的是同一个锁,而“后面”是指时间上的先后顺序。
- volatile变量规则(Volatile Variable Rule):对一个volatile变量的写操作先行发生于后面 对这个变量的读操作,这里的“后面”同样是指时间上的先后顺序。
- 线程启动规则(Thread Start Rule):Thread对象的start()方法先行发生于此线程的每 一个动作。
- 线程终止规则(Thread Termination Rule):线程中的所有操作都先行发生于对此线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值等手段检测到线程已经终止执行。
- 线程中断规则(Thread Interruption Rule):对线程interrupt()方法的调用先行发生于被 中断线程的代码检测到中断事件的发生,可以通过Thread.interrupted()方法检测到是否有 中断发生。
- 对象终结规则(Finalizer Rule):一个对象的初始化完成(构造函数执行结束)先行发 生于它的finalize()方法的开始
- 传递性(Transitivity):如果操作A先行发生于操作B,操作B先行发生于操作C,那就 可以得出操作A先行发生于操作C的结论
通过一个例子感受一下“时间上的先后顺序”与“先行发生”之间有什么不同
public class Demo2 {
private Integer age;
public Integer getAge() {
return age;
}
public void setAge(Integer age) {
this.age = age;
}
public static void main(String[] args) {
Demo2 demo2 = new Demo2();
new Thread(() -> {
//do other things
demo2.setAge(1);
}, "A").start();
new Thread(() -> {
//do other things
System.out.println(demo2.getAge());
}, "B").start();
}
}
假设存在线程A和B,线程 A先(时间上的先后)调用了“setValue(1)”,然后线程B调用了同一个对象的“getValue()”,那么线程B收到的返回值是什么?
我们依次分析一下先行发生原则中的各项规则,由于两个方法分别由线程A和线程B调用不在一个线程中,所以程序次序规则在这里不适用;由于没有同步块,自然就不会发生 lock和unlock操作,所以管程锁定规则不适用;由于value变量没有被volatile关键字修饰,所 以volatile变量规则不适用;后面的线程启动、终止、中断规则和对象终结规则也和这里完全没有关系。因为没有一个适用的先行发生规则,所以最后一条传递性也无从谈起,因此我们可以判定尽管线程A在操作时间上先于线程B,但是无法确定线程B中“getValue()”方法的返回结果,换句话说,这里面的操作不是线程安全的.
其实可以简单的理解成,尽管A线程是先与B线程start的(在时间的顺序上)但并不代表demo2.setAge(1);代码会先于B线程中System.out.println(demo2.getAge());代码执行,因为在现实场景中有很多中情况导致在执行demo2.setAge(1);和System.out.println(demo2.getAge());的执行顺序的不同,所以才有B线程中的操作不是线程安全一说。
那怎么修复这个问题呢?我们至少有两种比较简单的方案可以选择:要么把getter/setter 方法都定义为synchronized方法,这样就可以套用管程锁定规则;要么把value定义为volatile 变量,由于setter方法对value的修改不依赖value的原值,满足volatile关键字使用场景,这样就可以套用volatile变量规则来实现先行发生关系。
通过上面的例子,我们可以得出结论:一个操作“时间上的先发生”不代表这个操作会是“先行发生”,那如果一个操作“先行发生”是否就能推导出这个操作必定是“时间上的先发生”呢?
很遗憾,这个推论也是不成立的,一个典型的例子就是多次提到的“指令重排序”
public class Demo3 {
public static void main(String[] args) {
int i = 1;
int j = 2;
}
}
根据程序次序规则,“int i=1”的操作 先行发生于“int j=2”,但是“int j=2”的代码完全可能先被处理器执行,这并不影响先行发生原则的正确性,因为我们在这条线程之中没有办法感知到这点.
根据上述所说的,我们不难得出结论:时间先后顺序与先行发生原则之间基本没有太大的关系,所以我们衡量并发安全问题的时候不要受到时间顺序的干扰,一切必须以先行发生原则为准。
边栏推荐
- C语言学习概览(三)
- 特别数的和
- 并发编程13-JUC之CountDownLatch
- LabVIEW中局部变量和全局变量的分配
- Golang: go get url and form attribute value
- Golang: go to connect and use mysql
- POJ2031空间站题解
- Chapters 6 and 7 of Huawei Deep Learning Course
- Datagrip error "The specified database userpassword combination is rejected..."Solutions
- What do the values 1, 2, and 3 in nodetype mean?
猜你喜欢
22牛客多校1 C.Grab the Seat (几何 + 暴力)
【MySQL】操作表DML相关语句
Chapter 9 of Huawei Deep Learning Course - Convolutional Neural Network and Case Practice
案例实践 --- Resnet经典卷积神经网络(Mindspore)
rhcsa 第四天
我说过无数遍了:从来没有一种技术是为灵活组合这个目标而设计的
VoLTE Basic Learning Series | Enterprise Voice Network Brief
聊一聊ICMP协议以及ping的过程
最小生成树
How to use Photoshop to composite star trail photos, post-processing method of night sky star trail photos
随机推荐
三维坐标系距离
NIO programming
C语言学习概览(三)
The log causes these pits in the thread block, you have to prevent
2022杭电中超杯(1)个人题解
选择排序—直接选择排序和堆排序
金山打字通 官网 下载
return; represents meaning
R语言使用tidyquant包的tq_transmute函数计算持有某只股票的天、月、周收益率、ggplot2使用条形图可视化股票月收益率数据、使用百分比显示Y轴坐标数据、使用不同的色彩表征正负收益率
Vim三种模式
rhcsa 第三次
配置我的kitty
pytest接口自动化测试框架 | 执行失败跳转pdb
VoLTE基础学习系列 | 什么是SIP和IMS中的Forking
Data Analysis 6
13 - JUC CountDownLatch concurrent programming
What do the values 1, 2, and 3 in nodetype mean?
Golang:go开启web服务
【MySQL】操作表DML相关语句
七夕来袭——属于程序员的浪漫