6 显式锁、原子变量与CAS
6.1 Lock与ReentrantLock
Lock接口是JDK5.0新增的接口,ReentrantLock是其实现类,比内置锁Sychronized具有更高的灵活性。当内置锁机制不适用时,可作为一种选择方案。
在大多数情况下,内置锁都能很好地工作,但在功能上存在一些局限性。例如,无法中断一个正在等待获取锁的线程,另外就是在无法获取锁时会一直阻塞等待。而ReentrantLock可以解决这些问题。
在使用ReentrantLock时必须要在finally块中释放锁,否则,如果被保护的代码块中出现异常,会导致这个锁永远无法释放。另外在加锁时,还必须考虑try块中抛出异常的情况,如果可能使对象处于某种不一致的状态,则需要更多的try-catch或try-finally来处理。
6.1.1 轮询锁与定时锁
可定时的与可轮询的锁获取模式是由tryLock方法实现的,与无条件的锁获取模式相比,具有更完善的错误恢复机制。由于有超时机制,所以不会发生死锁。
以活跃性一章中讲过的动态锁顺序死锁为例,如果按照以下方式加锁会出现死锁:
public void transferMoney(Account fromAccount, Account toAccount, BigDecimal amount) {
synchronized (fromAccount) {
synchronized (toAccount) {
// 执行转账
...
}
}
}
通过分析知道,逻辑顺序是动态的,可以通过按物理顺序加锁来解决。
事实上,这里还有另外一种方法来防止死锁,那就是加锁时使用定时和轮询机制:
public boolean transferMoney(Account fromAccount, Account toAccount, BigDecimal amount, long timeout, TimeUnit unit) throws InterruptedException {
long fixedDelay = getFiexedDelayComponentNanos(timeout, unit); // 重试获取锁的基准延迟时间
long randMod = getRandomDelayModuleNanos(timeout, unit); // 重试获取锁的延迟时间波动值mod
long stopTime = System.nanoTime() + unit.toNanos(timeout); // 超时时限,允许重试获取锁的时间
while (true) {
if (fromAccount.lock.tryLock()) {
try {
if (toAccount.lock.tryLock()){
try {
fromAccount.debit(amount);
toAccount.credit(amount);
} finally {
toAccount.lock.unlock();
}
}
} finally {
fromAccount.lock.unlock();
}
}
if (System.nanoTime() >= stopTime) {
return false;
}
// 为防止活锁,重试间隔时间为:基准时间+波动值
Thread.sleep(fixedDelay + rnd.nextLong() % randMod);
}
}
通过tryLock()来获取锁,当获取失败时则休眠一段时间后重试获取锁操作,直到获取锁成功或者到达超时时限。sleep时间需要有一定的随机性是为了避免活锁(重试-失败循环即为活锁)。
tryLock方法可以带时间参数,表示一次获取锁的操作允许阻塞的时间,适用于某些限时操作的场景。比如某个受锁保护的操作需要3s,给出了限时时间是5s,那么必须在2s内获取到锁,否则应该返回失败。此时就可以用lock.tryLock(2, TimeUnit.SECONDS)来加锁。
6.1.2 可中断的锁获取操作
内置锁synchronized无法中断。Lock提供可中断的加锁方法lockInterruptibly。该方法在加锁过程中可以被中断:
@Test
public void test() throws Exception {
final ReentrantLock lock = new ReentrantLock();
Thread t1 = new Thread(() -> {
try {
lock.lock();
Thread.sleep(3000);
} catch (InterruptedException e) {
System.out.println("***");
} finally {
lock.unlock();
}
});
Thread t2 = new Thread(() -> {
try {
lock.lockInterruptibly();
} catch (InterruptedException e) {
System.out.println(Thread.currentThread().getName() + "在获取锁操作时被中断");
} finally {
try {
lock.unlock();
} catch (Exception e) {
System.out.println("释放锁异常,原因是并没有获取到锁");
}
}
});
t1.start();
t2.start();
t2.interrupt();
t1.join();
t2.join();
}
在以上栗子中,线程t1获取了lock的锁,并在sleep时间内一直持有该锁。线程t2尝试加锁时会被阻塞,直到线程t1释放掉lock的锁为止。主线程在t2加锁阻塞时调用了t2的中断方法,导致t2被中断并抛出中断异常。最终输出结果如下:
Thread-1在获取锁操作时被中断
释放锁异常,原因是并没有获取到锁
tryLock(long timeout, TimeUnit unit)方法也可以被中断。
6.2 读写锁
ReentrantLock实现了一种标准的互斥锁,即每次最多只有一个线程能持有锁。
互斥是一种保守的加锁策略,在某些场景下互斥锁可能并不合适。例如对某种数据结构进行读写操作,且读操作频率较高的情况下,互斥锁会导致“写-写”,“写-读”,“读-读”操作均互斥,而实际上“读-读”操作并不需要互斥,因为读操作并不涉及线程安全问题。
此时就可以用读写锁ReadWriteLock了,读写锁在读模式锁定时,允许其他线程共享读模式的读写锁,而在写模式锁定时,则不管读还是写都必须等待锁释放。简单来说,就是让“读-读”不再互斥。
读写锁是一种性能优化措施,但只有在保护读远大于写操作的数据结构时,才能发挥较好的作用,否则还是应该使用独占锁。
6.2.1 读写锁的可选实现
ReadWriteLock的一些可选实现包括:
释放优先 当一个写入锁释放时,对于等待队列中的读线程和写线程来说,谁能优先获取到锁?
读线程插队 如果当前的锁是读模式的读写锁,但有写线程正在等待,那么新到达的读线程能否立即获得访问权,还是应该在写线程后面等待?
重入性 读取锁和写入锁是否是可重入的?
降级 如果一个线程持有写入锁,那么它能否在不释放锁的情况下获得读取锁,这可能会使得写入锁被降级为读取锁。
升级 如果一个线程持有读取锁,那么它能否在不释放锁的情况下获取写入锁,即将读取锁升级为写入锁?
ReadWriteLock的主要实现类是ReenReadWriteLock。与ReentrantLock类似,其实现是可重入的。在构造时可以选择是一个非公平还是公平的锁,默认是非公平锁。
对于释放优先和读线程插队问题,公平锁是按照等待队列的顺序来获取加锁顺序的,非公平锁则是不确定的。
对于降级和升级问题,写入锁降级为读取锁是可以的(写入锁是独占的,降级为读取锁并不麻烦),而读取锁升级为写入锁则不可以(读取锁是共享的,如果同时有多个读取锁想升级为写入锁,显然会因为互不释放而发生死锁)。
下面看一个读写锁的应用例子:
public static class ReadWriteMap<K, V> {
private final Map<K, V> map;
private final ReadWriteLock lock = new ReentrantReadWriteLock();
private final Lock r = lock.readLock();
private final Lock w = lock.writeLock();
public ReadWriteMap(Map<K, V> map) {
this.map = map;
}
public V put(K key, V value) {
w.lock();
try {
return map.put(key, value);
} finally {
w.unlock();
}
}
// 对remove,putAll,clear等方法执行相同的操作
public V get(Object key) {
r.lock();
try {
return map.get(key);
} finally {
r.unlock();
}
}
// 对其他只读的map方法执行相同的操作
}
通过ReenReadWriteLock对map进行包装,可以使这个map在多个读线程之间被安全地共享。当然,实际上已经有ConcurrentHashMap已经能做到这一点而且性能也很好,但如果需要对另一种Map实现(如LinkedHashMap)提供并发性更高的访问,那么可以使用这项技术。
6.3 内置锁与显式锁比较
Synchronized | Lock | |
优点 | 实现简单,使用方便,由JVM控制加解锁过程;线程转储时便于JVM进行堆栈跟踪 | 可定时的、可轮询的与可中断的锁获取操作;提供了读写锁、公平锁和非公平锁 |
缺点 | 没有高级功能(如定时,中断等) | 需要手动释放锁;不适合JVM进行堆栈跟踪 |
共同点 | 都是可重入的 |
6.4 CAS与原子变量
在考虑线程安全时,首先考虑能不能进行线程封闭(第一章基础概念里介绍过),如果必须在不同线程间共享数据,那么再考虑并发的正确性。
阻塞同步
互斥同步是一种常见的并发正确性保障手段,一般通过加锁方式来实现。内置锁和显式锁都属于此类。
广义上讲,信号量Semaphore是对锁的扩展,与锁不同的是,信号量可以指定多个线程同时访问一个资源。
互斥同步最主要的问题是进行线程阻塞和唤醒带来的性能问题,因此这种同步也称为阻塞同步。从处理问题方式上说,互斥同步属于一种悲观的并发策略,无论共享数据是否真的会出现竞争,都要进行加锁、用户态核心态转换、维护锁计数器和检查是否有被阻塞的线程需要唤醒等操作。
6.4.1 非阻塞同步CAS
还有一种乐观的并发策略,那就是假设共享数据不会出现竞争,每个线程的操作正确执行。而如果线程发生了并发冲突,就采取补救措施,最常见的补救措施就是不断重试,直到成功为止。这种乐观的并发策略的许多实现都不需要把线程挂起,因此这种同步操作称为非阻塞同步。常用的无锁乐观并发策略是通过CAS来实现的。
常用的无锁乐观并发策略是采用CAS指令来实现。CAS指令需要有3个操作数,分别是要更新的变量V(实际上是内存位置),预期值E,新值N。CAS指令执行时,当且仅当V符合旧预期值E时,处理器采用新值N更新V的值,否则就什么都不做。但是无论是否更新了V的值,都会返回V的旧值,上述的处理过程是一个原子操作。当多个线程同时使用CAS操作一个共享变量时,只有一个会胜出并成功更新,其余均会失败。
简单地说,CAS需要你额外给出一个期望值,也就是你认为这个变量现在应该是什么样子的。如果变量不是你想象的那样,就说明它已经被别人修改过了。你就重新读取,并在此尝试修改就好了。CAS语义有一个逻辑漏洞,也就是CAS操作的“ABA问题”,那就是如果一个变量初次读取的时候是A值,准备赋值的时候检查到它仍然为A值,那么并不一定就没有发生过改变,有可能这段期间它的值曾经被改为了B,后来又被改回A,而CAS操作会误认为它从来没有改变过。为了解决这个问题,J.U.C包中提供了一个带有标记的原子引用类“AtomicStampedRefrence”,可以通过控制变量值得版本来保证CAS的正确性。
在硬件层面,大部分现代处理器都已经支持原子化的CAS指令。
6.4.2 原子变量
为了让Java程序员能够受益于CAS等CPU指令,JDK并发包中有一个atomic包,里面实现了一些直接使用CAS操作的线程安全的类型,如AtomicInteger,AtomicLong,AtomicBoolean。这些变量都称为原子变量。
举个例子说明原子变量与普通类的不同,相比Integer,AtomicInteger是可变的,并且是线程安全的。对其进行修改等任何操作,都是用CAS指令进行的。在多个线程同时对共享变量做循环自增的操作场景中,如果将共享变量类型设为AtomicInteger,则不会产生线程安全问题。
AomicInteger
通过源代码可以看到,原子整形类中有很多原子的复合操作API,如:
//基于原子操作,获取当前原子变量中的值并为其设置新值
public final int getAndSet(int newValue)
//基于原子操作,比较当前的value是否等于expect,如果是设置为update并返回true,否则返回false
public final boolean compareAndSet(int expect, int update)
//基于原子操作,获取当前的value值并自增一
public final int getAndIncrement()
//基于原子操作,获取当前的value值并自减一
public final int getAndDecrement()
//基于原子操作,获取当前的value值并为value加上delta
public final int getAndAdd(int delta)
//还有一些反向的方法,比如:先自增在获取值的等等
...
进一步查看可以发现,这些方法基本上都是通过sun.misc.Unsafe类的native方法来实现的,通过底层硬件来达到原子操作的目的。
在jdk1.7中,incrementAndGet方法是这样实现的:
public final int incrementAndGet() {
for (;;) {
int current = get();
int next = current + 1;
if (compareAndSet(current, next))
return next;
}
}
在jdk1.8中做了优化:
return unsafe.getAndAddInt(this, valueOffset, 1) + 1;
通过compareAndSwapInt来比较预期值和实际值并判断是否更新。
AtomicLong的接口与AtomicInteger差不多,但值得一提的是,可以用LongAdder来代替AtomicLong。
AtomicReference
对于引用类型,Java并发包也提供了原子变量的接口支持,AomicReference内部使用泛型来实现:
/**
* Creates a new AtomicReference with the given initial value.
*
* @param initialValue the initial value
*/
public AtomicReference(V initialValue) {
value = initialValue;
}
/**
* Creates a new AtomicReference with null initial value.
*/
public AtomicReference() {
}
设置值的接口:
/**
* Atomically sets to the given value and returns the old value.
*
* @param newValue the new value
* @return the previous value
*/
@SuppressWarnings("unchecked")
public final V getAndSet(V newValue) {
return (V)unsafe.getAndSetObject(this, valueOffset, newValue);
}
FieldUpdater
FieldUpdater可以通过反射机制,直接以原子操作来更新非原子变量。例如:
//定义一个计数器
public class Counter {
private volatile int count;
public int getCount() {
return count;
}
public void addCount(){
AtomicIntegerFieldUpdater<Counter> updater = AtomicIntegerFieldUpdater.newUpdater(Counter.class,"count");
updater.getAndIncrement(this);
}
}
ABA问题
CAS算法有一个经典的ABA问题,即如果初始值是A,现在某线程准备将其更新为B,如果此时已经有其他线程将其做了更改,并最终将结果又改回了A,当前线程判断出实际值与预期值相同,认为无并发并更新值为B。这里实际值A和预期值A虽然值相同,但实际上却是已经被其他线程操作过了的,是一个脏数据。
解决办法很容易想到,那就是通过一个时间戳来记录更改版本。AtomicStampedReference类即是带时间戳的原子引用类,其接口方法中增加了时间戳参数,在更新的时候,不光要比较实际值与预期值,还要比较实际时间戳与预期时间戳。