虚拟机是以软件的方式模拟具有硬件系统功能、且运行在一个完全隔离环境中的完整计算机系统 ,是物理机的软件实现,Java 虚拟机就是其中之一。这篇文章主要是介绍 Java 虚拟机的各个内存数据区域,讲解这些区域的作用及存储的对象。
对于 C++ 开发人员来说,在内存管理方面拥有最高的权限,同时也担负着每一个对象生命从开始到结束的维护责任,稍有不慎就可能出现内存问题。但对 Java 程序员来说,内存由虚拟机自动管理,不容易出现内存泄漏(本应该被回收的无用对象实际上还没有被回收,白白占据内存)和溢出(对象都是有用的,超过了内存容量)的问题,一切看起来都很好。但是一旦出现内存方面的问题,如果不了解虚拟机是怎样使用内存的,那么排查错误将会是一项异常艰难的工作。这也是这篇文章存在的意义。
接下来请看详细内容。
Java 虚拟机在执行 Java 程序的过程中会把它所管理的内存划分为若干个不同的数据区域,称为运行时数据区。根据 JVM 规范,JVM 内存共分为程序计数器、虚拟机栈、本地方法栈、堆、方法区五个部分:
1.程序计数器
也叫 PC 寄存器,占用内存空间较小,类似于当前线程所执行的字节码的行号指示器。分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖计数器完成。倘若当前执行的是 JVM 的方法,则该计数器中保存的是当前执行指令的地址;倘若执行的是 native 方法,则计数器中为空。它是唯一一个不会出现内存溢出问题的区域。
特点:线程私有。
原因:Java 虚拟机的多线程其实是通过线程轮流切换并分配处理器执行时间的方式来实现的,类似于操作系统的时间分片。所以为了线程切换之后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器。
2.Java 虚拟机栈
线程私有,生命周期与线程相同。每一个方法从调用直至执行完成的过程(会创建一个栈帧),就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。
3.本地方法栈
本地方法栈的作用跟 Java 栈类似,只不过它是为本地(native)方法服务。有些虚拟机干脆将 Java 栈和本地方法栈合二为一。
本地方法:为了调用其他编程语言的代码而写的方法,一个 native 方法就是一个 Java 调用非 Java 代码的接口(JNI 编程)。比如在 Java 代码中声明了一个 native 方法,但没有方法体,它的实现在 C 或者 C++ 代码中。本地方法保存在动态链接库中,各个平台有自己的格式,对于 Windows 系统,是在 .dll 文件中。
4.Java 堆
所有线程共享,在虚拟机启动的时候创建。用于存放对象实例和数组,是垃圾收集器管理的主要区域。(注:随着技术的发展,所有对象分配在堆上并不是那么绝对。)
5.方法区
所有线程共享。用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。
6.运行时常量池
是方法区的一部分。
下面再通过示例程序理解下各个内存区域的存储内容及相关溢出异常
1 | package com.test; |
intern 方法:
String.intern() 是一个 Native 方法,它的作用是:如果字符串常量池中已经包含一个等于此 String 对象的字符串,则返回代表池中这个字符串的 String 对象;否则,将此 String 对象包含的字符串添加到常量池中,并且返回此 String 对象的引用。
下图解释了堆内存与相关 JVM 参数的关系:
-Xms 和 -Xmx 用来配置堆内存,若想配置非堆内存,则需要用到下面的参数(「非堆」指的是方法区,方法区虽在逻辑上是独立于堆的一部分,但物理上仍然是在堆中,两者的内存都可以是不连续的,为了与堆区分开,故称其为「非堆」,最大堆内存与最大非堆内存的和不能超出操作系统的可用内存):
在 JDK1.8 之前,用:
-XX:PermSize:设置永久代的内存初始值,默认是物理内存的 1/64;
-XX:MaxPermSize:设置永久代的内存最大值,默认是物理内存的 1/4。
从 JDK1.8 开始,用:
-XX:MetaspaceSize:设置元空间的初始值;
-XX:MaxMetaspaceSize:设置元空间的最大值,默认无限制。
永久代是 Sun HotSpot 的概念,是 JVM 方法区的一种具体实现,在其他厂商的虚拟机实现中没有永久代这一说法,所以只有 HotSpot 会出现异常「java.lang.OutOfMemoryError: PermGen space」。JVM 的规范定义了方法区,但没有规定如何去实现它,在 JDK1.8 之前,Sun HotSpot 使用永久代实现方法区,方法区和永久代的关系很像 Java 中接口和类的关系。但从 JDK1.8 开始,HotSpot 的永久代被彻底移除了,取而代之的是元空间 / Metaspace,用元空间来实现方法区。
与永久代的实现相比,元空间有两点不同:
(1)存储位置不同,永久代使用的是 JVM 的内存,受 JVM 内存大小限制;元空间使用的是本地内存,受本机可用内存的限制;
(2)存储内容不同,元空间只存储类和类加载器的元数据信息,与永久代相比更单一纯粹,静态变量和常量池等存入堆中;相当于永久代的数据被分到了堆和元空间中(符号引用(Symbols)转移到了 native heap;字面量(interned strings)转移到了 Java heap;类的静态变量(class statics)转移到了 Java heap)。
为什么要用元空间替换永久代?
(1)字符串存在永久代中,容易出现性能问题和内存溢出;
(2)类及方法的信息等比较难确定其大小,因此对于永久代的大小指定比较困难,太小容易出现永久代溢出,太大则容易导致老年代溢出;在某些场景下,如果动态加载类过多,则容易产生 Perm 区的 OOM;
(3)降低 Full GC 次数(老年代或永久代空间不足时会触发 Full GC),并且永久代会为 GC 带来不必要的复杂度,回收效率低,对永久代调优也很困难(方法区的垃圾收集主要回收两部分内容:常量池中废弃的常量和不再使用的类型(Class))。
*直接内存
除了运行时数据区外,还有一个直接内存。直接内存并不是虚拟机运行时数据区的一部分,也不是 Java 虚拟机规范中定义的内存区域,但是这部分内存也被频繁地使用,而且也会导致内存溢出异常,所以也需要了解一下。
1 | package com.test; |
由 DirectMemory 导致的内存溢出,一个明显的特征是在 Heap Dump 文件中不会看见明显的异常,如果发现 OOM 之后 Dump 文件很小,而程序中又直接或间接使用了 NIO,就可以考虑一下是不是这方面的原因。
*名词解释
(1)字面量
也叫直接量,它表示数据在程序中的书写格式,也可理解为在程序中直接给出的值。
例如在 int a = 5; 这行代码中,把整数 5 赋值给 int 型变量 a,整数 5 就是一个字面量。再比如,String s = “abc”; 中的 abc 也是字面量。并不是所有的数据类型都可以指定字面量,能指定字面量的通常只有三种类型:基本类型、字符串类型和 null 类型。关于字符串字面量有一点需要指出,当程序第一次使用某个字符串字面量时,Java 会使用常量池来缓存该字符串字面量,如果后面的程序再次用到该字符串,Java 会直接使用常量池中的字符串字面量(上文中在介绍 intern 方法时也介绍过这个机制)。
常量池(constant pool)指的是在编译期被确定、并被保存在已编译的 .class 文件中的一些数据,主要包括类、方法、接口中的常量,也包括字符串字面量。
(2)元数据
对数据进行说明描述的数据。在编程语言上下文中,元数据是添加到程序元素(方法、字段、类、包)上的额外信息。
比如注解 / Annotation,就是 Java 平台的元数据。在注解诞生之前,程序的元数据存在的形式仅限于 XML 部署描述文件、Java 注释或 Java doc,但注解可以提供更多功能,它不仅包含元数据,还能作用于运行期,注解解析器能够使用注解决定处理流程。
(3)符号引用
符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能够无歧义的定位到目标即可。
比如 org.simple.People 类引用了 org.simple.Language 类,在编译时 People 类并不知道 Language 类的实际内存地址,因此只能使用符号 org.simple.Language 来表示 Language 类的地址(此处只是举例,实际上是由类似于 CONSTANT_CLASS_INFO 的常量来表示的)。
在 JVM 类加载过程中,在解析阶段,JVM 会把类的二进制数据中的符号引用替换为直接引用。
(4)直接引用
能直接找到实际的内存地址的引用。包括:
- 直接指向目标的指针(比如,指向「类型」(Class 对象)、类变量、类方法的直接引用可能是指向方法区的指针)
- 相对偏移量(比如,指向实例变量、实例方法的直接引用都是偏移量)
- 一个能间接定位到目标的句柄