「实战分享」深入讲解HashMap死循环及红黑树转换

本文详细介绍了在高并发环境中误用HashMap导致的死循环问题,通过复现生产问题、堆栈分析、断点调试和数据存储结构分析,揭示了HashMap在多线程环境下可能导致的死循环原因,以及如何定位和解决此类问题。同时,文章提供了dump文件的分析方法,帮助理解HashMap的内部存储结构。
摘要由CSDN通过智能技术生成

目录

一、前言

二、复现生产问题

 三、堆栈分析

 四、断点调试

五、分析验证

六、数据存储结构

七、归纳总结


一、前言

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循环,也同样造成了死循环。

 备注:一篇文章,耗费了大量时间,码字不易,行且珍惜!

  • 2
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

月夜烛峰

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值