对synchronized的了解
synchronized 关键字解决的是多个线程之间访问资源的同步性,synchronized 关键字可以保证被它修饰的方法或者代码块在任意时刻只能有一个线程执行。
在 Java 早期版本中,synchronized 属于 重量级锁,效率低下。
为什么呢?
因为监视器锁(monitor)是依赖于底层的操作系统的 Mutex Lock 来实现的,Java 的线程是映射到操作系统的原生线程之上的。如果要挂起或者唤醒一个线程,都需要操作系统帮忙完成,而操作系统实现线程之间的切换时需要从用户态转换到内核态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高。
庆幸的是在 Java 6 之后 Java 官方对从 JVM 层面对 synchronized 较大优化,所以现在的 synchronized 锁效率也优化得很不错了。JDK1.6 对锁的实现引入了大量的优化,如自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销。
对synchronized的使用
它修饰的对象有以下几种:
- 修饰一个代码块,被修饰的代码块称为同步语句块,其作用的范围是大括号{}括起来的代码,作用的对象是调用这个代码块的对象;
- 修饰一个方法,被修饰的方法称为同步方法,其作用的范围是整个方法,作用的对象是调用这个方法的对象;
- 修饰一个静态的方法,其作用的范围是整个静态方法,作用的对象是这个类的所有对象;
- 修饰一个类,其作用的范围是synchronized后面括号括起来的部分,作用主的对象是这个类的所有对象。
修饰代码块
class SyncThread implements Runnable{
private static int count;
@Override
public void run() {
synchronized (this){
for (int i = 0; i < 5; i++) {
try {
System.out.println(Thread.currentThread().getName()+"==="+count);
count++;
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
}
SyncThread syncThread = new SyncThread();
new Thread(syncThread,"Thread-01").start();
new Thread(syncThread,"Thread-02").start();
Thread-01===0
Thread-01===1
Thread-01===2
Thread-01===3
Thread-01===4
Thread-02===5
Thread-02===6
Thread-02===7
Thread-02===8
Thread-02===9
当两个并发线程(thread1和thread2)访问同一个对象(syncThread)中的synchronized代码块时,在同一时刻只能有一个线程得到执行,另一个线程受阻塞,必须等待当前线程执行完这个代码块以后才能执行该代码块。Thread1和thread2是互斥的,因为在执行synchronized代码块时会锁定当前的对象,只有执行完该代码块才能释放该对象锁,下一个线程才能执行并锁定该对象。
new Thread(new SyncThread(),"Thread-01").start();
new Thread(new SyncThread(),"Thread-02").start();
hread-02===0
Thread-01===2
Thread-02===2
Thread-01===4
Thread-02===4
Thread-01===6
Thread-02===7
Thread-01===8
Thread-02===9
这两把锁是互不干扰的,不形成互斥,所以两个线程可以同时执行。
在synchronized(对象){}中也可以直接锁一个对象,不使用this。
当没有明确的对象作为锁,只是想让一段代码同步时,可以创建一个特殊的对象来充当锁:
class Test implements Runnable
{
private byte[] lock = new byte[0];
public void method()
{
synchronized(lock) {
}
}
public void run() {
}
}
说明:零长度的byte数组对象创建起来将比任何对象都经济――查看编译后的字节码:生成零长度的byte[]对象只需3条操作码,而Object lock = new Object()则需要7行操作码。
修饰方法
方式一:
public synchronized void method()
{
}
方式二:
public void method()
{
synchronized(this) {
}
}
以上两者等价的,都是锁定了整个方法时的内容。
注意:
-
synchronized关键字不能继承。虽然可以使用synchronized来定义方法,但synchronized并不属于方法定义的一部分,因此,synchronized关键字不能被继承。如果在父类中的某个方法使用了synchronized关键字,而在子类中覆盖了这个方法,在子类中的这个方法默认情况下并不是同步的,而必须显式地在子类的这个方法中加上synchronized关键字才可以。当然,还可以在子类方法中调用父类中相应的方法,这样虽然子类中的方法不是同步的,但子类调用了父类的同步方法,因此,子类的方法也就相当于同步了。这两种方式的例子代码如下 class Parent {
public synchronized void method() { }
}
class Child extends Parent {
public synchronized void method() { }
}
class Parent {
public synchronized void method() { }
}
class Child extends Parent {
public void method() { super.method(); }
}
-
在定义接口方法时不能使用synchronized关键字。 -
构造方法不能使用synchronized关键字,但可以使用synchronized代码块来进行同步。
修饰静态方法
public synchronized static void method() {
}
synchronized修饰的静态方法锁定的是这个类的所有对象。
class SyncThread implements Runnable{
private static int count;
@Override
public void run() {
method();
}
private synchronized static void method() {
for (int i = 0; i < 5; i++) {
try {
System.out.println(Thread.currentThread().getName()+"==="+count);
count++;
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
SyncThread syncThread = new SyncThread();
new Thread(new SyncThread(),"Thread-01").start();
new Thread(new SyncThread(),"Thread-02").start();
,但在thread-01和thread-02并发执行时却保持了线程同步。这是因为run中调用了静态方法method,而静态方法是属于类的,所以new 的两个对象用了同一把锁。
修饰一个类
class ClassName {
public void method() {
synchronized(ClassName.class) {
}
}
}
synchronized作用于一个类T时,是给这个类T加锁,T的所有对象用的是同一把锁。
总结:
A. 无论synchronized关键字加在方法上还是对象上,如果它作用的对象是非静态的,则它取得的锁是对象;如果synchronized作用的对象是一个静态方法或一个类,则它取得的锁是对类,该类所有的对象同一把锁。
B. 每个对象只有一个锁(lock)与之相关联,谁拿到这个锁谁就可以运行它所控制的那段代码。
C. 实现同步是要很大的系统开销作为代价的,甚至可能造成死锁,所以尽量避免无谓的同步控制。
D.尽量不要使用 synchronized(String a) 因为 JVM 中,字符串常量池具有缓存功能!
E. **构造方法不能使用 synchronized 关键字修饰。**构造方法本身就属于线程安全的,不存在同步的构造方法一说。
面试题—单例模式
面试中面试官经常会说:“单例模式了解吗?来给我手写一下!给我解释一下双重检验锁方式实现单例模式的原理呗!”
双重校验锁实现对象单例(线程安全)
class Singleton{
private static volatile Singleton uniqueInstance;
public static Singleton getUniqueInstance(){
if (uniqueInstance == null){
synchronized(Singleton.class){
if (uniqueInstance == null){
uniqueInstance = new Singleton();
}
}
}
return uniqueInstance;
}
}
需要注意 uniqueInstance 采用 volatile 关键字修饰也是很有必要。
uniqueInstance = new Singleton(); 这段代码其实是分为三步执行:
- 为
uniqueInstance 分配内存空间 - 初始化
uniqueInstance ,即执行构造函数 - 将
uniqueInstance 指向分配的内存地址
但是由于 JVM 具有指令重排的特性,执行顺序有可能变成 1->3->2。指令重排在单线程环境下不会出现问题,但是在多线程环境下会导致一个线程获得还没有初始化的实例。即获得一个没有执行构造函数的实例,是一个空的对象。例如,线程 T1 执行了 1 和 3,此时 T2 调用 getUniqueInstance () 后发现 uniqueInstance 不为空,因此返回 uniqueInstance ,但此时 uniqueInstance 还未被初始化,不能使用。
使用 volatile 可以禁止 JVM 的指令重排,保证在多线程环境下也能正常运行。
synchronized底层原理
synchronized 关键字底层原理属于 JVM 层面。
synchronized同步代码块的情况
public void method(){
synchronized(this){
System.out.println("代码块solo");
}
}
通过 JDK 自带的 javap 命令查看 SynchronizedDemo 类的相关字节码信息:首先切换到类的对应目录执行 javac SynchronizedDemo.java 命令生成编译后的 .class 文件,然后执行javap -c -s -v -l SynchronizedDemo.class 。
从上面我们可以看出:
synchronized 同步语句块的实现使用的是 monitorenter 和 monitorexit 指令,其中 monitorenter 指令指向同步代码块的开始位置,monitorexit 指令则指明同步代码块的结束位置。
当执行 monitorenter 指令时,线程试图获取锁也就是获取 对象监视器 monitor 的持有权。
在 Java 虚拟机(HotSpot)中,Monitor 是基于 C++实现的,由ObjectMonitor实现的。每个对象中都内置了一个 ObjectMonitor 对象。
另外,wait/notify 等方法也依赖于monitor 对象,这就是为什么只有在同步的块或者方法中才能调用wait/notify 等方法,否则会抛出java.lang.IllegalMonitorStateException 的异常的原因(抛出该异常,表示一个线程已经尝试等待一个对象的监视器,或者通知正在等待一个对象的监视器的其他线程,而没有拥有指定的监视器)。
public static void main(String[] args) {
Object o = new Object();
try {
o.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
o.notify();
}
synchronized 必须是进入同一个对象的monitor才有上述的效果。每一个对象会有一个monitor,不加synchronized的对象,不会关联监视器,不遵从以上规则。
在执行monitorenter 时,会尝试获取对象的锁,如果锁的计数器为 0 则表示锁可以被获取,获取后将锁计数器设为 1 也就是加 1。
在执行 monitorexit 指令后,将锁计数器设为 0,表明锁被释放。如果获取对象锁失败,那当前线程就要阻塞等待,直到锁被另外一个线程释放为止。
synchronized同步方法的情况
public class SynchronizedDemo2 {
public synchronized void method() {
System.out.println("synchronized 方法");
}
}
synchronized 修饰的方法并没有 monitorenter 指令和 monitorexit 指令,取得代之的确实是 ACC_SYNCHRONIZED 标识,该标识指明了该方法是一个同步方法。JVM 通过该 ACC_SYNCHRONIZED 访问标志来辨别一个方法是否声明为同步方法,从而执行相应的同步调用。
总结:
synchronized 同步语句块的实现使用的是 monitorenter 和 monitorexit 指令,其中 monitorenter 指令指向同步代码块的开始位置,monitorexit 指令则指明同步代码块的结束位置。
synchronized 修饰的方法并没有 monitorenter 指令和 monitorexit 指令,取得代之的确实是 ACC_SYNCHRONIZED 标识,该标识指明了该方法是一个同步方法。
不过两者的本质都是对对象监视器 monitor 的获取。
monitor的结构:
- 刚开始 Monitor 中 Owner 为 null
- 当 Thread-2 执行 synchronized(obj) 就会将Monitor的所有者 Owner 置为Thread-2 ,Monitor中只能有一个Owner
- 在Thread-2上锁的过程中,如果Thread-3,Thread-4,Thread-5也来执行synchronized(obj),就会进入EntryList BLOCKED
- Thread-2执行完同步代码块中的内容,然后唤醒EntryList中等待的线程来竞争锁,竞争时是非公平的
- 图中WaitSet中的Thread-0,Thread-1是之前获得过锁,但条件不满足进入WAITING状态的线程,在wait-notify中
synchronized 必须是进入同一个对象的monitor才有上述的效果。每一个对象会有一个monitor,不加synchronized的对象,不会关联监视器,不遵从以上规则。
JDK1.6之后对synchronized的底层优化
参考链接
JDK1.6 对锁的实现引入了大量的优化,如偏向锁、轻量级锁、自旋锁、适应性自旋锁、锁消除、锁粗化等技术来减少锁操作的开销。
锁主要存在四种状态,依次是:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态,他们会随着竞争的激烈而逐渐升级。这种策略是为了提高获得锁和释放锁的效率。
实现同步的基础
Java中的每个对象都可以作为锁。具体变现为以下3种形式:
- 对于普通同步方法, 锁是当前实例对象。
- 对于静态同步方法, 锁是当前类的Class对象。
- 对于同步方法块, 锁是synchronized括号里配置的对象。
一个线程试图访问同步代码块时, 必须获取锁. 在退出或者抛出异常时, 必须释放锁.
实现方式
JVM基于进入和退出Monitor对象来实现方法同步和代码块同步, 但是两者的实现细节不一样.
- 代码块同步: 通过使用monitorenter和monitorexit指令实现的.
- 同步方法: ACC_SYNCHRONIZED修饰
monitorenter指令是在编译后插入到同步代码块的开始位置, 而monitorexit指令是在编译后插入到同步代码块的结束处或异常处.
示例代码
为了证明JVM的实现方式, 下面通过反编译代码来证明.
public class Demo {
public void f1() {
synchronized (Demo.class) {
System.out.println("Hello World.");
}
}
public synchronized void f2() {
System.out.println("Hello World.");
}
}
编译之后的字节码如下(只摘取了方法的字节码):
public void f1();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=2, locals=3, args_size=1
0: ldc #2
2: dup
3: astore_1
4: monitorenter
5: getstatic #3
8: ldc #4
10: invokevirtual #5
13: aload_1
14: monitorexit
15: goto 23
18: astore_2
19: aload_1
20: monitorexit
21: aload_2
22: athrow
23: return
Exception table:
from to target type
5 15 18 any
18 21 18 any
LineNumberTable:
line 6: 0
line 7: 5
line 8: 13
line 9: 23
StackMapTable: number_of_entries = 2
frame_type = 255
offset_delta = 18
locals = [ class me/snail/base/Demo, class java/lang/Object ]
stack = [ class java/lang/Throwable ]
frame_type = 250
offset_delta = 4
public synchronized void f2();
descriptor: ()V
flags: ACC_PUBLIC, ACC_SYNCHRONIZED
Code:
stack=2, locals=1, args_size=1
0: getstatic #3
3: ldc #4
5: invokevirtual #5
8: return
LineNumberTable:
line 12: 0
line 13: 8
}
先说f1()方法, 发现其中一个monitorenter对应了两个monitorexit, 这是不对的. 但是仔细看#15: goto语句, 直接跳转到了#23: return处, 再看#22: athrow语句发现, 原来第二个monitorexit是保证同步代码块抛出异常时锁能得到正确的释放而存在的, 这就理解了.
异常表中也可以看出当5-15行发生异常的时候,target18行。
综上: 发现同步代码块是通过monitorenter和monitorexit来实现的; 同步方法是加了一个ACC_SYNCHRONIZED修饰来实现的.
Java对象头(存储锁类型)
在HotSpot虚拟机中, 对象在内存中的布局分为三块区域: 对象头, 实例数据和对齐填充.
对象头中包含两部分: MarkWord 和 类型指针.
如果是数组对象的话, 对象头还有一部分是存储数组的长度.
多线程下synchronized的加锁就是对同一个对象的对象头中的MarkWord中的变量进行CAS操作.
MarkWord
Mark Word用于存储对象自身的运行时数据, 如HashCode, GC分代年龄, 锁状态标志, 线程持有的锁, 偏向线程ID等等. 占用内存大小与虚拟机位长一致(32位JVM -> MarkWord是32位, 64位JVM->MarkWord是64位).
Java对象头里的Mark Word里默认存储对象的HashCode、分代年龄和锁标记位。32位JVM 的Mark Word的默认存储结构如表2-3所示。
在运行期间,Mark Word里存储的数据会随着锁标志位的变化而变化。Mark Word可能变 化为存储以下4种数据,如表2-4所示。
在64位虚拟机下,Mark Word是64bit大小的,其存储结构如表2-5所示。
类型指针
类型指针指向对象的类元数据, 虚拟机通过这个指针确定该对象是哪个类的实例.
对象头的长度
长度 | 内容 | 说明 |
---|
32/64bit | MarkWord | 存储对象的hashCode或锁信息等 | 32/64bit | Class Metadada Address | 存储对象类型数据的指针 | 32/64bit | Array Length | 数组的长度(如果当前对象是数组) |
synchronized用的锁是存在Java对象头里的。如果对象是数组类型,则虚拟机用3个字宽 (Word)存储对象头,如果对象是非数组类型,则用2字宽存储对象头。在32位虚拟机中,1字宽等于4字节,即32bit,
优化后synchronized锁的分类
级别从低到高依次是:
- 无锁状态
- 偏向锁状态
- 轻量级锁状态
- 重量级锁状态
锁可以升级, 但不能降级. 即: 无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁是单向的.
下面看一下每个锁状态时, 对象头中的MarkWord这一个字节中的内容是什么. 以32位为例.
无锁状态
25bit | 4bit | 1bit(是否是偏向锁) | 2bit(锁标志位) |
---|
对象的hashCode | 对象分代年龄 | 0 | 01 |
偏向锁状态
23bit | 2bit | 4bit | 1bit | 2bit |
---|
线程ID | epoch | 对象分代年龄 | 1 | 01 |
轻量级锁状态
重量级锁状态
30bit | 2bit |
---|
指向互斥量(重量级锁)的指针 | 10 |
锁的升级(进化)
轻量级锁
轻量级锁的使用场景:如果一个对象虽然有多线程访问,但多线程访问的时间是错开的(也就是没有竞争),那么就可以使用轻量级锁来优化。
轻量级锁对使用者是透明的,即语法仍然是synchronized
假设有两个方法同步块,利用同一个对象加锁
public class ThreadTest1 {
static final Object obj = new Object();
public static void method1(){
synchronized (obj){
}
}
public static void method2(){
synchronized (obj){
}
}
}
分析以上代码:
- 创建锁记录(Lock Record)对象,每个线程的栈帧都会包含一个锁记录的结构,内部可以存储锁定对象的Mark Word
- 让锁记录中 Object reference 指向锁对象,并尝试用 cas 替换Object 的Mark Word,将Mark Word 的值存入锁记录
- 如果 cas 替换成功,对象头中存储了 锁记录地址和状态 00,表示由该线程对对象加锁
- 如果 cas 失败, 有两种情况
- 如果是其他线程已经持有了该Object 的轻量级锁,这时表明有竞争,进入锁膨胀过程
- 如果是自己执行了synchronized锁重入,那么再添加一条Lock Record 作为重入的计数[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-G0NEwJNe-1647092008290)(https://b3logfile.com/file/2021/07/image-016598e8.png)]
- 当退出synchronized 代码块(解锁时)如果有取值为null的锁记录,表示有重入,这时重置锁记录,表示重入计数减一[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-QPNLu5Uq-1647092008291)(https://b3logfile.com/file/2021/07/image-38c6f444.png)]当退出synchronized代码块(解锁时)锁记录的值不为null,这时使用 cas 将Mark Word 的值恢复给对象头
- 失败,说明轻量级锁进行了锁膨胀 或已经升级为重量级锁,进入重量级解锁流程
- 成功,则解锁成功
锁膨胀
如果在尝试加轻量级锁的过程中,CAS操作无法成功,这时一种情况就是有其他线程为此对象加上轻量级锁(有竞争),这时需要进行锁膨胀,将轻量级锁变为重量级锁。
- 当Thread-1 进行轻量级加锁时,Thread-0已经对该对象加了轻量级锁
- 这时Thread-1 加轻量级锁失败,进入锁膨胀流程
- 即为Object 对象申请Monitor锁,让Object的Mark word指向重量级锁地址
- 然后自己进入Monitor的EntryList(等待队列)BLOCKED
- 当Thread-0 退出同步解锁时,使用 CAS 将Mark Word的值恢复给对象头,会失败,这时进入重量级解锁流程,即按照Monitor地址(在 Mark Word中)找到Monitor 对象,设置Owner为null,唤醒EntryList中 BLOCKED线程。
自旋优化
重量级锁竞争的时候,还可以使用自旋来进行优化,如果当前线程自旋成功(即这个时候持有锁线程已经退出了同步块,释放了锁),这时当前线程就可以避免阻塞。
自旋成功的情况:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-wzCIbaO3-1647092008295)(https://b3logfile.com/file/2021/07/image-273103c4.png)]
自旋失败的情况:
自旋会占用 CPU 时间,单核 CPU 自旋就是浪费,多核 CPU 自旋才能发挥优势。 在 Java 6 之后自旋锁是自适应的,比如对象刚刚的一次自旋操作成功过,那么认为这次自旋成功的可能性会高,就多自旋几次;反之,就少自旋甚至不自旋,总之,比较智能。 Java 7 之后不能控制是否开启自旋功能
偏向锁
当一个线程访问同步块并获取锁时, 会在锁对象的对象头和栈帧中的锁记录里存储锁偏向的线程ID, 以后该线程进入和退出同步块时不需要进行CAS操作来加锁和解锁, 只需要简单的测试一下锁对象的对象头的MarkWord里是否存储着指向当前线程的偏向锁(线程ID是当前线程), 如果测试成功, 表示线程已经获得了锁; 如果测试失败, 则需要再测试一下MarkWord中偏向锁的标识是否设置成1(表示当前是偏向锁), 如果没有设置, 则使用CAS竞争锁, 如果设置了, 则尝试使用CAS将锁对象的对象头的偏向锁指向当前线程.
轻量级锁在没有竞争时(就自己这个线程),每次重入仍然需要执行 CAS 操作。 Java 6 中引入了偏向锁来做进一步优化:只有第一次使用 CAS 将线程 ID 设置到对象的 Mark Word 头,之后发现 这个线程 ID 是自己的就表示没有竞争,不用重新 CAS。以后只要不发生竞争,这个对象就归该线程所有 例如:
static final Object obj = new Object();
public static void m1() {
synchronized( obj ) {
m2();
}
}
public static void m2() {
synchronized( obj ) {
m3();
}
}
public static void m3() {
synchronized( obj ) {
}
}
轻量级锁:
偏向锁:
偏向状态
对象头:
一个对象创建时:
- 如果开启了偏向锁(默认开启),那么对象创建后,markword 值为 0x05 即最后 3 位为 101,这时它的thread、epoch、age 都为 0
- 偏向锁是默认是延迟的,不会在程序启动时立即生效,如果想避免延迟,可以加 VM 参数-XX:BiasedLockingStartupDelay=0 来禁用延迟
为什么虚拟机会对偏向锁增加一个默认延时生效的控制呢?虚拟机在启动的过程中也会启动一些线程,有部分逻辑在内部也进行了并发控制,如果直接开启偏向锁的话,那么通常就会导致偏向撤销,JVM在启用偏向撤销的时候使用了大量的安全点,一开始先不启用偏向锁更有助于提高JVM启动速度(感觉应该是这样o(╯□╰)o)。
- 如果没有开启偏向锁,那么对象创建后,markword 值为 0x01 即最后 3 位为 001,这时它的 hashcode、age 都为 0,第一次用到 hashcode 时才会赋值
注意:
处于偏向锁的对象解锁后,线程 id 仍存储于对象头中
撤销
撤销 - 调用对象 hashCode
调用了对象的 hashCode,但偏向锁的对象 MarkWord 中存储的是线程 id,如果调用 hashCode 会导致偏向锁被撤销
因为 hashCode 在MarkWord中占 31个字节 当使用偏向锁的时候 MarkWord 会有54个字节存储线程id,hashCode和线程id不能同时放入MarkWord ,所以在对象调用hashCode时 就不能使用偏向锁,偏向锁会撤销
轻量级锁会在锁记录中记录 hashCode 重量级锁会在 Monitor 中记录 hashCode
在调用 hashCode 后使用偏向锁,调用hashCode之后,对象是无锁状态,当进入同步块时会直接加轻量级锁,不会加偏向锁。
11:22:10.386 c.TestBiased [main] - 调用 hashCode:1778535015
11:22:10.391 c.TestBiased [t1] - synchronized 前
00000000 00000000 00000000 01101010 00000010 01001010 01100111 00000001
11:22:10.393 c.TestBiased [t1] - synchronized 中
00000000 00000000 00000000 00000000 00100000 11000011 11110011 01101000
11:22:10.393 c.TestBiased [t1] - synchronized 后
00000000 00000000 00000000 01101010 00000010 01001010 01100111 00000001
撤销 - 其它线程使用对象
当有其它线程使用偏向锁对象时,会将偏向锁升级为轻量级锁
private static void test2() throws InterruptedException {
Dog d = new Dog();
Thread t1 = new Thread(() -> {
log.debug(ClassLayout.parseInstance(d).toPrintableSimple(true));
synchronized (d) {
log.debug(ClassLayout.parseInstance(d).toPrintableSimple(true));
}
synchronized (TestBiased.class) {
TestBiased.class.notify();
}
}, "t1");
t1.start();
Thread t2 = new Thread(() -> {
synchronized (TestBiased.class) {
try {
TestBiased.class.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
log.debug(ClassLayout.parseInstance(d).toPrintableSimple(true));
synchronized (d) {
log.debug(ClassLayout.parseInstance(d).toPrintableSimple(true));
}
log.debug(ClassLayout.parseInstance(d).toPrintableSimple(true));
}, "t2");
t2.start();
}
[t1] - 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000101
[t1] - 00000000 00000000 00000000 00000000 00011111 01000001 00010000 00000101
[t2] - 00000000 00000000 00000000 00000000 00011111 01000001 00010000 00000101
[t2] - 00000000 00000000 00000000 00000000 00011111 10110101 11110000 01000000
[t2] - 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000001
撤销 - 调用 wait/notify
等待通知机制,只有重量级锁才有。
public static void main(String[] args) throws InterruptedException {
Dog d = new Dog();
Thread t1 = new Thread(() -> {
log.debug(ClassLayout.parseInstance(d).toPrintableSimple(true));
synchronized (d) {
log.debug(ClassLayout.parseInstance(d).toPrintableSimple(true));
try {
d.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
log.debug(ClassLayout.parseInstance(d).toPrintableSimple(true));
}
}, "t1");
t1.start();
new Thread(() -> {
try {
Thread.sleep(6000);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (d) {
log.debug("notify");
d.notify();
}
}, "t2").start();
}
[t1] - 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000101
[t1] - 00000000 00000000 00000000 00000000 00011111 10110011 11111000 00000101
[t2] - notify
[t1] - 00000000 00000000 00000000 00000000 00011100 11010100 00001101 11001010
撤销 - 总结
偏向锁使用了一种等到竞争出现才释放锁的机制, 所以当其他线程尝试竞争偏向锁时, 持有偏向锁的线程才会释放锁. 偏向锁的撤销需要等到全局安全点(在这个时间点上没有正在执行的字节码). 首先会暂停持有偏向锁的线程, 然后:
- 查看偏向的线程是否存活,如果已经不存活了,则直接撤销偏向锁。JVM维护了一个集合存放所有存活的线程,通过遍历该集合判断某个线程是否存活。
- 偏向的线程是否还在同步块中,如果不在了,则撤销偏向锁。我们回顾一下偏向锁的加锁流程:每次进入同步块(即执行
monitorenter )的时候都会以从高往低的顺序在栈中找到第一个可用的Lock Record ,将其obj字段指向锁对象。每次解锁(即执行monitorexit )的时候都会将最低的一个相关Lock Record 移除掉。所以可以通过遍历线程栈中的Lock Record 来判断线程是否还在同步块中。 - 将偏向线程所有相关
Lock Record 的Displaced Mark Word 设置为null,然后将最高位的Lock Record 的Displaced Mark Word 设置为无锁状态,最高位的Lock Record 也就是第一次获得锁时的Lock Record (这里的第一次是指重入获取锁时的第一次),然后将对象头指向最高位的Lock Record ,这里不需要用CAS指令,因为是在safepoint 。 执行完后,就升级成了轻量级锁。原偏向线程的所有Lock Record都已经变成轻量级锁的状态。这里如果看不明白,请回顾上篇文章的轻量级锁加锁过程。
批量重偏向与批量撤销
当只有一个线程反复进入同步块时,偏向锁带来的性能开销基本可以忽略,但是当有其他线程尝试获得锁时,就需要等到safe point 时将偏向锁撤销为无锁状态或升级为轻量级/重量级锁。safe point 这个词我们在GC中经常会提到,其代表了一个状态,在该状态下所有线程都是暂停的(大概这么个意思)。总之,偏向锁的撤销是有一定成本的,如果说运行时的场景本身存在多线程竞争的,那偏向锁的存在不仅不能提高性能,而且会导致性能下降。因此,JVM中增加了一种批量重偏向/撤销的机制。
存在如下两种情况:
1.一个线程创建了大量对象并执行了初始的同步操作,之后在另一个线程中将这些对象作为锁进行之后的操作。这种case下,会导致大量的偏向锁撤销操作。
2.存在明显多线程竞争的场景下使用偏向锁是不合适的,例如生产者/消费者队列。
批量重偏向(bulk rebias)机制是为了解决第一种场景。批量撤销(bulk revoke)则是为了解决第二种场景。
其做法是:以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。
当达到重偏向阈值后,假设该class计数器继续增长,当其达到批量撤销的阈值后(默认40),JVM就认为该class的使用场景存在多线程竞争,会标记该class为不可偏向,之后,对于该class的锁,直接走轻量级锁的逻辑。
锁消除
@Fork(1)
@BenchmarkMode(Mode.AverageTime)
@Warmup(iterations=3)
@Measurement(iterations=5)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
public class MyBenchmark {
static int x = 0;
@Benchmark
public void a() throws Exception {
x++;
}
@Benchmark
public void b() throws Exception {
Object o = new Object();
synchronized (o) {
x++;
}
}
}
synchronized和ReentrantLock的区别
两者都是可重入锁
“可重入锁” 指的是自己可以再次获取自己的内部锁。比如一个线程获得了某个对象的锁,此时这个对象锁还没有释放,当其再次想要获取这个对象的锁的时候还是可以获取的,如果不可锁重入的话,就会造成死锁。同一个线程每次获取锁,锁的计数器都自增 1,所以要等到锁的计数器下降为 0 时才能释放锁。
synchronized 依赖于 JVM 而 ReentrantLock 依赖于 API
synchronized 是依赖于 JVM 实现的,前面我们也讲到了 虚拟机团队在 JDK1.6 为 synchronized 关键字进行了很多优化,但是这些优化都是在虚拟机层面实现的,并没有直接暴露给我们。ReentrantLock 是 JDK 层面实现的(也就是 API 层面,需要 lock() 和 unlock() 方法配合 try/finally 语句块来完成),所以我们可以通过查看它的源代码,来看它是如何实现的。
ReentrantLock 比 synchronized 增加了一些高级功能
相比synchronized ,ReentrantLock 增加了一些高级功能。主要来说主要有三点:
- 等待可中断 :
ReentrantLock 提供了一种能够中断等待锁的线程的机制,通过 lock.lockInterruptibly() 来实现这个机制。也就是说正在等待的线程可以选择放弃等待,改为处理其他事情。 - 可实现公平锁 :
ReentrantLock 可以指定是公平锁还是非公平锁。而synchronized 只能是非公平锁。所谓的公平锁就是先等待的线程先获得锁。ReentrantLock 默认情况是非公平的,可以通过 ReentrantLock 类的ReentrantLock(boolean fair) 构造方法来制定是否是公平的。 - 可实现选择性通知(锁可以绑定多个条件):
synchronized 关键字与wait() 和notify() /notifyAll() 方法相结合可以实现等待/通知机制。ReentrantLock 类当然也可以实现,但是需要借助于Condition 接口与newCondition() 方法。
Condition 是 JDK1.5 之后才有的,它具有很好的灵活性,比如可以实现多路通知功能也就是在一个Lock 对象中可以创建多个Condition 实例(即对象监视器),线程对象可以注册在指定的Condition 中,从而可以有选择性的进行线程通知,在调度线程上更加灵活。 在使用notify()/notifyAll() 方法进行通知时,被通知的线程是由 JVM 选择的,用ReentrantLock 类结合Condition 实例可以实现“选择性通知” ,这个功能非常重要,而且是 Condition 接口默认提供的。而synchronized 关键字就相当于整个 Lock 对象中只有一个Condition 实例,所有的线程都注册在它一个身上。如果执行notifyAll() 方法的话就会通知所有处于等待状态的线程这样会造成很大的效率问题,而Condition 实例的signalAll() 方法 只会唤醒注册在该Condition 实例中的所有等待线程。
|