线程平安问题

线程不平安问题指的是一个类在多线程状况下运行会呈现一些未知后果.
线程平安问题次要有:原子性 可见性 有序性

原子性

对于波及共享变量拜访的操作,在除执行本操作的线程外的线程看来都是不可分割的,那么这个操作就叫做原子性操作,咱们称之为该操作具备原子性.

  1. 呈现原子性问题的两大因素(共享变量 + 多线程)

    • 原子性操作是针对共享变量的操作而言的,局部变量无所谓是否原子操作(因为局部变量位于栈帧处于线程外部,不存在多线程问题).
    • 原子性操作是针对多线程环境的,在单线程不存在线程平安问题.
  2. 原子性操作"不可分割"形容的含意

    • 对于共享变量的操作,对于除操作线程外的其它线程来说要么尚未产生要么曾经完结,它们无奈看到操作的两头后果.
    • 拜访同一组共享变量是不能交织进行的.
  3. 实现原子性操作的两种形式:1.锁 2.处理器的CAS指令
    锁个别是在软件层面实现的,CAS通常是在硬件层面实现
  4. 在Java语言中long/double两种根本类型的写操作不具备原子性,其它六种根本类型是具备写原子性的.应用volatile关键字润饰 long/double类型能够使其具备写原子性.

可见性

在多线程环境下,一个线程对共享变量做出更新,后续拜访这个共享变量的线程无奈立刻获取到这个更新后的后果,甚至永远也获取不到这个后果,这个景象就被称之为可见性问题.

  1. 处理器与内存的读写操作并不是间接进行的,而是要通过 寄存器 写缓冲器 高速缓存有效化队列等部件来进行内存的读写操作的.

    cpu ==> 写缓存器 ==> 高速缓存 ==> 有效化队列||        ||          ||===========       缓存一致性协定
  2. 缓存同步:尽管一个处理器的高速缓存的内容不能被另外的处理器读取,然而一个处理器能够通过缓存一致性协定(MESI)来读取其它处理器的高速缓存,并将读取到的内容更新到本人的高速缓存当中去,这个过程咱们称之为缓存同步.
  3. 可见性问题产生的起因

    • 程序中的共享变量可能被调配到处理器的寄存器中存储,每个处理器都有本人的寄存器,而且寄存器中的内容是不能被其它处理器拜访的.所以当两个线程被调配到不同的处理器且共享变量被存储在各自的寄存器当中,就会导致一个线程永远拜访不到另一个线程对共享变量的更新,就产生了可见性问题.
    • 即便共享变量被调配到主内存中存储,处理器读取主内存是通过高速缓存进行的,当处理器A操作完共享变量将后果更新到高速缓存先要通过写缓冲器,在操作后果只更新到写缓冲器的时候,处理器B来访问共享变量,一样会呈现可见性问题(写缓存器不能被其它处理器拜访).
    • 共享变量的操作后果从高速缓存更新到另一个处理器的高速缓存中后,然而却被这个处理器放进了有效化队列当中,导致处理器读取的共享变量内容依然是过期的,这也就呈现了可见性问题.
  4. 可见性保障的实现形式

    • 冲刷处理器缓存:当一个处理器对共享变量进行更新后,必须让它的更新最终被写入到高速缓冲或者主内存中.
    • 刷新处理器缓存:当处理器操作一个共享变量的时候,其它处理器在此之前曾经对这个共享变量进行了更新,那么必须要对高速缓存或者主内存进行缓存同步.
  5. volatile的作用

    • 提醒JIT编译器,这个volatile润饰的变量可能被多个线程共享,防止JIT编译器对其进行可能导致程序不失常运行的优化.
    • 在读取volatile润饰的变量的时候先进行刷新处理器缓存操作,在更新volatile润饰的变量后进行冲刷处理器缓存.
  6. 单处理器会不会呈现可见性问题
    单处理器实现多线程操作时通过上下文切换实现的,当产生切换的时候寄存器中的数据也会被保存起来不被"下文"所拜访,所以当共享变量存储在寄存器当中时也会呈现可见性问题.

有序性

  1. 重排序的概念:处理器执行操作的程序与咱们指标代码指定的程序不统一
    重排序有以下几种状况

    • 编译器编译出的字节码程序与指标代码不统一
    • 字节码指令执行程序与指标代码不统一
    • 指标代码正确执行,然而其它处理器对指标代码的执行程序感知产生谬误
      比方:处理器A先执行了a操作再执行了b操作,然而在处理器B看来处理器A先执行的是b操作,这就是一种感知谬误.
      从重排序的的起源个别将重排序分为:指令重排序存储子系统重排序

      重排序是对内存拜访操作的一种优化,它并不影响单线程下程序运行的正确性,然而会影响多线程下程序运行的正确性.
  2. 指令重排序
    编译器出于性能思考,在不影响程序正确性的状况下对指令的执行程序做出相应的调动,从而造成执行程序与源码程序不统一.
    java平台有两种编译器:

    • 动态编译器(javac),将java源代码翻译成字节码文件(.class),在这个期间根本不会产生指令重排序.
    • 动静编译器(JIT),将java字节码动静编译成机器码,指令重排序常常产生在这个期间.
      古代处理器为了执行效率往往不是依照程序程序执行指令,而是动静调整指令执行程序,做到哪条指令先就绪就先执行哪条指令,这被称为乱序执行.这些指令的执行后果会在写入寄存器或者主内存之前,会被先存入到重排序缓冲区中,而后重排序缓冲区会依照程序程序将指令执行后果提交给寄存器或者是主内存,所以乱序执行不会影响单线程的执行后果的正确性,然而在多线程环境中会呈现非预期的后果.
  3. 存储子系统重排序(内存重排序)

    Processor-0Processor-1
    data=1; //S1
    ready=true; //S2
    while(! ready){ }//L3
    System.out.println(data); //L4

    当Processor-0和Processor-1都没有产生指令重排序的状况下,Processor-0依照S1-S2的程序来执行程序,然而Processor-1先感知到了S2执行了,所以Processor-1有可能在没有感知到S1的状况下会执行完L3-L4那么此时程序会打印出data=0,这就呈现了线程平安问题.
    以上状况就是S1和S2产生了内存重排序.

  4. 貌似串行语义
    重排序并非编译器、处理器对指令、内存操作的后果进行随便的调整程序,而是遵循肯定的规定.
    编译器、处理器遵循这种规定会给单线程程序带来一种程序执行的"假象",这种假象被称作为貌似串行语义.
    为了保障貌似串行语义,有数据依赖关系的语句不会被重排序,没有数据依赖关系的语句可能被重排序.
    以上面为例:语句③依赖于语句①和语句②所以它们之间不能产生重排序,然而语句①和语句②没有数据依赖关系所以语句①和语句②能够重排序.

    float price = 59.0f; // 语句①short quantity = 5; // 语句②float subTotal = price * quantity; // 语句③

    存在管制依赖关系的语句是能够容许被重排序的,如下:
    flag和count存在管制依赖关系,能够被重排序,即,在不晓得 flag 的值的状况下,为了谋求效率可能先执行count++.

    if(flag){  count++;}
  5. 单处理器零碎是否会受重排序的影响
    1.动态编译期的重排序会影响单处理器零碎的处理结果

    Processor-0Processor-1
    data=1; //S1
    ready=true; //S2
    while(! ready){ }//L3
    System.out.println(data); //L4

    如上图在编译期S1和S2冲排序后

    Processor-0Processor-1
    ready=true;//S2data=1; //S1

    while(! ready){ }//L3
    System.out.println(data); //L4

    当在执行完S2,程序进行上下文切换由Processor-0切换至Processor-1那么显然这一次重排序造成了未预期的后果,造成了线程平安问题.
    2.运行期重排序(JIT动静编译、内存重排序)不会影响单解决零碎的处理结果.

    当产生这些重排序的时候,相干指令还没有齐全执行结束,零碎不会进行上下文切换,会等到产生重排序的指令执行结束提交后,再进行切换上下文.所以一个线程中的重排序对于切换后的另一个线程是没有任何影响的.

上下文切换

上下文切换所须要的开销

间接开销包含:

  • 操作系统保留和复原上下文所需的开销,这次要是处理器工夫开销.
  • 线程调度器进行线程调度的开销(比方,依照肯定的规定决定哪个线程会占用处理器运行).

间接开销包含:

  • 处理器高速缓存从新加载的开销。一个被切出的线程可能稍后在另外一个处理器上被切入持续运行。因为这个处理器之前可能未运行过该线程,那么这个线程在其持续运行过程中需拜访的变量依然须要被该处理器从新从主内存或者通过缓存一致性协定从其余处理器加载到高速缓存之中。这是有肯定工夫耗费的.
  • 上下文切换也可能导致整个一级高速缓存中的内容被冲刷(Flush),即一级高速缓存中的内容会被写入下一级高速缓存*(如二级高速缓存)或者主内存(RAM)中.