jvm内存模型 知乎(jvm内存模型详解)

jvm内存模型 知乎(jvm内存模型详解)本博文详细介绍 JVM 内存组成和结构 帮助大家更好的学习和了解 JVM 运行的原理 方便大家在日常的工作中对 JVM 进行调优 PC 寄存器是用来存储指向下一条指令的地址 将要执行的指令代码 执行引擎读取下一条指令 它是一块很小的内存空间 几乎可以忽略不计 也是运行速度最快的存储区域 在 jvm 规范中 每个线程都有它自己的程序计数器 是线程私有的 生命周期与线程的生命周期保持一致



本博文详细介绍JVM内存组成和结构。帮助大家更好的学习和了解JVM运行的原理。方便大家在日常的工作中对JVM 进行调优。

PC寄存器是用来存储指向下一条指令的地址,将要执行的指令代码。执行引擎读取下一条指令。

  • 它是一块很小的内存空间,几乎可以忽略不计。也是运行速度最快的存储区域。
  • 在jvm规范中,每个线程都有它自己的程序计数器,是线程私有的,生命周期与线程的生命周期保持一致。
  • 任何时间一个线程都只有一个方法在执行,也就是所谓的当前方法。程序计数器会存储当前线程正在执行的java方法的JVM指令地址;或者,如果实在执行native方法,则是未指定值(undefined)。
  • 它是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。
  • 字节码解释器工作时就是通过改变这个计数器的值来选取吓一跳需要执行的字节码指令。
  • 它是唯一一个在java虚拟机规范中没有规定任何OOM情况的区域。

利用javap -v xxx.class反编译字节码文件,查看指令等信息。

java虚拟机栈,早期也叫Java栈。 每个线程在创建时都会创建一个虚拟机栈,其内部保存一个个的栈帧(Stack Frame),对应这个一次次的java方法调用。它是线程私有的。生命周期和线程是一致的。作用:主管java程序的运行,它保存方法的局部变量(8种基本数据类型、对象的引用地址)、部分结果,并参与方法的调用和返回。局部变量:相对于成员变量(或属性)基本数据变量: 相对于引用类型变量(类,数组,接口)。

栈的存储结构和运行原理

  • 每个线程都有自己的栈,栈中的数据都是以栈帧(Stack Frame)的格式存在。
  • 在这个线程上正在执行的每个方法都对应各自的一个栈帧。
  • 栈帧是一个内存区块,是一个数据集,维系着方法执行过程中的各种数据信息。
  • JVM直接对java栈的操作只有两个,就是对栈帧的压栈和出栈,遵循先进后出/后进先出的和原则。
  • 在一条活动线程中,一个时间点上,只会有一个活动的栈帧。即只有当前正在执行的方法的栈帧(栈顶栈帧)是有效的,这个栈帧被称为当前栈帧(Current Frame),与当前栈帧对应的方法就是当前方法(Current Frame)。
  • 执行引擎运行的所有字节码指令只针对当前栈帧进行操作。
  • 如果在该方法中调用了其他方法,对应的新的栈帧会被创建出来,放在栈的顶端,成为新的当前栈帧。
  • 不同线程中所包含的栈帧是不允许相互引用的,即不可能在另一个栈帧中引用另外一个线程的栈帧。
  • 如果当前方法调用了其他方法,方法返回之际,当前栈帧会传回此方法的执行结果给前一个栈帧,接着,虚拟机会丢弃当前栈帧,使得前一个栈帧重新成为当前栈帧。
  • Java方法有两种返回函数的方式,一种是正常的函数返回,使用return指令;另外一种是抛出异常。不管使用哪种方式,都会导致栈帧被弹出

内存中的堆与栈区别

  • 栈是运行时的单位,而堆是存储的单位。即:栈解决程序的运行问题,即程序如何执行,或者说如何处理数据。堆解决的是数据存储的问题,即数据怎么放、放在哪儿。
  • 一般来讲,对象主要都是放在堆空间的,是运行时数据区比较大的一块。栈空间存放 基本数据类型的局部变量,以及引用数据类型的对象的引用。

