Bootstrap

Java并发编程系列——锁顺序

前篇写到了锁,那这篇必须得提下锁的顺序。

这里也多写一句,虽然示例都是用Java写,不同语言有不同的实现,但在基本原理、设计与用法上大体上是相同的。

在并发处理过程中,我们最主要要避免的就是死锁的产生,一旦产生死锁,轻则业务逻辑无法执行,重则可能导致服务崩溃。而避免死锁,很重要的一点是保证锁的正确顺序。

对于静态锁,也就是锁的顺序不随业务逻辑而有顺序变化,对于这样的锁,我们在代码层面保证所有使用锁的地方按顺序调用即可。比如,我们有两个锁,在不同的方法里执行时需要同时获取两把锁,要在每个方法中使用相同的顺序获取锁,通过如下伪码示例:

Lock lock1;
Lock lock2;
function1() {
  lock1.lock() {
     try {
       doSomething();
       lock2.lock();
       try {
       } finally {
         lock2.unlock();
       }
     } finally {
       lock1.unlock();
     }
  }
}

function2() {
  lock1.lock() {
     try {
       doSomething();
       lock2.lock();
       try {
       } finally {
         lock2.unlock();
       }
     } finally {
       lock1.unlock();
     }
  }
}

使用synchronized也是一样。

对于动态变化的锁,则不能简单的按上面的方法处理,比如常用的例子,一个人向另一个人转账,当我们定义了转账方法后,虽然参数都是从一个账户转向另一个账户,但实际运行中,A转向B,B转向A是可能同时进行的,这时如果只是按照业务上所理解的顺序,先锁转出账户,再锁转入账户,则会发生死锁。还是通过一个例子才理解。

transfer(from, to, amount) {
  synchronized(from) {
    synchronized(to) {
      from.amount -= amount;
      to.amount += amount;
    }
  }
}

从这个例子可以看出,虽然我们从逻辑上讲都是先锁from,后锁to,但是实际上from和to都是不固定的,也就导致了这样锁会存在死锁的问题。因此这里的锁就存在一个排序的问题,保证始终按一个规定的排序来执行。(当然这里也可以直接使用类锁,synchronized(xxx.class),这样所有这个类的方法执行到这里都会被锁,但明显效率是有问题的,采用这种方式也就意味着其他与此无关的账户间的转账行为也被阻塞,所以从效率出发并不希望这么处理。)

如果怀疑有死锁发生,如何查看,这里介绍两个命令,这两个命令都在Java的bin下(从哪个版本开始有没有考证),先构造一个死锁(构造死锁的代码就不再展示了),先使用命令

jps -m

可以看到如下输出

28837 ShowWrongLockSequence
28838 Launcher /Applications/IntelliJ ...
29099 Jps -m

28837是我们当前运行的程序,然后使用命令

jstack 28837

将会看到输出中有关死锁的信息如下

...
Found one Java-level deadlock:
=============================
"Thread-0":
  waiting to lock monitor 0x000000010b522200 (object 0x0000000787e58768, a com.sthlike.java.review.thread.lock.sequence.WrongBankAccount),
  which is held by "Thread-1"
"Thread-1":
  waiting to lock monitor 0x000000010b525c00 (object 0x0000000787e58720, a com.sthlike.java.review.thread.lock.sequence.WrongBankAccount),
  which is held by "Thread-0"

Java stack information for the threads listed above:
===================================================
"Thread-0":
        at com.sthlike.java.review.thread.lock.sequence.WrongBankAccount.transfer(WrongBankAccount.java:28)
        - waiting to lock <0x0000000787e58768> (a com.sthlike.java.review.thread.lock.sequence.WrongBankAccount)
        - locked <0x0000000787e58720> (a com.sthlike.java.review.thread.lock.sequence.WrongBankAccount)
        at com.sthlike.java.review.thread.lock.sequence.ShowWrongLockSequence.lambda$main$0(ShowWrongLockSequence.java:12)
        at com.sthlike.java.review.thread.lock.sequence.ShowWrongLockSequence$$Lambda$14/0x0000000800b4dc40.run(Unknown Source)
        at java.lang.Thread.run(java.base@13/Thread.java:830)
"Thread-1":
        at com.sthlike.java.review.thread.lock.sequence.WrongBankAccount.transfer(WrongBankAccount.java:28)
        - waiting to lock <0x0000000787e58720> (a com.sthlike.java.review.thread.lock.sequence.WrongBankAccount)
        - locked <0x0000000787e58768> (a com.sthlike.java.review.thread.lock.sequence.WrongBankAccount)
        at com.sthlike.java.review.thread.lock.sequence.ShowWrongLockSequence.lambda$main$1(ShowWrongLockSequence.java:15)
        at com.sthlike.java.review.thread.lock.sequence.ShowWrongLockSequence$$Lambda$16/0x0000000800b4e040.run(Unknown Source)
        at java.lang.Thread.run(java.base@13/Thread.java:830)

