1、什么是JVM?
1.1、定义:
JVM 指的是Java虚拟机( Java Virtual Machine )。JVM 本质上是一个运行在计算机上的程序,他的职责是运行Java字节码文件,Java虚拟机上可以运行Java、Kotlin、Scala、Groovy等语言。
启动这个程序:
package q1jvm;
import java.io.IOException;
//用java命令启动一个jvm进程,执行程序
public class C01JVM {
public static void main(String[] args) throws IOException {
System.in.read();
}
}
任务管理器中启动的Java进程,其实是一个虚拟机进程,它会执行我们编写好的代码。
通过jps命令也可以看到java进程,jps是JDK自带的一共显示Java进程的小工具:
只要能编译成Java字节码文件的语言,Java虚拟机都可以运行。下图是Groovy语言在Java虚拟机上成功运行的结果:
1.2、作用:
为了支持Java中Write Once,Run Anywhere;编写一次,到处运行的跨平台特性。
对于C/C++这类语言来说,需要将源代码编译成对应平台(不同的操作系统+CPU架构)的机器码,才能让计算机运行。不满足一次编译,到处运行的跨平台特性。
但是Java语言不同,Java语言将源代码编译成字节码文件之后,就可以交由不同平台下已经安装好的Java虚拟机。Java虚拟机会将字节码指令实时解释成机器码。这样就满足了一次编译(编译成字节码),到处运行的跨平台特性。
1.3、功能
-
解释和运行,对字节码文件中的指令,实时的解释成机器码,让计算机执行。
-
内存管理,自动为对象、方法等分配内存,自动的垃圾回收机制,回收不再使用的对象。
-
即时编译,对热点代码进行优化,提升执行效率。
执行以下代码:
package q1jvm;
//-Xint 禁止JIT即时编译器优化
public class C03Usage {
public static void main(String[] args) {
long start = System.currentTimeMillis();
C03Usage test = new C03Usage();
test.jitTest();
long end = System.currentTimeMillis();
System.out.println( "执行耗时:" + (end - start) + "ms" );
}
public int add (int a,int b){
return a + b;
}
public int jitTest(){
int sum = 0;
for (int i = 0; i < 10000000; i++) {
sum = add(sum,100);
}
return sum;
}
}
加上JIT即时编译优化之后,代码执行只需要3ms。但是如果加上-Xint参数关闭即时编译器优化,执行时间需要233ms。
1.4、组成
编译器:不属于Java虚拟机的一部分,负责将源代码文件编译成字节码文件。- 类加载子系统,负责将字节码文件读取、解析并保存到内存中。其核心就是类加载器。
- 运行时数据区,管理JVM使用到的内存。
- 执行引用,分为解释器 解释执行字节码指令;即时编译器 优化代码执行性能; 垃圾回收器 将不再使用的对象进行回收。
- 本地接口,保存了本地已经编译好的方法,使用C/C++语言实现。
1.5、常见的JVM
总结
1、JVM 指的是Java虚拟机,本质上是一个运行在计算机上的程序,他的职责是运行Java字节码文件,作用是为了支持跨平台特性。
2、JVM的功能有三项:第一是解释执行字节码指令;第二是管理内存中对象的分配,完成自动的垃圾回收;第三是优化热点代码提升执行效率。
3、JVM组成分为类加载子系统、运行时数据区、执行引擎、本地接口这四部分。
4、常用的JVM是Oracle提供的Hotspot虚拟机,也可以选择GraalVM、龙井、OpenJ9等虚拟机。
2、了解过字节码文件的组成吗?
字节码文件本质上是一个二进制的文件,无法直接用记事本等工具打开阅读其内容。需要通过专业的工具打开。不同环境用如下两种方式:
-
开发环境使用jclasslib插件 -
服务器环境使用javap –v命令,或者使用阿里arthas
2.1、基本信息
魔数、字节码文件对应的Java版本号、访问标识(public final等等)、父类和接口。
类代码:
package q2class;
public class MyClass extends MyParent implements MyInterface{
private int i = 0;
@Override
public void test() {
int j = 0;
j++;
}
public static void main(String[] args) {
new MyClass();
}
}
父类代码:
package q2class;
public class MyParent {
}
接口代码:
package q2class;
public interface MyInterface {
void test();
}
编译之后用notepad++打开:
魔数前四个字节是固定的内容0xcafebabe,只有前四个字节满足这个内容才是字节码文件。
使用jclasslib查看到基本信息:
如果在服务器上,可以通过javap -v命令打开字节码文件查看内容:
结果:
2.2、常量池
保存了字符串常量、类或接口名、字段名,主要在字节码指令中使用。
常量池是一个数组,比如这个序号为10的常量就是一个UTF8的字符串。保存了MyClass的全限定名。
2.3、字段
当前类或接口声明的字段信息
字段里保存的是名字、描述符(字段类型)、访问标识。其中名字和描述符都指向常量池中的内容。
2.4、方法
当前类或接口声明的方法信息、字节码指令。
方法中保存了方法名、描述符(参数和返回值)、访问标识。
还有字节码指令,代码编译后就变成了字节码指令:
2.5、属性
类的属性,比如源码的文件名、内部类的列表等。
3、 说一下运行时数据区
运行时数据区指的是JVM所管理的内存区域,其中分成两大类:
-
线程共享 – 方法区、堆 线程不共享 – 本地方法栈、虚拟机栈、程序计数器
-
直接内存主要是NIO使用,由操作系统直接管理,不属于JVM内存。
程序计数器
程序计数器(Program Counter Register)也叫PC寄存器,每个线程会通过程序计数器记录当前要执行的的字节码指令的地址。主要有两个作用:
1、程序计数器可以控制程序指令的进行,实现分支、跳转、异常等逻辑。
2、在多线程执行情况下,Java虚拟机需要通过程序计数器记录CPU切换前解释执行到那一句指令并继续解释运行。
栈 - Java虚拟机栈
Java虚拟机栈采用栈的数据结构来管理方法调用中的基本数据,先进后出 ,每一个方法的调用使用一个栈帧来保存。每个线程都会包含一个自己的虚拟机栈,它的生命周期和线程相同。
栈帧主要包含三部分内容:
1、局部变量表,在方法执行过程中存放所有的局部变量。
2、操作数栈,虚拟机在执行指令过程中用来存放临时数据的一块区域。
如下图中,iadd指令会将操作数栈上的两个数相加,为了实现i+1。最终结果也会放到操作数上。
3、帧数据,主要包含动态链接、方法出口、异常表等内容。
动态链接:方法中要用到其他类的属性和方法,这些内容在字节码文件中是以编号保存的,运行过程中需要替换成内存中的地址,这个编号到内存地址的映射关系就保存在动态链接中。
方法出口:方法调用完需要弹出栈帧,回到上一个方法,程序计数器要切换到上一个方法的地址继续执行,方法出口保存的就是这个地址。
异常表:存放的是代码中异常的处理信息,包含了异常捕获的生效范围以及异常发生后跳转到的字节码指令位置。
本地方法栈
Java虚拟机栈存储了Java方法调用时的栈帧,而本地方法栈存储的是native本地方法的栈帧。
在Hotspot虚拟机中,Java虚拟机栈和本地方法栈实现上使用了同一个栈空间。本地方法栈会在栈内存上生成一个栈帧,临时保存方法的参数同时方便出现异常时也把本地方法的栈信息打印出来。
堆
- 一般Java程序中堆内存是空间最大的一块内存区域。创建出来的对象都存在于堆上。
- 栈上的局部变量表中,可以存放堆上对象的引用。静态变量也可以存放堆对象的引用,通过静态变量就可以实现对象在线程之间共享。
- 堆是垃圾回收最主要的部分,堆结构更详细的划分与垃圾回收器有关。
方法区
方法区是Java虚拟机规范中提出来的一个虚拟机概念,在HotSpot不同版本中会用永久代或者元空间来实现。方法区主要存放的是基础信息,包含:
1、每一个加载的类的元信息(基础信息)。
2、运行时常量池,保存了字节码文件中的常量池内容,避免常量内容重复创建减少内存开销。
3、字符串常量池,存储字符串的常量。
直接内存
直接内存并不在《Java虚拟机规范》中存在,所以并不属于Java运行时的内存区域。在 JDK 1.4 中引入了 NIO 机制,由操作系统直接管理这部分内容,主要为了提升读写数据的性能。在网络编程框架如Netty中被大量使用。
要创建直接内存上的数据,可以使用ByteBuffer。
语法:
ByteBuffer directBuffer = ByteBuffer.allocateDirect(size);
总结
运行时数据区指的是JVM所管理的内存区域,其中分成两大类:
- 线程共享 方法区、堆
方法区:存放每一个加载的类的元信息、运行时常量池、字符串常量池。
堆:存放创建出来的对象。
- 线程不共享 – 本地方法栈、虚拟机栈、程序计数器
本地方法栈和虚拟机栈都存放了线程中执行方法时需要使用的基础数据。
程序计数器存放了当前线程执行的字节码指令在内存中的地址。
直接内存主要是NIO使用,由操作系统直接管理,不属于JVM内存。
4、哪些区域会出现内存溢出,会有什么现象?
内存溢出指的是内存中某一块区域的使用量超过了允许使用的最大值,从而使用内存时因空间不足而失败,虚拟机一般会抛出指定的错误。
在Java虚拟机中,只有程序计数器不会出现内存溢出的情况,因为每个线程的程序计数器只保存一个固定长度的地址。
堆内存溢出:
堆内存溢出指的是在堆上分配的对象空间超过了堆的最大大小,从而导致的内存溢出。堆的最大大小使用-Xmx参数进行设置,如-Xmx10m代表最大堆内存大小为10m。
package q1oom;
import java.io.IOException;
import java.util.ArrayList;
//-Xmx10m
public class HeapOOM {
public static void main(String[] args) throws InterruptedException, IOException {
ArrayList<Object> objects = new ArrayList<Object>();
while (true){
objects.add(new byte[1024 * 1024 * 1]);
}
}
}
溢出之后会抛出OutOfMemoryError,并提示是Java heap Space导致的:
栈内存溢出:
栈内存溢出指的是所有栈帧空间的占用内存超过了最大值,最大值使用-Xss进行设置,比如-Xss256k代表所有栈帧占用内存大小加起来不能超过256k。
package q1oom;
/**
* -Xss180k 每个线程栈内存最大180k
*/
public class StackOOM {
public static void main(String[] args) {
recursion();
}
public static int count = 0;
//递归方法调用自己
public static void recursion() {
long a,b,c,d,f,g,h,i,j,k;
System.out.println(++count);
recursion();
}
}
溢出之后会抛出StackOverflowError:
方法区内存溢出:
方法区内存溢出指的是方法区中存放的内容比如类的元信息超过了方法区内存的最大值,JDK7及之前版本方法区使用永久代(-XX:MaxPermSize=值)来实现,JDK8及之后使用元空间(-XX:MaxMetaspaceSize=值)来实现。
package q1oom;
import net.bytebuddy.jar.asm.ClassWriter;
import net.bytebuddy.jar.asm.Opcodes;
import java.io.IOException;
/**
* JDK8 -XX:MaxMetaspaceSize=20m JDK7 -XX:MaxPermSize=20m
*/
public class MethodAreaOOM extends ClassLoader {
public static void main(String[] args) throws IOException {
MethodAreaOOM demo1 = new MethodAreaOOM();
int count = 0;
while (true) {
String name = "Class" + count;
ClassWriter classWriter = new ClassWriter(0);
classWriter.visit(Opcodes.V1_7, Opcodes.ACC_PUBLIC, name, null
, "java/lang/Object" , null);
byte[] bytes = classWriter.toByteArray();
demo1.defineClass(name, bytes, 0, bytes.length);
System.out.println(++count);
}
}
}
元空间溢出:
永久代溢出:
直接内存溢出:
直接内存溢出指的是申请的直接内存空间大小超过了最大值,使用 -XX:MaxDirectMemorySize=值 设置最大值。
溢出之后会抛出OutOfMemoryError:
package q1oom;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.util.ArrayList;
import java.util.List;
/**
* -XX:MaxDirectMemorySize=50m
*/
public class DirectOOM {
public static int size = 1024 * 1024 * 100; //100mb
public static List<ByteBuffer> list = new ArrayList<ByteBuffer>();
public static int count = 0;
public static void main(String[] args) throws IOException, InterruptedException {
while (true) {
ByteBuffer directBuffer = ByteBuffer.allocateDirect(size);
list.add(directBuffer);
}
}
}
溢出之后出现:
总结:
内存溢出指的是内存中某一块区域的使用量超过了允许使用的最大值,从而使用内存时因空间不足而失败,虚拟机一般会抛出指定的错误。
堆:溢出之后会抛出OutOfMemoryError,并提示是Java heap Space导致的。
栈:溢出之后会抛出StackOverflowError。
方法区:溢出之后会抛出OutOfMemoryError,JDK7及之前提示永久代,JDK8及之后提示元空间。
直接内存:溢出之后会抛出OutOfMemoryError。
5、JVM在JDK6-8之间在内存区域上有什么不同
方法区的实现
方法区是《Java虚拟机规范》中设计的虚拟概念,每款Java虚拟机在实现上都各不相同。Hotspot设计如下:
- JDK7及之前的版本将方法区存放在堆区域中的永久代空间,堆的大小由虚拟机参数来控制。
- JDK8及之后的版本将方法区存放在元空间中,元空间位于操作系统维护的直接内存中,默认情况下只要不超过操作系统承受的上限,可以一直分配。也可以手动设置最大大小。
使用元空间替换永久代的原因:
1、提高内存上限:元空间使用的是操作系统内存,而不是JVM内存。如果不设置上限,只要不超过操作系统内存上限,就可以持续分配。而永久代在堆中,可使用的内存上限是有限的。所以使用元空间可以有效减少OOM情况的出现。
2、优化垃圾回收的策略:永久代在堆上,垃圾回收机制一般使用老年代的垃圾回收方式,不够灵活。使用元空间之后单独设计了一套适合方法区的垃圾回收机制。
字符串常量池的位置
字符串常量池从方法区移动到堆的原因:
1、垃圾回收优化:字符串常量池的回收逻辑和对象的回收逻辑类似,内存不足的情况下,如果字符串常量池中的常量不被使用就可以被回收;方法区中的类的元信息回收逻辑更复杂一些。移动到堆之后,就可以利用对象的垃圾回收器,对字符串常量池进行回收。
2、让方法区大小更可控:一般在项目中,类的元信息不会占用特别大的空间,所以会给方法区设置一个比较小的上限。如果字符串常量池在方法区中,会让方法区的空间大小变得不可控。
3、intern方法的优化:JDK6版本中intern () 方法会把第一次遇到的字符串实例复制到永久代的字符串常量池中。JDK7及之后版本中由于字符串常量池在堆上,就可以进行优化:字符串保存在堆上,把字符串的引用放入字符串常量池,减少了复制的操作。
总结
6、类的生命周期
类的生命周期分为以下几个阶段:
加载阶段
1、加载(Loading)阶段第一步是类加载器根据类的全限定名通过不同的渠道以二进制流的方式获取字节码信息。
程序员可以使用Java代码拓展的不同的渠道。
2、类加载器在加载完类之后,Java虚拟机会将字节码中的信息保存到内存的方法区中。在方法区生成一个InstanceKlass对象,保存类的所有信息。
3、在堆中生成一份与方法区中数据类似的java.lang.Class对象, 作用是在Java代码中去获取类的信息。
比如这段代码中,就会访问堆中的Class对象:
连接阶段
连接阶段分为三个小阶段:
连接(Linking)阶段的第一个环节是验证,验证的主要目的是检测Java字节码文件是否遵守了《Java虚拟机规范》中的约束。这个阶段一般不需要程序员参与。
主要包含如下四部分,具体详见《Java虚拟机规范》:
1.文件格式验证,比如文件是否以0xCAFEBABE开头,主次版本号是否满足当前Java虚拟机版本要求。
2.元信息验证,例如类必须有父类(super不能为空)。
3.验证程序执行指令的语义,比如方法内的指令执行到一半强行跳转到其他方法中去。
4.符号引用验证,例如是否访问了其他类中private的方法等。
准备阶段为静态变量(static)分配内存并设置初值。final修饰的基本数据类型的静态变量,准备阶段直接会将代码中的值进行赋值。
解析阶段主要是将常量池中的符号引用替换为直接引用。符号引用就是在字节码文件中使用编号来访问常量池中的内容。直接引用不在使用编号,而是使用内存中地址进行访问具体的数据。
初始化阶段
初始化阶段会执行静态代码块中的代码,并为静态变量赋值。
初始化阶段会执行字节码文件中clinit部分的字节码指令。
以如下代码为例:
package q3loadclass;
public class Demo1 {
public static int value = 1;
static {
value = 2;
}
{
value = 3;
}
public static void main(String[] args) {
new Demo1();
System.out.println(value);
}
}
1.连接的准备阶段value赋初值为0
2.初始化阶段执行clinit方法中的指令,value赋值为2
3.如果创建对象,会执行对象的init方法,value赋值为3 (类中代码块中的内容被放到了构造方法中)
卸载阶段
判定一个类可以被卸载。需要同时满足下面三个条件:
1、此类所有实例对象都已经被回收,在堆中不存在任何该类的实例对象以及子类对象。
2、加载该类的类加载器已经被回收。
3、该类对应的 java.lang.Class 对象没有在任何地方被引用。
总结
7、什么是类加载器?
类加载器负载在类的加载过程中将字节码信息以流的方式获取并加载到内存中。
JDK8及之前如下:
JDK9之后均由Java实现:
启动类加载器
启动类加载器(Bootstrap ClassLoader)是由Hotspot虚拟机提供的类加载器,JDK9之前使用C++编写的、JDK9之后使用Java编写。
默认加载Java安装目录/jre/lib下的类文件,比如rt.jar,tools.jar,resources.jar等。
//String类 核心类 由启动类加载器加载,在Java中无法获得启动类加载器
System.out.println(java.lang.String.class.getClassLoader());
打印结果为:
在Java代码中无法获得启动类加载器。
扩展类加载器
扩展类加载器(Extension Class Loader)是JDK中提供的、使用Java编写的类加载器。JDK9之后由于采用了模块化,改名为Platform平台类加载器。
默认加载Java安装目录/jre/lib/ext下的类文件。
//nashorn包中的类,使用java script引擎打印Hello World 由扩展类加载器加载
ScriptEngine engine = new ScriptEngineManager().getEngineByName( "nashorn" );
engine.eval( "print('Hello World!');" );
System.out.println(ScriptEngineManager.class.getClassLoader());
打印结果(JDK17平台类加载器):
应用程序类加载器
应用程序类加载器(Application Class Loader)是JDK中提供的、使用Java编写的类加载器。默认加载为应用程序classpath下的类。
自定义类加载器
自定义类加载器允许用户自行实现类加载的逻辑,可以从网络、数据库等来源加载类信息。自定义类加载器需要继承自ClassLoader抽象类,重写findClass方法。
package q4classloader;
import org.apache.commons.io.FileUtils;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.lang.reflect.Field;
//自定义类加载器
public class MyClassLoader extends ClassLoader{
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
String filename = name.substring(name.lastIndexOf( "." ) + 1);
byte[] bytes = new byte[0];
try {
bytes = FileUtils.readFileToByteArray(new File( "D:\jvm\data\" + filename + ".class" ));
} catch (IOException e) {
e.printStackTrace();
}
//获取字节码信息的二进制数据,调用defineClass方法
return defineClass(name, bytes, 0, bytes.length);
}
public static void main(String[] args) throws ClassNotFoundException, InstantiationException, IllegalAccessException {
MyClassLoader myClassLoader = new MyClassLoader();
Class<?> clazz = myClassLoader.loadClass( "com.itheima.springbootclassfile.pojo.vo.UserVO" );
//打印字段
Field[] fields = clazz.getDeclaredFields();
for (Field field : fields) {
System.out.println(field.getName());
}
}
}
总结:
1.启动类加载器(Bootstrap ClassLoader)加载核心类
2.扩展类加载器(Extension ClassLoader)加载扩展类
3.应用程序类加载器(Application ClassLoader)加载应用classpath中的类
4.自定义类加载器,重写findClass方法。
JDK9及之后扩展类加载器(Extension ClassLoader)变成了平台类加载器(Platform ClassLoader)
7、什么是双亲委派机制
类加载有层级关系,上一级称之为下一级的父类加载器。
测试代码:
package q4classloader;
import javax.script.ScriptEngineManager;
import javax.script.ScriptException;
public class PrintParentClassLoader {
public static void main(String[] args) throws ScriptException {
new ScriptEngineManager();
//扩展类加载器的父类加载器
System.out.println(ScriptEngineManager.class.getClassLoader().getParent());
//应用程序类加载器的父类加载器
System.out.println(PrintParentClassLoader.class.getClassLoader().getParent());
//自定义类加载器的父类加载器
System.out.println(new MyClassLoader().getParent());
}
}
打印结果:
-
扩展类加载器的父类加载器但是在java中无法获得,所以打印null
-
应用程序类加载器的父类加载器是扩展类加载器(平台类加载器)
-
自定义类加载器的父类加载器是应用程序类加载器
双亲委派机制指的是:当一个类加载器接收到加载类的任务时,会向上查找是否加载过,再由顶向下进行加载。
每个类加载器都有一个父类加载器,在类加载的过程中,每个类加载器都会先检查是否已经加载了该类,如果已经加载则直接返回,否则会将加载请求委派给父类加载器。
应用程序类加载器接收到加载类的任务,首先先检查自己有没有加载过:
没有加载过就一层一层向上传递,都检查下自己有没有加载过这个类:
到了启动类加载器发现已经加载过,就返回。
另一个案例:com.itheima.my.C 这个类在当前程序的classpath中,看看是如何加载的。
先由应用程序类加载器检查,发现没有加载过,向上传递检查发现都没有加载过。此时启动类加载器会优先加载:
接下来向下传递加载:
最后由应用程序类加载器加载成功:
双亲委派机制有什么用?
1.保证类加载的安全性,通过双亲委派机制避免恶意代码替换JDK中的核心类库,比如java.lang.String,确保核心类库的完整性和安全性。
2.避免重复加载,双亲委派机制可以避免同一个类被多次加载。
总结
双亲委派机制指的是:当一个类加载器接收到加载类的任务时,会向上交给父类加载器查找是否加载过,再由顶向下进行加载。
双亲委派机制的作用:保证类加载的安全性,避免重复加载。
8、如何打破双亲委派机制
先了解下双亲委派机制的原理:
调用关系如下:
ClassLoader中包含了4个核心方法,对Java程序员来说,打破双亲委派机制的唯一方法就是实现自定义类加载器重写loadClass方法,将其中的双亲委派机制代码去掉。
打破双亲委派机制的源码:
package q4classloader;
import org.apache.commons.io.FileUtils;
import java.io.File;
import java.io.IOException;
import java.lang.reflect.Field;
public class ItheimaClassLoader extends ClassLoader{
@Override
public Class<?> loadClass(String name) throws ClassNotFoundException {
if(name.startsWith( "java." )){
return super.loadClass(name);
}
//com.itheima.springbootclassfile.pojo.vo.UserVO .class
String filename = name.substring(name.lastIndexOf( "." ) + 1) + ".class" ;
//加载 D:/jvm/data
byte[] bytes = new byte[0];
try {
bytes = FileUtils.readFileToByteArray(new File( "D:\教学\同步课程资料\BaiduSyncdisk\实战Java虚拟机\实战Java虚拟机\代码\day12\jvm-interview\target\classes\q4classloader\" + filename));
} catch (IOException e) {
e.printStackTrace();
}
return defineClass(name,bytes,0,bytes.length);
}
public static void main(String[] args) throws ClassNotFoundException {
ItheimaClassLoader itheimaClassLoader = new ItheimaClassLoader();
Class<?> clazz = itheimaClassLoader.loadClass( "q4classloader.PrintParentClassLoader" );
//打印类字段
// Field[] declaredFields = clazz.getDeclaredFields();
// for (Field declaredField : declaredFields) {
// System.out.println(declaredField.getName());
// }
//打印类加载器名字
System.out.println(clazz.getClassLoader());
}
}
总结
双亲委派机制指的是:当一个类加载器接收到加载类的任务时,会自底向上交给父类加载器查找是否加载过,再由顶向下进行加载。
双亲委派机制的作用:保证类加载的安全性,避免重复加载。
打破双亲委派机制的方法:实现自定义类加载器,重写loadClass方法,将双亲委派机制的代码去除。