Java内存区域有哪些构成?

作者:小牛呼噜噜 | https://xiaoniuhululu.com
计算机内功、JAVA底层、面试相关资料等更多精彩文章在公众号「小牛呼噜噜

大家好,我是呼噜噜,这次我们一起来看看Java内存区域,本文 基于HotSpot 虚拟机,JDK8, 干货满满

前言

Java 内存区域, 也叫运行时数据区域、内存区域、JVM内存模型,和 Java 虚拟机(JVM)的运行时区域相关,是指 JVM运行时将数据分区域存储,强调对内存空间的划分。
经常与Java内存模型(JMM)混淆,其定义了程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量这样的底层细节。
JVM并不是只有唯一版本的,在Java发展历史中,有许多优秀的Java虚拟机,其中目前大家最熟悉的就是HotSpot虚拟机,什么你不知道?

Java内存区域有哪些构成?插图

我们去Oracle官网,下载JDK,其自带的虚拟机,就是HotSpot。

HotSpot VM的最大特色:热点代码探测,其可以通过执行计数器,找出最具有编译价值的代码,然后通知JIT编译器进行编译,通过编译器和解释器的协同合作,在最优程序响应时间和最佳执行性能中取得平衡。

Java内存区域有哪些构成?插图1

简单介绍一下,上图的主要组成部分:

  • 类加载器系统:主要用于子系统将编译好的.class文件加载到JVM中,了解见:类加载器
  • 执行引擎:包括即时编译器和垃圾回收器,即时编译器将Java字节码编译成具体的机器码,垃圾回收器用于回收在运行过程中不再使用的对象
  • 本地库接口:用于调用操作系统的本地方法库,完成具体的指令操作
  • 运行时数据区:用于储存在JVM运行过程中产生的数据,不同的虚拟机在内存分配上也略有差异,但总体来说都遵循《Java虚拟机规范》。在《Java虚拟机规范》中规定了五种虚拟机运行时数据区,他们分别为:程序计数器、Java虚拟机栈、本地方法栈、本地方法区、堆 以及方法区。下文我们以此图为基准,详细地分析各个部分,慢慢道来

Java 内存区域

程序计数器

程序计数器(Program Counter Register)是用于存放下一条指令所在单元地址的一块内存,在虚拟机的规范里,字节码解析器的工作是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。

我们来对Java中class文件反编译:

Java内存区域有哪些构成?插图2

在JVM逻辑上规定,程序计数器是一块较小的内存空间,可以看作是当前线程所执行字节码的行号指示器,PC寄存器,也叫"程序计数器",其是CPU中寄存器的一种,偏硬件概念

由于程序计数器保存了 下一条指令要执行地址,所以在JVM中,执行指令的一般过程:执行引擎会从 程序计数器中获得下一条指令的地址,拿到其对应的操作指令,对其进行执行,当该指令结束,字节码解释器根据pc寄存器里的值选取下一条指令并修改pc寄存器里面的值,达到执行下一条指令的目的,周而复始直至程序结束。

字节码解释器可以拿到所有的字节码指令执行顺序,而程序计数器只是为了记录当前执行的字节码指令地址,防止线程切换找不到下一条指令地址

我们知道操作系统中线程是由CPU调度来执行指令的,JVM的多线程是通过CPU时间片轮转来实现的,某个线程在执行的过程中可能会因为时间片耗尽而挂起。当它再次获取时间片时,需要从挂起的地方继续执行。在JVM中,通过程序计数器来记录程序的字节码执行位置。

执行程序在单线程情况下还好,但在多线程的情况下:线程在执行的指令时,CPU可能切换线程,去另一个更紧急的指令,执行完再继续执行先前的指令。特别是单核CPU的情况下,CPU会频繁的切换线程,"同时"执行多个任务。为了CPU切换线程后,依旧能恢复到先前指令执行的位置,这就需要每个线程有自己独立的程序计数器,互不影响。我们可以发现程序计数器是线程私有的,每条线程都有一个程序计数器。

程序计数器是java虚拟机规范中唯一一个没有规定任何OutofMemeryError(内存泄漏)的区域,它的生命周期随着线程的创建而创建,随着线程的结束而死亡。因为当前线程正在执行Java中的方法,程序计数器记录的就是正在执行虚拟机字节码指令的地址,如果是Native方法,这个计数器就为空(undefined)

PC寄存器(程序计数器)与JVM中的程序计数器还是有所区别的:

  1. PC寄存器永远指向下一条待执行指令的内存地址(永远不会为undefined),并且在程序开始执行前,将程序指令序列的起始地址,即程序的第一条指令所在的内存单元地址送入PC, CPU按照PC的指示从内存读取第一条指令(取指)
  2. 当执行指令时,CPU会自动地修改PC的内容,即每执行一条指令PC增加一个量,这个量等于指令所含的字节数(指令字节数),使PC总是指向下一条将要取指的指令地址。
  3. 由于大多数指令都是按顺序来执行的,所以修改PC的过程通常只是简单的对PC 加“指令字节数”。当程序转移时,转移指令执行的最终结果就是要改变PC的值,此PC值就是转去的目标地址。处理器总是按照PC指向,取指、译码、执行,以此实现了程序转移。

虚拟机栈

虚拟机栈(JVM Stacks),和数据结构上的栈类似,先进后出。其与程序计数器一样,也是线程私有的,其生命周期和线程相同,随着线程的创建而创建,随着线程的死亡而死亡。

虚拟机栈描述的是Java方法执行的内存模型:每个方法在执行的同时都会创建一个栈帧,用于存储局部变量表、操作数栈、动态连接、方法出口等信息。栈帧在虚拟机栈中入栈到出栈(顺序: 先进后出)的过程,其实就对应Java中方法的调用至执行完成的过程

栈帧是用于支持虚拟机进行方法调用和方法执行的数据结构,它是虚拟机运行时数据区中的虚拟机栈的栈元素,每个栈帧存储了方法的变量表、操作数栈、动态连接和方法返回等信息。

Java内存区域有哪些构成?插图3

其中:

  1. 在当前活动线程中,只有位于栈顶的帧才是有效的,称为当前栈帧。正在执行的方法称为当前方法,栈帧是方法运行的基本结构。在执行引擎运行时,所有指令都只能针对当前栈帧进行操作。
  2. 方法调用的数据需要通过栈进行传递,每一次方法调用都会有一个对应的栈帧被压入栈中,每一个方法调用结束后,都会有一个栈帧被弹出。
  3. 每个栈帧包含四个区域:局部变量表、操作数栈、动态连接、返回地址
  4. 在《Java虚拟机规范》中,对这个内存区域规定了两类异常状况:
  • 如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常
  • 如果Java虚拟机栈容量可以动态扩展,当栈尝试扩展时无法申请到足够的内存或为一个新线程初始化JVM栈时没有足够的内存时会抛出OutOfMemoryError异常。《Java虚拟机规范》明确允许Java虚拟机实现自行选择是否支持栈的动态扩展HotSpot虚拟机是选择不支持扩展,所以HotSpot虚拟机在线程运行时是不会因为扩展而导致OutOfMemoryError(内存溢出)的异常

我们下面主要介绍一下栈帧的结构:

  1. 局部变量表

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

我们知道局部变量没有赋初始值是不能使用的,而全局变量是放在堆的,有两次赋值的阶段,一次在类加载的准备阶段,赋予系统初始值;另外一次在类加载的初始化阶段,赋予代码定义的初始值。拓展见:类加载器

局部变量表的容量以 Variable Slot(变量槽)为最小单位,每个变量槽都可以存储 32 位长度的内存空间.基本类型数据以及引用和 returnAddress(返回地址)占用一个变量槽,long 和 double 需要两个

在方法执行时,虚拟机使用局部变量表完成参数值到参数变量列表的传递过程的,如果执行的是实例方法,那局部变量表中第 0 位索引的 Slot 默认是用于传递方法所属对象实例的引用(在方法中可以通过关键字 this 来访问到这个隐含的参数)其余参数则按照参数表顺序排列,占用从 1 开始的局部变量 Slot。关键字this详解
我们可以写个例子验证一下

public class Test {
    void fun(){
    }
}

javac -g:vars Test.java生成Test.class文件,一定要加参数-g:vars,不然反编译时,无法显示局部变量表LocalVariableTable
我们接着反编译一下:

javap -v Test

Classfile /D:/GiteeProjects/study-java/study/src/com/company/test3/Test.class
  Last modified 2022-11-20; size 261 bytes
  MD5 checksum 72c7d1fcc5d83dd6fc82c43ae55f2b34
public class com.company.test3.Test
  minor version: 0
  major version: 52
  flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
   #1 = Methodref          #3.#11         // java/lang/Object."":()V
   #2 = Class              #12            // com/company/test3/Test
   #3 = Class              #13            // java/lang/Object
   #4 = Utf8               
   #5 = Utf8               ()V
   #6 = Utf8               Code
   #7 = Utf8               LocalVariableTable
   #8 = Utf8               this
   #9 = Utf8               Lcom/company/test3/Test;
  #10 = Utf8               fun
  #11 = NameAndType        #4:#5          // "":()V
  #12 = Utf8               com/company/test3/Test
  #13 = Utf8               java/lang/Object
{
  public com.company.test3.Test();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=1, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."":()V
         4: return
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       5     0  this   Lcom/company/test3/Test;

  void fun();
    descriptor: ()V
    flags:
    Code:
      stack=0, locals=1, args_size=1
         0: return
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       1     0  this   Lcom/company/test3/Test; //!!!可以看出this在Slot的第0位!!!
}
  1. 操作数栈

操作数栈 主要用于存放方法执行过程中产生的中间计算结果或者临时变量,通过变量的入栈、出栈等操作来执行计算。
在方法执行的过程中,会有各种字节码指令往操作数栈中写入和提取内容,也就是出栈和入栈操作。我们前文说的JVM执行引擎,是基于栈的执行引擎, 其中的栈指的就是操作数栈

  1. 动态链接

每个栈帧都保存了 一个 可以指向当前方法所在类的 运行时常量池, 目的是: 当前方法中如果需要调用其他方法的时候, 能够从运行时常量池中找到对应的符号引用, 然后将符号引用转换为直接引用,然后就能直接调用对应方法, 这就是动态链接。本质就是,在方法运行时将符号引用转为调用方法的直接引用,这种引用转换的过程具备动态性
不是所有方法调用都需要动态链接的, 有一部分符号引用会在 类加载阶段, 将符号引用转换为直接引用, 这部分操作称之为: 静态解析. 就是编译期间就能确定调用的版本, 包括: 调用静态方法, 调用实例的私有构造器, 私有方法, 父类方法

Java内存区域有哪些构成?插图4

  1. 返回地址

Java 方法有两种返回方式:

  • 正常退出,即正常执行到任何方法的返回字节码指令,如 return等;
  • 异常退出

无论何种退出情况,都将返回至方法当前被调用的位置。方法退出的过程相当于弹出当前栈帧
我们可以发现:栈帧随着方法调用而创建,随着方法结束而销毁。无论方法正常完成还是异常完成都算作方法结束.

本地方法栈

本地方法栈(Native Method Stack):是线程私有的,其与虚拟机栈的作用基本是一样的,有点区别的是:虚拟机栈是服务Java方法的,而本地方法栈是为虚拟机调用Native方法服务的,通过 JNI (Java Native Interface) 直接调用本地 C/C++ 库,不再受JVM控制。

JNI 类本地方法最著名的应该是 System.currentTimeMillis() ,JNI使 Java 深度使用操作系统的特性功能,复用非 Java 代码。 当大量本地方法出现时,势必会削弱 JVM 对系统的控制力

本地方法被执行的时候,在本地方法栈也会创建一个栈帧,用于存放该本地方法的局部变量表、操作数栈、动态链接、出口信息。方法执行完毕后相应的栈帧也会出栈并释放内存空间。与虚拟机栈一样,本地方法栈区域也会抛出StackOverflowErrorOutOfMemoryError

另外在Java虚拟机规范中对于本地方法栈没有特殊的要求,虚拟机可以自由的实现它,因此在HotSpot虚拟机直接把本地方法栈和虚拟机栈合二为一了。因此对于HotSpot来说,-Xoss参数(设置 本地方法栈大小)虽然存在,但实际上是没有任何效果的,栈容量只能由-Xss参数来设定。

堆(Heap)是Java虚拟机所管理的最大的一块内存区域,是被所有线程共享的,Java堆唯一的目的就是存放对象实例几乎所有的对象实例都在堆上分配内存,但是随着JIT编译器的发展和逃逸分析技术的逐渐成熟,栈上分配、线程本地分配缓存(TLAB)也可以存放对象实例

Java虚拟机规范规定,Java堆可以处在物理上不连续的内存空间中,只要逻辑上连续即可,当前主流的虚拟机都是按照可扩展来实现的(通过 -Xmx 和 -Xms 控制)。如果在堆中没有内存完成实例分配,并且堆也无法再扩展时,将会抛出 OutOfMemoryError 异常。

方法区

方法区(Methed Area)用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译后的代码等数据。其是所有线程共享的内存区域。

在Java 虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫做 Non-Heap(非堆),与 Java 堆区分开来。

方法区是JVM规范的一个概念定义,并不是一个具体的实现,由于Java虚拟机对于方法区的限制是非常宽松的,因此也就导致了不同的虚拟机上方法区有不同的表现,我们还是以HotSpot虚拟机为例:

  • 在JDK8前,HotSpot 虚拟机对Java虚拟机规范中方法区的实现方式是永久代
  • 在JDK8及其以后,HotSpot 虚拟机对Java虚拟机规范中方法区的实现方式变成了元空间

网上许多文章喜欢拿"永久代"或者"元空间" 来代替方法区,但本质上两者并不等价。方法区是Java虚拟机规范的概念,"永久代"或者"元空间"是方法区的2中实现方式

方法区在JDK7之前是一块单独的区域,HotSpot虚拟机的设计团队把GC分代收集扩展到了方法区。这样HotSpot的垃圾收集器就可以向管理Java堆一样管理这部分内存。但是对于其它虚拟机(如BEA JRockit、IBM J9等)来说其实是不存在永久代的概念的。

HotSpot的团队显然也意识到了,用永久代来实现方法区并不是一个好主意:

  1. 字符串存在永久代中,容易出现性能问题和内存溢出
  2. 类及方法的信息等比较难确定其大小,因此对于永久代的大小指定比较困难,太小容易出现永久代溢出,太大则容易导致老年代溢出。
  3. 永久代会为 GC 带来不必要的复杂度,并且回收效率偏低。

因此,在JDK1.8中完全废除了“永久代”,使用元空间替代了永久代,其他内容移至元空间,元空间直接在本地内存分配。

当方法区无法满足内存分配需求时,将抛出OutOfMemoryError异常。元空间是使用直接内存实现的,我们下文再详细说。

Java内存区域大致就这些了,下面我们再补充几个比较让人迷惑的概念

字符串常量池

字符串属于引用数据类型,但是可以说字符串是Java中使用频繁的一种数据类型。因此,为了节省程序内存,提高性能,Java的设计者开辟了一块叫字符串常量池的区域,用来存储这些字符串,避免字符串的重复创建。字符串常量池是所有类公用的一块空间,在一个虚拟机中只有一块常量池区域。

在类加载完成,经过验证,准备阶段之后在堆中生成字符串对象实例,然后将该字符串对象实例的引用值存到字符串常量池中(这里描述指的是JDK7及以后的HotSpot虚拟机)。 在HotSpot虚拟机中字符串常量池是通过一个StringTable类来实现的。它是一个哈希表,里面存的是字符串引用

在JDK7以前,字符串常量池在方法区(永久代)中,此时常量池中存放的是字符串对象。而在JDK7及其以后中,字符串常量池从方法区迁移到了堆内存,同时将字符串对象存到了堆内存,只在字符串常量池中存入了字符串对象的引用。

在JDK7 就已经开始了HotSpot 的永久代的移除工作,主要由于永久代的 GC 回收效率太低。等到JDK 8 的时候,永久代被彻底移除了
Java 程序中通常会有大量的被创建的字符串等待回收,将字符串常量池放到堆中,能够更高效及时地回收字符串内存。

运行时常量池

运行时常量池(Runtime Constant Pool)是方法区的一部分。我们知道Class 文件中除了有类的版本、字段、方法、接口等常见描述信息外,但还有一项信息是常量池(Constant Pool Table),用于存放编译期生成的各种字面量,符号引用还有翻译出来的直接引用,这部分内容将在类加载后进入方法区的运行时常量池中存放。因此,每一个类都会有一个运行时常量池

因为Java语言并不要求常量一定在编译期间才能生成。也就是并非预置入Class文件常量池中的内容才能进入运行时常量池,运行期间也可以将新的常量放入常量池中,运行时常量池另外一个重要特征是具备动态性

既然运行时常量池是方法区的一部分,自然受到方法区内存的限制,当常量池无法再申请到内存时会抛出 OutOfMemoryError 异常。

直接内存

JDK 8 版本之后 永久代已被元空间取代,元空间使用的就是直接内存。直接内存(Direct Memory)并不是Java虚拟机运行时数据区的一部分,也不是 Java 虚拟机规范中定义的内存区域。

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

显然,本机直接内存的分配不会受到 Java 堆大小的限制,但是,既然是内存,肯定还是会受到本机总内存(包括 RAM 以及 SWAP 区或者分页文件)大小以及处理器寻址空间的限制。服务器管理员在配置虚拟机参数时,会根据实际内存设置 -Xmx 等参数信息,但经常忽略直接内存,使得各个内存区域总和大于物理内存限制(包括物理的和操作系统级的限制),从而导致动态扩展时出现 OutOfMemoryError 异常。

小结

  1. 线程私有区域(包括 程序计数器, 虚拟机栈, 本地方法栈),生命周期跟随线程的启动而创建,随线程的结束而销毁
  2. 线程共享区域(包括 方法区 和 堆 ),生命周期跟随虚拟机的启动而创建,随虚拟机的关闭而销毁

Java内存区域有哪些构成?插图5

参考资料:
《深入理解 Java 虚拟机:JVM 高级特性与最佳实践》
《On Java 8》
https://www.cnblogs.com/newAndHui/p/11168791.html
https://blog.csdn.net/qq_20394285/article/details/104673913
https://www.cnblogs.com/czwbig/p/11127124.html


本篇文章到这里就结束啦,很感谢你能看到最后,如果觉得文章对你有帮助,别忘记关注我!更多精彩的文章
计算机内功、JAVA底层、面试相关资料等更多精彩文章在公众号「小牛呼噜噜

Java内存区域有哪些构成?插图6

文章来源于互联网:Java内存区域有哪些构成?

THE END
分享
二维码