JUC八股分析

JUC

1.线程池常见的坑

  1. 线程池的参数配置:核心线程的数量,和最大线程的数量是业务场景来的,CPU密集型,比如数据的计算业务,就是CPU的数量+1。

    IO密集型根据业务压测的值来决定的,最佳线程数=((线程等待时间+线程CPU时间)/线程CPU时间)*CPU数量

比如,我们服务器CPU核数为8核,任务线程CPU耗时20ms,线程等待等等耗时80ms,那么最佳线程数=(80+20)/20*8=40线程,那我们最大线程数就是80个

  1. 共享线程池,次要的逻辑拖垮主要的逻辑。避免所有的业务都共享一个线程池,防止一个次要的业务一直在执行业务,占用线程池。而主要的业务并没有足够的线程数来执行,影响到了我们主要的服务。这样做是不合理的。我们应该要做线程池的隔离,使用Future.get方法的时候,使用带超时时间的,因为他是阻塞的,防止被其他抢占。
  2. @Async是Spring中一个注解,他不是线程池,他其实是SimpleAsyncTaskExecutor,不会复用线程,适合执行大量短时间的线程。还是尽量自己定义一个异步的线程池,然后使用@EnableAsync来注册
  3. 使用线程池的时候,不使用threadfactory参数来自定义命名,这样导致后期不好排查问题和回溯问题
  4. 使用submit提交任务,不会把异常直接抛出来。最好我们在submit之中进行try-catch进行捕获,或者是在 Future.get() 时捕获并记录异常。
  5. 线程池使用完之后,记得关闭,防止内存泄漏的问题。最好线程池设计成单例的模式。长期运行的全局线程池(如 Spring 管理的)不需手动关闭,临时线程池需在 finally 中调用 shutdown()
  6. 线程池不要和事务一起使用,使用@Transtation的时候,依赖于当前线程的线程上下文,而线程池的线程和当前事务的线程不是一个线程,事务的上下文不会传递,导致线程池中的业务代码不在事务中执行,事务就失效了。我们可以将事务放在线程池之外进行,这是最好的方法,或者是使用支持事务上下文传递的机制(如 TransactionAwareDataSourceProxy、消息队列保证一致性)
  7. 我们要负责监控线程池状态,比如当前活跃的线程池的数量,队列的长度,拒绝的次数
  8. 要配置合理的拒绝策略,比如一个需要快速获取结果的线程,就需要胚子和callerrunpolicy,这样的话,谁提交谁执行,回退给调用的线程。
  9. 执行过程:
  • Step 1: 判断核心线程数是否已满?
    • 当前运行的线程数 < corePoolSize
    • 是:直接创建一个新的核心线程来执行任务,即使其他核心线程现在是空闲的。
    • 否: 进入 Step 2。
  • Step 2: 判断任务队列是否已满?
    • workQueue.offer(task) 是否成功?
    • 是: 任务入队成功,等待空闲线程来处理。
    • 否: 进入 Step 3。
  • Step 3: 判断最大线程数是否已满?
    • 当前运行的线程数 < maximumPoolSize
    • 是:创建一个新的非核心线程来执行任务。
    • 否: 进入 Step 4。
  • Step 4: 执行拒绝策略。
    • 调用 rejectedExecutionHandler.rejectedExecution(task, this)

2.AQS的大局解析

AQS分析:

AQS (AbstractQueuedSynchronizer) 的设计思想是将同步状态的管理和线程的排队、阻塞、唤醒机制进行分离。它采用的是模板方法设计模式,AQS 本身提供了一套通用的线程排队和管理框架,而将具体的同步状态(即“锁”的获取与释放逻辑)交给子类去实现。

底层通过一个volatile的state变量+FIFO的队列来实现线程安全的资源性抢夺

state表示资源的状态,独占锁里面0没人占,1就是已经上锁。可重入锁里面数字代表可重入的次数,AQS 提供了 getState()setState()compareAndSetState() (CAS) 这三个方法来安全地读写 state。其中,compareAndSetState() 是一个原子操作,它利用了 CPU 级别的 CAS 指令,保证了在高并发场景下修改 state 的线程安全。这是实现所有同步器的基础。

线程要抢不到锁,就会被挂到队列里面进行排队,队列是双向链表实现的CLH队列,节点记录了等待状态,信息等

节点结构 (Node):队列中的每个节点(Node)都封装了一个等待获取锁的线程。Node 的关键属性包括:

  • thread: 节点所包装的线程。
  • prev / next: 指向前驱和后继节点的指针,构成双向链表。
  • waitStatus: 节点的状态,非常关键。例如 SIGNAL (-1) 表示后继节点需要被唤醒;CANCELLED (1) 表示节点因超时或中断已取消等待。

统一的排队与唤醒机制: 当一个线程尝试获取同步状态失败时(例如,tryAcquire 返回 false),AQS 就会将这个线程包装成一个 Node 节点,并将其以自旋 + CAS 的方式安全地插入到队列的尾部。然后,该线程就会在这个队列中“排队”。 当持有锁的线程释放同步状态时(例如,调用 release 方法),

