Java 并发编程基础 --Java 内存模型

用户头像
Java收录阁
关注
发布于: 2020 年 05 月 09 日

Java虚拟机规范中试图定义一种Java内存模型(Java Memory Model,JMM)来屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的内存访问效果。



主内存和工作内存

Java 内存模型的主要目标是定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量这样的底层细节。此处的变量与Java编程中所说的变量有所区别,它包括了实例字段、静态字段和构成数组对象的元素,但不包括局部变量与方法参数,因为这些是线程私有的,不会被共享,自然就不存在竞争问题。为了获得较好的执行效能,Java内存模型并没有限制执行引擎使用处理器的特定寄存器或者缓存来和主内存进行交互,也没有限制即使编译器进行调整代码执行顺序这类优化措施。



Java内存模型规定了所有的变量都要存储在主内存中(此处的主内存仅仅是虚拟机内存的一部分),每条线程还有自己的工作内存(working memory, 类似于处理器的高速缓存),线程的工作内存中保存了被该线程使用到的变量的主内存副本拷贝,线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读写主内存中的变量。不同线程之间也无法直接访问对方工作内存中的变量,线程之间变量值的传递均需要通过主内存来完成,线程、主内存、工作内存三者的交互关系如下图所示:



这里提到的主内存、工作内存与Java内存区域中的Java堆、栈、方法区等并不是同一个层次的内存划分,这两者基本没有关系;如果一定要勉强对应起来,那从变量、主内存、工作内存的定义来看,主内存主要对应Java堆中的对象实例数据那部分,而工作内存则对应于虚拟机栈中的部分区域。从更低层次上说,主内存就直接对应于物理硬件的内存,而为了获取更好的运行速度,虚拟机可能会让工作内存优先存储于寄存器和高速缓存中,因为程序运行时主要访问读写的是工作内存。



内存间交互操作

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

  1. lock(锁定):作用于主内存变量,它把一个变量标识为一条线程独占的状态

  2. unlock(解锁):作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其它线程锁定

  3. read(读取):作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的load动作使用

  4. load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中

  5. use(使用):作用于工作内存的变量,它把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用到变量的值的字节码指令时将会执行这个操作

  6. assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作

  7. store(存储):作用于工作内存的变量,它把工作内存中一个变量的值传送到主内存中,以便随后的write操作使用

  8. write(写入):作用于主内存的变量,它把store操作从工作内存中得到的变量的值放入主内存的变量中



如果要把一个变量从主内存复制到工作内存,那就要顺序地执行read和load操作;如果要把变量从工作内存同步回主内存,就要顺序地执行store和write操作。需要注意的是Java内存模型只要求上述两个操作必须按顺序执行,并没有保证是连续执行,也就是说read和load之间、store和write之间是可插入其他指令的。



对于volatile型变量的特殊规则

关键字volatile可以说是Java虚拟机提供的最轻量级的同步机制,但是它并不是很容易理解,所以很多时候我们需要处理多线程数据竞争的时候都使用synchronized来进行同步,了解volatile变量的语义对了解多线程操作的其它特性也很有意义。



Java内存模型对volatile专门定义了一些特殊的访问规则:

当一个变量定义为volatile之后,它将具备两种特性,第一是保证此变量对所有线程的可见性,这里的"可见性"是指当一条线程修改了这个变量的值,新值对于其他线程来说是可以立即得知的,而普通变量不能做到这一点,普通变量的值在线程间传递需要通过主内存来完成;例如:线程A修改一个普通变量的值,然后向主内存进行回写,另外一条线程B在线程A回写完成后再从主内存进行读取操作,新变量值才会对线程B可见。