栈中可能出现的异常

  • 如果采用固定大小的Java虚拟机栈,那每一个线程的java虚拟机栈容量可以在线程创建的时候独立选定。如果线程请求分配的栈容量超过java虚拟机栈允许的最大容量,java虚拟机将会抛出一个 StackOverFlowError异常。
  • 如果java虚拟机栈可以动态拓展,并且在尝试拓展的时候无法申请到足够的内存,或者在创建新的线程时没有足够的内存去创建对应的虚拟机栈,那java虚拟机将会抛出一个 OutOfMemoryError异常。

设置栈的内存大小:我们可以使用参数-Xss选项来设置线程的最大栈空间,栈的大小直接决定了函数调用的最大可达深度。 (IDEA设置方法:Run-EditConfigurations-VM options 填入指定栈的大小-Xss256k)

 
 
  • Java虚拟机栈用于管理Java方法的调用,而本地方法栈用于管理本地方法的调用。
  • 本地方法栈,也是线程私有的。
  • 允许被实现成固定或者是可动态拓展的内存大小。(在内存溢出方面是相同的)
  • 本地方法是使用C语言实现的
  • 它的具体做法是Native Method Stack中登记native方法,在Execution Engine执行时加载本地方法库。
  • 当某个线程调用一个本地方法时,它就进入了一个全新的并且不再受虚拟机限制的世界。它和虚拟机拥有同样的权限
    • 本地方法可以通过本地方法接口来 访问虚拟机内部的运行时数据区。
    • 它甚至可以直接使用本地处理器中的寄存器。
    • 直接从本地内存的堆中分配任意数量的内存。
  • 并不是所有的JVM都支持本地方法。因为Java虚拟机规范并没有明确要求本地方法栈的使用语言、具体实现方式、数据结构等。如果JVM产品不打算支持native方法,也可以无需实现本地方法栈。
  • 在hotSpot JVM中,直接将本地方法栈和虚拟机栈合二为一

本地方法栈两种异常状况:

  • 如果线程请求分配的栈容量超过本地方法栈允许的最大容量,Java虚拟机将会抛出一个 StackOverFlowError异常。
  • 如果本地方法栈可以动态扩展,并且在尝试扩展的时候无法申请到足够的内存,或者在创建新的线程时没有足够的内存去创建对应的本地方法栈,那么java虚拟机将会抛出一个OutOfMemoryError异常。

Java堆是Java虚拟机所管理的内存中最大的一块。Java堆是被所有线程共享的一块内存区域, 在虚拟机启动时创建。 此内存区域的唯一目的就是存放对象实例, 几乎所有的对象实例都在这里分配内存(不是全部的对象)。

Java 虚拟机规范规定,堆可以处于物理上不连续的内存空间中,但在逻辑上它应该被视为连续的。所有的线程共享Java堆,在这里还可以划分线程私有的缓冲区(Thread Local Allocation Buffer,TLAB)。所有的对象实例以及数组都应当在运行时分配在堆上。所有的线程共享Java堆,在这里还可以划分线程私有的缓冲区(ThreadLocal Allocation Buffer,TLAB)。

数组和对象几乎可能永远不会存储在栈上,但是在逃逸分析的时候可能存在分配对象在栈内,因为栈帧中保存引用,这个引用指向对象或者数组在堆中的位置。在方法结束后,堆中的对象不会马上被移除,仅仅在垃圾收集的时候才会被移除。堆是 GC(Garbage Collection,垃圾收集器)执行垃圾回收的重点区域。

JDK1.7 堆内存结构

Java 7及之前堆内存逻辑上分为三部分:新生区+养老区+永久区

  • Young Generation Space 新生区 Young/New 又被划分为 Eden 区和 Survivor 区
  • Tenure generation space 养老区 Old/Tenure
  • Permanent Space 永久区 Perm

JDK1.8 堆内存结构

