JVM 虚拟机规范中曾经试图定义一种 Java 内存模型,来屏蔽掉各种硬件和操作系统的内存访问差异,以实现让 Java 程序在各种平台下都可以达到一致性的内存访问效果。
然而定义这样一套内存模型并非很容易,这个模型必须足够严谨,才能让 Java 的并发内存访问操作不会有歧义。但是也必须足够宽松,这样使得虚拟机的具体实现能够有自由的发挥空间来利用各种硬件的优势。经过长时间的验证和弥补,到了 JDK1.5(实现了 JSR133 规范)之后,Java 内存模型才终于成熟起来了。
主内存和工作内存
Java 内存模型规定了所有的变量都存储在主内存
(Main Memory) 中,每条线程都有自己的工作内存
(Work Memory)
- 工作内存中保存了被该线程使用的变量的主内存副本,
- 线程对变量的读写操作必须在工作内存中进行。
- 而不能直接访问主内存的数据。
- 不同的线程也不能互相读写对方的工作内存,线程之间的变量传递必须通过主内存传递。
主内存和工作内存的交互
Java 内存模型定义了如下八种操作 (每一种操作都是原子的
,不可再分
的)
lock 锁定
: 作用于主内存,将一个变量标识为线程独占状态unlock: 解锁
: 作用于主内存,将一个线程独占状态的变量释放read 读取
: 从主内存读取数据到工作内存,便于之后的 load 操作load 载入
: 把 read 读取操作从主内存中得到的变量放入工作内存的变量副本中use 使用
: 将工作内存中的变量传递给执行引擎 当虚拟机遇到一个需要使用变量值的字节码时,执行此操作assign 赋值
: 将执行引擎中的值赋给工作内存的变量。 当虚拟机遇到一个赋值操作时,执行此操作store 存储
: 将工作内存的值传递到主内存 ,便于之后的 write 操作write 写入
:将 store 存储操作中从工作内存中获取的变量写入到主内存中
举例:
- 如果要把一个变量从主内存拷贝到工作内存,则依次执行 read 读取操作, load 载入操作
- 如果要把一个变量从工作内存写入到主内存,则依次执行 store 存储操作,write 写入操作
上述的 8 种操作必须满足以下规则:
- 不允许 read 和 load、store 和 write 操作之一单独出现。也就是说不允许一个变量从主内存读取但是工作内存不接受,也不允许工作内存发起回写请求但是主内存不接受。
- 不允许一个线程丢弃它的最近 assign 的操作,即变量在工作内存中改变了之后必须同步到主内存中。
- 不允许一个线程无原因地(没有发生过任何 assign 操作)把数据从工作内存同步回主内存中。
- 一个新的变量只能在主内存中诞生,不允许在工作内存中直接使用一个未被初始化(load 或 assign)的变量。即就是对一个变量实施 use 和 store 操作之前,必须先执行过了 assign 和 load 操作。
- 一个变量在同一时刻只允许一条线程对其进行 lock 操作,但 lock 操作可以被同一条线程重复执行多次,多次执行 lock 后,只有执行相同次数的 unlock 操作,变量才会被解锁。lock 和 unlock 必须成对出现
- 如果对一个变量执行 lock 操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前需要重新执行 load 或 assign 操作初始化变量的值
- 如果一个变量事先没有被 lock 操作锁定,则不允许对它执行 unlock 操作;也不允许去 unlock 一个被其他线程锁定的变量。
- 对一个变量执行 unlock 操作之前,必须先把此变量同步到主内存中(执行 store 和 write 操作)。
volatile 特殊规则
volatile 可以说是 Java 虚拟机提供的最轻量级的同步机制。但是它并不容易被正确,完整的理解。
Java 内存模型中规定
当一个变量被定义为volatile
之后,表示着线程工作内存无效,对此值的读写操作都会直接作用在主内存上,
因此它具备对所有线程的立即可见性
。以及禁止执行引擎对其进行指令重排序
当变量的值被修改之后,新值对于其他线程是立即可知的。普通变量并不能做到这一点,因为普通变量的值在线程之间的传递是要进过主内存来完成的。比如当线程 A 对变量进行了回写操作,线程 B 只有在 A 回写完成之后,在对主内存操作,新值才对 B 是可见的。在 A 回写到主内存的过程中,B 读取的依旧是旧值。
但是这并不可以推导出基于 volatile 变量的运算在并发下是安全的
,因为在 Java 中的运算操作符并不是原子性
的。这导致了volatile 变量在并发下运算是不安全
的。
通过代码验证volatile 变量在并发下运算是不安全
首先我们创建 20 个线程,每个线程对 volatile 变量进行 1000 次的自增操作。保证此变量对所有线程的
立即可见性
``` /**
- @作者: 写Bug的小杜 【email@shaoxiongdu.cn】
- @时间: 2021/07/31
- @描述: 通过代码验证 【volatile变量在并发下运算是不安全】 */ public class VolatileTest {
//volatile修饰的count private static volatile int count = 0;
//count自增方法 public static void increment(){
count++;
}
public static void main(String[] args) {
//对count进行递增1000次操作的可运行接口
Runnable runnable = new Runnable() {
@Override
public void run() {
System.out.println(Thread.currentThread().getName() + "线程开始对count进行递增操作");
for (int i = 0; i < 1000; i++) {
increment();
}
System.out.println(Thread.currentThread().getName() + "线程对count递增操作结束");
}
};
// 创建20个线程并启动
for (int i = 0; i < 20; i++) {
Thread thread = new Thread(runnable);
thread.setName((i+1) + "号线程");
thread.start();
}
while (Thread.activeCount() > 2){
//主线程回到就绪状态
Thread.yield();
}
System.out.println("所有线程结束,count = " + count);
}
}
``
<br />如果此程序在并发下是安全的,那么 count 的值最后肯定是 20*1000 = 20000;也就是说,如果运行结果为 20000,那么
volatile 变量在并发下运算是安全的<br />通过多次运行程序,我们发现,count 的值永远比 20000 小。<br />![](https://gitee.com/ShaoxiongDu/imageBed/raw/master//images/image-20210731215601479.png#crop=0&crop=0&crop=1&crop=1&id=JUbck&originHeight=184&originWidth=663&originalType=binary&ratio=1&rotation=0&showTitle=false&status=done&style=none&title=)<br />那么,这是为什么呢?<br />我们将上方的代码进行反编译,然后分析 increment 方法的字节码指令。 <br />我们可以发现,一行 count++ 代码被分为 4 行字节码文件去执行。通过对字节码的分析,我们发现,<br />当偏移量为 0 的字节码 getStatic 将 count 的值从局部变量表取到操作数栈顶的时候,
volatile保证了此时 count 的值是正确的,但是在执行 iconst_1, iadd 这些操作的时候,其他线程已经把 count 的值改变了,此时,操作数栈顶的 count 为过期的数据,所以 putStatic 字节码指令就有可能将较小的值同步到主内存中。因此最终的值会比 20000 稍微小。<br />也就是说,
volatile 变量在并发下运算是不安全的` 。
在并发环境下,volatile 的变量只是对全部线程即时可见的,如果要进行写的操作,还是要通过加锁来解决。
针对 long 和 double 类型变量的特殊规则
Java 内存模型要求对上述的八种操作必须是原子性的,但是对于 64 位的数据类型(8 字节的 Long 和 double)在模型中特别定义了一条宽松的规定: 允许虚拟机将没有被 volatile 修饰的 64 位数据的读写划分为 2 次 32 位的操作来进行。
即允许虚拟机实现自行选择是否保证 64 位的数据类型的 load,store,read 和 wirte 四个操作的原子性。这就是所谓的long 和 double 的非原子性协定
原子性
由 Java 内存模型直接保证原子性变量操作包括: read,load,assign,use,store 和 write 6 个。我们大致可以认为,基本数据类型的访问,读写操作都是原子性的。
如果场景需要一个更大范围的原子性保证,Java 内存模型还提供了 lock 和 unlock 操作来满足,尽管虚拟机未把 lock 和 unlock 的操作直接开放给用户。但是提供了更高层次的字节码指令 monitorenter
和monitorexit
来隐式的使用这两个操作。这两个字节码指令反映到代码层面就是synchronized
关键字。这就是为什么synchronized
关键字内部的操作也是原子性的。
可见性
可加性就是指当一个线程修改了共享变量的值时,其他线程能够立即得知这个修改,对修改是可见的。
Java 内存模型中是以这种方式实现的: 在变量修改之后将修改的值同步回主内存,其他线程读取前将新值从主内存同步到工作内存。
无论是普通变量还是volatile
变量都是如此。只不过volatile
保证了修改了新值能立即同步到主内存,每次读取都会从主内存同步。
除了volatile
之外,Java 中还有两个关键字是可以保证可见性的。synchronized
和final
synchronized
同步代码块可实现可见性是因为: 规定对一个变量执行 unlock 操作之前,必须把变量的值同步至主内存。也就是对一个变量进行解锁的时候,保证主内存中的值已经是最新的值。此时,解锁之后,别的线程从主内存进行同步,值就是正确的。
final
表示当 final 修饰的变量在构造器中一旦初始化完成,此时,不需要将this
引用传递出去,别的线程就可以立即感知到 final 修饰的变量的值。
有序性
在 Java 中,如果在本线程内观察,所有的操作都是有序的。
如果在一个线程中观察另一个线程,则所有的操作都是无序的。