原文: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. 使用随机数。
  2. 基于对象的内存地址计算。
  3. 硬编码为1(用于测试)。
  4. 从一个序列生成。
  5. 使用对象的内存地址,转换为int类型。
  6. 使用线程状态和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倍?相关推荐

  1. JAVA中重写equals()方法的同时要重写hashcode()方法

    object对象中的 public boolean equals(Object obj),对于任何非空引用值 x 和 y,当且仅当 x 和 y 引用同一个对象时,此方法才返回 true:注意:当此方法 ...

  2. 重写 equals 方法就一定要重写 hashCode 方法?其实有个前提

    作者 l 会点代码的大叔(CodeDaShu) 如果问到 == 和 equals 的区别,相信很多程序员同学都能脱口而出:一个是判断地址,一个是判断内容. 但是如果继续追问:"你重写过 eq ...

  3. hashcode相等的两个对象一定相等吗_为什么重写 equals方法时一定要重写hashCode方法?...

    推荐阅读: 一线架构师总结SpringBoot,Cloud,Nginx与Docker,不信你搞不懂 47天洒热血复习,我终于"挤进"了字节跳动(附面经+学习笔记) 五年时间,从蘑菇 ...

  4. 为什么要重写hashcode()方法

    主要原因是默认从Object继承来的hashCode是基于对象的ID实现的. 如果你重写了equals,比如说是基于对象的内容实现的,而保留hashCode的实现不变,那么很可能某两个对象明明是&qu ...

  5. 重写equals方法时必须重写hashcode方法吗

    重写equals方法时必须重写hashcode 有规范: 1,当obj1.equals(obj2) 为 true 时,obj1.hashCode() == obj2.hashCode() 2,当obj ...

  6. why在重写equals时还必须重写hashcode方法

    首先我们先来看下String类的源码:可以发现String是重写了Object类的equals方法的,并且也重写了hashcode方法 public boolean equals(Object anO ...

  7. java equals重写原则_java中为何重写equals时必须重写hashCode方法详解

    前言 大家都知道,equals和hashcode是java.lang.Object类的两个重要的方法,在实际应用中常常需要重写这两个方法,但至于为什么重写这两个方法很多人都搞不明白. 在上一篇博文Ja ...

  8. 学习:重写hashCode()方法的必要性

    当一个类有可能会和其他类发生比较的时候,我们会重写equals方法,但大多数情况下,都忽略了重写hashCode方法. 这里说一下重写hashCode的必要性. 当我们使用HashSet或者HashM ...

  9. java重写面试题_Java面试题:重写了equals方法,为什么还要重写hashCode方法?

    核心问题:重写了equals方法,为什么还要重写hashCode方法? 这不仅仅是一道面试题,而且是关系到我们的代码是否健壮和正确的问题.在前面两篇文章涉及到了equals方法的底层讲解:<说说 ...

最新文章

  1. 2021年大数据Kafka(四):❤️kafka的shell命令使用❤️
  2. linux centos7 /tmp目录 自动清理规则
  3. 使用pip安装python库的几种方式,解决pip安装python库慢的问题
  4. tomcat关闭和重启
  5. HTML怎么在li中加select标签,Vue.js做select下拉列表的实例(ul-li标签仿select标签)_莺语_前端开发者...
  6. vitualbox的一个问题总结
  7. 我的世界中国版服务器没有mods文件夹,我的世界中国版如何安装mod 国服安装mod的详细教程...
  8. Javascript特效:利用封装动画函数模拟关闭安全管家弹窗
  9. BZOJ4892:[TJOI2017]dna(hash)
  10. 鸿蒙系统电脑配置,鸿蒙系统 你装机了吗?
  11. CS61a-2020fall学习笔记
  12. 物联网和互联网有什么关系
  13. Borland 26年风雨路
  14. linux sftp 重命名,SFTP对文件重命名 删除 退出 查看
  15. tig 使用_使用TIG监控机器
  16. PS一分钟打造手机渐变壁纸
  17. 榆熙电商:拼多多商家一年能进行几次申诉?
  18. 什么是真正的架构设计?某厂十年Java经验让我总结出了这些,不愧是我
  19. 浅谈互联网行业发展趋势及现状
  20. Java架构师必备知识体系

热门文章

  1. 简单几个方法教你怎么把PDF压缩小,试试你就知道
  2. 2020起重机械指挥模拟考试题库及起重机械指挥实操考试视频
  3. android JIN 第一步 生成java转换成class然后再转化成.h文件
  4. intel编译器免费下载
  5. 二十八、动词不定式 2 做表语、宾语、后置定语、状语
  6. 大牛博士是如何进行文献检索和阅读的
  7. SpringBoot事务配置管理
  8. C语言同时满足三个并列条件,你不得不知道的编程基础之同时满足多个条件
  9. sql中的date的使用
  10. SQL Server日期数据类型DATE的使用