JVM学习

JVM结构

JVM(hotspot)结构概览如下图所示:

QQ20200303-134536@2x

上图中,灰色部分(Java栈,本地方法栈和程序计数器)是线程私有,不存在线程安全问题,橙色部分(方法区和堆)为线程共享区。

类加载器

类加载器(Class Loader)负责加载class文件,class文件在文件开头有特定的文件标识,将class文件字节码内容加载到内存中,并将这些内容转换成方法区中的运行时数据结构。ClassLoader只负责class文件的加载,至于它是否可以运行,则由执行引擎Execution Engine决定。类加载示意图:

QQ20200303-155915@2x

也就是说,类加载器识别的class文件除了是.class格式外,文件的开头还得有特殊的标识,使用文本编辑器打开一个class格式的文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
cafe babe 0000 0034 0010 0a00 0300 0d07
000e 0700 0f01 0006 3c69 6e69 743e 0100
0328 2956 0100 0443 6f64 6501 000f 4c69
6e65 4e75 6d62 6572 5461 626c 6501 0012
4c6f 6361 6c56 6172 6961 626c 6554 6162
6c65 0100 0474 6869 7301 0014 4c63 632f
6d72 6269 7264 2f63 6173 2f54 6573 743b
0100 0a53 6f75 7263 6546 696c 6501 0009
5465 7374 2e6a 6176 610c 0004 0005 0100
1263 632f 6d72 6269 7264 2f63 6173 2f54
6573 7401 0010 6a61 7661 2f6c 616e 672f
4f62 6a65 6374 0021 0002 0003 0000 0000
0001 0001 0004 0005 0001 0006 0000 002f
0001 0001 0000 0005 2ab7 0001 b100 0000
0200 0700 0000 0600 0100 0000 0300 0800
0000 0c00 0100 0000 0500 0900 0a00 0000
0100 0b00 0000 0200 0c

这个特定的标识就是十六进制字符cafe babe

类加载器分类

类加载器可以分为4种:

1.启动类加载器(BootstrapClassLoader)

启动类加载器BootstrapClassLoader也叫根加载器,是虚拟机自带的加载器,底层由C++实现,用于加载$JAVA_HOME/jre/lib/rt.jar包内的class文件。rt.jar是Java基础类库,包含Java运行环境所需的那些基础类:

QQ20200303-161919@2x

举个例子:

1
2
3
4
5
6
public class Test {
public static void main(String[] args) {
Object object = new Object();
System.out.println(object.getClass().getClassLoader());
}
}

Object为Java自带的类,运行结果如下:

1
null

并没有返回预期的BootstrapClassLoader,这是因为BootstrapClassLoader底层是由C++实现的,并非Java实现。

2.拓展类加载器(ExtClassLoader)

