【Java】Java并发编程-根源:为什么转账后,余额总是对不上?

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

然而,过了一段时间,用户居然可以无视余额,直接提现。眼看就要失业了,问题究竟出在哪里呢?

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

什么是并发问题

并发,就是在很短的时间内,有很多个请求同时发到了服务器上。这时候,你如果没有处理好,就出现了并发 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 上面,整个结构非常简单。

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

【Java】Java并发编程-根源:为什么转账后,余额总是对不上?

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

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

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

【Java】Java并发编程-根源:为什么转账后,余额总是对不上?

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

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 毫秒后,操作系统做“线程切换”,这个线程休眠,换另外一个线程执行。

你可以看下面这幅图,加深一下理解。

【Java】Java并发编程-根源:为什么转账后,余额总是对不上?

这带来了两个好处。

第一,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++ 是这样的,就像下面这副图一样。

【Java】Java并发编程-根源:为什么转账后,余额总是对不上?

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

【Java】Java并发编程-根源:为什么转账后,余额总是对不上?

在这个例子,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 对象;

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

【Java】Java并发编程-根源:为什么转账后,余额总是对不上?

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

写在最后

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

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

对于这些问题,大神们再也搞不定了,我们只能自己想办法解决了。

以上是 【Java】Java并发编程-根源:为什么转账后,余额总是对不上? 的全部内容, 来源链接: utcz.com/a/94162.html

回到顶部