【个人浏览后的大致上的总结:

主要讲的就是锁对于保护 存在不可变性条件的对象状态与存在后验条件的对象状态 以及 无关联关系的对象状态的不同方式。】


上一篇文章中提到,受保护资源和锁之间的关系应该是 N:1 的关系,用一把锁保护多个资源

那么具体该怎样做呢? 这就是这篇文章的主要内容。

当我们要保护多个资源时,首先要区分这些资源是否存在 「关联关系」

保护没有关联关系的多个资源

现实世界中,球场的座位电影院的座位对应两种不同的资源 】是没有**逻辑上的关联关系的,这种场景非常容易解决,球赛有球赛的门票,电影院有电影院的门票,各自进行管理**【 使用不同的锁保护不同的资源 】。对应到编程领域中的概念,也很好解决。

例如:「银行业务」 中有针对 余额 进行取款的操作,也有针对账户密码进行修改的操作。我们可以为 账户余额账户密码这两个**不同的资源分配不同的锁**来解决并发问题。

相关的示例代码如下:

账户类 Account 有两个成员变量,分别是账户余额 balance账户密码 password取款 withdraw()查看余额 getBalance() 操作会访问账户余额 banlance , 我们创建一个 final 对象 balLock 作为锁。而更改密码 updatePassword() 和 查看密码 getPassWorkd() 操作会修改账户密码 passWord,我们创建一个 final 对象 pwLock 作为锁。

不同的资源使用不同的锁进行保护,各自进行管理


public class Account {
    // 保护账余额的锁
    private final Object balanceLock = new Object();

    // 账户余额
    private Integer balance;

    // 保护账户密码的锁
    private final Object passwordLock = new Object();

    // 账户密码
    private String password;

    //取款 该操作需要对账户余额进行操作所以需要使用保护账户余额的锁来对该操作进行同步
    void withdraw(Integer amt) {
        synchronized (balanceLock) {
            if (this.balance > amt) {
                // 修改 balance 变量的值
                this.balance -= amt;
            }
        }
    }

    // 读取余额,为了看到该值最新的状态,也需要使用锁来进行同步。
    Integer getBalance() {
        synchronized (balanceLock) {
            return balance;
        }
    }

    // 更改密码
    void updatePassword(String pw) {
        synchronized (passwordLock) {
            this.password = pw;
        }
    }

    // 查看密码
    String getPassword() {
        synchronized (passwordLock) {
            return this.password;
        }
    }
}

我们也可以使用**一把互斥锁来保护多个资源,例如用 this 这一把锁来管理账户类中的所有资源:余额** 和 密码

这样的话所有示例程序中的代码使用 synchronized 来修饰就可以了。

但是使用一把锁的话,所有方法都会变成「串行」方法,极大的削弱了「可伸缩性」 ,变成了一个单线程的串行形式,而使用两把锁**的话,取款和修改密码这2个操作是可以并行的。

用不同的锁对受保护的资源进行精细化管理,可以提升性能,这种锁也被称为**「细粒度锁」**

保护有关联关系的多个资源

如果**多个资源是有关联关系**的,那么问题就比较复杂。比如银行中的转行操作,账户A 减少100元账户B 增加100元这两个账户的余额就是有关联关系的

对于 「转账」 这类有关联关系的操作,该怎样解决呢?

下面将这个问题用代码描述:

我们声明了一个账户类:Account, 该类有一个成员变量 余额:balance,还有一个用于转账的方法:transfer(),然后怎样保证转账操作 transfer() 没有并发问题呢?

public class Account {
    private int balance;
    // 转账 使用 this,当前类的实例 来保护 this.balance变量 ,但是这个锁无法保护 target.balance,所以存在安全隐患
    synchronized void transfer(Account targer, int amt) {
        if (this.balance > amt) {
            this.balance -= amt;
            targer.balance += amt;
        }
    }
}

也许你的第一直觉想到的是这样的方法,用锁来保护这个方法,把 余额的增加和减少 做为一组原子操作。

这段代码中,临界区内有两个资源,分别是转出账户的余额 this.balance转入账户的余额 target.balance ,并且用的是一把锁 this ,符合前面提到的: 多个资源可以用一把锁来保护

但是这个例子只是看上去正确,问题就出在这个锁 this 上, this 可以保护 这个类中的 this.balance,却无法保护 target.balance,就像你不能用自己家的锁来保护别人家的资产,也不能用自己的票保护别人的座位。【这个不能用自己家的锁保护别人家的资产感觉解释的挺到位的,一下子就指出了这个代码中存在的问题】

下面进行具体分:

假设 有 ABC 三个账户,余额都是200,我们用**两个线程分别执行两个转账操作** :「账户A」 转给 「账户B」 100元, 「账户B」 转给 「账户C」 100元,最后我们期望的结果是 A 的余额 是100元,B 的余额 是200元,C的余额是300元。

假设**线程1 执行 账户A 转账户B 的操作**,线程2 执行 账户B 转 账户C 的操作

两个线程分别在两颗CPU 上**同时执行** ,那它们是「互斥」的吗?我们期望是,但实际上并不是互斥的

因为 线程1 中的锁是 账户A 的实例(A.this),线程2的锁是 账户B实例B.this),所以**这两个线程可以「同时进入」**临界区 transfer()

同时进入临界区导致的结果是 线程1线程2 会读取到 账户B 的余额 为200,最终导致 账户B 的余额可能300线程1 后于 线程2 写入 B.balance线程2 写的 B.balance 的值 被 线程1覆盖) 可能结果是100( 线程1 先于线程2 写 B.balance,线程1 写的 B.balance 被线程2覆盖),就是不可能是200。

【也就是 最终 A 和 C 的账户余额是正确的,但是 B 的账户余额一定是有问题的。】

使用锁的正确姿势

在上一篇文章中,我们提到了用**「同一把锁」** 来保护多个资源,对应现实中的 "包场",在编程领域中怎么对应包场进行操作呢?

将**锁覆盖所有受保护的资源就可以了。 上个例子中 this对象级别的锁**,所以 AB 都有自己对象的锁?如何和让A 和 B 共享一把锁呢?

方案很多,有一种是:可以让所有对象都持有一个唯一性的对象,这个对象在创建 Account 时传入。

下面是示例代码,将 Account 的默认构造函数变为 private ,同时增加一个 带 Object lock 参数的构造函数,创建 Account 对象时,传入相同的 lock,这样所有 Account 对象都会共享这个 lock

public class Accoutn3 {
    // 用来作为保护多个资源的单一锁对象
    private Object lock;

    private int balance;

    // 创建 Account 时需要传入同一个锁对象,所有操作都使用这个锁对象来维持同步
    private Accoutn(Object lock) {
        this.lock = lock;
    }

    // 转账
    void transfer(Accoutn target, int amt) {
        synchronized (lock) {
            if (this.balance > amt) {
                this.balance -= amt;
                target.balance += amt;
            }
        }
    }
}

这个办法可以解决问题,但是要求对象的构造函数中必须传入同一个对象,如果创建 Account 对象时传入的锁对象不一致,则又会出现问题

真实的项目场景中,创建 Account 对象的代码可能分散在多个工程中,所以传入几个工程都能共享的 lock 对象不容易。**【从真实项目的角度出发说明这种方法的局限性】**所以这个方法缺乏实践的可行性。

另一个方法:用 Class 文件作为锁,这样**所有相同的类的锁对象就是唯一的**。

在这个例子中就是使用 Account.class 作为共享的锁,这个对象是 JVM 在加载 Account 类的时候创建的,所以不需要担心唯一性,而且这样的话就不需要在构造对象时传入额外的锁对象代码了。

// 使用 Account.Class 作为锁 每次只有一个线程能获取锁对象,因为这个锁对象是 Class 对象,只存在一个
public class Account {
    private int balance;

    void transger(Account target,int amt) {
        synchronized (Account.class) {
            if (this.balance > amt) {
                this.balance -= amt;
                target.balance += amt;
            }
        }
    }
}

下面这幅图很直观的展示了 我们是如何使用共享的 「锁」 Account.class 来保护不同对象的临界区的。

总结:

关于如何使用一个锁保护多个资源的关键在于 分析多个资源之间的关系,如果 **资源之间没有关系,也就是不存在可变性条件 **,则每个资源一把锁就可以解决问题

如果 资源之间存在关联关系 ,则 需要选择一个粒度更大的锁 ,这个锁可以覆盖所有相关的资源。除此之外还要梳理出有哪些 访问路径 ,所有的访问路径都要设置合适的锁,这个过程可以类比 「门票管理」。

上面的「关联关系」 如果使用更具体 更专业的语言来描述应该叫做 「原子性 ,这里转账操作的原子性属于面向高级语言层面的,与之前提到的 面向CPU指令原子性不同,但是 本质上相同

这里作者对原子性有一个描述:原子性的本质:不是不可分割,不可分割只是外在表现,其本质是多个资源之间有一致性的要求操作的中间状态对外不可见。」

例如在 32位机器上写 long 型变量有中间状态(只写了 64位中的32位),银行转账中也有中间状态(转出账户减少了,转入账户还没有增加),所以解决原子性问题,本质就是要保证中间状态对外不可见

思考题:

第一个示例程序中使用了两把锁来保护账户余额,和账户密码,创建锁的时候使用的是 private final Object xxLock = new Object()。如果账户余额用 this.balance 作为锁,账户密码用 this.password 作为锁 是否可行?

思考:锁的重要条件是不可变,而这里这两个字段都是可变,所以不行

image-20200411002235172

【当你这样写的时候,IDEA 也会提示你你在使用一个非不可变的字段作为锁,很智能。】

举个例子,假如this.balance = 10 ,多个线程同时竞争同一把锁this.balance,此时只有一个线程拿到了锁,其他线程等待,拿到锁的线程进行this.balance -= 1操作,this.balance = 9。 该线程释放锁, 之前等待锁的线程继续竞争this.balance=10的锁,新加入的线程竞争this.balance=9的锁,导致多个锁对应一个资源

个人总结:

专栏相比于 《jcip》,知识密度低了很多,讲的比较详细,个人感觉价值更大的是看评论区,从别人的留言中看到对一个问题不同的思考角度下的思考结果。

精选留言:

使用 Class 类对象 作为锁的 粒度太大 ,整个类都变成串行的类了,虽然能保证线程安全性,但是 性能太低

转账例子中粗粒度导致性能太差,细粒度可能导致死锁,专栏的深度还是有点浅,结合几本书进行学习作为补充资料更好。

老师,很感谢有这个专栏,让我能够更加系统的学习并发知识。
对于思考题,之所以不可行是因为每次修改balancepassword时都会使锁发生变化
-----------------------------------------------------------------------
以下只是我的猜想
比如有线程A、B、C
线程A首先拿到balance1锁,线程B这个时候也过来,发现锁被拿走了,线程B被放入一个地方进行等待。
当A修改掉变量balance的值后,锁由balance1变为balance2. (锁的值发生了改变,是大忌)
线程B也拿到那个balance1锁,这时候刚好有线程C过来,拿到了balance2锁。
由于B和C持有的锁不同,所以可以同时执行这个方法来修改balance的值,这个时候就有可能是线程B修改的值会覆盖掉线程C修改的值?
-----------------------------------------------------------------------
不知道到底是不是这样?老师可以详细讲下这个过程吗?谢谢

Q.E.D.

知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议

最是人间留不住,曾是惊鸿照影来。