java

关注公众号 jb51net

关闭
首页 > 软件编程 > java > Java HashMap

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

作者:tianyu234

本文将深入解析Java中HashMap及其相关类(HashSet、LinkedHashMap、ConcurrentHashMap)的核心原理与实现,通过对比表格归纳四类数据结构的底层实现、存储特性、线程安全性和适用场景,希望对大家有所帮助

别再死记硬背了!从哈希碰撞到红黑树,从扩容死链到 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多线程并发场景

黄金选型原则:

二、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()——最耗时的操作)

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

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

三、HashSet:披着 Set 外衣的HashMap

HashSet 的底层非常简单——直接复用 HashMap,元素作为 Key,Value 统一用一个虚拟占位对象 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 缓存的基础)

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

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(分段锁,已过时)

3. Java 8+ConcurrentHashMap(CAS +synchronized——当前的实现)

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

// 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?

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

特性HashMapHashSetLinkedHashMapConcurrentHashMap
底层哈希表 + 红黑树就是 HashMapHashMap + 双向链表CAS + synchronized + 红黑树
是否有序❌❌✅(插入或访问顺序)❌
线程安全❌❌❌✅
允许 null Key✅✅✅❌
允许 null Value✅N/A✅❌
适用场景单线程通用 Map单线程去重有序 Map / LRU 缓存多线程并发读写

七、高频面试连环追问

Q1:HashMap 中 hashCode() 和 equals() 的关系?

Q2:HashMap 的容量为什么是 2 的幂次方?

Q3:ConcurrentHashMap 在 Java 7 和 Java 8 的区别?

Q4:HashMap 在多线程下有什么问题?

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

// 问题 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() 去重。s1 和 s2 虽然是不同对象,但 String 重写了 equals() 和 hashCode(),它们内容相同,所以只能存 1 个。
  2. 数据覆盖、脏读,甚至无限循环或 ConcurrentModificationException。HashMap 线程不安全,多线程并发 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的资料请关注脚本之家其它相关文章!

您可能感兴趣的文章:
阅读全文