HashMap多线程环境下死循环问题(记一次生产问题)

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操作时进行了加锁。
新版jar包修改内容
下面连接防止图片连接失效备份图库(忽略)
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,两个线程都会新建新的数组。
触发rehash
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)条件,退出循环。

最终引用关系:
首先插入节点a
https://ws1.sinaimg.cn/large/006Xmmmggy1g5lb4k5z52j30o609mq2x.jpg
节点a和节点b的互相引用形成了一个环,当把当前newTable[i]赋值到原来的oldTable中时。数组在使用对应的get时就会发生死循环。并且因为a、b节点的互相引用也会导致c节点的丢失。

总结

并发场景下,发生扩容时,可能会产生链表循环,在执行get时触发死循环,引起cpu100%,所以并发环境下一定要慎用HashMap。要并发可以使用线程安全的ConcurrentHashmap或者对map操作时加锁。

参考资料:coolshell 占小狼

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值