菜单

Administrator
发布于 2026-05-18 / 1 阅读
0
0

Java 的 synchronized 关键字

Java 的 synchronized 关键字 —— 从用法到底层原理全面解析

一、引言

synchronized 是 Java 中最基础、最常用的线程同步关键字。它从 JDK 1.0 时代就已存在,经过 JDK 6 的大规模优化后,性能已经大幅提升,在很多场景下甚至优于 java.util.concurrent.locks.Lock。本文将全面讲解 synchronized 的用法、底层实现、锁升级机制以及相关优化。


二、基本用法

synchronized 有三种使用形式,分别对应不同的锁对象。

1. 修饰实例方法

synchronized 修饰实例方法时,锁的是当前实例对象(this)。

public class Counter {
    private int count = 0;

    public synchronized void increment() {
        count++;
    }

    public synchronized int getCount() {
        return count;
    }
}

同一时刻,同一个 Counter 实例的 increment()getCount() 只能有一个线程执行。不同实例之间互不影响。

2. 修饰静态方法

synchronized 修饰静态方法时,锁的是当前类的 Class 对象。

public class StaticCounter {
    private static int count = 0;

    public static synchronized void increment() {
        count++;
    }

    public static synchronized int getCount() {
        return count;
    }
}

静态方法的锁与实例方法的锁不是同一把锁,它们互不干扰。静态方法锁的是 StaticCounter.class,实例方法锁的是 this

3. 修饰代码块

synchronized 代码块可以指定任意对象作为锁,粒度更灵活。

public class BlockCounter {
    private int count = 0;
    private final Object lock = new Object();

    public void increment() {
        // 只同步需要保护的代码段
        synchronized (lock) {
            count++;
        }
    }

    public void doubleIncrement() {
        synchronized (lock) {
            count++;
            count++;
        }
    }
}

代码块形式的优势:

  • 缩小锁范围:只保护临界区,提升并发性能
  • 灵活指定锁对象:可以针对不同数据使用不同锁

💡 最佳实践synchronized 代码块优于修饰方法,因为锁的粒度更细。同时,锁对象建议声明为 private final,避免外部引用修改。


三、可重入性(Reentrancy)

synchronized可重入锁(Reentrant Lock)。同一个线程在持有锁的情况下,可以再次获取同一把锁。

public class ReentrantExample {

    public synchronized void outer() {
        System.out.println("outer");
        inner(); // 同一个线程直接进入
    }

    public synchronized void inner() {
        System.out.println("inner");
    }

    public static void main(String[] args) {
        new ReentrantExample().outer();
    }
}

输出:

outer
inner

可重入的实现原理:每个锁关联一个持有线程和一个计数器。当同一个线程再次请求锁时,计数器 +1;退出同步块时计数器 -1;当计数器归零时,锁才真正释放。

可重入避免了死锁的经典场景——同一线程在已持锁的情况下调用自身或其他同步方法。


四、底层实现原理

1. JVM 层面的字节码指令

通过 javap -v 反编译 synchronized 代码块,可以看到 JVM 使用 monitorentermonitorexit 两条指令。

// 源代码
public void demo() {
    synchronized (this) {
        System.out.println("hello");
    }
}

反编译结果:

public void demo();
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=3, args_size=1
         0: aload_0
         1: dup
         2: astore_1
         3: monitorenter          // 获取锁
         4: getstatic     #2     // System.out
         7: ldc           #3     // "hello"
         9: invokevirtual #4     // println
        12: aload_1
        13: monitorexit           // 释放锁
        14: goto          22
        17: astore_2
        18: aload_1
        19: monitorexit           // 异常路径下也保证释放锁
        20: aload_2
        21: athrow
        22: return
      Exception table:
         from    to  target type
             4    14    17   any

关键点:

  • monitorenter:尝试获取对象头的 Monitor(监视器)所有权
  • monitorexit:释放 Monitor
  • 异常处理:编译器会自动生成异常表,确保无论是否抛出异常,锁都会被释放

对于 synchronized 方法,字节码层面没有 monitorenter/monitorexit,而是在方法的 ACC_SYNCHRONIZED 标志位上标记。JVM 根据该标志隐式完成加锁/解锁。

2. 对象头(Object Header)与 Mark Word

Java 对象在堆内存中的布局包含三部分:

  • 对象头(Header)
  • 实例数据(Instance Data)
  • 对齐填充(Padding)

对象头中的 Mark Word 是锁实现的核心。在 64 位 JVM 中,Mark Word 占 8 字节(64位),其结构随锁状态变化:

锁状态 56 bit 1 bit 4 bit 1 bit (biased) 2 bit (lock)
无锁 哈希码 + 分代年龄 0 0 0 01
偏向锁 线程ID + 偏向时间戳 + 分代年龄 0 0 1 01
轻量级锁 指向栈中锁记录的指针 0 00
重量级锁 指向互斥量(Monitor)的指针 0 10
GC 标记 11

