欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页  >  IT编程

Java内存模型干货总结

程序员文章站 2022-06-22 11:17:18
Java内存模型 JMM 重排序 happens-before 顺序一致性内存模型 volatile 锁 ......

并发编程模型 

关键问题:线程之间如何通信 线程之间如何同步

共享内存模型(例:java):线程之间共享程序的公共状态,线程之间通过写-读内存中的公共状态来隐式进行通信

  同步是显式进行的。程序员必须显式指定某个方法或某段代码需要在线程之间互斥执行

消息传递模型:线程之间没有公共状态,线程之间必须通过明确的发送消息来显式进行通信

  由于消息的发送必须在消息的接收之前,因此同步是隐式进行的

java内存模型(jmm)

共享变量:堆内存在线程之间共享,存储在堆内存中所有实例域、静态域和数组元素共享变量

  (局部变量,方法定义参数、异常处理器参数不会在线程之间共享,不会有内存可见性问题,不受内存模型的影响)

jmm定义了线程和主内存之间的抽象关系:

  1)线程之间的共享变量存储在主内存(main memory)中

  2)每个线程都有一个私有的本地内存(local memory),本地内存中存储了该线程用以读/写共享变量的副本

  3)本地内存是jmm的一个抽象概念,并不真实存在。它涵盖了缓存,写缓冲区,寄存器以及其他的硬件和编译器优化

线程a与线程b通信:

  1)线程a把本地内存a中更新过的共享变量刷新到主内存中去

  2)线程b到主内存中去读取线程a之前已更新过的共享变量

jmm通过控制主内存与每个线程的本地内存之间的交互,提供内存可见性保证

Java内存模型干货总结Java内存模型干货总结

重排序

编译器和处理器会对指令做重排序

目的:提高并行度 提高性能

问题:重排序都可能会导致多线程程序出现内存可见性问题

Java内存模型干货总结

1)编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。

2)指令级并行的重排序。处理器多条指令重叠执行,改变语句对应机器指令的执行顺序(处理器重排)

3)内存系统的重排序。处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行(处理器重排)

 

举例:处理器对内存的读/写操作的执行顺序,不一定与内存实际发生的读/写操作顺序一致,导致重排序导致内存可见性问题

  (处理器使用写缓冲区来临时保存向内存写入的数据:避免由于处理器停顿下来等待向内存写入数据而产生的延迟

  以批处理的方式刷新写缓冲区,以及合并写缓冲区中对同一内存地址的多次写,可以减少对内存总线的占用)

Java内存模型干货总结

Java内存模型干货总结

假设处理器a和处理器b按程序的顺序并行执行内存访问,最终却可能得到x = y = 0的结果

第一步执行a1 b1

第二步执行a2 b2,此时已得到x=b=0 y=a=0

第三步执行a3 b3

执行完a3,a1才算执行完,a1 a2重排序了

 

jmm通过禁止特定类型的编译器重排序和处理器重排序,为程序员提供一致的内存可见性保证

jmm的编译器重排序规则会禁止特定类型的编译器重排序

java编译器在生成指令序列时,插入特定类型的内存屏障指令来禁止特定类型的处理器重排序

happens-before

从jdk5开始,java使用新的jsr -133内存模型,出了happens-before的概念,来阐述操作之间的内存可见性

一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须存在happens-before关系

两个操作之间具有happens-before关系,并不意味着前一个操作必须要在后一个操作之前执行!happens-before仅仅要求前一个操作(执行的结果)对后一个操作可见,且前一个操作按顺序排在第二个操作之前

程序顺序规则:

  1)一个线程中的每个操作,happens- before 于该线程中的任意后续操作。

  2)监视器锁规则:对一个监视器锁的解锁,happens- before 于随后对这个监视器锁的加锁。

  3)volatile变量规则:对一个volatile域的写,happens- before 于任意后续对这个volatile域的读。

  4)传递性:如果a happens- before b,且b happens- before c,那么a happens- before c。

既可以是在一个线程之内,也可以是在不同线程之间

数据依赖性

如果两个操作访问同一个变量,且这两个操作中有一个为写操作,此时这两个操作之间就存在数据依赖性

