浅识JVM

简介: 本篇文章,博主分享的是在面试中JVM常考的考点,希望这篇文章能够对你有用。

1.JVM执行流程


程序在执行之前先要把Java代码转换成字节码(class文件),JVM首先需要把字节码通过一定的方式如类加载器(ClassLoader),把文件加载到内存中的运行时数据区(Runtime Data Area),而字节码文件是JVM的一套指令集规范,并不能直接交给底层操作系统去执行,因此需要特定的命令解析器执行引擎(Execution Engine),将字节码翻译成底层系统指令再交给CPU去执行,而这个过程中需要调用其他语言的接口本地库接口(Native Interface),来实现整个程序的功能,这就是这4个主要组成部分的职责与功能。


微信图片_20230111190829.png

2.JVM内存区域划分


JVM的内存区域的划分就是指JVM的 运行时数据区(Runtime Data Area) 的划分,按照规范,其可以划分为以下几个区域:

微信图片_20230111190825.png

JVM内存分为线程私有区和线程共享区,其中方法区和堆是线程共享区,栈和程序计数器是线程隔离的私有数据区。


2.1 程序计数器


该区域是内存中最小的区域,保存了下一条要执行的指令(指令就是字节码)的地址在哪里。


程序要想运行,JVM就需要把字节码加载起来放到内存中,然后程序把一条一条的指令从内存中取出来,放到CPU上执行,由于CPU是并发式执行程序的,它同时为多个进程服务,线程则更多,所以每个线程都得记录自己的执行位置,即每个线程都有一个程序计数器。


2.2 栈

在该区域存储的有局部变量和方法调用信息


在方法调用的时候,每次调用一个新的方法,就都涉及到入栈操作,每次执行完了一个方法,就都涉及到出栈,每个线程都有这样一份栈。


2.3 堆

实例对象(new 出来的实例)在该区域存储


因为实例对象在堆区域存储,所以对象的成员变量也在该区域存储。


一个进程只有一份堆,多个线程共用一个堆


2.4 方法区

类对象存储在方法区中


程序的.java文件转为.class字节码文件后,通过类加载器加载到内存中,就被JVM构造成了类对象,此处的类对象就在方法区中;

类对象描述了这个类的细节,类的名字、里面有哪些成员/方法,每个成员/方法叫啥名字是啥类型等类信息。

类对象中还有很重要的静态成员,也就是被static修饰的成员,其也是类属性;而普通的成员,叫做实例属性。


3.JVM类加载机制


3.1 类加载过程


JVM的类加载,就是为了把.class文件加载到内存中,构建成类对象;具体加载过程见下图:

微信图片_20230111190821.png

类加载包括三个步骤:Loading,Linking,Initializing

可参考官方文档:https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-5.html


微信图片_20230111190817.png

3.1.1 Loading

先找到对应的.class文件,然后打开并读取.class文件,把读取并解析到的信息,初步的填写到生成的类对象中。


.class文件的格式如下图,JVM就按照这个格式来加载生成类对象


微信图片_20230111190812.png

3.1.2 Linking


1)Verification

校验阶段


主要就是验证读到的内容是不是和规范中的规定格式完全匹配,如果发现这里读到的数据格式不符合规范,就会类加载失败,并且抛出异常。


2)Preparation

准备阶段


该阶段主要完成的工作是正式为类中定义的变量(即被staitic修饰的静态变量)分配内存,并设置类变量的初始值。


3)Resolution

解析阶段


该阶段主要完成的工作是Java虚拟机将常量池内的符号引用替换为直接引用的过程,也就是初始化常量的过程。


3.1.3 Initializing


真正地对类对象进行初始化,特别是静态成员,Java虚拟机真正开始执行类中编写的Java程序代码,将主导权移交给应用程序。


3.2 典型面试题


3.2.1 代码块的执行先后


执行以下代码块:


class A {
    public A() {
        System.out.println("A的构造方法");
    }
    {
        System.out.println("A的构造代码块");
    }
    static {
        System.out.println("A的静态代码块");
    }
}
class B extends A {
    public B() {
        System.out.println("B的构造方法");
    }
    {
        System.out.println("B的构造代码块");
    }
    static  {
        System.out.println("B的静态代码块");
    }
}
public class Test {
    public static void main(String[] args) {
        new B();
        System.out.println("----------");
        new B();
    }
}
//
A的静态代码块
B的静态代码块
A的构造代码块
A的构造方法
B的构造代码块
B的构造方法
----------
A的构造代码块
A的构造方法
B的构造代码块
B的构造方法


