zoukankan      html  css  js  c++  java
  • Java并发编程实战 01|并发编程的Bug源头

    https://mp.weixin.qq.com/s/QT44HS47l_ir08pCZeFU5Q

    Java并发编程实战 02Java如何解决可见性和有序性问题

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

     

    摘要

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

    并发Bug源头

    在计算机系统中,执行速度为: CPU > 内存 > I/O设备 ,为了平衡这三者的速度差异,计算机体系机构、操作系统、编译程序都进行了优化:

    1.CPU增加了缓存,以均衡和内存的速度差异
    2.操作系统增加了进程、线程,已分时复用CPU,以均衡 CPU 与 I/O 设备的速度差异
    3.编译程序优化指令执行顺序,使得缓存能够更加合理的利用。

    但是这三者导致的问题为:可见性、原子性、有序性

    源头之一:CPU缓存导致的可见性问题

    一个线程对共享变量的修改,另外一个线程能够立即看到,那么就称为可见性。现在多核CPU时代中,每颗CPU都有自己的缓存,CPU之间并不会共享缓存;

    如线程A从内存读取变量V到CPU-1,操作完成后保存在CPU-1缓存中,还未写到内存中。
    此时线程B从内存读取变量V到CPU-2中,而CPU-1缓存中的变量V对线程B是不可见的
    当线程A把更新后的变量V写到内存中时,线程B才可以从内存中读取到最新变量V的值

    上述过程就是线程A修改变量V后,对线程B不可见,那么就称为可见性问题。

    源头之二:线程切换带来的原子性问题

    现代的操作系统都是基于线程来调度的,现在提到的“任务切换”都是指“线程切换”
    Java并发程序都是基于多线程的,自然也会涉及到任务切换,在高级语言中,一条语句可能就需要多条CPU指令完成,例如在代码 count += 1 中,至少需要三条CPU指令。

    指令1:把变量 count 从内存加载到CPU的寄存器中
    指令2:在寄存器中把变量 count + 1
    指令3:把变量 count 写入到内存(缓存机制导致可能写入的是CPU缓存而不是内存)

    操作系统做任务切换,可以发生在任何一条CPU指令执行完,所以并不是高级语言中的一条语句,不要被 count += 1 这个操作蒙蔽了双眼。假设count = 0,线程A执行完 指令1 后 ,做任务切换到线程B执行了 指令1、指令2、指令3后,再做任务切换回线程A。我们会发现虽然两个线程都执行了 count += 1 操作。但是得到的结果并不是2,而是1。

    如果 count += 1 是一个不可分割的整体,线程的切换可以发生在 count += 1 之前或之后,但是不会发生在中间,就像个原子一样。我们把一个或者多个操作在 CPU 执行的过程中不被中断的特性称为原子性

    源头之三:编译优化带来的有序性问题

    有序性指的是程序按照代码的先后顺序执行。编译器为了优化性能,可能会改变程序中的语句执行先后顺序。如:a = 1; b = 2;,编译器可能会优化成:b = 2; a = 1。在这个例子中,编译器优化了程序的执行先后顺序,并不影响结果。但是有时候优化后会导致意想不到的Bug。
    在单例模式的双重检查创建单例对象中。如下代码:

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

    问题出现在了new Singletion()这行代码,我们以为的执行顺序应该是这样的:

    指令1:分配一块内存M
    指令2:在内存M中实例化Singleton对象
    指令3:instance变量指向内存地址M

    但是实际优化后的执行路径确实这样的:

    指令1:分配一块内存M
    指令2:instance变量指向内存地址M
    指令3:在内存M中实例化Singleton对象

    这样的话看出来什么问题了吗?当线程A执行完了指令2后,切换到了线程B,
    线程B判断到 if (instance != null)。直接返回instance,但是此时的instance还是没有被实例化的啊!所以这时候我们使用instance可能就会触发空指针异常了。如图:

    总结

    在写并发程序的时候,需要时刻注意可见性、原子性、有序性的问题。在深刻理解这三个问题后,写起并发程序也会少一点Bug啦~。记住了下面这段话:CPU缓存会带来可见性问题、线程切换带来的原子性问题、编译优化带来的有序性问题。

    参考文章:极客时间:Java并发编程实战 01 | 可见性、原子性和有序性问题:并发编程Bug的源头

    Java并发编程实战 02Java如何解决可见性和有序性问题

     

    摘要

    在上一篇文章当中,讲到了CPU缓存导致可见性、线程切换导致了原子性、编译优化导致了有序性问题。那么这篇文章就先解决其中的可见性和有序性问题,引出了今天的主角:Java内存模型(面试并发的时候会经常考核到)

    什么是Java内存模型?

    现在知道了CPU缓存导致可见性、编译优化导致了有序性问题,那么最简单的方式就是直接禁用CPU缓存和编译优化。但是这样做我们的性能可就要爆炸了~。我们应该按需禁用。 Java内存模型是有一个很复杂的规范,但是站在程序员的角度上可以理解为:Java内存模型规范了JVM如何提供按需禁用缓存和编译优化的方法
    具体包括 volatile、synchronized、final三个关键字,以及六项Happens-Before规则。

    volatile关键字

    volatile有禁用CPU缓存的意思,禁用CPU缓存那么操作数据变量时直接是直接从内存中读取和写入。如:使用volatile声明变量 volatilebooleanv=false,那么操作变量 v时则必须从内存中读取或写入,但是在低于Java版本1.5以前,可能会有问题。
    在下面这段代码当中,假设线程A执行了 write方法,线程B执行了 reader方法,假设线程B判断到了 this.v==true进入到了判断条件中,那么此时的x会是多少呢?

    1. public class VolatileExample {

    2. private int x = 0;

    3. private volatile boolean v = false;

    4. public void write() {

    5. this.x = 666;

    6. this.v = true;

    7. }

    8. public void reader() {

    9. if (this.v == true) {

    10. // 这里的x会是多少呢?

    11. }

    12. }

    13. }

    在1.5版本之前,该值可能为666,也可能为0;因为变量 x并没有禁用缓存(volatile),但是在1.5版本以后,该值一定为666;因为Happens-Before规则

    什么是Happens-Before规则

    Happens-Before规则要表达的是:前面一个操作的结果对后续是可见的。如果第一次接触该规则,可能会有一些困惑,但是多去阅读几遍,就会加深理解。

    1.程序的顺序性规则

    这条规则是指在一个线程中,按照程序顺序,前面的操作Happens-Before于后续的任意操作(意思就是前面的操作结果对于后续任意操作都是可以看到的)。就如上面的那段代码,按照程序的顺序: this.x=666 Happens-Before于 this.v=true

    2.Volatile 变量规则

    这条规则指的是对一个Volatile变量的写操作,Happens-Before该变量的读操作。意思也就是:假设该变量被线程A写入后,那么该变量对于任何线程都是可见的。也就是禁用了CPU缓存的意思,如果是这样的话,那么和1.5版本以前没什么区别啊!那么如果再看一下规则3,就不同了。

    3.传递性

    这条规则指的是:如果 A Happens-Before 于B,且 B Happens-Before 于 C。那么 A Happens-Before 于 C。这就是传递性的规则。我们再来看看刚才那段代码(我复制下来方便看)

    1. public class VolatileExample {

    2. private int x = 0;

    3. private volatile boolean v = false;

    4. public void write() {

    5. this.x = 666;

    6. this.v = true;

    7. }

    8. public void reader() {

    9. if (this.v == true) {

    10. // 读取变量x

    11. }

    12. }

    13. }

    在上面代码,我们可以看到, this.x=666 Happens-Before this.v=truethis.v=true Happens-Before 读取变量x,根据传递性规则 this.x=666 Happens-Befote 读取变量x,那么说明了读取到变量 this.v=true时,那么此时的 读取变量x的指必定为 666
    假设线程A执行了 write方法,线程B执行 reader方法且此时的 this.v==true,那么根据刚才所说的传递性规则,读取到的变量 x必定为 666。这就是1.5版本对volatile语义的增强。而如果在版本1.5之前,因为变量 x并没有禁用缓存(volatile),所以变量 x可能为 0哦。

    4.管程中锁的规则

    这条规则是指对一个锁的解锁操作 Happens-Before 于后续对这个锁的加锁操作。管程是一种通用的同步原语,在Java中,synchronized是Java里对管程的实现。
    管程中的锁在Java里是隐式实现的。如下面的代码,在进入同步代码块前,会自动加锁,而在代码块执行完后会自动解锁。这里的加锁和解锁都是编译器帮我们实现的。

    1. synchronized(this) { // 此处自动加锁

    2. // x是共享变量,初始值 = 0

    3. if (this.x < 12) {

    4. this.x = 12;

    5. }

    6. } // 此处自动解锁

    结合管程中的锁规则,假设 x的初始值为0,线程A执行完代码块后值会变成12,那么当线程A解锁后,线程B获取到锁进入到代码块后,就能看到线程A的执行结果 x=12。这就是管程中锁的规则

    5.线程的start()规则

    这条规则是关于线程启动的,该规则指的是主线程A启动子线程B后,子线程B能够看到主线程启动子线程B前的操作。
    用HappensBefore解释:线程A调用线程B的start方法 Happens-Before 线程B中的任意操作。参考代码如下:

    1. int x = 0;

    2. public void start() {

    3. Thread thread = new Thread(() -> {

    4. System.out.println(this.x);

    5. });

    6. this.x = 666;

    7. // 主线程启动子线程

    8. thread.start();

    9. }

    此时在子线程中打印的变量 x值为666,你也可以尝试一下。

    6.线程join()规则

    这条规则是关于线程等待的,该规则指的是主线程A等待子线程B完成(主线A通过调用子线程B的 join()方法实现),当子线程B完成后,主线程能够看到子线程的操作,这里的看到指的是共享变量 的操作;
    用Happens-Before解释:如果在线程A中调用了子线程B的 join()方法并成功返回,那么子线程B的任意操作 Happens-Before 于主线程调用子线程B join()方法的后续操作。看代码比较容易理解,示例代码如下:

    1. int x = 0;

    2. public void start() {

    3. Thread thread = new Thread(() -> {

    4. this.x = 666;

    5. });

    6. // 主线程启动子线程

    7. thread.start();

    8. // 主线程调用子线程的join方法进行等待

    9. thread.join();

    10. // 此时的共享变量 x == 666

    11. }

    被忽略的final

    在1.5版本之前,除了值不可改变以外, final字段其实和普通的字段一样。 在1.5以后的Java内存模型中,对 final类型变量重排进行了约束。现在只要我们的提供正确的构造函数没有逸出,那么在构造函数初始化的 final字段的最新值,必定可以被其他线程所看到。代码如下:

    1. class FinalFieldExample {

    2. final int x;

    3. int y;

    4. static FinalFieldExample f;

    5. public FinalFieldExample() {

    6. x = 3;

    7. y = 4;

    8. }

    9. static void writer() {

    10. f = new FinalFieldExample();

    11. }

    12. static void reader() {

    13. if (f != null) {

    14. int i = f.x;

    15. int j = f.y;

    16. }

    17. }

    当线程执行 reader()方法,并且 f!=null时,那么此时的 final字段修饰的 f.x 必定为 3,但是 y不能保证为 4,因为它不是 final的。如果这是在1.5版本之前,那么 f.x也是不能保证为 3
    那么何为逸出呢?我们修改一下构造函数:

    1. public FinalFieldExample() {

    2. x = 3;

    3. y = 4;

    4. // 此处为逸出

    5. f = this;

    6. }

    这里就不能保证 f.x==3了,就算 x变量是用 final修饰的,为什么呢?因为在构造函数中可能会发生指令重排,执行变成下面这样:

    1. // 此处为逸出

    2. f = this;

    3. x = 3;

    4. y = 4;

    那么此时的 f.x==0。所以在构造函数中没有逸出,那么final修饰的字段没有问题。详情的案例可以参考这个文档

    总结

    在这篇文章当中,我一开始对于文章最后部分的 final约束重排一直看的不懂。网上不断地搜索资料和看文章当中提供的资料我才慢慢看懂,反复看了不下十遍。可能脑子不太灵活吧。
    该文章主要的核心内容就是Happens-Before规则,把这几条规则搞懂了就ok。

    参考文章:极客时间:Java并发编程实战 02

     
     
  • 相关阅读:
    hdu 6010 Daylight Saving Time
    hdu 5999 The Third Cup is Free
    2011 USP Try-outs F. Auction of Services
    1449 砝码称重
    hdu 6205 card card card
    hdu 6201 transaction transaction transaction
    Codeforces 828D
    Codeforces Round #434 D
    zoj
    Codeforces Round #434 C
  • 原文地址:https://www.cnblogs.com/xinxihua/p/12840745.html
Copyright © 2011-2022 走看看