0%

JVM基础知识三

垃圾回收算法

分代收集理论

当前商业虚拟机的垃圾收集器,大多遵循“分代收集”的理论来进行设计,这个理论大体上是这么描述的:

  1. 绝大部分的对象都是朝生夕死
  2. 熬过多次垃圾回收的对象就越难回收。
    根据以上两个理论,朝生夕死的对象放一个区域,难回收的对象放另外一个区域,这个就构成了新生代和老年代

GC种类

  1. 新生代回收(Minor GC/Young GC):指只是进行新生代的回收。
  2. 老年代回收(Major GC/Old GC):指只是进行老年代的回收。目前只有CMS垃圾回收器会有这个单独的收集老年代的行为。(Major GC定义是比较混乱,有说指是老年代,有的说是做整个堆的收集,这个需要你根据别人的场景来定,没有固定的说法)
  3. 整堆收集(Full GC):收集整个Java堆和方法区(注意包含方法区)

复制算法(Copying)

将可用内存按容量划分为大小相等的两块,每次只使用其中的一块当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。这样使得每次都是对整个半区进行内存回收,内存分配时也就不用考虑内存碎片等复杂情况,只要按顺序分配内存即可,实现简单,运行高效。只是这种算法的代价是将内存缩小为了原来的一半
注意:内存移动是必须实打实的移动(复制),不能使用指针玩。
复制回收算法适合于新生代,因为大部分对象朝生夕死,那么复制过去的对象比较少,效率自然就高,另外一半的一次性清理是很快的

Appel式回收

一种更加优化的复制回收分代策略:具体做法是分配一块较大的Eden区和两块较小的Survivor空间(你可以叫做From或者To,也可以叫做Survivor1和Survivor2)
专门研究表明,新生代中的对象98%是“朝生夕死”的,所以并不需要按照1:1的比例来划分内存空间,而是将内存分为一块较大的Eden空间和两块较小的Survivor空间,每次使用Eden和其中一块Survivor[1]。当回收时,将Eden和Survivor中还存活着的对象一次性地复制到另外一块Survivor空间上,最后清理掉Eden和刚才用过的Survivor空间。
HotSpot虚拟机默认Eden和Survivor的大小比例是8:1,也就是每次新生代中可用内存空间为整个新生代容量的90%(80%+10%),只有10%的内存会被“浪费”。当然,98%的对象可回收只是一般场景下的数据,我们没有办法保证每次回收都只有不多于10%的对象存活,当Survivor空间不够用时,需要依赖其他内存(这里指老年代)进行分配担保(Handle Promotion)

标记-清除算法(Mark-Sweep)

算法分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象
回收效率不稳定,如果大部分对象是朝生夕死,那么回收效率降低,因为需要大量标记对象和回收对象,对比复制回收效率很低
它的主要不足空间问题,标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后在程序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作
回收的时候如果需要回收的对象越多,需要做的标记和清除的工作越多,所以标记清除算法适用于老年代。复制回收算法适用于新生代。

标记-整理算法(Mark-Compact)

首先标记出所有需要回收的对象,在标记完成后,后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存标记整理算法虽然没有内存碎片,但是效率偏低
我们看到标记整理与标记清除算法的区别主要在于对象的移动。对象移动不单单会加重系统负担,同时需要全程暂停用户线程才能进行,同时所有引用对象的地方都需要更新。
所以看到,老年代采用的标记整理算法与标记清除算法,各有优点,各有缺点

JVM中常见的垃圾收集器

收集器 收集对象和算法 收集器类型
Serial 新生代,复制算法 单线程
ParNew 新生代,复制算法 并行的多线程收集器
Prallel Scavenge 新生代,复制算法 并行的多线程收集器
Serial Old 老年代,标记整理算法 单线程
Prallel Old 老年代,标记整理算法 并行的多线程收集器
CMS 老年代,标记清除算法 并行与并发收集器
G1 跨新生代和老年代;标记整理+化整为零 并行与并发收集器
简单的垃圾回收器工作示意图

avatar

Concurrent Mark Sweep (CMS)

avatar

CMS 算法实现步骤:

  1. 初始标记-短暂,仅仅只是标记一下GC Roots能直接关联到的对象,速度很快。
  2. 并发标记-和用户的应用程序同时进行,进行GC Roots追踪的过程,标记从GCRoots开始关联的所有对象开始遍历整个可达分析路径的对象。这个时间比较长,所以采用并发处理(垃圾回收器线程和用户线程同时工作)
  3. 重新标记-短暂,为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段稍长一些,但远比并发标记的时间短。
  4. 并发清除由于整个过程中耗时最长的并发标记和并发清除过程收集器线程都可以与用户线程一起工作,所以,从总体上来说,CMS收集器的内存回收过程是与用户线程一起并发执行的。

