一、前言

本文来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 层面的互斥锁(lock() 和 unlock() 方法配合 try/finally 语句块来完成),另一个表现为原生语法层面的互斥锁。

4.3.2 非阻塞同步

从阻塞同步到非阻塞同步、从悲观锁到乐观锁

互斥同步最主要的问题就是进行线程阻塞和唤醒所带来的性能问题,因此这种同步也称为阻塞同步(Blocking Synchronization)。从处理问题的方式上来说,互斥同步属于一种悲观的并发策略,总是认为只要不去做正确的同步措施(例如加锁),那就肯定会出现问题,无论共享数据是否真的会出现竞争,它都要进行加锁(这里讨论的是概念模型,实际上虚拟机会优化掉很大一部分不必要的加锁)、用户态核心态转换、维护锁计数器和检查是否有被阻塞的线程需要唤醒等操作。随着硬件指令集的发展,我们有了另外一个选择:基于冲突检测的乐观并发策略,通俗地说,就是先进行操作,如果没有其他线程争用共享数据,那操作就成功了;如果共享数据有争用,产生了冲突,那就再采取其他的补偿措施(最常见的补偿措施就是不断地重试,知道成功为止,如CAS),这种乐观的并发策略的许多实现都不需要把线程挂起,因此这种同步操作称为非阻塞同步(Non-Blocking Synchronization)。

阻塞同步(又称互斥同步)非阻塞同步(冲突检测同步)
悲观锁:1、先加锁,再操作(先加锁,再操作,操作完成后再释放锁,给所有线程公平竞争);2、悲观锁:抱着一种悲观的态度,害怕出现线程不安全问题,所有对于每一次线程操作之前都要加上锁,虽然降低了性能,但是提高了效率;3、阻塞同步:没有获取到锁的线程,即竞争锁失败的线程需要挂起,wait()或wait(时间参数),进入blocked阻塞状态,所以称为阻塞同步;4、实现方式:synchronized关键字和lock锁机制。乐观锁:1、先操作,若没有其他线程竞争,就操作成功了,若有其他线程竞争,产生冲突(冲突检测到冲突),再采取补救措施;2、乐观锁:抱着一种乐观的态度,也可说是一种侥幸的心理,不断重试,直至成功;3、非阻塞同步:操作过程中,不存在阻塞线程,所以称为非阻塞同步;4、实现方式:硬件的原子操作,下面介绍五条,重点CAS.
代价大,挂起线程和恢复线程的操作都需要转入内核态完成,代价大不大,直接使用原子操作不需要阻塞线程

从硬件上来确定操作的原子性(如果某条指令在硬件层面上是原子操作,就一定是原子操作了):

测试并设置(Test-and-Set)、
获取并增加(Fetch-and-Increment)、
交换(Swap)、
比较并交换(Compare-and-Swap,简称CAS)、
加载链接/条件存储(Load-Linked/Store-Conditional,简称LL/SC)。

对于上面五条指令,都是原子操作,这里重点介绍第四条——比较并交换(Compare-and-Swap,简称CAS)。

CAS引入,什么是CAS?

CAS是Compare And Swap的缩写,是以一种无锁的方式实现并发控制,是区别于synchronouse同步锁的一种乐观锁。synchronized是一种悲观锁,它会导致其他所有需要锁的线程挂起。

CAS指令需要有3个操作数,分别是内存位置(在Java中可以简单理解为变量的内存地址,用V表示)、旧的预期值(用A表示)和新值(用B表示)。CAS指令执行时,当且仅当V符合旧预期值A时,处理器用新值B更新V的值,否则它就不执行更新,但是无论是否更新了V的值,都会返回V的旧值,上述的处理过程(指粗体标记过程、CAS指令执行过程)是一个原子操作。且用程序看一个CAS操作:

在这里插入图片描述

为什么使用原子类AtomInteger可以完成?

一句话解释:synchronized == for + if(cas线程安全判断),在incrementAndGet()方法中使用 for循环 + if(cas判断)包裹,保证线程安全,所以最后等于20000,所以incrementAndGet()方法中的 for + if(cas线程安全判断) 保证了线程安全。

在一个无限循环中,不断尝试将一个比当前值大1的新值赋给自己。如果失败了,那说明在执行“获取-设置”操作的时候值已经有了修改,于是再次循环进行下一次操作,直到设置成功为止。

CAS操作的“ABA”问题:

CAS并不是绝对安全的原子操作,存在这样的一个逻辑漏洞:如果一个变量V初次读取的时候是A值,并且在准备赋值的时候检查到它仍然为A值,那我们就能说它的值没有被其他线程改变过了吗?如果在这段期间它的值曾经被改成了B,后来又被改回为A,那CAS操作就会误认为它从来没有被改变过。这个漏洞称为CAS操作的“ABA”问题。当然,我们上面这个20个线程每个线程i++一万遍的程序中,由于race的变化全部都是一个方向递增的,所以这里不存在ABA问题,我们不再谈论,关于ABA问题,笔者以后有单独的博客讲述。

4.3.3 无同步方案(可重入代码+线程本地存储)

要保证线程安全,并不是一定就要进行同步,两者没有因果关系。同步只是保证共享数据争用时的正确性的手段,如果一个方法本来就不涉及共享数据,那它自然就无须任何同步措施去保证正确性,因此会有一些代码天生就是线程安全的,笔者简单地介绍其中的两类。

第一类,可重入代码(Reentrant Code)

含义:又称纯代码(Pure Code),可以在代码执行的任何时刻中断它,转而去执行另外一段代码(包括递归调用它本身),而在控制权返回后,原来的程序不会出现任何错误。

可重入代码与线程安全的关系:相对线程安全来说,可重入性是更基本的特性,它可以保证线程安全,即所有的可重入的代码都是线程安全的,但是并非所有的线程安全的代码都是可重入的。

如何判断代码是否具备可重入性:如果一个方法,它的返回结果是可以预测的,只要输入了相同的数据,就都能返回相同的结果,那它就满足可重入性的要求,当然也就是线程安全的。

第二类,线程本地存储(Thread Local Storage)

含义:就是把共享数据的可见范围限制在同一个线程之内,这样,无须同步也能保证线程之间不出现数据争用的问题。

线程本地存储与线程安全的关系:由含义可知,只有共享数据可见范围限制在同一个线程中,则可完全保证线程安全。

符合这种特点的应用并不少见,大部分使用消费队列的架构模式(如 “生产者 - 消费者” 模式)都会将产品的消费过程尽量在一个线程中消费完,其中最重要的一个应用实例就是经典 Web 交互模型中的 “一个请求对应一个服务器线程”(Thread-per-Request)的处理方式,这种处理方式的广泛应用使得很多 Web 服务端应用都可以使用线程本地存储来解决线程安全问题。

Java 语言中,如果一个变量要被多线程访问,可以使用 volatile 关键字声明它为 “易变的”;如果一个变量要被某个线程独享,Java 中就没有类似 C++ 中的 __declspec(thread) (注:在 Visual C++ 是 “__declspec(thread)” 关键字,而在 GCC 中是 “__thread”)这样的关键字,不过还是可以通过 java.lang.ThreadLocal 类来实现线程本地存储的功能。每一个线程的 Thread 对象中都有一个 ThreadLocalMap 对象,这个对象存储了一组以 ThreadLocal.threadLocalHashCode 为键,以本地线程变量为值的 K-V 值对,ThreadLocal 对象就是当前线程的 ThreadLocalMap 的访问入口,每一个 ThreadLocal 对象都包含了一个独一无二的 threadLocalHashCode 值,使用这个值就可以在线程 K-V 值对中找回对应的本地线程变量。

五、锁优化

为什么是锁优化?

Java多线程为了实现线程同步,加入同步锁(synchronized和lock机制)机制,同步锁的诞生虽然保证了操作的原子性、线程的安全性,但是(相比不加锁的情况下)造成了程序性能下降。所以,我们这里要做的一件事就是“锁优化”,即既要保证实现锁的功能(即保证多线程下操作安全)又要提高程序性能(即不要让程序因为安全而损失太大效率)。

下面来介绍HotSpot虚拟机(JVM)的锁优化措施,包括自旋与自适应自旋(Adaptive Spinning)、锁消除(Lock Elimination)、锁粗化(Lock Coarsening)、轻量级锁(Lightweight Locking)和偏向锁(Biased Locking)。这些技术都是为了在线程之间更高效地共享数据,以及解决竞争问题,从而提高程序的执行效率。

5.1 自旋锁与自适用自旋

自旋锁定义:如果物理机器有一个以上的处理器,能让两个或以上的线程同时并行执行,我们就可以让后面请求锁的那个线程 “稍等一下”,但不放弃处理器的执行时间,看看持有锁的线程是否很快就会释放锁。为了让线程等待,我们只需让线程执行一个忙循环(自旋),这项技术就是所谓的自旋锁。

自旋锁具体业务流程:自旋等待不能代替阻塞,且先不说对处理器数量的要求,自旋等待本身虽然避免了线程切换的开销,但它是要占用处理器时间的,因此,如果锁被占用的时间很短,自旋等待的效果就会非常好,反之,如果锁被占用的时候很长,那么自旋的线程只会白白消耗处理器资源,而不会做任何有用的工作,反而会带来性能上的浪费。因此,自旋等待的时间必须要有一定的限度,如果自旋超过了限定的次数仍然没有成功获得锁,就应当使用传统的方式去挂起线程了。自旋次数的默认值是 10 次,用户可以使用参数 -XX:PreBlockSpin 来更改。

自适用自旋定义:自适应自旋是对自旋锁的改进,意味着自旋的时间不再固定了,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。

自适用自旋具体业务流程:如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也很有可能再次成功,进而它将允许自旋等待持续相对更长的时间,比如 100 个循环。另外,如果对于某个锁,自旋很少成功获得过,那在以后要获取这个锁时将可能省略掉自旋过程,以避免浪费处理器资源。

有了自适应自旋,随着程序运行和性能监控信息的不断完善,虚拟机对程序锁的状况预测就会越来越准确,虚拟机就会变得越来越 “聪明” 了。

5.2 锁消除

锁消除定义:锁消除是指虚拟机即时编译器在运行时,对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行消除(即经过JVM做一个锁消除优化,将确定没用的锁消除,是一种JVM并发优化技术)。

如何判断某个锁可以消除?

锁消除的主要判定依据来源于逃逸分析的数据支持,如果判定在一段代码中,堆上的所有数据都不会逃逸出去从而被其他线程访问到,那就可以把他们当做栈上数据对待,认为它们是线程私有的,同步加锁自然就无须进行(即对于只有该线程可以访问到其他线程无法访问的到的代码,可以大胆的做所消除)。

在这里插入图片描述

在这里插入图片描述

结合.java到.class字节码文件可以知道,在jdk8的情况下,return a+b+c;这句程序实际上底层被转换为StringBuilder的追加操作。

每个 StringBuffer.append() 方法中都有一个同步块,锁就是 sb 对象(StringBuilder新建的一个值为“”的空字符串对象)。虚拟机观察变量 sb,很快就会发现它的动态作用域被限制在 concatString() 方法内部。也就是说,sb 的所有引用永远不会 “逃逸” 道 concatString() 方法之外,其他线程无法访问到它,因此,虽然这里有锁,但是可以被安全地消除掉,在即时编译之后,这段代码就会忽略掉所有的同步而直接执行了(可以理解为StringBuilder虽然线程不安全,但是这里没有关系,因为整个执行过程都在main线程中,不会涉及任何线程同步,所以是安全的。这里底层使用StringBuilder就可以被认为是一种锁消除)。

5.3 锁粗化

锁粗化定义:这是一种JVM并发优化,如果虚拟机探测到由这样的一串零碎的操作都对同一个对象加锁,将会把加锁同步的范围扩展(粗化)到整个操作序列的外部,这就是锁粗化。

解释:以上面的return a+b+c;为例,底层被拆分为:

StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
sb.append(s3);
return sb.toString();

三个append()追加,如果对每一个append()都加锁操作,频繁地进行互斥同步操作也会导致不必要的性能损耗,这是JVM不乐意看到的,为了提高JVM并发性能,此时,JVM会进行一个优化,就是扩展到第一个 append() 操作之前直至最后一个 append() 操作之后,这样只需要加锁一次就可以了,这就是锁粗化。

5.4 轻量级锁

轻量级锁定义:一种JVM并发优化,是指在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。

注意两个:

没有多线程竞争:是指这种轻量级锁只有在没有多线程竞争才能减少性能消耗(实际上,如果有多线程竞争,它的消耗比重量级锁更大,后面会讲)。

传统的重量级锁:是指synchronized关键字(和lock锁机制)。

引子:HotSpot 虚拟机的对象(对象头部分)的内存布局

