【漫画】JAVA并发编程三大Bug源头(可见性、原子性、有序性)

简介: 并发程序是一把双刃剑,提升性能的同时带来了无形的bug。首先要知道问题出自哪里,才可能把问题解决。本文通过漫画形式,走进JAVA并发编程之三大BUG源头,可见性、原子性、有序性。

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

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

序幕

con2

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的内存模型,我绘制了一张图来描述:

JAVA_

主内存(Main Memory)

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

工作内存(Working Memory)

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

线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存中的变量。
线程之间无法直接访问对方的工作内存中的变量,线程间变量的传递均需要通过主内存来完成

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

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

con3_1

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);
}
​

con3_2

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

原子性问题

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

我们都知道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,下面这张图形象表示了该历程:

_

image

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

有序性问题

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 的成员变量就可能触发空指针异常,如图所示:

_

con4

总结

并发程序是一把双刃剑,一方面大幅度提升了程序性能,另一方面带来了很多隐藏的无形的难以发现的bug。我们首先要知道并发程序的问题在哪里,只有确定了“靶子”,才有可能把问题解决,毕竟所有的解决方案都是针对问题的。并发程序经常出现的诡异问题看上去非常无厘头,但是只要我们能够深刻理解可见性、原子性、有序性在并发场景下的原理,很多并发 Bug 都是可以理解、可以诊断的。
总结一句话:可见性是缓存导致的,而线程切换会带来的原子性问题,编译优化会带来有序性问题。至于怎么解决呢!欲知后事如何,且听下回分解。

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

相关文章
|
2天前
|
Java 开发者
Java并发编程:CountDownLatch实战解析
Java并发编程:CountDownLatch实战解析
|
12天前
|
NoSQL Java 关系型数据库
超全 Java 学习路线,帮你系统掌握编程的超详细 Java 学习路线
本文为超全Java学习路线,涵盖基础语法、面向对象编程、数据结构与算法、多线程、JVM原理、主流框架(如Spring Boot)、数据库(MySQL、Redis)及项目实战等内容,助力从零基础到企业级开发高手的进阶之路。
95 1
|
19天前
|
算法 Java
Java多线程编程:实现线程间数据共享机制
以上就是Java中几种主要处理多线程序列化资源以及协调各自独立运行但需相互配合以完成任务threads 的技术手段与策略。正确应用上述技术将大大增强你程序稳定性与效率同时也降低bug出现率因此深刻理解每项技术背后理论至关重要.
49 16
|
27天前
|
安全 Java Shell
Java模块化编程(JPMS)简介与实践
本文全面解析Java 9模块化系统(JPMS),帮助开发者解决JAR地狱、类路径冲突等常见问题,提升代码的封装性、性能与可维护性。内容涵盖模块化核心概念、module-info语法、模块声明、实战迁移、多模块项目构建、高级特性及最佳实践,同时提供常见问题和面试高频题解析,助你掌握Java模块化编程精髓,打造更健壮的应用。
|
28天前
|
安全 算法 Java
Java泛型编程:类型安全与擦除机制
Java泛型详解:从基础语法到类型擦除机制,深入解析通配符与PECS原则,探讨运行时类型获取技巧及最佳实践,助你掌握泛型精髓,写出更安全、灵活的代码。
|
30天前
|
安全 Java 数据库连接
2025 年最新 Java 学习路线图含实操指南助你高效入门 Java 编程掌握核心技能
2025年最新Java学习路线图,涵盖基础环境搭建、核心特性(如密封类、虚拟线程)、模块化开发、响应式编程、主流框架(Spring Boot 3、Spring Security 6)、数据库操作(JPA + Hibernate 6)及微服务实战,助你掌握企业级开发技能。
215 3
|
1月前
|
Java
Java编程:理解while循环的使用
总结而言, 使用 while 迴圈可以有效解决需要多次重复操作直至特定條件被触发才停止執行任务场景下问题; 它简单、灵活、易于实现各种逻辑控制需求但同时也要注意防止因邏各错误导致無限迁璇発生及及時處理可能発生异常以确保程序稳定运作。
174 0
|
6天前
|
数据采集 存储 弹性计算
高并发Java爬虫的瓶颈分析与动态线程优化方案
高并发Java爬虫的瓶颈分析与动态线程优化方案
Java 数据库 Spring
41 0
|
27天前
|
缓存 并行计算 安全
关于Java多线程详解
本文深入讲解Java多线程编程,涵盖基础概念、线程创建与管理、同步机制、并发工具类、线程池、线程安全集合、实战案例及常见问题解决方案,助你掌握高性能并发编程技巧,应对多线程开发中的挑战。