JVM内存模型详解

jvm内存架构

img

jvm架构主要分为三个部分:

  1. jvm内存模型,主要包括了方法区,堆,虚拟机栈,程序计数器,本地方法栈。
  2. 执行引擎,包括最核心的解释器和GC垃圾回收器,还有JIT即时编译器
  3. 本地方法接口和库

JVM内存模型

image-20231122222721611

在java中,堆被分为俩个不同的区域:新生代(Young)和老年代(Old),新生代又被划分为三个区域:Eden(伊甸园区),From Survior(幸存者1区)和To Survivor(幸存者2区)

image-20231122223345010

主要存储内容:

  • 对象实例
  • 类初始化生成的对象
  • 基本数据类型的数组也是对象实例
  • 字符串常量池。字符串常量池原本在方法区中,jdk1.8之后开始放置于堆中。
  • 静态变量。1.static修饰的静态变量,jdk1.8时从方法区迁移至堆中;2.线程分配缓冲池(Thread Local Allocation Buffer),是线程私有的,但不影响堆的共性,增加线程分配缓冲池是为了提升对象分配时的效率。

💔堆是java虚拟机所管理的内存中最大的一块区域,也是被各个线程共享的区域,该内存区域存放了对象的实例以及数组(但不是所有对象实例都在堆中,原因在于由于即时编译技术的进步,尤其是逃逸技术的发展,如果一个对象没有发生逃逸,或者只有参数逃逸,就可能为这个对象采取不同程度的优化,比如:栈上分配、标量替换、同步消除)。

其堆大小可以通过-Xms(最小值)和 -Xmx(最大值)参数设置,前者为启动时申请的最小内存,默认为操作系统物理内存的1/64,后者为JVM可申请的最大内存,默认为物理内存的1/4,默认当空余堆内存小于40%时,JVM会增大堆内存到-Xmx指定的大小,可通过-XX:MinHeapFreeRation=来指定这个比列。

在我们垃圾回收的时候,我们往往会将堆内存分成新生代和老年代(大小比例为1:2),新生代中的Eden和survivor0,survivor1组成,三者的比例为8:1:1,新生代的回收机制采用复制算法,在Minor GC的时候,我们都会留一个存活区来存放活的对象,真正进行的区域是Eden和一个存活区,当我们的对象时长(年龄)超过一定的年龄时(默认15,可以通过参数设置),将会把对象放入老年代,当然很大的对象会直接进入老年代,老年代采用的垃圾回收算法是标记清除算法或者标记整理算法。

补充说明:

1
2
3
新生代普遍采用复制算法的原因是因为新生代中的对象存活时间较短,采用复制算法可以有效地解决新生代中的对象频繁回收的问题。复制算法将新生代的内存空间分为两部分,每次只使用其中一部分进行对象的分配,当这部分内存空间被占满时,将存活的对象复制到另一部分空间中,同时清除已经死亡的对象,这样可以保证内存空间的连续性和高效的垃圾回收。

而老年代中的对象存活时间较长,采用标记清除算法或者标记整理算法的原因是因为这两种算法可以有效地处理老年代中的大规模对象回收问题。标记清除算法通过标记所有存活的对象,然后清除所有未标记的对象来进行垃圾回收,但会产生内存碎片;而标记整理算法则是在标记存活对象的同时,将存活的对象向一端移动,然后清理掉另一端的内存空间,从而解决了内存碎片的问题。这两种算法都适合处理老年代中的对象回收问题,因此在老年代中普遍采用。

方法区

方法区(Methed Area)和java堆一样,是各个线程共享的内存区域,它用于存储已虚拟机加载的类型信息、常量、静态常量、即时编译器编译后的代码缓存等数据。虽然《Java虚拟机规范》中把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫作“非堆”(Non-Heap),目的是与Java堆区分开来。