HotSpot 虚拟机的对象头(Object Header)分为两部分信息,第一部分用于存储对象自身的运行时数据,如哈希码(HashCode)、GC 分代年龄(Generational GC Age)等,这部分数据是长度在 32 位和 64 位的虚拟机中分别为 32 bit 和 64 bit,官方称它为 “Mark Word”,它是实现轻量级锁和偏向锁的关键。另外一部分用于存储指向方法区对象类型数据的指针,如果是数组对象的话,还会有一个额外的部分用于存储数组长度。

对象头信息是与对象自身定义的数据无关的额外存储成本,考虑到虚拟机的空间效率,Mark Work 被设计成一个非固定的数据结构以便在极小的空间内存储尽量多的信息,它会根据对象的状态复用自己的存储空间。HotSpot虚拟机对象头 Mark Word,如下表:

存储内容标志位状态
对象哈希码hash、对象分代年龄age01未锁定
指向锁记录的指针stack pointer00轻量级锁定
指向重量级锁的指针10重量级锁定,一般指synchronized
空,不需要记录信息11GC标记
偏向线程ID,偏向时间戳,对象分代年龄age01偏向锁锁定

问题1:上表中为什么有两个01,是不是重复了?

上表的五个项中,第一个表示的是未锁定,其他四个均表示锁定,即第一个01表示的是未锁定,后面四个表示的都是锁定.

问题2:未锁定和偏向锁标记的标记位都是01,如何区分?

对,因为它们的标记位都是01,所以根据其他位(biased lock flag 偏向锁标记)区分。

如果还是不懂,一图抵千言:

在这里插入图片描述

轻量级锁执行过程:

步骤一:在代码进入同步块的时候,如果此同步对象没有被锁定(如上表所述,锁标志位为 “01” 状态)虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的 Mark Word 的拷贝(官方把这份拷贝加上了一个 Displaced 前缀,即 Displaced Mark Word),这时候线程堆栈与对象头的状态如图:

在这里插入图片描述

然后,虚拟机将使用 CAS 操作尝试将对象的 Mark Word 更新为指向 Lock Record 的指针。

步骤二( Mark Word 更新为指向 Lock Record 的指针,更新成功): 如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象 Mark Word 的锁标志位 (Mark Word 的最后 2bit)将转变为 “00”,即表示此对象处于轻量级锁定状态,这时候线程堆栈与对象头的状态如图:

在这里插入图片描述

一个完整的轻量级锁获取过程,起码需要经历4次CAS操作。所以,获取轻量级锁的CAS次数总是>=4的。如果出现线程持续获取锁失败的情况,那么,轻量级锁就会执行膨胀,意思就是升级为重量级锁。且看步骤三。

步骤三(Mark Word 更新为指向 Lock Record 的指针,更新失败):如果这个更新操作失败了,虚拟机首先会检查对象的 Mark Word 是否指向当前线程的栈帧,如果只说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行,否则说明这个锁对象以及被其他线程线程抢占了。如果有两条以上的线程争用同一个锁,那轻量级锁就不再有效,要膨胀为重量级锁,所标志的状态变为 “10”,Mark Word 中存储的就是指向重量级锁(互斥量)的指针,后面等待锁的线程也要进入阻塞状态。这时候线程堆栈与对象头的状态如图:

在这里插入图片描述

小结:上面描述的是轻量级锁的加锁过程,它的解锁过程也是通过 CAS 操作来进行的,如果对象的 Mark Word 仍然指向着线程的锁记录,那就用 CAS 操作把对象当前的 Mark Word 和线程中复制的 Displaced Mark Word 替换回来,如果替换成功,整个同步过程就完成了。如果替换失败,说明有其他线程尝试过获取该锁,那就要释放锁的同时,唤醒被挂起的线程。

轻量级锁能提升程序同步性能的依据是 “对于绝大部分的锁,在整个同步周期内都是不存在竞争的”,这是一个经验数据,也可以说是一种侥幸。

为什么说既是一种经验,又是一种侥幸呢?

如果没有竞争,轻量级锁使用 CAS 操作避免了使用互斥量的开销;

如果存在锁竞争,除了互斥量的开销外,还额外发生了 CAS 操作,因此在有竞争的情况下,轻量级锁会比传统的重量级锁更慢。

