什么是线程的安全性?
当多个线程访问某个类时,不管运行时环境次啊用何种调度方式或者这些线程将如何交替执行,这个类始终都能表现正确的行为,那么称这个类是线程安全的。
什么是无状态的类?
若一个类中既不包含任何域,也不包含任何对其他类中域的引用,那么我们称这个类为无状态的类。
如下,我们模拟一个简单的因数分解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);
}
}
上面的StatelessFactorizer类为无状态的类,因为它不包含任何域和其他类中域的引用。
无状态对象一定是线程安全的
假设有两个线程A和B对StatelessFactorizer类进行访问,在访问的过程中,A不会影响到B的结果,B也不会影响到A的结果。因为这两个线程并没有共享状态,就如同它们在访问不同的实例。由于线程访问无状态对象的行为不会影响其他线程中操作的正确性,因此无状态对象是线程安全的。
在原有的基础上,我们添加一个变量count来记录Servlet请求次数。如下:
@ThreadSafe
public class StatelessFactorizer implements Servlet{
//用来表示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);
}
}
这个时候StatelessFactorizer类将不再是“无状态”的,因为它包含了变量count。也就意味着,该类可能是线程不安全的。
我们先来看到service中对count的操作count++
,该操作看起来是一个操作,但这个操作并非原子的,因而它不会作为一个不可分割的操作来执行。实际上,它包含了三个独立的操作:
a. 读取count的值
b. count值加一
c. 将计算的结果写入count中
假设有三个线程A,B,C同时访问该类,A调用service方法,执行了count++
操作,假设count初始为8,这时B和C都在调用getCount()
方法来获取到count值,那么存在以下情况:
1. B,C 在A执行c操作之前调用了getCount()方法,那么B和C获取到的count值都为8;
2. B,C 在A执行c操作之后调用了getCount()方法,那么B和C获取到的count值都为9;
3. B在A执行c操作之前调用了getCount()方法,而C在之后调用,那么B获取的count值为8,而C获取到的是9。
4. B在A执行c操作之后调用了getCount()方法,而C在之前调用,那么B获取的count值为9,而C获取到的是8。
上面的情况说明,线程中不同的交替运行情况将导致count值不同,意味着该类并不是线程安全的。
竞态条件
当某个计算的正确性取决于多个线程的交替执行时序时,那么就会发生竞态条件。
实际上,我们上面所说的4种情况便属于竞态条件,只有前两种情况是我们所希望的,而后两种情况并不是我们所想看见的。
对于竞态条件来说,我们可以采取的办法是”先检查后执行“:首先观察某个条件是否为真,然后根据这个观察结果来采用相应的动作。常见的一种情况是延迟初始化。
延迟初始化的目的是将对象的初始化操作推迟到实际被使用时才进行,并确保只被初始化一次。
@NotThreadSafe
public class LazyInitRace{
private ExpensiveObject instance = null;
public ExpensiveObject getInstance(){
if (instance == null){
instance = new ExpensiveObject();
}
return instance;
}
}
但实际上,这样的方案还是存在竞态条件:假设线程A和B同时执行getInstance,A若看到instance为空,那么会创建一个新的实例对象,同样B也会进行这样的操作。而此时instance是否为空,取决于不可预测的时序,以及线程的调度方式和A需要实例化对象的时间。若B在检查instance是否为空时,A没有完成初始化操作,那么将导致A和B创建两个不同的实例对象。
正确避免竞态条件:
要避免竞态条件,就必须在某个线程修改该变量时,通过某种方式防止其他线程使用这个变量,从而确保其他线程只能在修改操作完成之前或之后读取和修改状态,而不是在修改的过程中。
我们对之前的Servlet进行修改:
@ThreadSafe
public class StatelessFactorizer implements Servlet{
//用来表示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);
}
}
在java.util.concurrent.atomic包中包含了一些原子变量类,用于实现数值和对象引用上的原子状态转换。通过AtomicLong来替代long类型,能够确保所有对计数器状态的访问都是原子的。
当在无状态的类中添加一个状态时,如果该状态完全由线程安全的对象进行管理,那么这个类仍然是线程安全的。
假设我们希望提升Servlet的性能:将最近的结果缓存起来,当连续两个的请求相同的数值进行因式分解时,可以直接使用上一次的计算结果。
要实现该种缓存策略,需要保存两个状态:最近执行因数分解的数值,以及分解结果
@ThreadSafe
public class UnsafeCachingFactorizer implements Servlet{
//保存最近的数值
private final AtomicReference<BigInteger> lastNumber
= new AtomicReference<BigInteger>();
//保存最近的因式分解的结果
private final AtomicReference<BigInteger[]> lastFactors
= new AtomicReference<BigInteger[]>();
public void service(ServletRequest req, ServletResponse resp){
BigInteger i = extractFromRequest(req);
//若该次请求与上次请求一致,从缓存中取值
if (i.euqals(lastNumber.get())) {
encodeIntoResponse(resp, lastFactors.get());
}
//否则,调用方法计算并加入到缓存中
else {
BigInteger[] factors = factor(i);
lastNumber.set(i);
lastFactors.set(factors);
encodeIntoResponse(resp, factors);
}
}
}
为了线程的安全性,我们采用了两个原子引用来保证类不存在竞态条件。但实际上,这样的做法能否保证不存在竞态条件呢?
我们假设线程A,B同时访问该类,并且缓存中的最近记录lastNumber为6,lastFactors为[2,3],现在A请求的数值为8,因为i != 6,因此,会调用factor方法,并更新缓存,lastNumber.set(i)
,当A执行到这步时,B进来了,判断lastNumber是否为8,结果是为8,然后B就直接获取lastFactors中的值[2,3],获取后,A才执行lastFactors.set(factors)
。
对于,上面假设的情况,虽然偶然性很大,但是依旧存在这种可能,因为线程的运行是难以预测的!
实际上,出现上面的问题,主要是lastNumber和lastFactors在更新上存在不一致的情况。因此:
要保证状态的一致性,就需要在单个原子操作中更新所有相关的状态变量。
对于上面采用原子引用无法解决安全性问题,那么应该采取何种措施去解决?
实际上,Java提供了一种内置的锁机制来支持原子性:同步代码块。
使用方法如下:
synchronized (lock){
//访问或修改由锁保护的共享状态
}
- 每个Java对象都可以用作一个实现同步的锁,这些锁称为“内置锁”。线程在进入同步代码块之前会自动获得锁,并且在退出同步代码块时自动释放锁。
- Java的内置锁相当于一种互斥体,这意味着最多一个线程能持有这种锁。当线程A尝试获取一个由线程B持有的锁时,线程A必须等待或阻塞,直到线程B释放这个锁。
@ThreadSafe
public class SynchronizedFactorizer implements Servlet{
@GuardedBy("this") private BigInteger lastNumber;
@GuardedBy("this") private BigInteger[] lastFactors;
//使用Synchronized关键字
public Synchronized void service(ServletRequest req, ServletResponse resp){
BigInteger i = extractFromRequest(req);
//若该次请求与上次请求一致,从缓存中取值
if (i.euqals(lastNumber)) {
encodeIntoResponse(resp, lastFactors);
}
//否则,调用方法计算并加入到缓存中
else {
BigInteger[] factors = factor(i);
lastNumber = i;
lastFactors = factors;
encodeIntoResponse(resp, factors);
}
}
}
如上,我们使用Synchronized关键字来修饰service方法,因此同一时刻只有一个线程来执行service。如此,类必然是线程安全的,但实际上这种方法是过于极端。因为多个客户端无法同时使用因式分解Servlet,这样的服务响应性非常低。
我们对此,做如下修改,并增加了“计数器”和“缓存命中计数器”:
@ThreadSafe
public class SynchronizedFactorizer implements Servlet{
@GuardedBy("this") private BigInteger lastNumber;
@GuardedBy("this") private BigInteger[] lastFactors;
//表示请求次数
@GuardedBy("this") private long hits;
//表示缓存命中次数
@GuardedBy("this") private long cacheHits;
//使用Synchronized关键字获取hits,保证在获取的过程中,其他线程不会改变hits
public Synchronized long getHits(){
return hits;
}
//使用Synchronized关键字获取命中率,保证在获取的过程中,其他线程不会改变hits和xacheHits
public Synchronized double getCacheHitRadio(){
return (double) cacheHits / (double) hits;
}
public void service(ServletRequest req, ServletResponse resp){
BigInteger i = extractFromRequest(req);
BigInteger[] factors = null;
//使用Synchronized关键字,保证在取缓存过程中,不会改变lastNumber和lastFactors
Synchronized (this){
++ hits;
if (i.equals(lastNumber)) {
++ cacheHits;
factors = lastFactors.clone();
}
}
if (factors == null) {
factors = factor(i);
//使用Synchronized关键字,保证在写缓存中,其他线程无法读取lastNumber和lastFactors
Synchronized(this){
lastNumber = i;
lastFactors = factors.clone();
}
}
encodeIntoResponse(resp, factors);
}
}
实际上,我们知道该类为线程不安全主要是因为lastNumber和lastFactors的读写并发问题。因此,我们只要保证在对lastNumber和lastFactors读写过程中保证只有一个线程即可,而其他时候,则允许多线程并发执行,比如这里的factors方法,如此一来,我们便可以兼并安全性和性能。
内置锁是可重入的
如果某个线程试图获得一个已经由它自己持有的锁,那么这个请求就会成功。
如何实现可重入的内置锁?
为每个锁关联一个获取计数值和一个所有者线程
当计数值为0时,这个锁就被认为是没有被任何线程持有。当线程请求一个未被持有的锁时,JVM将记下锁的持有者,并且将获取计数值置为1。如果同一个线程再次获取这个锁,计数值将递增,而当线程退出同步代码块时,计数器会相应地递减。当计数值为0时,这个锁将被释放。
public class Widget(){
public synchronized void dosomething(){
...
}
}
public class LoggingWidget extends Widget{
public synchronized void dosomething(){
System.out.println(this.toString() + ": calling dosomething");
//调用父类中的dosomething方法
super.dosomething();
}
}
如上,我们设置类Widget和其子类LoggingWidget,并在子类中dosomething方法中调用父类的dosomething方法。
假设,内置锁是不可重入的,我们看会出现什么情况:首先子类中dosomething方法用synchronized修饰,因此会获取到Widget上的锁,然后,执行完输出后,会调用父类的dosomething方法,因为父类的该方法也被synchronized所修饰,因此需要获取Widget的锁,而Widget的锁已经被子类所获取,那么将导致无法执行父类的dosomething方法,同样,子类也是无法执行,导致陷入了死锁。