天天看点

【并发编程系列】1.并发安全问题的源头0.定义1.缓存导致的可见性问题2.线程切换带来的原子性问题3.编译优化带来的有序性问题

【并发编程系列】是整理自极客时间上王宝令老师的专栏:《Java并发编程实战》。

如有侵权,请告知。

0.定义

可见性: 一个线程对共享变量的修改,另一个线程能够立刻看到。

原子性: 一个或多个操作在CPU执行过程中不被中断,称为原子性。

有序性: 程序按照代码的先后顺序执行。

  • 导致可见性问题的原因是CPU缓存;
  • 导致有序性问题的原因是编译优化。
  • 线程切换可能带来原子性问题

    解决问题的直接方法就是禁用缓存和优化。Java内存模型JVM如何按需禁用缓存和编译优化的方法。

    具体来说这些方法包括 volatile、synchronized 和final三个关键字,以及六项Happens-Before

1.缓存导致的可见性问题

如果是单核CPU,所有的线程都在一个CPU上运行,那么CPU缓存和内存的一致性很容易解决。多个线程操作的都是CPU中的同一个值

【并发编程系列】1.并发安全问题的源头0.定义1.缓存导致的可见性问题2.线程切换带来的原子性问题3.编译优化带来的有序性问题

现在是多核CPU时代,每个CPU都有自己的缓存。当多个线程在不同CPU上执行时,这些线程操作的是不同的CPU缓存。这时候A线程对共享变量的操作,对B线程就不具备可见性了。

【并发编程系列】1.并发安全问题的源头0.定义1.缓存导致的可见性问题2.线程切换带来的原子性问题3.编译优化带来的有序性问题

2.线程切换带来的原子性问题

操作系统允许某个进程执行一小段时间。例如当一个进程在执行IO操作时,进程把自己标记为“休眠中”以让出CPU,等文件读入内存之后,操作系统会将整个进程唤醒,唤醒后的进程有机会重新获取CPU的使用权了。让出CPU是利用IO操作的时间,让CPU可以干其他事,提高CPU使用率。

Java语言中并发实现都是基于线程的,涉及到线程切换。高级语言中的一条语句,往往需要多条CPU指令来完成。比如i++操作有3条CPU指令:

1.将变量i的值从内存加载到CPU的寄存器

2.在寄存器中完成+1操作

3.将结果写入内存(或者CPU缓存)

操作系统的任务切换,可能在任意一条CPU指令完成时执行。这个的结果就是两个线程各自在自己的CPU完成了基于原始值的+1操作,而不是总体+2。

【并发编程系列】1.并发安全问题的源头0.定义1.缓存导致的可见性问题2.线程切换带来的原子性问题3.编译优化带来的有序性问题

3.编译优化带来的有序性问题

编译器为了优化性能,有时候会改变程序中语句的先后顺序。例如程序中:“a=6;b=7;”编译器优化后可能变成“b=7;a=6";

最典型的是双重检查锁定的单例模式。

private static Singleton instace;   
  
public static Singleton getInstance(){   
    //第一次null检查     
    if(instance == null){            
        synchronized(Singleton.class) { //1     
            //第二次null检查       
            if(instance == null){ //2  
                instance = new Singleton();//3  
            }  
        }           
    }  
    return instance;        
}
           

上面这段代码看似无懈可击,但是现实中有可能报错。

singleton = new Singleton();这段代码其实是分为三步:

分配内存空间。(1)

初始化对象。(2)

将 singleton 对象指向分配的内存地址。(3)

但是编译器优化指令重排之后,执行顺序变成了:

1.分配内存空间。(1)

2.将 singleton 对象指向分配的内存地址。(3)

3.初始化对象。(2)

第3步在第2步之前被执行,下一个线程拿到的单例对象是还没有初始化的,以致于报空指针异常。

对singleton 使用volatile修饰,就可以解决上述的问题。

使用volatile修饰的变量,它实现的作用是,对于这个变量的读写,不能使用CPU缓存必须从内存中写入或读出。