Java并发编程笔记:从原理到实战

1. 引言:为什么需要并发编程

1.1 背景与目的

现代服务器普遍采用多核架构,单线程程序只能利用一个核心,吞吐量低、响应慢。并发编程的核心目的就是提升资源利用率程序性能,让多个任务同时推进,充分利用CPU、内存和I/O资源。同时,Java作为主流后端语言,其并发模型(线程、锁、内存模型)是写出高性能、高可靠服务的基石。

1.2 整体知识架构概览

学习Java并发,建议沿着这条主线深入:

线程基础 → 线程状态与API → 共享模型(临界区/synchronized) → 锁优化(偏向/轻量/重量) 
→ JMM(可见/有序/原子) → Lock工具(ReentrantLock/AQS) → 线程池与实战

核心问答
Q:什么是并发?为什么Java开发者必须掌握并发编程?
A:并发指多个任务在同一时段内交替执行(微观串行,宏观并行)。Java开发者必须掌握,因为:

  • 几乎所有后端服务都需处理多用户请求,并发能力决定系统吞吐量。
  • 线程安全、锁、内存可见性等问题处理不当会引发生产事故(数据错乱、死锁、CPU飙升等)。
  • 掌握并发是进阶高级工程师的必备能力。

2. 基本概念与线程实现

2.1 进程与线程:操作系统视角

  • 进程:操作系统分配资源的基本单位,一个软件启动就对应一个进程,拥有独立的堆、方法区等内存空间。
  • 线程:CPU调度的最小单位,是一批顺序指令的集合。一个进程包含若干线程,它们共享进程的堆和方法区,每个线程拥有独立的虚拟机栈和程序计数器。

2.2 Java创建线程的三种方式

  1. 继承Thread类
    class MyThread extends Thread {
        public void run() { /* ... */ }
    }
    new MyThread().start();
    
  2. 实现Runnable接口(更推荐,分离任务与线程)
    new Thread(() -> { /* task */ }).start();
    
  3. FutureTask + Callable(可获取返回值、抛出异常)
    FutureTask<Integer> futureTask = new FutureTask<>(() -> {
        // 计算
        return 42;
    });
    new Thread(futureTask).start();
    Integer result = futureTask.get(); // 阻塞获取
    

核心问答
Q:Runnable与Callable的本质区别?FutureTask如何获取返回值?
A:Runnable的run()无返回值且不抛受检异常;
Callable的call()有返回值并可抛异常。
FutureTask实现了RunnableFuture(即RunnableFuture),
内部维护一个Callable引用和结果变量;
当线程执行run()时会调用call()并将结果保存在outcome字段,之后通过get()返回(若未完成则阻塞等待)。


3. JVM常用诊断命令

3.1 jps:查看Java进程

jps -l 列出所有Java进程PID及主类全名。常用于获取目标进程ID。

3.2 jstack:线程快照与死锁检测

jstack <pid> 打印所有线程状态、堆栈,末尾会自动检测死锁。线上排查死锁、线程卡顿第一工具。

3.3 jstat:堆内存、GC、类加载监控详解

jstat -gcutil <pid> 1000 每秒输出各代使用占比、GC次数和耗时,对分析GC频率、内存泄漏极有价值。
jstat -class <pid> 查看加载/卸载类数量。

3.4 jconsole:可视化管理工具

图形界面连接JMX,可查看堆内存曲线、线程图、死锁检测,适合开发阶段观察趋势。

核心问答
Q:如何用jstack定位死锁?jstat中各个指标代表什么?
A:jstack <pid>输出末尾会显示Found one Java-level deadlock并列出相互等待的线程和锁。
jstat -gcutil各列:S0/S1(Survivor区使用率)、E(Eden)、O(老年代)、M(元空间)、YGC/YGCT(Young GC次数/总耗时)、FGC/FGCT(Full GC次数/总耗时)。


4. 虚拟机栈与线程执行模型

4.1 程序计数器(PC寄存器)

线程私有,记录当前线程正在执行的字节码指令地址。执行native方法时值为空。它是线程切换后能恢复到正确执行位置的关键。

4.2 栈帧结构

每次方法调用会创建一个栈帧,栈帧中包含:

  • 局部变量表:存储方法参数和局部变量,包括基本类型和对象引用(指向堆)。线程安全的基础,因为每个线程有自己的栈帧。
  • 操作数栈:字节码指令执行时的临时数据区(如iadd指令从操作数栈弹出两个int相加再压回)。
  • 动态链接:指向运行时常量池中该方法的引用。
  • 返回地址:方法执行完毕后返回到调用者的指令位置。
  • 锁记录:优化篇会详述,用于轻量级锁。

4.3 方法调用示例

int add(int a, int b) { return a + b; }
字节码大致为:iload_1 (压a入操作数栈) → iload_2 (压b) → iadd (弹两数相加再压入) → ireturn。整个过程在栈帧内完成。

核心问答
Q:为什么局部变量表是线程安全的?栈帧在方法调用时如何创建和销毁?
A:局部变量表随栈帧创建,每个线程私有,不存在共享,因此无需同步就是安全的。方法调用时JVM创建一个新栈帧并入栈,方法正常返回或异常退出时栈帧出栈,其所占内存自动释放(局部变量也随之销毁)。


5. 线程上下文切换

5.1 触发切换的场景

  • CPU时间片用完,系统调度其他线程。
  • 发生GC(STW阶段所有线程需暂停)。
  • 有更高优先级的线程就绪(抢占式调度)。
  • 当前线程调用wait()/join()/LockSupport.park()等阻塞方法。

5.2 切换代价

切换时操作系统需保存当前线程的寄存器、程序计数器、栈指针等,再加载下一线程的上下文,涉及内核态开销。频繁切换会严重拖慢吞吐量。

核心问答
Q:频繁上下文切换为什么影响性能?如何减少?
A:上下文切换本身消耗CPU,且会清空CPU缓存、刷新TLB,导致后续代码执行变慢。减少手段:合理的线程数(CPU密集型N+1,I/O密集型可稍多)、避免不必要的锁竞争、使用无锁算法、调整JVM参数减少GC停顿。


6. 线程基本API与状态转换

6.1 sleep / yield / join

  • sleep:线程进入TIMED_WAITING,不会释放锁,可用TimeUnit.SECONDS.sleep(1)增强可读性;结束后进入就绪态。可被中断。
  • yield:让出CPU,进入就绪态,可能立即又被调度,不推荐用于业务控制。
  • join:等待目标线程执行完毕或超时,底层用wait()实现。

6.2 interrupt中断机制

在线程内部调用interrupt()将设置中断标志。若线程正在sleep/wait/join,会立即抛出InterruptedException清除中断标志
线上事故:某个线程在while循环中处理任务,当被中断时抛出异常,但异常处理中未再次设置中断或调用Thread.interrupted()清除标志,导致后续循环条件检查不到中断,线程无法优雅退出,永远运行部分代码。
正确做法

try {
    while (!Thread.currentThread().isInterrupted()) {
        // do work
    }
} catch (InterruptedException e) {
    // 重新设置中断标志或退出
    Thread.currentThread().interrupt();
}

6.3 线程状态:操作系统5状态 vs Java 6状态

  • 操作系统:新建、就绪、运行、阻塞、终止。
  • Java(Thread.State):NEW、RUNNABLE、BLOCKED、WAITING、TIMED_WAITING、TERMINATED。RUNNABLE对应操作系统就绪+运行,BLOCKED是JVM层面的等待监视器锁。

6.4 状态切换全景图

  • RUNNABLE → WAITINGwait()join()LockSupport.park()
  • RUNNABLE → TIMED_WAITINGsleep(n)wait(n)join(n)parkNanos(n)
  • RUNNABLE → BLOCKED:竞争synchronized锁失败
  • 各等待状态被唤醒后回到RUNNABLE

核心问答
Q:interrupt()后线程会立刻停止吗?sleep与yield状态转移有何不同?
A:不会,只是设置中断标志,仅当线程检查该标志或处于阻塞操作时抛出异常。sleep进入TIMED_WAITINGyield建议调度器让出CPU但状态仍为RUNNABLE,可能立刻又继续运行。


7. 线程通信与临界区

7.1 共享内存通信模型与竞态条件

Java多线程通过共享堆内存通信。竞态条件:多线程对共享变量读写时,由于上下文切换、CPU缓存等导致实际执行顺序不确定,结果不可预测。

7.2 临界区与synchronized

临界区:访问共享资源的代码段。synchronized通过互斥保证临界区代码的原子性(同一时刻仅一个线程执行),其锁对象关联Monitor,进入临界区获取Monitor,退出释放。

核心问答
Q:多线程下i++为什么不安全?synchronized到底锁的是什么?
A:i++在字节码层面是iload → iinc → istore三条指令,线程可能在这之间切换导致丢失更新。synchronized锁的是对象实例(或Class对象),每个对象关联一个Monitor,线程必须获得Monitor所有权才能进入同步块。


8. 线程安全深入分析

8.1 安全判定

  • 类变量:若只有读操作则安全,有写操作需同步。
  • 局部变量:基本类型天然安全,对象引用若未逃逸出方法(未发布给其他线程)也安全。但多个安全方法组合调用可能不安全,需注意整体原子性。
  • 不可变类(如String、Integer)天然线程安全。

8.2 Monitor与对象头

对象头中的Mark Word存储GC年龄、锁状态等信息,与Monitor相关联。JVM通过对象头的Mark Word实现synchronized的锁机制。

8.3 锁升级全链路(重点)

JDK 6起对synchronized做了大量优化,引入了锁膨胀过程:

  1. 无锁 → 偏向锁:第一次有线程获取锁时,Mark Word记录该线程ID(偏向),后续同一线程重入仅检查ID,无需CAS。
  2. 偏向锁 → 轻量级锁:当另一个线程尝试竞争偏向锁时,偏向撤销,升级为轻量级锁(CAS操作Mark Word指向栈中Lock Record)。
  3. 轻量级锁 → 重量级锁:竞争加剧,轻量级锁CAS自旋超过一定次数(默认10次,自适应)仍不成功,则膨胀为重量级锁,线程进入Monitor的EntryList阻塞等待。
  4. 批量重偏向与撤销:当某个类的偏向撤销次数达到阈值20(默认)时,JVM认为偏向锁不再合适,会进行批量重偏向;当撤销次数达到40时,彻底禁用该类偏向锁。

8.4 wait / notify 标准范式

synchronized (lock) {
    while (条件不满足) {   // 必须用while,防止虚假唤醒
        lock.wait();
    }
    // 条件满足,执行逻辑
    lock.notifyAll(); // 尽量用notifyAll
}

线上问题:用if判断条件,线程唤醒后未重新检查条件就执行,导致数据出错。

8.5 Benchmark性能基准测试

应基于JMH(Java Microbenchmark Harness)在真实多核环境下测试,注意JIT预热、锁竞争程度等因素。轻量级锁在低竞争下性能优秀,重量级锁稳定但开销大。

核心问答
Q:锁升级的过程是怎样的?为什么wait要放在while里?
A:偏向锁(重入无CAS)→轻量级锁(CAS自旋)→重量级锁(阻塞)。wait放在while是为了防止虚假唤醒或条件被其他线程抢先改变,唤醒后必须重新检查条件。


9. 同步模式之保护性暂停

9.1 模式定义

一个线程等待另一个线程的特定结果,即Guarded Suspension模式。核心结构:等待方持有一个条件变量,当结果未就绪时阻塞;产生方设置结果并唤醒等待方。

9.2 用wait/notifyAll实现简易Join与FutureTask

public class GuardedObject<T> {
    private T response;
    public synchronized T get() {
        while (response == null) {
            try { wait(); } catch (InterruptedException e) {}
        }
        return response;
    }
    public synchronized void complete(T response) {
        this.response = response;
        notifyAll();
    }
}

