一. JVM简介
1. 基本概念
JVM(Java Virtual Machine ),所有的Java程序都运行在JVM中,JVM形成了对Java程序的保护,使java程序的运行与外界环境没有关系,只要计算机系统可以运行JVM就可以运行Java程序。这就是java程序为什么可以跨平台和安全的原因。
JVM虚拟机是“虚拟”的,不是真实存在的,它是依赖宿主机的,是真实计算机中的一个进程,虚拟出来的一台计算机。
二. JVM规范与具体实现
2. 什么是规范
Oracle官方只是通过文字说明,某个版本的JVM应该如何去实现,应该包含哪些内容,这些规定就是规范。
3. 为什么是规范而不是具体实现
Java是开源的编程语言,既然是开源就允许其他组织和平台对Java做一定修改和扩展。JVM作为Java语言重要的组成部分,当然也允许其他组织、公司或个人对JVM做具体实现。
4. 我们使用的是哪种虚拟机
我们使用的是Java 官方对JVM规范的具体实现:HotSpot 虚拟机。
三. 深入理解类加载机制/类的生命周期(面试题)
1. 为什么要深入理解类加载机制
我们编写程序都是Java源文件,经过 Java源文件 -> 编译 -> .class文件 -> 运行 这个过程,得到结果。
但是只知道这最“浅显”加载流程肯定是不够的,不仅仅是因为这是一个常见面试题,更是一个Java程序员必备功底。
2. 图解及说明
2.1 类的生命周期
-
加载(Loading)
-
启动类加载器(Bootstrap ClassLoader)是优先级最高的加载器,最先进行加载。负责加载JDK目录/jre/lib 中相关jar包。包含了所有核心类,例如:String、System等。
-
2. 扩展类加载器(Extension ClassLoader):负责加载扩展类。具体就是JDK目录/jre/lib/ext。
3. 应用程序加载器(Application ClassLoader)负责加载类路径中字节码文件。也就是平时我们所说的classpath中内容。
3.链接(Linking)
-
校验(verify):校验加载的字节码文件是否正确。
2. 准备(prepare):所有静态变量初始化并赋予默认值。
(1)首先,这时候进行内存分配的仅包括类的静态变量(static),但是不包括实例变量,实例变量将会在对象实例化时随着对象一起分配在java堆中。
(2)其次,这里所设置的初始值通常是数据类型默认的初始值,而不是被在Java代码中被赋予的值。这里还需要注意如下几点:
1. 对基本数据类型来说,对于类的静态变量(static)和全局变量,会为其赋予默认值,而对于局部变量来说,只有方法被调用的时候才会赋值,否则编译时不通过。
2. 对于同时被static和final修饰的常量,必须在声明的时候就为其中的赋值,否则编译时不通过;而只被final修饰的常量则即可以在声明时显示其赋值,也可以在类类初始化时显示赋值,总之在使用前必须赋值,系统不会为其赋予默认值。
3. 对于引用数据类型来说,如数组引用,对象引用等,如果没有对其进行显示赋值而直接使用,系统都会为其赋予默认值,即为null,但是不会分配空间。
4. 如果在数组初始化时没有对数组中的各元素赋值,那么其中的元素将根据对应的数据类型而被赋予默认值。
3. 解析(resolve) : 把符号引用转换为直接引用。
1. 实例变量不在该阶段分配内存但是可以赋默认值。
2. 因为类方法和私有方法符合“编译器可知、运行期不可变”的要求,即不会被继承或重写,所以适合在类加载过程中解析。
什么是符号引用呢?
比如我们的程序中的main方法,写法是固定的,我们就可以将main当成一个符号。java虚拟机内部有个专业名词,把他叫做符号,这些符号被加载到 JVM内存里都会对应一个地址,将”符号引用“转变为直接引用,指的就是,将”main“等这些符号转变为对应的内存地址。这个地址就是代码的直接引用。根据直接引用的值,我们就可以知道代码在什么位置,然后根据地址拿到到代码去真正的运行。
4. 初始化(Initialization):执行静态代码块和静态变量赋值。
-
运行。运行字节码文件。
-
卸载。运行结束后进行卸载。
四. 类加载器源码分析
1. Launcher类介绍
sun.misc.Launcher 是JVM的入口类。
全局属性如下:
2. 介绍
2.1 parent属性说明
ClassLoader是Java中提供的类加载器父类。所有的类加载器都是这个类的子类或子孙类。
提供了全局属性parent,这意味着类加载器之间具有逻辑父子关系(不是继承关系)。
2.2 native关键字介绍
在Java中除了abstract修饰的方法没有方法体,还有一种没有方法体的方法,就是使用native关键字修饰的方法。
当方法使用native修饰时表示方法的具体实现是通过其他语言进行实现的。Java语言在操作内存或硬件方面多使用C/C++进行实现,这些方法需要通过native进行修饰。
3. 加载器的父子关系
3.1 获取类加载器
可以通过:类名.class.getClassLoader()进行查看类是由哪种加载器进行加载。其返回值为ClassLoader类对象。
package Test;
public class test18 {
public static void main(String[] args) {
ClassLoader classLoader = test08.class.getClassLoader(); //获取test08的加载器
System.out.println(classLoader);
ClassLoader parent = classLoader.getParent(); //获取加载器的父类
System.out.println(parent);
ClassLoader parent1 = parent.getParent();
System.out.println(parent1);
}
}
3.2 获取父加载器
ClassLoader中包含getParent()方法,表示获取当前加载器的父加载器。
package Test;
public class test18 {
public static void main(String[] args) {
ClassLoader classLoader = test08.class.getClassLoader(); //获取test08的加载器
System.out.println(classLoader);
ClassLoader parent = classLoader.getParent(); //获取加载器的父类
System.out.println(parent);
ClassLoader parent1 = parent.getParent();
System.out.println(parent1);
}
}
3.3 为什么ExtClassLoader的父加载器是null
类加载逻辑上的父子关系正常应该是下面的父子关系。
为什么当获取ExtClassLoader的父加载器时为null呢?
在Launcher中并没有BootstrapClassLoader类。因为Java中并没有提供BootstrapClassLoader类,而是通过C/C++语言编写的。既然Java中没有这个类所以我们在获取ExtClassLoader的父加载器时自然为null。
但是这三个加载器依然是具有逻辑父子关系的(再次强调:不是继承),他们三个都是classLoder的子类。
4. 双亲委派机制(面试题)
双亲委派机制在代码中体现可以通过java.lang.ClassLoader中的loadClass()方法进行查看,首先由应用程序加载器调用loadClass()方法。
4.1源码解析
public Class<?> loadClass(String name) throws ClassNotFoundException {
return loadClass(name, false); //按照全限定名(包名+类名)进行类加载
}
protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException
{
synchronized (getClassLoadingLock(name)) {
// First, check if the class has already been loaded
Class<?> c = findLoadedClass(name); //判断这个类是否被加载,如果被加载过就直接返回,证实了“类加载只会进行一次”这句话。
if (c == null) {
long t0 = System.nanoTime();
try {
if (parent != null) {
c = parent.loadClass(name, false); //如果本加载器没有加载过且有父类加载器存在,则让父类加载器进行加载。
} else {
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// ClassNotFoundException thrown if class not found
// from the non-null parent class loader
}
if (c == null) {
// If still not found, then invoke findClass in order
// to find the class.
long t1 = System.nanoTime();
c = findClass(name);
// this is the defining class loader; record the stats
sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
sun.misc.PerfCounter.getFindClasses().increment();
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
}
总结:
1. 委派的过程就是一层一层向上找的过程。只要当前加载器加载过,就不会重新加载。如果没有加载过,会向上寻找是否加载过。
2. 当加载到Bootstrap ClassLoader后会一层一层的向下判断是否可以进行加载,如果能加载则加载。如果不能加载向下一层寻找下个加载器是否能加载。如果到最后一层都无法加载则报ClassNotFoundException。
好处:避免核心类的串改(优先Bootstrap classloader),避免重复加载(加载一次就不加载)。
五. JVM内存结构(面试题)
1. JVM内存结构图
2. 源文件
源文件就是我们编写Java代码的文件。文件扩展名为.java。
3. 字节码文件
字节码文件是源文件编译后生成的文件。字节码文件是二进制文件,需要通过特定的工具才能查看。里面存放了源文件编译后的字节码指令。
4. 类加载器 Class Loader
Java 程序运行时会由类加载器负责把.class的字节码文件装在到内存中,供虚拟机执行。
4.1 加载 Loading
-
启动类加载器 BootStrap Class Loader
负责从启动类中加载类。具有最高执行优先级。
-
扩展类加载器 Extension Class Loader
负责加载扩展相关类。即:jre/lib/ext 目录
-
应用程序加载器 Application Class Loader
加载应用程序类路径(classpath)中相关类
4.2 链接 Linking
-
校验 Verify
校验器会校验字节码文件是否正确。
-
准备 Prepare
所有静态变量初始化并赋予默认值
-
解析 Resolve
将符号引用被换成直接引用。
4.3 初始化 Initialization
所有静态变量赋予初值,静态代码块执行。
5. 执行引擎
运行时数据区的字节码会交给执行引擎执行。
5.1 解释器 Interpreter
解释器负责解释字节码文件。每次方法调用都会被重新解释。
5.2 JIT编译器
Java程序在运行的时候,主要就是执行字节码指令,一般这些指令会通过解释器进行解释执行,这种就是解释执行。
当虚拟机发现某个方法或代码块的运行特别频繁时,就会把这些代码认定为 热点代码。为了提高热点代码的执行效率,在运行时虚拟机将会把这些代码编译成与本地平台相关的机器码,并进行各种层次的优化,完成这个任务的编译器称为即时编译器(Just In Time Compiler,简称 JIT 编译器)。
5.3 探测器
负责探测多次被调用的代码。
5.4 垃圾回收器 GC
负责回收不在被使用的对象。GC是JVM中非常重要的一块。
6. 本地库接口
在Java代码中使用native修饰的方法表示方法具体实现使用的是其他编程语言。例如:C语言。通过本地库接口为Java程序提供调用其他语言的实现方案。
7. 本地方法库
所有的本地方法,通过本地库接口调用。
8. 程序计数器
程序计数器简称:PC Register。
程序计数器是一块较小的内存空间。记录了当前线程执行到的字节码行号。每个线程都有自己的程序计数器,相互不影响。如果是native方法,计数器为空。
9. 虚拟机栈
虚拟机栈跟随线程创建而创建,所以每个线程都有一个虚拟机栈,虚拟机栈是线程独有。
虚拟机栈中存储的是栈帧,每个栈帧对应一个方法,每个栈帧都有自己的局部变量表、操作数栈、动态链接和返回地址等。当前正在执行的方法称为当前方法,当前方法所在的帧称为当前帧。方法执行时帧就是一个入栈操作,方法执行完成之后栈帧就是一个出站操作。
9.1 局部变量表
局部变量表存储的8大基本数据类型和返回值以及方法参数及对象的引用。 其中long和double占用2倍长度。
局部变量表就是一个数组,数组的长度在编译期确定。通过从0开始的索引调用局部变量表的内容。
9.2 操作数栈
操作数栈存在于栈帧中,其大小在编译期确定。操作数栈中存储了class文件中虚拟机指令以及准备要传递的参数和接收对方的返回结果。运行时常量池中数据以及局部变量表中得值都可以由操作数栈进行获取。
9.3 动态链接
符号引用转换为直接引用分为两种情况。在JVM加载或第一次使用转换时称为静态链接或静态解析。而在运行期间把符号转换为直接引用时就称为动态链接。
9.4 方法返回地址
方法返回地址分为两种情况:
1. 正常结束执行。例如碰见return关键字。调用程序计数器的值后当前栈帧直接出栈就可以了。
2. 异常结束。可能需要恢复上层方法的局部变量表和操作数栈,然后把返回值压如到栈帧的操作数栈中,之后调用程序计数器的值后获取到下条指令。
10. 堆
堆是所有线程共享的,存储类的实例和数组。
堆是在虚拟机启动时创建的,由GC负责回收。
堆可以是一块不连续的内存空间。
在Java 8 中,String是存在于堆中的。
堆被分为二大部分:
在Java 7:新生代(Young Generation)、老年代(Old Generation)、永久代。新生代、老年代和永久代是连续的。
新生代又被分为Eden区、From Survivor区、To Survivor区。官方说明默认分配比例为8:1:1。但是使用jmap工具进行测试时发现比例为6:1:1。
在Java 8:新生代(Young Generation)、老年代(Old Generation)。把永久代替换为元空间(MetaSpace),也就是说在Java8中使用元空间来实现方法区。且在Java8中把元空间移植到本地内存上(Native Memory),其实在Java 7 时,部分数据已经移植到本地内存上了。例如:符号引用(Symbols)。
字符串常量池在堆中。
11. 方法区
方法区是线程共享的。在虚拟机启动时自动创建方法区,方法区可以是一块不连续的内存空间。方法区可以理解为编译代码存储区。在方法区中存储每个类的结构、运行时常量池、字段、方法、构造方法。
在JVM规范上方法区是一个独立的区域,但是在Java SE7 的HotSpot 上方法区使用永久代作为实现,永久代在堆中和老年代为连续的空间。在Java SE8的JVM规范实现上,HotSpot使用元空间(本地内存)实现方法区。