Java内存模型与线程

Java内存模型与线程

Java内存模型

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

主内存与工作内存

Java内存模型的主要目标是定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量这样的底层细节,这里的变量指实例字段,静态字段和构成数组对象的元素,而不包括局部变量和方法参数,因为它们是线程私有的,不会被共享

Java内存模型规定所有变量都存储在主内存中,每条线程都有自己的工作内存(可以类比高速缓存),线程的工作内存中保存了被该线程使用的变量的主内存副本拷贝,线程对变量的所有操作(读取,赋值等)都必须在工作内存中进行,而不能直接读写主内存中的变量,不同线程之间也不能直接访问对方工作内存的变量,线程间变量值的传递均需要通过主内存来完成,如下图所示:

内存间交互操作

Java内存模型定义了下面8种操作来完成主内存与工作内存之间的交互协议,虚拟机实现必须保证这8种操作的每一种都是原子性的

  • lock:作用于主内存的变量,它把一个变量标识为一条线程独占的状态
  • unlock:作用于主内存的变量,它把一个处于锁定状态的变量释放出来
  • read:作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的load动作使用
  • load:作用于工作内存的变量,它把read操作从主内存得到的变量值放入工作内存的变量副本中
  • use:作用于工作内存的变量,它把工作内存中的一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值得字节码指令时就会执行这个操作
  • assign:作用于工作内存的变量,它把一个从执行引擎收到的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作
  • store:作用于工作内存的变量,它把工作内存中一个变量的值传送给主内存中,以便随后的write操作使用
  • write:作用于主内存的变量,它把store操作从工作内存得到的变量值写入主内存的变量中

read和load,store和write必须顺序执行(但可以不连续),上述8种操作还必须满足下面规则:

  • 不允许read和load,store和write操作之一单独出现,即不允许一个变量从主内存读取了但工作内存不接受,或者从工作内存发起回写了但主内存不接受的情况出现
  • 不允许一个线程丢弃它的最近的assign操作,即变量在工作内存中改变了之后必须把该变化同步写回主内存
  • 不允许一个线程无原因地(没有发生过任何assign操作)把数据从线程的工作内存同步回主内存中
  • 一个新的变量只能在主内存中诞生,不允许在工作内存中直接使用一个未被初始化(load,assign)的变量,换句话说,就是对一个变量实施use,store之前,必须先执行过了assign和load操作
  • 一个变量在同一时刻只允许一条线程对其进行lock操作,但lock操作可以被同一条线程重复执行多次,多次执行lock之后,只有执行相同次数的unlock,变量才会被解锁
  • 如果对一个变量执行lock操作,那将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行load或assign操作初始化变量的值
  • 如果一个变量实现没有被lock操作锁定,那就不允许对它执行unlock操作,也不允许去unlock一个被其他线程锁定的变量
  • 对一个变量执行unlock之前,必须先把此变量同步回主内存中

对于volatile型变量的特殊规则

volatile可以说是Java虚拟机提供的最轻量级的同步机制,当一个变量定义volatile后,它将具备两种特性:

第一是保证此变量对所有线程的可见性,可见性指一条线程修改了这个变量的值,新值对于其他线程来说是可以立即得知的,而普通变量做不到这一点,普通变量的值在线程间传递均需要通过主内存来完成(可见性并不能保证并发安全,详见另一篇blog:Java并发基础2-同步)

volatile适用于以下的运算场景:

  • 运行结果不依赖变量当前值(即不能count=count+t,可以count=t),或者能够确保只有单一的线程修改变量的值
  • 变量不需要与其他状态变量共同参与不变约束

下面例子就很适合使用volatile变量来控制并发

1
2
3
4
5
6
7
8
9
10
11
volatile boolean shutdownRequested;

public void shutdown() {
shutdownRequested = true;
}

public void doWork() {
while (!shutdownRequested) {
// do stuff
}
}

使用volatile的第二个语义是禁止指令重排序优化,普通的变量仅仅会保证在该方法的执行过程中所有依赖赋值结果的地方都能获取正确的结果,而不能保证变量赋值与程序代码中的执行顺序一致

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Map configOptions;
char[] configText;
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();
}
doSomethingWithConfig();

如果不声明为volatile,则可能会由于指令重排而导致initialized被提前执行,在配置没有配置好的情况下线程B就使用配置了

volatile变量读操作的性能消耗和普通变量几乎没有差别,但是写操作会有一些额外的开销,因为它需要在本地代码插入许多内存屏障指令来保证处理器不发送乱序执行,大多情况下,volatile的开销都比锁要低

volatile的特殊规则(操作层面略):

  • 在工作内存中,每次使用volatile的变量时都必须先从主内存刷新最新的值,用于保证能看见其他线程对变量所做的修改后的值
  • 在工作内存中,每次修改volatile的变量时都必须立即同步回主内存中,用于保证其他线程可以看到自己对变量所做的修改
  • 要求volatile修饰的变量不会被指令重排序优化,保证代码的执行顺序与程序顺序相同

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

Java内存模型要求lock,unlock等8个操作都具有原子性,但是对于64位的数据类型,允许虚拟机将没有被volatile修饰的64位数据的读写操作划分为2次32位的操作来进行,即允许虚拟机实现选择可以不保证64位数据类型的load,store,read和write的原子性,这就是long和double的非原子性协定

如果有多个线程共享一个未声明为volatile的long或double变量,并且同时对它们进行读取和修改操作,则可能会读取到一个非原值也不是其他线程修改值的数值,但这种情况很少出现,因为虽然规范允许long和double的读写不是原子操作,但还是强烈建议虚拟机实现把这些操作实现为具有原子性的操作,且虚拟机实现几乎都把64位数据的读写操作作为原子操作来对待

原子性,可见性与有序性

原子性:由Java内存模型来直接保证原子性变量操作包括read,load,assign,use,store和write,我们大致可以认为基本类型的访问读写是具备原子性的
synchronized块的内部其实就是lock与unlock操作,块之间的操作也具备原子性

可见性:可见性指一个线程修改了共享变量的值,其他线程能够立即知道这个修改。Java内存模型是通过在变量修改后将新值立即同步回主内存,在变量读取前从主内存刷新变量值这种依赖主内存作为传递媒介的方式来实现可见性的,除了volatile,Java还有两个关键字可以实现可见性,即synchronized和final

有序性:Java程序天然的有序性可以总结为一句话:如果在本线程内观察,所有的操作都是有序的;如果在一个线程中观察另一个线程,所有的操作都是无序的。前半句指”线程内表现为串行的语义”(即使重排序,单线程内最终结果也不会改变),后半句指”指令重排序”现象和”工作内存与主内存同步延迟”现象

先行发生原则

先行发生是Java内存模型中定义的两项操作之间的偏序关系,如果说操作A先行发生于操作B,其实就是说发生操作B之前,操作A产生的影响能被操作B观察到,”影响”包括修改了内存中共享变量的值,发送了消息,调用了方法等

看下面的例子

1
2
3
4
5
6
7
8
// 以下操作在线程A中执行
i = 1;

// 以下操作在线程B中执行
j = i;

// 以下操作在线程C中执行
i = 2;

假设线程A中的操作先行发送于线程B,那么可以确定线程B执行后,j一定等于1,得出这个结论的依据有两个:一是根据先行发生原则,i=1的结果可以被观察到,二是线程C还没有执行
如果线程A和线程B之间的先行发生仍然保持,但线程C出现在线程A和线程B的操作之间,但是线程C与线程B没有先行发生关系,那j的值是不确定的,有可能是1也有可能是2,因为线程C对变量i的影响可能会被线程B观察到,也可能不会,这时线程B就存在读取到过期数据的风险,不具备多线程安全性

