Java定时任务Timer调度器【三】 注意事项(任务精确性与内存泄漏)

原创
2018/11/26 17:28
阅读数 5.4K

一、任务精确性

通过前两节的分析,大概知道了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()就能避免内存泄漏,遗憾地是在我所接触的项目中,(或许没有引起重视)基本没有用到这个接口方法。

展开阅读全文
打赏
0
2 收藏
分享
加载中
更多评论
打赏
0 评论
2 收藏
0
分享
返回顶部
顶部