Java EE|多线程代码实例之定时器与线程池

🔴定时器

什么是定时器以及开发中的作用

程序中的定时器功能与我们现实生活中的定时器功能相似,都有起提示作用,但是与现实生活中闹钟不同的是,程序里的闹钟不仅是提醒,还能真正的去做事情。也就是说它的权限更大,更像是一个机器人,我们给它设定一个时间点让它去做什么事情,而不是说像闹钟一样,只能提醒我们,但是改变不了我们的想法,到底做不做这件事。

我们以后开发中,也会经常使用到定时器,这是软件开发中的一个重要组件。尤其是“网络编程”,比如说我们访问一个网页的话,很容易出现卡的现象,这时我们就可以使用定时器,来进行“止损”。一旦超时,就结束这次访问,不再阻塞/等待。

标准库中的定时器

对于定时器,标准库中提供了一个Timer类,我们可使用这个Timer来做我们想定时做的事情。

Timer类的核心方法是schedule,它包含两个参数.

第一个参数是即将要执行的任务的代码,以Runnable接口的形式呈现或者说这个TimerTask这个抽象类实现了Runnable接口,我们只需要继承这个类重写它的run方法即可;

第二个参数是指定多长时间后执行,单位为毫秒(millisecond)。

public class Code28_TimerTest {
    public static void main(String[] args) {
        Timer timer=new Timer();
        System.out.println("已经设置好定时器");
        timer.schedule(new TimerTask() {
            @Override
            public void run() {
                System.out.println("执行任务1");
            }
        },1000);
        timer.schedule(new TimerTask() {
            @Override
            public void run() {
                System.out.println("执行任务2");
            }
        },2000);
        timer.schedule(new TimerTask() {
            @Override
            public void run() {
                System.out.println("执行任务3");
            }
        },3000);
    }
}

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-hh46HNBk-1673685943980)(F:\typora插图\image-20230113231532477.png)]

定时器的实现

(一)思路分析

我们已经知道怎么使用标准库中的定时器,下边我们来自己实现一个定时器。那么在实现定时器之前,我们需要知道定时器需要做什么,才能更好的实现。

1.让被注册的任务能够在指定时间内执行

2.一个定时器可以注册多个任务,并且按照时间的先后执行

那么,接下来我们想想怎么才能达到这样的目的。

首先,他需要按照推迟时间的长短存放我们的任务,我们需要一个数据结构存放,不难想到需要队列,又因为由时间先后来决定先后,所以我们可以采用一个优先级队列,又因为定时器可以在多线程环境下正常工作,所以我们还需要保证线程安全,所以这里我们最终存储任务的数据结构就是基于堆实现的阻塞队列即PriorityBlockingQueue,因为这里使用的是时间戳,所以不需要额外传比较器,直接创建的就是小根堆.

其次,我们需要一个扫描线程,用来判断是不是到该执行的时间了,确保MyTImer一旦被实例化就能够这个线程就开始工作,所以,我们需要在这个类的构造方法中创建这个线程(不理解,可以先记住在构造方法中需要创建线程这个点)。

然后,对比原来的Timer,还有一个非常重要的成员方法schedule,用来把用户设置的任务和时间啥的放进任务队列,相当于普通队列的offer功能。

最后,因为线程是抢占执行、随机调度的,我们这里就通过wait/notify来控制线程的执行顺序。wait/notify方法的调用需要一个对象,它的阻塞队列和我们存放任务的阻塞队列相互呼应,只不过我们外部看不到。所以我们这里再定义一个私有的Object类对象。

综上我们的MyTimer={私有Object类型对象+存放任务的阻塞队列+连接对象阻塞队列和存放任务队列的扫描线程}。

具体实现细节我们在下边讨论

(二)代码实现

因为我们的任务都是Runnable类型的,与此同时,我们还需要给它配一个时间,所以我们不妨自定义一个MyTask类。因为,任务之间我们是需要排优先级的,是可比较的,所以我们需要实现比较器,这里我们采用实现Comparable接口。随之而来的,我们需要重写compareTo方法。因为这个任务是以runnable形式存在的,而这个runnable我们又是定义在类中的,它是需要显式调用,我们的任务才能工作,所以我们这里需要提供run方法,供外部调用,启动任务。

