程序员求职经验分享与学习资料整理平台

网站首页 > 文章精选 正文

深入剖析 Java 中 Synchronized 锁的原理

balukai 2025-07-10 13:08:53 文章精选 2 ℃

在互联网大厂后端开发领域,Java 作为一种广泛应用的编程语言,其多线程编程的重要性不言而喻。在多线程环境下,数据的同步访问至关重要,否则极易引发数据不一致等线程安全问题。而synchronized关键字作为 Java 提供的一种强大的同步机制,被众多开发者频繁使用。但你真的了解synchronized锁的底层原理吗?今天,就让我们一同深入探索 Java 中 Synchronized 锁的奥秘。

synchronized 的基本原理

基于 Monitor 实现

synchronized是一种对象锁,它基于 JVM 内置锁实现,通过内部对象 Monitor(监视器锁)来达成方法与代码块的同步。可以把 Monitor 想象成一个房间,每个对象都关联着这样一个 “房间”。当一个线程想要进入被synchronized修饰的代码块或方法时,就如同要进入这个 “房间”。而当 “房间” 被占用(即 Monitor 被占用)时,它就处于锁定状态。

线程执行monitorenter指令时,便尝试获取 Monitor 的所有权。这个过程类似人们排队进入一个房间:如果 Monitor 的进入数为 0,意味着这个 “房间” 是空的,那么该线程可以顺利进入 “房间”,然后将进入数设置为 1,此时该线程就成为了这个 “房间” 的所有者;倘若 “房间” 已经被其他线程占用(即 Monitor 已被占用),那么该线程只能在外面等待,进入阻塞状态,直到 “房间” 里的线程出来,Monitor 的进入数变为 0,它才能再次尝试获取 Monitor 的所有权。

当线程执行monitorexit指令时,这个线程必须是 Monitor 的所有者,就好比只有房间的主人才能离开房间。执行该指令时,Monitor 的进入数减 1,如果减 1 后进入数变为 0,那就说明房间里没人了,线程退出 Monitor,不再是所有者。此时,其他在外面等待的线程便可以尝试获取这个 Monitor 的所有权,进入 “房间”。

字节码指令层面

从字节码指令的角度来看,synchronized的实现更加清晰。在编译后,对于同步代码块,monitorenter指令会被插入到同步代码块开始的位置,monitorexit指令则插入到方法结束处以及异常处。JVM 会严格确保每个monitorenter都有对应的monitorexit配对,以此保证锁的正确使用。

比如,当我们有一段被synchronized修饰的代码块:

synchronized (obj) {
    // 同步代码块
}

编译后的字节码大致会是这样(简化示意):

monitorenter // 尝试获取obj对象关联的Monitor的所有权
// 同步代码块的字节码指令
monitorexit  // 释放obj对象关联的Monitor的所有权

而对于同步方法,虽然在代码中我们看不到monitorenter和monitorexit指令,但其实 JVM 会在方法调用时隐式地进行处理。当方法被调用时,调用指令会检查方法的ACC_SYNCHRONIZED访问标志是否被设置,如果设置了,执行线程将先获取 Monitor,获取成功之后才能执行方法体,方法执行完后再释放 Monitor。在方法执行期间,其他任何线程都无法再获得同一个 Monitor 对象,从而保证了方法的同步执行。

锁的状态及升级机制

在 JDK 1.6 之后,为了提升synchronized的性能,引入了锁升级的概念。对象在不同的竞争情况下,其锁状态会发生变化,依次有无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态。

无锁状态

对象刚创建出来时,处于无锁状态。此时对象的 Mark Word(对象头的一部分,用于存储对象的运行时数据,如哈希码、GC 分代年龄、锁状态标志等)存储的是对象的基本信息,比如哈希码、GC 年龄等,锁标志位表明当前对象未被锁定。

偏向锁

研究发现,在大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得。偏向锁就是为了减少同一线程获取锁的代价而引入的。

当一个线程首次获取到对象的锁时,锁进入偏向模式。此时,对象的 Mark Word 结构会变为偏向锁结构,其中记录了获取锁的线程 ID。当这个线程再次请求该对象的锁时,它无需再进行复杂的同步操作,比如 CAS(比较并交换)操作,而只需简单地检查 Mark Word 中的线程 ID 是否与自己的线程 ID 一致。如果一致,那么该线程可以直接获取锁,大大节省了锁申请的开销,提高了程序的性能。

但是,如果有其他线程开始竞争这个锁,偏向锁就不再适用了。这时,偏向锁会升级为轻量级锁。偏向锁的升级过程大致是:当到达全局安全点(safepoint,在这个时间点上没有字节码正在执行)时,JVM 会首先暂停拥有偏向锁的线程,然后检查持有偏向锁的线程是否还活着。如果线程不处于活动状态,那么将对象头设置成无锁状态,然后重新偏向新的线程;如果线程仍然活着,就撤销偏向锁并将其升级到轻量级锁状态。

轻量级锁

当出现两个线程竞争锁的情况(即发现有锁竞争),偏向锁就会取消并升级为轻量级锁。此时,线程会在自己的栈帧中创建一个锁记录(Lock Record)。然后,线程尝试通过 CAS 操作,将对象头的轻量级锁栈地址指针指向自己创建的锁记录。如果 CAS 操作成功,那么这个线程就获取到了锁,同时锁记录中会记录重入次数,并修改锁标志位,表明对象处于轻量级锁状态。

