一、经典面试题

有没有在JAVA笔试或面试中遇见过这样的题目:统计服务器某个接口的访问次数

这时我们容易大手一挥写出来下面的代码:
75200600_1621856049.png
这个类中accessCount字段表示接口被访问的次数, 每次访问接口的时候就调用一次access()方法,访问次数+1,累积下来,就能统计出接口被访问的次数了。

也许有些小白同学写出这样的方法会沾沾自喜,觉得离收到offer更进了一步,但是有点并发编程经验的同学都能想到这样一种常见的场景,就是接口很可能被多个线程同时访问,即access()方法被多个线程调用。那么问题来了:多线程调用access()方法时,接口访问次数统计的结果是否能保证准确呢?

答案显而易见:不能

二、内存模型概念

接下来分析一下为什么上面统计的结果会有问题?我们先来简单理解一下内存模型的概念,大家都知道,我们的程序是运行在Java虚拟机上面的,Java虚拟机本身有自己的内存模型, Java的内存模型和计算机的CPU内存模型又有很多相同之处,这里的话,先稍微介绍一下CPU的内存模型。
计算机在执行程序的时候,每条指令都是在CPU中执行的,而在CPU执行指令的过程中会涉及到数据的读取和写入操作,而在计算机运行过程中所有的数据都是存放在主存中的(比如一台普通的4核8G机器,这个8G就是指主存的容量),CPU则是从主存中读取数据进行运算。如图1:
图1.png
图1

但是,这样会存在一个问题,由于CPU执行速度非常快,比计算机主存的读取和写入速度快了很多,这样就会导致CPU的执行速度大大下降。
因此,每个CPU都会自带一个高速缓冲区,在运行的时候,会将需要运行的数据从计算机主存复制到CPU的高速缓冲区中,然后CPU再基于高速缓冲区的数据进行运算,运算结束之后,再将高速缓冲区的数据刷新到主存中。这样CPU的执行指令的速度就可以大大提升。如图2:
image.png

三、Java内存模型

JVM定义的内存模型和上面所说的内存模型有很多想似之处,只是JVM的模型屏蔽了不同的操作系统和底层硬件之间的内存访问差异,实现了在各个平台都能达到一致的内存访问效果。Java的内存模型如图3所示:
image.png

JVM启动之后,操作系统会围JVM进程分配一定的内存空间,这部分内存空间就称为“主内存”,Java中的线程在执行的过程中,会先将数据从主内存中复制到线程的工作内存,然后再执行计算,计算之后,再把计算结果刷到“主内存”中。
好了,了解了Java的内存模型基本原理之后,我们再来分析一下接口访问计计数器为什么统计结果不对。

四、基于内存模型一步步分析缓存一致性问题

假设现在有两个线程同时访问这个接口的access()方法,两个线程都执行了accessCount++,在内存中是怎么样执行的呢?
首先我们要明白,计算需要执行accessCount++这个语句,需要分为以下3个步骤:
1、从主存中读取accessCount的值
2、将accessCount的值进行加1
3、将accessCount的值写回主存中

先来看看第1步,假设两个线程同时来执行accessCount()方法,如图4:
72036100_1621856050.png
上面图4在第1个步骤的时候,线程1和线程2都会把accessCount的值从主存中复制到线程所属的工作内存中,两个线程此时得到的accessCount的值都是0.
接着两个线程执行第2步操作将accessCount的值进行加1。如图5所示:
kpevzdfz07wk.jpg
图5中,线程1和线程2都进行第2步的计算然后线程1得到的结果是accessCount=1,线程2得到的结果也是accessCount=1.
接着两个线程都到了第3步,将accessCount的值写回主存中,如图6所示:
kpew031z0reh.jpg
图6

线程1和线程2都计算完之后就会将计算结果刷新回主存,特别注意一下图中红框的内容,这是两个线程把计算结果刷新回主存的步骤,两个红框中操作的执行顺序不分先后(在实际运行情况,两个操作的顺序是随机的,可能是线程1先刷新,也可能是线程2先刷新),但是这不影响结果,因为无论是线程1还是线程2,写回主存的结果都是accessCount =1 。
但是实际上,我们观察到是2个线程都执行了一次access()方法,按照预期来说,accessCount的值应该是等于2才对。
这种多个线程访问同一个对象时,调用这个对象的方法得到不正确的结果。这种问题成为线程问题。

通常来说,解决线程安全问题都有一些常见的解决方案,这部分内容会在下一篇文章详细分析,但是在此之前,我们可以思考一下我们这个线程安全问题为什么会产生呢?

回顾一下accessCount++这个代码,需要分为3个步骤:
1、从主存中读取accessCount的值
2、将accessCount的值进行加1
3、将accessCount的值写回主存中
经过我们前面的分析,思考一下,如何才能让计算结果为accessCount=2呢?

很显然,如果线程2能等待线程1把3个步骤都执行完,线程2才开始执行第1个步骤就可以得到正确的答案。也就是说,假如能将这3个步骤看做一个整体,要不全部执行完、要不全部不执行,就能解决问题了。
这里需要引申出一个并发中很重要的概念:原子性
原子性即一个操作或者多个操作,要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。
对应前面的场景,就是如果我们能保证accessCount++这个操作的3个步骤是原子性的,比如线程1开始了第一个 步骤,那线程2如果要执行的话那就等着吧,等到线程1把3个步骤都执行完,线程2才能开始第一个步骤。那这样就能解决线程安全的问题了!
五、总结
总结一下本文的内容,首先从一个常见的面试题:统计服务器接口的访问次数,引出来了多线程高并发访问共享变量的问题。

接着简单介绍了一下计算机CPU内存模型,在这基础上,咱们又去理解了Java的内存模型,紧接着基于Java的内存模型一步一图分析线程安全问题产生的原因。

最后提出一些解决问题的思路,从而引出并发编程中一个很重要的概念:原子性。下篇文章会对一些常见的并发问题进行分析,同时给出一些并发问题常见的解决方案。