0. “电脑线圈”还挺不错的
我们并不希望每一内存访问进行分析以确保程序是线程安全的,而是希望将一些现有的线程安全组件组合为更大规模的组件或程序。本章将介绍一些组合模式,这些模式能够使一个类更容易成为线程安全的。
1. 设计线程安全的类
基本要素:
找出构成对象状态的所有变量
找出约束状态变量的不变性条件
建立对象状态的并发访问管理策略
同步策略定义了如何在不违背对象不变性条件或后验条件的情况下对其状态的访问操作进行协同。
1.1 收集同步需求
在操作中还会包括一些后验条件来判断状态迁移是否有效。
当下一状态需要依赖当前状态时,这个操作就必须是一个复合操作。
包含多个变量的不变性条件将带来原子性需求,这些相关的变量必须在单个原子操作中进行读取或更新。也就是说,如果在一个不变性条件中包含多个变量,那么在执行任何访问相关变量的操作时,都必须持有保护这些变量的锁。
如果不了解对象的不变性条件与后验条件,那么就不能确保线程安全性。
1.2 依赖状态的操作
要想实现某个等待先验条件为真时才执行的操作,一种更简单的方法是通过现在库中的类。(BQ、信号量)
1.3 状态的所有权
如果以某个对象为根节点构造一张对象图,那么该对象的状态将是对象中所有对象包含的域的一个子集。
所有权在Java中并没有得到充分的体现,而是属于类设计中的一个要素(就如同Map.Entry&Map)。垃圾回收机制使我们避免了如何处理所有权的问题,C++中,当把一个对象传递给某个方法时,必须认真考虑这种操作是否传递对象的所有权,是短期的所有权还是长期的所有权。在Java中同样存在这些所有权模型,只不过垃圾回收器为我们减少了许多在引用共享方面的常见错误,因此降低了在所有权处理上的开销。
许多情况下,所有权与封装性总是相关关联的:对象封装它拥有的状态,反之也成立。然而,如果发布了某个可变对象的引用,那么就不再拥有独占控制权,最多是“共享控制权”。
对于从构造函数或者从方法中传递进来的对象,类通常并不拥有这些对象,除非这些方法是被专门设计为转移传递进来的对象的所有权(例如:通过容器封装器的工厂方法)。
容器类通常表现出一种“所有权分离”的形式,其中容器类拥有其自身的状态,而客户代码则拥有容器中各个对象的状态。Servlet框架中的ServletContext就是其中的一个示例,ServletContext为Servlet提供了类似Map形式的对象容器服务。
由Servlet容器实现的ServletContext对象必须是线程安全的,因为它可定会被多个线程同时访问,当调用set/getAttribute()时,Servlet不需要使用同步,但当使用保存在ServletContext中的对象时,则可能需要使用同步,这些对象应由应用程序拥有,Servlet容器只是替代应用程序(WebApplication)保管它们。
HttpSession有着比Servlet框架更加严格的要求,同时Servlet容器可能需要访问HttpSession中的对象(复制或钝化(序列化以持久化到永久性存储)),除此以外,HttpSession也可能被Web application访问。
2. 实例封闭
当一个对象被封装到另一个对象中,能够访问被封装对象的所有代码路径都是已知的。
与对象可以由整个程序访问的情况相比,更易于对代码进行分析。
被封闭的对象一定不能超出他们既定的作用域(当然,也不能逸出 ),例如:
作为类的一个私有成员
封闭在某个作用域(如:作为局部变量)
线程中(被封闭对象由一个方法传递到另一个方法)
实例封闭可以做到使用不同的锁来保护不同的状态变量。
Java平台的类库中还有一些类可以将非线程安全的类转化为线程安全的类(Collections.synchronizedList()等等),这也是实例封闭结合设计模式做到。
这些工厂方法通过“装饰器”模式将容器类封装在一个同步的包装器对象中,而包装器能将接口中的每一个方法都实现为同步方法,并将调用请求转发到底层的容器对象上。只要包装器对象拥有对底层容器对象的唯一引用(即把底层容器对象封闭在包装器中),那么它就是线程安全的。对底层容器对象的访问必须通过包装器来进行。
封闭机制更易于构造线程安全的类,因为当封闭类的状态时,在分析类的线程安全性时无须检查整个程序。
从线程封闭原则及其逻辑推论可以得出Java监视器模式。遵循Java监视器模式的对象会把对象的所有可变状态都封装起来,并由对象自己的内置来保护。
Java平台的类库中诸如Vector、HashTable都使用到了Java监视器模式
Java监视器模式的优势在于它的简单性
但在某些情况下,程序需要更加细粒度的同步策略
使用 对象封装(私有的)的锁对象 & 使用 对象本身的内置锁的时候(或者任何其他可通过公有方式访问的锁)
使用私有的锁对象,可以避免客户代码得到锁
另一方面,如果客户代码可以通过公有方法访问锁,可以便于参与到它的同步策略中
当然,如果客户代码错误使用另一个对象的锁,那么可能产生活跃性问题
要想验证某个公有访问的锁在程序是否被正确使用,需要检查整个程序,而是单个的类
(基于监视器模式的实现方式)
一般来说,通过在返回客户代码之前复制可变的数据来维持线程安全性的方式,并不存在性能问题,但是在容器非常大的情况下将极大的降低性能。例如:当你需要频繁的刷新最新的快照的时候,这将成为一个问题(准确的来说,取决你实际的业务需求)。
3. 线程安全性的委托
当从头开始构建一个类,或者将多个非线程安全的类组合为一个类时,Java监视器模式是非常有用的。
如果类中的各个组件都已经是线程安全的,我们是否需要再增加一个额外的线程安全层?视情况而定
当变量类型不是final的时候,那么要分析封装类的线程安全性将变得更复杂。
当该变量的引用被修改到另一个的时候,那么必须确保所有访问该变量的线程的可见性,同时还要确保该变量的值不存在竞态条件,这也是之所以使用final的原因。
(基于委托的实现方式,委托给所封装的线程安全的状态变量,并使用final确保引用在初始化之后不会被修改)
回到前面的返回快照的问题:如果该变量是一个final域,并且直接返回这个不可修改但却实时的对象的时候,此时,该变量的修改对于其他的线程均是可见的。
3.1 独立的状态变量
我们还可以将线程安全性委托给多个状态变量,只要这些变量是彼此独立的,即组合而成的类并不会在其包含的多个状态变量上增加任何不变性条件。
3.2 当委托失效时
很多的组合对象的状态变量都存在着某些不变性条件。此时,如果头铁的将线程安全性委托给它的线程安全的状态变量,很容易得到一个无效的状态。此时,可以使用加锁机制以保证这些复合操作都是原子操作,除非整个复合操作都可以委托给状态变量。
3.3 发布底层的状态变量
如果一个状态变量时线程安全的,并且没有任何不变性条件来约束它们的值,在变量的操作上也不存在任何不允许的状态转换,那么就可以安全地发布这个变量。
3.4 在现有的线程安全类中添加功能
Java类库包含许多有用的“基础模块”类。通常,我们应该优先选择重用这些现有的类而不是创建新的类:重用能降低开发工作量、开发风险(因为现有的类都已经通过测试)以及维护成本。
但更多时候,现有的类只能支持大部分的操作,此时就需要在不破坏线程安全性的情况下添加一个新的操作:
- 最简单的方法时直接修改原始的类,需要了解代码中的同步策略并与原有的设计保持一致
- 扩展这个类,假定在设计者这个类时考虑了可扩展性(这种方法也是脆弱的,因为当底层类改变了同步策略,那么子类也将随之破坏)
- 客户端加锁,在客户端代码使用到线程安全类的时候的地方加锁(也是脆弱的,因为你必须去了解这个线程安全类使用的是其底层对象的锁还是自己的锁,如果直接使用客户端对象的锁,跟线程安全类的锁对象不是同一个)
- 使用 组合 是一种更好的方式:使用一个类封装线程安全的类(相当于将这个类的线程安全性委托给线程安全的类),此时客户端将借助这个类来访问内部所封装的线程安全类,提供客户端代码访问的方法可以使用封装对象的锁(通过损失额外的同步层所带来的一些微小的性能以换取更加健壮的程序结构)。
3.5 将同步策略文档化
糟糕的是,我们的直觉通常都是错误的,我们认为“可能是线程安全”的类通常并不是线程安全的,例如SimpleDateFormat并不是线程安全的。
在设计同步策略时需要考虑多个方面,例如:
将哪些变量声明为volatile类型
哪些变量使用锁来保护
哪些锁保护哪些变量
哪些变量必须是不可变的或者是被封闭在线程中的
哪些操作必须是原子操作
等等…
如果某个类没有明确地声明是线程安全的,那么就不要假设它是线程安全的。总之,就是避免写出让人容易假设的文档说明。
3.6 解释含糊的文档
(作者开始吐槽Servlet、JDBC等框架的文档,并做出一些有意义的推断)
如果框架的文档对于“线程"、“并发”的提及甚少的时候,那么你该做些什么呢?你只能去猜测,一种提高猜测准确性的方法时——从实现者(例如容器或者数据库的供应商的角度去解释规范),而不是从使用者的角度去解释。
3.6.1 Servlet
一方面:
Servlet容器能生成一些为多个Servlet对象服务的对象,例如HttpSession或ServletContext。因此,Servlet容器应该预见到这些对象被并发访问,因此我们不得不假设它们已经被实现成线程安全的。
另一方面:
通过setAttribute()放到ServletContext中或者将HttpSession的对象由Web应用程序拥有,而不是Servlet容器拥有。
但Servlet规范中却没有给出任何机制来协调这些共享属性的并发访问
3.6.2 JDBC
一方面:
JDBC规范并没有说明需要使用任何客户端加锁=>“如果不这么做将是不可思议的”=>我们只能假设DataSource.getConnection()不需要额外的客户端加锁
另一方面:
大多数应用程序在实现使用JDBC Connection对象的操作的,通常都会把Connection对象封闭在某个特定的线程中。