JVM之路(二)Java内存区域与内存溢出异常

一、概述

JVM 自动内存管理机制帮助开发人员管理内存,不易发生内存溢出、泄露问题,但是如果不了解 JVM 如何使用内存,那么当内存溢出、泄露问题发生时,排查错误将异常困难。
第二节学习的目的并不是对每个内存分区追根究底,而是去了解虚拟机各个内存分区的主要功能,以及里面重要的一些概念(如栈帧、局部变量表),在大脑中形成一幅图,为后续的学习打下基础。
第三节学习对象的创建过程、其在内存中的布局及访问对象的方式;
第四节则是内存区域异常的实战,模拟各内存区域的异常情况,尝试使用用以加深对内存分区的理解。

二、 运行时数据区域(分区)

JVM 根据功能不同将内存划分区域,并且各区的生命周期有差异;

2.1 程序计数器(Program Counter Register)

  1. 功能:
    当前线程所执行的字节码的行号指示器,字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令;
  2. 特性:
    线程私有,每条线程都有自己独立的程序计数器,占用较小的内存空间,是唯一一个没有规定任何OOM情况的区域;
  3. 概况:
    当线程执行 Java 方法时,计数器记录的是正在执行的虚拟机字节码指令的地址;执行 Native 方法时,计数器的值应该为空(Undefined);

2.2 虚拟机栈(VM Stack)

  1. 功能:
    描述 Java 方法执行的线程内存模型,为虚拟机执行 Java 方法字节码)服务

  2. 特性:
    线程私有,生命周期与线程相同

  3. 概况:
    a. 栈帧:
    每个方法被执行时,Java 虚拟机都会同步创建一个栈帧,每个方法被调用直到执行完毕的过程,就对应着一个栈帧在虚拟机栈中从出栈到入栈的过程;

    b. 局部变量表:
    当内存被粗糙的划分内存为“堆”和“栈”时,栈通常指的是虚拟机栈中的局部变量表,局部变量表存放内容如下:

    类型具体
    基本数据类型char,byte,short,int,long,float,double, boolean
    对象引用reference类型,可能是指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他于此对象相关的位置
    returnAddress类型指向一条字节码指令的地址

    局部变量表空间单位是“局部变量槽(Slot)”,而每个 slot 真实大小(譬如 32bit、64bit 是由虚拟机自行决定的);每个栈帧中的局部变量表所需的 slot 内存空间在编译期间已经完成分配;当进入一个方法时,这个方法需要在栈帧中分配多大的局部变量表空间是完全确定的,方法运行期间其大小不会改变

    c. 可能发生的异常
    SOF:如果线程请求的栈深度大于虚拟机所允许的深度,将会抛出 StackOverflowError 异常(局部变量表内容越多,栈帧越大,栈深度越大);
    OOM:如果JVM栈容量可以动态扩展,当栈扩展时无法申请到足够的内存就会抛出 OutOfMemory 异常;(HotSpot不支持栈容量动态扩展,只要线程在申请栈空间时成功,就不会出现 OOM,如果申请时就失败,那么仍然会 OOM)

2.3 本地方法栈(Native Method Stack)

  1. 功能:
    与虚拟机栈类似,只不过是为虚拟机使用到的本地(Native)方法服务
  2. 特性:
    JVM 规范没有做强制规定,HotSpot 则是将其与虚拟机栈合二为一了
  3. 概况:
    与虚拟机栈一样,本地方法栈也会在栈深度溢出或者栈扩展失败时分别抛出StackOverflowError和OutOfMemoryError异常

