备战面试日记(1.3) - (Java多线程.JUC)

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

记录日期:2021.12.29

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

文章目录

    • JUC包以及多线程
      • 线程(基础知识点)
        • 并发和并行的区别?
        • 线程和进程的区别?
        • 线程的状态
        • 创建线程的方式
        • 什么是守护线程
        • wait()和sleep()的区别
        • 死锁
          • 什么是死锁
          • 死锁产生的原因
          • 死锁产生的条件
          • 死锁的预防
          • 死锁的检查(需要掌握)
      • Java内存模型(这块建议和volatile关键字一起复习)
      • volatile关键字(重点)
      • synchronized关键字(重点)
      • Atomic原子类
        • CAS操作的原理
        • CAS操作存在的问题
          • ABA问题
          • 开销大
          • 只能保证单个变量的原子操作
      • LockSupport(AQS前置知识点)
        • 谈谈Object类、Condition类、LockSuport类的区别?
          • wait()和notify()
          • await()和signal()
          • park()和unpark()
      • AQS(重点)
        • 分析调用链路
          • AQS中acquire
          • Condition中await
          • Conditon的singal
          • AQS中的release
          • AQS中的acquireShared
            • 关于申请共享锁时释放锁的逻辑疑问
          • AQS中的releaseShared
      • Lock(重点在于AQS)
        • lock和synchronized关键字技术选型上的区别?
      • 并发工具类(重点在于AQS,以及使用)
        • CountDownLatch
        • CyclicBarrier
        • Semaphore
      • ConcurrentLinkedDeque(可略)
      • 线程池
        • 线程池的监控
          • 代码层面监控
          • 无侵入式监控
          • 拓展思维
      • ThreadLocal
        • 1.ThreadLocal使用场景?
          • 保存线程上下文信息
          • 线程安全
        • 2.强软弱虚引用说明(前置知识点)
          • 强引用
          • 弱引用(WeakReference)
          • 软引用(SoftReference)
          • 虚引用(PhantomReference)
        • 3.ThreadLocal内存泄漏问题?
        • 4.ThreadLocal的最佳实践?
          • 链路追踪
          • 保存数据库连接
          • 保存session
          • Netty中的FastThreadLocal等实现

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()引起阻塞的线程。

img

AQS代码层面实现如下,内部封装Node节点类实现Condition接口的ConditionObject类

public abstract class AbstractQueuedSynchronizer // AQSextends AbstractOwnableSynchronizerimplements 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为1if (!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 = nullNode 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 帮助gcfailed = false; // 标识自己已经获取成功return interrupted; // 返回中断停止标识,说明当前线程没有中断,并且获取到锁资源}// 如果请求资源失败// 调用shouldParkAfterFailedAcquire()方法判断当前线程是否需要阻塞// 简单说明就是如果前面节点是SINGAL的(即需要被唤醒的),就需要park(),如果是CANCEL的节点(即取消等待的),进行跳过删除// 对于初始节点和PROAGATE节点,都设置为SINGALif (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) // 如果前一个节点正在等待获取资源,返回truereturn 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.CONDITIONif (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中,返回falseif (!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时,改变该节点状态为0compareAndSetWaitStatus(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) {// 入队并封装节点对象,状态设置为SHAREDfinal 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 GCif (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 (;;) { // 乐观锁CASNode h = head;if (h != null && h != tail) {int ws = h.waitStatus;if (ws == Node.SIGNAL) {// 如果需要唤醒后续线程,那么就唤醒,同时设置为状态0if (!compareAndSetWaitStatus(h, Node.SIGNAL, 0))continue;            // loop to recheck casesunparkSuccessor(h);}// 设置PROPAGATE状态,说明要继续传播下去else if (ws == 0 &&!compareAndSetWaitStatus(h, 0, Node.PROPAGATE))continue;                // loop on failed CAS}// 如果循环过程中头节点改变,则推退出循环if (h == head)                   // loop if head changedbreak;}
}
关于申请共享锁时释放锁的逻辑疑问

在这里我们可能会有疑问,为什么明明是申请锁,却有释放锁的逻辑?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下增加了吞吐量,具体实现查看源码。


本文来自互联网用户投稿,文章观点仅代表作者本人,不代表本站立场,不承担相关法律责任。如若转载,请注明出处。 如若内容造成侵权/违法违规/事实不符,请点击【内容举报】进行投诉反馈!

相关文章

立即
投稿

微信公众账号

微信扫一扫加关注

返回
顶部