Synchronized原理

781 阅读10分钟

前提

对象头

首先我们要了解什么是对象头,因为synchronized锁信息是存放在对象头的。 对象头由 markwordklassword 组成,而我们锁的信息都存储在 markword 中,所以主要研究 markword 就可以了。下面这张图分别展示了 32位和 64 位虚拟机下 markword 的结构。很重要,建议一定要记下来。

image.png 这里我主要介绍 64 位虚拟机。我们通过图可以发现在 64 为虚拟机下 markword 的大小为 8byte(56bit + 1bit + 4bit + 1bit + 2bit = 64bit = 8byte),这个下文会用到。

锁介绍

通过上张图,我们很容易发现一个简单的 Sychronized 包括各种锁状态(无锁、偏向锁、轻量级锁和重量级锁),这些锁状态就是对 Sychronized 的优化,接下来我们来逐一介绍。

准备工作

为了让结果更明显,我们首先要引用 jol,然后用 idea 通过程序将我们需要的对象信息打印出来。 Maven

    <dependency>
      <groupId>org.openjdk.jol</groupId>
      <artifactId>jol-core</artifactId>
      <version>0.8</version>
    </dependency>

然后创建一个 Class A

    public class A {
        boolean b = false;
    }

测试程序

    package syc;
    import org.openjdk.jol.info.ClassLayout;
    import org.openjdk.jol.vm.VM;

    import static java.lang.System.out;
    public class SycTest {
        static A a = new A();
        public static void main(String[] args) {
            out.println(VM.current().details());
            System.out.println(ClassLayout.parseInstance(a).toPrintable());
        }
    }

如果您懒得弄也可以看我接下来的测试结果及分析。

无锁

上面代码运行结果为:

1615518894383.jpg 当新创建一个对象的时候,如果该对象所属的class没有关闭偏向锁模式,那新创建对象的mark word将是可偏向状态,此时mark word中的thread id(参见上图偏向状态下的mark word格式)为0,锁标志位为 01,表示未偏向任何线程,也叫做匿名偏向(anonymously biased)。 我们只看前 8 个bit(00000001),根据 mark word 结构图分析

| 占位 | 年龄 | 是否偏向 | 锁标志位 | | --- | --- --- | --- | | 0 | 0000| 0 | 01

偏向锁

偏向锁首先是没有办法发提供线程互斥的,那么为什么还要引用这把锁呢?

因为在大多数情况下,我们认为会有并发问题并用 synchronized 修饰的代码块其实只有一个线程在运行。所以直接使用轻量级锁会有性能损耗,因为轻量级锁加锁或者解锁都需要一次 CAS 操作,而偏向锁解锁时不需要修改对象头的 markword,就少一次 CAS 操作。如果当前线程反复调用一个加锁的方法,发现其对象内已经有偏向自己的记录,那么直接运行代码。这些操作都可以提升效率。

那么,偏向锁执行完代码块之后,会释放这个偏向锁吗?
退出同步代码块,对应的字节码指令是 monitorexit。jvm 处理这个指令时,第一步是将当前线程栈内与当前锁相关的锁记录全部拿到,将最后一条锁记录释放掉。通过检查 lock 对象的 markword,如果当前锁对象是偏向锁状态,就啥也不做。也就是说就算线程退出同步代码块了,锁对象仍然保留了偏向当前状态的偏向锁。

如果说某个锁对象之前被线程 A 获取过,它的锁状态是偏向 A 的。那假线程 B 也要获取这个锁对象,由于锁对象并不偏向线程 B,所以会触发锁升级的逻辑。线程 B 会触发偏向锁升级轻量级锁的逻辑。

轻量级锁

JVM的开发者发现在很多情况下,在Java程序运行时,同步块中的代码都是不存在竞争的,不同的线程交替的执行同步块中的代码。这种情况下,没有必要阻塞线程(重量级锁)。因此JVM引入了轻量级锁的概念。

当关闭偏向锁功能或者多个线程竞争偏向锁导致锁升级为轻量级锁,则会尝试获取轻量级锁,其步骤如下:获取锁

  1. 判断当前对象是否处于无锁状态(hashcode、0、01),如果是,则 JVM 首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的 Mark Word 的拷贝(即 Displaced Mark Word),将 Displaced Mark Word 复制到 Lock Record 中,将 Lock Recod 中的 owner 指向当前对象。
  2. JVM 利用 CAS 操作尝试将对象的 Mark Word 更新为指向 Lock Record 的指针,如果成功表示竞争到锁,则将锁标志位变成 00,执行同步操作。
  3. 如果第 2 步 CAS 失败则判断当前对象的 Mark Word 是否指向当前线程的栈帧,如果是则表示当前线程已经持有当前对象的锁,则直接执行同步代码块;否则只能说明该锁对象已经被其他线程抢占了,这时,轻量级锁需要膨胀微重量级锁,锁标志位变成 10,后面等待的线程将会进入阻塞状态。

轻量级锁的释放

