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相关外部接⼝或数 据库操作后对应⽤代码进⾏压测,通过压测排除锁误⽤带来的性能问题和死锁问题。