原创声明:本文转载自公众号【胖滚猪学编程】​

某日,胖滚猪写的代码导致了一个生产bug,奋战到凌晨三点依旧没有解决问题。胖滚熊一看,只用了一个volatile就解决了。并告知胖滚猪,这是并发编程导致的坑。这让胖滚猪坚定了要学好并发编程的决心。。于是,开始了我们并发编程的第一课。

序幕

BUG源头之一:可见性

刚刚我们说到,CPU缓存可以提高程序性能,但缓存也是造成BUG源头之一,因为缓存可以导致可见性问题。我们先来看一段代码:

private static int count = 0;
public static void main(String[] args) throws Exception {
Thread th1 = new Thread(() -> {
count = 10;
});
Thread th2 = new Thread(() -> {
//极小概率会出现等于0的情况
System.out.println("count=" + count);
});
th1.start();
th2.start();
}

按理来说,应该正确返回10,但结果却有可能是0。

一个线程对变量的改变另一个线程没有get到,这就是可见性导致的bug。一个线程对共享变量的修改,另外一个线程能够立刻看到,我们称为可见性。

那么在谈论可见性问题之前,你必须了解下JAVA的内存模型,我绘制了一张图来描述:

主内存(Main Memory)

主内存可以简单理解为计算机当中的内存,但又不完全等同。主内存被所有的线程所共享,对于一个共享变量(比如静态变量,或是堆内存中的实例)来说,主内存当中存储了它的“本尊”。

工作内存(Working Memory)

工作内存可以简单理解为计算机当中的CPU高速缓存,但准确的说它是涵盖了缓存、写缓冲区、寄存器以及其他的硬件和编译器优化。每一个线程拥有自己的工作内存,对于一个共享变量来说,工作内存当中存储了它的“副本”。

线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存中的变量。

线程之间无法直接访问对方的工作内存中的变量,线程间变量的传递均需要通过主内存来完成

现在再回到刚刚的问题,为什么那段代码会导致可见性问题呢,根据内存模型来分析,我相信你会有答案了。当多个线程在不同的 CPU 上执行时,这些线程操作的是不同的 CPU 缓存。比如下图中,线程 A 操作的是 CPU-1 上的缓存,而线程 B 操作的是 CPU-2 上的缓存

由于线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存中的变量,那么对于共享变量V,它们首先是在自己的工作内存,之后再同步到主内存。可是并不会及时的刷到主存中,而是会有一定时间差。很明显,这个时候线程 A 对变量 V 的操作对于线程 B 而言就不具备可见性了 。

private volatile long count = 0;

private void add10K() {
int idx = 0;
while (idx++ < 10000) {
count++;
}
}

public static void main(String[] args) throws InterruptedException {
TestVolatile2 test = new TestVolatile2();
// 创建两个线程,执行 add() 操作
Thread th1 = new Thread(()->{
test.add10K();
});
Thread th2 = new Thread(()->{
test.add10K();
});
// 启动两个线程
th1.start();
th2.start();
// 等待两个线程执行结束
th1.join();
th2.join();
// 介于1w-2w,即使加了volatile也达不到2w
System.out.println(test.count);
}

原创声明:本文转载自公众号【胖滚猪学编程】​

原子性问题

一个不可分割的操作叫做原子性操作,它不会被线程调度机制打断的,这种操作一旦开始,就一直运行到结束,中间不会有任何线程切换。注意线程切换是重点!

我们都知道CPU资源的分配都是以线程为单位的,并且是分时调用,操作系统允许某个进程执行一小段时间,例如 50 毫秒,过了 50 毫秒操作系统就会重新选择一个进程来执行(我们称为“任务切换”),这个 50 毫秒称为“时间片”。而任务的切换大多数是在时间片段结束以后,

那么线程切换为什么会带来bug呢?因为操作系统做任务切换,可以发生在任何一条CPU 指令执行完!注意,是 CPU 指令,CPU 指令,CPU 指令,而不是高级语言里的一条语句。比如count++,在java里就是一句话,但高级语言里一条语句往往需要多条 CPU 指令完成。其实count++包含了三个CPU指令!

  • 指令 1:首先,需要把变量 count 从内存加载到 CPU 的寄存器;
  • 指令 2:之后,在寄存器中执行 +1 操作;
  • 指令 3:最后,将结果写入内存(缓存机制导致可能写入的是 CPU 缓存而不是内存)。

小技巧:可以写一个简单的count++程序,依次执行javac TestCount.java,javap -c -s TestCount.class得到汇编指令,验证下count++确实是分成了多条指令的。

