String-HashCode源码分析
霍纳法则
在看源码前了解下霍纳法则:霍纳法则(Horner's Rule)是一种用于高效计算多项式的算法,通过减少乘法次数来提升计算效率。该法则将多项式从嵌套形式展开,适用于计算机科学和数值分析领域。
算法原理
给定一个多项式: [ P(x) = a_nx^n + a_{n-1}x^{n-1} + \cdots + a_1x + a_0 ] 霍纳法则将其重写为嵌套形式: [ P(x) = a_0 + x(a_1 + x(a_2 + \cdots + x(a_{n-1} + xa_n)\cdots)) ]
String的hashCode()方法使用了一个名为“多项式散列”或“霍纳法则”的算法。它并不是简单地计算字符串的ASCII和,而是通过一个公式 s[0]*31^(n-1) + s[1]*31^(n-2) + ... + s[n-1]
来生成一个int类型的哈希值,并且这个值会被缓存起来以避免重复计算。
源码分析
public final class String {/** Cache the hash code for the string */private int hash; // 默认值为 0public int hashCode() {int h = hash; // 1. 读取缓存的哈希值if (h == 0 && value.length > 0) { // 2. 如果缓存为0(且字符串非空),才进行计算char val[] = value; // 内部的字符数组for (int i = 0; i < value.length; i++) {h = 31 * h + val[i]; // 3. 核心计算:霍纳法则}hash = h; // 4. 计算完成后,将结果存入缓存字段}return h; // 5. 返回计算好或已缓存的哈希值}
}
实现原理分解
1. 缓存机制 (Caching)
String类有一个私有字段
private int hash;
,专门用于缓存计算好的哈希值。首次调用
hashCode()
方法时,因为hash
的初始值是0
,所以会进入计算逻辑。计算完成后,将结果赋值给
hash
字段。之后再次调用
hashCode()
方法时,直接返回缓存的hash
值,避免了重复计算,极大地提升了性能。因为String对象是不可变的,其内容一旦创建就不会改变,所以哈希值也永远不会变,缓存是绝对安全的。
2. 核心算法 (Polynomial Hashing)
算法的数学表达式是:
h(s) = s[0] * 31^(n-1) + s[1] * 31^(n-2) + ... + s[n-2] * 31^1 + s[n-1] * 31^0
在代码中,这个计算通过循环和乘加运算高效完成,应用了霍纳法则(Horner‘s method)进行优化,避免了直接计算高次幂。
h = 31 * h + val[i]
我们以一个简单的字符串 “Hi” 为例,手动计算一下:
初始值
h = 0
第一个字符 ‘H’ (ASCII 值为 72):
h = 31 * 0 + 72
->h = 72
第二个字符 ‘i’ (ASCII 值为 105):
h = 31 * 72 + 105
= 2232 + 105
= 2337
所以字符串 “Hi” 的 hashCode 是 2337。
你可以通过以下代码验证:
System.out.println("Hi".hashCode()); // 输出 2337
3. 为什么选择数字31?
这是一个精心选择的值,主要有以下几个原因:
质数 (Prime Number):31是一个质数。使用质数作为乘数可以帮助减少哈希碰撞(不同字符串产生相同哈希值的概率)。如果使用合数,比如偶数,那么乘以它相当于位移操作,信息更容易丢失。
性能优化 (Performance):
31 * i
可以被JVM优化为(i << 5) - i
。这是一个非常高效的位运算,因为移位和减法操作远比乘法操作快。现代JVM可能已经具备自动优化小整数乘法的能力,但31的选择是历史和经验的最佳实践。碰撞率与分布:经过大量实践和研究,31在各类字符串数据集上都能提供一个较好的哈希分布,碰撞率相对较低。虽然不是完美的(没有任何哈希函数是完美的),但是一个很好的权衡。
重要特性
一致性:只要字符串内容不变,
hashCode()
的返回值在同一个Java程序的一次执行中一定不变。但注意,在不同次执行中,值是否不变并不是Java规范所保证的(尽管当前的实现确实是稳定不变的)。哈希碰撞:不同的字符串有可能产生相同的hashCode,这被称为哈希碰撞。例如
"Aa"
和"BB"
的hashCode都是2112
。因此,在HashMap等使用哈希表的场景中,如果发生碰撞,还需要用equals()
方法进行最终确认。System.out.println("Aa".hashCode()); // 2112 System.out.println("BB".hashCode()); // 2112 System.out.println("Aa".equals("BB")); // false
不可变性的关键作用:正是因为String是不可变的,其哈希值才可以安全地缓存。如果一个类的对象是可变的,并且其hashCode()依赖于可变的状态,那么极不推荐缓存其哈希值,因为状态一变,缓存的哈希值就失效了,会导致基于哈希的集合(如HashMap)出现严重错误。
总结
特性 | 说明 |
---|---|
算法 | 多项式哈希(霍纳法则):h = 31 * h + char[i] //char[i]即string的一个ascii码 |
乘数 | 31(质数,可优化为位操作,碰撞率低) |
缓存 | 计算结果存储在hash 字段中,利用String的不可变性实现高效缓存 |
目的 | 为字符串生成一个分布良好的、唯一的int值标识,用于基于哈希的集合类(如HashMap, HashSet) |
哈希碰撞
hashcode范围是int,根据鸽巢原理原理来说有限的空间上无数个不同字符串肯定会重复,重复即会发生hashcode相同即哈希碰撞。
鸽巢原理 (Pigeonhole Principle)
这是最根本的数学限制:
输入空间是无限的:理论上,可以构造出无限多个不同的字符串。
输出空间是有限的:hashCode()的返回值是
int
类型,只有 $2^{32}$ (大约42.9亿) 个可能的哈希值。
根据鸽巢原理,只要输入的字符串数量超过42.9亿个,必然会发生哈希碰撞(两个不同的字符串拥有相同的哈希值)。而在实际应用中,我们完全可能处理超过42.9亿个字符串。
所以,从数学上讲,保证绝对不重复是绝对不可能的。
当碰撞发生时怎么办?
哈希数据结构(如HashMap
, HashSet
)在设计时就已经预期并处理了碰撞。它们不依赖哈希码的唯一性。
当 HashMap.put(key, value)
或 HashMap.get(key)
时:
计算哈希桶位置:首先调用key的
hashCode()
方法,再通过一个扰动函数处理,最后通过(n - 1) & hash
确定键值对应该存放在哪个桶(数组索引)。处理碰撞:
如果该桶为空:直接放入。
如果该桶不为空(发生了碰撞):会遍历这个桶里所有的元素(节点),并使用
equals()
方法逐个比较。如果找到
equals()
返回true
的key,说明是同一个key,则进行值覆盖。如果所有节点的key都不
equals
,说明是不同的key但哈希值相同(哈希碰撞),则将这个新的键值对添加到链表末尾(或插入红黑树)。
因此,equals()
方法才是最终判断两个键是否相同的终极裁决者。 hashCode()
只是负责快速定位到一个大概的范围(桶)。
实际碰撞示例
"Aa" 和 "BB"
"AaAa" 和 "BBBB"
"AaBB" 和 "BBAa"
"String" 和 "0f"
System.out.println("Aa".hashCode()); // 2112
System.out.println("BB".hashCode()); // 2112