Java和C++之间因内存动态分配和垃圾收集技术的区别而不同

概述

  • C和C++拥有每一个对象的“所有权”,担负着对每一个对象寿命从开始到终结的维护责任
  • Java的虚拟机可以自动管理内存,不需要为每一个new操作去写配对的delete/free代码,不容易出现内存泄漏和溢出问题,但一旦出现内存泄漏、溢出问题,如果不了解虚拟机是怎样使用内存的,那么排查与修正非常困难

运行时数据区域

根据《Java虚拟机规范》的规定,Java虚拟机运行时数据区域如下所示
image.png

程序计数器

一块较小的内存空间,可以看作是当前线程所执行的字节码的行号指示器。
每条线程都需要由一个独立的程序计数器,各条线程之间计数器互不影响,独立存储,这类区域为“线程私有”的内存

Java虚拟机栈

线程私有的,声明周期与线程相同。每个方法被执行的时候,Java虚拟机都会同步创建一个栈帧用于存储局部变量表、操作数栈、动态连接、方法出口等信息。每一个方法被调用支支执行完毕的过程就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程

局部变量表

“栈”通常就是指这里讲的虚拟机栈,或者更多情况下只是指虚拟机栈中局部变量表部分
局部变量表存放了编译期可知的各种Java虚拟机基本数据类型(boolean、byte、short、int、float、long、double)、对象引用(reference类型)和returnAddress类型(指向了一条字节码指令的地址)

栈异常:StackOverflowError、OutOfMemoryError

  • StackOverflowError:线程请求的栈深度大于虚拟机所允许的深度
  • OutOfMemoryError:栈扩展时无法申请到足够的内存

本地方法栈

与虚拟机栈作用相似,只是本地方法栈是为虚拟机使用到的本地(Native)方法服务,虚拟机栈是为虚拟机执行Java方法服务

Java堆

虚拟机所管理的内存中最大的一块,Java堆是被所有线程共享的一块内存区域,在虚拟机启动时创建
此区域的唯一目的就是存放对象实例,几乎所有的对象实例都在这里分配
Java堆是垃圾收集器管理的内存区域,因此也称为“GC堆”

回收内存的角度

垃圾回收器大部分都是基于分代收集理论设计,所以Java堆中经常会有新生代、老年代、永久代、伊甸园、幸存区等,这些知识一部分垃圾收集器的共同特性或者说设计风格而已,而非某个Java虚拟机具体实现的固有内存布局,更不是Java虚拟机规范的划分
HotSpot也出现了不采用分代设计的新垃圾收集器

内存分配角度看

  • 所有线程共享的Java堆中可以划分出多个线程私有的分配缓冲区,以提升对象分配时的效率。无论如何划分都不会改变Java堆内存中存储内容的共性,存储的都只能是对象的实例
  • Java堆可以处于物理上不连续的内存空间,但在逻辑上他应该被视为连续的
    对于大对象(数组等),多数虚拟机实现处于简单、存储高效的考虑,很可能会要求连续的内存空间

堆扩展及异常:OutOfMemory

主流的Java虚拟机都是按照可扩展实现的(-Xmx和-Xms设定)。如果Java堆没有可用内存完成实例分配,且堆也无法再扩展时,Java虚拟机会抛出OutOfMemory异常

方法区

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

  • 如何实现方法区属于虚拟机实现细节,不受Java虚拟机规范灌输,并不要求统一
  • 除了和Java堆一样不需要连续的内存和可以选择固定大小或者可扩展外,甚至还可以选择不实现垃圾收集,所以有“永久代”的说法
  • 如果方法去无法满足新的内存分配需求时,将抛出OutOfMemoryError异常

运行时常量池

运行时常量池(Runtime Constant Pool)为方法区的一部分,用于存放编译期生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中

  • 对于运行时常量池,Java虚拟机规范并没有做任何细节的要求,一般除了保存Class文件中秒数的符号引用外,还会把由符号引用翻译出来的直接引用也存储再运行时常量池中
  • 运行期间也可以将新的常量放入常量池中,用的比较多的就是String的intern()方法
  • 当常量池无法再申请到内存时会抛出OutOfMemoryError异常

直接内存(Direct Memory)

并不是虚拟机运行时数据区的一部分,也不是Java虚拟机规范中定义的内存区域,但会被频繁使用,也可能导致OutOfMemoryError

  • JDK1.4中新增了NIO类,引入了一种基于通道(Channel)与缓冲区(Buffer)的I/O方式,可以使用Native函数库直接分配堆外内存,避免在Java堆和Native堆中来回复制数据
  • 本机直接内存的分配不会收到Java堆大小的限制,但是受到本机总内存限制,如果忽略直接内存,使得各个内存区域总和大于物理内存限制,从而导致动态扩展时出现OutOfMemoryError异常。

HotSpot虚拟机对象探秘

对于涉及细节的问题,必须把讨论范围限定在具体的虚拟机和集中在某一个内存区域上才有意义

对象的创建

Java程序运行过程中无时无刻都有对象被创建出来

  • java虚拟机遇到一条字节码new指定时,先去检查这个指令的参数是否能在常量池中定位到一个类的引用符号,并且检查这个符号引用代表的类是否已被加载、解析和初始化过
  • 在类加载检查通过后,接下来虚拟机将为新生对象分配内存
  • 内存分配后,虚拟机将分配到的内存空间都初始化为零值,使程序能访问到这些字段的数据类型所对应的零值
  • 对象创建开始构造函数

对象的内存布局

在HotSpot虚拟机中,对象在堆内存中的布局可以划分为三个部分:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)

对象头

划分为2部分

  • 第一部分用于存储对象自身的运行时数据
  • 第二部分是类型指针,即对象指向它的类型元数据的指针,Java虚拟机通过这个指针来确定该对象是哪个类的实例

实例数据

实例数据部分是对象真正存储的有效信息,即程序代码里面所定义的各种类型的字段内容,无论是父类继承下来的,还是在子类中定义的字段都必须记录起来。收到虚拟机分配策略参数(-XX:FieldsAllocationStyle参数)和字段在Java源码中定义顺序的影响

对齐填充

不是必然存在的,也没有特别的含义,仅仅起着占位符的作用,HotSpot虚拟机自动内存管理系统要求对象起始地址必须为8字节整数倍,如果没有就通过对齐填充来补全

对象的访问定位

为了使用创建好的对象,Java程序会通过栈上的reference数据来操作堆上的具体对象,具体方式由虚拟机实现而定,主流有2点

句柄访问

image.png
Java堆中将可能会划分出一块内存来作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了对象实例与类型数据各自具体的地址信息

  • 特点:
    1.在对象被移动(垃圾回收时移动)时只会改变举兵中的实例数据指针,reference本身不需要修改

直接指针(主要使用)

image.png
HotSpot虚拟机主要使用该方式
Java堆中对象的内存布局就必须考虑如何放置访问类型数据的相关信息,reference中存储的直接就是对象地址

  • 特点:
    访问速度更快

实战:OutOfMemoryError异常

除程序计数器外,虚拟机内存的其他几个运行时区域都有发生OutOfMemoryError(OOM)异常的可能

  • 目的:
    1.验证运行时区域存储内容
    2.遇到异常时,能根据异常得出哪个区域的内存移除,即什么样的代码可能会出现这些异常

idea设置JVM参数

image.png
image.png
image.png

Java堆溢出

Java堆用于存储对象实例,只要不断地创建实例,并且保证GB Roots到对象之间有可达路径来避免垃圾回收机制清除这些对象,那么随着对象数量的增加,总容量触及最大堆的容量限制后就会产生内存溢出异常

  • VM参数
    VM Args: -Xms20m -Xmx20m -XX:+HeapDumpOnOutOfMemoryError

  • -Xms20m -Xmx20m:即堆内存的最大值和最小值相同,不可自动扩展,

  • -XX:+HeapDumpOnOutOfMemoryError:让虚拟机在出现内存溢出出现异常的时候Dump出当前的内存堆转储快照以便事后分析

  • 代码如下:

/**
 * @Author jtao
 * @Date 2021/1/18 23:24
 * @Description Java堆内存溢出异常测试
 *      VM Args: -Xms20m -Xmx20m -XX:+HeapDumpOnOutOfMemoryError
 *      -Xms20m -Xmx20m:即堆内存的最大值和最小值相同,不可自动扩展,
 *      -XX:+HeapDumpOnOutOfMemoryError:让虚拟机在出现内存溢出出现异常的时候Dump出当前的内存堆转储快照以便事后分析
 */

public class HeapOOM {
    static class OOMObject {
    }
    public static void main(String[] args) {
        List<OOMObject> list = new ArrayList<OOMObject>();
        while (true) {
            list.add(new OOMObject());
        }
    }
}

image.png

对快照进行分析

确认内存中导致OOM的对象是否是必要的,即区分到底是出现了内存泄漏(Memory Leak)还是内存溢出(Memory Overflow)
image
image
image

内存泄漏

通过工具查看泄漏对象到GC Roots的引用链,找到对象是通过怎样的引用路径、与哪些GC相关,才导致垃圾回收器无法回收,找到对象创建的位置,进而找出产生内存泄漏的代码具体位置

非内存泄漏

如果不是内存泄漏,即内存中的对象都是存活的,就应用检查虚拟机的堆参数设置,与机器的内存对比,看看是否还有向上调整的空间。再从代码上检查是否存在某些对象生命周期过长、持有状态时间过长、存储结构涉及不合理等情况

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

HotSpot虚拟机不区分虚拟机栈和本地方法栈,因此-Xoss参数对于HotSpot没有效果,栈容量只能由-Xss参数来设定

StackOverflowError

线程请求的栈深度大于虚拟机所允许的最大深度时

OutOfMemoryError

如果虚拟机栈内存允许动态扩展,当栈无法申请到足够内存时

HotSpot不支持栈动态扩展,除非在创建线程时就无法获得足够内存出现OutOfMemoryError,否在在运行时只会因为栈容量无法容纳新的栈帧而导致StackOverflowError

栈内存和本地方法栈测试

/**
 * @Author jtao
 * @Date 2021/1/19 21:12
 * @Description 虚拟机栈和本地方法栈测试
 *      VM Args:-Xss128K
 *      减少栈内存容量
 */

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

image

/**
 * @Author jtao
 * @Date 2021/1/19 21:30
 * @Description 虚拟机和本地方法栈测试
 */

public class JavaVMStackSOF1 {
    private static int stackLength = 0;
    public static void test() {
        long unused1, unused2, unused3, unused4, unused5, unused6, unused7, unused8, unused9, unused10;
        stackLength++;
        test();
        unused1 = unused2 = unused3 = unused4 = unused5 = unused6 = unused7 = unused8 = unused9 = unused10 = 0;
    }
    public static void main(String[] args) {
        try {
            test();
        } catch (Error e) {
            System.out.println("stack length:"+ stackLength);
            throw e;
        }
    }
}

image

总结:无论由于栈帧和虚拟机栈大小,当新的栈帧内存无法分配的时候,HotSpot虚拟机抛出的都是StackOverflowError异常,如果允许动态扩展则不一样

线程创建导致内存溢出异常

每个线程分配到的栈内存越大,可以建立的线程数量越少,越容易把剩下的内存耗尽

/**
 * @Author jtao
 * @Date 2021/1/19 21:47
 * @Description 创建线程导致内存溢出异常
 *              VM Args:-Xss2M
 *
 */

public class JavaVMStackOOM {
    private void dontStop() {
        while (true) {
        }
    }
    public void stackLeakByThread() {
        while (true) {
            Thread thread = new Thread(new Runnable() {
                public void run() {
                    dontStop();
                }
            });
            thread.start();
        }
    }
    public static void main(String[] args) {
        JavaVMStackOOM oom = new JavaVMStackOOM();
        oom.stackLeakByThread();
    }
}

上面神奇的代码,必须要虚拟机参数,且确保保存了需要的工作文件,线程N多,则CPU被过度使用,电脑会宕机,我当时就傻了😀
image
HotSpot虚拟机默认情况下,栈深度可达1000~2000,对正常方法调用(包括不能做尾递归优化的递归调用),这个深度完全够用

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

运行时常量池是方法区的一部分,所以溢出测试可以放到一起进行
JDK6或更早之前的HotSpot虚拟机中,常量是分配在永久代中,可以通过-XX:PermSize和-XX:MaxPermSize限制永久带的大小,即可间接限制常量池容量

/**
 * @Author jtao
 * @Date 2021/1/19 22:46
 * @Description 运行时常量池导致的内存溢出异常
 *      需要JDK6及以下  VM Args:-XX:PermSize=6M -XX:MaxPermSize=6M
 *      需要JDK7及以上  VM Args:-Xmx6m
 */

public class RuntimeConstantPoolOOM {
    public static void main(String[] args) {
        //使用Set保持者常量池引用,避免Full GC回收常量池行为
        HashSet<String> set = new HashSet<String>();
        //在short范围内足以让6MB的PermSize产生OOM了
        short i=0;
        while (true){
            //String::intern是本地方法
            set.add(String.valueOf(i++).intern());
        }
    }
}

image.png

  • 在JDK7及以上时设置-Xmx6m限制最大堆到6M就会出现以下情况
    image.png

元空间

在JDK8以后,永久带被元空间代替,HotSpot提供了一些参数作为元空间的防御措施

  • -XX:MaxMetaspaceSize
    设置元空间最大值,默认-1,不限制,只受限于本地内存大小
  • -XX:MetaspaceSize
    指定元空间的初始大小,达到该值就会触发垃圾收集进行类型卸载,同时收集器会对该值进行调整
  • -XX:MinMetaspaceFreeRatio
    在垃圾收集之后控制最小的元空间剩余容量的百分比,可减少因为元空间不足导致的垃圾收集的频率

本机直接内存溢出

直接内存的容量大小可通过-XX:MaxDirectMemorySize参数来指定,如果不指定,则默认与Java堆最大值(-Xmx)一致

/**
 * @Author jtao
 * @Date 2021/1/19 23:16
 * @Description 使用unsafe分配本机内存
 * VM Args:-Xmx20M -XX:MaxDirectMemorySize=10M
 */

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

    public static void main(String[] args) throws Exception {
        Field unsafeField = Unsafe.class.getDeclaredFields()[0];
        unsafeField.setAccessible(true);
        Unsafe unsafe = (Unsafe) unsafeField.get(null);
        while (true) {
            unsafe.allocateMemory(_1MB);
        }
    }
}

image.png

由直接内存导致的内存溢出最明显特征为Heap Dump文件不会有明显异常。如果Dump文件很小,直接或间接使用了DirectMemory(如NIO),就可以重点检查下直接内存方面原因


这个家伙很懒,啥也没有留下😋