业务场景描述:
我们公司有一个项目,它会在一个比较固定的时间oom。后来根据输出的堆日志定位到了问题所在。问题存在于一个定时任务接口,该接口的业务流程是先查询令牌桶中有没有令牌,如果有令牌就执行业务逻辑,如果没有令牌就自旋5次,要是还没拿到令牌就打印日志。我排查一下发现令牌桶中的ScheduledFuture线程不会自动停止,它会不断添加令牌除非系统停止。下面是我对这次事故的分析。
错误写法:
令牌桶类:
package com.haiwang.hotel.utils;
import lombok.extern.log4j.Log4j2;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.concurrent.*;
@Log4j2
public class TokenLimiter {
private static final SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss:SSS");
private ScheduledFuture<?> future;
private final ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor();
/**
* 令牌
*/
private static final String TOKEN = "token";
/**
* 阻塞队列,线程安全,非公平容量有限
*/
private ArrayBlockingQueue<String> arrayBlockingQueue;
/**
* 令牌桶容量
*/
private int limit;
/**
* 令牌每次生成的数量
*/
private int amount;
/**
* 令牌桶间隔时间——单位时间
*/
private int period;
public TokenLimiter(int limit, int period, int amount) {
this.limit = limit;
this.amount = amount;
this.period = period;
arrayBlockingQueue = new ArrayBlockingQueue<>(limit);
init();
}
/**
* 初始化生成令牌,按桶容量生成
*/
private void init() {
for (int i = 0; i < limit; i++) {
arrayBlockingQueue.offer(TOKEN);
}
}
/**
* 启动添加线程
* @param lock
*/
public void start(Object lock) {
this.future = this.executor.scheduleWithFixedDelay(() -> {
synchronized (lock) {
// 往令牌桶添加令牌
addToken();
lock.notifyAll();
}
}, this.period, period, TimeUnit.MILLISECONDS);
}
/**
* 添加令牌
*/
private void addToken() {
for (int i = 0; i < this.amount; i++) {
// 溢出返回false
arrayBlockingQueue.offer(TOKEN);
log.info("添加令牌");
}
}
public boolean tryAcquire() {
// 队首元素出队
return arrayBlockingQueue.poll() != null;
}
@Override
public String toString() {
return "TokenLimiter{" +
"arrayBlockingQueue=" + arrayBlockingQueue +
", limit=" + limit +
", amount=" + amount +
", period=" + period +
'}';
}
}
业务代码:
//规定每秒并发数为3
@ApiOperation(value = "测试锁")
@GetMapping("/testLock")
public Result<?> testLock() {
//定义好信息阻塞队列
ArrayBlockingQueue<String> arrayBlockingQueue = new ArrayBlockingQueue(10);
arrayBlockingQueue.add("A1");
arrayBlockingQueue.add("A2");
arrayBlockingQueue.add("A3");
arrayBlockingQueue.add("A4");
arrayBlockingQueue.add("A5");
arrayBlockingQueue.add("A6");
arrayBlockingQueue.add("A7");
arrayBlockingQueue.add("A8");
arrayBlockingQueue.add("A9");
arrayBlockingQueue.add("A10");
//定义好令牌桶
TokenLimiter limiter = new TokenLimiter(3, 1000, 3);
// 生产令牌
limiter.start(LOCK);
int size = arrayBlockingQueue.size();
//创建线程并进行推送
for (int i = 0; i < size; i++) {
threadPoolTaskExecutor.execute(() -> {
String msg = arrayBlockingQueue.poll();
if(limiter.tryAcquire()){
log.info("推送信息{},当前线程名称为:{},当前时间为:{},",msg,Thread.currentThread().getName(),sdf.format(new Date()));
}else {
boolean flag = false;
//重试五次,成功就推送,失败就不推送
for (int j = 0; j < 4; j++){
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
log.error("线程中断错误");
}
if(limiter.tryAcquire()){
log.info("推送信息{},当前线程名称为:{},当前时间为:{}",msg,Thread.currentThread().getName(),sdf.format(new Date()));
flag = true;
break;
}
}
if(!flag){
log.info("线程繁忙,无法进行业务,当前线程名称为:{},推送信息{}",Thread.currentThread().getName(),msg);
}
}
});
}
return Result.success(1);
}
错误原因:
这个写法咋看之下好像没什么问题,但是实际上问题很大,因为ScheduledFuture这个类不像CompletableFuture.runAsync一样会随着主线程结束而自动销毁,它会一直不断的运行,并且你新建一个令牌桶类,它就会新建一个线程并且一直运行不断消耗服务器资源。可以从下图看到,即使主方法返回结束了,该线程也会不断运行。
如果你打开OOM时自动输出的堆日志时,经过分析你可以发现大量ScheduledFuture处于阻塞状态,导致了资源被大量占用导致了oom。因此java程序启动的脚本一定要加上oom自动输出堆日志的参数。具体怎么操作可以参考这篇文章:JAVA常用启动脚本_java启动脚本_循环网络不循环的博客-CSDN博客
正确写法:
我们在调用完令牌桶类后,需要手动关闭线程,避免资源过度占用。
令牌桶类:
令牌桶类需要增加停止方法。
/**
* 关闭线程
*
*/
public void stop() {
executor.shutdown();
future.cancel(true);
}
业务代码:
业务代码也需要执行完业务代码后手动关闭线程。
@ApiOperation(value = "测试锁")
@GetMapping("/testLock")
public Result<?> testLock() {
//定义好信息阻塞队列
ArrayBlockingQueue<String> arrayBlockingQueue = new ArrayBlockingQueue(10);
arrayBlockingQueue.add("A1");
arrayBlockingQueue.add("A2");
arrayBlockingQueue.add("A3");
arrayBlockingQueue.add("A4");
arrayBlockingQueue.add("A5");
arrayBlockingQueue.add("A6");
arrayBlockingQueue.add("A7");
arrayBlockingQueue.add("A8");
arrayBlockingQueue.add("A9");
arrayBlockingQueue.add("A10");
//定义好令牌桶
TokenLimiter limiter = new TokenLimiter(3, 1000, 3);
// 生产令牌
limiter.start(LOCK);
int size = arrayBlockingQueue.size();
//定义好门阀,等执行完毕业务代码执行完毕后再执行其它操作
final CountDownLatch latch = new CountDownLatch(size);
//创建线程并进行推送
for (int i = 0; i < size; i++) {
threadPoolTaskExecutor.execute(() -> {
String msg = arrayBlockingQueue.poll();
if(limiter.tryAcquire()){
log.info("推送信息{},当前线程名称为:{},当前时间为:{},",msg,Thread.currentThread().getName(),sdf.format(new Date()));
latch.countDown();
}else {
boolean flag = false;
//重试五次,成功就推送,失败就不推送
for (int j = 0; j < 4; j++){
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
log.error("线程中断错误");
}
if(limiter.tryAcquire()){
log.info("推送信息{},当前线程名称为:{},当前时间为:{}",msg,Thread.currentThread().getName(),sdf.format(new Date()));
latch.countDown();
flag = true;
break;
}
}
if(!flag){
log.info("线程繁忙,无法进行业务,当前线程名称为:{},推送信息{}",Thread.currentThread().getName(),msg);
}
}
});
}
try {
latch.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
//停止令牌桶线程
limiter.stop();
return Result.success(1);
}
参考文章:
java - ScheduledFuture.get() is still blocked after Executor shutdown - Stack Overflow