2.4 堆(Heap)

  1. 功能:
    存放对象实例

  2. 特性:
    线程共享,是 JVM 管理的内存中最大的一块儿;所有的对象实例及数组都应当在堆上分配(目前已经不那么准确了);Java 堆是GC 管理的内存区域,所以也被称为“GC堆”;

  3. 概况:
    a. GC 分代回收:
    现代 GC 大部分是基于分代收集理论设计的,但是到了今天,垃圾回收技术已经发生了相当大的变化,HotSpot也出现不采用分代设计的新 GC,所以这个说法不再准确;

    b. 堆内存分配:
    从分配内存的角度,所有线程共享的 Java 堆中可以划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer,TLAB),以提升对象分配时的效率;但无论如何划分,都不会改变 Java 堆中存储内容的共性(对象实例);

    c. 物理内存:
    堆可以处于物理上不连续的内存空间,但逻辑上应被视为连续的;但对于大对象(如数组),多数虚拟机出于实现简单、存储高效的考虑,很可能会要求连续的内存空间;堆内存可以是固定大小,现在的堆空间大都可以扩展,可以通过参数 -Xmx-Xms 设定堆大小;

    d. 可能发生的异常
    OOM:如果堆中没有内存完成实例分配,并且堆也无法扩展时,JVM 将会抛出 OOM;

2.5 方法区(Method Area)

  1. 功能:
    存储已被虚拟机加载的类(型)信息、常量、静态变量、即时编译器编译后的代码缓存等数据

  2. 特性:
    线程共享

  3. 概况:
    a. “永久代”:
    JDK7 以前的版本,GC 的分代设计被拓展到方法区(图方便),而永久代有上限更易造成内存溢出问题(-XX:MaxPermSize 设置,即使不设置也有默认大小,而如 J9 和 JRockit 只要没有触碰到进程可用内存上限,如32位系统的4GB限制,就不会出现问题),所以在 JDK7 上将字符串常量池、静态变量等从永久代移出,JDK8 则完全废除永久代的概念,采用元空间(Meta-space)来替代实现方法区;

    b. 垃圾回收:
    方法区的垃圾回收主要针对常量池的回收和对类型的卸载,回收结果一般来说比较难令人满意;甚至可以选择不实现垃圾收集;

    c. 物理内存:
    不需要连续的内存,可选择固定大小或可扩展;

    d. 可能发生的异常:
    OOM:如果方法区无法满足新的内存分配需求时(内存满了,出现内存溢出),会抛出 OOM;

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

  1. 功能:
    方法区的一部分;Class 文件常量池表(Constant Pool Table)存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后存放到运行时常量池,同时符号引用翻译出来的直接引用一般也会存储在运行时常量池(注意与字符串常量池区分);

  2. 特性:
    线程共享

  3. 概况:
    a. Class 文件内容:
    类的版本、字段、方法、接口等描述信息,常量池表;JVM 对 Class 文件的每一个字节都做了严格规定,但对于运行时常量池却没有做任何细节要求;

    b. 动态性:
    并不要求常量全部在编译器产生,运行期间也可以将新的常量放入池中;

    c. 可能发生的异常:
    OOM:作为方法区的一部分,自然受到方法区内存的限制,如果常量池无法在申请到内存时,会抛出 OOM;

2.7 直接内存(Direct Memory)

  1. 功能:
    JDK1.4 加入的 NIO(New Input/Outpu)类,引入了一种基于通到(Channel)与缓冲区(Buffer)的 I/O方式,它可以使用Native函数库直接分配堆外内存,然后通过存储在堆中的 DirectByteBuffer 对象作为这块内存(直接内存)的引用进行操作;这样能显著提升性能,避免在 Java 堆和 Native 堆中来回复制数据;

  2. 特性:
    并非 JVM 运行时数据区的一部分,但频繁使用也会出现OOM,所以一并讲解;

  3. 概况:
    a. 可能会遇到的异常:
    直接内存的分配不会收到堆大小的限制,但是会受到本机总内存(包括物理内存、SWAP分区或者分页文件)大小和处理器寻址空间的限制;一般服务器管理员配置虚拟机参数时,会根据实际内存去设置-Xmx等参数信息,但经常忽略掉直接内存,使得各个内存区域总和大于物理内存限制(包括物理的和操作系统级的限制),从而导致动态扩展时出现 OutOfMemoryError 异常。

三、HotSpot VM 对象探秘

3.1 对象的创建

3.1.1 加载步骤

