翻牌源码之JAVA多线程系列---synchronized剖析

synchronized是java内置的锁,是java实现线程安全的一大利器,可用于实现原子性操作。(原子操作好比是动作的最小单位,要么一步到位做完,要么什么都没有做,中间谁都不可以横插一脚)

synchronized包裹的代码称为“同步代码块”,包含两部分:锁--对象引用,代码块--锁保护的代码块。如果synchronized修饰了一个方法,那么这个方法的全部内容,就相当于是一个被锁保护着的代码块,而此时的锁,就是方法调用所在的对象;而静态方法的锁,则是该方法所在的Class。

synchronized是一种可重入的互斥锁,也就是说,被这种锁保护的代码块,同一时间只能由获取了这把锁的同一个线程进入,并且可以反复进入。

对于synchronized,使用起来很简单,这里不再对如何使用进行过多的说明了。接下来的主要内容是,synchronized关键字是怎么实现同步锁这一功能的。

对象头

每一个java对象,都可以作为一个实现同步的锁。而对象之所以可以作为锁,与对象头中存储的内容关系紧密。下边就先来看一下一个对象的内部结构是啥样的。

首先,可以使用jol工具查看一下对象的内存布局,来对对象中存储的数据有一个大致的了解。新建一个maven项目,pom中加入依赖

<dependency>
    <groupId>org.openjdk.jol</groupId>
    <artifactId>jol-core</artifactId>
    <version>0.14</version>
</dependency>

新建一个Person类,里边有两个变量,String name和Integer age。然后执行如下代码

    public static void main(String[] args) {
        Person li = new Person();
        System.out.println(ClassLayout.parseInstance(li).toPrintable());
    }

可以看到,此时的输出为:

说明一下我的电脑用的是64位的系统,注意到object header在这里总共占用是12bytes,其中前8bytes存储的是markword,后4bytes这里存储的是类型指针,作用是指向当前java对象保存在方法区中的类信息。一般来讲,32位系统的类型指针大小4bytes,64位系统的类型指针为8types。那么这里使用的是64位系统,为什么类型指针只占4bytes呢,那是因为jvm默认为64位系统开启了类型指针压缩,在不影响寻址的情况下,减少空间占用。

通过这个图,还可以看到int类型的age占用4bytes,引用类型的name也占4bytes(这里也是因为使用了压缩指针的原理)。有效空间一共20bytes。因为对象占用空间的大小一般都要是8的倍数,所以会再补齐4bytes,最终大小24bytes。这里对这部分内容不在深入,只了解object header中的前8bytes,也就是markword部分。

markword是对象用来存储什么的呢?这里说明一下,32位系统和64位系统除了占用空间的不同,所存储的内容其实是一样的。32位系统的markword的设计十分经典,没有一位是多余的,可以说充满了美感,这里就以32位系统为例,来看一下,如下图所示:

32位markword

锁状态
25bits
4bits
1bits2bits
23bits2bits是否偏向锁锁标识位
无锁态对象的hashCode分代年龄001
偏向锁线程IDepoch分代年龄101
轻量级锁指向持有该锁的线程栈中锁记录的指针00
重量级锁指向重量级锁的指针10
GC标记11

到了这里可以发现,对象头的数据不是一成不变的,是会不停变化的。而且根据锁状态的不同,保存的内容也完全不同。这里的锁状态,就是接下来重点要说的内容。这四种锁状态也就是为了synchronized关键字锁的性能优化,而专门进行设计的。终于,来到了本文的主要内容部分,一个一个说。

无锁

无锁就是没有对资源进行锁定,所有的线程都能访问并修改同一个资源,但同时只有一个线程能修改成功。

无锁是一个比较抽象的概念,并不是指多线程可以无所顾忌的执行逻辑,而没有任何条件,这只是对锁的一种优化。比如CAS就是无锁的一种实现。无锁在某些情况下可以很好的执行,比如累加操作等执行时间非常短,并且基本没有并发的场景。但是无法全面代替锁。

偏向锁

