每日一例 | 多线程编程之可重入锁

云中志

共 6484字,需浏览 13分钟

 ·

2021-05-09 20:24

在目前web的开发大环境下,高并发,高可用的应用场景越来越普遍,对我们的要求也越来越要求越高了,为了应对这样超高的要求(比如多线程环境下的数据共享问题),我们必须掌握很多常用的技术方案,比如锁(Lock)(就是在某个方法或资源上加锁,确保同一时间段内只有我们可以访问该资源),这样才能写出更可靠的应用程序,今天我们就一起来看下一个很常用的锁——可重入锁(ReentrantLock)。

在开始今天的内容之前,我们先考虑这样一个场景:我们有一个审核业务,同一级的审核人员有两个,但是业务只能审核一次,不能重复审核。

如上图,如果整个审核方法不加锁的情况下,很可能发生同一笔数据审核两次的情况。因为审核过程会涉及多个步骤,假如第一个人员在查询未审核数据后,进行业务审核(处在第三步),但是尚未提交审核结果,这时候第二个人进来,也是查了未审核数据(第二步),由于第一个人员未提交审核结果,这时候数据依然是未审核,然后第二个人开始审核,这时候第一个人提交了审核结果,然后紧接着第二个人提交审核结果。最后,审核结果就会变成两条。

接下来,我们讲的内容,就是为了解决这样的额应用场景。

一个不加锁的案例

在开始可重入锁的介绍之前,我们先看一个和上面类似的例子,算是简化版:

public class Example {
    private static int i;
    public static void main(String[] args) throws InterruptedException {
        ThreadPoolExecutor executor = new ThreadPoolExecutor(5101, 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(5101, 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循环放进线程里面,我之前修复的异步线程问题就用的是这个方法。好了,下面开始理论方面的学习。

什么是可重入锁

可重入锁,顾名思义就是可以重复加锁的一种锁,它是指,线程可对同一把锁进行重复加锁,而不会被阻塞住,这样可避免死锁的产生。

加锁的方式

它的加锁方式有三种,分别是locktrylocktrylock(long,TimeUnit)。上面我们加锁的方法只是其中一种,也是最简单的。

可以看到ReentrantLock的使用方式比较简单,创建出一个ReentrantLock对象,通过lock()方法进行加锁,使用unlock()方法进行释放锁操作。

使用lock来获取锁的话,如果锁被其他线程持有,那么就会处于等待状态。同时,需要我们去主动的调用``unlock`方法去释放锁,即使发生异常,它也不会主动释放锁,需要我们显式的释放。

使用trylock方法获取锁,是有返回值的,获取成功返回true,获取失败返回false,不会一直处于等待状态。

使用trylock(long,TimeUnit)指定时间参数来获取锁,在等待时间内获取到锁返回true,超时返回false。还可以调用lockInterruptibly方法去中断锁,如果线程正在等待获取锁,可以中断线程的等待状态。

总结

关于锁这一块,其实内容比较多,涉及的知识也比较杂,不仅包括javasynchronized、原子类、锁等这些线程安全的知识,还包括数据的行级锁、表级锁等内容,如果是分布式应用,还需要考虑分布式锁的实现,这里面还涉及了redis的知识,想要完全掌握还是难度很大的,但是随着我们一点点的学习和应用,你慢慢会掌握很多常用的技术和解决方案,你会更清楚各种锁和技术的应用场景,你会涉及出更优秀的高并发高可用的系统,为了实现这个目标,让我们一起学习,一起遇见更好的自己,加油吧!

项目路径:

https://github.com/Syske/example-everyday

本项目会每日更新,让我们一起学习,一起进步,遇见更好的自己,加油呀

- END -


浏览 31
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报