Hotspot 偏向锁的评估与改进(一)

688 阅读11分钟

写在前面的话

在理解锁升级过程中,老是有些地方一知半解,比如monitor和lock record的数据结构到底是什么样的,在升级过程中,它们里面的数据是如何变化的. 在网上上找了半天也没发现很清楚的答案,而且很多都是不经考证,无脑转载.最后还是借助谷歌发现了瑞典皇家理工学院的一篇paper.虽然这篇papar的标题是讨论自旋锁,但是涉及到了整个升级过程,而且讲解的很详细,这或许可以帮我解开疑惑.所以想翻译出来便于理解和总结,如果恰好能帮助到你那更是善莫大焉.这是原文连接: Evaluating and improving biased locking in the HotSpot virtual machine,英语好的同学可以直接查看原文.

关于锁的底层概念确实很复杂抽象,我尽量使用简洁的方式来翻译,但由于水平有限再加上时间仓促,可能有翻译不准确甚至错误的地方,希望大家理解并指正.以下是译文

第一章 简介

Java编程语言为多线程开发提供了内置支持.更确切地说,使用synchronized关键字可以为任意的方法和代码块加锁,并潜在地将对象转换为监视器(monitor). 这些实现保证了多线程应用程序和库的安全性和正确性.当然,这也需要JVM的一些开销,因为所有的java对象都需要某些特定的加锁机制.在JDK的开发过程中,已做出大量工作以发现该问题的有效解决方案.锁(java 锁)需要占用很少的内存,在低竞争下表现出低延迟,并在高竞争下允许高吞吐量.

本文将评估和分析Hotspot虚拟机中的偏向锁,并研究改进该实现.

1.1 背景

总的来说,锁有两种类型:自旋锁和挂起锁(suspend-locks).自旋锁线程会一直循环直到成功获取该锁.自旋锁可以很简单,最少可只需一个字长的内存,并在低竞争下表现良好.缺点是,当竞争引起整体性能变差时,自旋锁将更多的时间用在循环以获取锁上,从而只有更少的时间来真正工作.

挂起锁有一点复杂,因为它引入了线程调度,这通常需要调用操作系统函数.不同于自旋锁,处于等待的线程会让出时间片给其他线程从而充分利用CPU.线程调度和操作系统的引入使得挂起锁变得复杂,再加上必要的上下文切换导致在低竞争环境下挂起锁要比自旋锁更慢. 然而在高竞争环境下,由于花费更少的时间在等待上,挂起锁表现更好. 即便如此,如果执行较短耗时操作,推荐使用自旋锁.因为上下文的切换成本要大于等待CPU周期的成本,这也取决于操作系统.

优化挂起锁的方式之一是与自旋锁结合,在采用慢路径[1]之前先自旋有限的次数.这种结合使得在低竞争环境下低延迟,当竞争加剧时又能保持高效.这种方式多用于java所必须的监视器中锁的实现.

译者注 [1] 慢路径(slow path): 所谓慢路径就是包含整个锁升级过程的路径.而与之相对的快路径就是在低竞争环境下无需CAS竞争,直接偏向第一次获取锁的线程.

1.1.1 Java锁

由于在java中任何对象都可作为监视器,所以需要某种类型的锁数据结构与之关联.在synchronized语句中,任何对象可作为锁,从而保证互斥访问对应的临界区(同步块或方法).监视器接口要求所有对象支持wait(),notify()以及notifyAll()方法,使得等待监视器对象的线程可被通知. 理想情况下,这也应该可以在上述混合的自旋-挂起锁下工作,从而获得低延迟和高效率.

无论实现监视器功能的锁类型怎样,每个对象都需要支持监视器的数据结构.从内存使用角度来说,盲目地为每个对象分配一个监视器并不是一个好办法.可行性之一是维护一个对象到监视器的全局辅助映射,并且只有在必要时才分配和建立这种映射.换句话说,只有当同步块或方法执行时才分配监视器,或者当调用wait()notify()时.如果没有对象用于同步操作,全局映射的内存开销将会很小.然而,该映射也需要用锁或类似机制来同步访问,这会影响性能,并且当大量对象需要同步访问时,内存消耗也很大.

一个可能更好更简洁的方式是在每个对象头中维护一个lock word[2]. 为避免这一个字长(word)的额外开销,可将它与对象元数据(如果存在)整合,比如类或垃圾回收器信息.结果就是要嘛独占一个lock word,要嘛是共用对象头中的一个word,其中一些位(bit)用于保留锁信息.JRockit和Hotspot都是使用后者.监视器及关联的对象信息就存储在这个字(word)中. 因此,当需要时我们才分配监视器数据结构,并且指向对象监视器的指针就保存在这个共用word的锁字段中(lock field)

译者注 [2] lock word: 它的意思是占用一个字长的锁信息,由于这样的表述在后面频繁用到,为了方便就用 lock word来代替.如果直接翻译成锁字可能会让大家更迷惑,所里这里不做翻译

1.1.2 轻量级锁和重量级锁

