Java定時任務Timer排程器【三】 注意事項(任務精確性與記憶體洩漏)
一、任務精確性
通過前兩節的分析,大概知道了Timer的執行原理,下面說說使用Timer需要注意的一些事項。下面是Timer簡單原理圖
從上圖可以看到,真正執行鬧鐘的是一個單執行緒。也就是說佇列中的鬧鐘,只能依次進行序列化的操作,鬧鐘的定時執行得不到保證。
比如下面的例子(本節所有程式碼只列出關鍵部分,下同)
public class ScheduleDemo { public static void main(String[] args) throws Exception { Timer timer = new Timer(); timer.schedule(new AlarmTask("鬧鐘"),1000,2000); } static class AlarmTask extends TimerTask { public void run() { log.info(new Date() +" 嘀。。。"); Thread.sleep(10_000); //模擬鬧鐘執行時間 } } }
從下面的執行結果可以看到,預期2秒以後執行的鬧鐘,推遲到了10秒以後。
Fri Nov 16 14:49:39 CST 2018 嘀。。。
Fri Nov 16 14:49:49 CST 2018 嘀。。。
下面是鬧鐘執行的時序圖
解決方法
針對上面的情況,使用者可在AlarmTask.run()裡面再開一個非同步執行緒,讓TimerThread及時返回,執行佇列中後續的鬧鐘。
public class ScheduleDemo { public static void main(String[] args) throws Exception { Timer timer = new Timer(); timer.schedule(new AlarmTask("鬧鐘"),1000,2000); } static class AlarmTask extends TimerTask{ static ExecutorService threadPool = Executors.newCachedThreadPool(); public void run() { // 建立執行緒池,提高執行緒的複用,避免執行緒建立與上下文切換所帶來的開銷 threadPool.execute(new Runnable() { public void run() { log.info(new Date()+" 嘀。。。"); Thread.sleep(10_000); //模擬鬧鐘執行時間 } }); } } }
從下面的執行結果可以看到,所有的鬧鐘執行間隔符合預期的2秒。
Fri Nov 16 15:37:59 CST 2018 嘀。。。
Fri Nov 16 15:38:01 CST 2018 嘀。。。
Fri Nov 16 15:38:03 CST 2018 嘀。。。
Fri Nov 16 15:38:05 CST 2018 嘀。。。
Fri Nov 16 15:38:07 CST 2018 嘀。。。
Fri Nov 16 15:38:09 CST 2018 嘀。。。
下面是非同步執行的時序圖
通過非同步執行任務的方式雖然保證了執行時間的準確性,但也會出現以下問題:
1. 作業系統一般對執行緒總量加以限制,比如linux下的/proc/sys/kernel/threads-max。當系統併發量很高的時候,開非同步會影響其他應用的執行緒使用。
2. 如果當前系統執行著計算密度型應用,在CPU使用率很高的情況下將會出現排隊現象。
3. JVM會給每一個執行緒分配棧記憶體,如果Timer分配的任務過多,將很快出現記憶體溢位的情況。
二、記憶體洩漏
第二個需要注意的問題是,當用戶取消了一個任務以後,失效的任務依然會佔據著queue佇列,造成記憶體洩漏,下面是取消任務的原始碼。
public abstract class TimerTask implements Runnable {
final Object lock = new Object();
int state = VIRGIN;
static final int CANCELLED = 3;
public boolean cancel() {
synchronized(lock) {
boolean result = (state == SCHEDULED);
state = CANCELLED;
return result;
}
}
可以看到TimerTask.cancel()僅僅只是修改task的狀態值,並沒有及時清理失效的任務。縱觀整個Timer原始碼,唯一進行自我清理是在TimerThread中維護的(前提是當前失效的任務優先順序最高)。
class TimerThread extends Thread {
private TaskQueue queue;
public void run() {
mainLoop();
}
private void mainLoop() {
while (true) {
synchronized(queue) {
task = queue.getMin();
synchronized(task.lock) {
if (task.state == TimerTask.CANCELLED) {
// 整個Timer中唯一維護自我清理的地方
queue.removeMin();
continue;
}
}
}
}
}
}
下面列舉一個記憶體洩漏的例子。
public class ScheduleDemo {
public static void main(String[] args) throws Exception {
Timer timer = new Timer();
int i = 0;
timer.schedule(new AlarmTask("鬧鐘"+i++),100,100);
while(true){
TimerTask alarm = new AlarmTask("鬧鐘"+i);
timer.schedule(alarm,100,10_0000);
alarm.cancel();
Thread.yield();
log.info("已取消鬧鐘"+i++);
}
}
static class AlarmTask extends TimerTask{
String name ;
byte[] bytes = new byte[10*1024*1024]; //模擬業務資料
public AlarmTask(String name){
this.name=name;
}
@Override
public void run() {
log.info("["+name+"]嘀。。。");
}
}
}
為了快速暴露問題,特意增加了鬧鐘例項的大小;同時限制了jvm的堆記憶體分配
-Xmx100M -Xms100M
執行結果如下
已取消鬧鐘1
已取消鬧鐘2
已取消鬧鐘3
已取消鬧鐘4
已取消鬧鐘5
已取消鬧鐘6
已取消鬧鐘7
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
at com.haanoo.schedule.ScheduleDemo$AlarmTask.<init>(ScheduleDemo.java:25)
at com.haanoo.schedule.ScheduleDemo.main(ScheduleDemo.java:15)
[鬧鐘0]嘀。。。
[鬧鐘0]嘀。。。
從執行的結果看出,失效鬧鐘沒有被及時清理,且很快造成了OOM(主執行緒因OOM異常退出,而TimerThread執行緒不受影響)。
有人會想:會不會GC沒有執行,或來不及執行而導致OOM?下面看一下GC日誌,同時dump一下OOM時的堆記憶體,方便後面MAT分析
-XX:+PrintGC -XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=d:/timer.dump
下面是執行結果
已取消鬧鐘1
[GC (Allocation Failure) 24103K->21319K(98304K), 0.0187832 secs]
已取消鬧鐘2
已取消鬧鐘3
[GC (Allocation Failure) 42289K->41792K(98304K), 0.0081251 secs]
已取消鬧鐘4
已取消鬧鐘5
[GC (Allocation Failure) 63024K->62160K(98304K), 0.0079021 secs]
[Full GC (Ergonomics) 62160K->62038K(98304K), 0.0261820 secs]
已取消鬧鐘6
已取消鬧鐘7
[Full GC (Ergonomics) 83014K->82518K(98304K), 0.0083257 secs]
[Full GC (Allocation Failure) 82518K->82503K(98304K), 0.0088677 secs]
java.lang.OutOfMemoryError: Java heap space
Dumping heap to d:/timer.dump ...
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
at com.haanoo.schedule.ScheduleDemo$AlarmTask.<init>(ScheduleDemo.java:25)
at com.haanoo.schedule.ScheduleDemo.main(ScheduleDemo.java:15)
[鬧鐘0]嘀。。。
Heap dump file created [85271860 bytes in 0.052 secs]
從日誌可以看出GC一直在努力,中間進行了3次Full GC(此時會影響應用效能),但基本沒啥效果。
再用MAT看一下堆快照
通過MAT觀察則一目瞭然,失效的7個鬧鐘(每個10M)佔據了70M堆記憶體。
通過上面的分析可以看到,雖然TimeTask.cancel()提供了一個及時取消的介面,但卻沒有一個自動機制保證失效的任務及時回收(需要使用者手動處理)。
解決方法
為了防止記憶體洩漏,Timer提供了一個介面purge()及時清除無效任務。
public class Timer {
private final TaskQueue queue = new TaskQueue();
public int purge() {
int result = 0;
synchronized(queue) {
for (int i = queue.size(); i > 0; i--) {
if (queue.get(i).state == TimerTask.CANCELLED) {
// 清除無效任務
queue.quickRemove(i);
result++;
}
}
if (result != 0)
// 重新整理佇列中得任務
queue.heapify();
}
return result;
}
使用者只要合理地使用timer.purge()就能避免記憶體洩漏,遺憾地是在我所接觸的專案中,(或許沒有引起重視)基本沒有用到這個介面方法。