从一次转账探究并发优化的思路

Posted 热爱编程的大忽悠

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了从一次转账探究并发优化的思路相关的知识,希望对你有一定的参考价值。

从一次转账探究并发优化的思路


引言

本文想通过简单转账过程,来谈谈如何活用java中的锁和相关同步工具,这也常在java面试的思维扩展题中被问到。

上一篇文章主要介绍了java的内存模型,jmm通过定义一套规范,使jvm能按需禁用cpu缓存导致的可见性问题和编译优化导致的有序性问题。这套规范包括对volatile,synchronized和final三个关键字的解析,和7个Happens-Before规则。

但是由线程上下文切换导致的原子性问题又该如何解决呢?


synchronized锁

要实现多个操作的原子性执行,最简单的思路就是加锁,在java中我们可以使用synchronized锁或者ReentrantLock。

对于synchronized锁而言,通过管程中锁的规则: 对一个锁的解锁Happens-Before于后续对这个锁的加锁。可知,一个线程的解锁操作对后一个线程的加锁操作可见,综合Happens-Before的传递性规则,我们就能得出前一个线程在临界区修改的共享变量,对后续进入临界区的线程是可见的。

对于锁的使用而言,我们需要以下几点:

  • 锁定的是哪个对象
  • 锁定对象保护的资源之间的关系
  • 在哪里加锁和解锁 – 临界区的范围有多大

我们还需要注意一点: 受保护资源和锁之间的合理关系应该是N:1的关系,也就是可以用一把锁保护多个资源,但是不能用多把锁保护一个资源。

还有一点需要注意,当我们要保护多个资源时,首先需求区分这些资源是否存在关联关系。


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

账户类 Account 有两个成员变量,分别是账户余额 balance 和账户密码 password :

class Account 
    private Integer balance;
    private String password;

    void withdraw(Integer amt) 
        if (this.balance > amt) 
            this.balance -= amt;
        
    

    Integer getBalance() 
        return balance;
    

    void updatePassword(String pw) 
        password = pw;
    

    String getPassword() 
        return password;
    

如何确保取款,查询余额和更新密码,查看密码这些操作的并发安全性?

  • 因为资源之间没有关联,所以可以考虑用不同的锁对受保护资源进行精细化管理,这种锁还有一个名字,叫细粒度锁。
class Account 
    private final Object balLock = new Object();
    private final Object pwdLock = new Object();
    private Integer balance;
    private String password;

    void withdraw(Integer amt) 
        synchronized (balLock) 
            if (this.balance > amt) 
                this.balance -= amt;
            
        
    

    Integer getBalance() 
        synchronized (balLock) 
            return balance;
        
    

    void updatePassword(String pw) 
        synchronized (pwdLock) 
            password = pw;
        
    

    String getPassword() 
        synchronized (pwdLock) 
            return password;
        
    


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

如果多个资源是有关联关系的,那这个问题就有点复杂了。例如银行业务里面的转账操作,账户 A 减少 100 元,账户 B 增加 100 元。这两个账户就是有关联关系的。那对于像转账这种有关联关系的操作,我们应该怎么去解决呢?

  • 转账过程如下:
class Account 
    private int balance;

    // 转账
    void transfer(Account target, int amt) 
        if (this.balance > amt) 
            this.balance -= amt;
            target.balance += amt;
        
    

  • 下面这种解决方案对吗?
class Account 
    private int balance;

    // 转账
    public synchronized void transfer(
            Account target, int amt) 
        if (this.balance > amt) 
            this.balance -= amt;
            target.balance += amt;
        
    

上面这种做法是错误的,因为this保护的是当前Account自己的余额,他保护不了target的余额,就像你不能用自家的锁来保护别人家的资产一样,you konw ?

上面这种写法何时会出现问题呢?

  • 假设有A,B,C三个账户,余额都是200元,我们用两个线程分别执行两个转账操作: 账户A转账给账户B 100元,账户B转账给账户C 100元,我们最终期望的结果应该是账户A的余额为100元,账户B的余额为100元,账户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对象只能保护一个资源,而不能保护临界区的所有资源,所以我们需要一把大锁能覆盖所有受保护的资源,最简单的做法就是使用类级别锁:

方案一: 让所有对象都持有一个唯一性的对象,这个对象在创建 Account 时传入

