JAVA内存模型

进程与线程

  • 操作系统是计算机的管理者,负责任务的调度、资源的分配和管理
  • CPU是计算机的核心,CPU承担了计算机的所有计算任务
  • 应用程序是具有某种功能的程序,程序是运行在操作系统之上

进程的概念

进程是一个具有一定功能的程序在一个数据集上的一次动态执行的过程

进程的具有的特点

  • 动态性:进程是程序在数据集上的一次运行过程,是有生命周期的、动态的
  • 并发性:任何进程都可以和其他进程一起并发执行
  • 独立性:进程是系统进行资源分配和调度的独立单位
  • 结构性:进程由程序、数据和进程控制块三部分组成

线程的概念

线程是程序执行中一个单一的顺序控制流程,是程序执行流的最小单元,是处理器调度和分派的基本单位。

  • 一个标准的线程由线程ID,当前指令指针PC,寄存器和堆栈组成
  • 进程的各个线程之间共享进程的部分内存空间 比如共享数据 进程空间 程序代码

进程和线程的区别

1、一个进程由一个或者多个线程组成,线程是一个进程中程序的不同执行路线

2、线程是程序执行的最小单位,而进程是操作系统分配资源的最小单位

3、线程上下文切换比进程上下文切换要快的多

任务调度

​ 在早期的操作系统中并没有线程的概念,进程是能拥有资源和和独立运行的最小单位,也是程序执行的最小单位,它相当于一个进程里只有一个线程,进程本身就是线程。所以线程有时被称为轻量级进程。之后随着计算机的发展,对多个任务之间上下文切换的效率要求越来越高,就抽象出一个更小的概念:线程。

大部分操作系统的任务调度是采用时间片轮转的抢占式调度方式

一个任务执行一小段时间后强制暂停去执行下一个任务,每个任务轮流执行。任务执行的一小段时间叫做时间片,任务正在执行时的状态叫运行状态,任务执行一段时间后强制暂停去执行下一个任务,被暂停的任务就处于就绪状态,等待下一个属于它的时间片的到来。这样每个任务都能得到执行,由于CPU的执行效率非常高,时间片非常短,在各个任务之间快速地切换,给人的感觉就是多个任务在“同时进行”,这也就是我们所说的并发。


CPU时间片.jpg

JVM线程与CPU线程

java多线程和内核线程.png

总结

​ 程序是实现某种功能的代码序列(静态)

​ 线程是占有资源的独立单元

​ 进程是程序在某些数据集上的一次运行过程(动态)

JVM内存区域

JVM内存区域.JPG
  • 堆内存(Heap) 堆内存是所有线程共享的数据区,可分为年轻代(Young Gen)和老年代(Old Memory),是GC的主要工作区域,当对象在堆中申请不到足够的空间时,将抛出OutOfMemoryError异常
  • 方法区(Method Area) 方法区是线程的的共享数据区域,它用于存储被虚拟机加载的类信息,常量,静态变量,即时编译(JIT)后的代码等数据。相对而言,垃圾收集行为在这个区域是比较少出现的。这个区域的内存回收目标主要是针对常量池的回收和对类型的卸载,一般来说这个区域的回收“成绩”比较难以令人满意,尤其是类型的卸载,条件相当苛刻,但是这部分区域的回收确实是有必要的。根据Java虚拟机规范的规定,当方法区无法满足内存分配需求时,将抛出OutOfMemoryError异常
  • 运行时常量池(RuntimeConstantPool) 是方法区的一部分。这里的常量池并不仅仅是Class文件中的常量池,JVM在进行编译优化时,会将部分常量载入到常量池中。JAVA语言并不要求常量一定在编译时期产生,允许开发人员在程序运行期间向常量池中放入新的常量。比如String的intern方法。另外当常量池无法申请到足够的内存时,将会抛出OutOfMemoryError异常
  • 程序计数器:JVM多线程是通过CPU时间片轮转(即线程的轮流切换并分配处理器执行时间)算法来实现。也就是说,某个线程在执行过程中可能会因为时间片耗尽而被挂起,而另一个线程获取到时间片开始执行。当被挂起的线程重新获取到时间片时,它想要从被挂起的地方继续执行,就必须知道上次执行的位置。在JVM中,程序计数器就是用来记录线程的字节码执行位置,因此程序计数器应当具有线程隔离的特性,也就是说在JVM中每一条线程都拥有属于自己的程序计数器。没条线程之间的计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存。
    • 如果线程正在执行的是一个Java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址
    • 如果正在执行的是Natvie方法,这个计数器值则为空(Undefined)。因为native方法是java通过JNI直接调用本地C/C++库,可以近似的认为native方法相当于C/C++暴露给java的一个接口,java通过调用这个接口从而调用到C/C++方法。由于该方法是通过C/C++而不是java进行实现。那么自然无法产生相应的字节码,并且C/C++执行时的内存分配是由自己语言决定的,而不是由JVM决定的。
    • 程序计数器占用内存很小,在进行JVM内存计算时,可以忽略不计。
    • 程序计数器区域是唯一一个在Java虚拟机规范中没有规定任何OutOfMemoryError情况的区域
  • JAVA虚拟机栈:虚拟机栈与程序计数器一样,是线程私有的,每一个线程对应一个虚拟机栈(也称为线程栈)。虚拟机栈是描述JAVA方法执行的内存模型,线程中每一个方法在执行的同时会创建一个栈针(Stack Frame)用于存储局部变量表等数据。每一个方法从调用至出栈的过程,就对应着栈帧在虚拟机中从入栈到出栈的过程。
    JAVA虚拟机栈.JPG

在这个区域如果线程请求深度大于虚拟机允许的最大深度,将会抛出StackOverflowError异常。如果虚拟机允许动态扩展(当前大部分虚拟机都可动态扩展,只不过java虚拟机也允许固定长度的虚拟机栈),当扩展无法申请到足够内存时会抛出OutOfMemoryError异常。


栈帧和操作数栈.png
  • 本地方法栈:本地方法栈是为虚拟机使用到的Native方法服务。与虚拟机栈一样,本地方法栈区域也会抛出StackOverflowError和OutOfMemoryError异常。


    本地方法栈.png

JAVA内存模型

JAVA内存模型规范了JAVA虚拟机与计算机内存是如何协同工作的,JAVA虚拟机是一个完整的计算机模型,因此这个模型的内存模型就是JAVA内存模型(规范抽象的模型)

java内存模型.JPG
  • 共享数据内存:线程共享数据
  • 工作内存:线程私有信息
    • 基本数据类型,直接分配到工作内存
    • 引用类型存在在堆中,工作内存存储引用类型的地址
  • 工作方式:
    • 线程修改私有数据,直接在工作空间修改
    • 线程修改共享数据,先将数据复制到工作空间中,在工作空间中修改,修改完成以后,刷新内存中的数据

硬件内存架构

硬件内存架构.JPG

每一个CPU都包含一系列的寄存器,CPU在寄存器上的执行速度远大于在主存上执行的速度,因为CPU访问寄存器的速度远快于访问主存的速度。

当代绝大多数CPU都有一定大小的缓存层,CPU访问缓存层的速度快于访问主存的速度,但通常比访问内部寄存器的速度还要慢一点。一些CPU还有多层缓存,比如一级缓存>二级缓存>三级缓存

通常情况下,当一个CPU要去读取主存时,它会事先将主存中的数据读到CPU缓存中,将缓存中的数据读到寄存器中执行操作。当CPU需要将结果写回到主存时,它会将寄存器中的值刷新到缓存中,然后在某个时间节点将值刷新会主存。

当cache带来性能飞跃的同时,也引入了新的“缓存一致性问题”。

比如CPU执行i++操作
(1)读取主存中的变量i的值到cache中
(2)在寄存器中对i进行自增操作
(3)将结果写会cache,最终同步到主存
一次步骤如果是单线程执行将不会存在问题
如果多个线程都去执行i++这个操作,变量i就会在不同的CPU缓存中存在
这时如果不保证CPU缓存数据的一致性,那么每一个CPU计算出来的结果就会不同,导致最终的计算错误

对于缓存一致性问题的解决方案

  • 总线锁定 前端总线(也叫CPU总线)是所有CPU与芯片组连接的主干道,负责CPU与外界所有部件的通信,包括高速缓存、内存、北桥,其控制总线向各个部件发送控制信号、通过地址总线发送地址信号指定其要访问的部件、通过数据总线双向传输。在CPU1要做 i++操作的时候,其在总线上发出一个LOCK#信号,其他处理器就不能操作缓存了该共享变量内存地址的缓存数据,也就是阻塞了其他CPU,使该处理器可以独享此共享数据内存。
  • MESI协议

    • MESI是内存中缓存的四种状态的缩写,分别是M(Modified 修改)、E(Exclusive 互斥/独占)、S(Shared 共享)、I(Invalid 无效)。每个cache line有四种状态,可用2bit表示
M   描述:cache line有效,数据被修改了,与主内存中的数据不一致,只存在与本cache中
    监听任务:一个处于M状态的缓存行,必须时刻监听所有试图读取该缓存行对应的主存地址的操作,如果监听到,必须在读取操作之前将缓存更新到主存中
E   描述:cache line有效,数据和内存中的数据一致,且数据只存在于本CPU缓存中
    监听任务:一个处于E状态的缓存行,必须时刻监听其他试图读取该缓存行对应的主存地址的操作,如果监听到,则必须将缓存行的状态置位S
S   描述:cache line有效,数据与内存中一致,数据存在与很多CPU缓存中
    监听任务:一个处于E状态的缓存行,必须时刻监听使缓存行无效和独享该缓存行的请求,如果监听到则必须将缓存行状态设置为I
I   描述:cache line无效,需要到主存中重新加载数据
  • 对于M和E的状态总是精确的,他们和所在缓存行的真正状态是一致的,而S状态是非一致的(有的线程工作内存中是S,实际上主内存中是E,因为消息传递的时间问题,可能存在不一致)

优点:解决了CPU缓存一致性问题

缺点:缓存消息的传递需要消耗时间,CPU需要等待缓存的响应从而引起各种各样的性能问题。从而引入了存储缓存 — Store Bufferes

处理器把它想要写入到主存的值写到存储缓存,然后继续去处理其他事情。当所有失效确认(Invalidate Acknowledge)都接收到时,数据才会最终被提交。

Store Bufferes的问题

处理器会尝试从存储缓存中读取值,如果存储缓存中存在值,则会将值返回,无论这个值是否已经提交

  • 数据什么时候会保存完成,并没有一定的保证
value = 3;

void exeToCPUA(){
  value = 10;
  isFinsh = true;
}
void exeToCPUB(){
  if(isFinsh){
    //value一定等于10?! isFinsh的存储缓存很有可能先于value储存缓存提交 就可能存在isFinsh的值为true,而value却不等于10
    assert value == 10;
  }
}

硬件内存对于处理缓存失效有以下约束

  • 所有受到Invalidate请求的缓存行,必须立即发送Invalidate Acknowlege消息
  • Invalidate并不真正执行,而是被放在一个特殊的队列(失效队列)中,在方便的时候才会去执行
  • 处理器不会发送任何消息给所处理的缓存条目,直到它处理Invalidate

即使是这样,处理器也不知道什么时候优化是允许的,什么时候是不允许的,所以就将这个任务交给了写程序的人。 这就是内存屏障的由来(Memory Barriers)

写屏障:Store Memory Barrier 是一条告诉处理器在执行这之后的指令之前,应该将所有存储在(store bufferers)中的值刷新到主内存。

读屏障:Load Memory Barrier 是一条告诉处理器在执行任何的加载前,应该将失效队列中的Invalidate处理完

void executedOnCpu0() {
    value = 10;
    // 在更新数据之前必须将所有存储缓存(store buffer)中的指令执行完毕。
    storeMemoryBarrier();
    finished = true;
}
void executedOnCpu1() {
    while(!finished);
    // 在读取之前将所有失效队列中关于该数据的指令执行完毕。
    loadMemoryBarrier();
    assert value == 10;
}

这样就可以完美的解决问题了。

JAVA内存模型与硬件内存架构的关系

Java内存模型与硬件内存架构的关系.JPG

从上图中可以看出部分线程和堆数据有时会出现在CPU缓存和CPU内部寄存器中,当对象和变量被存放在计算机各种不同的内存区域时,就可能出现以下问题:

  • 线程对共享变量修改的可见性
  • 多线程读、写和检查共享变量时,出现race conditions

共享变量的可见性

比如一个对象初始化在共享数据内存,当运行在CPU上的一个线程将这个对象读入CPU缓存中,并在寄存器中对该对象进行了修改。只要这个对象没有被刷新会主存,对象修改后的版本对于其他线程是不可见的。这种情况可能导致多个线程拥有这个共享对象的私有拷贝,每个拷贝停留在不同CPU缓存中。出现这种问题的主要原因还是线程之间的数据不可见性。

Race Conditions

如果存在两个或者多个线程共享一个对象,多个线程在这个共享对象更新属性,就有可能发生争用条件(Race Conditions)

比如线程A读取了一个共享对象的属性count到CPU缓存中,同时,线程B也做了同样的事情,但是是在一个不同的CPU缓存中。现在线程A和线程B都想去将count的值+1,如果这些增加操作是被顺序执行的,那么属性count应该被增加两次,然后再将结果写回到主存中。

然而,两次增加都是在没有适当的同步下并发执行的。无论是线程A还是线程B将count修改后的版本写回到主存中取,修改后的值仅会被原值大1,尽管增加了两次。

并发编程的特性

JMM的作用:

屏蔽硬件平台和操作系统访问内存的差异

规范内存数据和工作空间数据的交互,定义了程序中变量的访问规则

  • 原子性:即一个操作或者多个操作 要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行
