Skip to content

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

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

精选留言:

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

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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions