Java常见编程错误:锁

2021-02-07 14:15

阅读:386

标签:共享资源   adl   unlock   总结   void   val   readwrite   art   object   

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

场景:

在?个类?有两个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  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?法在判断ab也成?:

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作在字节码层?是加载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 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 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 order) {
        //存放所有获得的锁
        List 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 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 cart = createCart().stream()
                            .sorted(Comparator.comparing(Item::getName))
                            .collect(Collectors.toList());
                    return createOrder(cart);
                })
                .filter(result -> result)
                .count();

 

总结:

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

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

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

Java常见编程错误:锁

标签:共享资源   adl   unlock   总结   void   val   readwrite   art   object   

原文地址:https://www.cnblogs.com/liekkas01/p/12775880.html


评论


亲,登录后才可以留言!