那天我和小姐姐扯了半天的JVM~

990 阅读17分钟

前言

就在昨天,小码仔和同事蛋哥面试了一个前来求职的小姐姐。回顾整个面试过程,小姐姐的表现可以说是可圈可点。所以小码仔忙里偷闲把对小姐姐的面试过程整理出来,分享给大家。

事情的整个过程是这样子的,就在昨天阳光明媚的午后,我和蛋哥一如从前一样处理着社畜的日常工作,被hr小姐姐通知进行Java候选人面试。当我和蛋哥抱着吃饭的小本本进了面试接待室。啊,一个眉清目秀的小姐姐。

我老脸一红,不不不,同是女孩纸,我要矜持。我和蛋哥礼貌的冲妹子笑了笑说“不好意思,让你久等了”,然后我示意妹纸坐下,说:“我们开始吧,我看你的简历有做过JVM调优,那我们今天就来讨论下JVM……”。

正文

什么是jvm?

JVM简单来说它是一个虚构出来的计算机,是通过在实际的计算机上仿真模拟各种计算机功能来实现的。

JVM屏蔽了与具体操作系统平台相关的信息,使Java程序只需生成在Java虚拟机上运行的目标代码也就是字节码,就可以在多种平台上不加修改地运行。JVM在执行字节码时,实际上最终还是把字节码解释成具体平台上的机器指令执行。

jvm的运行时区域

Java虚拟机包括一套字节码指令集、一组寄存器、一个栈、一个垃圾回收堆和一个存储方法域。

它们的作用分别是什么?

  1. PC寄存器

PC寄存器是用于存储每个线程下一步将执行的JVM指令,如该方法为native的,则PC寄存器中不存储任何信息。

  1. JVM栈

JVM栈是线程私有的,每个线程创建的同时都会创建JVM栈,JVM栈中存放的为当前线程中局部基本类型的变量(java中定义的八种基本类型:boolean、char、byte、short、int、long、float、double)、部分的返回结果以及Stack Frame,非基本类型的对象在JVM栈上仅存放一个指向堆上的地址。

  1. 堆(Heap)

它是JVM用来存储对象实例以及数组值的区域,可以认为Java中所有通过new创建的对象的内存都在此分配,Heap中的对象的内存需要等待GC进行回收。

  1. 方法区域(Method Area)

(1)在Sun JDK中这块区域对应的为PermanetGeneration,又称为持久代。

(2)方法区域存放了所加载的类的信息(名称、修饰符等)、类中的静态变量、类中定义为final类型的常量、类中的Field信息、类中的方法信息,当开发人员在程序中通过Class对象中的getName、isInterface等方法来获取信息时,这些数据都来源于方法区域,同时方法区域也是全局共享的,在一定的条件下它也会被GC,当方法区域需要使用的内存超过其允许的大小时,会抛出OutOfMemory的错误信息。

  1. 运行时常量池(Runtime Constant Pool)

存放的为类中的固定的常量信息、方法和Field的引用信息等,其空间从方法区域中分配。

  1. 本地方法堆栈(Native Method Stacks)

JVM采用本地方法堆栈来支持native方法的执行,此区域用于存储每个native方法调用的状态。

简要介绍虚拟机类加载机制

虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验、装换解析和初始化,最终形成可以被虚拟机直接使用的 Java 类型。

对象创建过程

  1. 类加载检查

虚拟机遇到一条new指令时,首先将去检查这个指令的参数能否在常量池中定位到一个类的符号引用,并检查这个符号引用代表的类是否已经被加载、解析和初始化过,如果没有,那么必须先执行相应的类加载过程。

  1. 分配内存

在类加载检查通过后,接下来虚拟机将会为新生的对象分配内存。对象所需要的内存大小在类加载完成后便可完全确定,为对象分配空间等同于把一块确定大小的内存从java堆中划分出来。

  1. 初始零值

内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),这一步操作保证了对象的实例字段在 Java 代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。如果使用TLAB,这一工作过程也可以提前到TLAB分配时进行。

  1. 设置对象头

