Luckylau's Blog

Java虚拟机之运行时数据区

修改内容 时间
初版 2017-06-01
内存泄漏和访问堆中对象 2017-07-06

Java运行时数据区,探秘虚拟机内存管理

​ JVM就是一个特殊的进程, 我们执行的java程序, 都运行在一个JVM进程中, 这个进程的作用就是加载class文件, 并且执行class文件中的代码。既然虚拟机作为一个虚拟的计算机, 来执行我们的程序, 那么在执行的过程中, 必然要有地方存放我们的代码(class文件); 在执行的过程中, 总会创建很多对象, 必须有地方存放这些对象; 在执行的过程中, 还需要保存一些执行的状态, 比如, 将要执行哪个方法, 当前方法执行完成之后, 要返回到哪个方法等信息, 所以, 必须有一个地方来保持执行的状态。 上面的描述中, “地方”指的当然就是内存区域, 程序运行起来之后, 就是一个动态的过程, 必须合理的划分内存区域, 来存放各种数据。

​ JVM运行时数据区(JVM Runtime Area)其实就是指JVM在运行期间,其对计算机内存空间的划分和分配。

JVM的体系结构

类加载器子系统用于将class文件加载到虚拟机的运行时数据区中(准确的说应该是方法区) 。 可以认为执行引擎是字节码的执行机制, 一个线程可以看做是一个执行引擎的实例。

运行时数据区如何运转的?

运行时数据区的OutOfMemoryError异常?

这一块除了PC寄存器外,都有可能出现内存泄漏问题。我们可以做相关试验。

首先在Eclipse的Debug页签中设置虚拟机参数

1
2
3
4
-verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8
-Xms20M 设置JVM猝死内存为20M。此值可以设置与-Xmx相同,以避免每次垃圾回收完成后JVM重新分配内存。
-Xmx20M ,设置JVM最大可用内存为512M。
-Xmn10m:设置年轻代大小为10M。整个堆大小=年轻代大小 + 年老代大小 + 持久代大小。持久代一般固定大小为64m,所以增大年轻代后,将会减小年老代大小。此值对系统性能影响较大,Sun官方推荐配置为整个堆的3/8。

java堆溢出

在上述参数基础上加上-XX:+HeapDumpOnOutOfMemoryError让虚拟机在出现内存溢出异常时Dump出当前的内存堆转储快照以便事后进行分析。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import java.util.ArrayList;
import java.util.List;
public class Main {
public static void main(String[] args) {
List<Object> list = new ArrayList<>();
while(true){
list.add(new Object());
}
}
}
//输出
java.lang.OutOfMemoryError: Java heap space
Dumping heap to java_pid9212.hprof ...
Heap dump file created [27960620 bytes in 0.073 secs]

分析思路

使用Memory Analyzer分析,eclipse安装地址:http://archive.eclipse.org/mat/1.2/update-site/

​ 如果是内存泄露,可进一步通过工具查看泄露对象到GC Roots的引用链。 于是就能找到泄露对象是通过怎样的路径与GC Roots相关联并导致垃圾收集器无法自动回收它们的。 掌握了泄露对象的类型信息及GC Roots引用链的信息,就可以比较准确地定位出泄露代码的位置。
​ 如果不存在泄露,换句话说,就是内存中的对象确实都还必须存活着,那就应当检查虚拟机的堆参数(-Xmx与-Xms),与机器物理内存对比看是否还可以调大,从代码上检查是否存在某些对象生命周期过长、 持有状态时间过长的情况,尝试减少程序运行期的内存消耗。

虚拟机栈和本地方法栈溢出

​ 如果线程请求的栈深度大于虚拟机所允许的最大深度,将抛出StackOverflowError异常。如果虚拟机在扩展栈时无法申请到足够的内存空间,则抛出OutOfMemoryError异常。这里把异常分成两种情况,看似更加严谨,但却存在着一些互相重叠的地方:当栈空间无法继续分配时,到底是内存太小,还是已使用的栈空间太大,其本质上只是对同一件事情的两种描述而已。下面这个代码演示的是在单个线程下,无论是由于栈帧太大还是虚拟机栈容量太小,当内存无法分配的时候,虚拟机抛出的都是StackOverflowError异常。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class JavaVMStackSOF {
private int stackLength=1;
public void stackLeak(){
stackLength++;
stackLeak();
}
public static void main(String[] args) {
JavaVMStackSOF oom=new JavaVMStackSOF();
try{
oom.stackLeak();
}catch(Throwable e){
System.out.println("stack length:"+oom.stackLength);
throw e;
}
}
}
//输出
stack length:214238
Exception in thread "main" java.lang.StackOverflowError
at Concurrency.JavaVMStackSOF.stackLeak(JavaVMStackSOF.java:8)
at Concurrency.JavaVMStackSOF.stackLeak(JavaVMStackSOF.java:8)
at Concurrency.JavaVMStackSOF.stackLeak(JavaVMStackSOF.java:8)

