【JavaEE】多线程案例——定时器与线程池

文章目录

  • 一、定时器
    • 1.标准库中的定时器
    • 2.手动实现定时器
    • 3.定时器完整代码
  • 二、线程池
    • 1.认识线程池
    • 2.标准库中的线程池
    • 3.实现线程池
  • 三、最后

一、定时器

  与生活中的概念相似,所谓定时器就是设定一个之间,时间到了就执行某段代码。

  其实在很多网站上也有类似的定时器,比如我们去访问某个网站,访问一段时间时候还不能访问成功,这时就会提醒用户访问失败,这其实也是定时器的一种机制。

  像我们之前文章提到的join(指定超时时间)、sleep(休眠指定时间)等操作也是采用定时器的机制的,不过它们的定时器是基于系统内部来实现的。

1.标准库中的定时器

  在Java中我们有一个常用的包——java.util…,我们常用的Scanner也是出自这个包,那么在里面,其实还有一个Timer——java.util.Timer,这个东西就是标准库中的一个定时器。

  这个Timer的核心方法就一个——schedule(安排)

  schedule 包含两个参数. 第一个参数指定即将要执行的任务代码, 第二个参数指定多长时间之后执行 (单位为毫秒)。

public static void main(String[] args) {
        Timer time = new Timer();
        time.schedule(new TimerTask() {
            @Override
            public void run() {
                System.out.println("hello world");
            }
        },3000);
        System.out.println("main");
    }

【JavaEE】多线程案例——定时器与线程池_第1张图片
  实际上这里打印完,我们看打印结果其实进程还没有结束的,为什么会这样呢?这是因为Timer内部是有专门的线程,来负责执行注册的任务的。


2.手动实现定时器

  我们需要线了解定时器内部需要一些什么东西。

  1. 描述任务
  2. 组织任务
  3. 执行时间到了的任务


  具体分析

  1. 描述任务:创建一个专门的类来表示定时器中的任务(TimerTask).
//创建一个类,表示一个任务
class Mytask{
    //任务具体是什么
    private Runnable runnable;
    //任务具体实施的时间,保存任务要执行的毫秒级时间戳
    private long time;

    //after是一个时间间隔,不是绝对的时间戳的值
    public Mytask(Runnable runnable, long after) {
        this.runnable = runnable;
        //进行换算
        this.time = System.currentTimeMillis() + after;
    }

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

}

【JavaEE】多线程案例——定时器与线程池_第2张图片

  1. 组织任务:使用堆的数据结构把一些任务放到一起。

  为什么这里是使用堆的数据结构呢?我通过下面这个图来讲解一下:

【JavaEE】多线程案例——定时器与线程池_第3张图片
  而我们的标准库中,有一个专门的数据结构——PriorityQueue。但是由于可能会在多个线程里进行注册任务,同时还有一个专门的线程来进行任务执行,此处的队列就可能有线程安全的问题,因此我们采用的带有优先级并且有阻塞功能的队列——PriorityBlockingQueue。

class MyTimer{
    //定时器内部要能够存放多个任务
    PriorityBlockingQueue<Mytask> queue = new PriorityBlockingQueue<>();

    public void schedule(Runnable runnable,long delay){
        Mytask task = new Mytask(runnable,delay);
        queue.put(task);
    }
}
  1. 执行时间到了的任务:需要先执行时间最靠前的任务。

  需要一个线程,不停的检查当前优先队列的队首元素,检查当前最靠前的这个任务是不是时间到了。

  public MyTimer(){
        Thread t = new Thread(()->{
            try {
                //先取出队首元素
                Mytask task = queue.take();
                //再比较一下当前这个任务时间到了没
                long curTime = System.currentTimeMillis();
                if(curTime < task.getTime()){
                    //如果时间没到,把任务塞回队列中,
                    queue.put(task);
                }else{
                    //时间到了,执行这个任务
                    task.run();
                }
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        });
        t.start();
    }

  文章不是单纯的看,是带有思考性质的。我们上面的代码存在两个非常严重的问题。

(1) 第一个问题:Mytask没有指定比较规则

  我们写一个测试代码来看一下:

public static void main(String[] args) {
        MyTimer myTimer = new MyTimer();
        myTimer.schedule(new Runnable() {
            @Override
            public void run() {
                System.out.println("hello world");
            }
        },3000);
        System.out.println("main");
    }

  执行程序后,会发现有异常抛出
【JavaEE】多线程案例——定时器与线程池_第4张图片
  为什么会抛出这样的异常呢?

  上面实现的Mytask这个类的比较规则,并非默认存在,而是需要手动指定的。在这里,我们需要手动指定是按照时间大小来比较的。如果不手动指定,编译器就不知道如何比较了。

  这里多说一句,我们标准库中的集合类,很多都是有一定的约束限制的,不是随便拿哪个类都能够放到这些集合类里面去。

  那么,我们如何去解决这个问题呢?

  答案是:让Mytask这个类知道我们的比较规则,方法是:让Mytask类实现Comparable。

//创建一个类,表示一个任务
class Mytask implements Comparable<Mytask>{
    //任务具体是什么
    private Runnable runnable;
    //任务具体实施的时间,保存任务要执行的毫秒级时间戳
    private long time;

    //delay,不是绝对的时间戳的值
    public Mytask(Runnable runnable, long delay) {
        this.runnable = runnable;
        //进行换算
        this.time = System.currentTimeMillis() + delay;
    }

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

    public long getTime() {
        return time;
    }

    @Override
    public int compareTo(Mytask o) {
        return (int) (this.time - o.time);
    }
}

【JavaEE】多线程案例——定时器与线程池_第5张图片


(2)第二个问题:存在一个“忙等”问题

忙等:连续测试一个变量直到某个值出现为止。

  比如说,我们是下午5点西克的,可是在4点之后我们频频看时间,看了一次是4点01分,第二次还是4点01分…这种行为本身是没有意义的。等待是等了,但是课没有听,自己也没闲着,一直看时间,既没有实际性的收获,同时又累了自己。

【JavaEE】多线程案例——定时器与线程池_第6张图片

  那么我们如何解决这个问题呢?
  可使用wait来解决这样的问题,wait有一个版本可以指定等待时间,不需要notify,时间到了就能自然唤醒了。

  具体做法是,我们计算出当前时间和任务的目标之间的时间差,然后指定等待这个时间即可。

【JavaEE】多线程案例——定时器与线程池_第7张图片

  这里如果要指定时间,为什么我们不使用wait呢?

  这是因为,sleep是不能中途被唤醒的,而wait可以中途被唤醒。唤醒wait的时候,我们加上一个notify的操作就可以了。

  为啥需要唤醒呢?因为在等待的过程中,可能会有新的任务插入,而这个新的任务可能是时间最紧迫的,排在其他的任务之前的,需要首先执行它。如果不能被唤醒的话,新调整的线程执行的顺序就不能得到保证了。

【JavaEE】多线程案例——定时器与线程池_第8张图片


3.定时器完整代码

import java.util.PriorityQueue;
import java.util.concurrent.PriorityBlockingQueue;

//创建一个类,表示一个任务
class Mytask implements Comparable<Mytask>{
    //任务具体是什么
    private Runnable runnable;
    //任务具体实施的时间,保存任务要执行的毫秒级时间戳
    private long time;
    //delay,不是绝对的时间戳的值
    public Mytask(Runnable runnable, long delay) {
        this.runnable = runnable;
        //进行换算
        this.time = System.currentTimeMillis() + delay;
    }
    public void run(){
        runnable.run();
    }
    public long getTime() {
        return time;
    }
    @Override
    public int compareTo(Mytask o) {
        return (int) (this.time - o.time);
    }
}

class MyTimer{
    //定时器内部要能够存放多个任务
    PriorityBlockingQueue<Mytask> queue = new PriorityBlockingQueue<>();

    public void schedule(Runnable runnable,long delay){
        Mytask task = new Mytask(runnable,delay);
        //每次插入任务之后,都唤醒一下并且扫描线程,
        //让线程重新检查一下队首的任务是否到时间执行了
        synchronized (locker){
            locker.notify();
        }
        queue.put(task);
    }

    private Object locker = new Object();
    public MyTimer(){
        Thread t = new Thread(()->{
            try {
                //先取出队首元素
                Mytask task = queue.take();
                //再比较一下当前这个任务时间到了没
                long curTime = System.currentTimeMillis();
                if(curTime < task.getTime()){
                    //如果时间没到,把任务塞回队列中,
                    queue.put(task);
                    //指定一个等待时间,
                    synchronized (locker){
                        locker.wait(task.getTime() - curTime);
                    }
                }else{
                    //时间到了,执行这个任务
                    task.run();
                }
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        });
        t.start();
    }
}


public class Demo24 {
    public static void main(String[] args) {
        MyTimer myTimer = new MyTimer();
        myTimer.schedule(new Runnable() {
            @Override
            public void run() {
                System.out.println("hello world");
            }
        },3000);
        System.out.println("main");
    }
}


  总结过程:

  1. 描述一个任务:runnable + time
  2. 使用优先队列来组织若干个任务,PriorityBlockingQueue
  3. 实现schedule方法来注册任务到队列中
  4. 创建一个扫描线程,这个扫描线程不停的获取到队首元素,并且判定时间是否到达。

  需要注意,让Mytask类能够支持比较,以及注意处理这里的忙等问题。


二、线程池

1.认识线程池

  频繁创建线程和销毁线程需要时间,这样会大大降低系统的效率。
  线程池就是一种管理线程的池。它是用来解决我们频繁创建线程和销毁线程造成的开销大的问题。

  操作如下:

把线程提前创建好,放到线程池中。如果后续需要用到线程,直接从池子里取,而不必向系统申请。当线程用完了之后,也不用还给系统,而是放回池子里,以备下次利用,这样的话,创建和销毁线程的速度就快了。

  为啥把线程放到线程池里,会比从系统这边申请释放的速度更快呢?

  在操作系统中,有两种CPU状态,一种是用户态,一种是内核态。

  1. 内核态(Kernel Mode):运行操作系统程序,操作硬件;
  2. 用户态(User Mode):运行用户程序。

【JavaEE】多线程案例——定时器与线程池_第9张图片

  那为啥用户态的效率比内核态要高?

  举个例子,假如你在超市买了东西去买单,你有两种方式买单:

  1. 去那个可以自己买单的机器自己操作,这个就是纯用户态操作,自己完成操作。
  2. 让超市收银员扫你的商品,然后付钱。这个是内核态操作,不是自己完成的。

  如果你让超市收银员扫码收钱的话,万一中间有个vvip一插队,你就得先等待了,因为vvip可以先埋单,那么这个时候,你的效率比起你自己去机器买单的效率就低得多了。因为这中间的过程不可控,不能避免vvip人员的插队。而用户态的操作却是可控的。


2.标准库中的线程池

  标准库中的线程池叫做ThreadPoolExecutor.

  我们打开Java的官方文档找到java.util.concurrent这个包,concurrent是并发的意思,Java中很多和多线程相关的组件都在这个包里面,一般叫juc。
【JavaEE】多线程案例——定时器与线程池_第10张图片
【JavaEE】多线程案例——定时器与线程池_第11张图片
  虽然上面有这么多参数,实际上最重要的还是第一个——核心线程数。

  同时,这里又衍生出了一个问题:

有一个程序,这个程序要并发的或者多线程的来完成一些任务,如果使用线程池的话,这里的线程数设为多少合适?(面试题)

  答案是:
  要通过性能测试的方法,找到合适的值。
  例如,写一个服务器程序,服务器里通过线程池多线程地处理用户请求就可以对这个服务器进行性能测试了。比如构造一些请求,要测试性能。因为需要测试性能,这里面需要构造的请求就很多,比如每秒发送500/1000/2000个请求,具体根据我们实际的业务场景构造一个合适的值。
  然后,我们就可以根据不同的线程池的线程数观察程序处理任务的速度、程序持有的CPU占用率。
  当线程数多了,具体的速度是会变快的,但是CPU占用率也会变高;当线程数变少了,整体的速度是会变慢,但是CPU占用率也会变低。
  因此,我们需要在合适的情况找到一个让程序速度能接受,并且CPU占用率也可以的平衡点。
  不同类型的程序,由于单个任务里面的CPU计算时间和阻塞时间的分布是不相同的,因此随机的测试数字往往是不靠谱的


  那我们使用多线程的原因不是为了让程序跑的更快吗?为什么不让CPU的占用率太高呢?
  这是因为对于线上服务器,我们需要留有一定的冗余,应对一些可能会突发的情况。比如说,请求突然暴涨。如果本身的CPU已经快占完了,突然来了一波请求的峰值,此时服务器就可能挂了。

  标准库中还提供了一个简化版的线程池——Executors。
  这个线程池本质上是针对ThreadPoolExccutor进行了封装,提供了一些默认的参数。

  那么接下来,我们看一下这个Executors是怎么用的,然后仿照这个写一个线程池。
  

  Executors 创建线程池的几种方式:

创建方式 解释
newFixedThreadPool 创建固定线程数的线程池
newCachedThreadPool 创建线程数目动态增长的线程池
newSingleThreadExecutor 创建只包含单个线程的线程池.
newScheduledThreadPool 设定 延迟时间后执行命令,或者定期执行命令. 是进阶版的 Timer.

Executors 本质上是 ThreadPoolExecutor 类的封装

  • 使用 Executors.newFixedThreadPool(10) 能创建出固定包含 10 个线程的线程池.
  • 返回值类型为 ExecutorService
  • 通过 ExecutorService.submit 可以注册一个任务到线程池中
public class Demo25 {
    public static void main(String[] args) {
        //创建一个固定的线程数目的线程池,参数指定了线程个数
        ExecutorService pool = Executors.newFixedThreadPool(10);
//        //创建于给自动扩容的线程池,会根据任务量来自动扩容
//        Executors.newCachedThreadPool();
//        //创建一个只有一个线程的线程池
//        Executors.newSingleThreadExecutor();
//        //创建一个带有定时器功能的线程池,类似于Timer
//        Executors.newScheduledThreadPool(4000);

        for (int i = 0; i < 100; i++) {
            pool.submit(new Runnable() {
                @Override
                public void run() {
                    System.out.println("hello threadpool");
                }
            });
        }
    }
}


3.实现线程池

  线程池中如何实现?

  1. 先能够描述任务——直接使用Runnable
  2. 需要组织任务——直接使用BlockingQueue
  3. 能够描述工作线程
  4. 还需要能够组织这些线程
  5. 实现往线程池里面添加任务

  完整代码



import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;

class MyThreadPool {
    // 1. 描述一个任务. 直接使用 Runnable, 不需要额外创建类了.
    // 2. 使用一个数据结构来组织若干个任务.
    private BlockingQueue<Runnable> queue = new LinkedBlockingQueue<>();
    // 3. 描述一个线程, 工作线程的功能就是从任务队列中取任务并执行.
    static class Worker extends Thread {
        // 当前线程池中有若干个 Worker 线程~~ 这些 线程内部 都持有了上述的任务队列.
        private BlockingQueue<Runnable> queue = null;

        public Worker(BlockingQueue<Runnable> queue) {
            this.queue = queue;
        }

        @Override
        public void run() {
            // 就需要能够拿到上面的队列!!
            while (true) {
                try {
                    // 循环的去获取任务队列中的任务.
                    // 这里如果队列为空, 就直接阻塞. 如果队列非空, 就获取到里面的内容~~
                    Runnable runnable = queue.take();
                    // 获取到之后, 就执行任务.
                    runnable.run();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }
    }
    // 4. 创建一个数据结构来组织若干个线程.
    private List<Thread> workers = new ArrayList<>();

    public MyThreadPool(int n) {
        // 在构造方法中, 创建出若干个线程, 放到上述的数组中.
        for (int i = 0; i < n; i++) {
            Worker worker = new Worker(queue);
            worker.start();
            workers.add(worker);
        }
    }

    // 5. 创建一个方法, 能够允许程序猿来放任务到线程池中.
    public void submit(Runnable runnable) {
        try {
            queue.put(runnable);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

public class Demo26 {
    public static void main(String[] args) {
        MyThreadPool pool = new MyThreadPool(10);
        for (int i = 0; i < 100; i++) {
            pool.submit(new Runnable() {
                @Override
                public void run() {
                    System.out.println("hello threadpool");
                }
            });
        }
    }
}


  打印结果:
【JavaEE】多线程案例——定时器与线程池_第12张图片


三、最后

  我真是颓了呀,这知识不入脑啊,摄入的消化不良,这是目前最大问题。而这几天的问题在于,疲倦了,真有点疲倦了。这周六得好好休息,恢复一下精力。要一往无前啊。

你可能感兴趣的:(JavaEE,多线程,java,java-ee,后端)