通过观察发现大多数java对象并不存在竞争,因此我们引入了轻量级锁.当对象被锁时它并不是立刻分配monitor数据结构,而是使用了轻量级锁机制.轻量级锁引入诸如自旋锁中的锁字段(lock field),并使用原子指令比如CAS来获取锁. 因此无竞争下的锁对象仅需一个锁字段而非重量级的监视器数据结构(重量级锁).然而轻量级锁不支持依赖于监视器的wait(),notify()操作.此外,当锁对象被竞争时,它应该可以退回到挂起锁机制再次获取重量级锁.在这种情况下轻量级锁开始膨胀,与此同时,监视器数据结构被分配并且锁字段被更新成指向监视器的指针.为了区分轻量级锁和重量级锁,我们使用锁字段中的一位(bit)来决定,所以当前锁是双模[3]的

译者注 [3] 双模: 因为只使用一个bit来表示,它只能表示两种状态,即要嘛轻量级要嘛重量级,我们称之为双模

此外,java锁需要可重入,即同一个线程可以递归地获取同一把锁.这对锁提出了额外要求,它需要包含锁的所有者和递归次数信息.在递归情况下,锁拥有者信息对于识别加锁线程是否已经持有该锁非常必要.而在释放锁时,递归次数则用于判断加解锁的次数是否匹配,此信息很容易存储到支持性的数据结构中. 对于轻量级锁,它被编码到锁字段中. 也许最直接的方式是把锁字段分成两块,一块包含了作为锁拥有者的线程标识,另一块则包含了递归次数.由于轻量级锁的天然限制,递归的次数是有限的,一旦超过该限制,轻量级锁退回成重量级锁.

另一种方式是对于递归的轻量级锁,当线程竞争同一把锁时,使用线程栈中的锁记录(log record).当某一个线程尝试获取锁时,在它的栈中分配一个存储锁信息的锁记录.这些信息包含了锁是否可被递归获取以及用锁记录的个数所表示的递归次数[4]

译者注 [4]: 同一个线程递归获取同一把锁时,每获取一次会在它的栈中分配一个锁记录.

当线程第一次获取到该锁时,它会更新锁字段并将锁对象保存到锁记录中.当递归尝试加锁解锁时,相应的锁记录表明当前锁是递归的,对象会被一直锁定直到最初的锁被释放.该线程将获取的所有锁信息保存在它的锁记录集合中.集合的顺序与锁的获取顺序一致,从而保证正确的结构化获取和释放顺序.

上面加粗的那块不是很理解,因为如果线程递归的获取同一个把锁,那么只要保证锁记录集合为空不就表明已经释放锁了吗,为什么还要在意顺序?

轻量级锁的变体和改进包括tasuki-lockmeta-lock.这些算法的基本思路都保持一致,只在一些特定实现上有所不同.tasuki-lock改进了膨胀设计(inflation schema),并允许锁轻松收缩.meta-lock虽然也基于轻量级锁但它使用锁记录来代替单独的监视器结构实现重量级锁.

1.1.3

通过对大量锁表现出的称作线程本地性(thread locality)的观察,我们可以对锁进行进一步优化.也就是说锁的加锁序列中包含了某一线程大量重复的加锁和解锁,这个线程也称作锁的独占线程.

因为java是一个支持多线程开发的编程语言,许多库都支持并发应用的开发.这意味着这些库都包含必要的同步来保证任意场景下的安全性和正确性.然而当只有一个线程时这些同步只会增加开销.甚至在轻量级锁内部,每次获取锁时,这种同步也会增加诸如CAS这样耗时(costly)的同步指令的必然性.偏向锁试图开发在此情形下锁的线程本地性,即允许偏向第一次获取锁的线程.当已偏向时,锁会为偏向线程提供一种及其快速的路径,这仅需少量的非原子指令来获取或释放锁. 如果锁被其他线程获取,偏向会被撤销,然后退回到底层的加锁机制(例如轻量级锁).与偏向锁类似的设计还包括锁撤销和懒解锁技术.这些技术的思路都一样,为独占线程启动快速路径,主要区则别都在一些实现细节上.

为了区分偏向和非偏向,我们额外需要锁字段中一个二进制位. 最初锁都是可偏向状态,但没有为它们分配任何线程.当某一线程第一次访问时发现[5]没有其他线程分配到该锁,然后愉快的将锁偏向它自身.初始的偏向操作会使用CAS(或类似机制)来避免多线程并发初始化时的竟态条件.锁一旦偏向,那么获取或释放锁就是一个简单的读取锁字段来确认是否仍然偏向该线程的问题.重新获取锁的过程会引入递归次数,而这通过简单的存储指令就可完成.

译者注 [5]: 锁最初是无锁状态,可通过mark word的后三位看出

对于偏向锁也可以像以前一样忽略显式的的递归次数,并且不占用锁字段空间来表明该锁是否已被占用.在这种情况下,一旦锁偏向,那么它会一直看作被偏向线程锁定.这使得对偏向锁的获取和释放操作是不占用空间的,而且递归次数隐式存储在锁记录中.