Java内存模型与线程

5次阅读

共计 5173 个字符,预计需要花费 13 分钟才能阅读完成。

对《深入理解 Java 虚拟机——周志明》中第 12 章的总结
概述
硬件效率与一致性
为了解决处理器与内存之间的速度矛盾,引入了基于高速缓存的存储交互。但高速缓存的引入也带来了新的问题:缓存一致性,即多处理器中,每个处理器有各自的高速缓存,而他们又共享同一主内存。当多个处理器的运算任务额都涉及同一块主存区域的时候,将可能导致各自的缓存数据不一致,如果真的发生这种情况,那么同步回到主存时以谁的缓存数据为准呢?为了解决一致性的问题,需要各个处理器在访问缓存时都遵循一些协议,在读写时要根据协议来进行操作。在本章中将会多次提到“内存模型”一词,可以理解为在特定的操作协议下,对特定的内存或高速缓存进行读写访问时的过程抽象。不同的物理机器可以拥有不同的内存模型。而 Java 虚拟机也拥有自己的内存模型,并且在这里的内存访问操作与硬件的访问操作具有很高的可比性。
Java 内存模型
Java 虚拟机规范中试图定义一种 Java 内存模型 (Java Memory Model,JMM) 来屏蔽掉各种硬件和操作系统的内存访问差异,以实现让 Java 程序在各种平台下都能达到一致的内存访问效果。在 JDK1.5(实现了 JSR-133)发布后,Java 内存模型已经成熟和完善起来了。
主内存和工作内存
Java 内存模型的主要目标是定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量这样的底层细节。这里的变量不包括局部变量和参数,因为其实线程私有的,不会被共享 (但局部引用变量所指向的对象仍然是可共享的),自然不会存在竞争问题。Java 内存模型规定了所有的变量都存储在主内存中(此处的主内存与介绍物理硬件时的主内存名字一样,两者也可以相互类比,但此处仅是虚拟机内存的一部分),每条线程还有自己的工作内存(可与高速缓存类比),线程的工作内存中保存了被该线程使用到的变量的主内存副本拷贝,线程对变量的所有操作(读取赋值) 都必须在工作内存中进行,而不能直接读写主内存中的变量(包括 volatile 变量也是这样)。不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成。
内存间交互操作
关于主内存和工作内存之间具体的交互协议:即一个变量如何从主内存拷贝到工作内存、如何从工作内存同步回主内存之类的实现细节。Java 内存模型中定义了以下 8 种操作来完成,虚拟机实现时必须保证下面提及的每一种操作都是原子的、不可再分的(对于 double、long 类型的变量来说,load、store、read 和 write 操作在某些平台上允许有例外)。

lock
unlock
read
load
use
assign
store
write

       如果要把一个变量从主内存复制到工作内存,那么就要顺序地执行 read 和 load 操作,如果要把变量从工作内存同步回主内存,就要顺序地执行 store 和 write 操作。注意:Java 内存模型只要求上述两个操作必须按顺序执行,而没有保证是连续执行。也就是说 read 和 write 之间是可以插入其他指令的,如对主内存的变量 a、b 进行访问时,一种可能出现的顺序是 read a、read b、load b、load a。除此之外,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 操作)。

       这八种内存访问操作以及上述规则限定,再加上稍后介绍的对 volatile 的一些特殊规定,就已经完全确定了 Java 程序中哪些内存访问操作在并发下是安全的。由于这种定义相当严谨但确实比较繁琐,实践起来比较麻烦,所以后面会介绍这种定义的一个等效判断原则————先行发生原则,用来确定一个访问在并发环境下是否安全。
