在这个关于同步的Java教程系列中,我们将帮助您掌握并发编程上下文中有关同步的概念和实践经验。在第一部分中,让我们看一下更新同一数据的多个线程如何引起问题。
在多线程应用程序中,多个线程可以同时访问同一数据,这可能会使数据处于不一致状态(损坏或不准确)。让我们通过一个示例来演示多线程访问如何成为问题的根源,该示例演示银行中交易的处理。
假设我们有一个表示银行帐户的类,如下所示:
1个
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18岁
19
20
21
22
23
24
|
/**
* Account.java
* This class represents an account in the bank
* @author www.codejava.net
*/
public class Account {
private int balance = 0 ;
public Account( int balance) {
this .balance = balance;
}
public void withdraw( int amount) {
this .balance -= amount;
}
public void deposit( int amount) {
this .balance += amount;
}
public int getBalance() {
return this .balance;
}
}
|
由于存入和取款的交易,帐户的余额可以经常更改。
以下代码代表管理一些帐户的银行:
1个
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18岁
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
|
/**
* Bank.java
* This class represents a bank that manages accounts and provides
* money transfer function.
* @author www.codejava.net
*/
public class Bank {
public static final int MAX_ACCOUNT = 10 ;
public static final int MAX_AMOUNT = 10 ;
public static final int INITIAL_BALANCE = 100 ;
private Account[] accounts = new Account[MAX_ACCOUNT];
public Bank() {
for ( int i = 0 ; i < accounts.length; i++) {
accounts[i] = new Account(INITIAL_BALANCE);
}
}
public void transfer( int from, int to, int amount) {
if (amount <= accounts[from].getBalance()) {
accounts[from].withdraw(amount);
accounts[to].deposit(amount);
String message = "%s transfered %d from %s to %s. Total balance: %d\n" ;
String threadName = Thread.currentThread().getName();
System.out.printf(message, threadName, amount, from, to, getTotalBalance());
}
}
public int getTotalBalance() {
int total = 0 ;
for ( int i = 0 ; i < accounts.length; i++) {
total += accounts[i].getBalance();
}
return total;
}
}
|
如您所见,该银行由10个帐户组成,每个帐户的初始余额为100。因此,这10个帐户的总余额为10 x 100 = 1000。
的转移()方法提取从帐户指定的量,并存入其量到目标帐户。仅当源帐户具有足够的余额时,才会处理转帐。转移完成后,将打印一条日志消息,让我们知道交易详细信息。
该getTotalBalance()方法返回所有账户的总金额,它必须是始终1000我们每一笔交易之后检查这些号码,以确保该程序运行正常。
由于银行允许许多交易同时发生,因此以下类别代表交易:
1个
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18岁
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
|
/**
* Transaction.java
* This class represents a transaction task that can be executed by a thread.
* @author www.codejava.net
*/
public class Transaction implements Runnable {
private Bank bank;
private int fromAccount;
public Transaction(Bank bank, int fromAccount) {
this .bank = bank;
this .fromAccount = fromAccount;
}
public void run() {
while ( true ) {
int toAccount = ( int ) (Math.random() * Bank.MAX_ACCOUNT);
if (toAccount == fromAccount) continue ;
int amount = ( int ) (Math.random() * Bank.MAX_AMOUNT);
if (amount == 0 ) continue ;
bank.transfer(fromAccount, toAccount, amount);
try {
Thread.sleep( 10 );
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
|
如您所见,此Transaction类实现了Runnable接口,因此其run()方法中的代码可以由单独的线程执行。
源帐户是从构造函数传递的,目标帐户是随机选择的,并且源帐户和目标帐户不能相同。另外,要随机选择要转移的金额,但始终少于10。在事务完成之后,当前线程进入睡眠状态的时间很短(50毫秒),然后它继续重复执行相同的步骤,直到该线程终止。
这是测试程序:
1个
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
/**
* TransactionTest.java
* This is a test program that creates threads to process
* many transactions concurrently.
* @author www.codejava.net
*/
public class TransactionTest {
public static void main(String[] args) {
Bank bank = new Bank();
for ( int i = 0 ; i < Bank.MAX_ACCOUNT; i++) {
Thread t = new Thread( new Transaction(bank, i));
t.start();
}
}
}
|
如您所见,将创建Bank实例并在执行事务的线程之间共享。对于每个帐户,都会创建一个新线程将资金从该帐户转移到其他随机选择的帐户。这意味着总共有10个线程共享一个Bank类实例。这些线程将一直运行,直到通过按Ctrl + C终止程序。
请记住以下规则:无论处理了多少笔交易,所有帐户的总余额都必须保持不变。换句话说,程序必须始终将这个数字报告为1000。
现在,让我们编译并运行TransactionTest程序并观察输出。最初,您应该看到如下输出:
报告的总余额始终为1000。
可是等等!让程序继续运行更长的时间,您很快就会发现问题发生了:
哎哟! 总余额以某种方式改变了。它不再保持在1000。随着时间的流逝,它越来越小。为什么会这样呢?
该程序一定有问题。让我们分析代码以找出原因。
看一下Transaction类,您会看到多个线程执行Bank类的共享实例的transfer()方法:
1个
|
bank.transfer(fromAccount, toAccount, amount);
|
该方法实现如下:
1个
2
3
4
5
6
7
8
|
public void transfer( int from, int to, int amount) {
if (amount <= accounts[from].getBalance()) {
accounts[from].withdraw(amount);
accounts[to].deposit(amount);
// code to print the log message…
}
}
|
假设在进行一些交易后,帐户#1的余额为5。线程#1正在执行if语句,以验证帐户是否有足够的资金可以转账且金额为3。由于帐户的余额为5,因此线程#1进入if块的主体。
但是在线程#1执行要撤回的语句之前:
1个
|
accounts[from].withdraw(amount);
|
另一个线程(例如,线程2)执行了一项从帐户#1提取金额4的交易。现在,线程#1执行撤回操作,此时余额为5-4 = 1,线程#1不再将其视为5。因此,帐户1的余额现在为1-3 = -2。余额为负,这就是为什么当程序再次计算总余额时,余额会减少的原因!
如果您使程序运行的时间越来越长,您会看到总余额会越来越小:
这意味着当多个线程同时更新共享数据时,共享数据可能会损坏。
存款操作可能会发生类似的问题。假设线程#3将向帐户#3加8。在添加之前,线程#3看到该帐户的余额为10。但是在线程#3更新余额之前,另一个线程(例如线程#4)对该帐户执行了5的提款操作,因此其余额为是10-5 = 5。
同时,线程#3仍然看到余额为10,因此它将8加到10,这导致帐户#3的余额为18。但是将5的金额添加到了另一个帐户,这意味着总余额会增加5。这就是为什么您可能还会看到,当程序继续运行时,总余额会随着时间的推移而增加,如下面的屏幕快照所示:
让我们运行测试程序几次,然后自己观察输出。输出是不可预测的:有时您会看到总余额增加,有时会减少,有时会涨跌,无论如何!
还要尝试在Transaction类中更改睡眠时间。时间越长,总余额的变化就越慢。而且时间越短,总余额的变化就越快。
那么我们应该怎么做才能解决这个问题呢?
我们需要一种机制,以确保transfer()方法中的代码一次仅由一个线程执行。换句话说,我们需要同步对共享数据的访问。
现在您了解了不同步的代码可能会发生什么样的问题。我将在本教程系列的第二部分中向您展示第一个解决方案。
在Java同步教程系列的第二部分中,我们将向您展示第一部分中介绍的银行交易示例的第一个解决方案。我们需要保护共享数据,由于多个线程的并发更新,共享数据可能会损坏。现在,让我们看看Java提供了什么解决方案来序列化对Bank类的transfer()方法的访问。
1.将Lock与ReentrantLock对象一起使用
Java Concurrency API提供了一种同步机制,该机制涉及锁定对象上的锁定/解锁,如下所示:
1个
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
class Clazz {
private Lock lock = new ReentrantLock();
public void method() {
lock.lock(); // 1
try {
// 2: code needs to be protected
} finally {
lock.unlock(); // 3
}
}
}
|
让我解释一下这种机制是如何工作的。当线程进入第1行时,它将尝试获取锁对象,并且如果该锁未被另一个线程持有,则当前线程将获得对该锁对象的排他所有权。如果该锁当前由另一个线程持有,则当前线程将阻塞并等待直到释放该锁。
当前线程成功获取锁定后,它将在try块中执行代码,而无需担心其他线程的干预。最后,线程释放锁并退出方法(第3行)。之后,将获得锁的机会分配给其他线程。在任何时候,只有一个线程拥有该锁,并且可以执行受保护的代码。其他线程将阻塞并等待直到锁可用。
将unlock语句放置在finally块内,以确保在引发异常的情况下线程最终放弃锁。
因此,我们更新Bank类,如下所示:
1个
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18岁
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
|
import java.util.concurrent.locks.*;
/**
* Bank.java
* This class represents a bank that manages accounts and provides
* money transfer function.
* It demonstrates how to use the locking mechanism with a ReentrantLock object.
* @author www.codejava.net
*/
public class Bank {
public static final int MAX_ACCOUNT = 10 ;
public static final int MAX_AMOUNT = 10 ;
public static final int INITIAL_BALANCE = 100 ;
private Account[] accounts = new Account[MAX_ACCOUNT];
private Lock bankLock;
public Bank() {
for ( int i = 0 ; i < accounts.length; i++) {
accounts[i] = new Account(INITIAL_BALANCE);
}
bankLock = new ReentrantLock();
}
public void transfer( int from, int to, int amount) {
bankLock.lock();
try {
if (amount <= accounts[from].getBalance()) {
accounts[from].withdraw(amount);
accounts[to].deposit(amount);
String message = "%s transfered %d from %s to %s. Total balance: %d\n" ;
String threadName = Thread.currentThread().getName();
System.out.printf(message, threadName, amount, from, to, getTotalBalance());
}
} finally {
bankLock.unlock();
}
}
public int getTotalBalance() {
bankLock.lock();
try {
int total = 0 ;
for ( int i = 0 ; i < accounts.length; i++) {
total += accounts[i].getBalance();
}
return total;
} finally {
bankLock.unlock();
}
}
}
|
在这里,创建一个ReentrantLock对象作为该类的实例变量。该的ReentrantLock类是的实现锁接口。两者都在java.util.concurrent.locks包中定义。
仔细研究一下transfer()方法,该方法通过使用锁定对象来保护并发访问,如下所示:
1个
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
public void transfer( int from, int to, int amount) {
bankLock.lock();
try {
if (amount <= accounts[from].getBalance()) {
accounts[from].withdraw(amount);
accounts[to].deposit(amount);
String message = "%s transfered %d from %s to %s. Total balance: %d\n" ;
String threadName = Thread.currentThread().getName();
System.out.printf(message, threadName, amount, from, to, getTotalBalance());
}
} finally {
bankLock.unlock();
}
}
|
您会注意到,此方法调用getTotalBalance()方法,该方法也受同一bankLock对象上的锁定/解锁机制保护:
1个
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
public int getTotalBalance() {
bankLock.lock();
try {
int total = 0 ;
for ( int i = 0 ; i < accounts.length; i++) {
total += accounts[i].getBalance();
}
return total;
} finally {
bankLock.unlock();
}
}
|
我们还需要序列化对getTotalBalance()方法的访问,以避免出现其他线程读取当前总余额而当前线程正在更新影响总余额的帐户余额的情况。换句话说,当前线程正在执行transfer()方法时,没有线程可以访问getTotalBalance()方法,因为这两个方法都被同一个锁定对象bankLock锁定。
现在,让我们重新编译Bank类,然后运行TransactionTest程序,您将看到总余额损坏的问题已消失:
使用我们已应用的使用锁定对象的同步解决方案后,程序现在可以按预期运行:总余额始终保持不变。
2.为什么选择ReentrantLock?
您可能会觉得ReentrantLock这个名称有点难以理解,但是使用该名称有充分的理由。该ReentrantLock的允许一个线程获取它已经拥有多次递归锁。看一下transfer()方法,您会看到它调用了getTotalBalance()方法,对吗?通过输入getTotalBalance()方法,当前线程两次获取锁定对象,对吗?
线程获取锁的次数存储在保持计数变量中。当线程获得锁定时,保持计数将增加1,而当释放锁定时,保持计数将减少1。如果保持计数为0,则会完全放弃该锁定。因此,必须有一个调用unlock()每次调用lock()。
在上面的Bank类中,当当前线程在transfer()方法中获取锁定时,保持计数为1;否则,保持计数为1。当它在getTotalBalance()方法中获取锁时,保持计数为2。当线程在getTotalBalance()方法中释放锁时,保持计数为1。当线程在transfer()方法中释放锁时,保持计数为0。
这就是为什么此锁称为可重入的原因。
3.使用条件对象锁定
在我们的银行示例中,仅当帐户中有足够的余额来支付转帐时,才会处理交易:
1个
2
3
|
if (amount <= accounts[from].getBalance()) {
// transfer money...
}
|
万一该帐户没有足够的资金,如果我们希望当前线程等待其他线程向该帐户存入资金怎么办?可以通过以下伪代码解释此逻辑:
1个
2
3
4
5
6
7
8
9
10
11
12
|
lock.lock();
try {
if (not sufficient fund) {
// wait...
}
// transfer...
} finally {
lock.unlock();
}
|
Java Concurrency API通过提供一个Condition对象来实现此目的,该对象可以从锁对象获得,如下所示:
1个
|
Condition availableFund = bankLock.newCondition();
|
如果没有满足条件(足够的资金转移),则可以通过调用以下语句来告诉当前线程等待:
1个
|
availableFund.await();
|
这导致当前线程阻塞并等待,这意味着当前线程放弃了锁定,因此其他线程有机会更新此帐户的余额。当前线程阻塞,直到另一个线程调用:
1个
|
availableFund.signal();
|
要么:
1个
|
availableFund.signalAll();
|
signal()和signalAll()之间的区别在于,signal()方法仅唤醒等待线程中的一个线程。选择哪个线程取决于线程调度程序,这意味着不能保证如果一个线程调用signal(),则当前线程将唤醒。
并且signalAll()方法唤醒当前正在等待的所有线程。注意,由线程调度程序决定哪个线程轮到自己。所有线程均处于唤醒状态,但仅授予一个访问该锁的权限。这也意味着不能保证当前线程即使被唤醒也可以再次获取该锁。如果是这种情况,线程将继续阻塞并等待其他机会,直到获得锁并退出该方法为止。
现在,让我们如下更新Bank类:
1个
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18岁
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65岁
66
67
68
69
|
import java.util.concurrent.locks.*;
/**
* Bank.java
* This class represents a bank that manages accounts and provides
* money transfer function.
* It demonstrates how to use the locking mechanism with a condition object.
* @author www.codejava.net
*/
public class Bank {
public static final int MAX_ACCOUNT = 10 ;
public static final int MAX_AMOUNT = 10 ;
public static final int INITIAL_BALANCE = 100 ;
private Account[] accounts = new Account[MAX_ACCOUNT];
private Lock bankLock;
private Condition availableFund;
public Bank() {
for ( int i = 0 ; i < accounts.length; i++) {
accounts[i] = new Account(INITIAL_BALANCE);
}
bankLock = new ReentrantLock();
availableFund = bankLock.newCondition();
}
public void transfer( int from, int to, int amount) {
bankLock.lock();
try {
while (accounts[from].getBalance() < amount) {
availableFund.await();
}
accounts[from].withdraw(amount);
accounts[to].deposit(amount);
String message = "%s transfered %d from %s to %s. Total balance: %d\n" ;
String threadName = Thread.currentThread().getName();
System.out.printf(message, threadName, amount, from, to, getTotalBalance());
availableFund.signalAll();
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
bankLock.unlock();
}
}
public int getTotalBalance() {
bankLock.lock();
try {
int total = 0 ;
for ( int i = 0 ; i < accounts.length; i++) {
total += accounts[i].getBalance();
}
return total;
} finally {
bankLock.unlock();
}
}
}
|
重新编译此类,然后再次运行TransactionTest程序,并自己观察结果。
因此,如果您希望当前线程等待直到满足条件,而不是立即放弃,则使用Condition对象将很有用。
到目前为止,我们经历过的技术称为显式锁定机制,该机制将具体的Lock对象与Condition对象一起使用。
在下一部分中,您将学习使用synced关键字的隐式锁定机制。
欢迎来到Java同步教程系列的第3部分!在第2部分中,您将了解如何使用Lock和Condition对象来同步对方法的访问。这基本上就是在Java中设计和工作同步的方式。为了使程序员更容易使用,Java提供了对关键字的默认锁进行操作的synced关键字。此默认锁称为固有锁,它属于每个Java对象。
可以在方法级别或代码块级别使用synced关键字。首先让我们看一下第一种方法。
1.同步方法
考虑以下类别:
1个
2
3
4
5
|
public class A {
public synchronized void update() {
// code needs to be serialized for access
}
}
|
在这里,update()方法是同步的。它等效于以下代码,这些代码明确使用锁定对象:
1个
2
3
4
5
6
7
8
9
10
11
12
13
|
public class A {
public void update() {
this .intrinsicLock.lock();
try {
// code needs to be serialized for access
} finally {
this .intrinsicLock.unlock();
}
}
}
|
在此,固有锁属于该类的实例。以下代码说明了如何通过同步方法使用条件:
1个
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
public class A {
public synchronized void update() {
if (!condition) {
this .wait();
}
// code needs to be serialized for access
this .notify();
// or:
this .notifyAll();
}
}
|
方法wait(),notify()和notifyAll()的行为与Lock对象的await(),signal()和signalAll()的行为相同。这些方法由Object类提供。因此,每个对象都有其自身的固有锁定和固有条件。
现在,可以使用synced关键字重写Bank类,如下所示:
1个
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18岁
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
|
/**
* Bank.java
* This class represents a bank that manages accounts and provides
* money transfer function.
* It demonstrates how to use the the synchronized keyword to serialize
* access to methods.
* @author www.codejava.net
*/
public class Bank {
public static final int MAX_ACCOUNT = 10 ;
public static final int MAX_AMOUNT = 10 ;
public static final int INITIAL_BALANCE = 100 ;
private Account[] accounts = new Account[MAX_ACCOUNT];
public Bank() {
for ( int i = 0 ; i < accounts.length; i++) {
accounts[i] = new Account(INITIAL_BALANCE);
}
}
public synchronized void transfer( int from, int to, int amount) {
try {
while (accounts[from].getBalance() < amount) {
wait();
}
accounts[from].withdraw(amount);
accounts[to].deposit(amount);
String message = "%s transfered %d from %s to %s. Total balance: %d\n" ;
String threadName = Thread.currentThread().getName();
System.out.printf(message, threadName, amount, from, to, getTotalBalance());
notifyAll();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
public synchronized int getTotalBalance() {
int total = 0 ;
for ( int i = 0 ; i < accounts.length; i++) {
total += accounts[i].getBalance();
}
return total;
}
}
|
您看到,使用synced关键字使代码更紧凑,对吗?但是,如果不了解显式锁定机制,您将无法理解同步方法的工作原理,对吗?
让我们重新编译Bank类,然后再次运行TransactionTest程序,您将看到该程序的行为与使用显式锁定机制的先前版本相同。但是您编写的代码要少得多。很酷,不是吗?
关于同步实例方法(非静态实例方法),以下是一些值得注意的要点:
- 当线程进入同步方法时,它将尝试获取与该类的当前实例相关联的内部锁。如果线程成功拥有该锁,则其他线程将在尝试执行该类的任何同步实例方法时阻塞。这意味着,如果一个类包含多个同步的实例方法,那么一个线程一次只能执行一个。
- 线程在调用wait(),notify()或notifyAll()之前必须拥有锁。否则,将抛出IllegalMonitorStateException。
- 在等待()方法的原因当前线程等待,直到它是由一个线程的呼叫唤醒通知()或notifyAll的() 。并且在等待时,该线程可以被另一个线程中断。因此,我们必须处理InterruptedException。
- 的通知()方法导致当前线程所以随机选择的等待线程被给予锁放弃锁。这意味着不能保证选择了调用wait()的线程。这取决于线程调度程序。
- 该notifyAll的()方法使当前线程释放锁并唤醒当前等待所有其他线程。所有线程都有机会。
2.同步块
如果您想在较小的范围内(即代码块而不是整个方法)同步访问,则可以这样使用synced关键字:
1个
2
3
4
5
|
public void update() {
synchronized (obj) {
// code block
}
}
|
线程必须拥有与对象obj相关联的锁,然后才能执行代码块。该OBJ可以是任何类型的对象,你想用它作为锁。
并使用条件如下的同步块:
1个
2
3
4
5
6
7
8
9
10
11
12
13
|
synchronized (obj) {
if (!condition) {
obj.wait();
}
// code block
obj.notify();
// or:
obj.notifyAll();
}
|
通过使用同步块,您可以更好地控制应序列化代码的一部分以进行访问。例如,您可以在允许同时执行read()方法的同时,阻止对write()方法的并发访问:
1个
2
3
4
5
6
7
8
9
10
11
12
13
|
public class A {
private Object lock = new Object();
public void write() {
synchronized (lock) {
// code to write
}
}
public void read() {
// code to read
}
}
|
在这里,您可以看到一个纯对象被用作锁。所述写()方法可通过仅一个线程的时间执行,而读()方法可通过多个线程同时被执行。这是可能的,因为当线程执行同步块时,它不一定必须拥有与类的实例相关联的锁。相反,它持有与受同步块保护的对象关联的锁。
请注意,在类的当前实例上同步代码块等效于同步实例方法。这意味着以下代码:
1个
2
3
4
5
|
public void update() {
synchronized ( this ) {
// code block
}
}
|
等效于此:
1个
2
3
|
public synchronized void update() {
// code block
}
|
因此,可以在此实例上使用同步块来重写Bank类。这是你的运动。
3.同步静态方法
您可以同步静态方法,为此线程必须获取其他锁:与类本身相关联的锁(静态),而不是类的实例(此)。
这意味着如果您写:
1个
2
3
4
5
6
|
public class A {
public static synchronized void update() {
// code
}
}
|
等效于:
1个
2
3
4
5
6
7
8
|
public class A {
public static void update() {
synchronized (A. class ) {
// code
}
}
}
|
因此,当线程执行同步静态方法时,它还会阻止对所有其他同步静态方法的访问。同步的非静态方法仍然可以由其他线程执行。这是因为同步的静态方法和同步的非静态方法在不同的锁上起作用:类锁和实例锁。
换句话说,同步静态方法和非静态同步方法不会互相阻塞。它们可以同时运行。
这就是内在(隐式)锁定机制在Java中的工作方式。
4.显式锁定与固有锁定
到目前为止,我已经向您解释了Java中两种同步机制的工作:
- 使用Lock和Condition对象的显式锁定。
- 使用synced关键字的内部锁定。
现在的问题是:什么时候使用哪个?什么时候使用Lock和什么时候使用sync?
以下是一些有助于您做出决定的准则:
- 如果要阻止对实例方法(非静态同步方法)或静态方法(静态同步方法)的并发访问,请考虑使用synced关键字。
- 如果您希望对同步过程有更大的控制权,请考虑使用显式的Lock and Condition对象。
- 使用多个与锁关联的条件对象。
- 在线程等待时指定超时。这意味着线程可以在指定的超时时间到期后自行唤醒。
请记住,与使用显式锁相比,使用synced关键字更容易且更不易出错。使用显式锁可以为您提供更多控制权,但您必须付出更多努力。