深入理解JVM内存模型
Java虚拟机在执行Java程序的过程中,把它所管理里的内存划分了不同的数据类型区域,作为一名开发者,我们需要了解jvm的内存分配机制以及这些不同的数据区域各自的作用。
JVM将内存划分成了以下几个运行时数据区:
JVM一共将内存分为了五大数据区域,其中蓝色部分为线程共享的区域,橙色部分为线程私有区域。
一、程序计数器
程序计数器是一块较小的空间,记录着当前线程所执行的字节码的行号指示器。我们知道,Java虚拟机的多线程是通过抢占CPU分配的时间片来完成的,那么,当前线程正在执行的时候,就会遇到别的线程抢占了时间片导致当前线程挂起,如果没有程序计数器,当CPU下一次想要再继续执行这个线程的时候,它并不知道这个线程执行到哪里了,所以需要有这么一个计数器来记录它上次执行到哪个位置,因此每个Java虚拟机线程都有其自己的 pc(程序计数器)寄存器,该pc寄存器包含当前正在执行的Java虚拟机指令的地址。
有一点我们要知道,当我们执行的是native方法时,这个计数器为空,因为程序计数器记录的是字节码指令地址,但是native方法是大多是通过C实现并未编译成需要执行的字节码指令所以在计数器中是空的,native 方法是通过调用系统指令来实现的,由原生平台直接执行。
二、虚拟机栈
虚拟机栈和程序计数器一样,也是线程私有的,每个Java虚拟机线程都有一个私有Java虚拟机堆栈,与该线程同时创建。栈是一种数据结构,那么数据结构是用来存储数据的,每个方法在执行的时候都会创建一个栈帧用来存储局部变量表、操作数栈,本地方法栈、动态链接等。每个方法从调用到完成的过程就对应着一个栈帧在虚拟机栈中的入栈和出栈的过程。如图所示:
栈帧中的几种数据介绍:
1.局部变量表
局部变量表里面存放了编译器可知的八大基本类型(byte、char、short、int、long、double、float、boolean)、对象引用(可能是对象的句柄地址或者是对象在堆中的直接地址)以及returnAdress类型。其中基本类型中64位长度的long和double类型的数据将会占用2个局部变量表其余的数据类型占用一个。局部变量表所需的内存空间在编译器就可知,因此一个方法分配多大的局部变量表在进入这个方法的时候就已经固定了。局部变量表里面的局部变量是其起始位置是从0开始传递的。
2.操作数栈
每个栈帧都包含一个后进先出(LIFO)堆栈,称为其操作数堆栈,Java虚拟机提供了将局部变量或字段中的常量或值加载到操作数堆栈上的指令。其他Java虚拟机指令从操作数堆栈中获取操作数,对其进行操作,然后将结果压回操作数堆栈。操作数堆栈还用于准备要传递给方法的参数并接收方法结果。我们通过一个示例来演示下入栈和出栈的过程:
这里有一个类,类里有一个方法,
public class Person {
private String name="lczd";
private static String sex;
private int age;
private static final String custNum="3207231993";
public static int calc(int a,int b){
a = 1;
int c = a+b;
return c;
}
public static void main(String[] args) {
calc(2,3);
}
}
现在使用命令将这个文件编译字节码文件,并反编译查看它的字节码指令:
javac Person.java; javap -c Person.class
反编译过后的字节码文件,我们以calc方法为例:
Compiled from "Person.java"
public class classloader.Person {
public classloader.Person();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: aload_0
5: ldc #2 // String lczd
7: putfield #3 // Field name:Ljava/lang/String;
10: return
public static int calc(int, int);
Code:
0: iconst_1
1: istore_0
2: iload_0
3: iload_1
4: iadd
5: istore_2
6: iload_2
7: ireturn
public static void main(java.lang.String[]);
Code:
0: iconst_2
1: iconst_3
2: invokestatic #4 // Method calc:(II)I
5: pop
6: return
}
我们来看下这个calc的字节码指令:
calc()这个方法有两个局部变量入参a和b,iconst_1,1是数字,这个指令的含义是说把int类型的值push到操作数栈中,结合源码文件中把int类型为1的值赋值给了局部变量a,要得先有一个1,如何有这个1的值呢,得先把1压到这个操作数栈里面,istore的含义是说要把操作数为1进行出栈赋值给我们的局部变量表里的a,为什么是赋值给的a呢,上面也提到,局部变量表里面的局部变量是0开始传递的。虽然源码只是一个赋值操作,但是对应的字节码指令却是两个。iload_0的含义是把局部变量表中的第一个位置的变量也就是a的值进行入栈。iload1就是将局部变量b的值入栈。iadd操作就是将栈顶两int类型数相加,也就是说将这两个值出栈相加,然后再进行入栈。经过了入栈,出栈,,相加入栈的操作。因此根据字节码指令对应源码文件,我们可以大概了解这个方法在虚拟机中是如何操作的,字节码指令可以根据官网进行查看,或者直接百度搜索中文版的字节码指令集。
3.动态链接
每一个栈帧都包含着一个指向运行时常量池中的该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态链接。Class常量池中存在大量的符号引用,字节码中方法调用指令会以常量池中指向方法的符合引用作为参数,有一部分符号引用会在类加载阶段或者第一次使用的时候就会转化为直接引用,这种转化称为静态解析,还有一部分需要在运行期间转换,这一部分的称为动态链接,动态链接将这些符号方法引用转换为具体的方法引用,根据需要加载类以解析尚未定义的符号,并将变量访问转换为与这些变量的运行时位置关联的存储结构中的适当偏移量。比如说,多态是不知道调用的是哪个实现类,运行时确定具体类型
4.返回地址
简单来说一个方法的执行,不管是否有异常,都需要退出返回到方法被调用的位置,这样程序才能正常执行,这个返回地址就是上层方法的继续执行的指令地址。
三、Java堆
java堆是线程共享的一块区域,堆中存放着大量对象实例,java堆在虚拟机启动的时候就被创建了,这个区域存放着我们程序运行时的各种类实例和数组对象。那么对象到底存了些什么,或者说一个java对象由哪些数据组成呢?
在HotSpot虚拟机中,对象在内存中的存储可以分为3块区域:对象头(Header)、实例数据(Instance Data)、对其填充(Padding)。我们来看下下面这张图:
Java虚拟机在执行Java程序的过程中,把它所管理里的内存划分了不同的数据类型区域,作为一名开发者,我们需要了解jvm的内存分配机制以及这些不同的数据区域各自的作用。JVM将内存划分成了以下几个运行时数据区:
HotSpot虚拟机的对象头包含两部分,第一部分是运行时数据,如哈希吗、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID等,这部分数据的长度在32位和64位虚拟机中分别为32bit和64bit,以32位虚拟机为例,Mark Word的32bit位中的25bit用于存储对象哈希吗,4bit用于存储对象的分代年龄,2bit用于存储锁标志位,1bit固定为0。
对象头的另外一部分是类型指针。即指向他的类元数据的指针,虚拟机通过这个指针来确定是哪个类的实例,如果对象是一个java数组,那么对象头中还必须有一块用于记录数组长度的数据,因为虚拟机可以通过普通java对象的元数据信息确定java对象的大小,但是从数组的元数据中无法确定数组的大小。
对象中除了对象头之外,还包含了实例数据,实例数据是在程序代码中所定义的各种类型的字段内容,无论是从父类中继承下来的还是子类中定义的,都需要记录下来。实例数据的大小由各变量的类型决定。
第三部分的对其填充不是必然存在的,仅仅起着占位符的作用,HotSpot虚拟机的自动内存管理系统要求对象其实位置地址必须是8字节的整数倍,就是说对象的大小必须是8的整数倍,如果对象不是8的整数倍的话,就需要对其填充来补全。
四、方法区
同java堆一样,方法区是线程共享的内存区域,它存储每个类的信息,例如运行时常量池,字段和方法数据、方法代码等,包括用于类和实例初始化以及接口初始化的特殊方法。
举例:
比如类型信息:
- 类型的全限定名
- 超类的全限定名
- 直接超接口的全限定名
- 类型标志(该类是类类型还是接口类型)
- 类的访问描述符(public、private、default、abstract、final、static)
比如:运行时常量池
运行时常量池(Runtime Constant Pool)是方法区的一部分.Class文件中除了有类的版本/字段/方法/接口等描述信息外,还有一项信息是常量池(Constant Pool Table),用于存放编译期生成的各种字面量和符号引用,这部分内容将类在加载后进入方法区的运行时常量池中存放.
比如字段信息:
字段名,字段类型、字段的访问修饰符等,方法信息如:方法名,方法返回类型、方法修饰符等。
以上简单介绍了下java运行时数据区的各部分组成。java程序在执行的过程,会把执行程序所需要用到的数据和对象类型的信息分配在不同的运行时数据区,除了程序计数器以外,以上的的运行时数据区都有可能会在运行的时候产生一些内存不足相关的异常,下面就通过一些简单的实例来分析下各个数据区会产生的异常类型。
虚拟机栈的异常
前面提到,每一个方法调用直至完成的过程,就对应着一个栈帧在虚拟机中出栈和入栈的过程。而栈帧中的局部变量表存放了编译期可知的各种类型的数据,基本类型、引用类型、returnAdress类型,局部变量表所需的内存空间在编译期间完成分配,当一个方法需要在帧中分配多大的局部变量空间是完全确定的,在java虚拟机中,对这个区域规定了两种异常情况,如果请求的栈深度大于虚拟机所允许的栈深度,将会跑出StackOverFlowError异常,如果虚拟机栈可以动态扩展,并且在扩展时无法申请到足够的内存的时候就会抛出OutOfMemoryError异常。
我们来看下StackOverFlowError的示例:
public class StackOverDemo {
public static long count=0;
public static void sum(long count){
System.out.println(count++);
sum(count);
}
public static void main(String[] args) {
sum(1);
}
}
7477
Exception in thread "main" java.lang.StackOverflowError
at sun.nio.cs.UTF_8$Encoder.encodeLoop(UTF_8.java:691)
at java.nio.charset.CharsetEncoder.encode(CharsetEncoder.java:579)
at sun.nio.cs.StreamEncoder.implWrite(StreamEncoder.java:271)
at sun.nio.cs.StreamEncoder.write(StreamEncoder.java:125)
at java.io.OutputStreamWriter.write(OutputStreamWriter.java:207)
at java.io.BufferedWriter.flushBuffer(BufferedWriter.java:129)
at java.io.PrintStream.write(PrintStream.java:526)
at java.io.PrintStream.print(PrintStream.java:611)
at java.io.PrintStream.println(PrintStream.java:750)
at jvm.StackOverDemo.sum(StackOverDemo.java:6)
当我们在一个方法中不断的递归调用的时候,这个方法线程请求的栈的深度大于了虚拟机所允许的栈的深度,就抛出了StackOverFlowError异常,当然我们可以根据参数-Xss设置每个线程的堆栈大小。线程栈的大小是个双刃剑,如果设置过小,可能会出现栈溢出,特别是在该线程内有递归、大的循环时出现溢出的可能性更大,如果该值设置过大,就有影响到创建栈的数量,如果是多线程的应用,就会出现内存溢出的错误。在深入理解java虚拟机栈一书提供了一个内存溢出的例子,但是,正如书中所提示的那样,这段代码执行导致了我的Windows系统死机也没有出现OOM,最后不得不关机重启。有兴趣的朋友可以用书中的这段代码执行看看,友情提醒:注意保存数据。
代码示例:
public class StackOOMDemo {
private void dontStop() {
while (true) {
}
}
public void stackLeakByThread() {
while (true) {
new Thread(new Runnable() {
@Override
public void run() {
dontStop();
}
}).start();
}
}
public static void main(String[] args) {
StackOOMDemo oom = new StackOOMDemo();
oom.stackLeakByThread();
}
}
堆的内存溢出
对于大多数应用来说,java堆是虚拟机所管理里的内存中最大的一块区域,也是垃圾收集器管理的主要区域。java堆用于存储对象实例,只要不断的创建对象,并且保证GC Roots到对象之间有可达路径来避免垃圾回收机制来清除这些对象,那么在对象大小到达堆的最大容量限制之后就会产生内存溢出异常,也就是OutOfMemoryError。下面来通过代码示例演示一下:
为了方便演示,将堆的初始化和最大值都设置为20M并且可以通过参数-XX:+HeapDumpOnOutOfMemoryError可以让虚拟机在出现内存溢出遗产时Dump出当前的内存堆转存储快照进行分析。
-Xms20M -Xmx20M -XX:+HeapDumpOnOutOfMemoryError
public class HeapOOMDemo {
static class Student{
String name="chen";
}
public static void main(String[] args) {
List<Student> list = new ArrayList<>();
while (true){
list.add(new Student());
}
}
}
java.lang.OutOfMemoryError: Java heap space
Dumping heap to java_pid6524.hprof ...
Heap dump file created [34819181 bytes in 0.202 secs]
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
at java.util.Arrays.copyOf(Arrays.java:3210)
at java.util.Arrays.copyOf(Arrays.java:3181)
at java.util.ArrayList.grow(ArrayList.java:261)
at java.util.ArrayList.ensureExplicitCapacity(ArrayList.java:235)
at java.util.ArrayList.ensureCapacityInternal(ArrayList.java:227)
at java.util.ArrayList.add(ArrayList.java:458)
at jvm.HeapOOMDemo.main(HeapOOMDemo.java:15)
Process finished with exit code 1
方法区内存溢出
方法区存放的是类的信息如类名、常量池、字段信息等数据,所以需要在运行时产生大量的类去填满方法区,将元数据区(基于jdk1.8)设置大小为10M,使用字节码增强器来不断的产生类。-XX:MetaspaceSize=10M -XX:MaxMetaspaceSize=10M
public class MethodAreaOOMDemo {
static class Sutdent{
private void say(){};
private String name="cc";
}
public static void main(String[] args) {
while (true){
Enhancer enhancer = new Enhancer();
enhancer.setSuperclass(HeapOOMDemo.Student.class);
enhancer.setUseCache(false);
enhancer.setCallback(new MethodInterceptor() {
@Override
public Object intercept(Object o, Method method, Object[] objects, MethodProxy methodProxy) throws Throwable {
return methodProxy.invoke(o,objects);
}
});
enhancer.create();
}
}
}
Exception in thread "main" java.lang.OutOfMemoryError: Metaspace
at org.springframework.cglib.core.ReflectUtils.defineClass(ReflectUtils.java:526)
at org.springframework.cglib.core.AbstractClassGenerator.generate(AbstractClassGenerator.java:363)
at org.springframework.cglib.proxy.Enhancer.generate(Enhancer.java:582)
at org.springframework.cglib.core.AbstractClassGenerator$ClassLoaderData.get(AbstractClassGenerator.java:131)
at org.springframework.cglib.core.AbstractClassGenerator.create(AbstractClassGenerator.java:319)
at org.springframework.cglib.proxy.Enhancer.createHelper(Enhancer.java:569)
at org.springframework.cglib.proxy.Enhancer.create(Enhancer.java:384)
at jvm.MethodAreaOOMDemo.main(MethodAreaOOMDemo.java:27)
Process finished with exit code 1
我们注意到,方法区的java.lang.OutOfMemoryError和堆区的java.lang.OutOfMemoryError提示是不一样的,堆区是java heap space 而方法区是Metaspace,这里和它们所属的区域是对应的,需要注意的是,如果是jdk1.7的话,方法区提示的是PermGen space,并且设置方法区大小的参数是XX:PermSize10M -XX:MaxPermSize10M
,这是因为从JDK8开始,永久代(PermGen)的概念被废弃掉了,取而代之的是称为Metaspace的存储空间。Metaspace使用的是本地内存,默认情况下Metaspace的大小只与本地内存大小有关。
本地方法栈
本地方法栈和虚拟机栈类似,本地方法栈也会抛出StackOverFlow和OutofMemory异常。
通过上面的介绍,大致了解了虚拟机的内存结构和各个运行时数据区可能产生的异常,在生产中当我们遇到这些问题该如何下手,或者说如何分析,这一部分内容将在后面的JVM调优和各种常见异常分析章节中进行解读。其实,对于我们java程序员来说,在虚拟机自动内存管理机制的帮助下,出现上面的异常是很少的,除非代码有着严重的bug,我第一家公司刚入职的时候有个线上应用经常无法使用,程序经常报错OOM,但是当时确并不知道这个异常产生的原因。如果上天再给我一次机会的话,我一定要深扒一下😆。对于这些bug,在分析的时候,需要我们掌握java的内存模型,以及虚拟机的自动内存管理机制,也就是垃圾回收机制,下一章节将会对java中如何进行垃圾回收以及各种垃圾回收器进行一个剖析,以便我们能够更加快速准确的定位生产上面的问题。
参考书籍:
深入理解java虚拟机--周志明