在讲解java内存模型之前我们有必要先了解一下物理计算机中的并发问题
一、硬件的效率与一致性
我们知道计算机的处理器肯定要与内存进行交互,如读取运算数据,存储运算结果等,这个IO操作是很难消除的(无法仅靠寄存器来完成所有运算任务)。由于计算机的存储设备与处理器的运算速度有着几个数量级的差距,所以现代计算机都不得不加入一层或多层读写速度尽可能接近处理器运算速度的高速缓存(Cache)来作为内存与处理器之前的缓存:将运算要使用的数据复制到缓存中,让运算能快速进行,当运算结束后再从缓存同步回内存之中,这样处理器就无须等待缓慢的内存读写了
由于引进了高速缓存,因此引入了一个新问题:缓存一致性问题,在多路处理器系统中,每个处理器都有自己的高速缓存,而他们又同时共享同一主内存,这种系统称为共享内存多核系统。当多个处理器的运算任务都涉及同一块主内存区域时,将可能导致各自的缓存数据不一致;那我们如何解决呢:需要各个处理器访问缓存时都遵循一些协议,在读写时需要根据协议来操作,这类协议有MSI、MESI、MOSI、Synapse、Firefly及Dragon Protocol等。
二、Java内存模型
《Java虚拟机规范》中曾试图定义一种“java内存模型”来屏蔽各种硬件和操作系统的内存访问差异,以实现让java程序在各种平台下都能达到一致的内存访问效果。
2.1 主内存与工作内存
java内存模型的主要目的是定义程序中的各种变量的访问规则,即关注在虚拟机中把变量存储到内存和从内存中取出变量值这样的底层细节。此处的变量与java编程中的变量还是有点区别的,它包括了实例字段、静态变量、静态字段和构成数组对象的原素,但是不包括局部变量与方法参数,因为后者是线程私有的,不会被共享,也就不会存在竞争问题。
主内存:java内存模型规定了所有的变量都存储在主内存中
工作内存:每条线程都有自己的工作内存,线程的工作内存中被该线程使用的变量的主内存副本,线程对变量的所有操作(读取赋值等)都必须在工作内存中进行,而不能直接读写主内存中的数据。不通线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成。
2.2 内存间的交互操作
关于主内存和工作内存之间具体的交互协议,即一个变量如何从主内存拷贝到工作内存,如何从工作内存同步回主内存这一类的实现细节,java内存模型定义了一下8种操作来完成,具体如下:
- lock(锁定):作用于主内存的变量,它把一个变量标识为一条线程独占的状态
- unlock(解锁):作用于主内存的变量,他把一个锁定的变量释放,释放的变量才能被其他线程锁定
- read(读取):作用于主内存的变量,它把一个变量值从主内存传输到线程的工作内存中,以便随后的load动作使用
- load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中
- use(使用):作用于工作内存的变量,它把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作
- assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收的值赋值给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时将会执行这个操作
- store(存储):作用于工作内存的变量,它把工作内存中一个变量的值传送到主内存中,以便随后的write操作使用
- write(写入):作用于主内存的变量,他把store操作从工作内存中得到的变量的值放入主内存的变量中
如果要把一个变量从主内存拷贝到工作内存,那就要按顺序执行read和load操作,如果要把变量从工作内存同步回主内存,就要按顺序执行store和write操作。
2.3 volatile型变量的特殊规则
关键字volatile是java虚拟机提供的最轻量级的同步机制,但它在代码中使用的并不多,接下来我们就来了解一下
当一个变量被修饰为volatile后,它具有两项特征:第一项是保证此变量对所有线程的可见性,这里的可见性是指当一条线程修改了这个变量的值,新值对于其他线程来说是可以立即得知的。而普通变量并不能做到这一点,普通变量的值在线程间传递均需要通过主内存来完成。比如,线程A修改了一个普通变量的值,然后向主内存进行回写,另外一条线程B在线程A写完了之后再对主内存进行读取,新变量的值才会对线程B可见。
volatile的一致性问题:从物理存储的角度看,各个线程的工作内存中volatile变量也可以存在不一致的情况,但由于每次使用前都要先刷新,执行引擎看不到不一致的情况,因此可以认为不存在一致性的问题。但是java中的运算操作符并非原子操作,这导致volatile变量的运算在并发下一样是不安全的。
这里简单举例下并发下使用volatile的线程不安全行为:
通过volatile修饰一个静态变量i,对于一个非原子操作,比如 i++ ,反编译这行代码会得到只有一行代码的increase()方法在Class文件中是由四条字节码指令构成的,当getstatic指令把 i 的值取到操作栈顶时,volatile关键字保证了 i 的值在此时是正确的,但是在执行iconst_1,iadd这些指令的时候,其他线程可能已经把i的值改变了,而操作栈顶的值就变成了过期的数据。
那么什么时候适合用volatile关键字呢?
- 运算结果并不依赖变量的当前值,或者能够确保只有单一的线程来修改变量的值
- 变量不需要与其他的状态变量共同参与不变约束
使用volatile变量的第二个语义是禁止指令重排优化,他的大概实现为:在volatile修饰的变量,修饰完之后相当于给变量增加了一个内存屏障,指令重排时不能把后面的指令重排序到内存屏障之前的位置。
关于指令重排的解释:从硬件架构上讲,指令重排时指处理器采用了允许将多条指令不按程序规定的顺序分开发送给各个相应的电路单元进行处理。
其他并发情况,我们仍然需要通过加锁来保证原子性
volatile的性能
volatile变量读操作的性能消耗与普通变量几乎没有多大区别,但是写操作可能会慢上一些,因为它需要在本地代码中插入许多内存屏障指令来保证处理器不发生乱序执行(指令重排)
2.4 原子性、可见性与有序性
原子性
由java内存模型来直接保证原子性变量操作包括:read、load、assign、use、store和write这六个,我们大致可以认为基本类型的访问和读写都具备原子性。如果应用场景需要一个更大的范围来保证原子性,java内存模型还提供了lock和unlock操作来满足这种需求,虚拟机提供了lock和unlock的字节码指令monitorenter和monitorexit来隐示的使用这两个操作,这两个字节码反应到java代码中就是同步代码块–synchroized关键字,因此在synchroized代码块中也具备原子性。
可见性
可见性是指当一个线程修改了共享变量的值时,其他线程能够立即得知这个修改。
java内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值这种依赖主内存作为传递媒介的方式来实现可见性的,无论是普通变量和volatile都是。
普通变量和volatile变量的区别是:volatile的特殊规则保证了新值能立即同步回主内存,以及每次使用前立即从主内存刷新,因此volatile保证了多线程操作时变量的可见性
有序性
java语言提供了volatile和synchronized两个关键字来保证线程之间操作的有序性,volatile关键字本身就包含了禁止指令重排的语义,而synchronized则是由一个变量在同一时刻只允许一条线程对其进行lock操作这条规则获得的,这个规则决定了持有一个锁的两个同步块只能串行进入。
2.4 先行发生原则
java语言中有一个先行发生的原则,这个原则非常重要,它是判断数据是否存在竞争,线程是否安全的非常有用的手段。
什么是先行发生原则?
先行发生原则是java内存模型中定义的两项操作之间的偏序关系,比如说操作A先行发生于操作B,其实就是说在发生操作B之前,操作A产生的影响被B观察到,影响包括修改了内存中共享变量的值、发送了消息调用了方法等。
创作不易,点个赞吧~👍
最后的最后送大家一句话
白驹过隙,沧海桑田
与君共勉