并发编程之基本线程同步

在并发编程中发生的最常见的一种情况是超过一个执行线程使用共享资源。在并发应用程序中,多个线程读或写相同的数据或访问同一文件或数据库连接这是正常的。这些共享资源会引发错误或数据不一致的情况,我们必须通过一些机制来避免这些错误。
解决这些问题从临界区的概念开始。临界区是访问一个共享资源在同一时间不能被超过一个线程执行的代码块。
Java(和 几乎所有的编程语言)提供同步机制,帮助程序员实现临界区。当一个线程想要访问一个临界区,它使用其中的一个同步机制来找出是否有任何其他线程执行临界 区。如果没有,这个线程就进入临界区。否则,这个线程通过同步机制暂停直到另一个线程执行完临界区。当多个线程正在等待一个线程完成执行的一个临界 区,JVM选择其中一个线程执行,其余的线程会等待直到轮到它们。
本章展示了一些的指南,指导如何使用Java语言提供的两种基本的同步机制:
关键字synchronized
Lock接口及其实现
同步方法()()()()():
在Java中如何使用一个最基本的同步方法,即使用 synchronized关键字来控制并发访问方法。只有一个执行线程将会访问一个对象中被synchronized关键字声明的方法。如果另一个线程试图访问同一个对象中任何被synchronized关键字声明的方法,它将被暂停,直到第一个线程结束方法的执行。
换句话说,每个方法声明为synchronized关键字是一个临界区,Java只允许一个对象执行其中的一个临界区。
静态方法有不同的行为。只有一个执行线程访问被synchronized关键字声明的静态方法,但另一个线程可以访问该类的一个对象中的其他非静态的方法。 你必须非常小心这一点,因为两个线程可以访问两个不同的同步方法,如果其中一个是静态的而另一个不是。如果这两种方法改变相同的数据,你将会有数据不一致 的错误。

为了学习这个概念,我们将实现一个有两个线程访问共同对象的示例。我们将有一个银行帐户和两个线程:其中一个线程将钱转移到帐户而另一个线程将从账户中扣款。在没有同步方法,我们可能得到不正确的结果。同步机制保证了账户的正确。

1. package demo11;
2. public class Account {
3. private double balance;
4. public double getBalance() {
5. return balance;
6. }
7. public void setBalance(double balance) {
8. this.balance = balance;
9. }
10. public synchronized void addMount(double mount){
11. double tmp = balance;
12. try {
13. Thread.sleep(10);
14. } catch (Exception e) {
15. // TODO: handle exception
16. }
17. tmp += mount;
18. balance = tmp;
19. }
20. public synchronized void subMount(double mount){
21. double tmp = balance;
22. try {
23. Thread.sleep(10);
24. } catch (Exception e) {
25. // TODO: handle exception
26. }
27. tmp -= mount;
28. balance = tmp;
29. }
30. }
1. public class Bank implements Runnable{
2. private Account account;
3. public Bank(Account account) {
4. super();
5. this.account = account;
6. }
7. @Override
8. public void run() {
9. // TODO Auto-generated method stub
10. for (int i = 0; i < 100; i++) {
11. account.subMount(100);
12. }
13. }
14. }
1. public class Company implements Runnable{
2. private Account account;
3. public Company(Account account) {
4. super();
5. this.account = account;
6. }
7. @Override
8. public void run() {
9. // TODO Auto-generated method stub
10. for (int i = 0; i < 100; i++) {
11. account.addMount(100);
12. }
13. }
14. }
1. public class Main {
2. public static void main(String[] args) {
3. Account account = new Account();
4. account.setBalance(1000);
5. Company company = new Company(account);
6. Thread thread = new Thread(company);
7. Bank bank = new Bank(account);
8. Thread bankThread = new Thread(bank);
9. thread.start();
10. bankThread.start();
11. try {
12. thread.join();
13. bankThread.join();
14. System.out.println("account::balance::"+account.getBalance());
15. } catch (Exception e) {
16. // TODO: handle exception
17. }
18. }
19. }
你已经开发了一个增加和减少模拟银行账户的类的余额的应用程序。在这个程序中,每次都调用100次addAmount()方法来增加1000 的余额和调用100次subtractAmount()方法来减少1000的余额。你应该期望最终的余额和初始的余额是相等的。你试图促使一个错误情况使用tmp变量来存储账户余额,所以你读取帐户余额,你增加临时变量的值,然后你再次设置账户的余额值。另外,你通过使用Thread类的sleep()方 法引入一个小延迟,让执行该方法的线程睡眠10毫秒,所以,如果另一个线程执行该方法,它可以修改账户的余额来引发一个错误。这是 synchronized关键字机制,避免这些错误。
如果你想看到并发访问共享数据的问题,那么就删除addAmount()和 subtractAmount()方法的synchronized关键字,然后运行该程序。在没有synchronized关键字的情况下,当一个线程在 睡眠后再读取账户的余额,另一个方法将读取该账户的余额。所以这两个方法将修改相同的余额并且其中一个操作不会反映在最终的结果。
只有一个线程能访问一个对象的声明为synchronized关键字的方法。如果一个线程A正在执行一个 synchronized方法,而线程B想要执行同个实例对象的synchronized方法,它将阻塞,直到线程A执行完。但是如果线程B访问相同类的不同实例对象,它们都不会被阻塞。
synchronized关键字不利于应用程序的性能,所以你必须仅在修改共享数据的并发环境下的方法上使用它。如果你有多个线程正在调用一个synchronized方法,在同一时刻只有一个线程执行它,而其他的线程将会等 待。如果这个操作没有使用synchronized关键字,所有线程可以在同一时刻执行这个操作,减少总的执行时间。如果你知道一个方法将不会被多个线程 调用,请不要使用synchronized关键字。
 