轻量级锁释放也是通过 CAS 操作来进行的,主要步骤如下:

  1. 取出在获取轻量级锁保存在 Displaced Mark Word 中的数据;
  2. 用 CAS 操作将取出的数据替换当前对象的 Mark Word 中,如果成功,则说明释放锁成功。
  3. 如果 CAS 操作替换失败,说明有其他线程尝试获取该锁,则需要将轻量级锁膨胀升级为重量级锁。

重量级锁

重量级锁是我们常说的传统意义上的锁,其利用操作系统底层的同步机制去实现Java中的线程同步。synchronized 底层是利用 monitor 对象,CAS 和 mutex 互斥锁来实现的,内部会有等待队列(cxq 和 EntryList)和条件等待队列(waitSet)来存放相应阻塞的线程。

未竞争到锁的线程存储到等待队列中,获得锁的线程调用 wait 后便存放在条件等待队列中,解锁和 notify 都会唤醒相应队列中的等待线程来争抢锁。

然后由于阻塞和唤醒依赖于底层的操作系统实现,系统调用存在用户态与内核态之间的切换,所以有较高的开销,因此称之为重量级锁。

所以又引入了自适应自旋机制,来提高锁的性能。

自旋锁

monitor 实现锁的时候,会阻塞和唤醒线程,线程的阻塞和唤醒需要 CPU 从用户态转为核心态,频繁的阻塞和唤醒对 CPU 来说是一件负担很重的工作,这些操作给系统的并发性带来很大的压力。同时,通过调研发现共享数据的锁定状态只会持续很短的时间,为了这段时间阻塞和唤醒线程并不值得。所以,让两个或以上的线程同时并行执行,我们就可以让后面请求锁的那个线程“稍微等一下”,但不放弃处理器的执行时间,看看持有锁的线程是否很快就释放锁。为了让线程等待,我们需要让线程执行一个自旋,这项技术就是所谓的自旋锁。

我用白话讲一下:比如有两个线程 t1 和 t2,他们共同竞争一个锁对象。t1 先进去了那么 t2 就要阻塞。但是经过研究发现我们执行共享代码块的时间很短。假如没有自旋锁的情况 t2 抢占失败会去阻塞,还没等阻塞式完呢 t1 执行完成了。那 t2 就要先阻塞完再唤醒,这个过程很浪费性能。所以我们现在只需要让 t2 自旋等待一下 t1,如果在自旋期间 t1 执行完了我们就直接抢占不需要再阻塞了。

在 JDK6 后默认是开启自旋锁的。自旋次数默认值是 10 次,用户可以通过使用参数 -XX:PreBlockSpin 来更改。那么让外面的线程自旋多少次呢?自旋多了也会有 CPU 性能开销,自旋少了又让锁进入阻塞。所以自旋次数很难选择。

适应性自旋

自适应自旋锁意味着自选时间不再固定了,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋很有可能再次成功,进而它将允许自旋等待持续更长时间。另外,如果对于某个锁,自旋很少成功获得,那么以后要获取这个锁时将可能省略掉自旋过程,以避免浪费处理器资源。

synchronized 用法

synchronized 的用法呢大家可能都有所了解,总的来说就是锁对象和锁方法。

锁对象

public class SynchronizedMethod1 {
    private static int i = 0;
    public void method(){
        synchronized (SynchronizedMethod1.class){
            i++;
    }
}

sync2.jpeg 从反编译的同步代码块可以看到同步块是由 monitorenter 指令进入,然后 monitorexit 释放锁,在执行 monitorenter 之前需要尝试获取锁,如果这个对象没有被锁定,或者当前线程已经拥有了这个对象的锁,那么就把锁的计数器加1。当执行 monitorexit 指令时,锁的计数器也会减1。当获取锁失败时会被阻塞,一直等待锁被释放。

但是为什么会有两个 monitorexit 呢?其实第二个 monitorexit 是来处理异常的,仔细看反编译的字节码,正常情况下第一个 monitorexit 之后会执行后面指令,而该指令转向的就是 23 行的return,也就是说正常情况下只会执行第一个 monitorexit 释放锁,然后返回。而如果在执行中发生了异常,第二个 monitorexit 就起作用了,它是由编译器自动生成的,在发生异常时处理异常然后释放掉锁。

锁方法

public class SynchronizedMethod2 {
    public synchronized void method() {
        System.out.println("Hello World!");
    }
}

sycTest1.png 从反编译的结果来看,方法的同步并没有通过指令monitorenter和monitorexit来完成,不过相对于普通方法,其常 flags 多了 ACC_SYNCHRONIZED 标示符。JVM就是根据该标示符来实现方法的同步的:当方法调用时,调用指令将会检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程将先获取monitor,获取成功之后才能执行方法体,方法执行完后再释放 monitor。在方法执行期间,其他任何线程都无法再获得同一个 monitor 对象。

结语

synchronized 的东西没想到这么多,而且网上也没有很详细的解读。我尽量去找适合自己理解的文章和视频了,但其实还是有很多内容没有找到。接下来会持续关注 synchronized 的相关资料,如果有收获,会继续补充到这篇文章里。

站在巨人肩膀上