当一个类的实例或者静态方法被并发使用的时候, 这个类行为如何, 是该类与客户端建立的约定的重要组成部分. 如果没有在文档中描述其并发行为, 使用这个类的程序员不得不做出某些假设. 如果这些假设是错误的, 这样得到的程序可能引发并发问题, 或者过度同步(在类已经并发安全的情况下,依然在外部做了锁定,同步等操作) . 并发导致的计算错误当然是无法接受的行为, 但另一方面的外部锁定导致的性能严重下降也是无法忽略的问题.
你是否听说过这种说法: 文档中是否出现 synchronized 修饰符,可以确定一个方法是否是线程安全的 . 这种说法是错误的. 使用 synchronized 是实现细节,并不是 api 的一部分, 不一定保证线程是否安全. 并且,”出现synchronized就表明线程安全性” 这种说法实际上隐含了一种错误观念——认为线程安全只有两种情况:要么完全安全,要么完全不安全。 实际上,线程安全性有多种级别。有必要在文档中清晰地说明它支持的线程安全性级别。
-
不可变性(immutable), 此类的实例是不可变的。所以,不需要外部同步,这种例子包括 String Integer BigInteger 等, 他们只能被重新赋值到新的实例,而已创建的实例本身内部是不会变化的。这种就是完全的线程安全(当然,如果是状态管理发生bug ,引发内部状态变化,这属于缺陷,这种情况无法列入考虑范围)
-
无条件的线程安全(unconditionally thread-safe) 这个类的实例是可变的,但其内部处理了同步问题,所以他的实例可以被并发使用,无需外部的同步。 比如 jdk8 开始提供的 LocalDateTime 的格式化工具类 DateTimeFormatter。还有 Random ConcurrentHashMap等
-
有条件的线程安全(conditionally thread-safe) 部分方法为了安全并发,需要外部同步,比如 Collections.synchronized 包装返回的集合, 他们的迭代器(iterator) 要求外部使用同步保证线程安全
-
非线程安全(not thread-safe) 实例可变,内部的字段受方法调用影响而变化。为了并发使用,必须由调用者来处理并发冲突。比如 ArrayList HashMap SimpleDateFormat等
-
线程对立的(thread-hostile) 这个类不能安全的地被多个线程并发使用,即便所有的方法调用都被外部同步包围。线程对立的根源在于,静态数据受影响,且此数据设计时只考虑单个线程的影响。几乎没有人会刻意编写出线程对立的类,基本都是因为没有考虑并发性导致。幸运的是,线程对立的类在 jdk平台类库中非常少。System.runFinalizersOnExit 是线程对立的,已被废弃。
在文档中描述一个有条件的线程安全类要特别小心。你必须指明哪些地方需要外部同步,外部同步需要哪个锁(或者少数情况下,哪几个锁)。通常情况下,这是指作用在实例上的锁,但也有例外,比如一个对象代表了另一个对象的视图,这种情况下,就应该作用在原始的类对象上。
类的线程安全通常放在其文档注释中,但带有特殊线程安全属性的方法则应该在方法上也说明。
另外,没有必要说明枚举类型的不可变性,枚举天然不可变,除非你硬要在枚举中故意绕过机制去做一些可变性的动作。
静态工厂必须在文档中说明返回对象的线程安全型,除非从返回类型来看已经很明显。比如 Collections.synchronizedMap
当一个类承诺了“使用一个公有可访问的锁对象”时,就意味着允许客户端以原子的方式执行一个方法调用序列,但是这种灵活性是要付出代价的。(也即,某个类将自己内部的锁暴露出来,允许外部调用,这样调用者就可以获取锁,并将一组方法作为一个原子来进行调用。类似于jdbc 中声明一个事务,然后开始执行多个sql )
外部控制的锁与内部控制的锁往往是不能兼容的,甚至可能无意间导致死锁,这一点需要注意。
针对继承设计的类,需要考虑使用私有锁对象代替 synchronized 修饰方法。因为 synchronized 修饰方法其本质是锁定了对象,也就是 this。而在继承模式下, 子类可能会无意中干扰到父类的锁定行为,甚至两者之间可能会互相干扰。这不只是一个理论意义上的问题,在Thread 上就出现过。
注意,这个私有锁使用 final 修饰,以避免无意间的修改
简而言之, 每个类都应该认真说明线程的安全性, 安全等级。synchronized 修饰符与此并不是等价关系,或者说不能以这个关键字作为依据。如果有外部同步,需要注明外部同步的时候需要获得哪个(哪些)锁。如果你编写的是无条件的线程安全类,考虑使用私有对象所而不是同步来实现线程安全,不仅可以防止调用者和子类的锁干扰,在后续版本中也可以灵活地处理并发问题。