《Java并发编程实战》 线程安全之原子性

words: 1.5k    views:    time: 5min

如果多个线程访问某个类时,这个类始终都能表现正确的行为,则可以称这个类是线程安全的。所以一个对象是否要考虑线程安全问题,取决于它是否被多个线程访问,以及是否存在多种状态。反之如果一个对象是无状态的,既它不包含任何域,也不包含任何对其他类中域的引用,那么它一定是线程安全的。否则,一般会在类中会封装必要的同步机制来保证操作的原子性,以便保证类在被多线程访问时的线程安全。

1. 独立状态的原子性问题

NotThreadSafe
1
2
3
4
5
6
7
public class NotThreadSafe{
private long count = 0;

public void increase(){
++count;
}
}

NotThreadSafe并非线程安全的,尽管它在单线程环境中能正确运行。因为++count并非原子操作,它不会作为一个不可分割的操作来执行。实际上,它包括读取-修改-写入,并且其操作依赖于之前的状态。由于运行时可能将多个线程之间的操作交替执行,因此两个线程可能同时执行读取操作,从而得到相同的值,并各自将值加1,这样就得不到期望的结果(两个线程依次加1)。

1.1. 使用原子变量

ThreadSafe
1
2
3
4
5
6
7
8
public class ThreadSafe{

private final AtomicLong count = new AtomicLong(0);

public void increase(){
count.incrementAndGet();
}
}

对于单状态的类,可以委托原子变量来保证线程安全性。比如ThreadSafe中,使用AtomicLong来代替long作为计数器类型,由于count的状态就是ThreadSafe的状态,所以如果计数器count是线程安全的,那么ThreadSafe就是线程安全的。

2. 带约束条件的多状态原子性问题

NotThreadSafe
1
2
3
4
5
6
7
8
9
10
11
12
13
14
private final AtomicReference<BigInteger> lastNumber = new AtomicReference<BigInteger>();

private final AtomicReference<BigInteger[]> lastFactors = new AtomicReference<BigInteger[]>();

public BigInteger[] service(BigInteger param){
if(param.equals(lastNumber.get())){
return lastFactors.get();
}else{
BigInteger[] result = factor(param);
lastNumber.set(param);
lastFactors.set(result);
return lastFactors.get();
}
}

示例中,对请求参数进行因式分解并缓存上一次的结果。在lastFactors中缓存的因数之积应该等于lastNumber中缓存的值,只有确保了这个不变性条件(各个域之间的约束关系)不被破坏,NotThreadSafe的状态才是正确的。

在使用原子变量的情况下,虽然每次对set的调用都是原子的,但无法保证同时更新lastNumberlastFactors,那么在两次修改操作之间,其他线程将可能发现不变性条件被破坏了,从而访问到一个不正确的状态。因此,如果不变性条件涉及到多个变量,并且变量之间相互约束,那么,当更新一个变量时,需要在同一个原子操作中对其他变量同时进行更新

2.1. 使用内置锁

ThreadSafe
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
private BigInteger lastNumber;

private BigInteger[] lastFactors;

public synchronized BigInteger[] service(BigInteger param){

if(param.equals(lastNumber)){
return lastFactors;
}else{
BigInteger[] result = factor(param);
lastNumber = param;
lastFactors = result;
return lastFactors;
}
}

java对象都可以作为实现同步的锁对象,称为内置锁,获得内置锁的唯一途径就是进入由这个锁保护的同步代码块或方法。内置锁是一种互斥锁,意味着同一时刻最多只有一个线程能持有。当线程A试图获取一个由线程B持有的锁时,线程A必须等待或者阻塞,直到线程B释放这个锁。如果B不释放,A将永远等下去。因此这种方式比较极端,当多个线程同时访问时只能排队依次等待,响应性较低。

2.2. 优化锁使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
private long count = 0;

private BigInteger lastNumber;

private BigInteger[] lastFactors;

public BigInteger[] service(BigInteger param){

BigInteger[] factors = null;
synchronized(this){
++count;
if(param.equals(lastNumber)){
factors = lastFactors;
}
}

if(factors == null){
factors = factor(param);
synchronized(this){
lastNumber = param;
lastFactors = factors;
}
}
return factors;
}

可以尝试缩小同步操作的范围,确保线程安全的同时尽量保证并发性。比如将不影响共享状态且执行时间较长的操作从同步中分离出去,从而在这些操作的执行过程中,让其他线程可以访问共享状态。通常,对于同步范围合理大小的判断,需要在各种设计需求之间进行权衡,包括安全性、简单性和性能,以便达到简单性与并发性之间的平衡。

示例中的计数器count没有再使用AtomicLong,是因为这里已经使用了锁来构造原子操作。如果使用两种不同的同步机制,不仅不会在性能或安全上带来任何好处,而且会带来混乱。

3. 内置锁的重入问题

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class Widget {

public synchronized void dosomething(){

}
}

class LogWidget extends Widget{

public synchronized void dosomething(){

super.dosomething();
}
}

示例中,子类LogWidget在synchronized方法中调用父类Widget的synchronized方法,如果内置锁不是可重入的,那么super.dosomething()将发生死锁。

LogWidget.dosomething()调用将首先获取LogWidget实例的内置锁,然后super.dosomething()调用将再次获取Widget实例的内置锁,而在这里其实是同一个实例,所以两次请求的其实是同一个对象的内置锁

当某个线程请求一个由其它线程持有的锁时,会发生阻塞,而由于内置锁是可重入的,因此一个线程可以直接获取一个由自己持有的锁。重入意味着获取锁的操作粒度是线程,而不是调用。因此,重入进一步提升了加锁行为的封装性,简化了面向对象并发代码的开发。


参考:

  1. Copyright ©《java并发编程实战》