AQS获取锁和解锁的过程:

  • 获取锁 (acquire):
    1. 尝试用 CAS 修改 state 从 0 到 1。
    2. 如果成功,则获取锁成功,将锁持有者设为当前线程。
    3. 如果失败,说明锁被占用。则将当前线程包装成一个 Node 节点,加入到 CLH 队列的尾部
    4. 加入队列后,线程会自旋一小会儿,再次尝试获取锁。如果还是失败,则调用 LockSupport.park() 挂起当前线程,等待被唤醒。
  • 释放锁 (release):
    1. 修改 state 的值(比如减1)。
    2. 如果 state 变为 0,说明锁已完全释放。
    3. 则找到 CLH 队列头节点的下一个节点,调用 LockSupport.unpark() 唤醒它,让它去竞争锁。

独占锁的入队过程剖析:

ReentrantLock.lock() 为例,我们来详细看一下线程获取锁失败后的入队过程。这个过程主要发生在 AQS 的 acquire(int arg) 方法中。

  1. tryAcquire(arg)
    • 这是由子类 ReentrantLock 实现的方法,它定义了“获取锁”的具体逻辑,包括处理可重入、公平与非公平策略等。
    • 如果此方法返回 true,表示成功获取锁,acquire 方法直接结束。
    • 如果返回 false,表示获取锁失败,需要进入排队流程。
  2. addWaiter(Node.EXCLUSIVE)
    • tryAcquire 失败,此方法被调用,负责将当前线程包装成一个 Node 并加入到队列尾部。
    • 首先,它会创建一个代表当前线程的、模式为独占模式Node.EXCLUSIVE)的新节点。
    • 然后,它会尝试一次“快速路径”的 CAS 操作,试图直接将新节点设置为队尾。
    • 如果快速路径失败(通常是因为有其他线程也在同时修改队尾),则会进入一个被称为 enq(Node node) 的方法。enq 方法内部是一个死循环(自旋),通过 CAS 不断尝试,直到成功将节点原子地添加到队尾为止。这个自旋操作保证了在高并发下入队操作的线程安全。
  3. acquireQueued(Node node, int arg)
    • 节点成功入队后,acquireQueued 方法负责处理后续的逻辑:在队列中自旋、阻塞以及被唤醒后再次尝试获取锁
    • 线程进入一个近乎死循环的流程。在每次循环中,它会检查当前节点的前驱节点是否是头节点 (head)。
      • 如果是,这意味着它排在最前面,有资格去获取锁。于是它会再次调用 tryAcquire(arg)。如果成功,它就会将自己设置为新的 head 节点,并将旧的 head 节点断开连接(帮助 GC),然后方法返回。
      • 如果不是,或者尝试获取锁再次失败,线程就需要被挂起(Park)。
  4. 挂起等待
    • 在挂起之前,线程会调用 shouldParkAfterFailedAcquire 方法,检查并设置前驱节点的 waitStatusSIGNAL。这个状态的含义是:“前驱节点,当你释放锁的时候,请务必唤醒我(后继节点)”。
    • 设置成功后,线程就会调用 parkAndCheckInterrupt(),其内部的核心就是 LockSupport.park(this),此时当前线程就会被挂起,放弃 CPU,进入等待状态。

至此,一个获取锁失败的线程,就完成了从尝试获取、到封装成节点、再到安全入队、最后被挂起的完整流程。

LockSupport.park() 唤醒机制与 Object.wait/notify 的本质区别:

当一个线程调用 unlock() 释放锁时,AQS 的 release 方法会唤醒 head 节点的后继节点,这个唤醒操作底层依赖的就是 LockSupport.unpark(Thread thread)

LockSupport 是一个非常底层的线程工具类,park()unpark() 是它的核心方法。

  • 工作原理:每个线程都有一个与之关联的 “许可”(permit),这个许可是一个二元信号量(要么是 0,要么是 1)。
    • LockSupport.park():如果线程的 permit 是 1,它会消耗掉这个 permit 并立即返回;如果 permit 是 0,线程就会被阻塞,直到有其他线程为它颁发 permit。
    • LockSupport.unpark(Thread thread):它会将指定线程 thread 的 permit 设置为 1。如果这个线程当前正因 park() 而阻塞,它会立即被唤醒;如果它当前没有被阻塞,那么下一次它调用 park() 时,就会直接消耗掉这个 permit 而不会阻塞。
  • 精确唤醒:AQS 正是利用了 unpark(thread) 的这个特性。当一个线程释放锁时,它能准确地从 CLH 队列中找到需要被唤醒的下一个节点,并直接唤醒那个特定的线程。这是一种点对点的、精确的通知机制。

3.wait,sleep,notify的区别

wait()和sleep()的主要区别在于:

  1. 所属类不同,wait()是Object类的方法,sleep()是Thread类的静态方法;
  2. wait()会释放对象锁,而sleep()保持锁不释放;
  3. wait()必须在同步代码块中调用,sleep()没有此限制;
  4. wait()需要notify()或notifyAll()来唤醒,而sleep()在超时或被中断时自动恢复;
  5. 使用场景上,wait()用于线程间的协作,sleep()用于简单的延时操作。

