Java内存区域及对象

206 阅读10分钟

Java内存区域及对象

一、Java内存区域与OOM

在Java的开发中,我们无需管理每个创建的对象。借助jvm的自动内存管理机制,我们就不需要再为每一个new出来的对象去配对写delete/free代码了,且不容易出现内存泄露和溢出的问题。但一旦出现了内存泄露或溢出的问题,如果不了解虚拟机的内存机制,那么在排查和调优时将更为困难。

Java虚拟机在执行Java程序的过程中所管理的内存划分大致可划分为以下几个区域: image.png

1.1 程序计数器

可视为当前线程所执行的字节码的行号指示器。在任何时候,cpu中的一个核只会执行一条线程中的指令。因此,每条线程均有独立的程序计数器,即这片内存区域为“线程私有”。
若线程执行程序为java方法,计数器存储值为正在执行的虚拟机字节码指令的地址。若执行的为本地方法,则计数器值为Undefined。
Note: 该区域不会OutOfMemoryError。

1.2 Java虚拟机栈

同样为线程私有,生命周期与线程相同。
虚拟机栈描述的是Java方法执行的内存模型:一个方法执行对应一个栈帧,栈帧中包含局部变量表、操作数栈、动态链接、方法出口等。一个方法调用与结束对应着这个栈帧在虚拟机栈中的入栈以及出栈。
Note:

  • StackOverflowError:线程请求栈深度(栈帧的数量)大于虚拟机所允许的深度。
  • OutOfMemoryError:若虚拟机栈容量可动态扩展,当栈扩展无法申请到足够内存时。 好奇:一个虚拟机栈最多可拥有的栈帧数量为多少呢?写一小段代码测试一下
private static int depth = 0;

public static void testStack(){
    depth++;
    testStack();
}

public static void main(String[] args) {
    try {
        testStack();
    }catch (Throwable e){
        System.out.println(e);
        System.out.println("栈深度为:" + depth);
    }
}
结果为:
java.lang.StackOverflowError
栈深度为:53203

既然栈帧存储值为局部变量表等,那么局部变量数量的多少等必定影响栈帧的数量。理论上分析局部变量等越多,虚拟机栈多拥有的栈帧数量越少,验证一下:

private static int depth = 0;

public static void testStack(int a, int b){
    depth++;
    testStack(a ,b);
}

public static void main(String[] args) {
    try {
        testStack(1, 2);
    }catch (Throwable e){
        System.out.println(e);
        System.out.println("栈深度为:" + depth);
    }
}
结果为:
java.lang.StackOverflowError
栈深度为:43206

猜测正确,那么该如何调整虚拟机栈的深度呢?可通过java -Xss来调整,如

xxx@xxx test % java -Xss5m ./src/cn/pandaz/Main.java
java.lang.StackOverflowError
栈深度为:123925

1.3 本地方法栈

作用类似于虚拟机栈,区别为虚拟机栈服务于虚拟机执行Java方法,二本地方法栈服务于虚拟机适用本地方法。
Note:

  • StackOverflowError:当栈深度溢出时。
  • OutOfMemoryError:当栈扩展无法申请到足够内存时。

1.4 堆

堆为线程共享的内存区域,几乎所有的对象实例都在这里分配内存。若从分配内存的角度看,堆又可划分出多个线程私有的分配缓冲区,以提升对象分配时效率。若从回收内存的角度,就需要根据具体的垃圾收集器来看了。但无论如何划分,Java堆存储的都是对象的实例。堆大小可通过-Xmx和-Xms设定。
Note: OutOfMemoryError:堆中没有内存完成实例分配,且堆也无法扩展时。

1.5 方法区

方法区为线程共享的内存区域,用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。在JDK1.8前,方法区往往被称为“永久代”,这么叫的原因在于当时设计团队将收集器的分代设计扩展至方法区,用永久代来实现方法区。但到JDK1.7,字符串常量池、静态变量等已移至堆中,到了JDK1.8,完全放弃了永久代,而改为元空间来代替,并将剩余内容(主要为类型信息)移至元空间中。(元空间的介绍可以参考本文
Note: OutOfMemoryError:若方法区无法满足新的内存分配时。

1.5.1 运行时常量池

方法区的一部分,用于存放编译期生成的各种字面量和符号引用。一般来说,出了保存Class文件中的符号引用外,在java类的解析阶段会将符号引用翻译出来的直接引用也存储在运行时常量池。
Note: OutOfMemoryError:当常量池无法再申请到内存时。

二、OOM示例以及简略分析

2.1 堆溢出

代码示例:

public class HeapOOM {
    static class OOMObject {
    }

    public static void main(String[] args) {
        ArrayList<OOMObject> list = new ArrayList<>();

        while (true){
            list.add(new OOMObject());
        }
    }

}

通过如下命令启动后,结果如下:其中,-Xms设置堆最小值,-Xmx设置堆最大值,设置值一致即可避免自动扩展。-XX:+HeapDumpOnOutOfMemoryError可在OOM时Dump出当前内存堆快照。

pandaz@ZhoudeMacBook-Pro chapter2 % java -Xms20m -Xmx20m -XX:+HeapDumpOnOutOfMemoryError HeapOOM.java
java.lang.OutOfMemoryError: Java heap space
Dumping heap to java_pid9896.hprof ...
Heap dump file created [42199351 bytes in 0.107 secs]
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
        at chapter2.HeapOOM.main(HeapOOM.java:19)
pandaz@ZhoudeMacBook-Pro chapter2 % ls
HeapOOM.java            java_pid9892.hprof      java_pid9896.hprof

通常这种情况下的简略分析法:

  1. 通过分析工具(MAT等)对Dump出来的文件(即👆所示的java_pid9892.hprof等文件)进行分析,确定是内存泄露还是内存溢出,若是内存泄露可通过工具进一步分析找到产生内存泄露的代码。
  2. 若不是内存泄露,即内存中所有对象确实都是必须存活的。那么应当检查堆参数(-Xmx与-Xms)设置。再检查代码看是否有对象生命周期过长、存储结构不合理等情况。

2.2 栈溢出

java内存区域的栈包含虚拟机栈和本地方法栈,栈中存在以下两种异常:

  • StackOverFlowerError:请求栈深度大于所允许的深度。
  • OutOfMemoryError:(虚拟机运行动态扩展前提下)扩展栈容量无法申请到足够内存时。 栈溢出的示例在上面描述虚拟机栈相关概念时已有演示。
    一般出现StackOverflowError异常时,会有明确的错误信息提示可供直接定位到问题所在。一般HotSpot虚拟机默认参数下栈深度可达1000~2000。正常情况下,够用,但如果建立过多线程导致内存溢出。这时需要通过减少堆内存和减少栈容量来换取更多线程(一般情况下,单个进程内存是有限的)。

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

书中所述示例都基于JDK8之前,即用永久代实现方法区时。在JDK8后,所列举的示例已经很难迫使虚拟机产生方法区的异常操作了。下面列出了一些相关的防御措施:

  • -XX:MaxMetaspaceSize:设置元空间大小(默认-1,只受限于本地内存大小)。
  • -XX:MetaspaceSize:指定元空间初始空间大小,以字节为单位,达到该值就会触发垃圾收集进行类型卸载,同时收集器会动态调整该值(释放空间较大,则调小;释放空间小,则调大)
  • -XX:MinMetaspaceFreeRatio:作用是在垃圾收集之后控制最小的元空间剩余容量的百分比。
  • -XX:MaxMetaspaceFreeRatio:作用是在垃圾收集之后控制最大的元空间剩余容量的百分比。

2.4 直接内存溢出

直接内存为NIO适用Native函数库直接分配的堆外内存,通过堆中的DirectByteBuffer对象作为这块内存的引用进行操作,该区域也可能出现OOM,即各个内存区域总和大于物理内存限制,从而导致动态扩展时出现OOM。通常情况下,直接内存可通过-XX:MaxDirectMemorySize参数指定,若不指定则默认与堆最大值一致。
异常示例:附加参数运行:java -Xmx20m -XX:MaxDirectMemorySize=10m .\DirectMemoryOOM.java建议在虚拟机中进行相关调试,否则一不小心就死机咯。

public class DirectMemoryOOM {
    private static final int _1MB = 1024 * 1024;

    public static void main(String[] args) throws IllegalAccessException {
        Field field = Unsafe.class.getDeclaredFields()[0];
        field.setAccessible(true);
        Unsafe unsafe = (Unsafe) field.get(null);
        while (true){
            unsafe.allocateMemory(_1MB);
        }
    }
}
执行结果:
Exception in thread "main" java.lang.OutOfMemoryError
	at sun.misc.Unsafe.allocateMemory(Native Method)
	at DirectMemoryOOM.main(DirectMemoryOOM.java:12)

由直接内存导致的内存溢出存在一个明显的特征:在Heap Dump文件中无明显异常情况。若发现OOM且产生的Dump文件很小且程序中使用了DirectMemory,就需要重点检查一下直接内存方面的原因了。

三、虚拟机中的对象

在了解完虚拟机中相关的内存区域以及可能抛出的异常后,我们再简要分析一下虚拟机中的对象,即分析一下一个对象在虚拟机中是如何创建、内存布局如何以及如何访问该对象的。

3.1 对象的创建

当java虚拟机遇到new指令后:

  1. 检查该指令参数是否能在常量池中定位到一个类的符号引用。并检查该符号引用代表的类是否已被加载、解析以及初始化。若没有则先执行类加载。
  2. 分配内存。
    • 指针碰撞:假设堆中内存是绝对规整的,所有使用过的内存放在一边,空闲在另一边,中间放着一个指针作为分界指示器,那么分配内存就是把指针往空闲方向挪动一段与对象大小相等的距离。
    • 空闲列表:假设堆中内存不规整,虚拟机需要维护一个列表以记录哪些区域是可用的,分配时从列表中找到一段足够大的区域划分给对象。
  3. 将分配的内存空间(不包括对象头)初始化为0。
  4. 设置对象(归属那个类,如何找到元数据,对象GC分代年龄等)

3.2 对象的内存分布

在Hotspot虚拟机中,对象在堆中的存储布局可分为:对象头、实例数据以及对齐填充。

3.2.1 对象头

对象头包括两类信息:

  • 用于存储对象自身的运行时数据:如hashcode、GC分代年龄、锁状态标志、线程持有的锁、偏向线程id等
  • 类型指针:对象指向它的类型元数据指针。

3.2.2 实例数据

这部分是对象真正储存的有效信息,包括我们自己所定义的各种类型字段内容等。

3.2.3 对象填充

不是必须部分,也无特殊含义。仅用做占位符。(HotSpot中任何对象均是8字节的整数倍)

3.2.2

3.3 对象的访问

对象访问方式取决于虚拟机如何实现,主流访问方式有句柄和直接指针两种:

  • 通过句柄访问:优点为移动对象时只需改变句柄中的实例指针。 image.png
  • 通过直接指针访问:相比上一种方式,节省了一次指针定位的时间开销。 image.png 就目前主要的虚拟机HotSpot而言,采用第二种方式进行对象访问。

总结

image.png

小问题:字符串常量池和运行时常量池到底在哪部分内存区域?
在JDK1.7版本时,原存储在永久代中的字符串常量池、静态变量等移至Java堆中。到了JDK1.8,完全废弃了永久代,将永久代中还剩余部分移至元空间中(类型信息(元数据信息)等其他信息)。也就是说运行时常量池和字符串常量池实际移动到了堆中,只是逻辑上还是属于方法区。

参考文献

《深入理解JAVA虚拟机》

该博客仅为初学者自我学习的记录,粗浅之言,如有不对之处,恳请指正。