关于java:Java并发编程根源为什么转账后余额总是对不上

31次阅读

共计 4594 个字符,预计需要花费 12 分钟才能阅读完成。

你开发了一套转账零碎,转账的流程没问题,通过了内部测试,上线后看起来也没问题。

然而,过了一段时间,用户竟然能够忽视余额,间接提现。眼看就要就业了,问题到底出在哪里呢?

通过一番查看,你发现每次出事的时候,用户都同时发动了好几笔订单,导致了并发问题。

什么是并发问题

并发,就是在很短的工夫内,有很多个申请同时发到了服务器上。这时候,你如果没有解决好,就呈现了并发 Bug。

并发 Bug 十分奇葩,经常会导致意想不到的状况。更让人抓狂的是,这些 Bug 常常莫名其妙地呈现,又莫名其妙地隐没,你很难重现和追踪。

比如说,在用户提现前,你明明做了余额校验,平时没问题,但访问量一大,这个性能就突然生效,用户余额不够,却胜利提现了。

遇到这种状况,你要想找到其中的 Bug,只能一行一行地查看代码。所以,疾速、精准发现并发问题,这是优良程序员的基本功,而要做到这点,你必须深刻理解并发 Bug 的源头。

并发 Bug 是计算机谋求高性能的代价。

程序的运行离不开 CPU、内存、IO 设施,但这么多年无论技术怎么迭代,始终有一个外围矛盾:这三者的速度差距。

CPU 的速度最快,内存的速度次之,IO 设施的速度最慢。它们之间的速度差距,只能说是离谱。

打个比方,CPU 是天上一天,内存是地上一年,而 IO 设施就是地上一万年。一条数据,从 IO 设施传到内存要一万年,内存再传到 CPU 要一年,而 CPU 只有解决一天。这一来一回,光 IO 设施就花去 2 万年。

所以,你怎么进步 CPU 性能都没用,程序的整体性能取决于最慢的操作—读写 IO 设施。

那怎么均衡这三者的速度差别呢?

各路大神们是从这几个方面动手的,别离是:计算机体系结构、操作系统、编译程序。

在计算机体系结构上,减少 CPU 缓存,均衡 CPU 和内存的速度差距。

在操作系统上,减少了线程,分时复用 CPU,均衡 CPU 和 IO 设施的速度差距。

在编译程序上,优化指令的执行程序,进步程序性能。

然而,天下没有收费的午餐,并发问题的本源就在这些优化上。

缓存带来的可见性问题

所谓可见性,就是一个线程对共享变量的批改,另外一个线程可能立即看到。

为了均衡 CPU 和内存的速度差别,在计算机体系结构上,大神们做了翻新—减少 CPU 缓存。但随着 CPU 从单核走向多核,可见性问题就呈现了。

单核 CPU 是不会有可见性问题的。因为 CPU 只有 1 个,缓存也只有 1 个,所有线程都运行在这一个 CPU 下面,整个构造非常简单。

一个线程无论怎么读写数据,对其它线程都是可见的,对后果都没有影响。

然而,在多核时代,每颗 CPU 都有本人的缓存,数据就没法保持一致了。

当两个线程运行在不同的 CPU 上时,这两个线程就在操作本人 CPU 上的缓存,对其它线程是不可见的。

你能够看上面这幅图,变量 V 加载到两颗 CPU 上,线程 A 在操作 CPU-1 上的缓存,而线程 B 在操作 CPU-2 上的缓存。它们都不晓得对方做了什么,变量 V 怎么算都不对。

咱们来看一个具体的例子。

public class Withdraw {

    private long balance = 15000;

    /**
     * 提现
     * @param amount    提现金额
     * @return
     */
    private void withdraw(int amount) {
        /** 校验 **/
        // 非法金额
        if (amount <= 0) {return;}
        // 余额有余
        if (balance < amount) {return;}

        /** 提现操作 **/
        // 减余额
        balance = balance - amount;

        // 省略有数代码...
    }