偏向锁是指一段同步代码如果一直被一个线程所访问,那么该线程会自动获取锁,降低获取锁的代价。在大多数情况下,锁总是由同一线程多次获得,不存在多线程竞争。其目标就是在只有一个线程执行同步代码块时能够提高性能。下边说一下获取偏向锁的流程:

1、当线程A访问同步块时,结合上图的markword来看,当锁标识位为01,意味着当前对象是无锁状态或者是偏向锁状态。

2、步骤1判断成功后,这里判断是否偏向锁状态是否为1,否,则进入轻量级锁逻辑,一会儿再说。是,继续步骤3。需要注意的是,如果当前对象已经被计算过hashCode了,因为hashCode有一旦生成就不会更改的特点,所以,为了能够保存hashCode,此时一定无法进入到偏向锁模式。

3、检查对象markword中的 线程ID 是否是当前线程,如果不是,进入到步骤4,如果是,进入到步骤5。

4、此时,进行CAS操作,企图将当前线程ID替换进对象的markword。如果当前对象锁状态处于匿名偏向锁状态(可偏向未锁定,此时线程ID是0),则会替换成功,获取到锁,执行同步代码块。如果替换失败,说明该对象锁已经偏向其他线程或存在竞争,此时进入步骤6。

5、对象markword中的 线程ID 是当前线程,直接获取锁,执行同步块代码,也不用进行CMS,速度很快。

6、此时需要进行偏向锁撤销的操作。需要等待全局安全点(safe point,代表了一个状态,在该状态下所有线程都是暂停的),暂停持有偏向锁的线程,检查持有偏向锁的线程状态(遍历当前JVM的所有线程,如果能找到,则说明偏向的线程还存活),如果线程还存活,则检查线程是否在执行同步代码块中的代码,如果是,则升级为轻量级锁。

7、如果持有偏向锁的线程未存活,或者持有偏向锁的线程未在执行同步代码块中的代码,则进行校验是否允许重偏向,如果不允许重偏向,则撤销偏向锁,将markword设置为无锁状态(未锁定不可偏向状态),然后升级为轻量级锁,进行轻量级锁的获取步骤。

8、如果允许重偏向,设置为匿名偏向锁状态,CAS将偏向锁重新指向线程A(在对象头和线程栈帧的锁记录中存储当前线程ID)。

9、唤醒暂停的线程,从安全点继续执行代码。

偏向锁是JDK1.6才加入的特性,是为了解决Hashtable等api操作时性能低下而进行的优化。不过随着时间的发展,当时带来的性能优势现在来看已经不那么明显了,并且偏向锁的实现也很复杂并难以维护,所以在jdk15中默认禁用了偏向锁,并会在今后根据情况来确定是否废弃,官方说明如下:

轻量级锁

轻量级锁也是对synchronized的一种优化,和重量级锁一样适用于线程较多的情况,但是如果那么使用轻量级锁,用 CAS 操作 替代操作系统 互斥量, 可以避免内核态、用户态切换,效率更高。

若存在竞争,甚至存在剧烈竞争,轻量级锁会膨胀为重量级锁,一会儿再说,这里先看一下轻量级锁的获取过程。

1、在加锁前,虚拟机需要在当前线程的栈帧中建立锁记录(Lock Record)的空间。Lock Record 中包含一个 _displaced_header 属性,用于存储锁对象的 markword的拷贝。

2、判断当前对象是否有锁,没有锁则执行步骤3、有锁则执行步骤5.

3、将锁对象的 markword 复制到锁记录中,这个复制过来的记录叫做 Displaced Mark Word。具体来讲,是将 markword 放到锁记录的 _displaced_header 属性中。

4、虚拟机使用 CAS 操作,判断锁记录中的markword是否和对象中的markword一致,尝试将锁对象的 markword 更新为指向锁记录的指针。如果更新成功,这个线程就获得了该对象的锁。如果失败,则进行步骤5。

