背景

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

心中暗道不好,因为监控系统从来不会告诉我应用完美无 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. 关于linux find命令的使用

      find 和 xargs   xargs和find 在 使用find命令的-exec选项处理匹配到的文件时, find命令将所有匹配到的文件一起传递给exec执行.但有些系统对能够传递给exec的命令 ...

    2. 解决 Scrapy-Redis 空跑问题,链接跑完后自动关闭爬虫

      Scrapy-Redis 空跑问题,redis_key链接跑完后,自动关闭爬虫 问题:scrapy-redis框架中,reids存储的xxx:requests已经爬取完毕,但程序仍然一直运行,如何自动 ...

    3. 重设msyql数据库root密码

      重设密码的方法: 具体方法是: 1.先在安装目录找到my.ini配置文件,打开配置文件, 找到[mysqld]一行,在下面添加skip-grant-tables后保存该文件 重新启mysql动服务; ...

    4. 在MFC中UpdateData()的作用

      UpdateData()用来刷新数据,其中UpdateData(true)将控件中的数据传递到控件捆绑的变量中去.UpdateData(false)将变量值传递到控件中去.例如:窗口中用 DDX_Te ...

    5. Python Django 1.Hello Django

      #安装Djangopip install Django #==版本号#选择路径:D:#任意文件夹名 cd Django #罗列Django所提供的命令,其中startproject命令来创建项目 dj ...

    6. 权限系统与RBAC模型概述[绝对经典]

      0. 前言 一年前,我负责的一个项目中需要权限管理.当时凭着自己的逻辑设计出了一套权限管理模型,基本原理与RBAC非常相似,只是过于简陋.当时google了一些权限管理的资料,从中了解到早就有了RBA ...

    7. UWP中实现大爆炸效果(二)

      上一回实现了一个宽度不均匀的Panel,这次我们编写一个简单的BigbangView主体. 首先创建一个模板化控件,删掉Themes/Generic.xaml中的<Style TargetTyp ...

    8. shell与export命令

      围绕以下几个问题来学习export命令: 1.什么是export命令? 2.为什么要用export命令? 3.怎么使用export命令? 1.什么是export命令? ♦ 用户登录到Linux系统后, ...

    9. Android版数据结构与算法(八):二叉排序树

      本文目录 前两篇文章我们学习了一些树的基本概念以及常用操作,本篇我们了解一下二叉树的一种特殊形式:二叉排序树(Binary Sort Tree),又称二叉查找树(Binary Search Tree) ...

    10. 在已有的Asp.net MVC项目中引入Taurus.MVC

      Taurus.MVC是一个优秀的框架,如果要应用到已有的Asp.net MVC项目中,需要修改一下. 1.前提约定: 走Taurus.MVC必须指定后缀.如.api 2.原项目修改如下: web.co ...