线程安全性概述
要编写线程安全的代码,其核心在于要对其状态访问进行管理,特别是对共享的和可变的状态的访问,对象的状态是指存储在状态变量(例如实例域或静态域)中的数据,还可包括其他依赖对象的域。
“共享”意味着变量可以由多个线程同时进行访问,而“可变”是指变量的值在整个生命周期内是可变化的。
一个对象是否是线程安全的,取决于它是否是同时被线程访问,如果需要对象是线程安全的,就需要采用同步、协同的方式来对对象的可变变量进行访问,要不然会导致数据破坏或者其他非正常的结果产生。
当多个线程同时访问对象的可变变量没有作相应的同步时,有三种方式可以修复这个问题:1、不要在线程之间共享变量;2、将状态变量改为不可变变量;3、访问变量时使用同步。
一、线程安全性
1、线程安全性的定义:代码的行为与其规范一致。当多个线程访问某个类时,这个类始终都能表现出正确的行为,那么这个类就是线程安全的。
2、当多个线程访问某个类时,不管运行的环境采用何种调度方式,不管线程对类是如何交替的执行,并且在访问类的过程中不采用额外的同步,这个类都能表现出正确的行为,那么这个类就是线程安全的。
3、如果某个类在单线程的环境中执行都不是正确的,那么它肯定不是线程安全的。
4、无状态的类一定是线程安全的。
示例:
public class StatelessFactorizer implements Servlet{ public void service(ServletRequest req,ServletResponse resp){ BigInteger i = extractFromRequest(req); BigInteger[] factors = factor(i); encodeIntoResponse(resp,factors); } }
它既不包含任何域,也不包含任何对其他域中的引用,状态都是存在于线程栈上的局部变量中,并只能由当前执行的线程进行访问,线程间没有共享状态,因此无状态的对象是线程安全的。
二、原子性
先来看个例子:
public class UnsafeCountingFatorizer implements Servlet{ private long count = 0; public long getCount(){return count;} public void service(ServletRequest req,ServletResponse resp){ BigInteger i = extractFromRequest(req); BigInteger[] factors = factor(i); ++ count; encodeIntoResponse(resp,factors); } }
为什么这个类不是线程安全的,虽然看上去++count是个紧凑的语法,但是这个操作并不是原子的,实际上它包括了三个独立的操作:读取count的值,将值加1,然后将计算的结果写入count,这是个“读取-修改-写入”的一个操作序列,其结果的状态都依赖前一个状态。
1、竞态条件:像上述的例子中,有可能由多个线程执行时序不同而导致count的值不正确结果,它有个正式的名字,叫做竞态条件。当某个计算的正确性取决于多个线程执行的时序时,那么就发生了竞态条件,换句话说,结果的正确性取决于运气,最常见的竞太条件类型就是“先检查后执行(check-then-act)”,即有可能通过一个错误的结果决定下一步的操作。
2、示例:延迟初始化中的竞态条件
延迟初始化的目的是将对象的初始化操作推迟到实际被使用时才进行,同时还要确保只被初始化一次。
public class LazyInitRace{ private ExpensiveObject instance = null; public ExpensiveObject getInstance(){ if(instance == null) instance = new ExpensiveObject(); return instance; } }
LazyInitRace中包含了一个竞态条件,假设A和B线程同时执行getInstance方法,A看到instance为空,因此创建了一个新的ExpensiveObject实例,同时B也需要判断instance是否为空,如果B检查时,instance也为空,那么在两次调用getInstance方法时就会得到不同的实例结果,如果将此实例应用于注册表,多次调用的结果将返回不同的实例,那么要么会丢失部分的注册信息,要么多个行为对同一个注册对象表现出不同的视图。
3、复合操作
LazyInitRace和UnsafeCountingFactorizer都包含了一组需要以原子方式执行的操作,要避免竞态条件问题,就必须使线程在修改变量的同时,不允许其他的线程使用这个变量,确保其他线程只能在当前线程修改完成或者修改之前读取和修改变量状态,而不是在修改的过程中。
为了确保线程安全性,像“先检查后执行”、“读取-修改-写入”等这些复合操作都必须以原子的方式进行,后续我们会介绍确保线程安全的一些加锁机制,现在我们先用另一种方式来修复这个问题,即使用jdk1.5的java.util.concurrent.atomic包中现有的线程安全类。
public class SafeCountingFatorizer 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); } }
当在无状态的类中添加一个状态时,如果该状态完全由一个线程安全的对象进行管理,那么这个类仍然是线程安全的,但是当状态变量由一个变为多个时,就并不像由零个变为一个那样简单了,需要考虑变量之间的关系,后面的章节中你将看到。
三、加锁机制
当在Servlet中添加一个状态变量时,可以通过一个线程安全的对象来管理这个状态变量以维护Servlet的线程安全性,但是在Servlet中添加多个状态变量时,是否再添加多个线程安全的对象就能维护其线程的安全性呢?现在我们先来看个示例:
public class UnsafeCachingFactorizer implements Servlet{ private final AtomicReference<BigInteger> lastNumer = new AtomicReference<BigInteger>(); private final AtomicReference<BigInteger[]> lastFactors = new AtomicReference<BigInteger[]>(); 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); lastNumber.set(i); lastFactors.set(factors); encodeIntoResponse(resp, factors); } } }
尽管这些引用的对象是线程安全的,但在UnsafeCachingFactorizer中存在着竞态条件,就可能产生错误的结果,UnsafeCachingFactorizer的不变性条件是在lastFactors中缓存的因数之积等于在lastNumber中缓存的数值,只有确保不变性条件不被破坏,运行的结果才是正确的。
在A线程获取这两个值得过程中,B线程可能对它们进行修改,当在不变性条件中涉及到多个变量时,并且各个变量之间并不是相互独立,而是某个变量的值会对其他变量的值产生约束,这种情况下在更新一个变量时,需要在同一个原子操作中对其他有约束的变量同时更新。
下面会介绍通过锁的机制来支持操作的原子性:
1、内置锁
Java提供了一种内置锁来支持原子性:同步代码块。同步代码块分为两部分:一个作为锁的对象引用,一个作为由这个锁保护的代码块。用关键字synchronized来修饰整个方法体的代码块,该同步代码块的锁就是方法调用所在的对象,静态的synchronized方法是以Class对象作为锁。
synchronized(lock){ 由锁保护的共享状态 }
每个Java对象都可以作为实现同步的内置锁,或被叫为监视锁(monitor lock),获取内置锁的唯一途径就是进入由这个锁保护的代码块区域或者同步方法。
Java的内置锁是一种互斥体,这意味着只有一个线程能获取这个锁,当线程A尝试获取由B线程持有的这个锁时,线程A必须等待或阻塞,直到B线程执行完由这个锁保护的代码块,释放这个锁,A线程才能持有,如果B不释放这个锁,那么A线程将永远的等待下去。
这种加锁的同步机制使上述的因数分解缓存例子改为线程安全的变得简单,只需使用synchronized修饰service方法:
public class UnsafeCachingFactorizer implements Servlet{ private final AtomicReference<BigInteger> lastNumer = new AtomicReference<BigInteger>(); private final AtomicReference<BigInteger[]> lastFactors = new AtomicReference<BigInteger[]>(); public synchronized void service(ServletRequest req,ServletResponse resp){ BigInteger i = extractFromRequest(req); if(i.equals(lastNumber.get())){ encodeIntoResponse(resp, lastFactors.get()); }else{ BigInteger[] factors = factor(i); lastNumber.set(i); lastFactors.set(factors); encodeIntoResponse(resp, factors); } } }
这种加锁的方式虽然保证了不变性条件的正确性,但是每次只能由一个客户端来调用这个因数分解的方法,会导致服务的响应性非常低,当然,这里就变成一个性能问题,而不是一个线程安全的问题了,我们会在后面解决这个问题。
2、重入
现在大家知道了某个线程去请求由其他线程持有的锁时,这个线程会等待或阻塞,然而某个线程去试图请求一个由它自己持有的锁时,那么这个请求就会成功,这个因为“重入”获得这个锁操作的粒度是“线程”,而不是“调用”。
重入的实现原理:为每个锁都关联一个获取计数值和一个所有者线程,当数值为0时就说明这个锁没有被任何一个线程持有,当线程请求一个锁时,jvm会记下这个持有这个锁的所有者线程,并将数值变为1,如果同一个线程再次请求这个锁,就将数值加1,而当线程退出代码同步块时,就将数值递减,直到数值为0时,释放这个锁。
考虑下面的一段代码,看是否会出现死锁?
public class Widget{ public synchronized void doSomething(){.......} } public class LoggingWidget extends Widget{ public synchronized void doSomething(){ System.out.println(toString() + ": calling something"); super.doSomething(); } }
想想如果没有可重入的锁这将必定产生死锁,Widget和LoggingWidget 的doSomething方法都是synchronized的,因此每个doSomething方法在执行之前都会获取Widget上的锁,如果内置锁是不可重入的,那么在调用super.doSomething时将无法获取到Widget上的锁,因为这个锁已经被持有,将永远的等待下去,所以锁的重入避免了这种情况的发生。
四、用锁来保护状态
访问共享状态的复合操作都必须是原子操作以避免产生竞态条件。如果在复合操作的执行过程中持有一个锁,那么就会使复合操作成为一个原子操作,然而,仅仅将复合操作封装到同一个同步代码块中是不够的,如果用同步来协调对某个变量的访问,那么在访问这个变量的位置上都需要使用同步,而且,当使用锁来协调对变量的访问时,在访问变量的所有位置都要使用同一个锁。
一种常见的错误认为,只有在写入共享变量时才使用同步,但是并不是如此。
对象的内置锁与状态之间并没有内在的关联,虽然大多数的对象都是通过内置锁作为加锁的机制,但是对象的域并不是一定要通过内置锁保护。当获取与对象关联的锁时,并不能阻止其他线程对对象的访问,某个线程获得对象的锁后,只能阻止其他线程获得同一个锁。
一种常见的加锁约定是,将所有可变状态都封装在对象的内部,并通过对象的内置锁对访问状态的所有路径的代码进行同步,使得在该对象上不会发生并发访问,许多的线程安全类都使用了这种模式,如Vector何Hashtable等,这种模式如果在添加新的方法或者代码路径时忘记了同步,那么这种加锁协议就会遭到破坏。此外,将每个方法都作为同步方法将会导致活跃性问题和性能问题,将在下面的章节中进行说明。
如果同步可以避免竞态条件的问题,那为什么不在每个方法声明都用关键字synchronized?事实上是如果不加区别的滥用synchronized,可能会导致程序中出现过多的同步,并且,如果只是将每个方法都作为同步方法,例如Vector,那么并不足以确保Vector上的操作都是原子的:
if(!vector.contains(element)) vector.add(element);
虽然contains和add方法都是同步、原子的,但是不存在则添加的操作仍然存在竞态条件,synchronized可以确保单个操作的原子性,但是将多个操作合并为一个复合操作,还是需要额外的加锁的。
五、活跃性与性能
在前面的例子UnsafeCachingFactorizer中,我们通过在因数分解Servlet中引入了缓存机制来提升性能,在缓存中需要使用共享状态,因此需要使用同步来维护状态的完整性,然而示例中的代码性能却非常糟糕,使用的同步策略是通过Servlet的内置锁来保护每一个状态变量,对整个service方法进行了同步,虽然这种简单的方法能确保线程访问的安全性,但是性能却很低。
由于整个方法是synchronized,因此每次只有一个线程可以访问,这实际上是背离了Servlet的初衷,即Servlet可同时处理多个请求,这会使在负载比较高的情况下带来糟糕的体验,如果执行因数分解时会花很长的时间,那么其他的客户端就只能等待,知道Servlet处理完当前的请求,那么怎么来提高Servlet的处理性能呢?
幸运的是,通过缩小同步代码块的作用范围,我们可以既做到Servlet的并发性,又可以维护线程的安全性,当然,同步代码块也不要过小,不能本应该是原子操作拆分到多个同步快中去了,应该尽量将不影响共享状态且执行时间较长的操作从同步块中分离出去,从而在这些操作的执行过程中,其他线程可以访问共享状态。
refactory:
@ThreadSafe public class CachingFactorizer implements Servlet{ private BigInteger lastNumer; private BigInteger[]> lastFactors; private long hits; private long cacheHits; public synchronized long getHits(){ return hits} public synchronized double getCacheHitRadio(){ return (double) cacheHits / (double) hits; } public void service(ServletRequest req,ServletResponse resp){ BigInteger i = extractFromRequest(req); BigInteger[] factors = null; synchronized(this){ ++ hits; if(e.equals(lastNumber)){ ++ cacheHits; factors = lastFactors.clone(); } } if(factors == null){ factors = factor(i); synchronized(this){ lastNumber = i; lastFactors = factors.clone(); } } encodeIntoResponse(resp, factors); } }
要判断同步代码块的合理大小,需要在各种设计需求之间进行权衡,包括安全性、简单性和性能。
通常,在简单性和性能之间存在着相互制约的因素,当实现某个同步策略时,一定不要盲目的为了性能而牺牲了简单性,因为这可能会破坏安全性。
当使用锁时,你应该清楚代码块中实现的功能,以及执行该代码块时是否需要很长的时间,无论是执行计算密集型的操作,还是执行某个可能阻塞的操作,如果持有锁是时间过长,都会带来活跃性或性能问题。
所以请记住,当执行时间较长的计算或者可能无法快速完成的操作时(例如网络I/O),一定不要持有锁。