细品Java并发--synchronized底层原理详解

细品Java并发--synchronized底层原理详解

困而学,学而知

synchronized是JVM提供的锁机制,而JDK只是提供了Lock相关接口。接下来先来说说synchronized关键字,它到底是一个什么东西。

同步关键字synchronized

  1. 用于实例方法、静态方法时,隐式指定锁对象
  2. 用于代码块时,显示指定锁对象
  3. 锁的所用于:对象锁、类锁、分布式锁
  4. 非公平锁、悲观锁、可重入锁、独享锁

好了,说了定义,现在来说说看了定义之后的疑问。

关于synchonized的疑问

  1. synchronized(this)加锁的状态如何记录?
  2. 上面有说到synchronized是非公平锁,那又是什么原因导致了synchronized锁的非公平?
  3. 若锁被占用,线程挂起,释放锁时,唤醒挂起的线程,是如何做到的?

我们带着疑问来看synchronized关键字,要了解synchronized,就得来说说Mark Word。

Mark Word

详细说说Java中锁文章中,讲到无锁、偏向锁、轻量级锁和重量级锁这一节的时候,有提到过Mark Word,当时也是浅尝辄止,并没有深入描述,由于本文需要,这里就做一个详细的介绍。

在HotSpot虚拟机中,对象在内存区域的存储的布局分为三个区域:对象头(Header)实例数据(Instance Data)对齐填充(Padding)。 对象头也就是我们今天的重点内容。

虽然是废话,但是这里还是提一下,Mark Word 也是一块内存区域

HotSpot虚拟机的对象头分为两部分信息,第一部分是用于对象自身的运行时数据。如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向锁ID、偏向时间戳等,官方称这部分信息为Mark Word。Mark Word在不同位数的虚拟机中占有的字节数不同(未开启压缩指针),在32位虚拟机中占32bit,在64位虚拟机中占64bit

对象头信息和对象自身定义的数据是区分开的。为了能够在极小的空间内存储尽量多的信息,Mark Word被设计成一个非固定数据结构,也会根据对象复用自己的存储空间。例如,在32位的HotSpot虚拟机中,如果对象处于未被锁定的状态下,那么Mark Word的32bit空间中的24bit用于存储对象哈希码,4bit用于存储对象分代年龄,2bit用于存储锁标志位,1bit固定为0。那么Mark Word在不同状态下的内部存储的内容就如同下面的表格。

锁状态锁标志位存储内容
无锁态01hashCode、分代年龄,是否是偏向锁(0)
偏向锁01偏向线程ID、偏向时间戳、对象分代年龄、是否是偏向锁(1)
轻量级锁00指向栈中锁记录的指针
重量级锁10指向互斥量(重量级锁)的指针
GC标记11空,不需要记录信息

从上表中,我们可以看到Mark Word在不同锁状态下,标志位和存储内容都是不同的。不同的锁标志位标识这对象具有的锁等级,锁标志的转换涉及到加锁、解锁、锁升级等操作。还有自己之前经常搞混淆的一点就是,在同一时刻,对象头中只会有一个锁标志位(聪明大家肯定是不会搞错的啦),其实不这样做,也不能进行CAS操作。

其实有一个知识点,只要涉及到锁状态的变换,几乎都是用的CAS算法。

对象在分配完内存空间后,需要对对象进行必要的设置。JVM就是去对象头中找类的元信息、哈希码、对象的GC分代年龄等信息。

对象头的另一部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。并不是所有的虚拟机实现都必须在对象数据上保留类型指针,换句话说,查找对象的元数据信息不一定要经过对象本身。另外,如果对象是一个Java数组,那在对象头中还必须有一块用于记录数组长度的数据,因为虚拟机可以通过普通Java对象的元数据信息确定Java对象的大小,但是从数据的元数据中无法确定数组的大小。

我们可以来看看对象头在内存区域中的存储形式,通过下图我们可以看到类型指针指向方法区中对象的元数据,而Mark Word存储了锁的状态。

对象头在堆中的存储结果

简单总结一下,Java对象在内存中的存储分为三区域:对象头、实例数据和对其填充。上面只是说了对象头,对象头也分为几部分。第一部分存储了对象的哈希码、GC分代年龄、偏向锁ID、是否是偏向锁、锁标志等信息,这不是被官方称为Mark Word。Mark Word在不同锁标志位下,存储的bit是不同的,这样做的目的也是为了更大的利用对象头的内存空间。第二部分是类型指针,指向了方法区中对象元数据。还有一部分是数组长度,这部分只有是对象是Java数组的时候才会有值。