这里仅对普通 Java 对象做探讨,不包括数组、Class对象等;加载过程可以分为如下的五步:

  1. 类加载检查
    当 JVM 遇到一条字节码 new 指令时,首先将去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,那必须先执行相应的类加载过程(此处暂不谈);

  2. 分配内存
    通过类加载检查后,VM 将为新生对象分配内存,对象所需的内存大小在类加载完成之后便完全确定,为对象分配空间的任务实际上便等同于把一块确定大小的内存块从 Java 堆中划分出来。

    内存分配方式有两种:
    指针碰撞:假设Java堆中内存是绝对规整的,所有被使用过的内存都被放在一边,空闲的内存被放在另一边,中间放着一个指针作为分界点的指示器,那所分配内存就仅仅是把那个指针向空闲空间方向挪动一段与对象大小相等的距离,这种分配方式称为“指针碰撞”(Bump ThePointer);
    空闲列表:但如果Java堆中的内存并不是规整的,已被使用的内存和空闲的内存相互交错在一起,那就没有办法简单地进行指针碰撞了,虚拟机就必须维护一个列表,记录上哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录,这种分配方式称为“空闲列表”(Free List);

    因此,当使用Serial、ParNew等带压缩整理过程的收集器时,系统采用的分配算法是指针碰撞,既简单又高效;而当使用CMS这种基于清除(Sweep)算法的收集器时,理论上就只能采用较为复杂的空闲列表来分配内存(为了能在多数情况下分配得更快,设计了一个叫作LinearAllocation Buffer的分配缓冲区,通过空闲列表拿到一大块分配缓冲区之后,在它里面仍然可以使用指针碰撞方式来分配)。

    内存分配的线程安全问题:
    对象创建在虚拟机中是非常频繁的行为,即使仅仅修改一个指针所指向的位置,在并发情况下也并不是线程安全的,可能出现正在给对象A分配内存,指针还没来得及修改,对象B又同时使用了原来的指针来分配内存的情况;解决这个问题有两种可选择的方案:
    一种是对分配内存空间的动作进行同步处理——实际上虚拟机是采用CAS配上失败重试的方式保证更新操作的原子;
    一种是把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在Java堆中预先分配一小块内存,称为本地线程分配缓冲(Thread Local Allocation Buffer,TLAB),哪个线程要分配内存,就在哪个线程的本地缓冲区中分配,只有本地缓冲区用完了,分配新的缓存区时才需要同步锁定;
    虚拟机是否使用TLAB,可以通过-XX:+/-UseTLAB参数来设定。

  3. 内存(对象)初始化零值
    虚拟机必须将分配到的内存空间(但不包括对象头)都初始化为零值,如果使用了TLAB的话,这一项工作也可以提前至TLAB分配时顺便进行。这步操作保证了对象的实例字段在Java代码中可以不赋初始值就直接使用,使程序能访问到这些字段的数据类型所对应的零值(这里思考 null 是不是所说的零值呢?)。

  4. 对象头设置(必要信息)

    对象头所包含的内容如下:

     + 对象是哪个类的实例
     + 如何才能找到类的元数据信息
     + 对象的哈希码(实际上对象的哈希码会延后到真正调用Object::hashCode()方法时才计算)
     + 对象的GC分代年龄等信息。
    
     同时,根据虚拟机当前运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式。关于对象头的具体内容,将在后面介绍;
    
  5. 初始化对象(构造函数)
    对象创建才刚刚开始——构造函数,即Class文件中的()方法还没有执行,所有的字段都为默认的零值,new指令之后会接着执行()方法,按照程序员的意愿对对象进行初始化,一个真正可用的对象才算完全被构造出来。

3.1.2 示例代码

在HotSpot中有两个解释器:模板解释器和C++解释器(或者叫做zero解释器、字节码解释器),默认使用的是模板解释器,将字节码翻译成汇编代码(src/hotspot/share/interpreter/templateTable.cpp),这里的示例代码是以字节码解释器为例:
在这里插入图片描述

3.2 对象的内存布局

引申两个概念: klass(类的元数据),oop(java对象)
HotSpot VM 中,对象在堆内存的存储布局可以分为 对象头、实例数据、对齐填充 三个部分,我将每一部分的大致功能用图片的形式展示出来,更为直观:

在这里插入图片描述

