synchronized

Java 对象头

面试一:为什么有 Synchronized 升级过程。

简言之,最初无锁直接到重量级锁。由于重量级锁需要向内核申请额外的锁资源,涉及到用户态和内核态的转换,比较浪费资源。况且大多数情况下,都是同一个线程去争抢锁,完全不需要重量级锁。

Jdk1.6之前,被 synchronized 修饰的同步块在持有锁的线程执行完毕并释放锁之前,会无条件地阻塞后面其他线程的进入。这意味着无法像处理某些数据库中的锁那样,强制已获取锁的线程释放锁;也无法强制正在等待锁的线程中断等待或超时退出。
从执行成本的看,持有锁是一个重量级(Heavy-Weight)的操作。在主流 Java 虚拟机实现中,Java 的线程是映射到操作系统的原生内核线程之上的,如果要阻塞或唤醒一条线程,则需要操作系统来帮忙完成,这就不可避免地陷入用户态到核心态的转换中,进行这种状态转换需要耗费很多的处理器时间。尤其是对于代码特别简单的同步块,状态转换消耗的时间甚至会比用户代码本身执行的时间还要长。因此 synchronized 是 Java 中一个重量级的操作。也因此虚拟机本身进行优化,有了 Synchronized 升级过程。

面试二:什么情况下 Synchronized 升级。

  1. 当只有一个线程a去争抢锁的时候,会先使用偏向锁。就是给一个标识,说明现在这个锁被线程a占有。只有一个线程的时候就是偏向锁(当偏向锁开启的时候,偏向锁默认开启),当争抢的线程超过一个,升级为轻量级锁。
  2. 后来又来了线程b、线程c竞争,于是将标识去掉。也就是撤销偏向锁,升级为轻量级锁。三个线程通过CAS进行锁的争抢(其实还是偏向于原来持有偏向锁的线程)。轻量级锁适用于任务执行时间很短的线程,可能通过一两次自旋,就能够获取到锁。
  3. 线程a占有锁,随后陆陆续续多个线程,一直在自旋抢锁,如此也耗费CPU资源,所以就将锁升级为重量级锁,向内核申请资源,直接将等待的线程进行阻塞。当自旋的线程循环超过10次,或者线程等待的数量超过cpu的1/2,升级为重量级锁。

一、简述

synchronized 关键字是一把经典的JVM级别的锁。在加了它的方法、代码块中,一次只允许一个线程进入特定代码段,从而避免多线程同时修改同一数据。在 jdk1.6 之前,syncronized 是一把重量级的锁,不过随着 jdk 的升级,也在对它进行不断的优化,如今它变得不那么重了,甚至在某些场景下,它的性能反而优于轻量级锁。实现原理就是锁升级的过程。

1️⃣synchronized 的作用

  1. 原子性:保证语句块内操作是原子的。
  2. 可见性:保证可见性(通过“在执行 unlock 之前,必须先把此变量同步回主内存”实现)。
  3. 有序性:保证有序性(通过“一个变量在同一时刻只允许一条线程对其进行 lock 操作”)。

2️⃣synchronized 的使用

  1. 修饰实例方法,对当前实例对象加锁。
  2. 修饰静态方法,对当前类的 Class 对象加锁。
  3. 修饰代码块,对 synchronized 括号内的对象加锁。

二、实现原理

JVM 的互斥同步(synchronized)基于进入和退出管程monitor,虚拟机规范中用的是管程一词对象实现,无论是隐式同步还是显式同步(有明确的 monitorenter 和 monitorexit 指令,即同步代码块)都是如此。

1️⃣【隐式同步】方法级的同步
方法级的同步是隐式,即无需通过字节码指令来控制的,它实现在方法调用和返回操作之中。JVM 可以从方法常量池中的方法表结构的 ACC_SYNCHRONIZED 访问标志区分一个方法是否同步方法。当方法调用时,调用指令将会检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置。如果设置了,执行线程将先持有 monitor,再执行方法,最后在方法(无论正常还是非正常)完成时释放 monitor。

