鱼友3172
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; } } } } } 需要注意的是,我们给出两个方法,需要采取哪个方法,也是按照成本来选择的。
0个评论
点击登录,快来和大家讨论吧~
表情
图片
暂无评论
下载 APP