一、阻塞队列
什么是阻塞队列
阻塞队列是在多线程代码中常用的一种数据结构,也具有"先进先出"的原则。与普通队列相比,阻塞队列是一种线程安全的队列,并且具有如下特性:
1、当阻塞队列为空时,继续出队,不会抛出异常,而是会阻塞等待,直到其他线程往队列中添加元素为止。
2、当阻塞队列为满时,继续入队,不会抛出异常,而是会阻塞等待,直到其他线程往队列中删除元素为止。
由于阻塞队列是一个数据结构,所以往往用来管理数据,典型的应用场景是"生产者消费者模型"。
生产者消费者模型
生产者消费者模型是一种用来协调多个线程的设计模式。生产者负责生产数据,消费者用来消耗数据。
生产者与消费者之间通过一个阻塞队列来维护数据,即生产者将生成的数据放进阻塞队列中,消费者从阻塞队列中取数据,阻塞队列可以控制数据的流量,防止资源的过度消耗或浪费。
生产者消费者模型的意义:
1、解耦合
耦合指的是两个不同的模块,如果联系紧密(一个模块的修改会影响另一个模块),则称耦合度高。生产者消费者模型通过使用阻塞队列,生产者A不直接与消费者B联系,而是通过阻塞队列,此时耦合就降低了,如果后续增加了一个消费者C,生产者A不需要进行修改,而是只需让消费者C从阻塞队列中取数据即可~
2、削峰填谷
在实际生活中,有一些特定的场景,例如"学校抢课",服务器会在某一时刻收到大量请求,如果直接处理这些请求,服务器可能会扛不住(在处理的某些环节中,可能比较脆弱,如:数据库操作),这时候就可以把这些请求放到阻塞队列中,后续让消费者线程慢慢的处理。
标准库中内置的阻塞队列
1、BlockingQueue是一个接口,实现的类是ArrayBlockingQueue和LinkedBlockingQueue,即底层有链表和数组实现的类。
2、put方法用于阻塞式的入队了,take方法用于阻塞式出队列,offer、poll方法不带有阻塞特性。
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;
public class Demo1 {
public static void main(String[] args) throws InterruptedException {
BlockingQueue<String> blockingQueue = new LinkedBlockingQueue<>();
blockingQueue.put("hello");
while(!blockingQueue.isEmpty()){
System.out.println(blockingQueue.take());
}
//程序阻塞
blockingQueue.take();
}
}
阻塞队列的使用与普通队列类似,注意使用put和take方法即可~
模拟实现阻塞队列
首先实现一个循环队列,然后对put和take方法注入阻塞等待的特性。
class MyBlockingQueue{
//浪费一个空间,来区分满与空
private volatile String[] data = new String[2 + 1];
private volatile int head = 0;
private volatile int tail = 0;
public void put(String s) throws InterruptedException {
synchronized (this){
while((tail + 1) % data.length == head){
//如果队列满了,就会阻塞
this.wait();
}
//队列不满,插入元素
data[tail] = s;
tail = (tail + 1) % data.length;
//唤醒take中wait的线程
this.notify();
}
}
public String take() throws InterruptedException {
synchronized (this){
while(head == tail){
//如果队列为空,阻塞等待
this.wait();
}
String ret = data[head];
head = (head + 1) % data.length;
//唤醒put中wait的线程
this.notify();
return ret;
}
}
}
public class Demo2 {
public static void main(String[] args) throws InterruptedException {
MyBlockingQueue myBlockingQueue = new MyBlockingQueue();
Thread t1 = new Thread(() -> {
try {
myBlockingQueue.put("1");
myBlockingQueue.put("1");
myBlockingQueue.put("1");
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
});
t1.start();
Thread.sleep(100);
Thread t2 = new Thread(() -> {
try {
System.out.println(myBlockingQueue.take());
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
});
t2.start();
}
}
这份代码有一个地方值得注意:put方法中是否需要循环判断队列是否已满?
所以使用wait的时候,需要注意当前wait唤醒的时候,是通过notify唤醒的还是通过interrupt唤醒的,如果是通过notify唤醒的,说明别的线程已经删除了元素,队列不可能为满,如果是interrupt唤醒的,队列可能还满着呢,需要继续判断。因此使用while循环判断的话,就可以保证队列一定是不为满。
总结:使用wait的时候,往往都是使用while作为条件判定的方式,目的是为了让wait唤醒后的线程再确定一次,是否满足条件。上述while循环写法,也是官方文档的建议。
二、单例模式
三、定时器
简单使用
定时器是一种非常常用的组件,约定好某一时间,时间到达后,开始执行某些代码(在网络通信中经常出现)。
在java库中内置了一个Timer类,里面有一个核心方法schedule方法。
schedule方法包含了两个参数,第一个是指定要即将执行的任务代码,第二个是指定多久后执行。
import java.util.Timer;
import java.util.TimerTask;
public class Demo1 {
public static void main(String[] args) {
Timer timer = new Timer();
timer.schedule(new TimerTask() {
@Override
public void run() {
System.out.println("执行定时器任务");
}
}, 1000);
}
}
TimeTask是一个抽象类,实现了Runnable接口,所以需要进行重写run方法,通过run方法描述任务的详细情况。
主线程在执行schedule方法时,会把这个任务放到timer对象,而在timer对象内部包含了一个"扫描线程",一旦时间到了,扫描线程就会执行刚才安排的任务。(主线程的结束,并不会影响扫描线程~)
模拟实现
分析:
1、首先需要一个类来描述任务以及什么时候执行任务。
2、然后在Timer中需要一个线程,循环判断是否有任务已经到达时间了。
3、最后选择某个数据结构用来管理多个任务,由于一定是时间小的先执行,那么可以使用一个优先队列。
如何描述每个任务?通过创建一个类,这个类中包含一个Runnable属性(描述任务),和一个执行任务时间的属性time(在这使用绝对时间)。
class MyTimerTask{
//通过重写run,描述执行的任务
private Runnable runnable;
//执行任务的时间,这里使用绝对时间
private long time;
public MyTimerTask(Runnable runnable, long time) {
this.runnable = runnable;
//传入的是相对时间,这里计算绝对时间
this.time = System.currentTimeMillis() + time;
}
}
接下来创建Timer类,在Timer类中需要加入一个schedule方法,来添加任务。
class MyTimer{
private PriorityQueue<MyTimerTask> priorityQueue = new PriorityQueue<>();
public void schedule(Runnable runnable, long time){
priorityQueue.offer(new MyTimerTask(runnable, time));
}
public MyTimer() {
//描述线程
Thread thread = new Thread(() -> {
});
thread.start();
}
}
由于优先队列会进行比较的操作,所以我们需要让MyTimerTask类实现一下Comparable接口。
class MyTimerTask implements Comparable<MyTimerTask>{
//通过重写run,描述执行的任务
private Runnable runnable;
//执行任务的时间,这里使用绝对时间
private long time;
public MyTimerTask(Runnable runnable, long time) {
this.runnable = runnable;
//传入的是相对时间,这里计算绝对时间
this.time = System.currentTimeMillis() + time;
}
@Override
public int compareTo(MyTimerTask o) {
return (int) (this.time - o.time);
}
}
通过前面分析,Timer中应该有一个while循环,一直扫描是否有任务到时间该执行了,不过我们还要对还未添加任务的情况处理,跟之前的阻塞队列类似,我们可以让程序先阻塞着,直到添加了任务在进行执行,此时就需要用到wait方法,再进一步发现,在多线程的应用场景下,schedule方法和构造方法可能会同时对队列进行修改操作,因此我们还需要加锁。
完整代码:
import java.util.PriorityQueue;
class MyTimerTask implements Comparable<MyTimerTask>{
//通过重写run,描述执行的任务
private Runnable runnable;
//执行任务的时间,这里使用绝对时间
private long time;
public MyTimerTask(Runnable runnable, long time) {
this.runnable = runnable;
//传入的是相对时间,这里计算绝对时间
this.time = System.currentTimeMillis() + time;
}
public long getTime() {
return time;
}
public Runnable getRunnable() {
return runnable;
}
@Override
public int compareTo(MyTimerTask o) {
return (int) (this.time - o.time);
}
}
class MyTimer{
private PriorityQueue<MyTimerTask> priorityQueue = new PriorityQueue<>();
private Object locker = new Object();
public void schedule(Runnable runnable, long time){
synchronized (locker){
priorityQueue.offer(new MyTimerTask(runnable, time));
locker.notify();
}
}
public MyTimer() {
Thread thread = new Thread(() -> {
//扫描线程,需要不停的扫描队首元素,看是否到达时间
while(true){
synchronized (locker){
//如果当前没有任务,阻塞等待
while(priorityQueue.isEmpty()){
try {
locker.wait();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
//判断是否到执行时间了
long curTime = System.currentTimeMillis();
MyTimerTask task = priorityQueue.peek();
if(curTime >= task.getTime()){
//执行完任务,并删除队首元素
task.getRunnable().run();
priorityQueue.poll();
}else{
//如果还没有到时间,怎让线程阻塞等待
try {
//可以不做等待处理,但是会让线程一直去查看当前是否到达执行时间
//会浪费cpu资源,可以让其阻塞至指定时间
locker.wait(task.getTime() - curTime);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
}
}
});
thread.start();
}
}
四、线程池
为什么要有线程池?
首先我们需要知道为什么要使用多线程而不使用多进程呢?这是因为进程创建太过重量,当需要频繁销毁/创建的时候, 就不能忽视这些资源开销了。而线程依赖与进程,多个线程共享同个进程的资源,不过线程的创建/销毁也会消耗一定的资源(相比进程少),因此到达一定程度,这些开销也不能忽视了!!!
两种解决方案:
1、使用协程
协程相比于线程来说,更加轻量级,因为协程把系统调度的过程给省略了,程序猿可以手动调度。不过在Java中,标准库没有协程,只有一些第三方库中有,但第三方库靠不靠谱?使用协程更多的是Go和Python。
2、使用线程池
在计算中"池"是一个重要的思想方法,例如:线程池、进程池、内存池.......
大致思想就是,一次多创建几个线程,后续要用到的话,直接从池子里取出来,可为什么从池子里取出就比重新创建效率高呢?
原因:
在计算机工作中,操作系统会在"内核态"和"用户态"两种状态来回切换,创建一个新的线程,就需要调用系统API,让操作系统的内核去完成,而操作系统内核是需要给所有的进程提供服务的,可能并不会马上回应你,这是不可控的,可能操作系统花费了很多时间才来理睬捏。但如果是从线程池中取出一个线程,这个操作是只需要"用户态"来完成,程序立马就能去执行,这是可控的。
标准库中的线程池
简单使用
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.ThreadPoolExecutor;
public class Demo1 {
public static void main(String[] args) {
ExecutorService service = Executors.newFixedThreadPool(10);
service.submit(() -> {
System.out.println("111");
});
}
}
在这里线程池对象不是直接new出来的,而是通过一个专门的方法,返回了一个线程池对象。(在这使用到了一个设计模式——工厂模式)。
在这使用了Executors.newFixedThreadPool方法,可以创建一个固定包含10个线程的线程池,其返回值类型是ExecutorService,通过调用里面的submit方法,可以将一个任务放到线程池中。
Executors创建线程池有这几种方式:
-
newFixedThreadPool: 创建固定线程数的线程池.
-
newCachedThreadPool: 创建线程数目动态增长的线程池.
-
newSingleThreadPool: 创建单个线程的线程池.
-
newScheduleThreadPool: 设置延迟时间后执行命令,或者定期执行命令.
上述的几个工厂方法生成的线程池,本质上是Executors类对ThreadPoolExecutor类的封装。
ThreadPoolExecutor中的重要的方法就两个:
-
构造方法
-
submit方法(注册任务)
参数含义:
-
int corePoolSize:表示核心线程数(线程池中可以摸鱼的线程数目)。
-
int maxiumPoolSize:表示线程池中最大线程的个数。
-
long keepAliveTime:表示非核心线程可以摸鱼的时间,一到时间就会销毁。
-
TimeUnit unit:表示keepAliveTime的时间单位。
-
BlockingQueue<Runnable> workQueue:表示用来维护任务的容器,但必须为阻塞队列或其子类(优先阻塞队列)。
-
ThreadFactory threadFactory:使用某个工厂对象,线程由这个对象创建。使用工厂类是为了在创建过程中对线程属性做一些修改。
-
RejectedExecutionHandler handler:线程池的拒绝策略,一个线程池中线程数达到了最大容量,当继续往线程池中添加任务,的话就需要采用某种策略来处理。
线程池拒绝策略:
-
AbortPolicy:直接抛出异常。(摆烂~老子不干了!!)
-
CallerRunsPolicy:新添加的任务,由添加任务的那个线程执行。(假装没听到~~依旧是自己干)
-
DiscardOldestPolicy:丢弃任务队列中最老未被执行的任务。(放弃很久之前没做的事,将任务添加进来)
-
DiscardPolicy:丢弃当前新加的任务。(任务都别干了)
如果使用newFixedThreadPool方法来构建线程池的话,初始构建多少个合适?根据cpu核心数设置?
应当根据实际项目来设置。在一个线程中,执行的代码主要有两类:第一类为cpu密集型,在代码中主要进行算术运算/逻辑判断,另一类为IO密集型,在代码中主要进行IO操作。
假设一个线程的代码都是cpu密集型,这个时候线程池中的个数不应该超过cpu的核心数,此时如果超过了,也无法进一步提高效率了,反而回应为太多线程影响调度开销。
假设一个线程的代码都是IO密集型,这个时候不吃cpu,设置的线程数就以超过N,一个核心可以通过调度来执行并发。
所以需要根据实际需求,进行多轮测试的方式,来找到最佳的线程池的线程数目。
模拟实现
在这就实现一个最简单的线程池(固定个数,不使用工厂模式)
要点:
-
线程池中核心的操作时submit方法,将线程添加到线程池中。
-
由于有多个线程任务,考虑使用阻塞队列来管理这些任务。
实现代码:
class MyThreadPool{
private BlockingQueue<Runnable> queue = new LinkedBlockingQueue<>();
public void submit(Runnable runnable) throws InterruptedException {
queue.put(runnable);
}
MyThreadPool(int n){
//创建n个线程,如果没有任务,则阻塞等待
for(int i = 0; i < n; i++){
Thread t = new Thread(() -> {
try {
//如果没有任务,让线程阻塞等待
Runnable runnable = queue.take();
runnable.run();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
});
t.start();
}
}
}