java并发编程(七)——Lock锁

为什么要使用 Lock 锁

前面我们已经说个,使用同步有两种方式,一种是不使用锁,通过 CAS 来实现,另一种方式是加锁。加锁又分为两种,一种是使用 synchronized,还有一种就是使用 Lock,今天学习的就是 Lock。

既然已经有那么多方式可以实现同步,为什么还要使用 Lock 呢?

synchronized 是 Java 内置的关键字,通过 synchronized 来实现同步不需要我们来处理加锁和释放锁的过程,这些操作都会被 JVM 来处理,给我们提供了很大的便利。但是在 JVM 给我们提供便利的同时,我们也失去了对锁的控制。

synchronized 的缺陷:

如果一个代码块被synchronized关键字修饰,当一个线程获取了对应的锁,并执行该代码块时,其他线程便只能一直等待直至占有锁的线程释放锁。事实上,占有锁的线程释放锁一般会是以下三种情况之一:

1、占有锁的线程执行完了该代码块,然后释放对锁的占有。

2、占有锁线程执行发生异常,此时JVM会让线程自动释放锁。

3、占有锁线程进入 WAITING 状态从而释放锁,例如在该线程中调用 wait() 方法等。

试考虑以下三种情况:

Case 1 :

  在使用 synchronized 关键字的情形下,假如占有锁的线程由于要等待 IO 或者其他原因(比如调用 sleep 方法)被阻塞了,但是又没有释放锁,那么其他线程就只能一直等待,别无他法。这会极大影响程序执行效率。因此,就需要有一种机制可以不让等待的线程一直无期限地等待下去(比如只等待一定的时间 (解决方案:tryLock(long time, TimeUnit unit)) 或者 能够响应中断 (解决方案:lockInterruptibly())),这种情况可以通过 Lock 解决。

Case 2 :

  我们知道,当多个线程读写文件时,读操作和写操作会发生冲突现象,写操作和写操作也会发生冲突现象,但是读操作和读操作不会发生冲突现象。但是如果采用 synchronized 关键字实现同步的话,就会导致一个问题,即当多个线程都只是进行读操作时,也只有一个线程可以进行读操作,其他线程只能等待锁的释放而无法进行读操作。因此,需要一种机制来使得当多个线程都只是进行读操作时,线程之间不会发生冲突。同样地,Lock 也可以解决这种情况 (解决方案:ReentrantReadWriteLock) 。

Case 3 :

  我们可以通过 Lock 得知线程有没有成功获取到锁 (解决方案:ReentrantLock) ,但这个是 synchronized 无法办到的。

上面提到的三种情形,我们都可以通过 Lock 来解决,但 synchronized 关键字却无能为力。

事实上,Lock 是 java.util.concurrent.locks 包下的接口,Lock 实现提供了比 synchronized 关键字更广泛的锁操作,它能以更优雅的方式处理线程同步问题。也就是说,Lock 提供了比 synchronized 更多的功能。

如何使用 Lock 锁

Lock 接口中共有六个方法。

public interface Lock {
    
    // 获取锁  
    void lock()

    // 如果当前线程未被中断,则获取锁,可以响应中断  
    void lockInterruptibly()

    // 返回绑定到此 Lock 实例的新 Condition 实例  
    Condition newCondition()

    // 仅在调用时锁为空闲状态才获取该锁,可以响应中断  
    boolean tryLock()

    // 如果锁在给定的等待时间内空闲,并且当前线程未被中断,则获取锁  
    boolean tryLock(long time, TimeUnit unit)

    // 释放锁  
    void unlock()
}

在上面这些方法中,获取锁的方法有四个:lock()、tryLock()、tryLock(long time, TimeUnit unit) 和 lockInterruptibly()。释放锁的有 unlock(),还有一个用于线程间协作的 newCondition()。

获取 Lock 锁

