自省 | 使用Executors.xxx违反阿里Java代码规范,那还不写定时任务了?

开发 前端
逻辑看很简单,也很清晰,但任何事情都有两面性,每个锁配一个额外的线程做watchDog专门去处理,实现起来自然简单清晰,但肯定也有弊端。

一、背景

在《​​自省|分布式锁主动续期的入门级实现​​》中通过【自省】的方式讨论了关于分布式锁自动续期功能的入门级实现方式,简单同步一下上下文:

  • 客户端抢到分布式锁之后开始执行任务,执行完毕后再释放分布式锁。
  • 持锁后因客户端异常未能把锁释放,会导致锁成为永恒锁。
  • 为了避免这种情况,在创建锁的时候给锁指定一个过期时间。
  • 到期之后锁会被自动删除掉,这个角度看是对锁资源的一种保护。
  • 重点:但若锁过期被删除后,任务还没结束怎么办?
  • 可以通过在一个额外的线程中主动推迟分布式锁的过期时间,下文也用续期一词来表述;避免当任务还没执行完,锁就被删除了

二、使用高性价比的定时任务

逻辑看很简单,也很清晰,但任何事情都有两面性,每个锁配一个额外的线程做watchDog专门去处理,实现起来自然简单清晰,但肯定也有弊端。如果要把锁的功能做的健壮,总要从不断地自我质疑、自我反思中,理顺思路,寻找答案,我认为这属于自省式学习,以后会多尝试这种模式:

  • 问题:如果同时有成百上千个锁呢?同时有成百上千个锁,按照上篇中的实现方式,就会对应创建成百上千个线程在做续期工作,但实际上间歇性的续租操作并非高并发操作,只需要几个线程即可。类比一下一群羊只要少数牧羊犬看护的情景?
  • 问题:什么场景下会有同时出现这么多锁呢?如运营要做抢购活动,那么就会瞬间有成百上千的下单请求进入服务中,在高并发场景下特别容易出现超时而导致 rpc 重试 ,而这时需要拥有一种防重入的自保护机制的。对防重入感兴趣的这里提供一个直通车:《分布式锁中-基于 Redis 的实现如何防重入》。
  • 问题:如何避免创建这么多线程呢?池化机制,Java 中提供了用于执行调度任务的线程池,如 ScheduledExecutorService#scheduleAtFixedRate 。
  • 问题:如何构建示例:

new ScheduledThreadPoolExecutor(corePoolSize,
new NamedThreadFactory("defaultKeepAlivePool-", true),
new ThreadPoolExecutor.AbortPolicy()

构造函数详情:

public ScheduledThreadPoolExecutor(int corePoolSize,
ThreadFactory threadFactory,
RejectedExecutionHandler handler) {
super(corePoolSize, Integer.MAX_VALUE, 0, NANOSECONDS,
new DelayedWorkQueue(), threadFactory, handler);
}

  • 问题:线程池的构建居然敢用 Integer.MAX_VALUE ?这构造方法明显有问题,你该知道Integer.MAX_VALUE违反了阿里 Java 代码规范。

问题:哪一条规范?看下边这条规范中,明确指出允许的请求队列长度为Integer.MAX_VALUE,可能会 xxx 导致 OOM

图片

  • 问题:有Integer.MAX_VALUE就能 OOM 了?是的,交给线程池执行的是一个个任务对象,每个任务对象都会占用一定的内存,当线程池处理任务的能力降低,任务数越来越多的时候就 OOM 了。
  • 问题:能举个例子嘛?设置 JVM 内存-Xms200m -Xmx200m,JVM 内存上限设定小一些,每个任务里占用的内存给大一些,加速 OOM 报错。

// 调整VM参数,加速OOM:-Xms200m -Xmx200m
public static void testOOM(){
ScheduledExecutorService scheduledExecutorService = Executors.newScheduledThreadPool(1);
try {
for(int i = 0;i<10000000;i++){
scheduledExecutorService.scheduleAtFixedRate(() -> {
ScheduledThreadPoolExecutor executor = (ScheduledThreadPoolExecutor)scheduledExecutorService;
int size = executor.getQueue().size();
System.out.println("size : " + size);
//为了快速复现,任务里给个大内存占用
byte[] array = new byte[1024*1024*10];
try {
TimeUnit.MINUTES.sleep(10);
int length = array.length;
System.out.println(length);
} catch (InterruptedException e) {
e.printStackTrace();
}
},5,5,TimeUnit.SECONDS);
}
TimeUnit.HOURS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
}

  • 问题:结果啥样?果然模拟出了 OOM,结果如下:

size : 1637400
size : 1637778
...
Exception in thread "main"
java.lang.OutOfMemoryError: GC overhead limit exceeded
at java.util.concurrent.Executors.callable(Executors.java:407)
at java.util.concurrent.FutureTask.<init>(FutureTask.java:152)
at java.util.concurrent.ScheduledThreadPoolExecutor$ScheduledFutureTask.<init>(ScheduledThreadPoolExecutor.java:219)
at java.util.concurrent.ScheduledThreadPoolExecutor.scheduleAtFixedRate(ScheduledThreadPoolExecutor.java:570)
at TestSchedule.testOOM(TestSchedule.java:107
at TestSchedule.main(TestSchedule.java:8)

  • 问题:那ScheduledThreadPool就不能用了?其实要看场景,对于分布式锁看门口的场景下,一个系统中除非恶意写 bug;否则按照常规并发设置,如 Dubbo Provider 实例中,一般线程数是设置为 200,那并发情况下同时也就存在 200 个锁,从续期任务的提交看,最多只有 200 个,这个任务数,远远不到 Integer.MAX。
  • 问题:那要是就无限创建锁呢?
  • 这种不讲场景的无限 xxx 的操作是 bug。很多操作被无限 xxx 后都能 OOM 。
  • 问题:那我只能用它了?从网络中搜集到的资料情况来看,ScheduledThreadPool最多,读者老师若熟悉其它的池化调度组件,也烦请告知。
  • 问题:虽然只提交了 200 个任务,但任务是定时触发的,这有风险的呀?定时重复执行的任务,如果每 5 秒执行一次,一个任务执行的耗时在 30 秒,那任务数就越来越多了吧。
  • 问题:定时任务数会因执行慢而越来越多嘛?

public static void testInterval(){
ScheduledExecutorService scheduledExecutorService = Executors.newScheduledThreadPool(1);
try {
System.out.println("start : " + LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME));
scheduledExecutorService.scheduleAtFixedRate(() -> {
try {
System.out.println("enter : " + LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME));
TimeUnit.SECONDS.sleep(10);
System.out.println("exist : " + LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME));
} catch (InterruptedException e) {
e.printStackTrace();
}
},5,5,TimeUnit.SECONDS);

TimeUnit.HOURS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
}

任务没结束时,下个任务并未开始,而是等任务结束后才开始,那就没有了耗时大于定时间隔,而导致任务越来越多的风险了。

start : 2022-12-06T17:33:39.177 // 任务开始
enter : 2022-12-06T17:33:44.239 // 5秒后首次执行
exist : 2022-12-06T17:33:54.239 // 任务耗时10秒
enter : 2022-12-06T17:33:54.239 // 要求任务间隔是5秒,但已耗时10秒,超过了5秒,任务没结束时,下个任务并未开始,而是等任务结束后才开始
exist : 2022-12-06T17:34:04.24
enter : 2022-12-06T17:34:04.24
exist : 2022-12-06T17:34:14.241
enter : 2022-12-06T17:34:14.241
exist : 2022-12-06T17:34:24.241

  • 问题:那任务执行耗时 小于 定时间隔的,什么时机开始下一次任务呢?

public static void testInterval(){
ScheduledExecutorService scheduledExecutorService = Executors.newScheduledThreadPool(1);
try {
System.out.println("start : " + LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME));
scheduledExecutorService.scheduleAtFixedRate(() -> {
try {
System.out.println("enter : " + LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME));
TimeUnit.SECONDS.sleep(5);
System.out.println("exist : " + LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME));
} catch (InterruptedException e) {
e.printStackTrace();
}
},10,10,TimeUnit.SECONDS);

TimeUnit.HOURS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
```

```
start : 2022-12-06T17:31:01.771 //任务开始
enter : 2022-12-06T17:31:11.846 //10秒后首次执行
exist : 2022-12-06T17:31:16.846 //任务耗时5秒
enter : 2022-12-06T17:31:21.845 //因间隔10秒,任务耗时5秒,所以5秒后继续重新执行任务
exist : 2022-12-06T17:31:26.846
enter : 2022-12-06T17:31:31.846
exist : 2022-12-06T17:31:36.846
enter : 2022-12-06T17:31:41.847
exist : 2022-12-06T17:31:46.847

  • 问题:所以scheduleAtFixedRate的逻辑结论是?
  • 如果上一个任务的执行时间大于等待时间,任务结束后,下一个任务马上执行。
  • 如果上一个任务的执行时间小于等待时间,任务结束后,下一个任务在(间隔时间-执行时间)后开始执行。
  • 问题:那 scheduleWithFixedDealy 呢?
  • 如果上个任务的执行时间大于等待时间,任务结束后也会等待相应的时间才执行下一个任务

  • 问题:那阿里的规范写的有问题?

  • 可再认真阅读此规范,微妙之处请读者老师自行品鉴,也可留言讨论。
  • 图片


三、如何停掉任务

  • 问题:锁主动释放的时候,续期的任务要关闭嘛?是的,当锁被用户主动关闭的时候,主动续期的任务是要主动取消掉的。
  • 问题:如果我不主动取消呢?对于不主动续期的锁,抢锁后配置一个合适的过期时间,到期之后锁自然会被释放;这种情况下,客户端本就没有续期任务需要取消。但如果有额外的线程|线程池在定时续期的话,锁用完了需要被释放掉,任务一定要主动取消掉。
  • 问题:可万一忘记了呢?有加锁解锁的代码模板,按照模板来;获取锁之后,在 finally 中执行释放锁的操作。

boolean lockResult = lockInstance.tryLock()

if(lockResult){
//do work
}finally{
lockInstance.unLock();
}

  • 万一程序异常崩了,没执行 finally 呢?如果程序异常崩了,进程消失后,进程内的资源自然就都释放掉了:续期任务没有了,续期的线程|线程池也没有了。但锁资源就需要依赖锁服务,如 Redis ,在锁过期后主动释放掉锁资源。

  • 问题:关于停止任务,在前文独立线程的实现方式中,有介绍可通过中断机制;但是线程池里的任务怎么取消呢?遇事不决问百度,排名第一必有解
  • 图片

  • image.png咱得本意是取消一个任务,示例给出的方法是要把线程池关掉。
  • 问题:取消一个任务,要把整个线程池都关掉?按照示例所给的办法是不行的,每个任务的取消,都要关闭整个线程池的话,若给每个任务都配有独立的取消能力,就需要给每个任务都配一个独立的线程池,这就跟每个锁配一个独立的线程没有区别了。
  • 问题:目标是多个任务共享一个线程池,怎么不关闭线程池而只关闭特定的任务呢?百度出来跟问题相关的文章本就不多,而多数文章提供的奇思妙招并不好使,笔者是浪费了一些时间的,但不能再耽误读者朋友的时间,直接给思路:解铃还须系铃人,scheduleAtFixedRate的返回值是是ScheduledFuture。
  • 问题:看到 xxxFuture 是否想能想起Future接口的能力?猜测熟悉 get()方法的同学应该特别多,但不知道熟不熟悉cancel方法,如果看到这个方法感到惊喜,欢迎留言互动。

