《Java并发编程实战》 线程安全之原子性
words: 1.5k views: time: 5min如果多个线程访问某个类时,这个类始终都能表现正确的行为,则可以称这个类是线程安全的。所以一个对象是否要考虑线程安全问题,取决于它是否被多个线程访问,以及是否存在多种状态。反之如果一个对象是无状态的,既它不包含任何域,也不包含任何对其他类中域的引用,那么它一定是线程安全的。否则,一般会在类中会封装必要的同步机制来保证操作的原子性,以便保证类在被多线程访问时的线程安全。
1. 独立状态的原子性问题
1 | public class NotThreadSafe{ |
NotThreadSafe
并非线程安全的,尽管它在单线程环境中能正确运行。因为++count
并非原子操作,它不会作为一个不可分割的操作来执行。实际上,它包括读取-修改-写入,并且其操作依赖于之前的状态。由于运行时可能将多个线程之间的操作交替执行,因此两个线程可能同时执行读取操作,从而得到相同的值,并各自将值加1,这样就得不到期望的结果(两个线程依次加1)。
1.1. 使用原子变量
1 | public class ThreadSafe{ |
对于单状态的类,可以委托原子变量来保证线程安全性。比如ThreadSafe
中,使用AtomicLong
来代替long
作为计数器类型,由于count
的状态就是ThreadSafe
的状态,所以如果计数器count
是线程安全的,那么ThreadSafe
就是线程安全的。
2. 带约束条件的多状态原子性问题
1 | private final AtomicReference<BigInteger> lastNumber = new AtomicReference<BigInteger>(); |
示例中,对请求参数进行因式分解并缓存上一次的结果。在lastFactors
中缓存的因数之积应该等于lastNumber
中缓存的值,只有确保了这个不变性条件(各个域之间的约束关系)不被破坏,NotThreadSafe
的状态才是正确的。
在使用原子变量的情况下,虽然每次对set
的调用都是原子的,但无法保证同时更新lastNumber
和lastFactors
,那么在两次修改操作之间,其他线程将可能发现不变性条件被破坏了,从而访问到一个不正确的状态。因此,如果不变性条件涉及到多个变量,并且变量之间相互约束,那么,当更新一个变量时,需要在同一个原子操作中对其他变量同时进行更新。
2.1. 使用内置锁
1 | private BigInteger lastNumber; |
java对象都可以作为实现同步的锁对象,称为内置锁,获得内置锁的唯一途径就是进入由这个锁保护的同步代码块或方法。内置锁是一种互斥锁,意味着同一时刻最多只有一个线程能持有。当线程A试图获取一个由线程B持有的锁时,线程A必须等待或者阻塞,直到线程B释放这个锁。如果B不释放,A将永远等下去。因此这种方式比较极端,当多个线程同时访问时只能排队依次等待,响应性较低。
2.2. 优化锁使用
1 | private long count = 0; |
可以尝试缩小同步操作的范围,确保线程安全的同时尽量保证并发性。比如将不影响共享状态且执行时间较长的操作从同步中分离出去,从而在这些操作的执行过程中,让其他线程可以访问共享状态。通常,对于同步范围合理大小的判断,需要在各种设计需求之间进行权衡,包括安全性、简单性和性能,以便达到简单性与并发性之间的平衡。
示例中的计数器count
没有再使用AtomicLong
,是因为这里已经使用了锁来构造原子操作。如果使用两种不同的同步机制,不仅不会在性能或安全上带来任何好处,而且会带来混乱。
3. 内置锁的重入问题
1 | public class Widget { |
示例中,子类LogWidget
在synchronized方法中调用父类Widget
的synchronized方法,如果内置锁不是可重入的,那么super.dosomething()
将发生死锁。
LogWidget.dosomething()
调用将首先获取LogWidget实例的内置锁,然后super.dosomething()
调用将再次获取Widget实例的内置锁,而在这里其实是同一个实例,所以两次请求的其实是同一个对象的内置锁。
当某个线程请求一个由其它线程持有的锁时,会发生阻塞,而由于内置锁是可重入的,因此一个线程可以直接获取一个由自己持有的锁。重入意味着获取锁的操作粒度是线程,而不是调用。因此,重入进一步提升了加锁行为的封装性,简化了面向对象并发代码的开发。
参考:
- Copyright ©《java并发编程实战》