例:银行转账 或者 执行int i =10;
  • 可见性:当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值
  • 有序性:程序执行的顺序按照代码的先后顺序执行,JVM在保证程序最终执行结果和代码顺序执行的结果是一致的前提下(as-if-serial),为了提高程序运行效率,会进行指令重排序
// 示例代码片段
int a = 10;    //语句1
int r = 2;    //语句2
a = a + 3;    //语句3
r = a*a;     //语句4
在上述程序片段中,语句1和语句2执行的先后顺序并不会影响程序你最终结果,语句2和语句3的执行顺序也不会影响程序执行的结果
在考虑指令之间数据依赖的前提下进行指令重排。比如语句4就不会在语句1之前执行,因为语句4依赖与语句1的数据
在多线程的运行环境下出现了指令重排呢?
// 线程1
context = loadContext(); // 语句1
inited = true;// 语句2

// 线程2
while(!inited){
    sleep(3000);
}
doSomethings(context);

存在这样的一种情况:因为语句1和语句2并没有数据关联性,因此执行的指令会被重排序,如果线程1先执行了语句2,此时线程1因为失去了CPU时间片而停止运行,线程2就会以为初始化工作已经完成,那么就会跳出循环,去执行doSomethings方法,而此时的context并没有被初始化,就会导致程序的错误。

  • 由以上案例可以看出要想并发程序正确执行,必须要保证原子性、可见性和有序性

JMM对与并发特征的保证

JMM与原子性

在java中,对基本数据类型变量的读取和赋值操作是原子操作。

int x = 10; 
int y = x;
x++;
y = x + 1; 

多个原子性的操作合并到一起没有原子性

在JVM中保证操作的原子性,可以使用Synchronized和Lock来实现

JMM与可见性

JVM提供volatile关键字来保证数据的可见性,可以理解为在JMM模型上实现的MESI协议

还可以使用Synchronized和Lock实现可见性,保证同一个时刻只允许一个线程获取锁操作共享数据,并且在释放锁之前将共享数据更新到主存中,保证数据的可见性。

JMM与有序性

在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排序。重排序分3种类型。
1)编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
2)指令级并行的重排序。现代处理器采用了指令级并行技术(Instruction-LevelParallelism,ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
3)内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。
从Java源代码到最终实际执行的指令序列,会分别经历以下3种重排序:


重排序过程.png

以上的重排序可能会导致多线程程序出现内存可见性问题。

对于编译器,JMM的编译器重排序规则会禁止特定类型的编译器重排序(不是所有的编译器重排序都要禁止)。

对于处理器重排序,JMM的处理器重排序规则会要求Java编译器在生成指令序列时,插入特定类型的内存屏障(Memory Barriers)指令,通过内存屏障指令来禁止特定类型的处理器重排序。

JMM属于语言级的内存模型,它确保在不同的编译器和不同的处理器平台之上,通过禁止特定类型的编译器重排序和处理器重排序,为程序员提供一致的内存可见性保证。

Volatile关键字会禁止进行指令的重排序:在进行指令优化时,不能将对volatile变量访问之前的语句放在其后面执行,也不能把volatile变量后面的语句放到其前面执行。因此只能在一定程度上保证有序性,比如volatile之前执行的执行的指令就可以重排序。

//volatile boolean flag = false;
int x = 2;        
int y = 0;        
flag = true; 
// int x=2 和int y=0不能在flag = true;之后执行
// int x = 4和int y=-1也不能在flag = true;之前执行
// 但是int x=2 和int y=0的指令有可能重排序
int x = 4;        
int y = -1

另外可以使用Synchronized和Lock来实现有序性,变多线程为单线程,自然保证了有序性

JMM先天存在的有序规则:happens-before原则

  • 程序次序规则:一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作

  • 锁定规则:一个unLock操作先行发生于后面对同一个锁的lock操作

  • Volatile规则:对一个变量的写操作先行发生于后面对这个变量的读操作

  • 传递规则:如果操作A先于操作B,而操作B先于操作C,那么操作A先于操作C

启动规则(线程的start先于此线程的一切动作)
中断规则(线程interrupt方法的调用先于检测到中断事件的发生
线程终结规则(线程的所有操作都先于线程的终止检测)
对象终结规则(对象的初始化完成先于finalize的开始)

参考:

进程与线程:https://www.cnblogs.com/qianqiannian/p/7010909.html

jvm内存区域:https://www.cnblogs.com/junzi2099/p/8418009.html

java内存模型:http://ifeve.com/java-memory-model-6/

总线锁:https://blog.csdn.net/qq_35642036/article/details/82801708

volatile:https://www.cnblogs.com/dolphin0520/p/3920373.html

你可能感兴趣的:(JAVA内存模型)