周期性任务线程池 - ScheduledThreadPoolExecutor & DelayedWorkQueue


ScheduledThreadPoolExecutor是ThreadPoolExecutor的扩展类,用来实现延迟执行的任务、或者周期性执行的任务。

一般来讲,周期性任务或者定时任务包含两大组件:一个是执行任务的线程池,一个是存储任务的存储器。还记得Quartz吗?企业级定时任务框架,最重要的内容其实也是这两部分:SimpleThreadPool和JobStore。

ScheduledThreadPoolExecutor也不例外,由线程池和任务队列组成。线程池继承自ThreadPoolExecutor,任务队列DelayedWorkQueue,了解了ThreadPoolExecutor和DelayedWorkQueue,也就基本了解了ScheduledThreadPoolExecutor。

此外,ScheduledThreadPoolExecutor的特殊之处还在于他所执行的任务必须是ScheduledFutureTask,ScheduledFutureTask是“未来要执行的任务”,“未来”由delay指定。即使是通过ScheduledThreadPoolExecutor提交“立即”而不是“未来”要执行的任务,也要通过指定delay时长为0的ScheduledFutureTask来提交。ScheduledFutureTask任务提交之后加入阻塞队列DelayedWorkQueue等待调度。

ScheduledThreadPoolExecutor的创建

提供了四个构造方法,都是通过调用父类ThreadPoolExecutor的构造方法完成ScheduledThreadPoolExecutor对象的创建:


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

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

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

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

corePoolSize通过构造方法的参数指定,maximumPoolSize在构造方法中都固定设置为Integer.MAX_VALUE,也就是不受限制。

keepAliveTime设置为0,后面我们会知道其实ScheduledThreadPoolExecutor的线程数不会超过corePoolSize,而且如果allowCoreThreadTimeOut保持默认的话(false),那其实这个keepAliveTime是没有意义的。

四个构造方法均设置阻塞队列为new DelayedWorkQueue(),即仅支持DelayedWorkQueue。

ScheduledThreadPoolExecutor的线程池管理

ScheduledThreadPoolExecutor是ThreadPoolExecutor的扩展,线程池管理部分没有做扩展,保留了ThreadPoolExecutor的原有功能。

每次任务加入队列后会调用ensurePrestart(ThreadPoolExecutor实现)方法创建并启动一个线程,ThreadPoolExecutor的ensurePrestart方法:


	void ensurePrestart() {
	
	int wc = workerCountOf(ctl.get());
	if (wc < corePoolSize)
		addWorker(null, true);
	else if (wc == 0)
		addWorker(null, false);
	}

如果线程数小于corePoolSize则调用addWorker(null, true)创建核心线程线程,否则如果线程数为0(设置corePoolSize=0则可能走到这个分支)创建非核心线程。

corePoolSize大于0的情况下,ScheduledThreadPoolExecutor启动的线程数不会大于核心线程数。而且每一个线程创建的时候都不会有firstTask,线程总是从阻塞队列里获取任务执行。

提交任务

ScheduledThreadPoolExecutor提供execute(Executor接口的任务提交方法)、submit、schedule、scheduleAtFixedRate、scheduleWithFixedDelay等方法提交任务。

虽然说ScheduledThreadPoolExecutor只接受ScheduledFutureTask,但这并不是说应用层只能提交给他ScheduledFutureTask的任务,应用通过以上各方法提交任务的时候的Task是非常灵活的:可以是Callable,也可以是Runnable,ScheduledThreadPoolExecutor内部再把它们包装为ScheduledFutureTask — 对应用层来说是透明的。

提供了这么多提交任务的方法,无非是为了支持应用层以更加灵活的方式提交任务,其实底层执行逻辑大同小异。

我们就以scheduleAtFixedRate为例来分析任务提交过程:


	public ScheduledFuture<?> scheduleAtFixedRate(Runnable command,
	long initialDelay,
	long period,
	TimeUnit unit) {
		if (command == null || unit == null)
			throw new NullPointerException();
		if (period <= 0)
			throw new IllegalArgumentException();
		ScheduledFutureTask<Void> sft =new ScheduledFutureTask<Void>(command,null,
		triggerTime(initialDelay, unit),unit.toNanos(period));
		RunnableScheduledFuture<Void> t = decorateTask(command, sft);
		sft.outerTask = t;
		delayedExecute(t);
		return t;
	}

