Java面试问题总结——如果老年代对象引用年轻代对象,年轻代对象是否会被垃圾回收?

目录

一、Java内存管理

1.程序计数器(Program Counter Register)

2.虚拟机栈(Virtual Machine Stack)

3.本地方法栈(Native Method Stack)

4.方法区(Method Area)

5.堆(Heap)

6.直接内存

二、Java垃圾回收机制

1.垃圾回收判断方法

2.方法区的垃圾回收判断

3.垃圾收集算法

4.垃圾收集器

三、如果老年代对象引用年轻代对象,年轻代对象是否会被垃圾回收?


一、Java内存管理

在C语言中,开发人员可以直接访问内存并在代码中引用内存单元,这种机制虽然也有好处但一旦出现问题也很难定位问题。对于Java开发人员来说,则没有这样的烦恼,因为Java直接将内存管理交由JVM来管理,这样开发人员在编写程序的时候就不用担心内存的使用情况而可以专注内容的实现。但这其实也造成了一点隐患,如果不了解JVM内存管理的机制,很可能会因一些错误的代码写法而导致内存泄漏或内存溢出。

Java虚拟机在执行Java程序的过程中会把它所管理的内存划分为若干不同的数据区域,这些区域都有各自的用途以及创建和销毁的时间。JVM管理的内存包含程序计数器、虚拟机栈、本地方法栈、方法区和堆,下面内容根据数据区域详细介绍。

Java面试问题总结——如果老年代对象引用年轻代对象,年轻代对象是否会被垃圾回收?

1.程序计数器(Program Counter Register)

程序计数器是一块较小的内存空间,可以看作当前线程所执行字节码的行号指示器,即指向正在执行的字节码。在概念模型中,字节码解释器的工作就是通过改变这个程序计数器的值来选取下一条字节码的指令,分支、循环、跳转、异常处理、线程恢复等基础功能都要依赖这个计数器来完成。

多线程中,为了让线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间互不影响、独立存储,因此该内存是线程私有的。当线程正在执行的是一个Java方法,这个计数器记录的是在正在执行的虚拟机字节码指令的地址;当执行的是Native方法,这个计数器值为空。

注意:此内存区域是唯一一个没有规定任何 OutOfMemoryError 情况的区域 ,它的生命周期随着线程的创建而创建,随着线程的结束而死亡。

2.虚拟机栈(Virtual Machine Stack)

虚拟机栈是线程私有的内存空间,每个线程都有一个线程栈,每个方法被执行时都会创建一个栈帧,方法执行完成,栈帧弹出,线程运行结束,线程栈被回收。

虚拟机栈就是Java中的方法执行的内存模型,每个方法在执行的同时都会创建一个栈帧,这个栈帧用于存储局部变量表、操作数栈指向当前方法所属的类的运行时常量池的引用、方法返回地址等信息,每个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。

局部变量表存储了编译器可知的各种基本数据类型(boolean、byte、char、short、int、float、long、double)、对象引用和returnAddress类型(指向了一条字节码指令的地址)。局部变量表的大小在编译器就可以确定其大小,并且在程序执行期间局部变量表的大小是不会改变的。

程序中的所有计算过程都是在借助于操作数栈来完成的。

指向运行时常量池的引用,因为在方法执行的过程中有可能需要用到类中的常量,所以必须要有一个引用指向当前方法所属的类的运行时常量池

方法返回地址,当一个方法执行完毕之后,要返回之前调用它的地方,因此在栈帧中必须保存一个方法返回地址。

虚拟机栈可能会出现 *Error 和 OutOfMemoryError 两种内存异常问题:

  1. *Error: 若 Java 虚拟机栈的内存大小不允许动态扩展,那么当线程请求栈的深度超过当前 Java 虚拟机栈的最大深度的时候,就抛出 *Error 错误。
  2. OutOfMemoryError: 若 Java 虚拟机栈的内存大小允许动态扩展,且当线程请求栈时内存用完了,无法再动态扩展了,此时抛出 OutOfMemoryError 错误。

3.本地方法栈(Native Method Stack)

该内存区域和虚拟机栈所发挥的作用非常相似,主要区别是虚拟机栈为JVM执行 Java 方法 (也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。 在 HotSpot 虚拟机中把本地方法栈和 JVM 合二为一。

本地方法被执行的时候,在本地方法栈也会创建一个栈帧,用于存放该本地方法的局部变量表、操作数栈、动态链接、出口信息。

方法执行完毕后相应的栈帧也会出栈并释放内存空间,也会出现 *Error 和 OutOfMemoryError 两种错误。

4.方法区(Method Area)

方法区是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息(包括类的名称、方法信息、成员变量信息)、常量静态变量、以及编译器编译后的代码等数据。

方法区存放的数据一般不会发生垃圾回收,垃圾回收的主要目标是常量池的回收和类的卸载。当方法区中无法申请到足够的内存时,将会抛出 OutOfMemoryError 异常

运行时常量池(Runtime Constant Pool)是方法区的一部分,Class 文件中除了有类的版本、字段、方法、接口等描述信息外,还有常量池信息(存放编译期生成的各种字面量和符号引用)。

  • 字面量:1.文本字符串 2.八种基本类型的值 3.被声明为final的常量
  • 符号引用:1.类和方法的全限定名 2.字段的名称和描述符 3.方法的名称和描述符

注意:JDK1.7 及之后版本的 JVM 已经将运行时常量池从方法区中移了出来,在 Java 堆(Heap)中开辟了一块区域存放运行时常量池。

5.堆(Heap)

堆是 Java 虚拟机管理的内存中最大的一块,它是所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例以及数组都在这里分配内存。

堆是垃圾收集器管理的主要区域,也被称为“GC堆”,从内存回收的角度来看,堆可以按照1:2的比例划分为新生代和老年代。新生代也可以进一步按照8:1:1的比例细分为Eden区、From Survivor区和To Survivor区。这样划分的目的是为了使 JVM 能够更好的管理堆内存中的对象,包括内存的分配以及回收。

Java面试问题总结——如果老年代对象引用年轻代对象,年轻代对象是否会被垃圾回收?

一般创建的类对象是放在新生代的Eden区,如果Eden区存储空间不够则引发 Minor GC,这个过程会把Eden区存活的对象复制移到 From Survivor区,同时Eden区清空,这样Eden区依然可以用来存储创建的对象。如果后面不断创建对象导致Eden区又满了,这个时候又会触发 Minor GC,这个过程和之前略有不同,Eden区和 From Survivor 区存活的对象会被复制移动到 To Survivor 区,同时这个区域清空。From Survivor 区和 To Survivor 区依次被用来存放Minor GC后存活的对象,这种方法的好处是可以避免内存碎片的产生。如果一个对象经历了多次 Minor GC 后依然存活,它就会被直接放入老年代,这个次数一般是15次,也可以使用 -XX:MaxTenuringThreshold 参数来调整。

1.如果某次Minor GC后,Survivor 区的空间大小无法满足存活对象的总大小,这个时候老年代会进行分配担保,把Survivor无法容纳的对象直接进入老年代。JDK 6 Update 24之后,担保的规则是,只要老年代的连续空间大于新生代对象总大小或者历次晋升的平均大小就会进行MinorGC,否则将进行Full GC。

2.创建的类对象并不是一定放入新生代中,如果占据内存空间比较大的对象会被直接放入老年代中,这样可以避免为大对象分配内存时由于分配担保机制带来的复制而降低效率,虚拟机提供了 -XX:PretenureSizeThreshold参数来调整这个阈值。

6.直接内存

直接内存并不是虚拟机运行时数据区的一部分,也不是虚拟机规范中定义的内存区域,但是这部分内存也被频繁地使用,而且也可能导致 OutOfMemoryError 错误出现。

它在JDK中最直观的表现就是NIO,基于通道(Channel)与缓冲区(Buffer)的I/O方式,它可以使用Native函数库直接分配堆外内存,然后通过一个存储在Java堆中的 DirectByteBuffer 对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在Java堆和Native堆中来回复制数据。

本机直接内存的分配不会受到 Java 堆的限制,但是,既然是内存就会受到本机总内存大小以及处理器寻址空间的限制。

二、Java垃圾回收机制

垃圾回收(Garbage Collection,GC),顾名思义就是释放垃圾占用的空间,防止内存泄露,有效的使用可以使用的内存,对内存堆中已经死亡的或者长时间没有使用的对象进行清除和回收。

Java 内存运行时区域中的程序计数器、虚拟机栈、本地方法栈随线程而生灭;栈中的栈帧随着方法的进入和退出而有条不紊地执行着出栈和入栈操作。每一个栈帧中分配多少内存基本上是在类结构确定下来时就已知的(尽管在运行期会由 JIT 编译器进行一些优化),因此这几个区域的内存分配和回收都具备确定性,不需要过多考虑回收的问题,因为方法结束或者线程结束时,内存自然就跟随着回收了。

Java 堆则不一样,一个接口中的多个实现类需要的内存可能不一样,一个方法中的多个分支需要的内存也可能不一样,我们只有在程序处于运行期间时才能知道会创建哪些对象,这部分内存的分配和回收都是动态的,垃圾收集器所关注的是这部分内存

1.垃圾回收判断方法

Java中判断对象是否应该被回收采用两种方法,分别是引用计数法可达性分析法

  • 引用计数算法(Reachability Counting)是通过在对象头中分配一个空间来保存该对象被引用的次数(Reference Count)。如果该对象被其它对象引用,则它的引用计数加1,如果删除对该对象的引用,那么它的引用计数就减1,当该对象的引用计数为0时,那么该对象就会被回收。
  • 可达性分析算法(Reachability Analysis)是通过一些被称为引用链(GC Roots)的对象作为起点,从这些节点开始向下搜索,搜索走过的路径被称为(Reference Chain),当一个对象到 GC Roots 没有任何引用链相连时(即从 GC Roots 节点到该节点不可达),则证明该对象是不可用的。

引用计数法虽然性能比较高效,但是也有致命的缺点,它无法解决循环引用的问题,因此现在垃圾回收器不采用该方法。

可达性分析法解决了循环引用的问题,一般可作为 GC Roots 的对象包括下面几种。

  • A. 虚拟机栈(栈帧中的本地变量表)中引用的对象。
  • B. 方法区中类静态属性引用的对象。
  • C. 方法区中常量引用的对象。
  • D. 本地方法栈中 JNI(Native方法)引用的对象

Java面试问题总结——如果老年代对象引用年轻代对象,年轻代对象是否会被垃圾回收?

不可达的对象将暂时处于“缓刑”阶段,要真正宣告一个对象死亡,至少要经历两次标记过程:

  1. 如果对象在进行可达性分析后发现没有与 GC Roots 相连接的引用链,那它将会被第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行 finalize() 方法。
  2. 当对象没有覆盖 finalize() 方法,或者 finalize() 方法已经被虚拟机调用过,虚拟机将这两种情况都视为“没有必要执行”,直接进行第二次标记。
  3. 如果这个对象被判定为有必要执行 finalize() 方法,那么这个对象将会放置在一个叫做 F-Queue 的队列之中,并在稍后由一个由虚拟机自动建立的、低优先级的 Finalizer 线程去执行它。

这里所谓的“执行”是指虚拟机会触发这个方法,但并不承诺会等待它运行结束,因为如果一个对象在 finalize() 方法中执行缓慢,将很可能会一直阻塞 F-Queue 队列,甚至导致整个内存回收系统崩溃。

2.方法区的垃圾回收判断

方法区的垃圾收集主要关注废弃常量和无用的类两部分内容。

a.废弃常量与 Java 堆中的对象非常类似。以常量池中字面量的回收为例,假如一个字符串"abc"已经进入了常量池中,但是当前系统没有任何一个 String 对象是叫做"abc"的,也没有其他地方引用了这个字面量,如果这时发生内存回收,而且必要的话,这个"abc"常量就会被系统清理出常量池。常量池中的其他类(接口)、方法、字段的符号引用也与此类似。

b.无用的类被回收一般需要满足下列条件:

  • 该类所有的实例都已经被回收,也就是 Java 堆中不存在该类的任何实例。
  • 加载该类的 ClassLoader 已经被回收。
  • 该类对应的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

虚拟机可以对满足上述 3 个条件的无用类进行回收,这里说的仅仅是“可以”,而并不是和对象一样,不使用了就必然会回收。在大量使用反射、动态代理、CGLib 等 ByteCode 框架、动态生成 JSP 以及 OSGi 这类频繁自定义 ClassLoader 的场景都需要虚拟机具备类卸载的功能,以保证永久代不会溢出。

3.垃圾收集算法

垃圾收集算法一共有四种,分别是标记-清除算法、复制算法、标记整理算法和分代收集算法。

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

标记-清除(Mark-Sweep)算法是最基础的收集算法,它被分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。该算法也有两个问题:1.效率问题,标记和清除两个过程的效率都不高;2.空间问题,标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后在程序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。

复制(Copy)算法

复制算法是为了解决标记清除算法效率不高的问题而设计的,它可以将内存分为大小相同的两块,每次使用其中的一块。当这一块的内存使用完后,就将还存活的对象复制到另一块去,然后再把使用的空间一次清理掉。这样就使每次的内存回收都是对内存区间的一半进行回收。

现在的商业虚拟机都采用复制算法来回收新生代,IBM 研究指出新生代中的对象 98% 是“朝生夕死”的,所以并不需要按照 1:1 的比例来划分内存空间,而是将内存分为一块较大的 Eden 空间和两块较小的 Survivor 空间,每次使用 Eden 和其中一块 Survivor 

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

复制算法在对象存活率较高时就要进行较多的复制操作,效率将会变低。更关键的是,如果不想浪费 50% 的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都 100% 存活的极端情况,所以在老年代一般不能直接选用这种算法。

根据老年代的特点,有人提出了另外一种“标记-整理”(Mark-Compact)算法,标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。

分代收集(Generational Collection)算法

当前商业虚拟机的垃圾收集都采用“分代收集”(Generational Collection)算法,根据对象存活周期的不同将内存划分为几块并采用不用的垃圾收集算法。

一般是把 Java 堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法。在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。而老年代中因为对象存活率高、没有额外空间对它进行分配担保,就必须使用“标记—清理”或者“标记—整理”算法来进行回收。

4.垃圾收集器

垃圾收集器是垃圾回收算法的具体实现,不同商家和不同版本的JVM所提供的垃圾收集器可能会有很在差别,本文主要介绍HotSpot虚拟机中的垃圾收集器。

Java面试问题总结——如果老年代对象引用年轻代对象,年轻代对象是否会被垃圾回收?

上图展示了Serial、ParNew、Parallel Scavenge、Serial Old、Parallel Old、CMS、G1等七种作用于不同分代的收集器,如果两个收集器之间存在连线,就说明它们可以搭配使用。虚拟机所处的区域,则表示它是属于新生代收集器还是老年代收集器。虽然我们对各个收集器进行比较,但并非要挑选出一个最好的收集器。因为直到现在为止还没有最好的垃圾收集器出现,更加没有万能的垃圾收集器,我们能做的就是根据具体应用场景选择适合自己的垃圾收集器。

Serial 收集器

Serial(串行)收集器收集器是最基本、历史最悠久的垃圾收集器了。大家看名字就知道这个收集器是一个单线程收集器了。它的 “单线程 的意义不仅仅意味着它只会使用一条垃圾收集线程去完成垃圾收集工作,更重要的是它在进行垃圾收集工作的时候必须暂停其他所有的工作线程( "Stop The World" ),直到它收集结束。

新生代采用复制算法,老年代采用标记-整理算法。 

Java面试问题总结——如果老年代对象引用年轻代对象,年轻代对象是否会被垃圾回收?

Serial 收集器相对于其他收集器的单线程的优势是简单高效,Serial 收集器由于没有线程交互的开销,自然可以获得很高的单线程收集效率。Serial 收集器对于运行在 Client 模式下的虚拟机来说是个不错的选择。

ParNew 收集器

ParNew 收集器其实就是 Serial 收集器的多线程版本,除了使用多线程进行垃圾收集外,其余行为(控制参数、收集算法、回收策略等等)和 Serial 收集器完全一样。

新生代采用复制算法,老年代采用标记-整理算法。 

Java面试问题总结——如果老年代对象引用年轻代对象,年轻代对象是否会被垃圾回收?

该收集器是许多运行在 Server 模式下的虚拟机的首要选择,除了 Serial 收集器外,只有它能与 CMS 收集器(真正意义上的并发收集器,后面会介绍到)配合工作。

Parallel Scavenge 收集器

Parallel Scavenge 收集器也是使用复制算法的多线程收集器,关注点是吞吐量(高效率的利用 CPU)。CMS 等垃圾收集器的关注点更多的是用户线程的停顿时间(提高用户体验)。

所谓吞吐量就是 CPU 中用于运行用户代码的时间与 CPU 总消耗时间的比值。

Parallel Scavenge 收集器提供了很多参数供用户找到最合适的停顿时间或最大吞吐量,如果对于收集器运作不太了解的话,手工优化存在困难的话可以选择把内存管理优化交给虚拟机去完成也是一个不错的选择。

Java面试问题总结——如果老年代对象引用年轻代对象,年轻代对象是否会被垃圾回收?

新生代采用复制算法,老年代采用标记-整理算法。

Serial Old 收集器

Serial 收集器的老年代版本,它同样是一个单线程收集器。它主要有两大用途:一种用途是在 JDK1.5 以及以前的版本中与 Parallel Scavenge 收集器搭配使用,另一种用途是作为 CMS 收集器的后备方案。

Java面试问题总结——如果老年代对象引用年轻代对象,年轻代对象是否会被垃圾回收?

Parallel Old 收集器

Parallel Scavenge 收集器的老年代版本。使用多线程和“标记-整理”算法。在注重吞吐量以及 CPU 资源的场合,都可以优先考虑 Parallel Scavenge 收集器和 Parallel Old 收集器。

Java面试问题总结——如果老年代对象引用年轻代对象,年轻代对象是否会被垃圾回收?

Parallel Scavenge 收集器的使用场景:

  • JDK1.6及之后用来代替老年代的Serial Old收集器;
  • 特别是在Server模式,多CPU的情况下;
  • 注重吞吐量以及CPU资源敏感的场景,就有了Parallel Scavenge加Parallel Old收集器的"给力"应用组合;

CMS 收集器

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。它非常符合在注重用户体验的应用上使用。

CMS(Concurrent Mark Sweep)收集器是 HotSpot 虚拟机第一款真正意义上的并发收集器,它第一次实现了让垃圾收集线程与用户线程(基本上)同时工作。

从名字中的Mark Sweep这两个词可以看出,CMS 收集器是一种 “标记-清除”算法实现的,它的运作过程相比于前面几种垃圾收集器来说更加复杂一些。整个过程分为四个步骤:

  • 初始标记: 暂停所有的其他线程,并记录下直接与 root 相连的对象,速度很快 ;
  • 并发标记: 同时开启 GC 和用户线程,用一个闭包结构去记录可达对象。但在这个阶段结束,这个闭包结构并不能保证包含当前所有的可达对象。因为用户线程可能会不断的更新引用域,所以 GC 线程无法保证可达性分析的实时性。所以这个算法里会跟踪记录这些发生引用更新的地方。
  • 重新标记: 重新标记阶段就是为了修正并发标记期间因为用户程序继续运行而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段的时间稍长,远远比并发标记阶段时间短
  • 并发清除: 开启用户线程,同时 GC 线程开始对为标记的区域做清扫。

Java面试问题总结——如果老年代对象引用年轻代对象,年轻代对象是否会被垃圾回收?

从它的名字就可以看出它是一款优秀的垃圾收集器,主要优点:并发收集、低停顿。但是它有下面三个明显的缺点:

  • 对 CPU 资源敏感;
  • 无法处理浮动垃圾;
  • 它使用的回收算法-“标记-清除”算法会导致收集结束时会有大量空间碎片产生。

G1 收集器

G1 (Garbage-First) 是一款面向服务器的垃圾收集器,主要针对配备多颗处理器及大容量内存的机器. 以极高概率满足 GC 停顿时间要求的同时,还具备高吞吐量性能特征.

Java面试问题总结——如果老年代对象引用年轻代对象,年轻代对象是否会被垃圾回收?

被视为 JDK1.7 中 HotSpot 虚拟机的一个重要进化特征。它具备一下特点:

  • 并行与并发:G1 能充分利用 CPU、多核环境下的硬件优势,使用多个 CPU(CPU 或者 CPU 核心)来缩短 Stop-The-World 停顿时间。部分其他收集器原本需要停顿 Java 线程执行的 GC 动作,G1 收集器仍然可以通过并发的方式让 java 程序继续执行。
  • 分代收集:虽然 G1 可以不需要其他收集器配合就能独立管理整个 GC 堆,但是还是保留了分代的概念。
  • 空间整合:与 CMS 的“标记--清理”算法不同,G1 从整体来看是基于“标记整理”算法实现的收集器;从局部上来看是基于“复制”算法实现的。
  • 可预测的停顿:这是 G1 相对于 CMS 的另一个大优势,降低停顿时间是 G1 和 CMS 共同的关注点,但 G1 除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为 M 毫秒的时间片段内。

G1 收集器的运作大致分为以下几个步骤:

  • 初始标记
  • 并发标记
  • 最终标记
  • 筛选回收

G1 收集器在后台维护了一个优先列表,每次根据允许的收集时间,优先选择回收价值最大的 Region(这也就是它的名字 Garbage-First 的由来)。这种使用 Region 划分内存空间以及有优先级的区域回收方式,保证了 G1 收集器在有限时间内可以尽可能高的收集效率(把内存化整为零)。

三、如果老年代对象引用年轻代对象,年轻代对象是否会被垃圾回收?

在回答这个问题前我们需要知道 Minor GC 、Major GC 和 Full GC 的区别。

Minor GC 

Minor GC指新生代GC,即发生在新生代(包括Eden区和Survivor区)的垃圾回收操作,当新生代无法为新生对象分配内存空间的时候,会触发Minor GC。因为新生代中大多数对象的生命周期都很短,所以发生Minor GC的频率很高,虽然它会触发stop-the-world,但是它的回收速度很快。

Major GC 

Major GC清理Tenured区,用于回收老年代,出现 Major GC 通常会出现至少一次Minor GC。

Full GC 

Full GC是针对整个新生代、老生代、元空间(metaspace,java8以上版本取代perm gen)的全局范围的GC。Full GC不等于Major GC,也不等于Minor GC+Major GC,发生Full GC需要看使用了什么垃圾收集器组合,才能解释是什么样的垃圾回收。

当老年代存活对象较多时,每次Minor GC查询老年代所有对象影响回收效率(因为GC会 stop-the-world),所以在老年代有一个write barrier(写屏障)来管理的card table(卡表),card table存放了所有老年代对象对新生代对象的引用。