JVM

【深入理解Java虚拟机】【12】Java内存模型与线程

Posted by Charlie on 2019-09-12

[TOC]

Java内存模型

Java内存模型(JMM Java Memory Model):屏蔽掉各种硬件和操作系统的内存访问差异,以实现Java程序在各个平台下都能达到一致的内存访问效果。

(在此之前,C/C++直接使用物理硬件和操作系统的内存模型,会由于不同平台上内存模型的差异,导致程序在以套平台上并发完全正常,到另一平台上并发访问经常出错。)

经过长时间验证和修补,JDK1.5(实现了JSR-133)发布后,Java内存模型成熟和完善起来。

JSR-133:Java Memory Model and Thread Specification Revision Java内存模型和线程规范修订。

https://download.oracle.com/otndocs/jcp/memory_model-1.0-pfd-spec-oth-JSpec/

主内存和工作内存

Java内存模型规定了所有的变量都存储在主内存里。

每条线程有自己的工作内存,线程的工作内存保存了被该线程使用到的变量的主内存副本的拷贝。

线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读写主内存中的变量。

不同线程之间无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存完成。

这里所讲的主内存、工作内存与本书第2章所讲的Java内存区域中的Java堆、栈、方法区 等并不是同一个层次的内存划分,这两者基本上是没有关系的

image-20190912161838064

内存间交互操作

交互协议:一个变量如何从主内存拷贝到工作内存、如何从工作内存同步回主内存

Java内存模型定义了以下8种操作,虚拟机实现时需要保证每个操作都是原子的、不可再分的。

操作的规定

这8中内存操作及上述规定,加上对volatile的特殊规定,完全确定了Java程序中哪些内存操作在并发下是安全的。

最新的JSR-133文档,已经放弃采用这8种操作去定义Java内存模型访问协议了。后面会介绍等效判断原则——先行发生原则,来确定一个访问在并发环境下是否安全。

对于volatile型变量的特殊规则

volatile语义

保证可见性

volatile错误的用法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
/**
*volatile变量自增运算测试
*
*@author zzm
*/
public class VolatileTest{
public static volatile int race=0
public static void increase(){
race++;
}
private static final int THREADS_COUNT=20
public static void main(String[]args){
Thread[]threads=new Thread[THREADS_COUNT];
forint i=0;i<THREADS_COUNT;i++){
threads[i]=new Thread(new Runnable(){
@Override
public void run(){
forint i=0;i<10000;i++){
increase();
}
}
});
threads[i].start();
}
//等待所有累加线程都结束
while(Thread.activeCount()>1
Thread.yield();
System.out.println(race);
}
}

这段代码发起了20个线程,每个线程对race变量进行10000次自增操作,如果这段代码能 够正确并发的话,最后输出的结果应该是200000。但是实际执行的输出结果,每次都是一个小于200000的数字,这说明这段代码并没有做到正确的并发执行。

问题就出现在自增运算“race++”之中。

使用javap查看increase()方法的字节码,可以分析出原因在哪

1
2
3
4
5
6
7
8
9
10
11
public static void increase();
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
8return
LineNumberTable:
line 140
line 158

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

volatile正确的用法

1
2
3
4
5
6
7
8
9
volatile boolean shutdownRequested;
public void shutdown(){
shutdownRequested=true
}
public void doWork(){
while(!shutdownRequested){
//do stuff
}
}

***由于volatile变量只能保证可见性,在不符合以下两条规则的运算场景中,我们仍然要通 过加锁(使用synchronized或java.util.concurrent中的原子类)来保证原子性。 ***

禁止指令重排列

看一个因为指令重排列会导致错误的例子:

1
2
3
Map configOptions;
//此变量必须定义为volatile
volatile boolean initialized = false;

下面代码在线程A中执行,解析配置信息,解析配置后,将initialized设为true,通知其他线程

1
2
configOptions = processConfigOptions();
initialized = true;

下面代码在线程B中执行,等待nitialized设为true,对配置信息进行操作。

1
2
3
4
while (!initialized) {
sleep();
}
doSomethingWithConfig();