关于volatile变量的可见性,经常会被开发人员无解,认为以下描述成立:volatile变量对所有线程是立即可见的,对volatile变量所有的写操作都能立刻反应到其它线程之中;换句话说就是volatile变量在各个线程中是一致的,所以基于volatile变量的运算在并发下是安全的。这句话的论据部分并没有错,但是这个论据不能得出"基于volatile变量的运算在并发下是安全的"这个结论。volatile变量在各个线程的工作内存中不存在一致性的问题(在各个线程的工作内存中,volatile变量也可以存在不一致的情况,但是由于每次使用前都要先刷新,执行引擎看不到不一致的情况,因此可以认为不存在一致性问题),但是Java里面的运算并非原子操作,导致volatile变量的运算在并发下一样是不安全的,我们可以通过下面代码来说明:

public class VolatileTest {
public static volatile int race = 0;
public static void increase() {
race++;
}
public static void main(String[] args) {
Thread[] threads = new Thread[20];
for (int i = 0; i < 20; i++) {
threads[i] = new Thread(() -> {
for (int j = 0; j < 1000; j++) {
increase();
}
});
threads[i].start();
}
while (Thread.activeCount() > 1) {
Thread.yield();
}
System.out.println(race);
}
}



这段代码发起了20个线程,每个线程对race变量进行10000次自增操作,如果这段代码能够正确并发的话,最后输出的结果应该是200000。但是当我们运行这段代码的时候发现并不能得到预期的结果,而且每次运行的结果都可能不一样,基本上都是一个小于200000的数字,这是为什么呢?



其实问题就出在自增运算race++这行代码,我们用javap 反编译这段代码后会得到下面代码清单:

public class com.concurrent.VolatileTest {
public static volatile int race;
public com.concurrent.VolatileTest();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
public static void increase();
Code:
0: getstatic #2 // Field race:I
3: iconst_1
4: iadd
5: putstatic #2 // Field race:I
8: return
public static void main(java.lang.String[]);
Code:
0: bipush 20
2: anewarray #3 // class java/lang/Thread
5: astore_1
6: iconst_0
7: istore_2
8: iload_2
9: bipush 20
11: if_icmpge 41
14: aload_1
15: iload_2
16: new #3 // class java/lang/Thread
19: dup
20: invokedynamic #4, 0 // InvokeDynamic #0:run:()Ljava/lang/Runnable;
25: invokespecial #5 // Method java/lang/Thread."<init>":(Ljava/lang/Runnable;)V
28: aastore
29: aload_1
30: iload_2
31: aaload
32: invokevirtual #6 // Method java/lang/Thread.start:()V
35: iinc 2, 1
38: goto 8
41: invokestatic #7 // Method java/lang/Thread.activeCount:()I
44: iconst_1
45: if_icmple 54
48: invokestatic #8 // Method java/lang/Thread.yield:()V
51: goto 41
54: getstatic #9 // Field java/lang/System.out:Ljava/io/PrintStream;
57: getstatic #2 // Field race:I
60: invokevirtual #10 // Method java/io/PrintStream.println:(I)V
63: return
static {};
Code:
0: iconst_0
1: putstatic #2 // Field race:I
4: return
}



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



由于volatile变量只能保证可见性,在不符合下面两条规则的运算场景中,我们仍然要通过加锁来保证原子性:

  1. 运算结果并不依赖变量的当前值,或者能够确保只有单一的线程修改变量的值

  2. 变量不需要于其他的状态变量共同参与不变约束



而在像下面这样的代码所展示的场景就很适合使用volatile变量来控制并发,当shutdown()方法被调用时,能保证所有线程中执行的doWork()方法都立即停下来:

volatile boolean shutDownRequested;
public void shutdown() {
shutDownRequested = true;
}
public void doWork() {
while (!shutDownRequested) {
// do something
}
}



使用volatile变量的第二个语义是禁止指令重排序优化,普通的变量仅仅会保证在该方法的执行过程中,所有依赖赋值结果的地方都能获取到正确的结果,而不能保证变量赋值操作的顺序于程序代码中的执行顺序一致。因为在一个线程的方法执行过程中无法感知到这一点,这也就是Java内存模型中描述的所谓的"线程内表现为串行的语义"。



