【漫画】JAVA并发编程三大Bug源头(可见性、原子性、有序性)
原创声明:本文转载自公众号【胖滚猪学编程】
某日,胖滚猪写的代码导致了一个生产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源头(可见性、原子性、有序性)的更多相关文章
- Java并发编程实战 01并发编程的Bug源头
摘要 编写正确的并发程序对我来说是一件极其困难的事情,由于知识不足,只知道synchronized这个修饰符进行同步. 本文为学习极客时间:Java并发编程实战 01的总结,文章取图也是来自于该文章 ...
- Java并发编程三个性质:原子性、可见性、有序性
并发编程 并发程序要正确地执行,必须要保证其具备原子性.可见性以及有序性:只要有一个没有被保证,就有可能会导致程序运行不正确 线程不安全在编译.测试甚至上线使用时,并不一定能发现,因为受到当时的 ...
- Java并发编程(四)可见性
除了使用synchronized关键字用于实现原子性或者确定"临界区(Critical Section)",还有一个重要的方面就是:内存的可见性(Memory Visibility ...
- 【Java并发编程】:内存可见性
加锁(synchronized同步)的功能不仅仅局限于互斥行为,同时还存在另外一个重要的方面:内存可见性.我们不仅希望防止某个线程正在使用对象状态而另一个线程在同时修改该状态,而且还希望确保当一个线程 ...
- 【漫画】JAVA并发编程之并发模拟工具
原创声明:本文来源于公众号[胖滚猪学编程],转载请注明出处. 上一节[漫画]JAVA并发编程三大Bug源头(可见性.原子性.有序性)我们聊了聊并发编程的三个bug源头,这还没开始进入并发世界,胖滚猪就 ...
- Java并发编程实战 03互斥锁 解决原子性问题
文章系列 Java并发编程实战 01并发编程的Bug源头 Java并发编程实战 02Java如何解决可见性和有序性问题 摘要 在上一篇文章02Java如何解决可见性和有序性问题当中,我们解决了可见性和 ...
- Java并发编程实战 04死锁了怎么办?
Java并发编程文章系列 Java并发编程实战 01并发编程的Bug源头 Java并发编程实战 02Java如何解决可见性和有序性问题 Java并发编程实战 03互斥锁 解决原子性问题 前提 在第三篇 ...
- Java并发编程实战 05等待-通知机制和活跃性问题
Java并发编程系列 Java并发编程实战 01并发编程的Bug源头 Java并发编程实战 02Java如何解决可见性和有序性问题 Java并发编程实战 03互斥锁 解决原子性问题 Java并发编程实 ...
- 【Java并发编程】并发编程大合集-值得收藏
http://blog.csdn.net/ns_code/article/details/17539599这个博主的关于java并发编程系列很不错,值得收藏. 为了方便各位网友学习以及方便自己复习之用 ...
随机推荐
- c++动态数组的优点,创建和删除
动态数组可以有两种使用方式: 1:不能预先知道数组的大小使用动态数组 传统数组(静态数组)是需要在程序运行前,就指定大小,比如说 int i = 10; int a[i]; 这种就是不合法的. 因为函 ...
- Docker-准备Docker环境
1. 前言 要学习Docker,首先要搭建出虚拟机和docker环境.我的笔记本电脑是windows7系统,用VirtualBox创建虚拟机.虚拟机操作系统为CentOS7.5,Docker版本为18 ...
- EwoMail开源邮件服务器软件搭建
EwoMail开源邮件服务器软件简介 EwoMail是基于Linux的开源邮件服务器软件,集成了众多优秀稳定的组件,是一个快速部署.简单高效.多语言.安全稳定的邮件解决方案,帮助你提升运维效率,降低 ...
- ELK(日志审计系统)
ELk简介及工作流程 ELK即(Elasticsearch + Logstash + Kibana) 下载安装包 系统环境:Contos7.0 Java环境:Portal(这是历史下载地址,我的是 j ...
- JMeter分布式压测-常见问题之( Cannot start. localhost.localdomain is a loopback address)
问题描述: JMeter分布式测试时,以Linux系统作为被测服务器,在其中启动 jmeter-server 服务时出现异常,系统提示如下: [root@localhost bin]# ./jmete ...
- JMeter在Mac下的安装
其实不论操作系统是Windows.Unix(如Mac OS).Linux(如Ubuntu)等,JMeter所需要的基础环境配置都是类似的,本文介绍JMeter for MAC的安装与环境配置. JMe ...
- Linux常见提权
常见的linux提权 内核漏洞提权 查看发行版 cat /etc/issue cat /etc/*-release 查看内核版本 uname -a 查看已经安装的程序 dpkg -l rpm -qa ...
- [javascript]各种页面定时跳转(倒计时跳转)代码总结
(1)使用setTimeout函数实现定时跳转(如下代码要写在body区域内) <script type="text/javascript"> //3秒钟之后跳转到指定 ...
- kafka高吞吐量之消息压缩
背景 保证kafka高吞吐量的另外一大利器就是消息压缩.就像上图中的压缩饼干. 压缩即空间换时间,通过空间的压缩带来速度的提升,即通过少量的cpu消耗来减少磁盘和网络传输的io. 消息压缩模型 消息格 ...
- 从零开始学习docker之在docker中运行springboot项目
一.docker环境配置 首先需要一个安装了docker的服务器(本地或者云服务器),如果没有请看上文,传送门---https://www.cnblogs.com/wdfordream/p/12737 ...