synchronized的偏向、轻量、重量级锁

2023-05-05 20:13:11 浏览数 (1)

synchronized的偏向、轻量、重量级锁

Synchronized实现同步的方式有三种:偏向锁、轻量级锁、重量级锁。本文会从理论和代码实践两方面阐述三种锁的实现细节和原理。

偏向锁

偏向锁的思想很简单,就是偏向于第一个获取锁的线程,当其他线程要获取锁时,会在CAS操作中失败,然后挂起等待,直到第一个线程释放锁。这个锁的好处是可以满足大多数同步场景下的需求,并且消耗很小的资源。

要开启偏向锁,需要添加JVM参数-XX: UseBiasedLocking

代码语言:javascript复制
public class BiasedLocking {
    private Object lock = new Object();

    public void method1() {
        synchronized (lock) {
            // do something...
        }
    }
} 

当第一个线程进入synchronized块时,会将锁的标记从none修改为bias状态,同时记录偏向的线程ID。之后其他线程要获取锁,会通过CAS操作尝试将锁偏向自己,但这个操作会失败,所以只会短暂地竞争,很快其他线程就会进入阻塞状态,释放CPU时间片。

当偏向的线程退出同步块时,如果发现锁还没有其他线程在等待,那么会将锁的状态重置为none。如果发现有其他线程在等待,会释放锁,让等待线程获取。

当偏向的线程退出同步块时,如果发现锁还没有其他线程在等待,那么会将锁的状态重置为none。如果发现有其他线程在等待,会释放锁,让等待线程获取。

轻量级锁

轻量级锁的获取过程是通过CAS操作完成的。当前线程会先在对象头中记录自己,然后尝试用CAS将对象头中的锁记录替换为当前线程,如果成功就获取到锁,失败就进入阻塞队列等待唤醒。

代码语言:javascript复制
public class LightWeightLock {
    private Object lock = new Object();

    public void method1() {
        synchronized (lock) {
            // do something...
        }
    }
}

当第一个线程进入方法时,会将对象头中的锁状态修改为当前线程ID,然后进入同步块。其他线程要获取锁时,会先检查对象头的锁状态,如果发现锁已经被占用,那么会使用CAS操作进行抢占,如果成功则获取到锁,失败会加入到阻塞队列进行等待。

当前线程退出同步块时,会使用CAS操作释放锁,将对象头设置为unlocked状态,同时唤醒阻塞队列中的一个等待线程。

轻量级锁的优点是消耗资源小,对代码性能的影响小,但是在高并发的场景下,CAS操作的 ABA问题会导致线程无法正常工作,所以当锁重入超过10次,或者锁持有时间超过1s时,JVM会将轻量级锁升级为重量级锁。

重量级锁

重量级锁会导致当前拥有锁的线程和其他等待线程都进入阻塞状态,切换到内核态,这 obviously 是一个非常消耗资源的操作。

其实现过程是:当前线程首先会在对象头中记录自己,然后进入内核态被阻塞,同时其他线程也会被阻塞。当其中一个线程退出同步块时,会唤醒其他线程中的一个

代码语言:javascript复制
public class HeavyWeightLock {
    private Object lock = new Object();

    public void method1() {
        synchronized (lock) {
            // do something...
        }
    }
}

当第一个线程进入同步块时,会标记对象头表示此对象处于锁定状态,然后进入内核态挂起。其他线程要获取锁时,会发现对象头的锁定状态,也会进入内核态挂起。

当锁定的线程退出同步块时,会标记对象头为解锁状态,然后唤醒一个等待线程。被唤醒的线程会重新标记对象头为锁定状态,然后继续执行同步块中的内容。

重量级锁的优点是可以解决轻量级锁中的ABA问题,但是其性能消耗也是最大的。所以如果一个锁仅被一个线程使用,或有很高的重入概率,那么应选择偏向锁或轻量级锁,可以获得更高的性能。

偏向锁适用于单线程环境,性能最高;轻量级锁通过CAS实现,性能较好,但是会出现ABA问题;

操作步骤

要实际观察Synchronized锁的三种状态转换,可以使用JDK自带的JMC(Java Mission Control)工具。

下面是具体的操作步骤:

  1. 启动JMC,打开“标记对象(Mark Objects)”功能。
  2. 设置需要追踪的对象,这里我们选择上面的Demo对象。
  3. 运行代码,多线程访问这个对象的同步方法。
  4. JMC中打开“标记对象(Mark Objects)”视图,可以观察到对象头的状态在变化:
    • 初始为none状态,表示无锁
    • 第一个线程进入同步块后变为biased状态,表示偏向锁定
    • 多线程访问后变为轻量级锁,对象头记录为线程ID
    • 重入超过10次或持有超过1s后,变为重量级锁,对象头记录为锁定状态
  5. 当线程退出同步块后,可以观察到锁的释放过程
    • 偏向锁会重置为none状态
    • 轻量级锁使用CAS设置为unlocked状态,并唤醒后继线程
    • 重量级锁设置对象头为unlocked状态,并唤醒后继线程
  6. 可以尝试提高并发量或设置不同的超时时间,观察偏向锁和轻量级锁什么情况下会升级为重量级锁

通过上述步骤,我们可以直观的观察Synchronized锁的三种状态之间的切换过程,这也是理解其原理的最佳途径。

运维实施

