第2~5章是原书的第二部分,介绍多线程的基础知识,分别是:
- 第2章:线程安全性
- 第3章:共享对象
- 第4章:组合对象的线程安全性
- 第5章:线程安全组件
第2章介绍线程安全性的基本概念,内容如下。
状态
每当我们谈及线程安全的话题,都会想到Thread, Lock这些词语,不过这些都是线程安全相关的工具,就像建筑桥梁所用的钉子、砖头。线程安全真正关注的核心问题是“正确地管理程序状态”,更具体是“管理共享可变状态”。
状态可简单理解为内存数据,在java世界里,状态就是指存储在状态变量里的数据,状态变量可以是对象实例的字段,也可能是类的静态字段。对象的状态包括任何影响该对象外部行为的字内部数据,因此,一个对象的状态不仅包括于它的直接基本类型字段的状态,也包括了它所的引用的其他对象的状态。
并不是对象的所有字段都是它的状态,如果某个字段并不影响对象的外部行为,那么就不是对象状态的一部分。
在java里,状态往往是以对象的形式存在,为了叙述方便,下文会在适当的场合用“对象”这个称谓来代替“状态”。
共享可变状态
这里的共享是指对象可在多个线程中被访问,可变指在对象生命周期中它的状态可能发生变化。如果对象只会被一个线程使用,那么就不会有线程安全问题;同样,如果对象是不可变的,那么即使被多个线程访问,也不会有安全问题。
这个概念提供了两个指引:
1、一方面我们应该多使用非共享,或不可变的对象,以降低线程安全的风险;
2、另一方面,我们必须对共享可变对象采用某种线程安全手段。
多个线程访问一个共享可变对象,如果不采取线程同步措施,可能导致数据崩溃或其他错误行为。
什么是线程安全
我们经常听到一种说法,“一个线程安全的对象,可以在多线程中被安全地使用”,这其实是一句循环解释的废话。我们需要借助“正确性”来定义线程的安全性,正确性可从两方面定义:
-
不变式约束 (invarianty)
一个对象的状态必然要满足某些状态约束,比如一个地理坐标对象,内含经纬度坐标值(x,y),显然它必然满足一些条件才能成为一个有效的坐标值。 -
满足后置条件(postcondition)
对象在某个状态下,执行了一个操作,需要到达一个正确的新状态;比如向一个空的集合add一个元素,那么集合的状态必须是”非空“。
于是,我们可以这样定义线程安全性:一个对象是线程安全的,指当对象被多个线程访问时,无论线程被如何调度,线程之间的代码执行如何交错,它都能满足“正确性”要求。
无状态对象
假设一个对象没有任何影响外部行为的字段,这叫做无状态对象。因为线程安全是关于状态的,所以无状态对象天然就是线程安全的。
此类对象的典型是:工具算法类。
对象的线程安全性如何?
一个对象,我们是考虑它的线程安全属性,不仅取决该对象的状态及行为,更重要是该对象将会被如何使用。是否要将一个对象设计为线程安全的,是一个设计阶段的决定;几乎所有的GUI Framework相关对象都不是线程安全的,因为
这里的设计决策就是“只能在单一线程操作GUI组件”。
将一个非线程安全的对象改造为线程安全,几乎相当于重写;虽然设计线程安全对象需要大量线程同步的技术,但遵循面向对象设计规范是一个良好的开端。
操作的原子性
下面是一段具备记录执行次数的Servlet实现代码:
public class UnsafeCountingFactorizer implements Servlet {
private long count = 0;
public long getCount() { return count; }
public void service(ServletRequest req, ServletResponse resp) {
++count;
}
}
有一定java基础的人一定会判定,该类不是线程安全的,因为++count
不是一个原子操作,CPU实际执行了”加载变量、加法计算、写入变量“三个指令,在多线程环境下,count记录的次数可能会比实际发生的要少。
那么UnsafeCountingFactorizer是否是线程安全的呢?从技术角度来讲,肯定不是。在具体应用场景下,取决于你对正确性的定义,如果我们只想大致记录一下该servlet执行的次数,不关注精确性,那么可以认为它是线程安全的。
竞争条件
如果程序的正确性取决于多个线程之间指令执行的相对顺序,那么说明存在竞争条件。换句话说,程序能否正确运行需要看运气,竞争一词形象地描述了多个线程的相对执行顺序的不确定性。
上面UnsafeCountingFactorizer就存在一个竞争条件,这是典型的"read->modify->write“竞争条件。另外一个常见的竞争条件是"check->then->act":检查某个条件然后执行某个操作,线程竞争可能导致执行操作时条件不再满足。
下面的延迟初始化单例模式实现,包含“check->then->act”模式的竞争条件:
@NotThreadSafe
public class LazyInitRace {
private ExpensiveObject instance = null;
public ExpensiveObject getInstance() {
if (instance == null)
instance = new ExpensiveObject();
return instance;
}
}
组合操作
竞争条件存在的根源在于组合操作的“非原子性”,对于UnsafeCountingFactorizer来说,如果"read->modify->write“这个组合操作是原子操作,那么竞争条件就不复存在。
这里的原子操作是一个相对的概念:一个操作A和操作B(AB可能等价)互为原子操作,意味着当一个线程在执行A时,那么一定没有另外一个线程正在执行B,反过来亦然。
Atomic类
UnsafeCountingFactorizer只包含一个int类型的状态,可以使用Atomic类型来封装它,使得它的更新操作成为原子操作。
public class UnsafeCountingFactorizer implements Servlet {
private AtomicLong count = new AtomicLong(0);
public long getCount() { return count.get(); }
public void service(ServletRequest req, ServletResponse resp) {
count.incrementAndGet();
}
}
jdk的java.util.concurrent.atomic包含一系列Atomic*类型,来支持基本类型的原子修改操作。但是如果对象状态涉及多个字段,那么Atomic类型就无能为力了。
锁
假如有一个提供计算服务的servlet,它接受一个long参数,并返回一个long型的计算结果;由于这个计算是一个很耗时的操作,我们准备在servlet里缓存最后一次计算结果,如果恰好发生连续的、相同参数的调用,可直接使用缓存的结果。
public class UnsafeCacheServlet implements Servlet {
private AtomicLong param = new AtomicLong(0);
private AtomicLong result = new AtomicLong(0);
public void service(ServletRequest req, ServletResponse resp) {
long p = extractFromRequest(req);
if (p==param.get()) {
writeToResponse(resp,result.get())
} else {
long r = computeResult(p);
result.set(r)
writeToResponse(resp,r)
}
}
}
尽管Servlet使用了线程安全的字段类型,但是它自身不是线程安全的,因为param和result之间存在约束关系,并不互相独立,必须确保在同一个原子操作中修改二者。
监视器锁(monitor lock)
Monitor锁是java内置的一种锁,它的用法如下:
synchronized (lock) {
// Access or modify shared state guarded by lock
}
synchronized使用的lock是任意的java对象,使用同一个lock对象的synchronized代码块互为原子操作。任意一个线程在进入这个代码块会先加锁,如果另个一线程正拥有lock监视锁,线程会等待后者释放锁。当线程离开这个代码块时,就会立刻释放锁,即使发生异常。
synchronized可以加在方法上,表示以当前对象为锁,对整个方法体加锁,用该方法可以轻易地将UnsafeCacheServlet变成线程安全的。
public class SafeCacheServlet implements Servlet {
private long param = 0L;
private long result = 0L;
public synchronized void service(ServletRequest req, ServletResponse resp) {
long p = extractFromRequest(req);
if (p==param) {
writeToResponse(resp,result)
} else {
long r = computeResult(p);
param = p;
result = r;
writeToResponse(resp,r)
}
}
}
-
互斥性
监视器锁具备互斥性,也就是同一时刻,只有同一个线程持有监视器锁。 -
可重入性(Reentrancy)
如果线程正持有一个监视器锁,那么该线程可进入同一锁对象保护的代码块,这说明监视器锁是可重入的。可重入锁,是针对线程加锁,而不是针对方法调用加锁,这点很重要,便于我们封装代码,不至于引起意外的死锁。
用锁保护状态
在SafeCacheServlet的例子中,我们用synchronized关键字使得对param和result的修改成为原子操作,假设我们增加以下方法允许外部访问缓存结果:
public class SafeCacheServlet implements Servlet {
private long param = 0;
private long result = 0;
public synchronized long getResultIfCached(int p) {
if (param==p) {
return result;
}
return -1
}
}
尽管方法getResultIfCached没有修改状态,但是仍然需要加上synchronized关键字,否则可能返回错误的结果。这说明,我们必须将所有对某个状态的访问操作,都加上同一个锁,才能保证线程安全性。
锁是用来保护状态的,一个或一组状态用一个锁来保护,所有涉及该(组)状态的读写操作都必须先加锁。
在实际的设计中,必须非常清楚地指明:状态使用哪一个锁来保护的,否则代码难以维护。
锁的性能风险
由于SafeCacheServlet对service整个方法加锁,相当于拒绝了该方法的并发访问,在一个多核CPU的机器上,这是一段性能很差劲的代码。我们可以按以下方式改进性能:
public class SafeCacheServlet implements Servlet {
private long param = 0L;
private long result = 0L;
public void service(ServletRequest req, ServletResponse resp) {
long p = extractFromRequest(req);
synchronized(this) {
if (p==param) {
writeToResponse(resp,result)
}
}
long r = computeResult(p);
synchronized(this) {
param = p;
result = r;
}
writeToResponse(resp,r)
}
}
改进的方式是缩小了锁覆盖的代码范围,我们再次提醒自己,需要保护的不是代码而是状态,所以上面将访问状态的两段代码分别用锁覆盖。
上面的改进之所以是必要的,是因为computeResult是一个耗时的操作,否则将同步代码块一拆为二未必能提升性能,毕竟加锁&释放锁也有些许性能消耗。所以在实际工作中,所要同步的方法如果没有包含IO操作、复杂耗时操作,或其他可能导致阻塞的操作,更推荐将锁加在方法上。