只要重排序两个操作的执行顺序,程序的执行结果将会被改变

编译器和处理器在重排序时,会遵守数据依赖性,编译器和处理器不会改变存在数据依赖关系的两个操作的执行顺序

  写后读 a = 1;b = a; 写一个变量之后,再读这个位置。
  写后写 a = 1;a = 2; 写一个变量之后,再写这个变量。
  读后写 a = b;b = 1; 读一个变量之后,再写这个变量。

只针对单线程

as-if-serial语义

不管怎么重排序(编译器和处理器为了提高并行度),程序的执行结果不能被改变(只针对单线程)

编译器和处理器遵守数据依赖性原因:为了遵守as-if-serial语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果

遵守as-if-serial语义,单线程程序的程序员创建了一个幻觉:单线程程序是按程序的顺序来执行的。as-if-serial语义使单线程程序员无需担心重排序会干扰他们,也无需担心内存可见性问题

    // 举例:可能a-->b--c 也可能b-->a-->c
    double pi  = 3.14;    //a
    double r   = 1.0;     //b
    double area = pi * r * r; //c   

优先于happens-before

数据竞争

java内存模型规范对数据竞争的定义:

  • 在一个线程中写一个变量,
  • 在另一个线程读同一个变量,
  • 而且写和读没有通过同步来排序。

顺序一致性内存模型

顺序一致性内存模型是一个被计算机科学家理想化了的理论参考模型,它为程序员提供了极强的内存可见性保证(jmm没有顺序一致性内存模型保证)

特性:

  • 一个线程中的所有操作必须按照程序的顺序来执行。
  • (不管程序是否同步)所有线程都只能看到一个单一的操作执行顺序。在顺序一致性内存模型中,每个操作都必须原子执行且立刻对所有线程可见。

Java内存模型干货总结

视图信息:

1.顺序一致性模型有一个单一的全局内存

2.在任意时间点最多只能有一个线程可以连接到内存

3.每一个线程必须按程序的顺序来执行内存读/写操作

 

举例:

线程a:a1->a2->a3  线程b:b1->b2->b3  并发执行

正确同步:

Java内存模型干货总结

两个线程没有做同步:

Java内存模型干货总结

可以看出:

1.每个线程内部执行顺序 都是按照程序的顺序来执行

2.所有线程都只能看到一个一致的整体执行顺序(原因:顺序一致性内存模型中的每个操作必须立即对任意线程可见)

顺序一致性模型 与jmm区别:

  顺序一致性模型保证单线程内的操作会按程序的顺序执行,jmm不保证单线程内的操作会按程序的顺序执行(遵守as-if-serial语义)

  顺序一致性模型保证所有线程只能看到一致的操作执行顺序,而jmm不保证所有线程能看到一致的操作执行顺序

jmm在具体实现上的基本方针:在不改变(正确同步的)程序执行结果的前提下,尽可能的为编译器和处理器的优化打开方便之门。 

正确同步,jmm保证程序的执行结果将与该程序在顺序一致性模型中的执行结果相同(但不保证执行顺序)

 Java内存模型干货总结Java内存模型干货总结

假设a线程执行writer()方法后,b线程执行reader()方法

volatile

1、volatile的特性

把对volatile变量的单个读/写,看成是使用同一个监视器锁对这些单个读/写操作做了同步

两段等价代码:

class volatilefeaturesexample {
    volatile long vl = 0l;  //使用volatile声明64位的long型变量

    public void set(long l) {
        vl = l;   //单个volatile变量的写
    }

    public void getandincrement () {
        vl++;    //复合(多个)volatile变量的读/写
    }


    public long get() {
        return vl;   //单个volatile变量的读
    }
}
class volatilefeaturesexample {
    long vl = 0l;               // 64位的long型普通变量

    public synchronized void set(long l) {     //对单个的普通 变量的写用同一个监视器同步
        vl = l;
    }

    public void getandincrement () { //普通方法调用
        long temp = get();           //调用已同步的读方法
        temp += 1l;                  //普通写操作
        set(temp);                   //调用已同步的写方法
    }
    public synchronized long get() { 
    //对单个的普通变量的读用同一个监视器同步
        return vl;
    }
}

 假设线程a执行writer()方法之后,线程b执行reader()方法

