JVM深入理解-内存物理结构&类加载

背景

对于JVM这块儿的知识,我估计大部分的都是只有在需要面试的时候才会拿出来复习一下,然后就又放下来。也是因为这块儿是Java最底层的部分,非常难懂。其实如果真的说认真、细心的去撸一下,了解透彻,应该就不会那么容易忘记。

今天的主要目的也是根据Oracle的官方文档来一步一步的理解与学习,并且用用一些demo来验证理论。

Java虚拟机内存结构

我们先来看一下JVM一个大概的物理结构图(请注意,不叫内存模型):

http://static.cyblogs.com/QQ截图20191121231823.png

堆的划分

我们首先看一下官方地址对于运行时数据区域的一个划分:https://docs.oracle.com/javase/specs/jvms/se8/html/index.html

2.5. Run-Time Data Areas

堆存放:对象、数组 (官方证明: The heap is the run-time data area from which memory for all class instances and arrays is allocated. )

方法区

方法区存放:静态成员变量、常量、类的信息、常量池 (官方证明: It stores per-class structures such as the run-time constant pool, field and method data, and the code for methods and constructors, including the special methods (§2.9) used in class and instance initialization and interface initialization.)

Java虚拟机栈

然后我们看一段代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 方法一
public void a(){
System.out.println("this is a methd");
b();
}
// 方法二
private void b() {
System.out.println("this is b methd");
c();
}
// 方法三
private void c() {
System.out.println("this is c methd");
}

简单画一个方法压栈与出栈的过程:

http://static.cyblogs.com/WX20200130-133454@2x.png

其实在这就可以看到,因为有相互调用的情况,这里利用栈的原理(FILO=frist in last out)。

  • a()入栈发现调用了b()b()入栈发现调用c()
  • c()执行完毕出栈,然后b()出栈,最后c()方法出栈;

如果一直压栈的话,如果是无穷的递归会怎么办?所以栈是需要规定深度的,对应的就是栈的大小-Xss来控制的,如果是超过大小是会OOM的。

The following exceptional conditions are associated with native method stacks:

  • If the computation in a thread requires a larger native method stack than is permitted, the Java Virtual Machine throws a StackOverflowError.
  • If native method stacks can be dynamically expanded and native method stack expansion is attempted but insufficient memory can be made available, or if insufficient memory can be made available to create the initial native method stack for a new thread, the Java Virtual Machine throws an OutOfMemoryError.
本地方法栈

执行本地方法,native方法属于C的方法。 (官方证明:An implementation of the Java Virtual Machine may use conventional stacks, colloquially called “C stacks,” to support native methods (methods written in a language other than the Java programming language).)

程序计数器

因为在多线程的情况下,CPU通过轮询来去提高执行效率,线程之间会进行切换。如果从离开到下一次再进来,一定要知道上一次的一个状态。所以它应该是来干这件事情的。

(官方证明:The Java Virtual Machine can support many threads of execution at once (JLS §17). Each Java Virtual Machine thread has its own pc (program counter) register. )

类加载过程

类从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期包括:加载、验证、准备、解析、初始化、使用和卸载七个阶段。其中类加载的过程包括了加载、验证、准备、解析、初始化五个阶段。在这五个阶段中,加载、验证、准备和初始化这四个阶段发生的顺序是确定的,而解析阶段则不一定,它在某些情况下可以在初始化阶段之后开始,这是为了支持Java语言的运行时绑定(也成为动态绑定或晚期绑定)。

http://static.cyblogs.com/WX20200130-123334@2x.png

加载

加载时类加载过程的第一个阶段,在加载阶段,虚拟机需要完成以下三件事情:

  • 1、通过一个类的全限定名来获取其定义的二进制字节流。

  • 2、将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。

  • 3、在Java堆中生成一个代表这个类的java.lang.Class对象,作为对方法区中这些数据的访问入口。

注意,这里第1条中的二进制字节流并不只是单纯地从Class文件中获取,比如它还可以从Jar包中获取、从网络中获取(最典型的应用便是Applet)、由其他文件生成(JSP应用)等。这里也就是Java的开放之处,给程序员更多的选择。

说到加载,不得不提到类加载器,下面就具体讲述下类加载器。

