侧边栏壁纸
博主头像
孔子说JAVA博主等级

成功只是一只沦落在鸡窝里的鹰,成功永远属于自信且有毅力的人!

  • 累计撰写 285 篇文章
  • 累计创建 125 个标签
  • 累计收到 4 条评论

目 录CONTENT

文章目录

JAVA多线程之内存模型实例详解(Java Memory Model)

孔子说JAVA
2022-05-31 / 0 评论 / 0 点赞 / 75 阅读 / 8,941 字 / 正在检测是否收录...

java内存模型(Java Memory Model,JMM)是java虚拟机规范定义的,又叫共享内存模型,用来屏蔽掉java程序在各种不同的硬件和操作系统对内存的访问的差异,这样就可以实现java程序在各种不同的平台上都能达到内存访问的一致性。

  • Java 内存模型(简称 JMM)和内存区域是不一样的东西。内存区域是指 JVM 运行时将数据分区域存储,强调对内存空间的划分,即运行时数据区(Runtime Data Area)。

  • 内存模型是定义了线程和主内存之间的抽象关系,即 JMM 定义了 JVM 在计算机内存(RAM)中的工作方式。

  • java内存模型避免了像c等直接使用物理硬件和操作系统的内存模型在不同操作系统和硬件平台下表现不同,比如有些c/c程序可能在windows平台运行正常,而在linux平台却运行有问题。

1、基本概念

Java内存模型(JMM):描述了Java程序中各种变量(线程共享变量)的访问规则,JMM规定所有的变量都是存在主存中,每个线程都有自己的工作内存。线程对变量的操作都必须在工作内存进行,不能直接对主存进行操作,并且每个线程不能访问其他线程的工作内存。JMM分为主存和工作内存。主存是线程共享的,工作内存是线程私有的。

主存(main memory):线程共享,主要包括本地方法区和堆。java虚拟机规定所有的变量(不是程序中的变量)都必须在主内存中产生。物理机的主内存是整个机器的内存,而虚拟机的主内存是虚拟机内存中的一部分。一个计算机包含一个主存,所有CPU都可以访问主存,主存通常远大于CPU中的缓存。

工作内存(working memory):线程私有,线程的工作内存只是cpu的寄存器和高速缓存的抽象描述。java虚拟机中每个线程都有自己的工作内存,它保存了线程需要的变量在主内存中的副本。虚拟机规定,线程对主内存变量的修改必须在线程的工作内存中进行,不能直接读写主内存中的变量。不同的线程之间也不能相互访问对方的工作内存。如果线程之间需要传递变量的值,必须通过主内存来作为中介进行传递。

共享变量:如果一个变量在多个线程的工作内存中都存在副本,那这个变量就是这几个线程的共享变量。共享变量是存放在主内存中的,而且每一个线程都有自己的本地私有内存,如果有多个线程同时去访问一个变量的时候,可能出现的情况就是一个线程的本地内存中的数据没有及时刷新到主内存中,从而出现线程的安全问题。在Java当中,共享变量是存放在堆内存中的,而对于局部变量等是不会在线程之间共享的,他们不会有内存可见性问题,当然就不会受到内存模型的影响。

中央处理器(CPU):计算机系统的运算和控制核心,是信息处理、程序运行的最终执行单元。其功能主要是解释计算机指令以及处理计算机软件中的数据。

寄存器:每个CPU都包含一系列寄存器,他们是CPU的基础,寄存器执行的速度,远大于在主存上执行的速度。

CPU高速缓存:由于处理器与内存访问速度差距非常大,所以添加了读写速度尽可能接近处理器的高速缓存,来作为内存与处理器之间的缓冲,将数据读到缓存中,让运算快速进行,当运算结束,再从缓存同步到主存中,就无须等待缓慢的内存读写了。处理器访问缓存的速度快于访问主存的速度,但比访问内部寄存器的速度还是要慢点,每个cpu有一个cpu的缓存层,一个cpu含有多层缓存,某一时刻,一个或者多个缓存行可能同时被读取到缓存中,也可能同时被刷新到主存中。

2、JMM运作原理

计算机在执行程序时,每条指令都是在CPU中执行的,而执行指令过程中,势必涉及到数据的读取和写入。由于程序运行过程中的临时数据是存放在主存(物理内存)当中的,这时就存在一个问题,由于CPU执行速度很快,而从内存读取数据和向内存写入数据的过程跟CPU执行指令的速度比起来要慢的多,因此如果任何时候对数据的操作都要通过和内存的交互来进行,会大大降低指令执行的速度。因此在CPU里面就有了高速缓存。

通常,当一个CPU需要读取主存时,他会将主存的内容读取到缓存中,将缓存中的内容读取到内部寄存器中,在寄存器中执行操作,当CPU需要将结果回写到主存中时,他会将内部寄存器的值刷新到缓存中,然后会在某个时间点将值刷新回主存。当多个处理器的运算任务都涉及同一块主内存区域时,将可能导致各自的缓存数据不一致。为此需要各个处理器访问缓存时都遵循一些协议,在读写时要根据协议进行操作,来维护缓存的一致性。

image-1653960505282

JMM(抽象的概念)并不像JVM内存结构(真实的内存管理)一样是真实存在的,只是一个抽象的概念。JMM是和多线程相关的,描述了一组规则或规范,这个规范定义了一个线程对共享变量的写入时对另一个线程是可见的。Java的多线程之间是通过共享内存进行通信的,而由于采用共享内存进行通信,在通信过程中会存在一系列如可见性、原子性、顺序性等问题,而JMM就是围绕着多线程通信以及与其相关的一系列特性而建立的模型。JMM定义了一些语法集,这些语法集映射到Java语言中就是 volatilesynchronized等关键字,从而解决多线程对共享数据的读写一致性问题。

通过工作内存、主内存、线程三者的关系,主内存主要是存储变量,线程间变量的传递,工作内存主要负责缓存了存储变量的副本,对变量进行读取,运算,赋值,最后把变量刷新的主内存。

  • Java内存模型规定了所有的变量都存储在主内存中。
  • 线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读写主内存中的变量。
  • 线程之间是互不影响的。

3、JVM主内存与工作内存

JMM规范了java虚拟机与计算机内存如何协调工作,规定了一个线程如何及何时看到其他线程修改过的变量的值,以及在必须时,如何同步的访问共享变量。

  • 即在虚拟机中将变量(线程共享的变量)存储到内存和从内存中取出变量这样底层细节。

Java 内存模型中规定了所有的变量都存储在主内存中,每条线程还有自己的工作内存,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存中的变量。

这里的工作内存是 JMM 的一个抽象概念,也叫本地内存,其存储了该线程以读/写共享变量的副本。就像每个处理器内核拥有私有的高速缓存,JMM 中每个线程拥有私有的本地内存。

不同线程之间无法直接访问对方工作内存中的变量,线程间的通信一般有两种方式进行,一是通过消息传递,二是共享内存。Java 线程间的通信采用的是共享内存方式,线程、主内存和工作内存的交互关系如下图:

image-1653960486094

上图中线程A和线程B通信要经过2个步骤:

  • 线程A把本地内存A中更新过的共享变量刷新到主内存中。
  • 线程B到主内存中去读线程A之前已更新过的共享变量。

4、JMM三大特性

JMM主要是围绕在并发过程如何处理原子性、可见性和有序性来建立的。

4.1 原子性(Atomicity)

提供了互斥访问,同一时刻只能有一个线程对它进行操作。原子代表不可切割的最小单位,原子性是指一个操作或多个操作要么全部执行,且执行的过程不会被任何因素打断,要么就都不执行。

事务中原子性举例说明:比如从账户A向账户B转1000元,那么必然包括2个操作:从账户A减去1000元,往账户B加上1000元。这2个操作必须要具备原子性才能保证不出现一些意外的问题。

一个线程执行一个复合操作的时候,其他线程是否能够看到中间的状态、或进行干扰。可以作为判断具体操作是否符合原子性的一种思路。

  1. a=1; 只有赋值的动作,具有原子性。
  2. a=b; b有读取,a有赋值的动作;b是一个变量,如果此时有其他线程修改b的值,那么这个操作的是不具有原子性的。
  3. i++; 有对i进行读取,计算,写入的操作,在多线程情况,i的最终值可能不是你想要的,因为其原子性遭到破坏。

如何保证原子性

  • synchronized:通过synchronized关键字定义同步代码块或者同步方法保障原子性。
  • Lock:通过Lock接口保障原子性。
  • Atomic:通过Atomic类型保障原子性。