class Account 
    private int balance;
    private final Object globalLock;

    public Account(Object globalLock) 
        this.globalLock = globalLock;
    
    
    public void transfer(Account target, int amt) 
        synchronized (globalLock) 
            if (this.balance > amt) 
                this.balance -= amt;
                target.balance += amt;
            
        
    

评价: 这个办法确实能解决问题,但是有点小瑕疵,它要求在创建 Account 对象的时候必须传入同一个对象,如果创建 Account 对象时,传入的 lock 不是同一个对象,那可就惨了,会出现锁自家门来保护他家资产的荒唐事。在真实的项目场景中,创建 Account 对象的代码很可能分散在多个工程中,传入共享的 lock 真的很难。

方案二: 用 Account.class 作为共享的锁。Account.class 是所有 Account 对象共享的,而且这个对象是 Java 虚拟机在加载 Account 类的时候创建的,所以我们不用担心它的唯一性。使用 Account.class 作为共享的锁,我们就无需在创建 Account 对象时传入了,代码更简单。

class Account 
    private int balance;

    // 转账
    public void transfer(Account target, int amt) 
        synchronized (Account.class) 
            if (this.balance > amt) 
                this.balance -= amt;
                target.balance += amt;
            
        
    


阶段小结

  • 如果资源之间没有关系,每个资源一把锁就可以了。
  • 如果资源之间有关联关系,就需要选择一个粒度更大的锁,这个锁能够覆盖所有相关的资源。
  • 需要梳理出有哪些访问路径,所有的访问路径都需要设置合适的锁。

关联关系本质是一种"原子性"特征,原子性的本质是多个资源间有一致性的要求,操作的中间状态对外不可见。

解决原子性问题,关键在于保证中间状态对外不可见。

注意事项:

  • 不能用可变对象作为锁对象
  • 基本类型也不可以作为锁对象哈,这个在编译阶段就会报错 (顺带提一嘴)

原因是 balance 字段和 password 字段是可变的。但说它是可变的并不是指字段指向 Integer 对象和 String 对象是可变的(事实上在 openjdk 11 里实测 int 基本类型做引导锁并不会自动装箱,需要显式转换为包装类才能通过编译),而是指引用变量本身由于没有被 final 修饰所以是可变的,所以如果某处修改了引用指向的对象,就会出现 “多个锁管理同一个共享资源” 的问题。在这一阶段,问题的核心是 “引用可变”。

但在我们的例子中,哪怕字段被 final 修饰了,依然是不妥当的。这主要是因为 IntCache 和字符串常量池的存在,因此会出现不必要的锁竞争,从而降低系统性能。在这一阶段,问题的核心不是“锁对象本身可变”,而是可能存在 “锁复用问题”。

但本身用不会产生复用的 Integer 和 String 对象作为锁理论上应该是没有问题,比如用 new Integer() 或者 new String() 在堆上创建新的对象,和创建一个 Object 实例是一样的。

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

LCK01-J. Do not synchronize on objects that may be reused


细粒度锁优化

现实世界中的转账过程都是可以并行的,而我们上面的解决方案会使得所有转账过程都变为串行化执行,要解决这个问题,我们需要从现实中寻找灵感。

我们试想在古代,没有信息化,账户的存在形式真的就是一个账本,而且每个账户都有一个账本,这些账本都统一存放在文件架上。

银行柜员在给我们做转账时,要去文件架上把转出账本和转入账本都拿到手,然后做转账。这个柜员在拿账本的时候可能遇到以下三种情况:

  • 文件架上恰好有转出账本和转入账本,那就同时拿走;
  • 如果文件架上只有转出账本和转入账本之一,那这个柜员就先把文件架上有的账本拿到手,同时等着其他柜员把另外一个账本送回来;
  • 转出账本和转入账本都没有,那这个柜员就等着两个账本都被送回来。

上面这个过程在编程的世界里怎么实现呢?其实用两把锁就实现了,转出账本一把,转入账本另一把。

在 transfer() 方法内部,我们首先尝试锁定转出账户 this(先把转出账本拿到手),然后尝试锁定转入账户 target(再把转入账本拿到手),只有当两者都成功时,才执行转账操作。

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;
                
            
        
    

相对于用 Account.class 作为互斥锁,锁定的范围太大,而我们锁定两个账户范围就小多了,这样的锁,上一节我们介绍过,叫细粒度锁。使用细粒度锁可以提高并行度,是性能优化的一个重要手段。

但是,使用细粒度锁是有代价的,这个代价就是可能会导致死锁。


死锁: 一组相互竞争资源的现场因为互相等待,导致永久的阻塞的现象。


死锁问题探究

死锁产生必须具备的四个条件:

  • 互斥: 资源必须是互斥的
  • 占有且等待: 线程占有某个资源x的同时,等待另一个资源y,等待过程中不释放资源x
  • 不可抢占: 其他线程不能抢占当前线程已经占有的资源
  • 循环等待: 线程之间互相等待对方占用的资源,形成了一个闭环

解决死锁的最好办法是避免死锁,因此我们只需要破坏上面其中任意一个条件,就可以打破死锁:

  • 对于占有且等待这个条件,我们可以一次性申请所有资源,这样就不存在等待了
  • 对于不可抢占这个条件,占用部分资源的现场进一步申请其他资源时,如果申请不到,可以主动是否它占用的资源
  • 对于循环等待这个条件,可以靠按序申请资源来预防,即给资源排序,申请的时候从资源序号小的开始申请。

上面是理论分析,下面我们来落实到代码层面解决问题。


破坏占有且等待条件

从理论上讲,要破坏这个条件,可以一次性申请所有资源。在现实世界里,就拿前面我们提到的转账操作来讲,它需要的资源有两个,一个是转出账户,另一个是转入账户,当这两个账户同时被申请时,我们该怎么解决这个问题呢?

可以增加一个账本管理员,然后只允许账本管理员从文件架上拿账本,也就是说柜员不能直接在文件架上拿账本,必须通过账本管理员才能拿到想要的账本。例如,张三同时申请账本 A 和 B,账本管理员如果发现文件架上只有账本 A,这个时候账本管理员是不会把账本 A 拿下来给张三的,只有账本 A 和 B 都在的时候才会给张三。这样就保证了“一次性申请所有资源”。


对应到编程领域,“同时申请”这个操作是一个临界区,我们也需要一个角色(Java 里面的类)来管理这个临界区,我们就把这个角色定为 Allocator。它有两个重要功能,分别是:同时申请资源 apply() 和同时释放资源 free()。

账户 Account 类里面持有一个 Allocator 的单例(必须是单例,只能由一个人来分配资源)。当账户 Account 在执行转账操作的时候,首先向 Allocator 同时申请转出账户和转入账户这两个资源,成功后再锁定这两个资源;当转账操作执行完,释放锁之后,我们需通知 Allocator 同时释放转出账户和转入账户这两个资源。

class Account 
    // actr应该为单例 -- 饿汉式直接初始化
    private static final Allocator actr = new Allocator();
    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, false);
        
    

    public static class Allocator 
        /**
         * 资源:占用情况(true表示被占用,false表示未被占用)
         */
        private final Map<Object, Boolean> als = new HashMap<>();

        // 一次性申请所有资源
        synchronized boolean apply(Object from, Object to) 
            if (als.getOrDefault(from, false) || als.getOrDefault(to, false)) 
                return false;
            
            als.put(from, true);
            als.put(to, true);
            return true;
        

        // 归还资源,如果资源确定不会用到,设置remove为true
        synchronized void free(Object from, Object to, boolean remove) 
            if (remove) 
                als.remove(from);
                als.remove(to);
             else 
                als.put(from, false);
                als.put(to, false);
            
        
    


破坏不可抢占条件

破坏不可抢占条件看上去很简单,核心是要能够主动释放它占有的资源,这一点 synchronized 是做不到的。原因是 synchronized 申请资源的时候,如果申请不到,线程直接进入阻塞状态了,而线程进入阻塞状态,啥都干不了,也释放不了线程已经占有的资源。

java.util.concurrent 这个包下面提供的 Lock 是可以轻松解决这个问题的 , 利用Lock接口提供的超时等待获取锁方法可以破坏不可抢占条件。


破坏循环等待条件

破坏这个条件,需要对资源进行排序,然后按序申请资源。这个实现非常简单,我们假设每个账户都有不同的属性 id,这个 id 可以作为排序字段,申请的时候,我们可以按照从小到大的顺序来申请。

