晚上好,愿这深深的夜色给你带来安宁,让温馨的夜晚抚平你一天的疲惫,美好的梦想在这个寂静的夜晚悄悄成长。
目录
如果任务尚未完成,调用 get() 方法会阻塞当前线程,直到任务完成并返回结果:
前言
在软件开发中,多线程编程是一项重要的技能,特别是在处理并发问题和提升程序性能方面起到关键作用。本文将探讨Java中多线程编程的基础知识、常见应用场景以及一些最佳实践。
当多线程切换时间片的速度非常快时,会给人一种多个任务同时进行的感觉。比如,视频播放实际上是由一帧一帧的图片组成的,但由于切换速度很快,用户无法感知到图片之间的切换。这种体验使得用户可以在玩游戏的同时听歌、收到QQ消息,极大地提升了用户的体验。因此,多线程的重要性不言而喻。
一、多线程是什么?
多线程是指一个程序中包含多个执行流,并行执行各自独立的任务。在单核处理器上,多线程通过时间片轮转实现并发执行;在多核处理器上,多线程可以同时执行多个任务,充分利用多核资源提升程序性能。
Java中的多线程
对于Java的mt.start()会开辟一个栈内存,不同栈内存开始抢夺cpu的执行权。
记住每块线程都自己的栈空间,程序中所有的栈空间不运行完毕,就不会结束程序。
二、使用步骤
1.创建方式
1.1 Thread 线程
通过继承Thread类来创建线程是最基本的方式之一。这种方式需要定义一个新的类,直接继承自Thread,并重写其中的run方法来定义线程的任务逻辑。
class MyThread extends Thread {
public void run() {
// 线程执行的任务逻辑
}
}
// 创建并启动线程
MyThread thread = new MyThread();
thread.start();
特点和适用场景:
- 简单直观:继承Thread类易于理解和实现。
- 适合简单的线程逻辑:当线程的任务逻辑相对独立且简单时,可以考虑使用此方法。
注意事项:
- Java是单继承的:因此如果已经继承了其他类,则无法再通过继承Thread来创建线程。
1.2 Runnable 任务
实现Runnable接口是更加灵活的一种创建线程的方式。这种方法将任务逻辑封装在实现了Runnable接口的类中,并将其传递给Thread类的构造函数。
Thread底层:
使用一个成员变量存储传参的Runnable对象,然后底层的run方法会调用Runnable的run()方法
代码实现:
class MyRunnable implements Runnable {
public void run() {
// 线程执行的任务逻辑
}
}
// 创建线程并启动
Thread thread = new Thread(new MyRunnable());
thread.start();
特点和适用场景:
- 可继承性:实现Runnable接口不影响类的继承关系,可以更灵活地管理线程任务。
- 推荐的方式:在大多数情况下,推荐使用实现Runnable接口的方式来创建线程,因为它更加面向接口编程,符合面向对象设计原则。
注意事项:
- 线程安全:在多个线程访问共享资源时需要注意线程安全性。
1.3 Callable 带返回值任务
实现Callable接口可以获取返回值是更加灵活的一种创建线程的方式。这种方法将任务逻辑继承了Runnable接口的类中,并在通过get()获取返回值,底层会阻塞其他线程直到当前线程执行执行完毕并返回返回值,才会继续执行后续代码。
futureTask.get();获取子线程的任务,如果子线程没有返回结果,那么我就一直等等等(阻塞),直到返回结果为止!
Callable的底层原理
run方法底层会转调call()方法也就代码中重写的
如果任务尚未完成,调用 get() 方法会阻塞当前线程,直到任务完成并返回结果:
禁用其他线程,一直等待当前线程返回结果为止
创建带返回值的任务:
package demo2;
import javax.security.auth.callback.Callback;
import java.util.concurrent.Callable;
/**
* @author windStop
* @version 1.0
* @description 带回调的任务
* @date 2024年07月25日19:58:29
* async:函数是异步函数 await:等待某个函数执行完毕后,继续执行 ---Promise
*/
public class MyCallback implements Callable<Long> {
//线程任务
@Override
public Long call() throws Exception {
long sum = 0;
for (int i = 0; i < 100000; i++) {
sum+=i;
}
System.out.println("MyCallable.run 输出: "+sum);
return sum;
}
}
代码执行:
package demo2;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.FutureTask;
/**
* @author windStop
* @version 1.0
* @description 测试:带回调的任务
* @date 2024年07月25日20:00:42
*/
public class Test2 {
public static void main(String[] args) throws ExecutionException, InterruptedException {
//带回调任务的线程
//FutureTask是Runnable接口的实现类
//FutureTask中提供了一个方法 get方法,用于获取未来任务执行完毕后返回的结果
FutureTask<Long> futureTask = new FutureTask<>(new MyCallback());
Thread t = new Thread(futureTask);
t.start();
//获取子线程执行的结果,一定在开启线程之后获取
Long l = futureTask.get();//获取子线程的任务,如果子线程没有返回结果,那么我就一直等等等(阻塞),直到返回结果为止!
System.out.println("结果:" + l);
new Thread(() -> {
for (int i = 0; i < 100; i++) {
System.out.println(Thread.currentThread().getName() + "爱李白");
}
}).start();
}
}
好处:
-
返回结果:
Callable
接口的主要优势在于它可以返回一个结果,这个结果可以在任务执行完成后通过Future
对象获取。这使得在多线程任务中,可以方便地获取并处理任务的执行结果,而不需要使用共享变量或者其他的线程同步机制。 -
异常处理:
Callable
的call()
方法可以抛出异常,与Runnable不同,Runnable的run()
方法只能在内部捕获异常并处理,无法向外抛出。在实际应用中,能够处理任务执行过程中可能抛出的异常是非常重要的,Callable能够提供更灵活的异常处理机制。
Runnable 和 Callable 的区别
-
返回值类型:
- Runnable 接口的
run()
方法没有返回值,因此不能返回执行结果。 - Callable 接口的
call()
方法有返回值,可以返回执行结果。返回值类型通过泛型指定,在调用时可以获取到异步执行的结果。
- Runnable 接口的
-
异常抛出:
- Runnable 接口的
run()
方法无法抛出 checked 异常(即需要在方法签名中声明的异常),只能在方法内部进行捕获和处理,不能将异常向外抛出。 - Callable 接口的
call()
方法可以抛出异常(包括 checked 异常),允许将异常传播到调用者处,由调用者进行处理。
- Runnable 接口的
-
使用场景:
- Runnable 通常用于需要线程执行任务但不需要返回结果的情况,例如简单的线程任务执行或异步处理。
- Callable 通常用于需要线程执行任务并且能够获取执行结果的情况,例如在多线程计算中提交任务并获取计算结果,或者需要处理异常情况的任务。
1.4 使用Executor框架
Java提供了Executor框架来管理线程的生命周期和执行任务。Executor框架通过ThreadPoolExecutor类实现线程池,可以重用线程并提供更高的灵活性和性能。
ExecutorService executor = Executors.newFixedThreadPool(10); // 创建一个固定大小的线程池
executor.submit(new MyRunnable()); // 提交任务给线程池执行
executor.shutdown(); // 关闭线程池
特点和适用场景:
- 线程池管理:通过Executor框架可以有效地管理和调度大量线程。
- 提高性能:线程池可以重用线程、减少线程创建和销毁的开销,提高系统性能。
注意事项:
- 适当的线程池大小:需要根据实际情况选择合适的线程池大小,避免资源浪费或者任务阻塞。
2. 常用api
1. 启动线程:
- start()方法:调用Thread对象的start()方法来启动线程,实际上会调用线程的run()方法来执行任务。
在Java中,一旦一个线程被启动后,就不能再次调用它的start()方法。如果尝试多次调用start()方法会导致IllegalThreadStateException异常的抛出。
run()和 start()有什么区别?
- start():用来启动线程,通过该线程调用run方法执行run方法中所定义的逻辑代码。start方法只能被调用一次。
- run(:封装了要被线程执行的代码,可以被调用多次。
2. 线程生命周期管理:
join()方法:等待指定线程执行完成。当前线程会阻塞直到目标线程执行完成。
thread.join(); // 等待thread线程执行完成
3. 线程状态控制:
-
sleep()方法:使当前线程暂停执行一段时间,让出CPU给其他线程。
try { Thread.sleep(1000); // 暂停当前线程1秒 } catch (InterruptedException e) { e.printStackTrace(); }
-
yield()方法:暗示当前线程愿意让出CPU执行时间,但是实际是否让出取决于线程调度器的实现。
实际没啥用,你让了并不代表可以抢到。
4. 线程同步与通信:
-
synchronized关键字:用于实现线程的同步,可以修饰方法或代码块,保证多个线程对共享资源的安全访问。同步锁
synchronized void synchronizedMethod() { // 线程安全的代码块 }
-
Synchronized【对象锁】采用互斥的方式让同一时刻至多只有一个线程能持有【对象锁】,其它线程再想获取这个【对象锁】时就会阻塞住。
-
wait()、notify()、notifyAll()方法:与synchronized关键字一起使用,实现线程的等待和唤醒机制,用于线程间的协作。
-
notify():随机唤醒一个在wait()的线程
notifyAll():唤醒所有在wait的线程synchronized (sharedObject) { sharedObject.wait(); // 线程等待 sharedObject.notify(); // 唤醒一个等待的线程 sharedObject.notifyAll(); // 唤醒所有等待的线程 }
5. 设置线程名称:
可以通过setName()
方法设置线程的名称,使得线程在日志和调试中更易于识别
Thread thread = new Thread(new Runnable() {
public void run() {
// 线程执行的任务逻辑
}
});
thread.setName("BackupThread"); // 设置线程名称
6. 设置线程优先级:
- Java中线程优先级范围是1到10,其中1为最低优先级,10为最高优先级。可以使用
setPriority()
方法设置线程的优先级。main线程和默认线程优先级是5
thread.setPriority(Thread.MIN_PRIORITY); // 设置线程优先级为最低优先级
// 或者
thread.setPriority(Thread.MAX_PRIORITY); // 设置线程优先级为最高优先级
7.守护线程(Daemon Thread)
在Java中,线程可以分为两种类型:用户线程和守护线程。
- 用户线程(Non-Daemon Thread)是程序的主要执行线程,当所有的用户线程结束时,程序会退出。
- 守护线程(Daemon Thread)则是为其他线程提供服务的线程,当所有的用户线程结束时,守护线程会自动被终止。
创建守护线程的方法
在Java中,可以通过设置线程对象的setDaemon(true)
方法将其设置为守护线程。
Thread daemonThread = new Thread(new Runnable() {
public void run() {
// 守护线程的任务逻辑
}
});
daemonThread.setDaemon(true); // 将线程设置为守护线程
daemonThread.start(); // 启动守护线程
特点和注意事项
- 守护线程通常用于在后台提供一些服务或监视其他线程的运行状态。
- 当所有的非守护线程结束时,守护线程会随之自动结束,即使守护线程尚未执行完其任务。
- 守护线程不能持有任何会影响JVM退出的资源,例如文件句柄或数据库连接,因为它们可能会在任何时候被强制终止。
8. lock锁
1. 创建Lock对象
2. lock.lock()开启锁
3. lock.unlock()关闭锁,一般放在finally语句块,防止锁无法被释放
public class LockExample {
private final Lock lock = new ReentrantLock();
public void performTask() {
lock.lock(); // 开启锁
try {
// 在这里执行需要保护的代码块
System.out.println("Lock acquired, performing task...");
Thread.sleep(2000); // 模拟执行任务的耗时
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock.unlock(); // 确保在任何情况下都释放锁
System.out.println("Lock released.");
}
}
wait和sleep方法的不同?
- 共同点
- wait(),wait(long)和 sleep(long)的效果都是让当前线程暂时放弃 CPU 的使用权,进入阻塞状态
- 不同点
1.方法归属不同
sleep(lonq)是 Thread 的静态方法
而 wait0),wait(long)都是 Object 的成员方法,每个对象都有
2.醒来时机不同
执行 sleep(long) 和 wait(long) 的线程都会在等待相应毫秒后醒来
wait(long)和 wait() 还可以被 notify 唤醒,wait0) 如果不唤醒就一直等下去
它们都可以被打断唤醒
3.锁特性不同(重点)
wait 方法的调用必须先获取 wait 对象的锁,而 sleep 则无此限制
wait 方法执行后会释放对象锁,允许其它线程获得该对象锁(我放弃 cpu,但你们还可以用)而 sleep 如果在 synchronized 代码块中执行,并不会释放对象锁(我放弃 cpu,你们也用不了)
3.自己对多线程理解
线程在执行start的时候会在,栈内存中创建一个栈,与main线程抢夺线程, main线程执行执行到下个start()才会三个线程一起抢夺 当一个线程对象调用 start() 方法时,系统会为该线程分配必要的资源,并将其状态设置为可运行(Runnable)状态。 每个线程都有自己的执行栈(Execution Stack),用于存储方法调用、局部变量和部分运行状态。
1. 线程并不是同时创建的
线程只有执行到start()方法才会创建,并且创建也需要时间,时间非常快,差不多0.07ms,创建完毕后开始和main线程抢夺cpu资源,直到main线程抢夺资源后执行到第二个start()才会三个一起抢夺cpu资源。
2. 并不是main线程在等其他线程执行完毕后,程序才结束
main线程执行完毕后,main线程的栈内存会被释放,然后虚拟机并不会结束的原因是因为还有其他栈内存并没有执行完成。当其他线程执行完毕才会结束程序。
三、线程的六大状态
1. 概念
Thread有一个枚举内部类,表示着他们的状态
-
新建(New)状态:
- 当线程对象被创建但还未启动时,线程处于新建状态。
- 可以通过创建一个 Thread 对象来实现新建状态,但调用
start()
方法之前,线程不会进入可运行状态。
-
可运行(Runnable)状态:
- 当线程已经在 JVM 中创建,但尚未开始执行时,或者线程正在运行中,都属于可运行状态。
- 在可运行状态下,线程可以开始执行,也可能因为 CPU 时间片用尽而暂时停止执行。
-
阻塞(Blocked)状态:
- 线程处于阻塞状态时,它暂时放弃 CPU 并且不会参与调度。
- 典型的阻塞原因包括等待一个监视器锁、等待输入/输出完成或等待调用某个方法的其他线程执行完毕。
- 一旦等待的条件满足,线程将会重新进入可运行状态。
-
等待(Waiting)状态:
- 线程进入等待状态表示它无限期地等待另一个线程采取某些操作。
- 线程可以通过调用
Object.wait()
、Thread.join()
或LockSupport.park()
方法进入等待状态。 - 等待状态的线程需要其他线程显式地唤醒,以使其重新进入可运行状态或阻塞状态。
-
超时等待(Timed Waiting)状态:
- 当线程在指定时间内等待某一操作完成时,线程进入超时等待状态。
- 典型的超时等待包括调用
Thread.sleep(long millis)
、Object.wait(long timeout)
或Thread.join(long millis)
方法。 - 当指定的时间到达或等待条件满足时,线程会返回到可运行状态或阻塞状态。
-
终止(Terminated)状态:
- 线程已经完成了执行或者因为异常退出了
run()
方法而结束,进入终止状态。 - 一旦线程进入终止状态,它就不能再次进入可运行状态。
- 线程已经完成了执行或者因为异常退出了
2. 线程状态之间是如何变化的
- 新建状态 -> 可运行状态:调用
start()
方法启动线程,线程从新建状态转换到可运行状态。 - 可运行状态 -> 阻塞状态:例如等待某个锁,线程从可运行状态转换到阻塞状态。
- 阻塞状态 -> 可运行状态:条件满足时,线程从阻塞状态转换回可运行状态。
- 可运行状态 -> 等待状态或超时等待状态:线程调用
wait()
、join()
或sleep()
方法,进入等待或超时等待状态。 - 等待状态/超时等待状态 -> 可运行状态:其他线程唤醒等待的线程,使其重新进入可运行状态。
- 可运行状态 -> 终止状态:线程的
run()
方法执行完毕,线程进入终止状态。
3. 为什么运行状态不属于线程的状态?
线程运行就交给操作系统管理了,属于操作系统管理的内容,线程被选中执行并且正在CPU上执行代码时,它处于操作系统的运行状态。这种运行状态对于Java的线程管理来说并不是一个独立的状态,而是RUNNABLE状态的一部分,包括正在执行和等待执行的情况。
4. 为什么要有锁?
多线程的同步锁是一种重要的机制,用于在多个线程访问共享资源时确保线程安全。
线程不安全的三大条件:1.多线程 2. 具有公共资源 3 .增删改查共享数据 ,三个条件缺一不可才能满足线程不安全。
对于同步锁就是打破第三点增删改查共享数据,来让线程安全。
就是给一个增删改查的代码进行加锁,我在执行的时候,别的代码不能进来,这就代表只能同时有一个操作完成增删改的内容,就保证了线程的安全。
- 进入 synchronized 修饰的代码块, 相当于 加锁
- 退出 synchronized 修饰的代码块, 相当于 解锁
四、线程池
1. 介绍
概念: 使用一个容器(数组|集合),存放了多个线程对象
作用: 提高线程的使用率, 避免创建过多的线程
线程池是一种管理和复用线程的机制,它能够有效地管理大量线程并控制同时执行的线程数量。通过使用线程池,可以避免重复创建和销毁线程所带来的性能开销,并且能够更好地管理系统的并发资源。
比如:线程池就相当于一个碗柜,第一次吃饭需要买碗,买完碗放往柜放,如果没有线程池就相当于,你每次吃完饭都把碗给砸了,下次吃还要继续买碗。
2. 线程池的优势:
- 减少资源消耗:重用线程可以减少线程创建和销毁的开销。
- 提高响应速度:通过减少线程创建时间,可以更快地响应任务。
- 提高线程的可管理性:可以限制并发线程的数量,防止资源耗尽。
- 提供更强的功能:线程池提供了任务调度、线程安全、线程超时等功能。
3. 自定义线程池
public class Test {
public static void main(String[] args) {
//创建线程池
ThreadPoolExecutor executor = new ThreadPoolExecutor(
4,//核心线程数量
6,//最大线程数量
10,//最大存活时间
TimeUnit.MINUTES,//时间单位
new ArrayBlockingQueue<>(3),//任务队列
Executors.defaultThreadFactory(),//线程工厂
//new ThreadPoolExecutor.AbortPolicy()//拒绝策略 抛弃任务,报错
//new ThreadPoolExecutor.DiscardPolicy()//拒绝策略 抛弃任务
new ThreadPoolExecutor.CallerRunsPolicy()//拒绝策略, 抛弃任务,那个线程提交的任务,该线程执行这个任务
);
//2.把任务交给线程池 submit(Runnable r)
for (int i = 0; i < 10; i++) {
executor.submit(()->{
System.out.println(Thread.currentThread().getName()+" 我是你大爷...");
});
}
//关闭线程池
//executor.shutdown();
}
}
-
corePoolSize:
- 核心线程池大小为 4。在没有设置
allowCoreThreadTimeOut
的情况下,即使线程处于空闲状态,也不会被回收,始终保持在这个数量。
- 核心线程池大小为 4。在没有设置
-
maximumPoolSize:
- 最大线程池大小为 6。当线程池中的线程数量超过 corePoolSize,并且任务队列已满时,线程池会创建新的线程,但不会超过这个最大值。
-
keepAliveTime:
- 线程的最大存活时间为 10 分钟。超过核心线程数的空闲线程在此时间后会被回收,以减少资源消耗。
-
unit:
- keepAliveTime 的时间单位为
TimeUnit.MINUTES
,即分钟。
- keepAliveTime 的时间单位为
-
workQueue:
- 使用
ArrayBlockingQueue
作为任务队列,容量为 3。如果线程池中的线程数量超过 corePoolSize,多余的任务会被放入这个队列中等待执行。
- 使用
-
threadFactory:
Executors.defaultThreadFactory()
是默认的线程工厂,用来创建新的线程。可以通过自定义线程工厂来设置线程的名称、优先级等。
-
handler:
ThreadPoolExecutor.CallerRunsPolicy()
是一种拒绝策略,用于处理当任务无法加入到队列中时的情况。这个策略会让调用者线程自己执行该任务,这样一来,提交任务的线程不会被阻塞,而是由调用线程直接执行任务。
添加线程次序
首先添加到核心线程中,核心线程满了,会添加到任务队列里面,任务队列满了,才会添加到临时线程里面,临时线程满了,才会触发拒绝策略。对于临时线程会keepAliveTime时间到了还没有被使用就会被摧毁。
关于拒绝策略的选择:
- AbortPolicy:默认的拒绝策略,会抛出
RejectedExecutionException
异常。 - DiscardPolicy:直接丢弃无法处理的任务,不会抛出异常。
- CallerRunsPolicy:让提交任务的线程自己执行这个任务,不会抛弃任务,也不会抛出异常。
- DiscardOldestPolicy:丢弃队列中等待时间最长的任务,然后尝试重新提交当前任务。
总结
多线程编程在Java开发中扮演着重要角色,能够提升程序的并发能力和响应速度,有效地管理和利用系统资源。通过理解线程的创建方式、生命周期、状态转换以及线程池的使用,开发者可以更好地设计和实现复杂的并发应用程序,提高系统的稳定性和性能。
综上所述,掌握Java多线程编程的基础知识,并结合实际场景应用,将极大地提升软件开发的效率和质量。