拓展类加载器ExtClassLoader是虚拟机自带的加载器,由Java语言实现,用于加载$JAVA_HOME/jre/lib/ext/**.jar目录下的class文件:

QQ20200303-162428@2x

比如:

1
2
3
4
5
6
public class Test {
public static void main(String[] args) {
ZipInfo zipInfo = new ZipInfo();
System.out.println(zipInfo.getClass().getClassLoader());
}
}

这部分主要是Java在迭代过程中,一些拓展的功能。

ZipInfo$JAVA_HOME/jre/lib/ext/zipfs.jar包里的一个类,程序运行结果如下:

1
sun.misc.Launcher$ExtClassLoader@5e2de80c

3.应用程序类加载器(AppClassLoader)

应用程序类加载器AppClassLoader是虚拟机自带的加载器,用于加载当前应用的classpath的所有类,也就是我们自己写的那些Java代码,比如:

1
2
3
4
5
public class Test {
public static void main(String[] args) {
System.out.println(Test.class.getClassLoader());
}
}

程序运行结果:

1
sun.misc.Launcher$AppClassLoader@18b4aac2

4.用户自定义加载器

除了使用上面三种JVM自带的类加载器外,我们也可以通过继承Java.lang.ClassLoader抽象类自定义一个类加载器。

这四种类加载器的关系如下图所示:

QQ20200303-165603@2x

它们的关系是一种父子关系,我们可以通过代码验证:

1
2
3
4
5
6
7
8
public class Test {
public static void main(String[] args) {
Class<Test> testClass = Test.class;
System.out.println(testClass.getClassLoader());
System.out.println(testClass.getClassLoader().getParent());
System.out.println(testClass.getClassLoader().getParent().getParent());
}
}

程序运行结果如下所示:

1
2
3
sun.misc.Launcher$AppClassLoader@18b4aac2
sun.misc.Launcher$ExtClassLoader@61bbe9ba
null

类加载步骤

类的加载过程分为三个步骤:

  1. 加载Loading

    通过一个类的全类名获取其二进制字节流,将这个二进制流代表的静态存储结构转化为方法区的运行时数据结构,然后在内存中生成一个代表这个类的java.lang.Class对象,作为方法区中这个类的各种数据的访问入口。

  2. 链接Linking(该过程又可以分为三个阶段:验证Verfication准备Preparation解析Resolution);

    • 验证阶段用于确保加载的Class文件的字节流包含的信息是否符合虚拟机要求,保证其正确性合法性;
    • 准备阶段为类变量(static修饰的变量)分配内存并根据对象类型设置相应的默认初始值(比如int类型为0,Integer类型为null)。这里不包含常量,因为常量在编译的时候分配,准备阶段会显示初始化。类的实例变量不会在这个阶段准备初始化,类变量分配在方法区中,而实例变量是随着对象创建一起分配到堆中的。
    • 解析阶段用于将符号引用转换为直接引用。

      观察如下代码:

      1
      2
      3
      4
      5
      6
      7
      public class Test {

      public static void main(String[] args) {
      String str = "hello";
      System.out.println(str);
      }
      }

      使用javap -v命令查看其字节码:

      QQ20200615-171009@2x

      可以看到常量池中有许多符号引用(比如#2),解析阶段就是将其解析为直接引用(比如#2表示字符串常量hello)的过程。

  3. 初始化Initialization

    • 该阶段就是执行类的构造器方法<clinit>()的过程 ;该方法并不是类的构造器,不需要我们自己定义,是javac编译器自动搜集类中的所有类变量的赋值动作和静态代码块中的语句合并而来;

      创建一个简单的类,包含一个名为aaa的类变量:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      public class Test {

      private static int aaa = 1;

      static {
      aaa = 200;
      }

      public static void main(String[] args) {
      System.out.println(Test.aaa);
      }
      }

      然后通过IDEA的jclasslib插件查看该类的class文件对应的字节码:

      QQ20200615-143009@2x

      可以看到上面所说的构造器方法<clinit>(),指令的操作就是为所有类变量赋值以及静态代码块中的操作。换句话说,如果一个类不包含类变量和静态代码块,那么它的字节码中就不会有构造器方法<clinit>()

    • 构造器方法中的指令按照语句在源代码中出现的顺序执行;

      观察如下代码:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      public class Test {

      private static int aaa = 1;

      static {
      aaa = 200;
      bbb = 300;
      }

      private static int bbb = 2;

      public static void main(String[] args) {
      System.out.println(Test.bbb);
      }
      }

      上面程序输出结果为2,因为构造器方法中的指令按照语句在源代码中出现的顺序执行,查看构造器方法<clinit>()指令来证明这一点:

      QQ20200615-143747@2x

      这里还有一个细节,就是为什么在静态代码块下面才定义的类变量bbb,在静态代码块中可以进行修改呢?这就是Linking阶段中准备阶段所做的事情,准备阶段为类变量(static修饰的变量)分配内存并根据对象类型设置相应的默认初始值。这个时候bbb已经被分配并赋予默认初始值了,所以static块中可以使用该变量(换句话说,实例变量不行)。

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      public class Test {

      private static int aaa = 1;

      static {
      aaa = 200;
      System.out.println(Test.bbb);
      bbb = 300;
      }

      private static int bbb = 2;

      public static void main(String[] args) {
      System.out.println(Test.bbb);
      }
      }

      上面输出0 2。

      下面代码直接编译失败:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      public class Test {
      private static int aaa = 1;

      static {
      aaa = 200;
      System.out.println(Test.bbb);
      bbb = 300;
      ccc = 400;
      }

      private static int bbb = 2;
      private int ccc = 3;

      public static void main(String[] args) {
      System.out.println(Test.bbb);
      }
      }

      QQ20200615-144259@2x

      因为ccc还没分配初始化呢。

    • 若该类包含父类,那么JVM会保证父类的<clinit>()先执行完毕;

    • 虚拟机会保证一个类的<clinit>()方法在多线程下被同步加锁。

      观察如下代码:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      public class Test {

      public static void main(String[] args) {
      Runnable r = () -> {
      System.out.println(Thread.currentThread().getName() + "开始");
      new Hello();
      System.out.println(Thread.currentThread().getName() + "结束");
      };

      new Thread(r, "线程1").start();
      new Thread(r, "线程2").start();
      }

      }

      class Hello {
      static {
      if (true) {
      System.out.println(Thread.currentThread().getName() + "初始化当前类");
      while (true) {

      }
      }
      }
      }

      程序启动后,main线程启动两个子线程,控制台输出如下:

      1
      2
      3
      线程1开始
      线程2开始
      线程1初始化当前类

      然后程序block住,这说明了虚拟机会保证一个类的<clinit>()方法在多线程下被同步加锁。

双亲委派机制

聊到类加载器不得不提的另一个话题就是双亲委派机制,在了解什么是双亲委派机制之前,我们先来看个例子:

在src/main/java目录下新建java.lang包,然后在该包下新建一个String类:

QQ20200303-170626@2x

String类的代码如下所示:

1
2
3
4
5
6
7
package java.lang;

public class String {
public static void main(String[] args) {
System.out.println("helo");
}
}

程序输出结果:

1
2
3
错误: 在类 java.lang.String 中找不到 main 方法, 请将 main 方法定义为:
public static void main(String[] args)
否则 JavaFX 应用程序类必须扩展javafx.application.Application

所谓的双亲委派机制就是:当一个类收到了类加载请求,他首先不会尝试自己去加载这个类,而是把这个请求委派给父类去完成,每一个层次类加载器都是如此。只有当父类加载器反馈自己无法完成这个请求的时候(在它的加载路径下没有找到所需加载的Class),子类加载器才会尝试自己去加载。

所以上面的例子中,AppClassLoader委派给它的父类ExtClassLoader去加载,ExtClassLoader又委托给它的父类BootstrapClassLoader去加载。BootstrapClassLoader从它的加载路径$JAVA_HOME/jre/lib/rt.jar下找到了java.lang.String类,即rt.jar包下的String类,而该类里并没有main方法,所以便抛出了如上异常。

采用双亲委派的一个好处是:就如上面所说,不管是哪个加载器加载这个类,最终都是委托给顶层的启动类加载器进行加载,这样就保证了使用不同的类加载器最终得到的都是同样一个String对象,所以我们自定义的Java类并不会污染JDK自带的那些类(即使全类名一样),这种保护机制也叫沙箱安全机制。

程序计数器

程序计数器(Program Counter Register)又叫PC寄存器。每个线程都有一个程序计数器,是线程私有的。它是一个指针,指向方法区中的方法字节码,用来存储指向下一条指令的地址,也即将要执行的指令代码,由执行引擎读取下一条指令,是一个非常小的内存空间,几乎可以忽略不记。

QQ20200615-183140@2x

如果执行的是一个Native方法,那这个计数器的值为undefied。

为什么需要程序计数器呢?因为CPU需要不停地切换各个线程,有了程序计数器后,当CPU切换回来后,我们就可以知道接着从哪开始继续执行程序,举个例子,现有如下代码:

1
2
3
4
5
6
7
8
9
public class Test {

public static void main(String[] args) {
int a = 1;
int b = 2;
int c = a + b;
System.out.println(c);
}
}

查看其字节码:

QQ20200615-184326@2x

假如当前线程的程序计数器存储的指令地址为6,这时候CPU切换到别的线程中处理工作;一段时间后,当前线程重新获取了CPU时间片继续执行时,根据程序计数器存的6就知道,当前需要执行iadd(即a+b操作)指令。执行引擎会将这条指令翻译为机器指令,然后CPU执行该运算操作。

虚拟机栈(Java栈)

虚拟机栈也称为Java栈,每个线程在创建的时候都会创建一个虚拟机栈,其内部保存一个个栈帧(Stack Frame),对应着一次次的Java方法调用。和PC寄存器一样,虚拟机栈的生命周期和线程一致。虚拟机栈主管Java程序的运行,它保存方法的局部变量(8种基本数据类型,对象引用地址)、部分结果,并参与方法的调用和返回。

虚拟机栈示意图如下所示:

QQ20200618-091650@2x

JVM对虚拟机栈的操作只有压栈(入栈)和出栈操作,遵循FILO原则;在一个活动线程中,一个时间点只会有一个活动的栈帧,即当前正在执行方法对应的栈帧(当前栈帧);如果一个方法调用了另一个方法,那么对应的新的栈帧将会被创建出来,放在栈顶,成为新的当前栈帧。

编写一个简单代码,使用debug的方式来观察入栈和出栈操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
public class Test {

public static void main(String[] args) {
new Test().method1();
}

public void method1() {
System.out.println("method1 start");
method2();
System.out.println("method1 finish");
}

private void method2() {
System.out.println("method2 start");
method3();
System.out.println("method2 finish");
}

private void method3() {
System.out.println("method3 start");
method4();
System.out.println("method3 finish");
}

private void method4() {
System.out.println("method4 start");
System.out.println("method4 finish");
}
}

2020-06-18 09.33.53.gif

可以看到,执行main方法后,main方法首先入栈,接着调用method1方法,method1方法入栈;method1调用method2,method2入栈;method2调用method3,method3入栈;method3调用method4,method4入栈;method4执行结束,正常退出,method4出栈;method4出栈后,当前栈帧变为method3对应的栈帧;method3执行结束正常退出,method3出栈……以此类推,最终main方法执行结束出栈,程序结束。

Java方法执行结束正常退出和抛出异常这两种情况会导致栈帧被弹出(退出)

虚拟机栈大小调整

Java虚拟机规范允许虚拟机栈的大小固定不变或者动态扩展。

  • 固定情况下:如果线程请求分配的栈容量超过Java虚拟机允许的最大容量,则抛出StackOverflowError异常;
  • 可动态扩展情况下:尝试扩展的时候无法申请到足够的内存;或者在创建新的线程的时候没有足够的内存去创建对应的虚拟机栈,则会抛出OutOfMemoryError异常。

不同平台的虚拟机栈默认大小不同:

  • Linux/x64 (64-bit): 1024 KB

  • macOS (64-bit): 1024 KB

  • Oracle Solaris/x64 (64-bit): 1024 KB

  • Windows: 默认值取决于虚拟内存。

我们可以通过-Xss设置虚拟机栈大小,默认单位为字节。也可以通过k或者K指定单位为KB,m或M指定单位为MB,g或G指定单位为GB。下面这组配置都是将虚拟机栈大小设置为1024KB:

1
2
3
-Xss1m
-Xss1024k
-Xss1048576

虚拟机栈越大,方法调用深度越深,举个例子:

1
2
3
4
5
6
7
8
9
public class Test {
private static int count = 1;

public static void main(String[] args) {
System.out.println(count);
count++;
main(args);
}
}

在macOS平台下,虚拟机栈的默认大小为1024KB,程序运行结果如下:

1
2
3
4
5
6
7
8
9
10
11
......
10817
10818
10819
10820
10821
10822
10823
10824
*** java.lang.instrument ASSERTION FAILED ***: "!errorOutstanding" with message transform method call failed at JPLISAgent.c line: 844
Exception in thread "main" java.lang.StackOverflowError

程序输出10824后抛出StackOverflowError;

我们通过-Xss200k命令将虚拟机栈大小调整为200KB再观察输出结果:

1
2
3
4
5
6
7
8
9
10
11
......
1214
1215
1216
1217
1218
1219
*** java.lang.instrument ASSERTION FAILED ***: "!errorOutstanding" with message transform method call failed at JPLISAgent.c line: 844
*** java.lang.instrument ASSERTION FAILED ***: "!errorOutstanding" with message transform method call failed at JPLISAgent.c line: 844
*** java.lang.instrument ASSERTION FAILED ***: "!errorOutstanding" with message transform method call failed at JPLISAgent.c line: 844
Exception in thread "main" java.lang.StackOverflowError

可以看到,方法调用深度明显变小了。

栈帧内部结构

每个栈帧包含5个组成部分:局部变量表(Local Variables)、操作数栈(Operand Stack)、动态链接(Dynamic Linking)、方法返回地址(Return Address)和一些附加信息:

QQ20200618-140408@2x

  1. 局部变量表:局部变量表是一个数字数组,用于存储方法参数和方法体内的局部变量。

    下面Test类包含hello静态方法:

    1
    2
    3
    4
    5
    6
    7
    public class Test {

    public static void hello(String name) {
    Date date = new Date();
    int count = 1;
    }
    }

    使用javap -v命令查看其字节码:

    QQ20200618-143216@2x

    非静态方法的局部变量表和静态方法相比,多了个this对象(即当前类):

    1
    2
    3
    4
    5
    6
    7
    public class Test {

    public void halo(String name) {
    Date date = new Date();
    int count = 1;
    }
    }

    QQ20200618-143527@2x

    可以看到,非静态方法的局部变量表首位就存放了this对象,这也是静态方法内无法使用this的原因(因为静态方法的局部变量表中没有this对象)。

    局部变量表数组容量的大小在编译器就可以唯一确定下来,并保存在方法的Code属性的maximum locacl variables数据项中,就拿上面Test类的hello方法来说,其字节码里已经指明了局部变量表的大小:

    QQ20200618-145018@2x

    通过jclasslib插件也可以看到局部变量表的大小:

    QQ20200618-145126@2x

    局部变量表的最基本单元是变量槽(Slot)。局部变量表中32位以内的数据类型(除long和double外)只占用一个slot,64位类型(long和double)占用两个slot。举个例子:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    public class Test {

    public static void hello(String name) {
    Date date = new Date();
    long number = 200L;
    double salary = 6000.0;
    int count = 1;
    }
    }

    QQ20200618-150204@2x

    此外,通过局部变量表包含的信息,我们还可以得出局部变量的作用范围。举个例子,当前有如下代码:

    QQ20200618-150846@2x

    查看其字节码:

    QQ20200618-151022@2x

    以方法参数name为例,查看LocalVariableTable,name参数对应的Start列的值为0,表示其在第0行字节码指令处生效(通过LineNumberTable我们可以知道,第0行字节码指令对应程序中的第6行代码);Length列的值为3,说明name参数的有效作用域长度为3,因为name是在第0行字节码指令处生效的,所以name在0 ~ 2行字节码指令范围内有效(通过LineNumberTable的对应关系,我们也可以知道name在我们的代码中作用域范围为第6行到第7行)。

    局部变量表的槽位是可以重复利用的,如果一个局部变量过了其作用域,那么在其作用域之后申明的新的局部变量很有可能会复用过期局部变量的槽位。举个例子,现有如下代码:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    public class Test {

    public static void hello(String name) {
    {
    int a = 1;
    System.out.println(a);
    }
    int b = 2;
    }
    }

    查看其局部变量表:

    QQ20200618-160223@2x

    可以看到局部变量a和b的槽位都是1,说明槽位重复利用了。这是因为在定义局部变量b的时候,局部变量a已经出了作用域失效销毁了,但是局部变量表的槽位已经开辟了,所以局部变量b直接重复利用索引为1的槽位。

  2. 操作数栈:每一个独立的栈帧中除了包含局部变量表外,还包含一个FILO的操作数栈,用于保存计算过程中的中间结果,同时作为计算过程中变量临时的存储空间。每个操作数栈都还有一个明确的深度,在编译期已经确定下来:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    public class Test {

    public int add() {
    int a = 1;
    int b = 1;
    int c = a + b;
    return c;
    }
    }

    查看其字节码:

    QQ20200618-165953@2x

    栈中的任何一个元素都可以是任意的Java数据类型,32bit的类型占用一个栈深度,64bit的类型占用两个栈单位深度:

    1
    2
    3
    4
    5
    6
    public class Test {

    public void test() {
    int a = 1;
    }
    }

    查看其字节码:

    QQ20200619-092727@2x

    操作数栈深度为1。

    将代码的局部变量a类型改为64bit的long类型:

    1
    2
    3
    4
    5
    6
    public class Test {

    public void test() {
    double b = 1.0;
    }
    }

    QQ20200619-093123@2x

    操作数栈深度为2。

    操作数栈在方法的执行过程中,根据字节码指令往栈中写入数据或提取数据,即入栈和出栈操作。虽然栈是用数组实现的,但根据栈的特性,对栈中数据访问不能通过索引,而是只能通过标准的入栈和出栈操作来完成一次数据访问。

    下面通过一个例子来感受PC寄存器,局部变量表和操作数栈是如何相互配合完成一次方法的执行,代码如下所示:

    1
    2
    3
    4
    5
    6
    7
    8
    public class Test {

    public void add() {
    int a = 15;
    int b = 1;
    int c = a + b;
    }
    }

    在查看字节码指令之前,先记录下几个入栈出栈的字节码指令含义:

    • 当int取值 -1 ~ 5 采用iconst指令入栈;
    • 取值 -128 ~ 127(byte有效范围)采用bipush指令入栈;
    • 取值 -32768 ~ 32767(short有效范围)采用sipush指令入栈;
    • 取值 -2147483648 ~ 2147483647(int有效范围)采用ldc指令入栈;
    • istore,栈顶元素出栈,保存到局部变量表中;
    • iload,从局部变量表中加载数据入栈。

    更多字节码指令含义后续深入学习Java虚拟机字节码指令再说🌚。

    上面方法对应的字节码如下:

    QQ20200619-100036@2x

    指令执行过程中,PC寄存器,局部变量表和操作数栈状态如下图所示:

    QQ20200619-102230@2x

    QQ20200619-102507@2x

    QQ20200619-102909@2x

    QQ20200619-103226@2x

    QQ20200619-103556@2x

    QQ20200619-103957@2x

    QQ20200619-104225@2x

    QQ20200619-104405@2x

    如果被调用的方法带有返回值的话,其返回值会被压入当前栈帧的操作数栈中。

  3. 动态链接:在Java源文件被编译成字节码文件时,所有的变量和方法引用都作为符号应用保存在class文件的常量池里,动态链接的作用就是为了将这些符号引用转换为调用方法的直接引用,比如:

    1
    2
    3
    4
    5
    public class Test {
    public void hello() {
    System.out.println("hello");
    }
    }

    其字节码中的常量池如下:

    QQ20200619-141303@2x

    比如符号#27就表示为一个打印流。

  4. 方法返回地址:存放调用该方法的pc寄存器的值。一个方法的结束分为以下两种方式:

    • 正常执行结束;
    • 出现未处理异常,非正常退出。

    无论是哪种方式退出,在方法退出后都返回到该方法被调用的位置。方法正常退出时,调用者的pc寄存器的值作为返回地址,即调用该方法的指令的下一条指令地址;异常退出时,返回地址需要通过异常表来确定。

  5. 一些附加信息:比如对程序调式提供的支持信息。

本地方法接口

本地方法接口(Native Interface)的作用是融合不同的编程语言为Java所用,它的初衷是融合C/C++程序。Java诞生的时候是 C/C++横行的时候,要想立足,必须有调用 C/C++程序,于是就在内存中专门开辟了一块区域处理标记为native的代码。

比如查看java.lang.Thread类中存在许多native方法:

QQ20200623-164809@2x

native方法没有方法体(因为不是Java实现),所以看上去像是“接口”一样,故得名本地方法接口。

本地方法栈

如前所述,虚拟机栈用于管理Java方法的调用,而本地方法栈则是用于管理本地方法的调用。

方法区

方法区并不是所谓的存储方法的区域,而是供各线程共享的运行时内存区域。它存储了每一个类的结构信息(类加载器通过加载class文件所创建的类的模板,包含运行时常量池、字段和方法数据、构造函数和普通方法的字节码内容)。

方法区也是一种规范,在不同虚拟机里头实现是不一样的,最典型的实现就是Java8之前的永久代(PermGen space)和Java8的元空间(Metaspace)。

实例变量存在堆内存中,和方法区无关。在JDK7.0版本,字符串常量池从方法区中移到了堆中了。

堆(Heap)一个JVM实例只存在一个堆内存,堆内存的大小是可以调节的。堆中保存着所有引用类型的真实信息,以方便执行器执行。堆在逻辑上分为三个区域:

Java7:

QQ20200305-103335@2x

Java8:

QQ20200305-103619@2x

可以看到,在Java7时代,堆分为新生区(新生区包含伊甸园区和幸存区,幸存区又包含幸存者0区和幸存者1区。此外,幸存者0区又称为From区,幸存者1区又称为To区),养老区和永久代;在Java8中,永久代已经被移除,被一个称为元空间的区域所取代。元空间的本质和永久代类似。

元空间与永久代之间最大的区别在于:永久带使用的JVM的堆内存,但是java8以后的元空间并不在虚拟机中而是使用本机物理内存(所以在上图中,我用虚线表示)。

下面通过一个例子来讲述这几个区的交互逻辑:

1.任何新的对象都是在伊甸园区被new出来创建,刚开始的时候两个幸存者区和养老区都是空的:

QQ20200305-105423@2x

2.随着对象的不断创建,伊甸园区空间逐渐被填满:

QQ20200305-110220@2x_meitu_2.jpg

3.这时候将触发一次Minor GC(Young GC),删除未引用的对象,GC剩下来的还存在引用的对象将移动到幸存者0区,然后清空伊甸园区:

QQ20200305-110720@2x

4.随着对象的创建,伊甸园区空间又满了,再一次触发Minor GC,删除未引用的对象,留下存在引用的对象。这次和上一次Minor GC有些不同,这轮GC留下的对象将被移动到幸存者1区,并且上一轮GC留下来的存储在幸存者0区的对象年龄递增并移动到幸存者1区。当所有幸存对象都移动到幸存者1区后,幸存者0区和伊甸园区空间清除:

QQ20200305-111503@2x

5.随着对象的创建伊甸园区空间再一次满了,触发了第三次Minor GC,这一次幸存区空间将发生互换,GC留下来的幸存者将移动到幸存者0区,幸存者1区的幸存对象年龄递增后也移动到幸存者0区,然后伊甸园区和幸存者1区的空间被清除:

QQ20200305-112210@2x

6.随着Minor GC的不断发生,幸存对象在两个幸存区不断地交换存储,年龄也不断递增。如此反反复复之后,当幸存对象的年龄达到指定的阈值(这个例子中是8,由JVM参数MaxTenuringThreshold决定)后,它们将被移动到养老区:

QQ20200305-112448@2x

7.随着上述过程的不断出现,当养老区快满时,将触发Major GC(Full GC)进行养老区的内存清理。若养老区执行了GC之后发现依然无法进行对象的保存,就会产生OOM异常。

堆参数

以JDK1.8+HotSpot为例,常用的可调整的堆参数有:

参数含义
-Xms设置堆的初始内存大小,默认为物理内存的1/64
-Xmx设置堆的最大内存大小,默认为物理内存的1/4
-Xmn设置堆新生代的内存大小
-XX:MaxTenuringThreshold设置转入养老区的存活次数
-XX:Newratio设置新生区和养老区的比例,比如值为2,则养老区是新生区的2倍,即养老区占据堆内存的2/3
-XX:Newsize设置新生区的初始值大小
-XX:Maxnewsize设置新生区的最大值大小
-XX:Surviorratio设置伊甸园区和一个幸存区的比例,比如值为5则表示伊甸园区占新生区的5/7(两个幸存区是一样大的)

生产环境中,推荐将-Xms和-Xmx设置为一样大。此外,要在程序中输出详细的GC处理日志,可以使用-XX:+PrintGCDetails

比如,我的电脑内存为32GB,所以堆的默认初始值大小为500MB左右,堆的最大值大约为8000MB左右:

1
2
3
4
5
6
7
8
9
public class Test {
public static void main(String[] args) {
long maxMemory = Runtime.getRuntime().maxMemory();
long totalMemory = Runtime.getRuntime().totalMemory();

System.out.println("堆内存的初始值" + totalMemory / 1024 / 1024 + "mb");
System.out.println("堆内存的最大值" + maxMemory / 1024 / 1024 + "mb");
}
}

程序输出:

1
2
堆内存的初始值491mb
堆内存的最大值7282mb

可以通过IDEA调整堆的大小:

QQ20200305-142031@2x

我们将堆内存的初始大小和最大值都设置为10mb,并且开启GC日志打印,重新运行下面这段程序:

1
2
3
4
5
6
7
8
9
public class Test {
public static void main(String[] args) {
long maxMemory = Runtime.getRuntime().maxMemory();
long totalMemory = Runtime.getRuntime().totalMemory();

System.out.println("堆内存的初始值" + totalMemory / 1024 + "kb");
System.out.println("堆内存的最大值" + maxMemory / 1024 + "kb");
}
}

输出如下所示:

1
2
3
4
5
6
7
8
9
10
11
堆内存的初始值9728kb
堆内存的最大值9728kb
Heap
PSYoungGen total 2560K, used 1388K [0x00000007bfd00000, 0x00000007c0000000, 0x00000007c0000000)
eden space 2048K, 67% used [0x00000007bfd00000,0x00000007bfe5b370,0x00000007bff00000)
from space 512K, 0% used [0x00000007bff80000,0x00000007bff80000,0x00000007c0000000)
to space 512K, 0% used [0x00000007bff00000,0x00000007bff00000,0x00000007bff80000)
ParOldGen total 7168K, used 0K [0x00000007bf600000, 0x00000007bfd00000, 0x00000007bfd00000)
object space 7168K, 0% used [0x00000007bf600000,0x00000007bf600000,0x00000007bfd00000)
Metaspace used 2947K, capacity 4496K, committed 4864K, reserved 1056768K
class space used 320K, capacity 388K, committed 512K, reserved 1048576K

可以看到,PSYoungGen(新生区)的总内存大小为2560k,ParOldGen(养老区)的总内存大小为7168k,总和刚好是9728K,这也说明了:Java8后的堆物理上只分为新生区和养老区,Metaspace(元空间)不占用堆内存,而是直接使用物理内存。

再举个OOM的例子,使用刚刚-Xms10m -Xmx10m -XX:+PrintGCDetails的设置,运行下面这段程序:

1
2
3
4
5
6
7
8
public class Test {
public static void main(String[] args) {
String value = "hello";
while (true) {
value += value + new Random().nextInt(1000000000) + new Random().nextInt(1000000000);
}
}
}

输出如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
[GC (Allocation Failure) [PSYoungGen: 1893K->491K(2560K)] 1893K->597K(9728K), 0.0007246 secs] [Times: user=0.01 sys=0.00, real=0.00 secs] 
[GC (Allocation Failure) [PSYoungGen: 2207K->496K(2560K)] 2313K->1153K(9728K), 0.0008383 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
[GC (Allocation Failure) [PSYoungGen: 2007K->496K(2560K)] 2664K->1897K(9728K), 0.0009456 secs] [Times: user=0.01 sys=0.00, real=0.01 secs]
[GC (Allocation Failure) [PSYoungGen: 2021K->496K(2560K)] 4894K->4113K(9728K), 0.0010814 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
[GC (Allocation Failure) [PSYoungGen: 1359K->496K(2560K)] 6448K->5600K(9728K), 0.0015792 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
[GC (Allocation Failure) [PSYoungGen: 496K->496K(1536K)] 5600K->5600K(8704K), 0.0006416 secs] [Times: user=0.01 sys=0.00, real=0.00 secs]
[Full GC (Allocation Failure) [PSYoungGen: 496K->0K(1536K)] [ParOldGen: 5104K->2585K(7168K)] 5600K->2585K(8704K), [Metaspace: 2982K->2982K(1056768K)], 0.0044783 secs] [Times: user=0.02 sys=0.00, real=0.01 secs]
[GC (Allocation Failure) [PSYoungGen: 61K->192K(2048K)] 7061K->7192K(9216K), 0.0012566 secs] [Times: user=0.01 sys=0.00, real=0.00 secs]
[Full GC (Ergonomics) [PSYoungGen: 192K->0K(2048K)] [ParOldGen: 7000K->1840K(7168K)] 7192K->1840K(9216K), [Metaspace: 3042K->3042K(1056768K)], 0.0072023 secs] [Times: user=0.02 sys=0.00, real=0.01 secs]
[GC (Allocation Failure) [PSYoungGen: 65K->160K(2048K)] 6321K->6416K(9216K), 0.0022603 secs] [Times: user=0.02 sys=0.00, real=0.00 secs]
[Full GC (Ergonomics) [PSYoungGen: 160K->0K(2048K)] [ParOldGen: 6256K->4785K(7168K)] 6416K->4785K(9216K), [Metaspace: 3076K->3076K(1056768K)], 0.0056740 secs] [Times: user=0.03 sys=0.00, real=0.01 secs]
[GC (Allocation Failure) [PSYoungGen: 0K->0K(2048K)] 4785K->4785K(9216K), 0.0003871 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
[Full GC (Allocation Failure) [PSYoungGen: 0K->0K(2048K)] [ParOldGen: 4785K->4765K(7168K)] 4785K->4765K(9216K), [Metaspace: 3076K->3076K(1056768K)], 0.0049903 secs] [Times: user=0.02 sys=0.00, real=0.00 secs]
Heap
PSYoungGen total 2048K, used 59K [0x00000007bfd00000, 0x00000007c0000000, 0x00000007c0000000)
eden space 1024K, 5% used [0x00000007bfd00000,0x00000007bfd0efb8,0x00000007bfe00000)
from space 1024K, 0% used [0x00000007bfe00000,0x00000007bfe00000,0x00000007bff00000)
to space 1024K, 0% used [0x00000007bff00000,0x00000007bff00000,0x00000007c0000000)
ParOldGen total 7168K, used 4765K [0x00000007bf600000, 0x00000007bfd00000, 0x00000007bfd00000)
object space 7168K, 66% used [0x00000007bf600000,0x00000007bfaa77b8,0x00000007bfd00000)
Metaspace used 3113K, capacity 4496K, committed 4864K, reserved 1056768K
class space used 339K, capacity 388K, committed 512K, reserved 1048576K
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
at java.util.Arrays.copyOf(Arrays.java:3332)
at java.lang.AbstractStringBuilder.ensureCapacityInternal(AbstractStringBuilder.java:124)
at java.lang.AbstractStringBuilder.append(AbstractStringBuilder.java:674)
at java.lang.StringBuilder.append(StringBuilder.java:208)
at cc.mrbird.Test.main(Test.java:19)

可以看到,经过数次的GC和Full GC后,堆内存还是无法腾出空间,最终抛出OOM错误。日志的含义如下图所示:

Young GC(Minor GC):

QQ20200305-145255@2x

Full GC(Major GC):

QQ20200305-171134@2x

参考文章:

https://www.oracle.com/webfolder/technetwork/tutorials/obe/java/gc01/index.html

https://docs.oracle.com/en/java/javase/11/tools/java.html

0