synchronized锁升级过程

前言

Java 1.6 时引入了“偏向锁” 和 “轻量级锁”,级别从低到高依次是 :无锁,偏向锁,轻量级锁,重量级锁。这些状态会随着竞争而升级。下面我们就实操来研究一下升级过程,不过需要一些前提知识

对象内存布局

我们都知道对象在堆里存放的,那么它的内部结构是怎样的呢,下面以64为操作系统来说明

首先对象包含对象头,实例数据,对齐填充。

  • 对象头,包含mark word 和 Klass 指针 ,在64位下,mark word 占 8个字节,Klass占8个字节如果对象是数组类型还会有一块标志数组长度的数据是4个字节
  • 实例数据,对象中包含的实例变量,不包括静态变量,静态变量不属于对象
  • 对齐填充,对象的大小都必须是8的整数倍,如果前两者不是8的整数倍就会在这里填充字节,使对象达到8的整数倍

对象头

  • Klass 指针,指向的是Klass对象,每一个类对应一个Klass对象,这个对象在类的加载过程中(详细的Klass请关注后续发文)
  • mark word 我们主要要研究是这个东西 ,它在对象头里占8个字节(注意是64位操作系统,后续不再赘述)我们来看一下它里面都会存什么东西

synchronized锁升级过程_第1张图片

 锁状态位都是2bit 其他部分存储的数据要依据状态位来决定,这是为了减少对象头占用过多的内存

查看对象内存布局

我们了解过对象头之后,那我们怎么查看对象头呢?

引入依赖


  org.openjdk.jol
  jol-core
  0.8

 使用一下代码就会打印出对象的信息了

  public static void main(String[] args) {


        Object obs = new Object();
        System.out.println(ClassLayout.parseInstance(obs).toPrintable());

    }

打印结果

synchronized锁升级过程_第2张图片

  根据结果我们可以看到对象头(标记Object header 的行) 有三行,每一行4个字节,一共12个字节,最后一行就是我们说的对齐填充,将12填充为16,但是我们上面说了mark word 8个字节 Kclass8个字节,但是我们却是12个字节,这是因为,jvm把klass指针压缩了,我们需要在jvm参数上来不让他压缩 -XX:-UseCompressedOops再次打印

synchronized锁升级过程_第3张图片

 这次再看就会发现对齐填充不见了,对象头一共16字节,后两行是klass指针,前两行是mark wod

升级流程

我们在前面打印了对象的内存布局但是我们可以发现,此时的对象是无锁状态,但是是否偏向却是0表示不偏向,这是怎么回事呢?这是因为偏向锁是延迟加载的,我们可以设置Jvm参数来不让它延迟加载 如图

synchronized锁升级过程_第4张图片

 我们可以看到 是否偏向的位已经置为1了 表示当前为偏向锁

单个线程获取锁

到此为止我们的前提工作处理完毕,我们先看一下偏向锁情况下的加锁会发生什么,有以下代码

public static void main(String[] args) throws InterruptedException {
    Object obs = new Object();
    System.out.println(ClassLayout.parseInstance(obs).toPrintable());
    //00000101 00000000 00000000 00000000 00000000 00000000 00000000 00000000
    new Thread(new Runnable() {
        @Override
        public void run() {
            synchronized (obs){
                System.out.println(ClassLayout.parseInstance(obs).toPrintable());
                //00000101 00111000 00100101 10110010 00111111 00000010 00000000 00000000
                synchronized (obs){
                    System.out.println(ClassLayout.parseInstance(obs).toPrintable());
                 //00000101 00111000 00100101 10110010 00111111 00000010 00000000 00000000
                }
            }
        }
    }).start();
    Thread.sleep(100);
    System.out.println(ClassLayout.parseInstance(obs).toPrintable());
   //00000101 00111000 00100101 10110010 00111111 00000010 00000000 00000000
}

我们开启一个线程,获取两次锁,我们只关注mark word 

加锁之前  00000101 00000000 00000000 00000000 00000000 00000000 00000000 00000000

此时锁是偏向的,但是并没有线程获得锁 , 锁标志位为前一个字节的后两位为01,是否偏向为前一个字节的倒数第3位为1 表示偏向

第一次加锁00000101 00111000 00100101 10110010 00111111 00000010 00000000 00000000     此时mark word 中会保存当前获得锁的线程的id (这个id并不是我们getId()后的值的二进制),标志获得锁的线程

第二次加锁00000101 00111000 00100101 10110010 00111111 00000010 00000000 00000000

当线程再次获得锁会判断mark word中的线程id是不是当前线程,是的话就直接获得锁,显然正是

释放锁后00000101 00111000 00100101 10110010 00111111 00000010 00000000 00000000

释放锁后mark word中还是保存着线程的id,当这个线程再次请求锁的时候就直接获取锁(前提是没有其他线程来获取这把锁)

两个线程交替获取锁

两个线程交替获得锁,第一个线程先获得锁,锁释放后,第二个线程再获得锁,此时是不存在锁竞争的,有以下代码

Object a = new Object();
System.out.println(ClassLayout.parseInstance(a).toPrintable());
//00000101 00000000 00000000 00000000 00000000 00000000 00000000 00000000
synchronized (a)
{
    System.out.println(ClassLayout.parseInstance(a).toPrintable());
    //00000101 11011000 00111001 00001111 11111110 00000001 00000000 00000000
    System.out.println("=======");
}
System.out.println(ClassLayout.parseInstance(a).toPrintable());
//00000101 11011000 00111001 00001111 11111110 00000001 00000000 00000000
new Thread(() -> {
    synchronized (a){
        System.out.println(ClassLayout.parseInstance(a).toPrintable());
        //11110000 11110101 10111111 11010010 00111101 00000000 00000000 00000000
        System.out.println("=======");
    }
}).start();
Thread.sleep(100);
System.out.println(ClassLayout.parseInstance(a).toPrintable());
//00000001 00000000 00000000 00000000 00000000 00000000 00000000 00000000

我们先让主线程获得锁,之后再创建线程获得锁,来确保主线程释放锁后,之后的线程再获取锁,这样不会出现锁竞争,我们来看一下打印的mark word信息

初始时00000101 00000000 00000000 00000000 00000000 00000000 00000000 00000000

依然是偏向锁的状态

main线程加锁00000101 11011000 00111001 00001111 11111110 00000001 00000000 00000000

此时mark wrod中存放的是mian线程的线程id

main释放锁00000101 11011000 00111001 00001111 11111110 00000001 00000000 00000000

依然保存偏向的线程id,保证下次main线程来获取锁,可以直接获取

新线程获得锁11110000 11110101 10111111 11010010 00111101 00000000 00000000 00000000

此时的锁标志位为00即轻量级锁状态,此时保存的是锁记录(后面详细讲解)

新线程释放锁00000001 00000000 00000000 00000000 00000000 00000000 00000000 00000000 

我们可以看到锁标志位 为01 即无锁状态,而且是否偏向被置为0(第一个字节的倒数第三位),说明当前的锁已经不再是偏向锁了,当再有线程来获取锁时就会是轻量级锁的流程了(前提是没有锁竞争或者竞争力度小)

两个线程小力度竞争锁

小力度竞争就是说,一个线程持有锁的时间非常短暂,另一个线程不必等待太久。我们来看一下这种情况下会发生什么,有以下代码

Object a = new Object();
System.out.println("初始时");
System.out.println(ClassLayout.parseInstance(a).toPrintable());
//00000101 00000000 00000000 00000000 00000000 00000000 00000000 00000000
new Thread(() -> {
    synchronized (a){
        System.out.println("t1进入同步块");
        //System.out.println(ClassLayout.parseInstance(a).toPrintable());
    }
}).start();
//Thread.sleep(100);
new Thread(() -> {
    synchronized (a){
        System.out.println("t2进入同步块");
        System.out.println(ClassLayout.parseInstance(a).toPrintable());
        //01000000 11110011 11101111 00001100 00000011 00000000 00000000 00000000
    }
}).start();
Thread.sleep(100);
System.out.println(ClassLayout.parseInstance(a).toPrintable());
//00000001 00000000 00000000 00000000 00000000 00000000 00000000 00000000

需要注意的是我们在第一个线程里打印头信息的代码注释了,这是为了让第一个线程持有锁的时间比较短暂,而打印头信息比较耗时,下面我们通过头信息来解释一下

