Synchronized底层实现

810 阅读7分钟

Synchronized底层实现

1.功能

​ synchronized 可以保证方法或者代码块在运行时,同一时刻只有一个方法可以进入到临界区,同时它还可以保证共享变量的内存可见性

2.了解synchronized所需的基础知识

  • CAS(compare and swap)

如上图所示,长方形的内存中有一个值为1,现在线程A想实现想在它的基础上加1并将结果写回到到内存中,回经历上图中的三个步骤:

① 访问内存读取内存值1

② 计算结果

③ 将结果写回内存中时先对比该此时内存中的值是否与之前取出的值是否一致,一致则将结果写入内存,不 一致(中途线程B修改了内存中的值)则重复三个步骤直到写入结果成功为止

ABA问题:其他线程修改数次最后值与原值相同(线程B在A进行步骤三之前多次修改了内存值,但修改完的最终值仍然是1,此时A在步骤三进行比较时判定结果未被其他线程修改,直接将结果写入),在一些特殊的场景是需要区分内存是否被其他线程修改的情况,可采用版本号的方式进行判断

  • CAS底层实现

    在Java中CAS最底层实现是 lock cmpxchg

    cpu中有一条汇编指令是 cmpxchg,cpu底层支持compare and exchange操作,但该指令不是原子的,cpu在执行这条命令的中途也会被打断,所以在多核场景下需要使用指令lock将锁定一个北桥信号(可以简单理解为内存总线)锁住,使得cmpxchg不会被其他cpu打断

  • Java对象的内存分布

    如下图所示,一个普通的Java对象由四部分组成

    1.markword

    ​ 主要用来表示对象的线程锁状态,另外还可以用来配合GC、存放该对象的hashCode

    2.Klass pointer

    ​ 是一个指向方法区中Class信息的指针,意味着该对象可随时知道自己是哪个Class的实例

    3.instance data

    ​ 用于保存对象属性和值的主体部分,占用内存空间取决于对象的属性数量和类型

    4.对齐字节是为了减少堆内存的碎片空间

  • markword的细节

    下图为64位markword的具体细节功能,其中针对synchronized来说,synchronized本质上就是对markword里面的值进行操作,最需要关注的是最后三位的值,用于表示不同级别的锁

3.synchronized的升级过程(无锁态->偏向锁->轻量级锁->重量级锁)

​ synchronized在JDK1.6以前被开发者经常诟病,原因是因为一旦使用synchronized就必须向操作系统内核申请锁资源,这样会造成严重的性能损耗,之后对其进行了一系列优化,在大部分情况下使用synchronized就能满足需要,下面是锁的大致升级过程:

首先我们需要先了解一下什么是偏向锁、轻量级锁和重量级锁?

假设现在有一个只提供一人的自习室,目前自习室中没有人在使用

偏向锁

这时同学A(线程A)要使用该自习室,同学A便把自己的名字(线程id)贴到了自习室的门口(markword),告诉大家现在自习室正在使用。

为什么需要要有偏向锁呢?

因为有多数的synchronized方法,在很多情况下,只有一个一个线程或者在同一时刻只会有一个线程运行(如StringBuffer的一些sync方法),如果此时仍需要向内核获取锁代价太大,所以首先初始化一个对象,若某个线程需要使用则使用CAS的方式将创建这个对象的线程id写到markword上,标识为偏向锁(具体可见上表markword细节中偏向锁一行)

具体过程:

初始化一个对象,此时该对象K为无锁状态,对应的markword后三位的值为001,处于可偏向状态。

此时线程A需要使用对象K,首先获取对象头中的markword,判断对象所处状态,分为以下两种情况:

① 可偏向状态(001):

​ 处于可偏向状态时,线程A会采用CAS的方式将线程ID写入markword,那么就会有写入成功和失败的情况

​ 成功:即线程ID写入markword,则认为已经获取到该对象的偏向锁, 执行同步块代码

​ 失败:如果 CAS 操作失败, 则说明有另外一个线程 Thread B 抢先获取了偏向锁。 这种状态说明该对象的竞争 比较激烈, 此时需要撤销 Thread B 获得的偏向锁,将 Thread B 持有的锁升级为轻量级锁。 该操作需 要等待全局安全点 JVM safepoint ( 此时间点, 没有线程在执行字节码)

② 已偏向状态(101):

​ 检测markword中存储的线程ID是否等于当前线程ID,如果相等, 则证明本线程已经获取到偏向锁, 可以直接继续执行同步代码块,如果不等, 则证明该对象目前偏向于其他线程, 需要撤销偏向锁

偏向锁如何撤销?

这里所说的所得撤销不是指将对象回复到无锁的状态,而是在偏向锁的获取过程中,发现了竞争,需要将已偏向的线程锁标识的偏向锁状态去掉进而升级到轻量级锁的过程,在等到下一全局安全点(此时间点所有的工作线程都停止了字节码的执行),通过markword中的线程ID找到成功获取了偏向锁的那个线程, 然后在该线程的栈帧中补充上轻量级加锁时, 会保存的锁记录(Lock Record), 然后将被获取了偏向锁对象的 MarkWord 更新为指向这条锁记录的指针

轻量级锁

假设同学A(线程A)和同学B(线程B)同时来到了自习室门口,说要使用该自习室,这时两者之间就产生了竞争,这时将直接升级成轻量级锁,每个线程在线程栈中生成LockRecord,用CAS方式尝试把做自己的指针更新到markword,所以轻量级锁有被称为自旋锁

为什么轻量级锁不使用线程id而是用LockRecord?

使用LockRecord可使轻量级锁实现锁重入

重量级锁

重量级锁重量级锁依赖于操作系统的互斥量(mutex) 实现, 其具体的详细机制此处暂不展开, 日后可能补充。 此处暂时只需要了解该操作会导致进程从用户态与内核态之间的切换, 是一个开销较大的操作

那么自旋锁什么时候升级成重量级锁呢?由于自旋锁自旋是需要占用CPU,线程数量过多的时使用自旋不太合适,以下是升级成重量级锁的条件:

1.老的方式:有线程自旋10次;自旋线程达到cpu核数的一半

2.新的方式:jvm中采用自适应自旋(Adaptive CAS)自行判断何时升级成重量级锁

具体实现:

​ 重量级锁的具体实现比较复杂,这里我就简单画图讲解一下大致流程逻辑,底层抢锁的实质就是获取这个mutex,其中有两个队列,wait set和entry list,其中entry list里面是一些可以争抢锁的线程,而wait set是一些休眠的线程,在wait set里面的线程是不消耗cpu资源的,而wait set中的线程需要操作系统的唤醒才能进入entry list进行争抢锁,这也是为什么重量级锁消耗大的问题

4.思考题

1.偏向锁一定会提高效率么?

2.如果一个对象先偏向于某个线程, 执行完同步代码后, 另一个线程就不能直接重新获得偏向锁吗?

3.既然JDK已经将synchronized的性能优化的很好了,为什么还会使用像RentrantLock的库呢?

4.如何控制何时启动偏向锁,什么是匿名偏向?