JVM和垃圾回收面试入门,这一篇就够了

前些天在 google 上搜索了一些JVM的参考资料,偶然发现了一篇文章,如获至宝,简单易懂而且相对全面的JVM和垃圾回收介绍写得非常的棒,因此一直保存着,今天有时间特意翻译了一下。本人水平有限,但是遇到好文章希望可以与更多的人分享,特此发布此文。
英文OK的话,可以查看 原文


前言

JVM是大多数开发人员的致命弱点,甚至可能导致老练的开发者一败涂地。事实上,除非出现问题,否则我们正常情况下很少会关心它。也许在应用开始运行的时候我们对JVM进行些许的调整,但那之后就不会再去碰它,除非再有什么问题出现。这让它成为一个在面试中很难跨越的障碍。更糟糕的是,面试者非常喜欢问这方面的问题。每个开发者必须具有JVM的基本知识以完成他们的工作,但通用情况下,招聘方希望找寻那些懂得怎样修复类似内存泄露这样的问题的人。
在这篇文章里,我们将从零开始学习JVM和垃圾回收的,让你有信心继续深入学习。

JVM

面试题:JVM是什么?它有什么好处?什么是“一次编写,到处运行”?它们有什么缺点?

答:JVM是Java虚拟机( Java Virtual Machine)的缩写。Java源码被编译成一种中间的语言,即字节码。然后虚拟机处理执行这些字节码。而其他语言,如C++,会直接将源码编译成指定平台上可运行的本地代码(native code)。
这就是为什么Java拥有了“一次编写,到处运行”的能力的原因。对那些直接为指定平台编译的语言,你必须分别为你要直接的不同的平台编译和测试你的应用程序。这很可能造成一些问题,例如你的应用程序依赖某些库,你必须保证这些库在不同的平台上都是可用的。每新增加一个平台,就意味着新的编译和新的测试。非常消耗时间,也非常昂贵。
而Java程序可以在任何安装了Java虚拟机的系统上运行。JVM扮演的是一个中间层的角色,它负责处理特定系统的细节,这意味着作为开发人员,我们不必关心这些细节。事实上,在不同的系统之间仍然存在一些不同,但是这些相对较小。这个机制让开发变得快速且容易,而且意味着开发者可以在 Windows 机上为其他平台编写软件。我的软件只需要编写一次就可以将其运行在从 Android 到 Solaris 之类的各种各样的平台上。
理论上,这会造成一些速度上的损耗。额外的JVM层意味着它将比直接编译到特定平台的语言更慢,比如C语言。然而 Java 在最近这些年已经有了长足的进步,而且提供而其他更多的好处,比如易用性,它已经被越来越多地应用于各种低延迟的应用程序中。
另一个JVM带来的好处是,任何可以被编译成字节码的语言都可以在它上面运行。不仅仅是Java,像 Groovy,Scala和Clojure都是基于JVM的语言。这也意味着这些语言能够轻松地应用其他语言编写的库。作为一名 Scala 开发者,我可以在我的应用程序中使用 Java 库,因为它们都运行在同一个平台上。
与硬件的分离也意味着代码是沙箱式的,从而限制了对主机的损害程度。安全性是 JVM 带来的一大好处。
还有一个有趣的方面值得考虑:并不是所有的 JVM 都一样。除了Oracle 标准的 JVM 实现,还有有很多不同的其他实现。JRockit 是一款由于速度非常快而出名的 JVM。OpenJDK 是一款开源的与标准 JVM 等价的实现。还有非常多的其他各种实现可供使用。这些选择最终是一个好事,各种不同的 JVM 的表现会有细微的差别。Java 规范在它们的实现方面故意留有很多含糊不清的地方,以让不同的虚拟机可以做不同的事情。这可能导致一个bug,这个bug只在特定虚拟机和特定平台上才会出现。这些可能是最难修复的 bug 了。
从开发者的角度上,JVM 围绕着内存管理和性能优化提供了一系列的好处。

Java面试题:JIT是什么?

JIT就是即时编译(Just in Time)的意思。前面讨论过,JVM 执行字节码。然而,如果它发现某段代码被频繁地运行,那么它将选择性地把这段代码编译为本地代码以提高执行速度。可以被即时编译的最小单位是方法。默认情况下,一段代码需要被执行 1500 次才会被即时编译。不过这个值可以配置。因此才有了给 JVM “热身”的概念。因为有这些优化发生,程序的运行时间越长,性能越好。然而 JIT 也有坏处,因为它不是免费的,当它发生时,会带来时间和资源上的开销。

Java 垃圾收集(GC)

Java面试题:我们说用 Java 管理内存,是什么意思?垃圾收集器(Gabage Collector)是什么?

在一些语言如C语言中,开发人员拥有对内存的直接访问权。代码在字面上直接线上服务内容空间的地址。这是非常困难而且危险的,很容易导致内存泄漏。而在 Java 中,所有的内存都是被自动管理的。作为程序员我们只处理对象和原始类型,对内存和指针下面发生的事情没有概念。最重要的是,Java 有垃圾收集器的概念。当对象不再被需要的时候 JVM 会为我们自动识别并清理内存空间。

Java面试题:垃圾回收器的优缺点是什么?

优点:

  • 开发人员无须过多地关心内存管理,而是关注解决具体的业务。虽然内存泄漏在技术上仍然是可能出现的,但不常见。
  • GC 在管理内存上有很多智能的算法,它们自动在后台运行。与流行的想法相反,这些通常比手动回收更能确定什么时候是执行垃圾回收的最好时机。

缺点:

  • 当垃圾回收发生时将影响程序的性能,显著地降低运行速度甚至将程序停止。所谓 “Stop the world” 就是当垃圾回收发生的时候应用程序的其他任务都将被冻结。对于应用程序的要求来说,这将是不可接收的,虽然 GC 调优可以最小化甚至消除这个影响。
  • 虽然GC有很多方面可以调优,但你不能指定应用程序在何时怎样执行GC。

Java面试题:“Stop the World”是什么?

当GC发生的时候,有必要完全地暂停应用程序的线程。这就是众所周知的 Stop The World。对于大部分应用程序来说,长时间的暂停是不可接受的。所以调节垃圾收集器,最小化垃圾收集造成的影响到一个可以接受的范围内至关重要。

Java面试题:代际GC(Generational Garbage Collection)是如何工作的?为什么我们要使用代际GC?Java堆是如何构造的?

理解 Java 堆的运行机制对回答关于GC的问题非常重要。所有的对象都被保存在堆中(与之相对应的是栈,变量和方法以及堆中对象的引用都保存在那里)。垃圾收集是一个从堆中移除不再被需要的对象并释放内存空间的过程。几乎所有的垃圾收集器都是“代际”的,它们把堆分为不同的部分,或不同的代。这已经被证明是明显更优的,所以几乎所有的收集器都使用这种模式。

新生代 (New Generation)

大多数应用程序都有大量的生命周期短暂的对象。在一次GC中分析程序的所有对象将非常慢而且耗时,因此将“短命”的对象分开处理以快速收集它们就非常有必要了。所以所有的新对象都将放在新生代中。新生代又进一步切分为:

  • 伊甸区(Eden Space):所有新的对象将放在这里。当这个空间满了之后,次要GC(minor GC)将发生。所有仍然被引用的对象将被提升到幸存区。
  • 幸存区(Survivor Space):幸存区的实现根据不同的 JVM 有所不同但是前提是相同的。每次新生代的 GC 都会增加幸存区中对象的年龄。当一个对象从Minor GC中幸存的次数足够多时(默认值有所有同,一般是15次),它会被提升到老年代。一些实现使用两个幸存区,一个 From 区和一个 To 区。每次回收过程中,这两个区会相互交换角色,将所有被提升的 Eden 中的对象和幸存区的对象移到到 To 区,清空 From 区。

在新生代的GC叫做 次要GC(Minor GC)。
使用新生代的一个好处是减少碎片的影响。当一个对象被垃圾收集时,它会在它所在的位置留下一个内存中的间隔。我们可以整理其余的对象(会发生 stop-the-world 现象)或者我们可以把间隔留着,下次有新对象时将新对象放在这个位置上。利用代际GC,我们限制了间隔的数量,这发生在老年代中,因为它通常更加稳定,这有利于减少 stop the world,以改善延迟。然而,如果我们不整理对象,我们会发现,新的对象也许因为大小的原因,不能放入到间隔当中。如果是这样的话,你将看到对象无法从新生代中被提升。