你可以使用递归调用synchronized方法。当线程访问一个对象的synchronized方法,你可以调用该对象的其他synchronized方法,包括正在执行的方法。它将不会再次访问synchronized方法。
 
我 们可以使用synchronized关键字来保护访问的代码块,替换在整个方法上使用synchronized关键字。我们应该使用 synchronized关键字以这样的方式来保护访问的共享数据,其余的操作留出此代码块,这将会获得更好的应用程序性能。这个目标就是让临界区(在同 一时刻可以被多个线程访问的代码块)尽可能短。我们已经使用了synchronized关键字来保护访问指令,将不使用共享数据的长操作留出此代码块。当 你以这个方式使用synchronized关键字,你必须通过一个对象引用作为参数。只有一个线程可以访问那个对象的synchronized代码(代码 块或方法)。通常,我们将使用this关键字引用执行该方法的对象。

在同步的类里安排独立属性::::::::::
当你使用synchronized关键字来保护代码块时,你必须通过一个对象的引用作为参数。通常,你将会使用this关键字来引用执行该方法的对象,但是你也可以使用其他对象引用。通常情况下,这些对象被创建只有这个目的。比如,你在一个类中有被多个线程共享的两个独立属性。你必须同步访问每个变量,如果有一个线程访问一个属性和另一个线程在同一时刻访问另一个属性,这是没有问题的。
 
