如何通过重写hashCode()方法将偏向锁性能提高4倍?
原文:https://srvaroa.github.io/jvm/java/openjdk/biased-locking/2017/01/30/hashCode.html
作者:Galo Navarro
原文标题:How does the default hashCode() work?
原文发表日期:2017年1月30日
翻译:tommwq
译文:http://tommwq.tech/blog/?p=338
对hashCode()的肤浅了解会引发对JVM源代码的深入探索,认识了对象布局、偏向锁以及令人惊讶的hashCode()对性能的巨大影响。
非常感谢Gil Tene和Duarte Nunes对本文草稿进行审阅,并提供了非常有价值的见解、建议和修改。文中错误归咎于我。
1 微小谜题
上周在工作中我提交了对一个类的微小改动,实现了toString()方法,让日志更容易理解。令我吃惊的是,这个变动导致类的单元测试覆盖率下降了5%。我知道所有的新代码都被现有测试所覆盖。那么是哪错了呢?在比较覆盖率报告的时候,一个眼尖的同事注意到hashCode()在变更前被测试覆盖,而变更后却没有。这就说得通了:默认toString()方法调用了hashCode()方法。
public String toString() {return getClass().getName() + "@" + Integer.toHexString(hashCode()); }
在重写toString()之后,自定义hashCode()不再被调用。我遗漏了一项测试。
每个人都了解toString()方法,但是……
2 默认hashCode()方法是怎么实现的?
默认hashCode()方法的所返回的值叫做标识散列码(identity hash code)。从现在开始,我将使用这个术语来区分它与重写hashCode()方法返回的散列码。注:即使类重写了hashCode(),你仍然可以通过System.identityHashCode(o)来获得对象o的标识散列码。
使用内存地址的整型表示作为标识散列码是常识,也是J2SE文档所暗示的:
……通常是通过将对象的内部地址转换为整数来实现的,但这种实现技术并非Java编程语言所要求。
尽管如此,看起来还是有问题的,因为方法约定要求:
在Java应用程序执行期间,在同一对象上多次调用hashCode()方法时,hashCode()方法必须返回同一个值,无论调用的时机如何。
考虑到JVM会重新定位对象(例如在由晋升或压缩导致的GC周期中)。在计算对象的标识散列码之后,我们必须能以某种方式重新得到这个值,即使发生了对象重定位。
一种可能性是在第一次调用hashCode()时获取对象的当前内存位置,然后和对象一起保存,比如保存到对象头。这样即使对象被移动到不同的位置,它仍然留有最初的标识散列码。这种方法的一个隐患是:它无法阻止两个不同对象具有相同的标识散列码。但Java规范允许这种情况发生。
最好的确认方法是查看源代码。不幸的是,默认的java.lang.Object::hashCode()是一个本地方法。Listing 2: Object::hashCode是本地方法
public native int hashCode();
3 真正的hashCode()请出来
要注意的是,标识散列码的实现依赖于JVM。因为我只讨论OpenJDK源代码,所以我提到JVM时,总是指OpenJDK这一特定实现。代码链接指向代码仓库的Hotspot子目录。我认为这份代码中的大部分也适用于Oracle JVM,当然在个别地方可能(实际上)是不同的(稍后会详细介绍)。
OpenJDK定义了hashCode()入口点,在源代码src/share/vm/prims/jvm.h和src/share/vm/prims/jvm.cpp中:
508 JVM_ENTRY(jint, JVM_IHashCode(JNIEnv* env, jobject handle)) 509 JVMWrapper("JVM_IHashCode"); 510 // as implemented in the classic virtual machine; return 0 if object is NULL 511 return handle == NULL ? 0 : ObjectSynchronizer::FastHashCode (THREAD, JNIHandles::resolve_non_null(handle)) ; 512 JVM_END
identity_hash_value_for也调用了ObjectSynchronizer::FastHashCode(),前者被其他一些地方(如System.identityHashCode())调用。
708 intptr_t ObjectSynchronizer::identity_hash_value_for(Handle obj) { 709 return FastHashCode (Thread::current(), obj()) ; 710 }
有人可能简单的认为ObjectSynchronizer::FastHashCode()的做法类似:
if (obj.hash() == 0) {obj.set_hash(generate_new_hash()); } return obj.hash();
但实际上它是包含上百行代码、看起来复杂得多的函数。不过我们可以发现一些“如果没有则生成”(if-not-exists-generate)代码,比如:
685 mark = monitor->header(); ... 687 hash = mark->hash(); 688 if (hash == 0) { 689 hash = get_next_hash(Self, obj); ... 701 } ... 703 return hash;
这似乎证实了我们的假设。现在让我们暂时忽略管程(monitor),只要知道它可以提供对象头。对象头保存在变量mark中。mark是指向markOop实例的指针,markOop表示位于对象头中低地址的标记字(mark word)。因此hashCode()的算法是:尝试得到标记字中记录的散列码。如果没有,用get_next_hash()生成一个,保存然后返回。
4 标识散列码的生成
如我们所见,散列码由get_next_hash()生成。这个函数提供了6种计算方法,根据全局配置hashCode选择使用哪一个。
- 使用随机数。
- 基于对象的内存地址计算。
- 硬编码为1(用于测试)。
- 从一个序列生成。
- 使用对象的内存地址,转换为int类型。
- 使用线程状态和xorshift结合。
默认方法是哪一个?OpenJDK 8使用了方法5,依据是global.hpp:
1127 product(intx, hashCode, 5, \ 1128 "(Unstable) select hashCode generation algorithm") \
OpenJDK 9使用相同的默认值。查看以前的版本,OpenJDK 7和6都使用了第一个方法:随机数。
所以,除非我找错了源代码,否则OpenJDK中默认hashCode()方法的实现,和对象内存地址无关,至少从OpenJDK 6开始就是这样。
5 对象头和同步
让我们回顾几个之前没有考虑的地方。首先ObjectSynchronizer::FastHashCode()似乎过于复杂,使用了超过100行代码来执行我们认为是平凡的“得到或生成”(get-or-generate)操作。第二,管程是什么,它为什么拥有对象头?
查看标记词的结构是一个取得进展的好的起点。在OpenJDK中,它是这样的:
30 // The markOop describes the header of an object. 31 // 32 // Note that the mark is not a real oop but just a word. 33 // It is placed in the oop hierarchy for historical reasons. 34 // 35 // Bit-format of an object header (most significant first, big endian layout below): 36 // 37 // 32 bits: 38 // -------- 39 // hash:25 ------------>| age:4 biased_lock:1 lock:2 (normal object) 40 // JavaThread*:23 epoch:2 age:4 biased_lock:1 lock:2 (biased object) 41 // size:32 ------------------------------------------>| (CMS free block) 42 // PromotedObject*:29 ---------->| promo_bits:3 ----->| (CMS promoted object) 43 // 44 // 64 bits: 45 // -------- 46 // unused:25 hash:31 -->| unused:1 age:4 biased_lock:1 lock:2 (normal object) 47 // JavaThread*:54 epoch:2 unused:1 age:4 biased_lock:1 lock:2 (biased object) 48 // PromotedObject*:61 --------------------->| promo_bits:3 ----->| (CMS promoted object) 49 // size:64 ----------------------------------------------------->| (CMS free block) 50 // 51 // unused:25 hash:31 -->| cms_free:1 age:4 biased_lock:1 lock:2 (COOPs && normal object) 52 // JavaThread*:54 epoch:2 cms_free:1 age:4 biased_lock:1 lock:2 (COOPs && biased object) 53 // narrowOop:32 unused:24 cms_free:1 unused:4 promo_bits:3 ----->| (COOPs && CMS promoted object) 54 // unused:21 size:35 -->| cms_free:1 unused:7 ------------------>| (COOPs && CMS free block)
在32位机器和64位机器上,标记字的格式略有不同。后者有两个变体,取决于是否启用了压缩对象指针(Compressed Object Pointer)。Oracle JVM和OpenJDK 8都是默认启用的。
因此对象头可能与一个内存块或一个实际的对象关联,存在多种状态。在最简单的情况下(“普通对象”),标识散列码直接存储在对象头的低地址中。
但在其他状态下,对象头包含一个指向JavaThread或PromotedObject的指针。更复杂的是:如果我们把唯一散列码放到一个“普通对象”中,它会被移走吗?移动到哪?如果对象是有偏向的(biased),我们可以从哪里获得或设置标识散列码?什么又是有偏向的对象(biased object)呢?
让我们试着回答这些问题。
6 偏向锁
偏向对象看起来是偏向锁的结果。这是从HotSpot 6起默认启用的一个特性,试图减少锁定对象的成本。锁定操作是昂贵的,它的实现通常依赖于原子CPU指令(CAS),以便安全地处理来自不同线程的锁定和解锁请求。根据观察,在大多数应用程序中,大多数对象只被一个线程锁定,因此为原子操作付出的成本常常被浪费了。为了避免这种情况,带有偏向锁的JVM允许线程将对象设置为“偏向于”自己。如果一个对象是有偏向的,线程可以锁定和解锁对象,而无需原子指令。只要没有线程争用同一个对象,我们就会得到性能提升。
对象头中的偏向锁位(biased_lock bit)表示对象是否偏向于JavaThread*所指向的线程。锁定位(lock bit)表示该对象是否被锁定。
正是因为OpenJDK的偏向锁实现需要在标记字中写入一个指针,它需要重新定位真正的标记字(其中包含标识散列码)。
这可以解释FasttHashCode中额外的复杂性。对象头不仅包含标识散列码,也包含锁定状态(比如指向锁持有者线程的指针)。因此我们需要考虑所有情况,并找到标识散列码存储的位置。
让我们来读读FasttHashCode。我们发现的第一件事是:
601 intptr_t ObjectSynchronizer::FastHashCode (Thread * Self, oop obj) { 602 if (UseBiasedLocking) { 610 if (obj->mark()->has_bias_pattern()) {... 617 BiasedLocking::revoke_and_rebias(hobj, false, JavaThread::current());... 619 assert(!obj->mark()->has_bias_pattern(), "biases should be revoked by now"); 620 } 621 }
等等,它只是撤销了现有的偏向性,并禁用了对象上的偏向锁(false意味着不要尝试重置偏向性)。看接下来的几行,这确实是一个不变量:
637 // object should remain ineligible for biased locking 638 assert (!mark->has_bias_pattern(), "invariant") ;
如果我没看错,这意味着简单地请求对象的标识散列码将禁用偏向锁,这将强制要求锁定对象必须使用昂贵的原子指令,即使只有一个线程。
7 为什么偏向锁和标识散列码冲突?
要回答这个问题,我们必须了解标记字(包含标识散列码)可能存在的位置,这取决于对象的锁的状态。下面这张来自于HotSpot Wiki的图展示了转换过程: 我的(不可靠)推理如下。
对于图顶部的4种状态,OpenJDK将能够使用“轻”锁表示。在最简单的情况下(没有锁),这意味着将标识散列码和其他数据直接放在对象的标记字中:
46 // unused:25 hash:31 -->| unused:1 age:4 biased_lock:1 lock:2 (normal object)
在更复杂的情况下,它需要这个空间来保存指向“锁对象”的指针。因此,标记字将被“替换”,放到其他地方。
既然只有一个线程尝试锁定对象,指针实际上会指向线程堆栈中的某个内存位置。这么做有两个优点:访问速度快(没有争用或内存访问协调),并且能够让线程确定它拥有锁(因为内存位置指向自己的堆栈)。
但这并非在所有情况下都有效。如果存在对象争用(例如许多线程都会执行到的同步语句),我们将需要一个更复杂的结构,不仅可以保存对象头副本,也保存一组等待者。如果线程执行object.wait(),就会出现对等待者列表的类似需求。
这个更丰富的数据结构就是ObjectMonitor,在图中称为“重量级”管程。对象头不再指向“被替换的标记字”,而是指向一个实际的对象(管程)。这时访问标识散列码需要“扩张管程(inflate the monitor)”:跟踪指针得到对象,读取或修改包含被替换标记字的域。这个操作更加昂贵,而且需要协调。
FasttHashCode确实有工作要做。
L640到L680处理查找对象头并检查缓存的标识散列码。我相信存在一个快速路径来探测不需要扩张管程的情况。
从L682开始需要咬紧牙关:
682 // Inflate the monitor to set hash code 683 monitor = ObjectSynchronizer::inflate(Self, obj);684 // Load displaced header and check it has hash code 685 mark = monitor->header(); ... 687 hash = mark->hash();
此时,如果标识散列码存在(hash != 0),JVM可以直接返回。否则需要从get_next_hash()中得到散列码,并安全地存储在ObjectMonitor保存的对象头中。
这似乎提供了一个合理的解释,为什么在不覆盖默认实现的对象上调用hashCode()导致对象不符合偏向锁的条件:
- 为了在重定位后保持对象的标识散列码不变,需要将标识散列码存储在对象头中。
- 请求标识散列码的线程未必关心对象是否锁定,但上它们实际上共享了锁机制使用的数据结构。这种机制是一个复杂怪兽,它不仅自身要发生变化,还要移动(替换)对象头。
- 偏向锁能够在不使用原子操作的情况下进行锁定和解锁操作。偏向锁是高效的,如果只有一个线程锁定对象。我们可以将锁状态记录到标记字中。我不能100%肯定,但是我认为既然其他线程可能会读取标识散列码,即使只有一个线程需要锁定,标记字也会发生争用,并需要原子操作来保证准确。这否定了偏向锁的全部意义。
8 回顾
- 默认的hashCode()实现(标识哈希码)和对象的内存地址无关,至少在OpenJDK中是这样的。在OpenJDK 6和7中,它是一个随机生成的数字。在OpenJDK 8和9中,它是一个基于线程状态的数字。这里有一个测试得出了相同的结论。
- 证明“依赖于实现”的警告并非虚谈:Azul Zing确实从对象的内存地址生成标识散列码。
- 在HotSpot中,标识散列码只生成一次,然后缓存在对象头的标记字中。
- Zing使用了不同的方案来保证散列码在对象重定位后的一致的。他们在对象重定向时才保存标识散列码的值。这个时候散列码被保存在pre-header中。
- 在HotSpot中,调用默认值hashCode()或System.identityHashCode()将使对象的锁失去偏向性。
- 这意味着如果你对没有争用的对象进行同步(synchronized),最好重写默认的hashCode()实现,否则将错过JVM优化。
- 在HotSpot中,可以禁用单个对象的偏向锁。
- 这是非常有用的。我曾见过应用程序在争用的生产者-消费者队列中使用过多的偏向锁,这带来的麻烦比好处多,所以我们完全禁用了这个特性。实际上,我们可以通过在特定对象或类上调用System.identityHashCode()来实现这一点。
- 我发现HotSpot没有标志选择默认的hashCode生成器,所以试验其他生成器可能需要编译源代码。
- 说实话我没仔细看。Michael Rasmussen善意地指出-XX:hashCode=2可以用来更改默认值。谢谢!
9 基准测试
我编写了一个简单的JMH工具来验证这些结论。
基准测试所做的事情类似:
object.hashCode(); while(true) {synchronized(object) {counter++;} }
第一种配置(withIdHash)在使用标识散列码的对象上同步,我们预计调用hashCode()将导致偏向锁被禁用。第二种配置(withoutIdHash)实现了自定义散列码,因此不会禁用偏向锁。每个配置先用一个线程运行,然后用两个线程(带有后缀“Contended”)。
顺便说一下,我们必须启用-XX:BiasedLockingStartupDelay=0,否则JVM将等待4s时间才触发优化,这将影响测试效果。
第一次执行:
Benchmark Mode Cnt Score Error Units BiasedLockingBenchmark.withIdHash thrpt 100 35168,021 ± 230,252 ops/ms BiasedLockingBenchmark.withoutIdHash thrpt 100 173742,468 ± 4364,491 ops/ms BiasedLockingBenchmark.withIdHashContended thrpt 100 22478,109 ± 1650,649 ops/ms BiasedLockingBenchmark.withoutIdHashContended thrpt 100 20061,973 ± 786,021 ops/ms
我们可以看到,使用自定义散列码使锁定和解锁循环比使用标识散列码(禁用偏向锁)快4倍。当两个线程争用锁时,偏置锁将被禁用,因此两种散列方法之间没有显著差异。
第二次运行,禁用所有配置中的偏向锁(-XX:-UseBiasedLocking)。
Benchmark Mode Cnt Score Error Units BiasedLockingBenchmark.withIdHash thrpt 100 37374,774 ± 204,795 ops/ms BiasedLockingBenchmark.withoutIdHash thrpt 100 36961,826 ± 214,083 ops/ms BiasedLockingBenchmark.withIdHashContended thrpt 100 18349,906 ± 1246,372 ops/ms BiasedLockingBenchmark.withoutIdHashContended thrpt 100 18262,290 ± 1371,588 ops/ms
散列方法不再有任何影响,withoutIdHash也失去了它的优势。
(所有的基准测试都运行在一台 2.7 GHz Intel Core i5电脑上。)
10 参考文献
这些猜想以及我对JVM源代码的理解,来自于对关于布局、偏向锁等不同资料的拼凑。主要的资料有:
- https://blogs.oracle.com/dave/entry/biased_locking_in_hotspot
- http://fuseyism.com/openjdk/cvmi/java2vm.xhtml
- http://www.dcs.gla.ac.uk/~jsinger/pdfs/sicsa_openjdk/OpenJDKArchitecture.pdf
- https://www.infoq.com/articles/Introduction-to-HotSpot
- http://blog.takipi.com/5-things-you-didnt-know-about-synchronization-in-java-and-scala/#comment-1006598967
- http://www.azulsystems.com/blog/cliff/2010-01-09-biased-locking
- https://dzone.com/articles/why-should-you-care-about-equals-and-hashcode
- https://wiki.openjdk.java.net/display/HotSpot/Synchronization
- https://mechanical-sympathy.blogspot.com.es/2011/11/biased-locking-osr-and-benchmarking-fun.html
11 附录:基准测试代码
package com.github.srvaroa.jmh;import org.openjdk.jmh.annotations.*; import org.openjdk.jmh.infra.Blackhole;import java.util.concurrent.TimeUnit;@State(Scope.Benchmark) @OutputTimeUnit(TimeUnit.MILLISECONDS) @Warmup(iterations = 4) @Fork(value = 5, jvmArgsAppend = {"-XX:-UseBiasedLocking", "-XX:BiasedLockingStartupDelay=0"}) public class BiasedLockingBenchmark {int unsafeCounter = 0;Object withIdHash;Object withoutIdHash;@Setuppublic void setup() {withIdHash = new Object();withoutIdHash = new Object() {@Overridepublic int hashCode() {return 1;}};withIdHash.hashCode();withoutIdHash.hashCode();}@Benchmarkpublic void withIdHash(Blackhole bh) {synchronized(withIdHash) {bh.consume(unsafeCounter++);}}@Benchmarkpublic void withoutIdHash(Blackhole bh) {synchronized(withoutIdHash) {bh.consume(unsafeCounter++);}}@Benchmark@Threads(2)public void withoutIdHashContended(Blackhole bh) {synchronized(withoutIdHash) {bh.consume(unsafeCounter++);}}@Benchmark@Threads(2)public void withIdHashContended(Blackhole bh) {synchronized(withIdHash) {bh.consume(unsafeCounter++);}}}
如何通过重写hashCode()方法将偏向锁性能提高4倍?相关推荐
- JAVA中重写equals()方法的同时要重写hashcode()方法
object对象中的 public boolean equals(Object obj),对于任何非空引用值 x 和 y,当且仅当 x 和 y 引用同一个对象时,此方法才返回 true:注意:当此方法 ...
- 重写 equals 方法就一定要重写 hashCode 方法?其实有个前提
作者 l 会点代码的大叔(CodeDaShu) 如果问到 == 和 equals 的区别,相信很多程序员同学都能脱口而出:一个是判断地址,一个是判断内容. 但是如果继续追问:"你重写过 eq ...
- hashcode相等的两个对象一定相等吗_为什么重写 equals方法时一定要重写hashCode方法?...
推荐阅读: 一线架构师总结SpringBoot,Cloud,Nginx与Docker,不信你搞不懂 47天洒热血复习,我终于"挤进"了字节跳动(附面经+学习笔记) 五年时间,从蘑菇 ...
- 为什么要重写hashcode()方法
主要原因是默认从Object继承来的hashCode是基于对象的ID实现的. 如果你重写了equals,比如说是基于对象的内容实现的,而保留hashCode的实现不变,那么很可能某两个对象明明是&qu ...
- 重写equals方法时必须重写hashcode方法吗
重写equals方法时必须重写hashcode 有规范: 1,当obj1.equals(obj2) 为 true 时,obj1.hashCode() == obj2.hashCode() 2,当obj ...
- why在重写equals时还必须重写hashcode方法
首先我们先来看下String类的源码:可以发现String是重写了Object类的equals方法的,并且也重写了hashcode方法 public boolean equals(Object anO ...
- java equals重写原则_java中为何重写equals时必须重写hashCode方法详解
前言 大家都知道,equals和hashcode是java.lang.Object类的两个重要的方法,在实际应用中常常需要重写这两个方法,但至于为什么重写这两个方法很多人都搞不明白. 在上一篇博文Ja ...
- 学习:重写hashCode()方法的必要性
当一个类有可能会和其他类发生比较的时候,我们会重写equals方法,但大多数情况下,都忽略了重写hashCode方法. 这里说一下重写hashCode的必要性. 当我们使用HashSet或者HashM ...
- java重写面试题_Java面试题:重写了equals方法,为什么还要重写hashCode方法?
核心问题:重写了equals方法,为什么还要重写hashCode方法? 这不仅仅是一道面试题,而且是关系到我们的代码是否健壮和正确的问题.在前面两篇文章涉及到了equals方法的底层讲解:<说说 ...
最新文章
- 2021年大数据Kafka(四):❤️kafka的shell命令使用❤️
- linux centos7 /tmp目录 自动清理规则
- 使用pip安装python库的几种方式,解决pip安装python库慢的问题
- tomcat关闭和重启
- HTML怎么在li中加select标签,Vue.js做select下拉列表的实例(ul-li标签仿select标签)_莺语_前端开发者...
- vitualbox的一个问题总结
- 我的世界中国版服务器没有mods文件夹,我的世界中国版如何安装mod 国服安装mod的详细教程...
- Javascript特效:利用封装动画函数模拟关闭安全管家弹窗
- BZOJ4892:[TJOI2017]dna(hash)
- 鸿蒙系统电脑配置,鸿蒙系统 你装机了吗?
- CS61a-2020fall学习笔记
- 物联网和互联网有什么关系
- Borland 26年风雨路
- linux sftp 重命名,SFTP对文件重命名 删除 退出 查看
- tig 使用_使用TIG监控机器
- PS一分钟打造手机渐变壁纸
- 榆熙电商:拼多多商家一年能进行几次申诉?
- 什么是真正的架构设计?某厂十年Java经验让我总结出了这些,不愧是我
- 浅谈互联网行业发展趋势及现状
- Java架构师必备知识体系