src/hotspot/share/oops/markOop.hpp
// Bit-format of an object header (most significant first, big endian layout below):
//
//  32 bits:
//  --------
//             hash:25 ------------>| age:4    biased_lock:1 lock:2 (normal object)
//             JavaThread*:23 epoch:2 age:4    biased_lock:1 lock:2 (biased object)
//             size:32 ------------------------------------------>| (CMS free block)
//             PromotedObject*:29 ---------->| promo_bits:3 ----->| (CMS promoted object)
//
//  64 bits:
//  --------
//  unused:25 hash:31 -->| unused:1   age:4    biased_lock:1 lock:2 (normal object)
//  JavaThread*:54 epoch:2 unused:1   age:4    biased_lock:1 lock:2 (biased object)
//  PromotedObject*:61 --------------------->| promo_bits:3 ----->| (CMS promoted object)
//  size:64 ----------------------------------------------------->| (CMS free block)
//
//  unused:25 hash:31 -->| cms_free:1 age:4    biased_lock:1 lock:2 (COOPs && normal object)
//  JavaThread*:54 epoch:2 cms_free:1 age:4    biased_lock:1 lock:2 (COOPs && biased object)
//  narrowOop:32 unused:24 cms_free:1 unused:4 promo_bits:3 ----->| (COOPs && CMS promoted object)
//  unused:21 size:35 -->| cms_free:1 unused:7 ------------------>| (COOPs && CMS free block)
//
//  - hash contains the identity hash value: largest value is
//    31 bits, see os::random().  Also, 64-bit vm's require
//    a hash value no bigger than 32 bits because they will not
//    properly generate a mask larger than that: see library_call.cpp
//    and c1_CodePatterns_sparc.cpp.

实例数据这部分稍作补充:
这部分的存储顺序会受到虚拟机分配策略参数(-XX:FieldsAllocationStyle参数)和字段在Java源码中定义顺序的影响。HotSpot虚拟机默认的分配顺序为longs/doubles、ints、shorts/chars、bytes/booleans、oops(Ordinary Object Pointers,OOPs),从以上默认的分配策略中可以看到,相同宽度的字段总是被分配到一起存放,在满足这个前提条件的情况下,在父类中定义的变量会出现在子类之前。如果HotSpot虚拟机的+XX:CompactFields参数值为true(默认就为true),那子类之中较窄的变量也允许插入父类变量的空隙之中(todo:这里还不太理解,等后续深入了再回来填坑),以节省出一点点空间。

3.3 对象的访问定位

Java程序会通过栈上的reference数据来操作堆上的具体对象,而对象访问方式(reference数据)也由虚拟机实现而定的,主流的访问方式主要有使用句柄和直接指针两种:

  • 句柄访问:
    Java堆中将可能会划分出一块内存来作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自具体的地址信息;
    优点:使用句柄来访问的最大好处就是reference中存储的是稳定句柄地址,在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中的实例数据指针,而reference本身不需要被修改。

在这里插入图片描述

  • 直接内存访问:
    如果使用直接指针访问的话,Java堆中对象的内存布局就必须考虑如何放置访问类型数据的相关信息,reference中存储的直接就是对象地址,如果只是访问对象本身的话,就不需要多一次间接访问的开销;
    优点:使用直接指针来访问最大的好处就是速度更快,它节省了一次指针定位的时间开销,由于对象访问在Java中非常频繁,因此这类开销积少成多也是一项极为可观的执行成本;对于HotSpot而言,它主要使用第二种方式进行对象访问(有例外情况,如果使用了Shenandoah收集器的话也会有一次额外的转发,具体可参见第3章),但从整个软件开发的范围来看,在各种语言、框架中使用句柄来访问的情况也十分常见。

在这里插入图片描述

四、内存溢出(OOM)+ 栈溢出(SOF) 实战

内存溢出(OutOfMemory):内存不够了,供小于需;
内存溢出(Memory Leak):内存不释放造成系统变慢、崩溃;
栈溢出(StackOverflow):请求的栈深超出了线程栈大小时报错,常见递归操作;

