目录
一、前言
JDK版本为1.8。
高并发场景因误用HashMap而导致了一次生产事故,本文结合实际使用场景,通过代码模拟生产复现生产问题的方式进行逐一讲解。
二、复现生产问题
生产环境在分布式场景下误使用HashMap(使用fastjson效果相同),导致高并发访问出现死循环,在本地环境模拟生产环境,也是可以复现死循环场景。
复现逻辑:定义一个HashMap静态常量,其多个线程并发访问该HashMap,并且不停的进行put操作,有很大概率出现死循环场景。
常量代码:
package com.zhufeng.test;
import com.alibaba.fastjson.JSONObject;
import org.jsoup.nodes.Document;
import org.jsoup.nodes.Element;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;
/**
* @ClassName: ConstantParam
* @Description 常量
* @author 月夜烛峰
* @date 2022/7/27 14:07
*/
public class ConstantParam {
/**计数器*/
public static AtomicInteger atomicInteger = new AtomicInteger();
/**map数据集合*/
public static Map<Integer,Integer> mapData = new HashMap<Integer,Integer>(16);
}
HashMap操作线程代码
package com.zhufeng.test;
/**
* @ClassName: HashMapRunnable
* @Description map线程演示
* @author 月夜烛峰
* @date 2022/7/27 14:04
*/
public class HashMapRunnable implements Runnable{
private String name;
public HashMapRunnable(){}
public HashMapRunnable(String name){
this.name=name;
}
@Override
public void run() {
while (ConstantParam.atomicInteger.get()<100000000){
Integer curInt = ConstantParam.atomicInteger.incrementAndGet();
ConstantParam.mapData.put(curInt,curInt);
}
}
}
创建一个后台监控线程,用来检测当前值
package com.zhufeng.test;
/**
* @ClassName: ShowResultRunnable
* @Description 显示运行结果
* @author 月夜烛峰
* @date 2022/7/27 14:26
*/
public class ShowResultRunnable implements Runnable{
@Override
public void run() {
while (ConstantParam.atomicInteger.get()<100000000){
try {
//500毫秒打印一次当前结果
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("当前值:"+ConstantParam.atomicInteger.get());
}
}
}
多个线程启动测试
package com.zhufeng.test;
/**
* @ClassName: ThreadTest
* @Description TODO
* @author 月夜烛峰
* @date 2022/7/27 14:03
*/
public class ThreadTest {
public static void main(String[] args) {
//运行线程
runHashMap();
//线程监控
new Thread(new ShowResultRunnable()).start();
}
public static void runHashMap() {
for (int i = 0; i < 5; i++) {
new Thread(new HashMapRunnable("map-" + i)).start();
}
}
}
启动测试类,因为线程中的循环次数较高,出现死循环概率非常大,可以多次运行,提高出现概率。
运行短暂时间后,控制台会出现如下打印:
当前值:2949090
当前值:5821464
当前值:6498606
当前值:8763246
当前值:10137578
当前值:13645256
当前值:14588278
当前值:16968221
当前值:17015159
当前值:17015159
当前值:17015159
当前值:17015159
当前值:17015159
当前值:17015159
当前值:17015159
当前值:17015159
当前值:17015159
当前值:17015159
当前值:17015159
当前值:17015159
当前值停留在17015159,此时CPU占用率持续居高不下
三、堆栈分析
根据服务器资源特征,CPU巨高不下,内存使用率在正常范围,第一直觉怀疑可能有死循环。
jstack pid
通过jstack分析栈信息,如下:
在途中标注了两处,这两处都可以发生死循环,概率最大的地方是在红黑树插入新节点时,也就是HashMap第2239行balanceInsertion方法,在几个关键点打上断点。
balanceInsertion源码如下:
static <K,V> TreeNode<K,V> balanceInsertion(TreeNode<K,V> root,
TreeNode<K,V> x) {
x.red = true;
for (TreeNode<K,V> xp, xpp, xppl, xppr;;) {
if ((xp = x.parent) == null) {
x.red = false;
return x;
}
else if (!xp.red || (xpp = xp.parent) == null)
return root;
if (xp == (xppl = xpp.left)) {
if ((xppr = xpp.right) != null && xppr.red) {
xppr.red = false;
xp.red = false;
xpp.red = true;
x = xpp;
}
else {
if (x == xp.right) {
root = rotateLeft(root, x = xp);
xpp = (xp = x.parent) == null ? null : xp.parent;
}
if (xp != null) {
xp.red = false;
if (xpp != null) {
xpp.red = true;
root = rotateRight(root, xpp);
}
}
}
}
else {
if (xppl != null && xppl.red) {
xppl.red = false;
xp.red = false;
xpp.red = true;
x = xpp;
}
else {
if (x == xp.left) {
root = rotateRight(root, x = xp);
xpp = (xp = x.parent) == null ? null : xp.parent;
}
if (xp != null) {
xp.red = false;
if (xpp != null) {
xpp.red = true;
root = rotateLeft(root, xpp);
}
}
}
}
}
}
四、断点调试
通过debug方式重新运行测试代码
为方便调试,可以先忽略断点,当出现循环打印某一组数字时,启用断点
节点说明,此处先作为简单知晓,下文会重点介绍:
x为当前节点
xp为x父级节点,及x.parent
xpp为x父级节点的父级节点
xppl为x父级点的父级节点的左叶子节点
xppl为x父级点的父级节点的右叶子节点
x值为197128,xp值为197128,xpp值也为197128,继续debug
注意:197128为测试代码中HashMap中的value值
第2239行,xpp为TreeNode对象,此处讲引用指向了x节点,值为197128
继续debug会再次跳到第2228行,这里的值还是197128,只有在x.parent为空时,才会跳出for循环,而且这里x.parent会把对象指向xp节点, 而x和xp节点都指向了值为197128的TreeNode,也就是该TreeNode的的父级节点为其自身,从而造成此处逻辑死循环。
五、分析验证
为了更好的验证我们的猜想,导出dump文件,通过分析dump文件,画出HashMap的存储结构,则一目了然。
导出命令:
jmap -dump:file=dump.hprof,format=b pid
通过对应的pid,导出dump文件
通过jhat.hprof 命令进行对dump进行分析
jhat dump.hprof
jhat为JDK自带命令,分析注意JDK环境和代码运行JDK保持一致
MacBook-Pro:test zhufeng$ jhat dump.hprof
Reading from dump.hprof...
Dump file created Mon Aug 01 12:35:28 CST 2022
Snapshot read, resolving...
Resolving 3378353 objects...
Chasing references, expect 675 dots.........................
...
Eliminating duplicate references.......................
...
Snapshot resolved.
Started HTTP server on port 7000
Server is ready
通过浏览器分析解析结果,默认端口为7000
http://localhost:7000
因为测试的HashMap在ConstantParam类中,所以点开ConstantParam
点开后,找到hashmap对象
继续HashMap点击对象,map中所有的数据存储在链表中,也就是table
点击table对应链接
这里显示了所有已存入的数据,一般数据量较大。
为了方便查找,写了一个比较复杂的解析dump文件的小工具
选择一个结果进行查看
http://localhost:7000/object/0x5c0077f10
当前节点和父级节点为同一个引用 ,也就是当前节点的父级节点指向为自身,造成死循环。
六、数据存储结构
为了更为清晰的了解HashMap存储结构,特意整理了一部分真实数据
在JDK1.8+的版本中,数据量较大时,HashMap会进行扩容,当扩到一定程度时,存储结构会由链表转换为红黑树,替换过程如下:
对比源码,HashMap先以链表形式进行存储,对象类型为Node
/**
* Basic hash bin node, used for most entries. (See below for
* TreeNode subclass, and in LinkedHashMap for its Entry subclass.)
*/
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
V value;
Node<K,V> next;
Node(int hash, K key, V value, Node<K,V> next) {
this.hash = hash;
this.key = key;
this.value = value;
this.next = next;
}
public final K getKey() { return key; }
public final V getValue() { return value; }
public final String toString() { return key + "=" + value; }
public final int hashCode() {
return Objects.hashCode(key) ^ Objects.hashCode(value);
}
public final V setValue(V newValue) {
V oldValue = value;
value = newValue;
return oldValue;
}
public final boolean equals(Object o) {
if (o == this)
return true;
if (o instanceof Map.Entry) {
Map.Entry<?,?> e = (Map.Entry<?,?>)o;
if (Objects.equals(key, e.getKey()) &&
Objects.equals(value, e.getValue()))
return true;
}
return false;
}
}
当数据量较大时会进行扩容,一定程度后则转为红黑树,类型为TreeNode
// Tree bins
/**
* Entry for Tree bins. Extends LinkedHashMap.Entry (which in turn
* extends Node) so can be used as extension of either regular or
* linked node.
*/
static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
TreeNode<K,V> parent; // red-black tree links
TreeNode<K,V> left;
TreeNode<K,V> right;
TreeNode<K,V> prev; // needed to unlink next upon deletion
boolean red;
TreeNode(int hash, K key, V val, Node<K,V> next) {
super(hash, key, val, next);
}
/**
* Returns root of tree containing this node.
*/
final TreeNode<K,V> root() {
for (TreeNode<K,V> r = this, p;;) {
if ((p = r.parent) == null)
return r;
r = p;
}
}
LinkedHashMap.Entry继承Node
/**
* HashMap.Node subclass for normal LinkedHashMap entries.
*/
static class Entry<K,V> extends HashMap.Node<K,V> {
Entry<K,V> before, after;
Entry(int hash, K key, V value, Node<K,V> next) {
super(hash, key, value, next);
}
}
也就是TreeNode间接继承了Node。
分析dump可以根据数据在HashMap中的存储结构,例如我们以0x5c0077f10对象为突破口,找到该链表的跟节点(根Node),也就是prev节点为null(parent为TreeNode节点属性,链表通过prev和next进行上个节点和下一节点跟踪)
点击value,查看根节点的value值
以 2097371 为链表根节点值,根据对象的next节点,可以查看该链表的存储数据。该链表中一共有12条数据,链表存储结构如下:
通过jhat分析可知,该链表已经发生了红黑树的转换,通过分析parent、left、right节点的指向,转换后的红黑树结构如下:
可以看到1573091(也就是上文分析的0x5c0077f10对象)的左右节点均为自己, 形成死循环,虽然1048811的right节点指向了1573091,但因为在红黑树resize扩容时多线程抢夺节点值,导致1573091的父级节点引用指向了自己。
七、归纳总结
前面很大的篇幅都在跟踪说明死循环的过程,开篇的jstack信息中提示了两处死循环
1、balanceInsertion死循环
通过阅读源码,形成死循环的原因是无法跳出for循环所致,在balanceInsertion方法中,两个为null的条件无法满足,不能return。
2、root死循环
依然通过debug源码,在1821行root值为786671,1824行r.parent值依然为786671,从而无法跳出for循环,也同样造成了死循环。
备注:一篇文章,耗费了大量时间,码字不易,行且珍惜!