[笔记]深入理解Java虚拟机(1)-Java内存区域与内存溢出

1.走进 Java

2.Java 内存区域与内存溢出异常

2.2 运行时数据区域

先上一张JVM体系结构图(来自 https://www.cnblogs.com/JunFengChan/p/9250585.html):

2.2.1 程序计数器(Program Counter Register)

每个线程都有一个独立的程序计数器(是一块较小的内存空间),各线程之间计数器互不影响,独立存储——“线程私有”的内存。 如果线程正在执行一个 Java 方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行本地方法,则值为空(Undefined)。
程序计数器是唯一一个在 Java 虚拟机规范中没有规定任何 OutOfMemoryError 情况的区域。

2.2.2 Java 虚拟机栈(Java Virtual Machine Stacks)

也是线程私有的,生命周期与线程相同。描述的是Java方法执行的内存模型:每个方法在执行的同时都会创建一个栈帧用于存储局部变量表、操作数栈、动态链接、方法出口等信息。
局部变量表存放了编译期可预知的各种基本数据类型(bollean、byte、char、short、int、long、float、double)、对象引用(reference类型)和returnAddress类型(指向了一条字节码指令的地址)。
局部变量表所需的内存空间在编译期间完成分配,当进入一个方法时,这个方法需要在桢中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小。
Java虚拟机规范对该区域规定了两种异常状况:

  • StackOverflowError :如果线程请求的栈深度大于虚拟机所允许的深度
  • OutOfMemoryError :如果虚拟机栈可以动态扩展(大部分都支持),且在扩展时无法申请到足够的内存

2.2.3 本地方法栈(Native Method Stack)

与虚拟机栈作用非常相似,区别不过是,虚拟机栈为虚拟机执行Java方法(字节码)服务,而本地方法栈则为虚拟机用到的Native方法服务。也会抛出 StackOverflowError 和 OutOfMemoryError 异常。

2.2.4 堆(Java Heap)

Java 堆是被多有线程共享的一块内存区域,在虚拟机启动时创建。此区域唯一目的就是存放对象实例,几乎所有的对象实例都在这里分配内存。
堆是垃圾收集器管理的主要区域(“GC 堆”),堆内存区域的划分是为了更好地回收内存,或者更快地分配内存。
Java 堆可以处于物理上不连续的内存空间中,只要逻辑上是连续的即可。
如果在堆中没有内存完成实例分配,并且堆也无法再扩展时,将会抛出 OutOfMemoryError 异常。

2.2.5 方法区(Method Area)

与堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。

Java 虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却又一个别名叫做 Non-Heap (非堆),目的应该是与 Java 堆区分开来。 当方法去无法满足内存分配需求时,将会抛出 OutOfMemoryError 异常。

2.2.6 运行时常量池(Runtime Constant Pool)

是方法区的一部分。Class 文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池(Constant Pool Table),用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池中存放。
当常量池无法再申请到内存时会抛出 OutOfMemoryError 异常。

2.2.7 直接内存(Direct Memory)

并不是虚拟机运行时数据区的一部分,也不是Java虚拟机规范中定义的内存区域。但是这部分内存也被频繁的使用,而且也可能导致 OutOfMemoryError 出现。
本机直接内存的分配不会受到 Java 堆大小的限制,但是,既然是内存,肯定还是会受到本机总内存大小以及处理器寻址空间的限制。如果虚拟机参数配置不正确,动态扩展时依然可能出现 OutOfMemoryError 异常。

2.3.1 对象的创建

在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象多需内存的大小在类加载完成后便可以完全确定。为对象分配空间的任务等同于把一块确定大小的内存从Java堆中划分出来。

  • 指针碰撞法(Bump the Pointer):堆中内存是绝对规整的

    例如使用 Serial,ParNew 等带 Compact 过程的收集器时

  • 空闲列表法(Free List):堆中内存不规整

    例如使用 CMS 这种基于 Mark-Sweep 算法的收集器时

选择哪种分配方式由堆是否规整决定,堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。

2.3.2 对象的内存布局

在 HotSpot 虚拟机中,对象在内存中存储的布局可以分为3块区域:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。

1.对象头包括两部分信息:

  • "Mark Word"

    用于存储对象自身运行时的数据(非固定的数据结构),如哈希吗(HashCode)、GC 分代年龄、锁状态标示、线程持有的锁、偏向线程ID、偏向时间戳等。

  • 类型指针

    即对象指向他的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。并不是所有的虚拟机实现都必须在对象数据上保留类型指针,换句话说,查找对象的元数据信息并不一定要经过对象本身
    另外,如果对象是一个数组,那在对象头中还必须有一块用于记录数组长度的数据,因为虚拟机可以通过普通对象的元数据信息确定对象的大小,但是从数组的元数据中却无法确定数组的大小

2.实例数据
是对象真正存储的有效信息,也是再程序代码中所定义的各种类型的字段内容。

3.对齐填充
并不是必然存在的,也没有特别的含义,仅仅起着占位符的作用。

HotSpot VM 的内存管理系统要求对象的大小必须是8字节的整数倍。而对象头部分正好是8字节的倍数,因此,当对象实例数据部分没有对齐时,就需要通过对齐填充来补全。

2.3.3 对象的访问定位

Java 程序是通过栈上的reference数据来操作堆上的具体对象。对象访问方式也是取决于虚拟机的具体实现。目前主流的访问方式有使用句柄和直接指针两种。

  • 使用句柄:

    堆中会划分出一块内存来作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信息。 优势在于:reference 中存储的是稳定的句柄地址,在对象被移动(垃圾收集时移动对象是很普遍的行为)时只会改变句柄中的实例数据指针,而reference本身不需要修改。

  • 直接指针:

    使用直接指针访问,Java 堆对象的布局中就必须考虑如何放置访问类型数据的相关信息,而refrence中存储的直接就是对象地址。 优势在于:速度更快,节省了一次指针定位的时间开销,这类开销在积少成多之后也是一项非常可观的执行成本。Sun HotSpot 在使用直接指针进行对象访问。

2.4 OutOfMemoryError 异常

2.4.1 Java 堆溢出

/**
 * java 堆内存溢出异常测试
 * 
 * VM Args: -verbose:gc -Xms20M -Xmx20M -XX:+HeapDumpOnOutOfMemoryError -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8
        -verbose:gc 表示输出虚拟机中GC的详细情况
        -Xms20M -Xmx20M 将堆的最小值-Xms参数与最大值-Xmx参数设置一样,可避免堆自动扩展
        --XX:+HeapDumpOnOutOfMemoryError 表示当JVM发生OOM时,自动生成DUMP文件
        -XX:HeapDumpPath=${目录} 表示生成DUMP文件的路径,也可以指定文件名称
                            【可以使用Eclipse Memory Analyzer 打开堆转储快照文件(.hprof),区分是内存泄露还是内存溢出】
        -Xmn10M 设置年轻代为10M
        -XX:NewRatio=4 表示年轻代(年轻代包括:Eden和两个Surivor)与年老代(年老代不包括持久代)的比值=1:4
        -XX:SurvivorRatio=8 表示年轻代中,Survivor与Eden的比值=1:8(这样在整个年轻代中,Surivivor 占10分之2)
 *
 * @date 2018-10-23
 */
public class HeapOOM {

    static class OOMObject {

    }

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

        while (true) {
            list.add(new OOMObject());
        }
    }
}
/**
运行结果:
java.lang.OutOfMemoryError: Java heap space  
Dumping heap to java_pid14708.hprof ...  
Heap dump file created [28107585 bytes in 0.085 secs]  
*/

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

