Java虚拟机 -- 运行时数据区之方法区

193 阅读12分钟

文章目录


在这里插入图片描述


1. 概述

JVM中的方法区可看作时独立于Java堆的一块内存空间,它存在的目的就是希望和堆分开。方法区具有如下的特点:

  • 线程共享
  • 在JVM启动时就被创建,在JVM关闭时内存被释放
  • 实际的物理内存空间可以是不连续的
  • 空间大小可固定,也可动态扩展
  • 方法区的大小决定了系统可以保存的类的个数,如果系统定义了太多的类,导致方法区溢出,JVM会抛OOM异常

如何体会方法区在程序运行中所起到的作用呢?下面我们通过一个例子先简单的感受一下。假设定义的Student类为:

public class Student {
    private int age;
    private String name;

    public Student() {
    }

    public Student(int age, String name) {
        this.age = age;
        this.name = name;
    }
    
	// getter and setter
	
    @Override
    public String toString() {
        return "Student{" +
                "age=" + age +
                ", name='" + name + '\'' +
                '}';
    }
}

通常使用Student stu = new Student(18, "Forlogen")进行对象的实例化,如果将其和栈、堆、方法区联系起来可以简单的画为:
在这里插入图片描述

如上所示,使用new实例化的对象保存在堆中,Student的对象变量stu保存在栈中,它对应的是对象在堆中的地址;而栈中保存的是Student类的类型信息等其他相关的内容。

在JDK7及以前,方法区也称为永久代,而JDK8及之后,运行时数据区中只有元空间(Meta Space),不再有永久代。元空间的本质和永久代类似,它们都是JVM规范中方法区的实现方式。不过元空间和永久代的最大区别在于:元空间使用的不再是JVM的内存空间,而是直接使用本地内存。


2. 参数设置

前面讲到方法区的特点中提到,方法区的大小不必是固定的,它也可以根据应用的需求动态扩展。有关方法区大小设置的参数为:

  • JDK7及之前

    • -XX:PermSize用来设置永久代初始分配空间,默认为20.75M
    • -XX:MaxPermSize用来设置永久代最大可分配空间,默认是64M(32bits)或是82M(63bits)

    当JVM加载的类信息容量超过了MaxPermSize,JVM就会抛出OutOfMemoryError:PermGen space错误。

  • JDK8及之后

    • -XX:MetaSpaceSize用来设置元空间初始分配空间,默认为21M

    • -XX:MaxMetaSpaceSize用来设置元空间最大可分配空间,默认为-1,表示没有限制

      因为元空间使用的是直接内存,因此只有当耗尽所有的系统内存时,JVM才会抛出OutOfMemoryError:Metaspace错误。

    对于MetaSpaceSize默认的值的设定来说,当所用空间触及这个值时,Full GC就会被触发并卸载没用的类,然后重置该值。新的值取决于GC后释放的空间大小,如果释放的空间不足,则在不超过MaxMetaSpaceSize的前提下,设当提高该值;如果释放的空间过多,则适当降低该值。

    如果MetaSpaceSize默认的值太小,那么Full GC将会多次被触发。因此,通常选择将它设置为一个较高的值。


3. 内部结构

在这里插入图片描述

当一个ClassLoader启动时,生存地点在堆中,然后它将A.class装载到JVM的方法区。方法区的这个字节码文件被用来创建对象。这个字节码文件中有两个引用:

  • 一个用于指向A的class类对象:它存储了这个字节码内存块所有的相关信息,例如可以使用this.getClass().getDeclaredMethods()获取方法信息;使用this.getClass().getDeclaredFields()获取字段信息等
  • 一个指向加载自己的ClassLoader:例如可以使用this.getClass().getClassLoader()获取类对应的类加载器

此外,方法区还用于存储已被虚拟机加载的类型信息、常量、静态常量、即时编译器编译后的代码缓存等。

3.1 类型信息

对于每个加载的类型(类class、接口interface、枚举enum、注解annotation),JVM必须在方法区中存储以下的类型信息:

  • 这个类型的完整有效名称(全类名)
  • 这个类型直接父类的完整有效名(对于interface或是java.lang.Object,都没有父类)
  • 这个类型的修饰符(public、abstract、final的某个子集)
  • 这个类型直接接口的一个有序列表
3.2 域信息

JVM 必须在方法去中保存类型的所有域的相关信息以及域的声明顺序。域的相关信息包括:域名称、域类型、域修饰符(public、private、protected、static、final、volatile、transient的某个子集)。

3.3 方法信息

JVM必须保存所有方法的以下信息,同样需要保存声明顺序:

  • 方法名称
  • 方法的返回类型
  • 方法参数的数量和类型
  • 方法的修饰符
  • 方法的字节码、操作数栈、局部变量表及大小(abstract和native方法除外)
  • 异常表(abstract和native方法除外):每个异常处理的开始位置、结束位置、代码处理在程序计数器中的偏移地址、被捕获的异常类的常量池索引
3.4 non-final的类变量

静态变量和类关联在一起,随着类的加载而加载,它们成为类数据在逻辑上的一部分。类变量被类的所有实例共享,即使没有类实例时也可以访问。

