查看原文
其他

JVM 运行时数据区域,书中没有说清楚的方法区、永久代、元空间

The following article is from 飞天小牛肉 Author 小牛肉

点击关注公众号,一周多次包邮送书

来源:经授权转自 飞天小牛肉(ID:Java-House)

作者:小牛肉


数据库系列吭哧吭哧写得差不多了,准备寒假看完 JVM,然后开学来看看框架背背八股就准备秋招了。话不多说,JVM 第一个知识点必定要奉献给 Java 程序运行时的数据区域划分。

老规矩,背诵版在文末。点击阅读原文可以直达我收录整理的各大厂面试真题

JVM 运行时数据区域总览

JVM 在执行 Java 程序的过程中(简称运行时)会把它所管理的内存划分为若干个不同的数据区域。这些区域有各自的用途,以及创建和销毁的时间,有的区域随着虚拟机进程的启动而一直存在,有些区域则是依赖用户线程的启动和结束而建立和销毁

根据《Java 虚拟机规范》的规定,Java 虚拟机所管理的内存将会包括以下几个运行时数据区域,如下图所示:

从图中可以看到,线程共享的区域是方法区和堆,线程隔离(线程私有)的区域是虚拟机栈、本地方法栈和程序计数器。

简单解释下线程共享和线程私有是啥意思:

  • 所谓线程私有,通俗来说就是每个线程都会创建一个属于自己的空间,每个线程之间的这块私有空间互不影响,独立存储。比如程序计数器就是线程私有的,每个线程都会拥有一个属于自己的程序计数器,互不干涉。
  • 线程共享就没啥好说的,简单理解为公共场所,谁都能去,存储的数据所有线程都能访问。

下面我们来分别解释下这几个数据区域 👇

线程私有:程序计数器 Program Counter Register

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

由于 Java 虚拟机的多线程是通过线程轮流切换、分配处理器执行时间的方式来实现的,在任何一个确定的时刻,一个处理器(对于多核处理器来说是一个内核)都只会执行一条线程中的指令。

因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各个线程之间的计数器互不影响

那么程序计数器里存的到底是什么东西呢?

  • 如果线程正在执行的是一个 Java 方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址
  • 如果正在执行的是本地(Native)方法,这个计数器值则应为空(Undefined)。至于什么是 Native 方法,在本地方法栈那一小节会详细解释

注意!此内存区域是唯一一个在《Java虚拟机规范》中没有规定任何 OutOfMemoryError(内存溢出)情况的区域。这个问题也算是一个比较常见的面试题了

线程私有:虚拟机栈 Java Virtual Machine Stack

虚拟机栈其实是由一个一个的 栈帧(Stack Frame) 组成的,一个栈帧描述的就是一个 Java 方法执行的内存模型。也就是说每个方法在执行的同时都会同步创建一个栈帧,用于存储局部变量表、操作数栈、动态链接、方法的返回地址等信息

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

其中,局部变量表存放了以下三种类型的数据:

  • 编译期可知的各种 Java 虚拟机的基本数据类型:boolean、byte、char、short、int、float、long、double
  • 对象引用,reference 类型:它并不等同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或者其他与此对象相关的位置。(关于 reference 类型,具体涉及到对象的访问定位的两种方式,使用句柄访问和使用直接指针访问,这个在后续文章中会详细介绍)
  • returnAddress 类型:指向了一条字节码指令的地址

这些数据类型在局部变量表中的存储空间以 局部变量槽 (Slot) 来表示,或者说局部变量表的基本存储单元是 Slot,JVM 为每一个 Slot 都分配了一个访问索引,通过这个索引就可以成功访问到局部变量表中存储的某个值。

其中,64 位长度的 long 和 double 类型的数据会占用两个 Slot,其余的数据类型都是 32 位只占用一个。

在《Java虚拟机规范》中,对虚拟机栈这个内存区域规定了两类异常状况:

  1. 如果线程请求的栈深度大于虚拟机所允许的深度,将抛出 StackOverflowError 异常(栈溢出)
  2. 如果使用的 JVM 支持动态扩展虚拟机栈容量的话,当栈扩展时无法申请到足够的内存就会抛出 OutOfMemoryError 异常(内存溢出)

线程私有:本地方法栈 Native Method Stacks

本地方法栈和上面我们所说的虚拟机栈作用基本一样,区别只不过是本地方法栈为虚拟机使用到的 Native 方法服务,而虚拟机栈为虚拟机执行 Java 方法(也就是字节码)服务。

这里解释一下 Native 方法的概念,其实不仅 Java,很多语言中都有这个概念。

"A native method is a Java method whose implementation is provided by non-java code."

就是说一个 Native 方法其实就是一个接口,但是它的具体实现是在外部由非 Java 语言比如 C 或 C++ 等来写的。Java 通过 JNI 来调用本地方法, 而本地方法是以库文件的形式存放的(在 WINDOWS 平台上是 DLL 文件形式,在 UNIX 机器上是 SO 文件形式)。

所以同一个 Native 方法,如果用不同的虚拟机去调用它,那么得到的结果和运行效率可能是不一样的,因为不同的虚拟机对于某个 Native 方法都有自己的实现,比如 Object 类的 hashCode 方法。

那么为什么需要 Native 方法呢

其主要原因就是 Java 虽然使用起来很方便,但是有些层次的任务用 Java 实现起来不容易,或者对程序的效率有比较高的要求时,Java 语言可能并不是最好的选择。所以 Native 方法使得 Java 程序能够超越 Java 运行时的界限,有效地扩充了 JVM。

与虚拟机栈一样,本地方法栈也会在栈深度溢出或者栈扩展失败时分别抛出 StackOverflowError 和 OutOfMemoryError 异常

线程共享:堆 Heap

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

注意!这里我们用的是几乎,技术发展至今,其实并非所有的对象实例都会分配到堆上,比如逃逸技术,这个我们后续文章我再做解释~

堆是垃圾收集器管理的内存区域,因此一些资料中它也被称作 “GC 堆”(Garbage Collected Heap)。

对于堆这个概念小伙伴们肯定还听说过各种诸如新生代、老年代、永久代、Eden 空间、From Survivor 空间、To Survivor 空间等名词,需要注意的是,这些区域划分仅仅是一部分垃圾收集器的共同特性或者说设计风格而已,只是为了通过这种分代设计来更好地回收内存,或者更快地分配内存,而非某个 Java 虚拟机具体实现的固有内存布局,更不是《Java虚拟机规范》里对 Java 堆的进一步细致划分

根据《Java虚拟机规范》的规定,Java 堆可以处于物理上不连续的内存空间中,但在逻辑上它应该被视为连续的,这点就像我们用磁盘空间去存储文件一样,并不要求每个文件都连续存放。但对于大对象(典型的如数组对象),多数虚拟机实现出于实现简单、存储高效的考虑,很可能会要求连续的内存空间。

Java 堆既可以被实现成固定大小的,也可以是可扩展的,当前主流的 Java 虚拟机都是按照可扩展来实现的(通过参数 -Xmx 和 -Xms 设定)

如果在堆中没有内存来完成对象实例的分配,并且堆也无法再扩展时,JVM 就会抛出 OutOfMemoryError 异常

线程共享:方法区 Method Area

方法区通俗点理解就是,在虚拟机完成类加载之后,存储这个类相关的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据

It stores per-class structures such as the run-time constant poolfield and method data, and the code for methods and constructors, including the special methods used in class and instance initialization and interface initialization. 它存储每个类的结构,如运行时的常量池、字段和方法数据,以及方法和构造函数的代码,包括类和实例初始化和接口初始化中使用的特殊方法

举个简单的小例子:

方法区其实本身很好理解,但是《Java 虚拟机规范》/ 《深入理解 Java 虚拟机》提到的一句话:方法区是堆的一个逻辑部分,真的是让我困惑了很长时间。

下面我来结合我的理解给大家解释下,我觉得这个 “方法区是堆的一个逻辑部分” 应该适用于 JDK 8 以前,而不适用 JDK 8

先来看 JDK 8 之前:

可以看到,JDK 8 之前,堆和方法区其实是连在一起的,或者说,方法区就是堆的一部分

但是呢,方法区存储的东西又有些特别,在过去自定义类加载器使用不普遍的时候,类几乎是 “静态的” 并且很少被卸载和回收,因此类也可以被看成 “永久的”(这也就是永久代的含义),另外由于类作为 JVM 实现的一部分,它们不由程序来创建,所以为了和堆区分开来呢,就给了 “方法区” 这样一个名字用来存储类的信息,也有人把方法区称为 “非堆”。

⭐ 需要注意的是,无论是方法区还是非堆,其实都只是一个逻辑上的概念,在 JDK 8 之前,其具体的实现方法是永久代

永久代是 HotSpot 虚拟机给出的实现,但是对于其他虚拟机实现,譬如 BEA JRockit、IBM J9 等来说,是不存在永久代的概念的。

image-20220116120649643

永久代是一段连续的内存空间,我们在 JVM 启动之前可以通过设置 -XX:MaxPermSize 的值来控制永久代的大小,32 位机器默认的永久代的大小为 64M,64 位的机器则为 85M。

永久代的垃圾回收和老年代的垃圾回收是绑定的,一旦其中一个区域被占满,这两个区都要进行垃圾回收

显然这种设计并不是一个好的主意,由于我们可以通过 ‑XX:MaxPermSize 设置永久代的大小,一旦类的元数据超过了设定的大小,程序就会耗尽内存,并出现内存溢出错误 (java.lang.OutOfMemoryError: PermGen space)。

而且有极少数的方法(例如适用 String的 intern()方法可以在运行过程中手动的将字符串添加到 字符串常量池中,在 JDK1.7 之前的 HotSpot 虚拟机中,字符串常量池被存储在永久代中)会因永久代的原因而导致不同虚拟机下有不同的表现

所以我们总结下 HotSpots 在 JDK 8 抛弃永久代,转而用元空间来实现方法区的两大原因

  1. 由于永久代的垃圾回收和老年代的垃圾回收是绑定的,一旦其中一个区域被占满,这两个区都要进行垃圾回收,增大了 OOM 发生的概率
  2. 有少数的方法例如 String 的 intern() 方法会因永久代的原因而导致不同虚拟机下有不同的表现,不利于代码迁移

那么元空间到底是个啥,和方法区有啥区别?

元空间与永久代之间最大的区别在于:元空间不再与堆连续,并且是存在于本地内存(Native memory)中的

运行时数据区域的对比如下图:

元空间存在于本地内存,意味着只要本地内存足够,它就不会 OOM,不会出现像永久代中的 java.lang.OutOfMemoryError: PermGenspace

运行时常量池 Runtime Constant Pool

运行时常量池是方法区的一部分。上面我们说过方法区包含类信息,而描述类信息的 Class 文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池表 (Constant Pool Table),用于存放编译期生成的各种字面量(字面量相当于 Java 语言层面常量的概念,如文本字符串,声明为 final 的常量值等)与符号引用。有一些文章会把 class 常量池表称为静态常量池

都是常量池,常量池表和运行时常量池有啥关系吗?运行时常量池是干嘛的呢?

运行时常量池可以在运行期间将 class 常量池表中的符号引用解析为直接引用。简单来说,class 常量池表就相当于一堆索引,运行时常量池根据这些索引来查找对应方法或字段所属的类型信息和名称及描述符信息

为什么需要常量池这个东西呢?主要是为了避免频繁的创建和销毁对象而影响系统性能,其实现了对象的共享。以字符串常量池为例,字符串 String 既然作为 Java 中的一个类,那么它和其他的对象分配一样,需要耗费高昂的时间与空间代价,作为最基础最常用的数据类型,大量频繁的创建字符串,将会极大程度的影响程序的性能。为此,JVM 为了提高性能和减少内存开销,在实例化字符串常量的时候进行了一些优化:

  • 为字符串开辟了一个字符串常量池 String Pool,可以理解为缓存区
  • 创建字符串常量时,首先检查字符串常量池中是否存在该字符串
  • 若字符串常量池中存在该字符串,则直接返回该引用实例,无需重新实例化;若不存在,则实例化该字符串并放入池中。

需要注意的是,字符串常量池的位置在 JDK 1.7 前后有所变化,可以参考下面这张表:


最后放上这道题的背诵版:

🥸 面试官:讲一下 JVM 运行时数据区域

😎 小牛肉:JVM 在执行 Java 程序的过程中会把它所管理的内存划分为若干个不同的数据区域。线程共享的区域是方法区和堆,线程私有的区域是虚拟机栈、本地方法栈和程序计数器。

所谓线程私有就是每个线程都会创建一个属于自己的空间,每个线程之间的这块私有空间互不影响,独立存储。

先来说线程私有的三个区域:

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

    这个内存区域是唯一一个在《Java 虚拟机规范》中没有规定任何 OutOfMemoryError 情况的区域。

  2. 虚拟机栈:虚拟机栈其实是由一个一个的栈帧(Stack Frame)组成的,一个栈帧描述的就是一个 Java 方法执行的内存模型。也就是说每个方法在执行的同时都会同步创建一个栈帧,用于存储局部变量表、操作数栈、动态链接、方法的返回地址等信息。每一个方法被调用直至执行完毕的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。

    虚拟机栈这个内存区域有两种异常状况:

  • 如果线程请求的栈深度大于虚拟机所允许的深度,将抛出 StackOverflowError 异常(栈溢出)
  • 如果使用的 JVM 支持动态扩展虚拟机栈容量的话,当栈扩展时无法申请到足够的内存就会抛出 OutOfMemoryError 异常(内存溢出)
  • 本地方法栈:本地方法栈和虚拟机栈作用基本一样,区别只不过是本地方法栈为虚拟机使用到的 Native 方法服务,而虚拟机栈为虚拟机执行 Java 方法(也就是字节码)服务

    本地方法栈也会在栈深度溢出或者栈扩展失败时分别抛出 StackOverflowError 和 OutOfMemoryError 异常

  • 再来说线程共享的两个区域:

    1. 堆:Java 堆是虚拟机所管理的内存中最大的一块。堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,“几乎” 所有 new 出来的对象实例都在这里分配内存。

      Java 堆既可以被实现成固定大小的,也可以是可扩展的,如果在堆中没有内存来完成对象实例的分配,并且堆也无法再扩展时,JVM 就会抛出 OutOfMemoryError 异常

    2. 方法区:方法区就是在虚拟机完成类加载之后,存储这个类相关的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。

      在 JDK 8 之前,堆和方法区其实是连在一起的,或者说方法区其实就是堆的一部分,HotSpot 虚拟机给出的具体实现是永久代,永久代是一个连续的内存空间,由于永久代的垃圾回收和老年代的垃圾回收是绑定的,一旦其中一个区域被占满,这两个区都要进行垃圾回收,增大了 OOM 发生的概率,另外,有少数的方法例如 String 的 intern() 方法会因永久代的原因而导致不同虚拟机下有不同的表现,不利于代码迁移。这两个原因呢,促使 HotSpots 在 JDK 8 之后将方法区的实现更换成了元空间。

      元空间与永久代之间最大的区别在于:元空间不再与堆连续,并且是存在于本地内存(Native memory)中的,这意味着只要本地内存足够,它就不会发生 OOM


    ·················END·················

    推荐阅读

    •   一个由“ YYYY-MM-dd ”引发的惨案 !•   能用js实现的最终用js实现,Shell脚本也不例外•   面试官太难伺候?一个try-catch问出这么多花样•   Spring Boot 实现各种参数校验,写得太好了,建议收藏!•   【建议收藏】MMU是如何完成地址翻译的?•   18张图,详解SpringBoot解析yml全流程•   活久见!在知乎匿名发表差评竟被判?!•   实战!阿里神器 Seata 实现 TCC模式 解决分布式事务,真香!•   HTTP Host 头攻击,是什么鬼?•   一次线上 JVM 调优实践,FullGC 40 次/天 到10 天一次的优化过程


    👇更多内容请点击👇

    您可能也对以下帖子感兴趣

    文章有问题?点此查看未经处理的缓存