《深入理解Java虚拟机》第2章:Java内存区域与内存溢出异常

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

2.2 运行时数据区域

根据《Java虚拟机规范》的规定,Java虚拟机所管理的内存将会包括以下几个运行时数据区域

在这里插入图片描述

  • 1. 程序计数器

    程序计数器(Program Counter Register)是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器
    字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,是流程控制实现的基础

    Java中的多线程是通过线程轮流切换、分配处理器执行时间的方式来实现的,程序计数器是线程私有的

    如果线程正在执行的是一个Java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址
    如果正在执行的是本地(Native)方法,这个计数器值则应为空(Undefined)
    此内存区域是唯一一个在《Java虚拟机规范》中没有规定任何OutOfMemoryError情况的区域。

  • 2. Java虚拟机栈

    线程私有,生命周期与线程相同

    描述的是Java方法执行的线程内存模型:
    每个方法被执行的时候,Java虚拟机都会同步创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态连接、方法出口等信息。每一个方法被调用直至执行完毕的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程

    说到Java虚拟机栈,大多关心的是局部变量表;局部变量表存放了编译期可知的(1)8种Java虚拟机基本数据类型、(2)各种对象引用(reference类型,并非对象本身,而是对象变量/引用)、(3)和returnAddress类型(指向了一条字节码指令的地址)

    局部变量表中的存储空间以局部变量槽(Slot)来表示,其中64位长度的long和double类型的数据会占用两个变量槽,其余的数据类型只占用一个

    局部变量表所需的内存空间在编译期间完成分配,注意:在方法运行期间,一个栈帧对应的局部变量空间是完全确定的,即局部变量槽的个数是固定的,至于Slot是64bit还是32bit则由虚拟机规定

    Java虚拟机栈这个内存区域有两个异常,分别是

    • StackOverflowError(栈溢出):线程请求的栈深度(栈帧的个数)大于虚拟机所允许的深度
    • OutOfMemoryError(内存溢出):当栈扩展时无法申请到足够的内存
  • 3. 本地方法栈

    和Java虚拟机栈相似,只是虚拟机栈用来处理java方法,而本地方法栈则是为本地方法(Native Method)服务
    这个栈相对不受重视,有的虚拟机如HotSpot直接将它和虚拟机栈合二为一
    同样有StackOverflowError和OutOfMemoryError两种异常

  • 4. Java堆

    所管理的内存中最大的一块;所有线程共享;虚拟机启动时创建

    此内存区域的唯一目的就是存放对象实例,所有的对象实例以及数组都应当在堆上分配(由于优化,目前有些微变化)

    Java堆是垃圾收集器管理的内存区域,也被称为“GC堆”;
    注意:Java堆在一些地方会进一步细分为新生代等等,但这些都并非是标准规定的,而是多数存在的共性

    Java堆可以处于物理上不连续的内存空间中,但在逻辑上它应该被视为连续的
    对于大对象(典型的如数组对象),出于简单高效的目的,多数虚拟机会要求连续的内存空间

    Java堆既可以被实现成固定大小的,也可以是可扩展的,不过当前主流的Java虚拟机都是按照可扩展来实现的

    • OutOfMemoryError异常:堆中没有内存完成实例分配,并且堆也无法再扩展时抛出
  • 5. 方法区

    所有线程共享

    用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据

    约束宽松:不需要连续的内存;可以选择固定大小或者可扩展;甚至可以选择不实现垃圾收集
    这区域的内存回收目标主要是针对常量池的回收和对类型的卸载,难以达到好的回收效果,特别是类型卸载困难。HotSpot曾经因为这部分未能完全回收而出现内存泄漏,导致若干bug

    • OutOfMemoryError异常:当方法区无法满足新的内存分配需求抛出
  • 6. 方法区之运行时常量池

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

    一般来说,除了保存Class文件中描述的符号引用外,还会把由符号引用翻译出来的直接引用也存储在运行时常量池中

    当程序运行到某个类时,class文件中的信息就会被解析到内存的方法区里的运行时常量池中。每个类都有一个运行时常量池;此外还有一个字符串常量池

    概念解析:参考:https://blog.csdn.net/qq_45737068/article/details/107149922
    常量池:每个class一份,存在于字节码文件中,包含字面量和符号引用。就是一张表,虚拟机指令根据这张常量表找到要执行的类名,方法名,参数类型,字面量(字符串,基本类型)等信息
    在这里插入图片描述

    运行时常量池:每个class一份,存在于方法区中(元空间)。当类加载到内存中后,jvm就会将class常量池中的内容存放到运行时常量池中。经过解析(resolve)之后,也就是把符号引用替换为直接引用,解析的过程会去查询字符串常量池,以保证运行时常量池所引用的字符串与字符串常量池中所引用的是一致的
    具备动态性,运行期间也能添加常量。
    字符串常量池:每个JVM中只有一份,逻辑上属于方法区,但实际存放在堆中。

    关于字符串常量池中到底存放的是引用还是字面值说法很多,目前更倾向于存放的是字面值

    在这里插入图片描述

    关于常量池存放的位置,有机会验证一下:运行时常量池和静态常量池存放在元空间中,而字符串常量池依然存放在堆中
    参考:https://javaforall.cn/164776.html

    • OutOfMemoryError:当常量池无法再申请到内存时抛出
  • 7. 直接内存

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

    在JDK 1.4中新加入了NIO(New Input/Output)类,引入了一种基于通道(Channel)与缓冲区(Buffer)的I/O方式,它可以使用Native函数库直接分配堆外内存,然后通过一个存储在Java堆里面的DirectByteBuffer对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在Java堆和Native堆中来回复制数据

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

    • OutOfMemoryError异常

