喵手头像
都 2026 年了,你真的搞清楚 HashMap 在 JDK 1.7 和 1.8 之间到底变了什么吗?封面图

都 2026 年了,你真的搞清楚 HashMap 在 JDK 1.7 和 1.8 之间到底变了什么吗?

文章配图

㊗️本期内容已收录至专栏《Java核心实操(进阶版)》,持续完善知识体系与项目实战,建议先订阅收藏,后续查阅更方便~
㊙️本期难度指数:⭐⭐⭐
🉐福利: 一次订阅后,专栏内的所有文章可永久免费看,持续更新中,保底1000+(篇)硬核实战内容。

前言:从一次线上事故说起

那是某年某月,一个凌晨三点的生产环境。

我盯着监控大屏,CPU 飙到 100%,某个服务线程全卡死,日志里一堆 java.lang.OutOfMemoryError,看得我头皮发麻。排查到最后,定位到的罪魁祸首是——几个线程同时往一个 HashMap 里写数据,触发了扩容,然后形成了死循环。

对,你没看错。HashMap 在多线程扩容的时候,会形成链表死循环,然后 CPU 就永远转圈圈了,永不停止,堪称死亡螺旋。

那是 JDK 1.7 的年代。

后来升到 JDK 1.8,这个问题消失了,但并不是说 HashMap 就线程安全了——它只是换了一种方式"坑"你。

所以这篇文章就是我在踩完这些坑、翻完 OpenJDK 源码之后,用我自己的话,把 JDK 1.7 和 JDK 1.8 的 HashMap 到底有哪些区别、为什么要改、改了之后有什么影响,全都讲清楚。

适合人群:

  • 刚接触 Java 集合框架、听说过 HashMap 但没深入研究的同学
  • 面试前临时抱佛脚想搞清楚这道经典题的同学
  • 像我一样在生产环境踩过坑、想彻底搞明白底层原因的同学

全文会从数据结构、哈希算法、插入方式、扩容机制、线程安全问题五个维度展开,每个维度都会有代码和图解辅助。准备好了,咱们开始。

一、先搞清楚 HashMap 的基本骨架

在聊 1.7 和 1.8 的区别之前,先把 HashMap 的基本设计逻辑梳理一遍,不然后面讲变化的时候容易摸不着头脑。

1.1 HashMap 是个什么东西?

说人话就是:一个允许你用任意类型的键来存取值的容器,底层通过哈希表实现快速的存取操作,平均时间复杂度是 O(1)。

用生活化的比喻来说,你可以把 HashMap 想象成一个超大型的快递柜:

  • 每个格子有一个编号(数组下标)
  • 你寄快递的时候,系统根据你的手机号(键)算出一个格子编号(哈希值),然后把快递放进去
  • 取的时候一样,输入手机号,算出格子编号,直接开柜

但问题来了:不同的手机号可能算出同一个格子编号——这就是哈希冲突(Hash Collision)。

解决哈希冲突的方式,HashMap 用的是链地址法(Separate Chaining)——同一个格子里放不下了,就在格子后面挂一条链表,新来的往链表上加。

这是 HashMap 的基础逻辑,在 JDK 1.7 和 1.8 中都一样,区别在于:当链表越来越长的时候,该怎么处理?

二、JDK 1.7 的实现:数组 + 链表

2.1 数据结构

JDK 1.7 的 HashMap,底层是一个 Entry<K, V>[] 数组,每个 Entry 是链表的节点,包含 key、value、hash、next 四个字段。

HashMap内部数组 table[]

table[0]: null

table[1]: Entry{k1,v1}

table[2]: Entry{k2,v2} -> Entry{k3,v3} -> Entry{k4,v4}

table[3]: null

table[4]: Entry{k5,v5} -> Entry{k6,v6}

...

这张图就是 JDK 1.7 HashMap 的真实状态:大部分位置是 null,有些位置只挂了一个节点,有些位置因为哈希冲突,挂了一条长长的链表。

2.2 哈希算法:对扰动函数的执念

JDK 1.7 对 key 的 hashCode 做了四次扰动,目的是让哈希分布更均匀,减少冲突:

// JDK 1.7 的 hash 方法
static int hash(int h) {
    h ^= (h >>> 20) ^ (h >>> 12);
    return h ^ (h >>> 7) ^ (h >>> 4);
}

// 然后通过这个方法计算数组下标
static int indexFor(int h, int length) {
    return h & (length - 1);  // 等价于 h % length,但位运算更快
}

说实话,第一次看到这段代码的时候我是懵的。这几个右移和异或操作,粗看像是乱写的,其实背后是有数学依据的——通过多次移位和异或,把高位的信息"混入"低位,这样在 & (length - 1) 的时候,高位信息也能参与到下标计算中,减少碰撞概率。

2.3 插入方式:头插法

JDK 1.7 往链表里插入新节点,用的是头插法——每次都把新节点放到链表的头部,原有的节点往后挪。

为什么用头插法?当年的设计者觉得:新插入的数据被访问的概率更高(局部性原理),放在头部可以更快被找到。

这个逻辑听起来没问题,但问题藏在扩容的时候……等我们讲到多线程问题那节再展开。

// JDK 1.7 的 addEntry 和 createEntry(简化版)
void addEntry(int hash, K key, V value, int bucketIndex) {
    if ((size >= threshold) && (null != table[bucketIndex])) {
        resize(2 * table.length);  // 扩容
        // ...重新计算 hash 和 bucketIndex
    }
    createEntry(hash, key, value, bucketIndex);
}

void createEntry(int hash, K key, V value, int bucketIndex) {
    Entry<K,V> e = table[bucketIndex];
    // 头插法:新节点的 next 指向原来的头节点
    table[bucketIndex] = new Entry<>(hash, key, value, e);
    size++;
}

2.4 扩容机制

JDK 1.7 扩容的触发条件:当 size >= threshold(threshold = capacity × loadFactor,默认 16 × 0.75 = 12)且当前 bucket 非空时,触发扩容,容量翻倍。

扩容时,每一个元素都要重新计算哈希值和新数组下标,然后再把元素重新插入新数组。这个过程叫 rehash。

// JDK 1.7 的 transfer 方法(扩容核心逻辑,简化版)
void transfer(Entry[] newTable, boolean rehash) {
    int newCapacity = newTable.length;
    for (Entry<K,V> e : table) {
        while(null != e) {
            Entry<K,V> next = e.next;
            if (rehash) {
                e.hash = null == e.key ? 0 : hash(e.key);
            }
            int i = indexFor(e.hash, newCapacity);
            // 头插法!新数组里也是头插
            e.next = newTable[i];
            newTable[i] = e;
            e = next;
        }
    }
}

注意那个头插法——单线程下没问题,多线程下这就是灾难的起源。后面重点讲。

三、JDK 1.8 的进化:数组 + 链表 + 红黑树

3.1 最大的变化:引入红黑树

JDK 1.8 对 HashMap 做了重大重构,最显眼的变化是:当链表长度超过 8,且数组长度达到 64 时,链表会转换成红黑树。

为什么要引入红黑树?

链表查找的时间复杂度是 O(n),如果哈希冲突严重,链表越来越长,性能就越来越差。红黑树的查找复杂度是 O(log n),在链表比较长的场景下,性能提升非常明显。

但是红黑树的维护成本也比链表高,节点数少的时候,用链表反而更轻量。所以 JDK 1.8 选了一个阈值:8。这个值的选取基于泊松分布的计算——在默认负载因子(0.75)的情况下,一个 bucket 里链表长度超过 8 的概率约为 0.00000006,也就是说正常情况下很少会触发树化,只有在哈希函数特别差或者恶意攻击的情况下才会出现。

是

否

是

否

是

否

是

否

put(key, value)

计算哈希值
确定bucket位置

bucket是否为空?

直接插入节点

当前节点是
红黑树节点?

按红黑树方式插入

遍历链表

找到相同key?

更新value

链表尾部插入

链表长度 >= 8
且数组长度 >= 64?

链表转红黑树 treeifyBin()

保持链表结构

size++,检查是否需要扩容

3.2 数据结构变化

节点类型从 Entry 变成了 Node(正常情况)和 TreeNode(树化之后):

// JDK 1.8 的 Node 类
static class Node<K,V> implements Map.Entry<K,V> {
    final int hash;
    final K key;
    V value;
    Node<K,V> next;
    // ...
}

// JDK 1.8 的 TreeNode 类(继承自 LinkedHashMap.Entry,再继承自 Node)
static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
    TreeNode<K,V> parent;  // 父节点
    TreeNode<K,V> left;    // 左子节点
    TreeNode<K,V> right;   // 右子节点
    TreeNode<K,V> prev;    // 用于删除时维护链表
    boolean red;           // 颜色标志
    // ...
}