搞清楚这几个概念后,接下来就实战不同内存分区出现的内存异常;前面在介绍内存分区时,介绍过程序计数器是唯一不会发生内存溢出的区域,其他分区都有发生异常的可能。同时内存溢出与虚拟机本身实现方式密切系相关,并非约定的公共行为,所以不同虚拟机、不同版本情况下可能会有差异;

  • 工欲善其事,必先利其器,分析 heap_dump 采用的分析工具是 Eclipse Memory Analyzer(MAT),解压缩即可使用,需要注意 MemoryAnalyzer.ini 文件中的 -Xmx 内存大小一般为1024M,而实际使用时 heap_dump 文件一般都远大于此,我通常修改为 8192M;
  • 在实验时,需要加入特定的 VM 参数,会在每节里单独声明,同时我以 IDEA 为例讲参数添加方法附上:
    在这里插入图片描述

4.1 堆溢出

堆用来存放对象实例,那如果存放的实例总容量触及最大堆内存,那么就会报错OOM:
堆内存设置为20M,-XX:+HeapDumpOnOutOfMemoryError 参数可以让虚拟机在出现内存溢出异常的时候Dump出当前的堆内存堆转储快照以便进行事后分析(使用MAT);

4.1.1 模拟程序

VM Args:-Xms20m -Xmx20m -XX:+HeapDumpOnOutOfMemoryError

public class HeapOOM {
    static class OOMObject {
    }

    public static void main(String[] args) {
        List<OOMObject> list = new ArrayList<>();
        int i = 0;
        //注意捕获卸载循环外,避免出现异常后依然不断执行报错
        try {
            while (true) {
                i++;
                //不停创建新的对象放入集合中
                list.add(new OOMObject());
            }
        } catch (Throwable e) {
            System.out.println(e);
            System.out.println("创建了" + i + "次对象");
        }
    }
}

在这里插入图片描述

4.1.2 heap_dump 分析

首先根据上面的异常日志,可以发现是 OOM_JavaHeapSpace 出现问题,共计创建了810326次对象实例将我们预先设置的堆空间占满;

出现异常后,在项目根目录会产生预期的 java_pid5048.hprof heap_dump文件,使用 MAT 分析查看Details:

在这里插入图片描述
然后查看异常对象细节(也可以直接点击overview页面中的 Dominator Tree),可以发现异常问题是由com.coderwhat.HeapOOM对象实例引起的,实例个数与日志打印中也对得上
在这里插入图片描述
在分析之前,有一件很重要的事,就是区分发生的问题到底是内存溢出还是内存泄漏;在上面我一件对两个概念的区别做了解释,那么实际上应该怎么看呢:我们要去判断占用内存空间的对象是否是必须保留的,例如上面的 HeapOOM,如果这是一个因为代码错误导致的无用对象,那就是非必要未被及时清理的,这就是内存泄漏(Memory Leak);如果 HeapOOM 是有用的,并且是因为堆空间被占满导致无法再创建对象实例报错的,那么就是内存溢出(OutOfMemory);

4.1.2.1 内存泄漏处理方式

如果是内存泄漏,那么就要搞清楚为什么对象没有被及时释放回收,在可达性回收算法中,是通过 GC Roots 来判断对象是否可以回收的,如果对象存在 GC Roots 引用链,那么对象就无法释放掉,MAT 工具支持回溯 GC Roots 引用链(是 Dominator Tree 中找到的对象才能回溯到对象,Histogram回溯只能到线程级别(个人发现,不知道是否准确)):
在这里插入图片描述
这里可以看到,是由于集合的引用,导致对象无法释放(要是能释放我们也验不到异常。。)
在这里插入图片描述

4.1.2.2 内存溢出处理方式

如果是内存溢出,那么说明对象都是有必要存活的,此时就应该着手考虑如何优化使得该对象能保持存活,例如:

  1. 检查堆内存是否设置合理,可否向上调整;
  2. 检查其他对象声明周期过长、持有状态时间过长、存储结构设计不合理,减少内存占用消耗;

4.2 虚拟方法栈、本地方法栈栈溢出、内存溢出

栈是负责方法的执行的(方法执行对应栈帧的入栈到出栈),除了内存外,栈还有独特的栈深,所以其溢出情况有两种:

  1. 线程请求新栈帧超过最大栈深度,出现栈溢出(StackOverflow);
  2. 虚拟机栈如果支持动态扩展,扩展时内存不足将出现内存溢出(OutOfMermory);而如HotSpot不支持动态扩展,除非在创建线程时申请内存是就报错OOM;

