前言
如今,几乎所有高级语言都支持并发操作,且无一例外都把并发相关内容都放到了高级编程当中。由此可见并发编程并非是哪一门编程语言独有,掌握了并发编程的思想,便如同打通了任督二脉,一通百通。当然并发编程也没有想象中那么难,步步为营,从源头解决并发问题。
硬件工程师的锅,我们来背
冯诺依曼是计算之父,经典的冯诺依曼结构沿用至今,计算机是由5大部分组成:1.运算器。2.控制器。3.存储设备。4.输入设备 5.输出设备。
图1-冯诺依曼结构(图片来自网络)
其中运算器和控制器也就是CPU,是计算机的大脑,运行速度是最快的。其次存储设备,我们的存储设备,运行速度比CPU慢很多。最后的输入设备,例如硬盘,也就是IO操作,比内存更慢。
图2-速度和价格
一方面是为了普及设备,让我们每个人都能用上物美价廉的计算机,另一方面为了均衡各个设备的速度。硬件工程师做了很多的优化,但正是这些优化,给我们软件工程师带来了很多意料之外的烦恼。
主要做了哪些优化呢?主要是以下3点:
- CPU缓存,均衡内存和CPU速度。
- 分时复用CPU,可以在IO操作时,让出CPU让其他进程线程使用CPU。
- 编译程序优化指令执行次序,使得缓存能够得到更加合理地利用。
硬件工程师通过优化,使得我们计算机的运行效率大大提高,但任何事情都是一把双刃剑,也给我们软件工程师带来了很多影响。
CPU缓存带来的可见性影响

图3-电脑配置(我的PC)
随着时代的不断发展,CPU已经进入了多核时代,现在购买的CPU基本都是8个核心起步,当我们有多个线程同时修改同一个变量,就会发现,数据一致性已经没法保证了,如图所示:
图4-可见性问题
线程A在CPU-1上修改变量V的值,线程B在CPU-2上同时修改变量V的值,那么变量V最终是多少呢?这个谁也说不清,因为线程B和线程A互相不可见!也就说无法保证线程之间的可见性。
(可见性:一个线程对共享变量的修改,另外一个线程能够立刻看到,我们称为可见性)
举个栗子-多线程数字相加
假设,我们写一个程序,从1加到一万,开启线程A和B,那么结果是多少呢,理论上来讲是20000,但实际结果确大相径庭。代码如下。
public class Demo1 {public static void main(String[] args) throws InterruptedException {System.out.println(new Demo1().addThread());}private static long count = 0;private void add() {int idx = 0;while(idx++ < 10000) {count += 1;}}public long addThread() throws InterruptedException {final Demo1 test = new Demo1();// 创建两个线程,执行add()操作Thread thread1 = new Thread(()->{test.add();});Thread thread2 = new Thread(()->{test.add();});// 启动两个线程thread1.start();thread2.start();// 等待两个线程执行结束thread1.join();thread2.join();return count;}}
这段代码无论执行多少次,它的结果总是在10000和20000之间,为什么会出现这种情况呢?
线程A 和 线程B同时执行,都把变量值加载到了CPU中计算,再把结果加载到缓存当中。例如第一次线程A和线程B加载到CPU中都为0,然后各自相加1,CPU缓存结果为1,写入内存结果也为1,这样A和B都是根据各自CPU缓存中的结果进行操作,当我们相加次数越大,最终结果越趋近一个线程计算的结果。
分时系统对原子性带来的影响
什么是分时系统呢?即使我们CPU是单核的,也可以一边听歌,一边写博客,后台还挂着游戏。
这就是分时系统所做的事情,分时系统更重要的是可以提高我们CPU的利用效率,当一个进程或线程进行IO操作时,就可以把CPU让出来,让另外一个进程或线程使用CPU进行计算。
为什么分时系统,线程切换会对原子性有影响呢?
类似于Java这样的高级语言,一个简单的操作需要多条指令来执行。例如: x = x + 1 ,对于高级语言来讲只是简单的加操作,对应到指令他需要多个步骤:1.把变量从内存加载到CPU。2.执行+1操作。3.再把新的结果写入内存。
这其中的每一步都可能会进行线程切换,CPU的原子性只能保证指令级别的。
编译优化带来的有序性影响
我们使用高级语言进行编码,编译器为了优化性能,可能会调整代码的执行顺序。a = 7, b = c.调整为 b = c ,a = 7 .这种没什么问题。举个简单的例子,单例模式中我们会有双重校验锁的写法。
public class Test2 {private static Test2 singleton;public static Test2 getSingleton() {if (null == singleton) {synchronized (Test.class) {if (null == singleton) {singleton = new Test2();}}}return singleton;}}
代码本身是没有问题的,通过加锁的方式,一个线程只能创建一个对象。问题就出在new 这个操作上,一个new操作执行逻辑是这样的:
- 分配内存空间。
- 将地址赋给内存变量。
- 初始化对象。
假设A线程执行到了 singleton = new Test2() ,new 对象的时候走到第二步,这时singleton 已经不为空,但对象未还没有初始化,线程切换到了B,线程B走到 if (null == singleton), singleton 不为空,但对象未能初始化。
B线程发现singleton并不为空,但是我们的对象还没有初始化,就会发生空指针异常。
解决可见性 有序性 —happens-before六项原则
final,volatile,sycchronized关键字和happens-Before原则是是我们解决问题的大杀器,接下来逐一和大家讲解一下。
先以一段简单的代码为例:
public class Demo2 {int num = 0;volatile boolean bool = false;// 修改变量public void writer() {num = 66;bool = true;}// 修改布尔值public void reader() {if (bool == true) {System.out.println(num);}}public static void main(String[] args) throws InterruptedException {Demo2 demo2 = new Demo2();Thread thread1 = new Thread(()->{demo2.writer();demo2.reader();});Thread thread2 = new Thread(()->{demo2.writer();demo2.reader();});thread1.start();thread2.start();}}
这段代码的结果是多少呢?到底是66呢还是0呢?
我们运行的结果是66,无论多少次都是66。对于这个结果是66的原因主要是Java中的happens-Before规则。
1.程序顺序性规则。
前面执行的写代码,对于后面执行的读代码是可见的。也就是说,writer中的 num = 66 对于后面read代码是可见。 避免编译优化的影响。
2.第二个规则是针对volatile关键字的。
voltatile修饰的变量,前面执行的写操作对于其他线程的读操作是可见的。也就是说 bool = true,其他线程读到的都是ture。
3.传递性规则。
这个传递性规则比较难理解哈,假设A线程执行了write操作,此时上下文切换,线程B执行reder操作,num = 66 是先于 bool = true的,bool = true 是先于 bool == true的, 那么num = 66 也是先于 输出操作的。
这三个规则保证了,在多线程的情况下,我们结果一直是66.
4.管程中锁的规则
管程 是通用的同步源句,Java中通过Synchronize实现。
synchronized (this) { //此处自动加锁// x是共享变量,初始值=10if (this.x < 12) {this.x = 12;}} //此处自动解锁
Java 中 会自动加锁,自动解锁。加锁和解锁这个区域称作临界区。
这个规则是:线程A执行了这个临界区的操作,x = 12,那么线程B 进入代码块后,能够看到 x = 12.
5.线程start()原则
线程A启动子线程B后,子线程B能够看到线程A的所有操作。
也就是说,start操作前的操作对start后操作都是可见的。
6.Join规则
也就是说主线程A等待B线程操作完成,join前的所有操作线程A都是可见的。
解决原子性-锁

图5-Java主流锁(图片来自美团技术团队)
Java中的锁(图5)的类型很多,不一一为大家讲解了,主要和大家分享一下锁是如何解决原子性问题的。
上面我们谈到,CPU中的原子性只能保证指令级别的,造成原子性的最主要的原因就是线程切换。那我们如何才能解决原子性问题呢?换个思路考虑,只需操作变量时,只允许一个线程访问,也就保证了原子性。
很容易能够想到锁的结构:
图6-锁的基本模型
这样看起来已经足够解决我们的问题,但是出现下面这种问题怎么办呢?
图7-我家大门被锁
我们需要明确两件事情,第一件:我们锁的是什么?第二件事:这把锁谁能够访问? 一般来讲,我们锁的是共享的资源,能开这把锁的是当前对象或者是Class。也就是说 上锁的对象 和 锁的资源需要对应起来。
解决了这个问题,还会出现这种问题?
图8-我家大门被好多大佬上了锁
图9-奈落的锁了多个资源
我还需要避免另外一种情况,锁 和 受保护的资源之间的关系可以是1 对 多,1 对 1(图9),但不能是 多 对 1(图8)
结语
硬件工程师为了优化计算机,做出了杰出的贡献,软件工程也需要不断的优化程序,在大家的联手之下,希望能够服务好我们的每一位客户。
参考文献
https://tech.meituan.com/2018/11/15/java-lock.html Java的琐事
极客时间:Java并发实战
