AI摘要

文章从一次HashMap内存泄漏切入,剖析put流程、哈希计算、树化条件,揭示JDK7并发扩容死循环与JDK8数据覆盖隐患,并给出排序、并发、缓存场景的Map选型建议。

(一) 从一次内存泄漏排查说起

线上服务运行一周后,老年代内存占用持续升高,Full GC无法回收。使用jmap -histo:live <pid>查看堆内存对象,发现有几个HashMap$Node数组的长度达到数万,但元素个数只有几十个。这是典型的内存泄漏信号。

​根本原因:​​ 代码中使用了HashMap作为缓存,键是一个自定义对象,但这个对象没有正确重写hashCode()equals()方法。导致每次get操作都找不到对应的键,于是不停地put新键值对。由于键对象不同(尽管业务意义相同),HashMap不断扩容,但无法有效回收旧数组,造成内存浪费。

(二) 源码级解析:PUT方法的完整旅程

我们打开JDK8的HashMap源码,跟踪putVal方法:

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为空或长度为0,则进行初始化扩容
    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 {
        // 3. 桶下标冲突,处理哈希碰撞
        Node<K,V> e; K k;
        // 3.1 如果第一个节点key就相等(引用相等或equals相等),直接覆盖
        if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k))))
            e = p;
        // 3.2 如果节点是树节点,说明已经是红黑树
        else if (p instanceof TreeNode)
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
        else {
            // 3.3 遍历链表
            for (int binCount = 0; ; ++binCount) {
                if ((e = p.next) == null) {
                    // 到达链表尾部,插入新节点(尾插法)
                    p.next = newNode(hash, key, value, null);
                    // 如果链表长度达到树化阈值8,尝试树化
                    if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                        treeifyBin(tab, hash);
                    break;
                }
                // 在链表中找到key相等的节点
                if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k))))
                    break;
                p = e;
            }
        }
        // 4. 处理key已存在的情况,返回旧值
        if (e != null) { 
            V oldValue = e.value;
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            afterNodeAccess(e);
            return oldValue;
        }
    }
    ++modCount;
    // 5. 如果size超过阈值,扩容
    if (++size > threshold)
        resize();
    afterNodeInsertion(evict);
    return null;
}

关键点详解:

  • ​哈希计算:​(n - 1) & hash为什么高效?因为当n是2的幂次时,这个操作等价于hash % n,但位运算比取模快得多。
  • ​树化条件:​​ 链表长度 > 8 ​ 数组长度 >= 64。如果数组长度较小,会优先选择扩容而不是树化,因为小数组树化后查询效率提升不大,但树节点占用空间是链表节点的两倍。

(三) 并发场景下的致命问题深度分析

JDK7的死循环场景重现:

假设有两个线程A和B同时执行扩容操作transfer(),HashMap内部是单链表,采用头插法:

  1. 线程A执行到Entry<K,V> next = e.next;后挂起,此时e指向key(3),next指向key(7)
  2. 线程B完成扩容,新数组中的链表顺序变为key(7) -> key(3)(头插法导致逆序)
  3. 线程A恢复执行,先将key(3)插入新数组,然后处理next即key(7),将key(7)插入到key(3)前面
  4. 此时key(3).next又指向了key(7),形成了key(3)⇄key(7)的环形链表

JDK8的改进与依然存在的问题:

JDK8改为尾插法,解决了死循环问题。但并发put仍会导致数据覆盖:

  • 线程A和B同时put,计算出的桶下标相同且该位置为空
  • 两个线程都判断p == null,然后分别插入新节点,后插入的会覆盖先插入的

(四) 实战中的选择策略

// 1. 如果明确需要排序
Map<String, String> linkedMap = new LinkedHashMap<>(); // 保持插入顺序
Map<String, String> treeMap = new TreeMap<>(); // 按key排序

// 2. 高并发场景
Map<String, String> concurrentMap = new ConcurrentHashMap<>(); // 分段锁,性能好
Map<String, String> synchronizedMap = Collections.synchronizedMap(new HashMap<>()); // 全局锁,性能较差

// 3. 缓存场景,需要LRU淘汰
Map<String, String> lruCache = new LinkedHashMap<String, String>(16, 0.75f, true) {
    @Override
    protected boolean removeEldestEntry(Map.Entry<String, String> eldest) {
        return size() > 1000; // 超过1000个元素时移除最老的
    }
};

​总结:​​ HashMap是Java集合框架的明珠,理解其实现原理不仅能应对面试,更能帮助我们在实际开发中做出正确的技术选型和问题排查。

版权声明 ▶ 本网站名称:黄磊的博客
▶ 本文标题:HashMap夺命连环问:从源码解析到并发陷阱的深度剖析
▶ 本文链接:https://www.huangleicole.com/backend-related/32.html
▶ 转载本站文章需要遵守:商业转载请联系站长,非商业转载请注明出处!!

如果觉得我的文章对你有用,请随意赞赏