一、深入理解java虚拟机——java内存区域与内存溢出异常

本文主要从概念上介绍java虚拟机内存的各个区域,以及这些区域的作用、服务对象以及其中可能产生的问题。这是深入学习虚拟机内存管理的第一步。

1 运行时数据区域

  • java虚拟机
    • 程序计数器
    • java虚拟机栈
    • 本地方法栈
    • java堆
    • 方法区
      • 运行时常量池
  • 直接内存

在这里插入图片描述

1.1 程序计数器 program counter register

程序计数器是一块较小的内存,存放当前线程所执行的字节码的行号指示器。是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都依赖它。
每个线程有一个独立的程序计数器,各个线程之间的计数器相互不影响,独立存储。线程私有。
如果线程正在执行的是一个java方法,计数器记录的是正在执行的虚拟机字节码指令地址;如果正在执行的是本地native方法,则计数器的值为空(undefined)。
此内存区域是唯一一个在《java虚拟机规范》中没有定义任何OOM情况的区域。

1.2 java虚拟机栈 java virtual machine stack

线程私有,和线程的生命周期一样。描述的是java方法执行的线程内存模型:每个方法被执行时,java虚拟机都会同步创建一个栈帧用于存储局部变量表、操作数栈、动态连接、方法出口等信息。一个方法从调用到执行结束的过程,就对应一个栈帧在虚拟机栈中从入栈到出栈的过程。
平常所讲的栈大多情况下只是指的虚拟机栈中局部变量表部分。
局部变量表存放了编译期可知的各种java虚拟机基本数据类型(boolean/byte/char/short/int/float/long/double)、对象引用和returnAddress类型(指向了一条字节码指令地址)。这些数据类型在局部变量表中的存储空间以局部变量槽(slot)的来表示,其中64位长度的long和double类型的数据占用两个变量槽,其余数据占用一个。编译期完成局部变量表内存的分配,运行时不会改变局部变量表的大小。
《java虚拟机规范》中,对这个内存区规定了两类异常:

  • 线程请求的栈的深度大于虚拟机所运行的深度,抛出StackOverflowError;
  • 如果虚拟机栈的容量可以扩展,当扩展时无法申请到足够内存则抛OOM。

1.3 本地方法栈native method stacks

作用和虚拟机栈相似,区别是虚拟机栈为执行java方法服务,本地方法栈为本地方法服务。
Hotspot虚拟机的实现直接把虚拟机栈和本地方法栈合二为一。
本地方法栈也会抛出StackOverflowError和OOM。

1.4 java堆 java Heap

所有的对象实例以及数组都应当在堆上分配。—《java虚拟机规范》

“几乎”所有的对象实例都在堆里分配内存。由于逃逸分析技术的日渐强大,栈上分配、标量替换优化手段已经导致java对象实例都分配在对上逐渐变得没有那么绝对了。

堆是GC管理的内存区域。平常所说的“java 虚拟机的堆内存分为新生代 老年代 永久代 Eden Survivor 等’”并不是《java虚拟机规范》里对java堆的进一步细致划分;这些区域的划分仅仅是一部分垃圾回收器的共同特性或者说是设计风格。以G1收集器的出现为界,之前HotSpot虚拟机基于“经典分代”来设计,上述说法没毛病;但是G1出现之后 Hotspot就出现了不采用分代设计的垃圾收集器,在按照上面的说法就有歧义了。

java堆可以处理物理上不连续的内存空间,但是逻辑上堆应该被视为连续的。对于大对象(如数组),大多虚拟机实现出于实现简单、存储高效的考虑,很可能要求连续的内存空间。
可以使用-Xmx,-Xms参数设置堆的最大和初始大小。

1.5 方法区

和堆一样是线程共享的。用于存储已被虚拟机加载的类型信息、常量、静态变量、及时编译器编译后的代码缓存等数据。是堆的一个逻辑部分,但是它有个别名是Non-Heap(非堆),目的是为了和堆区分开。

