多线程编程 之java内存模型(JMM)可见性有序性问题解决方案

Posted 踩踩踩从踩

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了多线程编程 之java内存模型(JMM)可见性有序性问题解决方案相关的知识,希望对你有一定的参考价值。

前言

在java多线程编程中涉及到线程之间数据交互,会涉及到很多不可控性,包括cpu中缓存机制,以及jvm中为提高代码运行效率,从而进行的指令重排,jit解释器优化,缓存技术等等,而JMM则是使多线程可控,而提出的规范;本篇文章主要讲解java内存模型详解和多线程数据的可见性问题

java内存模型

定义

  • java语言规范描述java语言特性,包括基本类型、强类型语言、泛型、各种特性等等;
  • java虚拟机规范则描述的是虚拟机运行时内存数据区域,内存回收机制等等;
  • java内存模型指的是java语言规范提出来的,它包含当多个线程修改了共享内存中的值时,应该读取到哪个值的规则。由于这部分规范类似于不同硬件体系结构的内存模型,因此这些语义称为Java编程语言内存模型。这部分是没有规定如何执行多线程程序。它只描述允许多线程程序的合法行为就是规则,因此多线程如何执行是由cpu进行执行的,而且是不可控制的。

为什么要有java内存规范

多线程中常会出现很多的问题,包括 见到的不一定是想要的、无法从肉眼去检测程序的准确性、不同平台有不同表现、错误很难重现。
从下面代码来看
public class Visibility {
    int i = 0;
    boolean isRunning = true;

    public static void main(String args[]) throws InterruptedException {
        Visibility v = new Visibility();

        new Thread(new Runnable() {
            @Override
            public void run() {
                System.out.println("here i am...");
                while(v.isRunning){
                    v.i++;
                }
                System.out.println("i=" + v.i);
            }
        }).start();

        Thread.sleep(3000L);
        v.isRunning = false;
        System.out.println("shutdown...");
    }
}

这里主要让主线程 等待3秒钟,看子线程对i进行自增长后,最后得到i的值,但是实际情况是只打印了shutdown,并没有打印i的值

shutdown...

这个isRunning一直为true,一直没有被修改,循环没有被退出

在jvm中运行参数添加下面的参数,则i值会出现下面的情况 (不同的jit编译器)

参数         32位 JDK          64位 JDK 
-server    不打印i的值          不打印i的值
-client          打印i的值         不打印i的值
因此在多线程的情况下,代码对数据操作执行效果是变化的,完全没有可控性
而java内存模型,就是提出规则解决上面的多线程的问题的。

从内存区域到内存模型

 

    因为这种内存结构,在多线程下数据交互会有各种情况出现 ,然后分析isrunning未改变的原因

数据不可见问题

  • 子线程的内存区域是分配在cpu和物理内存中,cpu中也有高速缓存。因为有这个原因就有可能会导致数据可能马上不可见。

 

 这就多线程之间操作数据,有可能出现数据马上不可见的问题。但cpu隔一段时间会去更新主内存中isrunning的值,然后子内存不断查看的时候应该是更新的。

  • cpu指令重排也可能导致线程之间数据不可见
Java编程语言的语义允许Java编译器和微处理器进行执行优化,这些优化导致了与其交
互的代码不再同步,从而导致看似矛盾的行为。 他只能保证单线程数据安全

  •  jit编译器进行指令重排。不是执行前编译器, 而是即时编译器。

         脚本语言 与 编译语言的区别
        解释执行:即脚本,在执行时,由语言的解释器将其一条条翻译成机器可识别的指令。
        编译执行:将编写的程序,直接编译成机器可以识别的指令码。
        Java介于脚本语言与编译语言之间,也是为了提升java运行效率的,在运行时进行优化。

在运行时会把数据优化isrunning并缓存起来,发现数据问题;这个问题是jvm为了优化效率而采用的缓存起来方式,所以不算问题,java语言规范中由java内存模型提出规范防止这个数据缓存起来

解决办法

可见性:让一个线程对共享变量的修改,能够及时的被其他线程看到 。

Java内存模型中规定:

  •    对某个 volatile 字段的写指令 happens-before 每个后续对该 volatile 字段的读指令。(这在jvm虚拟机中有规范)
  •    对 volatile 变量 v 的写指令,与所有其他线程后续对 v 的读同步 指令
Volatile的如何实现它的语义
  • 禁止缓存;volatile变量的访问控制符会加个ACC_VOLATILE
  • 对volatile变量相关的指令不做重排序;

java内存模型基本内容

shared variables

 可以在线程之间共享的内存称为共享内存或堆内存。
 所有实例字段、静态字段和数组元素都存储在堆内存中,这些字段和数组都是标题中提到的共享 变量。
 冲突:如果至少有一个访问是写操作,那么对同一个变量的两次访问是冲突的。
 这些能被多个线程访问的共享变量是内存模型规范的对象。  

线程间操作

  • 线程间操作指:一个程序执行的操作可被其他线程感知或被其他线程直接影响。读写也会产生冲突。
  • Java内存模型只描述线程间操作,不描述线程内操作(是没有冲突的),线程内操作按照线程内语义执行。

