Java并发编程 - 锁机制
本篇文章主要基于国外的两篇论文进行翻译整理的,论文链接在文章底部。
介绍
Java编程语言对并发编程提供了内置支持。更明确地说,任意方法或者是语句块都可以是同步的,使用synchronzied关键字修饰,潜在地将任意对象转变成为一个监视器。这种监视器结构用来保证应用和包在多线程环境下的安全性和正确性。当然了,因为所有的对象都需要这样的一个加锁的机制,所以给JVM是会带来一定的开销。JVM开发中许多工作都是专注于找到高效的解决这个问题的方案。锁(Java锁)需要很少的内存占用,and impose low latencies with low contention but still allow for maximum throughput when heavily contended.
1.1 背景
一般有两种类型的锁,spin-locks(自旋锁)和suspend-locks(挂起锁)。通过spin-lock锁定的线程会一直循环直到成功获取到锁。自旋锁可以说是简单的,最少的只需要一个字的内存,在低竞争情况下可以工作地很好。这种锁的不好之处是,当竞争上升时,整体性能会受到影响。它会在自旋获取锁上花费大量的时间(还是会占用CPU),而只花很少的时间做真实的工作。
Suspend-locks稍微有点复杂,因为它涉及到线程的调度,线程调度通常都是操作系统功能。这种做法是,希望更好地利用CPU,等待锁的线程会让出它的时间片给另一个线程。线程调度和操作系统的参与使得suspend-lock复杂化,并且因为涉及到线程上下文的切换,当只有少量的竞争的情况下,suspend-lock表现得比spin-lock慢。而在竞争比较激烈的情况下,因为在等待锁上花费的CPU时间比较少,suspend-lock会工作得更好。不过,这种情况下,如果线程占用锁的时间是非常短的,那么更好的方法还是使用spin-lock,因为比起等待锁引起的CPU周期浪费来说,线程上下文会带来更大的开销,当然了,具体还要依赖于操作系统。
一种优化的方式就是将两者结合起来用,在将线程挂起之前,先给自旋一点时间。这种杂化的方式允许低延迟和低争用,争用上升时的也会是高效的。通常这种方式被Java用来为监视器实现锁。
1.1.1 Java锁
由于Java中任何对象都被用来作为监视器,那么所有的对象都必须有一些类别的锁数据与它关联。在synchronzied语句中每个对象都可以被作为锁,来保证临界区(同步块或同步方法)的互斥访问。监视器接口也需要所有的对象支持wait(),notify()和notifyAll(),这样允许线程在监视器对象上等待直到被通知。理论上来说,为了低延时和高效性,应该用上面所说的spin-suspend锁方式来实现。
不管用哪种类型的锁来实现监视器功能,都需要为每个对象提供某种支持监视器的数据结构。从内存使用的角度上看,这种迫切地为每一个对象分配监视器的方式并不好。一种可能的解决方法是为对象和它们的监视器提供一个全局的映射结构,只有当需要的时候才做映射。换句话说,每当执行同步块或方法时,或者是在调用wait()或notify()方法时为对象分配监视器。如果没有对象被同步,那么全局映射结构就不需要大的内存开销。然而,这个映射本身将需要与锁或类似的同步,从而阻碍性能,当许多对象需要同步时,映射可能需要相当大的内存开销。
一种可能更好、更简单的方法是在对象头中保留一个lock word。为了避免为每个对象添加额外的开销,可以(如果存在)将其与对象的元数据(如类和垃圾收集器(GC)信息集成。在对象头中保存一个明确的lock word,或者是一个multiple-purpose word——这个word有些比特位用来存储加锁机制。JRockit和HotSpot都采用的是后面的这种方案。有关与对象关联的监视器的信息可以简单地存储在这个word中。使用这种技术,只在需要时分配监视器数据结构,从对象到其监视器的映射,只是一个保存在multi-purpose word的lock域中的简单指针。
1.1.2 轻量级锁和重量级锁
因为通过观察可以发现在Java中大多数的对象实际上从来都没有被竞争过,这样,就提出了轻量级锁的概念。使用轻量级的加锁机制而不是在对象被锁的时候马上分配监视器数据结构。这种方式使用一个lock域,像自旋锁一样通过compare-and-swap(CAS)这样的原子型指令来获取锁。因此,没有竞争的对象只需要锁字段,而不需要重量级监视器数据结构(重量级锁)。然而,轻量级锁不支持wait()或notify()的操作,因为这些操作需要真实的监视器。此外,每当锁被争用时,它应该回到挂起锁机制,再次需要一个重量级锁。在这些情况下,轻量级锁被膨胀化了,在此期间它被分配监视器结构,并更新锁字段以包含指向此监视器的指针。为了区分轻量级锁和重量级锁,使用锁字段中的位来确定其模式,and the lock is referred to as bimodal.
此外,Java锁需要重入,这意味着线程需要能够递归地获取相同的锁。这在锁上增加了一些额外的约束,因为它需要包含关于锁所有权和递归计数的信息。对于递归情况下的锁定线程来说,所有权信息是必要的,以便能够识别它已经持有锁。递归数量对锁来说是必要的,只有当解锁的调用数与前面的加锁调用数相匹配时,才会释放锁。在使用对象监视器的情况下,此信息很容易存储在支持的数据结构中。对于轻量级锁,此信息必须以某种方式被编码在锁字段中。可能最简单的解决方案是将锁字段拆分为两个子字段,一个子字段包含所有者的线程标识符,另一个子字段包含递归计数。由于字段的大小是有限的,因此对于轻量级锁支持的递归计数自然是有限的。在这种溢出的情况下,只需将锁膨胀化,然后返回到重量级锁机制。
递归轻量级锁的另一种解决方案是在获得锁的线程堆栈上使用锁记录。无论什么时候线程试图获取锁时,都会在该线程的堆栈上分配一个锁记录,其中存储有关该锁的信息。此信息可以包括锁获取是否是递归的,并且通过栈上的lock记录数就隐式得保存了递归次数。当线程第一次获得锁时,它将更新锁字段,并将有关锁定对象的信息保存在锁记录中。During recursive lock and unlock attempts, the corresponding lock records will indicate that the locking was recursive, and the object will remian locked until the initial lock is released. 然后,指定线程获得的所有锁的信息将保存在该线程的一组锁记录中。此集合需要按照获得的锁的相同顺序排序,以确保正确的结构化获取和释放序列。
1.1.3 偏向锁定
通过观察发现许多锁具有所谓的线程局部特性,基于这个对象Java锁进行进一步的优化。也就是说锁被一个特定的线程重复地请求和释放,这个线程称之为锁的主导线程。
由于Java是一种支持多线程程序的编程语言,许多库都是为了支持并发应用程序中的部署而编写的。这意味着在所有用例中,这些库将包含必要的同步,以保证安全性和正确性。但是,当仅由单个线程使用时,这些同步只会增加开销。即使使用轻量级锁,每次获取锁时,同样需要调用原子指令比如说CAS,而原子性的指令是昂贵的。偏向锁定尝试使用锁的线程局部原理,允许锁偏向于第一个请求它的线程。
当有偏向时,锁为偏向线程提供了一个超快的路径,只需要几个非原子指令就可以获得或释放锁。如果锁是由与偏向所有者不同的线程获得的,则需要撤销偏向,并且锁会后退以使用基础锁定机制(例如,轻量级锁定)。类似于偏向锁定的方案包括所谓的锁定保留和懒惰的解锁技术。这些技术的思想是相同的,为占主导地位的线程提供了一条超快的路径,它们主要在实现细节上有所不同。
为了区分偏向锁和非偏向锁,在锁字段中使用了一个附加位。最初,锁处于偏置状态,但没有分配给它线程。当线程第一次获得锁时,它会观察到,锁未分配给其他线程,并且锁将偏向于自己。这种初始偏向操作需要使用CAS(或者类似的手段)来避免不同的线程并发得争夺初始偏向。一段锁时偏向的,那么获取和释放就是简单地读取lock field来确认锁是否仍然偏向自己。重新获取过程可能涉及将递归计数存储在锁字段中,这可以通过简单的存储指令来完成。还可以像之前那样,忽略显式的偏向锁递归计数,并且在锁字段中不保留是否使用锁的指示。在这种情况下,一旦锁是有偏向的,它就可以认为始终是由偏向线程锁定的。这使得在偏向锁上的获取和释放操作完全是免费的,而递归计数则由锁记录隐式保留。
撤销
当锁被与它偏向的线程不同的线程请求时,偏向需要被撤销。由于偏向线程可能在任何时候获得或释放锁,并且由于它在这样做时没有使用原子指令,因此必须挂起偏向线程,以便安全地对锁进行修改。一旦偏向线程被停止,锁就可以安全地被撤销,可能包括遍历拥有线程的堆栈来查找和修改其所有的锁记录。或者,如果有偏线程不再存在,则可以直接撤销或重定向锁。如果撤销器发现锁被持有,则通过修改锁字段和相应的锁记录,将偏向锁转换为轻量级锁,使之看起来好像从一开始就使用了轻量级机制。如果发现该锁不是由偏向所有者持有的,则撤销者可以选择简单地将锁重定向到自己。这有效地将偏向传送到新的线程,消除了锁的先前的偏向。这种重定向有利于生产者-使用者模式,允许锁从一个线程转移到另一个线程,并继续利用线程局部性。
撤销是一项昂贵的操作,因为它需要线程挂起和堆栈遍历才能找到和修改锁记录。与轻量锁定机制,甚至重量级锁定机制相比,撤销过程要慢得多。挂起偏向线程可以用OS信号来完成,但是这增加了JVM对底层操作系统上的需求。此外,许多I/O操作在被信号中断时不能正常工作,因此通常避免发送信号。Because JVMs are garbage collected environments, they typically provide a way to stop the world (STW).This means that all the Java threads are suspended at well known states, safepoints, for example to allow the GC to run safely. 这种将线程挂起到安全点的机制也是一种很好的方法,可以将线程挂起用于撤销。当精心设计时,安全点可以保证在STW-time内没有线程在获取或释放锁,否则会使撤销过程复杂化。SafePoint撤销的一个缺点是许多JVM只支持全局安全点,这意味着当请求SafePoint时所有线程都将被挂起。由于撤销只需要暂停偏向线程,不能很好地扩展的话会进一步增加了撤销的成本。
现有的HotSpot实现
2.1 对象和锁
在HotSpot JVM中,每个对象有一个头部,头部包含两个words。一个word用于识别对象的类型,另外一个word,叫做mark word,用于hashcode计算,同步和垃圾收集。标记字包含不同的信息,这取决于它的两个最小为。This word is used for biased, thin and fat locks alike.
图2.1和图2.2分别描述了64位和32位架构的mark word的不同布局。由于hashcode计算中的实现细节,hashcode字段最多需要31位,因此64位标记字留下了一些未使用的位。Age和CMS位用于GC,跟锁的实现没有关联。
2.1 HotSpot 64位Mark Word布局.png 2.2 HotSpot 32位Mark Word布局.png2.2 轻量级锁
Thin locks are implemented in HotSpot using displaced headers, so called stack locks. 当线程获得一个轻量级锁时,它会将标记字复制到线程的锁记录中,然后通过CAS将头部设置为轻量级锁定状态,在对象的mark word中留下一个指向锁记录的指针。mark work中的两个最小有效位将被设置为00,以指示它是轻量级锁定的,并且mark work的其余部分包含指向原始对象mark word的指针。图2.3显示了获取轻量级锁之后对象和锁记录的状态。在递归获取期间,通过检查执行栈中锁记录的指针,它会知晓已经拥有过锁。The lock record is in this case set to NULL (0), indicating that it is recursive. 解锁尝试将检查锁记录,如果为NULL,则释放锁操作只会返回,因为锁仍然是递归持有的。锁记录在堆栈上分配,或者在解释执行期间显式分配,或者在编译时隐式分配。锁记录包含可以放置displayed mark word的位置,以及指向被锁定对象的指针。对象指针在分配时由解释器或编译器初始化,and the displaced mark word is initialized whenever the lock is thin or fat locked.
2.3 对象轻量级锁示例.png2.3 膨胀
当线程试图获取其他线程已经持有的轻量级锁(CAS失败)时,非所有者线程将尝试将锁膨胀。在膨胀过程中,为对象分配和初始化重量级对象监控器结构。膨胀化线程将尝试在一个循环中膨胀锁,直到它成功,或者直到其他一些线程成功地膨胀了锁。即使对象没有被多锁定,如果wait和notify被调用,膨胀也会发生。
为了将一个轻量级锁膨胀化,膨胀化线程首先会通过CAS操作将mark word置为INFLATING(0) , 然后会读取displaced mark work并且将它拷贝到对象监视器结构中。线程碰到INFLATING mark word会等待膨胀化线程完成膨胀操作。这包括当前持有锁的线程,使其在膨胀完成之前无法释放锁。使用这种临时膨胀状态是必要的,因为否则膨胀线程可能会读取到mark word的过时版本。通过将锁置为膨胀状态,膨胀化线程保证displaced mark work不被修改,这样就能安全得对它进行读取和拷贝。
根据膨胀循环每次迭代中锁的状态,将执行以下操作:
- 锁已经被膨胀化了(标记是10)
其他线程成功地膨胀了锁。退出循环。 - 锁正在被膨胀化(mark word是INFLATING)
其他线程当前正在膨胀化锁。等待直到被膨胀。 - 锁是轻量级的(标记是00)
分配ObjectMonitor,通过CAS操作将mark work置于INFLATING。如果CAS操作失败,回收monitor并且重新进入循环。如果CAS操作成功,通过设置适当的字段建立monitor,将display mark word拷贝到monitor,最后将monitor的指针设置到mark word中(替换INFLATING)。 - 锁是为锁定的(标记是01)
分配ObjectMonitor,建立它,并且尝试通过CAS将它的引用设置到mark word中。如果失败 ,回收monitor并且重入循环,反之,就退出循环。
一旦锁被膨胀化,任何请求将会仅仅使用底层的monitor机制来获取锁。图2.4是重量级锁的一个示例。HotSpot会在SWT-time deflate空闲(无用)的monitors, 这样允许no longer contended lock会再次退回到轻量级或偏向锁定。这自然是安全的,因为没有线程可以在在STW-time获得或释放锁。
2.4 重量级锁示例.png2.4 偏向锁定
HotSpot在前面几节描述的轻量级和重量级锁定机制的基础上,HotSpot supports store free biased locking with bulk rebias and revocation. 这个特性可以用JVM参数切换,默认情况下是启用的。在mark work的未锁定状态中使用一个位来指示对象是否使用偏锁或不允许有偏锁。3.1和3.2的图中可以看到这个标记位。如果位数为0,则该对象将被真正解锁,并且不允许对该对象进行偏置。如果为1时,则锁可以处于下列状态之一:
匿名偏向
线程指针是NULL(0)表示还没有线程被锁偏向。第一个获取锁的线程会注意到这个它,并且会通过原子的CAS指令将锁偏向自己。这个状态是允许偏向锁定的对象的初始状态。
重偏向
偏向锁总得epoch域是无效的。下一个获取锁的线程会注意到这个,从而会通过CAS指令将锁偏向自己。During bulk rebias operations, not held biased locks are put in this state to allow quick rebiasing.
偏向的
线程指针不为NULL并且epoch域是有效的,这就意味着可能有某个线程正持有锁。
由于偏置锁定需要使用哈希码字段作为偏向线程标识符,偏向锁定不能用于散列对象。HashCode computations on objects that allow biasing will first revoke any (valid or invalid) bias, and then CAS the computed hashcode into the mark word. This only applies to the identity hashcode though, which is what the hashcode() method on the Object class will compute. Hashcode computations for classes that override the hashcode() method will not require an identity hashcode (unless explicitly using the object hashcode of course), and can still use the biased locking mechanism.
HotSpot keeps a prototype mark word in the class metadata for every loaded class. Whether or not biased locking is allowed for the class is determined by the bias bit in this prototype. Also, the current epoch for the class is kept in the prototype. This means new objects can simply copy the prototype and use it without any further modification. During bulk rebias operations the prototype’s epoch is updated, and bulk revocation will change the prototype into the unbiasable state (setting the bias bit to 0).
偏向锁需要通过使用CAS指令将线程指针设置到mark work中。当然,使锁偏向的一个先决条件是,它要么是匿名偏向的,要么是可重定向的(锁一次只能偏向于一个线程)。一旦锁是偏向的,递归锁定和解锁只需要读取对象头和类原型来验证偏向是否被撤销。有偏向的锁记录将包含指向锁定对象的指针,but are otherwise uninitialized. 图2.5中可以看到有偏锁的一个例子。如果偏置被撤销,则仍然需要displaced mark work的未使用内存位置,在此期间,锁被转换为轻量级锁。HotSpot使用全局安全点来完成撤销操作。Revoker将遍历拥有线程的锁记录的偏差,以便得出对象当前是否被锁定的结论。如果发现锁是由偏向线程持有的,则对锁记录进行修改,使其看起来像是使用了轻量级锁。如果当前未持有锁,并且取决于导致撤销的原因,则不允许对象使用偏向锁定,也不允许将其重定向到撤消线程。
即使启用了偏向锁定,在JVM启动后的前四秒钟,由于在启动时使用性能不佳,特性也会被禁用。这意味着prototype mark words将在此期间将其偏置位设置为0,从而不允许在此期间实例化对象的偏置。在四秒钟之后,所有prototype mark workds中的偏置位被设置为1,从而允许对新对象使用偏置锁定。
During GC, object mark words are sometimes normalized to the prototype mark word. This is done to reduce the number of object headers that must be preserved during GC. Object headers that are hashed, locked or have biasing locally disabled (bias bit set to 0 when the prototype has its set to 1) will be preserved through GC, but not headers that are biased but currently not held by its biased thread. This means that the bias of unlocked biased objects are possibly forgotten at each GC (depending on the GC), forcing these locks to the anonymously biased state.
2.5 偏向锁示例.png若偏向配置启用时获得锁,执行以下步骤:
-
测试对象的bias位
如果是0,那么锁是非偏向的,就应该使用轻量级锁定机制。 -
测试对象类的bias位
检查是否在对象类的prototype mark word中设置了偏置位。如果不是,则对该类对象全局不允许偏置,应该重置对象的偏置位并使用轻量级锁。 -
验证epoch
检查对象mark word的epoch是否与prototype mark word的epoch相匹配。如果不是,偏置已经过期,锁是可重定向的。在这种情况下,锁定线程可以简单地尝试用原子CAS重定位锁。 -
检查拥有者
将偏向线程标识符与锁定线程的标识符进行比较。如果它们匹配,则锁定线程当前持有锁,并且可以安全返回。如果它们不匹配,则假定锁是匿名偏置的,并且锁定线程应该尝试使用原子CAS获取偏差。在失败时,撤销偏置(可能涉及SafePoint)并返回到轻量级锁定。成功时,锁定线程是锁的偏向所有者,可以返回。
在初始检查之后,最后三次检查只需一次约定跳转即可实现,方法是先按位加载prototype mark word,或者加载锁定线程的标识符,然后用对象的标记字对结果进行异或。如果结果为0-这意味着类允许偏置,则该epoch是有效的,并且锁定线程当前是偏向所有者。否则,如果结果不是0,则锁定线程必须调查不同的位,以了解所遇到的三种情况中的哪一种才能继续进行。
启用偏置锁定时的释放顺序只是检查是否设置了偏置位。如果没有设置,则使用轻量级锁定算法。如果设置了锁,则解锁线程必须是偏置所有者,因为锁在持有锁时不可能重定向到其他线程。通过设置偏置位,解锁线程将什么也不做。给定的线程是否持有某一锁取决于线程堆栈上的锁记录,不需要额外的记账。作为概述,mark word的各种状态以及它们之间的转换如图2.6所示。为了简化,省略了等待、通知和hashCode操作的转换。此外,取决于锁是否保持在偏置状态,不同的转换是可能的。例如,如果在吊销期间没有保持锁,则它只是转换到未锁定状态。如果在撤销期间保持锁,则转换到轻量级锁定状态。每当调用等待或通知时,锁总是转换到膨胀状态。在轻量级锁定状态下的Hashcode计算需要通过膨胀来支持移位标记字的修改。在未锁定的情况下,它只是向未锁定但哈希状态的自然转换,其中计算出的哈希码已插入标记字中。由于膨胀状态包含计算哈希码时和不计算哈希码时的情况,因此它将根据这一点导致不同的平减转换。此外,对有偏或偏置状态的哈希码计算将首先撤销偏置,之后它将遵循与轻量级锁定或未锁定状态相同的哈希码转换。
2.6 HotSpot中简单的mark word状态转换图.png参考:
Evaluating and improving biased locking in the HotSpot Virtual machine
Java Locks Analysis and Acceleration