在 web 业务开发中究竟该如何使用锁?

举报
JavaEdge 发表于 2022/05/08 23:27:30 2022/05/08
【摘要】 案例demo 类起俩线程分别执行add、compare乍一看,a、b“同时”自增,应该一直相等,compare中的判断不会为true。但是看日志:不仅有a<b成立,a>b有时也为 true。评论里肯定有人在这里就笑了,这是你的代码太垃圾,操作两个字段a和b,有线程安全问题,应该为add方法加上锁,确保a和b的++是原子性的,就不会错了。那么,就在add方法加锁看看?public synch...

案例

  • demo 类

  • 起俩线程分别执行add、compare

乍一看,a、b“同时”自增,应该一直相等,compare中的判断不会为true。

  • 但是看日志:不仅有a<b成立,a>b有时也为 true。

评论里肯定有人在这里就笑了,这是你的代码太垃圾,操作两个字段a和b,有线程安全问题,应该为add方法加上锁,确保a和b的++是原子性的,就不会错了。

那么,就在add方法加锁看看?

public synchronized void add()

但,加锁后问题并没有解决。

1 为什么锁可以解决线程安全问题

因为只有一个线程可拿到锁,所以加锁后的代码中的资源操作线程安全。
但该案例中的 add 始终只有一个线程在操作,显然只为 add 加锁无意义。

所以因为两个线程是交错执行add和compare中的业务逻辑,而且这些业务逻辑不是原子性的:a++和b++操作中可以穿插在compare方法的比较代码中;

a<b这种比较操作在字节码层面是三步,即不是原子的:

  1. 加载a
  2. 加载b
  3. 比较

应该为add和compare都加锁,确保add执行时,compare无法读取a和b:

public synchronized void add()
public synchronized void compare()

所以,使用锁一定要梳理清楚线程、业务逻辑和锁三者关系。

2 锁和被保护的对象是不是同一层面

梳理要保护的对象是否是同一层面的。

案例

  • 累加counter

  • 测试

  • 因为传参运行100万次,所以执行后应该输出100万,但输出:

why?

在非静态的wrong方法上加锁,只能确保多线程无法执行同一实例的wrong,无法保证不执行不同实例的wrong。静态counter在多实例是共享的,所以会出现线程安全问题。

解决方案

在类中定义一个Object类型的静态字段,在操作counter之前对该字段加锁。

评论里肯定又有人会说:就这?直接把wrong定义为静态不就行?锁不就是类级别的了?
是可以,但不可能为解决线程安全改变代码结构,随便把实例方法改为静态方法。

3 加锁前考虑锁粒度和业务场景

方法上加synchronized加锁是简单,但也不能在业务代码中滥用:

  1. 没必要
    绝大多数业务代码是MVC三层架构,数据经过无状态的Controller=>Service=>Repository=>DB
    没必要使用synchronized保护什么数据。所以这也是为何很多同学笑评面试造火箭,工作拧螺丝~
  2. 大概率降低性能
    使用Spring时,默认Controller、Service、Repository都是单例,加synchronized会导致整个程序几乎只能支持单线程,造成极大性能问题。
    即使我们确实有一些共享资源需要保护,也要尽可能减小锁粒度。就像 concurrentHashMap 的一生发展。

案例

业务代码有个ArrayList会被多线程操作而需保护,但又有段比较耗时的不涉及线程安全的操作,应该如何加锁?
推荐只在操作ArrayList时给这ArrayList加锁。

正确加锁的版本几乎是对错误加锁的十倍性能。

细化锁后,性能还无法满足,就要考虑另一个维度的粒度问题:区分读写场景以及资源的访问冲突,考虑

4 悲观锁 V.S 乐观锁

一般业务代码很少需要进一步考虑这两种更细粒度的锁,自己结合业务的性能需求考虑是否要继续优化:

  1. 读写差异明显场景,考虑使用ReentrantReadWriteLock读写锁
  2. 若JDK版本>8、共享资源的冲突概率也没那么大,考虑使用StampedLock乐观读
  3. JDK的ReentrantLockReentrantReadWriteLock都提供了公平锁版本,在没有明确需求情况下不要轻易开启公平锁,在任务很轻情况下开启公平锁可能会让性能下降百倍

5 死锁

锁的粒度够用就好,这意味着程序逻辑中有时会存在一些细粒度锁。但一个业务逻辑如果涉及多锁,就很容易产生死锁。

案例

在电商场景的下单流程中,需要锁定订单中多个商品的库存,拿到所有商品的锁后再进行下单扣减库存,全部操作完成后释放所有锁。
上线后发现,下单失败概率高,失败后用户需重新下单,极大影响用户体验。

案发原因

因为扣减库存的顺序不同,导致并发下多个线程可能相互持有部分商品的锁,又等待其他线程释放另一部分商品的锁,于是出现死锁。