下面是Java内存模型下一些天然的先行发生关系,不需要任何同步器就已经存在,可以在编码中直接使用

  • 程序次序规则:在一个线程内,按照程序代码顺序,书写在前面的操作先行发生于书写在后面的操作
  • 管程锁定规则:一个unlock操作先行发生于后面对同一个锁的lock的操作。强调同一个锁,且”后面”指时间上的先后顺序
  • volatile变量规则:对一个volatile变量的写操作先行发生于后面对这个变量的读操作,这里的”后面”同样指时间上的先后顺序
  • 线程启动规则:Thread对象的start()方法先行发生于此线程的每一个动作
  • 线程终止规则:线程中的所有操作先行发生于对此线程的终止检测,我们可以通过Thread.join()方法结束,Thread.isAlive()的返回值等手段检测到线程已经终止执行
  • 线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断时间的发生
  • 对象终结规则:一个对象的初始化完成先行发生于它的finalize()方法的开始
  • 传递性:如果操作A先行发生于操作B,操作B先行发生于操作C,那就可以得出操作A先行发生于操作C的结论

注意”时间上的先后顺序”和”先行发生”之间没有任何关联,看下面的例子

1
2
3
4
5
6
7
8
9
private int value = 0;

public void setValue(int value) {
this.value = value;
}

public int getValue() {
return value;
}

线程A先(时间上的先后)调用了”setValue(1)”,然后线程B调用了同一个对象的”getValue()”,那么线程B会收到什么?
依次分析先行发生原则中的各项规则,发现没有任何一个适合的先行发生规则,那么就可以说明,尽管线程A在操作时间上是先于线程B,但是无法确定线程B中”getValue()”方法的返回结果,换句话说,这里面的操作不是线程安全的
要修复这个问题,可以把getter/setter定义为synchronized方法,就可以套用管程锁定规则,要么就把value定义为volatile,这样就可以套用volatile变量规则

通过上面例子,我们可以得出结论:一个操作”时间上的先发生”不代表这个操作会是”先行发生”,同样也可以证明一个操作”先行发生”不能推导出这个操作必定是”时间上的先发生”

1
2
3
// 下面操作在同一线程执行
int i = 1;
int j = 2;

他们是符合先行发生规则的,但是由于指令重排,他们不一定是时间上的先发生

综上,时间先后顺序和先行发生原则之间没有太大的关系,衡量并发安全问题的时候不要受到时间顺序的干扰,一切必须以先行发生原则为准

Java与线程

线程的实现

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

主流的OS都提供了线程实现,Java语言则提供了不同硬件和OS下对线程操作的统一处理,每个已经执行了start()且还未结束的java.lang.Thread类的实例代表了一个线程,Thread类所有关键方法都是Native的,即这个方法没有使用或无法使用平台无关的手段来实现,实现线程主要有3种方式:使用内核线程实现,使用用户线程实现和使用用户线程加轻量级进程混合实现

使用内核线程实现

内核线程(KLT)就是直接由OS内核支持的线程,这种线程由内核来完成线程切换,内核通过操纵调度器对线程进行调度,并负责将线程任务映射到各个处理器上
程序一般不会直接去使用内核线程,而是去使用内核线程的一种高级接口——轻量级进程(LWP),轻量级进程就是我们通常意义上所讲的线程,每个轻量级进程都由一个内核线程支持,这种轻量级进程与内核线程之间1:1的关系称为一对一的线程模型

(P代表进程,KLT代表内核线程,LWP代表轻量级进程)

由于内核线程的支持,每个轻量级进程都成为一个独立的调度单位,即使有一个轻量级进程在系统调用中阻塞了,也不会影响整个进程继续工作

轻量级进程局限性表现为:基于内核线程实现,所以各种线程操作,如创建,析构及同步,都需要进行系统调用,而系统调用的代价相对较高,需要在用户态和内核态中来回切换,其次,每个轻量级进程都需要有一个内核线程的支持,因此轻量进程要消耗一定的内核资源,因此一个系统支持轻量级进程的数量有限

使用用户线程实现