方法区的实现,对于JDK8之前的版本,我们都把他称为永久代,或者将两者混为一谈,其实两者并不是一个概念,使用永久代来实现方法区,可以像java堆一样去管理方法区的内存,而它会更容易导致内存溢出的问题(永久代有上限,参数:-XX:MaxPermSize,即使不设置也会有默认大小),到了JDK7,尝试将字符串常量池、静态变量移出来,而在JDK8之后的版本,就完全舍弃了永久代,改用元空间来实现

方法区演变:img

img

img

img

1.类型信息

对每个加载的类型(类class、接口interface、枚举enum、注解annotation),JVM必须在方法区中存储以下类型信息:
①这个类型的完整有效名称(全名=包名.类名)
②这个类型直接父类的完整有效名(对于interface或是java.lang.0bject,都没有父类)
③这个类型的修饰符(public, abstract,final的某个子集)
④这个类型直接接口的一个有序列表

2.域信息(Field)成员变量

JVM必须在方法区中保存类型的所有域的相关信息以及域的声明顺序。
域的相关信息包括:域名称、域类型、域修饰符(public, private,protected,static,final, volatile, transient的某个子集)

3.方法(Method)信息

JVM必须保存所有方法的以下信息,同域信息一样包括声明顺序:

  • 方法名称
  • 方法的返回类型(或void)·方法参数的数量和类型(按顺序)
  • 方法的修饰符(public, private,protected,static, final,synchronized,native,abstract的一个子集)
  • 方法的字节码(bytecodes)、操作数栈、局部变量表及大小(abstract和native方法除外)

4.运行时常量池

方法区中运行时常量池是用来存放编译期生成的各种字面量和符号引用的。它包含了类文件中的常量池表、字段和方法的符号引用和字面量,以及运行时生成的一些常量。在方法区中运行时常量池的作用包括但不限于:

  1. 存放类和接口的全限定名、字段的名称和描述符、方法的名称和描述符等信息。
  2. 存放字符串常量、数字常量等字面量。
  3. 存放类和接口的符号引用,如父类、接口、字段、方法等。
  4. 存放运行时生成的一些常量,如动态生成的方法、字段等。

在程序运行时,方法区中运行时常量池的内容会被加载到内存中,并被虚拟机使用。它为虚拟机提供了必要的信息来执行类的加载、链接和初始化等操作。因此,方法区中运行时常量池在程序的运行过程中扮演着非常重要的角色。

5.字符串常量池
在JDK6.0及之前版本,字符串常量池存放在方法区中在JDK7.0版本以后,字符串常量池被移到了堆中了。

❓为什么jdk1.7要将字符串常量池移动到堆中

​ 答:主要是因为永久代(方法区实现)的GC回收效率太低,只有在整堆收集(full GC)的时候才会被执行GC,java程序中通常会有大量的被创建的字符串等待回收,将字符串常量池放到堆中,能够高效及时地回收字符串内存。

在HotSpot VM里实现的string pool功能的是一个StringTable类,它是一个Hash表,默认值大小长度是1009;这个StringTable在每个HotSpot VM的实例只有一份,被所有的类共享。字符串常量由一个一个字符组成,放在了StringTable上。

在JDK6.0中,StringTable的长度是固定的,长度就是1009,因此如果放入String Pool中的String非常多,就会造成hash冲突,导致链表过长,当调用String#intern()时会需要到链表上一个一个找,从而导致性能大幅度下降;在JDK7.0中,StringTable的长度可以通过参数指定。

程序计数器

程序计数器是一块较小的内存空间,可以看做当前线程所执行的字节码的行号指示器。字节码解释器工作是通过改变这个计数器的值来选择下一条执行的字节码指令,分支、循环、异常处理、线程恢复等功能都需要依赖这个计数器来完成。

另外,为了线程切换后能够恢复得到正确的执行位置,每个线程都需要有一个独立的程序计数器,各个线程之间互不影响,独立存储。所以程序计数器是线程私有的。

程序计数器主要拥有俩个作用:

  • 字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制,如:顺序执行、选择、循环、异常处理。
  • 在多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来的时候就能够知道该线程上次运行到哪了。

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