scheduleAtFixedRate方法的目的是提交一个在提交后延时(参数initialDelay指定)启动、以固定时间周期(参数period指定)重复执行的任务。

主要执行了以下动作:

  1. 首先创建了ScheduledFutureTask任务

  2. 之后将它包装成RunnableScheduledFuture,其实decorateTask方法直接返回了创建好的ScheduledFutureTask,没什么好分析的

  3. 然后调用delayedExecute启动线程执行任务

ScheduledFutureTask任务

这个也是ScheduledThreadPoolExecutor的重头戏!

ScheduledFutureTask继承自FutureTask,并实现了RunnableScheduledFuture接口,所以他是一个复合体:

  1. 可以异步执行带有返回的任务(Future接口)

  2. 可以执行周期性任务(RunnableScheduledFuture接口)

先认识几个重要属性:

time: 任务应该被执行的时间(纳秒)

period: 周期执行任务的间隔时间(纳秒),正数表示fix-rate,负数表述fix-delay(fixed-rate和fixed-delay前面jdk timer的文章讲过,含义一样)

outerTask:指向自己的RunnableScheduledFuture对象

compareTo方法:进入DelayedWorkQueue队列时需要调用CompareTo方法比较大小,以便把最近执行的任务放在堆头。compareTo方法最终比较的其实就是time属性。

run方法:


	public void run() {
		boolean periodic = isPeriodic();
		if (!canRunInCurrentRunState(periodic))
			cancel(false);
		else if (!periodic)
			ScheduledFutureTask.super.run();
		else if (ScheduledFutureTask.super.runAndReset()) {
			setNextRunTime();
		reExecutePeriodic(outerTask);
		}
	}

如果是一次性任务则直接调用FutureTask的run方法执行任务,否则,如果是周期性任务,首先调用FutureTask的runAndReset方法,调用成功的话,设置下次执行时间,然后通过调用reExecutePeriodic将当前任务再次加入队列。

runAndReset方法首先执行当前任务,执行完成后重新设置当前任务状态为NEW,准备下次执行。

通过分析runAndReset方法可以知道,周期性任务执行后不再能够获取到返回(回忆一下FutureTask的代码逻辑,状态设置为NEW之后,就不再可能获取到返回了)。

delayedExecute启动线程

方法代码很简单:


	private void delayedExecute(RunnableScheduledFuture<?> task) {
		if (isShutdown())
			reject(task);
		else {
			super.getQueue().add(task);
		if (isShutdown() &&!canRunInCurrentRunState(task.isPeriodic()) &&remove(task))
			task.cancel(false);
		else
			ensurePrestart();
		}
	}

任务加入队列,之后调用ensurePrestart方法启动线程!

任务直接进入队列,之后启动线程,把任务的执行完全交给ThreadPoolExecutor的任务执行线程Worker去调度了:Worker线程调用getTask方法从队列获取并执行任务!

我们现在可以大胆猜测一下了:周期性任务是有严格的执行时间要求的,没到执行时间的任务是不能执行的,由于ThreadPoolExecutor的任务执行线程的逻辑中并没有执行时间的判断,那么,这个逻辑应该是在getTask方法向队列获取任务的时候、由队列的出队方法实现的。

现在轮到阻塞队列DelayedWorkQueue出场了,我们带着这个疑问来研究一下DelayedWorkQueue队列。

阻塞队列DelayedWorkQueue

DelayedWorkQueue底层是以数组实现的堆结构。堆结构是一个完全二叉树,可以确保每一个节点都比他的叶子节点大(或者小),这样的话堆头节点(也就是数组的第一个元素)就一定是最大(或最小的)。

DelayedWorkQueue是存储ScheduledFutureTask的队列,最近执行的任务需要存放在堆头,每一个节点都应该比他的叶子节点小。

每次任务加入队列、节点出队、删除节点等操作都需要按照任务执行时间time重新调整队列。

初始化容量为16,新节点加入队列时如果队列容量不够则扩容原来容量的50%(新容量 = 1.5 * 旧容量)。

新节点入队的时候调用siftUp方法重新调整队列,以便新节点加入到堆的合适位置。


	private void siftUp(int k, RunnableScheduledFuture<?> key) {
		while (k > 0) {
			int parent = (k - 1) >>> 1;
			RunnableScheduledFuture<?> e = queue[parent];
			if (key.compareTo(e) >= 0)
				break;
			queue[k] = e;
			setIndex(e, k);
			k = parent;
		}
		queue[k] = key;
		setIndex(key, k);
	}

