记录一次HashMap并发导致的线上事故

发现问题

线上日志平台报错如下

同时,系统监控告警平台也发来了CPU告急的消息,根据排查,是以下代码片段导致的:

"DubboServerHandler-10.30.66.58:13300-thread-65" #1081 daemon prio=5 os_prio=0 tid=0x00007f50a01ec800 nid=0x7ca4 runnable [0x00007f502122b000]
   java.lang.Thread.State: RUNNABLE
	at java.util.HashMap$TreeNode.putTreeVal(HashMap.java:2017)
	at java.util.HashMap.putVal(HashMap.java:638)
	at java.util.HashMap.put(HashMap.java:612)
	......
复制代码

定位问题

找到出错代码块

Node强转TreeNode错误,第一反应就是HashMap在链表转红黑树的时候出现了并发问题,遂找到代码片段

这段代码有两处存在线程安全问题:

  1. HashMap本身就是线程不安全的,如a处所示
  2. 如b处所示,每次进入这个方法的时候都会将全局变量重新初始化,想象一下,如果有两个线程同时调用这个方法,其中一个已经到了最后一步,打算返回map值,而另一个线程刚刚进来把map重新初始化,那么前者返回给被调用方的结果就是一个空map了。

这两处都违背了并发编程中的原子性。

线上日志平台报错显然是来源于第1个原因。

HashMap的线程非安全性

遂定位到HashMap源码中的对应代码块:

static <K,V> void moveRootToFront(Node<K,V>[] tab, TreeNode<K,V> root) {
            int n;
            if (root != null && tab != null && (n = tab.length) > 0) {
                int index = (n - 1) & root.hash;
                TreeNode<K,V> first = (TreeNode<K,V>)tab[index];
                if (root != first) {
                    Node<K,V> rn;
                    tab[index] = root;
                    TreeNode<K,V> rp = root.prev;
                    if ((rn = root.next) != null)
                        ((TreeNode<K,V>)rn).prev = rp;
                    if (rp != null)
                        rp.next = rn;
                    if (first != null)
                        first.prev = root;
                    root.next = first;
                    root.prev = null;
                }
                assert checkInvariants(root);
            }
        }
复制代码

这段代码的入口是:

向HashMap中插入新Node节点 -> bin数量超过阈值 调用#resize()进行扩容 -> 新节点所在的位置是一个树状结构 遂调用#split() #treeify()方法来进行红黑树节点的编排

上述代码中,第5行,将root节点的hash值,与数组数量-1得到的掩码进行与运算,得到在bin数组中新的index位置,此时将数组中这个位置的node强转为TreeNode类型。这里明显违反了原子性,因为其他线程也在进行HashMap初始化和插入操作,在别的线程中,index位置的节点由于初始化后的插入,变成了一个普通的Node,这时,强转就发生了异常。

改成ConcurrentHashMap就可以了吗?

虽然保证了map的线程安全,但是代码本身的非原子性还是没有得到解决啊

那么我们把代码进行修改

@Service
public class SubscriptServiceImpl implements SubscriptService {
    @Autowired
    private JedisClusterTemplate musicFmJedisClusterTemplate;

    @Override
    public Map<Integer, String> getSubscriptMap() {
        String json = musicFmJedisClusterTemplate.get("subscript:config");
        if (StringUtils.isBlank(json)) {
            return Maps.newHashMap();
        }

        List<SubscriptCache> subscriptCaches = JSON.parseArray(json, SubscriptCache.class);

        return subscriptCaches.stream()
                .collect(Collectors.toMap(SubscriptCache::getChannelId, SubscriptCache::getIconText));

    }

}
复制代码

这样线程是安全了,但是CPU占用高的问题还是得不到解决。这个应用中,实际每次Map中有两千多个键值,并且这个接口调用场景很多,QPS对于当前系统来说非常高,显然每次塞值都涉及到了大量的扩容和黑红树的操作,要知道,红黑树每次塞值的时候都要经过左旋右旋等复杂操作,比较消耗CPU性能。

说到底,HashMap和ConcurrentHashMap适用于查询多的场景,高并发下的增删改操作性能并不理想。即使是初始化自定义负载因子和容量,也只是用空间换时间,无法做到两全。

解决问题

换个思路,把缓存结构改成hash结构就行了,这样每次查出一个值,就不存在问题了。

@Override
    public String getSubscript(Integer channelId) {
        return musicFmJedisClusterTemplate.hget("subscript:config", channelId.toString());
    }
复制代码

碎碎念

解决线程安全常用的还有两种手段:

  1. 将全局变量的subscriptMap用ThreadLocal包装起来,这样一来确实能解决并发问题,但是每个线程都含有一个含有两千多个键值的map,无论是CPU还是内存都扛不住啊。
  2. 将这个类的scope改成prototype,其实和方法1一样,也解决不了CPU和内存的问题。

总结

  1. 保证操作的原子性是保证线程安全的关键

  2. 避免HashMap和ConcurrentHashMap高并发增删改

  3. 全局变量容易造成类的状态问题

猜你喜欢

转载自juejin.im/post/5e7b41e9f265da574947712e