3.3 哈希算法简化

JDK 1.8 把原来的四次扰动简化成了一次扰动:

// JDK 1.8 的 hash 方法
static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

只做了一次右移 16 位再异或。为什么够用了?因为 JDK 1.8 引入了红黑树,即使冲突稍多一点,也能保证查询性能,不需要像 1.7 那样用复杂的扰动来极力减少冲突。

另外,null 键会被映射到下标 0,这一点两个版本都一样。

3.4 插入方式:尾插法

这是 JDK 1.8 一个非常关键的改变:链表的插入方式从头插法改成了尾插法。

新节点不再插到链表头部,而是插到链表尾部。这个改变看似微小,却直接解决了多线程扩容时死循环的问题(虽然并没有让 HashMap 变成线程安全的,但至少不会死循环了)。

// JDK 1.8 的 putVal 方法(链表插入部分,简化版)
for (int binCount = 0; ; ++binCount) {
    if ((e = p.next) == null) {
        // 尾插法!
        p.next = newNode(hash, key, value, null);
        if (binCount >= TREEIFY_THRESHOLD - 1) // TREEIFY_THRESHOLD = 8
            treeifyBin(tab, hash);  // 转红黑树
        break;
    }
    if (e.hash == hash &&
        ((k = e.key) == key || (key != null && key.equals(k))))
        break;
    p = e;
}

3.5 扩容优化:高低位拆分

JDK 1.8 扩容时有一个非常精妙的优化,不再需要像 1.7 那样每个元素都重新计算哈希值。

逻辑是这样的:数组容量始终是 2 的幂次(设原容量为 n),扩容后变成 2n。元素在新数组中的位置,要么还在原来的下标 i,要么在 i + n。判断的依据是元素的哈希值的第 log2(n) + 1 位(也就是新增的那一位掩码位)是 0 还是 1。

// JDK 1.8 扩容的核心判断逻辑(简化版)
// e.hash & oldCap 如果为 0,新下标 = 原下标
// e.hash & oldCap 如果为 1,新下标 = 原下标 + oldCap
do {
    next = e.next;
    if ((e.hash & oldCap) == 0) {
        // 放到低位链表(lo 链表),位置不变
        if (loTail == null) loHead = e;
        else loTail.next = e;
        loTail = e;
    } else {
        // 放到高位链表(hi 链表),位置 = 原位置 + 原容量
        if (hiTail == null) hiHead = e;
        else hiTail.next = e;
        hiTail = e;
    }
} while ((e = next) != null);

这个优化有两个好处:

  1. 不需要重新计算哈希值,性能更好
  2. 维持了链表的原有顺序(因为是尾插),不会出现链表反转

扩容后 capacity=16

扩容前 capacity=8

hash & 8 == 0

hash & 8 != 0

hash & 8 != 0

index 0

hash:01001 key:A

hash:10001 key:B

hash:11001 key:C

index 0 (bit=0)

key:A hash:01001

index 8 (bit=1)

key:B hash:10001

key:C hash:11001

四、核心区别汇总对比

到这里,核心的变化已经全部讲完了,来个表格对比一下,方便记忆:

对比维度JDK 1.7JDK 1.8
底层数据结构数组 + 链表数组 + 链表 + 红黑树
节点类型EntryNode / TreeNode
哈希扰动次数4次(复杂扰动)1次(简单扰动)
链表插入方式头插法尾插法
扩容 rehash每个元素重新 hash高低位拆分,无需重新 hash
树化阈值无链表长度 ≥ 8 且数组长度 ≥ 64
退化为链表阈值无树节点数 ≤ 6 时退化
并发死循环风险有(扩容时头插导致)无(但仍然不是线程安全)

五、最容易被忽视的坑:线程安全问题

这部分是整篇文章最重要的实战内容。我在前言里提到的那次线上事故,就出在这里。

5.1 JDK 1.7 的死循环问题

先用图来理解 JDK 1.7 扩容时为什么会死循环。

假设当前链表是 A -> B -> null,两个线程同时触发扩容:

共享内存 线程2 线程1 共享内存 线程2 线程1 原链表: A ->> B ->> null 扩容后(头插法): B ->> A ->> null ⚠️ 形成环: A ->> B ->> A ->> B... 读取 e=A, next=B 线程1被挂起(时间片用完) 完成整个扩容过程 恢复执行 头插A到新table: A ->> null next=B, 继续处理B B.next = A (因为扩容后B.next指向A) 头插B: B ->> A ->> null next = A (B.next在扩容前是A) 处理A: A.next已指向B

核心原因:头插法在扩容时会反转链表顺序,两个线程并发执行时,会形成一个指针环,导致后续的 get 操作陷入无限循环,CPU 100%。

5.2 JDK 1.8 的改进与残留问题

JDK 1.8 改成尾插法之后,扩容时不再反转链表,所以不会再出现死循环。但是——

HashMap 在 JDK 1.8 中仍然不是线程安全的!多线程并发 put 时,可能会出现数据丢失:

// 这是一个反面教材!千万不要在多线程环境下这样用!
HashMap<String, Integer> map = new HashMap<>();

// 线程1和线程2同时执行 put,且两个 key 映射到同一个 bucket
// 当两个线程同时判断 (p.next == null) 为 true
// 都执行 p.next = newNode(...)
// 其中一个线程的写入会被覆盖,数据直接丢失!

另外,JDK 1.8 的扩容过程中,多线程可能导致 table 引用和 size 不一致,最终也会出现各种稀奇古怪的问题,比如 get 返回 null(明明已经 put 过了)。

5.3 正确的多线程方案

老实说,工作这么多年,我见过太多把 HashMap 用在多线程场景的代码了。这里给出几个正确姿势:

方案一:使用 ConcurrentHashMap(推荐)

import java.util.concurrent.ConcurrentHashMap;

// 直接替换,API 基本一致
ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>();

// JDK 1.7: 分段锁(Segment)
// JDK 1.8: CAS + synchronized,锁的粒度更细(只锁链表/树的头节点)
map.put("user_count", 100);
Integer count = map.get("user_count");

// 原子操作:不存在才插入
map.putIfAbsent("session_id", 1);

// 原子操作:计算并更新
map.compute("visit_count", (key, oldVal) -> oldVal == null ? 1 : oldVal + 1);

方案二:Collections.synchronizedMap(不推荐,性能差)

Map<String, String> syncMap = Collections.synchronizedMap(new HashMap<>());
// 所有操作都加了 synchronized,并发性能很差
// 而且遍历的时候还需要手动加锁,否则依然不安全
synchronized (syncMap) {
    for (Map.Entry<String, String> entry : syncMap.entrySet()) {
        // 遍历逻辑
    }
}

方案三:Hashtable(别用了,太老了)

// Hashtable 的所有方法都加了 synchronized
// 性能比 Collections.synchronizedMap 还差
// 而且继承了很多历史遗留问题,现代项目别用这个了
Hashtable<String, String> table = new Hashtable<>();

结论:多线程场景,老老实实用 ConcurrentHashMap。

六、红黑树的那些细节

红黑树这块很多文章一笔带过,但如果面试被问到"为什么用红黑树而不用 AVL 树",不提前想清楚可能会答错。

6.1 红黑树 vs AVL 树

AVL 树是严格平衡的二叉搜索树,左右子树高度差不超过 1,查找效率极高,但维护平衡的代价也大——每次插入删除都可能触发大量旋转操作。

红黑树是"近似平衡"的,不要求严格平衡,插入和删除时旋转次数更少(最多 3 次旋转)。对于 HashMap 这种读写都频繁的场景,红黑树的综合性能更优。

红黑树示例 (HashMap内的 bucket)

黑节点: 7

红节点: 3

黑节点: 11

黑节点: 1

黑节点: 5

红节点: 9

红节点: 13

6.2 树化与反树化阈值为什么是 8 和 6?

  • 树化阈值 8:泊松分布计算的结果,正常情况下链表长度超过 8 的概率极低(约 6×10⁻⁸),只有哈希函数很差或者数据刻意构造的时候才会发生。
  • 反树化阈值 6:并不是 7,而是 6,为了防止频繁在链表和红黑树之间来回转换——如果树化和反树化的阈值一样,比如都是 8,那么删除一个元素变成 7 就退化成链表,再插入一个变成 8 又树化,这就是性能抖动,很蠢。设置一个差值(8 - 6 = 2)来做缓冲,是很合理的设计。