public interface Future<V> {
boolean cancel(boolean mayInterruptIfRunning);
...
V get() throws InterruptedException, ExecutionException;
...
}

  • 问题:cancel方法好使嘛?不看理论看实效果,试试看:

public static void testCancel() throws InterruptedException {
ScheduledExecutorService scheduledExecutorService = Executors.newScheduledThreadPool(1);

System.out.println(" start : " + LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME));
ScheduledFuture<?> scheduledFuture = scheduledExecutorService.scheduleAtFixedRate(() -> {
System.out.println(" work : " + LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME));
}, 5, 5, TimeUnit.SECONDS);

TimeUnit.SECONDS.sleep(15);
scheduledFuture.cancel(true);
System.out.println("cancel : " + LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME));
TimeUnit.SECONDS.sleep(30);
}

效果满足预期,成功取消了。

start : 2022-12-10T19:24:31.508
work : 2022-12-10T19:24:36.538
work : 2022-12-10T19:24:41.539
work : 2022-12-10T19:24:46.541
cancel : 2022-12-10T19:24:46.541 //成功取消

  • 问题:cancel 方法里都做了什么呢?

看源码可知,其内有两层核心逻辑:
尝试取消正在执行的任务
避免任务再被定时执行
public boolean cancel(boolean mayInterruptIfRunning) {
// 1. 先调用父类FutureTask#cancel来取消任务。
boolean cancelled = super.cancel(mayInterruptIfRunning);
//2. 核心逻辑是从队列中删除该任务。
if (cancelled && removeOnCancel && heapIndex >= 0)
remove(this);
return cancelled;
}

至此,关于使用线程池来执行|取消续期任务,看起来已经没啥问题了;

四、相关周边

  • 问题:cancel方法的参数mayInterruptIfRunning 是什么意思?从父类 cancel 方法的注释中可以寻找到答案,如果是 true 的话,即代表尝试通过中断的方式来停止任务

If the task has already started, then the mayInterruptIfRunning parameter determines whether the thread executing this task should be interrupted in an attempt to stop the task.

@Override
public boolean cancel(boolean mayInterruptIfRunning) {
if (RESULT_UPDATER.compareAndSet(this, null, CANCELLATION_CAUSE_HOLDER)) {
if (checkNotifyWaiters()) {
notifyListeners();
}
return true;
}
return false;
}

  • 问题:那就是说也可能抛出 InterruptedException 了?如果是抛出 InterruptedException ,示例中,并未看到程序测试有异常中断,也未看到有异常日志信息。
  • 问题:怎么有点玄学了,还能不是interrupt机制?在任务内尝试捕获一下看看:

public static void testExceptionCatch() throws InterruptedException {
ScheduledExecutorService scheduledExecutorService = Executors.newScheduledThreadPool(1);
ScheduledFuture<?> scheduledFuture = null;
System.out.println(" start : " + LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME));
try {
scheduledFuture = scheduledExecutorService.scheduleAtFixedRate(() -> {
System.out.println(" work : " + LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME));
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
//throw new RuntimeException("");
}, 5, 5, TimeUnit.SECONDS);
}catch (Exception exp){
exp.printStackTrace();
}
TimeUnit.SECONDS.sleep(15);
scheduledFuture.cancel(true);
System.out.println("cancel : " + LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME));
TimeUnit.SECONDS.sleep(30);
}

结果中的信息 java.lang.InterruptedException: sleep interrupted 可以明确是任务内的逻辑是可通过中断机制实现的。

start : 2022-12-10T20:10:31.248
work : 2022-12-10T20:10:36.276
work : 2022-12-10T20:10:41.272
work : 2022-12-10T20:10:46.277
cancel : 2022-12-10T20:10:46.277
java.lang.InterruptedException: sleep interrupted
at java.lang.Thread.sleep(Native Method)
at java.lang.Thread.sleep(Thread.java:340)
at java.util.concurrent.TimeUnit.sleep(TimeUnit.java:386)
at com.wushiyii.lock.ScheduleTest.lambda$testExceptionCatch$1(ScheduleTest.java:39)
at java.util.concurrent.Executors$RunnableAdapter.call(Executors.java:511)
at java.util.concurrent.FutureTask.runAndReset(FutureTask.java:308)
at java.util.concurrent.ScheduledThreadPoolExecutor$ScheduledFutureTask.access$301(ScheduledThreadPoolExecutor.java:180)
at java.util.concurrent.ScheduledThreadPoolExecutor$ScheduledFutureTask.run(ScheduledThreadPoolExecutor.java:294)
at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1149)
at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:624)
at java.lang.Thread.run(Thread.java:748)

  • 问题:之前实例中取消任务时,外部也无异常信息,线程池内部留着这个异常干嘛了呢?直接抛出异常试试看

public static void testExceptionCatch() throws InterruptedException {
ScheduledExecutorService scheduledExecutorService = Executors.newScheduledThreadPool(1);
ScheduledFuture<?> scheduledFuture = null;
System.out.println(" start : " + LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME));
try {
scheduledFuture = scheduledExecutorService.scheduleAtFixedRate(() -> {
System.out.println(" work : " + LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME));
throw new RuntimeException("just throw ");
//throw new RuntimeException("");
}, 5, 5, TimeUnit.SECONDS);
}catch (Exception exp){
exp.printStackTrace();
}
TimeUnit.SECONDS.sleep(15);
scheduledFuture.cancel(true);
System.out.println("cancel : " + LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME));
TimeUnit.SECONDS.sleep(30);
}

仔细观察能看出,结果变的有意思了,work 只执行了一次,前文中的执行结果中 work 都执行了 3 次,这里却只执行了一次。

start : 2022-12-10T20:16:53.285
work : 2022-12-10T20:16:58.307
cancel : 2022-12-10T20:17:08.305

  • 问题:任务内抛出异常能导致定时任务失去定时执行的能力?是的,使用scheduleAtFixedRate有以下几个情况必须注意:
  1. 任务逻辑中未捕获的异常能导致本该定时执行的任务,后续不再执行。
  2. 任务逻辑中未捕获的异常不会外抛,外部感知不到。
  3. 任务逻辑中的异常,需在任务逻辑内捕获并记录,否则无处可知。
  • 问题:那还有什么注意事项?给线程池指定的线程数要合理,不要无限制的提交任务,也不要每提交一个任务就 new 一个线程池。

本文转载自微信公众号「架构染色」,可以通过以下二维码关注。转载本文请联系【架构染色】公众号作者。


责任编辑:武晓燕 来源: 架构染色
相关推荐

2009-10-28 10:05:29

Ubuntucrontab定时任务

2022-08-15 15:43:29

Linuxcron

2024-05-13 09:49:30

.NETQuartz库Cron表达式

2012-02-07 13:31:14

SpringJava

2021-04-11 07:48:42

定时任务Linux jdk

2010-03-10 15:47:58

crontab定时任务

2019-02-20 15:52:50

技术开发代码

2022-03-28 08:31:29

线程池定时任务

2022-11-11 14:55:14

Linuxcron

2010-06-02 17:31:10

MySQL定时任务

2024-11-04 16:01:01

2020-12-21 07:31:23

实现单机JDK

2010-01-07 13:38:41

Linux定时任务

2024-07-29 08:08:30

2023-12-11 09:50:35

Linux定时器

2019-02-25 15:44:16

开源RabbitMQSpring Clou

2010-06-02 13:22:18

MySQL定时任务

2024-09-03 10:44:32

2021-12-16 14:25:03

Linux定时任务

2023-12-19 08:09:06

Python定时任务Cron表达式
点赞
收藏

51CTO技术栈公众号