JVM学习笔记之Java内存区域与OOM【二】

Java 内存区域与 OOM

虚拟机基本结构图示

一、运行时数据区域

运行时数据区域 图示

标注颜色的两块区域:所有线程共享的数据区域

1.1 程序计数器(progams count Register)

程序计数器是一块比较小的内存空间,可以把它看作当前线程正在执行的字节码的行号指示器。程序计数器里面记录的是当前线程正在执行的那一条字节码指令的地址。当然,程序计数器是线程私有的。但是,如果当前线程执行的是一个线程本地的方法,那么此时这个线程的程序计数器为 undefined。

本地方法是使用关键字 native 修饰的方法

如:public native String intern();

程序计数器的作用:

  • 字节码解释器通过改变程序计数器来一次读取指令,从而实现代码的流程控制,如顺序执行、选择、循环、异常处理等。
  • 在多线程的条件下,程序计数器用来记录当前线程执行的位置,从而当线程被切换回来的时候能够知道这个线程上次运行到哪个地方了。

程序计数器的特点:

  • 是一块比较小的存储空间
  • 是线程私有的,即每一个线程都有一个独立程序计数器
  • 是唯一一个不会出现 OOM(OutOfMemoryError)的内存区域
  • 声明周期随着线程的开始而创建,随着线程的终止而结束

1.2 堆(Head)

堆定义:

  • 在 Java 虚拟机中堆是一个线程共享的区域。在运行区域中,堆主要用于存放 new 关键字创建的对象实例和分配的数组空间。

  • 堆在 Java 虚拟机启动时被创建。Java 堆是完全自动化管理的,通过垃圾回收机制,垃圾对象会自动清理释放内存,而不需要显示地释放。

  • 如果堆的分配的空间太多,觉得没必要,也可以手动调整。

  • 如果计算需要的内存多于可以提供的堆内存,则 Java 虚拟机将会抛出内存溢出异常(OutOfMemoryError)。

堆内存划分及其参数调整图示:

堆空间调整参数:

  • -Xms:设置初始分配大小,默认为物理內存的 1/64
  • -Xmx:最大分配內存,默认為物理內存的 1/4
  • -XX:+PrintGCDetails:输出详细的 GC 处理日志
  • -XX:+PrintGCTimeStamps:输出 GC 的時間戳信息
  • -XX:+PrintGCDateStamps:输出 GC 的時間戳信息(以日期的形式)
  • -XX:+PrintHeapAtGC:在 GC 进行处理的前后打印堆內存信息
  • -Xloggc:(SavePath):设置日志信息保存文件
  • 在堆內存的調整策略中,基本上只要調整兩個参数:-Xms 和-Xmx

其它参数请看官网文档

堆空间了解

新生代:这是短暂居住的地方,分为两个空间

  • Eden 空间:使用在此空间上分配的 new 关键字内存创建的对象。
  • 幸存者空间(Survivor Space):这是包含从 Eden 空间收集 java 垃圾收集后存活的对象的池。
  • Eden、S0、S1 3 个部分默认的比例是 8:1:1 的大小

老年代:这个池基本上包含了终身和虚拟(预留)空间,并将持有年轻一代垃圾收集后存活的那些物体。

  • 终身空间:这个内存池包含多个垃圾收集装置后幸存下来的对象,这些对象在从幸存者空间收集垃圾后存活。

注意:如果在 Java 堆中没有内存完成实例分配,并且堆也无法再扩展时,Java 虚拟机将会抛出 OutOfMemoryError 异常。

1.3 本地方法栈(Native Method Stacks)

本地方法栈类似于 Java 栈,主要存储了本地方法调用的状态。区别不过是 Java 栈为 JVM 执行 Java 方法服务,而本地方法栈为 JVM 执行 Native 方法服务。本地方法栈也会抛出 StackOverflowError 和 OutOfMemoryError 异常。

1.4 方法区(Method Area)

什么是方法区:

  • 在 JVM 中,方法区是可供各个线程共享运行时的内存区域。
  • 方法区域传统语言中的编译代码存储区或者操作系统进程的正文段的作用非常类似,它存储了每一个类的结构信息,例如运行时常量池、字段和方法数据、类的构造函数和普通方法的字节码内容、还包括一些类、实例、接口初始化的时候用到的特殊方法。
  • 在 Hotspot 虚拟机中,JDK 1.7 版本称作永久代(Permanent Generation),而在 JDK 1.8 则称为 元空间(Metapace)。
  • 方法区有个别名叫做非堆(Non-Heap),用于区别于 Java 堆区。

方法区 jvm 参数调整:

#Java8 以前版本参数设置
-XX:PermSize=10m
-XX:MaxPermSize=55m

#Java8后参数设置
-XX:MetaspaceSize=10m
-XX:MaxMetaspaceSize=55m

方法区的特点:

  • 线程共享:方法区是堆的一个逻辑部分,因此和对一样是线程共享的。整个虚拟机中只有一个方法区。

  • 内存回收低:方法区中的信息一般需要长期存在,回收一遍内存之后可能之后少量信息无效。对方法区的内存回收主要是 对常量池的回收和对类型的卸载。

  • JVM 规范对方法区的定义比较宽松:和堆一样,允许固定大小,也允许可扩展大小,还允许不实现垃圾回收。

运行时常量池:

  • 类加载后,Class 文件结构中常量池中的数据将被存储在运行时常量池中

  • 具备动态性,在运行期间也可以将新的常量放入池中,如 String 类的 intern()方法

注意:根据《Java 虚拟机规范》的规定,如果方法区无法满足新的内存分配需求时,将抛出 OutOfMemoryError 异常。

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

与程序计数器一样,Java 虚拟机栈(Java Virtual Machine Stack)也是线程私有的,它的生命周期与线程相同。

虚拟机栈描述的是 Java 方法执行的线程内存模型:

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

注意:

  • 如果线程请求的栈深度大于虚拟机所允许的深度,将抛出 StackOverflowError 异常;
  • 如果 Java 虚拟机栈容量可以动态扩展,当栈扩展时无法申请到足够的内存会抛出 OutOfMemoryError 异常。

说明:

  • 字(Word) 指的是计算机内存中占据一个单独的内存单元编号的一组二进制串,一般 32 位计算机上一个字为 4 个字节长度

二、Java 虚拟机对象

2.1 对象的创建过程

图示

相关概念认识:

  • 指针碰撞(Bump The Pointer):
  • 假设 Java 堆中内存是绝对规整的,所有被使用过的内存都被放在一边,空闲的内存被放在另一边,中间放着一个指针作为分界点的指示器,那所分配内存就仅仅是把那个指针向空闲空间方向挪动一段与对象大小相等的距离,这种分配方式称为“指针碰撞”(Bump The Pointer)。
  • 空闲列表(Free List):
    • 使用一个列表,记录上哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录,这种分配方式称为“空闲列表”(Free List)。
  • 本地线程分配缓冲(Thread Local Allocation Buffer,TLAB)
    • 把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在 Java 堆中预先分配一小块内存,称为本地线程分配缓冲(Thread Local Allocation Buffer,TLAB)。
    • 虚拟机是否使用 TLAB,可以通过-XX:+/-UseTLAB 参数来设定。

2.2 对象的内存布局

图示

对象头(Header):

Mark Word

  • 用于存储对象自身的数据,如:哈希码(HashCode)、  GC 分代年龄、锁状态标志、线程持有锁、偏向线程 ID、偏向时间戳等,这部分数据的长度在 32 为和 64 位的虚拟机(未开启压缩指针)中分别为 32bit 和 64bit

  • MarkWord 是根据对象的状态区分不同的状态位,从而区分不同的存储结构(32bit 下)

    • 正常对象: 对象的 HashCode (25bit) + 对象的分代年龄(4bit)+是否偏向锁状态(1bit, 值为 0) + 锁标志状态(2bit,值 01)
    • 偏向对象: 线程 ID(23bit )+ Epoch (2bit)+ 对象的分代年龄(4bit)+是否偏向锁状态(1bit) + 锁标志状态(2bit)

  • 在头文件 src/hotspot/share/oops/markOop.hpp(openJDK10)的注释中,描述的对象头 Mark Work 的存储状态
// 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)
//

对象头的另一部分是类型指针(Klass Pointer):

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

对象实例数据(Instance Data)

对齐填充(Padding)

  • 第三部分对齐填充并不是必然存在的,也没有特别的含义,它仅仅起着占位符的作用。
  • 由于 HotSpot VM 的自动内存管理系统要求对象起始地址必须是 8 字节的整数倍,换句话说就是对象的大小必须是 8 字节的整数倍。
  • 对象头正好是 8 字节的倍数(1 倍或者 2 倍),因此当对象实例数据部分没有对齐的话,就需要通过对齐填充来补全。

2.3 对象的访问地址

直接指针访问(访问速度更快)

句柄发访问

三、内存区域 OOM 异常实战

3.1 堆内存 OOM

/**
 * VM Args : -Xmx20m -Xms20m -XX:+HeapDumpOnOutOfMemoryError
 *  限制堆内存大小: -Xmx20m -Xms20m
 *  导出当前的内存堆转储快照: -XX:+HeapDumpOnOutOfMemoryError ,可以使用 MAT分析工具打开导出.hprof的文件进行分析
 */
public class HeadOOM {
    static class OOMObject{}
    public static void main(String[] args) {
        List<OOMObject> list=new ArrayList<>();
        while (true) {
            list.add(new OOMObject());
        }
    }
}

简单的分析步骤:

  • 首先先判断是内存泄漏(Memory Leak)还是内存溢出(Memory Overflow)
  • 如果是内存泄漏,可进一步通过工具查看泄漏对象到 GC Roots 的引用链,找到泄漏对象是通过怎样的引用路径、与哪些 GC Roots 相关联,才导致垃圾收集器无法回收它们
  • 否则,应当检查 Java 虚拟机的堆参数(-Xmx 与-Xms)设置,与机器的内存对比,看看是否还有向上调整的空间。再从代码上检查是否存在某些对象生命周期过长、持有状态时间过长、存储结构设计不合理等情况,尽量减少程序运行期的内存消耗。

注意:

  • 内存泄漏(Memory Leak):是指程序在申请内存后,无法释放已申请的内存空间,一次内存泄露危害可以忽略,但内存泄露堆积后果很严重,无论多少内存,迟早会被占光。最终导致内存溢出
  • 内存溢出(Memory Overflow):是指程序在申请内存时,没有足够的内存空间供其使用,出现 out of memory;比如申请了一个 integer,但给它存了 long 才能存下的数,那就是内存溢出。

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

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

/**
 * VM arg:  -Xss128k
 *
 * 测试:如果线程请求的栈深度大于虚拟机所允许的最大深度,将抛出StackOverflowError异常
 */
public class JavaVMStackSOF {

    private int stackLlength = 0;

    private void stackLeak() {
        this.stackLlength++;
        this.stackLeak();
    }
    public static void main(String[] args) {
        JavaVMStackSOF javaVMStackSOF=new JavaVMStackSOF();
        try {
            javaVMStackSOF.stackLeak();
        } catch (Throwable e) {
            System.out.println("栈长度 "+ javaVMStackSOF.stackLlength);
            throw e;
        }
    }
}

//输出
栈长度 1000
Exception in thread "main" java.lang.StackOverflowError
	at cn.hdj.jvm.memoryarea.JavaVMStackSOF.stackLeak(JavaVMStackSOF.java:13)
	at cn.hdj.jvm.memoryarea.JavaVMStackSOF.stackLeak(JavaVMStackSOF.java:14)