siftUp方法基于完全二叉树的一个特性:完全二叉树的第k个节点的父节点在数组中的位置为:(k-1)/2取整。

节点加入队列的时候默认加入到尾部k(当前数组的size),获取到k的父节点、比较当前节点和父节点,如果当前节点大于父节点(调用了ScheduledFutureTask的compareTo方法,比较的是time),说明当前节点找到了正确的位置,否则当前节点与父节点交换位置,继续寻找父节点比较、直到找到根节点。

这样,新加入的节点通过siftUp操作之后会根据任务触发时间time进入到队列的合适位置。

节点需要从队列remove的时候也需要执行类似的操作(调用siftUp或siftDown方法)确保堆的正确顺序。

这样一来,DelayedWorkQueue队列可以始终保证堆头(也就是数组的第一个元素)就是最近需要执行的任务。任务执行线程在获取最近需要被执行任务的时候,不需要遍历整个队列、只需要获取堆头第一个节点执行即可。

堆结构非常适合周期性任务或定时任务这一应用场景:节点加入任务时的时效性要求不高(因为是需要延时执行的任务嘛,时效性要求肯定就不高了),获取数据的时效性要求高(到了任务的执行时间了,最好当然是能即时获取到、立即执行),能够非常有效的提高任务执行效率。

了解了堆结构的特性,知道了堆结构入队出队的排序逻辑,接下来还需要去验证我们的猜测:出队逻辑会判断当前是否已经到了节点的执行时间。

因为ThreadPoolExecutor的getTask()方法调用队列的take方法获取任务,所以,直接看DelayedWorkQueue的take()方法就可以了,


	public RunnableScheduledFuture<?> take() throws InterruptedException {
		final ReentrantLock lock = this.lock;
		lock.lockInterruptibly();
		try {
				for (;;) {
					RunnableScheduledFuture<?> first = queue[0];
					if (first == null)
						available.await();
					else {
						long delay = first.getDelay(NANOSECONDS);
						if (delay <= 0)
							return finishPoll(first);
						first = null; // don't retain ref while waiting
						if (leader != null)
							available.await();
						else {
							Thread thisThread = Thread.currentThread();
							leader = thisThread;
							try {
								available.awaitNanos(delay);
							} finally {
								if (leader == thisThread)
									leader = null;
							}
						}
					}
				}
			} finally {
				if (leader == null && queue[0] != null)
					available.signal();
				lock.unlock();
			}
	}

逻辑很清晰:

  1. 队列上锁

  2. 获取堆头的任务,如果为空(空队列,没有任务),等待(注意等待是会释放锁资源的、等待被唤醒之后重新获取锁资源,有关ReentrantLock我们后面会专门做详细分析)

  3. 否则,判断堆头任务已经到执行时间了,堆头任务出队列并返回堆头任务。

  4. 否则,堆头任务执行时间未到,采用Leader-Follower模式等待

上面第3点验证了我们的猜测!

Leader-Follower模式是指线程池中多个线程在等待执行任务的时候,线程会竞争Leader,只有一个线程会在竞争中获胜成为Leader,其他线程就都是Follower。Leader获权仅等待指定时间(当前距下次任务执行的时间差)、Follower线程则需要无限期等待(被取消或者被其他线程唤醒)。等待过程中如果有新的节点加入队列并成为堆头的话(新加入的任务变成了最近要被执行的任务),此时需要设置leader为空并唤醒等待线程重新竞争leader。Leader-Follower模式可以有效避免所有等待线程都进入无限期、被动等待其他线程唤醒的等待模式、在等待时长达到后主动唤醒执行任务。

小结

周期性任务线程池ScheduledThreadPoolExecutor扒完了,简单总结下:

  1. 周期性线程池可以处理立即执行的任务、延迟执行的一次性任务、延迟执行的周期性任务(FixedRate和FixedDelay两种模式)

  2. 创建的时候指定核心线程数量,线程池最终启动的线程数量不会超过核心线程数量,每提交一个任务的同时启动一个线程、直到线程池数量达到核心线程数

  3. 不管是立即执行的任务、还是延迟执行的任务,任务提交后直接加入阻塞队列,等待线程从队列中获取并执行

  4. 阻塞队列采用DelayedWorkQueue,堆结构,最近执行的任务始终放在堆头

  5. 线程池中的线程采用Leader-Follower模式竞争任务,可以认为竞争成为Leader的线程获得了堆头任务的优先执行权