4.2 可见性(Visibility)

可见性是指当一个线程修改了共享变量的值,其他线程能够立即得知这个修改。JMM是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值这种依赖主内存作为传递媒介的方式来实现可见性的。

如何保证可见性

  • volatile:volatile的特殊规则保证了新值能立即同步到主内存,以及每次使用前立即从主内存刷新。
    -synchronized:synchronized关键字在释放锁之前,必须先把此变量同步回主内存中(执行store、write操作)。
    -final:被final修饰的字段在构造器中一旦初始化完成,并且构造器没有把“this”的引用传递出去(this引用逃逸是一件很危险的事情,其他线程有可能通过这个引用访问到“初始化了一半”的对象),那在其他线程中就能看见final字段的值。简单的说就是final修饰的变量,一旦完成初始化,就不能改变。

4.3 有序性(Ordering)

JMM有序性是指如果在本线程内观察,所有的操作都是有序的;如果在一个线程中观察另一个线程,所有的操作都是无序的。前一句是线程内表现为串行的语义,后一句是指令重排序现象和“工作内存与主内存同步延迟”的现象。

如何保证有序性

  • volatile:volatile关键字本身就包含了禁止指令重排序的语义。
  • synchronized:一个变量在同一个时刻只允许一条线程对其进行lock操作,这条规则决定了持有同一个锁的两个同步块只能串行地进入。

5、指令重排序

5.1 概念及分类

JAVA中指令重排序通常是编译器或运行时环境为了优化程序性能而采取的对指令进行重新排序执行的一种手段,重排序不会破坏程序原有的语意结构。

重排序分为两类:编译期重排序和运行期重排序,分别对应编译时和运行时环境。指令重排序不是随意重排序,它需要满足以下两个条件:

  1. 在单线程环境下不能改变程序运行的结果。即时编译器(和处理器)需要保证程序能够遵守 as-if-serial 属性。通俗地说,就是在单线程情况下,要给程序一个顺序执行的假象。即经过重排序的执行结果要与顺序执行的结果保持一致。
  2. 存在数据依赖关系的不允许重排序。

image-1653963020095

如果两个操作访问同一个变量,且这两个操作中有一个为写操作,此时这两个操作之间就存在数据依赖。数据依赖分下列三种类型:

名称 代码示例 说明
写后读 a = 1;b = a; 写一个变量之后,再读这个变量。
写后写 a = 1;a = 2; 写一个变量之后,再写这个变量。
读后写 a = b;b = 1; 读一个变量之后,再写这个变量。

上面三种情况,只要重排序两个操作的执行顺序,程序的执行结果将会被改变。所以,编译器和处理器在重排序时,会遵守数据依赖性,编译器和处理器不会改变存在数据依赖关系的两个操作的执行顺序。也就是说:在单线程环境下,指令执行的最终效果应当与其在顺序执行下的效果一致,否则这种优化便会失去意义。这句话有个专业术语叫做 as-if-serial semantics (as-if-serial语义)

5.2 重排序对多线程的影响

重排序是否会改变多线程程序的执行结果?我们看下例子:

public class ReorderExample{
   int a = 0;  
   boolean flag = false;  
   public void writer() {  
      a = 1;        // 1  
      flag = true;    // 2  
   }  
   public void reader() {  
      if (flag) {    // 3  
         int i = a * a; // 4  
       }  
   }  
}  

代码中的flag变量是个标记,用来标识变量a是否已被写入。这里假设有两个线程A和B,A首先执行writer()方法,随后B线程接着执行reader()方法。线程B在执行操作4时,能否看到线程A在操作1对共享变量a的写入?

  • 答案是:不一定能看到。

由于操作1和操作2没有数据依赖关系,编译器和处理器可以对这两个操作重排序;同样,操作3和操作4没有数据依赖关系(3和4为控制依赖),编译器和处理器也可以对这两个操作重排序。

重排序方式1:2 -> 3 -> 4 -> 1,操作1和操作2做了重排序。程序执行时,线程A首先写标记变量flag,随后线程B读这个变量。由于条件判断为真,线程B将读取变量a。此时,变量a还根本没有被线程A写入,在这里多线程程序的语义被重排序破坏了!

重排序方式2:操作3和操作4做重排序。在程序中,操作3和操作4存在控制依赖关系。当代码中存在控制依赖性时,会影响指令序列执行的并行度。为此,编译器和处理器会采用猜测(Speculation)执行来克服控制相关性对并行度的影响。以处理器的猜测执行为例,执行线程B的处理器可以提前读取并计算 a*a,然后把计算结果临时保存到一个名为重排序缓冲(reorder buffer ROB)的硬件缓存中。当接下来操作3的条件判断为真时,就把该计算结果写入变量i中。重排序在这里破坏了多线程程序的语义!

在单线程程序中,对存在控制依赖的操作重排序,不会改变执行结果(这也是as-if-serial语义允许对存在控制依赖的操作做重排序的原因);但在多线程程序中,对存在控制依赖的操作重排序,可能会改变程序的执行结果。

6、happens-before(先行发生规则)

虽然Java虚拟机和执行系统会对指令进行一定的重排,但是指令重排是有原则的,并发所有的指令都可以随便更改执行位置。从 JDK5 开始,Java 内存模型提出了 happens-before 的概念,通过这个概念来阐述操作之间的内存可见性。

分析一个并发程序是否安全,其实都依赖Happen-Before原则进行分析。Happen-Before被翻译成先行发生原则,意思就是当A操作先行发生于B操作,则在发生B操作的时候,操作A产生的影响能被B观察到,“影响”包括修改了内存中的共享变量的值、发送了消息、调用了方法等。下面罗列了一些基本原则,这些原则是指令重排不可以违背的:

  • 程序顺序原则:一个线程内,代码执行的过程必须保证语义的串行性(as-if-serial,看起来是串行的;另外如果程序内数据存在依赖,也不允许进行重排序 )。

  • volatile规则:volatile变量的写操作,先发生于读操作,这保证了volatile变量的可见性。

  • 锁规则:解锁(unlock)必然发生在随后的加锁(lock)前,这里必须是同一个锁。也就是说,如果对一个锁的解锁后再加锁,那么加锁的执行动作绝对不可能重排到解锁的动作之前,很显然如果这么做,加锁就没有意义了。

  • 传递性:A先于B,B先于C,那么A必然先于C。

  • 线程的start规则:线程的start操作先于线程内其他任何操作。

  • 线程的join规则:如果线程ThreadA中执行了ThreadB.join()方法,那么ThreadB的所有操作先于ThreadA中ThreadB.join()返回后的操作。

  • 线程的中断规则:线程的中断(interrupt)先于被中断线程的代码。

  • 对象中止规则:对象的构造函数执行、结束先于finalize()方法。

下图是 happens-before 与 JMM 的关系:

image-1653963111514

6.1 as-if-serial语义

as-if-serial语义:看起来像串行的。编译器和处理器对重排序的机制对程序员是透明的,但是我们观察到的结果跟按照编写程序的顺序是一致的,这就是看起来像的含义。

int a = 2; // A  
int b = 3; // B  
int c = a*b; // C 

在上述程序中,步骤C依赖于步骤A和B,但是步骤A和B之间没有依赖关系,根据程序的顺序执行规则,由于C依赖于A和B,那么C的执行顺序不能排在A和B之前,但是A和B的顺序是可以互换的,也就是说,我们按照程序顺序执行的语义,看到的执行顺序是这样:A–>B–>C,但是编译器和处理器可能进行重排序成这样子:B–>A–>C,但是这个过程对我们来说是透明的,但是最终结果跟我们想要的是一样的。这就是看起来像 as-if-serial的语义。

6.2 锁规则

锁规则:在并发编程中,锁保证了临界区的互斥访问,同时还可以让释放锁的线程向另一个线程发送消息。

public class MonitorDemo {  
  int a = 0;  
  public synchronized void writer() { // 1  
     a++; // 2  
  } // 3  
  public synchronized void reader() { // 4  
      int i = a; // 5  
      ……  
  } // 6  
}  

比如现在有两个线程A和B,线程A执行writer方法,线程B随后执行reader方法,根据happens-before原则,我们来梳理下这个过程包含的happens-before关系。

  • ①依据程序顺序执行顺序原则,1–>2–>3;4–>5–>6
  • ②根据监视器锁规则,锁的获取先于锁的释放,那么在A线程未执行完writer时,线程B是无法得到锁的。因此3–>4.
  • ③根据传递性规则,那我们可以得到2–>5.

