CodeXiaoMai

CodeXiaoMai的博客


  • 首页

  • 关于

  • 标签

  • 分类

  • 归档

  • 搜索

Java内存模型

发表于 2019-11-10 更新于 2019-11-23 分类于 Java 阅读次数:
本文字数: 6.5k

Java 内存模型是多线程并发的基础,理解了 Java 内存模型能够对多线程并发中的问题迎刃而解。Java 的内存模型在很大程度上与硬件的交互模型类似,所以先了解一下硬件的交互。

硬件的效率与一致性

在计算机中绝大多数的运算任务不可能只靠处理器就能完成,处理器至少要与内存交互,如读取运算数据、存储运算结果等,这个 I/O 操作是很难避免的。由于计算机的存储设备比处理器的运算速度慢几个数量级的差距,所以现代的计算机系统加入了一层读写速度尽可能接近处理器运算速度的高速缓存(Cache)来作为内存与处理器之间的缓冲。这个高速缓存(Cache)存储运算需要的数据,让运算能快速进行,当运算结束后再从缓存 同步回内存中,这样处理器就不用等待缓慢的内存读写了。

虽然高速缓存解决了处理器与内存之间的 I/O 瓶颈,但同时又引入了一个新的问题:缓存一致性(Cache Coherence)。在多处理器系统中,每个处理器都有自己的高速缓存,而它们又共享同一主内存,当多个处理器的运算任务都涉及到同一块主内存区域时,将可能导致各自的缓存数据不一致,数据同步回主内存时,主内存又该以哪个缓存的数据为准。所以为了解决一致性的问题,需要各个处理器访问缓存时都遵循一些协议。处理器、高速缓存、主内存之间的关系如下图:

Java 内存模型

Java 内存模型(Java Memory Model,JMM)的主要目标是定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量这样的底层细节。它是用来屏蔽各种硬件和操作系统的内存访问差异,以实现 Java 程序在各种平台下都能达到一致的内存访问效果。

Java 内存模型规定所有的变量都存储在主内存(Main Memory)中(此处的主内存指的是虚拟机内存的一部分)。每条线程还有自己的工作内存(Working Memory),线程的工作内存中保存了被该线程使用到的变量的主内存副本拷贝,线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,不能直接读写主内存中的变量(volatile 变量依然有工作内存的拷贝,但是由于它特殊的操作有顺序性规定,所以看起来如同直接在主内存中读写访问一样)。不同的线程之间无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成,线程、主内存、工作内存三者的关系如下:

由上图可以看出,Java 内存模型图与硬件的一致性关系图非常相似。

1
2
3
4
Java 线程  <==>  工作内存  <==> Save 和 Load 操作  <==>  主内存
| | | |
| | | |
处理器 <==> 高速缓存 <==> 缓存一致性协议 <==> 主内存

Java 内存间的交互操作

关于主内存与工作内存之间具体的交互协议,就是一个变量如何从主内存拷贝到工作内存,以及如何从工作内存同步回主内存之类的实现细节。Java 内存模型中定义了 8 种操作来完成,虚拟机必须保证下面的每一种操作都原子的,不可再分的。

  • lock
  • unlock
  • read
  • load
  • use
  • assign
  • store
  • write

Java 内存模型还规定了在执行上述8种操作时必须满足如下规则:

  • 不允许read和load、store和write操作之一单独出现,即不允许一个变量从主内存读取了但工作内存不接受,或者从工作内存发起回写了但主内存不接受的情况出现。

  • 不允许一个线程丢弃它的最近的assign操作,即变量在工作内存中改变了之后必须把该变化同步回主内存。

  • 不允许一个线程无原因地(没有发生过任何assign操作)把数据从线程的工作内存同步回主内存中。

  • 一个新的变量只能在主内存中“诞生”,不允许在工作内存中直接使用一个未被初始化(load或assign)的变量,换句话说,就是对一个变量实施use、store操作之前,必须先执行过了assign和load操作。

  • 一个变量在同一个时刻只允许一条线程对其进行lock操作,但lock操作可以被同一条线程重复执行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才会被解锁。

  • 如果对一个变量执行lock操作,那将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行load或assign操作初始化变量的值。

  • 如果一个变量事先没有被lock操作锁定,那就不允许对它执行unlock操作,也不允许去unlock一个被其他线程锁定住的变量。

  • 对一个变量执行unlock操作之前,必须先把此变量同步回主内存中(执行store、write操作)。

这8种内存访问操作以及上述规则限定,再加 volatile 的一些特殊规定,就已经完全确定了Java程序中哪些内存访问操作在并发下是安全的。由于这种定义相当严谨但又十分烦琐,实践起来很麻烦,所以这种定义的一个等效判断原则 —— 先行发生原则(文章最后介绍),用来确定一个访问在并发环境下是否安全。

volatile

