免费获取学习方案
ARTICLE DETAIL

资讯详情

深耕编程基础知识与建站技术分享的一线实战洞察。

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

Java并发编程核心:线程状态、synchronized、volatile与JUC实战 1. 为什么面试会先从“并发基础”开始卷1.1 “八股”不是贬义词它是面经体系的骨架先说点题外话。很多人在牛客、脉脉上刷到“JUC 并发编程【八股篇】”这种标题第一反应就是又来了又在背面试题。说实话我以前也这么觉得后来被工作毒打几轮之后反而改观了。所谓“八股”本质上是把高频考点整理成一套有标准答案的问答体系它不能代表你真实写代码的水平但它能代表你对某个领域的知识边界到底有没有建立起来。尤其是并发编程这种“平时遇不到、一遇到就是线上事故”的领域八股反而是最低成本的入门索引。如果完全抛开面试纯粹从技术角度看JUCjava.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 方法。它的实现原理是每个对象关联一个 monitormonitor 内部维护持有锁的线程。介绍到这里可以顺口带一句“这种可重入设计避免了自己锁死自己”面试官一般会点头表示认可。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不释放锁不需要让出 CPULockSupport.park()JUC不释放锁可配合锁不需要unpark 许可证我每次都跟人说sleep 不释放锁是重点。因为很多人以为 sleep 会像 wait 一样放锁结果写代码时死锁了都不知道为什么。另一个重点wait 和 notify 必须在 synchronized 代码块里调用否则抛 IllegalMonitorStateException。原因也好理解wait 的语义是条件不满足就释放锁并等待那你必须已经拿到锁才能释放。为什么 wait 需要被唤醒而 sleep 只需睡够因为 wait 实质上把自己从同步队列挪到了等待队列必须靠其他线程把它唤回来重新参与锁竞争sleep 只是让线程暂时不参与调度时间一到自然恢复 RUNNABLE。4. JUC 的锁与同步器从 CAS 到 AQS4.1 CAS 并不是万能的CASCompare 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 的半壁江山AQSAbstractQueuedSynchronizer你得把它当成一个“排队器”理解。它维护一个 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 可以从同一个锁创建多个条件队列比如生产者消费者模型里用两个 ConditionnotEmpty 和 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 构造器的七个参数corePoolSize核心线程数默认常驻。maximumPoolSize最大线程数包含核心线程和救急线程。keepAliveTime救急线程的空闲存活时间。TimeUnit时间单位。workQueue任务队列。threadFactory线程工厂用于自定义线程命名、优先级等。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 密集选 N1IO 密集选 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基于 AQSstate 记录重入次数可公平可不公平CAS无锁自旋有 ABA 问题可用 AtomicStampedReference线程池参数corePoolSize、maxPoolSize、keepAliveTime、unit、workQueue、threadFactory、handler线程池流程核心线程→队列→救急线程→拒绝策略CountDownLatch减法计数等任务完成CyclicBarrier栅栏互相等待可复用Semaphore信号量限流ConcurrentHashMapJDK8CAS synchronized 桶级锁6.3 关于并发基础我个人最想说的一件事我当年准备并发基础的时候最喜欢做的事是画状态机和执行流程图。现在白板面试虽然少了但这套思路还是能用。遇到任何并发问题先不要急着套方案先问自己问题出在原子性、可见性还是有序性上如果是复合操作的原子性问题优先考虑锁如果只是状态标记volatile 够用如果需要的是“读多写少”且一致性要求不高还可以考虑读写锁。这套决策路径比单纯背结论靠谱太多。最后送大家一个小技巧。面试官问“你说说 volatile 的原理”如果你能顺手把 JMM 的 happen-before 规则带出来比如“volatile 变量规则对一个 volatile 变量的写操作先行发生于后面对这个变量的读操作”这一句话就能把你从“背答案”的人群里拎出来。并发基础说到底就是这些规则之间的组合理清楚规则八股题也能答出设计感。
返回列表