Java 真实面试专题

13 篇 · 免费在线阅读

Java 并发与多线程面试题精选

Java 后端真实面试专题 · 并发与多线程篇

并发是 10–25k 后端岗的必考区,问得深、追问狠。每题三段: ① 标准答(讲透:是什么+为什么+怎么做)→ ② 拓展(成体系带出关联点和面试官会追问的,答一题等于答一片)→ ③ 怎么接到你自己的项目(背八股的人答不出这段)。

年限标签:🟢 3年内 🔴 3年+ 这一篇的答法本身就是教学:别人问一个点,你成体系地答一片——这才是面试官眼里的"懂"。


1. 🟢 线程有哪几种状态?是怎么流转的?

标准答:Java 用 Thread.State 暴露 6 种 JVM 层状态,它们是线程在某一时刻的观察结果,不等同于操作系统的全部状态:

  • NEW:对象已创建但还没有调用 start();同一个线程对象只能成功 start() 一次。
  • RUNNABLE:已经启动、正在运行或等待操作系统调度。Java 把“就绪”和“运行中”合并在这里,不能据此断定线程真的占着 CPU。
  • BLOCKED:等待进入 synchronized 的 monitor,通常是别的线程持有锁。
  • WAITING:主动无限期等待,如 Object.wait()Thread.join()(无超时)、LockSupport.park(),需要通知或其他线程结束才能继续。
  • TIMED_WAITING:带截止时间的等待,如 sleepwait(timeout)join(timeout)parkNanos
  • TERMINATEDrun 正常返回或因未捕获异常结束,不能再次启动。

典型流转如下;BLOCKEDWAITING 的原因不同,排查时要结合栈顶方法看:

jstack 中看到 BLOCKED,优先找竞争的锁;看到 WAITING,则检查谁应该发出通知、队列是否有数据或 Future 是否完成。状态本身只说明“在等什么”,还要结合业务链路判断是否是正常背压。

拓展:面试官常顺着追问:

  • "BLOCKED 和 WAITING 区别?"——BLOCKED 是等锁(被动,抢 synchronized 没抢到),WAITING 是主动等通知(调了 wait/park)。
  • "操作系统层面线程有几种状态?"——新建、就绪、运行、阻塞、终止,Java 的 RUNNABLE 对应了就绪+运行两个。
  • "线程状态在哪看?"——jstack 导出线程栈,每个线程都标了状态,线上排查全靠它。
  • 状态不能跳——比如 NEW 不能直接到 RUNNING,必须经 start。

往项目引 ⭐:"理解状态对排查线上很关键。我项目有次接口大面积超时,jstack 一看几十个线程全 BLOCKED 在同一把锁上,立刻判断是锁竞争,定位到一段范围过大的 synchronized,缩小锁粒度后解决——状态不是背的,是排查问题的工具。"


2. 🟢 sleep 和 wait 的区别?

标准答:最本质的区别是:sleep 只是让线程计时休眠,不释放已经持有的 monitor;wait 是条件等待,会释放调用对象的 monitor

对比项Thread.sleepObject.wait
定义位置Thread 静态方法Object 实例方法
是否释放锁不释放任何已持有的锁释放被调用对象的 monitor
唤醒方式时间到自动唤醒,也可被中断notify/notifyAll、超时或中断
使用前提不要求持有特定锁必须先进入同一对象的 synchronized
典型语义限速、重试退避、定时让出 CPU等待某个条件成立
final Object monitor = new Object();

synchronized (monitor) {
    while (!ready) {
        monitor.wait();       // 释放 monitor,醒来后重新竞争并复查条件
    }
}

synchronized (monitor) {
    ready = true;
    monitor.notifyAll();      // 修改条件后再通知
}

wait 必须放在 while 而不是 if 中:线程可能发生虚假唤醒,或被唤醒后条件又被其他线程消费。notify 只唤醒一个等待者,若多个条件共用一个 monitor,容易唤醒“用不上”的线程,生产代码通常优先 notifyAll 或直接使用 BlockingQueueCondition 等高层抽象。两者被中断都会抛 InterruptedException,捕获后应恢复中断标志或把异常继续向上抛出。

拓展:这题能引出一大片,面试官最爱追:

  • "wait/notify 为什么定义在 Object 而不是 Thread?"——锁是绑在任意对象上的,等待/唤醒针对的是"对象监视器(monitor)",所以必须是 Object 的方法。
  • "为什么 wait 必须在 synchronized 里调用?"——调用前必须先持有该对象的 monitor,否则抛 IllegalMonitorStateException
  • "notify 和 notifyAll 区别?"——notify 随机唤醒一个等待线程(可能信号丢失),notifyAll 唤醒全部再重新竞争锁,生产上一般用 notifyAll 更安全。
  • "wait 为什么要用 while 而不是 if 判断条件?"——防止"虚假唤醒",唤醒后要重新检查条件。
  • 进阶对比 LockSupport.park/unpark:不需要持锁、更灵活,AQS 底层用的就是它。

往项目引 ⭐:"我项目里很少直接写 wait/notify,而是用更上层的 BlockingQueue(它内部就是等待-通知机制)。比如订单异步处理做缓冲队列,消费线程 take() 时队列空了自动阻塞、生产者 put 进来自动唤醒,比手写 wait/notify 安全得多。"


3. 🟢 创建线程有几种方式?为什么实际只用线程池?

标准答:从 API 角度常见四种方式:

  1. 继承 Thread 并重写 run(),耦合任务和执行线程,扩展性最差。
  2. 实现 Runnable,把任务从线程对象中分离,但没有返回值。
  3. 实现 Callable<T>,交给 FutureTask/执行器执行,可以返回结果并抛出受检异常;Future.get() 可能阻塞。
  4. 提交到 ExecutorService 线程池,由池统一管理线程生命周期、队列和拒绝策略。
ExecutorService pool = Executors.newFixedThreadPool(8);
Future<Result> future = pool.submit(() -> calculate(input));
Result result = future.get();

前三种只是创建任务的方式,真正生产代码应让线程池负责执行:线程可复用,能限制并发、统一命名和监控,应用关闭时还能集中 shutdown。直接 new Thread 在高峰期会频繁创建/销毁、无法背压,线程数失控还会造成上下文切换和 OOM;线程池也不是“越大越好”,应按任务类型、下游承载能力和压测结果配置。

如果需要多个异步步骤的依赖、超时和异常编排,CompletableFuture 比手动阻塞 Future.get() 更合适;无论哪种方式,都要明确任务取消、超时以及线程池关闭策略。

拓展

  • "为什么不手动 new Thread?"——频繁创建销毁开销大、线程不可复用、数量不可控(并发一高线程暴涨直接 OOM)。阿里开发规约强制要求用线程池。
  • Runnable 和 Callable 区别——Callable 有返回值 call()、能抛受检异常,配合 Future 拿结果。
  • Future 的问题——get() 会阻塞,所以 JDK8 出了 CompletableFuture 做异步编排。
  • 本质上四种方式的"任务"和"执行"是分离的,线程池就是把"执行"复用起来。

往项目引 ⭐:"我项目所有异步任务都走自定义线程池。比如商品批量导入,用 Callable 把每一批丢进线程池并行处理、再用 Future 收集结果,十万条从几分钟降到几十秒——既复用线程又能拿到每批的处理结果。"


4. 🟢 并发编程的三大特性是什么?

标准答:并发问题通常从三个维度分析:

  • 原子性:一个操作或一组操作不可被观察到“做到一半”。i++ 其实是读、加一、写回三步,多个线程会丢更新;用 synchronized/Lock 保护临界区,或用 AtomicInteger.incrementAndGet
  • 可见性:一个线程写入共享变量后,其他线程能看到最新值。线程可能把变量缓存到寄存器/工作内存,volatile、解锁/加锁、线程启动和结束等 happens-before 规则建立可见性。
  • 有序性:编译器、JIT 和 CPU 可能在不改变单线程结果的前提下重排指令;并发下若没有约束,另一个线程可能观察到不符合代码书写顺序的状态。volatile 的内存屏障、锁和安全发布规则可以限制这种重排。

三者不是同义词:volatile 能解决一个状态标志的可见性和一定的有序性,但不能让 count++ 原子;锁通常同时提供互斥、可见性和有序性;Atomic* 主要通过 CAS 保证特定变量的原子更新。

// 只需要“停止信号”的场景
private volatile boolean running = true;

// 需要读-改-写整体原子的场景
private final AtomicLong count = new AtomicLong();
long current = count.incrementAndGet();

判断该用什么工具时先问“缺的是哪种保证”,不要把所有共享变量都用一把大锁包住。最终还要考虑复合不变量:多个字段必须一起变化时,单独给每个字段加 volatile 仍然不够。

拓展

  • "i++ 为什么线程不安全?"——它是"读-改-写"三步,不满足原子性,多线程会丢更新。
  • "volatile 能保证原子性吗?"——不能,只保证可见性和有序性,所以计数要用 AtomicLong
  • "什么是指令重排?为什么允许?"——为了优化性能,单线程下重排不影响结果(as-if-serial),但多线程下会出问题。
  • happens-before 是判断"是否存在数据竞争"的核心规则,比如解锁 happens-before 后续加锁。
  • 三大特性是并发所有问题的根,几乎所有并发工具都是在解决这三个中的某一个。

往项目引 ⭐:"我项目里按'缺哪个特性补哪个'来选工具:优雅停机的状态开关只要可见性,用 volatile;并发计数要原子性,用 AtomicLong;复合操作要原子性+互斥,才上 synchronized/Lock。而不是无脑一把锁锁到底,那样性能差。"


5. 🟢 线程池的核心参数有哪些?一个任务提交进来的完整流程?

标准答ThreadPoolExecutor 的七个参数分别控制容量、排队、命名和过载行为:corePoolSizemaximumPoolSizekeepAliveTimeTimeUnitBlockingQueue<Runnable>ThreadFactoryRejectedExecutionHandler

提交任务时不是“先把线程开到最大”,而是按下面顺序决策:

  1. 当前工作线程数小于核心数:立即创建核心线程执行任务(即使队列里还有空位)。
  2. 核心线程已满:尝试放入阻塞队列。
  3. 队列已满且工作线程数小于最大数:创建非核心线程执行任务。
  4. 队列已满且已经达到最大线程数:调用拒绝策略。

核心线程默认不会因空闲超时而销毁,除非调用 allowCoreThreadTimeOut(true);非核心线程空闲超过 keepAliveTime 会回收。队列应优先使用有界实现,容量代表可接受的排队上限;线程工厂要设置有意义的名字并处理未捕获异常,方便监控和 jstack 定位。配置还要和下游数据库连接池、HTTP 连接池容量匹配,否则只是把压力从线程池转移到下游。

拓展

  • 高频追问"先创建线程还是先入队?"——先入队、再扩线程,很多人答反。原因是入队比创建线程代价小。
  • "keepAliveTime 对核心线程生效吗?"——默认不,除非设 allowCoreThreadTimeOut(true)
  • 阻塞队列怎么选——有界队列(ArrayBlockingQueue/有界 LinkedBlockingQueue)防止任务无限堆积 OOM;SynchronousQueue 不存任务直接交付。
  • 这套"先核心→再队列→再扩容→再拒绝"的设计哲学:优先复用、其次缓冲、最后才扩张和拒绝。

往项目引 ⭐:"我项目按业务隔离了多个线程池——订单、消息推送各用各的,避免一个业务把线程占满拖垮另一个(线程池隔离)。核心数是压测后定的,队列用有界队列,宁可触发拒绝策略也不让任务无限堆积把内存打爆。"


6. 🟢 线程池的拒绝策略有哪些?生产上你用哪种?

标准答:当工作线程已达到最大数且队列也满时,ThreadPoolExecutor 调用 RejectedExecutionHandler。JDK 提供四种策略:

  • AbortPolicy:抛 RejectedExecutionException,让调用方明确感知过载;默认策略。
  • CallerRunsPolicy:由提交任务的线程同步执行,形成反压,但提交线程若是请求线程,可能把接口延迟直接放大。
  • DiscardPolicy:静默丢弃新任务,只适用于允许丢失且有独立统计的非关键任务。
  • DiscardOldestPolicy:丢弃队列最旧任务,再尝试提交新任务;会破坏先进先出,不适合有顺序或不能丢数据的业务。

生产上不是固定选某一种,而是先按任务重要性定义语义:日志采样可以丢,订单、扣款、消息发送不能丢。关键任务通常自定义策略:记录任务类型和 traceId、打指标和告警,再把任务落库或投递到 MQ 做补偿;也可以对请求线程使用 CallerRunsPolicy 做短时背压,但必须设置超时,避免把 Web 容器线程全部拖住。

RejectedExecutionHandler handler = (task, executor) -> {
    rejectedCounter.increment();
    persistForRetry(task); // 不能在这里无限阻塞
};
ThreadPoolExecutor pool = new ThreadPoolExecutor(
        8, 32, 60, TimeUnit.SECONDS,
        new ArrayBlockingQueue<>(1000), threadFactory, handler);

拒绝本身是容量告警:要同时观察队列长度、活跃线程、任务耗时、下游响应和拒绝次数,确认是突发流量还是下游变慢。单纯把最大线程数调大,可能把数据库连接池压垮。

拓展

  • 生产一般不用默认的——抛异常会丢任务且影响主流程。
  • CallerRunsPolicy 适合"不能丢任务、宁可慢"的场景,用调用线程执行天然限流。
  • 大多数情况会自定义拒绝策略:记日志、报警、把任务落库或丢 MQ 后续补偿。
  • 拒绝策略触发说明池子已经扛不住了,要顺带排查是不是参数设小了或下游慢了。

往项目引 ⭐:"我项目自定义了拒绝策略:任务被拒时先记日志报警,再把任务持久化到 DB / 丢进 MQ,等池子空闲了补偿执行,保证重要任务(如订单后续处理)不丢——而不是默认抛异常把任务弄丢了。"


7. 🔴 为什么不建议用 Executors 直接创建线程池?

标准答:问题不在 Executors 这个类本身,而在它提供的几个快捷工厂把关键容量藏起来:

  • newFixedThreadPool/newSingleThreadExecutor 使用近似无界的 LinkedBlockingQueue,生产速度大于消费速度时任务会一直堆积,最终耗尽堆内存。
  • newCachedThreadPool 允许把最大线程数扩到 Integer.MAX_VALUE,短时间突发就可能创建大量线程,带来上下文切换、栈内存和 OOM 风险。
  • newScheduledThreadPool 的队列也没有给业务设置清晰的堆积上限,延迟任务异常时很难做容量治理。

因此生产代码直接构造 ThreadPoolExecutor(或使用经过审核的动态线程池组件),显式指定核心/最大线程数、有界队列、线程命名、拒绝策略和关闭方式。容量要根据任务耗时、流量峰值、下游连接数压测出来;“有界”还要配合监控和补偿,否则只是把无界内存问题变成无提示丢任务。

拓展

  • 这题本质考"你是不是真的踩过/懂线程池参数",背过的人才知道。
  • 延伸到"队列怎么选"——核心是有界,给一个能接受的堆积上限。
  • 再延伸"线程数怎么定"(见下一题)。