由以上代码的执行结果可得:


对象创建时代码的加载顺序为:静态代码–>非静态代码–>构造方法


若继承了父类,则加载顺序为:父类的静态代码块–>子类的静态代码块–>父类内部非静态代码–>父类的构造方法–>子类的非静态代码块–>子类的构造方法


静态代码块在类加载的时候只会执行一次,再实例化对象时并不会再次执行静态代码块。


3.2.2 双亲委派模型


双亲委派模型,描述的是JVM中的类加载器,如何根据类的全限定名(java.lang.String)来找到.class文件的过程。


类加载器是专门负责进行类加载的对象,默认的类加载器主要有三个,每个类加载器负责一个片区:


1.启动类加载器 (Bootstrap Class Loader)

负责加载标准库中的类(String,ArrayList,Random,Scanner…)


2.扩展类加载器 (Extension Class Loader)

负责加载JDK中扩展的类(较少用到)


3.应用程序类加载器 (Application Class Loader)

负责加载当前项目目录中的类


程序猿还可以自定义类加载器,来加载其他目录中的类


例如Tomcat就自定义了类加载器,用来专门加载webapps里面的.class


双亲委派模型就描述了上述类加载器如何通过配合,来找目录的过程:


微信图片_20230111190808.png

1)加载java.lang.String


a. 程序启动,先进入ApplicationClassLoader类加载器。


b. ApplicationClassLoader会检查其父加载器是否已经加载过了;如果没有就调用父 类加载器 ExtensionClassLoader


c. ExtensionClassLoader也会检查其父加载器是否加载过了;如果没有就调用父 类加载器 BootStrapClassLoader


d. BootStrapClassLoader也会检查其父加载器是否加载过了;但因为其没有父加载器,于是就扫描自己负责的目录


e. java.lang.String这个类在标准库中能找到,则直接由BootStrapClassLoader负责后续的加载过程


微信图片_20230111190805.png

2)加载自己写的Test类

a. 程序启动,先进入ApplicationClassLoader类加载器。


b. ApplicationClassLoader会检查其父加载器是否已经加载过了;如果没有就调用父 类加载器 ExtensionClassLoader


c. ExtensionClassLoader也会检查其父加载器是否加载过了;如果没有就调用父 类加载器 BootStrapClassLoader


d. BootStrapClassLoader也会检查其父加载器是否加载过了;但因为其没有父加载器,于是就扫描自己负责的目录;没有扫描到,回到子加载器继续扫描


e. ExtensionClassLoader也扫描自己的目录,也没扫描到,回到子加载器继续扫描


f. ApplicationClassLoader也扫描自己负责的目录,能找到Test类,于是进行后续加载,查找目录环节结束


如果最终ApplicationClassLoader也找不到,就会抛出ClassNotFoundException的异常


微信图片_20230111190802.png

这样的查找规则就叫做双亲委派模型


4.JVM的垃圾回收(GC)


在我们写代码的时候,经常会通过创建变量,new 对象或者加载类等方式来申请内存,但我们不能只申请内存而不释放。


申请内存的时机我们一般都是明确的(需要保存某些数据时,就需要申请内存),但是释放内存的时机,则不是那么清楚;


内存释放的早或者晚都会影响开发效率,所以我们需要让内存释放的时机恰到好处;


Java,Go,Python等较为主流的编程语言就采取了垃圾回收机制(GC),靠运行时环境额外多做一些工作,来完成释放内存的操作,让程序猿的工作量减少。


同样,垃圾回收机制也带来了一些劣势:


消耗额外的开销(消耗的资源更多了)

可能会影响程序的流畅运行(会引入STW问题)

STW<=>Stop The World 时间静止,就是卡了。


4.1 什么垃圾需要回收?


在JVM的内存中,划分有很多区域,我们在2中也有介绍,但是不是这些所有的区域都需要进行垃圾回收;


程序计数器占据固定大小的内存,不涉及释放,所以也就用不到GC;栈在函数执行完毕后,对应的栈帧就自动释放了,也不需要GC;堆是最需要GC的,因为代码中大量的内存都是在堆上;方法区中存储类对象,进行类卸载操作需要释放内存,但类卸载操作是一个非常低频的操作,所以也不需要过多考虑,只需考虑堆区域内存的垃圾回收即可。


在堆区域中,空间被分为三部分,一部分是正在使用的内存,一部分是不再使用,但尚未回收的内存,另一部分是未分配的内存;同时也有三类对象在这三部分区域中存在,一种是“积极派”,一种是“消极派”,还有另一种是“中间摇摆派”。

微信图片_20230111190758.png

在上述的三个对象中,JVM只会回收“消极派”对象,GC回收是以对象为单位的,并不会出现半个对象的情况。


我们知道了什么垃圾需要回收,下边就需要我们来进行具体的垃圾回收操作,第一阶段需要我们找垃圾(判别垃圾),第二阶段就是释放垃圾,下边具体介绍:


4.2 如何找垃圾(判定垃圾)?


4.2.1 基于引用计数


该方案的具体操作是,针对每个对象,都会额外引入一小块内存,保存这个对象有多少个引用,当这个引用计数为0时,也就说明这个内存不再使用了,就释放了。


void func() {
  Test t=new Test();
  Test t2=t;//t和t2都是指向new对象的引用
}


微信图片_20230111190755.png

如上边的func()方法的调用过程中,创建了分配对象(分配了内存),在方法的执行过程中,引用计数是2,当方法执行结束后,由于t和t2都是局部变量,就跟随栈帧一起释放了,如此而来引用计数就变为了0,此时就认为这个对象是垃圾。


通过引用计数的方法来找垃圾,确实简单可靠高效,但也存在两个问题:


1.空间利用率较低

每个new的对象都需要额外开辟一块空间来存储计数器,如果对象本身就很小,而且还需要计数器,就会导致空间浪费很大。


2.会有循环引用的问题

通过以下代码来引入这个问题:


class Test {
  Test t=null;
}
Test t1=new Test();
Test t2=new Test();
t1.t=t2;
t2.t=t1;
---------------------
t1=null;
t2=null;
//代码执行到这里就会出现循环引用的问题


微信图片_20230111190751.png

在代码执行完最后一行后,两个对象的引用计数不为0,但是引用在彼此身上,外界的代码无法访问到这两个对象,此时此刻,这两个对象既不能使用,也不能释放,就出现了“内存泄漏”的问题。


4.2.2 基于可达性分析


该方案是Java采取的方案。


可达性分析就是通过额外的线程,对整个内存空间的对象进行扫描,首先会找到一些起始位置GCRoots,从这些位置开始,以类似深度优先搜索的方式去遍历对象,它会对所有可以到达的对象进行标记,无法到达的对象自然也就没有标记,就被视为垃圾。


微信图片_20230111190748.png

可以作为GCRoots的对象有以下几种:


栈上的局部变量

常量池中的引用指向的对象

方法区中的静态成员指向的对象

可达性分析的方法克服了引用计数中空间利用率低,循环引用两个缺点,但其也有自身的缺点:系统开销较大,遍历一次可能比较满。


结合以上两种方法,找垃圾的核心就是确定这个对象未来是否还会使用了,如果没有引用指向这个对象,这个对象就是垃圾,不再使用了。


4.3 垃圾如何回收(释放内存)?

垃圾回收算法共有以下四种:


4.3.1 标记-清除算法

标记就是可达性分析的过程,清除就是直接释放内存,如果直接释放内存,虽然内存是还给系统了,但是被释放的内存还是离散的(不连续)


微信图片_20230111190744.png

在需要申请内存时,并不能提供连续的内存,这将非常影响程序的执行。


4.3.2 复制算法

为了解决内存碎片,引入了复制算法


复制算法就是把内存分为两部分,直接把不是垃圾的,拷贝到另一半,然后把原来的空间整体释放掉。


微信图片_20230111190740.png

但该算法存在的问题有:


内存空间利用率低

如果要保留的对象多,要释放的对象少,此时复制开销就很大


4.3.3 标记-整理算法


该算法又针对复制算法做出了改进,该算法的实现类似于顺序表中删除中间元素的操作。


这个方法的空间利用率虽然提高了,但是仍然没有解决复制/搬运元素开销大的问题。


4.3.4 分代回收算法

上述的三种算法,虽然都能解决问题,但都有缺陷,在实际的JVM中的实现,会把多种方法结合起来使用,就形成了分代回收


