JVM系列(四) -内存布局详解

一、摘要

熟悉 Java 语言特性的同学都知道,相比 C、C++ 等编程语言,Java 无需通过手动方式回收内存,内存中所有的对象都可以交给 Java 虚拟机来帮助自动回收;而像 C、C++ 等编程语言,需要开发者通过代码手动释放内存资源,否则会导致内存溢出。

尽管如此,如果编程不当,Java 应用程序也可能会出现内存溢出的现象,例如下面这个异常!

Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
    at java.util.Arrays.copyOf(Arrays.java:2760)
    at java.util.Arrays.copyOf(Arrays.java:2734)
    at java.util.ArrayList.ensureCapacity(ArrayList.java:167)
    at java.util.ArrayList.add(ArrayList.java:351)

它表示当前服务已出现内存溢出,简单的说就是当服务出现了内存不足时,就会抛OutOfMemoryError异常。

这种异常是怎么出现的呢?该如何解决呢?

熟悉 JVM 内存结构的同学,可能会很快看得出以上错误信息表示虚拟机堆内存空间不足,因此了解 JVM 内存结构对快速定位问题并解决问题有着非常重要的意义。今天我们一起来了解一下 JVM 内存结构。

本文以 JDK1.7 版本为例,不同的版本 JVM 内存布局可能稍有不同,但是所涉及的知识点基本大同小异。

二、内存结构介绍

Java 虚拟机在执行程序的过程中,会把所管理的内存划分成若干不同的数据区域。这些区域各有各有的用途,有的区域会随着虚拟机进程的启动而一直存在;有的区域会伴随着用户线程的启用和结束而创建和销毁。

其次,JVM 内存区域也称为运行时数据区域,这些数据区域包括:程序计数器、虚拟机栈、本地方法栈、堆、方法区等,可以用如下图来简要概括。

其中,运行时数据区的程序计数器、虚拟机栈、本地方法栈属于每个线程私有的区域;堆和方法区属于所有线程间共享的区域

运行时数据区的线程间内存区域布局,可以用如下图来简要描述:

下面我们一起来看下每个区域的作用。

2.1、程序计数器

程序计数器(Program Counter Register)是一块较小的内存空间,它的作用可以看做是当前线程所执行的字节码的行号指示器。

在虚拟机的概念模型里,字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,比如分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。

我们知道 Java 是支持多线程的,其中虚拟机的多线程就是通过轮流切换线程并分配处理器执行时间的方式来实现的。在任何一个确定的时刻,一个处理器(对于多核处理器来说是一个内核)只会执行一条线程中的指令,为了线程切换后能恢复到正确的执行位置,虚拟机为每个线程都设计了一个独立的程序计数器,各条线程之间的程序计数器互不影响,独立存储,属于线程私有的内存区域,生命周期与线程相同

在 JVM 规范中,如果线程执行的是非native方法,则程序计数器中保存的是当前需要执行的指令的地址;如果线程执行的是native方法,则程序计数器中的值是Undefined,也就是空。

由于程序计数器中存储的数据所占空间的大小不会随程序的执行而发生改变,因此,此内存区域是唯一一个在 JVM 规范中没有规定任何OutOfMemoryError情况的区域

2.2、虚拟机栈

虚拟机栈(Java Virtual Machine Stacks)与程序计数器一样,也是线程私有的内存区域,它的生命周期与线程相同

虚拟机栈描述的是 Java 方法执行时的内存模型,每个方法执行的时候都会创建一个栈帧(Stack Frame), 用于存储局部变量表、操作数栈、动态链接、方法出口和一些额外的附加信息。每一个方法从被调用直到执行完成的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的全过程。

虚拟机栈内部结构,可以用如下图来简要描述。

下面简单看看栈帧里的四种组成元素的作用。

2.2.1、局部变量表

局部变量表是一组变量值的存储空间,用于存储方法参数和局部变量,例如:

  • 基本数据类型:比如 boolean、byte、char、short、int、float、long、double 等 8 种基本数据类型
  • 对象引用类型:指向对象起始地址的引用指针
  • 返回地址类型:指向一条字节码指令的返回地址

通常,局部变量表的内存空间在编译器就会确定其大小,当进入一个方法时,这个方法需要在帧中分配多大的局部变量空间是可以完全确定的,因此在程序执行期间局部变量表的大小是不会改变的。

其次,局部变量表的最小单位为 32 位的字长,对于 64 位的 long 和 double 变量而言,虚拟机会为其分配两个连续的局部变量空间。

2.2.2、操作数栈

操作数栈也常称为操作栈,是一个后入先出的栈。虚拟机会利用操作栈的压栈和出栈操作来执行指令运算。

比如下面的两个数据相加的计算示例。

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的位置,完成数据的计算。

2.2.3、动态链接

每个栈帧都包含一个对当前方法类型的运行时常量池的引用,以支持方法调用过程中的动态链接。可以简单的理解成,当前栈帧与运行时常量池的方法引用建立链接。

比如方法 a 入栈后,栈帧中的动态链接会持有对当前方法所属类的常量池的引用,当方法 a 中调用了方法 b(符号引用),就可以通过运行时常量池查找到方法 b 具体的直接引用(方法地址),然后调用执行。

2.2.4、方法出口

当一个方法执行完毕之后,要返回之前调用它的地方,因此在栈帧中必须保存一个方法返回地址,也称为方法出口。

