JAVA多线程问题 — 显式锁(Lock与重入锁ReentrantLock)

显式锁

在Java 5.0之前,在协调对共享对象的访问时可以使用的机制只有synchronized和volatile。Java 5.0 增加了一种新的机制:ReentrantLock.它并不是一种替代内置加锁的方法,而是当内置加锁机制不适用时,作为一种可选择的高级功能。

1、Lock 与 ReentranLock

Java并发包中的显式锁接口和类位于包java.util.concurrent.locks下,主要接口和类有:

  • 锁接口Lock,主要实现类是ReentrantLock
  • 读写锁接口ReadWriteLock,主要实现类是ReentrantReadWriteLock

Lock接口

Lock接口位于java.util.concurrent.locks包中,在使用时,对于synchronized 同步的代码块可以由JVM自动释放,Lock 则需要程序员在finally块中手工释放;synchronized是比较古老的实现机制,设计较早,有一些功能上的限制:
——它无法中断一个正在等候获得锁的线程
——也无法通过投票得到锁,如果不想等下去,也就没法得到锁。
——同步还要求锁的释放只能在与获得锁所在的堆栈帧相同的堆栈帧中进行

与内置加锁机制不同的是, Lock提供了一种无条件的、 可轮询的、 定时的以及可中断的锁获取操作, 所有加锁和解锁的方法都是显式的。

而且对多线程环境中,使用synchronized后,线程要么获得锁,执行相应的代码,要么无法获得锁处于等待状态,对于锁的处理不灵活。而Lock提供了多种基于锁的处理机制,比如:

public interface Lock {
    //获取锁
    void lock();
    // 如果当前线程未被中断,则获取锁
    void lockInterruptibly() throws InterruptedException;
    //仅在调用时锁为空闲状态才获取该锁
	//如果锁可用,则获取锁,并立即返回值 true。如果锁不可用,则此方法将立即返回值 false
    boolean tryLock();
  	//如果锁在给定的等待时间内空闲,并且当前线程未被中断,则获取锁
    boolean tryLock(long time, TimeUnit unit) throws InterruptedException;
   	//释放锁
    void unlock();
    //返回绑定到此 Lock 实例的新 Condition 实例
    Condition newCondition();
}

ReentrantLock

ReentrantLock实现了Lock 接口,并提供了与synchronized相同的互斥性和内存可见性。 在获取 ReentrantLock 时, 有着与进入同步代码块相同的内存语义, 在释放 ReentrantLock时, 同样有着与退出同步代码块相同的内存语义。此外,与synchronized 一样, ReentrantLock还提供了可重入的加锁语义。 ReentrantLock 支持在 Lock 接口中定义的所有获取锁模式, 井且与 synchronized 相比, 它还为处理锁的不可用性问题提供了更高的灵活性。

为什么要创建一种与内置锁如此相似的新加锁机制?在大多数情况下, 内置锁都能很好地工作, 但在功能上存在一些局限性,例如:

  1. 我们想给锁加个等待时间超时时间,超时还未获得锁就放弃,不至于无限等下去;
  2. 我们想以可中断的方式获取锁,这样外部线程给我们发一个中断信号就能唤起等待锁的线程;
  3. 我们想为锁维持多个等待队列,比如一个生产者队列,一个消费者队列,一边提高锁的效率。

显式锁(ReentrantLock)正式为了解决这些灵活需求而生。ReentrantLock的字面意思是可重入锁,可重入的意思是线程可以同时多次请求同一把锁,而不会自己导致自己死锁。下面是内置锁和显式锁的区别:

  • 轮询锁与定时锁:RenentrantLock.tryLock(long timeout, TimeUnit unit) 提供了一种以定时结束等待的方式,如果线程在指定的时间内没有获得锁,该方法就会返回false并结束线程等待,内置锁很难实现带有时间限制的操作。

  • 可中断的锁获取:可中断性给我们提供了一种让线程提前结束的方式(而不是非得等到线程执行结束),这对于要取消耗时的任务非常有用。对于内置锁,线程拿不到内置锁就会一直等待,除了获取锁没有其他办法能够让其结束等待。RenentrantLock.lockInterruptibly()给我们提供了一种以中断结束等待的方式,能够在获得锁的同时保持对中断的响应, 并且由于它包含在Lock中, 因此无须创建其他类型的不可中断阻塞机制。

  • 非块结构的加锁
    在内置锁中, 锁的获取和释放等操作都是基于代码块的——释放锁的操作总是与获取锁的 操作处于同一个代码块, 而不考虑控制权如何退出该代码块。 自动的锁释放操作简化了对程序 的分析, 避免了可能的编码错误, 但有时侯需要更灵活的加锁规则。通过降低锁的粒度可以提高代码的可伸缩性。 锁分段技术在基于散列的容器中实现了 不同的散列链, 以便使用不同的锁。 我们可以通过采用类似的原则来降 低链表中锁的粒度, 即为每个链表节点使用一个独立的锁, 使不同的线程能独立地对链表的不 同部分进行操作。 每个节点的锁将保护链接指针以及在该节点中存储的数据, 因此当遍历或修改链表时, 我们必须持有该节点上的这个锁, 直到获得了下一个节点的锁, 只有这样, 才能释放前一个节点上的锁。

  • 条件队列(condition queue):线程在获取锁之后,可能会由于等待某个条件发生而进入等待状态(内置锁通过 Object.wait() 方法,显式锁通过 Condition.await() 方法),进入等待状态的线程会挂起并自动释放锁,这些线程会被放入到条件队列当中。synchronized对应的只有一个条件队列,而ReentrantLock可以有多个条件队列。
    线程在获取锁之后,有时候还需要等待某个条件满足才能做事情,比如生产者需要等到“缓存不满”才能往队列里放入消息,而消费者需要等到“缓存非空”才能从队列里取出消息。这些条件被称作 条件谓词,线程需要先获取锁,然后判断条件谓词是否满足,如果不满足就不往下执行,相应的线程就会放弃执行权并自动释放锁。使用同一把锁的不同的线程可能有不同的条件谓词,如果只有一个条件队列,当某个条件谓词满足时就无法判断该唤醒条件队列里的哪一个线程;但是如果每个条件谓词都有一个单独的条件队列,当某个条件满足时我们就知道应该唤醒对应队列上的线程(内置锁通过Object.notify()或者Object.notifyAll()方法唤醒,显式锁通过Condition.signal()或者Condition.signalAll()方法唤醒)。这就是多个条件队列的好处。

使用模板:

// ReentrantLock是Lock接口的默认实现类
// 显式锁的使用示例
ReentrantLock lock = new ReentrantLock();

// 获取锁,这是跟synchronized关键字对应的用法。
lock.lock();
try{
    // your code
}finally{
    lock.unlock();
}

// 可定时,超过指定时间为得到锁就放弃
try {
    lock.tryLock(10, TimeUnit.SECONDS);
    try {
        // your code
    }finally {
        lock.unlock();
    }
} catch (InterruptedException e1) {
    // exception handling
}

// 可中断,等待获取锁的过程中线程线程可被中断
try {
    lock.lockInterruptibly();
    try {
        // your code
    }finally {
        lock.unlock();
    }
} catch (InterruptedException e) {
    // exception handling
}

// 多个等待队列,具体参考[ArrayBlockingQueue]
/** Condition for waiting takes */
private final Condition notEmpty = lock.newCondition();
/** Condition for waiting puts */
private final Condition notFull = lock.newCondition();

注意事项:必须在finally块中释放锁。否则在被保护的代码中抛出了异常,那么这个锁永远都无法释放。

2、公平性

ReentrantLock 作为 Lock 显式锁的最基本实现,也是使用最频繁的一个锁实现类。它提供了两个构造函数,用于支持公平竞争锁。

//默认无参的构造函数表示启用非公平锁
public ReentrantLock()
//传入 fair 参数值为 true 指明启用公平锁
public ReentrantLock(boolean fair)

公平性选择:

在公平的锁上,线程将按照它们发出请求的顺序来获得锁,但在非公平的锁上,则允许“插队”:当一个线程请求非公平的锁时,如果在发出请求的同时该锁的状态变为可 用,那么这个线程将跳过队列中所有的等待线程井获得这个锁。

两种策略各有利弊,公平策略可以保证每个线程都公平的竞争到锁,但是维护公平算法本身也是一种资源消耗,每一次锁请求的线程都直接被挂在队列的尾部,而只有队列头部的线程有资格使用锁,后面的都得排队。

那么假设这么一种情况,A 获得锁正在运行,B 尝试获得锁失败被阻塞,此时 C 也尝试获得锁,失败而阻塞,虽然 C 只需要很短运行时间,它依然需要等待 B 执行结束才有机会获得锁来运行。

非公平锁的前提下,A 执行结束,找到队列首部的 B 线程,开始上下文切换,假如此时的 C 过来竞争锁,非公平策略前提下,C 是可以获得锁的,并假设它迅速的执行结束了,当 B 线程被切换回来之后再去获取锁也不会有什么问题,结果是,C 线程在 B 线程的上下文切换过程中执行结束。显然,非公平策略下 CPU 的吞吐量是提高的。

但是,非公平策略的锁可能会造成某些线程饥饿,始终得不到运行,各有利弊,适时取舍。庆幸的是,我们的显式锁支持两种模式的切换选择。

在Java中,synchronized就是非公平锁,它无法保证等待的线程获取锁的顺序。
而对于ReentrantLock和ReentrantReadWriteLock,它默认情况下是非公平锁,但是可以设置为公平锁。在ReentrantLock中定义了2个静态内部类,一个是NotFairSync,一个是FairSync,分别用来实现非公平锁和公平锁。

/**
 * Sync object for non-fair locks
 */
static final class NonfairSync extends Sync {
    private static final long serialVersionUID = 7316153563782823691L;

    /**
     * Performs lock.  Try immediate barge, backing up to normal
     * acquire on failure.
     */
    final void lock() {
        if (compareAndSetState(0, 1))
            setExclusiveOwnerThread(Thread.currentThread());
        else
            acquire(1);
    }

    protected final boolean tryAcquire(int acquires) {
        return nonfairTryAcquire(acquires);
    }
}

/**
 * Sync object for fair locks
 */
static final class FairSync extends Sync {
    private static final long serialVersionUID = -3000897897090466540L;

    final void lock() {
        acquire(1);
    }

    /**
     * Fair version of tryAcquire.  Don't grant access unless
     * recursive call or no waiters or is first.
     */
    protected final boolean tryAcquire(int acquires) {
        final Thread current = Thread.currentThread();
        int c = getState();
        if (c == 0) {
            if (!hasQueuedPredecessors() &&
                compareAndSetState(0, acquires)) {
                setExclusiveOwnerThread(current);
                return true;
            }
        }
        else if (current == getExclusiveOwnerThread()) {
            int nextc = c + acquires;
            if (nextc < 0)
                throw new Error("Maximum lock count exceeded");
            setState(nextc);
            return true;
        }
        return false;
    }
}

另外在ReentrantLock类中定义了很多方法,比如:

isFair()        //判断锁是否是公平锁
isLocked()    //判断锁是否被任何线程获取了
isHeldByCurrentThread()   //判断锁是否被当前线程获取了
hasQueuedThreads()   //判断是否有线程在等待该锁
3、对比synchronized 和Reentrantlock

ReentrantLock在加锁和内存上提供的语义与与内置锁相同,此外它还提供了一些其他功能,包括定时的锁等待、可中断的锁等待、公平性,以及实现非块结构的加锁。ReentrantLock 在性能上似乎优于内置锁,其中在Java 6中略有胜出,而在Java 5.0中则是远远胜出。 那么为什么不放弃synchronized, 并在所有新的并发代码中都使用ReentrantLock ?

  1. 与显式锁相比,内置锁仍然具有很大的优势。内置锁为许多开发人员所熟悉,并且简洁紧凑,而且在许多现有的程序中都已经使用了内置锁,如果将这两种机制混合使用,那么不仅容易令人困惑,也容易发生错误。ReentrantLock的危险性比同步机制要高,如果忘记在finally块中调用unlock, 那么虽然代码表面上能正常运行,但实际上已经埋下了一颗定时炸弹,并有可能伤及其他代码。仅当内置锁不能满足需求时,才可以考虑使用ReentrantLock。

  2. 在一些内置锁无法满足需求的情况下,Reentrantlock 可以作为一种高级工具。当需要 一些高级功能时才应该使用Reentrantlock, 这些功能包括:可定时的、可轮询的与可中断的锁获取操作,公平队列,以及非块结构的锁。否则,还是应该优先使用synchtonized。

  3. Java 5.0中,内置锁与ReentrantLock 相比还有另一个优点:在线程转储中能给出哪些调用帧中获得了哪些锁,井能够检测和识别发生死锁的线程。JVM并不知道哪些线程持有ReentrantLock, 因此在调试使用ReentrantLock的线程的问题时,将起不到帮助作用。 Java 6解决了这个问题,它提供了一个管理和调试接口,锁可以通过该接口进行注册,从而与ReentrantLocks相关的加锁信息就能出现在线程转储中,并通过其他的管理接口和调试接口来访问。与synchronized相比,这些调试消息是一种重要的优势,即便它们大部分都是临时性消息,线程转储中的加锁能给很多程序员带来帮助。ReentrantLock的非块结构特性仍然意味着, 获取锁的操作不能与特定的栈帧关联起来,而内置锁却可以。

  4. 未来更可能会提升synchronized而不是ReentrantLock的性能。因为synchronized是JVM的内置属性,它能执行一些优化,例如对线程封闭的锁对象的锁消除优化,通过增加锁的粒度来消除内置锁的同步,而如果通过基于类库的锁来实现这些功能,则可能 性不大。

4、读-写锁

读写锁将对一个资源(比如文件)的访问分成了2个锁,一个读锁和一个写锁,一个资源可以被多个读操作访问,或者被 一个写操作访问,但两者不能同时进行。
正因为有了读写锁,才使得多个线程之间的读操作不会发生冲突。

ReadWriteLock就是读写锁,它是一个接口,ReentrantReadWriteLock实现了这个接口。
这个接口定义如下:

public interface ReadWriteLock {
   //获取读锁
   Lock readLock();
   //获取写锁
   Lock writeLock();
}

ReentrantLockReadWriteLock
显式锁的实现类主要有三个,ReentrantLock 是其最主要的实现类,ReadLock 和 WriteLock 是 ReentrantReadWriteLock 内部定义的两个内部类,他们继承自 Lock 并实现了其定义的所有方法,精细化读写分离。而 ReentrantReadWriteLock 向外提供读锁写锁。

模版代码:

private final ReadWriteLock rwLock = new ReentrantLockReadWriteLock();
private final Lock readLock = rwLock.readLock();
private final Lock writeLock = rwLock.writeLock();

// 读线程执行该方法
public void reader(){
    // 申请读锁
    readLock.lock();
    try{
        // 再此区域读取共享变量
    } finally{
        // 总是在finally块中释放锁,以免锁泄漏 
        readLock.unlock();
    }
}

// 写线程执行该方法
public void writer(){
    // 申请写锁
    writeLock.lock();
    try{
        // 再此区域写共享变量
    } finally{
        // 总是在finally块中释放锁,以免锁泄漏 
        writeLock.unlock();
    }
}

使用场景

  • 只读操作比写(更新)操作要频繁得多
  • 线程持有锁时间比较长

同时满足上面两个条件的时候,读写锁才是适宜的选择。
ReentrantReadWriteLock所实现的读写锁是个可重入锁。ReentrantReadWriteLock支持锁的降级,即一个线程持有读写锁的写锁的情况下,可以继续获得相应的读锁。ReentrantReadWriteLock并不支持锁的升级。

Lock接口中的Condition用法
如果想编写一个带有多个条件谓词的并发对象,或者想获得除了条件队列可见性之外的更多控制权,就可以使用显式的Lock和Condition而不是内置锁和条件队列,这是一种更灵活的选择。

一个Condition和一个Lock关联在一起,就像一个条件队列和一个内置锁相关联一样。要创建一个Condition,可以在相关联的Lock上调用Lock.newCondition方法。
与内置条件队列不同的是,对于每个Lcok,可以有任意数量的Condition对象。Condition对象继承了相关的Lock对象的公平性,对于公平的锁,线程会依照FIFO顺序从Condition.await中释放。
代码示例:

import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class ConditionBoundedBuffer<T> {
    protected final Lock lock = new ReentrantLock();
    /**
     * 条件谓词:notFull(coun<items.length)
     */
    private final Condition notFull = lock.newCondition();
    /**
     * 条件谓词:notEmpty(count>0)
     */
    private final Condition notEmpty = lock.newCondition();
    private static final int BUFFER_SIZE = 100;
    private final T[] items = (T[]) new Object[BUFFER_SIZE];
    private int tail, head, count;

    /**
     * 阻塞并直到:notFull
     *
     * @param x
     * @throws InterruptedException
     */
    public void put(T x) throws InterruptedException {
        lock.lock();
        try {
            while (count == items.length)
                notFull.await();
            items[tail] = x;
            if (++tail == items.length)
                tail = 0;
            ++count;
            notEmpty.signal();
        } finally {
            lock.unlock();
        }
    }

    /**
     * 阻塞并直到:notEmpty
     *
     * @return
     * @throws InterruptedException
     */
    public T take() throws InterruptedException {
        lock.lock();
        try {
            while (count == 0)
                notEmpty.await();
            T x = items[head];
            items[head] = null;
            if (++head == items.length)
                head = 0;
            --count;
            notFull.signal();
            return x;
        } finally {
            lock.unlock();
        }
    }
}

Condition.await( )方法会释放锁,所以下一个线程可以进入take( )方法。当其他线程中使用signal( )或者signalAll( )方法时,线程会重新获得锁并继续执行。或者当线程中断时,也能跳出等待。

在读- 写锁实现的加锁策略中,允许多个读操作同时进行,但每次只允许一个写操作。 与Lock 一样,ReadWriteLock 可以采用多种不同的实现方式,这些方式在性能、调度保证、获取优先性、公平性以及加锁语义等方面可能有所不同。

在读取锁和写入锁之间的交互可以采用多种实现方式,ReadWriteLock 中的一些可选实现包括:

  • 释放优先。当一个写入操作释放写入锁时,并且队列中同时存在读线程和写线程,那么应该优先选择读线程,写线程,还是最先发出请求的线程?

  • 读线程插队。 如果锁是由读线程持有,但有写线程正在等待, 那么新到达的读线程能否立即获得访问权,还是应该在写线程后面等待?如果允许读线程插队到写线程之前, 那么将提高并发性,但却可能造成写线程发生饥饿问题。

  • 重入性。 读取锁和写入锁是否是可重入的?

  • 降级。 如果一个线程持有写人锁, 那么它能否在不释放该锁的情况下获得读取锁?这可能会使得写入锁被 “降级” 为读取锁, 同时不允许其他写线程修改被保护的资源。

  • 升级。 读取锁能否优先于其他正在等待的读线程和写线程而升级为一个写人锁?在大多数的读 - 写锁实现中并不支持升级, 因为如果没有显式的升级操作, 那么很容易造成死锁。(如果两个读线程试图同时升级为写人锁, 那么二者都不会释放读取锁。)

5、结论

内置锁能够解决大部分需要同步的场景,只有在需要额外灵活性是才需要考虑显式锁,比如可定时、可中断、多等待队列等特性。

显式锁虽然灵活,但是需要显式的申请和释放,并且释放一定要放到finally块中,否则可能会因为异常导致锁永远无法释放!这是显式锁最明显的缺点。

引用:
http://ifeve.com/?x=0&y=0&s=%E6%98%BE%E5%BC%8F%E9%94%81
https://blog.csdn.net/qq_33704186/article/details/90723743

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

RachelHwang

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值