​ 如果使用虚拟机默认参数,栈深度在大多数情况下(因为每个方法压入栈的帧大小并不是一样的,所以只能说在大多数情况下)达到1000~2000完全没有问题,对于正常的方法调用(包括递归),这个深度应该完全够用了。 但是,如果是建立过多线程导致的内存溢出,在不能减少线程数或者更换64位虚拟机的情况下,就只能通过减少最大堆和减少栈容量来换取更多的线程。

方法区和运行时常量池溢出

​ String.intern()是一个Native方法,它的作用是:如果字符串常量池中已经包含一个等于此String对象的字符串,则返回代表池中这个字符串的String对象;否则,将此String对象包含的字符串添加到常量池中,并且返回此String对象的引用。通过-XX:PermSize-XX:MaxPermSize限制方法区大小,从而间接限制其中常量池的容量。这两个命令java8已移除,不做讨论。

本机直接内存溢出

运行时数据区之PC寄存器

即程序计数器,用于存放一条指令的地址, 这条指令就是虚拟机要执行的下一条指令。如果是java,其PC记录的是正在执行的虚拟机字节码指令地址。如上图所示,pc寄存器和线程相关联, 每一个线程都有一个PC寄存器。

运行时数据区之Java栈

如图所示,在线程创建的时候,都会为其创建一个私有的虚拟机栈,也就是说Java栈上的所有数据都是线程私有的, 也就是说, 每个线程都会有自己的Java栈, 不会相互访问其他Java栈中的数据。

​ 栈帧随着方法调用而创建,随着方法结束而销毁——无论方法是正常完成还是异常完成(抛出了在方法内未被捕获的异常)都算作方法结束。栈帧的存储空间分配在 Java 虚拟机栈之中,每一个栈帧都有自己的局部变量表(Local Variables)操作数栈(Operand Stack)指向当前方法所属的类的运行时常量池的引用。在一条线程之中,只有目前正在执行的那个方法的栈帧是活动的。这个栈帧就被称为是当前栈
帧(Current Frame),这个栈帧对应的方法就被称为是当前方法(Current Method),定义这个方法的类就称作当前类(Current Class)。对局部变量表和操作数栈的各种操作,通常都指的是对当前栈帧的对局部变量表和操作数栈进行的操作。

​ 如果当前方法调用了其他方法,或者当前方法执行结束,那这个方法的栈帧就不再是当前栈帧了。当一个新的方法被调用,一个新的栈帧也会随之而创建,并且随着程序控制权移交到新的方法而成为新的当前栈帧。当方法返回的之际,当前栈帧会传回此方法的执行结果给前一个栈帧,在方法返回之后,当前栈帧就随之被丢弃,前一个栈帧就重新成为当前栈帧了。

注意:栈帧是线程本地私有的数据,不可能在一个栈帧之中引用另外一条线程的栈帧。

局部变量表使用索引来进行定位访问,第一个局部变量的索引值为零,局部变量的索引值是从零至小于局部变量表最大容量的所有整数。

操作数栈所属的栈帧在刚刚被创建的时候,操作数栈是空的。 Java 虚拟机提供一些字节码指令来从局部变量表或者对象实例的字段中复制常量或变量值到操作数栈中,也提供了一些指令用于从操作数栈取走数据、操作数据和把操作结果重新入栈。在方法调用的时候,操作数栈也用来准备调用方法的参数以及接收方法返回结果。

Java 虚拟机栈可能发生如下异常情况:

如果线程请求分配的栈容量超过 Java 虚拟机栈允许的最大容量时, Java 虚拟机将会抛出一个 StackOverflowError 异常。

