synchronized 关键字原理

一.是什么
  • synchronized是Java中的关键字,是一种同步锁,以前我们总是觉得synchronized是重量级的锁,随着Java1.6以来的优化synchronized 并不会显得那么重了。
  • 作用
    • synchronized 可以保证方法或者代码块在运行时,同一时刻只有一个方法可以进入到临界区,同时它还可以保证共享变量的内存可见性。
二.用法

synchronized 带代码中的位置可以有以下几种在这里插入图片描述
假如我们有如下代码

public class SynchronizedTest {

    public synchronized void test1() {

    }

    public void test2() {
        synchronized(this) {

        }
    }
    
}

利用 Javap 工具查看生成的 class 文件信息来分析 synchronized 的实现
在这里插入图片描述
-从上面可以看出:

  • 1)同步代码块是使用 monitorenter 和 monitorexit 指令实现的;
  • 2)同步方法(在这看不出来需要看JVM底层实现)依靠的是方法修饰符上的ACC_SYNCHRONIZED 实现。

Java 对象头和 Monitor 是实现 synchronized 的基础! 下面就这两个概念来做详细介绍。

  • synchronized 用的锁是存在Java对象头里的。那么什么是 Java 对象头呢?Hotspot 虚拟机的对象头主要包括两部分数据:Mark Word(标记字段)、Klass Pointer(类型指针)。其中:
    • Klass Point 是是对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。

    • Mark Word 用于存储对象自身的运行时数据,如哈希码(HashCode)、GC 分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等等。Java 对象头一般占有两个机器码(在 32 位虚拟机中,1 个机器码等于 4 字节,也就是 32 bits)。但是如果对象是数组类型,则需要三个机器码,因为 JVM 虚拟机可以通过 Java 对象的元数据信息确定 Java 对象的大小,无法从数组的元数据来确认数组的大小,所以用一块来记录数组长度。
      在这里插入图片描述

  • 每一行,是一种情况。

三、Monitor

每个对象都有一个监视器。当该监视器被占用时即是锁定状态(或者说获取监视器即是获得同步锁)。线程执行monitorenter指令时会尝试获取监视器的所有权,过程如下: 若该监视器的进入次数为0,则该线程进入监视器并将进入次数设置为1,此时该线程即为该监视器的所有者若线程已经占有该监视器并重入,则进入次数+1若其他线程已经占有该监视器,则线程会被阻塞直到监视器的进入次数为0,之后线程间会竞争获取该监视器的所有权只有首先获得锁的线程才能允许继续获取多个锁

四、锁优化
  • 自旋锁
    • 为了解决什么问题
      • 线程的阻塞和唤醒,需要 CPU 从用户态转为核心态。频繁的阻塞和唤醒对 CPU 来说是一件负担很重的工作,势必会给系统的并发性能带来很大的压力。同时,我们发现在许多应用上面,对象锁的锁状态只会持续很短一段时间。为了这一段很短的时间,频繁地阻塞和唤醒线程是非常不值得的。所以引入自旋锁。
    • 所谓自旋锁,就是通过无意义的循环让该线程等待一段时间,不会被立即挂起,看持有锁的线程是否会很快释放锁。
    • 自旋等待不能替代阻塞,如果持锁线程快速释放锁,效率自然高,若锁被长时间持有,那么自选只会带来更多的性能消耗。若以自旋次数一定要有限制,
    • 自旋锁在 JDK 1.4.2 中引入,默认关闭,但是可以使用 -XX:+UseSpinning 开开启。
    • 在 JDK1.6 中默认开启。同时自旋的默认次数为 10 次,可以通过参数 -XX:PreBlockSpin 来调整。

通过参数 -XX:PreBlockSpin 来调整自旋锁的自旋次数,会带来诸多不便。于是 JDK 1.6 引入自适应的自旋锁,让虚拟机会变得越来越聪明。

适应自旋锁

JDK 1.6 引入了更加聪明的自旋锁,即自适应自旋锁。

  • 所谓自适应就意味着自旋的次数不再是固定的,它是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。它怎么做呢?

  • 线程如果自旋成功了,那么下次自旋的次数会更加多,因为虚拟机认为既然上次成功了,那么此次自旋也很有可能会再次成功,那么它就会允许自旋等待持续的次数更多。
    反之,如果对于某个锁,很少有自旋能够成功的,那么在以后要或者这个锁的时候自旋的次数会减少甚至省略掉自旋过程,以免浪费处理器资源。

  • 有了自适应自旋锁,随着程序运行和性能监控信息的不断完善,虚拟机对程序锁的状况预测会越来越准确,虚拟机会变得越来越聪明。

锁消除
  • 由来

为了保证数据的完整性,我们在进行操作时需要对这部分操作进行同步控制。但是,在有些情况下,JVM检测到不可能存在共享数据竞争,这是JVM会对这些同步锁进行锁消除。如果不存在竞争,为什么还需要加锁呢?所以锁消除可以节省毫无意义的请求锁的时间。

  • 定义

锁消除的依据是逃逸分析的数据支持。变量是否逃逸,对于虚拟机来说需要使用数据流分析来确定,但是对于我们程序员来说这还不清楚么?我们会在明明知道不存在数据竞争的代码块前加上同步吗?但是有时候程序并不是我们所想的那样?我们虽然没有显示使用锁,但是我们在使用一些 JDK 的内置 API 时,如 StringBuffer、Vector、HashTable 等,这个时候会存在隐性的加锁操作。比如 StringBuffer 的 #append(…)方法,Vector 的 add(…) 方法:

3.3 锁粗化
  • 由来

我们知道在使用同步锁的时候,需要让同步块的作用范围尽可能小:仅在共享数据的实际作用域中才进行同步。这样做的目的,是为了使需要同步的操作数量尽可能缩小,如果存在锁竞争,那么等待锁的线程也能尽快拿到锁。

在大多数的情况下,上述观点是正确的,LZ 也一直坚持着这个观点。但是如果一系列的连续加锁解锁操作,可能会导致不必要的性能损耗,所以引入锁粗话的概念。

  • 定义

锁粗话概念比较好理解,就是将多个连续的加锁、解锁操作连接在一起,扩展成一个范围更大的锁。

如上面实例:vector 每次 add 的时候都需要加锁操作,JVM 检测到对同一个对象(vector)连续加锁、解锁操作,会合并一个更大范围的加锁、解锁操作,即加锁解锁操作会移到 for 循环之外。

锁的升级

锁主要存在四种状态,依次是:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态。它们会随着竞争的激烈而逐渐升级。注意,锁可以升级不可降级,这种策略是为了提高获得锁和释放锁的效率。
重量级锁通过对象内部的监视器(Monitor)实现。

其中,Monitor 的本质是,依赖于底层操作系统的 Mutex Lock 实现。操作系统实现线程之间的切换,需要从用户态到内核态的切换,切换成本非常高。

轻量级锁

引入轻量级锁的主要目的,是在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。

当关闭偏向锁功能或者多个线程竞争偏向锁,导致偏向锁升级为轻量级锁,则会尝试获取轻量级锁,其步骤如下:

  • 获取锁

  • 1)判断当前对象是否处于无锁状态?若是,则 JVM 首先将在当前线程的栈帧中,建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的 Mark Word的 拷贝(官方把这份拷贝加了一个 Displaced 前缀,即 Displaced Mark Word);否则,执行步骤(3);

  • 2)JVM 利用 CAS 操作尝试将对象的 Mark Word 更新为指向 Lock Record 的指正。如果成功,表示竞争到锁,则将锁标志位变成 00(表示此对象处于轻量级锁状态),执行同步操作;如果失败,则执行步骤(3);

  • 3)判断当前对象的 Mark Word 是否指向当前线程的栈帧?如果是,则表示当前线程已经持有当前对象的锁,则直接执行同步代码块;否则,只能说明该锁对象已经被其他线程抢占了,当前线程便尝试使用自旋来获取锁。若自旋后没有获得锁,此时轻量级锁会升级为重量级锁,锁标志位变成 10,当前线程会被阻塞。
    在这里插入图片描述

  • 注意事项

    • 对于轻量级锁,其性能提升的依据是:“对于绝大部分的锁,在整个生命周期内都是不会存在竞争的”。如果打破这个依据则除了互斥的开销外,还有额外的 CAS 操作,因此在有多线程竞争的情况下,轻量级锁比重量级锁更慢。
