2021.05.07 多线程之可重入锁
在目前web
的开发大环境下,高并发,高可用的应用场景越来越普遍,对我们的要求也越来越要求越高了,为了应对这样超高的要求(比如多线程环境下的数据共享问题),我们必须掌握很多常用的技术方案,比如锁(Lock
)(就是在某个方法或资源上加锁,却保同一时间段内只有我们可以访问该资源),这样才能写出更可靠的应用程序,今天我们就一起来看下一个很常用的锁——可重入锁(ReentrantLock
)。
在开始今天的内容之前,我们先考虑这样一个场景:我们有一个审核业务,同一级的审核人员有两个,但是业务只能审核一次,不能重复审核。
如上图,如果整个审核方法不加锁的情况下,很可能发生同一笔数据审核两次的情况。因为审核过程会涉及多个步骤,假如第一个人员在查询未审核数据后,进行业务审核(处在第三步),但是尚未提交审核结果,这时候第二个人进来,也是查了未审核数据(第二步),由于第一个人员未提交审核结果,这时候数据依然是未审核,然后第二个人开始审核,这时候第一个人提交了审核结果,然后紧接着第二个人提交审核结果。最后,审核结果就会变成两条。
接下来,我们讲的内容,就是为了解决这样的额应用场景。
一个不加锁的案例
在开始可重入锁的介绍之前,我们先看一个和上面类似的例子,算是简化版:
public class Example {
private static int i;
public static void main(String[] args) throws InterruptedException {
ThreadPoolExecutor executor = new ThreadPoolExecutor(5, 10, 1, TimeUnit.MICROSECONDS, new ArrayBlockingQueue<Runnable>(100));
for (int j = 0; j < 1000; j++) {
Thread.sleep(10L);
final int finalJ = j;
executor.submit(() -> test(finalJ));
}
executor.shutdown();
}
public static void test(int j) {
System.out.println("==第" + j + "次调用==start");
i ++;
Thread.sleep(20L);
i ++;
System.out.println(i);
System.out.println("==第" + j + "次调用==end");
}
}
上面这段代码其实就是模拟多线程共享数据(就是这里的i
),并对数据进行操作的一个示例,运行结果可以很直观的说明,不加锁的情况下,在一个线程未执行完方法之前,另一个方法也会进入方法执行。按照我们代码的逻辑,应该是先打印start
,然后打印i
的值,然后再打印end
,但是实际情况却并发如此,往往可能是这样的:
上面的运行结果很直观的说明,在第1995
次未正常运行结束时,第1996
次已经开始了,同样在第1996
次未运行完的时候,第1998
次都开始了。而且不论你运行多少次,上面的结果都大同小异。
这时候,如果我们将代码调整一下,加上锁,看下会发生什么:
public class Example {
// 可重入锁
private static final ReentrantLock mainLock = new ReentrantLock();
private static int i;
public static void main(String[] args) throws InterruptedException {
ThreadPoolExecutor executor = new ThreadPoolExecutor(5, 10, 1, TimeUnit.MICROSECONDS, new ArrayBlockingQueue<Runnable>(100));
for (int j = 0; j < 1000; j++) {
Thread.sleep(10L);
final int finalJ = j;
executor.submit(() -> testLock(finalJ));
}
executor.shutdown();
}
public static void testLock(int j) {
final ReentrantLock reentrantLock = mainLock;
// 如果被其它线程占用锁,会阻塞在此等待锁释放
reentrantLock.lock();
try {
System.out.println("==第" + j + "次调用==start");
i ++;
Thread.sleep(20L);
i ++;
System.out.println(i);
System.out.println("==第" + j + "次调用==end");
} catch (Exception e) {
e.printStackTrace();
} finally {
// 执行完之后必须释放锁
reentrantLock.unlock();
}
}
}
然后我们运行一下:
这时候,你会发现,无论你运行多少次,都是像上面这样规整,也和我们的代码逻辑是一致的,这其实就是加锁的作用,目的就是为了控制资源的访问秩序。
当然,上面的代码其实还是存在问题的,因为在循环中使用线程池本身就是不合理的,当单个线程执行时间较长,for
中启动前程前的业务响应比较快的时候(就是这里的Thread.sleep(10L);
),所有的压力都会到线程池上,会把线程池的资源耗尽,然后报如下错误:
这时候解决方法有两个,一个就是人为增加线程启动前的业务处理时间,这里就是增加睡眠时间,比如调整到Thread.sleep(20L);
;另一个是提高线程中的业务处理效率,只要比前面的业务处理快就行,但是在实际业务中,这个是不可能的;最好的解决方法是重构业务逻辑,想办法把for
循环放进线程里面,我之前修复的异步线程问题就用的是这个方法。好了,下面开始理论方面的学习。
什么是可重入锁
可重入锁,顾名思义就是可以重复加锁的一种锁,它是指,线程可对同一把锁进行重复加锁,而不会被阻塞住,这样可避免死锁的产生。
加锁的方式
它的加锁方式有三种,分别是lock
、trylock
和trylock(long,TimeUnit)
。上面我们枷锁的方法只是其中一种,也是最简单的。
可以看到ReentrantLock
的使用方式比较简单,创建出一个ReentrantLock
对象,通过lock()
方法进行加锁,使用unlock()
方法进行释放锁操作。
使用lock
来获取锁的话,如果锁被其他线程持有,那么就会处于等待状态。同时,需要我们去主动的调用``unlock`方法去释放锁,即使发生异常,它也不会主动释放锁,需要我们显式的释放。
使用trylock
方法获取锁,是有返回值的,获取成功返回true
,获取失败返回false
,不会一直处于等待状态。
使用trylock(long,TimeUnit)
指定时间参数来获取锁,在等待时间内获取到锁返回true
,超时返回false
。还可以调用lockInterruptibly
方法去中断锁,如果线程正在等待获取锁,可以中断线程的等待状态。
总结
关于锁这一块,其实内容比较多,涉及的知识也比较杂,不仅包括java
的synchronized
、原子类、锁等这些线程安全的知识,还包括数据的行级锁、表级锁等内容,如果是分布式应用,还需要考虑分布式锁的实现,这里面还涉及了redis
的知识,想要完全掌握还是难度很大的,但是随着我们一点点的学习和应用,你慢慢会掌握很多常用的技术和解决方案,你会更清楚各种锁和技术的应用场景,你会涉及出更优秀的高并发高可用的系统,为了实现这个目标,让我们一起学习,一起遇见更好的自己,加油吧!
项目路径:
https://github.com/Syske/example-everyday
本项目会每日更新,让我们一起学习,一起进步,遇见更好的自己,加油呀