线程间操作有:

  • read操作 (一般读,即 非volatile)
  • write 操作 ( 一般写,即 非 volatile
  • volatile read 
  • volatile write 
  • Lock unlock
  • 线程的第一个和最后一个操作 (线程的生命周期 thread.state  默认 在jvm执行时加了happens-before指令的
  • 外部操作 访问db
所有线程间操作,都存在可见性问题,JMM需要对其进行规范

对于同步的规则定义

  • volatile 变量 v 的写入,与所有其他线程后续对 v 的读同步
  • 对于监视器m的解锁与所有后续操作对于m的加锁同步  

   默认情况下多线程之间 频繁加锁,解锁;代码是不能指令重排的。

 线程2进行操作执行对i和y操作,然后线程1操作过后,马上进行抢锁,读取值,一定是可见的。

 

  • 对于每个属性写入默认值(0, false,null)与每个线程对其进行的操作同步

初始默认值在堆中内存值,一定所有线程都可见的。并不是赋值的这里 ,这里赋值并不是每个线程同步的

class S{
   private int a=20;
   private String b="20";
}
  • 启动线程的操作与线程中的第一个操作同步
  • 线程 T2的 最后操作 与线程 T1 发现线程 T2 已经结束同步。( isAlive ,join可以判断线程是否终结)  修改的过程是不能缓存的  
  • 如果线程 T1 中断 了 T2,那么线程 T1 的中断操作与其他所有线程发现 T2 被中断了同步
    通过抛出 InterruptedException 异常,或者调用 Thread.interrupted 或 Thread.isInterrupted

    Happens-Before先行发生原则

    happens-before关系用于描述两个有冲突的动作之间的顺序,如果一个action happends before另一个action,则第一个操作被第二个操作可见 ,jvm需要实现如下的happens-before规则
    • 某个线程中的每个动作都 happens-before 该线程中该动作后面的动作。
    • 某个管程上的 unlock 动作 happens-before 同一个管程上后续的 lock 动作
    • 对某个 volatile 字段的写操作 happens-before 每个后续对该 volatile 字段的读操作
    • 在某个线程对象上调用 start()方法 happens-before 被启动线程中的任意动作
    • 如果在线程t1中成功执行了t2.join(),则t2中的所有操作对t1可见
    • 如果某个动作 a happens-before 动作 b,且 b happens-before 动作 c,则有 a happens-before c.

final在JMM中的处理

  • final在该对象的构造函数中设置对象的字段,当线程看到该对象时,将始终看到该对象的final字段的正确构造版 本。伪代码示例:f = new finalDemo(); 读取到的 f.x 一定最新,x为final字段。
public class Demo2Final {

    final int x;
    int y;

    static Demo2Final f;

    public Demo2Final(){
        x = 3;
        y = 4;
    }

    static void writer(){
        f = new Demo2Final();
    }

    static void reader(){
        if (f!=null){
            int i = f.x;        //一定读到正确构造版本
            int j = f.y;        //可能会读到 默认值0
            System.out.println("i=" + i + ", j=" +j);
        }
    }


    public static void main(String args[]) throws InterruptedException {
        // Thread1 writer
        // Thread2 reader
    }

}

也就是说创建的时候,final修饰时,一定不会被缓存。

  • 如果在构造函数中设置字段后发生读取,则会看到该final字段分配的值,否则它将看到默认值; 伪代码示例:public finalDemo(){ x = 1; y = x; }; y会等于1; 
public class Demo3Final {
    final int x;
    int y;

    static Demo2Final f;

    public Demo3Final(){
        x = 3;
        //#### 重点 语句 #####
        y = x;      //因为x被final修饰了,所以可读到y的正确构造版本
    }

    static void writer(){
        f = new Demo2Final();
    }

    static void reader(){
        if (f!=null){
            int i = f.x;        //一定读到正确构造版本
            int j = f.y;        //也能读到正确构造版本
            System.out.println("i=" + i + ", j=" +j);
        }
    }


    public static void main(String args[]) throws InterruptedException {
        // Thread1 writer
        // Thread2 reader
    }
}
  • 读取该共享对象的final成员变量之前,先要读取共享对象。
  • 通常被static final修饰的字段,不能被修改 。然而 System.in、System.out、System.err被static final修饰,

word tearing字节处理

有些处理器(尤其是早期的 Alphas 处理器)没有提供写单个字节的功能。在这样的处理器上更新 byte 数组,若只是简单地读取整个内容,更新对应的字节,然后将整个内容再写回内存,将是不合法的。

这个问题有时候被称为“字分裂(word tearing)”,更新单个字节有难度的处理器,就需要寻求其它方式来解决问题。

尽量不要对 byte[] 中的元素进行重新赋值,更不要在多线程程序中这样做。数据一致性的问题

double和long的特殊处理

由于《Java语言规范》的原因,对非 volatile 的 double、long 的单次写操作是分两次来进行的,每次操作其中32位,这可能导致第一次写入后,读取的值是脏数据,第二次写完成后,才能读到正确值。
读写volatile 修饰的 long、double是原子性的。
商业JVM不会存在这个问题,虽然规范没要求实现原子性,但是考虑到实际应用,大部分都实现了原子性。
《Java语言规范》中说道:建议程序员将共享的64位值(long、double)用volatile修饰或正确同步其程序以避 免可能的复杂的情况

 总结

大部分讨论仅涉及代码的行为,即一次执行单个语句或表达式,即通过单个线程来执
行。Java虚拟机可以同时支持多个执行线程,若未正确同步,线程的行为可能会出现混淆和违反直觉。描述了多线程程序的语义;它包含了,当多个线程修改了共享内存中的值时,应该读取到哪个值的规则。由于这部分规范类似于不同硬件体系结构的内存模型,因此这些语义称为Java编程语言内存模型。这些语义没有规定如何执行多线程程序。相反,它们描述了允许多线程程序的合法行为。

以上是关于多线程编程 之java内存模型(JMM)可见性有序性问题解决方案的主要内容,如果未能解决你的问题,请参考以下文章

Java内存模型之有序性问题

Java内存模型之原子性问题

并发基础之Java内存模型JMM

Java多线程的三大特性,线程池,JMM(Java内存模型)

二:并发编程之JMM&synchronized&volatile详解

Java内存模型(JMM)总结与学习