作为 Java 开发者,我们经常会听到“线程安全”这个词。在构建高并发应用时,如何确保多个线程安全地访问共享资源,是我们必须面对的核心挑战。如果我们处理不当,就可能会导致脏读、数据不一致,甚至系统崩溃。在 Java 中,监视器(Monitor)便是解决这一问题的基石,它是一种内置的同步机制,能够帮助我们轻松地管理并发。
在这篇文章中,我们将深入探讨 Java 监视器的内部工作原理。我们将通过具体的代码示例,一步步演示它如何通过互斥和协作来保证线程安全。无论你是刚接触并发编程的新手,还是希望巩固基础的老手,这篇文章都将为你提供实用的见解和最佳实践。
目录
什么是监视器(Monitor)?
在 Java 中,监视器通常被理解为一种同步机制,它用于控制对共享资源的并发访问。你可以把它想象成一个专门的房间,这个房间里有特定的资源(比如数据或代码),而房间同一时间只允许一个人进入。
当我们使用 synchronized 关键字时,其实我们就是在利用 Java 的监视器机制。它的主要目的是确保临界区(即访问共享资源的代码段)在同一时刻只能被一个线程执行。这就好比我们在现实生活中使用洗手间,当一个人进去并锁上门(获取监视器锁)后,其他人必须在外面排队(等待),直到里面的人出来并释放锁。
让我们来看看当一个线程试图进入同步块时,底层究竟发生了什么:
- 请求监视器:线程请求访问特定对象的监视器。
- 获取与执行:如果监视器是空闲的(计数为0),线程就会获取它,并将计数器加1,然后开始执行同步代码。
- 阻塞与等待:如果另一个线程已经持有了这个监视器,新线程就会被阻塞,被迫进入等待队列,直到锁被释放。
- 释放锁:当线程执行完同步代码或发生异常时,它会自动释放监视器,允许等待队列中的其他线程尝试获取。
监视器的核心概念
要真正掌握监视器,我们需要理解两个核心概念:互斥和协作。让我们逐一拆解。
1. 互斥
互斥是监视器最基本的功能。它确保在同一时间,只有一个线程能执行受保护的代码段。这对于保护共享状态至关重要。例如,如果两个线程同时尝试更新同一个银行账户余额,如果不加控制,最终的结果可能会出现错误。
2. 线程协作
除了互斥,监视器还提供了线程间通信的机制。在某些场景下,线程不仅仅是竞争锁,还需要相互配合。比如经典的“生产者-消费者”模型:如果缓冲区满了,生产者必须等待消费者取走数据;如果缓冲区空了,消费者必须等待生产者放入数据。
Java 通过以下三个方法来实现这种协作(注意,这些方法必须在 synchronized 块或方法中调用):
-
wait():当前线程释放监视器锁,并进入等待状态,直到其他线程唤醒它。 -
notify():唤醒一个正在等待该监视器的线程(具体是哪一个由 JVM 调度器决定)。 -
notifyAll():唤醒所有正在等待该监视器的线程,让它们竞争锁。
实战示例:使用 synchronized 实现互斥
让我们通过代码来巩固上面的理论。在这个例子中,我们将创建一个共享资源 INLINECODE7aa3952e,它包含一个打印乘法表的方法。我们将使用 INLINECODE4154ab86 块来确保同一时间只有一个线程能打印。
class SharedResource {
void printTable(int n) {
// 获取当前对象的监视器锁
// synchronized (this) 确保同一时刻只有一个线程能进入这个代码块
synchronized(this) {
for (int i = 1; i <= 5; i++) { // 循环次数增加到5以便更清晰观察
System.out.println(Thread.currentThread().getName() + " 打印: " + n * i);
try {
Thread.sleep(500); // 模拟耗时操作
} catch (InterruptedException e) {
System.out.println(e.getMessage());
}
}
}
}
}
class MyThread1 extends Thread {
SharedResource resource;
MyThread1(SharedResource resource, String name) {
super(name);
this.resource = resource;
}
@Override
public void run() {
resource.printTable(5);
}
}
class MyThread2 extends Thread {
SharedResource resource;
MyThread2(SharedResource resource, String name) {
super(name);
this.resource = resource;
}
@Override
public void run() {
resource.printTable(10);
}
}
public class Main {
public static void main(String[] args) {
SharedResource obj = new SharedResource();
// 两个线程共享同一个对象资源
MyThread1 t1 = new MyThread1(obj, "线程-1");
MyThread2 t2 = new MyThread2(obj, "线程-2");
t1.start();
t2.start();
}
}
代码深度解析
在这个例子中,你可以看到 synchronized(this) 的关键作用。
- 加锁:当 INLINECODEa5121ca3 线程进入 INLINECODE4a469a9e 方法并遇到 INLINECODE803e130f 时,它检查 INLINECODEcffd3586 对象的监视器是否可用。因为它是第一个到达的,所以它获取了锁。
- 执行:INLINECODE8ec0c4be 开始执行打印循环。在此期间,即使 INLINECODE977a44ee 也在运行并调用了 INLINECODEecd4290c,当 INLINECODE118f2c4d 尝试获取同一个对象
obj的监视器时,它会被阻塞,必须等待。 - 释放:只有当 INLINECODE70f3c7ed 完成整个循环并退出 INLINECODE97e93d07 块后,监视器才被释放。此时
t2才有机会获取锁并执行。
输出示例:
线程-1 打印: 5
线程-1 打印: 10
线程-1 打印: 15
线程-1 打印: 20
线程-1 打印: 25
线程-2 打印: 10
线程-2 打印: 20
... (后续输出)
注意:一个线程的任务会完整打印完毕,另一个线程才会开始。这就是互斥的直观体现。如果没有 synchronized,两个线程的输出将会交错在一起,导致混乱。
进阶示例:线程协作与 wait/notify
光有互斥是不够的。在实际开发中,我们经常需要线程间进行配合。下面的例子模拟了一个简单的银行转账场景。账户需要余额充足才能转账,否则取款线程必须等待,直到存款线程存入钱。
class BankAccount {
private int balance = 0;
// 存款:操作完成后通知等待的线程
public synchronized void deposit(int amount) {
System.out.println(Thread.currentThread().getName() + " 准备存款 " + amount);
balance += amount;
System.out.println("余额更新为: " + balance);
// 通知正在等待取款的线程:钱来了
this.notify();
}
// 取款:如果余额不足则等待
public synchronized void withdraw(int amount) {
System.out.println(Thread.currentThread().getName() + " 试图取款 " + amount);
// 使用 while 循环防止虚假唤醒
while (balance account.withdraw(1000), "取款人-A");
// 线程2:稍后存入钱款,唤醒线程1
Thread t2 = new Thread(() -> account.deposit(1500), "存款人-B");
t1.start();
// 让取款线程先跑起来,确保它先进入 wait 状态
try { Thread.sleep(100); } catch (InterruptedException e) {}
t2.start();
}
}
为什么要用 while 循环检查条件?
你可能会注意到,我们在 INLINECODE9a58ce8f 方法中使用了 INLINECODE3a2b3e73 而不是 if。这是一个非常重要的最佳实践。
虽然 INLINECODE6280c67c 唤醒了等待的线程,但并不意味着条件就一定满足了。有时候可能会发生“虚假唤醒”(Spurious Wakeup),操作系统或 JVM 可能会在没有收到 INLINECODEd81df601 的情况下唤醒线程。如果我们使用 INLINECODE0ad70017,线程被虚假唤醒后就会直接执行后续的取款操作,导致余额再次变负。使用 INLINECODE1a3458eb 循环可以确保线程被唤醒后,再次检查条件,安全性大大增加。
监视器 vs 显式锁
随着 Java 的发展,并发工具包提供了更强大的工具。了解监视器和显式锁的区别,能帮助我们做出更好的技术选型。
监视器
—
依赖于 JVM 内置的 INLINECODE437d495e 关键字和对象头。
ReentrantLock)。 由 JVM 自动管理锁的获取和释放。代码块执行完或抛异常时,锁会自动释放。
finally 块中释放锁以防止死锁。 语法简单,但功能相对固定。无法在获取锁时设置超时,也无法中断锁的获取过程。
在 JDK 1.6 之后进行了大量优化(如偏向锁、轻量级锁),性能已经非常优异。
使用对象内置的 INLINECODEd7e772e3 和 INLINECODEe92501a2。
Condition 对象,支持多个等待队列,可以实现更精细的线程间通知。 优化建议与最佳实践
在我们的日常开发中,为了写出高效且安全的并发代码,以下是一些经验之谈:
- 锁的范围尽量小:虽然我们需要同步来保护数据,但
synchronized块的范围应尽可能小。只包含那些必须原子操作的代码,把不需要同步的逻辑(如耗时的计算或 I/O 操作)移出同步块。
- 避免在锁内调用外部方法:如果在持有锁的状态下调用了你不确定的外部代码(比如第三方库的方法),这可能会导致死锁或极其严重的性能下降。
- 考虑使用并发集合:对于简单的共享变量,INLINECODEac331f5f 等原子类比 INLINECODE67fc2a2f 性能更好;对于集合操作,优先考虑 INLINECODE1f482cd1 等 INLINECODE1ee73e14 包下的类,而不是自己使用
synchronized去包装 HashMap。
- 永远不要在锁上进行字符串常量拼接:如果你在
synchronized ("StringLiteral")中使用字符串字面量作为锁对象,整个 JVM 中所有使用相同字面量的地方都会共用同一个锁,这会导致极其隐蔽的 Bug。
总结
今天,我们一起深入探索了 Java 监视器的奥秘。我们了解到,监视器不仅仅是一个简单的“锁”,它是 JVM 为我们提供的同步基础设施,涵盖了互斥与协作两个核心维度。
我们通过示例学习了 INLINECODE2a9203c7 如何自动管理对象锁,以及如何利用 INLINECODE311f0663 和 INLINECODE898005c8 实现线程间优雅的沟通。虽然现代 Java 开发中有了 INLINECODEbfcda120 等更灵活的工具,但理解并掌握监视器机制,依然是每一位 Java 程序员必修的内功。只有在理解了底层原理之后,我们才能在处理复杂并发问题时游刃有余。
希望这篇文章能帮助你更好地理解 Java 并发编程。现在,你可以尝试在自己的项目中审视一下那些多线程代码,看看是否可以用今天学到的知识来优化它们。