好家伙!JVM 内存区域划分得这么洒脱

共 4437字,需浏览 9分钟

 ·

2021-04-07 17:54

虽然这周只上了两天班,但今天已经周三了!有没有一种占了资本家便宜的感觉?

嗯,不好意思,今天既不是广告,也不是水文,仍然是硬货技术文,关于 JVM 的内存区域划分,有没有小失望,哈哈哈。

在谈 JVM 内存区域划分之前,我们先来看一下 Java 程序的具体执行过程,我画了一幅图。

Java 源代码文件经过编译器编译后生成字节码文件,然后交给 JVM 的类加载器,加载完毕后,交给执行引擎执行。在整个执行的过程中,JVM 会用一块空间来存储程序执行期间需要用到的数据,这块空间一般被称为运行时数据区,也就是常说的 JVM 内存。

所以,当我们在谈 JVM 内存区域划分的时候,其实谈的就是这块空间——运行时数据区。

大家应该对官方出品的《Java 虚拟机规范》有所了解吧?了解这个规范可以让我们更深入地理解 JVM。该规范主要包含 6 个部分,分别是:

  • 第一章:引言
  • 第二章:Java 虚拟机结构
  • 第三章:Java 虚拟机编译
  • 第四章:Class 文件
  • 第五章:加载、链接和初始化
  • 第六章:Java 虚拟机指令集
  • 第七章:操作码

根据第二章 Java 虚拟机结构中的规定,运行时数据区可以分为以下几个部分,见下图。

01、程序计数器

程序计数器(Program Counter Register)所占的内存空间不大,很小一块,可以看作是当前线程所执行的字节码指令的行号指示器。字节码解释器会在工作的时候改变这个计数器的值来选取下一条需要执行的字节码指令,像分支、循环、跳转、异常处理、线程恢复等功能都需要依赖这个计数器来完成。

在 JVM 中,多线程是通过线程轮流切换来获得 CPU 执行时间的,因此,在任一具体时刻,一个 CPU 的内核只会执行一条线程中的指令,因此,为了线程切换后能恢复到正确的执行位置,每个线程都需要有一个独立的程序计数器,并且不能互相干扰,否则就会影响到程序的正常执行次序。

也就是说,我们要求程序计数器是线程私有的。

《Java 虚拟机规范》中规定,如果线程执行的是非本地(native)方法,则程序计数器中保存的是当前需要执行的指令地址;如果线程执行的是本地方法,则程序计数器中的值是 undefined。

为什么本地方法在程序计数器中的值是 undefined 的?因为本地方法大多是通过 C/C++ 实现的,并未编译成需要执行的字节码指令。

由于程序计数器中存储的数据所占的空间不会随程序的执行而发生大小上的改变,因此,程序计数器是不会发生内存溢出现象(OutOfMemory)的。

02、Java 虚拟机栈

Java 虚拟机栈中是一个个栈帧,每个栈帧对应一个被调用的方法。当线程执行一个方法时,会创建一个对应的栈帧,并将栈帧压入栈中。当方法执行完毕后,将栈帧从栈中移除。遵循的是后进先出的原则,所以线程当前执行的方法对应的栈帧必定在 Java 虚拟机栈的顶部。

栈帧包含以下 5 个部分,见下图。

1)局部变量表

顾名思义,就是用来存储方法中的局部变量的,包括方法的参数。对于基本数据类型的变量,直接存储变量的值;对于引用类型的变量,存储的是对象的引用。局部变量表的大小在编译期间就确定了,程序执行期间,它的大小是不会改变的。

2)操作数栈

表达式的计算是在操作数栈中完成的。当一个方法刚开始执行的时候,这个方法的操作数栈是空的,在方法的执行过程中,会有各种字节码指令往操作数栈中写入和提取内容,也就是入栈/出栈操作。例如,在做算术运算的时候是通过操作数栈来进行的,又或者在调用其他方法的时候是通过操作数栈来进行参数传递的。

3)指向运行时常量池的引用

当前方法所属的类的运行时常量池的引用,引用其他的常量类或者使用字符串常量池中的字符串。

4)方法返回地址

方法执行完(不论是正常执行还是发生了异常)后需要返回到方法被调用的位置,程序才能继续执行,方法返回地址保存一些用来帮助恢复上层方法的执行状态的信息。

5)动态链接

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

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

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

  • StackOverFlowError:当线程请求栈的深度超过 Java 虚拟机栈的最大深度的时候抛出。
  • OutOfMemoryError:如果 Java 虚拟机栈允许动态扩容,当栈扩容时无法申请到足够的内存时抛出。

最有名的 HotSpot 虚拟机的栈容量是不允许动态扩容的,所以在 HotSpot 虚拟机上是不会出现 OutOfMemoryError 的。

03、本地方法栈

本地方法栈与 Java 虚拟机栈类似,区别是本地方法栈执行的是本地方法,也就是带有 native 关键字修饰的方法。

在 HotSpot 虚拟机中,本地方法栈和 Java 虚拟机栈不做区分。

04、堆

堆是所有线程共享的一块内存区域,在 Java 虚拟机启动的时候创建,用来存储对象(数组也是一种对象)。

以前,Java 中“几乎”所有的对象都会在堆中分配,但随着 JIT(Just-In-Time)编译器的发展和逃逸技术的逐渐成熟,所有的对象都分配到堆上渐渐变得不那么“绝对”了。从 JDK 7 开始,Java 虚拟机已经默认开启逃逸分析了,意味着如果某些方法中的对象引用没有被返回或者未被外面使用(也就是未逃逸出去),那么对象可以直接在栈上分配内存。

简单解释一下 JIT 和逃逸分析。

常见的编译型语言如 C++,通常会把代码直接编译成 CPU 所能理解的机器码来运行。而 Java 为了实现“一次编译,处处运行”的特性,把编译的过程分成两部分,首先它会先由 javac 编译成通用的中间形式——字节码,然后再由解释器逐条将字节码解释为机器码来执行。所以在性能上,Java 可能会干不过 C++ 这类编译型语言。

为了优化 Java 的性能 ,JVM 在解释器之外引入了 JIT 编译器:当程序运行时,解释器首先发挥作用,代码可以直接执行。随着时间推移,即时编译器逐渐发挥作用,把越来越多的代码编译优化成本地代码,来获取更高的执行效率。解释器这时可以作为编译运行的降级手段,在一些不可靠的编译优化出现问题时,再切换回解释执行,保证程序可以正常运行。

逃逸分析(Escape Analysis),简单来讲就是,Hotspot 虚拟机可以分析新创建对象的使用范围,并决定是否在 Java 堆上分配内存的一项技术。

堆是 Java 垃圾收集器管理的主要区域,因此也被称作 GC 堆(Garbage Collected Heap)。从垃圾回收的角度来看,由于垃圾收集器基本都采用了分代垃圾收集的算法,所以堆还可以细分为:新生代和老年代。新生代还可以细分为:Eden 空间、From Survivor、To Survivor 空间等。进一步划分的目的是更好地回收内存,或者更快地分配内存。

堆这最容易出现的就是 OutOfMemoryError 错误,分为以下几种表现形式:

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

05、元空间

JDK 8 的时候,原有的方法区(更准确的说应该是永久代)被彻底移除,取而代之的是元空间。

我们来说说方法区吧。方法区和堆一样,是线程共享的区域,它用来存储已经被 Java 虚拟机加载的类信息、常量、静态变量,以及便器编译后的代码等。

在有些地方,方法区也被称为永久代。但其实不能这么理解。

《Java 虚拟机规范》中只规定了有方法区这么一个概念和它的作用,并没有规定如何去实现它。那么不同的 Java 虚拟机可能就会有不同的实现。永久代是 HotSpot 对方法区的一种实现形式。也就是说,永久代只是 HotSpot 中的一个概念,而方法区则是 Java 虚拟机规范中的一个定义,一种规范。

换句话说,方法区和永久代的关系就像是 Java 中接口和类的关系,类实现了接口。

在方法区中,还有一块非常重要的部分,也就是运行时常量池。在讲 class 文件的时候,提到了每个 class 文件都会有个常量池,用来存放字符串常量、类和接口的名字、字段名、常量等等。运行时常量池和 class 文件的常量池是一一对应的,它就是通过 class 文件中的常量池来构建的。

JDK 7 之前,运行时常量池中包含着字符串常量池,都在方法区。

JDK 7 的时候,字符串常量池从方法区中拿出来放到了堆中,运行时常量池中的其他东西还在方法区中。

JDK 8 的时候,HotSpot 移除了永久代,也就是说方法区不存在了,取而代之的是元空间。也就意味着字符串常量池在堆中,运行时常量池跑到了元空间。

再来说说为什么要将永久代 (PermGen) 或者说方法区替换为元空间 (MetaSpace) 。

第一,永久代放在 Java 虚拟机中,就会受到 Java 虚拟机内存大小的限制,而元空间使用的是本地内存,也就脱离了 Java 虚拟机内存的限制。

第二,JDK 8 的时候,在 HotSpot 中融合了 JRockit 虚拟机,而 JRockit 中并没有永久代的概念,因此新的 HotSpot 就没有必要再开辟一块空间来作为永久代了。


参考链接:

https://www.cnblogs.com/dolphin0520/p/3613043.html https://github.com/Snailclimb/JavaGuide/blob/master/docs/java/jvm/Java内存区域.md


对于我们 Java 程序员来说,不需要像 C/C++ 程序员那样时时刻刻关心着内存泄露和内存溢出的问题,但实际的工作中,这两个问题出现的频率还是蛮高的,尤其是在多线程并发的情况下。如果不了解 Java 虚拟机是如何管理内存的,那么一旦遇到问题可能就会束手无策。

了解 Java 虚拟机的内存区域划分有助于我们更好的去理解 Java 虚拟机,从而掌握内存问题排查的主动权。

我是一直在沉默中进化的二哥,希望大家能和我一样,偷偷地成长,然后惊艳所有人。我们下期见~

浏览 37
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报