接下来,虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例,如何才能找到类的元数据信息,对象的哈希吗,对象的GC分代年龄等信息,这些信息存放在对象的对象头中。根据虚拟机当前的运行状态的不同,对象头会有不同的设置方式。

  1. 执行init方法

在上面工作都完成之后,从虚拟机的视角来看,一个新的对象已经产生了,但从 Java 程序的视角来看,对象创建才刚开始, 方法还没有执行,所有的字段都还为零。所以一般来说,执行 new 指令之后会接着执行 方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全产生出来。

在创建对象的时候,虚拟机是如何来保证线程安全的?

在创建对象的时候有一个很重要的问题,就是线程安全,因为在实际开发过程中,创建对象是很频繁的事情,例如正在给A对象分配内存,但是指针还没修改,这时候对象B可能使用原来的指针来分配内存的情况。作为虚拟机来说,必须要保证线程是安全的,通常来讲,虚拟机采用两种方式来保证线程安全:

  1. CAS+失败重试: CAS 是乐观锁的一种实现方式。所谓乐观锁就是,每次不加锁而是假设没有冲突而去完成某项操作,如果因为冲突失败就重试,直到成功为止。虚拟机采用 CAS 配上失败重试的方式保证更新操作的原子性。

  2. TLAB: 为每一个线程预先在 Eden 区分配一块内存。JVM 在给线程中的对象分配内存时,首先在各个线程的TLAB 分配,当对象大于TLAB 中的剩余内存或 TLAB 的内存已用尽时,再采用上述的 CAS 进行内存分配。虚拟机是否启用TLAB,可以通过-XX:+/-UseTLAB参数来设定。

你前面有提到垃圾回收,何为垃圾?

简而言之就是已经不再存活的对象即为垃圾。

有哪些算法可以判定对象已不再存活?

判定对象是否存活的算法有两种:

  1. 引用计数算法:给对象中添加一个引用计数器,每当一个地方应用了对象,计数器加1;当引用失效,计数器减1;当计数器为0表示该对象已死、可回收。但是它很难解决两个对象之间相互循环引用的情况。

  2. 可达性分析算法:通过一系列称为“GC Roots”的对象作为起点,从这些节点开始向下搜索,搜索所走过的路径称为引用链,当一个对象到GC Roots没有任何引用链相连(即对象到GC Roots不可达),则证明此对象已死、可回收。Java中可以作为GC Roots的对象包括:虚拟机栈中引用的对象、本地方法栈中Native方法引用的对象、方法区静态属性引用的对象、方法区常量引用的对象。

由于引用计数算法很难解决两个对象之间相互循环引用的情况,因此在我们的日常开发过程中,通常通过可达性分析算法来判定对象是否存活的。

哪些情况下对象变成垃圾,并举例说明

使对象变为垃圾主要有以下情况:

  1. 对非线程的对象来说,所有的活动线程都不能访问该对象,那么该对象就会变为垃圾。
  2. 对线程对象来说,满足上面的条件,且线程未启动或者已停止。

例如:

(1)改变对象的引用,如置为null或者指向其他对象。 
Object x=new Object();//object1
Object y=new Object();//object2
x=y;//object1 变为垃圾
x=y=null;//object2 变为垃圾

(2)超出作用域
if(i==0){
Object x=new Object();//object1
}//括号结束后object1将无法被引用,变为垃圾
(3)类嵌套导致未完全释放
class A{
A a;
}
A x= new A();//分配一个空间
x.a= new A();//又分配了一个空间
x=null;//将会产生两个垃圾
(4)线程中的垃圾
class A implements Runnable{
void run(){
//....
}
}
//main
A x=new A();//object1
x.start();
x=null;//等线程执行完后object1才被认定为垃圾

熟悉的JVM垃圾回收算法有哪些?

  1. 标记-清除算法

最基础的算法,分标记和清除两个阶段:首先标记处所需要回收的对象,在标记完成后统一回收所有被标记的对象。

