Ref: https://pdai.tech/md/java/thread/java-thread-x-key-synchronized.html

带着问题

  • Synchronized 可以作用在哪里?分别通过对象锁和类锁进行举例。
  • Synchronized 本质上是通过什么保证线程安全的?分三个方面回答:加锁和释放锁的原理,可重入原理,保证可见性原理。
  • Synchronized 有什么样的缺陷?Java Lock 是怎么弥补这些缺陷的。
  • Synchronized 和 Lock 的对比与选择
  • Synchronized 在使用注意事项
  • Synchronized 修饰的方法在抛出异常时,会释放锁吗?
  • 多个线程等待同一个 snchronized 锁的时候,JVM 如何选择下一个获取锁的线程?
  • Synchronized 使得同时只有一个线程可以执行,性能比较差,有什么提升的方法?
  • 我想更加灵活地控制锁的释放和获取 (现在释放锁和获取锁的时机都被规定死了),怎么办?
  • 什么是锁的升级和降级?什么是 JVM 里的偏斜锁、轻量级锁、重量级锁?
  • 不同的 JDK 中对 Synchronized 有何优化?

    Synchronized 的使用

    在应用 Sychronized 关键字时需要把握如下注意点:

  • 一把锁只能同时被一个线程获取,没有获得锁的线程只能等待;

  • 每个实例都对应有自己的一把锁 (this), 不同实例之间互不影响;例外:锁对象是 *.class 以及 synchronized 修饰的是 static 方法的时候,所有对象公用同一把锁
  • synchronized 修饰的方法,无论方法正常执行完毕还是抛出异常,都会释放锁

    对象锁

    包括方法锁 (默认锁对象为 this, 当前实例对象) 和同步代码块锁 (自己指定锁对象)
  1. 代码块形式:手动指定锁定对象,也可是是 this, 也可以是自定义的锁
  2. 方法锁形式:synchronized 修饰普通方法,锁对象默认为 this

    类锁

    指 synchronize 修饰静态的方法或指定锁对象为 Class 对象

synchronized 可以修饰静态方法和非静态方法:

  • synchronized static void method 是给 Class 类加锁,对类的所有实例对象起作用;等同于 synchronized(ClassA.class)
  • synchronized void method 是给类对象(实例)加锁;

例如:

  1. // 包含静态同步方法、非静态同步方法的类
  2. public class SynchronizedService {
  3. // 同步静态方法
  4. synchronized static void printA() {
  5. try {
  6. System.out.printf("thread: %s come A at %s%n",
  7. Thread.currentThread().getName(), System.currentTimeMillis());
  8. Thread.sleep(2000);
  9. System.out.printf("thread: %s exit A at %s%n",
  10. Thread.currentThread().getName(), System.currentTimeMillis());
  11. } catch (Exception e) {
  12. e.printStackTrace();
  13. }
  14. }
  15. // 同步静态方法
  16. synchronized static void printB() {
  17. System.out.printf("thread: %s come B at %s%n",
  18. Thread.currentThread().getName(), System.currentTimeMillis());
  19. System.out.printf("thread: %s exit B at %s%n",
  20. Thread.currentThread().getName(), System.currentTimeMillis());
  21. }
  22. // 同步非静态方法
  23. synchronized void printC() {
  24. System.out.printf("thread: %s come C at %s%n",
  25. Thread.currentThread().getName(), System.currentTimeMillis());
  26. System.out.printf("thread: %s exit C at %s%n",
  27. Thread.currentThread().getName(), System.currentTimeMillis());
  28. }
  29. public void run() {
  30. // 同步代码块形式——锁为this,两个线程使用的锁是一样的,线程1必须要等到线程0释放了该锁后,才能执行
  31. synchronized (this) {
  32. System.out.println("我是线程" + Thread.currentThread().getName());
  33. try {
  34. Thread.sleep(3000);
  35. } catch (InterruptedException e) {
  36. e.printStackTrace();
  37. }
  38. System.out.println(Thread.currentThread().getName() + "结束");
  39. }
  40. }
  41. }
  42. public class SynchronizedServiceTest {
  43. public static void main(String[] args) {
  44. // 实例化类
  45. SynchronizedService service = new SynchronizedService();
  46. // 调用静态方法的线程A、B 被 SynchronizedService.class 阻塞,需要排队执行
  47. new Thread(SynchronizedService::printA).start();
  48. new Thread(SynchronizedService::printB).start();
  49. // 调用非静态方法的线程C直接执行,未被阻塞,因为只被当前实例 service 阻塞
  50. new Thread(service::printC).start();
  51. }
  52. }

结果:

  1. thread: Thread-0 come A at 1631546758596
  2. thread: Thread-2 come C at 1631546758598
  3. thread: Thread-2 exit C at 1631546758617
  4. thread: Thread-0 exit A at 1631546760622
  5. thread: Thread-1 come B at 1631546760623
  6. thread: Thread-1 exit B at 1631546760623

Synchronized 原理分析

加锁和释放锁的原理

现象、时机 (内置锁 this)、深入 JVM 看字节码 (反编译看 monitor 指令)

深入 JVM 看字节码,创建如下的代码:

public class SynchronizedDemo2 {

    Object object = new Object();
    public void method1() {
        synchronized (object) {

        }
        // 重入锁:在同一锁程中,线程不需要再次获取同一把锁。
        method2();
    }

    private static void method2() {

    }
}

反编译:
image.png
关注红色方框里的 monitorenter 和 monitorexit 即可。
Monitorenter 和 Monitorexit 指令,会让对象在执行,使其锁计数器加 1 或者减 1。每一个对象在同一时间只与一个 monitor (锁) 相关联,而一个 monitor 在同一时间只能被一个线程获得,一个对象在尝试获得与这个对象相关联的 Monitor 锁的所有权的时候,monitorenter 指令会发生如下 3 种情况之一:

  • monitor 计数器为 0,意味着目前还没有被获得,那这个线程就会立刻获得,然后把锁计数器 + 1,一旦 + 1,别的线程需要等待获取
  • 如果这个 monitor 已经拿到了这个锁的所有权,又重入了这把锁,那锁计数器就会累加,变成 2,并且随着重入的次数,会一直累加
  • 这把锁已经被别的线程获取了,等待锁释放

monitorexit 指令:释放对于 monitor 的所有权,释放过程很简单,就是讲 monitor 的计数器减 1,如果减完以后,计数器不是 0,则代表刚才是重入进来的,当前线程还继续持有这把锁的所有权,如果计数器变成 0,则代表当前线程不再拥有该 monitor 的所有权,即释放锁。

下图表现了对象,对象监视器,同步队列以及执行线程状态之间的关系:
image.png
该图可以看出,任意线程对 Object 的访问,首先要获得 Object 的监视器,如果获取失败,该线程就进入同步状态,线程状态变为 BLOCKED,当 Object 的监视器占有者释放后,在同步队列中的线程就会有机会重新获取该监视器。

可重入原理:加锁次数计数器

上面的 demo 中在执行完同步代码块之后紧接着再会去执行一个静态同步方法,而这个方法锁的对象依然就这个类对象,那么这个正在执行的线程还需要获取该锁吗?答案是不必的,从上图中就可以看出来,执行静态同步方法的时候就只有一条 monitorexit 指令,并没有 monitorenter 获取锁的指令。这就是锁的重入性,即在同一锁程中,线程不需要再次获取同一把锁。
Synchronized 先天具有重入性。每个对象拥有一个计数器,当线程获取该对象锁后,计数器就会加一,释放锁后就会将计数器减一。

保证可见性的原理:内存模型和 happens-before 规则

Synchronized 的 happens-before 规则,即监视器锁规则:对同一个监视器的解锁,happens-before 于对该监视器的加锁。继续来看代码:

public class MonitorDemo {
    private int a = 0;

    public synchronized void writer() {     // 1
        a++;                                // 2
    }                                       // 3

    public synchronized void reader() {    // 4
        int i = a;                         // 5
    }                                      // 6
}

该代码的 happens-before 关系如图所示:
image.png
在图中每一个箭头连接的两个节点就代表之间的 happens-before 关系,黑色的是通过程序顺序规则推导出来,红色的为监视器锁规则推导而出:线程 A 释放锁 happens-before 线程 B 加锁,蓝色的则是通过程序顺序规则和监视器锁规则推测出来 happens-befor 关系,通过传递性规则进一步推导的 happens-before 关系。现在我们来重点关注 2 happens-before 5,通过这个关系我们可以得出什么?
根据 happens-before 的定义中的一条:如果 A happens-before B,则 A 的执行结果对 B 可见,并且 A 的执行顺序先于 B。线程 A 先对共享变量 a 进行加一,由 2 happens-before 5 关系可知线程 A 的执行结果对线程 B 可见即线程 B 所读取到的 a 的值为 1。

JVM 中锁的优化

简单来说在 JVM 中 monitorenter 和 monitorexit 字节码依赖于底层的操作系统的 Mutex Lock 来实现的,但是由于使用 Mutex Lock 需要将当前线程挂起并从用户态切换到内核态来执行,这种切换的代价是非常昂贵的;然而在现实中的大部分情况下,同步方法是运行在单线程环境 (无锁竞争环境) 如果每次都调用 Mutex Lock 那么将严重的影响程序的性能。不过在 jdk1.6 中对锁的实现引入了大量的优化,如锁粗化 (Lock Coarsening)、锁消除 (Lock Elimination)、轻量级锁 (Lightweight Locking)、偏向锁 (Biased Locking)、适应性自旋 (Adaptive Spinning) 等技术来减少锁操作的开销

  • 锁粗化(Lock Coarsening):也就是减少不必要的紧连在一起的 unlock,lock 操作,将多个连续的锁扩展成一个范围更大的锁。
  • 锁消除(Lock Elimination):通过运行时 JIT 编译器的逃逸分析来消除一些没有在当前同步块以外被其他线程共享的数据的锁保护,通过逃逸分析也可以在线程本地 Stack 上进行对象空间的分配 (同时还可以减少 Heap 上的垃圾收集开销)。
  • 轻量级锁(Lightweight Locking):这种锁实现的背后基于这样一种假设,即在真实的情况下我们程序中的大部分同步代码一般都处于无锁竞争状态 (即单线程执行环境),在无锁竞争的情况下完全可以避免调用操作系统层面的重量级互斥锁,取而代之的是在 monitorenter 和 monitorexit 中只需要依靠一条 CAS 原子指令就可以完成锁的获取及释放。当存在锁竞争的情况下,执行 CAS 指令失败的线程将调用操作系统互斥锁进入到阻塞状态,当锁被释放的时候被唤醒 (具体处理步骤下面详细讨论)。
  • 偏向锁(Biased Locking):是为了在无锁竞争的情况下避免在锁获取过程中执行不必要的 CAS 原子指令,因为 CAS 原子指令虽然相对于重量级锁来说开销比较小但还是存在非常可观的本地延迟。
  • 适应性自旋(Adaptive Spinning):当线程在获取轻量级锁的过程中执行 CAS 操作失败时,在进入与 monitor 相关联的操作系统重量级锁 (mutex semaphore) 前会进入忙等待 (Spinning) 然后再次尝试,当尝试一定的次数后如果仍然没有成功则调用与该 monitor 关联的 semaphore (即互斥锁) 进入到阻塞状态。

下面来详细讲解下,先从 Synchronied 同步锁开始讲起:

锁的类型

在 Java SE 1.6 里 Synchronied 同步锁,一共有四种状态:无锁、偏向锁、轻量级所、重量级锁,它会随着竞争情况逐渐升级。锁可以升级但是不可以降级,目的是为了提供获取锁和释放锁的效率。
锁膨胀方向: 无锁 → 偏向锁 → 轻量级锁 → 重量级锁 (此过程是不可逆的)

自旋锁与自适应自旋锁

自旋锁

引入背景:大家都知道,在没有加入锁优化时,Synchronized 是一个非常 “胖大” 的家伙。在多线程竞争锁时,当一个线程获取锁时,它会阻塞所有正在竞争的线程,这样对性能带来了极大的影响。在挂起线程和恢复线程的操作都需要转入内核态中完成,这些操作对系统的并发性能带来了很大的压力。同时 HotSpot 团队注意到在很多情况下,共享数据的锁定状态只会持续很短的一段时间,为了这段时间去挂起和回复阻塞线程并不值得。在如今多处理器环境下,完全可以让另一个没有获取到锁的线程在门外等待一会 (自旋),但不放弃 CPU 的执行时间。等待持有锁的线程是否很快就会释放锁。为了让线程等待,我们只需要让线程执行一个忙循环 (自旋),这便是自旋锁由来的原因。

