渴望就奋力追寻 2019-12-27
面对这一大波JVM面试题,你真的Hold住吗?文章有点长,可以先收藏慢慢看。
JVM内存模型可以大致可划分为线程私有区域和共享区域,线程私有区域由虚拟机栈、本地方法栈、程序计数器组成,而共享区域由堆、元数据空间(方法区)组成。
再有人问你JVM的内存模型就回想下上面的图,但是知道JVM的内存模型的样子还是不行的,还要知道他们分别干什么的。
当你碰到过StackOverflowException
这个异常的时候,有没有思考下为什么会出现这样的异常呢?答案就在虚拟机栈中,JVM会为每个方法生成栈帧然后将栈帧压入虚拟机栈中。
举个粟子:假设JVM参数-Xss
设置为1m
,如果某个方法里面创建一个128kb的数组,那这个方法在同一个线程中只能递归4次,再递归第五次的时候就会报StackOverflowException
异常,因为虚拟机栈的大小只有1m,每次递归都需要为方法在虚拟机栈中分配128kb的空间,很显示到第五次的时候就空间不足了。
程序计数器是一个记录着当前线程所执行的字节码的行号指示器。JVM的多线程是通过CPU时间片轮转(即线程轮流切换并分配处理器执行时间)算法来实现的。也就是说,某个线程在执行过程中可能会因为时间片耗尽而被挂起,而另一个线程获取到时间片开始执行。
简单的说程序计数器的主要功能就是记录着当前线程所执行的字节码的行号指示器。
方法区存储了类的元数据信息、静态变量、常量等数据。
平常大家使用new
关键字创建的对象都会进入堆中,堆也是GC重点照顾的区域,堆会被划分为:新生代、老年代,而新生代还会被进一步划分为Eden区和Survivor区:
新生代中的Eden区和Survivor区,是根据JVM回收算法来的,只是现在大部分都是使用的分代回收算法,所以在介绍堆的时候会直接将新生代归纳为Eden区和Survivor区。
JVM内存模型小结:
new
关键字创建的对象都会进入堆中,堆被划分为新生代和老年代JVM判断对象回收有两种方式:引用记数、GC Roots,引用记数比较简单,JVM为每个对象维护一个引用计数,假设A对象引用计数为零说明没有任务对象引用A对象,那A对象就可以被回收了,但是引用计数有个缺点就是无法解决循环引用的问题。
GC Roots通过一系列的名为GC Roots的对象作为起始点,从这些节点开始向下搜索,搜索过的路径称为引用链,当一个对象到GC Roots没有任何引用链相连时,则证明对象是不可用的。
在Java中,可以作为GC Roots的对象包括下面几种:
总的来说就是当一个对象通过GC Roots搜索不到时,说明对象可以被回收了,但什么时候回收还要看GC的心情!
这种算法分两分:标记、清除两个阶段,
标记阶段是从根集合(GC Root)开始扫描,每到达一个对象就会标记该对象为存活状态,清除阶段在扫描完成之后将没有标记的对象给清除掉。
用一张图说明:
这个算法有个缺陷就是会产生内存碎片,如上图B被清除掉后会留下一块内存区域,如果后面需要分配大的对象就会导致没有连续的内存可供使用。
标记整理就没有内存碎片
的问题了,也是从根集合(GC Root)开始扫描进行标记然后清除无用的对象,清除完成后它会整理内存。
这样内存就是连续的了,但是产生的另外一个问题是:每次都得移动对象,因此成本很高。
复制算法会将JVM推分成二等分,如果堆设置的是1g,那使用复制算法的时候堆就会有被划分为两块区域各512m。给对象分配内存的时候总是使用其中的一块来分配,分配满了以后,GC就会进行标记,然后将存活的对象移动到另外一块空白的区域,然后清除掉所有没有存活的对象,这样重复的处理,始终就会有一块空白的区域没有被合理的利用到。
两块区域交替使用,最大问题就是会导致空间的浪费,现在堆内存的使用率只有50%。
JVM回收算法小结:
新创建出来的对象一开始都会停留在新生代中,但随着JVM的运行,有些存活的长的对象会慢慢的移动到老年代中。
JVM会给对象增加一个年龄(age)的计数器,对象每“熬过”一次GC,年龄就要+1,待对象到达设置的阈值(默认为15岁)就会被移移动到老年代,可通过-XX:MaxTenuringThreshold
调整这个阈值。
一次Minor GC
后,对象年龄就会+1,达到阈值的对象就移动到老年代,其他存活下来的对象会继续保留在新生代中。
根据对象年龄有另外一个策略也会让对象进入老年代,不用等待15次GC之后进入老年代,他的大致规则就是,假如当前放对象的Survivor,一批对象的总大小大于这块Survivor内存的50%,那么大于这批对象年龄的对象,就可以直接进入老年代了。
如图上的A、B、D、E这四个对象,假如Survivor 2
是100m,如果A + B + D
的内存大小超过50m,现在D的年龄是10,那E都会被移动到老年代。实际上这个计算逻辑是这样的:年龄1 + 年龄2 + 年龄n的多个对象总和超过Survivor
区的50%,那就会把年龄n以上的对象都放入老年代。
如果设置了-XX:PretenureSizeThreshold
这个参数,那么如果你要创建的对象大于这个参数的值,比如分配一个超大的字节数组,此时就直接把这个大对象放入到老年代,不会经过新生代。
这么做就可以避免大对象在新生代,屡次躲过GC,还得把他们来复制来复制去的,最后才进入老年代,这么大的对象来回复制,是很耗费时间的。
JVM在发生Minor GC之前,虚拟机会检查老年代最大可用的连续空间是否大于新生代所有对象的总空间,如果大于,则此次Minor GC是安全的如果小于,则虚拟机会查看HandlePromotionFailure设置项的值是否允许担保失败。如果HandlePromotionFailure=true,那么会继续检查老年代最大可用连续空间是否大于历次晋升到老年代的对象的平均大小,如果大于则尝试进行一次Minor GC,但这次Minor GC依然是有风险的;如果小于或者HandlePromotionFailure=false,则改为进行一次Full GC。
将前面的一些问题总结下来,然后应用到线上,那JVM应该如何优化减少Full GC呢?以标准的4核8G机器为例说明,首先系统预留4G,其他4G按如下规则分配 :
设置参数如下:
-Xms3072m -Xmx3072m -Xmn1536m -Xss=1m -XX:PermSize=256m -XX:MaxPermSize=256m -XX:HandlePromotionFailure -XX:SurvivorRatio=8
在优化JVM之前,要先估算要系统每秒占用的内存数量,如有个日活百万的商场系统,每日下单量在20w左右,按照一天8个小时算,那订单服务的每秒大概会有500个请求,然后粗略的估算下每个请求占用多少内存,计算出每秒要花费多少内存。
假设是每秒500个请求,每个请求需要分配100k的空间,那1秒需要分配大约50m的内存。
按照之前的估算1秒需要分配大约50m的内存的话,Eden区的空间是1228m那平均每25秒就要执行一次Minor GC。
按照上面的模型,每25秒就要执行一次Minor GC,GC执行期间并不能回收掉所有的新生代中的对象,那每秒50m那每次GC执行期间还会剩下大约100m无法回收的对象会进入Survivor区,但是别忘记JVM有动态年龄判断机制,这样设置下来Survivor的空间明显小了一点,所以将新生代设置2048m,才能避免触发动态年龄判断:
-Xms3072m -Xmx3072m -Xmn2048m ...
大对象一般是长期存活和使用的对象,一般来说设置1M的对象直接进入老年代,这样避免大对象一直处于新生代中来回复制,所以加上PretenureSizeThreshold=1m参数。
... -XX:PretenureSizeThreshold=1m ...
Minor GC后默认躲过15次垃圾回收后自动升入老年代,按照我们的评估25秒触发一次Minor GC,如果按照MaxTenuringThreshold参数的默认值,躲过15次GC后,应该是6分钟之后的事了,结合当前业务场景这里可以降低一点,让那些本应该进入老年代的对象,尽快的进入老年代,避免复制成本和浪费新生代空间,从而导致新生代Survivor空间不足,引发Full GC。
... -XX:MaxTenuringThreshold=6 ...
#结语
上面6个常见的面试题理解了吗?想要更多相关内容可以关注我和我的专栏,点击面试