volatile 是 Java 虚拟机提供的最轻量级的同步机制。

Java 内存模型对 volatile 专门定义了一些特殊的访问规则,当一个变量定义为 volatile之后,它将具备两种特性:保证此变量对所有线程的可见性;禁止指令重排序优化。

可见性

『可见性』是指当一个线程修改了这个变量的值,新值对于其他线程来说是可以立即得知的。而普通变量不能做到这一点,普通变量的值在线程间传递需要通过主内存来完成。除了volatile之外,Java还有两个关键字能实现可见性,即synchronized和final。同步块的可见性是由“对一个变量执行unlock操作之前,必须先把此变量同步回主内存中(执行store、write操作)”这条规则获得的,而final关键字的可见性是指:被final修饰的字段在构造器中一旦初始化完成,并且构造器没有把“this”的引用传递出去(this引用逃逸是一件很危险的事情,其他线程有可能通过这个引用访问到“初始化了一半”的对象),那在其他线程中就能看见final字段的值。

一个错误的结论:由于 volatile 变量对所有线程是立即可见的,对 volatile 变量所有的写操作都能立刻反应到其他线程中,所以 volatile 变量的运算在并发下是安全的。

这句话的论据没有错,但并不能得出 “volatile 变量的运算在并发下是安全的” 这个结论。原因是:Java 中的运算并非原子操作。

例如一个累加运算如下:

1
2
3
4
5
public static volatile int number = 0;

public static void increase() {
number++;
}

把这段代码反编译后,得到的字节码如下:

1
2
3
4
5
6
7
8
9
public static volatile int number;

public static void increase();
Code:
0: getstatic #2 // Field number:I
3: iconst_1
4: iadd
5: putstatic #2 // Field number:I
8: return

从字节码中可以发现,只有一行代码的 increase() 方法在 Class 文件中却有 4 条字节码指令构成(不算 return),所以很容易就明白并发失败的原因了:当getstatic指令把race的值取到操作栈顶时,volatile关键字保证了race的值在此时是正确的,但是在执行iconst_1、iadd这些指令的时候,其他线程可能已经把race的值加大了,而再操作栈顶的值就变成了过期的数据,所以putstatic指令执行后就可能把较小的race值同步回主内存之中。

所以 volatile 变量在不符合以下两条规则时,仍然要通过加锁来保证原子性:

  • 运算结果并不依赖变量的当前值(正例:boolean,反例:累加),或者能够确保只有一个线程修改变量的值。
  • 变量不需要与其他的状态变量共同参与不变约束。意思是:volatile 没办法保证和其他变量一起用时,多线程对 volatile 变量和其他变量操作的原子性。

指令重排序

『指令重排序』是在保证不影响当前线程执行结果的前提下对代码执行顺序做出一些调整,但这个调整在多线程并发时可能会影响其他线程的结果。这也就是 Java 内存模型中所谓的『线程中表现为串行』的语义。

什么时候使用 volatile

volatile 变量读操作的性能与普通变量几乎没有差别,但是写操作可能会慢一些,因为它需要在本地代码中插入许多内存屏障指令来保证处理器不发生乱序执行。大多数场景下 volatile 的总开销比锁低,当在 volatile 与锁之中选择时,如果满足 volatile 的使用场景就使用它,否则使用锁。

Java 内存模型的三个特征

  • 原子性(Atomicity):由Java内存模型来直接保证的原子性变量操作包括read、load、assign、use、store和write,我们大致可以认为基本数据类型的访问读写是具备原子性的。如果应用场景需要一个更大范围的原子性保证,Java内存模型还提供了lock和unlock操作来满足这种需求,尽管虚拟机未把lock和unlock操作直接开放给用户使用,但是却提供了更高层次的字节码指令monitorenter和monitorexit来隐式地使用这两个操作,这两个字节码指令反映到Java代码中就是同步块——synchronized关键字,因此在synchronized块之间的操作也具备原子性。

  • 可见性:指当一个线程修改了这个变量的值,新值对于其他线程来说是可以立即得知的。

  • 有序性:Java程序中天然的有序性可以总结为一句话:如果在本线程内观察,所有的操作都是有序的;如果在一个线程中观察另一个线程,所有的操作都是无序的。前半句是指“线程内表现为串行的语义”(Within-Thread As-If-Serial Semantics),后半句是指“指令重排序”现象和“工作内存与主内存同步延迟”现象。Java语言提供了volatile和synchronized两个关键字来保证线程之间操作的有序性,volatile关键字本身就包含了禁止指令重排序的语义,而synchronized则是由“一个变量在同一个时刻只允许一条线程对其进行lock操作”这条规则获得的,这条规则决定了持有同一个锁的两个同步块只能串行地进入。

先行发生原则(Happens-before)

