线程安全性

Java中主要的同步机制:

  • synchronized关键字
  • volatile类型变量
  • 显式锁
  • 原子变量

面向对象的程序状态的封装性越好,访问某个变量的代码越少,就越容易确保对变量的所有访问实现正确同步,同时也更容易找出变量在哪些条件下被访问,也就越容易实现线程安全性。

什么是线程安全性

正确性:某个类的行为与其规范完全一致。

线程安全性:当多个线程访问某个类时,不管运行时环境运用何种调度方式,或者这些线程如何交替执行,并且在调用代码中不需要任何额外的同步或协同,这个类始终表现正确的行为,那么就称这个类是线程安全的。

书中举了一个无状态Servlet的例子,表示无状态对象一定是线程安全的。

原子性

在Servlet中增加一个实例变量,并且多线程调用i++的方式,说明非线程安全。

竞态条件

当某个计算的正确性取决于多个线程的交替执行时序时,就会发生竞态条件。

常见的竞态条件类型就是“先检查后执行”操作,即通过一个可能失效的观测结果来决定下一步的动作:首先观察到某个条件为真(例如文件X不存在),然后根据这个结果采用相应的动作(创建文件X),但事实上,在你观察到结果以及开始创建文件之间,观察结果可能会失效(另一个线程在此期间创建了文件X),从而导致各种问题(某预期的异常、数据被覆盖、文件被破坏等)。

示例:延迟初始化中的竞态条件

线程不安全的懒汉单例模式。

复合操作

我们将“先检查后执行”、“读取-修改-写入”等称为复合操作:包含了一组必须以原子方式执行的操作以确保线程安全性。

书中采用了现有的线程安全类java.util.concurrent.atomic.AtomicLong原子变量,来实现在数值和对象引用上的原子状态转换(AtomicLong#incrementAndGet())。

加锁机制

如何想在Servlet中添加更多的状态,是否只需要增加更多的线程安全状态变量(如AtomicReference)就可以了?

要保持状态的一致性,就需要在单个原子操作中更新所有相关的状态变量。

内置锁

synchronized修饰的方法的锁就是方法调用所在的对象,static synchronized方法以Class对象为锁。

Java内置锁(也成为监视器锁Monitor Lock),相当于一种互斥锁,最多只有一个线程能持有。

重入

内置锁是可重入的,如果某个线程试图获取一个已经由自己持有的锁,那么这个请求可以成功。

可重入意味着获取锁的粒度是线程,而不是调用。

重入的一种实现方式是,为每个锁关联一个所有者线程与获取计数器,计数器为0表示未被任何锁持有;重入时,计数器递增。

重入提升了加锁行为的封装性,简化了面向对象并发代码的开发。

同一对象内,一个synchronized方法调用另外一个synchronized不会出现死锁。

用锁来保护状态

你需要自行构造加锁协议或者同步策略来实现对共享状态的安全访问,并且在程序中自始至终使用它。

一种常见的加锁约定:将所有可变状态都封装在对象内部,并通过对象的内置锁对所有访问可变状态的代码路径同步,使得在该对象上不会出现并发访问。常见的线程安全集合类如Vector、HashTable都使用了这种模式。

当类的不变性条件涉及到多个状态变量时,那么还有另外一个需求:在不变性条件中的每个变量都必须有同一个锁来保护。

另外,如果只是将每个方法加上synchronized,并不能保证符合操作都是原子的,如Vector:

1
2
3
if (vector.contains(element)) {
vector.add(element);
}

活跃性与性能

将Servlet#service()方法声明为synchronized虽然可以同步,每次只有一个线程可以执行,但这背离了Servlet框架的初衷。这种程序称之为不良并发(Poor Concurrency):可同时调用的数量,不仅受到可用处理资源的限制,还受到程序本身结构的限制。

需要缩小同步代码块范围。

使用synchronized后不再使用Atomic变量,不推荐使用多种不同的同步机制。

使用锁时,如果持有锁时间过长(如执行时间很长的计算、网络IO等无法快速完成的操作),那么会带来活跃性或性能问题。