2.3 HotSpot虚拟机对象探秘

先深入探讨一下HotSpot虚拟机在Java堆中对象分配、布局和访问的全过程,这里仅讨论普通对象,不包括数组和Class对象

对象创建

符号引用:例如在Person类中引用了Language类,在编译时,Person类不知道Language的实际地址,就用一个符号来代替

  • 检查被new的类是否已被加载进内存

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

    类加载在编译期间完成,而分配内存在运行期间进行,即Person person = new Person(),等号两边执行不同步

  • 分配内存

    在类加载检查通过后,接下来虚拟机将为新生对象分配内存。
    对象所需内存的大小在类加载完成后便可完全确定,分配就是从Java堆中划分一块空间给对象。

    分配方式有:

    • 指针碰撞
      堆内存规整,已使用内存和空闲内存仅通过一个指针分开,分配内存就是移动指针,这种方式成为“指针碰撞”
    • 空闲列表
      堆内存不规整,已使用内存和空闲内存相互交错,这样虚拟机就需要维护一个空闲列表

    选择哪种分配方式由Java堆是否规整决定,而Java堆是否规整又由所采用的垃圾收集器是否带有空间压缩整理(Compact)的能力决定

  • 初始化

    内存分配完成之后,虚拟机必须将分配到的内存空间(但不包括对象头)都初始化为零值

  • 设置对象头

    Java虚拟机还要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码(实际上对象的哈希码会延后到真正调用Object::hashCode()方法时才计算)、对象的GC分代年龄等信息

    这些信息存放在对象的对象头(Object Header)之中。根据虚拟机当前运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式

  • 构造函数

    在上面工作都完成之后,从虚拟机的视角来看,对象创建已完成;
    但是从Java程序来看,构造函数,即Class文件中的()方法还没有执行;所有的字段都为零值;且对象需要的其他资源和状态信息也还没有按照预定的意图构造好

    一般来说,new指令之后会接着执行()方法,这样初始化完之后,一个对象才算创建成功

    Java编译器会在遇到new关键字的地方同时生成 invoke 和 special 指令来进行初始化;非new的其他方式则不一定

HotSpot解释器代码片段

这个解释器实现很少有机会实际使用,大部分平台上都使用模板解释器,但可以用来了解HotSpot运作过程