3. Monitor 机制

每个 Java 对象都有一个关联的 Monitor(监视器)。在 HotSpot 虚拟机中,Monitor 由 ObjectMonitor 类实现(C++),核心数据结构:

ObjectMonitor {
    _header       // 对象头
    _count        // 锁计数器
    _owner        // 持有锁的线程
    _WaitSet      // 执行 wait() 的线程队列
    _EntryList    // 等待获取锁的线程队列(阻塞状态)
    _Recursions   // 重入次数
}

工作流程:

  1. 线程执行 monitorenter 时,尝试将 _owner 设为当前线程
  2. _owner == null,获取成功,_count = 1
  3. _owner == 当前线程_count++(可重入)
  4. _owner == 其他线程,线程进入 _EntryList 阻塞等待
  5. 执行 monitorexit 时,_count--,减到 0 时释放锁,唤醒 _EntryList 中的线程

五、锁升级(锁膨胀)

JDK 6 之前,synchronized重量级锁(直接依赖操作系统的 Mutex Lock),性能较差。JDK 6 引入了锁升级机制,让锁可以根据竞争激烈程度动态演化。

偏向锁 → 轻量级锁 → 重量级锁(不可逆,只能升级不能降级)

注意:JDK 15 之后,偏向锁被默认禁用并在 JDK 21 中被正式移除。但理解其设计思路对理解锁优化仍有帮助。

1. 偏向锁(Biased Locking)

设计目标:消除线程无竞争时的同步开销。

思想:当一个线程多次获取同一把锁时,该锁"偏向"于该线程,后续无需 CAS 操作即可加锁/解锁。

工作流程

  1. 线程首次获取锁时,CAS 将 Mark Word 中的线程 ID 设为自己
  2. 之后该线程再次进入同步块时,检查线程 ID 是否匹配,匹配则直接进入(无 CAS 开销)
  3. 其他线程尝试获取该锁时,偏向锁被撤销(Revoke),升级为轻量级锁

偏向锁撤销

  • 等待全局安全点(Safe Point)
  • 暂停拥有偏向锁的线程
  • 判断线程是否存活:若已死亡,将对象头恢复为无锁状态;若存活,升级为轻量级锁
  • 恢复线程

2. 轻量级锁(Lightweight Locking)

设计目标:线程交替执行同步块,不存在真正竞争时,用 CAS 代替互斥量。

工作流程

  1. 线程在执行同步块前,JVM 在当前线程的栈帧中创建一块空间(Lock Record),用于存储对象当前的 Mark Word 副本(Displaced Mark Word)
  2. 线程通过 CAS 尝试将对象头的 Mark Word 替换为指向 Lock Record 的指针
  3. CAS 成功 → 获得锁
  4. CAS 失败 → 检查 Mark Word 是否指向当前线程的栈帧
    • 是 → 重入
    • 否 → 存在竞争,锁膨胀为重量级锁

解锁:通过 CAS 将 Displaced Mark Word 替换回对象头。若失败,说明锁已膨胀,需要释放重量级锁。

3. 重量级锁(Heavyweight Locking)

当竞争加剧时,轻量级锁会膨胀为重量级锁。重量级锁依赖操作系统的 Mutex Lock 实现,线程获取锁失败时会进入阻塞状态,涉及用户态 → 内核态切换,开销最大。

4. 锁升级流程图

                  线程尝试获取锁
                        │
                        ▼
          ┌─── 偏向锁是否可用? ───┐
          │                      │
          ▼                      ▼
         是                     否
          │                      │
          ▼                      ▼
  检查线程 ID 是否一致   轻量级锁尝试 CAS
          │                      │
     ┌────┴────┐            ┌────┴────┐
     │ 匹配 │ 不匹配       │ 成功 │ 失败
     └────┬────┘            └────┬────┘
          │                      │
          ▼                      ▼
     获取成功             撤销偏向锁     CAS 重试(自旋)
          │              ┌───────┘
          │              │
          │              ▼
          │        尝试轻量级锁 CAS
          │              │
          │         ┌────┴────┐
          │         │ 成功 │ 失败
          │         └────┬────┘
          │              │
          │              ▼
          │         获取成功   膨胀为重量级锁
          │                      │
          │                      ▼
          │                线程阻塞(内核态)
          │                      │
          └──────────────────────┘

六、JDK 6+ 优化详解

JDK 6 对 synchronized 进行了里程碑式的优化,引入了多项技术。

1. 自旋锁(Spin Lock)

背景:线程阻塞唤醒涉及内核态切换,如果锁持有时间很短,让线程"原地等待"反而更高效。

原理:线程获取锁失败时,不立即阻塞,而是执行一段忙循环(自旋),尝试获取锁。

自适应自旋(Adaptive Spinning):JDK 6 优化为自适应自旋。JVM 根据前一次自旋等待时间、锁持有者状态动态调整自旋次数。如果上次自旋成功获取了锁,这次自旋次数会适度增加;如果很少成功,则减少甚至不自旋。

2. 锁消除(Lock Elimination)

JVM 通过**逃逸分析(Escape Analysis)**判断对象是否只在当前线程内使用。如果确定不会逃逸,JVM 会将 synchronized 代码块完全消除。

// 示例
public String concat(String a, String b) {
    // StringBuffer 是线程安全的,但这里只在方法内使用
    // JVM 可能会消除 synchronized 块
    StringBuffer sb = new StringBuffer();
    sb.append(a);
    sb.append(b);
    return sb.toString();
}

3. 锁粗化(Lock Coarsening)

如果 JVM 检测到一系列连续的加锁/解锁操作都针对同一对象,会将多个锁合并为一个更大范围的锁。

// 优化前
for (int i = 0; i < 100; i++) {
    synchronized (lock) {
        // 操作
    }
}

// JVM 粗化为
synchronized (lock) {
    for (int i = 0; i < 100; i++) {
        // 操作
    }
}

七、synchronized vs Lock

Java 1.5 引入了 java.util.concurrent.locks.Lock 接口(典型实现为 ReentrantLock)。两者对比如下:

对比维度 synchronized Lock
关键字/接口 Java 关键字,JVM 内置 接口,基于 AQS 实现
锁获取/释放 自动,由 JVM 保证释放 手动,lock()/unlock(),必须在 finally 中释放
可中断 ❌ 不可中断 lockInterruptibly() 可响应中断
超时等待 ❌ 不支持 tryLock(timeout, unit)
公平性 非公平 支持公平/非公平两模式
读写分离 ReentrantReadWriteLock / StampedLock
条件等待 Object.wait()/notify() Condition.await()/signal(),支持多个条件队列
性能(低竞争) 极佳(偏向锁) 一般
性能(高竞争) 一般(重量级锁) 良好(自定义队列)
调试/诊断 困难,jstack 可见 较好,可配合 tryLock() 排查

选择建议

  • 优先使用 synchronized 除非你需要以下特性:

    • 可中断锁
    • 超时等待
    • 公平锁
    • 读写锁
    • 多个条件队列
  • 使用 Lock 的场景

    • 高并发且需要精细控制
    • 需要非阻塞尝试获取锁
    • 读写分离场景

代码对比

// synchronized 版本
public class SynchronizedStack {
    private final List<String> list = new ArrayList<>();

    public synchronized void push(String s) {
        list.add(s);
    }

    public synchronized String pop() {
        if (list.isEmpty()) return null;
        return list.remove(list.size() - 1);
    }
}

// Lock 版本
public class LockStack {
    private final List<String> list = new ArrayList<>();
    private final Lock lock = new ReentrantLock();

    public void push(String s) {
        lock.lock();
        try {
            list.add(s);
        } finally {
            lock.unlock();
        }
    }

    public String pop() {
        lock.lock();
        try {
            return list.isEmpty() ? null : list.remove(list.size() - 1);
        } finally {
            lock.unlock();
        }
    }
}

八、常见问题与注意事项

1. 锁对象不能为 null

// ❌ 错误
private final Object lock = null;
synchronized (lock) { ... }  // NullPointerException

2. 不要用 String 常量作为锁

// ❌ 危险:字符串常量会被 intern
private final String LOCK = "LOCK";
// 其他类也可能用这个字符串常量,导致意外锁竞争

3. 锁对象的可见性

锁对象应声明为 final,避免引用被修改后不同线程看到不同对象。

// ✅ 正确
private final Object lock = new Object();

// ❌ 错误
private Object lock = new Object();
public void resetLock() {
    lock = new Object(); // 其他线程可能还在用旧锁
}

4. 不要锁住包装类型

// ❌ 危险:Integer 缓存池
private Integer count = 0;
public void increment() {
    synchronized (count) {  // -128~127 之间使用缓存对象
        count++;
    }
}

九、总结

特性 说明
本质 JVM 层面的互斥同步原语
核心实现 基于对象头的 Mark Word + Monitor
锁升级 偏向锁 → 轻量级锁 → 重量级锁(仅单向)
可重入 同一线程可重复获取同一把锁
悲观锁 假设一定会发生冲突,阻塞其他线程
JDK 6 优化 自旋锁、自适应自旋、锁消除、锁粗化、锁升级
适用场景 低竞争 -> 极佳;高竞争 -> 可用但需考虑 Lock

synchronized 是 Java 并发编程的基石,理解它的原理对于写出正确的并发代码至关重要。从简单的用法到底层的锁升级机制,从 JVM 优化到与 Lock 的对比,掌握这些知识能帮助我们在实际开发中做出更优的选择。


参考资源

  • 《深入理解 Java 虚拟机》—— 周志明
  • 《Java 并发编程的艺术》—— 方腾飞
  • OpenJDK HotSpot 源码
  • Java Language Specification (JLS §17.1)

评论