// JDK 1.8 中相关的常量定义
static final int TREEIFY_THRESHOLD = 8;    // 树化阈值
static final int UNTREEIFY_THRESHOLD = 6;  // 反树化阈值
static final int MIN_TREEIFY_CAPACITY = 64; // 最小树化数组容量

注意 MIN_TREEIFY_CAPACITY = 64 这个常量——如果数组长度还不到 64,即使链表长度超过 8,也不会树化,而是选择扩容。这是因为当数组长度比较小的时候,扩容可以更有效地减少哈希冲突,没必要急着树化。

七、从实战角度:如何用好 HashMap?

光知道原理还不够,工程上怎么用才是关键。

7.1 初始化容量的设置

这个细节很多人忽略,但可能对性能有影响。

如果你已经知道 HashMap 里大概会放多少元素,应该在初始化的时候就指定容量,避免频繁扩容:

// 反面教材:不指定初始容量
Map<String, Object> userInfo = new HashMap<>();
// 随着数据增加,会触发多次扩容(每次扩容都要 rehash,性能开销大)

// 正确做法:预估好数量,除以负载因子,向上取整到 2 的幂
// 比如预计存 100 个元素:100 / 0.75 = 134,所以初始化 256(2^8)
Map<String, Object> userInfoOptimized = new HashMap<>(256);

// 或者用 Guava 提供的工具方法,它帮你算好了
// Maps.newHashMapWithExpectedSize(100)
// 等价于 new HashMap<>(147),内部按 expectedSize / 0.75 + 1 计算

7.2 Key 的设计原则

HashMap 的 key 需要正确实现 hashCode() 和 equals() 方法,这几乎是个老生常谈的点,但踩坑的人依然不少:

// 反面教材:用可变对象作为 key
public class MutableKey {
    private String name;
    // 省略 hashCode 和 equals,都基于 name
}

Map<MutableKey, String> map = new HashMap<>();
MutableKey key = new MutableKey("Alice");
map.put(key, "工程师");

key.setName("Bob");  // 修改了 key 的属性!
// 此时 key 的 hashCode 变了,但它在 map 里存的位置没变
// get 操作用新的 hashCode 找,找到错误的 bucket,返回 null
String value = map.get(key);  // null!!!
System.out.println(value);    // 数据"丢失"了,其实还在,只是找不到了

最佳实践:用不可变对象作为 key,比如 String、Integer 等包装类,或者你自己定义的 final 类。

// 正确做法:用不可变的 String 作为 key
Map<String, UserInfo> userMap = new HashMap<>();
userMap.put("user_001", new UserInfo("Alice", 28));
// String 是不可变的,hashCode 不会变,安全

7.3 一个完整的电商场景示例

结合一个真实场景来展示 HashMap 的合理用法——一个简单的商品库存管理模块:

import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

/**
 * 商品库存管理服务
 * 单机版用 HashMap,分布式场景需要换成 Redis
 */
public class InventoryService {

    // 单线程操作用 HashMap(比如批量导入场景)
    private final HashMap<String, Integer> localCache;

    // 多线程操作用 ConcurrentHashMap(比如并发下单场景)
    private final ConcurrentHashMap<String, Integer> stockMap;

    public InventoryService(int expectedProductCount) {
        // 预估商品数量,计算合理的初始容量,避免扩容开销
        // expectedProductCount / 0.75 + 1,取最近的 2 的幂
        int initialCapacity = tableSizeFor((int)(expectedProductCount / 0.75f + 1.0f));
        
        this.localCache = new HashMap<>(initialCapacity);
        this.stockMap = new ConcurrentHashMap<>(initialCapacity);
    }

    /**
     * 批量初始化库存(单线程批量导入,用普通 HashMap 处理后转入)
     */
    public void batchInitStock(Map<String, Integer> stockData) {
        // 在单线程的批量处理逻辑里用 HashMap 临时存储,性能更好
        HashMap<String, Integer> tempMap = new HashMap<>(stockData.size() * 2);
        
        for (Map.Entry<String, Integer> entry : stockData.entrySet()) {
            String skuId = entry.getKey();
            Integer stock = entry.getValue();
            
            // 简单的数据校验
            if (skuId == null || skuId.isBlank() || stock == null || stock < 0) {
                System.err.printf("非法库存数据,跳过:skuId=%s, stock=%s%n", skuId, stock);
                continue;
            }
            
            tempMap.put(skuId, stock);
        }
        
        // 批量转入线程安全的 ConcurrentHashMap
        stockMap.putAll(tempMap);
        System.out.printf("成功初始化 %d 个 SKU 的库存数据%n", tempMap.size());
    }

