拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Java并发编程核心:线程状态、synchronized、volatile与JUC实战

Java并发编程核心:线程状态、synchronized、volatile与JUC实战

1. 为什么面试会先从“并发基础”开始卷

1.1 “八股”不是贬义词:它是面经体系的骨架

先说点题外话。很多人在牛客、脉脉上刷到“JUC 并发编程【八股篇】”这种标题,第一反应就是:又来了,又在背面试题。说实话,我以前也这么觉得,后来被工作毒打几轮之后,反而改观了。所谓“八股”,本质上是把高频考点整理成一套有标准答案的问答体系,它不能代表你真实写代码的水平,但它能代表你对某个领域的知识边界到底有没有建立起来。尤其是并发编程这种“平时遇不到、一遇到就是线上事故”的领域,八股反而是最低成本的入门索引。

如果完全抛开面试,纯粹从技术角度看,JUC(java.util.concurrent)是 Java 并发编程的核心工具库。它里面有锁、同步器、原子类、线程池、并发容器,基本涵盖了日常业务里 90% 的并发场景。面试官喜欢问 JUC,不是因为它难,而是因为它能透过几个问题看穿你到底只是会用 API,还是真懂底层原理。比如同样问“ConcurrentHashMap 为什么线程安全”,有人答“用了锁分段”,有人能答到 CAS、synchronized、红黑树、扩容细节,这就是差距。

我这篇内容想做的,就是把并发基础这一层彻底掰开。不光是列结论,还会尽量解释为什么是这个结论,以及面试现场你会怎么被连环追问。适合正在准备 Java 后端岗、或者被并发问题反复折磨的新人,也适合想把自己脑子里的并发知识重新梳理一遍的开发。

1.2 并发基础到底在考什么

并发基础的考点看起来很多,但归纳起来就三条线:

  • 第一条线:线程本身。进程和线程的区别、线程生命周期、线程创建方式、线程间通信。
  • 第二条线:三大特性。原子性、可见性、有序性。这是并发问题的根源,也是 synchronized 和 volatile 的设计动机。
  • 第三条线:JUC 的经典组件。AQS、ReentrantLock、CAS、并发工具类、线程池。

面试官往往不会直接问你“三大特性是什么”,而是会包装成场景题。比如“两个线程同时对 i++ 执行一万次,为什么结果不是 20000”,或者“为什么单例模式要用 volatile 修饰 instance”。这些问题的底层,全部指向同一套知识。所以我的建议是,背结论的同时一定要把底层链条补全:从 CPU 缓存、指令重排序,到 Java 内存模型,再到锁的实现机制,一条线串下来,你背起来也容易,面试时也经得住追问。

2. 并发基础第一关:线程与状态机

2.1 进程和线程别再混为一谈

很多新手把进程和线程的关系理解成“进程大、线程小”,这没错,但不够准确。放到 JVM 场景里,一次 Java 程序启动就是一个进程,它拥有独立的地址空间;线程是进程内部的执行单元,多个线程共享进程的内存空间,但每个线程有自己的程序计数器、虚拟机栈和本地方法栈。

我面试的时候特别喜欢追问一个点:线程比进程轻量,轻在哪?答案是上下文切换的成本。进程切换需要切换页表、刷新 TLB(快表),开销很大;线程切换主要保存和恢复寄存器状态和程序计数器,共享的地址空间不需要重新映射。但注意,线程创建和销毁的代价其实并不低,所以才需要线程池来复用。这里就埋了一个伏笔,后面聊线程池的时候会展开。

还有一对概念容易被搞混:并发和并行。并发是多个任务在同一时间段内交替执行,逻辑上同时;并行是多个任务在同一时刻真正同时执行,物理上同时。单核 CPU 上只能实现并发,多核 CPU 上才谈得上并行。很多面试题里提到的“并发编程安全性”,其实即使单核也会出问题,因为线程调度随时可能发生,一个线程执行到一半被切走,另一个线程看到的数据状态就是中间状态。

2.2 Java 线程的生命周期

Java 线程的六种状态是面试高频题。NEW、RUNNABLE、BLOCKED、WAITING、TIMED_WAITING、TERMINATED。

关键在于 RUNNABLE 这个状态,它把“就绪”和“运行中”合并了。只要线程能被调度器执行,不管它是不是正在 CPU 上跑,都是 RUNNABLE。其他状态就好理解了:

  • BLOCKED:等待进入 synchronized 代码块或方法,因为锁被别人持有。
  • WAITING:调用了 Object.wait()、Thread.join()、LockSupport.park() 等方法,无限期等待。
  • TIMED_WAITING:调用了 Thread.sleep(long)、Object.wait(long)、LockSupport.parkNanos() 等,有限期等待。
  • TERMINATED:线程执行完毕或者异常退出。

面试官常在这里挖一个细节:BLOCKED 和 WAITING 怎么区分?最直接的口径是,BLOCKED 是拿不到锁被挡在门外,WAITING 是主动放弃了 CPU、等待别人唤醒。代码里如果只用 synchronized,你大概率只会见到 BLOCKED;用了 Lock 和 Condition 之后,等锁的时候是 WAITING,因为 Lock 基于 AQS 的 LockSupport.park。

2.3 最小知识:原子性、可见性、有序性

这是并发领域绕不开的三座大山。

原子性:一个操作或者多个操作要么全部执行且不会被中断,要么全部不执行。i++ 在字节码层面是 getfield、iconst_1、iadd、putfield 四条指令,所以它不具备原子性。

可见性:一个线程修改了共享变量,其他线程能不能立刻看到。由于 CPU 缓存的存在,线程修改可能先写到自己工作内存,还没刷新到主内存,其他线程就读到旧值了。

有序性:程序执行时,编译器和处理器可能对指令进行重排序,但重排序会遵守 as-if-serial 语义,也就是单线程内结果不变。到了多线程环境,重排序可能导致意想不到的现象。

为什么面试必考这三个特性?因为后面所有的并发工具都可以归到这三件事上。volatile 解决可见性和有序性,但不能解决原子性;synchronized 三个都管;Lock 和原子类也各有侧重。你把这三座山立住了,后面全都是它们的衍生题。

3. 基础同步手段:synchronized 与 volatile 的底层视角

3.1 synchronized 真的“重”吗

老八股喜欢讲“synchronized 是重量级锁”,这句话现在要修正了。JDK 1.6 之后引入了锁升级机制,synchronized 的路径是:无锁 → 偏向锁 → 轻量级锁 → 重量级锁。也就是说,大部分场景下它并不会直接上重量级。

  • 偏向锁:只有一个线程反复进入临界区时,线程 ID 写进对象头 Mark Word,后续进入无需额外开销。
  • 轻量级锁:有第二个线程竞争时,偏向锁撤销,线程通过 CAS 抢锁,抢不到就自旋少量次数。
  • 重量级锁:自旋超过阈值或等待线程较多,就膨胀为基于操作系统的 monitor 锁,此时才会发生线程阻塞和唤醒。

面试时被问到“synchronized 和 ReentrantLock 区别”,很多人张口就来“synchronized 是重量级、ReentrantLock 更轻”,这是不准确的。准确说法是,synchronized 经过锁升级后,无竞争时不重;但一旦竞争激烈膨胀为重量级锁,性能就下降明显。而 ReentrantLock 基于 AQS 实现,支持公平锁、可中断、超时、多个条件队列,控制粒度更细。

另外,synchronized 是可重入的。同一个线程进入 synchronized 方法后发现锁是自己的,可以继续进入另一个 synchronized 方法。它的实现原理是每个对象关联一个 monitor,monitor 内部维护持有锁的线程。介绍到这里,可以顺口带一句“这种可重入设计避免了自己锁死自己”,面试官一般会点头表示认可。

3.2 volatile 的可见性与禁止重排

volatile 的关键字作用用两句话就能概括:

  • 保证一个线程对 volatile 变量的写,对后续其他线程的读可见。
  • 禁止编译器和处理器对这个变量的重排序。

底层靠的是内存屏障。JMM 规定 volatile 写操作前插入 StoreStore 屏障,写操作后插入 StoreLoad 屏障;读操作前和读操作后也有对应的 LoadLoad、LoadStore 屏障。简单说,就是在关键位置画了一条“栅栏”,指令不允许跨过栅栏乱跑。

经典面试题“DCL 单例为什么要加 volatile”。看我这个示例:

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

new Singleton()在字节码层面不是一个原子操作。它大致三步:分配内存、调用构造方法初始化、把引用赋值给 instance。如果不加 volatile,第二步和第三步可能被重排序,线程 A 先赋值、后初始化,线程 B 读到非 null 的 instance,但对象还没构造好,直接拿去用就炸了。volatile 的禁止重排保证了赋值动作一定发生在初始化完成之后。