最后我们得到的happens-before关系图是这样子的:

image-1653962510859

这也就是说,当线程B获取到线程A释放的锁后,线程A操作过的共享变量的内容对B是可见的(线程A的步骤2改变了a的值,线程B的步骤5获得了同一把锁后立刻可以得到a的最新值)。

并发编程中锁的语义如下

  • 线程A释放一个锁,实质上是线程A向接下来将要获取这个锁的某个线程发出了(线程A对共享变量所做修改的)消息。

  • 线程B获取一个锁,实质上是线程B接收了之前某个线程发出的(在释放这个锁之前共享变量所做修改的)消息。

  • 线程A释放锁,随后线程B获取这个锁,这个过程实质上是线程A通过主内存向线程B发送消息。

6.3 volatile规则

volatile规则:在并发编程中,单个volatile变量的读、写可以看成是使用同一把锁对单个变量读写操作进行了同步锁操作。如,线程A和线程B执行下列代码,线程A执行set()方法,线程B随后执行get()方法。使用volatile变量和对普通变量进行操作加锁的执行效果是一致的。下面两个代码执行效果是等价的:

代码1:

public class VolatileDemo {  
  volatile long vl = 0L; // 使用volatile声明64位的long型变量  
  public void set(long l) {  
     vl = l; //1. 单个volatile变量的写  
  }   
  public void getAndIncrement () {  
     vl++; // 复合(多个)volatile变量的读/写  
  }  
  public long get() {  
     return vl; //2. 单个volatile变量的读  
  }  
}

代码2:

public class VolatileDemo2 {  
  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;  
  }  
}

换句话说,volatile变量的写与锁的获取有相同的内存语义,volatile变量的读与锁的释放有相同的内存语义,这也就证明了对单个volatile变量的读写操作是原子性的,但是对volatile变量进行复合操作不具有原子性的,这个一定要注意。

线程A和线程B执行下列代码,线程A执行set()方法,线程B随后执行get()方法:

public class VolatileDemo {  
  volatile long vl = 0L; // 使用volatile声明64位的long型变量  
  public void set(long l) {//1  
     vl = l; // 2  
  }   
  public long get() {//3  
     return vl; // 4  
  }  
}
  • ①根据程序顺序执行原则,1–>2,;3–>4
  • ②根据volatile规则,volatile变量的写先于读,所以2–>3
  • ③根据传递性规则,1–>4

所以,我们最后得到的happens-before关系图是这样的:

image-1653962710186

volatile的内存语义如下

  • 线程A写一个volatile变量,实质上是线程A向接下来将要读这个volatile变量的某个线程发出了(其对共享变量所做修改的)消息。
  • 线程B读一个volatile变量,实质上是线程B接收了之前某个线程发出的(在写这个volatile变量之前对共享变量所做修改的)消息。
  • 线程A写一个volatile变量,随后线程B读这个volatile变量,这个过程实质上是线程A通过主内存向线程B发送消息。

volatile语义的实现,下表是编译器制定的volatile重排序规则表。

image-1653962802135

  • 当第一个操作是volatile读时,不管第二个操作是什么都不能重排序。
  • 当第一个操作是volatile写时,第二个操作为volatile读、写时不能重排序。

为了实现volatile的语义,编译器在编译代码时候,会生成对应的内存屏障指令,来禁止特定类型操作的处理器重排序。JMM采用保守(认为每个都必须这么做)的内存屏障插入策略来实现volatile语义:

  • 在每个volatile写操作的前面插入一个StoreStore屏障。
  • 在每个volatile写操作的后面插入一个StoreLoad屏障。
  • 在每个volatile读操作的后面插入一个LoadLoad屏障。
  • 在每个volatile读操作的后面插入一个LoadStore屏障。
public class VolatileBarrierDemo {  
    int c;  
    volatile int a = 1;  
    volatile int b = 2;  
    void readAndWrite() {  
        int i = a; // 第一个volatile读  
        int j = b; // 第二个volatile读  
        c = i + j; // 普通写  
        a = i + 1; // 第一个volatile写  
        b = j * 2; // 第二个 volatile写  
    }  
}

最后生成的指令执行示意图如下(红色部分的屏障可以省略掉,因为紧跟着的操作跨越不了已有的屏障):

image-1653962917505

0

评论区