目录
一、同步锁简介
在并发编程中,多个线程访问同一个共享资源时,我们必须考虑如何维护数据的原子性。在 JDK1.5 之前,Java 是依靠 Synchronized 关键字实现锁功能来做到这点的。Synchronized 是 JVM 实现的一种内置锁,锁的获取和释放是由 JVM 隐式实现。
到了 JDK1.5 版本,并发包中新增了 Lock 接口来实现锁功能,它提供了与 Synchronized 关键字类似的同步功能,只是在使用时需要显式获取和释放锁。
Lock 同步锁是基于 Java 实现的,而 Synchronized 是基于底层操作系统的 Mutex Lock 实现的,每次获取和释放锁操作都会带来用户态和内核态的切换,从而增加系统性能开销。因此,在锁竞争激烈的情况下,Synchronized 同步锁在性能上表现较差,常被称为重量锁。
特别是在单个线程重复申请锁的情况下,JDK1.5 版本的 Synchronized 锁性能要比 Lock 的性能差很多。如果使用 Synchronized 同步锁,那么每当同一个线程请求锁资源时,都会发生一次用户态和内核态的切换。
到了 JDK1.6 版本之后,Java 对 Synchronized 同步锁做了充分的优化,甚至在某些场景下,它的性能已经超越了 Lock 同步锁。 Synchronized 同步锁究竟是通过了哪些优化,实现了性能地提升。
二、Synchronized 同步原理
通常 Synchronized 实现同步锁的方式有两种,一种是修饰方法,一种是修饰方法块。以下就是通过 Synchronized 实现的两种同步方法加锁的方式:
// 关键字在实例方法上,锁为当前实例
public synchronized void method1() {
// code
}
// 关键字在代码块上,锁为括号里面的对象
public void method2() {
Object o = new Object();
synchronized (o) {
// code
}
}
通过反编译看下具体字节码的实现,运行以下反编译命令,就可以输出我们想要的字节码:
javap -v SyncTest.class //再通过javap打印出字节文件
通过输出的字节码会发现:Synchronized 在修饰同步代码块时,是由 monitorenter 和 monitorexit 指令来实现同步的。进入 monitorenter 指令后,线程将持有 Monitor 对象,退出 monitorenter 指令后,线程将释放该 Monitor 对象。
public void method2();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=2, locals=4, args_size=1
0: new #2 // class java/lang/Object
3: dup
4: invokespecial #1 // Method java/lang/Object."<init>":()V
7: astore_1
8: aload_1
9: dup
10: astore_2
11: monitorenter
12: aload_2
13: monitorexit
14: goto 22
17: astore_3
18: aload_2
19: monitorexit
20: aload_3
21: athrow
22: return
Exception table:
from to target type
12 14 17 any
17 20 17 any
LineNumberTable:
line 16: 0
line 17: 8
line 19: 12
line 20: 22
StackMapTable: number_of_entries = 2
frame_type = 255 /* full_frame */
offset_delta = 17
locals = [ class com/scf/SyncTest, class java/lang/Object, class java/lang/Object ]
stack = [ class java/lang/Throwable ]
frame_type = 250 /* chop */
offset_delta = 4
再来看同步方法的字节码,你会发现:当 Synchronized 修饰同步方法时,并没有发现 monitorenter 和 monitorexit 指令,而是出现了一个 ACC_SYNCHRONIZED 标志。
public synchronized void method1();
descriptor: ()V
flags: ACC_PUBLIC, ACC_SYNCHRONIZED
Code:
stack=0, locals=1, args_size=1
0: return
LineNumberTable:
line 12: 0
这是因为 JVM 使用了 ACC_SYNCHRONIZED 访问标志来区分一个方法是否是同步方法。当方法调用时,调用指令将会检查该方法是否被设置 ACC_SYNCHRONIZED 访问标志。如果设置了该标志,执行线程将先持有 Monitor 对象,然后再执行方法。在该方法运行期间,其它线程将无法获取到该 Mointor 对象,当方法执行完成后,再释放该 Monitor 对象。
JVM 中的同步是基于进入和退出管程(Monitor)对象实现的。每个对象都会有一个 Monitor,Monitor 可以和对象一起创建、销毁。Monitor 是由 ObjectMonitor 实现,而 ObjectMonitor 是由C++ 的 ObjectMonitor.hpp 实现的。
ObjectMonitor() {
_header = NULL;
_count = 0; //记录个数
_waiters = 0,
_recursions = 0;
_object = NULL;
_owner = NULL;
_WaitSet = NULL; //处于wait状态的线程,会被加入到_WaitSet
_WaitSetLock = 0 ;
_Responsible = NULL ;
_succ = NULL ;
_cxq = NULL ;
FreeNext = NULL ;
_EntryList = NULL ; //处于等待锁block状态的线程,会被加入到该列表
_SpinFreq = 0 ;
_SpinClock = 0 ;
OwnerIsThread = 0 ;
}
当多个线程访问一段代码时,多个线程会先被存放在 _EntryList 集合中,处于 block 状态的线程会被加入到该列表中。接下来,当线程获取到 Monitor 时,Monitor是依靠底层操作系统的Mutex Lock 来实现互斥的,线程申请 Mutex 成功,则持有该 Mutex,其他线程将无法获取到该Mutex,竞争失败的线程会再次进入 _EntryList 被挂起。
如果线程调用 wait 方法,就会释放当前持有的 Mutex,并且该线程进入到 _WaitSet 集合中,等待下一次被唤醒。如果当前线程顺利执行完成,也将释放 Mutex。
同步锁在这种实现方式中,因 Monitor 是依赖于底层的操作系统实现,存在用户态与内核态之间的切换,所以增加了性能开销。
三、锁升级优化
为了提升性能,JDK1.6 引入了偏向锁、轻量级锁、重量级锁概念,来减少锁竞争带来的上下文切换,而正是新增的 Java 对象头实现了锁升级功能。
当 Java 对象被 Synchronized 关键字修饰成为同步锁后,围绕这个锁的一些列升级操作都将和 Java 对象头有关。
3.1 java 对象头
对象实例在堆内存中被分为3部分:对象头、实例数据、填充对齐。其中对象头由 Mark Word、指向类的指针以及数组长度三部分组成。
Mark Word 记录了对象和锁有关的信息。Mark Word 在 64 位 JVM 中的长度是 64bit,64位JVM存储结构如下图:
锁升级功能主要依赖于Mark Word中的锁标志位和释放偏向锁标志位,Synchronized 同步锁就是从偏向锁开始的,随着竞争越来越激烈,偏向锁升级到轻量级锁,最终升级到重量级锁。
下面带你来真实的看下对象头的信息:
添加依赖
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>version</version>
</dependency>
对象头查看
// 定义实体类
public class User {
private String name;
private Integer age;
private boolean sex;
// 省略 get set
}
public class Test {
public static void main(String[] args) {
User user = new User();
ClassLayout classLayout = ClassLayout.parseInstance(user);
// 获取对象头信息
String s = classLayout.toPrintable();
System.out.println(s);
}
}
64位虚拟机的对象头大小(未开启指针压缩)
对象头占用内存大小 16*8bit=128bit。jdk8 版本默认是开启指针压缩的,通过 jvm 参数可以关闭指针压缩:-XX:-UseCompressedOops。开启指针压缩后对象的大小如下图,开启指针压缩后对象头的大小为 12*8bit=96bit
在 java 程序运行的过程中,每创建一个新对象,JVM 就会相应的创建一个对象类型的 oop对象,存储在堆中。markOop 则是 MarkOopDesc 类型指针,markOopDesc 就是MarkWord。
3.2 偏向锁
偏向锁主要用来优化同一线程多次申请同一个锁的竞争。在某些场景下,大部分时间是同一个线程竞争锁资源,同一个线程每次都需要获取和释放锁,都会发生用户态和内核态的切换。
偏向锁的作用就是,当一个线程再次访问这个同步代码或方法时,该线程只需去对象头 Mark Word 中去判断一下是否偏向锁指向他的ID,无需在进入 Monitor 去竞争锁对象。当对象被当做同步锁并有一个线程抢到锁时,锁标志位还是01,是否偏向锁标志位设置为1,并且记录抢到锁的线程 ID,表示进入偏向锁状态。
一旦出现其他线程竞争锁资源,偏向锁就会被撤销。偏向锁的撤销需要等到全局安全点,暂停持有该锁的线程,同时检查该线程是否还在执行方法,如果是,则升级锁,反之则被其他线程抢占。
因此,在高并发场景下,当大量线程同时竞争一个锁资源时,偏向锁就会被撤销,发生 STW 后,开启偏向锁无疑会带来更大的性能开销。
3.3 轻量级锁
当有另外一个线程竞争获取这个锁时,由于该锁已经是偏向锁,当发现对象头 Mark Word 中的线程 ID 不是自己的线程 ID,就会进行 CAS 操作获取锁,如果获取成功,直接替换 Mark Word 中的线程 ID 为自己的 ID,该锁会保持偏向锁状态;如果获取锁失败,代表当前锁有一定的竞争,偏向锁将升级为轻量级锁。
轻量级锁适用于线程交替执行同步块的场景,绝大部分的锁在整个同步周期内都不存在长时间的竞争。
3.4 自旋锁与重量级锁
轻量级锁 CAS 抢锁失败,线程将会被挂起进入阻塞状态。所有持有锁的线程在很多的时间内释放了锁资源,那么进入阻塞状态的线程无疑又要申请锁资源。
JVM 提供了一种自旋锁,可以通过自旋的方式不断的获取锁,从而避免线程被阻塞。这是基于大多数情况下,线程持有锁的时间不长,毕竟线程被挂起有点浪费。
从 JDK1.7 开始,自旋锁默认启用,自旋次数由 JVM 设置决定,这里我不建议设置的重试次数过多,因为 CAS 重试操作意味着长时间地占用 CPU。
自旋锁重试之后如果抢锁依然失败,同步锁就会升级为重量级锁。在这个状态下,未抢到锁的线程都会进入 Monitor,之后被阻塞在 _WaitSet 队列中。
在锁竞争不激烈且锁占用时间非常短的场景下,自旋锁可以提高系统性能。一旦锁竞争激烈或锁占用的时间过长,自旋锁将会导致大量的线程一直处于 CAS 重试状态,占用 CPU 资源,反而会增加系统性能开销。所以自旋锁和重量级锁的使用都要结合实际场景。
3.5 锁优化
锁消除、锁粗化
Java 还使用了编译器对锁进行优化。JIT 编译器在动态编译同步块的时候,借助了一种被称为逃逸分析的技术,来判断同步块使用的锁对象是否只能够被一个线程访问,而没有被发布到其它线程。
如果当前锁不会逃逸出当前的线程,那么 JIT 编译器在编译这个同步块的时候不会生成 synchronized 所表示的锁的申请与释放的机器码,即消除了锁的使用。在 Java7 之后的版本就不需要手动配置了,该操作可以自动实现。
锁粗化同理,就是在 JIT 编译器动态编译时,如果发现几个相邻的同步块使用的是同一个锁实例,那么 JIT 编译器将会把这几个同步块合并为一个大的同步块,从而避免一个线程“反复申请、释放同一个锁”所带来的性能开销。
减小锁粒度
除了锁内部优化和编译器优化之外,我们还可以通过代码层来实现锁优化,减小锁粒度就是一种惯用的方法。
当我们的锁对象是一个数组或队列时,集中竞争一个对象的话会非常激烈,锁也会升级为重量级锁。我们可以考虑将一个数组和队列对象拆成多个小对象,来降低锁竞争,提升并行度。
最经典的减小锁粒度的案例就是 JDK1.8 之前实现的 ConcurrentHashMap 版本。我们知道,HashTable 是基于一个数组 + 链表实现的,所以在并发读写操作集合时,存在激烈的锁资源竞争,也因此性能会存在瓶颈。而 ConcurrentHashMap 就很很巧妙地使用了分段锁 Segment 来降低锁资源竞争。
总之,在工作中药结合实际场景,合理的应用同步锁,使程序达到最优的性能。
往期经典推荐
SpringBoot项目并发处理大揭秘,你知道它到底能应对多少请求洪峰?_一个springboot能支持多少并发-CSDN博客