如果 Java 虚拟机栈可以动态扩展,并且扩展的动作已经尝试过,但是目前无法申请到足够的内存去完成扩展,或者在建立新的线程时没有足够的内存去创建对应的虚拟机栈,那 Java 虚拟机将会抛出一个 OutOfMemoryError 异常。

运行时数据区之本地方法栈

​ Java可以和C/C++互调。如果当前线程执行的代码是C/C++写的本地代码, 那么这些方法就在本地方法栈中执行,而不会在Java栈中执行, Java栈中只执行Java方法。

Java 虚拟机规范允许本地方法栈被实现成固定大小的或者是根据计算动态扩展和收缩的。如果采用固定大小的本地方法栈,那每一条线程的本地方法栈容量应当在栈创建的时候独立地选定。一般情况下, Java 虚拟机实现应当提供给程序员或者最终用户调节虚拟机栈初始容量的手段,对于长度可动态变化的本地方法栈来说,则应当提供调节其最大、最小容量的手段。

本地方法栈可能发生如下异常情况:

如果线程请求分配的栈容量超过本地方法栈允许的最大容量时, Java 虚拟机将会抛出一个StackOverflowError 异常。

如果本地方法栈可以动态扩展,并且扩展的动作已经尝试过,但是目前无法申请到足够的内存去完成扩展,或者在建立新的线程时没有足够的内存去创建对应的本地方法栈,那 Java 虚拟机将会抛出一个 OutOfMemoryError 异常。

运行时数据区之方法区

​ 在字面意思上, “方法区”这个词会让人产生误解。因为方法区存放的不只是方法, 它存放的是类型信息。我们在写程序的时候, 几乎总是在和类, 对象打交道, 我们知道根据一个类可以创建对象。 一般来说, 我们操纵的是对象, 访问对象的属性, 调用对象的方法等, 但是我们要思考这样一个问题, 虚拟机根据什么信息知道如何创建对象的呢? 当然是根据这个对象的类型信息, 但是这个类型信息在哪里呢?现在我们知道是在方法区中。 那么类型信息是被谁加载到方法区中的呢?由上面的体系结构图, 我们可以知道是类加载器子系统。类加载器子系统提取class文件里面的类型信息,并将这些类型信息存放到方法区中。在 Java 虚拟机中,方法区(Method Area) 是可供各条线程共享的运行时内存区域。

方法区可能发生如下异常情况:

如果方法区的内存空间不能满足内存分配请求,那 Java 虚拟机将抛出一个OutOfMemoryError 异常。

运行时数据区之堆

​ 方法区是存放类型数据的, 而堆则是存放运行时产生的对象的。 和C++不同的是, Java只能在堆中存放对象, 而不能在栈上分配对象, 所有运行时产生的对象全部都存放于堆中, 包括数组。 我们知道, 在Java中, 数组也是对象。一个JVM实例中只有一个堆, 所有线程共享堆中的数据(对象) 。

​ 但是Java虚拟机的指令集中并不包含任何释放内存的指令, 因而我们也就不能手动释放内存。 所有被创建的对象都会被一个叫做垃圾收集器(GC)的模块自动回收, 垃圾收集器有不同的实现方式, 他们以 特定的方式判断对象是否过期, 并以特定的方式对对象进行回收。

​ 所有创建的对象都存在堆中, 而垃圾收集器会自动回收过期的对象, 所以,JVM的堆区是垃圾收集器的“重点管理区” 。

如何访问堆中的对象?

​ 建立对象是为了使用对象,我们的Java程序需要通过栈上的reference数据来操作堆上的具体对象。 由于reference类型在Java虚拟机规范中只规定了一个指向对象的引用,并没有定义这个引用应该通过何种方式去定位、 访问堆中的对象的具体位置,所以对象访问方式也是取决于虚拟机实现而定的。 目前主流的访问方式有使用句柄和直接指针两种。

​ 使用句柄来访问的最大好处就是reference中存储的是稳定的句柄地址,在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中的实例数据指针,而reference本身不需要修改。
​ 使用直接指针访问方式的最大好处就是速度更快,它节省了一次指针定位的时间开销,由于对象的访问在Java中非常频繁,因此这类开销积少成多后也是一项非常可观的执行成本。

Java 堆可能发生如下异常情况:

如果实际所需的堆超过了自动内存管理系统能提供的最大容量,那 Java 虚拟机将会抛出一个OutOfMemoryError 异常。

Luckylau wechat
如果对您有价值,看官可以打赏的!