读书笔记: Java并发编程实战(6)

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 内置锁与显式锁比较

SynchronizedLock
优点实现简单,使用方便,由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类即是带时间戳的原子引用类,其接口方法中增加了时间戳参数,在更新的时候,不光要比较实际值与预期值,还要比较实际时间戳与预期时间戳。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值