Java编程思想学习课时(八)第21章-并发
顺序编程,即程序中的所有事物在任意时刻都只能执行一个步骤。并发编程,程序能够并行地执行程序中的多个部分。
21.2.1 定义任务
线程可以驱动任务,因此你需要一种描述任务的方式,这可以由Runnable
接口来提供。要想定义任务,只需实现Runnable
接口并编写run()
方法,使得该任务可以执行你的命令。
当从Runnable
导出一个类时,它必须具有run()
方法,但是这个方法并无特殊之处——它不会产生任何内在的线程能力。要实现线程行为,你必须显式地将一个任务附着到线程上。
21.2.3 使用Executor
FixedThreadPool
与 CachedThreadPool
FixedThreadPool
, 可以一次性预先执行代价高昂的线程分配,因而也就可以限制线程的数量了。这可以节省时间,因为你不用为每个任务都固定地付出创建线程的开销。在事件驱动的系统中,需要线程的事件处理器,通过直接从池中获取线程,也可以如你所愿地得到服务。你不会滥用可获得的资源,因为FixedThreadPool使用的Thread对象的数量是有界的。
注意,在任何线程池中,现有线程在可能的情况下,都会被自动复用。
尽管本书将使用
CachedThreadPool
,但是也应该考虑在产生线程的代码中使用FiexedThreadPool
。CachedThreadPool
在程序执行过程中通常会创建与所需数量相同的线程,然后在它回收旧线程时停止创建新线程,因此它是合理的Executor
的首选。只有当这种方式会引发问题时,你才需要切换到FixedThreadPool
。SingleThreadExecutor
就像是线程数量为1
的FixedThreadPool
。(它还提供了一种重要的并发保证,其他线程不会(即没有两个线程会)被调用。这会改变任务的加锁需求)
如果向SingleThreadExecutor
提交了多个任务,那么这些任务将排队,每个任务都会在下一个任务开始之前运行结束,所有的任务将使用相同的线程。在下面的示例中,你可以看到每个任务都是按照它们被提交的顺序,并且是在下一个任务开始之前完成的。因此,SingleThreadExecutor
会序列化所有提交给它的任务,并会维护它自己(隐藏)的悬挂任务队列。
21.2.4 从任务中产生返回值
Runnable
是执行工作的独立任务,但是它不返回任务值。如果你希望任务在完成时能够返回一个值,那么可以实现Callable
接口而不是Runnable
接口。在Java SE5中引入的Callable
是一种具有类型参数的泛型,它的类型参数表示的是从方法call()
(而不是run()
)中返回的值,并且必须使用ExecutorService.submit()
方法调用它。
21.2.9 编码的变体
另一种可能会看到的惯用法是自管理的Runnable
。
这与从Thread
继承并没有什么特别的差异,只是语法稍微晦涩一些。但是,实现接口使得你可以继承另一个不同的类,而从Thread
继承将不行。
注意,自管理的Runnable
是在构造器中调用的。这个示例相当简单,因此可能是安全的,但是你应该意识到,在构造器中启动线程可能会变得很有问题,因为另一个任务可能会在构造器结束之前开始执行,这意味着该任务能够访问处于不稳定状态的对象。这是优选Executor
而不是显式地创建Thread对
象的另一个原因。
21.2.13 线程组
线程组持有一个线程集合。线程组的价值可以引用Joshua Bloch的话来总结:“最好把线程组看成是一次不成功的尝试,你只要忽略它就好了。”
如果你花费了大量的时间和精力试图发现线程组的价值(就像我一样),那么你可能会惊异,为什么没有来自Sun的关于这个主题的官方声明,多年以来,相同的问题对于Java发生的其他变化也询问过无数遍。诺贝尔经济学将得主Joseph Stiglitz的生活哲学可以用来解释这个问题,它被称为承诺升级理论(The Theory of Escalating Commitment):“继续错误的代价由别人来承担,而承认错误的代价由自己承担。”
21.2.14 捕获异常
由于线程的本质特性,使得你不能捕获从线程中逃逸的异常。一旦异常逃出任务的run()
方法,它就会向外传播到控制台,除非你采取特殊的步骤捕获这种错误的异常。
21.3 共享受限资源
可以把单线程程序当作在问题域求解的单一实体,每次只能做一件事情。
21.3.1 不正确地访问资源
因为canceled
标志是boolean
类型的,所以它是原子性的,即诸如赋值和返回值这样的简单操作在发生时没有中断的可能,因此你不会看到这个域处于在执行这些简单操作的过程中的中间状态。
有一点很重要,那就是要注意到递增程序自身也需要多个步骤,并且在递增过程中任务可能会被纯种机制挂起——也就是说,在Java中,递增不是原子性的操作。因此,如果不保护任务,即使单一的递增也不是安全的。
21.4 终结任务
21.4.3 中断
Executor
上调用shutdownNow()
,它将发送一个interrupt()
调用给它启动的所有线程。
Executor
通过调用submit()
而不是excutor()
来启动任务,就可以持有该任务的上下文。submit()
将返回一个泛型的Future<?>
,持有这种Future
的关键在于你可以在其上调用cancel()
,并因此可以使用它来中断某个特定任务。如果你将true
传递给cancel()
,那么它就会拥有在该线程上调用interrupt()
以停止这个线程的权限。因此,cancel()
是一个种中断由Excutor
启动的单个线程的方式。
SleepBlock()
是可中断的阻塞,而IOBlocked
和SynchronizedBlocked
是不可中断的阻塞。上面三个类的示例证明I/O和在synchronized
块上的等待是不可中断的。无论是I/O还是尝试调用synchronized
方法,都不需要任何InterruptedException
处理器。
从关于上面三个类的示例的输出中可以看到,你能够中断对sleep()
的调用(或者任何要求抛出InterruptedException
的调用)。但是,你不能中断试图获取synchronized
锁或者试图执行I/O操作的线程。这有点令人烦恼,特别是在妊I/O的任务时,因为这意味着IO具有锁住你的多线程程序的潜在可能。特别是对于基于Web的程序,这更是关乎利害。
对于这类问题,有一个略显笨拙但是有时确实行之有效的解决方案,即关闭任务在其上发生阻塞的底层资源:
21.5 线程之间的协作
21.5.1 wait()与notifyAll()
wait()
使你可以等待某个条件发生变化,而改变这个条件超出了当前方法的控制能力。通常,这种条件将由另一个任务来改变。你肯定不想在你的任务测试这个条件的同时,不断地进行空循环,这被称为忙等待, 通常是一种不良的周期使用方式。因此wait()
会在等等外部世界产生变化的时候将任务挂起,并且只有在notify()
或notifyAll()
发生时,即表示发生了某些感兴趣的事物,这个任务才会被唤醒并去检查所产生的变化。因此,wait()
提供了一种在任务之间对活动同步的方式。
调用sleep()
的时候锁并没有被 释放,调用yield()
也属于这种情况,理解这一点很重要。 wait()
, notify()
以及notifyAll()
有一个比较特殊的方面,那就是这些方法是基类Object
的一个部分,而不是属于Thread
的一部分。
错失的信号。
21.5.2 notify() 与 notifyAll()
在有关Java的线程机制的讨论中,有一个令人困惑的描述: notifyAll()
将唤醒“所有下在等等的任务”。这是否意味着在程序中任何地方,任何处于wait()
状态中的任务都将被任何对notifyAll()
的调用唤醒呢?有示例说明情况并非如此——事实上,当notifyAll()
因某个特定锁而被调用时,只有等待这个锁的任务才会被唤醒。
21.6 死锁
由Edsger Dijkstrar提出的哲学家就餐问题是一个经典的死锁例证。
要修正死锁问题,你必须明白,当以下四个条件同时满足时,就会发生死锁:
互斥条件。任务使用的资源中至少有一个是不能共享的。这里,一根Chopstick一次就只能被一个Philosopher使用。
至少有一个任务它必须持有一个资源且正在等待获取一个当前被别的任务持有的资源。也就是说,要发生死锁,Philosopher必须拿着一根Chopstick并且等待另一根。
资源不能被任务抢占,任务必须把资源释放当作普通事件。Philosopher很有礼貌,他们不会从其他Philosopher那里抢占Chopstick。
必须有循环等待,这时,一个任务等待其他任务所持有的资源,后者又在等待另一个任务所持有的浆,这样一直下去,直到有一个任务在等待第一个任务所持有的资源,使得大家都被锁住。在DeadlockingDiningPhilosophers.java中,因为每个Philosopher都试图先得到右边的Chopstick,然后得到左边的Chopstick,所以发徨了循环等待。
所以要防止死锁的话,只需破坏其中一个即可。防止死锁最容易的方法是破坏第4个条件。
21.7 新类库中的构件
21.7.1 CountDownLatch
适用场景:它被用来同步一个或多个任务,强制它们等待由其他任务执行的一组操作完成。即一个或多个任务需要等待,等待到其它任务,比如一个问题的初始部分,完成为止。
你可以向CountDownLatch
对象设置一个初始值,任何在这个对象上调用wait()的方法都将阻塞,直到这个计数值到达0.其他因结束其工作时,可以在访对象上调用countDown()来减小这个计数值。CountDownLatch
被设计为只解发一次,计数值不能被重置。如果你需要能够重置计数值的版本,则可以使用CyclicBarrier
。
调用countDown()
的任务在产生这个调用时并没有被阻塞,只有对await()
的调用会被阻塞,直至计数值到达0
。
CountDownLatch
的典型用法是将一个程序分为n
个互相独立的可解决任务,并创建值为n
的CountDownLatch
。当每个任务完成时,都会在这个锁存器上调用countDown()
。等待问题被解决的任务在这个锁存器上调用await()
,将它们自己挂起,直至锁存器计数结束。
21.7.2 CyclicBarrier
适用于这样的情况:你希望创建一组任务,它们并行地执行工作,然后在进行下一下步骤之前等待,直至所有任务都完成(看起来有些像Join())。它使得所有的并行任务都将在栅栏处列队,因此可以一致地向前移动。
例如程序赛马程序:HorseRace.java
21.7.3 DelayQueue
DelayQueue
是一个*的BlockingQueue
(同步队列),用于放置实现了Delayed
接口的对象,其中的对象只能在其到期时才能从队列中取走。这种队列是有序的,即队头对象是最先到期的对象。如果没有到期的对象,那么队列就没有头元素,所以poll()
将返回null
(也正因为此,我们不能将null
放置到这种队列中)。如上所述,DelayQueue
就成为了优先级队列的一种变体。
21.7.4 PriorityBlockingQueue
这是一个很基础的优先级队列,它具有可阻塞的读取操作。这种队列的阻塞特性提供了所有必需的同步,所以你应该注意到了,这里不需要任何显式的同步——不必考虑当你从这种队列中读取时,其中是否有元素,因为这个队列在没有元素时,将直接阻塞读取者。
21.7.5 使用ScheduledExecutor的室温控制器
“温室控制系统”可以被看作是一种并发问题,每个期望的温室事件都是一个预定时间运行的任务。 ScheduledThreadPoolExecutor
可以解决这种问题。其中schedule()用来运行一次任务,scheduleAtFixedRate()每隔规定的时间重复执行任务。两个方法接收delayTime参数。可以将Runnable对象设置为在将来的某个时刻执行。
21.7.6 Semaphre
21.8 仿真
21.8.1 银行出纳员
21.8.2 饭店仿真
BlockingQueue
: 同步队列,当第一个元素为空或不可用时,执行.take()时,等待(阻塞、Blocking)。
SynchronousQueue
: 是一种没有内部容量的阻塞队列,因此每个put()都必须等待一个take(),反之亦然(即每个take()都必须等待一个put())。这就好像你在把一个对象交给某人——没有任何桌子可以放置这个对象,因此只有在这个人伸出手,准备好接收这个对象时,你才能工作。在本例中,SynchronousQueue表示设置在用餐者面前的某个位置,以加强在任何时刻只能上一道菜这个概念。
关于这个示例,需要观察的一项非常重要的事项,就是使用队列在任务间通信所带来的管理复杂度。这个单项技术通过反转控制极大地简化了并发编程的过程:任务没有直接地互相干涉,而是经由队列互相发送对象。接收任务将处理对象,将其当作一个消息来对待,而不是向它发送消息。如果只要可能就遵循这项技术,那么你构建出健壮的并发系统的可能性就会大大增加。
21.8.3 分发工作
21.9 性能调优(Performance Tuning)
21.9.1 比较各类互斥技术(Comparing mutex technologies)
“微基准测试(microbenchmarking)”危险:这个术语通常指在隔离的、脱离上下文环境的情况下对某个特性进行性能测试。当然,你仍旧必须编写测试来验证诸如“Lock比synchronized更快”这样的断言,但是你需要在编写这些测试的进修意识到,在编译过程中和在运行时实际会发生什么。
不同的编译器和运行时系统在这方面会有所差异,因此很难确切了解将会发生什么,但是我们需要防止编译器去预测结果的可能性。
使用Lock通常会比使用synchronized要高效许多,而且synchronized的开销看起来变化范围太大,而Lock相对比较一致。
这是否意味着你永远都不应该使用synchronized关键字呢?这里有两个因素需要考虑:
一是互斥方法的方法体的大小。
二是synchronized关键字所产生的代码与Lock所需的“加锁-try/finally-解锁”惯用法所产生的代码相比,可读性提高了很多。
代码被阅读的次数远多于被编写的次数。在编程时,与其他人交流相对于与计算机交流而言,要重要得多,因此代码的可读性至关重要。因此,以synchronized关键字入手,只有在性能调优时才替换为Lock对象这种做法,是具有实际意义的。
21.9.2 免锁容器(Lock-free containers)
这些免锁窗口的通用策略是:对容器的修改可以与读取操作同时发生,只要读取者只能看到完成修改的结果婀。修改是在容器数据结构的某个部分的一个单独的副本(有时是整个数据结构的副本)上执行的,并且这个副本在修改过程中是不可视的。只有当修改完成时,被修改的结构都会自动地与主数据结构进行交换,之后读取者就可以看到这个修改了。
乐观锁
只要你主要是从免锁容器中读取,那么它就会比其synchronized对应物快许多,因为获取和释放锁的开销被省掉了。如果需要向免锁容器中执行少量写入,那么情况仍旧如此,但是什么算“少量”?这是一个很有意思的问题。
21.11 总结
线程的一个额外好处是它们提供了轻量级的执行上下文切换(大约100条指令),而不是重量级的进程上下文切换(要上千条指令)。因为一个给定进程内的所有线程共享相同的内存空间,轻量级的上下文切换只是改变了程序的执行序列和局部变量。进程切换(重量级的上下文切换)必须改变所有内存空间。
相关文章:
以上就是Java编程思想学习课时(八)第21章-并发的详细内容,更多请关注其它相关文章!
上一篇: Netty 线程模型的实例详解
下一篇: Java基础学习-- 继承 的简单总结
推荐阅读
-
阿里Java学习路线:阶段 1:Java语言基础-Java语言高级特性:第1章:Java多线程编程:课时3:Thread类实现多线程
-
java并发编程学习(八) synchronized详解
-
《Java编程思想》第21章 并发(1)
-
Java编程思想第21章并发读书笔记(上)
-
阿里Java学习路线:阶段 1:Java语言基础-Java语言高级特性:第1章:Java多线程编程:课时5:Thread与Runnable关系
-
Java编程思想学习课时(一):第1~13、16章
-
Java编程思想学习课时(二)第14章-类型信息
-
Java编程思想学习笔记——第11章 持有对象
-
Java编程思想学习课时(一):第1~13、16章
-
Java编程思想学习课时(二)第14章-类型信息