FutureTask内部本质上也是类似机制。

9.3 生产者-消费者(建议阻塞队列)

使用BlockingQueue实现,生产者放入数据,消费者取出,利用其内部锁和条件队列优雅实现线程协作,避免自行实现复杂同步。

核心问答
Q:保护性暂停和生产者-消费者有什么设计上的区别?
A:保护性暂停是一对一的直接匹配,生产者等待消费者消费当前结果后才继续;生产者-消费者通常多对多,通过队列解耦,生产和消费速率相对独立。


10. LockSupport工具类

10.1 park / unpark 原理

每个线程内部有一个许可(permit,类似二元信号量),park()消耗许可,若许可存在则不阻塞;若许可不存在则阻塞。unpark()发放一个许可(至多一个)。与synchronized的wait不同,不需要持有锁。

10.2 与wait/notify对比

  • unpark可在park之前调用,线程随后调用park不会阻塞。
  • 不需要配合synchronized,使用更灵活。
  • 但许可证只能有一个,不累计。

核心问答
Q:为什么unpark可以在park之前调用?这比wait/notify方便在哪?
A:因为许可机制是状态化的,unpark提前“存”下许可,之后park直接消耗通过。这避免了wait/notify必须“等待方先阻塞、通知方后通知”的顺序限制,实现更简洁的线程同步。


11. 多把锁与活跃性问题

11.1 细粒度锁与死锁

使用多把不相干的锁可提升并发度,但若获取顺序不当易导致死锁(线程持有A等B,另一线程持有B等A)。
诊断jstack直接检测死锁,jconsole线程页也有检测按钮。

11.2 活锁

多个线程相互谦让资源或改变对方结束条件,导致均无法推进。如多线程加减同一个计数器,触发互斥使计数器来回摆动。
解决方案:引入随机退避时间打破对称性。

11.3 饥饿

使用顺序锁(固定获取锁的顺序)可避免死锁,但若某些线程请求锁的频率远低于其他线程,可能总获取不到执行机会(饥饿)。可通过公平锁缓解。

核心问答
Q:死锁的四个必要条件是什么?顺序锁为何能避免死锁却可能引发饥饿?
A:互斥、持有并等待、不可剥夺、循环等待。顺序锁打破了循环等待条件,但若一个线程频繁获取锁,另一个低频率线程可能永远等待,产生饥饿。公平锁按请求顺序调度可解决。


12. ReentrantLock高级锁机制

12.1 特性一览

  • 可重入:同一线程可多次获取同一把锁。
  • 可中断lockInterruptibly()支持响应中断。
  • 可超时tryLock(time, unit)避免永久阻塞。
  • 公平/非公平:构造函数参数控制,公平锁按等待队列分配,解决饥饿但吞吐量略低。
  • 多条件变量:一个锁可创建多个Condition

12.2 Condition与 await/signal

Lock lock = new ReentrantLock();
Condition notFull = lock.newCondition();
Condition notEmpty = lock.newCondition();

可精确控制等待/通知,优于synchronized的单一等待集合。

12.3 ReadWriteLock读写锁

ReentrantReadWriteLock,读读共享,读写互斥,适合读多写少场景。内部同样基于AQS,写锁可降级为读锁(获取写锁后再获取读锁,释放写锁)。

12.4 实践:控制交替输出

要求:三个线程分别打印A、B、C,循环10次。用ReentrantLock条件变量实现:

public class PrintABC {
    private int state = 0;
    private Lock lock = new ReentrantLock();
    private Condition condA = lock.newCondition();
    private Condition condB = lock.newCondition();
    private Condition condC = lock.newCondition();
    
    public void printA() {
        lock.lock();
        try {
            for (int i = 0; i < 10; i++) {
                while (state % 3 != 0) condA.await();
                System.out.print("A");
                state++;
                condB.signal();
            }
        } finally { lock.unlock(); }
    }
    // B、C线程类似...
}

等价实现还有synchronized+wait/notifyLockSupportpark/unpark版本。

