【多线程】模拟实现一个定时器

1. Java自带的定时器

相信大家都定过闹钟,在我上学有早八的时候,硬是要定三个闹钟才起得来,7:20,7:30,7:40,那么我们今天所要实现的定时器,就类似于闹钟,设定多长时间之后,要干某某事情...

定时器是一种实际开发中非常常用的组件,比如网络通信中,如果对方 500ms 内没有返回数据,则断开连接尝试重连等等

在 Java 标准库中,也给我们提供了一个定时器:Timer 类,这个类的核心方法是 schedule。

schedule 方法中包含了两个参数,第一个参数指定将要执行的代码,第二个参数指定多长时间之后执行,单位是毫秒。


public static void main(String[] args) {
    Timer timer = new Timer();
    timer.schedule(new TimerTask() {
        @Override
        public void run() {
            System.out.println("执行任务!");
        }
    }, 3000);
}

此时我们运行程序,就会在 3s 后打印 "执行任务!",这里有个点需要注意,我们描述任务使用的是 TimerTask 里的 run 方法,其实这个 TimerTask 里的 run 方法,跟 Runnable 的 run 方法是一模一样,因为源码实现中,TimerTask 类实现了 Runnable 这个接口,相当于多了一层封装!

当然我们也可以一次注册多个任务,这就好比列出一个清单一样:

这里我们注意,清单上的任务有很多,每个任务多久后执行的时间点也不一样,那我写清单的时候,可能先想到下午要干嘛,再想到早上要干嘛,先在清单上写下午干的活,再写早上要干的活,但是我执行的顺序,肯定是先执行早上的任务,在执行中午,下午...

这就好比我们注册任务一样,先注册一个 3s 后执行的任务,再注册一个 2s 后执行的任务,显然后注册的任务要先执行!


public static void main(String[] args) {
    Timer timer = new Timer();
    timer.schedule(new TimerTask() {
        @Override
        public void run() {
            System.out.println("3秒后的任务执行");
        }
    }, 3000);
    timer.schedule(new TimerTask() {
        @Override
        public void run() {
            System.out.println("2秒后的任务执行");
        }
    }, 2000);
}
/*
打印结果:
2秒后的任务执行
3秒后的任务执行
*/

有了上述对定时器的认识,这里我们就模拟实现一个 Timer(定时器)。


2. 模拟实现定时器

上述的案例和分析,我们能很清楚的认识到,并不是先注册的任务先执行,而是按照时间前后来执行,比如我们定了三个闹钟:17:30 14:00 16:00,那么肯定是 14:00 的闹钟最先响!

所以我们注册的任务也是同理,是带有优先级的!这个优先级取决你设定的时间,到了时间就启动。

想到优先级,在前面学习的数据结构中,有一个优先级队列 PriorityQueue,底层是用堆来实现的,这样一来我们可以建小堆,按照指定的时间进行比较,谁会最先执行,谁就是堆顶的元素。

同时我们在内部定义一个线程,来扫描堆中元素是否到点该执行了,由于我们是小堆,所以堆顶元素一定是最先执行的,如果堆顶的任务都不能执行,那么后面的任务肯定也都不能执行,所以这个线程只需要扫描堆顶的元素,判断堆顶元素是否到时间的就行!

但是问题又来了,调用 schedule 注册任务时是一个线程往堆中写,而 MyTimer 内部还有一个线程一直读堆顶元素,而这两个线程都是在操作里面的优先级队列,势必会有线程安全问题(一个线程读,一个线程写),此处显然 PriorityQueue 就不行了,但是还有另外一个选择:PriorityBlockingQueue,这个上节我们提到过,是 Java 标准库提供的一个优先级阻塞队列,是线程安全的!

好了,基于上述的分析,下面我们就来模拟实现一个定时器,取名为 MyTimer:

这里我们使用内部类的方式,利用 MyTask 类来描述任务和执行任务的时间:


public class MyTimer {
    private static class MyTask implements Comparable<MyTask> {
        // 要执行的任务
        private Runnable runnable;
        // 执行任务的时间
        private long delay;

        private MyTask(Runnable runnable, long delay) {
            this.runnable = runnable;
            this.delay = delay;
        }

        private void run() {
            runnable.run();
        }

        // 重写 compareTo 按照执行时间进行比较
        @Override
        public int compareTo(MyTask o) {
            // 根据注册时间指定优先级, 建小堆
            return (int) (this.delay - o.delay);
        }
    }
}

接着 MyTimer 类中还需要有一个优先级阻塞队列来存放要执行的任务,加上一个线程来扫描堆顶元素:


// 存放任务
private PriorityBlockingQueue<MyTask> queue = new PriorityBlockingQueue<>();

// 扫描线程
private Thread t;

那么这个 t 线程如何扫描呢?此处我们可以在 MyTimer 构造方法中,让 t 线程进行扫描:


public MyTimer() {
    t = new Thread(() -> {
        while (true) {
            try {
                MyTask task = queue.take();
                // 判断是否到了执行时间了
                if (task.delay <= System.currentTimeMillis()) {
                    // 执行任务
                    task.run();
                } else {
                    // 没到时间把任务塞回队列
                    queue.put(task);
                }
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    });
    t.start();
}

最后就是 schedule 方法了:


public void schedule(Runnable runnable, long delay) {
    // 注册一个任务, 执行时间为: 当前时间+延迟时间
    queue.put(new MyTask(runnable, System.currentTimeMillis() + delay));
}

其实写到这,一个简单的定时器已经写完了,但是这个代码不够好,我们可以做优化!


3. 定时器代码优化

回过头来看我们上述写的 MyTimer 的构造方法,里面是一个 while (true) 循环,也就意味着这个线程要无止境的从队列中读元素,而这个线程会一直占用 CPU 资源。

举个例子,比如我现在定了一个早上 8:00 的闹钟,我 7点醒了,那么我打开手机一看,时间没到,关上手机,立刻又打开手机看,时间没到,关上手机,又马上打开手机看,还是没到时间.....

这里的 while (true) 循环就类似于上面的例子,t 线程从队列中取元素,发现时间没到,塞回队列,第一次循环结束,又从队列中取,发现时间没到,又塞回去....

有必要一直看到没到点吗?能不能让这个线程等一会呢?假设还差 100s,那就让这个线程等 100s 之后再执行嘛!这样还节省了 CPU 资源,更不用反复从队列中 take 和 put 了,也不用重复的向上调整了(堆的特性)!

于是我们就可以使用 wait 带参数版本,让线程主动等一段时间,等当前时间和执行到点时间的时间差就行,那么既然等的时间是明确的,可不可以采用 sleep 呢?

注意!sleep 是不建议的,如果当前需要等 30min 执行任务,那么在 sleep 的过程中,又添加了一个任务呢?只需要 10s 后执行呢?这样可能就会错过新任务的时间!有人说,sleep 不是也是可以唤醒吗?但是 sleep 的唤醒是会抛异常的,这个不推荐!

如果采用 wait 带参数,则会更合适,每次注册任务的时候,都 notify 唤醒一下,重新看堆顶的元素即可。


public MyTimer() {
    t = new Thread(() -> {
        while (true) {
            try {
                MyTask task = queue.take();
                long curTime = System.currentTimeMillis();
                if (curTime <= task.delay) {
                    // 到时间了执行任务
                    task.run();
                } else {
                    // 没到时间先放回队列
                    queue.put(task);
                    // 根据当前时间和任务要执行的时间, 等一个时间差
                    synchronized (this) {
                        this.wait(curTime - task.delay);
                    }
                }
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    });
    t.start();
}

public void schedule(Runnable runnable, long delay) {
    // 注册一个任务, 执行时间为: 当前时间+延迟时间
    queue.put(new MyTask(runnable, System.currentTimeMillis() + delay));
    // 每次有新的任务,都唤醒一下,让线程重新读堆顶元素,防止新任务最先执行
    synchronized (this) {
        this.notify();
    }
}

但是上述代码还存在一个线程安全问题!

当代码执行完 12 行,就被 CPU 切走了,另一个线程开始注册任务,这个任务比堆中的其他任务都先执行,那么此时的 notify 就空喊一嗓子了,当 CPU 切回来时,扫描线程 t 就可能没有感知到又有新的任务注册进来了。

本质上就是 synchronized 范围太小了!我们扩大加锁的范围即可:


public MyTimer() {
    t = new Thread(() -> {
        while (true) {
            try {
                synchronized (this) {
                    MyTask task = queue.take();
                    long curTime = System.currentTimeMillis();
                    if (curTime <= task.delay) {
                        // 到时间了执行任务
                        task.run();
                    } else {
                        // 没到时间先放回队列
                        queue.put(task);
                        // 没到根据当前时间和任务要执行的时间, 等一个时间差
                        this.wait(curTime - task.delay);
                    }
                }
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    });
    t.start();
}

这样一来,t 线程读堆顶元素的时候,其他线程就不能放入元素了,等 t 线程执行到 this.wait(),就会自动释放锁,后面其他线程再注册任务的时候,每次 notify 就都是有效的了,t 也能感知到了!保证了每次 notify 都能有效唤醒!

那么实现到这,我们差不多已经能和自带的 Timer 差不多的效果了,但是注意!这里的定时器,不一定那么的准时!而我们目前能写到这个地步也就差不多可以了。


下期预告:【多线程】实现一个线程池

  • 3
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 4
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

程序猿教你打篮球

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值