一、概述
在构建稳健的 并发程序时,必须正确地使用
线程和锁
。但这些终归只是一些机制。要编写线程安全的代码, 其核心在于要对状态访问操作进行管理,特别是对共享的(Shared)和可变的(Mutable)状态
的访问。
从非正式的意义上来说_,对象的
状态是指存储在状态变量(例如实例或静态域)中的数据。
对象的状态可能包括其他依赖对象的域。例如,某个HashMap的状态不仅存储在 HashMap对象本身,还存储在许多Map.Entry对象中。在对象的状态中包含了任何可能影响其外部可见行为的数据。
“共享”意味着变量可以由多个线程同时访问,而“可变”则意味着变量的值在其生命周 期内可以发生变化。
一个对象是否需要是线程安全的,取决于它是否被多个线程访问。
这指的是在程序中访 问对象的方式,而不是对象要实现的功能。要使得对象是线程安全的,需要采用同步机制来
协同对对象可变状态的访问。如果无法实现协同,那么可能会导致数据破坏以及其他不该出现 的结果。
当多个线程访问某个状态变量并且其中有一个线程执行写入操作时,必须采用同步机制来协同这些线程对变量的访问。
Java中的主要同步机制是关键字synchronized,它提供了一种独占的加锁方式,
但“同步”这个术语还包括volatile类型的变量,显式锁(Explicit Lock)以及 原子变量。
如果当多个线程访问同一个可变的状态变量时没有使用合适的同步,那么线程就会出现错误。
有三种方式可以修复这个问题:
- 不在线程之间共享该状态变量。
- 将状态变量修改为不可变的变量。
- 在访问状态变量时使用同步。
当设计钱程安全的类时,良好的面向对象技木、不可修政性,以及明晰的不变性规范都能起到一定的帮助作用。
线程安全的程序是否完全由线程安全类构成?答案是否定的,完全由线程安全类构成的程序并不一定就是线程安全的,而在线程安全类中也可以包含非线程安全的类。
在任何情况中,只有当类中仅包含自己的 状态时,线程安全类才是有意义的。线程安全性是一个在代码上使用的术语,但它只是与状态 相关的,因此只能应用于封装其状态的整个代码,这可能是一个对象,也可能是整个程序。
二 、什么是线程安全?
正确性的含义是,某个类的行为与其规范完全一致。
在良好的规范中通常会定义各种不变性条件(Itwariant)来约東对象的状态,
以及定义各种后验条件(Postcondition)来描述对象操作的结果。
我们可以将单线程的正确性近似定义为“所见即所知(we know it when we see it)”。
线程安全性:当多个线程访问某个类时,这个类始终都能表现出正确的行为,那么就称这个类是线程安全的。
由于单线程程序也可以看成是一个多线程程序,如果某个类在单线程环境中都不是正确的,那么它肯定不会是线程安全的。
如果正确地实现了某个对象,那么在任何操作中(包括调用对象的公有方法或者对其公有域进行读/写操作)都不会违背不变性条件或后验条件。 在线程安全类的对象实例上执行的任何串行或并行操作都不会使对象处于无效状态。
如果在线程安全类中封装了必要的同步机—制,那么客户端无须进一步東取同步措。
示列:一个无状态的Servlet
public class StatelessServlet extends HttpServlet {
@Override
public void service(ServletRequest req, ServletResponse res) throws ServletException, IOException {
super.service(req, res);
System.out.println("Hello Servlet");
}
}
与大多数Servlet相同,StatelessServlet是
无状态的:它既不包含任何域,也不包含任何对其他类中域的引用。
计算过程中的临时状态仅存在于线程栈上的局部变量中,并且只能由正在执行的线程访问。访问StatelessServlet的线程不会影响另一个访问同一个StatelessServlet的线程的计算结果,因为这两个线程并没有共享状态,就好像它们都在访问不同的实例。由于线程访问无状态对象的行为并不会影响其他线程中操作的正确性,因此无状态对象一定是线程安全的。
三、原子性
示列:在Servlet中增加一个计数器
public class StatelessServlet extends HttpServlet {
private long count = 0;
public long getCount(){
return count;
}
@Override
public void service(ServletRequest req, ServletResponse res) throws ServletException, IOException {
super.service(req, res);
System.out.println("Hello Servlet");
++count;
}
StatelessServlet并不是线程安全的,尽管它在单线程环境中能正确运行。
这个类很可能会丢失一些更新操作。虽然递增操作++count是一种紧凑的语法,使其看上去只是一个操作,但这个操作并非原子的,因而它并不会作为一个不可分割的操作来执行。实际上,它包含了三个独立的操作:读取count的值,将值加1,然后 将计算结果写入count。这是一个“读取-修改-写入”的操作序列,并且其结果状态依赖于之前的状态。
原子性是指一个操作是不可中断的,要么全部执行成功要么全部执行失败,有着“同生共死”的感觉。
竞态条件
由于不恰当的执行时序而出现不正确的结果是一种非常重要的情况,它有一个正式的名字:竞态条件(Race Condition)。
最常见的竞态条件类型就是“先检査后执行(Check-Then-Act)”
操作,即通过一个可能失效的观测结果来决定下一步的动作。
示例:延迟初始化中的竞态条件
getlnstance方法首先判断ExpensiveObject是否已经 被初始化,如果已经初始化则返回现有的实例,否则,它将创建一个新的实例,并返回一个引 用,从而在后来的调用中就无须再执行这段高开销的代码路径。
public class LazylnitRace {
private Object instance = null;
public Object getlnstance() {
if (instance == null){
instance = new Object();
}
return instance;
}
}
在LazylnitRace中包含了一个竞态条件,它可能会破坏这个类的正确性。假定线程A和 线程B同时执行getlnstance。A看到instance为空,因而创建一*个新的ExpensiveObject实 例。B同样需要判断instance是否为空。此时的instance是否为空,要取决于不可预测的时序, 包括线程的调度方式,以及A需要花多长时间来初始化ExpensiveObject并设置instance。如 果当B检查时,instance为空,那么在两次调用getlnstance时可能会得到不同的结果,即使 getlnstance通常被认为是返回相同的实例。
与大多数并发错误一样,竞态条件并不总是会产生错误,还需要某种不恰当的执行时序。
复合操作
复合操作:包含了一组必须以原子方式执 行的操作以确保线程安全性。
要避免竞态条件问题,就必须在某个线程修改该变量时,通过某种方式防止其他 线程使用这个变量,从而确保其他线程只能在修改操作完成之前或之后读取和修改状态,而不 是在修改状态的过程中。
示列:线程安全的计数器
public class StatelessServlet extends HttpServlet {
private final AtomicLong count = new AtomicLong(0);
public AtomicLong getCount(){
return count;
}
@Override
public void service(ServletRequest req, ServletResponse res) throws ServletException, IOException {
super.service(req, res);
System.out.println("Hello Servlet");
count.incrementAndGet();
}
}
在java.util.concurrent.atomic包中包含了一些原子变量类,用于实现在数值和对象引用上 的原子状态转换。通过用AtomicLong来代替long类型的计数器,能够确保所有对计数器状态 的访问操作都是原子的。e由于Servlet的状态就是计数器的状态,并且计数器是线程安全的, 因此这里的Servlet也是线程安全的。
四、加锁机制
在线程安全性的定义中要求,多个线程之间的操作无论采用何种执行时序或交替方式,都要保证不变性条件不被破坏。
当在不变性条件中涉及多个变量时,各个变量之间并不是彼此独立 的,而是某个变量的值会对其他变量的值产生约束。因此,当更新某一个变量时,需要在同一 个原子操作中对其他变量同时进行更新。
要保持状态的一致性,就需要在单个原子操作中更新所有相关的状态。
内置锁
Java提供了一种内置的锁机制来支持原子性:同步代码块(Synchronized Block)。
(后面将介绍加锁机制以及其他同步机制的另一个重要方面:可见性)同步代码块包括两部分:一个作为锁的对象引用,一个作为由这个锁保护的代码块。
以关键字synchronized来修饰的方法就是一种横跨整个方法体的同步代码块,其中该同步代码块的锁就是方法调用所在的对象。
静态的synchronized方法以Class对象作为锁。
synchronized (lock){
// 访问或修改由锁保护的共享状态
}
每个Java对象都可以用做一个实现同步的锁,这些锁被称为内置锁(Intrinsic Lock)或监视器锁(Monitor Lock)。
线程在进入同步代码块之前会自动获得锁,并且在退出同步代码块时自动释放锁,而无论是通过正常的控制路径退出,还是通过从代码块中抛出异常退出。获得内置锁的唯一途径就是进入由这个锁保护的同步代码块或方法。
Java的内置锁相当于一种互斥体(或互斥锁),这意味着最多只有一个线程能持有这种锁。 当线程A尝试获取一个由线程B持有的锁时,线程A必须等待或者阻塞,直到线程B释放这 个锁。如果B永远不释放锁,那么A也将永远地等下去。
由于每次只能有一个线程执行内置锁保护的代码块,因此,由这个
锁保护的同步代码块会以原子方式执行
,多个线程在执行该代码块时也不会相互干扰。任何一个执行同步代码块的线程,都不可能看到有其他线程正在执行由同一个锁保护的同步代码块。
public synchronized void service(){
}
public synchronized void service(){
synchronized(this){
}
}
重入
当某个线程请求一个由其他线程持有的锁时,发出请求的线程就会阻塞。
然而,由于内置 锁是可重入的,因此如果某个线程试图获得一个已经由它自己持有的锁,那么这个请求就会成功。
“重人”意味着获取锁的操作的粒度是“线程”,而不是“调用”。
重入的一种实现方法 是,为每个锁关联一个获取计数值和一个所有者线程。当计数值为0时,这个锁就被认为是没 有被任何线程持有。当线程请求一个未被持有的锁时,JVM将记下锁的持有者,并且将获取 计数值置为1。如果同一个线程再次获取这个锁,计数值将递增,而当线程退出同步代码块时, 计数器会相应地递减。当计数值为0时,这个锁将被释放。
public class Widget {
public synchronized void doSomething() {
}
}
public class LoggingWidget extends Widget {
@Override
public synchronized void doSomething() {
super.doSomething();
}
}
五、用锁来保护状态
由于锁能使其保护的代码路径以串行形式来访问,因此可以通过锁来构造一些协议以实 现对共享状态的独占访问。只要始终遵循这些协议,就能确保状态的一致性。
对于可能被多个线程同时访问的可变状态变量,在访问它时都需要持有同一个锁,
在这种情况下,我们称状态变量是由这个锁保护的。
每个共享的和可变的变量都应该只由一个锁来保栌,从而使維护人员知道是哪一个锁。
种常见的加锁约定是,将所有的可变状态都封装在对象内部,并通过对象的内置锁对所 有访问可变状态的代码路径进行同步,使得在该对象上不会发生并发访问。在许多线程安全类 中都使用了这种模式,例如Vector和其他的同步集合类。
并非所有数据都需要锁的保护,只有被多个线程同时访问的可变数据才需要通过锁来保护。
当某个变量由锁来保护时,意味着在每次访问这个变量时都需要首先获得锁,这样就确保 在同一时刻只有一个线程可以访问这个变量。当类的不变性条件涉及多个状态变量时,那么还 有另外一个需求:在不变性条件中的每个变量都必须由同一个锁来保护。因此可以在单个原子 操作中访问或更新这些变量,从而确保不变性条件不被破坏。
对于每个包含多个变量的不变性条件,其中涉及的所有变量都需要由同一个锁来保护。
如果同步可以避免竞态条件问题,那么为什么不在每个方法声明时都使用关键字 synchronized ?事实上,
如果不加区别地滥用synchronized,可能导致程序中出现过多的同步。
此外,如果只是将每个方法都作为同步方法,例如Vector,那么并不足以确保Vector上复合操 作都是原子的:
if (!vector.contains(element)){
vector.add(element);
}
此外,将每个方法都作为同步方法还可能导致活跃性问题 (Liveness)或性能问题(Performance)。