通过对轻量级锁的学习,我们知道,轻量级锁不是要取代重量级锁,而是对重量级锁的补充,只有在获取轻量级锁的CAS次数,出现线程持续获取锁失败的情况,轻量级锁就会执行膨胀,升级为重量级锁。

5.5 偏向锁

偏向锁定义:在轻量级锁的基础上,消除数据在无竞争情况下的同步原语,进一步提高程序的运行性能。

如果说轻量级锁是在无竞争的情况下使用 CAS 操作去消除同步使用的互斥量,那偏向锁就是在无竞争的情况下把整个同步都消除掉,连 CAS 操作都不做了。

偏向锁的 “偏”,就是偏心的 “偏”、偏袒的 “偏”,它的意思是这个锁会偏向于第一个获得它的线程,如果在接下来的执行过程中,该锁没有被其他的线程获取,则持有偏向锁的线程将永远不需要再进行同步。

偏向锁执行流程:

假设当前虚拟机启用了偏向锁(启用参数 -XX:+UseBiasedLocking,这是 JDK 1.6 的默认值),那么,当锁对象第一次被线程获取的时候,虚拟机将会把对象头中的标志位设为 “01”,即偏向模式。同时使用 CAS 操作把获取到这个锁的线程 ID 记录在对象的 Mark Word 之中,如果 CAS 操作成功,持有偏向锁的线程以后每次进入这个锁相关的同步块时,虚拟机都可以不再进行如何同步操作(例如 Locking、Unlocking 及对 Mark Word 的 Update 等)。

当有另外一个线程去尝试获取这个锁时,偏向模式就宣告结束。根据锁对象目前是否处于被锁定的状态,撤销偏向(Revoke Bias)后恢复到未锁定(标志位为 “01”)或轻量级锁定(标志位为 “00”)的状态,后续的同步操作就如上面介绍的轻量级锁那样执行。

偏向锁、轻量级锁的状态转换及对象 Mark Word 的关系如图:

在这里插入图片描述

对于上图的理解:这个图为我们提供从分配对象开始的整个锁安全的变化,先查看倒数第三位,biased lock flag偏向锁标记若为1,则表示偏向锁,若为0,则表示无锁/未加锁,

对于偏向锁,初始锁定即确定了threadID,撤销偏向即取消了、去掉了偏向,进入另一个状态,若对象未锁定,变为无锁/未加锁状态,若对象锁定,变为轻量级锁定状态,这里,无锁状态访问变量优先使用轻量级锁定状态,若轻量级锁定产生获取锁失败,会产生膨胀,变为重量级锁(一般使用synchronized锁定)。

对于无锁,优先使用轻量级锁定状态,若轻量级锁定产生获取锁失败,会产生膨胀,变为重量级锁(一般使用synchronized锁定)。

小结:偏向锁可以提高带有同步但无竞争的程序性能。它同样是一个带有效益权衡(Trade Off)性质的优化,也就是说,它并不一定总是对程序运行有利,如果程序中大多数的锁总是被多个不同的线程访问,那偏向模式就是多余的。在具体问题具体分析的前提下,有时候使用参数 -XX:-UseBiasedLocking 来禁止偏向锁优化反而可以提升性能。

六、尾声

本文介绍了四个方面的内容,分别是Java内存模型、Java线程、Java线程安全、锁优化。

第二部分介绍Java内存模型(Jave Memory Model,JMM),介绍JVM底层原理,从而引入原子性、多线程先行发生原则;

第三部分介绍JVM多线程底层原理,线程实现的三种方式、线程调度两种方式(协同式、抢占式)、优先级、线程状态转换;

第四部分介绍Java线程安全,包括5种级别的线程安全(不可变、绝对线程安全、相对线程安全、线程兼容、线程对立)和三种实现线程安全的方法(阻塞同步、非阻塞同步、无同步方案);

第五部分介绍锁优化,对于传统的重量级锁(synchronized)提供一系列优化操作——自旋与自适应自旋(Adaptive Spinning)、锁消除(Lock Elimination)、锁粗化(Lock Coarsening)、轻量级锁(Lightweight Locking)和偏向锁(Biased Locking)。

天天打码,天天进步!

Logo

华为开发者空间,是为全球开发者打造的专属开发空间,汇聚了华为优质开发资源及工具,致力于让每一位开发者拥有一台云主机,基于华为根生态开发、创新。

更多推荐