class volatileexample {
    int a = 0;
    volatile boolean flag = false;

    public void writer() {
        a = 1;                   //1
        flag = true;               //2
    }

    public void reader() {
        if (flag) {                //3
            int i =  a;           //4
            ……
        }
    }
}

Java内存模型干货总结

 

2、volatile写-读的内存语义

volatile写:当写一个volatile变量时,jmm会把该线程对应的本地内存中的共享变量刷新到主内存。

volatile读:当读一个volatile变量时,jmm会把该线程对应的本地内存置为无效。线程接下来将从主内存中读取共享变量。

3、volatile内存语义的实现原理

1)jmm把内存屏障指令分为下列四类:

Java内存模型干货总结

storeload barriers是一个“全能型”的屏障,它同时具有其他三个屏障的效果。现代的多处理器大都支持该屏障(其他类型的屏障不一定被所有处理器支持)。执行该屏障开销会很昂贵,因为当前处理器通常要把写缓冲区中的数据全部刷新到内存中(buffer fully flush)。

store:数据对其他处理器可见(即:刷新到内存)

load:让缓存中的数据失效,重新从主内存加载数据 

2)jmm针对编译器制定的volatile重排序规则表

是否能重排序 第二个操作
第一个操作 普通读/写 volatile读 volatile写
普通读/写     no
volatile读 no no no
volatile写   no no

 

 

 

 

举例来说,第三行最后一个单元格的意思是:在程序顺序中,当第一个操作为普通变量的读或写时,如果第二个操作为volatile写,则编译器不能重排序这两个操作。

从上表我们可以看出:

  • 当第二个操作是volatile写时,不管第一个操作是什么,都不能重排序。这个规则确保volatile写之前的操作不会被编译器重排序到volatile写之后。
  • 当第一个操作是volatile读时,不管第二个操作是什么,都不能重排序。这个规则确保volatile读之后的操作不会被编译器重排序到volatile读之前。
  • 当第一个操作是volatile写,第二个操作是volatile读时,不能重排序。

jmm内存屏障插入策略(编译器可以根据具体情况省略不必要的屏障):

  • 在每个volatile写操作的前面插入一个storestore屏障。
  • 在每个volatile写操作的后面插入一个storeload屏障。
  • 在每个volatile读操作的后面插入一个loadload屏障。
  • 在每个volatile读操作的后面插入一个loadstore屏障。

1、锁 释放-获取的happens before 关系

 假设线程a执行writer()方法,随后线程b执行reader()方法。

class monitorexample {
    int a = 0;

    public synchronized void writer() {  //1
        a++;                             //2
    }                                    //3

    public synchronized void reader() {  //4
        int i = a;                       //5
        ……
    }                                    //6
}

 Java内存模型干货总结

2、内存语义

 释放锁时,jmm会把该线程对应的本地内存中的共享变量刷新到主内存中。

 获取锁时,jmm会把该线程对应的本地内存置为无效。从而使得被监视器保护的临界区代码必须要从主内存中去读取共享变量。

3、锁内存语义的实现原理

释放锁:释放锁的最后写volatile变量state

  java的compareandset()方法调用简称为cas

cas:如果当前状态值等于预期值,则以原子方式将同步状态设置为给定的更新值。此操作具有 volatile 读和写的内存语义

protected final boolean tryrelease(int releases) {
    int c = getstate() - releases;
    if (thread.currentthread() != getexclusiveownerthread())
        throw new illegalmonitorstateexception();
    boolean free = false;
    if (c == 0) {
        free = true;
        setexclusiveownerthread(null);
    }
    setstate(c);           //释放锁的最后,写volatile变量state
    return free;
}
protected final boolean compareandsetstate(int expect, int update) {
    return unsafe.compareandswapint(this, stateoffset, expect, update);
}

获取锁:加锁方法首先读volatile变量state

protected final boolean tryacquire(int acquires) {
    final thread current = thread.currentthread();
    int c = getstate();   //获取锁的开始,首先读volatile变量state
    if (c == 0) {
        if (isfirst(current) &&
            compareandsetstate(0, acquires)) {
            setexclusiveownerthread(current);
            return true;
        }
    }
    else if (current == getexclusiveownerthread()) {
        int nextc = c + acquires;
        if (nextc < 0)  
            throw new error("maximum lock count exceeded");
        setstate(nextc);
        return true;
    }
    return false;
}

 通过volatile cas(具有volatile的内存语义)实现锁的内存语义。

 final

对于final域,编译器和处理器要遵守两个重排序规则:

1.在构造函数内对一个final域的写入,与随后把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。

  (先写入final变量,后调用该对象引用)

  原因:编译器会在final域的写之后,插入一个storestore屏障

2.初次读一个包含final域的对象的引用,与随后初次读这个final域,这两个操作之间不能重排序。

  (先读对象的引用,后读final变量)

  编译器会在读final域操作的前面插入一个loadload屏障 

处理器内存模型

如果完全按照顺序一致性模型来实现,那么很多的处理器和编译器优化都要被禁止,这对执行性能将会有很大的影响。

根据对不同类型读/写操作组合的执行顺序的放松,可以把常见处理器的内存模型划分为下面几种类型:

  1. 放松程序中写-读操作的顺序,由此产生了total store ordering内存模型(简称为tso)。
  2. 在前面1的基础上,继续放松程序中写-写操作的顺序,由此产生了partial store order 内存模型(简称为pso)。
  3. 在前面1和2的基础上,继续放松程序中读-写和读-读操作的顺序,由此产生了relaxed memory order内存模型(简称为rmo)和powerpc内存模型。

注意,这里处理器对读/写操作的放松,是以两个操作之间不存在数据依赖性为前提的(因为处理器要遵守as-if-serial语义,处理器不会对存在数据依赖性的两个内存操作做重排序)。

从上到下,模型由强变弱。越是追求性能的处理器,内存模型设计的会越弱。因为这些处理器希望内存模型对它们的束缚越少越好,这样它们就可以做尽可能多的优化来提高性能。

jmm,处理器内存模型,顺序一致性内存模型 之间的关系

jmm是一个语言级的内存模型,处理器内存模型是硬件级的内存模型,顺序一致性内存模型是一个理论参考模型。

语言内存模型,处理器内存模型和顺序一致性内存模型的强弱对比示意图:

Java内存模型干货总结

内存模型越强,越容易保证内存可见性,易编程性就越好。但是重排序就会越少,执行效率就越低。

jmm的设计

1)常见的处理器内存模型比jmm要弱,java编译器在生成字节码时,会在执行指令序列的适当位置插入内存屏障来限制处理器的重排序。

2)由于各种处理器内存模型的强弱并不相同,为了在不同的处理器平台向程序员展示一个一致的内存模型,jmm在不同的处理器中需要插入的内存屏障的数量和种类也不相同。

程序员希望:强内存模型编程,易于理解,易于编程

编译器和处理器希望:弱内存模型,内存模型对它们的束缚越少越好,以提高性能

jmm时的核心目标就是找到一个好的平衡点:一方面要为程序员提供足够强的内存可见性保证;另一方面,对编译器和处理器的限制要尽可能的放松。

jmm把happens- before要求禁止的重排序分为了下面两类:

1)会改变程序执行结果的重排序,jmm要求编译器和处理器必须禁止这种重排序。

2)不会改变程序执行结果的重排序,jmm对编译器和处理器不作要求(jmm允许这种重排序)。

  只要不改变程序的执行结果(指的是单线程程序和正确同步的多线程程序),编译器和处理器怎么优化都行。

  比如,如果编译器经过细致的分析后,认定一个锁只会被单个线程访问,那么这个锁可以被消除。

  再比如,如果编译器经过细致的分析后,认定一个volatile变量仅仅只会被单个线程访问,那么编译器可以把这个volatile变量当作一个普通变量来对待。

  这些优化既不会改变程序的执行结果,又能提高程序的执行效率。

 

 参考资料:

《成神之路-基础篇》jvm——java内存模型 

细说java多线程之内存可见性

java内存模型faq

深入理解java内存模型