2023跳槽最新面试题整理——JVM系列
今天是农历2022年腊月二十七了,和往常的春节假期、五一假期和十一假期一样都是团队中坚持到最后的一个。没几天也要快过年了,我先提前向大家拜个早年——祝大家兔年大吉,新春快乐,财源滚滚,万事如意。
今年从十一假期之后由于种种原因也没有更新文章,今天最后一天,由于做其他的也没有心情,那就老规划——最后一天就自己静下心来整理文章来和大家分享;再加上今年由于YQ放开,大家都在积蓄力量准备明年跳槽,那今天我就开始给大家来持续更新面试题系列吧,这篇文章先来更新JVM系列,打算完整的给大家整理出面试问题重点,然后再看时间是否充裕再来判断是否给大家整理详细问题答案。
1、类加载机制
1.1、类加载过程
类加载过程可以初步分成如下几步:
加载—>验证—>准备—>解析—>初始化
- 加载: 根据类的全限定类名从磁盘获取类的二进制字节流。
- 验证: 字节码验证、文件格式验证、元数据验证和符号引用验证
- 准备: 静态变量分配内存并且赋予零值
- 解析: 将在编译阶段的符号引用转化为直接引用,直接指向其内存地址
- 初始化: 将解析过程中的零值更改为程序员赋值的值
1.2、类加载器分类
类加载器分为:启动类加载器、扩展类加载器、应用程序类加载器和自定义类加载器
- 启动类加载器(Bootstrap Class Loader): 加载< JAVA-HOME >\lib目录下的文件
- 扩展类加载器(Extension Class Loader): 加载< JAVA-HOME >\lib\ext目录下的文件
- 应用程序类加载器(Application Class Loader): 加载用户类路径下的所有类包,也就是用户自己写的类
- 自定义类加载器(User Class Loader): 负责加载用户自定义的类路径下的类包
1.3、双亲委派机制
双亲委派机制流程图如下所示:
1.3.1、双亲委派如何运行
这段我就用大白话来给大家讲解了哈。比如当我自己写的一个类需要加载的时候,它会先去应用程序类加载器中看是否被加载过,发现没有;然后交由应用程序类加载器的父类扩展类加载器看是否被加载过,发现也没有;然后它就会交由扩展类加载器的父类启动类加载器加载,发现同样也没有,上面的这些交由父类加载器就是向上委托;此时会进行向下指派,启动类加载器指派给它的子类扩展类加载器,扩展类加载器指派给应用程序类加载器,此时应用程序类加载器没有向下指派的了,那么就由应用程序类加载器加载。
上面如果大家不太明白,你们可以根据我的上段所说的内容去画流程图,那么大家在画流程图之后就完全明白了。
1.3.2、双亲委派这种机制好处
采用双亲委派机制加载主要有一下两点好处:
1、保证沙箱安全;比如JDK中的java.lang.String中,如果我对这个类重写了,那么是不生效的,因为它在走到启动类加载器的时候发现已经被加载了,那么就会直接返回,此时就保证了其安全。
2、防止重复加载
1.3.3、为什么在父类加载器没有加载的时候为什么不自己加载,反而交给子类加载器加载?
如果直接直接在父类加载器加载,此时加载看着比较省事了,因为不需要再向下指派,但是在下一次需要加载的时候,又需要向上进行查找,那么是比较麻烦的,所以直接进行向下委派给子类加载器加载。
2、JVM运行时数据区
JVM运行时数据区主要包括堆、虚拟机栈、方法区、本地方法栈和程序计数器。
运行时数据区图如下所示:
下面我详细介绍下运行时数据区各个部分其功能:
线程共享:
- 堆: 主要存放new出来的对象,还有数组和JDK1.8存放字符串常量池;
- 方法区: 主要存放类信息、方法信息,静态变量和常量池等等;
线程独享:
- 虚拟机栈: 虚拟机栈是由一个个栈帧组成,当每调用一个方法时就是生成一个栈帧压入虚拟机栈中,栈帧中包含局部变量、操作数栈、动态链接和方法出口;
- 本地方法栈: 本地方法栈和虚拟机栈的原理一样,只是虚拟机栈调用的是Java方法,但是本地方法栈调用的是本地方法(Native);
- 程序计数器: 在Java程序运行过程中,会发生线程上下文切换,当线程重新获得CPU时间片时,要保证从上次执行的行数继续执行,那么程序计数器就是保证从原来位置继续执行;
3、JVM对象创建和内存分配机制
3.1、堆内存结构介绍
堆内存分为新生代和老年代,新生代:老年代=1:2;新生代中又分为Eden、S0和S1区;Eden:S0:S1=8:1:1;
3.2、对象创建流程
类加载流程如下图所示:
详细介绍其每一步流程:
1、加载: 当new对象时,会根据双亲委派机制检查下该类是否被加载过,如果没有加载过则执行类加载过程:加载—>验证—>准备—>解析—>初始化;
2、分配内存: 当加载完成之后,接下来就要为新生的对象去分配内存空间;当类加载完成之后就知道了对象所需要内存空间;就从堆中找出一块对象所需要内存空间大小的内存给该对象;
划分内存方法:
1、指针碰撞法: 如果Java堆中内存是绝对规整的,所有用过的内存都放在一边,空闲的内存放在另一边,中间放着一个指针作为分界点 的指示器,那所分配内存就仅仅是把那个指针向空闲空间那边挪动一段与对象大小相等的距离。
2、空闲列表法: 如果Java堆中的内存并不是规整的,已使用的内存和空闲的内存相互交错,那就没有办法简单地进行指针碰撞了,虚拟机就必须维护一个列表,记录上哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例, 并更新列表上的记录。
解决并发分配内存产生问题:
在并发情况下,可能出现正在给对象A分配内存,指针还没来得及修改,对象B又同时使用了原来的指针来分配内存的情况。
CAS(compare and swap)法:
虚拟机采用CAS配上失败重试的方式保证更新操作的原子性来对分配内存空间的动作进行同步处理。
3、初始化: 内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),这一步操作保证了对象的实例字段在Java代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。
注意: 这里的初始化和加载过程的初始化是不一样的,加载的初始化是将static修饰的静态变量赋予零值。
4、设置对象头: 初始化零值之后,要对对象做些必要设置,例如对象属于哪个类的实例对象,GC年龄,对象hashcode和如何找到类的元数据信息等等。
在虚拟机中,对象在内存中布局分为三部分:对象头、实例数据和对齐填充。
对象头主要包含两部分信息:一部分是运行时自身数据:hash码、GC年龄、偏向锁ID,线程持有的锁等等;另一部分是对象持有类的指针,即对象指向它类元数据的指针,虚拟机通过这个指针找到该对象归属于哪个类。
5、执行init()方法:
执行方法,即对象按照程序员的意愿进行初始化。对应到语言层面上讲,就是为属性赋值(注意,这与上面的赋零值不同,这是由程序员赋的值),和执行构造方法。
3.3、对象内存分配
对象内存分配流程图如下所示:
一般我们认为对象会直接分配到堆中新生代Eden区中,这是不对的,对象可能分配到虚拟机栈中,也有可能分配到堆中的老年代中,这都是可能发生的。下面我来介绍下这几种情况
3.3.1、对象在栈上分配
当对象发生了逃逸分析时,那么此时对象就会直接分配到栈中,而不是分配到堆中。这样做的目的是能够减少GC的压力,因为分配到栈中之后,会随着调用这个方法结束而出栈销毁。
什么是逃逸分析?
这个通俗讲就是我在方法内部new一个对象后,该对象只在该方法中使用,不会让方法以外的使用,那么此时就发生了逃逸分析。
3.3.2、对象在Eden区分配
正常情况下大量的对象都是分配到堆中新生代的Eden区。
3.3.3、大对象直接分配至老年代
当我们创建的是大对象时,会直接分配到老年代中,这样做的的目的减少它在新生代中每次GC来回复制,并且能减少触发minor GC次数而引发的实际不是真正长时间存活的对象而存放老年代所引发的一系列问题。
大对象是如何判断的呢?
在JVM中设置-XX:PretenureSizeThreshold 可以设置大对象的大小
3.3.4、长期存活的对象进入老年代
这种也是正常的,当对象创建之后放入堆中新生代的Eden区,然后当新生代满了之后触发minor GC,每次minor GC存活的对象,他们的对象头的GC年龄就会+1,当达到15次之后,就会讲对象存放进老年代,此时就是长期存活对象进入老年代。
3.3.5、对象动态年龄判断
当某一年龄的对象占Survivor区的一半以上时,那么他就会将大于等于它的GC年龄直接存放进老年代中。
3.3.6、老年带空间担保机制
当触发minor GC的时候都会去看下老年代的可用空间大小,如果老年代的可用空间大小大于等于新生代对象所有之和,那么可以直接触发minor GC;否则会查看是否开启了分配担保机制,如果没有开启,那么会直接触发full GC ,如果开启了,就会看每次minor GC存活的对象平均大小是否小于等于老年代可用空间大小,如果满足,则直接minor GC,否则触发Full GC。
4、垃圾收集
4.1、如何判断一个对象是否存活
判断对象是否存活有两种方式——计数器算法和可达性分析算法
4.1.1、计数器算法
此方法比较简单,每个对象就是专门有个计数的变量,当该对象被引用一次就会+1;引用失效一次就会-1;当计数变量为0时,代表该对象没有被其他对象引用,可以被垃圾回收。
该方法有个弊端,就是当两个对象互相引用是,比如A对象引用B对象,B对象又引用A对象,他们之间不可能将计数器减为0,这就无法将对象进行垃圾回收。
4.1.2、可达性分析算法
将GC roots为起点向下遍历,遍历到的对象说明被其他引用,需要存活,没有遍历到的对象说明被引用,是垃圾对象,会被回收。
GC roots根节点可以是静态变量、局部变量等等
4.2、垃圾收集算法
垃圾收集算法主要有复制算法、标记清除算法和标记整理算法
4.2.1、复制算法
效率比较高,主要是年轻代中使用该垃圾算法;它可以将内存分为大小相同的两块,每次使用其中的一块。当这一块的内存使用完后,就将还存活的对象复制到另一块去,然后再把使用的空间一次清理掉。这样就使每次的内存回收都是对内存区间的一半进行回收。
特点: 效率比较高,但是内存利用率不高;每次只能利用50%的内存
4.2.2、标记清除算法
算法分为“标记”和“清除”阶段:标记存活的对象, 统一回收所有未被标记的对象;
特点: 1、效率比较低(如果需要标记的对象非常多,那么效率就会下降);2、空间利用率较高,但是会产生空间碎片化,导致大对象在存放时不能存放
4.2.3、标记整理算法
根据老年代的特点特出的一种标记算法,标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象回收,而是让所有存活的对象向一端移动,然后直接清理掉端边界以外的内存。
4.3、垃圾收集器
常见的垃圾收集器有如下几种:
如果说收集算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现。
4.3.1、Serial收集器(-XX:+UseSerialGC -XX:+UseSerialOldGC)
Serial(串行) 收集器是最基本、历史最悠久的垃圾收集器了。大家看名字就知道这个收集器是一个单线程收集器了。它 的 “单线程” 的意义不仅仅意味着它只会使用一条垃圾收集线程去完成垃圾收集工作,更重要的是它在进行垃圾收集工作的时候必须暂停其他所有的工作线程( “Stop The World” ),直到它收集结束。
优点:
它简单而高效(与其他收集器的单线程相比)。Serial 收集器由于没有线程交互的开销,自然可以获得很高的单线程收集效率。
Serial Old收集器是Serial收集器的老年代版本,它同样是一个单线程收集器。它主要有两大用途:一种用途是在JDK1.5 以及以前的版本中与Parallel Scavenge收集器搭配使用,另一种用途是作为CMS收集器的后备方案 (当CMS在垃圾收集时,垃圾回收的内存远远不够对象创建所使用的内存,那么此时就会触发Seral Old收集器做兜底,会STW,整个线程不能使用)。
4.3.2、Parallel收集器(-XX:+UseParallelGC(年轻代),-XX:+UseParallelOldGC(老年代))
Parallel收集器其实就是Serial收集器的多线程版本,除了使用多线程进行垃圾收集外,其余行为(控制参数、收集算法、回收策略等等)和Serial收集器类似。默认的收集线程数跟cpu核数相同,当然也可以用参数(XX:ParallelGCThreads)指定收集线程数,但是一般不推荐修改。 Parallel Scavenge收集器关注点是吞吐量(高效率的利用CPU)。CMS等垃圾收集器的关注点更多的是用户线程的停顿时间(提高用户体验)。所谓吞吐量就是CPU中用于运行用户代码的时间与CPU总消耗时间的比值。
新生代采用复制算法,老年代采用标记-整理算法。
Parallel Old收集器是Parallel Scavenge收集器的老年代版本。使用多线程和“标记-整理”算法。在注重吞吐量以及 CPU资源的场合,都可以优先考虑 Parallel Scavenge收集器和Parallel Old收集器(JDK8默认的新生代和老年代收集器)。
4.3.3、ParNew收集器(-XX:+UseParNewGC)
ParNew收集器其实跟Parallel收集器很类似,区别主要在于它可以和CMS收集器配合使用。
新生代采用复制算法,老年代采用标记-整理算法。
4.3.4、CMS收集器(-XX:+UseConcMarkSweepGC(old))
CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。它非常符合在注重用户体验的应用上使用,它是HotSpot虚拟机第一款真正意义上的并发收集器,它第一次实现了让垃圾收集线程与用户线程 (基本上)同时工作。
从名字中的Mark Sweep这两个词可以看出,CMS收集器是一种 “标记-清除”算法实现。
过程步骤如下:
1、初始标记: 暂停所有的其他线程(STW),并记录下gc roots直接能引用的对象,速度很快。
2、并发标记: 并发标记阶段就是从GC Roots的直接关联对象开始遍历整个对象图的过程, 这个过程耗时较长但是不需要停顿用户线程, 可以与垃圾收集线程一起并发运行。因为用户程序继续运行,可能会有导致已经标记过的对象状态发生改变。
3、重新标记: 重新标记阶段就是为了修正并发标记期间因为用户程序继续运行而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段的时间稍长,远远比并发标记阶段时间短。主要用到三色标记里的增量更新算法(见下面详解)做重新标记。(也是STW)
4、并发清理: 开启用户线程,同时GC线程开始对未标记的区域做清扫。这个阶段如果有新增对象会被标记为黑色不做任何处理(见下面三色标记算法详解)。
5、并发重置: 重置本次GC过程中的标记数据。
从它的名字就可以看出它是一款优秀的垃圾收集器,主要优点:并发收集、低停顿。但是它有下面几个明显的缺点:
1、对CPU资源敏感(会和服务抢资源);
2、无法处理浮动垃圾(在并发标记和并发清理阶段又产生垃圾,这种浮动垃圾只能等到下一次gc再清理了);
3、它使用的回收算法-“标记-清除”算法会导致收集结束时会有大量空间碎片产生
4、执行过程中的不确定性,会存在上一次垃圾回收还没执行完,然后垃圾回收又被触发的情况,特别是在并发标记和并发清理阶段会出现,一边回收,系统一边运行,也许没回收完就再次触发full gc,也就是"concurrent mode failure",此时会进入stop the world,用serial old垃圾收集器来回收。
4.3.5、G1收集器
G1 (Garbage-First)是一款面向服务器的垃圾收集器,主要针对配备多颗处理器及大容量内存的机器。以极高概率满足GC 停顿时间要求的同时,还具备高吞吐量性能特征。
G1将Java堆划分为多个大小相等的独立区域(Region),JVM最多可以有2048个Region。 一般Region大小等于堆大小除以2048,比如堆大小为4096M,则Region大小为2M,当然也可以用参数"XX:G1HeapRegionSize"手动指定Region大小,但是推荐默认的计算方式。
G1保留了年轻代和老年代的概念,但不再是物理隔阂了,它们都是(可以不连续)Region的集合。
默认年轻代对堆内存的占比是5%,如果堆大小为4096M,那么年轻代占据200MB左右的内存,对应大概是100个 Region,可以通过“-XX:G1NewSizePercent”设置新生代初始占比,在系统运行中,JVM会不停的给年轻代增加更多 的Region,但是最多新生代的占比不会超过60%,可以通过“-XX:G1MaxNewSizePercent”调整。年轻代中的Eden和 Survivor对应的region也跟之前一样,默认8:1:1,假设年轻代现在有1000个region,eden区对应800个,s0对应100 个,s1对应100个。
一个Region可能之前是年轻代,如果Region进行了垃圾回收,之后可能又会变成老年代,也就是说Region的区域功能 可能会动态变化。
G1垃圾收集器对于对象什么时候会转移到老年代跟之前讲过的原则一样,唯一不同的是对大对象的处理,G1有专门分配 大对象的Region叫Humongous区
,而不是让大对象直接进入老年代的Region中。
在G1中,大对象的判定规则就是一个大对象超过了一个Region大小的50%,比如按照上面算的,每个Region是2M,只要一个大对象超过了1M,就会被放 入Humongous中,而且一个大对象如果太大,可能会横跨多个Region来存放。
Full GC的时候除了收集年轻代和老年代之外,也会将Humongous区一并回收。
G1收集器一次GC的运作过程大致分为以下几个步骤:
1、初始标记(initial mark,STW): 暂停所有的其他线程,并记录下gc roots直接能引用的对象,速度很快 ;
2、并发标记(Concurrent Marking): 同CMS的并发标记
3、最终标记(Remark,STW): 同CMS的重新标记
4、筛选回收(Cleanup,STW): 筛选回收阶段首先对各个Region的回收价值和成本进行排序,根据用户所期望的GC停顿时间(可以用JVM参数 -XX:MaxGCPauseMillis指定)来制定回收计划,比如说老年代此时有1000个 Region都满了,但是因为根据预期停顿时间,本次垃圾回收可能只能停顿200毫秒,那么通过之前回收成本计算得知,可能回收其中800个Region刚好需要200ms,那么就只会回收800个Region(Collection Set,要回收的集合),尽量把GC导致的停顿时间控制在我们指定的范围内。这个阶段其实也可以做到与用户程序一起并发执行,但是因为只回收一部分Region,时间是用户可控制的,而且停顿用户线程将大幅提高收集效率。不管是年轻代或是老年代,回收算法主要用的是复制算法,将一个region中的存活对象复制到另一个region中,这种不会像CMS那样 回收完因为有很多内存碎片还需要整理一次,G1采用复制算法回收几乎不会有太多内存碎片。(注意:CMS回收阶段是跟用户线程一起并发执行的,G1因为内部实现太复杂暂时没实现并发回收,不过到了Shenandoah就实现了并发收集,Shenandoah可以看成是G1的升级版本)。
G1收集器在后台维护了一个优先列表,每次根据允许的收集时间,优先选择回收价值最大的Region(这也就是它的名字 Garbage-First的由来),比如一个Region花200ms能回收10M垃圾,另外一个Region花50ms能回收20M垃圾,在回收时间有限情况下,G1当然会优先选择后面这个Region回收。这种使用Region划分内存空间以及有优先级的区域回收方式,保证了G1收集器在有限时间内可以尽可能高的收集效率。
G1垃圾收集分类:
1、YoungGC:
YoungGC并不是说现有的Eden区放满了就会马上触发,G1会计算下现在Eden区回收大概要多久时间,如果回收时间远远小于参数 -XX:MaxGCPauseMills 设定的值,那么增加年轻代的region,继续给新对象存放,不会马上做Young GC,直到下一次Eden区放满,G1计算回收时间接近参数 -XX:MaxGCPauseMills 设定的值,那么就会触发Young GC。
2、MixedGC:
不是FullGC,老年代的堆占有率达到参数(-XX:InitiatingHeapOccupancyPercent)设定的值则触发,回收所有的Young和部分Old(根据期望的GC停顿时间确定old区垃圾收集的优先顺序)以及大对象区,正常情况G1的垃圾收集是先做MixedGC,主要使用复制算法,需要把各个region中存活的对象拷贝到别的region里去,拷贝过程中如果发现没有足够的空region能够承载拷贝对象就会触发一次Full GC。
3、Full GC:
停止系统程序,然后采用单线程进行标记、清理和压缩整理,好空闲出来一批Region来供下一次MixedGC使用,这 个过程是非常耗时的。(Shenandoah优化成多线程收集了)
4.4、垃圾收集底层算法
4.4.1、三色标记
在并发标记的过程中,因为标记期间应用线程还在继续跑,对象间的引用可能发生变化,多标和漏标的情况就有可能发生。
这里我们引入“三色标记”来给大家解释下,把Gc roots可达性分析遍历对象过程中遇到的对象, 按照“是否访问过”这个条件标记成以下三种颜色:
- 黑色: 表示对象已经被垃圾收集器访问过, 且这个对象的所有引用都已经扫描过。 黑色的对象代表已经扫描过, 它是安全存活的, 如果有其他对象引用指向了黑色对象,无须重新扫描一遍。 黑色对象不可能直接(不经过灰色对象) 指向某个白色对象。
- 灰色: 表示对象已经被垃圾收集器访问过, 但这个对象上至少存在一个引用还没有被扫描过。
- 白色: 表示对象尚未被垃圾收集器访问过。 显然在可达性分析刚刚开始的阶段, 所有的对象都是白色的, 若 在分析结束的阶段, 仍然是白色的对象, 即代表不可达。
4.5、一次完整GC流程
这里准备用大白话来给大家来讲解哈。首先对象创建之后,会存放进堆中新生代的Eden区中,当Eden区满了,会触发minoe GC,存活的对象移动到S0区,并且存活的对象GC年龄+1;此时又有新建的对象存放进Eden区,当Eden区满了,就又会触发minior GC,这次不单单回收Eden区,还会回收S区,此时存活的对象会移动到S1区,存活的对象年龄+1;反反复复这样垃圾回收,当对象GC年龄大于15次时,那么此时对象会存放进老年代;当老年代满了之后,会触发Full GC,Full GC不单单回收老年代,它还回收新生代,Full GC 的时间会比较长,大概是Minor GC的十倍左右。
好了,上面就是一次完整的GC流程,大白话讲解的应该还算比较清晰。不理解的可以在评论中评论哦。
5、JVM调优
这个JVM调优就先不给大家讲解了吧,因为今天是年前上班的最后一天,已经下班了,我还明天中午的高铁,今天晚上回去还要收拾东西;今天就不讲解了,等有时间了给大家补上哈。
废话少说,赶紧来个结尾,准备收拾东西下班回家过年!
再次提前祝大家新年快乐哦(从下写作文老师就教导我们写作文要首尾相应🐶)
文章中有讲解不对的内容希望大家在评论区中指出哦,希望大家点赞评论转发哦,点赞超过三个我就更新下一篇——MySQL系列