1. 为什么“每日一面”这种刷题方式对并发基础特别有效“每日一面系列之并发基础面试题”这个标题乍一看像是一个刷题专栏的日常更新但真正做过技术面试官或者被面试过的人都知道并发基础这块内容几乎是所有后端岗位、系统开发岗位、甚至部分客户端岗位的必考区。它不像算法题那样可以靠短期突击背模板也不像框架用法那样查文档就能解决——并发知识点的特点是概念之间高度关联且每一个细节都可能被追问三层以上。我见过太多候选人简历上写着“熟悉多线程编程”结果被问到“线程池的核心线程数设置为0会怎样”就直接卡住也见过工作三四年的开发者能熟练使用各种并发工具类但说不清楚volatile和synchronized在内存语义上的本质区别。这不是能力问题而是知识体系没有经过系统性的梳理和压力测试。“每日一面”这种形式之所以对并发基础特别有效原因有三点。第一并发知识点的遗忘曲线很陡今天看懂了AQS的state机制一周不碰就只剩模糊印象每日一题的节奏刚好能对抗遗忘。第二面试场景下的并发问题往往不是孤立的一道题会牵扯出线程生命周期、锁升级、内存屏障、CAS ABA 问题等一串知识点每天消化一个链条比一次性灌入所有内容更符合认知规律。第三并发编程的很多坑只有在“被追问”的压力下才会暴露——自己看书觉得懂了一被问“为什么这里要用ThreadLocal而不是局部变量”就发现理解是浮于表面的。这篇文章不是简单的面试题罗列而是把并发基础中最容易被问到、也最容易答错的核心问题按照“面试官想考什么—标准答案的边界在哪里—实际开发中怎么用”三层结构拆解清楚。适合正在准备面试的开发者、想系统梳理并发知识的技术人以及需要带新人的团队负责人参考。全文会涉及大量代码示例和参数分析建议边看边在本地跑一遍验证。2. 线程生命周期与状态转换面试官最爱挖的“第一层坑”2.1 六种线程状态到底怎么区分为什么BLOCKED和WAITING总是被混淆Java 中Thread.State枚举定义了六种状态NEW、RUNNABLE、BLOCKED、WAITING、TIMED_WAITING、TERMINATED。很多人背得滚瓜烂熟但一到具体场景就分不清BLOCKED和WAITING的区别。这两个状态的核心差异在于触发条件不同BLOCKED是线程在等待获取一个被其他线程持有的监视器锁也就是synchronized锁而WAITING是线程主动调用了Object.wait()、Thread.join()或LockSupport.park()后进入的等待状态。我面试时喜欢问这样一个场景线程 A 持有锁 L 并调用了wait()方法线程 B 尝试获取锁 L。此时 A 处于WAITING状态B 处于BLOCKED状态。如果线程 C 调用了notify()唤醒 AA 会从WAITING变成BLOCKED——因为它需要重新竞争锁 L而此时锁还被 B 持有或者 B 正在竞争。这个转换链条能答清楚的人基本可以确认对线程状态机有真实理解。实际开发中这个知识点直接影响到死锁排查。当你用jstack打印线程栈时BLOCKED状态的线程会明确显示“waiting to lock 0x...”而WAITING状态的线程显示的是“waiting on 0x...”。如果你看到大量线程卡在BLOCKED说明锁竞争激烈如果卡在WAITING可能是某个notify信号丢失或者join的线程一直没结束。注意Thread.sleep()和Object.wait()都会让线程暂停但sleep不释放锁wait释放锁。这个区别在面试中出现的频率极高答错基本会被判定为“没有实际并发编程经验”。2.2 线程中断机制为什么interrupt()不是强制停止很多初学者以为调用thread.interrupt()就能立刻停止线程实际上它只是设置了一个中断标志位。如果目标线程正在sleep、wait或join会抛出InterruptedException并清除中断标志如果目标线程在正常运行中断标志被设置但线程继续执行需要目标线程自己检查Thread.currentThread().isInterrupted()来决定是否退出。我见过一个真实的生产事故某服务在关闭时调用executorService.shutdownNow()期望立即停止所有任务但部分任务在循环中处理数据时没有检查中断状态导致关闭流程卡了将近两分钟才完成。后来在循环中加入了if (Thread.currentThread().isInterrupted()) break;才解决问题。这里有一个面试高频追问为什么InterruptedException抛出后要调用Thread.currentThread().interrupt()恢复中断状态因为抛出异常时 JVM 会清除中断标志如果上层调用栈需要感知中断信号就必须手动恢复。这个细节能答出来面试官会认为你真正写过健壮的并发代码。2.3 线程池状态与线程状态的联动关系线程池有自己的状态机RUNNING、SHUTDOWN、STOP、TIDYING、TERMINATED。面试中常问“线程池调用了shutdown()之后正在执行的任务和队列中的任务分别怎么处理”。标准答案是shutdown()将状态置为SHUTDOWN不再接受新任务但队列中的任务会继续执行完shutdownNow()将状态置为STOP尝试中断正在执行的任务并清空队列。但实际场景中有一个坑如果任务不响应中断shutdownNow()也无法真正停止它。所以在线程池中运行的任务必须对中断信号保持敏感。另外线程池的allowCoreThreadTimeOut参数如果设为true核心线程在空闲超过keepAliveTime后也会被回收这个参数在流量波动大的服务中很有用但默认是false很多人不知道可以改。3.synchronized与ReentrantLock的选型逻辑不只是性能差异3.1 锁升级过程偏向锁、轻量级锁、重量级锁的触发条件synchronized在 JDK 1.6 之后引入了锁升级机制顺序是无锁 → 偏向锁 → 轻量级锁 → 重量级锁。偏向锁的核心思想是“同一个线程反复进入同步块时不需要任何同步操作”只在第一次进入时通过 CAS 将线程 ID 记录在对象头 Mark Word 中。轻量级锁则是当有第二个线程尝试获取锁时偏向锁升级为轻量级锁通过 CAS 自旋尝试获取。如果自旋失败或竞争加剧才升级为重量级锁依赖操作系统的互斥量实现。面试中常问“偏向锁在什么情况下会撤销”。触发条件包括另一个线程尝试获取锁、调用了Object.hashCode()因为偏向锁的 Mark Word 没有足够空间存储哈希码、以及批量撤销场景。实际开发中如果你的同步块竞争非常频繁偏向锁反而会带来额外的撤销开销可以考虑用-XX:-UseBiasedLocking关闭。不过从 JDK 15 开始偏向锁已经被默认禁用并逐步移除这个变化在面试中如果主动提到会是加分项。3.2ReentrantLock的公平锁与非公平锁吞吐量与延迟的权衡ReentrantLock支持公平锁和非公平锁两种模式通过构造函数参数指定。公平锁按照线程请求锁的顺序分配锁保证不会出现饥饿非公平锁允许“插队”新请求的线程可能比队列中等待的线程先拿到锁。默认是非公平锁因为它的吞吐量通常更高——减少线程上下文切换的次数。但这里有一个容易被忽略的细节非公平锁并不等于完全无序。在ReentrantLock的实现中非公平锁在尝试获取锁时如果当前锁空闲会直接 CAS 抢占如果锁被占用还是会进入队列排队。所以“插队”只发生在锁刚好释放的瞬间。实际选型时如果业务对延迟敏感且线程数不多公平锁可以避免个别线程长时间等待如果追求最大吞吐量非公平锁是更好的选择。3.3 条件变量Condition与wait/notify的对比ReentrantLock可以通过newCondition()创建多个条件变量而synchronized只能配合一个监视器锁使用wait/notify。这个差异在实现生产者-消费者模型时非常关键使用synchronized时生产者和消费者等待在同一个监视器上notifyAll()会唤醒所有线程包括不该被唤醒的那些而使用ReentrantLock可以创建notFull和notEmpty两个条件精确唤醒需要唤醒的线程。我实测过一个场景在队列深度为 100 的阻塞队列中使用synchronized notifyAll的版本在消费者数量较多时无效唤醒比例超过 60%换成ReentrantLock 两个 Condition后无效唤醒降到 5% 以下。这个数据在面试中如果主动提出来能直接证明你有性能优化的实战经验。4.volatile、CAS 与原子类无锁编程的基石与陷阱4.1volatile的两层语义可见性与禁止重排序volatile关键字有两个核心作用保证变量的可见性一个线程的修改对其他线程立即可见和禁止指令重排序。可见性是通过在写操作后插入StoreLoad屏障、在读操作前插入LoadLoad屏障实现的。禁止重排序则体现在经典的“双重检查锁定”单例模式中——instance new Singleton()这行代码实际上分为三步分配内存、初始化对象、将引用指向内存地址。如果没有volatile步骤 2 和 3 可能重排序导致另一个线程拿到一个未初始化完成的对象。面试中常问“volatile能保证原子性吗”答案是不能。i这种复合操作即使变量声明为volatile也存在线程安全问题因为读取、加一、写入三个步骤之间可能被其他线程插入。要保证原子性需要用AtomicInteger或加锁。4.2 CAS 的 ABA 问题与AtomicStampedReference的解法CASCompare-And-Swap是无锁编程的核心操作它包含三个值内存地址、预期原值、新值。只有当内存中的值与预期原值相等时才将内存值更新为新值。CAS 的经典问题是 ABA线程 1 读取值为 A线程 2 将值改为 B 再改回 A线程 1 的 CAS 操作仍然成功但它不知道中间发生过变化。在大多数场景下 ABA 不影响正确性但在链表、栈等数据结构中可能导致问题。解决方案是使用AtomicStampedReference它同时维护一个版本号每次修改版本号递增。面试中如果被问到“CAS 有什么缺点”除了 ABA还要提到自旋开销竞争激烈时 CPU 空转和只能保证单个变量的原子性多个变量需要AtomicReference封装成对象。4.3LongAdder与AtomicLong的性能差异及适用场景AtomicLong在高并发下所有线程竞争同一个 CAS 操作失败后自旋重试性能会急剧下降。LongAdder采用分段累加的思想内部维护一个Cell数组不同线程哈希到不同的 Cell 上累加最终求和时再把所有 Cell 的值加起来。在写多读少的场景下LongAdder的吞吐量可以比AtomicLong高一个数量级。但LongAdder也有代价求和操作不是常数时间需要遍历所有 Cell而且在并发更新时求和结果可能不是精确的瞬时值。所以如果是需要精确计数的场景比如序列号生成还是得用AtomicLong。我一般建议统计类指标用LongAdder状态标记和需要精确值的场景用AtomicLong。5. 并发工具类的实战避坑从CountDownLatch到ThreadLocal5.1CountDownLatch与CyclicBarrier的本质区别CountDownLatch是一次性的计数器减到零后不能重置CyclicBarrier是可循环使用的所有线程到达屏障后会重置并可以继续下一轮。这个区别在面试中几乎必问但更重要的是理解它们的使用场景差异CountDownLatch通常用于“一个线程等待多个线程完成”比如主线程等待所有子任务初始化完成CyclicBarrier用于“多个线程互相等待”比如多阶段计算中每一阶段结束后需要同步。有一个容易踩的坑CountDownLatch的await()方法如果等待的线程被中断会抛出InterruptedException但计数器不会重置。如果在线程池中复用CountDownLatch必须确保每次使用前重新创建实例否则第二次await()会立即返回。5.2ThreadLocal的内存泄漏弱引用与remove()的必要性ThreadLocal的内部实现是每个Thread维护一个ThreadLocalMapkey 是ThreadLocal对象的弱引用value 是强引用。当ThreadLocal对象被回收后key 变成 null但 value 仍然被Thread强引用导致无法回收——这就是内存泄漏的根源。在线程池场景下线程长期存活泄漏的 value 会越积越多。解决方案是在使用完ThreadLocal后必须调用remove()。我见过一个线上问题某服务用ThreadLocal存储用户上下文但在请求结束时忘记清理运行几天后 Full GC 频繁堆内存中大量ThreadLocalMap$Entry对象。后来在拦截器的afterCompletion中统一调用remove()才解决。面试中如果被问到“ThreadLocal为什么会内存泄漏”能把弱引用和ThreadLocalMap的结构画出来解释基本就稳了。5.3Semaphore的公平性与限流实践Semaphore用于控制同时访问特定资源的线程数量常用于限流。它同样支持公平和非公平模式。在限流场景中如果使用非公平模式可能会出现某些线程长时间抢不到许可的情况公平模式则按照请求顺序分配许可但吞吐量会低一些。实际做接口限流时我通常用Semaphore配合tryAcquire(timeout)来实现带超时的限流如果规定时间内拿不到许可直接返回限流响应避免线程无限等待。这个模式比简单的acquire()更健壮因为后者在许可耗尽时会一直阻塞。6. 并发面试中的“追问链”如何从一道题延伸到整个知识体系6.1 从“线程池参数”到“拒绝策略”再到“监控指标”面试官问“线程池的核心参数有哪些”这只是入口。接着会问“如果队列满了且线程数达到最大值会发生什么”——考察拒绝策略。再接着会问“你怎么监控线程池的运行状态”——考察getActiveCount()、getQueue().size()、getCompletedTaskCount()等指标的使用。最后可能问“如果线上线程池任务堆积你怎么排查”——这时候需要结合jstack、日志和监控数据来回答。我建议准备面试时把每个核心知识点都画成一张“追问树”。比如线程池的根节点下挂参数含义、队列类型、拒绝策略、状态流转、监控方法、常见故障。这样被追问时不会慌因为你知道面试官只是在沿着树往下走。6.2 从“死锁”到“排查工具”再到“预防策略”死锁是并发面试的经典题。初级问题“死锁的四个必要条件是什么”中级问题“怎么用jstack排查死锁”高级问题“除了加锁顺序还有哪些预防死锁的策略”排查死锁时jstack的输出会直接告诉你“Found one Java-level deadlock”并显示线程持有的锁和等待的锁。预防策略包括固定加锁顺序、使用tryLock(timeout)避免无限等待、减少锁的持有时间、用无锁数据结构替代锁。实际开发中我倾向于用tryLock加超时来替代synchronized因为前者在获取不到锁时可以走降级逻辑而不是傻等。这个经验在面试中说出来比背概念更有说服力。6.3 从“内存模型”到“伪共享”再到“性能优化”JMMJava 内存模型是并发的基础面试中常问happens-before规则。但真正拉开差距的是“伪共享”问题当两个线程分别修改同一个缓存行中的不同变量时会导致缓存行频繁失效性能急剧下降。解决方案是用Contended注解或手动填充字节来让变量独占缓存行。我实测过一个计数器场景两个线程分别递增两个相邻的volatile long变量耗时是递增两个相隔 64 字节的变量的 3 倍以上。这个数据在面试中如果主动提出来面试官会立刻知道你不仅懂理论还做过性能调优。7. 我整理的一套“每日一面”执行方案如果你打算用“每日一面”的方式系统梳理并发基础我建议按下面的节奏来这是我带过几个新人后验证有效的方案第一周聚焦“线程基础与状态机”每天一个主题线程创建方式对比、六种状态转换、中断机制、ThreadLocal原理、线程池参数。每天花 30 分钟看概念30 分钟写代码验证30 分钟模拟面试自问自答。第二周聚焦“锁与同步”主题包括synchronized锁升级、ReentrantLock源码、Condition使用、AQS原理、死锁排查。这一周的重点是画图——把锁的获取释放流程画成流程图把AQS的state和队列画出来。第三周聚焦“无锁与工具类”主题包括volatile内存语义、CAS 与 ABA、AtomicInteger与LongAdder、CountDownLatch与CyclicBarrier、Semaphore限流。第四周做综合模拟找朋友或者自己对着镜子把前三周的知识点串起来讲一遍重点练习“被追问三层”的场景。每道题准备一个实际案例比如“我在项目中用LongAdder替代AtomicLong后 QPS 提升了多少”。提示每天结束后用一句话总结“今天这个知识点如果面试官追问我最怕被问到什么”然后针对这个薄弱点补漏。这个方法比盲目刷题效率高得多。最后分享一个我自己的习惯每次面试结束后不管过没过立刻把被问到的并发问题记下来标注自己答得不好的地方。积累三个月后你会发现高频考点其实就那么二三十个但每个考点都能延伸出一张知识网。把这张网织密了并发基础这块就不会再是面试的短板。
阅读完成 · 觉得有帮助?