Java面试之synchronized的前世今生

前世今生的缘分

synchronized原理

这是一个月还没黑风也不咋高的大白天,逆旅开始了他人生中的第一次面试,他颤颤巍巍的打开了面试大门······················

面试官

里面坐着一个发际线有点高的油腻大叔,

面试官:“你先来个自我介绍吧”

逆旅:“嗯,好的,面试官你好,我叫逆旅,逆天改命的逆, 人在旅途的旅,我来自***大学,大二期间在实验室做了两个项目,balabalbala”

面试官(汗),并给了你一个爱意的眼神,“哦~,叫逆旅是吧,你这名字挺独特的呀,那我开始了”

“嗯嗯,您来吧,我(内心)不怕疼(很强大)”

面试官:“看你的项目涉及到了锁,那你能讲讲synchronized可以具体应用在哪些场景呢,比如方法和代码块啥的?”

逆旅(这也忒容易了)故作深沉并咳嗽了一声道:“嗯嗯,好的,面试官”

synchronized如果要实现同步,先得具有一个基础:Java中的对象都可以作为锁。因为synchronized用的锁都是存在Java对象头的,它的应用场景有以下几种:

synchronized应用场景

简而言之,就是

  • 同步普通方法,锁的是当前对象。
  • 同步静态方法,锁的是当前 Class 对象。
  • 同步块,锁的是 () 中的对象。

这里有一个面试题,问你获取对象锁和类锁分别有哪些方法?

获取对象锁的两种方法

  1. 同步代码块(synchronized(this),synchronized(类实例对象)),锁是小括号中的实例对象。
  2. 同步非静态方法(synchronized(method))锁是当前对象的实例对象。

获取类锁的两种方法

  1. 同步代码块(synchronized(类.class)),锁是小括号中的类对象(Class对象)。
  2. 同步静态方法(synchronized (staticMethod)),锁是当前对象的类对象(Class对象)。

实现原理

面试官邪恶一笑:“嗯,可以,那你能讲讲它具体底层是怎么实现的吗,一般人都只知道它能实现同步。”

逆旅:“哇,上来就来这么难的,得亏我是二般人呀(幸亏我用Javap命令仔细研究过)”

JVM基于进入和退出Monitor对象来实现方法同步和代码块同步,但两者的实现细节不一样。代码块同步是使用monitorenter 和monitorexit指令实现的,而方法同步是使用另外一种方式实现的,细节在JVM规范里并没有详细说明。但是,方法的同步同样可以使用这两个指令来实现。先得记住这两个命令,挺好记的,中文意思就是进—和出~

monitorenter指令是在编译后插入到同步代码块的开始位置,而monitorexit是插入到方法结束处和异常处,JVM要保证每个monitorenter必须有对应的monitorexit与之配对。任何对象都有一个monitor与之关联,当且一个monitor被持有后,它将处于锁定状态就是让获取过程具有排他性,从而就能达到同一时刻只能一个线程访问的目的。

线程执行到monitorenter 指令时,将会尝试获取对象所对应的monitor的所有权,即尝试获得对象的锁。对于没有获取到锁的线程就会阻塞到方法入口处,进入BLOCKED状态,(所以这就是只有一个线程能进入同步块的原因)直到获取锁的线程 monitor.exit 之后才能尝试继续获取锁。

让我们来通过简短的代码来演示一下:

public class Synchronize {
    public static void main(String[] args) {
        synchronized (Synchronize.class){
            System.out.println("Synchronize");
        }
    }
}

使用 javap -c Synchronize 可以查看编译之后的具体信息。

反汇编代码

可以看到在同步块的入口和出口分别有 monitorenter,monitorexit指令。

用流程图来表示就是这样的:

获取同步块流程图

锁优化

面试官:“哎哟,不错哦,小伙,那听说JDK1.6之前synchronized性能不太好,后面进行了一些优化,你可知道?”

逆旅内心有点小欣喜,(问的都是我会的,yes),“当然面试官,且听我细细道来”

忍住不笑

JDK1.6为了减少获得锁和释放锁带来的性能消耗,引入了**“偏向锁”和“轻量级锁”**,在 JDK1.6中,锁一共有4种状态,级别从低到高依次是:无锁状态、偏向锁状态、轻量级锁状 态和重量级锁状态,这几个状态会随着竞争情况逐渐升级。锁可以升级但不能降级,目的是为了提高获得锁和释放锁的效率。

偏向锁

偏向锁的特征是:锁不存在多线程竞争,并且应由一个线程多次获得锁。

获得锁

当线程访问同步块时,会使用 CAS线程 ID 更新到锁对象的 Mark Word 中,如果更新成功则获得偏向锁,并且之后每次进入这个对象锁相关的同步块时都不需要再次获取锁了。

撤销锁

偏向锁使用了一种等到竞争出现才释放锁的机制,所以当其他线程尝试竞争偏向锁时, 持有偏向锁的线程才会释放锁,释放时会等待全局安全点(这个时间点没有字节码运行)。

首先暂停拥有偏向锁的线程,然后检查持有偏向锁的线程是否活着, 如果线程它挂了,就是不处于活动状态,则将对象头设置成无锁状态;如果线程仍然活着,拥有偏向锁的栈会被执行,遍历偏向对象的锁记录,栈中的锁记录和对象头的Mark Word要么重新偏向于其他 线程,要么恢复到无锁或者标记对象不适合作为偏向锁(因为有新的线程出现了,所以它需要退出偏向锁状态),最后唤醒暂停的线程。

偏向锁可以提高带有同步却没有竞争的程序性能,但如果程序中大多数锁都存在竞争时,那偏向锁就起不到太大作用。可以使用 -XX:-userBiasedLocking=false 来关闭偏向锁,并默认进入轻量锁。

我们可以配合着下面这张图一起理解:

线程1表示偏向锁初始化的流程,线程2表示偏向锁撤销的流程。

偏向锁的获得和撤销流程图

这里有个小知识点Mark Word的锁标志位为01的时候代表无锁状态或偏向锁状态,通过前面的一个bit位(0或1)来表示是否是偏向锁,详情可以戳这——Mark Word

轻量级锁

轻量级锁是由偏向锁升级来的,偏向锁运行在一个线程进入同步块的情况下,当第二个线程加入锁争用的时候,偏向锁就会升级为轻量级锁。

面试官心想,这小子有点东西啊,“那你能继续讲讲轻量级锁加锁和解锁的流程吗?”(微笑)

逆旅:“好的呢,面试官,您想听啥我就讲啥”

加锁

当代码进入同步块时,如果同步对象为无锁状态时,当前线程会在栈帧中创建一个锁记录(Lock Record)区域,同时将锁对象的对象头中 Mark Word 拷贝到锁记录中,再尝试使用 CASMark Word 更新为指向锁记录的指针

如果更新成功,当前线程就获得了锁。

如果更新失败 JVM 会先检查锁对象的 Mark Word 是否指向当前线程的锁记录。如果是则说明当前线程拥有锁对象的锁,可以直接进入同步块。不是则说明有其他线程抢占了锁,如果存在多个线程同时竞争一把锁,轻量锁就会膨胀为重量级锁

解锁

轻量锁的解锁过程也是利用 CAS 来实现的,会尝试锁记录替换回锁对象的 Mark Word 。如果替换成功则说明整个同步操作完成,失败则说明有其他线程尝试获取锁,这时就会唤醒被挂起的线程(此时已经膨胀为重量级锁)

轻量级锁及膨胀流程图

面试官:“哦~,那轻量级锁为什么能提升性能呢?”

因为自旋会消耗CPU,为了避免无用的自旋(比如获得锁的线程被阻塞住了),锁一旦升级成重量级锁,就不会再恢复到轻量级锁状态。当锁处于这个状态下,其他线程试图获取锁时, 都会被阻塞住,当持有锁的线程释放锁之后会唤醒这些线程,被唤醒的线程就会进行新一轮的对锁的抢占。

认为大多数锁在整个同步周期都不存在竞争,所以使用 CAS 比使用互斥开销更少。(但如果锁竞争激烈,轻量锁就不但有互斥的开销,还有 CAS 的开销,甚至比重量锁更慢。)

重量级锁

锁竞争失败,锁就会膨胀为重量级锁,当线程处于这个状态,其他线程试图获取锁时,都会被阻塞住

其他优化

面试官:“逆旅挺不错呀,那你知道除了刚刚你讲的那两点,还有其他优化吗?”

逆旅:“这个嘛,当然有啦,面试官,当它在轻量级锁状态时,它如果没抢到锁,会自旋一段时间,也就是自旋锁”

自旋锁与自适应自旋锁

自旋锁定义:通过让线程执行忙循环等待锁的释放,不让出CPU。

自旋锁在JDK1.4.2中就已经引入,只不过默认是关闭的,可以使用-XX:+UseSpinning参数来开启,在JDK1.6中就已经改为默认开启了。