synchronized锁的原理

现在有一个对象在堆中,我们给对象加了一个synchronized锁。 最开始对象处于无锁阶段,现在有一个线程A来获取这个锁,为了后面的说明,后续文中说的虚拟机栈也可以理解成线程。 由于下文中也有一个概念:Lock Record,所以再说Mark Word原理之前,还要先来说说Lock Record。(概念好多啊,学Java好难啊!!!!)

Lock Record

Lock Record是线程私有的数据结构,每一个线程都有一个可用Lock Record列表,同时还有一个全局的可用列表。每一个被锁住的对象Mark Word都会和一个Lock Record关联(对象头的MarkWord中的Lock Record address指向Lock Record的起始地址),同时Lock Record中有一个Owner字段存放拥有该锁的线程的唯一标识(或者object mark word),表示该锁被这个线程占用。

synchronized底层原理

  • 现在线程A来抢锁,会从对象的对象头中复制一份Mark Word信息,存在自己相应栈帧中。栈帧中保存Mark Word信息的内存区域就叫做Lock Record。Lock Record中会保存对象的HashCode分代年龄偏向锁标志
  • 线程A为了获取对象的锁,那么线程A会用CAS算法机制更新对象头中Mark Word的值。我们知道CAS机制有三个中重要的值,期望旧值内存中的值新值。在这个例子中就分别对应了:Lock Record中的值对象头中的Mark Word的值需要加的锁的标志(这里就是: 线程ID、Epoch、分代年龄、偏向锁标志)。
  • 如果当前只有线程A去访问该对象,那么对象当前的锁就为偏向锁,同时偏向锁保存的线程ID会线程A的ID。那么这个时候线程A如果再次去访问这个对象,会继续获取对象的偏向锁,不会频繁的加锁和解锁。在详细说说Java中锁文中,我们说了偏向锁的目的当一段同步代码并没有被多个线程同时竞争的时候,降低加锁和解锁带来的性能消耗,提高程序的执行性能。
  • 但是这时候有新的线程B来竞争锁,那么偏向锁就会升级为轻量级锁。这时Mark Word内存存储的值为:指向栈中锁记录的指针也就是Lock record address
  • 线程A和线程B会从对象头中复制Mark Word到自己的Lock Record区域。这时候,两个线程都会使用CAS去更新Mark Word的值,这里更新什么呢?
  • 如果线程B争抢到了锁,那么Mark Word中的Lock record address 就是变为线程B中的Lock Record的地址。同时,Lock Record中的owner字段会存储这个Mark Word的地址。回答上面的问题,这里更新就是Lock Record address的值。
  • 此时就剩下线程A在了,线程A会不断自旋使用CAS去获取锁,但CAS会失败。线程A还会不断的自旋(并不会阻塞),不断的获取锁。这样子就会操作内存使用率过高,这样子肯定是有问题的,那怎么解决的呢?
  • 这里自旋是有一定次数的,如果达到一定次数之后,就会进行锁升级,升级成重量级锁。
  • 还有一种情况下,对象锁也会直接升级成重量级锁。就是当这时有一个新的线程来争抢这个锁的时候,对象锁就直接会升级到重量级锁。
  • 如果线程B释放锁,那么对象的Mark Word就会变为无锁状态,并且会唤醒其他线程,让其他线程来抢锁。

上面也是简单的锁升级过程

同时只有锁升级,没有锁降级

简单总结一下,synchronized实现锁的原理是改变Mark Word中的状态,使用CAS算法修改Mark Word的值,控制对象的加锁和解锁。同时每个线程的虚拟机栈栈帧中都会保存一个Lock Record区域,这个区域有一个owner字段存放锁的线程的唯一标识,且锁对象Mark Word中Lock Record address也会指向Lock Record的起始地址。当只有一个线程获取对象锁的时候,对象的锁为偏向锁,但当有两个线程来争抢对象锁的时候,偏向锁就会升级成轻量级锁。并有一个线程争抢成功,另一个线程会不断自旋获取锁。如果另一个线程自旋到一定次数或又有新的线程来争抢锁,那么对象锁就会升级为重量级锁。在线程释放锁的同时也会唤醒其他线程来争抢锁

