I. 代码加锁
1.加锁前要清楚锁和被保护的对象是不是一个层面的
静态字段属于类,类级别的锁才能保护;而非静态字段属于类实例,实例级别的锁就可以保护。
2.加锁要考虑锁的粒度和场景问题
在方法上加 synchronized 关键字实现加锁确实简单,也因此我曾看到一些业务代码中几乎所有方法都加了 synchronized,但这种滥用 synchronized 的做法:
一是,没必要。通常情况下 60% 的业务代码是三层架构,数据经过无状态的 Controller、Service、Repository 流转到数据库,没必要使用 synchronized 来保护什么数据。
二是,可能会极大地降低性能。使用 Spring 框架时,默认情况下 Controller、Service、Repository 是单例的,加上 synchronized 会导致整个程序几乎就只能支持单线程,造成极大的性能问题。
即使我们确实有一些共享资源需要保护,也要尽可能降低锁的粒度,仅对必要的代码块甚至是需要保护的资源本身加锁。
如果精细化考虑了锁应用范围后,性能还无法满足需求的话,我们就要考虑另一个维度的粒度问题了,即:区分读写场景以及资源的访问冲突,考虑使用悲观方式的锁还是乐观方式的锁。
一般业务代码中,很少需要进一步考虑这两种更细粒度的锁,可以根据自己的需求来考虑是否有必要进一步优化:
对于读写比例差异明显的场景,考虑使用 ReentrantReadWriteLock 细化区分读写锁,来提高性能。
如果你的 JDK 版本高于 1.8、共享资源的冲突概率也没那么大的话,考虑使用 StampedLock 的乐观读的特性,进一步提高性能。
JDK 里 ReentrantLock 和 ReentrantReadWriteLock 都提供了公平锁的版本,在没有明确需求的情况下不要轻易开启公平锁特性,在任务很轻的情况下开启公平锁可能会让性能下降上百倍。
3.多把锁要小心死锁问题
三是,没有充分了解并发工具的特性,还是按照老方式使用新工具导致无法发挥其性能。比如,使用了 ConcurrentHashMap,但没有充分利用其提供的基于 CAS 安全的方法,还是使用锁的方式来实现逻辑。
使用 JDK 自带的 VisualVM 工具来跟踪一下,重新执行方法后不久就可以看到,线程 Tab 中提示了死锁问题,根据提示点击右侧线程 Dump 按钮进行线程抓取操作。
II. 重点回顾
第一,使用 synchronized 加锁虽然简单,但我们首先要弄清楚共享资源是类还是实例级别的、会被哪些线程操作,synchronized 关联的锁对象或方法又是什么范围的。
第二,加锁尽可能要考虑粒度和场景,锁保护的代码意味着无法进行多线程操作。对于 Web 类型的天然多线程项目,对方法进行大范围加锁会显著降级并发能力,要考虑尽可能地只为必要的代码块加锁,降低锁的粒度;而对于要求超高性能的业务,还要细化考虑锁的读写场景,以及悲观优先还是乐观优先,尽可能针对明确场景精细化加锁方案,可以在适当的场景下考虑使用 ReentrantReadWriteLock、StampedLock 等高级的锁工具类。
第三,业务逻辑中有多把锁时要考虑死锁问题,通常的规避方案是,避免无限等待和循环等待。
此外,如果业务逻辑中锁的实现比较复杂的话,要仔细看看加锁和释放是否配对,是否有遗漏释放或重复释放的可能性;并且对于分布式锁要考虑锁自动超时释放了,而业务逻辑却还在进行的情况下,如果别的线线程或进程拿到了相同的锁,可能会导致重复执行。
III.补充知识
1.超时自动释放锁后怎么避免重复逻辑?
a.避免超时,单独开一个线程给锁延长有效期。比如设置锁有效期30s,有个线程每隔10s重新设置下锁的有效期。
b.避免重复,业务上增加一个标记是否被处理的字段。或者开一张新表,保存已经处理过的流水号。
2.并发量没那么大的情况下,下单扣减库存是不是选择使用version乐观锁机制呢,使用乐观锁如果更新失败应该怎么设计重试策略,避免乐观锁异常造成偶尔下单失败的情况呢?
可以使用乐观锁,版本号不对,更新操作会失败,回滚事务重试当前操作即可。建议最多重试两次,再不行直接下单失败,由用户进行重试。