本人本科毕业,21届毕业生,一年工作经验,简历专业技能如下,现根据简历,并根据所学知识复习准备面试。

记录日期:2021.12.29

大部分知识点只做大致介绍,具体内容根据推荐博文链接进行详细复习。

文章目录

JUC包以及多线程

java.util.concurrent并发包复习资料整理。

书籍参考:《并发编程的艺术》等,其他多线程的书籍感觉没有这本讲得好。

JUC部分的知识点有:atomic原子类、Lock(ReentrantLock、读写锁)、并发工具(闭锁、信号量、栅栏)、线程池、AQS、LockSuport、Condition、volatile关键字、sync关键字、ThreadLocal、ConcurrentLinkedDeque等…

线程(基础知识点)

并发和并行的区别?

在1.2中有提过。

  • 并发的关键是你有处理多个任务的能力,不一定要同时
  • 并行的关键是你有同时处理多个任务的能力

线程和进程的区别?

  • 进程是执行中的一个应用程序,是程序的一种动态形式,是CPU、内存等资源占用的基本单位,而且进程之间相互独立,通信比较困难,进程在执行的过程中,包含比较固定的入口、执行顺序、出口等,进程表示资源分配的基本概念,又是调度原型的基本单位,是系统中并发执行的单位。
  • 线程是进程内部的一个执行序列,属于某个进程,线程是进程中执行运算的最小单位。一个进程可以有多个线程,线程不能占用CPU、内存等资源。而且线程之间共享一块内存区域,通信比较方便,线程的入口执行顺序这些过程被应用程序所控制。

简而言之,一个进程对应一个端口,一个进程内包含多个线程。

线程的状态

  1. 初始(NEW):新创建了一个线程对象,但还没有调用start()方法。
  2. 运行(RUNNABLE):Java线程中将就绪(ready)和运行中(running)两种状态笼统的称为“运行”。
    线程对象创建后,其他线程(比如main线程)调用了该对象的start()方法。该状态的线程位于可运行线程池中,等待被线程调度选中,获取CPU的使用权,此时处于就绪状态(ready)。就绪状态的线程在获得CPU时间片后变为运行中状态(running)。
  3. 阻塞(BLOCKED):表示线程阻塞于锁。
  4. 等待(WAITING):进入该状态的线程需要等待其他线程做出一些特定动作(通知或中断)。
  5. 超时等待(TIMED_WAITING):该状态不同于WAITING,它可以在指定的时间后自行返回。
  6. 终止(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的可见性和有序性的实现,都是基于JMMMESI协议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问题。

底层都是调用了UnsafecompareAndSwapXxx()方法来实现,底层调用了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体系中的等待/通知机制。
ConditionReentrantLock对象创建,并且可以同时创建多个,可以把他理解成一个条件队列
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执行的,而lockjava写的控制锁的代码。
  • Java1.5中,synchronized是性能低效的。因为这是一个重量级操作,需要调用操作接口,导致有可能加锁消耗的系统时间比加锁以外的操作还多。
  • Java1.6中,synchronized在语义上很清晰,可以进行很多优化,有适应自旋,锁消除,锁粗化,轻量级锁,偏向锁等等。导致在Java1.6synchronized的性能并不比Lock差。
  • 性能不一样:资源竞争激励的情况下,lock性能会比synchronized好,竞争不激励的情况下,synchronizedlock性能好,synchronized会根据锁的竞争情况,从偏向锁–>轻量级锁–>重量级锁升级,而且编程更简单。
  • 锁机制不一样:synchronized是在JVM层面实现的,系统会监控锁的释放与否。lockJDK代码实现的,需要手动释放,在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—线程池原理、实现、拒绝策略、复用

线程池的监控

代码层面监控
  1. 通过重写线程池的execute、shutdown方法,可以达到监控或统一处理某些业务场景。
无侵入式监控

暂无

拓展思维

定义线程池的公共监控

线程池的采集历史运行数据如果在各个应用系统中,数据的存储、定期删除是否可以抽象出来,避免重复的工作。

如果选择抽象数据存储,客户端节点与服务端之间的交互如下:

  1. 客户端定时采集线程池历史运行数据,将数据打包好发送服务端
  2. 服务端接收客户端上报的数据,进行数据入库持久化存储
  3. 服务端定期删除或存档客户端线程池历史运行数据
  4. 由服务端统一对外提供线程池运行图表的数据展示

可以通过定制统一接口,使用缓冲队列串行化处理历史运行数据,使用生产者消费者模式。

使用最新抽象出来的客户端、服务端交互流程,有以下几个优点

  1. 数据的存储和查询展示由服务端提供功能,减轻客户端压力和重复工作量
  2. 历史运行数据的删除或备份操作由服务端统一执行
  3. 不同的项目不需要为线程池历史运行数据分别创建表结构存储
  4. 形成交互规范,避免业务发散单独开发,中心化的设计更利于技术的迭代和管理

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一般是用于多线程场景下,他的作用有如下:

  1. 保存线程上下文信息,在任意需要的地方可以获取
  2. 线程安全,避免某些情况下需要同步数据带来的性能缺失
保存线程上下文信息

保存上下文信息的话,可以将一个请求的全部链路关联起来,而不用在方法内频繁传参。

线程安全

每个线程中拥有独立的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下增加了吞吐量,具体实现查看源码。