如果nitialized变量不用volatile进行修饰,可能会由于重排序优化,导致线程A中initialized=true被提前执行,导致程序错误。

(重排序优化是机器级的优化,在此提前执行值对应的汇编代码被提前执行)

1
2
3
4
5
6
7
8
9
10
11
线程A  
initialized=true

线程B
while (!initialized) {
sleep();
}
doSomethingWithConfig();

线程A
configOptions = processConfigOptions();

如何禁止执行重排序的?会插入一些内存屏障汇编机器指令,使重排序时无法将指令重排序到内存屏障之前或之后的位置。

volatile变量的注意点

volatile只能保证可见性,在以下场景中,需要使用锁(synchronized 或 juc包中的原子类)来保证原子性。

1)运算结果依赖变量的当前值

2)变量需要与其他的状态变量来共同构成不变性约束

对于long和double型变量的特殊规则

Java内存模型允许没有被volatile修饰的64位数据(long和double)划分为两次32位操作。

目前,商用虚拟机都选择把64位数据的读写作为原子操作看待,因此,写代码时,不需要专门将long和double声明为volatile。

原子性、可见性、有序性

Java内存模型是围绕并发过程中如何处理原子性、可见性和有序性这3个特征来建立的。

哪些操作实现了这3个特征那?

原子性:

1)对于基本数据类型的访问读写是具备原子性的。

2)synchronized块之间操作是原子性的(通过monitorenter和monitorexit两条指令)。

可见性:

可见性指当一个线程修改了共享变量的值,其他线程能够立即得知到。

Java内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值来实现可见性的。

1)volatile保证可见性:保证新值能够立即同步到主内存,每次使用前从主内存刷新。

Java内存模型是通 过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值这种依赖主内存作 为传递媒介的方式来实现可见性的

2)synchronized同步块保证可见性:对一个变量unlock之前,必须先把此变量同步回主内存

3)final保证可见性:被final修饰的字段在构造器中一但初始化完成,并且构造器没有把”this“引用传递出去,那其他线程就能够看到final字段的值。

有序性:

如果在本线程内观察,所有的操作都是有序的

(线程内表现为串行的语义:方法执行过程中所有依赖赋值结果的地方都能获取到正确的结果,但不保证变量赋值操作的顺序与程序代码中的顺序一致)

如果在一个线程观察另一个线程,所有的操作都是无序的

("指令重排序"现象 和 ”工作内存与主内存同步延迟“现象)。

1)volatile关键字通过禁止指令重排序的语义保证线程之间操作的有序性

2)synchronized通过”一个变量在同一个时刻只运行一个线程对其进行lock操作“这条规则,保证持有同一个锁的两个同步块只能串行进入

先行发生原则(happens-before)

先行发生原则指的是什么?

如果操作A先行发生于B,那么在发生操作B之前,操作A产生的影响能够被B观察到,影响包括修改了内存中共享变量的值、调用了方法等。

先行发生原则可以用来做什么那?

可以用于判断数据是否存在竞争、线程是否安全。

Java内存模型下有以下天然的先行发生关系,无须任何同步协助就已经存在。

如果两个操作之间的关系不在此列,且无法根据下列规则推到出来,它们就没有顺序性保障,虚拟机可对他们随意重排序。

Java与线程

线程是比进程更轻量级的调度执行单位,线程的引入,可以把一个进程的资 源分配和执行调度分开,各个线程既可以共享进程资源(内存地址、文件I/O等),又可以 独立调度(线程是CPU调度的基本单位)。

Java语言则提供了在不同硬件和操作系统平台下对 线程操作的统一处理,每个已经执行start()且还未结束的java.lang.Thread类的实例就代表 了一个线程。我们注意到Thread类与大部分的Java API有显著的差别,它的所有关键方法都 是声明为Native的。在Java API中,一个Native方法往往意味着这个方法没有使用或无法使用 平台无关的手段来实现(当然也可能是为了执行效率而使用Native方法,不过,通常最高效 率的手段也就是平台相关的手段)。

线程的实现