在构建稳健的并发程序时,必须正确地使用线程和锁。但这些终归只是一些机制。要编写线程安全的代码,其核心在于要对访问状态操作进行管理,特别是对共享的(Shared)和可变的(Mutable)状态的访问。
从非正式的意义上来说,对象的状态是指存储在状态变量中的数据。对象的状态可能包括其他依赖的对象的域。例如,某个HashMap的状态不仅存储在HashMap对象本身,还存储在许多Map.Entry对象中。在对象的状态中包含了任何可能影响其外部可见行为的数据。
“共享”意味着变量可以由多个线程同时访问,而“可变”则意味着变量的值在其生命周期内可以发生变化。一个对象是否需要是线程安全的,取决于它是否被多个线程访问。这指的是在程序中访问对象的方式,而不是对象要实现的功能。要使得对象是线程安全的,需要采用同步机制来协同对对象可变状态的访问。如果无法实现协同,那么可能会导致数据破坏以及其他不该出现的结果。
当多个线程访问某个状态变量并且其中有一个线程执行写入操作时,必须采用同步机制来协同这些线程对变量的访问。Java中的主要同步机制是关键字synchronized,它提供了一种独占的加锁方式,但“同步”这个术语还包括volatile类型的变量,显示锁(Explicit Lock)以及原子变量。
如果当多个线程访问同一个可变的状态变量时没有使用合适的同步,那么程序就会出现错误。有三种方式可以修复这个问题:
- 不在线程之间共享该状态变量;
- 将状态变量修改为不可变的变量;
- 在访问状态变量时使用同步;
如果在设计类时没有考虑并发访问的情况,那么在采用上述方法时可能需要对设计进行重大修改,因此要修复这个问题可谓是知易行难。如果从一开始就设计一个线程安全的类,那么比在以后再将这个类修改为线程安全的类要容易的多。
当设计线程安全的类时,良好的面向对象技术、不可修改性,以及明晰的不变性规范都能起到一定的帮助作用。
2.1 什么是线程安全性
当多个线程访问某个类时,不管运行时环境采用何种调度方式或者这些线程将如何交替执行,并且在主调代码中不需要任何额外的同步或协同,这个类都能表现出正确的行为,那么就称这个类是线程安全的。
在线程安全类中封装了必要的同步机制,因此客户端无需进一步采用同步措施。
来看一个实例:一个无状态的类
public class StatelessFactorizer {
public void service(int i) {
}
}
它既不包含任何域,也不包含任何对其他类中域的引用。访问StatelessFactorizer的线程不会影响到另一个访问同一个StatelessFactorizer的线程的计算结果。
无状态对象一定是线程安全的。
在web开发中,大多数Servlet都是无状态的,从而极大地降低了在实现Servlet线程安全性时的复杂性。
2.2 原子性
当我们在无状态对象中增加一个状态时,会出现什么情况呢?假设我们希望增加一个“命中计数器(Hit Counter)”来统计所处理的请求数量。一种直观的方式是在类中增加一个long类型的域,并且每处理一个请求就将这个值加1,例如:
// 线程不安全
public class StatelessFactorizer {
private long count = 0;
public long getCount() {
return count;
}
public void service(int i) {
++count;
}
}
很不幸,虽然递增操作++count是一宗紧凑的语法,使其看上去只是一个操作,但这个操作并非原子性的,因而它并不会作为一个不可分割的操作来执行。实际上,它包含了三个独立的操作:读取count的值,将值+1,然后将计算结果写入count中。这个一个“读取——修改——写入”的操作序列,并且其结果状态依赖于之前的状态。
在并发编程中,这种由于不恰当的执行时序而出现不正确的结果是一种非常重要的情况,他有一个正式的名字:竞态条件(Race Condition)。
2.2.1 竞态条件
当存在多个竞态条件,会使结果变得不可靠。当某个计算的正确性取决于多个线程的交替执行时序时,那么就会发生 竞态条件。换句话说,就是结果取决与运气。最常见的竞态条件类型就是“先检查后执行(Check-Then-Act)”操作,即通过一个可能失效的观测结果来决定下一步的动作。
2.2.2 延迟初始化
使用“先检查后执行”的一种常见情况就是延迟初始化。延迟初始化的目的是将对象的初始化操作延迟到实际被使用时才进行,同事要确保只初始化一次。例如:
// 线程不安全
public class LazyInitRace {
private LazyInitRace instance = null;
public LazyInitRace getInstance() {
if (instance == null)
instance = new LazyInitRace();
return instance;
}
}
在LazyInitRace中包含了一个竞态条件,它可能会破坏这个类的正确性。假定线程A和线程B同时执行getInstance。A看到instance为空,因而创建一个新的实例。B同样需要判断instance是否为空。此时的instance是否为空,取决于不可预测的时序,包括线程的调度方式,以及A需要花多长时间来初始化实例并设置instance。如果当B检查时,instance为空,那么在两次调用getInstance时可能会得到不同的结果,即使getInstance通常被认为是返回相同的实例。
2.2.3 复合操作
LazyInitRace和StatelessFactorizer都包含一组需要以原子的方式执行的操作。要避免竞态条件问题,就必须在某个线程修改该变量时,通过某种方式阻止其他线程使用这个变量,从而确保其他线程只能在修改操作完成之前或之后读取和修改状态,而不是在修改状态的过程中。
在StatelessFactorize中的计数器问题,我们可以使用加锁机制,在接下会介绍,这是Java中用于确保原子性的内置机制。就目前而言,我们先采用另一种方式来修复这个问题,即使用一个现有的线程安全类:
package cn.net.bysoft.lesson2;
import java.util.concurrent.atomic.AtomicLong;
// 线程安全
public class StatelessFactorizer {
private final AtomicLong count = new AtomicLong(0);
public long getCount() {
return count.get();
}
public void service(int i) {
count.incrementAndGet();
}
}
在实际情况中,应尽可能地使用现有的线程安全对象来管理类的状态。与非线程安全的对象相比,判断线程安全对象的可能状态及其状态转换情况要更为容易,从而也更容易维护和验证线程安全性。
2.3 加锁机制
当在StatelessFactorizer中添加一个状态变量时,可以通过线程安全的对象来管理Servlet的状态以维护安全性。但如果想在其中添加更多的状态,那么是否只需添加更多的线程安全状态变量就足够了?
要保持状态的一致性,就需要在单个原子操作中更新所有相关的状态变量。
2.3.1 内置锁
Java提供了一种内置锁来支持原子性:同步代码块(Synchronized Block)。同步代码块包括两部分:一个作为锁的对象引用,一个作为由这个锁保护的代码块。以关键字synchronized来修饰的方法就是一种横跨整个方法体的同步块,其中该同步块的锁就是方法调用所在的对象。静态的synchronized方法以Class对象作为锁。
每个Java对象都可以用做一个实现同步的锁,这些锁被称为内置锁(Intrinsic Lock)或监视锁(Monitor Lock)。线程在进入同步代码块之前会自动获得锁,并且在退出同步代码块时自动释放锁,而无论是通过正常的控制路径退出,还是通过从代码块中抛出异常退出。获得内置锁的唯一途径就是进入由这个锁保护的代码块或方法。
2.3.2 重入
当某个线程请求一个由其他线程持有的锁时,发出请求的线程就会阻塞。然后,由于内置锁是可重入的,因此如果某个线程试图获得一个已经由自己持有的锁,那么这个请求就会成功。“重入”意味着获取锁的操作的颗粒是“线程”,而不是“调用”。重入的一种实现方法是,为每个锁关联一个获取计数值和一个所有者线程。当计数值为0时,这个锁就被认为是没有被任何线程持有。当线程请求一个未被持有的锁时,JVM将记下锁的持有者,并且将获取计数值设置为1.如果同一个线程再次获取这个锁,计数值将递增,而当线程退出同步代码块时,计数器会相应地递减。当计数值为0时,这个锁被释放。
2.4 用锁来保护状态
由于锁能使其保护的代码路径以串行形式来访问,因此可以通过锁来构造一些协议以实现对共享状态的独占访问。只要始终遵循这些协议,就能确保状态的一致性。
对于可能被多个线程同时访问的可变状态变量,在访问它时都需要持有一个锁,在这种情况下,我们称状态变量是由这个锁保护的。
对象的内置锁与其状态之间没有内在的关联。虽然大多数类都将内置锁用作一种有效的加锁机制,但对象的域并不一定要通过内置锁来保护。当获取与对象关联的锁时,并不能阻止其他线程访问该对象,某个线程在获得对象的锁之后,只能阻止其他线程获得同一个锁。之所以每个对象都有一个内置锁,只是为了免去显示地创建锁对象。你需要自行构造加锁协议或者同步策略来实现对共享状态的安全访问,并且在程序中自始至终地使用他们。
每个共享的和可变的变量都应该只由一个锁来保护,从而使维护人员知道是哪一个锁。
当某个变量由锁来保护时,意味着在每次访问这个变量时都需要首先获得锁,这样就确保在同一时刻只有一个线程可以访问这个变量。当类的不变性条件涉及多个状态变量时,那么还有另外一个需求:在不变性条件中的每个变量都必须由同一个锁来保护。
对于每个包含多个变量的不变性条件,起重涉及的所有变量都需要由同一个锁来保护。
2.5 活跃性与性能
有时候将一个方法用synchronized修饰获得锁,性能方面会非常糟糕。因为每次只能有一个线程在执行这个方法。如果这个方法需要执行很长时间,那么其他的线程必须一直等待这个线程执行完。
在设计时要判断同步代码块的合理大小,需要在各种需求之间权衡,包括安全性、简单性和性能。有时候,在简单性和性能之间会发生冲突,但在二者之间通常能找到某种合理的平衡。
通常,在简单性与性能之间存在着相互制约因素。当实现某个同步策略时,一定不要盲目地为了性能而牺牲简单性(这可能会破坏安全性)。
当使用锁时,你应该清除代码块中实现的功能,以及在执行该代码块时是否需要很长的时间。无论是执行计算密集的操作,还是在执行某个可能阻塞的操作,如果持有锁的时间过长,那么都会带来活跃性或性能问题。
当执行时间较长的计算或者可能无法快速完成的操作时(例如,网络I/O或控制台I/O),一定不要持有锁。