思考

如果有很多线程来争抢这个锁,并且都是通过CAS去修改Mark Word。那这个时候会不会太耗资源啊?

肯定会的,所以这个时候,设计JVM的大叔就有了很好的idea,他们用一个队列来保存那些没有争抢到锁的线程,并且将这些线程阻塞。这个时候,没有争抢到锁的线程,就会在队列中带着,并且状态为阻塞状态。那么只有锁被释放了,才能出来争抢锁。这也就是我们下面要说对象监视器

对象监视器(Object monitor)

如果这时候有一个新的线程来争抢锁,那么对象锁则直接升级到重量级锁,这个时候就会用到对象监视器,为对象提供锁的机制。并且每个对象都有一个对象监视器, 我们先来看看对象监视器的结构。

我们先来看看对象监视器的结构。

对象监视器

owner

我们从最容易理解的开始,owner就是表示是谁获取了这个锁。那个线程获取了这个锁,这个owner的值就是获取锁的线程的ID。

锁池和等待池

当第三个线程T3来争抢锁的时候,如果争取不到锁,则T3就会进入到锁池中,这时候T3线程的状态就会变成BLOCKED 。也就是说这时候T3线程的状态就是阻塞的状态,这样子做的最根本目的就是为了解决T3线程一直自旋,造成CPU资源占有率过高,以达到节约资源。

锁池就是一个队列。这里可能有人问了,既然是队列,那就应该先进先出啊,那为什么synchronized还是一个非公平锁呢?这个问题我们后面的回答开篇章节来回答。

说完了锁池,我们来说说等待池。

我们知道wait/notify这两个方法需要配合synchronized关键字使用,wait方法除了挂起线程,也会释放锁。假设在T2线程之前,是T1线程获取了锁。当我们调用T1.wait(),那么T1线程会释放锁。这时候T2争抢到锁,owner变成T2。T1线程挂起(WAITING),并进入等待池。最终结果也就是上图表示的结果。

如果这个时候调用了T1.notify,这样子T1就会去抢锁,如果抢不到就会进入锁池。

其实我的理解锁池和等待池的根本区别,就是保存这两个内存区域的线程的状态,一个是BLOCKED状态,一个是WAITING状态。

锁池和等待池都是队列

T2线程释放锁

如果这时候T2线程释放了锁,那么T2线程就会monitorexit,就是退出对象监视器。

这时候,等待队列头部会出队列,并且争抢锁,如果争抢锁成功,就会出队列。

简单总结一下,每个对象都有一个对象时间器,是用来为对象提供锁机制。对象监视器中有三个属性:owner、等待队列、等待池。owner是当前获取锁的线程ID;等待队列是存储了争抢锁失败的线程,线程的状态时BLOCKED;等待池存储了调用了挂起的线程,线程的状态时WAITING。存在等待池的线程可以使用notify和unpark唤醒。线程进入对象监视器是monitorenter,释放锁是monitorexit。

回答开篇

synchronized(this)是通过改变对象头的Mark Word的状态来记录。

我们所说的公平和非公平锁主要的点就是看有没有线程插队获得锁。我们首先知道在等待队列中的线程是不会发生插队情况的。但是如果没有在等待队列中线程,就有可能出现插队。如果当前锁线程释放锁的时候,有一个新线程来争抢锁,等待队列头部的线程也出来争抢锁。但是新的线程刚好抢到了锁,那么插队的情况就存在了,也就非公平了。所以说synchronized就是一个非公平锁。

我们可以通过notify/notifyAll/unpark方法区唤醒挂起的线程。

全文总结

文主要从理论上讲解了synchronized同步关键字的原理,synchronized可以修饰方法和代码块,最根本都是锁住对象。我们通过修改对象头中的Mark Word来实现锁升级。后面还来讲解了对象监视器,什么是对象监视器,对象监视器中的三个属性:等待队列、owner、等待池,以及线程在这三个属性之间的转换。

参考文献

啃碎并发(七):深入分析Synchronized原理

Copyright: 采用 知识共享署名4.0 国际许可协议进行许可

Links: https://baozi.fun/archives/synchronized-detail

Buy me a cup of coffee ☕.