// 确保常量池中存放的是已解释的类
if (!constants->tag_at(index).is_unresolved_klass()) {
    
    
    // 断言确保是klassOop和instanceKlassOop(这部分下一节介绍)
    oop entry = (klassOop) *constants->obj_at_addr(index);
    assert(entry->is_klass(), "Should be resolved klass");
    klassOop k_entry = (klassOop) entry;
    assert(k_entry->klass_part()->oop_is_instance(), "Should be instanceKlass");
    instanceKlass* ik = (instanceKlass*) k_entry->klass_part();
    // 确保对象所属类型已经经过初始化阶段
    if ( ik->is_initialized() && ik->can_be_fastpath_allocated() ) {
    
    
        // 取对象长度
        size_t obj_size = ik->size_helper();
        oop result = NULL;
        // 记录是否需要将对象所有字段置零值
        bool need_zero = !ZeroTLAB;
        // 是否在TLAB中分配对象
        if (UseTLAB) {
    
    
            result = (oop) THREAD->tlab().allocate(obj_size);
        }
        if (result == NULL) {
    
    
            need_zero = true;
            // 直接在eden中分配对象
            retry:
            HeapWord* compare_to = *Universe::heap()->top_addr();
            HeapWord* new_top = compare_to + obj_size;
            // cmpxchg是x86中的CAS指令,这里是一个C++方法,通过CAS方式分配空间,并发失败的
            话,转到retry中重试直至成功分配为止
                if (new_top <= *Universe::heap()->end_addr()) {
    
    
                    if (Atomic::cmpxchg_ptr(new_top, Universe::heap()->top_addr(), compare_to) != compare_to) {
    
    
                        goto retry;
                    }
                    result = (oop) compare_to;
                }
        }
        if (result != NULL) {
    
    
            // 如果需要,为对象初始化零值
            if (need_zero ) {
    
    
                HeapWord* to_zero = (HeapWord*) result + sizeof(oopDesc) / oopSize;
                obj_size -= sizeof(oopDesc) / oopSize;
                if (obj_size > 0 ) {
    
    
                    memset(to_zero, 0, obj_size * HeapWordSize);
                }
            }
            // 根据是否启用偏向锁,设置对象头信息
            if (UseBiasedLocking) {
    
    
                result->set_mark(ik->prototype_header());
            } else {
    
    
                result->set_mark(markOopDesc::prototype());
            }
            result->set_klass_gap(0);
            result->set_klass(k_entry);
            // 将对象引用入栈,继续执行下一条指令
            SET_STACK_OBJECT(result, 0);
            UPDATE_PC_AND_TOS_AND_CONTINUE(3, 1);
        }
    }
}

对象的内存布局

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

  • 1. 对象头

    • (1)存储对象自身的运行时数据

      如哈希码、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等
      这部分数据的长度在32位和64位的虚拟机(未开启压缩指针)中分别为32个比特和64个比特
      官方称它为“Mark Word”

      对象需要存储的运行时数据很多,其实已经超出了32、64位Bitmap结构所能记录的最大限度

      但对象头里的信息是与对象自身定义的数据无关的额外存储成本,考虑到虚拟机的空间效率,Mark Word被设计成一个有着动态定义的数据结构,以便在极小的空间内存储尽量多的数据,根据对象的状态复用自己的存储空间。

      例如在32位的HotSpot虚拟机中,如对象未被同步锁锁定的状态下,Mark Word的32个比特存储空间中的25个比特用于存储对象哈希码,4个比特用于存储对象分代年龄,2个比特用于存储锁标志位,1个比特固定为0,在其他状态在其他状态(轻量级锁定、重量级锁定、GC标记、可偏向)下对象的存储内容如下所示
      在这里插入图片描述

      • HotSpot虚拟机中代表Mark Word的代码注释,它描述了32位虚拟机Mark Word的存储布局:

        // 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)
        
    • (2)存储类型指针

      类型指针是对象指向它的类型元数据的指针,Java虚拟机通过这个指针来确定该对象是哪个类的实例
      但不是所有的虚拟机实现都必须在对象数据上保留类型指针
      Java数组的对象头中还必须有一块用于记录数组长度的数据

  • 2. 实例部分

    对象真正存储的有效信息,包括自己定义和父类基础的字段

    这部分的存储顺序会受到虚拟机分配策略参数和字段在Java源码中定义顺序的影响,HotSpot虚拟机默认的分配顺序为 longs/doubles、ints、shorts/chars、bytes/booleans、oops(Ordinary Object Pointers,OOPs)

    相同宽度的字段总是被分配到一起存放
    但如果CompactFields参数值为true(默认就为true),那子类之中较窄的变量也允许插入父类变量的空隙之中,以节省空间

  • 3. 对齐填充

    作为占位符,HotSpot虚拟机的自动内存管理系统要求对象起始地址必须是8字节整数倍,即任何对象的大小都必须是8字节的整数倍
    对象头已经是8字节,如果对象实例数据部分没有对齐的话,就需要通过对齐填充来补全

对象的访问定位

Java程序会通过栈上的reference数据来操作堆上的具体对象,主流的访问方式主要有使用句柄和直接指针两种

  • 句柄访问

    Java堆中将可能会划分出一块内存来作为句柄池,reference中存储的就是对象的句柄地址
    句柄中包含了对象实例数据与类型数据各自具体的地址信息
    在这里插入图片描述

    对象类型数据就是被虚拟机加载的类信息(即Class信息,存放在方法区(回顾方法区存放内容))
    对象实例数据就是被new出来的对象信息

  • 直接指针访问

    reference中存储的直接就是对象地址,访问对象时不需要多一次间接访问的开销
    但必须考虑如何访问类型数据的问题
    在这里插入图片描述

