Object::hashCode的返回值是不是对象的内存地址?

简介: 某一天,和小伙伴之间的话题不知怎么转到如何实现Object::hashCode上,于是就有了这篇文章。 有什么好讨论的呢,取对象的内存基址不就挺好的吗?方便又高效。且看下文的讨论 当GC发生时…… JavaDoc中描述了Object::hashCode的三个约束,其中要求对象不变时其hash code就应该不变,Object本身没什么属性可变的,自然hash code也就不会变。

某一天,和小伙伴之间的话题不知怎么转到如何实现Object::hashCode上,于是就有了这篇文章。

有什么好讨论的呢,取对象的内存基址不就挺好的吗?方便又高效。且看下文的讨论

当GC发生时……

JavaDoc中描述了Object::hashCode的三个约束,其中要求对象不变时其hash code就应该不变,Object本身没什么属性可变的,自然hash code也就不会变。而Java是自带GC的语言,大家都知道。某些GC算法,比如Copy,比如Mark-Compact都会移动对象,自然地对象的基址也会改变,基于内存基址实现hashCode返回值就有可能在GC后变了。

我们还是假设就用对象内存基址做hashCode的返回值,这样通常也不会有什么问题,毕竟直接调用hashCode方法等场景少之又少。直到遇到以下场景

Object obj = new Object(); // allocated at 0x02
Map<Object, String> map = new HashMap<>(); // 16 slots
map.put(obj, "a1"); // assume hashed in slot[0x02]
// after GC, obj moved (0x02 -> 0x20)
String value = map.get(obj); // assume hashed in slot[0x00]
System.out.println("true or false? : " + (value == null)); // ???

虽然我们不太可能会用到一个Object instance作为map的key,但如果以内存基址作为hashCode的实现还真是令人头皮发麻:刚存到map不久的数据居然找不回来了!

解决对象移动

好的,既然对象可能跑来跑去,每次都取内存基址行不通,不过又要求生成后就不变,那我们要找个字段把Object的hashCode存好。类似这样

class Object {
    private final int _hashCode = _toAddress(this);
    public int hashCode() {
        return _hashCode;
    }
}

一切完美,无论对象被移动多少次,我的map都可以正常工作。不过缺点也很明显,比较浪费内存:Java中所有的类都是Object的子类,于是每个类都至少多占用一个Word的内存,而且这个字段绝大部分情况也是用不到的。

怎么更省空间

从上面讨论来看,为了保证hashCode的约束,这个Word无论如何都省不掉,我们最好能让这字段能存更多信息,比如放Java对象头中。首先从openjdk(jdk-9+181)里面抠点信息,了解一下一个Word究竟怎么个物尽其用

// hotspot/src/share/vm/oops/markOop.hpp
//  64 bits:
//  --------
//  unused:25 hash:31 -->| unused:1   age:4    biased_lock:1 lock:2 (normal object)
//  JavaThread*:54 epoch:2 unused:1   age:4    biased_lock:1 lock:2 (biased object)
//  PromotedObject*:61 --------------------->| promo_bits:3 ----->| (CMS promoted object)
//  size:64 ----------------------------------------------------->| (CMS free block)
//
//  unused:25 hash:31 -->| cms_free:1 age:4    biased_lock:1 lock:2 (COOPs && normal object)
//  JavaThread*:54 epoch:2 cms_free:1 age:4    biased_lock:1 lock:2 (COOPs && biased object)
//  narrowOop:32 unused:24 cms_free:1 unused:4 promo_bits:3 ----->| (COOPs && CMS promoted object)
//  unused:21 size:35 -->| cms_free:1 unused:7 ------------------>| (COOPs && CMS free block)

//  - the two lock bits are used to describe three states: locked/unlocked and monitor.
//
//    [ptr             | 00]  locked             ptr points to real header on stack
//    [header      | 0 | 01]  unlocked           regular object header
//    [ptr             | 10]  monitor            inflated lock (header is wapped out)
//    [ptr             | 11]  marked             used by markSweep to mark an object
//                                               not valid at any other time

可以看到一个Word里面存了几个信息:hash code、锁优化标识、GC标识,主要是根据末两位标识做不同的表意,甚至这个东西上锁时还会copy来copy去。不过我们还是只关注hash code,下面用hsdb工具浏览一下JVM内存。

首先要写一个小demo

public class Hash {
    int verbose;
    public Hash(int verbose) {this.verbose = verbose;}
    public static void main(String[] args) throws Exception {
        Hash h1 = new Hash(0x1234);
        Hash h2 = new Hash(0x5678);
        System.out.println("breakpoint 1");
        
        System.out.println("before gc, h1.hashCode=" + Integer.toHexString(h1.hashCode()) +
                ", h2.hashCode=" + Integer.toHexString(h2.hashCode()));
        System.out.println("breakpoint 2");
        
        h1 = null;
        System.gc();
        System.out.println("after gc, h2.hashCode=" + Integer.toHexString(h2.hashCode()));
        System.out.println("breakpoint 3");
    }
}

代码的目的是借用Hotspot的System.gc方法触发FullGC,使得h2对象被复制到old gen。接下来要用调试器调试代码,eclipse、IDEA什么的都OK,在对应的地方加上断点。注意为了按预期执行和方便查看,要设置一下JVM参数: -XX:+UseSerialGC -Xmx10m -XX:-UseCompressedOops

假设程序已经停在了 System.out.println("breakpoint 1") ,我们就可以启动hsdb attach到目标进程:

# JDK 8
java -cp .:$JAVA_HOME/lib/sa-jdi.jar sun.jvm.hotspot.HSDB
# JDK 9
jhsdb hsdb

进入到hsdb后,先用 Tools - Find Object by Query OQL查出所有实例: select x from test.Hash x ,然后用各种查看器看内存数据即可。一顿操作后类似这个样子

hsdb-usage.png

# Hash h1
hsdb> inspect 0x000000010b33d690
instance of Oop for test/Hash @ 0x000000010b33d690 @ 0x000000010b33d690 (size = 24)
_mark: 1
_metadata._klass: InstanceKlass for test/Hash
verbose: 4660
hsdb> mem 0x000000010b33d690 3
0x000000010b33d690: 0x0000000000000001 
0x000000010b33d698: 0x000000010c000578 
0x000000010b33d6a0: 0x0000000000001234 
# Hash h2
hsdb> inspect 0x000000010b33d6a8
instance of Oop for test/Hash @ 0x000000010b33d6a8 @ 0x000000010b33d6a8 (size = 24)
_mark: 1
_metadata._klass: InstanceKlass for test/Hash
verbose: 22136
hsdb> mem 0x000000010b33d6a8 3
0x000000010b33d6a8: 0x0000000000000001 
0x000000010b33d6b0: 0x000000010c000578 
0x000000010b33d6b8: 0x0000000000005678 

可以看到两个对象的的MarkWord都是0x0000000000000001,即未被锁定、没有偏向、分代年龄为0、hashCode还未分配。后面的Class标识、实例字段和padding略过不谈。

下一步是让程序执行到第二个断点(注意,要先让hsdb detach,否则调试器无法工作),即 System.out.println("breakpoint 2") ,程序控制台也输出了:

breakpoint 1
before gc, h1.hashCode=6f2b958e, h2.hashCode=1eb44e46

hsdb再次连上,查看数据,发现预期一样写入了对应的位: 0x000000 6f2b958e 01 0x000000 1eb44e46 01

# Hash h1
hsdb> mem 0x000000010b33d690 3
0x000000010b33d690: 0x0000006f2b958e01 
0x000000010b33d698: 0x000000010c000578 
0x000000010b33d6a0: 0x0000000000001234 
# Hash h2
hsdb> mem 0x000000010b33d6a8 3
0x000000010b33d6a8: 0x0000001eb44e4601 
0x000000010b33d6b0: 0x000000010c000578 
0x000000010b33d6b8: 0x0000000000005678 

再让程序执行到第三个断点,程序输出 after gc, h2.hashCode=1eb44e46 ,hash code没变。理论上此时h1被回收,h2被copy到old gen,地址变化了。于是使用OQL再次查询h2的地址为0x000000010b5ea220,查看内存如下

