synchronized关键字与ReentrantLock的区别和应用
简介
你在一个咖啡店里,有一台唯一的咖啡机,顾客们需要排队使用这台咖啡机。这台咖啡机就像是一个共享资源,而synchronized关键字和ReentrantLock都是确保顾客能有序使用咖啡机的机制。
synchronized关键字:
想象synchronized就像是咖啡店的一个规矩:当一个顾客正在使用咖啡机时,其他顾客必须等待。这个规矩是由咖啡店自动强制执行的,顾客们不需要额外做什么,只需等待前面的人用完。当顾客开始使用咖啡机时,他们就自动获得了使用权,完成后也会自动释放,让下一个顾客使用。这个过程很简单,但顾客们不能决定等待多久,也不能尝试中途放弃等待。
ReentrantLock:
现在想象ReentrantLock是咖啡店提供的一个可选服务,它更像是一个高级的取号系统。当顾客进入咖啡店时,他们可以选择拿一个号码牌,这样他们就知道轮到自己的顺序了。使用ReentrantLock,顾客可以决定他们是否愿意等待(可以设置尝试获取锁的时间),或者在等待太久后选择放弃并离开咖啡店。此外,这个取号系统还允许顾客在等待时做一些其他事情(比如读书或使用手机),这就是ReentrantLock的可中断锁定特性。ReentrantLock还允许顾客按照一些公平的规则排队,比如“先来后到”,但这可能会稍微减慢整个流程。
区别和应用:
- 简易性 vs. 灵活性:synchronized是嵌入在Java语言中的,使用起来非常简单,不需要程序员做太多的管理工作。相比之下,ReentrantLock提供了更多的灵活性,比如可中断的锁获取、定时锁等待和公平锁选项。
- 自动释放锁 vs. 手动释放锁:使用synchronized块或方法时,JVM会自动管理锁的获取和释放。而使用ReentrantLock时,程序员必须手动调用.lock()来获取锁,并在finally块中调用.unlock()来释放锁,这样可以避免潜在的锁泄漏。
- 条件变量:ReentrantLock还提供了条件变量(Condition),这相当于咖啡店中的额外通知机制,允许顾客在特定条件下等待或接收通知,这在synchronized中是不可用的。
在选择使用synchronized还是ReentrantLock时,如果你需要简单的同步机制,不需要额外的特性,那么synchronized是一个很好的选择。如果你需要更高级的功能,比如锁的公平性、可中断的锁等待,或者条件变量,那么ReentrantLock可能是更合适的选择。
代码演示
案例一:使用synchronized关键字的银行账户转账
假设我们有一个银行账户类,需要确保在进行转账操作时不会出现并发问题。我们使用synchronized关键字来同步访问共享资源,即账户余额。
public class BankAccount { private double balance; // 账户余额 public BankAccount(double initialBalance) { this.balance = initialBalance; } // 同步方法,保证同时只有一个线程可以执行该方法 public synchronized void deposit(double amount) { balance += amount; } // 同步方法,保证同时只有一个线程可以执行该方法 public synchronized void withdraw(double amount) { balance -= amount; } // 转账方法也是同步的,防止并发问题 public synchronized void transfer(BankAccount toAccount, double amount) { this.withdraw(amount); // 从当前账户扣除金额 toAccount.deposit(amount); // 向目标账户存入金额 } // 获取账户余额,同步方法保护余额的读取 public synchronized double getBalance() { return balance; } }
在这个案例中,我们使用synchronized修饰符来确保deposit、withdraw、transfer和getBalance方法在执行时,同一时刻只有一个线程能够访问该对象的这些同步方法。
当一个线程调用BankAccount对象的方法时,如deposit、withdraw、transfer或getBalance,以下是代码的运行过程:
- 同步方法调用:
- 当线程调用deposit、withdraw、transfer或getBalance方法时,由于这些方法都使用了synchronized修饰符,同一时刻只有一个线程可以执行这些方法。其他线程需要等待当前线程执行完毕后才能进入这些方法。
- deposit方法:
- 当一个线程调用deposit方法时,它会获取BankAccount对象的锁,然后执行balance += amount;操作,增加账户余额。
- 在执行完balance += amount;操作后,释放BankAccount对象的锁。
- withdraw方法:
- 当一个线程调用withdraw方法时,它会获取BankAccount对象的锁,然后执行balance -= amount;操作,减少账户余额。
- 在执行完balance -= amount;操作后,释放BankAccount对象的锁。
- transfer方法:
- 当一个线程调用transfer方法时,它会依次执行this.withdraw(amount);和toAccount.deposit(amount);两个操作。
- 由于这两个操作都是同步方法,因此在执行this.withdraw(amount);和toAccount.deposit(amount);时,同一时刻只有一个线程能够执行这些操作,从而避免了并发问题。
- getBalance方法:
- 当一个线程调用getBalance方法时,它会获取BankAccount对象的锁,然后读取账户余额并返回。
- 在读取完账户余额后,释放BankAccount对象的锁。
总结:通过synchronized修饰符,我们确保了对BankAccount对象的各个方法的访问是线程安全的,即在同一时刻只有一个线程能够执行这些方法,从而避免了并发访问导致的数据不一致性问题。
案例二:使用ReentrantLock的打印队列
假设我们有一个打印队列,多个用户可能会同时发送打印任务,我们使用ReentrantLock来同步任务的提交。
import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; public class PrintQueue { private final Lock queueLock = new ReentrantLock(); public void printJob(Object document) { queueLock.lock(); // 获取锁 try { // 模拟打印任务需要一段时间 long duration = (long) (Math.random() * 10000); System.out.println(Thread.currentThread().getName() + ": PrintQueue: Printing a job during " + (duration / 1000) + " seconds"); Thread.sleep(duration); } catch (InterruptedException e) { e.printStackTrace(); } finally { queueLock.unlock(); // 确保锁被释放 } } }
代码的运行过程:
- 首先,我们定义了一个名为 PrintQueue 的类,其中包含一个名为 queueLock 的 ReentrantLock 对象,用于控制对打印队列的访问。
- printJob 方法是一个模拟打印任务的方法。在这个方法中,我们首先调用 queueLock.lock() 来获取锁,确保只有一个线程可以访问打印队列。
- 然后,我们模拟了打印任务需要一段时间,使用 Thread.sleep 方法来让当前线程休眠一段随机时间,模拟打印任务的耗时。
- 在 try 块中,我们使用 Thread.sleep 来模拟打印任务的时间,并在控制台打印出当前线程的名称以及打印任务的耗时。
- 在 finally 块中,我们调用 queueLock.unlock() 来确保锁被释放,无论是否发生异常,都会释放锁。
现在让我来解释一下整个代码的运行过程:
- 当一个线程调用 printJob 方法时,它会首先尝试获取 queueLock 对象的锁。
- 如果当前没有其他线程持有该锁,那么这个线程会成功获取锁,并且可以执行打印任务。
- 如果另一个线程已经持有了锁,那么当前线程将被阻塞,直到锁被释放。
- 当打印任务完成后,无论是否发生异常,finally 块中的 queueLock.unlock() 语句都会确保锁被释放,以便其他线程可以获取锁并执行打印任务。
这样,通过使用 ReentrantLock 对象,我们可以确保对打印队列的访问是线程安全的,避免了多个线程同时访问打印队列可能引发的问题。