JVM内存学习

1. JVM内存

  • 照例絮絮叨叨一下吧,虽然已经是一个工作狗了,其实自己对JVM的内存并不了解
  • 只是知道一些很凌乱的知识点:
    • 堆存放对象和数组,栈存放基本数据类型和对象的引用
    • 如果程序如果不能在递归中触底反弹,会出现StackOverflowError,就是所谓的栈溢出
    • 如果存在内存泄漏,积少成多使得堆内存不足,会出现OutOfMemoryError,就是所谓的OOM
  • 当维护的集群出现OOM异常时,自己拿到dump文件都不知道如何分析
  • 甚至,一度因为不懂GC而排斥去学习、解决与GC有关的问题

1.1 JDK1.6的JVM内存布局

  • JVM运行起来后,会从本机内存中申请一部内存,用于运行Java程序
  • JVM管理的内存可以划分为5大运行时数据区域:堆、方法区、虚拟机栈、本地方法栈、程序计数器
  • 除了JVM内存,JVM还会与直接内存打交道
  • 综合起来,JDK 1.6的JVM内存布局如下:

1.2 程序计数器

  • 程序计数器,又被翻译为PC寄存器,与计算机系统中PC寄存器有着相同的作用
  • 如果当前线程正在执行的是Java方法,则程序计数器记录的是虚拟机字节码指令的地址;如果当前线程正在执行的是native方法,则程序计数器的值为空(Undefined)

程序计数器为线程私有

  • 字节码解释器工作时,通过改变程序计数器的值来选取下一条需要执行的字节码指令,从而实现分支、循环、跳转、线程恢复等基础功能
  • Java多线程依靠CPU时间片实现,线程之间存在上下文切换
  • 为了线程获取CPU时间片后能恢复到正确的执行位置,每个线程都应该有自己的独立的程序计数器

程序计数器,是JVM个运行时数据区域中,唯一一个不会发生 OutOfMemoryError 情况的区域

1.3 Java虚拟机栈

  • Java虚拟机栈(简写为VM栈),同程序计数器一样也是线程私有,其生命周期与线程相同

VM栈为执行Java方法服务

  • Java方法在执行时,都会在VM栈创建一个栈帧用于存储局部变量表、操作数栈、动态链接、方法出口等信息
  • Java方法从开始执行到执行结束,对应着栈帧在VM栈中入栈和出栈

局部变量表

  • 最受程序员关注的是堆内存与栈内存,这两个内存区域与对象内存分配的关系最为密切
  • 所谓的栈内存,其实就是VM栈,更确切地说是VM栈中的局部变量表
  • 局部变量表中存放了编译期可知的基本数据类型、对象引用和returnAddress类型(指向一条字节码指令的地址)
  • 其中,对象引用为reference类型,可能为指向对象内存地址、对象的句柄或其他与对象相关的位置
  • 局部变量表所需的空间在编译时确定,执行Java方法时,栈帧中的局部变量表大小固定且不变的
  • 其中,long和double占据两个局部变量空间(slot),其余数据类型占据一个slot

VM栈的两种异常情况

  • 如果线程请求的栈深度超过虚拟机所允许的栈深度,将会抛出 StackOverflowError 异常
    • 所谓的栈深度,其实就是栈内存,这与当前线程方法调用的深度息息相关
    • 也就是说,线程请求的栈内存超过最大栈内存时,会栈溢出
    • 栈溢出,可能是栈内存太小,导致方法调用无法过深;也可能是已用的栈内存太大,导致无法增加方法的调用深度
  • 如果大量创建线程,新建线程时无法申请到足够的内存,将会抛出 OutOfMemoryError 异常

VM栈的配置参数:

  • 可以通过-Xss-XX:ThreadStackSize设置VM栈的大小,参数详情可以查看官方文档
    // 将栈大小设置为1MB
    -Xss1m
    -XX:ThreadStackSize=1m
    

1.4 本地方法栈

  • 本地方法栈为native方法服务
  • JVM规范并未规定本地方法栈中方法语言、使用方式与数据结构,不同JVM中本地方法栈的实现有所差异
  • HotSpot 虚拟机中,VM栈与本地方法栈合二为一
  • 同VM栈一样,本地方法栈也会出现StackOverflowError 和 OutOfMemoryError

1.5 Java堆

Java堆的特点

  • Java堆是所有线程共享的一块内存区域,在虚拟机启动时创建

  • 大多数Java应用程序,Java堆是JVM中最大的一块内存

  • 几乎所有的对象实例和数组都在堆上分配内存,因此,Java堆是垃圾回收主要区域,又称GC堆

    JVM规范的原文:对象实例和数组都在堆上分配内存

  • 但是由于JIT编译器的发展与逃逸分析技术逐渐成熟,栈上分配、标量替换优化技术使得对象实例可能不在堆上分配内存

Java堆的内存划分

  • Java堆可以处于物理上不连续的内存空间,只要逻辑上是连续的即可
  • 从内存回收的角度看:
    • 收集器基本都采用分代收集算法,Java堆可以划分为新生代、老年代
    • 新生代又被划分为Eden、From Survivor(简称S0)和To Survivor(简称S1)三大块
  • 从内存分配的角度看
    • 线程共享的Java堆可能划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer, TLAB),其存储的内容仍然是对象实例和数组
    • TLAB的划分是为了更好地回收内存、更快地分配内存

Java堆的异常

  • 当Java堆无法继续扩展且无足够内存去完成实例分配时,将会抛出OutOfMemoryError 异常,

Java堆的配置参数

  • 通过 -Xms 设置Java堆的初始值,与-XX:InitalHeapSize作用相同,其值必须为 MB的整数倍且大于1 MB

  • 通过-Xmx设置Java堆的最大值,与-XX:MaxHeapSize作用相同,其值必须为MB的整数倍且大于2 MB

    建议将堆内存的初始值和最大值设置为相同值,避免每次垃圾回收完成后JVM重新分配内存

  • 通过-Xmn同时设置新生代的初始值和最大值,初始值和最大值相等。若想分开设置,使用 -XX:NewSize设置新生代初始值, 使用-XX:MaxNewSize 设置新生代的最大值

  • 通过-XX:SurvivorRatio设置Eden和Survivor的大小比例,默认值为8,即 Eden:S0:S1 = 8:1:1

    老年代空间大小 = 堆内存大小 - 新生代空间大小,因此只需设置新生代空间大小即可影响老年代空间大小

  • JVM参数的配置示例:

    -Xms2048k
    -Xmx80m
    -Xmn48m
    -XX:SurvivorRatio=4
    

1.6 方法区

1.6.1 JDK 1.6中的方法区

  • 方法区与Java堆一样,属于线程共享的内存区域,用于存储已被JVM加载的类信息、常量、静态变量、JIT编译器编译后的代码(codeCache)

方法区与永久代的关系

  • 在HotSpot虚拟机中,为了能将GC分代收集扩展到方法去,使用永久代(Permanent Generation,简称PermGen)实现方法区
  • 其他JVM,如BEA JRockit、IBM J9等并没有永久代的概念
  • 永久代实现方法的方式在JDK 1.8中被移除,JDK 1.8中使用元空间(Metaspace)替代永久代
  • 总的来说,方法区是JVM的一种规范,而永久代是方法区的一种实现

永久代的垃圾回收

  • 方法区的内存空间可以在物理上不连续,可以设置初始值和最大值(或者说支持固定大小、可扩展),甚至可以不进行垃圾回收
  • 基于永久代的方法区实现,可以通过-XX:PermSize设置初始值,通过 -XX:MaxPermSize 设置最大值。
  • 永久代的不足:
    • 永久代存在最大值限制,因此更容易因为内存空间不足而触发OutOfMemoryError,具体错误信息为 OutOfMemoryError: PermGen space
    • 永久代内存回收的主要是针对常量池的回收和对类的卸载,但是这个区域的内存回收效果不是特别理想,容易因为回收不完全而出现内存泄漏,甚至进一步地导致内存溢出

1.6.2 JDK 1.7的方法区实现

  • 针对通过实现方法区存在问题,Oracle从JDK 1.7开始逐步移除永久代
  • JDK 1.7方法区实现变化:
    • 字符串字面量(interned strings),也就是将字符串常量池转移到了Java堆
    • 静态变量(class statics)转移到了Java堆
    • 符号引用(Symbols)转移到了 Native堆
  • JDK 1.7仍然保留了永久代的实现,只是将部分数据进行了转移到了Java堆或Native堆

1.6.3 JDK 1.8的方法区实现

  • JDK 1.8在JDK 1.7的基础上,使用元空间替代了永久代
  • 基于元空间的方法区实现,可以通过-XX:MetaspaceSize设置初始值,通过-XX:MaxMetaspaceSiz设置最大值
  • 从JDK 1.6到JDK 1.8,整个方法区的变化如下:
    在这里插入图片描述