2️⃣【显式同步】代码块的同步
代码块的同步是利用 monitorenter 和 monitorexit 这两个字节码指令实现的。被 synchronized 修饰的代码,编译器编译后在前后加上了一组字节指令。

  1. 在代码开始加了 monitorenter,在代码后面加了 monitorexit,这两个字节码指令配合完成了 synchronized 关键字修饰代码的互斥访问。
  2. 在虚拟机执行到 monitorenter 指令的时候,会请求获取对象的 monitor 锁,基于 monitor 锁又衍生出一个锁计数器的概念。
  3. 当执行 monitorenter 时,若对象未被锁定时,或者当前线程已经拥有了此对象的 monitor 锁,则锁计数器+1,该线程获取该对象锁。
  4. 当执行 monitorexit 时,锁计数器 -1,当计数器为 0 时,此对象锁就被释放了。那么其它阻塞的线程就可以请求获取该 monitor 锁。
  5. 如果获取 monitor 对象失败,该线程则会进入阻塞状态,直到其他线程释放锁。

注意:

  1. synchronized 是可重入的,不会自己把自己锁死。
  2. synchronized 锁一旦被一个线程持有,其他试图获取该锁的线程将被阻塞。

关于 ACC_SYNCHRONIZED 、monitorenter、monitorexit 指令,可以看如下的反编译代码:

public class SynchronizedDemo {
    public synchronized void f(){//这个是同步方法
        System.out.println("Hello world");
    }
    public void g(){
        synchronized (this){//这个是同步代码块
            System.out.println("Hello world");
        }
    }
    public static void main(String[] args) {
    }
}

使用javap -verbose SynchronizedDemo反编译后得到:同步方法,反编译后得到ACC_SYNCHRONIZED标志
同步代码块反编译后得到monitorenter和monitorexit指令

三、JVM 对 synchronized 的锁优化

Jdk1.6 为了减少获得锁和释放锁带来的性能消耗,引入了“偏向锁”和“轻量级锁”。锁的状态总共有四种,级别从低到高依次是:无锁状态、偏向锁、轻量级锁和重量级锁。随着锁的竞争,锁可以升级但不能降级。

1️⃣偏向锁
偏向锁是 JDK6 中引入的一项锁优化措施,它的目的是消除数据在无竞争情况下的同步原语,进一步提高程序的运行性能。如果说轻量级锁是在无竞争的情况下使用CAS操作去消除同步使用的互斥量,那偏向锁就是在无竞争的情况下把整个同步都消除掉,连CAS操作都不去做了。

假设虚拟机启用了偏向锁(启用参数-XX:+UseBiased Locking,这是自JDK6起HotSpot虚拟机的默认值),当锁对象第一次被线程获取的时候,虚拟机将会把对象头中的锁标志位设置为“01”、把偏向模式设置为“1”,表示进入偏向模式。同时使用CAS操作把获取到该锁的线程的ID记录在对象的Mark Word之中。如果CAS操作成功,持有偏向锁的线程以后每次进入该锁相关的同步块时,虚拟机都可以不再进行任何同步操作(例如加锁、解锁及对Mark Word的更新操作等)。如此就省去了大量有关锁申请的操作,从而也就提升了程序的性能。

所以,对于没有锁竞争的场合,偏向锁有很好的优化效果,毕竟极有可能连续多次是同一个线程申请相同的锁。但是对于锁竞争比较激烈的场合,偏向锁就失效了,因为极有可能每次申请锁的线程都是不同的,因此这种场合下不应该使用偏向锁,否则会得不偿失。需要注意的是,偏向锁失败后,并不会立即膨胀为重量级锁,而是先升级为轻量级锁。

偏向锁在 JDK1.8 中,其实默认是轻量级锁,但如果设定了-XX:BiasedLockingStartupDelay=0,那在对一个 Object 做 synchronized 的时候,会立即上一把偏向锁。当处于偏向锁状态时,Mark Word 会记录当前线程 ID。

  1. 判断是否为可偏向状态。
  2. 如果为可偏向状态,则判断线程 ID 是否为当前线程,如果是进入同步块。
  3. 如果线程 ID 不是当前线程,利用 CAS 操作竞争锁。如果竞争成功,将 Mark Word 中线程 ID 更新为当前线程 ID,进入同步块。
  4. 如果竞争失败,等待全局安全点,准备撤销偏向锁。根据线程是否处于活动状态,决定是转换为无锁状态还是升级为轻量级锁。

偏向锁的释放:偏向锁使用了遇到竞争才释放锁的机制

偏向锁的撤销需要等待全局安全点,首先暂停拥有偏向锁的线程,然后判断线程是否还活着。如果线程还活着,则升级为轻量级锁,否则,将锁设置为无锁状态。

