乐趣区

关于aqs:谈谈JVM内部锁升级过程

简介:对象在内存中的内存布局是什么样的?如何形容 synchronized 和 ReentrantLock 的底层实现和重入的底层原理?为什么 AQS 底层是 CAS+volatile?锁的四种状态和锁降级过程应该如何形容?Object o = new Object() 在内存中占用多少字节?自旋锁是不是肯定比重量级锁效率高?关上偏差锁是否效率肯定会晋升?重量级锁到底重在哪里?重量级锁什么时候比轻量级锁效率高,同样反之呢?带着这些问题往下读。

作者 | 洋锅
起源 | 阿里技术公众号

一 为什么讲这个?

总结 AQS 之后,对这方面顺带的温习一下。本文从以下几个高频问题登程:

  • 对象在内存中的内存布局是什么样的?
  • 形容 synchronized 和 ReentrantLock 的底层实现和重入的底层原理。
  • 谈谈 AQS,为什么 AQS 底层是 CAS+volatile?
  • 形容下锁的四种状态和锁降级过程?
  • Object o = new Object() 在内存中占用多少字节?
  • 自旋锁是不是肯定比重量级锁效率高?
  • 关上偏差锁是否效率肯定会晋升?
  • 重量级锁到底重在哪里?
  • 重量级锁什么时候比轻量级锁效率高,同样反之呢?

二 加锁产生了什么?

有意识中用到锁的状况:

//System.out.println 都加了锁
public void println(String x) {synchronized (this) {print(x);
    newLine();}
}

简略加锁产生了什么?

要弄清楚加锁之后到底产生了什么须要看一下对象创立之后再内存中的布局是个什么样的?

一个对象在 new 进去之后在内存中次要分为 4 个局部:

  • markword 这部分其实就是加锁的外围,同时还蕴含的对象的一些生命信息,例如是否 GC、通过了几次 Young GC 还存活。
  • klass pointer 记录了指向对象的 class 文件指针。
  • instance data 记录了对象外面的变量数据。
  • padding 作为对齐应用,对象在 64 位服务器版本中,规定对象内存必须要能被 8 字节整除,如果不能整除,那么就靠对齐来补。举个例子:new 出了一个对象,内存只占用 18 字节,然而规定要能被 8 整除,所以 padding=6。

晓得了这 4 个局部之后,咱们来验证一下底层。借助于第三方包 JOL = Java Object Layout java 内存布局去看看。很简略的几行代码就能够看到内存布局的款式:

public class JOLDemo {
    private static Object  o;
    public static void main(String[] args) {o = new Object();
        synchronized (o){System.out.println(ClassLayout.parseInstance(o).toPrintable());
        }
    }
}

将后果打印进去:

从输入后果看:

1)对象头蕴含了 12 个字节分为 3 行,其中前 2 行其实就是 markword,第三行就是 klass 指针。值得注意的是在加锁前后输入从 001 变成了 000。Markword 用途:8 字节 (64bit) 的头记录一些信息,锁就是批改了 markword 的内容 8 字节 (64bit) 的头记录一些信息,锁就是批改了 markword 的内容字节 (64bit) 的头记录一些信息。从 001 无锁状态,变成了 00 轻量级锁状态。

2)New 出一个 object 对象,占用 16 个字节。对象头占用 12 字节,因为 Object 中没有额定的变量,所以 instance = 0,思考要对象内存大小要被 8 字节整除,那么 padding=4,最初 new Object() 内存大小为 16 字节。

拓展:什么样的对象会进入老年代?很多场景例如对象太大了能够间接进入,然而这里想探讨的是为什么从 Young GC 的对象最多经验 15 次 Young GC 还存活就会进入 Old 区(年龄是能够调的,默认是 15)。上图中 hotspots 的 markword 的图中,用了 4 个 bit 去示意分代年龄,那么能示意的最大范畴就是 0 -15。所以这也就是为什么设置新生代的年龄不能超过 15,工作中能够通过 -XX:MaxTenuringThreshold 去调整,然而个别咱们不会动。

三 锁的降级过程

1 锁的降级验证

探讨锁的降级之前,先做个试验。两份代码,不同之处在于一个中途让它睡了 5 秒,一个没睡。看看是否有区别。

public class JOLDemo {
    private static Object  o;
    public static void main(String[] args) {o = new Object();
        synchronized (o){System.out.println(ClassLayout.parseInstance(o).toPrintable());
        }
    }
}
----------------------------------------------------------------------------------------------
public class JOLDemo {
    private static Object  o;
    public static void main(String[] args) {try { Thread.sleep(5000); } catch (InterruptedException e) {e.printStackTrace(); }
        o = new Object();
        synchronized (o){System.out.println(ClassLayout.parseInstance(o).toPrintable());
        }
    }
}

这两份代码会不会有什么区别?运行之后看看后果:


有点意思的是,让主线程睡了 5s 之后输入的内存布局跟没睡的输入后果竟然不一样。

Syn 锁降级之后,jdk1.8 版本的一个底层默认设置 4s 之后偏差锁开启。也就是说在 4s 内是没有开启偏差锁的,加了锁就间接降级为轻量级锁了。

那么这里就有几个问题了?

  • 为什么要进行锁降级,以前不是默认 syn 就是重量级锁么?要么不必要么就用别的不行么?
  • 既然 4s 内如果加了锁就间接到轻量级,那么能不能不要偏差锁,为什么要有偏差锁?
  • 为什么要设置 4s 之后开始偏差锁?

问题 1:为什么要进行锁降级?锁了就锁了,不就要加锁么?

首先明确早起 jdk1.2 效率非常低。那时候 syn 就是重量级锁,申请锁必须要通过操作系统老大 kernel 进行零碎调用,入队进行排序操作,操作完之后再返回给用户态。

内核态:用户态如果要做一些比拟危险的操作间接拜访硬件,很容易把硬件搞死(格式化,拜访网卡,拜访内存干掉、)操作系统为了系统安全分成两层,用户态和内核态。申请锁资源的时候用户态要向操作系统老大内核态申请。Jdk1.2 的时候用户须要跟内核态申请锁,而后内核态还会给用户态。这个过程是十分耗费工夫的,导致晚期效率特地低。有些 jvm 就能够解决的为什么还交给操作系统做去呢?能不能把 jvm 就能够实现的锁操作拉取出来晋升效率,所以也就有了锁优化。

问题 2:为什么要有偏差锁?

其实这实质上归根于一个概率问题,统计示意,在咱们日常用的 syn 锁过程中 70%-80% 的状况下,个别都只有一个线程去拿锁,例如咱们常应用的 System.out.println、StringBuffer,尽管底层加了 syn 锁,然而根本没有多线程竞争的状况。那么这种状况下,没有必要降级到轻量级锁级别了。偏差的意义在于:第一个线程拿到锁,将本人的线程信息标记在锁上,下次进来就不须要在拿去拿锁验证了。如果超过 1 个线程去抢锁,那么偏差锁就会撤销,降级为轻量级锁,其实我认为严格意义上来讲偏差锁并不算一把真正的锁,因为只有一个线程去访问共享资源的时候才会有偏差锁这个状况。

无心应用到锁的场景:

/***StringBuffer 外部同步 ***/
public synchronized int length() {return count;} 

//System.out.println 有意识的应用锁
public void println(String x) {synchronized (this) {print(x);
     newLine();}
 }

问题 3:为什么 jdk8 要在 4s 后开启偏差锁?

其实这是一个斗争,明确晓得在刚开始执行代码时,肯定有好多线程来抢锁,如果开了偏差锁效率反而升高,所以下面程序在睡了 5s 之后偏差锁才凋谢。为什么加偏差锁效率会升高,因为中途多了几个额定的过程,上了偏差锁之后多个线程争抢共享资源的时候要进行锁降级到轻量级锁,这个过程还的把偏差锁进行撤销在进行降级,所以导致效率会升高。为什么是 4s?这是一个统计的工夫值。

当然咱们是能够禁止偏差锁的,通过配置参数 -XX:-UseBiasedLocking = false 来禁用偏差锁。jdk15 之后默认曾经禁用了偏差锁。本文是在 jdk8 的环境下做的锁降级验证。

2 锁的降级流程

下面曾经验证了对象从创立进去之后进内存从无锁状态 -> 偏差锁(如果开启了)-> 轻量级锁的过程。对于锁降级的流程持续往下,轻量级锁之后就会变成重量级锁。首先咱们先了解什么叫做轻量级锁,从一个线程抢占资源(偏差锁)到多线程抢占资源降级为轻量级锁,线程如果没那么多的话,其实这里就能够了解为 CAS,也就是咱们说的 Compare and Swap,比拟并替换值。在并发编程中最简略的一个例子就是并发包上面的原子操作类 AtomicInteger。在进行相似 ++ 操作的时候,底层其实就是 CAS 锁。

public final int getAndIncrement() {return unsafe.getAndAddInt(this, valueOffset, 1);
}

public final int getAndAddInt(Object var1, long var2, int var4) {
   int var5;
   do {var5 = this.getIntVolatile(var1, var2);
   } while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));

   return var5;
}

问题 4:什么状况下轻量级锁要降级为重量级锁呢?

首先咱们能够思考的是多个线程的时候先开启轻量级锁,如果它 carry 不了的状况下才会降级为重量级。那么什么状况下轻量级锁会 carry 不住。1、如果线程数太多,比方上来就是 10000 个,那么这里 CAS 要转多久才可能替换值,同时 CPU 光在这 10000 个活着的线程中来回切换中就消耗了微小的资源,这种状况下天然就降级为重量级锁,间接叫给操作系统入队治理,那么就算 10000 个线程那也是解决休眠的状况期待排队唤醒。2、CAS 如果自旋 10 次仍然没有获取到锁,那么也会降级为重量级。

