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