Lock是什么
在之前的文章synchronized底层实现说到synchronized
是属于JVM层面的锁,而且它只是一个关键字,是不能查看Java源码的,因此我们可以把它当做隐式锁。
有了 synchronized 为什么还要 Lock?
Lock
又是做什么的呢?我们知道synchronized
在1.6之前把它叫做重量锁,这时还没有偏向锁和轻量锁级别的优化,因此Doug Lea觉得很不爽,于是就自己开发了一套锁,也就是我们熟知的JUC(java.util.concurrent )包的作者,我们可以叫他并发大师。
Lock是一个接口,提供了无条件的、可轮询的、定时的、可中断的锁获取操作,所有的加锁和解锁操作方法都是显示的,因而称为显示锁。
使用 synchronized
关键字将会隐式地获取锁,但是它将锁的获取和释放固化了,也就是先获取再释放,中间是不能有其他操作的。如下:
特性 | 描述 |
---|---|
尝试非阻塞地获取锁 | 当前线程尝试获取锁,如果这一时刻锁没有被其他线程获取,则成功获取并持有锁 |
能被中断地获取锁 | 与synchronized不同,获取到锁的线程能够响应中断,当获取到锁的线程被中断时,中断异常将会被抛出,同时锁会被释放 |
超时获取锁 | 在指定的截至时间之前获取锁,如果截止时间到了仍旧无法获取锁,则返回 |
Lock的使用范式
synchronized
的使用归根到底就是要么同步方法要么同步代码块,那么我们来看看Lock的标准使用范式:
//加锁
lock.lock();
try {
//业务代码
} finally {
//释放锁
lock.unlock();
}
在 finally 块中释放锁,目的是保证在获取到锁之后,最终能够被释放。不要将获取锁的过程写在 try 块中,因为如果在获取锁(自定义锁的实现)时发生了异常,异常抛出的同时,也会导致锁无故释放。
Lock的常用API
Lock接口提供的几个API:
方法 | 描述 |
---|---|
void lock() | 获取锁,调用该方法当前线程将会获取锁,当获取锁后,从该方法返回 |
void lockInterruptibly() | 可中断的获取锁,和lock()方法不同之处在于该方法会响应中断,即在锁的获取中可以中断当前线程 |
boolean tryLock() | 尝试非阻塞地获取锁,调用该方法后立刻返回,如果能够获取返回true,反之false |
boolean tryLock(long time, TimeUnit unit) | 超时的获取锁,当前线程在一下3种情况下会返回: ①当前线程在超时时间内获得了锁 ②当前线程在超时时间内被中断 ③超时时间结束,返回false |
void unlock() | 释放锁 |
newCondition() | 返回一个Condition对象,与Lock配合可以实现等待/通知模式 |
我们知道Lock只是一个接口,具体是由其子类显示具体功能的,其中常见的就是可重入锁ReentrantLock
和读写锁ReentrantReadWriteLock
。
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 接口。
实际上,上面的可以总结为三种情况:
- 读读并发,即同一时刻可以允许多个读线程访问,共享。
- 读写互斥,即在写线程访问时,所有的读线程和其他写线程均被阻塞。
- 写写互斥,在写线程访问时,其他写线程均被阻塞。
什么意思呢?我们来看下面的代码。
读读并发
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
换成写锁时:
//线程2
new 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的特性,对比如下
对比项 | Object Monitor Methods | Condition |
---|---|---|
前置条件 | 获取对象的锁 | 1.调用Lock.lock()获取 2.调用Lock.newCondition()获取Condition对象 |
调用方式 | 直接调用,如:object.wait() | 直接调用,如:condition.await() |
等待队列个数 | 一个 | 多个 |
当前线程释放锁并进入等待状态 | 支持 | 支持 |
当前线程释放锁并进入等待状态,在等待状态中不响应终端 | 不支持 | 支持 |
当前线程释放锁并进入超时等待状态 | 支持 | 支持 |
当前线程释放锁并进入等待状态到将来的某个时间 | 不支持 | 支持 |
唤醒等待队列中的一个线程 | 支持 | 支持 |
唤醒等待队列中的全部线程 | 支持 | 支持 |
Condition常用方法
方法 | 描述 |
---|---|
void await() throws InterruptedException | 当线程进入等待状态直到被通知(signal)或中断,当前线程将进入运行状态且从await()方法返回的情况,包括: 其他线程调用该Condition的signal()或signalAll()方法,而当前线程被选中唤醒 1. 其他线程(调用interrupt()方法)中断当前线程 2. 如果当前等待线程从await()方法返回,那么表明该线程已经获取了Condition对象所对应的锁 |
void awaitUninterruptibly() | 当前线程进入等待状态直到被通知,从方法名称上可以看出该方法对中断不敏感 |
long awaitNanos(long nanosTimeout) throws InterruptedException | 当前线程进入等待状态直到被通知、中断或者超时。返回值表示剩余的时间,如果在nanosTimeout纳秒之前被唤醒,那么返回值就是(nanosTimeout - 实际耗时)。如果返回值是0或者负数,那么可以认定已经超时了 |
boolean awaitUntil(Date deadline) throws InterruptedException | 当前线程进入等待状态直到被通知、中断或者到某个时间。如果没有到指定时间就被通知,方法返回true,否则,表示到了指定时间,返回false |
void signal() | 唤醒一个等待在Condition上的线程,该线程从等待方法返回前必须获得与Condition相关联的锁 |
void signalAll() | 唤醒所有等待在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,因为读的场景多于写嘛。