Java 8 及之后堆内存逻辑上分为三部分:新生区+养老区+元空间

  • Young Generation Space 新生区 Young/New 又被划分为 Eden 区和 Survivor 区
  • Tenure generation space 养老区 Old/Tenure
  • Meta Space 元空间 Meta

元空间是 Java 8 引入的一个新概念,它代替了永久代(PermGen)的概念。与永久代不同,元空间不再是 Java 堆的一部分,而是使用本地内存来存储类的元数据。

本地内存指的是操作系统分配给进程的内存空间,与 Java 堆不同,它并不受 JVM 管理,因此在内存使用上更加灵活。元空间使用本地内存存储类的元数据,可以有效地避免了永久代的一些限制,例如永久代大小有限、永久代垃圾收集效率低等问题。

需要注意的是,由于元空间使用的是本地内存,因此它的大小不再受到 Java 堆大小的限制,但是它的大小仍然会受到操作系统本身的限制,因此需要根据具体的系统配置来进行调整。

永久代(Permanent Generation)和元空间(Metaspace)都是 Java 虚拟机中用于存储类信息的内存区域,但它们有一些重要的区别:

  • 存储位置:永久代是在 Java 堆中的一个特殊区域,而元空间是在本地内存中的。
  • 大小调整:永久代的大小有限制的,并且必须在启动时指定,元空间可以根据需要自动调整大小。
  • 垃圾收集:永久代使用 Java 堆的垃圾收集器进行垃圾回收,而元空间使用本地内存的垃圾收集器。
  • 存储内容:永久代主要存储类的信息(如类名、方法名、字段名等),而元空间存储的是类的元数据(如类的结构、方法表、字段表等)。
  • 类信息的存储方式:永久代中的类信息是使用永久代专用的类加载器加载和卸载的,而元空间中的类信息是使用与应用程序类加载器相同的类加载器加载和卸载的。
  • 需要注意的是,永久代在 JDK 8 中已经被元空间所取代,所以在 JDK 8 及以后的版本中,永久代已不存在。

尽管所有的方法区在逻辑上是属于堆的一部分,但一些简单的实现可能不会选择去进行垃圾收集或者进行压缩。”但对于 HotSpotJVM 而言,方法区还有一个别名叫做 Non-Heap(非堆),目的就是要和堆分开。所以,方法区看作是一块独立于 Java 堆的内存空间。

  • 方法区(Method Area)与 Java 堆一样,是各个线程共享的内存区域。
  • 方法区在 JVM 启动的时候被创建,并且它的实际的物理内存空间中和 Java 堆区一样都可以是不连续的。
  • 方法区的大小,跟堆空间一样,可以选择固定大小或者可扩展。
  • 方法区的大小决定了系统可以保存多少个类,如果系统定义了太多的类,导致方法区溢出,虚拟机同样会抛出内存溢出错误:java.lang.OutOfMemoryError: PermGen space 或者java.lang.OutOfMemoryError: Metaspace
  • 加载大量的第三方的 jar 包;Tomcat 部署的工程过多(30~50 个);大量动态的生成反射类
  • 关闭 JVM 就会释放这个区域的内存。

JDK1.6 及之前有永久代(permanet),静态变量存储在永久代上JDK1.7有永久代,但已经逐步 “去永久代”,字符串常量池,静态变量移除,保存在堆中JDK1.8无永久代,类型信息,字段,方法,常量保存在本地内存的元空间,但字符串常量池、静态变量仍然在堆中。

JMM(Java内存模型)主要定义了对于一个共享变量,当另一个线程对这个共享变量执行写操作后,这个线程对这个共享变量的可见性。要想理解透彻 JMM(Java 内存模型),要从CPU 缓存模型和指令重排序说明。

为什么要弄一个 CPU 高速缓存呢? 类比我们开发网站后台系统使用的缓存(比如 Redis)是为了解决程序处理速度和访问常规关系型数据库速度不对等的问题。 CPU 缓存则是为了解决 CPU 处理速度和内存处理速度不对等的问题。我们甚至可以把 内存看作外存的高速缓存,程序运行的时候我们把外存的数据复制到内存,由于内存的处理速度远远高于外存,这样提高了处理速度。总结:CPU Cache 缓存的是内存数据用于解决 CPU 处理速度和内存不匹配的问题,内存缓存的是硬盘数据用于解决硬盘访问速度过慢的问题。

