并发编程
# 基础
# 线程的几种创建方式
- 继承Thread类,重写run()方法
- 实现Runnable接口,重写run()方法
- 实现Callable接口,重写call()方法,可以通过FutureTask获取任务执行的返回值
# 线程有几种状态?
在 Java 中,线程有 6 种状态,它们分别是:
- 新建(NEW):当一个线程对象被创建后,它就进入了新建状态。此时它还没有开始运行。
- 就绪(RUNNABLE):当调用线程对象的
start()
方法后,线程就进入了可运行状态。此时线程已经准备好运行,等待操作系统为它分配 CPU 时间。 - 运行(RUNNING):当线程获得 CPU 时间后,它就开始运行。此时线程正在执行它的
run()
方法中的代码。 - 阻塞(BLOCKED):当线程在等待锁时,它就进入了阻塞状态。此时线程无法继续执行,直到它获得锁。
- 等待(WAITING):当线程调用了
wait()
、join()
或park()
方法后,它就进入了等待状态。此时线程无法继续执行,直到其他线程调用了相应的方法来唤醒它。 - 终止(TERMINATED):当线程执行完毕或被中断后,它就进入了终止状态。此时线程已经结束运行。
可以使用 Thread.getState()
方法来获取一个线程的当前状态。
总之,在 Java 中,线程有 6 种状态,分别是新建、可运行、运行、阻塞、等待和终止。这些状态表示了一个线程在其生命周期中的不同阶段。
# 为什么需要守护线程
守护线程(Daemon Thread)是一种特殊的线程,它主要用于在后台为其他线程提供服务。它与普通线程的区别在于,当 JVM 中所有非守护线程都结束时,JVM 会自动退出,而不管当前是否还有守护线程在运行 ¹。
守护线程通常用于执行一些后台任务,例如垃圾回收、内存管理等。这些任务通常不是程序的核心部分,但又需要一直运行。当程序中所有非守护线程都结束时,这些后台任务也就没有继续运行的必要了,因此 JVM 会自动退出 ²。
在 Java 中,可以使用 Thread.setDaemon(true)
方法将一个线程设置为守护线程。需要注意的是,这个方法必须在调用 Thread.start()
方法之前调用。下面是一个简单的例子:
public class Main {
public static void main(String[] args) {
Thread thread = new Thread(() -> {
while (true) {
System.out.println("Daemon thread is running");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
thread.setDaemon(true);
thread.start();
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("Main thread finished");
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
在上面的例子中,我们创建了一个守护线程,并使用 setDaemon(true)
方法将其设置为守护线程。这个线程会一直运行,并每隔一秒打印一条消息。
在主线程中,我们等待三秒后退出。当主线程退出时,JVM 也会自动退出,因为此时已经没有非守护线程在运行了。此时,守护线程也会被强制结束。
总之,守护线程是一种特殊的线程,它主要用于在后台为其他线程提供服务。当 JVM 中所有非守护线程都结束时,JVM 会自动退出,而不管当前是否还有守护线程在运行。
# ThreadLocal
# 为什么要有ThreadLocal
ThreadLocal
是 Java 中的一个类,它提供了一种将变量绑定到线程上的方法。每个线程都可以独立地访问这个变量,而不会影响其他线程中的同一个变量。这样,就可以在多线程环境下实现线程间数据的隔离 ¹。
ThreadLocal
的作用主要有以下几点:
- 避免多次传递参数。当需要在多个方法中传递同一个参数时,可以使用
ThreadLocal
来避免多次传递参数,打破层次间的约束 ⁴。 - 线程间数据隔离。当需要在多个线程中独立地访问同一个变量时,可以使用
ThreadLocal
来实现线程间数据的隔离 ⁴。 - 事务管理。
ThreadLocal
可以用来存储线程事务信息,实现事务管理 ⁴。 - 数据库连接和会话管理。
ThreadLocal
可以用来存储数据库连接和会话信息,实现数据库连接和会话管理 ⁴。
下面是一个简单的例子,演示了如何使用 ThreadLocal
来实现线程间数据的隔离:
public class Main {
private static ThreadLocal<String> threadLocal = new ThreadLocal<>();
public static void main(String[] args) {
new Thread(() -> {
threadLocal.set("Thread 1");
printThreadLocalValue();
}).start();
new Thread(() -> {
threadLocal.set("Thread 2");
printThreadLocalValue();
}).start();
}
private static void printThreadLocalValue() {
System.out.println(Thread.currentThread().getName() + ": " + threadLocal.get());
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
在上面的例子中,我们创建了一个 ThreadLocal<String>
对象,并在两个线程中分别调用 set()
方法来设置它的值。然后我们在每个线程中调用 printThreadLocalValue()
方法来打印出 ThreadLocal
对象中存储的值。
由于每个线程都有自己独立的 ThreadLocal
值,因此在每个线程中打印出来的值都是该线程自己设置的值。
总之,ThreadLocal
是 Java 中的一个类,它提供了一种将变量绑定到线程上的方法。它可以用来避免多次传递参数、实现线程间数据隔离、事务管理和数据库连接和会话管理等。
# ThreadLocal是怎么实现的呢
ThreadLocal
的实现原理是通过为每个线程提供一个独立的存储空间来实现线程间数据隔离。每个线程都有一个 ThreadLocalMap
,它是一个类似于 HashMap
的数据结构,用来存储 ThreadLocal
变量的值。ThreadLocalMap
以 ThreadLocal
对象作为键,以 ThreadLocal
变量的值作为值 ¹。
当调用 ThreadLocal
的 set()
方法时,会获取当前线程的 ThreadLocalMap
对象,然后将 ThreadLocal
对象和它的值作为键值对存储到 ThreadLocalMap
中。当调用 ThreadLocal
的 get()
方法时,也会获取当前线程的 ThreadLocalMap
对象,然后根据 ThreadLocal
对象作为键来获取它的值 ¹。
下面是一个简单的例子,演示了如何使用 ThreadLocal
来实现线程间数据隔离:
public class Main {
private static ThreadLocal<String> threadLocal = new ThreadLocal<>();
public static void main(String[] args) {
new Thread(() -> {
threadLocal.set("Thread 1");
printThreadLocalValue();
}).start();
new Thread(() -> {
threadLocal.set("Thread 2");
printThreadLocalValue();
}).start();
}
private static void printThreadLocalValue() {
System.out.println(Thread.currentThread().getName() + ": " + threadLocal.get());
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
在上面的例子中,我们创建了一个 ThreadLocal<String>
对象,并在两个线程中分别调用 set()
方法来设置它的值。然后我们在每个线程中调用 printThreadLocalValue()
方法来打印出 ThreadLocal
对象中存储的值。
由于每个线程都有自己独立的 ThreadLocalMap
,因此在每个线程中打印出来的值都是该线程自己设置的值。
总之,ThreadLocal
的实现原理是通过为每个线程提供一个独立的存储空间来实现线程间数据隔离。每个线程都有一个 ThreadLocalMap
,它是一个类似于 HashMap
的数据结构,用来存储 ThreadLocal
变量的值。
# ThreadLocalMap的扩容机制
# ThreadLocalMap是怎么解决hash冲突问题
# Java内存模型
# 你对Java内存模型(JMM)的理解?
- 主内存
- 每个线程都有私有的本地内存
# 原子性、可见性、有序性
# 为什么会发生指令重排
指令重排是指在不改变程序执行结果的前提下,编译器和处理器对指令顺序进行重新排列,以提高程序的执行效率。这是一种常见的编译器优化技术。
在单线程环境下,指令重排通常不会对程序的正确性产生影响,因为编译器和处理器会保证重排后的指令序列与原始指令序列在单线程环境下的执行结果相同。但是在多线程环境下,指令重排可能会导致严重的问题。
下面是一个简单的例子,演示了指令重排可能导致的问题:
public class ReorderExample {
int a = 0;
boolean flag = false;
public void writer() {
a = 1; // 语句1
flag = true; // 语句2
}
public void reader() {
if (flag) { // 语句3
int i = a * a; // 语句4
// ...
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
在上面的例子中,writer
方法和 reader
方法分别在两个不同的线程中执行。按照代码的顺序,writer
方法先执行语句1,再执行语句2;reader
方法先执行语句3,再执行语句4。
但是由于指令重排的存在,编译器和处理器可能会改变这些语句的执行顺序。例如,编译器可能会将 writer
方法中的两条语句交换顺序,使得语句2先于语句1执行。
当这种情况发生时,就有可能出现以下情况:reader
线程先执行语句3,判断 flag
为 true
;然后执行语句4,计算 a * a
的值。由于此时 a
的值还没有被更新为1(因为语句1还没有执行),所以计算结果为0,而不是期望的1。
总之,在多线程环境下,指令重排可能会导致严重的问题。为了避免这种问题,Java 提供了内存屏障(memory barrier)机制来禁止特定类型的指令重排。内存屏障可以通过使用 volatile
变量或者同步块来实现。
# 为什么会出现Volatile
volatile
是 Java 中的一个关键字,它用来修饰变量,表示该变量是易变的。volatile
变量具有两个特性:可见性和禁止指令重排。
在多线程环境下,每个线程都有自己的工作内存,用来存储该线程使用到的变量的副本。当一个线程修改了一个变量的值时,这个修改只会更新工作内存中的副本,而不会立即更新主内存中的值。这就导致了其他线程可能无法立即看到这个修改。
为了解决这个问题,Java 提供了 volatile
关键字。当一个变量被声明为 volatile
时,它就具有了可见性。这意味着当一个线程修改了一个 volatile
变量的值时,这个修改会立即更新到主内存中;当其他线程读取这个 volatile
变量的值时,它们会从主内存中读取最新的值。
此外,volatile
还具有禁止指令重排的特性。正如前面所述,指令重排可能会导致严重的问题。为了避免这种问题,Java 提供了内存屏障机制来禁止特定类型的指令重排。当一个变量被声明为 volatile
时,编译器和处理器就不会对它进行指令重排。
总之,volatile
关键字用来解决多线程环境下变量可见性和指令重排问题。它能够保证多个线程之间对共享变量的操作是可见的,并且禁止对 volatile
变量进行指令重排。
# happens-before
happens-before
是 Java 内存模型中的一个重要概念,它用来描述两个操作之间的偏序关系。如果操作 A happens-before
操作 B,那么操作 A 的结果对操作 B 可见,且操作 A 的执行顺序在操作 B 之前。
happens-before
关系用来保证多线程环境下的数据一致性。在多线程环境下,由于编译器优化、处理器乱序执行和缓存一致性等原因,不同线程之间对共享变量的操作可能会出现不一致的情况。为了解决这个问题,Java 提供了 happens-before
关系来定义两个操作之间的偏序关系。
Java 内存模型定义了若干种 happens-before
规则,例如:
- 程序顺序规则:在一个线程中,按照程序顺序,前面的操作
happens-before
后面的操作。 - 监视器锁规则:对一个锁的解锁
happens-before
随后对这个锁的加锁。 volatile
变量规则:对一个volatile
变量的写入操作happens-before
随后对这个变量的读取操作。- 线程启动规则:线程的启动操作
happens-before
线程中的任意操作。 - 线程终止规则:线程中的任意操作
happens-before
线程的终止检测。
通过使用这些规则,程序员可以在多线程环境下编写正确的并发程序。
总之,happens-before
关系是 Java 内存模型中用来描述两个操作之间偏序关系的概念。它能够保证多线程环境下的数据一致性,并帮助程序员编写正确的并发程序。
# volitile的底层原理
volatile 是 Java 提供的一种轻量级的同步机制,它能够保证变量的可见性和有序性,但不能保证原子性。它的底层实现原理是内存屏障(Memory Barrier)1。
在多线程环境中,每个线程都有自己的工作内存,它们会从主内存中读取共享变量的值,并将其缓存在工作内存中。当一个线程修改了共享变量的值后,它会将修改后的值写回主内存。但是,由于各个线程之间并不共享工作内存,因此其他线程可能无法立即看到共享变量被修改后的值。这就是所谓的可见性问题。
为了解决这个问题,Java 提供了 volatile 关键字。当一个变量被声明为 volatile 时,JVM 会在每次读取该变量之前插入一个 LoadLoad 屏障和一个 LoadStore 屏障,在每次写入该变量之后插入一个 StoreStore 屏障和一个 StoreLoad 屏障。
这些屏障能够保证 volatile 变量的可见性和有序性。例如,在读取一个 volatile 变量之前,JVM 会执行 LoadLoad 屏障和 LoadStore 屏障,以确保之前所有的读操作和写操作都已完成。在写入一个 volatile 变量之后,JVM 会执行 StoreStore 屏障和 StoreLoad 屏障,以确保写操作对其他线程立即可见,并且禁止指令重排序。
# volatile如何保证可见性的
# volatile如何保证有序性的
# 锁
# 为什么出现synchronized锁
synchronized 是 Java 中的一个关键字,它可以用来保护资源线程安全的同步锁。它锁定的是一个个对象,实现的方式是在对象头上做出标记,表示这个对象上锁了,是哪个线程持有的锁以及当前使用的是什么锁方式¹。这样可以保证同步代码块中的代码执行是串行执行的¹。
synchronized 关键字可以加在方法上,也可以新建一个同步代码块并且自定义 monitor 锁对象³。它与 Lock 显式的加锁和解锁不同,synchronized 的加解锁是隐式的,尤其是抛异常的时候也能保证释放锁³。
# synchronized是怎样进行加锁的
synchronized
关键字可以用来保护代码块或方法,确保同一时间只有一个线程可以访问它。当一个线程进入synchronized
代码块或方法时,它会自动获取锁。如果另一个线程试图进入相同的synchronized
代码块或方法,它将被阻塞,直到第一个线程释放锁。
在底层,synchronized
关键字通过对象监视器(monitor)来实现加锁。当一个线程进入synchronized
代码块或方法时,它会执行一个名为monitorenter
的指令,该指令会尝试获取与锁对象关联的监视器。如果获取成功,监视器中的计数器会增加,表示该线程持有锁。当线程离开synchronized
代码块或方法时,它会执行一个名为monitorexit
的指令,该指令会递减监视器中的计数器。如果计数器递减为0,则表示该线程不再持有锁,并且锁被释放。
synchronized
关键字还支持可重入性。这意味着如果一个线程已经持有锁,它可以再次进入相同的synchronized
代码块或方法而不会被阻塞。每次进入synchronized
代码块或方法时,监视器中的计数器都会增加。当线程离开synchronized
代码块或方法时,计数器会递减。
# synchronized锁是如何保证原子性、可见性、有序性
synchronized
关键字能够保证原子性、可见性和有序性。
原子性:当一个线程进入
synchronized
代码块或方法时,它会自动获取锁。如果另一个线程试图进入相同的synchronized
代码块或方法,它将被阻塞,直到第一个线程释放锁。这样,就能保证同一时间只有一个线程能够访问该代码块或方法,从而保证了原子性。可见性:当一个线程进入
synchronized
代码块或方法时,它会在工作内存中创建一个锁记录(Lock Record),并将主内存中的变量值复制到工作内存中。当线程离开synchronized
代码块或方法时,它会把工作内存中的变量值刷新回主内存。这样,就能保证不同线程之间对共享变量的修改能够及时地被其他线程看到,从而保证了可见性。有序性:在Java内存模型中,有一个
happens-before
原则。根据这个原则,对于一个锁的解锁操作happens-before
于后续对同一个锁的加锁操作。这意味着,在一个线程释放锁之前对共享变量的修改,在随后另一个线程获取同一个锁时将是可见的。这样,就能保证synchronized
代码块或方法内部的操作是有序的。
# synchronized为什么会进行锁升级啊
synchronized
关键字的锁升级是为了提高性能。在Java 6之前,synchronized
关键字只能使用重量级锁,这种锁的开销很大,因为它需要在操作系统层面进行线程切换。为了减少这种开销,Java 6引入了偏向锁和轻量级锁。
偏向锁是一种针对单线程执行的优化。当一个线程第一次获取synchronized
关键字保护的锁时,它会在对象头中记录自己的线程ID。如果该线程再次获取相同的锁,它只需要检查对象头中的线程ID是否与自己相同,而不需要进行任何同步操作。这样就能大大减少获取锁的开销。
轻量级锁是一种针对多线程竞争不激烈的情况下的优化。当一个线程试图获取synchronized
关键字保护的锁时,它会先尝试使用CAS操作将对象头中的Mark Word替换为指向自己栈帧中的锁记录(Lock Record)的指针。如果CAS操作成功,则该线程获取到了锁;否则,该线程会进行自旋等待,尝试再次获取锁。轻量级锁避免了重量级锁在操作系统层面进行线程切换的开销,但如果多个线程同时竞争同一个锁,自旋等待会消耗大量CPU资源。
当多个线程同时竞争同一个synchronized
关键字保护的锁时,偏向锁和轻量级锁可能无法满足需求。此时,JVM会将锁升级为重量级锁。重量级锁通过在操作系统层面进行线程切换来实现同步,虽然开销较大,但能够保证在激烈竞争的情况下仍能正确地实现同步。
总之,synchronized
关键字通过使用偏向锁、轻量级锁和重量级锁来适应不同的竞争情况,从而提高性能。
# 如何通过CAS来竞争锁
CAS(Compare And Swap)是一种无锁的原子操作,它能够在多线程环境下安全地更新共享变量。CAS操作需要三个参数:一个内存地址V,一个期望值A和一个新值B。CAS操作会比较内存地址V中的值是否等于期望值A,如果相等,则将内存地址V中的值更新为新值B,并返回true;否则不做任何操作,并返回false。
在synchronized
关键字的轻量级锁实现中,CAS操作用于将对象头中的Mark Word替换为指向线程栈帧中的锁记录(Lock Record)的指针。当一个线程试图获取synchronized
关键字保护的锁时,它会先在自己的栈帧中创建一个锁记录(Lock Record),并将对象头中的Mark Word复制到锁记录中。然后,该线程会使用CAS操作尝试将对象头中的Mark Word替换为指向锁记录的指针。如果CAS操作成功,则该线程获取到了锁;否则,该线程会进行自旋等待,尝试再次获取锁。
举个例子,假设有两个线程A和B同时竞争同一个synchronized
关键字保护的锁。当线程A和线程B都创建好了各自的锁记录后,它们都会尝试使用CAS操作将对象头中的Mark Word替换为指向各自锁记录的指针。由于CAS操作是原子的,所以只有一个线程能够成功替换Mark Word。假设线程A成功替换了Mark Word,则它获取到了锁;而线程B则会进行自旋等待,尝试再次获取锁。
# ReentrantLock出现的意义
ReentrantLock
是Java并发包中提供的一种可重入锁,它与synchronized
关键字类似,都能够实现线程同步。然而,ReentrantLock
比synchronized
关键字更加灵活,提供了更多的功能。
下面是一些ReentrantLock
相对于synchronized
关键字的优点:
ReentrantLock
支持公平锁和非公平锁。公平锁能够保证线程按照请求锁的顺序来获取锁,而非公平锁则不能保证这一点。相比之下,synchronized
关键字只能实现非公平锁。ReentrantLock
提供了一个Condition类,能够让线程在特定条件下等待或被唤醒。这比synchronized
关键字中的wait()和notify()方法更加灵活,因为一个锁可以有多个Condition对象。ReentrantLock
提供了可中断的获取锁操作。当一个线程正在等待获取锁时,其他线程可以调用它的interrupt()方法来中断它的等待。相比之下,使用synchronized
关键字时,线程在等待获取锁时无法被中断。ReentrantLock
提供了尝试获取锁的操作。线程可以尝试在指定时间内获取锁,如果获取失败,则返回false。这比synchronized
关键字更加灵活,因为它允许线程在无法获取锁时执行其他操作。
总之,虽然synchronized
关键字和ReentrantLock
都能够实现线程同步,但是ReentrantLock
比synchronized
关键字更加灵活,提供了更多的功能。在某些情况下,使用ReentrantLock
能够编写出更加高效、灵活的并发代码。
# 为什么会有AQS
AQS(AbstractQueuedSynchronizer)是Java并发包中的一个重要类,它提供了一种用于实现阻塞锁和相关同步器(如信号量、CountDownLatch等)的框架。AQS的出现是为了简化阻塞锁和相关同步器的实现,并提供一种统一、高效的实现方式。
AQS的核心思想是基于一个FIFO(先进先出)的等待队列来实现阻塞锁和相关同步器。当一个线程试图获取锁或者等待某个条件时,如果不能立即满足,则会将该线程放入等待队列中。当锁被释放或者条件被满足时,等待队列中的线程会被唤醒并重新尝试获取锁或者等待条件。
AQS内部维护了一个状态变量,用于表示锁或者相关同步器的状态。子类可以通过定义对状态变量的操作来实现自己的同步语义。例如,ReentrantLock类就是通过定义对状态变量的操作来实现可重入锁的语义。
总之,AQS是Java并发包中的一个重要类,它提供了一种用于实现阻塞锁和相关同步器的框架。AQS的出现简化了阻塞锁和相关同步器的实现,并提供了一种统一、高效的实现方式。
# 为什么会有CAS
CAS(Compare And Swap)是一种无锁的原子操作,它能够在多线程环境下安全地更新共享变量。CAS操作的出现是为了解决多线程环境下的同步问题,它提供了一种高效、无锁的方式来实现线程同步。
在多线程环境下,多个线程可能会同时访问和修改共享变量。如果不进行同步,那么这些线程之间的操作可能会相互干扰,导致数据不一致等问题。为了解决这个问题,我们需要使用锁来保护共享变量。
然而,传统的锁机制(如互斥锁、读写锁等)通常都需要在操作系统层面进行线程切换,这会带来很大的开销。为了减少这种开销,研究人员提出了一种无锁的原子操作——CAS。
CAS操作能够在不使用锁的情况下安全地更新共享变量。它通过硬件指令来实现原子性,能够保证在多线程环境下的安全性。相比传统的锁机制,CAS操作具有更高的性能和更低的开销。
总之,CAS操作的出现是为了解决多线程环境下的同步问题,它提供了一种高效、无锁的方式来实现线程同步。
# CAS会有哪些问题
尽管CAS操作能够在多线程环境下安全地更新共享变量,但它并不是万能的。CAS操作也有一些问题和局限性,主要包括以下几点:
- ABA问题:CAS操作在判断共享变量是否被修改时,只会比较变量的当前值和期望值是否相等。然而,如果一个变量在被修改后又被修改回原值,那么CAS操作就无法检测到这种情况。这就是所谓的ABA问题。为了解决这个问题,研究人员提出了一种带版本号的CAS操作,它能够检测共享变量在被修改过程中的版本变化。
- 自旋开销:如果CAS操作失败,那么线程通常会进行自旋等待,尝试再次执行CAS操作。然而,如果多个线程同时竞争同一个共享变量,那么自旋等待会消耗大量的CPU资源。在竞争激烈的情况下,CAS操作的性能可能不如传统的锁机制。
- 只能保护一个变量:CAS操作只能保护一个共享变量。如果需要同时保护多个共享变量,那么就需要使用多个CAS操作来实现。然而,多个CAS操作之间并不是原子的,这可能会导致数据不一致等问题。
总之,尽管CAS操作能够在多线程环境下安全地更新共享变量,但它也有一些问题和局限性。在使用CAS操作时,需要仔细考虑这些问题,并采取相应的措施来解决它们。
# 并发工具类
# 为什么会有CountDownLatch(倒计时器)
让多个线程同步的去完成一些操作
# 线程池
# 为什么会有线程池啊
线程池(Thread Pool)是一种管理线程的工具,它能够复用已创建的线程来执行任务,从而减少线程创建和销毁的开销。线程池的出现是为了解决多线程环境下的性能问题,它提供了一种高效、灵活的方式来管理线程。
在多线程环境下,如果需要频繁地创建和销毁线程,那么这些操作会带来很大的开销。每次创建一个新线程都需要分配内存、初始化线程栈和寄存器等资源;每次销毁一个线程都需要回收这些资源。这些操作会消耗大量的CPU时间和内存空间。
为了减少这种开销,研究人员提出了线程池的概念。线程池维护了一组已创建的线程,当需要执行一个任务时,它会从池中取出一个空闲的线程来执行该任务;当任务执行完毕后,该线程会被归还到池中,等待下一次使用。
通过复用已创建的线程来执行任务,线程池能够减少线程创建和销毁的开销。此外,线程池还能够有效地管理线程数量,避免过多的线程竞争导致系统性能下降。
总之,线程池是一种管理线程的工具,它能够复用已创建的线程来执行任务,从而减少线程创建和销毁的开销。线程池的出现提供了一种高效、灵活的方式来管理线程。
# 线程池工作流程
- 如果正在运行的线程数量小于 corePoolSize,那么马上创建线程运行这个任务;
- 如果正在运行的线程数量大于或等于 corePoolSize,那么将这个任务放入队列;
- 如果这时候队列满了,而且正在运行的线程数量小于 maximumPoolSize,那么还是要创建非核心线程立刻运行这个任务;
- 如果队列满了,而且正在运行的线程数量大于或等于 maximumPoolSize,那么线程池会根据拒绝策略来对应处理。
# 线程池主要参数
线程池主要有以下几个参数:
- 核心线程数(corePoolSize):线程池中核心线程的数量。当线程池中的线程数量小于核心线程数时,即使有空闲线程,也会创建新的线程来执行任务。
- 最大线程数(maximumPoolSize):线程池中最大线程的数量。当线程池中的线程数量达到最大值时,新提交的任务会被拒绝。
- 空闲线程存活时间(keepAliveTime):当线程池中的线程数量超过核心线程数时,多余的空闲线程在等待新任务时最多能等待的时间。超过这个时间后,空闲线程会被终止。
- 任务队列(workQueue):用于存储等待执行的任务的阻塞队列。当所有工作线程都处于忙碌状态时,新提交的任务会被放入任务队列中等待执行。
- 线程工厂(threadFactory):用于创建新线程的工厂对象。可以通过指定自定义的线程工厂来控制新创建的线程的名称、优先级等属性。
- 拒绝策略(handler):当任务队列已满且线程池中的线程数量达到最大值时,新提交的任务会被拒绝。拒绝策略用于处理这种情况。
# 线程池的拒绝策略
- AbortPolicy :直接抛出异常,默认使用此策略
- CallerRunsPolicy:用调用者所在的线程来执行任务
- DiscardOldestPolicy:丢弃阻塞队列里最老的任务,也就是队列里靠前的任务
- DiscardPolicy :当前任务直接丢弃
# 线程池常用的工作队列
线程池中常用的工作队列有以下几种:
ArrayBlockingQueue
:基于数组实现的有界阻塞队列。这种队列的容量是固定的,当队列满时,新提交的任务会被拒绝。LinkedBlockingQueue
:基于链表实现的阻塞队列。这种队列的容量可以是固定的,也可以是无限的。当队列满时,新提交的任务会被拒绝。SynchronousQueue
:一种特殊的阻塞队列,它没有容量。每个插入操作都必须等待一个相应的删除操作,反之亦然。这种队列通常用于实现线程池中的缓存任务。PriorityBlockingQueue
:基于优先级堆实现的无界阻塞队列。这种队列中的元素按照优先级顺序进行排序。当线程池中的线程数量达到最大值时,新提交的任务会根据优先级顺序被执行。
这些工作队列各有优缺点,应根据实际情况来选择合适的工作队列。例如,如果需要控制线程池中任务的数量,可以使用ArrayBlockingQueue
或LinkedBlockingQueue
;如果需要按照任务的优先级顺序来执行任务,可以使用PriorityBlockingQueue
。
# 常见的线程池
Java并发包中提供了几种常用的线程池,它们分别是:
FixedThreadPool
:固定大小的线程池。这种线程池中的线程数量是固定的,当所有线程都处于忙碌状态时,新提交的任务会被放入工作队列中等待执行。CachedThreadPool
:缓存型线程池。这种线程池中的线程数量不固定,当有新任务提交时,如果有空闲线程,则使用空闲线程来执行任务;否则,创建新的线程来执行任务。当线程空闲时间超过一定时间后,会被终止。SingleThreadExecutor
:单线程线程池。这种线程池中只有一个线程,所有提交的任务会按照顺序依次执行。ScheduledThreadPoolExecutor
:定时任务线程池。这种线程池能够在指定时间执行任务,或者以指定的时间间隔周期性地执行任务。
下面是一个简单的例子,它展示了如何使用FixedThreadPool
来执行任务:
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class FixedThreadPoolDemo {
public static void main(String[] args) {
ExecutorService threadPool = Executors.newFixedThreadPool(2);
for (int i = 0; i < 10; i++) {
final int taskId = i;
threadPool.execute(() -> {
System.out.println("Task " + taskId + " is running");
});
}
threadPool.shutdown();
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
在这个例子中,我们使用Executors.newFixedThreadPool()
方法创建了一个固定大小为2的线程池。然后,我们向线程池中提交了10个任务。由于线程池的大小为2,所以最多只能有两个任务同时执行;其他任务会被放入工作队列中等待执行。
通过这个例子,你可以看到如何使用FixedThreadPool
来执行任务。需要注意的是,在使用完线程池后,应调用shutdown()
方法来关闭线程池。