JVM运行时数据区

本篇主要介绍一下,JVM运行时数据区的内容。

概述

首先大概介绍一下下图所示的内容。JVM运行时数据区主要分为了两大部分的内容:线程共有的方法区(Method Area)堆(Heap)、线程私有的虚拟机栈(VM Stack)本地方法栈(Native Method Stack)程序计数器(Program Counter Register)。在数据区下面的执行引擎中又包含了:即时编译器(JITCompiler)和垃圾收集器(GC)。GC主要用于回收线程共享的区域(方法区和堆),对于私有的内存区域则方法执行完毕后系统自动释放。(在实际的程序当中,线程私有的内存区域会有很多份)

对于整个运行时数据区而言,外部交互的模块有执行引擎、本地库接口和类加载器。

下面分别介绍一下,各个内存区域的详细信息。

程序计数器(Program Counter Register [私有]):是一块较小的内存空间,它的作用可以看做是当前线程所执行的字节码的行号指示器。① (此内存区域是唯一一个在JVM规范中没有规定任何OutOfMemoryError的区域)

JVM栈(Java Virtual Machine Stacks [私有]):每个方法在执行的时候都会同时创建一个栈帧。JVM栈中包含了:局部变量表、操作栈、动态链接、方法出口等信息。

本地方法栈(Native Method Stacks [私有]):其内存结构类似于JVM栈,不过使用到的是本地库接口。

(Heap [共享]):堆区域是JVM内存管理里面最大的一块,几乎所有通过new出来的对象都放在此区域。在GC的过程中,绝大部分的内存回收发生在此区域。

方法区(Method Area [共享]):方法区主要用于存储被JVM加载的类信息、常量、静态变量、即时编译后的代码等。

运行时常量池(Runtime Constant Pool [方法区组成部分]):用于存放编译器生成的各种字面量和符号引用。

Java堆

Java堆是应用程序最为关心的内存空间,几乎所有的对象实例都存放在堆中。并且Java中的GC都是JVM自动管理的,开发者不需要手动释放内存空间。

下面以HotSpot为例,介绍一下堆的内存结构。

从上面堆的结构图可以看出,HotSpot的堆结构包含了两大部分区域:新生代、老年代。新生代又分为:Eden区、from区、和to区,其中新创建的对象会存放在Eden区。当一次GC之后未被收集之后,对象将进入from区。from区和to区是对等的(分代收集/复制算法),其中只有一块区域可以使用。通常情况下,当GC次数达到15次之后,对象还存活的话,下一次GC时对象将进入老年代。如果新创建的对象在新生代存放不了的话,那么对象刚创建就在老年代中。

下面通过一段代码,看一下堆、方法区和栈的指向关系。

public class SimpleHeap {
	private int id;
	
	public SimpleHeap(int id) {
		this.id = id;
	}

	public void show(){
		System.out.println("My ID is " + id);
	}
	
	public static void main(String[] args) {
		SimpleHeap s1 = new SimpleHeap(1);
		SimpleHeap s2 = new SimpleHeap(2);
		s1.show();
		s2.show();
	}

}

上述代码对应的内存引用关系如下所示:

Java栈

Java栈是一块线程私有的内存空间。线程执行的基本行为是函数调用,每次函数调用的数据都是通过Java栈传递的。

Java栈可以类比数据结构中的栈结构。每一次函数调用都会有一个对应的栈帧压入Java栈,每个函数调用的返回,都会有一个Java栈帧的弹出。一个栈帧中,至少要包含局部变量表、操作数栈和栈数据区几个部分。如下图所示:

JVM提供了参数-Xss来指定线程的最大栈空间,这个参数也决定了函数调用的最大深度。如果调用深度过大,则系统会抛出StackOverflowError。

局部变量表

局部变量表是栈帧的重要组成部分之一。它用于保存函数的参数、局部变量。局部变量表中的变量只对当前函数调用有效。

操作数栈

操作数栈也是栈帧的重要组成之一。它主要用于保存计算过程中的中间结果,同时作为计算过程中变量临时的存储空间。如下图所示:

帧数据区

帧数据区主要用于支持常量池解析、正常方法返回和异常处理等。在帧数据区中保存着访问常量池的指针,方便程序访问常量池;异常处理表也是其重要组成的一部分。

栈上分配

栈上分配是JVM提供的一项优化技术。基本思想是,对于那些线程私有的对象,可以将它们打散分配在栈上,而不是在堆上。这样以来,当方法执行结束后,栈上的对象可以自动被释放,不需要GC的介入。下面看一段代码:

/**
 * 非逃逸对象的栈上分配:大对象不适合栈上分配
 * 
 * VM Args: -server -Xmx10M -Xms10M -XX:+DoEscapeAnalysis -XX:+PrintGC -XX:-UseTLAB -XX:+EliminateAllocations
 * 
 * @author xuefeihu
 *
 */
public class OnStackTest {

	public static class User {
		public int id = 0;
		public String name = "";
	}

	public static void alloc() {
		User u = new User();
		u.id = 5;
		u.name = "geym";
	}

	public static void main(String[] args) {
		long b = System.currentTimeMillis();
		for (int i = 0; i < 100000000; i++) {
			alloc();
		}
		long e = System.currentTimeMillis();
		System.out.println(e - b);
	}

}

运行上述代码需要开启逃逸分析,运行结果如下(笔者1.8的JDK):

​​​​​​​

从上图可以看出,循环分配了近1.5G的内存空间,而GC只进行了1次,且只释放了1M多的空间。从而可以看出,对象基本上都是在栈上分配的。

方法区

方法区是线程共享的内存区域,它用于保存系统的类信息,比如类的字段、方法、常量池等。

在JDK6或JDK7中,方法区可以理解为永久区(Perm)。可以使用-XX:PermSize和-XX:MaxPermSize指定,默认情况下为64M。

在JDK8中,永久代被移除。取而代之的是元数据区,其大小可以使用-XX:MaxMetaspaceSize指定,它是一块堆外的直接内存。如果不指定大小,默认情况下它可以耗尽系统内存。


参考:《深入理解Java虚拟机》、《实战Java虚拟机》