第二章 早期的研究
2.1 批量操作
为了降低撤销成本,一种引入批量重偏向和撤销的技术得到了发展.它本质上不单是为某一个锁,而是为所有对象锁上的一种特殊数据类型进行重偏向或撤销.不同的类一般具有不同的使用场景,有些对象很可能在线程间共享,其他则不然.特定数据类型的使用场景能够证明偏向锁设计的问题.例如,生产者-消费者队往往应用于竞争环境. 这些场景中,当线程拥有关系发生变更时,一次性地为该类型撤销和重偏所有对象,而非为每个对象单独撤销有助于在数据类型层面检测到模式.换句话说,在偏向锁算法中添加探索,评估每种数据类型的偏向锁撤销成本以及当达到某一阈值时为该类型所有锁进行重偏向.批量偏向操作为给定数据类型的每个未加锁对象重置偏向线程,从而允许下一个试图获取的线程将锁偏向自己而无需撤销[6]. 当该操作锁定对象期间,可以撤销或者设置为偏向.这种探索或许也可以决定运行一种所谓批量撤销的操作,从而完全禁止所有类对象使用偏向锁.
译者注 [6]: Hotspot现有偏向锁不能自己撤销,需要JVM找个全局安全点通过把所有线程挂起后撤销,这是个耗时操作,应当避免.
这种设计的一种低级实现是扫面整个堆中所有对象,从而偏向哪些未加锁对象. 一种可能更好的批量偏向实现是为对象引入阶段(epoch)字段.把该字段添加到每个对象的类元数据中(例如作为锁字段的一部分),只有当类元中的阶段数字和对象匹配时给定锁的偏向才有效. 增加类中的阶段字段,遍历所有线程来更新锁定对象的阶段字段都很容易可以构成无效偏向. 没有更新的对象将会包含一个无效阶段数字,然后会被重定向. 阶段数字是大小受限的. 当该字段缠绕(wraps around)时,有时会发生无效偏向再次变成有效偏向. 这有幸不是个正确性问题,而是锁被无意偏向的性能问题. 最坏情况下这会导致撤销. Detlefs 和 Russell通过实验证明这种缠绕导致的性能影响微不足道. 此外,当某一对象遇到阶段数字无效时,GC会用初始的无锁并可重偏向状态来重置锁字段,从而进一步降低了阶段缠绕.
为了允许类的批量撤销,可以简单地在类元中添加一个字段并制定是否可偏向.当获取某个锁时,通过检查该字段来决定使用偏向锁还是轻量级锁. 如果类元中包含了原型对象头,可将该原型锁字段中的偏向设置为禁用,这样该类新对象会保留原型的锁字段. 从本质上说,当批量撤销发生时,所有当前偏向锁定的类对象必须能撤销,此后原型中的偏向标志位被设置为0,隐式地撤销了该类的其他对象.
2.2 学习型自适应偏向锁
另外一种对偏向锁的改进是尝试降低对撤销的需求,它为锁引入了学习阶段. 锁不再是第一次获取时设置为偏向,而是当某一单独线程尝试获取一定次数后变为偏向锁.在学习阶段获取锁的方式就像轻量级锁一样,使用原子指令. 在学习阶段, 字段正常来说包含已偏向线程的引用而非一个偏向候选线程. 一旦候选线程获取足够多次数的锁, 该锁偏向候选者.[7]
译者注 [7]: 当存在竞争的时候,传统的偏向锁存在撤销操作,该操作比较昂贵. 所以自适应偏向锁通过获取次数来自动判定候选线程是否为为偏向线程,这就省去了撤销的操作.
该设计最直接的实现方式是在锁字段中保存一个计数器,用来记录候选线程尝试获取锁的次数(忽略递归次数).自然地,如果其他线程在学习阶段获取了锁,那么计数器会被重置,候选线程也会被调整.另外,正在偏向的线程可能随后被完全禁止,因为这个锁并没有展现出任何线程本地性. 学习阶段可以减少撤销的数量, 而撤销操作是在锁变成偏向之前由不断增长的CAS指令数所造成的必须成本.
避免在锁字段上为计数器分配额外空间的一种设计是引入随机计数(random counting). 不同于真实计数到N次尝试获取,每次尝试时,线程都有1/N的概率偏向该锁. 平均下来,这就需要N次尝试才能偏向该锁. 由于实现和使用随机数生成器可能很复杂并降低同步性能, 一种简单版本是每个线程拥有一个本地计数器, 它记录了成功的获取次数. 一旦达到了成功尝试次数阈值线程就偏向该锁. 虽然这并不是真的随机,但是平均来说它也可以达到1/N的偏向可能性.
2.3 固定偏向锁
完全消除撤销需求的一种方式是已偏向的锁允许其他非偏向线程获取该锁. 换句话说锁会拥有一个绝对不变(或几乎不变)的固定偏向线程, 它仍然允许其他非偏向线程在无需撤销的情况下获取该锁. 在进入临界区时,偏向线程毋须保证没有其他线程持有这把锁. 因为偏向锁的根本目的就是避免使用读-修改-写(RMW)指令, 锁的算法必须保证只使用读和写的指令. 这种算法的有效实例包含了Dekker’s 算法和Peterson’s算法. 这两种方案都是为了解决两个线程的互斥问题.
谨慎采用上述加锁技术可以保证偏向线程和其他线程的互斥访问, 并保证仅使用常规的读写指令就可让偏向线程获取或释放锁. 依赖于具体实现, 很可能需要内存屏障来保证读写指令不会被CPU重排序. 非偏向线程要想获取锁需要首先在跟偏向线程的竞争中胜出. 非偏向线程需要原子的RMW来获取锁,这些线程可以简单地使用基于RWM的锁来保证互斥.
由Onodera等人提出的Spin-by-KKO-lock就是这种类型锁的实现. 从概念上说,这种所需要三个字段: 一个是用来标识偏向线程,一个用来标识其他线程(非偏向的持锁线程)以及一个指示是否被偏向线程持有的标志. 像以前一样, 通过CAS在偏向线程标识字段标识锁已偏向. 一旦偏向,偏向线程使用常规的写指令来设置状态字段为合适的值从而实现获取获和释放. 一旦获取锁,再进入临界区前偏向线程也会保证其他字段为空(0). 非偏向线程通过在other和状态字段上使用CAS来获取锁, 直到它们都被设置成0以及将他们的标识写入到other字段. 如果任何一次尝试失败,算法回退到底层的锁算法(比如膨胀锁). 该实现实际上只需要一个字长就可包含这三个字段. 这允许使用工作到多个字段一个CAS指令,而无需多字(multiple word)CAS指令. 为避免覆盖other字段, 当偏向线程获取锁时,使用覆盖部分偏向线程字段值并不触碰other字段的8-bit写指令来修改状态位.