# Java锁与对象头总结

# 锁机制有哪些?

# 乐观锁和悲观锁

乐观锁和悲观锁是针对写共享数据而言的。乐观情况是写共享数据的频率较少,也就是相对读多而写少的场景。而悲观情况就是相对写比较多的场景。

乐观锁由于其场景特点,可以选择一些比较轻量级的锁方式,主要目的在于加快读取的速度。例如非阻塞的CAS操作等,版本号机制等。我们可以假设一次读操作为:读A,读B,写C。版本号机制的思想就是在上述操作前,设置版本号version,然后在写C之前看一下这个version还是否是正确的。如果version发生变化了就意味着读期间有其他线程写了A或B,那么写C就停止。反之如果版本号没有发生过变化,那么就成功写入。也就是说“一直操作同一个版本的数据,不然就操作失败”。

悲观锁的特点在于频繁地写入,会导致乐观锁的解决方案频繁失效。(可以想象成版本不同地变化)。

# 自旋锁

自旋锁的含义是当前线程获取不到锁的情况下,自旋锁会反复确认当前锁是否可以获取,因此陷入了一种忙等待的模式。

自旋锁避免了进程上下文的调度开销,因此对于线程只会阻塞很短时间的场合是有效的。因此操作系统的实现在很多地方往往用自旋锁。Windows操作系统提供的轻型读写锁(SRW Lock)内部就用了自旋锁。显然,单核CPU不适于使用自旋锁,这里的单核CPU指的是单核单线程的CPU,因为,在同一时间只有一个线程是处在运行状态,假设运行线程A发现无法获取锁,只能等待解锁,但因为A自身不挂起,所以那个持有锁的线程B没有办法进入运行状态,只能等到操作系统分给A的时间片用完,才能有机会被调度。这种情况下使用自旋锁的代价很高。 获取、释放自旋锁,实际上是读写自旋锁的存储内存或寄存器。因此这种读写操作必须是原子的。通常用test-and-set等原子操作来实现。 来自维基百科。

# 偏向锁

偏向锁的偏向是指偏向某一个线程。实际上从经验角度来看,同一个资源大多数情况是被同一个线程所使用的,因此当一个线程频繁访问一个资源的时候(比如java对象就是一种资源),频繁地加锁和解锁是很耗费时间的。因此偏向锁只使用偏向线程的线程号(ID),当线程访问资源时,直接用自身的线程号和偏向锁中的线程号进行对比,如果相同则代表当前锁偏向自身线程,那么就默认获取了锁。(可见仅仅对比线程号,避免了加锁的复杂过程)

下文谈到Java对象头时还会具体阐述Java中偏向锁具体的实现,包括如何竞争和解锁的问题。

# 轻量级锁和重量级锁

轻量级和重量级其实就比较像悲观锁和乐观锁这样的区别,但是出发点不一样。轻量级和重量级是相对于“同一把锁”而言的。

轻量级锁在其他线程获取所的过程中,认为”我虽然没有获取锁,但是我获取锁等待的时间比较短,且和我一起竞争的其他线程少”。在Java中,轻量级锁一般使用自旋锁的方式。

而重量级锁则认为线程获取锁的过程中竞争激烈,且等待时间较久。这种方式由上文自旋锁时描述的可知,如果自旋会大量消耗CPU时间,可能会导致程序整体性能下降。因此一般重量级锁在Java中使用阻塞的方式。由于阻塞的线程需要CPU来唤醒,因此阻塞方式的加锁解锁往往比非阻塞的方式慢。

# 公平锁和非公平锁

公平和非公平是针对重新获取锁的线程而言的。公平有“先来先服务”的意味,一般可以通过一个队列来实现,每次锁释放后,由队列中的第一个线程来获取,后面的线程排队等待。这种方式可以保证每一个线程都“公平对待”,不至于被饿死。这里有几个缺点:首先显然队列后面的线程有过长的等待时间的问题,其次因为线程必须是按顺序进行的,那么意味着除了第一个线程外,其他线程必须等待,当后续线程其中存在类似于读写锁这种情况时,也可能因为前面的写锁线程阻塞而无法同时获取读锁,降低了整体的吞吐效率。还有个问题是如果采用阻塞的方式来进行等待,由于不必要的阻塞,CPU唤醒线程的开销显然也较大。

非公平锁一般不设置排队等待,每个线程只要当下锁可用,就获取锁。因此相比于公平锁效率更高,CPU也不一定需要唤醒所有的线程。缺点是有一些线程可能存在一直拿不到锁被饿死的问题。

# 可重入锁和非可重入锁

可重入锁是指同一个线程,当其外部方法获取锁后,内部方法可以直接获取同一把锁,而不是“竞争”或“阻塞”。可重入锁又叫递归锁,因此需要注意的是线程多次调用获取锁后也要多次解锁,次数相对应才能正确释放锁。

# 共享锁和排他锁

共享锁的共享是线程级别的,也就是说多个线程可以同时获取锁,称之为“共享”。如果一个线程获取锁了之后,其他线程不能获取,则称之为“排他锁”。例如Java的读写锁,其中读锁是共享锁,也就是说一个线程获取了读锁后,其他线程仍然可以获取读锁,实现“多读”的场景。而写锁就是排他锁,当一个线程获取了写锁后,无论其他线程获取读锁还是写锁,都应该陷入等待。这就实现了“同一时间只有一个线程可以写”的情况。

# Java对象头