当扩展栈容量无法申请到足够的内存时

/**
 * VM arg:  -Xss128k
 *
 * 如果虚拟机的栈内存允许动态扩展,当扩展栈容量无法申请到足够的内存时,将抛出OutOfMemoryError异常。
 */
public class JavaVMStackSOF2 {

    private static int stackLlength = 0;

    private static void test(){
        long  unuse1, unuse2, unuse3, unuse4, unuse5, unuse6, unuse7, unuse8, unuse9, unuse10, unuse11, unuse12, unuse13, unuse14, unuse15, unuse16, unuse17, unuse18, unuse19, unuse20, unuse21, unuse22, unuse23, unuse24, unuse25, unuse26, unuse27, unuse28, unuse29, unuse30, unuse31, unuse32, unuse33, unuse34, unuse35, unuse36, unuse37, unuse38, unuse39, unuse40, unuse41, unuse42, unuse43, unuse44, unuse45, unuse46, unuse47, unuse48, unuse49, unuse50, unuse51, unuse52, unuse53, unuse54, unuse55, unuse56, unuse57, unuse58, unuse59, unuse60, unuse61, unuse62, unuse63, unuse64, unuse65, unuse66, unuse67, unuse68, unuse69, unuse70, unuse71, unuse72, unuse73, unuse74, unuse75, unuse76, unuse77, unuse78, unuse79, unuse80, unuse81, unuse82, unuse83, unuse84, unuse85, unuse86, unuse87, unuse88, unuse89, unuse90, unuse91, unuse92, unuse93, unuse94, unuse95, unuse96, unuse97, unuse98, unuse99, unuse100;
        stackLlength++;
        test();
        unuse1= unuse2= unuse3= unuse4= unuse5= unuse6= unuse7= unuse8= unuse9= unuse10= unuse11= unuse12= unuse13= unuse14= unuse15= unuse16= unuse17= unuse18= unuse19= unuse20= unuse21= unuse22= unuse23= unuse24= unuse25= unuse26= unuse27= unuse28= unuse29= unuse30= unuse31= unuse32= unuse33= unuse34= unuse35= unuse36= unuse37= unuse38= unuse39= unuse40= unuse41= unuse42= unuse43= unuse44= unuse45= unuse46= unuse47= unuse48= unuse49= unuse50= unuse51= unuse52= unuse53= unuse54= unuse55= unuse56= unuse57= unuse58= unuse59= unuse60= unuse61= unuse62= unuse63= unuse64= unuse65= unuse66= unuse67= unuse68= unuse69= unuse70= unuse71= unuse72= unuse73= unuse74= unuse75= unuse76= unuse77= unuse78= unuse79= unuse80= unuse81= unuse82= unuse83= unuse84= unuse85= unuse86= unuse87= unuse88= unuse89= unuse90= unuse91= unuse92= unuse93= unuse94= unuse95= unuse96= unuse97= unuse98= unuse99= unuse100=0;
    }
    public static void main(String[] args) {
        try {
            test();
        } catch (Throwable e) {
            System.out.println("栈长度 "+ stackLlength);
            throw e;
        }
    }
}

//输出
栈长度 52
Exception in thread "main" java.lang.StackOverflowError
	at cn.hdj.jvm.memoryarea.JavaVMStackSOF2.test(JavaVMStackSOF2.java:14)
	at cn.hdj.jvm.memoryarea.JavaVMStackSOF2.test(JavaVMStackSOF2.java:15)

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

常量池溢出

/**
 * 方法区和运行时常量池  OOM
 * 在JDK 6或更早之前的HotSpot虚拟机中,常量池都是分配在永久代中,
 * 使用-XX:PermSize 或 -XX:MaxPermSize参数限制永久的的大小,当超出-XX:MaxPermSize限制的大小时会抛出OOM
 * <p>
 * <p>
 * <p>
 * 自JDK7起,原本存放在永久代的字符串常量池被移至Java堆之中,
 * 所以在JDK 7及以上版本,限制方法区的容量对该测试用例来说是毫无意义的,只有限制堆的大小才会出现OOM
 * <p>
 * JVM  arg:
 * Java7 : -XX:PermSize=6m  -XX:MaxPermSize=6m
 * Java8 : -XX:MaxMetaspaceSize=6m
 */