由于在HoSpot虚拟机中并不区分虚拟机栈和本地方法栈,因此,对于HotSpot来说,虽然-Xoss参数(设置本地方法栈大小)存在,但实际上是无效的,栈容量只有-Xss参数设定。

  • 如果线程请求的栈深度大于虚拟机所允许的最大深度,将抛出 StackOverflowError 异常
  • 如果虚拟机在扩展栈时无法申请到足够的内存空间,则抛出 OutOfMemoryError 异常
/**
 * 虚拟机栈和本地方法栈OOM测试
 * 
 * VM Args: -Xss128k
 * @date 2018-10-23
 */
public class JavaVMStackSOF {  
    private int stackLength = 1;

    // 无出口的递归调用
    public void stackLeak() {
        stackLength++;
        stackLeak();
    }

    public static void main(String[] args) throws Throwable {
        JavaVMStackSOF sof = new JavaVMStackSOF();
        try {
            sof.stackLeak();
        } catch (Throwable e) {
            System.out.println("stack length: " + sof.stackLength);
            throw e;
        }
    }
}
/**
运行结果:
stack length: 985  
Exception in thread "main" java.lang.StackOverflowError  
    at sof.JavaVMStackSOF.stackLeak(JavaVMStackSOF.java:16)
    at sof.JavaVMStackSOF.stackLeak(JavaVMStackSOF.java:16)
    at sof.JavaVMStackSOF.stackLeak(JavaVMStackSOF.java:16)
    ...
*/

在Windows平台的虚拟机中,Java的线程是映射到操作系统内核线程上的。

