返回
八股文背诵版之—JVM篇
基础知识
👉你知道Java的哪几种引用?
- 强引用,GC时不会被回收
- 软引用,内存不足时会被回收
- 弱引用,GC时会被回收,threadlocal
- 虚引用,不对对象生存时间产生影响,也无法通过它获得对象实例,只有在指向对象被回收时,向ReferenceQueue队列中插入一个元素,起到通知的作用,常用于堆外内存管理,令虚引用指向DirectBuffer对象,等它被回收后,进行通知,表面需要回收堆外内存了
👉知道深拷贝和浅拷贝吗?
知道。对一个对象的拷贝,浅拷贝对其基本数据类型的属性和引用类型属性的地址都是进行值传递的,因此,两个对象中相对应的引用类型的属性会指向同一个内存地址;深拷贝对其基本数据类型进行值传递,对引用类型创建一个全新的对象,并复制其内容,因此两个对象中对应的引用类型的属性,会指向不同的内存地址。Object的clone()方法是浅拷贝,需要对其进行重写实现深拷贝
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-T2JkvywY-1642691091105)(imgs\JVM\2.png)]
👉讲一下JVM主要组成部分?
两个子系统(类加载、执行引擎)、两个组件(运行时数据区、本地方法接口)
JVM主要由类加载子系统、执行引擎、运行时数据区和本地方法接口来组成
其中类加载子系统用于将二进制Class文件或运行过程中动态生成的字节码加载到内存中去,主要包括类的装载,链接、初始化这三个步骤;执行引擎主要用于对字节码文件采用模板解释器进行逐行解释;运行时数据区用于存放Java运行过程中的各种数据及结构;本地方法接口用于在Java程序内调用本地方法

👉讲一下运行时数据区?
规范划分为五个区域
运行时数据区用于存放Java程序运行过程中的各种数据及数据结构,主要包括堆、虚拟机栈、本地方法栈、程序计数器以及方法区这几个组成部分
-
堆是线程共享的区域,主要用于存放Java中的各种实例对象,创建新对象的内存几乎都是在堆上分配的
-
虚拟机栈是线程私有的,主要用于方法的调用,每次方法调用,对应产生一个虚拟机栈中的栈帧,一个栈桢包括局部变量表、操作数栈、动态链接、方法返回地址
-
本地方法栈是线程私有的,对应线程中调用的本地方法,在hotspot中与虚拟机栈合二为一
-
程序计数器是线程私有的,用于存储程序运行时下一条指令的地址,程序的分支、循环、跳转、异常跳转等功能都是靠它实现,字节码解释器通过修改程序计数器的值来选取下一条需要执行的指令(根据指令类型不同有所不同,可以在取指阶段计算长度就能知道,也可能是需要根据指令内容来计算)
-
方法区是线程共享的,主要用于存放各种元信息,包括类元信息、方法信息、CodeCache及运行时常量池等
👉为什么pc寄存器要线程私有?
PC寄存器需要保存线程执行到哪一条指令了,线程在执行过程中可能由于时间片轮转把cpu让给其他线程而暂停执行,恢复执行的时候需要从上次执行到的位置继续往下执行,如果这一区域共享了,就不容易保存上次运行到的位置了,线程之间会互相干扰
👉堆栈的区别?
- 内存:堆的内存不是连续分配的,栈的内存是连续分配的
- 线程共享/私有:堆是共享的,栈是私有的
- 抛出异常:堆抛出OOM异常;不支持栈动态扩展的话,栈深度超过某一阈值会报SOF,支持的话则可能由于内存不足报OOM
👉对象实例化过程?
6个步骤
调用new方法在堆中创建一个对象,① 首先会检查类是否加载完成,没有的话则进行类的加载。② 接着会在堆上分配内存,如果内存是规整的,即是通过标记压缩算法整理的,则采用指针碰撞法来分配内存;否则通过空闲列表法分配内存。③ 在分配内存时也要考虑并发问题,首选通过TLAB(Thread Local Allocation Buffer)给每个线程分配私有的缓冲区,在缓冲区上分配,分配失败后可以通过CAS来避免冲突。④ 接着会对分配到的空间进行初始化,主要是给成员变量赋零值等。⑤ 然后设置对象头。⑥ 最后调用<init>初始化,包括显式初始化、代码块初始化、构造器初始化等
👉知道句柄访问吗?
知道。hotspot中采用的是直接访问,栈中的引用指向堆中的实例对象地址,实例对象头中的klass pointer指向方法区的类元信息,如果发生GC对对象进行移动,栈中引用指向的地址也要随之改变,但速度快,能够一次访问到对象。而句柄访问是在堆中句柄池中创建一个句柄,包括一个指向实例对象地址的指针和一个指向类元信息的指针,并令栈中的引用指向这个句柄地址;GC移动对象后,只需要改变句柄中指向实例对象的指针,而不需要去改变栈中的引用,缺点是要消耗更多空间,并且访问对象时要多一次步骤