HotSpot VM 不区分虚拟机栈和本地方法栈,所以 -Xoss 参数(设置本地方法栈大小)对其并没有实际意义,单线程栈容量由 -Xss参数来设定;

4.2.1 模拟SOF

书中是采用两个实验来模拟SOF,第一个是通过减少栈内存大小,递归执行方法达到最大栈深使新请求栈帧报错SOF,第二个则是不减小栈内存,通过创建大量局部变量来增大栈帧中的局部变量表(每栈帧变大),达到最大栈深使新请求栈帧报错SOF;具体的代码及结果截图将在下面附上:
第一种方法:
VM Args:-Xss128k:占内存修改为128k(64bit win11系统最小为108k)

public class JVMStackSOF {
    private int stackLength = 1;

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

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

在这里插入图片描述
第二种方法:

public class JVMStackSOF2 {
    private static int stackLength = 0;

    public static void test() {
        long unused1, unused2, unused3, unused4, unused5, unused6, unused7, unused8, unused9, unused10,
                unused11, unused12, unused13, unused14, unused15, unused16, unused17, unused18, unused19,
                unused20, unused21, unused22, unused23, unused24, unused25, unused26, unused27, unused28,
                unused29, unused30, unused31, unused32, unused33, unused34, unused35, unused36, unused37,
                unused38, unused39, unused40, unused41, unused42, unused43, unused44, unused45, unused46,
                unused47, unused48, unused49, unused50, unused51, unused52, unused53, unused54, unused55,
                unused56, unused57, unused58, unused59, unused60, unused61, unused62, unused63, unused64,
                unused65, unused66, unused67, unused68, unused69, unused70, unused71, unused72, unused73,
                unused74, unused75, unused76, unused77, unused78, unused79, unused80, unused81, unused82,
                unused83, unused84, unused85, unused86, unused87, unused88, unused89, unused90, unused91,
                unused92, unused93, unused94, unused95, unused96, unused97, unused98, unused99, unused100;

        stackLength++;
        test();

        unused1 = unused2 = unused3 = unused4 = unused5 = unused6 = unused7 = unused8 = unused9 = unused10
                = unused11 = unused12 = unused13 = unused14 = unused15 = unused16 = unused17 = unused18
                = unused19 = unused20 = unused21 = unused22 = unused23 = unused24 = unused25 = unused26
                = unused27 = unused28 = unused29 = unused30 = unused31 = unused32 = unused33 = unused34
                = unused35 = unused36 = unused37 = unused38 = unused39 = unused40 = unused41 = unused42
                = unused43 = unused44 = unused45 = unused46 = unused47 = unused48 = unused49 = unused50
                = unused51 = unused52 = unused53 = unused54 = unused55 = unused56 = unused57 = unused58
                = unused59 = unused60 = unused61 = unused62 = unused63 = unused64 = unused65 = unused66
                = unused67 = unused68 = unused69 = unused70 = unused71 = unused72 = unused73 = unused74
                = unused75 = unused76 = unused77 = unused78 = unused79 = unused80 = unused81 = unused82
                = unused83 = unused84 = unused85 = unused86 = unused87 = unused88 = unused89 = unused90
                = unused91 = unused92 = unused93 = unused94 = unused95 = unused96 = unused97 = unused98
                = unused99 = unused100 = 0;
    }

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

在这里插入图片描述

4.2.2 PS:windows系统栈内存如何查看

这里要提一点有意思的是:我想到第二种方法中如果不限制栈内存大小,仅仅通过增大局部变量表的方式,怎么能确保栈容量一定会占满呢?而不是将win系统内存全部用完引起windows系统崩溃,首先我尝试最常用的方法:通过百度到的如下命令来获取栈内存大小:(windows系统命令行可以使用:java -XX:+PrintFlagsFinal -version > jvm_args.txt 放到文件中查看,-version只是少打一些不必要信息)

root@uos-PC:~# java -XX:+PrintFlagsFinal -version | grep ThreadStack
     intx CompilerThreadStackSize                   = 0                                   {pd product}
     intx ThreadStackSize                           = 1024                                {pd product}
     intx VMThreadStackSize                         = 1024                                {pd product}
openjdk version "1.8.0_292"
OpenJDK Runtime Environment (Loongson 8.1.8-loongarch64-Loongnix) (build 1.8.0_292-b10)
OpenJDK 64-Bit Server VM (build 25.292-b10, mixed mode)

意外的是windows系统上获取到的值都是 0,当然不可能真真为 0,只是这个 0 到底代表什么呢?最后在 “Stack Overflow” 上找到了答案:

  1. 十年老哥,这个为什么叫十年老哥了,因为他的问题整整经历了十年(比我从业还长。。),虽然最终没有找到答案,但是也给出了一个思路:与系统本身虚拟内存有关、从源码中去寻找找答案;
  2. Final Answer,这里正是从JDK源码中剖析了,0所代表的的是使用系统默认栈大小,同时也提供了一种新的查看栈内存大小的方法如下,从中我们可以看到栈内存总共33792KB,线程34个,两者相除即可得到每个栈内存大小
root@uos-PC:~#java -XX:+UnlockDiagnosticVMOptions -XX:NativeMemoryTracking=summary -XX:+PrintNMTStatistics -version
-                    Thread (reserved=33930KB, committed=33930KB)
                            (thread #34)
                            (stack: reserved=33792KB, committed=33792KB)
                            (malloc=99KB #180)
                            (arena=39KB #54)

4.2.3 模拟OOM

对于hotspot而言,不存在栈动态扩展,所以采用不断创建线程的方式,并将单个栈容量增大,使系统内存迅速被沾满,新创建的线程无法申请到内存而出现OOM;(这里我就不尝试了,32位系统有系统内存大小限制)

  • 操作系统限制内存减去最大堆容量,再减去最大方法区容量,由于程序计数器消耗内存很小,可以忽略掉,如果把直接内存和虚拟机进程本身耗费的内存也去掉的话,剩下的内存就由虚拟机栈和本地方法栈来分配了。因此为每个线程分配到的栈内存越大,可以建立的线程数量自然就越少,建立线程时就越容易把剩下的内存耗尽

在这里插入图片描述
其运行结果如下:

Exception in thread "main" java.lang.OutOfMemoryError: unable to create native thread
JDK 7起,以上提示信息中“unable to create native thread”后面,虚拟机会特别注明原因可能是“possibly out of memory or process/resource limits reached”

建立过多线程导致的内存溢出的处理方式
在不能减少线程数量或者更换64位虚拟机的情况下,就只能通过减少最大堆和减少栈容量来换取更多的线程。这种通过“减少内存”的手段来解决内存溢出的方式,如果没有这方面处理经验,一般比较难以想到,这一点读者需要在开发32位系统的多线程应用时注意;

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

4.3.1 字符串常量池OOM

jdk7+ 版本字符串常量池已经被移到堆中,jdk8+ 不再有永久代的概念,取而代之的是元空间,所以限制永久代大小的 -XX:PermSize=6M-XX:MaxPermSize=6M也已经不再适用了,这里采用限制堆内存大小的方式,不断的创建字符串并保持引用,直至堆内存占满,常量池动态扩展失败出现OOM;

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

VM Args:-Xmx6M

public class RuntimeConstantPoolOOM {
    public static void main(String[] args) {
        Set<String> s = new HashSet<>();
        short i = 0;

        while (true){
            s.add(String.valueOf(i++).intern());
        }
    }
}

在这里插入图片描述

4.3.2 String.inter()返回有趣实验

一下注释为jdk8中的结果,思考一下为什么?

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

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

第一个:常量池中没有“计算机软件”这个字符串,所以 s.intern() 返回的引用就是s自身;
第二个:“java”这个字符串是本身存在于常量池的,其 String对象的引用与 s1不是同一个;

4.3.3 方法区OOM

方法区主要存放类信息,如果想让方法区OOM,直接的方法就是产生大量的类填满方法区,直至发生OOM;

  • 虽然直接使用Java SE API也可以动态产生类(如反射时的GeneratedConstructorAccessor和动态代理等),但在本次实验中操作起来比较麻烦。在代码清单2-8里笔者借助了CGLib直接操作字节码运行时生成了大量的动态类。

这个例子中模拟的场景并非纯粹是一个实验,类似这样的代码确实可能会出现在实际应用中:当前的很多主流框架,如Spring、Hibernate对类进行增强时,都会使用到CGLib这类字节码技术,当增强的类越多,就需要越大的方法区以保证动态生成的新类型可以载入内存,很多运行于Java虚拟机上的动态语言(例如Groovy等)通常都会持续创建新类型来支撑语言的动态性,随着这类动态语言的流行,与下方示例相似的溢出场景也越来越容易遇到。

VM Args:-XX:PermSize=10M -XX:MaxPermSize=10M:修改永久代方法区大小,jdk8已结不适用,会出现如下报错

Java HotSpot(TM) 64-Bit Server VM warning: ignoring option PermSize=10M; support was removed in 8.0
Java HotSpot(TM) 64-Bit Server VM warning: ignoring option MaxPermSize=10M; support was removed in 8.0
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 o, Method method, Object[] objects, MethodProxy methodProxy) throws Throwable {
                    return methodProxy.invokeSuper(o, objects);
                }
            });
            enhancer.create();
        }
    }

    static class OOMObject {
    }
}

