文章目录
- 8.多线程案例
- 8.1 单例模式
- 8.1.1 饿汉模式
- 8.1.2 懒汉模式
- 8.2 阻塞队列
- 8.2.1 什么是阻塞队列
- 8.2.2 生产者消费者模型
- 8.2.3 标准库中的阻塞队列
- 8.2.4 阻塞队列的应用场景
- 8.2.4.1 消息队列
- 8.2.5 异步操作
- 8.2.5 自定义实现阻塞队列
- 8.2.6 阻塞队列--生产者消费者模型``
- 8.3 定时器
- 8.3.1 标准库中的定时器
- 8.3.2 实现定时器
- 8.4 线程池
- 8.4.1 线程池是什么
- 8.4.2 为什么要使用线程池
- 8.4.3 标准库中的线程池
- 8.4.4 自定义一个线程池
- 8.4.5 创建系统自带的线程池
- 8.4.6 线程池流程图
- 8.4.7 拒绝策略
- 9. 总结-保证线程安全的思路
- 10. 对比线程和进程
- 10.1 线程的优点
- 10.2 进程与线程的区别
- 11. wait() 和 sleep()的区别
8.多线程案例
8.1 单例模式
单例模式是校招中最常考的设计模式之一。
什么是单例?
在程序中一个类只需要有一个对象实例。
什么是设计模式?
设计模式是对常见的业务场景总结出来的处理方法,可以将设计模式理解为解决某个问题时限制了边界,同时限制了程序员的下限。
1. JVM中哪些类只有一个对象?
类对象:.class文件被加载到JVM中以后,会创建一个描述类结构的对象,称之为类对象,全局唯一。
在Java中可以通过 .class 获取到类对象。
static关键字修饰的属性,在该类所有实例对象中共享。
static 代码块在类加载的时候执行;不带static修饰的代码块,每new 一个对象都执行一次。
Java程序运行过程:
- 从磁盘加载 .class 文件到JVM,同时生成一个类对象。
- 创建实例变量
8.1.1 饿汉模式
实现过程:
- 要实现单例类,只需要定义一个static修饰的变量,就可以保证这个变量全局唯一(单例)。
- 既然是单例,就不想让外部去new这个对象,虽然返回的是同一个对象,已经实现了单例,但是在代码书写上有歧义。
public class Singleton {
//定义一个类的成员变量,用static修饰,保证全局唯一
private static Singleton instance = new Singleton();
public Singleton getInstance() {
return instance;
}
}
public class Demo01 {
public static void main(String[] args) {
Singleton instance1 = new Singleton();
System.out.println(instance1.getInstance());
Singleton instance2 = new Singleton();
System.out.println(instance2.getInstance());
Singleton instance3 = new Singleton();
System.out.println(instance3.getInstance());
}
}
输出结果:
-
构造方法私有化
这样从语法上就不能再new对象了。 -
把获取对象的方法改为static 通过类名.方法名的方式调用。
输出结果:
我们把这种类加载的时候就完成对象初始化的创建方式称为“饿汉模式”。
由于程序在启动的时候可能需要加载很多的类。单例类,并不一定要在程序启动的时候用,为了节省计算机资源,加快程序的启动,可以让单例类在用到的时候在进行初始化。在编程中延时加载是一个褒义词。
8.1.2 懒汉模式
- 只声明这个全局变量,不初始化。
public class SingletonLazy {
//定义一个类的成员变量,用static修饰,保证全局唯一
private static SingletonLazy instance = null;
}
-
在 获取单例对象的时候加一个是否为空的判断,若为空则创建对象。
-
多次获取对象,打印对象结果。(单线程)
public class Demo03 {
public static void main(String[] args) {
SingletonLazy instance1 = SingletonLazy.getInstance();
System.out.println(instance1);
SingletonLazy instance2 = SingletonLazy.getInstance();
System.out.println(instance2);
SingletonLazy instance3 = SingletonLazy.getInstance();
System.out.println(instance3);
}
}
输出结果:
- 测试在多线程环境中的运行结果
public class Demo02 {
public static void main(String[] args) {
for (int i = 0; i < 10; i++) {
Thread thread = new Thread(()->{
SingletonLazy instance = SingletonLazy.getInstance();
System.out.println(instance);
});
thread.start();
}
}
}
输出结果:
可以看到在多线程中出现了线程安全问题,不再是单例对象了。
分析出现线程安全问题的原因:
当t1LOAD时,instance 为NULL,执行完t1 的LOAD之后,被CPU调度到了 t2,我们假设CPU一次把 t2 的指令全部执行完,当执行完 t2 的最后一个指令STORE(将创建的instance对象写回到了主内存中)。又被CPU调度到了 t1 此时已经执行完了LOAD,已经进入了if语句,所以就会直接执行下面的NEW操作,就又创建了一个新的对象。当 t1 的指令执行到STORE,就会把在 t1 新创建的instance 写入到主内存中,会将在 t2 中创建的 instance 覆盖掉,这样就造成了线程安全问题。
给内层加锁
分析给内层加锁不能解决线程安全问题的原因:
我们假设CPU先执行完 t1 的 LOAD 和 判断操作,此时已经执行完了判断操作,并且此时 instance 为NULL,已经进入了 if 语句。但是接下来被CPU调度到了 t2 ,我们假设 t2 中的所有指令执行完,才被CPU再次调回了 t1 ,t2 中将instance对象写入到了主内存中,并释放了锁之后,此时已经进入到了 if 语句,t1 拿到了锁,就会执行下面的创建 instance 对象的操作,此时又创建了一个新的instance对象,然后被写入到了主内存中,覆盖掉了t2中创建的instance对象。此时,线程安全问题依旧存在。
给外层加锁
分析给外层加锁解决线程安全问题的原因:
给外层加锁和给内存加锁最大的不一样就是,给内层加锁是先进入 if 语句再竞争锁,还是先竞争锁再进入if 语句。
我们假设t1 先竞争到了锁,执行到了判断指令,此时 t1 已经进入到了 if 语句,然而被CPU调度到了t2,此时t2想要拿到锁,但是此时锁还被t1 拿着, t1 并没有释放锁,直到再次被CPU调度回 t1 ,直到执行完UNLOCK,此时已经创建了 instance 对象,并将其写入到了主内存中,当再次被CPU调度到 t2 时,它指向判断操作时,已经发现instance对象不为NULL,所以它就进不去if语句,就修改不了instance。所以,线程安全问题得以解决。
给外层加锁的另一个小问题:
- 当第一个线程进入getInstance 方法时,如果线程还没有初始化,则获取锁进行初始化操作,此时单例对象被第一个线程创建完成。
- 给外层加锁时,一旦有一个线程获取到了锁,那么这个线程就会创建 instance 对象,后面再竞争到锁的线程就永远不会进入 if 语句。
- 那么后面的竞争锁的行为就都是对资源的一种消耗,LOCK和UNLOCK对应的锁指令是互斥锁,比较消耗系统资源。
解决问题:
我们在加锁前再去判断一下是否需要加锁。
我们把这种叫做双重检查锁(DCL)
解决内存可见性和指令重排序问题:
DCL的方式必须要学会手写,面试中如果手写代码,必考!!!
面试中使用DCL,工作中使用“饿汉式”
8.2 阻塞队列
8.2.1 什么是阻塞队列
阻塞队列是⼀种特殊的队列.也遵守"先进先出"的原则.
阻塞队列能是⼀种线程安全的数据结构,并且具有以下特性:
• 当队列满的时候,继续⼊队列就会阻塞,直到有其他线程从队列中取⾛元素.
• 当队列空的时候,继续出队列也会阻塞,直到有其他线程往队列中插⼊元素.
阻塞队列的⼀个典型应⽤场景就是"⽣产者消费者模型".这是⼀种⾮常典型的开发模型.
8.2.2 生产者消费者模型
⽣产者消费者模式就是通过⼀个容器来解决⽣产者和消费者的强耦合问题。
⽣产者和消费者彼此之间不直接通讯,⽽通过阻塞队列来进⾏通讯,所以⽣产者⽣产完数据之后不⽤等待消费者处理,直接扔给阻塞队列,消费者不找⽣产者要数据,⽽是直接从阻塞队列⾥取.
- 阻塞队列就相当于⼀个缓冲区,平衡了⽣产者和消费者的处理能⼒.(削峰填⾕)
⽐如在"秒杀"场景下,服务器同⼀时刻可能会收到⼤量的⽀付请求.如果直接处理这些⽀付请求,服务器可能扛不住(每个⽀付请求的处理都需要⽐较复杂的流程).这个时候就可以把这些请求都放到⼀个阻塞队列中,然后再由消费者线程慢慢的来处理每个⽀付请求.
这样做可以有效进⾏"削峰",防⽌服务器被突然到来的⼀波请求直接冲垮.
8.2.3 标准库中的阻塞队列
public class Demo0 {
public static void main(String[] args) throws InterruptedException {
BlockingQueue queue = new LinkedBlockingQueue(3);
queue.put(1);
queue.put(2);
queue.put(2);
System.out.println("队列已满.....");
queue.put(4);
System.out.println("4不会被执行....");
}
}
输出结果:
public class Demo0 {
public static void main(String[] args) throws InterruptedException {
BlockingQueue queue = new LinkedBlockingQueue(3);
queue.put(1);
queue.put(2);
queue.put(2);
System.out.println("队列已满.....");
System.out.println(queue.take());
System.out.println(queue.take());
System.out.println(queue.take());
System.out.println("已经取出三个元素....");
System.out.println(queue.take());
System.out.println("已经取出四个元素");
}
}
输出结果:
8.2.4 阻塞队列的应用场景
8.2.4.1 消息队列
问:如何判断消息是发给服务器A、服务器B还是服务器C?
答:服务器A在生产消息的时候,可以打一个标签,相当于对消息进行了分类,消费者在获取消息时,可以根据这个标签来获取。
- 阻塞队列也能使⽣产者和消费者之间解耦.
⽐如过年⼀家⼈⼀起包饺⼦.⼀般都是有明确分⼯,⽐如⼀个⼈负责擀饺⼦⽪,其他⼈负责包.擀饺⼦⽪的⼈就是"⽣产者",包饺⼦的⼈就是"消费者".
擀饺⼦⽪的⼈不关⼼包饺⼦的⼈是谁(能包就⾏,⽆论是⼿⼯包,借助⼯具,还是机器包),包饺⼦的⼈也不关⼼擀饺⼦⽪的⼈是谁(有饺⼦⽪就⾏,⽆论是⽤擀⾯杖擀的,还是拿罐头瓶擀,还是直接从超市买的).
8.2.5 异步操作
8.2.5 自定义实现阻塞队列
自定义实现的阻塞队列:
public class MyBlockingQueue {
//定义一个数组来存放数据,具体的容量由构造方法中的参数决定
private Integer[] elementData;
//定义头尾下标
private volatile int head;
private volatile int tail;
//定义数组中元素的个数
private volatile int size = 0;
//构造
public MyBlockingQueue(int capacity){
if (capacity <= 0){//处理输入不合法
throw new RuntimeException("队列容量必须大于0");
}
elementData = new Integer[capacity];
}
// 插入---给代码块加锁
public void put(Integer value) throws InterruptedException {
synchronized (this){
//判满
if (size >= elementData.length){
//阻塞队列在队列满的时候应该阻塞等待
this.wait();//wait操作释放锁
}
//插入数据
elementData[tail] = value;
tail++;
size++;
//队列中有元素了,唤醒阻塞等待的线程
synchronized (this){
this.notifyAll();
}
//处理队尾下标
if (tail >= elementData.length){
tail = 0;
}
}
}
//获取数据---给方法加锁
public synchronized Integer take() throws InterruptedException {
//判空
if (size == 0){
//队列空的时候阻塞队列应该阻塞等待
this.wait();
}
//获取数据
Integer value = elementData[head];
head++;
size--;
//队列中有空的位置了,唤醒阻塞队列的线程
this.notifyAll();
//处理队头下标
if (head >= elementData.length){
head = 0;
}
return value;
}
}
测试加入元素:
public class Demo01 {
public static void main(String[] args) throws InterruptedException {
MyBlockingQueue queue = new MyBlockingQueue(3);
queue.put(1);
queue.put(2);
queue.put(3);
System.out.println("已经加入三个元素....");
queue.put(4);
System.out.println("已经加入四个元素....");
}
}
输出结果:
测试取出元素:
public class Demo02 {
public static void main(String[] args) throws InterruptedException {
MyBlockingQueue queue = new MyBlockingQueue(3);
queue.put(1);
queue.put(2);
queue.put(3);
queue.take();
queue.take();
queue.take();
System.out.println("已经取出三个元素....");
queue.take();
System.out.println("已经取出四个元素....");
}
}
输出结果:
如果在put元素的时候,队列满了,积压了很多线程,当size–之后,就会有不止一个线程去put元素,就会出现还没有出队元素被覆盖的情况。为了解决这个问题我就需要把判满的 if 换成 while,让被唤醒之后的线程重新判断一次这个条件。
8.2.6 阻塞队列–生产者消费者模型``
public class Demo03 {
public static void main(String[] args) {
MyBlockingQueue queue = new MyBlockingQueue(100);
//创建生产者线程
Thread producer = new Thread(()->{
int num = 0;
while (true){
try {
//添加元素
queue.put(num);
System.out.println("生产了元素:" + num);
num++;
//休眠一会:10毫秒
TimeUnit.MILLISECONDS.sleep(10);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
});
producer.start();
//定义一个消费者线程
Thread comsumer = new Thread(()->{
//不断的从队列取出元素
while (true) {
try {
//取出元素
Integer value = queue.take();
System.out.println("消费了元素:" + value);
//休眠1秒
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
});
comsumer.start();
}
}
输出结果:
生产者先把队列生产满,生产满了之后消费一个生产一个。
8.3 定时器
8.3.1 标准库中的定时器
那么这个task任务究竟是怎样的呢?
我们追溯源码发现这个方法实现了Runnable接口。
而且里面有一个没有实现的抽象方法run()方法。我们就可以通过它来定义自己的任务。
public class Demo01 {
public static void main(String[] args) {
//根据JDK中提供的类,创建一个定时器
Timer timer = new Timer();
//向定时器中添加任务
timer.schedule(new TimerTask() {
@Override
public void run() {
System.out.println("该起床了.....");
}
}, 1000);
timer.schedule(new TimerTask() {
@Override
public void run() {
System.out.println("任务2.....");
}
}, 3000);
timer.schedule(new TimerTask() {
@Override
public void run() {
System.out.println("任务3.....");
}
}, 5000);
}
}
输出结果:
执行完已有任务之后,就阻塞等待新任务。
8.3.2 实现定时器
public class MyTimer {
//用一个阻塞队列来组织任务
private BlockingQueue<MyTask> queue = new PriorityBlockingQueue<>();
//提供一个方法,提交任务
public void schedule(Runnable runnable, long delay){
//根据传入的参数,构造一个MyTask
MyTask task = new MyTask(runnable, delay);
//把任务放入阻塞队列
try {
queue.put(task);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
public MyTimer(){
//创建扫描线程
Thread thread = new Thread(()->{
//不断的扫描队列中的任务
while (true){
//1. 取出任务
try {
MyTask task = queue.take();
//2. 判断执行时间到了吗
long currentTime = System.currentTimeMillis();
if (currentTime >= task.getTime()){
//时间到了,执行任务
task.getRunnable().run();
}else {
//没有到时间,重新放回队列
queue.put(task);
}
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
});
thread.start();
}
}
//用一个类来描述任务及任务执行的时间
class MyTask implements Comparable<MyTask>{
//任务
private Runnable runnable;
//任务执行的时间
private long time;
public MyTask(Runnable runnable, long delay) {
//校验任务不能为空
if (runnable == null){
throw new IllegalArgumentException("任务不能为空");
}
//时间不能为负数
if (delay < 0){
throw new IllegalArgumentException("时间不能为负数");
}
this.runnable = runnable;
//计算出任务执行的具体时间
this.time = delay + System.currentTimeMillis();
}
public Runnable getRunnable() {
return runnable;
}
public long getTime() {
return time;
}
@Override
public int compareTo(MyTask o) {
//为了解决可能会溢出的问题,我们不使用相减的方式,使用比较的方式
if (this.getTime() > o.getTime()){
return 1;
} else if (this.getTime() < o.getTime()) {
return -1;
}else {
return 0;
}
//return (int) (this.time - o.getTime());//小根堆,小的在前
}
}
public class Demo02 {
public static void main(String[] args) {
//创建一个定时器对象
MyTimer timer = new MyTimer();
//添加任务
timer.schedule(()->{
System.out.println("该起床了");
},1000);
timer.schedule(()->{
System.out.println("任务2");
},2000);
timer.schedule(()->{
System.out.println("任务3");
},3000);
timer.schedule(null, -10);
}
}
输出结果:
问题1:忙等
假如当前的时间为18:52,判断出距离我们的队列中下一个要执行的任务时间还差一个小时,那么我们就会再次把这个任务放回队列中,在这一个小时中,构造方法中的while循环一直在循环执行,这个现象叫忙等,浪费了计算机的资源。
我们发现,放回队列的操作是导致忙等问题等问题的代码,为了解决这个问题,我们可以在放回队列时让程序等待一段时间,等待的时间为下一个任务的执行时间和当前时间的差。
问题2:添加新任务之后的第一个要执行的任务的时间变了
上一个问题解决了之后,在这个等待的时间里,我们可能会添加新的任务,假设我们添加了任务3,那么我们就会做不到定时执行任务。
为了解决这个问题,我们可以在当向队列中新添加任务时,统一唤醒一次线程,这样就能 保证能够扫描到新添加进去的线程,不会超时执行任务。
问题3:基于线程抢占式执行,由于CPU调度的问题产生的一系列现象
CPU调度的过程中可能会产生执行顺序的问题,或当一个线程执行到一半的时间被调度走的现象。
在执行上面这段代码我们假设该线程t1执行完MyTask task = queue.take();
之后就被CPU调度走了,被调度走去执行主线程t2中的任务,我们假设主线程又添加了一个新的任务,执行下面这段代码,直执行完下面的代码,才被CPU重新调度回原来的线程t1。
线程t1得到CPU资源之后继续执行后面的代码
那么可能会出现下面的问题,由于线程调度的问题,t2先入队了新任务,执行事件中爱t1读取的任务执行时间之间,t1读的任务发现时间没有到放回队列的时候,设置的等待时间超过了新任务的执行时间,导致t2放入队列的新任务不能即使的执行。造成这个现象的原因是没有保证原子性。
为了解决上面的问题,我们需要扩大锁的范围。
//构造方法
public MyTimer(){
//创建扫描线程
Thread thread = new Thread(()->{
//不断的扫描队列中的任务
while (true){
//1. 取出任务
try {
synchronized (this){//wait和notify必须搭配synchronized使用
MyTask task = queue.take();
//2. 判断执行时间到了吗
long currentTime = System.currentTimeMillis();
if (currentTime >= task.getTime()){
//时间到了,执行任务
task.getRunnable().run();
}else {
//当前时间与执行任务时间的差
long waitTime = task.getTime() - currentTime;
//没有到时间,重新放回队列
queue.put(task);
//加入等待时间
this.wait(waitTime);
}
}
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
});
thread.start();
}
这样就解决了原子性的问题。
再来看接下来的代码:如果我们添加的任务延迟时间都是0呢?
public class Demo02 {
public static void main(String[] args) throws InterruptedException {
//创建一个定时器对象
MyTimer timer = new MyTimer();
//添加任务
timer.schedule(()->{
System.out.println("该起床了");
},0);
timer.schedule(()->{
System.out.println("任务2");
},0);
timer.schedule(()->{
System.out.println("任务3");
},0);
}
}
输出结果:
此时线程就又出现了问题。
在多线程环境中出现的问题,一定要使用线程查看工具去观察线程的状态。
上面显示第37行被锁定,我们就找一下第37行
当代码执行到这一行时,要从队列中取任务,但是当队列中没有任务的时候,就会阻塞等待,一直到队列中有可用元素才会执行。
- 提交任务1
- 扫描线程取出任务执行
- while循环继续执行任务,但是现在队列中没有任务可用,于是就阻塞等待。
8.4 线程池
只要面试问到多线程,必问!!!
8.4.1 线程池是什么
其实就是字面意思,一次创建很多个线程,用的时候从池子里拿一个出来,用完之后还回池子。
8.4.2 为什么要使用线程池
避免了频繁创建销毁线程的开销,提升程序的性能。
在数据库中就有一个DataSource数据源,一开始就初始化了很多个数据库连接,当需要用数据库连接的时候,从池子中获取一个连接,用完之后换回池子,并不真正的销毁连接。
线程池中的线程不停的扫描保存任务中的集合,当有任务的时候执行任务,没有任务的时候阻塞等待,但是并不销毁线程。
为什么使用线程池可提升效率?
少量创建,少量销毁。
内核态: 操作系统层面。
**用户态:**JVM层面(应用程序层)
8.4.3 标准库中的线程池
需要背一下,面试中可能会问JDK中提供了几种线程池。
在使用线程池时,我们只需要定义好任务,并提交给线程池即可,线程是池子自动创建的。
这是通过类名.方法名的方式获取对象,那么可不可以通过new的方式去获取对象?
当然可以,但是构造方法不能完整的覆盖业务的需要。
public class Student {
private int id;
private int age;
private int classId;
private String name;
private String sno;
//通过age 和 name 初始化一个对象
public Student(int age, String name){
this.age = age;
this.name = name;
}
//通过classId 和 name 初始化一个对象
public Student (int classId, String name){
this.classId = classId;
this.name = name;
}
public int getId() {
return id;
}
public void setId(int id) {
this.id = id;
}
public int getAge() {
return age;
}
public void setAge(int age) {
this.age = age;
}
public int getClassId() {
return classId;
}
public void setClassId(int classId) {
this.classId = classId;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getSno() {
return sno;
}
public void setSno(String sno) {
this.sno = sno;
}
}
方法重载了,参数列表相同了,方法重载时要保证参数列表的类型和个数不同。
这个需求是真实存在的,但是语法限制,不能这么写。
//通过age 和 name 初始化一个对象
public static Student createStudentByAgeAndName(int age, String name){
Student student = new Student();
student.setAge(age);
student.setName(name);
return student;
}
//通过classId 和 name 初始化一个对象
public static Student createStudentByClassIdAndName(int classId, String name){
Student student = new Student();
student.setClassId(classId);
student.setName(name);
return student;
}
这是一种工厂方法模式,根据不同的业务需求定义不同的方法获取对象。
8.4.4 自定义一个线程池
思路:
- 用Runnable描述任务
- 组织管理任务可以使用一个队列,可以用阻塞队列去实现,使用阻塞队列的好处是:当队列中没有任务的时候就等待,节省系统资源。
- 提供一个向队列中添加任务的方法。
- 创建多个线程,扫描队列中的任务,有任务的时候就取出来执行即可。
写代码的时候,要先整理思路,再动手实现。
MyThreadPool类:
public class MyThreadPool {
//定义阻塞队列来组织任务
BlockingQueue<Runnable> queue = new LinkedBlockingQueue<>(100);
//构造方法
public MyThreadPool(int threadNum){
if (threadNum < 0){
throw new IllegalArgumentException("线程任务必须大于0");
}
//创建线程
for (int i = 0; i < threadNum; i++) {
Thread thread = new Thread(()->{
//不停的扫描队列
while (true){
try {
//取出任务
Runnable runnable = queue.take();
//执行任务
runnable.run();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
});
//启动线程
thread.start();
}
}
/**
* 提交任务到线程池
* @param runnable 具体的任务
* @throws InterruptedException
*/
public void submit(Runnable runnable) throws InterruptedException {
if (runnable == null){
throw new IllegalArgumentException("任务不能为空");
}
//把任务加入到队列
queue.put(runnable);
}
}
测试类:
public class Demo01 {
public static void main(String[] args) throws InterruptedException {
//初始化一个自定义的线程池
MyThreadPool threadPool = new MyThreadPool(3);
//通过循环向线程中提交任务
for (int i = 0; i <10; i++) {
int taskId = i +1;
threadPool.submit(()->{
System.out.println("执行任务:" + taskId + Thread.currentThread().getName());
});
}
}
}
输出结果:
执行任务:1Thread-0
执行任务:2Thread-0
执行任务:3Thread-0
执行任务:4Thread-0
执行任务:5Thread-0
执行任务:6Thread-0
执行任务:8Thread-0
执行任务:9Thread-0
执行任务:7Thread-1
执行任务:10Thread-0
8.4.5 创建系统自带的线程池
通过上面的工厂方法获取的线程池比较固定,也就是说不能进行定制,在实际的开发过程中,使用的是定制性比较强的创建线程池的方式。
面试题:说一说创建线程时的七个参数?
- 核心线程的数量
- 线程池中最大的线程数,最大线程数减去核心线程数 = 临时线程数。
- 临时线程的存活时间(一个数)。
- 临时线程的存活时间的时间单位,它和第三个参数配合在一起就是临时线程真正的存活时间
- 组织(保存)任务的队列。
- 创建线程的工厂,不关注。
- 拒绝策略。
面试题:线程池的工作原理:
实例1:
周末去吃火锅,火锅店很火,去的晚了就需要排号。
- 火锅店里有5张桌子(核心线程数)去了早了,店里没人就可以直接上桌点菜。
- 越到饭点人越来越多,这时5张桌子都坐满了,后面来的人就需要排号,最多可以排到20号(当于阻塞队列,20相当于阻塞队列的容量)。
- 排队的人越来越多,已经排到20号了(阻塞队列已经满了),在外面加了10张临时的桌子(临时线程数,线程池中总的线程数 = 核心线程数 + 临时线程数)。
- 排号的人就可以在外面的桌子上就餐。
- 时间越来越晚,排队的人都已经就餐了,外面的桌子慢慢也空下来了,老板说再等30分钟(临时线程的存活时间,临时线程的时间单位),如果再没人来就把外面的桌子收掉。
- 收掉外面的桌子,店里的5张桌子(最后又回归到了核心线程数)就可以满足顾客的就餐要求。
- 中途如果排号满了20号(阻塞队列满了),10张外面的桌子也坐满了(线程数量达到了线程池的最大个数),老板就不接待客人了(拒绝策略)。
实例2:去银行办业务
- 银行平时只开两上办理业务的窗口,相当于线程池的核心线程数。
- 当有新客户来银时,看到开放的两个容口空着,就可以直接去办理业务。
- 当两个窗口都有人在办理业务,后进来的客户就要去等待区等待。
- 随着等待的人越来越多,等待区已经满了,那么银行就叫来其他的业务员来开放其他三个窗口,一起办理业务。
- 再来银行的客户,就执行拒绝策略。
8.4.6 线程池流程图
- 添加任务,核心线程从队列中取任务去执行。
- 核心线程都在工作时,再添加的任务会进入到阻塞队列。
- 阻塞队列满了之后,会创建临时线程。
- 执行拒绝策略。
8.4.7 拒绝策略
- 直接拒绝
比如公司给分配了一个任务,我说现在我很忙,没有时间去处理这个任务,你就告诉领导说:你找别人干吧,我没时间。- 返回给调用者
比如公司给分配了一个任务,我说现在我很忙,没有时间去处理这个任务,你自己做吧。谁给我分配的任务我就把这个任务返回给谁,保证整个任务有线程执行。- 放弃目前最早等待的任务
比如公司给分配了一个任务,我说现在我很忙,没有时间去处理这个任务,老板说:最开始给你分的那个活,你可以不干了。
4. 放弃新提交的任务
放弃的任务,以后也找不回来了,所以指定拒绝策略的时候,要关注任务是不是需要必须执行,如果必须执行,就指定“返回调用者”,否则1 3 4 选一个即可,1在拒绝后会抛出异常;3,4在拒绝后不会抛出异常。
- 直接拒绝
public class Demo02 {
public static void main(String[] args) throws InterruptedException {
//定义一个线程池
ThreadPoolExecutor threadPool =
new ThreadPoolExecutor(3,
5,
1,
TimeUnit.SECONDS,
new LinkedBlockingQueue<>(5),
new ThreadPoolExecutor.AbortPolicy());
//通过循环向线程池中提交任务
for (int i = 0; i < 100; i++) {
int taskId = i + 1;
threadPool.submit(()->{
System.out.println("执行任务:" + taskId + ", " + Thread.currentThread().getName());
});
}
}
}
输出结果:
- 放弃目前最早的任务
输出结果:
- 抛弃最新的任务
输出结果:
- 返回给调用者
输出结果:
根据不同的业务场景选择不同的拒绝策略
9. 总结-保证线程安全的思路
- 使用没有共享资源的模型
- 使用共享资源,只读不写的模型
- 不需要写共享资源的模型
- 使用不可变对象
- 直面线程安全(重点)
- 保证原子性
- 保证顺序性
- 保证可见性
10. 对比线程和进程
10.1 线程的优点
- 创建⼀个新线程的代价要⽐创建⼀个新进程⼩得多
- 与进程之间的切换相⽐,线程之间的切换需要操作系统做的⼯作要少很多
- 线程占⽤的资源要⽐进程少很多
- 能充分利⽤多处理器的可并⾏数量
- 在等待慢速I/O操作结束的同时,程序可执⾏其他的计算任务
- 计算密集型应⽤,为了能在多处理器系统上运⾏,将计算分解到多个线程中实现
- I/O密集型应⽤,为了提⾼性能,将I/O操作重叠。线程可以同时等待不同的I/O操作。
10.2 进程与线程的区别
- 进程是系统进⾏资源分配和调度的⼀个独⽴单位,线程是程序执⾏的最⼩单位。
- 进程有⾃⼰的内存地址空间,线程只独享指令流执⾏的必要资源,如寄存器和栈。
- 由于同⼀进程的各线程间共享内存和⽂件资源,可以不通过内核进⾏直接通信。
- 线程的创建、切换及终⽌效率更⾼。
11. wait() 和 sleep()的区别
- 共同点,都会让线程阻塞一会儿
- 从实现使用上来说是两种不同的方式wait是Object类的方法,和锁相关,配合synchronized一起使用,调用wait之后会释放锁sleep是Thread类的方法,与锁无关.
- wait可以通过指定超时时间和通过notify方法唤醒,唤醒之后会重新竞争锁资源sleep只能通过超时时间唤醒