核心问答
Q:synchronized与ReentrantLock如何选择?tryLock带超时如何避免死锁?
A:简单互斥首选synchronized,需要可中断、超时、公平锁或多条件变量时用ReentrantLock。使用tryLock可尝试获取多个锁,若成功则执行,失败则释放已获锁并重试,打破“持有并等待”从而避免死锁。


13. Java内存模型(JMM)

13.1 三大特性

  • 原子性:一个或多个操作整体不被中断。synchronizedLock可保证。
  • 可见性:一个线程对共享变量的修改对其他线程立即可见。volatilesynchronizedfinal均可保证。
  • 有序性:禁止指令重排序。volatile通过内存屏障禁止特定重排序,synchronized块内可重排但对外呈现原子性。

13.2 volatile关键字

修饰的变量会直接刷新到主存,并通知其他线程缓存行失效,保证可见性。同时禁止对该变量读写前后的指令重排序(内存屏障)。
陷阱volatile不保证复合操作的原子性(如count++),只适用于一个线程写、多个线程读的场景。

13.3 指令重排序与happens-before

CPU和编译器可能在不改变单线程执行结果的前提下重排指令,但多线程下可能暴露问题。happens-before规则定义操作间的内存可见性顺序(如锁释放happens-before后续锁获取,volatile写happens-before后续volatile读等)。

核心问答
Q:volatile适合什么场景?为什么不能替代synchronized?
A:适合状态标志位、双重检查锁定(DCL单例)、volatile bean模式等。不能替代,因为它不保证代码块的原子性,不能保护多个步骤组成的临界区。


14. 线程池核心原理

14.1 参数与工作流程

ThreadPoolExecutor构造参数:核心线程数、最大线程数、空闲存活时间、工作队列、线程工厂、拒绝策略。提交任务时的流程:

  1. 当前线程数 < 核心线程数,直接新建线程执行任务。
  2. 否则尝试将任务加入工作队列。
  3. 若队列已满,且线程数 < 最大线程数,新建线程执行。
  4. 否则执行拒绝策略。

关键问题:当任务队列未满,但当前线程数小于最大线程数时,会不会创建新线程?
答:不会。只有队列满了才会创建超过核心线程数的线程。因为设计原则是优先用核心线程+队列处理任务,只有压力大到队列塞满时才额外创建临时线程,任务高峰过后临时线程会被回收。

14.2 常见拒绝策略

  • AbortPolicy(抛异常)
  • CallerRunsPolicy(由调用线程执行)
  • DiscardPolicy(静默丢弃)
  • DiscardOldestPolicy(丢弃队列最老任务)

14.3 自定义线程池监控

可继承ThreadPoolExecutor,重写beforeExecuteafterExecuteterminated来记录任务耗时、异常、队列大小等。

核心问答
Q:线程池在什么情况下会创建超过核心线程数的线程?队列未满时提交任务的行为是怎样的?
A:只有核心线程满且队列满时才会创建新线程,直至最大线程数。队列未满时,任务一律入队,不创建新线程(无论当前线程数是否小于最大线程数),这体现了“核心线程+队列缓冲”优先的设计。


15. 总结与学习路线

15.1 整体流程串联

本文从进程线程基础→线程API与状态→共享模型与锁优化→JMM→高级并发工具→线程池,层层递进。掌握这些就能应对大多数并发编程场景。

15.2 线上事故复盘精要

  • 中断标识不清除:导致线程永不退出。
  • wait误用if:虚假唤醒造成逻辑错误。
  • 锁顺序不当:死锁。
  • 不可变对象未正确发布:可见性问题。
  • 线程池队列无界:OOM。

15.3 进一步学习建议

  • 深入AQS(AbstractQueuedSynchronizer)——几乎所有并发工具的基础。
  • 并发集合:ConcurrentHashMap、CopyOnWriteArrayList等。
  • ForkJoinPool、CompletableFuture异步编程。
  • JVM对锁的底层实现源码分析。

并发编程是实践性极强的领域,代码+工具+监控缺一不可。愿这份笔记能帮你构建扎实的并发知识体系。

更多推荐