写点什么

【开发宝典】Java 并发系列教程(四)

  • 2023-02-14
    北京
  • 本文字数:9085 字

    阅读完需:约 30 分钟

【开发宝典】Java并发系列教程(四)

作者:京东零售 刘跃明

Monitor 概念

Java 对象的内存布局

对象除了我们自定义的一些属性外,还有其它数据,在内存中可以分为三个区域:对象头、实例数据、对齐填充,这三个区域组成起来才是一个完整的对象。


对象头:在 JVM 中需要大量存储对象,存储时为了实现一些额外的功能,需要在对象中添加一些标记字段用于增强对象功能,这些标记字段组成了对象头。


实例数据:存放类的属性数据信息,包括父类的属性信息。


对齐填充:由于虚拟机要求对象其实地址必须是 8 字节的整数倍,需要存在填充区域以满足 8 字节的整数倍,填充数据不是必须存在的,仅仅是为了字节对齐。



图 1

Java 对象头

JVM 中对象头的方式有以下两种(以 32 位虚拟机为例):


普通对象



数组对象


Mark Word

这部分主要用来存储对象自身的运行数据,如 hashcode、gc 分带年龄等,Mark Word 的位长度为 JVM 的一个 Word 大小,也就是说 32 位 JVM 的 Mark Word 为 32 位,64 位 JVM 为 64 位。为了让一个字大小存储更多的信息,JVM 将字的最低两个位设置为标记位,不同标记位下的 Mark Word 示意如下:



其中各部分的含义如下:


lock: 2 位的锁状态标记位,该标记的值不同,整个 Mark Word 表示的含义不同。



biased_lock: 对象是否启用偏向锁标记,只占 1 个二进制位,为 1 时表示对象启用偏向锁,为 0 时表示对象没有偏向锁。


age: 4 位的 Java 对象年龄,在 GC 中,如果对象再 Survivor 区复制一次,年龄增加 1,当对象达到设定的阈值时,将会晋升到老年代,默认情况下,并行 GC 的年龄阈值为 15,并发 GC 的年龄阈值为 6,由于 age 只有 4 位,所以最大值为 15,这就是-XX:MaxTenuringThreshold 选项最大值为 15 的原因。


identity_hashcode: 25 位的对象表示 Hash 码,采用延迟加载技术,调用方法 System.idenHashcode()计算,并会将结果写到该对象头中,当对象被锁定时,该值会移动到管程 Monitor 中。


thread: 持有偏向锁的线程 ID。


epoch: 偏向时间戳。


ptr_to_lock_record: 指向栈中锁记录的指针。


ptr_to_heavyweight_monitor: 指向管程 Monitor 的指针。

Klass Word

这一部分用于存储对象的类型指针,该指针指向它的类元数据,JVM 通过这个指针确定对象是哪个类的实例,该指针的位长度为 JVM 的一个字大小,即 32 位的 JVM 为 32 位,64 位的 JVM 为 64 位。

array length

如果对象是一个数组,那么对象头还需要有额外的空间用于存储数组的长度,这部分数据的长度也随着 JVM 架构的不同而不同:32 位的 JVM 长度为 32 位,64 位 JVM 则为 64 位。

Monitor 原理

Monitor 被翻译为监视器管程


每个 Java 对象都可以关联一个 Monitor 对象,如果使用 synchronized 给对象上锁(重量级)之后,该对象头的 Mark Word 中就被设置指向 Monitor 对象的指针。


Monitor 结构如下:



图 2


•刚开始 Monitor 中 Owner 为 null


•当 Thread-2 执行 synchronized(obj)就会将 Monitor 的所有者 Owner 置为 Thread-2,Monitor 中只能有一个 Owner


•在 Thread-2 上锁的过程中,如果 Thread-3、Thread-4、Thread-5 也来执行 synchronized(obj),就会进入 EntryList BLOCKED


•Thread-2 执行完同步代码块的内容,然后唤醒 EntryList 中等待的线程来竞争锁,竞争是非公平的,也就是先进并非先获取锁


•图 2 中 WaitSet 中的 Thread-0、Thread-1 是之前获得过锁,但条件不满足进入 WAITING 状态的线程,后面讲 wait-notify 时会分析


注意:


•synchronized 必须是进入同一个对象的 Monitor 才有上述的效果


•不加 synchronized 的对象不会关联监视器,不遵从以上规则

synchronized 原理

static final Object lock = new Object();static int counter = 0;
public static void main(String[] args) { synchronized (lock) { counter++; }}
复制代码


对应的字节码为:


public static main([Ljava/lang/String;)V


TRYCATCHBLOCK L0 L1 L2 null


TRYCATCHBLOCK L2 L3 L2 null


L4


LINENUMBER 6 L4


GETSTATIC MyClass03.lock : Ljava/lang/Object;


DUP


ASTORE 1


MONITORENTER //注释 1


L0


LINENUMBER 7 L0


GETSTATIC MyClass03.counter : I


ICONST_1


IADD


PUTSTATIC MyClass03.counter : I


L5


LINENUMBER 8 L5


ALOAD 1


MONITOREXIT //注释 2


L1


GOTO L6


L2


FRAME FULL [[Ljava/lang/String; java/lang/Object] [java/lang/Throwable]


ASTORE 2


ALOAD 1


MONITOREXIT //注释 3


L3


ALOAD 2


ATHROW


L6


LINENUMBER 9 L6


FRAME CHOP 1


RETURN


L7


LOCALVARIABLE args [Ljava/lang/String; L4 L7 0


MAXSTACK = 2


MAXLOCALS = 3


注释 1


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


•如果 Monitor 的进入数为 0,则该线程进入 Monitor,并将进入数设置为 1,该线程即为 Monitor 的所有者(Owner)


•如果该线程已经占用 Monitor,只是重新进入 Monitor,则进入 Monitor 的进入数加 1


•如果其它线程已经占用 Monitor,则该线程进入阻塞状态,直到 Monitor 进入数为 0,再重新尝试获取 Monitor 的所有权


注释 2


MONITOREXIT 的意思为:执行指令时,Monitor 的进入数减 1,如果减 1 后进入数为 0,该线程退出 Monitor,不再是这个 Monitor 的所有者,其它被 Monitor 阻塞的线程重新尝试获取 Monitor 的所有权。


总结


通过注释 1 和注释 2 可知,synchronized 的实现原理,底层是通过 Monitor 的对象来完成,其实 wait 和 notify 等方法也依赖 Monitor,这就是为什么 wait 和 notify 方法必须要在同步方法内调用,否则会抛出 java.lang.IllegalMonitorStateException 的原因。


如果程序正常执行则按上述描述即可完成,如果程序在同步方法内发生异常,代码则会走注释 3,在注释 3 可以看到 MONITOREXIT 指令,也就是 synchronized 已经处理异常情况下的退出。


注:方法级别的 synchronized 不会在字节码指令中有所体现,而是在常量池中增加了 ACC_SYNCHRONIZED 标识符,JVM 就是通过该标识符来实现同步的,方法调用时,JVM 会判断方法的 ACC_SYNCHRONIZED 是否被设置,如果被设置,线程执行方法前会先获取 Monitor 所有权,执行完方法后再释放 Monitor 所有权,本质是一样的。

synchronized 原理进阶

轻量级锁

轻量级锁的使用场景:如果一个对象虽然有多线程要加锁,但加锁的时间是错开的(也就是没有竞争),那么可以使用轻量级锁来优化。


轻量级锁对使用者是透明的,即语法仍然是 synchronized


假设有两个方法同步块,利用同一个对象加锁


static final Object obj = new Object();
public static void method1() { synchronized (obj) { // 同步块 A method2(); }}
public static void method2() { synchronized (obj) { // 同步块 B }}
复制代码


创建锁记录(Lock Record)对象,每个线程的栈帧都会包含一个锁记录的结构,内部可以存储锁定对象的 Mark Word



图 3


让锁记录中 Object reference 指向锁对象,并尝试用 cas 替换 Object 的 Mark Word,将 Mark Word 的值存入锁记录



图 4


如果 cas 替换成功,对象头中存储了锁记录地址和状态 00,表示由该线程给对象加锁,这是图示如下



图 5


如果 cas 失败,有两种情况


•如果是其它线程已经持有了该 Object 的轻量级锁,这是表明有竞争,进入锁膨胀过程


•如果是自己线程执行了 synchronized 锁重入,那么再添加一条 Lock Record 作为重入的技术



图 6


当退出 synchronized 代码块(解锁时),如果有取值为 null 的锁记录,表示由重入,这是重置锁记录,表示重入技术减一



图 7


当退出 synchronized 代码块(解锁时),锁记录的值不为 null,这时使用 cas 将 Mark Word 的值回复给对象头


•成功,则解锁成功


•失败,说明轻量级锁进行了锁膨胀或已经升级为重量级锁,进入重量级锁解锁流程

锁膨胀

如果在尝试加轻量级锁的过程中,CAS 操作无法成功,这是一种情况就是有其它线程为此对象加上了轻量级锁(有竞争),这是需要进行锁膨胀,将轻量级锁变为重量级锁。


当 Thread-1 进行轻量级加锁时,Thread-0 已经对该对象加了轻量级锁



图 8


这是 Thread-1 加轻量级锁失败,进入锁膨胀流程


•即为 Object 对象申请 Monitor 锁,让 Object 指向重量级锁地址


•然后自己进入 Monitor 的 EntryList BLOCKED



图 9


当 Thread-0 退出同步块解锁时,使用 cas 将 Mark Word 的值恢复给对象头,失败,这是会进入重量级解锁流程,即按照 Monitor 地址找到 Monitor 对象,设置 Owner 为 null,唤醒 EntryList 中 BLOCKED 线程

自旋优化

重量级锁竞争的时候,还可以使用自旋来进行优化,如果当前线程自旋成功(即这时候持锁线程已经退出了同步,释放了锁),这是当前线程就可以避免阻塞。


自旋重试成功的情况



自旋重试失败的情况



•自旋会占用 CPU 时间,单核 CPU 自旋就是浪费,多核 CPU 自旋才能发挥优势。


•在 Java 6 之后自旋锁是自适应的,比如对象刚刚的一次自旋操作成功过,那么认为这次自旋成功的可能性会高,就多自旋几次;反之,就少自旋甚至不自旋,总之,比较智能。


•Java 7 之后不能控制是否开启自旋功能。

偏向锁

轻量级锁在没有竞争时(就自己这个线程),每次重入仍然需要执行 CAS 操作。


Java 6 中引入了偏向锁做进一步优化:只有第一次使用 CAS 将线程 ID 设置到对象的 Mark Word 头,之后发现这个线程 ID 是自己的就表示没有竞争,不用重新 CAS,以后只要不发生竞争,这个对象就归该线程所有。


注:


Java 15 之后废弃偏向锁,默认是关闭,如果想使用偏向锁,配置-XX:+UseBiasedLocking 启动参数。


启动偏向锁之后,偏向锁有一个延迟生效的机制,这是因为 JVM 启动时会进行一系列的复杂活动,比如装载配置,系统类初始化等等。在这个过程中会使用大量 synchronized 关键字对对象加锁,且这些锁大多数都不是偏向锁。为了减少初始化时间,JVM 默认延时加载偏向锁。这个延时的时间大概为 4s 左右,具体时间因机器而异。当然我们也可以设置 JVM 参数 -XX:BiasedLockingStartupDelay=0 来取消延时加载偏向锁。


例如:


static final Object obj = new Object();
public static void m1() { synchronized (obj) { // 同步块 A m2(); }}
public static void m2() { synchronized (obj) { // 同步块 B m3(); }}
public static void m3() { synchronized (obj) { }}
复制代码


如果关闭偏向锁,使用轻量锁情况:



图 10


开启偏向锁,使用偏向锁情况:



图 11

偏向状态

回忆一下对象头格式



一个对象创建时:


•如果开启了偏向锁(默认开启),那么对象创建后,Mark Word 值为 0x05,也就是最后是 3 位为 101,这是它的 thread、epoch、age 都为 0


•如果没有开启偏向锁,那么对象创建后,Mark Word 值为 0x01,也就是最后 3 位为 001,这时它的 hashcode、age 都为 0,第一次用到 hashcode 时才会赋值


我们来验证下,使用 jol 第三方工具,以及对工具打印对象头做了一个处理,让对象头开起来更简便:


测试代码


public synchronized static void main(String[] args){    log.info("{}", toSimplePrintable(object));}
复制代码


开启偏向锁的情况下


打印的数据如下(由于 Java15 之后偏向锁废弃,因此打开偏向锁打印会警告)


17:15:17 [main] c.MyClass03 - 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000101


最后为 101,其他都为 0,验证了上述第一条。


可能你又要问了,我这也没使用 synchronized 关键字呀,那不也应该是无锁么?怎么会是偏向锁呢?


仔细看一下偏向锁的组成,对照输出结果红色划线位置,你会发现占用 thread 和 epoch 的 位置的均为 0,说明当前偏向锁并没有偏向任何线程。此时这个偏向锁正处于可偏向状态,准备好进行偏向了!你也可以理解为此时的偏向锁是一个特殊状态的无锁


关闭偏向锁的情况下


打印的数据如下


17:18:32 [main] c.MyClass03 - 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000001


最后为 001,其它都是 0,验证了上述第二条。


接下来验证加锁的情况,代码如下:


private static Object object = new Object();public synchronized static void main(String[] args){    new Thread(()->{        log.info("{}", "synchronized前");        log.info("{}", toSimplePrintable(object));        synchronized (object){            log.info("{}", "synchronized中");            log.info("{}", toSimplePrintable(object));        }        log.info("{}", "synchronized后");        log.info("{}", toSimplePrintable(object));    },"t1").start();}
复制代码


开启偏向锁的情况,打印数据如下


17:24:05 [t1] c.MyClass03 - synchronized 前


17:24:05 [t1] c.MyClass03 - 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000101


17:24:05 [t1] c.MyClass03 - synchronized 中


17:24:05 [t1] c.MyClass03 - 00000000 00000000 00000000 00000001 00001110 00000111 01001000 00000101


17:24:05 [t1] c.MyClass03 - synchronized 后


17:24:05 [t1] c.MyClass03 - 00000000 00000000 00000000 00000001 00001110 00000111 01001000 00000101


使用了偏向锁,并记录了线程的值(101 前面的一串数字),但是处于偏向锁的对象解锁后,线程 id 仍存储于对象头中。


关闭偏向锁的情况,打印数据如下


17:28:24 [t1] c.MyClass03 - synchronized 前


17:28:24 [t1] c.MyClass03 - 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000001


17:28:24 [t1] c.MyClass03 - synchronized 中


17:28:24 [t1] c.MyClass03 - 00000000 00000000 00000000 00000001 01110000 00100100 10101001 01100000


17:28:24 [t1] c.MyClass03 - synchronized 后


17:28:24 [t1] c.MyClass03 - 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000001


使用轻量锁(最后为 000),并且记录了占中存储的锁信息地址(000 前面一串数字),同步块结束后恢复到原先状态(因为没有使用 hashcode,所以 hashcode 值为 0)。

偏向锁撤销

在真正讲解偏向撤销之前,需要和大家明确一个概念——偏向锁撤销和偏向锁释放是两码事。


•撤销:笼统的说就是多个线程竞争导致不能再使用偏向模式的时候,主要是告知这个锁对象不能再用偏向模式


•释放:和你的常规理解一样,对应的就是 synchronized 方法的退出或 synchronized 块的结束


何为偏向撤销?


从偏向状态撤回原有的状态,也就是将 MarkWord 的第 3 位(是否偏向撤销)的值,从 1 变回 0


如果只是一个线程获取锁,再加上「偏心」的机制,是没有理由撤销偏向的,所以偏向的撤销只能发生在有竞争的情况下

撤销-hashcode 调用

调用了对象的 hashcode 会导致偏向锁被撤销:


•轻量级锁会在锁记录中记录 hashcode


•重量级锁会在 Monitor 中记录 hashcode


测试代码如下


private static Object object = new Object();public synchronized static void main(String[] args){    object.hashCode();//调用hashcode    new Thread(()->{        log.info("{}", "synchronized前");        log.info("{}", toSimplePrintable(object));        synchronized (object){            log.info("{}", "synchronized中");            log.info("{}", toSimplePrintable(object));        }        log.info("{}", "synchronized后");        log.info("{}", toSimplePrintable(object));    },"t1").start();}
复制代码


打印如下:


17:36:05 [t1] c.MyClass03 - synchronized 前


17:36:06 [t1] c.MyClass03 - 00000000 00000000 00000000 01011111 00100001 00001000 10110101 00000001


17:36:06 [t1] c.MyClass03 - synchronized 中


17:36:06 [t1] c.MyClass03 - 00000000 00000000 00000000 00000001 01101110 00010011 11101001 01100000


17:36:06 [t1] c.MyClass03 - synchronized 后


17:36:06 [t1] c.MyClass03 - 00000000 00000000 00000000 01011111 00100001 00001000 10110101 00000001

撤销-其它线程使用对象

当有其它线程使用偏向锁对象时,会将偏向锁升级为轻量级锁。


测试代码如下


private static void test2() {    Thread t1 = new Thread(() -> {        synchronized (object) {            log.info("{}", toSimplePrintable(object));        }        synchronized (MyClass03.class) {            MyClass03.class.notify();//t1执行完之后才通知t2执行        }    }, "t1");    t1.start();    Thread t2 = new Thread(() -> {        synchronized (MyClass03.class) {            try {                MyClass03.class.wait();            } catch (InterruptedException e) {                e.printStackTrace();            }        }        log.info("{}", toSimplePrintable(object));        synchronized (object) {            log.info("{}", toSimplePrintable(object));        }        log.info("{}", toSimplePrintable(object));    }, "t2");    t2.start();}
复制代码


打印数据如下


17:51:38 [t1] c.MyClass03 - 00000000 00000000 00000000 00000001 01000111 00000000 11101000 00000101


17:51:38 [t2] c.MyClass03 - 00000000 00000000 00000000 00000001 01000111 00000000 11101000 00000101


17:51:38 [t2] c.MyClass03 - 00000000 00000000 00000000 00000001 01111000 00100000 01101001 01010000


17:51:38 [t2] c.MyClass03 - 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000001


可以看到线程 t1 是使用偏向锁,线程 t2 使用锁之前是一样的,但是一旦使用了锁,便升级为轻量级锁,执行完同步代码之后,恢复成撤销偏向锁的状态。

撤销-调用 wait/notify

代码如下


private static void test3(){    Thread t1 = new Thread(() -> {        log.info("{}", toSimplePrintable(object));        synchronized (object) {            log.info("{}", toSimplePrintable(object));            try {                object.wait();            } catch (InterruptedException e) {                e.printStackTrace();            }            log.info("{}", toSimplePrintable(object));        }    }, "t1");    t1.start();    new Thread(() -> {        try {            Thread.sleep(6000);        } catch (InterruptedException e) {            e.printStackTrace();        }        synchronized (object) {            log.debug("notify");            object.notify();        }    }, "t2").start();}
复制代码


打印数据如下


17:57:57 [t1] c.MyClass03 - 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000101


17:57:57 [t1] c.MyClass03 - 00000000 00000000 00000000 00000001 00001111 00001100 11010000 00000101


17:58:02 [t2] c.MyClass03 - notify


17:58:02 [t1] c.MyClass03 - 00000000 00000000 01100000 00000000 00000011 11000001 10000010 01110010


调用 wait 和 notify 得是用 Monitor,所以会从偏向锁升级为重量级锁。

批量重偏向

如果对象虽然被多个线程访问,但没有竞争,这是偏向了线程 t1 的对象仍然有机会重新偏向 t2,重偏向会重置对象的 Thread ID。


当撤销偏向锁阈值超过 20 次后,JVM 会这样觉得,我是不是偏向错了呢,于是会在给这些对象加锁时重新偏向至加锁线程。


代码如下


public static class Dog{}
private static void test4() { Vector<Dog> list = new Vector<>(); Thread t1 = new Thread(() -> { for (int i = 0; i < 30; i++) { Dog d = new Dog(); list.add(d); synchronized (d) { log.info("{}", i+"\t"+toSimplePrintable(d)); } } synchronized (list) { list.notify(); } }, "t1"); t1.start(); Thread t2 = new Thread(() -> { synchronized (list) { try { list.wait(); } catch (InterruptedException e) { e.printStackTrace(); } } log.debug("===============> "); for (int i = 0; i < 30; i++) { Dog d = list.get(i); log.info("{}", i+"\t"+toSimplePrintable(d)); synchronized (d) { log.info("{}", i+"\t"+toSimplePrintable(d)); } log.info("{}", i+"\t"+toSimplePrintable(d)); } }, "t2"); t2.start();}
复制代码


打印如下



图 12


另外我在测试的是否发现一个线程,当对象是普通类(如 Dog)时,重偏向的阈值就是 20,也就是第 21 次开启了偏向锁,但是如果把普通类替换成 Object 时,重偏向的阈值就是 9,也就是第 10 次开启了偏向锁并重偏向(如图 13),这是怎么回事儿,有了解的同学可以评论交流下。



图 13

批量撤销

当撤销偏向锁阈值超过 40 次后,JVM 会这样觉得,自己确实偏向错了,根本不该偏向,于是整个类的所有对象都会变为不可偏向的,新建的对象也是不可偏向的。


代码如下


static Thread t1, t2, t3;
private static void test6() throws InterruptedException { Vector<Dog> list = new Vector<>(); int loopNumber = 40; t1 = new Thread(() -> { for (int i = 0; i < loopNumber; i++) { Dog d = new Dog(); list.add(d); synchronized (d) { log.info("{}", i + "\t" + toSimplePrintable(d)); } } LockSupport.unpark(t2); }, "t1"); t1.start(); t2 = new Thread(() -> { LockSupport.park(); log.debug("===============> "); for (int i = 0; i < loopNumber; i++) { Dog d = list.get(i); log.info("{}", i + "\t" + toSimplePrintable(d)); synchronized (d) { log.info("{}", i + "\t" + toSimplePrintable(d)); } log.info("{}", i + "\t" + toSimplePrintable(d)); } LockSupport.unpark(t3); }, "t2"); t2.start(); t3 = new Thread(() -> { LockSupport.park(); log.debug("===============> "); for (int i = 0; i < loopNumber; i++) { Dog d = list.get(i); log.info("{}", i + "\t" + toSimplePrintable(d)); synchronized (d) { log.info("{}", i + "\t" + toSimplePrintable(d)); } log.info("{}", i + "\t" + toSimplePrintable(d)); } }, "t3"); t3.start(); t3.join(); log.info("{}", toSimplePrintable(new Dog()));}
复制代码


打印如下



图 14

发布于: 刚刚阅读数: 5
用户头像

拥抱技术,与开发者携手创造未来! 2018-11-20 加入

我们将持续为人工智能、大数据、云计算、物联网等相关领域的开发者,提供技术干货、行业技术内容、技术落地实践等文章内容。京东云开发者社区官方网站【https://developer.jdcloud.com/】,欢迎大家来玩

评论

发布
暂无评论
【开发宝典】Java并发系列教程(四)_Java_京东科技开发者_InfoQ写作社区