并发编程学习(3)线程安全性分析

初步认识 Volatile

    public /*volatile*/ static boolean stop=false;
    public static void main( String[] args ) throws InterruptedException {
        Thread t1=new Thread(()->{
            int i=0;
            while(!stop){ 
                i++;
            }
        });
        t1.start();
        Thread.sleep(1000);
        stop=true; //true
    }
  • 定义一个共享变量 stop
  • 在main线程中创建一个子线程 thread,子线程读取到 stop的值做循环结束的条件
  • main线程中修改stop的值为 true
  • 当 stop没有增加volatile修饰时,子线程对于主线程的 stop=true的修改是不可见的,这样将导致子线程出现死循环
  • 当 stop增加了volatile修饰时,子线程可以获取到主线程对于 stop=true的值,子线程while循环条件不满足退出循环

增加volatile关键字以后,main线程对于共享变量 stop值的更新,对于子线程 thread可见,这就是volatile的作用

volatile 关键字是如何保证可见性的?

我们可以使用【hsdis】这个工具,来查看前面演示的这段代码的汇编指令,然后在输出的结果中,查找下 lock 指令,会发现,在修改
带有 volatile 修饰的成员变量时,会多一个lock指令。lock是一种控制指令,在多处理器环境下,lock 汇编指令可以基于总线锁或者缓存锁的机制来达到可见性的一个效果。

从硬件层面了解

在并发编程中,线程安全问题的本质其实就是 原子性、有序性、可见性;接下来主要围绕这三个问题进行展开分析其本质,彻底了解可见性的特性

  • 原子性 和数据库事务中的原子性一样,满足原子性特性的操作是不可中断的,要么全部执行成功要么全部执行失败
  • 有序性 编译器和处理器为了优化程序性能而对指令序列进行重排序,也就是你编写的代码顺序和最终执行的指令顺序是不一致的,重排序可能会导致多线程程序出现内存可见性问题
  • 可见性 多个线程访问同一个共享变量时,其中一个线程对这个共享变量值的修改,其他线程能够立刻获得修改以后的值

一台计算机中最核心的组件是 CPU、内存、以及 I/O 设备。在整个计算机的发展历程中,除了 CPU、内存以及 I/O 设备不断迭代升级来提升计算机处理性能之外,还有一个非常核心的矛盾点,就是这三者在处理速度的差异。CPU 的计算速度是非常快的,内存次之、最后是 IO 设备比如磁盘。而在绝大部分的程序中,一定会存在内存访问,有些可能还会存在 I/O 设备的访问为了提升计算性能,CPU 从单核升级到了多核甚至用到了超线程技术最大化提高 CPU 的处理性能,但是仅仅提升CPU 性能还不够,如果后面两者的处理性能没有跟上,意味着整体的计算效率取决于最慢的设备。为了平衡三者的速度差异,最大化的利用 CPU 提升性能,从硬件、操作系统、编译器等方面都做出了很多的优化

  • CPU 增加了高速缓存
  • 操作系统增加了进程、线程。通过 CPU 的时间片切换最大化的提升 CPU 的使用率 (在IntelPentium4开始,引入了超线程技术,也就是一个CPU核心模拟出2个线程的CPU,实现多线程并行)
  • 编译器的指令优化,更合理的去利用好 CPU 的高速缓存然后每一种优化,都会带来相应的问题,而这些问题也是导致线程安全性问题的根源。为了了解前面提到的可见性问题的本质,我们有必要去了解这些优化的过程

原子性

多线程并行访问同一个共享资源的时候的原子性问题,如果把问题放大到分布式架构里面,这个问题的解决方法就是锁。所以在CPU层面,提供了两种锁的机制来保证原子性

总线锁

处理器会提供一个LOCK#信号,当一个处理器在总线上输出这个信号时,其他处理器的请求会被阻塞,那么该处理器就可以独占共享内存

总线锁有一个弊端,总线锁相当于使得多个CPU由并行执行变成了串行,使得CPU的性能严重下降,所以在P6系列以后的处理器中,引入了缓存锁。

缓存锁

我们只需要保证 多个线程操作同一个被缓存的共享数据的原子性就行,所以只需要锁定被缓存的共享对象即可。所谓缓存锁是指被缓存在处理器中的共享数据,在Lock操作期间被锁定,那么当被修改的共享内存的数据回写到内存时,处理器不在总线上声明LOCK#信号,而是修改内部的内存地址,并通过 缓存一致性机制来保证操作的原子性。

所谓缓存一致性,就是多个CPU核心中缓存的同一共享数据的数据一致性,而(MESI)使用比较广泛的缓存一致性协议。MESI协议实际上是表示缓存的四种状态
M(Modify) 表示共享数据只缓存在当前CPU缓存中,并且是被修改状态,也就是缓存的数据和主内存中的数据不一致
E(Exclusive) 表示缓存的独占状态,数据只缓存在当前CPU缓存中,并且没有被修改
S(Shared) 表示数据可能被多个CPU缓存,并且各个缓存中的数据和主内存数据一致
I(Invalid) 表示缓存已经失效
每个CPU核心不仅仅知道自己的读写操作,也会监听其他Cache的读写操作 CPU的读取会遵循几个原则

  • 如果缓存的状态是I,那么就从内存中读取,否则直接从缓存读取
  • 如果缓存处于M或者E的CPU 嗅探到其他CPU有读的操作,就把自己的缓存写入到内存,并把自己的状态设置为S
  • 只有缓存状态是M或E的时候,CPU才可以修改缓存中的数据,修改后,缓存状态变为M

可见性

首先cpu执行代码时,执行顺序会根据他自己的优化重排序代码执行顺序,这也就导致了乱序访问问题。这也就是加入内存屏障的原因。

