JVM笔记(二)JVM内存模型

目录

JVM内存结构

程序计数器(线程私有)

Java虚拟机栈(线程私有)

本地方法栈(线程私有)

方法区(线程共享)

堆(线程共享)

直接内存(线程共享)


JVM内存结构

image-20220310144543645
image-20220208155105680

Java 虚拟机在执行 Java 程序的过程中会把它管理的内存划分成若干个不同的数据区域。JDK 1.8 和之前的版本略有不同,下面会介绍到。

img
img

Jvm 内存区域主要分为线程私有区域 程序计数器、虚拟机栈、本地方法区,线程共享区域 堆、方法区、直接内存

线程私有数据区域生命周期与线程相同, 依赖用户线程的启动/结束 而 创建/销毁(在 Hotspot VM 内, 每个线程都与操作系统的本地线程直接映射, 因此这部分内存区域的存/否跟随本地线程的生/死对应)。

线程共享区域随虚拟机的启动/关闭而创建/销毁。直接内存并不是 JVM 运行时数据区的一部分, 但也会被频繁的使用: 在 JDK 1.4 引入的 NIO 提供了基于 Channel 与 Buffer 的 IO 方式, 它可以使用 Native 函数库直接分配堆外内存, 然后使用DirectByteBuffer 对象作为这块内存的引用进行操作(详见: Java I/O 扩展), 这样就避免了在 Java堆和 Native 堆中来回复制数据, 因此在一些场景中可以显著提高性能。

image-20220208162650003

程序计数器(线程私有)

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

另外,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各线程之间计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存。

从上面的介绍中我们知道程序计数器主要有两个作用:

  • 字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制,如:顺序执行、选择、循环、异常处理。
  • 在多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来的时候能够知道该线程上次运行到哪儿了。
  • 每一个线程都有自己的程序计数器
  • 线程是私有的,生命周期与线程相同,随JVM启动而生,JVM关闭而死。
  • 线程执行Native方法时,计数器记录为空(Underfined)

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

Java虚拟机栈(线程私有)

线程执行期间,每个方法执行时间都会创建一个栈桢(Stack Frame),用于存储局部变量表、操作数栈、动态链接、方法出口等信息。

是描述java方法执行的内存模型,每个方法在执行的同时都会创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态链接、方法出口等信息

每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程

栈帧(Frame)是用来存储数据和部分过程结果的数据结构,同时也被用来处理动态链接(Dynamic Linking)、 方法返回值和异常分派(Dispatch Exception)。

栈帧随着方法调用而创建,随着方法结束而一一销毁。无论方法是正常完成还是异常完成(抛出了在方法内未被捕获的异常)都算作方法结束。

image-20220208163316278
  • 局部变量表

局部变量表是一组变量值的存储空间,用于存储方法参数局部变量。 在 Class 文件的方法表的 Code 属性的 max_locals 指定了该方法所需局部变量表的最大容量

局部变量表在编译期间分配内存空间,可以存放编译期的各种变量类型:

  1. 基本数据类型boolean, byte, char, short, int, float, long, double8种;
  2. 对象引用类型reference,指向对象起始地址引用指针; 3. 返回地址类型returnAddress,返回地址的类型。

变量槽(Variable Slot):

变量槽局部变量表最小单位,规定大小为32位。对于64位的longdouble变量而言,虚拟机会为其分配两个连续Slot空间。

  • 操作数栈

操作数栈(Operand Stack)也常称为操作栈,是一个后入先出栈。在 Class 文件的 Code 属性的 max_stacks 指定了执行过程中最大的栈深度。Java虚拟机的解释执行引擎被称为基于栈的执行引擎 ,其中所指的就是指-操作数栈。 1. 和局部变量表一样,操作数栈也是一个以32字长为单位的数组。 2. 虚拟机在操作数栈中可存储的数据类型intlongfloatdoublereferencereturnType等类型 (对于byteshort以及char类型的值在压入到操作数栈之前,也会被转换为int)。 3. 和局部变量表不同的是,它不是通过索引来访问,而是通过标准的栈操作压栈出栈来访问。比如,如果某个指令把一个值压入到操作数栈中,稍后另一个指令就可以弹出这个值来使用。

虚拟机把操作数栈作为它的工作区——大多数指令都要从这里弹出数据,执行运算,然后把结果压回操作数栈

begin
iload_0    // push the int in local variable 0 onto the stack
iload_1    // push the int in local variable 1 onto the stack
iadd       // pop two ints, add them, push result
istore_2   // pop int, store into local variable 2
end

在这个字节码序列里,前两个指令 iload_0iload_1 将存储在局部变量表中索引为01的整数压入操作数栈中,其后iadd指令从操作数栈中弹出那两个整数相加,再将结果压入操作数栈。第四条指令istore_2则从操作数栈中弹出结果,并把它存储到局部变量表索引为2的位置。

下图详细表述了这个过程中局部变量表操作数栈的状态变化(图中没有使用的局部变量表操作数栈区域以空白表示)。

image-20220310153759747
  • 动态链接

每个栈帧都包含一个指向运行时常量池中所属的方法引用,持有这个引用是为了支持方法调用过程中的动态链接

Class文件的常量池中存在有大量的符号引用,字节码中的方法调用指令就以常量池中指向方法的符号引用为参数。这些符号引用:

  1. 静态解析:一部分会在类加载阶段或第一次使用的时候转化为直接引用(如finalstatic域等),称为静态解析
  2. 动态解析:另一部分将在每一次的运行期间转化为直接引用,称为动态链接
  • 方法出口

当一个方法开始执行以后,只有两种方法可以退出当前方法:

  1. 正常返回:当执行遇到返回指令,会将返回值传递给上层的方法调用者,这种退出的方式称为正常完成出口(Normal Method Invocation Completion),一般来说,调用者的PC计数器可以作为返回地址。
  2. 异常返回:当执行遇到异常,并且当前方法体内没有得到处理,就会导致方法退出,此时是没有返回值的,称为异常完成出口(Abrupt Method Invocation Completion),返回地址要通过异常处理器表来确定。

当一个方法返回时,可能依次进行以下3个操作:

  1. 恢复上层方法局部变量表操作数栈
  2. 返回值压入调用者栈帧操作数栈
  3. PC计数器的值指向下一条方法指令位置。

与程序计数器一样,Java 虚拟机栈也是线程私有的,它的生命周期和线程相同,描述的是 Java 方法执行的内存模型,每次方法调用的数据都是通过栈传递的。

Java 内存可以粗糙的区分为堆内存(Heap)和栈内存 (Stack),其中栈就是现在说的虚拟机栈,或者说是虚拟机栈中局部变量表部分。 (实际上,Java 虚拟机栈是由一个个栈帧组成,而每个栈帧中都拥有:局部变量表、操作数栈、动态链接、方法出口信息。)

局部变量表主要存放了编译期可知的各种数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference 类型,它不同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他与此对象相关的位置)。

Java 虚拟机栈会出现两种错误:StackOverFlowErrorOutOfMemoryError

  • StackOverFlowError 若 Java 虚拟机栈的内存大小不允许动态扩展,那么当线程请求栈的深度超过当前 Java 虚拟机栈的最大深度的时候,就抛出 StackOverFlowError 错误。
  • OutOfMemoryError Java 虚拟机栈的内存大小可以动态扩展, 如果虚拟机在动态扩展栈时无法申请到足够的内存空间,则抛出OutOfMemoryError异常。
image-20220208163717161

Java 虚拟机栈也是线程私有的,每个线程都有各自的 Java 虚拟机栈,而且随着线程的创建而创建,随着线程的死亡而死亡。

扩展:那么方法/函数如何调用?

Java 栈可以类比数据结构中栈,Java 栈中保存的主要内容是栈帧每一次函数调用都会有一个对应的栈帧被压入 Java 栈,每一个函数调用结束后,都会有一个栈帧被弹出。

Java 方法有两种返回方式:

  1. return 语句。
  2. 抛出异常。

不管哪种返回方式都会导致栈帧被弹出。

本地方法栈(线程私有)

本地方法栈和 Java虚拟机栈(Java Stack) 作用类似, 区别是虚拟机栈为执行 Java 方法服务, 而本地方法栈则为Native 方法服务, 如果一个 VM 实现使用 C-linkage 模型来支持 Native 调用, 那么该栈将会是一个C 栈,但 HotSpot VM 直接就把本地方法栈和虚拟机栈合二为一。