它有两点不足:一个效率问题,标记和清除过程都效率不高;一个是空间问题,标记清除之后会产生大量不连续的内存碎片(类似于我们电脑的磁盘碎片),空间碎片太多导致需要分配大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾回收动作。

  1. 复制算法

为了解决效率问题,出现了“复制”算法,他将可用内存按容量划分为大小相等的两块,每次只需要使用其中一块。当一块内存用完了,将还存活的对象复制到另一块上面,然后再把刚刚用完的内存空间一次清理掉。这样就解决了内存碎片问题,但是代价就是可以用内容就缩小为原来的一半。

  1. 标记-整理算法

复制算法在对象存活率较高时就会进行频繁的复制操作,效率将降低。因此又有了标记-整理算法,标记过程同标记-清除算法,但是在后续步骤不是直接对对象进行清理,而是让所有存活的对象都向一侧移动,然后直接清理掉端边界以外的内存。

  1. 分代收集算法

当前商业虚拟机的GC都是采用分代收集算法,这种算法并没有什么新的思想,而是根据对象存活周期的不同将堆分为:新生代和老年代,方法区称为永久代(在新的版本中已经将永久代废弃,引入了元空间的概念,永久代使用的是JVM内存而元空间直接使用物理内存)。

这样就可以根据各个年代的特点采用不同的收集算法。

新生代中的对象“朝生夕死”,每次GC时都会有大量对象死去,少量存活,使用复制算法。新生代又分为Eden区和Survivor区(Survivor from、Survivor to),大小比例默认为8:1:1。

老年代中的对象因为对象存活率高、没有额外空间进行分配担保,就使用标记-清除或标记-整理算法。

新产生的对象优先进去Eden区,当Eden区满了之后再使用Survivor from,当Survivor from 也满了之后就进行Minor GC(新生代GC),将Eden和Survivor from中存活的对象copy进入Survivor to,然后清空Eden和Survivor from,这个时候原来的Survivor from成了新的Survivor to,原来的Survivor to成了新的Survivor from。复制的时候,如果Survivor to 无法容纳全部存活的对象,则根据老年代的分配担保将对象copy进去老年代,如果老年代也无法容纳,则进行Full GC(老年代GC)。

大对象直接进入老年代:JVM中有个参数配置-XX:PretenureSizeThreshold,令大于这个设置值的对象直接进入老年代,目的是为了避免在Eden和Survivor区之间发生大量的内存复制。

长期存活的对象进入老年代:JVM给每个对象定义一个对象年龄计数器,如果对象在Eden出生并经过第一次Minor GC后仍然存活,并且能被Survivor容纳,将被移入Survivor并且年龄设定为1。没熬过一次Minor GC,年龄就加1,当他的年龄到一定程度(默认为15岁,可以通过XX:MaxTenuringThreshold来设定),就会移入老年代。但是JVM并不是永远要求年龄必须达到最大年龄才会晋升老年代,如果Survivor 空间中相同年龄(如年龄为x)所有对象大小的总和大于Survivor的一半,年龄大于等于x的所有对象直接进入老年代,无需等到最大年龄要求。

简要介绍一种你熟悉的垃圾收集器

CMS收集器是一种以获取最短回收停顿时间为目标的收集器,停顿时间短,用户体验就好。

基于“标记清除”算法,并发收集、低停顿,运作过程复杂,分4步:

1)初始标记:仅仅标记GC Roots能直接关联到的对象,速度快,但是需要“Stop The World”

2)并发标记:就是进行追踪引用链的过程,让垃圾回收器和用户线程同时运行,并发工作。

3)重新标记:修正并发标记阶段因用户线程继续运行而导致标记发生变化的那部分对象的标记记录,比初始标记时间长但远比并发标记时间短,需要“Stop The World”

4)并发清除:清除标记为可以回收对象,可以和用户线程并发执行

由于整个过程耗时最长的并发标记和并发清除都可以和用户线程一起工作,所以总体上来看,CMS收集器的内存回收过程和用户线程是并发执行的。

但是CMS收集器有3个缺点:

1)对CPU资源非常敏感

并发收集虽然不会暂停用户线程,但因为占用一部分CPU资源,还是会导致应用程序变慢,总吞吐量降低。

