Java线程同步全解:如何确保多个线程“井然有序”地执行任务
在 Java 多线程编程中,线程同步是一个绕不开的话题。当多个线程同时操作共享资源时,如果没有做好同步,程序可能会出现一些难以预料的问题,比如数据不一致、脏读等。今天,我们就来聊聊 Java 中的线程同步,看看如何确保多个线程能够“井然有序”地执行任务。
1. 为什么需要线程同步?
多线程的一个重要优势是并发执行任务,提升程序的效率。但是当多个线程同时访问和修改共享资源时,问题就来了。如果不同线程同时读写同一变量或共享数据,而没有妥善的同步机制,可能会导致数据出现不一致的情况。
举个例子:假设有一个银行账户,它的余额是共享资源,两个线程分别执行存款和取款操作。如果没有同步机制,可能导致以下问题:
线程A读取余额为100元;线程B也读取余额为100元;线程A执行存款操作,余额变为150元;线程B则基于之前读取的余额执行取款操作,余额被错误地修改为50元。
由于线程之间的操作没有得到很好的协调,这种数据不一致的问题就是典型的竞态条件(Race Condition)。线程同步的目的,就是为了解决这种情况,保证多个线程对共享资源的访问和修改是有序的,不会相互干扰。
2. 使用 synchronized 关键字
在 Java 中,最简单、最常用的同步机制就是 synchronized 关键字。它可以保证同一时间内,只有一个线程可以访问 synchronized 修饰的代码块或方法,其他线程必须等待该线程执行完毕后才能继续。
2.1. 同步方法
将方法声明为 synchronized,可以确保一次只有一个线程能够执行该方法。
public class BankAccount {
private int balance = 100;
public synchronized void deposit(int amount) {
balance += amount;
System.out.println("Deposited " + amount + ", New Balance: " + balance);
}
public synchronized void withdraw(int amount) {
balance -= amount;
System.out.println("Withdrew " + amount + ", New Balance: " + balance);
}
}
在这个例子中,deposit() 和 withdraw() 方法都被声明为同步方法,这意味着同一时刻只能有一个线程访问它们,其他线程会被阻塞,直到当前线程执行完成。
2.2. 同步代码块
有时,可能只需要同步部分代码,而不是整个方法。这时,可以使用同步代码块,这样可以提升效率,减少锁的粒度。
public class BankAccount {
private int balance = 100;
public void deposit(int amount) {
synchronized(this) {
balance += amount;
System.out.println("Deposited " + amount + ", New Balance: " + balance);
}
}
public void withdraw(int amount) {
synchronized(this) {
balance -= amount;
System.out.println("Withdrew " + amount + ", New Balance: " + balance);
}
}
}
this 代表的是当前对象,即在调用 deposit() 或 withdraw() 时,线程会锁住当前对象实例,确保该对象的这部分代码只能由一个线程执行。
3. ReentrantLock——更灵活的锁
虽然 synchronized 是最常用的同步机制,但 Java 还提供了更灵活的同步机制:ReentrantLock。它是 java.util.concurrent.locks 包中的类,提供了比 synchronized 更高级的锁功能,比如可以尝试获取锁、定时获取锁、中断获取锁等。
3.1. 基本使用
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class BankAccount {
private int balance = 100;
private final Lock lock = new ReentrantLock();
public void deposit(int amount) {
lock.lock();
try {
balance += amount;
System.out.println("Deposited " + amount + ", New Balance: " + balance);
} finally {
lock.unlock();
}
}
public void withdraw(int amount) {
lock.lock();
try {
balance -= amount;
System.out.println("Withdrew " + amount + ", New Balance: " + balance);
} finally {
lock.unlock();
}
}
}
在这里,我们使用 ReentrantLock 替代了 synchronized。lock() 方法获取锁,而 unlock() 方法释放锁。为了确保即使在发生异常的情况下也能够正确释放锁,通常我们会把 unlock() 放在 finally 块中。
3.2. tryLock()——非阻塞的获取锁
ReentrantLock 的另一个好处是可以使用 tryLock() 方法,尝试获取锁,而不是一直等待锁被释放。
public void deposit(int amount) {
if (lock.tryLock()) {
try {
balance += amount;
System.out.println("Deposited " + amount + ", New Balance: " + balance);
} finally {
lock.unlock();
}
} else {
System.out.println("Unable to acquire lock, deposit failed.");
}
}
如果锁没有被其他线程持有,tryLock() 会返回 true,否则它会立即返回 false,避免线程长时间等待锁。
4. 线程同步的其他工具
4.1. volatile 关键字
volatile 是一种轻量级的同步机制,它可以保证线程对变量的可见性。也就是说,当一个线程修改了 volatile 变量的值,其他线程能够立即看到更新的值,而不会使用缓存的值。
public class SharedData {
private volatile boolean flag = false;
public void setFlag(boolean flag) {
this.flag = flag;
}
public boolean isFlag() {
return flag;
}
}
但是 volatile 只保证可见性,不保证原子性。因此,volatile 通常用于状态标记或者简单的读写操作,不能代替 synchronized 来处理复杂的同步需求。
4.2. CountDownLatch
CountDownLatch 是一种非常有用的同步工具类,允许一个或多个线程等待其他线程完成操作。它的原理是设定一个计数器,计数器的值由线程逐个减少,直到减到 0,所有等待的线程才能继续执行。
import java.util.concurrent.CountDownLatch;
public class Worker implements Runnable {
private final CountDownLatch latch;
public Worker(CountDownLatch latch) {
this.latch = latch;
}
@Override
public void run() {
System.out.println(Thread.currentThread().getName() + " is working");
latch.countDown(); // Decrement the count
}
}
CountDownLatch 通常用于一些初始化任务,所有线程准备就绪后再统一开始执行。
5. 总结
线程同步是多线程编程中的一项核心技能,目的是确保多个线程之间能够安全、有效地共享数据。在 Java 中,常见的同步工具包括:
synchronized:简单易用的同步机制,但限制较大。ReentrantLock:灵活的锁机制,适用于复杂场景。volatile:保证变量的可见性,但不保证操作的原子性。CountDownLatch:允许线程之间协调执行顺序。
选择合适的同步机制,可以帮助我们编写更加高效和健壮的并发程序。掌握这些工具,你就能在多线程世界中游刃有余,轻松解决并发难题。