
本文深入探讨了在Java中利用`Semaphore`实现线程交替执行特定方法的同步机制。我们将分析一个常见的同步问题,即如何确保两个线程严格按照1-2-1-2的顺序打印输出,并详细解释原始代码中导致同步失败的陷阱——`Semaphore`实例的错误管理。最终,我们将提供一个经过优化的解决方案,并通过代码示例和最佳实践,指导开发者正确使用`Semaphore`进行精细化的线程协作。
1. 理解线程交替执行的需求
在多线程编程中,有时需要强制多个线程按照特定的顺序执行操作。一个典型的场景是,线程A执行完某项任务后,才允许线程B开始其任务;线程B完成后,再轮到线程A,如此循环往复。例如,我们希望两个线程分别打印数字“1”和“2”,最终输出序列为“121212...”。
实现这种精细的线程协作,Java提供了多种并发工具,其中Semaphore(信号量)是一种强大的选择,它通过管理许可数量来控制对共享资源的访问。
2. 初始尝试及问题分析
考虑以下使用Semaphore尝试实现“1212...”序列的代码:
立即学习“Java免费学习笔记(深入)”;
import java.util.concurrent.Semaphore;
public class SemTest {
Semaphore sem1 = new Semaphore(1); // 实例变量
Semaphore sem2 = new Semaphore(0); // 实例变量
public static void main(String args[]) {
final SemTest semTest1 = new SemTest(); // 第一个实例
final SemTest semTest2 = new SemTest(); // 第二个实例
new Thread() {
@Override
public void run() {
try {
semTest1.numb1(); // 线程1操作semTest1的实例变量
} catch (Exception e) {
throw new RuntimeException(e);
}
}
}.start();
new Thread() {
@Override
public void run() {
try {
semTest2.numb2(); // 线程2操作semTest2的实例变量
} catch (Exception e) {
throw new RuntimeException(e);
}
}
}.start();
}
private void numb1() {
while (true) {
try {
sem1.acquire(); // 获取semTest1的sem1许可
System.out.print("1");
sem2.release(); // 释放semTest1的sem2许可
Thread.sleep(100); // 适当缩短休眠时间,方便观察
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
private void numb2() {
while (true) {
try {
sem2.acquire(); // 获取semTest2的sem2许可
System.out.print("2");
sem1.release(); // 释放semTest2的sem1许可
Thread.sleep(100);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
}这段代码的预期是线程1打印“1”,然后通知线程2打印“2”,线程2打印“2”后,再通知线程1打印“1”,如此循环。然而,实际运行时,程序通常只打印一个“1”后就停止了。
问题根源: 核心问题在于SemTest类中的sem1和sem2是实例变量。在main方法中,我们创建了两个SemTest的实例:semTest1和semTest2。
- 第一个线程调用semTest1.numb1(),它操作的是semTest1实例内部的sem1和sem2。
- 第二个线程调用semTest2.numb2(),它操作的是semTest2实例内部的sem1和sem2。
这意味着两个线程各自拥有独立的、不共享的Semaphore对象集合。线程1释放的semTest1.sem2的许可,并不能被线程2在semTest2.sem2上获取。由于semTest2.sem2初始许可为0,线程2尝试acquire()时会一直阻塞,导致整个程序无法继续交替执行。同步机制失效,线程之间无法进行有效的协作。
3. 正确的Semaphore同步方案
为了实现线程间的协作,Semaphore对象必须是共享的。这意味着所有需要通过这些Semaphore进行协调的线程,都必须引用同一个Semaphore实例。
以下是修正后的代码示例,它通过将Semaphore实例在main方法中创建并作为局部变量传递给线程(或通过匿名内部类捕获),确保了所有线程都操作相同的Semaphore对象。
import java.util.concurrent.Semaphore;
public class CorrectedSemTest {
public static void main(String[] args) {
// 声明并初始化两个共享的Semaphore实例
// sem1初始许可为1,允许第一个线程立即执行
final Semaphore sem1 = new Semaphore(1);
// sem2初始许可为0,阻止第二个线程在第一个线程完成前执行
final Semaphore sem2 = new Semaphore(0);
// 线程1:负责打印"1"
new Thread(() -> {
try {
while (true) {
sem1.acquire(); // 等待获取sem1的许可
System.out.print("1"); // 打印"1"
sem2.release(); // 释放sem2的许可,允许线程2执行
Thread.sleep(100); // 模拟工作
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
System.out.println("Thread 1 interrupted.");
}
}, "Thread-1").start(); // 给线程命名方便调试
// 线程2:负责打印"2"
new Thread(() -> {
try {
while (true) {
sem2.acquire(); // 等待获取sem2的许可
System.out.print("2"); // 打印"2"
sem1.release(); // 释放sem1的许可,允许线程1再次执行
Thread.sleep(100); // 模拟工作
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
System.out.println("Thread 2 interrupted.");
}
}, "Thread-2").start(); // 给线程命名方便调试
}
}代码解析:
- 共享Semaphore实例: sem1和sem2被定义为main方法内的final局部变量。由于匿名内部类(Runnable的lambda表达式)可以访问final或“effectively final”的局部变量,这两个Semaphore实例被两个线程共享和操作。
-
初始化许可:
- sem1 = new Semaphore(1);:sem1初始有一个许可。这意味着第一个线程(打印“1”的线程)可以立即获取许可并开始执行。
- sem2 = new Semaphore(0);:sem2初始没有许可。这意味着第二个线程(打印“2”的线程)在尝试获取sem2的许可时会立即阻塞,直到有其他线程释放了sem2的许可。
-
线程1 (Thread-1) 的逻辑:
- sem1.acquire();:获取sem1的许可。由于初始有1个许可,线程1可以顺利通过。
- System.out.print("1");:打印“1”。
- sem2.release();:释放sem2的一个许可。此时sem2的许可数变为1,允许等待中的线程2继续执行。
-
线程2 (Thread-2) 的逻辑:
- sem2.acquire();:等待获取sem2的许可。在线程1释放许可后,sem2有了1个许可,线程2可以获取并继续。
- System.out.print("2");:打印“2”。
- sem1.release();:释放sem1的一个许可。此时sem1的许可数变为1,允许等待中的线程1再次执行。
通过这种精确的acquire()和release()交替操作,两个线程得以严格按照“121212...”的顺序协作执行。
4. 注意事项与最佳实践
- 共享性原则: 任何用于线程间同步的工具(如Semaphore、Lock、Condition等)都必须是所有参与同步的线程都能访问到的同一个实例。这是多线程协作的基础。
- 初始许可的重要性: Semaphore的初始许可数量决定了哪个线程或多少个线程可以首先进入临界区。在本例中,sem1的1个许可确保了线程1优先开始。
- acquire()与release()的配对: 务必确保acquire()和release()操作是成对出现的,并且逻辑正确。错误的配对会导致死锁或意外的并发行为。
- 异常处理: 在acquire()方法中,通常需要捕获InterruptedException。当线程被中断时,应根据业务逻辑决定是重新尝试、清理资源还是退出。通常的最佳实践是重新设置中断标志:Thread.currentThread().interrupt();。
- 可读性与维护性: 对于更复杂的同步场景,可以考虑将同步逻辑封装到独立的类或方法中,以提高代码的可读性和可维护性。例如,可以创建Worker1和Worker2类,并在其构造函数中传入共享的Semaphore实例。
5. 总结
通过本教程,我们深入探讨了如何利用Java的Semaphore实现线程间的精确交替执行。关键在于确保所有参与同步的线程都操作同一个共享的Semaphore实例。错误的Semaphore实例化(如每个线程拥有独立的Semaphore实例)是导致同步失败的常见陷阱。理解并正确应用Semaphore的共享性原则和许可机制,是编写健壮、高效并发程序的关键。