接下来,我们剖析一下核心的业务代码。

首先,定义一个商品类型,包含商品名、库存剩余和商品的库存锁三个属性,每一种商品默认库存1000个;然后,初始化10个这样的商品对象来模拟商品清单:

模拟在购物车进行商品选购,每次从商品清单(items字段)中随机选购三个商品(不考虑每次选购多个同类商品的逻辑,购物车中不体现商品数量):

下单代码

先声明一个List保存所有获得的锁,然后遍历购物车中的商品依次尝试获得商品的锁,最长等待10秒,获得全部锁之后再扣减库存;如果有无法获得锁的情况则解锁之前获得的所有锁,返回false下单失败。

private boolean createOrder(List<Item> order) {
    //存放所有获得的锁
    List<ReentrantLock> locks = new ArrayList<>();

    for (Item item : order) {
        try {
            //获得锁10秒超时
            if (item.lock.tryLock(10, TimeUnit.SECONDS)) {
                locks.add(item.lock);
            } else {
                locks.forEach(ReentrantLock::unlock);
                return false;
            }
        } catch (InterruptedException e) {
        }
    }
    //锁全部拿到之后执行扣减库存业务逻辑
    try {
        order.forEach(item -> item.remaining--);
    } finally {
        locks.forEach(ReentrantLock::unlock);
    }
    return true;
}

测试下单操作

模拟在多线程情况下进行100次创建购物车和下单操作,最后通过日志输出成功的下单次数、总剩余的商品个数、100次下单耗时,以及下单完成后的商品库存明细:

@GetMapping("wrong")
public long wrong() {
    long begin = System.currentTimeMillis();
    //并发进行100次下单操作,统计成功次数
    long success = IntStream.rangeClosed(1, 100).parallel()
            .mapToObj(i -> {
                List<Item> cart = createCart();
                return createOrder(cart);
            })
            .filter(result -> result)
            .count();
    log.info("success:{} totalRemaining:{} took:{}ms items:{}",
            success,
            items.entrySet().stream().map(item -> item.getValue().remaining).reduce(0, Integer::sum),
            System.currentTimeMillis() - begin, items);
    return success;
}

运行程序,输出如下日志:

可以看到,100次下单操作成功了65次,10种商品总计10000件,库存总计为9805,消耗了195件符合预期(65次下单成功,每次下单包含三件商品),总耗时50秒。

为什么会这样呢?

使用JDK自带的VisualVM工具来跟踪一下,重新执行方法后不久就可以看到,线程Tab中提示了死锁问题,根据提示点击右侧线程Dump按钮进行线程抓取操作:

查看抓取出的线程栈,在页面中部可以看到如下日志:

显然出现死锁,线程4在等待的一个锁被线程3持有,线程3在等待的另一把锁被线程4持有。

为什么会死锁

购物车添加商品的逻辑,随机添加三种商品,假设一个购物车中的商品是item1和item2,另一个购物车中的商品是item2和item1,一个线程先获取到了item1的锁,同时另一个线程获取到了item2的锁,然后两个线程接下来要分别获取item2和item1的锁,这个时候锁已经被对方获取了,只能相互等待一直到10秒超时。

避免死锁的方案很简单,为购物车中的商品排序,让所有的线程一定先获取item1锁然后获取item2锁,就不会有问题。
所以,我只需要修改一行代码,对createCart获得的购物车按照商品名进行排序即可:

@GetMapping("right")
public long right() {
    ...
.    
    long success = IntStream.rangeClosed(1, 100).parallel()
            .mapToObj(i -> {
                List<Item> cart = createCart().stream()
                        .sorted(Comparator.comparing(Item::getName))
                        .collect(Collectors.toList());
                return createOrder(cart);
            })
            .filter(result -> result)
            .count();
    ...
    return success;
}

测试发现不管执行多少次都是100次成功下单,而且性能相当高,达到了3000以上TPS:

虽然产生了死锁问题,但因为尝试获取锁的操作并不是无限阻塞,所以没有造成永久死锁,之后的改进就是避免循环等待,通过对购物车的商品进行排序来实现有顺序的加锁,避免循环等待。

锁的实现比较复杂的话,要仔细看看加锁和释放是否配对,是否有遗漏释放或重复释放的可能性;并且对于分布式锁要考虑锁自动超时释放了,而业务逻辑却还在进行的情况下,如果别的线线程或进程拿到了相同的锁,可能会导致重复执行。

如果你的业务代码涉及复杂的锁操作,强烈建议Mock相关外部接口或数据库操作后对应用代码进行压测,通过压测排除锁误用带来的性能问题和死锁问题。

【版权声明】本文为华为云社区用户原创内容,转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息, 否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。