在实际项目中,我们如何根据场景选择和设置合适的锁机制呢?这里提供一些参考建议:

  1. 偏向锁:默认开启,适用于大多数轻 contention 的场景,可以通过-XX:-UseBiasedLocking关闭。
  2. 轻量级锁:默认开启,无需配置,在大部分场景下可以获得不错的性能,如果出现ABA问题,会自动升级到重量级锁。
  3. 重量级锁:无需配置,在以下场景会自动使用:
    • 偏向锁或轻量级锁升级
    • JVM发现轻量级锁CAS操作次数过高
    • 同步块内耗时较长(默认超过1s)
  4. 锁消除:可以通过-XX: EliminateLocks开启,JVM会分析代码,消除不可能存在竞争的锁,以提高性能。
  5. 锁粗化:可以通过-XX: DoEscapeAnalysis 开启,JVM会分析代码,将多个连续的加锁操作锁合并为一个,以减少加锁操作次数。
  6. 自旋锁:可以通过-XX:PreBlockSpin设置自旋次数,指令在获取锁失败先自旋一定次数,再进入阻塞状态,以减少线程切换。但是如果自旋太长,会消耗CPU,需要根据场景设置。
  7. 锁定超时:可以通过-XX:MonitorTimeout=x设置重量级锁定超时时间,以避免线程因锁定过长出现死锁现象。

除上述JVM层面的设置外,在代码层面我们也可以根据场景选择不同的锁来提高性能,比如使用ReentrantLock代替Synchronized等。

面试相关

理解Synchronized锁及其实现原理,是Java后端工程师的基础知识之一,这部分内容在面试中也常会涉及,下面是一些可能的面试题:

  1. Synchronized的作用是什么?它的实现原理是什么? 答:Synchronized关键字实现同步,使得运行在同一进程中的多个线程操作同步代码段(或方法)时是互斥的。它的实现方式有三种:偏向锁、轻量级锁、重量级锁。具体可以参考本文前述内容。
  2. 偏向锁、轻量级锁、重量级锁的优缺点分别是什么? 可以参考本文“总结”部分的内容。偏向锁资源消耗最少,单线程场景使用;轻量级锁性能较好,使用CAS实现,存在ABA问题;重量级锁性能最差但安全,用于阻塞线程和处理ABA问题。
  3. Synchronized如何进行锁升级? 当偏向锁被不同线程获取超过20次,或轻量级锁被不同线程获取超过10次、或持有时间超过1s,Synchronized会进行锁升级。升级规则如下:
    • 偏向锁升级为轻量级锁
    • 轻量级锁升级为重量级锁
    • 重量级锁不会再降级

    锁升级的目的是为了提高并发性能。偏向锁适用于单线程,升级为轻量级锁可以适应更高的并发;轻量级锁使用CAS有性能损耗,升级为重量级锁可以解决该问题。

  4. JDK1.6之前的Synchronized如何实现?现在的实现方式有何不同? 答:JDK1.6之前,Synchronized只有一种实现方式:重量级锁。 JDK1.6之后,引入了偏向锁和轻量级锁,实现方式更加灵活,可以根据场景选择,大大提高了并发性能。重量级锁会让线程进入阻塞状态,拥有锁的线程与其他线程都阻塞在内核态,资源消耗大。偏向锁和轻量级锁让线程可以在用户态取得锁,资源消耗小,性能更好。所以现代JDK的Synchronized实现方式相比以前更加智能化,可以根据实际场景选择合适的锁,以obtian更好的并发性能。
  5. 是否应该使用Synchronized优先于ReentrantLock?两者的区别是什么? 答:这两个都是可重入锁,用于实现同步功能。 Synchronized是JVM实现的,性能稍差但使用简单,自动释放锁,不会出现死锁风险。 ReentrantLock是JDK实现的,性能更好,可以设置公平锁、锁定超时时间等,但是使用不当会造成死锁,并且需要手动释放锁,否则可能导致资源泄漏。 所以是否应该优先使用,需要根据实际场景决定:
    • 简单同步场景,没有特殊要求,使用Synchronized简单可靠
    • 追求高性能或需要设置特殊锁策略,使用ReentrantLock
    • 资源竞争激烈,且同步块时间长,使用ReentrantLock并设置锁定超时时间可以避免死锁

    两者的主要区别如下:

    • Synchronized是隐式锁(内置语法),ReentrantLock是显式锁(API)
    • Synchronized无法设置锁定超时,ReentrantLock可以
    • Synchronized粗暴地让线程阻塞在内核态,ReentrantLock可以先自旋再阻塞
    • Synchronized适合少量同步代码段,ReentrantLock可以同时锁定多个同步资源
  6. 对锁的理解?锁主要有哪几种? 答:锁是用于实现同步的机制,保证共享资源被线程排他地访问。主要有以下几种锁:
    • 偏向锁:锁定一次后,后续的锁定由同一线程完成,适用于单线程或同一线程重复加锁的场景
    • 轻量级锁:使用CAS操作进行加锁,性能好但存在ABA问题,用于短期加锁
    • 重量级锁:进入内核态阻塞,其他线程也阻塞,性能差但安全,用于长期加锁
    • 自旋锁:获取锁失败后先自旋一定次数再阻塞,适用于锁定时间很短的场景,可以减少线程切换开销
    • 可重入锁:同一线程可以多次获取同一把锁,Synchronized和ReentrantLock都是可重入锁
    • 读写锁:读锁可以被多个线程同时获取,写锁是排他锁,在追求读写并发场景使用
    • 死锁:两个或两个以上线程分别占有一部分资源并等待其他资源,导致无限期等待,需要避免出现

0 人点赞