和虚拟机栈所发挥的作用非常相似,区别是: 虚拟机栈为虚拟机执行 Java 方法 (也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。 在 HotSpot 虚拟机中和 Java 虚拟机栈合二为一。

本地方法栈跟虚拟机栈的功能类似,虚拟机栈用于管理 Java 函数的调用,而本地方法栈则用于管理本地方法的调用。这里的「本地方法」指的是「非Java方法」,一般本地方法是使用C语言实现的。

本地方法被执行的时候,在本地方法栈也会创建一个栈帧,用于存放该本地方法的局部变量表、操作数栈、动态链接、出口信息。

方法执行完毕后相应的栈帧也会出栈并释放内存空间,也会出现 StackOverFlowErrorOutOfMemoryError 两种错误。

方法区(线程共享)

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

前面提到了运行时数据区这个分区是JVM的规范,具体的落地实现,不同的虚拟机厂商可能是不一样的。所以方法区也只是 JVM 中规范的一部分而已。在HotSpot虚拟机,就会常常提到永久代这个词。HotSpot虚拟机在JDK8前用「永久代」实现了「方法区」,而很多其他厂商的虚拟机其实是没有「永久代」的概念的。

img

在JDK8中,已经用元空间来替代了「永久代」作为「方法区」的实现了

-XX:MetaspaceSize=N //设置 Metaspace 的初始(和最小大小)
-XX:MaxMetaspaceSize=N //设置 Metaspace 的最大大小

关于为什么要将永久代被替换成元空间?

最主要的区别就是:元空间存储不在虚拟机中,而是使用本地内存JVM 不会再出现方法区的内存溢出以往永久代经常因为内存不够用导致跑出OOM异常。

按JDK8版本,总结起来其实就相当于:「类信息」是存储在「元空间」的(也有人把「类信息」这块叫做「类信息常量池」,主要是叫法不同,意思到位就好)而「常量池」用JDK7开始,从「物理存储」角度上就在「堆中」,这是没有变化的。

img

方法区主要是用来存放已被虚拟机加载的 类相关信息

  • 包括类信息
    • 类信息又包括了类的版本、
    • 字段
    • 方法
    • 接口
    • 父类等信息。
  • 常量池
    • 静态常量池
      • 静态常量池主要存储的是字面量以及符号引用等信息。静态常量池也包括了我们说的字符串常量池
    • 运行时常量池
      • 存储的是类加载时生成的直接引用等信息。(所以有时候面试会遇到,问你:在java中的常量池存储的是对象还是对象的引用,看到这儿就不难回答了。显然是引用。)

又值得注意的是:从逻辑分区的角度而言,常量池是属于方法区的,但自从在JDK7以后,就已经把运行时常量池和静态常量池转移到了堆内存中进行存储(对于物理分区来说运行时常量池和静态常量池就属于堆)。

image-20220208172557401

堆(线程共享)

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

Java 世界中“几乎”所有的对象实例都在堆中分配,但是,随着 JIT 编译器的发展与逃逸分析技术逐渐成熟,栈上分配、标量替换优化技术将会导致一些微妙的变化,所有的对象都分配到堆上也渐渐变得不那么“绝对”了。从 JDK 1.7 开始已经默认开启逃逸分析,如果某些方法中的对象引用没有被返回或者未被外面使用(也就是未逃逸出去),那么对象可以直接在栈上分配内存。

Java 堆是垃圾收集器管理的主要区域,因此也被称作GC 堆(Garbage Collected Heap)。从垃圾回收的角度,由于现在收集器基本都采用分代垃圾收集算法,所以 Java 堆还可以细分为:

  • 新生代和老年代
  • 新生代再细致一点有:Eden(伊甸园) 空间、From Survivor()、To Survivor() 空间等

进一步划分的目的是更好地回收内存,或者更快地分配内存。

在 JDK 7 版本及 JDK 7 版本之前,堆内存被通常分为下面三部分:

  • 新生代内存(Young Generation)

  • 老生代(Old Generation)

  • 永生代(Permanent Generation)

img

JDK 8 版本之后方法区(HotSpot 的永久代)被彻底移除了(JDK1.7 就已经开始了),取而代之是元空间,元空间使用的是直接内存。

image-20220208165316730

上图所示的 Eden 区、两个 Survivor 区都属于新生代(为了区分,这两个 Survivor 区域按照顺序被命名为 from 和 to),中间一层属于老年代。

大部分情况,对象都会首先在 Eden 区域分配,在一次新生代垃圾回收后,如果对象还存活,则会进入 s0 或者 s1,并且对象的年龄还会加 1(Eden 区->Survivor 区后对象的初始年龄变为 1),当它的年龄增加到一定程度(默认为 15 岁),就会被晋升到老年代中。对象晋升到老年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold 来设置。

“Hotspot 遍历所有对象时,按照年龄从小到大对其所占用的大小进行累积,当累积的某个年龄大小超过了 survivor 区的一半时,取这个年龄和 MaxTenuringThreshold 中更小的一个值,作为新的晋升年龄阈值”。

动态年龄计算的代码如下

uint ageTable::compute_tenuring_threshold(size_t survivor_capacity) {
	//survivor_capacity是survivor空间的大小
size_t desired_survivor_size = (size_t)((((double) survivor_capacity)*TargetSurvivorRatio)/100);
size_t total = 0;
uint age = 1;
while (age < table_size) {
total += sizes[age];//sizes数组是每个年龄段对象大小
if (total > desired_survivor_size) break;
age++;
}
uint result = age < MaxTenuringThreshold ? age : MaxTenuringThreshold;
	...
}

堆这里最容易出现的就是 OutOfMemoryError 错误,并且出现这种错误之后的表现形式还会有几种,比如:

  1. java.lang.OutOfMemoryError: GC Overhead Limit Exceeded : 当 JVM 花太多时间执行垃圾回收并且只能回收很少的堆空间时,就会发生此错误。
  2. java.lang.OutOfMemoryError: Java heap space :假如在创建新的对象时, 堆内存中的空间不足以存放新创建的对象, 就会引发此错误。(和配置的最大堆内存有关,且受制于物理内存大小。最大堆内存可通过-Xmx参数配置,若没有特别配置,将会使用默认值,详见:Default Java 8 max heap size (opens new window))
  3. ......

直接内存(线程共享)

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

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

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

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值