@@ 要编写线程安全的代码,其核心在于要对状态访问操作进行管理,特别是对共享的(Shared)
和可变的(Mutable)状态的访问。
@@ 从非正式的意义上来说,对象的状态是指存储在状态变量(例如实例或静态域)中的数据。
对象的状态可能包括其他依赖对象的域。
@@ “ 共享 ” 意味着变量可以由多个线程同时访问,而“ 可变 ” 则意味着变量的值在其生命周期
内可以发生变化。
@@ 一个对象是否需要线程安全的,取决于它是否被多个线程访问。这指的是在程序中访问对象
的方式,而不是对象要实现的功能。要使得对象是线程安全的,需要采用同步机制来协同对象可变
可变状态的访问。如果无法实现协同,那么可能会导致数据破坏以及其他不该出现的结果。
@@ 当多个线程访问某个状态变量并且其中有一个线程执行写入操作时,必须采用同步机制来协同
这些线程对变量的访问。Java 中的主要同步机制是 synchronized ,它提供了一种独占的加锁方式,
但 “ 同步 ” 这个术语还包括 volatile 类型的变量,显式锁(Explicit Lock)以及原子变量。
@@ 如果当多个线程访问同一个可变的状态变量时没有任何合适的同步,那么程序会出现错误。
有三种方式可以修复这个问题:
------------ 不在线程之间共享该状态变量
------------ 将状态变量修改为不可变的变量
------------ 在访问状态变量时使用同步
@@ 如果从一开始就设计一个线程安全的类,那么比在以后再将这个类修改为线程安全的类要
容易的多。
@@ 面向对象这种技术不仅有助于编写出结构优雅、可维护性高的类,还有助于编写出线程安全
的类。访问某个变量的代码越少,就越容易确保对变量的所有访问都实现正确同步,同时也更容易
找出变量在哪些条件下被访问。Java 语言并没有强制要求将状态都封装在类中,开发人员完全可以
将状态保存在某个公开的域(甚至公开的静态域)中,或者提供一个对内部对象的公开引用。然而,
程序状态的封装性越好,就越容易实现程序的线程安全性,并且代码的维护人员也越容易保持这种
方式。
@@ 当设计线程安全的类时,良好的面向对象技术、不可修改性,以及明晰的不变性规范都能
起到一定的帮助作用。
@@ 一种正确的编程方法是:首先使代码正确运行,然后再提高代码的速度。即便如此,最好也
只是当性能测试结果和应用需求告诉你必须提高性能,以及测量结果表明这种优化在实际环境中
确实能带来性能提升时,才进行优化。
@@ 完全由线程安全类构成的程序并不一定就是线程安全的,而在线程安全类中也可以包含非
线程安全的类。
@@ 在任何情况下,只有当类中仅包含自己的状态时,线程安全类才是有意义的。线程安全性是
一个在代码上使用的术语,但它只是与状态相关的,因此只能应用于封装其状态的整个代码,这可
能是一个对象,也可能是整个程序。
》》什么是线程安全性
@@ 在线程安全的定义中,最核心的概念就是正确性。
《
正确性的含义:某个类的行为与其规范完全一致。在良好的规范中通常会定义各种不变性条件
(Invariant)来约束对象的状态,以及定义各种后验条件(Postcondition)来描述对象操作的结
果。
》
@@ 线程安全性:当多个线程访问某个类时,这个类始终都能表现出正确的行为,那么就称这个类
线程安全地。
@@ 当多个线程访问某个类时,不管运行时环境采用何种调度方式或者这些线程将如何交替执行,
并且在主调代码中不需要额外的同步或协同,这个类都能表现出正确的行为,那么就称为这个类是
线程安全的。
@@ 在线程安全类的对象实例上执行的任何串行或并行操作都不会使对象处于无效状态。
@@ 在线程安全类中封装了必要的同步机制,因此客户端无须进一步采用同步机制。
@@ 示例:一个无状态的 Servlet
《一个基于 Servlet 的因数分解服务-----Servlet 从请求中提取出数值,执行因数分解,
然后将结果封装到 该 Servlet 的响应中》
----------------------------------------------------------------------------------------------------------------
@ThreadSafe
public class StatelessFactorizer implements Servlet {
public void service( ServletRequest req , ServletResponse resp){
BigInteger i = extractFromRequest(req);
BigInteger[ ] factors = factor(i);
encodeIntoResponse(resp , factors );
}
}
----------------------------------------------------------------------------------------------------------------------
说明:(1)、StatelessFactorizer 是无状态的:它既不包含任何域,也不包含任何对
其他类中域的引用。计算过程中的临时状态仅存在于线程栈上的局部变量中,并且
只能由正在执行的线程访问。访问 StatelessFactorizer 的线程不会影响另一个
访问同一个 StatelessFactorizer 的线程的计算结果,因为这两个线程并没有共享
状态。
@@ 无状态对象一定是线程安全的。
@@ 大多数 Servlet 都是无状态的,从而极大地降低了在实现 Servlet 线程安全性时的复杂性。
只有当 Servlet 在处理请求时需要保存一些信息,线程安全才会成为一个问题。
》》原子性
@@ 在上面的无状态对象中增加一个 “ 命中计数器 ” 来统计所处理的请求数量,代码示例如下:
------------------------------------------------------------------------------------------------------
@NotThreadSafe
public class UnsafeCountingFactorizer implements Servlet {
private long count = 0 ; // 统计该 Servlet 处理的请求数量
public long getCount() {
return count;
}
public void service( ServletRequest req , ServletResponse resp){
BigInteger i = extractFromRequest(req);
BigInteger[ ] factors = factor(i);
++count ; // "读入---修改---写入"操作序列
encodeIntoResponse(resp , factors );
}
}
------------------------------------------------------------------------------------------------------------------------------
说明:(1)、上面的 UnsafeCountingFactorizer 中的命中计数器,可能会丢失数据。如果
该命中计数器生成数值序列或唯一的对象标识符,那么在多次调用中返回相同的值将
导致严重的数据完整性问题。
(2)、在并发编程中,这种由于不恰当的执行时序而出现不正确的结果是一种非常重要
的情况,它有一个正式的名字:竞态条件(RaceCondition)
### 竞态条件
@@ 当某个计算的正确性取决于多个线程的交替执行时序时,那么就会发生竞态条件。换句话说,
就是正确的结果要取决于运气。最常见的竞态条件类型就是 “先检查后执行(Check-Then-Act)”,
即通过一个可能失败的观测结果来决定下一步的动作。
@@ 竞态条件,因为要获得正确的结果,必须取决于事件的发生时序。
@@ 大多数竞态条件的本质:基于一种可能失效的观察结果来做出判断或者执行某个计算。
### 示例:延迟初始化中的竞态条件
@@ 使用 “ 先检查后执行 ” 的一种常见情况就是延迟初始化。延迟初始化的目的是将对象的
初始化操作推迟到实际被使用才进行,同时要确保只被初始化一次。(例如下面的代码)
-----------------------------------------------------------------------------------------------------------------------------------
@NotThreadSafe
public class LazyInitRace {
private ExpensiveObject instance = null; // 单例
public ExpensiveObject getInstance( ){
if ( instance == null){
instance = new ExpensiveObject ( );
}
return instance ;
}
}
-------------------------------------------------------------------------------------------------------------------------
补充:上面的程序 LazyInitRace 中包含一个竞态条件,它可能会破坏这个类的正确性。
假定线程 A 和线程 B 同时执行 getInstance( ) , 那么两个线程获取的结果会是什么呢?
这将取决于两个线程之间的不可预测的时序。
@@ 与大多数并发错误一样,竞态条件并不总是会产生错误,还需要某种不恰当的执行
时序。然而,竞态条件也可能导致严重的问题。
### 复合操作
@@ UnsafeCountingFactorizer 和 LazyInitRace 都包含一组需要以原子方式执行(或者
说不可分割)的操作。要避免竞态条件问题,就必须在某个线程修改变量时,通过某种方式
防止其他线程使用这个变量,从而确保其他线程只能在修改操作完成之前或之后读取和修改状态,
而不是在修改状态的过程中。
@@ 假定有两个操作 A 和 B ,如果从执行 A 的线程来看,当另一个线程执行 B 时,要么
将 B 全部执行完,要么完全不执行 B ,那么 A 和 B 对彼此来说是原子的。
原子操作是指,对于访问同一个状态的所有操作(包括该操作本身来说),这个操作
是以原子方式执行的操作。
@@ 我们将 " 先检查后执行 " 以及 “ 读入-----修改------写入 ” 等操作统称为复合操作:包含了
一组必须以原子方式执行的操作以确保线程安全性。
@@ 下面使用 AtomicLong 类型的变量来统计已处理请求的数量
--------------------------------------------------------------------------------------------------------------------------
@ThreadSafe
public class UnsafeCountingFactorizer implements Servlet {
private final AtomicLong count = new AtomicLong( 0 ) ;
public long getCount() {
return count.get( ) ;
}
public void service( ServletRequest req , ServletResponse resp){
BigInteger i = extractFromRequest(req);
BigInteger[ ] factors = factor(i);
count.incrementAndGet( ) ;
encodeIntoResponse(resp , factors );
}
}
----------------------------------------------------------------------------------------------------------------
补充:(1)、在 java.util.concurrent.atomic 包中包含了一些原子变量类,用于实现在
数值和对象引用上的原子状态转换。上面的程序用 AtomicLong 来代替 long
类型的计数器,能够确保所有对计数器状态的访问操作都是原子的。
(2)、当在无状态的类中添加一个状态时,如果该状态完全由线程安全的对象来管理,
那么这个类仍然是线程安全的。
@@ 在实际情况中,应尽可能地使用现有的线程安全对象(例如 AtomicLong)来管理类的
状态。与非线程安全的对象相比,判断线程安全对象的可能状态及其状态转换情况要更为
容易,从而更容易维护和验证线程安全性。
@@ 当状态变量的数量由一个变为多个时,并不会像状态变量数量由零个变为一个那样简单。
》》加锁机制
@@ 加锁机制是 Java 中用于确保原子性的内置机制。
@@ 在线程安全性的定义中要求,多个线程之间的操作无论采用何种执行时序或交替方式都要
保证不变性条件不被破坏。
@@ 当在不变性条件中涉及多个变量时,各个变量之间并不是彼此独立的,而是某个变量的值
会对其他变量的值产生约束。因此,当更新某一个变量时,需要在同一个原子操作中对其他
变量同时进行 更新。
@@ 要保持状态的一致性,就需要在单个原子操作中更新所有相关的状态变量。
### 内置锁
@@ Java 提供了一种内置的锁机制来支持原子性:同步代码块(Synchronized Block)。
《
同步代码块包括两部分:一个作为锁的对象引用
一个作为由这个锁保护的代码块
》
@@ 以关键字 synchronized 来修饰的方法就是一种横跨整个方法体的同步代码块,其中该
同步代码块的锁就是方法调用所在的对象。
静态的 synchronized 方法以 Class 对象作为锁。
@@ 每个 Java 对象都可以用做一个实现同步的锁,这些锁被称为内置锁(Intrinsic Lock)
或监视器锁(Monitor Lock)。
线程在进入同步代码块之前会自动获得锁,并且在退出代码块时自动释放锁,而无论是
通过正常的控制路径退出,还是通过从代码块中抛出异常退出。
获得内置锁的唯一途径是进入由这个锁保护的同步代码块或方法。
@@ Java 的内置锁相当于一种互斥体(或互斥锁),这意味着最多只能有一个线程能持有
这种锁。
@@ 由于每次只能有一个线程执行内置锁保护的代码块,因此,由内置锁保护的同步代码块
会以原子方式执行,多个线程在执行该代码块时也不会相互干扰。
并发环境中的原子性与事务应用程序中的原子性有着相同的含义------一组语句作为一个
不可分割的单元被执行。
@@ 示例:使用内置锁,确保因数分解 Servlet 线程安全性变的简单(代码如下)
-------------------------------------------------------------------------------------------------------------------------------
@ThreadSafe
public class SynchronizedFactorizer implements Servlet {
private BigInteger lastNumber ;
private BigInteger[ ] lastFactors ;
public synchronized void service( ServletRequest req , ServletResponse resp){
BigInteger i = extractFromRequest( req ) ;
if ( i.equals( lastNumber )){
encodeIntoResponse( resp , lastFactors ) ;
}else{
BigInteger[ ] factors = factor( i ) ;
lastNumber = i ;
lastFactors = factors ;
encodeIntoResponse( resp , factors ) ;
}
}
}
-------------------------------------------------------------------------------------------------------------------------------
补充:
上面的 SynchronizedFactorizer 使用 synchronized void service是线程安全的,
然而,这种方法却过于极端,因为多个客户端无法同时使用因数分解 Servlet ,服务
的响应性非常低,无法令人接受。这是一个性能问题,而不是线程安全问题。
### 重入
@@ 当某个线程请求一个由其他线程持有的锁时,发出请求的线程就会阻塞。然后,由于
内置锁是可重入的,因此如果某个线程试图获得一个已经由它持有的锁,那么这个请求就会
成功。
“ 重入 ”意味着获得锁的操作的粒度是 “ 线程 ” 而不是 “ 调用 ” 。
重入的一种实现方法是,为每个锁关联一个获取计数值和一个所有者线程。当计数值为
为 0 时,这个锁就被认为是没有被任何线程持有。当线程请求一个未被持有的锁时,JVM 将
记下锁的持有者,并且将获取计数值置为 1 。 如果同一个线程再次获得这个锁,计数值将递
增,而当线程退出同步代码块时,计数器会相应地递减。当计数值为 0 时 , 这个锁将被释放。
@@ 重入进一步提升了加锁行为的封装性,因此简化了面向对象并发代码的开发。
》》用锁来保护状态
@@ 由于锁能使其保护的代码路径以串行形式来访问,因此可以通过锁来构造一些协议以实现
对共享状态的独占访问。只要始终遵循这些协议,就能确保状态的一致性。
@@ 访问共享状态的复合操作
------- 如果在复合操作的执行过程中持有一个锁,那么会使复合操作成为原子操作。然而,
仅仅将复合操作封装到一个同步代码块中是不够的。
-------- 如果用同步来协调对某个变量的访问,那么在访问这个变量的所有位置上都需要使用
同步。
当使用锁来协调对某个变量的访问时,在访问变量的所有位置上都要使用同一个锁。
@@ 对于可能被多个线程同时访问的可变状态变量,在访问它时都需要持有同一个锁,在这
种情况下,我们称状态变量是由这个锁保护的。
@@ 一种常见的错误是认为,只有在写入共享变量时才需要使用同步,然而事实并非如此。
@@ 当获取与对象关联的锁时,并不能阻止其他线程访问该对象,某个线程在获得对象的锁
之后,只能阻止其他线程获得同一个锁。之所以每个对象都有一个内置锁,只是为了免去
显式地创建锁对象。
@@ 你需要自行构造加锁协议或者同步策略来实现对共享状态的安全访问,并且在程序中自始
至终地使用它们。
@@ 每个共享的和可变的变量都应该只由一个锁来保护,从而使维护人员知道是哪一个锁。
@@ 一种常见的加锁约定是,将所有的可变状态都封装在对象内部,并通过对象的内置锁对
所有访问可变状态的代码路径进行同步,使得在该对象上不会发生并发访问。在许多线程
安全类中都使用了这种模式,例如 Vector 和其他的同步集合类。
补充:(1)、上面的模式并没有什么任何特殊之处,编译器或运行时都不会强制实施这种
(或者其他的)模式。
(2)、如果在添加新的方法或代码路径时忘记了使用同步,那么这种加锁协议会很
容易被破坏。
@@ 并非所有数据都需要锁的保护,只有被多个线程同时访问的可变数据才需要通过锁来
保护。
@@ 当某个变量由锁来保护时,意味着在每次访问这个变量时都需要首先获得锁,这样就确保
在同一时刻只有一个线程可以访问这个变量。当类的不变性条件涉及多个状态变量时,那么
还有另外一个需求:在不变性条件中的每个变量都必须由同一个锁来保护。因此可以在单个
原子操作中访问或更新这些变量,从而确保不变性条件不被破坏。
@@ 对于每个包含多个变量的不变性条件,其中涉及的所有变量都需要由同一个锁来保护。
@@ 如果不加区别地滥用 synchronized , 可能会导致程序中出现过多的同步。
@@ 虽然 synchronized 方法可以确保单个操作的原子性,但如果要把多个操作合并为一个复合
操作,还是需要额外的加锁机制。
@@ 将每个方法都作为同步方法还可能导致活跃性问题(Liveness)或性能问题(Performance)。
》》活跃性和性能
@@ 不良并发应用程序:可同时调用的数量,不仅受到可用处理资源的限制,还受到应用程序
本身结构的限制。
@@ 通过缩小同步代码快的作用范围,我们很容易做到既确保 Servlet 的并发性,同时又维护
线程安全性。要确保同步代码块不要过小,并且不要将本应是原子的操作拆分到多个同步代码
块中。应该尽量将不影响共享状态且执行时间较长的操作从同步代码块中分离出去,从而这些
操作的执行过程中,其他线程可以访问共享状态。
@@ 示例:缓存最近执行因数分解的数值及其计算结果的 Servlet
-------------------------------------------------------------------------------------------------------------------------
@ThreadSafe
public class CacheFactorizer implements Servlet {
private BigInteger lastNumber ;
private BigInteger[ ] lastFactors;
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 ) ;
}
}
---------------------------------------------------------------------------------------------------------------------------------------
补充:(1)、位于同步代码块之外的代码将以独占的方式来访问局部(位于栈上的)变量,这些
变量不会在多个线程间共享,因此不需要同步。
(2)、对在单个变量上实现原子操作来说,原子变量是很有用的,但由于我们已经使用了
同步代码块来构造原子操作,而使用两种不同的同步机制不仅会带来混乱,也不会在性能
或安全性上带来任何好处,因此上面的代码中不使用原子变量。
(3)、上面的 CacheFactorizer 实现了简单性(对整个方法进行同步)与并发性(对尽可能
短的代码路径进行同步)之间的平衡。
(4)、要判断同步代码块的合理大小,需要在各种设计需求之间进行权衡,包括安全性(这
个需求必须得到满足)、简单性和性能。
@@ 通常,在简单性与性能之间存在着相互制约因素。当实现某个同步策略时,一定不要盲目地
为了性能而牺牲简单性(这可能会破坏安全性)。
@@ 当使用锁时,你应该清楚代码块中实现的功能,以及在执行该代码块时是否需要很长的时间。
无论是执行计算密集的操作,还是在执行某个可能阻塞的操作,如果持有锁的时间过长,那么
都会带来活跃性问题或性能问题。
@@ 当执行时间较长的计算或可能无法快速完成的操作时(例如,网络 I / O 或 控制台 I / O),
一定不要持有锁。