广义上说,一个线程只要不是内核线程,就可以认为是用户线程,狭义上说,用户线程是指完全建立在用户空间的线程库上,系统内核不能感知线程存在的实现。用户线程的建立,同步,销毁和调度完全在用户态中完成,不需要内核的帮助。如果程序实现得当,这种线程不需要切换到内核态,因此操作可以是非常快速且低消耗的,也可以支持规模更大的线程数量,部分高性能数据库中的多线程就是由用户线程实现,这种进程与用户线程之间1:N的关系称为一对多的线程模型

(UT代表用户线程,P代表进程)

用户线程的优势和劣势都体现在不需要系统内核支援,因此诸如”阻塞如何处理”,”多处理器系统中如何将线程映射到其他处理器上”等问题解决起来异常困难,甚至不可能完成,所以用的很少

使用用户线程加轻量级进程混合实现

在这种混合实现下,既存在用户线程,也存在轻量级进程。用户线程完全建立在用户空间中,因此创建,切换等操作依旧廉价,并且可以支持大规模的用户线程并发,OS提供支持的轻量级进程则作为用户线程和内核线程之间的桥梁,这样可以使用内核提供的线程调度功能及处理器映射,并且用户线程的系统调用要通过轻量级进程完成,大大降低整个进程被完全阻塞的风险。这种关系是N:M关系

Java线程的实现

目前的JDK版本中,OS支持怎样的线程模型,很大程度上决定了Java虚拟机的线程是怎么样映射的,这点在不同的平台上没有办法达成一致,虚拟机规范也没有限定Java线程要使用哪种模型实现

对于Sun JDK来说,它的Windows版本和Linux都是使用一对一的线程模型实现的,一条Java线程就映射一条轻量级进程中

Java线程调度

线程调度是指系统为线程分配处理器使用权的过程,主要调度方式有两种,分别是协同式线程调度和抢占式线程调度

协同式调度,线程的执行时间由线程本身决定,线程把自己的工作执行完了之后,要主动通知系统切换到另外一个线程上
抢占式调度,每个线程由系统来分配执行时间,线程的切换不由线程本身决定(当然,可以使用Thread.yield()让出执行时间),Java使用的线程调度方式就是抢占式调度

可以通过设置优先级来给某些线程多分配一些执行时间,Java提供了从Thread.MIN_PRIORITY到Thread.MAX_PRIORITY共10种优先级,两个线程同时处于Ready状态时,高优先级的线程越容易被系统选择执行

Windows系统只提供了7种优先级,会导致Java优先级会有几个相同,如下图所示:

状态转换

Java语言定义了5种线程状态,任意一个时间点,一个线程只能有且只有其中的一种状态,这5种状态分别如下:

  • 新建New:创建后未启动的线程处于这种状态
  • 运行Runable:包括了操作系统线程状态中的Running和Ready,也就是处于此状态的线程有可能正在执行,也有可能正在等待CPU为它分配时间
  • 无限期等待Waiting:处于这种状态的线程不会被分配CPU执行时间,它们要等待被其他线程显式唤醒,以下方法会导致线程陷入无限期的等待状态:
    • 没有设置Timeout参数的Object.wait()方法
    • 没有设置Timeout参数的Thread.join()方法
    • LockSupport.park()方法
  • 限期等待Timed Waiting:处于这种状态的线程也不会被分配CPU执行时间,不过无须等待被其他线程显式地唤醒,一定时间后它们会由系统自动唤醒,以下方法会让线程进入限期等待状态:
    • Thread.sleep()方法
    • 设置了Timeout参数的Object.wait()方法
    • 设置了Timeout参数的Object.join()方法
    • LockSupport.parkNanos()方法
    • LockSupport.parkUntil()方法
  • 阻塞Blocked:线程被阻塞了,阻塞与等待的区别是:阻塞在等待获取一个排他锁,这个事件将在另外一个线程放弃这个锁的时候发生;而等待则是等待一段时间,或者唤醒动作的发生,在程序等待进入同步区域的时候,线程将进入这种状态
  • 结束Terminated:已终止线程的线程状态,线程已经结束执行

这5种状态在遇到特定时间发生时会互相转换,它们的转换关系如下图所示: