这篇笔记围绕 Java 并发中的高频基础问题展开,重点覆盖线程生命周期、线程池、
synchronized、volatile、CAS、AQS 以及常见锁模型之间的关系。文章目标不是罗列零散问答,而是把这些概念按“语义 -> 机制 -> 边界 -> 典型误区”的顺序串起来,形成一份可复用的并发基础索引。
文中涉及若干 JDK 版本差异,例如
ConcurrentHashMap在 JDK 7 与 JDK 8 之后的实现路线、偏向锁在较新 JDK 中的状态变化,以及线程池工厂方法在工程实践中的使用边界;阅读时应特别区分“语言语义”和“某一版本 HotSpot 的具体实现”。
参考资料:
官方文档:Java Language Specification, Chapter 17: Threads and Locks 、 Thread.State 、 ThreadPoolExecutor
[TOC]
并发编程三要素
- 原子性:一个操作或一组操作不能被执行到一半再被其他线程打断,要么整体完成,要么整体不做完
- 可见性:一个线程对共享变量的修改,另一个线程能够在符合内存语义的前提下及时看到
- 有序性:程序在单线程语义上看起来按既定顺序执行,但编译器和处理器可能为优化而重排序,多线程下需要额外约束
原子性
线程切换会带来原子性问题。单次读取或写入通常是原子的,但“读取 -> 计算 -> 写回”这一类复合操作并不是原子操作。
可以通过 synchronized、Lock 或 java.util.concurrent.atomic 包中的原子类保护这类复合操作。
可见性
缓存和重排序都会带来可见性问题。线程 A 更新共享变量后,线程 B 未必会立刻观察到这次修改。
synchronized、Lock 与 volatile 都能在各自适用的语义范围内提供可见性保证。
有序性
有序性强调的是线程观察到的执行效果是否符合预期的先后关系。
编译器和处理器为了提高执行效率,可能在不改变单线程结果的前提下进行重排序;但多线程场景下,另一个线程可能观察到未预期的中间状态。
synchronized、Lock 与 volatile 都会通过内存语义约束一定范围内的重排序。
更准确地说,这些机制最终依赖的是 Java 内存模型中的 happens-before 关系,而不是简单的“完全禁止所有重排序”。
创建线程的有哪些方式
- 继承Thread类创建线程类
- 通过Runnable接口创建线程类
- 通过Callable和Future创建线程
创建线程的三种方式的对比
- 采用实现Runnable、Callable接口的方式创建多线程
优势是:
线程类只是实现了Runnable接口或Callable接口,还可以继承其他类。
在这种方式下,多个线程可以共享同一个target对象,所以非常适合多个相同线程来处理同一份资源的情况,从而可以将CPU、代码和数据分开,形成清晰的模型,较好地体现了面向对象的思想
劣势是:
编程稍微复杂,如果要访问当前线程,则必须使用Thread.currentThread()方法
- 使用继承Thread类的方式创建多线程
优势是:
编写简单,如果需要访问当前线程,则无需使用Thread.currentThread()方法,直接使用this即可获得当前线程
劣势是:
线程类已经继承了Thread类,所以不能再继承其他父类
- Runnable和Callable的区别
- Callable规定(重写)的方法是call(),Runnable规定(重写)的方法是run()
- Callable的任务执行后可返回值,而Runnable的任务是不能返回值的
- Call方法可以抛出异常,run方法不可以
- 运行Callable任务可以拿到一个Future对象,表示异步计算的结果。它提供了检查计算是否完成的方法,以等待计算的完成,并检索计算的结果。通过Future对象可以了解任务执行情况,可取消任务的执行,还可获取执行结果
线程的生命周期及状态

Java API 中可观测到的线程状态并不是 5 种,而是 Thread.State 枚举定义的 6 种:
| 状态 | 含义 | 典型进入方式 |
|---|---|---|
NEW |
线程对象已创建,但还未调用 start() |
new Thread(...) |
RUNNABLE |
线程处于可运行状态,可能正在运行,也可能在等待 CPU 调度 | 调用 start() 后进入 |
BLOCKED |
等待获取对象监视器锁 | 进入 synchronized 代码块失败 |
WAITING |
无限期等待其他线程显式唤醒 | Object.wait()、Thread.join()、LockSupport.park() |
TIMED_WAITING |
带超时时间的等待 | sleep()、wait(timeout)、join(timeout) |
TERMINATED |
run() 正常结束或异常退出 |
线程执行完成 |
其中最容易混淆的一点是:Java 线程状态里没有单独的 Running 枚举值。RUNNABLE 同时覆盖“已经拿到 CPU 正在执行”和“已经具备运行条件、正在等待调度”这两类情况。
stateDiagram-v2
[*] --> NEW
NEW --> RUNNABLE: start()
RUNNABLE --> BLOCKED: 等待 monitor 锁
BLOCKED --> RUNNABLE: 获取 monitor 锁
RUNNABLE --> WAITING: wait()/join()/park()
WAITING --> RUNNABLE: notify()/unpark()/join 结束
RUNNABLE --> TIMED_WAITING: sleep()/wait(timeout)/join(timeout)
TIMED_WAITING --> RUNNABLE: 超时或被唤醒
RUNNABLE --> TERMINATED: run() 结束
几个常见边界需要单独区分:
wait()会释放当前持有的对象监视器锁,但sleep()不会释放锁BLOCKED特指等待进入synchronized监视器;WAITING与TIMED_WAITING则通常是主动等待某个条件或时间- Java 线程状态是 JVM 视角的抽象状态,不等同于操作系统线程状态
什么是线程池,有几种创建方式
线程池(Thread Pool)是一种基于池化思想管理线程的工具,经常出现在多线程服务器中,如MySQL
线程过多会带来额外的开销,其中包括创建销毁线程的开销、调度线程的开销等等,同时也降低了计算机的整体性能。线程池维护多个线程,等待监督管理者分配可并发执行的任务。这种做法,一方面避免了处理任务时创建销毁线程开销的代价,另一方面避免了线程数量膨胀导致的过分调度问题,保证了对内核的充分利用
Java 并发包通过 Executor、ExecutorService、ThreadPoolExecutor 等抽象来管理线程池,其中 Executors 提供了一组常见工厂方法。
常见工厂方法包括:
newCachedThreadPool():可缓存线程池,空闲线程会复用,线程数理论上可以快速增长newFixedThreadPool(int nThreads):固定大小线程池newScheduledThreadPool(int corePoolSize):支持延迟和周期任务newSingleThreadExecutor():单线程串行执行器
这四种写法适合帮助理解线程池的行为差异,但在工程实践中,通常更推荐直接使用 ThreadPoolExecutor 或 ScheduledThreadPoolExecutor 显式指定核心参数,例如核心线程数、最大线程数、阻塞队列、线程工厂与拒绝策略。
| 创建方式 | 核心特征 | 主要风险 |
|---|---|---|
newCachedThreadPool() |
使用 SynchronousQueue,任务到来时可能快速创建新线程 |
高并发突发流量下线程数可能过大 |
newFixedThreadPool() |
线程数固定,默认使用无界 LinkedBlockingQueue |
任务持续积压时可能导致内存压力 |
newSingleThreadExecutor() |
始终只有一个工作线程,默认也是无界队列 | 吞吐量有限,队列同样可能无限增长 |
newScheduledThreadPool() |
支持延迟与周期任务 | 长时间任务堆积会影响调度精度 |
因此,“线程池有几种创建方式”更适合回答成:JDK 提供了若干常见工厂方法,但真正落地时需要回到 ThreadPoolExecutor 的参数设计。
线程池的优点
- 降低资源消耗:通过池化技术重复利用已创建的线程,降低线程创建和销毁造成的损耗
- 提高响应速度:任务到达时,无需等待线程创建即可立即执行
- 提高线程的可管理性:线程是稀缺资源,如果无限制创建,不仅会消耗系统资源,还会因为线程的不合理分布导致资源调度失衡,降低系统的稳定性。使用线程池可以进行统一的分配、调优和监控
- 提供更多更强大的功能:线程池具备可拓展性,允许开发人员向其中增加更多的功能。比如延时定时线程池ScheduledThreadPoolExecutor,就允许任务延期执行或定期执行
Java中的同步集合与并发集合有什么区别
同步集合类:
- Vector
- Stack
- HashTable
- Collections.synchronized方法生成
并发集合类:
- ConcurrentHashMap
- CopyOnWriteArrayList
- CopyOnWriteArraySet等
区别:
同步集合与并发集合都提供线程安全,但设计思路并不相同。同步集合通常通过对整个容器或外层包装统一加锁来保证安全,并发集合则更强调细粒度并发控制、无锁或低锁竞争的数据结构设计,因此在高并发下通常具有更好的伸缩性。
这里尤其要区分 ConcurrentHashMap 的版本差异:
- JDK 7 中的
ConcurrentHashMap采用Segment分段锁思路 - JDK 8 及之后不再使用
Segment结构,而是基于数组、链表/红黑树、CAS 与桶级别同步实现并发控制
因此,把 ConcurrentHashMap 一概描述成“把整个 Map 切成几个分段再加锁”只适用于 JDK 7 语境,不适用于较新的实现
synchronized 的作用
synchronized 的核心作用是对临界区建立互斥访问,同一时刻只允许一个线程进入受保护代码。
它最主要解决的是多个线程同时操作共享资源时,出现数据错乱的问题。
比如下面这个例子:
1
2
3
4
5
6
7
class Counter {
private int count = 0;
public void add() {
count++;
}
}
count++ 看起来只有一行,但它不是原子操作,大致可以拆成三步:
- 读取
count count + 1- 写回
count
如果两个线程同时执行,就可能都读到旧值,最后只加了一次。
这时候就可以用 synchronized:
1
2
3
4
5
6
7
class Counter {
private int count = 0;
public synchronized void add() {
count++;
}
}
加上之后,同一时刻只能有一个线程执行 add(),这样这个复合操作就被保护起来了。
synchronized 能保证什么
synchronized 主要能保证三件事:
- 原子性:被它保护的代码块,同一时刻只能有一个线程执行。
- 可见性:一个线程在同步代码块里修改了共享变量,退出同步块后,其他线程再次拿到同一把锁时能看到最新值。
- 有序性:在同步代码块内部,JVM 不会让这段临界区里的执行效果对持有同一把锁的线程表现得乱序。
它通过加锁保证临界区同一时刻只被一个线程执行,从而保证原子性,同时借助进入锁和释放锁的内存语义保证可见性和一定程度上的有序性。
synchronized 锁的是什么
synchronized 的本质是“对某个对象监视器加锁”,因此关键在于锁对象是谁。
- 对于普通同步方法,锁的是当前实例对象,也就是
this - 对于静态同步方法,锁的是当前类对应的
Class对象 - 对于同步代码块,锁的是括号里显式传入的那个对象
例如:
1
2
public synchronized void methodA() {
}
等价于:
1
2
3
4
public void methodA() {
synchronized (this) {
}
}
再比如:
1
2
public static synchronized void methodB() {
}
它锁的是:
1
2
synchronized (当前类.class) {
}
使用 synchronized 时要注意什么
1. 锁对象必须是多个线程真正共享的那一个
下面这种写法几乎等于没加锁:
1
2
3
4
5
public void method() {
synchronized (new Object()) {
// do something
}
}
因为每次进来都会 new 一个新对象,每个线程拿到的都不是同一把锁。
2. 锁的范围不要过大
锁的范围越大,并发度越低。能只锁核心共享逻辑,就不要把无关代码也一起锁住。
3. synchronized 是可重入锁
同一个线程已经拿到这把锁之后,再次进入这把锁保护的代码,不会把自己锁死。
1
2
3
4
5
6
synchronized void a() {
b();
}
synchronized void b() {
}
这里 a() 里调用 b() 是没问题的,因为当前线程已经持有这把锁。
结论归纳
synchronized适合保护一整段临界区- 它可以保证原子性、可见性、有序性
- 它是悲观锁思路,先加锁,再操作共享资源
- 它不能提高并发,只能保证并发下结果正确
volatile关键字的作用
volatile 的核心语义是:一个线程写入后,其他线程能够及时看到最新值;同时,JVM 会通过内存屏障约束与该变量相关的重排序。
它主要有两个作用:
- 保证可见性
- 禁止指令重排序
volatile 不能保证复合操作的原子性。
1
volatile public int i = 1;
当volatile变量i被赋值2时,这时线程1会做两件事:
- 更新主内存
- 向CPU总线发送一个修改信号
这时监听CPU总线的处理器会收到这个修改信号后,如果发现修改的数据自己缓存了,就把自己缓存的数据失效掉。这样其它线程访问到这段缓存时知道缓存数据失效了,需要从主内存中获取。这样所有线程中的共享变量i就达到了一致性
1. volatile 的可见性
看一个最经典的例子:
1
2
3
4
5
6
7
8
9
10
11
12
13
class TaskRunner {
private volatile boolean running = true;
public void stop() {
running = false;
}
public void run() {
while (running) {
// do work
}
}
}
如果 running 不加 volatile,有可能线程 A 已经把它改成 false 了,但线程 B 还一直读到旧值 true,导致循环停不下来。
加上 volatile 后,线程 A 的修改会更快地对其他线程可见,线程 B 读到新值后就能结束循环。
所以 volatile 很适合这种场景:
- 一个线程写
- 多个线程读
- 变量本身的赋值是独立、简单的
比如:
- 开关标记
- 状态位
- 配置刷新标记
2. volatile 的有序性
volatile 还能禁止某些关键的指令重排序。
这个点最常见的场景是单例双重检查:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
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;
}
}
这里 instance 必须用 volatile,否则可能发生指令重排序,导致“对象引用已经赋值了,但对象还没完全初始化好”,其他线程就可能拿到一个未完全构造的对象。
3. 为什么 volatile 不能保证线程安全
很多人最容易误会这里。
例如:
1
2
3
4
5
6
7
class Counter {
volatile int count = 0;
public void add() {
count++;
}
}
虽然 count 是 volatile,但 count++ 依然不是原子操作。
它还是那三步:
- 读
- 改
- 写
如果两个线程同时执行,还是可能互相覆盖,最终结果依旧不对。
所以:
volatile能保证线程读取到的是最新值- 但不能保证“读出来再改再写回去”这一整套动作是安全的
如果要保护 i++、check-then-act、余额扣减这类复合逻辑,就不能只靠 volatile,而要用:
synchronizedLockAtomicInteger这类原子类
4. volatile 的底层语义
- 对
volatile变量的写,会及时刷新到主内存 - 其他线程读取这个变量时,会从主内存重新读取最新值
- JVM 会在
volatile读写前后插入内存屏障,限制重排序
再往下看,可以理解为缓存一致性协议会让其他 CPU 核心中对应缓存行失效,之后别的线程再读时,就会拉取新值。
volatile 可见性
其效果可以概括为:某个线程修改 volatile 变量后,其他线程下一次读取时不会长期停留在旧缓存视图上。
它解决的是“一个线程改了值,其他线程迟迟看不见”的问题,而不解决“多个线程同时修改同一个变量”的原子性问题。
从硬件视角理解,线程修改 volatile 变量后,相关写入会更快地传播到其他处理器核心;若其他核心缓存了同一数据,对应缓存行会失效,后续读取需要重新获取最新值。
volatile 禁止指令重排序
指令重排序是指编译器和 CPU 为了提高执行效率,可能在不改变单线程结果的前提下调整部分指令顺序。
单线程下通常没问题,但多线程下如果另一个线程刚好观察这个过程,就可能看到一个“中间状态”。
volatile 会通过内存屏障约束这种重排序。
常见记法是:
volatile写前后会插入写屏障volatile读前后会插入读屏障
实际理解时不必执着于 StoreStore、StoreLoad、LoadLoad、LoadStore 这些名字,重点是明白它们的作用在于限制重排序并保证内存可见性。
问题一:处理器怎么感知这是一次 volatile 写
更准确一点的理解是:
volatile是 Java 内存模型里的语义要求- JVM 在生成机器指令时,会选择合适的屏障或带锁前缀的指令来实现这种语义
- 最终效果是让其他处理器核心感知到这次写入,并保证可见性和有序性
JVM 会把 volatile 的语义翻译成底层的内存屏障和相关机器指令,底层硬件再配合缓存一致性协议完成可见性保证。
synchronized 和 volatile 怎么选
这个问题面试非常常见,可以直接这样记:
| 关键字 | 能保证什么 | 不能保证什么 | 典型场景 |
|---|---|---|---|
synchronized |
原子性、可见性、有序性 | 不能提升并发性能 | 保护临界区、复合操作 |
volatile |
可见性、有序性 | 不能保证原子性 | 状态标记、开关量、单次赋值 |
一句话总结:
- 只需要让一个值对别的线程立刻可见,用
volatile - 需要把一整段逻辑作为一个整体保护起来,用
synchronized
什么是CAS
CAS全称Compare and swap,字面意思:”比较并交换“
CAS 不是基于互斥锁的操作,而是一种硬件级原子比较交换原语。它通常被视为无锁或非阻塞同步的重要基础,经常用于在不阻塞线程的前提下完成共享变量更新。
一个 CAS 涉及到以下操作:假设内存中的原数据V,旧的预期值A,需要修改的新值B
- 比较 A 与 V 是否相等
- 如果比较相等,将 B 写入 V
- 返回操作是否成功
CAS 通常会配合自旋重试使用。如果线程 A 在比较后发现目标值已经被线程 B 改写,那么线程 A 需要重新读取并再次尝试。
CAS的问题
ABA问题
就是一个值从A变成了B又变成了A,使用CAS操作不能发现这个值发生变化了
而这个问题的解决方案可以使用版本号标识,每操作一次version加1
在java5中,已经提供了AtomicStampedReference来解决问题
不能保证代码块的原子性
CAS机制所保证的知识一个变量的原子性操作,而不能保证整个代码块的原子性。比如需要保证3个变量共同进行原子性的更新,就不得不使用synchronized了
性能问题
CAS 往往通过循环重试直到成功。低竞争场景下响应很快,但竞争激烈时会产生大量重试,自旋会持续消耗 CPU 时间。
Java死锁
死锁例子讲解可参考知乎
Java发生死锁的根本原因是:在申请锁时发生了交叉闭环申请
一般来说死锁的出现必须满足以下四个必要条件:
1. 互斥条件: 指进程对所分配到的资源进行排它性使用,即在一段时间内某资源只由一个进程占用。如果此时还有其它进程请求资源,则请求者只能等待,直至占有资源的进程用毕释放
2. 请求和保持条件: 指进程已经保持至少一个资源,但又提出了新的资源请求,而该资源已被其它进程占有,此时请求进程阻塞,但又对自己已获得的其它资源保持不放
3. 不剥夺条件: 指进程已获得的资源,在未使用完之前,不能被剥夺,只能在使用完时由自己释放
4. 环路等待条件: 指在发生死锁时,必然存在一个进程——资源的环形链,即进程集合{P0,P1,P2,···,Pn}中的P0正在等待一个P1占用的资源;P1正在等待P2占用的资源,……,Pn正在等待已被P0占用的资源
要避免出现死锁的问题,只需要破坏四个条件中的任何一个就可以了
并发和并行的区别
单核 CPU 上多个线程交替推进,体现的是并发 多核 CPU 上多个线程真正同时执行,体现的是并行
如果某个系统支持两个或者多个动作(Action)同时存在,那么这个系统就是一个并发系统。如果某个系统支持两个或者多个动作同时执行,那么这个系统就是一个并行系统。并发系统与并行系统这两个定义之间的关键差异在于 “存在” 这个词
如果程序能够并行执行,那么就一定是运行在多核处理器上
“并行”概念是“并发”概念的一个子集
AQS
AQS(AbstractQueuedSynchronizer)就是一个抽象的队列同步器,AQS定义了一套多线程访问共享资源的同步器框架,许多同步类实现都依赖于它
AQS的主要作用是为Java中的并发同步组件提供统一的底层支持,比如大家熟知的:
- ReentrantLock
- Semaphore
- CountDownLatch
- CyclicBarrier
等并发类均是基于AQS来实现的
AQS 不是具体的锁,而是一套“线程如何竞争资源、竞争失败后如何入队等待、资源释放后如何唤醒后继节点”的通用同步框架。
从结构上看,AQS 可以拆成三个核心部分:
state:资源现在是不是可用tryAcquire/tryRelease:线程能不能拿到资源、什么时候释放资源CLH队列:没抢到资源的线程先去排队park / unpark:排队的线程先休息,轮到它时再叫醒
AQS 主要解决两个问题:
- 当前线程能不能拿到共享资源?
- 如果现在拿不到,线程应该怎么排队、怎么挂起、谁来唤醒它?
AQS 独占模式主线
flowchart TD
A[线程尝试获取资源] --> B{tryAcquire 成功?}
B -- 是 --> C[直接进入临界区]
B -- 否 --> D[封装为 Node 并入队]
D --> E[park 挂起线程]
E --> F[前驱线程 release]
F --> G[unpark 后继节点]
G --> H[被唤醒后再次 tryAcquire]
H --> I{成功?}
I -- 是 --> J[设置为新的 head 并继续执行]
I -- 否 --> E
先抢,抢不到就入队并阻塞;前面的线程释放资源后,再唤醒后继节点重新竞争。
比如java.util.concurrent.locks.ReentrantLock
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class ReentrantLock implements Lock, java.io.Serializable {
private static final long serialVersionUID = 7373984872572414699L;
/** Synchronizer providing all implementation mechanics */
private final Sync sync;
/**
* Base of synchronization control for this lock. Subclassed
* into fair and nonfair versions below. Uses AQS state to
* represent the number of holds on the lock.
*/
abstract static class Sync extends AbstractQueuedSynchronizer {
private static final long serialVersionUID = -5179523762034025860L;
/**
* Performs {@link Lock#lock}. The main reason for subclassing
* is to allow fast path for nonfair version.
*/
abstract void lock();
...
...
...
}
AQS的数据模型
也就是说,像 ReentrantLock 这样的并发工具,本身更关注“什么条件下算拿到锁”,而排队、唤醒、状态管理等通用细节由 AQS 负责。

AQS 使用上图的资源变量 state来表示同步状态,通过内置的 CLH FIFO 队列来完成获取资源线程的排队工作,这里会涉及到三个要素
AQS 最核心的三个东西
1. state:资源状态
state 是一个整数,但不同组件对它的含义定义不同:
- 对
ReentrantLock来说,state = 0一般表示没加锁,state > 0表示已经被占用 - 对可重入锁来说,
state还能顺便表示重入次数 - 对
Semaphore来说,state可以表示许可证数量 - 对
CountDownLatch来说,state可以表示剩余计数
所以 state 不是“固定含义”的变量,它更像是 AQS 留给子类的一块“共享资源状态位”。
2. head / tail:等待队列的头尾
抢资源失败的线程不会一直傻等,它会被包装成一个 Node 节点,放进队列里。
head指向队头tail指向队尾
新来的失败线程通常从队尾入队,等前面的线程释放资源后,再按顺序唤醒后继节点。
3. Node:排队中的线程
AQS 队列里不是直接塞 Thread,而是把线程包装成一个 Node 节点。
这个节点除了保存线程本身,还会保存:
- 前驱节点
prev - 后继节点
next - 当前等待状态
waitStatus - 当前是独占模式还是共享模式
所以可以把 Node 理解成:线程在线程等待队列里的“座位号 + 状态卡片”。
java.util.concurrent.locks.AbstractQueuedSynchronizer
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public abstract class AbstractQueuedSynchronizer
extends AbstractOwnableSynchronizer
implements java.io.Serializable {
/**
* Head of the wait queue, lazily initialized. Except for
* initialization, it is modified only via method setHead. Note:
* If head exists, its waitStatus is guaranteed not to be
* CANCELLED.
*/
// 队头结点
private transient volatile Node head;
/**
* Tail of the wait queue, lazily initialized. Modified only via
* method enq to add new wait node.
*/
// 队尾结点
private transient volatile Node tail;
/**
* The synchronization state.
*/
//共享资源变量state
private volatile int state;
...
...
...
}
head、tail、state三个变量都是volatile的,通过volatile来保证共享变量的可见性
state:资源是否可用head:当前轮到谁后面那个线程最有机会被唤醒tail:新来的失败线程往哪里排
state: 它是int数据类型的,其访问方式有3种:
- getState()
- setState(int newState)
- compareAndSetState(int expect, int update)
java.util.concurrent.locks.AbstractQueuedSynchronizer
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
/**
* Returns the current value of synchronization state.
* This operation has memory semantics of a {@code volatile} read.
* @return current state value
*/
// 具有内存读可见性语义
protected final int getState() {
return state;
}
/**
* Sets the value of synchronization state.
* This operation has memory semantics of a {@code volatile} write.
* @param newState the new state value
*/
// 具有内存写可见性语义
protected final void setState(int newState) {
state = newState;
}
/**
* Atomically sets synchronization state to the given updated
* value if the current state value equals the expected value.
* This operation has memory semantics of a {@code volatile} read
* and write.
*
* @param expect the expected value
* @param update the new value
* @return {@code true} if successful. False return indicates that the actual
* value was not equal to the expected value.
*/
// 具有内存读/写可见性语义
protected final boolean compareAndSetState(int expect, int update) {
// See below for intrinsics setup to support this
return unsafe.compareAndSwapInt(this, stateOffset, expect, update);
}
这里最关键的是 compareAndSetState,也就是 CAS。
因为多个线程可能同时来抢资源,所以不能直接“看一眼 state 再改”,而要用 CAS 保证:
只有当 state 还是我预期的值时,我这次修改才算成功。
比如两个线程同时看到 state = 0,只有一个线程能 CAS 成功把它改成 1,另一个线程就会失败,然后进入排队逻辑。
AQS资源的两种共享方式
独占锁Exclusive:
独占模式下时,其他线程试图获取该锁将无法取得成功,只有一个线程能执行,如ReentrantLock采用独占模式

ReentrantLock还可以分为公平锁和非公平锁
- 公平锁:按照线程在队列中的排队顺序,先到者先拿到锁
- 非公平锁:当线程要获取锁时,无视队列顺序直接去抢锁,谁抢到就是谁的
共享锁shared:
多个线程获取某个锁可能会获得成功,多个线程可同时执行,如:Semaphore、CountDownLatch

- 独占模式:一次只让一个线程通过,像单人闸机
- 共享模式:一次允许多个线程通过,像多张票同时放行
这也是为什么同样是 AQS,不同组件表现差异会很大,因为它们对 state 的解释方式和获取资源的规则不同。
AQS将大部分的同步逻辑均已经实现好,继承的自定义同步器只需要实现state的获取(acquire)和释放(release)的逻辑代码就可以,主要包括下面方法:
- tryAcquire(int):独占方式。尝试获取资源,成功则返回true,失败则返回false
- tryRelease(int):独占方式。尝试释放资源,成功则返回true,失败则返回false
- tryAcquireShared(int):共享方式。尝试获取资源。负数表示失败;0表示成功,但没有剩余可用资源;正数表示成功,且有剩余资源
- tryReleaseShared(int):共享方式。尝试释放资源,如果释放后允许唤醒后续等待结点返回true,否则返回false
- isHeldExclusively():该线程是否正在独占资源。只有用到condition才需要去实现它
AQS需要子类复写的方法均没有声明为abstract,目的是避免子类需要强制性覆写多个方法,因为一般自定义同步器要么是独占方法,要么是共享方法,只需实现tryAcquire-tryRelease、tryAcquireShared-tryReleaseShared中的一种即可
也就是说,AQS 已经把“排队、阻塞、唤醒”这些公共流程写好了,子类只需要回答:
- 什么条件下算拿到资源?
- 什么条件下算释放资源?
- 释放之后要不要继续唤醒后继节点?
AQS的锁获取与释放原理

- 线程获取锁流程:
- 线程A获取锁,state将0置为1,线程A占用
- 在A没有释放锁期间,线程B也来获取锁,线程B获取state为1,表示线程被占用,线程B创建Node节点放入队尾(tail),并且阻塞线程B
- 同理线程C获取state为1,表示线程被占用,线程C创建Node节点,放入队尾,且阻塞线程
- 线程释放锁流程:
- 线程A执行完,将state从1置为0
- 唤醒下一个Node B线程节点,然后再删除线程A节点
- 线程B占用,获取state状态位,执行完后唤醒下一个节点 Node C,再删除线程B节点
下面这张时序图把 ReentrantLock 在独占模式下依赖 AQS 的主线串起来:
sequenceDiagram
participant A as 线程A
participant S as AQS/Sync
participant B as 线程B
A->>S: lock()
S->>S: tryAcquire(1)
S-->>A: 获取成功,state 0 -> 1
B->>S: lock()
S->>S: tryAcquire(1) 失败
S->>S: addWaiter(Node B)
S-->>B: park 挂起
A->>S: unlock()
S->>S: tryRelease(1)
S->>S: unparkSuccessor(head)
S-->>B: 唤醒
B->>S: 再次 tryAcquire(1)
S-->>B: 获取成功并成为新的 head
这里有两个特别容易误会的点:
-
入队不代表立刻执行 入队只是说明“先到等待区排好队”,真正执行还得等前面的线程释放资源。
-
被唤醒也不代表一定马上成功 被唤醒后,线程通常还要再试一次
tryAcquire();成功了才真正拿到资源。
CLH队列(FIFO)
The wait queue is a variant of a "CLH" (Craig, Landin, and Hagersten) lock queue. CLH locks are normally used for spinlocks.
等待队列是“ CLH”(Craig Landin Hagersten)锁定队列,CLH锁通常用于自旋锁 (自旋锁(spinlock):是指当一个线程在获取锁的时候,如果锁已经被其它线程获取,那么该线程将循环等待,然后不断的判断锁是否能够被成功获取,直到获取到锁才会退出循环)
CLH同步队列是一个FIFO双向队列,AQS依赖它来完成同步状态的管理,当前线程如果获取同步状态失败时,AQS则会将当前线程已经等待状态等信息构造成一个节点(Node)并将其加入到CLH同步队列,同时会阻塞当前线程
在CLH同步队列中,一个节点表示一个线程,它保存着线程的引用(thread)、状态(waitStatus)、前驱节点(prev)、后继节点(next),AQS是通过内部类Node来实现FIFO队列的
CLH 队列可以理解成一个“排号队列”:
- 新来的失败线程,先去队尾拿号
- 只有排在前面的线程释放资源,后面的线程才有机会继续
- 队列本身不负责“把锁给谁”,它负责的是“谁先等、谁后等、该唤醒谁”
队列维护等待顺序,真正能不能拿到资源,还是要靠 tryAcquire() 再判断一次。
java.util.concurrent.locks.AbstractQueuedSynchronizer
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
static final class Node {
// 表明节点在共享模式下等待的标记
static final Node SHARED = new Node();
// 表明节点在独占模式下等待的标记
static final Node EXCLUSIVE = null;
// 表征等待线程已取消的
static final int CANCELLED = 1;
// 表征需要唤醒后续线程
static final int SIGNAL = -1;
// 表征线程正在等待触发条件(condition)
static final int CONDITION = -2;
// 表征下一个acquireShared应无条件传播
static final int PROPAGATE = -3;
/**
* SIGNAL: 当前节点释放state或者取消后,将通知后续节点竞争state。
* CANCELLED: 线程因timeout和interrupt而放弃竞争state,当前节点将与state彻底拜拜
* CONDITION: 表征当前节点处于条件队列中,它将不能用作同步队列节点,直到其waitStatus被重置为0
* PROPAGATE: 表征下一个acquireShared应无条件传播
* 0: None of the above
*/
volatile int waitStatus;
// 前继节点
volatile Node prev;
// 后继节点
volatile Node next;
// 持有的线程
volatile Thread thread;
// 链接下一个等待条件触发的节点
Node nextWaiter;
// 返回节点是否处于Shared状态下
final boolean isShared() {
return nextWaiter == SHARED;
}
// 返回前继节点
final Node predecessor() throws NullPointerException {
Node p = prev;
if (p == null)
throw new NullPointerException();
else
return p;
}
// Shared模式下的Node构造函数
Node() {
}
// 用于addWaiter
Node(Thread thread, Node mode) {
this.nextWaiter = mode;
this.thread = thread;
}
// 用于Condition
Node(Thread thread, int waitStatus) {
this.waitStatus = waitStatus;
this.thread = thread;
}
}
这里不用一开始就死记所有状态值,先重点记两个就够了:
SIGNAL = -1:前驱节点释放资源时,要记得唤醒我CANCELLED = 1:这个节点不等了,作废了
平时看源码时,先抓住这几个字段最有帮助:
thread:当前节点对应哪个线程prev/next:它在队列里的前后关系waitStatus:它现在是不是还在有效等待
CLH同步队列遵循 FIFO,首节点释放同步状态后会唤醒后继节点;后继节点在获取同步状态成功后,会把自己设置为新的首节点。这个过程可以概括为:
前面的人走了,叫醒后面第一个有效等待的人;后面这个人如果成功拿到资源,就成为新的“队头代表”。
独占式同步状态获取
acquire(int arg)方法为AQS提供的模板方法,该方法为独占式获取同步状态,但是该方法对中断不敏感,也就是说由于线程获取同步状态失败加入到CLH同步队列中,后续对线程进行中断操作时,线程不会从同步队列中移除
- 首先线程通过tryAcquire(arg)尝试获取独占资源,若获取成功则直接返回,若不成功,则将该线程以独占模式添加到等待队列尾部,tryAcquire(arg)由继承AQS的自定义同步器来具体实现
- 当前线程加入等待队列后,会通过acquireQueued方法基于CAS自旋不断尝试获取资源,直至获取到资源
- 若在自旋过程中,线程被中断过,acquireQueued方法会标记此次中断,并返回true
- 若acquireQueued方法获取到资源后,返回true,则执行线程自我中断操作selfInterrupt()
- 我先试试能不能直接拿到锁
- 如果拿不到,就排到队尾
- 排队后不是一直空转,而是合适的时候挂起自己
- 当前面线程释放资源时,我会被唤醒
- 被唤醒后再试一次,成功了就继续执行
这个过程并不是“线程一直疯狂占 CPU 自旋”,而是“短暂尝试 + 排队等待 + 被唤醒后再竞争”。
独占式释放资源
AQS的释放资源过程,其入口函数为:
1
2
3
4
5
6
7
8
9
10
11
public final boolean release(int arg) {
if (tryRelease(arg)) {
// 获取到等待队列的头结点h
Node h = head;
// 若头结点不为空且其ws值非0,则唤醒h的后继节点
if (h != null && h.waitStatus != 0)
unparkSuccessor(h);
return true;
}
return false;
}
通过tryRelease(arg)来释放资源,和tryAcquire类似,tryRelease也是有继承AQS的自定义同步器来具体实现
- 当前线程先释放自己占有的资源
- 如果确认资源真的可用了
- 就去唤醒等待队列里的后继节点
- 后继节点醒来后再自己去抢
这里不是“释放线程直接把锁交给下一个线程”,而是唤醒后继线程,让其自己重新竞争资源。
获取资源(共享模式)
方法入口:
1
2
3
4
public final void acquireShared(int arg) {
if (tryAcquireShared(arg) < 0)
doAcquireShared(arg);
}
执行tryAcquireShared方法获取资源,若获取成功则直接返回,若失败,则进入等待队列,执行自旋获取资源,具体由doAcquireShared方法来实现
共享模式和独占模式最大的区别是:
- 独占模式:一个线程成功,其他线程都得等
- 共享模式:一个线程成功后,可能还有剩余资源,后面的线程也可能继续成功
这就是为什么像 Semaphore 这种组件能允许多个线程同时通过。
释放资源(共享模式)
方法入口:
1
2
3
4
5
6
7
8
9
public final boolean releaseShared(int arg) {
// 尝试释放资源
if (tryReleaseShared(arg)) {
// 唤醒后继节点的线程
doReleaseShared();
return true;
}
return false;
}
tryReleaseShared(int)由继承AQS的自定义同步器来具体实现
共享模式释放资源后,AQS 可能继续传播唤醒后续节点,因此在共享模式下可能出现“一个线程放行后,后续多个线程继续推进”的效果。
AQS 到底是怎么管理线程的
- 线程先尝试通过
tryAcquire/tryAcquireShared直接获取资源 - 获取失败,就把线程包装成
Node加到队尾 - 入队后线程不会一直忙等,而是会被挂起
- 前面的线程释放资源时,会唤醒后继节点
- 被唤醒的线程再次尝试获取资源
- 成功则出队并继续执行,失败则继续留在队列里等
所以 AQS 的队列本质上管理的是:
- 等待顺序
- 阻塞与唤醒
- 谁最有资格在下一轮去竞争资源
而不是简单地“把锁对象放在队列里传来传去”。
ReentrantLock 与 AQS 的关系
ReentrantLock 通过 AQS 实现独占式加锁与排队唤醒流程。主线可以概括为:先 lock(),内部尝试 tryAcquire();失败则入队并挂起;前驱线程 unlock() 后唤醒后继节点;后继节点再次尝试获取资源,成功后继续执行。
AQS、synchronized、CAS 三者关系
这三个概念并不在同一个层级上,可以先看最短结论:
CAS是一种原子更新手段AQS是一套同步器框架synchronized是 Java 语言层面提供的内置锁机制
1. CAS 是底层原子操作工具
CAS 关注的是:
“某个值还是不是我刚才看到的那个值?如果是,我就把它改掉。”
所以它解决的是“多线程同时修改同一个状态值时,怎么安全地更新”。
AQS 内部就大量使用 CAS 来做这些事情,比如:
- 修改
state - 初始化队列
- 更新
tail - 某些节点状态变更
也就是说,CAS 更像是 AQS 的底层工具之一。
2. AQS 是基于 CAS + 队列 + park/unpark 的同步框架
AQS 不直接等于锁,它更像一个“搭锁的底盘”。
它负责的事情包括:
- 维护同步状态
state - 管理等待队列
- 处理线程挂起和唤醒
- 提供独占模式和共享模式
像这些类,很多都建立在 AQS 之上:
ReentrantLockSemaphoreCountDownLatchReentrantReadWriteLock
AQS 往下会用到 CAS,往上会支撑各种并发组件。
3. synchronized 不是基于 AQS 实现的
这个点特别容易被误会。
synchronized 是 JVM 原生支持的关键字,它依赖的是对象头、Monitor、字节码指令 monitorenter / monitorexit 这一套机制。
所以:
ReentrantLock主要走的是AQS这条路synchronized主要走的是 JVM Monitor 这条路
它们都能实现互斥和同步,但底层实现路线不同。
4. 三者怎么放在一张脑图里
可以直接记下面这张关系图:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
Java 并发同步相关概念
┌──────────────┐
│ CAS │
│ 原子更新手段 │
└──────┬───────┘
│
v
┌──────────────────┐
│ AQS │
│ 同步器通用框架 │
│ state + 队列 + │
│ park/unpark │
└──────┬───────────┘
│
┌───────────────┼────────────────┐
v v v
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ReentrantLock │ │ Semaphore │ │CountDownLatch│
└──────────────┘ └──────────────┘ └──────────────┘
另一条路线:
┌──────────────────────────────┐
│ synchronized │
│ JVM Monitor 机制 │
│ monitorenter/monitorexit │
└──────────────────────────────┘
5. 一句话概括
CAS 是原子更新的基础手段,AQS 是基于 CAS、等待队列和线程挂起唤醒机制实现出来的同步器框架,ReentrantLock 等并发组件建立在 AQS 之上;而 synchronized 是 JVM 原生的 Monitor 机制,不依赖 AQS,但它和 AQS 路线最终都在解决并发场景下的互斥与同步问题。
公平锁/非公平锁
公平锁是指多个线程按照申请锁的顺序来获取锁
非公平锁是指多个线程获取锁的顺序并不是按照申请锁的顺序,有可能后申请的线程比先申请的线程优先获取锁
有可能会造成优先级反转或者饥饿现象
对于 Java ReentrantLock而言,通过构造函数指定该锁是否是公平锁,默认是非公平锁。非公平锁的优点在于吞吐量比公平锁大
对于Synchronized而言,也是一种非公平锁。由于其并不像ReentrantLock是通过 AQS 的来实现线程调度,所以并没有任何办法使其变成公平锁
ReentrantLock 公平锁 vs 非公平锁 对比流程图
- 公平锁:先看队列里有没有人在等,有就老老实实排队
- 非公平锁:先不管队列,先直接抢一下,抢到了再说
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
ReentrantLock 公平锁 vs 非公平锁
线程来执行 lock()
│
v
┌──────────────────────┬──────────────────────┐
│ 公平锁 Fair │ 非公平锁 Nonfair │
├──────────────────────┼──────────────────────┤
│ 1. 先检查等待队列 │ 1. 先直接 CAS 抢 state│
│ 是否已有前驱节点 │ │
│ │ │
│ 2. 如果前面有人在等 │ 2. 抢到了:直接成功 │
│ 就不能插队 │ 不管队列里是否有人 │
│ │ │
│ 3. 只有“队列没人”且 │ 3. 没抢到:再进入 │
│ “state可用”时 │ 正常排队流程 │
│ 才尝试获取锁 │ │
│ │ │
│ 4. 获取失败则入队 │ 4. 入队后再等待唤醒 │
│ 等待唤醒 │ 并重新竞争 │
└──────────────────────┴──────────────────────┘
如果拆成两条更细的路径,可以这样看:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
公平锁:
lock()
│
v
检查 hasQueuedPredecessors()
│
├─ 有前驱节点 -> 不能插队 -> 入队等待
│
└─ 没前驱节点
│
v
tryAcquire()
│
├─ 成功 -> 获得锁
└─ 失败 -> 入队等待
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
非公平锁:
lock()
│
v
先直接 CAS 抢 state
│
├─ 成功 -> 立刻获得锁
│
└─ 失败
│
v
再走 acquire()
│
v
入队等待 / 被唤醒后再竞争
它们最本质的区别,不是在“是否使用队列”,而是在:
- 公平锁:先看有没有人排队,再决定我能不能抢
- 非公平锁:先抢一次,抢不到再去排队
为什么非公平锁吞吐量更高
因为非公平锁少了一次“严格按排队顺序执行”的约束。
有时候锁刚被释放,后来的线程如果正好在 CPU 上运行,就可以立刻抢到锁,不用一定先唤醒并等待队列里的老线程完成切换,所以整体吞吐量通常更高。
但代价就是:
- 可能出现后来的线程插队
- 等待时间不那么均匀
- 极端情况下可能让队列里的线程更久才能拿到锁
一句话概括
可以直接这样答:
公平锁会先判断等待队列里是否已有前驱节点,有的话当前线程不能插队;非公平锁则会先直接尝试 CAS 抢锁,失败后才进入队列。所以非公平锁吞吐量通常更高,但公平性更差。
可重入锁
可重入锁又名递归锁,是指同一个线程在外层方法获取锁后,进入内层同一把锁保护的代码时可以再次成功获取该锁
ReentrantLock 与 synchronized 都属于可重入锁。可重入性避免了线程在已经持有某把锁时再次进入相关临界区而把自己阻塞住。
1
2
3
4
5
6
7
8
9
10
// setA() 外层方法
synchronized void setA() throws Exception{
Thread.sleep(1000);
setB();
}
// setB() 内层方法
synchronized void setB() throws Exception{
Thread.sleep(1000);
}
上面的代码就是一个可重入锁的一个特点,如果不是可重入锁的话,setB 可能不会被当前线程执行,可能造成死锁
独享锁/共享锁
独享锁是指该锁一次只能被一个线程所持有
共享锁是指该锁可被多个线程所持有
ReentrantLock 属于独享锁。ReadWriteLock 是读写锁抽象,其常见实现 ReentrantReadWriteLock 中,读锁是共享模式,写锁是独享模式。读读之间可以并发,读写、写读、写写之间互斥。synchronized 也属于独享式互斥同步。
互斥锁/读写锁
独享锁/共享锁是更偏抽象层面的分类,互斥锁/读写锁则是更具体的同步器形态。Java 中常见的互斥锁实现包括 synchronized 与 ReentrantLock,常见的读写锁实现是 ReentrantReadWriteLock。
什么是乐观锁和悲观锁
乐观锁: 总是假设最好的情况,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新数据
可以用版本号机制和CAS算法实现,适用于多读的应用类型,可以提高吞吐量,Java中java.unit.concurrent.atomic包下面的原子变量类就是使用了乐观锁的一种实现方式CAS实现
版本号机制:一般在数据表中加上一个数据版本号Version字段,表示数据被修改的次数
悲观锁: 总是假设最坏的情况,每次去拿数据都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁(共享数据每次只给一个线程使用,其他线程阻塞,用完后再把资源让给其他线程)
传统的关系型数据库里边就用到很多这种锁机制,比如行锁、表锁、读锁和写锁等,都是在操作之前先上锁,Java中synchronized(关键字)和 ReentrantLock(类)等独占锁就是悲观锁实现
分段锁
分段锁是一种锁粒度拆分思路,并不是某个固定 API 名称。
在 Java 并发语境中,ConcurrentHashMap 经常被拿来说明这种设计,但必须明确区分 JDK 版本。
以 ConcurrentHashMap 为例,分段锁主要对应 JDK 7 的实现思路:
ConcurrentHashMap 在 JDK 7 中引入 Segment,每个分段都维护自己的一组桶,并通过分段级锁降低全表级别的竞争。
当执行 put 时,不需要对整个 HashMap 加锁,而是根据键路由到某个 Segment,只锁定对应分段。因此,不同分段上的写操作可以并行推进。
但在统计 size 这类全局信息时,往往需要汇总多个分段的状态,成本也随之上升。
分段锁的设计目的,是把“整张表一把锁”细化成“局部区域加锁”,从而提高并发度。
需要特别说明的是:JDK 8 之后的 ConcurrentHashMap 已经不再使用 Segment 结构,而是采用 CAS 与桶级别同步等手段来控制并发。因此,“ConcurrentHashMap 基于分段锁”只能作为 JDK 7 时代的历史实现描述。
偏向锁/轻量级锁/重量级锁
这三种锁描述的是 synchronized 在 HotSpot 中的几种实现形态或优化路径,更准确地说,这是 JVM 为了优化监视器获取成本而设计的一套状态演进机制。
很多人看到这里会卡住:“对象头到底在哪?”
对象头不是单独放在别处的一块数据,它就在 Java 对象内存布局的最前面。
也就是说,一个对象在堆里不是只有“成员变量”,它大致可以分成下面几部分:
1
2
3
4
5
6
7
8
9
对象在内存中的大致布局
┌──────────────────────┐
│ 对象头 Header │
├──────────────────────┤
│ 实例数据 Instance │
├──────────────────────┤
│ 对齐填充 Padding │
└──────────────────────┘
例如一个普通对象:
1
Object lock = new Object();
它在堆里其实就像这样:
1
2
3
4
5
6
7
8
9
10
11
┌──────────────────────────────────────┐
│ 对象头 │
│ - Mark Word │
│ - Klass Pointer │
│ - (数组对象还会多一个长度字段) │
├──────────────────────────────────────┤
│ 实例数据 │
│ - 实例字段 │
├──────────────────────────────────────┤
│ 对齐填充 │
└──────────────────────────────────────┘
这里的“对象头”不是抽象概念,而是对象本身在堆内存里的头部区域。
再结合 synchronized 去理解:
synchronized锁住的是某个对象- 这个对象的运行时锁信息,会体现在这个对象头里的
Mark Word - JVM 会根据竞争情况,修改
Mark Word中和锁相关的标记位或指针信息
所以原来那句“这三种锁的状态是通过对象监视器在对象头中的字段来表明的”,可以更准确地理解成:
锁状态相关信息主要体现在对象头里的 Mark Word 中;当锁膨胀为重量级锁后,又会进一步关联到 Monitor 对象。
对象头里最关键的是什么
先不用把对象头的所有细节一次背下来,先记住两个关键词就够了:
Mark WordKlass Pointer
其中和锁最相关的是 Mark Word。
Mark Word:存放对象运行时信息,比如哈希码、GC 分代年龄、锁标志位等Klass Pointer:指向这个对象对应的类元数据,JVM 通过它知道“这个对象到底是什么类型”
如果是数组对象,还会多一块数组长度信息,因为 JVM 还得知道数组有多长。
为什么锁信息会放在对象头里
因为 synchronized 的锁对象本来就是“对象本身”。
所以 JVM 最自然的做法就是:把和锁相关的状态直接记在这个对象自己的头部信息里。这样当线程来竞争这个对象锁时,JVM 只要先看这个对象头,就能知道:
- 现在是不是无锁状态
- 是否已经偏向某个线程
- 是否处于轻量级锁状态
- 是否已经膨胀成重量级锁
可以把对象头理解成对象随身携带的一张“运行时状态卡片”,JVM 在加锁、解锁、升级锁时会不断调整其中的内容。
那如果锁的不是普通对象,而是类呢
这个问题也很常见,但结论其实很统一:
synchronized 本质上永远锁的是对象。所谓“锁类”,实际锁的是这个类对应的 Class 对象。
例如:
1
2
3
4
public class UserService {
public static synchronized void test() {
}
}
它本质上等价于:
1
2
3
4
5
6
public class UserService {
public static void test() {
synchronized (UserService.class) {
}
}
}
所以这里并不是在锁一个抽象的“类定义”,而是在锁 UserService.class 这个真实存在的对象。
而 UserService.class 本身也是一个对象,它的类型是 java.lang.Class,因此它同样有:
- 对象头
Mark Word- 对应的锁状态变化
因此:
synchronized (this),那看的是当前实例对象的对象头synchronized (UserService.class),那看的是UserService.class这个Class对象的对象头
本质上没有变,变的只是“锁对象是谁”。
可以直接记成:
1
2
3
普通对象锁 -> 锁实例对象
类锁 -> 锁 Xxx.class 对象
本质上都还是锁对象
再补一个最容易混淆的点:
1
2
3
4
5
6
7
public class Demo {
public synchronized void a() {
}
public static synchronized void b() {
}
}
这里两把锁不是同一把:
a()锁的是某个Demo实例b()锁的是Demo.class
所以一个线程进实例同步方法,另一个线程进静态同步方法,不一定互斥,因为它们抢的可能根本不是同一个对象锁。
常见加锁写法对照表
后面看到各种 synchronized 写法时,可以直接对照下面这张表:
| 写法 | 锁的是谁 | 是否每次都是同一把锁 | 典型场景 | 易错点 |
|---|---|---|---|---|
synchronized(this) |
当前实例对象 | 同一个实例内是 | 保护当前对象的实例状态 | 不同实例之间不互斥 |
synchronized(new Object()) |
每次新创建的对象 | 不是 | 几乎没有实际加锁价值 | 每次进来都是新锁,等于没锁住共享资源 |
synchronized(Xxx.class) |
Xxx.class 这个 Class 对象 |
是 | 类级别互斥、静态资源保护 | 和实例锁不是同一把锁 |
synchronized(lock),其中 lock 是 static final Object |
这个固定锁对象 | 是 | 显式定义全局共享锁 | 要保证所有线程拿的是同一个 lock |
this:锁“当前这个对象”new Object():锁“临时现造的一个新对象”Xxx.class:锁“这个类对应的Class对象”static final Object lock:锁“大家约定好共用的那把固定锁”
怎么快速判断一把锁到底是不是同一把
判断标准其实只有一个:
多个线程进入这段代码时,拿到的是不是同一个对象引用。
如果是同一个对象,就会互斥;如果不是同一个对象,就不会互斥。
例如下面这段代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class Demo {
private final Object lock = new Object();
public void a() {
synchronized (this) {
}
}
public void b() {
synchronized (lock) {
}
}
public static void c() {
synchronized (Demo.class) {
}
}
}
这里:
a()锁的是实例对象自己b()锁的是这个实例里的lockc()锁的是Demo.class
它们默认都不是同一把锁。
它们到底是不是在抢同一个对象锁?
偏向锁指的是对象在一段时间内总是被同一个线程访问时,JVM 倾向于把锁偏向该线程,以减少无竞争同步的额外开销。
轻量级锁通常出现在存在轻度竞争时,线程会优先通过 CAS、自旋等方式尝试获取锁,而不是立刻进入内核阻塞。
重量级锁则意味着竞争已经升级到需要依赖 Monitor 阻塞与唤醒线程,线程切换和内核参与的成本更高。
synchronized锁升级
synchronized 的锁升级,是指 JVM 根据竞争强度自动切换不同同步实现路径的过程。这里说“锁信息在对象头里”,并不是整个锁对象都塞在对象头中,而是:JVM 会把和锁状态相关的关键信息记录在对象头的 Mark Word 中,必要时再关联到 Monitor。
Java对象头中的MarkWord
Mark Word:默认存储对象的HashCode,分代年龄和锁标志位信息。这些信息都是与对象自身定义无关的数据,所以Mark Word被设计成一个非固定的数据结构以便在极小的空间内存存储尽量多的数据。它会根据对象的状态复用自己的存储空间,也就是说在运行期间Mark Word里存储的数据会随着锁标志位的变化而变化。
它是对象头里最活跃的一块区域,专门拿来放“对象运行时会变的信息”。
比如同一个对象,在不同时间点,Mark Word 里可能表示的是:
- 还没加锁
- 已经偏向某个线程
- 处于轻量级锁
- 已经膨胀成重量级锁
- 或者存着对象哈希码等信息
所以对象头不是一直不变的,尤其是 Mark Word,会随着运行时状态变化而变化。
可以把对象头和锁升级串起来理解
1
2
3
4
5
6
7
8
9
10
11
线程还没来竞争
-> 对象头记录“当前是普通/可偏向状态”
一个线程长期访问
-> 对象头里的 Mark Word 更偏向这个线程
第二个线程开始竞争
-> 对象头里的锁标记变化,升级为轻量级锁
竞争进一步加剧
-> 对象头不再只靠轻量级信息表达,转而关联重量级 Monitor
JVM 根据竞争情况,不断调整对象头里锁相关信息的表示方式。
一个容易混淆但很重要的点
对象头在Java 堆中的对象内存里,而不是在线程栈里,也不是单独在某个“锁表”里放一份。
但是:
- 轻量级锁会涉及线程栈中的锁记录(Lock Record)
- 重量级锁会涉及 Monitor
因此很多资料会同时提到“对象头”“栈帧”“Monitor”,它们并不矛盾,而是不同锁状态下共同参与同步过程的几部分。
可以先粗略记成:
- 对象头:锁状态入口,先看这里
- 线程栈里的锁记录:轻量级锁阶段会用到
- Monitor:重量级锁阶段会重点用到

升级过程
1、创建一个对象LockObject时,该对象的部分Markword关键数据如下

在早期 HotSpot 实现中,对象可能处于可偏向状态;线程首次进入同步块后,JVM 会尝试把偏向信息记录到对象头中,以降低后续无竞争加锁成本。
2、在启用偏向锁优化的旧版 HotSpot 中,当线程进入临界区时,JVM 会尝试把线程 ID 等信息写入 Mark Word
临界区:就是只允许一个线程进去执行操作的区域,即同步代码块,只要对多线程并发有影响的都叫临界区。CAS是一个原子性操作

此时可以理解为该对象已经偏向某个线程,后续没有竞争时可以降低重复加锁成本
偏向锁是 JDK 6 时代引入的一项 HotSpot 优化,用于降低无竞争同步的开销。但需要注意,这是一项具体实现层面的历史优化,并不是 Java 语言层面的固定语义。
3、当另一个线程开始竞争同一把锁时,偏向锁会被撤销,随后可能升级为轻量级锁

轻量级路径通常会配合自旋与自适应自旋等策略,以避免一开始就进入阻塞
自旋是指线程在短时间内循环尝试获取锁,而不是立刻阻塞挂起
自旋会持续消耗 CPU,因此只适合临界区很短、竞争不重的场景
因此,轻量级锁更适合同步代码块执行时间很短的场景;如果临界区较长或竞争较重,自旋收益就会迅速下降。
基于这个问题,必须给线程空循环设置一个次数,当线程超过了这个次数,就认为使用自旋锁就不适合了,此时锁会再次膨胀,升级为重量级锁 早期资料中常见的固定自旋次数与相关参数,只适合特定 HotSpot 版本;具体行为会随着 JDK 演进而变化
自适应自旋锁: 所谓自适应自旋锁就是线程空循环等待的自旋次数并非是固定的,而是会动态着根据实际情况来改变自旋等待的次数。 假如一个线程1刚刚成功获得一个锁,当它把锁释放了之后,线程2获得该锁,并且线程2在运行的过程中,此时线程1又想来获得该锁了,但线程2还没有释放该锁,所以线程1只能自旋等待,但是虚拟机认为,由于线程1刚刚获得过该锁,那么虚拟机觉得线程1这次自旋也是很有可能能够再次成功获得该锁的,所以会延长线程1自旋的次数。 另外,如果对于某一个锁,一个线程自旋之后,很少成功获得该锁,那么以后这个线程要获取该锁时,是有可能直接忽略掉自旋过程,直接升级为重量级锁的,以免空循环等待浪费资源
4、竞争继续加剧时,轻量级锁可能进一步膨胀为重量级锁

当锁膨胀为重量级锁后,等待线程会进入阻塞。阻塞线程不会持续消耗 CPU,但阻塞与唤醒会带来额外的上下文切换与系统调用成本,这也是重量级锁开销较大的原因。
需要补充一个版本边界:偏向锁在较新的 HotSpot 中已经逐步退出历史舞台。JDK 15 起该优化默认关闭并被弃用,JDK 18 起相关开关被废弃,因此阅读这部分时应把重点放在“锁状态演进思路”上,而不是把偏向锁当成所有新版本 JVM 的默认行为。
自旋锁
在 Java 中,自旋锁是指尝试获取锁的线程不会立即阻塞,而是采用循环的方式去尝试获取锁,这样的好处是减少线程上下文切换的消耗,缺点是循环会消耗 CPU
为什么用 Lock、ReadWriteLock
synchronized 的缺陷
- 被 synchronized 修饰的方法或代码块,只能被一个线程访问。如果这个线程被阻塞,其他线程也只能等待
- synchronized 不能响应中断
- synchronized 没有超时机制
- synchronized 只能是非公平锁
Lock、ReadWriteLock 相较于 synchronized,解决了以上的缺陷:
- Lock 允许显式获取和释放锁,便于把加锁范围控制得更精细
- Lock 可以在等待获取锁时响应中断
- Lock 可以设置超时时间,避免无限期等待
- Lock 可以选择公平锁或非公平锁两种模式
- ReadWriteLock 将读写锁分离,从而在读多写少场景中提高并发性
Lock 和 ReentrantLock
如果采用 Lock,必须主动释放锁,并且在异常路径中也要保证释放。因此通常会把 unlock() 放到 finally 块中,以避免锁泄漏。
lock() 方法的作用是获取锁。如果锁已被其他线程获取,则进行等待
tryLock() 方法的作用是尝试获取锁,如果成功则返回 true;如果失败(即锁已被其他线程获取)则返回 false。这个方法无论如何都会立即返回,获取不到锁时不会等待
tryLock(long time, TimeUnit unit) 方法和 tryLock() 类似,区别在于它会在限定时间内等待获取锁;如果超时仍未获取到,则返回 false
lockInterruptibly() 方法比较特殊。当线程通过这个方法等待获取锁时,可以响应中断,即等待状态可以被中断打断
例如两个线程都通过 lock.lockInterruptibly() 获取同一把锁时,若线程 A 已经获得锁、线程 B 正在等待,那么对线程 B 调用 interrupt() 可以中断它的等待过程。由于 lockInterruptibly() 会抛出 InterruptedException,因此调用处需要显式处理该异常
需要注意的是:线程在已经获得锁并执行临界区代码时,不会因为 interrupt() 而自动释放锁;可中断的是“等待获取锁”的过程,而不是“已经持锁执行”的过程
unlock() 方法的作用是释放锁
ReentrantLock 是 Lock 接口最常见的实现之一,但并不是唯一实现
ReentrantLock 字面意为可重入锁
ReadWriteLock 和 ReentrantReadWriteLock
对于特定的资源,ReadWriteLock 允许多个线程同时对其执行读操作,但是只允许一个线程对其执行写操作。
ReadWriteLock 维护一对相关的锁。一个是读锁;一个是写锁。将读写锁分开,有利于提高并发效率。
ReentrantReadWriteLock 实现了 ReadWriteLock 接口,所以它是一个读写锁。
“读-读”线程之间不存在互斥关系。
“读-写”线程、“写-写”线程之间存在互斥关系
Synchronized和lock区别
synchronized和lock的用法区别
synchronized:在需要同步的对象中加入此控制,synchronized可以加在方法上,也可以加在特定代码块中,括号中表示需要锁的对象
lock:需要显示指定起始位置和终止位置。一般使用ReentrantLock类做为锁,多个线程中必须要使用一个ReentrantLock类做为对象才能保证锁的生效。且在加锁和解锁处需要通过lock()和unlock()显示指出。所以一般会在finally块中写unlock()以防死锁
synchronized和lock性能区别
synchronized 由 JVM 原生支持
Lock 则是 java.util.concurrent.locks 包提供的显式锁抽象
在 Java 早期版本中,synchronized 的性能开销确实较高,因此很多资料会强调显式锁的优势。
但从 JDK 6 开始,HotSpot 围绕 synchronized 引入了大量优化,二者的性能差距已经不能简单概括成“Lock 一定更快”。
synchronized 在语义上更直接,JVM 也能围绕它做锁消除、锁粗化、轻量级路径等优化;因此在许多普通互斥场景中,优先使用 synchronized 是合理的。
无论是 synchronized 还是 ReentrantLock,本质上都属于互斥同步手段,都可能在竞争激烈时引入阻塞、唤醒和上下文切换成本。
ReentrantLock 内部会借助 CAS 与 AQS 管理同步状态,但这并不意味着它本身就是“乐观锁”。更准确的说法是:ReentrantLock 仍然是互斥锁,只是它在实现上比 synchronized 提供了更丰富的控制能力。
synchronized和lock用途区别
synchronized 与 ReentrantLock 在一般互斥场景下都能完成同步目标;但在更复杂的同步需求中,ReentrantLock 会更灵活,尤其是以下几类场景:
- 某个线程在等待锁的过程中需要响应中断
- 需要把等待条件拆分为多个
Condition队列,而不是共用一个对象监视器等待集 - 具有公平锁功能,每个到来的线程都将排队等候
| 类别 | synchronized |
Lock |
|---|---|---|
| 存在层次 | Java 关键字,由 JVM 原生支持 | Java 并发包中的显式锁接口/实现 |
| 锁的释放 | 正常退出同步块或异常退出时自动释放 | 需要显式 unlock(),通常放在 finally 中 |
| 锁的获取 | 获取失败时等待 monitor,可读性高 | 提供 lock、tryLock、lockInterruptibly 等多种获取方式 |
| 锁状态 | 难以直接观察等待队列等信息 | 可通过部分 API 判断或监控锁状态 |
| 锁特性 | 可重入;获取监视器锁时不可中断;实现上通常为非公平 | 可重入;支持可中断、可超时、公平/非公平等策略 |
| 使用建议 | 普通互斥场景优先考虑 | 需要更强控制能力时使用 |