    /**
     * 扣减库存(多线程安全版)
     * 使用 ConcurrentHashMap 的原子操作
     */
    public boolean deductStock(String skuId, int quantity) {
        if (skuId == null || quantity <= 0) {
            throw new IllegalArgumentException("参数非法:skuId=" + skuId + ", quantity=" + quantity);
        }
        
        // compute 方法是原子操作,避免并发扣减出现超卖
        final int[] result = {-1};  // 用数组绕过 lambda 的 effectively final 限制
        
        stockMap.compute(skuId, (key, currentStock) -> {
            if (currentStock == null) {
                result[0] = -1;  // 商品不存在
                return null;
            }
            if (currentStock < quantity) {
                result[0] = 0;   // 库存不足
                return currentStock;  // 不变
            }
            result[0] = 1;      // 扣减成功
            return currentStock - quantity;
        });
        
        if (result[0] == 1) {
            System.out.printf("库存扣减成功:skuId=%s, 扣减数量=%d%n", skuId, quantity);
            return true;
        } else if (result[0] == 0) {
            System.out.printf("库存不足:skuId=%s%n", skuId);
            return false;
        } else {
            System.out.printf("商品不存在:skuId=%s%n", skuId);
            return false;
        }
    }

    /**
     * 查询库存
     */
    public int getStock(String skuId) {
        return stockMap.getOrDefault(skuId, 0);
    }

    /**
     * 批量查询库存(返回一个快照,用 HashMap 存结果)
     */
    public Map<String, Integer> batchGetStock(Iterable<String> skuIds) {
        Map<String, Integer> result = new HashMap<>();
        for (String skuId : skuIds) {
            result.put(skuId, getStock(skuId));
        }
        return result;
    }

    // 计算最近的 2 的幂(参考 HashMap 内部实现)
    private static int tableSizeFor(int cap) {
        int n = cap - 1;
        n |= n >>> 1;
        n |= n >>> 2;
        n |= n >>> 4;
        n |= n >>> 8;
        n |= n >>> 16;
        return (n < 0) ? 1 : (n >= (1 << 30)) ? (1 << 30) : n + 1;
    }
}
// 使用示例
public class Main {
    public static void main(String[] args) {
        InventoryService service = new InventoryService(1000);
        
        // 批量初始化
        Map<String, Integer> initData = new HashMap<>();
        initData.put("SKU_APPLE_001", 500);
        initData.put("SKU_MILK_002", 1000);
        initData.put("SKU_BREAD_003", 300);
        service.batchInitStock(initData);
        
        // 查询库存
        System.out.println("苹果库存: " + service.getStock("SKU_APPLE_001")); // 500
        
        // 扣减库存
        boolean success = service.deductStock("SKU_APPLE_001", 50);
        System.out.println("扣减结果: " + success); // true
        System.out.println("扣减后库存: " + service.getStock("SKU_APPLE_001")); // 450
        
        // 尝试超卖
        boolean oversell = service.deductStock("SKU_BREAD_003", 400);
        System.out.println("超卖扣减结果: " + oversell); // false
    }
}

代码里有几个设计值得说一下:

  1. 批量导入用普通 HashMap 做临时容器:ConcurrentHashMap 的并发控制是有开销的,单线程批量处理的时候用普通 HashMap 更快,处理完再整体 putAll 进去。
  2. compute 做原子扣减:ConcurrentHashMap 的 compute 方法是原子操作,在单机场景下能保证不超卖,比 get + 判断 + put 这种非原子组合安全得多。
  3. 初始化容量的预估:复用了 HashMap 内部的 tableSizeFor 方法逻辑,确保初始容量是 2 的幂,且足够容纳预期数量的元素,避免扩容。

八、面试高频问题与我的理解

既然讲到这里了,顺带把 HashMap 相关的高频面试题也梳理一下,算是帮大家把学到的知识做个应用:

Q1:HashMap 的初始容量是多少?负载因子是多少?为什么默认这些值?

初始容量 16,负载因子 0.75。容量必须是 2 的幂,方便用位运算代替取模(h & (n-1) vs h % n)。负载因子 0.75 是空间和时间的平衡值:太大了冲突多,太小了浪费空间,官方文档提到这是基于泊松分布选出的一个经验值。

Q2:HashMap 的 key 可以是 null 吗?

可以,而且只能有一个 null key,会被映射到数组下标 0。Hashtable 不允许 null key。

Q3:为什么 HashMap 不是线程安全的?

JDK 1.7:并发扩容时头插法导致链表成环,get 操作无限循环,CPU 100%。
JDK 1.8:尾插法解决了死循环,但并发 put 时仍然可能丢失数据(两个线程同时判断链表尾节点为 null,后写的覆盖前写的)。

Q4:HashMap 和 LinkedHashMap、TreeMap 的区别?

  • HashMap:无序,查找 O(1),不保证迭代顺序
  • LinkedHashMap:维护插入顺序或访问顺序(双向链表),可用来实现 LRU 缓存
  • TreeMap:按 key 的自然顺序或 Comparator 排序,基于红黑树,查找 O(log n)

Q5:JDK 1.8 的红黑树化,什么时候触发?

两个条件同时满足:① 链表长度 ≥ 8;② 数组长度 ≥ 64。如果数组长度 < 64 但链表长度 ≥ 8,会优先选择扩容而不是树化。

九、总结与思考

回过头来看,JDK 1.8 对 HashMap 的改进,本质上是在解决一个问题:如何在保证查找性能的前提下,应对极端的哈希冲突场景?

JDK 1.7 的方案是:更好的哈希算法(四次扰动),让冲突尽量少发生。

JDK 1.8 的方案是:一方面简化哈希算法(减少计算开销),另一方面引入红黑树做兜底(就算冲突发生了,查找也能维持 O(log n) 而不是退化到 O(n))。

同时,把头插法改成尾插法,消除了并发扩容时的死循环风险,让 HashMap 的行为在多线程场景下更可预期(虽然还是不安全,但至少不会把服务搞死)。

工程上,记住几个原则:

  1. 单线程用 HashMap,多线程用 ConcurrentHashMap,坚决不用 Hashtable
  2. 知道数据量的时候,指定初始容量,减少扩容开销
  3. key 要用不可变类型,或者确保 hashCode 和 equals 实现正确
  4. 如果需要有序,考虑 LinkedHashMap(维护插入顺序)或 TreeMap(按 key 排序)

最后说一句:这些原理不是为了背面试题而存在的。真正搞懂了,你才能在生产环境出问题的凌晨三点,不是茫然地看着崩掉的服务,而是有条不紊地定位问题,三分钟给出解决方案。

那种感觉,真的不一样。

🌟 文末

哈喽,各位小伙伴们,你们好呀,我是喵手。运营社区:C站/掘金/腾讯云/阿里云/华为云/51CTO;欢迎大家常来逛逛

好啦~以上就是本期 《Java核心实操(进阶版)》的全部内容啦!如果你在实践过程中遇到任何疑问,欢迎在评论区留言交流,我看到都会尽量回复~咱们下期见!

小伙伴们在批阅的过程中,如果觉得文章不错,欢迎点赞、收藏、关注哦~
三连就是对我写作道路上最好的鼓励与支持! ❤️🔥

📌 专栏持续更新中|建议收藏 + 订阅

专栏 👉 《Java核心实操(进阶版)》,我会按照“入门 → 进阶 → 工程化 → 项目落地”的路线持续更新,争取让每一篇都做到:

✅ 讲得清楚(原理)|✅ 跑得起来(代码)|✅ 用得上(场景)|✅ 扛得住(工程化)

📣 想系统提升的小伙伴:强烈建议先订阅专栏,再按目录顺序学习,效率会高很多~

文章配图

⭐️ 若喜欢我,就请关注我叭~(更新不迷路)
⭐️ 若对你有用,就请点赞支持一下叭~(给我一点点动力)
⭐️ 若有疑问,就请评论留言告诉我叭~(我会补坑 & 更新迭代)


文章配图

转载自 CSDN-专业IT技术社区

原文链接:https://blog.csdn.net/weixin_66592566/article/details/166847700

文章来源转载

评论

评论列表

微信小程序
QQ小程序

关于作者

点赞数:0
关注数:0
粉丝:0
文章:0
关注标签:0
加入于:--