1. 基础概念
并发与并行
并发是指多个任务在同一时间段内被调度执行,宏观上“同时”,微观上可能交替执行,单核CPU的并发靠的是操作系统快速切换上下文。
并行是指多个任务真正同时执行,靠的是多核CPU。
Java的线程池是并发还是并行取决于CPU,单核CPU为并发,多核CPU为并行。
进程、线程、协程 概念与区别
进程是操作系统资源分配的基本单位,每个进程都有独立的内存空间,文件句柄,系统资源,进程之间相互隔离、安全性高、但切换开销大。
线程是CPU调度的基本单位,一个进程中可以包含多个线程,线程共享同一个进程的内存和资源,线程比进程轻量,但数据共享带来线程安全问题。
协程是比线程更轻量的“用户态线程”,不由操作系统调度,而由用户程序调度,切换不需要陷入内核态(比线程上下文切换更便宜),Java 本身没有原生协程,但 Kotlin 有(挂起函数),Java Loom 项目未来会支持虚拟线程(类似协程)。
协程本质可以理解为用户态的可挂起/恢复的函数,或者说可以暂停的函数。
线程上下文切换
当多个线程竞争 CPU 时,操作系统需要不断将线程暂停、恢复,这个过程就是上下文切换。
需要切换的内容有:程序计数器(PC)、线程私有栈、寄存器内容、内核态/用户态切换
由于频繁切换会影响性能,并发不是越多线程越好,过多反而降低性能
线程的生命周期
Java 线程有 6 种状态(Thread.State 枚举):
NEW:创建了线程对象,但还没 start()
RUNNABLE:可能正在运行,也可能正在等待 CPU 调度(Java 将就绪和运行合并)
BLOCKED:等待获取 synchronized 锁
WAITING:等待 notify()、join() 等(无限等待)
TIMED_WAITING:带超时的等待(如 sleep(1000))
TERMINATED:执行完 run() 方法
其中 RUNNABLE ≠ RUNNING,在Java 中 RUNNABLE 包括了 “就绪 + 运行” 两种状态,因为JVM 只关心这个线程“能不能运行”,只要线程不处于等待、定时等待、阻塞,只要它具备运行条件,就归为 RUNNABLE。
注意,所谓Java的线程状态是JVM规定的,而不是操作系统规定的。不同的操作系统对线程状态的划分不一样,如果直接照搬某一种划分,就无法实现跨平台性。
用户进程、守护进程
用户进程是程序的核心逻辑线程,例如 main、业务线程,JVM 必须等待用户线程全部结束才退出。
守护进程是为用户线程提供服务的线程,如 GC,当只剩守护线程时,JVM 直接退出。
例如:
public class Test {
public static void main(String[] args) {
Thread t = new Thread(() -> {
try {
while (true) {
System.out.println("daemon working...");
}
} finally {
System.out.println("daemon finally");
}
});
t.setDaemon(true);
t.start();
// 主线程(用户线程)很快结束
System.out.println("main exit");
}
}代码输出为:
main exit
daemon working...
daemon working...
daemon working...
daemon working...
(JVM退出)发现t线程中的finally代码没有执行,守护线程不是 JVM 正常关闭的阻碍,JVM 不会等待守护线程完成,因此守护线程的 finally 代码不一定能执行。
线程池中默认使用用户线程。
2. Java内存模型(JMM)
为什么需要JMM
JMM 是 Java 为解决并发语义问题,屏蔽不同 CPU、不同操作系统的内存行为而制定的抽象规范。
其目标是解决多线程读写共享变量时的三大问题:
可见性(线程看到的数据不一致)
原子性(操作被 CPU 断开)
有序性(指令重排)
内存结构:主内存 & 工作内存
JMM规定内存分为两部分:
主内存:所有共享变量存放的地方(Java堆/方法区),所有线程都能访问
工作内存:每个线程私有,相当于线程的CPU缓存副本
线程不会直接操作主内存,而是读写自己的工作内存
JMM解决的三大问题
原子性:
一个操作或多个操作要么全部执行且执行过程不被中断,要么全部不执行。例如i++看似简单,实则包含读取、修改、写入三个步骤,多线程环境下可能出现部分执行的情况。JMM 中,synchronized 和 JUC(java.util.concurrent)中的原子类(如 AtomicInteger)通过锁机制或CAS 操作保证原子性。
可见性:
当一个线程修改了共享变量的值,其他线程能否立即看到这个修改。在多核 CPU 架构中,每个线程可能拥有独立的缓存,若未遵循缓存一致性协议,就会导致 “线程 A 修改了变量,线程 B 却读取到旧值” 的现象。JMM 通过volatile 关键字、synchronized和final等机制,强制刷新缓存,保证变量修改的即时可见。
有序性:
程序执行的顺序是否与代码顺序一致。编译器的指令重排序、CPU 的乱序执行等优化可能改变代码实际执行顺序,在单线程下这是透明的优化,但多线程中可能导致逻辑错误(如 DCL 单例模式中的指令重排问题)。JMM 通过volatile、synchronized和happens-before 规则限制重排序,确保有序性。
Happens-before
是JMM中最重要的概念,所有并发行为都基于他。前一个操作的结果必须对后一个操作可见,并且前一个操作按顺序发生。
几个happens-before规则:
程序顺序规则(Program Order Rule):同一个线程内的代码按照程序顺序执行
监视器锁规则(synchronized):解锁(unlock) happens-before 之后的加锁(lock),解决可见性 + 原子性 + 有序性
volatile 规则:写 volatile happens-before 读 volatile,保证可见性 + 有序性(禁止重排),不保证原子性
线程启动规则(Thread Start Rule):thread.start() happens-before 线程内部的任何操作
线程终止规则(Thread Join Rule):线程结束 happens-before join() 返回
传递性:A happens-before B,B happens-before C → A happens-before C
volatile的内存语义
volatile 关键字其实并非是 Java 语言特有的,在 C 语言里也有,它最原始的意义就是禁用 CPU 缓存。如果我们将一个变量使用 volatile 修饰,这就指示编译器,这个变量是共享且不稳定的,每次使用它都到主存中进行读取。
在 Java 中,volatile 关键字除了可以保证变量的可见性,还有一个重要的作用就是防止 JVM 的指令重排序。 如果我们将变量声明为 volatile ,在对这个变量进行读写操作的时候,会通过插入特定的内存屏障的方式来禁止指令重排序。
为了优化性能,代码可能被 CPU 和编译器重排,不会改变单线程的最终执行结果,但多线程下重排会导致严重问题
Java 使用内存屏障指令(memory barrier)保证:读写行为不被重排+缓存一致性
典型屏障有LoadLoad Barrier、StoreStore Barrier、LoadStore Barrier、StoreLoad Barrier(最昂贵)
在 Java 内存模型中,volatile 写操作前后会插入 StoreStore + StoreLoad 屏障,volatile 读操作前会插入 LoadLoad + LoadStore 屏障,从而保证可见性和禁止重排序。
synchronized 会使用 monitorenter/monitorexit,间接插屏障
volatile规则可以保证可见性与有序性,但不保证原子性。
3. 线程的创建与管理
三种创建方式
Java 创建线程有三种最常见的方式:
继承 Thread 类(但Java只能单继承,继承 Thread 会限制继承其他类,因此实际开发几乎不用这种方式):
class MyThread extends Thread {
@Override
public void run() {
System.out.println("Running");
}
}
new MyThread().start();实现 Runnable 接口(推荐方式,可被多个线程共享,可继承):
class MyTask implements Runnable {
@Override
public void run() {
System.out.println("Running");
}
}
new Thread(new MyTask()).start();实现 Callable(带返回值的线程任务,能抛出异常,常用于线程池):
Callable<Integer> task = () -> {
return 100;
};
FutureTask<Integer> ft = new FutureTask<>(task);
new Thread(ft).start();
Integer result = ft.get();start() or run()
创建新线程需要调用start()方法而不是run()方法,只有调用start()方法 JVM 才会创建新的线程,而调用run()方法只是普通的方法调用,在当前线程执行。
线程的核心管理方法
sleep():
Thread.sleep(1000);该方法不释放锁,不依赖monitor,会让出CPU,但保持线程状态为 TIMED_WAITING
yield():
Thread.yield();让线程“尝试”让出 CPU,但不保证真的让出(操作系统可能无视)
join()(线程等待):
t.join();当前线程等待另一个线程结束,本质通过 wait() + notifyAll() 实现
interrupt()(中断机制):
t.interrupt();Java 的线程不能被强制中断,只能“请求中断”,线程内部需要配合:
if(Thread.currentThread().isInterrupted()) { ... }线程的优先级
线程可以设置优先级:
thread.setPriority(Thread.MAX_PRIORITY);但优先级不可靠,通常不依赖,而且不同 OS 对优先级支持差异很大,不保证生效。
线程的栈空间
可以设置线程的栈大小:
new Thread(null, runnable, "threadName", 2 * 1024 * 1024)栈太大会增加内存消耗;栈太小可能导致 StackOverflowError
为什么不推荐手动创建线程
手动创建线程创建销毁开销大,无法管理线程数,无法复用线程,无法执行任务队列,缺乏拒绝策略,无法监控线程状态,实际开发中一般使用线程池。
4. ThreadLocal
什么是 ThreadLocal
ThreadLocal 并不是用来解决线程安全问题的,而是用来解决线程隔离问题。
ThreadLocal 的作用是:
为每个线程提供一个变量的独立副本,不同线程之间互不影响。
常见应用场景:
保存用户上下文(userId、traceId)
保存数据库连接 / Session
保存事务上下文
链路追踪(MDC)
ThreadLocal 的基本用法
ThreadLocal<String> tl = new ThreadLocal<>();
tl.set("hello");
String value = tl.get();
tl.remove();每个线程调用 set() / get() 访问到的,都是当前线程自己的变量副本。
ThreadLocal 的底层原理
核心结构
ThreadLocal 的数据并不存储在 ThreadLocal 对象中,而是存储在线程内部:
class Thread {
ThreadLocal.ThreadLocalMap threadLocals;
}真正的数据结构是 ThreadLocalMap,属于 Thread 对象。
ThreadLocalMap 的结构
ThreadLocalMap 是 ThreadLocal 的静态内部类,本质是一个定制化的 HashMap:
static class ThreadLocalMap {
static class Entry extends WeakReference<ThreadLocal<?>> {
Object value;
}
}关键点:
key:ThreadLocal(弱引用)
value:实际存储的值(强引用)
为什么 ThreadLocal 的 key 是弱引用?
设计原因
如果 key 使用强引用:
Thread → ThreadLocalMap → ThreadLocal(key)即使外部 ThreadLocal 变量已经不可达,也无法被 GC,导致内存泄漏。
使用 WeakReference:
当 ThreadLocal 对象没有外部强引用时
key 会被 GC 回收
ThreadLocal 为什么仍然会内存泄漏?
弱引用 key 被回收,但 value 是强引用!
泄漏场景:
Thread (线程池中的线程,长期存活)
└── ThreadLocalMap
└── Entry (key=null, value=Object)key 被 GC 回收 → 变成
nullvalue 仍然强引用
线程不结束 → value 永远无法释放
什么时候 ThreadLocal 最容易泄漏?
线程池 + ThreadLocal:
ExecutorService pool = Executors.newFixedThreadPool(10);
ThreadLocal<User> tl = new ThreadLocal<>();
pool.submit(() -> {
tl.set(user);
// 忘记 remove()
});线程池线程长期复用
ThreadLocalMap 不会销毁
value 持续堆积
如何正确使用 ThreadLocal
必须在 finally 中 remove:
try {
tl.set(value);
// 业务逻辑
} finally {
tl.remove();
}这是 使用 ThreadLocal 的铁律。
InheritableThreadLocal
InheritableThreadLocal 可以让子线程继承父线程的 ThreadLocal 值:
InheritableThreadLocal<String> itl = new InheritableThreadLocal<>();注意:
只在线程创建时拷贝
线程池中的线程是复用的 → 不生效
TransmittableThreadLocal(TTL)
阿里开源的 TransmittableThreadLocal:
解决 线程池中上下文无法传递的问题
在任务提交时复制上下文
常用于日志 traceId、链路追踪
ThreadLocal 与并发安全的关系
ThreadLocal ≠ 线程安全
ThreadLocal = 线程隔离
ThreadLocal 通过 不共享数据 避免并发问题,而不是通过加锁。
ThreadLocal 使用总结
5. synchronized
原理
synchronized 是 Java 中最重要的同步手段,保证原子性、有序性、可见性。
其底层实现为对象头:Java 的每一个对象都有对象头(Object Header),其中一部分叫 Mark Word,存储着synchronized 的锁信息,结构如下:
不同锁状态下,Mark Word 的内容也不同。
而在编译后,源代码:
synchronized(obj) {
}会变为:
monitorenter
...
monitorexitsynchronized 同步语句块的实现使用的是 monitorenter 和 monitorexit 指令,其中 monitorenter 指令指向同步代码块的开始位置,monitorexit 指令则指明同步代码块的结束位置。
在执行monitorenter时,会尝试获取对象的锁,如果锁的计数器为 0 则表示锁可以被获取,获取后将锁计数器设为 1 也就是加 1。对象锁的拥有者线程才可以执行 monitorexit 指令来释放锁。在执行 monitorexit 指令后,将锁计数器设为 0,表明锁被释放,其他线程可以尝试获取锁。
线程申请 monitor 时,若发现已经是自己持有,就会直接进入,所以说synchronized 是可重入锁。
synchronized 内存语义
synchronized 的 happens-before 语义:
进入 synchronized(monitorenter)之前 → 会清空当前线程的工作内存
退出 synchronized(monitorexit)之后 → 会将工作内存刷新到主内存
锁的释放 happens-before 锁的获取
三种使用方式
修饰实例方法(锁对象:当前实例 this):
public class Counter {
private int count = 0;
public synchronized void increment() {
count++;
}
}等价于:
public void increment() {
synchronized (this) {
count++;
}
}该方式锁的是 当前对象实例,不同实例之间 互不影响,是最常见、最安全的写法之一
适用于对象级别的共享数据,单例 / Spring Bean 中的实例变量
修饰静态方法(锁对象:Class 对象):
public class Counter {
private static int count = 0;
public static synchronized void increment() {
count++;
}
}等价于:
public static void increment() {
synchronized (Counter.class) {
count++;
}
}该方式锁的是 Class 对象,全 JVM 中该类只有一把锁,比实例锁粒度更大
适用于静态变量,全局共享资源
synchronized 代码块(锁对象:任意对象):
public class Counter {
private int count = 0;
private final Object lock = new Object();
public void increment() {
synchronized (lock) {
count++;
}
}
}锁对象可以自定义,锁粒度最小、性能最好,是最推荐的写法(控制更精细)
重量级锁?
在 Java 早期版本中,synchronized 属于 重量级锁,效率低下。这是因为监视器锁(monitor)是依赖于底层的操作系统的 Mutex Lock 来实现的,Java 的线程是映射到操作系统的原生线程之上的。如果要挂起或者唤醒一个线程,都需要操作系统帮忙完成,而操作系统实现线程之间的切换时需要从用户态转换到内核态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高。
不过,在 Java 6 之后, synchronized 引入了大量的优化如自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销,这些优化让 synchronized 锁的效率提升了很多。因此, synchronized 还是可以在实际项目中使用的,像 JDK 源码、很多开源框架都大量使用了 synchronized 。
锁升级机制
锁有四个状态,按低到高依次是:
无锁
偏向锁(Biased Lock)
轻量级锁(Lightweight Lock)
重量级锁(Heavyweight Lock)
锁只能升级,不能降级(除了自旋锁内部优化)
无锁→偏向锁:
第一次加锁时,将对象头 Mark Word 设置为:
偏向模式
持有偏向的线程 ID
下次同一线程再进入锁 → 直接进入,不做任何同步操作
适用于锁长期由同一线程持有,如 StringBuffer、单线程执行同步方法
偏向锁→轻量级锁:
当第二个线程竞争锁时,偏向锁撤销,升级为轻量级锁。
线程会在栈中创建一个 Lock Record(锁记录),尝试用 CAS 将对象头 Mark Word 替换为指向 Lock Record 的地址,如果 CAS 成功则获得锁,失败则表明有竞争,自旋。
轻量级锁比重量级锁快,但自旋会占用 CPU
轻量级锁→重量级锁:
当自旋失败次数达到阈值时,JVM 将锁升级为 “重量级锁”,进入阻塞(BLOCKED),队列管理等待线程,解锁时唤醒下一个线程
重量级锁本质上依赖 OS 的互斥量(mutex),涉及内核态切换,开销最大
6. 锁与并发工具类
LockSupport
是 Java 并发阻塞/唤醒的最底层操作,AQS 内部所有阻塞机制都基于 LockSupport 实现。
提供两个原语:
park();
unpark(thread);特点:
不需要获得锁
unpark 先发生不会丢失(与 notify 不同)
用 Unsafe.park/unpark 实现
允许构建各种同步器(AQS 基于它)
AQS(AbstractQueuedSynchronizer)
AQS用一个状态值(state)和一个 FIFO 队列(CLH 队列)来实现线程阻塞/唤醒,几乎所有锁都基于 AQS。
AQS 的核心组成:
一个 volatile int state(同步状态)
一个 FIFO CLH 双向队列
acquire/release 公共模板方法
park/unpark 实现阻塞和唤醒
CLH队列结构:
//每个节点结构
static final class Node {
volatile Node prev;
volatile Node next;
volatile Thread thread;
volatile int waitStatus;
}waitStatus:
0 → 正常
SIGNAL → 释放锁时需要唤醒该节点
CANCELLED → 超时或中断
CONDITION → Condition 专用状态
AQS内部可以理解为两个队列:
同步队列(Synch Queue)——锁竞争的队列
等待队列(Condition Queue)——对应 Condition.await() 的队列
ReentrantLock
ReentrantLock 的本质是基于 AQS 的可重入互斥锁,使用 CAS + CLH 队列 + LockSupport 实现。
结构:
public class ReentrantLock implements Lock {
private final Sync sync;
abstract static class Sync extends AbstractQueuedSynchronizer { ... }
static final class NonfairSync extends Sync { ... }
static final class FairSync extends Sync { ... }
}锁的逻辑其实全部在 Sync(继承 AQS) 中
对于 ReentrantLock来说,AQS内部的state表示锁状态,为0时说明锁空闲,不为0时说明锁已被某线程持有(值 = 重入次数)
默认为非公平锁,允许刚来的现在CAS抢锁:
final void lock() {
if (compareAndSetState(0, 1)) { // 1. 直接用 CAS 抢锁(插队)
setExclusiveOwnerThread(Thread.currentThread());
} else {
acquire(1); // 2. CAS 失败 → 进入 AQS acquire 流程
}
}也可以指定为公平锁,不允许插队:
final void lock() {
acquire(1);
}acquire方法作用为获取独占锁:
acquire(arg):
1. 尝试 tryAcquire() → 尝试获取锁(子类 ReentrantLock 实现)
2. 失败 → 将当前线程包装为 Node 加入 CLH 同步队列尾部
3. 自旋(for (;;))检查前驱节点是否是头节点
4. 如果是 → 再次 tryAcquire()
5. 如果还失败 → park() 阻塞线程
6. 被 unpark() 唤醒后继续循环ReentrantLock的解锁流程:
release(arg):
1. tryRelease(arg) → state--,若 state == 0 则释放锁成功
2. 若锁彻底释放成功 → 唤醒队列头节点的后继节点(unpark)Condition
Condition为多条件队列,是 ReentrantLock 的“等待队列机制”,让你可以让线程在锁内部“有条件地等待和唤醒”。
synchronized 的 wait/notify 是“单一等待队列”,无法有条件的唤醒某类线程,而一个 Lock 可以创建多个 Condition,这就等于同一个锁,拥有多个独立的等待队列,每个队列对应一种“条件状态”
Condition的三个核心方法:
await():把当前线程放入该 Condition 的队列,并释放锁 → 阻塞
signal():唤醒 Condition 队列中的一个线程(不会立即拿到锁,需要参与锁竞争)
signalAll():唤醒该条件队列中的所有线程
await() 步骤:
1 线程被封装成 Node(状态为 CONDITION)
2 加入 Condition 的等待队列
3 释放锁(state = 0)
4 线程被 park() 阻塞
5 被 signal 唤醒后,从 Condition 队列移动到同步队列
6 再次竞争锁
7 获取锁后 await 返回ReentrantReadWriteLock
读写锁,设计思想为读之间不互斥,写与写互斥,读与写互斥,适用于“读多写少”的高并发场景。
读锁不升级为写锁,写锁可以降级为读锁
StampedLock
是JDK8 引入的新型锁,有悲观读锁、乐观读锁、写锁三种模式。
其中乐观读锁性能高,是其最大的亮点:
long stamp = lock.tryOptimisticRead();
// 使用共享数据
if (!lock.validate(stamp)) {
// 失败,升级为悲观读锁
stamp = lock.readLock();
try {
} finally {
lock.unlockRead(stamp);
}
}乐观读不阻塞写线程,但必须 validate() 确认数据是否被写线程修改,适用于数据读多写少,对实时一致性要求不像金融系统那么高的场景。
但StampedLock是不可重入的。
7. CAS 与原子类(Atomic 包)
什么是CAS
CAS(Compare-And-Swap) 是一种 无锁原子操作:比较内存中的值是否等于期望值,如果是,则更新为新值;否则失败。
比较 + 修改 是一个不可分割的原子操作,这是 CAS 的核心
CAS为什么原子
CAS 并不是 Java 发明的,而是 CPU 指令级支持:
x86:
LOCK CMPXCHGARM:
LDXR / STXRRISC-V:
LR / SC
这些指令保证:
在执行 CAS 时,CPU 会锁住对应内存地址
或使用缓存一致性协议(MESI)
确保 多核下只有一个 CPU 能成功修改
Java 的 CAS 实际是 JVM 调用 CPU 的原子指令。
CAS三大问题
ABA 问题:
线程 A 读取到值 A,线程 B 将 A 改成 B,又改回 A,线程 A CAS 成功,但“期间发生过修改”。
解决:使用版本号/AtomicStampedReference/AtomicMarkableReference
自旋开销问题:
CAS 在竞争激烈时,大量线程 CAS 失败,无限自旋,CPU 飙高
只能保证一个变量的原子性:
CAS 只能作用于一个内存地址,无法保证多个变量的一致性,无法实现复杂临界区,这时需要锁。
CAS vs synchronized
Atomic包
基础原子类:
版本号原子类(解决 ABA):
数组原子类:
AtomicIntegerArray
AtomicLongArray
AtomicReferenceArray
字段更新器(反射 + CAS):
AtomicIntegerFieldUpdater
AtomicLongFieldUpdater
AtomicReferenceFieldUpdater
为什么Atomic保证原子性
Atomic 类内部最终都会调用CAS方法,CAS 操作是硬件保证的原子操作,多线程并发更新时,只有一个线程 CAS 成功,失败的线程会重试(自旋)。
AtomicInteger如何工作
以incrementAndGet为例:
public final int incrementAndGet() {
return unsafe.getAndAddInt(this, valueOffset, 1) + 1;
}底层逻辑:
do {
old = value;
new = old + 1;
} while (!CAS(value, old, new));失败就自旋重试,直到成功。
LongAdder、DoubleAdder
LongAdder是高并发下 AtomicLong 的升级优化版,是 Atomic 包的一员,它通过分段 CAS 解决 AtomicLong 在高并发下的竞争问题,是高吞吐计数的优化方案。
LongAdder 比 AtomicLong 快:
AtomicLong:
所有线程 CAS 同一个变量
高并发下冲突严重
LongAdder:
分段计数(Striped Cells)
每个线程更新不同的 Cell
最后 sum() 汇总
LongAdder 用空间换时间,牺牲实时精确性换高并发性能
8. 并发容器
早期线程安全集合:Collections.synchronizedXxx
早期的线程安全集合(比如 Vector、Hashtable,或者 Collections.synchronizedList)都依赖synchronized 整体加锁(方法级别 + JVM Monitor)
这种整体加锁的方法有很多缺点,锁的粒度大,整个对象是一个大锁,只有一个线程能访问。没有读写分离,读写操作是互斥的,且对高并发完全不适用。每个操作都要获取监视器锁,开销大。
JUC 并发容器
JUC(java.util.concurrent)中的容器采用现代并发结构:
分段锁 / Node 局部锁
无锁结构(CAS、自旋)
读写分离
写时复制(CopyOnWrite)
阻塞队列(BlockingQueue)
JUC 的并发容器主要分为 5 类:
ConcurrentHashMap
CopyOnWriteArrayList / CopyOnWriteArraySet
ConcurrentLinkedQueue / ConcurrentLinkedDeque
BlockingQueue 系列
并发跳表:ConcurrentSkipListMap / SkipListSet
ConcurrentHashMap
在JDK7和JDK8中是两种方案:
JDK7结构:
ConcurrentHashMap
└── Segment[] segments ← 每个 Segment 一个锁
└── HashEntry[] table整个 Map 被分成 16 个 Segment(默认),每个 Segment 内独立加锁(ReentrantLock),写操作锁 Segment,读操作无锁(volatile 读)
缺点是Segment 数量固定,并发程度有限,扩容时锁住整个 Segment,性能也一般。
JDK8结构:
Node[] table ← 数组 + 链表 + 树使用 CAS + synchronized 替代 ReentrantLock,将链表树化优化查找。仅对单个桶位(bin)加锁,而不是全段加锁,扩容采用协作式迁移(多个线程一起搬迁 Node)
put/get操作流程如下:
1. table 未初始化 → CAS 初始化
2. 根据 hash 定位桶位 i
3. 如果 table[i] 为空 → CAS 插入新 Node(无锁)
4. 如果非空 → synchronized 锁住这个 bin
5. 链表插入(或树插入)
6. 如果长度超过 8 → 树化(红黑树)
计算 hash,定位 table[i]
如果链表则遍历
如果是红黑树则树查找
因为节点的 value / next 是 volatile,因此无锁也能安全读
HashMap可以存储null值,但ConcurrentHashMap不能,原因在于null 值会让 get() 的返回无法区分“没有 key”还是“key 映射到 null”,并发情况下会造成二义性
CopyOnWriteArrayList / CopyOnWriteArraySet
写时复制容器,原理为写操作时复制整个数组,写完后替换引用;读操作无锁。
写操作:
lock.lock();
try {
Object[] newArr = Arrays.copyOf(oldArr);
newArr[i] = x;
array = newArr;
} finally {
lock.unlock();
}读操作:
return array[index]; // 无锁读这种方案的优点是读极快(无锁),可以避免读写冲突,适合读远多于写的场景
但是写代价巨大,内存开销大
ConcurrentLinkedQueue
无锁队列,也是经典 CAS 队列,结构基于 Michael-Scott Lock-Free Queue(M&S 算法)
特点是入队 CAS 不加锁,出队 CAS 不加锁,链表结构,不扩容
出队/入队只会 CAS 头/尾引用:
CAS tail
CAS head优点是无锁,性能极高,无阻塞
缺点是非有界队列,可能 OOM。不是严格 FIFO,在极端情况下有轻微延迟
BlockingQueue 系列
是生产者消费者框架的核心,这些队列内部都使用 锁 + Condition 来实现阻塞行为。
主要有:
其中,
ArrayBlockingQueue 的特点:
单 ReentrantLock + 两个 Condition
put 阻塞直到不满
take 阻塞直到不空
LinkedBlockingQueue 的特点:
用两个锁提高吞吐量(putLock、takeLock)
读写互不影响。
ConcurrentSkipListMap
跳表结构,支持有序 Map,比 TreeMap 线程安全。基于随机层级,非阻塞 CAS 实现。适合需要排序的高并发场景如排行榜等等。
跳表为什么能做成线程安全?
跳表是“多层有序链表”的结构,天然适合无锁化(Lock-Free / CAS)操作,每个节点之间是有序链表结构,可以只更新局部节点,不需要全局锁。
插入/删除只影响局部指针,删除使用标记法无需立即物理删除,链表结构不存在树旋转,可以用 CAS 更新 next 指针,因此可以实现 Lock-Free 的高并发结构。
跳表 vs 红黑树?
时间复杂度一致,查找添加删除都为O(log n),细微的差别在跳表常数项较小,而红黑树由于严格平衡的结构在极端情况下稍快
跳表并发结构简单,易实现无锁,结构修改简单。红黑树实现无锁需要树旋转,结构修改复杂。
跳表性能很好,红黑树略快但差距极小。
跳表比红黑树占用更多内存,有多层索引且每个节点有多个 next 指针,使用空间换时间。
跳表适合并发,是因为插入和删除只需要调整局部指针,不会破坏全局结构,因此可以基于 CAS 实现 lock-free;而红黑树需要旋转和颜色调整,多个节点之间强耦合,很难做到无锁化,所以没有 ConcurrentRedBlackTree。
跳表空间占用更高,但并发性能远优,红黑树则单线程性能略好但难并发化。
9. 线程池
设计目的
线程池的设计目标是三个:
降低线程创建和销毁的成本(线程是重量级资源)
控制并发数量(避免 OOM、过载)
任务调度能力(任务队列 + 拒绝策略)
ThreadPoolExecutor 的七大核心参数
ThreadPoolExecutor 的构造函数:
public ThreadPoolExecutor(
int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue<Runnable> workQueue,
ThreadFactory threadFactory,
RejectedExecutionHandler handler)corePoolSize:核心线程数。核心线程默认不会被回收,即使空闲也会一直存在。线程池创建之后不会立刻创建核心线程,只有任务提交才会创建
maximumPoolSize:最大线程数。核心线程数 < 活动线程数 ≤ 最大线程数。
workQueue:任务队列。队列的选择决定线程池行为,常用队列类型有SynchronousQueue、LinkedBlockingQueue、ArrayBlockingQueue、PriorityBlockingQueue
unit:时间单位。用来指定 keepAliveTime 的时间单位。
keepAliveTime:非核心线程存活时间。只有当队列满时才会创建非核心线程,默认只有非核心线程才会受此参数影响。
threadFactory:线程工厂。用于创建线程,一般用来自定义线程名称,设置 daemon/priority 与自定义 UncaughtExceptionHandler
handler:拒绝策略。当队列满、线程数达到 maximumPoolSize 时执行RejectedExecutionHandler.rejectedExecution(),四种内置拒绝策略:
线程池执行流程
当调用:
executor.execute(task);ThreadPoolExecutor 会按如下顺序处理:
步骤 1:如果当前线程数 < corePoolSize → 创建新线程执行任务。
步骤 2:如果当前线程数 ≥ corePoolSize → 尝试将任务放入队列
步骤 3:如果队列已满 → 尝试创建新线程(非核心线程)
步骤 4:如果线程数达到 maximumPoolSize → 调用拒绝策略 handler
内部工作线程
ThreadPoolExecutor 内部用 Worker 包装线程:
private final class Worker
extends AbstractQueuedSynchronizer
implements Runnable {Worker 继承 AQS,本身是一把锁,保存一个 firstTask,其run() 方法执行任务循环
run() 方法:
while (任务 != null 或 从队列取任务 != null) {
执行任务
}线程池状态
ThreadPoolExecutor 的 ctl(32bit)高 3 位表示状态:
任务队列的选择
SynchronousQueue(不存任务):
场景:缓存线程池(Executors.newCachedThreadPool)
行为:每提交一个任务,都需要找到一个空闲线程,否则创建新线程。
缺点:容易创建大量线程 → CPU 飙高
LinkedBlockingQueue(无限队列):
场景:默认 newFixedThreadPool
问题:
队列无限增长
maximumPoolSize 失效
容易导致 OOM
ArrayBlockingQueue(有界队列):
推荐生产使用的最佳选择
可以通过队列长度做流量控制。
PriorityBlockingQueue(带优先级):
用于任务按照优先级执行
注意任务必须实现 Comparable
配置线程池
公式来自《Java Concurrency in Practice》
对于CPU 密集型任务,为了避免上下文切换:
线程数 = CPU核心数 + 1对于 IO 密集型任务:
线程数 ≈ CPU核心数 × IO等待时间/CPU执行时间一般经验(大部分线程在等待 IO):
线程数 ≈ CPU 核数 × 2 ~ 4对于混合型任务:
拆分成 CPU 部分 + IO 部分,分别建线程池。
为什么不推荐 Executors 创建线程池
Executors 的默认实现存在严重隐患,不推荐用 Executors,推荐显式使用 ThreadPoolExecutor:
10. 并发编程中的设计模式
生产者–消费者模型
生产者–消费者模型的定义为:生产者生产数据,将它放入缓冲区;消费者从缓冲区获取数据处理。
Java 实现方式有三种:
① synchronized + wait/notify(基础版):
synchronized(lock) {
while(full) lock.wait();
put();
lock.notifyAll();
}缺点:
易写错
wait/notify 操作不精确
只有一个等待队列(WaitSet)
② BlockingQueue(推荐)
BlockingQueue 内部用ReentrantLock、Condition notEmpty、Condition notFull 实现阻塞队列。
例如:
BlockingQueue<Integer> queue = new LinkedBlockingQueue<>();生产者:
queue.put(x); // 队列满时阻塞消费者:
queue.take(); // 队列空时阻塞这是 Java 生产者–消费者的最推荐实现。
③ Disruptor(高性能场景)
用于交易撮合、日志处理、高频风控等场景。
底层是 RingBuffer + CAS + 内存屏障,比 BlockingQueue 快很多。
Future / Promise / CompletableFuture
传统 Future 的问题:
Future<String> f = executor.submit(task);
String result = f.get(); // 阻塞Future 无法组合多个任务,无法链式执行,无法非阻塞获取结果也不能捕获异常
CompletableFuture (Java 8):
CompletableFuture.supplyAsync(() -> task1())
.thenApply(result1 -> task2(result1))
.thenAccept(finalResult -> System.out.println(finalResult));特点:
完全异步(基于 ForkJoinPool)
提供 50+ API:thenApply、handle、exceptionally、allOf、anyOf…
解决回调地狱
异步执行流水线(任务依赖链)
CompletableFuture与回调函数的区别?CompletableFuture 提供声明式异步链,不依赖深层嵌套回调,同时内置异常传播机制。
线程池模式
线程池本身就是一种并发设计模式,其理念为用线程池复用线程,减少创建成本,并通过队列控制系统压力。
Java 的 ThreadPoolExecutor 实现了完整的线程池模式:
Worker 线程复用
任务队列
拒绝策略
队列 + 线程数控制负载
提供异步执行模型
Actor 模式(Akka、Erlang 模型)
Java 没有原生 Actor,但 Akka 实现了它,其核心思想为每个 Actor 都有自己的消息队列。
所有线程之间不共享内存,靠消息通信。
优点在于无锁编程,天然解决并发冲突,高扩展性
缺点是编程模型较复杂,在Java 中依赖 Akka 框架
应用:
分布式系统
电信系统(Erlang)
大规模聊天系统
Reactor 模式(Netty 的核心)
核心思想是一个(或多个)线程等待事件到来,然后分发到不同的处理器(Handler)。
用于 IO 多路复用(事件驱动 IO)
当你调用:
NioEventLoopGroup bossGroup = new NioEventLoopGroup();你就在使用 Reactor 模式。
过程:
Reactor 监听事件(accept、read、write)
事件发生 → Reactor 分发给处理器
Handler 执行具体逻辑
Netty 是经典 Reactor + 多线程版本。
应用:
高并发网络服务
聊天服务器
RPC 框架(Netty、Dubbo)
Fork/Join 模式(Java 7 引入的并行计算框架)
Fork/Join 的目标是将大任务拆分为多个小任务并行执行,再合并结果。
使用 RecursiveTask:
class Fibonacci extends RecursiveTask<Integer> {
protected Integer compute() {
if(n <= 1) return n;
Fibonacci f1 = new Fibonacci(n-1);
f1.fork();
Fibonacci f2 = new Fibonacci(n-2);
return f2.compute() + f1.join();
}
}底层使用:
工作窃取(Work Stealing)算法
每个线程都有自己的任务队列
空闲线程会偷别人的任务
用途:
CPU 密集型任务
并行计算(大数组求和、大文件处理)
Disruptor(高性能事件驱动模型)
Lmax 的 Disruptor 曾经打破交易系统的性能记录。
用途:
超低延迟系统
高频交易
银行风控
日志异步处理
核心机制:
RingBuffer(环形数组)
CAS + 内存屏障
无锁生产者消费者模型
快到能替代队列
Disruptor 为什么比 BlockingQueue 快?
避免 GC
自旋 CAS
内存连续、CPU 缓存友好
单 Producer 单 Consumer 情况性能极致
11. 并发 bug 与调优
并发的典型问题
死锁
两个或多个线程互相等待对方释放资源,导致永远无法继续执行。
四个必要条件:互斥(Mutual Exclusion)、占有并等待(Hold and Wait)不可抢占(Non-preemption)、循环等待(Circular Waiting)
synchronized(A) {
synchronized(B) { ... }
}
synchronized(B) {
synchronized(A) { ... }
}避免:
加锁顺序一致(最有效的方法)
尽量减少锁的粒度
使用 tryLock(timeout)
使用死锁检测工具
排查:
使用 jstack:
jstack <pid> | grep -A20 "Found one Java-level deadlock"活锁
线程没有阻塞,也没有死锁,但彼此“谦让”,导致任务一直无法完成。
解决:
引入随机时间退避(Backoff)
使用有边界的重试机制
饥饿
线程长期得不到 CPU 时间或资源(例如被高优先级线程长期压制)
解决:
使用公平锁(ReentrantLock(true))
避免线程优先级控制系统行为
不要在锁内执行耗时操作
伪共享
两个线程修改两个不同变量,但这两个变量位于同一个缓存行(cache line),导致 CPU 不断缓存失效,性能极差。
解决方法
使用 @Contended(JDK 8 需加 -XX:-RestrictContended)
将变量分散到不同缓存行
使用 LongAdder(内部已经避免伪共享)
调度延迟 / 上下文切换过多
CPU 高但程序处理能力低,Thread Dump 显示大量 Runnable,perf 显示 schedule() 开销大
原因可能是线程数远高于 CPU 核数/频繁阻塞与唤醒(锁竞争)/使用重量级锁
解决:
控制线程池线程数
减少锁竞争
使用无锁结构(CAS / Disruptor / LongAdder)
内存可见性问题
原因:
缓存导致线程读到旧值
指令重排导致读取时机不确定
缺乏同步手段(volatile / synchronized)
解决:
使用 volatile(状态标志)
synchronized / Lock(进入前清空本地缓存)
使用并发容器代替普通容器
并发问题如何排查
jstack
可以定位死锁、阻塞、等待
查看 Java 线程栈:
jstack <pid>关键看:
BLOCKED(锁竞争)
WAITING(等待 Condition)
TIMED_WAITING
RUNNABLE(可能在忙循环)
Arthas(阿里巴巴神器)
常用命令:
thread -b # 查看哪个线程阻塞了
thread -n 10 # 查看最耗 CPU 线程
monitor # 监控方法调用时间
trace # 方法调用链top + perf(查看系统层性能瓶颈)
比如:
perf top可以看到 CPU 在跑什么:
多在 futex → 说明锁竞争严重
多在 schedule → 上下文切换太多
多在 memcpy → 拷贝数据太多
多在 GC → GC 压力大
并发调优
优化 1:减少锁竞争
方法:
缩小锁的粒度(Lock Splitting / Fine-Grained Locking)
减少锁的持有时间(不要在锁里做 IO)
使用读写锁 ReentrantReadWriteLock
使用无锁结构(ConcurrentHashMap、CAS)
使用写时复制(CopyOnWrite)适合读多写少
优化 2:避免不必要的同步
场景:
synchronized(list) {
for (...) { ... }
}问题:持有锁时间过长
做法:拆成两段:
snapshot = new ArrayList(list); // 快照
// 无锁处理优化 3:选择合适的线程池
CPU密集型:
线程数 = CPU核数 + 1IO密集型:
线程数 = CPU核心数 * (1 + IO耗时/CPU耗时)
≈ 核心数 * 2~4优化 4:减少上下文切换
减少线程数量
避免频繁阻塞
使用协程(虚拟线程、Kotlin coroutine)
使用 Disruptor 或无锁队列
优化 5:提高缓存命中率(避免伪共享)
padding 填充缓存行
使用 @Contended
使用 LongAdder 替代 AtomicLong
调优总结
死锁来自循环等待,可以通过固定加锁顺序避免;活锁来自过度让步,需要随机退避;饥饿来自调度不公平,可以用公平锁或避免长时间占锁解决。伪共享会导致 CPU 缓存行抖动,使用 @Contended 或 LongAdder 可避免。并发调优本质是减少锁竞争、减少线程切换、提高缓存命中率。