写点什么

一文带你了解 Java 的内存区域

作者:宇宙之一粟
  • 2022 年 2 月 20 日
  • 本文字数:4208 字

    阅读完需:约 14 分钟

一、内存简介

内存和存储器这两个术语均指计算机的内部存储空间。存储器包括:内部存储器(内存)、外部存储器(外存)、寄存器。


内存是应用程序在处理过程中放置其使用的数据的地方。内存包括:只读存储器(ROM,Read Only memory)(只读,断电后数据保留)、随机存取存储器(RAM,Random Access Memory)(主存)(内存条)(可读可写,断电后数据丢失)、高速缓冲存储器(CACHE)。


物理内存即随机存取存储器空间。


虚拟内存即硬盘一部分空间映射虚拟的内存。物理内存已满时从物理内存碎片甚至硬盘按需取用空间。虚拟内存对应的存储文件 pagefile.sys 在系统盘根目录下,默认隐藏。虚拟内存使得多个进程在同时运行时可以共享物理内存,这里的共享只是空间上共享,在逻辑上彼此仍然是隔离的。

二、分段和分页管理机制

在早期的计算机中,要运行一个程序,会把这些程序全都装入内存,程序都是直接运行在内存上的,也就是说程序中访问的内存地址都是实际的物理内存地址。


但是如果遇到同时需要运行多个应用程序的时候,操作系统的内存可能就会不太够了。而且还可能会遇到如下问题:


  • 进程地址空间不隔离

  • 内存使用效率低

  • 程序运行的地址不确定


为此,计算机科学家们设计增加一个中间层,利用一种间接的地址访问方法访问物理内存。按照这种方法,程序中访问的内存地址不再是实际的物理内存地址,而是一个虚拟地址,然后由操作系统将这个虚拟地址映射到适当的物理内存地址上。


分段的映射方法虽然解决了上述中的问题一和问题三,但并没能解决问题二,即内存的使用效率问题。在分段的映射方法中,每次换入换出内存的都是整个程序,这样会造成大量的磁盘访问操作,导致效率低下。所以这种映射方法还是稍显粗糙,粒度比较大。实际上,程序的运行有局部性特点,在某个时间段内,程序只是访问程序的一小部分数据,也就是说,程序的大部分数据在一个时间段内都不会被用到。基于这种情况,人们想到了粒度更小的内存分割和映射方法,这种方法就是分页(Paging)。


分页的基本方法是,将地址空间分成许多的页。每页的大小由 CPU 决定,然后由操作系统选择页的大小。



32 位和 64 位电脑也是指的内存:


  • 32 位处理器:即内存地址长度为 32,拥有 的可寻址范围,使用 32 位地址线的最大寻址空间为 2 的 32 次方 bytes,计算后即 4294967296 Bytes,也就是我们常说的 4096MB,32 位地址线的寻址空间封顶即为 4GB。

  • 64 位处理器:即内存地址长度为 64,拥有 的可寻址范围,64 位系统使用 64 位地址线的最大寻址空间为 2 的 64 次方 bytes,计算后其可寻址空间达到了 18446744073709551616 Bytes,即 16384 PB(PebiByte)或 16777216 TB(TebiByte)。

三、地址空间的划分

一个计算通常有固定大小的内存空间,但是程序并不能使用全部的空间。因为这些空间被划分为内核空间和用户空间,而程序只能使用用户空间的内存。


  • 内核空间:主要的操作系统程序和 c 运行时空间。链接计算机硬件,提供了联网和虚拟内容逻辑的进程

  • 用户空间:Java 实际运行时空间


Java 代码启动后,有如下组建需要占用内存:


  • 堆内存:Java 堆、类和类加载器

  • 栈内存:线程

  • 本地内存:NIO、JNI

四、JVM 架构图

Java 内存模型就是指的下图中的 Runtime Data Area,运行时数据区。Java 内存模型描述了在多线程代码中哪些行为是合法的,以及线程如何通过内存进行交互。它描述了“程序中的变量“ 和 ”从内存或者寄存器获取或存储它们的底层细节”之间的关系。Java 内存模型通过使用各种各样的硬件和编译器的优化来正确实现以上事情。


运行时数据区域

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



各数据区又可分为:线程独占部分和线程共享部分。

线程独占部分

线程独占部分又包括:程序计数器、虚拟机栈和本地方法栈



接下来分别介绍一下这几个部分。

程序计数器(Program Counter Register)

程序计数器(Program Counter Register) 是一块较小的内存空间,它可以看做是当前线程所执行的字节码的行号指示器。例如,分支、循环、跳转、异常、线程恢复等都依赖于计数器。


主要特点有:


  • 当前线程所执行的字节码行号指示器(逻辑)

  • 改变计数器的值来选取下一条需要执行的字节码指令

  • 和线程是一对一的关系,即“线程私有”

  • 如果是对 Java 方法计数,此时计数器记录的是正在执行的虚拟机字节码指令的地址

  • 如果是 Native 方法则计数器值为 Undefined

  • 不会发生内存泄漏

Java 虚拟机栈

Java 虚拟机栈(Java Virtual Machine Stacks) 也是线程私有的,它的生命周期与线程相同。


每个 Java 方法在执行的同时都会创建一个栈帧(Stack Frame)用于存储 局部变量表、操作数栈、常量池引用 等信息。每一个方法从调用直至执行完成的过程,就对应着一个栈帧在 Java 虚拟机栈中入栈和出栈的过程。


虚拟机栈的特点有:


  • Java 方法执行的内存模型

  • 包含多个栈帧


Java 虚拟机是基于「栈」架构的,栈帧(Stack Frame)是用于支持虚拟机进行方法调用和方法执行的数据结构。每一个方法从调用至执行完成的过程,都对应着一个栈帧在虚拟机栈里从入栈到出栈的过程。


每个栈帧存储了:局部变量表、操作数栈、动态链接、方法的返回地址。


局部变量表和操作数栈

  • 局部变量表:32 位变量槽,存放了编译期可知的各种基本数据类型、对象引用、ReturnAddress 类型。

  • 操作数栈:入栈、出栈、复制、交换、产生消费变量。基于栈的执行引擎,虚拟机把操作数栈作为它的工作区,大多数指令都要从这里弹出数据、执行运算,然后把结果压回操作数栈。

  • 动态链接: 每个栈帧都包含一个指向运行时常量池(方法区的一部分)中该栈帧所属方法的引用。持有这个引用是为了支持方法调用过程中的动态连接。Class 文件的常量池中有大量的符号引用,字节码中的方法调用指令就以常量池中指向方法的符号引用为参数。这些符号引用一部分会在类加载阶段或第一次使用的时候转化为直接引用,这种转化称为静态解析。另一部分将在每一次的运行期间转化为直接应用,这部分称为动态链接。

  • 方法出口:返回方法被调用的位置,恢复上层方法的局部变量和操作数栈,如果无返回值,则把它压入调用者的操作数栈。


注意:

  1. 递归为什么会引发 java.lang.StackOverflowError 异常原因:递归过深,栈帧数超出虚拟栈深度,引发 Exception in thread "main" java.lang.StackOverflowError 异常

  2. 虚拟机栈过多,无法申请到足够内存会引发 java.lang.OutOfMemoryError 异常

本地方法栈

本地方法栈(Native Method Stack)与虚拟机栈相似,主要作用于标注了 native 的方法。


二者的区别在于:虚拟机栈为 Java 方法服务;本地方法栈为 Native 方法服务。本地方法并不是用 Java 实现的,而是由 C 语言实现的。


注意:本地方法栈也会抛出 StackOverflowError 异常和 OutOfMemoryError 异常

线程共享部分

元空间(MetaSpace)与永久代(PermGen)的区别

元空间和永久代 均是方法区的实现,存放 class 相关信息,method 和 field 。方法区是一种 JVM 的规范。


JDK7 之后,原先位于方法区的字符串常量池移动到了 Java 堆中,JDK8 中元空间替代了永久代。元空间使用本地内存,而永久代使用的是 jvm 的内存


java.lang.OutOfMemoryError:PermGen space不复存在,解决了空间不足的问题。原则上,本地空间多大,元空间就可以多大。但是 jvm 会根据程序需要动态调整所需空间大小。

MetaSpace 相比 PermGen 的优势

  • 字符串常量池存在永久代中,容易出现性能问题和内存溢出

  • 类和方法的信息大小难易确定,给永久代的大小指定带来困难

  • 永久代会为 GC 带来不必要的复杂性

  • 方便 HotSpot 与其他 JVM 如 Jrockit 的集成

Java 堆(Heap)

类实例和数组存储在堆内存中。堆内存也称为共享内存。因为这是多个线程将共享相同数据的地方。


Java 堆(Java Heap) 的作用就是存放对象实例,几乎所有的对象实例都是在这里分配内存。


Java 堆是垃圾收集的主要区域(因此也被叫做"GC 堆")。现代的垃圾收集器基本都是采用分代收集算法,该算法的思想是针对不同的对象采取不同的垃圾回收算法。


因此虚拟机把 Java 堆分成以下三块:


  • 新生代(Young Generation)

  • Eden - Eden 和 Survivor 的比例为 8:1

  • From Survivor

  • To Survivor

  • 老年代(Old Generation)

  • 永久代(Permanent Generation)


当一个对象被创建时,它首先进入新生代,之后有可能被转移到老年代中。新生代存放着大量的生命很短的对象,因此新生代在三个区域中垃圾回收的频率最高。


注意:Java 堆不需要连续内存,并且可以动态扩展其内存,扩展失败会抛出 OutOfMemoryError 异常。

方法区

方法区(Method Area)也被称为永久代。方法区用于存放已被加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。


对这块区域进行垃圾回收的主要目标是对常量池的回收和对类的卸载,但是一般比较难实现。


和 Java 堆一样不需要连续的内存,并且可以动态扩展,动态扩展失败一样会抛出 OutOfMemoryError 异常。JDK 1.7 之前,HotSpot 虚拟机把它当成永久代来进行垃圾回收。可通过参数 -XX:PermSize 和 -XX:MaxPermSize 设置。JDK 1.8 之后,取消了永久代,用 **metaspace(元数据)**区替代。可通过参数 -XX:MaxMetaspaceSize 设置。

运行时常量池

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


  • 字面量 - 文本字符串、声明为 final 的常量值等。

  • 符号引用 - 类和接口的完全限定名(Fully Qualified Name)、字段的名称和描述符(Descriptor)、方法的名称和描述符。除了在编译期生成的常量,还允许动态生成,例如 String 类的 intern()。这部分常量也会被放入运行时常量池。

直接内存

直接内存(Direct Memory)并不是虚拟机运行时数据区的一部分,也不是 JVM 规范中定义的内存区域。


在 JDK 1.4 中新加入了 NIO 类,它可以使用 Native 函数库直接分配堆外内存,然后通过一个存储在 Java 堆里的 DirectByteBuffer 对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在 Java 堆和 Native 堆中来回复制数据。

总结

简单的归类,Java 运行时内存区域中:

  • 属于线程私有的区域是:程序计数器、Java 虚拟机栈、本地方法栈;

  • 属于线程共享的区域是:Java 堆、方法区(包括运行时常量池)。



发布于: 刚刚阅读数: 2
用户头像

宇宙古今无有穷期,一生不过须臾,当思奋争 2020.05.07 加入

🏆InfoQ写作平台-第二季签约作者 🏆 混迹于江湖,江湖却没有我的影子 热爱技术,专注于后端全栈,轻易不换岗 拒绝内卷,工作于软件工程师,弹性不加班 热衷分享,执着于阅读写作,佛系不水文

评论

发布
暂无评论
一文带你了解 Java 的内存区域