锁的使用与死锁的避免

Posted by Gevin on Category: 开发 Tags: 并发

enter image description here

我在写《并发场景下数据写入功能的实现》时提过,在并发场景下,如果存在数据竞争,则需要用锁来保证线程安全。锁会增加编码的复杂度,也会降低代码的执行效率,还潜在死锁、活锁等隐患。活锁,通常加个随机的等待时间,做几次重试就可以避免,故本对“锁的使用”和“死锁的避免”做近一步说明。

1. 锁的使用

锁的使用套路是:

  1. 在访问共享资源之前,先获取锁
  2. 如果获取锁成功,则访问共享资源
  3. 访问结束,释放锁,以便其他线程继续访问共享资源

对代码上锁,可以使用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();
  }
}

再次强调,锁的使用是有代价的:

  1. 加锁和解锁过程,需要CPU时间,有性能损耗;锁的使用可能会带来线程等待,降低程序的执行性能
  2. 锁的使用不当,会造成死锁,且调试不便。

故锁的使用,一定要注意锁的释放,且只有在并发环境中,共享资源不支持并发访问,或者说并发访问共享资源会导致系统错误的情况下,才需要使用锁

具体来说,锁的使用,要注意以下几点:

(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的线程也会进入等待,而且两个线程各自持有的锁在等待时也不会释放,从而产生死锁,两个线程会一直等待下去。

参考这个死锁的分析,通常避免死锁有以下几种套路:

  1. 注意加锁/解锁顺序
  2. 加锁超时释放
  3. 加锁中断释放
  4. 多把锁转换为一把锁进行加锁

本文以“注意加锁顺序”为例,解决死锁问题:

如上面死锁的分析,方法中,都是先对自己上锁,然后对target上锁,即account1 先对account1 上锁,然后在对account2 上锁,而account2 是先对account2上锁,再对account1上锁,这造成了循环等待,从而死锁;若两个对象上锁顺序一致,如都先对account1 上锁,再对account2 上锁,就对循环解套了,可以避免死锁。

所以这里,我们对Account类做个改造,加一个自增的id字段,上锁统一按id从小到大的顺序上锁,就可以避免死锁了:

public void transferSafeWithLockInOrder(Account target, int amount) {
        Account left = this.id < target.id ? this : target;
        Account right = this.id < target.id ? target : this;
        synchronized (left.dummyLock) {
            synchronized (right.dummyLock) {
                transfer(target, amount);
            }
        }
    }

考虑到本文篇幅,持有多把锁时,如何避免死锁并未进一步展开,有兴趣的同学可以查看实战:并发转账业务中避免死锁的各种方法

What's More

本文同步发表于我的微信公众号,欢迎关注。


注:转载本文,请与Gevin联系




如果您觉得Gevin的文章有价值,就请Gevin喝杯茶吧!

|

欢迎关注我的微信公众账号



Comments