一、什么是JVM
JVM(Java虚拟机)是JRE的重要组成部分,它是一个虚拟的计算机,可以执行Java字节码文件。Java程序首先被编译成字节码,然后由JVM解释执行字节码文件,JVM的主要功能是将字节码转换为机器码并运行程序。JVM提供了一种平台无关的方式来运行Java程序,因为它可以在不同的操作系统和硬件架构上运行。JVM还提供了垃圾回收机制和其他一些优化,以提高程序的性能和稳定性。
二、Java跨平台
所谓跨平台就是相同的源码可以在不同的操作系统和CPU上运行。不同的CPU,差异主要在底层指令集不同,指令集分为精简指令集(RISC)和复杂指令集(CISC)。每个CPU都有自己的特定指令集。
Java跨平台主要依赖于JVM。源码通过编译生成一个字节码文件(.clss),这个字节码文件可以在各个平台通用,再由各个平台自己的JVM解释执行生成自己平台对应的机器码指令通过操作系统操作硬件,完成跨平台。
JVM解释执行时,解释为主,编译为辅,将使用频率超过十万次的字节码通过JIT即时编译成机器码,提高性能。
三、JVM组成结构
- 类装载器(Class Loader):类装载器将类文件(.class文件)加载到JVM中,并形成类的字节流。JVM中不同的类装载器负责不同的任务,包括发现并加载类文件、验证类文件的格式和内容、执行类文件中的字节码等。
- 运行时数据区(Runtime Data Area):JVM中的运行时数据区包括Java堆、方法区、虚拟机栈、本地方法栈和程序计数器等。这些区域用来存储运行期间的数据和计算结果,提供了JVM运行时的内存空间。
- 执行引擎(Execution Engine):执行引擎是JVM的核心组成部分,它由解释器和Just-In-Time(JIT)编译器等组件组成。执行引擎将字节码文件转化为CPU可以执行的机器码指令,用于实现程序的实际运行。
- 本地方法接口(Native Method Interface,简称JNI)是Java平台的一个重要特性,它使得Java程序能够调用本地(Native)代码库中的方法。本地方法是指使用Java语言无法实现的、需要通过本地代码实现的方法。例如,常见的图形界面、操作系统底层接口、网络通信等都需要使用本地方法来实现。使用JNI,Java程序可以轻松地调用这些本地方法。
四、Java代码执行流程
- Java源代码编译成字节码;
- 字节码校验并把Java程序通过类加载器加载到JVM内存中;
- 在加载到内存后针对每个类创建
Class
对象; - 字节码指令和数据初始化到内存中;
- 找到
main
方法,并创建栈帧; - 初始化程序计数器内部的值为
main
方法的内存地址; - 程序计数器不断递增,逐条执行JAVA字节码指令,把指令执行过程的数据存放到操作数栈中(入栈),执行完成后从操作数栈取出后放到局部变量表中,遇到创建对象,则在堆内存中分配一段连续的空间存储对象,栈内存中的局部变量表存放指向堆内存的引用;遇到方法调用则再创建一个栈帧,压到当前栈帧的上面。
五、类加载机制
类是在运行期间第一次使用时,被类加载器动态加载至JVM。JVM不会一次性加载所有类。因为如果一次性加载,那么会占用很多的内存。
5.1 类加载过程
- 加载(Loading)阶段:读取Class文件,将其转化为某种静态数据结构存储在方法区内,并在堆中生成一个便于被用户调用的Class类型的对象的过程。
- 其中二进制字节流可以从以下方式中获取:
- 从ZIP包读取,成为 JAR、EAR、WAR格式的基础。
- 从网络中获取,最典型的应用是 Applet。
- 运行时计算生成,例如动态代理技术。
- 由其他文件或容器生成,例如由 JSP 文件生成对应的 Class类。
- 验证(Verification)阶段:对类的二进制数据进行校验,检查其是否符合Java语言规范和JVM规范的要求,以及是否有安全方面的问题。
- 准备(Preparation)阶段:为类的静态变量分配内存空间,存储在堆中,并设置默认初始值,没被 final 修饰一般为0。
- 解析(Resolution)阶段:将类中的符号引用转换成直接引用,并解析其定义的方法和变量等。
- 初始化(Initialization)阶段:为类的静态变量赋值,并执行类构造器<clinit>()方法,<clinit>()方法是由编译器自动收集类中所有类变量的赋值动作和静态语句块中的语句合并产生的。
5.2 类加载时机
主动引用
主动引用是指通过4个指令、反射、加载子类、执行main函数、加载实现类来引用某个类,从而触发类的加载和初始化。
public class zhudong {
public static void main(String[] args) throws Exception{
//1.4个指令:
//new,当程序创建一个类的实例对象
Person person = new Person();
//getstatic,程序访问类的静态变量
System.out.println(Person.A);
//putstatic,程序给类的静态变量赋值
Person.A = 199;
//invokestatic,程序调用类的静态方法
Person.dosth();
//2.反射
Person p = Person.class.newInstance();
//3.加载子类,先加载父类
Student student = new Student();
//4.执行main方法
//5.加载实现类,加载接口类
TeacherImp t = new TeacherImp();
t.dosth();
}
}
class Person{
public static int A = 188;
static {
System.out.println("父类被加载");
}
public static void dosth(){
}
}
class Student extends Person{
static {
System.out.println("子类被加载");
}
}
interface Teacher {
default void dosth() {
System.out.println("接口类被加载");
}
}
class TeacherImp implements Teacher{
}
被动引用
被动引用是指虽然代码中出现了某个类的名称,但是没有直接引用该类或者对该类的静态变量进行访问,因而不会触发该类的加载和初始化。
public class beidong {
public static void main(String[] args) {
//1.子类引用父类静态变量,子类不加载
System.out.println(Son.A);
//2.引用静态常量,该类不加载
System.out.println(Son.B);
//3.通过数组定义引用类,该类不加载
Son[] array = new Son[16];
}
}
class Parent{
static int A = 188;
static {
System.out.println("Parent类被加载");
}
}
class Son extends Parent{
static final int B = 199;
static {
System.out.println("Son类被加载");
}
}
5.3 类加载器
类加载器是Java虚拟机(JVM)提供的一种机制,用于将类的字节码文件加载到JVM内存中并执行,从而使程序能够运行。Java虚拟机提供了三种类加载器:
1. 启动类加载器(Bootstrap ClassLoader)
作为JVM的一部分,它负责加载Java平台核心库,如java.lang.*等。由于这些类是JVM必须的,所以它们必须由引导类加载器来加载。
2. 扩展类加载器(Extension ClassLoader)
负责加载Java平台扩展库,默认情况下从"jre/lib/ext/"目录加载Jar包。
3. 应用程序类加载器(Application ClassLoader)
负责加载应用程序中的类,即在classpath中的类,同时也是最常用的类加载器。
此外,还可以自定义类加载器。自定义类加载器可以用于加载非标准的类文件,例如从非标准位置或非标准格式的文件中加载类,或者对已有的类进行加密和保护。自定义类加载器需要继承java.lang.ClassLoader类,重写其中的findClass()方法。
5.4 双亲委派模型
应用程序是由三种类加载器互相配合,从而实现类加载,除此之外还可以加入自己定义的类加载器。类加载器之间的层次关系,称为双亲委派模型。该模型要求除了顶层的启动类加载器外,其它的类加载器都要有自己的父类加载器。这里的父子关系一般通过组合关系来实现,而不是继承关系。
机制:
一个类加载器首先将类加载请求转发到父类加载器,只有当父类加载器无法完成时才尝试自己加载。
作用:
- 使得Java类随着它的类加载器一起具有一种带有优先级的层次关系,从而使得基础类得到统一,避免冲突
- 实现热加载,比如Spring Boot DevTools
5.5 对象的创建过程
-
检查类加载:在创建对象之前,JVM会首先检查该对象所属的类是否已经被加载到内存中。如果没有被加载,则先进行类加载操作。
-
分配内存:在检查类加载之后,JVM会在内存中为该对象分配一块连续的内存空间。内存的大小取决于对象的大小,内存分配的查找方式有 指针碰撞 和 空闲列表 两种。
-
初始化零值:在内存分配完成后,JVM会将该内存空间中的所有位都设置为零,这样可以保证对象的实例变量都有一个初始值。
-
设置对象头:对象头是每个Java对象在内存中都有的一个数据结构,它包含了对象的元信息,包括该对象的哈希码、GC分代年龄等等。在分配内存后,JVM会在对象头中存储这些信息。
-
执行构造方法:在对象头设置完成后,JVM会调用构造方法来完成对象的初始化。构造方法会对实例变量进行赋值、执行一些其他的初始化操作,并返回一个对该对象的引用。
六、Java 内存模型
JVM 内存模型(JVM Memory Model)是一种规范,定义了 JVM 如何管理 Java 程序在内存中的存储和访问。
JVM
虚拟机在执行 Java 程序的过程中会把它管理的内存划分成若干个不同的数据区域。
- JDK1.8之前分为:线程共享(
Heap
堆区、Method Area
方法区)、线程私有(虚拟机栈、本地方法栈、程序计数器) - JDK1.8以后分为:线程共享(
Heap
堆区、MetaSpace
元空间)、线程私有(虚拟机栈、本地方法栈、程序计数器)
程序计数器(Program Counter Register)
字节码解释器在解释执行字节码文件工作时,每当需要执行一条字节码指令时,就通过改变程序计数器的值来完成。程序中的分支、循环、跳转、异常处理、线程恢复等功能都需要依赖这个计数器来完成。
程序执行过程中,会不断的切换当前执行线程,切换后,为了能让当前线程恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,并且各线程之间计数器互不影响,独立存储。
作用:
- 字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制,如:顺序执行、选择、循环、异常处理。
- 在多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来的时候,能够知道当前线程的运行位置
Java 虚拟机栈(Java Virtual Machine Stacks)
每个线程在运行时都有一个 Java 虚拟机栈,用于存储方法的栈帧。每个栈帧包括了该方法的局部变量、操作数栈、方法返回值和异常处理器等信息。Java 虚拟机栈是由一个个栈帧组成,而每个栈帧中都拥有:局部变量表、操作数栈、动态链接、方法出口信息。
\
每一次方法调用都会有一个对应的栈帧被压入VM Stack虚拟机栈,每一个方法调用结束后,代表该方法的栈帧会从VM Stack虚拟机栈中弹出。在活动线程中, 只有位于栈顶的帧才是有效的, 称为当前活动栈帧,代表正在执行的当前方法。
Java 方法有两种返回方式,不管哪种返回方式都会导致当前活动栈帧被弹出
- return语句
- 抛出异常
Java 虚拟机栈会出现两种错误:StackOverFlowError和 OutOfMemoryError。
- StackOverFlowError: 当线程请求栈的深度超过
JVM
虚拟机栈的最大深度的时候,就抛出 StackOverFlowError错误。 - OutOfMemoryError:
JVM
的内存大小可以动态扩展, 如果虚拟机在动态扩展栈时无法申请到足够的内存空间,则抛出OutOfMemoryError异常。
本地方法栈(Native Method Stack)
native关键字修饰的本地方法被执行的时候,在本地方法栈中也会创建一个栈帧,用于存放该native本地方法的局部变量表、操作数栈、动态链接、方法出口信息。方法执行完毕后,相应的栈帧也会出栈并释放内存空间。也会出现 StackOverFlowError和 OutOfMemoryError两种错误。
Java 堆(Java Heap)
是所有线程共享的内存区域,用于存储对象实例和数组。Java 堆是 JVM 运行时内存中最大的一块内存区域。
新生代、老年代
Heap
堆是垃圾收集器GC
管理的主要区域,因此堆区也被称作GC
堆。
从垃圾回收的角度,由于现在收集器基本都采用分代垃圾收集算法,所以JVM中的堆区往往进行分代划分,例如:新生代 和 老年代。目的是更好地回收内存,或者更快地分配内存。
创建一个新对象,在堆中的分配内存。
大部分情况下,对象会在 Eden
区生成,当 Eden
区装填满的时候,会触发 Young Garbage Collection
,即 YGC
垃圾回收的时候,在 Eden
区实现清除策略,没有被引用的对象则直接回收。
依然存活的对象会被移送到 Survivor
区。Survivor
区分为 s0
和 s1
两块内存区域。每次 YGC
的时候,它们将存活的对象复制到未使用的Survivor
空间(s0
或 s1
),然后将当前正在使用的空间完全清除,交换两块空间的使用状态。每次交换时,对象的年龄会加+1
。
如果 YGC
要移送的对象大于 Survivor
区容量的上限,则直接移交给老年代。一个对象也不可能永远呆在新生代,在 JVM
中 一个对象从新生代晋升到老年代的阈值默认值是 15
,可以在 Survivor
区交换 14 次之后,晋升至老年代。
堆区最容易出现的就是 OutOfMemoryError
错误,这种错误的表现形式会有以下两种:
OutOfMemoryError: GC Overhead Limit Exceeded
: 当JVM
花太多时间执行垃圾回收,并且只能回收很少的堆空间时,就会发生此错误。OutOfMemoryError: Java heap space
:假如在创建新的对象时, 堆内存中的空间不足以存放新创建的对象, 就会引发此错误。
元空间(Meta Space)
元空间是Java虚拟机中的一块内存区域,用于存储类的元数据(即描述类的数据)。在Java 8之前,元数据存储在永久代中,但在Java 8中,永久代被移除,元数据被转移到了元空间中。元空间的大小可以根据应用程序的需要自动调整,而不受固定的大小限制。由于元数据的越来越大,元空间的大小也会随之增长。在使用大量类的应用程序中,需要特别注意元空间的占用情况,以免发生内存溢出等问题。
由于元空间并不在虚拟机中,而是使用本地内存。因此,默认情况下,元空间的大小仅受本地内存限制。