面试官:谈谈你对 Synchronized 的理解吧

188 阅读8分钟

前言

我们在开发中肯定会遇到在同一个 JVM 中,存在多个线程同时操作同一个资源时,此时需要想要确保操作的结果满足预期,就需要使用同步方法。 官方解释:同步方法支持一种简单的策略来防止线程干扰和内存一致性错误:如果一个对象对多个线程可见,则对该对象变量的所有读取或写入都是通过同步方法完成的。 官方推荐使用的同步方法 (JDK 1.6后):Synchronized 基于 JVM 实现(此次主角);当然还有 ReentrantLock 基于 JDK 实现的。

我们先简单地热个身,举一个常用 synchronized 的方式(锁的是该类的实例对象

public class SynchronizedCodeTest {

    public void testSynchronized() throws InterruptedException {
        synchronized (this) {
            System.out.println("进入同步代码块");
            Thread.sleep(100);
            System.out.println("离开同步代码块");
        }
    }

    public static void main(String[] args) throws InterruptedException {
        new SynchronizedCodeTest().testSynchronized();
    }

}

Synchronized 常用场景

任何对象(都有Mark Word结构,后面会详细描述) 都可以能作为 synchronized 锁的对象,根据使用的方式不同,锁的对象和对应的粒度也是有所不同

并发编程三大特性

简单回顾了下 synchronized ,一聊到锁就会提到 原子性、有序性、可见性,简单的介绍下这些(就不具体展开说明了,有需要的读者可以查阅相关资料,或者感兴趣的话我后续补充)

原子性

原子性:一个操作或者多个操作 要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。简单理解为:如果将下单和支付2个操作看作一个整体,只要其中一个操作失败了,都算失败,反之成功。

有序性

有序性:即程序执行的顺序按照代码的先后顺序执行。大家可能或多或少,有听说过 Java 为了提高性能允许重排序(编译器重排序 和 处理器重排序),因此程序执行可能出现乱序也是由此而来。简单理解为:有序性保证了 同样的代码 在多线程和单线程执行的最后结果相同,按照代码的先后顺序执行

可见性

可见性:当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。

某个类的一个成员变量 Integer A = 0;

# 线程1执行操作
  A = 10# 与此同时 线程2执行操作(B的值是0,而不是10,这就是可见性的问题)
  Integer B = A; 

# 常用的解决方案使用:volatile修饰 A 或者 使用synchronized修饰代码块 都可以解决这个问题

既然提到 synchronized 再多延伸出2个特性

可重入性

synchronized monitor(锁对象) 有个计数器,获取锁时 会记录当前线程获取锁的次数,在执行完对应的代码块之后,计数器就会-1,直到计数器清零,锁就会被释放了。

不可中断性

不可中断:一个线程获取锁之后,另外一个线程处于阻塞或者等待状态,前一个不释放,后一个也一直会阻塞或者等待,不可以被中断。Synchronized是不可中断,而 ReentrantLock是可中断(二者比较重要的区别之一)

Synchronized 字节码

介绍完一些基本的特性后,我们正式开始进入 synchronized 实现原理分析

# 将上面 热身例子反编译成字节码
javac -verbose SynchronizedCodeTest.java
javap -c SynchronizedCodeTest

我们主要关注下,monitorentermonitorexit 这2个指令,对应的是 当前线程获取锁&计数器加一释放锁&计数器减一。多个线程获取对象的监视器monitor获取是互斥。

对象,对象监视器,同步队列以及执行线程状态之间的关系

任意线程对 Object 的访问,首先要获得 Object 的监视器,如果获取失败,该线程就进入同步状态,线程状态变为 BLOCKED,当 Object 的监视器占有者释放后,在同步队列中得线程就会有机会重新获取该监视器。

Java 对象头(Mark Word)

前面提到所有对象都可以作为synchronized锁的对象,在同步的时候是获取对象的monitor,即操作Java对象头里的Mark Word 。下面是32位为JVM Mark Word默认存储结构(无锁状态)

  • 对象的 hashCode:25位的对象标识hashCode,采用延迟加载技术。调用方法System.identityHashCode()计算,并会将结果写到该对象头中。当对象加锁后(偏向、轻量级、重量级),MarkWord的字节没有足够的空间保存hashCode,因此该值会移动到管程Monitor中。

  • 对象分代年龄 :4位的Java对象年龄。每次 GC 未被回收累加的年龄就记录在此处,默认达到15次进入老年代(-XX:MaxTenuringThreshold 可通过该配置进行修改进入老年代的阈值,最大值为15**[age 只有 4bit]**)

  • 是否是偏向锁:1位的偏向锁标志位

  • 锁标志位:2位锁标志位,4种标志位后面展示说明

锁一共有4种状态,级别从低到高依次是:无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态,这几个状态会随着竞争情况逐渐升级。锁可以升级但不能降级,意味着偏向锁升级成轻量级锁后不能降级成偏向锁。这种锁升级不能降级的策略,目的是为了提高获得锁和释放锁的效率

接下来分别介绍这三种锁的实现原理和步骤与上图结合思考

偏向锁

HotSpot的作者经过研究发现,大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低而引入了偏向锁。

偏向锁的获取

当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程ID,以后该线程在进入和退出同步块时不需要进行CAS操作来加锁和解锁,只需简单地测试一下对象头的Mark Word里是否存储着指向当前线程的偏向锁。如果测试成功,表示线程已经获得了锁。如果测试失败,则需要再测试一下Mark Word中偏向锁的标识是否设置成1(表示当前是偏向锁):如果没有设置,则使用CAS竞争锁;如果设置了,则尝试使用CAS将对象头的偏向锁指向当前线程。

偏向锁的撤销

偏向锁使用了一种等到竞争出现才释放锁的机制,所以当其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁。

偏向锁的获得和撤销流程

线程1--展示了偏向锁获取的过程线程2--展示了偏向锁撤销的过程

轻量级锁

轻量级锁介于 偏向锁与重量级锁之间,竞争的线程不会阻塞。

轻量级加锁

线程在执行同步块之前,JVM会先在当前线程的栈桢中创建用于存储锁记录的空间,并将对象头中的Mark Word复制到锁记录中,官方称为 Displaced Mark Word。然后线程尝试使用CAS将对象头中的Mark Word替换为指向锁记录的指针。如果成功,当前线程获得锁,如果失败,表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁。

轻量级解锁

轻量级解锁时,会使用原子的CAS操作将Displaced Mark Word替换回到对象头,如果成功,则表示没有竞争发生。如果失败,表示当前锁存在竞争,锁就会膨胀成重量级锁。下图是两个线程同时争夺锁,导致锁膨胀的流程图。

轻量级锁及膨胀流程图

因为自旋会消耗CPU,为了避免无用的自旋(比如获得锁的线程被阻塞住了),一旦锁升级成重量级锁,就不会再恢复到轻量级锁状态。 当锁处于这个状态下,其他线程试图获取锁时,都会被阻塞住,当持有锁的线程释放锁之后会唤醒这些线程,被唤醒的线程就会进行新一轮的夺锁之争。

重量级锁

Synchronized 是通过对象内部的一个叫做 监视器锁(Monitor)来实现的但是监视器锁本质又是依赖于底层的操作系统的Mutex Lock来实现的。而操作系统实现线程之间的切换这就需要从用户态转换到核心态,这个成本非常高,状态之间的转换需要相对比较长的时间,这就是为什么Synchronized效率低的原因。因此,这种依赖于操作系统 Mutex Lock 所实现的锁我们称之为 “重量级锁”

三种锁的比较

分析了原理后,选择哪种锁就得看对应适用场景决定

最后提一个 Synchronzied 避坑点(美团大佬分享):如果你的系统有很明确的 高低峰期,不建议使用 Synchronized,可以考虑使用 ReentrantLock。原因是 上面提到过 Synchronized 锁的膨胀是不可逆的,导致一旦经历了高峰期后就一直是重量级锁,性能也会由此一直达到一个瓶颈上不去了。


我的微信公众号:Java架构师进阶编程
专注分享Java技术干货,期待你的关注!