Java性能优化多线程篇
Java性能优化 · 多线程篇
多线程性能调优围绕三条主线展开:
| 优化维度 | 核心问题 | 主要手段 |
|---|---|---|
| 锁优化 | 如何降低锁竞争与持有成本 | 锁升级、锁分离、锁分段、CAS 乐观锁 |
| 上下文切换 | 减少线程被切出/切入的开销 | 减少阻塞、协程、合理线程池 |
| 资源容器 | 不同场景选择最合适的并发容器 | ConcurrentHashMap / SkipListMap / CopyOnWriteArrayList / 阻塞队列 |
支撑以上三者的基础是:线程池的合理设置与对 Java 内存模型 / 一致性模型的理解。
锁优化
Synchronized
JDK1.5 及之前,Synchronized 性能很差,因为官方未对其实现进行相应的优化,JDK1.6 起其性能得到了明显的提升。
实现原理
- 修饰代码块:编译为
monitorenter+monitorexit指令,依赖对象的 Monitor(ObjectMonitor)。 - 修饰方法:通过
ACC_SYNCHRONIZED访问标志实现。 - 每个 Java 对象都关联一个 Monitor,线程获取 Monitor 即获得锁,未获取到的进入
_EntryList阻塞,调用wait()的进入_WaitSet。
锁升级路径(JDK1.6 引入,核心优化)
1 | |
| 锁状态 | 适用场景 | 关键机制 |
|---|---|---|
| 偏向锁 | 同一线程多次获取锁 | 对象头 Mark Word 记录线程 ID;高竞争下应关闭(-XX:-UseBiasedLocking),自JDK15 已经默认禁用了 |
| 轻量级锁 | 多线程交替执行、竞争不激烈 | CAS 修改 Mark Word 中的指针 |
| 自旋锁 | 锁占用时间极短 | CAS 循环重试;JDK1.7 默认开启;占用 CPU |
| 重量级锁 | 竞争激烈、锁占用时间长 | 线程挂起阻塞;用户态/内核态切换 |
编译期优化(JIT)
- 锁消除:通过逃逸分析确认锁对象只被一个线程访问,则消除 synchronized 字节码。
- 锁粗化:相邻同步块使用同一锁时,合并为一个大的同步块,减少反复申请/释放。
减小锁粒度
- 把数组/队列拆分为多个小对象,让竞争分散。
- 经典案例:JDK1.7 的
ConcurrentHashMap通过Segment(分段锁)降低锁粒度;JDK1.8 改为Synchronized + CAS,结构更简单。
核心结论:减少锁竞争是优化 Synchronized 的关键。尽量让锁停留在轻量级锁,通过减小锁粒度、减少锁持有时间来达成。
Lock 同步锁
与 Synchronized 的对比
- Synchronized:JVM 隐式加锁/释放;锁升级到重量级锁后性能不稳定。
- Lock:Java 层显式加锁/释放,基于乐观锁实现;但线程阻塞时仍会挂起,本质上仍属悲观锁。
AQS 框架
AbstractQueuedSynchronizer(AQS)是 Lock 的实现基础:
state变量:表示加锁状态(ReentrantLock 中代表重入次数)。- CLH 等待队列:基于链表实现,所有阻塞线程在此排队;所有操作通过 CAS 完成。
锁分离优化 —— 读写锁
ReentrantReadWriteLock(RRW)通过高低位巧妙设计 state:
- 高 16 位:读锁计数
- 低 16 位:写锁计数
特点:
- 多个读线程可并发;读读不互斥。
- 写线程与读/写线程都互斥。
- 缺点:在读多写少场景下,写线程可能遭遇饥饿(Starvation)。
StampedLock(JDK1.8)是用来解决 RRW 读多写少时写线程饥饿问题的,它有三种模式:
| 模式 | 特点 |
|---|---|
| 写锁 | 独占,返回 stamp |
| 悲观读 | 共享,返回 stamp |
| 乐观读 | 无锁读取获取 stamp,操作后用 validate(stamp) 二次校验;失败则升级为悲观读 |
优势:乐观读无 CAS 竞争(直接读取 stamp 的值),效率高于 RRW。
缺陷(导致未被广泛使用):
- 不支持重入(嵌套调用易死锁)
- 不支持条件变量 Condition
- 写多读少场景下性能无优势
乐观锁(CAS)
核心算法 CAS
三个参数:V(变量当前值)、E(预期值)、N(新值)。仅当 V == E 时才将 V 置为 N,否则返回 V 的真实值。
底层基于 CPU 指令,由处理器提供总线锁定与缓存锁定两种机制保证原子性。
JDK 中的实现
java.util.concurrent.atomic.*全部基于 CAS(AtomicInteger、AtomicLong)。Unsafe类调用 CPU 底层指令完成原子操作。
CAS 的问题与优化
- ABA 问题:值从 A 变 B 又变回 A,CAS 误判为未变。解决:引入版本号(
AtomicStampedReference)。 - 高并发写竞争激烈:大量线程 CAS 失败并循环重试,占用大量 CPU。
LongAdder(JDK1.8)的解决思路
- 空间换时间:将单一共享变量的写压力分散到
Cell[]数组的不同槽上。 - 读时:合并
base + 各 cell,可能为近似值,但最终值准确。 - 适合:高并发写场景、统计型业务(如全局计数)。
- 不适合:对实时性要求极高的场景(仍用
AtomicLong)。
三种锁性能对比结论
| 场景 | 最优选择 |
|---|---|
| 读 >> 写 | ReentrantReadWriteLock / StampedLock / 乐观锁 |
| 写 >> 读 | 乐观锁(LongAdder)性能最佳,其它四者差不多 |
| 读 ≈ 写 | 两种读写锁 + 乐观锁优于 Synchronized / ReentrantLock |
上下文切换调优
上下文切换基础
定义:线程由 RUNNING 转为 BLOCKED,再回到 RUNNABLE 被调度执行的过程。需保存/恢复:
- CPU 寄存器内容(已执行/正在执行/即将执行的任务)
- 程序计数器内容(当前指令位置 + 下一条指令位置)
诱因分类
| 类型 | 触发操作/原因 |
|---|---|
| 自发性 | sleep()、wait()、yield()、join()、park()、synchronized、lock |
| 非自发性 | 时间片用完、GC(Stop-The-World)、线程优先级 |
监控工具
| 工具 | 适用平台 | 用途 |
|---|---|---|
vmstat 1 3 |
Linux | 查看系统整体 cs(context switch)次数 |
pidstat -w -p <pid> -t |
Linux | 查看指定进程每个线程的 cswch/s、nvcswch/s |
| Process Explorer | Windows | 图形化查看线程上下文切换 |
jstack <pid> |
跨平台 | 导出线程堆栈信息,定位 BLOCKED 死锁 |
优化上下文切换
核心观点:在多线程编程中,锁不是性能开销的根源,竞争锁才是。
优化方向一:减少竞争锁
- 减少锁持有时间:将无关代码移出同步块,特别是 I/O 与阻塞操作。
- 降低锁粒度:
- 锁分离:读写锁。
- 锁分段:ConcurrentHashMap(JDK1.7)。
- CAS 替代锁:
Atomic*与volatile读写不会引起上下文切换。
优化方向二:wait/notify 优化
wait/notify 至少引起 3 次上下文切换,且 notifyAll() 易过早唤醒导致线程再次阻塞。
优化策略:
- **优先用
notify()替代notifyAll()**,避免唤醒无关线程。 - 通知后尽快释放锁,减少被唤醒线程的等待。
- 使用
Lock + Condition替代synchronized + wait/notify:- 可解决
wait(long)无法区分”超时 vs 被通知”的问题。 - 可解决过早唤醒的问题。
- 可解决
优化方向三:合理线程池大小
避免 Executors.newCachedThreadPool() 等会无限创建线程的池——大量长任务时会产生数百个线程,引发频繁上下文切换。
优化方向四:轻量级线程
核心就是轻量级线程,第六节有介绍。
优化方向五:减少 GC 频率
GC(特别是 Full GC)会触发 Stop-The-World。减少对象分配、合理设置堆分区可降低上下文切换。
监控建议:把上下文切换(cs)作为服务性能指标纳入监控,及时发现异常。
并发容器的选择
Map 容器
| 容器 | 一致性 | 数据结构 | 适用场景 |
|---|---|---|---|
| Hashtable | 强一致 | 数组+链表 | 强一致需求,但并发性能差(方法全用 synchronized 修饰) |
| ConcurrentHashMap | 弱一致 | 数组+链表/红黑树 | 首选;JDK1.7 分段锁 → JDK1.8 Synchronized + CAS |
| ConcurrentSkipListMap | 弱一致 | 跳表 | 大数据量、写多读少;按 key 排序;O(log n) 平均复杂度 |
跳跃表(SkipList)要点:
- 通过多层索引实现”空间换时间”。
- 增删基于 CAS,并发性能优于红黑树(树的操作涉及大量节点)。
- 在非线程安全的 Map 中,TreeMap(红黑树)与 SkipListMap 单线程性能相当。
List 容器
| 容器 | 机制 | 适用场景 |
|---|---|---|
| Vector | synchronized 修饰所有方法 |
读写都加锁,性能差;读远大于写时不推荐 |
| CopyOnWriteArrayList | 读无锁,写时复制新数组 | 读远大于写、写少;如黑名单、配置中心;数据一致性要求最终一致即可 |
队列
阻塞队列(用于线程池、生产者-消费者模型)
| 队列 | 数据结构 | 特性 |
|---|---|---|
| ArrayBlockingQueue | 数组 | 有界,FIFO,ReentrantLock + Condition |
| LinkedBlockingQueue | 链表 | 有界,FIFO,吞吐量通常高于 Array |
| PriorityBlockingQueue | 二叉堆 | 无界(MAX_VALUE - 8),按优先级 |
| DelayQueue | 基于 PriorityBlockingQueue | 支持延时获取元素 |
| SynchronousQueue | 不存储元素 | 每次 put 必须等 take;可指定 FIFO 或 LIFO |
非阻塞队列
ConcurrentLinkedQueue:基于链表 + CAS 实现的无界线程安全队列,适合高并发排队(如抢购);注意内存溢出风险。
线程池调优
线程池原理
Java 线程(JDK21之前)采用 1:1 线程模型:每个 Java 线程映射到一个内核线程(通过 pthread_create → clone() 创建)。线程的创建/销毁有系统开销,线程池通过复用减少开销。
Executor 框架
推荐使用 ThreadPoolExecutor 自定义线程池,而非 Executors 工厂类(默认参数不利于调优)。
核心参数
1 | |
默认工作流程
- 线程数 < corePoolSize → 创建新线程执行。
- 线程数 ≥ corePoolSize → 任务进入队列。
- 队列满 → 创建非核心线程,直到达到 maximumPoolSize。
- 已达 maximumPoolSize 且队列满 → 触发拒绝策略(默认抛
RejectedExecutionException)。
注意点:
- 预热:
prestartAllCoreThreads()提前创建核心线程(抢购场景常用)。 - 核心线程也可回收:通过
allowCoreThreadTimeOut(true)。
线程数计算公式
| 任务类型 | 推荐公式 |
|---|---|
| CPU 密集型 | N + 1(N 为 CPU 核数;+1 应对偶发缺页中断) |
| I/O 密集型 | 2N |
| 混合型(通用) | N * (1 + WT/ST),其中 WT = 线程等待时间,ST = 线程运行时间 |
注意这只是理论推荐值,生产环境要根据压测结果配置。
核心原则:
- 先保证合理线程数使 CPU 利用最大化。
- 再通过有界队列(防止 OOM)缓存来不及处理的任务。
- 不同任务类型使用不同线程池,避免相互影响;核心业务应单独部署。
Amdahl 定律
S = 1 / (1 - a + a/n)
系统性能提升的瓶颈在于串行部分。优化并行段只是缩短 a/n 这一项;优化串行部分(如减少锁持有时间、避免阻塞 I/O)才能真正提升整体性能。
轻量级线程
线程实现模型
| 模型 | 关系 | 优缺点 |
|---|---|---|
| 1:1(Java) | 用户线程 : 内核线程 = 1 : 1 | 简单,但创建/切换有用户态/内核态切换开销 |
| N:1 | 多用户线程对应 1 内核线程 | 切换快,但一个线程系统调用会阻塞整个进程 |
| N:M(Go) | N 用户线程映射到 M 内核线程 | 灵活,兼顾性能与并发能力 |
Java 早期在 Linux 下基于 pthread_create → clone() 实现 → 1:1 模型,JDK21 引入了轻量级用户线程(fibers),并行能力有了极大的提升。
协程的本质
- 比线程更轻量,由程序(用户态)调度,不进入内核。
- 可看作”可暂停的函数”或”线程上的代码块”。
- 挂起时让出执行权,唤醒后从挂起点继续。
- 同一线程上可运行成千上万个协程。
数据一致性
分布式系统的很多问题和多线程领域非常类似,一个经典的例子就是 Happens-Before。
Java 内存模型(JMM)核心
- 局部变量 → 线程栈(线程私有,无需关心一致性)。
- 共享变量 → 堆/方法区(线程共享)。
- 工作内存:CPU 高速缓存(L1/L2/L3),每核一份。
多核问题:两个 CPU 核心同时缓存同一共享变量时,各自修改后写回主存可能产生数据不一致。
重排序
编译器/CPU 为优化性能可能改变指令执行顺序。在单线程下不影响结果,但在并发下可能引发问题。
Happens-Before 规则
为约束线程执行顺序,JMM 定义了 8 条 Happens-Before 规则:
| 规则 | 内容 |
|---|---|
| 程序次序规则 | 单线程内执行结果与顺序执行一致 |
| 锁定规则 | unlock 先于后续对同一锁的 lock |
| volatile 变量规则 | volatile 写先于后续读 |
| 线程启动规则 | start() 先于线程内任何动作 |
| 线程终结规则 | 线程内所有操作先于 terminate() 检测 |
| 对象终结规则 | 对象初始化完成先于 finalize() |
| 传递性 | A hb B,B hb C ⇒ A hb C |
| 线程中断规则 | interrupt() 调用先于中断事件检测 |
一致性
| 级别 | 含义 | 典型实现 |
|---|---|---|
| 严格一致性(强一致) | 任何时刻各线程读到的缓存数据完全一致;全局时钟序 | Hashtable、synchronized |
| 顺序一致性 | 单线程内执行有序;保证读到最近一次写入 | volatile(阻止重排序) |
| 弱一致性 | 不保证立刻读到最近写入,但能保证最终读到 | 单写锁 + 无锁读、ConcurrentHashMap |
ConcurrentHashMap 为什么是弱一致?
get()不加锁,可能读到尚未刷新到主存的数据。size()、clear()、foreach等方法也都未完全加锁,存在数据不确定性。
关于一致性,在分布式系统中有线性一致性、顺序一致性、因果一致性等说法。
多线程优化的本质是减少竞争、减少切换、减少等待。锁要选对、粒度要小、容器要匹配场景、线程池要合理,最终都要靠压测数据而非经验值来落地。