5、这一步说明已经有线程获取了该对象锁,此时需要判断该线程是不是当前线程。

    1)、判断锁对象的markword中的指针,如果指向当前线程的栈帧范围之内,说明获取锁的是当前线程,这是进行锁重入:这时也需要建立一个锁记录,不过不再将锁对象的 markword 复制到锁记录中,直接设置 _displaced_header 属性为null即可。

    2)、如果获取对象锁的不是当前线程,则升级锁为重量级锁。此时,释放锁的线程将释放重量级锁对应的mutex(重量级锁时有详细介绍),以及通知其余在等待队列中的线程,让其开始竞争。

解锁操作:

解锁是使用CAS操作把对象markword中的 指向锁记录的指针 替换为 线程锁记录中的markword 的过程。

这里涉及到锁重入的情况。上边提到,锁重入的时候,会直接添加一个 _displaced_header 属性为null的锁记录,对应的解锁时,如果看到 _displaced_header 属性为null,直接移除掉这个记录即可。直到 _displaced_header 属性不为null,才执行上述操作。

这里,如果解锁的CAS操作失败,则说明当前锁存在线程竞争,已经升级为重量级锁。此时,释放锁的线程将释放重量级锁对应的mutex(重量级锁时有详细介绍),以及通知其余在等待队列中的线程,让其开始竞争。

重量级锁

重量级锁是依赖对象内部的monitor锁来实现的,而monitor又依赖操作系统的MutexLock(互斥锁)来实现的,所以重量级锁也被成为互斥锁。系统检查到锁是重量级锁之后,会把等待想要获得锁的线程进行阻塞,被阻塞的线程不会消耗cup。但是阻塞或者唤醒一个线程时,都需要操作系统来帮忙,这就需要从用户态转换到内核态,而转换状态是需要消耗很多时间的,有可能比用户执行代码的时间还要长。

轻量级锁膨胀为重量级锁:

如果线程A持有当前对象O的轻量级锁,此时线程B来获取轻量级锁,发现锁已经被其他线程所持有,则使用线程B进行锁膨胀的操作。

1、获取锁对象O中markword的对象mark

2、分配一个ObjectMonitor对象,也就是上边所说的monitor。

3、将锁对象O的markword设置为0,代表锁正在膨胀为重量级锁。

4、用mark获取到锁对象O的markword,设置到上边分配的monitor中的header中。

5、用mark获取持有当前锁对象O的线程的LockRecord,也就是线程A的LockRecord,设置到上边分配的monitor中的owner中。

6、将锁对象O设置到monitor的object中。

7、将对象头设置为重量级锁的状态,并指向上边的monitor。

8、到了这里,锁膨胀完成。线程B需要进入到线程等待队列中去等待唤醒。但在这之前,线程B会多次尝试获取锁,这也是一种优化措施,尽可能防止用户态和内核态的转换。

重量级锁获取锁过程:

一个ObjectMonitor对象包括这么几个关键字段:cxq(下图中的ContentionList),EntryList ,WaitSet,owner。其中cxq ,EntryList ,WaitSet都是由ObjectWaiter的链表结构,owner指向持有锁的线程。

 

当一个线程尝试获得锁时,如果该锁已经被占用,会先进行自旋(JVM支持自适应自旋,即会根据历史情况,自动设置自旋次数,也可以指定自旋次数),如果还是没有获取锁,则会将该线程封装成一个ObjectWaiter对象插入到cxq的队列,然后调用park函数挂起当前线程。

当有线程释放锁时,会从cxq或EntryList中挑选一个线程唤醒,被选中的线程叫做Heir presumptive。就是图中的Ready ThreadHeir presumptive被唤醒后会尝试获得锁,但synchronized是非公平的,所以不一定能获得锁。

如果线程获得锁后调用Object#wait方法,则会将线程加入到WaitSet中,当被Object#notify唤醒后,会将线程从WaitSet移动到cxq或EntryList中去。需要注意的是,当调用一个锁对象的waitnotify方法时,如当前锁的状态是偏向锁或轻量级锁则会先膨胀成重量级锁

关于synchronized的剖析,这篇文章先到这里,后续还会对其他概念进行补充。理解有限,欢迎大家批评指正,技术之路是没有尽头的不是吗?

引用:

https://github.com/farmerjohngit/myblog

 

 

 

 

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值