一. 认识线程(Thread)
1. 线程是什么
定义:线程是一个轻量级的执行流,它代表了程序执行的一个路径。每个线程都有自己的程序计数器、栈和局部变量,但线程之间可以共享同一个进程的全局变量和堆。
主线程:在 Java 程序中,main()
方法所运行的线程被称为主线程(Main Thread)。当你启动一个 Java 应用程序时,JVM 会创建一个主线程来执行 main()
方法。
执行流:每个线程可以看作是一条独立的执行路径,多个线程可以并行执行同一段代码,但它们的执行顺序是非确定性的,这意味着可能会发生交错执行(interleaving)。
2. 为啥要有线程
并发编程的需求:现代计算需求往往需要同时执行多个任务,尤其在多核处理器环境下。并发编程能够充分利用 CPU 的资源,提升程序性能和响应速度。
提高算力:多核 CPU 的发展使得并发编程成为必要。通过创建多个线程,可以将计算任务分配到多个 CPU 核心上,提高执行效率。
等待 I/O 的场景:许多程序在执行过程中会等待输入/输出(I/O)操作,如文件读取、网络请求等。使用多线程可以在等待 I/O 时,让其他线程继续执行,从而提高资源的利用率。
线程相对进程的优势:
- 创建速度:线程的创建和销毁比进程更快,因为线程的上下文切换比进程的上下文切换开销小。
- 调度效率:操作系统调度线程的效率高于进程,线程在同一进程内的切换非常迅速。
- 资源共享:同一进程内的线程共享内存空间,这使得数据传递更加高效。
线程池与协程:
- 线程池:为了进一步提高性能,Java 提供了线程池机制,允许预先创建一组线程,重复使用,减少频繁创建和销毁线程的开销。
- 协程:协程是一种更轻量级的线程实现,它允许在单线程内实现并发。协程通过协作式调度管理执行状态,使得资源利用更加高效。
3. 进程和线程的区别
3.1. 基本概念
- 进程(Process):
- 进程是操作系统分配资源的基本单位,它是一个执行中的程序。每个进程拥有自己的地址空间、数据段、堆栈和其他属性。
- 线程(Thread):
- 线程是进程内部的一个执行单元,它是操作系统能够独立调度的最小单位。每个进程至少有一个主线程,多个线程可以并行执行。
3.2. 资源分配
- 进程:
- 每个进程都有独立的内存空间,包括代码段、数据段和堆栈。进程间的内存是相互隔离的。
- 线程:
- 同一进程中的所有线程共享进程的内存空间,包括全局变量和堆。这使得线程之间可以快速通信,但也带来了线程安全问题。
3.3. 数据共享与通信
- 进程:
- 进程间不能直接共享数据,数据共享需要通过进程间通信(IPC)机制,如管道、消息队列、共享内存、信号量等。这使得进程间的数据交换相对复杂。
- 线程:
- 线程可以直接访问同一进程的内存,数据共享非常简单,可以通过直接读写共享变量实现。这种共享能力虽然方便,但也可能导致数据竞争和一致性问题。
3.4. 创建与销毁
- 进程:
- 创建和销毁进程的开销较大。创建进程时,操作系统需要分配新的内存空间和系统资源,这会消耗更多的时间和资源。
- 线程:
- 线程的创建和销毁相对更快,因为线程共享进程的资源和内存。创建新线程只需分配少量的系统资源(如线程栈和寄存器)。
3.5. 调度与执行
- 进程:
- 进程是操作系统调度的基本单位。进程的上下文切换涉及到更复杂的操作,如保存和恢复进程的完整状态,涉及大量的资源管理。
- 线程:
- 线程是最小的执行单位,操作系统调度线程的开销较小。线程的上下文切换速度比进程快,因为线程共享相同的地址空间,保存和恢复的状态较少。
3.6. 崩溃影响
- 进程:
- 如果一个进程崩溃,通常不会影响到其他进程的运行。操作系统能够保证进程之间的隔离性。
- 线程:
- 如果一个线程崩溃,可能会导致整个进程崩溃。这是因为线程之间共享内存,崩溃的线程可能会破坏共享数据,从而影响其他线程的执行。
3.7. 应用场景
- 进程:
- 适用于需要高隔离性的应用,如不同的服务或应用程序之间的运行(如浏览器、文本编辑器、播放器等),各个进程独立且不干扰。
- 线程:
- 适用于需要高并发的场景,如服务器处理多个客户端请求时,使用线程可以提高响应速度和资源利用率。
3.8. 小结
特征 | 进程 | 线程 |
---|---|---|
定义 | 资源分配的基本单位 | 最小的执行单元 |
内存空间 | 独立的地址空间 | 共享同一进程的内存空间 |
数据共享 | 通过 IPC 机制 | 直接访问共享内存 |
创建与销毁 | 开销较大 | 开销较小 |
调度 | 系统调度的基本单位 | 轻量级的调度单位 |
崩溃影响 | 不影响其他进程 | 可能导致整个进程崩溃 |
应用场景 | 高隔离性需求(如独立服务) | 高并发性需求(如 web 服务器处理请求) |
这种区别能帮助你全面理解进程和线程之间的不同。如果还有其他方面需要进一步探讨或具体的例子,请告诉我!
4. Java的线程和操作系统线程的关系
操作系统的支持:
- 线程是操作系统的基本概念,操作系统内核通过提供线程的创建、调度、同步等功能来支持多线程编程。
- 不同的操作系统使用不同的机制来管理线程,例如,Linux 使用 pthread 库来提供线程功能。
Java 的线程模型:
- Java 的
Thread
类是对操作系统线程 API 的抽象,JVM 在底层将 Java 线程映射为操作系统线程。 - Java 线程通常是用户级线程,JVM 负责调度这些线程,并将它们映射到操作系统的线程上。
线程调度:
- Java 线程的调度依赖于操作系统的调度策略,通常是时间片轮转(Round Robin)或优先级调度(Priority Scheduling)。
- 在 Java 中,开发者可以通过
Thread
类设置线程的优先级,但实际效果还依赖于操作系统的实现。
二 Java 中创建线程的方式总结
1.继承 Thread 类
-
描述:创建一个类继承
Thread
,重写run()
方法,使用start()
方法启动线程。 -
优点
- 代码简单,易于理解。
- 可以直接调用
Thread
类中的方法。
-
缺点
- Java 是单继承的,不能继承其他类。
-
示例
class MyThread extends Thread { public void run() { // 线程执行的代码 } }
2.实现 Runnable 接口
-
描述:创建一个类实现
Runnable
接口,重写run()
方法,并将Runnable
实例传递给Thread
对象。 -
优点
- 支持多重继承,可以同时继承其他类。
- 适合多个线程共享同一任务。
-
缺点
- 代码相对复杂。
-
示例
class MyRunnable implements Runnable { public void run() { // 线程执行的代码 } }
3.使用 Callable 接口
-
描述:创建一个类实现
Callable
接口,重写call()
方法,使用FutureTask
或ExecutorService
执行任务。 -
优点
- 可以返回结果并处理异常。
- 更适合需要计算结果的场景。
-
缺点
- 需要使用
FutureTask
,相对较复杂。
- 需要使用
-
示例
class MyCallable implements Callable<String> { public String call() throws Exception { // 线程执行的代码 return "结果"; } }
4.使用 Lambda 表达式(Java 8 及以上)
-
描述:使用 Lambda 表达式简化
Runnable
和Callable
的实现。 -
优点
- 代码简洁,减少了样板代码。
- 更容易阅读和维护。
-
示例
Thread thread = new Thread(() -> { // 线程执行的代码 });
5.小结
创建方式 | 描述 | 优点 | 缺点 |
---|---|---|---|
继承 Thread 类 | 直接创建线程类并重写 run() 方法 | 简单易懂,直接调用 Thread 方法 | 单继承限制,无法继承其他类 |
实现 Runnable 接口 | 实现接口并重写 run() 方法 | 支持多重继承,适合共享任务 | 代码较复杂 |
实现 Callable 接口 | 实现接口并重写 call() 方法 | 可以返回结果,处理异常 | 相对复杂,需要使用 FutureTask |
使用 Lambda 表达式 | 使用 Lambda 简化代码 | 代码简洁,易读 | 仅适用于 Java 8 及以上 |
以上总结涵盖了 Java 中创建线程的主要方法及其优缺点,可以根据具体的应用场景选择最适合的方式。如果还有其他问题或需要进一步讨论的内容,请随时告诉我!
三. 线程的生命周期
线程在 Java 中的生命周期主要包括以下七种状态:
1.新建(New):
-
描述:当线程对象被创建时,它处于新建状态。此时,线程尚未开始执行,且没有占用系统资源。
-
示例:
Thread thread = new Thread(() -> { // 线程执行的代码 }); // 此时线程处于新建状态
2.可运行(Runnable):
-
描述:当调用
start()
方法后,线程进入可运行状态。可运行状态的线程可能是就绪(READY)或正在运行(RUNNING)。可运行状态的线程处于系统的可运行线程池中,等待 CPU 的调度。 -
- 就绪(READY):线程已准备好,等待 CPU 时间片。
-
- 运行中(RUNNING):线程获得 CPU 时间片,正在执行代码。
-
示例:
thread.start(); // 调用 start() 方法,线程进入可运行状态
3.运行(Running):
-
描述:当线程获得 CPU 资源后,进入运行状态,开始执行
run()
方法中的代码。只有一个线程可以在任意时刻处于运行状态。 -
示例:
// 在这里,线程正在执行具体的任务
4.阻塞(Blocked):
-
描述:线程在等待某个资源(如获取锁)而被挂起时进入阻塞状态。阻塞通常发生在多线程环境中,当一个线程试图访问一个已经被其他线程占用的资源时,它会被阻塞,无法继续执行。
-
示例:
synchronized (someObject) { // 这里是访问被锁定的资源 } // 如果另一个线程已经获取了 someObject 的锁,当前线程将被阻塞
5.等待(Waiting):
-
描述:线程进入等待状态时,它需要等待其他线程的特定动作(如通知或中断)。在等待状态下,线程不会占用 CPU 资源。
-
示例:
synchronized (someObject) { someObject.wait(); // 线程在这里等待 }
6.超时等待(Timed Waiting):
-
描述:线程在此状态时,会在指定的时间后自动返回。这种状态通常用于等待一定时间。
-
示例:
synchronized (someObject) { someObject.wait(1000); // 等待最多 1000 毫秒 }
7.终止(Terminated):
-
描述:线程的执行完成或因异常中断后,进入终止状态。此时,线程生命周期结束,无法再被启动。
-
示例:
// run() 方法执行完毕,线程进入终止状态
8.线程状态的转换
- 新建到可运行:当调用
start()
方法时,线程从新建状态转变为可运行状态。 - 可运行到运行:当线程调度器分配 CPU 资源时,线程从可运行状态转变为运行状态。
- 运行到阻塞:当线程尝试获取锁而未成功时,或者执行了
sleep()
、wait()
等方法时,线程将被阻塞。 - 阻塞到可运行:当所等待的资源可用时,阻塞的线程会返回到可运行状态,等待再次获得 CPU 资源。
- 运行到等待:线程在调用
wait()
、join()
等方法时,进入等待状态。 - 等待到可运行:当其他线程调用了相应的通知方法(如
notify()
或notifyAll()
)时,等待的线程会返回到可运行状态。 - 超时等待到可运行:当指定的等待时间到达时,线程会自动返回到可运行状态。
- 运行到终止:线程的
run()
方法执行完毕或抛出未捕获异常时,线程进入终止状态。
流转图如下:
9.线程状态的查询
可以使用 Thread.getState()
方法来查看线程的当前状态。
四 多线程带来的风险 - 线程安全
1. 线程安全的概念
- 定义:在多线程环境中,确保多个线程对共享资源的访问是安全的,使得程序的行为是可预测的,且数据始终保持一致。
- 重要性:
- 防止数据不一致。
- 保护共享资源,确保程序逻辑的正确执行。
2. 线程安全问题的来源
- 数据竞争(Race Condition):
- 多个线程同时访问和修改共享变量。
- 示例:两个线程同时递增同一个计数器,导致最终结果不正确。
- 脏读(Dirty Read):
- 一个线程读取到尚未提交的值。
- 结果:可能基于错误的数据进行操作,导致程序状态异常。
- 死锁(Deadlock):
- 两个或多个线程互相等待对方释放锁,导致程序无法继续执行。
- 示例:线程A持有资源1的锁并等待资源2的锁,而线程B则相反,形成循环等待。
- 活锁(Livelock):
- 线程不断变更状态以避免死锁,但由于状态调整没有实际工作,导致系统无法进展。
3. 线程同步的必要性
为了避免上述问题,线程间必须进行有效的同步,确保同一时刻只有一个线程可以操作共享资源。
a. 使用 synchronized
-
同步方法:
- 在方法声明中使用
synchronized
关键字,确保同一时间只有一个线程能执行该方法。 - 适合简单场景,易于使用。
- 在方法声明中使用
-
同步代码块:
-
只锁住特定的代码段,适用于复杂的逻辑或对性能有较高要求的场景。
-
语法示例:
public void increment() { synchronized(this) { count++; } }
-
b. 使用 Lock
接口
- 特点:
- 提供更灵活的锁机制,如可重入锁、尝试锁等。
- 适合高并发场景,能够减少锁竞争。
- 常用实现:
ReentrantLock
- 可重入性:同一线程可以多次获得同一个锁。
- 尝试获取锁:
tryLock()
方法允许线程在获取锁时不被阻塞。
c. 使用 ReadWriteLock
- 定义:提供读锁和写锁,读锁允许多个线程同时读取,写锁独占。
- 使用场景:适合读操作占多数的情况,例如缓存读取,能有效提高并发性能。
d. 使用 Semaphore
- 定义:信号量用于控制同时访问特定资源的线程数量。
- 应用:适合限流场景,比如限制数据库连接的最大数量。
e. 使用 CountDownLatch
- 定义:
CountDownLatch
是一个同步辅助类,用于让一个或多个线程等待直到一组操作完成。 - 基本原理:
CountDownLatch
维护一个计数器,线程可以通过调用countDown()
方法来减少计数,其他线程可以通过await()
方法等待计数器归零。
- 使用场景:
- 适合于等待多个线程完成初始化或其他操作的场景。例如,在进行多线程并发处理时,主线程可以等待所有工作线程完成后再继续执行。
f. 使用 join
方法
- 定义:
join
方法用于让调用该方法的线程等待其他线程完成执行。 - 基本用法:
- 当一个线程调用另一个线程的
join()
方法时,调用线程会阻塞,直到被调用线程执行完成。
- 当一个线程调用另一个线程的
- 使用场景:
- 适合在需要确保某个线程完成任务后再执行后续逻辑的场合,例如在多个线程并行处理数据时,主线程需要等待所有子线程完成后再进行汇总或输出结果。
4. 线程通信的必要性
线程间有时需要交换信息或协调工作,这就是线程通信的必要性。
a. 使用 wait()
, notify()
, notifyAll()
- wait():
- 使当前线程进入等待状态,直到被其他线程通知。
- 示例:消费者在没有可用数据时进入等待。
- notify():
- 唤醒一个正在等待的线程。
- notifyAll():
- 唤醒所有等待的线程,适用于需要通知多个线程的场景。
b. 使用 Condition
- 定义:提供更灵活的等待/通知机制,通常与
Lock
接口结合使用。 - 优势:支持多个等待队列,增强了线程协调能力。
5. 线程安全的设计模式
a. 生产者-消费者模式
- 原理:生产者生成数据,消费者消费数据,通过阻塞队列实现安全通信。
- 实现:使用
BlockingQueue
,生产者在队列满时等待,消费者在队列空时等待。
b. 单例模式(线程安全实现)
-
实现方法:双重检查锁定(Double-Checked Locking),确保单例实例在多线程环境下只被创建一次。
-
代码示例:
public class Singleton { private static volatile Singleton instance; private Singleton() {} public static Singleton getInstance() { if (instance == null) { synchronized (Singleton.class) { if (instance == null) { instance = new Singleton(); } } } return instance; } }
五. 线程池
1. 什么是线程池?
线程池是一种并发编程的设计模式,用于管理和复用多个线程。线程池会预先创建一定数量的线程,避免在高并发环境下频繁创建和销毁线程,从而减少资源消耗和提升性能。
2. 线程池的优势
- 性能提升:线程的创建和销毁是昂贵的操作,线程池通过复用线程显著提高系统性能。
- 资源控制:通过限制线程的数量,线程池可以有效防止资源耗尽。
- 响应速度:线程池可以迅速响应新的任务请求,降低了任务的等待时间。
- 灵活管理:线程池通过任务队列和多种任务处理策略,增强了任务的调度能力。
3. 线程池的核心组件
1. 核心线程数(corePoolSize)
- 定义:线程池中始终保持的线程数量。
- 作用:在任务提交时,如果当前线程数少于核心线程数,则会创建新线程执行任务。
2. 最大线程数(maximumPoolSize)
- 定义:线程池中允许的最大线程数量。
- 作用:限制线程池能创建的最大线程数量,防止系统资源过度消耗。
3. 空闲线程存活时间(keepAliveTime)
- 定义:当线程数超过核心线程数时,多余的空闲线程在此时间内会被终止。
- 作用:有助于释放资源,避免过多的空闲线程占用系统内存。
4. 时间单位(unit)
- 定义:用于指定
keepAliveTime
的时间单位。 - 常用单位:
SECONDS
,MILLISECONDS
,MINUTES
,HOURS
,DAYS
。
5. 任务队列(workQueue)
- 定义:用于存放等待执行的任务的队列。
- 常见队列类型:
- ArrayBlockingQueue:有界队列,限制最大任务数,适合任务数量可预测的场景。
- LinkedBlockingQueue:无界队列,适合任务数量不确定的情况,线程安全。
- SynchronousQueue:不存储元素,每个插入操作都必须等待对应的删除操作,适合瞬时任务。
- PriorityBlockingQueue:按照优先级处理任务,适合任务优先级差异较大的场景。
6. 拒绝策略(handler)
- 定义:处理无法执行的任务的策略。
- 常用策略:
- AbortPolicy:抛出异常,任务无法执行。
- CallerRunsPolicy:由调用线程处理任务,适合简单的任务重试。
- DiscardPolicy:直接丢弃任务,不抛出异常。
- DiscardOldestPolicy:丢弃队列中最旧的任务,优先处理新任务。
* 运行流程
- 首先按核心线程数创建线程任务
- 核心线程数满了。任务进入工作队列
- 队列也满了 扩大核心线程数
- 当核心线程数达到最大线程数
- 并且工作队列也满了 触发拒绝策略
- 工作队列分有界和无界的
4. 创建线程池的方式
在Java中,java.util.concurrent
包提供了多种方式来创建线程池,其中使用Executors
工厂类是最常见的方法。
1. 使用 Executors
创建线程池
-
固定线程池:适用于处理固定数量的并发任务。
ExecutorService fixedThreadPool = Executors.newFixedThreadPool(5); // 固定5个线程
-
可缓存线程池:适合处理大量短期任务,线程会根据需要动态创建和回收。
ExecutorService cachedThreadPool = Executors.newCachedThreadPool(); // 根据需求动态创建线程
-
单线程池:适用于需要保证任务按顺序执行的场景。
ExecutorService singleThreadPool = Executors.newSingleThreadExecutor(); // 仅有一个线程
-
定时任务线程池:用于周期性或延迟执行的任务。
ScheduledExecutorService scheduledThreadPool = Executors.newScheduledThreadPool(5); // 固定线程数的定时任务池
2. 使用 ThreadPoolExecutor
自定义线程池
除了使用Executors
,我们还可以通过ThreadPoolExecutor
类来创建更灵活的线程池:
ThreadPoolExecutor executor = new ThreadPoolExecutor(
2, // 核心线程数
5, // 最大线程数
60, // 空闲线程存活时间(秒)
TimeUnit.SECONDS, // 时间单位
new ArrayBlockingQueue<>(10), // 任务队列
new ThreadPoolExecutor.AbortPolicy() // 拒绝策略
);
5. 示例代码
以下是使用 ThreadPoolExecutor
创建一个线程池的示例代码:
import java.util.concurrent.*;
public class ThreadPoolExample {
public static void main(String[] args) {
ExecutorService executor = Executors.newFixedThreadPool(3); // 创建固定大小的线程池
for (int i = 0; i < 10; i++) {
final int taskId = i;
executor.submit(() -> {
System.out.println("正在执行任务:" + taskId + ",线程:"+Thread.currentThread().getName());
try {
Thread.sleep(200); // 模拟任务执行时间
} catch (InterruptedException e) {
e.printStackTrace();
}
});
}
executor.shutdown(); // 关闭线程池
}
}
6. 适用场景
- 固定大小线程池:适用于任务量稳定且可预测的场景,例如文件处理、图片上传等。
- 可缓存线程池:适合处理大量短期任务,可以动态创建和回收线程,适合高并发场景。
- 单线程池:适用于需要保证任务按顺序执行的场景,例如日志处理、任务调度等。
- 定时任务线程池:适合定期执行的任务调度,例如定时备份、定时清理等。
7. 常见问题与注意事项
- 合理配置参数:核心线程数、最大线程数和任务队列的选择应根据应用的实际需求进行合理配置,以避免资源耗尽或过度消耗。
- 线程池的关闭:在使用完线程池后,确保调用
shutdown()
或shutdownNow()
以释放资源,防止内存泄漏。 - 监控线程池状态:定期检查线程池的状态(如活跃线程数、已完成任务数等)以确保系统运行正常,避免任务堆积。