news 2026/9/8 7:10:15

Java并发编程全解析:从三性到锁、线程池与面试实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Java并发编程全解析:从三性到锁、线程池与面试实战

最近在面试别人时碰到一个挺典型的场景:简历上写着"精通并发编程",结果聊到volatile和synchronized的区别,答了"一个修饰变量一个修饰方法"就卡住了。再追问一句"那synchronized在JDK 1.6之后到底优化了什么",就开始背八股文了。这个现象其实不怪候选人,市面上讲并发的资料要么太散,要么直接甩源码,很少有人能把整条线串起来讲清楚。

这篇文章就是冲着"把并发编程从入门到面试这一条线彻底理顺"来的。我会从并发问题的根源开始讲,一路走到锁、AQS、线程池、并发容器这些面试重点,每个关键点都结合真实场景说明白"为什么这么设计"和"实际代码里怎么用"。不管是准备面试的Java开发,还是工作中要写并发代码、排查并发问题的工程师,这篇文章都能当一份反复翻阅的手册。

1. 面试官问并发,到底想听什么

很多人在面试前背了几十道并发八股文:synchronized和ReentrantLock的区别、volatile和AtomicInteger的区别、线程池的七大参数……但一被追问"为什么有这些问题""这些机制到底解决了什么根因",就露馅了。并发编程看似知识点零散,其实所有问题都源于同一个根源:多个线程同时访问共享资源时,如何保证结果正确

1.1 三性问题不是背概念,是要讲出根源

并发编程的三大核心问题——可见性、原子性、有序性,几乎每次面试都会被翻出来。但如果你只背"可见性是线程A改了值线程B看不见"这种结论,面试官只要追问一句"为什么会看不见"就麻烦了。

根源要追溯到计算机的存储体系。CPU的计算速度和内存读写速度差距太大,所以中间加了好几层缓存。现代多核CPU架构下,每个核心都有自己的L1、L2缓存,共享L3缓存和主内存。线程A在核心1上修改了一个变量的值,修改首先发生在核心1的缓存里,如果不写回主内存,线程B在核心2上读到的就是旧值——这就是可见性问题的硬件根源。

原子性问题则来自线程的上下文切换。Java里count++看起来是一条语句,编译后却是"读取 → 加1 → 写回"三步操作。线程可能在加1之后、写回之前被操作系统挂起,让出CPU,另一个线程读取到旧值,最终导致结果丢失更新。

有序性的根源是编译器和CPU为了提升性能做的指令重排。代码写的顺序不一定是实际执行的顺序,只要重排前后单线程语义一致,优化器就敢动手。但在多线程环境下,这种"看似没问题"的重排可能导致另一个线程观察到违背直觉的执行顺序。

这三个问题不是孤立的,它们经常叠加爆发。面试中如果能从硬件到JVM把这层根源讲清楚,就已经超过80%的候选人了。

1.2 JMM与happens-before:判断并发安全性的标尺

JMM(Java内存模型)是回答并发问题绕不开的基石。它规定了哪些情况下一个线程的写操作对另一个线程可见,核心就是happens-before原则。这套原则定义了Java并发编程的"因果关系":如果操作A happens-before操作B,那么A的执行结果对B可见,且A的执行顺序在B之前。

happens-before规则里有几条面试高频的:

  • 程序顺序规则:一个线程内,书写在前面的代码 happens-before 后面的代码。
  • 锁规则:解锁操作 happens-before 后续对同一把锁的加锁操作。
  • volatile规则:对volatile变量的写操作 happens-before 后续对这个变量的读操作。
  • 传递性:A happens-before B,B happens-before C,则A happens-before C。

实际排查并发Bug时,拿happens-before去套,就能判断你的代码到底有没有保证可见性。举一个实际例子,网上广为流传的双重检查锁单例模式:

public class Singleton { private static volatile Singleton instance; public static Singleton getInstance() { if (instance == null) { // 第一次检查 synchronized (Singleton.class) { if (instance == null) { // 第二次检查 instance = new Singleton(); // 问题点 } } } return instance; } }

如果instance不加volatile,new Singleton()这一步并不是原子的。JVM会先分配内存空间、初始化对象、把引用指向内存。指令重排后可能出现"引用指向了内存但对象还没初始化完成"的中间状态。线程A拿到半初始化对象,线程B第一次检查发现instance不为null直接返回,用的就是坏对象。加volatile的深层作用就是禁止这步重排,保证happens-before关系成立。

很多居然还在怀疑volatile在这里的作用,甚至有人会把instance的类型改成普通静态变量,然后信誓旦旦说"大部分情况没问题"。对,大部分情况确实没问题,但并发Bug的可怕之处就在于大部分情况下没问题,偶然出一次问题,线上半夜就炸了

2. synchronized的锁升级:从偏向锁到重量级的完整演化

synchronized是Java并发里最基础也最常被问到的关键字。面试一般有两个维度:一是问用法,二是问原理。但深度一点的面试官会直接问"JDK 1.6之后synchronized做了哪些优化",这背后是一场关于锁性能的进化史。

2.1 对象头与Mark Word:锁信息存在哪

要理解synchronized的实现机制,得先知道锁信息放在哪里。Java对象在内存中的布局分为三块:对象头、实例数据、对齐填充。对象头里包含Mark Word类型指针(数组还有数组长度),Mark Word里存的就是锁状态的关键信息。

Mark Word本身是一个可变长度的数据结构,根据锁状态复用存储空间。无锁状态下它存对象哈希码、年龄分代;偏向锁状态下存线程ID和偏向时间戳;轻量级锁状态下存指向栈中锁记录的指针;重量级锁状态下存指向监视器(Monitor)的指针。

这就是为什么面试问"synchronized锁的是谁"时,正确回答是"锁的是Java对象头中的Mark Word,而Monitor则通过对象的monitor指针关联"。

2.2 锁升级的完整过程

JDK 1.6对synchronized做了重大优化,引入了偏向锁、轻量级锁、重量级锁三条路径,锁只能升级不能降级。整个升级过程是性能权衡的产物:

偏向锁:无竞争场景下的极致优化。当线程第一次进入同步块时,JVM会把对象头中的偏向锁标记置为1,并在Mark Word里记录下当前线程的ID。之后这个线程再进入同一个同步块时,不需要做任何同步操作,直接执行即可。好处是单线程反复加锁的开销几乎为零。

轻量级锁:一旦出现第二个线程尝试获取偏向锁,偏向锁会撤销并升级为轻量级锁。轻量级锁通过CAS自旋来获取锁:线程在自己的栈帧中创建锁记录,然后把对象头中的Mark Word复制到锁记录里,再用CAS把对象头中的Mark Word改为指向自己栈中的锁记录。如果CAS成功,说明抢锁成功;失败则说明存在竞争。

这里有个面试常追问的细节:轻量级锁与重量级锁的本质区别。轻量级锁在没有实际竞争时避免了操作系统级线程阻塞和唤醒的开销,线程通过自旋忙等。但如果自旋超过一定次数(默认可能自适应调整)仍然拿不到锁,说明竞争激烈,就会膨胀成重量级锁,由操作系统通过Monitor实现线程的阻塞和唤醒。自旋本身也消耗CPU,所以自旋次数太多不如干脆阻塞划算,这个阈值由JVM自适应调节。

用法上,synchronized有三种加锁位置,我整理了一个表格方便对照:

加锁方式锁定的对象适用场景
修饰实例方法当前实例对象this保护实例状态
修饰静态方法当前类的Class对象保护类级别的共享状态
修饰代码块括号里指定的对象减小锁粒度,只锁关键代码

实际开发中,如果锁的是同一个类的静态方法和实例方法,它们之间不互斥,这个坑很多人踩过。因为一把锁在Class对象上,另一把在实例对象上,压根不是同一个对象你锁个什么呢?

2.3 synchronized的三种用法和面试回答要点

面试时synchronized常见的三种用法必须烂熟于心:修饰实例方法、修饰静态方法、修饰代码块。这三种方式锁定的对象完全不同,我在上表里已经做了对比。

还有一个面试必考:synchronized和ReentrantLock的区别。我会从四个维度回答:

  1. 实现机制:synchronized是JVM层面通过Monitor实现的,ReentrantLock是JDK基于AQS实现的。
  2. 功能特性:ReentrantLock支持公平锁、支持超时获取锁、支持多个Condition条件队列、支持可中断获取锁;synchronized只支持非公平锁,在JDK 1.6之后性能和ReentrantLock差距已经不大。
  3. 锁释放:synchronized自动释放;ReentrantLock需要手动lock/unlock,必须在finally里释放。
  4. 原理层次:synchronized有偏向锁、轻量级锁、重量级锁升级路径;ReentrantLock底层是通过CAS和LockSupport实现的。

记住这个回答结构,远比零散地背"ReentrantLock有tryLock"强得多。

3. volatile为什么能扛起可见性:内存屏障与JMM的实战理解

volatile可以说是Java并发里"最轻量"的同步机制,但也是被误解最深的关键字。很多新手以为volatile能保证原子性,这是个大坑。volatile保证两条:可见性禁止指令重排,完全不保证原子性。

3.1 内存屏障、缓存一致性协议与不可分割的操作

讲volatile的可见性,绕不开内存屏障。JVM在volatile变量的读写操作前后插入特定类型的内存屏障,防止CPU和编译器对指令进行重排,同时确保写操作的修改能立刻被其他线程看到。

但真正的底层功臣是CPU的缓存一致性协议(比如MESI协议)。当线程A修改一个volatile变量时,JVM发出一个lock前缀指令,这个指令会让线程A所在核心的缓存行失效或写回主内存,同时其他核心中缓存了该变量的缓存行会被标记为无效。其他线程再次读取时,发现自己缓存行失效,就强制从主内存重新加载。这个过程就是"一个线程写,其他线程立即可见"的硬件基础。

顺带说一句,volatile保证不了原子性这一点,经常成为面试题的切入点。假设多个线程同时执行volatile int count; count++,即便是volatile变量,count++依然不是原子操作。两个线程可能同时读到相同的旧值,各自加1后写回,最终只增加了1。要解决这个场景,应该用AtomicInteger或者synchronized。

3.2 从JMM层面理解volatile的happens-before

前面提过volatile的happens-before规则:对volatile变量的写操作 happens-before 后续对这个变量的读操作。这条规则的含义是,线程A在写volatile变量之前的所有操作,对于线程B在读取该volatile变量之后的操作都是可见的。

这意味着volatile不仅仅保证变量本身的可见性,还充当了一个内存栅栏,把之前的操作"推"给其他线程。这就是为什么双重检查锁单例中,instance加volatile之后,不仅变量本身不会被重排,instance的赋值操作之前的所有操作(比如对象初始化的字段赋值)也不会被重排到volatile写之后。

实际工作中有一个经验:volatile适用于"一个线程写、多个线程读"的场景,比如状态标志位。我曾经在一个实时数据采集系统里用volatile布尔变量做服务优雅关闭的标记:

public class DataCollector { private volatile boolean running = true; public void stop() { running = false; // 主线程调用 } public void run() { while (running) { // 工作线程读取 // 采集逻辑 } } }

这个场景下running是典型的"一写多读",用volatile足够了,不需要引入锁,性能几乎无损。判断是否该用volatile,就看是否满足"状态变量不依赖旧值"和"一写多读"这两个条件。

4. CAS的轻盈与AQS的厚重:无锁到锁框架的内功修炼

并发知识点里,CAS和AQS是分不开的一对。CAS是无锁编程的基础,AQS则是Java显式锁和大量并发工具的基石。面试到中高级岗位,这两个基本是必考。

4.1 CAS乐观锁:自旋的代价与ABA问题

CAS全称Compare And Swap(比较并交换),本质是一种乐观锁思想。它包含三个操作数:内存位置V、预期原值A、新值B。只有V上的当前值等于A时,才把V更新为B,否则不做任何操作,整个比较和交换是原子的(由CPU指令保证)。

Java里的AtomicInteger、AtomicLong等类就是基于CAS实现的。以AtomicInteger.incrementAndGet()为例,内部会进入一个自旋循环:读取当前值,计算新值,然后CAS尝试更新,失败就重试,直到成功为止。没有线程阻塞,所以并发不高场景下性能远优于synchronized。

CAS有两个经典考点:ABA问题自旋开销。ABA问题指线程A读到变量值为X,被挂起;线程B把变量改成Y又改回X;线程A继续执行CAS时发现还是X,认为没被修改过,就成功了。解决ABA问题可以用带版本号的AtomicStampedReference,每次修改带上版本号,比较时同时比较值和版本号。

自旋开销则是另一个角度。CAS失败后会一直自旋重试,如果竞争激烈,大量线程都在自旋,CPU占用率会飙升。高并发下的ConcurrentHashMap早期版本也是用分段锁而不是纯CAS,就是在权衡这个开销。所以"无锁一定比有锁快"是个伪命题,低并发下无锁有优势,高并发激烈竞争下有锁反而更稳定。

4.2 AQS的设计精髓:CLH队列、state状态和模板方法

AQS(AbstractQueuedSynchronizer)是JDK中几乎所有显式锁和同步工具的核心,ReentrantLock、Semaphore、CountDownLatch、ReentrantReadWriteLock全都构建在它之上。理解AQS,等于理解了半部JUC。

AQS的核心就三个东西:

volatile int state:同步状态。不同子类用法不同,ReentrantLock里表示持有锁的次数,Semaphore里表示剩余许可数,CountDownLatch里表示还需要等待的计数。

CLH双向队列:保存等待获取锁的线程。线程获取锁失败时,会被包装成Node节点加入队列尾部,通过LockSupport的park/unpark机制实现阻塞和唤醒。CLH队列没有用自旋等待,而是真实地阻塞线程,所以它能在竞争激烈时避免CPU空转。

模板方法设计:AQS定义了tryAcquiretryReleasetryAcquireSharedtryReleaseShared等钩子方法,由子类去实现具体的同步语义。比如ReentrantLock的公平锁和非公平锁,就是在tryAcquire实现里差一个hasQueuedPredecessors()判断。公平锁会先看队列里有没有前驱节点,有就排队;非公平锁直接尝试CAS抢锁,抢不到再排队。

下面是一个简化版的ReentrantLock获取锁和非公平锁对比,帮助理解这个设计:

// 非公平锁的tryAcquire final boolean nonfairTryAcquire(int acquires) { final Thread current = Thread.currentThread(); int c = getState(); if (c == 0) { // 锁是空闲状态 if (compareAndSetState(0, acquires)) { // 直接CAS抢锁 setExclusiveOwnerThread(current); return true; } } else if (current == getExclusiveOwnerThread()) { // 可重入 int nextc = c + acquires; if (nextc < 0) throw new Error("Maximum lock count exceeded"); setState(nextc); return true; } return false; }

面试时如果把AQS的三要素说出来,再把模板方法和ReentrantLock的非公平锁执行过程串一遍,基本就稳了。

4.3 可重入、可中断、公平与非公平:ReentrantLock的关键特性

ReentrantLock是最常用的AQS子类,几个关键特性面试也常问:

可重入:同一个线程可以多次获取同一把锁,state累加,释放时需要释放相同次数。可重入的实现是靠判断当前线程是否是持有锁线程来实现的,代码就在上面的tryAcquire里的else if分支。

可中断lock.lockInterruptibly()支持在等待锁的过程中响应中断。这个特性在业务里有实际价值:比如多个线程争同一把锁,某个线程被外部调用interrupt()后可以主动退出等待,避免业务卡死。

公平与非公平:公平锁保证先等待的线程先获取锁,非公平锁允许后来者插队。ReentrantLock默认非公平,因为非公平锁的吞吐量通常更高:刚释放锁的线程再次获取锁时还在同一个CPU核心上,缓存热,成本低。强一致性的业务场景才需要公平锁。

Condition条件队列:ReentrantLock可以创建多个Condition,实现类似"生产者消费者"的精确唤醒。相比synchronized的wait/notify只能整体通知,Condition可以做到"只唤醒读线程"或"只唤醒写线程"。这个特性在实际代码里很实用,比如实现一个有界阻塞队列。

5. 线程池调参:参数背后的计算逻辑和线上案例

线程池是Java并发里实战性最强的知识点。面试时可能问你七大参数、四种拒绝策略,但更高级的面试官会把问题落在"线上系统怎么调参"上。泛泛背参数意义不大,真正要理解的是每个参数背后的触发时机和计算逻辑。

5.1 七个参数与一次任务提交的完整旅程

ThreadPoolExecutor构造函数的七个参数:

参数含义触发逻辑
corePoolSize核心线程数线程数小于该值时,新任务直接创建线程
maximumPoolSize最大线程数线程数大于corePoolSize且队列满了,创建新线程直到该上限
keepAliveTime非核心线程空闲存活时间超过该时间回收非核心线程
TimeUnit存活时间单位配合keepAliveTime使用
workQueue阻塞队列核心线程全部忙时,任务先进队列
threadFactory线程工厂创建线程的工厂,可以自定义线程名
RejectedExecutionHandler拒绝策略线程满+队列满时触发

一次新任务提交,线程池的判断顺序是这样的:第一步判断核心线程数是否满,没满就创建核心线程执行;满了进第二步,任务丢进阻塞队列;队列也满了进第三步,创建非核心线程执行;线程数到了maximumPoolSize还是忙不过来,就触发拒绝策略。很多人忽略了一个细节:核心线程执行完任务后不会被回收,而是阻塞等待下一个任务。所以corePoolSize本质是线程池的保底资源。

这里的经验是,使用线程池一定要给线程命名。默认线程池创建的线程名是"pool-1-thread-1"这种,线上排查问题时根本分不清哪个线程在干嘛。用自定义ThreadFactory给线程取业务名,出问题时查日志一眼就能定位:

ThreadFactory namedThreadFactory = new ThreadFactory() { private final AtomicInteger seq = new AtomicInteger(1); @Override public Thread newThread(Runnable r) { Thread t = new Thread(r); t.setName("order-async-processor-" + seq.getAndIncrement()); t.setDaemon(false); return t; } };

5.2 核心线程数到底怎么算:CPU密集型与IO密集型

线程池调参最核心的问题是:核心线程数和最大线程数设多少?这必须结合任务类型才能回答。

CPU密集型任务:核心线程数设为CPU核心数 + 1就够了,或者CPU核心数 * 2也可以接受。CPU密集型任务一直在消耗CPU,线程数超过核心数太多没什么好处,反而增加上下文切换开销。

IO密集型任务:线程大部分时间阻塞在IO上,CPU很闲,可以多搞些线程来压榨CPU。经验公式是CPU核心数 * (1 + IO耗时/CPU耗时),更粗略的经验值是CPU核心数 * 2。如果任务里有很明显的等待(比如HTTP调用、数据库查询),线程数可以往大了调。

这里说一个实际案例。我之前做过一个订单消息推送服务,任务是往外调推送接口,平均耗时80ms是网络IO,而真正CPU计算只有2ms左右,比例大约40比1。按公式计算,4核机器可以开4 * (1 + 40) = 164个线程。但实际我只配了32个核心线程、64个最大线程,因为还要考虑下游推送服务的承受能力,以及操作系统上下文切换的开销。调优是一个权衡过程,不是硬套公式,最终要通过压测来验证。

5.3 队列选型、拒绝策略与优雅停机

队列选型同样有讲究。LinkedBlockingQueue是无界队列,任务不会被拒绝,但风险在于队列无限增长可能导致内存溢出;SynchronousQueue不存储任务,直接交给线程,适合"不缓存任务、立即执行"的场景;ArrayBlockingQueue有界队列最推荐,队列长度会根据业务承载能力做明确限定。

拒绝策略有四种:AbortPolicy(直接抛异常)、CallerRunsPolicy(提交任务的线程自己执行)、DiscardPolicy(丢弃不报错)、DiscardOldestPolicy(丢弃最老的任务)。线上生产环境我几乎不用默认的AbortPolicy,因为任务被拒会导致业务失败;CallerRunsPolicy更适合,提交任务的线程由调用方自行消化压力,起到天然限流的作用。

还有一个非常容易被忽视的坑:线程池的优雅停机。直接调用shutdownNow()会中断正在运行的任务,可能导致数据状态不一致。我处理过的方案是:先shutdown()阻止新任务提交,等待一段时间让在跑任务自然结束,超时后再shutdownNow()强制停止,同时记录被中断的任务日志,后续通过补偿机制重新处理。

executor.shutdown(); // 平缓关闭 try { if (!executor.awaitTermination(60, TimeUnit.SECONDS)) { executor.shutdownNow(); // 超时后强制关闭 } } catch (InterruptedException e) { executor.shutdownNow(); Thread.currentThread().interrupt(); }

这段代码就是线上通用做法。很多时候并发问题不是代码逻辑出错,而是资源没清理干净导致的下一次运行时数据错乱。

6. ThreadLocal的内存泄漏与并发容器的选型决策

ThreadLocal和并发容器都是面试爱问、实战常用的组件。这两个东西看起来简单,坑也不少。

6.1 ThreadLocal为什么会出现内存泄漏:弱引用与强引用链

ThreadLocal的使用场景很清晰:每个线程一个变量副本,互相隔离。最常见的就是SimpleDateFormat,它不是线程安全的,如果做成static的会被多线程并发调用出问题;每次new一个又太浪费。用ThreadLocal包一层,每个线程持有自己的实例,既安全又复用。

ThreadLocal的底层结构是每个Thread内部维护了一个ThreadLocalMap,key是ThreadLocal对象(弱引用),value是set进去的对象(强引用)。坑就在这里:key是弱引用,只要外部没有强引用指向ThreadLocal对象,GC时key就会被回收,但value还通过一条"Thread → ThreadLocalMap → Entry → value"的强引用链可达。如果线程一直存活(比如线程池里的线程),value就永远不会被回收,导致内存泄漏。

这也是为什么线程池里使用ThreadLocal要格外小心。线程池线程是复用的,一个任务往ThreadLocal里塞了值,下一个任务可能读到上一个任务留下的脏数据。正确做法是在finally块里主动remove:

ThreadLocal<Session> sessionHolder = new ThreadLocal<>(); try { sessionHolder.set(session); // 业务逻辑 } finally { sessionHolder.remove(); // 一定要清理 }

6.2 ConcurrentHashMap的演进:从分段锁到CAS+synchronized

并发容器里面试最常问的必然是ConcurrentHashMap。它有两个关键版本:JDK 1.7的分段锁,和JDK 1.8的CAS+synchronized。

JDK 1.7的ConcurrentHashMap把整个map分成若干个Segment(默认16个),每个Segment是一把可重入锁。写操作只锁对应的Segment,其他Segment不受影响,从而把锁竞争分散到16把锁上。锁粒度是Segment。

JDK 1.8放弃Segment,直接对桶数组中的每个节点(Node[]的每个桶位)使用CAS和synchronized实现并发控制。首先通过CAS尝试插入头节点,失败则对头节点加synchronized锁再处理。锁粒度从Segment细化到单个桶,并发能力进一步提升,同时synchronized经过锁升级优化后,性能已经足够好,实现也简洁了很多。

面试时问到ConcurrentHashMap,最好还能补一个细节:size()方法的实现。JDK 1.8不再像1.7那样先不加锁统计两次,如果两次一致就直接返回;而是维护一个baseCount,结合CounterCell数组进行累加,并发很高时把计数分散到不同的CounterCell上,最终汇总。这样大大减少了统计size时的锁竞争。

6.3 CopyOnWriteArrayList、阻塞队列与日常选型

除了ConcurrentHashMap,JUC包里还有几个容器值得记住,面试也可能被问到选型:

CopyOnWriteArrayList:读多写少的场景,写操作通过复制一个新数组来完成,读写分离。由于写时复制,它天然支持弱一致性迭代器,不会抛ConcurrentModificationException。代价是每次写操作都要复制整个底层数组,内存开销大。

LinkedBlockingQueue / ArrayBlockingQueue:线程池的workQueue就是这两个。它们都是BlockingQueue实现,支持take和put阻塞语义,常被用在生产者-消费者模型里。

ConcurrentLinkedQueue:无界非阻塞队列,基于CAS实现,适合高并发下对队列长度不敏感的场景。

选型逻辑一句话概括:读多写少用CopyOnWriteArrayList,高频读写共享Map用ConcurrentHashMap,生产者消费者用BlockingQueue,高吞吐无界队列场景用ConcurrentLinkedQueue。

7. 并发排查实战:一个真实死锁的定位过程

八股文背得再熟,线上出了问题不会排查也白搭。这里分享一个我之前遇到过的真实死锁案例,完整走一遍排查链路,这部分内容是面试官也很想听到"你现场怎么处理"的加分项。

7.1 现象:服务接口偶发超时,线程池任务堆积

当时是一个账务系统,对外提供批量转账接口。上线后隔几天就会出现一次接口大量超时,进程还在,但新请求全卡住。用top看CPU不高,用jstack看不到任何异常日志,服务就像"僵住"了一样。

这类现象的经典特征就是死锁或线程饥饿。服务没有挂,但所有业务线程都阻塞在某个点上,导致新任务无法处理。

7.2 排查链路:jstack加日志定位两把锁的交叉等待

排查第一步,先抓现场。用jstack <pid>导出线程快照,搜索deadlock关键字。Java的ThreadMXBean会主动检测死锁,jstack里如果存在死锁,会直接打印出Found one Java-level deadlock的信息。当时确实打印出来了,两个线程互相持有对方需要的锁,循环等待。

顺着线程栈往下看,发现A线程持有锁A,在等待锁B;B线程持有锁B,在等待锁A。两把锁来自两个不同的服务组件:锁A是数据库行锁,锁B是分布式缓存锁。业务代码里,一个在扣减账户余额事务内去调用缓存接口,另一个在更新缓存后反向查数据库账户,正好交叉。

定位之后,修复方案其实不难:调整加锁顺序,所有流程都先拿数据库锁再拿缓存锁,从代码层面破坏循环等待条件。同时给锁的获取加超时时间,避免无限等待。

7.3 生产经验:并发问题的预防与应急三板斧

死锁发生一次,影响可能很严重,所以预防比排查更重要。我的经验可以总结为三板斧:

第一,锁顺序统一。多个锁嵌套使用的场景,所有代码路径必须保持相同的加锁顺序。

第二,所有锁获取尽量带超时。比如用ReentrantLock的tryLock(timeout)而不是lock(),拿不到锁就先放弃,别赌对方会释放。

第三,建立线程池监控与告警。线上服务要监控线程池的任务队列长度、活跃线程数、拒绝次数,这些指标可以做成Grafana监控面板。队列长度持续增长说明处理速度跟不上生产速度,需要提前扩容或优化,不用等系统真的卡死才去排查。

排查工具方面,jstack是首选,但生产环境jstack一次会暂停JVM一小段时间,高流量下要谨慎。更好的做法是提前通过JMX或Arthas在线导出线程栈,或者用async-profiler做采样,对线上影响更小。

8. 从入门到面试通关:一份可直接执行的复习路线

最后这部分不做总结,直接给一份能落地的复习和执行清单。如果你正准备Java并发面试,按照下面这个顺序去准备,比零散刷八股文高效得多。

第一阶段(基础扫盲,约3天)

  • 掌握三性问题、synchronized三种用法、Java内存模型和happens-before规则。
  • 自己能画一遍线程状态转换图,并能说清WAITING和BLOCKED的区别(面试很喜欢问)。

第二阶段(内核原理,约5天)

  • 深入读透AQS源码,重点看ReentrantLock的lock、unlock、公平锁与非公平锁。
  • 掌握volatile和CAS的底层原理,知道JVM层面的内存屏障作用。
  • 这里建议动手写个小Demo:用synchronized、ReentrantLock、AtomicLong分别实现一个计数器,压测对比性能,体感会强很多。

第三阶段(实战工具,约3天)

  • 线程池七大参数、执行流程、四种拒绝策略,亲手封装一个自定义线程池工具类。
  • CompletableFuture的常用API,至少要会thenApply、thenAccept、thenCombine、exceptionally。
  • 并发容器重点看ConcurrentHashMap,知道1.7和1.8的实现差异。

第四阶段(排查能力,约2天)

  • 学会用jstack、Arthas排查死锁和线程阻塞,把上面的排查链路自己模拟一遍。
  • 理解ThreadLocal的内存泄漏原因和remove的正确用法。

准备过程中有一个重要的心态调整:不要试图背下所有源码,面试官问源码时,他要的是"你理解设计意图并能讲清楚关键流程",而不是逐行默写。把CLH队列是什么、state干什么、为什么这样设计这些"所以然"想明白,比源码背诵有效得多。

另外,面试如果被问到"你项目里哪里用到了并发编程",一定要拿真实案例说事。哪怕是一个最简单的线程池任务提交,能说出你当时怎么定的参数、为什么这样定、遇到过什么问题、怎么解决的,这段回答的质量就远高于"我们项目里用过线程池"一句话。

我把这几年面试候选人的经验和自己的踩坑经历都写进了这篇内容里,希望能帮你把Java并发这条线真正串起来。并发编程没有什么玄学,根子是计算机体系结构和JVM的内存模型,机制是无非就是锁、CAS、队列、状态这些基础组件的组合,想通一次,后面就是水和渠成的事。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/8 7:10:00

为 NAND 续命:页隔离技术如何让“坏块”重获新生

1. 引言&#xff1a;NAND 的寿命焦虑与坏块现实NAND Flash 是今天几乎所有电子设备存储的基础。从手机里的 UFS、电脑里的 SSD&#xff0c;到数据中心中的企业级盘&#xff0c;再到工业设备中的 eMMC&#xff0c;NAND 凭借高密度、低功耗和非易失性成为主流选择。然而&#xff…

作者头像 李华
网站建设 2026/9/8 7:09:52

SpringBoot学生选课管理系统:从业务设计到并发控制的完整实践

每年到了毕业设计选题季&#xff0c;技术社区里总会出现同一个问题&#xff1a;“Java 毕设选什么题好&#xff1f;”而“基于 SpringBoot 的学生选课管理系统”几乎是所有候选列表里的常客。乍一看这个题目有点老套——选课管理&#xff0c;网上源码一大把&#xff0c;还有什么…

作者头像 李华
网站建设 2026/9/8 7:08:36

离线工具箱实战指南:硬件检测、跑分烤机与系统优化全流程

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/8 7:06:59

AI Agent排障救星:结构化Trace机制深度实践

做AI Agent工程这两年&#xff0c;我有个越来越强烈的体会&#xff1a;Agent跑成功的时候&#xff0c;你根本不需要看日志&#xff1b;但Agent一旦跑失败&#xff0c;你大概率什么都查不到。上周我就经历了一次典型事故——一个数据聚合Agent跑了40分钟&#xff0c;调用了十几个…

作者头像 李华
网站建设 2026/9/8 7:05:47

绿色简历HTML5模板全解析:设计思路与打印技术实践

简介&#xff1a;这套以绿色为视觉主调的HTML5简历模板&#xff0c;专为需要在线上展示专业能力与个人经历的求职者准备&#xff0c;既能营造清新自然的个人形象&#xff0c;又能保持商务级的专业观感&#xff0c;尤其适合缺少前端设计经验的人群使用。模板基于响应式布局&…

作者头像 李华
网站建设 2026/9/8 7:04:07

AI语音合成与老照片修复:构建数字记忆档案的实用技术路线

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华