但是要反过来说,volatile 不保证原子性。比如volatile int count; count++依然是错的。因为 count++ 是“读-改-写”三步,volatile 保证每次读都读到最新值,但三个步骤之间依然可能被其他线程插进来。这也是面试官埋在 volatile 里的第二个陷阱。

3.3 从 wait/notify 到 sleep,状态切换的经典连环问

面试官很喜欢从“线程通信”切进去,然后抛出一串对比题。我实务中总结出一个小表格,直接背就能用:

方法属于谁是否释放锁需要持有锁吗恢复条件
Object.wait()Object释放锁需要被 notify/notifyAll 唤醒
Object.notify()Object不释放锁需要-
Thread.sleep()Thread不释放锁不需要时间到
Thread.yield()Thread不释放锁不需要让出 CPU
LockSupport.park()JUC不释放锁(可配合锁)不需要unpark 许可证

我每次都跟人说,sleep 不释放锁是重点。因为很多人以为 sleep 会像 wait 一样放锁,结果写代码时死锁了都不知道为什么。另一个重点:wait 和 notify 必须在 synchronized 代码块里调用,否则抛 IllegalMonitorStateException。原因也好理解,wait 的语义是条件不满足就释放锁并等待,那你必须已经拿到锁才能释放。

为什么 wait 需要被唤醒,而 sleep 只需睡够?因为 wait 实质上把自己从同步队列挪到了等待队列,必须靠其他线程把它唤回来,重新参与锁竞争;sleep 只是让线程暂时不参与调度,时间一到自然恢复 RUNNABLE。

4. JUC 的锁与同步器:从 CAS 到 AQS

4.1 CAS 并不是万能的

CAS(Compare And Swap)是很多 JUC 类的基石。它的判断逻辑一句话:内存当前值 == 期望值时,就改为新值,否则不做修改并返回失败。整个过程由处理器指令支持,是原子操作,不会被打断。

Java 里的实现主要是 Unsafe 类的 native 方法,到了 JDK 9 之后有了 VarHandle,提供更规范的操作入口。AtomicInteger 这类原子类就是靠 CAS 实现无锁递增。看这个:

public class Counter { private final AtomicInteger count = new AtomicInteger(0); public void increment() { count.incrementAndGet(); } public int get() { return count.get(); } }

incrementAndGet 内部是乐观锁思路,不断重试 CAS,直到成功。无锁设计的代价是 CPU 开销:一旦竞争激烈,大量线程会空转在自旋上,反而比锁更耗资源。所以要记住:CAS 适合竞争不激烈的场景,竞争激烈就用 ReentrantLock 或 synchronized。

CAS 还有一个经典问题:ABA。假设线程 A 读到值是 A,线程 B 把值改成 B 又改回 A,线程 A 再 CAS 时发现值还是 A,就认为没变过,但中间其实被改过。解决方式是用带版本号的原子类 AtomicStampedReference。我实际工作中很少需要用这个,但面试几乎必问。

4.2 AQS 是 JUC 的半壁江山

AQS(AbstractQueuedSynchronizer)你得把它当成一个“排队器”理解。它维护一个 volatile int state 和一个 FIFO 双向等待队列。state 的含义由子类自己定义,比如 ReentrantLock 里 state 表示加锁次数(可重入的体现),Semaphore 里 state 表示剩余许可证数量,CountDownLatch 里 state 表示还需要等待的计数。

核心流程:

  • 获取资源:尝试用 CAS 把 state 从 0 改成 1。成功表示抢到锁,失败则封装成 Node 塞进队尾,然后 LockSupport.park 挂起。
  • 释放资源:state 减 1,唤醒队列头部的后继节点。
  • 对于可重入锁,state 大于 0,每次重入就加 1,释放时减 1,直到归零才真正释放锁并唤醒其他线程。

ReentrantLock 有公平和非公平两种模式。非公平模式下,新来的线程会直接先尝试 CAS 抢一次,抢不到才排队;公平模式下,直接看队列里有没有前驱节点在等待,有就乖乖排到队尾。非公平锁吞吐量更高,因为减少了线程挂起和唤醒的开销;公平锁能避免线程饥饿,适合实际需要“先来先服务”的场景。