HotSpot中主要采用直接指针访问,因为对象访问是非常频繁的,这样速度更快
但在各种语言、框架中使用句柄来访问的情况也十分常见

2.4 实战:OutOfMemoryError异常

参考:https://www.jb51.net/article/244872.htm

Java堆异常

  • 环境设置:在IDEA下,限制堆大小为20M以方便测试

在这里插入图片描述

Xms:最小堆内存 Xmx:最大可扩展内存
将堆的最小值-Xms参数与最大值-Xmx参数设置为一样即可避免堆自动扩展
通过参数-XX:+HeapDumpOnOutOf-MemoryError可以让虚拟机在出现内存溢出异常的时候Dump出当前的内存堆转储快照以便进行事后分析

-Xms20m -Xmx20m -XX:+HeapDumpOnOutOfMemoryError

在这里插入图片描述

  • Java堆异常测试
public class Test {
    
    
    static class OOMObejct{
    
    }
    public static void main(String[] args){
    
    
        int i=0;
        List<OOMObejct> list = new ArrayList<OOMObejct>();
        while(true){
    
    
            //System.out.println(++i);//加入这一句后就死循环,原因?
            list.add(new OOMObejct());
        }
    }
}
  • 异常报错

在这里插入图片描述

可以看到,报错的是heap space,即堆空间

  • 解决堆内存区域异常方法

    • 准备工作:安装内存分析工具JProfiler

      安装教程可见:https://blog.csdn.net/weixin_42311968/article/details/120726106
      (1)在IDEA中Settings - Plugins,搜索JProfiler进行安装
      (2)插件安装完毕之后,还需要外部安装一个JProfier软件,如jprofiler_windows_11_1_4.exe
      注意这里安装时许可证获取:运行附带的KenGen程序,点击Generate自动生成
      此外,IDE集成那里选择 IDEA 集成
      可以检测Settings - Tools - JProfiler中的地址是否正确

    • 检查导致异常的OOM对象

      常规的处理方法是首先通过内存映像分析工具(如Eclipse MemoryAnalyzer,IDEA的JProfiler)对Dump出来的堆转储快照(即hprof文件)进行分析。第一步首先应确认内存中导致OOM的对象是否是必要的,也就是要先分清楚到底是出现了内存泄漏(Memory Leak)还是内存溢出(MemoryOverflow)
      在这里插入图片描述

      可以看到是ArrayList占了绝大多数内存;点击Thread Dump中的main可以看到具体报错是19行

      在这里插入图片描述

先学习到这里,之后有时间再学习怎么使用JProfier进行内存泄漏和内存溢出的定位排查

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

HotSpot虚拟机中并不区分虚拟机栈和本地方法栈,因此-Xoss参数(设置本地方法栈大小)虽然存在,但没有用处,栈容量只能由-Xss参数来设定

如果虚拟机的栈内存允许动态扩展,当扩展栈容量无法申请到足够的内存时,将抛出OutOfMemoryError异常
但HotSpot是不允许动态扩展栈空间的,因此线程运行时是不会因为扩展而导致内存溢出的。只会因为栈满而抛出StackOverflowError

  • -Xss配置栈容量

    -Xss128k
    
  • (1)虚拟机栈和本地方法栈测试一:栈帧容量

    /**
     * VM Args:-Xss128k
     * @author zzm
     */
    public class Test {
          
          
        private int stackLength = 1;
        public void stackLeak() {
          
          
            stackLength++;
            stackLeak();
        }
        public static void main(String[] args) {
          
          
            Test oom = new Test();
            try {
          
          
                oom.stackLeak();//死循环,递归调用stackLeak()函数
            } catch (Throwable e) {
          
          
                System.out.println("stack length:" + oom.stackLength);
                throw e;
            }
        }
    }
    

    异常结果
    在这里插入图片描述

    最终得到的是StackOverflowError异常,在java虚拟机栈上的报错

  • (2)虚拟机栈和本地方法栈测试二:容纳的局部变量

    public class Test {
          
          
        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 (Error e){
          
          
                System.out.println("stack length:" + stackLength);
                throw e;
            }
        }
    }
    

    异常结果
    在这里插入图片描述

    根据Java规范,在虚拟机栈和本地方法栈中,如果是栈深度超出限制时,会报StackOverflowError,即测试一的情况

    如果是当栈扩展时无法申请到足够的内存,应当是报OutOfMemeoryError;但是由于HotSpot不允许栈扩展,所以栈帧超出或是局部变量过多,都将报StackOverflowError

    在远早时期的Classic虚拟机上运行第二个测试用例,就会报OutOfMemoryError(此时注意是用-oss参数)

  • (3)虚拟机栈和本地方法栈测试三:OutOfMemoryError测试

    HotSpot虚拟机上单线程不会产生OOM异常,但可以通过创建多进程来模拟这个异常

    OS给每个进程分配的内存是有限制,譬如32位Windows的单个进程最大内存限制为2GB。那么
    虚拟机栈和本地方法栈容量 = 2GB - 最大堆容量 - 最大方法区容量 - 可以忽略的程序计数器内存

    -Xss2M	//增大栈容量,使得需要创建的线程数减少
    

    导致溢出的代码如下(这段代码可能会导致内存占满而电脑假死,因此不建议运行

    /**
    * VM Args:-Xss2M (这时候不妨设大些,请在32位系统下运行)
    * @author zzm
    */
    public class Test {
          
          
        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) {
          
          
            Test oom = new Test();
            oom.stackLeakByThread();
        }
    }
    

    理论上,会报以下错误

    Exception in thread "main" java.lang.OutOfMemoryError: unable to create native thread
    

