Java EE|多线程代码实例之定时器与线程池
阿里云国内75折 回扣 微信号:monov8 |
阿里云国际,腾讯云国际,低至75折。AWS 93折 免费开户实名账号 代冲值 优惠多多 微信号:monov8 飞机:@monov6 |
文章目录
🔴定时器
什么是定时器以及开发中的作用
程序中的定时器功能与我们现实生活中的定时器功能相似都有起提示作用但是与现实生活中闹钟不同的是程序里的闹钟不仅是提醒还能真正的去做事情。也就是说它的权限更大更像是一个机器人我们给它设定一个时间点让它去做什么事情而不是说像闹钟一样只能提醒我们但是改变不了我们的想法到底做不做这件事。
我们以后开发中也会经常使用到定时器这是软件开发中的一个重要组件。尤其是“网络编程”比如说我们访问一个网页的话很容易出现卡的现象这时我们就可以使用定时器来进行“止损”。一旦超时就结束这次访问不再阻塞/等待。
标准库中的定时器
对于定时器标准库中提供了一个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);
}
}
定时器的实现
一思路分析
我们已经知道怎么使用标准库中的定时器下边我们来自己实现一个定时器。那么在实现定时器之前我们需要知道定时器需要做什么才能更好的实现。
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);
}
}
🔴线程池
什么是线程池
当当前代码能满足需求的前提下我们不免想要压缩时间来提高编程的效率。我们已经知道线程是为了解决并发程度很高的情况下创建/销毁进程时间开销很大的一种优化办法。然而很多东西需要有对比当并发程度进一步提高时多线程确实要比多进程编程效率要高但是这跟我们预期的效率还差点意思所以为了进一步提高并发编程下的效率前辈们提出了一些方法供我们使用。
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给我们提供了很多种风格的线程池
而这些线程池本质上都是通过包装ThreadPoolExecutor来实现出来的而这个线程池用起来比较麻烦功能更强大。
再有运行之后我们发现main线程虽然结束了但是整个进程并没有结束这是因为线程池中的线程都是前台线程会阻止进程结束。定时器中的各个任务也是前台线程所以最后并没有Process finished巴拉巴拉的。
如果我们想要它强制停止可以点击右上角的stop按钮。
另外这里还涉及到lambda的一个小的语法点——变量捕获。变量i是main线程中的局部变量run方法是属于Runnbale接口的并不一定是立刻马上去执行而线程池中带着任务的线程和主线程基本上是并行的关系有可能主线程结束了它这部分的代码块已经销毁了他们还没结束或者在线程池中还没排到所以这里再去取一直变化的i是不恰当的所以java官方给出了这样一个语法如果拿到的变量是不可变的或者final修饰(jdk1.8以后)就可以。所以需要再次定义个中间变量n.这是为了避免变量生命周期的不同带来的错误。
再有当线程任务耗时是差不多时基本上可以认为每个线程负责的任务数是平均的。
ThreadPoolExecutor构造方法解析
关于这个简单的测试代码我们搞明白了我们下边来看重头戏,ThreadPoolExecutor这个类的构造方法
下边我们来讨论一个问题
-
corePoolSize和maximumPool设置多少合适
不同的程序特点不同此时要设置的线程数也是不同的。考虑两个极端情况。
- cpu密集型每个线程要执行的任务都是狂转cpu进行一些列的算数运算此时线程池线程数最多不应超过cpu核数。因为此时cpu一直占着弄太多线程也没坑填它。
- 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);
}
});
}
}
}