跳到正文
Java 入门到面试指南
返回

01-多线程与并发编程

阶段:进阶篇 | 建议时长:7 天 | 前置:02-面向对象与核心类.md、03-集合与泛型.md

学习目标

  • 能用三种方式创建线程,画出线程 6 种状态流转图。
  • 能解释 JMM 三性与 volatile 的两个保证、一个不保证。
  • 掌握 synchronized 三种用法与 ReentrantLock 差异,会写死锁并排查。
  • 能默写线程池 7 参数、执行流程、4 种拒绝策略,并给出线程数经验公式。
  • 会用 ThreadLocal、CAS 工具类、JUC 协调工具与 CompletableFuture 完成典型并发任务。

核心知识点

线程的三种创建方式

面试官想听:本质都是 Runnable——Thread 封装任务、FutureTask 接收返回值、线程池统一管理。

// 1. 继承 Thread
class MyThread extends Thread {
    public void run() { System.out.println("thread"); }
}
// 2. 实现 Runnable(推荐,任务与执行解耦,还能交给线程池)
Runnable task = () -> System.out.println("runnable");
new Thread(task).start();
// 3. Callable + FutureTask(有返回值、可抛异常)
Callable<Integer> c = () -> 42;
FutureTask<Integer> ft = new FutureTask<>(c);
new Thread(ft).start();
Integer result = ft.get();      // 阻塞直到完成
  • 必须调用 start() 而不是 run():run() 只是普通方法调用(单线程执行),start() 才创建新线程。
  • 实战第 4 种:线程池 ExecutorService(见下文),避免手动 new Thread。
  • Thread.currentThread() 获取当前线程;线程默认不继承父线程状态(InheritableThreadLocal 除外)。

线程的 6 种状态

面试官想听:NEW→RUNNABLE 的启动路径、RUNNABLE 与阻塞系状态的区分、WAITING 与 TIMED_WAITING 的差别。

NEW -> start() -> RUNNABLE <-> BLOCKED(等 synchronized 锁)
                     |-> WAITING(wait/join/LockSupport.park,无超时)
                     |-> TIMED_WAITING(sleep(n)/wait(n)/join(n))
                     |-> TERMINATED(run 结束)
  • sleep 是 Thread 静态方法、不释放锁;wait 是 Object 方法、释放锁,且必须在 synchronized 块内调用,靠 notify/notifyAll 唤醒。
  • join():等待目标线程结束;yield():提示调度器让出 CPU(不可依赖);interrupt() 只是设置中断标志,阻塞中线程会抛 InterruptedException,需自行决定退出策略。
  • 守护线程:thread.setDaemon(true) 必须在 start 前调用;所有非守护线程结束 JVM 就退出,GC 线程就是守护线程。

JMM 三性

面试官想听:原子性、可见性、有序性,能各举一个反例并给出对应解决方案。

  • 原子性:操作不可分割。i++ 是「读-改-写」三步,多线程下会丢更新;用 synchronized、AtomicInteger、Lock 保证。经典对比:10 个线程各 count++ 1000 次,用 int count 结果通常小于 10000 且每次运行都不同(读-改-写被交错打断,丢更新),换成 AtomicInteger.incrementAndGet() 则精确等于 10000(CAS 保证单次自增原子)。
  • 可见性:一个线程的修改对其他线程立即可见。工作内存(CPU 缓存)与主存不同步导致;用 volatile、synchronized、final 保证。
  • 有序性:编译器/CPU 指令重排导致代码执行顺序与书写顺序不一致;volatile 禁止指令重排,锁保证串行语义。
  • happens-before:JMM 定义的操作间偏序规则(程序顺序、锁的解锁先于加锁、volatile 写先于读、线程 start/join 等),满足则后操作可见前操作的结果,是理解并发的理论抓手。
    • 传递性推演(面试爱考):线程 A 里先写 v = 1(volatile)再写 y = 2(普通变量);线程 B 读到 v == 1 后再读 y,则 B 一定读到 y == 2。推导链:A 中”写 v 先于写 y”(程序顺序规则)+ “v 的写 happens-before B 对 v 的读”(volatile 规则)+ B 中”读 v 先于读 y”(程序顺序规则),传递可得 y 的写对 B 可见——普通变量的写跟着 volatile 的写”顺带”可见了。反过来(先写普通变量、后写 volatile)不保证。

volatile

面试官想听:两个保证(可见性、禁止重排)+ 一个不保证(原子性),最好带双重检查锁单例的例子。

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;
    }
}
  • new 对象不是原子操作:若无 volatile,另一线程可能在「赋值完成但未初始化」时拿到半成品。
  • volatile 适合一写多读或状态标志位(volatile boolean running = false;);复合运算仍需锁或原子类。

synchronized 与锁升级

面试官想听:三种用法对应不同锁对象;1.6 后锁升级路径(偏向→轻量级→重量级)说明 synchronized 并不重。

  • 三种用法:① 修饰实例方法(锁当前对象 this);② 修饰静态方法(锁 Class 对象);③ 同步代码块 synchronized(obj)(锁指定对象)。
  • 底层基于对象头的 Monitor(重量级锁依赖 OS mutex),Java 6 引入优化:无锁 → 偏向锁(单线程反复进入)→ 轻量级锁(CAS 自旋)→ 重量级锁(线程挂起),只升不降。
  • 可重入:同一线程可重复获取自己持有的锁;异常会自动释放锁。
  • 注意 String 常量、Integer 缓存对象等被共享的锁对象,可能造成意外竞争。

公平锁与非公平锁(synchronized 只有非公平语义,公平性见 ReentrantLock):new ReentrantLock(true) 为公平锁,严格按队列顺序发放;默认非公平——新来的线程先 CAS 抢一次,抢到直接执行,省掉挂起/唤醒与排队的开销,上下文切换更少、吞吐更高。需要「排队保证」或防饥饿时才用公平模式或 tryLock(fair)/带超时的 tryLock。

读写锁 ReentrantReadWriteLock 与 StampedLock

  • 锁分离:读读并行、读写互斥、写写互斥——state 高 16 位记读锁数、低 16 位记写锁重入数,适合「读多写少」(如本地配置缓存)。写线程可降级为读线程,读锁不能升级为写锁(会死锁)。
  • StampedLock(Java 8):增加乐观读 tryOptimisticRead()——只拿一个戳不加锁,读完 validate(stamp) 校验期间没被写过,冲突才升级为读锁;性能更高但不可重入、不支持 Condition,出问题时排查更难。

ReentrantLock 与 synchronized vs Lock

面试官想听:默认用 synchronized,需要「可中断、超时、公平、多条件」四个能力之一才上 ReentrantLock。

private final ReentrantLock lock = new ReentrantLock();       // true 为公平锁
private final Condition notEmpty = lock.newCondition();
public void take() throws InterruptedException {
    lock.lockInterruptibly();                                 // 可中断获取
    try {
        while (queue.isEmpty()) notEmpty.await();             // 条件等待(等价 wait)
        // 取数据
    } finally {
        lock.unlock();                                        // 必须放 finally
    }
}
维度synchronizedReentrantLock
层级JVM 关键字,自动释放JDK API,手动 lock/unlock
可中断/超时不支持lockInterruptibly()/tryLock(超时)
公平锁不支持构造参数可选
条件变量单一 wait/notify多个 Condition 精准唤醒

AQS(抽象队列同步器)

面试官想听:JUC 里锁和同步工具的公共地基——一个 volatile int + 一条排队队列。

  • 核心:volatile int state 表示资源状态(ReentrantLock 中=重入次数,Semaphore 中=许可数,CountDownLatch 中=剩余计数);抢不到就封装成节点进入 CLH 变体的双向等待队列,靠 LockSupport.park() 挂起,前序释放时 unpark 唤醒。
  • 模板方法模式:AQS 管排队与挂起/唤醒,子类只需实现 tryAcquire/tryRelease(独占)或 tryAcquireShared/tryReleaseShared(共享)——ReentrantLock、Semaphore、CountDownLatch、ReentrantReadWriteLock 都基于它。
  • 两种模式:独占模式(同一时刻只有一个线程持有,如 ReentrantLock)vs 共享模式(多个线程可同时获取,如 Semaphore、CountDownLatch)。
  • 一句话记忆口径:state 表示资源,队列表示排队的人。

死锁:四条件与排查

面试官想听:背出四个必要条件并给出「按序加锁」这个最实用的预防手段。

  • 四个必要条件:互斥、持有并等待、不可剥夺、循环等待,破坏任意一个即可预防(最常用:所有线程按相同顺序加锁;或 tryLock 带超时)。
  • 排查:jstack pid 末尾直接输出 Found one Java-level deadlock;jconsole 检测死锁按钮;代码上 lock.tryLock(timeout) 日志告警。

线程池:7 参数、执行流程与拒绝策略

面试官想听:这是并发面试第一高频,参数、流程、拒绝策略必须一次说全,再补 Executors 的坑。

ThreadPoolExecutor pool = new ThreadPoolExecutor(
        4, 8, 60, TimeUnit.SECONDS,
        new ArrayBlockingQueue<>(100),
        Thread::new,                       // 线程工厂:命名便于排障
        new ThreadPoolExecutor.CallerRunsPolicy());
  • 7 参数:核心线程数 corePoolSize、最大线程数 maximumPoolSize、空闲存活时间 keepAliveTime、时间单位、任务队列 workQueue、线程工厂 threadFactory、拒绝策略 handler。
  • 执行流程:核心线程未满则新建核心线程执行 → 满了进队列排队 → 队列满了再创建非核心线程至 maximum → 达到 maximum 则执行拒绝策略。
  • 4 种拒绝策略:AbortPolicy(默认,抛 RejectedExecutionException)、CallerRunsPolicy(调用者线程自己执行,天然削峰)、DiscardPolicy(静默丢弃)、DiscardOldestPolicy(丢队首最旧任务再重试)。
  • Executors 的坑(阿里规约禁止):newFixedThreadPool/newSingleThreadExecutor 用无界队列 LinkedBlockingQueue,任务堆积可 OOM;newCachedThreadPool 最大线程数 Integer.MAX_VALUE,可无限建线程。手动 new ThreadPoolExecutor。
  • 线程数经验公式:CPU 密集型 ≈ N核 + 1;I/O 密集型 ≈ N核 × (1 + 等待时间/计算时间),再压测校准。
  • 优雅关闭:shutdown()(等任务跑完)→ awaitTermination → 超时后 shutdownNow()。
  • submit 吞异常:异常存在返回的 Future 里,不调用 get() 就无人知晓——记得处理或用 execute。

CAS 与 Atomic、ABA 问题

面试官想听:CAS = 比较并交换的 CPU 原子指令,知道它的三个问题:ABA、自旋开销、只能保一个变量。

  • CAS:compareAndSwap(内存值V, 预期值E, 新值N),硬件级原子指令,失败自旋重试;Unsafe → AtomicInteger 等的实现基础。
  • ABA 问题:值从 A→B→A,CAS 以为没变过;用 AtomicStampedReference(版本号戳)解决。
  • 常用原子类:AtomicInteger/AtomicLong(incrementAndGet)、AtomicReference、LongAdder(分段累加,高并发计数优于 AtomicLong)。

ThreadLocal 原理与内存泄漏

面试官想听:ThreadLocalMap 的 key 是弱引用这条链路,以及「为什么必须 finally 里 remove」。

static ThreadLocal<SimpleDateFormat> DF =
        ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd"));
try {
    use(DF.get());
} finally {
    DF.remove();          // 必须手动清理
}
  • 原理:每个 Thread 内有 ThreadLocalMap,key 是 ThreadLocal 对象(弱引用),value 是存的值(强引用)。
  • 泄漏链路:ThreadLocal 外部引用消失后 key 被 GC 变 null,但 value 仍被线程强引用,线程池线程长存 → value 永远无法回收。Entry 的 key 为 null 也不会被主动清。
  • 结论:线程池场景用完必须 remove();用途:按线程隔离状态(如 SimpleDateFormat、用户上下文、链路 traceId)。

ConcurrentHashMap 1.7 vs 1.8

面试官想听:分段的粒度从「Segment 二级哈希」进化到「单桶 CAS+synchronized」。

  • 1.7:Segment 分段锁(默认 16 段,继承 ReentrantLock),并发度=段数;两次 hash 定位。
  • 1.8:抛弃分段,Node 数组 + 链表/红黑树;put 时桶为空用 CAS 插入,冲突时 synchronized 锁住桶头节点,锁粒度更细、并发度更高。
  • size 统计用 baseCount + CounterCell[] 分片计数(LongAdder 思想);扩容支持多线程协助迁移。
  • key/value 都不允许 null(并发下无法区分「不存在」与「值为 null」)。

