在当今的软件开发领域,Java 语言及其运行环境——Java 虚拟机(JVM)占据了举足轻重的地位。无论是企业级应用、Web 应用还是移动应用,JVM 都扮演着核心角色。然而,对于许多初学者来说,理解 JVM 的工作原理和内部机制可能是一项挑战。
本文将带你从零开始,逐步了解 JVM 的基本概念、结构与功能。无论你是刚刚接触 Java 编程的新手,还是希望深入了解 JVM 内部运作的技术爱好者,这篇文章都将为你提供全面而易懂的知识点介绍。
一、详解JVM基础概念
1.什么是JVM?JVM的作用是什么
JVM是Java设计者用于屏蔽多平台差异,基于操作系统之上的一个"小型虚拟机",正是因为JVM的存在,使得Java应用程序运行时不需要关注底层操作系统的差异。使得Java程序编译只需编译一次生成class字节码,即可在任何操作系统都可以以相同的方式运行。
2.JVM运行时区域划分
(1) JVM体系结概览
因为JVM屏蔽了底层操作系统的差异,所以它自成了一套内存结构进行独立的内存管理,整体来说JVM可分为以下几个部分:
- 方法区
- 堆区
- 虚拟机栈和本地方法栈
- 程序计数器
对应的我们也给出一张宏观的图片:
(2) 方法区
我们先来说说方法区,这里我们所说的方法区指的不是存放Java方法的区域,并且它也只是一个逻辑上的物理区域的概念,在不同的JDK版本中它的实现都会有所和不同,方法区主要存放的数据包括:
- 类信息:例如类名、父类名、接口列表、常量池、字段表、方法表等。
- 常量池:存储编译器生产的各种字面量和符号引用。
- 方法代码:包括方法的字节码指令和其他辅助信息,例如操作数栈和局部变量表等。
- 静态变量:属于类的各种静态变量。
- 类的构造器和初始化块。
(3) 堆内存
然后就是JVM的堆区,对象实例和数组大部分都会存储在这块内存空间中,注意笔者这里所说的一个强调——大部分,因为现代即时编译技术的进步,在JVM进行逃逸分析时如果发现对象并未逃逸,则会直接进行栈上分配、标量替换等手段将其分配在栈空间,并且java堆区是线程共享区域的,所以多线程情况下操作相同对象可能存在线程安全问题。
(4) 虚拟机栈
我们日常对象实例的方法调用都是在虚拟机栈上运行的,它是Java方法执行的内存模型,存储着被执行方法的局部变量表、动态链表、方法入口、栈的操作用(入栈和出栈)。
由于虚拟机栈是栈结构所以方法调用按顺序压入栈中,就会倒序弹出虚拟机栈,例如我们的下面这段代码:
public void a(){
b();
}
public void b(){
c();
}
public void c(){
}
当线程调用a方法时,优先为a产生一个栈帧A压入栈中,发现a方法调用了b方法,再为b产生一个栈帧B压入栈中,然后b再调用c方法,再为c产生一个栈帧C方法压入栈中。
同理,执行顺序也是c先执行结束,优先弹出栈,然后是b,最后是a。由此我们可知Java中方法是可以嵌套调用的,但这并不意味方法可以无线层次的嵌套调用,当方法嵌套调用深度超过了虚拟机栈规定的最大深度,就会抛出StackOverflowError,而这个错误也常常发生在我们编写的无终止条件的递归代码中。
虚拟机栈属于线程独享,所以也就没有什么生命周期的概念,每个方法随着调用的结束栈空间也随之释放,所以栈的生命周期也可以理解为和线程生命周期是一致的。
每个方法的调用都是往虚拟机栈中压入一个栈帧,例如上述我们调用a方法,就是将a方法压入栈帧,而每一个栈帧都有一个局部变量表,这个局部变量表用于记录方法体内的某些基本类型(byte、short、int、boolean、float、char、long、double)还有对象引用(不等同于对象本省,可能是一个指向对象起始地址的引用指针)和returnAddress(指向一条字节码指令的地址)。
这些数据都会存储在局部变量表的slot槽中,在某些情况下每个栈帧可能存在复用,我们不妨举个例子,可以看到下面这段代码就是在main方法上分配一个byte数组,我们添加一个-verbose:gc参数观察gc回收情况:
public static void main(String[] args) {
byte[] placeHolder = new byte[1024 * 1024 * 64];
System.gc();
}
查看输出结果可以看到byte数组空间没有被回收,就是因为slot局部变量placeHolder 对应的槽还没有被其他变量所复用,这也就意味着此刻可达性算法分析认为这块placeHolder 不可被GC所以就不会被垃圾回收:
[GC (System.gc()) 86054K->68541K(243712K), 0.0023357 secs]
[Full GC (System.gc()) 68541K->68243K(243712K), 0.0203291 secs]
对此我们简单调整一下代码,将placeHolder 放在某个作用域里,只要执行走出这个作用域,就意味着placeHolder 为无用的局部变量,后续新分配的a就会直接复用局部变量表的空间:
public static void main(String[] args) {
{
//placeHolder在代码块的作用域内完成内存分配
byte[] placeHolder = new byte[1024 * 1024 * 64];
}
//分配一个新的变量尝试复用上述slot
int a = 0;
System.gc();
}
这也就是为什么本次gc可以回收64M的内存空间的原因:
[GC (System.gc()) 86054K->68502K(243712K), 0.0023594 secs]
[Full GC (System.gc()) 68502K->2707K(243712K), 0.0221691 secs]
小结一下虚拟栈的特点:
- 是方法执行时的内存模型。
- 方法调用以栈帧形式压入栈中。
- 方法嵌套调用并将栈帧压入栈帧时,深度操作虚拟机栈最大深度会报StackOverflowError。
- 虚拟机栈的局部变量表随着变量使用的完结,之前的内存区域可被复用。
- 栈的生命周期跟随线程,线程调用结束栈即可被销毁。
本地方法栈
下面这个带有native关键字的方法就是在本地方法,它就是本地方法栈管理的方法,其工作机制和特点是虚拟机栈是差不多的,所以这里就不多做介绍了。
private native void start0();
(5) 程序计数器
程序计数器和我们操作系统学习的程序计数器概念差不多,是一块比较小的内存空间,我们可以将其看作当前现场所执行的字节码行号的指示器,记录着当前线程下一条要执行的指令的地址,对于程序中的分支、循环、跳转、异常以及线程恢复和挂起都是基于这个计数器完成的。
我们以下面这段代码为例展示一下程序计数器实质记录的信息:
public static void main(String[] args) {
int num = 1;
int num2 = 2;
int num3 = 3;
System.out.println("total: " + (num + num2 + num3));
}
可以看到实际上其编译后的字节码内容如上,每一行指令前方所记录的字节码的偏移地址就是程序计数器所记录的地址信息:
因为是每一个线程都有各自的计数器,所以我们可以认为计数器是不会互相影响是线程安全的。需要注意的是程序计数器只有在记录虚拟机栈的方法时才会有值,对于native方法,程序计数器是不工作的。
二、详解JVM类加载器
1.什么是类加载器
类加载器实现将编译后的class文件加载到内存,并转为为运行时区域划分的运行时数据结构,注意类加载器只能决定类的加载,至于能不能运行则是由Execution Engine 来决定。
整体来说,类加载器对应类的生命周期应该是以下几个阶段:
- 加载
- 链接:分为验证、准备、解析
- 初始化
- 使用:此时用户就可以基于这个类创建实例了
- 卸载
2.类的加载
加载的过程本质上就是将class文件加载到JVM中,JVM根据类的全限定名获取定义该类的二进制字节流。
- 将编译后class文件加载到内存。
- 将静态数据结构转换成方法区中运行时数据结构。
- 在堆区创建一个java.lang.Class对象作为数据访问的入口。
3.链接的过程
链接整体是分为上述所说的3个过程:
- 验证:分为验证阶段主要是校验类的文件格式、元数据、字节码、二进制兼容性
- 准备:在方法区为静态变量常见空间,并对其进行初始化,例如private static int a=3;,在此阶段就会在方法区完成创建,并初始默认值0。
- 解析:即将类的符号引用直接转换为直接引用,引用包括但不限于类、接口、字段、类方法、接口方法、方法类型、方法句柄、发文控制修饰符等,例如import java.util.ArrayList在此阶段就会直接转为指针或者对象地址。
4.初始化
将方法区中准备好的值,通过调用<cinit>完成初始化工作。<cinit>会收集好所有的赋值动作,例如上文的private static int a=3就是这时候完成赋值的。
5.卸载
当对象使用完成后,GC将无用对象从内存中卸载。
6.类加载器的加载顺序
其实类加载器并非只有一个,按照分类我们可以将其分为:
BootStrap ClassLoader:rt.jar
Extention ClassLoader: 加载扩展的jar包
App ClassLoader:指定的classpath下面的jar包
Custom ClassLoader:自定义的类加载器
所以,为了保证JDK自带rt.jar的类能够正常加载,就出现了一种名为双亲委派的类加载机制。
举个例子,JDK自带的包中有一个名为String的类,而我们自定义的代码中也有一个String类,我们自己的类肯定是由App ClassLoader完成加载,如果我们的类加载器优先执行,那么JDK自带的String类就无法被使用到。
所以双亲委派机制就规定了类加载优先由BootStrap ClassLoader先加载,只有根加载器加载不到需要的类,才会交由下层类完成加载。 正是因为双亲委派机制的存在,jdk自带的String类才能够正常的使用,而我们也无法通过自定义String类进行重写。
类加载器的工作流程为:
- 加载class文件到方法区并转为运行时数据结构,并在堆区创建一个Class对象作为入口
- 验证class的类方法是否由危害JVM的行为
- 准备阶段初始化静态变量数据
- 解析阶段将符号引用转为可以可直接导向对象地址的直接引用
- 初始化阶段通过cinit方法初始化对象实例变量等数据
- 使用完成后该类就会被卸载。
7.用一个线程的代码执行解释Java文件是如何被运行的
如下所示,我们编写一个Student 类,他有name这个成员属性:
/**
* 学生类
*/
public class Student {
private String name;
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
}
然后我们编写一个main方法,调用student类,完成属性赋值。
public class Main {
public static void main(String[] args) throws InterruptedException {
Student student = new Student();
student.setName("小明");
}
}
首先编译得到Main.class文件后,系统会启动一个JVM进程,从classpath中找到这个class的二进制文件,将在到方法区的运行时数据区域:
再将当前执行的main方法压入虚拟机栈中:
main方法中需要new Student();,JVM发现方法区中没有Student类的信息,于是开始加载这个类,将这个类的信息存放到方法区,并在堆区创建一个Class对象作为方法区信息的入口。
new Student();在此时就会根据类元信息获取创建student对象所需要的空间大小,在堆区申请并开辟一个空间调用构造函数创建Student实例。
main方法调用setName,student 的引用找到堆区的Student,通过其引用找到方法区中Student 类的方法表得到方法的字节码地址,从而完成调用。
上述步骤完成后,方法按照入栈顺序后进先出的弹出,虚拟机栈随着线程一起销毁。
三、详解虚拟机堆
1.区域划分
JVM将堆内存分为年轻代和老年代。以及一个非堆内存区域,我们称为永久代,注意:这里所说的永久代只有在JDK8之前才会出现,对此我们也给出JDK8之前版本的堆内存区域划分图解:
在Java8之后考虑到与其他规范的虚拟机的兼容性以及GC效率,将方法区的实现交由元空间实现,元空间所使用的内存都是本地内存,这里的本地内存说的就是我们物理机上的内存,所以理论上物理机内存多大,元空间内存就可以分配多大,元空间大小分配和JVM从物理机上分配的内存大小没有任何关系。
对应的我们也给出元空间两个设置参数:
- MetaspaceSize:初始化元空间大小,控制发生GC
- MaxMetaspaceSize:限制元空间大小上限,防止占用过多物理内存。
2.详解新生代
我们再来聊聊年轻代,新生代又可以分为Eden和Survivor区,Survivor区又被平均分为两块。所以年代整体比例为8:1:1。当然这个值也可以通过-XX:+UsePSAdaptiveSurvivorSizePolicy来调整。
任何对象刚刚创建的时候都会放在Eden区。我们都知道堆区内存是共享的,所以Eden区的空间也是多线程共享的,但是为了确保多线程彼此之间相对独立(注意是线程之间彼此独立而不是操作Eden区对象独立),Eden区会专门划出一块连续的空间给每个线程分配一个独立空间,这个空间叫做TLAB空间,每个线程都可以操作自己的TLAB空间和读取其他线程的TLAB空间。
一旦Eden区满了之后,就会触发第一次Minor GC,就会将存活的对象从Eden区放到Survivor区。
需要注意的是,Survivor分为Survivor0和Survivor1区。JVM使用from和to两个指针管理这两块区域,其中from指针指向有对象的区域空间,to指针指向空闲区域的Survivor空间。
从Eden区中存活下来首先会在Survivor0区,一旦下一次Eden区空间满了之后就再次触发Minor GC 将Eden区和Survivor0区存活的对象复制到Survivor1区,就这样保存存活的对象在两个Survivor区中来回游走,直到晋升到老年代:
经过15次之后还活着的对象就会被存放到老年代,这里是15是由-XX:MaxTenuringThreshold指定的,-XX:MaxTenuringThreshold 占4位,默认配置为15。 这里补充一下,同样会将Survivor存放到老年代的第2个条件,当Survivor区对象比例达到XX:TargetSurvivorRatio时,也会将存活的对象放到老年区。
3.详解老年代
老年代存放的都是经历过无数次GC的老对象,一旦这个空间满了之后就会出现一次Full GC,Full GC期间所有线程都会停止手头工作等待Full GC完成,所以在此期间,系统可能会出现卡顿现象。 这就意味着在高并发多对象创建场景的情况下,我们需要合理分配老年区的内存。一旦Full GC后还是无法容纳新对象,就会报OOM问题。
四、JVM如何判断对象是否需要被销毁
1.引用计数器法
一个对象被引用时+1,被解除引用时-1。我们根据引用计数结果决定是否GC,但是这种方式无法解决两个对象互相引用的情况。例如我们栈区没有一个引用指向当前两个对象,可堆区两个对象却互相引用对方。
2.可达性分析法
将一系列的GC ROOTS作为起始的存活对象集,查看是否有任意一个GC ROOTS可以到达这个对象,都不可达就说明这个对象要被回收了。
而以下几种可以作为GC ROOTS:
- 虚拟机栈中的局部变量等,被该变量引用的对象不可回收。
- 方法区的静态变量,被该变量引用的对象不可回收。
- 方法区的常量,被该变量引用的对象不可回收。
- 本地方法栈(即native修饰的方法),被该变量引用的对象不可回收。
- 未停止且正在使用该对象的线程,被该线程引用的对象不可回收。
通过可达性算法分析对象是否被回收需要经过两个阶段:
- 可达性分析法发现不可达的对象后,就将其第一次标记一下,然后判断该对象的是否要执行finalize()方法,若确定则将其存到F-Queue中。
- 将F-Queue中的对象调用finalize(),若此时还是没有任何引用链引用,则说明这个对象要被回收了。
五、详解几种常见垃圾回收算法
1.标记清除法
如下图,这种算法很简单,标记出需要被回收的对象的空间,然后直接清除。同样的缺点也很明显,容易造成内存碎片,内存碎片也很好理解,回收的对象空间都是一小块一小块的,当我们需要创建一个大对象时就没有一块连续大空间供其使用。
2.复制算法
这种算法和上文说的survivor一样,将空间一分为二,from存放当前活着的对象,to作为空闲空间。在进行回收时,将没有被标记回收的对象挪到另一个空间,然后from指向另一个空间。这种算法缺点也很明显,可利用空间就一半。
3.标记整理
这种算法算是复制算法的改良版,将存活对象全部挪动到一段,确保空闲和对象空间都是连续的,且空间利用率100%。
4.分代收集算法(综合算法)
这种算法就是上面算法的组合,即年轻代存活率低,采用复制算法。老年代存活率高,采用标记清除算法或者标记整理算法。例如hotspot虚拟机的搭配就是新生代采用复制算法,每次触发Minor gc就将Eden和survivor区存活的对象移动到to指针指向的survivor区,而老年代而用标记整理法将存活的对象都归整到同一个段中: