JVM详解 — 内存模型概述

时间:2021-6-12 作者:qvyue

对于 Java 程序员来说,在虚拟机自动内存管理机制下,不再需要像 C/C++程序开发程序员这样为每一个 new 操作去写对应的 delete/free 操作,不容易出现内存泄漏和内存溢出问题。正是因为 Java 程序员把内存控制权利交给 Java 虚拟机,一旦出现内存泄漏和溢出方面的问题,如果不了解虚拟机是怎样使用内存的,那么排查错误将会是一个非常艰巨的任务。

JVM详解 --- 内存模型概述
JVM内存模型思维导图

1.JVM内存模型概述

  • Runtime data area(运行时数据区):JVM在执行Java程序的过程中会把它所管理的内存划分为若干个不同的数据区域(即JVM的内存),这些数据区域可以分为两个部分:一部分是线程共享的,一部分则是线程私有的。其中,线程共享的数据区包括方法区和堆,线程私有的数据区包括虚拟机栈、本地方法栈和程序计数器。

  • Class loader(类装载子系统):根据给定的全限定名类名(如:java.lang.Object)来装载class文件到Runtime data area中的method area。

  • Execution engine(执行引擎):执行classes中的指令。

  • Native Interface(本地接口):与native libraries交互,是其它编程语言交互的接口。

如下图所示:

JVM详解 --- 内存模型概述
JVM内存模型框架

2.Java程序执行流程

java源文件到最后运行起来,主要会经过两个阶段。其中,第二阶段在java虚拟机上执行的:

  • 编写Java源代码(后缀.java),编辑器编译(javac命令)生成字节码文件(后缀.class)
  • 类加载子系统将字节码文件加载到JVM内存空间,由JVM运行字节码文件(执行引擎找到入口方法main(),执行其中的字节码指令)
JVM详解 --- 内存模型概述
java程序执行流程

3.JVM运行时数据区

不同虚拟机的运行时数据区可能略微有所不同,但都会遵从 Java 虚拟机规范, Java 虚拟机规范规定的区域分为以下 5 个部分,其中线程私有程序计数器、虚拟机栈、本地方法栈;线程共享堆、方法区、直接内存。

JVM详解 --- 内存模型概述
运行时数据区

(1)程序计数器(Program Counter Register)

在多线程情况下,当线程数超过CPU数量或CPU内核数量时,线程之间就要根据 时间片轮询抢夺CPU时间资源。也就是说,在任何一个确定的时刻,一个处理器都只会执行一条线程中的指令。因此,为保证线程切换后能够恢复到正确的执行位置,每条线程都需要一个独立的记录正在执行的虚拟机字节码指令的地址(如果正在执行的是本地方法则为空)。

注意:程序计数器是唯一一个不会出现 OutOfMemoryError 的内存区域,它的生命周期随着线程的创建而创建,随着线程的结束而死亡。

(2) Java 虚拟机栈(Java Virtual Machine Stacks)

Java 内存可以粗糙的区分为堆内存(Heap)和栈内存 (Stack),其中栈就是现在说的虚拟机栈,或者说是虚拟机栈中局部变量表部分。 每个 Java 方法在执行的同时会创建一个栈帧用于存储局部变量表、操作数栈、常量池引用等信息。从方法调用直至执行完成的过程,对应着一个栈帧在 Java 虚拟机栈中入栈和出栈的过程。

JVM详解 --- 内存模型概述
java虚拟机栈

可以通过 -Xss 这个虚拟机参数来指定每个线程的 Java 虚拟机栈内存大小,在 JDK 1.4 中默认为 256K,而在 JDK 1.5+ 默认为 1M:

java -Xss2M HackTheJava

该区域可能抛出以下异常:

  • 当线程请求的栈深度超过最大值,会抛出StackOverflowError 异常;
  • 栈进行动态扩展时如果无法申请到足够内存,会抛出OutOfMemoryError异常。

(3)本地方法栈(Native Method Stack)

本地方法一般是用其它语言(C、C++ 或汇编语言等)编写的,并且被编译为基于本机硬件和操作系统的程序,对待这些方法需要特别处理。