CPU Cache 的工作方式 先复制一份数据到 CPU Cache 中,当 CPU 需要用到的时候就可以直接从 CPU Cache 中读取数据,当运算完成后,再将运算得到的数据写回 Main Memory 中。但是,这样存在内存缓存不一致性的问题 !比如我执行一个 i++ 操作的话,如果两个线程同时执行的话,假设两个线程从 CPU Cache 中读取的 i=1,两个线程做了 1++ 运算完之后再写回 Main Memory 之后 i=2,而正确结果应该是 i=3。

CPU 为了解决内存缓存不一致性问题可以通过制定缓存一致协议(MESI)或者其他手段来解决。 这个缓存一致性协议指的是在 CPU 高速缓存与主内存交互的时候需要遵守的原则和规范。不同的 CPU 中,使用的缓存一致性协议通常也会有所不同。

我们的程序运行在操作系统之上,操作系统屏蔽了底层硬件的操作细节,将各种硬件资源虚拟化。于是,操作系统也就同样需要解决内存缓存不一致性问题。操作系统通过内存模型(Memory Model)定义一系列规范来解决这个问题。无论是 Windows 系统,还是 Linux 系统,它们都有特定的内存模型。

说完了 CPU 缓存模型,我们再来看看另外一个比较重要的概念 指令重排序 。为了提升执行速度/性能,计算机在执行程序代码的时候,会对指令进行重排序。什么是指令重排序? 简单来说就是系统在执行代码的时候并不一定是按照你写的代码的顺序依次执行。常见的指令重排序有下面 2 种情况:

  • 编译器优化重排 :编译器(包括 JVM、JIT 编译器等)在不改变单线程程序语义的前提下,重新安排语句的执行顺序。
  • 指令并行重排 :现代处理器采用了指令级并行技术(Instruction-Level Parallelism,ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
  • 另外,内存系统也会有“重排序”,但又不是真正意义上的重排序。在 JMM 里表现为主存和本地内存的内容可能不一致,进而导致程序在多线程下执行可能出现问题。

Java 源代码会经历编译器优化重排 —> 指令并行重排 —> 内存系统重排的过程,最终才变成操作系统可执行的指令序列。指令重排序可以保证串行语义一致,但是没有义务保证多线程间的语义也一致 ,所以在多线程下,指令重排序可能会导致一些问题。

编译器和处理器的指令重排序的处理方式不一样。对于编译器,通过禁止特定类型的编译器重排序的方式来禁止重排序。对于处理器,通过插入内存屏障(Memory Barrier,或有时叫做内存栅栏,Memory Fence)的方式来禁止特定类型的处理器重排序。指令并行重排和内存系统重排都属于是处理器级别的指令重排序。

内存屏障(Memory Barrier,或有时叫做内存栅栏,Memory Fence)是一种 CPU 指令,用来禁止处理器指令发生重排序(像屏障一样),从而保障指令执行的有序性。另外,为了达到屏障的效果,它也会使处理器写入、读取值之前,将主内存的值写入高速缓存,清空无效队列,从而保障变量的可见性。

Java 是最早尝试提供内存模型的编程语言。由于早期内存模型存在一些缺陷(比如非常容易削弱编译器的优化能力),从 Java5 开始,Java 开始使用新的内存模型。一般来说,编程语言也可以直接复用操作系统层面的内存模型。不过,不同的操作系统内存模型不同。如果直接复用操作系统层面的内存模型,就可能会导致同样一套代码换了一个操作系统就无法执行了。Java 语言是跨平台的,它需要自己提供一套内存模型以屏蔽系统差异。

这只是 JMM 存在的其中一个原因。实际上,对于 Java 来说,你可以把 JMM 看作是 Java 定义的并发编程相关的一组规范,除了抽象了线程和主内存之间的关系之外,其还规定了从 Java 源代码到 CPU 可执行指令的这个转化过程要遵守哪些和并发相关的原则和规范,其主要目的是为了简化多线程编程,增强程序可移植性的。

为什么要遵守这些并发相关的原则和规范呢? 这是因为并发编程下,像 CPU 多级缓存和指令重排这类设计可能会导致程序运行出现一些问题。就比如说我们上面提到的指令重排序就可能会让多线程程序的执行出现问题,为此,JMM 抽象了 happens-before 原则来解决这个指令重排序问题。

JMM 说白了就是定义了一些规范来解决这些问题,开发者可以利用这些规范更方便地开发多线程程序。对于 Java 开发者说,你不需要了解底层原理,直接使用并发相关的一些关键字和类(比如 、、各种 )即可开发出并发安全的程序。

JMM 是如何抽象线程和主内存之间的关系

Java 内存模型(JMM) 抽象了线程和主内存之间的关系,就比如说线程之间的共享变量必须存储在主内存中。在 JDK1.2 之前,Java 的内存模型实现总是从主存 (即共享内存)读取变量,是不需要进行特别的注意的。而在当前的 Java 内存模型下,线程可以把变量保存本地内存 (比如机器的寄存器)中,而不是直接在主存中进行读写。这就可能造成一个线程在主存中修改了一个变量的值,而另外一个线程还继续使用它在寄存器中的变量值的拷贝,造成数据的不一致。

什么是主内存?什么是本地内存?

  • 主内存 :所有线程创建的实例对象都存放在主内存中,不管该实例对象是成员变量还是方法中的本地变量(也称局部变量)
  • 本地内存 :每个线程都有一个私有的本地内存来存储共享变量的副本,并且,每个线程只能访问自己的本地内存,无法访问其他线程的本地内存。本地内存是 JMM 抽象出来的一个概念,存储了主内存中的共享变量副本。

从上图来看,线程1与线程2之间如果要进行通信的话,必须要经历下面 2 个步骤:

  1. 线程 1 把本地内存中修改过的共享变量副本的值同步到主内存中去。
  2. 线程 2 到主存中读取对应的共享变量的值。

也就是说,JMM为共享变量提供了可见性的保障。不过,多线程下,对主内存中的一个共享变量进行操作有可能诱发线程安全问题。举个例子:

  1. 线程 1 和线程 2 分别对同一个共享变量进行操作,一个执行修改,一个执行读取。
  2. 线程 2 读取到的是线程 1 修改之前的值还是修改后的值并不确定,都有可能,因为线程 1 和线程 2 都是先将共享变量从主内存拷贝到对应线程的工作内存中。

关于主内存与工作内存直接的具体交互协议,即一个变量如何从主内存拷贝到工作内存,如何从工作内存同步到主内存之间的实现细节,Java 内存模型定义来以下八种同步操作

  • 锁定(lock): 作用于主内存中的变量,将他标记为一个线程独享变量。
  • 解锁(unlock): 作用于主内存中的变量,解除变量的锁定状态,被解除锁定状态的变量才能被其他线程锁定。
  • read(读取):作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的 load 动作使用。
  • load(载入):把 read 操作从主内存中得到的变量值放入工作内存的变量的副本中。
  • use(使用):把工作内存中的一个变量的值传给执行引擎,每当虚拟机遇到一个使用到变量的指令时都会使用该指令。
  • assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
  • store(存储):作用于工作内存的变量,它把工作内存中一个变量的值传送到主内存中,以便随后的 write 操作使用。
  • write(写入):作用于主内存的变量,它把 store 操作从工作内存中得到的变量的值放入主内存的变量中。

除了这 8 种同步操作之外,还规定了下面这些同步规则来保证这些同步操作的正确执行(了解即可,无需死记硬背):

  • 不允许一个线程无原因地(没有发生过任何 assign 操作)把数据从线程的工作内存同步回主内存中。
  • 一个新的变量只能在主内存中 “诞生”,不允许在工作内存中直接使用一个未被初始化(load 或 assign)的变量,换句话说就是对一个变量实施 use 和 store 操作之前,必须先执行过了 assign 和 load 操作。
  • 一个变量在同一个时刻只允许一条线程对其进行 lock 操作,但 lock 操作可以被同一条线程重复执行多次,多次执行 lock 后,只有执行相同次数的 unlock 操作,变量才会被解锁。
  • 如果对一个变量执行 lock 操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行 load 或 assign 操作初始化变量的值。
  • 如果一个变量事先没有被 lock 操作锁定,则不允许对它执行 unlock 操作,也不允许去 unlock 一个被其他线程锁定住的变量。

Java 内存区域和 JMM 有何区别?Java 内存区域和内存模型是完全不一样的两个东西

  • JVM 内存结构和 Java 虚拟机的运行时区域相关,定义了 JVM 在运行时如何分区存储程序数据,就比如说堆主要用于存放对象实例。
  • Java 内存模型和 Java 的并发编程相关,抽象了线程和主内存之间的关系就比如说线程之间的共享变量必须存储在主内存中,规定了从 Java 源代码到 CPU 可执行指令的这个转化过程要遵守哪些和并发相关的原则和规范,其主要目的是为了简化多线程编程,增强程序可移植性的。

happens-before 原则是什么?

通过一系列规则来定义逻辑时钟的变化,从而能通过逻辑时钟来对分布式系统中的事件的先后顺序进行判断。逻辑时钟并不度量时间本身,仅区分事件发生的前后顺序,其本质就是定义了一种 happens-before 关系happens-before 原则的诞生是为了程序员和编译器、处理器之间的平衡。程序员追求的是易于理解和编程的强内存模型,遵守既定规则编码即可。编译器和处理器追求的是较少约束的弱内存模型,让它们尽己所能地去优化性能,让性能最大化。happens-before 原则的设计思想其实非常简单:

  • 为了对编译器和处理器的约束尽可能少,只要不改变程序的执行结果(单线程程序和正确执行的多线程程序),编译器和处理器怎么进行重排序优化都行。
  • 对于会改变程序执行结果的重排序,JMM 要求编译器和处理器必须禁止这种重排序。

happens-before 原则的定义:

  • 如果一个操作 happens-before 另一个操作,那么第一个操作的执行结果将对第二个操作可见,并且第一个操作的执行顺序排在第二个操作之前。
  • 两个操作之间存在 happens-before 关系,并不意味着 Java 平台的具体实现必须要按照 happens-before 关系指定的顺序来执行。如果重排序之后的执行结果,与按 happens-before 关系来执行的结果一致,那么 JMM 也允许这样的重排序。

happens-before 原则表达的意义其实并不是一个操作发生在另外一个操作的前面,虽然这从程序员的角度上来说也并无大碍。更准确地来说,它更想表达的意义是前一个操作的结果对于后一个操作是可见的,无论这两个操作是否在同一个线程里。

happens-before 常见规则有哪些

  • 程序顺序规则 :一个线程内,按照代码顺序,书写在前面的操作 happens-before 于书写在后面的操作;
  • 解锁规则 :解锁 happens-before 于加锁;
  • volatile 变量规则 :对一个 volatile 变量的写操作 happens-before 于后面对这个 volatile 变量的读操作。说白了就是对 volatile 变量的写操作的结果对于发生于其后的任何操作都是可见的。
  • 传递规则 :如果 A happens-before B,且 B happens-before C,那么 A happens-before C;
  • 线程启动规则 :Thread 对象的 方法 happens-before 于此线程的每一个动作。

happens-before 和 JMM 什么关系

JMM(Java 内存模型)详解 | JavaGuide(Java面试+学习指南)

Java内存模型(JMM)总结 - 知乎

《Java 并发编程的艺术》

编程小号
上一篇 2025-02-06 20:27
下一篇 2025-04-02 13:27

相关推荐

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。
如需转载请保留出处:https://bianchenghao.cn/bian-cheng-ri-ji/42448.html