因为我们可以很容易的通过本地方法currentTimeMillis得到当前的时间,但是我们通过记录每次任务安排时间的时刻,但是这样做免不了有些麻烦,所以我们不如直接放任务时刻就设置成具体的时间点,也就说我们在schedule时时间在原来的基础上在加上当前的时间。

最后,我们需要明确wait和notify的位置以及过程的模拟其实也就是线程怎么周期性扫描的问题。

wait、notify的话肯定是locker调用,然后呢,我们每次去取任务时,如果到时间了,不就直接执行了吗,但是如果不到时间,那么我们需要阻塞等待,所以说,我们的wait就在if逻辑里边的put(会触发堆的调整)之后。又因为wait其实是和join方法一样,可以规定等待的时间,不死等的,那么这个时间定的肯定是现在时间和目标时间的时间差。wait这里就安排好了,记得进行异常处理哦。

那么notify呢?因为上边如果不到时间的话,线程其实已经进入了阻塞等待状态,这个时候我们新加入的任务如果执行时间早于原来等待时间的话,就错过了,所以,这里我们每次新任务加进来的时候,就进行通知。原来如果在阻塞等待,那么就解除阻塞状态,如果没有在等待状态,空打一枪也没关系。这样notify的位置我们也安排好了,记得加锁。

最后,对于线程安全,我们把读写操作捆绑,让这个操作是原子的。

【一般锁的范围我们需要合理控制】

class MyTask implements Comparable<MyTask>{
    //需要执行任务的内容
    private Runnable runnable;
    //推迟的时间
    private long delaytime;

    public MyTask(Runnable runnable, long delaytime) {
        this.runnable = runnable;
        this.delaytime = delaytime;
    }
    public long getDelaytime() {
        return delaytime;
    }

    @Override
    public int compareTo(MyTask o) {
        return (int)(this.delaytime-o.delaytime);
    }
    //执行任务!!!!
    public void run(){
        runnable.run();
    }
}
class MyTimer{
    //用来控制线程执行顺序的对象(利用它的阻塞队列)
    private Object locker=new Object();
    //用来存放任务的队列
    private PriorityBlockingQueue<MyTask> queue=new PriorityBlockingQueue<>();
    //扫描线程
    private Thread t;
    public MyTimer(){
        t=new Thread(){
            @Override
            public void run() {
                while (true) {
                    try {
                        synchronized (locker){
                            MyTask myTask=queue.take();
                            long curTime=System.currentTimeMillis();
                            if(curTime<myTask.getDelaytime()){
                                //不到时间,不执行,把任务再塞回去
                                queue.put(myTask);
                                //这里的等待是最长等待时间
                                locker.wait(myTask.getDelaytime()-curTime);
                            }else{
                                myTask.run();
                            }
                        }
                    } catch (InterruptedException e) {
                        throw new RuntimeException(e);
                    }
                }
            }
        };
        t.start();
    }
    public void schedule(Runnable runnable,long after){
        MyTask myTask=new MyTask(runnable,System.currentTimeMillis()+after);
        queue.put(myTask);
        synchronized (locker) {
            locker.notify();
        }
    }
}
public class Code29_MyTimer {
    public static void main(String[] args) {
        MyTimer myTimer=new MyTimer();
        myTimer.schedule(new Runnable() {
            @Override
            public void run() {
                System.out.println("执行任务1");
            }
        },1000);
        myTimer.schedule(new Runnable() {
            @Override
            public void run() {
                System.out.println("执行任务2");
            }
        },2000);
    }
}

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-OpABWJho-1673685943982)(F:\typora插图\image-20230114004829899.png)]

🔴线程池

什么是线程池

当当前代码能满足需求的前提下,我们不免想要压缩时间来提高编程的效率。我们已经知道线程是为了解决并发程度很高的情况下,创建/销毁进程时间开销很大的一种优化办法。然而,很多东西需要有对比,当并发程度进一步提高时,多线程确实要比多进程编程效率要高,但是这跟我们预期的效率还差点意思,所以,为了进一步提高并发编程下的效率,前辈们提出了一些方法供我们使用。

1.纤程也称为“轻量级线程”。虽然这种办法能给并发编程带来一系列的优势,但是但是它并没有被广泛纳入标准库中,java就位列其中。不过近些年比较火的GO语言,将它纳入标准库了。

2.“线程池”。与字符串常量池、数据库连接池类似的是,线程池也是提前创建好,随用随去,不需要反复创建/销毁,效率会比较高。我们在java中,还是使用线程池比较多一些。

对于它的概念我们不必抠字眼,只需要理解它的意思,知道它大概是在干什么就可以了。

不过,这里边可能会有一个疑问,为什么从池子中拿和放比反复创建/销毁效率要高?反映到计算机本身上的解释又是什么?

对此,这里给出一种解释。

创建线程/销毁线程都是由操作系统内核来做的;而从池子中获取线程,把线程还到池子里边,我们自己用代码实现。

那么问题进一步转化成为了,为什么由OS内核做事情速度<用户直接做这些事情速度呢?

这里,我们不妨来看个例子:银行管理系统

对于普通用户来讲,他假设正在办理一个业务,需要用到身份证复印件,但是呢,他只带了原件。这个时候,柜员给它提供了两种选择:第一,他帮他去他们的后台复印;第二,用户自己去大厅里边复印。这里我们将情况理想化,假设大厅复印位置无限多或者需要复印的用户无限少,此用户复印之后无需再排队。那么此时就意味着用户直接复印无限快。又因为银行后台不可见,我们只是知道柜员拿着原件去复印了,但是它有没有借此机会去做其他事情或者到底是先做复印这件事还是先复印趁机再做一些其他事情,这些我们都无从得知。但是一般情况下,他们是会的,上厕所或者摸会鱼……那么这就意味着,速度会相对慢。

而实际计算机在执行线程的任务时,因为操作系统内核需要负责的任务比较多,当我们把任务交给它时,其实也就是将任务放到了它的任务队列里边,很大概率不能第一时间执行。(它不存在摸鱼情况,它是一个机器,只是负担太大,忙不过来)而我们如果采用线程池,自己取线程,自己放回(其实run方法执行完了,执行此任务的线程自动解放回归池子),就很大简单了操作系统内核的负担。所以这就是为什么OS内核做事情速度<用户直接做这些事情速度。

另外,我们这里解释一下什么叫做用户态什么叫做内核态。整个计算机等价于创建银行的假设是政府,政府把这个银行的管理员权限交给了柜员,而操作系统内核等价于柜员,剩余的操作系统空间等价于普通用户。一些操作我们不需要内核来做,就可以直接做,而有些必须要更高一级的权限,也就是说我们把部分功能(黑盒)的实现交给了内核,OS内核把黑盒怎么使用给计算机的其他部分说明了。这个黑盒也可以反映到代码上就是api。

程序借用api完成完整操作的过程叫做系统调用,驱动内核完成一些工作。这些黑盒到底是怎么实现的,执行效率是快是慢,我们都无法控制,都是由OS内核独立完成的。

所以,相对而言,用户态程序的执行行为整体是可控的,内核态的执行行为整体是不可控的。

标准库中的线程池

创建一个线程池

java标准库中也提供了现成的线程池,可以直接使用。但是这里还是有些不同的。下边我们来讨论一下,然后给出测试代码。

这个被提供的类叫ThreadPoolExecutor,这里我们需要重点掌握它的构造方法的各个参数的含义,以及submit这个给线程池提交任务的方法。又因为这个类提供的功能过于强大用起来比较麻烦,所以我们一般使用被工厂类Executors包装过的工厂方法构造线程池。下边我们结合测试代码分析。

//for test
public class Code30_ExecutorSevicePoolTest {
    public static void main(String[] args) {
        ExecutorService pool= Executors.newFixedThreadPool(5);
        for (int i = 0; i < 5; i++) {
            int n=i+1;
            pool.submit(new Runnable() {
                @Override
                public void run() {
                    System.out.println("在线程池中执行任务:"+n);
                }
            });
        }
    }
}

这里跟其他提供组件的使用略有区别,这里使用的是Executors这个类的静态方法,直接构造出对象来,相当于是把new操作隐藏到静态方法里边了。我们每次可以使用submit方法,将任务以Runnable接口的方式交给线程池。

这样把new操作隐藏在静态方法里边的方法就是工厂方法。提供工厂方法的类就叫做工厂类。这种设计模式叫做工厂模式。

那么工厂模式有什么作用呢?

尽可能的避免了构造方法上的坑,比如创建坐标点,有笛卡尔坐标系和极坐标两种体系,这两个参数我们一般都设置成double,此时我们试图通过重载完成任务时,就会发现不能成功。工厂模式这里就是尽可能的填了java语法上的坑。