wait()方法使当前线程进入等待状态,将其从运行状态转变为等待状态,并将其加入到等待池中。

Object.wait()/notify():

  1. 依赖关系不同
    • wait/notify 必须在 synchronized 代码块中调用,它们依赖于对象的监视器锁(Monitor)。一个线程必须先持有这个对象的 monitor 锁,才能调用该对象的 waitnotify 方法。
    • park/unpark 是一个更底层的、静态的方法,它不依赖于任何锁,可以在任何地方调用。它直接作用于线程本身。
  2. 唤醒的精确性不同
    • notify() 是从等待队列中随机唤醒一个线程,你无法指定唤醒哪一个。notifyAll() 则会唤醒所有等待的线程,造成“惊群效应”,大量被唤醒的线程会再次竞争同一个锁,导致不必要的上下文切换开销。
    • unpark(thread) 能够精确唤醒指定的线程,AQS 利用这一点实现了高效、有序的唤醒。
  3. 调用顺序的灵活性
    • 如果 notify()wait() 之前被调用,那么这个 notify 信号就会丢失
    • unpark() 可以在 park() 之前调用。unpark 给予线程的 “permit” 不会丢失。如果一个线程先被 unpark,然后它再调用 park,它将不会阻塞。这个特性使得 park/unpark 在处理并发时能够避免一些棘手的竞态条件。
  4. 中断响应
    • 调用 wait() 的线程被中断时,会抛出 InterruptedException 异常,并且会清除中断状态。
    • 调用 park() 的线程被中断时,park 方法会返回,但不会抛出异常。它可以通过 Thread.interrupted() 来检查中断状态。AQS 正是利用这一点在 acquireQueued 方法中处理中断。

总结一下Object.wait/notify 是 Java synchronized 关键字和监视器模型的一部分,而 LockSupport.park/unpark 是一个更底层、更灵活的线程阻塞原语。AQS 选择 LockSupport 而非 wait/notify,正是看中了它的精确唤醒能力与锁解耦的灵活性,这使得 AQS 能够构建出比 synchronized 更高效、功能更强大的同步器。

4.异步编排

在我看来,异步编排的核心思想是,将多个独立的、耗时的异步任务(尤其是I/O密集型任务)组合、编排起来,让它们尽可能地并行执行,最终汇总结果,从而极大地缩短整体的响应时间。 这在微服务架构中尤其重要。

在现代Java开发中,实现异步编排最核心的工具就是 CompletableFuture

举一个我们项目中非常典型的例子:获取‘商品详情页’数据。一个商品详情页通常需要展示多种信息,而这些信息可能来自不同的微服务或数据库表:

  • 任务A:调用商品服务,获取商品基本信息。
  • 任务B:调用用户服务,获取当前用户的优惠券信息。
  • 任务C:调用评论服务,获取商品的热门评论。
  • 任务D:调用推荐服务,获取相关商品推荐。

如果采用传统的同步调用方式,总耗时将是 A + B + C + D 的累加。但实际上,这四个任务没有任何依赖关系,完全可以并行执行。通过异步编排,理想情况下的总耗时将仅仅取决于耗时最长的那一个任务,即 Max(A, B, C, D),性能会得到指数级的提升。

实现:

  1. 任务并行化:为每一个独立的调用任务创建一个CompletableFuture实例。关键是使用supplyAsync(Supplier<U> supplier, Executor executor)方法,并为其提供一个自定义的线程池。这可以避免耗尽Web服务器(如Tomcat)的业务线程池。
  2. 结果编排与组合:当所有并行的任务都完成后,我需要将它们的结果组合成一个最终的ProductDetailPageDTO。我会使用CompletableFuture.allOf()来等待所有任务完成。
  3. 最终结果处理:在allOf()完成后,通过thenApply()thenAccept()来执行最终的组装逻辑。
  4. 异常处理与超时控制:在生产环境中,还需要考虑健壮性。我会使用exceptionally()来处理任何一个异步任务的失败,返回一个默认值或降级数据。同时,使用orTimeout()为整个编排流程设置一个最大等待时间,防止因为某个下游服务缓慢而导致整个请求长时间阻塞。

5.synchronized 锁升级的“细节追问

1.线程是如何从‘偏向锁’升级到‘轻量级锁’的?JVM是如何判断‘偏向’失效的

“偏向锁的核心思想是,它‘偏向’于第一个获取它的线程,认为在接下来的执行中,锁将一直被这个线程持有。

  1. 偏向状态:当一个线程第一次获取锁时,JVM会通过CAS操作,尝试将锁对象头(Mark Word)中的线程ID指向当前线程。如果成功,就获取了偏向锁。
  2. 升级触发点:当另一个线程(线程B)尝试获取这个已经被线程A持有的偏向锁时,升级过程就被触发了。
  3. 偏向锁的撤销
    • 首先,线程B的CAS操作会失败。JVM会检查Mark Word中记录的线程ID是否是线程A。
    • JVM会暂停线程A(在一个全局安全点),然后检查线程A是否还存活。
    • 如果线程A已经执行完毕,那么锁对象恢复到无锁状态,线程B可以重新尝试获取。
    • 如果线程A仍然存活且还在同步块内,说明发生了真正的竞争。此时,偏向锁就会被撤销(Revoke)。锁对象头的Mark Word会被修改,清除偏向锁标志,并升级为轻量级锁的状态。同时,线程A的栈帧中会创建锁记录(Lock Record),指向锁对象。
    • 之后,线程A和线程B都会在轻量级锁的状态下进行竞争(通过自旋)。
  4. 只不过目前在JDK 15 中被 默认禁用,并在 JDK 18完全移除。因为偏向锁的撤销消耗的性能是比较大的

2.那轻量级锁又是如何升级到重量级锁的?‘自旋’失败后发生了什么?

轻量级锁的核心思想是,它认为锁的竞争时间会非常短,线程只需要‘稍等一下’(自旋),就可以拿到锁,从而避免了线程阻塞和唤醒带来的内核态切换开销。

  1. 轻量级锁的获取:线程在自己的栈帧中创建锁记录(Lock Record),然后通过CAS操作尝试将锁对象的Mark Word指向这个锁记录。如果成功,就获取了轻量级锁。

  2. 自旋等待:如果CAS失败,说明锁已被其他线程持有。当前线程并不会立即阻塞,而是会进行自旋,即执行一个空循环,不断地重试CAS操作。

  3. 升级触发点:升级到重量级锁主要有两种情况:

    • 自旋失败:自旋的次数是有限的(JVM会动态调整,比如10次)。如果一个线程自旋了指定次数后,仍然没有获取到锁,JVM就认为竞争已经非常激烈了,不适合再空耗CPU。

    现代 JVM 采用的是自适应自旋(Adaptive Spinning)。这意味着自旋的次数不是固定的。JVM 会根据上一次在该锁上的自旋时间以及锁的拥有者的状态来决定自旋的次数。如果对于某个锁,自旋很少成功,那么下一次可能会减少自旋次数甚至不自旋;反之,如果自旋经常成功,JVM 会认为自旋的期望收益很高,可能会允许更长的自旋时间。

    • 竞争者过多:如果在自旋过程中,又有第三个线程也来竞争这把锁,那么也会立即触发升级。
  4. 锁膨胀(Inflation)

    • 一旦触发升级,锁就会膨胀为重量级锁。
    • 它会向操作系统申请一个互斥量(Mutex),并创建一个与之关联的 ObjectMonitor 对象。锁对象的Mark Word会被修改,指向一个重量级锁的监视器对象(Monitor)。 并更新锁标志位为“10”,表示该锁已进入重量级状态。
    • 所有等待锁的线程(包括正在自旋的线程和后来者)都不再自旋,而是会被阻塞,并放入Monitor的等待队列中。

    ObjectMonitor 内部维护了两个队列:_EntryList(竞争队列)和 _WaitSet(等待队列)。未能获取到锁的线程(如线程 B 和 C)会被封装成特定的节点,放入 _EntryList 队列中。

    然后,这些线程会调用操作系统提供的同步原语(例如 Linux 下的 pthread_mutex_lock),从而被挂起(阻塞),进入 BLOCKED 状态,并让出 CPU。

    • 当持有锁的线程释放锁时,会唤醒等待队列中的一个线程,进行新一轮的锁竞争。这个过程就涉及到了操作系统的互斥量(Mutex)和线程的上下文切换。

锁的升级是单向的,只能从低级别到高级别,不能降级(在HotSpot JVM的实现中)。

3.为什么是操作系统层面的动作?

  • 线程调度权:用户程序本身没有权力去剥夺一个正在运行的线程的 CPU 使用权,并把它挂起。这个权力属于操作系统的内核。内核维护着所有进程和线程的状态,并负责调度哪个线程在哪个 CPU 核心上运行。
  • 系统调用 (System Call):当 JVM 需要阻塞一个线程时,它必须通过系统调用向操作系统内核发出请求。这个请求会触发一次从用户态到内核态的切换

用户态到内核态切换的性能消耗:

  1. 上下文保存与恢复:在切换时,CPU 需要保存当前用户态线程的所有运行状态,包括寄存器值、程序计数器、栈指针等。然后加载内核执行相关操作所需要的上下文。当操作完成后,再从内核态切回用户态,这个过程需要恢复之前保存的用户线程上下文。这一来一回是纯粹的性能开销。
  2. CPU 缓存失效:切换到内核态运行时,可能会使用与用户态程序不同的数据和指令,这会导致 CPU 的高速缓存(L1, L2 Cache)以及 TLB(地址翻译后备缓冲器)中的缓存数据失效。当切回用户态时,需要重新从主内存加载数据,这会显著降低程序的执行速度。
  3. 内核处理开销:内核执行线程的挂起和后续的唤醒调度本身也需要时间。

唤醒过程:当持有重量级锁的线程 A 执行完同步代码块,释放锁时,它会唤醒 _EntryList 中的一个或多个等待线程。这个唤醒过程同样需要陷入内核态,由操作系统来完成。操作系统会将某个被唤醒的线程的状态从 BLOCKED 改为 RUNNABLE,并将其放入就绪队列,等待下一次被 CPU 调度。

6.ThreadLocal

既然key用弱引用会导致内存泄漏,那为什么ThreadLocalMap的设计者不把key也设计成强引用呢?或者,为什么不把value也设计成弱引用

1.为什么Key不能是强引用?

  • 假设Key是强引用。那么Thread对象会通过threadLocals这个Map强引用着ThreadLocal对象(Key)。只要线程本身不消亡,这个强引用链(Thread -> ThreadLocalMap -> Entry -> ThreadLocal对象)就一直存在。
  • 这意味着,即使我们在业务代码中已经不再使用某个ThreadLocal对象了(比如,myThreadLocal = null;),只要这个线程还在线程池中被复用,这个ThreadLocal对象本身就永远无法被GC回收。这会导致ThreadLocal对象本身的泄漏,比现在的情况更糟糕。”

2.为什么Value不能是弱引用?

  • ThreadLocal的核心目的就是让我们存放一些与线程绑定的数据(Value)。这些数据通常是我们业务逻辑中需要用到的对象,比如用户信息对象、数据库连接等。
  • 如果我们把Value也设计成弱引用,那么当一次GC发生时,只要这个Value对象在其他地方没有被强引用,它就可能被意外地回收掉
  • 这会导致我们调用threadLocal.get()时,突然得到一个null值,这完全违背了ThreadLocal的设计初衷,会引发严重的业务逻辑错误。我们存放进去的对象,必须保证在remove()之前是可靠存在的。所以,Value必须是强引用。”

因此只能做出了个权衡:

  • Key使用弱引用:是为了当ThreadLocal对象本身在外部不再被使用时,GC能够回收它,从而让Map中的Entry的key变为null,为后续的清理(expungeStaleEntry)提供了可能性。
  • Value使用强引用:是为了保证我们存放的数据的生命周期是可控的,不会被GC意外回收。

7.谈谈怎么理解线程安全的

线程安全指的是当多个线程同时访问一个对象或方法时,无论操作系统如何调度这些线程,也无需调用方在代码中去做额外的同步处理,都能保证程序的正确性,不会出现数据损坏或不一致的情况。

线程不安全的问题通常会表现在三个方面

  1. 原子性:一个或多个操作作为一个不可分割的整体来进行,要去这个操作序列,必须由一个线程独占完整的去执行,不能被其他线程所干扰,调不可被中断。i++
  2. 可见性:一个线程修改了一个共享变量的值,这个修改的值能够被其他线程看到。但是实际在CPU的高速缓存下,对指令做出的重排序操作,导致共享变量的值,对其他线程不是立即课件的。缓存读的旧值
  3. 有序性:写的代码的顺序和实际代码的顺序不一致,是由于编译器和处理器层面对指令重排优化导致的,可能会导致可见性问题

我们可以使用voliate或者是直接加synchronized,或者是直接加锁

或者使用原子类的CAS,或者是线程安全的ThreadLocal

8.@ConditionalOnClass设计内涵

面试官提出了一个非常精妙的问题:“@ConditionalOnClass(User.class)这行代码能编译通过,说明User.class肯定存在于classpath中,那为什么还需要这个注解呢?

未能理解@Conditional系列注解是为了解决通用starter模块在不同应用环境下的适配性问题,而不是为了解决当前项目中的类是否存在的问题。

确实,如果在我当前的项目中写@ConditionalOnClass(User.class),这个条件判断看起来是多余的。因为User.class如果不存在,我的项目根本无法编译通过。

这个注解的真正威力体现在开发通用的starter模块时。想象一下,我们正在开发一个my-sms-spring-boot-starter,这个starter希望能够同时支持阿里云短信腾讯云短信

  • 我们的starter会提供两个自动配置类:AliyunSmsAutoConfigurationTencentSmsAutoConfiguration
  • AliyunSmsAutoConfiguration负责创建阿里云短信服务的Bean。
  • TencentSmsAutoConfiguration负责创建腾讯云短信服务的Bean。

  • 一个使用者(应用项目)\在他的项目中引入了我们的starter。他可能只想使用阿里云短信,所以他只会在他的pom.xml中添加*阿里云的SDK依赖*,而不会添加腾讯云的。

  • 这时,我们的starter如何智能地判断只加载阿里云的Bean,而不去加载腾讯云的Bean呢?(如果去加载腾讯云的Bean,会因为缺少腾讯云SDK的jar包而直接抛出ClassNotFoundException,导致应用启动失败)
  • 我们就是使用@ConditionalOnClass

