Synchronized如何实现同步/互斥的效果?
monitorenter: 将锁对象对象头中Mark Word的前30bit替换成指向操作系统中与其关联的monitor对象,将锁记录位状态改为10
monitorexit: 将锁对象对象头中Mark Word进行重置,重新恢复成原来的样子,并通过在EntryList中等待的线程来继续竞争!
工作流程:
-
开始时 Monitor 中 Owner 为 null
-
当 Thread-2 执行 synchronized(obj) 就会将 Monitor 的所有者 Owner 置为 Thread-2,Monitor 中只能有一个 Owner,obj 对象的 Mark Word 指向 Monitor,把对象原有的 MarkWord 存入线程栈中的锁记录中(轻量级锁部分详解)
-
在 Thread-2 上锁的过程,Thread-3、Thread-4、Thread-5 也执行 synchronized(obj),就会进入 EntryList BLOCKED(双向链表)
-
Thread-2 执行完同步代码块的内容,根据 obj 对象头中 Monitor 地址寻找,设置 Owner 为空,把线程栈的锁记录中的对象头的值设置回 MarkWord
-
唤醒 EntryList 中等待的线程来竞争锁,竞争是非公平的,如果这时有新的线程想要获取锁,可能直接就抢占到了,阻塞队列的线程就会继续阻塞
-
WaitSet 中的 Thread-0,是以前获得过锁,但条件不满足进入 WAITING 状态的线程(wait-notify 机制)
注意:
- synchronized 必须是进入同一个对象的 Monitor 才有上述的效果
- 不加 synchronized 的对象不会关联监视器,不遵从以上规则
锁状态改变之后,hashcode分代年龄都去哪里了?
轻量级锁:原先MarkWord中这些信息都会被存储到 线程栈帧中的锁记录(存储锁对象的Mark Word拷贝)中。
重量级锁:当锁对象的Mark Word被重置之后,hashcode 分代年龄都会被存储到操作系统的monitor对象中,然后当monitorexit的时候会从monitor对象中取出此类信息,然后重置Mark Word
注意:
synchronized中代码抛出了异常在字节码层面会发现会将锁正确的释放。不会造成死锁等情况!即使出现了异常还是会正常的帮我们monitorexit!重置Mark Word和唤醒entryList中等待线程!
Synchronized锁升级原理
无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁 // 随着竞争的增加,只能锁升级,不能降级
无锁状态(001) ----> 偏向锁(101) --(偏向撤销)–> 轻量级锁(00) – (锁膨胀CAS失败说明有竞争情况) --> 重量级锁
偏向锁
偏向锁的思想是偏向于让第一个获取锁对象的线程,这个线程之后重新获取该锁不再需要同步操作:
-
当锁对象第一次被线程获得的时候进入偏向状态,标记为 101,同时使用 CAS 操作将线程 ID 记录到 Mark Word。如果 CAS 操作成功,这个线程以后进入这个锁相关的同步块,查看这个线程 ID 是自己的就表示没有竞争,就不需要再进行任何同步操作
-
当有另外一个线程去尝试获取这个锁对象时,偏向状态就宣告结束,此时撤销偏向(Revoke Bias)后恢复到未锁定或轻量级锁状态
一个对象创建时:
-
如果开启了偏向锁(默认开启),那么对象创建后,MarkWord 值为 0x05 即最后 3 位为 101,thread、epoch、age 都为 0
-
偏向锁是默认是延迟的,不会在程序启动时立即生效,如果想避免延迟,可以加 VM 参数
-XX:BiasedLockingStartupDelay=0
来禁用延迟。JDK 8 延迟 4s 开启偏向锁原因:在刚开始执行代码时,会有好多线程来抢锁,如果开偏向锁效率反而降低 -
当一个对象已经计算过 hashCode,就再也无法进入偏向状态了
-
添加 VM 参数
-XX:-UseBiasedLocking
禁用偏向锁
撤销偏向锁的状态:
- 调用对象的 hashCode:偏向锁的对象 MarkWord 中存储的是线程 id,调用 hashCode 导致偏向锁被撤销
- 当有其它线程使用偏向锁对象时,会将偏向锁升级为轻量级锁
- 调用 wait/notify,需要申请 Monitor,进入 WaitSet
批量撤销:如果对象被多个线程访问,但没有竞争,这时偏向了线程 T1 的对象仍有机会重新偏向 T2,重偏向会重置对象的 Thread ID
-
批量重偏向:当撤销偏向锁阈值超过 20 次后,JVM 会觉得是不是偏向错了,于是在给这些对象加锁时重新偏向至加锁线程
-
批量撤销:当撤销偏向锁阈值超过 40 次后,JVM 会觉得自己确实偏向错了,根本就不该偏向,于是整个类的所有对象都会变为不可偏向的,新建的对象也是不可偏向的
轻量级锁
工作流程中是CAS操作尝试将对象头Mark Word的前30bit替换成指向获取到锁线程栈帧内部的Lock Record锁记录(用于存储锁对象MarkWord的拷贝)的指针!
获取成功则执行代码块,CAS失败虚拟机会检查当前锁记录中是否是当前线程的锁记录,如果是则出现锁重入现象,会在当前虚拟机栈中开辟新的栈帧中的锁记录,新的锁记录不会存储拷贝Mark Word而是用于作为重入的计数!如果不是说明有竞争的线程了,而轻量级锁的核心就是认为“多把锁场景下。不会存在竞争的情况。
当有两个及以上线程来获取对象锁的时候,这时候轻量级锁会进行膨胀升级为重量级锁,并且竞争的线程会被放入该锁对象对应的monitor对象的EntryList中处于阻塞等待状态!CAS操作尝试将锁对象的Mark Word恢复回去!!!
一个对象有多个线程要加锁,但加锁的时间是错开的(没有竞争),可以使用轻量级锁来优化,轻量级锁对使用者是透明的(不可见)
可重入锁:线程可以进入任何一个它已经拥有的锁所同步着的代码块,可重入锁最大的作用是避免死锁
轻量级锁在没有竞争时(锁重入时),每次重入仍然需要执行 CAS 操作,Java 6 才引入的偏向锁来优化
锁重入实例:
static final Object obj = new Object();
public static void method1() {synchronized( obj ) {// 同步块 Amethod2();}
}
public static void method2() {synchronized( obj ) {// 同步块 B}
}
- 创建锁记录(Lock Record)对象,每个线程的栈帧都会包含一个锁记录的结构(不可见),存储锁定对象的 Mark Word
-
让锁记录中 Object reference 指向锁住的对象,并尝试用 CAS 替换 Object 的 Mark Word,将 Mark Word 的值存入锁记录
-
如果 CAS 替换成功(原 object 是01 无锁),对象头中存储了锁记录地址和状态 00(轻量级锁) ,表示由该线程给对象加锁
-
如果 CAS 失败,有两种情况:
- 如果是其它线程已经持有了该 Object 的轻量级锁,这时表明有竞争,进入锁膨胀过程
- 如果是线程自己执行了 synchronized 锁重入,就添加一条 Lock Record 作为重入的计数
-
当退出 synchronized 代码块(解锁时)
- 如果有取值为 null 的锁记录,表示有重入,这时重置锁记录,表示重入计数减 1
- 如果锁记录的值不为 null,这时使用 CAS 将 Mark Word 的值恢复给对象头
- 成功,则解锁成功
- 失败,说明轻量级锁进行了锁膨胀或已经升级为重量级锁,进入重量级锁解锁流程
锁膨胀
在尝试加轻量级锁的过程中,CAS 操作无法成功,可能是其它线程为此对象加上了轻量级锁(有竞争),这时需要进行锁膨胀,将轻量级锁变为重量级锁
-
当 Thread-1 进行轻量级加锁时,Thread-0 已经对该对象加了轻量级锁
-
Thread-1 加轻量级锁失败,进入锁膨胀流程:为 Object 对象申请 Monitor 锁,通过 Object 对象头获取到持锁线程,将 Monitor 的 Owner 置为 Thread-0,将 Object 的对象头指向重量级锁地址,然后自己进入 Monitor 的 EntryList BLOCKED
-
当 Thread-0 退出同步块解锁时,使用 CAS 将 Mark Word 的值恢复给对象头失败,这时进入重量级解锁流程,即按照 Monitor 地址找到 Monitor 对象,设置 Owner 为 null,唤醒 EntryList 中 BLOCKED 线程
锁优化
自旋锁
重量级锁竞争时,尝试获取锁的线程不会立即阻塞,可以使用自旋(默认 10 次)来进行优化,采用循环的方式去尝试获取锁
注意:
- 自旋占用 CPU 时间,单核 CPU 自旋就是浪费时间,因为同一时刻只能运行一个线程,多核 CPU 自旋才能发挥优势
- 自旋失败的线程会进入阻塞状态
优点:不会进入阻塞状态,减少线程上下文切换的消耗
缺点:当自旋的线程越来越多时,会不断的消耗 CPU 资源
自旋锁说明:
- 在 Java 6 之后自旋锁是自适应的,比如对象刚刚的一次自旋操作成功过,那么认为这次自旋成功的可能性会高,就多自旋几次;反之,就少自旋甚至不自旋,比较智能
- Java 7 之后不能控制是否开启自旋功能,由 JVM 控制
锁消除
锁消除是指对于被检测出不可能存在竞争的共享数据的锁进行消除,这是 JVM 即时编译器的优化
锁消除主要是通过逃逸分析来支持,如果堆上的共享数据不可能逃逸出去被其它线程访问到,那么就可以把它们当成私有数据对待,也就可以将它们的锁进行消除(同步消除:JVM 逃逸分析) -XX:-EliminateLocks
锁粗化
对相同对象多次加锁,导致线程发生多次重入,频繁的加锁操作就会导致性能损耗,可以使用锁粗化方式优化
如果虚拟机探测到一串的操作都对同一个对象加锁,将会把加锁的范围扩展(粗化)到整个操作序列的外部
-
一些看起来没有加锁的代码,其实隐式的加了很多锁:
public static String concatString(String s1, String s2, String s3) {return s1 + s2 + s3; }
-
String 是一个不可变的类,编译器会对 String 的拼接自动优化。在 JDK 1.5 之前,转化为 StringBuffer 对象的连续 append() 操作,每个 append() 方法中都有一个同步块
public static String concatString(String s1, String s2, String s3) {StringBuffer sb = new StringBuffer();sb.append(s1);sb.append(s2);sb.append(s3);return sb.toString(); }
扩展到第一个 append() 操作之前直至最后一个 append() 操作之后,只需要加锁一次就可以