本地方法栈与 Java 虚拟机栈类似,区别在于:虚拟机栈为虚拟机执行 Java 方法 (也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。 在 HotSpot 虚拟机中和 Java 虚拟机栈合二为一。它们之间的只不过是本地方法栈为本地方法服务。

与虚拟机栈一样,本地方法栈区域也会抛出 StackOverflowError 和 OutOfMemoryError 异常。

(4)Java 堆(Java Heap)

Java 虚拟机所管理的内存中最大的一块,线程共享,此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例以及数组都在这里分配内存。Java 堆是垃圾收集器管理的主要区域,因此也被称作GC 堆(Garbage Collected Heap)

现代的垃圾收集器基本都是采用分代收集算法,其主要的思想是针对不同类型的对象采取不同的垃圾回收算法。可以将堆分成两块:

  • 新生代(Young Generation)
  • 老年代(Old Generation)

堆不需要连续内存,并且可以动态增加其内存,增加失败最容易出现的就是 OutOfMemoryError 错误,并且出现这种错误之后的表现形式还会有几种,比如:

  1. OutOfMemoryError: GC Overhead Limit Exceeded : 当JVM花太多时间执行垃圾回收并且只能回收很少的堆空间时,就会发生此错误。
  2. java.lang.OutOfMemoryError: Java heap space :假如在创建新的对象时, 堆内存中的空间不足以存放新创建的对象, 就会引发java.lang.OutOfMemoryError: Java heap space 错误。(和本机物理内存无关,和你配置的内存大小有关!)

可以通过 -Xms 和 -Xmx 这两个虚拟机参数来指定一个程序的堆内存大小,第一个参数设置初始值,第二个参数设置最大值。

java -Xms1M -Xmx2M HackTheJava

(5)方法区(Methed Area)

用于存放已被加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。和堆一样不需要连续的内存,并且可以动态扩展,动态扩展失败一样会抛出 OutOfMemoryError 异常。

对这块区域进行垃圾回收的主要目标是对常量池的回收和对类的卸载,但是一般比较难实现。

HotSpot 虚拟机把它当成永久代来进行垃圾回收。但很难确定永久代的大小,因为它受到很多因素影响,并且每次 Full GC 之后永久代的大小都会改变,所以经常会抛出 OutOfMemoryError 异常。为了更容易管理方法区,从 JDK 1.8 开始,移除永久代,并把方法区移至元空间,它位于本地内存中,而不是虚拟机内存中。原来永久代的数据被分到了堆和元空间中。元空间存储类的元信息,静态变量和常量池等放入堆中。

注意:方法区是一个 JVM 规范,永久代与元空间都是其一种实现方式。

(6)运行时常量池(Runtime Constant Pool)

运行时常量池(Runtime Constant Pool)是方法区的一部分,用于存放编译期生成的各种 字面量 和 符号引用。其中,字面量比较接近Java语言层次的常量概念,如文本字符串、被声明为final的常量值等;而符号引用则属于编译原理方面的概念,包括以下三类常量:类和接口的全限定名、字段的名称和描述符 和 方法的名称和描述符。因为运行时常量池(Runtime Constant Pool)是方法区的一部分,那么当常量池无法再申请到内存时也会抛出 OutOfMemoryError 异常。

Class 文件中的常量池(编译器生成的字面量和符号引用)会在类加载后被放入这个区域。除了在编译期生成的常量,还允许动态生成,例如 String 类的 intern()。

(7)直接内存(Direct Memory)

直接内存并不是虚拟机运行时数据区的一部分,也不是虚拟机规范中定义的内存区域,但是这部分内存也被频繁地使用。而且也可能导致 OutOfMemoryError 错误出现。

JDK1.4 中新加入的 NIO(New Input/Output) 类,引入了一种基于通道(Channel)缓存区(Buffer) 的 I/O 方式,它可以直接使用 Native 函数库直接分配堆外内存,然后通过一个存储在 Java 堆中的 DirectByteBuffer 对象作为这块内存的引用进行操作。这样就能在一些场景中显著提高性能,因为避免了在 Java 堆和 Native 堆之间来回复制数据

本机直接内存的分配不会受到 Java 堆的限制,但是,既然是内存就会受到本机总内存大小以及处理器寻址空间的限制。

4.Java对象在虚拟机中的创建与访问定位

(1)java对象的创建

JVM详解 --- 内存模型概述
java创建对象的过程

Step1:类加载检查

检查虚拟机是否加载了所要new的类,若没有加载,则首先执行相应的类加载过程。虚拟机遇到new指令时,首先去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个引用代表的类是否已经被加载、解析和初始化过。

Step2:内存分配

在类加载检查通过后,对象所需内存的大小在类加载完成后便可完全确定,虚拟机就会为新生对象分配内存。一般来说,根据Java堆中内存是否绝对规整,而 Java 堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。内存的分配有两种方式:

JVM详解 --- 内存模型概述
java对象的内存分配方式

在创建对象的时候有一个很重要的问题,就是线程安全,因为在实际开发过程中,创建对象是很频繁的事情,作为虚拟机来说,必须要保证线程是安全的,通常来讲,虚拟机采用两种方式来保证线程安全:

  • 对分配内存空间的动作进行同步处理采用CAS+失败重试的方式保证更新操作的原子性;
  • 本地线程分配缓冲:每个线程在Java堆中预先分配一小块内存,称为本地线程分配缓冲(TLAB),哪个线程要分配内存,就在自己的TLAB上分配,如果TLAB用完并分配新的TLAB时,再加同步锁定。

Step3:初始化零值

内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),这一步操作保证了对象的实例字段在 Java 代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。

