前文回顾
上一篇博客
从零开始学多线程之组合对象(三)
主要讲解了:
1. 设计线程安全的类要考虑的因素.
2. 对于非线程安全的对象,我们可以考虑使用锁+实例限制(Java监视器模式)的方式,安全的访问它们.
3. 扩展线程安全类的四种方式.
本篇博客将要讲解的知识点
使用java提供的线程安全容器和同步工具.来构建线程安全的类.
这些同步工具包括: 同步容器、并发容器和阻塞队列.
开始之前先介绍几个简单的基础知识:
Thread、Runnable 和 Callable. Runnable是一个接口,里面只有一个抽象的方法public void run(),Thread是Runnable的实现类,我们一般开启一个新线程执行一些任务的时候就如此这般:
//声明一个任务Runnable r = new Runnable() { @Override public void run() { //你要执行的任务 ); //把任务放入执行线程 Thread t = new Thread(r); //执行任务 t.start();
而Callable是一个带返回值的Runnable.好正文开始.
同步容器
同步容器通过Collections.sychronizedXXX工厂方法创建,可以创建各种线程安全的同步容器.
public class Synchronization { private final List<Object> list = Collections.synchronizedList(new ArrayList<>()); }
本质上就是使用上一篇博客讲到的实例限制实现的(把非线程安全的对象包装进一个类,通过这个类的锁去访问这个对象).
同步容器都是线程安全的,但是它有很多的局限性,因为它的方法都是同步的,所以它的并发性会受到影响,如果有其他的线程去并发修改容器的时候,同步容器也会出现问题.
对于一些复合操作有时你可能需要使用额外的客户端加锁进行保护
再看之前的例子:
1 public class Synchronization { 2 3 private final List<Object> list = Collections.synchronizedList(new ArrayList<>()); 4 5 public Object getLast(){ 6 //获得最后一个元素的下标 7 int lastIndex = list.size() - 1; 8 return list.get(lastIndex); 9 } 10 11 public void removeLast(){ 12 int lastIndex = list.size() - 1; 13 list.remove(lastIndex); 14 } 15 16 }
虽然list是线程安全的,但是当并发调用getLast()和removeLast()方法的时候还是会出现问题,当代码
走到getLast()方法第7行的时候,另一个线程可能已经执行完了removeLast()方法,所以此时的lastIndex
下标是一个过期值,会出现数组下标越界的问题.
为了解决这个问题,我们可以采用客户端加锁的方式:
public Object getLast() { synchronized (list) { //获得最后一个元素的下标 int lastIndex = list.size() - 1; return list.get(lastIndex); } } public void removeLast() { synchronized (list) { int lastIndex = list.size() - 1; list.remove(lastIndex); } }
同样的我们在迭代list集合的时候,如果list被其他线程修改了,会抛出ConcurrentModifacationException.
可以使用客户端加锁的方式规避,但是影响并发性
public void forEach(){ synchronized (list) { for (int i = 0; i < list.size(); i++) { System.out.println(list.get(i)); } } }
接下来给大家展示一个"有趣的""代码:
public class HiddenIterator { private Set<Integer> set = new HashSet<>(); public synchronized void add(Integer i){ set.add(i); } public synchronized void remove(Integer i){ set.remove(i); } public void addTenThings(){ Random r = new Random(); for (int i = 0; i < 10; i++) { set.add(r.nextInt()); } System.out.println("set = " + set); } }
HiddenIterator限制了非线程安全的set的访问,使它可以被安全的访问,addTenThings()方法
增加十个随机值到集合中,最后打印输出set的值,一切都看上去很完美,然而就是这么一个人畜无害的代码,却有着抛出ConcurrentModifacationException的可能.
这是怎么回事呢? 答案在System.out.println("set = " + set);这一行.这是一个隐藏的迭代过程,字符串的拼接操作经过编译转换成调用StringBuilder.append(Object)来完成,它会调用容器的toString方法.标准容器内的toString的实现会通过迭代容器中的每个元素,来获得关于容器内容格式良好的展现,所以在这个过程进行中,如果有另一个线程修改了容器的大小,就会抛出ConcurrentModifacationException.
容器的hashcode和equals方法也会间接地调用迭代,为了构建更安全的类,我们应该尽量使用线程安全的容器.
正如封装一个对象的状态,能够使它更加容易地保持不变约束一样,封装它的同步则可以破式它符合同步策略.(封装同步就是让对象的成员变量自己去内部同步的意思.)
好了,说了半天同步容器的种种不好和局限,其实都是为了衬托出接下来的这个容器,我们继续往下看
并发容器
并发容器类是同步容器的升级版,同步容器通过对容器的所有状态进行串行访问,从而实现了他们的线程安全.这样做的代价是削弱并发性,当多个线程共同竞争容器级的锁时,吞吐量就会降低.
并发容器就是专门为多编程并发访问设计的!!!! 新的ConcurrentMap接口介入了对常见复合操作的支持,例如以前提到过的缺少即加入、替换和条件删除.
用并发容器替换同步容器,这种作法以有很小风险带来了可扩展性显著的提高.
我们以ConcurrentHashMap和同步的HashMap为例.
ConcurrentHashMap比同步的HashMap提供了更好的并发性和可伸缩性,同步容器使用一个公共锁同步每一个方法,并严格地限制只能有一个线程可以访问容器.而ConcurrentHashMap使用一个更加细化的锁机制--分离锁这个锁机制(这个锁机制允许更深层次的共享访问).
任意数量的读线程可以并发访问Map,有限数量的写线程可以并发修改Map.结果是,为并发访问带来更高的吞吐量,同时几乎没有损失单个线程访问的性能.
还记得同步容器在迭代时修改会抛出ConcurrentModifacationException异常吗,这在并发容器中不会发生.ConcurrentHashMap返回的迭代器具有弱一致性.弱一致性的迭代器可以允许并发修改.当迭代器被创建时,它会遍历已有的元素,并且可以(但是不保证)感应到在迭代器被创建后对容器的修改.
并发容器的size和isEmpty这样的方法在并发环境下没什么用,因为它们的目标是运动的,所以对这些操作的需求弱化了.
同步容器和并发容器的选择上已经很清晰了,我们的第一选择应该是并发容器(更好的性能,没有劣势),然而因为并发容器使用的是分离锁,无法独占访问,所以在需要独占访问容器的时候我们还是需要同步容器的.(需要独占访问的 情况:原子化的加入一些映射add(),或者对元素进行若干次迭代,需要保证元素的顺序).
CopyOnWriteArrayList
CopyOnWriteArrayList是同步List的一个并发替代品,也提供了更好的并发性,并避免了在迭代期间对容器加锁和复制.
写入时复制容器的安全性来源于这样一个事实,只要有效的不可变对象被正确发布,那么访问它将不再需要更多的同步.
在每次需要修改时,他们会创建一个并重新发布一个新的容器拷贝,以此来实现可变性.
写入时赋值容器返回的迭代器不会抛出ConcurrentModifacationException,并且返回的元素严格与容器
创建时相一致,不会考虑后续的修改
public static void main(String [] args) throws InterruptedException { List<Point> list = new CopyOnWriteArrayList<>(); list.add(new Point(1,1)); list.add(new Point(2,2)); list.add(new Point(3,3)); list.add(new Point(4,4)); new Thread(new Runnable() { @Override public void run() { for (Point point : list) { try { Thread.sleep(500); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(point); } } }).start(); Thread.sleep(1000); System.out.println("继续执行了"); list.add(new Point(5,5)); System.out.println("list = " + list); }
输出结果:
Point{x=1, y=1}
继续执行了
list = [Point{x=1, y=1}, Point{x=2, y=2}, Point{x=3, y=3}, Point{x=4, y=4}, Point{x=5, y=5}]
Point{x=2, y=2}
Point{x=3, y=3}
Point{x=4, y=4}
即使在迭代中给集合添加一个元素,输出的元素确实还是与迭代时创建的一致.
限于篇幅这里就不过多展开CopyOnWriterArrayList这个容器了.
阻塞队列和生产者-消费者模式
阻塞队列可以说是非常有用的东西,请睁大您的双眼看仔细了.
阻塞队列(Blocking queue)提供了可阻塞的put和take方法,和可定时的offer,poll是等价的(如果超过规定的时间会停止阻塞继续执行).
如果Queue满了,put方法会被阻塞,直到有空间可用;如果Queue是空的,那么take方法会被
阻塞直到有元素可用,Queue的长度可以有限,也可以无限;无限的Queue永远不会阻塞,所以
它的put方法永远不会阻塞.(无限的Queue等于无限的任务,无限的任务对上有限的内存 = 程序崩溃,所以我们的选择显而易见)
public class Blocking { public static void main(String [] args) throws InterruptedException { //构造函数传递的1,代表队列的容量 Queue<String> queue = new ArrayBlockingQueue<String>(1); //该线程3秒后会给队列加入一个值 new Thread(new Runnable() { @Override public void run() { try { Thread.sleep(3000); ((ArrayBlockingQueue<String>) queue).put("1"); } catch (InterruptedException e) { e.printStackTrace(); } } }).start(); //这时候队列是空的会阻塞...直到上面的线程执行添加任务.. ((ArrayBlockingQueue<String>) queue).take(); System.out.println("继续执行了"); } }
感兴趣的读者可以把这段代码copy执行一下,new ArrayBlockingQueue<String>(1); 构造函数传递的参数1
是设置这个队列的边界的,只可以存放一个对象,new Thread().start 声明了一个新线程,里面的任务就是
3s后往阻塞队列中加入一个元素,主线程执行take()操作,这时候因为队列没有值,所以被阻塞了没有输出
"继续执行了"这句话,等待3秒以后,成功输出"继续执行了".
使用poll()相当于可定时的take,拿出对象:
public class Blocking { public static void main(String [] args) throws InterruptedException { Queue<String> queue = new ArrayBlockingQueue<>(1); ((ArrayBlockingQueue<String>) queue).poll(3,TimeUnit.SECONDS); System.out.println("继续执行了"); } }
3s后输出,"继续执行了".
关于put和take,与offer(可定时的put)和poll(可定时的take)之间如何抉择呢?
答案是选择后者,因为put和take有可能会有长时间阻塞的风险,会产生死锁,所以最优的选择
是使用定时的方法.
有界的Queue和无限的Queue之间也最好选择前者,因为如果无限的队列有可能占用过多的内存
导致程序或者系统崩溃.
阻塞队列支持生产者-消费者设计模式,生产者put,消费者take,该模式不会发现一个工作立即处理,而是把工作置入一个任务清单
中(队列),生产者消费者模式简化了开发,因为它解除了生产者类和消费者类之间相互依赖的代码.
最常见 的生产者-消费者设计是将线程池与工作队列相结合.
如果生产者不能够足够快的产生工作,让消费者忙碌起来,那么消费者只能一直等待,直到有工作可做
这时候可能需要将生产者线程和消费者线程进行调整,以获得更好的资源利用率.
如果生产者产生工作的速度总是比消费者处理的速度快,那么队列会越来越大,如果是无界的队列
内存最终会耗尽,使用put方法的阻塞特性大大简化了生产者的编码;当队列满的时候生产者就会阻塞,
给消费者追赶的时间.
使用offer方法(可定时的put),如果添加元素失败,会返回一个false失败状态,我们可以用offer返回的状态
做一些减轻负载、序列化剩余工作条目并写入硬盘,减少生产者线程或者其它的方法遏制生产者线程的处理.
示例:
public static void main(String [] args) throws InterruptedException { Queue<String> queue = new ArrayBlockingQueue<>(1); ((ArrayBlockingQueue<String>) queue).put("a"); ((ArrayBlockingQueue<String>) queue).poll(3,TimeUnit.SECONDS); System.out.println("继续执行了"); boolean first = ((ArrayBlockingQueue<String>) queue).offer("a", 1, TimeUnit.SECONDS); System.out.println("first = " + first); boolean second = ((ArrayBlockingQueue<String>) queue).offer("a",1,TimeUnit.SECONDS); System.out.println("two = " + second); boolean third = ((ArrayBlockingQueue<String>) queue).offer("a",1,TimeUnit.SECONDS); System.out.println("three = " + third); } }
输出:
继续执行了
first = true
two = false
three = false
有界队列是强大的资源管理工具,用来建立可靠的应用程序;他们遏制那些可以产生过多工作量、具有威胁的活动,从而让你的程序在面对超负荷工作
时更加健壮.
一些常用的阻塞队列介绍: 类库中包含一些BlockingQueue的实现,其中LinkedBlockingQueue和ArrayBlockingQueue
是FIFO(first in first out 先进先出)队列,与LinkedList和ArrayList相似,但是却拥有比同步List更好的
并发性能.PriorityBlockingQueue是一个按优先级顺序排序的队列,可以使用Comparator进行排序
还有一个SynchronousQueue,它不是真正的队列,因为它不会为队列元素维护任何存储空间,它非常
直接地移交工作,减少了在生产者和消费者之间移动数据的延迟时间.SynchronousQueue这类队列
只有在消费者充足的时候比较合适,它们总能为下一个任务做好准备.
阻塞队列非常重要,只要使用线程池就离不开阻塞队列.
声明一个线程池,构造函数就需要传递阻塞队列:
对阻塞队列有一个非常深入的理解,可以帮助构建更加健壮的并发程序.
Deque(双端队列)和BlcokingDeque是Queue和BlockIngQueue的升级版.Deque允许高效的在头和尾分别进行插入和移除.实现他们的是ArrayDeque和LinkedBlockingDeque.
阻塞队列是和用于生产者-消费者模式,双端队列适用于窃取工作的模式..一个消费者生产者设计中,所有的消费者只共享一个工作队列;在窃取工作的设计中,每一个消费者都有自己的双端队列.如果一个消费者完成了自己双端队列中的全部工作,它可以偷取其他消费者的双端队列中的末尾任务.因为工作者线程并不会竞争一个共享的任务队列,所以窃取工作模式比传统的生产者-消费者设计有更佳的可伸缩性;大多数时候他们访问自己的双端队列,减少竞争.当一个工作者必须要访问另一个队列时,它会从尾部截取,而不是头部,从而进一步降低对双端队列的争夺.
阻塞和可中断的方法
线程可能会因为几种原因阻塞或暂停: 等待I/O操作结束,等待获得一个锁,等待从Thread.sleep中唤醒,或者是等待另一个线程的计算结果.当一个线程阻塞时,他通常被挂起,并被设置成线程阻塞的某个状态(BLOCKED、WAITING,或是TIMED_WATTING)一个阻塞的操作和一个普通的操作之间的差别仅仅在于,被阻塞的线程必须等待一个事件的发生才能继续进行,并且这个事件是超越它自己控制的,因而需要花费更长的时间----等待I/O操作完成,锁可用,或者是外部计算.当外部事件发生后,线程被置回RUNNABLE状态,重新获得调度的机会.
如果一个方法能够抛出InterruptedException异常,说明这是一个可阻塞的方法,进一步看,如果它被中断,将可以提前结束阻塞状态.
Thread提供了interrupt方法,用来中断一个线程,或者查询某线程是否已经被中断,每一个线程都有一个Boolean类型的属性,这个属性代表了线程的中断状态;中断线程时需要设置这个值.
中断线程休眠的实例:
1 public static void main(String[] args) { //声明一个线程,休眠10s 2 Thread t = new Thread(new Runnable() { 3 @Override 4 public void run() { 5 try { 6 Thread.sleep(10000); 7 } catch (InterruptedException e) { 8 e.printStackTrace(); 9 System.out.println("Thread.currentThread().isInterrupted() = " + Thread.currentThread().isInterrupted()); 10 } 11 } 12 }); 13 System.out.println("t.isInterrupted() = " + t.isInterrupted()); 14 t.start(); 15 t.interrupt(); 16 System.out.println("t.isInterrupted() = " + t.isInterrupted()); 17 18 }
Thread.sleep()方法抛出了一个受检查的异常,证明他是一个可以被中断的方法.在第15行调用的t.interrupt()方法可以中断这个sleep方法.方法的13行、16行、9行 分别在中断操作前,中断操作后、捕获异常后,打印输出线程的中断状态.输出的结果是 false,true,fasle, 说明默认的中断状态是false,执行中断操作以后状态为true,捕获到中断异常又变为了false.
有两种方式来响应中断:
1. 不捕获中断异常,而是抛给上层的调用者.或者捕获异常,做一些简单的处理,然后再重新抛出异常给上层代码
2. 有的时候无法抛出异常,例如在Runnable中的时候,这时候必须捕获InterruptedException.而且你还应该调用interrupt方法恢复中断状态,这样调用栈中更高层的代码可以发现中断已经发生.
示例: 在第8行恢复中断,重新将中断状态设置为true,返回给上层代码.
1 new Runnable() { 2 @Override 3 public void run() { 4 try { 5 Thread.sleep(10000); 6 } catch (InterruptedException e) { 7 e.printStackTrace(); 8 Thread.currentThread().interrupt() 9 } 10 } 11 }
不应该捕获InterruptedException之后不做任何处理,这样做会丢失线程中断的证据,从而剥夺了上层栈的代码处理中断的机会.只有一种情况允许掩盖中断: 你扩展了Thread,并因此控制流所有处于调用栈上层的代码.
Synchronizer
Synchronizer(同步装置)是一个对象,它根据本身的状态调节线程的控制流.阻塞队列可以扮演一个Synchronizer(阻塞的take和put方法来使线程阻塞或执行),接下来简单介绍几个其他类型的同步装置:信号量(semaphore),关卡(barrier)和闭锁(latch).
所有Synchronizer都有类似的结构特性: 它们封装状态,而这些状态决定着线程执行到某一点时是通过还是被迫等待;它们还提供操控状态的方法,以及高效地等待Synchronizer进入到期望状态的方法.
1. 闭锁
闭锁: 可以延迟线程的进度直到线程到达终止状态. 在终止状态到来之前没有线程能够通过,终止状态到来的时候,所有线程都允许通过.终止状态是不可逆的,会永远保持这个状态.
闭锁可以用来确保特定活动指导其他的活动完成后才发生,适合使用闭锁的情况:
1. 确保一个计算不会执行,直到它需要的资源被初始化.
2. 确保一个服务不会开始,直到它依赖的其他服务都已经开始.
3. 等待,直到活动的所有部分都为就处理做好准备
具体的使用: CountDownLatch是一个灵活的闭锁实现,用于以上各种情况:允许一个或多个线程等待一个事件集的发生.闭锁的状态包括一个计数器,初始化为一个整数,用来表现需要等待的事件数,countDown方法对计数器做减操作,表示一个事件已经发生了,而await方法等待计数器到达零,此时所有需要等待的时间都已发生.如果计数器入口时值为非零,await会一直阻塞直到计数器为零,或者等待线程中断以及超时.
示例代码:
public static void main(String[] args) { /*构造函数传入的数字表示的是需要倒计时的次数,这里传了三 * 也就是说必须倒计时三次,否则await方法会阻塞住. * */ CountDownLatch countDownLatch = new CountDownLatch(3); //倒计时三次 //1 countDownLatch.countDown(); //2 countDownLatch.countDown(); //3 countDownLatch.countDown(); try { //如果countDown的次数少于构造方法传入的参数的数量,就会阻塞... countDownLatch.await(); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(1111); }
2. FutureTask
这个用的也挺多的.主要用在需要长时间运行的操作.FutureTask也可以作为闭锁.FutureTask的计算是通过Callable实现的,它等价于一个可携带结果的Runnable,并且有3个状态:等待、运行和完成.完成包括有计算以任意的方式结束,包括正常结束、取消和异常.一旦FutureTask进入完成状态,它会永远停止在这个状态上(是不是和闭锁的终止状态一样).
Future.get方法用来获取任务的结果,如果完成了就及时返回结果,如果没完成那就阻塞.FutureTask把计算的结果从运行计算的线程传送到这个需要结果的线程:FutureTask的归约保证了这种传递建立在结果的安全发布基础之上.
Executor框架(可以理解为线程池)利用FutureTask来完成异步任务,并可以用来进行任何潜在好事计算,而且可以在真正需要计算结果之前就启动它们开始计算.(尽早开始计算,你可以减少等待结果所需花费的时间),
public static void main(String [] args){ Callable<String> callable = new Callable<String>() { @Override public String call() throws Exception { Thread.sleep(5000); return "执行完毕"; } }; FutureTask<String> futureTask = new FutureTask<String>(callable); Thread t = new Thread(futureTask); t.start(); try { String result = futureTask.get(); System.out.println(result); } catch (InterruptedException e) { e.printStackTrace(); } catch (ExecutionException e) { e.printStackTrace(); } }
3.信号量
使用的方式和闭锁差不多,计数信号量(Counting semaphore)用来控制能够同时访问某指定资源的活动的数量,或者同时执行某一给定操作的数量.计数信号量可以用来实现资源池或者给一容器限定边界.
简单的方法介绍:
public static void main(String [] args) throws InterruptedException { //构造方法传入的参数,可以创建一个叫所有集的东西 Semaphore semaphore = new Semaphore(3); //acquire()每次调用消耗一个所有集 semaphore.acquire(); System.out.println("使用了一次"); semaphore.acquire(); System.out.println("使用了两次"); semaphore.acquire(); System.out.println("使用了三次"); //这是第四次调用,没有可用的所有集了,会阻塞.. semaphore.acquire(); // 调用semaphore.release()可恢复一个所有集; }
关卡
之前闭锁介绍的闭锁只要到达了终点状态就没法再次使用了,现在介绍的关卡类似于闭锁,但是它能循环使用,它们都能阻塞一组线程,直到某些时间发生,其中关卡与闭锁关键的不同在于,所有线程必须同时到达关卡点,才能继续处理.闭锁等待的是事件;关卡等待的是线程.
简单的减少了一下CyclicBarrier的使用方法,有兴趣的读者可以复制下来自己测试一下:
public static void main(String [] args) throws BrokenBarrierException, InterruptedException { //构造函数传了两个参数,第一个是等待的线程数,第二个是当所有线程到达关卡点统一执行的任务 CyclicBarrier cyclicBarrier = new CyclicBarrier(3, new Runnable() { @Override public void run() { System.out.println("嘿,还真一起执行了"); } }); //设置三个线程,每个阻塞不同的时间. new Thread(new Runnable() { @Override public void run() { try { Thread.sleep(2000); cyclicBarrier.await(); System.out.println("是否一起执行"); } catch (InterruptedException e) { e.printStackTrace(); } catch (BrokenBarrierException e) { e.printStackTrace(); } } }).start(); new Thread(new Runnable() { @Override public void run() { try { cyclicBarrier.await(); System.out.println("是否一起执行"); } catch (InterruptedException e) { e.printStackTrace(); } catch (BrokenBarrierException e) { e.printStackTrace(); } } }).start(); new Thread(new Runnable() { @Override public void run() { try { Thread.sleep(3000); cyclicBarrier.await(); System.out.println("是否一起执行"); } catch (InterruptedException e) { e.printStackTrace(); } catch (BrokenBarrierException e) { e.printStackTrace(); } } }).start(); /*下面注掉的这些的代码证明关卡可以重复使用. Thread.sleep(1000); Long startTime = System.nanoTime(); new Thread(new Runnable() { @Override public void run() { try { Thread.sleep(5000); Long endTime = System.nanoTime() - startTime; System.out.println("测试阻塞"+endTime); cyclicBarrier.await(); System.out.println("是否一起执行"); } catch (InterruptedException e) { e.printStackTrace(); } catch (BrokenBarrierException e) { e.printStackTrace(); } } }).start(); new Thread(new Runnable() { @Override public void run() { try { Thread.sleep(2000); cyclicBarrier.await(); System.out.println("是否一起执行"); } catch (InterruptedException e) { e.printStackTrace(); } catch (BrokenBarrierException e) { e.printStackTrace(); } } }).start(); new Thread(new Runnable() { @Override public void run() { try { Thread.sleep(1000); cyclicBarrier.await(); System.out.println("是否一起执行"); } catch (InterruptedException e) { e.printStackTrace(); } catch (BrokenBarrierException e) { e.printStackTrace(); } } }).start(); */ }
简单的使用关卡的例子
关卡的用处: 一个步骤的计算可以并行完成,但是必须完成所有与一个步骤相关的工作后才能进行下一步.
总结
基础部分到这里就结束了.以下是基础部分的总结:
1.所有并发问题都归结为如何协调访问并发状态.可变状态越少,保证线程安全就越容易.
2. 尽量将域声明为final类型,除非它们的需要是可变的.
3. 不可变对象天生是线程安全的.
4. 封装使管理复杂度变得可行.
5. 用锁来守护每一个可变变量
6. 对同一不变约束中的所有变量都使用相同的锁.
7. 在非同步的多线程情况下,访问可变变量的程序是存在隐患的.
8. 在设计过程中就考虑线程安全,或者在文档中明确地说明它不是线程安全的.
9. 文档化你的同步策略.
本篇笔记分享就到此为止了,博主下一篇会更新构建并发程序(线程、Executor)方面的博客.我们下篇博客再见!
原文地址:https://www.cnblogs.com/xisuo/p/9779356.html