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