当前位置: 代码网 > it编程>编程语言>Java > Java中HashMap及其相关类的底层原理与实现详解

Java中HashMap及其相关类的底层原理与实现详解

2026年08月31日 Java 我要评论
别再死记硬背了!从哈希碰撞到红黑树,从扩容死链到 cas,这篇带你真正理解 maphashmap 是 java 中最常用的集合类之一,也是面试中必考、深挖、连环问的绝对核心。而它的“兄弟姐

别再死记硬背了!从哈希碰撞到红黑树,从扩容死链到 cas,这篇带你真正理解 map

hashmap 是 java 中最常用的集合类之一,也是面试中必考、深挖、连环问的绝对核心。

而它的“兄弟姐妹”们——hashset(底层就是 hashmap)、linkedhashmap(双向链表 + hashmap)和 concurrenthashmap(并发安全的 hashmap)——则是构建高阶知识体系的拼图。

今天这篇文章,我们从底层数据结构哈希算法扩容机制java 8+ 红黑树优化三种遍历顺序并发演进六个维度,把这四个类彻底讲透。

一、先上结论(一张表看懂四兄弟)

对比维度hashmaphashsetlinkedhashmapconcurrenthashmap
底层数据结构数组 + 链表 + 红黑树(java 8+)就是 hashmap(值存占位符 present)hashmap + 双向链表(维护顺序)数组 + 链表 + 红黑树(cas + synchronized 实现并发)
存储内容key-value 键值对只存 key(去重)key-value 键值对key-value 键值对
是否允许 null key✅ 允许(存在数组第 0 位)✅ 允许(一个)✅ 允许❌ 禁止(会 npe)
是否允许 null value✅ 允许n/a(存的是 present,非 null)✅ 允许❌ 禁止(会 npe)
线程安全❌ 不安全(fast-fail 机制)❌ 不安全❌ 不安全✅ 安全(并发读写)
顺序性无序(插入顺序不保证)无序有序(插入顺序 或 访问顺序)无序(与 hashmap 一致)
适用场景单线程通用存储单线程去重集合需要可预测顺序的 map多线程并发场景

黄金选型原则:

  • 单线程存 key-value → hashmap
  • 单线程存 key 去重 → hashset
  • 需要保持插入顺序或做 lru 缓存 → linkedhashmap
  • 多线程并发读写 → concurrenthashmap(千万别用 hashtable,已淘汰)

二、hashmap:底层结构与核心原理(源码级)

1. 底层数据结构(java 8+)

// jdk 1.8 源码核心字段
public class hashmap<k,v> extends abstractmap<k,v>
    implements map<k,v>, cloneable, serializable {
    
    // 1. 核心数组(node 数组,即哈希桶)
    transient node<k,v>[] table;
    
    // 2. 实际元素个数
    transient int size;
    
    // 3. 扩容阈值(capacity * loadfactor)
    int threshold;
    
    // 4. 负载因子(默认 0.75)
    final float loadfactor;
    
    // 5. 静态内部类 node(链表节点)
    static class node<k,v> implements map.entry<k,v> {
        final int hash;
        final k key;
        v value;
        node<k,v> next;  // 指向下一个节点(链表指针)
    }
    
    // 6. 树化阈值(链表长度 >= 8 且数组长度 >= 64 时转红黑树)
    static final int treeify_threshold = 8;
    
    // 7. 退树化阈值(红黑树节点数 <= 6 时退化为链表)
    static final int untreeify_threshold = 6;
    
    // 8. 最小树化容量
    static final int min_treeify_capacity = 64;
}

2. 数据结构示意图

hashmap 底层结构(java 8+)
┌─────────────────────────────────────────────────────────────────┐
│                         table 数组(哈希桶)                   │
│  ┌────┬────┬────┬────┬────┬────┬────┬────┬────┬────┐        │
│  │ 0  │ 1  │ 2  │ 3  │ 4  │ 5  │ 6  │ 7  │ 8  │ 9  │ ...   │
│  └────┴────┴────┴────┴────┴────┴────┴────┴────┴────┘        │
│    │                  │                                       │
│    ↓                  ↓                                       │
│  node(链表)       红黑树(treenode)                            │
│  ┌─────┐         ┌─────┐                                    │
│  │key1  │──→      │key4  │  (红黑节点,自平衡)                │
│  ├─────┤   next  ├─────┤                                    │
│  │key2  │──→      │key5  │                                    │
│  ├─────┤   next  ├─────┤                                    │
│  │key3  │         │key6  │                                    │
│  └─────┘         └─────┘                                    │
└─────────────────────────────────────────────────────────────────┘

