JVM_Synchronized
浅谈下jvm对Synchronized的优化
- 对象的分布
- 锁升级
- 锁优化
Java对象的组成
在HotSpot虚拟机中,对象在内存中存储的布局可以分为3块区域 : 对象头(Header)、实例数据(Instance Data)、填充(Padding)
Header (对象头)
Java对象的对象头由 mark word 和 klass pointer 、Array length
mark word: 存储了同步状态、标识、hashcode、GC状态等等。
klass pointer:存储对象的类型指针,该指针指向它的类元数据
Array length: 数组的长度(如果当前对象是数组)
值得注意的是,如果应用的对象过多,使用64位的指针将浪费大量内存。64位的JVM比32位的JVM多耗费50%的内存。
我们现在使用的64位 JVM会默认使用选项 +UseCompressedOops 开启指针压缩,将指针压缩至32位。
mark word存储结构
以64位操作系统为例,对象头存储内容图例。(在运行期间,Mark Word里的存储数据会随着锁标记位的变化而变化)
简单介绍一下各部分的含义
lock: 锁状态标记位,该标记的值不同,整个mark word表示的含义不同。
biased_lock:偏向锁标记,为1时表示对象启用偏向锁,为0时表示对象没有偏向锁。
biased_lock | Lock | 状态 |
---|---|---|
0 | 01 | 无锁 |
1 | 01 | 偏向锁 |
0 | 00 | 轻量级锁 |
0 | 10 | 重量级锁 |
0 | 11 | GC标记 |
age:Java GC标记位对象年龄。
identity_hashcode:对象标识Hash码,采用延迟加载技术。当对象使用HashCode()计算后,并会将结果写到该对象头中。当对象被锁定时,该值会移动到线程Monitor中。
thread:持有偏向锁的线程ID和其他信息。这个线程ID并不是JVM分配的线程ID号,和Java Thread中的ID是两个概念。
epoch:偏向时间戳。
ptr_to_lock_record:指向栈中锁记录的指针。
ptr_to_heavyweight_monitor:指向线程Monitor的指针。
锁升级与对比
Java SE 1.6为了减少获得锁和释放锁带来的性能消耗,引入“偏向锁” 和 “轻量级锁”
锁一共有4种状态,级别从低到高依次是:无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态.
锁可以升级但不能降级(为了获得锁和释放锁的效率)
JOL工具
借助jol工具打印对象实例分布
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.8</version>
</dependency>
无锁
首先先来看看无锁状态下的对象实例数据分布,简单的一个Class对象
public class ObjectLayoutDemo {
public static class A {
boolean flag = false;
}
public static void main(String[] args) throws InterruptedException {
A a = new A();
System.out.println(ClassLayout.parseInstance(a).toPrintable());
// 手动调用hashCode 触发懒加载
System.out.println(Integer.toBinaryString(a.hashCode()));
System.out.println(ClassLayout.parseInstance(a).toPrintable());
}
}
看看输出结果
蓝色部分:lock
红色部分:hashcode
绿色部分:被指针压缩为32位的klass pointer
偏向锁
引入偏向锁是为了在无多线程竞争的情况下尽量减少不必要的轻量级锁执行路径,因为轻量级锁的获取及释放依赖多次CAS原子指令,而偏向锁只需要在置换ThreadID的时候依赖一次CAS原子指令(由于一旦出现多线程竞争的情况就必须撤销偏向锁,所以偏向锁的撤销操作的性能损耗必须小于节省下来的CAS原子指令的性能消耗)
从无锁状态到偏向锁
当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储偏向的线程ID,以后该线程在进入和退出同步块时,不需要CAS操作来加锁和解锁 , 只需要测试一下对象头的Mark Word是否存储着指向当前线程的偏向锁
以后该线程进入同步块时,不需要CAS进行加锁,只会往当前线程的栈中添加一条Displaced Mark Word为空的Lock Record中,用来统计重入的次数.
退出同步块释放偏向锁时,则依次删除对应Lock Record,但是不会修改对象头中的Thread Id;
偏向锁的撤销
偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程不会主动去释放偏向锁。偏向锁的撤销,需要等待全局安全点(在这个时间点上没有字节码正在执行),它会首先暂停拥有偏向锁的线程,判断锁对象是否处于被锁定状态,撤销偏向锁后恢复到未锁定(标志位为“01”)或轻量级锁(标志位为“00”)的状态。
适用场景
只有一个线程访问同步块
下图显示的
线程1演示了偏向锁初始化的流程
线程2演示了偏向锁的撤销的流程
偏向锁对象头
public class BiasedLockDemo {
public static void main(String[] args) throws InterruptedException {
Thread.sleep(5000);
ObjectLayoutDemo.A a = new ObjectLayoutDemo.A();
System.out.println(ClassLayout.parseInstance(a).toPrintable());
Thread thread1 = new Thread(() -> {
synchronized (a){
System.out.println("thread1 locking");
System.out.println(ClassLayout.parseInstance(a).toPrintable());
}
});
Thread thread2 = new Thread(() -> {
synchronized (a){
//开始撤销, 直接return 模拟偏向锁撤销到未锁定
return;
}
});
thread1.start();
//让thread1执行完同步代码块中方法。
thread1.join();
System.out.println("thread1's state:" + thread1.isAlive());
thread2.start();
System.out.println("after thread2:"+ClassLayout.parseInstance(a).toPrintable());
}
}
结果
分析
这里注意偏向锁是在应用程序启动几秒钟之后才激活的,所以sleep
第一次打印,可以看到偏向锁是开启的.
第二次打印,可以看到记录了线程ID.
第三次偏向锁撤销
轻量级锁
轻量级锁所适应的场景是线程交替执行同步块的情况
public class LightLockDemo {
public static void main(String[] args) throws InterruptedException {
Thread.sleep(5000);
ObjectLayoutDemo.A a = new ObjectLayoutDemo.A();
Thread thread1 = new Thread(){
@Override
public void run() {
synchronized (a){
System.out.println("thread1 locking");
System.out.println(ClassLayout.parseInstance(a).toPrintable());
}
}
};
Thread thread2 = new Thread(){
@Override
public void run() {
synchronized (a){
System.out.println("thread2 locking");
System.out.println(ClassLayout.parseInstance(a).toPrintable());
}
}
};
thread1.start();
//让thread1执行完同步代码块中方法,并退出线程
thread1.join();
System.out.println("thread1's state:" + thread1.isAlive());
thread2.start();
}
}
结果
分析
第一次是偏向锁
第二次撤销偏向锁, 升级为轻量级锁
注意 两次执行 指向栈的指针 也不一致了
重量级锁
轻量级锁升级重量级
线程1首先尝试 CAS将对象头中的Mark Word替换为指向锁的指针.(在执行同步块前,JVM会先在当前线程的栈帧中创建用于存储锁记录的空间,并将对象头中的Mark Word复制到所记录中, Displaced Mark Word)
线程2 CAS 同样尝试,获取失败,尝试自旋获取(成功,说明线程1退出了同步块)失败升级为 重量级锁,阻塞等待唤醒
重量级锁解锁
线程1 解锁CAS 将Displace Mark Word替换回对象头, 失败,对象头已被线程2修改(膨胀为重量级锁),释放锁并唤醒等待的线程(同时清除指向栈的指针)
example
public static void main(String[] args) throws InterruptedException {
Thread.sleep(5000);
ObjectLayoutDemo.A a = new ObjectLayoutDemo.A();
Thread thread1 = new Thread(() -> {
synchronized (a) {
System.out.println("thread1 locking");
System.out.println(ClassLayout.parseInstance(a).toPrintable());
try {
//让线程晚点儿死亡,造成锁的竞争
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
Thread thread2 = new Thread(() -> {
synchronized (a) {
System.out.println("thread2 locking");
System.out.println(ClassLayout.parseInstance(a).toPrintable());
}
});
thread1.start();
//模拟a从轻量级锁到重量级锁到转变
Thread.sleep(1000);
thread2.start();
}
结果
分析
第一次打印是轻量级锁
第二次打印为重量级锁
注意 两次执行的线程ID也不一致了
重量级锁、轻量级锁和偏向锁之间转换
重量级锁、轻量级锁和偏向锁之间对比
锁 | 优点 | 缺点 | 适用场景 |
---|---|---|---|
偏向锁 | 加锁和解锁不需要额外的消耗,和执行非同步方法比仅存在纳秒级的差距。 | 如果线程间存在锁竞争,会带来额外的锁撤销的消耗。 | 适用于只有一个线程访问同步块场景。 |
轻量级锁 | 竞争的线程不会阻塞,提高了程序的响应速度。 | 如果始终得不到锁竞争的线程使用自旋会消耗CPU。 | 追求响应时间。同步块执行速度非常快。 |
重量级锁 | 线程竞争不使用自旋,不会消耗CPU。 | 线程阻塞,响应时间缓慢。 | 追求吞吐量。同步块执行速度较长。 |
锁优化
jvm层面
适应式自旋
适应性自旋,简单来说就是线程如果自旋成功了,则下次自旋的次数会更多,如果自旋失败了,则自旋的次数就会减少。
锁消除
锁消除是指虚拟机即时编译器(JIT)在运行时,对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行消除。
-server -XX:+DoEscapeAnalysis -XX:+EliminateLocks
锁粗化
如果一系列的连续操作都对同一个对象反复加锁和解锁,甚至加锁操作是出现在循环体中的,那即使没有线程竞争,频繁地进行互斥同步操作也会导致不必要的性能损耗。
语言编程
减少锁持有的时间
只对需要同步代码加锁
锁分离
ReadWriteLock
减小锁的力度
ConcurrentHashMap
link:https://houbb.github.io/2018/10/08/jvm-30-lock-optimize
link:https://www.cnblogs.com/paddix/p/5405678.html
link : https://www.cnblogs.com/LemonFive/p/11246086.html
link:https://researcher.watson.ibm.com/researcher/files/us-bacon/Bacon98ThinSlides.pdf
深入理解java虚拟机
java并发编程艺术
转载请注明来源,欢迎对文章中的引用来源进行考证,欢迎指出任何有错误或不够清晰的表达。可以在下面评论区评论,也可以邮件至 951488791@qq.com