2️⃣轻量级锁
当又一个线程参与到偏向锁竞争时,会先判断 mark word 中保存的线程 ID 是否与竞争线程 ID 相等。如果不相等,会立即撤销偏向锁,升级为轻量级锁。每个线程在自己的线程栈中生成一个 LockRecord(LR),然后各个线程通过 CAS 操作将锁对象头中的 mark word 设置为指向自己的 LR 的指针,哪个线程设置成功,就意味着获得锁。关于 synchronized 中此时执行的 CAS 操作是通过 native 的调用 HotSpot 中 bytecodeInterpreter.cpp 文件 C++ 代码实现的。

轻量级锁能够提升程序性能的依据是“对绝大部分的锁,在整个同步周期内都不存在竞争”,注意这是经验数据。需要了解的是,轻量级锁所适应的场景是线程交替执行同步块的场合,如果存在同一时间访问同一锁的场合,就会导致轻量级锁膨胀为重量级锁。

3️⃣自旋锁
轻量级锁失败后,虚拟机为了避免线程真实地在操作系统层面挂起,还会进行一项称为自旋锁的优化手段。这是基于大多数情况下,线程持有锁的时间都不会太长,如果直接挂起操作系统层面的线程可能会得不偿失,毕竟操作系统实现线程之间的切换时需要从用户态转换到核心态,这个状态之间的转换,时间成本相对较高。自旋锁假设很快当前的线程就可以获得锁,因此虚拟机会让当前想要获取锁的线程做几个空循环(这也是称为自旋的原因),一般不会太久,可能是 50 个循环或 100 循环。在经过若干次循环后,如果得到锁,就顺利进入临界区。如果还不能获得锁,那就会将线程在操作系统层面挂起,这就是自旋锁的优化方式,这种方式确实也是可以提升效率的。最后没办法也就只能升级为重量级锁了。

4️⃣重量级锁
如果锁竞争加剧(如线程自旋次数或者自旋的线程数超过某阈值,JDK1.6 之后,由 JVM 自己控制改动规则),就会升级为重量级锁。此时就会向操作系统申请资源,线程挂起,进入到操作系统内核态的等待队列中,等待操作系统调度,然后映射回用户态。在重量级锁中,由于需要做内核态到用户态的转换,而这个过程中需要消耗较多时间,也就是“重”的原因之一。

synchronized 的重量级锁是通过对象内部的一个叫做监视器锁(monitor)来实现的,监视器锁本质又是依赖于底层的操作系统的 Mutex Lock(互斥锁) 来实现的。而操作系统实现线程之间的切换需要从用户态转换到核心态,这个成本非常高,状态之间的转换需要相对比较长的时间,这就是为什么 Synchronized 效率低的原因。因此,这种依赖于操作系统 Mutex Lock 所实现的锁称之为“重量级锁”。

5️⃣锁消除
锁消除是虚拟机另外一种锁的优化,这种优化更彻底,Java 虚拟机在 JIT 编译时(可以简单理解为当某段代码即将第一次被执行时进行编译,又称即时编译),通过对运行上下文的扫描,去除不可能存在共享资源竞争的锁,通过这种方式消除没有必要的锁,可以节省毫无意义的请求锁时间。如:

public void add() {
    StringBuffer sb = new StringBuffer();
    sb.append("a").append("b");
}

StringBuffer 的 append() 是一个同步方法。add() 中的局部对象 sb,只在该方法内的作用域有效,不可能被其他线程引用(因为是局部变量,栈私有)。不同线程同时调用 add() 时,都会创建不同的 sb 对象,sb 不可能存在共享资源竞争的情景。因此此时的 append() 若是同步,就是白白浪费的系统资源。JVM 会自动消除 StringBuffer 对象内部的锁。

6️⃣锁粗化
如果虚拟机检测到有这样一串零碎的操作都对同一个对象加锁,将会把加锁同步的范围扩展(粗化)到整个操作序列的外部。如:

public String test() {
    int i = 0;
    StringBuffer sb = new StringBuffer();
    while (i < 100) {
        sb.append("a");
        i++;
    }
    return sb.toString();
}

JVM 会检测到这样一连串的操作都对同一个对象加锁(while 循环内 100 次执行 append(),没有锁粗化就要进行 100 次加锁/解锁),此时 JVM 就会将加锁的范围粗化到这一连串的操作的外部(比如 while 循环体外),使得这一连串操作只需要加一次锁即可。

  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

JFS_Study

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值