基于本文回答

播面 播面

文图音视,全方位拆解八股文
0
评论

Synchronized详解

知识点图片

本文深入剖析Java synchronized关键字,重点讲解其如何保障并发下的原子性与可见性、三种使用方式、基于Monitor的底层原理,以及从偏向锁到重量级锁的升级过程。

我们来对 Java 中的 synchronized 关键字进行一次全面而深入的详解。


核心摘要

synchronized 是 Java 中用于解决多线程并发问题最基本、最常用的关键字。它的核心作用是提供一种互斥锁(Mutual Exclusion Lock)机制,确保在同一时刻,只有一个线程可以执行被 synchronized 保护的代码块或方法。这能有效解决由多线程引发的原子性可见性有序性问题。


1. 为什么需要 synchronized?—— 线程安全问题

在多线程环境中,如果多个线程同时访问和修改同一个共享变量,就会出现不可预知的结果,这就是线程安全问题。主要体现在三个方面:

  1. 原子性(Atomicity)
  • 问题:一个或多个操作,要么全部执行成功,要么全部不执行,中间不能被任何因素打断。像 count++ 这样的操作,看起来是一行代码,但实际上包含三个步骤:① 读取 count 值;② 将值加 1;③ 将新值写回 count。多线程环境下,这三步之间可能被其他线程插入,导致结果错误。
    • synchronized 的作用:它能保证被其修饰的代码块在执行期间不会被其他线程打断,从而保证了原子性。
  1. 可见性(Visibility)

    • 问题:当一个线程修改了共享变量的值,其他线程能够立即看到这个修改。由于 Java 内存模型(JMM)的存在,每个线程有自己的工作内存(通常是 CPU 缓存),线程对变量的修改可能先存在于工作内存中,没有及时刷新到主内存,导致其他线程读取到的是旧值。
    • synchronized 的作用:当一个线程释放锁时,JMM 会强制它将工作内存中的所有修改刷新到主内存。当另一个线程获取锁时,JMM 会强制它从主内存中重新加载共享变量的值。这就保证了可见性。
  2. 有序性(Ordering)

    • 问题:为了提高性能,编译器和处理器可能会对指令进行重排序。在单线程环境下,这通常没问题,但在多线程环境下,重排序可能会导致意想不到的逻辑错误。
    • synchronized 的作用:它隐式地禁止了临界区(被锁定的代码)内外的指令重排序,保证了代码执行的“先后顺序”符合预期。

2. synchronized 的三种使用方式

synchronized 锁定的对象被称为锁监视器(Monitor)。锁住哪个对象是理解其工作方式的关键。

a. 修饰实例方法

