大家好,我是wave,这次来和大家详细的聊一聊Synchronized这个关键字,希望大家看完可以对synchronized关键字有一个非常全面的了解。
Synchronized基本操作
synchronized主要有三种使用方式:
修饰实例方法: 给一个类上的方法添加synchronized关键字,这个锁会作用于这个类当前的实例对象上,进入同步代码前要获得 当前对象实例的锁
public synchronized void method(){
// do something
}
修饰静态方法: 由于静态方法属于类,就相当于给一个类加了锁,会作用于类的所有对象实例 ,进入同步代码前要获得 当前 class 的锁
public synchronized static void method(){
// do something
}
修饰代码块 :指定加锁对象,对给定对象/类加锁。synchronized(this|object) 表示进入同步代码库前要获得给定对象的锁。synchronized(类.class) 表示进入同步代码前要获得 当前 class 的锁
public class Solution {
public void method(){
synchronized (this){
//给当前对象加锁
}
synchronized (Solution.class){
//给Solution这个类加锁
}
Object lock = new Object();
synchronized (lock){
//给Object这个对象加锁
}
}
}
synchronized在JDK早期是一个非常重量级的锁,依靠JVM的指令对代码块进行加锁,所以synchronized是JVM层面的锁,而Lock是用Java代码实现的,所以可以看作是API层面的锁。但是后续JDK对synchronized进行了非常多的优化和升级,目前synchronized与Lock的效率已经相差不多了。
synchronized修饰代码块
synchronized修饰代码块是用两个指令monitorenter、monitorexit完成的,口说无凭,我带大家看一看字节码吧~
public class Solution {
public void method(){
synchronized (this){
System.out.println("synchronized修饰代码块");
}
}
}
- 首先写一段这样的代码,然后可以通过javap来查看它的字节码。如果使用IDEA的话可以经过一点小小的配置就可以在控制台直接输出字节码了,先给大家看看IDEA应该如何配置
- File->setting->Tools->External->左上角的+号,里面中间的三个配置分别为:
$JDKPath$\bin\javap.exe- -verbose -p -c
$FileClass$$OutputPath$- 然后编译一下程序,按运行旁边的绿色小锤子就可以了,再右键打开External tool找到刚才配置的工具,就可以在控制台打印出字节码了。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-i0NuCB6I-1608209600129)( http://gtwave.gitee.io/image/images/wechart/synchronized字节码.png)]


现在我说synchronized加在同步代码块上是用JVM指令来实现的就没啥问题了吧~当JVM执行到monitorenter的时候线程试图获取锁也就是获取 对象监视器 monitor 的持有权。
synchronized修饰方法
public class Solution {
public synchronized void method(){
System.out.println("synchronized 修饰方法");
}
}

可以看到修饰一个方法就会有一个ACC_SYNCHRONIZED标识,代表这个方法是同步方法。
synchronized锁升级
JDK1.6的时候对synchronized进行了大量的优化,synchronized的锁现在有三种形态:偏向锁、轻量级锁、重量级锁。有这三种锁可以适应不同的应用场景,在几乎没有并发的时候偏向锁效率高于轻量级锁与重量级锁,在并发不是特别高的时候锁升级为了轻量级锁,轻量级锁的效率又会高于重量级锁。
- 那么偏向锁、轻量级锁、重量级锁又是什么?
- 偏向锁:我认为偏向锁的关键就是“偏”,偏向于第一个访问的线程。也就是说在无竞争的环境下,有一个线程访问的同步代码块,那么这个锁就会偏向这个线程,下次有线程访问的时候就会判断是不是之前访问过的线程访问,这样就会少一次cas的开销。因为第一次有线程访问同步代码块的时候会用cas把线程id写入mark word中。偏向锁会有一个延迟,程序刚启动的5s内不会出现偏向锁,这个我等会儿给大家证明一下,计算过hashcode值的对象不会加偏向锁,因为对象头没有空间放线程id了。
- 轻量级锁:轻量级锁体现轻量的点就在于自旋,如果线程访问轻量级锁的同步代码块,会cas判断线程id是否一致,不一致会自旋一定的时间一直进行cas,如果cas成功就还是轻量级锁。如果失败了,然后轻量级锁就会升级为重量级锁。
- 重量级锁:jvm层面的两个标识,加锁解锁都会阻塞其他线程。
- Cas操作有三个参数,一个是旧值的地址,一个是旧值,还有一个是新值,如果从旧值地址取出来的值和旧值相等,那么就把旧值改为新值。
- mark word是对象头的组成部分,对象头主要是有mark word与klass word,mark word通常会存放线程id信息、垃圾回收年限、锁的状态这些信息。klass word存放的是一个指针,指向对象的实例,也就是在堆里面的地址。通常这个指针为64bit,但是一般都会压缩成32bit。

- 从上面这张图中我们主要关注一下最后面的那2bit,这两个bit就代表了锁的状态。所以说一个对象有五种状态,无锁、偏向锁、轻量级锁、重量级锁、GC中(表示正在进行垃圾回收)。
- 理论上2bit只能表示4种状态,那么为什么会有五种状态呢?细心的朋友就能发现偏向锁前一个bit也是用来表示锁状态的,也就是说还有1bit是用来判断这个锁是否偏向。
- 所以无锁是01,轻量级锁是00,GC是11,重量级锁是10,偏向锁是101

- 这是一张网上广为流传的锁升级图,看懂这张图绝对面试锁升级可以让面试官刮目相看,但是个人觉得这张图还是有点晦涩难懂。想要更深入的了解锁升级的过程就研究一下这张图吧~(如果看不清其实网上随便搜搜都能搜出来。。。当然也可以在公众号留言:锁升级图)
JOL打印对象头
说了这么多虚无缥缈的东西,能不能直观的看一看什么样的场景下synchronized会有锁升级呢?来,Java中有一个jar包叫jol-core,可以用这里面的API把对象的对象头打印出来。
无锁
import org.openjdk.jol.info.ClassLayout;
import org.openjdk.jol.vm.VM;
import static java.lang.System.out;
public class JOLExample1 {
public static void main(String[] args) throws Exception {
A a = new A();
out.println(VM.current().details());
out.println(ClassLayout.parseInstance(a).toPrintable());
}
}

- 这里例子中对象A并没有加任何锁,所以肯定是无锁状态的,从控制台打印的结果也可以看到,VALUE的值就是对象头的值,前两位打印结果就是01,确实是无锁。怎么样,我没骗你吧~
- 可能细心的小伙伴又发现了,我们前面说的是对象头最后两位是代表锁状态,为什么打印出来的又是前两位代表锁状态了呢?
- 这里涉及到一个计算机中的概念叫大端模式与小端模式,现在的计算机都是小端模式,也就是字数据的低字节则存放在高地址,所以就产生了这样的结果。
偏向锁
import org.openjdk.jol.info.ClassLayout;
import org.openjdk.jol.vm.VM;
import static java.lang.System.out;
//没有竞争,理论上是偏向锁
public class JOLExample2 {
static A a;
public static void main(String[] args)throws Exception{
a = new A();
out.println("befre lock");
out.println(ClassLayout.parseInstance(a).toPrintable());
sync();
out.println("after lock");
out.println(ClassLayout.parseInstance(a).toPrintable());
}
public static void sync() throws InterruptedException {
synchronized (a){
System.out.println("我也不知道要打印什么");
}
}
}
可以看到上面这个例子虽然给A加了synchronized,但是并没有竞争,根据我们面的分析就是偏向锁了,所以打印出来会是101吗?

- 神奇的发现还是01,表示无锁。为什么呢?这里是因为偏向锁会有一个4s的延迟,修改虚拟机参数或者睡眠5s就可以看到101了
- (XX:+UseBiasedLocking -XX:BiasedLockingStartupDelay=0)
public class JOLExample2 {
static A a;
public static void main(String[] args)throws Exception{
Thread.sleep(5000);//先停一个5s
a = new A();
out.println("befre lock");
out.println(ClassLayout.parseInstance(a).toPrintable());
sync();
out.println("after lock");
out.println(ClassLayout.parseInstance(a).toPrintable());
}
public static void sync() throws InterruptedException {
synchronized (a){
System.out.println("我也不知道要打印什么");
}
}
}

看到没有,千呼万唤的偏向锁终于出来了
轻量级锁
public class JOLExample3 {
static A a;
public static void main(String[] args)throws Exception{
Thread.sleep(5000);
a = new A();
out.println("befre lock");
new Thread(()->{
sync();
}).start();
Thread.sleep(5000);//保证上面这个线程执行完成
out.println("after lock");
//out.println(ClassLayout.parseInstance(a).toPrintable());
sync();
out.println("lock over");
out.println(ClassLayout.parseInstance(a).toPrintable());
}
public static void sync() {
synchronized (a){
try {
out.println(ClassLayout.parseInstance(a).toPrintable());
}catch (Exception e){
}
}
}
}

我们从图中可以看出来,一个线程给a加了锁之后还是偏向锁,因为没有竞争。而有另外一个线程又给a加了锁之后,就会变成轻量级锁了(注意这里其实还是没有竞争),退出synchronized代码块之后又会变成无锁的状态,因为轻量级锁会有一个锁撤销的过程。
重量级锁
public class JOLExample3 {
static A a;
public static void main(String[] args)throws Exception{
Thread.sleep(5000);
a = new A();
out.println("befre lock");
new Thread(()->{
sync();
}).start();
//Thread.sleep(5000);//保证上面这个线程执行完成
out.println("after lock");
//out.println(ClassLayout.parseInstance(a).toPrintable());
sync();
out.println("lock over");
out.println(ClassLayout.parseInstance(a).toPrintable());
}
public static void sync() {
synchronized (a){
try {
out.println(ClassLayout.parseInstance(a).toPrintable());
}catch (Exception e){
}
}
}
}

只需要在前面轻量级锁的案例中把睡眠5s注释就可以产生两个线程的竞争,即主线程与新建的线程发生竞争。可以看到打印的结果就是10,表示重量级锁。
synchronized的底层实现
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-gFimtILu-1608209600151)( http://gtwave.gitee.io/image/images/wechart/2020-12/synchronized底层原理.png)]
- 首先来看看它是由那些部分组成的
- Wait Set:那些调用wait方法被阻塞的线程放置在这里
- contention List:竞争队列,所有请求锁的线程首先被放在竞争队列
- entry list:contention中那些有资格的会被移入contention list
- ondeck:任意时刻,最多有一个线程在竞争资源,该线程为ondeck
- owner:当前获取到资源的线程
- 再来详细说一下synchronized的大致工作流程
- JVM 每次从队列的尾部取出一个数据用于锁竞争候选者(OnDeck),但是并发情况下,ContentionList 会被大量的并发线程进行 CAS 访问,为了降低对尾部元素的竞争,JVM 会将一部分线程移动到 EntryList 中作为候选竞争线程。
- Owner 线程会在 unlock 时,将 ContentionList 中的部分线程迁移到 EntryList 中,并指定EntryList 中的某个线程为 OnDeck 线程(一般是最先进去的那个线程)。
- Owner 线程并不直接把锁传递给 OnDeck 线程,而是把锁竞争的权利交给 OnDeck,OnDeck 需要重新竞争锁。这样虽然牺牲了一些公平性,但是能极大的提升系统的吞吐量,在JVM 中,也把这种选择行为称之为“竞争切换”。
- OnDeck 线程获取到锁资源后会变为 Owner 线程,而没有得到锁资源的仍然停留在 EntryList中。如果 Owner 线程被 wait 方法阻塞,则转移到 WaitSet 队列中,直到某个时刻通过 notify或者 notifyAll 唤醒,会重新进去 EntryList 中。处于 ContentionList、EntryList、WaitSet 中的线程都处于阻塞状态,该阻塞是由操作系统来完(Linux 内核下采用pthread_mutex_lock 内核函数实现的)。
- Synchronized 是非公平锁。 Synchronized 在线程进入 ContentionList 时,等待的线程会先尝试自旋获取锁,如果获取不到就进入 ContentionList,这明显对于已经进入队列的线程是不公平的,还有一个不公平的事情就是自旋获取锁的线程还可能直接抢占 OnDeck 线程的锁资源。
- 每个对象都有个 monitor 对象,加锁就是在竞争 monitor 对象,代码块加锁是在前后分别加上 monitorenter 和 monitorexit 指令来实现的,方法加锁是通过一个标记位来判断的synchronized 是一个重量级操作,需要调用操作系统相关接口,性能是低效的,有可能给线程加锁消耗的时间比有用操作消耗的时间更多。
ENDING
看完这些大家应该对synchronized有一个非常全面且深入的了解吧~希望面试官问到你synchronized的时候你能用这篇文章学到的知识“手撕”面试官。
本次分享就到这里结束了,对于这样一篇干货满满的文章不点赞你的心不会痛吗~
学习群成立啦~
往期推荐