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内部是单链表,采用头插法:
- 线程A执行到
Entry<K,V> next = e.next;后挂起,此时e指向key(3),next指向key(7) - 线程B完成扩容,新数组中的链表顺序变为key(7) -> key(3)(头插法导致逆序)
- 线程A恢复执行,先将key(3)插入新数组,然后处理next即key(7),将key(7)插入到key(3)前面
- 此时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集合框架的明珠,理解其实现原理不仅能应对面试,更能帮助我们在实际开发中做出正确的技术选型和问题排查。