JMM,从虚拟机的角度来解释并发

Posted 爱奇志

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了JMM,从虚拟机的角度来解释并发相关的知识,希望对你有一定的参考价值。

一、前言

本文来JVM底层是如何一步步实现多线程并发的,一共包括四个部分的内容——Java内存模型、Java线程、线程安全、锁优化。

二、Java内存模型

2.1 引子:Java内存模型

Java内存模型含义?什么是Java内存模型?

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

Java内存模型的好处?

要想知道的Java内存模型的好处,可以对比没有Java内存模型的情况,再此之前,主流程序语言(C/C++)没有实现自己独立的内存模型,直接使用物理硬件和操作系统的内存模型,因此,会由于不同平台上的内存模型的差异,可能导致程序在一套平台上并发正常运行,在另一套平台并发访问出错。

Java自身独立的内存模型使其可以实现在不同平台上正常并发,是其实现跨平台的关键。

2.2 主内存与工作内存

Java内存模型的主要目标是

JVM和JMM: JVM是对物理计算机的模拟,JMM是JVM内部的一套线程访问内存的规划,是对物理机中处理器访问主存的模拟。JMM是线程访问内存的一种规范,所以,JVM是存在的,JMM是不存在的。

唯一需要注意的是,上下两个图中都有主内存,但是仅有类似对比意义,实际上是不一样的,上图(物理计算机内存模型)中的主内存是指计算机的物理内存条,下图(Java内存模型)中的主内存是指JVM申请的那部分物理内存(即不是全部物理内存,否则整个电脑上就跑一个Java程序,其他应用程序就跑不动了)。

在物理计算机中,处理器CPU要与主内存进行数据交互,但是由于两者(处理器和主内存)之间的速度不匹配的剪刀差,所以现代计算机的解决方式是在处理器和主内存之间加一个高速缓存,缓存和主存之间约定好“缓存一致性协议”即可,运行的时候,对于处理器中需要的数据,先从缓存中找,找不到再到主存中去取,写入到缓存中,处理器在从缓存中取,总之处理器不直接与主存交互,这是学生年代《计算机组成原理》中介绍过的。

工作中使用Java开发时,实际上Java内存模型也借鉴了物理计算机这个模型,由于Java是支持多线程的语言,对于程序中创建的多个线程,需要访问代码中的某个公共变量(即计算机主内存中的变量)时,不直接访问内存(像处理器不直接访问内存一样),而是将主存中的变量放在工作内存中去,线程从自己的工作内存中取,下一次又要读写变量时,直接从工作内存中取,如果工作内存中没有,就再次将主内存的目标变量拷贝到工作内存,再由工作内存提供给Java线程,总之Java线程不直接与主内存交互,和上面(物理计算机)一样。

各个线程对变量读写:各个线程中保存了被该线程使用的变量在主内存的拷贝(就像缓存中保存主存中的数据拷贝一样),线程对变量的读写都必须在工作内存中进行,不能直接访问主内存。

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

2.3 主内存和工作内存数据交互(原子性:八种原子性操作和八条原则)

2.3.1 八种原子性操作

对于物理机来说,高速缓存和主内存之间的交互有协议,同样的,Java内存中每个线程的工作内存和JVM占用的主内存的交互是由JVM定义了如下的8种操作来完成的,每种操作必须是原子性的。JVM中主内存和工作内存交互,就是一个变量如何从主内存传输到工作内存中,如何把修改后的变量从工作内存同步回主内存。

1)lock(锁定):作用于主内存的变量,一个变量在同一时间只能一个线程锁定,该操作表示一个线程独占这个变量

2)unlock(解锁):作用于主内存的变量,表示这个变量的状态由处于锁定状态被释放,这样其他线程才能对该变量进行锁定

3)read(读取):作用于主内存变量,表示把一个主内存变量的值传输到线程的工作内存,以便随后的load操作使用(解释:主内存–>工作内存,读取主内存)

4)load(载入):作用于线程的工作内存的变量,表示把read操作从主内存中读取的变量的值放到工作内存的变量副本中(副本是相对于主内存的变量而言的)(解释:主内存–>工作内存,写入工作内存)

5)use(使用):作用于线程的工作内存中的变量,表示把工作内存中的一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时就会执行该操作(解释:工作内存–>执行引擎,变量操作)

6)assign(赋值):作用于线程的工作内存的变量,表示把执行引擎返回的结果赋值给工作内存中的变量,每当虚拟机遇到一个给变量赋值的字节码指令时就会执行该操作(解释:执行引擎–>工作内存,变量赋值)

7)store(存储):作用于线程的工作内存中的变量,把工作内存中的一个变量的值传递给主内存,以便随后的write操作使用(解释:工作内存–>主内存,读取工作内存)

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

对于Java程序中的变量读取语句,要把一个变量从主内存传输到工作内存,就要顺序的执行read和load操作;

对于Java程序中的变量写入语句,要把一个变量从工作内存回写到主内存,就要顺序的执行store和write操作。

2.3.2 八条规则

