前面讲到在并发编程中有两个核心问题:线程间的互斥与协同。所谓互斥就是一次只能允许一个线程访问共享变量,而协同就是线程间的等待与通知机制。管程提供了一种多线程互斥与协同的机制,Java 语言内置的 sychronized/wait/notify/notifyAll 提供了一个简化版的管程实现。今天我们介绍 JDK 并发包中的另外一个管程实现:Lock/Condition。
首先看一个简单的例子:
package demo;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class LockDemo {
private final Lock lock = new ReentrantLock();
private int value = 0;
public int getValue() {
lock.lock();
try {
return value;
} finally {
lock.unlock();
}
}
public void addOne() {
lock.lock();
try {
value = getValue() + 1;
} finally {
lock.unlock();
}
}
public static void main(String[] args) throws InterruptedException {
LockDemo demo = new LockDemo();
Thread t1 = new Thread(() -> {
for (int i = 0; i < 100; i++) {
demo.addOne();
try {
Thread.sleep(100);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 100; i++) {
demo.addOne();
try {
Thread.sleep(10);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("value=" + demo.getValue());
}
}
复制代码
在这个例子中,我们使用了 Lock,在读取或者更新 value 变量前,调用 Lock.lock()方法。这段代码和我们在 getValue()和 addOne()方法上加 synchronized 的效果是一样的。既然这样,那我们为什么 Java 还需要在 JDK 中实现 Lock 呢?
答案是:为了灵活性和可控性。
为什么还需要重新实现一个管程?
sychronized 关键字实现了管程,但因为是编译器生成了加锁和解锁的代码,因此锁的工作方式是固定的:当线程获取不到锁时,直接接入阻塞状态,直到锁被释放才能被唤醒继续执行。这就产生了一个问题:如果在复杂情况下出现了死锁,程序没有任何办法可以解决死锁的情况,只能重启程序。或者当程序设计为如果获取不到锁则主动退出竞争,或者等待 20 毫秒获取不到则退出竞争,或者在加锁过程中发现条件不满足了抛出异常,这些都是 synchronized 无法实现的。
而 JDK 并发包中提供的 Lock 接口,提供了 tryLock()、lockInterupptibly()等接口,让线程在获取锁后可以被中断或者获取不到锁则退出等待。虽然理论上编译器可以做到足够灵活以提供类似的功能,但无疑会给编译器的设计带来复杂度,语法也会变得复杂。与其在编译器上折腾,还不如利用 java 语言的特性来提供这些丰富的控制功能。
Lock 接口的加锁方法和实现类
三种加锁方式
Java 并发包中的 java.util.concurrent.Lock 接口,定义了以下几个加锁的接口:
//如果线程获取不到锁则阻塞,效果等同于synchronized关键字
void lock();
//如果线程获取不到锁则阻塞,获得锁后可以被中断释放锁
void lockInterruptibly() throws InterruptedException;
//尝试获取锁,如果获取不到则退出,返回false
boolean tryLock();
//尝试获取锁,如果在规定等待时间内获取不到则退出,在等待期间可以被中断
boolean tryLock(long time, TimeUnit unit) throws InterruptedException;
复制代码
让我们看一个关于 tryLock 的示例代码:
package demo;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class TryLockDemo {
private final Lock lock = new ReentrantLock();
private int value = 0;
public int getValue() {
lock.lock();
try {
return value;
} finally {
lock.unlock();
}
}
public boolean addOne() {
if (lock.tryLock()) {
try {
value = getValue() + 1;
return true;
} finally {
lock.unlock();
}
} else {
System.out.println("尝试获取锁失败");
return false;
}
}
public static void main(String[] args) throws InterruptedException {
TryLockDemo demo = new TryLockDemo();
Thread t1 = new Thread(() -> {
for (int i = 0; i < 100; i++) {
while(!demo.addOne());
try {
Thread.sleep(10);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 100; i++) {
while (!demo.addOne());
try {
Thread.sleep(10);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("value=" + demo.getValue());
}
}
复制代码
在 addOne 方法中,我们把加锁改为如果获取不到锁,那么则返回 false。为了简化,调用线程使用了 while 不断重试调用 addOne 方法(实际业务中可以 sleep 等待一段时间或者返回失败的结果)。执行程序你会发现大量尝试获取锁失败的打印输出。
也可以使用等待超时的 tryLock 方法:
public boolean addOne() {
boolean success = false;
try {
success = lock.tryLock(5, TimeUnit.MILLISECONDS);
} catch (InterruptedException e) {
System.out.println("interrupted while acquiring lock");
}
if (success) {
try {
value = getValue() + 1;
return true;
} finally {
lock.unlock();
}
} else {
System.out.println("尝试获取锁失败");
return false;
}
}
复制代码
这时你会发现获取锁失败的打印输出大大减少,因为等待 5 毫秒几乎肯定可以获取锁,毕竟执行 addOne 的操作非常快。
使用 tryInteruptibly 加锁的代码示例:
package demo;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class LockInteruptiblyDemo {
private static final Lock lock = new ReentrantLock();
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
try {
lock.lockInterruptibly();
System.out.println("线程1获得锁,睡眠10秒");
Thread.sleep(10*1000); //模拟线程要执行很久
System.out.println("线程1执行完成");
} catch (InterruptedException e) {
System.out.println("线程1被中断了");
} finally {
lock.unlock();
System.out.println("线程1释放锁");
}
});
Thread t2 = new Thread(() -> {
try {
lock.lockInterruptibly();
System.out.println("线程2获得锁");
Thread.sleep(100);
System.out.println("线程2执行完成");
} catch (InterruptedException e) {
System.out.println("线程2被中断了");
} finally {
lock.unlock();
System.out.println("线程2释放锁");
}
});
t1.start();
t2.start();
Thread.sleep(1000);
//在主线程中 中断线程1的执行
t1.interrupt();
t1.join();
t2.join();
}
}
复制代码
Lock 的实现类之一:可重入锁
可重入锁,顾名思义就是可以被一个线程重复获取的锁。比如一个线程要调用多个方法,多个方法都使用了同一把锁,那么线程只要在一开始获得了这个锁,以后所有的方法调用都可以继续获得这把锁。回忆一下,其实 synchronized 实现的锁就是一把可重入锁。
JDK 中可重入锁的实现类是:java.util.concurrent.locks.ReentrantLock; 这是我们最常用的锁。这个类支持两种模式:公平锁和非公平锁。通过在构造函数中指定参数设置,默认是非公平锁。所谓公平锁,就是被锁阻塞的线程等待时间越长,就越先被唤醒,而非公平锁就没有这个保障。
一个问题:Java 中有不可重入锁吗?如果有,用在什么场景?
使用 Lock 的注意事项
使用 synchronized 加锁虽然没那么灵活,但我们也不用操心加锁和释放锁导致的问题。而使用 Lock 后,在获得灵活性的同时,我们需要手动进行加锁和释放锁,因此需要格外小心,以避免出现忘记释放锁、死锁、以及锁定的代码范围过大等问题。
以下是使用 Lock 的一些注意事项:
在 try ... finally 中进行加锁和解锁操作。在 try 模块中进行加锁,在 finally 模块中进行解锁,确保锁一定会被释放。
尽可能缩小被锁定的代码块。这样可以最大化并行度。一个原则就是只针对共享变量的读取和更新代码块进行加锁。
在调用外部代码库时,不加锁。因为不确定别的代码库是否会有性能问题,是否有 sleep 等会阻塞线程但不释放锁的操作。除非你非常确定外部代码库不存在上述问题。
评论