3.5 例子

代码如下所示:

public class MethodAreaTest {
    public static void main(String[] args) {
        Order order = new Order();
        System.out.println(order.count);
    }
}

class Order {
    public static int count = 1;
    public static final int number = 2;


    public static void hello() {
        System.out.println("hello!");
    }
}

我们编译上面的代码,然后在命令行使用javap -v -p MethodAreaTest.class > test.txt命令获取类对应字节码文件反编译后的结果,主要内容如下所示:

Compiled from "MethodAreaTest.java"
public class MethodArea.MethodAreaTest
  minor version: 0
  major version: 52
  flags: (0x0021) ACC_PUBLIC, ACC_SUPER
  this_class: #7                          // MethodArea/MethodAreaTest
  super_class: #8                         // java/lang/Object
  interfaces: 0, fields: 0, methods: 2, attributes: 1
Constant pool:
   #1 = Methodref          #8.#24         // java/lang/Object."<init>":()V
   #2 = Class              #25            // MethodArea/Order
   #3 = Methodref          #2.#24         // MethodArea/Order."<init>":()V
   #4 = Fieldref           #26.#27        // java/lang/System.out:Ljava/io/PrintStream;
   #5 = Fieldref           #2.#28         // MethodArea/Order.count:I
   #6 = Methodref          #29.#30        // java/io/PrintStream.println:(I)V
   #7 = Class              #31            // MethodArea/MethodAreaTest
   #8 = Class              #32            // java/lang/Object
   #9 = Utf8               <init>
  #10 = Utf8               ()V
  #11 = Utf8               Code
  #12 = Utf8               LineNumberTable
  #13 = Utf8               LocalVariableTable
  #14 = Utf8               this
  #15 = Utf8               LMethodArea/MethodAreaTest;
  #16 = Utf8               main
  #17 = Utf8               ([Ljava/lang/String;)V
  #18 = Utf8               args
  #19 = Utf8               [Ljava/lang/String;
  #20 = Utf8               order
  #21 = Utf8               LMethodArea/Order;
  #22 = Utf8               SourceFile
  #23 = Utf8               MethodAreaTest.java
  #24 = NameAndType        #9:#10         // "<init>":()V
  #25 = Utf8               MethodArea/Order
  #26 = Class              #33            // java/lang/System
  #27 = NameAndType        #34:#35        // out:Ljava/io/PrintStream;
  #28 = NameAndType        #36:#37        // count:I
  #29 = Class              #38            // java/io/PrintStream
  #30 = NameAndType        #39:#40        // println:(I)V
  #31 = Utf8               MethodArea/MethodAreaTest
  #32 = Utf8               java/lang/Object
  #33 = Utf8               java/lang/System
  #34 = Utf8               out
  #35 = Utf8               Ljava/io/PrintStream;
  #36 = Utf8               count
  #37 = Utf8               I
  #38 = Utf8               java/io/PrintStream
  #39 = Utf8               println
  #40 = Utf8               (I)V
{
  public MethodArea.MethodAreaTest();
    descriptor: ()V
    flags: (0x0001) ACC_PUBLIC
    Code:
      stack=1, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."<init>":()V
         4: return
      LineNumberTable:
        line 6: 0
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       5     0  this   LMethodArea/MethodAreaTest;

  public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: (0x0009) ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=2, args_size=1
         0: new           #2                  // class MethodArea/Order
         3: dup
         4: invokespecial #3                  // Method MethodArea/Order."<init>":()V
         7: astore_1
         8: getstatic     #4                  // Field java/lang/System.out:Ljava/io/PrintStream;
        11: aload_1
        12: pop
        13: getstatic     #5                  // Field MethodArea/Order.count:I
        16: invokevirtual #6                  // Method java/io/PrintStream.println:(I)V
        19: return
      LineNumberTable:
        line 8: 0
        line 9: 8
        line 10: 19
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0      20     0  args   [Ljava/lang/String;
            8      12     1 order   LMethodArea/Order;
}
SourceFile: "MethodAreaTest.java"

4. 运行时常量池

运行时常量池位于方法区,常量池位于字节码文件中。一个有效的字节码文件中除了包含类的版本信息、字段、方法以及接口等描述信息外,还包含一项信息那就是常量池表(constant pool table),包括各种字面量和对类型、域和方法的符号引用。

4.1 常量池

一个Java源文件中的类、接口在编译后会产生一个字节码文件,而Java中的字节码需要数据支持。通常这种数据会很大,以至于不能直接存到字节码文件中。而是选择换一种方式,将其存到常量池中。这个字节码包含了指向常量池的引用,在动态链接时会使用到运行时常量池。

常量池中包含了以下的几类信息:

  • 数量值
  • 字符串值
  • 类引用
  • 字段引用
  • 方法引用

总之,常量池可以看做是一张表,虚拟机指令根据这张表来找到要执行的类名、方法名、参数类型和字面量等信息。

4.2 运行时常量池

运行时常量池(Runtime Constant Pool)是方法区的一部分。前面说到的常量池是字节码文件的一部分,它用于存放编译期生成的各种字面量和符号引用,而这部分内容将在类加载后存放到方法区的运行时常量池中。

JVM会为每一个已加载的类型(类或接口)维护一个常量池,池中的数据项像数组项一样,可以通过索引进行访问。运行时常量池中包含多种不同的常量,包括编译期就已经明确的数值字面量,也包括到运行期解析后才能够获得的方法或是字段引用,此时不再是常量池中的符号地址,而是转换后的真实地址。

当创建类或接口的运行时常量池时,如果构造运行时常量池所需的内存空间超过了方法区所能提供的最大值,则JVM会抛出OOM异常。


5. 演进过程

永久代只存在于HotSpot虚拟机的JDK7极其之前的版本,JDK8和之后的版本以及其他类型的虚拟机中并没有永久代的概念。

针对于HotSpot虚拟机来说,方法区经过了持续的演变,主要过程为:

JDK1.6及之前有永久代,静态变量存放在永久代上
JDK1.7有永久代,但已经逐步“去永久代”,字符串常量池、静态变量保存在堆中
JDK1.8及之后无永久代,类型信息、字段、方法、常量保存在本地内存的元空间,但字符串常量池和静态变量仍在堆中

那么,为什么要去永久代,或是为什么要适用元空间来代替永久代呢?主要的原因有:

  • 为永久代设置空间大小是很难确定:某些场景下,如果动态加载的类过多,容易产生永久代区的OOM。元空间并不在虚拟机中,而是使用本地内存。因此,默认情况下元空间的大小仅受本地内存限制
  • 对永久代进行调优很困难

JDK1.7及之后将字符串常量池转移到了堆中,这是为什么呢?前面提到,对于永久代的垃圾回收效率很低,在Full GC的时候才会触发。而只有在老年代的空间不足时,Full GC才会被触发,永久代不足并不是触发的条件。这就导致了字符串常量的回收效率不高,而在实际的使用中又会大量的用到字符串。因此如果回收效率低,将导致永久代内存不足,而将其放到堆中能做到及时回收。


6. 垃圾收集

对于方法区的垃圾收集,Java虚拟机规范并没有做强制性的要求。如果要进行方法区的垃圾收集,它主要回收两部分内容:

  • 常量池中废弃的常量
  • 不再使用的类型

对于HotSpot虚拟机来说,只要常量池中的常量没有被任何其他的地方引用,就可以被回收。而判断一个类型是否属于不再被引用的类需要满足三个条件:

  • 该类的所有实例都已经被回收,即Java堆中不存在该类及其任何派生子类的实例
  • 加载该类的类加载器已经被回收
  • 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法

7. 使用案例

下面通过例子来看一下方法区在程序运行过程中是如何被使用的。假设代码如下所示:

public class MethodAreaDemo {
    public static void main(String[] args) {
        int x = 500;
        int y = 100;
        int a = x / y;
        int b = 50;
        System.out.println(a + b);
    }
}

将其编译后再反编译得到对应的字节码指令为:

 0 sipush 500
 3 istore_1
 4 bipush 100
 6 istore_2
 7 iload_1
 8 iload_2
 9 idiv
10 istore_3
11 bipush 50
13 istore 4
15 getstatic #2 <java/lang/System.out>
18 iload_3
19 iload 4
21 iadd
22 invokevirtual #3 <java/io/PrintStream.println>
25 return

然后我们通过图解的方法看一下每条指令的执行过程,如下所示:

  • 程序中包含args、x、y、a和b四个变量,其中args存放在局部变量表的0号位置。执行int x = 500;,对应0号指令,将500压入操作数栈
    在这里插入图片描述

  • 执行3号指令,将栈顶的500存到局部变量表

    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-vlFY2wfW-1590974664592)(D:\project\work\Java\Java虚拟机\幻灯片29.PNG)]

  • 执行4号指令,将100压栈

    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-PZiwKRgu-1590974664594)(D:\project\work\Java\Java虚拟机\幻灯片30.PNG)]

  • 执行6号指令,将栈顶的100 存入局部变量表

    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-5FzzCpcA-1590974664595)(D:\project\work\Java\Java虚拟机\幻灯片31.PNG)]

  • 执行7号和8号指令,分别从局部变量表中读取序号为1和2的元素,并将它们压入操作数栈
    在这里插入图片描述
    在这里插入图片描述

  • 执行9号和10号指令,两数相除,并将结果压入操作数栈,最后将栈顶元素存入局部变量表
    在这里插入图片描述在这里插入图片描述

  • 执行11和12号指令,将50压栈,然后再存放到局部变量表
    在这里插入图片描述
    在这里插入图片描述

  • 执行15号指令,获取类或接口字段的值,并将其压栈
    在这里插入图片描述

  • 执行18、19、21号指令,取数并执行相加操作,将结果压栈,最后取栈顶元素存放到局部变量表
    在这里插入图片描述
    在这里插入图片描述
    在这里插入图片描述

  • 执行22号指令

在这里插入图片描述

  • 最后执行25号指令,void函数返回,main方法执行结束
    在这里插入图片描述