请点赞,你的点赞对我意义重大,满足下我的虚荣心。
🔥常在河边走,哪有不湿鞋。或许面试过程中你遇到的问题就在这呢?
🔥关注我个人简介,面试不迷路~
一、描述JVM类加载过程
这道题想考察什么?
了解JVM是如何加载类的,并且通过JVM类加载过程能更直观了解掌握如APT注解处理器执行、热修复等技术的本质
考察的知识点
JVM类加载过程
考生如何回答
类加载的本质
一般情况下,类的数据都是在Class
文件中。将描述类的数据 从Class
文件加载到内存 同时 对数据进行校验、转换解析 和 初始化,最终形成可被虚拟机直接使用的Java
使用类型。
类加载过程
java类加载过程:加载-->验证-->准备-->解析-->初始化,之后类就可以被使用了。绝大部分情况下是按这
样的顺序来完成类的加载全过程的。但是是有例外的地方,解析也是可以在初始化之后进行的,这是为了支持
java的运行时绑定,并且在一个阶段进行过程中也可能会激活后一个阶段,而不是等待一个阶段结束再进行后一个阶段。
![](https://img.haomeiwen.com/i28627856/d82ee6788f9869ea.png)
1.加载
加载时jvm做了这三件事:
1)通过一个类的全限定名来获取该类的二进制字节流
2)将这个字节流的静态存储结构转化为方法区运行时数据结构
3)在内存堆中生成一个代表该类的java.lang.Class对象,作为该类数据的访问入口
2.验证
验证、准备、解析这三步可以看做是一个连接的过程,将类的字节码连接到JVM的运行状态之中
验证是为了确保Class文件的字节流中包含的信息符合当前虚拟机的要求,不会威胁到jvm的安全
验证主要包括以下几个方面的验证:
1)文件格式的验证,验证字节流是否符合Class文件的规范,是否能被当前版本的虚拟机处理
2)元数据验证,对字节码描述的信息进行语义分析,确保符合java语言规范
3)字节码验证 通过数据流和控制流分析,确定语义是合法的,符合逻辑的
4)符号引用验证 这个校验在解析阶段发生
3.准备
为类的静态变量分配内存,初始化为系统的初始值。对于final static修饰的变量,直接赋值为用户的定义值。如下面的例子:这里在准备阶段过后的初始值为0,而不是7:
public static int a=7
4.解析
解析是将常量池内的符号引用转为直接引用(如物理内存地址指针)
5.初始化
到了初始化阶段,jvm才真正开始执行类中定义的java代码
1)初始化阶段是执行类构造器<clinit>()方法的过程。类构造器<clinit>()方法是由编译器自动收集
类中的所有类变量的赋值动作和静态语句块(static块)中的语句合并产生的。
2)当初始化一个类的时候,如果发现其父类还没有进行过初始化、则需要先触发其父类的初始化。
3)虚拟机会保证一个类的<clinit>()方法在多线程环境中被正确加锁和同步。
二、请描述new一个对象的流程
这道题想考察什么?
对JVM的理解
考察的知识点
JVM 对象分配、并发安全
考生应该如何回答
JVM创建对象的过程如下图:
![](https://img.haomeiwen.com/i28627856/8e3918de7ef337c4.png)
虚拟机遇到一条new指令时,首先检查是否被类加载器加载,如果没有,那必须先执行相应的类加载过程。类加载就是把class加载到JVM的运行时数据区的过程。
检查加载
首先检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查类是否已经被加载、解析和初始化过。
符号引用:以一组符号来描述所引用的目标。符号引用可以是任何形式的字面量,JAVA在编译的时候一个每个java类都会被编译成一个class文件,但在编译的时候虚拟机并不知道所引用类的地址(实际地址),就用符号引用来代替,而在类的解析阶段就是为了把这个符号引用转化成为真正的地址的阶段。
假设People类被编译成一个class文件时,如果People类引用了Tool类,但是在编译时People类并不知道引用类的实际内存地址,因此只能使用符号引用(org.simple.Tool)来代替。而在类装载器装载People类时,此时可以通过虚拟机获取Tool类的实际内存地址,因此便可以既将符号org.simple.Tool替换为Tool类的实际内存地址。
分配内存
完成类的加载检查后,虚拟机将为新生对象分配内存。为对象分配空间的任务等同于把一块确定大小的内存从Java堆中划分出来。
指针碰撞
如果Java堆中内存是绝对规整的,所有用过的内存都放在一边,空闲的内存放在另一边,中间放着一个指针作为分界点的指示器,那所分配内存就仅仅是把那个指针向空闲空间那边挪动一段与对象大小相等的距离,这种分配方式称为—指针碰撞。
空闲列表
如果Java堆中的内存并不是规整的,已使用的内存和空闲的内存相互交错,那就没有办法简单地进行指针碰撞了,虚拟机就必须维护一个列表,记录上哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录,这种分配方式称为—空闲列表。
选择哪种分配方式由Java堆是否规整决定,而Java堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。
- 如果是Serial、ParNew等带有压缩的整理的垃圾回收器的话,系统采用的是指针碰撞,既简单又高效。
- 如果是使用CMS这种不带压缩(整理)的垃圾回收器的话,理论上只能采用较复杂的空闲列表。
并发安全
除如何划分可用空间之外,还有另外一个需要考虑的问题是对象创建在虚拟机中是非常频繁的行为,即使是仅仅修改一个指针所指向的位置,在并发情况下也并不是线程安全的,可能出现正在给对象A分配内存,指针还没来得及修改,对象B又同时使用了原来的指针来分配内存的情况。
解决这个问题有两种方案:
CAS
对分配内存空间的动作进行同步处理—实际上虚拟机采用CAS配上失败重试的方式保证更新操作的原子性。
![](https://img.haomeiwen.com/i28627856/79d40cbd5c3120f8.png)
分配缓冲
把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在Java堆中预先分配一小块私有内存,也就是本地线程分配缓冲(Thread Local Allocation Buffer,TLAB)。
JVM在线程初始化时,同时也会申请一块指定大小的内存,只给当前线程使用,这样每个线程都单独拥有一个Buffer,如果需要分配内存,就在自己的Buffer上分配,这样就不存在竞争的情况,可以大大提升分配效率,当Buffer容量不够的时候,再重新从Eden区域申请一块继续使用。
TLAB的目的是在为新对象分配内存空间时,让每个Java应用线程能在使用自己专属的分配指针来分配空间,减少同步开销。
TLAB只是让每个线程有私有的分配指针,但底下存对象的内存空间还是给所有线程访问的,只是其它线程无法在这个区域分配而已。当一个TLAB用满(分配指针top撞上分配极限end了),就新申请一个TLAB。
默认情况下启用允许在年轻代空间中使用线程本地分配块(TLAB)。要禁用TLAB,需要指定-XX:-UseTLAB
。
内存空间初始化
内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值。这一步操作保证了对象的实例字段在Java代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。(如int值为0,boolean值为false等等)。
设置
完成空间初始化后,虚拟机对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息(Java classes在Java hotspot VM内部表示为类元数据)、对象的哈希码、对象的GC分代年龄等信息。这些信息存放在对象的对象头之中。
对象初始化
在以上工作都完成之后,从虚拟机的视角来看,一个新的对象已经产生了。但从Java程序的视角来看,对象创建才刚刚开始,所有的字段都还为零值。所以,一般来说,执行new指令之后会接着把对象按照程序员的意愿进行初始化(构造方法),这样一个真正可用的对象才算完全产生出来。
三、Java对象会不会分配到栈中?
这道题想考察什么?
创建的对象是否都在堆中,如果不是,对照JVM运行时数据区堆栈相关内容,能够把控对象不在堆中对程序的影响
考察的知识点
逃逸分析
考生应该如何回答
Java对象可能会分配到栈中。
逃逸分析
逃逸分析指的是分析对象动态作用域,当一个对象在方法中定义后,它可能被外部方法所引用。比如:调用参数传递到其他方法中,这种称之为方法逃逸。甚至还有可能被外部线程访问到,例如:赋值给其他线程中访问的变量,这个称之为线程逃逸。从不逃逸到方法逃逸到线程逃逸,称之为对象由低到高的不同逃逸程度。
如果确定一个对象不会逃逸出线程之外,那么让对象在栈上分配内存可以提高JVM的效率。如果是逃逸分析出来的对象可以在栈上分配的话,那么该对象的生命周期就跟随线程了,就不需要垃圾回收,如果是频繁的调用此方法则可以得到很大的性能提高。
逃逸分析的触发前提条件必须触发JIT执行
public class EscapeAnalysisTest{
public static void main(String[] args){
long start = System.currentTimeMillis();
for (int i = 0; i < 50000000; i++){
allocate();
}
System.out.println((System.currentTimeMillis() - start) + " ms");
}
static void allocate(){
Object obj = new Object();
}
}
在上述代码中,Object对象属于不可逃逸,JVM可以做栈上分配。在启动JVM时候,通过 -XX:-DoEscapeAnalysis
参数可以关闭逃逸分析(JVM默认开启)。
开启逃逸分析:
![](https://img.haomeiwen.com/i28627856/b31d6c21ba82229f.png)
关闭逃逸分析:
![](https://img.haomeiwen.com/i28627856/209ead567e16d3a1.png)
测试结果可见,开启逃逸分析对代码的执行性能有很大的影响!
四、GC的流程是怎么样的?介绍下GC回收机制与分代回收策略
这道题想考察什么?
Java基础掌握情况,掌握对象回收过程以避免开发时出现内存问题
考察的知识点
GC机制
考生如何回答
说到GC垃圾回收,首先要知道什么是“垃圾”,垃圾就是没有用的对象,那么怎样判定一个对象是不是垃圾(能不能被回收)?Java 虚拟机中使用一种叫作可达性分析的算法来决定对象是否可以被回收。
可达性分析
可达性分析就通过一组名为”GC Root"的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链,最后通过判断对象的引用链是否可达来决定对象是否可以被回收。
![](https://img.haomeiwen.com/i28627856/aaa5856563ddb9f5.png)
GC Root指的是:
- Java 虚拟机栈(局部变量表)中的引用的对象。也就是正在运行的方法中的局部变量所引用的对象
- 方法区中静态引用指向的对象。也就是类中的static修饰的变量所引用的对象
- 方法区中常量引用的对象。
- 仍处于存活状态中的线程对象。
- Native 方法中 JNI 引用的对象。
优点
可达性分析可以解决引用计数器所不能解决的循环引用问题。即便对象a和b相互引用,只要从GC Roots出发无法到达a或者b,那么可达性分析便不会将它们加入存活对象合集之中。
缺点
在多线程环境下,其他线程可能会更新已经访问过的对象中的引用,从而造成误报(将引用设置为null)或者漏报(将引用设置为未被访问过的对象)。误报并没有什么伤害,Java虚拟机至多损失了部分垃圾回收的机会。漏报则比较麻烦,因为垃圾回收器可能回收事实上仍被引用的对象内存。 一旦从原引用访问已经被回收了的对象,则很有可能会直接导致Java虚拟机崩溃。
垃圾回收算法
在标记出对象是否可被回收后,接下来就需要对可回收对象进行回收。基本的回收算法有:标记-清理、标记-整理与复制算法。
标记清除算法
从”GC Roots”集合开始,将内存整个遍历一次,保留所有可以被 GC Roots 直接或间接引用到的对象,而剩下的对象都当作垃圾对待并回收,过程分为 标记 和 清除 两个步骤。
![](https://img.haomeiwen.com/i28627856/3823ceeaf22eea4b.png)
- 优点:实现简单,不需要将对象进行移动。
- 缺点:这个算法需要中断进程内其他组件的执行(stop the world),并且可能产生内存碎片,提高了垃圾回收的频率。
标记整理算法
与标记-清除不同的是它并不简单地清理未标记的对象,而是将所有的存活对象压缩到内存的一端。最后,清理边界外所有的空间。
- 优点:这种方法既避免了碎片的产生,又不需要两块相同的内存空间,因此,其性价比比较高。
- 缺点:所谓压缩操作,仍需要进行局部对象移动,所以一定程度上还是降低了效率。
复制算法
将现有的内存空间分为两快,每次只使用其中一块,在垃圾回收时将正在使用的内存中的存活对象复制到未被使用的内存块中。之后,清除正在使用的内存块中的所有对象,交换两个内存的角色,完成垃圾回收。
![](https://img.haomeiwen.com/i28627856/6fbf86abb040e254.png)
- 优点:按顺序分配内存即可,实现简单、运行高效,不用考虑内存碎片。
- 缺点:可用的内存大小缩小为原来的一半,对象存活率高时会频繁进行复制。
分代回收策略
不同的垃圾收集器实现采用不同的算法进行垃圾回收,除此之外现代虚拟机还会采用分代机制来进行垃圾回收,根据对象存活的周期不同,把堆内存划分为不同区域,不同区域采用不同算法进行垃圾回收。
分代的垃圾回收策略,是基于这样一个事实:不同的对象的生命周期是不一样的。因此,不同生命周期的对象可以采取不同的收集方式,以便提高回收效率。
在Java程序运行的过程中,会产生大量的对象,其中有些对象是与业务信息相关,比如Http请求中的Session对象、线程、Socket连接,这类对象跟业务直接挂钩,因此生命周期比较长。但是还有一些对象,主要是程序运行过程中生成的临时变量,这些对象生命周期会比较短,比如:String对象,由于其不变类的特性,系统会产生大量的这些对象,有些对象甚至只用一次即可回收。
试想,在不进行对象存活时间区分的情况下,每次垃圾回收都是对整个堆空间进行回收,花费时间相对会长,同时,因为每次回收都需要遍历所有存活对象,但实际上,对于生命周期长的对象而言,这种遍历是没有效果的,因为可能进行了很多次遍历,但是他们依旧存在。因此,分代垃圾回收采用分治的思想,进行代的划分,把不同生命周期的对象放在不同代上,不同代上采用最适合它的垃圾回收方式进行回收。
代际划分
![](https://img.haomeiwen.com/i28627856/42eab7490175909b.png)
堆内存分为年轻代(Young Generation)和老年代(Old Generation)。而持久代使用非堆内存,主要用于存储一些类的元数据,常量池,java类,静态文件等信息。
垃圾回收
年轻代会划分出Eden区域与两个大小对等的Survivor区域。 其比例一般为8:1:1,这是因为根据统计95%的对象朝生夕死,存活时间极短。
- 新生成的对象优先存放在新生代中
- 存活率很低,回收效率很高
- 一般采用的 GC 回收算法是复制算法
当新对象生成,并且在Eden申请空间失败时,就会触发GC,对Eden区域进行GC,清除非存活对象,并且把尚且存活的对象移动到Survivor区,然后整理Survivor的两个区。这种方式的GC是对年轻代的Eden区进行,不会影响到年老代。因为大部分对象都是从Eden区开始的,同时Eden区不会分配的很大,所以Eden区的GC会频繁进行。所以一般在这里需要使用速度快、效率高的算法,使Eden区能尽快空闲出来。
![](https://img.haomeiwen.com/i28627856/d750643ac544bc72.png)
minor gc
新对象的内存分配都是先在Eden区域中进行的,当Eden区域的空间不足于分配新对象时,就会触发年轻代上的垃圾回收,我们称之为"minor gc"。同时,每个对象都有一个“年龄”,这个年龄实际上指的就是该对象经历过的minor gc的次数。如图1所示,当对象刚分配到Eden区域时,对象的年龄为“0”,当minor gc被触发后,所有存活的对象(仍然可达对象)会被拷贝到其中一个Survivor区域,同时年龄增长为“1”。并清除整个Eden内存区域中的非可达对象。
当第二次minor gc被触发时,JVM会通过Mark算法找出所有在Eden内存区域和Survivor1内存区域存活的对象,并将他们拷贝到新的Survivor2内存区域(这也就是为什么需要两个大小一样的Survivor区域的原因),同时对象的年龄加1. 最后,清除所有在Eden内存区域和Survivor1内存区域的非可达对象。
当对象的年龄足够大(年龄可以通过JVM参数进行指定,默认为15岁,CMS收集器默认6岁,不同的垃圾收集器会略微有点不同 ),当minor gc再次发生时,它会从Survivor内存区域中升级到年老代中,如图3所示。
major gc
当minor gc发生时,又有对象从Survivor区域升级到Tenured区域,但是Tenured区域已经没有空间容纳新的对象了,那么这个时候就会触发年老代上的垃圾回收,我们称之为"major gc"。而在年老代上选择的垃圾回收算法则取决于JVM上采用的是什么垃圾回收器。
总结
在JVM中一般采用可达性分析法进行是否可回收的判定,确定对象需要被回收后,对象在哪个代际将会采用不同的垃圾回收算法进行回收,这些算法包括:标记-清除,标记-整理与复制算法。
而之所以采用分代策略的原因是:不同的对象的生命周期是不一样的。因此,不同生命周期的对象可以采取不同的收集方式,以便提高回收效率。 如果每次垃圾回收都是对整个堆空间进行回收,花费时间相对会长,而对于生命周期长的对象而言,这种遍历是没有效果的,因为可能进行了很多次遍历,但是他们依旧存在。
今天的面试分享到此结束拉~下期在见
网友评论