线程的定义和概念
线程是进程中的一个执行单元,每个线程都有独立的执行路径。多个线程可以在同一进程中并发执行,共享进程的资源。线程可以同时执行不同的任务,提高程序的效率。
区别于进程,进程是操作系统中资源分配和调度的基本单位,是程序执行时的实例。每个进程都有独立的内存空间和系统资源。
每个进程都拥有独立的内存空间、文件描述符、打开的文件等系统资源。(线程共享所属进程的内存空间和系统资源)
进程切换时,需要保存和恢复整个进程的上下文信息,包括寄存器、内存映射、打开的文件等,切换开销较大。进程间通信需要使用操作系统提供的机制,如管道、消息队列、共享内存、套接字等。不同进程之间的执行是并发进行的,每个进程有独立的执行序列。(而线程在同一进程中执行,多个线程之间可以并发执行,共享进程的资源,实现多任务的并发性。)
线程的创建方式
通常情况下,创建线程的方式有以下四种:
-
继承Thread类:
class MyThread extends Thread { public void run() { // 线程的执行逻辑 } } // 创建线程并启动 MyThread thread = new MyThread(); thread.start();
-
实现Runnable接口:
class MyRunnable implements Runnable { public void run() { // 线程的执行逻辑 } } // 创建线程并启动 MyRunnable runnable = new MyRunnable(); Thread thread = new Thread(runnable); thread.start();
-
实现Callable接口:
class MyCallable implements Callable<Integer> { public Integer call() throws Exception { // 线程的执行逻辑 return 42; } } // 创建线程并启动 Callable<Integer> callable = new MyCallable(); FutureTask<Integer> futureTask = new FutureTask<>(callable); // 创建线程并启动 Thread thread = new Thread(futureTask); thread.start(); try { Integer result = futureTask.get(); System.out.println("任务执行结果:" + result); } catch (InterruptedException | ExecutionException e) { // 处理异常 }
-
线程池创建:
ThreadPoolExecutor executor = new ThreadPoolExecutor( 5, // 核心线程数 10, // 最大线程数 60, // 线程空闲时间 TimeUnit.SECONDS, // 空闲时间单位 new ArrayBlockingQueue<>(100) // 任务队列 ); // 提交任务给线程池 executor.execute(() -> { // 线程的执行逻辑 }); // 关闭线程池 executor.shutdown();
线程池参数配置
-
核心线程数(corePoolSize):
- 线程池中
保持活动状态
的线程数量,即使线程处于空闲状态也不会被回收。 - 当有新任务提交时,核心线程会被优先创建和启动。
- 线程池中
-
最大线程数(maximumPoolSize):
- 最大线程数是线程池中允许的最大线程数量。
- 当有新任务提交时,如果
核心线程数已满且任务队列已满
,会创建新的线程,但不超过最大线程数。 - 如果线程池中的线程数达到最大线程数,后续的任务会根据拒绝策略进行处理。
-
线程空闲时间(keepAliveTime):
- 线程空闲时间是
非核心的空闲线程
等待新任务到达的时间。 - 如果线程在空闲时间内没有接收到新任务,超过空闲时间后会被终止,以减少资源消耗。
- 可以通过设置合适的空闲时间来控制线程池中线程的数量。
- 线程空闲时间是
-
时间单位(unit):
- 时间单位是用于表示线程空闲时间和超时时间的单位,例如秒、毫秒等。
-
任务队列(workQueue):
- 任务队列用于存储提交的任务,在线程池中等待执行。
- 常见的任务队列有:ArrayBlockingQueue、LinkedBlockingQueue、SynchronousQueue等。
- 不同的任务队列实现对任务的排队和调度方式有所不同,可以根据具体需求选择合适的任务队列。
-
线程工厂(threadFactory):
- 线程工厂用于创建新的线程。
- 默认情况下,线程池使用默认的线程工厂创建线程。
- 可以自定义线程工厂来创建自定义的线程,并指定线程的名称、优先级等属性。
-
拒绝策略(rejectedExecutionHandler):
-
当
线程池和任务队列都已满
,无法继续接收新任务时,拒绝策略定义了如何处理被拒绝的任务。
- AbortPolicy(默认):当线程池和任务队列都已满,无法继续接收新任务时,抛出
RejectedExecutionException
异常,表示拒绝执行新任务。 - CallerRunsPolicy:当线程池和任务队列都已满,无法继续接收新任务时,将任务交给提交该任务的线程来执行。这样做的效果是任务提交的线程自己执行任务,而不会抛弃任务。
- DiscardPolicy:当线程池和任务队列都已满,无法继续接收新任务时,直接丢弃新任务,不做任何处理。
- DiscardOldestPolicy:当线程池和任务队列都已满,无法继续接收新任务时,丢弃任务队列中最旧的任务,然后尝试将新任务加入队列。
-
线程池配置案例
OkHttp中Dispatcher的线程池配置:
- 核心线程数设置为0:如果您的应用场景中有
大量的临时性任务
,这些任务在某个时间段内可能会有突发的到达,并且在完成后不会再有新的任务到达
,那么将核心线程数设置为0可以避免空闲线程的资源浪费
。当然因为每次任务到达时都需要创建新的线程,也是有相应的损耗代价的 - 最大线程数设置为MAX_VALUE:当然正常不会这么设置,它这里是通过自己有最大请求数去动态控制了
- SynchronousQueue作为任务队列:
无界的阻塞队列
,没有容量使得所有任务都将直接创建新线程(和最大线程数的设置也有关联)。对于插入和移除操作是同步的。当一个线程试图将元素放入队列时,它会被阻塞,直到另一个线程从队列中获取这个元素;反之亦然。这使得任务的提交和执行成为一种同步操作。因为其阻塞特性,当有新的任务提交到线程池时,它会立即寻找一个可用的线程
来执行,而不需要将任务先放入队列中等待。这样可以减少任务的等待时间和延迟。
executorServiceOrNull = ThreadPoolExecutor(0, Int.MAX_VALUE, 60, TimeUnit.SECONDS,
SynchronousQueue(), threadFactory("$okHttpName Dispatcher", false))
死锁
死锁是指在并发系统中,两个或多个线程(或进程)因为互相等待对方释放资源而无法继续执行的状态。在死锁状态下,每个线程都在等待其他线程释放资源,从而导致所有线程都无法继续执行下去。
简单来说:>=2线程,吃着自己碗里的,想着别人碗里的
由此死锁有四个必要条件:
- 互斥条件(Mutual Exclusion):一个资源一次只能被一个线程持有。
- 不可剥夺条件(No Preemption):已经分配的资源不能被强制性地从线程中抢占。
因为可以抢的话,或者一起用的话,那就不用光想了
所以,可以使用
共享资源代替独占资源
,或者允许抢占已分配的资源,强制其他线程释放资源(这个就有点流氓了)
- 请求与保持条件(Hold and Wait):线程持有至少一个资源,并且在等待其他线程释放资源的同时继续请求新的资源。
- 循环等待条件(Circular Wait):存在一个线程资源的循环链,每个线程都在等待下一个线程所持有的资源。
这就是吃着自己碗里的,想着别人碗里的表现
那怎么办?两个思路:
1.排队:一个个按序吃,都必须从1~10,吃了1,才能吃2。即通过对资源进行排序,线程按照相同的顺序请求资源,从而避免循环等待。
2.不贪或者贪死:要么一口气吃完;要么想吃别人的,就把自己的放下。即要么一次性获取所有需要的资源,要么先释放已经持有的资源再重新请求。
上面根据四个条件,给出了预防的策略,那么现实生产过程中,死锁就像ANR,总是可能会发生的。那还有两个方案:
- 使用资源分配图算法(Resource Allocation Graph Algorithm)来检测是否存在死锁的可能性。如果检测到可能发生死锁,就不进行资源分配,从而避免死锁的发生。
- 使用死锁检测算法(Deadlock Detection Algorithm)来检测死锁的发生。一旦检测到死锁,可以采取一些措施来解除死锁,例如终止某些线程或回滚操作。
对于这两种策略其实都是基于有向图的思路,判断有无环路来进行检测,通常有个算法叫银行家算法
,关于死锁的更多这里就不多讲了,水平有限Hhh
线程同步
互斥锁
synchronized
和Lock
是Java中用于实现线程同步的机制。它们都可以用于保证多个线程对共享资源的安全访问,但在实现和使用上有一些不同之处。
-
synchronized
:- 是Java语言内置的关键字,可以用于代码块、方法、类级别的同步。不需要显式地创建锁对象,会自动释放锁,当持有锁的线程执行完毕或抛出异常时,锁会被释放,其他等待锁的线程可以获取锁并执行。
- 是非公平的,可重入的
锁升级机制
,无锁状态
(没有线程占有锁)-偏向锁
(有线程占有锁)-轻量级锁
(发生锁竞争,首先尝试自旋CAS)-重量级锁
(竞争失败,开始摆烂),是一种性能上的优化。
-
Lock
:- 是Java.util.concurrent包下的接口,提供了更加灵活和可扩展的锁机制。
- 提供了更多的功能,例如可重入性、条件变量、公平性等。
- 需要显式地进行锁的获取和释放操作,需要在正确的位置手动获取锁,并在合适的时机手动释放锁。通常需要配合
try-finally
语句块使用,确保锁的释放操作一定会执行,即使在获取锁的过程中发生异常。
AQS
AbstractQueuedSynchronizer
AQS 是一个用于实现同步器的抽象基类,提供了底层的同步机制。
public class ReentrantLock implements Lock, Serializable {
private final Sync sync;
abstract static class Sync extends AbstractQueuedSynchronizer {
// 省略其他代码...
// 尝试获取锁
abstract void lock();
// 省略其他代码...
}
static final class NonfairSync extends Sync {
// 省略其他代码...
// 尝试获取锁
final void lock() {
// 调用 AQS 的 acquire 方法尝试获取锁
if (compareAndSetState(0, 1))
setExclusiveOwnerThread(Thread.currentThread());
else
acquire(1);
}
// 省略其他代码...
}
static final class FairSync extends Sync {
// 省略其他代码...
// 尝试获取锁
final void lock() {
acquire(1);
}
protected final boolean tryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
if (c == 0) {
if (!hasQueuedPredecessors() &&
compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
}
else if (current == getExclusiveOwnerThread()) {
int nextc = c + acquires;
if (nextc < 0)
throw new Error("Maximum lock count exceeded");
setState(nextc);
return true;
}
return false;
}
// 省略其他代码...
}
// 省略其他代码...
// 获取锁
public void lock() {
sync.lock();
}
// 省略其他代码...
}
Sync
类中定义了 lock()
方法,用于尝试获取锁。在 NonfairSync
和 FairSync
两个具体子类中,分别实现了不公平锁和公平锁的获取逻辑。这些子类继承了 Sync
并重写了 lock()
方法。
在 NonfairSync
中,lock()
方法首先尝试使用 compareAndSetState(0, 1)
方法来将锁的状态从 0 设置为 1,如果成功则将当前线程设置为独占锁的持有者;否则,调用 acquire(1)
方法来进入等待状态,直到获取到锁。
其中state是一个原子变量,用于记录锁的重入性
,>0则表示占有,在FairSync
可以看到,会判断current与当前锁的持有者。而公平性
则取决于新的竞争者,是否有机会直接获取锁,还是说会进入等待队列进行按序获取
线程通信
在线程之间实现协调和通信,以便正确地执行任务和共享数据。
-
wait()
、notify()
和notifyAll()
:这些方法是Object
类的一部分,用于在线程之间进行等待和唤醒操作。wait()
方法使当前线程等待,释放对象的锁,notify()
方法唤醒一个等待的线程,notifyAll()
方法唤醒所有等待的线程。这些方法应该在synchronized
代码块内使用,并且对共享对象调用。 -
使用
Condition
接口:Condition
接口提供了更高级别的线程通信机制。它与Lock
接口一起使用,并提供了await()
、signal()
和signalAll()
方法来实现等待和唤醒操作。
Lock lock = new ReentrantLock();
Condition condition = lock.newCondition();
// 等待
lock.lock();
try {
while (condition不满足条件) {
condition.await();
}
// 执行任务
} finally {
lock.unlock();
}
// 唤醒
lock.lock();
try {
condition.signalAll();
} finally {
lock.unlock();
}
通常经典的使用就是生产者-消费者模型,下面给出案例
import java.util.LinkedList;
import java.util.Queue;
class Producer implements Runnable {
private final Queue<Integer> buffer;
private final int maxSize;
private int value = 0;
public Producer(Queue<Integer> buffer, int maxSize) {
this.buffer = buffer;
this.maxSize = maxSize;
}
@Override
public void run() {
while (true) {
synchronized (buffer) {
try {
while (buffer.size() == maxSize) {
// 队列已满,生产者等待
buffer.wait();
}
// 生产一个元素并加入队列
buffer.offer(value);
System.out.println("Produced: " + value);
value++;
// 通知消费者可以消费了
buffer.notifyAll();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
}
class Consumer implements Runnable {
private final Queue<Integer> buffer;
public Consumer(Queue<Integer> buffer) {
this.buffer = buffer;
}
@Override
public void run() {
while (true) {
synchronized (buffer) {
try {
while (buffer.isEmpty()) {
// 队列为空,消费者等待
buffer.wait();
}
// 从队列中取出一个元素并消费
int value = buffer.poll();
System.out.println("Consumed: " + value);
// 通知生产者可以继续生产
buffer.notifyAll();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
}
public class ProducerConsumerExample {
public static void main(String[] args) {
Queue<Integer> buffer = new LinkedList<>();
int maxSize = 5;
Thread producerThread = new Thread(new Producer(buffer, maxSize));
Thread consumerThread = new Thread(new Consumer(buffer));
producerThread.start();
consumerThread.start();
}
}
原子性和可见性
原子性(Atomicity):原子性指的是一个操作是不可分割的,要么全部执行成功,要么全部不执行,没有中间状态。
原子性需要结合代码指令一起思考:i++
- 从内存中读取变量
i
的值到线程的工作内存中。- 在线程的工作内存中对变量
i
的值进行加 1。- 将结果写回内存,更新变量
i
的值。
可见性(Visibility):可见性指的是当一个线程修改了共享变量的值后,其他线程能够立即看到这个修改的结果。
对于可见性,要有一个概念:在多线程环境中,每个线程都有自己的
工作内存
,线程在执行过程中会将共享变量从主内存
中复制到自己的工作内存中进行操作。如果没有适当的同步机制
,其他线程可能无法及时看到对共享变量的修改,导致读取到过期的值。
双检索
public class Singleton {
private volatile static Singleton instance;
private Singleton() {
// 私有构造函数
}
public static Singleton getInstance() {
if (instance == null) { // 第一次检查
synchronized (Singleton.class) {
if (instance == null) { // 第二次检查
instance = new Singleton();
}
}
}
return instance;
}
}
双重检查和锁
一次在同步块外部,一次在同步块内部,来避免多线程环境下重复创建实例的问题。首先,如果instance
已经被实例化,那么在同步块外部的检查会直接返回实例。这样可以避免在每次调用getInstance()
方法时都进行同步的开销。其次,当多个线程同时通过了第一次检查,进入同步块时,只有一个线程能够进入同步块创建实例
,而其他线程在同步块外部等待。
volatile的作用
instance = new Singleton();
这个操作在Java中实际上可以被分解为以下几个步骤:
-
分配内存空间:首先,会在堆内存中为
Singleton
对象分配一块内存空间。 -
初始化对象:在分配内存空间后,会对
Singleton
对象进行初始化,即执行构造函数。这一步包括设置对象的成员变量的默认值。 -
将对象的引用赋值给变量:在对象初始化完成后,会将对象的引用赋值给
instance
变量。
注意,这些步骤在指令级别上可能会被重新排序,以提高执行效率。而在使用 volatile
关键字修饰的 instance
变量时,会禁止特定类型的指令重排序,确保这些步骤的顺序不会被改变。
由于指令重排序的存在,如果没有使用 volatile
关键字修饰 instance
变量,其他线程可能会在对象初始化之前看到 instance
不为 null
,从而可能访问到尚未完全初始化的对象,导致不正确的行为。而使用 volatile
关键字修饰后,可以确保在对象初始化完成之前,不会对 instance
的读写指令进行重排序,从而保证其他线程在读取 instance
变量时能够看到正确的对象状态。当然,其刷新主存同步
的作用也是非常重要的