方法区

方法区

概述

  • 定义:虚拟机中独立于堆空间的较大存储空间

  • 作用:[存放所有和 类相关的信息 ]{.red}

  • 实现方式:

    • 永久代(PermSpace):[方法区使用的虚拟机的内存]{.red}
    • 元空间(MetaSpace):[方法区直接使用操作系统提供的物理内存]{.red}

    +++danger 为什么要采用元空间实现方法区而不是永久代呢?

    ① 官方文档中的解释非常模糊:因为被收购的 JRockit 采用的元空间实现方法区,所以整合 HotSpot 和 JRockit 虚拟机时也采用元空间实现

    ② 实际的原因应该是这样的:

    永久代使用的是虚拟机内存,操作系统分配给虚拟机内存是非常有限的,只要方法区加载过多的类,就非常容易造成 OutOfMemoryError 异常

    元空间直接使用操作系统的内存,只要加载的类的数量不超过操作系统的内存大小就行,显然没有那么容易出现 OutOfMemoryError 异常

    +++

  • 历史:

    • [JDK 6:方法区采用永久代实现,静态变量和字符串常量池都存放在方法区中]{.blue}

    • [JDK 7:方法区依旧采用永久代实现,静态变量和字符串常量池移动到堆空间中]{.blue}

    • [JDK 8:方法区改用元空间实现,静态变量和字符串常量池依旧存放在堆空间中]{.blue}

      :::info

      注:从 JDK 7 开始就打算逐渐采用元空间替代永久代了

      :::

  • 特点:

    • 方法区和堆区都不需要连续的内存空间,都可以采用固定容量或者动态扩展
    • [方法区是线程共享区域]{.red}
    • [方法区 存在垃圾回收机制,也存在 OutOfMemoryError 异常]{.red}
    • [方法区有两种具体的实现方式:永久代和元空间]{.red}

存储信息

方法区中存储的类型信息是从哪里获得的?

  • 过程:
    • 前端编译器将源代码编译成字节码文件
    • 虚拟机利用 IO 流读取字节码文件
    • 虚拟机利用 [字节码文件和运行时信息]{.blue} 构建 Klass 对象存储类型信息

方法区中的存储的是什么?

:::warning

如果不了解 OOP-Klass 模型的读者,请先了解后再继续阅读

:::

  • 核心:[Klass 对象]{.red}

    • 每个 Klass 对象包含了 [类相关的字节码信息和运行时的信息]{.blue}

    • 字节码文件存储的信息仅是方法区中信息的一部分

      其余信息必须等到类被加载到虚拟机中之后才能够得到(诸如类加载器信息,虚方法表)

每个 Klass 对象又包含什么信息呢?

  • 图示
JDK-6-方法区
  • 类相关信息

    • 类型信息:

      • 类的全限定名:包名 + 类名(java.lang.Object)
      • 类的修饰符:访问权限、是否静态(static)、是否可变(final)
      • 继承的父类:父类的名称也必须使用全限定名
      • 实现的所有接口:接口的名称也必须是全限定名
    • 字段信息:字段的名称 + 字段的类型 + 字段的修饰符

    • 静态变量:静态变量的名称 + 静态变量的类型 + 静态变量的访问权限

      • [静态变量在没有对象的情况下依旧可以访问]{.green}

        public class MethodComponent
        {
        // 实例变量
        private int count;
        // 静态变量
        private static String string = "初始值";
        // 测试静态变量
        public static void main(String[] args)
        {
        // 即使对象为 null 依旧是可以访问静态变量的
        MethodComponent methodComponent = null;
        System.out.println(methodComponent.string);
        }
        }
      • [如果静态变量是引用类型的话,引用存放在方法区中,对象存放在堆区中]{.blue}

    • 方法信息:方法签名 + 方法返回值 + 方法访问权限 + 方法参数 + 局部变量表 + 操作数栈大小 + 异常信息表

    • [方法表]{.red}

      • [虚方法表:存放该类自身的和继承的所有虚方法以实现方法分派]{.blue}
      • [接口方法表:存放该类实现的所有接口提供的方法以实现方法分派]{.blue}
      • [生成时间:方法表在类加载阶段被初始化(连接阶段)]{.blue}
    • [类加载器]{.red}

      • 定义:记录当前类是被哪个类加载器加载进入虚拟机的
      • 生成时间:[类加载器的相关信息也是在类加载阶段被初始化的(加载阶段)]{.blue}
    • [Class 对象的引用]{.red}

      • 定义:保留对 Class 对象的引用确保反射能够找到 Class 对象

        [对象中保存是的类元数据(Klass 对象)的指针,只有类元数据中记录 Class 对象的引用,反射才能够找到 Class对象]{.blue}

  • 运行时常量池:字面量(字符串常量池、其余常量)、符号引用

    • [每个 Klass 对象都拥有一个运行时常量池,也就意味每个类都拥有独立的运行时常量池]{.red}
    • [非字符串类型的常量的值会在编译期间就写入字节码文件中,所以认为常量在编译期间就被赋值]{.red}
  • 即时编译生成的代码缓存

上述提到的信息真的都存放在方法区中吗?

  • JDK 6 版本之前方法区确实按照这些信息进行存放

  • JDK 7 版本将 [静态变量、字符串常量池全部移动到堆空间中]{.red}

    JDK-7-方法区
  • JDK 8 版本没有变动静态变量、字符串常量池的位置,[只是改用元空间实现]{.red}

    JDK-8-方法区

+++danger 为什么要将字符串常量池移动到堆区中呢?

+++

方法区大小

  • 采用永久代实现的方法区
    • 方法区初始大小:20.75 MB
    • 方法区最大容量:32位虚拟机最大容量为 64MB,64位虚拟机最大容量为 82MB
    • 设置方法区大小的命令:
      • [XX:PermSize=size 更改方法区的初始大小值]{.blue}
      • [XX:MaxPermSize=size 更改方法区的最大容量]{.blue}
    • 细节:
      • [方法区的设置的最大容量不可以超过虚拟机的最大内存]{.red}
      • 方法区存放的类信息的大小超过了方法区的最大容量,就会抛出 OutOfMemoryError 异常
  • 采用元空间实现的方法区
    • 元空间初始大小:21 MB([取决于不同的操作系统]{.red})
    • 元空间最大容量:[默认没有上限值,取决于操作系统提供的物理内存大小]{.red}
    • 设置方法区大小的命令
      • [XX:MetaspaceSize=size 更改方法区的初始大小值]{.blue}
      • [XX:MaxMetaspaceSize=size 更改方法区的最大容量]{.blue}
    • 细节:[每次方法区触发垃圾回收机制时,都会根据释放的空间大小来动态地改变方法区的大小]{.red}
      • [如果垃圾回收释放的空间较少,那么方法区的容量将会提升]{.blue}
      • [如果垃圾回收释放的空间较多,那么方法区的容量就会相应减少]{.blue}

方法区溢出

  • 原因:内存泄露或者内存溢出

  • 内存泄露:[不再使用的类或者被废弃的常量没有得到回收]{.red}

  • 内存溢出

    • 情况:① 加载大量的类(Jar 包)② 服务器(Tomcat)部署工程过多 ③ 反射/动态代理生成太多的类

    • 测试:

      • [加载的类的数量过多造成的溢出]{.red}

        • 自定义类加载器加载大量类

          测试代码

          // 虚拟机参数:
          // JDK 7:-XX:PermSize=10m -XX:MaxPermSize=10m
          // JDK 8:-XX:MetaspaceSize=10m -XX:MaxMetaspaceSize=10m
          // 关闭指针压缩:-XX:-UseCompressedClassPointers
          // 该类继承类加载器:所以该类自身就是个类加载器了
          public class MethodArea extends ClassLoader
          {
          public static void main(String[] args)
          {
          // 加载大量类使得方法区溢出
          MethodArea methodArea = new MethodArea();
          for (int i = 0; i < 100000; i++)
          {
          ClassWriter classWriter = new ClassWriter(0);
          // 获取 Object 的字节码文件
          classWriter.visit(Opcodes.V1_6, Opcodes.ACC_PUBLIC, "Class" + i, null, "java/lang/Object", null);
          // 将 Object 的字节码文件转换成字节数组保存
          byte[] bytes = classWriter.toByteArray();
          // 将字节数组转换成 Class 对象, 方法区中会生成对应的 Klass 对象
          methodArea.defineClass("Class"+i, bytes, 0, bytes.length);
          // Klass 对象数量过多导致方法区溢出, Class 对象是存放在堆区中的
          }
          }
          }

          测试结果

          JDK 8 版本:如果不关闭指针压缩,抛出的异常就是 Compressed class space,指针压缩都没有办法省出空间了

          元空间溢出

          JDK 7 版本:① 需要更换运行环境 ② 不需要手动关闭压缩指针 ③ 记得更换虚拟机命令 ④ ClassWriter 类需要重新导入(所属包不一样)

          永久代溢出
        • 反射/动态代理加载的类过多

          测试代码:可以了解下代理模式(动态代理 + 静态代理)

          public class MethodArea
          {
          public static void main(String[] args)
          {
          // 测试生成的代理类数量
          int 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(++count);
          }
          }

          static class OOMObject{
          }
          }

          测试结果

          动态生成溢出
    • [运行时常量池造成的溢出]{.blue}

      :::warning

      运行时常量池造成的溢出仅会在 JDK 6 之前的版本出现

      :::

垃圾回收

方法区进行垃圾回收的前提

  • 前提:[虚拟机规范中没有明确规定方法区必须实现垃圾回收机制]{.green}
    • 大多数的垃圾回收器都支持对方法区进行垃圾回收
    • 部分垃圾回收器([如 ZGC 垃圾回收器]{.red})不支持方法区的垃圾回收

为什么最好支持对方法区进行垃圾回收呢?

  • 核心:[避免对方法区造成过大的内存压力从而导致的内存泄露或者内存溢出]{.red}
    • 许多采用反射和动态代理的框架,以及频繁自定义类加载器框架中会使得大量类被加载进入虚拟机
      • 采用反射和动态代理的框架:Spring、Mybatis
      • 频繁自定义类加载器的框架:Tomcat、OSGi
    • 大量的类被加载进入虚拟机而无法被卸载的话就容易造成 OutOfMemoryError 异常

方法区垃圾回收的对象以及条件

  • 回收对象:[常量池中废弃的各种类型的常量和不再使用的类]{.red}
  • 回收条件
    • 常量回收条件:[常量不再被任何地方引用]{.red}
    • 类回收条件:
      • [该类所有的实例及其派生子类的实例全部都被回收完成]{.red}
      • [加载该类的类加载器已经被回收(非常难以达成)]{.red}
      • [该类对应的 Class 对象已经被回收,无法再使用反射获取类信息]{.red}
    • 细节:
      • [没有被引用的常量可以立刻被回收]{.red}
      • [满足回收条件的类仅仅只是允许被回收,具体是否回收取决于虚拟机自身]{.red}

运行时常量池

什么是运行时常量池?

  • 定义:存放编译期生成的各种字面量和符号引用的池子
    • 字节码文件将所有常量存放在 [常量池]{.red} 中
    • 字节码文件被加载进入虚拟机后,虚拟机将常量池单独提取出来成为运行时常量池
  • 特点:[具有动态性:可以在进程运行期间将常量添加进入运行时常量池(String.intern())]{.red}

运行时常量池存放哪些常量呢?

  • 字面量

    • 基本数据类型声明的变量

    • [采用非创建对象的方式声明的字符串]{.red}

      // 基本数据类型常量
      private static final int first = 10;
      private static final float second = 10;
      private static final double third = 10;
      private static final short fourth = 10;
      // 字符串字面量: 字符串本身就是常量
      private String str = "初始值";
      // 采用对象的创建方式, 字符串就不是字面量了
      private String strObj = new String("另一个初始值");
  • 符号引用 & 直接引用

    • 符号引用:

      • 定义:[表示类型信息和名称的 字符串]{.red}

      • 表示内容:

        • 类和接口的全限定名称
        • 字段的名称和类型
        • 方法的名称和类型
      • 实例测试

        测试代码

        public class SymbolReferrence
        {
        public void methodA(){
        methodB();
        }

        public void methodB(){
        }
        }

        字节码(methodA 方法)

        0 aload_0
        // #2 是符号引用所在的索引,在常量池中找到对应的符号引用
        1 invokevirtual #2
        4 return

        常量池(省略了每条索引之后的注释)

        // 1. 根据字节码指令提供的索引,在常量池中找到对应的符号引用
        #2 = Methodref #3.#21
        // 2. 该符号引用告诉我们去找另外两个索引 #3 #21
        #3 = Class #22
        #21 = NameAndType #13:#6
        // 3. 上面两个符号索引告诉我们接着找其他的索引
        // 4. #22 被解析出来了,对应的就是方法所在的类的名字
        #22 = Utf8 chapter09/SymbolReferrence
        // 5 #13 #6 也被相应的解析出来了,方法的返回值和名称
        #6 = Utf8 ()V
        #13 = Utf8 methodB

        完整流程

        符号引用

        总结:符号引用就是用来表示类、方法、字段、接口的相关信息的字符串

      • 细节

        • 虚拟机是无法直接利用符号引用找到类或者方法在内存中的具体的位置,必须解析成直接引用
        • [符号引用可以在两个阶段被解析:类加载的解析阶段,方法执行时的动态链接阶段]{.red}
    • 直接引用:

      • 定义:[类、方法、字段、接口在虚拟机内存中的实际地址]{.red}
      • 细节:
        • 符号引用解析成的直接引用会直接替换原有的符号引用,存在方法区中
        • 虚拟机能够直接使用的地址,用于访问类、方法、字段、接口的相关信息
      解析过程

    :::primary

    参考:JVM里的符号引用如何存储?

    :::

为什么需要运行时常量池呢?

  • 核心:[避免字节码膨胀:减小字节码文件的大小]{.red}
  • 原因:
    • 如果每个类在被编译的时候都把自己继承的类和实现的接口的详细信息都写入字节码
    • 那么显然就会导致 [字节码的容量非常大]{.red},因为继承的类和实现的接口也有自己相应的继承实现关系
    • 为了避免这个问题字节码膨胀,显然将用符号引用来代替表示继承的类和实现的接口是非常合适的,不需要记录详细信息
    • 那么既然使用了符号引用,显然需要一块空间来存储符号引用,所以采用运行时常量池来存储符号引用
Author: Fuyusakaiori
Link: http://example.com/2021/09/21/jvm/runtime/methodarea/方法区/
Copyright Notice: All articles in this blog are licensed under CC BY-NC-SA 4.0 unless stating additionally.