线程池其实是一种池化的技术实现,池化技术的核心思想就是实现资源的复用,避免资源的重复创建和销毁带来的性能开销。
线程池可以管理一堆线程,让线程执行完任务之后不进行销毁,而是继续去处理其它线程已经提交的任务。
使用线程池的好处
Java 主要是通过构建 ThreadPoolExecutor 来创建线程池的。
因此,如果我们想要线程池操作线程,就先使用构造方法创建一个 ThreadPoolExecutor实例,再通过该实例创建线程。
通过 Executor 构造线程池,可以指定几种类型:
Executor 是一个接口;ThreadPoolExecutor 是 Executor接口的一个实现类;Executors 是一个工具类。
ThreadPoolExecutor 可以更灵活地配置线程池的各种参数,如核心线程数、最大线程数、任务队列、线程空闲时间,提供了更精细的控制
ThreadPoolExecutor executor = new ThreadPoolExecutor(
5, // core pool size
10, // maximum pool size
60L, // keep-alive time
TimeUnit.SECONDS, // time unit for keep-alive time
new LinkedBlockingQueue<Runnable>() // task queue
);
executor.execute(() -> {
// Your task code here
});
Executor 是一个接口;ThreadPoolExecutor 是 Executor接口的一个实现类;Executors 是一个工具类。
// 1. 创建一个固定大小的线程池,线程池大小为3
ExecutorService fixedThreadPool = Executors.newFixedThreadPool(3);
// 2. 创建一个单线程的线程池
ExecutorService singleThreadExecutor = Executors.newSingleThreadExecutor();
// 3. 创建一个可缓存的线程池
ExecutorService cachedThreadPool = Executors.newCachedThreadPool();
// 4. 创建一个定时调度的线程池,核心线程数为3
ScheduledExecutorService scheduledThreadPool = Executors.newScheduledThreadPool(3);
// 5. 创建一个单线程的定时调度线程池
ScheduledExecutorService singleThreadScheduledExecutor = Executors.newSingleThreadScheduledExecutor();
通过 Executors 构造线程池,其内部仍然是调用 ThreadPoolExecutor 进行创建,也就是说 Executors 实质上只是把 ThreadPoolExecutor 封装起来了。
public static ExecutorService newFixedThreadPool(int nThreads) {
return new ThreadPoolExecutor(nThreads, nThreads,
0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<Runnable>());
}
这也是为什么后面我们说不推荐使用 ThreadPoolExecutor ,因为其灵活性太低了,可能出现一些问题。
在 Java 程序中,其实经常需要用到多线程来处理一些业务,但是不建议单纯继承 Thread 或者实现 Runnable 接口来创建线程,这样会导致频繁创建及销毁线程,同时创建过多的线程也可能引发资源耗尽的风险。
使用线程池是一种更合理的选择,方便管理任务,同时实现线程的重复利用。
public class SimpleWebServer {
private static final int NTHREADS = 100;
private static final ExecutorService exec = Executors.newFixedThreadPool(NTHREADS);
public static void main(String[] args) {
while (true) {
// 接收请求
Runnable request = new Runnable() {
public void run() {
// 处理请求
System.out.println("Request handled by " + Thread.currentThread().getName());
}
};
// 使用 exec.execute(request)来提交 Runnable对象到线程池中执行。
// 这意味着每当循环一次,就有一个新的任务提交给线程池,由线程池中的某个线程来执行。
exec.execute(request);
}
}
}
使用线程池进行并行的数值计算。
public class ParallelCalculation {
private static final int NTHREADS = 4;
private static final ExecutorService exec = Executors.newFixedThreadPool(NTHREADS);
public static void main(String[] args) {
Callable<Double> task = new Callable<Double>() {
@Override
public Double call() {
// 这里模拟一些数值计算
return Math.random() * 100;
}
};
List<Future<Double>> results = new ArrayList<>();
for (int i = 0; i < 10; i++) {
results.add(exec.submit(task));
}
for (Future<Double> result : results) {
try {
System.out.println(result.get());
} catch (InterruptedException | ExecutionException e) {
e.printStackTrace();
}
}
exec.shutdown();
}
}
在多线程的环境中中,我们当然不希望任意一个线程任务暂停后,主线程也随之暂停。
运行线程池,可以令空闲的 CPU 转去服务别的线程任务。
public class AsynchronousTaskProcessor {
private static final ExecutorService exec = Executors.newCachedThreadPool();
public static void main(String[] args) {
exec.execute(() -> {
// 执行某些异步任务
System.out.println("Async task started");
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("Async task completed");
});
// 添加一个延迟,确保异步任务有机会先执行
try {
Thread.sleep(100); // 延迟 100 毫秒
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("Main thread continues to execute other operations.");
exec.shutdown();
}
}
虽然 JDK 提供了 Executors 快速创建线程池的方法,但其实不推荐使用 Executors 来创建线程池。
因为从上面构造线程池的代码可以看出,newFixedThreadPool 线程池由于使用了 LinkedBlockingQueue,队列的容量默认无限大,实际使用中出现任务过多时会导致内存溢出;
newCachedThreadPool 线程池由于核心线程数无限大,当任务过多的时候会导致创建大量的线程,可能机器负载过高导致服务宕机。
线程安全问题的核心在于多个线程会对同一个临界区的共享资源进行访问,那如果每个线程都拥有自己的 “共享资源”,各用各的,互不影响,这样就不会出现线程安全的问题了。
通常,我们会使用 synchronzed 关键字 或者 lock 来控制线程对临界区资源的同步顺序,但这种加锁的方式会让未获取到锁的线程进行阻塞,很显然,这种方式的时间效率不会特别高。
ThreadLocal 是 java.lang包中的一个类,它提供了线程局部变量的功能。
简单来说,ThreadLocal 可以让每个使用它的线程拥有一个独立的变量副本,这样就可以确保每个线程操作的是自己的数据,不会影响到其他线程的数据。
从而实现线程隔离,用于解决多线程中共享对象的线程安全问题。
事实上,这就是一种“空间换时间”的思想。
每个线程拥有自己的“共享资源”,虽然内存占用变大了,但由于不需要同步,也就减少了线程可能存在的阻塞问题,从而提高时间上的效率。
ThreadLocal 的特性也导致了应用场景比较广泛,主要的应用场景如下:
下面是一个使用 ThreadLocal 来保存用户登录信息的示例。这个示例适用于像 Web服务器这样的多线程环境,其中每个线程处理一个独立的用户请求。
这个示例定义了一个 UserAuthenticationService类,该类使用 ThreadLoca l来保存与当前线程关联的用户登录信息。
假设用户已经通过身份验证,将用户对象存储在 currentUser ThreadLocal变量中。
getCurrentUser方法用于检索与当前线程关联的用户信息。由于使用了ThreadLocal,因此不同的线程可以同时登录不同的用户,而不会相互干扰
public class UserAuthenticationService {
// 创建一个 ThreadLocal实例,用于保存用户登录信息
private static ThreadLocal<User> currentUser = ThreadLocal.withInitial(() -> null);
public static void main(String[] args) {
// 模拟用户登录
loginUser(new User("Alice", "password123"));
System.out.println("User logged in: " + getCurrentUser().getUsername());
// 模拟另一个线程处理另一个用户
Runnable task = () -> {
loginUser(new User("Bob", "password456"));
System.out.println("User logged in: " + getCurrentUser().getUsername());
};
Thread thread = new Thread(task);
thread.start();
}
// 模拟用户登录方法
public static void loginUser(User user) {
// 这里通常会有一些身份验证逻辑
currentUser.set(user);
}
// 获取当前线程关联的用户信息
public static User getCurrentUser() {
return currentUser.get();
}
// 用户类
public static class User {
private final String username;
private final String password;
// 省略 Constructor、getter和setter...
}
}
set 方法用于设置当前线程中 ThreadLocal 的变量值,该方法的源码如下:
public void set(T value) {
//1. 获取当前线程实例对象
Thread t = Thread.currentThread();
//2. 通过当前线程实例获取到 ThreadLocalMap对象
ThreadLocalMap map = getMap(t);
if (map != null)
//3. 如果 Map 不为 null,则以当前 ThreadLocal实例为 key,值为 value 进行存入
map.set(this, value);
else
//4.map 为 null, 则新建 ThreadLocalMap 并存入 value
createMap(t, value);
}
每个线程都有自己的 ThreadLocalMap,这个映射表存储了线程的局部变量,其中键是 ThreadLocal 对象,值为特定于线程的对象。
如果 Map 不为 null,则以当前 ThreadLocal 实例为 key,值为 value 进行存入;如果 map 为 null,则新建 ThreadLocalMap 并存入 value。
get 方法用于获取当前线程中 ThreadLocal 的变量值,同样的还是来看源码:
重点是第五步,可以看到,get方法实质上是不允许返回一个空值的。
这对 remove 方法会产生影响,看下去就知道。
public T get() {
//1. 获取当前线程的实例对象
Thread t = Thread.currentThread();
//2. 获取当前线程的 ThreadLocalMap
ThreadLocalMap map = getMap(t);
if (map != null) {
//3. 获取 map 中当前 ThreadLocal实例为 key 的值的 entry
ThreadLocalMap.Entry e = map.getEntry(this);
if (e != null) {
@SuppressWarnings("unchecked")
//4. 当前 entitiy != null 的话,就返回相应的值 value
T result = (T)e.value;
return result;
}
}
//5. 若 map == null 或者 entry == null ,通过该方法初始化,并返回该方法返回的 value
return setInitialValue();
}
remove 方法的作用是从当前线程的 ThreadLocalMap 中删除与当前 ThreadLocal 实例关联的条目。
这个方法在释放线程局部变量的资源或重置线程局部变量的值时特别有用。
源代码如下:
public void remove() {
//1. 获取当前线程的ThreadLocalMap
ThreadLocalMap m = getMap(Thread.currentThread());
if (m != null)
//2. 从map中删除以当前 ThreadLocal实例为 key 的键值对
m.remove(this);
}
以下是使用 remove 方法的示例代码:
// 指定 ThreadLocal 的泛型为 String, 则该线程独享一个 String 类型的变量,
// 变量名为 threadLocal, 变量内容为 "Initial Value"
ThreadLocal<String> threadLocal = ThreadLocal.withInitial(() -> "Initial Value");
Thread thread = new Thread(() -> {
System.out.println(threadLocal.get()); // 输出 "Initial Value"
threadLocal.set("Updated Value");
System.out.println(threadLocal.get()); // 输出 "Updated Value"
threadLocal.remove();
// 明明删除了值,为什么输出的还是第一次初始化的值?
// get 不允许返回空值, 因此即使 remove了,我们下一次 get 的时候, 还是自动用最初的参数再初始化了一次.
System.out.println(threadLocal.get()); // 输出 "Initial Value"
});
thread.start();
输出结果:
Initial Value
Updated Value
Initial Value
ConcurrentMap 直接继承自 Map接口,如果只需要一个能并发的简单 Map,使用这个就对了
ConcurrentMap 接口继承了 Map 接口,在 Map 接口的基础上又定义了四个方法:
public interface ConcurrentMap<K, V> extends Map<K, V> {
// 插入元素
// 与原有 put 方法不同的是,putIfAbsent 如果插入的 key 相同,则不替换原有的 value 值;
V putIfAbsent(K key, V value);
// 移除元素
// 与原有 remove 方法不同的是,新 remove 方法中增加了对 value 的判断,
// 如果要删除的 key-value 不能与 Map 中原有的 key-value 对应上,则不会删除该元素;
boolean remove(Object key, Object value);
// 替换元素
// 增加了对 value 值的判断,如果 key-oldValue 能与 Map 中原有的 key-value 对应上,才进行替换操作;
boolean replace(K key, V oldValue, V newValue);
// 替换元素
// 与上面的 replace 不同的是,此 replace 不会对 Map 中原有的 key-value 进行比较,如果 key 存在则直接替换;
V replace(K key, V value);
}
通过 Collections 的Map
比如 SynchronzedMap 的 put 方法源码就是加锁过的:
public V put(K key, V value) {
synchronized (mutex) {return m.put(key, value);}
}
synchronized 同步代码块的方式我们前面也讲过了,大家应该都还有印象。
J7 的 ConcurrentHashMap 提供了一种细粒度的加锁机制,这种机制叫分段锁「Lock Striping」。
整个哈希表被分为多个段,每个段都独立锁定。读取操作不需要锁,写入操作仅锁定相关的段。这减小了锁冲突的几率,从而提高了并发性能。
分段锁,就是将数据分段,对每一段数据分配一把锁。当一个线程占用锁访问其中一个段数据的时候,其他段的数据也能被其他线程访问。
有些方法需要跨段,比如 size()、isEmpty()、containsValue(),它们可能需要锁定整个表而不仅仅是某个段,这需要按顺序锁定所有段,操作完后,再按顺序释放所有段的锁.
这种机制的优点:在并发环境下将实现更高的吞吐量,而在单线程环境下只损失非常小的性能。
ConcurrentHashMap 是由 Segment数组结构和 HashEntry数组构成的。
Segment 是一种可重入的锁 ReentrantLock,HashEntry 则用于存储键值对数据。
一个 ConcurrentHashMap 里包含一个 Segment数组,Segment 的结构和 HashMap 类似,是一种数组和链表结构,一个 Segment 里包含一个 HashEntry数组,每个 HashEntry 是一个链表结构的元素,每个 Segment 守护着一个 HashEntry 数组里的元素,当对 HashEntry 数组的数据进行修改时,必须首先获得它对应的 Segment 锁。
get方法执行的过程如下:
put 方法执行的过程如下:
在以下的例子中,我们模拟构建一个线程安全的高并发统计用户访问次数的功能。
首先要考虑的自然是如何存储访问次数这个变量。
由于用户线程当然是并发执行,为了不出现脏写,使用适合多线程环境的并发容器。
import java.util.concurrent.ConcurrentHashMap;
public class UserVisitCounter {
private final ConcurrentHashMap<String, Integer> visitCountMap;
public UserVisitCounter() {
this.visitCountMap = new ConcurrentHashMap<>();
}
// 用户访问时调用的方法
public void userVisited(String userId) {
visitCountMap.compute(userId, (key, value) -> value == null ? 1 : value + 1);
}
// 获取用户的访问次数
public int getVisitCount(String userId) {
return visitCountMap.getOrDefault(userId, 0);
}
public static void main(String[] args) {
UserVisitCounter counter = new UserVisitCounter();
// 模拟用户访问
counter.userVisited("user1");
counter.userVisited("user1");
counter.userVisited("user2");
System.out.println("User1 visit count: " + counter.getVisitCount("user1")); // 输出: User1 visit count: 2
System.out.println("User2 visit count: " + counter.getVisitCount("user2")); // 输出: User2 visit count: 1
}
}
在聊 CopyOnWrite 容器之前我们先来谈谈什么是 CopyOnWrite 机制,CopyOnWrite 是计算机设计领域的一种优化策略,也是一种在并发场景下常用的设计思想——写入时复制。
什么是写入时复制呢?
就是当有多个调用者同时去请求一个资源数据的时候,有一个调用者出于某些原因需要对当前的数据源进行修改,这个时候系统将会复制一个当前数据源的副本给调用者修改。
CopyOnWrite 容器即写时复制的容器,当我们往一个容器中添加元素的时候,不直接往容器中添加,而是将当前容器进行 copy,复制出来一个新的容器,然后向新容器中添加我们需要的元素,最后将原容器的引用指向新容器。
这样做的好处在于,我们可以在并发的场景下对容器进行"读操作"而不需要"加锁",从而达到读写分离的目的。从 JDK 1.5 开始 Java 并发包里提供了两个使用 CopyOnWrite 机制实现的并发容器,分别是 CopyOnWriteArrayList(后面会细讲,戳链接直达) 和 CopyOnWriteArraySet(不常用)。
ConcurrentSkipListSet 是线程安全的有序集合。底层是使用 ConcurrentSkipListMap 来实现。
谷歌的 Guava 实现了一个线程安全的 ConcurrentHashSet:
Set<String> s = Sets.newConcurrentHashSet();
Set 日常开发中用的并不多。
我们假设一种场景,生产者一直生产资源,消费者一直消费资源;资源存储在一个缓冲池中,生产者将生产的资源存进缓冲池中,消费者从缓冲池中拿到资源进行消费,这就是大名鼎鼎的生产者-消费者模式。
实现这个模式需要考虑以下几点:
线程安全
的队列访问方式,并发包下很多高级同步类的实现都是基于 BlockingQueue 实现的。阻塞队列提供了四组不同的方法用于插入、移除、检查元素,针对修改时出错可能的措施,可以选择不同的方法。
由数组结构组成的有界阻塞队列。内部结构是数组,具有数组的特性。
可以初始化队列大小,一旦初始化将不能改变。
由链表结构组成的有界阻塞队列。
该队列中的元素只有当其指定的延迟时间到了,才能够从队列中获取到该元素。
注入其中的元素必须实现 java.util.concurrent.Delayed 接口。
DelayQueue 是一个没有大小限制的队列,因此往队列中插入数据的操作(生产者)永远不会被阻塞,而只有获取数据的操作(消费者)才会被阻塞。
基于优先级的无界阻塞队列(优先级的判断通过构造函数传入的 Compator 对象来决定),内部控制线程同步的锁采用的是非公平锁。
这个队列比较特殊,没有任何内部容量,甚至连一个队列的容量都没有。并且每个 put 必须等待一个 take,反之亦然。