深入JAVA线程安全问题

线程安全问题 线程不安全问题指的是一个类在多线程情况下运行会出现一些未知结果.
线程安全问题主要有:原子性 可见性 有序性
原子性 【深入JAVA线程安全问题】对于涉及共享变量访问的操作,在除执行本操作的线程外的线程看来都是不可分割的,那么这个操作就叫做原子性操作,我们称之为该操作具有原子性.

  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操作,这就是一种感知错误.
      从重排序的的来源一般将重排序分为:指令重排序和存储子系统重排序
      深入JAVA线程安全问题
      文章图片

      重排序是对内存访问操作的一种优化,它并不影响单线程下程序运行的正确性,但是会影响多线程下程序运行的正确性.
  2. 指令重排序
    编译器出于性能考虑,在不影响程序正确性的情况下对指令的执行顺序做出相应的调动,从而造成执行顺序与源码顺序不一致.
    java平台有两种编译器:
    • 静态编译器(javac),将java源代码翻译成字节码文件(.class),在这个时期基本不会发生指令重排序.
    • 动态编译器(JIT),将java字节码动态编译成机器码,指令重排序经常发生在这个时期.
      现代处理器为了执行效率往往不是按照程序顺序执行指令,而是动态调整指令执行顺序,做到哪条指令先就绪就先执行哪条指令,这被称为乱序执行.这些指令的执行结果会在写入寄存器或者主内存之前,会被先存入到重排序缓冲区中,然后重排序缓冲区会按照程序顺序将指令执行结果提交给寄存器或者是主内存,所以乱序执行不会影响单线程的执行结果的正确性,但是在多线程环境中会出现非预期的结果.
  3. 存储子系统重排序(内存重排序)
    Processor-0 Processor-1
    data=https://www.it610.com/article/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=https://www.it610.com/article/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-0 Processor-1
    data=https://www.it610.com/article/1; //S1
    ready=true; //S2
    while(! ready){ }//L3
    System.out.println(data); //L4
    如上图在编译期S1和S2冲排序后
    Processor-0 Processor-1
    ready=true; //S2
    data=https://www.it610.com/article/1; //S1
    while(! ready){ }//L3
    System.out.println(data); //L4
    当在执行完S2,程序进行上下文切换由Processor-0切换至Processor-1那么显然这一次重排序造成了未预期的结果,造成了线程安全问题.
    2.运行期重排序(JIT动态编译、内存重排序)不会影响单处理系统的处理结果.
    当发生这些重排序的时候,相关指令还没有完全执行完毕,系统不会进行上下文切换,会等到发生重排序的指令执行完毕提交后,再进行切换上下文.所以一个线程中的重排序对于切换后的另一个线程是没有任何影响的.
上下文切换 上下文切换所需要的开销
直接开销包括:
  • 操作系统保存和恢复上下文所需的开销,这主要是处理器时间开销.
  • 线程调度器进行线程调度的开销(比如,按照一定的规则决定哪个线程会占用处理器运行).
间接开销包括:
  • 处理器高速缓存重新加载的开销。一个被切出的线程可能稍后在另外一个处理器上被切入继续运行。由于这个处理器之前可能未运行过该线程,那么这个线程在其继续运行过程中需访问的变量仍然需要被该处理器重新从主内存或者通过缓存一致性协议从其他处理器加载到高速缓存之中。这是有一定时间消耗的.
  • 上下文切换也可能导致整个一级高速缓存中的内容被冲刷(Flush),即一级高速缓存中的内容会被写入下一级高速缓存*(如二级高速缓存)或者主内存(RAM)中.

    推荐阅读