往项目引 ⭐:"我项目所有线程池都是 new ThreadPoolExecutor 手动建、统一封装成工具类,用有界队列 + 自定义拒绝策略 + 有意义的线程名(方便 jstack 排查)。就是因为知道 Executors 的无界队列在流量高峰会把内存打爆。"


8. 🔴 线程池的线程数怎么设置?

标准答:先区分任务主要在计算还是等待外部资源,再把公式当作起点而不是答案:

  • CPU 密集型可从 CPU 核数核数 + 1 开始,线程过多只会增加上下文切换。
  • IO 密集型线程在等待数据库/网络时可以让出 CPU,可从 核数 * 2 或经验公式 核数 × (1 + IO 等待时间 / CPU 计算时间) 估算,但上限受数据库连接池、远端 QPS 和内存限制。
  • 混合任务最好拆成独立线程池,避免 IO 等待挤占计算线程;阻塞调用不要直接塞进公共 ForkJoinPool。

调参流程是:记录任务 CPU/IO 时间和队列等待时间 → 在固定流量下压测不同配置 → 观察吞吐、P99 延迟、CPU、GC、连接池和拒绝数 → 选择满足 SLO 的最小容量。线程数不是越大越快,队列过长会把延迟隐藏起来,甚至造成请求超时后任务仍在后台运行。

拓展

  • "为什么 CPU 密集不能开太多线程?"——核数就那么多,线程多了只是轮流切换,切换本身耗 CPU。
  • "怎么判断是 CPU 还是 IO 密集?"——看任务在算还是在等;可用监控看 CPU 利用率。
  • 实际业务大多是 IO 密集(查库、调接口)。
  • 还可以做动态线程池(如美团 DynamicTp),运行时调参数不重启。

往项目引 ⭐:"我项目导入任务是典型 IO 密集(大量读写库),线程数设得比核数高很多,再根据压测的吞吐曲线微调;而做图片处理那种 CPU 密集的池子就设核数附近,避免无谓切换。"


9. 🟢 synchronized 和 ReentrantLock 的区别?

标准答:两者都是可重入的互斥机制,区别主要在控制能力和出错风险:

synchronizedReentrantLock
本质JVM 关键字,自动加解锁JUC 的 API,手动 lock/unlock
释放自动(出块/异常)必须 finally 里 unlock,否则死锁
中断不可中断可中断(lockInterruptibly)
公平只能非公平可选公平/非公平
条件一个等待队列可绑定多个 Condition,精准唤醒
尝试不支持tryLock 可带超时

使用 ReentrantLock 时必须把 unlock() 放在 finally,否则异常路径会永久占锁:

lock.lock();
try {
    update();
} finally {
    lock.unlock();
}

简单的临界区优先用 synchronized,编译器保证异常时自动释放,代码更短;需要可中断获取、超时抢锁、公平策略或多个 Condition 队列时才选 ReentrantLock。JDK 6 以后 synchronized 已做大量优化,不能用“Lock 一定更快”作为理由。无论哪种锁,都要缩小临界区、避免在锁内调用不受控的远程服务,并统一锁顺序降低死锁风险。

拓展

  • synchronized 的锁升级(无锁→偏向锁→轻量级锁→重量级锁,见下题)让它 JDK6 后已经不慢,简单同步优先用它,代码也更简洁不易出错。
  • ReentrantLock 的优势场景:需要 tryLock 超时、需要可中断、需要多个 Condition(如阻塞队列的"非空"和"非满"两个条件)。
  • "可重入"是什么——同一线程能重复获取自己已持有的锁,避免自己把自己锁死。
  • 读多写少还可以用 ReentrantReadWriteLock 或 StampedLock 提升并发。

往项目引 ⭐:"我项目里大多数同步用 synchronized 就够、简洁可靠;只有一个抢占式任务调度的场景用了 ReentrantLock 的 tryLock(超时)——抢不到锁的线程不能一直死等,超时就放弃去干别的,这是 synchronized 给不了的。"


10. 🔴 synchronized 的锁升级过程?锁信息存在哪?

标准答:经典 HotSpot 资料常把 synchronized 描述为“无锁 → 偏向锁 → 轻量级锁 → 重量级锁”,本质是根据竞争程度选择成本更合适的实现:

  • 无竞争时,线程可快速进入;早期 JDK 的偏向锁会在对象头 Mark Word 记录线程标识,重复进入几乎无 CAS。
  • 出现少量竞争时,轻量级锁让线程在用户态短暂自旋,用 CAS 争抢,避免立即挂起/唤醒。
  • 竞争激烈或持锁时间长时,锁膨胀为重量级 monitor,失败线程阻塞,释放时再唤醒。

锁记录、对象 hash、GC 年龄等信息主要编码在对象头的 Mark Word 中,具体布局随 JVM、压缩指针和版本变化。需要注意版本边界:JDK 15 起偏向锁默认被移除/禁用,不能把旧版“升级链”当成所有现代 JDK 的运行时事实;面试时说明“这是 HotSpot 历史实现,现代版本以 monitor/轻量级路径为主”更严谨。

自旋会消耗 CPU,只有在锁很快释放时才划算;真正的性能优化仍是缩小锁范围、减少共享状态,而不是追着锁状态“调级别”。

拓展

  • "为什么要锁升级?"——大多数情况锁竞争不激烈,重量级锁的阻塞/唤醒要切到内核态、很贵,所以先用轻量级方案。
  • 偏向锁在高并发反复竞争下有撤销开销,JDK15 后默认禁用了偏向锁。
  • 自旋是"忙等",消耗 CPU 但避免线程切换,适合锁很快释放的场景。
  • 引申到对象内存布局:对象头(Mark Word + 类型指针)+ 实例数据 + 对齐填充。

往项目引 ⭐:"理解锁升级让我对 synchronized 有底——它在低竞争下其实很轻量。所以项目里简单的同步我放心用 synchronized,不会一上来就换成 Lock '显得高级',反而把代码搞复杂。"


11. 🟢 volatile 的作用和底层原理?能保证原子性吗?

标准答volatile 是对一个共享变量的轻量级并发语义,主要保证:

  • 可见性:一个线程写入后,后续读取该变量的线程能看到新值;JMM 会在读写处建立主内存同步关系。
  • 有序性:读写周围的内存屏障限制编译器、JIT 和 CPU 的重排,使发布标志前的普通写入对读到标志的线程可见。

不保证复合操作的原子性volatile int count; count++ 仍是读-改-写三步,两个线程可能同时读到 10,最后都写回 11。

class Worker implements Runnable {
    private volatile boolean running = true;
    public void stop() { running = false; }
    public void run() {
        while (running) { doOne(); } // 能及时看到 stop
    }
}

底层实现依赖 Java 内存模型与 CPU 缓存一致性/内存屏障,具体指令随平台而变,不能简单概括为“每次都加 lock 前缀”。适合状态开关、一次写多次读的安全发布;需要计数、检查并更新或多个字段保持不变量时,使用 Atomic*、锁或不可变快照。双重检查锁单例中的实例引用也必须 volatile,否则可能看到尚未完成构造的对象。

拓展

  • "经典应用?"——双重检查锁(DCL)单例的 instance 必须加 volatile,否则可能因为"new 对象不是原子的(分配内存→初始化→赋引用,可能重排)"而拿到半初始化对象。
  • "和 synchronized 比?"——volatile 只保证可见性/有序、更轻量、不阻塞;synchronized 还保证原子性、会互斥。
  • 适用场景:一写多读的状态标志位。
  • 要原子复合操作就上 Atomic 或锁。

往项目引 ⭐:"我项目优雅停机用 volatile boolean running 做标志位——主线程改成 false,各工作线程下一轮循环立刻可见并退出,不用加锁。但需要并发累加的计数我一律用 AtomicLong,因为 volatile 保证不了 ++ 的原子性。"


12. 🔴 CAS 是什么?有哪些问题?怎么解决?

标准答:CAS(Compare-And-Swap)把“比较当前值并更新”作为一个不可分割的硬件原子操作:只有内存值仍等于预期值时才写入新值,否则失败并由代码重试。AtomicIntegerAtomicReference 和 AQS 的很多路径都建立在这个思想上。

AtomicInteger stock = new AtomicInteger(10);
boolean success = stock.compareAndSet(10, 9);

主要代价有三类:

  • ABA:值经历 A→B→A,单看值的 CAS 认为没变化,但中间操作可能已经影响业务。给值附加版本号,使用 AtomicStampedReference 或业务序列号。
  • 自旋开销:竞争激烈时 CAS 反复失败,线程持续占 CPU;应限制重试、退避,或改用阻塞锁。
  • 多变量一致性:一次 CAS 通常只覆盖一个引用/字长,多个字段需要一起更新时可封装成不可变对象后用 AtomicReference 整体替换,或使用锁/事务。

CAS 不等于“无成本、无锁就一定快”:它仍受缓存行争用、内存序和失败重试影响。高并发计数可以用 LongAdder 分散热点,但读取是近似快照;要求严格线性一致时要用 AtomicLong 或锁,并结合业务幂等。

拓展

  • "CAS 和加锁比好在哪?"——无锁、不阻塞线程、没有线程切换开销,适合竞争不激烈的场景。
  • Atomic 类底层就是"CAS + 自旋"(getAndIncrement 循环 CAS 直到成功)。
  • 竞争激烈时 CAS 自旋失败率高,JDK8 的 LongAdder 用分段(Cell)思想分散竞争,比 AtomicLong 更适合高并发计数。

往项目引 ⭐:"我项目的库存扣减本质就是 CAS 思想——update stock set stock=stock-1 where id=? and stock>0,这是数据库层的乐观锁:不加悲观锁、靠条件更新,影响行数为 0 就说明卖光了。既防了超卖又避免了悲观锁的性能损耗。"


13. 🔴 AQS 的原理是什么?

标准答:AQS(AbstractQueuedSynchronizer)是一个提供排队、阻塞和唤醒模板的同步器框架,ReentrantLockSemaphoreCountDownLatch、读写锁等都复用它,但各自对 state 的含义不同。

  • 一个 volatile int state 表示同步资源:ReentrantLock 用它表示持有/重入次数,Semaphore 表示剩余许可,CountDownLatch 表示倒计数。
  • 获取资源时先调用子类实现的 tryAcquire/tryAcquireShared;成功直接返回,失败则把当前线程封装成 Node 放入双向等待队列。
  • 队列中的线程由 LockSupport.park 挂起,前驱释放资源后唤醒后继,线程被唤醒再循环检查条件,避免只靠一次通知。

