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 内部是数组 + 链表/红黑树:

graph LR subgraph A["数组(桶)"] B["桶 0"] C["桶 1"] D["桶 2"] end B --> B1["元素 1"] B --> B2["元素 2"] C --> C1["元素 3"] style B fill:#FFE4B5 style C fill:#FFE4B5 style D fill:#FFE4B5

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 决策流程

flowchart TD A[新写一个类] --> B{作为 HashMap/HashSet 键?} B -->|否| C[可不重写 hashCode] B -->|是| D[用 final 字段或 Record] D --> E{equals 用哪些字段?} E --> F[hashCode 用同样的字段] F --> G{性能要求高?} G -->|是| H[手写 31 倍数法] G -->|否| I[用 Objects.hash 或 Lombok] style D fill:#90EE90 style F fill:#90EE90

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+)
posted @ 2026-10-10 21:32  RK5123153  阅读(3)  评论(0)    收藏  举报