再谈多线程
预备知识:
JavaSE多线程部分(必备)、操作系统、JVM(推荐)、计算机组成原理。
还记得我们在JavaSE中学习的多线程吗?让我们来回顾一下:
在我们的操作系统之上,可以同时运行很多个进程,并且每个进程之间相互隔离互不干扰。我们的CPU会通过时间片轮转算法,为每一个进程分配时间片,并在时间片使用结束后切换下一个进程继续执行,通过这种方式来实现宏观上的多个程序同时运行。
由于每个进程都有一个自己的内存空间,进程之间的通信就变得非常麻烦(比如要共享某些数据)而且执行不同进程会产生上下文切换,非常耗时,那么有没有一种更好地方案呢?
后来,线程横空出世,一个进程可以有多个线程,线程是程序执行中一个单一的顺序控制流程,现在线程才是程序执行流的最小单元,各个线程之间共享程序的内存空间(也就是所在进程的内存空间),上下文切换速度也高于进程。
现在有这样一个问题:
public static void main(String[] args) {
int[] arr = new int[]{3, 1, 5, 2, 4};
//请将上面的数组按升序输出
}
按照正常思维,我们肯定是这样:
public static void main(String[] args) {
int[] arr = new int[]{3, 1, 5, 2, 4};
//直接排序吧
Arrays.sort(arr);
for (int i : arr) {
System.out.println(i);
}
}
而我们学习了多线程之后,可以换个思路来实现:
public static void main(String[] args) {
int[] arr = new int[]{3, 1, 5, 2, 4};
for (int i : arr) {
new Thread(() -> {
try {
Thread.sleep(i * 1000); //越小的数休眠时间越短,优先被打印
System.out.println(i);
} catch (InterruptedException e) {
e.printStackTrace();
}
}).start();
}
}
我们接触过的很多框架都在使用多线程,比如Tomcat服务器,所有用户的请求都是通过不同的线程来进行处理的,这样我们的网站才可以同时响应多个用户的请求,要是没有多线程,可想而知服务器的处理效率会有多低。
虽然多线程能够为我们解决很多问题,但是,如何才能正确地使用多线程,如何才能将多线程的资源合理使用,这都是我们需要关心的问题。
在Java 5的时候,新增了java.util.concurrent(JUC)包,其中包括大量用于多线程编程的工具类,目的是为了更好的支持高并发任务,让开发者进行多线程编程时减少竞争条件和死锁的问题!通过使用这些工具类,我们的程序会更加合理地使用多线程。而我们这一系列视频的主角,正是JUC
。
但是我们先不着急去看这些内容,第一章,我们先来补点基础知识。
并发与并行
我们经常听到并发编程,那么这个并发代表的是什么意思呢?而与之相似的并行又是什么意思?它们之间有什么区别?
比如现在一共有三个工作需要我们去完成。
顺序执行
顺序执行其实很好理解,就是我们依次去将这些任务完成了:
实际上就是我们同一时间只能处理一个任务,所以需要前一个任务完成之后,才能继续下一个任务,依次完成所有任务。
并发执行
并发执行也是我们同一时间只能处理一个任务,但是我们可以每个任务轮着做(时间片轮转):
只要我们单次处理分配的时间足够的短,在宏观看来,就是三个任务在同时进行。
而我们Java中的线程,正是这种机制,当我们需要同时处理上百个上千个任务时,很明显CPU的数量是不可能赶得上我们的线程数的,所以说这时就要求我们的程序有良好的并发性能,来应对同一时间大量的任务处理。学习Java并发编程,能够让我们在以后的实际场景中,知道该如何应对高并发的情况。
并行执行
并行执行就突破了同一时间只能处理一个任务的限制,我们同一时间可以做多个任务:
比如我们要进行一些排序操作,就可以用到并行计算,只需要等待所有子任务完成,最后将结果汇总即可。包括分布式计算模型MapReduce,也是采用的并行计算思路。
再谈锁机制
谈到锁机制,相信各位应该并不陌生了,我们在JavaSE阶段,通过使用synchronized
关键字来实现锁,这样就能够很好地解决线程之间争抢资源的情况。那么,synchronized
底层到底是如何实现的呢?
我们知道,使用synchronized
,一定是和某个对象相关联的,比如我们要对某一段代码加锁,那么我们就需要提供一个对象来作为锁本身:
public static void main(String[] args) {
synchronized (Main.class) {
//这里使用的是Main类的Class对象作为锁
}
}
我们来看看,它变成字节码之后会用到哪些指令:
其中最关键的就是monitorenter
指令了,可以看到之后也有monitorexit
与之进行匹配(注意这里有2个),monitorenter
和monitorexit
分别对应加锁和释放锁,在执行monitorenter
之前需要尝试获取锁,每个对象都有一个monitor
监视器与之对应,而这里正是去获取对象监视器的所有权,一旦monitor
所有权被某个线程持有,那么其他线程将无法获得(管程模型的一种实现)。
在代码执行完成之后,我们可以看到,一共有两个monitorexit
在等着我们,那么为什么这里会有两个呢,按理说monitorenter
和monitorexit
不应该一一对应吗,这里为什么要释放锁两次呢?
首先我们来看第一个,这里在释放锁之后,会马上进入到一个goto指令,跳转到15行,而我们的15行对应的指令就是方法的返回指令,其实正常情况下只会执行第一个monitorexit
释放锁,在释放锁之后就接着同步代码块后面的内容继续向下执行了。而第二个,其实是用来处理异常的,可以看到,它的位置是在12行,如果程序运行发生异常,那么就会执行第二个monitorexit
,并且会继续向下通过athrow
指令抛出异常,而不是直接跳转到15行正常运行下去。
实际上synchronized
使用的锁就是存储在Java对象头中的,我们知道,对象是存放在堆内存中的,而每个对象内部,都有一部分空间用于存储对象头信息,而对象头信息中,则包含了Mark Word用于存放hashCode
和对象的锁信息,在不同状态下,它存储的数据结构有一些不同。
重量级锁
在JDK6之前,synchronized
一直被称为重量级锁,monitor
依赖于底层操作系统的Lock实现(每个对象都有一个monitor
监视器与之对应),Java的线程是映射到操作系统的原生线程上,切换成本较高。而在JDK6之后,锁的实现得到了改进。我们先从最原始的重量级锁开始:
我们说了,每个对象都有一个monitor与之关联(将monitor理解是操作系统的变量),在Java虚拟机(HotSpot)中,monitor是由ObjectMonitor实现的:
ObjectMonitor() {
_header = NULL;
_count = 0; //记录个数
_waiters = 0,
_recursions = 0;
_object = NULL;
_owner = NULL;
_WaitSet = NULL; //处于wait状态的线程,会被加入到_WaitSet
_WaitSetLock = 0 ;
_Responsible = NULL ;
_succ = NULL ;
_cxq = NULL ;
FreeNext = NULL ;
_EntryList = NULL ; //处于等待锁block状态的线程,会被加入到该列表
_SpinFreq = 0 ;
_SpinClock = 0 ;
OwnerIsThread = 0 ;
}
每个等待锁的线程都会被封装成ObjectWaiter对象,进入到如下机制:
- ObjectWaiter首先会进入 Entry Set等着 当线程获取到对象的
monitor
后进入 The Owner区域。 - 并把
monitor
中的owner
变量设置为当前线程,同时monitor
中的计数器count
加1。 - 若线程调用
wait()
方法,将释放当前持有的monitor
,owner
变量恢复为null
,count
自减1,同时该线程进入WaitSet集合中等待被唤醒。 - 若当前线程执行完毕也将释放
monitor
并复位变量的值,以便其他线程进入获取对象的monitor
。
虽然这样的设计思路非常合理,但是在大多数应用上,每一个线程占用同步代码块的时间并不是很长(也就是每一个线程拿着锁的时间不是很长),我们完全没有必要将竞争中的线程挂起然后又唤醒(因为频繁挂起再唤醒很耗时间),并且现代CPU基本都是多核心运行的,我们可以采用一种新的思路来实现锁。
在JDK1.4.2时,引入了自旋锁(**JDK6之后默认开启),它不会将处于等待状态的线程挂起,而是通过无限循环的方式,不断检测是否能够获取锁,由于单个线程占用锁的时间非常短,所以说循环次数不会太多,可能很快就能够拿到锁并运行,这就是自旋锁。**当然,仅仅是在等待时间非常短的情况下,自旋锁的表现会很好,但是如果等待时间太长,由于循环是需要处理器继续运算的,所以这样只会浪费处理器资源,因此自旋锁的等待时间是有限制的,默认情况下为10次,如果失败,那么会进而采用重量级锁机制。
在JDK6之后,自旋锁得到了一次优化,自旋的次数限制不再是固定的,而是自适应变化的,比如在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行,那么这次自旋也是有可能成功的,所以会允许自旋更多次。当然,如果某个锁经常都自旋失败,那么有可能会不再采用自旋策略,而是直接使用重量级锁。(底层算法实现的)
轻量级锁(乐观锁)
从JDK 1.6开始,为了减少获得锁和释放锁带来的性能消耗,就引入了轻量级锁。
轻量级锁的目标是,在无竞争情况下,减少重量级锁产生的性能消耗(并不是为了代替重量级锁,实际上就是赌一手同一时间只有一个线程在占用资源),消耗包括系统调用引起的内核态与用户态切换、线程阻塞造成的线程切换等。它不像是重量级锁那样,需要向操作系统申请互斥量。它的运作机制如下:
在即将开始执行同步代码块中的内容时,会首先检查锁对象的Mark Word,查看锁对象是否被其他线程占用,如果没有任何线程占用,那么会在当前线程中所处的栈帧(翻译就是当前线程所执行的方法,这个方法就是同步代码块)中建立一个名为锁记录(Lock Record)的空间,用于复制并存储对象目前的Mark Word信息(官方称为Displaced Mark Word),这相当于在备份,备份好就可以去修改Mark Word信息。
栈帧也叫过程活动记录,是编译器用来实现过程或函数调用的一种数据结构。
C语言中,每个栈帧对应着一个未运行完的函数。栈帧中保存了该函数的返回地址和局部变量。
栈帧,顾名思义,就是栈中的一帧,栈分成很多帧,就如同一个视频动作分成好多帧一样。每个栈帧,对应一个函数,就是这个函数在栈中占用的部分。其实就是有一个栈这种数据结构来存放线程要执行的方法或函数。
接着,虚拟机将使用CAS操作将锁对象的Mark Word更新为轻量级锁状态(数据结构变为指向Lock Record的指针,指向的是当前的栈帧)
CAS(Compare And Swap)是一种无锁算法(我们之前在Springboot阶段已经讲解过了),它并不会为对象(这个对象就是要修改的变量)加锁,而是在执行的时候,看看当前数据的值是不是我们预期的那样,如果是,那就正常进行替换,如果不是,那么就替换失败。比如有两个线程都需要修改变量
i
的值,默认为10,现在一个线程要将其修改为20,另一个要修改为30,如果他们都使用CAS算法,那么并不会加锁访问i
,而是直接尝试修改i
的值,但是在修改时,需要确认i
是不是10,如果是,表示其他线程还没对其进行修改,如果不是,那么说明其他线程已经将其修改,此时不能完成修改任务,修改失败。
在CPU中,CAS操作使用的是cmpxchg
指令,能够从最底层硬件层面得到效率的提升。
如果CAS操作失败了的话,那么说明可能这时有线程已经进入这个同步代码块了,这时虚拟机会再次检查对象的Mark Word,是否指向当前线程的栈帧,如果是,说明不是其他线程,而是当前线程已经有了这个对象的锁,直接放心大胆进同步代码块即可。如果不是,那确实是被其他线程占用了。
这时,轻量级锁一开始的想法就是错的(这时有对象在竞争资源,已经赌输了),所以说只能将锁膨胀为重量级锁,按照重量级锁的操作执行(注意锁的膨胀是不可逆的)
所以,轻量级锁 -> 失败 -> 自适应自旋锁 -> 失败 -> 重量级锁
解锁过程(与加锁是逆过程)同样采用CAS算法,如果对象的MarkWord仍然指向线程的锁记录,那么就用CAS操作把对象的MarkWord和复制到栈帧中的Displaced Mark Word进行交换。如果替换失败(对象的MarkWord不是指向当前线程的锁记录),说明其他线程尝试过获取该锁,在释放锁的同时,需要唤醒被挂起的线程。
偏向锁
偏向锁相比轻量级锁更纯粹,干脆就把整个同步都消除掉,不需要再进行CAS操作了。它的出现主要是得益于人们发现某些情况下某个锁频繁地被同一个线程获取,这种情况下,我们可以对轻量级锁进一步优化。
偏向锁实际上就是专门为单个线程而生的,当某个线程第一次获得锁时,如果接下来都没有其他线程获取此锁,那么持有锁的线程将不再需要进行同步操作。
可以从之前的MarkWord结构中看到,偏向锁也会通过CAS操作记录线程的ID,如果一直都是同一个线程获取此锁,那么完全没有必要在进行额外的CAS操作。当然,如果有其他线程来抢了,那么偏向锁会根据当前状态,决定是否要恢复到未锁定或是膨胀为轻量级锁。
如果我们需要使用偏向锁,可以添加-XX:+UseBiased
参数来开启。
JVM调参
所以,最终的锁等级为:未锁定 < 偏向锁 < 轻量级锁 < 重量级锁
值得注意的是,如果对象通过调用hashCode()
方法计算过对象的一致性哈希值,那么它是不支持偏向锁的,会直接进入到轻量级锁状态,因为Hash是需要被保存的,而偏向锁的Mark Word数据结构,无法保存Hash值;如果对象已经是偏向锁状态,再去调用hashCode()
方法,那么会直接将锁升级为重量级锁,并将哈希值存放在monitor
(有预留位置保存)中。
锁消除和锁粗化
锁消除和锁粗化都是在运行时的一些优化方案,比如我们某段代码虽然加了锁,但是在运行时根本不可能出现各个线程之间资源争夺的情况,这种情况下,完全不需要任何加锁机制,所以锁会被消除。锁粗化则是我们代码中频繁地出现互斥同步操作,比如在一个循环内部加锁(循环一次就要进行一次加锁和释放锁),这样明显是非常消耗性能的,所以虚拟机一旦检测到这种操作,会将整个同步范围进行扩展(扩展到循环外部,也就是整个循环只加锁解锁一次)。