JUC 容器补充:CopyOnWriteArrayList

  • 原理:写时复制——add/remove/set 时复制一份新数组改完再替换引用,读永远走旧数组,迭代器不加锁且不会抛 ConcurrentModificationException;适合读极多写极少(如监听器列表、黑白名单)。
  • 代价:每次写都复制整个数组,写开销大、内存翻倍峰值;且读到的可能是旧快照(弱一致性)。与 ConcurrentHashMap 并列记忆:都是「并发场景替代非线程安全集合」的方案,一个治 ArrayList,一个治 HashMap。

CountDownLatch / CyclicBarrier / Semaphore

面试官想听:三个工具一句话定位——等 N 件事完成、凑 N 个人再一起走、只放行 M 个。

  • CountDownLatch:countDown() 减计数、await() 阻塞到 0;一次性,用于主线程等待多个子任务完成。
  • CyclicBarrier:N 个线程互相等待到齐后一起继续,可复用(可传回调);用于分段计算后汇总。
  • Semaphore:许可数=并发上限,acquire/release;用于限流(如数据库连接数、接口并发数)。
  • 阻塞队列:ArrayBlockingQueue(有界数组)、LinkedBlockingQueue(默认无界链表)、SynchronousQueue(不存储、直接交接)、DelayQueue/PriorityBlockingQueue;put/take 自动阻塞,是生产者消费者的现成方案。

CompletableFuture 与虚拟线程

CompletableFuture<String> price = CompletableFuture.supplyAsync(() -> queryPrice(id), pool);
CompletableFuture<Integer> stock = CompletableFuture.supplyAsync(() -> queryStock(id), pool);
price.thenCombine(stock, (p, s) -> p + " / " + s)     // 两个结果合并
     .orTimeout(2, TimeUnit.SECONDS)                  // 超时保护
     .whenComplete((r, e) -> log.info("{}", r, e));
  • 异步编排三件套:supplyAsync 提交、thenApply/thenAccept/thenCombine 链式转换、thenCompose 串行依赖、allOf/anyOf 聚合多个任务。必须传入自定义线程池,默认池仍是 ForkJoinPool.commonPool。
  • 虚拟线程(Java 21 正式):JVM 调度的轻量级线程(KB 级栈),Thread.ofVirtual().start(...) 或 Executors.newVirtualThreadPerTaskExecutor();阻塞时自动挂起不占 OS 线程,适合大量阻塞 I/O 任务(一秒开万十个线程等下游)。
  • 虚拟线程不适合什么:① CPU 密集任务无收益——它解决的是「等待时不占 OS 线程」,计算量不变,还是 N 核;② 观念要变:不池化(创建极廉价,用完即弃,别再建 newFixedThreadPool 复用)、ThreadLocal 慎用以避免长生命周期对象泄漏(可考虑替代方案),且 synchronized 块内长时间阻塞会把载体线程一起占住(建议改用 ReentrantLock)。

动手实践

任务 1:三线程交替打印 ABC

private static final Object lock = new Object();
private static int state = 0;   // 0->A, 1->B, 2->C
// 每个线程:synchronized (lock) { while (state % 3 != self) lock.wait(); print; state++; lock.notifyAll(); }
  • 验收标准:连续输出 ABCABCABC... 至少 10 轮不乱序;用 wait/notifyAll(不能用 Thread.sleep 控制顺序);能解释为什么用 while 而不是 if 判断条件(防止虚假唤醒)。

任务 2:阻塞队列生产者消费者

  • 要求:ArrayBlockingQueue<Integer>(容量 5),3 个生产者各生产 100 个随机数,2 个消费者取出求和;用「毒丸对象」通知消费者退出。
  • 验收标准:程序正常退出且总和打印完整;生产过快时观察 put 阻塞(有界队列生效);改用手写 wait/notifyAll 版本对比一次。

任务 3:线程池批处理统计耗时

  • 要求:用 new ThreadPoolExecutor(4, 8, ...) 提交 1000 个模拟任务(Thread.sleep(1)),统计总耗时;分别设置 CPU 密集(sleep 0)与 I/O 密集(sleep 10)参数对比线程数公式。
  • 验收标准:记录不同 corePoolSize 下的耗时对比数据;拒绝策略演示触发过一次(队列调小);所有线程有命名(自定义 ThreadFactory)。

高频面试题