# Hash h2
hsdb> mem 0x000000010b5ea220 3
0x000000010b5ea220: 0x0000001eb44e4601 
0x000000010b5ea228: 0x000000010c000578 
0x000000010b5ea230: 0x0000000000005678

对象数据不变,所以还是能从MarkWord 0x000000 1eb44e46 01 中取出生成过的hash code。那此时h2被copy到哪里了呢?再次执行universe命令,看堆概况

hsdb> universe
Heap Parameters:
Gen 0:   eden [0x000000010b200000,0x000000010b20dc68,0x000000010b4b0000) space capacity = 2818048, 2.0022370094476742 used
  from [0x000000010b4b0000,0x000000010b4b0000,0x000000010b500000) space capacity = 327680, 0.0 used
  to   [0x000000010b500000,0x000000010b500000,0x000000010b550000) space capacity = 327680, 0.0 usedInvocations: 0

Gen 1:   old  [0x000000010b550000,0x000000010b5eabd0,0x000000010bc00000) space capacity = 7012352, 9.038451007593459 usedInvocations: 1

输出含义: [0x000000010b200000,0x000000010b20dc68,0x000000010b4b0000) 表示的是分代回收中区(eden、survivor、old gen等)内存地址段,三个地址分别表示段起始、已分配指针、段截止。可以看到GC前h2地址(0x000000010b33d6a8)在eden区,而GC后h2地址(0x000000010b5ea220)落在old gen。

总结

回到标题,hashCode的返回值很明确不仅仅是对象地址。从openjdk源码中可以找到其实现,目前默认用hashCode=5的实现。有兴趣的同学可以试试加上 -XX:+UnlockExperimentalVMOptions -XX:hashCode=2 再输出对象的hashCode

// hotspot/src/share/vm/runtime/synchronizer.cpp
static inline intptr_t get_next_hash(Thread * Self, oop obj) {
  intptr_t value = 0;
  if (hashCode == 0) {
    // This form uses an unguarded global Park-Miller RNG,
    // so it's possible for two threads to race and generate the same RNG.
    // On MP system we'll have lots of RW access to a global, so the
    // mechanism induces lots of coherency traffic.
    value = os::random();
  } else if (hashCode == 1) {
    // This variation has the property of being stable (idempotent)
    // between STW operations.  This can be useful in some of the 1-0
    // synchronization schemes.
    intptr_t addrBits = cast_from_oop<intptr_t>(obj) >> 3;
    value = addrBits ^ (addrBits >> 5) ^ GVars.stwRandom;
  } else if (hashCode == 2) {
    value = 1;            // for sensitivity testing
  } else if (hashCode == 3) {
    value = ++GVars.hcSequence;
  } else if (hashCode == 4) {
    value = cast_from_oop<intptr_t>(obj);
  } else {
    // Marsaglia's xor-shift scheme with thread-specific state
    // This is probably the best overall implementation -- we'll
    // likely make this the default in future releases.
    unsigned t = Self->_hashStateX;
    t ^= (t << 11);
    Self->_hashStateX = Self->_hashStateY;
    Self->_hashStateY = Self->_hashStateZ;
    Self->_hashStateZ = Self->_hashStateW;
    unsigned v = Self->_hashStateW;
    v = (v ^ (v >> 19)) ^ (t ^ (t >> 8));
    Self->_hashStateW = v;
    value = v;
  }

  value &= markOopDesc::hash_mask;
  if (value == 0) value = 0xBAD;
  assert(value != markOopDesc::no_hash, "invariant");
  TEVENT(hashCode: GENERATE);
  return value;
}

参考资料

目录
相关文章
|
1月前
|
缓存 监控 算法
Python内存管理:掌握对象的生命周期与垃圾回收机制####
本文深入探讨了Python中的内存管理机制,特别是对象的生命周期和垃圾回收过程。通过理解引用计数、标记-清除及分代收集等核心概念,帮助开发者优化程序性能,避免内存泄漏。 ####
51 3
|
2月前
|
存储 编译器 Linux
【c++】类和对象(上)(类的定义格式、访问限定符、类域、类的实例化、对象的内存大小、this指针)
本文介绍了C++中的类和对象,包括类的概念、定义格式、访问限定符、类域、对象的创建及内存大小、以及this指针。通过示例代码详细解释了类的定义、成员函数和成员变量的作用,以及如何使用访问限定符控制成员的访问权限。此外,还讨论了对象的内存分配规则和this指针的使用场景,帮助读者深入理解面向对象编程的核心概念。
183 4
|
3月前
|
缓存 算法 Java
JVM知识体系学习六:JVM垃圾是什么、GC常用垃圾清除算法、堆内存逻辑分区、栈上分配、对象何时进入老年代、有关老年代新生代的两个问题、常见的垃圾回收器、CMS
这篇文章详细介绍了Java虚拟机(JVM)中的垃圾回收机制,包括垃圾的定义、垃圾回收算法、堆内存的逻辑分区、对象的内存分配和回收过程,以及不同垃圾回收器的工作原理和参数设置。
124 4
JVM知识体系学习六:JVM垃圾是什么、GC常用垃圾清除算法、堆内存逻辑分区、栈上分配、对象何时进入老年代、有关老年代新生代的两个问题、常见的垃圾回收器、CMS
|
3月前
|
Java 测试技术 Android开发
让星星⭐月亮告诉你,强软弱虚引用类型对象在内存足够和内存不足的情况下,面对System.gc()时,被回收情况如何?
本文介绍了Java中四种引用类型(强引用、软引用、弱引用、虚引用)的特点及行为,并通过示例代码展示了在内存充足和不足情况下这些引用类型的不同表现。文中提供了详细的测试方法和步骤,帮助理解不同引用类型在垃圾回收机制中的作用。测试环境为Eclipse + JDK1.8,需配置JVM运行参数以限制内存使用。
47 2
|
3月前
|
存储 Java
JVM知识体系学习四:排序规范(happens-before原则)、对象创建过程、对象的内存中存储布局、对象的大小、对象头内容、对象如何定位、对象如何分配
这篇文章详细地介绍了Java对象的创建过程、内存布局、对象头的MarkWord、对象的定位方式以及对象的分配策略,并深入探讨了happens-before原则以确保多线程环境下的正确同步。
71 0
JVM知识体系学习四:排序规范(happens-before原则)、对象创建过程、对象的内存中存储布局、对象的大小、对象头内容、对象如何定位、对象如何分配
|
3月前
|
存储 Java
深入理解java对象的内存布局
这篇文章深入探讨了Java对象在HotSpot虚拟机中的内存布局,包括对象头、实例数据和对齐填充三个部分,以及对象头中包含的运行时数据和类型指针等详细信息。
35 0
深入理解java对象的内存布局
|
3月前
|
算法 Java
JVM进阶调优系列(3)堆内存的对象什么时候被回收?
堆对象的生命周期是咋样的?什么时候被回收,回收前又如何流转?具体又是被如何回收?今天重点讲对象GC,看完这篇就全都明白了。
|
2月前
|
缓存 Prometheus 监控
Elasticsearch集群JVM调优设置合适的堆内存大小
Elasticsearch集群JVM调优设置合适的堆内存大小
455 1
|
1月前
|
存储 监控 算法
深入探索Java虚拟机(JVM)的内存管理机制
本文旨在为读者提供对Java虚拟机(JVM)内存管理机制的深入理解。通过详细解析JVM的内存结构、垃圾回收算法以及性能优化策略,本文不仅揭示了Java程序高效运行背后的原理,还为开发者提供了优化应用程序性能的实用技巧。不同于常规摘要仅概述文章大意,本文摘要将简要介绍JVM内存管理的关键点,为读者提供一个清晰的学习路线图。
|
2月前
|
Java
JVM内存参数
-Xmx[]:堆空间最大内存 -Xms[]:堆空间最小内存,一般设置成跟堆空间最大内存一样的 -Xmn[]:新生代的最大内存 -xx[use 垃圾回收器名称]:指定垃圾回收器 -xss:设置单个线程栈大小 一般设堆空间为最大可用物理地址的百分之80