3.put()方法的执行流程(面试必画流程图)

final v putval(int hash, k key, v value, boolean onlyifabsent, boolean evict) {
    node<k,v>[] tab; node<k,v> p; int n, i;
    // 1. table 为空则初始化(resize)
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;
    // 2. 计算数组下标 (n - 1) & hash,若该位置为空则直接放入
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newnode(hash, key, value, null);
    else {
        node<k,v> e; k k;
        // 3. 如果 key 相同(hash 相同 && (引用相同 || equals 为 true))→ 覆盖
        if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k))))
            e = p;
        // 4. 如果已经是红黑树节点 → 走红黑树的 puttreeval
        else if (p instanceof treenode)
            e = ((treenode<k,v>)p).puttreeval(this, tab, hash, key, value);
        else {
            // 5. 链表情况:遍历链表
            for (int bincount = 0; ; ++bincount) {
                if ((e = p.next) == null) {
                    p.next = newnode(hash, key, value, null);
                    // 5a. 如果链表长度 >= 8 → 尝试转红黑树
                    if (bincount >= treeify_threshold - 1) // -1 for 1st
                        treeifybin(tab, hash);
                    break;
                }
                if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k))))
                    break;
                p = e;
            }
        }
        // 6. 覆盖旧值
        if (e != null) {
            v oldvalue = e.value;
            if (!onlyifabsent || oldvalue == null)
                e.value = value;
            return oldvalue;
        }
    }
    ++modcount;  // 记录修改次数(用于 fast-fail)
    if (++size > threshold)  // 7. 超过阈值则扩容
        resize();
    return null;
}

4. 哈希算法(hash()方法——扰动函数)

static final int hash(object key) {
    int h;
    // 让 hashcode 的高 16 位参与低 16 位的运算(混合扰动),减少哈希碰撞
    return (key == null) ? 0 : (h = key.hashcode()) ^ (h >>> 16);
}

计算数组下标的公式(n - 1) & hash(n 是数组长度,必须是 2 的幂次方)。

5. 扩容机制(resize()——最耗时的操作)

  • 触发条件size >= threshold(容量 × 负载因子),默认 16 × 0.75 = 12
  • 新容量翻倍oldcap << 1),所以容量始终是 2 的幂。
  • 重哈希(rehash):扩容后,每个元素要么留在原位置 i,要么移动到 i + oldcap 位置(利用 2 的幂取模特性,效率极高)。

扩容性能提示:如果能预知元素数量,请在构造时指定 initialcapacity,避免频繁扩容带来的性能损耗。

6. 为什么链表长度 ≥ 8 时转红黑树?

  • 理想情况下,链表的平均长度为 0.75(负载因子),出现 8 个碰撞的概率低于千万分之一(泊松分布)。
  • 如果大于等于 8,说明哈希函数有严重问题或数据量极大,此时转为红黑树(时间复杂度从 o(n) 降为 o(log n)),保证性能。
  • 退树化:红黑树节点数 ≤ 6 时,退化为链表(节点数少时,树维护开销反而更大)。

三、hashset:披着 set 外衣的hashmap

hashset 的底层非常简单——直接复用 hashmap,元素作为 keyvalue 统一用一个虚拟占位对象 present

// jdk 源码
public class hashset<e> extends abstractset<e>
    implements set<e>, cloneable, java.io.serializable {
    
    private transient hashmap<e,object> map;  // 底层就是 hashmap!
    
    // 虚拟占位值(所有 value 都指向它)
    private static final object present = new object();
    
    // 构造方法(直接 new hashmap)
    public hashset() {
        map = new hashmap<>();
    }
    
    // 添加元素:存到 map 的 key 位置
    public boolean add(e e) {
        return map.put(e, present) == null;
    }
    
    // 删除元素
    public boolean remove(object o) {
        return map.remove(o) == present;
    }
}

结论hashset所有操作都是委托给内部的 hashmap 完成的,它的特性和 hashmap 完全一致(线程不安全、允许 null、无序)。

四、linkedhashmap:有序的hashmap(lru 缓存的基础)

linkedhashmaphashmap 的基础上,额外维护了一个双向链表来记录元素的顺序。

1. 两种顺序模式

模式构造参数 accessorder顺序逻辑典型用途
插入顺序(默认)false(默认)按照元素插入的顺序遍历需要可预测迭代顺序的普通 map
访问顺序true按照元素最近被访问(get/put) 的顺序遍历lru 缓存(最近最少使用淘汰)
// 1. 插入顺序(默认)
linkedhashmap<string, string> map1 = new linkedhashmap<>();
map1.put("a", "1");
map1.put("b", "2");
map1.put("c", "3");
// 遍历输出:a, b, c(按插入顺序)

// 2. 访问顺序(accessorder = true)
linkedhashmap<string, string> map2 = new linkedhashmap<>(16, 0.75f, true);
map2.put("a", "1");
map2.put("b", "2");
map2.put("c", "3");
map2.get("a");  // 访问 a,a 会被移动到链表尾部
// 遍历输出:b, c, a(最近访问的放在最后)

2. 实现 lru 缓存的经典写法(面试加分项)

// 实现一个固定大小的 lru 缓存
class lrucache<k, v> extends linkedhashmap<k, v> {
    private final int maxsize;
    
    public lrucache(int maxsize) {
        super(maxsize, 0.75f, true);  // accessorder = true
        this.maxsize = maxsize;
    }
    
    @override
    protected boolean removeeldestentry(map.entry<k, v> eldest) {
        // 当元素数量超过最大容量时,自动移除最老的元素(链表头部)
        return size() > maxsize;
    }
}

// 使用示例
lrucache<string, integer> cache = new lrucache<>(3);
cache.put("a", 1);
cache.put("b", 2);
cache.put("c", 3);
cache.get("a");  // 访问 a
cache.put("d", 4);  // 触发淘汰,淘汰最久未使用的 b
// 缓存中:c, a, d

五、concurrenthashmap:并发安全的终极方案

1. 为什么不用hashtable?(面试送命题)

对比维度hashtableconcurrenthashmap
锁粒度整张表(所有操作锁住整个对象)分段锁(java 7)/ 节点锁(java 8+)
并发度极低(同一时刻只有一个线程能操作)极高(多线程可以同时操作不同段/不同节点)
性能差(已被淘汰)优(并发王者)
null 支持不支持(会 npe)不支持(会 npe)

结论hashtable 已被官方标记为“遗留类”,新代码中绝不要使用

2. java 7concurrenthashmap(分段锁,已过时)

  • 将数据分成 16 个 segment(段),每个 segment 独立加锁(继承 reentrantlock)。
  • 并发度 = 16。
  • 缺点:segment 数量固定,无法动态调整,且 16 个锁的并发度在大规模并发下不够高。

3. java 8+concurrenthashmap(cas +synchronized——当前的实现)

java 8 彻底抛弃了 segment 分段锁,采用了更细粒度的锁机制:

  • put 操作:
    1. 如果该数组位置为空 → cas(compare and swap) 自旋插入,无锁。
    2. 如果该位置不为空 → synchronized(头节点),只锁住当前链表的头节点或红黑树的根节点。
  • get 操作:完全无锁(通过 volatile 保证可见性),读取效率极高。
  • 扩容(transfer):并发扩容,多个线程可以同时参与迁移数据(把一个大任务拆成小任务)。
// jdk 8+ 源码核心(putval 方法片段)
final v putval(k key, v value, boolean onlyifabsent) {
    if (key == null || value == null) throw new nullpointerexception();  // 禁止 null
    int hash = spread(key.hashcode());
    for (node<k,v>[] tab = table;;) {
        node<k,v> f; int n, i, fh;
        if (tab == null || (n = tab.length) == 0)
            tab = inittable();
        else if ((f = tabat(tab, i = (n - 1) & hash)) == null) {
            // 1. 位置为空 → cas 尝试插入(无锁)
            if (castabat(tab, i, null, new node<k,v>(hash, key, value, null)))
                break;
        } else if ((fh = f.hash) == moved)
            // 2. 正在扩容 → 帮助迁移
            tab = helptransfer(tab, f);
        else {
            v oldval = null;
            // 3. 位置不为空 → synchronized 锁住头节点
            synchronized (f) {
                // ... 插入或覆盖逻辑
            }
            break;
        }
    }
    addcount(1l, bincount);
    return null;
}