CMS的缺点:

  • CPU敏感:CMS对处理器资源敏感,毕竟采用了并发的收集、当处理核心数不足4个时,CMS对用户的影响较大。
  • 浮动垃圾:由于CMS并发清理阶段用户线程还在运行着,伴随程序运行自然就还会有新的垃圾不断产生,这一部分垃圾出现在标记过程之后,CMS无法在当次收集中处理掉它们,只好留待下一次GC时再清理掉。这一部分垃圾就称为“浮动垃圾”。
    由于浮动垃圾的存在,因此需要预留出一部分内存,意味着 CMS 收集不能像其它收集器那样等待老年代快满的时候再回收。
    在1.6的版本中老年代空间使用率阈值(92%)
    如果预留的内存不够存放浮动垃圾,就会出现 Concurrent Mode Failure,这时虚拟机将临时启用 Serial Old 来替代 CMS。
  • 会产生空间碎片:标记 - 清除算法会导致产生不连续的空间碎片。
Stop The World现象

任何的GC收集器都会进行业务线程的暂停,这个就是STW,Stop The World,所以我们GC调优的目标就是尽可能的减少STW的时间和次数

常量池与String

常量池有很多概念,包括运行时常量池、class常量池、字符串常量池。
avatar

常见问题

  1. JVM内存结构

avatar

  1. 什么情况下内存栈溢出?

    • java.lang.StackOverflowError 如果出现了可能会是无限递归。
    • OutOfMemoryError:不断建立线程,JVM申请栈内存,机器没有足够的内存。
  2. 描述new一个对象的流程

avatar

  1. Java对象会不会分配在栈中?

    可以,如果这个对象不满足逃逸分析,那么虚拟机在特定的情况下会走栈上分配

  2. 如果判断一个对象是否被回收,有哪些算法,实际虚拟机使用得最多的是什么?

    引用计数法和根可达性分析两种,用得最多是根可达性分析。

  3. GC收集算法有哪些?他们的特点是什么?

    复制、标记清除、标记整理。复制速度快,但是要浪费空间,不会内存碎片。标记清除空间利用率高,但是有内存碎片。标记整理算法没有内存碎片,但是要移动对象,性能较低。三种算法各有所长,各有所短

  4. JVM中一次完整的GC流程是怎样的?对象如何晋级到老年代?

    对象优先在新生代区中分配,若没有足够空间,Minor GC;
    大对象(需要大量连续内存空间)直接进入老年态;长期存活的对象进入老年态。
    如果对象在新生代出生并经过第一次MGC后仍然存活,年龄+1,若年龄超过一定限制(15),则被晋升到老年态。

  1. Java中的几种引用关系,他们的区别是什么

强引用

一般的Object obj = new Object() ,就属于强引用。在任何情况下,只有有强引用关联(与根可达)还在,垃圾回收器就永远不会回收掉被引用的对象
软引用 SoftReference
一些有用但是并非必需,用软引用关联的对象,系统将要发生内存溢出(OuyOfMemory)之前,这些对象就会被回收(如果这次回收后还是没有足够的空间,才会抛出内存溢出)。
弱引用 WeakReference
一些有用(程度比软引用更低)但是并非必需,用弱引用关联的对象,只能生存到下一次垃圾回收之前,GC发生时,不管内存够不够,都会被回收。
虚引用 PhantomReference
幽灵引用,最弱(随时会被回收掉)垃圾回收的时候收到一个通知,就是为了监控垃圾回收器是否正常工作

  1. final、finally、finalize的区别?

    在java中,final可以用来修饰类,方法和变量(成员变量或局部变量)
    当用final修饰类的时,表明该类不能被其他类所继承。当我们需要让一个类永远不被继承,此时就可以用final修饰,但要注意:

final类中所有的成员方法都会隐式的定义为final方法
使用final方法的原因主要有两个
   1. 把方法锁定,以防止继承类对其进行更改。
   2. 效率,在早期的java版本中,会将final方法转为内嵌调用。但若方法过于庞大,可能在性能上不会有多大提升。因此在最近版本中,不需要final方法进行这些优化了。
final成员变量表示常量,只能被赋值一次,赋值后其值不再改变。

finally作为异常处理的一部分,它只能用在try/catch语句中,并且附带一个语句块,表示这段语句最终一定会被执行(不管有没有抛出异常),经常被用在需要释放资源的情况下
Object中的Finalize方法
即使通过可达性分析判断不可达的对象,也不是“非死不可”,它还会处于“缓刑”阶段,真正要宣告一个对象死亡,需要经过两次标记过程,一次是没有找到与GCRoots的引用链,它将被第一次标记。随后进行一次筛选(如果对象覆盖了finalize),我们可以在finalize中去拯救
所以建议大家尽量不要使用finalize,因为这个方法太不可靠。在生产中你很难控制方法的执行或者对象的调用顺序,建议大家忘了finalize方法!因为在finalize方法能做的工作,java中有更好的,比如try-finally或者其他方式可以做得更好

  1. String s = new String(“xxx”);创建了几个对象

    2个

    1. 在一开始字符串”xxx”会在加载类时,在常量池中创建一个字符串对象。
    2. 调用 new时 会在堆内存中创建一个 String 对象,String 对象中的 char 数组将会引用常量池中字符串。