初始时00000101 00000000 00000000 00000000 00000000 00000000 00000000 00000000

依然是可偏向状态

然后两个线程一起启动(基本同时第一个线程获得锁,会走偏向锁逻辑,将mark word中保存自己的线程id(虽然没有打印,但是上面已经证实过了),第一个线程获得锁后,第二个线程来获得锁时,也会尝试加偏向锁,但是偏向锁已经被其他线程获得了,第二个线程就会将锁标志置为00,即为轻量级锁,如果此时第一个线程依然持有锁那么Jvm就会在第一个线程中开辟锁记录的空间,将mark word中 gc年龄 hashcode 等信息存入第一个线程的锁记录中 然后让锁记录中的指针指向锁对象,锁对象中保存锁记录的指针,之后,第一个线程依然在执行,第二个线程就会 一直尝试获得当前锁,也会开辟锁记录尝试将mark word中的锁记录指针替换为自己的锁记录。第二个线程会在当前状态下自旋一段时间,如果自旋超过一定次数会升级重量锁(后面详细解锁),因为我们第一个线程获得锁的时间是短暂的,当第一个线程释放锁时,会把它锁记录中的 gc年龄 hashcode等信息再替换会mark word ,然后第二个线程在自旋没有结束时,就可以成功把自己的锁记录存入mark word 从而获取锁,此时依然是轻量级锁

第二个获得锁01000000 11110011 11101111 00001100 00000011 00000000 00000000 00000000

可以看到,锁标志为 00 轻量级锁 

第二个线程释放锁00000001 00000000 00000000 00000000 00000000 00000000 00000000 00000000

释放后已经不是偏向锁了,之后就会走轻量级锁的流程,因为我们没有调用hashcode,也没有gc所以除锁标志位其他都为0

那么锁记录和mark word中的锁记录指针是怎么回事呢?

synchronized锁升级过程_第5张图片

 

两个线程强力度竞争

上面我们说了,线程会自旋一段时间,如果超过了一定值的时候就会升级为重量级锁,那么我们让线程持有锁的时间加长

Object a = new Object();
System.out.println("初始时");
System.out.println(ClassLayout.parseInstance(a).toPrintable());
//00000101 00000000 00000000 00000000 00000000 00000000 00000000 00000000
new Thread(() -> {
    synchronized (a){
        System.out.println("t1进入同步块");
        System.out.println(ClassLayout.parseInstance(a).toPrintable());
        //00111010 11000110 10011000 10111000 10111011 00000001 00000000 00000000
    }
}).start();
//Thread.sleep(100);
new Thread(() -> {
    synchronized (a){
        System.out.println("t2进入同步块");
        System.out.println(ClassLayout.parseInstance(a).toPrintable());
        //00111010 11000110 10011000 10111000 10111011 00000001 00000000 00000000
    }
}).start();
Thread.sleep(100);
System.out.println(ClassLayout.parseInstance(a).toPrintable());
//00000001 00000000 00000000 00000000 00000000 00000000 00000000 00000000

因为前面的流程都和上面一样这里就不在赘述,我们直接从线程二自旋开始说起,刚刚说了线程二会自旋一段时间,如果自旋过程中没有获得锁就会升级重量级锁,JVM会创建一个monitor对象这是一个c++对象,然后锁状态变为10 mark word中保存指向monitor的指针,monitor会保存一个队列来保存没有获得锁的对象,线程二就会进入这个队列,然后monitor中也会保存当前持有锁的线程,即为线程1,保存gc年龄 还有 hashcode值,除了这些之外还会有一个wait队列,来保存调用了wait方法的线程,当其他线程获得锁调用notify方法或notifyall方法会唤醒wait队列中的线程。

目前为止,我们现在线程二在monitor的队列中阻塞,线程一正在执行代码块,当线程一执行完毕释放锁的时候,发现当前锁已经升级为重量级锁,就去去monitor中将当前持有锁的线程置为null

然后唤醒队列中等待的线程。

synchronized锁升级过程_第6张图片

 

当所有线程都执行完毕时mark word 又会变成00000001 00000000 00000000 00000000 00000000 00000000 00000000 00000000等待一下次锁升级

你可能感兴趣的:(锁,java)