Java后端
·2025-11-18学习笔记
直接先看代码
class Account {
private int balance;
// 转账
synchronized void transfer(
Account target, int amt){
if (this.balance > amt) {
this.balance -= amt;
target.balance += amt;
}
}
}
这段代码是有并发问题的,在多线程的情况下,假设账户A,账户B和账号C都只有两块钱,现在要执行账户A转账1块钱给账户B,账户B转账一块钱给账户C。线程T1执行A转B的操作,读取B的余额是两块钱,但是此时,线程T2也在执行B转C的操作,读取到余额也是两块钱,(虽然方法上加了锁,但是它获取的是this这把锁,账户A执行转账方法和账户B执行转账方法,各自需要获取的锁不是同一把锁,是没有形成互斥的),最后账户B的余额可能是一块钱,也可能是三块钱,但是照常理来说,应该是两块钱的(A转B一块,B又转C一块,相当于没变)。
所以这样子加锁不行,我们得换一种加锁方式。上面两个账户调用方法获取的锁是不同的,所以没有形成互斥,所以我们可以从这方面出发,让账户调用转账方法时,获取到的锁是同一种锁,形成互斥即可。
第一种方式,不获取this锁,而是获取Account.Class锁,使用Account.Class锁给转账方法上锁
第二种方式,在Account类里再加一个Object属性,这个属性就代表着锁,使用Account的构造方法为其赋值,每次创建Account的实例对象时,都传入同一个Object对象,那么每个Account的对象里的Object都是同一个Object对象,使用这个Object属性进行上锁,这样每个Account对象使用转账方式时都需要获取Object锁,形成互斥。
但是我们发现,以上两种方式会使得所有转账方法都变成串行执行,太慢了,我们改进一下。
比如说,账户A要转账给账户B,那我们就用A.this锁和B.this锁来锁住这个转账方法,只用同时获取到A锁和B锁才能执行转账方法,如代码
class Account {
private int balance;
// 转账
void transfer(Account target, int amt){
// 锁定转出账户
synchronized(this) {
// 锁定转入账户
synchronized(target) {
if (this.balance > amt) {
this.balance -= amt;
target.balance += amt;
}
}
}
}
}
这个时候可能就有同学要问了:“哎?前面不是说,受保护的资源与锁的关系应该是N:1吗?为什么这次用了两个锁呢?”。我来解释一下把,这个代码其实和之前的那个代码是不同的。
之前的代码:
class SafeCalc {
static long value = 0L;
synchronized long get() {
return value;
}
synchronized static void addOne() {
value += 1;
}
}
之前的代码有两个锁,分别是this锁和SafeCalc .Class锁,但是两个锁之间的关系是“||”的关系,你拿到了this锁,就可以执行相应的临界区代码,你拿到了SafeCalc .Class锁,也可以执行对应的临界区代码。它们两是各搞各的,并且它们都是使用的同一个资源value,那当然不行。
反观我上面转账的两个A锁和B锁,它们的关系是“&&”的关系,你拿到了A锁还不行,你还得拿到B锁,才能执行临界区代码,你完全可以把A锁和B锁看成一个整体,看成一个锁,就称这个锁为AB锁,到头来它其实还是只算一个锁,和我们所说的“受保护的资源与锁的关系应该是N:1”并不冲突。
但是呢A锁B锁真的是一个整体吗?我会说,不够整体,它还蕴藏着一个巨大的问题。
死锁问题!!!
我们说到,你先获取了A锁,但是不够,你还得获取B锁,但是,你一定拿得到B锁吗?不一定!
假如账户A执行转账操作,A转账给B。账户B也执行转账操作,B转账给A。两者同时执行,账户A先获取到它的this锁,也就是A锁,账户B也先获取到它的this锁,也就是B锁。问题来了,接下来账户A要获取B锁才能执行临界区,而账户B需要获取A锁才能执行临界区,而A锁和B锁又被它们两个各自获取了,形成了互相等待,死锁问题。
那我们就得优化这段代码了,避免死锁问题。如何才能避免死锁问题呢?有一位牛人Coffman总结了发生死锁问题的四个条件,当这四个条件全都满足发生死,就会有死锁问题,这四个条件分别是:
1.互斥
2.占有且等待:占有共享资源X,还在等待着共享资源Y,但是不释放共享资源X
3.不可抢占:占有着共享资源X,其他线程无法抢占资源X
4.循环等待:线程1等待线程2占用的资源,线程2等待线程1占用的资源
我们想要避免死锁问题,就可以从这四个条件下手,只要破坏了其中一个条件即可。
破坏条件一,那是不行的,我们就是为了实现互斥才用的锁,pass掉
破坏条件二:要么就不让它占有X,要么就让它不需要等待Y。显然,后者更加合理,因为我们在实际开发中,难免要获取多个不同的资源。那么如何做到呢?我们可以一次性的获取到资源X和资源Y,要么就同时拿到资源X和资源Y,要么就一个都不拿到。
class Allocator {
// 1. 私有静态 final 实例,类加载时即初始化
private static final Allocator INSTANCE = new Allocator();
// 2. 私有构造函数,防止外部通过 new 创建实例
private Allocator() {
// 可以在这里添加初始化逻辑
System.out.println("Allocator 实例已创建");
}
// 3. 公共静态方法,提供全局访问点
public static Allocator getInstance() {
return INSTANCE;
}
private List<Object> als =
new ArrayList<>();
// 一次性申请所有资源
synchronized boolean apply(
Object from, Object to){
if(als.contains(from) ||
als.contains(to)){
return false;
} else {
als.add(from);
als.add(to);
}
return true;
}
// 归还资源
synchronized void free(
Object from, Object to){
als.remove(from);
als.remove(to);
}
}
class Account {
private final Allocator allocator = Allocator.getInstance();
private int balance;
// 转账
void transfer(Account target, int amt){
// 一次性申请转出账户和转入账户,直到成功
while(!actr.apply(this, target))
;
try{
// 锁定转出账户
synchronized(this){
// 锁定转入账户
synchronized(target){
if (this.balance > amt){
this.balance -= amt;
target.balance += amt;
}
}
}
} finally {
actr.free(this, target)
}
}
}
这段代码比较长,静下心来慢慢看。简单来说就是,每个账户执行转账操作时,都需要去找同一个人去拿到对应的两个锁,如果这个人成功的帮你拿到了这两个锁,那才可以执行临界区。
破坏条件三:要想破坏条件三,那就得允许其他线程抢占资源或者说,允许自主去释放资源,这一点synchronized是做不到的,所以我们后面再讲
破坏条件四:主要是循环两字,如何破坏循序?像是我们上面的例子,账户A先获取A锁再获取B锁,账户B先获取B锁再获取A锁,如果我们换个顺序,账户A和账户B都是先获取A锁再获取B锁,或者都是先获取B锁再获取A锁,那么它们就不会出现循环问题了。
如此,我们就可以尝试去改变获取锁的顺序,来破坏这个条件,比如说,按照账户id的大小去获取锁,在执行账户方法去获取两个锁时,哪个锁对应的id小,就先获取哪个锁。
class Account {
private int id;
private int balance;
// 转账
void transfer(Account target, int amt){
Account left = this ①
Account right = target; ②
if (this.id > target.id) { ③
left = target; ④
right = this; ⑤
} ⑥
// 锁定序号小的账户
synchronized(left){
// 锁定序号大的账户
synchronized(right){
if (this.balance > amt){
this.balance -= amt;
target.balance += amt;
}
}
}
}
}
需要注意的是,我们给出两个方法,需要采取哪个方法,也是按照成本来选择的。
1
0
分享
操作
评论
问答助学
相关内容
0个评论
全部评论
点击登录,快来和大家讨论吧~
表情
图片
暂无评论
