编写正确的程序难,编写正确的并发程序则是难上加难。既然这么难为何还要并发,单线程执行很差吗?为了快呀,点个连接你愿意等1分钟吗?,别说等一分钟了,要是有个网页让我等超过10秒钟,我就立刻要关掉了。java
咱们编写的代码在计算机中运行,那么它确定会用到计算机中的资源,通常都逃不过cpu、内存以及I/O(文件I/O或者网络I/O等)。可是这三者速度上有极大的差别。web
CPU的速度远远快于内存,而内存的速度又远远远快于I/O。数据库
❝比喻: CPU速度至关于 火箭,内存速度至关于 高铁,I/O速度至关于 步行。编程
❞
而咱们的程序运行的快慢其实是取决于最慢的那个操做--I/O操做,仿佛在这个时候CPU再快都没啥做用。缓存
❝咱们通常都说尽量少的查询数据库(batch的方式更好),就是为了较少I/O操做网络
❞
为了合理使用CPU性能,平衡这三者间的速度差。计算机体系结果、操做系统、编译程序都作出了贡献,主要体如今:多线程
单核CPU的时候,全部线程操做的都是同一个CPU的缓存,一个线程对另缓存的写,对另外一个线程来讲必定是可见的。例如在下面的图中,线程 A 和线程 B 都是操做同一个 CPU 里面的缓 存,因此线程 A 更新了变量 V 的值,那么线程 B 以后再访问变量 V,获得的必定是 V 的最新值(线程 A 写过的值)。并发
「一个线程对共享变量的修改,另一个线程可以马上看到,咱们称为可见性。」编辑器
可是随着多核时代的来临,每颗 CPU 都有本身的缓存,这时 CPU 缓存与内存的数据一致性就没那么容易解决了,当多个线程在不一样的 CPU 上执行时,这些线程操做的是不一样的 CPU 缓存。好比 下图中,线程 A 操做的是 CPU1 上的缓存,而线程 B 操做的是 CPU2 上的缓存,很明显,这个时候线程 A 对变量 V 的操做对于线程 B 而言就不具有可见性了性能
public class Counter {
int v = 0; public void add() { for(int i = 0; i < 10000; i++) { v += 1; } } public static void main(String[] args) throws InterruptedException { Counter c = new Counter(); Thread t1 = new Thread(() -> { c.add(); }); Thread t2 = new Thread(() -> { c.add(); }); // 启动线程 t1.start(); t2.start(); // 等待两个线程执行结束 t1.join(); t2.join(); System.out.println(c.v); } } 复制代码
好比上面的代码,每次执行的结果都不同,执行结果也是介于10000和20000之间。
CPU cache中的值何时刷新到内存(主存)中是不肯定的,因此有可能某个后启动的线程读取到的值不必定是1,而是其余值(代码所示的两个线程启动是存在时间差的)。
你可知道电脑中的进程是交替运行的,你能一边听歌一边看电影都归功于这个进程切换。操做系统容许某个进程执行一小段时间,例如 50 毫秒,过了 50 毫秒操做系统就会从新选 择一个进程来执行(咱们称为“任务切换”),这个 50 毫秒称为“时间片”。
Java 并发程序都是基于多线程的,天然也会涉及到任务切换,也许你想不到,任务切换居然也是并发编程里诡异 Bug 的源头之一。任务切换的时机大多数是在时间片结束的时候, 咱们如今基本都使用高级语言编程,高级语言里一条语句每每须要多条 CPU 指令完成,例如上面代码中的v += 1,至少须要三条 CPU 指令。
操做系统作任务切换,能够发生在任何一条CPU 指令执行完,是的,是 CPU 指令,而不是高级语言里的一条语句。对于上面的三条指令来讲,咱们假设 v=0,若是线程 A 在 指令 1 执行完后作线程切换,线程 A 和线程 B 按照下图的序列执行,那么咱们会发现两个线程都执行了 v+=1 的操做,可是获得的结果不是咱们指望的 2,而是 1。
咱们都知道编译器为了优化性能,是会调整语句顺序的。好比下面的代码
int a = 1; long b = 2L; 复制代码
编译器优化以后可能会变成
long b = 2L;
int a = 1;
复制代码
虽然优化后不影响执行结果,不过有时候编译器以及解释器的优化会带来意想不到的结果。
还记得java中获取单例对象的双重检查吗?
public class Singleton {
static Singleton instance; static Singleton getInstance() { if(instance == null) { synchronized(Singleton.class) { if(instance == null) instance = new Singleton(); } } return instance; } } 复制代码
实际上不能保证上面的代码有效,当咱们经过返回的Singleton对象访问其成员变量,就有可能触发空指针异常。 instance = new Singleton();
不是原子操做,它由分配空间,初始化对象的字段以及为instance分配地址的多条指令组成。
为了显示实际发生的状况,我使用一些伪代码扩展instance = new Singleton();
并内联对象初始化代码。
public class Singleton {
static Singleton instance; static Singleton getInstance() { if(instance == null) { synchronized(Singleton.class) { if(instance == null) pointer = allocate(); pointer.field1 = initField1(); pointer.field2 = initField2(); instance = pointer; } } return instance; } } 复制代码
为了提升总体性能,某些编译器,内存系统或处理器可能会对指令进行从新排序,例如在初始化对象的字段以前移动 instance = pointer。那么代码就会变成下面这样
public class Singleton {
static Singleton instance; static Singleton getInstance() { if(instance == null) { synchronized(Singleton.class) { if (instance == null) pointer = allocate(); instance = pointer; pointer.field1 = initField1(); pointer.field2 = initField2(); } } return instance; } } 复制代码
「这种从新排序是合法的,由于instance = pointer;与初始化字段的指令之间没有数据依赖性。」 可是,这种从新排序(以某些执行顺序)可能致使其余线程看到instance的非null值,但访问了该对象的未初始化字段就会出错。
只要在写代码的时候充分考虑上面说的三种状况,那么必定能够帮助你抽丝剥茧的排查多线程下遇到的问题。
巨人肩膀: 「极客时间--<java并发编程实战>」
你的关注是对我最大的顾虑,是兄弟就关注我(狗头保命)