AliyunSmsAutoConfiguration上,我们会这样写,@ConditionalOnClass(com.aliyun.sms.sdk.SmsClient.class)

  • 当使用者的应用启动时,Spring Boot会解析我们starter中的这两个自动配置类。
  • 在解析AliyunSmsAutoConfiguration时,它会检查当前应用的classpath中是否存在com.aliyun.sms.sdk.SmsClient.class。因为使用者添加了阿里云的SDK依赖,所以这个类存在,条件满足,这个配置类就会被加载,阿里云的Bean就会被创建。
  • 在解析TencentSmsAutoConfiguration时,它会检查classpath中是否存在com.tencent.cloud.sms.sdk.SmsSender.class。因为使用者没有添加腾讯云的SDK依赖,所以这个类不存在,条件不满足,这个配置类就会被优雅地跳过,不会被加载,从而避免了ClassNotFoundException

@ConditionalOnClass并不是为了判断我们自己项目里的类是否存在,而是为了让我们开发的通用模块(starter)\能够*智能地感知和适配它所运行的应用环境*,根据应用环境中引入了哪些依赖,来动态地决定哪些功能应该被激活。这是Spring Boot实现‘约定大于配置’和‘开箱即用’的关键魔法之一

9.ThreadLocal 在线程池中的失效问题

  • InheritableThreadLocal之所以能够实现父子线程间的数据传递,是因为在new Thread()创建子线程时,子线程的构造函数会检查父线程的inheritableThreadLocals这个Map。如果它不为空,子线程就会将父线程Map中的所有值拷贝一份到自己的inheritableThreadLocals中。
  • 关键在于:这个值的拷贝动作,只发生在子线程被创建的那一瞬间
  • 在线程池的场景下,工作线程通常在系统启动时就已经被预先创建好了,并存放在池中。当我们提交一个任务时,线程池只是从池中取出一个已经存在的线程来执行我们的任务,并没有new Thread()这个动作

为了解决这个问题,阿里巴巴开源了一个非常强大的工具——TransmittableThreadLocal(TTL)。它专门用于解决在使用线程池等会池化线程的组件时,实现父子线程、任务提交者与任务执行者之间的上下文传递问题。