Thanks a lot!

### Log4j2-TF-3-Scheduled-1 线程未正确停止导致内存泄漏的解决方案 在 Web 应用程序中,Log4j2 的 `Log4j2-TF-3-Scheduled-1` 线程未正确停止可能会导致内存泄漏问题。以下是针对该问题的详细分析和解决方法。 #### 1. 问题的根本原因 `Log4j2-TF-3-Scheduled-1` 是 Log4j2 中用于异步日志记录的线程池的一部分。当应用程序关闭时,如果未能正确关闭这些线程池,则会导致线程无法终止[^1]。这种情况下,JVM 无法完全退出,从而引发内存泄漏。 #### 2. 解决方案 为了确保 `Log4j2-TF-3-Scheduled-1` 线程能够正确停止并释放资源,可以采取以下措施: #### 2.1 确保调用 `LogManager.shutdown()` 在应用程序关闭时,显式调用 `LogManager.shutdown()` 方法以确保所有日志组件被正确清理。例如,在 Servlet 容器(如 Tomcat)中,可以通过 `ServletContextListener` 来实现这一点: ```java import org.apache.logging.log4j.LogManager; public class ShutdownListener implements javax.servlet.ServletContextListener { @Override public void contextDestroyed(javax.servlet.ServletContextEvent sce) { LogManager.shutdown(); } @Override public void contextInitialized(javax.servlet.ServletContextEvent sce) { // Initialization logic if needed } } ``` 此代码片段确保在 Web 应用程序关闭时,Log4j2 的上下文被正确关闭[^2]。 #### 2.2 配置异步日志的线程池 Log4j2 提供了对异步日志记录的支持,但默认配置可能不适合所有场景。可以通过调整线程池的配置来优化行为。例如,在 `log4j2.xml` 文件中添加以下配置: ```xml &lt;AsyncLogger name=&quot;com.example&quot; level=&quot;debug&quot; includeLocation=&quot;true&quot;&gt; &lt;AppenderRef ref=&quot;Console&quot;/&gt; &lt;/AsyncLogger&gt; &lt;Configuration status=&quot;WARN&quot;&gt; &lt;Properties&gt; &lt;Property name=&quot;log4j2.threadPool.size&quot;&gt;5&lt;/Property&gt; &lt;Property name=&quot;log4j2.threadPool.queueSize&quot;&gt;100&lt;/Property&gt; &lt;/Properties&gt; &lt;/Configuration&gt; ``` 上述配置限制了线程池的大小和队列长度,避免线程长时间挂起或占用过多资源[^3]。 #### 2.3 使用 `isShutdownHookEnabled` 属性 Log4j2 默认会在 JVM 关闭时注册一个关闭钩子来清理资源。如果不需要此功能,可以通过设置 `isShutdownHookEnabled` 属性为 `false` 来禁用它,并手动管理关闭过程: ```properties log4j2.isShutdownHookEnabled=false ``` 然后在应用程序的生命周期管理中显式调用 `LogManager.shutdown()`。 #### 2.4 检查第三方库的兼容性 某些第三方库可能与 Log4j2 的线程管理机制不兼容。建议检查是否存在其他库干扰 Log4j2 的正常关闭逻辑。如果发现问题,考虑升级相关依赖或使用替代方案。 --- ### 示例代码:手动关闭 Log4j2 上下文 以下是一个完整的示例,展示如何通过 `ServletContextListener` 和 `LogManager.shutdown()` 手动关闭 Log4j2: ```java import org.apache.logging.log4j.LogManager; public class Log4j2ShutdownListener implements javax.servlet.ServletContextListener { @Override public void contextDestroyed(javax.servlet.ServletContextEvent sce) { System.out.println(&quot;Shutting down Log4j2...&quot;); LogManager.shutdown(); System.out.println(&quot;Log4j2 has been shut down.&quot;); } @Override public void contextInitialized(javax.servlet.ServletContextEvent sce) { System.out.println(&quot;Log4j2 initialized.&quot;); } } ``` 此代码确保在 Web 应用程序关闭时,Log4j2 的上下文被正确清理[^4]。 --- ### 总结 通过显式调用 `LogManager.shutdown()`、调整线程池配置以及禁用不必要的关闭钩子,可以有效解决 `Log4j2-TF-3-Scheduled-1` 线程未正确停止的问题。此外,定期检查第三方库的兼容性也是预防类似问题的重要步骤。 ---
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值