线程间通信
等待与通知机制
1. Object
类的wait()
、notify()
、notifyAll()
方法
-
wait()
方法- 线程调用该方法后,会释放持有的对象锁,进入等待状态,直到被其他线程调用
notify()
或notifyAll()
方法唤醒。 - 必须在同步块或同步方法中调用,否则会抛出
IllegalMonitorStateException
异常。
- 线程调用该方法后,会释放持有的对象锁,进入等待状态,直到被其他线程调用
-
notify()
方法- 唤醒在此对象上等待的单个线程(如果有)。
- 选择具体线程的方式是由JVM决定的,不受程序控制。
- 必须在同步块或同步方法中调用,否则会抛出
IllegalMonitorStateException
异常。
-
notifyAll()
方法- 唤醒在此对象上等待的所有线程。
- 这些线程会竞争对象的锁,只有一个线程能成功持有锁并继续执行。
2. 使用等待/通知机制实现线程间通信
-
生产者-消费者模型
生产者-消费者模型是经典的多线程协作问题,下面是一个使用
wait()
和notifyAll()
实现的示例:import java.util.LinkedList; import java.util.Queue; public class ProducerConsumerExample { private static final int CAPACITY = 5; private final Queue<Integer> queue = new LinkedList<>(); private int value = 0; public static void main(String[] args) { ProducerConsumerExample example = new ProducerConsumerExample(); Thread producerThread = new Thread(example.new Producer(), "Producer"); Thread consumerThread = new Thread(example.new Consumer(), "Consumer"); producerThread.start(); consumerThread.start(); } class Producer implements Runnable { @Override public void run() { while (true) { synchronized (queue) { while (queue.size() == CAPACITY) { try { queue.wait(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); return; } } queue.offer(value++); queue.notifyAll(); } } } } class Consumer implements Runnable { @Override public void run() { while (true) { synchronized (queue) { while (queue.isEmpty()) { try { queue.wait(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); return; } } int val = queue.poll(); System.out.println("Consumed: " + val); queue.notifyAll(); } } } } }
解释代码:
-
同步块中的
wait()
:- 在生产者中,当队列已满时调用
queue.wait()
,让生产者线程等待,直到有空间可以生产新的产品。 - 在消费者中,当队列为空时调用
queue.wait()
,让消费者线程等待,直到有新的产品可以消费。
- 在生产者中,当队列已满时调用
-
同步块中的
notifyAll()
:- 在生产者中,当生产了新产品后调用
queue.notifyAll()
,通知等待的消费者线程可以继续消费。 - 在消费者中,当消费了产品后调用
queue.notifyAll()
,通知等待的生产者线程可以继续生产。
- 在生产者中,当生产了新产品后调用
-
线程同步和通信:
- 生产者和消费者都使用
queue
对象作为同步锁,确保对队列的访问是线程安全的。 - 通过
wait()
和notifyAll()
方法,生产者和消费者可以有效地进行线程间通信,避免忙等待,提高效率。
- 生产者和消费者都使用
这种机制在实际项目中非常重要,能够帮助我们设计出高效且安全的多线程应用程序。通过对这些方法的深入理解和应用,能够有效处理线程间的协调和通信问题。
Condition接口
Condition
接口提供了一种更灵活的等待/通知机制,与传统的Object
的wait()
、notify()
和notifyAll()
方法相比,Condition
可以与任意类型的锁配合使用,而不仅限于内置的监视器锁。Condition
常与Lock
接口结合使用。
内置监视器锁即使用sychronized时产生的锁,这个锁是被JVM管理的,而Lock接口的锁是用户管理的,比如利用redis管理的锁
Condition
接口的使用(与Lock
结合)
Condition
必须与Lock
结合使用,常用的Lock
实现是ReentrantLock
。Condition
对象由Lock
对象创建。- 使用
await()
代替wait()
,使用signal()
代替notify()
,使用signalAll()
代替notifyAll()
。
Condition
接口的主要方法
-
await()
:- 使当前线程等待,直到被通知(signal)或被中断。
- 当前线程必须持有与
Condition
关联的锁。 - 调用
await()
会释放锁,并且等待其他线程通知。
-
signal()
:- 唤醒一个等待在
Condition
上的线程。 - 唤醒的线程从
await()
方法返回并重新获取与Condition
关联的锁。
- 唤醒一个等待在
-
signalAll()
:- 唤醒所有等待在
Condition
上的线程。 - 唤醒的线程依次从
await()
方法返回并重新获取与Condition
关联的锁。
- 唤醒所有等待在
使用示例
下面是一个使用Condition
接口实现生产者-消费者模型的示例:
import java.util.LinkedList;
import java.util.Queue;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class ProducerConsumerWithCondition {
private static final int CAPACITY = 5;
private final Queue<Integer> queue = new LinkedList<>();
private final Lock lock = new ReentrantLock();
private final Condition notFull = lock.newCondition();
private final Condition notEmpty = lock.newCondition();
private int value = 0;
public static void main(String[] args) {
ProducerConsumerWithCondition example = new ProducerConsumerWithCondition();
Thread producerThread = new Thread(example.new Producer(), "Producer");
Thread consumerThread = new Thread(example.new Consumer(), "Consumer");
producerThread.start();
consumerThread.start();
}
class Producer implements Runnable {
@Override
public void run() {
while (true) {
lock.lock();
try {
while (queue.size() == CAPACITY) {
notFull.await();
}
queue.offer(value++);
System.out.println("Produced: " + (value - 1));
notEmpty.signal();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return;
} finally {
lock.unlock();
}
}
}
}
class Consumer implements Runnable {
@Override
public void run() {
while (true) {
lock.lock();
try {
while (queue.isEmpty()) {
notEmpty.await();
}
int val = queue.poll();
System.out.println("Consumed: " + val);
notFull.signal();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return;
} finally {
lock.unlock();
}
}
}
}
}
解释代码
-
锁定和解锁:
- 生产者和消费者在操作共享资源(队列)时都使用
lock.lock()
和lock.unlock()
来确保线程安全。
- 生产者和消费者在操作共享资源(队列)时都使用
-
等待和通知:
- 生产者在队列满时调用
notFull.await()
等待,直到有空余空间。 - 生产者在生产了新产品后调用
notEmpty.signal()
通知消费者有新产品可用。 - 消费者在队列为空时调用
notEmpty.await()
等待,直到有新产品可消费。 - 消费者在消费了产品后调用
notFull.signal()
通知生产者可以继续生产。
- 生产者在队列满时调用
-
灵活性和可扩展性:
Condition
接口提供了比Object
的wait()
和notify()
更灵活的线程通信机制,可以创建多个条件变量(如notFull
和notEmpty
),使得代码更具可读性和可维护性。
BlockingQueue
之前已经发过
线程调度和任务执行
Executor 框架
Java 的 Executor
框架提供了一种异步执行任务的机制,可以轻松地将任务的提交与执行策略分离。主要接口和类包括 Executor
、ExecutorService
和 ScheduledExecutorService
。
-
Executor 接口:
- 定义了一个单一的执行方法
execute(Runnable command)
,用于执行提交的任务。
Executor executor = new Executor() { @Override public void execute(Runnable command) { new Thread(command).start(); } }; executor.execute(() -> System.out.println("Task executed!"));
- 定义了一个单一的执行方法
-
ExecutorService 接口:
- 扩展了
Executor
接口,提供了管理终止的方法以及产生Future
以跟踪一个或多个异步任务的执行情况。
ExecutorService executorService = Executors.newFixedThreadPool(10); Future<String> future = executorService.submit(() -> { Thread.sleep(1000); return "Task completed!"; }); try { String result = future.get(); // 获取任务的结果 System.out.println(result); } catch (InterruptedException | ExecutionException e) { e.printStackTrace(); } finally { executorService.shutdown(); // 关闭 ExecutorService }
- 扩展了
-
ScheduledExecutorService 接口:
- 提供了在给定的延迟后运行或定期执行任务的方法。
ScheduledExecutorService scheduledExecutorService = Executors.newScheduledThreadPool(5); scheduledExecutorService.schedule(() -> System.out.println("Task executed after delay"), 5, TimeUnit.SECONDS); scheduledExecutorService.scheduleAtFixedRate(() -> System.out.println("Periodic task executed"), 0, 10, TimeUnit.SECONDS);
Fork/Join 框架
处理大任务的分解和并行执行
Fork/Join
框架是 Java 7 引入的,用于并行执行任务,通过将大任务分解成多个小任务并行处理来提高性能。ForkJoinPool
类是核心,使用 ForkJoinTask
的子类 RecursiveTask
(有返回值)和 RecursiveAction
(无返回值)来实现任务。
-
ForkJoinPool:
ForkJoinPool
是一个特殊的线程池,设计用于执行大量的短小任务,使用工作窃取算法来平衡负载。
-
RecursiveTask(有返回值):
- 通过继承
RecursiveTask
类来定义任务,并在compute()
方法中实现任务的分解和合并。
import java.util.concurrent.RecursiveTask; import java.util.concurrent.ForkJoinPool; public class FibonacciTask extends RecursiveTask<Integer> { private final int n; public FibonacciTask(int n) { this.n = n; } @Override protected Integer compute() { if (n <= 1) { return n; } FibonacciTask f1 = new FibonacciTask(n - 1); f1.fork(); // 分解任务并异步执行 FibonacciTask f2 = new FibonacciTask(n - 2); return f2.compute() + f1.join(); // 计算任务并等待结果 } public static void main(String[] args) { ForkJoinPool pool = new ForkJoinPool(); FibonacciTask task = new FibonacciTask(10); int result = pool.invoke(task); System.out.println("Fibonacci(10) = " + result); } }
- 通过继承
-
RecursiveAction(无返回值):
- 类似于
RecursiveTask
,但compute()
方法没有返回值。
import java.util.concurrent.RecursiveAction; import java.util.concurrent.ForkJoinPool; public class SimpleAction extends RecursiveAction { private final int workload; public SimpleAction(int workload) { this.workload = workload; } @Override protected void compute() { if (workload > 1) { SimpleAction subtask1 = new SimpleAction(workload / 2); SimpleAction subtask2 = new SimpleAction(workload / 2); invokeAll(subtask1, subtask2); } else { System.out.println("Workload: " + workload); } } public static void main(String[] args) { ForkJoinPool pool = new ForkJoinPool(); SimpleAction action = new SimpleAction(10); pool.invoke(action); } }
- 类似于
总结
-
Executor 框架:
Executor
:提供基础的任务执行机制。ExecutorService
:管理线程池和任务提交,提供Future
接口。ScheduledExecutorService
:处理延迟和周期性任务。
-
Fork/Join 框架:
- 适用于分解和并行处理大任务,通过
ForkJoinPool
和ForkJoinTask
的子类(如RecursiveTask
和RecursiveAction
)实现高效并行计算。
- 适用于分解和并行处理大任务,通过
工作窃取算法
工作窃取算法(Work Stealing Algorithm)是一种用于并行计算的负载均衡策略。该算法的基本思想是:当一个线程完成了自己任务队列中的所有任务时,它会窃取其他线程任务队列中的任务来执行。这种方式可以有效地平衡各个线程的工作负载,从而提高多核处理器的利用率。
工作窃取算法的原理
-
任务分割:
- 将大任务分割成多个小任务,每个小任务可以独立执行。
-
双端队列:
- 每个线程都有一个双端队列(Deque),线程从队列的一端(通常是末尾)取任务来执行。
-
窃取任务:
- 当一个线程完成了自己队列中的任务,它会去尝试从其他线程的队列头部窃取任务来执行。
-
负载均衡:
- 通过窃取任务,可以使得各个线程的工作量大致均衡,避免某些线程过于忙碌而其他线程空闲的情况。
Java中的工作窃取算法
在Java中,工作窃取算法主要体现在ForkJoinPool
类中。ForkJoinPool
是一个线程池实现,适用于大规模并行任务的执行。它使用工作窃取算法来实现任务的负载均衡。
ForkJoinPool
的实现
ForkJoinPool
使用了一个专门设计的双端队列ForkJoinTask
来存放任务,每个工作线程都有自己的任务队列。当一个工作线程完成了自己队列中的任务时,它会去窃取其他工作线程的任务队列中的任务。
import java.util.concurrent.ForkJoinPool;
import java.util.concurrent.RecursiveTask;
public class WorkStealingExample {
static class FibonacciTask extends RecursiveTask<Integer> {
private final int n;
public FibonacciTask(int n) {
this.n = n;
}
@Override
protected Integer compute() {
if (n <= 1) {
return n;
}
FibonacciTask f1 = new FibonacciTask(n - 1);
FibonacciTask f2 = new FibonacciTask(n - 2);
f1.fork(); // 分解任务并异步执行
return f2.compute() + f1.join(); // 计算任务并等待结果
}
}
public static void main(String[] args) {
ForkJoinPool pool = new ForkJoinPool();
FibonacciTask task = new FibonacciTask(10);
int result = pool.invoke(task);
System.out.println("Fibonacci(10) = " + result);
}
}
工作窃取算法的优势
-
高效利用多核处理器:
- 工作窃取算法通过动态负载均衡,充分利用多核处理器的计算能力。
-
简化编程模型:
- 通过任务的递归分解和窃取执行,程序员可以更容易地编写并行算法。
-
减少线程间的锁争用:
- 每个线程主要操作自己的任务队列,只有在需要窃取任务时才会访问其他线程的队列,从而减少了锁争用和同步开销。
工作窃取算法的挑战
-
任务分解的粒度:
- 如果任务分解得过细,会导致任务调度和管理的开销增加;如果任务分解得过粗,会影响并行执行的效率。
-
线程间通信开销:
- 虽然工作窃取算法减少了锁争用,但线程间的任务窃取仍然会有一定的通信和同步开销。
-
任务窃取的代价:
- 任务窃取的频率和代价需要合理平衡,否则可能会影响整体性能。
并行计算常见负载策略
在并行计算中,负载均衡策略是确保任务在多个处理器上均匀分布,以提高效率和性能。除了工作窃取算法,以下是一些常见的负载均衡策略:
1. 静态负载均衡
定义: 在任务执行之前,任务的分配就已经确定,不会在执行过程中动态调整。
适用场景: 任务数量和执行时间已知且固定。
方法
- 轮询法(Round Robin):
- 任务按顺序分配给处理器,每个处理器轮流获取任务。
- 随机分配(Random Assignment):
- 任务随机分配给处理器。
- 任务划分(Task Partitioning):
- 根据任务的特性进行预先划分,分配到各处理器。
2. 动态负载均衡
定义: 在任务执行过程中,任务的分配可以动态调整,以应对任务负载的不均衡。
适用场景: 任务执行时间不可预测或任务数量动态变化。
方法
- 集中式调度(Centralized Scheduling):
- 由一个中央调度器负责监控和分配任务,确保所有处理器负载均衡。
- 分布式调度(Distributed Scheduling):
- 每个处理器都有一定的自主权,可以根据自身负载情况向其他处理器请求或转移任务。
3. 递归分割(Recursive Bisection)
定义: 将任务递归地分割成更小的子任务,并分别分配到不同的处理器。
适用场景: 适用于多层次分解的任务,如矩阵乘法和快速傅里叶变换。
4. 图划分(Graph Partitioning)
定义: 将任务表示为图结构,节点表示任务,边表示任务之间的依赖关系,然后对图进行划分以最小化跨处理器边的数量。
适用场景: 任务间有复杂依赖关系的并行计算,如有限元分析。
5. 随机游走(Random Walk)
定义: 通过随机游走的方式动态调整任务分配,使得任务逐步趋向均匀分布。
适用场景: 任务之间相互独立,且每个任务执行时间相似。
6. 区域划分(Domain Decomposition)
定义: 将计算域分成若干子域,每个子域分配给一个处理器,并通过边界条件进行交互。
适用场景: 数值模拟和科学计算,如气象模拟和流体动力学。
7. 基于代理的调度(Agent-Based Scheduling)
定义: 使用智能代理监控和调整任务负载,根据任务和处理器的状态动态进行任务分配。
适用场景: 任务复杂且动态变化的场景,如云计算和分布式计算。
8. 需求驱动调度(Demand-Driven Scheduling)
定义: 任务根据需求动态请求计算资源,资源根据任务负载和优先级进行分配。
适用场景: 资源有限且任务负载不均的场景,如实时系统和分布式数据库。
9. 基于机器学习的负载均衡
定义: 使用机器学习算法预测任务负载和处理器性能,动态调整任务分配以达到最佳负载均衡。
适用场景: 负载模式复杂且变化频繁的场景,如大数据处理和人工智能计算。