最近另外一个项目组生产环境出现事故,导致项目无法提供服务,由于他们的项目属于公司的基础服务,所以导致了很严重的问题,之后经过排查发现是CountDownLatch使用不规范导致的,在这里和大家分享一下,希望能够有所帮助。
1、CountDownLatch 概念
CountDownLatch可以使一个获多个线程等待其他线程各自执行完毕后再执行。
CountDownLatch 定义了一个计数器,和一个阻塞队列, 当计数器的值递减为0之前,阻塞队列里面的线程处于挂起状态,当计数器递减到0时会唤醒阻塞队列所有线程,这里的计数器是一个标志,可以表示一个任务一个线程,也可以表示一个倒计时器,CountDownLatch可以解决那些一个或者多个线程在执行之前必须依赖于某些必要的前提业务先执行的场景。
2、CountDownLatch 常用方法说明
CountDownLatch(int count); //构造方法,创建一个值为count 的计数器。
await();//阻塞当前线程,将当前线程加入阻塞队列。
await(long timeout, TimeUnit unit);//在timeout的时间之内阻塞当前线程,时间一过则当前线程可以执行,
countDown();//对计数器进行递减1操作,当计数器递减至0时,当前线程会去唤醒阻塞队列里的所有线程。
对于CountDownLatch的概念和用法这些都相对来说比较简单,那么接下来我们来看一看它的实现原理。
3、CountDownLatch实现原理
** (1)、创建计数器**
当我们调用CountDownLatch countDownLatch=new CountDownLatch(4) 时候,此时会创建一个AQS的同步队列,并把创建CountDownLatch 传进来的计数器赋值给AQS队列的 state,所以state的值也代表CountDownLatch所剩余的计数次数;
public CountDownLatch(int count) {
if (count < 0) throw new IllegalArgumentException("count < 0");
this.sync = new Sync(count);//创建同步队列,并设置初始计数器值
}
** (2)、阻塞线程**
当我们调用countDownLatch.wait()的时候,会创建一个节点,加入到AQS阻塞队列,并同时把当前线程挂起。
public void await() throws InterruptedException {
sync.acquireSharedInterruptibly(1);
}
判断计数器是技术完毕,未完毕则把当前线程加入阻塞队列
public final void acquireSharedInterruptibly(int arg)
throws InterruptedException {
if (Thread.interrupted())
throw new InterruptedException();
//锁重入次数大于0 则新建节点加入阻塞队列,挂起当前线程
if (tryAcquireShared(arg) < 0)
doAcquireSharedInterruptibly(arg);
}
构建阻塞队列的双向链表,挂起当前线程
private void doAcquireSharedInterruptibly(int arg)
throws InterruptedException {
//新建节点加入阻塞队列
final Node node = addWaiter(Node.SHARED);
boolean failed = true;
try {
for (;;) {
//获得当前节点pre节点
final Node p = node.predecessor();
if (p == head) {
int r = tryAcquireShared(arg);//返回锁的state
if (r >= 0) {
setHeadAndPropagate(node, r);
p.next = null; // help GC
failed = false;
return;
}
}
//重组双向链表,清空无效节点,挂起当前线程
if (shouldParkAfterFailedAcquire(p, node) &&
parkAndCheckInterrupt())
throw new InterruptedException();
}
} finally {
if (failed)
cancelAcquire(node);
}
}
** (3)、计数器递减**
当我们调用countDownLatch.down()方法的时候,会对计数器进行减1操作,AQS内部是通过释放锁的方式,对state进行减1操作,当state=0的时候证明计数器已经递减完毕,此时会将AQS阻塞队列里的节点线程全部唤醒。
public void countDown() {
//递减锁重入次数,当state=0时唤醒所有阻塞线程
sync.releaseShared(1);
}
public final boolean releaseShared(int arg) {
//递减锁的重入次数
if (tryReleaseShared(arg)) {
doReleaseShared();//唤醒队列所有阻塞的节点
return true;
}
return false;
}
private void doReleaseShared() {
//唤醒所有阻塞队列里面的线程
for (;;) {
Node h = head;
if (h != null && h != tail) {
int ws = h.waitStatus;
if (ws == Node.SIGNAL) {//节点是否在等待唤醒状态
if (!compareAndSetWaitStatus(h, Node.SIGNAL, 0))//修改状态为初始
continue;
unparkSuccessor(h);//成功则唤醒线程
}
else if (ws == 0 &&
!compareAndSetWaitStatus(h, 0, Node.PROPAGATE))
continue; // loop on failed CAS
}
if (h == head) // loop if head changed
break;
}
}
3、出现问题的代码以及现象
相关调用系统的报错信息(删除了一些敏感信息)
-
MessageService. Last error is: Failed to invoke remote method: threadpool is exhausted ,detail msg:Thread pool is EXHAUSTED! Thread Name: Pool Size:
-
200 (active: 200, core: 200, max: 200, largest: 200), Task: 16150 (completed: 15950)
-
RemotingException: Server side(192.168.10.65,18090) threadpool is exhausted ,detail msg:Thread pool is EXHAUSTED
可以看出异常信息是dubbo线程池耗尽,但是究竟为何会出现这个异常呢,从业务相关的异常日志暂时没有发现更多的端倪,所以我们查看jstack 历史dump文件,看一下堆栈的具体情况。如果现在dubbo刚刚挂掉,可以根据card-service的pid来获取jstack日志,获取dubbo服务card-service的jstack日志的步骤如下:
1. 去程序源码找到这个dubbo服务的端口为20880
2. 根据20880查找对应的pid,发现pid为99668
[root@NYSJHL64-57 20170829171658]# netstat -ntlp |grep 20880
tcp 0 0 0.0.0.0:20880 0.0.0.0:* LISTEN 99668/java
3. jstack 'pid' > xxxx.log
(xxx.log是你希望生成的jstack dump文件) jstack 99668 > card-service_down.log
经过对日志的分析发现,导致异常的原因是在服务使用countDownLatch做业务处理时候,有一个线程抛出了异常,导致线程阻塞(具体敏感信息暂时不展开)。
出现问题的原理:
•方法一抛出异常,但是没有做异常处理导致不会执行线程关闭步骤,是不是和想象中不一样,一开始我也是懵,看了一下CountDownLatch原理就很好理解了,“CountDownLatch是通过一个计数器来实现的,计数器的初始化值为线程的数量。每当一个线程完成了自己的任务后,计数器的值就相应得减1。当计数器到达0时,表示所有的线程都已完成任务,然后在闭锁上等待的线程就可以恢复执行任务。”
** 如何解决: **
(1)、await返回false,主线程主动抛出异常,终止接下来的操作。
(2)、任务中加一个任务完成标志位(需要volatile修饰),countdown完成之后将标志位置为完成状态,存在超时则判断标志位,只处理标志位完成的任务
( 3)、把任务封装成Callable,直接使用ExecutorService接口带超时参数的invokeAll方法(任务超时后会被cancel掉),Future接口带了isCancelled方法可以得到任务运行是否被取消。
所以后续的解决方案就比较简单了,该项目的解决办法就是使用Future,并设置了time,并记录业务的详细异常信息,之后该项目没有出现异常。