在轻量级锁状态下,如果一个线程在获取锁时,CAS 操作失败,说明已经有其他线程持有了该对象的轻量级锁。此时,JVM 并不会立即让线程进入阻塞状态,而是采用自旋锁的方式。即未抢到锁的线程会在一定次数内不断重试去获取锁,因为在很多情况下,持有锁的线程可能很快就会释放锁。如果自旋次数超过了阈值,轻量级锁就会升级为重量级锁。

重量级锁

当竞争激烈,CAS 次数超过重试阈值时,JVM 会将轻量级锁升级为重量级锁。此时,JVM 会创建 Monitor 对象或者使用已有的 Monitor 对象,并将 Monitor 指针指向它。之后,线程的调度就交由操作系统来负责。在重量级锁状态下,竞争锁的线程会进入 entryList 排队竞争锁,这个过程会引起线程上下文的切换,涉及到从用户态到核心态的转换,性能开销较大。

线程进入同步代码块时,首先会进入 entryList 等待获取锁。获取到锁后,线程会更改 Monitor 的 Owner 为自己,并将重入计数器 Recursion Count 加 1。如果线程在执行过程中调用了wait方法,它会释放锁,然后进入 Wait Set 等待,只有通过notify或者notifyAll方法才能被唤醒,唤醒之后又会进入 entryList 排队竞争锁。当获取到锁的线程执行完毕,它会释放锁,将 Recursion Count 减 1,当前线程的 Owner 置为 Null,其他等待的线程就有机会获取锁了。

不同修饰情况下的锁对象

修饰普通方法

当synchronized修饰普通方法时,锁的对象是当前实例对象。也就是说,不同的实例对象拥有各自独立的锁。例如:

public class SyncDemo {
    public synchronized void normalSyncMethod() {
        // 同步方法体
    }
}

假设有两个SyncDemo的实例demo1和demo2,当一个线程调用demo1.normalSyncMethod()时,只会对demo1这个实例对象加锁,其他线程仍然可以调用demo2.normalSyncMethod(),因为它们的锁是相互独立的。

修饰静态方法

如果synchronized修饰静态方法,那么锁的对象是当前类的 Class 对象。由于 Class 对象在整个 JVM 中是唯一的,所以所有该类的实例共享这把锁。比如:

public class StaticSyncDemo {
    public static synchronized void staticSyncMethod() {
        // 同步方法体
    }
}

无论有多少个StaticSyncDemo的实例,只要有一个线程调用了
StaticSyncDemo.staticSyncMethod(),其他线程无论是通过哪个实例调用该静态同步方法,或者直接通过类名调用,都需要等待锁的释放,因为它们竞争的是同一个 Class 对象的锁。

修饰代码块

当synchronized修饰代码块时,锁的对象是synchronized括号里配置的对象。例如:

public class BlockSyncDemo {
    private Object lock = new Object();
    public void blockSyncMethod() {
        synchronized (lock) {
            // 同步代码块
        }
    }
}

在这个例子中,线程在进入synchronized (lock)代码块时,竞争的是lock对象的锁。只要其他线程没有获取到lock对象的锁,就无法进入该同步代码块。

synchronized 的内存语义

synchronized不仅用于实现线程同步,还具有重要的内存语义。在多线程编程中,由于每个线程都有自己的工作内存,对共享变量的操作可能会导致数据不一致的问题。而synchronized通过其内存语义来保证共享变量的内存可见性。

当线程进入synchronized块时,会把块内使用的变量从线程的工作内存中清除,这样在使用这些变量时,线程就必须直接从主内存中获取,从而保证获取到的是最新的值。当线程退出synchronized块时,会把块内对共享变量的修改刷新到主内存中,确保其他线程能够看到最新的修改。

从 happens-before 原则来看,如果线程 A 在释放锁之前对共享变量进行了修改,那么在线程 B 获取同一个锁之后,这些修改对线程 B 是可见的。这就如同线程 A 在离开房间(释放锁)之前对房间里的东西(共享变量)做了一些改变,当线程 B 进入这个房间(获取锁)时,能看到这些改变。

synchronized还常用于实现原子性操作。因为被synchronized修饰的代码块或方法在同一时刻只能有一个线程执行,不会被其他线程打断,从而保证了操作的原子性。例如,对一个共享变量进行自增操作,如果不使用synchronized,在多线程环境下可能会出现数据错误,但使用synchronized修饰这个操作后,就可以确保自增操作的原子性,得到正确的结果。

深入理解 Java 中synchronized锁的原理,对于我们在互联网大厂后端开发中编写高效、安全的多线程代码至关重要。无论是锁的基本原理、锁状态的升级机制,还是不同修饰情况下的锁对象以及内存语义,每一个方面都值得我们细细研究和掌握。希望通过本文的介绍,能让大家对synchronized有更深入的认识,在实际开发中能够更加得心应手地运用它来解决多线程同步问题。

你在使用synchronized的过程中是否遇到过一些问题或者有什么有趣的经历呢?欢迎在评论区分享交流。

最近发表
标签列表