【java基础】Java开发中使用锁的常见错误

本文通过具体案例探讨了并发编程中的锁使用误区,包括线程安全问题、锁与被保护对象的关系及死锁问题,并提供了相应的解决方案。

若你发现博客内容有误,请及时在评论中指出

1. 理解的去加锁

  我们直接看一个案例,现在有一个 add 方法需要对 a、b 两值进行 1 万次的累加,然后有一个 compare 方法对两值进行判断,如果出现 a < b 的情况就打印两值,代码如下:

@Slf4j
public class Interesting {

    volatile int a = 1;
    volatile int b = 2;

    public void add() {
        log.info("add start");
        for (int i = 0; i < 10000; i++) {
            a++;
            b++;
        }
    }

    public void compare() {
        log.info("compare start");
        for (int i = 0; i < 10000; i++) {
            if (a < b) {
                log.info("a:{},b:{},{}", a, b, a > b);
            }
        }
        log.info("compare done");
    }

    public static void main(String[] args) {
        Interesting interesting = new Interesting();
        new Thread(() -> interesting.add()).start();
        new Thread(() -> interesting.compare()).start();
    }
    
}

  你如何理解这段代码?尝试运行代码,你就可以发现:
在这里插入图片描述

  compare 方法的输出,在 if 条件成立的情况下, a > b 的判断竟然也成立了。你可能认为这是线程安全问题, add 方法同时操作了两个数据,但是没有加锁,这样的操作不是原子性的,只要给 add 方法加锁就可以了。
  实际上只给 add 方法加锁也是无济于事的。
  **这个案例中的 add 方法始终只有一个线程在操作,显然只为 add 方法加锁是没用的。**之所以出现这个问题是因为代码在执行这段逻辑时 cpu 是混合执行的,而这些操作不是原子性的, 一个简单a < b操作在数据底层是先加载 a,再加载 b, 最后再进行比较的,我们的代码只有一行,但是操作不是原子性的,所以我们还需要给 compare 方法进行加锁。
  所以,使用锁解决问题之前一定要理清楚,我们要保护的是什么逻辑,多线程执行的情况又是怎样的。

2. 理解锁是否与被保护的对象在同一层面

  我们知道静态字段属于类,只有类级别的锁才能够锁住;非静态字段则属于实例,需要实例级别的锁锁住。我们现在先来看这段代码有什么问题:在类 Data 中定义了一个静态的 int 字段 counter 和一个非静态的 wrong 方法,做 counter 字段的累加操作。

class Data {
    @Getter
    private static int counter = 0;
    
    public static int reset() {
        counter = 0;
        return counter;
    }

    public synchronized void wrong() {
        counter++;
    }
}

public class Controller {

    @GetMapping("wrong")
    public int wrong(@RequestParam(value = "count", defaultValue = "1000000") int count) {
        Data.reset();
        //多线程循环一定次数调用Data类不同实例的wrong方法
        IntStream.rangeClosed(1, count).parallel().forEach(i -> new Data().wrong());
        return Data.getCounter();
    }

}

  最后我们得到的结果是:
在这里插入图片描述
  因为默认运行 100 万次,所以执行后应该输出 100 万,但页面输出的是 266972。同学们看到这里可以先停一下思考以下这是为什么?
  上面提过静态字段是需要类级别的锁在锁住,在非静态的 wrong 方法上加锁,只能够保证该多个线程无法执行同一个实例的 wrong 方法,不能保证不同实例之间执行 wrong 方法,静态字段是对象间共享的,而我们的程序运行在 tomcat 这样的线程池中,造成这样的情况就是难免的,我推荐给出的解决方案如下:


class Data {
    @Getter
    private static int counter = 0;
    private static Object locker = new Object();

    public void right() {
        synchronized (locker) {
            counter++;
        }
    }
}

  将 right 方法设置为静态方法确实能够解决问题,但是如果我们是在接手前任代码的基础上我的建议还是在不直接改变代码结构的基础上做修改。 另外不光是需要辨别被保护对象,还要考虑锁的粒度(指不要随意就在方法上加 synchronized)、锁的读写场景,以及悲观优先还是乐观优先,尽可能针对明确场景精细化加锁方案。

避免死锁

  我在群内看到这样一个案例:有个商城的下单操作需要锁定订单中多个商品的库存,拿到所有商品的锁之后进行下单扣减库存操作,全部操作完成之后释放所有的锁。代码上线后发现,下单失败概率很高,失败后需要用户重新下单,极大影响了用户体验,还影响到了销量。
  让我们来简单还原一下当时的场景:

// 定义一个商品类型,包含商品名、库存剩余和商品的库存锁三个属性
// 每一种商品默认库存 1000 个;然后,初始化 10 个这样的商品对象来模拟商品清单
@Data
@RequiredArgsConstructor
static class Item {
    final String name; //商品名
    int remaining = 1000; //库存剩余
    @ToString.Exclude //ToString不包含这个字段 
    ReentrantLock lock = new ReentrantLock();
}

  再模拟一下在购物车进行商品选购:

private List<Item> createCart() {
    return IntStream.rangeClosed(1, 3)
            .mapToObj(i -> "item" + ThreadLocalRandom.current().nextInt(items.size()))
            .map(name -> items.get(name)).collect(Collectors.toList());
}

  下单代码如下:先声明一个 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;
}

  最后进行测试:


@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;
}

  那么同学们能猜出问题出现在哪里吗?是顺序,两个不同的用户都在购物车内下单商品1、商品2,但是由于下单的顺序不同,导致无法互相获取到锁,虽然有 10 秒超时的设定,但是这段时间就可能造成用户的耐心耗尽,草草关掉界面去另一款 APP 的购物下单。
  所以解决方案也很简单,我们只要每次下单的时候都对商品进行排序就可以:


@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;
}

  之后无论我们怎么测试都可以畅快的下单。
  碰到这种业务中有复杂的使用锁的场景,要仔细看看加锁和释放是否配对,是否有遗漏释放或重复释放的可能性;并且对于分布式锁要考虑锁自动超时释放了,而业务逻辑却还在进行的情况下,如果别的线线程或进程拿到了相同的锁,可能会导致重复执行。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值