TTL的优点在于它通过Java Agent手动包装的方式,对线程池的submit/execute等方法以及Runnable/Callable任务进行了装饰(Decorate)。”

  1. 任务提交时(submit:当我们调用被装饰过的threadPool.submit(myRunnable)时,TTL会捕获当前线程(父线程)的ThreadLocal值,并将其‘打包’进一个TtlRunnableTtlCallable对象中。
  2. 任务执行前(run:当线程池中的某个工作线程开始执行这个被包装过的TtlRunnable时,在其run方法的try块开始处,TTL会将被‘打包’的父线程ThreadLocal值,‘回放’(replay)到当前工作线程的ThreadLocal中。
  3. 任务执行后(finally:在finally块中,TTL会清理当前工作线程的ThreadLocal,将其恢复到执行任务之前的状态,从而避免了数据串扰。

10.如何保证三个线程有序执行任务

方案1:使用wait/notify 方案

你需要自己管理锁(synchronized)、状态变量(volatile int state)、while循环(防止伪唤醒)、try-finally(保证锁释放),代码量大且极易出错。

使用notifyAll()会唤醒所有等待的线程,造成不必要的CPU竞争。而使用notify()又存在风险:如果错误地唤醒了不该被唤醒的线程(比如T1唤醒了T3而不是T2),信号就可能丢失,导致程序死锁。

方案2:升级版 wait/notify - ReentrantLock + Condition

ReentrantLock提供了比synchronized更强大的功能。Condition对象则将wait/notify机制从“一个锁只有一个等待队列”升级为“一个锁可以有多个独立的等待队列”,我们可以为每个线程的“等待室”创建一个Condition,实现精准的“点对点”唤醒,彻底避免了notify()的信号丢失问题。

  1. 创建一个ReentrantLock实例。
  2. 创建一个volatile状态变量,例如volatile int state = 1;,用于标识当前应该哪个线程执行。
  3. 每个线程创建一个Condition对象:Condition c1 = lock.newCondition(); Condition c2 = lock.newCondition(); Condition c3 = lock.newCondition();
  4. 线程T1的逻辑:
    • 获取锁 lock.lock()
    • try...finally中执行,finally块中lock.unlock()
    • while (state != 1)c1.await()
    • 执行任务1。
    • 更新状态 state = 2
    • 精准唤醒线程T2:c2.signal()
  5. 线程T2和T3的逻辑与T1类似,分别在自己的Conditionawait,并在执行完任务后,更新statesignal下一个线程的Condition

实现了有序执行,通过signal()实现了精准唤醒,比notifyAll()更高效,比notify()更安全。但还是比较复杂

方案3:信号量接力 - Semaphore

Semaphore(信号量)是控制同时访问特定资源的线程数量的工具。我们可以创建两个初始许可为0的信号量,作为两个线程之间的“接力棒”。

  1. 创建两个信号量:Semaphore sem2 = new Semaphore(0);Semaphore sem3 = new Semaphore(0);
  2. 线程T1的逻辑:
    • 执行任务1。
    • 执行完毕后,释放一个“给T2的许可”:sem2.release()
  3. 线程T2的逻辑:
    • 首先尝试获取“来自T1的许可”,如果许可未被释放,T2将在此阻塞:sem2.acquire()
    • 获取到许可后,执行任务2。
    • 执行完毕后,释放一个“给T3的许可”:sem3.release()
  4. 线程T3的逻辑:
    • 首先尝试获取“来自T2的许可”:sem3.acquire()
    • 获取到许可后,执行任务3。

代码清晰简单,但需要创建N-1个Semaphore对象,如果线程数量很多,会增加一些对象管理的开销。

方案4:SingleThreadExecutor

Executors.newSingleThreadExecutor()会创建一个单线程的线程池。这个线程池的核心特性是:它内部有一个无界的LinkedBlockingQueue\来存放任务,并且*永远只有一个工作线程来从队列中取出并执行任务。这就天然地保证了所有提交给它的任务,都会严格按照提交的顺序(FIFO)来串行执行*

  1. 创建一个单线程执行器:ExecutorService executor = Executors.newSingleThreadExecutor();

  2. 定义三个任务(RunnableCallable):task1, task2, task3

  3. 按顺序提交任务:java

    1
    2
    3
    executor.submit(task1);
    executor.submit(task2);
    executor.submit(task3);
  4. 关闭线程池:executor.shutdown()

  • 严格来说,这是“三个任务有序执行”,而不是“三个不同的线程有序执行”。因为所有任务都是由同一个工作线程来执行的。如果面试官的题目严格要求必须是三个不同的、预先创建好的线程,那么这个方案就不完全符合字面要求。

11.ReentrantLocksynchronized 在性能上到底差异在哪?

  • synchronized

    • 实现:它是Java的关键字,由JVM层面直接实现。其核心依赖于操作系统底层的Mutex Lock(互斥量)
    • 开销:获取和释放Mutex Lock需要进行用户态到内核态的切换,这是一个非常昂贵的操作,涉及到线程上下文的切换和调度,会消耗大量的CPU时间。
  • ReentrantLock

    • 实现:它是一个Java类,位于java.util.concurrent.locks包下。其核心是基于AQS(AbstractQueuedSynchronizer)框架实现的。
    • 开销:AQS在底层利用了CAS(Compare-And-Swap)\这一CPU原子指令和*volatile*关键字。在*无竞争或低竞争*的情况下,ReentrantLock可以通过CAS操作直接在用户态完成锁的获取,完全避免了内核态的切换,因此性能极高。

ReentrantLock性能一定优于synchronized”这个说法,在JDK 1.6之前是成立的。但在1.6之后,JVM对synchronized进行了翻天覆地的优化,引入了锁升级(Lock Escalation)机制,使其性能在很多场景下已经不输甚至优于ReentrantLock

  • 偏向锁:在只有一个线程访问同步块的场景下,synchronized几乎没有同步开销,性能极高。
  • 轻量级锁:当出现少量线程交替竞争时,synchronized会使用自旋(Spinning)的方式尝试获取锁。自旋也是在用户态完成的,避免了线程阻塞和内核态切换,性能同样很高。
  • 重量级锁:只有当竞争非常激烈,自旋多次仍无法获取锁时,synchronized才会升级为重量级锁,退化到依赖操作系统的Mutex Lock

只不过ReetrantLock有更多的功能,

  • 可中断等待lockInterruptibly()允许线程在等待锁的过程中响应中断。
  • 可超时等待tryLock(long timeout, TimeUnit unit)可以避免死等。
  • 多条件变量:一个ReentrantLock可以创建多个Condition对象,实现更精细的线程通信。

12 JMM 内存模型与 volatile 的可见性保证

可见性:

a)JMM

JMM 并非指物理内存的划分,而是一个抽象的、用于规范多线程环境下内存访问行为的模型。

  • 主内存 (Main Memory):这是所有线程共享的区域,Java 中所有的实例变量、静态变量和数组元素都存储在这里。
  • 工作内存 (Working Memory):这是每个线程私有的区域。线程不能直接读写主内存中的变量,而是需要先把变量从主内存拷贝一份副本到自己的工作内存中(这个副本可能是 CPU 的高速缓存、寄存器等)。线程的所有操作(读取、赋值等)都是针对其工作内存中的副本进行的。操作完成后,线程会在某个不确定的时间将修改后的变量值写回(flush)到主内存。

b) 可见性问题的产生

在这种模型下,可见性问题就显而易见了:如果线程 A 修改了其工作内存中的共享变量 flag,但没有及时将其写回主内存,那么线程 B 在读取 flag 时,仍然会从主内存读取旧值,或者使用自己工作内存中更旧的缓存值。此时,线程 A 的修改对线程 B 就是不可见的。

c) volatile 如何保证立即可见