对象其实也就是一种Java特有的计算机底层数据结构。想象一下,计算机底层拿到一个对象数据的时候(可能是一串连续的内存空间),但是你并不知道具体对象的内容是啥,也不知道具体长度是多少,这时候和任何一种计算机数据结构一样,就需要读取对象数据的对象头来解析。
以32位Hotspot JVM为例,数组对象类型的对象头是12个字节,非数组对象类型的对象头是8个字节。区别在于数组对象类型多4个字节来保存数组长度,作者认为是因为同种类型的不同数组长度不同,没法放在Class类型的数据中。4个字节的长度也很好解释了为什么Java中声明数组长度必须是int类型。

长度(32位/64位) 名字 具体内容
32/64bit Mark Word 对象的hashCode,锁信息,GC相关信息
32/64bit Class Metadata Address 到对象Class类型数据的指针
32/64bit Array length (数组特有)数组的长度

# 无锁、偏向锁、轻量级锁、重量级锁以及锁升级的Java实现

Java中锁可以升级,不可以降级。升级顺序为:无锁,偏向锁,轻量级锁,重量级锁。在每个场景满足某些条件时,锁会向更重量级的方向升级,升级后的锁不可降级。

这里的对象头中加锁部分信息,是特指JDK 6后synchronized部分进行描述的。由上文可知32位JVM中,对象头使用Mark Word 32bit来表示锁结构。

# 无锁

在无锁状态下,Mark Word里默认存储对象的HashCode,分代年龄和锁标记位。以32位JVM为例,其主要结构为:

锁状态 25bit 4bit 1bit 是否为偏向锁 2bit 锁标识位
无锁状态 对象的HashCode 对象分代年龄 0 01

# 偏向锁

当对象首次加锁时,对象头Mark Word会变为偏向锁结构。

如果每个线程对于偏向锁进行访问时都设计加锁或解锁过程,那么显然偏向锁会更慢。实际上偏向锁是假设线程获取锁后,有较少的线程进行竞争的。因此偏向锁引入了“全局安全点”和“批量重偏向”的概念。

全局安全点Safe Point的含义是某一个时刻,没有字节码在执行(这样保证不会有并发问题),此时进行批量重偏向。批量重偏向是指针对Java中的Class类,针对其每个实例对象的线程栈,进行一次偏向刷新,来判断每个实例对象的偏向锁是否需要做出调整。显然这是一个批量修改的过程,而且保证修改的安全。

锁状态 23bit 2bit 4bit 1bit 是否为偏向锁 2bit 锁标识位
偏向锁 线程ID Epoch偏向时间戳 分代年龄 1 01

其中线程ID指向了当前锁偏向的线程。Epoch偏向时间戳用来表明偏向的合法性。在每个Java的Class对象中会有一个对应的Epoch字段,每个处于偏向锁状态的对象也有一个Epoch字段。当在全局安全点中发生批量重偏向时,会做如下处理:

  1. Class对象中持有的Epoch字段加一。
  2. 扫描所有Class对应的对象实例的线程栈,根据线程栈内的锁信息判断当前线程是否持有此对象实例的锁,如果持有,则将Class对象中新的Epoch字段值赋值给当前对象中Mark Word Epoch字段。
  3. 对于那些没有被线程持有锁的对象,则不做处理。此时这些对象由于和Class对象中的Epoch不一致,则默认为锁已经失效。

因此当新的线程尝试CAS获取当前对象的偏向锁时,会比较Class对象中的Epoch字段和当前对象的Epoch字段对比,来判断当前对象的偏向锁是否还有效。无效则进行重新偏向。

可见偏向锁的锁撤销不是获取锁的线程完成的,而是重新偏向操作时做的。

# 轻量级锁

当竞争的线程数量或偏向锁中CAS获取锁的线程时间较长时,偏向锁会升级成轻量级锁。

轻量级锁的对象头比较简单。

锁状态 30bit 2bit 锁标识位
轻量级锁 指向线程栈中锁记录的指针 00

这里不得不提到,在线程栈中有一部分锁记录的空间,称之为Lock Record。这部分空间中有暂时记录对象头Mark Word的内容,称之为Displayed Mark Word。也就是说当线程获取某个对象的轻量级锁时,会用CAS将对象头的Mark Word复制到Displayed Mark Word中,并将Mark Word的30bit置为指向Displayed Mark Word的指针。

当轻量级锁解锁时,用CAS方式根据指向线程栈中锁记录指针的地址将对象头加锁前的Mark Word复制回来。

值得注意的是,CAS操作是存在失败可能的。也就是说线程加锁和解锁过程都有可能在CAS过程中因为其他线程的竞争而失败,失败会导致轻量级锁升级为重量级锁。

轻量级锁一般使用自旋的方式,适合等待时间短,竞争压力不大的对象。

# 重量级锁

重量级锁依赖操作系统中互斥量mutex实现。该操作会导致用户态和内核态切换。

重量级锁在JVM中又叫“对象监视器 Monitor”。其实也就是说重量级锁的加锁和解锁过程是由重量级锁本身完成的。线程获取对象的重量级锁时,会直接根据对象头的指针到重量级锁中去“阻塞排队”,被加锁的对象头中只需要保存指向重量级锁的指针即可。

锁状态 30bit 2bit 锁标识位
重量级锁 指向重量级锁的指针 10

重量级锁一般采用线程阻塞的方式,需要每次加锁解锁唤醒阻塞的竞争线程,但阻塞线程本身不消耗CPU时间片。因此适合竞争较大,等待时间长的场景。

# 参考

  • 《Java并发编程的艺术》
  • https://tech.meituan.com/2018/11/15/java-lock.html
  • https://www.itqiankun.com/article/bias-lock-epoch-effect

不足之处欢迎指出,其他细节在日后学习中继续补充。