对于HotSpot虚拟机,栈帧深度是可以达到1000~2000的
而对于由于创建线程过多而导致的OOM溢出,可以通过 减小最大堆和栈容量 来换取更多的线程

方法区和运行时常量池溢出(复杂)

运行时常量池是方法区中的一部分,但JDK 7之后,有所变化
HotSpot从JDK 7开始逐步“去永久代”的计划,并在JDK 8中完全使用元空间来代替永久代
这里主要是测试的是字符串常量池

  • (1)测试一:JDK 6版本

    先给出测试结果:

    -XX:PermSize=6M -XX:MaxPermSize=6M	//限制永久代,有用,报错:PermGen space
    -Xms6m -Xmx6m	//限制堆大小,无用
    

    前提:需要JDK6或更早版本,IDEA切换方式:File - Program Structure - SDKs里面的Programs中切换
    下载和安装多版本JDK:https://blog.csdn.net/widsoor/article/details/127973277

    -XX:PermSize=6M -XX:MaxPermSize=6M	//设置虚拟机参数,限制永久代大小
    

    测试异常的代码:

    /**
    * VM Args:-XX:PermSize=6M -XX:MaxPermSize=6M
    * @author zzm
    */
    public class Test {
          
          
        public static void main(String[] args) {
          
          
            // 使用Set保持着常量池引用,避免Full GC回收常量池行为
            Set<String> set = new HashSet<String>();
            // 在short范围内足以让6MB的PermSize产生OOM了
            short i = 0;
            while (true) {
          
          
                set.add(String.valueOf(i++).intern());//将字符串放入常量池中
            }
        }
    }
    /**
    * String::intern()是一个本地方法,它的作用是如果字符串常量池中已经包含一个等于此String对象的字符串,则返回代表池中
    * 这个字符串的String对象的引用;否则,会将此String对象包含的字符串添加到常量池中,并且返回此String对象的引用。
    * 在JDK 6或更早之前的HotSpot虚拟机中,常量池都是分配在永久代中,我们可以通过-XX:PermSize和-XX:MaxPermSize
    * 限制永久代的大小,即可间接限制其中常量池的容量
    */
    

    异常结果

    在这里插入图片描述

    这里报错是 `PermGen space`,永久代的内存溢出。在JDK 8中就没有永久代了
    

    (2)测试二:JDK8

    先给出测试结果:

    -XX:PermSize=6M -XX:MaxPermSize=6M	//限制永久代,无用,并提示该配置被ignoring
    -Xms6m -Xmx6m	//限制堆大小,有用,报错:Java heap space
    
    • JDK7及以前(已放弃):-XX:PermSize设置永久代初始大小。-XX:MaxPermSize设置永久代最大可分配空间。
    • JDK8及以后:使用-XX:MetaspaceSize和-XX:MaxMetaspaceSize设置元空间初始大小以及最大可分配大小

    无论是在JDK 7中继续使用-XX:MaxPermSize参数或者在JDK 8及以上版本使用-XX:MaxMetaspaceSize参数把方法区容量同样限制在6MB,也都不会重现JDK 6中的溢出异常,循环将一直进行下去,永不停歇

    注意:测试三中测试了一下JDK 7,发现这个版本比较混乱

    这是因为从JDK7开始,字符串常量池被移动到 Java堆 之中,所以这些限制方法区的参数对其无效应该使用限制堆大小的 -Xmx 进行设置

    -Xms6m -Xmx6m -XX:+HeapDumpOnOutOfMemoryError
    

    在JDK 8中执行测试一中的代码,得到如下异常结果

    在这里插入图片描述
    这里的报错不是一定的,如果将最大堆从6M减小到2M,就会报下面的错误
    在这里插入图片描述

    配置不同的堆内存大小,选用不同的GC算法,产生的错误信息也不相同
    可以确定的是:在JDK 8版本中,字符串常量池已经被放到了堆区

  • (3)测试三:JDK7版本

    • 限制堆大小:无效果
    -Xms6m -Xmx6m //不起作用
    
    • 限制永久区大小:无效果
    -XX:PermSize=2M -XX:MaxPermSize=2M	//设置太小,会导致虚拟机初始化失败
    -XX:PermSize=6M -XX:MaxPermSize=6M	//不起作用,死循环,说明限制永久代不行
    
    • 尚待学习的部分

    这里记录一下:https://blog.csdn.net/weixin_44556968/article/details/109468386
    该文章里面的测试代码是有点问题的,在JDK 6中限制永久区和堆都会分别报错 PermGen Space 和 Java Heap Space
    原理之后有机会在学习:目前看来是因为 “+” 会导致生成一个新的对象并存放在堆中,所有两者都有效
    文章中的测试代码:

    public class Test {
          
          
        static String  base = "string";
        public static void main(String[] args) {
          
          
            List<String> list = new ArrayList<String>();
            for (int i=0;i< Integer.MAX_VALUE;i++){
          
          
                String str = base + base;
                base = str;
                list.add(str.intern());
            }
        }
    }
    

    测试结果如下:

    //限制永久代
    -XX:PermSize=6m -XX:MaxPermSize=6m
    //JDK 6下报PermGen Space
    //JDK 7下报Java heap space
    //JDK 8下提示该条语句不起作用,但仍然报了Java heap space
    //注意:即便不加任何虚拟机配置,也会在JDK 7和JDK 8中报Java heap space,说明这段代码无论如何都会堆异常,区别在时间长度
    
    //限制堆
    -Xms6m -Xmx6m	//JDK 678下都报的Java heap space
    
    //限制元空间
    -XX:MetasapceSize=6m -XX:MaxMetaspaceSize=6m	
    //JDK 67报错:Unrecognized VM option
    //JDK 8下报错:Java heap space
    

    JDK 7 的常量池分配在哪,需要进一步研究

    目前说法是:
    JDK 6及之前:常量池在方法区,这时的方法区也称为永久代
    JDK 7的时候:方法区合并到了堆内存,这时的常量池在堆内存中
    JDK 8及以后:方法区又从堆内存中剥离出来,但实现方式与之前的永久代不同,这时的方法区称为元空间
    类型信息(元数据信息)等其他信息被移动到了元空间中;但是字符串常量池被移动到了堆中
    字符串常量池逻辑上属于方法区,但是实际存放在堆内存中
    其他常量池存放位置:暂时未知

  • (4)测试四:方法区其他部分

    主要是用于存放类型的相关信息,需要通过创建大量的类进行测试。采用CGLib字节码技术生成大量动态类

    方法区溢出也是一种常见的内存溢出异常,一个类如果要被垃圾收集器回收,要达成的条件是比较苛刻的。在经常运行时生成大量动态类的应用场景里,就应该特别关注这些类的回收状况

    • 前提准备
      添加cglib-nodep-2.1_3和asm-9.2包,添加jar包方法:https://blog.csdn.net/widsoor/article/details/127990354

      注意这里cglib-nodep的版本问题:我这里第一次就用的这个版本,在JDK 6和JDK 7上成功,在JDK 8上偶尔失败
      之后再换其他版本,JDK 678都报错,原因尚且未知

    • 测试代码

      //动态不断地生成类,之前是测试常量池,现在是测试方法区的其他部分
      import net.sf.cglib.proxy.Enhancer;
      import net.sf.cglib.proxy.MethodInterceptor;
      import net.sf.cglib.proxy.MethodProxy;
      import java.lang.reflect.Method;
      public class Test {
              
              
          public static void main(String[] args) {
              
              
              while (true) {
              
              
                  Enhancer enhancer = new Enhancer();
                  enhancer.setSuperclass(OOMObject.class);
                  enhancer.setUseCache(false);
                  enhancer.setCallback(new MethodInterceptor() {
              
              
                      public Object intercept(Object obj, Method method, Object[] args, MethodProxy proxy) throws Throwable {
              
              
                          return proxy.invokeSuper(obj, args);
                      }
                  });
                  enhancer.create();
              }
          }
          static class OOMObject {
              
              
          }
      }
      
    • JDK 6中测试

      虚拟机设置:

      -XX:PermSize=10M -XX:MaxPermSize=10M		//JDK 6
      

      结果:

      在这里插入图片描述

    • JDK 7中的测试

      虚拟机设置:

      -XX:PermSize=10M -XX:MaxPermSize=10M		//JDK 7
      

      结果:
      在这里插入图片描述

    • JDK 8中的测试

      在JDK 8以后,永久代便完全退出了历史舞台,元空间作为其替代者登场。在默认设置下,前面列举的那些正常的动态创建新类型的测试用例已经很难再迫使虚拟机产生方法区的溢出异常了

      虚拟机设置:

      -XX:MaxMetaspaceSize=10m -XX:MetaspaceSize=10m
      

      结果:

      在这里插入图片描述

      可以看到JDK 6中报的是 PermGen(永久代) 错误,而JDK 8中报的是元空间错误了(JDK 7依然看不懂)

      此外,在JDK 8中还有以下的虚拟机设置

      -XX:MinMetaspaceFreeRatio:作用是在垃圾收集之后控制最小的元空间剩余容量的百分比,可减少因为元空间不足导致的垃圾收集的频率。
      类似的还有-XX:Max-MetaspaceFreeRatio,用于控制最大的元空间剩余容量的百分比。
      