偏向锁
  • 痛点: Hotspot作者发现在大多数情况下不存在多线程竞争的情况,而是同一个线程多次获取到同一个锁,为了让线程获得锁代价更低,因此设计了偏向锁 (这个跟业务使用有很大关系)主要目的: 为了在无多线程竞争的情况下尽量减少不必要的轻量级锁执行路径原理: 在只有一个线程执行同步块时通过增加标记检查而减少CAS操作进一步提高性能数据结构: 包括占有锁的线程id,是否是偏向锁,epoch(偏向锁的时间戳),对象分代年龄、锁标志位
    在这里插入图片描述
  • 偏向锁初始化
  • 当一个线程访问同步块并获取到锁时,会在对象头和栈帧中的锁记录里存储偏向锁的线程ID,以后该线程在进入和退出同步块时不需要花费CAS操作来加锁和解锁,而是先简单检查对象头的MarkWord中是否存储了线程:
  • 如果已存储,说明线程已经获取到锁,继续执行任务即可
  • 如果未存储,则需要再判断当前锁否是偏向锁(即对象头中偏向锁的标识是否设置为1,锁标识位为01):
  • 如果没有设置,则使用CAS竞争锁(说明此时并不是偏向锁,一定是等级高于它的锁)
  • 如果设置了,则尝试使用CAS将对象头的偏向锁指向当前线程,也就是结构中的线程ID
偏向锁撤销
  • 锁偏向锁使用一种等到竞争出现才释放锁的机制,只有当其他线程竞争锁时,持有偏向锁的线程才会释放锁
  • 偏向锁的撤销需要等待全局安全点(该时间点上没有字节码正在执行)
  • 偏向锁的撤销需要遵循以下步骤:
    首先会暂停拥有偏向锁的线程并检查该线程是否存活:
  • 如果线程非活动状态,则将对象头设置为无锁状态(其他线程会重新获取该偏向锁)
  • 如果线程是活动状态,拥有偏向锁的栈会被执行,遍历偏向对象的锁记录,并将对栈中的锁记录和对象头的MarkWord进行重置:
  • 要么重新偏向于其他线程(即将偏向锁交给其他线程,相当于当前线程"被"释放了锁)
  • 要么恢复到无锁或者标记锁对象不适合作为偏向锁(此时锁会被升级为轻量级锁)最后唤醒暂停的线程,被阻塞在安全点的线程继续往下执行同步代码块
偏向锁关闭锁
  • 偏向锁在JDK1.6以上默认开启,开启后程序启动几秒后才会被激活
  • 有必要可以使用JVM参数来关闭延迟 -XX:BiasedLockingStartupDelay = 0
  • 如果确定锁通常处于竞争状态,则可通过JVM参数 -XX:-UseBiasedLocking=false 关闭偏向锁,那么默认会进入轻量级锁
偏向锁注意事项
  • 优势:
    偏向锁只需要在置换ThreadID的时候依赖一次CAS原子指令,其余时刻不需要CAS指令(相比其他锁)
  • 隐患:
    由于一旦出现多线程竞争的情况就必须撤销偏向锁,所以偏向锁的撤销操作的性能损耗必须小于节省下来的CAS原子指令的性能消耗(这个通常只能通过大量压测才可知)
  • 对比:
    轻量级锁是为了在线程交替执行同步块时提高性能,而偏向锁则是在只有一个线程执行同步块时进一步提高性能
对比

在这里插入图片描述

参考问文章:
链接:https://juejin.im/post/5abc9de851882555770c8c72
http://www.iocoder.cn/JUC/sike/synchronized/

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

林木森^~^

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

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

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

打赏作者

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

抵扣说明:

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

余额充值