Step4:设置对象头

初始化零值完成之后,虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的 GC 分代年龄等信息。 这些信息存放在对象头中。 另外,根据虚拟机当前运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式。

Step5:执行 init 方法

在上面工作都完成之后,从虚拟机的视角来看,一个新的对象已经产生了,但从 Java 程序的视角来看,对象创建才刚开始, 方法还没有执行,所有的字段都还为零。所以一般来说,执行 new 指令之后会接着执行 方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全产生出来。

(2)对象的内存布局

在 Hotspot 虚拟机中,对象在内存中的布局可以分为 3 块区域:对象头实例数据对齐填充

JVM详解 --- 内存模型概述
对象的内存布局
  • 对象头:对象头分为Mark WordClass Metadata Addresss两个部分, Mark Word存储对象的hashCode、锁信息或者分代年龄GC等标志等信息。Class Metadata Addresss存放指向类元数据的指针,JVM通过这个指针确定该对象是那个类的实列。
  • 实例数据:存放类的属性数据信息,包括父类的属性信息,如果是数组的实例部分还包括数组的长度,这部分内存按4字节对齐
  • 对齐填充:由于虚拟机要求对象起始地址必须是8字节的整数倍。填充数据不是必须存在的,仅仅是为了字节对齐,这点了解即可。

Java对象内存布局中最重要的一块应该就是对象头中的Mark Word部分了,他涉及到了hash值、锁状态、分代年龄等许多非常重要的内容

(3)对象访问定位

创建对象是为了使用对象,我们的Java程序通过栈上的reference数据来操作堆上的具体对象。在虚拟机规范中,reference类型中只规定了一个指向对象的引用,并没有定义这个引用使用什么方式去定位、访问堆中的对象的具体位置。目前的主流的访问方式有使用句柄访问直接指针访问两种。

指针: 指向对象,代表一个对象在内存中的起始地址。

句柄: 可以理解为指向指针的指针,维护着对象的指针。句柄不直接指向对象,而是指向对象的指针(句柄不发生变化,指向固定内存地址),再由对象的指针指向对象的真实内存地址。

  • 句柄访问

Java堆中划分出一块内存来作为句柄池reference中存储对象的句柄地址!而句柄中包含了对象实例数据对象类型数据各自的具体地址信息,具体构造如下图所示:

JVM详解 --- 内存模型概述
句柄访问方式

优势:reference中存储的是稳定的句柄地址,在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中实例数据指针,而reference本身不需要修改。

  • 直接指针访问

如果使用直接指针访问,reference 中存储的直接就是对象地址!那么Java堆对象内部的布局中就必须考虑如何放置访问类型数据的相关信息。

JVM详解 --- 内存模型概述
直接访问方式

优势:速度更,节省了一次指针定位的时间开销。由于对象的访问在Java中非常频繁,因此这类开销积少成多后也是非常可观的执行成本。HotSpot 中采用的就是这种方式。

5.补充(面试常考)

(1)堆与栈的区别

栈与堆的区别主要从物理地址、内存分配、存放内容和程序的可见度进行解释

  • 物理地址

堆的物理地址分配给对象是不连续的,性能慢;栈使用的是数据结构中的栈,先进后出的原则,物理地址分配是连续的。所以性能快。

  • 内存分配

堆因为是不连续的,所以分配的内存是在运行期确认的,因此大小不固定。一般堆大小远远大于栈。栈是连续的,所以分配的内存空间要在编译器就确定,大小是固定的。

  • 存放内容

堆存放的是对象的实例和数组。因此该区更关注的是数据的存储;栈存放的是局部变量、操作数栈与返回结果。因此栈更关注的是程序方法的执行。

  • 程序可见度

堆对整个应用程序都是共享的、可见的,故堆是线程共享的;栈只对线程是可见的,故栈是线程私有的。它的生命周期与线程相同。

(2)详解JVM内存模型

jvm内存模型主要指运行时的数据区,包括5个部分。包括栈、本地方法栈、程序计数器、堆和方法区。可以翻阅上述总结,答题要点:1.各部分的功能;2.线程共有和私有性。

6.参考链接

https://github.com/CyC2018/CS-Notes

https://github.com/Snailclimb/JavaGuide

https://blog.csdn.net/ThinkWon/article/details/104390752

https://gitee.com/SnailClimb/JavaGuide/blob/master/docs/java/jvm/Java

https://blog.csdn.net/javazejian/article/details/72828483)

https://www.jianshu.com/p/e74fe532e35e

声明:本文内容由互联网用户自发贡献自行上传,本网站不拥有所有权,未作人工编辑处理,也不承担相关法律责任。如果您发现有涉嫌版权的内容,欢迎发送邮件至:qvyue@qq.com 进行举报,并提供相关证据,工作人员会在5个工作日内联系你,一经查实,本站将立刻删除涉嫌侵权内容。