其他:intern() 返回引用的测试

由于JDK 7许多地方没弄清楚,所以这里不讨论JDK 7,用JDK 8替代

  • 前提知识

    经典提问:创建了几个对象(之后练一下)

    (1)String str1 = “xyz”;

    String str = "xyz";//仅创建1份,此时字符串放在字符串常量池中,返回其在字符串常量池中的引用
    String str1 = new String("xyz");//仅创建1份,即在堆中拷贝一份字符串常量池中的"xyz",然后返回堆中引用
    System.out.println(str == str1);//返回false
    

    (2)String str2 = new String(“opq”);

    String str2 = new String("opq");//创建2份,常量池中1份,堆中拷贝1份,返回其在堆中的引用
    //"opq"本身是字符串常量,会在常量池中先保存一份;然后执行到new String,于是再在堆上创建一个对象
    String str3 = new String("opq");//创建1份,由于str2时已经在常量池中创建,str3查找常量池成功,于是仅需要在堆中新建1份
    System.out.println(str2 == str3);//得到false;虽然它们都是“opq”在常量池中的拷贝,但在堆中有2份拷贝,引用(地址)不同
    

    思考,下列语句创建了几个对象

    String s = new String(“str”) + new String("ing");
    

    在不考虑编译器优化的情况(实际上会有优化),上面这条语句将产生6个对象。(1)其中5个String对象,常量池中2个:“str”,“ing”;堆中3个:“str”,“ing”,“string”;(2)此外,还有一个Java对象:+操作符实际上是new了一个StringBuilder对象,也就是这个对象调用toString方法在堆上创建了“string”字符串

    (3)StringBuilder

    String str4 = new StringBuilder("rst").toString();//StringBuilder是数组转字符串,总体可以相当于new String
    //注意:有参初始化时new String一次,之后每append一次,也相当于new String一次
    String str1 = new StringBuilder("计算机").append("软件").toString();
    //可能创建:(1)3个String对象:字符串常量池:“计算机”,“软件”;堆中:“计算机软件”;(2)1个StringBuilder对象
    //append不会新建对象
    

    (4)str5 + str6

    String str5 = "cd";
    String str6 = "ef";
    String str7 = "cdef";
    String str8 = "cd"+"ef";//字面值相加,编译期间优化成字符串“cdef”
    String str9 = str5 + str6;//变量相加,相当于创建一个StringBuilder,再toString
    System.out.println(str7 == str8);//true
    System.out.println(str7 == str9);//false
    
  • (1)测试一:intern()返回引用的简单测试

    intern()方法作用:
    查找常量池中有没有调用equals()方法为true的String对象;如果有,就将这个常量池中的String对象作为结果返回;如果没有,则将当前对象加入到常量池中,将本对象返回

    以下两段代码返回结果不同
    参考:https://blog.csdn.net/cczdbgf/article/details/123537692

    //使用如下地址返回字符串的地址
    //identityHashCode返回根据对象物理内存地址产生的hash值
    System.out.println(String.class.getName() + "@" + Integer.toHexString(System.identityHashCode(s3)));
    
    • JDK 8中的测试情况

      //在JDK 8中测试intern() 1
      String s1="a";
      String s2="b";
      String s3=s1+s2;//在堆中创建一个ab,返回堆中引用
      //此时s3的地址是:@1b6d3586
      s3.intern();//发现常量池中没有ab,于是将堆中的ab变为常量池ab,返回常量池ab
      //此时s3的地址是:@1b6d3586
      String s4="ab";//发现常量池已有ab,返回常量池的ab
      //此时s4的地址是:@1b6d3586
      System.out.println(s3==s4);
      /**
       * JDK 8中返回:true
       */
      

      个人理解(不一定正确,以后有机会再学习下底层代码验证):
      在JDK 8中,字符串常量池在堆中。
      而s3本来是属于堆中非常量池部分,当s3.intern()执行时,发现堆中的常量池里面没有“ab”,就将堆中的这个对象变为常量池中的对象,并返回

      //在JDK 8中测试intern() 2
      String s1="a";
      String s2="b";
      String s3=s1+s2;//在堆中创建一个ab,返回堆中引用
      //此时s3的地址是:@1b6d3586
      String s4="ab";//在常量池创建一个ab,返回常量池中引用
      //此时s4的地址是:@4554617c
      String s5 = s3.intern();//发现常量池已有ab,返回常量池的ab
      //此时s3的地址是:@1b6d3586
      //此时s5的地址是:@4554617c
      System.out.println(s3==s4);//返回:false
      System.out.println(s4==s5);//返回:true
      

      这里s3率先被放在堆中非常量池部分,然后 s4 的“ab”再放入常量池。之后执行s3.intern()时,将返回常量池的“ab”引用,但s3本身没有改变,因此s3和s4引用不同,而s4和s5引用相同

    • JDK 6中的测试情况

      JDK 6的常量池并不在堆中,它的intern不会将本来在堆中的字符串变为常量池中的字符串,因此:

      //在JDK 6中测试intern()
      String s1="a";
      String s2="b";
      String s3=s1+s2;//在堆中创建一个ab,返回堆中引用
      //此时s3的地址是:@38a97b0b
      s3.intern();//发现常量池中没有ab,于是将堆中的ab变为常量池ab,返回常量池ab
      //此时s3的地址是:@38a97b0b
      String s4="ab";//发现常量池已有ab,返回常量池的ab
      //此时s4的地址是:@7ecd2c3c
      System.out.println(s3==s4);
      /**
       * JDK 8中返回:false
       */
      

      可以看到,在JDK 8中是直接将堆中的字符串变为常量池中字符串(类似于存储“ab”的那块内存从普通堆改名为字符串常量池)
      而JDK 6中,有2个不同的引用地址,说明做的是拷贝工作

  • (3)测试二:String.intern()返回引用的测试

    将下面代码放在JDK 6和JDK 8中的结果也是不一样的

    public class Test {
          
          
        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);
        }
    }
    
    • 在JDK 8中,它返回的是一个true,一个false

      • 对于str1:在str1被放在了堆中,str1.intern()将其所在内存变为字符串常量池所属,但这个过程引用(地址)是不变的
      • 对于str2:“java”字符串比较特殊,它是已经被放到常量池中的字符串。str2时依旧创建一个“java”字符串对象,但当执行到 str2.intern()时,发现常量池中已经有“java”了,它就不会再将堆中的对象变为常量池中的,而是直接返回常量池引用,因此得到了false结果
    • 在JDK 6中,它返回的是两个false

      • 对于str1

        注意此时,常量池和堆是分开的内存区域
        str1的字符串在StringBuilder的过程中被放在了常量池中,然后复制一份被放到了堆中,返回的是在堆中的引用
        str1.intern() 返回的是其在常量池中的引用,所以二者不相等

      • 对于str2,同理

本机直接内存溢出

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

-Xmx20M -XX:MaxDirectMemorySize=10M

测试代码

import sun.misc.Unsafe;
import java.lang.reflect.Field;
public class Test {
    
    
    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);
        }
    }
}

结果

在这里插入图片描述

!!注意:
由直接内存导致的内存溢出,一个明显的特征是在Heap Dump文件中不会看见有什么明显的异常情况,如果读者发现内存溢出之后产生的Dump文件很小,而程序中又直接或间接使用了DirectMemory(典型的间接使用就是NIO),那就可以考虑重点检查一下直接内存方面的原因了

猜你喜欢

转载自blog.csdn.net/widsoor/article/details/127998145
今日推荐