自旋锁早在 JDK1.4 中就引入了,只是当时默认时关闭的。在 JDK 1.6 后默认为开启状态。自旋锁本质上与阻塞并不相同,先不考虑其对多处理器的要求,如果锁占用的时间非常的短,那么自旋锁的性能会非常的好,相反,其会带来更多的性能开销 (因为在线程自旋时,始终会占用 CPU 的时间片,如果锁占用的时间太长,那么自旋的线程会白白消耗掉 CPU 资源)。因此自旋等待的时间必须要有一定的限度,如果自旋超过了限定的次数仍然没有成功获取到锁,就应该使用传统的方式去挂起线程了,在 JDK 定义中,自旋锁默认的自旋次数为 10 次,用户可以使用参数 -XX:PreBlockSpin 来更改。

可是现在又出现了一个问题:如果线程锁在线程自旋刚结束就释放掉了锁,那么是不是有点得不偿失。所以这时候我们需要更加聪明的锁来实现更加灵活的自旋。来提高并发的性能。(这里则需要自适应自旋锁!)

自适应自旋锁

在 JDK 1.6 中引入了自适应自旋锁。这就意味着自旋的时间不再固定了,而是由前一次在同一个锁上的自旋 时间及锁的拥有者的状态来决定的。如果在同一个锁对象上,自旋等待刚刚成功获取过锁,并且持有锁的线程正在运行中,那么 JVM 会认为该锁自旋获取到锁的可能性很大,会自动增加等待时间。比如增加到 100 次循环。相反,如果对于某个锁,自旋很少成功获取锁。那再以后要获取这个锁时将可能省略掉自旋过程,以避免浪费处理器资源。有了自适应自旋,JVM 对程序的锁的状态预测会越来越准确,JVM 也会越来越聪明。

Synchronized 与 Lock

synchronized 的缺陷

  • 效率低:锁的释放情况少,只有代码执行完毕或者异常结束才会释放锁;试图获取锁的时候不能设定超时,不能中断一个正在使用锁的线程,相对而言,Lock 可以中断和设置超时
  • 不够灵活:加锁和释放的时机单一,每个锁仅有一个单一的条件 (某个对象),相对而言,读写锁更加灵活
  • 无法知道是否成功获得锁,相对而言,Lock 可以拿到状态,如果成功获取锁,….,如果获取失败,…..

    Lock 解决相应问题

    Lock 类这里不做过多解释,主要看里面的 4 个方法:

  • lock(): 加锁

  • unlock(): 解锁
  • tryLock(): 尝试获取锁,返回一个 boolean 值
  • tryLock(long,TimeUtil): 尝试获取锁,可以设置超时

Synchronized 只有锁只与一个条件 (是否获取锁) 相关联,不灵活,后来 Condition 与 Lock 的结合解决了这个问题。

多线程竞争一个锁时,其余未得到锁的线程只能不停的尝试获得锁,而不能中断。高并发的情况下会导致性能下降。ReentrantLock 的 lockInterruptibly () 方法可以优先考虑响应中断。 一个线程等待时间过长,它可以中断自己,然后 ReentrantLock 响应这个中断,不再让这个线程继续等待。有了这个机制,使用 ReentrantLock 时就不会像 synchronized 那样产生死锁了。
ReentrantLock 为常用类,它是一个可重入的互斥锁 Lock,它具有与使用 synchronized 方法和语句所访问的隐式监视器锁相同的一些基本行为和语义,但功能更强大。详细分析请看: JUC 锁: ReentrantLock 详解

再深入理解

synchronized 是通过 JVM 实现的,简单易用,即使在 JDK5 之后有了 Lock,仍然被广泛地使用。

  • 使用 Synchronized 有哪些要注意的?
    • 锁对象不能为空,因为锁的信息都保存在对象头里
    • 作用域不宜过大,影响程序执行的速度,控制范围过大,编写代码也容易出错
    • 避免死锁
    • 在能选择的情况下,既不要用 Lock 也不要用 synchronized 关键字,用 java.util.concurrent 包中的各种各样的类,如果不用该包下的类,在满足业务的情况下,可以使用 synchronized 关键,因为代码量少,避免出错
  • synchronized 是公平锁吗?

synchronized 实际上是非公平的,新来的线程有可能立即获得监视器,而在等待区中等候已久的线程可能再次等待,这样有利于提高性能,但是也可能会导致饥饿现象。