Java与C++之间有一堵由内存动态分配和垃圾收集技术所围成的“高墙”,墙外面的人想进来,墙里面的人想出去。
学习虚拟机的内存管理机制的意义
对于java程序员,在虚拟机自动内存管理机制的帮助下,不再需要为每个new操作写配对的delete/free代码,不容易出现内存泄露和溢出的问题,一切都看似十分美好。不过,也正因为Java程序员把内存控制的权利交给了虚拟机,一旦内存出现泄露或溢出,如果不了解虚拟机是怎样管理内存的,那么排查起来讲变得十分吃力。
运行时数据区域
1.1 程序计数器
程序计数器是一块较小的内存空间,可以看做是当前线程所执行的字节码的行号指示器。每个线程都有一个独立的程序计数器,用于记录当前线程的执行位置,各个线程的程序计数器互不影响,独立存储,这类内存区域被称为“线程私有”的内存。此内存是唯一一个在Java虚拟机规范中没有规定任何OutOfMemoryError的区域。
1.2 Java 虚拟机栈
与程序计数器一样,Java虚拟机栈也是线程私有的,它的生命周期与线程相同。虚拟机栈描述的是Java方法执行的内存模型:每一个方法在执行时都会创建一个栈帧(用于存储局部变量表、操作数栈、方法出口等信息),每一个方法的调用和完成对应着一个栈帧的入栈和出栈。
局部变量表存放了编译期可知的各种基本数据类型、对象引用(引用类型,不等同于对象本身)和returnAddress类型(指向一条字节码指令的地址)。局部变量表所需要的内存空间在编译期间就明确可知,当进入一个方法时,这个方法需要在栈帧中分配多大的局部变量表空间是完全确定的,在方法运行期间是不会改变的。
1.3 本地方法栈
与虚拟机栈发挥的作用很类似,区别是:虚拟机栈为虚拟机执行Java方法服务,而本地方法栈为虚拟机使用到的Native(本地)方法服务。两者的工作原理是一致的,只是服务的对象不同。
1.4 Java 堆
Java堆是虚拟机管理的内存中最大的一块。Java堆是被所有线程共享的,在虚拟机启动时创建。此内存的唯一目的就是存放实例对象,几乎所有的对象实例都在此分配内存。
Java堆是垃圾收集器管理的主要区域,因此也被称为“GC堆”。从内存回收的角度看,由于现在的收集器基本都基于分代算法,所以Java堆又可以细分为:新生代和老年代;更细致的话有Eden 空间、From Survivor 空间、To Survivor 空间等。但无论怎么划分,每个区域存储的仍然是对象的实例,进一步划分只是为了更好地回收内存或者更快地分配内存。
1.5 方法区
方法区与Java堆一样,是各个线程共享的内存区域,用于存储已被虚拟机加载的类信息、常量、静态变量等数据。此区域又被人称为“永久代”。垃圾回收行为在此区域是比较少出现的,此区域的内存回收目标主要是针对常量池的回收和类型的卸载,但是回收的成绩却很难让人满意,类型的卸载的条件也是十分苛刻。
1.6 运行时常量池
运行时常量池是方法区的一部分。Class文件中除了有类的版本、字段、方法、接口等信息外,还有一项信息是常量池,用于存放编译期生成的各种字面量和符号引用,这部分内容在类加载后进入方法区的运行时常量池存放。
2.1 对象的创建
- 虚拟机遇到一条new指令时,首先检查这个指令的参数(new后的类)是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过,如果类没有加载,则先执行相应的类加载过程。
- 在类加载通过后,虚拟机将为新生对象分配内存,对象所需的内存大小在类加载后就完全确定,为对象分配内存等同于将一块确定大小的内存从Java堆中划分出来。
内存的分配有两种方式(由采用的垃圾收集器是否带有压缩整理的功能决定):指针碰撞和空闲列表
指针碰撞:Java堆中内存是绝对规整的,空闲的在一边,已用的在一边,中间用一个指针作为分界点的指示器,内存分配只需要将指针往空闲区域移动与对象大小相等的距离即可。
空闲列表:Java堆内存是不规整的,空闲列表记录着哪些内存是空闲的,在分配时从列表中找到一块足够大的内存划分给对象,并更新列表的记录。 - 内存分配完成后,虚拟机将分配到的内存空间都初始化为零值(默认值)
- 接下来,虚拟机要对对象进行必要的设置,例如这个对象时哪个类的实例、如何找到类的元数据信息、对象的哈希码、对象的GC分带年龄等,这些信息存储在对象的对象头中。
2.2 对象的内存布局
在HotSpot虚拟机中,对象在内存中的存储可以分为3块区域:对象头、实例数据和对齐填充。HotSpot虚拟机的对象头包括两部分信息:
- 第一部分用于存储对象自身的运行时数据,如哈希码、GC分代年龄、锁状态标志等
- 另外一部分是类型指针,即对象指向它的类元数据的指针,虚拟机根据这个指针确定这个对象是哪个类的实例。如果对象是一个Java数组,那在对象头必须有一块记录数组长度的数据。
2.3 对象的访问定位
建立对象就是为了使用对象,Java程序通过栈上的reference数据来操作堆上的对象。对象的访问形式取决于虚拟机的实现,目前主流的访问方式有句柄和直接指针两种:
-
句柄:Java堆会划分出一块内存作为句柄池,reference中存储的是对象的句柄地址,而句柄中存储了对象实例数据和类型数据的具体地址信息
-
直接指针:reference存储的直接是对象的地址,对象的布局就必须考虑放置访问类型数据的信息
- 两种访问方式的对比
- 使用句柄访问的最大好处是reference中存储的是稳定的句柄地址,在对象被移动时(垃圾收集时对象移动是十分普遍的)不需要修改reference,只需要修改句柄中实例数据指针
- 使用直接指针访问的最大优势在速度更快,因为它不需要进行两次查询,节省了一次指针定位的开销。由于对象的访问在Java中非常频繁,所以这类开销集少成多是十分可观的。HotSpot虚拟机采用的就是第二种方式。
实战
- Java 堆溢出
package com.whut.java;
import java.util.ArrayList;
import java.util.List;
/**
* Java 堆溢出
* JVM参数:
* 1. -Xms20m:设置堆的最小值为20m
* 2. -Xmx20m:设置堆的最大值为20m
* 堆最小值和最大值相等,说明堆大小不可拓展
*
* 3. -XX:+HeapOutOfMemoryError: 虚拟机出现内存溢出时Dump出堆的快照以便进行分析
*/
public class HeapOOM {
public static void main(String[] args){
// 使用objects保持对象实例的引用,避免GC自动回收堆内存
List<OOMObject> objects = new ArrayList<>();
while (true){
OOMObject oomObject = new OOMObject();
objects.add(oomObject);
}
}
}
class OOMObject{
}
- 虚拟机栈溢出
package com.whut.java;
/**
* User: Chunguang Li
* Date: 2018/3/7
* Email: 1192126986@foxmail.com
*/
/**
* 虚拟机栈溢
* JVM参数:
* -Xss128k:设置栈容量为128k
*/
public class StackOOM {
private int stackLength = 1;
/**
* 递归调用,方法入栈
*/
public void stackLeak(){
stackLength ++ ;
stackLeak();
}
public static void main(String[] args) {
StackOOM stackOOM = new StackOOM();
try {
stackOOM.stackLeak();
}catch (Throwable e){
System.out.println("stack length: " + stackOOM.stackLength);
throw e;
}
}
}
- 方法区和运行时常量池
package com.whut.java;
import java.util.ArrayList;
import java.util.List;
/**
* User: Chunguang Li
* Date: 2018/3/7
* Email: 1192126986@foxmail.com
*/
/**
* 方法区和运行时常量池溢出
* JVM参数:
* -XX:PermSize=10m:设置方法区内存为10m
* -XX:MaxPermSize=10m:设置方法区最大内存为10m
*/
public class RuntimeConstantOOM {
public static void main(String[] args) {
// 使用list保持着字符串常量的引用,避免GC自动回收常量池
List<String> list = new ArrayList<>();
int i = 0;
while (true){
list.add(String.valueOf(i++).intern());
}
}
}