0 Bug案例
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这种比较操作在字节码层面是三步,非原子:
- 加载a
- 加载b
- 比较
应该为add、compare都加锁,确保add执行时,compare无法读取a和b:
public synchronized void add()
public synchronized void compare()
所以,使用锁一定要梳理清楚线程、业务逻辑和锁三者关系。
2 锁和被保护的对象是否同一层面?
2.1 案例
累加counter:
测试:
因为传参运行100万次,所以执行后应输出100万,但:
why?在非静态的wrong方法上加锁,只能确保多线程无法执行同一实例的wrong,无法保证不执行不同实例的wrong。静态counter在多实例是共享的,所以会出现线程安全问题。
解决方案
在类中定义一个Object类型的静态字段,在操作counter之前对该字段加锁。
就这?直接把wrong定义为静态不就行?锁不就是类级别?是可以,但不可能为解决线程安全改变代码结构,随便把实例方法改为static方法。
3 加锁前,考虑锁粒度和业务场景
方法上加synchronized
加锁是简单,但别滥用:
- 没必要
绝大多数业务代码是MVC三层架构,数据经过无状态的Controller=>Service=>Repository=>DB
没必要使用synchronized
保护啥数据。 - 大概率降低性能
使用Spring时,默认Controller、Service、Repository都是单例,加synchronized
会导致整个程序几乎只能支持单线程,造成极大性能问题。即使确实有一些共享资源需要保护,也要尽可能减小锁粒度。像concurrentHashMap 。
3.1 案例
业务代码有个ArrayList会被多线程操作而需保护,但又有段比较耗时的不涉及线程安全的操作,如何加锁?推荐只在操作ArrayList时给这ArrayList加锁。
细化锁后,性能还无法满足,就要考虑另一个维度的粒度问题:区分读写场景以及资源的访问冲突,考虑
4 悲观锁 V.S 乐观锁
一般业务代码很少需要进一步考虑这两种更细粒度的锁,自己结合业务的性能需求考虑是否要继续优化:
- 读写差异明显场景,考虑
ReentrantReadWriteLock
读写锁 - 若JDK版本>8、共享资源的冲突概率也没那么大,考虑
StampedLock
乐观读 - JDK的
ReentrantLock
、ReentrantReadWriteLock
都提供了公平锁版本,在没有明确需求情况下不要轻易开启公平锁,在任务很轻情况下开启公平锁可能会让性能下降百倍
5 死锁
锁粒度,够用就好,即程序逻辑中有时存在一些细粒度锁。但一个业务逻辑若涉及多个锁,易死锁。
5.1 案例
下单流程需锁定订单中多个商品的库存,拿到所有商品的锁后再进行下单扣减库存,全部操作完成后,释放所有锁。
上线后发现,下单失败概率高,失败后用户需重新下单。
事故原因
扣减库存的顺序不同,导致并发下多个线程可能相互持有部分商品的锁,又等待其他线程释放另一部分商品的锁,于是死锁。
核心业务代码
先定义一个商品类型,包含:
- 商品名
- 库存剩余
- 商品的库存锁
三个属性,每种商品默认库存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持有。
5.2 为何死锁
购物车添加商品的逻辑,随机添加三种商品,假设一个购物车中的商品是item1和item2,另一个购物车中的商品是item2和item1,一个线程先获取到了item1的锁,同时另一个线程获取到了item2的锁,然后两个线程接下来要分别获取item2和item1的锁,这个时候锁已经被对方获取了,只能相互等待一直到10s超时。
5.3 避免死锁
为购物车中的商品排序,让所有线程一定先获取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相关外部接口或数据库操作后对应用代码进行压测,通过压测排除锁误用带来的性能问题和死锁问题。