第十三章 并发编程
13.1 为什么要并发
在单线程的应用中,目的与时机紧密耦合,程序员只需要通过堆栈、断定即可断定应用的状态。
而并发是一种解耦策略,他帮助我们把做什么(目的)和何时(时机)做分解开。
解耦目的与时机能明显地改进应用程序的吞吐量和结构。从结构的角度来看,应用程序看起来更像是许多台协同工作的计算机,而不是一个大循环。
例如,web应用的servlet标准模式,当有web请求时,servlet就会异步执行。原则上,每次servlet 是在自己的小世界中执行,与其他servlet的执行是分离的。
但结构并非采用并发的唯一动机。有些系统对响应时间和吞吐量有要求,需要手工编写并发解决方案。
迷思与误解
- 并发总能改进性能
- 编写并发程序无需修改设计
- 在采用web容器的时候,理解并发问题并不重要
并发一些中肯的说法
- 并发会在性能和编写额外代码上增加一些开销
- 正确的并发是复杂的,即使对于简单的问题也是如此
- 并发缺陷并非总能重现,所以常被看作偶发事件而忽略,未被当做真的缺陷看待
- 并发常常需要对设计模式策略的根本性修改
13.2 挑战
一个i++。当有多个线程执行的时候,他的计数就就会有多种结果。
13.3 并发防御原则
13.3.1 单一权责原则
13.3.2 推论:限制数据作用域
两个线程修改共享对象的同一字段时,可能互相干扰,导致未预期的行为。
共享数据的地方越多,就越可能:
- 你会忘记保护一个或多个临界区——破坏了修改共享数据的代码
- 得多花力气保证一切都受到有效防护
- 很难找到错误源,也很难判断错误源
建议:谨记数据封装;严格限制对可能被共享的数据的访问。
13.3.3 推论:使用数据副本
一开始就要避免数据共享,如果有避免共享数据的简易手段,结果代码就会大大减少导致错误的可能。从多个线程收集所有复本的结果,并在单个线程中合并这些结果。
13.3.4 推论:线程应尽可能地独立
尝试将数据分解到可被独立线程(可能在不同处理器上)操作的独立子集。让每个线程在自己的世界中存在,不与其他线程共享数据。
13.4 了解Java库
Doug Lea写了一本《Java并发编程》,里面的代码成了java.util.concurrent的一部分。
13.5 了解执行模型
13.5.1 生产者-消费者模型
一个或多个生产者线程创建某些工作,并置于缓存或队列中。一个或多个消费者线程从 队列中获取并完成这些工作。生产者和消费者之间的队列是一种限定资源。、
13.5.2 读者-作者模型
需要为读者线程提供信息源,单偶尔会被作者线程更新的共享资源。需要平衡读者和作者的线程。
13.5.3 宴席哲学家
圆桌里面有一碗面,每个哲学家左手都有一个叉子,但是需要2个叉子才可以进食。要么是先给左或右边的先吃,吃完再给中间的人叉子。
用线程代替哲学家,用资源代替叉子,就变成了许多企业级应用中进程竞争资源的情形。
建议:学习这些基础算法,理解其解决方案。
13.6 警惕同步方法之间的依赖
避免使用一个共享对象的多个方法。
有时必须使用一个共享对象的多个方法。在这种情况发生时,有3种写对代码的手段:
- 基于客户端的锁定——客户端代码在调用第一个方法前锁定服务端,确保锁的范围 覆盖了调用最后一个方法的代码;
- 基于服务端的锁定——在服务端内创建锁定服务端的方法,调用所有方法,然后解 锁。让客户端代码调用新方法;
- 适配服务端——创建执行锁定的中间层。这是一种基于服务端的锁定的例子,但不 修改原始服务端代码。
13.7 保持同步区域微小
13.8 很难编写正确的关闭代码
想象一个系统中有个父线程分裂出数个子线程,父线程等待所有子线程结束,然后释放资源并关闭。如果其中一个子线程发生死锁会怎样?父线程将一直等待下去,而系统就永远不能关闭。
这类情形并非那么不常见。如果你要编写涉及平静关闭的并发代码,请多预留一些时间 搞对关闭过程。
**建议:尽早考虑关闭问题,尽早令其工作正常。这会花费比你预期更多的时间。检视既 有算法,因为这可能会比想象中难得多。 **
13.9 测试线程代码
证明代码的准确性不切实际,测试并不能确保正确性,好的测试却能尽量降低风险。
- 将伪失败看作可能的线程问题;
- 先使非线程代码可工作;
- 编写可插拔的线程代码;
- 编写可调整的线程代码;
- 运行多于处理器数量的线程;
- 在不同平台上运行;
- 调整代码并强迫错误发生。
13.9.1 将伪失败看作可能的线程问题
在多线程情况下,代码缺陷可能要执行上万次才会显现出一次。经常会把这些错误归结为硬件错误操作系统错误等等,所以遇到错误不要归咎为偶发事件。
13.9.2 先使非线程代码可工作
不要同时追踪非线程缺陷和线程缺陷。确保代码在线程之外可工作。
13.9.3 编写可插拔的线程代码
编写可在数个配置环境下运行的线程代码:
- 单线程与多个线程在执行时不同的情况;
- 线程代码与实物或测试替身互动;
- 用运行快速、缓慢和有变动的测试替身执行;
- 将测试配置为能运行一定数量的迭代。
编写可插拔的代码,这样就能在不同的环境下运行。
13.9.4 编写可调整的线程代码
需要写要多线程代码,就要试错。需要在不同的环境下调整线程数量。
13.9.5 运行多于处理器数量的线程
系统在切换任务时会发生一些事。为了促使任务交换的发生,运行多于处理器或处理器核心数量的线程。任务交换越频繁,越有可能找到错过临界区或导致死锁的代码。
13.9.6 在不同平台上运行
13.9.7 装置试错代码
并发的代码是很难去捕获的,可以装置代码,增加对Object.wait()、 Object.sleep()、Object.yield ()和Object .priority(()等方法的调用,改变代执行顺序。
13.9.8 硬编码
- 你得手工找到合适的地方插入该方法。
- 不要在生产环境中留下这些代码
- 往往这样也找不到缺陷,因为不在你的把握之中。
13.9.9 自动化
可以使用Aspect -Oriented Framework 、CGLIB 或ASM之类工具通过编程来装置代。 例如,可以使用有单个方法的类:
13.10 小结
只要采用了整洁的做法,做对的可能性就有翻天覆地的提高。