Java虚拟机栈

虚拟机栈也就是平常所称的栈内存,每个线程对应一个私有的栈,随着线程的创建而创建。栈里面存着的是一种叫“栈帧”的东西,每个方法在执行的同时都会创建一个栈帧,方法被执行时入栈,执行完后出栈。不存在垃圾回收问题,只要线程一结束该栈就释放,生命周期和线程一致。每个栈帧主要包含的内容如下:

Java虚拟机栈的示例分析 - 开发技术 - 亿速云

  • 局部变量表:存储着 java 基本数据类型(byte/boolean/char/int/long/double/float/short)以及对象的引用( 注意:这里的基本数据类型指的是方法内的局部变量),局部变量表随着栈帧的创建而创建,它的大小在编译时确定,创建时只需分配事先规定的大小即可。在方法运行过程中,局部变量表的大小不会发生改变。

    局部变量表

  • 操作数栈:主要作为方法调用的中转站使用,用于存放方法执行过程中产生的中间计算结果。另外,计算过程中产生的临时变量也会放在操作数栈中。

  • 动态连接:主要服务一个方法需要调用其他方法的场景。Class 文件的常量池里保存有大量的符号引用比如方法引用的符号引用。当一个方法要调用其他方法,需要将常量池中指向方法的符号引用转化为其在内存地址中的直接引用。动态链接的作用就是为了将符号引用转换为调用方法的直接引用,这个过程也被称为 动态连接

  • 方法返回地址

虚拟机栈可能会抛出两种异常:

  • 栈溢出(StackOverFlowError):若 Java 虚拟机栈的大小不允许动态扩展,那么当线程请求栈的深度超过当前 Java 虚拟机栈的最大深度时,抛出 StackOverFlowError 异常
  • 内存溢出(OutOfMemoryError):若虚拟机栈的容量允许动态扩展,那么当线程请求栈时内存用完了,无法再动态扩展时,抛出 OOM 异常

本地方法栈

本地方法栈是为 JVM 运行 Native 方法准备的空间,由于很多 Native 方法都是用 C 语言实现的,所以它通常又叫 C 栈。

本地方法栈与虚拟机栈的作用是相似的,都是线程私有的,只不过本地方法栈是描述本地方法运行过程的内存模型。本地方法被执行时,在本地方法栈也会创建一块栈帧,用于存放该方法的局部变量表、操作数栈、动态链接、方法出口信息等。方法执行结束后,相应的栈帧也会出栈,并释放内存空间。也会抛出 StackOverFlowError 和 OutOfMemoryError 异常。

虚拟机栈和本地方法栈的主要区别:虚拟机栈执行的是 java 方法,本地方法栈执行的是 native 方法

直接内存

直接内存不是虚拟机运行时数据区的一部分,而是在 Java 堆外,直接向系统申请的内存区域。

常见于 NIO 操作时,用于数据缓冲区(比如 ByteBuffer 使用的就是直接内存)。

分配、回收成本较高,但读写性能高。直接内存不受 JVM 内存回收管理(直接内存的分配和释放是 Java 会通过 UnSafe 对象来管理的),但是系统内存是有限的,物理内存不足时会报OOM。

Java 程序内存 = JVM 内存 + 本地内存

JVM 内存(JVM 虚拟机数据区):Java 虚拟机在执行的时候会把管理的内存分配到不同的区域,这些区域称为虚拟机(JVM)内存。JVM 内存受虚拟机内存大小的参数控制,当大小超过参数设置的大小时会报 OOM

本地内存(元空间 + 直接内存):对于虚拟机没有直接管理的物理内存,也会有一定的利用,这些被利用但不在虚拟机内存的地方称为本地内存。本地内存不受虚拟机内存参数的限制,只受物理内存容量的限制。\n\n虽然不受参数的限制,如果所占内存超过物理内存,仍然会报 OOM

image-20231123133436410