ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Java并发与多线程面试32个核心考点系统拆解

Java并发与多线程面试32个核心考点系统拆解 1. 为什么并发与多线程是Java面试的“必争之地”但凡经历过几场像样的Java后端面试你一定会发现一个规律并发与多线程的问题几乎从不缺席。不管是初级岗还是高级岗面试官总会在这个领域里挑几个问题来探你的底。原因很直接——并发编程能力直接反映了一个工程师对JVM、操作系统、内存模型乃至系统架构的理解深度这不是靠背八股文就能糊弄过去的。我在过去几年里帮不少朋友做过模拟面试也跟不少面试官聊过他们出题的思路。一个共识是并发题答得好不好基本能判断出这个人的技术天花板在哪。初级候选人能说清楚synchronized和Lock的区别就算及格中级候选人得能讲明白AQS的原理、线程池的参数怎么调高级候选人则要能结合实际场景分析高并发下的性能瓶颈和解决方案。这篇文章的定位很明确把Java并发与多线程领域最常考的32个考点做一次系统性的拆解。不是那种干巴巴的“面试题标准答案”罗列而是从面试官想考什么、你应该怎么答、实际工作中怎么用三个维度来展开。适合正在准备面试的Java工程师也适合想系统梳理并发知识体系的朋友。文章会比较长建议收藏后分块阅读每个考点我都会尽量给出代码示例和实际场景分析。需要提前说明的是并发编程本身是一个需要反复实践才能掌握的领域。光看文章不够你得动手写、动手调、动手踩坑。我在文中会穿插一些自己踩过的坑和实操心得希望能帮你少走弯路。2. 线程基础与生命周期面试开场的高频区2.1 线程创建的四种方式及选择逻辑面试官问“怎么创建线程”的时候如果你只回答Thread和Runnable虽然不算错但会显得知识面偏窄。完整的回答应该覆盖四种方式并且能说清楚各自的适用场景。继承Thread类是最直观的方式但实际项目中几乎不用。原因很简单Java是单继承的你继承了Thread就没法继承别的类了灵活性太差。而且把线程逻辑和业务逻辑耦合在一起不符合职责单一原则。实现Runnable接口是更常见的做法它把“任务”和“执行机制”解耦了。你可以把同一个Runnable实例交给不同的线程池去执行也可以交给Thread去执行。这是面向接口编程的体现。实现Callable接口配合FutureTask解决了Runnable不能返回结果、不能抛异常的痛点。实际项目中如果你需要异步执行一个任务并且拿到返回值这就是标准做法。使用线程池是生产环境中最推荐的方式。手动new Thread()的问题在于线程创建和销毁的开销大、无法统一管理、容易导致线程数失控。线程池通过复用线程、控制并发数、提供任务队列和拒绝策略把这些问题都解决了。// 方式一继承Thread class MyThread extends Thread { Override public void run() { System.out.println(Thread方式执行); } } // 方式二实现Runnable Runnable task () - System.out.println(Runnable方式执行); // 方式三实现Callable FutureTask CallableString callable () - 返回结果; FutureTaskString futureTask new FutureTask(callable); new Thread(futureTask).start(); String result futureTask.get(); // 阻塞获取结果 // 方式四线程池推荐 ExecutorService pool Executors.newFixedThreadPool(4); pool.execute(() - System.out.println(线程池执行Runnable)); FutureString future pool.submit(() - 线程池执行Callable);注意阿里开发规范里明确禁止使用Executors直接创建线程池因为newFixedThreadPool和newSingleThreadExecutor的队列长度是Integer.MAX_VALUE可能堆积大量任务导致OOMnewCachedThreadPool的最大线程数是Integer.MAX_VALUE可能创建大量线程导致OOM。正确做法是通过ThreadPoolExecutor手动指定参数。2.2 线程的六种状态与转换条件Java线程的状态定义在Thread.State枚举里一共六种NEW、RUNNABLE、BLOCKED、WAITING、TIMED_WAITING、TERMINATED。面试官经常让你画状态转换图或者问“sleep和wait的区别”这类问题。NEW是线程对象创建了但还没调用start()。RUNNABLE包括操作系统层面的“就绪”和“运行”两种状态JVM不区分。BLOCKED是等待获取synchronized锁。WAITING是调用了Object.wait()、Thread.join()或LockSupport.park()后进入的无期限等待。TIMED_WAITING是带了超时参数的等待比如Thread.sleep(1000)。TERMINATED是线程执行完毕。一个常见的坑是sleep不释放锁wait释放锁。sleep是Thread的静态方法可以在任何地方调用wait是Object的方法必须在synchronized块内调用。理解这个区别的关键在于wait的设计目的是让线程在等待某个条件时释放锁好让其他线程有机会修改条件而sleep只是单纯地暂停执行不涉及锁的交互。2.3 线程中断机制的正确使用姿势很多开发者对线程中断的理解停留在“interrupt()就是停止线程”这是不对的。Java的中断是一种协作机制调用interrupt()只是设置了一个标志位具体怎么响应由目标线程自己决定。如果线程在sleep、wait、join等阻塞方法中中断会抛出InterruptedException并清除中断标志。如果线程在正常运行中断只是把标志位设为true需要线程自己通过isInterrupted()检查并决定是否退出。Thread t new Thread(() - { while (!Thread.currentThread().isInterrupted()) { // 执行任务 } System.out.println(线程被中断准备退出); }); t.start(); t.interrupt();实操心得捕获InterruptedException后要么继续向上抛要么重新调用Thread.currentThread().interrupt()恢复中断状态。千万不要捕获后什么都不做这会导致上层代码无法感知中断信号。3. synchronized与锁机制从偏向锁到重量级锁的完整链路3.1 synchronized的三种使用方式与锁对象synchronized可以修饰实例方法、静态方法和代码块。修饰实例方法时锁的是当前对象实例this修饰静态方法时锁的是类的Class对象修饰代码块时可以指定锁对象。面试官经常问的一个问题是“两个线程同时访问同一个对象的两个synchronized方法会互斥吗”答案是会因为锁的是同一个对象。如果一个是实例方法一个是静态方法则不会互斥因为锁对象不同。public class SyncDemo { // 锁的是当前实例 public synchronized void methodA() { } // 锁的是SyncDemo.class public static synchronized void methodB() { } // 锁的是指定的lock对象 private final Object lock new Object(); public void methodC() { synchronized (lock) { } } }3.2 锁升级过程偏向锁、轻量级锁、重量级锁这是面试中的高频深水区。Java 6之后对synchronized做了大量优化引入了锁升级机制。理解这个过程能体现你对JVM底层实现的掌握程度。偏向锁针对的是“同一个线程反复进入同一个同步块”的场景。第一次进入时JVM把线程ID记录在对象头的Mark Word里之后该线程再进入时只需检查ID是否匹配不需要任何原子操作。轻量级锁针对的是“多个线程交替进入同步块”的场景通过CAS操作尝试获取锁失败则自旋等待。重量级锁针对的是“多个线程同时竞争”的场景依赖操作系统的互斥量实现未获取到锁的线程会被挂起。锁升级是单向的一旦升级到重量级锁就不会降级。这个设计的逻辑是JVM认为一旦发生了真正的竞争后续继续竞争的概率很高降级反而增加开销。注意偏向锁在JDK 15之后默认关闭JDK 18之后被废弃。面试时如果提到偏向锁可以补充说明这个变化会显得你关注了JDK的演进。3.3 锁消除与锁粗化JIT编译器的两个优化手段锁消除是指JIT编译器在运行时发现某个锁对象不可能被其他线程访问就会把同步操作消除掉。最典型的例子是StringBuffer的append方法虽然它内部用了synchronized但如果StringBuffer是局部变量JIT就会把锁消除。锁粗化是指JIT发现一连串操作都对同一个对象反复加锁解锁就会把锁的范围扩大到整个操作序列。比如在循环体内反复appendJIT会把锁提到循环外面。这两个优化说明了一个道理写代码时不必过度担心synchronized的性能问题JIT比你想的聪明。但在高竞争场景下还是应该考虑使用Lock或Atomic类来替代。4. volatile与Java内存模型可见性与有序性的基石4.1 JMM的核心概念主内存与工作内存Java内存模型JMM规定所有变量存储在主内存中每个线程有自己的工作内存。线程对变量的操作必须在工作内存中进行不能直接操作主内存。线程间变量的传递需要通过主内存来完成。这个模型导致了一个经典问题线程A修改了变量flag线程B可能看不到。原因是线程A的修改还在自己的工作内存里没有同步到主内存或者线程B一直从自己的工作内存里读没有去主内存刷新。4.2 volatile如何保证可见性和有序性volatile通过两个机制来工作。可见性方面写volatile变量时JVM会把工作内存中的值刷新到主内存并让其他线程的工作内存中该变量的缓存失效读volatile变量时JVM会从主内存重新读取最新值。有序性方面volatile通过插入内存屏障来禁止指令重排序。具体来说写操作前插入StoreStore屏障写操作后插入StoreLoad屏障读操作前插入LoadLoad屏障读操作后插入LoadStore屏障。public class VolatileDemo { private volatile boolean flag false; public void writer() { flag true; // 写volatile保证之前的操作对后续读可见 } public void reader() { if (flag) { // 读volatile保证能看到之前的写 // 执行逻辑 } } }4.3 volatile不保证原子性经典i问题这是面试必问的陷阱题。volatile int i; i;这个操作不是原子的因为它包含三步读取i的值、加1、写回i。多个线程同时执行时可能两个线程都读到相同的值然后各自加1写回导致结果少了一次。解决方案有三种用synchronized加锁、用AtomicInteger的incrementAndGet()、用LongAdder高并发下性能更好。实操心得volatile最适合的场景是“一个线程写、多个线程读”的状态标志位。如果需要复合操作的原子性老老实实用锁或原子类别想着用volatile取巧。4.4 happens-before规则理解可见性的钥匙happens-before是JMM定义的一套规则用来描述两个操作之间的可见性关系。如果操作A happens-before 操作B那么A的结果对B可见。主要规则包括程序顺序规则同一线程内前面的操作happens-before后面的、监视器锁规则解锁happens-before后续加锁、volatile变量规则写happens-before后续读、传递性规则等。理解happens-before的意义在于它让你不需要死记硬背各种同步机制的细节而是从“可见性保证”的角度去分析代码是否正确。5. CAS与原子类无锁并发的核心武器5.1 CAS的原理与ABA问题CASCompare And Swap是乐观锁的核心实现包含三个操作数内存位置V、预期值A、新值B。当且仅当V的当前值等于A时才将V更新为B否则不做任何操作。整个操作由CPU的cmpxchg指令保证原子性。CAS的经典问题是ABA问题线程1读取值为A线程2把值改成B又改回A线程1的CAS操作仍然成功但它不知道中间发生过变化。解决方案是加版本号AtomicStampedReference就是干这个的。AtomicStampedReferenceInteger ref new AtomicStampedReference(100, 0); int stamp ref.getStamp(); ref.compareAndSet(100, 200, stamp, stamp 1);5.2 原子类的分类与选型java.util.concurrent.atomic包下的原子类可以分为五组基本类型AtomicInteger、AtomicLong、AtomicBoolean、数组类型AtomicIntegerArray等、引用类型AtomicReference等、字段更新器AtomicIntegerFieldUpdater等、累加器LongAdder、DoubleAdder。LongAdder是JDK 8引入的在高并发场景下比AtomicLong性能好很多。原理是LongAdder内部维护了一个Cell数组不同线程分散到不同的Cell上累加最后求和。代价是读取时可能不是精确值适合统计场景。5.3 CAS的自旋开销与应对策略CAS失败后会自旋重试如果竞争激烈大量线程同时自旋会浪费CPU。应对策略包括限制自旋次数、使用退让策略Thread.yield()、改用锁。JDK的AtomicInteger在竞争激烈时性能会下降这时候LongAdder是更好的选择。6. AQS与Lock体系面试区分度的分水岭6.1 AQS的核心设计state CLH队列AQSAbstractQueuedSynchronizer是ReentrantLock、Semaphore、CountDownLatch等同步器的底层框架。它的核心是一个volatile int state变量和一个FIFO的双向队列CLH队列的变体。state的含义由子类定义ReentrantLock中表示重入次数Semaphore中表示可用许可数CountDownLatch中表示剩余计数。获取锁失败的线程会被包装成Node节点加入队列然后通过LockSupport.park()挂起。释放锁时会唤醒队列中的后继节点。6.2 ReentrantLock与synchronized的对比选型维度synchronizedReentrantLock实现层面JVM内置JDK代码实现锁获取自动手动lock/unlock公平性非公平可选公平/非公平条件变量单一多个Condition中断响应不支持支持lockInterruptibly超时获取不支持支持tryLock(timeout)选型逻辑如果只需要基本的互斥用synchronized就够了代码简洁不易出错。如果需要公平锁、超时获取、中断响应或多个条件变量才用ReentrantLock。6.3 Condition的await/signal机制Condition把Object的wait/notify机制拆分了一个Lock可以创建多个Condition实现更精细的线程等待/唤醒控制。经典应用是阻塞队列notFull条件让生产者等待notEmpty条件让消费者等待。ReentrantLock lock new ReentrantLock(); Condition notFull lock.newCondition(); Condition notEmpty lock.newCondition(); // 生产者 lock.lock(); try { while (queue.size() capacity) { notFull.await(); } queue.add(item); notEmpty.signal(); } finally { lock.unlock(); }注意await()必须放在while循环里而不是if里因为被唤醒后条件可能又不满足了虚假唤醒。6.4 ReadWriteLock与StampedLockReadWriteLock允许多个线程同时读但写时独占。适合读多写少的场景。但它的读锁和写锁是互斥的而且写锁可以降级为读锁读锁不能升级为写锁。StampedLock是JDK 8引入的支持乐观读。乐观读不加锁读完后通过validate检查是否有写操作发生。如果验证失败再升级为悲观读。在读多写极少的场景下StampedLock的性能比ReadWriteLock好很多。7. 线程池高并发场景的核心组件7.1 ThreadPoolExecutor的七个核心参数这是面试必问的题目七个参数必须能脱口而出corePoolSize核心线程数、maximumPoolSize最大线程数、keepAliveTime空闲线程存活时间、unit时间单位、workQueue任务队列、threadFactory线程工厂、handler拒绝策略。任务提交后的处理流程如果当前线程数小于核心线程数创建新线程执行如果核心线程都在忙任务入队如果队列满了且线程数小于最大线程数创建新线程如果线程数达到最大且队列满了执行拒绝策略。7.2 线程数怎么定CPU密集 vs IO密集这是一个没有标准答案但面试官爱问的问题。核心逻辑是CPU密集型任务线程数设为CPU核数1多一个线程是为了在某个线程偶尔缺页中断时顶上。IO密集型任务线程数设为CPU核数 × (1 等待时间/计算时间)因为线程大部分时间在等IO需要更多线程来提高CPU利用率。实际项目中更靠谱的做法是通过压测来确定最优线程数。先设一个初始值然后逐步调整观察QPS和响应时间的变化。7.3 四种拒绝策略的适用场景AbortPolicy是默认策略直接抛RejectedExecutionException。CallerRunsPolicy让提交任务的线程自己执行起到降速作用。DiscardPolicy直接丢弃不抛异常。DiscardOldestPolicy丢弃队列中最老的任务然后重试提交。选型建议核心业务用CallerRunsPolicy保证任务不丢非核心业务可以用DiscardOldestPolicy如果任务绝对不能丢需要自定义策略把任务持久化到数据库或消息队列。7.4 线程池的监控与动态调整生产环境必须对线程池做监控关键指标包括活跃线程数、队列大小、已完成任务数、拒绝任务数。可以通过ThreadPoolExecutor的getActiveCount()、getQueue().size()等方法获取。动态调整方面ThreadPoolExecutor提供了setCorePoolSize()和setMaximumPoolSize()方法可以在运行时修改。但队列容量不能动态修改需要自定义队列实现。8. 并发容器与工具类实际项目中的高频使用8.1 ConcurrentHashMap的演进与实现JDK 7的ConcurrentHashMap使用分段锁把数据分成多个Segment每个Segment独立加锁。JDK 8改为CAS synchronized锁的粒度细化到每个桶。当链表长度超过8且数组长度超过64时链表转为红黑树。面试常问的问题ConcurrentHashMap的size()怎么实现的JDK 8通过baseCount和CounterCell数组来统计类似LongAdder的思路。8.2 CopyOnWriteArrayList的适用场景CopyOnWriteArrayList在写操作时复制整个数组读操作不加锁。适合读多写极少的场景比如配置列表、监听器列表。缺点是写操作开销大且数据有延迟。8.3 BlockingQueue家族对比队列特点适用场景ArrayBlockingQueue数组实现有界固定容量场景LinkedBlockingQueue链表实现可选有界高吞吐场景SynchronousQueue不存储元素直接传递场景PriorityBlockingQueue优先级排序任务优先级场景DelayQueue延迟获取定时任务场景8.4 CountDownLatch、CyclicBarrier、Semaphore的区别CountDownLatch是一次性的主线程等待多个子任务完成。CyclicBarrier是可循环的一组线程互相等待到齐后继续。Semaphore控制同时访问的线程数用于限流。// CountDownLatch示例 CountDownLatch latch new CountDownLatch(3); for (int i 0; i 3; i) { new Thread(() - { // 执行任务 latch.countDown(); }).start(); } latch.await(); // 主线程等待9. 常见问题与排查技巧实录9.1 死锁的四个条件与排查方法死锁的四个必要条件互斥、持有并等待、不可剥夺、循环等待。破坏任意一个就能避免死锁。实际开发中最容易破坏的是“循环等待”通过给锁排序来保证所有线程按相同顺序获取锁。排查死锁的工具jstack打印线程栈会直接提示“Found one Java-level deadlock”jconsole的“检测死锁”按钮Arthas的thread -b命令。9.2 线程泄漏的常见原因线程泄漏是指线程池中的线程被阻塞或占用后无法释放。常见原因包括任务中调用了没有超时的阻塞方法、线程池的队列无限增长、ThreadLocal没有清理导致内存泄漏。排查方法通过jstack查看线程状态如果大量线程处于WAITING或TIMED_WAITING需要检查阻塞点。9.3 并发问题的排查速查表问题现象可能原因排查工具CPU飙升自旋过多、死循环top jstack响应变慢锁竞争激烈jstack看BLOCKED线程内存泄漏ThreadLocal未清理jmap MAT数据不一致缺少同步代码审查 压测线程池拒绝队列满、线程数不足监控指标实操心得并发问题最难的地方在于“偶现”。我的经验是先在代码里加日志记录关键操作的线程名和时间戳然后通过压测复现。如果复现不了用Arthas的watch命令监控方法调用往往能找到线索。9.4 压测中常见的并发陷阱压测时最容易踩的坑是压测工具本身的并发能力成为瓶颈。比如用单机JMeter压测高并发接口可能压测机的网络先扛不住了。解决方案是用分布式压测或者用wrk、Gatling等更高性能的工具。另一个坑是数据库连接池配置不当。应用线程池有200个线程数据库连接池只有10个连接结果大量线程在等数据库连接。线程池和连接池的大小需要匹配。10. 我在实际项目中的几点体会说了这么多考点最后聊几句实在的。并发编程这个东西光看面试题是学不会的。我的经验是先理解原理再动手写代码最后在生产环境里验证。原理层面JMM、happens-before、AQS这三块是基石值得反复琢磨。代码层面建议自己实现一个简单的线程池、一个基于AQS的锁写完之后对ReentrantLock的理解会上一个台阶。生产环境层面多关注线程池的监控指标遇到问题不要慌jstack和Arthas能解决90%的并发问题。还有一个容易被忽视的点并发问题往往不是孤立存在的。一个接口响应慢可能是线程池配置问题也可能是数据库锁竞争还可能是下游服务拖累。排查时要系统性思考不要只盯着一个点。面试中回答并发问题时我的建议是先给出结论再解释原理最后结合实际场景。比如问“volatile有什么用”你可以说“volatile保证可见性和有序性但不保证原子性。实际项目中我主要用它来做状态标志位比如用一个volatile boolean来控制线程的启停。如果需要原子性我会用AtomicInteger或加锁。”这样的回答既有深度又有实操感面试官会眼前一亮。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进