Found 1 deadlock.

上面省略输出中的一些内容。从中可以看到发生了死锁,并且在输出的堆栈信息中可以看到发生死锁的具体位置。

对于这类问题,我们通常有两种方式来解决,第一种对要锁的对象按一定规则排序,按排序后的顺序来依次锁对象,这里有一个特殊的地方,就是如果两个对象根据我们的算法其排序特征相等,无法确定先后,那这时可以引入一个第三方对象,专门用于这种情况下的锁定,这个对象需要是一个类级别的变量。这里的排序可以使用什么方法呢。最简单的使用对象的HashCode,但是HashCode方法经常会被覆写,我们可以使用对象的原始HashCode,这是系统级的,不随我们的业务覆写而改变。如果对象本身有类似id这样的唯一标识,也可以用这个标识来判别,如果能保证一定有值且不会重复,逻辑上还可省去排序相等时的判断。如下我们用一段代码来示例,假设我们定义了一个BankAccount类,其中有一个转账方法transfer,我们来看下其实现:

public class BankAccount {
    private static final Object tieLock = new Object(); //排序相等时用来同步的锁对象
    private final String id;
    private volatile double amount;

    public BankAccount(String id, double amount) {
        this.id = id;
        this.amount = amount;
    }

    /**
     * 根据对象原始的HashCode来判断顺序后进行锁定,当Hash冲突时借助一个联合锁来保证顺序。
     * 如果对象本身有唯一的标识,如ID,也可用ID进行顺序判断,确保相等情况可免去联合锁。
     *
     * @param to
     * @param amount
     */
    public void transfer(BankAccount to, double amount) {
        int fromHash = System.identityHashCode(this);
        int toHash = System.identityHashCode(to);
        if (fromHash < toHash) {
            synchronized (this) {
                synchronized (to) {
                    this.amount -= amount;
                    to.amount += amount;
                }
            }
        } else if (fromHash > toHash) {
            synchronized (to) {
                synchronized (this) {
                    this.amount -= amount;
                    to.amount += amount;
                }
            }
        } else {
            synchronized (tieLock) {
                synchronized (this) {
                    synchronized (to) {
                        this.amount -= amount;
                        to.amount += amount;
                    }
                }
            }
        }
    }

    public double getAmount() {
        return this.amount;
    }
}

另一种方式,我们可以让每个对象都持有一把锁,采用类似CAS的实现方式,在循环中不断的尝试获取锁,这时我们将不管锁的动态顺序,这种方式效率上总体不如上一种方法效率高。这种实现方式中要特别注意,在要进入下一次循环时最好做一个随机的短时间的休眠,不然很容易出现“活锁”的状态(锁之间互相谦让,拿不到另一把锁就把自己也释放,结果造成长时间无法同时获取锁)。代码示例如下:

public class AnotherBankAccount {
    private final Lock lock = new ReentrantLock();
    private final String id;
    private volatile double amount;

    public AnotherBankAccount(String id, double amount) {
        this.id = id;
        this.amount = amount;
    }

    public Lock getLock() {
        return this.lock;
    }

    /**
     * 使用类似自旋一样的方式处理,每个对象定义一个锁,用对象的锁本身进行tryLock,
     * 成功即可继续,不成功继续尝试,但需要休眠一个随机数,避免活锁的产生。
     *
     * @param to
     * @param amount
     */
    public void transfer(AnotherBankAccount to, double amount) {
        Random random = new Random();
        while (true) {
            if (this.getLock().tryLock()) {
                try {
                    if (to.getLock().tryLock()) {
                        try {
                            this.amount -= amount;
                            to.amount += amount;
                            break;
                        } finally {
                            to.getLock().unlock();
                        }
                    }
                } finally {
                    this.getLock().unlock();
                }
            }
            try {
                Thread.sleep(random.nextInt(10));
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }

    public double getAmount() {
        return this.amount;
    }
}

到这里简单总结几条用锁的注意事项:

根据需要使用不同的锁,比如读多写少可以用读写锁来提升并发性能。

尽可能减小锁的范围,但要注意避免频繁切换锁,切换锁本身也是很耗时的,所以在减小锁的范围的同时也注意锁粗化的概念。

大部分情况可能使用synchronized就可以了,不必过早优化性能,性能优化还是要基于测试来定。

注意锁的顺序,尤其是避免动态死锁的产生。

考虑需要的是对象锁还是类锁。

JDK是否提供了合适的方式,有则不必自己实现。

本系列其他文章: