![](https://img.haomeiwen.com/i22533675/a12663ab78391904.jpg)
发现问题
线上日志平台报错如下
![](https://img.haomeiwen.com/i22533675/1980b7f3684eb9f3.jpg)
同时,系统监控告警平台也发来了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在链表转红黑树的时候出现了并发问题,遂找到代码片段。
![](https://img.haomeiwen.com/i22533675/72b1a9c210762c0c.jpg)
这段代码有两处存在线程安全问题:
HashMap本身就是线程不安全的,如a处所示
如b处所示,每次进入这个方法的时候都会将全局变量重新初始化,想象一下,如果有两个线程同时调用这个方法,其中一个已经到了最后一步,打算返回map值,而另一个线程刚刚进来把map重新初始化,那么前者返回给被调用方的结果就是一个空map了。这两处都违背了并发编程中的原子性。线上日志平台报错显然是来源于第1个原因。
ashMap的线程非安全性
遂定位到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());
}
碎碎念
解决线程安全常用的还有两种手段:
将全局变量的subscriptMap用ThreadLocal包装起来,这样一来确实能解决并发问题,但是每个线程都含有一个含有两千多个键值的map,无论是CPU还是内存都扛不住啊。这个类的scope改成prototype,其实和方法1一样,也解决不了CPU和内存的问题。
总结
保证操作的原子性是保证线程安全的关键
避免HashMap和ConcurrentHashMap高并发增删改
全局变量容易造成类的状态问题
最近看到的一篇不错的文章,实用性很强。就转发一下,希望大家喜欢。
作者:国家一级老实人
网友评论