volatile虽然能保证执行完及时把变量刷到主内存中,但对于count++这种非原子性、多指令的情况,由于线程切换,线程A刚把count=0加载到工作内存,线程B就可以开始工作了,这样就会导致线程A和B执行完的结果都是1,都写到主内存中,主内存的值还是1不是2,下面这张图形象表示了该历程:

原创声明:本文转载自公众号【胖滚猪学编程】​

有序性问题

JAVA为了优化性能,允许编译器和处理器对指令进行重排序,即有时候会改变程序中语句的先后顺序:

例如程序中:“a=6;b=7;”编译器优化后可能变成“b=7;a=6;”只是在这个程序中不影响程序的最终结果。

有序性指的是程序按照代码的先后顺序执行。但是不要望文生义,这里的顺序不是按照代码位置的依次顺序执行指令,指的是最终结果在我们看起来就像是有序的。

重排序的过程不会影响单线程程序的执行,却会影响到多线程并发执行的正确性。有时候编译器及解释器的优化可能导致意想不到的 Bug。比如非常经典的双重检查创建单例对象。

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

你可能会觉得这个程序天衣无缝,我两次判断是否为空,还用了synchronized,刚刚也说了,synchronized 是独占锁/排他锁。按照常理来说,应该是这么一个逻辑:

线程A和B同时进来,判断instance == null,线程A先获取了锁,B等待,然后线程 A 会创建一个 Singleton 实例,之后释放锁,锁释放后,线程 B 被唤醒,线程 B 再次尝试加锁,此时加锁会成功,然后线程 B 检查 instance == null 时会发现,已经创建过 Singleton 实例了,所以线程 B 不会再创建一个 Singleton 实例。

但多线程往往要有非常理性的思维,我们先分析一下 instance = new Singleton()这句话,根据刚刚原子性说到的,一句高级语言在cpu层面其实是多条指令,这也不例外,我们也很熟悉new了,它会分为以下几条指令:

1、分配一块内存 M;

2、在内存 M 上初始化 Singleton 对象;

3、然后 M 的地址赋值给 instance 变量。

如果真按照上述三条指令执行是没问题的,但经过编译优化后的执行路径却是这样的:

1、分配一块内存 M;

2、将 M 的地址赋值给 instance 变量;

3、最后在内存 M 上初始化 Singleton 对象

假如当执行完指令 2 时恰好发生了线程切换,切换到了线程 B 上;而此时线程 B 也执行 getInstance() 方法,那么线程 B 在执行第一个判断时会发现 instance != null ,所以直接返回 instance,而此时的 instance 是没有初始化过的,如果我们这个时候访问 instance 的成员变量就可能触发空指针异常,如图所示:

总结

并发程序是一把双刃剑,一方面大幅度提升了程序性能,另一方面带来了很多隐藏的无形的难以发现的bug。我们首先要知道并发程序的问题在哪里,只有确定了“靶子”,才有可能把问题解决,毕竟所有的解决方案都是针对问题的。并发程序经常出现的诡异问题看上去非常无厘头,但是只要我们能够深刻理解可见性、原子性、有序性在并发场景下的原理,很多并发 Bug 都是可以理解、可以诊断的。

总结一句话:可见性是缓存导致的,而线程切换会带来的原子性问题,编译优化会带来有序性问题。至于怎么解决呢!欲知后事如何,且听下回分解。

原创声明:本文转载自公众号【胖滚猪学编程】​

本文转载自公众号【胖滚猪学编程】 用漫画让编程so easy and interesting!欢迎关注!形象来源于微信表情包【胖滚家族】喜欢可以下载哦~