自旋次数的默认值是10次,用户可以使用参数-XX:PreBlockSpin来更改。

共享数据的锁定状态只会持续很短的一段时间,为了这段时间去挂起和恢复线程并不值得这个时候就需要用到自旋锁,当然自旋锁也有缺点:它虽然避免了线程切换的开销,但仍需要占用处理器时间,所以如果锁被占用的时间很长,那么自旋线程只会白白消耗处理器资源,反而会加大性能上的开销。

那它每次一遇到这种情况就要像个呆瓜似的自旋一段固定的时间,总不能次次都这样吧?

答案是肯定的,所以在JDK1.6中引入了自适应的自旋锁。自适应意味着自旋的时间不再固定,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在进行中,那么虚拟机就会认为这次自旋也很有可能再次成功,进而它将允许自旋等待相对更长的时间。如果对于某个锁,自旋很少成功获得过,那在以后获取这个锁时将可能省略掉自旋过程,以避免浪费处理器资源。这里有一个面试题:如果第一次用了很短的时间获取到锁,那么第二次虚拟机将允许它的时间是多于还是少于上次的时间?答案是多于

锁消除

锁消除是指虚拟机即时编译器在运行时,对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行消除。

锁消除的主要判定依据来源于逃逸分析的数据支持,如果判断到一段代码中,在堆上的所有数据都不会逃逸出去被其他线程访问到,那就可以把它们当做栈上数据对待,认为它们是线程私有的,同步加锁自然就无须进行。

可以来看这一行代码:

public String concatString(String s1, String s2, String s3) {
  return s1 + s2 + s3;
}

因为String是一个不可变的类,因此Java编译器会对String连接做自动优化。在JDK1.5之前,会转化为StringBuffer对象的连续append()操作,在JDK1.5及以后的版本中,会转化为StringBuilder对象的连续append()操作。即会变成如下代码:

public String addString(String s1, String s2, String s3) {
  StringBuffer s = new StringBuffer();
  s.append(s1);
  s.append(s2);
  s.append(s3);
  return s.toString();
}

每个StringBuffer.append()方法中都有一个同步块,锁就是s对象。虚拟机观察变量s,发现它的动态作用域被限制在addString()方法的内部。也就是s的所有引用永远不会“逃逸”到addString()方法之外,其他线程无法访问到它,所以这里虽然有锁,但是可以被安全地消除掉,在即时编译之后,这段代码就会忽略掉所有的同步而直接执行了。

锁粗化

通过扩大加锁的范围,避免反复加锁和解锁

通常情况下,为了保证多线程间的有效并发,会要求每个线程持有锁的时间尽可能短,但是在某些情况下,一个程序对同一个锁不间断、高频地请求、同步与释放,会消耗掉一定的系统资源,因为锁的请求、同步与释放本身会带来性能损耗,我们反而希望把很多次锁的请求合并成一个请求,以降低短时间内大量锁请求、同步、释放带来的性能损耗。

例如:StringBuffer 循环内调用100次append方法,append源码有synchronized,JVM会检测到一连串操作对同一对象反复加锁,JVM就会粗化到外部,只需加一次锁(面试中可能会要你举一个锁粗化的例子

正是因为synchronized有了这么多的优化,ConcurrentHashMap在JDK1.7之后内部方法使用了许多synchronized

面试官:“小旅啊,没看出来,你还挺能说啊,那你最后讲一下三种锁的优缺点和应用场景吧,说出来,我就给你过

逆旅:“真的吗,面试官,此时我只想对你说一句”

面试官

锁的优缺点对比

优点缺点适用场景
偏向锁加锁和解锁不需要额外的消耗,和执行非同步方法比仅存在纳秒级的差距如果线程间存在锁竞争,会带来额外的锁撤销的消耗适用于只有一个线程访问同步块场景
轻量级锁竞争的线程不会阻塞,提高了程序的响应速度如果始终得不到锁竞争的线程使用自旋会消耗CPU追求响应时间,锁占用时间很短
重量级锁线程竞争不使用自旋,不会消耗CPU线程阻塞,响应时间缓慢追求吞吐量,锁占用时间较长

面试官:“阔以哦,明天可以来上班了”

逆旅(哇咔咔?@#+——&^^…¥%…&*镇定):“嗯,谢谢面试官”

都看到这了,不点个赞再走吗,亲

​ 都看到这了,不点个赞再走吗,亲

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值