写点什么

一问带你彻底了解 JVM-Java 虚拟机内存区域详解

作者:派大星
  • 2022 年 6 月 20 日
  • 本文字数:5429 字

    阅读完需:约 18 分钟

Java 内存模型在 1.8 之前和 1.8 之后略有不同,也就是运行时数据区域,请看如下图:

运行时数据区域

Java1.6:



JDK1.8



正如上图所示:Java 内存模型可以简要分为两种:线程私有的:


  • 虚拟机栈

  • 本地方法栈-Native Method Stack

  • 程序计数器-Program Counter Register


线程共享的:


  • 堆-Heap


堆可以是连续空间,也可以不是连续空间,同时也可以固定大小,也可以在运行时扩展;并且虚拟机的实现者可使用任何的垃圾回收算法管理堆,甚至完全不进行垃圾收集也是可以的


  • 方法区-Method Area

  • 直接内存-Direct Memory

程序计数器

程序计数器是一个较小的内存空间,可以看作是当前线程所执行的字节码的行号指示器。字节码解释器工作时通过改变这个计数器的值来选取下一条需要执行的字节码指令;分支、循环、跳转、异常处理、线程恢复等功能都需要这个计数器来协助完成。还有就是为了线程切换后能恢复到正确的位置,每个线程都有自己的独立程序计数器,各个线程之间程序计数器互不影响,独立存储。所以我们称这类内存区域为线程私有的内存。综上所述:程序计数器主要有两大作用:


  1. 字节码解析器通过改变程序计数器来依次执行指令,从而实现代码的流程控制,如:顺序执行、选择、跳转、异常处理等等

  2. 在多线程情况先,每个线程拥有自己独立的程序计数器,并由程序计数器记录当前线程执行的位置,从而可以使当前线程切换回来后可以知道上次运行的位置


TIP:程序计数器是唯一不会出现 OutOfMemoryError 的内存区域,它的生命周期随着线程的创建而创建,随着线程的消亡而消亡

Java 虚拟机栈

Java 虚拟机栈和程序计数器一样也是**线程私有的,**Java 虚拟机栈也可称为栈、Java 栈,同样是随着线程的创建二创建随着线程的消亡而消亡。Java 栈可以称得上是 JVM 运行时数据区域的一个核心。因为除了一些 Native 方法是通过本地方法栈实现的,其它的所有 Java 方法都是通过 Java 栈来实现的。但是也是需要其它的运行时内存区域的配合比如程序计数器。通过方法调用的数据都需要通过 Java 栈来进行传递,每一次方法调用都会有一个对应的栈帧压入栈中,每一个方法调用结束后都有一个栈帧弹出。


栈的组成


每一个栈都是由一个个栈帧组成,栈帧里又拥有局部变量表、操作数栈、动态链接、方法返回地址。它的结构和我们学习的数据结构中的栈比较类似,都是先进后出,只支持入栈出栈



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


**操作数栈:**主要是作为方法调用的中转站使用,用于存放方法执行过程中产生的中间计算结果,另外计算过程中产生的临时变量也会存在操作数栈中


**动态链接:**主要是服务一个方法需要调用其它方法,在 Java 源文件编译成字节码文件时,所有的方法和变量都作为符号引用 (Symbilic Reference) 保存在 Class 文件的常量池中,当一个方法调用其他方法,需要将常量池中指向方法的符号引用转化为其在内存地址中的直接引用。动态链接就是为了将符号引用转化为调用方法的直接引用。



栈的空间虽然是无限的,但是一般正常情况下调用时没有问题的。不过如果函数陷入了无限循环的话,就会导致被压入太多栈帧而导致占用太多空间,从而导致栈空间过深。如果当前请求的栈深度超过当前 Java 虚拟机的最大深度的时候就会抛出StackOverFlowError


Tip:Java 方法有两种返回方式:


  • 正常的 return 返回

  • 抛出异常


以上不管哪种放回方式都会导致栈帧弹出。也就是说栈帧随着方法的调用而创建,随着方法的结束而销毁,无论是正常完成还是异常完成都算方法结束除了上述提到的 StackOverFlowError 错误之外,栈还有可能发生 OutOfMemoryError 错误,这是因为栈的内存大小可以动态扩展,如果虚拟机在动态扩展时却无法申请到足够的内存空间,则会抛出 OutOfMemoryError 的异常综上所述:栈可能会出现两种错误


  1. **StackOverflowError:**若栈的内存空间不允许动态扩展,那么当前线程请求栈的深度如果超过 Java 虚拟机栈的最大深度,则会抛出 StackOverflow 的错误

  2. **OutOfMemoryError:**如果栈的内存大小可以动态扩展,如果 Java 虚拟机栈在动态扩展内存时无法申请到足够的内存空间,则会抛出 OutOfMemoryError 的错误


参考书籍:《深入理解 Java 虚拟机》-第三版


本地方法栈

和虚拟机栈所发挥的作用非常类似,区别是:虚拟机栈为虚拟机执行Java方法服务(也就是字节码服务),而本地方法栈则为虚拟机使用到的Native方法服务在 HotSpot 虚拟机中和 Java 虚拟机栈合二为一。本地方法栈执行的时候,在本地方法栈也会创建一个栈帧,用于存放本地方法的局部变量表,操作数栈、动态链接、方法返回地址。方法执行完毕后相应的栈帧也会弹出并释放内存空间,同时也会出现 StackOverflowError 和 OutOfMemoryError 两种错误

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


Java 世界中几乎所有的对象都在堆中分配,但是随着JIT编译器的发展和逃逸分析技术的逐渐成熟,栈上分配标量替换优化技术会导致一些微妙的变化,所有的对象都分配到栈上也没有那么绝对了。从 JDK1.7 开始就已经默认开启逃逸分析,如果某些方法中的对象引用没有被返回或被外面使用(也就是未逃逸出去),那么对象可以直接在栈上分配内存。


**Java 堆是垃圾回收器管理的主要区域,因此也被称作 GC 堆(Garbage Collected Heap)。**从垃圾回收的角度:由于现在的收集器基本都是采用分代垃圾收集算法,所以 Java 堆还可细分为:新生代和老年代;再细致一点还有 Eden、Survivor、Old 等空间,进一步划分的目的是更好的回收内存,或者说是更快的回收内存。在 JDK 版本 1.7 和 JDK 版本 1.7 之前堆主要分为:


  • 新生代内存(Young Generation)

  • 老年代(Old Generation)

  • 永久代(Permanent Generation)


具体如图所示(图中的 Eden 区、两个 Survivor 区 S0、S1)都属于新生代,中间一层属于老年代,最下面一层属于永久代。



JDK 8 版本后就移除了 PermGen(永久)使用 MetaSpace(元空间)所替代元空间使用的是直接内存。关于 JVM 是如何动态计算年龄的大致如下:


Hotspot 遍历所有对象时,按照年龄从小到大对其所占用的大小进行累计,当累计的某个年龄大小超过 Survivor 区的一半时,取这个年龄和 MaxTenuringThreshold 中更小的一个值,作为新的晋升阈年龄值


uint ageTable::compute_tenuring_threshold(size_t survivor_capacity) {  //survivor_capacity是survivor空间的大小size_t desired_survivor_size = (size_t)((((double) survivor_capacity)*TargetSurvivorRatio)/100);size_t total = 0;uint age = 1;while (age < table_size) {total += sizes[age];//sizes数组是每个年龄段对象大小if (total > desired_survivor_size) break;age++;}uint result = age < MaxTenuringThreshold ? age : MaxTenuringThreshold;  ...}
复制代码


堆这里最容易出现的错误是 OutOfMemoryError,并且出现这种错误的表现形式还有几种比如:


  • java.lang.OutOfMemoryError: GC Overhead Limit Exceeded,当 JVM 花费太多时间来执行垃圾回收,并且只能回收很小的堆空间时,就会发生此错误

  • java.lang.OutOfMemoryError: Java heap space:假如在创建新对象时,堆的内存空间不足以存放该新对象时,就会发生次错误。(和配置的最大栈内存有关,并且受制于物理内存的大小,最大堆内存可通过参数-Xmx 配置,若没有特别配置,则使用默认的配置),这个默认值目前我本人并没有在哪本书籍上看到,或者是我忘记了。可参考文章:默认的堆大小

  • 还有很多类似的表现就不以一举例了

方法区

方法区是 JVM 运行时数据区域的一块逻辑区域,是各个线程共享的内存区域。


《Java 虚拟机规范》只是规定了方法区的概念和它的作用,方法区是如何实现的就要看 Java 虚拟机它自己的实现了,换句话说就是在不同的 Java 虚拟机上,方法区的实现方式是有可能不同的。当虚拟机要使用一个类的时候,它需要读取并解析 Class 文件获取的相关信息,再将信息存入方法区。方法区会存储已被虚拟机加载的类信息、字段信息、方法信息、常量、静态变量、即使编译器编译后的代码缓存等数据。那么问题来了方法区和永久代、元空间有什么关系呢?


其实三者之间的关系很像 Java 类中的接口和实现类,类实现接口;方法区比较像接口,而永久代和元空间更像是方法区的具体实现(这里指的是 Hotspot 虚拟机对方法区的两种实现方式)。并且永久代是 JDK1.8 之前的实现方式,JDK1.8 之后由元空间所代替。



至于为什么永久代(PermGen)会被元空间(MetaSpace)所替代呢?在《深入理解 Java 虚拟机中》3 版有下面这段话:



  • 关于永久代有一个 JVM 本身设置的固定大小上限,它是无法进行调整的;但是元空间使用的是直接内存,这意味着元空间只受物理内存空间大小的限制,即使它也有可能会出现内存溢出的情况,但是几率要相对小很多

  • 元空间内存溢出会出现如下错误:


java.lang.OutOfMemoryError: MetaSpace


  • 元空间的大小

  • 关于元空间的大小可以通过参数-XX: MetaSpaceSize 来设置元空间的最大限制,默认是unlimited意味着只受系统内存空间的限制;-XX: MetaSpaceSize参数 定义了元空间的初始大小,如果未指定该参数,则元空间(MetaSpace)则会在运行时的应用程序动态调整大小。

  • 元空间存储的数据

  • 元空间存放的是类的元数据,如果未指定参数 --XX: MetaSpaceSize 的大小,那么加载多少类的元数据就不由参数 MetaSpaceSize 来控制了,就由系统实际可用的内存空间来限制了,其实这样能够加载类的元数据相比较会更多一些。

  • 在 JDK 8,合并 Hotspot 和 JRockit 的代码时,JRockit 压根也没有一个永久代的概念,合并之后就没必要额外的设置一个永久代的地方了,


方法区常用的参数


  • JDK1.8 之前永久代还没有移除的时候通常通过以下参数来进行调解:


# 方法区 永久代的初始大小-XX:PermSize=N# 方法区 永久代的最大大小 ,超过这个值将会抛出 OutOfMemoryError 异常:java.lang.OutOfMemoryError: PermGen-XX:MaxPermSize=N
复制代码


相对而言垃圾的收集行为在该区域是比较少出现的,但是并不意味着数据进入方法区就永久存在了。JDK1.8 之后,永久代就被移除了(其实在 JDK1.7 就已经开始了),取而代之的是元空间。


  • 元空间参数调节:


# 设置元空间(MetaSpace)的初始大小(和最小的大小)-XX:MetaSpaceSize:N# 设置元空间(MetaSpace)的最大的大小-XX:MaxMetaSpaceSize:N
复制代码


元空间与永久代不同就是在于:如果不指定大小的话,随着创建的类越来越多,最后可能后导致系统内存的耗尽。

运行时常量池

Class 文件除了有类的版本、字段、方法、接口等描述信息外,还有用于存放编译期生成的各种字面量、符号引用的常量池表


字面量在源码中是固定值的表示法,简单来说就是通过字面量我们就知道其值的含义。字面量主要包括整数、浮点数和字符串字面量,符号引用包括类符号引用、字段符号引用、方法符号引用和接口方法符号引用。常量池会在类加载后存放到方法区的运行时区常量池。


运行时常量池的功能类似于传统编程语言的符号表,尽管它包含了比常规的符号表更宽泛的数据既然运行时常量池是方法区的一部分,自然而然也收到内存的限制,当常量池无法申请到内存时也会抛出 OutOfMemoryError 的错误。

字符串常量池

字符串常量池是 JVM 为了提高性能减少内存消耗专门为字符串(String 类)开辟的一块内存区域,主要目的是为了防止字符串的重复创建。


// 在堆中创建字符串对象"paidaxing"// 将字符串对象"paidaxing"的引用保存在字符串常量池中String a = "paidaxing";// 直接返回字符串常量池中字符串对象"paidaxing"的引用String b = "paidaxing";System.out.println(a==b);// true
复制代码


Hotspot 常量池的具体实现是:src/hotspot/share/classfile/stringTable.cpp,StringTable 实际上就是一个 HashSet<String>,容量为 StringTableSize。可以通过参数-XX:StringTableSize来设置。


StringTable保存的是字符串对象的引用,字符串的引用指向堆中的字符串的对象。说到字符串那么就会有一个面试题,字符串是保存在哪里的?先说总结:


在 JDK1.7 之前字符串常量池是保存在永久代的,JDK1.7 及 1.7 之后字符串常量池和静态变量是保存在 Java 堆中的。如图所示:





问题来了为什么 JDK1.7 要将字符串常量池移到堆中呢?主要原因就是永久代(PermGen)-【方法区的实现】的 GC 的效率太低了,只有在整堆收集(也就是 Full GC)的时候才会被执行 GC,Java 通常情况下会有大量被创建的字符串需要被回收,将字符串常量池存放到堆中,能够提高 GC 的回收效率,及时回收字符串的内存。比较好的问题:


直接内存

直接内存并不是虚拟机运行时数据区域的一部分,也不是虚拟机规范中定义的内存区域,但是这部分内存也被频繁的使用。也有可能导致 OutOfMemoryError 的错误。


在 Java1.4 中新加入的 NIO(New Input/Output 类),引入了基于通道(Channel)和缓存区(Buffer)的 I/O 方式。它可以直接使用 Native 函数直接分配堆外内存,然后通过 Java 堆中的 DirectByteBuffer 对象作为这块内存的引用进行操作。这样在一些场景中就能显著的提高性能,避免了 Java 堆和 Native 堆之间来回复制数据。


本机直接内存的分配不会受到 Java 堆的限制,但是既然是内存总会收到系统本机内存以及处理器寻址空间的限制。


整理不易,如果对你有所帮助欢迎点赞关注

微信搜索【码上遇见你】获取更多精彩内容

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

派大星

关注

还未添加个人签名 2021.12.13 加入

还未添加个人简介

评论

发布
暂无评论
一问带你彻底了解JVM-Java虚拟机内存区域详解_JVM_派大星_InfoQ写作社区