类加载器虽然只用于实现类的加载动作,但它在Java程序中起到的作用却远远不限于类的加载阶段。对于任意一个类,都需要由它的类加载器和这个类本身一同确定其在就Java虚拟机中的唯一性,也就是说,即使两个类来源于同一个Class文件,只要加载它们的类加载器不同,那这两个类就必定不相等。这里的“相等”包括了代表类的Class对象的equals()、isAssignableFrom()、isInstance()等方法的返回结果,也包括了使用instanceof关键字对对象所属关系的判定结果。

http://static.cyblogs.com/类加载器.jpg

  • 启动类加载器:Bootstrap ClassLoader,它负责加载存放在JDK\jre\lib(JDK代表JDK的安装目录,下同)下,或被-Xbootclasspath参数指定的路径中的,并且能被虚拟机识别的类库(如rt.jar,所有的java.*开头的类均被Bootstrap ClassLoader加载)。启动类加载器是无法被Java程序直接引用的。
  • 扩展类加载器:Extension ClassLoader,该加载器由sun.misc.Launcher$ExtClassLoader实现,它负责加载JDK\jre\lib\ext目录中,或者由java.ext.dirs系统变量指定的路径中的所有类库(如javax.*开头的类),开发者可以直接使用扩展类加载器。
  • 应用程序类加载器:Application ClassLoader,该类加载器由sun.misc.Launcher$AppClassLoader来实现,它负责加载用户类路径(ClassPath)所指定的类,开发者可以直接使用该类加载器,如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。

这种层次关系称为类加载器的双亲委派模型。我们把每一层上面的类加载器叫做当前层类加载器的父加载器,当然,它们之间的父子关系并不是通过继承关系来实现的,而是使用组合关系来复用父加载器中的代码。

验证

验证的目的是为了确保Class文件中的字节流包含的信息符合当前虚拟机的要求,而且不会危害虚拟机自身的安全。不同的虚拟机对类验证的实现可能会有所不同,但大致都会完成以下四个阶段的验证:文件格式的验证、元数据的验证、字节码验证和符号引用验证。

  • 文件格式的验证:验证字节流是否符合Class文件格式的规范,并且能被当前版本的虚拟机处理,该验证的主要目的是保证输入的字节流能正确地解析并存储于方法区之内。经过该阶段的验证后,字节流才会进入内存的方法区中进行存储,后面的三个验证都是基于方法区的存储结构进行的。
  • 元数据验证:对类的元数据信息进行语义校验(其实就是对类中的各数据类型进行语法校验),保证不存在不符合Java语法规范的元数据信息。
  • 字节码验证:该阶段验证的主要工作是进行数据流和控制流分析,对类的方法体进行校验分析,以保证被校验的类的方法在运行时不会做出危害虚拟机安全的行为。
  • 符号引用验证:这是最后一个阶段的验证,它发生在虚拟机将符号引用转化为直接引用的时候(解析阶段中发生该转化,后面会有讲解),主要是对类自身以外的信息(常量池中的各种符号引用)进行匹配性的校验。
准备

准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些内存都将在方法区中分配。对于该阶段有以下几点需要注意:

  • 1、这时候进行内存分配的仅包括类变量(static),而不包括实例变量,实例变量会在对象实例化时随着对象一块分配在Java堆中。
  • 2、这里所设置的初始值通常情况下是数据类型默认的零值(如0、0L、null、false等),而不是被在Java代码中被显式地赋予的值。
解析

解析阶段是虚拟机将常量池中的符号引用转化为直接引用的过程。这里需要对编译后的字节码文件结构有一个深入了解才能明白。

符号引用(Symbolic Reference):符号引用以一组符号来描述所引用的目标,符号引用可以是任何形式的字面量,符号引用与虚拟机实现的内存布局无关,引用的目标并不一定已经在内存中。

直接引用(Direct Reference):直接引用可以是直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄。直接引用是与虚拟机实现的内存布局相关的,同一个符号引用在不同的虚拟机实例上翻译出来的直接引用一般都不相同,如果有了直接引用,那引用的目标必定已经在内存中存在。

1、类或接口的解析:判断所要转化成的直接引用是对数组类型,还是普通的对象类型的引用,从而进行不同的解析。

2、字段解析:对字段进行解析时,会先在本类中查找是否包含有简单名称和字段描述符都与目标相匹配的字段,如果有,则查找结束;如果没有,则会按照继承关系从上往下递归搜索该类所实现的各个接口和它们的父接口,还没有,则按照继承关系从上往下递归搜索其父类,直至查找结束,查找流程如下:

本类 → 接口 → 父接口 → …→ 父类 → 祖父类→… 我们看一段代码:

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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
package com.vernon.test.classloader;

/**
* Created with vernon-test
*
* @description:
* @author: chenyuan
* @date: 2020/1/30
* @time: 11:40 AM
*/
public class Super {
public static int m = 11;
static {
System.out.println("执行了super类静态语句块");
}
}
package com.vernon.test.classloader;

/**
* Created with vernon-test
*
* @description:
* @author: chenyuan
* @date: 2020/1/30
* @time: 11:40 AM
*/
public class Super {
public static int m = 11;
static {
System.out.println("执行了super类静态语句块");
}
}
package com.vernon.test.classloader;

/**
* Created with vernon-test
*
* @description:
* @author: chenyuan
* @date: 2020/1/30
* @time: 11:44 AM
*/
public class Child extends Father{
static {
System.out.println("执行了子类静态语句块");
}
}
package com.vernon.test.classloader;

/**
* Created with vernon-test
*
* @description:
* @author: chenyuan
* @date: 2020/1/30
* @time: 11:27 AM
*/
public class ClassLoaderCase {

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

执行结果如下:

1
2
3
4
5
Connected to the target VM, address: '127.0.0.1:62747', transport: 'socket'
执行了super类静态语句块
执行了父类静态语句块
33
Disconnected from the target VM, address: '127.0.0.1:62747', transport: 'socket'
初始化

初始化是类加载过程的最后一步,到了此阶段,才真正开始执行类中定义的Java程序代码。在准备阶段,类变量已经被赋过一次系统要求的初始值,而在初始化阶段,则是根据程序员通过程序指定的主观计划去初始化类变量和其他资源,或者可以从另一个角度来表达:初始化阶段是执行类构造器<clinit>()方法的过程。
这里简单说明下<clinit>()方法的执行规则:
1、<clinit>()方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序所决定的,静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句中可以赋值,但是不能访问。
2、<clinit>()方法与实例构造器<init>()方法(类的构造函数)不同,它不需要显式地调用父类构造器,虚拟机会保证在子类的<clinit>()方法执行之前,父类的<clinit>()方法已经执行完毕。因此,在虚拟机中第一个被执行的<clinit>()方法的类肯定是java.lang.Object。
3、<clinit>()方法对于类或接口来说并不是必须的,如果一个类中没有静态语句块,也没有对类变量的赋值操作,那么编译器可以不为这个类生成<clinit>()方法。
4、接口中不能使用静态语句块,但仍然有类变量(final static)初始化的赋值操作,因此接口与类一样会生成<clinit>()方法。但是接口鱼类不同的是:执行接口的<clinit>()方法不需要先执行父接口的<clinit>()方法,只有当父接口中定义的变量被使用时,父接口才会被初始化。另外,接口的实现类在初始化时也一样不会执行接口的<clinit>()方法。
5、虚拟机会保证一个类的<clinit>()方法在多线程环境中被正确地加锁和同步,如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的<clinit>()方法,其他线程都需要阻塞等待,直到活动线程执行<clinit>()方法完毕。如果在一个类的<clinit>()方法中有耗时很长的操作,那就可能造成多个线程阻塞,在实际应用中这种阻塞往往是很隐蔽的。

总结

类加载这块儿部分其实是非常重要的知识点,它让我们了解到Java的开放、包容以及一个类是如何被加载、被分配的。其中这也是为什么说Java是一次编译,到底执行的原因,其中包含了字节码部分,还有如何做一些字节码增强的技术,后续还有对于GC部分的知识。总之,越往基础越是重要!

参考地址

如果大家喜欢我的文章,可以关注个人订阅号。欢迎随时留言、交流。

简栈文化服务订阅号