Skip to content

极客时间 ——《Java并发编程实战》 04 | 互斥锁,如何用一把锁保护多个资源(下) #39

@funnycoding

Description

@funnycoding

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

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


上一篇文章中提到,受保护资源和锁之间的关系应该是 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) {
                // 取款操作
                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;
    // 转账
    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,就像你不能用自己家的锁来保护别人家的资产,也不能用自己的票保护别人的座位。【这个不能用自己家的锁保护别人家的资产感觉解释的挺到位的,一下子就指出了这个代码中存在的问题】

下面进行具体分:

假设 有 A、B、C 三个账户,余额都是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。

使用锁的正确姿势

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

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

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

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

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

    private int balance;

    // 创建 Account 时需要传入同一个 lock 对象
    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 作为锁
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》,知识密度低了很多,讲的比较详细,个人感觉价值更大的是看评论区,从别人的留言中看到对一个问题不同的思考角度下的思考结果。

精选留言:

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

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

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions