1. synchronized 的特性
1.1原子性
原子性是指:一段操作要么全部同时完成,要么就不完成,中间过程不会被线程切换打断。
按道理来说都是这样的呀,为什么原子性还是个特性呢?原因就是CPU执行的不是语句,而是指令。
就比如:count++这个语句,我们就认为自增一下,0->1这种。但是实际上却有三步:
- 从主内存读取 count 到寄存器(读)
- count+1(加)
- 写回主内存(写)
但是此时又会出现新的问题:假设一个场景,先有一个count(初始值为0),需要线程A和B都对count自增,那么按照我们上面的步骤,线程A先从主内存取出count的值(0),然后对0自增一次成为1,但在这个时候线程B读取count的值(0),然后B自增完成了1,A保存,B保存,最后保存了的值还是1(期望值是2,因为执行了两次自增操作)。这就是因为等多个线程同时进入了一个非原子操作,并且指令执行被穿插。
这里来一个代码案例观察观察~
java
class Counter{
int count = 0;
public void add(){
count++;
}
}
public class t {
public static void main(String[] args) throws InterruptedException {
Counter counter = new Counter();
Thread t1 = new Thread(() -> {
for (int j = 0; j < 10000; j++) {
counter.add();
}
});
Thread t2 = new Thread(() -> {
for (int j = 0; j < 10000; j++) {
counter.add();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(counter.count);
}
}
上面就是两个线程都对count进行自增10000次,理论上来说结果应该是20000,但是结果却不是20000,而是在大概在10000-20000之间浮动:

这也就是上面所说的多个线程没有遵守原子性操作,导致穿插执行(随机调度)。
如何让 synchronized 保证原子性呢?
主要就是将多条指令变成临界区独占执行。在底层原理中,每个Java对象都有一个对象头(Object Header),其中Mark Word可以指向一个Monitor对象(操作系统层面的互斥结构)。
Monitor内部维护了:
- _owner:当前持有锁的ID
- _EntryList:等待获取锁的线程队列
当线程执行synchronized 块时,JVM会生成 monitorenter 和 monitorexit 指令。monitorenter 尝试将_owner 设置为当前线程--同一时刻只有一个线程能成功。其他线程必须等待。这样就会让count++的三条指令在执行期间不会被任何其他线程插入,因此变成了不可分割的原子操作。
java
class Counter {
int count = 0;
public synchronized void add() {
count++;
}
}
加上synchronized关键字后,count++就会从可并发执行转换为串行执行。
详细讲解:
什么是HotSpot?
你可以将HotSpot理解为Java程序的"高性能发动机"。它并非一个理论规范,而是一个具体的、业界广泛使用的Java虚拟机实现。
它的核心思想是"自适应编译 "和"热点代码探测":
-
混合执行模式 :HotSpot采用"解释器+JIT(即时)编译器"的混合模式工作。
-
探测"热点":程序启动时,代码先由解释器逐行执行。同时,HotSpot会默默记录下各段代码的执行频率。那些被高频调用的方法或循环体,就被标记为"热点代码"。
-
编译与优化:一旦某段代码被判定为"热点",HotSpot就会启动JIT编译器,将其动态编译成本地机器码,并执行优化。这段编译后的代码会直接被缓存起来,下次执行时无需再次编译,从而实现性能飞跃。
这种模式在启动速度 (得益于解释器)和运行效率(得益于JIT编译)之间取得了极佳的平衡。
什么是Monitor?
Monitor(监视器/管程)是Java并发编程的基石,它确保了多线程环境下对共享资源的安全访问。
1. 概念:一把看不见的"锁"
你可以把Monitor理解成与每个Java对象绑定的一把看不见的、排他性的"锁"。
-
互斥性 :任何一个时刻,最多只有一个线程可以持有(或者说"拥有")一个对象的Monitor。
-
synchronized的本质 :当你用synchronized关键字修饰一段代码或方法时,JVM的底层操作正是让线程在进入前获取(monitorenter) 关联对象的Monitor,在退出后释放(monitorexit)。只有成功获取到Monitor的线程,才能进入临界区执行代码。
2. 实现:ObjectMonitor 结构体
在HotSpot的C++源码中,Monitor的具体实现是一个名为 ObjectMonitor 的结构体。它的核心属性就像一张详细记录锁状态的名册:
| 属性 | 作用描述 |
|---|---|
| _owner | 指向当前拥有该Monitor的线程的指针。 |
| _EntryList | 阻塞队列 。存放那些暂时没抢到锁、被阻塞的线程。 |
| _WaitSet | 等待队列 。存放那些曾经拥有锁,但主动调用 wait() 方法进入等待状态的线程。 |
| _cxq | 竞争队列。新来的线程抢锁失败,会先进入这个队列,是 _EntryList 的"预备队"。 |
| _recursions | 记录同一个线程重入同一把锁的次数。对于可重入锁的实现至关重要。 |
| _count | 一个计数器,其值大约为 _EntryList 和 _WaitSet 中线程数量之和。 |
3. 工作机制:线程的"调度中心"
当一个线程请求一把已经被占用的锁时(即重量级锁状态),Monitor的工作流程如下:
-
尝试获取 :线程尝试进入
synchronized块,JVM执行monitorenter指令。 -
加入队列 :如果锁已被占用,当前线程会被封装成一个
ObjectWaiter对象,并被加入到_cxq(竞争队列) 的头部。 -
竞争或阻塞:加入队列后,线程会短暂进行自旋,再次尝试获取锁。如果依然失败,线程将被挂起(park),等待被唤醒。
-
唤醒与晋升 :当持有锁的线程释放锁后,HotSpot会唤醒
_cxq或_EntryList中的等待线程,并让它们重新竞争锁。 -
wait()与notify():拥有锁的线程如果调用wait(),它会释放锁,_owner被置空,自身进入_WaitSet等待。当其他线程调用notify()时,会从_WaitSet中随机唤醒一个线程,将其移入_EntryList重新竞争锁。
4. 状态演变:从"轻"到"重"的锁升级
为了提高性能,Monitor的状态并非一蹴而就,而是会随着线程竞争情况逐步 "升级",且这个过程是单向的,不可逆。Java 6及以后,锁主要有四种状态:
-
无锁:初始状态,没有线程竞争。
-
偏向锁:当一个线程反复获取同一把锁时,JVM会偏向它,省去CAS操作,性能极高。
-
轻量级锁:当第二个线程开始竞争,偏向锁升级。线程通过自旋(CAS)尝试获取锁,避免线程阻塞和唤醒的开销。
-
重量级锁 :当竞争激烈,自旋超过一定次数后,升级为重量级锁。未获得锁的线程会直接进入
_EntryList阻塞,由操作系统内核进行线程调度。
注意 :与重量级锁关联的
ObjectMonitor对象,也并非一创建就存在,而是在锁首次膨胀为重量级锁时被延迟创建并关联到对象上的。
完整流程
用一个简单的代码块,把上面所有知识串联起来:
java
public class Example {
private static final Object lock = new Object();
public static void main(String[] args) {
synchronized (lock) {
System.out.println("Hello, Monitor!");
}
}
}
-
字节码层面 :编译后,
synchronized代码块前后会生成monitorenter和monitorexit指令。 -
JVM执行 :当程序运行到
monitorenter指令时,JVM会定位到lock对象。 -
查看对象头 :JVM会检查
lock对象的对象头中的Mark Word字段。 -
锁状态判定:
-
如果
Mark Word显示为无锁 ,JVM会通过CAS操作尝试将锁设置为轻量级锁 ,并将Mark Word指向线程栈中的锁记录。 -
如果锁已经是轻量级锁 且被当前线程持有,则执行锁重入 ,计数器
_recursions加1。 -
如果发生锁竞争(另一线程尝试获取),JVM会执行锁膨胀:
-
为
lock对象创建一个ObjectMonitor对象。 -
将
lock对象头的Mark Word更新为指向这个ObjectMonitor的指针,锁标志位变为 重量级锁。 -
竞争失败的线程被封装成
ObjectWaiter,进入ObjectMonitor的_cxq队列,并最终被挂起。
-
-
-
执行临界区 :成功获取锁的线程执行
System.out.println(...)代码。 -
退出并释放 :当执行到
monitorexit指令时,线程会退出临界区,并调用ObjectMonitor::exit()释放锁。这包括将_owner置为null,并根据策略从_EntryList或_cxq中唤醒下一个等待线程。
总结
synchronized 通过对象头中的Monitor 实现互斥锁,保证同一时刻只有一个线程执行临界区,从而将多条执行绑定为一个不可分割的原子操作。
1.2 可见性
可见性指的是:一个线程对变量的修改能够被其他的线程立即(或者在可预期的时间内)看到。
出现不可见的原因是:CPU缓存+寄存器+编译优化。现代川普的运算速度远高于内存访问速度,因此每个核心都有自己的L1/L2缓存。线程执行时,会将变量从主内存复制到自己的工作内存(抽象概念,对应CPU缓存和寄存器),并优先从缓存中读写,如果没有挺不错是,线程永远不会主动去内存中重新读取,因为从缓存读取会快很多很多~
主内存
↑↓
线程A工作内存(缓存) 线程B工作内存(缓存)
就比如一个场景:
java
boolean flag = false;
// 线程A:flag = true; // 仅写入自己缓存,未刷回主内存
// 线程B:while(!flag){} // 一直读取自己缓存中的 false → 死循环
修改后的结果没有存入内存,而是存入了自己的缓存,这样就会导致线程B永远看不到变化
synchronized 如何解决这个问题呢?
关键点不是加锁,而是锁的获取与释放伴随内存同步
- 锁释放:将工作内存中修改的变量刷新到主内存中
- 锁获取:是本地缓存失效,迫使后续读操作从主内存中重新拉取
这背后其实是JMM的 happens-before后续对同一个锁的lock操作。
这意味着:线程A在释放锁之前修改的所有共享变量,在线程B获得同一个锁之后,B一定能看到A的修改(及时物理上变量还留在缓存,JVM也会通过内存屏障强制同步)
java
class Test {
static boolean flag = false;
static final Object lock = new Object();
public static void main(String[] args) {
new Thread(() -> {
while (true) {
synchronized (lock) {
if (flag) break;
}
}
System.out.println("结束");
}).start();
new Thread(() -> {
synchronized (lock) {
flag = true;
}
}).start();
}
}
这段代码中,线程B释放锁时把flag = true刷回主内存;线程A每次获取锁时都会强制从主内存读取最新的flag,从而看到变化。
synchronized 在锁释放和再次加锁之间建立了happens-before 关系。具体而言,一次 synchronized 代码块或方法的退出(释放锁)与同一监视器锁下一次进入(获取锁)之间有严格的先后关系,这保证了前者对共享变量的写入对于后者是可见的。JMM 规定:"对一个监视器的解锁发生于对同一个监视器的随后的加锁之前"。因此,只要程序正确同步(无数据竞争),执行结果将符合顺序一致性,不需要考虑重排序对正确性的影响。
1.3 有序性
有序性指的是:程序执行顺序复合代码书写顺序。即:写在前面的代码应看上去先执行,写在后面的后执行。
既然有有序性这个概念那么必然会有与之对应的无序性。发生无序性的原因是编译器优化+CPU指令重排序。为了提升性能,编译器和CPU可能会在不影响单线程执行结果的前提下调整指令执行顺序。
就比如说:a=1,b=2 实际CPU可能限制性b=2再执行a=1。在单线程情况下最终结果一样,没有什么影响。但是在多线程下,重排可能导致严重后果比如:其他线程读到半初始化状态。
经典问题:双重检查锁单利失效:
java
instance = new Singleton();
这条语句再JVM中实际上可能分解为三个步骤:
- 分配内存空间
- 初始化对象(调用构造器)
- 将instance 引用指向这块内存
编译器/CPU可能重排为:1->3->2,此时另一个线程判断instance != null,但是返回来的对象尚未执行构造器,使用时会出错。
synchronized 如何保证有序性?
synchronized本质上通过内存屏障来禁止重排序。
当JVM遇到synchronized块时,会在临界区的入口和出口插入特定类型的内存屏障(如 StoreStore、StoreLoad、LoadLoad),其中最关键的StoreLoad屏障会:
- 禁止屏障上方的写造作与下方的读操作重排
- 强制将写缓冲区的数据刷到缓存/主内存
| 屏障指令 | 作用(防止重排序) | 典型场景 |
|---|---|---|
| StoreStore | 屏障前的所有写操作(Store)先于屏障后的写操作执行(写-写有序) | 保证写操作按顺序提交到主存,例如普通变量写后跟随 volatile 写 |
| LoadLoad | 屏障前的所有读操作(Load)先于屏障后的读操作执行(读-读有序) | 保证读操作看到的数据版本一致,例如 volatile 读后跟随普通读 |
| StoreLoad | 屏障前的所有写操作先于 屏障后的读操作执行,且写结果对所有处理器可见(写-读有序,最重量级) | 实现完全的顺序一致性,例如 volatile 写后立即读另一个 volatile 变量 |
-
StoreStore:写-写屏障,保证写入顺序。
-
LoadLoad:读-读屏障,保证读取顺序。
-
StoreLoad:写-读屏障,最严格的顺序保证 + 可见性保证,开销大。
效果:临界区的代码不会被重排序放到临界区外,临界区外的代码也不会被重排序到临界区内。
java
synchronized(lock) {
a = 1;
b = 2;
}
这段代码中,JVM保证:在释放锁之前,a=1一定在b=2之前对外部线程可见,不会出现b=2先写的情况。
synchronized通过内存屏障机制禁止指令重排序,从而保证了临界区代码执行的有序性。
三大特性总结
synchronized 做了三件事:
-
加锁(Monitor互斥) → 保证原子性
-
内存同步(缓存刷新/失效) → 保证可见性
-
内存屏障(禁止重排) → 保证有序性
synchronized本质是通过对象头中的 Monitor 实现互斥锁,结合 JMM 的 happens-before 规则(unlock → lock)以及内存屏障机制,共同保证了原子性、可见性和有序性。它既是互斥锁,也是同步屏障。
2. synchronized的使用方式
synchronized 可以用在啊三个地方:修饰实例方法、修饰静态方法、修饰代码块。不同的用法对应不同的锁对象
2.1 修饰实例方法
语法:
java
public synchronized void method() {
// 方法体
}
锁对象:当前实例对象,也就是this。
效果:同一时刻,同一个实例对象的多个线程只能有一个进入该方法。不同实例之间互不影响,因为是不同的对象。
java
public class Counter {
private int count = 0;
public synchronized void increment() {
count++;
}
public synchronized int getCount() {
return count;
}
}
等价于使用 synchronized 代码块包裹整个方法体:
java
public void increment() {
synchronized(this) {
count++;
}
}
多个线程同时调用的情况:
java
public class Counter {
private int count = 0;
public synchronized void increment() {
count++;
}
public synchronized int getCount() {
return count;
}
// 测试多线程并发
public static void main(String[] args) throws InterruptedException {
final Counter counter = new Counter();
final int threadCount = 1000; // 线程数量
final int incrementsPerThread = 100; // 每个线程增加次数
Thread[] threads = new Thread[threadCount];
// 启动所有线程
for (int i = 0; i < threadCount; i++) {
threads[i] = new Thread(() -> {
for (int j = 0; j < incrementsPerThread; j++) {
counter.increment();
}
});
threads[i].start();
}
// 等待所有线程结束
for (Thread t : threads) {
t.join();
}
// 预期结果:threadCount * incrementsPerThread
int expected = threadCount * incrementsPerThread;
int actual = counter.getCount();
System.out.println("Expected count: " + expected);
System.out.println("Actual count : " + actual);
System.out.println("Correct? " + (expected == actual));
}
}
-
同一个
Counter实例上的increment()和getCount()都使用了synchronized实例方法。 -
这意味着这两个方法都锁定
this(即当前Counter对象)。同一时刻,只有一个线程 能进入increment()或getCount()。 -
因此
count++操作(读-改-写)成为原子操作,不会出现多个线程交错执行导致的更新丢失。 -
synchronized保证了进入同步块时从主内存重新读取变量,退出同步块时将修改刷新回主内存。 -
所以一个线程对
count的修改对其他线程立即可见。
但是如果去掉synchronized关键字的话会导致count++变为非原子操作~~
还可以增加一个演示,让部分线程调用 increment(),部分线程调用 getCount(),验证互斥依然生效:
java
// 10个写线程 + 10个读线程
for (int i = 0; i < 10; i++) {
new Thread(() -> {
for (int j = 0; j < 1000; j++) counter.increment();
}).start();
new Thread(() -> {
for (int j = 0; j < 1000; j++) counter.getCount(); // 读操作也会阻塞写操作
}).start();
}
由于读方法也加了 synchronized,读线程和写线程不能同时执行,这可能会降低并发吞吐量,但保证了强一致性 (每次读取到的都是最新值)。如果读操作允许脏读,可以只对写方法加锁,读方法不加锁(但需保证可见性,例如使用 volatile)。
| 场景 | 是否有锁 | 结果正确性 | 并发性能 |
|---|---|---|---|
两个方法都加 synchronized |
是 | 总是正确 | 较低(读写互斥) |
只有 increment 加锁 |
部分 | 可能出现脏读 | 较高(读并发) |
| 都不加锁 | 无 | 数据竞争,结果错误 | 最高但不安全 |
2.2 修饰静态方法
语法:
java
public static synchronized void staticMethod() {
// 方法体
}
锁对象:当前类的Class对象,即ClassName.class。
效果:同一时刻,整个应用程序中所有线程只能有一个进入该静态方法。因为所有实例共享同一个Class对象。
java
public class SharedResource {
private static int sharedCount = 0;
public static synchronized void incrementShared() {
sharedCount++;
}
public static synchronized int getSharedCount() {
return sharedCount;
}
}
这个代码也等价于:
java
public static void incrementShared() {
synchronized(SharedResource.class) {
sharedCount++;
}
}
在这个代码中,对于static synchronized方法,锁对象是当前类的Class对象,即SharedResource.class。
- 每个对象在JVM中都有且仅有一个Class对象(类加载器生成)
- 当线程执行incrementShared()时,必须获得SharedResource.class 这把锁
并且在这个程序案例中,是有两个方法公用一把锁的(incrementShared() 和getSharedCount()都用 static synchronized 修饰)因此它们是共用同一把锁的。
这样做的话,当线程A执行incrementShared() 时,线程B无法执行getShared(),因为两者需要同一把锁~ 这就保证了读写互斥,读操作也能看到最新的值,不会读到中间状态。
java
// 线程1
SharedResource.incrementShared(); // 持有类锁
// 线程2
int v = SharedResource.getSharedCount(); // 必须等待线程1释放锁
与实例同步方法对比:
| 特性 | 静态同步方法 | 实例同步方法 |
|---|---|---|
| 锁对象 | 类的 Class 对象 | 当前实例 this |
| 保护的数据 | 静态成员变量 | 实例成员变量 |
| 多个实例是否互斥 | 是(所有实例共享同一个类锁) | 否(不同实例锁不同) |
| 典型使用场景 | 计数器、全局配置、单例模式等 | 普通对象的线程安全方法 |
java
SharedResource obj1 = new SharedResource();
SharedResource obj2 = new SharedResource();
// 静态同步方法:obj1 和 obj2 调用时会互斥(因为锁是 SharedResource.class)
obj1.incrementShared(); // 持有类锁
obj2.incrementShared(); // 阻塞,直到上一个释放
// 实例同步方法(假设有):obj1 和 obj2 调用时不会互斥(锁分别是 obj1 和 obj2)
并且,synchronized是可重入锁,同一个线程可以重复进入用一个锁对象保护的任何同步方法。
java
public static synchronized void methodA() {
methodB(); // 允许,因为线程已经持有 SharedResource.class 锁
}
public static synchronized void methodB() {
// ...
}
原理:Monitor 记录持有锁的线程ID和重入次数,每次进入加1,退出减1,只有减到0才是真正的释放锁。
根据JMM规则:unlock 操作 happens-before 后续对同一锁的 lock 操作如下:
- 线程A执行 incrementShared() 释放锁之前,对 sharedCount 的修改会刷新岛主内存中
- 线程B执行 getSharedCount() 获取锁时,会使本地缓存失效,强制从主内存中读取最新值
因此可以不需要额外使用volatile 修饰 sharedCount (当然加上也可以~)
不过也有部分潜在的问题:
-
两个方法使用同一把锁的情况下,读操作也会阻塞写操作,如果读操作非常频繁,会降低并发性能。可以使用 ReentrantReadWriteLock ,允许多个读线程并发,写线程互斥。
-
由于SharedResource.class 是公开的,任何凄然代码都可以通过synchronized(SharedResource.class)获取这把锁,可能会导致外部死锁或意外阻塞。
java// 外部恶意或失误代码 synchronized (SharedResource.class) { Thread.sleep(10000); // 阻塞所有使用 SharedResource 静态同步方法的线程 }可以通过使用内部私有锁对象,但静态方法无法直接使用实例锁对象,也可以改为静态内部类持有锁:
javapublic class SharedResource { private static int sharedCount = 0; private static final Object LOCK = new Object(); // 私有锁,外部无法获取 public static void incrementShared() { synchronized (LOCK) { sharedCount++; } } }这样既保证互斥,又避免了锁风险。
这里再给出一个代码案例:
java
public class Test {
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for (int i = 0; i < 1000; i++) {
SharedResource.incrementShared();
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 1000; i++) {
SharedResource.incrementShared();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(SharedResource.getSharedCount()); // 始终输出 2000
}
}
这个代码案例在没有同步的情况下,结果会小于2000,但是在使用静态方法同步之后结果就正常了~
| 核心点 | 说明 |
|---|---|
| 锁对象 | SharedResource.class,全局唯一 |
| 保护的数据 | 静态变量 sharedCount |
| 互斥范围 | 所有线程,所有实例,所有静态同步方法之间互斥 |
| 可重入 | 支持,同一线程可再次进入该类其他静态同步方法 |
| 可见性 | 由 happens-before 规则保证,释放锁强制刷新,获取锁强制重新读取 |
| 缺点 | 粒度粗(读写互斥),锁对象暴露 |
| 改进建议 | 读多写少时改用 ReadWriteLock;避免锁暴露使用 private static final 锁对象 |
2.3 修饰代码块
synchronized 修饰代码块是这些方法中最灵活的~
语法:
java
synchronized(lockObject) {
// 临界区代码
}
锁对象:可以是任何Java对象 (推荐使用 private final 的专用锁对象)。
只有在lockObjec 锁的线程才能进入代码块,多个线程使用同一个lockObject 时才会互斥,使用不同的 lockObject 则并发执行。
比如:
java
public class DataHolder {
private final Object lock = new Object(); // 专用锁对象
private int value = 0;
public void setValue(int v) {
synchronized(lock) {
value = v;
}
}
public int getValue() {
synchronized(lock) {
return value;
}
}
}
推荐使用 private final 修饰的专用锁对象有以下两点原因:
- 防止外部线程意外持有锁导致死锁或者性能问题
- final 能确保锁对象引用不变,避免锁对象被意外替换造成并发混乱
锁对象的选择:
| 使用方式 | 锁对象 | 适用场景 |
|---|---|---|
| 修饰实例方法 | this(当前实例) |
保护实例级别的成员变量 |
| 修饰静态方法 | Class 对象 | 保护静态成员变量 |
| 修饰代码块 | 自定义对象 | 粒度更细,性能更好 |
-
不要使用
String常量或Integer等可能被 JVM 缓存的对象作为锁,因为不同地方的相同字面量可能指向同一个对象,意外造成不必要的互斥。 -
不要使用可变的字段作为锁对象,否则锁对象被修改后,不同的线程看到的锁对象不一致,互斥失效。
推荐:
java
private final Object lock = new Object();
2.4 可重入性
synchronized 是一个可重入锁,即同一个线程已经拥有某个对象的锁,可以再次进入该对象上的其他synchronized 方法/块,而不会阻塞自己
java
public synchronized void methodA() {
methodB(); // 同一线程可以进入,因为已经持有锁
}
public synchronized void methodB() {
// ...
}
原理: Monitor 内部会记录 _owner 线程和 _recursions(重入次数)。每次重入计数加 1,每次退出减 1,计数为 0 时释放锁。
小结
-
不可中断 :如果一个线程等待锁,无法被中断(不像
Lock可以tryLock)。等待过程中线程一直处于BLOCKED状态。 -
非公平锁:等待锁的线程不会按先来后到获得锁,而是由操作系统调度决定。
-
锁升级(JVM 优化) :在 JDK 1.6 之后,
synchronized不再是单纯的重量级锁,会经历"无锁 → 偏向锁 → 轻量级锁 → 重量级锁"的升级过程,以降低加锁开销。
代码示例(synchronized 实现线程安全的计数器)
java
public class SafeCounter {
private int count = 0;
private final Object lock = new Object();
// 方式一:同步代码块
public void increment() {
synchronized(lock) {
count++;
}
}
// 方式二:同步实例方法(锁是 this)
public synchronized int getCount() {
return count;
}
// 方式三:同步静态方法(锁是 SafeCounter.class)
private static int staticCount = 0;
public static synchronized void incrementStatic() {
staticCount++;
}
}
总结:什么时候用哪种 synchronized?
-
保护实例变量且整个方法都需要同步 → 实例方法
-
保护静态变量 → 静态方法
-
只需要同步方法中的一小段代码 → 代码块 + 专用锁对象(性能更好)
-
多个方法需要共享同一个锁对象(但不是整个 this) → 代码块 + 自定义锁对象
通过合理选择 synchronized 的使用方式,可以在保证线程安全的同时,尽可能提升并发性能。
3. 锁策略
在 JDK 1.6 之前,synchronized 是一个纯粹的重量级锁 ,依赖于操作系统的互斥量(mutex),线程阻塞和唤醒需要用户态到内核态的切换,开销很大。从 JDK 1.6 开始,JVM 对 synchronized 进行了大量优化,引入了锁升级 机制和多种锁策略,使得 synchronized 的性能在不竞争或轻度竞争的场景下接近无锁。
3.1 锁升级过程(无锁 → 偏向锁 → 轻量级锁 → 重量级锁)
每个 Java 对象的对象头中有一个 Mark Word,其中记录了锁的状态。锁的升级是单向的,只能由低级别向高级别膨胀,不能降级。
(1)无锁状态
对象刚创建时,没有线程竞争锁,Mark Word 处于无锁状态。
(2)偏向锁
核心思想: 如果一个线程多次获取同一个锁,那么锁会偏向于这个线程,消除该线程后续重入时的同步开销。
工作方式:
当线程第一次获取锁时,JVM 将 Mark Word 中的线程 ID 设置为当前线程,并将锁标志位设置为"偏向锁"。之后该线程再次进入同步块时,只需比较 Mark Word 中的线程 ID 是否是自己,如果是则直接进入,无需任何 CAS 操作或互斥。
偏向锁的撤销:
当其他线程尝试竞争该锁时,偏向锁需要撤销。撤销操作需要在全局安全点(所有线程停止执行)进行,开销较大。因此,如果应用程序中大部分锁始终由同一个线程持有,偏向锁能显著提升性能;如果锁竞争激烈,偏向锁的频繁撤销反而会降低性能。JVM 参数可以关闭偏向锁:-XX:-UseBiasedLocking。
(3)轻量级锁
核心思想: 当偏向锁被撤销,或者没有开启偏向锁,且当前没有其他线程竞争锁时,JVM 会使用轻量级锁。轻量级锁通过 CAS(Compare And Swap) 操作在用户态尝试获取锁,避免进入内核态。
工作方式:
线程在执行同步块之前,会在自己的栈帧中创建一个锁记录(Lock Record),并将对象头的 Mark Word 复制到锁记录中(称为 Displaced Mark Word)。然后通过 CAS 尝试将对象头的 Mark Word 替换为指向锁记录的指针。如果成功,该线程获得轻量级锁;如果失败,说明有其他线程竞争,轻量级锁会自旋(见下文)一定次数,若仍无法获得锁,则膨胀为重量级锁。
(4)重量级锁
核心思想: 当锁竞争激烈,自旋等待超过一定次数(或自旋线程数超过 CPU 核数的一半)时,轻量级锁会升级为重量级锁。此时线程阻塞,通过操作系统的互斥量实现,不再使用 CAS。
工作方式:
重量级锁对应的就是之前提到的 Monitor 对象,_owner 记录持有锁的线程,其他线程进入 _EntryList 阻塞队列,等待被唤醒。重量级锁虽然开销大,但能保证在严重竞争下不会浪费 CPU 资源。
2. 自旋锁(Spin Lock)
为什么需要自旋锁?
当线程无法获得轻量级锁时,如果直接阻塞线程,需要用户态到内核态的切换,开销很大。而很多时候,持有锁的线程很快就会释放锁。因此,JVM 会让等待的线程自旋(执行一段空循环),循环等待锁释放。如果自旋期间锁被释放,线程就能立刻获得锁,避免了线程阻塞和唤醒的开销。
自旋的缺点: 如果锁被长时间占用,自旋会白白消耗 CPU 资源。因此 JVM 会自适应调整自旋次数(自适应自旋),例如根据上一次自旋是否成功来决定下一次自旋的时间。
自适应自旋:
如果一次自旋成功,JVM 认为这次自旋也可能成功,会适当增加自旋次数;反之,如果经常自旋失败,则会减少自旋次数甚至直接升级为重量级锁。
3. 锁消除(Lock Elimination)
核心思想: JVM 通过逃逸分析 (Escape Analysis)判断某个锁对象是否只被一个线程访问(即不会逃逸出当前线程)。如果确定不会逃逸,JVM 会消除这个锁,因为不存在竞争。
示例:
java
public void appendString(String s1, String s2) {
StringBuffer sb = new StringBuffer(); // StringBuffer 的方法是同步的
sb.append(s1);
sb.append(s2);
}
StringBuffer 的 append 方法使用了 synchronized,但 sb 对象只在方法内部使用,不会被其他线程访问。JVM 在运行时会将这两个同步操作优化掉,直接变成普通的方法调用,提高性能。
4. 锁粗化(Lock Coarsening)
核心思想: 如果 JVM 检测到同一个线程连续多次对同一个对象进行加锁解锁操作,且中间没有其他线程竞争,JVM 会将这些锁操作合并为一个更大的同步块,减少锁的获取和释放次数。
示例:
java
public void test() {
synchronized(lock) {
// do something
}
synchronized(lock) {
// do something else
}
}
JVM 可能将其粗化为:
java
public void test() {
synchronized(lock) {
// do something
// do something else
}
}
锁粗化减少了多次进入/退出同步块的开销。
5. 锁策略总结对比
| 锁类型 | 实现方式 | 适用场景 | 开销 |
|---|---|---|---|
| 偏向锁 | 对象头记录线程 ID | 单线程反复获取同一锁 | 几乎为零 |
| 轻量级锁 | CAS + 自旋 | 轻度竞争,锁持有时间短 | 较低(用户态) |
| 重量级锁 | 操作系统互斥量 | 激烈竞争,锁持有时间长 | 高(内核态) |
| 自旋锁 | 忙循环等待 | 配合轻量级锁,锁很快释放 | 消耗 CPU |
| 锁消除 | 逃逸分析后删除锁 | 锁对象不逃逸 | 无开销 |
| 锁粗化 | 合并连续的同步块 | 同一线程反复加锁解锁 | 减少加锁次数 |
6. 总结
JDK 1.6 之后,
synchronized不再是一成不变的重量级锁,而是通过锁升级 (偏向锁 → 轻量级锁 → 重量级锁)、自旋锁 、锁消除 、锁粗化 等一系列优化策略,在不改变语义的前提下大幅提升了并发性能。理解这些策略有助于我们写出更高效的并发代码,同时也说明了为什么在轻度竞争下synchronized的性能常常优于显式的ReentrantLock。
以上就是本篇全部内容啦~咱们下期再见~~