public class RuntimeConstantPoolOOM {

    public static void main(String[] args) {
        //测试OOM
        Set<String> strings = new HashSet<>();
        Short i = 0;
        while (true) {
            strings.add(String.valueOf(i++).intern());
        }
    }
}

String.intern()返回引用的测试

以下运行环境为:openjdk jdk8u265

public class RuntimeConstantPoolOOM_intern {
    public static void main(String[] args) {
        // jdk8u265 中 sun.misc.Version#launcher_name  不是java 而是openjdk

        String str1 = new StringBuilder("计算机").append("软件").toString();
        System.out.println(str1.intern() == str1);  //true
        String str2 = new StringBuilder("open").append("jdk").toString();
        System.out.println(str2.intern() == str2);  //false
    }
}

先了解 intern 方法:

  • 在 JDK 6 中,intern()方法会把首次遇到的字符串实例复制到永久代的字符串常量池中存储,返回这个字符串实例在永久代存储的引用
  • 而在 JDK 7 中,intern()方法实现就不需要再拷贝字符串的实例到永久代了,既然字符串常量池已经移到 Java 堆中,那只需要在常量池里记录一下首次出现的实例引用
  • 还有所有的字符串字面量和字符串值常量表达式都会调用 intern,存入字符串常量池中

再简单分析:

  • 在调用 str1.intern()之前,通过 intern 方法的了解,字符串 "计算机软件" 还没有存入常量池中
  • 调用了 str1.intern()后,在 JDK7 及以后版本,会把首次出现的字符串实例引用,存入常量池中,并返回实例引用;所以,str1.intern() == str1 的比较结果为 true
  • 再看 str2 , 因为 sum.misc.Version 里面的静态常量 launcher_name 字段的值“openjdk”再 JVM 启动过程中被加载而存入常量池中了,导致在执行 str2.intern()时,不符合 intern()方法要求“首次遇到”的原则,所以 str2 的引用是堆里的字符串实例引用,str2.intern()则是常量池里的引用,str2.intern() == str2 的结果为 false
  • 进一步了解 https://www.zhihu.com/question/51102308/answer/124441115

方法区 OOM

依赖

  <dependency>
            <groupId>cglib</groupId>
            <artifactId>cglib</artifactId>
            <version>3.3.0</version>
        </dependency>
/**
 * 方法区OOM  -XX:MaxMetaspaceSize=12m
 *
 * -XX:MetaspaceSize 元空间初始值大小
 * -XX:MaxMetaspaceSize 元空间最大值大小,默认值为-1,不限制大小
 * -XX:MinMetaspaceFreeRatio  作用是在垃圾收集之后控制最小的元空间剩余容量的百分比,可减少因为元空间不足导致的垃圾收集的频率
 *
 *
 * 注意:
 * 1. 在经常运行时生成大量动态类的应用场景里,就应该特别关注这些类的回收状况,防止方法区OOM
 */
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{}
}

3.4 本机直接内存溢出

/**
 * 直接内存OOM
 *
 * VM args : -Xmx20m -XX:MaxDirectMemorySize=10
 *
 * 注意:
 * 程序中又直接或间接使用了DirectMemory(典型的间接使用就是NIO),那就可以考虑重点检查一下直接内存方面的原因了。
 */
public class DirectMemoryOOM {


    private final static int _1M = 1024*1024;

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

代码:cn.hdj.jvm.memoryarea 包中
https://github.com/h-dj/Java-Learning

参考

posted @ 2020-12-06 16:11  JiaJianHuang  阅读(130)  评论(0编辑  收藏  举报