『先行发生』是指 Java 内存模型中定义的两项操作之间的偏序关系,如果操作 A 先行发生于操作 B,其实就是说在发生操作 B 之前,操作 A 的影响能被操作 B 观察到。这个原则是判断数据是否存在竞争、线程是否安全的主要依据。

下面是Java内存模型下一些“天然的”先行发生关系,这些先行发生关系无须任何同步器协助就已经存在,可以在编码中直接使用。如果两个操作之间的关系不在此列,并且无法从下列规则推导出来的话,它们就没有顺序性保障,虚拟机可以对它们随意地进行重排序。

  • 程序次序规则:在一个线程内,按照程序代码顺序,书写在前面的操作先行发生于书写在后面的操作。准确地说,应该是控制流顺序而不是程序代码顺序,因为要考虑分支、循环等结构。

  • 管程锁定规则:一个 unlock 操作先行发生于后面对同一个锁的 lock 操作。这里必须强调的是同一个锁,而『后面』是指时间上的先后顺序。

  • volatile 变量规则:对一个 volatile 变量的写操作先行发生于后面对这个变量的读操作,这里的『后面』也是指时间上的先后顺序。

  • 线程启动规则:Thread 对象的 start() 方法先行发生于此线程的每一个动作。

  • 线程终止规则:线程中的所有操作都先行发生于对此线程的终止检测,我们可以通过 Thread.join() 方法结束、Thread.isAlive() 的返回值等手段检测到线程已经终止执行。

  • 线程中断规则:对线程 interrupt() 方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过 Thread.interrupted() 方法检测到是否有中断发生。

  • 对象终结规则:一个对象的初始化完成(构造函数执行结束)先行发生于它的 finalize() 方法的开始。

  • 传递性:如果操作 A 等待发生于操作 B,操作 B 先生发生于操作 C,那就可以得出操作 A 先行发生于操作 C 的结论。

一个例子判定操作是否具备顺序性:

1
2
3
4
5
6
7
8
private int value = 0;

pubilc void setValue(int value) {
this.value = value;
}

public int getValue(){
return value;
}

假设存在线程A和B,线程A先(时间上的先后)调用了“setValue(1)”,然后线程B调用了同一个对象的“getValue()”,那么线程B收到的返回值是什么?

我们依次分析一下先行发生原则中的各项规则:

由于两个方法分别由线程A和线程B调用,不在一个线程中,所以程序次序规则在这里不适用;

由于没有同步块,自然就不会发生lock和unlock操作,所以管程锁定规则不适用;

由于value变量没有被volatile关键字修饰,所以volatile变量规则不适用;

后面的线程启动、终止、中断规则和对象终结规则也和这里完全没有关系。因为没有一个适用的先行发生规则,所以最后一条传递性也无从谈起,因此我们可以判定尽管线程A在操作时间上先于线程B,但是无法确定线程B中“getValue()”方法的返回结果,换句话说,这里面的操作不是线程安全的。

那怎么修复这个问题呢?我们至少有两种比较简单的方案可以选择:要么把getter/setter方法都定义为synchronized方法,这样就可以套用管程锁定规则;要么把value定义为volatile变量,由于setter方法对value的修改不依赖value的原值,满足volatile关键字使用场景,这样就可以套用volatile变量规则来实现先行发生关系。

通过上面的例子,我们可以得出结论:一个操作“时间上的先发生”不代表这个操作会是“先行发生”,那如果一个操作“先行发生”是否就能推导出这个操作必定是“时间上的先发生”呢?很遗憾,这个推论也是不成立的,一个典型的例子就是多次提到的“指令重排序”,如下所示:

1
2
3
// 以下操作在同一个线程在执行
int i = 1;
int j = 2;

两条赋值语句在同一个线程之中,根据程序次序规则,“int i=1”的操作先行发生于“int j=2”,但是“int j=2”的代码完全可能先被处理器执行,这并不影响先行发生原则的正确性,因为我们在这条线程之中没有办法感知到这点。

上面两个例子综合起来证明了一个结论:时间先后顺序与先行发生原则之间基本没有太大的关系,所以我们衡量并发安全问题的时候不要受到时间顺序的干扰,一切必须以先行发生原则为准。

# Java # 虚拟机 # 多线程
IntentService源码解析
Java 线程安全的集合
  • 文章目录
  • 站点概览
CodeXiaoMai

CodeXiaoMai

CodeXiaoMai的博客
12 日志
6 分类
19 标签
GitHub E-Mail
  1. 1. 硬件的效率与一致性
  2. 2. Java 内存模型
  3. 3. Java 内存间的交互操作
  4. 4. volatile
    1. 4.1. 可见性
    2. 4.2. 指令重排序
    3. 4.3. 什么时候使用 volatile
    4. 4.4. Java 内存模型的三个特征
  5. 5. 先行发生原则(Happens-before)
© 2019 CodeXiaoMai
|