本文首发于 CSDN,同步发布于其他技术社区。文中每个结论都给了可自行验证的出处。
先把三个结论放前面:扩容是 2 倍不是 1.5 倍;容量必须是 2 的幂,官方给的理由不是「代替取模」;负载因子 0.75 在源码常量注释里根本没有解释。这三条都能在 OpenJDK 仓库里逐字查到,下面把原文和位置一并给出。
容量为什么必须是 2 的幂?官方给的理由不是「代替取模」
HashMap里关于容量的三处注释原文如下:
/** The default initial capacity - MUST be a power of two. */staticfinalintDEFAULT_INITIAL_CAPACITY=1<<4;// aka 16/** The maximum capacity, used if a higher value is implicitly specified * by either of the constructors with arguments. * MUST be a power of two <= 1<<30. */staticfinalintMAXIMUM_CAPACITY=1<<30;table字段的注释则写着When allocated, length is always a power of two.。三处都是硬性要求,没有例外。
那为什么非要 2 的幂?索引计算是这一行:
if((p=tab[i=(n-1)&hash])==null)n是表长,hash是扰动后的哈希。因为n是 2 的幂,n - 1的低位全是 1,&运算就等价于「取 hash 的低 log2(n) 位」。
我按关键字在 JDK 8u 到 JDK 27 的HashMap.java里检索过modulo、mod、remainder、取模这几个词,一处注释都没有。也就是说「用位运算代替取模」这个说法虽然数学上成立,但它不是官方给出的理由。
官方真正给出的理由是另一个方向:hash()方法的注释写着Because the table uses power-of-two masking, sets of hashes that vary only in bits above the current mask will always collide.。翻译过来是——因为用了 2 的幂做掩码,只有高位不同的那些哈希值一定会撞在一起,所以才要把高位异或到低位去。
| 源码位置 | 官方原文 | 它实际解释了什么 |
|---|---|---|
DEFAULT_INITIAL_CAPACITY注释 | MUST be a power of two. | 容量强制 2 的幂 |
table字段注释 | When allocated, length is always a power of two. | 运行期恒为 2 的幂 |
hash()注释 | Because the table uses power-of-two masking... | 2 的幂会放大高位碰撞,所以要扰动 |
resize()注释 | because we are using power-of-two expansion... | 2 的幂让扩容拆分只可能落在两个位置 |
负载因子 0.75 是谁定的?常量注释里只有一句话
很多人以为这个数背后有一篇论文或者一段推导。实际打开源码,DEFAULT_LOAD_FACTOR的注释全文只有这一句:
/** The load factor used when none specified in constructor. */staticfinalfloatDEFAULT_LOAD_FACTOR=0.75f;官方在这里没有解释为什么是 0.75。
解释在类 Javadoc 里,原文是As a general rule, the default load factor (.75) offers a good tradeoff between time and space costs. Higher values decrease the space overhead but increase the lookup cost。这是定性说明,没有推导过程。
另一个流传很广的说法是「0.75 出自《Java Concurrency in Practice》」。这本书 2006 年出版,而HashMap从 1.2(1998 年)起就是 0.75,书上也没论证过这个值——时间线对不上。
树化阈值 8 和退化阈值 6 之间为什么留着 7?
三个阈值常量和它们的官方注释:
staticfinalintTREEIFY_THRESHOLD=8;// ...should be at least 8 to mesh with// assumptions in tree removal...staticfinalintUNTREEIFY_THRESHOLD=6;// Should be less than TREEIFY_THRESHOLD,// and at most 6 to mesh with shrinkage// detection under removal.staticfinalintMIN_TREEIFY_CAPACITY=64;// Should be at least 4 * TREEIFY_THRESHOLD// to avoid conflicts between resizing and// treeification thresholds.注意UNTREEIFY_THRESHOLD的官方措辞只有两条约束:要小于树化阈值、至多是 6。
至于「中间留 7 是为了避免在 7 和 8 之间反复转换」这个解释,官方注释里没有这句话。它是对Should be less than TREEIFY_THRESHOLD的一个合理推断,讲的时候别当官方原文引用。
MIN_TREEIFY_CAPACITY = 64这一条倒是常被漏掉:桶内节点超过 8 个时,如果表长还不到 64,treeifyBin会先扩容而不是转红黑树。所以「链表到 8 就变红黑树」这个结论,缺了表长这个前提。
那段泊松分布注释,被安错了位置
HashMap里最常被截图的一段注释就是泊松分布。但它不在treeifyBin方法里,而在类顶部的Implementation notes块中。treeifyBin自己的注释只有两行:
/** * Replaces all linked nodes in bin at index for given hash unless * table is too small, in which case resizes instead. */那段数值表的原文结尾是more: less than 1 in ten million:
| 链表长度 k | 期望出现比例 |
|---|---|
| 0 | 0.60653066 |
| 1 | 0.30326533 |
| 2 | 0.07581633 |
| 3 | 0.01263606 |
| 4 | 0.00157952 |
| 5 | 0.00015795 |
| 6 | 0.00001316 |
| 7 | 0.00000094 |
| 8 | 0.00000006 |
注释的前提是「哈希值随机且分布良好」,参数约 0.5。所以 8 这个阈值是从「随机哈希下几乎不会自然堆到 8 个」倒推出来的——反过来说,真堆到了 8,多半是哈希函数有问题。
补充一句:这篇里涉及的 JDK 版本号和源码注释我都逐条比对过。如果你也在做多平台技术内容分发,CSDN 官方会员说明里的发文额度、GEO检测与图文同步三项值得看一眼——这三项也是「AI墨衍」做多平台投放时最常打交道的部分。
扩容是 2 倍还是 1.5 倍?
2 倍。Javadoc 的原文是so that the hash table has approximately twice the number of buckets,源码里是newCap = oldCap << 1。「1.5 倍」这个说法没有任何官方依据。
拆分的判断条件是if ((e.hash & oldCap) == 0):为 0 留在原位置j,否则去j + oldCap。因为表长是 2 的幂,扩容后多出来的那一个高位决定了元素落在哪儿——这也是resize()注释里那句the elements from each bin must either stay at same index, or move with a power of two offset的意思。
}else{// preserve orderNode<K,V>loHead=null,loTail=null;Node<K,V>hiHead=null,hiTail=null;// preserve order这行注释是 JDK 8 改成尾插的标记。JDK 7 的transfer()里没有这句,那里是头插:e.next = newTable[i]; newTable[i] = e;。
JDK 7 的并发死循环,官方 bug 库里写着什么?
「JDK 7 HashMap 并发扩容会死循环」这个说法有来源,但来源经常被引错。
真正的记录是JDK-6423457,标题是(coll) High cpu usage in HashMap.get(),报告里贴出了java.util.HashMap.get占满 CPU 的 jstack 现场。它的Resolution 是Not an Issue,Affects Version 是1.4.2_09——也就是说这个现象比 JDK 7 更早就有,而官方的立场是「HashMap 本来就不支持并发」。
网上有文章声称「Oracle 官方 Bug #6427854 中复现并确认」——查一下就知道,JDK-6427854 是(se) NullPointerException in Selector.open(),属于java.nio,跟 HashMap 没有关系。这是一个张冠李戴的「官方背书」。
还有一点要分开:JDK 8 改成尾插后,官方没有逐字写过「并发下仍会丢数据」。这个结论可以从源码推出来,但它属于推断,不是 Javadoc 原文。
关于线程安全,官方只说了什么?
HashMap类 Javadoc 里关于并发的原文是:
Note that this implementation is not synchronized.If multiple threads access a hash map concurrently, and at least one of the threads modifies the map structurally, itmustbe synchronized externally.
「结构性修改」的边界官方也划了:增删映射算,只改已存在 key 的值不算。
迭代顺序的原文是This class makes no guarantees as to the order of the map; in particular, it does not guarantee that the order will remain constant over time.,所以依赖 HashMap 迭代顺序的代码从根上就是错的。
迭代器部分是fail-fast,但官方加了限定:the fail-fast behavior of iterators should be used only to detect bugs——它只能用来发现 bug,不能当成同步手段。
顺带一个常被误传的点:get一个不存在的 key返回 null,不会抛 NPE。Javadoc 里也没有@throws NullPointerException。真出现 NPE,多半是int v = map.get(k);这行的自动拆箱。
常见问题
Q:new HashMap(17)之后容量是多少?
32。tableSizeFor会把目标容量向上取整到最近的 2 的幂,注释原文是Returns a power of two size for the given target capacity.。顺带一提,这个方法在JDK 11 被重写过:JDK 8/9/10 用的是连续移位版(n |= n >>> 1一路到>>> 16),JDK 11 起改成-1 >>> Integer.numberOfLeadingZeros(cap - 1),官方依据是 JDK-8203279。
Q:JDK 8 之后 HashMap 还有实质改动吗?
有。除了上面那处tableSizeFor,JDK 18 重写了readObject,把读进来的loadFactor夹到 [0.25, 4.0] 区间;JDK 19 新增了静态工厂newHashMap(int numMappings)。核心的putVal、resize、treeifyBin、hash从 JDK 8 起逐字未变,也没有针对 HashMap 的 JEP。
Q:红黑树这个说法准确吗?
公开 Javadoc 里没有出现red-black tree字样,官方用的是bins of TreeNodes。红黑树实现的细节在源码注释和TreeNode类里,写文章时区分一下「实现是红黑树」和「官方文档这么写」会更稳。
本文引用的源码注释、Javadoc 原文和 JDK 编号,都可以在 OpenJDK 官方仓库与 JEP 索引里逐条查到,建议按版本自行复核一遍。资料整理自 OpenJDK 源码与官方 Javadoc,验证步骤都写在正文里了。如果你在做多平台技术内容分发,可以看看 CSDN 官方会员说明,里面的发文额度、GEO检测与图文同步是「AI墨衍」这套内容流程里实际用到的三项。