使用场景
在多个线程对同一个共享资源进行读写操作时,由于代码的执行序列的不确定会导致结果不可预测;为了避免这情况的发生,java 提供了多种解决方案来达到目的;
- 阻塞式:synchronized、reentrantLock
- 非阻塞:基于CAS算法+自旋 的乐观锁
synchronized的使用
- 静态方法上加锁 (被锁的的是 类对象)
public synchronized static void doSomething(){ System.out.println("静态方法上锁"); }
- 非静态方法上加锁 (被锁的是 类实例对象)
public synchronized void doSomething(){ System.out.println("非静态方法上锁"); }
- this对象加锁 (被锁的是 类实例对象)
public void doSomething(){ synchronized (this){ System.out.println("代码块加锁"); } }
- 对象加锁 (被锁的是 object 对象)
Object lock = new Object(); public void doSomething(){ synchronized (lock){ System.out.println("对象加锁"); } }
synchronized 的原理
synchronized 是JVM内置锁,它通过Monitor机制实现;
方法上的 synchronized 是通过 jvm 指令 ACC_synchronized 实现的(没找到。。网上这么说来着);
代码块的 synchronized 是通过 jvm 指令 Monitorenter 和 Monitorexit 实现的;
这个可以通过 idea 的 jclasslib 插件查看 jvm 指令
Monitor机制
管程是指管理共享变量以及对共享变量操作的过程,让它们支持并发。这样说可能有点抽象,来看看一个模型吧,下面这个模型是所有管程模型中使用最为广泛的一种 MESA 模型
MESA 模型(重点理解什么是MESA 模型)
- 在多个线程竞争同一把锁时,MESA 规定所有线程会被添加到一个入口等待队列中,然后再依次唤醒,判断当前线程是否满足执行条件,如果不满足,线程会被添加到条件等待队列中,等着唤醒;
- 管程中引入了条件变量的概念,而且每个条件变量都对应有一个等待队列。条件变量和等待队列的作用是解决线程之间的同步问题。
- 这里可能有一个疑问:既然条件不满足为什么还要唤醒这个线程呢?
原因:线程被唤醒 和 真正执行的时间是不一致的,线程再被唤醒的时候可能是满足条件的,但是在真正执行的时候,可能因为这样或者那样的原因导致条件不满足
MESA 模型(java 中的实现)
java 中的 Object 对象 继承与 jvm 定义的 ObjectMonitor 对象,在 hotspot 的 ObjectMonitor.hpp 中,有以下 ObjectMonitor 定义:
ObjectMonitor() {
_header = NULL;
_count = 0;
_waiters = 0,
_recursions = 0; // 重入次数
_object = NULL;
_owner = NULL; // 锁拥有者
_WaitSet = NULL; // 等待队列
_WaitSetLock = 0 ;
_Responsible = NULL ;
_succ = NULL ;
_cxq = NULL ; // 多线程竞争优先进入CXQ队列(先进后出)
FreeNext = NULL ;
_EntryList = NULL ; // 锁竞争失败线程进入EntryList队列,如果该队列为空,则将cxq队列数据移到该队列
_SpinFreq = 0 ;
_SpinClock = 0 ;
OwnerIsThread = 0 ;
_previous_owner_tid = 0;
}
对比 ObjectMonitor 和 MESA 模型,我们不难发现,_CXQ队列对应的MESA的入口等待队列,_WaitSet对应的MESA的条件等待队列
在获取锁时,是将当前线程插入到_cxq的头部,而释放锁时,默认策略是:如果_EntryList为空,则将_cxq中的元素按原有顺序插入到_EntryList,并唤醒第一个线程,也就是当_EntryList为空时,是后来的线程先获取锁(非公平锁)。_EntryList不为空,直接从_EntryList中唤醒线程。
锁在对象中的存储方式
既然锁是加在java的对象上的,那么Object又是如何存储锁对象的呢?
java 对象的存储分为三个部分:对象头、实例数据、对齐填充。
- 对象头:保存对象的hash值,对象年龄,对象锁状态、数组长度(不一定有,数据对象才会有)
- markword:8个字节,64个Byte
- 元数据指针: 4个字节,32个Byte
- 数组长度:4个字节,32个Byte
- 实例数据:对象属性数据,父对象属性信息等
- 对齐填充:jvm虚拟机,要求对象必须是8字节的整数倍,这块长度只是为了满足该要求
(一个小面试题:Object obj = new Object() 占用多少字节?答案是16 字节【markword 8 + 元数据指针 4 + 对其填充 4】)
64 位机器下的 MarkWord 结构
锁状态模拟
我们通过以下代码模拟锁竞争的场景,运行以下代码需要一个依赖
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.9</version>
</dependency>
我们 new 两个线程来模拟线程竞争的场景,在 Thread1 解锁后,让 Thread1 再 sleep 一秒钟,模拟轻微锁竞争场景;
public static void main(String[] args) throws InterruptedException {
Object lockBefore = new Object();
System.out.println("JVM 初始化完成前初始化的对象:");
System.out.println(ClassLayout.parseInstance(lockBefore).toPrintable());
Thread.sleep(5000);
Object lock = new Object();
System.out.println("JVM 初始化完成后初始化的对象:");
System.out.println(ClassLayout.parseInstance(lock).toPrintable());
System.out.println("==============================================================================================");
new Thread(()->{
synchronized (lock){
System.out.println(Thread.currentThread().getName() + "=============> 加锁的情况下:");
System.out.println(ClassLayout.parseInstance(lock).toPrintable());
System.out.println("==============================================================================================");
}
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
},"Thread1").start();
Thread.sleep(1);
new Thread(()->{
synchronized (lock){
System.out.println(Thread.currentThread().getName() + "=============> 加锁的情况下:");
System.out.println(ClassLayout.parseInstance(lock).toPrintable());
System.out.println("==============================================================================================");
}
},"Thread2").start();
Thread.sleep(8000);
System.out.println("全部解锁之后");
System.out.println(ClassLayout.parseInstance(lock).toPrintable());
System.out.println("==============================================================================================");
}
运行后我们可以得到以下输出:(我们可以通过前8位 或者 最后8位,来判断锁状态,前缀输出还是后缀输出是你的系统决定的)
- 第一个输出的 lockBefore ,我们可以看到它的锁状态是无锁状态(001 表示无锁);而第二个输出的 lock ,却是偏向锁状态(101 表示偏向锁);jdk6之后,java 新建对象默认开启偏向锁,那为什么第一个 lockBefore 是无锁状态呢?原因是 jvm 虚拟机启动后有接近4s的延迟,这个延迟后才会开启偏向锁;
2. 我们初始化的 Lock 对象默认是偏向锁,但是他的锁拥有者并没有被赋予对应的值,所以它是处于可偏向状态,还没有偏向;可以和 下面的【Thread1=============> 加锁的情况下:】的状态进行比较;
3. 由于我们在 Thread1 synchronized代码块结束后,仍然将线程睡眠了一秒,来模拟轻微锁竞争的场景;
在 Thread2 加锁时,锁的状态已经升级为轻量级锁(00 表示轻量级锁)了;
4. 当所有线程都解锁后,锁状态变为无锁状态,而不是偏向状态;且锁拥有者的也被删除了,恢复到0000的状态
5. 最后可以将 Thread1 中的 sleep 移到synchronized代码块中,这样接口模拟重量级锁加锁过程了;
JVM 初始化完成前初始化的对象:
java.lang.Object object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) e5 01 00 f8 (11100101 00000001 00000000 11111000) (-134217243)
12 4 (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
JVM 初始化完成后初始化的对象:
java.lang.Object object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 05 00 00 00 (00000101 00000000 00000000 00000000) (5)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) e5 01 00 f8 (11100101 00000001 00000000 11111000) (-134217243)
12 4 (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
==============================================================================================
Thread1=============> 加锁的情况下:
java.lang.Object object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 05 f0 5f 47 (00000101 11110000 01011111 01000111) (1197469701)
4 4 (object header) 3a 02 00 00 (00111010 00000010 00000000 00000000) (570)
8 4 (object header) e5 01 00 f8 (11100101 00000001 00000000 11111000) (-134217243)
12 4 (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
==============================================================================================
Thread2=============> 加锁的情况下:
java.lang.Object object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 8a c1 08 46 (10001010 11000001 00001000 01000110) (1174978954)
4 4 (object header) 3a 02 00 00 (00111010 00000010 00000000 00000000) (570)
8 4 (object header) e5 01 00 f8 (11100101 00000001 00000000 11111000) (-134217243)
12 4 (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
==============================================================================================
全部解锁之后
java.lang.Object object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) e5 01 00 f8 (11100101 00000001 00000000 11111000) (-134217243)
12 4 (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
==============================================================================================
由以上我们可以得出锁状态转移情况:
锁状态转移总结
锁升级过程
- jdk1.6之后 默认开启偏向锁,但是 jvm 初始化需要时间,所以会在项目启动4秒后,生成的对象才会是偏向锁,之前的对象 是 无锁状态的;
- 偏向锁解锁之后还是偏向锁
- 轻量级锁 和 重量级锁 解锁后,都回到无锁状态;
- 对象初始化时(jvm初始化 4s后),对象是偏向锁状态,但是他的所属线程为null,也就是处于可偏向状态;
- 第一个线程对锁对象进行加锁后,锁对象的所属线程变成的该线程,锁状态还是偏向锁,状态属于偏向锁状态
- 如果第一个线程还没有结束(锁已经释放),且第二个线程再次加锁,那么锁对象会进入轻量级锁状态;
- 如果第一个线程还没有释放锁,那么第二个线程会进入等待状态,且第二个线程得到锁时为重量级锁;
- 分两个过程
- 过程一:线程二尝试获取锁(此时还是轻量级锁);
- 过程二:没有获取到,那么判断是否时线程二自己加的锁,如果不是,那么锁膨胀,变成重量级锁;
- 分两个过程
- 重量级锁的加锁过程:
- 通过 cas 尝试加锁一次(说不定上一个线程已经释放锁了,这样可以减少后续的自旋,线程挂起的性能损耗)
- 再通过自适应自旋(和 上次获取锁的自旋的次数有关,上次自选次数越少,说明越有机会得到锁,自旋次数越多,否则反之)尝试加锁,
- 如果还是没有加锁成功,那么再进行一次 CAS 尝试加锁
- 还是失败,再次自适应自旋等待;
- 如果还是失败,线程被挂起,然后添加到等待队列中,等待被唤醒
synchronized 的优化
在jdk1.6前,synchronized 做为一个重量锁,频繁的切换用户态和内核态,非常的消耗性能,在jdk1.6中对synchronized 进行了优化,引入了偏向锁、轻量级锁的概念,同时提供了不同场景下的虚拟机对synchronized的优化
偏向锁批量重偏向&批量撤销
- 偏向锁批量重偏向:一个线程A创建了大量对象并执行了初始的同步操作,后来另一个线程B也来将这些对象作为锁对象进行操作,这样会导致大量的偏向锁撤销操作。而这个操作相对于锁重偏向来说是比较消耗性能的;【一开始由线程A创建并执行同步操作
synchronized(object)
,此时这些对象是偏向线程A的;当线程B对这些对象进行加锁操作时,且数量超过一定的阈值,JVM 会认为一开始对象的偏向是错误的,JVM会将剩余所有的对象的偏向状态设置为线程B】 - 批量撤销:一个线程A创建了大量对象并执行了初始的同步操作,后来由多个线程竞争这些对象做为加锁对象进行操作,这种情况下由于多个线程同时竞争这些对象,JVM也不知道该将这些对象怎么偏向,所以JVM干脆将这些对象的偏向进行撤销,这样可以消除偏向锁消除的消耗【偏向锁撤销:是指偏向锁恢复到无锁状态或者膨胀到轻量级锁状态】
自适应自旋
如果 synchronized 升级为重量级锁,如果不能获取到锁,那么线程会挂起,而挂起操作涉及到系统调用,那么就会从用户态切换到内核态执行,这个操作非常耗时,也是重量级锁之所以重量级的原因;
为了防止这个情况的频繁发生,jdk1.6后引入自适应自旋,synchronized 升级为重量级锁后,会反复自旋尝试获取锁,如果能在自旋过程中获取到锁,就可以避免线程阻塞,减少消耗;
自适应自旋:JVM 上一次自旋获取锁成功过,那么就会多自旋几次,如果jvm认为此次获取锁的概率不高,则会少自旋几次;
锁粗化
在一系列操作中,同一个线程对同一个对象反复加锁,JVM 会扩大加锁范围;
例子:
public static void main(String[] args) {
StringBuffer buffer = new StringBuffer();
buffer.append("a").append("b").append("c");
}
我们知道 StringBuffer 是线程安全的,因为StringBuffer的操作都是加锁后操作的,JVM检测到一连串的操作都是同一个线程对同一个对象进行加锁,那么他会扩大加锁范围,从每一个append操作,扩大到第一个到最后一个,以此来减少反复加锁的消耗;
锁消除
锁消除就是JVM在编译期间通过逃逸分析认为代码中的加锁操作完全是多余的,那么jvm会去除这些不必要的加锁操作;
最后的最后
在线蹲赞环节
老板!点赞!