lock() 是最常用的方法,就是用来获取锁。如果锁已被其他线程获取,则进行等待。在前面已经讲到,如果采用Lock,必须主动去释放锁,并且在发生异常时,不会自动释放锁。因此,一般来说,使用 Lock 必须在 try…catch 块中进行,并且将释放锁的操作放在 finally 块中进行,以保证锁一定被被释放,防止死锁的发生。通常使用 Lock 来进行同步的话,是以下面这种形式去使用

Lock lock = ...;
lock.lock();
try{
    //处理任务
}catch(Exception ex){

}finally{
    lock.unlock();   //释放锁
}

 tryLock() 是有返回值的,它表示用来尝试获取锁,如果获取成功,则返回 true;如果获取失败(即锁已被其他线程获取),则返回 false,也就是说,这个方法无论如何都会立即返回(在拿不到锁时不会一直在那等待)。

tryLock(long time, TimeUnit unit) 和 tryLock() 是类似的,只不过区别在于这个方法在拿不到锁时会等待一定的时间,在时间期限之内如果还拿不到锁,就返回 false,同时可以响应中断。如果一开始拿到锁或者在等待期间内拿到了锁,则返回 true。

Lock lock = ...;
if(lock.tryLock()) {
     try{
         //处理任务
     }catch(Exception ex){

     }finally{
         lock.unlock();   //释放锁
     } 
}else {
    //如果不能获取锁,则直接做其他事情
}

上面说过,Lock 与 synchronized 一样,是阻塞式的锁,如果一个线程没有获得锁,就会陷入阻塞状态。首先需要注意的是,interrupt 只是给线程提供了一个需要中断的信号,具体要不要中断是由线程自己决定的。一个在同步阻塞状态下的线程是不能被 interrupt 中断的,也就是说 synchronized 和 普通的 lock() 不会被 interrupt() 中断,线程会一直阻塞直到获取锁。这种情况无疑会浪费很多系统资源。为了解决这种问题,Lock 提供了 lockInterruptibly(),可以中断同步阻塞状态下的线程,这也是 Lock 的主要优点之一。

先看下一般的 synchronized 或者 lock() 的运行情况

public class TestLock {

    public static void main(String[] args) throws InterruptedException {

        Lock lock = new ReentrantLock();

        Thread t = new Thread(()->{
            synchronized (lock) {   //或者使用lock.lock()
                System.out.println(Thread.currentThread().getName() + "获取锁" + System.currentTimeMillis());
                try {
                    Thread.sleep(10000);    //线程t1会持有锁10秒
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });

        t.start();

        Thread.sleep(1000);     //确保t1线程先启动

        Thread t2 = new Thread(()->{
            synchronized (lock) {   //或者使用lock.lock()
                System.out.println(Thread.currentThread().getName() + "获取锁" + System.currentTimeMillis());
            }
        });

        t2.start();

        Thread.sleep(5000);

        t2.interrupt();
    }
}

 可以看到,线程 t2 一直会等到线程 t1 释放锁后执行,不会被 interrupt 中断。接下来使用 lockInterruptibly() 看下运行情况

public class TestLock {

    public static void main(String[] args) throws InterruptedException {

        Lock lock = new ReentrantLock();

        Thread t = new Thread(()->{
            try {
                lock.lockInterruptibly();
                System.out.println(Thread.currentThread().getName() + "获取锁" + System.currentTimeMillis());
                Thread.sleep(10000);    //线程t1会持有锁10秒
            } catch (InterruptedException e) {
                e.printStackTrace();
            }finally {
                lock.unlock();
            }
        });

        t.start();

        Thread.sleep(1000);     //确保t1线程先启动

        Thread t2 = new Thread(()->{
            try {
                lock.lockInterruptibly();
                System.out.println(Thread.currentThread().getName() + "已经获取获取锁" + System.currentTimeMillis());
            } catch (InterruptedException e) {
                e.printStackTrace();
            }finally {
                System.out.println("等不到就不等了");
                lock.unlock();
            }
        });

        t2.start();

        Thread.sleep(5000);

        t2.interrupt();
    }
}

 从运行结果我们可以看到,线程 t2 根本没有执行,当调用 t2.interrupt() 时,线程 t2 就已经被中断了,我们可以在 finally 中继续做其他的事。

lockInterruptibly() 是一个很重要的特性,因为它允许打破死锁,我们可以使用 interrupt() 来中断同步阻塞状态下的线程。当然,也可以通过 tryLock(long time, TimeUnit unit) 来设置超时时间从而打破死锁,但这些都是 synchronized 无法实现的。

Condition

在使用 synchronized 进行同步的情况中,每个锁对象都关联着一个 Monitor 对象,我们称之为监视器。Monitor 不仅实现了线程同步,还提供了 wait() 和 notify() 等方法实现了线程间的通信。与 synchronized 类似,每个 Lock 对象也关联着另一个对象来实现线程间的通信,而这个对象就是 Condition。而且 Lock 中的 Condition 功能更加强大。

对比项 Object监视器 Condition
前置条件 获取对象的锁

调用Lock.lock()获取锁

Lock.newCondition获取Condition对象

调用方式 直接调用Object.notify() 直接调用condition.await()
等待队列的个数 一个 多个
当前线程释放锁进入等待状态 支持 支持
当前线程释放锁进入等待状态在等待状态中不断响应中断 不支持 支持
当前线程释放锁并进入等待超时状态 支持 支持
当前线程释放锁并进入等待状态直到将来的某个时间 不支持 支持
唤醒等待队列中的一个线程 支持notify() 支持condition.signal()
唤醒等待队列中的全部线程 支持notifyAll() 支持condition.signalAll()

Condition 是一个接口,里面定义了一些方法

public interface Condition {
    
    void await() throws InterruptedException;
    
    void awaitUninterruptibly();
    
    long awaitNanos(long nanosTimeout) throws InterruptedException;
    
    boolean await(long time, TimeUnit unit) throws InterruptedException;

    void signal();
    
    void signalAll();
}

我们可以像下面代码一样使用下 Condition

public class TestCondition {

    public static void main(String[] args) throws InterruptedException {

        Lock lock = new ReentrantLock();    //创建锁对象

        Condition condition = lock.newCondition();  //创建Condition对象

        new Thread(()->{
            try {
                lock.lock();
                System.out.println(Thread.currentThread().getName() + "正在执行~~" + System.currentTimeMillis());
                Thread.sleep(3000);     //模拟执行任务
                System.out.println(Thread.currentThread().getName() + "正在等待~~" + System.currentTimeMillis());
                condition.await();
                System.out.println(Thread.currentThread().getName() + "执行完毕~~" + System.currentTimeMillis());
            } catch (InterruptedException e) {
                e.printStackTrace();
            } finally {
                lock.unlock();
            }
        }).start();

        Thread.sleep(1000);

        new Thread(()->{
            try {
                lock.lock();
                System.out.println(Thread.currentThread().getName() + "正在执行" + System.currentTimeMillis());
                Thread.sleep(3000);
                System.out.println(Thread.currentThread().getName() + "唤醒等待线程" + System.currentTimeMillis());
                condition.signal();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }finally {
                lock.unlock();
            }
        }).start();

    }
}

 await() 和 signal() 的使用与 wait() 和 notify() 基本一样,但是一个 Lock 对象可以有多个 Condition 对象。

ReentrantLock——可重入锁

Lock 是一个接口,它的实现类是 ReentrantLock,在上面的例子中我们也已经使用过。

从图中可以看出来,Lock 框架共有两个顶层接口,一个是 Lock 接口,还有一个是 ReadWriteLock(读写锁)接口。而 Lock 接口可用的实现类只有 ReentrantLock(可重入锁),其他的两个实现类均是 ReentrantReadWriteLock 的内部类。而 ReentrantReadWriteLock 就是 ReadWriteLock 接口的实现类。至于 Condition 如何通过 AQS 来实现线程间的通信,在接下来的几篇文章会学习到。

下面是 ReentrantLock 中的一些常用方法,其中 Lock 接口中已经介绍的方法没有写出来。我们一般情况下用 Lock 中的方法就足够了。

方法名称描述
ReentrantLock() 构造方法,创建一个 ReentrantLock ,默认是“非公平锁”
ReentrantLock(boolean fair) 构造方法,创建策略是fair的 ReentrantLock。fair为true表示是公平锁,fair为false表示是非公平锁
int getHoldCount() 查询当前线程保持此锁定的个数,也就是调用lock()方法的次数
protected Thread getOwner() 返回当前拥有此锁的线程,如果不拥有,则返回 null
protected Collection getQueuedThreads() 返回包含可能正在等待获取此锁的线程的集合
int getQueueLength() 返回等待获取此锁的线程数的估计
protected Collection getWaitingThreads(Condition condition) 返回包含可能在与此锁相关联的给定条件下等待的线程的集合
int getWaitQueueLength(Condition condition) 返回与此锁相关联的给定条件等待的线程数的估计
boolean hasQueuedThread(Thread thread) 查询给定线程是否等待获取此锁
boolean hasQueuedThreads() 查询是否有线程正在等待获取此锁
boolean hasWaiters(Condition condition) 查询任何线程是否等待与此锁相关联的给定条件
boolean isFair() 如果此锁的公平设置为true,则返回 true 
boolean isHeldByCurrentThread() 查询此锁是否由当前线程持有
boolean isLocked() 查询此锁是否由任何线程持有

 下面是 ReentrantLock 与 synchronized 的区别

特性synchronizedReentrantLock相同
可重入
响应中断
超时等待
公平锁
非公平锁
是否可尝试加锁
是否是Java内置特性
自动获取/释放锁
对异常的处理 自动释放锁 需手动释放锁

 ReadWriteLock——读写锁

开篇我们提到过一个例子,如果多个线程读写共享资源时我们需要对共享资源进行加锁,但在读多写少的情况下,即使是多个线程进行读操作,我们也会进行加锁,也就是说即使是读操作,也会进行加锁操作,只有等一个线程读操作结束,另一个线程才能进行读操作,这样自然会浪费系统资源。这种情况在 synchronized 中是不好解决的,但是通过 ReadWriteLock 我们很容易解决这个问题。

public interface ReadWriteLock {
    /**
     * Returns the lock used for reading.
     * 返回读锁
     * @return the lock used for reading
     */
    Lock readLock();

    /**
     * Returns the lock used for writing.
     * 返回写锁 
     * @return the lock used for writing
     */
    Lock writeLock();
}

ReadWriteLock 接口中只有两个方法,通过这两个方法我们可以处理上面的问题。ReadWriteLock 接口具有如下规则:

1、允许多个线程同时进行读操作。

2、当一个线程进行读操作,另一个线程需要写操作时,另一个线程会被阻塞。

3、当一个线程进行写操作,另一个线程需要读 / 写操作时,另一个线程会被阻塞。

根据上面的规则我们可以简单的实现下读写锁

public class ReadWriteLock{
    private int readers = 0;
    private int writers = 0;
    private int writeRequests = 0;

    public synchronized void lockRead() 
        throws InterruptedException{
        while(writers > 0 || writeRequests > 0){
            wait();
        }
        readers++;
    }

    public synchronized void unlockRead(){
        readers--;
        notifyAll();
    }

    public synchronized void lockWrite() 
        throws InterruptedException{
        writeRequests++;

        while(readers > 0 || writers > 0){
            wait();
        }
        writeRequests--;
        writers++;
    }

    public synchronized void unlockWrite() 
        throws InterruptedException{
        writers--;
        notifyAll();
    }
}

读锁的实现在lockRead()中,只要没有线程拥有写锁(writers==0),且没有线程在请求写锁(writeRequests ==0),所有想获得读锁的线程都能成功获取。

写锁的实现在lockWrite()中,当一个线程想获得写锁的时候,首先会把写锁请求数加1(writeRequests++),然后再去判断是否能够真能获得写锁,当没有线程持有读锁(readers==0 ),且没有线程持有写锁(writers==0)时就能获得写锁。有多少线程在请求写锁并无关系。

需要注意的是,在两个释放锁的方法(unlockRead,unlockWrite)中,都调用了notifyAll方法,而不是notify。要解释这个原因,我们可以想象下面一种情形:

如果有线程在等待获取读锁,同时又有线程在等待获取写锁。如果这时其中一个等待读锁的线程被notify方法唤醒,但因为此时仍有请求写锁的线程存在(writeRequests>0),所以被唤醒的线程会再次进入阻塞状态。然而,等待写锁的线程一个也没被唤醒,就像什么也没发生过一样(信号丢失现象)。如果用的是notifyAll方法,所有的线程都会被唤醒,然后判断能否获得其请求的锁。

用notifyAll还有一个好处。如果有多个读线程在等待读锁且没有线程在等待写锁时,调用unlockWrite()后,所有等待读锁的线程都能立马成功获取读锁 —— 而不是一次只允许一个。

我们简单实现的 读写锁还是存在问题的,比如是不可重入的,当一个线程持有锁后如果想再次获得锁就会被阻塞。但是官方的 ReadWriteLock 解决了这些问题。下面是简单的应用读写锁。

public class TestReadWriteLock {

    public static void main(String[] args) {

        ReadWriteLock lock = new ReentrantReadWriteLock();

        for (int i=0; i<10; i++){
            new Thread(()->{
                try {
                    lock.readLock().lock();
                    System.out.println(Thread.currentThread().getName() + "--- 正在读取 ---");
                    Thread.sleep(100);
                    System.out.println(Thread.currentThread().getName() + "【读取完毕】");
                } catch (InterruptedException e) {
                    e.printStackTrace();
                } finally {
                    lock.readLock().unlock();
                }
            }).start();
        }

        for (int i=0; i<10; i++){
            new Thread(()->{
                try {
                    lock.writeLock().lock();
                    System.out.println(Thread.currentThread().getName() + "--- 正在写入 ---");
                    Thread.sleep(1000);
                    System.out.println(Thread.currentThread().getName() + "【写入完毕】");
                } catch (InterruptedException e) {
                    e.printStackTrace();
                } finally {
                    lock.writeLock().unlock();
                }
            }).start();
        }

    }
}

执行结果为

Thread-2--- 正在读取 ---
Thread-4--- 正在读取 ---
Thread-3--- 正在读取 ---
Thread-1--- 正在读取 ---
Thread-0--- 正在读取 ---
Thread-2【读取完毕】
Thread-3【读取完毕】
Thread-4【读取完毕】
Thread-1【读取完毕】
Thread-0【读取完毕】
Thread-5--- 正在写入 ---
Thread-5【写入完毕】
Thread-6--- 正在写入 ---
Thread-6【写入完毕】
Thread-7--- 正在写入 ---
Thread-7【写入完毕】
Thread-8--- 正在写入 ---
Thread-8【写入完毕】
Thread-9--- 正在写入 ---
Thread-9【写入完毕】

Process finished with exit code 0

可以看到读操作是可以同时进行的,但是写操作会互斥的进行。

这篇文章只是简单介绍了 Lock 和 ReadWriteLock,至于 Lock 如何实现线程同步,Condition 又是怎样实现线程间的通信,为什么 ReadWriteLock 具有这种读写的特性。预知后事详情,请听下回分解——底层原理 AQS。

参考资料

Java并发编程:Lock

java 锁 Lock接口详解

Thread的中断机制(interrupt)

Java多线程基础——Lock类

Java中的读/写锁

posted @ 2020-07-27 23:58  路半_知风  阅读(477)  评论(0编辑  收藏  举报