目录
synchronized的使用方式:
Java中的每一个对象都可以作为锁,具体表现为以下3种形式。
- 对于普通同步方法,锁是当前实例对象
- 对于静态同步方法,锁是当前类的Class对象(这个类的所有对象)
- 对于同步代码块,锁是synchronized括号里配置的对象
synchronized修饰的对象有几种:
- 修饰一个类:其作用的范围是synchronized后面括号括起来的部分,作用的对象是这个类的所有对象;
- 修饰一个方法:被修饰的方法称为同步方法,其作用的范围是整个方法,作用的对象是调用这个方法的对象;
- 修饰一个静态的方法:其作用的范围是整个方法,作用的对象是这个类的所有对象;
- 修饰一个代码块:被修饰的代码块称为同步语句块,其作用范围是大括号{}括起来的代码块,作用的对象是调用这个代码块的对象;
Java对象头:
从synchronized的用法可以看出,synchronized修饰的是Java对象
其实synchronized用的锁是存在Java对象头里的。如果对象是数组,则虚拟机用3个字宽(Word)存储对象头,如果对象是非数组类型,则用2字宽存储对象头。在32位虚拟机中,1字宽等于4字节,即32bit,如图所示:
Java对象头里的Mark Word里默认存储对象的HashCode、分代年龄和锁标记为。32位JVM的Mark Word的默认存储结构如下表所示:(默认为无锁状态)
在64位虚拟机下,Mark Word是64bit大小的,其存储结构如下表:
synchronized的底层实现:
每个synchronized都有一个monitor监视器,synchronized的底层是通过monitor进行实现同步锁。
Monitor:
- 每个 Java 对象都可以关联一个 Monitor 对象,如果使用 synchronized 给对象上锁(重量级)之后,该对象头的 Mark Word 中就被设置指向 Monitor 对象的指针
ObjectMonitor() {
_count = 0; //用来记录该对象被线程获取锁的次数
_waiters = 0;
_recursions = 0; //锁的重入次数
_owner = NULL; //指向持有ObjectMonitor对象的线程
_WaitSet = NULL; //处于wait状态的线程,会被加入到_WaitSet
_WaitSetLock = 0 ;
_EntryList = NULL ; //处于等待锁block状态的线程,会被加入到该列表
}
Monitor 结构如下:
刚开始 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 中等待的线程来竞争锁,竞争的时是非公平的。
- 图中 WaitSet 中的 Thread-0,Thread-1 是之前获得过锁,但条件不满足进入 WAITING 状态的线程,后面讲 wait-notify 时会分析。
- synchronized 必须是进入同一个对象的 monitor 才有上述的效果
- 不加 synchronized 的对象不会关联监视器,不遵从以上规则
字节码层次上理解synchronized实现原理:
其实就是monitor在字节码层次上的实现过程:
static final Object lock = new Object();
static int counter = 0;
public static void main(String[] args) {
synchronized (lock) {
counter++;
}
}
对应的字节码为:查看字节码命令为javap -v xxx.class
//直接跳到 public static void main(java.lang.String[]);哪行看,这个是字节码原文件
Last modified 2021-5-29; size 648 bytes
MD5 checksum 351759d249726d0691faa94acd1a8a99
Compiled from "Test001.java"
public class likou.Test001
SourceFile: "Test001.java"
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
#1 = Methodref #4.#28 // java/lang/Object."<init>":()V
#2 = Fieldref #5.#29 // likou/Test001.lock:Ljava/lang/Object;
#3 = Fieldref #5.#30 // likou/Test001.counter:I
#4 = Class #31 // java/lang/Object
#5 = Class #32 // likou/Test001
#6 = Utf8 lock
#7 = Utf8 Ljava/lang/Object;
#8 = Utf8 counter
#9 = Utf8 I
#10 = Utf8 <init>
#11 = Utf8 ()V
#12 = Utf8 Code
#13 = Utf8 LineNumberTable
#14 = Utf8 LocalVariableTable
#15 = Utf8 this
#16 = Utf8 Llikou/Test001;
#17 = Utf8 main
#18 = Utf8 ([Ljava/lang/String;)V
#19 = Utf8 args
#20 = Utf8 [Ljava/lang/String;
#21 = Utf8 StackMapTable
#22 = Class #20 // "[Ljava/lang/String;"
#23 = Class #31 // java/lang/Object
#24 = Class #33 // java/lang/Throwable
#25 = Utf8 <clinit>
#26 = Utf8 SourceFile
#27 = Utf8 Test001.java
#28 = NameAndType #10:#11 // "<init>":()V
#29 = NameAndType #6:#7 // lock:Ljava/lang/Object;
#30 = NameAndType #8:#9 // counter:I
#31 = Utf8 java/lang/Object
#32 = Utf8 likou/Test001
#33 = Utf8 java/lang/Throwable
{
static final java.lang.Object lock;
flags: ACC_STATIC, ACC_FINAL
static int counter;
flags: ACC_STATIC
public likou.Test001();
flags: ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
LineNumberTable:
line 3: 0
LocalVariableTable:
Start Length Slot Name Signature
0 5 0 this Llikou/Test001;
public static void main(java.lang.String[]);
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=3, args_size=1
0: getstatic #2 // <- lock引用 (synchronized开始) 获取lock(锁的对象)的引用 这里#2的意思是符号引用,它会根据这个命令找到常量池第11行 #2 = Fieldref(直接引用)
3: dup // 复制一份(地址引用)
4: astore_1 // lock引用 -> slot 1 把复制的那一份放在临时变量slot中,为了后面的解锁
5: monitorenter // 将 lock对象 MarkWord 置为 Monitor 指针
6: getstatic #3 // <- i
9: iconst_1 // 准备常数 1
10: iadd // +1
11: putstatic #3 // -> i
14: aload_1 // <- 拿到lock地址引用 (slot 1)
15: monitorexit // 将 lock对象 MarkWord 重置, 唤醒 EntryList
16: goto 24
19: astore_2 // e -> slot 2
20: aload_1 // <- lock引用
21: monitorexit // 将 lock对象 MarkWord 重置, 唤醒 EntryList
22: aload_2 // <- slot 2 (e)
23: athrow // throw e
24: return
Exception table:
from to target type
6 16 19 any
19 22 19 any
LineNumberTable:
line 11: 0
line 12: 6
line 13: 14
line 16: 24
LocalVariableTable:
Start Length Slot Name Signature
0 25 0 args [Ljava/lang/String;
StackMapTable: number_of_entries = 2
frame_type = 255 /* full_frame */
offset_delta = 19
locals = [ class "[Ljava/lang/String;", class java/lang/Object ]
stack = [ class java/lang/Throwable ]
frame_type = 250 /* chop */
offset_delta = 4
static {};
flags: ACC_STATIC
Code:
stack=2, locals=0, args_size=0
0: new #4 // class java/lang/Object
3: dup
4: invokespecial #1 // Method java/lang/Object."<init>":()V
7: putstatic #2 // Field lock:Ljava/lang/Object;
10: iconst_0
11: putstatic #3 // Field counter:I
14: return
LineNumberTable:
line 5: 0
line 6: 10
}
synchronized 原理进阶
1. 轻量级锁
static final Object obj = new Object();
public static void method1() {
synchronized( obj ) {
// 同步块 A
method2();
}
}
public static void method2() {
synchronized( obj ) {
// 同步块 B
}
}
- 如果是其它线程已经持有了该 Object 的轻量级锁,这时表明有竞争,进入锁膨胀过程
- 如果是自己执行了 synchronized 锁重入,那么再添加一条 Lock Record 作为重入的计数
- 当退出 synchronized 代码块(解锁时)如果有取值为 null 的锁记录,表示有重入,这时重置锁记录,表示重 入计数减一
- 当退出 synchronized 代码块(解锁时)锁记录的值不为 null,这时使用 cas 将 Mark Word 的值恢复给对象 头
- 成功,则解锁成功
- 失败,说明轻量级锁进行了锁膨胀或已经升级为重量级锁,进入重量级锁解锁流程
2. 锁膨胀
static Object obj = new Object();
public static void method1() {
synchronized( obj ) {
// 同步块
}
}
- 当 Thread-1 进行轻量级加锁时,Thread-0 已经对该对象加了轻量级锁
这时 Thread-1 加轻量级锁失败,进入锁膨胀流程
-
即为 Object 对象申请 Monitor 锁,让 Object 指向重量级锁地址
- 然后自己进入 Monitor 的 EntryList BLOCKED
3. 自旋优化
- 自旋会占用 CPU 时间,单核 CPU 自旋就是浪费,多核 CPU 自旋才能发挥优势。
- 在 Java 6 之后自旋锁是自适应的,比如对象刚刚的一次自旋操作成功过,那么认为这次自旋成功的可能性会 高,就多自旋几次;反之,就少自旋甚至不自旋,总之,比较智能。
- Java 7 之后不能控制是否开启自旋功能
4. 偏向锁
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 ) {
// 同步块 C
}
}
轻量级锁与偏向锁的对比:
偏向状态:
- 如果开启了偏向锁(默认开启),那么对象创建后,markword 值为 0x05 即最后 3 位为 101,这时它的 thread、epoch、age 都为 0
- 偏向锁是默认是延迟的,不会在程序启动时立即生效,如果想避免延迟,可以加 VM 参数 - XX:BiasedLockingStartupDelay=0 来禁用延迟
- 如果没有开启偏向锁,那么对象创建后,markword 值为 0x01 即最后 3 位为 001,这时它的 hashcode、 age 都为 0,第一次用到 hashcode 时才会赋值
数据同步需要依赖锁,那锁的同步又依赖谁?synchronized给出的答案是在软件层面依赖JVM,而j.u.c.Lock给出的答案是在硬件层面依赖特殊的CPU指令。
当一个线程访问同步代码块时,首先是需要得到锁才能执行同步代码,当退出或者抛出异常时必须要释放锁,那么它是如何来实现这个机制的呢?我们先看一段简单的代码:
static final Object room1 = new Object();
public static void main(String[] args) throws Exception {
synchronized (room1){
System.out.println("hello world");
}
}
查看反编译后结果:
0 getstatic #2 <CreateThread1.room1>
3 dup
4 astore_1
5 monitorenter
6 getstatic #3 <java/lang/System.out>
9 ldc #4 <hello world>
11 invokevirtual #5 <java/io/PrintStream.println>
14 aload_1
15 monitorexit
16 goto 24 (+8)
19 astore_2
20 aload_1
21 monitorexit
22 aload_2
23 athrow
24 return
反编译结果
-
monitorenter:每个对象都是一个监视器锁(monitor)。当monitor被占用时就会处于锁定状态,线程执行monitorenter指令时尝试获取monitor的所有权,过程如下:
- 如果monitor的进入数为0,则该线程进入monitor,然后将进入数设置为1,该线程即为monitor的所有者;
- 如果线程已经占有该monitor,只是重新进入,则进入monitor的进入数加1;
- 如果其他线程已经占用了monitor,则该线程进入阻塞状态,直到monitor的进入数为0,再重新尝试获取monitor的所有权;
-
monitorexit:执行monitorexit的线程必须是objectref所对应的monitor的所有者。指令执行时,monitor的进入数减1,如果减1后进入数为0,那线程退出monitor,不再是这个monitor的所有者。其他被这个monitor阻塞的线程可以尝试去获取这个 monitor 的所有权。
monitorexit指令出现了两次,第1次为同步正常退出释放锁;第2次为发生异步退出释放锁;
通过上面两段描述,我们应该能很清楚的看出Synchronized的实现原理,Synchronized的语义底层是通过一个monitor的对象来完成,其实wait/notify等方法也依赖于monitor对象,这就是为什么只有在同步的块或者方法中才能调用wait/notify等方法,否则会抛出java.lang.IllegalMonitorStateException的异常的原因。
CAS
在介绍synchronized锁升级过程之前,我们需要先了解cas的原理,为什么呢?因为cas贯穿了整个synchronized锁升级的过程。
CAS : compare and swap 或者 compare and exchange 比较交换。
当我们需要对内存中的数据进行修改操作时,为了避免多线程并发修改的情况,我们在对他进行修改操作前,先读取他原来的值E,然后进行计算得出新的的值V,在修改前去比较当前内存中的值N是否和我之前读到的E相同,如果相同,认为其他线程没有修改过内存中的值,如果不同,说明被其他线程修改了,这时,要继续循环去获取最新的值E,再进行计算和比较,直到我们预期的值和当前内存中的值相等时,再对数据执行修改操作。
CAS具体流程如下下图:
自旋锁
线程的阻塞和唤醒需要CPU从用户态转为核心态,频繁的阻塞和唤醒对CPU来说是一件负担很重的工作,势必会给系统的并发性能带来很大的压力。同时我们发现在许多应用上面,对象锁的锁状态只会持续很短一段时间,为了这一段很短的时间频繁地阻塞和唤醒线程是非常不值得的。
所以引入自旋锁,何谓自旋锁?
所谓自旋锁,就是指当一个线程尝试获取某个锁时,如果该锁已被其他线程占用,就一直循环检测锁是否被释放,而不是进入线程挂起或睡眠状态。
自旋锁适用于锁保护的临界区很小的情况,临界区很小的话,锁占用的时间就很短。自旋等待不能替代阻塞,虽然它可以避免线程切换带来的开销,但是它占用了CPU处理器的时间。如果持有锁的线程很快就释放了锁,那么自旋的效率就非常好,反之,自旋的线程就会白白消耗掉处理的资源,它不会做任何有意义的工作,典型的占着茅坑不拉屎,这样反而会带来性能上的浪费。所以说,自旋等待的时间(自旋的次数)必须要有一个限度,如果自旋超过了定义的时间仍然没有获取到锁,则应该被挂起。
自旋锁在JDK 1.4.2中引入,默认关闭,但是可以使用-XX:+UseSpinning开开启,在JDK1.6中默认开启。同时自旋的默认次数为10次,可以通过参数-XX:PreBlockSpin来调整。
如果通过参数-XX:PreBlockSpin来调整自旋锁的自旋次数,会带来诸多不便。假如将参数调整为10,但是系统很多线程都是等你刚刚退出的时候就释放了锁(假如多自旋一两次就可以获取锁),是不是很尴尬。于是JDK1.6引入自适应的自旋锁,让虚拟机会变得越来越聪明。
适应性自旋锁
JDK 1.6引入了更加聪明的自旋锁,即自适应自旋锁。所谓自适应就意味着自旋的次数不再是固定的,它是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。那它如何进行适应性自旋呢?
线程如果自旋成功了,那么下次自旋的次数会更加多,因为虚拟机认为既然上次成功了,那么此次自旋也很有可能会再次成功,那么它就会允许自旋等待持续的次数更多。反之,如果对于某个锁,很少有自旋能够成功,那么在以后要或者这个锁的时候自旋的次数会减少甚至省略掉自旋过程,以免浪费处理器资源。
有了自适应自旋锁,随着程序运行和性能监控信息的不断完善,虚拟机对程序锁的状况预测会越来越准确,虚拟机会变得越来越聪明。
偏向锁
大多数情况下,锁总是由同一个线程多次获得。当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程ID,偏向锁是一个可重入的锁。如果锁对象头的Mark Word里存储着指向当前线程的偏向锁,无需重新进行CAS操作来加锁和解锁。当有其他线程尝试竞争偏向锁时,持有偏向锁的线程(不处于活动状态)才会释放锁。偏向锁无法使用自旋锁优化,因为一旦有其他线程申请锁,就破坏了偏向锁的假定进而升级为轻量级锁。
轻量锁
减少无实际竞争情况下,使用重量级锁产生的性能消耗。JVM会现在当前线程的栈桢中创建用于存储锁记录的空间 LockRecord,将对象头中的 Mark Word 复制到 LockRecord 中并将 LockRecord 中的 Owner 指针指向锁对象。然后线程会尝试使用CAS将对象头中的Mark Word替换为指向锁记录的指针,成功则当前线程获取到锁,失败则表示其他线程竞争锁当前线程则尝试使用自旋的方式获取锁。自旋获取锁失败则锁膨胀升级为重量级锁。
重量锁
通过对象内部的监视器(monitor)实现,其中monitor的本质是依赖于底层操作系统的Mutex Lock实 现,操作系统实现线程之间的切换需要从用户态到内核态的切换,切换成本非常高。线程竞争不使用自旋,不会消耗CPU。但是线程会进入阻塞等待被其他线程被唤醒,响应时间缓慢。
锁升级的思路
对象刚创建时偏向锁状态(有延迟,而且这是Mark Word的线程ID为空),当一个线程A尝试去获取锁时,去查看 标志位(01),然后去查看是否为偏向锁,为1(表示是偏向锁),查看Mark Word记录的线程ID是否为自己,如果不是自己的CAS尝试获取偏向锁,获取成功把Mark Word的线程ID变成自己的,获取失败,说明有线程竞争(说明当时有其他线程已经获取到了偏向锁 ,我们假设为 线程1),失败情况下 ,需要偏向锁的撤销,需要等待全局安全点(在这个时间点上没有正在执行的字节码),它会首先暂停拥有偏向锁的线程,然后检查持有偏向锁的线程是否为活着,如果线程不处于活着状态,则将对象头设置为无锁状态,线程A会通过CAS自旋升级为轻量级锁,如果线程1还活着,则线程1升级为轻量级锁,然后经过轻量级锁升级为重量级锁,monitor的owner的所有者,线程A会进入EntryList队列中进行等待。
如果线程1不处于活着状态,线程A会升级为轻量级锁,当线程A执行完同步代码块后,锁的对象头为变成无锁状态,当有线程B尝试获取锁的对象时,会重新升级为轻量级锁,直接跳过了偏向锁。
测试代码:
package com.example.demo.web;
import com.example.demo.pojo.People;
import org.openjdk.jol.info.ClassLayout;
import java.util.concurrent.locks.LockSupport;
public class T {
public static void main(String[] args) throws InterruptedException {
Thread.sleep(5000);
People a = new People();
System.out.println("1");
System.out.println(ClassLayout.parseInstance(a).toPrintable());
new Thread(() -> {
System.out.println("2");
System.out.println(ClassLayout.parseInstance(a).toPrintable());
synchronized (a){
System.out.println("3");
System.out.println(ClassLayout.parseInstance(a).toPrintable());
}
System.out.println("4");
System.out.println(ClassLayout.parseInstance(a).toPrintable());
synchronized (T.class){
T.class.notify();
}
},"t1").start();
new Thread(() -> {
synchronized (T.class){
try {
T.class.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println("5");
System.out.println(ClassLayout.parseInstance(a).toPrintable());
synchronized (a){
System.out.println("6");
System.out.println(ClassLayout.parseInstance(a).toPrintable());
}
System.out.println("7");
System.out.println(ClassLayout.parseInstance(a).toPrintable());
},"t2").start();
Thread.sleep(5000);
System.out.println("8");
System.out.println(ClassLayout.parseInstance(a).toPrintable());
new Thread(() -> {
System.out.println("9");
System.out.println(ClassLayout.parseInstance(a).toPrintable());
synchronized (a){
System.out.println("10");
System.out.println(ClassLayout.parseInstance(a).toPrintable());
try {
Thread.sleep(10000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("11");
System.out.println(ClassLayout.parseInstance(a).toPrintable());
}
},"t3").start();
Thread.sleep(5000);
new Thread(() -> {
synchronized (a){
System.out.println("12");
System.out.println(ClassLayout.parseInstance(a).toPrintable());
}
},"t4").start();
Thread.sleep(10000);
System.out.println("13");
System.out.println(ClassLayout.parseInstance(a).toPrintable());
}
}
在Maven中导入:
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.10</version>
</dependency>
结果:
- 00000101 00000000 00000000 00000000 前八位的后三位表示锁的状态:00000101 1 为 开启了偏向锁,01 表示偏向锁 后24位表示线程的一些信息
- 00000101 00000000 00000000 00000000 前八位为锁的状态:00000101 表示偏向锁
- 00000101 00100000 00000111 00011011
- 00000101 00100000 00000111 00011011
- 00000101 00100000 00000111 00011011
- 01100000 11101110 01101011 00011011 前八位的后三位表示锁的状态:01100000 00 表示轻量级锁 后24位表示线程的一些信息
- 00000001 00000000 00000000 00000000
- 00000001 00000000 00000000 00000000 前八位的后三位表示锁的状态:00000001 0为 关闭了偏向锁 01 表示无锁 后24位表示线程的一些信息
- 00000001 00000000 00000000 00000000
- 01011000 11110001 01011000 00011011
- 01101010 00110011 11111100 00010111 前八位的后三位表示锁的状态:01101010 10 表示重量级锁 后24位表示线程的一些信息
- 01101010 00110011 11111100 00010111
- 00000001 00000000 00000000 00000000
下图是从网上找的,大致思路相同,感觉不对
思路差不多,就是先判断标记位:00 01 10等,然后进行加锁或者锁升级的过程
哪里不对请多多指教