线上日志平台报错如下
同时,系统监控告警平台也发来了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源码中的对应代码块:
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,这时,强转就发生了异常。
虽然保证了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()); } 复制代码
解决线程安全常用的还有两种手段:
保证操作的原子性是保证线程安全的关键
避免HashMap和ConcurrentHashMap高并发增删改
全局变量容易造成类的状态问题