前提
对象头
首先我们要了解什么是对象头,因为synchronized锁信息是存放在对象头的。 对象头由 markword 和 klassword 组成,而我们锁的信息都存储在 markword 中,所以主要研究 markword 就可以了。下面这张图分别展示了 32位和 64 位虚拟机下 markword 的结构。很重要,建议一定要记下来。
这里我主要介绍 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());
}
}
如果您懒得弄也可以看我接下来的测试结果及分析。
无锁
上面代码运行结果为:
当新创建一个对象的时候,如果该对象所属的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引入了轻量级锁的概念。
当关闭偏向锁功能或者多个线程竞争偏向锁导致锁升级为轻量级锁,则会尝试获取轻量级锁,其步骤如下:获取锁
- 判断当前对象是否处于无锁状态(hashcode、0、01),如果是,则 JVM 首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的 Mark Word 的拷贝(即 Displaced Mark Word),将 Displaced Mark Word 复制到 Lock Record 中,将 Lock Recod 中的 owner 指向当前对象。
- JVM 利用 CAS 操作尝试将对象的 Mark Word 更新为指向 Lock Record 的指针,如果成功表示竞争到锁,则将锁标志位变成 00,执行同步操作。
- 如果第 2 步 CAS 失败则判断当前对象的 Mark Word 是否指向当前线程的栈帧,如果是则表示当前线程已经持有当前对象的锁,则直接执行同步代码块;否则只能说明该锁对象已经被其他线程抢占了,这时,轻量级锁需要膨胀微重量级锁,锁标志位变成 10,后面等待的线程将会进入阻塞状态。
轻量级锁的释放
轻量级锁释放也是通过 CAS 操作来进行的,主要步骤如下:
- 取出在获取轻量级锁保存在 Displaced Mark Word 中的数据;
- 用 CAS 操作将取出的数据替换当前对象的 Mark Word 中,如果成功,则说明释放锁成功。
- 如果 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++;
}
}
从反编译的同步代码块可以看到同步块是由 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!");
}
}
从反编译的结果来看,方法的同步并没有通过指令monitorenter和monitorexit来完成,不过相对于普通方法,其常 flags 多了 ACC_SYNCHRONIZED 标示符。JVM就是根据该标示符来实现方法的同步的:当方法调用时,调用指令将会检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程将先获取monitor,获取成功之后才能执行方法体,方法执行完后再释放 monitor。在方法执行期间,其他任何线程都无法再获得同一个 monitor 对象。
结语
synchronized 的东西没想到这么多,而且网上也没有很详细的解读。我尽量去找适合自己理解的文章和视频了,但其实还是有很多内容没有找到。接下来会持续关注 synchronized 的相关资料,如果有收获,会继续补充到这篇文章里。
站在巨人肩膀上
- [Java并发编程:Synchronized及其实现原理]www.cnblogs.com/paddix/p/53…
- [关于 Synchronized 的一个点,网上99%的文章都错了]mp.weixin.qq.com/s/3PBGQBR9D…
- [死磕 Synchronized 底层实现]mp.weixin.qq.com/s/E8qOcBz5G…
- [深入理解synchronized底层原理,一篇文章就够了!]cloud.tencent.com/developer/a…