tags : 避坑指南
一.问题
今天在工作的时候项目组的同事说他们系统生产环境有线程stuck了,我抱着学习生产问题定位的心态凑合过去。了解到出现此问题时,操作员有多人使用同一帐号在系统中进行操作,且此种情况之前因为大批量的人员登录系统出现过。在拿到了报错日志时,在其中一段日志细节中突然发现了些许端倪。
下面连接防止图片连接失效备份图库(忽略)
https://ws3.sinaimg.cn/large/006Xmmmgly1g5ke0d338mj31fl0gdwg3.jpg
https://img04.sogoucdn.com/app/a/100520146/0265cf53f113ead98c352ee1c3bec72a
来整理一下线索,由于多人使用同一帐号进行操作出现,且有大量人员登录进行操作时出现,联想到之前看耗子叔的一篇文章讲述关于多线程环境下操作map导致死循环问题,是否是因为HashMap在执行put的方法时线程阻塞了呢?
二.定位
在查看调用处的代码时发现,此处是cas-client的一个jar包。
点开了jar包中SingleSignOutFilter这个类找到了其中doFilter方法
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest)servletRequest;
if ("POST".equals(request.getMethod())) {
String logoutRequest = request.getParameter("logoutRequest");
if (CommonUtils.isNotBlank(logoutRequest)){
if (log.isTraceEnabled()) {
log.trace("Logout request=[" + logoutRequest + "]");
}
String sessionIdentifier = XmlUtils.getTextForElement(logoutRequest, "SessionIndex");
if (CommonUtils.isNotBlank(sessionIdentifier)) {
HttpSession session = SESSION_MAPPING_STORAGE.removeSessionByMappingId(sessionIdentifier);
if (session != null) {
String sessionID = session.getId();
if (log.isDebugEnabled()) {
log.debug("Invalidating session [" + sessionID + "] for ST [" + sessionIdentifier + "]");
}try{
session.invalidate();
} catch (IllegalStateException e) {
log.debug(e, e);
}
}
return;
}
}
} else {
String artifact = request.getParameter(this.artifactParameterName);
HttpSession session = request.getSession();
if ((log.isDebugEnabled()) && (session != null)) {
log.debug("Storing session identifier for " + session.getId());
}
if (CommonUtils.isNotBlank(artifact)) {
SESSION_MAPPING_STORAGE.addSessionById(artifact, session);
}
}
filterChain.doFilter(servletRequest, servletResponse);
}
此代码反编译而成,语法稍有变化
代码中的32行是引发报错的地方,根据SESSION_MAPPING_STORAGE 查看其代码定义处。
private static SessionMappingStorage SESSION_MAPPING_STORAGE = new HashMapBackedSessionMappingStorage();
public SingleSignOutFilter(){
this.artifactParameterName = "ticket";
}
SESSION_MAPPING_STORAGE 的key值是获取的请求中的ticket,而value则是当前session,下面来看SESSION_MAPPING_STORAGE到底是个啥。
public final class HashMapBackedSessionMappingStorage
implements SessionMappingStorage{
private final Map MANAGED_SESSIONS;
private final Map ID_TO_SESSION_KEY_MAPPING;
public HashMapBackedSessionMappingStorage() {
this.MANAGED_SESSIONS = new HashMap();
this.ID_TO_SESSION_KEY_MAPPING = new HashMap();
}
public void addSessionById(String mappingId, HttpSession session){
this.ID_TO_SESSION_KEY_MAPPING.put(session.getId(), mappingId);
this.MANAGED_SESSIONS.put(mappingId, session);
}
public void removeBySessionById(String sessionId){
String key = (String)this.ID_TO_SESSION_KEY_MAPPING.get(sessionId);
this.MANAGED_SESSIONS.remove(key);
this.ID_TO_SESSION_KEY_MAPPING.remove(sessionId);
}
public HttpSession removeSessionByMappingId(String mappingId) {
HttpSession session = (HttpSession)this.MANAGED_SESSIONS.get(mappingId);
if (session != null) {
removeBySessionById(session.getId());
}
return session;
}
}
SessionMappingStorage 是一个接口而HashMapBackedSessionMappingStorage 是它的实现类,其中11行addSessionById方法中的MANAGED_SESSIONS、ID_TO_SESSION_KEY_MAPPING都是使用的HashMap!且用final修饰,联想到此类在SingleSignOutFilter中使用时定义为static,自此找到问题所在。
考虑到可能是由于jar包太过久远,查看了后续发布的jar包,其中对map操作时进行了加锁。
下面连接防止图片连接失效备份图库(忽略)
https://ws1.sinaimg.cn/large/006Xmmmggy1g5kdyihqz8j30uy0adt9q.jpg
https://i.loli.net/2019/08/01/5d42c85b65f4599497.jpg
三.分析
为何HashMap在多线程环境下使用会有死循环问题呢?在此来回顾下其具体原理。
1.HashMap 是什么?
hashMap是一个散列表,来源于数组,借助散列函数对数组进行了扩展,利用数组支持下标随机访问元素的特性进行存储。当key被加入的时候会通过散列函数计算出这个数组的下标i然后将这个< key,value >插入到array[i]中,如果有两个不同的key被计算到了同一个i的位置,叫做hash冲突,这时在数组i的位置会横向扩展成一个链表。(JDK1.8之前)
2.源码实现
源码:
put方法实现
1、判断key是否已经存在
public V put(K key, V value) {
if (key == null)
return putForNullKey(value);
int hash = hash(key);
int i = indexFor(hash, table.length);
// 如果key已经存在,则替换value,并返回旧值
for (Entry<K,V> e = table[i]; e != null; e = e.next) {
Object k;
if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue;
}
}
modCount++;
// key不存在,则插入新的元素
addEntry(hash, key, value, i);
return null;
}
2、检查容量是否达到阈值threshold
void addEntry(int hash, K key, V value, int bucketIndex) {
if ((size >= threshold) && (null != table[bucketIndex])) {
resize(2 * table.length);
hash = (null != key) ? hash(key) : 0;
bucketIndex = indexFor(hash, table.length);
}
createEntry(hash, key, value, bucketIndex);
}
如果元素个数已经达到阈值,则扩容,并把原来的元素移动过去。
3、扩容实现
void resize(int newCapacity){
Entry[] oldTable = table;
int oldCapacity = oldTable.length;
......
//创建一个新的Hash Table
Entry[] newTable = new Entry[newCapacity];
//将Old Hash Table上的数据迁移到New Hash Table上
transfer(newTable);
table = newTable;
threshold = (int)(newCapacity * loadFactor);
}
4、通过方法将数据迁移到扩容后的数组
void transfer(Entry[] newTable){
Entry[] src = table;
int newCapacity = newTable.length;
//下面这段代码的意思是:
// 从OldTable里摘一个元素出来,然后放到NewTable中
for (int j = 0; j < src.length; j++) {
Entry<K,V> e = src[j];
if (e != null) {
src[j] = null;
do {
//获得下一节点
Entry<K,V> next = e.next;
//计算得到新数组下标位置
int i = indexFor(e.hash, newCapacity);
//头插法,将当前节点next指向新数组,如果为空则为null,如果有值则获得其值
e.next = newTable[i];
//将数组赋值为新插入的链表头
newTable[i] = e;
e = next;
} while (e != null);
}
}
}
原链表值a-b-c-null ,使用头插法链表反转后循环结果依次为 a-null, b-a-null, c-b-a-null
ok上述流程在单线程环境下是没有问题的,来看一下多线程场景。
3.多线程场景
假设HashMap初始化大小为2,插入了三个节点,且三个都在同一hash槽内,此处设置其负载因子为1,元素数量达到3,此时触发了扩容。
下面连接防止图片连接失效备份图库(忽略)
https://ws2.sinaimg.cn/large/006Xmmmggy1g5la5c7yg3j30cw04zwea.jpg
插入第4个节点时,发生rehash,假设现在有两个线程同时进行,线程1和线程2,两个线程都会新建新的数组。
https://ws1.sinaimg.cn/large/006Xmmmgly1g5la9qndm9j30ja08ddfp.jpg
如果线程2 在执行到 Entry<K,V> next = e.next;之后,cpu时间片用完了,这时变量e指向节点a,变量next指向节点b。
线程2继续执行,此时int i = indexFor(e.hash, newCapacity);
加入计算后的新位置又在同一个位置3。
第一次循环移动节点a
https://ws2.sinaimg.cn/large/006Xmmmgly1g5las8g1pbj308p08pjr7.jpg
第二次循环移动节点b
https://ws3.sinaimg.cn/large/006Xmmmgly1g5lav75hmej309d07y0sk.jpg
使用的头插法,这里的顺序是反过来的继续移动节点c
https://ws2.sinaimg.cn/large/006Xmmmggy1g5laxk0dkrj30bj07ojr8.jpg
在这个时候线程1的时间片用完,内部的oldTable还没有设置成新的数组, 线程2 开始执行。
此时回顾下线程2时间片到了的时候
变量e指向节点a,变量next指向节点b。(注意此时因为线程1的干扰,这里的节点b.next已经不在指向c而是指向了a)
1.首先插入节点a,e=next循环。Entry< K,V > next = e.next 此时next为a,e为b。再次插入节点b,e=next循环。
2.第三次循环进来的时候执行完Entry< K,V > next = e.next; e为a节点,a的next指向null,next为null(循环退出条件)。
3.e.next = newTable[i]; 此时newTable[i]指向节点b,链表节点依次为b-a-null。而e.next =b,就等于把a的next指向了b,这样a和b就互相引用了,形成了一个环。
4.newTable[i] = e 把节点a放到了数组i位置此时链表节点依次为a-b-a(环)
5.e=next 变量e=null 此时满足 while (e != null)条件,退出循环。
最终引用关系:
https://ws1.sinaimg.cn/large/006Xmmmggy1g5lb4k5z52j30o609mq2x.jpg
节点a和节点b的互相引用形成了一个环,当把当前newTable[i]赋值到原来的oldTable中时。数组在使用对应的get时就会发生死循环。并且因为a、b节点的互相引用也会导致c节点的丢失。
总结
并发场景下,发生扩容时,可能会产生链表循环,在执行get时触发死循环,引起cpu100%,所以并发环境下一定要慎用HashMap。要并发可以使用线程安全的ConcurrentHashmap或者对map操作时加锁。