JVM学习

JVM结构

JVM结构概览如下图所示:

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

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

在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自带的那些类(即使全类名一样),这种保护机制也叫沙箱安全机制。

本地方法接口&&本地方法栈

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

程序计数器

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

这块内存区域很小,它是当前线程所执行的字节码的行号指示器,字节码解释器通过改变这个计数器的值来选取下一条需要执行的字节码指令。

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

方法区

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

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

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

Java栈

Java栈(Java Stack)也叫栈内存,负责Java程序的运行,是在线程创建时创建,它的生命期是跟随线程的生命期,线程结束栈内存也就释放,所以对于栈来说不存在垃圾回收问题,只要线程一结束该栈就结束。8种基本类型的变量+对象的引用变量+实例方法都是在函数的栈内存中分配。

栈中的数据都是以栈帧(Stack Frame)的格式存在,栈帧是一个内存区块,是一个数据集,是一个有关方法(Method)和运行期数据的数据集,栈帧中主要保存3 类数据:

  1. 本地变量(Local Variables):输入参数和输出参数以及方法内的变量;

  2. 栈操作(Operand Stack):记录出栈、入栈的操作;

  3. 栈帧数据(Frame Data):包括类文件、方法等等。

栈运行的主要过程:

当一个方法A被调用时就产生了一个栈帧F1,并被压入到栈中,A方法又调用了B方法,于是产生栈帧F2也被压入栈,B方法又调用了C方法,于是产生栈帧F3也被压入栈。F3执行完毕后,先弹出F3栈帧,再弹出F2栈帧,再弹出F1栈帧,遵循先进后出,后进先出(FILO)原则。

比如:

1
2
3
4
5
6
7
8
9
10
11
public class Test {
public static void method1(){
System.out.println("method1");
}

public static void main(String[] args) {
System.out.println("11111");
method1();
System.out.println("22222");
}
}

程序输出:

1
2
3
11111
method1
22222

上面例子中,main线程开始执行后,对应的栈运行开始:main方法首先入栈,输出了11111,然后调用method1方法,method1方法入栈。此时位于栈底的main方法必须等待栈顶的method1方法执行完毕才能退出,所以程序输出method1后再输出22222。

栈空间是有限的,如果方法不停的递归调用入栈,栈空间将会被“压爆”,抛出java.lang.StackOverflowError栈溢出错误:

1
2
3
4
5
6
7
8
9
public class Test {
public static void method1(){
method1();
}

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

堆(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倍,即养老区占据堆内存的1/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

请作者喝瓶肥宅水🥤

0