首先,我们通过一个程序来进一步加深对 JVM 内存区域的认识和理解。
/**
* 设置程序运行的虚拟机参数如下:
* -Xms30m -Xmx30m -XX:MaxMetaspaceSize=30m
* -XX:+UseConcMarkSweepGC -XX:-UseCompressedOops
*/
public class JVMObject {
public final static String MAN_TYPE = "man"; // 常量
public static String WOMAN_TYPE = "woman"; // 静态变量
public static void main(String[] args)throws Exception {
Teacher T1 = new Teacher();
T1.setName("Mark");
T1.setSexType(MAN_TYPE);
T1.setAge(36);
for(int i = 0; i < 15; i++) {
System.gc(); // 主动触发GC 垃圾回收 15次 --- T1存活
}
Teacher T2 = new Teacher();
T2.setName("King");
T2.setSexType(MAN_TYPE);
T2.setAge(18);
Thread.sleep(Integer.MAX_VALUE); // 线程休眠
}
}
class Teacher {
String name;
String sexType;
int age;
// setter、getter方法略
}
对于上述的程序,JVM 的整个处理过程如下:
1)JVM 向操作系统申请内存。JVM 通过配置参数或者默认配置参数向操作系统申请内存空间,操作系统根据 JVM 申请的内存大小找到具体的内存分配表,然后把内存段的起始地址和终止地址分配给 JVM,接下来 JVM 就进行内部分配。
2)JVM 获得内存空间后,会根据配置参数分配堆、栈以及方法区的内存大小。
3)类加载。主要是把 class 放入方法区、还有 class 中的静态变量和常量也要放入方法区。 JVM 首先会执行构造器,编译器会在 .java 文件被编译成 .class 文件时,收集所有类的初始化代码,包括静态变量赋值语句、静态代码块、静态方法,静态变量和常量放入方法区。
4)执行方法及创建对象:启动 main 线程,执行 main 方法,开始执行第一行代码。此时堆内存中会创建一个 Teacher 对象,对象引用 Teacher 就存放在栈中。后续代码中遇到 new 关键字,按相同的方式处理。
从底层深入理解运行时数据区
我们借用 JDK 自带的内存可视化工具 JHSDB 来分析上述程序的执行情况。
JHSDB 是一款基于服务性代理实现的进程外调试工具。服务性代理是 HotSpot 虚拟机中一组用于映射 Java 虚拟机运行信息的,主要基于 Java 语言实现的 API 集合。
堆空间信息
查看堆空间的信息如下:
对象信息
两个 Teacher 对象的信息如下:
King 老师对象信息
Mark 老师对象信息
这里先说明一下,在 JVM 中堆空间是分代划分的,堆空间被划分为新生代和老年代(Tenured),新生代又被进一步划分为 Eden 和 Survivor 区,最后 Survivor 由 From Survivor 和 To Survivor 组成。
经过 JHSDB 工具的分析,可以知道当前 JVM 堆空间的内存划分信息如下:
-
Eden : 起始地址 3400000 ~ 3c00000;
-
From : 起始地址 3c00000 ~ 3d00000;
-
To : 起始地址 3d00000 ~ 3e00000;
-
Tenured : 起始地址 3e00000 ~ 5200000;
再结合 King 和 Mark 两个对象的地址,可以得出 King 对象位于 Eden 区,Mark 对象位于 Tenured 区。
栈内存信息
查看当前线程的栈内存信息如下:
GC 概念
GC- Garbage Collection 垃圾回收,在 JVM 中是自动化的垃圾回收机制,我们一般不用去关注,在 JVM 中 GC 的重要区域是堆空间。我们也可以通过一些额外方式主动发起它,比如 System.gc(),主动发起。(项目中切记不要使用)
深入辨析堆和栈
功能
堆:堆内存用来存储 Java 中的对象。无论是成员变量,局部变量,还是类变量,它们指向的对象都存储在堆内存中;
栈:以栈帧的方式存储方法调用的过程,并存储方法调用过程中基本数据类型的变量(int、short、long、byte、float、double、boolean、char 等)以及对象的引用变量,其内存分配在栈上,变量出了作用域就会自动释放;
线程独享还是共享
堆:堆内存中的对象对所有线程可见。堆内存中的对象可以被所有线程访问;
栈:栈内存归属于单个线程,每个线程都会有一个栈内存,其存储的变量只能在其所属线程中可见,即栈内存可以理解成线程的私有内存;
空间大小
堆的内存要远远大于栈的内存。
内存溢出
栈内存
通常,虚拟机栈的大小缺省为 1M。用参数 -Xss 调整大小,例如 -Xss256k。
参考官方文档(JDK1.8):docs.oracle.com/javase/8/do…
HotSpot 版本中栈的大小是固定的,是不支持拓展的。
java.lang.StackOverflowError 一般的方法调用是很难出现的,如果出现了可能会是无限递归。
虚拟机栈带给我们的启示:方法的执行因为要打包成栈桢,所以天生要比实现同样功能的循环慢,所以树的遍历算法中:递归和非递归(循环来实现)都有存在的意义。递归代码简洁,非递归代码复杂但是速度较快。
OutOfMemoryError:不断建立线程,JVM 申请栈内存,机器没有足够的内存。(一般演示不出,演示出来机器也死了)同时要注意,栈区的空间 JVM 没有办法去限制的,因为 JVM 在运行过程中会有线程不断的运行,没办法限制,所以只限制单个虚拟机栈的大小。
堆空间
内存溢出:申请内存空间,超出最大堆内存空间。如果是内存溢出,则通过调整 -Xms, -Xmx 参数。
如果不是内存泄漏,就是说内存中的对象却是都是必须存活的,那么就应该检查 JVM 的堆参数设置,与机器的内存对比,看是否还有可以调整的空间,再从代码上检查是否存在某些对象生命周期过长、持有状态时间过长、存储结构设计不合理等情况,尽量减少程序运行时的内存消耗。
方法区
1、运行时常量池溢出;
2、方法区中保存的 Class 对象没有被及时回收掉或者 Class 信息占用的内存超过了我们配置;
注意 Class 要被回收,条件比较苛刻(仅仅是可以,不代表必然,因为还有一些参数可以进行控制)
1)该类所有的实例都已经被回收,也就是堆中不存在该类的任何实例;
2)加载该类的 ClassLoader 已经被回收;
3)该类对应的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法;
CGLIB 是一个强大的,高性能,高质量的 Code 生成类库,它可以在运行期扩展 Java 类与实现 Java 接口。 CGLIB 包的底层是通过使用一个小而快的字节码处理框架 ASM,来转换字节码并生成新的类。除了 CGLIB 包,脚本语言例如 Groovy 和 BeanShell,也是使用 ASM 来生成 Java 的字节码。当然不鼓励直接使用 ASM,因为它要求你必须对 JVM 内部结构包括 class 文件的格式和指令集都很熟悉。
直接内存
直接内存的容量可以通过 MaxDirectMemorySize 来设置(默认与堆内存最大值一样),所以也会出现 OOM 异常;由直接内存导致的内存溢出,一个比较明显的特征是在 HeapDump 文件中不会看见有什么明显的异常情况,如果发生了 OOM,同时 Dump 文件很小,可以考虑重点排查下直接内存方面的原因。
常量池
Class 常量池(静态常量池)
在 class 文件中除了有类的版本、字段、方法和接口等描述信息外,还有一项信息是常量池 (Constant Pool Table),用于存放编译期间生成的各种字面量和符号引用。
字面量:给基本类型变量赋值的方式就叫做字面量或者字面值。
例如,String a = "b",这里 "b" 就是字符串字面量,同样类推还有整数字面值、浮点类型字面量、字符字面量。
符号引用:符号引用以一组符号来描述所引用的目标。符号引用可以是任何形式的字面量,Java 在编译的时候每个 Java 类都会被编译成一个 class 文件,但在编译的时候虚拟机并不知道所引用类的地址(实际地址),就用符号引用来代替,而在类的解析阶段就是为了把这个符号引用转化成为真正的地址的阶段。
例如,一个 Java 类(假设为 People 类)被编译成一个 class 文件时,如果 People 类引用了 Tool 类,但是在编译时 People 类并不知道引用类的实际内存地址,所以只能使用符号引用(org.simple.Tool)来代替。而在类装载器装载 People 类时,此时可以通过虚拟机获取 Tool 类的实际内存地址,因此便可以将符号 org.simple.Tool 替换为 Tool 类的实际内存地址。
运行时常量池
运行时常量池(Runtime Constant Pool)是每一个类或接口的常量池(Constant_Pool)的运行时表示形式,它包括了若干种不同的常量:从编译期可知的数值字面量到必须运行期解析后才能获得的方法或字段引用。(这个是虚拟机规范中的描述,很生涩)
运行时常量池是在类加载完成之后,将 Class 常量池中的符号引用值转存到运行时常量池中,类在解析之后,将符号引用替换成直接引用。运行时常量池在 JDK1.7 版本之后,就移到堆内存中了,这里指的是物理空间,而逻辑上还是属于方法区。(方法区是逻辑分区)
在 JDK1.8 中,使用元空间代替永久代来实现方法区,但是方法区并没有改变,变动的只是方法区中内容的物理存放位置,运行时常量池和字符串常量池被移动到了堆中。不论它们物理上如何存放,逻辑上还是属于方法区的。