AQS 支持独占和共享两种模式;公平锁会检查队列先来者,非公平锁允许新线程先 CAS 抢占,吞吐通常更高。子类只需实现资源获取/释放规则,排队细节由 AQS 统一处理,这就是模板方法思想。理解 state + 队列 + park/unpark 三件事,比死记某个锁的源码更容易迁移到其他 JUC 工具。

拓展

  • 两种模式:独占(ReentrantLock,一次一个线程拿 state)、共享(CountDownLatch/Semaphore,多个线程可同时拿)。
  • 公平锁 vs 非公平锁:公平锁严格按队列顺序、非公平锁允许新来的线程直接抢(吞吐更高,ReentrantLock 默认非公平)。
  • AQS 用了模板方法模式,子类只需实现 tryAcquire/tryRelease。
  • 底层挂起/唤醒用 LockSupport.park/unpark(不需要持锁,比 wait/notify 灵活)。

往项目引 ⭐:"这题偏底层,我面试会这么答:'我用过基于 AQS 的工具——ReentrantLock 做互斥、CountDownLatch 等多个并行任务完成、Semaphore 做并发数限流,并理解它们底层都是 state + 等待队列',把理论稳稳接到我真实用过的类上,而不是空背源码。"


14. 🟢 ConcurrentHashMap 是怎么保证线程安全的?和 HashMap、Hashtable 的区别?

标准答:JDK 8 ConcurrentHashMap 仍是数组 + 链表/红黑树,但把互斥范围缩小到桶:

  1. 桶为空时用 CAS 放入首节点,避免为无竞争写入加锁。
  2. 桶非空时只对该桶头节点做 synchronized,不同桶可以并行更新;链表过长也会树化。
  3. 扩容时多个线程可通过 ForwardingNode 协助迁移,读操作在迁移期间仍能找到数据。

和其他 Map 的边界要说清:HashMap 不提供并发安全;Hashtable 对整个表加内置锁,吞吐低;Collections.synchronizedMap 也只保证单次方法调用,遍历需手动同步。ConcurrentHashMapputIfAbsentcompute 等原子组合方法适合“检查并更新”场景,但“先 get 再 put”仍不是原子操作。它禁止 null key/value,以免并发读取时无法区分缺失与空值。

高并发计数可使用 ConcurrentHashMap<K, LongAdder>;若需要全局一致快照,仍要额外协调,因为 size() 和迭代结果可能反映不同时间点。

拓展

  • "JDK7 和 8 的实现差异?"——JDK7 用分段锁(Segment,默认 16 段),JDK8 改成 CAS + synchronized 锁单桶,粒度更细。
  • "size() 怎么算的?"——用 baseCount + CounterCell 数组分散统计,避免单点竞争。
  • "key/value 能为 null 吗?"——不能,因为并发下 null 无法区分"不存在"还是"值为 null"。
  • 扩容时支持多线程协助迁移(transfer),提升扩容速度。

往项目引 ⭐:"我项目里本地缓存、并发计数这些都用 ConcurrentHashMap,而不是 Collections.synchronizedMap(那是锁整个 map、性能差)。比如统计各接口调用量,用 ConcurrentHashMap<String, LongAdder>,高并发下也准也快。"


15. 🟢 乐观锁和悲观锁的区别?分别用在什么场景?

标准答

  • 悲观锁先假设会冲突,获取资源后再读写,其他线程必须等待。例如 Java 的 synchronized、数据库 select ... for update。它适合冲突频繁、临界区短且必须一次成功的场景,但锁范围过大或事务过长会降低并发、诱发死锁。
  • 乐观锁先读并计算,提交更新时校验版本/条件是否仍成立,失败就重试或返回冲突。例如 CAS、where version = oldVersionwhere stock > 0 的条件更新。它不阻塞读取,适合冲突概率低或失败可快速反馈的场景。

数据库版本号例子:

UPDATE product
SET stock = stock - 1, version = version + 1
WHERE id = ? AND version = ? AND stock > 0;

检查影响行数:1 表示本次更新成功,0 表示版本冲突或库存不足。选择方案时还要考虑重试成本、用户体验、事务边界和下游副作用;乐观锁失败后不能盲目重试发券/扣款,必须配合幂等键。高冲突热点可先在 Redis 或队列削峰,再由数据库做最终条件校验。

拓展

  • 乐观锁实现版本号:表加 version 字段,更新时 where version=旧值,成功才说明没被改、并把 version+1。
  • CAS 也是乐观锁思想。
  • 悲观锁要注意锁范围和死锁。
  • 数据库乐观锁失败后业务上要决定是重试还是报错给用户。

往项目引 ⭐:"我项目库存扣减用乐观锁(stock>0 条件更新),并发高、冲突可接受、失败就提示'已抢完';而账户转账这种要绝对一致的,用 select for update 悲观锁锁住两个账户,避免中间被改。"


16. 🟢 CountDownLatch、CyclicBarrier、Semaphore 的区别?

标准答:三者都能让线程等待,但等待关系不同:

