雨滴.
雨滴.
技术分享

当多个线程撞进同一扇门:我终于看懂了 Synchronized

刚学 synchronized 时,理解通常停在一句话:加上它,同一时间只能有一个线程进来。这句话没错,但只停在这里,其实还没真正理解 synchronized——它背后至少牵扯三个问题:到底锁住了什么?线程为什么会因为这把锁排队?除了互斥,它为什么还能保证共享变量的可见性?再往 JVM 里挖,还会遇到 monitorenter/monitorexit、对象头、Monitor,以及 JIT 对锁的优化。这篇文章顺着它真正执行的过程,一层层看看这把锁到底做了什么。

一、Synchronized 解决了什么问题

假设有 100 张票,多个线程同时卖:

public void sellTicket() {
    if (ticket > 0) {
        ticket--;
    }
}

单线程没问题,但多线程交错执行时,ticket-- 实际上是"读取→修改→写回"三个动作,线程 A、B 都读到 ticket = 1,都执行了 ticket--,最终可能只卖出一张票,但两个线程都以为自己卖成功了。

加上 synchronized:

public synchronized void sellTicket() {
    if (ticket > 0) {
        ticket--;
    }
}

同一时刻只有一个线程能进入这个方法,其他线程等它释放锁。可以理解成:给一段代码划出一个临界区,同一把锁对应的临界区,同一时间只能让一个线程进入。Java 语言规范把这套机制建立在 Monitor(监视器) 之上:每个对象关联一个 Monitor,同一时间只能由一个线程持有;线程获取锁后执行同步代码,无论正常还是异常退出都会释放锁。

但马上会出现一个问题:到底是哪一把锁?

二、Synchronized 到底锁谁

这是理解 synchronized 最重要的一步——它锁的不是"这个方法",而是某个对象对应的 Monitor。三种常见写法,本质上是在决定"锁哪个对象"。

修饰实例方法:锁住当前对象

public synchronized void sellTicket() { ... }

等价于 synchronized (this) { ... }。所以 windowA.sellTicket() 和 windowB.sellTicket() 锁的是两个不同对象,互不影响——实例方法上的 synchronized,只能保证同一个对象上的并发访问互斥。多个线程要互斥,前提是竞争同一个锁对象。

修饰静态方法:锁住 Class 对象

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

锁的不是某个实例,而是当前类的 Class 对象,等价于 synchronized (StaticSafeDemo.class)。即使创建多个实例,调用这个静态同步方法时竞争的仍是同一个 Class 对象,影响范围是整个类。可以记成:实例方法锁 this,静态方法锁 Xxx.class——Java 语言规范对此有明确规定。

修饰代码块:自己决定锁谁

private final Object lock = new Object();

public void doSomething() {
    // 准备工作...
    synchronized (lock) {
        // 核心逻辑
    }
    // 善后工作...
}

只要是一个对象引用,都可以作为锁对象(this、某个类的 .class、专门的锁对象都行)。代码块的意义不只是"性能更高",而是可以精确控制锁的对象和范围——如果只有几行代码需要互斥,没必要锁住整个方法。

三、真正进入 JVM:Monitor 到底是什么

JVM 怎么知道一个对象有没有被锁?这就要进入底层实现了。同步代码块编译后会涉及 monitorenter → 执行同步代码 → monitorexit:进入同步块前,线程先获取对应对象的 Monitor;执行结束后释放。编译器会保证异常退出时同样释放锁,不是简单地"代码最后一行执行完才释放"。

多个线程竞争同一个 Monitor 时,只有一个能进入临界区,其余等待。而同步方法的实现方式不同:它通常通过方法的 ACC_SYNCHRONIZED 标志表达同步语义,而不是在字节码里直接插入 monitorenter/monitorexit——这也是面试常把"同步代码块"和"同步方法"的底层实现放在一起问的原因。

四、为什么不只是"互斥"

如果 synchronized 只做到"一个线程进去,其他等着",其实还不够完整——并发问题除了原子性,还有可见性:

// 线程 A
running = false;
// 线程 B
while (running) { ... }

线程 A 修改的数据,线程 B 什么时候能看到?synchronized 同样解决了这个问题:一个线程在释放某个 Monitor 之前对共享变量的修改,对之后获取同一个 Monitor 的线程可见。这对应 Java 内存模型里明确的 happens-before 关系:对同一个 Monitor 的解锁操作 happens-before 后续对这个 Monitor 的加锁操作。

所以 synchronized 同时承担两个职责:互斥(同一时间只有一个线程进入临界区)和可见性(释放锁之前的修改,对后续获得同一把锁的线程可见)。不能简单把它理解成"把多线程变成单线程",它真正做的是在临界区内建立可靠的同步关系。

五、能不能保证有序性

"synchronized 保证原子性、可见性、有序性"是面试常见简化答案,但追问下去需要更准确:synchronized 不是靠"禁止所有指令重排"生效的开关,JVM 和处理器依然可以做合法的重排序,但同步操作本身有明确的内存语义——Monitor 的加锁/解锁建立 happens-before 关系,因此在正确同步的程序中,线程能观察到符合 Java 内存模型要求的执行结果。

更准确的说法是:synchronized 不需要禁止所有指令重排来保证线程安全,而是通过 Monitor 的同步语义建立 happens-before 关系,从而保证同步代码之间的可见性和有序的同步效果。

这和 volatile 的思路不完全一样:volatile 偏向"不需要互斥,但需要读写及时可见";synchronized 偏向"这段操作需要互斥执行,同时保证数据可见性"。

六、为什么能保证原子性

count++ 本身不是原子的,但加上 synchronized void increment() { count++; } 后,因为同一时间只有一个线程能进入临界区,整个"读取→修改→写回"过程不会被另一个受同一把锁保护的线程插入。

**synchronized 不是让 count++ 这个 CPU 操作本身变成原子操作,而是通过互斥,让多个线程无法同时执行这段复合操作。**这句话比"synchronized 保证原子性"更值得记住。

七、对象头和 Mark Word:锁的信息放在哪里

Java 对象的锁信息存在哪?答案是对象头。HotSpot JVM 的对象布局中,对象头包含运行时状态信息,Mark Word 承担部分元数据存储职责。早期 HotSpot 里经典的锁状态图是:

无锁 → 偏向锁 → 轻量级锁 → 重量级锁

锁状态

Mark Word 中典型信息

无锁

HashCode、GC 年龄等

偏向锁

线程相关信息、Epoch 等

轻量级锁

指向 Lock Record 的信息

重量级锁

指向 Monitor 的相关信息

这个知识点在旧版面试资料里很常见,但如果用的是 JDK 21,要注意版本差异:偏向锁在 JDK 15 已被正式废弃,现代 JDK 不能再按"无锁→偏向锁→轻量级锁→重量级锁"这条路线理解 synchronized 的实际运行机制。所以最好把"面试经典知识(对象头/Mark Word/锁状态)"和"现代 JDK 实现(Monitor + CAS + 自旋 + 阻塞,根据竞争情况运行时优化)"分开理解,不要把早期 HotSpot 实现细节当成语言层面的永久规则。

八、锁升级到底是怎么回事

很多文章会说"JDK 1.6 开始 synchronized 做了锁升级,性能大幅提升"——这句话放在历史背景里没问题,但今天讲不够准确。更值得理解的是:JVM 会根据锁竞争情况选择合适的实现路径,不是每次 synchronized 都直接进入操作系统阻塞。竞争较少时,线程可能通过 CAS 和自旋尽量避免立即阻塞;竞争加剧后,才会进入阻塞/唤醒路径。这比死记"轻量级锁什么时候升级成重量级锁"更接近现代 JVM 的思想。

还要注意:Monitor 不等于操作系统 Mutex。JVM 可以用多种底层机制实现 Monitor 语义,不能简单认为每次 synchronized 都会发生一次用户态/内核态切换——否则它也不会经过这么多年优化,依然是 Java 最常用的内置同步机制之一。

九、计算过 HashCode 为什么会影响偏向锁

这是老版本 HotSpot 面试里经典的历史问题:Mark Word 空间有限,在特定的旧版偏向锁实现中,不同状态需要复用这部分空间,而对象的 identity hash code 也要存储相关信息。一旦对象已经计算并记录了 identity hash code,就可能与偏向锁状态冲突——所以旧版 HotSpot 下,已计算 hash code 的对象无法继续保持偏向锁状态。这属于偏向锁时代的实现细节,面试可以了解,但不应作为 JDK 21 中 synchronized 的核心机制来理解。

十、JIT 还会帮我们"偷偷省锁"

synchronized 是语言级别的同步机制,但 JVM 不会原样按源码执行,JIT 会做两类经典优化:

锁消除(Lock Elimination):如果 JIT 能证明某个锁对象只在当前线程内部使用,不可能被其他线程访问,那这把锁就没有竞争的可能,JVM 可能直接把这段无意义的同步操作优化掉。

锁粗化(Lock Coarsening):如果代码频繁对同一对象加锁/解锁,重复开销可能反而更大。JIT 在能证明不改变程序正确性的前提下,可能把多个连续的同步区域合并成一个更大的临界区。

所以看到 synchronized 不要只想到"加锁很慢"——现代 JVM 会尽可能判断这把锁有没有必要、竞争激不激烈、能不能让它更便宜。

十一、Synchronized 和 Volatile 的区别

一句话记:volatile 解决共享变量的可见性和特定语义下的有序性;synchronized 解决临界区的互斥,同时建立内存可见性保证。

private volatile boolean running 适合"多个线程读取,一个修改后其他线程要及时看到"的场景;但即使 count 是 volatile 的,count++ 依然不是线程安全的复合操作(读取→+1→写回仍可能交错),这时候需要的是互斥,即 synchronized。

机制

核心作用

volatile

可见性 + 特定的有序性保证

synchronized

互斥 + 可见性 + happens-before

AtomicInteger

基于 CAS 的原子操作

ReentrantLock

更灵活的显式锁机制

它们不是互相替代的关系,而是针对不同并发场景解决不同问题。

十二、重新认识一下 Synchronized

学 synchronized 最容易犯的错误,是把它记成"一把锁,同一时间只能一个线程执行"——这只是最表面的一层。真正串起来看,它背后是一条完整的链:

synchronized → 对象锁 → Monitor → monitorenter/monitorexit
       → 互斥 → happens-before → 可见性保证 → JIT/JVM 锁优化

从一个关键字,一路能看到 JVM、Java 内存模型、对象布局、CAS、自旋、线程阻塞和 JIT 优化。表面上它只是告诉 JVM"这里排个队",但 JVM 真正要解决的问题是:当很多线程同时做同一件事时,怎样既不把数据弄乱,又尽可能少为"排队"付出代价。所以 synchronized 不只是一把锁——它更像 Java 并发世界里一扇很小的门,推开之后,站着的是整个 JVM 的并发机制。

豫ICP备2026047996号 公安备案 豫公网安备41072702000373号