内存屏障就是将 store bufferes中的指令写入到内存,从而使得其他访问同一共享内存的线程的可见性。 X86的memory barrier指令包括lfence(读屏障) sfence(写屏障) mfence(全屏障)

  • Store Memory Barrier(写屏障) 告诉处理器在写屏障之前的所有已经存储在存储缓存(store bufferes)中的数据同步到主内存,简单来说就是使得写屏障之前的指令的结果对屏障之后的读或者写是可见的
  • Load Memory Barrier(读屏障) 处理器在读屏障之后的读操作,都在读屏障之后执行。配合写屏障,使得写屏障之前的内存更新对于读屏障之后的读操作是可见的
  • Full Memory Barrier(全屏障) 确保屏障前的内存读写操作的结果提交到内存之后,再执行屏障后的读写操作
    总的来说,内存屏障的作用可以通过防止CPU对内存的乱序访问来保证共享数据在多线程并行执行下的可见性

有序性

  1. 编译器优化重排序,在不改变单线程程序语义的前提下,改变代码的执行顺序
  • 指令集并行的重排序,对于不存在数据依赖的指令,处理器可以改变语句对应指令的执行顺序来充分利用CPU资源
  • 内存系统的重排序,也就是前面说的CPU的内存乱序访问问题

也就是说,我们编写的源代码到最终执行的指令,会经过三种重排序

有序性会带来可见性问题,所以可以通过内存屏障指令来进制特定类型的处理器重排序

所以 volatile这个关键字会有一个lock指令这也就是相当于内存屏障的作用从而实现可见性 因为lock会实现cpu底层的缓存锁。

JMM层面

硬件层面的原子性、有序性、可见性在不同的CPU架构和操作系统中的实现可能都不一样,而Java语言的特性是 write once,run anywhere,意味着JVM层面需要屏蔽底层的差异,因此在JVM规范中定义了JMM(内存模型)
可见性根本原因是就是高速缓存与重排序

JMM属于语言级别的抽象内存模型,可以简单理解为对硬件模型的抽象,它定义了共享内存中多线程程序读写操作的行为规范,也就是在虚拟机中将共享变量存储到内存以及从内存中取出共享变量的底层细节。 通过这些规则来规范对内存的读写操作从而保证指令的正确性,它解决了CPU多级缓存、处理器优化、指令重排序导致的内存访问问题,保证了并发场景下的可见性。 需要注意的是,JMM并没有限制执行引擎使用处理器的寄存器或者高速缓存来提升指令执行速度,也没有限制编译器对指令进行重排序,也就是说在JMM中,也会存在缓存一致性问题和指令重排序问题。只是JMM把底层的问题抽象到JVM层面,再基于CPU层面提供的内存屏障指令,以及限制编译器的重排序来解决并发问题

JMM 层面的内存屏障

为了保证内存可见性,Java 编译器在生成指令序列的适当位置会插入内存屏障来禁止特定类型的处理器的重排序,

HappenBefore

它的意思表示的是前一个操作的结果对于后续操作是可见的,所以它是一种表达多个线程之间对于内存的可见性。所以我们可以认为在 JMM 中,如果一个操作执行的结果需要对另一个操作课件,那么这两个操作必须要存在happens-before 关系。这两个操作可以是同一个线程,也可以是不同的线程

JMM 中有建立 happen-before 的规则
public class Demo {
    int a=0;
    volatile  boolean flag=false;

    public void writer(){ //线程A
        a=1;             //1
        flag=true;       //2
    }
    public void reader(){
        if(flag){  //3
            int x=a; //4
        }
    }
}
  • 一个线程中的每个操作,happens-before 于该线程中的任意后续操作; 可以简单认为是 as-if-serial(as-if-serial语义的意思是:不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不会改变)。单个线程中的代码顺序不管怎么变,对于结果来说是不变的顺序规则表示 1 happenns-before 2; 3 happensbefore 4
  • volatile 变量规则,对于 volatile 修饰的变量的写的操作,一定 happen-before 后续对于 volatile 变量的读操作;根据 volatile 规则,2 happens before 3
  • 传递性规则,如果 1 happens-before 2; 3happensbefore 4; 那么传递性规则表示: 1 happens-before 4;
  • start 规则 线程执行之前主线程改变的值一定对线程可见
public StartDemo{
int x=0;
Thread t1 = new Thread(()->{
 // 主线程调用 t1.start() 之前
 // 所有对共享变量的修改,此处皆可见
 // 此例中,x==10
});
// 此处对共享变量 x 修改
x = 10;
// 主线程启动子线程
t1.start();
}
  • join规则 线程t1改变的值一定对主线程可见
int x=0;
Thread t1=new Thread(()->{
 x=100;
 });
 t1.start();
 t1.join();
 System.out.println(x);
  • 监视器锁的规则,对一个锁的解锁,happens-before 于随后对这个锁的加锁(其实就是加锁后改变的值后续进来的线程是可以拿到的)
synchronized (this) { // 此处自动加锁
// x 是共享变量, 初始值 =10
if (this.x < 12) {
 this.x = 12;
 }
} // 此处自动解锁

JMM层面解决原子性、有序性、可见性

原子性:Java中提供了两个高级指令 monitorenter和 monitorexit,也就是对应的synchronized同步锁来保证原子性
可见性:volatile、synchronized、final(修饰的东西初始化时就已存在并且不能更改happens-before于随后的操作)都可以解决可见性问题
有序性:synchronized和volatile可以保证多线程之间操作的有序性,volatile会禁止指令重排序

猜你喜欢

转载自blog.csdn.net/yakax/article/details/104401214