
编辑
添加图片注释,不超过 140 字(可选)
我在写 《并发场景下数据写入功能的实现》 时提过,在并发场景下,如果存在数据竞争,则需要用锁来保证线程安全。锁会增加编码的复杂度,也会降低代码的执行效率,还潜在死锁、活锁等隐患。活锁,通常加个随机的等待时间,做几次重试就可以避免,故本对“锁的使用”和“死锁的避免”做近一步说明。 1. 锁的使用 锁的使用套路是:
-
在访问共享资源之前,先获取锁
-
如果获取锁成功,则访问共享资源
-
访问结束,释放锁,以便其他线程继续访问共享资源
对代码上锁,可以使用java 关键字 synchronized 或java并发包中的 Lock ,如:
private Object lock = new Object(); public void visitShareResWithLock() { synchronized (lock) { // 在这里安全的访问共享资源 } }
private Lock lock = new ReentrantLock(); public void visitShareResWithLock() { lock.lock(); try { // 在这里安全的访问共享资源 } finally { lock.unlock(); } }
再次强调,锁的使用是有代价的:
-
加锁和解锁过程,需要CPU时间,有性能损耗;锁的使用可能会带来线程等待,降低程序的执行性能
-
锁的使用不当,会造成死锁,且调试不便。
故锁的使用,一定要注意 锁的释放,且只有在并发环境***享资源不支持并发访问,或者说并发访问共享资源会导致系统错误的情况下,才需要使用锁 。
具体来说,锁的使用,要注意以下几点:
(1) 一定要 unlock
synchronized 关键字,会对代码块加锁,代码块执行结束,锁自动释放,比较方便,性能不比并发包中的Lock差,推荐使用。 使用并发包中的Lock时,需要显式调用lock()和unlock()语句,要注意使用try{} finally 方式,把unlock()放到finally里,避免异常时无法解锁的情况。
另外,像Python等语言,有with lock 语法,简化锁的try{} finally写法,java中默认不支持,如果必要,可以自己封装实现
(2) 注意锁的颗粒度
锁,会让代码从可并行访问,转为串行访问,在多核场景下,降低了代码的并行执行效率,影响系统性能。因此,锁的颗粒度越小越好,以尽量减小代码执行的串行度。
(3) 可重入锁/不可重入锁
可重入锁,指一个线程,可以多次获取同一把锁;而不可重入锁,指即使是同一个线程,当获取了一把锁后,如果想再次获取这把锁,也会失败。 如果上锁的代码块中,存在递归,或者多次上锁的逻辑,一定要确认这段代码中用的锁,是不是可重入的,如果不是,会产生死锁
Java中常用的 synchronized , ReentrantLock , ReentrantReadWriteLock 等,都是可重入锁,比读写锁性能更好的 StampedLock ,是不可重入锁。
(4) 公平锁/非公平锁
公平锁,指多个线程按照申请锁的顺序去获得锁,所有线程都在队列里排队,这样就保证了队列中的第一个先得到锁。 公平锁的优点是,所有的线程能依次得到资源,不会饿死在队列中;缺点是,会增加一个维护排队队列的开销
非公平锁,指多个线程不按照申请锁的顺序去获得锁,而是同时抢锁,抢到则线程继续往下执行,抢不到则等待,下次被唤醒时再次抢锁 非公平锁的优点是,整体开销比公平锁要低一点,缺点是,可能某个线程长时间获取不到锁。
实际应用中,如果业务场景不要求严格的公平,通常使用非公平锁就够了。 2. 死锁的避免 2.1 不用上锁 如果没有锁,则不用担心死锁问题。上锁是因为存在数据竞争,如果能够通过一些变通方式避免数据竞争,则不需要考虑锁的使用及死锁问题。如之前文章中提到的并发场景下写入流水码的业务,如果通过读取数据库中最新流水码数据来决定下一个流水码的大小,会存在对数据范围的竞争,需要上锁实现,而如果把流水码的计算转移到单线程的redis中去,则避免了数据竞争,这样就不需要代码中进行上锁。 2.2 加锁解锁放在同一方法中,注意确保锁的释放 依然是下面这段代码,再次强调,若使用lock(),则一定要在finally里进行unlock(),确保代码正常执行和异常退出时,都能把锁释放掉。
private Lock lock = new ReentrantLock(); public void visitShareResWithLock() { lock.lock(); try { // 在这里安全的访问共享资源 } finally { lock.unlock(); } } 2.3 注意一把锁时的死锁 下面这段代码,只用到一个锁,会产生死锁么?
public void visitShareResWithLock() { lock.lock(); // 获取锁 try { lock.lock(); // 再次获取锁,会导致死锁吗? } finally { lock.unlock(); }
上文对此已做过分析,是否会死锁,取决于代码中的锁是不是可重入锁;是,则不会死锁,否,则代码会卡在第二个上锁语句上,等待锁的释放,一直等下去。
再次说明,一段代码中,若只上锁一次,或多次上锁间没有嵌套,不会产生死锁;若多次嵌套上锁(如递归,或调用其他上锁的方法等),则要注意锁是不是可重入锁,避免死锁的发生。 2.4 持有多把锁时,格外注意锁的持有和释放 当业务逻辑需要对多个资源上锁时,是最复杂也最容易出现死锁的,要格外注意,同时尽量避免持有为多个资源上锁的业务。
本节以“转账业务”为依托,说明持有多把锁时,如何避免死锁。
public class Account { private final long id; private int balance; private final Object dummyLock = new Object(); public Account(int balance) { this.balance = balance; } public int getBalance() { return this.balance; } public void transfer(Account target, int amount){ if (this.balance > amount) { this.balance -= amount; target.balance += amount; } }
如上面代码,若两个 Account 对象(account1, account2)进行转账,调用account1.transfer(account2, 100)方法可以实现。但在并发场景下,若同时存在多笔转账交易,由于数据竞争,需要上锁来保证线程安全。考虑到锁的颗粒度,我们可以最好把锁加在对象上,而不是加在类上,由于account1和account2两个对象都存在数据竞争,所以两个对象都要加锁,故上锁后的代码如下:
public void transferWithDeadLock(Account target, int amount) { synchronized (this.dummyLock) { synchronized (target.dummyLock) { transfer(target, amount); } } }
这段代码是存在“死锁”隐患的。若并发进行两个账号的互相转账,即并发调用account1.transfer(account2, 100)和account2.transfer(account1, 100),可能发生的情况是,account1锁住account1.dummyLock时,account2锁住了account2.dummyLock,此时,account1 继续往下执行,尝试对 account2.dummyLock 加锁时,由于 account2.dummyLock 已经被account2加锁了,account1的线程会进入等待,同理,account2的线程也会进入等待,而且两个线程各自持有的锁在等待时也不会释放,从而产生死锁,两个线程会一直等待下去。
参考这个死锁的分析,通常避免死锁有以下几种套路:
-
注意加锁/解锁顺序
-
加锁超时释放
-
加锁中断释放
-
多把锁转换为一把锁进行加锁
本文以“注意加锁顺序”为例,解决死锁问题:
如上面死锁的分析,方法中,都是先对自己上锁,然后对target上锁,即account1 先对account1 上锁,然后在对account2 上锁,而account2 是先对account2上锁,再对account1上锁,这造成了循环等待,从而死锁;若两个对象上锁顺序一致,如都先对account1 上锁,再对account2 上锁,就对循环解套了,可以避免死锁。
所以这里,我们对Account类做个改造,加一个自增的id字段,上锁统一按id从小到大的顺序上锁,就可以避免死锁了:
public void transferSafeWithLockInOrder(Account target, int amount) { Account left = <a href="http://this.id" rel="nofollow">this.id</a> < <a href="http://target.id" rel="nofollow">target.id</a> ? this : target; Account right = <a href="http://this.id" rel="nofollow">this.id</a> < <a href="http://target.id" rel="nofollow">target.id</a> ? target : this; synchronized (left.dummyLock) { synchronized (right.dummyLock) { transfer(target, amount); } } }