Java常见编程错误:锁

分析解决线程安全问题的锁在使用中的问题。

场景:

在⼀个类⾥有两个int类型的字段a和b,有⼀个add⽅法循环1万次对a和b进 ⾏++操作,有另⼀个compare⽅法,同样循环1万次判断a是否⼩于b,条件成⽴就打印a和b的值,并判断 a>b是否成⽴。

代码如下:

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

    int loop=10000000;

    public void add() {
        System.out.println("add start");
        for (int i = 0; i < loop; i++) {
            a++;
            b++;
        }
        System.out.println("add done");
    }

    public void compare() {
        System.out.println("compare start");
        for (int i = 0; i < loop; i++) {
            //a始终等于b吗?
            if (a < b) {
                System.out.println(a + "," + b + "," + (a > b));
                //最后的a>b应该始终是false吗?
            }
        }

        System.out.println("compare done");
    }

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

按道理,a和b同样进⾏累加操作,应该始终相等,compare中的第⼀次判断应该始终不会成⽴,不会输出任何⽇志。但,执⾏代码后发现不但输出了⽇志,⽽且更诡异的是,compare⽅法在判断a<b成⽴的情况下还输出了a>b也成⽴:

9899491,9899492,false
9899949,9899950,true
9900959,9900959,false
9901787,9901786,true

 

解决方案1:

操作两个字段a和b,有线程安全问题,为add⽅法加上锁,确保a和b的++是原⼦性的,就不会错乱 了。

public synchronized void add()

加锁后问题并没有解决。

来仔细想⼀下,为什么锁可以解决线程安全问题呢。因为只有⼀个线程可以拿到锁,所以加锁后的代码 中的资源操作是线程安全的。

但是,这个案例中的add⽅法始终只有⼀个线程在操作,显然只为add⽅法加锁是没⽤的

之所以出现这种错乱,是因为两个线程是交错执⾏add和compare⽅法中的业务逻辑,⽽且这些业务逻辑不
是原⼦性的:a++和b++操作中可以穿插在compare⽅法的⽐较代码中;更需要注意的是,a<b这种⽐较操
作在字节码层⾯是加载a、加载b和⽐较三步,代码虽然是⼀⾏但也不是原⼦性的。

解决方案2:

正确的做法应该是,为add和compare都加上⽅法锁,确保add⽅法执⾏时,compare⽆法读取a和 b:

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

所以,使⽤锁解决问题之前⼀定要理清楚,我们要保护的是什么逻辑,多线程执⾏的情况⼜是怎样的。

 

加锁前要清楚锁和被保护的对象是不是⼀个层⾯的

除了没有分析清线程、业务逻辑和锁三者之间的关系随意添加⽆效的⽅法锁外,还有⼀种⽐较常⻅的错误是,没有理清楚锁和要保护的对象是否是⼀个层⾯的。

静态字段属于类,类级别的锁才能保护;⽽⾮静态字段属于类实例,实例级别的锁就可以保护。

场景:

在类Data中定义了⼀个静态的int字段counter和⼀个⾮静态的wrong⽅法,实 现counter字段的累加操作。

代码如下:

static int count = 1000000;
    @Getter
    private static int counter = 0;

    public static int reset() {
        counter = 0;
        return counter;
    }

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

    public static void main(String[] args) {
        Data.reset();
        //多线程循环⼀定次数调⽤Data类不同实例的wrong⽅法
        IntStream.rangeClosed(1, count)
                .parallel()
                .forEach(i -> new Data().wrong());

        System.out.println(Data.getCounter());
    }

因为默认运⾏100万次,所以执⾏后应该输出100万,但实际输出的是673767:

问题分析:

在⾮静态的wrong⽅法上加锁,只能确保多个线程⽆法执⾏同⼀个实例的wrong⽅法,却不能保证不会执⾏不同实例的wrong⽅法。

⽽静态的counter在多个实例中共享,所以必然会出现线程安全问题。

解决方案:

同样在类中定义⼀个Object类型的静态字段,在操作counter之前对这个字段加锁。

static Object locker = new Object();

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

 

加锁要考虑锁的粒度和场景问题

在⽅法上加synchronized关键字实现加锁确实简单,也因此曾看到⼀些业务代码中⼏乎所有⽅法都加了synchronized,但这种滥⽤synchronized的做法:

  • ⼀是,没必要。通常情况下60%的业务代码是三层架构,数据经过⽆状态的Controller、Service、 Repository流转到数据库,没必要使⽤synchronized来保护什么数据。
  • ⼆是,可能会极⼤地降低性能。使⽤Spring框架时,默认情况下Controller、Service、Repository是单例 的,加上synchronized会导致整个程序⼏乎就只能⽀持单线程,造成极⼤的性能问题。

即使我们确实有⼀些共享资源需要保护,也要尽可能降低锁的粒度,仅对必要的代码块甚⾄是需要保护的资源本⾝加锁。

场景:

在业务代码中,有⼀个ArrayList因为会被多个线程操作⽽需要保护,⼜有⼀段⽐较耗时的操作(代码中的slow⽅法)不涉及线程安全问题,应该如何加锁呢?

错误的做法是,给整段业务逻辑加锁,把slow⽅法和操作ArrayList的代码同时纳⼊synchronized代码块; 更合适的做法是,把加锁的粒度降到最低,只在操作ArrayList的时候给这个ArrayList加锁。

private List<Integer> data = new ArrayList<>();

    private void slow() {
        try {
            TimeUnit.MICROSECONDS.sleep(10);
        } catch (InterruptedException e) {

        }
    }


    public int wrong() {
        long begin = System.currentTimeMillis();
        IntStream.rangeClosed(1, 1000).parallel()
                .forEach(i -> {
                    //加锁粒度太粗了
                    synchronized (this) {
                        slow();
                        data.add(i);
                    }
                });
        System.out.println("took: " + (System.currentTimeMillis() - begin));
        return data.size();
    }

    public int right() {
        long begin = System.currentTimeMillis();
        IntStream.rangeClosed(1, 1000).parallel().forEach(i -> {
            slow();
            //只对List加锁
            synchronized (data) {
                data.add(i);
            }
        });
        System.out.println("took: " + (System.currentTimeMillis() - begin));
        return data.size();
    }

    public static void main(String[] args) {
        LockTest1 test = new LockTest1();
        new Thread(() -> test.wrong()).start();
        new Thread(() -> test.right()).start();
    }

 

如果精细化考虑了锁应⽤范围后,性能还⽆法满⾜需求的话,就要考虑另⼀个维度的粒度问题了,

即: 区分读写场景以及资源的访问冲突,考虑使⽤悲观⽅式的锁还是乐观⽅式的锁。

⼀般业务代码中,很少需要进⼀步考虑这两种更细粒度的锁,⼤概的结论:

  • 对于读写⽐例差异明显的场景,考虑使⽤ReentrantReadWriteLock细化区分读写锁,来提⾼性能;
  • JDK版本⾼于1.8、共享资源的冲突概率也没那么⼤的话,考虑使⽤StampedLock的乐观读的特 性,进⼀步提⾼性能;
  • JDK⾥ReentrantLock和ReentrantReadWriteLock都提供了公平锁的版本,在没有明确需求的情况下不要 轻易开启公平锁特性,在任务很轻的情况下开启公平锁可能会让性能下降上百倍。

多把锁要⼩⼼死锁问题

锁的粒度够⽤就好,这就意味着我们的程序逻辑中有时会存在⼀些细粒度的锁。但⼀个业务逻 辑如果涉及多把锁,容易产⽣死锁问题。

案例:

下单操作需要锁定订单中多个商品的库存,拿到所有商品的锁之后进⾏下单扣 减库存操作,全部操作完成之后释放所有的锁。代码上线后发现,下单失败概率很⾼,失败后需要⽤⼾重新 下单,极⼤影响了⽤⼾体验,还影响到了销量。

经排查发现是死锁引起的问题,背后原因是扣减库存的顺序不同,导致并发的情况下多个线程可能相互持有 部分商品的锁,⼜等待其他线程释放另⼀部分商品的锁,于是出现了死锁问题。

代码示例:

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

@Data
@RequiredArgsConstructor
public class Item {
    final String name; //商品名
    int remaining = 1000; //库存剩余
    //ToString不包含这个字段
    @ToString.Exclude
    ReentrantLock lock = new ReentrantLock();
}

写⼀个⽅法模拟在购物⻋进⾏商品选购,每次从商品清单(items字段)中随机选购三个商品(为了逻辑简单,不考虑每次选购多个同类商品的逻辑,购物⻋中不体现商品数量)

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

 

写⼀段代码测试这个下单操作。模拟在多线程情况下进⾏100次创建购物⻋和下单操作,最后通过⽇志 输出成功的下单次数、总剩余的商品个数、100次下单耗时,以及下单完成后的商品库存明细:

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

使⽤JDK⾃带的VisualVM⼯具来跟踪⼀下,重新执⾏⽅法后不久就可以看到,线程Tab中提⽰了死锁问题

分析:

购物⻋添加商品的逻辑,随机添加了三种商品,假设⼀个购物⻋中的商品是item1和 item2,另⼀个购物⻋中的商品是item2和item1,

⼀个线程先获取到了item1的锁,同时另⼀个线程获取到 了item2的锁,然后两个线程接下来要分别获取item2和item1的锁,这个时候锁已经被对⽅获取了,只能相互等待⼀直到10秒超时。

解决方案:

为购物⻋中的商品排⼀下序,让所有的线程⼀定是先获取item1的锁然后获 取item2的锁,就不会有问题了。所以,我只需要修改⼀⾏代码,对createCart获得的购物⻋按照商品名进⾏排序即可:

 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();

 

总结:

  • 使⽤synchronized加锁虽然简单,但我们⾸先要弄清楚共享资源是类还是实例级别的、会被哪些线 程操作,synchronized关联的锁对象或⽅法⼜是什么范围的。
  • 加锁尽可能要考虑粒度和场景,锁保护的代码意味着⽆法进⾏多线程操作。对于Web类型的天然多线 程项⽬,对⽅法进⾏⼤范围加锁会显著降级并发能⼒,要考虑尽可能地只为必要的代码块加锁,降低锁的粒 度;⽽对于要求超⾼性能的业务,还要细化考虑锁的读写场景,以及悲观优先还是乐观优先,尽可能针对明 确场景精细化加锁⽅案,可以在适当的场景下考虑使⽤ReentrantReadWriteLock、StampedLock等⾼级的 锁⼯具类。
  • 业务逻辑中有多把锁时要考虑死锁问题,通常的规避⽅案是,避免⽆限等待和循环等待。

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

如果业务代码涉及复杂的锁操作,应该Mock相关外部接⼝或数 据库操作后对应⽤代码进⾏压测,通过压测排除锁误⽤带来的性能问题和死锁问题。

posted @ 2020-04-25 22:40  liekkas01  阅读(318)  评论(0编辑  收藏  举报