【漫画】JAVA并发编程三大Bug源头(可见性、原子性、有序性)的更多相关文章

  1. Java并发编程实战 01并发编程的Bug源头

    摘要 编写正确的并发程序对我来说是一件极其困难的事情,由于知识不足,只知道synchronized这个修饰符进行同步. 本文为学习极客时间:Java并发编程实战 01的总结,文章取图也是来自于该文章 ...

  2. Java并发编程三个性质:原子性、可见性、有序性

      并发编程 并发程序要正确地执行,必须要保证其具备原子性.可见性以及有序性:只要有一个没有被保证,就有可能会导致程序运行不正确  线程不安全在编译.测试甚至上线使用时,并不一定能发现,因为受到当时的 ...

  3. Java并发编程(四)可见性

    除了使用synchronized关键字用于实现原子性或者确定"临界区(Critical Section)",还有一个重要的方面就是:内存的可见性(Memory Visibility ...

  4. 【Java并发编程】:内存可见性

    加锁(synchronized同步)的功能不仅仅局限于互斥行为,同时还存在另外一个重要的方面:内存可见性.我们不仅希望防止某个线程正在使用对象状态而另一个线程在同时修改该状态,而且还希望确保当一个线程 ...

  5. 【漫画】JAVA并发编程之并发模拟工具

    原创声明:本文来源于公众号[胖滚猪学编程],转载请注明出处. 上一节[漫画]JAVA并发编程三大Bug源头(可见性.原子性.有序性)我们聊了聊并发编程的三个bug源头,这还没开始进入并发世界,胖滚猪就 ...

  6. Java并发编程实战 03互斥锁 解决原子性问题

    文章系列 Java并发编程实战 01并发编程的Bug源头 Java并发编程实战 02Java如何解决可见性和有序性问题 摘要 在上一篇文章02Java如何解决可见性和有序性问题当中,我们解决了可见性和 ...

  7. Java并发编程实战 04死锁了怎么办?

    Java并发编程文章系列 Java并发编程实战 01并发编程的Bug源头 Java并发编程实战 02Java如何解决可见性和有序性问题 Java并发编程实战 03互斥锁 解决原子性问题 前提 在第三篇 ...

  8. Java并发编程实战 05等待-通知机制和活跃性问题

    Java并发编程系列 Java并发编程实战 01并发编程的Bug源头 Java并发编程实战 02Java如何解决可见性和有序性问题 Java并发编程实战 03互斥锁 解决原子性问题 Java并发编程实 ...

  9. 【Java并发编程】并发编程大合集-值得收藏

    http://blog.csdn.net/ns_code/article/details/17539599这个博主的关于java并发编程系列很不错,值得收藏. 为了方便各位网友学习以及方便自己复习之用 ...

随机推荐

  1. Java课程设计之——爬虫篇

    主要使用的技术 Httplcient Jsoup 多线程 dao模式 网络爬虫简介 网络爬虫(又称为网页蜘蛛,网络机器人,在FOAF社区中间,更经常的称为网页追逐者),是一种按照一定的规则,自动地抓取 ...

  2. JAVA获取EXCEL列头

    FileInputStream fileInputStream = new FileInputStream(rootPath + path + "/" + fileName); L ...

  3. Ant安装与配置

    1. 到apache 官网去下载最新版本的ant,http://ant.apache.org/:下载后直接解压缩到电脑上,不需要安装: 2.环境变量配置: 2.1 ->计算机右键->属性- ...

  4. Linux学习笔记(三)目录和文件都能操作的命令

    目录和文件都能操作的命令 rm cp mv rm 英文原意:remove files or directories 功能:删除文件或目录 语法:rm 选项[-fir] 文件或目录 rm -f 强制删除 ...

  5. 词向量表示:word2vec与词嵌入

    在NLP任务中,训练数据一般是一句话(中文或英文),输入序列数据的每一步是一个字母.我们需要对数据进行的预处理是:先对这些字母使用独热编码再把它输入到RNN中,如字母a表示为(1, 0, 0, 0, ...

  6. 神奇的Kivy,让Python快速开发移动app

    随着移动互联网的不断发展,手机.Pad等移动终端已经被普遍使用,充斥在人们的工作.学习和生活中,越来越多的程序都转向移动终端,各类app应用相拥而至. Kivy作为Python的Android和IOS ...

  7. 数值计算方法实验之Hermite 多项式插值 (Python 代码)

    一.实验目的 在已知f(x),x∈[a,b]的表达式,但函数值不便计算,或不知f(x),x∈[a,b]而又需要给出其在[a,b]上的值时,按插值原则f(xi)= yi(i= 0,1…….,n)求出简单 ...

  8. Python数据分析:大众点评数据进行选址

    前言 本文的文字及图片来源于网络,仅供学习.交流使用,不具有任何商业用途,版权归原作者所有,如有问题请及时联系我们以作处理. 作者:砂糖侠 如果你处于想学Python或者正在学习Python,Pyth ...

  9. Scrapy中的crawlspider

    crawlspider 能自动的获取url并提交请求 命令:scrapy genspider -t crawl spidername 'example.cn' 所导入的模块 # -*- coding: ...

  10. Windows VHD Create, Attach, 获得Disk序号

    // create_vhd.cpp : Defines the entry point for the console application. // #include "stdafx.h& ...