对于普通变量,虚拟机只是要求顺序的执行,并没有要求连续的执行,即虚拟机可以在不影响逻辑的情况下,对八个指令重排序。

对于两个线程,分别从主内存中读取变量a和b的值,并不一样要read a; load a; read b; load b; 也会出现如下执行顺序:read a; read b; load b; load a; 对于这8种操作,虚拟机也规定了一系列规则,在执行这8种操作的时候必须遵循如下的规则:

1)read和load、store和write:对于Java程序中的读取和写入,不允许read和load、store和write操作之一单独出现,也就是不允许从主内存读取了变量的值但是工作内存不接收的情况,或者不允许从工作内存将变量的值回写到主内存但是主内存不接收的情况

2)assign:对于Java程序中的执行引擎运算返回结果,不允许一个线程丢弃最近的assign操作,也就是不允许线程在自己的工作线程中修改了变量的值却不同步/回写到主内存

3)assign:不允许一个线程回写没有修改的变量到主内存,也就是如果线程工作内存中变量没有发生过任何assign操作,是不允许将该变量的值回写到主内存

4)use-load,store-assign:先后原则,变量只能在主内存中产生,源头必须是主内存,不允许在工作内存中直接使用一个未被初始化的变量,也就是没有执行load或者assign操作,也就是说在执行use、store之前必须对相同的变量执行了load、assign操作

5)lock:一个变量在同一时刻只能被一个线程对其进行lock操作,也就是说一个线程一旦对一个变量加锁后,在该线程没有释放掉锁之前,其他线程是不能对其加锁的,但是同一个线程对一个变量加锁后,可以继续加锁,同时在释放锁的时候释放锁次数必须和加锁次数相同。

6)lock:对变量执行lock操作,就会清空工作空间该变量的值,执行引擎使用这个变量之前,需要重新load或者assign操作初始化变量的值

7)unlock:不允许对没有lock的变量执行unlock操作,如果一个变量没有被lock操作,那也不能对其执行unlock操作,当然一个线程也不能对被其他线程lock的变量执行unlock操作

8)unlock:对一个变量执行unlock之前,必须先把变量同步回主内存中,也就是执行store和write操作

当然,最重要的还是如开始所说,这8个动作必须是原子的,不可分割的。

2.3.3 分解Java程序练习

常量读取零步操作,变量读取一步操作;

常量赋值一步操作,变量赋值两步操作;

常量计算并写入两步操作,变量计算并写入三步操作。

解释(八个原子性操作):

常量读取零步操作,啥都不干

变量读取一步操作,读取变量a,主内存–>工作内存,先读取主内存read,再写入工作内存load,根据下面规则1,两个不能拆开,所以变量读取是原子操作。

常量赋值一步操作,int a=1,工作内存–>主内存,先读取工作内存store,再写入主内存write,根据下面规则1,两个不能拆开,所以常量赋值给变量是原子操作。

变量赋值两步操作,int b=a,先变量a 主内存–>工作内存,然后变量b 工作内存–>主内存,两步操作。

常量计算并写入两步操作,int a=1+1,先 1+1=2 返回结果,执行引擎–>工作内存,使用assign命令,然后变量a 工作内存–>主内存,总共两步操作。

变量计算并写入三步操作,int b=a+1,先变量a 主内存–>工作内存,然后 a+1 返回结果,执行引擎–>工作内存,最后变量b 工作内存–>主内存,三步操作。

注意,先用int,先不考虑long和double,这两个64位的。

2.3.4 long double型变量的特殊规则

Java内存模型要求对主内存和工作内存交换的八个动作是原子的,正如上面所讲,但是对long和double有一些特殊规则。原因是什么呢?

其实,问题倒不是出现在8个动作上,这个8个动作是确实是原子性操作,这一点是毋庸置疑的,问题出在long和double这两种基本数据类型上。八个动作中lock、unlock、read、load、use、assign、store、write对待32位的基本数据类型都是原子操作,对待long和double这两个64位的数据,java虚拟机规范对java内存模型的规定中特别定义了一条相对宽松的规则:允许虚拟机将没有被volatile修饰的64位数据的读写操作划分为两次32位的操作来进行,也就是允许虚拟机不保证对64位数据的read、load、store和write这4个动作的操作是原子的。这也就是我们常说的long和double的非原子性协定(Nonautomic Treatment of double and long Variables)。

2.4 原子性、可见性与有序性

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

1)原子性:

由Java内存模型来直接保证原子性的变量操作包括read、load、use、assign、store、write这6个动作,虽然存在long和double的特例,但基本可以忽略不计,目前虚拟机基本都对其实现了原子性。如果需要更大范围的控制,lock和unlock也可以满足需求。lock和unlock虽然没有被虚拟机直接开放给用户使用,但是提供了字节码层次的指令monitorenter和monitorexit对应这两个操作,对应到java代码就是synchronized关键字,因此在synchronized块之间的代码都具有原子性(这是程序员所熟知的)。

2)可见性:

可见性是指一个线程修改了一个变量的值后,其他线程立即可以感知到这个值的修改。正如前面所说,volatile类型的变量在修改后会立即同步给主内存,在使用的时候会从主内存重新读取,是依赖主内存为中介来保证多线程下变量对其他线程的可见性的。
除了volatile,synchronized和final也可以实现可见性。synchronized关键字是通过unlock之前必须把变量同步回主内存来实现的,final则是在初始化后就不会更改,所以只要在初始化过程中没有把this指针传递出去也能保证对其他线程的可见性。

3)有序性:

有序性从不同的角度来看是不同的。单纯单线程来看都是有序的,但到了多线程就会跟我们预想的不一样。可以这么说:如果在本线程内部观察,所有操作都是有序的;如果在一个线程中观察另一个线程,所有的操作都是无序的。前半句说的就是“线程内表现为串行的语义”,后半句值得是“指令重排序”现象和主内存与工作内存之间同步存在延迟的现象。
保证有序性的关键字有volatile和synchronized,volatile禁止了指令重排序,而synchronized则由“一个变量在同一时刻只能被一个线程对其进行lock操作”来保证。

总体来看,synchronized对三种特性(原子性、可见性、有序性)都有支持,虽然简单,但是如果无控制的滥用对性能就会产生较大影响。

synchronized关键字是绝对安全的,因为它可以同时保证原子性、可见性、有序性,但是这并不意味着synchronized关键字可以随意使用,事实上,synchronized是一种重量级锁,对性能的影响还是比较大的,本文第五部分介绍锁优化就是为了解决synchronized重量级锁的性能损耗问题。

2.5 有序性:先行发生原则

2.5.1 有序性:八条先行发生原则

 Java内存模型具备一些 先天的“有序性”,即不需要通过任何手段就能够得到保证的有序性 ,这个通常也称为 happens-before 原则。如果两个操作的执行次序无法从happens-before原则推导出来,那么它们就不能保证它们的有序性,虚拟机可以随意地对它们进行重排序。

下面就来具体介绍下happens-before原则(先行发生原则):

(1)程序次序规则:一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作;

(2)锁定规则:一个unLock操作先行发生于后面对同一个锁额lock操作;

(3)volatile变量规则:对一个变量的写操作先行发生于后面对这个变量的读操作;

(4)传递规则:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C;

(5)线程启动规则:Thread对象的start()方法先行发生于此线程的每个一个动作;

(6)线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生;

(7)线程终结规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行;

(8)对象终结规则:一个对象的初始化完成先行发生于他的finalize()方法的开始。
  
  这8条原则摘自《深入理解Java虚拟机》。

这8条规则中,前4条规则是比较重要的,后4条规则都是显而易见的。

下面我们来解释一下前4条规则:

第一条规则:对于程序次序规则来说,我的理解就是一段程序代码的执行在单个线程中看起来是有序的。注意,虽然这条规则中提到“书写在前面的操作先行发生于书写在后面的操作”,这个应该是程序看起来执行的顺序是按照代码顺序执行的,因为虚拟机可能会对程序代码进行指令重排序。虽然进行重排序,但是最终执行的结果是与程序顺序执行的结果一致的,它只会对不存在数据依赖性的指令进行重排序。因此,在单个线程中,程序执行看起来是有序执行的,这一点要注意理解。事实上,这个规则是用来保证程序在单线程中执行结果的正确性,但无法保证程序在多线程中执行的正确性。

第二条规则也比较容易理解,也就是说无论在单线程中还是多线程中,同一个锁如果出于被锁定的状态,那么必须先对锁进行了释放操作,后面才能继续进行lock操作。

第三条规则是一条比较重要的规则,也是后文将要重点讲述的内容。直观地解释就是,如果一个线程先去写一个变量,然后一个线程去进行读取,那么写入操作肯定会先行发生于读操作。

第四条规则实际上就是体现happens-before原则具备传递性。

2.5.2 时间上先发生与先行发生

时间上先发生:实际运行先发生,实际运行顺序从控制台打印结果就可以看到;

先行发生:指先行发生的操作影响能被后来的观察到,A先行于B发生,A的操作影响能被B观察到。

先行发生实例分析——这里假设A B两个线程分别调用setValue() getValue(),结果线程不安全:

private int value = 0;
public void setValue(int value) {
  this.value = value;
}
public int getValue() {
  return this.value;
}

如果有两个线程A和B,A先调用setValue方法,然后B调用getValue方法,那么B线程执行方法返回的结果是什么?是默认值0,还是客户端调用setter设置后的值呢?
我们去对照先行发生原则一个一个对比。首先是程序次序规则,这里是多线程,不在一个线程中,不适用;然后是管程锁定规则,这里没有synchronized,自然不会发生lock和unlock,不适用;后面对于线程启动规则、线程终止规则、线程中断规则也不适用,这里与对象终结规则、传递性规则也没有关系。使用刚刚上面粗体标记的这句,如果两个操作之间均不满足下列规则,并且无法从下列规则推导出来的话,它们就没有顺序性保障,虚拟机可以对它们随意地进行重排序。这个示例就是这样,不满足所有规则,所以虚拟机可以这个实例程序随意重排序,所以B返回的结果是不确定的,所以这个实例在多线程环境下该操作不是线程安全的。

这里告诉我们,“时间上先发生”(setValue实际顺序先于getValue)不代表操作上“先行发生”(getValue不一定能观察到由于setValue所导致的value值变化)

解决思想:

因为这个实例代码在多线程下是不安全的,返回值是随机的,要使这个程序在多线程下安全,返回值唯一确定,必须满足上面8条规则中其中一条。

解决方式一:加上管程锁定规则,getter/setter方法加上synchronized关键字或者lock锁机制,实现原子操作。

解决方式二:加上volatile变量规则,将value变量上加上volatile关键字,实现所有线程可见。

先行发生实例分析——这里假设同一线程,结果线程安全:

int i = 2;
int j = 1;

这里对i的赋值先行发生于对j赋值的操作,但是代码重排序优化,也有可能是j的赋值先发生,但是这个实例是安全的,因为这里是在同一个线程内,代码重排序不会导致结果发生变化。

这里告诉我们,由于代码重排序优化的存在,“先行发生”(因为这里是假设同一线程,i的设置被j观察到)不代表操作上“时间上先发生”(i不一定比j先赋值,因为代码重排序优化的存在)

所以,综上所述,时间先后顺序与先行发生原则之间基本没有太大关系(这里我们得到的目标定律)。所以,我们衡量并发安全的问题的时候不要受到时间先后顺序的干扰,一切以先行发生原则为准。

三、Java线程——将宏观代码与底层原理对比着来看

Java是一个支持多线程语言,线程是比进程更轻量的调度执行单位,线程的引入,将进程的资源调度和执行调度分开,各个线程既可以共享进程资源,又可以独立调度。

实现线程包括3种方式:内核线程实现、用户线程实现、用户线程加轻量级进程混合实现。

我们可以将线程和《操作系统》中进程来对比学习,如进程有内核态、用户态。

操作系统进程Java线程
系统级内核态(又称核心态、系统态、管态、管理态)Kernel Mode内核级线程(KLT,Kernel-Level Thread)
用户级用户态(又称目态)User Mode用户级线程
切换1、切换:用户态切换到内核态的途径——>中断/异常/陷入(注意:这个陷入有解释);内核态切换到用户态的途径——>设置程序状态字。2、指令:特权指令——在系统态时运行的指令、非特权指令——在用户态时运行的指令。3、陷入指令:陷入指令(又称为访管指令,因为内核态也被称为管理态,访管就是访问管理态)该指令给用户提供接口,用于调用操作系统的服务。1、内核支持线程是OS内核可感知的,而用户级线程是OS内核不可感知的。2、用户级线程的创建、撤消和调度不需要OS内核的支持,是在语言(如Java)这一级处理的;而内核支持线程的创建、撤消和调度都需OS内核提供支持,而且与进程的创建、撤消和调度大体是相同的。3、用户级线程执行系统调用指令时将导致其所属进程被中断,而内核支持线程执行系统调用指令时,只导致该线程被中断。4、在只有用户级线程的系统内,CPU调度还是以进程为单位,处于运行状态的进程中的多个线程,由用户程序控制线程的轮换运行;在有内核支持线程的系统内,CPU调度则以线程为单位,由OS的线程调度程序负责线程的调度。5、用户级线程的程序实体是运行在用户态下的程序,而内核支持线程的程序实体则是可以运行在任何状态下的程序。

3.1 Java线程的底层实现原理

3.1.1 内核级线程实现(KLT,Kernel-Level Thread)

内核线程,即KLT,全称Kernel-Level Thread,有操作系统内核支持的线程,这种线程(内核级线程)有内核完成线程切换,而内核中又通过操纵调度器(scheduler)对线程调度,并负责将线程的任务映射到各个处理器上。每个内核级线程可以视为内核的一个分量。

由于内核线程的实现,每一个轻量级进程成为一个独立的调度单位,即使是一个轻量级进程在系统调用中阻塞了,也不会影响整个进程继续工作,但是轻量级进程有它的局限性:第一,由于是基于内核线程实现的,所以各种线程操作,如创建、析构与同步,都需要进行系统调用。而系统调用的代价相对较高,需要在用户态和核心态中来回切换。第二,每一个轻量级进程都需要一个内核级线程的支持,因此轻量级进程需要消耗一个内核资源,因此一个系统支持轻量级进程的数量是有限的。

3.1.2 用户级线程实现(UT,User Thread)

用户级线程,UT,英文全称User Thread,存在两个定义方式。

广义的用户线程:不属于内核级线程的线程都是用户级线程,所以,上图中的轻量级进程也属于用户线程。

狭义的用户线程:是指完全建立在用户空间的线程库上,系统内核不能感知线程存在的实现。用户线程的建立、同步、销毁和调度完全在用户态中完成,不需要内核协助(这是重点,记住)。用户线程也可以支持规模更大的线程数量,部分高性能数据库中的多线程就是由用户线程实现的。这种进程与用户线程之间1:N的关系称为一对多的线程模型,如图:

使用用户线程的优势在于不需要系统内核支援,劣势也在于没有系统内核的支援,所有的线程操作都需要用户程序自己处理。 线程的创建、切换和调度都是需要考虑的问题,而且由于操作系统只把处理器资源分配到进程,那诸如“阻塞如何处理”、“多处理器系统中如何将线程映射到其他处理器上”这类问题解决起来将会异常困难,甚至不可能完成。以Java语言为例,曾经使用过用户级线程,后来又放弃了,现在Java使用的是用户线程加轻量级进程的混合模式,且看下面。

注意,现在所使用的Java并未使用用户线程实现,使用的是用户线程加轻量级进程混合实现。

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

含义:用户线程加轻量级进程混合实现,是一种将内核线程与用户线程一起使用的实现方式。

在用户线程加轻量级进程混合实现下,既存在用户线程,也存在轻量级进程。两者(用户线程和轻量级进程)一起发挥自己的作用:

用户线程的作用:完全建立在用户空间中,因此用户线程的创建、切换、析构等操作依然廉价,并且可以支持大规模的用户线程并发。

轻量级进程的作用:由操作系统提供,作为用户线程和内核线程之间的桥梁,这样可以使用内核提供的线程调度功能及处理器映射,并且用户线程的系统调用要通过轻量级线程来完成,大大降低了整个进程被完全阻塞的风险。

在这种混合模式中,用户线程与轻量级进程的数量比是不定的,即为N:M的关系,如图:

3.2 Java线程调度底层原理(调度方式与线程优先级)

3.2.1 Java线程调度方式(协同式调度+抢占式调度)

Java线程调度方式主要两种,分别是协同式调度(Cooperative Threads-Scheduling)和抢占式调度(Preemptive Threads-Scheduling)。

协同式调度: 线程的执行时间由线程本身来控制,线程把自己的工作执行完了之后,要主动通知系统切换到另外一个线程上。优点是实现简单,而且由于线程要把自己的事情干完后才会进行线程切换,切换操作对线程自己是可知的,所以没有什么线程同步的问题。缺点:线程执行时间不可控制,甚至如果一个线程编写有问题,一直不告知系统进行线程切换,那么程序就会一直阻塞在那里。

抢占式调度:线程将由系统来分配执行时间,线程的切换不由线程本身来决定(在Java中,Thread.yield()可以让出执行时间,但是要获取执行时间的话,线程本身是没有什么办法的)。在这种实现线程调度的方式下,线程的执行时间是系统可控的,也不会有一个线程导致整个进程阻塞的问题,Java使用的线程调度方式就是抢占式调度(记住,Java的抢占式调度就是对同步锁的抢夺)。

3.2.2 线程优先级读写

Java语言一共设置了10个级别的线程优先级(从1到10,1为优先级最低,10为优先级最高,Thread.MIN_PRIORITY表示优先级为1,Thread.MAX_PRIORITY表示优先级为10),在两个线程同时处于Ready状态时,优先级越高的线程越容易被系统选择执行。Java的线程是通过映射到系统的原生线程上来实现的,所以线程调度最终还是取决于操作系统,即系统线程优先级跟Java线程的优先级一般对不上。

注意1:值得注意的是,线程优先级并不是指线程执行的先后顺序,而是线程被执行的概率权重。事实上,除非程序员使用标志位做线程通信,否则Java并没有提供任何线程执行先后顺序的机制,哪个线程先执行只取决于CPU调度。

注意2:此外,不同的操作系统支持的线程优先级不同的,建议使用上述三个优先级MAX_PRIORITY、MIN_PRIORITY、NORM_PRIORITY,不要自定义。

3.3 Java线程状态转换底层原理(6种状态)

先介绍线程包含的6种状态,然后分别介绍五种状态含义,最后给出状态转换图。

线程状态:新建状态New、可运行状态Runnable、等待状态Waiting、限时等待状态Timed_Waiting、阻塞状态Blocked、结束状态Terminated,在任意一个时间点,一个线程只能有且只有其中的一种状态 。

新建状态New:创建后尚未启动的线程处于这种状态。

可运行状态Runnable:Runable包括了操作系统线程状态中的Running和Ready,也就是处于此状态的线程有可能正在执行,也有可能正在等待着CPU为它分配执行时间。

等待状态Waiting:处于这种状态的线程不会被分配CPU执行时间,它们要等待被其他线程显式地唤醒。以下方法会让线程陷入无限期的等待状态:没有设置Timeout参数的Object.wait()方法、没有设置Timeout参数的Thread.join()方法、LockSupport.park()方法。

计时等待状态Timed_Waiting:处于这种状态的线程也不会被分配CPU执行时间,不过无须等待被其他线程显式地唤醒,在一定时间之后它们会由系统自动唤醒。以下方法会让线程进入限期等待状态: Thread.sleep()方法、设置了Timeout参数的Object.wait()方法、设置了Timeout参数的Thread.join()方法、LockSupport.parkNanos()方法、LockSupport.parkUntil()方法。

阻塞状态Blocked:线程被阻塞了,“阻塞状态”与“等待状态”的区别是:“阻塞状态”在等待着获取到一个排他锁,这个事件将在另外一个线程放弃这个锁的时候发生;而“等待状态”则是在等待一段时间,或者唤醒动作的发生。 在程序等待进入同步区域的时候,线程将进入这种状态。

结束状态Terminate:已终止线程的线程状态,线程已经结束执行。

线程的状态有不同说法:

有的说Java线程5种状态,这是因为将“等待状态Waiting+限时等待状态Timed_Waiting”作为一种状态,5种状态为:

新建状态New、可运行状态Runnable(Running+Ready)、等待状态Waiting+Timed_Waiting、阻塞状态Blocked、结束状态Terminated

有的说Java线程6种状态,这是因为将将Running和Ready两种状态拆分开了,6种状态为:

新建状态New、Ready准备状态、Running运行状态、等待状态Waiting+Timed_Waiting、阻塞状态Blocked、结束状态Terminated

或者如本文等待状态Waiting和限时等待状态Timed_Waiting两种状态拆开,6种状态为:

新建状态New、可运行状态Runnable(Running+Ready)、等待状态Waiting、计时等待状态Timed_Waiting、阻塞状态Blocked、结束状态Terminated

有的说Java线程7种状态,这是因为将将Running和Ready两种状态拆分开、等待状态Waiting和限时等待状态Timed_Waiting两种状态拆开,7种状态为:

新建状态New、Ready准备状态、Running运行状态、 等待状态Waiting、计时等待状态Timed_Waiting、阻塞状态Blocked、结束状态Terminated

不管采用哪种说法,Java线程状态以下几种,新建状态New、可运行状态Runnable(Running+Ready)、等待状态(等待状态Waiting+限时等待状态Timed_Waiting)、阻塞状态Blocked、结束状态Terminated

对于上图(Java线程状态转换),注意以下四点:

注意1:New状态这里是单向箭头,表示只能从New状态到Runnable状态、不能从Runnable状态到New状态,即线程新建启动就不能再回来。

注意2:Terminate状态这里是单向箭头,表示进入到Terminate状态就不能再回去了,表示线程死亡后就不能再复活。

注意3:只有New状态和Terminate状态这里是单向箭头,其他都是双向箭头,表示其他状态之间可以相互转换,同时表示任何一个线程一定会经历New – Runnable – Terminate 这个顺序状态,这三个状态是必备的,其他状态是可选的。

注意4:很多博客的图中间这个状态都是Running,笔者任何不合适,笔者这里使用Runnable (Runnable = Ready + Running).

四、线程安全

4.1 线程安全

线程安全含义:当多个线程访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方法进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那这个对象是线程安全的。

4.2 Java语言中的线程安全

按照线程安全的“安全程度”由强至弱排序,我们可以将Java语言中各种操作共享的数据分为以下5类:不可变、绝对线程安全、相对线程安全、线程兼容与线程对立。

4.2.1 不可变

由上可知,不可变是安全性最强的线程安全,事实上,不可变的对象一定是线程安全的,不管是方法实现还是方法调用,都不需要再采取任何方式来维护线程安全。

对于基本数据类型和引用数据类型的处理方式有所不同。

如果共享数据是一个基本数据类型,那么只要在定义时使用 final 关键字修饰它就可以保证它是不可变的。

如果共享数据是一个引用数据类型(实例对象),那就需要保证对象的行为不会对其状态产生任何影响才行(如java.lang.String类的对象,它是一个不可变对象,们调用它的 substring()、replace() 和 concat() 这些方法都不会影响它原来的值,只会返回一个新的构造的字符串对象)。

附加补充:String类对象是不可变的,StringBuilder和StingBuffer是可变的,其中,StringBuilder是线程不安全的,StringBuffer是线程安全的。

保证对象行为不影响自己状态的途径有很多种,其中最简单的就是把对象中带有状态的变量都声明为 final,这样在构造函数结束之后,它就是不可变的,如下代码所示, java.lang.Integer 构造函数所示的,它通过将内部状态变量 value 定义为 final 来保障状态不变。

private final int value;
public Integer(int value) {
    this.value = value;
}

在 Java API 中符合不可变要求的类型,除了上面提到的 String 之外,常用的还有枚举类型,以及 java.lang.Number 的部分子类,如 Long 和 Double 等数值包装类型,BigInteger 和 BigDecimal 等大数据类型。

4.2.2 绝对线程安全

绝对的线程安全完全满足 Brian Goetz 给出的线程安全的定义(即当多个线程访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方法进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那这个对象是线程安全的),这个定义其实是很严格的,一个类要达到 “不管运行是环境如何,调用者都不需要任何额外的同步措施” 通常需要付出很大的,甚至有时候是不切实际的代价。在 Java API 中标注自己是线程安全的类,大多数都不是绝对的线程安全。我们可以通过 Java API 中一个不是 “绝对线程安全” 的线程安全类来看看这里的 “绝对” 是什么意思。

如果说 java.util.Vector 是一个线程安全的容器,相信所有的 Java 程序员对此都不会有异议,因为它的 add()、get() 和 size() 这类方法都是被 synchronized 修饰的,尽管这样效率很低,但确实是安全的。但是,即时它所有的方法都被修饰成同步,也不意味着调用它的时候永远都不需要同步手段了,且看代码。

测试代码:(对于vector框架,removeThread是写操作,printThread是读操作)

private static Vector<Integer> vector = new Vector<Integer>();
 
public static void main(String[] args) {
//无限循环
	while (true) {
		for (int i = 0; i < 10; i++) {    //vector中添加10个元素  元素值为0-9
			vector.add(i);
		}
		//新建一个removeThread,该线程用于删除vector容器中所有元素,这里不能直接用vector.empty()  直接清空无法和下面的printThread配合达到模拟的效果
		Thread removeThread = new Thread(new Runnable() {
			@Override
			public void run() {
				for (int i = 0; i < vector.size(); i++) {
					vector.remove(i);
				}
			}
		});
	//新建一个printThread,用于打印vector元素值
		Thread printThread = new Thread(new Runnable() {
			@Override
			public void run() {
				for (int i = 0; i < vector.size(); i++) {
					System.out.println(vector.get(i));
				}
			}
		});
		//同时启动两个线程,removeThread用于删除元素,printThread用于访问元素
		removeThread.start();
		printThread.start();
		
		// 不要同时产生过多的线程,否则会导致操作系统假死
		while (Thread.activeCount() > 20);
	}
}

输出结果:

Exception in thread "Thread-10865" java.lang.ArrayIndexOutOfBoundsException: Array index out of range: 11
	at java.util.Vector.get(Vector.java:744)
	at cn.mk.day0810.MyTest$2.run(MyTest.java:30)
	at java.lang.Thread.run(Thread.java:745)

疑问:这个错误是数组越界,就是说调用get(i)方法的时候,访问的是一个不存在的元素,为什么会出问题呢?

问题描述:就是说某个序号为i的元素在removeThread线程中被删除了,但是后来printThread再去访问这个序号为i的元素,所以数组越界,这里用图来解释

解决方式:对于两个run方法的方法体用synchronized包裹一层代码块,如下:

	Thread removeThread = new Thread(new Runnable() {
		@Override
		public void run() {
 //将run()方法体变成同步代码块,这里加一个同步锁vector,进入时持有锁,删除完后释放锁,结束函数,
 //然后removeThread和printThread才能再次竞争锁
			synchronized(vector) {  
				for (int i = 0; i < vector.size(); i++) {
					vector.remove(i);
				}
			}
		}
	});
	
	Thread printThread = new Thread(new Runnable() {
		@Override
		public void run() {
			synchronized(vector) {
				for (int i = 0; i < vector.size(); i++) {
					System.out.println(vector.get(i));
				}
			}
		}
	});

这样一来,一定要removeThread执行完后,然后removeThread和printThread才能再次竞争锁,保证操作安全。

4.2.3 相对线程安全

相对的线程安全就是我们通常意义上所讲的线程安全,它需要保证对这个对象单独的操作是线程安全,我们在调用的时候不需要做额外的保障措施,但是对于一些特定顺序的连续调用,就可能需要在调用端使用额外的同步手段来保证调用的正确性。上面的对于removeThread printThread操作Vector容器就是一种相对线程安全,虽然vector.remove(i) vector.get(i)方法本身是线程安全,但是for循环线程不安全,所以造成错误。

上面的例子告诉我们,相对线程安全确实比绝对线程安全要低一个安全级别,绝对线程安全程序员可以啥事不管,放心的用,但是相对线程安全(以线程安全的容器为例),使用的同时程序员需要关注具体程序。

在 Java 语言中,大部分的线程安全类都属于这种类型,例如 Vector、HashTable、Collections 的 synchronizedCollection() 方法包装的集合等。

相对线程安全,以Vector集合框架为例,只是保证这个框架对象的单独操作(指某个函数 get remove add 在函数内的处理逻辑是安全的),像上面的程序一样,remove(i) 和 get(i) 的方法内部逻辑确实是线程安全的(即线程同步的),但是外层for循环、变量i并不是线程同步的,正是因为i变量没有线程间同步,所以get(i)出现数组越界。解决方案中给for循环加上synchronized包裹一层,使其线程同步,就解决了。

4.2.4 线程兼容

线程兼容是指对象本身并不是线程安全的,但是可以通过在调用端正确地使用同步手段来保证对象在并发环境中可以安全地使用,我们平常说一个类不是线程安全的,绝大多数时候指的是这一种情况。Java API 中大部分的类都是属于线程兼容的,如集合框架类 ArrayList 和 HashMap 等。

4.2.5 线程对立

线程对立是安全级别最弱的一种共享数据操作,它是指无论调用端是否采取了同步措施,都无法在多线程环境中并发使用的代码。由于 Java 语言天生就具备多线程特性,线程对立这种排斥多线程的代码是很少出现的,而且通常都是有害的,应当尽量避免。

简单的理解:线程对立是指走向了与多线程安全对立的一面,永远达不到线程安全,这是程序员所不愿意看到的,多线程开发中一定不能使用线程对立的类与方法。

一个线程对立的例子是 Thread 类的 suspend() 和 resume() 方法,如果有两个线程同时持有一个线程对象,一个尝试去中断线程,另一个尝试去恢复线程,如果并发进行的话,无论调用时是否进行了同步,目标线程都是存在死锁风险的,如果 suspend() 中断的线程就是即将要执行 resume() 的那个线程,那就肯定要产生死锁了。也正是由于这个原因,suspend() 和 resume() 方法已经被 JDK 声明废弃(@Deprecated)了。常见的线程对立的操作还有 System.setIn()、System.setOut() 和 System.runFinalizerosOnExit() 等。

4.3 线程安全的实现方式(阻塞同步+非阻塞同步+无同步方案)

我们现在的问题如何实现线程安全/线程安全的实现方式,我们从两个角度来看这个问题。

从程序员代码角度来看:程序员努力确保自己的代码没有线程同步和通信问题,不会出现代码层面的线程安全问题,更不会出现死锁问题;

从JVM底层保障机制来看:JVM提供同步机制;’

本文的重点是JVM,所以我们从JVM底层来看线程安全的实现方式——同步机制。其中,同步机制包括阻塞同步、非阻塞同步、无同步机制。

4.3.1 阻塞同步(又称互斥同步)

同步含义:是指在多个线程并发访问共享数据时,保证共享数据在同一个时刻只被一个(或者是一些,使用信号量的时候)线程使用。

互斥含义:是实现同步的一种手段,临界区(Critical Section)、互斥量(Mutex)和信号量(Semaphore)都是主要的互斥实现方式。

因此,同步和互斥的关系是:互斥是因,同步是果;互斥是方法,同步是目的。

在 Java 中,最基本的互斥同步手段就是 synchronized 关键字,synchronized 关键字经过编译之后,会在同步块的前后分别形成 monitorenter 和 monitorexit 这两个字节码指令,这两个字节码都需要一个 reference 类型的参数来指明要锁定和解锁的对象。如果 Java 程序中的 synchronized 明确指定了对象参数,那就是这个对象的 reference;如果没有明确指定,那就根据 synchronized 修饰的是实例方法还是类方法,去取对应的对象实例或 Class 对象来作为锁对象。

根据虚拟机规范的要求,在执行 monitorenter 指令时,首先要尝试获取对象的锁。如果这个对象没被锁定,或者当前线程已经拥有了那个对象的锁,把锁的计数器加 1,相应的,在执行 monitorexit 指令时将锁计数器减 1,当计数器为 0 时,锁就被释放。如果获取对象锁失败,那当前线程就要阻塞等待,知道对象锁被另外一个线程释放为止。

在虚拟机规范对 monitorenter 和 monitorexit 的行为描述中,有两点是需要特别注意的。首先,synchronized 同步块对同一条线程来说是可重入的,不会出现自己把自己锁死的问题。其次,同步块在已进入的线程执行完之前,会阻塞后面其他线程的进入。

加粗的解释是:

1、synchronized关键字实现的同步锁是一种互斥同步,这种互斥同步是线程间的互斥,一个线程互斥其他线程,当一个线程拿到互斥锁后,其他线程被阻挡在外面,这就是线程间互斥,但是在同一个线程内,是不存在这种同步锁互斥的。

2、顺序是 获得锁(其他线程获得锁失败,阻塞)----运行同步方法-----运行完成同步方法-----释放锁(所有线程可以重新开始竞争同步锁)

在前面讲过,Java 的线程是映射到操作系统的原生线程之上的,如果要阻塞或唤醒一个线程,都需要操作系统来帮忙完成,这就需要从用户态转换到核心态中,因此状态转换需要耗费很多的处理器时间。对于代码简单的同步块(如被 synchronized 修饰的 getter() 或 setter() 方法),状态转换消耗的时间有可能比用户代码执行的时间还要长。所以 synchronized 是 Java 语言中一个重量级(Heavyweight)的操作,有经验的程序员都会在确实必要的情况下才使用这种操作。而虚拟机本身也会进行一些优化,譬如在通知操作系统阻塞线程之前加入一段自旋等待过程,避免频繁地切入到核心态之中。

除了 synchronized 之外,我们还可以使用 java.util.concurrent(下文成 J.U.C)包中的重入锁(ReentrantLock)来实现同步,在基本用法上,ReentrantLock 与 synchronized 很相似,他们都具备一样的线程重入特性,只是代码写法上有点区别,一个表现为 API

以上是关于JMM,从虚拟机的角度来解释并发的主要内容,如果未能解决你的问题,请参考以下文章

基于JVM原理JMM模型和CPU缓存模型深入理解Java并发编程

Java并发编程之happens-before

Java 内存模型

并发编程之线程进阶

从Java虚拟机规范看HotSpot虚拟机的内存结构和变迁

从源码的角度,来解释Tomcat为什么要实现自己的类加载器打破双亲委派模型?