在这里插入图片描述
方法区溢出也是一种常见的内存溢出异常,一个类如果要被垃圾收集器回收,要达成的条件是比较苛刻的。在经常运行时生成大量动态类的应用场景里,就应该特别关注这些类的回收状况。这类场景除了之前提到的程序使用了CGLib字节码增强和动态语言外,常见的还有:大量JSP或动态产生JSP文件的应用(JSP第一次运行时需要编译为Java类)、基于OSGi的应用(即使是同一个类文件,被不同的加载器加载也会视为不同的类)等

jdk8后元空间替换了永久带,很难再出现上方测试用例中的溢出异常了,但为了以防万一,还是有参数对与元空间进行设置:
-XX:MaxMetaspaceSize:设置元空间最大值,默认是-1,即不限制,或者说只受限于本地内存大小;
-XX:MetaspaceSize:指定元空间的初始空间大小,以字节为单位,达到该值就会触发垃圾收集进行类型卸载,同时收集器会对该值进行调整:如果释放了大量的空间,就适当降低该值;如果释放了很少的空间,那么在不超过-XX:MaxMetaspaceSize(如果设置了的话)的情况下,适当提高该值;
-XX:MinMetaspaceFreeRatio:作用是在垃圾收集之后控制最小的元空间剩余容量的百分比,可减少因为元空间不足导致的垃圾收集的频率。类似的还有-XX:Max-MetaspaceFreeRatio,用于控制最大的元空间剩余容量的百分比。

4.4 本机直接内存溢出

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

代码越过了DirectByteBuffer类直接通过反射获取Unsafe实例进行内存分配(Unsafe类的getUnsafe()方法指定只有引导类加载器才会返回实例,体现了设计者希望只有虚拟机标准类库里面的类才能使用Unsafe的功能,在JDK 10时才将Unsafe的部分功能通过VarHandle开放给外部使用),因为虽然使用DirectByteBuffer分配内存也会抛出内存溢出异常,但它抛出异常时并没有真正向操作系统申请分配内存,而是通过计算得知内存无法分配就会在代码里手动抛出溢出异常,真正申请分配内存的方法是Unsafe::allocateMemory()。

VM args:-Xmx20M -XX:MaxDirectMemorySize=10M:堆内存指定为20M,直接内存指定为10M

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

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

在这里插入图片描述
mark:
由直接内存导致的内存溢出,一个明显的特征是在Heap Dump文件中不会看见有什么明显的异常情况,如果读者发现内存溢出之后产生的Dump文件很小,而程序中又直接或间接使用了DirectMemory(典型的间接使用就是NIO),那就可以考虑重点检查一下直接内存方面的原因了。
(+HeapDumpOnOutOfMemoryError 这里想生成heap_dump,不知道为什么加上参数后乱码报错)
在这里插入图片描述

最后,希望你学习是因为兴趣,而不是压力 +v+

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

mitays

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值