我们可以通过下面伪代码示例看看为何指令重排会干扰程序的并发执行:

Map configOptions;
char[] configText;
// 此变量必须是volatile
volatile boolean initialized= false;
// 假设下面代码在线程A中执行
// 模拟读取配置信息,当读取完成后将initialized设置为true以通知其他线程配置可用
configOptions = new HashMap();
configText = readConfigFile(fileName);
processConfigOptions(configText, configOptions);
initialized= true;
// 假设下面代码在线程B中执行
// 等待initlizated 为true,代表线程A已经把配置信息初始化完成
while (!initialized) {
sleep();
}
// 使用线程A中初始化好的配置
doSomethingWithConfig();



上面伪代码描述的场景十分常见,只是我们在处理配置文件时一般不会出现并发而已。如果定义initialized变量时没有使用volatile修饰,就可能会由于指令重排序的优化,导致位于线程A中最后一句代码initialized=true被提前执行,这样在线程B中使用配置信息的代码就可能出现错误,而volatile关键字则可以避免此类情况的发生。



指令重排序是并发编程中最容易让开发人员产生疑惑的地方,除了上面的伪代码的例子之外,我们可以再通过一个例子来分析volatile关键字是如何禁止指令重排序优化的,下面是单例的代码,可以观察加入volatile和未加volatile关键字时所生成的汇编代码的差别:

public class LazyDoubleCheckSingleton {
private static volatile LazyDoubleCheckSingleton instance;
private LazyDoubleCheckSingleton() {}
public static LazyDoubleCheckSingleton getInstance() {
if (instance == null) {
synchronized (LazyDoubleCheckSingleton.class) {
if (instance == null) {
instance = new LazyDoubleCheckSingleton();
}
}
}
return instance;
}
}



编译后,这段代码的汇编指令如下:

public class com.designpattern.singleton.LazyDoubleCheckSingleton {
public static com.designpattern.singleton.LazyDoubleCheckSingleton getInstance();
Code:
0: getstatic #2 // Field instance:Lcom/designpattern/singleton/LazyDoubleCheckSingleton;
3: ifnonnull 37
6: ldc #3 // class com/designpattern/singleton/LazyDoubleCheckSingleton
8: dup
9: astore_0
10: monitorenter
11: getstatic #2 // Field instance:Lcom/designpattern/singleton/LazyDoubleCheckSingleton;
14: ifnonnull 27
17: new #3 // class com/designpattern/singleton/LazyDoubleCheckSingleton
20: dup
21: invokespecial #4 // Method "<init>":()V
24: putstatic #2 // Field instance:Lcom/designpattern/singleton/LazyDoubleCheckSingleton;
27: aload_0
28: monitorexit
29: goto 37
32: astore_1
33: aload_0
34: monitorexit
35: aload_1
36: athrow
37: getstatic #2 // Field instance:Lcom/designpattern/singleton/LazyDoubleCheckSingleton;
40: areturn
Exception table:
from to target type
11 29 32 any
32 35 32 any
}



通过对比发现,关键变化在于有volatile修饰的变量,赋值后多执行了一个lock addl $0x0操作,这个操作相当于一个内存屏障,指令重排序时不能把后面的指令重排序到内存屏障之前的位置。指令中addl $0x0(%esp)是一个空操作,关键在于lock前缀,它的作用是使得本CPU的Cache写入了内存,所以通过这样一个空操作,可让前面volatile变量的修改对其它CPU立即可见。



发布于: 2020 年 05 月 09 日 阅读数: 129
用户头像

Java收录阁

关注

士不可以不弘毅,任重而道远 2020.04.30 加入

喜欢收集整理Java相关技术文档的程序员,欢迎关注同名微信公众号 Java收录 阁获取更多文章

评论

发布
暂无评论
Java并发编程基础--Java内存模型