工具等待语义是否可复用典型用途
CountDownLatch一个或多个线程等一组任务计数归零主线程等并行查询全部完成
CyclicBarrier一组线程互相等,全部到达栅栏再继续分阶段并行计算、每轮集合
Semaphore竞争有限数量的许可限制并发访问连接/资源
CountDownLatch latch = new CountDownLatch(3);
for (Runnable task : tasks) {
    pool.execute(() -> {
        try { task.run(); }
        finally { latch.countDown(); } // 必须放 finally,异常也要释放计数
    });
}
if (!latch.await(500, TimeUnit.MILLISECONDS)) {
    throw new TimeoutException("tasks not completed");
}

CyclicBarrier 的参与线程数固定,某线程异常或超时会使 barrier 进入 broken 状态,下一轮要显式处理;CountDownLatch 计数到 0 后不能复位。Semaphore 获取许可后必须在 finallyrelease(),否则许可泄漏会让后续请求永久阻塞。三者底层都可借助 AQS 的共享模式,但业务语义和故障处理不同,不能互换。

拓展

  • CountDownLatch 是"一个等多个"或"多个等一个开始",CyclicBarrier 是"多个互相等齐"。
  • CountDownLatch 计数到 0 不能复位,要重复用得换 CyclicBarrier。
  • 三者底层都是 AQS(前两个共享模式,Semaphore 控制 state 为许可数)。

往项目引 ⭐:"我项目首页要并行查商品、库存、营销三个服务,全部回来再聚合返回,就用 CountDownLatch(3),三个查询各 countDown 一次、主线程 await 等齐;接口并发保护用 Semaphore 限制同时处理的请求数。"


17. 🔴 CompletableFuture 怎么用?解决了什么问题?

标准答CompletableFuture 把异步任务表示成可组合的阶段,既能注册回调,也能表达依赖、合并、超时和异常处理,避免到处 Future.get() 阻塞。

CompletableFuture<Detail> detail = CompletableFuture.supplyAsync(
        () -> detailClient.query(id), ioPool);
CompletableFuture<Stock> stock = CompletableFuture.supplyAsync(
        () -> stockClient.query(id), ioPool);

CompletableFuture<View> view = detail.thenCombine(stock,
        (d, s) -> assemble(d, s))
    .orTimeout(800, TimeUnit.MILLISECONDS)
    .exceptionally(ex -> fallbackView(id, ex));

常用操作可按依赖关系记:

  • supplyAsync/runAsync 启动有返回值/无返回值任务;
  • thenApply 转换结果,thenAccept 消费结果,thenRun 只关心完成;
  • thenCompose 串联“后一步依赖前一步结果”的 Future;
  • thenCombine/allOf 合并独立任务,anyOf 取先完成者。

默认异步方法会使用公共 ForkJoinPool.commonPool();阻塞 IO、第三方 SDK 或大批量任务应传入业务隔离的自定义线程池,否则一个慢任务可能拖累同 JVM 的其他异步逻辑。异常必须在链上处理(exceptionallyhandlewhenComplete),并区分“返回降级值”和“继续向上失败”。allOf 本身不返回各任务结果,通常要在完成后逐个 join,且要设置整体超时和取消策略。

拓展

  • "要不要指定线程池?"——必须传自定义线程池,否则默认用 ForkJoinPool 公共池,被一个慢任务占满会影响全局。
  • 异常处理用 exceptionally/handle,别让异步异常被吞掉。
  • 和 Future 比:Future 只能阻塞 get 或轮询,CompletableFuture 能声明式编排。

往项目引 ⭐:"我项目首页聚合接口原来串行调三个服务要 2 秒,改成 CompletableFuture 用自定义线程池并行发起、thenCombine 汇总,整体耗时变成最慢的那个服务(几百毫秒)。这是'多接口并行聚合'的标准做法,面试官很爱听。"


18. 🔴 怎么保证多个线程的执行结果是有序的?

标准答:先区分“提交顺序、执行顺序、结果顺序”三个概念。线程池只保证任务进入队列的顺序(且有界队列/多个工作线程下也可能改变),不保证开始执行或完成顺序。

  • 只要求返回结果有序:给任务携带原始索引,并行执行后写入按索引排列的数组,或收集完成结果再排序。
  • 要求严格执行有序:使用单线程执行器、同一 key 的串行队列,或用 CompletableFuture.thenCompose 串联依赖;吞吐会下降。
  • 要求分阶段同步:一阶段并行,全部完成后用 CountDownLatch/CyclicBarrier 放行下一阶段。
List<CompletableFuture<Indexed<Result>>> futures = IntStream.range(0, inputs.size())
        .mapToObj(i -> CompletableFuture.supplyAsync(
                () -> new Indexed<>(i, handle(inputs.get(i))), pool))
        .toList();
List<Result> ordered = futures.stream()
        .map(CompletableFuture::join)
        .sorted(Comparator.comparingInt(Indexed::index))
        .map(Indexed::value)
        .toList();

并行任务还要定义失败策略:某一项失败是整个批次失败、填充默认值,还是重试后继续。消息顺序消费则通常按业务 key 分区/入同一队列,并由单线程或同 key 串行消费者处理;仅靠时间戳排序无法修复已经发生的状态覆盖。