在虚拟机栈中,只有两种方式可以退出当前方法:

  • 正常返回:当执行遇到返回指令,会将返回值传递给上层的方法调用者,这种退出方式称为正常返回,一般来说,调用者的程序计数器可以作为方法返回地址
  • 异常返回:当执行遇到异常,并且当前方法体内没有得到处理,就会导致方法退出,此时是没有返回值的,这种退出方式称为异常返回,返回地址要通过异常处理器表来确定

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

  • 1.恢复上层方法的局部变量表和操作数栈
  • 2.把返回值压入调用者栈帧的操作数栈
  • 3.将程序计数器的值指向下一条方法指令位置
2.2.5、小结

在 JVM 规范中,对这个内存区域规定了两种异常状况:

  • 如果当前线程请求的栈深度大于虚拟机栈所允许的深度,将抛出StackOverFlowError异常(当前虚拟机栈不允许动态扩展的情况下)
  • 如果虚拟机栈可以动态扩展,当扩展到无法申请内存到足够的内存,就会抛出OutOfMemoryError异常
2.3、本地方法栈

本地方法栈(Native Method Stacks)与虚拟机栈发挥的作用非常相似,主要区别在于:虚拟机栈为虚拟机执行 Java 方法(也就是字节码)服务;本地方法栈则是为虚拟机使用到的Native方法服务(通常采用 C 编写)

有些虚拟机发行版本,比如Sun HotSpot虚拟机,直接将本地方法栈和 Java 虚拟机栈合二为一。与虚拟机栈一样,本地方法栈也会抛出StackOverflowErrorOutOfMemoryError异常。

2.4、堆

Java 堆是被所有线程共享的最大的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例和数组都在这里分配内存,也是出现OutOfMemoryError异常最常见的区域。

在虚拟机中,堆被划分成两个不同的区域:年轻代 (Young Generation) 和老年代 (Old Generation),默认情况下按照1 : 2的比例来分配空间

其中年轻代又被划分为三个不同的区域:Eden 区、From Survivor 区、To Survivor 区,默认情况下按照8 : 1 : 1的比例来分配空间

整个堆内存的空间划分,可以用如下图来简要描述。

这样划分的目的是为了使 JVM 能够更好的管理堆内存中的对象,包括内存的分配以及回收。

新创建的对象分配会首先放在年轻代的 Eden 区,此区的对象回收频次会比较高,Survivor 区作为 Eden 区和 Old 区之间的缓冲区,在 Survivor 区的对象经历若干次收集仍然存活的,就会被转移到老年代 Old 区。

关于对象内存回收的相关知识,我们在后续的文章会再次进行介绍。

2.5、方法区

方法区在 JVM 中也是一个非常重要的区域,和 Java 堆一样,也是多个线程共享区域,它用于存储类的信息(包括类的名称、方法信息、字段信息)、静态变量、常量以及即时编译后的代码等数据。

为了与 Java 堆区分,它还有一个别名 Non-Heap(非堆的意思)。相对而言,GC 对于这个区域的收集是很少出现的,但是也不意味着不会出现异常,当方法区无法满足内存分配需求时,也会抛出OutOfMemoryError异常。

在 Java 7 及之前版本,大家也习惯称方法区它为“永久代”(Permanent Generation),更确切来说,应该是“HotSpot 使用永久代实现了方法区”!

2.6、运行时常量池

运行时常量池是方法区的一部分Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池 (Constant pool table),用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后进入运行时常量池中存放

运行时常量池的功能类似于传统编程语言的符号表,方便下游程序通过查表可找到对应的数据信息。

同时,运行时常量池相对于Class文件常量池的另外一个特性是具备动态性,Java 语言并不要求常量一定只有编译器才产生,也就是说并非预置入Class文件中常量池的内容才能进入方法区运行时常量池,运行期间也可能将新的常量放入池中,其中String.intern()方法就是这个特性的应用。

2.7、直接内存

在之前的 Java NIO 文章中,我们提及到直接内存。直接内存(Direct Memory)并不是虚拟机运行时数据区的一部分,也不是 JVM 规范中定义的内存区域。

在 JDK1.4 中引入了 NIO 机制,它允许 Java 程序直接从操作系统中分配直接内存,这部分内存也被称为堆外内存,在某些场景下可以提高程序执行性能,因为避免了在 Java 堆和 Native 堆中来回复制数据的耗时。

Java NIO 创建堆外内存的简单示例。

// 创建直接内存
ByteBuffer byteBuffer = ByteBuffer.allocateDirect(1024);

这部分内存如果出现资源不足,也可能导致OutOfMemoryError异常出现。

三、小结

通过以上的内容分析,相信大家对 JVM 内存结构以及相关的区域用途有了一些初步的了解。

后续小编会再次介绍 JVM 内存相关的调优参数。

四、参考

1.https://zhuanlan.zhihu.com/p/43279292

2.http://www.ityouknow.com/jvm/2017/08/25/jvm-memory-structure.html

3.https://www.cnblogs.com/xrq730/p/4827590.html

4.https://www.cnblogs.com/aflyun/p/10575740.html

5.https://zhuanlan.zhihu.com/p/371778309

写到最后

很早之前和小伙伴们分享过 JVM 相关的技术知识,再次感谢大家的支持和反馈。

经过几个月的努力,对 JVM 技术知识进行了重新整理,最后再次献上 JVM系列文章合集索引,感兴趣的小伙伴可以点击查看。

最后。如果感觉文章内容不错,帮忙动动小指头点个赞,点赞对我真的非常重要!加个关注我会非常感激!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值