文章目录
一、阻塞式队列
1.1阻塞队列
阻塞队列是一种特殊的队列. 也遵守 “先进先出” 的原则.
阻塞队列能是一种线程安全的数据结构, 并且具有以下特性:
- 当队列满的时候, 继续入队列就会阻塞, 直到有其他线程从队列中取走元素.
- 当队列空的时候, 继续出队列也会阻塞, 直到有其他线程往队列中插入元素.
阻塞队列的一个典型应用场景就是 “生产者消费者模型”. 这是一种非常典型的开发模型.
1.2生产者消费者模型
生产者消费者模式就是通过一个容器来解决生产者和消费者的强耦合问题。
生产者和消费者彼此之间不直接通讯,而通过阻塞队列来进行通讯,所以生产者生产完数据之后不用等待消费者处理,直接扔给阻塞队列,消费者不找生产者要数据,而是直接从阻塞队列里取.
- 阻塞队列就相当于一个缓冲区,平衡了生产者和消费者的处理能力.
比如在 “秒杀” 场景下, 服务器同一时刻可能会收到大量的支付请求. 如果直接处理这些支付请求,
服务器可能扛不住(每个支付请求的处理都需要比较复杂的流程). 这个时候就可以把这些请求都放
到一个阻塞队列中, 然后再由消费者线程慢慢的来处理每个支付请求.
这样做可以有效进行 “削峰”, 防止服务器被突然到来的一波请求直接冲垮. - 阻塞队列也能使生产者和消费者之间解耦.
比如过年一家人一起包饺子. 一般都是有明确分工, 比如一个人负责擀饺子皮, 其他人负责包. 擀饺
子皮的人就是 “生产者”, 包饺子的人就是 “消费者”. 擀饺子皮的人不关心包饺子的人是谁(能包就行, 无论是手工包, 借助工具, 还是机器包), 包饺子的人也不关心擀饺子皮的人是谁(有饺子皮就行, 无论是用擀面杖擀的, 还是拿罐头瓶擀, 还是直接从超市买的).
1.3标准库中的阻塞队列
在 Java 标准库中内置了阻塞队列. 如果我们需要在一些程序中使用阻塞队列, 直接使用标准库中的即可.
- BlockingQueue 是一个接口. 真正实现的类是 LinkedBlockingQueue.
- put 方法用于阻塞式的入队列, take 用于阻塞式的出队列.
- BlockingQueue 也有 offer, poll, peek 等方法, 但是这些方法不带有阻塞特性
public class Demo7 {
public static void main(String[] args) throws InterruptedException {
BlockingDeque<Integer> blockingDeque=new LinkedBlockingDeque<>(100);
//blockingDeque.offer(1);
//put带有阻塞
//入队列
blockingDeque.put(1);
blockingDeque.put(2);
blockingDeque.put(3);
//出队列, 如果没有 put 直接 take, 就会阻塞.
Integer ret=blockingDeque.take();
System.out.println(ret);
ret=blockingDeque.take();
System.out.println(ret);
ret=blockingDeque.take();
System.out.println(ret);
}
}
1.4实现一个阻塞队列
class MyBlockQueue {
private int[] items = new int[1000];
private volatile int head = 0;
private volatile int tail = 0;
private volatile int size = 0;
//入队列
public void put(int elem) throws InterruptedException {
synchronized (this) {
//判定队列是否满了
if (size >= items.length) {
this.wait();
}
//进行插入
items[tail] = elem;
tail++;
if (tail >= items.length) {
tail = 0;
}
size++;
this.notify();
}
}
//出队列
public Integer take() throws InterruptedException {
synchronized (this) {
//判定队列是否为空
if (size == 0) {
this.wait();
}
int ret = items[head];
head++;
if (head >= items.length) {
head = 0;
}
this.notify();
return ret;
}
}
}
这里又存在一个问题,这里的wait不一定是另一个线程put成功后,notify唤醒的,也可能是其他方式唤醒的,比如interrupt。如果是这样的话,很可能唤醒之后,条件还没满足。
这里改为循环判断是稳妥的写法
//判定队列是否为空
while (size == 0) {
this.wait();
}
通过 “循环队列” 的方式来实现.
- 使用 synchronized 进行加锁控制.
- put 插入元素的时候, 判定如果队列满了, 就进行 wait. (注意, 要在循环中进行 wait. 被唤醒时不一
定队列就不满了, 因为同时可能是唤醒了多个线程). - take 取出元素的时候, 判定如果队列为空, 就进行 wait. (也是循环 wait)
1.5生产者消费者模型
class MyBlockQueue {
private int[] items = new int[1000];
private volatile int head = 0;
private volatile int tail = 0;
private volatile int size = 0;
//入队列
public void put(int elem) throws InterruptedException {
synchronized (this) {
//判定队列是否满了
while (size >= items.length) {
this.wait();
}
//进行插入
items[tail] = elem;
tail++;
if (tail >= items.length) {
tail = 0;
}
size++;
this.notify();
}
}
//出队列
public Integer take() throws InterruptedException {
synchronized (this) {
//判定队列是否为空
while (size == 0) {
this.wait();
}
int ret = items[head];
head++;
if (head >= items.length) {
head = 0;
}
size--;
this.notify();
return ret;
}
}
}
public class Demo8 {
public static void main(String[] args) {
MyBlockQueue queue = new MyBlockQueue();
Thread producer = new Thread(() -> {
int n = 1;
while (true) {
try {
queue.put(n);
System.out.println("生产元素" + n);
n++;
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
Thread customer = new Thread(() -> {
while (true) {
try {
int n = queue.take();
System.out.println("消费元素" + n);
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
producer.start();
customer.start();
}
}
二、 定时器
2.1定时器是什么
定时器也是软件开发中的一个重要组件. 类似于一个 “闹钟”. 达到一个设定的时间之后, 就执行某个指定好的代码.
定时器是一种实际开发中非常常用的组件.
比如网络通信中, 如果对方 500ms 内没有返回数据, 则断开连接尝试重连.
比如一个 Map, 希望里面的某个 key 在 3s 之后过期(自动删除).
类似于这样的场景就需要用到定时器.
2.2标准库中的定时器
- 标准库中提供了一个 Timer 类. Timer 类的核心方法为 schedule .
- schedule 包含两个参数. 第一个参数指定即将要执行的任务代码, 第二个参数指定多长时间之后执行 (单位为毫秒).
Timer timer=new Timer();
timer.schedule(new TimerTask() {
@Override
public void run() {
System.out.println("时间到!");
}
},3000);
System.out.println("开始计时!");
定时器可以执行多个任务。
执行完上述代码后,进程没有退出。Timer内部需要一组线程来执行注册的任务,而这里的线程主要是前台线程会影响进程退出。
2.3实现定时器
定时器的构成:
- 一个带优先级的阻塞队列
为啥要带优先级呢?
因为阻塞队列中的任务都有各自的执行时刻 (time). 最先执行的任务一定是 time最小的. 使用带
优先级的队列就可以高效的把这个 time最小的任务找出来.
- 队列中的每个元素是一个 Task 对象.
- Task 中带有一个时间属性, 队首元素就是即将要执行的任务.
- 同时有一个线程一直扫描队首元素, 看队首元素是否需要执行.
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.PriorityBlockingQueue;
class MyTask implements Comparable<MyTask> {
//要执行的任务
private Runnable runnable;
//什么时候执行任务(时间戳)
private long time;
public MyTask(Runnable runnable, long delay) {
this.runnable = runnable;
this.time = System.currentTimeMillis() + delay;
}
public long getTime() {
return time;
}
public Runnable getRunnable() {
return runnable;
}
public int compareTo(MyTask o) {
return (int) (this.time - o.time);
}
}
class MyTimer {
//线程不安全
//private PriorityQueue<MyTask> queue=new PriorityQueue<>();
private BlockingQueue<MyTask> queue = new PriorityBlockingQueue<>();
private Object locker = new Object();
public void schedule(Runnable runnable, long after) throws InterruptedException {
MyTask myTask = new MyTask(runnable, after);
queue.put(myTask);
synchronized (locker) {
locker.notify();
}
}
public MyTimer() {
Thread t = new Thread(() -> {
while (true) {
//取出队首元素
try {
synchronized (locker) {
MyTask task = queue.take();
long curTime = System.currentTimeMillis();
if (curTime >= task.getTime()) {
task.getRunnable().run();
} else {
queue.put(task);
//没到时间,等待
locker.wait(task.getTime() - curTime);
}
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
t.start();
}
}
public class Demo10 {
public static void main(String[] args) throws InterruptedException {
MyTimer timer = new MyTimer();
timer.schedule(new Runnable() {
public void run() {
System.out.println("时间到! ");
}
}, 3000);
System.out.println("开始计时!");
}
}
三、线程池
3.1线程池是什么
线程池是一种利用池化技术思想来实现的线程管理技术,主要是为了复用线程、便利地管理线程和任务、并将线程的创建和任务的执行解耦开来。我们可以创建线程池来复用已经创建的线程来降低频繁创建和销毁线程所带来的资源消耗。在JAVA中主要是使用ThreadPoolExecutor类来创建线程池,并且JDK中也提供了Executors工厂类来创建线程池(不推荐使用)。
线程池的优点:
- 降低资源消耗,复用已创建的线程来降低创建和销毁线程的消耗。
- 提高响应速度,任务到达时,可以不需要等待线程的创建立即执行。
- 提高线程的可管理性,使用线程池能够统一的分配、调优和监控。
线程池最大的好处就是减少每次启动、销毁线程的损耗。使用线程池是纯用户态操作,要比创建线程,经历内核态的操作要快。
3.2标准库中的线程池
- 使用 Executors.newFixedThreadPool(10) 能创建出固定包含 10 个线程的线程池.
- 返回值类型为 ExecutorService
- 通过 ExecutorService.submit 可以注册一个任务到线程池中
ExecutorService pool = Executors.newFixedThreadPool(10);
pool.submit(new Runnable() {
public void run() {
System.out.println("hello");
}
});
此处创建线程池,没有显示的new,而是通过另外的Executors类的静态方法newCachedThreadPool来完成。
工厂模式
newCachedThreadPool称为工厂方法。创建一个实例最主要的就是使用构造方法new,有的时候希望类提供多种构造实例的方式,就需要重载构造方法来实现不同版本的对象创建。但是重载要求参数个数/参数类型不同,这就带来了一定的限制。为了绕开局限,就引入了工厂模式。
其实Java中,线程池的本体叫做ThreadPoolExecutor,它的构造方法写起来很麻烦,参数很多,为了简化构造,标准库就提供了一系列的工厂方法,来简化使用。
3.3线程池的实现
class MyThreadPool{
private BlockingQueue<Runnable> queue =new LinkedBlockingQueue<>();
public void submit(Runnable runnable) throws InterruptedException {
queue.put(runnable);
}
public MyThreadPool(int m){
for (int i=0;i<m;i++){
Thread t=new Thread(() ->{
while (true){
try {
Runnable runnable=queue.take();
runnable.run();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
t.start();
}
}
}
public class Demo11 {
public static void main(String[] args) throws InterruptedException {
MyThreadPool pool=new MyThreadPool(10);
for (int i = 0; i < 1000; i++) {
int taskId=i;
pool.submit(new Runnable() {
public void run() {
System.out.println("执行当前任务:"+taskId+" 当前线程:"+Thread.currentThread().getName());
}
});
}
}
}
3.4标准库中的线程池详解
public ThreadPoolExecutor(int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue<Runnable> workQueue,
ThreadFactory threadFactory,
RejectedExecutionHandler handler)
- corePoolSize 核心线程数(正式员工数)
- maximumPoolSize 最大线程数(正式员工+实习生个数)
核心线程空闲了也不会被销毁,其他线程空闲达到一定时间就会被销毁线程。这个设定是为了防止线程池空闲的时候,空闲线程太多,占用系统资源。
- keepAliveTime 允许其他线程空闲的最大时间(允许实习生摸鱼的最大时间)
- unit 空闲存活时间单位
- workQueue 手动给线程池传入一个任务队列
线程池本身有队列,但是业务中,本身就有一个队列来保存这里的任务,这个时候把自己队列中的任务拷贝到线程池内部,过于繁琐,所以直接让线程池消费业务逻辑中已有的队列即可。
- threadFactory 描述了线程如何创建的。工厂对象就负责创建线程,程序员可以手动指定线程的创建策略。
- handler 【重点】线程池的拒绝策略。当线程池处于饱和时,使用某种策略来拒绝任务提交。
3.5 线程池拒绝策略
当线程池中的线程和阻塞队列中的任务已经处于饱和状态,线程池则需要执行给定的拒绝策略来拒绝正在提交的任务,ThreadPoolExecutor主要提供了一下四种拒绝策略来拒绝任务。
- ThreadPoolExecutor的默认拒绝策略为AbortPolicy,抛出RejectedExecutionException异常拒绝任务提交,让用户可根据具体任务来做出具体的判断
- CallerRunsPolicy,让提交任务的线程来执行任务。如果当前提交任务的线程已经shutdown,就丢弃这个任务。
- DiscardOldestPolicy,将阻塞队列中的任务poll出来,然后执行当前任务
- DiscardPolicy,什么也不做,直接丢弃任务
3.6合理设置线程池参数
在实际开发中,线程池的线程数目如何确定?
在这里我们是不可能确定出具体的线程个数的
1.主机的CPU配置不确定
2.程序的执行特点不确定
工作中实际的处理方案是进行实验验证!!
针对程序进行性能测试,分别给线程池设置成不同的数目。分别记录每种情况下,程序的一些核心性能指标和系统负载情况,最终选一个最合适的配置。
以下只能作为参考
CPU 密集型任务(N+1)
这种任务消耗的主要是 CPU 资源,可以将线程数设置为 N(CPU 核心数)+1,比 CPU 核心数多出来的一个线程是为了防止线程偶发的缺页中断,或者其它原因导致的任务暂停而带来的影响。一旦任务暂停,CPU 就会处于空闲状态,而在这种情况下多出来的一个线程就可以充分利用 CPU 的空闲时间。
I/O 密集型任务(2N)
这种任务应用起来,系统会用大部分的时间来处理 I/O 交互,而线程在处理 I/O 的时间段内不会占用 CPU 来处理,这时就可以将 CPU 交出给其它线程使用。因此在 I/O 密集型任务的应用中,我们可以多配置一些线程,具体的计算方法是 2N。 如何判断是 CPU 密集任务还是 IO 密集任务?
CPU 密集型简单理解就是利用 CPU 计算能力的任务比如你在内存中对大量数据进行排序。单凡涉及到网络读取,文件读取这类都是 IO 密集型,这类任务的特点是 CPU 计算耗费时间相比于等待 IO 操作完成的时间来说很少,大部分时间都花在了等待 IO 操作完成上。