背景

上午刚到公司,准备开始一天的摸鱼之旅时突然收到了一封监控中心的邮件。

心中暗道不好,因为监控系统从来不会告诉我应用完美无 bug,其实系统挺猥琐。

打开邮件一看,果然告知我有一个应用的线程池队列达到阈值触发了报警。

由于这个应用出问题非常影响用户体验;于是立马让运维保留现场 dump 线程和内存同时重启应用,还好重启之后恢复正常。于是开始着手排查问题。

分析

首先了解下这个应用大概是做什么的。

简单来说就是从 MQ 中取出数据然后丢到后面的业务线程池中做具体的业务处理。

而报警的队列正好就是这个线程池的队列。

跟踪代码发现构建线程池的方式如下:

ThreadPoolExecutor executor = new ThreadPoolExecutor(coreSize, maxSize,
0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<Runnable>());;
put(poolName,executor);

采用的是默认的 LinkedBlockingQueue 并没有指定大小(这也是个坑),于是这个队列的默认大小为 Integer.MAX_VALUE

由于应用已经重启,只能从仅存的线程快照和内存快照进行分析。

内存分析

先利用 MAT 分析了内存,的到了如下报告。

其中有两个比较大的对象,一个就是之前线程池存放任务的 LinkedBlockingQueue,还有一个则是 HashSet

当然其中队列占用了大量的内存,所以优先查看,HashSet 一会儿再看。

由于队列的大小给的够大,所以结合目前的情况来看应当是线程池里的任务处理较慢,导致队列的任务越堆越多,至少这是目前可以得出的结论。

线程分析

再来看看线程的分析,这里利用 fastthread.io 这个网站进行线程分析。

因为从表现来看线程池里的任务迟迟没有执行完毕,所以主要看看它们在干嘛。

正好他们都处于 RUNNABLE 状态,同时堆栈如下:

发现正好就是在处理上文提到的 HashSet,看这个堆栈是在查询 key 是否存在。通过查看 312 行的业务代码确实也是如此。

这里的线程名字也是个坑,让我找了好久。

定位

分析了内存和线程的堆栈之后其实已经大概猜出一些问题了。

这里其实有一个前提忘记讲到:

这个告警是凌晨三点发出的邮件,但并没有电话提醒之类的,所以大家都不知道。

到了早上上班时才发现并立即 dump 了上面的证据。

所有有一个很重要的事实:这几个业务线程在查询 HashSet 的时候运行了 6 7 个小时都没有返回

通过之前的监控曲线图也可以看出:

操作系统在之前一直处于高负载中,直到我们早上看到报警重启之后才降低。

同时发现这个应用生产上运行的是 JDK1.7 ,所以我初步认为应该是在查询 key 的时候进入了 HashMap 的环形链表导致 CPU 高负载同时也进入了死循环。

为了验证这个问题再次 review 了代码。

整理之后的伪代码如下:

//线程池
private ExecutorService executor; private Set<String> set = new hashSet(); private void execute(){ while(true){
//从 MQ 中获取数据
String key = subMQ();
executor.excute(new Worker(key)) ;
}
} public class Worker extends Thread{
private String key ; public Worker(String key){
this.key = key;
} @Override
private void run(){
if(!set.contains(key)){ //数据库查询
if(queryDB(key)){
set.add(key);
return;
}
} //达到某种条件时清空 set
if(flag){
set = null ;
}
}
}

大致的流程如下:

  • 源源不断的从 MQ 中获取数据。
  • 将数据丢到业务线程池中。
  • 判断数据是否已经写入了 Set
  • 没有则查询数据库。
  • 之后写入到 Set 中。

这里有一个很明显的问题,那就是作为共享资源的 Set 并没有做任何的同步处理

这里会有多个线程并发的操作,由于 HashSet 其实本质上就是 HashMap,所以它肯定是线程不安全的,所以会出现两个问题:

  • Set 中的数据在并发写入时被覆盖导致数据不准确。
  • 会在扩容的时候形成环形链表

第一个问题相对于第二个还能接受。

通过上文的内存分析我们已经知道这个 set 中的数据已经不少了。同时由于初始化时并没有指定大小,仅仅只是默认值,所以在大量的并发写入时候会导致频繁的扩容,而在 1.7 的条件下又可能会形成环形链表

不巧的是代码中也有查询操作(contains()),观察上文的堆栈情况:

发现是运行在 HashMap 的 465 行,来看看 1.7 中那里具体在做什么:

已经很明显了。这里在遍历链表,同时由于形成了环形链表导致这个 e.next 永远不为空,所以这个循环也不会退出了。

到这里其实已经找到问题了,但还有一个疑问是为什么线程池里的任务队列会越堆越多。我第一直觉是任务执行太慢导致的。

仔细查看了代码发现只有一个地方可能会慢:也就是有一个数据库的查询

把这个 SQL 拿到生产环境执行发现确实不快,查看索引发现都有命中。

但我一看表中的数据发现已经快有 7000W 的数据了。同时经过运维得知 MySQL 那台服务器的 IO 压力也比较大。

所以这个原因也比较明显了:

由于每消费一条数据都要去查询一次数据库,MySQL 本身压力就比较大,加上数据量也很高所以导致这个 IO 响应较慢,导致整个任务处理的就比较慢了。

但还有一个原因也不能忽视;由于所有的业务线程在某个时间点都进入了死循环,根本没有执行完任务的机会,而后面的数据还在源源不断的进入,所以这个队列只会越堆越多!

这其实是一个老应用了,可能会有人问为什么之前没出现问题。

这是因为之前数据量都比较少,即使是并发写入也没有出现并发扩容形成环形链表的情况。这段时间业务量的暴增正好把这个隐藏的雷给揪出来了。所以还是得信墨菲他老人家的话。

总结

至此整个排查结束,而我们后续的调整措施大概如下:

  • HashSet 不是线程安全的,换为 ConcurrentHashMap同时把 value 写死一样可以达到 set 的效果。
  • 根据我们后面的监控,初始化 ConcurrentHashMap 的大小尽量大一些,避免频繁的扩容。
  • MySQL 中很多数据都已经不用了,进行冷热处理。尽量降低单表数据量。同时后期考虑分表。
  • 查数据那里调整为查缓存,提高查询效率。
  • 线程池的名称一定得取的有意义,不然是自己给自己增加难度。
  • 根据监控将线程池的队列大小调整为一个具体值,并且要有拒绝策略。
  • 升级到 JDK1.8
  • 再一个是报警邮件酌情考虑为电话通知

    一次 HashSet 所引起的并发问题的更多相关文章

    1. JUC并发基础

      目录 一.Volatile 0.基础知识 1. volatile的解释 3.volatile的应用 二.CAS 0.CAS的定义 1.CAS底层原理 2.CAS的缺点 3.ABA问题 三.集合类并发安 ...

    2. Java进阶步骤

      一.基础篇 面向对象 什么是面向对象 面向对象.面向过程 面向对象的三大基本特征和五大基本原则 平台无关性 Java如何实现的平台无关 JVM还支持哪些语言(Kotlin.Groovy.JRuby.J ...

    3. Effective java笔记(九),并发

      66.同步访问共享的可变数据 JVM对不大于32位的基本类型的操作都是原子操作,所以读取一个非long或double类型的变量,可以保证返回的值是某个线程保存在该变量中的,但它并不能保证一个线程写入的 ...

    4. Java并发编程:线程池的使用

      Java并发编程:线程池的使用 在前面的文章中,我们使用线程的时候就去创建一个线程,这样实现起来非常简便,但是就会有一个问题: 如果并发的线程数量很多,并且每个线程都是执行一个时间很短的任务就结束了, ...

    5. Java并发编程:同步容器

      Java并发编程:同步容器 为了方便编写出线程安全的程序,Java里面提供了一些线程安全类和并发工具,比如:同步容器.并发容器.阻塞队列.Synchronizer(比如CountDownLatch). ...

    6. java并发编程实战学习(3)--基础构建模块

      转自:java并发编程实战 5.3阻塞队列和生产者-消费者模式 BlockingQueue阻塞队列提供可阻塞的put和take方法,以及支持定时的offer和poll方法.如果队列已经满了,那么put ...

    7. 【JAVA并发编程实战】2、对象的组合

      1. 设计线程安全的类 1.找出构成对象状态的所有变量 2.找出约束状态变量的不变性条件 3.建立对象状态的并发访问管理策略 package cn.xf.cp.ch04; /** * *功能:JAVA ...

    8. Java 并发工具包 java.util.concurrent 用户指南

      1. java.util.concurrent - Java 并发工具包 Java 5 添加了一个新的包到 Java 平台,java.util.concurrent 包.这个包包含有一系列能够让 Ja ...

    9. HashTable、HashMap、HashSet

      1. HashMap 1)  hashmap的数据结构 Hashmap是一个数组和链表的结合体(在数据结构称“链表散列“),如下图示: 当我们往hashmap中put元素的时候,先根据key的hash ...

    随机推荐

    1. mysql(mariadb)如何更改root密码

      mysql(或者mariadb,她是mysql的一个分支,完全开源,新版本的linux系统默认安装的是mariadb)如何更改root密码呢?我们主要介绍命令mysqladmin来实现. mysql( ...

    2. Pandas的函数应用、层级索引、统计计算

      1.Pandas的函数应用 1.apply 和 applymap 1. 可直接使用NumPy的函数 示例代码: # Numpy ufunc 函数 df = pd.DataFrame(np.random ...

    3. Mongodb数据更新命令、操作符

      一.Mongodb数据更新命令 Mongodb更新有两个命令:update.save. 1.1update命令 update命令格式: db.collection.update(criteria,ob ...

    4. Java 字符流文件读写

      上篇文章,我们介绍了 Java 的文件字节流框架中的相关内容,而我们本篇文章将着重于文件字符流的相关内容. 首先需要明确一点的是,字节流处理文件的时候是基于字节的,而字符流处理文件则是基于一个个字符为 ...

    5. C++中将整型数与字符串型之间的类型转换

      整数转换成字符串类型: 方法一: 这里用到了char *itoa(int value, char *string, int radix); 函数当中参数为:int value 被转换的整数,char ...

    6. 获得指定数据库中指定块表中所有实体的id

      该函数也使用外部指定图纸中的数据库中的块 Int getIdsByDwgBlkName(AcDbDatabase *pDwg, CString strBlkName, AcDbObjectIdArra ...

    7. Linux下可以ping ip地址但无法ping域名解决方法

      分析:当前系统无法解决域名至ip地址故障. 步骤阅读 2 三:解决过程: 1.分析dns故障: 2.物理机可以ping 地址,但无法ping域名: 3.检查/etc/resolv.conf: 注: ( ...

    8. Promise (1) 如何使用Promise

      Promise 也是面试高频问题, 今天我们来看看Promise是什么, 能做什么, 怎么用, 下一期我们自己来模拟一个myPromise 1  Promise 是什么 我们要学会自己给自己提问, 才 ...

    9. javascript模块化编程 从入门到实战

      <!DOCTYPE html> <html lang="zh-cn"> <head> <meta charset="UTF-8& ...

    10. python——绘制二元高斯分布的三维图像,

      在对数据进行可视化的过程中,可能经常需要对数据进行三维绘图,在python中进行三维绘图其实是比较简单的,下面我们将给出一个二元高斯分布的三维图像案例,并且给出相关函数的参数. 通常,我们绘制三维图像 ...