synchronized 使用
synchronized 同步块是 Java 提供的一种原子性内置锁,Java 中的每个对象都可以把它当作一个同步锁来使用,这些 Java 内置的使用者看不到的锁被称为内置锁,也叫作监视器锁。
加锁方式
//实例方法 锁对象 ->类的实例对象
public synchronized void add(){
}
//静态方法 锁对象->类对象
public static synchronized void add(){
}
//同步代码块 锁对象->该类的实例对象
public void sub(){
synchronized (this){
}
}
//同步代码块 锁对象-> 类对象
public void sub() {
synchronized (Object.class) {
}
}
synchronized 底层原理
synchronized是JVM内置锁,基于Monitor机制实现,依赖底层操作系统的互斥原语Mutex(互斥量),它是一个重量级锁,性能较低。当然,JVM内置锁在1.5之后版本做了重大的优化,如锁粗化(Lock Coarsening)、锁消除(Lock Elimination)、轻量级锁(Lightweight Locking)、偏向锁(Biased Locking)、自适应自旋(Adaptive Spinning)等技术来减少锁操作的开销,内置锁的并发性能已经基本与Lock持平。
我们通过使用 jclasslib插件查看字节码指令 ,我们可以看出同步方法是通过方法中的access_flags中设置ACC_SYNCHRONIZED标志来实现;同步代码块是通过monitorenter和monitorexit来实现。两个指令的执行是JVM通过调用操作系统的互斥原语mutex来实现,被阻塞的线程会被挂起、等待重新调度,会导致“用户态和内核态”两个态之间来回切换,对性能有较大影响。
同步方法:access_flags中设置ACC_SYNCHRONIZED
同步代码块:通过monitorenter和monitorexit来实现
思考:synchronized加锁加在对象上,锁对象是如何记录锁状态的?
对象内存布局
在Hotspot 中 对象在内存中存储的布局分为三块区域:对象头(Header) 、实例数据 、对齐填充
- 对象头:对象的hashcode,分代年龄,锁标记位,偏向锁线程ID,偏向时间,数组长度(只有数组对象才有)
- 实例数据:类的属性数据信息,包括父类的属性数据信息
- 对齐填充:由于虚拟机要求 对象起始地址必须是8字节的整数倍。填充数据不是必须存在的,仅仅是为了字节对齐
对象头
-
Mark Word
用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等,这部分数据的长度在32位和64位的虚拟机中分别为32bit和64bit,官方称它为“Mark Word”。 -
Klass Pointer
对象头的另外一部分是klass类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。 32位4字节,64位开启指针压缩或最大堆内存<32g时4字节,否则8字节。jdk1.8默认开启指针压缩后为4字节,当在JVM参数中关闭指针压缩(-XX:-UseCompressedOops)后,长度为8字节。 -
数组长度(只有数组对象有)
如果对象是一个数组, 那在对象头中还必须有一块数据用于记录数组长度。 4字节
使用JOL 工具查看对象内存布局
引入maven
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.10</version>
<dependency>
测试
public static void main(String[] args) {
Object obj = new Object();
//查看对象内部信息
System.out.println(ClassLayout.parseInstance(obj).toPrintable());
}
利用jol查看64位系统java对象(空对象),默认开启指针压缩,总大小显示16字节,前12字节为对象头
2. 关闭指针压缩后,对象头为16字节:-XX:-UseCompressedOops
由此我们可以看出一个空的Object 对象占16 个字节
回到上面的问题synchronized加锁加在对象上,对象是如何记录锁状态的?
锁状态标记在对象头中
那么锁标记如何在对象头中,并且如何发生变化的
64位jvm对象头结构描述
偏向锁
偏向锁是一种针对加锁操作的优化手段,经过研究发现,在大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,因此为了消除数据在无竞争情况下锁重入(CAS操作)的开销而引入偏向锁。对于没有锁竞争的场合,偏向锁有很好的优化效果。
public static void main(String[] args) throws InterruptedException {
//查看对象内部信息
System.out.println(ClassLayout.parseInstance(new Object()).toPrintable());
Thread.sleep(4000); //JVM默认延时加载偏向锁 4s //关闭延迟开启偏向锁-XX:BiasedLockingStartupDelay=0//禁止偏向锁-XX:-UseBiasedLocking //启用偏向锁 -XX:+UseBiasedLocking
System.out.println(ClassLayout.parseInstance(new Object()).toPrintable());
}
4s 后 锁标记位变为可偏向或者匿名偏向状态
如果调用对象的hashcode 还是偏向锁吗?
public static void main(String[] args) throws InterruptedException {
//查看对象内部信息
System.out.println(ClassLayout.parseInstance(new Object()).toPrintable());
Thread.sleep(4000); //JVM默认延时加载偏向锁 4s //关闭延迟开启偏向锁-XX:BiasedLockingStartupDelay=0//禁止偏向锁-XX:-UseBiasedLocking //启用偏向锁 -XX:+UseBiasedLocking
Object obj = new Object();
obj.hashCode();
System.out.println(ClassLayout.parseInstance(obj).toPrintable());
}
从图中我们会发现锁标记位发生变化,偏向锁变成无锁状态 发生了偏向锁被撤销
为什么会发生这种现象?
由偏向锁对象头结构中可以看出,是无法保存hashcode 值的,所以就发生了锁撤销。
- 轻量级锁:会在线程栈帧中保存一份MarkWork 存储hashcode值
- 重量级锁:会在 Monitor 中记录 hashCode
轻量级锁
倘若偏向锁失败,虚拟机并不会立即升级为重量级锁,它还会尝试使用一种称为轻量级锁的优化手段,此时Mark Word 的结构也变为轻量级锁的结构。轻量级锁所适应的场景是线程交替执行同步块的场合,如果存在同一时间多个线程访问同一把锁的场合,就会导致轻量级锁膨胀为重量级锁。
public static void main(String[] args) throws InterruptedException {
//查看对象内部信息
System.out.println(ClassLayout.parseInstance(new Object()).toPrintable());
Thread.sleep(4000); //JVM默认延时加载偏向锁 4s //关闭延迟开启偏向锁-XX:BiasedLockingStartupDelay=0//禁止偏向锁-XX:-UseBiasedLocking //启用偏向锁 -XX:+UseBiasedLocking
Object obj = new Object();
Thread thread = new Thread(() -> {
synchronized (obj) {
}
System.out.println(ClassLayout.parseInstance(obj).toPrintable());
});
thread.start();
Thread.sleep(1000);
Thread thread1 = new Thread(() -> {
synchronized (obj) {
System.out.println(ClassLayout.parseInstance(obj).toPrintable());
}
System.out.println(ClassLayout.parseInstance(obj).toPrintable());
});
thread1.start();
}
从图中可以看出当出现轻微竞争时锁的标记位有偏向锁变成轻量级锁
重量级锁
重量级锁是直接调用ObjectMonitor的enter和exit完成对操作系统级别的重量级锁mutex的使用,这使得每次上锁都需要从用户态转内核态尝试获取重量级锁的过程。
ObjectMonitor() {
_header = NULL; //对象头 markOop
_count = 0;
_waiters = 0,
_recursions = 0; // 锁的重入次数
_object = NULL; //存储锁对象
_owner = NULL; // 标识拥有该monitor的线程(当前获取锁的线程)
_WaitSet = NULL; // 等待线程(调用wait)组成的双向循环链表,_WaitSet是第一个节点
_WaitSetLock = 0 ;
_Responsible = NULL ;
_succ = NULL ;
_cxq = NULL ; //多线程竞争锁会先存到这个单向链表中 (FILO栈结构)
FreeNext = NULL ;
_EntryList = NULL ; //存放在进入或重新进入时被阻塞(blocked)的线程 (也是存竞争锁失败的线程)
_SpinFreq = 0 ;
_SpinClock = 0 ;
OwnerIsThread = 0 ;
_previous_owner_tid = 0;
在获取锁时,是将当前线程插入到cxq的头部,而释放锁时,默认策略(QMode=0)是:如果EntryList为空,则将cxq中的元素按原有顺序插入到EntryList,并唤醒第一个线程,也就是当EntryList为空时,是后来的线程先获取锁。_EntryList不为空,直接从_EntryList中唤醒线程。
public static void main(String[] args) throws InterruptedException {
//查看对象内部信息
System.out.println(ClassLayout.parseInstance(new Object()).toPrintable());
Thread.sleep(4000); //JVM默认延时加载偏向锁 4s //关闭延迟开启偏向锁-XX:BiasedLockingStartupDelay=0//禁止偏向锁-XX:-UseBiasedLocking //启用偏向锁 -XX:+UseBiasedLocking
Object obj = new Object();
Thread thread = new Thread(() -> {
synchronized (obj) {
}
System.out.println(ClassLayout.parseInstance(obj).toPrintable());
});
thread.start();
Thread.sleep(1000);
Thread thread1 = new Thread(() -> {
synchronized (obj) {
System.out.println(ClassLayout.parseInstance(obj).toPrintable());
}
System.out.println(ClassLayout.parseInstance(obj).toPrintable());
});
thread1.start();
Thread thread2 = new Thread(() -> {
synchronized (obj) {
System.out.println(ClassLayout.parseInstance(obj).toPrintable());
}
System.out.println(ClassLayout.parseInstance(obj).toPrintable());
});
thread2.start();
}
从图中可以看出存在多线程竞争时,轻量级锁会升级重量级锁
总结
- HotSpot 的作者经过研究发现,大多数情况下,锁不仅不存在多线程竞争,而且总是有一个线程多次获得,为了让线程获得锁的代价更低而引入了偏向锁。当一个线程访问同步代码块并且获取锁时,会在对象头和栈帧中的锁记录存储锁偏向的线程ID。
- 轻量级锁加锁,当多线程存在轻微竞争,偏向锁CAS 偏向失败,线程在执行同步代码块之前,JVM 会在当前线程的栈帧中创建用于存储记录的空间,并将对象头的Mark Word 复制到记录中,官方称为Displaced Mark Word。然后线程尝试使用CAS将对象头中的Mark Word 替换为指向锁记录的指针,如果成功,当前线程获得锁,如果失败,表示其他线程竞争锁,当前线程便尝试使用自适应自旋来获取锁。
- 当存在多线程竞争时,自适应自旋获取轻量级锁失败,锁会膨胀成重量级锁。
进阶 synchroized 锁优化
偏向锁批量重偏向 & 批量撤销
从偏向锁的加锁解锁过程中可看出,当只有一个线程反复进入同步块时,偏向锁带来的性能开销基本可以忽略,但是当有其他线程尝试获得锁时,就需要等到全局安全点(在这个时间点上没有正在执行的代码)时,再将偏向锁撤销为无锁状态或升级为轻量级,会消耗一定的性能,所以在多线程竞争频繁的情况下,偏向锁不仅不能提高性能,还会导致性能下降。于是,就有了批量重偏向与批量撤销的机制。
原理
以class为单位,为每个class维护一个偏向锁撤销计数器,每一次该class的对象发生偏向撤销操作时,该计数器+1,当这个值达到重偏向阈值(默认20)时,JVM就认为该class的偏向锁有问题,因此会进行批量重偏向。
每个class对象会有一个对应的epoch字段,每个处于偏向锁状态对象的Mark Word中也有该字段,其初始值为创建该对象时class中的epoch的值。每次发生批量重偏向时,就将该值+1,同时遍历JVM中所有线程的栈,找到该class所有正处于加锁状态的偏向锁,将其epoch字段改为新值。下次获得锁时,发现当前对象的epoch值和class的epoch不相等,那就算当前已经偏向了其他线程,也不会执行撤销操作,而是直接通过CAS操作将其Mark Word的Thread Id 改成当前线程Id。
当达到重偏向阈值(默认20)后,假设该class计数器继续增长,当其达到批量撤销的阈值后(默认40),JVM就认为该class的使用场景存在多线程竞争,会标记该class为不可偏向,之后,对于该class的锁,直接走轻量级锁的逻辑。
测试:批量重偏向
我们可以通过-XX:BiasedLockingBulkRebiasThreshold 和 -XX:BiasedLockingBulkRevokeThreshold 来手动设置批量重偏向阈值 默认值为20
public static void main(String[] args) throws InterruptedException {
//延时产生可偏向对象
Thread.sleep(5000);
// 创建一个list,来存放锁对象
List<Object> list = new ArrayList<>();
// 线程1
new Thread(() -> {
for (int i = 0; i < 50; i++) {
// 新建锁对象
Object lock = new Object();
synchronized (lock) {
list.add(lock);
}
}
try {
//为了防止JVM线程复用,在创建完对象后,保持线程thead1状态为存活
Thread.sleep(100000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}, "thead1").start();
//睡眠3s钟保证线程thead1创建对象完成
Thread.sleep(3000);
System.out.println("打印thead1,list中第20个对象的对象头:" +ClassLayout.parseInstance(list.get(19)).toPrintable());
// 线程2
new Thread(() -> {
for (int i = 0; i < 40; i++) {
Object obj = list.get(i);
synchronized (obj) {
if(i>=15&&i<=21||i>=38){
System.out.println("thread2-第" + (i + 1) + "次加锁执行中\t"+
ClassLayout.parseInstance(obj).toPrintable());
}
}
if(i==17||i==19){
System.out.println("thread2-第" + (i + 1) + "次释放锁\t"+
ClassLayout.parseInstance(obj).toPrintable());
}
}
try {
Thread.sleep(100000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}, "thead2").start();
LockSupport.park();
}
1-18 偏向锁撤销,升级为轻量级锁 (thread1释放锁之后为偏向锁状态)
19-40 偏向锁撤销达到阈值(20),执行了批量重偏向 (测试结果在第19就开始批量重偏向了)
锁粗化
假设一系列的连续操作都会对同一个对象反复加锁及解锁,甚至加锁操作是出现在循环体中的,即使没有出现线程竞争,频繁地进行互斥同步操作也会导致不必要的性能损耗。如果JVM检测到有一连串零碎的操作都是对同一对象的加锁,将会扩大加锁同步的范围(即锁粗化)到整个操作序列的外部。
StringBuffer buffer = new StringBuffer();
/**
* 锁粗化
*/
public void append(){
buffer.append("aaa").append(" bbb").append(" ccc");
@Override
public synchronized StringBuffer append(String str) {
toStringCache = null;
super.append(str);
return this;
}
因为StringBuffer 是线程安全的,我们可以看到,StringBuffer append 方法 加上了synchronized,我们平时在写代码时可能会连续调用append 方法,这样会一直对同一个对象加锁,而JVM检测到有一连串零碎的操作都是对同一对象的加锁,将会扩大加锁同步的范围(即锁粗化)到整个操作序列的外部。
锁消除
锁消除即删除不必要的加锁操作。锁消除是Java虚拟机在JIT编译期间,通过对运行上下文的扫描,去除不可能存在共享资源竞争的锁,通过锁消除,可以节省毫无意义的请求锁时间
测试
public class Test {
public static void main(String[] args) throws InterruptedException {
/**
* 锁消除
* -XX:+EliminateLocks 开启锁消除(jdk8默认开启)
* -XX:-EliminateLocks 关闭锁消除
* @param str1
* @param str2
*/
long start = System.currentTimeMillis();
for (int i = 0; i < 100000000; i++) {
Test.append("aaa", "bbb");
}
long end = System.currentTimeMillis();
System.out.println("执行时间:" + (end - start) + " ms");
}
public static void append(String str1, String str2) {
StringBuffer stringBuffer = new StringBuffer();
stringBuffer.append(str1).append(str2);
}
}
StringBuffer的append是个同步方法,但是append方法中的 StringBuffer 属于一个局部变量,不可能从该方法中逃逸出去,因此其实这过程是线程安全的,可以将锁消除。
测试结果: 关闭锁消除执行时间3304ms 开启锁消除执行时间:2021ms
参考文献
本文参考《并发编程的艺术》
synchroized 原理