4. 为什么concurrenthashmap不允许null?

  • 设计歧义性:在并发环境下,map.get(key) 返回 null,无法区分是“这个 key 不存在”还是“这个 key 存在,但 value 就是 null”。
  • 在非并发的 hashmap 中,可以通过 containskey() 区分,但在并发环境下,两次调用之间状态可能已经改变,区分变得毫无意义。因此 jdk 设计者直接禁止了 null key 和 null value。

六、四兄弟对比总结(面试速记版)

特性hashmaphashsetlinkedhashmapconcurrenthashmap
底层哈希表 + 红黑树就是 hashmaphashmap + 双向链表cas + synchronized + 红黑树
是否有序✅(插入或访问顺序)
线程安全
允许 null key
允许 null valuen/a
适用场景单线程通用 map单线程去重有序 map / lru 缓存多线程并发读写

七、高频面试连环追问

q1:hashmap hashcode() equals() 的关系?

  • 两个对象 equals()true,则 hashcode() 必须相等。
  • 两个对象 hashcode() 相等,equals() 不一定true(哈希碰撞)。
  • 实践:重写 equals() 必须重写 hashcode()

q2:hashmap 的容量为什么是 2 的幂次方?

  • 方便 (n - 1) & hash 快速取模(位运算比 % 快)。
  • 扩容时,元素要么在原位,要么在 i + oldcap,迁移逻辑简单高效。

q3:concurrenthashmap 在 java 7 和 java 8 的区别?

  • java 7segment(分段锁),一个锁管一个 segment。
  • java 8+cas + synchronized(锁头节点),并发度更高,锁粒度更细。

q4:hashmap 在多线程下有什么问题?

  • jdk 1.7:并发扩容可能产生死链(环形链表),导致 cpu 100%。
  • jdk 1.8:扩容算法优化,不会死链,但仍存在数据覆盖问题(两个 put 同时操作同一个数组位置,后覆盖前),不建议多线程使用

八、思考题(检验是否真的懂了)

// 问题 1:下面代码输出什么?为什么?
public static void main(string[] args) {
    hashset<string> set = new hashset<>();
    string s1 = new string("hello");
    string s2 = new string("hello");
    set.add(s1);
    set.add(s2);
    system.out.println(set.size());  // a
}
// 问题 2:下面代码在多线程环境下会有什么问题?
public class test {
    private static final map<string, string> map = new hashmap<>();
    public static void main(string[] args) {
        for (int i = 0; i < 100; i++) {
            new thread(() -> {
                map.put(thread.currentthread().getname(), "value");
            }).start();
        }
    }
}
// 问题 3:下面哪个 map 最合适用来实现一个带有过期淘汰策略的缓存?
// a. hashmap   b. linkedhashmap   c. concurrenthashmap   d. hashtable

答案(选中下方空白区域查看):

  1. 输出 1。因为 hashset 底层是 hashmap,通过 equals()hashcode() 去重。s1s2 虽然是不同对象,但 string 重写了 equals()hashcode(),它们内容相同,所以只能存 1 个。
  2. 数据覆盖、脏读,甚至无限循环或 concurrentmodificationexceptionhashmap 线程不安全,多线程并发 put 可能导致内部数组结构被破坏(如 size 不准确、键值覆盖等)。
  3. b. linkedhashmap。因为它的 accessorder=true 配合 removeeldestentry() 方法,可以非常轻松地实现 lru 缓存淘汰策略。

总结(终极速查表)

知识点一句话记忆
hashmap 底层数组 + 链表 + 红黑树(java 8)
扩容因子默认 0.75(时间和空间的权衡)
树化阈值链表长度 ≥ 8 且数组 ≥ 64 时转红黑树
hashset底层就是 hashmap,只存 key
linkedhashmaphashmap + 双向链表,可以按插入/访问顺序迭代
concurrenthashmap(java 8)cas + synchronized(锁头节点),读操作无锁
线程安全选择多线程 → concurrenthashmap(绝不用 hashtable)
null 限制concurrenthashmap 和 hashtable 禁止 null

以上就是java中hashmap及其相关类的底层原理与实现详解的详细内容,更多关于java hashmap的资料请关注代码网其它相关文章!

(0)

相关文章:

版权声明:本文内容由互联网用户贡献,该文观点仅代表作者本人。本站仅提供信息存储服务,不拥有所有权,不承担相关法律责任。 如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至 2386932994@qq.com 举报,一经查实将立刻删除。

发表评论

验证码:
Copyright © 2017-2026  代码网 保留所有权利. 粤ICP备2024248653号
站长QQ:2386932994 | 联系邮箱:2386932994@qq.com