Q:创建线程有几种方式?⭐

  • 继承 Thread 重写 run;实现 Runnable 交给 Thread;实现 Callable 配 FutureTask 拿返回值;线程池提交任务。
  • 本质上只有「new Thread().start()」一种启动方式,其他都是任务形态差异。
  • 实践必须用线程池:复用线程、统一管控、避免频繁创建销毁。

Q:线程有哪些状态?⭐

  • 6 种:NEW、RUNNABLE、BLOCKED、WAITING、TIMED_WAITING、TERMINATED。
  • 调 start 后进入 RUNNABLE;等 synchronized 进入 BLOCKED;wait/join/park 进入 WAITING;带超时版本进入 TIMED_WAITING。
  • sleep 不释放锁、wait 释放锁且必须在同步块内。

Q:volatile 和 synchronized 的区别?⭐

  • volatile 只保证可见性和有序性,不保证原子性;synchronized 三性都保证。
  • volatile 是轻量级无锁方案,适合状态标志、一写多读;synchronized 互斥执行,适合复合操作。
  • volatile 不阻塞线程;synchronized 可能阻塞。volatile 修饰变量,synchronized 修饰方法/代码块。

Q:说说线程池的核心参数与任务执行流程。⭐

  • 7 参数:corePoolSize、maximumPoolSize、keepAliveTime、时间单位、workQueue、threadFactory、拒绝策略 handler。
  • 流程:核心线程未满直接新建执行 → 满了进队列 → 队列满再建线程到最大值 → 达到最大值执行拒绝策略。
  • 四种拒绝策略:Abort(抛异常)、CallerRuns(调用者执行)、Discard(丢弃)、DiscardOldest(丢最旧)。

Q:线程池大小怎么定?

  • CPU 密集型:N+1(N 为核心数),避免过多上下文切换。
  • I/O 密集型:N × (1 + 等待时间/计算时间),等待占比越大线程越多。
  • 经验值需压测校准;区分业务用独立线程池,避免相互影响。

Q:ThreadLocal 为什么会内存泄漏?⭐

  • 每个 Thread 持有 ThreadLocalMap,key 是弱引用的 ThreadLocal,value 是强引用。
  • ThreadLocal 被回收后 key 变 null,value 无法访问但被长生命周期线程(线程池)强引用,无法回收。
  • 规范:使用完(尤其线程池场景)必须在 finally 中 remove()。

Q:CAS 是什么?有什么问题?

  • Compare And Swap:比较内存值与预期值,相等则替换为新值,由 CPU 原子指令保证,失败自旋重试。Atomic 类的底层。
  • 问题:ABA(用 AtomicStampedReference 加版本号解决)、自旋开销(竞争激烈时 CPU 空转)、只能保证单个变量原子性。
  • 典型应用:AtomicInteger 计数、ConcurrentHashMap 空桶插入。

Q:ConcurrentHashMap 如何保证并发安全?1.7 和 1.8 区别?⭐

  • 1.7:Segment 分段锁(默认 16 段 ReentrantLock),锁一段。
  • 1.8:CAS + synchronized 锁单个桶头节点,粒度更细并发更高;链表过长转红黑树。
  • size 用 CounterCell 分片计数;不允许 null key/value。

Q:sleep 和 wait 的区别?

  • 所属:Thread.sleep 是静态方法;wait 是 Object 方法。
  • 锁:sleep 不释放锁;wait 释放对象锁。
  • 场景:wait/notify 配合做线程间通信,必须在 synchronized 内;sleep 单纯休眠。

Q:死锁怎么产生、怎么排查和避免?

  • 四个必要条件:互斥、持有并等待、不可剥夺、循环等待。
  • 排查:jstack 输出 Found one Java-level deadlock;jconsole 检测死锁。
  • 避免:全局固定加锁顺序、tryLock 带超时、减小锁粒度、用并发工具替代手写锁。

推荐资料

资料说明链接
廖雪峰 Java 多线程线程基础到同步机制的主线教程https://liaoxuefeng.com/books/java/threading/index.html
pdai 并发知识体系JMM/锁/线程池/JUC 全景与源码索引https://pdai.tech/md/java/thread/java-thread-x-overview.html
JavaGuide 并发面试题本章所有面试题的标准展开版本https://javaguide.cn/java/concurrent/java-concurrent-questions-01.html