拓展

  • "需要顺序消费消息怎么办?"——把同一类消息发到同一队列/分区,单线程消费(引到 MQ 顺序消费)。
  • 并行 + 有序往往是"并行计算、串行汇总"。

往项目引 ⭐:"我项目批量处理要并行提速、但结果必须按原顺序返回给前端,我给每个任务带上索引并行跑,最后用索引把结果重新排好,既拿到了并行的速度又保住了顺序。"


19. 🟢 ThreadLocal 是什么?底层原理?有什么坑?

标准答ThreadLocal<T> 把同一个逻辑变量隔离成“每个线程一份”,调用 get/set 时实际访问的是当前 Thread 内部的 ThreadLocalMap,不是一个全局 Map。它适合线程封闭数据,如请求 traceId、当前租户、格式化上下文,不适合在线程之间共享结果。

private static final ThreadLocal<String> TENANT = new ThreadLocal<>();

try {
    TENANT.set(request.tenantId());
    service.handle(request); // 同一线程调用链可读取
} finally {
    TENANT.remove();         // 线程池复用时必须清理
}

ThreadLocalMap 的 key 是对 ThreadLocal 的弱引用,value 仍是强引用。若 ThreadLocal 对象被回收而线程长期存活(线程池),就会留下“key 已空、value 还在”的 stale entry,可能造成内存占用和数据串线;弱引用只是缓解,不替代 finally + remove。线程池还会复用线程,不能假设一次请求结束线程就销毁。

异步切线程后普通 ThreadLocal 不会自动传递:InheritableThreadLocal 只适合创建子线程的场景,在线程池中可能读到旧值;需要跨线程传递时使用明确的上下文参数,或经过审计的 TTL/框架上下文,并在任务结束清理。不要把大对象、连接或用户敏感数据长时间放在 ThreadLocal 中。

拓展

  • "为什么会内存泄漏?"——key 是弱引用会被 GC 回收,但 value 是强引用还挂在 map 上,线程池线程长期复用就会堆积。所以用完一定 remove()(最好放 finally)。
  • "key 为什么用弱引用?"——为了 ThreadLocal 对象本身能被回收,是一种缓解措施,但不能替代 remove。
  • "父子线程怎么传值?"——InheritableThreadLocal,但线程池场景下要用阿里的 TransmittableThreadLocal(TTL)。

往项目引 ⭐:"我项目用 ThreadLocal 存当前登录用户和租户 id——请求进来在拦截器里 set、整条调用链都能取到、请求结束在 finally 里 remove 防泄漏。多租户 SaaS 这套几乎是标配,也是它最典型的真实用法。"


20. 🔴 死锁是怎么产生的?怎么定位和避免?

标准答:死锁是线程永久等待彼此持有的资源。经典必要条件有四个:互斥、持有并等待、不可剥夺、循环等待;工程上最常破坏循环等待,给多把锁建立全局顺序。

定位时先看现象(线程堆积、接口超时),再用 jstack <pid> 或 Arthas thread:JVM 通常会打印 Found one Java-level deadlock,列出每个线程持有和等待的 monitor。对于 ReentrantLock、数据库锁或跨服务锁,还要结合应用日志、锁指标和数据库死锁报告,不能只看 Java 堆栈。

避免手段包括:按资源 id 统一加锁顺序;缩小锁范围、缩短持有时间;用 tryLock(timeout),超时释放已拿到的锁并重试/失败;避免在锁内调用远程服务;必要时改成无锁数据结构或单线程化处理。修复后仍要保留死锁监控,因为新代码可能引入另一条循环路径。死锁和活锁/饥饿也要区分:活锁是线程持续让步却没有进展,饥饿是某线程长期抢不到资源。

拓展

  • "怎么定位死锁?"——jstack 会直接打印 Found one Java-level deadlock 和涉及的线程与锁;也能用 jconsole/Arthas 检测。
  • 其他避免手段:用 tryLock(超时) 拿不到就放弃(破坏"不可剥夺/持有并等待");减少锁持有时间和范围;尽量用无锁结构。
  • 死锁、活锁、饥饿的区别——活锁是不断重试但都让步谁也不前进,饥饿是某线程一直抢不到资源。

往项目引 ⭐:"我项目转账场景两个账户互转曾经死锁——A 转 B 锁了 A 等 B,B 转 A 锁了 B 等 A。后来统一规则'按账户 id 从小到大依次加锁',破坏了循环等待,问题根治。这是死锁避免最经典也最实用的手段。"


你能答到第几层?

  • 三段都能答、还能往项目引:你是面试里的少数派,冲 18k+ 没问题。
  • 标准答 + 拓展能成体系答:知识扎实,差把它绑到你真做过的项目上。
  • 标准答都磕巴:别急,并发是有主线的(三大特性 → 锁 → JUC 工具 → 线程池),按主线学一遍就通。

这是面试专题的「并发篇」,网站上还有 MySQL、Redis、Spring、微服务、项目场景等系统整理。 🌐 更多真实面试专题与资料:smallredtech.com 💬 想系统学 / 简历与辅导咨询,加微信:Ahongbb666(备注「面试题」)