synchronized 修饰一个普通(非静态)方法时,它锁定的对象是当前类的实例(this

java
public class MyCounter {
    private int count = 0;

    // 锁对象是 this,即 MyCounter 的实例
    public synchronized void increment() {
        count++;
    }
}

// 使用示例
MyCounter counter1 = new MyCounter();
MyCounter counter2 = new MyCounter();

// 线程A和B竞争 counter1 对象锁,会互斥
new Thread(() -> counter1.increment()).start();
new Thread(() -> counter1.increment()).start();

// 线程C可以和A、B同时运行,因为它锁的是 counter2 对象
new Thread(() -> counter2.increment()).start();
  • 效果:多个线程同时调用同一个实例increment() 方法时,会发生互斥,只有一个线程能执行。如果它们调用的是不同实例increment() 方法,则不会互斥。

b. 修饰静态方法

synchronized 修饰一个静态方法时,它锁定的对象是当前类的 Class 对象(例如 MyCounter.class

java
public class MyCounter {
    private static int count = 0;

    // 锁对象是 MyCounter.class
    public static synchronized void increment() {
        count++;
    }
}

// 使用示例
MyCounter counter1 = new MyCounter();
MyCounter counter2 = new MyCounter();

// 所有线程都在竞争同一个锁:MyCounter.class
// 无论通过哪个实例调用,或直接通过类名调用,都会互斥
new Thread(() -> MyCounter.increment()).start();
new Thread(() -> MyCounter.increment()).start();
  • 效果:这个锁是类级别的,对于这个类的所有实例都是全局唯一的。无论有多少个实例,只要调用这个静态同步方法,就必须竞争同一个 MyCounter.class 锁。

c. 修饰代码块

这是最灵活的方式,可以显式指定用哪个对象作为锁。

java
public class MyCounter {
    private int count = 0;
    private final Object lock = new Object(); // 推荐使用一个专用的、final的、私有的对象作为锁

    public void performAction() {
        // ... 其他非同步操作 ...

        // 只对关键代码块加锁,减小锁的粒度,提高性能
        synchronized (lock) { // 显式指定 lock 对象为锁
            count++;
        }
        
        // ... 其他非同步操作 ...
    }
    
    // 也可以锁定 this 或 Class 对象
    public void anotherAction() {
        synchronized(this) { 
            // 效果等同于修饰实例方法
        }
    }
    
    public static void staticAction() {
        synchronized(MyCounter.class) {
            // 效果等同于修饰静态方法
        }
    }
}
  • 优点
    1. 减小锁的粒度:只锁定必要的代码,而不是整个方法,可以显著提高并发性能。
    2. 明确锁对象:可以清晰地控制锁定的对象,避免使用 this 这种可能被外部代码无意中锁定的对象,减少死锁风险。

3. 底层原理 —— monitorentermonitorexit

synchronized 的实现依赖于 JVM 底层的 Monitor(监视器锁) 机制。

  • 字节码层面

    • 对于同步代码块,JVM 会在编译后的字节码中插入 monitorentermonitorexit 两条指令。monitorenter 在代码块开始处,尝试获取锁;monitorexit 在代码块正常结束和异常结束时都会被插入,确保锁一定会被释放。
    • 对于同步方法,JVM 会在方法的 access_flags 中添加一个 ACC_SYNCHRONIZED 标志,当方法被调用时,JVM 会自动执行获取和释放锁的逻辑。
  • 对象与 Monitor 的关系

    • Java 中的任何对象都可以作为锁。
    • 每个对象内部都与一个 Monitor 关联。当线程尝试获取这个对象的锁时,实际上是在尝试获取这个 Monitor 的所有权。
    • Monitor 内部维护了几个关键部分:
      • _owner: 指向当前持有锁的线程。
      • _EntryList: 一个等待队列,存放所有尝试获取锁但被阻塞的线程。
      • _WaitSet: 一个等待队列,存放调用了该对象 wait() 方法的线程。
      • _recursions: 一个计数器,用于支持锁的重入。

锁升级(JDK 1.6 后的优化)

为了提高性能,synchronized 在现代 JVM 中并不总是使用重量级的操作系统锁,而是实现了一个从低到高的锁升级过程。

  1. 偏向锁 (Biased Locking)

    • 场景:锁大多数情况下由同一个线程获取,几乎没有竞争。
    • 原理:当一个线程首次获取锁时,JVM 会在对象头(Object Header)中记录下这个线程的 ID,并将锁状态标记为“偏向”。之后,该线程再次进入同步块时,无需任何 CAS 操作或同步,直接检查线程 ID 即可,开销极低。
    • 升级:如果另一个线程尝试获取这个锁,偏向模式就会被撤销,锁会升级为轻量级锁。
  2. 轻量级锁 (Lightweight Locking)

    • 场景:存在少量线程竞争,且锁的持有时间非常短。
    • 原理:线程尝试获取锁时,会使用自旋(Spinning)的方式,即在一个循环中不断尝试用 CAS(Compare-And-Swap)原子操作将对象头中的锁记录指向自己的线程栈。自旋不会让线程进入阻塞状态,避免了操作系统层面的线程上下文切换,如果能很快获得锁,效率很高。
    • 升级:如果自旋一定次数(或一定时间)后仍未获取到锁,说明竞争激烈,锁会膨胀为重量级锁。
  3. 重量级锁 (Heavyweight Locking)

    • 场景:存在激烈竞争,或锁的持有时间长。
    • 原理:这是最传统的锁实现。锁会升级为真正的 Monitor,获取不到锁的线程会被阻塞(挂起),并放入 _EntryList 队列中。这需要操作系统介入,进行线程的上下文切换,开销较大,但能很好地处理高并发场景。

4. synchronized 的关键特性

  1. 可重入性 (Reentrancy)

    • 一个线程在持有某个对象的锁之后,可以再次进入任何其他使用该对象锁的同步代码块,而不会造成死锁。
    • 原理:Monitor 内部有一个计数器 (_recursions)。线程每获取一次锁,计数器加 1;每释放一次锁,计数器减 1。只有当计数器为 0 时,锁才真正被释放。
    java
    public synchronized void methodA() {
        System.out.println("进入 methodA");
        methodB(); // 同一个线程可以成功调用 methodB
    }
    
    public synchronized void methodB() {
        System.out.println("进入 methodB");
    }
  2. 不可中断性 (Non-interruptible)

    • 一个线程在等待获取 synchronized 锁的过程中,是不能被 Thread.interrupt() 方法中断的。它会一直等待下去,直到获取到锁为止。这一点与 ReentrantLock 不同。
  3. 非公平性 (Non-fair)

    • synchronized 是一种非公平锁。当锁被释放时,JVM 允许新来的线程“插队”,直接尝试获取锁,而不是严格按照等待队列(_EntryList)的先进先出顺序。这可能会提高吞吐量,但可能导致某些线程长时间“饥饿”。

5. synchronized vs. ReentrantLock

特性 synchronized ReentrantLock
性质 Java 关键字,由 JVM 实现 Java API 类 (java.util.concurrent.locks.Lock)
锁的释放 自动释放:代码块执行完毕或异常退出时,JVM 自动释放锁 手动释放:必须在 finally 块中调用 unlock() 方法,否则可能导致死锁
功能 基本的同步功能 功能更强大,提供更多高级特性
可中断 不可中断 可中断 (lockInterruptibly())
公平性 非公平锁 可选,可创建公平锁或非公平锁(默认)
超时获取 不支持 支持 (tryLock(long, TimeUnit))
绑定条件 只能与一个条件(wait/notify)关联 可绑定多个 Condition 对象,实现精确唤醒
性能 JDK 1.6 后性能大幅提升,与 ReentrantLock 在低竞争下相差无几 高竞争下,通过精细控制,性能可能更优

总结与最佳实践

  1. 首选 synchronized:在不需要 ReentrantLock 的高级特性(如可中断、超时、公平锁)时,优先使用 synchronized。它的语法更简洁,不易出错(自动释放锁),且 JVM 的持续优化使其性能非常出色。

  2. 减小锁粒度:尽量使用同步代码块,并且只包裹必要的、修改共享资源的代码,以提高并发能力。

  3. 使用专用锁对象:推荐使用 private final Object lock = new Object(); 作为锁对象,而不是 this 或字符串常量。这可以避免外部代码无意中获取你的锁,造成意外的干扰或死锁。

  4. 理解锁的对象:清晰地知道你的代码锁住的是哪个对象(是 this 实例,还是 .class 对象,或是某个自定义对象),这是正确使用 synchronized 的基础。

synchronized 是 Java 并发编程的基石,虽然现代并发包(JUC)提供了更多高级工具,但深刻理解 synchronized 的工作原理和适用场景,对于编写健壮、高效的并发程序至关重要。

00:00
00:00