CMS的默认收集线程数量是=(CPU数量+3)/4;当CPU数量多于4个,收集线程占用的CPU资源多于25%,对用户程序影响可能较大;不足4个时,影响更大,可能无法接受。

2)并发清理阶段用户线程还在运行,这段时间就可能产生新的垃圾,新的垃圾在此次GC无法清除,只能等到下次清理。

并发清除时需要预留一定的内存空间,不能像其他收集器在老年代几乎填满再进行收集;如果CMS预留内存空间无法满足程序需要,就会出现一次"Concurrent Mode Failure"失败;这时JVM启用后备预案:临时启用Serail Old收集器,而导致另一次Full GC的产生;

3)产生大量内存碎片:CMS基于"标记-清除"算法,清除后不进行压缩操作产生大量不连续的内存碎片,这样会导致分配大内存对象时,无法找到足够的连续内存,从而需要提前触发另一次Full GC动作。

并发清除除了会产生浮动垃圾,还会出现什么问题呢?

还会造成“对象消失”。

举个例子,我们先看一下一次正常的标记过程:

蓝色对象是存活的对象,白色对象是消亡了,可以回收的对象。同时需要注意下面的图片的箭头方向,代表的是有向的,比如其中的一条引用链是:根节点->5->6->7->8->11->10

再来看一下这个,如图对象7和对象10本来就是原引用链(根节点->5->6->7->8->11->10)的一部分。修改后的引用链变成了(根节点->5->6->7->10)。

由于蓝色对象不会重新扫描,这将导致扫描结束后对象10和对象11都会回收了。他们都是被修改之前的原来的引用链的一部分。

如何解决“对象消失”的问题?

当且仅当以下两个条件同时满足时,会产生"对象消失"的问题,原来应该是蓝色的对象被误标为了白色:

条件一:赋值器插入了一条或者多条从蓝色对象到白色对象的新引用。

条件二:赋值器删除了全部从蓝色对象到该白色对象的直接或间接引用。

我们结合前面造成“对象消失”的图可以看到:

蓝色对象7到白色对象10之间的引用是新建的,对应条件一。

蓝色对象8到白色对象11之间的引用被删除了,对应条件二。

由于两个条件之间是当且仅当的关系。所以,我们要解决并发标记时对象消失的问题,只需要破坏两个条件中的任意一个就行。

于是产生了两种解决方案:增量更新和原始快照。

什么是增量更新?

增量更新要破坏的是第一个条件(赋值器插入了一条或者多条从蓝色对象到白色对象的新引用),当蓝色对象插入新的指向白色对象的引用关系时,就将这个新插入的引用记录下来,等并发扫描结束之后,再将这些记录过的引用关系中的蓝色对象为根,重新扫描一次。

这样对象9又被扫描成为了蓝色。也就不会被回收,所以不会出现对象消失的情况。

什么是原始快照?

原始快照要破坏的是第二个条件,即赋值器删除了全部从蓝色对象到该白色对象的直接或间接引用,当蓝色对象要删除指向白色对象的引用关系时,就将这个要删除的引用记录下来,在并发扫描结束之后,再将这些记录过的引用关系中的蓝色对象为根,重新扫描一次。

这个可以简化理解为:无论引用关系删除与否,都会按照刚刚开始扫描那一刻的对象图快照开进行搜索。

最后

哇~你居然看到了这里!好了各位小可爱,以上就是这篇文章的全部内容了,也是JVM最常见的一些面试题,能看到这里的人呀,都是最胖的!哦不,都是最棒哒~

最近忙里偷闲更了一篇JVM相关的文章,非常感谢小可爱们能看到这里,如果觉得这个文章写得还不错, 求点赞👍 求关注❤️ 求分享👥 没错,本少女就是这么的虚荣!嘻嘻~

如果本篇文章有任何错误,请批评指教,不胜感激 !

对啦~最后蛋哥问小姐姐,“是什么让你如此优秀”???

小姐姐微微一笑拿出手机,“因为我一直在关注【小码仔】呀~”