针对对象进行分类(根据对象的“年龄”分类),一个对象经历过一轮GC扫描,就认为是“长了一岁”,针对不同年龄的对象,采取不同的方案,这就是分代回收。


在内存中划分出如下图的区域划分:


微信图片_20230111190728.png

1.刚创建出来的对象,就放在伊甸区


2.如果伊甸区的对象熬过一轮GC扫描,就会被拷贝到幸存区(应用了复制算法)


3.在后续的几轮GC中,幸存区的对象就在两个幸存区之间来回拷贝(复制算法),每一轮拷贝都会淘汰掉一波幸存者


4.在持续若干轮之后,对象进入老年代,老年代中的对象都是比较老的,也就是继续存活的可能性是越大的,因此在老年代中的GC扫描频率大大低于新生代,所以在老年代中使用标记-清除或标记-整理的方式来进行回收。


4.4 垃圾收集器


在JVM里,上述垃圾回收算法具体实现的模块就是垃圾收集器,下边我们来了解一些常见的垃圾收集器。


4.4.1 串行收集器

串行收集就是指,在进行垃圾的扫描和释放的时候,业务线程要停止工作,这种方式扫描的慢,释放的也慢,会产生严重的STW(Stop The World,时间静止/卡顿)


基于这一方式的收集器有:


Serial收集器(新生代收集器,串行GC)

Serial Old收集器(老年代收集器,串行GC)


4.4.2 并行收集器


并行收集,就是通过引入多线程,来进行垃圾的扫描和释放任务,效率比串行更高


基于这一方式的收集器有:


ParNew收集器(新生代收集器,并行GC)

Parallel Scavenge收集器(新生代收集器,并行GC)

Parallel Old收集器(老年代收集器,并行GC)


4.4.3 CMS收集器


该收集器设计的初衷就是尽可能的让STW时间短,其具体流程如下:


1)初始标记

速度很快,只会引起短暂的STW(这一步只是找到GCRoots)


2)并发标记

虽然速度慢,但是可以和业务线程并发执行,不会产生STW


3)重新标记

2)的业务代码可能会影响并发标记的结果,3)就是针对2)的结果进行微调,虽然会引起STW,但是很快。


4)回收内存

该操作也与业务线程并发


前三步就是可达性分析分开来完成了,最后一步就是通过标记-整理来处理老年代对象。


4.4.4 G1收集器


G1收集器是唯一一款全区域的垃圾回收器,该收集器把整个内存,分成了很多小的区域-Region,有的Region放新生代对象,有的放老年代对象,然后在扫描的时候,一次扫描若干个Region(不追求一轮GC就扫描完,分多次来扫描),这样对于业务代码的影响最小。


相关文章
|
4月前
|
Arthas 监控 算法
JVM成神路终章:深入死磕Java虚拟机序列总纲
JVM成神路终章:深入死磕Java虚拟机序列总纲
107 1
|
Java
烧点脑子使劲看--JVM运行时数据区详讲(上)
Java虚拟机在执行程序的过程会把它管理的内存划分为若干个不同的数据区。这些数据区有些是随着虚拟机进程的启动而一直存在的,有些区域则是依赖线程的启动和结束而创建和销毁的。
97 0
|
Oracle 前端开发 Java
小师妹学JVM之:JVM的架构和执行过程
小师妹学JVM之:JVM的架构和执行过程
小师妹学JVM之:JVM的架构和执行过程
|
缓存 Java 编译器
烧点脑子使劲看--JVM运行时数据区详讲(下)
关于VM运行时数据区详讲(上)可以看我上篇的文章
110 0
|
前端开发 安全 Java
JVM00_面试官对类加载器子系统、运行时数据区、内存分布、执行引擎的灵虚拷问,你能坚持到第几问?(二)
⑧. 判定一个常量是否"废弃”还是相对简单,而要判定一个类型是否属于"不再被使用的类”的条件就比较苛刻了。需要同时满足下面三个条件 ⑨. 说说类的加载器分类? ⑩. 说说你对引导类加载器、扩展类加载器和应用程序类加载器的理解 ⑩①. 谈谈你对双亲委派机制的理解
121 0
JVM00_面试官对类加载器子系统、运行时数据区、内存分布、执行引擎的灵虚拷问,你能坚持到第几问?(二)