JVM(Java 虚拟机)简单来说就是运行 Java 代码的解释器, 作为螺丝钉程序员 JVM 其实了解下就差不多啦, 不懂 JVM 内部细节照样能写出优质的代码! 但是一到造火箭, 飞机的场景 (面试) 不懂 JVM 的你, 会被面试官虐的体无完肤, 本期内容列举常见的 JVM 面试题:
说一 JVM 的内存模型是什么样子的?
什么时候对象可以被收回?
常见的垃圾回收器算法有哪些, 各有什么优劣?
什么时候对象会进入老年代?
什么是空间分配担保策略?
如何优化减少 Full GC?
面对这一大波 JVM 面试题, 你真的 Hold 住吗?
JVM 的内存模型是什么样子的?
JVM 内存模型可以大致可划分为线程私有区域和共享区域, 线程私有区域由虚拟机栈, 本地方法栈, 程序计数器组成, 而共享区域由堆, 元数据空间 (方法区) 组成.
再有人问你 JVM 的内存模型就回想下上面的图, 但是知道 JVM 的内存模型的样子还是不行的, 还要知道他们分别干什么的.
虚拟机栈 / 本地方法栈
当你碰到过 StackOverflowException 这个异常的时候, 有没有思考下为什么会出现这样的异常呢? 答案就在虚拟机栈中, JVM 会为每个方法生成栈帧然后将栈帧压入虚拟机栈中.
举个粟子: 假设 JVM 参数 - Xss 设置为 1m, 如果某个方法里面创建一个 128kb 的数组, 那这个方法在同一个线程中只能递归 4 次, 再递归第五次的时候就会报 StackOverflowException 异常, 因为虚拟机栈的大小只有 1m, 每次递归都需要为方法在虚拟机栈中分配 128kb 的空间, 很显示到第五次的时候就空间不足了.
程序计数器
程序计数器是一个记录着当前线程所执行的字节码的行号指示器. JVM 的多线程是通过 CPU 时间片轮转 (即线程轮流切换并分配处理器执行时间) 算法来实现的. 也就是说, 某个线程在执行过程中可能会因为时间片耗尽而被挂起, 而另一个线程获取到时间片开始执行.
简单的说程序计数器的主要功能就是记录着当前线程所执行的字节码的行号指示器.
方法区(元数据区)
方法区存储了类的元数据信息, 静态变量, 常量等数据.
堆(heap)
平常大家使用 new 关键字创建的对象都会进入堆中, 堆也是 GC 重点照顾的区域, 堆会被划分为: 新生代, 老年代, 而新生代还会被进一步划分为 Eden 区和 Survivor 区:
新生代中的 Eden 区和 Survivor 区, 是根据 JVM 回收算法来的, 只是现在大部分都是使用的分代回收算法, 所以在介绍堆的时候会直接将新生代归纳为 Eden 区和 Survivor 区.
小结
JVM 内存模型小结:
JVM 内存模型划分为线程私有区域和共享区域
虚拟机栈 / 本地方法栈负责存放线程执行方法栈帧
程序计数器用于记录线程执行指令的位置
方法区 (元数据区) 存储类的元数据信息, 静态变量, 常量等数据
堆 (heap) 使用 new 关键字创建的对象都会进入堆中, 堆被划分为新生代和老年代
什么时候对象可以被收回?
JVM 判断对象回收有两种方式: 引用记数, GC Roots, 引用记数比较简单, JVM 为每个对象维护一个引用计数, 假设 A 对象引用计数为零说明没有任务对象引用 A 对象, 那 A 对象就可以被回收了, 但是引用计数有个缺点就是无法解决循环引用的问题.
GC Roots 通过一系列的名为 GC Roots 的对象作为起始点, 从这些节点开始向下搜索, 搜索过的路径称为引用链, 当一个对象到 GC Roots 没有任何引用链相连时, 则证明对象是不可用的.
在 Java 中, 可以作为 GC Roots 的对象包括下面几种:
虚拟机栈中引用的对象;
方法区中类静态属性引用的对象;
方法区中的常量引用的对象;
本地方法栈中 JNI(即一般说的 Native 方法)的引用的对象;
小结
总的来说就是当一个对象通过 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.
如何优化减少 Full GC?
将前面的一些问题总结下来, 然后应用到线上, 那 JVM 应该如何优化减少 Full GC 呢? 以标准的 4 核 8G 机器为例说明, 首先系统预留 4G, 其他 4G 按如下规则分配 :
堆内存: 3g
新生代: 1.5g
新生代 Eden 区: 1228m
新生代 Survivor 区: 153m
方法区: 256m
虚拟机栈: 1m/thread
设置参数如下:
- -Xms3072m
- -Xmx3072m
- -Xmn1536m
- -Xss=1m
- -XX:PermSize=256m
- -XX:MaxPermSize=256m
- -XX:HandlePromotionFailure
- -XX:SurvivorRatio=8
估算系统每秒占用内存数量
在优化 JVM 之前, 要先估算要系统每秒占用的内存数量, 如有个日活百万的商场系统, 每日下单量在 20w 左右, 按照一天 8 个小时算, 那订单服务的每秒大概会有 500 个请求, 然后粗略的估算下每个请求占用多少内存, 计算出每秒要花费多少内存.
假设是每秒 500 个请求, 每个请求需要分配 100k 的空间, 那 1 秒需要分配大约 50m 的内存.
计算下多长时间触发一次 Minor GC
按照之前的估算 1 秒需要分配大约 50m 的内存的话, Eden 区的空间是 1228m 那平均每 25 秒就要执行一次 Minor GC.
检查下 Survivor 区是否足够
按照上面的模型, 每 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
- ...
《架构文摘》每天一篇架构领域重磅好文, 涉及一线互联网公司应用架构(高可用, 高性 能, 高稳定), 大数据, 机器学习等各个热门领域.
来源: https://www.cnblogs.com/xwgblog/p/11842394.html