老年代(Old Generation)

任何从新生代的幸存区中幸存下来的对象都会被提升到老年代。老年代通常比新生代要大得多。在老年代中发生的GC叫做 Full GC. Full GC 也会 stop-the-world 而且需要更长的时间,这也正是为什么大多数的 JVM 调优要调整这里的原因。在垃圾收集中有多种不同的算法可以使用,而且可以对新生代和老年代使用不同的算法。

串行GC(Serial GC)

为单核计算机设计的,当GC发生的时候将会停止整个程序。它使用标记-清除-整理(mark-sweep-compact)算法。这意味着它将扫描所有对象,从而标记出所有可以被收集的对象,然后清理它们,最后把所有的对象都拷贝到一个连续的空间中(没有碎片)。

并行GC(Parallel GC)

与串行GC类似,不同的是它使用了多线程执行GC,所以会比较快一些。

并发标记清除(Concurrent Mark Sweep, CMS)

CMS GC 通过与应用程序进程并发执行GC相关的大部分工作使暂停最小化。这把程序不得不完全暂停的时间最小化了,因此更适合对时间比较敏感的应用程序。CMS是一个不整理(non compacting)的算法,这将导致碎片问题。CMS 收集器实际上为新生代使用并行GC(Parallel GC)。

G1GC(Gabage 1st Gabage Collector)

一个并发并行的收集器,它被视为未来CMS的替代者,它不会像CMS那样被碎片问题所困扰。

永久代(PermGen)

永久代是 JVM 存储类的元数据的地方。它在 Java8中已经被元数据区(metaspace)所代替。尽管当类没有被正确地卸载的情况下可能出现泄漏,但是通常情况下永久代不需要任何调优来确保它有足够的空间。

Java面试题:哪个(GC)更好?串行、并行还是CMS?

这要看整个应用程序的情况。每个收集器都是为特定的应用程序量身订做的。如果你使用的是单个CPU,或者单台机器上运行的虚拟机超过CPU的个数的情况下,串行GC(Serial GC)会更好。如果你有大量的工作要做而且可以接收暂停现象,并行GC(Parallel GC)是个好选择。如果你需要稳定的响应和最小的暂停,CMS是这三者中最好的选择。

Java面试题:你能让系统执行垃圾回收吗?

这是一个有趣的问题。答案即是肯定的又是否定的。我们可以执行 System.gc() 建议 JVM 执行垃圾回收。然而,没有保证这个建议会执行任何效果。作为Java开发者,我们不能确定地知道我们的代码将运行在哪个JVM上。JVM不保证这个方法执行后会发生什么。甚至有一个启动参数 -XX:+DisableExplicitGC 来禁止手动执行GC。
使用System.gc()是一个糟糕的实践。

Java面试题:finallize() 方法会做什么?

finallize() 是一个 java.lang.Object 里的方法,所以所有的对象都有该方法。默认的实现没有做任何事情。这个方法将在这个对象已经没有被引用,垃圾收集决定回收它的时候调用。因此这里的代码没有保证会被执行,所以这个方法不能被用于执行实际的功能。相反,它被用来清理资源,例如文件的引用。一个对象中,这个方法只会被JVM调用一次。

Java面试题:我可以使用哪些参数来对JVM和GC进行调优?

为GC进行JVM进行调优有非常多的书可以看。但是为面试准备了解几个是很好的。

-XX:-UseConcMarkSweepGC: 对老年代使用CMS收集器
-XX:-UseParallelGC: 对新生代使用 Parallel GC
-XX:-UseParallelOldGC: 在老年代和新生代都使用 Parallel GC
-XX:-HeapDumpOnOutOfMemeryError: 当应用发生OOM时创建一个线程转储(dump)。对诊断非常有用。
-XX:-PrintGCDetails: 打印GC的详细日志
-Xms512m: 设置初始堆大小为 512m
-Xmx1024m: 设置最大堆大小为 1024m
-XX:NewSize 和 -XX:MaxNewSize: 指定新生代的默认和最大空间。
-XX:NewRatio=3: 设置新生代和老年代大小的比例为 1:3
-XX:SurvivorRatio=10: 设置 Eden space 和 Survivor space 的比例

诊断(Diagnosis)