    /*************** 测试函数 ****************/
    private void withdraw10k() {for (int i = 0; i < 10000; i++) {this.withdraw(1);
        }
    }

    private static long mockWithdraw() throws InterruptedException {
        // 创立账户
        final Withdraw account = new Withdraw();

        // 模仿提现,发动 1 万笔订单,每次提现 1 块钱
        Thread th1 = new Thread(() -> {account.withdraw10k();
        });
        Thread th2 = new Thread(() -> {account.withdraw10k();
        });

        // 启动两个线程
        th1.start();
        th2.start();

        // 期待两个线程执行完结
        th1.join();
        th2.join();

        return account.balance;
    }


    public static void main(String[] args) throws InterruptedException {long balance = mockWithdraw();
        System.out.println("账户余额:" + balance + "元");
    }
}

第一次执行后果 - 账户余额:823 元
第二次执行后果 - 账户余额:2274 元
第三次执行后果 - 账户余额:1525 元

用户有一万五千块钱,当初发动了两万笔提现订单,每笔订单一块钱。照理说,如果钱用完,就不能提现了,余额应该是 0。但连着执行三次,每次的余额都不一样。换句话说,用户忽视余额,超额提现。

其实,你想想看,如果线程一和线程二同时启动,都把 balance = 15000 读到缓存里,在执行完 balance = 15000 – 1 后,它们都把 balance = 14999 写到了内存。如果多运行几次,余额必定越来越离谱。

因而,并发编程的第一道坎,就是保障 CPU 缓存的可见性。

线程切换带来的原子性问题

所谓原子性,就是所有操作要么不间断地全副被执行,要么一个也没有执行。

为了均衡 CPU 和 IO 设施的速度差距,操作系统一直迭代,呈现了多线程技术。然而,多线程技术也有副作用,就是原子性问题。所以,咱们要想搞清楚原子性问题,必须先理解多线程技术。

在多线程呈现前,计算机是怎么运作的呢?

你设想一下这个场景,你只有一台电脑,当初想边敲代码,边听音乐,该怎么办呢?答案是,没得办。你要不就敲代码,要不就听音乐,两者只能二选一。

可是,多线程技术的呈现,让 CPU 能分时复用,从此扭转所有。

这是怎么做到的呢?

操作系统容许线程只运行一段时间。比如说,某个线程取得了 50 毫秒的“工夫片”,那这个线程就能执行 50 毫秒。过了 50 毫秒后,操作系统做“线程切换”,这个线程休眠,换另外一个线程执行。

你能够看上面这幅图,加深一下了解。

这带来了两个益处。

第一,CPU 的利用效率大幅提高。简略来说,CPU 能够全年无休地工作了。

比方,一个线程要进行 IO 操作,读硬盘的电影什么的,那这个线程就进入“休眠状态”,让出 CPU 的使用权,另一个线程运行。等电影读到内存后,操作系统再把这个线程从休眠中唤醒,让另一个线程休眠。

第二,IO 的利用效率大幅提高,这要从两个中央说起。

首先,线程在休眠的时候,利用闲暇工夫读写 IO。比方,一个线程没有失去 CPU 的使用权,那能够利用闲暇工夫,去做 IO 操作,像是读文件什么的。

而后,IO 操作减少了排队机制。一个线程在读文件,另一个线程也要读文件,那读文件的操作就会排队。比方,一个线程读完文件后,发现有排队的工作,就立刻启动下一个读操作。

这套组合拳下来,IO 的利用效率也上来了。

毫不夸大的说,多线程技术是一块里程碑,是操作系统历史上不可磨灭的一笔。

然而,这一切都是有代价的。

并发程序的实现离不开多线程,天然也会用到线程切换,但 线程切换正是很多诡异 Bug 的源头。

这是为什么呢?

因为咱们用的是高级编程语言。在高级编程语言里,一条语句往往会被拆成多个 CPU 指令。比方,Java 中自增运算 count++,就至多被拆成了 3 个 CPU 指令:

  1. 读取内存,把 count 加载到 CPU;
  2. CPU 执行 count+1 操作;
  3. 把后果写入内存;

在这个过程中,任何一个 CPU 指令执行完后,都有可能产生线程切换。换句话说,在高级语言里,一条语句不肯定能被正确执行。

举个例子,假如 count = 0,有 2 个线程执行 count++,后果应该是 count = 2。在咱们的设想中,count++ 是这样的,就像上面这副图一样。

然而,CPU 的原子操作只是 CPU 指令级别的,而不是编程语言的操作符。一旦产生线程切换,最初的后果就是 count = 1。实际上,count++ 是这样的。

在这个例子,count++ 没有保障原子性。起因在于,一条编程语句被拆成多个 CPU 指令,如果产生线程切换,原子性就被毁坏了。

所以,在高级语言层面,怎么控制线程切换,从而保障操作的原子性?这是并发编程的第二道坎。

编译优化带来的有序性问题

所谓有序性,就是程序按代码的先后顺序运行。

为了进步程序性能,编程语言有时会扭转代码的先后顺序。这很容易了解,有些代码更加重要,很多中央都要用到,但这些代码偏偏写在最初,其它中央想用就得始终等着,程序的性能必定好不了。

所以,编译器会扭转代码的先后顺序,优先执行更重要的代码。比如说,一段代码本来是这样的。

int count1 = 1;
int count2 = 2;

但编译器可能感觉这段代码的效率太低,得做编译优化。后果,代码就变成了这个样子:

int count2 = 2;
int count1 = 1;

然而,这也是有代价的。程序如果依照代码的先后顺序执行,确实是慢了点,可起码不会出错。但 通过了编译优化,可能会呈现一些意想不到的 Bug,这就是有序性问题。

最经典的例子就是通过双重查看,来创立单例对象。你在工作中,为了保障 ID 是惟一的,会用到一个惟一的 ID 生成器,而这个生成器往往是单例对象。

public class IdGen {

    private static IdGen instance;

    static IdGen getInstance() {if (instance == null) {synchronized (IdGen.class) {if (instance == null) {instance = new IdGen();
                }
            }
        }
        return instance;
    }

}

你认真看 getInstance() 办法,如同没什么问题。一个线程获取 instance 对象 时,先在 6 行代码做第一重查看,判断 instance 是不是空。如果是空,7 行代码就加锁,禁止其它线程进来,执行初始化。

这时侯,就算有其它线程同时进来,也没关系。因为 7 行代码加了锁,只有拿到锁的线程能力进来,而且等其它线程拿到锁进来,还得再通过 8 行代码的第二重查看,判断 instance 是不是空。

整个过程看上去没什么,可一旦呈现编译优化,问题就来了。你注意第 9 行代码的 new 操作,如果没有做编译优化,那么应该是这样的:

  1. 调配一块内存 M;
  2. 在内存 M 上初始化 IdGen 对象;
  3. 把 M 的地址赋值给 instance 变量;

但编译优化当前,就变成这样了:

  1. 调配一块内存 M;
  2. 把 M 的地址赋值给 instance 变量;
  3. 在内存 M 上初始化 IdGen 对象;

通过这一番优化后,一旦同时进来两个线程,就有可能呈现空指针异样。

因而,怎么管制编译优化,让程序能正确运行?这是并发编程的第三道坎。

写在最初

并发编程是优良程序员的标记,而要做到这点,你得深刻理解:并发 Bug 是计算机谋求高性能的代价。

为了进步性能,计算机界的大神们做了各种优化,但却毁坏了程序的可见性、原子性、有序性。CPU 缓存带来了可见性问题,线程切换带来了原子性问题,编译优化带来了有序性问题。

对于这些问题,大神们再也搞不定了,咱们只能本人想方法解决了。

正文完
 0