Skip to content

Commit d3da2cb

Browse files
committed
✨ Introducing new features.线程池中你不容错过的一些细节
1 parent c398353 commit d3da2cb

3 files changed

Lines changed: 324 additions & 0 deletions

File tree

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@
4242
- [ConcurrentHashMap 的实现原理](https://github.com/crossoverJie/JCSprout/blob/master/MD/ConcurrentHashMap.md)
4343
- [如何优雅的使用和理解线程池](https://github.com/crossoverJie/JCSprout/blob/master/MD/ThreadPoolExecutor.md)
4444
- [深入理解线程通信](https://github.com/crossoverJie/JCSprout/blob/master/MD/concurrent/thread-communication.md)
45+
- [一个线程罢工的诡异事件](docs/jvm/thread-gone.md)
46+
- [线程池中你不容错过的一些细节](docs/jvm/thread-gone2.md)
4547

4648
### JVM
4749
- [Java 运行时内存划分](https://github.com/crossoverJie/JCSprout/blob/master/MD/MemoryAllocation.md)

docs/jvm/thread-gone.md

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
2+
# 一个线程罢工的诡异事件
3+
4+
5+
![](https://ws1.sinaimg.cn/large/006tKfTcly1g0z9cox30rj31c00u010w.jpg)
6+
7+
# 背景
8+
9+
事情(事故)是这样的,突然收到报警,线上某个应用里业务逻辑没有执行,导致的结果是数据库里的某些数据没有更新。
10+
11+
虽然是前人写的代码,但作为 `Bug maker&killer` 只能咬着牙上了。
12+
13+
<!--more-->
14+
15+
因为之前没有接触过出问题这块的逻辑,所以简单理了下如图:
16+
17+
![](https://ws2.sinaimg.cn/large/006tKfTcly1g101rrbfpuj30ha0kkmz7.jpg)
18+
19+
1. 有一个生产线程一直源源不断的往队列写数据。
20+
2. 消费线程也一直不停的取出数据后写入后续的业务线程池。
21+
3. 业务线程池里的线程会对每个任务进行入库操作。
22+
23+
整个过程还是比较清晰的,就是一个典型的生产者消费者模型。
24+
25+
# 尝试定位
26+
27+
接下来便是尝试定位这个问题,首先例行检查了以下几项:
28+
- 是否内存有内存溢出?
29+
- 应用 GC 是否有异常?
30+
31+
通过日志以及监控发现以上两项都是正常的。
32+
33+
紧接着便 dump 了线程快照查看业务线程池中的线程都在干啥。
34+
35+
![](https://ws2.sinaimg.cn/large/006tKfTcly1g102sbm3nzj31m20bm79h.jpg)
36+
37+
结果发现所有业务线程池都处于 `waiting` 状态,队列也是空的。
38+
39+
40+
同时生产者使用的队列却已经满了,没有任何消费迹象。
41+
42+
结合上面的流程图不难发现应该是消费队列的 `Consumer` 出问题了,导致上游的队列不能消费,下有的业务线程池没事可做。
43+
44+
## review 代码
45+
46+
于是查看了消费代码的业务逻辑,同时也发现消费线程是一个**单线程**
47+
48+
![](https://ws2.sinaimg.cn/large/006tKfTcly1g1039na4mtj31l40b2wjk.jpg)
49+
50+
结合之前的线程快照,我发现这个消费线程也是处于 waiting 状态,和后面的业务线程池一模一样。
51+
52+
他做的事情基本上就是对消息解析,之后丢到后面的业务线程池中,没有发现什么特别的地方。
53+
54+
> 但是由于里面的分支特别多(switch case),看着有点头疼;所以我与写这个业务代码的同学沟通后他告诉我确实也只是入口处解析了一下数据,后续所有的业务逻辑都是丢到线程池中处理的,于是我便带着这个前提去排查了(埋下了伏笔)。
55+
56+
因为这里消费的队列其实是一个 `disruptor` 队列;它和我们常用的 `BlockQueue` 不太一样,不是由开发者自定义一个消费逻辑进行处理的;而是在初始化队列时直接丢一个线程池进去,它会在内部使用这个线程池进行消费,同时回调一个方法,在这个方法里我们写自己的消费逻辑。
57+
58+
59+
所以对于开发者而言,这个消费逻辑其实是一个黑盒。
60+
61+
于是在我反复 `review` 了消费代码中的数据解析逻辑发现不太可能出现问题后,便开始疯狂怀疑是不是 `disruptor` 自身的问题导致这个消费线程罢工了。
62+
63+
再翻了一阵 `disruptor` 的源码后依旧没发现什么问题后我咨询对 `disruptor` 较熟的@咖啡拿铁,在他的帮助下在本地模拟出来和生产一样的情况。
64+
65+
# 本地模拟
66+
67+
![](https://ws1.sinaimg.cn/large/006tKfTcly1g103njc7wzj31160jeq5p.jpg)
68+
![](https://ws1.sinaimg.cn/large/006tKfTcly1g103og02o7j30y60is40t.jpg)
69+
70+
本地也是创建了一个单线程的线程池,分别执行了两个任务。
71+
72+
- 第一个任务没啥好说的,就是简单的打印。
73+
- 第二个任务会对一个数进行累加,加到 10 之后就抛出一个未捕获的异常。
74+
75+
接着我们来运行一下。
76+
77+
![](https://ws4.sinaimg.cn/large/006tKfTcly1g103zsuu2mj311k0cutdb.jpg)
78+
![](https://ws3.sinaimg.cn/large/006tKfTcly1g1044fe64gj31ma0aedkq.jpg)
79+
80+
发现当任务中抛出一个没有捕获的异常时,线程池中的线程就会处于 `waiting` 状态,同时所有的堆栈都和生产相符。
81+
82+
> 细心的朋友会发现正常运行的线程名称和异常后处于 waiting 状态的线程名称是不一样的,这个后续分析。
83+
84+
## 解决问题
85+
86+
![](https://ws4.sinaimg.cn/large/006tKfTcly1g1048r27i4j30w406y3ze.jpg)
87+
88+
当加入异常捕获后又如何呢?
89+
90+
![](https://ws2.sinaimg.cn/large/006tKfTcly1g10480uffij31260lydnq.jpg)
91+
92+
程序肯定会正常运行。
93+
94+
> 同时会发现所有的任务都是由一个线程完成的。
95+
96+
虽说就是加了一行代码,但我们还是要搞清楚这里面的门门道道。
97+
98+
# 源码分析
99+
100+
于是只有直接 `debug` 线程池的源码最快了;
101+
102+
---
103+
104+
![](https://ws3.sinaimg.cn/large/006tKfTcly1g104asx28lj314m04emyr.jpg)
105+
106+
![](https://ws2.sinaimg.cn/large/006tKfTcly1g104csaukgj31g90u0tgp.jpg)
107+
108+
通过刚才的异常堆栈我们进入到 `ThreadPoolExecutor.java:1142` 处。
109+
110+
- 发现线程池已经帮我们做了异常捕获,但依然会往上抛。
111+
-`finally` 块中会执行 `processWorkerExit(w, completedAbruptly)` 方法。
112+
113+
114+
![](https://ws1.sinaimg.cn/large/006tKfTcly1g104ey1gyaj31mu0pwq9c.jpg)
115+
116+
看过之前[《如何优雅的使用和理解线程池》](https://crossoverjie.top/2018/07/29/java-senior/ThreadPool/)的朋友应该还会有印象。
117+
118+
线程池中的任务都会被包装为一个内部 `Worker` 对象执行。
119+
120+
`processWorkerExit` 可以简单的理解为是把当前运行的线程销毁(`workers.remove(w)`)、同时新增(`addWorker()`)一个 `Worker` 对象接着处理;
121+
122+
> 就像是哪个零件坏掉后重新换了一个新的接着工作,但是旧零件负责的任务就没有了。
123+
124+
125+
接下来看看 `addWorker()` 做了什么事情:
126+
127+
![](https://ws2.sinaimg.cn/large/006tKfTcly1g104lk6vdij31lc0u0107.jpg)
128+
129+
只看这次比较关心的部分;添加成功后会直接执行他的 `start()` 的方法。
130+
131+
132+
![](https://ws2.sinaimg.cn/large/006tKfTcly1g104q4mzb4j31mm0u0tgu.jpg)
133+
134+
由于 `Worker` 实现了 `Runnable` 接口,所以本质上就是调用了 `runWorker()` 方法。
135+
136+
---
137+
138+
139+
140+
`runWorker()` 其实就是上文 `ThreadPoolExecutor` 抛出异常时的那个方法。
141+
142+
![](https://ws1.sinaimg.cn/large/006tKfTcly1g104st473vj31m40u0ai2.jpg)
143+
![](https://ws2.sinaimg.cn/large/006tKfTcly1g104tzsj2gj31fv0u0aik.jpg)
144+
145+
它会从队列里一直不停的获取待执行的任务,也就是 `getTask()`;在 `getTask` 也能看出它会一直从内置的队列取出任务。
146+
147+
而一旦队列是空的,它就会 `waiting``workQueue.take()`,也就是我们从堆栈中发现的 1067 行代码。
148+
149+
150+
151+
## 线程名字的变化
152+
153+
![](https://ws2.sinaimg.cn/large/006tKfTcly1g1050zwdylj31go0u0gtx.jpg)
154+
![](https://ws2.sinaimg.cn/large/006tKfTcly1g1051ei4ehj31my0580u4.jpg)
155+
![](https://ws4.sinaimg.cn/large/006tKfTcly1g10529y0qhj31m008276j.jpg)
156+
157+
上文还提到了异常后的线程名称发生了改变,其实在 `addWorker()` 方法中可以看到 `new Worker()`时就会重新命名线程的名称,默认就是把后缀的计数+1。
158+
159+
这样一切都能解释得通了,真相只有一个:
160+
161+
162+
> 在单个线程的线程池中一但抛出了未被捕获的异常时,线程池会回收当前的线程并创建一个新的 `Worker`
163+
> 它也会一直不断的从队列里获取任务来执行,但由于这是一个消费线程,根本没有生产者往里边丢任务,所以它会一直 waiting 在从队列里获取任务处,所以也就造成了线上的队列没有消费,业务线程池没有执行的问题。
164+
165+
# 总结
166+
167+
所以之后线上的那个问题加上异常捕获之后也变得正常了,但我还是有点纳闷的是:
168+
169+
> 既然后续所有的任务都是在线程池中执行的,也就是纯异步了,那即便是出现异常也不会抛到消费线程中啊。
170+
171+
这不是把我之前储备的知识点推翻了嘛?不信邪!之后我让运维给了加上异常捕获后的线上错误日志。
172+
173+
结果发现在上文提到的众多 `switch case` 中,最后一个竟然是直接操作的数据库,导致一个非空字段报错了🤬!!
174+
175+
这事也给我个教训,还是得眼见为实啊。
176+
177+
虽然这个问题改动很小解决了,但复盘整个过程还是有许多需要改进的:
178+
179+
1. 消费队列的线程名称竟然和业务线程的前缀一样,导致我光找它就花了许多时间,命名必须得调整。
180+
2. 开发规范,防御式编程大家需要养成习惯。
181+
3. 未知的技术栈需要谨慎,比如 `disruptor`,之前的团队应该只是看了个高性能的介绍就直接使用,并没有深究其原理;导致出现问题后对它拿不准。
182+
183+
实例代码:
184+
185+
[https://github.com/crossoverJie/JCSprout/blob/master/src/main/java/com/crossoverjie/thread/ThreadExceptionTest.java](https://github.com/crossoverJie/JCSprout/blob/master/src/main/java/com/crossoverjie/thread/ThreadExceptionTest.java)
186+
187+
188+
**你的点赞与分享是对我最大的支持**
189+

docs/jvm/thread-gone2.md

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
# 线程池中你不容错过的一些细节
2+
3+
![](https://ws4.sinaimg.cn/large/006tKfTcly1g14ndc0hrkj31hc0u0ae4.jpg)
4+
5+
# 背景
6+
7+
上周分享了一篇[《一个线程罢工的诡异事件》](docs/jvm/thread-gone.md),最近也在公司内部分享了这个案例。
8+
9+
无独有偶,在内部分享的时候也有小伙伴问了之前分享时所提出的一类问题:
10+
11+
<!--more-->
12+
13+
![](https://ws1.sinaimg.cn/large/006tKfTcly1g1fftbaa1vj30lq050ab1.jpg)
14+
15+
![](https://ws2.sinaimg.cn/large/006tKfTcly1g13jch6xtkj30yu0u0ah3.jpg)
16+
17+
![](https://ws1.sinaimg.cn/large/006tKfTcly1g13je0xu1qj30vv0u0aj0.jpg)
18+
19+
![](https://ws4.sinaimg.cn/large/006tKfTcly1g1fgah6l5tj30i00l5wiu.jpg)
20+
21+
这其实是一类共性问题,我认为主要还是两个原因:
22+
23+
- 我自己确实也没讲清楚,之前画的那张图还需要再完善,有些误导。
24+
- 第二还是大家对线程池的理解不够深刻,比如今天要探讨的内容。
25+
26+
27+
# 线程池的工作原理
28+
29+
首先还是来复习下线程池的基本原理。
30+
31+
我认为线程池它就是一个**调度任务**的工具。
32+
33+
众所周知在初始化线程池会给定线程池的大小,假设现在我们有 1000 个线程任务需要运行,而线程池的大小为 10~20,在真正运行任务的过程中他肯定不会创建这1000个线程同时运行,而是充分利用线程池里这 10~20 个线程来调度这1000个任务。
34+
35+
而这里的 10~20 个线程最后会由线程池封装为 `ThreadPoolExecutor.Worker` 对象,而这个 `Worker` 是实现了 Runnable 接口的,所以他自己本身就是一个线程。
36+
37+
# 深入分析
38+
39+
![](https://ws3.sinaimg.cn/large/006tKfTcly1g1fhhl9mwoj30g402j0sr.jpg)
40+
41+
这里我们来做一个模拟,创建了一个核心线程、最大线程数、阻塞队列都为2的线程池。
42+
43+
这里假设线程池已经完成了预热,也就是线程池内部已经创建好了两个线程 `Worker`
44+
45+
当我们往一个线程池丢一个任务会发生什么事呢?
46+
47+
![](https://ws3.sinaimg.cn/large/006tKfTcly1g1fhfvpuvuj30ej0bu0tn.jpg)
48+
49+
- 第一步是生产者,也就是任务提供者他执行了一个 execute() 方法,本质上就是往这个内部队列里放了一个任务。
50+
- 之前已经创建好了的 Worker 线程会执行一个 `while` 循环 ---> 不停的从这个`内部队列`里获取任务。(这一步是竞争的关系,都会抢着从队列里获取任务,由这个队列内部实现了线程安全。)
51+
- 获取得到一个任务后,其实也就是拿到了一个 `Runnable` 对象(也就是 `execute(Runnable task)` 这里所提交的任务),接着执行这个 `Runnable`**run() 方法,而不是 start()**,这点需要注意后文分析原因。
52+
53+
结合源码来看:
54+
55+
![](https://ws3.sinaimg.cn/large/006tKfTcly1g1fhr8q4iyj30tq0kjtcy.jpg)
56+
57+
从图中其实就对应了刚才提到的二三两步:
58+
59+
- `while` 循环,从 `getTask()` 方法中一直不停的获取任务。
60+
- 拿到任务后,执行它的 run() 方法。
61+
62+
这样一个线程就调度完毕,然后再次进入循环从队列里取任务并不断的进行调度。
63+
64+
# 再次解释之前的问题
65+
66+
接下来回顾一下我们上一篇文章所提到的,导致一个线程没有运行的根本原因是:
67+
68+
> 在单个线程的线程池中一但抛出了未被捕获的异常时,线程池会回收当前的线程并创建一个新的 `Worker`
69+
> 它也会一直不断的从队列里获取任务来执行,但由于这是一个消费线程,**根本没有生产者往里边丢任务**,所以它会一直 waiting 在从队列里获取任务处,所以也就造成了线上的队列没有消费,业务线程池没有执行的问题。
70+
71+
结合之前的那张图来看:
72+
73+
![](https://ws2.sinaimg.cn/large/006tKfTcly1g101rrbfpuj30ha0kkmz7.jpg)
74+
75+
这里大家问的最多的一个点是,为什么会没有是`根本没有生产者往里边丢任务`,图中不是明明画的有一个 `product` 嘛?
76+
77+
这里确实是有些不太清楚,再次强调一次:
78+
79+
**图中的 product 是往内部队列里写消息的生产者,并不是往这个 Consumer 所在的线程池中写任务的生产者。**
80+
81+
因为即便 `Consumer` 是一个单线程的线程池,它依然具有一个常规线程池所具备的所有条件:
82+
83+
- Worker 调度线程,也就是线程池运行的线程;虽然只有一个。
84+
- 内部的阻塞队列;虽然长度只有1。
85+
86+
再次结合图来看:
87+
88+
![](https://ws4.sinaimg.cn/large/006tKfTcly1g1fi8yth6gj30f70ca3ze.jpg)
89+
90+
所以之前提到的【没有生产者往里边丢任务】是指右图放大后的那一块,也就是内部队列并没有其他线程往里边丢任务执行 `execute()` 方法。
91+
92+
而一旦发生未捕获的异常后,`Worker1` 被回收,顺带的它所调度的线程 `task1`(这个task1 也就是在执行一个 while 循环消费左图中的那个队列) 也会被回收掉。
93+
94+
新创建的 `Worker2` 会取代 `Worker1` 继续执行 `while` 循环从内部队列里获取任务,但此时这个队列就一直会是空的,所以也就是处于 `Waiting` 状态。
95+
96+
97+
> 我觉得这波解释应该还是讲清楚了,欢迎还没搞明白的朋友留言讨论。
98+
99+
# 为什是 run() 而不是 start()
100+
101+
问题搞清楚后来想想为什么线程池在调度的时候执行的是 `Runnable``run()` 方法,而不是 `start()` 方法呢?
102+
103+
我相信大部分没有看过源码的同学心中第一个印象就应该是执行的 `start()` 方法;
104+
105+
因为不管是学校老师,还是网上大牛讲的都是只有执行了` start()` 方法后操作系统才会给我们创建一个独立的线程来运行,而 `run()` 方法只是一个普通的方法调用。
106+
107+
而在线程池这个场景中却恰好就是要利用它**只是一个普通方法调用**
108+
109+
回到我在文初中所提到的:我认为线程池它就是一个**调度任务**的工具。
110+
111+
假设这里是调用的 `Runnable``start` 方法,那会发生什么事情。
112+
113+
如果我们往一个核心、最大线程数为 2 的线程池里丢了 1000 个任务,**那么它会额外的创建 1000 个线程,同时每个任务都是异步执行的,一下子就执行完毕了**
114+
115+
从而没法做到由这两个 `Worker` 线程来调度这 1000 个任务,而只有当做一个同步阻塞的 `run()` 方法调用时才能满足这个要求。
116+
117+
> 这事也让我发现一个奇特的现象:就是网上几乎没人讲过为什么在线程池里是 run 而不是 start,不知道是大家都觉得这是基操还是没人仔细考虑过。
118+
119+
# 总结
120+
121+
针对之前线上事故的总结上次已经写得差不多了,感兴趣的可以翻回去看看。
122+
123+
这次呢可能更多是我自己的总结,比如写一篇技术博客时如果大部分人对某一个知识点讨论的比较热烈时,那一定是作者要么讲错了,要么没讲清楚。
124+
125+
这点确实是要把自己作为一个读者的角度来看,不然很容易出现之前的一些误解。
126+
127+
在这之外呢,我觉得对于线程池把这两篇都看完同时也理解后对于大家理解线程池,利用线程池完成工作也是有很大好处的。
128+
129+
如果有在面试中加分的记得回来点赞、分享啊。
130+
131+
132+
**你的点赞与分享是对我最大的支持**
133+

0 commit comments

Comments
 (0)