对于 volatile 型变量的特殊规则
关键字可以说是 Java 虚拟机提供的最轻量级的同步机制,但它并不是很容易完全被正确、完整地理解。以至于许多程序员都习惯不去使用它,遇到需要处理多线程数据竞争的时候一律使用 synchronized 来进行同步。当将一个变量定义为 volatile 之后,它将具备两种特性,第一是保证此变量对所有线程的可见性,这里的可见性是指当一条线程修改了这个变量的值,新值对于其他线程来说是可以立即得知的。而普通变量不能保证这一点,普通变量的值在线程间传递均需要通过主内存来完成,例如,线程 A 修改一个普通变量的值,然后向主内存进行回写,另一条线程 B 在线程 A 回写完成了之后在从主内存进行读取操作,新变量的值才会对线程 B 可见。第二是禁止指令重排序优化,普通的变量仅仅会保证在该方法执行的过程中所有依赖赋值的结果都能获取到正确的结果,而不能保证变量赋值操作的顺序与程序代码中的执行顺序一致。因为在一个线程的方法执行过程中无法感知这点,这就是 Java 内存模型当中所描述的“线程内表现为串行”的语义(Within Thread As-If Serial Semantics)。
可见性
关于 volatile 可见性:volatile 变量是对所有线程可见的,对 volatile 变量所有的写操作都能立刻反映到其他线程之中,换言之,volatile 变量在各个线程中是一致的。但一致并不代表基于 volatile 变量的运算在并发下是安全的。volatile 变量在各个线程的工作内存中不存在一致性的问题(在各个线程的工作内存中,volatile 变量也可以存在不一致的情况,但在每次使用之前都要先刷新,执行引擎看不到不一致的情况,所以可以认为不存在一致性的问题),但 Java 里面的运算并非是原子操作,导致 volatile;变量在并发下一样是不安全的。比如下例:
/**
* volatile 变量自增测试运算
* @author xpc
* @date 2018 年 12 月 16 日下午 8:40:02
*/
public class VolatileTest {
public static volatile int race=0;
public static void increase() {
race++;
}
public static final int THREADCOUNT=20;
public static void main(String[] args) {
Thread[] threads=new Thread[THREADCOUNT];
for(int i=0;i<THREADCOUNT;i++) {
threads[i]=new Thread(()->{
for(int j=0;j<10000;j++)
increase();
});
threads[i].start();
}
while(Thread.activeCount()>1) {
Thread.yield();
}
System.out.println(race);// 最后打印的结果是小于 20*10000 即 200000 的数
}
}
其自增部分对应的字节码为
public static void increase();
descriptor: ()V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=0, args_size=0
0: getstatic #13 // Field race:I
3: iconst_1
4: iadd
5: putstatic #13 // Field race:I
8: return
LineNumberTable:
line 11: 0
line 12: 8
LocalVariableTable:
Start Length Slot Name Signature
之所以最后输出的结果小于 200000,并且每次运行程序输出的结果都不一样。问题就出现在自增运算 race++ 上,反编译后发现一个 race++ 会产生 4 条字节码指令(不包括 return),从字节码层面很容易分析出并发失败的原因:当 getstatic 指令把 race 的值取到操作栈顶时,volatile 关键字保证了 race 的值在此时是正确的,但在执行 iconst_1、iadd 这些指令的时候,其他线程可能已经把 race 的值加大了,而在操作栈顶的值就变成了过期的数据,所以 putstatic 指令执行后就可能把较小的 race 值同步回主内存之中。这里使用字节码来分析并发问题仍然是不严谨的,因为即使编译出来只有一条字节码指令,也不意味这执行这条指令就是一个原子操作。一个字节码指令在解释执行时,解释器将要运行许多行代码才能实现它的语义。如果是编译执行,一条字节码指令可能转化为若干条本地机器码指令。由于 volatile 只能保证可见性,在不符合以下两条规则的运算场景中,仍然要通过加锁 (使用 synchronized 或者 java.util.concurrent 中的原子类) 来保证原子性。同时满足以下两条规则的运算场景才适合使用 volatile 去保证原子性

运算结果并不依赖变量的当前值,或者,能够确保只有单一线程修改变量的值
变量不需要与其他的状态变量共同参与不变约束

满足第一条但不满足第二条的一个例子:
volatile static int start = 3;
volatile static int end = 6;
// 只有线程 B 修改变量的值,满足了第一条。尽管不满足运算结果不依赖变量的当前值,false||ture==ture

线程 A 执行如下代码:
while (start < end){
//do something
}

线程 B 执行如下代码:
start+=3;
end+=3;
适合使用 volatile 来控制并发的场景的例子,当 shutdown()方法被调用时,能保证所有线程中执行的 doWork()方法都立即停止下来。
volatile boolean shutdownRequested;

public void shutdown(){
shutdownRequested=ture;
}

public void doWork(){
while(!shutdownRequested){
//do stuff
}
}
禁止指令重排序
指令重排序干扰程序的并发执行的例子
Map configOptions;
char[] configText;
// 此变量必须定义为 volatile
volatile boolean initialized=false;

// 假设以下代码在线程 A 中执行
// 模拟读取配置信息,当读取完后将 initialized 设置为 true 以通知其他线程配置可用
configOptions=new HashMap();
configText=readConfigFile(fileName);
processConfigOptions(configText,configOptions);
initialized=true;

// 假设以下代码在线程 B 中执行
// 等待 initialized 为 true,代表线程 A 已经把配置信息处理化完成
while(!initialized){
sleep();
}
// 使用线程 A 初始化好的配置信息
doSomethingWithConfig();
在这个例子中,如果定义 initialized 变量时没有使用 volatile 修饰,就可能会由于指令重排序的优化,导致位于线程 A 中的最后一句的代码 initialized=true; 被提前执行(虽然使用 java 代码来作为伪代码,但所指的重排序优化是机器级的优化操作,提前执行是值这句话对应的汇编代码被提前执行),这样在线程 B 中使用配置信息的代码就可能出现错误,而 volatile 关键字则可以避免此类情况的发生。

正文完
 0