方法区和永久代本质上是不等价的。
永久代的概念不是《java虚拟机规范》里定义的,它属于HotSpot虚拟机实现独有的分区,HotSpot为了省事(把收集器的的分代设计扩展至方法区,这样就能实现堆和方法区的内存统一处理,不用为方法区写专门的收集器),将永久代待实现方法区。其他虚拟机的实现就可能没有永久区这个概念。
方法区是《java虚拟机规范》所规定的,可以理解为理论规范。

HotSpot将永久代代替方法区的做法并不太好,更容易出现内存溢出,JDK6时HotSpot团队就有放弃永久代,使用本地内存代替方法区的计划;JDK7的Hotspot已经把字符串常量池、静态变量等移至JAVA堆了。JDK8时完全放弃了永久代的概念,改用本地内存中实现的元空间(Mata-space)来代替。 至此永久代的内容全部被移出。

1.5.1 运行时常量池

是方法区的一部分。用于存放Class文件中的常量池表(常量池表用于存放编译期生成的各种字面变量与符号引用)。

Class文件包含类的版本、字段、方法、接口、常量池表等描述信息。

并非编译期的常量才能进入常量池,运行期间产生的常量也会进入常量池。最经典的就是String类的intern()方法,用来返回该字符串对象对应的常量池中的字符串常量。

1.6 直接内存

不是虚拟机运行时数据区的一部分,也不是《java虚拟机规范》定义的内存区。这部分内存会被频繁的使用,而且也能导致OOM。
JDK1.4之后NIO出现,引入了通道与缓存区,它可以直接使用Native函数库直接分配堆外内存,然后通过一个存储在java堆里的DirectByteBuffer对象作为这块内存的引用进行操作。这样就避免了java堆和Native堆中来回copy数据。
直接内存和java堆内存都是占用的内存空间,在设置java堆内存大小时注意合理设置,避免留给直接内存的空间太小,导致OOM。

2 Hotspot虚拟机对象探秘

2.1 对象创建

以HotSpot虚拟机为例,讨论一下java堆中对象的分配、布局和访问的全过程。

  1. 虚拟机遇到new指令时,先检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并检查这个符号引用的类是否被加载、解析和初始化过。如果没有则先加载类。
  2. 加载通过后,为新生对象分配内存。当使用Serial、parNew等带压缩整理的收集器时,系统采用分配算法是指针碰撞
    当使用CMS这种基于清除算法的收集器时,理论上就只能采用较复杂的空闲列表来分配内存。

指针碰撞:假设堆内存是绝对规整的,即所有使用过个内存在一边,所有未被使用过的内存在另一边,用一个指针作为分界点指示器,当要分配内存时,只需要把指针向未使用区域移动相应的大小就行了。
空闲列表:当内存是非规整时,就要用个列表,用来记录哪些内存未被使用,当需要分配内存时从列表中选一块够大的内存分配给对象实例。

内存的分配还要考虑到线程安全问题,在移动指针时是线程不安全的。解决方法要么用时间换空间,要么用空间换时间。

  • 一种是对内存分配动作进行同步处理,虚拟机一般采用CAS配上失败重试的方式保证操作的原子性。
  • 另一种是把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程先在堆中预先分配一小块内存,成为本地线程分配缓冲,哪个线程分配内存时就用自己的线程分配缓冲区拿内存。 虚拟机是否使用本地线程分配缓冲,使用-XX:+/-UseTLAB参数设置。
  1. 内存分配完成之后,虚拟机会对内存进行初始化为零值,如果使用了TLAB,初始化工作会在TLAB分配时就完成。初始化保证了对象的实例字段在java代码可以不被赋初值就可以直接使用,程序访问到的是这些字段的数据类型所对应的零值。

  2. 上述完成之后,虚拟机还要对对象进行必要的设置:
    将对象class引用,元数据信息,对象哈希码,对象的GC分代年龄等信息放到对象头中。
    根据虚拟机当前的运行状态不同,如是否启用偏向锁等,对象的头信息会有不同的设置方式。之后会详细讨论。

    -------------------------------分割线,1234是java虚拟机角度看对象的产生,之后是java呈现执行的角度来看,还有好多要做---------------------------------

  3. 执行构造函数,即Class的init方法,new指令之后会紧接着执行init方法,按照构造方法的设置进行对象初始化,执行完成之后一个对象才算完全被构造出来。

源码就不搞出来了。。

2.2 对象的内存布局

Hotspot中对象的内存中存储布局分为三个部分:对象头Header、实例数据Instance Data和对齐填充区Padding。

Hotspot的对象头部包含两类信息:

  1. 对象自身的运行时数据,如哈希码、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等;32位和64位的虚拟机中分别占32和64比特。官方称之为Mark Word。Mark Word是一个动态定义的数据结构,以便在有限的空间存储更多的数据,根据对象的状态复用自己的存储空间。比如在32位Hotspot中,如对象未被同步锁锁定的状态下,Mark word的32个比特中的25个用于存储对象哈希码,4个用于存储对象的分代年龄,2个用于存储锁标志位,1个固定为0;在其他状态下(轻量级锁定,重量级锁定、GC标记、可偏向)对象的存储内容:
存储内容锁标志位状态
对象哈希码、对象分代年龄01未锁定
指向所记录的指针00轻量级锁
指向重量级锁的指针10膨胀(重量级锁)
空,不需要记录信息11GC标记
偏向线程ID、偏向时间戳、对象分代年龄01可偏向
  1. 另一部分是类型指针,即指向他的类型元数据的指针,虚拟机通过该指针知道该对象是哪个实例。也不一定都是通过对象的指针找到对象的元数据信息。此外如果对象是个数组,对象头中还必须包含用于记录数组长度的数据。

实例数据区域

存储对象的真正有效信息,即代码中定义的各种类型的字段内容,父类继承的还是本身定义的都会被记录下来。这部分的存储顺序会受到虚拟机分配策略参数-XX:FieldsAllocationStyle参数和字段在代码中的定义顺序影响。Hotspot默认的分配顺序是longs/doubles、ints、shorts、chars、bytes/booleans、oops(ordinary object pointers),即相同宽度的总是被分配到一起。如果-XX:CompactFields参数为true(默认就是),那么子类较窄的变量也会被允许插入父类变量的空隙之中,以便节省空间。

填充区

没有实际意义,就是为了占位。Hotspot的自动内存管理系统要求对象的起始位置是8字节的整数倍,也就是每个对象的大小都必须是8字节的整数倍。对象头是被计算好的,固定为8的整数倍,因此,如果对象实例数据部分不是,则用填充区补齐。

2.3 对象的访问定位

java程序会通过栈上的reference数据来操作堆上的具体对象。对象的访问方式根据不同的虚拟机实现而定;主流的访问方式主要有使用句柄和直接指针两种。

  • 句柄访问:在java堆中划分出一块内存作为句柄池,reference中存储的是句柄池,句柄池包含了对象实例数据类型数据各自具体的地址信息。
  • 指针访问:java堆的对象布局就要考虑如何防止访问类型数据的相关信息,reference中存储的直接就是对象地址,如果只访问对象本身,就不需要多一次访问开销。

指针访问对象的好处就是快,不需要两次定位。而句柄池访问的好处是在对象被移除是,只需要改变句柄中的实例数据指针,reference本身不需要修改。
Hotspot虚拟机使用的是指针访问。
在这里插入图片描述

3 OOM问题

除了程序计数器之外,虚拟机内存的其他几个运行时区域都会发生OOM异常。以下内容主要是针对Hotspot虚拟机,其他虚拟机可能有所不同。

3.1 java堆溢出

不断地创建对象并避免GC掉,当容量触及最大的堆容量就会产生内存溢出异常。

列1、java堆内存溢出异常测试,参数设置为 -Xms20m -Xmx20m -XX:+HeapDumpOnOutOfMemoryError
‘HeapDumpOnOutOfMemoryError’参数是当产生内存溢出时,让虚拟机打印出内存堆快照。

/**
 * VM Args:-Xms20m -Xmx20m -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=./
 *
 * @author zzm
 */
public class HeapOOM {

    static class OOMObject {
    }

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

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

运行结果:

java.lang.OutOfMemoryError: Java heap space
Dumping heap to java_pid9180.hprof ...
Heap dump file created [28181289 bytes in 0.115 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 org.fenixsoft.jvm.chapter2.HeapOOM.main(HeapOOM.java:20)

Process finished with exit code 1

jvisualvm是JDK自带的Java性能分析工具,在JDK的bin目录下,文件名就叫jvisualvm.exe。
jvisualvm可以监控本地、远程的java进程,实时查看进程的cpu、堆、线程等参数,对java进程生成dump文件,并对dump文件进行分析。
在这里插入图片描述
解决这个内存区域的异常,常规的处理方法:

  1. 通过内存映像分析工具,例如jvisualvm,对Dump出来的堆转储快照进行分析。第一步首先确认导致OOM的对象是否是必要的,也就是先分清楚是出现了内存泄漏还是内存溢出。
    在这里插入图片描述

  2. 如果是泄漏,通过工具查看泄漏对象到GC Root的引用链,找到泄漏对象是通过怎样的引用路径、与哪些GC Roots相连,才导致垃圾回收器无法回收他们。根据泄漏对象类型信息和GC roots引用链定位到这些对象的创建位置,进而找出具体代码。

  3. 如果是内存溢出,也就是内存不够用,那就检查虚拟机的参数-Xxm与-Xms设置,适当调大堆内存;再从代码上检查是否有些对象的生命周期过长、持有状态时间过长、存储结构设计不合理等情况,尽量减少运行时内存消耗。

堆内存的分析后续会深入讲解,这里只是简单分析。

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

HotSpot虚拟机不区分虚拟机栈和本地放方法栈,所以-Xoss参数对Hotspot虚拟机不起作用,只有-Xss参数是有效的。《java虚拟机规范》定义了两种栈异常:

1. 如果线程请求的栈深度大于虚拟机所允许的最大深度,将抛出StackOverflowError。
2. 如果虚拟机的栈内存允许动态扩展,当扩展栈容量无法申请足够的内存时,将抛出OOM。

栈内存分配是以线程为单位的,当线程创建时创建自己的栈内存。栈帧是指线程中局部变量所占用的栈内存大小。

Hotspot虚拟机不支持栈内存扩展,所以Hotspot虚拟机只会在创建线程申请内存时因内存不足而抛出OOM,不会在运行时因扩展栈内存抛出OOM;只会因为栈容量无法容纳新栈而导致SOF。以实验证明:

实验1:使用-Xss参数减少内存容量,然后函数递归调用;

package org.fenixsoft.jvm.chapter2;

/**
 * VM Args:-Xss128k
 *
 * @author zzm
 */
public class JavaVMStackSOF_1 {

    private int stackLength = 1;

    public void stackLeak() {
        stackLength++;
        stackLeak();
    }

    public static void main(String[] args) {
        JavaVMStackSOF_1 oom = new JavaVMStackSOF_1();
        try {
            oom.stackLeak();
        } catch (Throwable e) {
            System.out.println("stack length:" + oom.stackLength);
            throw e;
        }
    }
}

结果:抛出SOF。

stack length:988
Exception in thread "main" java.lang.StackOverflowError
	at org.fenixsoft.jvm.chapter2.JavaVMStackSOF_1.stackLeak(JavaVMStackSOF_1.java:13)

实验2:定义大量的本地变量,增大此方法栈帧中本地变量表的长度。

package org.fenixsoft.jvm.chapter2;

/**
 * VM: JDK 1.0.2, Sun Classic VM
 *
 * @author zzm
 */
public class JavaVMStackSOF_3 {
    private static int stackLength = 0;

    public static void test() {
        long unused1, unused2, unused3, unused4, unused5,....unused100;

        stackLength ++;
        test();

        unused1 = unused2 = unused3 = unused4 = unused5 =....unused100 = 0;
    }

    public static void main(String[] args) {
        try {
            test();
        }catch (Error e){
            System.out.println("stack length:" + stackLength);
            throw e;
        }
    }
}

结果:抛出SOF

stack length:20191
Exception in thread "main" java.lang.StackOverflowError

结果表明无论是栈帧太大,还是虚拟机栈容量太小,Hotspot都会抛出SOF。但是如果实验2在可以扩展栈容量的虚拟机上运行,将会抛出OOM异常。


实验1,2证明了运行时栈内存不足而引起的SOF,然鹅还有一周情况:如果创建许多线程过多,线程创建时无法申请到栈空间,将会抛出OOM。
实验3

package org.fenixsoft.jvm.chapter2;

/**
 * VM Args:-Xss2M (这时候不妨设大些,请在32位系统下运行)
 *
 * @author zzm
 */
public class JavaVMStackOOM {

    private void dontStop() {
        while (true) {
        }
    }

    public void stackLeakByThread() {
        while (true) {
            Thread thread = new Thread(new Runnable() {
                @Override
                public void run() {
                    dontStop();
                }
            });
            thread.start();
        }
    }

    public static void main(String[] args) throws Throwable {
        JavaVMStackOOM oom = new JavaVMStackOOM();
        oom.stackLeakByThread();
    }
}

我在64位电脑上没跑出来异常,就是电脑卡死了。。。。。
在32位电脑上应该抛出OOM:unable to create native thread给每个线程分配的栈内存越大,线程个数就会受到限制;相反如果想要尽量多线程就应该给每个线程的栈容量减小,但是如果减少了栈容量,就会容易导致SOF异常。

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

常量池是方法区的一部分,这两个区的溢出测试就放在一起进行。Hotspot从jdk7开始逐渐抛弃永久代,到jdk8中完全用元空间实现方法区;下面就看一下永久代和元空间这两种方法区的实现方式对程序的影响。

String::intern()是一个本地方法,作用是如果字符串常量池中已经包含一个等于此String对象的字符串,则返回常量池中的这个字符串的String对象的引用;否则,将此String对象包含的字符串添加到常量池,并且返回此常量池中String对象的引用。

jdk7之前的常量池都分配在永久代,我们可以使用-XX:PermSize=10M -XX:MaxPermSize=10M 参数设置其大小;而在jdk7时和之后永久代的字符串常量池和静态变量被移动到堆里,到jdk8永久区剩余的类信息,及时编译器编译的代码被放在了元空间。从此再无永久代。所以jdk7在设置 -XX:PermSize=10M -XX:MaxPermSize=10M就不起作用了。

public class RuntimeConstantPoolOOM_2 {

    public static void main(String[] args) {
        String str1 = new StringBuilder("计算机").append("软件").toString();
        System.out.println(str1.intern() == str1);

        String str2 = new StringBuilder("ja").append("va").toString();
        System.out.println(str2.intern() == str2);
    }
}

上述代码在jdk6和在jdk7及以上分别运行会得到两种不同的结果:jdk6是两个false,jdk7是true,false。

原因是:

  • jdk6中str.intern()会把首次遇到的字符串实例复制到永久代常量池中存储,返回的也是永久代里面的字符串实例的引用,而由StringBuilder创建的两个字符串的引用是在堆上,所以不可能是同一引用,结果就是两个false。
  • jdk7中str.intern()方法实现就不需要再拷贝字符串的实例到永久代了,既然字符串常量池以及移动到java堆中,那只需要在常量池中记录一下首次出现这个实例的引用即可,因此str.intern()返回的引用就是有StringBuffer创建的那个字符串对象的引用,所以返回true。而str2返回false是因为“java”这个字符串在之前已经出现过(在加载sun.misc.Version这个类的时候进入的常量池);所以str2返回的是首次出现的那个对象的引用所以是false。

再看一下方法区其他的内容,方法区主要是存放类的相关信息:如类名、访问修饰符、常量池、字段描述、方法描述等。那么对于这部分的测试,基本思想就是创建大量的类制造OOM;可以使用反射或者动态代理动态的产生大量类。所以在运行时生成大量的动态类的时候要注意这些类的回收情况。
jdk8之后这些全部移到元空间,而元空间是直接使用的本地内存,所以jdk8产生了方法区的溢出要检查元空间的大小,使用相关参数进行调整。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值