Hello, Hello~ 亲爱的朋友们👋👋,这里是E绵绵呀✍️✍️。 如果你喜欢这篇文章,请别吝啬你的点赞❤️❤️和收藏📖📖。如果你对我的内容感兴趣,记得关注我👀👀以便不错过每一篇精彩。 当然,如果在阅读中发现任何问题或疑问,我非常欢迎你在评论区留言指正🗨️🗨️。让我们共同努力,一起进步! 加油,一起CHIN UP!💪💪
一个线程就是一个 " 执行流 ". 每个线程之间都可以按照顺序执行自己的代码 . 多个线程之间 " 同时 " 执行着多份代码。
我们设想如下场景: 一家公司要去银行办理业务,既要进行财务转账,又要进行福利发放,还得进行缴社保。如果只有张三一个会计就会忙不过来,耗费的时间特别长。为了让业务更快的办理好,张三又找来两位同事李四、王五一起来帮助他,三个人分别负责一个事情,分别申请一个号码进行排队,自此就有了三个执行流共同完成任务,但本质上他们都是为了办理一家公司的业务。此时,我们就把这种情况称为多线程,将一个大任务分解成不同小任务,交给不同执行流就分别排队执行。其中李四、王五都是张三叫来的,所以张三一般被称为主线程(Main Thread)。
首先, "并发编程" 成为 刚需。
其次, 虽然多进程也能实现并发编程, 但是线程比进程更轻量。线程是轻量级进程。
最后, 线程虽然比进程轻量, 但是人们还不满足, 于是又有了 "线程池"和 "协程",关于线程池和协程我后面会再介绍。
之前讲过一个进程一般使用一个或者多个 PCB 来表示,之所以可以由多个PCB表示,是因为其实一个线程才是由一个PCB来表示,进程中有一个或者多个线程。
对于线程,它们的PCB属性跟上篇文章说的一样,也是主要有这七个属性,那么对于一个进程中的线程,它们的属性是相同的吗?
答案是 前三个都是一样的,后面四个不一样。 从而可以得出同一个进程中的若干线程之间,是共用相同的内存资源和文件资源的,线程1 中 new 个对象,线程2 是可以访问到的; 线程1 打开一个文件,线程2 也是可以直接使用的,这样每个线程就不用单独申请内存,可以共用,效率就更高。但是每个线程都是独立的在 cpu 上调度执行.
so可以得出以下结论:
线程是操作系统中的概念。 操作系统内核实现了线程这样的机制 , 并且对用户层提供了一些 API 供用户使用。
Java 标准库中 Thread 类可以视为是对操作系统提供的 API 进行了进一步的抽象和封装。从而达到跨系统(可以用于多个系统)
对于java中自带一个主线程,main就是java的主线程,在其里面的代码都是主线程中的任务
我们可以通过jconsole这个工具去观察线程,至于怎么使用,这里有一篇文章写的很好: 观测线程的工具——jconsole_观测电脑线程-CSDN博客
创建线程有两种方法:
通过继承 Thread
类并重写 run()
方法来创建线程。
class MyThread extends Thread {
@Override
public void run() {
System.out.println("Thread is running (Thread Class)");
System.out.println("Thread name: " + Thread.currentThread().getName());
}
}
public class Main {
public static void main(String[] args) {
MyThread thread = new MyThread();
thread.start(); // 启动线程
}
}
通过实现 Runnable
接口来定义线程任务。
class MyRunnable implements Runnable {
@Override
public void run() {
System.out.println("Thread is running (Runnable)");
System.out.println("Thread name: " + Thread.currentThread().getName());
}
}
public class Main {
public static void main(String[] args) {
Thread thread = new Thread(new MyRunnable());
thread.start(); // 启动线程
}
}
在讲完这两个方法后,我们发现run和start两个重要方法,那么它们是干什么的?
run里面用于描述线程干什么任务,通过 start 去创建线程而后执行该任务。
start是Thread中自带的方法,通俗的来说run方法记录这个"事情",而strat就是要执行run里面的"事情"
如果不去调用start,直接调用run的话,就没有创建出新的线程,就是在主线程中执行run任务。
其实对于上述代码,我们可以用匿名内部类让其更简便。
public class Main {
public static void main(String[] args) {
// 使用匿名内部类继承 Thread
Thread thread = new Thread() {
@Override
public void run() {
System.out.println("Thread is running (Thread - Anonymous Inner Class)");
System.out.println("Thread name: " + Thread.currentThread().getName());
}
};
// 启动线程
thread.start();
}
}
public class Main {
public static void main(String[] args) {
// 使用匿名内部类实现 Runnable
Runnable runnable = new Runnable() {
@Override
public void run() {
System.out.println("Thread is running (Runnable - Anonymous Inner Class)");
System.out.println("Thread name: " + Thread.currentThread().getName());
}
};
// 创建线程并启动
Thread thread = new Thread(runnable);
thread.start();
}
}
Lambda 表达式 我们之前学过,可以将匿名内部类更加简洁出现,但只适用于函数式接口,这里只可以用于简化实现runna接口的方法。
public class Main {
public static void main(String[] args) {
// 使用 Lambda 表达式实现 Runnable
Runnable runnable = () -> {
System.out.println("Thread is running (Runnable - Lambda Expression)");
System.out.println("Thread name: " + Thread.currentThread().getName());
};
// 创建线程并启动
Thread thread = new Thread(runnable);
thread.start();
}
}
由于
Thread
类本身不是函数式接口,所以无法直接使用 Lambda 表达式
对于匿名内部类和Lambda 表达式如果有忘的同学可以看看这篇文章
【Java数据结构】反射、枚举以及lambda表达式_java反射获取lamda表达式-CSDN博客
我们在前面看到了线程的创建需要Thread类,那么Thread类到底是什么呢? Thread 类是 JVM 用来管理线程的一个类,换句话说,每个线程都有一个唯一的 Thread 对象与之关联。 每个执行流,也需要有一个对象来描述,而 Thread 类的对象就是用来描述一个线程执行流的,JVM 会将这些 Thread 对象组织起来,用于线程调度,线程管理,
这些刚才都学过,就不继续说了
常见属性说明:
ID 是线程的唯一标识,不同线程不会重复,这里的id和pcb的id是不同的,是jvm自己搞的一套体系,Java代码也无法获取到pcb的id
名称是各种调试工具用到
状态表示线程当前所处的一个情况,这里有六种状态
1.NEW(新建) 线程对象被创建,但尚未启动。此时线程还未开始执行。 2.TERMINATED(终止) 线程已经完成了执行,线程对象还存在。 3.RUNNABLE(可运行) 线程已经启动,正在执行。 4.TIMED WAITING(计时等待) 线程进入一个有时间限制的等待状态。可以通过Thread.sleep(long millis)、Obiect.wait(long timeout)Thread.join(long millis)进行。 5.WAITING(等待) 线程进入等待状态直到其他线程显式地唤醒它。可以通过Obiect.wait()、Thread.join()方法进入此状态 6.BLOCKED(阻塞) 线程因为等待获取一个监视器锁而进入阻塞状态。通常发生在同步代码块或方法中,线程试图获取一个已经被其他线程持有的锁。
对于后面三个状态讲sleep,join,wait以及锁时,才能理解这三种状态。现在看可能有点看不懂,待我在后面讲解。
优先级高的线程理论上来说更容易被调度到
关于后台线程,需要记住一点:JVM会在一个进程的所有前台线程结束后,才会结束运行。而后台线程无论结束还是不结束,都不影响jvm结束运行
在 java 代码中, main 线程, 就是前台线程另外程序员创建出来的线程,默认情况下都是 前台线程.可以通过上述 setDaemon 方法来把线程设置为后台线程 在 jconsole 中看到的 jvm 中包含一些其他的内置的线程, 就属于后台线程。我不期望这个线程影响JVM的结束,就设为后台线程,举个例子,比如,有的线程负责进行 gc. (垃圾回收),gc 是要周期性持续性执行的.不可能主动结束,要是把他设为前台,进程就永远也结束不了
是否存活,很直白的意思,就是线程是否还存在
是否被中断(isInterrupted)这个属性之后在中断会讲到,这里先打个哑谜。
start 才是正在的创建线程(在内核中创建pcb),一个线程需要通过run/lambda把线程要执行的任务定义出来,start 才是正在的创建线程,并开始执行 一个 Thread 对象只能 start 一次
在 Java 中,线程的中断(Interrupt)是一种协作机制,用于通知线程应该停止当前的任务并退出。线程中断并不是强制终止线程,而是通过设置线程的中断状态来通知线程,线程可以根据中断状态决定如何响应,是否要中断。 操作系统原生的线程中,其实是有办法让别的线程直接被强制终止的,这种设定其实不太好, 所以 java 没有采纳过来,
Thread 内部包含了一个 boolean 类型的变量作为线程是否被中断的标记 ,我们称该变量为中断状态。 Java 提供了以下三个与线程中断相关的方法: 方法名说明
void interrupt()
设置中断状态为true。如果线程正在阻塞状态(如sleep,wait,join
),会抛出InterruptedException,并设置中断状态为falseboolean isInterrupted()
检查目标线程的中断状态,不会清除中断状态。static boolean interrupted()
检查当前线程的中断状态,并清除中断状态。这个用的少。
下面是有关线程中断的使用代码,可以利用以上三个方法结合一些代码 终止线程或者提前终止线程。
public class Main {
public static void main(String[] args) {
Thread thread = new Thread(() -> {
while (!Thread.currentThread().isInterrupted()) {
try {
System.out.println("Thread is running...");
Thread.sleep(1000); // 线程休眠 1 秒
} catch (InterruptedException e) {
System.out.println("Thread was interrupted while sleeping!");
// 恢复中断状态
Thread.currentThread().interrupt();
}
}
System.out.println("Thread was interrupted!");
});
thread.start();
// 主线程休眠 3 秒后中断子线程
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
thread.interrupt(); // 中断子线程
}
}
输出:
Thread is running...
Thread is running...
Thread was interrupted while sleeping!
Thread was interrupted!
在捕获 InterruptedException
后,调用 Thread.currentThread().interrupt()
恢复中断状态。这样可以确保线程能够正确退出。
在该代码中线程陷入死循环,此时通过设计我们就可以通过interrupt去终止该死循环线程。
这里还是写的最简单的逻辑代码,之后会出现更高级的逻辑代码,更加复杂。
有时,我们需要等待一个线程完成它的工作后,才能进行自己的下一步工作。
由于线程是"抢占式"执行且并发执行,所以谁先结束每次都是不确定的,如果希望让代码里面的 t 先结束,main后结束,就可以在main中使用线程等待 t.join()。
上述只是join无参数版本的,也就是死等,只要 t 不结束,就会一直等待下去,还要带参数的版本。 在实际开发中,一般很少使用死等这个策略 传入一个时间:传入的时间是最大等待时间,比如写的等待100s,如果100s之内,t 线程结束了之间返回,如果100s到了,t 线程还没有结束不等了!!!继续往下走。
这个方法我们之前用过,现在来看下吧。
它可以获取当前代码所在线程的对象。
它也是我们比较熟悉一组方法,有一点要记得,因为线程的调度是不可控的,所以,这个方法只能保证实际休眠时间是大于等于参数设置的休眠时间的。
join()
和sleep()
方法都会抛出InterruptedException
,这是因为它们都是可中断的阻塞方法。当线程在调用这些方法时,如果被其他线程中断(通过调用interrupt()
方法),就会抛出InterruptedException
。 为了确保程序的健壮性,我们需要在代码中正确处理这个异常。通常有两种方式来处理InterruptedException
:
throws
声明抛出异常。
try-catch
捕获并处理异常。
这样就不会编译错误,能正常运行,那么该用哪种方式处理呢?
我认为用第二种是最好的,因为假如没异常,第一种第二种都能正常运行,而真出现异常了,第一种会运行错误,第二种能真正解决。
并且如果在重写的run()方法中throws interruptedException 就会发生编译错误,由于父类不存在throws interruptedException ,重写的方法就不能throws interruptedException ,只能try catch 解决异常。
所以在面对join()和sleep()时,我们都一致用try catch解决InterruptedException
异常。后面还会学一个wait()方法,它也是跟join()和sleep()一样,也是用try catch解决InterruptedException
异常。
想给出一个线程安全的确切定义是复杂的,但我们可以这样认为: 如果多线程环境下代码运行的结果是符合我们预期的,即在单线程环境应该的结果,则说这个程序是线程安全的。
下面我们给出线程不安全的案例:
首先,我们写一个多线程代码,一个线程负责把一个数加50000次,另一个线程也负责把这个数加50000次。(从0开始加)
class Counter{
private int count = 0;
public void increase(){
count++;
}
public int getCount() {
return count;
}
}
public class Test4 {
public static void main(String[] args) throws InterruptedException {
Counter counter = new Counter();
Thread t1 = new Thread(()->{
for (int i = 0; i < 50000; i++) {
counter.increase();
}
});
Thread t2 = new Thread(()->{
for (int i = 0; i < 50000; i++) {
counter.increase();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(counter.getCount());
}
}
按照我们的逻辑,希望最后得到的结果是100000,为什么会出现这么大的偏差呢?其实,这样的运行结果是由于线程不安全导致的。 事实上:上面的count++操作,在CPU指令角度看,本质上是三个操作: 把内存中的数据加载到CPU的寄存器中。(LOAD) 把寄存器中的寄存器进行加一运行算。(ADD) 把寄存器中的数据写回到内存中。(save) 如果是单个线程执行,没有问题。但是如果是多个线程并发执行,就可能会出现错误。由于CPU调度线程的顺序是不确定的,因此这两个线程并发执行的过程中,线程1的操作可能会和线程2的操作相互影响,也就是说,这两个线程的命令的排列方式可能有很多种:
从而因为这样就导致了线程不安全。那么怎么解决该问题呢?看下文可知:
线程不安全的原因有多个:
1.多个线程之间的调度顺序是随机的,操作系统使用抢占式执行的策略来调度线程。【根本原因】 2.多个线程同时修改同一个变量,容易产生线程安全问题。 一个线程修改同一个变量 =>没事. 多个线程读取同一个变量 =>没事.每次读到的结果都是固定的, 多个线程修改不同的变量 =>没事.你改你的,我改我的,不影响. 3.进行的修改,不是原子性的。如果修改操作,能够按照原子的方式来完成,就不会出现线程安全问题。 原子性是指在一个操作在cpu中不可以中途暂停然后再调度,即不被中断操作,要不全部执行完成,要不都不执行(Mysql学过) 4.内存可见性引起的线程安全问题。 5.指令重排序,引起的线程安全问题。
要解决该案例我们首先得看是哪个原因造成的? 我们发现是前三个原因造成的。那么我们试着从这三个原因为出发点去解决它。 第一个原因,我们改变不了,因为内核已经是搞好了的,我们自己改也没用 第二个原因通过调整代码结构,尽量避免出现拿多个线程同时改同一个变量,这是一个切入点,但是在Java中,这种做法不是很普适,只是针对一些特定的场景是可以做到的。 第三个原因,这是解决线程安全问题最普适的方案
该问题不是由内存可见性和指令重排序引起的,所以现在先不讲,后面会讲到。
我们可以把修改操作改成“原子性”的操作,那么怎么操作呢? 可以进行加锁操作。相当于是把一组操作,打包成一个整体的操作。此处这里的原子性,是通过锁进行“互斥”,当前线程执行的时候,其他线程无法执行。对于加锁,Java引入了一个synchornized关键字。 synchronized 会起到互斥效果, 某个线程执行到某个对象的 synchronized 中时, 其他线程如果也执行到同一个对象 synchronized 就会阻塞等待. 进入 synchronized 修饰的代码块, 相当于 加锁 退出 synchronized 修饰的代码块, 相当于解锁。
可以粗略理解成, 每个对象在内存中存储的时候, 都存有一块内存表示当前的 "锁定" 状态(类似于厕 所的 "有人/无人"). 如果当前是 "无人" 状态, 那么就可以使用, 使用时需要设为 "有人" 状态. 如果当前是 "有人" 状态, 那么其他人无法使用, 只能排队
注意: 上一个线程解锁之后, 下一个线程并不是立即就能获取到锁. 而是要靠操作系统来 "唤醒". 这 也就是操作系统线程调度的一部分工作. 假设有 A B C 三个线程, 线程 A 先获取到锁, 然后 B 尝试获取锁, 然后 C 再尝试获取锁, 此时 B 和 C 都在阻塞队列中排队等待. 但是当 A 释放锁之后, 虽然 B 比 C 先来的, 但是 B 不一定就能获取到锁, 而是和 C 重新竞争, 并不遵守先来后到的规则.
从而可以通过该代码去解决问题
class Counter{
private int count = 0;
synchronized public void increase(){
count++;
}
public int getCount() {
return count;
}
}
public class Test4 {
public static void main(String[] args) throws InterruptedException {
Counter counter = new Counter();
Thread t1 = new Thread(()->{
for (int i = 0; i < 50000; i++) {
counter.increase();
}
});
Thread t2 = new Thread(()->{
for (int i = 0; i < 50000; i++) {
counter.increase();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(counter.getCount());
}
}
由上可知我们可以通过synchronized解决该线程不安全问题,但是具体使用和特质我们还是不清楚,这里详细说下:
1.最常见的是synchronized
修饰代码块,并指定锁对象。
public class Counter {
private int count = 0;
private final Object lock = new Object(); // 锁对象
public void increment() {
synchronized (lock) {
count++;
}
}
public int getCount() {
return count;
}
}
2.当 synchronized
修饰实例方法时,锁对象是当前实例(this
)。
public class Counter {
private int count = 0;
public synchronized void increment() {
count++;
}//该代码约等于如下代码
public void increment() {
synchronized(this){
count++;
}
}
public int getCount() {
return count;
}
}
Counter
实例的 increment()
方法时,同一时间只有一个线程能够执行该方法。
this
)。
3.当 synchronized
修饰静态方法时,锁对象是当前类的 Class
类对象。对于一个类来说,只有一个唯一的calss类对象。
public class Counter {
private int count = 0;
public static synchronized void increment() {
count++;
}//该代码约等于如下代码
public static void increment() {
synchronized(Counter.class){
count++;
}
}
public int getCount() {
return count;
}
}
Counter.increment()
方法时,同一时间只有一个线程能够执行该方法。
Counter.class
。
synchronized
的特性互斥性
synchronized
确保同一时间只有一个线程能够执行被保护的代码块或方法。
这个我们之前就讲述过了,这里不多讲。
static class Counter {
public int count = 0;
synchronized void increase() {
count++;
}
synchronized void increase2() {
increase();
}
}
如果用该代码,按照之前对于锁的设定, 第二次加锁的时候, 该线程就会阻塞等待. 直到第一次的锁被释放, 才能获取到第二个锁. 但是释放第一个锁也是由该线程来完成, 结果这个线程已经堵塞了, 也就无法进行解锁操作. 这时候就会 死锁.
这样的锁称为 不可重入锁.
那么可重入锁就是
虽然在synchronized中连续加锁不会出现死锁,但还有其他很多情况会出现死锁, 比如嵌套锁导致的死锁
public class NestedLockDeadlock {
// 定义两个锁对象
private static final Object lock1 = new Object();
private static final Object lock2 = new Object();
// 线程1:先获取lock1,再获取lock2
public static void thread1() {
synchronized (lock1) { // 获取lock1
System.out.println("Thread 1: Acquired lock1");
try {
Thread.sleep(100); // 模拟一些操作
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (lock2) { // 尝试获取lock2
System.out.println("Thread 1: Acquired lock2");
}
}
}
// 线程2:先获取lock2,再获取lock1
public static void thread2() {
synchronized (lock2) { // 获取lock2
System.out.println("Thread 2: Acquired lock2");
try {
Thread.sleep(100); // 模拟一些操作
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (lock1) { // 尝试获取lock1
System.out.println("Thread 2: Acquired lock1");
}
}
}
public static void main(String[] args) {
// 创建两个线程
Thread t1 = new Thread(NestedLockDeadlock::thread1);
Thread t2 = new Thread(NestedLockDeadlock::thread2);
// 启动线程
t1.start();
t2.start();
}
}
运行这段代码后,程序会陷入死锁,输出类似如下内容: Thread 1: Acquired lock1 Thread 2: Acquired lock2
死锁原因
lock1
,然后试图获取 lock2
。
lock2
,然后试图获取 lock1
。
那么如何避免死锁呢?有四个原因导致死锁,我们从这解决问题
避免死锁问题只需要打破上述四点的其中一点即可,对于第一点和第二点对于Java中是打破不了的,他们都是synchronized的基本特性 从第三点来看,不要让锁嵌套获取即可(但是有的时候必须嵌套,那就破除循环等待) 第四点破除循环等待:约定好加锁的顺序,让所有的线程都按照约定要的顺序来获取锁。
我们在最开始讲到线程安全的时候,聊到了关于线程安全问题总共有五种原因,前面我们讲到了三种,还要两种没有涉及到,那么就来聊聊内存可见性引起的线程安全问题。 内存可见性问题指的是在一个线程修改了共享变量的值之后,其他线程是否能够立即看到(即“看到”最新值)这个修改。如果不能,就可能出现内存可见性问题。
public class ThreadDemon26 {
public static int flag = 0;
public static void main(String[] args) {
Thread t1 = new Thread(()->{
while(flag == 0){//等待t1线程输入flag的值,只要不为0就能结束t1线程
}
System.out.println("t1线程结束");
});
Thread t2 = new Thread(()->{
System.out.println("请输入flag的值");
Scanner scanner = new Scanner(System.in);
flag = scanner.nextInt();
});
t1.start();
t2.start();
}
}
从之前的内容可知两个线程都写的情况会造成线程安全问题,那么这段代码有一个线程在写,一个线程在读会不会造成线程安全问题? 答案是会的,内存可见性会导致该问题 那么两个线程都进行读会造成线程安全问题?这里的答案是不会。
这段代码想要表现出来的效果是,t1,t2线程同时运行,通过t2线程中输入的flag的值来控制t1线程是否结束。
可是上文我们先后输入了1,0,2......都没能使t1线程结束,这是为什么呢?
我们看while(flag == 0){};这条语句其实有两个指令 ①load cpu从内存中读取flag的值(load)到cpu的寄存器上 ②访问寄存器 cpu访问寄存器中存储的flag的值,与0进行比较 ①中load的操作(读内存),相较于②中访问寄存器的操作,开销要大很多。(访问寄存器的速度是读内存的一万倍) 上述while循环中①②这两条指令整体看,执行的速度非常快,等你scanner几秒钟了,我while循环中①②可能都执行几亿次了(cpu的计算能力非常强) 此时JVM就会怀疑,这个①号load 的操作是否还有存在的必要(节省开销),于是经过load试探很多次发现都是一样的,JVM索性就认为load 的值一直都一样(速度太快了,等不到我们scanner输入flag的值),在load一次后,寄存器保存了它的值,然后把load这个操作给优化掉,只留一个访问寄存器的操作指令,访问之前寄存器中保存的值,大大提高循环的执行速度。这就是内存可见化问题会出现的本质原因。
那么怎么解决该问题呢?我们就用volatile关键词修饰变量。
对于JVM的优化,都适用于单线程,但不适用于多线程,可能会出现bug。 而volatile关键字,是强制性关闭JVM优化,开销是变大了,但是数据更准了。 volatile都是用来修饰变量的 功能①:解决内存可见性问题,每次访问被volatile修饰的变量都要读取内存,而不是优化到寄存器或者缓存器当中 功能②:禁止指令重排序,对于被volatile修饰的变量的操作指令,是不能被重排序的(这个等会会讲) 对于线程指令是否会发生JVM的优化,我们程序员也很难判定是否发生了,所以更需要通过volatile去避免这种可能存在的问题。
volatile 和 synchronized 有着本质的区别. synchronized 能够保证原子性, volatile 保证的是内存可见性.
指令重排序也是一种在编译器发生的优化过程,它改变了程序原有的指令执行顺序,使程序变得更好。 这在单线程是没问题的,但是在多线程可能会导致bug,所以在多线程中我们需要解决该问题,就要用到volatile修饰重排序操作指令涉及的变量,这样就没问题了。
对于指令重排序问题的相关代码后面讲单例模式会有所涉及到。
由于线程之间是抢占式执行的, 因此线程之间执行的先后顺序难以预知. 但是实际开发中有时候我们希望合理的协调多个线程之间的执行先后顺序. 完成这个协调工作, 主要涉及到三个方法 wait() / wait(long timeout): 让当前线程进入等待状态. notify() / notifyAll(): 唤醒在当前对象上等待的线程.
wait 做的事情: 1.使当前执行代码的线程进行等待. (把线程放到等待队列中) 2.释放当前的锁(释放后就可以允许其他线程用该锁了) 3.满足一定条件时被唤醒, 重新尝试获取这个锁 wait 要搭配 synchronized 来使用,脱离 synchronized 使用 wait 会直接抛出异常. wait 结束等待的条件: 1.其他线程调用该对象的 notify 方法. 2.wait 等待时间超时 (wait 方法提供一个带有 timeout 参数的版本, 来指定等待时间). 3.其他线程调用该等待线程的 interrupted 方法, 导致 wait 抛出 InterruptedException 异常.
这样在执行object.wait()之后就一直等待下去,那么程序肯定不能一直这么等待下去了。这个时候就 需要使用到了另外一个方法唤醒的方法notify()。
notify 方法是唤醒等待的线程. 方法notify()也要在synchronized中调用,该方法是用来通知那些可能等待该对象的对象锁的 其它线程,对其发出通知notify,并使它们重新获取该对象的对象锁。 如果notify和wait要联动,必须要求notify的调用对象,notify的锁对象,wait的调用对象,wait的锁对象都必须相同。 如果有多个线程等待,则有线程调度器随机挑选出一个呈 wait 状态的线程。(并没有 "先来后到") 在notify()方法后,当前线程不会马上释放该对象锁,要等到执行notify()方法的线程将程序执行完,也就是退出synchronized之后才会释放对象锁。
下面是一个案例
static class WaitTask implements Runnable {
private Object locker;
public WaitTask(Object locker) {
this.locker = locker;
}
@Override
public void run() {
synchronized (locker) {
while (true) {
try {
System.out.println("wait 开始");
locker.wait();
System.out.println("wait 结束");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
}
static class NotifyTask implements Runnable {
private Object locker;
public NotifyTask(Object locker) {
this.locker = locker;
}
@Override
public void run() {
synchronized (locker) {
System.out.println("notify 开始");
locker.notify();
System.out.println("notify 结束");
}
}
}
public static void main(String[] args) throws InterruptedException {
Object locker = new Object();
Thread t1 = new Thread(new WaitTask(locker));
Thread t2 = new Thread(new NotifyTask(locker));
t1.start();
Thread.sleep(1000);
t2.start();
}
notify方法只是随机唤醒某一个等待线程. 使用notifyAll方法可以一次唤醒所有的等待线程.
notifyAll()
比notify()
更安全,因为它不会随机选择一个线程唤醒,而是让所有线程都有机会重新竞争锁,从而避免了某些线程被永久忽略的问题。所以在大多数场景中,推荐使用notifyAll()
注意:虽然是同时唤醒所有线程, 但是这些线程需要竞争锁. 所以并不是同时执行, 而仍然是有先有后的依次执行.
static class WaitTask implements Runnable {
private Object locker;
public WaitTask(Object locker) {
this.locker = locker;
}
@Override
public void run() {
synchronized (locker) {
while (true) {
try {
System.out.println("wait 开始");
locker.wait();
System.out.println("wait 结束");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
}
static class NotifyTask implements Runnable {
private Object locker;
public NotifyTask(Object locker) {
this.locker = locker;
}
@Override
public void run() {
synchronized (locker) {
System.out.println("notify 开始");
locker.notifyall();
System.out.println("notify 结束");
}
}
}
public static void main(String[] args) throws InterruptedException {
Object locker = new Object();
Thread t1 = new Thread(new WaitTask(locker));
Thread t3 = new Thread(new WaitTask(locker));
Thread t4 = new Thread(new WaitTask(locker));
Thread t2 = new Thread(new NotifyTask(locker));
t1.start();
t3.start();
t4.start();
Thread.sleep(1000);
t2.start();
}
wait(long time)
是 Java 中Object
类的一个方法,用于使当前线程在指定的时间内等待某个对象的监视器。如果在指定时间内没有被唤醒,线程将自动从wait
状态中退出。这个方法是Object
类的wait()
方法的超时版本,允许线程在等待时设置一个最大等待时间。 时间是以毫秒为单位。
其实理论上 wait 和 sleep 完全是没有可比性的,因为一个是用于线程之间的通信的,一个是让线程阻塞一段时间,唯一的相同点就是都可以让线程放弃执行一段时间。 不同点在于: wait 需要搭配 synchronized 使用, sleep 不需要. wait 是 Object 的方法 ,sleep 是 Thread 的静态方法
单例模式是一种经典的设计模式,是校招中最常考的设计模式之一. 那么啥是设计模式呢? 软件开发中有很多常见的 “问题场景”. 针对这些问题场景, 大佬们总结出了一些固定的套路. 按照这个套路来实现代码, 就不会吃亏,起码能保下限。这些套路就是设计模式 那么什么是单例模式呢? 单例模式是 Java 中最简单的设计模式之一。单例模式 =>只允许存在单个实例. 如何保证这个类只有一个实例呢?靠程序猿口头保证是否可行?比如, 你在类上写一个注释: 该类只能 new 一个实例,不能 new 多次。这完全在依赖人,人 一定是不靠谱的!!! 所以需要让编译器来帮我们做一个强制的检查通过一些编码上的技巧,使编译器可以自动发现咱们的代码中是否有多个实例并且在尝试创建多个实例的时候,直接编译出错,根本上保证对象是唯一实例.=>这样的代码,就称为 单例模式
下面是一个简单的单例模式代码:
public class Singleton{
private static final Singleton singleton = new Singleton();
private Singleton(){}
public static Singleton getInstance() {
return singleton;
}
}
通过精巧的代码设计,就可以达到只允许存在一个实例对象。通过Singletion.getinstance()可以得到唯一的实例对象。
除该类型代码以外,还有另一种类型的代码,下面讲述一下。
单例模式有两种类型:
懒汉式:在真正需要使用对象时才去创建该单例类对象
public class Singleton {
private static Singleton singleton;
private Singleton(){}
public static Singleton getInstance() {
if (singleton == null) {
singleton = new Singleton();
}
return singleton;
}
}
饿汉式:在类加载时已经创建好该单例对象,等待被程序使用(最上面的代码就是)
对于懒汉式的代码因为是真正要用的时候才创建对象,所以相对于饿汉式来说开销更小,效率更高。 但是虽然效率更高,在多线程中饿汉却比懒汉更加安全,懒汉会触发线程安全问题。下面请看分析。
两个线程同时调用懒汉的单例模式中的Singletion.getinstance()和两个线程同时调用饿汉的单例模式中的Singletion.getinstance(),哪个会有bug?
饿汉都是读取,由于读读不会触发线程安全,所以饿汉不会引发线程安全问题。 懒汉两个线程都涉及到了修改,由于并发执行,导致可能出现两个对象同时存在,不符合单例模式,发生线程安全问题。
最容易想到的解决方法就是在方法上加锁,或者是对类对象加锁,程序就会变成下面这个样子
public static synchronized Singleton getInstance() {
if (singleton == null) {
singleton = new Singleton();
}
return singleton;
}
// 或者
public static Singleton getInstance() {
synchronized(Singleton.class) {
if (singleton == null) {
singleton = new Singleton();
}
}
return singleton;
}
这样就规避了两个线程同时创建Singleton对象的风险,但是引来另外一个问题:每次去获取对象都需要先获取锁,锁的开销是很大的,可以说有锁的线程虽然安全,但注定不会高性能,极端情况下,可能会出现卡顿现象。
所以接下来要做的就是优化性能,我们发现,当创建好了对象后不获取锁也不会引发线程安全问题,只有第一次没有对象的时候不获取锁才会引发线程安全问题。所以只有第一次创建对象时才需要加锁,我们就将代码设计成如下逻辑:如果没有实例化对象则加锁创建,如果已经实例化了,则不需要加锁,直接获取实例。这样就能减少获取锁的次数
所以直接在方法上加锁的方式就被废掉了,因为这种方式无论如何都需要先获取锁,开销极大,我们改善后的代码如下:
public static Singleton getInstance() {
if (singleton == null) { // 线程A和线程B同时看到singleton = null,如果不为null,则直接返回singleton
synchronized(Singleton.class) { // 线程A或线程B获得该锁进行初始化
if (singleton == null) { // 其中一个线程进入该分支,另外一个线程则不会进入该分支
singleton = new Singleton();
}
}
}
return singleton;
}
上面的代码已经完美地解决了并发安全+性能低效问题:
第2行代码,如果singleton不为空,则直接返回对象,不需要获取锁;而如果多个线程发现singleton为空,则进入内部获取锁; 第3行代码,多个线程尝试争抢同一个锁,只有一个线程争抢成功,第一个获取到锁的线程会再次判断singleton是否为空,因为singleton有可能已经被之前的线程实例化 其它之后获取到锁的线程在执行到第4行校验代码,发现singleton已经不为空了,则不会再new一个对象,直接返回对象即可 之后所有进入该方法的线程都不会去获取锁,在第一次判断singleton对象时已经不为空了
上面这段代码已经近似完美了,但是还存在最后一个问题:指令重排序问题(我们之前讲过)
创建一个对象,在JVM中会经过三步: (1)为singleton分配内存空间 (2)初始化singleton对象 (3)将singleton指向分配好的内存空间 指令重排序是指:JVM在保证最终结果正确的情况下,可以不按照程序编码的顺序执行语句,尽可能提高程序的性能。 在这三步中,第2、3步有可能会发生指令重排现象,创建对象的顺序变为1-3-2,会导致多个线程获取对象时,有可能线程A创建对象的过程中,执行了1、3步骤,此时经过线程的调度执行线程B,线程B判断singleton已经不为空,获取到未初始化的singleton对象,就会报N异常。 这在单线程是不会出现该问题,多线程会出现。
使用volatile关键字可以防止指令重排序,所以通过volatile修饰跟创建对象有关的变量则可以阻止该问题发生。
public class Singleton {
private static volatile Singleton singleton;
private Singleton(){}
public static Singleton getInstance() {
if (singleton == null) { // 线程A和线程B同时看到singleton = null,如果不为null,则直接返回singleton
synchronized(Singleton.class) { // 线程A或线程B获得该锁进行初始化
if (singleton == null) { // 其中一个线程进入该分支,另外一个线程则不会进入该分支
singleton = new Singleton();
}
}
}
return singleton;
}
}
但无论是完美的懒汉式还是饿汉式,终究敌不过反射和序列化,它们俩都可以把单例对象破坏掉(产生多个对象)。但由于反射和序列化在该情况中用的极少,所以这里就没必要详细讲述,只需要清楚就行。
阻塞队列是一种特殊的队列,同样遵循“先进先出”的原则,支持入队操作和出队操作和一些基础方法。在此基础上,阻塞队列会在队列已满或队列为空时陷入阻塞,所以它具有如下特性: 1.当队列已满时,继续入队列就会阻塞,直到有其他线程从队列中取走元素。 2.当队列为空时,继续出队列也会阻塞,直到有其他线程向队列中插入元素。 3.并且它是线程安全的,就是多线程中使用它是不会引发线程安全bug的 那么我们用它是干嘛呢?一般用它实现生产者消费者模型,对于该概念我们下面详细说下:
生产者消费者模型有两种角色,生产者和消费者,两者之间通过缓冲容器来达到解耦合和削峰填谷的效果。类似于厂商和客户与中转仓库之间的关系,如下图:
厂家生产的商品堆积在中转仓库,当中转仓库满时,入仓阻塞,当中转仓库为空时,出仓阻塞。通过上述结构,生产者和消费者摆脱了“产销一体”的运作模式,即解耦合。同时,无论是客户需求暴增,还是厂家产量飙升,都会被中央仓库协调,避免突发情况导致结构崩溃,达到削峰填谷的作用。
同理,根据生产者消费者模型,我们将线程带入到消费者和生产者的角色,阻塞队列带入到缓冲空间的角色,一个类似的模型很容易就搭建起来了。
所以说,阻塞队列对生产者消费者模型是相当重要的。
①解耦合
作为生产者消费者模式的缓冲空间,将线程(其他)之间分隔,通过阻塞队列间接联系起来,起到降低耦合性的作用,这样即使其中一个挂掉,也不会使另一个也跟着挂掉。(就是降低它们之间的联系性)
②削峰填谷
因为阻塞队列本身的大小是有限的,所以能起到一个限制作用,即在消费者面对突发暴增的入队操作,依然不受影响。
如电商平台在每年双十一时都会出现请求峰值的情况,如下:
而假设电商平台对请求的处理流程是这样的:
因为处理请求需要消耗硬件资源,如果没有消息队列,面对双十一这种请求暴增的情况,请求处理服务器很可能就直接挂掉了。
而有了消息队列之后,请求处理服务器不必直接面对大量请求的冲击,仍旧可以按原先的处理速度来处理请求,避免了被冲爆,这就是‘削峰’。
没有被处理的请求也不是不处理了,而是当消息队列有空闲时再继续流程,即高峰请求被填在低谷中,这就是‘填谷’。
经过‘削峰填谷’之后的请求处理曲线就(大致)变成了下图:
经过阻塞队列的请求量就相比之前稳定很多了
在 Java 标准库中就提供了现成阻塞队列这样的数据结构:BlockingQueue ,这里 BlockingQueue 是一个接口,实现这个接口的类也有很多:
ArrayBlockingQueue
: 基于数组的阻塞队列。
LinkedBlockingQueue
: 基于链表的阻塞队列。
PriorityBlockingQueue
: 支持优先级的阻塞队列。
阻塞队列一般用put和take方法。 put 方法用于阻塞式的入队列, take 用于阻塞式的出队列. BlockingQueue 也有 offer, poll, peek 等方法, 但是这些方法不带有阻塞特性,所以不用
在了解了阻塞队列的使用后,我们就准备实现一个阻塞队列来加深对阻塞队列的理解
实现阻塞队列,我们可以从浅到深的来实现,先实现一个普通队列,再在普通队列的基础上,添加上线程安全,再增加阻塞功能,那么就来普通队列的实现吧。这里我们实现一个环形队列(之前讲过怎么实现,这里直接给代码)
class MyBlockingQueue {
//对象公用锁
private Object lock = new Object();
//String类型的数组,存储队列元素
private String[] elems = null;
//队首位置
private int head = 0;
//队尾位置
private int tail = 0;
//存储的元素个数
private int size = 0;
//构造方法,用于构建定长数组,数组长度由参数指定
public MyBlockingQueue(int capacity) {
elems = new String[capacity];
}
//入队方法
public void put(String elem) throws InterruptedException {
synchronized(lock) {
//已满时入队操作阻塞
while(size == elems.length) {
lock.wait();
}
//将元素存入队尾
elems[tail] = elem;
//存入后,队尾位置后移一位
tail++;
//实现环形队列的关键,超过数组长度后回归数组首位
if(tail >= elems.length) {
//回归数组首位
tail = 0;
}
//存入后元素总数加一
size++;
//当出队操作阻塞时,入队后为其解除阻塞
//(入队后队列不为空了)
lock.notify();
}
}
//出队方法
public String tack() throws InterruptedException {
//存储取出的元素,默认为null
String elem = null;
synchronized (lock) {
//队列为空时出队操作阻塞
while (size == 0) {
lock.wait();
}
//出队,取出队首值(不用置空,队尾存入时覆盖)
elem = elems[head];
//出队后,队首位置后移一位
head++;
//实现环形队列的关键,超过数组长度后回归数组首位
if(head == elems.length) {
//回归数组首位
head = 0;
}
//存入后元素总数加一
size--;
//当入队操作阻塞时,出队后为其解除阻塞
//(出队后队列不满)
lock.notify();
}
//返回取出的元素
return elem;
}
}
之后我们要将其变为阻塞队列,就要改进该代码。 首先由于阻塞队列是线程安全的,所以要用volatile修饰变量,sychronized修饰take和put方法。 然后对于满了或者空了会导致阻塞情况,我们就用wait()去阻塞,notify()则放在take和put方法的最后面去唤醒。 那么现在代码还有问题吗?还是存在的,wait()除了用notify()唤醒还可以用什么唤醒? 还可以用interrupt去强制唤醒并抛出异常,如果用的话此时阻塞队列是满的且退出了if循环,并且让size再加一,此时就会引发bug,所以我们的if必须换成while,这样就不能退出循环,并且继续阻塞。 此时阻塞队列就是没有问题的了。
阻塞队列完全版:
class MyBlockingQueue {
private String[] elems = null;
private volatile int head = 0;
private volatile int tail = 0;
private volatile int size = 0;
public MyBlockingQueue(int capacity) {
elems = new String[capacity];
}
public synchronized void put(String elem) {
while(size == elems.length) {
try{
this.wait();
}catch (Exception e)
{
e.printStackTrace();
}
}
elems[tail] = elem;
tail++;
if(tail >= elems.length) {
tail = 0;
}
size++;
this.notify();
}
public synchronized String take() {
String elem = null;
while(size == 0) {
try{
this.wait();
}catch (Exception e)
{
e.printStackTrace();
}
}
elem = elems[head];
head++;
if(head == elems.length) {
head = 0;
}
size--;
this.notify();
return elem;
}
}
class ThreadDemo5 {
public static void main(String[] args) throws InterruptedException {
MyBlockingQueue myBlockingQueue = new MyBlockingQueue(100);
Thread customer = new Thread(()->{
while (true) {
try {
String elem = myBlockingQueue.take();
System.out.println("消费元素:-> " + elem);
Thread.sleep(500);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
},"消费者");
Thread producer = new Thread(()->{
int n = 0;
while (true) {
try {
myBlockingQueue.put(n + "");
System.out.println("生产元素:-> " + n);
} catch (Exception e) {
throw new RuntimeException(e);
}
n++;
}
},"生产者");
// 启动生产者与消费者线程
customer.start();
producer.start();
}
}
定时器是软件开发中的一个重要组件. 类似于一个 "闹钟". 达到一个设定的时间之后, 就执行某个指定好的代码.
标准库中的定时器 标准库中提供了一个 Timer 类. Timer 类的核心方法为 schedule . schedule 包含两个参数.
第一个参数是继承timetask抽象类的类实例且内部重写了run方法:指定即将要执行的任务代码(timetask实现了runable接口所以有run方法)
第二个参数指定多长时间之后执行 (单位为毫秒).
Timer timer = new Timer();
timer.schedule(new TimerTask() {
@Override
public void run() {
System.out.println("hello");
}
}, 3000);
执行schedule方法的时候,系统把要执行的任务放到timer对象中,与此同时timer对象里头自带一个线程叫做“扫描线程”,一旦时间到扫描线程就会执行刚才安排的任务,执行完所有任务后线程也不会销毁,会阻塞等待直到其他的任务被放到timer对象中再继续执行(就这么重复)
对于定时器来说,重要的还是怎么用它,实现它主要是加深对它的理解。 所以这里的实现过程逻辑细节等我就不讲了,强烈推荐一个文章,写的很好。 JavaEE 初阶(13)——多线程11之“定时器”_jeecg 定时器-CSDN博客
总代码:
import java.util.PriorityQueue;
class MyTimerTask implements Comparable<MyTimerTask> {
//此处这里的 time,通过毫秒时间戳,表示这个任务具体啥时候执行
private long time;
private Runnable runnable;
public MyTimerTask(Runnable runnable, long delay) {
this.time = System.currentTimeMillis() + delay;
this.runnable = runnable;
}
public void run() {
runnable.run();
}
public long getTime() {
return time;
}
@Override
public int compareTo(MyTimerTask o) {
//比如,当前时间是 10:30,任务时间是 12:00,不应该执行
//如果当前时间是 10:30,任务时间是 10:29,应该执行
//谁减去谁,可以通过实验判断
return (int) (this.time - o.time);
}
}
public class MyTimer {
private final PriorityQueue<MyTimerTask> queue = new PriorityQueue<>();
private final Object locker = new Object();
public MyTimer() {
Thread t = new Thread(() -> {
try {
while (true) {
synchronized (locker) {
while (queue.isEmpty()) {
//如果还没添加任务,会不断循环执行判断,出现线程饿死。
//continue;
//因此,使用wait等待,当添加任务后唤醒
locker.wait();
}
MyTimerTask task = queue.peek();
if (System.currentTimeMillis() >= task.getTime()) {
task.run();
queue.poll();
} else {
//如果还没到任务执行时间,依旧不断循环判断,出现线程饿死。
//continue;
//因此,使用有等待期限的 wait,计算执行的时间与当前时间的差值
//当添加新的任务后,wait 被唤醒,再进行新的判断
locker.wait(task.getTime() - System.currentTimeMillis());
}
}
}
} catch (InterruptedException e) {
e.printStackTrace();
}
});
t.start();
}
public void schedule(Runnable runnable, long delay) {
synchronized (locker) {
MyTimerTask task = new MyTimerTask(runnable, delay);
queue.offer(task);
// 唤醒 wait
locker.notify();
}
}
}
在前面我们都是通过new Thread() 来创建线程的,虽然在java中对线程的创建、中断、销毁、等值等功能提供了支持,但从操作系统角度来看,如果我们频繁的创建和销毁线程,是需要大量的时间和资源的,那么有没有什么开销更小的方法? 第一种是协程,它可以说是轻量级线程,但是java很少用,多用于go和python。 第二种是线程池,java中多用线程池去解决频繁的创建和销毁线程问题。 那么为啥引入线程池就能够提升效率呢? 1.直接创建/销毁线程,是需要在用户态+内核态配合完成的工作,对于线程池,只需要在用户态即可,不需要内核态的配合,这样开销就更小 2.等线程用完之后,线程池不会销毁该线程,而是让其阻塞,等下次用的时候会再次利用它,所以不用频繁的进行创建和销毁。
线程池最核心的设计思路:复用线程,平摊线程的创建与销毁的开销代价
java 提供了多种方式来创建线程池,主要通过
Executors
工厂类或直接使ThreadPoolExecutor
类来完成
使用Executors工厂类: newFixedThreadPool(int nThreads):创建一个固定大小的线程池,线程数量由nThreads参数确定。 newCachedThreadPool():创建一个线程数量为动态的线程池,线程数量会根据任务数量动态变化,当长时间没有新任务时,空闲线程会被终止。 newSingleThreadExecutor():创建一个单线程的线程池,它只会创建一个线程来执行任务。 newScheduledThreadPool(int corePoolSize):创建一个可以安排任务的线程池,可以指定延迟执行任务或定期执行任务。 后面两个我们用的都不多,主要是用前面两个
下面是使用代码:
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class ThreadPoolExample {
public static void main(String[] args) {
// 创建一个固定大小的线程池
ExecutorService fixedThreadPool = Executors.newFixedThreadPool(4);
// 创建一个可缓存的线程池(线程数量动态调整)
ExecutorService cachedThreadPool = Executors.newCachedThreadPool();
}
}
这代码我们有个疑点,我们并没有new一个对象,那我们是怎么创建出来对象的呢?
这个问题涉及到工厂模式这种设计模式:
工厂模式是一种常用的设计模式,用于封装对象的创建逻辑。它通过使用方法来创建对象(new在方法内部),而不是直接使用 new
关键字实例化对象。这样可以将对象的创建逻辑与使用逻辑解耦,提高代码的可维护性和可扩展性。
这里就是用方法创建出对象,所以涉及到了工厂模式
对于刚才讲的 Executors 本质上是 ThreadPoolExecutor 类的封装. 而对于ThreadPoolExecutor类本身我们提供了更多的可选参数, 可以进一步细化线程池行为的设定.
如下图是 ThreadPoolExecutor类的构造方法:
corePoolSize
):线程池中始终保持的线程数量。这是不会被销毁的。
maximumPoolSize
):线程池中允许的最大线程数量。这种一般涉及到刚才的动态线程池,如果任务多了则创建一些线程,少了的话过了一段时间则会销毁,但核心线程数不变。
keepAliveTime
):当线程池中的线程数量超过核心线程数时,空闲线程的存活时间。
workQueue
):其为阻塞队列,用于存储等待执行的任务。要记住,当我们创建线程池时,系统也会同时自动创建一个阻塞队列去存储等待执行的任务,这样效率就更高。
threadFactory
):线程工厂是一个用于创建线程的工具类或接口,它允许用户自定义线程的创建逻辑,开发者可以控制线程的名称、优先级、异常处理等属性,从而更好地管理线程资源。
handler
):当线程池已满且阻塞队列也已满时,新任务的处理策略。
下面重点讲述一下拒绝策略:
AbortPolicy
:直接抛出 RejectedExecutionException
异常。
CallerRunsPolicy
:由提交任务的线程直接执行任务。
DiscardPolicy
:直接丢弃任务,不抛出异常。
DiscardOldestPolicy
:丢弃队列中最老的任务,然后尝试提交新任务。
下面是其创建代码
ThreadPoolExecutor threadPool = new ThreadPoolExecutor(
2, // 核心线程数
4, // 最大线程数
60, // 空闲线程存活时间
TimeUnit.SECONDS, // 时间单位
new ArrayBlockingQueue<>(10), // 任务队列,容量为 10
Executors.defaultThreadFactory(), // 线程工厂
new ThreadPoolExecutor.AbortPolicy() // 拒绝策略
);
总结一下:
通过线程池.submit(继承runable的类的对象) 可以提交一个任务到线程池中执行.
ExecutorService pool = Executors.newFixedThreadPool(10);
pool.submit(new Runnable() {
@Override
public void run() {
System.out.println("hello");
}
});
这里就直接上代码了,不多说,重点还是使用线程池,不是实现线程池。
/**
* 自定义线程池执行器类
* 该类通过实现一个具有固定大小的线程池和一个阻塞队列来管理线程,用于异步执行任务
*/
class MyThreadPoolExecutor {
// 创建阻塞队列,用于存放待执行的任务
// 队列大小设为1000,用于控制并发任务的数量,避免过多任务导致资源耗尽
BlockingQueue<Runnable> blockingQueue=new ArrayBlockingQueue<>(1000);
/**
* 构造函数,初始化线程池
* 创建一个线程,该线程循环从阻塞队列中取任务并执行
* 这个线程是线程池中的工作线程,负责执行提交的任务
*/
public MyThreadPoolExecutor(int n) {
for (int i = 1; i <= n; i++) {
Thread t = new Thread(() -> {
// 无限循环,确保线程池可以持续处理任务,直到程序中断或阻塞队列被清空
while (true) {
try {
// 从阻塞队列中取出一个任务,如果队列为空,则线程被阻塞,直到有任务放入队列
Runnable task = blockingQueue.take();
// 执行取出的任务
task.run();
} catch (InterruptedException e) {
// 如果线程在等待状态时被中断,抛出运行时异常
// 这通常会导致程序异常终止
throw new RuntimeException(e);
}
}
});
// 启动线程池中的工作线程
t.start();
}
}
/**
* 提交一个任务到线程池
* @param task 需要被执行的任务
* 任务被放入阻塞队列中,随后由线程池中的工作线程执行
*/
public void submit(Runnable task){
// 将任务放入阻塞队列,如果队列已满,则操作会阻塞,直到有空间可用
blockingQueue.offer(task);
}
}
class DemoTest1{
public static void main(String[] args) throws InterruptedException {
MyThreadPoolExecutor ex=new MyThreadPoolExecutor(4);
for(int i=0;i<100;i++) {
int id = i;
ex.submit(()->{
System.out.println(Thread.currentThread().getName()+" 任务:"+id);
});
}
}
}
下图为执行结果: