【漫画】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 都是可以理解、可以诊断的。
总结一句话:可见性是缓存导致的,而线程切换会带来的原子性问题,编译优化会带来有序性问题。至于怎么解决呢!欲知后事如何,且听下回分解。

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

相关文章
|
1天前
|
安全 Java 调度
深入理解Java并发编程:线程安全与性能优化
【5月更文挑战第12天】 在现代软件开发中,多线程编程是提升应用程序性能和响应能力的关键手段之一。特别是在Java语言中,由于其内置的跨平台线程支持,开发者可以轻松地创建和管理线程。然而,随之而来的并发问题也不容小觑。本文将探讨Java并发编程的核心概念,包括线程安全策略、锁机制以及性能优化技巧。通过实例分析与性能比较,我们旨在为读者提供一套既确保线程安全又兼顾性能的编程指导。
|
2天前
|
安全 Java
深入理解Java并发编程:线程安全与性能优化
【5月更文挑战第11天】在Java并发编程中,线程安全和性能优化是两个重要的主题。本文将深入探讨这两个方面,包括线程安全的基本概念,如何实现线程安全,以及如何在保证线程安全的同时进行性能优化。我们将通过实例和代码片段来说明这些概念和技术。
3 0
|
2天前
|
Java 调度
Java并发编程:深入理解线程池
【5月更文挑战第11天】本文将深入探讨Java中的线程池,包括其基本概念、工作原理以及如何使用。我们将通过实例来解释线程池的优点,如提高性能和资源利用率,以及如何避免常见的并发问题。我们还将讨论Java中线程池的实现,包括Executor框架和ThreadPoolExecutor类,并展示如何创建和管理线程池。最后,我们将讨论线程池的一些高级特性,如任务调度、线程优先级和异常处理。
|
3天前
|
缓存 Java 数据库
Java并发编程学习11-任务执行演示
【5月更文挑战第4天】本篇将结合任务执行和 Executor 框架的基础知识,演示一些不同版本的任务执行Demo,并且每个版本都实现了不同程度的并发性。
22 4
Java并发编程学习11-任务执行演示
|
3天前
|
存储 安全 Java
12条通用编程原则✨全面提升Java编码规范性、可读性及性能表现
12条通用编程原则✨全面提升Java编码规范性、可读性及性能表现
|
3天前
|
缓存 Java 程序员
关于创建、销毁对象⭐Java程序员需要掌握的8个编程好习惯
关于创建、销毁对象⭐Java程序员需要掌握的8个编程好习惯
关于创建、销毁对象⭐Java程序员需要掌握的8个编程好习惯
|
缓存 安全 Java
【漫画】JAVA并发编程 如何解决可见性和有序性问题
JAVA并发编程 如何解决可见性与有序性问题呢?HappensBefore八大原则来搞定!胖滚猪用漫画的形式带你迅速入门。
【漫画】JAVA并发编程 如何解决可见性和有序性问题
|
1天前
|
Java 调度
Java一分钟之线程池:ExecutorService与Future
【5月更文挑战第12天】Java并发编程中,`ExecutorService`和`Future`是关键组件,简化多线程并提供异步执行能力。`ExecutorService`是线程池接口,用于提交任务到线程池,如`ThreadPoolExecutor`和`ScheduledThreadPoolExecutor`。通过`submit()`提交任务并返回`Future`对象,可检查任务状态、获取结果或取消任务。注意处理`ExecutionException`和避免无限等待。实战示例展示了如何异步执行任务并获取结果。理解这些概念对提升并发性能至关重要。
15 5
|
1天前
|
Java
Java一分钟:线程协作:wait(), notify(), notifyAll()
【5月更文挑战第11天】本文介绍了Java多线程编程中的`wait()`, `notify()`, `notifyAll()`方法,它们用于线程间通信和同步。这些方法在`synchronized`代码块中使用,控制线程执行和资源访问。文章讨论了常见问题,如死锁、未捕获异常、同步使用错误及通知错误,并提供了生产者-消费者模型的示例代码,强调理解并正确使用这些方法对实现线程协作的重要性。
10 3