1.6.4 通过代码体会方法区实现的变化

  • 通过学习我们知道,JDK 1.6及以前,字符串常量池位于方法区中;JDK 1.7开始,字符串常量池位于Java堆中

  • 执行如下代码,JDK 1.6和JDK 1.7中都会触发OutOfMemoryError,但是具体的错误信息将会有所不同

    import org.apache.commons.lang.RandomStringUtils;
    
    import java.util.ArrayList;
    import java.util.List;
    import java.util.Locale;
    
    public class GcTest {
        private final static int MB = 1024 * 1024;
        private final static int LEN = 12;
    
        public static void main(String[] args) {
            List<String> list = new ArrayList<>();
    
            String base = RandomStringUtils.random(1024, true, true).toUpperCase(Locale.ROOT);
            // 不停向字符串常量池中添加字符串字,直到内存溢出
            while (true) {
                String str = base + base; // 字符串长度不断增加
                base = str;
                // 将字符串添加到list中,避免因为gc使得字符串常量池中的字符串被回收,从而无法触发内存溢出
                // 同时,将字符串添加到字符串常量池
                list.add(str.intern());
            }
        }
    }
    
  • 在 src/main/java 目录下执行如下命令,运行Java程序(关于无法加载主类的问题,可以参考之前的博客:OOM JVM参数配置 —— dump文件的产生以及执行shell脚本

    java -XX:PermSize=8m -XX:MaxPermSize=8m -Xms16m -Xmx16m internet.gc.GcTest
    
  • JDK 1.6中,因为字符串常量池存储了大量无法被垃圾回收的字符串,导致永久代内存溢出
    在这里插入图片描述

  • JDK 1.7中,因为字符串常量池中存储了大量被垃圾回收的字符串,导致Java堆内存溢出
    在这里插入图片描述

  • 在JDK 1.8中,若使用同样的Java命令执行代码,首先会提示关于永久代的配置不起作用,其次;同JDK 1.7一样,将出现Java堆内存溢出的情况
    在这里插入图片描述

  • 将方法区的内存大小配置改为元空间的配置参数-XX:MetaspaceSize=8m -XX:MaxMetaspaceSize=8m,将提示8m的参数配置太小

  • 查看官方文档对参数的说明:-XX:MetaspaceSize的默认值取决于不同的平台 😂

1.6.5 运行时常量池

  • 上面的代码示例中,将新拼接出来的字符串通过 str.intern()被放入字符串常量池中
  • 其实,还可以认为字符串被放到了方法区的运行时常量池(Runtime constant Pool)中

运行时常量池 VS Class文件常量池

  • Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一个常量池(Constant Pool Table),用于存放编译期生成的各种字面量和符号引用
  • 类加载后,Class文件常量池中的内容将被加载到运行时常量池中
  • 运行时常量池具备动态特性:除了编译期生成的常量,还允许运行期间将新的常量放入常量池
    • String类的native方法 intern() 就具备这样的作用,可以将新的字符串字面加入到常量池中
      在这里插入图片描述

关于OutOfMemoryError

  • 《深入理解Java虚拟机(第2版)》一书中,这样描述运行时常量池的OutOfMemoryError

    既然运行时常量池是方法区的一部分,自然受到方法区内存的限制,当常量池无法再申请到内存时会抛出OutOfMemoryError异常

  • 个人理解,这句话在JDK 1.6的方法区实现中并没有任何问题
  • 因为,JDK1.6中方法区基于永久代实现的,此时字符串常量池和静态变量都还没有被移动到堆中
  • 但在JDK 1.7及以后,字符串常量池被移动到了堆中,运行时常量池是否整体移动尚待确认
  • 如果运行时常量池被移动到了堆中,那它的的OutOfMemoryError其实受限于Java堆内存的大小

1.7 直接内存

1.7.1 直接内存概述

  • JDK 1.4新加入的NIO(New Input/Output) 类,NIO采用一种基于通道(Channel)与缓冲区(Buffer)的I/O方式
  • NIO类可以使用native函数库直接分配堆外内存(即直接内存),然后通过Java堆中的DirectByteBuffer 对象作为这块内存的引用,从而操作这块内存
  • 直接内存避免了在Java堆和Native堆之间来回拷贝数据,在一些场景中可以显著提高性能
  • 同事的说法:避免I/O读写时数据需要在系统内存、用户内存之间重复存储和两次拷贝

直接内存的OOM

  • 直接内存不属于JVM运行时数据区域,也不是JVM规范定义的内存区域
  • 直接内存虽然不受Java堆的限制,但是会受到本机总内存的影响以及处理器寻址空间的限定
  • 如果只关注Java堆的设置,而忽略直接内存的大小设置,很容易使得Java程序各内存区域总和超过物理内存限制,从而在动态扩展时容易出现OutOfMemoryError

直接内存配置参数

  • 为了避免NIO通信时申请过多的直接内存,可以通过-XX:MaxDirectMemorySize限制直接内存的上限
  • 设置了直接内存的上限后,如果申请的直接内存超过上限,将触发OutOfMemoryError

1.7.2 直接内存的两种分配方法

方法一:通过ByteBuffer 类的allocateDirect() 方法,分配直接内存

  • 该方法将返回一个DirectByteBuffer对象,通过DirectByteBuffer对象可以操作直接内存

    public static void main(String[] args) {
        ByteBuffer byteBuffer = ByteBuffer.allocateDirect(1024 * 1024);
        System.out.println(byteBuffer.toString() );
    }
    
  • 从执行结果和源代码都可以看出,ByteBuffer 类的allocateDirect() 方法返回的实际是一个DirectByteBuffer对象
    在这里插入图片描述

    public static ByteBuffer allocateDirect(int capacity) {
        return new DirectByteBuffer(capacity);
    }
    
  • 注意:

    • DirectByteBuffer的构造函数中,实际调用Unsafe类的allocateMemory() 方法分配直接内存
    • DirectByteBuffer类,为包访问权限,通过ByteBuffer类的allocateDirect()获取的DirectByteBuffer对象,并不能赋值给DirectByteBuffer引用
    • DirectByteBuffer是间接继承ByteBuffer的

方法二:通过Unsafe类的native方法allocateMemory() ,分配直接内存

  • Unsafe类是一个单例类,其全局访问点限制只有当类加载器为SystemDomainLoader(引导类加载器)时,才会返回唯一实例theUnsafe

  • theUnsafe这个唯一实例的初始化,是放到静态语句块中的,也就是说Unsafe类为饿汉模式的单例

    public static Unsafe getUnsafe() {
        Class var0 = Reflection.getCallerClass();
        if (!VM.isSystemDomainLoader(var0.getClassLoader())) {
            throw new SecurityException("Unsafe");
        } else {
            return theUnsafe;
        }
    }
    
  • 在JDK源码外,通过 getUnsafe() 方法获取Unsafe,将会抛出SecurityException
    在这里插入图片描述

    public static void main(String[] args) {
            Unsafe unsafe = Unsafe.getUnsafe();
            unsafe.allocateMemory(1024 * 1024);
        }
    
  • 因此,只能通过反射破坏Unsafe类的单例模式,然后调用allocateMemory() 方法分配直接内存

    public static void main(String[] args) throws NoSuchFieldException, IllegalAccessException {
        Class unsafeClass = Unsafe.class;
        Field field = unsafeClass.getDeclaredField("theUnsafe");
        // 允许访问
        field.setAccessible(true);
        // 获取theUnsafe对象,然后分配直接内存
        Unsafe unsafe = (Unsafe) field.get(null);
        unsafe.allocateMemory(1024 * 1024);
    }
    

2. 实战:代码触发栈溢出或内存溢出

2.1 栈溢出

  • 在Hot Spot虚拟中,没有区分VM栈和本地方法栈,参数-Xss可以设置栈空间大小

  • 如果线程请求的栈深度大于栈所能允许的最大深度,将抛出StackOverflowError

  • 栈溢出一般都是由于方法调用层次太深导致

    • 每次方法调用都会向VM栈中push一个栈帧,如果方法调用的层次太深,线程私有的、空间有限的VM栈将会内存不足,从而JVM将抛出StackOverflowError
    • 方法调用层次较深,一般又是因为不正常的递归导致(不存在触底反弹)
  • 其实,StackOverflowError就是当前线程的栈空间不足

  • 下面的代码中,将递归调用func() 方法却未从调用中返回(不正常的递归),最终栈空间将不足

    public class GcTest {
        int stackDepth = 0;
    
        public void func() {
            stackDepth++;
            func();
        }
    
        public static void main(String[] args) {
            GcTest test = new GcTest();
            new Thread(() -> {
                try {
                    test.func();
                } catch (StackOverflowError error) {
                    System.out.println("线程" + Thread.currentThread().getName() + "栈溢出,方法调用深度为: " + test.stackDepth);
                }
            }, "thread-1").start();
        }
    }
    
  • 在IDE的运行配置中,通过-Xss8M设置栈空间为8M,在进行476705次递归调用时栈空间不足

2.2 栈内存溢出

  • 而栈内存溢出,一般都是因为新建线程时,系统没有足够的内存为线程分配栈,从而触发JVM的OutOfMemoryError异常

  • 系统内存除去Java堆、方法区占用的内存(程序计数器占用的内存可以忽略不接),剩下的就由VM栈和本地方法栈瓜分

  • 如果创建的线程过多,系统内存将被耗尽,JVM将抛出OutOfMemoryError

  • 下面的代码,将不停创建新的线程,从而需要不停地为新的线程分配栈内存

    public static void main(String[] args) {
        while (true) {
            new Thread(() -> {
                System.out.println("新建线程" + Thread.currentThread().getName());
                // 执行循环,让线程一直占用一定大小的栈空间
                while (true) {
                }
            }).start();
        }
    }
    
  • 不知什么原因,笔者将线程的VM栈都设置为256MB,堆内存初始和最大值都设置为6GB了,仍然创建了很多线程且并未触发OutOfMemoryError 😂

  • 盗信息错误信息一波:栈内存溢出,其实是系统无法新建线程

    Caused by: java.lang.OutOfMemoryError: unable to create new native thread
        at java.lang.Thread.start0(Native Method)
        at java.lang.Thread.start(Thread.java:597)
        at java.util.Timer.<init>(Timer.java:154)
    

2.3 堆内存溢出

  • 堆内存溢出,其实就非常简单了:创建的Java对象实例或数组过多,都将导致堆内存溢出

  • JDK 1.7开始,字符串常量池中字符串过多,也将导致堆内存溢出

  • 下面的代码将不停地创建OOMObject对象,最终导致Java堆内存溢出

    static class OOMObject {
    	private final int[] array = new int[1024];
    }
    public static void main(String[] args) {
        List<OOMObject> list = new ArrayList<>();
        while (true) {
            list.add(new OOMObject());
            System.out.println("成功创建对象OOMObject-" + list.size());
        }
    }
    
  • 当创建近3万个OOMObject对象后,JVM抛出Java堆内存溢出的错误信息
    在这里插入图片描述

  • 参考链接:由多线程内存溢出产生的实战分析

2.4 方法区内存溢出

  • 因为本人JDK版本的问题,方法区的内存溢出实质是元空间的内存溢出

  • 通过CGLib动态代理机制,动态生成的Class被加载进元空间,将导致元空间内存溢出

    static class OOMObject {
        private final int[] array = new int[1024];
    }
    
    public static void main(String[] args) {
        long count = 0;
        while (true){
            Enhancer enhancer = new Enhancer();
            enhancer.setSuperclass(OOMObject.class);
            enhancer.setUseCache(false);
            enhancer.setCallback((MethodInterceptor) (o, method, objects, methodProxy) -> methodProxy.invokeSuper(o, objects));
            enhancer.create();
            System.out.println("动态创建Class - " + (++count));
        }
    }
    
  • 将元空间大小设置为24MB,-XX:MetaspaceSize=24m -XX:MaxMetaspaceSize=24m

  • 运行程序,当创建大约2300个动态类后,将导致元空间内存溢出
    在这里插入图片描述

2.5 直接内存溢出

  • 通过-XX:MaxDirectMemorySize设置了直接内存的上限,如果Java程序分配过多的直接内存,JVM将抛出OutOfMemoryError

    public static void main(String[] args)  {
        List<ByteBuffer> list = new ArrayList<>();
        long count = 0;
        while (true){
            // 不停分配直接内存
            ByteBuffer byteBuffer = ByteBuffer.allocateDirect(1024 * 1024);
            list.add(byteBuffer);
            // 打印分配情况
            System.out.println("申请1MB的直接内存,其序号为:" + (++count));
        }
    }
    
  • 设置-XX:MaxDirectMemorySize=8m,运行程序

  • 当申请第9块1MB的直接内存时,超过直接内存的上限,JVM抛出OutOfMemoryError
    在这里插入图片描述

  • 注意: 《深入理解Java虚拟机(第二版)》中有这样一段话,是否正确待验证

    DirectByteBuffer分配内存也会抛出OOM异常,但是它抛出异常是并没有真正向操作系统申请分配内存,而是通过计算得知内存无法分配

3. 总结

内容总结

  • 以JDK 1.6的JVM(Hot Spot)的内存设计为基础:
    • 五大内存区域,线程共享的:方法区和Java堆,线程私有的:程序计数器、VM栈、本地方法栈
    • 每个内存区域存储的数据、参数配置、可能发生的异常(内存溢出或栈溢出)等
    • 重点:Java堆基于分代收集算法的划分以及TLAB、方法区从JDK 1.6到JDK 1.8的演变(永久代到元空间、某些数据的迁移)
  • 除此之外,堆外内存(又叫直接内存)的作用、申请直接内存的两种方法
  • 编写Java代码,配置JVM参数,模拟JVM的内存异常

JVM内存的相关参考链接

  • 1
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 5
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值