synchronized详解
分析了Java锁的一些底层原理,该博文底部的四篇参考文章也挺有意思,有时间可以看一下
synchronized使用方法与注意事项
使用方法
- 对象锁
- 类锁
具体的使用方法太过于基础,不再在这里赘述了。
注意事项
在应用synchronized的时候,有一些细节需要注意:
- 被锁住的代码块发生异常会怎么样?
- synchronized修饰的方法(代码块),如果在sync锁住的代码块内抛出了异常,那么该代码块将会把这个sync锁释放掉。(原理见第二章字节码分析)
一直以来都没有细想过在sync锁住的代码中抛出异常会发生什么事情,这次算是弄明白了。
synchronized的原理分析
加锁和释放sync锁在字节码层的原理
以如下代码举例:
public class SynchronizedDemo {
Object objLock = new Object();
// 加锁
public void method1() {
synchronized (objLock) {
}
}
}
将该段代码编译成.class文件再反编译查看,如下图所示:
可以看到:synchronized同步代码块的实现是使用了monitorenter和monitorexit指令。
:::info
Q:为什么要有两个exit指令呢?
有两个monitorexit是为了在有异常被抛出的时候,也能够正确释放sync锁。
:::
Java中对象在内存里的实例
如果想要了解sync锁底层的工作原理,首先要知道Java对象实例在内存中是什么样的。
- 对象头,由Mark Word和Klass Point组成。
- Mark Word:存储对象自身运行时数据,例如HashCode,GC分代年龄,锁标志位等信息。64位JVM的Mark Word如下:
- Klass Point:指向对象所属的类元数据。
- 实例数据
- 这个对象实例所拥有的一些属性的值。
- 字节对齐
- 用来保证对象实例在内存中占用的空间是字节的倍数。
接下来,我们就要开始了解JVM中sync锁的三种形态了,分别是:偏向锁,轻量级锁,重量级锁。
重量级锁
重量级锁的锁对象Mark Word中有一个指向Monitor监视器的指针,该Monitor中有三个字段:
- WaitSet
- Owner
轻量级锁
轻量级锁的设计思想是:允许有多个线程都能获取到这个锁,但是获取的时候不能发生竞争,否则要升级为重量级锁。
操作步骤:
- 在刚开始,当前线程的虚拟机栈中会有一块Lock Record区域,记载了该区域的地址以及一个00的轻量级锁标记,和一个指向要加锁的Object的指针。
- 当前线程尝试CAS交换锁对象的Mark Word到自己的Lock Record,并且将Object指针指向该锁对象。
如果CAS失败,有两种情况:
- 如果发现Mark Word中记录了指向自己线程栈的指针,则是自己执行了synchronized的锁重入,
- 如果是其他线程已经持有了该Object的轻量级锁,表明了出现锁竞争,进入锁膨胀过程。
轻量级锁的膨胀
在轻量级锁尝试加锁的时候,会先尝试CAS将锁对象头的Mark Word中带有01无锁标记的Mark Word替换为自己线程的虚拟机栈中的Lock Record区域记录的地址值加上一个00轻量级锁标记。
如果CAS失败,发现锁对象头中已经是00轻量级锁,并且获取到锁的线程不是自己,则会进入一个锁膨胀为重量级锁的阶段。
锁膨胀所做的工作:将Mark Word修改为指向重量级锁的Monitor的指针,并且将当前线程添加到该Monitor的等待队列中,阻塞等待锁。之前拥有轻量级锁的线程在解锁的时候会尝试CAS交换锁对象头的Mark Word,就会发现锁已经升级成了重量级锁,这时,就会走重量级锁的解锁流程。
偏向锁
:::info
Q:为什么要引入偏向锁?
-
轻量级锁在没有其他线程竞争锁的时候,每次重入锁时,仍然需要执行CAS操作,然后判断出持有锁的线程就是自己这个线程,之后往线程的虚拟机栈中添加一个Lock Record,其中的记录为null。
-
偏向锁的设计初衷,就是有些时候从头到尾只有同一个线程会获得某个对象锁,那么重量级锁和轻量级锁的操作都会略显繁杂,可以在判断锁的步骤上进行优化。
:::
要研究清楚偏向锁,首先要回忆一下Java内存中对象头的格式:
在对象头格式中,如果biased_lock为1,并且锁标志位为01,则代表当前是偏向锁状态。知道了对象头格式后,我们就能清楚各种加锁状态下对象头的变化了。
当我们创建了一个新对象时: -
如果JVM参数中开启了偏向锁(默认是开启的),那么对象创建后,Mark Word值为000...000101,也就是最后3位为101,代表了一个偏向锁状态。这时thread、epoch、age都为0.
-
偏向锁默认是有延迟的,也就是说:JVM刚启动的时候,创建的对象默认是Normal状态,在几秒之后,新创建的对象才会默认赋予一个偏向锁状态。如果想避免这个延迟,可以在JVM的启动参数上加上:
-XX:BiasedLockingStartupDelay=0
来禁用延迟。下图为代码示例,使用到了openJDK提供的查看对象头的工具:
注意:在这里有一个现象,就是偏向锁状态时,hashcode是没有值的。
如果在加锁前调用了锁对象的hashcode()方法,hashcode值就会填充在Mark Word里,此时锁会自动升级成轻量级锁。(其中的逻辑就是,使用偏向锁的话,Mark Word中没有空间来存储hashcode值了。
:::info
Q:为什么轻量级锁和重量级锁调用hashcode()没事呢?
因为轻量级锁的hashcode存在线程栈的Lock Record里,重量级锁的hashcode存在锁对象的monitor里。
:::
偏向锁的加锁流程:
- 尝试用CAS操作将自己的线程ID替换到锁对象的Mark Word上
偏向锁的膨胀
当一个对象不只被同一个线程加锁的时候,就会使偏向锁进行升级,但是直接升级成重量级锁还是升级为轻量级锁,又分了两种状况:
- 情况一:多个线程先后顺序加同一个对象锁,并不产生锁竞争
- 这个时候,后来的线程会将偏向锁升级为轻量级锁。
- 情况二:多个线程竞争同一个偏向锁
- 这种情况,因为有锁竞争,会直接升级成重量级锁。
批量重偏向
在上述的情况一下,偏向锁会被升级为轻量级锁,但是之后可能一直没有其他的线程再来获取这个锁了(注意:获取指的是不来尝试加锁,竞争意味着同时抢锁,是会升级成重量级锁的),这样的话,JVM是对此有一个优化的,也就是批量重偏向。
在同一个线程对轻量级锁反复加锁解锁20次之后,这个轻量级锁会重新变成偏向锁,偏向当前的线程。
重量级锁的自旋优化
在重量级锁进行竞争的时候,可以使用自旋来进行优化(避免频繁的进行上下文切换,因为线程阻塞获取锁是要切换用户态和内核态的)。
- 在Java 6 之后,自旋锁是自适应的,会根据上一次获取锁所需要的自旋次数,来动态调整本次自旋的次数。
一些面试题搜集
- Synchronized可以作用在哪里? 分别通过对象锁和类锁进行举例。
- Synchronized本质上是通过什么保证线程安全的? 分三个方面回答:加锁和释放锁的原理,可重入原理,保证可见性原理。
- Synchronized由什么样的缺陷? Java Lock是怎么弥补这些缺陷的。
- Synchronized和Lock的对比,和选择?
- Synchronized在使用时有何注意事项?
- Synchronized修饰的方法在抛出异常时,会释放锁吗?
- 多个线程等待同一个Synchronized锁的时候,JVM如何选择下一个获取锁的线程?
- Synchronized使得同时只有一个线程可以执行,性能比较差,有什么提升的方法?
- 我想更加灵活的控制锁的释放和获取(现在释放锁和获取锁的时机都被规定死了),怎么办?
- 什么是锁的升级和降级? 什么是JVM里的偏斜锁、轻量级锁、重量级锁?
- 不同的JDK中对Synchronized有何优化?