需要特别说明的是,我们基本可以认为,设计模式就是为了填语法上的坑。又因为不同的语言语法规定不同,有些设计模式已经融入到语法当中了,所以每个语言上使用的设计模式也不尽相同。

Executors给我们提供了很多种风格的线程池

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-m6ORpfWB-1673685943983)(F:\typora插图\image-20230114152012852.png)]

而这些线程池,本质上都是通过包装ThreadPoolExecutor来实现出来的,而这个线程池用起来比较麻烦,功能更强大。

再有,运行之后,我们发现,main线程虽然结束了,但是整个进程并没有结束,这是因为线程池中的线程都是前台线程,会阻止进程结束。定时器中的各个任务也是前台线程,所以最后并没有Process finished巴拉巴拉的。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-GWi1DDWy-1673685943984)(F:\typora插图\image-20230114150136720.png)]

如果我们想要它强制停止,可以点击右上角的stop按钮。

另外,这里还涉及到lambda的一个小的语法点——变量捕获。变量i是main线程中的局部变量,run方法是属于Runnbale接口的,并不一定是立刻马上去执行,而线程池中带着任务的线程和主线程基本上是并行的关系,有可能主线程结束了,它这部分的代码块已经销毁了,他们还没结束或者在线程池中还没排到,所以这里再去取一直变化的i是不恰当的,所以java官方给出了这样一个语法,如果拿到的变量是不可变的或者final修饰(jdk1.8以后)就可以。所以需要再次定义个中间变量n.这是为了避免变量生命周期的不同带来的错误。

再有,当线程任务耗时是差不多时,基本上可以认为每个线程负责的任务数是平均的。

ThreadPoolExecutor构造方法解析

关于这个简单的测试代码我们搞明白了,我们下边来看重头戏,ThreadPoolExecutor这个类的构造方法!!!

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-7MjIpx4l-1673685943984)(F:\typora插图\image-20230114161339311.png)]

下边我们来讨论一个问题

  1. corePoolSize和maximumPool设置多少合适?

    不同的程序特点不同,此时要设置的线程数也是不同的。考虑两个极端情况。

    1. cpu密集型,每个线程要执行的任务都是狂转cpu(进行一些列的算数运算),此时线程池线程数最多不应超过cpu核数。因为此时cpu一直占着,弄太多线程也没坑填它。
    2. io密集型,每个线程干的工作就是等待io(读写硬盘、网卡、等待用户输入……),不吃cpu,此时这样的线程处于阻塞状态,不参与cpu调度……这个时候可以多搞一些线程都无所谓,不受制于cpu核数,线程数设置的可以尽可能的大。

    然而,实际开发中没有程序符合这两种理想模式,真实的程序,往往是一部分吃cpu,一部分等待io。因此我们需要根据具体占比进行设置,一般是通过测试的方法。

线程池的实现

不难确定,线程池={阻塞队列=》存放任务+若干工作线程(类似定时器,也是在构造方法中)+注册任务的submit方法}

class MyThreadPool{
    //不涉及时间,直接BQ
    private BlockingQueue<Runnable> queue=new LinkedBlockingQueue<>();
    //构造方法中创建出工作的线程
    public MyThreadPool(int n){
        for (int i = 0; i < n; i++) {
            Thread t=new Thread(()->{
               while(true){
                   Runnable runnable= null;
                   try {
                       runnable = queue.take();
                   } catch (InterruptedException e) {
                       throw new RuntimeException(e);
                   }
                   runnable.run();
               }
            });
            t.start();
        }
    }
    //用来注册任务的方法
    public void submit(Runnable runnable){
        try {
            queue.put(runnable);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
    }
}
//for test
public class Code31_MyThreadPool {
    public static void main(String[] args) {
        MyThreadPool pool=new MyThreadPool(10);
        for (int i = 0; i < 98; i++) {
            int n=i;
            pool.submit(new Runnable() {
                @Override
                public void run() {
                    System.out.println("执行线程池中的任务"+n);
                }
            });
        }
    }
}

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-sch3sBsr-1673685943985)(F:\typora插图\image-20230114163745054.png)]

  • 9
    点赞
  • 10
    收藏
    觉得还不错? 一键收藏
  • 14
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值