volatile 关键字正是为了解决这个问题。当一个变量被声明为 volatile 后,JMM 会对所有线程施加两条特殊的规则:

  1. 写操作的强制刷新 (Flush):当一个线程修改了一个 volatile 变量的值,JMM 会强制该线程立即将这个修改后的值从其工作内存写回到主内存中。
  2. 读操作的强制失效 (Invalidate) 与加载 (Load):当一个线程准备读取一个 volatile 变量时,JMM 会强制该线程先使其工作内存中关于该变量的副本失效,然后必须直接从主内存中重新加载最新的值。

通过这一写一读的强制规定,volatile 巧妙地打通了主内存与工作内存之间的壁垒。任何线程对 volatile 变量的写操作,都会立刻被同步到主内存;而任何线程对 volatile 变量的读操作,都必须从主内存获取。这就确保了 volatile 变量的修改对所有其他线程是“立即可见”的。

禁止重排性:

a) 指令重排序的动机

为了提升性能,编译器和处理器(CPU)可能会在不改变单线程程序执行结果的前提下,对指令的执行顺序进行调整。例如,将没有数据依赖关系的指令并行执行。但在多线程环境下,这种优化可能会导致意想不到的严重后果(例如著名的“双重检查锁定”单例模式失效问题)。

b) 内存屏障 (Memory Barrier) 的作用

为了禁止特定类型的重排序,volatile 的实现依赖于一种特殊的底层硬件指令——内存屏障(也称内存栅栏或内存栅障)。

内存屏障就像一个“栅栏”,它向编译器和 CPU 发出明确的指令:

  • 所有在屏障之前的指令必须在屏障之前执行完毕。
  • 所有在屏障之后的指令必须在屏障之后才能开始执行。
  • 严禁将屏障前后的指令进行重排序,即任何指令都不能“越过”这个屏障。

c) volatile 如何插入内存屏障

JMM 针对 volatile 变量的读写操作,规定了具体的内存屏障插入策略:

  • 在每个 volatile 写操作之前,插入一个 StoreStore 屏障
    • 作用:保证在 volatile 写操作执行前,其前面的所有普通写操作都已经执行完毕,并且结果对其他处理器可见。
  • 在每个 volatile 写操作之后,插入一个 StoreLoad 屏障
    • 作用:防止 volatile 写与后面可能有的 volatile 读/写或普通读/写发生重排序。这是最关键也是开销最大的屏障。
  • 在每个 volatile 读操作之后,插入一个 LoadLoad 屏障 和一个 LoadStore 屏障
    • LoadLoad 作用:保证 volatile 读操作之后的所有普通读操作,都在其后执行。
    • LoadStore 作用:保证 volatile 读操作之后的所有普通写操作,都在其后执行。

通过在 volatile 变量的读写操作前后插入这些精心设计的内存屏障,JMM 成功地阻止了编译器和处理器对 volatile 相关代码的激进优化,从而确保了程序在并发环境下的有序性。

在所有内存屏障中,StoreLoad 屏障是功能最强、开销最大的一个,它通常被称为“全能屏障”(Full Fence)。它的作用是确保屏障之前的所有内存操作的结果,对屏障之后的所有内存操作都是可见的。

具体作用可以分解为两部分:

  1. 刷新处理器写缓冲 (Flush Store Buffers):它会强制将当前处理器写缓冲中的所有数据刷新到主内存中。这保证了在 StoreLoad 屏障之前的所有写操作,其结果都对其他处理器变得可见。
  2. 清空处理器无效化队列 (Invalidate Queue):它会处理无效化队列中的消息,强制使本地缓存中与主内存不一致的数据失效。这保证了在 StoreLoad 屏障之后的读操作,能够获取到主内存中最新的数据。

13.死锁

死锁定义:指两个或两个以上的线程在执行过程中,因争夺资源而造成的一种互相等待的现象,若无外力干涉,它们都将无法推进下去。

死锁的四个必要条件(必须同时满足):

  1. 互斥条件 (Mutual Exclusion):一个资源每次只能被一个线程使用。
  2. 占有并等待 (Hold and Wait):一个线程因请求资源而阻塞时,对已获得的资源保持不放。
  3. 不可剥夺 (No Preemption):线程已获得的资源,在未使用完之前,不能被强行剥夺,只能在使用完后由自己释放。
  4. 循环等待 (Circular Wait):若干线程之间形成一种头尾相接的循环等待资源关系。

如何避免死锁: 要避免死锁,只需破坏上述四个必要条件中的任意一个即可。

  1. 破坏“占有并等待”:实行资源“一次性分配”策略,即线程在运行前一次性申请所有需要的资源。
  2. 破坏“不可剥夺”:当一个线程占有部分资源并请求其他资源时,如果请求不到,可以主动释放它当前占有的资源。Lock 接口的 tryLock 方法提供了这种可能性。
  3. 破坏“循环等待”(最常用):采用资源有序分配法。对所有资源进行统一编号,所有线程在申请资源时,必须严格按照资源编号的递增(或递减)顺序进行申请,这样就不会形成环路。