1.概述
synchronized
翻译过来是同步的意思,它是Java中一个关键字,是JVM层面提供的同步锁机制,用于保证多线程访问同一资源的可见性、互斥性。即当一个线程已经获取资源锁时,其他试图获取的线程只能等待或者阻塞在那里。
在 Java 5 以前,synchronized
是仅有的同步手段,Java 5才有了JUC并发包实现锁的。在代码中,synchronized
可以用来修饰方法,也可以使用在特定的代码块
修饰方法
synchronized void method() {
//方法代码逻辑
}
这时候会对当前方法的实例对象进行加锁,也就是要求进入同步代码前要获得当前对象实例的锁
修饰静态方法
synchronized static void method() {
//方法代码逻辑
}
修饰静态方法锁的是当前类class,例如调用A类的同步静态方法,那么就会对A.class
加锁
静态 synchronized
方法和非静态 synchronized
方法之间的调用是不互斥的!如果一个线程 A 调用一个实例对象的非静态 synchronized
方法,而线程 B 需要调用这个实例对象所属类的静态 synchronized
方法,是允许的,不会发生互斥现象,因为访问静态 synchronized
方法占用的锁是当前类的锁,而访问非静态 synchronized
方法占用的锁是当前实例对象锁。
修饰代码块
Object o = new Object();
synchronized(o) {
//业务逻辑代码
}
表示给当前对象o加锁。
项目推荐:基于SpringBoot2.x、SpringCloud和SpringCloudAlibaba企业级系统架构底层框架封装,解决业务开发时常见的非功能性需求,防止重复造轮子,方便业务快速开发和企业技术栈框架统一管理。引入组件化的思想实现高内聚低耦合并且高度可配置化,做到可插拔。严格控制包依赖和统一版本管理,做到最少化依赖。注重代码规范和注释,非常适合个人学习和企业使用
Github地址:https://github.com/plasticene/plasticene-boot-starter-parent
Gitee地址:https://gitee.com/plasticene3/plasticene-boot-starter-parent
微信公众号:Shepherd进阶笔记
2.synchronized底层原理
synchronized
同步机制底层实现是JVM层面提供的,所以要想了解,只能从同步类的字节码文件入手,接下来我们就从同步代码块案例(卖票)进行分析:
class Ticket {
// 票数量
private int number = 500;
// 卖票的方法
public void sale() {
synchronized (this) {
if (number > 0) {
System.out.println(Thread.currentThread().getName() + "卖出了" + (number--) + "票,剩余:" + number);
}
}
}
}
先通过idea运行一下代码或者使用javac
命令编译当前类生成字节码文件Ticket.class
,如果执行javap -c -s -v -l Ticket.class
得到字节码文件的汇编指令:
{
com.shepherd.juc.demo01.Ticket();
......
public void sale();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=6, locals=3, args_size=1
0: aload_0
1: dup
2: astore_1
3: monitorenter
4: aload_0
5: getfield #2 // Field number:I
8: ifle 67
11: getstatic #3 // Field java/lang/System.out:Ljava/io/PrintStream;
14: new #4 // class java/lang/StringBuilder
17: dup
18: invokespecial #5 // Method java/lang/StringBuilder."<init>":()V
21: invokestatic #6 // Method java/lang/Thread.currentThread:()Ljava/lang/Thread;
24: invokevirtual #7 // Method java/lang/Thread.getName:()Ljava/lang/String;
27: invokevirtual #8 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
30: ldc #9 // String 卖出了
32: invokevirtual #8 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
35: aload_0
36: dup
37: getfield #2 // Field number:I
40: dup_x1
41: iconst_1
42: isub
43: putfield #2 // Field number:I
46: invokevirtual #10 // Method java/lang/StringBuilder.append:(I)Ljava/lang/StringBuilder;
49: ldc #11 // String 票,剩余:
51: invokevirtual #8 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
54: aload_0
55: getfield #2 // Field number:I
58: invokevirtual #10 // Method java/lang/StringBuilder.append:(I)Ljava/lang/StringBuilder;
61: invokevirtual #12 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
64: invokevirtual #13 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
67: aload_1
68: monitorexit
69: goto 77
72: astore_2
73: aload_1
74: monitorexit
75: aload_2
76: athrow
77: return
.....
}
可以看出,synchronized
同步语句块的实现使用的是 monitorenter
和 monitorexit
指令,其中 monitorenter
指令指向同步代码块的开始位置,monitorexit
指令则指明同步代码块的结束位置。synchronized
代码块是由一对 monitorenter/monitorexit 指令实现的,上面指令代码中中发现有两个monitorexit,其实后面这个是保证同步代码块抛出异常时锁能得到正确的释放而存在的。
java的线程是映射到操作系统原生线程之上的,如果要阻塞或唤醒一个线程就需要操作系统介入,需要在户态与内核态之间切换,这种切换会消耗大量的系统资源,因为用户态与内核态都有各自专用的内存空间,专用的寄存器等,用户态切换至内核态需要传递给许多变量、参数给内核,内核也需要保护好用户态在切换时的一些寄存器值、变量等,以便内核态调用结束后切换回用户态继续工作。
在Java6之前,synchronized
属于重量级锁,效率低下,因为Monitor 对象监视器锁是同步的基本实现单元,Monitor监视器是在jvm底层实现的,底层代码是c++。本质是依赖于底层操作系统的Mutex Lock实现,完全是依靠操作系统内部的互斥锁,因为需要进行用户态到内核态的切换,所以同步操作是一个无差别的重量级操作。挂起线程和恢复线程都需要转入内核态去完成,阻塞或唤醒一个Java线程需要操作系统切换CPU状态来完成,这种状态切换需要耗费处理器时间,如果同步代码块中内容过于简单,这种切换的时间可能比用户代码执行的时间还长,时间成本相对较高,这也是为什么早期的synchronized
效率低的原因
从Java 6之后,为了减少获得锁和释放锁所带来的性能消耗,引入了轻量级锁和偏向锁。
3.synchronized锁升级撤销流程
上面说到synchronized
同步是一个重量级操作,性能低下,所以在Java6中JVM对此进行了大刀阔斧地改进,引入偏斜锁(Biased Locking)、轻量级锁和重量级锁,大大改进了其性能,该优化主要是基于 Mark Word
和Object monitor
来实现的
3.1 对象内存分布
在 JVM 中,对象在内存中分为三块区域:
对象头:由
Mark Word
和Klass Point
构成。Mark Word(标记字段):用于存储对象自身的运行时数据,例如存储对象的HashCode,分代年龄、锁标志位等信息,是synchronized实现轻量级锁和偏向锁的关键。 64位JVM的Mark Word组成如下:
偏向锁前56位线程ID 和 epoch 占用了 hashCode 的位置,所以,如果对象如果计算过 hashCode 后,便无法进入偏向锁状态,反过来,如果对象处于偏向锁状态,并且需要计算其 hashCode 的话,则偏向锁会被撤销,升级为重量级锁。
Klass Point(类型指针):对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。
实例数据:这部分主要是存放类的数据信息,父类的信息。
字节对齐:为了内存的IO性能,JVM要求对象起始地址必须是8字节的整数倍。对于不对齐的对象,需要填充数据进行对齐。
在JDK 1.6之前,synchronized
只有传统的锁机制,直接关联到monitor
对象,存在性能上的瓶颈。在JDK 1.6后,为了提高锁的获取与释放效率,JVM引入了两种锁机制:偏向锁和轻量级锁。它们的引入是为了解决在没有多线程竞争或基本没有竞争的场景下因使用传统锁机制带来的性能开销问题。这几种锁的实现和转换正是依靠对象头中的Mark Word
3.2 锁升级概述
所谓锁的升级,就是 JVM 优化 synchronized
运行的机制,当 JVM 检测到不同的竞争状况时,会自动切换到适合的锁实现,这种切换就是锁的升级。当没有竞争出现时,默认会使用偏斜锁。JVM 会利用 CAS 操作(compare and swap),在对象头上的 Mark Word 部分设置线程 ID,以表示这个对象偏向于当前线程,所以并不涉及真正的互斥锁。这样做的假设是基于在很多应用场景中,大部分对象生命周期中最多会被一个线程锁定,使用偏斜锁可以降低无竞争开销。如果有另外的线程试图锁定某个已经被偏斜过的对象,JVM 就需要撤销(revoke)偏斜锁,并切换到轻量级锁实现。轻量级锁依赖 CAS 操作 Mark Word 来试图获取锁,如果重试成功,就使用普通的轻量级锁;否则,进一步升级为重量级锁。大体流程图如下所示:
3.3 偏向锁
多线程的情况下,锁不仅不存在多线程竞争,还存在锁由同一线程多次获得的情况,你运行上面的多线程买票示例,你会发现大部分的票都是同一个线程卖出的~~偏向锁就是在这种情况下出现的,它的出现是为了解决只有在一个线程执行同步时提高性能。而且在实际应用运行过程中发现,“锁总是同一个线程持有,很少发生竞争”,也就是说锁总是被第一个占用他的线程拥有,这个线程就是锁的偏向线程。那么只需要在锁第一次被拥有的时候,记录下偏向线程ID。这样偏向线程就一直持有着锁(后续这个线程进入和退出这段加了同步锁的代码时,不需要再次加锁和释放锁。而是直接比较对象头里面是否存储了指向当前线程的偏向锁)。如果相等表示偏向锁是偏向于当前线的,就不需要再尝试获得锁了,直到竞争发生才释放锁。以后每次同步,检查锁的偏向线程ID与当前线程ID是否一致,如果一致直接进入同步。无需每次加锁解锁都去CAS更新对象头。如果自始至终使用锁的线程只有一个,很明显偏向锁几乎没有额外开销,性能极高。假如不一致意味着发生了竞争,锁已经不是总是偏向于同一个线程了,这时候可能需要升级变为轻量级锁,才能保证线程间公平竞争锁。偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程是不会主动释放偏向锁的。
偏向锁的实现:一个synchronized
方法被一个线程抢到了锁时,那这个方法所在的对象就会在其所在的Mark Word中将偏向锁修改状态位,同时还会有占用前54位来存储线程指针作为标识。若该线程再次访问同一个synchronized
方法时,该线程只需去对象头的Mark Word 中去判断一下是否有偏向锁指向本身的ID,无需再进入 Monitor 去竞争对象了。
偏向锁的操作不用直接捅到操作系统,不涉及用户到内核转换,不必要直接升级为最高级,我们以一个account对象的“对象头”为例,
假如有一个线程执行到synchronized
代码块的时候,JVM使用CAS操作把线程指针ID记录到Mark Word当中,并修改标偏向标示,标示当前线程就获得该锁。锁对象变成偏向锁(通过CAS修改对象头里的锁标志位),字面意思是“偏向于第一个获得它的线程”的锁。执行完同步代码块后,线程并不会主动释放偏向锁。
这时线程获得了锁,可以执行同步代码块。当该线程第二次到达同步代码块时会判断此时持有锁的线程是否还是自己(持有锁的线程ID也在对象头里),JVM通过account对象的Mark Word判断:当前线程ID还在,说明还持有着这个对象的锁,就可以继续进入临界区工作。由于之前没有释放锁,这里也就不需要重新加锁。 如果自始至终使用锁的线程只有一个,很明显偏向锁几乎没有额外开销,性能极高。
结论:JVM不用和操作系统协商设置Mutex(争取内核),它只需要记录下线程ID就标示自己获得了当前锁,不用操作系统接入。上述就是偏向锁:在没有其他线程竞争的时候,一直偏向偏心当前线程,当前线程可以一直执行。
-XX:+UseBiasedLocking 开启偏向锁(默认)
-XX:-UseBiasedLocking 关闭偏向锁
-XX:BiasedLockingStartupDelay=0 关闭延迟(演示偏向锁时需要开启)
参数说明:偏向锁在JDK1.6以上默认开启,开启后程序启动几秒后才会被激活,可以使用JVM参数来关闭延迟 -XX:BiasedLockingStartupDelay=0 如果确定锁通常处于竞争状态则可通过JVM参数 -XX:-UseBiasedLocking 关闭偏向锁,那么默认会进入轻量级锁
偏向锁的撤销
偏向锁使用一种等到竞争出现才释放锁的机制,只有当其他线程竞争锁时,持有偏向锁的原来线程才会被撤销。撤销需要等待全局安全点(该时间点上没有字节码正在执行),同时检查持有偏向锁的线程是否还在执行:
① 第一个线程正在执行synchronized
方法(处于同步块),它还没有执行完,其它线程来抢夺,该偏向锁会被取消掉并出现锁升级。此时轻量级锁由原持有偏向锁的线程持有,继续执行其同步代码,而正在竞争的线程会进入自旋等待获得该轻量级锁。
② 第一个线程执行完成synchronized
方法(退出同步块),则将对象头设置成无锁状态并撤销偏向锁,重新偏向 。
3.4 轻量级锁
轻量级锁是为了在线程近乎交替执行同步块时提高性能。
主要目的: 在没有多线程竞争的前提下,通过CAS减少重量级锁使用操作系统互斥量产生的性能消耗,说白了先自旋再阻塞。
升级时机: 当关闭偏向锁功能或多线程竞争偏向锁会导致偏向锁升级为轻量级锁
假如线程A已经拿到锁,这时线程B又来抢该对象的锁,由于该对象的锁已经被线程A拿到,当前该锁已是偏向锁了。
而线程B在争抢时发现对象头Mark Word中的线程ID不是线程B自己的线程ID(而是线程A),那线程B就会进行CAS操作希望能获得锁。
此时线程B操作中有两种情况:
如果锁获取成功,直接替换Mark Word中的线程ID为B自己的ID(A → B),重新偏向于其他线程(即将偏向锁交给其他线程,相当于当前线程"被"释放了锁),该锁会保持偏向锁状态,A线程Over,B线程上位;
如果锁获取失败,则偏向锁升级为轻量级锁,此时轻量级锁由原持有偏向锁的线程持有,继续执行其同步代码,而正在竞争的线程B会进入自旋等待获得该轻量级锁。
3.5 重量级锁
有大量的线程参与锁的竞争,冲突性很高,自旋到达一定次数还是没有获取锁成功,这时候轻量级锁就会膨胀为重量级锁,当锁膨胀为重量锁时,就不能再退回到轻量级锁。
4.总结
synchronized
锁升级过程总结:一句话,就是先自旋,不行再阻塞。实际上是把之前的悲观锁(重量级锁)变成在一定条件下使用偏向锁以及使用轻量级(自旋锁CAS)的形式。
synchronized
在修饰方法和代码块在字节码上实现方式有很大差异,但是内部实现还是基于对象头的MarkWord来实现的。
JDK1.6之前synchronized
使用的是重量级锁,JDK1.6之后进行了优化,拥有了无锁->偏向锁->轻量级锁->重量级锁的升级过程,而不是无论什么情况都使用重量级锁。
偏向锁:适用于单线程适用的情况,在不存在锁竞争的时候进入同步方法/代码块则使用偏向锁。
轻量级锁:适用于竞争较不激烈的情况(这和乐观锁的使用范围类似), 存在竞争时升级为轻量级锁,轻量级锁采用的是自旋锁,如果同步方法/代码块执行时间很短的话,采用轻量级锁虽然会占用cpu资源但是相对比使用重量级锁还是更高效。
重量级锁:适用于竞争激烈的情况,如果同步方法/代码块执行时间很长,那么使用轻量级锁自旋带来的性能消耗就比使用重量级锁更严重,这时候就需要升级为重量级锁。
锁消除:同步代码synchronized(o)
,对象o每次都是new一个新的,那么该锁对象就不会其他线程所使用,此时JIT编译器就会锁消除。
锁粗化:加入方法中收尾相接,前后相邻的都是同一个锁对象,那个JIT编译器会把这几个synchronized(o)
合成一个锁粗化,效果一样。