
1. 多线程并发问题概述
在java等多线程编程中,当多个线程尝试同时访问和修改同一个共享资源(例如打印输出、共享变量、文件等)时,如果没有适当的协调机制,就可能出现以下问题:
- 数据不一致: 线程A在修改数据过程中被线程B打断,线程B修改了数据,然后线程A继续修改,导致最终结果错误。
- 操作中断: 一个线程正在执行一系列相关操作,但另一个线程突然介入并执行了自己的操作,打断了前一个线程的完整性,例如在打印一段完整信息时,被其他线程的打印内容穿插。
- 竞态条件: 程序的行为取决于线程执行的相对顺序,这种不确定性使得程序难以调试和预测。
开发者有时会考虑使用线程优先级来解决这类问题,期望高优先级的线程能够优先完成任务。然而,Java的线程优先级机制并不可靠,它仅仅是对操作系统调度器的一种“建议”,具体效果高度依赖于操作系统和JVM的实现,不能保证线程的执行顺序或原子性。因此,对于确保关键操作的完整性,我们需要更强大的同步机制。
2. 解决方案:使用Java同步锁(synchronized)
Java提供了synchronized关键字作为实现同步的内置机制。synchronized可以用于修饰方法或代码块,它确保在任何给定时刻,只有一个线程可以执行被同步的代码。当一个线程进入synchronized代码块或方法时,它会获取一个对象的监视器锁(monitor lock)。其他试图进入相同同步块的线程将被阻塞,直到持有锁的线程退出同步块并释放锁。
2.1 synchronized 的工作原理
- 对象监视器: Java中的每个对象都可以作为一个锁。当一个线程进入synchronized代码块时,它会尝试获取与该对象关联的锁。
- 独占访问: 一旦一个线程成功获取了锁,其他试图获取相同锁的线程将被暂停(阻塞),直到锁被释放。
- 自动释放: 当线程退出synchronized代码块(无论是正常完成还是抛出异常),锁都会被自动释放。
2.2 使用共享锁对象进行同步
为了确保多个不相关的方法或代码段在访问同一个共享资源时能够互斥,最常见的做法是定义一个专门的、静态的、最终的锁对象。所有需要同步访问该共享资源的代码都应该同步在这个同一个锁对象上。
示例代码:
立即学习“Java免费学习笔记(深入)”;
假设我们有一个打印管理器,多个线程需要调用其中的打印方法。为了确保每次打印任务都能完整地输出,不被其他线程打断,我们可以使用一个共享的Object作为锁。
public class PrintManager {
// 定义一个静态的、最终的锁对象。
// 确保所有PrintManager实例以及所有线程都使用同一个锁来同步打印操作。
private static final Object printLock = new Object();
/**
* 执行一个打印任务,该方法被同步,确保在打印过程中不会被其他线程打断。
* @param message 要打印的内容
* @param times 打印的次数
*/
public void executePrintTask(String message, int times) {
// 使用synchronized块对printLock对象进行同步
// 任何时刻,只有一个线程能持有printLock并执行这段代码
synchronized (printLock) {
System.out.println("--- 任务开始: " + message + " ---");
for (int i = 0; i < times; i++) {
System.out.println("打印中: " + message + " - " + (i + 1));
try {
// 模拟打印耗时
Thread.sleep(50);
} catch (InterruptedException e) {
// 捕获中断异常,并重新设置中断标志
Thread.currentThread().interrupt();
System.err.println("打印任务 " + message + " 被中断。");
return; // 提前退出
}
}
System.out.println("--- 任务结束: " + message + " ---");
}
}
/**
* 另一个可能需要同步的打印方法
* 同样同步在printLock上,以保证与executePrintTask互斥
*/
public void specialPrint(String content) {
synchronized (printLock) {
System.out.println("--- 特殊打印开始 ---");
System.out.println(content);
System.out.println("--- 特殊打印结束 ---");
}
}
public static void main(String[] args) {
PrintManager manager = new PrintManager();
// 创建并启动多个线程,它们将尝试并发调用executePrintTask方法
new Thread(() -> manager.executePrintTask("文档A", 3), "线程-A").start();
new Thread(() -> manager.executePrintTask("报告B", 5), "线程-B").start();
new Thread(() -> manager.executePrintTask("日志C", 2), "线程-C").start();
// 也可以让一个线程调用specialPrint
new Thread(() -> manager.specialPrint("这是一条紧急通知!"), "线程-紧急").start();
}
}代码解释:
- private static final Object printLock = new Object();:我们声明了一个static final的Object实例作为锁。
- static:确保所有PrintManager的实例共享同一个锁对象,而不是每个实例有自己的锁。
- final:确保printLock引用一旦初始化后就不会改变,始终指向同一个锁对象。
- new Object():使用一个普通的Object实例作为锁,因为它除了作为锁之外没有其他业务含义,避免了潜在的副作用。
- synchronized (printLock) { ... }:这是同步代码块的语法。任何线程在执行这个代码块之前,都必须成功获取printLock对象的监视器锁。
- 当一个线程进入executePrintTask方法并执行到synchronized (printLock)时,它会尝试获取printLock的锁。如果锁当前没有被其他线程持有,它就获取锁并进入同步块执行打印操作。
- 如果另一个线程此时也尝试调用executePrintTask或specialPrint方法,并到达synchronized (printLock),它会发现printLock已经被第一个线程持有,因此它会被阻塞,直到第一个线程完成其打印任务并释放printLock。
- 这样就保证了executePrintTask和specialPrint内部的关键打印逻辑是原子性的,不会被其他线程的打印操作中断。
3. 注意事项与最佳实践
-
选择合适的锁对象:
- 不要使用String字面量或基本类型包装类作为锁: Java的字符串常量池和自动装箱机制可能导致你以为锁的是不同对象,但实际上却是同一个对象,或者反之,从而引发意想不到的死锁或同步失效。
- 推荐使用private static final Object: 这种方式创建的锁对象是唯一的、私有的,且不会被误用。
- 谨慎使用this作为锁: 如果同步的是实例方法,synchronized (this)会锁定当前实例。这可能导致不同实例之间无法同步,或者如果实例本身是共享的,外部代码也可能通过锁定该实例来影响你的同步,从而破坏封装性。
- synchronized方法: 等同于synchronized (this)(对于实例方法)或synchronized (ClassName.class)(对于静态方法)。
-
锁的粒度:
- 过粗的锁: 如果同步的代码块过大,包含了很多不必要的非共享资源操作,会导致并发性降低,因为线程会等待更长时间。
- 过细的锁: 如果同步的代码块过小,可能无法保证操作的原子性。应只同步那些必须互斥访问共享资源的代码。
- 最佳实践: 锁的粒度应尽可能小,只包含对共享资源进行操作的必要代码。
-
避免死锁:
- 当两个或多个线程互相持有对方所需的锁时,就会发生死锁。
- 避免死锁的关键是确保所有线程获取锁的顺序一致,或者使用java.util.concurrent.locks.ReentrantLock等高级锁,它们提供了更灵活的死锁检测和避免机制(如尝试获取锁、定时获取锁)。
-
性能考量:
4. 总结
在多线程编程中,确保共享资源的正确访问和操作的原子性是至关重要的。依赖线程优先级来控制执行顺序是不可靠的。Java的synchronized关键字提供了一种强大而直接的方式来实现线程同步。通过定义一个private static final Object作为共享锁,我们可以确保关键代码段的独占访问,有效防止线程间的干扰,从而构建出健壮、可靠的多线程应用程序。理解并正确运用synchronized是Java并发编程的基础。