4.3 Condition 和 Object.wait 的现代替代

ReentrantLock 配合 Condition 可以达到比 wait/notify 更精细的等待-唤醒控制。Condition 可以从同一个锁创建多个条件队列,比如生产者消费者模型里,用两个 Condition:notEmpty 和 notFull。生产者等待 notFull,消费者等待 notEmpty。比 wait/notify 只用一把锁、一个等待队列要清晰得多,也避免了虚假唤醒和无法定向唤醒的问题。

ReentrantLock lock = new ReentrantLock(); Condition notEmpty = lock.newCondition(); Condition notFull = lock.newCondition(); // 生产者 lock.lock(); try { while (queue.size() == capacity) { notFull.await(); } queue.add(item); notEmpty.signal(); } finally { lock.unlock(); } // 消费者 lock.lock(); try { while (queue.isEmpty()) { notEmpty.await(); } item = queue.poll(); notFull.signal(); } finally { lock.unlock(); }

注意这里循环用 while 而不是 if。这是为了处理“虚假唤醒”:线程被唤醒后,条件可能还是不能满足,必须再检查一次条件,否则直接往下走就会出错。

4.4 CountDownLatch、CyclicBarrier、Semaphore 的区别

这三个工具类,面试挽着花问。

CountDownLatch 是计数器扣减。一个线程等 N 个线程完成各自任务后再继续。计数只能减不能加,用完即废,不能复用。

CyclicBarrier 是栅栏。N 个线程互相等待,大家都到了才一起继续。计数可以重置,循环使用。注意,CyclicBarrier 的计数是由到达的线程自己 await 递减的,CountDownLatch 的计数通常由执行完毕的任务去 countDown。

Semaphore 是信号量,更像是“限流器”。它控制同一时间最多有多少个线程拿到许可证。初始化时传一个 permits 数,每个线程 acquire 一个许可,用完 release 归还。

我给个最简单的区分口:CountDownLatch 是“等人都走了再关门”,CyclicBarrier 是“人都到齐了再出发”,Semaphore 是“洞就那么大,一个一个来”。背下这个,面试一上来就不慌。

5. 并发容器与线程池的实战问答

5.1 ConcurrentHashMap 的线程安全演进

ConcurrentHashMap 是并发场景下的首选 Map。从 JDK 7 到 JDK 8 的变化很能体现 JUC 的设计演进。

JDK 7 的 ConcurrentHashMap 用的分段锁。整个表分成多个 Segment,每个 Segment 是一把独立的锁,不同段的操作可以并发,只有落在同一段的才会竞争。锁粒度是段级别。

JDK 8 放弃了分段锁,改为数组 + 链表/红黑树 + CAS + synchronized。写操作时,如果对应桶位是空的,直接用 CAS 插入;如果桶位不为空,则对桶位头节点加 synchronized 锁。锁粒度从段级别降低到单个桶位,并发度更高。

另外一个细节是,JDK 8 的 synchronized 锁把人引向一个结论:在冲突少的场景,synchronized 经过锁升级后开销并不高,反而比 Segment 这种“大范围拉锁”更优秀。

同样值得留意的还有扩容机制。它支持多线程协助扩容,扩容时每个线程领取一段迁移任务,迁移完成再继续做自己原来的工作。面试问得深的会提到 sizeCtl 这个变量,它控制扩容的触发条件和并发扩容的线程数。这一块听起来复杂,但真要加分就在这种源码细节。

5.2 线程池七大参数要一下子说出来

线程池的问题基本属于“背了就能拿分”。ThreadPoolExecutor 构造器的七个参数:

  1. corePoolSize:核心线程数,默认常驻。
  2. maximumPoolSize:最大线程数,包含核心线程和救急线程。
  3. keepAliveTime:救急线程的空闲存活时间。
  4. TimeUnit:时间单位。
  5. workQueue:任务队列。
  6. threadFactory:线程工厂,用于自定义线程命名、优先级等。
  7. RejectedExecutionHandler:拒绝策略。

执行任务的完整顺序是:核心线程不满,就新增核心线程执行;核心线程满了,任务进队列;队列满了,新增救急线程(不超过 maximumPoolSize);队列和最大线程数都满了,触发拒绝策略。

四种拒绝策略:

  • AbortPolicy:直接抛异常,默认策略。
  • CallerRunsPolicy:谁提交谁执行,也就是退回调用线程执行。
  • DiscardPolicy:静默丢弃。
  • DiscardOldestPolicy:丢弃队列最老的任务,再加入新任务。

实际开发里,我会避免用 DiscardPolicy,因为丢了任务没有感知。宁可抛异常,让报警系统抓到,也别默默吞掉。这点在面试中聊出来,很显水平。

5.3 execute 和 submit 的区别

execute(Runnable) 用的是 Executor 接口语义,没有返回值,提交异常会直接抛给线程池里的线程处理,如果处理不当可能看不到异常日志。

submit(Callable/Runnable) 返回 Future,异常被封装在 Future 里,调用 Future.get() 时才会抛出 ExecutionException。如果你想感知任务执行成败,submit 更适合。不过要留意:如果任务长期不 get(),异常信息会被吞在 Future 内部,最终什么都看不见。

线程池大小怎么定?网上有一堆公式,CPU 密集选 N+1,IO 密集选 2N。我实战中的经验是,先用压测确定基线,再用公式做初始值。2N 只是一个起点,不完全是真理。因为 IO 密集的线程大部分时间在等待,多开线程能提交吞吐,但也不能无限多,否则线程切换成本会反超收益。

6. 避坑指南与面试速查表

6.1 实战中最容易踩的五个并发坑

第一个坑,用 SimpleDateFormat。它不是线程安全的,多个线程共用同一个实例时,会出现转换结果异常或者直接抛 NumberFormatException。解决办法是用 ThreadLocal 包一层,或者改用 JDK 8 的 DateTimeFormatter。

第二个坑,双检锁不加 volatile。前面单例部分已经说了,这里再次强调,真有人直接抄代码却忘了 volatile,只在测试环境跑,测不出问题,上线后偶发空指针。

第三个坑,HashMap 并发 put 导致死循环。虽然 JDK 8 里这个问题已经缓解到不会导致死循环,但数据丢失和错误覆盖依旧可能。并发场景下,老老实实上 ConcurrentHashMap。

第四个坑,自定义线程池却没有拒绝策略和监控。公司线上一般会禁止直接用 Executors 创建线程池,因为默认的无界队列会导致内存飙升。Executors.newFixedThreadPool 用的是无界 LinkedBlockingQueue,任务积压时 OOM 风险极高。规范做法是新 ThreadPoolExecutor 传阻塞队列,配好核心参数和拒绝策略。

第五个坑,误以为 volatile 能解决原子性问题。前面说过很多次,volatile 管不了复合操作。i++ 这种操作要么加 synchronized,要么用 AtomicInteger。

6.2 并发基础速查表

主题一句话记忆点
三大特性原子性、可见性、有序性
volatile保证可见性、禁止重排,不保证原子性
synchronized锁升级:无锁→偏向锁→轻量级锁→重量级锁
ReentrantLock基于 AQS,state 记录重入次数,可公平可不公平
CAS无锁自旋,有 ABA 问题,可用 AtomicStampedReference
线程池参数corePoolSize、maxPoolSize、keepAliveTime、unit、workQueue、threadFactory、handler
线程池流程核心线程→队列→救急线程→拒绝策略
CountDownLatch减法计数,等任务完成
CyclicBarrier栅栏,互相等待,可复用
Semaphore信号量,限流
ConcurrentHashMapJDK8:CAS + synchronized + 桶级锁

6.3 关于并发基础,我个人最想说的一件事

我当年准备并发基础的时候,最喜欢做的事是画状态机和执行流程图。现在白板面试虽然少了,但这套思路还是能用。遇到任何并发问题,先不要急着套方案,先问自己:问题出在原子性、可见性还是有序性上?如果是复合操作的原子性问题,优先考虑锁;如果只是状态标记,volatile 够用;如果需要的是“读多写少”且一致性要求不高,还可以考虑读写锁。这套决策路径比单纯背结论靠谱太多。

最后送大家一个小技巧。面试官问“你说说 volatile 的原理”,如果你能顺手把 JMM 的 happen-before 规则带出来,比如“volatile 变量规则:对一个 volatile 变量的写操作先行发生于后面对这个变量的读操作”,这一句话就能把你从“背答案”的人群里拎出来。并发基础说到底就是这些规则之间的组合,理清楚规则,八股题也能答出设计感。

返回列表