实验结果表明:在单个线程下,无论是由于栈帧太大还是虚拟机栈容量太小,当内存无法分配的时候,虚拟机抛出的都是 StackOverflowError 异常。
如果测试不限于单线程,通过不断地建立线程的方式倒是可以产生内存溢出异常。但是这样产生的内存溢出异常与栈空间是否足够大并不存在任何联系,或者准确地说,在这种情况下,为每个线程的栈分配的内存越大,反而越容易产生内存溢出异常。

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

  • JDK1.6及之前的版本中,由于常量池分配在永久代内,可以通过-XX:PermSize-XX:MaxPermSize限制方法区大小,从而间接限制其中常量池的大小。

    1.常量池溢出会报: java.lang.OutOfMemoryError: PermGen space
    2.intern()方法会把首次遇到的字符串实例复制到常量池(还在永久代中)中,返回的也是常量池中这个字符串实例的引用

  • JDK1.7 开始逐步“去永久代”,Hotspot虚拟机便将运行时常量池从永久代移除了,被放入到堆空间中。

    1.存储在永久代的部分数据就已经转移到了Java Heap或者是 Native Heap,但永久代仍存在于JDK1.7中,并没完全移除,譬如符号引用(Symbols)转移到了native heap;字面量(interned strings)转移到了java heap;类的静态变量(class statics)转移到了java heap。(字面量和符号引用以前在常量池中)。
    2.对于intern()方法:如果常量池中不存在对应实例,不再复制实例,只是在常量池中记录首次出现的实例引用,并返回。(常量池中不需要再存储一份对象了,可以直接存储堆中的引用)

  • JDK1.8 开始,PermGen 空间全部移除,一种新的内存空间诞生——元空间(Metaspace)

    1.8中关于intern()部分可以参考1.7。

关于 intern() 方法具体分析可以参考,你真的了解String类的intern()方法吗

方法区用于存放 Class 的相关信息,如类名、访问修饰符、常量池、字段描述、方法描述等。对于这些区域的溢出测试,基本的思路是运行时产生大量的类去填满方法区,直到溢出。

import java.lang.reflect.Method;  
import net.sf.cglib.proxy.Enhancer;  
import net.sf.cglib.proxy.MethodInterceptor;  
import net.sf.cglib.proxy.MethodProxy;

/**
 * 借助CGLib使方法去出现内存溢出异常
 * VM Args: -XX:PermSize=10M -XX:MaxPermSize=10M
 * @date 2018-10-24
 */
public class JavaMethodAreaOOM {  
    public static void main(String[] args) {
        while (true) {
            Enhancer enhancer = new Enhancer();
            enhancer.setSuperclass(OOMObject.class);
            enhancer.setUseCache(false);
            enhancer.setCallback(new MethodInterceptor() {
                @Override
                public Object intercept(Object obj, Method method, Object[] args, MethodProxy proxy) throws Throwable {
                    return proxy.invokeSuper(obj, args);
                }
            });
            enhancer.create();
        }
    }
    static class OOMObject {}
}
/**
运行结果:
Exception in thread "main" java.lang.OutOfMemoryError: PermGen space  
    at java.lang.Class.getDeclaredMethods0(Native Method)
    at java.lang.Class.privateGetDeclaredMethods(Class.java:2427)
    at java.lang.Class.getDeclaredMethod(Class.java:1935)
    at net.sf.cglib.proxy.Enhancer.getCallbacksSetter(Enhancer.java:627)
    at net.sf.cglib.proxy.Enhancer.setCallbacksHelper(Enhancer.java:615)
    at net.sf.cglib.proxy.Enhancer.setThreadCallbacks(Enhancer.java:609)
    at net.sf.cglib.proxy.Enhancer.createUsingReflection(Enhancer.java:631)
    at net.sf.cglib.proxy.Enhancer.firstInstance(Enhancer.java:538)
    at net.sf.cglib.core.AbstractClassGenerator.create(AbstractClassGenerator.java:225)
    at net.sf.cglib.proxy.Enhancer.createHelper(Enhancer.java:377)
    at net.sf.cglib.proxy.Enhancer.create(Enhancer.java:285)
    at oom.JavaMethodAreaOOM.main(JavaMethodAreaOOM.java:28)
**/

方法区的溢出也是一种常见的内存溢出异常,一个类要被垃圾收集器回收掉,判定条件是比较苛刻的。在经常动态生成大量Class的应用中,需要特别注意类的回收状况。

2.4.4 本机直接内存溢出

DirectMemory 容量可通过-XX:MaxDirectMemorySize指定,如果不指定,则默认与Java堆最大值(-Xmx指定)一样。
由 DIrectMemory 导致的内存溢出,一个明显的特征是在 Heap Dump 文件中不会看见明显的异常,如果读者发现OOM之后Dump文件很小,而程序中又直接或间接使用了NIO,那就可以考虑检查一下是不是这方面原因。