1.什么是进程?什么是程序?什么是线程?什么是协程?
在Java中,进程它指的是一个JVM
进程,它是资源分配的最小单元,它是系统运行程序的最小单位,因此你可以把它理解成动起来的程序
,系统运行一个程序就是一个进程从创建,运行到消亡的过程
在Java中,线程指的是一个Thread
,通常来说可以通过new Thread()
、implements Runnable
、implement Callable<T>
三种方式来实现一个线程,其中new Thread()
出来的线程是由当前运行中的线程创建出来的,因此属于一个子线程,而Runnable
也是归属于子线程,但是它没有返回值,适合做一些定时调度任务,而Callable<T>
的返回值为T,适合做一个异步调用
Java20提出了虚拟线程,这个所谓的虚拟线程其实就是协程的概念,协程它本质上来说就是一个可以在某个地方挂起的特殊函数,并且可以在重新挂起的地方继续运行,一个线程内的多个线程的运行都是串行的
因此协程它本质上是串行执行的,因此不适合计算密集型场景,协程适合IO阻塞型场景
那么它和线程相比有什么用呢?首先它使得CPU的利用率更高了,比如说有5个IO任务,如果交给线程来执行,那么就会导致这5个线程阻塞,而线程的维护是需要开销的,因此就浪费掉了
但是使用线程的话,只使用1个线程,就能够开启5个协程,然后同时发出IO指令,这样的话就很快了,并且线程资源能够得到很好的运用
2. 进程和线程有啥区别?
这个问题可以从JVM的角度来分析:
首先进程是资源分配的最小单位,每个进程都有自己独一无二的内存空间,包括有PCB,PCB中保存了当前进程运行的相关信息,各个进程之间除非使用IPC通信,比如说管道通信,通过fork()
、消息队列等方式进行通信,各个进程之间的运行都是毫不相干的
而线程是CPU调度的最小单位,每个线程共享进程内的空间,但是对于一些影响到线程运行逻辑的数据结构进行了隔离,比如说本地方法栈,它是给本地方法在运行的时候使用的,虚拟机栈,它是给线程方法在运行的时候使用的,每一个栈中有若干个栈帧,每一个栈帧就代表着一次函数调用,栈中会有局部变量表,这个表的存在解释了为什么有实参和形参这两种不同的参数,有一个操作数栈,这个操作数栈记录了某次的临时计算结果,比如说a+b+c
等,需要记录中间运算的过程。还有的比如说有程序计数器,这个东西也是私有的,这是因为线程被发明出来就是被调度的,假设A线程被调度上CPU,执行到一半时间片到,然后这个需要记录当前这个线程方法执行到哪了
然后就会将程序计数器中的值记录下来,等到下一次再上CPU的时候使用
3. 什么叫并发?什么叫并行
并发指的是:一个CPU单核,多个线程在上面轮转
并行指的是:一个CPU多核,多个线程同时运行
4. 什么叫同步?什么叫异步?
同步
:当发出一个调用之后,在没有得到结果之前,这个调用就不可以返回,一直等待异步
:当发出一个调用之后,无论结果是否被得到了,这个调用都可以返回,直到用户主动获取结果
5. 使用多线程有什么好处?
从多核的角度来说,由于CPU多核结构的存在,因此一个CPU多核,意味着一个CPU可以同时执行多条指令流,如果只有单线程的话,那么多核结构就被浪费掉了
从单核的角度来说,因为CPU的资源是很珍贵的,我们希望尽量的利用CPU,如果只有一个线程,那么当这个线程发起阻塞IO的时候,那么这个线程就啥也没干,CPU也没有参加到运算中,最终就导致了CPU的利用率低
那么从应用的角度上来说,由于线程的上下文的切换更加轻量级,不需要切换页表,因此多线程相比于多进程来说,能够减少开销,利用多线程可以提高系统的并发能力
6. 线程的生命周期和状态
线程的生命周期通常可以分为以下几种:
NEW
:新建状态,也就是说此时线程刚被创建出来,但是还没有调用start()
RUNNABLE
:运行状态,线程调用了start()
等待运行的状态
BLOCKED
:阻塞状态,一般来说是因为锁而被阻塞
WAITING
:等待状态,表示该线程正在等待某些事情发生,比如说被notify()
TIME_WAITING
:等待超时状态,比如说使用了sleep(seconds)
的时候,当到了seconds
的时候就会自动返回成为RUNNABLE
Terminated
:终止态,表示线程运行完毕
那么这些状态是如何转换的知道吗?
当一个线程被New()
出来之后,就是进入了NEW
的状态
当一个线程被调用了start()
的之后,就是进入了RUNNABLE
的状态
当一个线程执行了wait()
方法之后,这个线程就进入了WAITING
的状态,进入等待状态的线程需要依靠其他线程的通知才能返回到运行的状态
当一个线程调用了sleep(seconds)
或者wait(seconds)
的时候,就相当于在等待的状态上增加了超时限制,此时会进入一个TIMED_WAITIING
的状态,当超时时间结束之后,线程将会返回到RUNNABEL
的状态
当线程进入了synchronized
方法/快调用wait()
后,然后被notify()
之后重新进入了synchronized
的时候,但是因为锁被其他线程占用,此时线程就会进入到一个BLOCKED
的状态
线程在执行完了run()
方法之后就会进入到TERMINATED
的状态
7. 什么叫上下文切换?
当发生1.线程的CPU时间片用完之后2.发生垃圾回收3.有更加高级的线程需要运行的时候4.当线程自己调用了sleep
、yield
、wait
、join
、park
、synchronized
、lock
等方法的时候,就会发生线程上下文切换
当ContextSwitch发生的时候,需要由操作系统保存当前线程的状态,并且恢复另一个线程的状态,Java中对应的概念就是程序计数器
(Program Counter Register)
,它的作用就是记住下一条JVM指令的执行地址,是线程私有的
- 状态包括程序计数器,虚拟机栈中每个栈帧的信息,如局部变量、操作数栈、返回地址等
- ContextSwitch频繁发生会影响性能
上下文的切换可以分成三种
系统调用的上下文切换
系统调用的上下文切换,本质上就是同一个进程,调用不同函数的过程,但是由于要从用户空间切换到内核空间,因此需要对CPU的运行环境进行切换,比如说保存当前用户态的程序计数器和CPU寄存器到系统内核中,然后加载调用系统调用所需要的上下文信息到CPU环境中,然后完成后,从内核中取出相关数据到CPU中
进程的上下文切换
进程的上下文切换,指的是多个进程间的任务切换,它的做法是将当前的CPU运行环境保存到PCB中,这个过程中,由于进程是由内核来管理的,因此进程的切换只能发生在内核态,因此进程的上下文切换不仅包括有内存堆栈、寄存器等内核空间的状态,还包括有虚拟内存、栈、全局变量等用户空间的资源
因此进程的上下文切换还会导致页表等用户空间数据的切换,等新的进程被加载到CPU上的时候,还需要刷新新的进程的虚拟空间和用户栈
线程的上下文切换
首先要明白,线程是CPU调度的基本单位,而进程是资源分配的基本单位,内核中的任务调度,实际上的调度对象是线程,同一个进程中的所有线程共享进程的虚拟内存、全局变量等资源
它的上下文切换通常是切换私有方法栈以及程序计数器等内容,这些数据结构所占用的内存更小,因此通常来说是更加轻量级的操作
8. 什么是线程死锁?如何避免死锁?
线程死锁
指的是多个线程同时阻塞了,它们中的一个或者全部都在等待某个资源被释放,由于线程被无限期的阻塞,因此程序不可能正常终止,产生死锁有四个条件
- 互斥:就是导致死锁的资源是互斥的,一旦一个线程占用了这个资源,其他线程试图获取的时候就会被阻塞
- 占用且等待:当线程占用资源的时候,它在申请新的资源的时候不会释放原有的资源
- 不可强制剥夺:指的是其他线程不可强制剥夺某个线程所占用的资源,重复操作系统干预
- 形成循环等待链:指的是线程之间互相等待资源释放
如何来避免死锁?
首先互斥条件一般来说是不能破坏了,如果资源失去了互斥的特性,那么就起不到保护的作用了,因此一般不会将互斥条件破坏掉
其次是占用且等待,说的意思是线程占用资源的时候,它申请资源的时候不会释放原有的资源,这个条件可以通过资源预分配的方案来实现,也就是破坏后面半句话中的申请新资源
,在线程开始执行任务的时候就提前计算好资源量,如果当前的资源是足够的,那么就让线程运行,否则的话就不允许线程运行
第二个就是角度可以通过不释放原有的资源来进行破解,也就是说当线程申请新资源的时候,如果申请不到新资源的话,那么就会释放原有的资源,让其他线程来获取
第三个就是破坏循环等待的条件,可以通过按序申请资源来预防,按照某一个顺序来申请资源,释放资源则反序释放
如何来预防死锁?
预防死锁可以使用银行家算法,这个算法运行的具体逻辑就是通过估算当前线程执行任务的时候
,所需要的资源的个数,当存在一个资源分配序列可以满足当前线程的运行,那么就证明这次分配是安全的,一旦出现某次分配导致线程无法推进的时候,就有可能导致死锁的发生,于是放弃分配,具体的流程就是一个试探性分配的过程,也就是说当将资源分配到P1的时候,会检查当前的进程队列,如果进程队列中没有任何一个进程能够运行完毕并且释放资源的话,就会被判断为不安全的状态,此时就会导致死锁的发生
9. sleep()和wait()方法对比
sleep()
和wait()
都能够暂停线程的执行
sleep()
没有释放锁,而wait()
释放了锁wait()
通常用于线程间交互/通信,sleep()
通常用于暂停执行wait()
方法被调用了之后,线程会自动苏醒,或者也可以使用wait(long timeout)
超时后线程会主动苏醒sleep()
是Thread
类的静态本地方法,而wait()
是Object
的本地方法
当线程调用了sleep()
和wait()
都会导致线程进入阻塞状态
10. 为什么wait()方法不定义在Thread?
这个问题可以从wait()
方法的特性上来说,这个方法是为了让获得对象锁的线程实现等待,会自动释放当前线程所占有的对象锁,每个对象都拥有一个对象锁,既然是要释放当前线程占有的对象锁并让其进入WAITING
状态,自然是要操作对应的对象Object
而非当前的线程Thread
sleep()
定义在Thread
中,是因为让线程暂停运行
,不涉及到对象类,也不需要获取对象锁
11.可以直接调用Thread的run()方法吗?
这取决于开发者的目的,我们说一个线程的生命周期是NEW=>RUNNABLE=>RUNNING
当使用start()
的时候,此时线程并不会立即开始执行run()
中代码,而是由分派器决定,当线程获得了时间片之后才会执行run()
中的方法
当使用run()
的时候,此时线程会立即执行run()
中的代码,此时会将run()
当做是main()
线程下普通方法来执行,并不会在某个线程中执行它,所以这并不是真正的多线程工作
12. 说说CPU的多级缓存结构
CPU
的多级缓存结构主要是为了处理CPU处理速度和内存处理速度不对等的问题,对于访问速度来说:
磁盘的访问速度
<内存的访问速度
<CPUCache的访问速度
<寄存器的访问速度
这是由各个存储介质的空间大小来决定的,当存储介质的空间越小,意味着查询的速率就会越快
一般来说,CPU的多级缓存是这样工作的,当CPU需要访问某一部分数据的时候,就会将内存中的内容写入到cache
中,然后当CPU需要使用的时候就直接从cache
中取出数据来用就可以了
但是在并发的环境下是有问题的,比如说有线程1试图修改进程A中的数据i=1
,使得i++
,但是执行到一半,还没有刷回到内存中的时候,线程2抢占CPU,然后视图修改进程A中的数据i=1
,使得i++
,然后线程1和线程2将修改后的CpuCache
中的数据同时刷回内存,这时候内存中的数据就是i=2
了,这就导致了并发计算结果的不一致。
怎么解决?
操作系统通过定义内存模型以及一系列的规范来解决这个问题
13. 什么是叫指令重排序?
指令重排序
,简单来说就是执行代码的时候并不一定是按照你写的代码的顺序执行的,常见的指令重排序有:
编译器优化重排:编译器
(包括JVM、JIT编译器等)
,在不改变单线程程序语义的前提下,重新安排语句的指令顺序指令并行排序:现代处理器采用指令级并行技术来将多条指令重叠执行,简单来说就是当指令之间不存在一个数据的依赖关系的话,那么处理器就可以改变语句对应及其指令的执行顺序
内存系统也有一个重排序的操作,但是不是真正意义上的重排序,在JMM中via噢西安为主存和本地内存的内容可能不一致,进而导致程序在多线程下执行可能出现问题
Java
源代码会经历编译器优化重排
=>指令并行重排
=>内存系统重排
的过程,最终才会变成操作系统可执行的指令序列
指令重排序可以保证串行语义一致,但是没有义务保证多线程间的语义也一致,所以在多线程下,指令重排序可能会导致一些问题的发生
编译器和处理器的指令重排序的处理方式不一致,对于编译器,通过禁止特定类型的编译器重排序的方式来禁止重排序,对于处理器,通过插入内存屏障的方式来禁止特定类型的处理器重排序,指令并行排序和内存系统排序都是属于处理器级别的指令重排序
内存屏障是一种CPU指令,它是用来禁止处理器指令发生重排序,从而保障指令执行的有序性,另外,为了达到屏障的效果,它也会使得处理器写入、读取值之前,将主内存中的值写入到高速缓冲区中,清空无效队列,从而保障变量的可见性
14. 什么是JMM?为什么需要JMM?
JMM(JavaMomoryModel)
:其中文名称是Java内存模型,其主要的目的就是为了解决指令重排序和线程本地内存和主存之间的数据不一致的问题,主要目的是为了简化多线程编程,增强程序的可移植性
在各个操作系统中也会开发出一套关于线程=>主存之间数据缓存一致性的规范,Java完全可以复用操作系统的内存模型来解决多线程下,变量可见性的问题以及指令重排序的问题,但是由于Java是跨平台的,所以这样的话就可能导致同一套代码在不同的操作系统中运行就会产生不一样的结果
为此,Java提供了JMM
这一套内存模型来屏蔽系统的差异,它说白了就是通过定义一些规范来解决这些问题,开发者可以利用这些规范来更好地解决多线程编程的问题,可以直接使用并发相关的关键字和类,比如说synchronized
和Lock
、AQS
等
15. JMM具体是如何的?
在JDK1.2之前,Java内存模型总是从主存中读取数据的,那么这样的做法就是安全而稳定,但是每次CPU都需要到主存中读取数据,从而导致性能较差
那么在当前的Java的内存模型之下,它将内存区域抽象成了本地内存和主存,但是这样的设计可能带来的问题就是数据的不一致,就好像是我们的CPU
的三级缓存,如果没有设计CPU的三级缓存,那么就会导致Java
内存数据的不一致,比如说线程A和线程B都读取了内存中变量小a的变量,然后当线程A将小a的值写回到主存中的时候,这时候就会出现一个情况:就是线程B依然读取的是本地内存中的拷贝,最终就导致了数据的不一致性
16.具体说说什么是主内存?什么本地内存?
具体来说,就是一种cache
技术,它将主存中的数据复制一份到本地内存中,然后当线程执行过程中需要相关的数据之后,就可以直接从自己的本地内存中读取了,本地内存是线程私有的,只有本线程才能读取,而主存是所有线程是所公有的,所有线程创建的实例对象都存放在主内存中,因此是属于一个共享内存的范畴
本地内存则是每个线程都有一个私有的本地内存来存储共享变量的副本,并且每个线程都只能够访问自己的本地内存,无法访问其他线程的本地内存,它是JMM抽象出来的概念,存储了主内存中的共享变量副本
那么线程是如何操作JMM的呢?
假设现在有线程A和线程B,有共享变量小a存在主存中,然后线程A和线程B就将一份副本a1
和a2
分别拷贝到线程A和线程B中的本地内存中,然后执行相关的操作
如果线程A和线程B要进行通信的话,那么必须通过以下的步骤
线程A将本地内存中修改过的共享变量的副本刷回到主存中
线程B将读取主存中的共享变量
但是这依然会产生线程安全问题,这是因为线程A和线程B读取共享变量的时机是不确定的
也就是说线程B读取共享变量a的时候,有可能线程A还没有将数据刷回到主存中,也有可能线程A已经将数据刷会到了主存中了。
17. Java内存区域和Java内存模型有什么区别?
Java
内存区域它指的是JVM
进程中各个数据段的划分,比如说有堆内存,运行时数据区,方法区,以及线程所私有的程序计数器,本地方法栈,虚拟机栈,以及存储在栈中的操作数栈,局部变量表等,可见Java
的内存模型它定义的JVM
进程中的内存分布,定义了各个数据段的划分
Java
内存模型指的是工作/本地内存
和主内存
之间交互的细节,如果说Java
的内存区域管理对标的是os
中的内存管理,那么Java
内存模型管理对标的是os
中的进程管理,它规定了进程内部中的线程是如何访问共享内存的,通过指定一系列的规范来简化并发编程
18. 什么是可见性?什么是原子性?
所谓可见性,它指的是在多个线程之间,一个线程对volatile
变量的修改对另外一个线程是可见的,但是不能够保证原子性,通常用在一个写线程和多个读取线程的情况
比如说有
static int i = 0;
new Thread(()->{
i++;
}).start;
new Thread(()->{
i--;
}).start();
在预想情况下,应该i=0
,但是在指令交错的情况下还是会导致一个i = -1
的情况
一般来说,线程获取这个静态变量值的过程是:
- 指令1(getstatic):先读取主存中的值,然后将这个主内存中的值存储到工作内存中
- 指令2(iconst_1):将工作内存中的值交给执行引擎
- 指令3(iadd/isub):将执行引擎中得到的值执行的运算
- 指令4(putstatic):将工作内存的值刷回到主存中
正常情况下应该要是
线程1: 1=>2=>3=>4,此时刷回去的值为1
线程2: 1=>2=>3=>,此时刷回去的值为0
但是在指令交错的情况下就变成了
线程1: 1=>2
线程2: 1=>2
线程1: 3=>4,此时刷回去的值为1
线程2: 3=>4,注意,此时线程工作内存中的值为`0`,因此最终刷回去一个`-1`
综上所述:volatile
只能够保证线程之间对变量的一个可见性,但是无法保证因为指令交错而导致的一个原子性
19. 如何终止掉一个线程
方法1:使用线程对象的stop()
暴力关停线程,如果此时线程锁住了资源,可能导致死锁
方法2:设置一个boolean
变量,定义为volatile
的,然后在外面的线程中修改这个变量即可
什么叫做犹豫模式,这个所谓的犹豫模式就是设置一个变量,如果在某件事执行之前检查有没有其他线程开启了这个方法了,如果有的话就不执行,这个模式就是基于
volatile
实现的,通过volatile
强行看到最新的数据+synchronized
的方式来保证原子性和可见性
20. 什么是有序性?
指令重排
:指的是在单线程下不影响程序的运行结果的前提下,将Java指令代码重排序以提高CPU的执行效率,但是这种指令重排将会在多线程下产生问题
为什么要做指令重排?
现代处理器会设计为一个时钟周期完成一条执行时间最长的CPU
指令,为什么这么做呢?这是因为想到指令还可以划分为一个个更小的阶段,比如说每条指令都可以分为
- 取指令=>指令译码=>执行指令=>内存访问=>数据写回的五个节点
那么在现代处理器中,由于现代CPU的支持多级指令流水线,例如支持同时执行
- 取指令=>指令译码=>执行指令=>内存访问=>数据写回
就可以称之为五级指令流水线,此时CPU可以在一个时钟周期内,同时运行5条指令的不同阶段,相当于一条执行时间最长的复杂指令,流水线技术不能能够缩短单条指令的执行时间,但是它变相地提高了指令的吞吐率
例子?
boolean ready = false;
int nums = 0;
new Thread(()->{
if(ready){
r.r1 = num+num;
}else{
r.r1 = 1;
}
}).start();
new Thread(()->{
num = 2;
ready = true;
}).start();
上面如果发生了指令重排序,那么最终可能会导致输出为0的结果
这是因为发生了这样的指令重排
ready = true;
num = 2;
21. volatile原理是什么?
volatile
的底层实现原理是内存屏障
- 对
volatile
变量的写指令后会加入写屏障 - 对
volatile
变量的读执行前会加入读屏障
如何保证可见性?
volatile boolean ready = false;
int nums = 0;
new Thread(()->{
//在这之前加入读屏障,在这之后的读操作都会读取主存中的内容
if(ready){
r.r1 = num+num;
}else{
r.r1 = 1;
}
}).start();
new Thread(()->{
num = 2;
ready = true;
//在这添加了写屏障,写屏障之前的所有改动都会全部同步到主存当中
}).start();
如何保障有序性?
volatile boolean ready = false;
int nums = 0;
new Thread(()->{
//在这之前加入读屏障,可以保障在这之前的数据不会被指令重排到当前这个读屏障之后
if(ready){
r.r1 = num+num;
}else{
r.r1 = 1;
}
}).start();
new Thread(()->{
num = 2;
ready = true;
//在这添加了写屏障,可以保障在对volatile之前的代码在它的后面
}).start();
总结一下,volatile
能够生成两种内存屏障
写屏障之前的更改都会直接同步到主内存中,写屏障之前的指令不会指令重排到写屏障之后,不会对写屏障之前的指令进行指令重排序,这样的话就能够避免之前的指令重排序导致其他线程发生异常
读屏障之后的读取都会直接从主内存中读取,不会将读屏障之后的代码排在读屏障之前,不会对读屏障之后的代码进行指令重排序,这样的话就能够避免读取到不一样的值
22. double-checked locking是什么
dcl问题
懒汉式的单例模式
public final class Singleton{
private Singleton(){}
private static Singleton INSTANCE = null;
public static Singleton getInstance(){
if(INSTANCE == null){//在这判断不加锁
//只有进到这里的时候才会加锁
synchronized(Singleton.class){
INSTANCE = new Singleton();
}
}
return INSTANCE;
}
}
- 为什么要加
synchronized
?
这是因为存在一个线程安全问题,当有两个线程同时判断INSTNACE==NULL
的时候,就会同时进入到创建对象的流程中,最终就会导致出现两个对象,破坏了单例模式
synchronized
锁的是什么?
这个关键字锁的是static
关键字修饰的方法,因此锁的是类对象
并且只有需要创建对象的时候才需要加一个同步锁,在创建完毕之后不会加锁
有什么问题?
注意,最外层的if(INSTANCE == null)
是在同步代码块之外的,因此这个判断没有受到synchronized
的保护,因此就会导致有序性
、可见性
、原子性
这三个特性得不到保障,从而导致问题的发生
主要的原因是:
一般来说创建对象的时候,先1.创建一个对象出来,2.然后赋值引用地址,3.然后调用init()
构造函数,4.然后才给INSTANCE
赋值的
但是在指令重排的时候,可能是先12然后43,这在JVM中是可能发生的
如果外面的线程在判断if(INSTANCE == null)
的时候,如果先调用了4
,而还没有调用3
,那么就会这个线程所使用的对象的变量还没有被赋初值,就会导致问题的发生
解决办法,给INSTANCE
实例使用volatile
即可,可以解决这个问题的原因是volatile
能够生成内存屏障,在这里的话就是生成一个写屏障,这个写屏障能够避免前面的代码被重排到后面去,这样的话就可以杜绝先赋值对象地址再执行初始化的发生
而读屏障呢则是避免读屏障之后的代码跑到前面去
23. 什么是happends-before?
happends-before
规定了对共享变量的写操作对其他线程的读操作是可见的,它是可见性和有序性的一套规则的总结,JMM并不能够保证一个线程对共享的写,对其他线程对该共享变量的读可见
static int x;
static Object m = new Object();
new Thread(()->{
synchronized(m){
x=10;//对x进行写入的操作
}
}).start();
new Thread(()->{
synchronized(m){
sout(x);//获取x的值来读取
}
}).start();
可以的话就保证了同步了,因此就是可见的
线程对volatile
变量的写,接下来其他线程对其他变量的读是可见的
volatile static int x;
new Thread(()->{
x= 10;
}).start();
new Thread(()->{
sout(x);
}).start()
线程start
前对变量的写,对该线程开始后的,变量是可见的
static int x;
x = 10;
new Thread(()->{
sout(x);
}).start();
线程结束前对变量的写,对其他线程得知它结束后的读是可见的
static int x;
Thread t1 = new Thread(()->{
x= 10;
});
t1.start();
t1.join();
sout(x);
线程t1打断t2前对变量的写,对于其他线程得知t2被打断后的对变量的读是可见的
24. 查看进程/线程的方法
- windows
tasklist#查看进程的列表
taskkill#杀死进程
- linux
ps -fe #查看所偶遇进程
ps -fT -p <PID> 查看某个进程(PID)
kill -9 pID#给某个进程发信号,-9是说停止程序的运行
top -H
#通常也会使用top -n1,每隔一段时间就会采集当前系统的负载
- java
jps#查看所有的Java进程
jstack <PID>查看某个Java进程的所有线程状态
jconsole <PID>查看某个进程中线程的运行情况
可以使用jsconsole
图形化监控工具来监控进程,支持本地连接和远程连接
25. 什么叫栈?什么是栈帧?
JVM中有堆栈和方法区,每个线程启动之后,虚拟机就会为其分配一块栈的内存
每个栈由多个栈帧来组成,对应着每次方法调用时所占用的内存增加一块栈帧
每次的方法调用都会产生一块栈帧
简述一下函数调用的过程
从main()
被执行开始,会先为main()
函数分配一块栈帧,这个栈帧里面有自己的局部变量表,返回地址,操作数栈,这个操作数栈中记录了栈帧中计算数据的中间结果
接着按照程序流,将代码载入到程序计数器中,然后内核执行这些代码,当遇到一个新的方法调用的时候,就会开辟一块新的栈帧空间,并且记录当前的返回地址,用来表示在这个函数执行完毕之后要返回到哪里继续运行
当函数执行完毕后,就弹出这块栈帧即可,弹出的时候,会将返回地址中的地址取出,然后将当前的栈指针指向这个地址
26. 线程的常见方法有哪些?
start()
:让线程进入就绪状态,等待被CPU调度,以分派器的决定为准,每个线程对象的start()
只能够运行一次,如果调用了多次就会出现IllegalThreadStateException
run()
:线程启动后会调用的方法
join()
:等待线程运行结束,也就是说,当线程1正在运行,正在计算某个结果,线程2想要获取这个计算结果,就可以通过join()
方法等待计算结果的产生,等到线程运行结束了就可以获取结果了
Thread t1 = new Thread(()->{
sleep(1);
r = 10;
}).start();
t1.join();
sout(r);
join(long n)
:等待线程运行结束,最多等待n秒
getState()
:获取线程运行状态
isInterrupted()
:判断是否被打断
sleep(long n)
:让当前执行的线程休眠n秒,休眠时让出CPU
的时间片给其他线程,让线程休眠,其他线程可以使用interupt
来打断当前正在sleep()
的线程,这时候sleep
就会抛出中断异常
,睡眠结束后的线程因为被是退回到就绪态,因此不会有立即上CPU运行的效果
yield()
:提示线程调度器让出当前对CPU的使用,这个方法是让当前线程从Running
进入到Runnable
的状态
就绪状态
还是有机会到运行状态的
,但是阻塞状态
是无法直接到运行状态的
27. 什么是线程的优先级
线程优先级会提示分派器优先调度该线程,但是它仅仅只是个提示,具体起不起作用以分派器的决定为主,调度器完全可以忽略这个hint
在CPU繁忙的时候,一般来说会将更多的时间片分配给高优先级的线程,在CPU空闲的时候,线程的优先级不会起什么作用。
sleep()
可以防止CPU
的占用100%
在没有利用CPU来进行计算的时候,不要让while(true)
控制来浪费CPU,这时候可以使用yield
或者sleep
来让出CPU的使用权
28. 说说interrupt()
阻塞状态
的线程操作系统不会考虑这些线程,这个方法可以打断那些处于阻塞状态的进程,打断sleep()
的线程,会清空它的打断状态,以sleep
为例
Thread t1 = new Thread(()->{
Thread.sleep(5000);
});
t1.start();
//主线程打断这个线程
t1.interrupt();
//此时t1的状态是什么?反正不会是被打断的状态,因为sleep会将这个打断标记清除
29. 两阶段终止模式
当线程被设置为中断状态的时候,那么这时候就是说在这个中断状态的时候,给一个料理后事的机会
这是因为stop()
可能会导致线程没有释放锁而直接被杀死,可能引发死锁
Thread t1 = new Thread(()->{
//后台监控线程
while(true){
if(isInterrupted()){
sout("释放资源....");
break;
}
try{
sleep(25);
}catch(Exception e){
sout("打断了");
current.interrupt();
}
监控日志....
}
});
public void start(){
t1.start();//准备被调度上CPU
}
public void stop(){
t1.stop();
}
interrupted()
会将打断标志给重置
简单来说两阶段终止就是:首先第一个阶段就是被终止,这个阶段线程会被打上终止的标志,但是不会立即终止,第二阶段就是收尾,只有当收尾工作完成了,才算被彻底终止
30. 什么叫临界区?什么叫竞态条件
问题出现在多个线程访问共享资源,多个线程共享资源的时候,如果发生了指令交错,那么就会产生线程安全区问题
这个问题从本质上就是说出现了临界资源的访问问题,如果不对临界资源进行同步和互斥的控制,那么就会出现并发的问题,临界区就是说有一段代码存在了对共享资源的多线程读写操作
,临界资源就是说有多个线程同时访问的资源
多个线程在临界区内执行,由于代码的执行序列不同而导致结果无法预测,称之为发生了竞态条件
怎么解决竞态条件?
为了避免临界区的竞态条件的发生,有多种手段可以达到目的
- 阻塞式的解决方案:
synchronized(对象锁,互斥,同一时刻只能有一个线程执行被同步关键字修饰的代码区域)
、Lock
- 非阻塞式的解决方案:
原子变量
什么叫同步?什么叫互斥?有什么区别?
同步
就是说一个线程需要等待另外一个线程执行到指定位置后再运行,这是因为线程在并发的环境下无法控制其指令的执行顺序,因此为了达到一些条件,必须使得线程之间的执行相互制约
互斥
是说的是对临界资源的访问,这是因为临界区中的竞态条件发生,同一时刻只能有一个线程执行临界区的代码
synchronized(任意的对象){//线程1进来,获取对象上的锁,线程2再来,就会将线程2加入到阻塞队列中
临界区代码
}
31. 如何理解synchronized?
syncronized
关键字中锁的对象可以想象成一个房间,有唯一入口(门)
,房间只能一次进入一人进行计算,线程t1和t2想象成两个人,当线程t1,t2,t3同时到达,那么这些线程就同时竞争这个门的钥匙,这时候t1拿到了这把钥匙,于是这个门锁对应的房间的owner
字段就被标记为了t1
的ID
然后t2
和t3
就会被阻塞等待,加入到一个队列中,这期间假设t1
的时间片用完了,那么t1
也不会将门锁让出来,而是休眠,等到t1
被唤醒,再次进入到这个门中,因为owner
是它自己,因此它可以获得同步代码段的执行权限,然后就进去执行
等到t1
执行完毕之后,它就会把t2和t3都给唤醒,然后他们俩再去竞争锁
synchronized解决了什么问题?
synchronized
解决的问题是:它将同步代码块中的代码的执行设置成了原子性的,也就是说这个代码段中的代码不会被其他线程所打断,不会产生不同线程同时执行这些代码,然后产生指令交错的问题
//这种方式是保证了整个for循环都是原子性的
synchronized(obj){
for(int i = 0; i < 5000 ;i++){
count++;
}
}
//这种方式是保证了里面的那条count++指令是原子性的
for(int i = 0; i < 5000; i++){
synchronized(obj){
count++;
}
}
- 原子性:指的是被
synchronized
修饰的代码段是原子性的,不会被线程的上下文切换而导致指令交错 - 锁对象:执行代码必须上锁,进行代码控制的必须是同一把锁
public synchronized void save(){
}
public void save(){
synchronized(this){
}
}
这两种写法是等价的
public synchronized static void save(){}
public static void save(){
synchronized(this.class){
}
}
32. 如何判变量是否是线程安全的
判断变量是否是线程安全的,最本质的就是看这个变量是否有可能被多个线程所共享
一般来说,局部变量不会导致线程安全的问题,这是因为局部变量的作用域是栈帧,一旦栈帧被弹出,这个局部变量就会被弹出,这就不会导致线程安全问题,因为每个栈帧都是线程所独有的
同时,如果局部变量是对象类型,还要考虑一下对象是否会逃逸出当前的作用范围,如果逃逸出去了,那么这个对象就有可能被别的线程所竞争,最终造成线程安全的问题
常见的线程安全类
- String
- Integer
- StringBuffer
- Random
- Vector
- HashTable
- JUC
线程安全
是说多个线程调用同一个实例的某个方法的时候,是线程安全的,每个方法都是线程安全的,但是组合起来使用并不是线程安全的
比如说有
public static void main(String[] args){
Vector<Integer> v = new Vector<Integer>;
new Thread(()->{
for(int i = 0;i < 10;i++){
v.add(i);
}
}).start();
new Thread(()->{
int size = v.size();
for(int i = 0;i < size;i++){
v.add(i);
}
}).start();
}
//最终这个执行结果都会导致不同的Vector的值
或者有经典的
if(table.get("key") == null){
table.put("key",value);
}
String类中的值不能被修改,它是线程安全的吗?
String
中的char[]
的值是不会被改变的,因此无论外部线程如何操作,他们都是线程安全的。
33. Java对象头了解过吗?
Java
中的对象结构主要分为对象头和对象中存储的数据,对象头中一般存储了有
MarkWord
:标记位(25位为hashcode)Klass Word
:类对象的标识符,用来表示这个对象属于什么类型,通过这个标识符就能找到对应的类对象
34. Monitor(锁)
Minitor
被翻译为监视器
或者是管程,每个Java
对象都可以关联一个Minitor
对象,如果使用synchronized
给对象上锁(重量级锁)之后,该对象头中的MarkWord
就被设置指向Minitor
对象的指针
初始的时候,Minitor
中的Owner
为NULL,也就是这把锁并没有没有任何线程所占有
于是此时有一个线程Thread1
到来,然后当这个Thread1
到来的时候,会先检查这个对象头部的MarkWord
,如果这个MarkWord
中的后面两位不是10
的时候,那么就会为这个对象找一个监视器
这个监视器是操作系统底层分配的,通过监视器可以得知当前同步代码块中线程的竞争情况
首先,如果Minitor
的Owner
为空的话,那么这个线程就会上去,将自己的线程ID填入到Owner
字段中,然后执行任务,接着线程2和线程3到来,检查对象obj
所对应的监视器,如果这个监视器的owner
不为空而且owner
的ID不是自己,那么就会加入到entryList
中,这个列表又被称为是阻塞队列的一种结构,当线程1时间片下来的时候,它修改owner
字段,也不会唤醒entryList
对应的线程
只有当线程1执行代码完毕后,才会释放这个锁,然后唤醒entryList
中的线程,但是这个唤醒不一定是公平的,这个取决于JDK底层的实现
这个就解释了,为什么锁的对象不一样,就会导致锁不到相关的代码,本质上因为对象中关联的Minitor
不一样
从底层字节码的运行角度上来看,当线程进入同步代码块的时候,先会执行一条monitorenter
的指令,这个指令要完成的工作是,将当前的lock对象头的hashCode()
,分代年龄等信息临时存储到Monitor
中,然后将Markword
的前30位设置为Monitor
的地址,接着会将这个lock
对象引用保存一份,便于在后续解锁使用,接着就是执行计算,在计算完成之后,就会执行monitorexit
指令,这个指令会将之前保存到Monitor
中的hashcode
等信息还原到obj
中,然后将entryList
中的指定线程唤醒,让他们重新竞争
执行同步方法,出错了会怎么样?
在这个synchronized
执行的时候,实际上从字节码可以观察到JVM层面是会监听同步代码块中是否有发生异常的,如果抓到了对应的异常,那么就执行锁的释放,也就是唤醒entryList
中的线程,然后恢复obj
35. 说说synchronized的优化原理
轻量级锁阶段:在这个阶段,如果一个对象虽然有多线程访问,但是多线程的访问时间是错开的,也就是没有发生竞争的现象,那么就可以使用轻量级锁来优化,避免直接使用重量级锁
static final Object obj = new Object();
//两次加锁的动作
public static void method1(){
synchronized(obj){
method2();
}
}
public static void method2(){
synchronized(obj){
//同步块
}
}
如何加锁的?
首先先创建锁记录(LockRecord)对象
,每个线程的栈帧都会包含一个锁记录的结构,内部可以存储对象的MarkWord
,以及锁记录对象的引用地址,便于找到这个对象,在使用完毕后解锁
然后在线程的执行过程中,一旦遇到同步代码块,就执行加锁,轻量级锁的加锁流程是这样的,将对象头的MarkWord
先交换记录到LockRecord
中,这个过程是基于CAS
来实现的,对象头中存储了锁记录地址和状态00,表示由该线程给对象加锁
第一种情况,如果CAS
成功,那么就证明之前没有人和我竞争,那么就可以继续使用轻量级锁
第二种情况,如果CAS
失败,那么就证明有人和我竞争,进入了锁膨胀的过程
如果是自己执行了synchronized
锁重入,那么就再添加一条Lock Record
作为重入的计数
这条LockRecord
会保留锁对象的引用,但是会在记录Lock
的MarkWord
的字段设置为null
当在解锁的时候如果发现有一条记录的MarkWord
的字段为null
,表示有重入,那么就直接去掉这条记录,解锁即可,如果发现记录的MarkWord
的字段不为null
,那么就证明是最先开始加的锁,然后将LockRecord
中的MarkWord
字段恢复到原对象的头部中
当退出synchronized
代码块的时候,如果不为NULL,而且CAS
失败,那么说明轻量级锁已经膨胀了,那么就进入重量级锁的解锁流程
锁膨胀的阶段
就是当CAS失败的时候,这时候就会触发锁膨胀的过程,这个过程将会将MarkWord
替换为锁监视器的地址,然后将当前线程加入到Monitor
中的entryList
中,等到线程退出同步块解锁的时候,就使用CAS
将MarkWord
的值恢复到对象头,此时肯定是会失败的,然后按照重量级锁的流程,将EntyrList
中的阻塞线程给唤醒
自旋优化
重量级锁竞争的时候,还可以使用自旋锁来进行优化,如果当前线程自旋成功,这时候持有锁的线程已经退出了同步块,这时候的线程就可以避免阻塞
自旋指的是先不要让当前线程进入阻塞状态,而是先尝试,这样的话就可以避免上下文切换,但是如果长期自旋失败,那么就会直接失败,进入阻塞队列中
偏向锁
轻量级锁在没有竞争的时候,每次重入依然需执行CAS
操作,比如说线程1进入了同步代码块1,加锁的对象是obj,然后需要进入同步代码块2,这时候还需要对对象MarkWord
中的锁记录地址进行比较,此时肯定是失败的了,但是它可以通过MarkWord
中的00
后两位来判断是轻量级锁,因此就会加入一条新的RecordLock
那么我们就想要将这个CAS
的过程优化掉,于是引入了偏向锁来改进这个问题,只有第一次使用CAS
将线程ID设置到对象的MarkWord
的时候,就会将线程的ID设置到MarkWord
中,后续再发生了锁重入,那么就直接判断MarkWord
的是否等于线程的ID,如果等于的话就直接重入即可,前30位为线程的ID
那么当MarkWord
中的ID不是自己的ID,那么就说明发生了竞争,最终就发生一个锁的升级
当调用了hashcode()的时候就会将偏向锁撤销,这是因为偏向锁的信息都是存储在对象头的,一旦调用hashcode()就会导致偏向锁的信息没有地方存储
当偏向锁的状态发生竞争的时候,就会将偏向锁升级为轻量级锁
批量重偏向
当对象虽然被多个线程访问,但是没有发生竞争,这时候偏向了线程T1的对象依然有机会重新偏向T2,重偏向会重置对象的ThreadId
,当撤销偏向锁的阈值超过20次之后,JVM会认为取偏向的过程产生了错误,于是会在个这些对象加锁的时候不要撤销偏向了,而是重新偏向到加锁线程
批量撤销
当撤销偏向锁的阈值超过40次之后,jvm会如认为确实是偏向错了,根本不应该偏向,于是整个类的对象都会变为是不可偏向的,新建的喜爱那个也是不可偏向的
36. wait-notify
wait-notify执行原理
执行原理是owner线程
发现执行条件不满足,于是调用wait
方法,就进入了waitSet
变为了WAITING
状态,BLOCKED
和WAITING
的线程都处于阻塞状态,不会占用CPU的时间片
BLOCKED
线程都会在owner
线程释放锁的时候被唤醒
WAITING
线程会在Owner
线程调用notify
或者notifyAll()
的时候被唤醒,但是唤醒并不意味着立即获得锁,而是和之前的EntryList
中的线程一起竞争锁。
什么是虚假唤醒?
虚假唤醒指的是唤醒的是错误的线程,唤醒不应该唤醒的线程,这是因为notify()
是挑选一个线程后唤醒,没有特定的规律而言
37. 什么是ReentrantLock?
相对于synchronized
来说,它具有如下的特点
- 可以中断:使用
lockInterruptibly()
的这个api,如果线程在竞争这个锁的时候,会进入阻塞队列,但是有其他线程可以给当前线程打上中断标记,lock()
和synchronized
是不支持打断的,基本的编写逻辑为:当catch
到中断异常的时候,就直接退出, - 可以设置超时时间:
- 可以设置为公平锁
- 可以支持多个条件变量
- 支持可重入:如果同一个线程首次获得了这把锁,那么因为它是这把锁的持有者,因此就有权利再次获取这把锁,如果是不可重入锁,那么在第二次获得锁的时候,自己也会被挡住导致死锁
syncronized
加锁的代码段不可以进行中断,同时当竞争重量级锁失败的时候,会加入到Monitor
中的entryList
中,这时候就会阻塞直到唤醒
但是ReentrantLock
则是在等待一定的时间失败后就会直接返回,不再阻塞了,回去执行其他的逻辑,执行公平锁的目的是为了防止饥饿,所谓的饥饿就是说有一些线程被长期忽视,得不到调度运行的机会
ReentrantLock lock = new ReentrantLock();
lock.lock();
//临界代码段
lock.unlock();
锁超时机制是什么?
tryLock()
:如果其他线程没有在指定的时间内释放锁,那么就会直接返回,从而可以避免死锁
锁的公平性
ReentrantLock
:它本身是不公平,不是按照底层的阻塞队列来实现的,当在构造的时候传入一个true
,就代表是公平锁,其底层是将AQS实现的
条件变量
synchronized
中也有条件变量,就是那个waitSet
休息室时,当条件不满足的时候,获得锁的线程调用wait()
方法就会进入waitSet
中等待,等待持有锁的线程唤醒waitSet
缺点是由于notify()是随机唤醒任意一个线程,notifyAll()是唤醒全部,可能导致唤醒了不该唤醒的线程,因此不灵活
ReentrantLock
支持多个休息室
,也就是说支持多个条件变量,这就好比:
synchronized
是哪些不满足条件的线程都在一间休息室中等消息而
ReentrantLock
则是支持多间休息室,在唤醒的时候可以唤醒指定的线程await
在执行前需要获取锁await在执行后会释放锁,进入conditionObject等待
await的线程被唤醒,取重新竞争lock锁
竞争lock成功后,从await后继续执行
38. 什么是CAS
CAS
比较并设置对应的值的过程,CompareAndSwap
它简单来说就是一条指令,它具有两个操作数
boolean compreaAndSwap(int o1,int o2);
//只有在内存中的值为op1的时候才将o2写进去
比如说
int prev = balance.get();
//此时可能发生并发,有人修改过了balance.get()的值
int next = prev - amount;
if(balance.compareAnde(prev,next)){
//此时它会将prev和实际上的balance.get()中的值进行比较
//如果相同,那么它就认为没有人修改过
//如果不相同,那么就认为有人修改过,修改失败
break;
}
CAS
的底层实现是基于lock cmpxchg指令
实现的,在单核CPU和多核CPU都能够保证指令的原子性
在多核状态下,某个核执行到带lock
的指令的时候,CPU会让总线锁住,当这个核把指令执行完毕后再开启总线,简单来说就是一个开中断和关中断的机制
获取共享变量
的时候,为了保障该变量的可见性,需要使用volatile
进行修饰,它可以用来修饰成员变量和景甜成员变量,当它插入了读屏障的时候,它会确保当前以及之前的语句都会从主存中去读取最新的值,从而避免了工作缓存中的值和主存中的值不一致的现象
CAS
必须借助于volatile
来实现,因此CAS
必须借助于volatile
来实现
CAS为什么快?
无锁情况下,即使重试失败,线程也都是在高速运行的,没有停止,而synchronized
会让线程在没有获得锁的情况下发生上下文切换,而上下文切换是会导致一定的开销的,但是无锁的代价就是CPU忙等,CPU的利用率低
- CAS是基于乐观锁的,最乐观的估计,它认为竞争并不激烈,就算修改了也没有关系
synchronized
是基于悲观锁的,它认为时时刻刻都有线程来修改共享变量,它体现的是无锁并发,无阻塞并发,因为没有使用synchronized
,所以线程不会陷入阻塞,从而提高了效率- 竞争激烈的情况下,线程本身就对CPU的需求高,而CAS导致CPU忙等,最终反而反而导致效率下降
39. 如何使得线程交替打印?
static final Object lock = new Object();
static int print = 1;
static int loopNumber = 5 ;
public static void print(char c,int waitFlag,int nextFlag){
for (int i = 0; i < loopNumber; i++) {
synchronized (lock){
while(print != waitFlag){
try {
lock.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
//当满足调节后会到这里
System.out.println(c);
print = nextFlag;
lock.notifyAll();
}
}
}
public static void main(String[] args) {
new Thread(()->{
print('a',1,2);
}).start();
new Thread(()->{
print('b',2,3);
}).start();
new Thread(()->{
print('c',3,1);
}).start();
}
package leetcode_acm.concurrent;
import com.beust.ah.A;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.ReentrantLock;
/**
* 功能描述
*
* @author: 张庭杰
* @date: 2023年03月25日 10:21
*/
public class PrintValue {
public static void main(String[] args) {
AwaitSignal awaitSignal = new AwaitSignal(5);
Condition a = awaitSignal.newCondition();
Condition b = awaitSignal.newCondition();
Condition c = awaitSignal.newCondition();
new Thread(()->{
awaitSignal.print('a',a,b);
}).start();
new Thread(()->{
awaitSignal.print('b',b,c);
}).start();
new Thread(()->{
awaitSignal.print('c',c,a);
}).start();
try{
awaitSignal.lock();
a.signal();
}finally {
awaitSignal.unlock();
}
}
private static class AwaitSignal extends ReentrantLock{
private int loopNumber = 0;
public AwaitSignal(int loopNumber){
this.loopNumber = loopNumber;
}
public void print(char c,Condition current,Condition next){
for (int i = 0; i < loopNumber; i++) {
lock();
try{
current.await();
System.out.println(c);
next.signal();
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
unlock();
}
}
}
}
}
40. AQS原理
AbstractQueuedSynchronized
是阻塞式锁和相关的同步器工具的框架
特点:
- 用
state
属性来表示资源的状态(分为独占模式和共享模式)
,子类需要定义如何维护我这个状态,控制如何获取锁和释放锁 getState
:获取state状态setState
:设置state状态compareAndSetState
:乐观锁机制设置state
状态- 独占模式是只有一个线程能够访问资源,而共享模式可以允许多个线程访问资源
- 提供了基于
FIFO
的等待队列,类似于Monitor
的EntryList
- 条件变量来实现等待、唤醒机制,支持多个条件变量,类似于
Monitor
的WaitSet
简单来说,它是一个框架,用来为构建锁和同步锁提供了一些通用功能的实现
AQS的核心思想是什么?
如果请求的资源
是空闲的,那么就将当前请求资源的线程设置为有效的工作线程,将共享资源设置为锁定的状态,如果共享资源被占用,就需要一定的阻塞等待唤醒机制来保障锁的分配,这个机制主要是依托于CLH队列的变体实现的,将暂时获取不到锁的线程加入到了队列中
CLH
:是单向链表,而AQS魔改了CLH
,将其变成了虚拟双向队列,AQS
通过将每条请求共享资源的线程封装成一个节点来实现锁的分配
AQS
使用一个Volatile
的int
类型的成员变量来表示同步状态,通过内置的FIFO
队列来完成资源获取的排队工作,通过CAS
完成对STATE
值的修改
说说AQS对类中的数据结构
private class Node{
int waitStatus;//当前节点在对类中的状态
Thread thread;//线程的引用,表示处于该节点的线程
Node prev;//前驱指针
Node predecessor(){}//返回前驱节点
nextWaiter();//指向下一个处于`Condition`状态的指针
Node next;//next指针
}
什么是State变量?
AQS
中维护一个名为state
的字段,含义是同步状态,可以通过修改State
字段表示的同步状态来实现多线程的独占模式和共享模式,AQS定义两种资源的共享方式:
Exclusive
:独占模式,只有一个线程能够执行Share
:多个线程可以同时执行
如何实现一个自定义同步器?
tryAcquire(int);
tryRelease(int);
tryAcquireShared(int);
tryReleaseShared(int);
isHeldExclusively();
模板设计通过设计这些钩子方法,来让子类完成具体的模板操作,自己只是完成定义而已
41. 说说ReentrantLock的原理?
ReentrantLock是如何加锁的?
当线程刚被new出来试图加锁的时候,就会使用aqs
中的compareAndSet(0,1)
来修改条件变量,如果能够修改成功,那么就将这个锁的持有者的ID修改为当前线程
如果线程加锁失败,那么就会重试一次,如果重试再不成功,那么就会进入一个addWaiter
的逻辑
这个逻辑主要就是构建一个等待队列,如果初始时,AQS
中的head == null
,那么就会创建一条链表,这条链表上存储了当前因为加锁失败的线程所对应的节点,在这条链表上的线程节点会在一个死循环中不断尝试获取锁,失败后进入park()
阻塞,如果自己是第二个节点,也就是实际链表的第一个节点,那么就会再次重试获取锁,如果获取锁失败了,那么就会将前一个节点的waitStatus = -1
,这表示着,你的前一个节点有义务去唤醒你的后继节点
ReentrantLock是如何解锁的?
解锁的操作主要是将exclusiveOwnerThread
设置为null
,然后将state
设置为0
,或者是state--
通过调用unlock()
进行解锁,然后会调用内部类的Sync
的Rekease
方法,这个方法继承自AQS
Release
中会调用tryRelease()
,需要自定义同步器实现,释放成功后,所有处理都是由AQS
完成的
那么在这个操作中,实际上在底层的队列中,取得需要释放锁的节点,然后这个节点会显式地通知下一个节点解除阻塞,使得下一个线程检查到锁已经被释放了,最终就会导致锁的获取成了
ReentrantLock是如何实现可重入的?
ReenttrantLock
实现可重入的原理是通过修改底层的state
变量来实现的
具体的流程是:当线程来获取锁的时候,如果还没有被加锁,那么就会使用CAS
将STATE
从0修改成1,然后接着当其他线程到来的时候,就会将当前线程包装成一个Node
,这个Node
中包含了一个线程的引用,以及当前线程的状态
然后当这个线程被获取锁失败的时候,就会将当前线程包装成Node,然后添加到尾部,然后将前一个线程的状态
设置成-1,代表着,当前一个线程释放锁的时候,它要来显式地通知这个线程
那么在实现锁重入的时候,就会将AQS
中的内置state
变量执行++,然后在解锁的时候执行--
,这是因为lock()
和unlock()
通常是成对出现的
这里的话讲述一下AQS队列是如何处理这种情况的,首先它会知道队列中距离这个head
最近的第一个没有处于Cancel
的节点,然后检查这个是不是空的,空的话意味着没有线程可以唤醒,结束
如果非空,并且这个节点的状态不是0,也就是说是-1
,就是说它有义务去唤醒下一个线程的执行,那么它就会唤醒下一个节点,然后在这时候,如果加锁成功了,那么就会将原来的头节点删除,将当前的节点的值设置为null,顶上去作为新的头节点,通知成功
如果竞争失败了,也就是说是有竞争的表现,非公平的,那么就会保持不变,不会做任何的删除