总的来说 2 种状况会从轻量级降级为重量级,10 次自旋或期待 cpu 调度的线程数超过 cpu 核数的一半,主动降级为重量级锁。看服务器 CPU 的核数怎么看,输出 top 指令,而后按 1 就能够看到。

问题 5:都说 syn 为重量级锁,那么到底重在哪里?

JVM 偷懒把任何跟线程无关的操作全副交给操作系统去做,例如调度锁的同步间接交给操作系统去执行,而在操作系统中要执行先要入队,另外操作系统启动一个线程时须要耗费很多资源,耗费资源比拟重,重就重在这里。

整个锁降级过程如图所示:

四 synchronized 的底层实现

下面咱们对对象的内存布局有了一些理解之后,晓得锁的状态次要寄存在 markword 外面。这里咱们看看底层实现。

public class RnEnterLockDemo {public void method() {synchronized (this) {System.out.println("start");
         }
     }
}

对这段简略代码进行反解析看看什么状况。javap -c RnEnterLockDemo.class

首先咱们能确定的是 syn 必定是还有加锁的操作,看到的信息中呈现了 monitorenter 和 monitorexit,主观上就能够猜到这是跟加锁和解锁相干的指令。有意思的是 1 个 monitorenter 和 2 个 monitorexit。为什么呢?失常来说应该就是一个加锁和一个开释锁啊。其实这里也体现了 syn 和 lock 的区别。syn 是 JVM 层面的锁,如果异样了不必本人开释,jvm 会主动帮忙开释,这一步就取决于多进去的那个 monitorexit。而 lock 异样须要咱们手动补获并开释的。

对于这两条指令的作用,咱们间接参考 JVM 标准中形容:

monitorenter:
Each object is associated with a monitor. A monitor is locked if and only if it has an owner. The thread that executes monitorenter attempts to gain ownership of the monitor associated with objectref, as follows: • If the entry count of the monitor associated with objectref is zero, the thread enters the monitor and sets its entry count to one. The thread is then the owner of the monitor. • If the thread already owns the monitor associated with objectref, it reenters the monitor, incrementing its entry count. • If another thread already owns the monitor associated with objectref, the thread blocks until the monitor’s entry count is zero, then tries again to gain ownership

翻译一下:

每个对象有一个监视器锁(monitor)。当 monitor 被占用时就会处于锁定状态,线程执行 monitorenter 指令时尝试获取 monitor 的所有权,过程如下:

  • 如果 monitor 的进入数为 0,则该线程进入 monitor,而后将进入数设置为 1,该线程即为 monitor 的所有者。
  • 如果线程曾经占有该 monitor,只是从新进入,则进入 monitor 的进入数加 1。
  • 如果其余线程曾经占用了 monitor,则该线程进入阻塞状态,直到 monitor 的进入数为 0,再从新尝试获取 monitor 的所有权。

monitorexit:
The thread that executes monitorexit must be the owner of the monitor associated with the instance referenced by objectref. The thread decrements the entry count of the monitor associated with objectref. If as a result the value of the entry count is zero, the thread exits the monitor and is no longer its owner. Other threads that are blocking to enter the monitor are allowed to attempt to do so.

翻译一下:

执行 monitorexit 的线程必须是 objectref 所对应的 monitor 的所有者。指令执行时,monitor 的进入数减 1,如果减 1 后进入数为 0,那线程退出 monitor,不再是这个 monitor 的所有者。其余被这个 monitor 阻塞的线程能够尝试去获取这个 monitor 的所有权。

通过这段话的形容,很分明的看出 Synchronized 的实现原理,Synchronized 底层通过一个 monitor 的对象来实现,wait/notify 等办法其实也依赖于 monitor 对象,这就是为什么只有在同步的块或者办法中能力调用 wait/notify 等办法,否则会抛出 java.lang.IllegalMonitorStateException 的异样。

每个锁对象领有一个锁计数器和一个指向持有该锁的线程的指针。

当执行 monitorenter 时,如果指标对象的计数器为零,那么阐明它没有被其余线程所持有,Java 虚构机会将该锁对象的持有线程设置为以后线程,并且将其计数器加 i。在指标锁对象的计数器不为零的状况下,如果锁对象的持有线程是以后线程,那么 Java 虚拟机能够将其计数器加 1,否则须要期待,直至持有线程开释该锁。当执行 monitorexit 时,Java 虚拟机则需将锁对象的计数器减 1。计数器为零代表锁已被开释。

总结

以往的教训中,只有用到 synchronized 就认为它曾经成为了重量级锁。在 jdk1.2 之前的确如此,起初发现太重了,耗费了太多操作系统资源,所以对 synchronized 进行了优化。当前能够间接用,至于锁的力度如何,JVM 底层曾经做好了咱们间接用就行。

最初再看看结尾的几个问题,是不是都了解了呢。带着问题去钻研,往往会更加清晰。心愿对大家有所帮忙。

原文链接
本文为阿里云原创内容,未经容许不得转载。

退出移动版