class Account 
    private int id;
    private int balance;

    // 转账
    void transfer(Account target, int amt) 
        Account first = target.id <= this.id ? target : this;
        Account second = target.id <= this.id ? this : target;
        //锁定序号小的账户
        synchronized (first) 
            //锁定序号大的账户
            synchronized (second) 
                if (this.balance > amt) 
                    this.balance -= amt;
                    target.balance += amt;
                
            
        
        


阶段小结

使用细粒度锁锁定多个资源时,要注意死锁问题。

预防死锁问题主要是破坏三个条件的其中一个:

  • 破坏占有并等待条件: 一次性申请完所有需要的资源
  • 破坏不可抢占条件: 获取锁的超时释放
  • 破坏循环等待条件: 给资源进行排序,按序申请资源

注意:

synchronized(Account.class) 锁了Account类相关的所有操作,只要与Account有关联,通通需要等待当前线程操作完成。而破坏占有并等待条件案例中的while死循环的方式只锁定了当前操作的两个相关的对象。两种影响到的范围不同。


利用等待通知机制继续优化

在细粒度锁一节中,我们会面临死锁问题,我们可以通过一次性申请所有需要的资源破坏占有且等待条件,但是上面的案例中,如果不能一次性申请到所有的所需资源,我们是通过死循环的方式不断重试的。

        // 一次性申请转出账户和转入账户,直到成功
        while (!actr.apply(this, target)) ;

该方案在并发冲突量很大的情况下不适用,因为可能要循环上万次才能获取到锁,太消耗CPU了。

how to deal with it ?

  • 利用等待-通知机制,如果线程要求的条件不满足,可以先重试几次,如果还是不行就进入等待状态
  • 当条件满足后,通知等待的线程重新执行

在 Java 语言里,等待 - 通知机制可以有多种实现方式,比如 Java 语言内置的 synchronized 配合 wait()、notify()、notifyAll() 这三个方法就能轻松实现。

等待队列和互斥锁是一对一的关系,每个互斥锁都有自己独立的等待队列。

在并发程序中,当一个线程进入临界区后,由于某些条件不满足,需要进入等待状态,Java 对象的 wait() 方法就能够满足这种需求。如上图所示,当调用 wait() 方法后,当前线程就会被阻塞,并且进入到右边的等待队列中,这个等待队列也是互斥锁的等待队列。 线程在进入等待队列的同时,会释放持有的互斥锁,线程释放锁后,其他线程就有机会获得锁,并进入临界区了。

那线程要求的条件满足时,该怎么通知这个等待的线程呢?很简单,就是 Java 对象的 notify() 和 notifyAll() 方法。我在下面这个图里为你大致描述了这个过程,当条件满足时调用 notify(),会通知等待队列(互斥锁的等待队列)中的线程,告诉它条件曾经满足过。

为什么说是曾经满足过呢?因为 notify() 只能保证在通知时间点,条件是满足的。而被通知线程的执行时间点和通知的时间点基本上不会重合,所以当线程执行的时候,很可能条件已经不满足了(保不齐有其他线程插队)。这一点你需要格外注意。

除此之外,还有一个需要注意的点,被通知的线程要想重新执行,仍然需要获取到互斥锁(因为曾经获取的锁在调用 wait() 时已经释放了)。

上面我们一直强调 wait()、notify()、notifyAll() 方法操作的等待队列是互斥锁的等待队列,所以如果 synchronized 锁定的是 this,那么对应的一定是 this.wait()、this.notify()、this.notifyAll();如果 synchronized 锁定的是 target,那么对应的一定是 target.wait()、target.notify()、target.notifyAll() 。而且 wait()、notify()、notifyAll() 这三个方法能够被调用的前提是已经获取了相应的互斥锁,所以我们会发现 wait()、notify()、notifyAll() 都是在 synchronized内部被调用的。如果在 synchronized外部调用,或者锁定的 this,而用 target.wait() 调用的话,JVM 会抛出一个运行时异常:java.lang.IllegalMonitorStateException。


利用wait-notify对资源分配器代码进行优化:

    public static class Allocator 
        /**
         * 资源:占用情况(true表示被占用,false表示未被占用)
         */
        private final Map<Object, Boolean> als = new HashMap<>();

        // 一次性申请所有资源
        synchronized void apply(Object from, Object to) 
            //唤醒后,先看能不能抢到锁,抢到锁了,还要看此时条件满不满足,不满足就继续休眠
            while (als.getOrDefault(from, false) || als.getOrDefault(to, false)) 
                //可以考虑先重试指定次数
                try 
                    this.wait();
                 catch (InterruptedException e) 
            
            als.put(from, true);
            als.put(to, true);
        

        // 归还资源,如果资源确定不会用到,设置remove为true
        synchronized void free(Object from, Object to, boolean remove) 
            if (remove) 
                als.remove(from);
                als.remove(to);
             else 
                als.put(from, false);
                als.put(to, false);
            
            this.notifyAll();
        
    

阶段小结

等待 - 通知机制是一种非常普遍的线程间协作的方式。工作中经常看到有同学使用轮询的方式来等待某个状态,其实很多情况下都可以用今天我们介绍的等待 - 通知机制来优化。

注意点:

  • 尽量使用notifyAll , 因为 notify() 是会随机地通知等待队列中的一个线程,而 notifyAll() 会通知等待队列中的所有线程。
  • 使用 notify() 的风险在于可能导致某些线程永远不会被通知到。

wait() 方法和 sleep() 方法都能让当前线程挂起一段时间,那它们的区别是什么?

  • wait释放资源,sleep不释放资源
  • wait需要被唤醒,sleep不需要
  • wait需要获取到监视器,否则抛出异常,sleep不需要
  • wait是Object父类的方法,sleep是Thread的方法

总结

由线程上下文切换导致的原子性问题可以通过锁来解决,我们可以直接使用jvm层面提供的synchronized锁。

使用锁时,我们需要注意以下几点:

  • 锁定的对象是哪个
  • 锁定的对象和需要保护的资源之间关系
  • 临界区范围有多大,即在哪里加锁,在哪里解锁
  • 梳理好对资源的所有访问路径,所有的访问路径都需要设置合适的锁

对于没有关联关系的多个资源而言,通常都是一个资源对应一把锁,这种锁我们也称之为细粒度锁。

对于存在关联关系的多个资源而言,最直接的想法就是使用一把能够覆盖所有资源的锁,进一步优化的想法就是使用细粒度锁,例如转账过程中先锁定转出账户,再锁定转入账户,避免大粒度锁锁定所有账户。

细粒度锁容易导致死锁问题的发生,死锁问题必须具备: 互斥,占有并等待,不可抢占,循环等待这四个条件,我们只需要打破其中一个条件即可。

  • 打破不可抢占条件: 指定时间内没抢到锁就释放已经获取到的资源
  • 打破占有并等待条件: 一次性申请所有资源,如果当前时刻条件不满足,则阻塞等待,条件满足时,被唤醒
  • 打破循环等待条件: 资源排序,并按序申请

补充

这里补充一道面试题,看看大家对并发的理解程度:

开启 3 个线程,这三个线程的 ID 分别为 A、B、C,每个线程将自己的 ID 在屏幕上打印 10 遍,要求输出的结果必须按顺序显示。如:ABCABCABC……

线程A输出完自己的ID,接着线程B才能输出自己的ID,然后是线程C,这里面蕴含等待唤醒关系,因此首先想到条件变量实现多个线程之间的同步,这里给出一个我自己写的答案,解法不一定唯一:

public class Main 
    private static final int count = 10;
    private static final ReentrantLock lock = new ReentrantLock();
    private static final Condition aOver = lock.newCondition();
    private static final Condition bOver = lock.newCondition();
    private static final Condition cOver = lock.newCondition();
    private static int num = 1;

    public static void main(String[] args) throws InterruptedException, ExecutionException 
        Thread t1 = new Thread(() -> 
            task(() -> System.out.print("A");num=2;, cOver, aOver, 1);
        ,"A");
        Thread t2 = new Thread(() -> 
            task((Java面试高频题:Spring Boot+JVM+Nacos高并发+高可用已撸完

Spring家族面试题+阿里技术官实战心得+性能优化+并发+分布式等

程序员进阶架构师必备架构基础技能:并发编程+JVM+网络+Tomcat等

Java面试问题笔记——JVM

java后台面试题整理及解答Java 并发

从一次小哥哥与小姐姐的转账开始, 浅谈分布式事务从理论到实践