4. synchronized关键字:用法与锁的底层实现

说到Java并发编程,synchronized绝对是个绕不开的话题。我记得刚入行那会儿,总觉得它就是个简单的加锁工具,用起来也没什么技术含量。直到后来在线上遇到一次诡异的性能抖动,排查了整整两天,才发现是对synchronized的理解不够深。嗯,今天我们就把它彻底讲透。

4.1 synchronized的三种用法

先说说最基础的东西。synchronized可以用在三个地方:实例方法、静态方法、代码块。每种用法的锁对象都不一样,这个必须搞清楚。

4.1.1 修饰实例方法

synchronized加在实例方法上时,锁的是当前对象实例(也就是this)。

public class Counter {
    private int count = 0;

    // 锁的是当前Counter实例
    public synchronized void increment() {
        count++;
    }

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

这里有个坑,我踩过。如果你有两个不同的Counter实例,它们各自调用increment()是不会互斥的。为什么?因为锁的是对象本身,不是类。说白了,每个对象都有自己的锁。

4.1.2 修饰静态方法

静态方法就不一样了。锁的是Class对象,也就是整个类级别的锁。

public class Counter {
    private static int totalCount = 0;

    // 锁的是Counter.class
    public static synchronized void addTotal() {
        totalCount++;
    }
}

我曾经在项目中见过一个案例:有人把实例方法和静态方法混着用,以为它们互斥。结果呢?线上数据对不上了。因为实例方法锁的是对象,静态方法锁的是类,它们根本就不是同一把锁。

4.1.3 修饰代码块

代码块是最灵活的用法。你可以指定任意对象作为锁,粒度更细。

public class SafeList {
    private final Object lock = new Object();
    private List<String> list = new ArrayList<>();

    public void add(String item) {
        synchronized (lock) {
            list.add(item);
        }
    }

    public String get(int index) {
        synchronized (lock) {
            return list.get(index);
        }
    }
}

我个人习惯用专门的锁对象,而不是直接用this。为什么?因为this可能被外部代码拿去当锁,容易造成死锁或者意外的阻塞。你想想看,别人拿着你的对象锁,你这边还傻等着呢。

小技巧:锁对象的范围越小越好。能用代码块就别用方法,能用局部锁就别用全局锁。这是提升并发性能的基本原则。

4.2 锁的底层实现:Monitor

好,用法说完了。接下来我们深入底层看看。synchronized到底是怎么实现的?

每个Java对象在内存里都有一个对象头(Object Header)。对象头里存着一些元数据,其中就包括锁相关的信息。这个锁机制的核心,就是Monitor(监视器锁)。

在HotSpot虚拟机里,每个对象都可以关联一个Monitor。当线程要进入synchronized代码块时,它必须先获取这个Monitor的所有权。获取不到?那就得等着,进入一个叫EntryList的队列里排队。

我简单画个流程:

  1. 线程尝试获取Monitor的所有权
  2. 如果Monitor没有被占用,线程直接进入
  3. 如果被占用了,线程进入EntryList等待
  4. 持有锁的线程执行完,释放Monitor
  5. EntryList里的线程被唤醒,重新竞争

这里有个细节:wait()notify()也是基于Monitor实现的。调用wait()的线程会进入WaitSet,而不是EntryList。这两个队列是不同的,千万别搞混。

注意:Monitor的实现依赖于操作系统的Mutex Lock(互斥锁)。这意味着线程的阻塞和唤醒会涉及用户态和内核态的切换,开销很大。这就是为什么早期的synchronized被称为「重量级锁」。

4.3 锁升级过程

说到重量级锁,就不得不提Java 6之后做的优化——锁升级。说白了,就是JVM根据竞争情况,动态调整锁的「重量」。

整个过程分为四个阶段:

锁状态 特点 适用场景
无锁 没有线程竞争 初始状态
偏向锁 只有一个线程反复获取 单线程访问
轻量级锁 少量线程交替获取 低竞争场景
重量级锁 多线程激烈竞争 高竞争场景

4.3.1 偏向锁

偏向锁的想法很聪明:如果一个线程反复获取同一把锁,那何必每次都做昂贵的同步操作呢?干脆把锁「偏向」给这个线程。

怎么实现的?在对象头里记录线程ID。下次这个线程再来,直接比对ID,一样的话就放行,连CAS操作都省了。

不过偏向锁有个问题:一旦有其他线程来竞争,它就得撤销。撤销过程需要等待全局安全点(SafePoint),这个开销其实不小。

我的经验:在高并发场景下,偏向锁的撤销反而会成为性能瓶颈。我曾经优化过一个系统,关闭偏向锁(-XX:-UseBiasedLocking)后,吞吐量提升了15%。所以别迷信偏向锁,它不一定适合所有场景。

4.3.2 轻量级锁

当偏向锁被撤销后,锁会升级为轻量级锁。轻量级锁的核心是CAS自旋

线程不会立即阻塞,而是通过CAS操作尝试获取锁。如果成功了,那就皆大欢喜。如果失败了,线程会自旋一会儿(空转CPU),再试一次。

自旋的好处是避免了线程阻塞带来的上下文切换。但坏处也很明显:自旋会消耗CPU。如果锁被占用的时间很长,自旋就是白费力气。

// 轻量级锁的CAS操作(伪代码)
if (compareAndSwap(&lock, 0, 1)) {
    // 获取锁成功
} else {
    // 自旋等待
    spin();
}

4.3.3 重量级锁

如果自旋也搞不定,锁就会升级为重量级锁。这时候就回到我们前面说的Monitor机制了,线程会真正地阻塞,进入内核态等待。

重量级锁虽然开销大,但它是最后的保障。在高竞争场景下,让线程阻塞反而比自旋更高效——至少CPU不用空转了。

一句话总结:锁升级是JVM的「自适应」策略。它根据竞争程度,在性能和资源消耗之间做权衡。作为开发者,我们不需要手动干预,但理解这个过程能帮我们写出更高效的并发代码。

4.4 避坑指南

最后分享几个我踩过的坑:

  • 锁对象不要用字符串字面量:字符串常量池可能导致意想不到的锁共享
  • 不要锁住包装类型IntegerLong等包装类型有缓存机制,容易出问题
  • 锁的粒度要适中:太粗影响性能,太细容易死锁
  • 注意锁的可见性synchronized不仅能保证原子性,还能保证可见性(内存屏障)

我曾经在项目里用String作为锁对象,结果两个不相关的模块莫名其妙地互相阻塞了。查了半天才发现,它们用了同一个字符串常量。嗯,从那以后我再也不敢偷懒了。

好了,关于synchronized的核心内容就这些。记住:用法要准确,原理要理解,坑要避开。下一章我们聊聊volatile关键字,它和synchronized有什么异同?到时候见。