知道所有上面的问题让你可以展示出你对JVM的工作机制有基本的了解。最标准的面试题中有一个是这样的:“你遇到过内存泄漏吗?你如果诊断它的?”。
这对于大多数人来说是一个非常难的问题,尽管他们可能做过,但那可能是很久以前的事情了。最好的准备是,真实地试着写一个有内存泄漏的应用程序然后尝试去诊断它。下面我创建一个设计好的内存泄漏的例子,它将让我们一步一步地经历识别问题的过程。我强烈建议你下载源码然后跟着完成这个过程。你真的这么做的话,会增强你的记忆。

public class Main {
    public static void main(String[] args) {
        TaskList taskList = new TaskList();
        final TaskCreator taskCreator = new TaskCreator(taskList);
        new Thread(new Runnable() {
            @Override
            public void run() {
                for (int i = 0; i < 100000; i++) {
                    taskCreator.createTask();
                }
            }
        }).start();
    }
}
public class TaskList {
    private static Deque<Task> tasks = new ArrayDeque<Task>();
    public void addTask(Task task){
        tasks.add(task);
        tasks.peek().execute();//Memory leak!
    }
}

上面是一个非常不自然的例子,程序执行一个任务,将它放到一个双向队列中(Deque)。然而当我们运行它的时候,我们会得到一个内存溢出错误(OutOfMemoryError)!为什么会这样呢?
OOM
为了找出问题,我们需要使用一个分析器(profiler)。一个可以让我们确切地看到虚拟机正在发生什么的分析器。这里有很多可供选择的分析器。VisualVM 是一个免费的可以做基本分析的分析器。还有其他更多完整的工具套件可以选择,但我个人最喜欢的是 Yourkit。它拥有一系列非常棒的工具来帮助你诊断和分析。但是使用这些工具的原则其实是一样的。
我开始在本地运行我的应用程序,打开 VisualVM,然后选择进程。你可以准确地看到堆、永久代中在发生什么。
Visual VM
你可以在堆(右上角)那里看到内存泄漏的尾部标记。那些每秒产生的锯齿是没有问题的,但是内存一直上涨而且没有回归到基本的水平就出现问题了。这里有内存泄漏的味道。但是我们怎么说出这里面到底发生了什么事呢?如果我们切换到 Sampler 页,我们可以得到一个清楚的指示,告诉我们堆里面都有些什么东西。
Sampler页,应用刚执行时的截图
这些对象数组(Object arrays)看起来有点奇怪。但我们怎么知道它有没有问题呢?Visual VM 可以让我们拍一个快照,就像内存在某个时间点上的照片一样。上面的截图是一个程序刚刚运行起来之后的快照。下面这张图是几分钟之后拍的:
执行几分钟后的截图

我们在菜单中选择这两个快照并选择对比,就可以直接对比它们。
菜单
这个对象数组肯定不太好。但是我们如何指出它泄漏了呢?通过使用分析器页(profiler tab)。如果我切换到分析器,然后在设置中打开“记录分配堆栈信息(record allocations stack traces)”,我们可以找出泄漏来自哪里。
记录分配堆栈信息(
现在拍摄快照并展示分配信息,我们可以看到这个 Object 数组是在哪里被实例化的。
展示对象在哪里被实例化
看起来这里有成千上万的 Task 对象被 Object 数组所引用!但是这是些什么 Task 项目呢?
如果我们返回到 Monitor 页,我们可以创建一个堆转储(heap dump)。如果我们在 heap dump 上双击Object[],它会向我们展示在程序中的所有实例,然后在右下角的面板上我们可以辨别出这些引用是什么。
Heap Dump

看起来这个 TaskList 就是罪魁祸首!如果我们看一下源码,我们可以看到问题所在:
tasks.peek().execute();
我们执行完任务之后没有清理引用!如果我们改一下,使用 poll() 那么内存泄漏就修复了。
这是一个特别设计的例子,经历这些步骤将让你刷新记忆。如果你被问到解释你如果辨认程序中的内存泄漏,这个例子会非常有帮助。

  • 首先,查看不管GC发生与否内存都在持续增长的地方;
  • 其次,生成内存快照然后进行比对以找出哪个 Objects 是可能的没有被释放的候选者;
  • 最后,使用 heap dump 来分析是哪里在引用它们。

猜你喜欢

转载自blog.csdn.net/dadiyang/article/details/82823447
今日推荐