对象的状态
对象的状态是指存储在状态变量(例如实例域,静态域)中的数据,还可能包括其他依赖对象的域。对象中的域的值的集合描述着当前特征的信息,这就是对象的状态。在对象的状态中包含了任何可能影响其外部可见行为的数据。
要编写线程安全的代码,其核心在于要对状态访问操作进行管理,特别是对共享的和可变的状态的访问。“共享”意味着变量可以被多个线程同时访问;“可变”意味着变量的值在其生命周期内可以发生变化。
一个对象是否需要是线程安全的,取决于它是否被多个线程访问。这指的是在程序中访问对象的方式,而不是对象要实现的功能。要使得对象是线程安全的,需要采用同步机制来协同对对象可变状态的访问。
Java中的主要同步机制是关键字synchronized,它提供了一种独占的加锁方式。但是“同步”这个术语还包括volatile类型的变量,显式锁以及原子变量。
如果当多个线程访问同一个可变的状态变量时没有使用合适的同步,那么程序就会出现错误。有三种方式可修复这个问题:
- 不在线程之间共享该状态变量。
- 将状态变量修改为不可变的变量;
- 在访问状态变量时使用同步;
应当始终遵循的原则
编写并发应用程序时应当始终遵循的原则是:首先使代码正确运行,然后再提高代码的速度。即便如此,最好也只是当性能测试结果和应用需求告诉你必须提高性能,以及测量结果表明这种优化在实际环境中确实能够带来性能提升时,才进行优化。
线程安全性
线程安全性是一个在代码上使用的术语,但它只是与状态相关的,因此只能应用于封装其状态的整个代码,这可能是一个对象,也可能是整个程序。
在线程安全性的定义中,最核心的概念就是正确性。正确性的含义是:某各类的行为与其规范完全一致。在良好的规范中通常会定义各种不变性条件来约束对象的状态,以及定义各种后验条件来描述对象操作的结果。
线程安全性:当多个线程访问某个类时,不管运行时环境采用何种调度方式或者这些线程将如何交替执行,并且在主调代码中不需要任何额外的同步或协同,这个类都能表现出正确的行为,那么就称这个类是线程安全的。
也可以将线程安全类认为是一个在单线程环境和并发环境都不会被破坏的类。如果正确地实现了某个对象,那么在任何操作中都不会违背不变性条件或后验条件。在线程安全类的对象实例上执行的任何串行或并行操作都不会使对象处于无效状态。
无状态对象一定是线程安全的。因为不存在任何域,也不包含任何对其他类中域的引用。计算过程中的临时状态仅仅存在于线程栈上的局部变量中,并且只能由正在执行的线程访问。
竞态条件: 在并发编程中,由于不恰当的执行时序而出现不正确的结果是一种非常重要的情况。当某个计算的正确性取决于多个线程的交替执行时序时,就会发生竞态条件。
常见的竞态条件类型: “先检查后执行”操作:通过一个可能失效的观测结果来决定下一步的动作,或者来做出判断,或者执行某个计算。常见情况就是延迟初始化。 “读取-修改-写入”操作:基于对象之前的状态来定义对象状态的转换。比如递增运算。
要避免竞态条件问题,就必须在某个线程修改变量时,通过某种方式防止其他线程使用这个变量,从而确保其他线程只能在修改操作完成之前或之后读取和修改状态,而不是在修改状态的过程中。
复合操作
原子操作是指,对于访问同一个状态的所有操作(包括该操作本身)来说,这个操作是一个以原子方式执行的操作。
复合操作是指,包含了一组必须以原子方式执行的操作以确保线程安全性。比如“先检查后执行”,“读取-修改-写入”等操作统称为复合操作。
如何确保原子性?
-
使用一个现有的线程安全类:java.util.concurrent.atomic包中就包含了一些原子变量类。当在无状态的类中添加一个状态时,如果该状态完全由线程安全的对象来管理,那么这个类仍然是线程安全的。然而,当状态变量的数量由一个变为多个时,并不会像状态变量由零个变为一个那样简单。。。
为何?见下例
在线程安全性的定义中要求,多个线程之间的操作无论采用何种执行时序或交替方式,都要保证不变性条件不被破坏。当在不变性条件中涉及多个变量时,各个变量之间并不是彼此独立的,而是某个变量的值会对其他变量的值产生约束。因此,当更新某一个变量时,需要在同一个原子操作中对其他变量同时进行更新。
@NotThreadSafepublic class UnsafeCachingFactorizer implements Servlet { private final AtomicReference
lastNumber = new AtomicReference (); private final AtomicReference lastFactors = new AtomicReference ; public void service(ServletRequest req, ServletResponse resp) { BigInteger i = extractFromRequest(req); if (i.equals(lastNumber.get())) { encodeIntoResponse(resp, lastFactors.get()); } else { BigInteger[] factors = factor(i); // 尽管这里对set方法的每次调用都是原子的,但仍然无法同时更新lastNumber和lastFactors这两个 // 状态变量,而它们之间存在值的约束关系.而且也不能保证会同时获取这两个值。 lastNumber.set(i); lastFactors.set(factors); encodeIntoResponse(resp, factors); } }}复制代码 -
加锁机制:Java提供了一种内置的锁机制来支持原子性:同步代码块(Synchronized Block).
同步代码块包括两部分,一个是作为锁的对象引用,一个作为由锁保护的代码块。以关键字synchronized来修饰的方法就是一种横跨整个方法体的同步代码块,其中该同步代码块的锁就是方法调用所在的对象。静态的synchronized方法以Class对象作为锁。
每个Java对象都可以用做一个实现同步的锁,这些锁被称为内置锁或监视器锁,它们相当于一种互斥体或互斥锁,这意味着最多只有一个线程能持有这种锁。所以每次只能有一个线程执行内置锁保护的代码块,所以由这个锁保护的同步代码块是以原子方式执行的。而获得锁的唯一途径就是进入由这个锁保护的同步代码块或方法。
并发环境中的原子性与事务应用程序中的原子性有着相同的含义,即一组语句作为一个不可分割的单子被执行。
重入:内置锁是可以重入的。如果某个线程试图获得一个已经由它自己持有的锁,那么这个请求就会成功,而不会阻塞。重入进一步提升了加锁行为的封装性。
重入意味着获取锁的操作的粒度是“线程”,而不是调用。一种实现方法就是为每个锁关联一个获取计数值和一个所有者线程。当锁不被任何线程持有时,计数值为0;当线程请求一个未被持有的锁时,JVM将记下锁的持有者,并将获取计数值置为1;如果同一个线程再次获取这个锁,计数值将递增;而当线程退出同步代码块时,计数值会相应地递减;直到计数值为0时,锁被释放。
用锁来保护状态
访问共享状态的复合操作都必须是原子操作,以避免产生竞态条件。如果在复合操作的执行过程中持有一个锁,那么该复合操作就是源自操作。然而仅仅将复合操作封装到一个同步代码块中是不够的。如果用同步来协调对某个变量的访问,那么在访问这个变量的所有位置上都需要使用同步,而且当使用锁来协调对某个变量的访问时,在访问变量的所有位置上都需要使用同一个锁。
对于可能被多个线程同时访问的可变状态变量,在访问它时都需要持有同一个锁,在这种情况下,我们称状态变量是由这个锁保护的。 每个共享的和可变的变量都应该只由一个锁来保护。
由于锁能使其保护的代码路径以串行形式来访问,因此可通过锁来构造一些协议以实现对共享状态的独占访问。只要始终遵循这些协议,就能确保状态的一致性。
一种常见的加锁约定是,将所有的可变状态都封装在对象内部,并通过对象的内置锁对所有访问可变状态的代码路径进行同步,使得在该对象上不会发生并发访问。然而如果在添加新的方法或者代码路径时忘记了使用同步,那么这种加锁协议就会被破坏。
附:对象的内置锁与其状态之间没有内在的关联。虽然大多数类都将内置锁用做一种有效的加锁机制,但对象的域并不一定要通过内置锁来保护。当获取与对象关联的锁时,并不能阻止其他线程访问该对象;某个线程在获得对象的锁之后,只能阻止其他线程获取同一个锁。之所以每个对象都有一个内置锁,只是为了免去显式地创建锁对象。
并非所有的数据都需要锁的保护,只有被多个线程同时访问的可变数据才需要通过锁来保护。当某个变量由锁来保护时,意味着在每次访问这个变量时都必须首先获得锁,这样就可确保在同一时刻只有一个线程可以访问这个变量。
当类的不变性涉及多个状态变量时,那么还有另外一个需求:在不变性条件中的每个变量都必须由同一个锁来保护。所以可以在单个原子操作中访问或更新这些变量,从而确保不变性条件不被破坏。对于每个包含多个变量的不变性条件,其中涉及的所有变量都需要由同一个锁来保护。
同步可避免竞态条件问题,为何不在每个方法声明上都使用synchronized?不加区别地滥用synchronized会怎样?
-
可能导致程序中出现过多的同步。
-
如果只是将每个方法都作为同步方法,其实并不足以确保复合操作都是原子的。
//虽然contains和add方法都是原子方法,但是该复合操作“如果不存在则添加”中仍然存在竞态条件。if(!vector.contains(element)) { vector.add(element);}复制代码
-
如果将每个方法都作为同步方法,还可能导致活跃性问题或性能问题。
所以虽然synchronized方法可以确保单个操作的原子性,但是如果要把多个操作合并为一个复合操作,还是需要额外的加锁机制。
总结
要确保同步代码块不要过小,并且不要将本应是原子的操作拆分到多个同步代码块中。应该尽量将不影响共享状态且执行时间较长的操作从同步代码块中分离出去,从而在这些操作的执行过程中,其他线程可以访问共享状态。要判断同步代码块的合理大小,需要在各种设计需求之间进行权衡,包括安全性(该需求必须满足)、简单性和性能。有时候在简单性和性能之间会发生冲突,但是通常在二者之间能够找到某种平衡。
简单性可粗略理解为对整个方法进行同步;性能可粗略理解为并发性,对尽可能短的代码路径进行同步。见下例。
@ThreadSafepublic class CachedFactorizer implements Servlet { private BigInteger lastNumber; private BigInteger[] lastFactors; //此处并未使用原子变量AtomicLong, 因为已经使用了同步代码块来构造原子操作。若使用两种不同的同步机制会带来混乱。 private long hits; private long cacheHits; public synchronized long getHits() { return hits; } public synchronized double getCacheHitRatio() { return (double) cacheHits / (double) hits; } public void service(ServletRequest req, ServletResponse resp) { BigInteger i = extractFromRequest(req); BigInteger[] factors = null; //此处并发,并没有分得过细,比如递增操作分解到另一个同步代码块中。 synchronized (this) { ++hits; if (i.equals(lastNumber)) { ++cacheHits; factors = lastFactors.clone(); } } if (factors == null) { //该执行时间较长的操作没有持有锁,从而不会过多地影响并发性。 factors = factor(i); //此处并发。 synchronized (this) { lastNumber = i; lastFactors = factors.clone(); } } encodeIntoResponse(resp, factors); }}复制代码