在这个指南中,你将学习如何解决这种情况的一个例子,编程模拟一家电影院有两个屏幕和两个售票处。当一个售票处出售门票,它们用于两个电影院的其中一个,但不能用于两个,所以在每个电影院的免费席位的数量是独立的属性。
1. package demo12;
2. public class Cinema {
3. private long cinema1;
4. private long cinema2;
5. private final Object controlCinema1,controlCinema2;
6. public Cinema(){
7. controlCinema1 = new Object();
8. controlCinema2 = new Object();
9. cinema1 = 20;
10. cinema2 = 20;
11. }
12. public boolean sellTicket1 (int number) {
13. synchronized (controlCinema1) {
14. if (number < cinema1) {
15. cinema1 -= number;
16. return true;
17. } else {
18. return false;
19. }
20. }
21. }
22. public boolean sellTicket2 (int number) {
23. synchronized (controlCinema2) {
24. if (number < cinema2) {
25. cinema1 -= number;
26. return true;
27. } else {
28. return false;
29. }
30. }
31. }
32. public boolean returnTicket1 (int number) {
33. synchronized (controlCinema1) {
34. cinema1 += number;
35. return true;
36. }
37. }
38. public boolean returnTicket2 (int number) {
39. synchronized (controlCinema2) {
40. cinema1 += number;
41. return true;
42. }
43. }
44. public long getCinema1() {
45. return cinema1;
46. }
47. public long getCinema2() {
48. return cinema2;
49. }
50. }
当你使用synchronized关键字来保护代码块,你使用一个对象作为参数。JVM可以保证只有一个线程可以访问那个对象保护所有的代码块(请注意,我们总是谈论的对象,而不是类)。
注释:在这个示例中,我们用一个对象来控制vacanciesCinema1属性的访问。所以,在任意时刻,只有一个线程能修改该属性。用另一个对象来控制 vacanciesCinema2属性的访问。所以,在任意时刻,只有一个线程能修改这个属性。但是可能有两个线程同时运行,一个修改 vacancesCinema1属性而另一个修改vacanciesCinema2属性。
在同步代码中使用条件:::::
在并发编程中的一个经典问题是生产者与消费者问题,我们有一个数据缓冲区,一个或多个数据的生产者在缓冲区存储数据,而一个或多个数据的消费者,把数据从缓冲区取出。
由于缓冲区是一个共享的数据结构,我们必须采用同步机制,比如synchronized关键字来控制对它的访问。但是我们有更多的限制因素,如果缓冲区是满的,生产者不能存储数据,如果缓冲区是空的,消费者不能取出数据。
对于这些类型的情况,Java在Object对象中提供wait(),notify(),和notifyAll() 方法的实现。一个线程可以在synchronized代码块中调用wait()方法。如果在synchronized代码块外部调用wait()方法,JVM会抛出IllegalMonitorStateException异常。当线程调用wait()方法,JVM让这个线程睡眠,并且释放控制 synchronized代码块的对象,这样,虽然它正在执行但允许其他线程执行由该对象保护的其他synchronized代码块。为了唤醒线程,你必 须在由相同对象保护的synchronized代码块中调用notify()或notifyAll()方法。
1. package demo12;
2. import java.util.Date;
3. import java.util.LinkedList;
4. import java.util.List;
5. public class EventStorage {
6. private int maxSize;
7. private List<Date> storage;
8. public EventStorage(){
9. maxSize = 10;
10. storage = new LinkedList<>();
11. }
12. public synchronized void set() {
13. while (storage.size() == maxSize) {
14. try {
15. wait();
16. } catch (Exception e) {
17. // TODO: handle exception
18. }
19. }
20. ((LinkedList<Date>) storage).offer(new Date());
21. System.out.println("set: storage list size:" + storage.size());
22. notifyAll();
23. }
24. public synchronized void get() {
25. while (storage.size() == 0) {
26. try {
27. wait();
28. } catch (Exception e) {
29. // TODO: handle exception
30. }
31. }
32. System.out.println("get a :::"+storage.size()+"::::"+((LinkedList<Date>) storage).poll());
33. notifyAll();
34. }
35. }
1. package demo12;
2. public class Producer implements Runnable{
3. private EventStorage eventStorage;
4. public Producer(EventStorage eventStorage) {
5. super();
6. this.eventStorage = eventStorage;
7. }
8. @Override
9. public void run() {
10. // TODO Auto-generated method stub
11. for (int i = 0; i < 100; i++) {
12. eventStorage.set();
13. }
14. }
15. }
1. package demo12;
2. public class Consumer implements Runnable{
3. private EventStorage eventStorage;
4. public Consumer(EventStorage eventStorage) {
5. super();
6. this.eventStorage = eventStorage;
7. }
8. @Override
9. public void run() {
10. // TODO Auto-generated method stub
11. for (int i = 0; i < 100; i++) {
12. eventStorage.get();
13. }
14. }
15. }
1. package demo12;
2. public class Main {
3. public static void main(String[] args) {
4. EventStorage eventStorage = new EventStorage();
5. Producer producer = new Producer(eventStorage);
6. Thread thread1 = new Thread(producer);
7. Consumer consumer = new Consumer(eventStorage);
8. Thread thread2 = new Thread(consumer);
9. thread1.start();
10. thread2.start();
11. }
12. }
EventStorage 类的set()方法和get()方法是这个示例的关键。首先,set()方法检查storage属性是否有空闲空间。如果它满了,调用wait()方法等 待有空闲的空间。当其他线程调用notifyAll()方法,这个线程将被唤醒并且再次检查这个条件。这个notifyAll()方法并不保证线程会醒 来。这个过程是重复,直到storage有空闲空间,然后它可以生成一个新的事件并存储它。
get()方法的行为是相似的。首先,它检查storage是否有事件。如果EventStorage类是空的,调用wait()方法等待事件。当其他线程调用notifyAll()方法,这个线程将被唤醒并且再次检查这个条件直到storage有一些事件。
注释:在while循环中,你必须保持检查条件和调用wait()方法。你不能继续执行,直到这个条件为true。
这个东西跟concurrent 的 BlockingQueue 是一样的东西。这个使用场景不是像你说的:【生产了一个产品后,notifyAll()方法没有唤醒消费者,去消费这个产品,而是在所有产品都生成后才去消费】,而是因为:生产和消费速度是不一样的,可能因为IO或者业务链长度等等种种原因,致使生产消费速度有差异。所以需要一个临界区来缓存这些东西,而最易用且合理的容器就是list, FIFO嘛,通常道理上都是讲得过去的。也就是说:produce > consume 时queue就会越来越大越来越大(搞笑了哈)最终OOM,所以这是要避免的;produce < consume 这是最理想的,也就是 queueSize = 0. 

使用Lock同步代码块:::::::::
Java提供另外的机制用来同步代码块。它比synchronized关键字更加强大、灵活。它是基于Lock接口和实现它的类(如ReentrantLock)。这种机制有如下优势:
它允许以一种更灵活的方式来构建synchronized块。使用synchronized关键字,你必须以结构化方式得到释放synchronized代码块的控制权。Lock接口允许你获得更复杂的结构来实现你的临界区。
Lock 接口比synchronized关键字提供更多额外的功能。新功能之一是实现的tryLock()方法。这种方法试图获取锁的控制权并且如果它不能获取该锁,是因为其他线程在使用这个锁,它将返回这个锁。使用synchronized关键字,当线程A试图执行synchronized代码块,如果线程B正在执行它,那么线程A将阻塞直到线程B执行完synchronized代码块。使用锁,你可以执行tryLock()方法,这个方法返回一个 Boolean值表示,是否有其他线程正在运行这个锁所保护的代码。
当有多个读者和一个写者时,Lock接口允许读写操作分离。
Lock接口比synchronized关键字提供更好的性能
1. package demo13;
2. import java.util.concurrent.locks.Lock;
3. import java.util.concurrent.locks.ReentrantLock;
4. public class PrintQueue {
5. private final Lock queueLock = new ReentrantLock();
6. public void printJob(Object document) {
7. queueLock.lock();//获取控制权
8. try {
9. Long duration = (long) (Math.random()*10000);//模拟打印
10. Thread.sleep(duration);
11. } catch (Exception e) {
12. // TODO: handle exception
13. }finally{
14. queueLock.unlock();//释放控制
15. }
16. }
17. }

在 printJob()中,PrintQueue类是这个示例的关键所在。当我们通过锁来实现一个临界区并且保证只有一个执行线程能运行一个代码块,我们必 须创建一个ReentrantLock对象。在临界区的起始部分,我们必须通过使用lock()方法来获得锁的控制权。当一个线程A调用这个方法时,如果 没有其他线程持有这个锁的控制权,那么这个方法就会给线程A分配这个锁的控制权并且立即返回允许线程A执行这个临界区。否则,如果其他线程B正在执行由这 个锁控制的临界区,lock()方法将会使线程A睡眠直到线程B完成这个临界区的执行。
在临界区的尾部,我们必须使用unlock()方法来释放锁的控制权,允许其他线程运行这个临界区。如果你在临界区的尾部没有调用unlock()方法,那么其他正在等待该代码块的线程将会永远等待,造成 死锁情况。如果你在临界区使用try-catch代码块,别忘了在finally部分的内部包含unlock()方法的代码。  
Lock 接口(和ReentrantLock类)包含其他方法来获取锁的控制权,那就是tryLock()方法。这个方法与lock()方法的最大区别是,如果一 个线程调用这个方法不能获取Lock接口的控制权时,将会立即返回并且不会使这个线程进入睡眠。这个方法返回一个boolean值,true表示这个线程 获取了锁的控制权,false则表示没有。
注释:考虑到这个方法的结果,并采取相应的措施,这是程序员的责任。如果这个方法返回false值,预计你的程序不会执行这个临界区。如果是这样,你可能会在你的应用程序中得到错误的结果。
ReentrantLock类也允许递归调用(锁的可重入性,译者注),当一个线程有锁的控制权并且使用递归调用,它延续了锁的控制权,所以调用lock()方法将会立即返回并且继续递归调用的执行。此外,我们也可以调用其他方法。
你必须要非常小心使用锁来避免死锁,这种情况发生在,当两个或两个以上的线程被阻塞等待将永远不会解开的锁。比如,线程A锁定Lock(X)而线程B锁定 Lock(Y)。如果现在,线程A试图锁住Lock(Y)而线程B同时也试图锁住Lock(X),这两个线程将无限期地被阻塞,因为它们等待的锁将不会被解开。请注意,这个问题的发生是因为这两个线程尝试以相反的顺序获取锁(译者注:锁顺序死锁)。在附录中,提供了一些很好的并发编程设计的建议,适当的设计并发应用程序,来避免这些死锁问题。
使用读/写锁同步数据访问::::::
锁所提供的最重要的改进之一就是ReadWriteLock接口和唯一 一个实现它的ReentrantReadWriteLock类。这个类提供两把锁,一把用于读操作和一把用于写操作。同时可以有多个线程执行读操作,但只有一个线程可以执行写操作。当一个线程正在执行一个写操作,不可能有任何线程执行读操作。
1. package demo13;
2. import java.util.concurrent.locks.ReadWriteLock;
3. import java.util.concurrent.locks.ReentrantReadWriteLock;
4. public class PricesInfo {
5. private double price1;
6. private double price2;
7. private ReadWriteLock lock;
8. public PricesInfo() {
9. price1 = 1.0;
10. price2 = 2.0;
11. lock = new ReentrantReadWriteLock();
12. }
13. public double getPrice1(){
14. lock.readLock().lock();
15. double value = price1;
16. lock.readLock().unlock();
17. return value;
18. }
19. public double getPrice2(){
20. lock.readLock().lock();
21. double value = price2;
22. lock.readLock().unlock();
23. return value;
24. }
25. public void setPrices(double price1, double price2){
26. lock.writeLock().lock();
27. this.price1 = price1;
28. this.price2 = price2;
29. lock.writeLock().unlock();
30. }
31. }

正如我们前面提及到的,ReentrantReadWriteLock类有两把锁,一把用于读操作,一把用于写操作。用于读操作的锁,是通过在 ReadWriteLock接口中声明的readLock()方法获取的。这个锁是实现Lock接口的一个对象,所以我们可以使用lock(), unlock() 和tryLock()方法。用于写操作的锁,是通过在ReadWriteLock接口中声明的writeLock()方法获取的。这个锁是实现Lock接 口的一个对象,所以我们可以使用lock(), unlock() 和tryLock()方法。确保正确的使用这些锁,使用它们与被设计的目的是一样的,这是程序猿的职责。当你获得Lock接口的读锁时,不能修改这个变量的值。否则,你可能会有数据不一致的错误。
修改Lock的公平性:::::::::
在ReentrantLock类和 ReentrantReadWriteLock类的构造器中,允许一个名为fair的boolean类型参数,它允许你来控制这些类的行为。默认值为 false,这将启用非公平模式。在这个模式中,当有多个线程正在等待一把锁(ReentrantLock或者 ReentrantReadWriteLock),这个锁必须选择它们中间的一个来获得进入临界区,选择任意一个是没有任何标准的。true值将开启公平 模式。在这个模式中,当有多个线程正在等待一把锁(ReentrantLock或者ReentrantReadWriteLock),这个锁必须选择它们 中间的一个来获得进入临界区,它将选择等待时间最长的线程。考虑到之前解释的行为只是使用lock()和unlock()方法。由于tryLock()方 法并不会使线程进入睡眠,即使Lock接口正在被使用,这个公平属性并不会影响它的功能。
所有线程都创建一个0.1秒的差异,第一需要获取锁的控制权的线程是Thread0,然后是Thread1,以此类推。当Thread0正在运行第一个由锁 保护的代码块时,有9个线程正在那个代码块上等待执行。当Thread0释放锁,它需要马上再次获取锁,所以我们有10个线程试图获取这个锁。当启用代码 模式,Lock接口将会选择Thread1,它是在这个锁上等待最长时间的线程。然后,选择Thread2,然后是Thread3,以此类推。直到所有线 程都通过了这个锁保护的第一个代码块,否则,没有一个线程能执行该锁保护的第二个代码块。
一旦所有线程已经执行完由这个锁保护的第一个代码块,再次轮到Thread0。然后,轮到Thread1,以此类推。
为了看与非公平模式的差异,改变传入锁构造器的参数,传入false值。在以下截图中,你可以看到修改示例后的执行结果:在这种情况下,线程按被创建的顺序执行,但每个线程各自执行两个受保护的代码块。然而,这种行为的原因是没有保证的,正如之前解释的,这个锁将选择任意一个线程获得访问保护代码块。在这种情况下,JVM不能保证线程的执行顺序。
在Lock中使用多个条件::::
一个锁可能伴随着多个条件。这些条件声明在Condition接口中。 这些条件的目的是允许线程拥有锁的控制并且检查条件是否为true,如果是false,那么线程将被阻塞,直到其他线程唤醒它们。Condition接口提供一种机制,阻塞一个线程和唤醒一个被阻塞的线程。
1. package demo13;
2. import java.util.LinkedList;
3. import java.util.concurrent.locks.Condition;
4. import java.util.concurrent.locks.ReentrantLock;
5. public class Buffer {
6. private LinkedList<String> buffer;
7. private int maxSize;
8. private ReentrantLock lock;
9. private Condition lines;
10. private Condition space;
11. private boolean pendingLines;
12. public Buffer(int maxSize){
13. this.maxSize = maxSize;
14. buffer = new LinkedList<>();
15. lock = new ReentrantLock();
16. lines = lock.newCondition();
17. space = lock.newCondition();
18. pendingLines = true;
19. }
20. public void insert(String line){
21. lock.lock();
22. try {
23. while (buffer.size() == maxSize) {
24. space.await();
25. }
26. buffer.offer(line);
27. lines.signalAll();
28. } catch (Exception e) {
29. // TODO: handle exception
30. }finally{
31. lock.unlock();
32. }
33. }
34. public String get(){
35. String line = null;
36. lock.lock();
37. try {
38. while(buffer.size()==0){
39. lines.await();
40. }
41. line = buffer.poll();
42. space.signalAll();
43. } catch (Exception e) {
44. // TODO: handle exception
45. }finally{
46. lock.unlock();
47. }
48. return line;
49. }
50. }

所 有Condition对象都与锁有关,并且使用声明在Lock接口中的newCondition()方法来创建。使用condition做任何操作之前, 你必须获取与这个condition相关的锁的控制。所以,condition的操作一定是在以调用Lock对象的lock()方法为开头,以调用相同 Lock对象的unlock()方法为结尾的代码块中。
当一个线程在一个condition上调用await()方法时,它将自动释放锁的控制,所以其他线程可以获取这个锁的控制并开始执行相同操作,或者由同个锁保护的其他临界区。
注释:当一个线程在一个condition上调用signal()或signallAll()方法,一个或者全部在这个condition上等待的线程将被唤醒。这并不能保证的使它们现在睡眠的条件现在是true,所以你必须在while循环内部调用await()方法。你不能离开这个循环,直到 condition为true。当condition为false,你必须再次调用 await()方法。
你必须十分小心 ,在使用await()和signal()方法时。如果你在condition上调用await()方法而却没有在这个condition上调用signal()方法,这个线程将永远睡眠下去。
在调用await()方法后,一个线程可以被中断的,所以当它正在睡眠时,你必须处理InterruptedException异常。
Condition接口提供不同版本的await()方法,如下:
await(long time, TimeUnit unit):这个线程将会一直睡眠直到:
(1)它被中断
(2他线程在这个condition上调用singal()或signalAll()方法
 
(3)指定的时间已经过了
 
(4)TimeUnit类是一个枚举类型如下的常量:
 
DAYS,HOURS, MICROSECONDS, MILLISECONDS, MINUTES, NANOSECONDS,SECONDS
 
awaitUninterruptibly():这个线程将不会被中断,一直睡眠直到其他线程调用signal()或signalAll()方法
awaitUntil(Date date):这个线程将会一直睡眠直到:
(1)它被中断
 
(2)其他线程在这个condition上调用singal()或signalAll()方法
 
(3)指定的日期已经到了
 
你可以在一个读/写锁中的ReadLock和WriteLock上使用conditions。





  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值