本人本科毕业,21届毕业生,一年工作经验,简历专业技能如下,现根据简历,并根据所学知识复习准备面试。
记录日期:2021.12.29
大部分知识点只做大致介绍,具体内容根据推荐博文链接进行详细复习。
文章目录
JUC包以及多线程
java.util.concurrent并发包复习资料整理。
书籍参考:《并发编程的艺术》等,其他多线程的书籍感觉没有这本讲得好。
JUC部分的知识点有:atomic原子类、Lock(ReentrantLock、读写锁)、并发工具(闭锁、信号量、栅栏)、线程池、AQS、LockSuport、Condition、volatile关键字、sync关键字、ThreadLocal、ConcurrentLinkedDeque等…
线程(基础知识点)
并发和并行的区别?
在1.2中有提过。
- 并发的关键是你有
处理多个任务的能力,不一定要同时
。 - 并行的关键是你有
同时处理多个任务的能力
。
线程和进程的区别?
- 进程是执行中的一个应用程序,是程序的一种动态形式,是CPU、内存等资源占用的基本单位,而且进程之间相互独立,通信比较困难,进程在执行的过程中,包含比较固定的入口、执行顺序、出口等,进程表示资源分配的基本概念,又是调度原型的基本单位,是系统中并发执行的单位。
- 线程是进程内部的一个执行序列,属于某个进程,线程是进程中执行运算的最小单位。一个进程可以有多个线程,线程不能占用CPU、内存等资源。而且线程之间共享一块内存区域,通信比较方便,线程的入口执行顺序这些过程被应用程序所控制。
简而言之,一个进程对应一个端口,一个进程内包含多个线程。
线程的状态
- 初始(NEW):新创建了一个线程对象,但还没有调用start()方法。
- 运行(RUNNABLE):Java线程中将就绪(ready)和运行中(running)两种状态笼统的称为“运行”。
线程对象创建后,其他线程(比如main线程)调用了该对象的start()方法。该状态的线程位于可运行线程池中,等待被线程调度选中,获取CPU的使用权,此时处于就绪状态(ready)。就绪状态的线程在获得CPU时间片后变为运行中状态(running)。 - 阻塞(BLOCKED):表示线程阻塞于锁。
- 等待(WAITING):进入该状态的线程需要等待其他线程做出一些特定动作(通知或中断)。
- 超时等待(TIMED_WAITING):该状态不同于WAITING,它可以在指定的时间后自行返回。
- 终止(TERMINATED):表示该线程已经执行完毕。
创建线程的方式
- 继承Thread类
- 实现Runnable接口
- 实现Callable接口
- 应用程序可以使用Executor来创建线程池
什么是守护线程
守护线程是程序运行的时候在后台提供一种通用服务的线程
。所有用户线程停止,进程会停掉所有守护线程,退出程序。Java中把线程设置为守护线程的方法:在start 线程之前
调用线程的 setDaemon(true)
方法。
wait()和sleep()的区别
wait()
会释放锁,sleep()
不会释放锁。
死锁
什么是死锁
在一个进程组内,每个进程都在等待只有其他进程才能引发的事件,那么该进程组处于死锁状态。
举例子说明:线程A有1号资源,它还想要2号资源;线程B有2号资源,它还想要1好资源;从而两个线程在互相等待对方的资源,都不给对方让资源,却又都得不到,就会导致这两个线程处于死锁状态。
死锁产生的原因
竞争资源
;系统资源有限,不能满足每一个进程的要求;多道程序运行时
,推进进程运行的顺序不合理。
死锁产生的条件
请求和保持
:每个进程都在请求还未得到的资源,但是又一直拿着自己已有的资源不放;互斥条件
:每个资源只能被一个进程所使用;不可剥夺
:对于其他进程已经获得的资源,在它为释放该资源之前,这个资源不能被其他进程剥夺循环等待
:进程组内进程之间形成循环等待资源的情形。
死锁的预防
死锁预防是指使系统不进入死锁状态的一种策略,通过遵循一种策略来打破这四个必要条件中的一个或者几个。
打破互斥条件
:允许一个资源可以被几个进程同时访问,但是对于一般的资源来说这是不可行的;
打破请求和保持条件
:提前对所有资源进行分配,看这样的分配策略是否恰当。但是在很多时候,资源的多少是动态的,计算起来并不容易。
打破不可剥夺条件
:允许进程剥夺其他进程拥有的资源,就是说,当一个进程在申请某个资源却得不到的时候,它必须释放自己所的资源,这样这个进程的资源就可以被别的进程所用;
打破循环等待条件
:对资源提前编号处理,是资源在使用时计算是否会形成环路。
死锁的检查(需要掌握)
博客参考链接:Java中死锁之代码演示和检测
1.在命令行中输入:jps
,查到我们代码程序的进程号为6164
2.然后在命令行再输入:jstack -l 6164
,分析下程序状态
Java内存模型(这块建议和volatile关键字一起复习)
博客复习参考链接:全面理解Java内存模型(JMM)及volatile关键字
众所周知,当我们在进行读写操作时,肯定不是完全基于内存去操作数据的。java为我们抽象出一个java内存模型,被称为JMM
(Java Memory Model) java内存模型
,它主要规定了线程和内存之间的一些关系。
- 工作内存 : 每个线程在创建时,都会拥有自己的工作内存,保存的是对于主存的共享变量的独立拷贝,线程之间是不可见的,无法相互访问,工作内存是基于寄存器和高速缓存的。
- 主存 : 保存的是共享变量,所有线程都可以访问到的公共区域。
volatile关键字(重点)
参考本人博客链接:java并发编程 — volatile关键字最全详解
也可参考敖丙的博客:阿里面试官没想到,一个Volatile,我都能跟他吹半小时
主要是要清楚,volatile的可见性和有序性的实现,都是基于JMM
、MESI协议
和as-if-serial 原则
、happens-before规则
等,具体的规则实现,都是根据不同CPU的原理而实现,对于硬件架构上的探究不需要太深入。
这块知识点,建议直接看书,《并发编程的艺术》中有详细说明。
synchronized关键字(重点)
参考囧辉博客链接:全网最硬核的 synchronized 面试题深度解析
主要是清楚,synchronized关键字的锁对象,锁升级,锁降级,底层代码实现、和lock的区别、锁优化的内容。
这块有一个争议,关于自旋存在的阶段,大部分网上博客认为自旋存在于轻量级锁阶段,当轻量级锁自旋修改对象头失败后,升级为重量级锁;但是囧辉的文章中说,自旋发生在重量级锁阶段。本人没有亲自去阅读过锁升级的代码,如果有了解的话,请去手撕一下面试官叭xdm。
Atomic原子类
java.util.concurrent.atomic包下,主要关注以下类:
1.AtomicBoolean:原子更新布尔类型,内部使用int类型的value存储1和0表示true和false,底层也是对int类型的原子操作。
2.AtomicInteger:原子更新int类型。
3.AtomicLong:原子更新long类型。
4.AtomicReference:原子更新引用类型,通过泛型指定要操作的类。
5.AtomicMarkableReference:原子更新引用类型,内部使用Pair承载引用对象及是否被更新过的标记,避免了ABA问题。
6.AtomicStampedReference:原子更新引用类型,内部使用Pair承载引用对象及更新的邮戳,避免了ABA问题。
底层都是调用了Unsafe
的compareAndSwapXxx()
方法来实现,底层调用了JNI来完成CPU指令的操作。
CAS操作的原理
CAS操作即compareAndSwap
,比较并交换,是一种乐观锁机制。
CAS主要操作数有三个,内存值MemoryData,旧的预期值OldData,要修改成的新值NewData,且仅当预期值和内存值相同时,将内存值修改为新值,否则什么都不做。
CAS操作存在的问题
ABA问题
举例子说明,两个线程A、B一起操作一个变量1。
1.A线程 get获得变量为1。
2.B线程修改变量为2,过了一会,B线程再修改变量为1。
3.A线程get获得变量为1。
此时,上面有一个很明显的问题,就是A线程无法感知到该变量在这段时间发生了变化,此时的解决方案有添加版本号
。
开销大
当大量线程,同时进行CAS操作,同一个时刻,只有一个线程可以修改成功,其他线程全部修改失败,会引起大量的失败重试操作,此时对CPU的消耗非常大,此时的解决方案有增加休眠时间
,或者直接上悲观锁
,从而提高CPU的利用率。
此时就要在乐观锁和悲观锁的并发量做好技术选型,思想与synchronized关键字的锁升级相同。
只能保证单个变量的原子操作
对一个变量进行操作时,我们可以多线程CAS保证该变量的原子性操作;但是对多个变量进行操作时,CAS无法保证变量的原子性操作;所以提供了AtomicReference类,来将多个共享变量聚合到一个共享变量中进行操作。
同时引申一下,向在ConcurrentHashMap和线程池中,为了操作多个共享变量,它们的解决方案是将一个原子Integer类分为3bit和29bit来操作,具体看它们的代码实现,变量名为ctl。
LockSupport(AQS前置知识点)
java.util.concurrent.locks包下,LockSupport类,它可以理解为一个工具类,用于挂起和继续执行线程,常用的api有:
- public static void park() : 如果没有可用许可,则挂起当前线程。
- public static void unpark(Thread thread):给thread一个可用的许可,让它得以继续执行。
park()
和unpark()
可以根据字面意思理解为停车场的现场,park就是停车通行证
,unpark就是离场通行证
。
park()
和unpark()
的执行效果和它调用的先后顺序没有关系。这一点相当重要,因为在一个多线程的环境中,我们往往很难保证函数调用的先后顺序(都在不同的线程中并发执行),因此,这种基于许可的做法能够最大限度保证程序不出错。
park()
和unpark()
的调用次数不限,调用100次的park()就需要调用100次的unpark()来取消。
谈谈Object类、Condition类、LockSuport类的区别?
wait()和notify()
Object类的wait()
方法和notify()
方法的实现原理,是要在同一个监视器里(比如同步代码块中)时,才可以生效。
拿黑马程序员的图参考:
调用 wait ()
方法,即可进入WaitSet
变为 WAITING
状态。
然后EntryList
中的第一个BLOCKED
线程 Thread3
会在Owner
线程释放锁时被唤醒,然后成为获得锁并成为Owner
线程
WAITING 线程会在 Owner
线程调用notify
() 或notifyAll()
时唤醒,但唤醒后并不意味着立刻获得锁,仍需进入 EntryList
重新竞争。
这里要注意,notify不一定唤醒的是队列的最前或最后一个线程,可参考囧辉在synchronized文章中的内容。
await()和signal()
Condition
条件变量是 java Lock体系中
的等待/通知机制。
Condition
由ReentrantLock
对象创建,并且可以同时创建多个,可以把他理解成一个条件队列
wait()和 notify()方法是与 synchronized 关键字合作使用的, Condition 是与重入锁一起使用的,通过Lock接口的 Condition newCondition()
方法可以生成 一个与当前重入锁绑定的Condition实例(AQS的内部类ConditionObject类型)
。
park()和unpark()
这个不用多说了,上面说过了。
AQS(重点)
参考博客链接: Java 并发高频面试题:聊聊你对 AQS 的理解?
AQS,即AbstractQueuedSynchronizer
,叫做抽象队列同步器,是一个抽象类,大部分并发工具(闭锁、信号量、栅栏)
、以及Lock(ReentrantLock)
都是基于它来实现的,它们的底层操作类Sync
都实现了该类,具体如下。
AbstractQueuedSynchronizer
内部,维护一个同步等待队列。它的作用是保存等待在这个锁上的线程(由于lock()操作引起的等待)。
此外,为了维护等待在条件变量上的等待线程,AbstractQueuedSynchronizer又需要再维护一个条件变量等待队列,也就是那些由Condition.await()
引起阻塞的线程。
AQS代码层面实现如下,内部封装Node节点类
、实现Condition接口的ConditionObject类
。
public abstract class AbstractQueuedSynchronizer // AQS
extends AbstractOwnableSynchronizer
implements java.io.Serializable {
static final class Node {
// 节点类
volatile int waitStatus; // 等待状态
volatile Node prev; // 前指针
volatile Node next; // 后指针
volatile Thread thread; // 当前线程
Node nextWaiter; // 下一个等待在条件变量队列中的节点
}
public class ConditionObject implements Condition, java.io.Serializable {
// 条件等待队列
/** First node of condition queue. */
private transient Node firstWaiter; // 头节点
/** Last node of condition queue. */
private transient Node lastWaiter; // 尾节点
}
}
Node类中的waitStatus
有不同表示,如下:
- CANCELLED:表示线程取消了等待。如果取得锁的过程中发生了一些异常,则可能出现取消的情况,比如等待过程中出现了中断异常或者出现了timeout。
- SIGNAL:表示后续节点需要被唤醒。
- CONDITION:线程等待在条件变量队列中。
- PROPAGATE:在共享模式下,无条件传播releaseShared状态。早期的JDK并没有这个状态,咋看之下,这个状态是多余的。引入这个状态是为了解决共享锁并发释放引起线程挂起的bug 6801020。
- 0: 初始状态。
分析调用链路
AQS中acquire
从请求许可入口处分析代码:
public final void acquire(int arg) {
// 尝试获得许可,arg为获得许可的个数,对于重入锁来说,每次请求传入arg为1
if (!tryAcquire(arg) &&
// 如果tryAcquire为false,即尝试获得许可失败,则先调用addWaiter()将当前线程加入到同步等待队列中
// 然后继续尝试获取锁
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt(); // 自我了结
}
进入一步看一下tryAcquire()
函数。该函数的作用是尝试获得一个许可。对于AbstractQueuedSynchronizer
来说,这是一个未实现的抽象函数,这说明这是一个钩子函数,如果子类不实现该方法,则抛出异常。
protected boolean tryAcquire(int arg) {
throw new UnsupportedOperationException();
}
如果tryAcquire()
尝试获取锁失败,我们就要将当前线程加入同步等待队列中,来一下addWaiter()
方法的实现。
private Node addWaiter(Node mode)
// 将当前线程封装成Node对象,mode 为 Node.EXCLUSIVE = null
Node node = new Node(Thread.currentThread(), mode);
// 获取队列尾端的节点tail,如果尾节点不为空,则通过CAS操作尝试将尾节点设置为当前线程节点
// 这是一个快速尝试的方法,可能失败,主要是为了提升性能
Node pred = tail;
if (pred != null) {
node.prev = pred;
if (compareAndSetTail(pred, node)) {
pred.next = node;
return node;
}
}
// 如果快速尝试失败了,就使用enq()方法,将node添加到队尾
enq(node);
return node;
}
如果在第一次快速尝试失败的情况下,就会调用enq()方法,去尝试将node添加到队尾,这是一个CAS操作,这里有一个注意点,如果当前同步等待队列中没有节点,则new Node()设置为头节点,这是一个哨兵节点。
private Node enq(final Node node) {
for (;;) {
// 乐观锁CAS修改尾节点
Node t = tail;
if (t == null) {
// 必须被初始化执行一次的代码块
if (compareAndSetHead(new Node())) // 这里是一个注意点,如果当前队列中没有节点,会new一个哨兵节点作为头节点
tail = head;
} else {
node.prev = t;
if (compareAndSetTail(t, node)) {
t.next = node;
return t; // 设置成功则返回当前线程节点node
}
}
}
}
入队成功后,调用acquireQueued()
方法,方法名通俗易懂,就是说为已经在队列中的node请求许可。
inal boolean acquireQueued(final Node node, int arg) {
// node 为当前入队的节点,arg为获取许可的个数
boolean failed = true; // 失败标识
try {
boolean interrupted = false; // 中断标识
for (;;) {
final Node p = node.predecessor(); // 返回node节点的前一个node
// 当前一个节点是头节点的时候,则说明当前节点是队列的第二个元素,尝试获取锁资源
// 为什么是第二个元素的时候去尝试呢?因为第一个节点是已经在运行了的,请求锁资源已经成功了
// 第二个元素就是最早的请求者
if (p == head && tryAcquire(arg)) {
// 如果请求资源成功
// 设置自己为头节点
setHead(node);
p.next = null; // help GC 帮助gc
failed = false; // 标识自己已经获取成功
return interrupted; // 返回中断停止标识,说明当前线程没有中断,并且获取到锁资源
}
// 如果请求资源失败
// 调用shouldParkAfterFailedAcquire()方法判断当前线程是否需要阻塞
// 简单说明就是如果前面节点是SINGAL的(即需要被唤醒的),就需要park(),如果是CANCEL的节点(即取消等待的),进行跳过删除
// 对于初始节点和PROAGATE节点,都设置为SINGAL
if (shouldParkAfterFailedAcquire(p, node) && // 见下方
// 调用park(),并且判断是否被中断
parkAndCheckInterrupt())
interrupted = true;
}
} finally {
if (failed)
cancelAcquire(node);
}
}
shouldParkAfterFailedAcquire()
方法如下,大致浏览一下。
private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
int ws = pred.waitStatus; // 获取前一个节点的状态
if (ws == Node.SIGNAL) // 如果前一个节点正在等待获取资源,返回true
return true;
if (ws > 0) {
// 大于0就是CANCEL状态,则跳过节点
do {
node.prev = pred = pred.prev;
} while (pred.waitStatus > 0);
pred.next = node;
} else {
// 否则全部修改为SINGAL状态
compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
}
return false; // 返回失败
}
Condition中await
如果调用conditon.await()
方法,那么线程也会进入等待,代码如下:
public final void await() throws InterruptedException {
// condition.await() 进入等待方法
if (Thread.interrupted()) // 线程中断校验
throw new InterruptedException();
// 添加当前线程到等待队列中,并返回已经封装的当前线程节点
Node node = addConditionWaiter();
// 进入等待前,一定要释放已经持有的许可,不然别的线程无法工作
int savedState = fullyRelease(node);
int interruptMode = 0;
// 判断当前节点是不是不在同步队列中了
while (!isOnSyncQueue(node)) {
// 即在conditon中的节点才可进入
// 当前节点在conditon中,直接park()挂起线程
LockSupport.park(this);
if ((interruptMode = checkInterruptWhileWaiting(node)) != 0)
break;
}
// 到这里说明当前节点被唤醒,已经不在conditon中了,被移到同步等待队列中了
// 已经在同步等待队列中的话,直接调用acquireQueued()尝试获取许可,之前释放几个就获取几个
if (acquireQueued(node, savedState) && interruptMode != THROW_IE)
interruptMode = REINTERRUPT; // 获取成功了
if (node.nextWaiter != null) // 清除CANCEL节点
unlinkCancelledWaiters();
if (interruptMode != 0)
reportInterruptAfterWait(interruptMode);
}
addConditionWaiter()
方法主要是将当前线程封装为节点并添加到等待队列中,具体实现如下:
tip :如果说有好奇宝宝问,啊呀,这里怎么不用做乐观锁判断呀,不会有并发问题呀!= =。。。这可是可重入锁下操作的,肯定是当前线程操作,肯定不会有线程安全问题。
private Node addConditionWaiter() {
// 添加等待节点
Node t = lastWaiter; // 获取最后一个等待节点
// 主要是判断该节点是否被取消,进行删除CANCEL的节点
if (t != null && t.waitStatus != Node.CONDITION) {
unlinkCancelledWaiters(); // while循环删除已经取消的节点
t = lastWaiter;
}
Node node = new Node(Thread.currentThread(), Node.CONDITION); // 当前线程封装节点,设置状态为Node.CONDITION
if (t == null) // t == null时,说明当前conditon中没有等待节点,将当前节点设为第一个节点
firstWaiter = node;
else // 如果存在最后一个等待节点,将当前节点置为队尾节点的下一个
t.nextWaiter = node;
lastWaiter = node; // 将当前节点设置为队尾
return node; // 返回当前节点
}
Conditon的singal
调用condition.singal()
方法,通知唤醒节点,唤醒顺序是FIFO
,从第一个节点开始:
public final void signal() {
if (!isHeldExclusively())
throw new IllegalMonitorStateException();
Node first = firstWaiter; // 获取第一个等待节点
if (first != null) // 不为空
doSignal(first); // 进行唤醒操作
}
实际调用doSingal()
方法,好像大多数源码都喜欢在实际操作的代码前加do
,比如Spring
中的源码,说回来,继续看唤醒操作:
private void doSignal(Node first) {
do {
// do while循环
if ( (firstWaiter = first.nextWaiter) == null) // 如果当前第一个节点的下一个节点是空的,说明只有这一个节点
lastWaiter = null; // 因为只有一个节点,所以最后一个节点置空
first.nextWaiter = null; // 第一个节点的下一个等待者置空,将第一个节点单独拿出来处理
} while (!transferForSignal(first) && // 将conditon中的头节点 转移到 同步等待队列 中
(first = firstWaiter) != null);
}
transferForSignal()
是处理头节点的方法,它主要的作用是把条件等待队列中的元素,移动到同步等待队列的尾部,具体代码如下:
final boolean transferForSignal(Node node) {
// 如果状态不是存在在Condition中,返回false
if (!compareAndSetWaitStatus(node, Node.CONDITION, 0))
return false;
Node p = enq(node); // 尝试将该节点入队尾
int ws = p.waitStatus; // 获取状态
if (ws > 0 || !compareAndSetWaitStatus(p, ws, Node.SIGNAL)) // 如果当前是CANCEL状态或者不是SINGAL状态
LockSupport.unpark(node.thread); // 解锁,直接唤醒
return true; // 说明入队成功
}
AQS中的release
release()
即释放锁,实现比较简单,具体代码如下:
public final boolean release(int arg) {
if (tryRelease(arg)) {
// 尝试释放资源,也是一个钩子方法,在子类中有具体实现
Node h = head; // 释放成功,获取当前头节点
if (h != null && h.waitStatus != 0) // 头节点不为空,并且状态不是初始化
// 唤醒该线程,如果遇到CANCEL状态的节点则跳过
unparkSuccessor(h);
return true;
}
return false;
}
来看一下解锁unparkSuccessor()
的方法,具体代码如下:
private void unparkSuccessor(Node node) {
// 传入头节点
// 获取当前节点的状态
int ws = node.waitStatus;
if (ws < 0) // < 0时,改变该节点状态为0
compareAndSetWaitStatus(node, ws, 0);
Node s = node.next; // 获取当前节点的下一个节点
if (s == null || s.waitStatus > 0) {
// 下一个节点为空,或者节点状态为CANCEL(即已经被取消)
s = null;
// 则循环遍历获取到一个没有被取消的节点
for (Node t = tail; t != null && t != node; t = t.prev)
if (t.waitStatus <= 0)
s = t;
}
// 获取到该节点,并释放资源
if (s != null)
LockSupport.unpark(s.thread);
}
AQS中的acquireShared
共享锁的实现类有比如:闭锁、信号量等…
之前都以排他锁为举例,为了实现共享锁,在AQS中,专门设计了一套针对共享锁的方法。
获取共享锁的方法代码如下:
public final void acquireShared(int arg) {
// tryAcquireShared()也是一个钩子方法,需要子类具体实现
// 它表示尝试获取arg个许可,返回负数则为失败
// 返回0表示成功,但是没有多余的资源可以分配,返回正数也表示请求成功
if (tryAcquireShared(arg) < 0)
// 申请资源失败,入队
doAcquireShared(arg);
}
来看一下共享锁的入队方法doAcquireShared()
,具体代码如下:
private void doAcquireShared(int arg) {
// 入队并封装节点对象,状态设置为SHARED
final Node node = addWaiter(Node.SHARED);
boolean failed = true; // 同样,失败标识
try {
boolean interrupted = false; // 同样,中断标识
for (;;) {
final Node p = node.predecessor(); // 获取前一个节点
if (p == head) {
// 如果当前是第二个节点
// 尝试申请许可
int r = tryAcquireShared(arg);
if (r >= 0) {
// 尝试申请成功了
// 将自己设置为头部
// 并根据条件判断是否要唤醒后续线程
// 如果条件允许,就会尝试传播这个唤醒到后续节点
setHeadAndPropagate(node, r);
p.next = null; // help GC
if (interrupted)
selfInterrupt();
failed = false;
return;
}
}
// 如果不是第二个节点,判断是否需要被阻塞
if (shouldParkAfterFailedAcquire(p, node) &&
parkAndCheckInterrupt())
interrupted = true;
}
} finally {
if (failed)
cancelAcquire(node);
}
}
我们来看设置头部和传播唤醒的方法setHeadAndPropagate()
,具体代码如下:
private void setHeadAndPropagate(Node node, int propagate) {
// 这里已经申请资源成功了
Node h = head;
// 将当前节点放到头部
setHead(node);
// 主要由 propagate 和 waitStatus 来判断是否要传播唤醒
if (propagate > 0 || h == null || h.waitStatus < 0 ||
(h = head) == null || h.waitStatus < 0) {
Node s = node.next;
if (s == null || s.isShared())
// 唤醒下一个线程 或者 设置为传播状态
doReleaseShared();
}
}
来看一下唤醒以及设置传播状态的方法doReleaseShared()
,具体代码如下:
// 唤醒以及设置传播状态
private void doReleaseShared() {
for (;;) {
// 乐观锁CAS
Node h = head;
if (h != null && h != tail) {
int ws = h.waitStatus;
if (ws == Node.SIGNAL) {
// 如果需要唤醒后续线程,那么就唤醒,同时设置为状态0
if (!compareAndSetWaitStatus(h, Node.SIGNAL, 0))
continue; // loop to recheck cases
unparkSuccessor(h);
}
// 设置PROPAGATE状态,说明要继续传播下去
else if (ws == 0 &&
!compareAndSetWaitStatus(h, 0, Node.PROPAGATE))
continue; // loop on failed CAS
}
// 如果循环过程中头节点改变,则推退出循环
if (h == head) // loop if head changed
break;
}
}
关于申请共享锁时释放锁的逻辑疑问
在这里我们可能会有疑问,为什么明明是申请锁,却有释放锁的逻辑?doAcquireShared()
方法是有什么特别的地方吗?
有疑问就对了,直接看文章吧,最好看一下,面试必考AQS-共享锁的申请与释放,传播状态
AQS中的releaseShared
AQS中释放共享锁的代码如下:
public final boolean releaseShared(int arg) {
if (tryReleaseShared(arg)) {
// 同样,钩子方法
doReleaseShared(); // 这个上面已经说过了
return true;
}
return false;
}
从上面代码看出来一个细节啊,就是AQS的同步等待队列的头节点,一定会有一个正在执行的节点 或者是 哨兵节点。
Lock(重点在于AQS)
java.util.concurrent.locks包下,Lock接口,主要关注其实现类有ReentrantLock,ReentrantLockReadWriteLock等。
ReentrantLock是可重入锁,通过构造传参设置公平锁以及非公平锁,是一个排他锁。
ReentrantLockReadWriteLock是读写锁,读读不互斥,读写互斥,写读互斥。
lock和synchronized关键字技术选型上的区别?
用法上:
- synchronized:在需要同步的对象中加入此控制,synchronized可以加在方法上,也可以加在特定代码块中,括号中表示需要锁的对象。
- lock:需要显示指定起始位置和终止位置。一般使用
ReentrantLock
类做为锁,多个线程中必须要使用一个ReentrantLock
类做为对象才能保证锁的生效。且在加锁和解锁处需要通过lock()
和unlock()
显示指出。所以一般会在finally
块中写unlock()
以防死锁。lock只能写在代码里,不能直接修改方法。
性能上:
synchronized
是托管给JVM
执行的,而lock
是java
写的控制锁的代码。Java1.5
中,synchronized
是性能低效的。因为这是一个重量级操作,需要调用操作接口,导致有可能加锁消耗的系统时间比加锁以外的操作还多。Java1.6
中,synchronized
在语义上很清晰,可以进行很多优化,有适应自旋,锁消除,锁粗化,轻量级锁,偏向锁等等。导致在Java1.6
上synchronized
的性能并不比Lock
差。- 性能不一样:资源竞争激励的情况下,
lock
性能会比synchronized
好,竞争不激励的情况下,synchronized
比lock
性能好,synchronized
会根据锁的竞争情况,从偏向锁–>轻量级锁–>重量级锁升级,而且编程更简单。 - 锁机制不一样:
synchronized
是在JVM
层面实现的,系统会监控锁的释放与否。lock
是JDK
代码实现的,需要手动释放,在finally
块中释放。可以采用非阻塞的方式获取锁。 synchronized
的编程更简洁,lock
的功能更多更灵活,缺点是一定要在finally
里面unlock()
资源才行。
对于现在jdk来说,基本上两者的性能已经相差无几了,更多是在业务场景下的技术选型。
其他区别:
lock
可以是公平锁也可以是非公平锁,synchronized
是非公平锁。lock
可能产生死锁,synchronized
会主动释放锁。
Lock支持的独有功能:
- 公平锁:
synchronized
是非公平锁,Lock
支持公平锁,默认非公平锁。 - 可中断锁:
ReentrantLock
提供了lockInterruptibly()
的功能,可以中断争夺锁的操作,抢锁的时候会check是否被中断,中断直接抛出异常,退出抢锁。而Synchronized只有抢锁的过程,不可干预,直到抢到锁以后,才可以编码控制锁的释放。 - 快速反馈锁:
ReentrantLock
提供了trylock()
和trylock(tryTimes)
的功能,不等待或者限定时间等待获取锁,更灵活。可以避免死锁的发生。 - 读写锁:
ReentrantReadWriteLock
类实现了读写锁的功能,类似于Mysql
,锁自身维护一个计数器,读锁可以并发的获取,写锁只能独占。而synchronized
全是独占锁 - Condition:
ReentrantLock
提供了比synchronized
更精准的线程调度工具,Condition
,一个lock
可以有多个Condition
,比如在生产消费的业务下,一个锁通过控制生产Condition
和消费Condition
精准控制。\
并发工具类(重点在于AQS,以及使用)
参考囧辉文章: Java并发:同步工具类详解(CountDownLatch、CyclicBarrier、Semaphore)
CountDownLatch
java.util.concurrent.CountDownLatch
类,简称闭锁,可以延迟线程的进度直到其到达终止状态。闭锁的作用相当于一扇门,在闭锁到达结束状态之前,这扇门一直是关闭的,并且没有任何线程能够通过,当到达结束状态时,这扇门会打来并允许所有的线程通过。当闭锁到达结束状态后,将不会再改变状态,因此这扇门将永远保持打开状态,(即只能使用一次)
。闭锁可以用来确保某些活动直到其他活动都完成后才继续执行。
使用场景举例如下:
- 统计计算服务中,所有资源计算数据返回结果后继续执行统计。
- 资源初始化,所有服务资源全部初始化完毕后继续执行。
- LOL所有玩家加载完成到100%后一起进入游戏。
使用方法如下:
第一种方式:
CountDownLatch cdl1= new CountDownLatch(n);
调用cdl1.await()
方法时,则需要等待所有线程调用cdl1.countDown()
达到n次后,才可继续执行代码。
第二种方式
CountDownLatch cdl2= new CountDownLatch(1);
所有线程调用cdl2.await()
方法进行阻塞,主线程在调用cdl2.countDown()
后,继续执行代码。
CyclicBarrier
java.util.concurrent.CyclicBarrier
类,类似于闭锁,它能阻塞一组线程知道某个事件发生。栅栏与闭锁的关键区别在于,所有的线程必须同时到达栅栏位置,才能继续执行。闭锁用于等待等待时间,而栅栏用于等待线程。栅栏用于实现一些协议。
使用场景举例如下:
-
所有运动员都到达起点后,才开始赛跑。
-
所有人都到达战场后,英雄才可以出泉水。
使用方法如下:
n是指定调用n次await()
方法,new Runnable()是n个线程执行await()
方法后,最先执行该方法,然后继续执行线程的后续方法。
CyclicBarrier barrier = new CyclicBarrier(n, new Runnable() {
public void run() {
try {
System.out.println("所有线程执行await()后, 执行本run方法, 也就是栅栏打开后会先执行本方法");
Thread.sleep(1000); // 睡眠1秒, 更好的观察此方法的执行顺序
} catch (InterruptedException e) {
e.printStackTrace();
}
}
Semaphore
java.util.concurrent.Semaphore,计数信号量(counting semaphore)用来控制同时访问某个特定资源的操作数量,或者同时执行某个制定操作的数量。计数信号量还可以实现某种资源池,或者对容器施加边界。
使用场景一般在于限流。
使用方法如下:
Semaphore sem = new Semaphore(1); // 每次只有一个线程能执行
sem.acquire(); // 获取资源,获取不到就阻塞
sem.release(); // 方法释放资源。
ConcurrentLinkedDeque(可略)
在《并发编程的艺术》中有提到该类的设计,主要是在迭代时,队列保证弱一致性,即头节点可以不是头节点,尾节点可以不是尾节点,但是在下次查询节点的过程中,会自动修正头尾节点,建议了解一下。
线程池
直接看本人的文章吧,链接:多线程学习摘要01—线程池原理、实现、拒绝策略、复用
线程池的监控
代码层面监控
- 通过重写线程池的execute、shutdown方法,可以达到监控或统一处理某些业务场景。
无侵入式监控
暂无
拓展思维
定义线程池的公共监控
线程池的采集历史运行数据如果在各个应用系统中,数据的存储、定期删除是否可以抽象出来,避免重复的工作。
如果选择抽象数据存储,客户端节点与服务端之间的交互如下:
- 客户端定时采集线程池历史运行数据,将数据打包好发送服务端
- 服务端接收客户端上报的数据,进行数据入库持久化存储
- 服务端定期删除或存档客户端线程池历史运行数据
- 由服务端统一对外提供线程池运行图表的数据展示
可以通过定制统一接口,使用缓冲队列串行化处理历史运行数据,使用生产者消费者模式。
使用最新抽象出来的客户端、服务端交互流程,有以下几个优点
- 数据的存储和查询展示由服务端提供功能,减轻客户端压力和重复工作量
- 历史运行数据的删除或备份操作由服务端统一执行
- 不同的项目不需要为线程池历史运行数据分别创建表结构存储
- 形成交互规范,避免业务发散单独开发,中心化的设计更利于技术的迭代和管理
ThreadLocal
参考敖丙博客链接:拼多多面试官没想到ThreadLocal我用得这么溜,人直接傻掉
底层实际是操作一个ThreadLocalMap
静态内部类map,ThreadLocalMap底层代码如下:
static class ThreadLocalMap {
static class Entry extends WeakReference<ThreadLocal<?>> {
// 实现虚引用,所以可能会有内存泄漏问题
/** The value associated with this ThreadLocal. */
Object value;
Entry(ThreadLocal<?> k, Object v) {
super(k);
value = v;
}
}
private Entry[] table; // 对象数组
}
ThreadLocalMap遇到Hash冲突时,如果key相等的情况下,会刷新Entry中的value;如果key不相等的情况下,会找下一个空位置,直到遇到空位置插入。
1.ThreadLocal使用场景?
ThreadLocal一般是用于多线程场景下,他的作用有如下:
- 保存线程上下文信息,在任意需要的地方可以获取
- 线程安全,避免某些情况下需要同步数据带来的性能缺失
保存线程上下文信息
保存上下文信息的话,可以将一个请求的全部链路关联起来,而不用在方法内频繁传参。
线程安全
每个线程中拥有独立的Threadlocal,读写数据都是线程隔离的,互不影响。
《阿里巴巴开发手册》中提到:
ThreadLocal 无法解决共享对象的更新问题,ThreadLocal 对象建议使用 static修饰。这个变量是针对一个线程内所有操作共享的,所以设置为静态变量,所有此类实例共享此静态变量 ,也就是说在类第一次被使用时装载,只分配一块存储空间,所有此类的对象(只要是这个线程内定义的)都可以操控这个变量。
2.强软弱虚引用说明(前置知识点)
强引用
正常定义的对象引用。
回收条件:不在引用
弱引用(WeakReference)
可用来解决asynctask 内存泄漏的问题。在切换其他acitivty的时候,如果这个actiity已经destory了,就应该让它回收。此时如果我们用弱引用的话,就能防止不能被回收。
回收条件:一般在弱引用的同时,这个对象可能也被强引用了。如果这个强引用消失了,系统就开始回收弱引用。
软引用(SoftReference)
在内存紧张的时候,能为其他对象释放内存。
回收条件: 内存不够的时候回收
虚引用(PhantomReference)
随时回收,用途不明。
回收条件:无条件,随时回收。
3.ThreadLocal内存泄漏问题?
ThreadLocal
实现变量的访问隔离原理是在每个线程的内部维护了一个ThreadLocalMap
类型的变量,这个变量的key就是该ThreadLocal(弱引用类型)
,值就是每个线程存储的值。从ThreadLocal
获取值的时候,是先获取当前运行的线程
,从而获取到当前线程的ThreadLocalMap变量
,根据key获取到当前线程的值。设置和移除的操作类似。
ThreadLocal造成内存泄漏的原因就在于,ThreadLocalMap的存活时间和当前线程的存活时间一样长,由于ThreadLocalMap的key是弱引用,所有有GC的时候就会被回收,而如果此时当前线程的生命周期还没有结束,那么就会出现ThreadLocalMap中的某个Entry的key为null,value不为null的情况。
为了避免内存泄漏,最好在每次使用完ThreadLocal后调用其remove()将数据清除掉。
在java8中,ThreadLocalMap 的 set 方法
通过调用 replaceStaleEntry 方法回收键为 null 的 Entry 对象的值(即为具体实例)以及 Entry 对象本身从而防止内存泄漏;get方法
会间接调用 expungeStaleEntry 方法将键和值为 null 的 Entry 设置为 null 从而使得该 Entry 可被回收。
4.ThreadLocal的最佳实践?
链路追踪
在项目中,使用ThreadLocal
可以对一条request请求进行链路追踪,如果要对子线程进行追踪就要使用InheritableThreadLocal
来追踪,但是大部分项目都基于池化思想来管理线程,对于线程池每次的请求都可能复用线程,此时可以使用阿里开源TransmittableThreadLocal
来完成线程池中的线程数据传递,具体实现查看源码。
保存数据库连接
比如说,当建立了数据库连接时,使用线程去操作数据库时,每次都要建立一次数据库连接,会造成大量性能损失,还会造成数据库连接过多性能下降。此时可以在使用线程池时,在线程池的每条核心线程内初始化数据库连接,并进行复用。
保存session
同理的,自己理解一下。
Netty中的FastThreadLocal等实现
在原生jdk的ThreadLocal下增加了吞吐量,具体实现查看源码。