Java hashCode 深度解析
目标:彻底讲清四个问题——hashCode 是什么、为什么快、源码怎么实现、自己怎么重写
读者:有 Java 基础,了解集合框架
一、hashCode 是什么
1.1 定义
hashCode() 是 Object 类的 native 方法,返回一个 int 整数。它的作用是为对象生成一个整数指纹,用于在哈希表(HashMap、HashSet、ConcurrentHashMap 等)中定位元素所在的桶。
public native int hashCode();
基本特性:
- 返回
int(32 位,范围 -2^31 到 2^31-1) - 同一对象多次调用,结果应保持稳定(未修改前提下)
- 不同对象允许返回相同值(碰撞是常态)
- 不保证跨 JVM 运行的一致性
1.2 与 equals 的契约
hashCode 不是独立存在的——它与 equals 共同定义对象在集合框架中的行为:
| equals 返回 | hashCode 应该 |
|---|---|
a.equals(b) == true |
a.hashCode() == b.hashCode()(必须) |
a.equals(b) == false |
a.hashCode() == b.hashCode() 是否成立(无要求) |
违反契约会导致 HashMap/HashSet 行为异常(详见 4.1 节)。
1.3 哈希表的存储模型
HashMap 内部是数组 + 链表/红黑树:
hashCode 的作用就是确定元素应该放进哪个桶。所有 hashCode 相同的元素会落在同一个桶内。
寻址方式对比:
| 寻址方式 | 实现 | 时间复杂度 | 典型代表 |
|---|---|---|---|
| 顺序查找 | 不知道位置,挨个遍历 | O(n) | ArrayList.indexOf |
| 二分查找 | 知道排序关系,二分逼近 | O(log n) | 排序数组的 binarySearch |
| 直接寻址 | 知道精确位置,一次访问 | O(1) | 数组下标、HashMap |
核心洞察:使用 hashCode 计算出位置后,HashMap 走的是"直接寻址"——一次位运算 (n - 1) & hash 直接定位到桶,不需要遍历。这正是 O(1) 时间复杂度的根本来源。
原来不知道位置时才需要遍历(顺序查找 O(n)),HashMap 通过
hashCode把"未知位置"转化为"已知位置",避免了遍历。
后续章节讨论 hashCode "为什么快"时,都建立在这个核心洞察之上。
二、为什么 hashCode 让查找变快
2.1 两种查找方式对比
假设有 10 万个用户,要查找 id = "U-087541" 的用户:
方式 1:线性查找(ArrayList)
for (User user : list) { // 10 万次循环
if (user.id.equals("U-087541")) {
return user;
}
}
平均 5 万次比较,最坏 10 万次 → O(n)
方式 2:哈希查找(HashMap)
User user = map.get("U-087541"); // 1 次
HashMap.get() 内部执行 3 步:
| 步骤 | 操作 | 时间复杂度 |
|---|---|---|
| 1. 算 hashCode | 调用 key.hashCode() |
O(1) |
| 2. 定位桶 | (n - 1) & hash 算出下标 |
O(1) |
| 3. 桶内比较 | 桶内逐个 equals |
O(1) ~ O(log n) |
2.2 为什么桶内也是 O(1)
关键是每个桶平均只装几个元素。HashMap 通过动态扩容保证这一点:
- 默认初始 16 桶,负载因子 0.75
- 元素超过 12 时翻倍到 32 桶,超过 24 时变 64 桶……
- 10 万个元素时,HashMap 扩容到 131072 桶(2^17)
- 平均每桶 0.76 个元素 → 桶内比较几乎 1 次命中
但桶数比元素数还多,查找时难道不会先遍历桶吗?
不会。原因:HashMap 不是"遍历所有桶找目标",而是"用 key 直接算出目标桶下标"。
// HashMap.get(key) 的核心逻辑(简化)
int hash = hash(key); // O(1)
int bucketIndex = (n - 1) & hash; // O(1) 位运算
Node<K,V> node = buckets[bucketIndex]; // 直接跳到目标桶
while (node != null && !node.key.equals(key)) { // 只在桶内遍历
node = node.next;
}
return node;
bucketIndex = (n - 1) & hash用 key 的 hashCode 一次位运算算出目标桶下标- 不管有 16 个桶还是 13 万个桶,这一步都是 O(1)
- 不存在"遍历桶"的过程——直接跳到目标桶,桶内再比较
类比:宿舍楼有 1000 间房,住 100 个学生。找某个学生时,不需要挨个敲 1000 个门——根据学号算出房间号(一次除法),直接去那个房间敲门。即使 900 间房是空的,也不增加查找难度。
桶数 > 元素数的对比:
| 桶数 | 每桶平均元素 | 桶内比较次数 | 总效果 |
|---|---|---|---|
| 16(不扩容) | 6250 | 几千次 | 退化为 O(n) |
| 131072(扩容后) | 0.76 | 1 次 | 理想 O(1) |
桶数越多,每个桶越稀疏,哈希冲突越少,桶内比较越快。桶的总数只决定"目标桶怎么算",不决定"要遍历多少个桶"。
2.3 时间复杂度对比
| 查找方式 | 数据结构 | 时间复杂度 | 10 万数据实际比较 |
|---|---|---|---|
| 顺序查找 | ArrayList | O(n) | 最多 10 万次 |
| 二分查找 | 排序数组 | O(log n) | 17 次 |
| 哈希查找 | HashMap | O(1) | 1-2 次 |
这就是 hashCode 让查找变快的本质——它把搜索空间从 n 压缩到 1。
三、源码实现原理
3.1 Object.hashCode 默认实现
Object.hashCode() 是 native 方法,由 JVM 底层用 C++ 实现:
public native int hashCode();
HotSpot 的实现(src/hotspot/share/runtime/synchronizer.cpp):
intptr_t get_next_hash(Thread* self, oop obj) {
intptr_t value = 0;
if (hashCode == 0) {
// 第一次调用时用 Marsaglia's xor-shift scheme
value = os::random();
value ^= (value >> 16);
value *= 0x45d9f3b;
value ^= (value >> 16);
// 存到对象头的 mark word 中
}
return value;
}
关键机制:
- 懒生成:第一次调用
hashCode()时才计算 - 存到对象头:mark word 有 25 bit 空闲空间,后续直接返回
- 25 bit 空间:只能存 2^25 = 33,554,432 个不同值(超过会截断)
- 基于随机数:不同对象 hashCode 大概率不同,但不保证唯一
默认实现的局限:基于对象身份,不反映业务内容——两个 id 相同但实例不同的 User 对象,hashCode 不同。
3.2 String.hashCode 实现
OpenJDK 源码(String.java):
private int hashCode; // 默认 0 表示未缓存
public int hashCode() {
int h = hash;
if (h == 0 && value.length > 0) {
char val[] = value;
for (int i = 0; i < value.length; i++) {
h = 31 * h + val[i];
}
hash = h;
}
return h;
}
多项式哈希公式:
hash = s[0] * 31^(n-1) + s[1] * 31^(n-2) + ... + s[n-1]
以 "abc" 为例:
- h = 0
- h = 31 × 0 + 'a' = 97
- h = 31 × 97 + 'b' = 3105
- h = 31 × 3105 + 'c' = 96354
两个优化:
- 延迟缓存:第一次调用计算并存到
hash字段,后续直接返回 h == 0哨兵:用 0 表示未缓存,前提是 hashCode 几乎不可能等于 0
0 哨兵的安全前提:
31 * h + val[i] 里 val[i] 是字符(必然 ≥ 1),所以非空串的 hashCode 永远 ≥ 1。
唯一例外是空串 ""——hashCode 就是 0。所以 String 用 value.length > 0 守住:空串直接跳过计算,永远返回 0(反正没字符要算,返回 0 是对的)。
自定义类不能照搬:
class Point {
int x, y;
private int hashCode; // 默认 0
@Override
public int hashCode() {
if (hashCode == 0) { // "没算过"还是"算过恰好是 0"?
hashCode = 31 * x + y; // x=0, y=0 时结果 = 0
}
return hashCode;
}
}
new Point(0, 0) 第一次算出 0,存为 0;第二次看到 hashCode == 0 又走进来重算——缓存永远不生效。
通用方案:
方案 A:boolean 标志位
private int hashCode;
private boolean cached;
@Override
public int hashCode() {
if (!cached) {
hashCode = 31 * x + y;
cached = true;
}
return hashCode;
}
方案 B:不可变对象构造时算好(最干净)
class Point {
private final int x, y;
private final int hashCode; // 构造时算好,final 字段
public Point(int x, int y) {
this.x = x;
this.y = y;
this.hashCode = 31 * x + y;
}
@Override
public int hashCode() { return hashCode; } // 零判断,直接返回
}
不可变对象根本不需要缓存逻辑——字段不变 → hashCode 不变 → 构造时算一次就够了。
3.3 HashMap 中的扰动函数
HashMap.hash():
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
为什么需要扰动? HashMap 用 (n - 1) & hash 算桶下标。当 n = 16 时:
n - 1 = 15(二进制0000 1111)hash & 15只用到了 hash 的低 4 位- hash 的高 28 位信息被浪费
扰动函数 h ^ (h >>> 16) 把高 16 位与低 16 位异或,让高位信息参与桶定位。在 16-65536 桶区间内,扰动函数对性能至关重要。
3.4 桶内结构演进
Java 8 之前,HashMap 桶内是链表。当某个桶内元素过多(≥ 8)且数组长度 ≥ 64 时,自动转为红黑树:
static final int TREEIFY_THRESHOLD = 8;
static final int UNTREEIFY_THRESHOLD = 6;
static final int MIN_TREEIFY_CAPACITY = 64;
| 桶内元素数 | 数据结构 | 桶内查找 |
|---|---|---|
| 0-1 | 直接命中 | O(1) |
| 2-7 | 链表 | O(n) |
| ≥ 8 | 红黑树 | O(log n) |
设计动机:正常情况下冲突少,链表就够(常数小、缓存友好);极端情况(恶意 hashCode 攻击)下,红黑树防止性能崩溃。
四、如何重写 hashCode
4.1 为什么重写
先理解两个集合类:
- HashSet:不允许重复元素的集合,添加两次"相同"的元素,集合里只留一份
- HashMap:键值对集合,通过 key 存取值。如果 put 一个键,再 get 同一个键,应该拿到刚才 put 的值
两者内部都依赖 hashCode() 定位元素(详见 1.3 节)。
Object 默认的 hashCode() 基于对象身份(每个对象都不一样),不反映业务内容。
默认实现的 bug:
class User {
Long id; // 业务上区分两个 User 的字段
}
User a = new User(1L); // 业务:id=1
User b = new User(1L); // 业务:id=1
System.out.println(a.hashCode() == b.hashCode()); // false ✗ 业务相等但 hashCode 不等
实际后果演示:
// HashSet 的"去重"——相同业务内容的元素应该只留一份
Set<User> set = new HashSet<>();
set.add(new User(1L)); // 集合:[User(1)]
set.add(new User(1L)); // 集合:[User(1), User(1)] ✗ 应该只剩 1 个
System.out.println(set.size()); // 输出:2 ✗ 业务去重失败
// HashMap 的"通过 key 找 value"——put 后 get 应该拿到 value
Map<User, String> map = new HashMap<>();
map.put(new User(1L), "张三");
String value = map.get(new User(1L)); // 应该返回 "张三"
System.out.println(value); // 输出:null ✗ 找不到刚 put 的值
根因:hashCode 不同 → 进不同桶 → equals 不会被调用(HashSet/HashMap 认为它们是两个不同对象)。
重写的目标:让 hashCode 反映业务内容——业务上相等的对象返回相同 hashCode。
4.2 两条铁律
| 铁律 | 说明 | 违反后果 |
|---|---|---|
| 字段一致 | equals 用什么字段,hashCode 就用什么字段 | 业务相等的对象进不同桶,去重失败 |
| 字段不可变 | 参与 hashCode 的字段必须是 final(或对象不可变) |
字段修改后 hashCode 变化,HashMap 找不到键 |
铁律 1 的反例:
class User {
Long id;
String name;
@Override
public boolean equals(Object o) {
return Objects.equals(id, ((User) o).id); // 只用 id
}
@Override
public int hashCode() {
return Objects.hash(name); // 错用 name
}
}
两个 id=1, name=张三 和 id=1, name=李四 的对象,equals 相等但 hashCode 不同。HashSet 不会去重。
铁律 2 的反例:
class Person {
private String name;
private int age;
public Person(String name, int age) {
this.name = name;
this.age = age;
}
public void birthday() { age++; } // 正常的业务方法
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Person)) return false;
Person p = (Person) o;
return age == p.age && Objects.equals(name, p.name);
}
@Override
public int hashCode() {
return Objects.hash(name, age);
}
}
Person p = new Person("张三", 25);
Set<Person> set = new HashSet<>();
set.add(p); // 假设放进桶 7
p.birthday(); // age: 25 → 26(正常的业务操作)
set.contains(p); // 返回 false ✗ 找不到!
birthday() 是业务上完全合理的操作,调用方没有"修改 key"的意图——但 set.contains(p) 却找不到。这就是字段可变的隐蔽陷阱:实体被多个服务共享、ORM 重新加载、并发修改都可能触发这个问题。
4.3 实战:3 种实现方式
示例场景:一个 User 类,有 id、name 两个字段,按 id 和 name 判定相等。
方式 1:手写 31 倍数法(极致性能)
public class User {
private final Long id;
private final String name;
public User(Long id, String name) {
this.id = id;
this.name = name;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof User)) return false;
User u = (User) o;
return Objects.equals(id, u.id) && Objects.equals(name, u.name);
}
@Override
public int hashCode() {
int result = id.hashCode(); // 第一个字段作初始值
result = 31 * result + name.hashCode(); // 引用类型:直接 hashCode(name 已 final,不为 null)
return result;
}
}
关键点:
id和name参与 equals → 也参与 hashCode(字段一致铁律)- 所有字段
final→ 满足不可变铁律
方式 2:Objects.hash(简洁但有性能成本)
JDK 提供的工具方法:
@Override
public int hashCode() {
return Objects.hash(id, name); // 可变参数语法
}
源码追踪:
Objects.hash 内部:
// java.util.Objects
public static int hash(Object... values) {
return Arrays.hashCode(values); // 把可变参数当 Object[] 处理
}
Arrays.hashCode 内部:
public static int hashCode(Object[] a) {
int result = 1; // 注意:初始值是 1,不是 0
for (Object element : a) {
result = 31 * result + (element == null ? 0 : element.hashCode());
}
return result;
}
和手写 31 倍数法的对比:
手写:
int result = id.hashCode(); // 第一个字段作初始值
result = 31 * result + name.hashCode(); // 第二个字段
Objects.hash(id, name) 展开后等价于:
Object[] values = {id, name}; // 1 次堆分配
int result = 1;
for (Object element : values) { // 多一次循环开销
result = 31 * result + element.hashCode();
}
性能开销的来源:
- 堆数组分配:每次调用都
new Object[],增加 GC 负担 - 基本类型装箱:
int会自动装箱成Integer,多一次对象创建 - 多一次循环:手写是 N-1 次乘法,
Objects.hash是 N 次(初始值 1 也参与一次)
适用场景:
- 字段少(≤ 5 个):分配开销可接受
- 业务逻辑代码:性能不是关键瓶颈
- 不希望重写时手工算初始值和累加
方式 3:Record(Java 14+,不可变值对象首选)
public record User(Long id, String name) {}
Record 是什么:
Java 14 引入的语法糖,专为"数据载体"设计。一行代码等同于下面这个完整类:
public final class User extends Record {
private final Long id;
private final String name;
public User(Long id, String name) {
this.id = id;
this.name = name;
}
public Long id() { return id; } // 注意:访问器是 id() 不是 getId()
public String name() { return name; }
// 编译器自动生成的 equals/hashCode/toString
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof User)) return false;
User u = (User) o;
return Objects.equals(id, u.id) && Objects.equals(name, u.name);
}
@Override
public int hashCode() {
int result = 1;
result = 31 * result + id.hashCode();
result = 31 * result + name.hashCode();
return result;
}
@Override
public String toString() {
return "User[id=" + id + ", name=" + name + "]";
}
}
Record 的限制:
- 所有组件(字段)自动
final—— 不可变 - 不能继承其他类(隐式继承
Record) hashCode/equals基于所有组件 —— 不能排除某些字段
关键限制举例:
如果 User 还有 email 字段,但不参与 equals:
public record User(Long id, String name, String email) {}
// 等价于:email 也参与 equals 和 hashCode
// 这与"id+name 判定相等"的需求冲突
业务上 id 相同就是同一个人,但 email 改了应该还是同一个人——Record 做不到这点。这种情况必须退回方式 1 或方式 2。
适用场景:
- DTO / VO:数据传输对象,所有字段都参与业务标识
- 配置类:常量配置
- 函数返回值:多字段返回值打包
方式对比
| 方式 | 性能 | 简洁度 | 字段控制 | 推荐场景 |
|---|---|---|---|---|
| 手写 31 倍数 | 最快 | 一般 | 完全控制 | 性能敏感 |
| Objects.hash | 中等 | 简洁 | 完全控制 | 快速开发 |
| Record | 编译期生成 | 最简洁 | 自动包含所有组件 | 不可变值对象 |
| Lombok | 编译期生成 | 简洁 | 注解配置 | Lombok 项目 |
4.4 为什么是 31
方式 1 里反复出现 31 * result + ...,为什么是 31?
result = 31 * result + field;
数学性质:
- 奇数:保证乘法不丢失最低位信息
- 反例:若用
32(偶数),32 × 任意整数的最低位永远为 0 → 浪费 1 bit
- 反例:若用
- 素数:与任意数互质 → 多字段组合时哈希分布更均匀
JVM 优化:
31 = 2^5 - 1,因此31 * x可被 HotSpot 优化为(x << 5) - x- 这是移位 + 减法指令,比普通乘法快
- 选 37、41 等其他素数没这个优化
经验验证:
- Joshua Bloch(《Effective Java》作者、Java 集合框架作者)经过大量实验验证
- 31 与 37、41 分布质量相当,但 31 更快
总结:31 是数学、JVM、性能三层因素的最优解,JDK 多年沿用至今。
4.5 常见错误
错误 1:返回常量
@Override
public int hashCode() { return 1; }
所有对象进同一桶 → 退化为 O(n)。绝对不要。
错误 2:使用可变字段
见 4.2 铁律 2 反例。修复:字段加 final 或用 Record。
错误 3:equals 和 hashCode 字段不一致
见 4.2 铁律 1 反例。修复:两个方法用同样的字段集合。
五、总结
5.1 四个核心问题
| 问题 | 答案 |
|---|---|
| hashCode 是什么 | 对象的整数指纹,用于在哈希表中定位元素所在的桶 |
| 为什么快 | O(1) 定位桶 + O(1) 桶内查找,把搜索空间从 n 压缩到 1 |
| 源码怎么实现 | HotSpot 默认用对象头 mark word 存随机数;String 用 31 倍数多项式 + 延迟缓存 |
| 怎么重写 | 31 倍数法是经典,Objects.hash 适合简单场景,Record 是不可变对象首选 |
5.2 决策流程
5.3 一句话总结
hashCode = 对象在哈希表中的"桶定位指纹"。重写 equals 必须同时重写 hashCode 且字段一致;31 倍数法是经验证的经典实现,Record 是不可变对象首选。
5.4 延伸阅读
- 《Effective Java》第 3 版 第 11 条:hashCode 编写的权威指南
- OpenJDK 源码:
String.java、HashMap.java、Objects.java、synchronizer.cpp - JEP 384:Records(Java 14+)

浙公网安备 33010602011771号