写点什么

了解 Java 中的锁 Lock

作者:Ayue、
  • 2021 年 12 月 24 日
  • 本文字数:4888 字

    阅读完需:约 16 分钟

Lock 是什么

在之前的文章synchronized底层实现说到synchronized是属于 JVM 层面的锁,而且它只是一个关键字,是不能查看 Java 源码的,因此我们可以把它当做隐式锁。


有了 synchronized 为什么还要 Lock?


Lock又是做什么的呢?我们知道synchronized在 1.6 之前把它叫做重量锁,这时还没有偏向锁和轻量锁级别的优化,因此Doug Lea觉得很不爽,于是就自己开发了一套锁,也就是我们熟知的 JUC(java.util.concurrent )包的作者,我们可以叫他并发大师。


Lock 是一个接口,提供了无条件的、可轮询的、定时的、可中断的锁获取操作,所有的加锁和解锁操作方法都是显示的,因而称为显示锁。


使用 synchronized 关键字将会隐式地获取锁,但是它将锁的获取和释放固化了,也就是先获取再释放,中间是不能有其他操作的。如下:


Lock 的使用范式

synchronized的使用归根到底就是要么同步方法要么同步代码块,那么我们来看看 Lock 的标准使用范式:


//加锁lock.lock();try {  //业务代码} finally {  //释放锁  lock.unlock();}
复制代码


在 finally 块中释放锁,目的是保证在获取到锁之后,最终能够被释放。不要将获取锁的过程写在 try 块中,因为如果在获取锁(自定义锁的实现)时发生了异常,异常抛出的同时,也会导致锁无故释放。

Lock 的常用 API

Lock 接口提供的几个 API:




我们知道 Lock 只是一个接口,具体是由其子类显示具体功能的,其中常见的就是可重入锁ReentrantLock和读写锁ReentrantReadWriteLock

独占锁概念

独占锁也叫排他锁,是指该锁一次只能被一个线程所持有。如果线程 T 对数据 A 加上排他锁后,则其他线程不能再对 A 加任何类型的锁。获得排它锁的线程即能读数据又能修改数据。JDK 中的 synchronized 和 JUC 中 Lock 的实现类就是互斥锁。

共享锁概念

共享锁是指该锁可被多个线程所持有。如果线程 T 对数据 A 加上共享锁后,则其他线程只能对 A 再加共享锁,不能加排它锁。获得共享锁的线程只能读数据,不能修改数据。如 ReentrantReadWriteLock。


独享锁与共享锁也是通过 AQS 来实现的,通过实现不同的方法,来实现独享或者共享。

ReentrantLock

ReentrantLock是可重入的互斥锁,虽然具有与synchronized相同功能,但是会比synchronized更加灵活,我们先来使用体验一下。

可重入

简单地讲就是:同一个线程对于已经获得到的锁,可以多次继续申请到该锁的使用权。


synchronized 关键字隐式的支持重进入,比如一个 synchronized修饰的递归方法,在方法执行时,执行线程在获取了锁之后仍能连续多次地获得该锁。ReentrantLock 在调用 lock()方法时,已经获取到锁的线程,能够再次调用 lock()方法获取锁而不被阻塞。


可打断

通过 Lock 提供的 API 可知,lockInterruptibly() 可中断的获取锁,和lock()方法不同之处在于该方法会响应中断,即在锁的获取中可以中断当前线程。


简单来说就是如果线程 1 获取到锁,在没释放之前线程 2 也想去获取锁,那线程 2 就会中断而不会阻塞。


如线程t1先拿到锁,让它在 5s 后释放:



再让t2去获取锁:



完整代码:


public static void main(String[] args) throws InterruptedException    ReentrantLock lock = new ReentrantLock();    //t1首先获取锁 然后阻塞5s    new Thread(() -> {        try {            lock.lock();//获取锁            System.out.println("t1获取锁");            TimeUnit.SECONDS.sleep(5);            System.out.println("t1 5s 之后释放锁");        } catch (InterruptedException e) {            e.printStackTrace();        } finally {            lock.unlock();        }    }, "t1").start();    //主要是为了让t1先拿到锁    TimeUnit.SECONDS.sleep(1);    //t2加锁失败因为被t1持有    Thread t1 = new Thread(() -> {        try {            //中断式获取锁            lock.lockInterruptibly();            System.out.println("t2 获取了锁--执行代码");        } catch (Exception e) {            e.printStackTrace();            System.out.println("t2被打断了没有获取锁");            return;        } finally {            lock.unlock();        }    }, "t2");    t1.start();    try {        TimeUnit.SECONDS.sleep(2);        System.out.println("主线程---2s后打断t2");        t1.interrupt();//打断    } catch (InterruptedException e) {        e.printStackTrace();    }}
复制代码


输出:



可以看到,如果在时间范围内获取不到锁,是可以打断不在让它获取锁,从而使线程不会堵塞。

可超时

可超时和可打断实际是是不是同一个意思呢?Lock 提供了一个tryLock()方法,用来尝试获取锁,并且还可以超时获取。如下:



main 线程先获取锁并且休眠 3s 后在释放锁,然后这时t1线程是拿不到锁的,如果我们给tryLock加上超时呢?


ReentrantReadWriteLock

ReentrantReadWriteLock,也叫读写锁,在同一时刻可以允许多个读线程访问,但是在写线程访问时,所有的读线程和其他写线程均被阻塞。


读写锁维护了一对锁,一个读锁和一个写锁,通过分离读锁和写锁,使得并发性相比一般的排他锁有了很大提升。除了保证写操作对读操作的可见性以及并发性的提升之外,读写锁能够简化读写交互场景的编程方式。假设在程序中定义一个共享的用作缓存数据结构,它大部分时间提供读服务(例如查询和搜索),而写操作占有的时间很少,但是写操作完成之后的更新需要对后续的读服务可见。


在没有读写锁支持的(Java 5 之前)时候,如果需要完成上述工作就要使用 Java 的等待通知机制,就是当写操作开始时,所有晚于写操作的读操作均会进入等待状态,只有写操作完成并进行通知之后,所有等待的读操作才能继续执行(写操作之间依靠 synchronized 关键进行同步),这样做的目的是使读操作能读取到正确的数据,不会出现脏读。


改用读写锁实现上述功能,只需要在读操作时获取读锁,写操作时获取写锁即可。当写锁被获取到时,后续(非当前写操作线程)的读写操作都会被阻塞,写锁释放之后,所有操作继续执行,编程方式相对于使用等待通知机制的实现方式而言,变得简单明了。


一般情况下,读写锁的性能都会比排它锁好,因为大多数场景读是多于写的。在读多于写的情况下,读写锁能够提供比排它锁更好的并发性和吞吐量,ReentrantReadWriteLock 其实实现的是 ReadWriteLock 接口。


实际上,上面的可以总结为三种情况:


  1. 读读并发,即同一时刻可以允许多个读线程访问,共享。

  2. 读写互斥,即在写线程访问时,所有的读线程和其他写线程均被阻塞。

  3. 写写互斥,在写线程访问时,其他写线程均被阻塞。


什么意思呢?我们来看下面的代码。

读读并发

public static void main(String[] args) {    //读写锁    ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();    //读锁    Lock readLock = rwl.readLock();    //写锁    Lock writeLock = rwl.writeLock();    //线程1    new Thread(()->{        readLock.lock();        try {            for (int i = 0; i < 10; i++) {                read();            }        } finally {            readLock.unlock();        }    },"t1").start();    //线程2    new Thread(()->{        readLock.lock();        try {            for (int i = 0; i < 10; i++) {                read();            }        } finally {            readLock.unlock();        }    },"t2").start();}

public static void read() { System.out.println("我是" + Thread.currentThread().getName()); try { //休眠1s是为了更清楚演示 Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); }}
复制代码


输出:


我是t1我是t2我是t2我是t1我是t2我是t1我是t1我是t2......
复制代码


可以看到t1在 for 循环没有执行完成之前,t2也可以拿到锁。

读写互斥

当把t2换成写锁时:


//线程2new Thread(()->{    writeLock.lock();    try {        for (int i = 0; i < 10; i++) {            read();        }    } finally {        writeLock.unlock();    }},"t2").start();
复制代码


输出:


我是t1我是t1我是t1我是t1我是t1我是t1我是t1我是t1我是t1我是t1我是t2我是t2......
复制代码


可以看到t1在 for 循环执行完成之后,t2才拿到锁开始执行。


读写都互斥,写写就不用说了吧。

读写锁的适用场景

在一些共享资源的读和写操作,且写操作没有读操作那么频繁的场景下可以用读写锁。


常见的有:


  • 商品的库存,因为一般看的人多,买的人少。

  • 缓存,多线程更新和获取。

Condition 接口

Lock 中还有一个方法newCondition()


任意一个 Java 对象,都拥有一组监视器方法(定义在 java.lang.Object 上),主要包括 wait()、wait(long timeout)、notify()以及 notifyAll()方法,这些方法与 synchronized 同步关键字配合,可以实现等待/通知模式。Condition 接口也提供了类似 Object 的监视器方法,与 Lock 配合可以实现等待/通知模式。


通过对比 Object 的监视器方法和 Condition 接口,可以更详细地了解 Condition 的特性,对比如下


Condition 常用方法

Condition 使用范式

Lock lock = new ReentrantLock();//获取一个Condition必须通过Lock的newCondition()方法Condition condition = lock.newCondition();
public void conditionWait() throws InterruptedException { lock.lock(); try { condition.await(); } finally { lock.unlock(); }}
public void conditionSignal() throws InterruptedException { lock.lock(); try { condition.signal(); } finally { lock.unlock(); }}
复制代码


如示例所示,一般都会将 Condition 对象作为成员变量。当调用 await()方法后,当前线程会释放锁并在此等待,而其他线程调用 Condition 对象的 signal()方法,通知当前线程后,当前线程才从 await()方法返回,并且在返回前已经获取了锁。


示例:


public class ConditionTest {
static ReentrantLock lock = new ReentrantLock(); static Condition condition = lock.newCondition(); static boolean isMoney = false;//工资
public static void main(String[] args) {
new Thread(() -> { lock.lock(); try { //如果不发工资 while (!isMoney) { System.out.println(Thread.currentThread().getName() + ":不发工资不干活..."); condition.await(); } System.out.println(Thread.currentThread().getName() + ":工资已到账,我爱公司..."); } catch (InterruptedException e) { e.printStackTrace(); } finally { lock.unlock(); }
}, "打工人").start();

new Thread(() -> { lock.lock(); try { isMoney = true; System.out.println("老板:发工资了"); condition.signal(); } finally { lock.unlock(); }
}, "老板").start(); }}
复制代码


输出:


打工人:不发工资不干活...老板:发工资了打工人:工资已到账,我爱公司...
复制代码

选择 synchronized 还是 Lock

先看看他们的区别:


  • synchronized 是关键字,是 JVM 层面的底层啥都帮我们做了,而 Lock 是一个接口,是 JDK 层面的有丰富的 API。

  • synchronized 会自动释放锁,而 Lock 必须手动释放锁。

  • synchronized 是不可中断的,Lock 可以中断也可以不中断。

  • 通过 Lock 可以知道线程有没有拿到锁,而 synchronized 不能。

  • synchronized 能锁住方法和代码块,而 Lock 只能锁住代码块。

  • Lock 可以使用读锁提高多线程读效率。

  • synchronized 是非公平锁,ReentrantLock 可以控制是否是公平锁。


所以,具体用什么取决于我们具体的场景,像上面说的商品的读写,那肯定使用 Lock,因为读的场景多于写嘛。

用户头像

Ayue、

关注

个人站点:javatv.net 2019.10.16 加入

学习知识,目光坚毅

评论

发布
暂无评论
了解 Java 中的锁 Lock