“一次编写,到处运行”是Java的核心优势”。Java如今位居当今最热门的编程语言之一,跨平台性有着不可磨灭的功劳,其可以运行在所有平台(物理机 + 操作系统)上的优势,是C、C++等语言无法实现的,原因主要有以下两点:
1. 相同指令在不同系统下的二进制码不同
【move】指令,在Windows系统上的二进制码为【010】,而在Linux系统上则可能为【001】。而C、C++等语言程序会被直接编译为相关平台的二进制码,因此在Windows系统上完成编译的C、C++等语言程序无法在Linux系统正常运行,反之亦然。
2. 不同物理机的内存模型实现不同
上篇博文所讲述的物理机内存模型其实是一种概念 / 规范,实际实现往往更加复杂多样。不同物理机各有各自的内存模型实现,这也是导致程序在不同物理机下运行异常的重要原因。
Java内存模型是实现跨平台性的基础之一。虚拟机在物理机内存模型的基础上建立了Java内存模型,因为Java内存模型的存在,Java成功达到在统一内存模型实现上运行程序的目的,避免了物理机内存模型实现多样化所带来的负面影响。而底层与各大物理机内存模型的数据交互则由虚拟机负责,对开发者屏蔽。
Java内存模型由主内存和工作内存组成,其作用是隔绝物理机内存模型实现的多样化,实现多线程对内存变量的安全共享。在模型中,Java线程可类比为处理器,而工作内存则类比高速缓存,主内存对应内存,很明显可以看出这是一套与物理机内存模型相似的结构。
Java内存区域(堆栈模型)不是Java内存模型(JMM)的具体实现。在我查询资料探寻Java内存模型(JMM)与Java内存区域(堆栈模型)的关系时看到这样一种说法:
Java内存区域(堆栈模型)是Java内存模型(JMM)的具体实现。
个人在经过思虑之后否定了这种说法。首先书中明言两者没有直接关系,其次两者对于变量的定义也不尽相同。Java内存模型(JMM)明确定义变量的含义为共享变量,这代表Java内存区域(堆栈模型)虚拟机栈中的局部变量全都不属于Java内存模型(JMM)的变量范围,那所谓的具体实现也就无从谈起。
Java内存模型(JMM)与Java内存区域(堆栈模型)并无关联,而是对内存两种不同维度的划分。Java内存区域(堆栈模型)作用于虚拟机程序运行时数据分区域存储(即怎么放),而Java内存模型(JMM)则定义了线程对内存变量的访问规则(即怎么用)。两者在逻辑上没有关联,只是在具体实现中有所牵扯。
Java内存模型共有八种交互操作,并且这些操作全都是原子操作。关于操作的具体含义书中已有详细的叙述,这里不再提及。需要着重说明的是,操作仅作用于内存模型,use(变量传输至执行引擎)与assign(变量保存至工作内存)操作仅仅起到与执行引擎的交互作用,与执行引擎的内部执行并无关联。
read - load / store - write实际上是传输 - 保存的过程,两者本质上是独立的,但逻辑上要求不可独立执行。
lock - unlock在逻辑上也应该不允许独立执行,否则会造成死锁或无锁解锁的异常发生。
关键字volatile是Java最轻量级的同步机制,Java内存模型对volatile变量定义有三条特殊规则,并以此形成了两种特性。
规则1:只有当线程T对变量V执行的前一个操作为load时,线程T才可以对变量V执行use操作;
并且只有当线程T对变量V执行的后一个操作为use时,线程T才可以对变量V执行load操作。
该规则令read - load - use形成整体性的原子操作,使得volatile变量在被使用前会从主内存中读取最新值。
规则2:只有当线程T对变量V执行的前一个操作为assign时,线程T才可以对变量V执行store动作;
并且只有当线程T对变量V执行的后一个操作为store时,线程T才可以对变量V执行assign动作。
该规则令assign - store - write形成整体性的原子操作,使得volatile变量在被赋值后会立即同步回主内存。
规则1、2形成了volatile变量的第1个特性 ------ 保证可见性。可见性是指变量被一个线程修改后的新值是否可以被其它线程观察到。普通变量无法保证可见性,以主内存中的变量值1为例,同时被线程A、B读取并在相应工作内存中加载副本。线程A将副本值递增(递增操作由执行引擎完成,与内存模型无关)后赋值,此时线程A工作内存副本值为2,线程B工作内存副本值为1,两者互不可见。随后线程A将副本值同步回主内存,此时主内存中变量值为2,但对线程B而言,其工作内存副本值中依然为1,使用时的值也是1,故线程A操作后的新值无法被线程B观察到。volatile变量则不相同,因为规则2的原因线程A将工作内存副本值递增为2并赋值后会立即同步回主内存,此时主内存中变量值为2,而线程B工作内存副本值为1,与之前相同。但因为规则1令线程B在使用工作内存副本值前会重新从主内存中读取 - 加载volatile变量的最新值2,因此使得线程A对volatile变量的操作对线程B可见。
volatile变量可保证可见性并不意味着其线程安全。很多开发者错误的认为,既然volatile变量能保证读取到最新值,那其应当是线程安全的,但实际并非如此,原因是执行引擎对volatile变量的操作并非都是原子操作。以代码清单 12-1的为例,代码【race++;】会被编译为下列字节码指令。
上述字节码指令集是一个整体,但并非是原子的,通俗的说就是线程可以在字节码指令间中断,故可能发生下述情况:
线程A | 线程B | |
---|---|---|
T1 | 读取volatile变量值1并压入操作数栈顶(即存入工作内存) | |
T2 | 变量值1压入操作数栈顶 | |
T3 | volatile变量值1与变量值1弹出操作数栈并相加,结果值2压入操作数栈顶 | |
T4 | 读取volatile变量值1并压入操作数栈顶(即存入工作内存) | |
T5 | 变量值1压入操作数栈顶 | |
T6 | volatile变量值1与变量值1弹出操作数栈并相加,结果值2压入操作数栈顶 | |
T7 | 结果值2赋值回工作内存 | |
T8 | 结果值2赋值回工作内存 |
可以看到多线程的情况下,对volatile变量的执行结果可能会出现重复且覆盖的情况,导致线程(数据)安全问题。表中描述的只是出现线程安全问题情况的的其中一种,实际情况还有更多,故发现线程安全问题的可能性也更大。
规则3:假定操作A是线程T对变量V实施的use或assign操作,操作F是和操作A关联的load或store操作,操作P是和操作F相关联的read或write操作;
假定操作B是线程T对变量W实施的use或assign操作,操作G是和操作B关联的load或store操作,操作Q是和操作G相关联的read或write操作;
如果A先于B,则P先于Q。
规则3形成了volatile变量的第2个特性 ------ 避免重排序。重排序是Java虚拟机的一种优化策略,在保证指令关联性(即执行结果)不变的情况下,将指令集打乱执行。重排序的目的是为了契合CPU的物理运算规则以增大指令的执行效率,但也容易造成一些问题。以代码清单 12-5为例,代码【instance = new Singeton();】实际上至少由以下几个指令组成:
上述指令中,2、3依赖1,但3不依赖2,如果instance变量未修饰volatile关键字,那在实际执行中指令顺序可能如下所示:
由此就可能发生这样一种情况:
线程A | 线程B | |
---|---|---|
T1 | 判断instance变量是否为null(无锁) | |
T2 | 判断instance变量是否为null(有锁) | |
T3 | 实例化Singeton类对象 | |
T4 | 将Singeton类对象赋予instance变量 | |
T5 | 判断instance变量是否为null(无锁) | |
T6 | 返回instance变量(不完整) | |
T7 | 初始化Singeton类对象 | |
T8 | 返回instance变量(完整) |
线程B确实返回了Singeton类对象,但却不是完整的Singeton类对象,程序依然出现了线程问题。这便是著名的双重检查锁(DCL)问题,是多线程学习过程中的经典案例,具体可查看下方博客。
附:《Java【2】双重检查锁(DCL)的原理与失效原因》
volatile变量可避免指令重排序。将instance变量修饰volatile关键字后,指令将按原顺序执行,故此程序将不会在产生线程问题。那volatile变量为什么能避免重排序呢?按书中P448的说法,volatile变量在被赋值后会多执行一条指令,这条指令在将volatile变量值同步回主内存(volatile变量会在赋值后立即同步回主内存的特性便是由该指令实现的)的同时会形成内存屏障,阻止后方指令重排序时排列在内存屏障之前。
Java内存模型只保证32位数据类型操作的原子性,对于64位数据类型(long和double)操作则由两个32位原子操作组成。对于为何如此设计书中并未提及,但可以知道的是对于64位数据类型,我们将可能遭遇“半值”情况,即读到一个改了一半的值。
线程A | 线程B | |
---|---|---|
T1 | 修改64位变量前32位 | |
T2 | 读取64位变量前32位 | |
T3 | 修改64位变量后32位 | |
T3 | 读取64位变量后32位 |
volatile关键字可以避免“半值”情况。如果将64位的数据类型声明volatile变量,可杜绝“半值”情况,因为当变量修饰volatile,原本的两次操作将合为一次原子操作,从而保证了64位数据的正确性。
建议对共享的64位变量修饰volatile关键字。关于是否修饰volatile,个人想法与书中不同。各大厂商在实现虚拟机时都优化了相关操作,使得几乎不会出现“半值”情况,但终究无法保证绝对正确,因此对于开发者而言,除非能完全确保无恙,否则还是建议主动修饰volatile关键字,并且volatile变量操作对性能的影响极小,也不会导致程序有太大波动。
Java内存模型三大特性:原子性、可见性与有序性。如果已经学习了前篇内容,那对于这三个特性一定有所感触,此处再进行一次总结。
原子性
Java内存模型通过read - road - use- assign - store - write这六个原子操作保证变量操作最小范围的原子性,更大范围的原子性则由lock - unlock实现。
需要提及的是,use与assign之间的变量计算操作属于执行引擎的职责范围,不归于内存模型,并且也不一定是(绝大部分都不是)原子操作,这也是volatile变量无法保证线程安全的根本原因。
可见性
普通变量无法保证可见性,但volatile变量可以,因为在use指令执行前会重新从主内存中读取最新值。除了volatile关键字外,final与synchrozied关键字也可以保证变量可见性。final变量为常量,不存在值被修改的问题;而synchrozied变量则是因为会对变量执行lock - unlock指令使之只能被被一条线程访问,并且在unlock指令执行前一定会将最新值同步至主内存。
有序性
所谓的有序性,个人理解是在指定环境(指单线程或多线程环境)内,线程对安全的共享变量进行访问,而安全的共享变量,是指共享变量的值是“最新且正确”的。
如果在本线程内观察,所有操作都是有序的;如果在一个线程内观察另一个线程,所有操作都是无序的。
前一句:对于单线程环境,可以保证线程访问到的共享变量是安全。即使发生了重排序,也不会改变存在依赖关系的指令顺序,即访问到的共享变量值全都是“最新且正确”的,也就是安全的。
后一句:对于多线程环境,无法保证某一线程访问到的共享变量是安全的,因为存在“重排序”与“工作内存与主内存同步延迟”现象。当线程B发生重排序时,线程A访问到的共享变量值未必是正确的(例双重检查锁(DCL)问题);而如果线程B对共享变量的操作结果未及时同步至主内存,那线程A访问到的共享变量值也未必是最新的。对于多线程环境无法保证有序性的问题,可以用volatile与synchrozied关键字来解决。volatile关键字可以保证Java内存模型范围内的有序性,因为其可以避免共享变量的重排序,并会第一时间将操作结果同步至主内存(即assign - store - write合并为原子操作);而synchrozied关键字可同时保证Java内存模型与执行引擎两个范围内的有序性,因为lock指令会使得只有一个线程可访问共享变量,而在单线程环境,即使发生了重排序也依然是有序的,且unlock指令执行前一定会将最新值同步至主内存,因此其它线程访问到共享变量一定是安全的。
先行发生原则(happens - before):如果操作A先行发生于操作B,其实就是说在发生操作B之前,操作A产生的影响能被操作B观察到。这是书中对于先行发生原则定义,但我个人对此并无法理解。书中一再强调先行发生原则与时间并无关联,却又在内容中与时间连连交集,甚至于“在发生操作B之前”一句本身就引入了时间的概念。我尝试着用定义去解释程序次序规则,但遗憾的是我没有做到这一点,以代码清单12 -10为例。
// 以下操作在统一线程中执行。
// 操作A。
int i = 1;
// 操作B。
int j = 2;
根据程序次序规则,操作A先行发生于操作B,但是由于两者没有依赖关系,在具体的执行中,操作B可能先于(时间概念)操作A发生,并且其结果对于操作A而言是开放的(即可被观察到的,虽然操作A没有去观察),那根据定义,操作B应该是先行发生于操作A,但这显然违背了程序次序规则,而书中对此“在这条线程中没办法感知到这一点”的解释更是令我啼笑皆非…无法感知这一点难道能否认这一点?因此本人对该节的知识还处于死记规则的等级。
【上篇】《深入理解Java虚拟机【十二】Java内存模型与线程【12.2】硬件的效率与一致性》
【下篇】《深入理解Java虚拟机【十二】Java内存模型与线程【12.4】Java与线程》