了解 Java 中的锁 Lock
Lock 是什么
在之前的文章synchronized底层实现说到synchronized
是属于 JVM 层面的锁,而且它只是一个关键字,是不能查看 Java 源码的,因此我们可以把它当做隐式锁。
有了 synchronized 为什么还要 Lock?
Lock
又是做什么的呢?我们知道synchronized
在 1.6 之前把它叫做重量锁,这时还没有偏向锁和轻量锁级别的优化,因此Doug Lea觉得很不爽,于是就自己开发了一套锁,也就是我们熟知的 JUC(java.util.concurrent )包的作者,我们可以叫他并发大师。
Lock 是一个接口,提供了无条件的、可轮询的、定时的、可中断的锁获取操作,所有的加锁和解锁操作方法都是显示的,因而称为显示锁。
使用 synchronized
关键字将会隐式地获取锁,但是它将锁的获取和释放固化了,也就是先获取再释放,中间是不能有其他操作的。如下:
Lock 的使用范式
synchronized
的使用归根到底就是要么同步方法要么同步代码块,那么我们来看看 Lock 的标准使用范式:
在 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
去获取锁:
完整代码:
输出:
可以看到,如果在时间范围内获取不到锁,是可以打断不在让它获取锁,从而使线程不会堵塞。
可超时
可超时和可打断实际是是不是同一个意思呢?Lock 提供了一个tryLock()
方法,用来尝试获取锁,并且还可以超时获取。如下:
main 线程先获取锁并且休眠 3s 后在释放锁,然后这时t1
线程是拿不到锁的,如果我们给tryLock
加上超时呢?
ReentrantReadWriteLock
ReentrantReadWriteLock,也叫读写锁,在同一时刻可以允许多个读线程访问,但是在写线程访问时,所有的读线程和其他写线程均被阻塞。
读写锁维护了一对锁,一个读锁和一个写锁,通过分离读锁和写锁,使得并发性相比一般的排他锁有了很大提升。除了保证写操作对读操作的可见性以及并发性的提升之外,读写锁能够简化读写交互场景的编程方式。假设在程序中定义一个共享的用作缓存数据结构,它大部分时间提供读服务(例如查询和搜索),而写操作占有的时间很少,但是写操作完成之后的更新需要对后续的读服务可见。
在没有读写锁支持的(Java 5 之前)时候,如果需要完成上述工作就要使用 Java 的等待通知机制,就是当写操作开始时,所有晚于写操作的读操作均会进入等待状态,只有写操作完成并进行通知之后,所有等待的读操作才能继续执行(写操作之间依靠 synchronized 关键进行同步),这样做的目的是使读操作能读取到正确的数据,不会出现脏读。
改用读写锁实现上述功能,只需要在读操作时获取读锁,写操作时获取写锁即可。当写锁被获取到时,后续(非当前写操作线程)的读写操作都会被阻塞,写锁释放之后,所有操作继续执行,编程方式相对于使用等待通知机制的实现方式而言,变得简单明了。
一般情况下,读写锁的性能都会比排它锁好,因为大多数场景读是多于写的。在读多于写的情况下,读写锁能够提供比排它锁更好的并发性和吞吐量,ReentrantReadWriteLock 其实实现的是 ReadWriteLock 接口。
实际上,上面的可以总结为三种情况:
读读并发,即同一时刻可以允许多个读线程访问,共享。
读写互斥,即在写线程访问时,所有的读线程和其他写线程均被阻塞。
写写互斥,在写线程访问时,其他写线程均被阻塞。
什么意思呢?我们来看下面的代码。
读读并发
输出:
可以看到t1
在 for 循环没有执行完成之前,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 使用范式
如示例所示,一般都会将 Condition 对象作为成员变量。当调用 await()方法后,当前线程会释放锁并在此等待,而其他线程调用 Condition 对象的 signal()方法,通知当前线程后,当前线程才从 await()方法返回,并且在返回前已经获取了锁。
示例:
输出:
选择 synchronized 还是 Lock
先看看他们的区别:
synchronized 是关键字,是 JVM 层面的底层啥都帮我们做了,而 Lock 是一个接口,是 JDK 层面的有丰富的 API。
synchronized 会自动释放锁,而 Lock 必须手动释放锁。
synchronized 是不可中断的,Lock 可以中断也可以不中断。
通过 Lock 可以知道线程有没有拿到锁,而 synchronized 不能。
synchronized 能锁住方法和代码块,而 Lock 只能锁住代码块。
Lock 可以使用读锁提高多线程读效率。
synchronized 是非公平锁,ReentrantLock 可以控制是否是公平锁。
所以,具体用什么取决于我们具体的场景,像上面说的商品的读写,那肯定使用 Lock,因为读的场景多于写嘛。
评论