👉java内存泄漏的场景?
长生命周期对象持有短生命周期对象的引用,导致短生命周期对象迟迟不能被回收,这就是内存泄漏。典型场景有threadlocal,如果不作清空操作,线程中一直有一个以null为键的Entry迟迟不能被回收,线程持续多久这个Entry就要持续多久
👉讲一下动态链接过程?
参考:
动态链接是将针对编译期无法确认具体调用者的方法,在运行期间将该方法在常量池中对应的符号引用转换为直接引用的过程
具体过程如下:当解释器解释到该方法时,假设它的调用者是x对象。首先能够通过操作数栈获取栈顶对象x,接着能通过invokevirtual指令后面的索引在常量池中找到该方法的符号引用,就能获取方法签名;接着根据这个方法签名,在x对象对应的类的方法区的虚方法表中寻找到方法的直接入口;最后将常量池中的该方法的符号引用,替换成这个直接入口,这就是动态链接的过程
👉讲一下虚方法表?
虚方法表是一个类似数组的结构,里面存储着方法相关的类似于方法签名、方法直接入口等信息。它在类加载的链接阶段的准备阶段之后创建,目的是为了加快方法查找效率。当调用一个方法时,可以根据对应的对象的虚方法表,遍历查找方法的直接入口,并完成动态链接,如果没有虚方法表,只能对应类的方法元数据中遍历搜索,效率就没有那么高。子父类相同方法在对应虚方法表中索引位置相同,重写会在子类的虚方法表对应索引位置实现覆盖
类加载
👉java类加载过程?
java类加载包括三个步骤,类的装载、链接和初始化。其中装载是根据类的全限定名将二进制Class文件或动态生成的字节码加载到内存当中,将静态结构转换成内存中运行的数据结构,并且在堆中生成一个对应的Class对象。链接包括验证、准备和解析,验证阶段会对字节码的合法性进行校验,包括魔数头校验等等;准备阶段会对类变量进行零值初始化,解析阶段会将部分常量池中的符号引用转换成直接引用;初始化阶段会调用<clinit>方法对类变量进行显式初始化并执行静态代码块
👉java有哪几种类加载器?
-
BootstrapClassloader,即启动类加载器,是最顶层的一个加载器,负责加载java的核心类库,即%JAVA_HOME%/jre/lib路径或
-Xbootclasspath
指定的路径下的jar包,比如rt.jar -
ExtClassLoader,扩展类加载器,用来加载%JAVA_HOME%/jre/lib/ext路径或-Djava.ext.dirs指定路径下的jar包
-
AppClassLoader,应用类加载器,用来加载Classpath路径下或-Djava.class.path指定路径下的jar包或Class文件
-
用户自定义类加载器,通过继承java.lang.ClassLoader来实现
👉讲一下双亲委派机制模型?
当一个类加载器接收到加载一个类的请求,首先不会自己去加载这个类,而是先委托给父类的加载器进行加载,最终所有的请求都会被传达到启动类加载器,只有发现当父类加载器无法实现加载的时候,子类才会去尝试加载该类
扩展:JDK9后会先进行模块化加载。加载一个类时,首先根据类名找到所在的模块,再使用模块对应的类加载器进行加载,通过这样的手段,能够大大减小Java程序打包的体积,比如将rt.jar拆分成多个模块。同时大大提高加载效率
只有找不到对应模块时,才会调用双亲委派机制来加载
👉双亲委派机制作用?
是Java的沙箱安全机制,首先可以防止类被重复加载,父类能加载,子类就不必加载;其次可以防止核心类库被任意覆盖,比如对于核心类库来说,我们肯定希望Java程序在各个环境下加载的都是同一个核心类,如果没有安全委派机制,用户命名一个同名的类,然后用自己的类加载器加载,造成程序中出现多个同名但不相同的类,造成混乱
👉讲一下双亲委派机制破坏?
通过继承ClassLoader类并重写loadClass方法来破环。原生的loadClass方法首先会调用findLoadedClass(String name)方法检查类是否被加载,如果没有,则递归调用parent.loadClass方法,如果parent为null,就到了启动类加载器了,就会使用启动类加载器加载,如果父类加载失败返回null,则会由子类逐层调用findClass方法,在findClass中如果找到对应字节码资源,则调用defineClass方法字节码转换成Class对象并返回,实现加载,这就是双亲委派的逻辑。因此,打破双亲委派一定要重写loadClass方法
protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException
{
synchronized (getClassLoadingLock(name)) {
Class<?> c = findLoadedClass(name);
if (c == null) {
long t0 = System.nanoTime();
try {
if (parent != null) {
c = parent.loadClass(name, false);
} else {
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
}
if (c == null) {
long t1 = System.nanoTime();
c = findClass(name);
sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
sun.misc.PerfCounter.getFindClasses().increment();
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
}
如果要正常实现一个满足双亲委派的自定义类加载器,只需要重写findClass方法就好。调用父类的loadClass最终都会调用到这个findClass方法
GC
👉什么是GC?
在Java程序中,程序员并不需要手动去进行内存管理与垃圾回收,而是交给JVM来管理。JVM中有一个低优先级的垃圾回收线程,在内存不足时会启动垃圾回收过程来对无用的内存空间进行回收,有效防止了内存泄漏,降低了程序员的工作量
👉GC原理?
首先JVM会申请一个Stop The World,对内存空间中的GC Roots进行枚举;接着基于GC Roots进行可达性分析,将GC Roots引用链上的对象标记为可达对象;对于不可达的对象,如果没有重写finalize方法,则直接回收,否则加入F-Queue队列,并由低优先级的Finalizer线程调用队列中对象的finalize方法;之后再判断对象是否可达,如果不可达,直接回收,如果可达,对象复活,但被标记为finalized状态,也就是已经调用过finalize的状态,下次GC时如果不可达,就直接回收
👉你提到了GC Roots,什么是GC Roots?
GC Roots是一组当前时刻保持活跃的对象集合,一般包括虚拟机栈/本地方法栈中的引用指向的对象、静态变量指向的对象、常量指向的对象等等
👉怎么判断对象能否被回收?
- 引用计数法,有指向对象的引用,则计数加一,若不再指向它了,则计数减一,最后计数为0时则可以回收,缺点是无法解决循环引用的问题
- 可达性分析,会基于GC Roots进行可达性分析,不可达且未重写finalize方法的直接回收;重写了finalize方法的对象会被加入F-Queue队列中等待Finalizer线程调用它的finalize方法
👉你知道哪些垃圾回收算法?
- 标记清除算法,首先标记不可达对象,接着回收不可达对象的内存。优点是垃圾回收效率较高,缺点是会产生大量不好整理的内存碎片,不便于后续分配大对象
- 标记复制算法,将GC一次后的这一区域存活对象复制到另一个区域,并回收该区域所有内存空间,接着互换这两个空间的角色。优点是不产生内存碎片,缺点是会浪费多一份内存空间,复制并移动对象需要更改指向它的引用,如果存活对象多,则开销会很大
- 标记压缩算法,GC后将对象整理到整个内存区域的一边,使其保持连续性。优点在于不会浪费多一份内存空间,不会有内存碎片,缺点是对存活的对象都要在内存中进行整理,开销大
- 分代算法,根据对象的生命周期分为不同的代,比如Hotspot堆区的新生代、老年代,提高回收效率
- 分区算法,将内存区域分为若干个小的分区,以区为单位进行垃圾回收
- 增量收集,gc时用户线程和垃圾回收线程并发,减少用户线程停顿时间
👉你知道Major GC吗?
一般来说,对大多数垃圾回收器组合,针对老年代的收集都是触发了Full GC的,所以常常有将Major GC和Full GC混为一谈的。实际上,如果狭义一点,Major GC只指针对老年代的回收的话,那就只有CMS的Concurrent Mode属于Major GC
👉你知道哪些垃圾回收器?
按出现时间来捋吧。首先是Serial收集器,面向新生代的收集器,是串行非并发的,GC时会STW,采用的算法是标记复制算法;然后有Serial Old收集器,可以作为全局的Full GC,是串行非并发的,采用的算法是标记压缩算法。采用-XX:+UseSerialGC
使用
接着有ParNew算法,面向新生代,是并行非并发的算法,GC时多个线程同时进行GC,提高了GC效率,采用的算法同样是标记复制算法,可以用-XX:+UseParNewGC
指定在新生代使用
然后有Parallel Scanvage,面向新生代,与ParNew的不同之处在于它可以通过参数调节堆内存分布和其他一些指标,从而尽量调整STW时间和吞吐量,且提供了自适应调节的策略,分别是-XX:MaxGCPauseillis
、-XX:GCTimeRatio
和-XX:+UseAdaptiveSizePolicy
,可以通过-XX:+UseParallelGC
来使用;搭配有Parallel Old收集器,可以作为全局的Full GC,通过-XX:+UseParallelOldGC
来使用,默认开启一个,另一个就开启,一般Parallel Old作为Full GC的收集器的话,会在Full GC之前进行一次Young GC
然后就是CMS收集器,面向老年代的收集器,是首款可在GC过程中与用户线程并发的收集器,采用的算法是标记清除算法,可以与ParNew搭配,由于是与用户线程并发的,所以CMS在内存使用超过某一阈值就开始回收,默认值为92%,可以通过-XX:+CMSInitiatingOccupanyFraction
来指定,如果预留的内存不够新进入老年代对象的存放,会产生Concurrent Mode Failure收集失败,这时会采用Serial Old作为后备方案进行Full GC,可以通过-XX:+UseConcMarkSweepGC
来指定使用。并且可以通过-XX:UseCMSCompactAtFullCollection
指定Full GC之后是否进行内存压缩整理,采用-XX:CMSFullGCsBeforeCompaction
指定多少次不压缩的Full GC后执行一次内存压缩整理,默认是每次都整理
最后就是G1GC,面向新生代和老年代,是并发并行的收集器,采用的算法是标记复制算法,是首个分区算法,将堆内存分成若干个region,每次选取一些进行回收,大大提高了回收过程的效率和可控性
👉简单讲一下内存分配策略?
总体来说可用几句话概括:优先在Eden分配、长期存活的对象进入老年代、大对象直接进入老年代、动态年龄判定以及空间分配担保:
- 对象绝大多数都是在堆区分配的,首先会优先在Eden区分配
- 大于
-XX:PretenureSizeThreshold
指定的大对象会直接在老年代分配,减少复制次数 - 如果Eden区空间不够,触发一次Minor GC,将Eden区存活对象和survivor的from区的对象移动到to区,回收Eden和from的空间,并且令from区成为新的to区,并令所有被复制的对象的对象头中分代年龄加一,如果大于
-XX:MaxTenuringThreshold
则会进入老年代,最大值为15。如果复制时发现to区空间不够,则将对象 - 并不一定达到
MaxTenuringThreshold
才会进入老年代,如果Survivor区中某年龄的对象大小超过Survivor空间的一半,则大于等于该年龄的对象直接进入 - 如果minor gc时to区空间不足,会令对象进入老年代,这时需要担保老年代有足够的空间,即空间分配担保。具体操作是这样的:
- 首先判断老年代中最大连续可用空间大于新生代所有对象大小之和,如果是则判定为安全的,直接进行minor gc
- 查看HandlePromotionFailure的值,如果允许担保失败,则判断老年代最大连续空间大于过去晋升到老年代对象的平均大小,如果是,则尝试进行一次minor gc;如果不是或不允许担保失败,则进行一次Full GC
👉Minor GC、Major GC和Full GC触发时机?
minor gc:堆区eden区空间不足
major gc:CMS在Concurrent Mode下老年代使用率超过阈值,默认92%
full gc:
- 调用System.gc()
- 大对象分配、长存活时间对象进入老年代时,老年代空间不足
- promotion failure,空间分配担保失败
- 方法区空间不足,比如JDK1.7之前调用intern,或者加载类太多等
- CMS concurrent mode failure
👉简单讲一下CMS清理过程?
三标记一清理
堆占用率超过CMSInitiatingOccupancyFraction
后触发
首先是进行根节点枚举和初始标记阶段,标记GC Roots和与GC Roots直接相连的引用,这个过程是STW的
接着进入并发标记阶段,这个阶段是可以和用户线程并发的,沿着引用链标记其他可达引用
然后进入重新标记阶段,使用增量更新法对标记进行修正,避免出现存活对象被标记成垃圾的情况
最后进入并发清理阶段,可以与用户线程并发
👉你提到了增量更新,那是什么?
用三色标记法来解释,在并发标记过程中,白色对象是指还未被扫描的对象,灰色对象指的是本身被扫描,但其成员变量指向的对象还未被扫描,黑色对象是指自身和其成员变量指向的对象全部完成扫描。并发标记过程中,从GC Roots开始,逐步扫描引用链上的对象,将白色对象变成灰色对象,再扫描其成员变量,完成后变成黑色对象。但由于这个过程是和用户线程并发的,所以可能导致引用关系被修改,出现问题
一是多标问题,也就是某对象在标记过程中被标记为灰色对象,但在用户线程中指向该灰色对象的引用断了,那么这个灰色对象本应当是垃圾,却仍继续扫描,最后逃过这一轮的GC,产生浮动垃圾;另一个是漏标问题,需要两个条件:第一个条件,某对象被标记为灰色对象,正当要扫描到它的某白色对象时,这个引用断了;第二个条件,这个白色对象又被之前已经扫描过的黑色对象给引用了,那么,这个白色对象明明还要使用,却被看作是垃圾
漏标可能会导致程序错误,因此一定要避免。CMS采用的是增量更新算法,利用写后屏障,当白色对象被一个黑色对象重新引用时,将这个黑色对象记录下来,重新标记时再扫描一遍,也就是将这个黑色对象变成灰色对象,破坏了第二个条件。G1采用的是SATB,Snapshot At The Beginning,即原始快照法,通过写前屏障,当白色对象与灰色对象的引用断开时,将指向这个白色对象的引用记录下来,再后面的过程中,判断这个白色对象是否应该存活,即是否被其他黑色对象引用了。这一功能正好可以用G1的RSet来实现,判断是否有非收集区域的对象引用了白色对象,即判断其卡表元素是否为1,就知道这个对象是否有用了
👉你提到了记忆集和卡表,还有写屏障,介绍一下?
写屏障有写前屏障和写后屏障,就是针对于“写”这个动作,比如说令某个引用指向空、或者指向新的对象这种,修改了变量指向的地址值,都属于写操作,写屏障在写操作前后加入一定功能,类似AOP切面,可以对写这个操作进行增强,实现一些额外的功能
记忆集是一种在收集区域中记录了非收集区域指向收集区域的指针集合。在G1收集器中,每个Region都有一个RSet记忆集,用于解决跨代引用问题。跨代引用问题就是比如在对新生代进行GC时,可以将老年代指向新生代的一部分对象作为GC Roots,这部分就是跨代引用,为了找这些GC Roots扫描整个老年代太不划算,因此在region中置入记忆集,其中的元素与老年代的内存段相映射,通过新生代某region的记忆集可以找到指向该region的老年代的某个内存段,将这个内存段的对象加入GC Roots即可
记忆集是靠卡表实现的,它类似于一种数组结构,其中的元素与卡页相映射,比如第0个元素映射第0~512字节的卡页,卡页就是一个固定大小的内存段。如果卡页中有引用指向自己,则令卡表对应的元素置为1,这是通过写后屏障来实现的。region能够通过卡表轻易知道哪些卡页中有指向自己的引用,可以用在SATB和上述情形
👉讲一下G1流程?
G1提供Young GC和Mixed GC
-
Young GC全程是STW的,首先进行可达性分析并标记,接着采用复制算法将选择所有年轻代的region为cset,进行回收
-
Mixed GC分为global concurrent marking阶段和回收阶段,其中,YGC在eden满后触发,global concurrent marking在老年代占堆内存率达到
InitiatingHeapOccupancyPercent
触发,进行标记并收集各region的回收价值- global concurrent marking分为四个阶段
- 首先是初始标记阶段,标记与GC Roots直接关联的引用,这是STW的,时间较短;这个过程与YGC的标记过程是复用的,因此,这一阶段往往伴随一次YGC
- 接着是并发标记阶段,这一阶段与用户线程并发,对存活对象进行标记;此阶段若产生新对象,会在两个TAMS(Top At Mark Start)指针以上分配,默认隐式存活
- 接着是重新标记阶段,采用SATB,即原始快照法对标记进行修正,利用写前屏障的过程记录下那些与引用链断开的对象,本轮GC不回收它们,后面根据相应region的RSet判断是否有存活对象的引用指向该对象,再判断是否应该回收
- 最后是清理阶段,这个阶段将没有存活对象的region加入空闲region列表,并计算region的垃圾占比、回收价值
- 然后是回收阶段,即evacuation阶段,根据标记阶段收集的信息,判断老年代垃圾占比是否超过
G1HeapWastePercent
,超过则进行Mixed GC回收,在存活对象比例小于G1MixedGCLiveThresholdPercent
的region中选取小于C1OldCSetRegionThresholdPercent
的若干个加入CSet,并进行复制回收,需要STW
- global concurrent marking分为四个阶段
-
如果Mixed GC后老年代空间依旧不够新对象的分配,产生evacuation failure,则会退化成Serial Old GC进行Full GC
大厂真题
待更新