Java 内存模型
一、什么是JMM?JMM的基础原理?
JMM 即 Java Memory Model,它定义了主存、工作内存的抽象概念,底层对应着CPU 寄存器、缓存、硬件内存、CPU 指令优化等。
- 主存:所有线程共享的数据
- 工作内存:线程私有的
JMM 体现在以下几个方面:
- 原子性 - 保证指令不会受到线程上下文切换的影响
- 可见性 - 保证指令不会受 cpu 缓存的影响
- 有序性 - 保证指令不会受 cpu 指令并行优化的影响
JMM的基础原理
- 指令重排序:通过插入特定类型的Memory Barrier(内存屏障)来禁止特定类型的编译器重排序和处理器重排序,为上层提供一致的内存可见性保证。
- 内存屏障(Memory Barrier )
- happens-before原则:要求前一个操作的执行结果,对于后一个操作是可见的,且前一个操作按顺序排在后一个操作之前。
二、基础知识
1 进程和线程有什么区别?
- 进程是程序的一次执行过程,是系统运行程序的基本单位,因此进程是动态的。系统运行一个程序即是一个进程从创建,运行到消亡的过程。
- 线程与进程相似,但线程是一个比进程更小的执行单位。一个进程在其执行的过程中可以产生多个线程。与进程不同的是同类的多个线程共享进程的堆和方法区资源,但每个线程有自己的程序计数器、虚拟机栈和本地方法栈,所以系统在产生一个线程,或是在各个线程之间作切换工作时,负担要比进程小得多,也正因为如此,线程也被称为轻量级进程。
2 什么是线程安全
如果你的代码在多线程下执行和在单线程下执行永远都能获得一样的结果,那么你的代码就是线程安全的。
3 多线程解决什么?
访问共享变量时,保证临界区代码的【原子性】,共享变量在多线程间的【可见性】,问题与多条指令执行时的【有序性】问题。
4 Java中实现多线程有几种方法?
创建线程的常用三种方式:
- 继承Thread类
- 实现Runnable接口
- 实现Callable接口( JDK1.5>= )
- 线程池方式创建
实现Runnable接口与实现Callable接口的方式基本相同,只是Callable接口里定义的方法返回值,可以声明抛出异常而已。因此可以将实现Runnable接口和实现Callable接口归为一种方式。
5 Thread 类中的start() 和 run() 方法有什么区别?
1、start()内部调用了run()方法
2、调用run()方法的时候,只会是在原来的线程中调用,没有新的线程启动,start()方法才会启动新线程。
6 如何停止一个正在运行的线程?
1、使用退出标志,使线程正常退出,也就是当run方法完成后线程终止。退出标志要用volatile修饰
2、使用stop方法强行终止,但是不推荐这个方法,因为stop和suspend及resume一样都是过期作废的方法。
3、使用interrupt方法中断线程。interrupt只是给线程设置一个中断标志,线程仍会继续运行。参考两阶段终止模式,并能用犹豫(Balking)模式进行改进
@Slf4j
class TwoPhaseTerminations {
private Thread monitor;
// 打断标记 保证共享变量在线程之间的可见性
private volatile boolean stop;
// 判断是否执行过start了
private boolean start = false;
public void start() {
synchronized (this) { // 防止多个线程同时修改start
if (start) {
return;
}
start = true;
}
monitor = new Thread(() -> {
while(true) {
Thread currentThread = Thread.currentThread();
if (stop) {
log.debug("被打断...");
break;
}
try {
Thread.sleep(1000);
log.debug("保存监控记录");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
},"监控线程");
monitor.start();
}
public void stop() {
stop = true;
}
}
7 线程之间的状态转换
7.1 操作系统层面 - 五种
- 【初始状态】:仅是在语言层面创建了线程对象,还未与操作系统线程关联
- 【可运行状态(就绪状态)】:指该线程已经被创建(与操作系统线程关联),可以由 CPU 调度执行
- 【运行状态】:指获取了 CPU 时间片运行中的状态
- 当 CPU 时间片用完,会从 【运行状态】转换至【可运行状态】,会导致线程的上下文切换。
- 【阻塞状态】
- 如果调用了阻塞 API,如 BIO 读写文件,这时该线程实际不会用到 CPU,会导致线程上下文切换,进入【阻塞状态】
- 等 BIO 操作完毕,会由操作系统唤醒阻塞的线程,转换至【可运行状态】
- 与【可运行状态】的区别是,对【阻塞状态】的线程来说只要它们一直不唤醒,调度器就一直不会考虑调度它们
- 【终止状态】 表示线程已经执行完毕,生命周期已经结束,不会再转换为其它状态
7.2 Java API 层面 - 六种
这是从 Java API 层面来描述的,根据 Thread.State 枚举,分为六种状态
- NEW: 线程刚被创建,但是还没有调用 start() 方法
- RUNNABLE:当 调用了 start() 方法之后,注意,Java API 层面的 RUNNABLE 状态涵盖了 操作系统 层面的【可运行状态】、【运行状态】和【阻塞状态】(由于 BIO 导致的线程阻塞,在 Java 里无法区分,仍然认为是可运行)
- BLOCKED个, WAITING个 , TIMED_WAITING都是 Java API 层面对【阻塞状态】的细分,后面会在状态转换一节详述。
- TERMINATED:当线程代码运行结束
7.2.1 NEW ==> RUNNABLE(一种)
当调用t.start()方法时,由 NEW ==> RUNNABLE
7.2.2 RUNNABLE <==> WAITING(三种)
方式一
t 线程用synchronized(obj)获取了对象锁后:
- 调用obj.wait()方法时,t 线程从RUNNABLE ==> WAITING
- 调用obj.notify()、obj.notifyAll()、t.interrupt()时
- 竞争锁成功,t 线程从 WAITING ==> RUNNABLE
- 竞争锁失败,t 线程从WAITING ==> BLOCKED
public class TestWaitNotify {
final static Object obj = new Object();
public static void main(String[] args) {
new Thread(() -> {
synchronized (obj) {
log.debug("执行....");
try {
obj.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
log.debug("其它代码...."); // 断点
}
},"t1").start();
new Thread(() -> {
synchronized (obj) {
log.debug("执行....");
try {
obj.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
log.debug("其它代码...."); // 断点
}
},"t2").start();
sleep(0.5);
log.debug("唤醒 obj 上其它线程");
synchronized (obj) {
obj.notifyAll(); // 唤醒obj上所有等待线程 断点
}
}
}
方式二
- 当前线程调用 t.join() 方法时,当前线程从RUNNABLE ==> WAITING
- 注意是当前线程在t 线程对象的监视器上等待
- t 线程运行结束,或调用了当前线程的 interrupt() 时,当前线程从 WAITING ==> RUNNABLE
方式三
- 当前线程调用LockSupport.park()方法会让当前线程从RUNNABLE ==> WAITING
- 调用LockSupport.unpark(目标线程)或调用了线程的interrupt(),会让目标线程从WAITING ==> RUNNABLE
7.2.3 RUNNABLE <==> TIMED_WAITING(四种)
方式一
t 线程用synchronized(obj)获取了对象锁后:
- 调用obj.wait(long n)方法时,t 线程从RUNNABLE ==> TIMED_WAITING
- 调用 obj.notify() , obj.notifyAll() , t.interrupt() 时
- 竞争锁成功,t 线程从 TIMED_WAITING ==> RUNNABLE
- 竞争锁失败,t 线程从 TIMED_WAITING ==> BLOCKED
方式二
- 当前线程调用 t.join(long n) 方法时,当前线程从 RUNNABLE ==> TIMED_WAITING
- 注意是当前线程在t 线程对象的监视器上等待
- t 线程运行结束,或调用了当前线程的 interrupt() 时,当前线程从 TIMED_WAITING ==> RUNNABLE
方式三
- 当前线程调用
Thread.sleep(long n)
,当前线程从 RUNNABLE ==> TIMED_WAITING - 当前线程等待时间超过了 n 毫秒,当前线程从 TIMED_WAITING ==> RUNNABLE
方式四
- 当前线程调用
LockSupport.parkNanos(long nanos)
或LockSupport.parkUntil(long millis)
时,当前线程从 RUNNABLE ==> TIMED_WAITING - 调用
LockSupport.unpark(目标线程)
或调用了线程 的interrupt()
,或是等待超时,会让目标线程从TIMED_WAITING ==> RUNNABLE
7.2.4 RUNNABLE <==> BLOCKED(一种)
- t 线程用 synchronized(obj) 获取了对象锁时如果竞争失败,从 RUNNABLE ==> BLOCKED
- 持 obj 锁线程的同步代码块执行完毕,会唤醒该对象上所有 BLOCKED 的线程重新竞争,如果其中 t 线程竞争成功,从 BLOCKED ==> RUNNABLE ,其它失败的线程仍然 BLOCKED
7.2.5 RUNNABLE <==> TERMINATED(一种)
当前线程所有代码运行完毕,进入TERMINATED
8 sleep() 方法和 wait() 方法区别和共同点?
- 对于sleep()方法,我们首先要知道该方法是属于Thread类中的。而wait()方法,则是属于Object类中的。
- 在调用sleep()方法的过程中,线程不会释放对象锁。
- 当调用wait()方法的时候,线程会放弃对象锁,进入等待此对象的等待锁定池,只有针对此对象调用notify()方法后本线程才进入对象锁定池准备,获取对象锁进入运行状态。
9 为什么我们调用 start() 方法时会执行 run() 方法,为什么我们不能直接调用 run() 方法?
- 调用 start() 方法,会启动一个线程并使线程进入了就绪状态,当分配到时间片后就可以开始执行run()方法中的内容了
- 直接执行 run() 方法,会把 run 方法当成一个 main 线程下的普通方法去执行,并不会在某个线程中执行它,所以这并不是多线程工作。
10 Thread类中的yield()方法有什么作用?
- yield()方法可以暂停当前正在执行的线程对象,让其它有相同优先级的线程执行。
- 它是一个静态方法而且只保证当前线程放弃CPU占用而不能保证使其它线程一定能占用CPU。
- 执行yield()的线程有可能在进入到暂停状态后马上又被执行。
11 wait()、notify()、notifyAll()
11.1 notify()和notifyAll()有什么区别?使用时有什么注意事项?
- notify可能会导致死锁,而notifyAll则不会。
- 使用notifyall,可以唤醒 所有处于wait状态的线程,使其重新进入锁的争夺队列中,而notify只能唤醒一个。
注意事项
- wait() 应配合while循环使用,防止虚假唤醒。
- notify() 是对notifyAll()的一个优化,但它有很精确的应用场景,并且要求正确使用。不然可能导致死锁。
正确的场景应该是 WaitSet中等待的是相同的条件,唤醒任一个都能正确处理接下来的事项
,如果唤醒的线程无法正确处理,务必确保继续notify()下一个线程,并且自身需要重新回到WaitSet中。
11.2 notify()为什么会导致死锁?
简单的说,notify()只唤醒一个正在等待的线程,当该线程执行完以后施放该对象的锁,而没有再次执行notify()方法,则其它正在等待的线程则一直处于等待状态,不会被唤醒而进入该对象的锁的竞争池,就会发生死锁。
11.3 为什么wait、notify和 notifyAll这些方法不在thread类里面?
- 明显的原因是JAVA提供的锁是对象级的而不是线程级的,每个对象都有锁,通过线程获得。如果线程需要等待某些锁那么调用对象中的wait()方法就有意义了。
- 如果wait()方法定义在Thread类中,线程正在等待的是哪个锁就不明显了。简单的说,由于wait,notify和notifyAll都是锁级别的操作,所以把他们定义在Object类中因为锁属于对象。
11.4 为什么wait和notify方法要在同步块中调用?
- wait()方法强制当前线程释放对象锁。这意味着在调用某对象的wait()方法之前,当前线程必须已经获得该对象的锁。因此,线程必须在某个对象的同步方法或同步代码块中才能调用该对象的wait()方法。
- 调用wait()方法的原因通常是,调用线程希望某个特殊的状态(或变量)被设置之后再继续执行。调用notify()或notifyAll()方法的原因通常是,调用线程希望告诉其他等待中的线程:“特殊状态已经被设置”。这个状态作为线程间通信的通道,它必须是一个可变的共享状态(或变量)。
12 说说 join() 方法
- 当前线程 调用 t.join() 方法时,当前线程从RUNNABLE ==> WAITING
- 注意是当前线程在t 线程对象的监视器上等待
- t 线程运行结束,或调用了当前线程的 interrupt() 时,当前线程从 WAITING ==> RUNNABLE
13 interrupt() 、interrupted()、isInterrupted()
13.1 说说interrupt()方法
- 对于线程的执行,大家都很清楚最基本的三个状态:就绪、运行、结束,分别对应的方法是start()、run()、stop()。
- interrupt()的功能就是让线程再次获得执行权限,打断wait()、join()、sleep()等方法
13.2 interrupted 和 isInterrupted方法的区别?
- interrupted() 和 isInterrupted()的主要区别是前者会将中断状态清除而后者不会。
- 调用Thread.interrupt()来中断一个线程就会设置中断标识为true
- 当中断线程调用静态方法Thread.interrupted()来检查中断状态时,中断状态会被清零。
- 而非静态方法isInterrupted()用来查询其它线程的中断状态且不会改变中断状态标识。
- 简单的说就是任何抛出InterruptedException异常的方法都会将中断状态清零
13.3 两阶段终止模式
参考:https://blog.csdn.net/qq_36389060/article/details/121743099
14 有三个线程T1,T2,T3,如何保证顺序执行?
参考:https://blog.csdn.net/qq_36389060/article/details/121743099
三、synchronized
1 说一说自己对于 synchronized 关键字的了解?
synchronized关键字解决的是多个线程之间访问资源的同步性,synchronized关键字可以保证被它修饰的方法或者代码块在任意时刻只能有一个线程执行。
在 Java 早期版本中,synchronized属于重量级锁。JDK1.6对锁的实现引入了大量的优化,如自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销。
Synchronized的作用有哪些?
- 原子性:
确保线程互斥的访问同步代码
; - 可见性:
保证共享变量的修改能够及时可见
,其实是通过Java内存模型中的 “对一个变量unlock操作之前,必须要同步到主内存中;如果对一个变量进行lock操作,则将会清空工作内存中此变量的值,在执行引擎使用此变量前,需要重新从主内存中load操作或assign操作初始化变量值
” 来保证的; - 有序性:
有效解决重排序问题
,即 “一个unlock操作先行发生(happen-before)于后面对同一个锁的lock操作
”。
说一下 synchronized 底层实现原理?
分两个方面 修饰代码块
和 修饰方法
:
synchronized 同步代码块的实现是通过 monitorenter
和 monitorexit
指令
- monitorenter 指令指向同步代码块的开始位置
- monitorexit 指令则指明同步代码块的结束位置。
当执行 monitorenter 指令时,线程试图获取锁也就是获取 monitor的持有权。
monitor对象存在于每个Java对象的对象头中,synchronized 锁便是通过这种方式获取锁的,也是为什么Java中任意对象可以作为锁的原因
其内部包含一个计数器,当计数器为0则可以成功获取,获取后将锁计数器设为1也就是加1。相应的在执行 monitorexit 指令后,将锁计数器设为0,表明锁被释放。如果获取对象锁失败,那当前线程就要阻塞等待,直到锁被另外一个线程释放为止
synchronized 修饰的方法并没有 monitorenter 指令和 monitorexit 指令,取得代之的确实是 ACC_SYNCHRONIZED
标识,该标识指明了该方法是一个同步方法,JVM 通过该 ACC_SYNCHRONIZED
访问标志来辨别一个方法是否声明为同步方法,从而执行相应的同步调
说说synchronized怎么使用?
- 修饰普通方法:作用于
当前对象实例
,进入同步代码前要获得当前对象实例的锁 - 修饰静态方法:作用于
当前类
,进入同步代码前要获得当前类对象的锁,synchronized 关键字加到static 静态方法和 synchronized(xxx.class)代码块上都是给 Class 类上锁
- 修饰代码块:
指定加锁对象
,对给定对象加锁,进入同步代码库前要获得给定对象的锁
特别注意:
如果一个线程A调用一个实例对象的非静态 synchronized 方法,而线程B需要调用这个实例对象所属类的静态 synchronized 方法,是允许的,不会发生互斥现象,因为访问静态 synchronized 方法占用的锁是当前类的锁
- 尽量不要使用 synchronized(String a) 因为JVM中,字符串常量池具有缓存功能!
synchronized 锁升级的原理是什么?
synchronized 锁升级原理(锁膨胀):偏向锁 ==》 轻量级锁 ==》 重量级锁
- 在锁对象的对象头里面有一个 ThreadID 字段,在第一次访问的时候 ThreadID 为空,
jvm 让其持有偏向锁,并将 ThreadID 设置为其线程 id
,再次进入的时候会先判断ThreadID 是否与其线程 id 一致。- 如果一致则可以直接使用此对象
- 如果不一致,则
升级偏向锁为轻量级锁
,通过自旋循环一定次数来获取锁
,执行一定次数之后,如果还没有正常获取到要使用的对象,此时就会把锁从轻量级升级为重量级锁,此过程就构成了 synchronized 锁的升级。
锁的升级的目的:锁升级是 为了减低了锁带来的性能消耗
。在 Java 6 之后优化 synchronized 的实现方式,使用了偏向锁升级为轻量级锁再升级到重量级锁的方式,从而减低了锁带来的性能消耗。
JVM对synchronized的优化有哪些?
锁膨胀:是锁升级的流程,膨胀方向是:无锁 ==》 偏向锁 ==》 轻量级锁 ==》 重量级锁,并且膨胀方向不可逆。
synchronized原理 之 重量级锁 Monitor
- Monitor(重量级锁)被翻译为监视器或管程
- 每个 Java 对象都可以关联一个 Monitor 对象,如果使用 synchronized 给对象上锁(重量级)之后,
该对象头的Mark Word 中就被设置指向 Monitor 对象的指针
- 刚开始 Monitor 中 Owner 为 null。
- 当 Thread-2 执行 synchronized(obj) 就会将 Monitor 的所有者 Owner 置为 Thread-2,Monitor中只能有一个 Owner。
- 在 Thread-2 上锁的过程中,如果 Thread-3,Thread-4,Thread-5 也来执行 synchronized(obj),就会进入EntryList BLOCKED。
- Thread-2 执行完同步代码块的内容,然后唤醒 EntryList 中等待的线程来竞争锁,竞争的时是非公平的。
图中 WaitSet 中的线程是之前获得过锁,但条件不满足进入 WAITING 状态的线程,后面讲wait-notify 时会分析。
synchronized优化 之 轻量级锁(synchronized是否是可重入锁)?
-
如果一个对象虽然有多线程要加锁,但加锁的时间是错开的(也就是没有竞争),那么可以使用轻量级锁来优化。
-
轻量级锁对使用者是透明的,即语法仍然是
synchronized
。 -
每个线程的栈帧都会包含一个锁记录(Lock Record)对象的结构,内部可以存储锁定对象的 Mark Word
-
当一个线程要获取锁时,会让锁记录中 Object reference 指向锁对象,并尝试用
cas
替换 Object 的 Mark Word,将 Mark Word 的值存入锁记录- 将所锁录中的记录和对象头markword的记录进行交换,00 和 01 进行互换,互换成功代表加锁成功
- cas保证交换操作是原子的,不会被打断。
-
如果 cas 替换成功,
对象头中存储了锁记录地址和状态 00
,表示由该线程给对象加锁,这时图示如下
-
如果 cas 失败,有两种情况
- 如果是其它线程已经持有了该 Object 的轻量级锁,这时表明有竞争,进入
锁膨胀
过程 - 如果是自己执行了 synchronized
锁重入( 同一个线程给同一个对象加了多次锁 )
,那么再添加一条 Lock Record 作为重入的计数
,如下图:
- 如果是其它线程已经持有了该 Object 的轻量级锁,这时表明有竞争,进入
-
当退出 synchronized 代码块(解锁时)如果有取值为 null 的锁记录,表示有重入,这时重置锁记录,表示重入计数减一
-
当退出 synchronized 代码块(解锁时)锁记录的值不为 null,这时使用 cas 将 Mark Word 的值恢复给对象头
- 成功,则解锁成功
- 失败,
说明轻量级锁进行了锁膨胀或已经升级为重量级锁,进入重量级锁解锁流程
synchronized优化 之 偏向锁
- 轻量级锁在没有竞争时(就自己这个线程),每次重入仍然需要执行 CAS 操作。
- Java 6 中引入了偏向锁来做进一步优化
- 偏向锁假定将来只有第一个申请锁的线程会使用锁(不会有任何线程再来申请锁),因此第一次交换时使用 CAS 将线程 ID 设置到对象的 Mark Word 头,如果交换成功,则偏向锁获取成功,记录锁状态为偏向锁
以后当前线程等于owner就可以零成本的直接获得锁;否则,说明有其他线程竞争,膨胀为轻量级锁。
自旋锁与自适应自旋锁
重量级锁竞争的时候,还可以使用自旋来进行优化,如果当前线程自旋成功(即这时候持锁线程已经退出了同步块,释放了锁),这时当前线程就可以避免阻塞。
自适应自旋锁:这种相当于是对上面自旋锁优化方式的进一步优化,它的自旋的次数不再定,其 自旋的次数由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定
,这就解决了自旋锁带来的缺点。
锁消除
public void b() throws Exception {
Object o = new Object();
synchronized (o) {
x++;
}
}
- o对象不会被共享,synchronized没有任何意义,那么JIT即时编译器会把synchronized优化掉,实际执行时是没有对o对象加锁的
锁粗化
在遇到一连串地对同一锁不断进行请求和释放的操作时,把所有的锁操作整合成锁的一次请求,从而减少对锁的请求同步次数,这个操作叫做锁的粗化。
哪些情况会撤销偏向状态?
情况一:调用对象hashCode()
- 调用 hashCode() 会禁用 偏向锁,这是因为 markword 一共64位,使用偏向锁,Thread ID占54位,那么就没地方存放31位hashcode
- 轻量级锁会在锁记录中记录 hashCode
- 重量级锁会在 Monitor 中记录 hashCode
情况二:其它线程加锁对象
- 如果存在其他线程加锁,那么偏向锁将膨胀为轻量级锁。
情况三:调用 wait/notify
- 这个很好理解,因为wait/notify只有重量级锁才有
什么是批量重偏向?
- 当撤销偏向锁阈值超过 20 次后,jvm 会这样觉得,我是不是偏向错了呢,于是会在给这些对象加锁时重新偏向至加锁线程
synchronized 锁能降级吗?
可以的。
具体的触发时机:在全局安全点(safepoint)中
,执行清理任务的时候会触发尝试降级锁。当锁降级时,主要进行了以下操作:
- 恢复锁对象的 markword 对象头;
- 重置 ObjectMonitor,然后将该 ObjectMonitor 放入全局空闲列表,等待后续使用。
四、volatile
谈谈volatile的使用及其原理?
volatile可以用来修饰成员变量和静态成员变量。它能保证可见性和有序性,但无法保证原子性。
volatile的两层语义:
- 保证变量对所有线程的可见性:当volatile变量被修改,新值对所有线程会立即更新。或者理解为多线程环境下使用volatile修饰的变量的值一定是最新的。
- jdk1.5以后volatile完全避免了指令重排优化,实现了有序性。
volatile原理之有序性(单例模式)重要
以单例模式说明,在并发环境下的单例实现方式,我们通常可以采用双重检查加锁(DCL)的方式来实现。其源码如下:
public class Singleton {
public static volatile Singleton singleton;
/**
* 构造函数私有,禁止外部实例化
*/
private Singleton() {};
public static Singleton getInstance() {
if (singleton == null) {
synchronized (singleton) {
if (singleton == null) {
singleton = new Singleton();
}
}
}
return singleton;
}
}
为什么要在变量singleton之间加上volatile关键字。要理解这个问题,先要了解对象的构造过程,实例化一个对象其实可以分为三个步骤:
- 分配内存空间。
- 初始化对象。
- 将内存空间的地址赋值给对应的引用。
但是由于操作系统可以对指令进行重排序,所以上面的过程也可能会变成如下过程:
- 分配内存空间。
- 将内存空间的地址赋值给对应的引用。
- 初始化对象
如果是这个流程,多线程环境下就可能将一个未初始化的对象引用暴露出来,从而导致不可预料的结果。因此,为了防止这个过程的重排序,我们需要将变量设置为volatile类型的变量。
volatile 的底层实现原理是内存屏障,Memory Barrier(Memory Fence)
- 对 volatile 变量的
写指令后
会加入写屏障。写屏障会确保指令重排序时,不会将写屏障之前的代码排在写屏障之后。
- 对 volatile 变量的
读指令前
会加入读屏障。读屏障会确保指令重排序时,不会将读屏障之后的代码排在读屏障之前。
volatile原理之可见性
可见性问题主要指一个线程修改了共享变量值,而另一个线程却看不到。引起可见性问题的主要原因是每个线程拥有自己的一个高速缓存区 ——线程工作内存。
修改volatile变量时会强制将修改后的值刷新的主内存中 。
什么是happens-before
happens-before 规定了对共享变量的写操作对其它线程的读操作可见,它是可见性与有序性的一套规则总结
,抛开以下 happens-before 规则,JMM 并不能保证一个线程对共享变量的写,对于其它线程对该共享变量的读可见
五、ThreadLocal
ThreadLocal是什么?
ThreadLocal,即 线程本地变量
。如果你创建了一个ThreadLocal变量,那么 访问这个变量的每个线程都会有这个变量的一个本地拷贝
,多个线程操作这个变量的时候,实际是操作自己本地内存里面的变量
, 从而起到线程隔离的作用,避免了线程安全问题。
//创建一个ThreadLocal变量
static ThreadLocal<String> localVariable = new ThreadLocal<>();
ThreadLocal的实现原理?
每个Thread对象都有一个ThreadLocalMap,当创建一个ThreadLocal的时候,就会将该ThreadLocal对象添加到该Map中,其中键就是ThreadLocal,值可以是任意类型。
- ThreadLocalMap内部维护着Entry数组,每个Entry代表一个完整的对象,key是ThreadLocal本身。
- 每个线程在往ThreadLocal里设置值的时候,都是往自己的ThreadLocalMap里存,读也是以某个ThreadLocal作为引用,在自己的map里找对应的key,从而实现了线程隔离。
ThreadLocalMap(ThreadLocal<?> firstKey, Object firstValue) { table = new Entry[INITIAL_CAPACITY]; int i = firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1); table[i] = new Entry(firstKey, firstValue); size = 1; setThreshold(INITIAL_CAPACITY); }
- 当设置值时,需要首先获得当前线程对象Thread;
- 然后取出当前线程对象的成员变量ThreadLocalMap;
- 如果ThreadLocalMap存在,那么进行KEY/VALUE设置,KEY就是ThreadLocal;
- 如果ThreadLocalMap没有,那么创建一个;
知道ThreadLocal 内存泄露问题吗?
- 由于ThreadLocalMap 的生命周期跟 Thread 一样长,如果没有手动删除(remove()方法)对应 key 就会导致内存泄漏。
- key使用了强引用, 是无法完全避免内存泄漏的。
- key使用了弱引用,
ThreadLocalMap的key没了,value还在,这就会「造成了内存泄漏问题」。
- 弱引用:只要垃圾回收机制一运行,不管JVM的内存空间是否充足,都会回收该对象占用的内存。
- 如何「解决内存泄漏问题」?使用完ThreadLocal后,
及时调用remove()方法释放内存间
。
JUC
一、LockSupport 之 park & unpark
park & unpark使用方法
暂停当前线程 | 恢复某个线程的运行 |
---|---|
LockSupport.park(); | LockSupport.unpark(暂停线程对象 ); |
情况一:先park再unpark
@Slf4j(topic = "c.TestParkUnpark")
public class TestParkUnpark {
public static void main(String[] args) {
Thread t1 = new Thread(() -> {
log.debug("start...");
sleep(1);//t1睡眠了一秒
log.debug("park...");
LockSupport.park();//t1线程一秒后暂停
log.debug("resume...");
}, "t1");
t1.start();
sleep(2);//主线程睡眠二秒
log.debug("unpark...");
LockSupport.unpark(t1);//二秒后由主线程恢复t1线程的运行
}
}
分析:先被暂停后又唤醒
情况二:先unpark再park(好奇不,为啥也可以)
@Slf4j(topic = "c.TestParkUnpark")
public class TestParkUnpark {
public static void main(String[] args) {
Thread t1 = new Thread(() -> {
log.debug("start...");
sleep(2);//t1睡眠了两秒
log.debug("park...");
LockSupport.park();//t1线程两秒后暂停
log.debug("resume...");
}, "t1");
t1.start();
sleep(1);//主线程睡眠一秒
log.debug("unpark...");
LockSupport.unpark(t1);//一秒后由主线程恢复t1线程的运行
}
}
"C:\Program Files\Java\jdk1.8.0_191\bin\java.exe" ...
11:18:06.790 c.TestParkUnpark [t1] - start...
11:18:07.789 c.TestParkUnpark [main] - unpark...
11:18:08.793 c.TestParkUnpark [t1] - park...
11:18:08.793 c.TestParkUnpark [t1] - resume...
Process finished with exit code 0
分析,在06秒的时候,t1线程启动,这个时候,它开始睡眠二秒。主线程只睡了一秒(此时t1线程还在睡眠的过程中),主线程醒来后执行了unpark方法,我们看到t1线程没有报错,也没有抛异常。当t1线程睡够二秒了,在08秒的时候执行park方法,执行park方法,理应暂停线程,但是并没有停下来,几乎同时在08秒的时候,继续向下执行。
与Object的wait/notify对比
- wait,notify和notifyAll必须配合Object Monitor一起使用,而unpark不必
- park & unpark是以线程为单位来【阻塞】和【唤醒】线程,而notify只能随机唤醒一个等待线程,notifyAll是唤醒所有等待线程,就不那么【精确】
- park & unpark可以先unpark,而wait & notify不能先notify
原理分析
_counter字段,就是用来记录所谓的 “许可”
的。当调用park时,先尝试直接能否直接拿到“许可”,即_counter>0时,如果成功,则把_counter设置为0。
情况一:先调用park,再调用unpark
先调用park
1.当前线程调用Unsafe.park()方法
2.检查_counter,本情况为0,这时,获得_mutex互斥锁
3.线程进入_cond条件变量阻塞
4.设置_counter=0
再调用unpark分析
1.调用Unsafe.unpark(Thread_0)方法,设置_counter为1
2.唤醒_cond条件变量中的Thread_0
3.Thread_0恢复运行
4.设置_counter为0
情况二:先unpark再park(好奇不,为啥也可以)
1.调用Unsafe.unpark(Thread_0)方法,设置_counter为1
2.当前线程调用Unsafe.park()方法
3.检查_counter,本情况为1,这时线程无需阻塞,继续运行
4.设置_counter为0
二、ReentrantLock - 可重入锁
ReentrantLock 了解吗?
- ReetrantLock是一个
可重入的独占锁
,主要有两个特性,一个是支持公平锁和非公平锁,一个是可重入。 - ReetrantLock实现依赖于
volatile 变量 + CAS设置值 + AQS(AbstractQueuedSynchronizer)
。 - ReetrantLock主要
依靠AQS维护一个阻塞队列,多个线程对加锁时,失败则会进入阻塞队列。等待唤醒,重新尝试加锁。
- 使用方法
ReentrantLock reentrantLock = new ReentrantLock(); // 获取锁 reentrantLock.lock(); try { // 临界区 } finally { // 释放锁 reentrantLock.unlock(); }
说说ReentrantLock 和 synchronized 的区别?
相对于 synchronized 它具备如下特点
可中断
可以设置超时时间
可以设置为公平锁
支持多个条件变量
与 synchronized 一样,都支持可重入(重复加锁)
实现交替打印(条件变量)
@Slf4j(topic = "test")
public class Test28 {
public static void main(String[] args) throws InterruptedException {
AwaitSignal awaitSignal = new AwaitSignal(5);
Condition a = awaitSignal.newCondition();
Condition b = awaitSignal.newCondition();
Condition c = awaitSignal.newCondition();
new Thread(() -> {
awaitSignal.print("a",a,b);
},"t1").start();
new Thread(() -> {
awaitSignal.print("b",b,c);
},"t2").start();
new Thread(() -> {
awaitSignal.print("c",c,a);
},"t3").start();
Thread.sleep(1000);
awaitSignal.lock();
try {
System.out.println("开始。。。。");
a.signal();
} finally {
awaitSignal.unlock();
}
}
}
class AwaitSignal extends ReentrantLock {
// 循环次数
private int loopNumber;
public AwaitSignal(int loopNumber) {
this.loopNumber = loopNumber;
}
// 打印内容,进入哪一家休息室
public void print(String str,Condition room,Condition nextRoom) {
for (int i = 0; i < loopNumber; i++) {
lock();
try {
room.await();
System.out.print(str);
nextRoom.signal();
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
unlock();
}
}
}
}
谈谈 ReentrantLock的内部实现?
public class ReentrantLock implements Lock, java.io.Serializable {
abstract static class Sync extends AbstractQueuedSynchronizer {}
static final class NonfairSync extends Sync {}
static final class FairSync extends Sync {}
public ReentrantLock() {
sync = new NonfairSync();
}
public ReentrantLock(boolean fair) {
sync = fair ? new FairSync() : new NonfairSync();
}
}
- ReentrantLock继承了Lock接口。
- ReentrantLock的真正实现在他的两个内部类NonfairSync 和 FairSync中,并且内部类都继承于内部类Sync,而Sync根本的实现则是大名鼎鼎的AbstractQueuedSynchronizer同步器(AQS)。
- 我们可以设置是否使用公平锁,默认非公平。
- lock方法实际上是调用了Sync的子类NonfairSync(非公平锁)的lock方法。
ReentrantLock 是如何实现可重入性的?
ReentrantLock内部自定义了同步器sync,在加锁的时候通过CAS算法
,将线程对象放到一个双向链表中,每次获取锁的时候,会检查当前占有锁的线程和当前请求锁的线程是否一致
,如果一致,同步状态加1,表示锁被当前线程获取了多次。
ReentrantLock获取锁的步骤?TODO
lock方法实际上是调用了Sync的子类NonfairSync(非公平锁)的lock方法。
public void lock() { sync.lock(); }
============= NonfairSync =============
final void lock() {
// CAS 尝试获取锁
if (compareAndSetState(0, 1))
// 设置当前进程为持有锁进程
setExclusiveOwnerThread(Thread.currentThread());
else
// 获取失败
acquire(1);
}
// AQS 中方法
public final void acquire(int arg) {
// AQS 中的抽象方法,由NonfairSync实现
if (!tryAcquire(arg) &&
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}
// 由 NonfairSync 实现 tryAcquire()方法吧
protected final boolean tryAcquire(int acquires) {
return nonfairTryAcquire(acquires);
}
// 最终调用的是父类Sync的nonfairTryAcquire方法。
final boolean nonfairTryAcquire(int acquires) {
// 获取当前线程
final Thread current = Thread.currentThread();
int c = getState();
// 获得State,再次尝试获取锁
if (c == 0) {
// 获取成功,设为独占
if (compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
}
// 当前进程已经持有锁
else if (current == getExclusiveOwnerThread()) {
// 重新计算State,说明ReentrantLock是可重入锁
int nextc = c + acquires;
if (nextc < 0) // overflow
throw new Error("Maximum lock count exceeded");
// 更新 statr
setState(nextc);
return true;
}
// 获取锁失败
return false;
}
- lock方法首先执行compareAndSetState,而该方法实际上就是AQS中的一个方法,这个方法最终会调用unsafe的一个CAS操作,线程安全的改变state为1,独占锁。
- compareAndSetState方法则是判断AbstractQueuedSynchronizer中的state值是否为0,如果为0,就修改为1,并返回true。
- 修改成功调用AQS父类AbstractOwnableSynchronizer的方法setExclusiveOwnerThread(Thread.currentThread())方法将当前独占锁线程设置为当前线程,线程抢锁成功。
- 如果此时其他线程也调用了lock方法,执行compareAndSetState方法失败,因为此时的state不为0,于是执行acquire方法。acquire方法最终调用的是父类Sync的nonfairTryAcquire方法。
- 再次尝试获取锁,获取到返回true;
- 如果失败继续会判断当前独占锁的线程和当前线程是否为同一个线程(重入锁的实现),如果是,将state设置为state+1,并且反回true;
- 如果不相等,当前独占锁的线程为线程A,当前线程为B,所以结构会返回false。
- !tryAcquire(arg)则为true,所以会继续执行同步器的addWaiter(Node.EXCLUSIVE)方法。
https://blog.csdn.net/sinat_32873711/article/details/106619980
三、CAS
什么是 CAS?有什么特点?
CAS 必须借助 volatile
才能 读取到共享变量的最新值
来实现 比较并交换
的效果。
- CAS 是基于乐观锁的思想
- CAS 体现的是
无锁并发、无阻塞并发
- 因为没有使用 synchronized,所以线程不会陷入阻塞,这是效率提升的因素之一
- 但如果竞争激烈,可以想到重试必然频繁发生,反而效率会受影响
为什么无锁效率高?
- 无锁情况下,即使重试失败,线程始终在高速运行,没有停歇,而 synchronized 会让线程在没有获得锁的时候,
发生上下文切换,进入阻塞
。 - 但无锁情况下,因为线程要保持运行,需要额外 CPU 的支持,CPU 在这里就好比高速跑道,没有额外的跑道,线程想高速运行也无从谈起,虽然不会进入阻塞,
但由于没有分到时间片,仍然会进入可运行状态,还是会导致上下文切换
。
CAS有什么缺陷(带来的问题)?
ABA 问题
并发环境下,假设初始条件是A,去修改数据时,发现是A就会执行修改。但是看到的虽然是A,中间可
能发生了A变B,B又变回A的情况。此时A已经非彼A,数据即使成功修改,也可能有问题。
可以通过 AtomicStampedReference
解决ABA问题,它,一个带有标记的原子引用类,通过控制变量值
的版本来保证CAS的正确性。
循环时间长开销
CAS 保证的是对一个变量执行操作的原子性,如果对多个变量操作时,CAS 目前无法直接保证操作的原子性的。
可以通过这两个方式解决这个问题:
- 使用互斥锁来保证原子性;
将多个变量封装成对象,通过AtomicReference来保证原子性。
只能保证一个变量的原子操作
说说 Atomic 原子类?
介绍一下 Atomic 原子类
- Atomic 是指一个操作是不可中断的。即使是在多个线程一起执行的时候,一个操作一旦始,就不会被其他线程干扰。
- 所以,所谓原子类说简单点就是具有原子 / 原子操作特征的类。
- 并发包 java.util.concurrent 的原子类都存放在 java.util.concurrent.atomic 下
JUC 包中的原子类是哪4类?
基本类型:使用原子的方式更新基本类型
- AtomicInteger : 整型原子类
- AtomicLong: 长整型原子类
- AtomicBoolean: 布尔型原子类
数组类型:使用原子的方式更新数组里的某个元素
- AtomicIntegerArray: 整型数组原子类
- AtomicLongArray: 长整型数组原子类
- AtomicReferenceArray: 引用类型数组原子类
引用类型:使用原子的方式更新引用类型
- AtomicReference: 引用类型原子类
- AtomicStampedReference:
原子更新带有版本号的引用类型
。该类将整型数值与引用关联起来,可用于解决原子的更新数据和数据的版本号,可以解决使用 CAS 进行原子更新时可能出现的ABA 问题。
- AtomicMarkableReference: 原子更新带有标记位的引用类型。对象属性修改类型
- AtomicIntegerFieldUpdater: 原子更新整型字段的更新器
- AtomicLongFieldUpdater: 原子更新长整型字段的更新器
- AtomicMarkableReference: 原子更新带有标记位的引用类型
简单介绍一下 AtomicInteger 类的原理
AtomicInteger 类主要 利用 CAS和 volatile 和 native 方法来保证原子操作
,从而避免synchronized 的高开销,执行效率大为提升。
四、线程池
为什么要用线程池?
线程池提供了一种限制和管理资源(包括执行一个任务)。 每个线程池还维护一些基本统计信息,例如已完成任务的数量。
使用线程池的好处:
- 降低资源消耗。 通过重复利用已创建的线程降低线程创建和销毁造成的消耗。
- 提高响应速度。 当任务到达时,任务可以不需要的等到线程创建就能立即执行。
- 提高线程的可管理性。 线程是稀缺资源,如果无限制的创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一的分配,调优和监控。
线程池的状态有哪些?
ThreadPoolExecutor 使用 int 的高 3 位来表示线程池状态,低 29 位表示线程数量。
为什么不用两个int来分别存储线程状态和线程个数呢?
这些信息存储在一个 原子变量
ctl 中,目的是将线程池状态与线程个数合二为一,这样就可以用一次 cas 原子操作进行赋值
// c 为旧值, ctlOf 返回结果为新值
ctl.compareAndSet(c, ctlOf(targetState, workerCountOf(c))));
// rs 为高 3 位代表线程池状态, wc 为低 29 位代表线程个数,ctl 是合并它们
private static int ctlOf(int rs, int wc) { return rs | wc; }
说下线程池核心参数?
public ThreadPoolExecutor(
int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue<Runnable> workQueue,
ThreadFactory threadFactory,
RejectedExecutionHandler handler
)
- corePoolSize 核心线程数目 (最多保留的线程数)
- maximumPoolSize 最大线程数目
- keepAliveTime 生存时间 - 针对救急线程
- unit 时间单位 - 针对救急线程
- workQueue 阻塞队列
- threadFactory 线程工厂
- handler 拒绝策略
说明:救急线程 = maximumPoolSize - corePoolSize ,每个线程是用到了才创建,节省资源,懒汉式,当阻塞队列达到容量时,还有任务加入就会创建救急线程,它有生存时间。
线程池有哪些拒绝策略?
- AbortPolicy : 丢弃线程任务并报错。让调用者抛出
RejectedExecutionException
异常,这是默认策略。 - DiscardPolicy : 线程任务直接丢弃不报错。
- DiscardOldestPolicy : 将workQueue队首任务丢弃,将最新线程任务重新加入队列执行。
- CallerRunsPolicy :线程池之外的线程直接调用run方法执行。让调用者运行任务。
还知道哪些拒绝策略?TODO
说说线程池的执行流程?
- 线程池执行execute/submit方法向线程池添加任务,当任务小于核心线程数corePoolSize,线程池中可以创建新的线程。
- 当任务大于核心线程数corePoolSize,就向阻塞队列添加任务。
- 如果阻塞队列已满,需要通过比较参数maximumPoolSize,在线程池创建新的线程,当线程数量大于maximumPoolSize,说明当前设置线程池中线程已经处理不了了,就会执行饱和策略。
执行execute()方法和submit()方法的区别是什么呢?
- execute() 方法用于提交不需要返回值的任务,所以无法判断任务是否被线程池执行成功与否;
- submit()方法用于提交需要返回值的任务。线程池会返回一个future类型的对象,通过这个future对象可以判断任务是否执行成功,并且可以通过future的get()方法来获取返回值,get()方法会阻塞当前线程直到任务完成,而使用 get(long timeout,TimeUnit unit) 方法则会阻塞当前线程一段时间后立即返回,这时候有可能任务没有执行完。
常用的线程池有哪些?
newScheduledThreadPool
:创建一个大小无限的线程池,此线程池支持定时以及周期性执行任务的需求。newFixedThreadPool
:创建固定大小的线程池,每次提交一个任务就创建一个线程,直到线程达到线程池的最大大小。public static ExecutorService newFixedThreadPool(int nThreads) { return new ThreadPoolExecutor(nThreads, nThreads, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<Runnable>()); }
newCachedThreadPool
:创建一个可缓存的线程池,此线程池不会对线程池大小做限制,线程池大小完全依赖于操作系统(或者说JVM)能够创建的最大线程大小
。public static ExecutorService newCachedThreadPool(ThreadFactory threadFactory) { return new ThreadPoolExecutor(0, Integer.MAX_VALUE, 60L, TimeUnit.SECONDS, new SynchronousQueue<Runnable>(), threadFactory); }
- 核心线程数是 0, 最大线程数是 Integer.MAX_VALUE,救急线程的空闲生存时间是 60s,意味着
全部都是救急线程(60s 后可以回收)
救急线程可以无限创建
- 队列采用了 SynchronousQueue 实现特点是,它没有容量,但是当一个线程想放任务,但没有线程来取,放任务的线程是放不进去任务的(一手交钱、一手交货)
- 核心线程数是 0, 最大线程数是 Integer.MAX_VALUE,救急线程的空闲生存时间是 60s,意味着
newSingleThreadExecutor
:创建一个单线程的线程池,此线程池保证所有任务的执行顺序按照任务的提交顺序执行。
public static ExecutorService newSingleThreadExecutor(ThreadFactory threadFactory) { return new FinalizableDelegatedExecutorService (new ThreadPoolExecutor(1, 1, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<Runnable>(), threadFactory)); }
使用场景:希望多个任务排队执行。线程数固定为 1,任务数多于 1 时,会放入无界队列排队。任务执行完毕,这唯一的线程也不会被释放。
那么我们自己定义一个线程不行吗,是有区别的
- 自己创建一个单线程串行执行任务,如果任务执行失败而终止那么没有任何补救措施,
而线程池还会新建一个线程,保证池的正常工作
- Executors.newSingleThreadExecutor() 线程个数始终为1,不能修改
- FinalizableDelegatedExecutorService 应用的是装饰器模式,只对外暴露了 ExecutorService 接口,因此不能调用 ThreadPoolExecutor 中特有的方法
- Executors.newFixedThreadPool(1) 初始时为1,以后还可以修改
- 对外暴露的是 ThreadPoolExecutor 对象,可以强转后调用 setCorePoolSize 等方法进行修改
线程池常用的阻塞队列有哪些?
LinkedBlockingQueue
:对于 FixedThreadPool 和 SingleThreadExector 而言,它们使用的阻塞队列是容量为 Integer.MAX_VALUE 的 LinkedBlockingQueue,可以认为是无界队列
。- 由于 FixedThreadPool 线程池的线程数是固定的,所以没有办法增加特别多的线程来处理任务,这时就需要LinkedBlockingQueue 这样一个没有容量限制的阻塞队列来存放任务。这里需要注意,
由于线程池的任务队列永远不会放满,所以线程池只会创建核心线程数量的线程,所以此时的最大线程数对线程池来说没有意义
,因为并不会触发生成多于核心线程数的线程。
- 由于 FixedThreadPool 线程池的线程数是固定的,所以没有办法增加特别多的线程来处理任务,这时就需要LinkedBlockingQueue 这样一个没有容量限制的阻塞队列来存放任务。这里需要注意,
SynchronousQueue
:CachedThreadPool 是线程数可以无限扩展,所以CachedThreadPool 线程池并不需要一个任务队列来存储任务,因为一旦有任务被提交就直接转发给线程或者创建新线程来执行,而不需要另外保存它们。- SynchronousQueue不是一个真正的队列,而是一种线程之间移交的机制。
要将一个元素放入SynchronousQueue中,必须有另一个线程正在等待接收这个元素。只有在使用无界线程池或者有饱和策略时才建议使用该队列
。
- SynchronousQueue不是一个真正的队列,而是一种线程之间移交的机制。
DelayedWorkQueue
:ScheduledThreadPool 和SingleThreadScheduledExecutor,这两种线程池的最大特点就是可以延迟执行任务,比如说一定时间后执行任务或是每隔一定的时间执行一次任务。DelayedWorkQueue的数据结构是采用数组来实现堆
,并且内部元素并不是按照放入的时间顺序来排序的,而是会按照延迟的时间长短对任务进行排序
。
源码中线程池是怎么复用线程的?(重要)
源码中ThreadPoolExecutor中有个内置对象Worker,每个worker都是一个线程,worker线程数量和参数有关,每个worker线程池的线程复用就是通过取 Worker 的 firstTask 或者通过 getTask 方法从 workQueue 中不停地取任务,并直接调用 Runnable 的 run 方法来执行任务,这样就保证了每个线程都始终在一个循环中,反复获取任务,然后执行任务,从而实现了线程的复用。
Executor、ExecutorService以及Executors之间的区别?
-
Executor, ExecutorService, 和 Executors 最主要的区别是 Executor 是一个抽象层面的核心接口(大致代码如下)。
public interface Executor { void execute(Runnable command); }
-
ExecutorService 接口 对 Executor 接口进行了扩展,提供了返回 Future 对象,终止,关闭线程池等方法。当调用 shutDown 方法时,线程池会停止接受新的任务,但会完成正在 pending 中的任务。
-
Executors 是一个工具类,类似于 Collections。提供工厂方法来创建不同类型的线程池,比如 FixedThreadPool 或 CachedThreadPool。
shutdown() VS shutdownNow()
- shutdown() :关闭线程池,线程池的状态变为 SHUTDOWN。线程池不再接受新任务了,但是队列里的任务得执行完毕。
- shutdownNow() :关闭线程池,线程的状态变为 STOP。线程池会终止当前正在运行的任务,并停止处理排队的任务并返回正在等待执行的 List。
shutdownNow的原理是遍历线程池中的工作线程,然后逐个调用线程的interrupt方法来中断线程,所以无法响应中断的任务可能永远无法终。
isTerminated() VS isShutdown()
- isShutDown 当调用 shutdown() 方法后返回为 true。
- isTerminated 当调用 shutdown() 方法后,并且所有提交的任务完成后返回为 true
知道 Fork/Join 线程池吗?
- Fork/Join 是 JDK 1.7 加入的新的线程池实现,它体现的是一种分治思想,适用于能够进行任务拆分的 cpu 密集型运算
- 所谓的任务拆分,是将一个大任务拆分为算法上相同的小任务,直至不能拆分可以直接求解。跟递归相关的一些计算,如归并排序、斐波那契数列、都可以用分治思想进行求解
- Fork/Join 在分治的基础上加入了多线程,可以把每个任务的分解和合并交给不同的线程来完成,进一步提升了运算效率
- Fork/Join 默认会创建与 cpu 核心数大小相同的线程池
五、AQS TODO
什么是AQS?它的内部实现原理是什么?什么是CLH队列?
AQS(AbstractQuenedSynchronizer,抽象的队列式同步器
) ,定义了一套多线程访问共享资源的同步器框架,许多并发工具的实现都依赖于它,如常用的 ReentrantLock、Semaphore、CountDownLatch
。
AQS的核心思想:
- AQS使用一个volatile的int类型的成员变量state来表示同步状态,通过CAS修改同步状态的值。
//共享变量,使用volatile修饰保证线程可见性 private volatile int state;
- 当线程调用 lock 方法时 ,
如果 state=0,说明没有任何线程占有共享资源的锁,可以获得锁并将 state加1
。 - 如果 state不为0,则说明有线程目前正在使用共享变量,
其他线程必须加入同步队列(CLH)进行等待
。
- 当线程调用 lock 方法时 ,
CLH(Craig,Landin,and Hagersten)队列是一个 虚拟的双向队列
,虚拟的双向队列即 不存在队列实例,仅存在节点之间的关联关系
。AQS是将每一条请求共享资源的线程封装成一个CLH锁队列的一个结点(Node),来实现锁的分配
。
当前线程获取同步状态失败时,同步器会将当前线程以及等待状态(独占或共享)构造成为一个节点(Node)并将其加入同步队列并 进行自旋
,当同步状态释放时,会把首节点中的后继节点对应的线程唤醒,使其再次尝试获取同步状态。
注意:AQS是自旋锁
,在等待唤醒的时候,经常会使用自旋(while(!cas()))的方式,不停地尝试获取锁,直到被其他线程获取成功。
说说如何利用AQS实现一个锁?利用AQS实现的锁都有哪些?
如图示,AQS维护了一个 volatile int state
和一个 FIFO( First Input First Output)线程等待队列
,多线程争用资源被阻塞的时候就会进入这个队列。state就是共享资源
,其访问方式有如下三种:getState();setState();compareAndSetState();
实现了AQS的锁有:自旋锁、互斥锁、读锁写锁、条件产量、信号量、栅栏都是AQS的衍生物
。
AQS 定义了两种资源共享方式:
- Exclusive:独占,只有一个线程能执行,如 ReentrantLock
- Share:共享,多个线程可以同时执行,如Semaphore、CountDownLatch、ReadWriteLock,CyclicBarrier
说说AQS底层使用的设计模式?(模板方法模式)
同步器的设计是基于模板方法模式的,如果需要自定义同步器一般的方式是这样(模板方法模式很经典的一个应用):
- 使用者继承AbstractQueuedSynchronizer并重写指定的方法。(这些重写方法很简单,无非是对于共享资源state的获取和释放)
- 将AQS组合在自定义同步组件的实现中,并调用其模板方法,而这些模板方法会调用使用者重写的方法。
这和我们以往通过实现接口的方式有很大区别,这是模板方法模式很经典的一个运用。
- isHeldExclusively():该线程是否正在独占资源。只有用到condition才需要去实现它。
- tryAcquire(int):独占方式。尝试获取资源,成功则返回true,失败则返回false。
- tryRelease(int):独占方式。尝试释放资源,成功则返回true,失败则返回false。
- tryAcquireShared(int):共享方式。尝试获取资源。负数表示失败;0表示成功,但没有剩余可用资源;正数表示成功,且有剩余资源。
- tryReleaseShared(int):共享方式。尝试释放资源,如果释放后允许唤醒后续等待结点返回true,否则返回false。
以ReentrantLock为例,(可重入独占式锁):
- state初始化为0,表示未锁定状态;
- A线程lock()时,会调用tryAcquire()独占锁并将state+1;
- 之后其他线程再想tryAcquire的时候就会失败,直到A线程unlock()到state=0为止,其他线程才有机会获取该锁;
- A释放锁之前,自己也是可以重复获取此锁(state累加),这就是可重入的概念。
注意:获取多少次锁就要释放多少次锁,保证state是能回到零态的。
说说什么是 信号量?TODO
说说什么是读写锁?TODO
各种对比
synchronized 和 volatile 的区别?
- volatile 本质是在告诉 JVM 当前变量在寄存器(工作内存)中的值是不确定的,需要从主存中读取; synchronized 则是锁定当前变量,只有当前线程可以访问该变量,其他线程被阻塞住。
- volatile 仅能使用在变量级别;synchronized 则可以使用在 变量、方法、和类级别的。
- volatile 仅能实现变量的修改可见性,不能保证原子性;而synchronized 则可以保证变量的修改可见性和原子性。
- volatile 不会造成线程的阻塞;synchronized 可能会造成线程的阻塞。
- volatile 标记的变量不会被编译器优化;synchronized 标记的变量可以被编译器优化。
synchronized 和 Lock 的区别?
- synchronized 可以给类、方法、代码块加锁;而 lock
只能给代码块加锁
。 - synchronized 不需要手动获取锁和释放锁,使用简单,发生异常会自动释放锁,不会造成死锁;而 lock 需要自己加锁和释放锁,如果使用不当没有 unLock()去释放锁就会造成死锁。
- 通过 Lock 可以知道有没有成功获取锁,而 synchronized 却无法办到。
synchronized 和 ReentrantLock 的区别?
相似点
它们都是加锁方式同步,而且都是阻塞式的同步,也就是说当如果一个线程获得了对象锁,进入了同步块,其他访问该同步块的线程都必须阻塞在同步块外面等待,而进行线程阻塞和唤醒的代价是比较高的。
区别
1、Synchronized是java语言的关键字,是原生语法层面的互斥,需要jvm实现。而ReentrantLock它是JDK 1.5之后提供的API层面的互斥锁,需要lock()和unlock()方法配合try/finally语句块来完成。
2、Synchronized进过编译,会在同步块的前后分别形成monitorenter和monitorexit这个两个字节码指令。
- 在执行monitorenter指令时,首先要尝试获取对象锁。如果这个对象没被锁定,或者当前线程已经拥有了那个对象锁,把锁的计算器加1,相应的,在执行monitorexit指令时会将锁计算器就减1,当计算器为0时,锁就被释放了。
- 如果获取对象锁失败,那当前线程就要阻塞,直到对象锁被另一个线程释放为止。
3、ReentrantLock是java.util.concurrent包下提供的一套互斥锁,相比Synchronized,ReentrantLock类提供了一些高级功能,主要有以下3项:
-
等待可中断,持有锁的线程长期不释放的时候,正在等待的线程可以选择放弃等待,这相当于Synchronized来说可以避免出现死锁的情况。通过 lock.lockInterruptibly() 来实现这个机制。
try { if (!lock.tryLock(1, TimeUnit.SECONDS)) { log.debug("获取不到锁"); return; } } catch (InterruptedException e) { e.printStackTrace(); }
-
可以设置为公平锁。
ReentrantLock lock = new ReentrantLock(true);
-
支持多个条件变量,synchronized 中也有条件变量,就是我们讲原理时那个 waitSet ,当条件不满足时进入 waitSet 等待,ReentrantLock 的条件变量比 synchronized 强大之处在于,它是支持多个条件变量的。
Lock和Synchronized的区别?
乐观锁和悲观锁的理解及如何实现,有哪些实现方式?
悲观锁:总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁。
synchronized
和ReentrantLock
属于悲观锁。
乐观锁:每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,。
- 乐观锁最常见的实现就是
CAS
,也可以使用版本号
等机制。当然CAS会有ABA等问题
补充知识 方便查询
Java对象头
- 在HotSpot虚拟机中,对象在内存中存储的布局可以分为3块区域:
对象头(Header),实例数据(Instance Data)和对齐填充(Padding)。
- 也就是说
JAVA对象 = 对象头 + 实例数据 + 对齐填充
。 - 其中,对象头由两部分组成,一部分用于
存储自身的运行时数据
,称之为 Mark Word,另外一部分是类型指针,即对象指向它的类元数据的指针。
对象头 =
Mark Word + 类型指针(未开启指针压缩的情况下)
- 在32位系统中,Mark Word = 4 bytes = 32 bits,对象头 = 8 bytes = 64 bits;
- 在64位系统中,Mark Word = 8 bytes = 64 bits ,对象头 = 16 bytes = 128bits;
bytes 是字节,bits 是位。所以说,在32位JVM虚拟机系统中,Mark Word部分,占了4个字节,Klass Word部分也占了4个字节,所以,对象头大小为8个字节。在64位JVM虚拟机系统中,Mark Word部分,占了8个字节,Klass Word部分也占了8个字节,所以,对象头大小为16个字节。
|-----------------------------------------------------------------------------------------------------------------|
| Object Header(128bits) |
|-----------------------------------------------------------------------------------------------------------------|
| Mark Word(64bits) | Klass Word(64bits) | State |
|-----------------------------------------------------------------------------------------------------------------|
| unused:25|identity_hashcode:31|unused:1|age:4|biase_lock:0| 01 | OOP to metadata object | Nomal |
|-----------------------------------------------------------------------------------------------------------------|
| thread:54| epoch:2 |unused:1|age:4|biase_lock:1| 01 | OOP to metadata object | Biased |
|-----------------------------------------------------------------------------------------------------------------|
| ptr_to_lock_record:62 | 00 | OOP to metadata object | Lightweight Locked |
|-----------------------------------------------------------------------------------------------------------------|
| ptr_to_heavyweight_monitor:62 | 10 | OOP to metadata object | Heavyweight Locked |
|-----------------------------------------------------------------------------------------------------------------|
| | 11 | OOP to metadata object | Marked for GC |
|-----------------------------------------------------------------------------------------------------------------|
- lock:2位的锁状态标记位,由于希望用尽可能少的二进制位表示尽可能多的信息,所以设置了lock标记。该标记的值不同,整个mark word表示的含义不同。
- biased_lock:对象是否启用偏向锁标记,只占1个二进制位。为1时表示对象启用偏向锁,为0时表示对象没有偏向锁。
biased_lock | lock | 状态 |
---|---|---|
0 | 01 | 无锁 |
1 | 01 | 偏向锁 |
0 | 00 | 轻量级锁 |
0 | 10 | 重量级锁 |
0 | 11 | GC标记 |
- age:4位的Java对象年龄。
- identity_hashcode:25位的对象标识Hash码
- thread:持有偏向锁的线程ID。
- epoch:偏向时间戳。
- ptr_to_lock_record:指向栈中锁记录的指针。
- ptr_to_heavyweight_monitor:指向管程Monitor的指针。
synchronizedMap和ConcurrentHashMap有什么区别?
SynchronizedMap()和Hashtable一样,实现上在调用map所有方法时,都对整个map进行同步。而ConcurrentHashMap的实现却更加精细,它对map中的所有桶加了锁。所以,只要有一个线程访问map,其他线程就无法进入map,而如果一个线程在访问ConcurrentHashMap某个桶时,其他线程,仍然可以对map执行某些操作。
所以,ConcurrentHashMap在性能以及安全性方面,明显比Collections.synchronizedMap()更加有优势。同时,同步操作精确控制到桶,这样,即使在遍历map时,如果其他线程试图对map进行数据修改,也不会抛出ConcurrentModificationException。