java——面试题 进阶(四)

Java常见的垃圾收集器有哪些?

典型回答

实际上,垃圾收集器(GC,Garbage Collector)是和具体 JVM 实现紧密相关的,不同厂商(IBM、Oracle),不同版本的 JVM,提供的选择也不同。接下来,我来谈谈最主流的 Oracle JDK。

  • Serial GC,它是最古老的垃圾收集器,“Serial”体现在其收集工作是单线程的,并且在进行垃圾收集过程中,会进入臭名昭著的“Stop-The-World”状态。当然,其单线程设计也意味着精简的 GC 实现,无需维护复杂的数据结构,初始化也简单,所以一直是 Client 模式下 JVM 的默认选项。
    从年代的角度,通常将其老年代实现单独称作 Serial Old,它采用了标记 - 整理(Mark-Compact)算法,区别于新生代的复制算法。
    Serial GC 的对应 JVM 参数是:
-XX:+UseSerialGC
  • ParNew GC,很明显是个新生代 GC 实现,它实际是 Serial GC 的多线程版本,最常见的应用场景是配合老年代的 CMS GC 工作,下面是对应参数
-XX:+UseConcMarkSweepGC -XX:+UseParNewGC
  • CMS(Concurrent Mark Sweep) GC,基于标记 - 清除(Mark-Sweep)算法,设计目标是尽量减少停顿时间,这一点对于 Web 等反应时间敏感的应用非常重要,一直到今天,仍然有很多系统使用 CMS GC。但是,CMS 采用的标记 - 清除算法,存在着内存碎片化问题,所以难以避免在长时间运行等情况下发生 full GC,导致恶劣的停顿。另外,既然强调了并发(Concurrent),CMS 会占用更多 CPU 资源,并和用户线程争抢。
  • Parrallel GC,在早期 JDK 8 等版本中,它是 server 模式 JVM 的默认 GC 选择,也被称作是吞吐量优先的 GC。它的算法和 Serial GC 比较相似,尽管实现要复杂的多,其特点是新生代和老年代 GC 都是并行进行的,在常见的服务器环境中更加高效。
    开启选项是:
-XX:+UseParallelGC

另外,Parallel GC 引入了开发者友好的配置项,我们可以直接设置暂停时间或吞吐量等目标,JVM 会自动进行适应性调整,例如下面参数:

-XX:MaxGCPauseMillis=value
-XX:GCTimeRatio=N // GC 时间和用户时间比例 = 1 / (N+1)
  • G1 GC 这是一种兼顾吞吐量和停顿时间的 GC 实现,是 Oracle JDK 9 以后的默认 GC 选项。G1 可以直观的设定停顿时间的目标,相比于 CMS GC,G1 未必能做到 CMS 在最好情况下的延时停顿,但是最差情况要好很多。
    G1 GC 仍然存在着年代的概念,但是其内存结构并不是简单的条带式划分,而是类似棋盘的一个个 region。Region 之间是复制算法,但整体上实际可看作是标记 - 整理(Mark-Compact)算法,可以有效地避免内存碎片,尤其是当 Java 堆非常大的时候,G1 的优势更加明显。
    G1 吞吐量和停顿表现都非常不错,并且仍然在不断地完善,与此同时 CMS 已经在 JDK 9 中被标记为废弃(deprecated),所以 G1 GC 值得你深入掌握。

考点分析

这个问题是考察你对 GC 的了解,GC 是 Java 程序员的面试常见题目,但是并不是每个人都有机会或者必要对 JVM、GC 进行深入了解,前面的总结是为不熟悉这部分内容的同学提供一个整体的印象。

对于垃圾收集,面试官可以循序渐进从理论、实践各种角度深入,也未必是要求面试者什么都懂。但如果你懂得原理,一定会成为面试中的加分项。下面的讲解中,侧重介绍比较通用、基础性的部分:

  • 垃圾收集的算法有哪些?如何判断一个对象是否可以回收?
  • 垃圾收集器工作的基本流程。

另外,Java 一直处于非常迅速的发展之中,在最新的 JDK 实现中,还有多种新的 GC,会在最后补充,除了前面提到的垃圾收集器,看看还有哪些值得关注的选择。

知识扩展

垃圾收集的原理和基础概念

第一,自动垃圾收集的前提是清楚哪些内存可以被释放。

主要就是两个方面,最主要部分就是对象实例,都是存储在堆上的;还有就是方法区中的元数据等信息,例如类型不再使用,卸载该 Java 类似乎是很合理的。

对于对象实例收集,主要是两种基本算法,引用计数和可达性分析。

  • 引用计数算法,顾名思义,就是为对象添加一个引用计数,用于记录对象被引用的情况,如果计数为 0,即表示对象可回收。这是很多语言的资源回收选择,例如因人工智能而更加火热的 Python,它更是同时支持引用计数和垃圾收集机制。具体哪种最优是要看场景的,业界有大规模实践中仅保留引用计数机制,以提高吞吐量的尝试。
    Java 并没有选择引用计数,是因为其存在一个基本的难题,也就是很难处理循环引用关系。
  • 另外就是 Java 选择的可达性分析,Java 的各种引用关系,这种类型的垃圾收集通常叫作追踪性垃圾收集(Tracing Garbage Collection)。其原理简单来说,就是将对象及其引用关系看作一个图,选定活动的对象作为 GC Roots,然后跟踪引用链条,如果一个对象和 GC Roots 之间不可达,也就是不存在引用链条,那么即可认为是可回收对象。JVM 会把虚拟机栈和本地方法栈中正在引用的对象、静态属性引用的对象和常量,作为 GC Roots。

方法区无用元数据的回收比较复杂,简单梳理一下。一般来说初始化类加载器加载的类型是不会进行类卸载(unload)的;而普通的类型的卸载,往往是要求相应自定义类加载器本身被回收,所以大量使用动态类型的场合,需要防止元数据区(或者早期的永久代)不会 OOM。在 8u40 以后的 JDK 中,下面参数已经是默认的:

-XX:+ClassUnloadingWithConcurrentMark

第二,常见的垃圾收集算法,总体上有个了解,理解相应的原理和优缺点,就已经足够了,其主要分为三类:

  • 复制(Copying)算法,我前面讲到的新生代 GC,基本都是基于复制算法,将活着的对象复制到 to 区域,拷贝过程中将对象顺序放置,就可以避免内存碎片化。
    这么做的代价是,既然要进行复制,既要提前预留内存空间,有一定的浪费;另外,对于 G1 这种分拆成为大量 region 的 GC,复制而不是移动,意味着 GC 需要维护 region 之间对象引用关系,这个开销也不小,不管是内存占用或者时间开销。
  • 标记 - 清除(Mark-Sweep)算法,首先进行标记工作,标识出所有要回收的对象,然后进行清除。这么做除了标记、清除过程效率有限,另外就是不可避免的出现碎片化问题,这就导致其不适合特别大的堆;否则,一旦出现 Full GC,暂停时间可能根本无法接受。
  • 标记 - 整理(Mark-Compact),类似于标记 - 清除,但为避免内存碎片化,它会在清理过程中将对象移动,以确保移动后的对象占用连续的内存空间。

注意,这些只是基本的算法思路,实际 GC 实现过程要复杂的多,目前还在发展中的前沿 GC 都是复合算法,并且并行和并发兼备。

如果对这方面的算法有兴趣,可以参考一本比较有意思的书《垃圾回收的算法与实现》,虽然其内容并不是围绕 Java 垃圾收集,但是对通用算法讲解比较形象。

垃圾收集过程的理解

在垃圾收集的过程,对应到 Eden、Survivor、Tenured 等区域会发生什么变化呢?

这实际上取决于具体的 GC 方式,先来熟悉一下通常的垃圾收集流程,我画了一系列示意图,希望能有助于你理解清楚这个过程。

第一,Java 应用不断创建对象,通常都是分配在 Eden 区域,当其空间占用达到一定阈值时,触发 minor GC。仍然被引用的对象(绿色方块)存活下来,被复制到 JVM 选择的 Survivor 区域,而没有被引用的对象(黄色方块)则被回收。注意,我给存活对象标记了“数字 1”,这是为了表明对象的存活时间。

第二, 经过一次 Minor GC,Eden 就会空闲下来,直到再次达到 Minor GC 触发条件,这时候,另外一个 Survivor 区域则会成为 to 区域,Eden 区域的存活对象和 From 区域对象,都会被复制到 to 区域,并且存活的年龄计数会被加 1。

第三, 类似第二步的过程会发生很多次,直到有对象年龄计数达到阈值,这时候就会发生所谓的晋升(Promotion)过程,如下图所示,超过阈值的对象会被晋升到老年代。这个阈值是可以通过参数指定:

-XX:MaxTenuringThreshold=<N>

后面就是老年代 GC,具体取决于选择的 GC 选项,对应不同的算法。下面是一个简单标记 - 整理算法过程示意图,老年代中的无用对象被清除后, GC 会将对象进行整理,以防止内存碎片化。

通常我们把老年代 GC 叫作 Major GC,将对整个堆进行的清理叫作 Full GC,但是这个也没有那么绝对,因为不同的老年代 GC 算法其实表现差异很大,例如 CMS,“concurrent”就体现在清理工作是与工作线程一起并发运行的。

GC 的新发展

GC 仍然处于飞速发展之中,目前的默认选项 G1 GC 在不断的进行改进,很多我们原来认为的缺点,例如串行的 Full GC、Card Table 扫描的低效等,都已经被大幅改进,例如, JDK 10 以后,Full GC 已经是并行运行,在很多场景下,其表现还略优于 Parallel GC 的并行 Full GC 实现。

即使是 Serial GC,虽然比较古老,但是简单的设计和实现未必就是过时的,它本身的开销,不管是 GC 相关数据结构的开销,还是线程的开销,都是非常小的,所以随着云计算的兴起,在 Serverless 等新的应用场景下,Serial GC 找到了新的舞台。

比较不幸的是 CMS GC,因为其算法的理论缺陷等原因,虽然现在还有非常大的用户群体,但是已经被标记为废弃,如果没有组织主动承担 CMS 的维护,很有可能会在未来版本移除。

如果你有关注目前尚处于开发中的 JDK 11,你会发现,JDK 又增加了两种全新的 GC 方式,分别是:

  • Epsilon GC,简单说就是个不做垃圾收集的 GC,似乎有点奇怪,有的情况下,例如在进行性能测试的时候,可能需要明确判断 GC 本身产生了多大的开销,这就是其典型应用场景。
  • ZGC,这是 Oracle 开源出来的一个超级 GC 实现,具备令人惊讶的扩展能力,比如支持 T bytes 级别的堆大小,并且保证绝大部分情况下,延迟都不会超过 10 ms。虽然目前还处于实验阶段,仅支持 Linux 64 位的平台,但其已经表现出的能力和潜力都非常令人期待。

当然,其他厂商也提供了各种独具一格的 GC 实现,例如比较有名的低延迟 GC,ZingShenandoah等,有兴趣请参考我提供的链接。

谈谈你的GC调优思路?

典型回答

谈到调优,这一定是针对特定场景、特定目的的事情, 对于 GC 调优来说,首先就需要清楚调优的目标是什么?从性能的角度看,通常关注三个方面,内存占用(footprint)、延时(latency)和吞吐量(throughput),大多数情况下调优会侧重于其中一个或者两个方面的目标,很少有情况可以兼顾三个不同的角度。当然,除了上面通常的三个方面,也可能需要考虑其他 GC 相关的场景,例如,OOM 也可能与不合理的 GC 相关参数有关;或者,应用启动速度方面的需求,GC 也会是个考虑的方面。

基本的调优思路可以总结为:

  • 理解应用需求和问题,确定调优目标。假设,我们开发了一个应用服务,但发现偶尔会出现性能抖动,出现较长的服务停顿。评估用户可接受的响应时间和业务量,将目标简化为,希望 GC 暂停尽量控制在 200ms 以内,并且保证一定标准的吞吐量。
  • 掌握 JVM 和 GC 的状态,定位具体的问题,确定真的有 GC 调优的必要。具体有很多方法,比如,通过 jstat 等工具查看 GC 等相关状态,可以开启 GC 日志,或者是利用操作系统提供的诊断工具等。例如,通过追踪 GC 日志,就可以查找是不是 GC 在特定时间发生了长时间的暂停,进而导致了应用响应不及时。
  • 这里需要思考,选择的 GC 类型是否符合我们的应用特征,如果是,具体问题表现在哪里,是 Minor GC 过长,还是 Mixed GC 等出现异常停顿情况;如果不是,考虑切换到什么类型,如 CMS 和 G1 都是更侧重于低延迟的 GC 选项。
  • 通过分析确定具体调整的参数或者软硬件配置。
  • 验证是否达到调优目标,如果达到目标,即可以考虑结束调优;否则,重复完成分析、调整、验证这个过程。

考点分析

考察的 C 调优问题是 JVM 调优的一个基础方面,很多 JVM 调优需求,最终都会落实在 GC 调优上或者与其相关,我提供的是一个常见的思路。

真正快速定位和解决具体问题,还是需要对 JVM 和 GC 知识的掌握,以及实际调优经验的总结,有的时候甚至是源自经验积累的直觉判断。面试官可能会继续问项目中遇到的真实问题,如果你能清楚、简要地介绍其上下文,然后将诊断思路和调优实践过程表述出来,会是个很好的加分项。

会从下面不同角度进行补充:

  • 涉及具体的 GC 类型,JVM 的实际表现要更加复杂。目前,G1 已经成为新版 JDK 的默认选择,所以值得你去深入理解。
  • 因为 G1 GC 一直处在快速发展之中,会侧重它的演进变化,尤其是行为和配置相关的变化。并且,同样是因为 JVM 的快速发展,即使是收集 GC 日志等方面也发生了较大改进。
  • 从 GC 调优实践的角度,理解通用问题的调优思路和手段。

知识扩展

首先,先来整体了解一下 G1 GC 的内部结构和主要机制。

从内存区域的角度,G1 同样存在着年代的概念,但是与我前面介绍的内存结构很不一样,其内部是类似棋盘状的一个个 region 组成,请参考下面的示意图。

region 的大小是一致的,数值是在 1M 到 32M 字节之间的一个 2 的幂值数,JVM 会尽量划分 2048 个左右、同等大小的 region,这点可以从源码heapRegionBounds.hpp中看到。当然这个数字既可以手动调整,G1 也会根据堆大小自动进行调整。

在 G1 实现中,年代是个逻辑概念,具体体现在,一部分 region 是作为 Eden,一部分作为 Survivor,除了意料之中的 Old region,G1 会将超过 region 50% 大小的对象(在应用中,通常是 byte 或 char 数组)归类为 Humongous 对象,并放置在相应的 region 中。逻辑上,Humongous region 算是老年代的一部分,因为复制这样的大对象是很昂贵的操作,并不适合新生代 GC 的复制算法。

你可以思考下 region 设计有什么副作用?

例如,region 大小和大对象很难保证一致,这会导致空间的浪费。不知道你有没有注意到,、示意图中有的区域是 Humongous 颜色,但没有用名称标记,这是为了表示,特别大的对象是可能占用超过一个 region 的。并且,region 太小不合适,会令你在分配大对象时更难找到连续空间,这是一个长久存在的情况,请参考OpenJDK 社区的讨论。这本质也可以看作是 JVM 的 bug,尽管解决办法也非常简单,直接设置较大的 region 大小,参数如下:

-XX:G1HeapRegionSize=<N, 例如 16>M

从 GC 算法的角度,G1 选择的是复合算法,可以简化理解为:

  • 在新生代,G1 采用的仍然是并行的复制算法,所以同样会发生 Stop-The-World 的暂停。
  • 在老年代,大部分情况下都是并发标记,而整理(Compact)则是和新生代 GC 时捎带进行,并且不是整体性的整理,而是增量进行的。

习惯上人们喜欢把新生代 GC(Young GC)叫作 Minor GC,老年代 GC 叫作 Major GC,区别于整体性的 Full GC。但是现代 GC 中,这种概念已经不再准确,对于 G1 来说:

  • Minor GC 仍然存在,虽然具体过程会有区别,会涉及 Remembered Set 等相关处理。
  • 老年代回收,则是依靠 Mixed GC。并发标记结束后,JVM 就有足够的信息进行垃圾收集,Mixed GC 不仅同时会清理 Eden、Survivor 区域,而且还会清理部分 Old 区域。可以通过设置下面的参数,指定触发阈值,并且设定最多被包含在一次 Mixed GC 中的 region 比例。
–XX:G1MixedGCLiveThresholdPercent
–XX:G1OldCSetRegionThresholdPercent

从 G1 内部运行的角度,下面的示意图描述了 G1 正常运行时的状态流转变化,当然,在发生逃逸失败等情况下,就会触发 Full GC。

G1 相关概念非常多,有一个重点就是 Remembered Set,用于记录和维护 region 之间对象的引用关系。为什么需要这么做呢?试想,新生代 GC 是复制算法,也就是说,类似对象从 Eden 或者 Survivor 到 to 区域的“移动”,其实是“复制”,本质上是一个新的对象。在这个过程中,需要必须保证老年代到新生代的跨区引用仍然有效。下面的示意图说明了相关设计。

G1 的很多开销都是源自 Remembered Set,例如,它通常约占用 Heap 大小的 20% 或更高,这可是非常可观的比例。并且,我们进行对象复制的时候,因为需要扫描和更改 Card Table 的信息,这个速度影响了复制的速度,进而影响暂停时间。

描述 G1 内部的资料很多,就不重复了,如果你想了解更多内部结构和算法等,我建议参考一些具体的介绍,书籍方面我推荐 Charlie Hunt 等撰写的《Java Performance Companion》。

接下来,介绍下大家可能还不了解的 G1 行为变化,它们在一定程度上解决了其他讲中提到的部分困扰,如类型卸载不及时的问题。

  • 上面提到了 Humongous 对象的分配和回收,这是很多内存问题的来源,Humongous region 作为老年代的一部分,通常认为它会在并发标记结束后才进行回收,但是在新版 G1 中,Humongous 对象回收采取了更加激进的策略。
    我们知道 G1 记录了老年代 region 间对象引用,Humongous 对象数量有限,所以能够快速的知道是否有老年代对象引用它。如果没有,能够阻止它被回收的唯一可能,就是新生代是否有对象引用了它,但这个信息是可以在 Young GC 时就知道的,所以完全可以在 Young GC 中就进行 Humongous 对象的回收,不用像其他老年代对象那样,等待并发标记结束。
  • 8u20 以后字符串排重的特性,在垃圾收集过程中,G1 会把新创建的字符串对象放入队列中,然后在 Young GC 之后,并发地(不会 STW)将内部数据(char 数组,JDK 9 以后是 byte 数组)一致的字符串进行排重,也就是将其引用同一个数组。你可以使用下面参数激活:
-XX:+UseStringDeduplication

注意,这种排重虽然可以节省不少内存空间,但这种并发操作会占用一些 CPU 资源,也会导致 Young GC 稍微变慢。

  • 类型卸载是个长期困扰一些 Java 应用的问题,一个类只有当加载它的自定义类加载器被回收后,才能被卸载。元数据区替换了永久代之后有所改善,但还是可能出现问题。

G1 的类型卸载有什么改进吗?很多资料中都谈到,G1 只有在发生 Full GC 时才进行类型卸载,但这显然不是我们想要的。你可以加上下面的参数查看类型卸载:

-XX:+TraceClassUnloading

幸好现代的 G1 已经不是如此了,8u40 以后,G1 增加并默认开启下面的选项:

-XX:+ClassUnloadingWithConcurrentMark

也就是说,在并发标记阶段结束后,JVM 即进行类型卸载。

  • 我们知道老年代对象回收,基本要等待并发标记结束。这意味着,如果并发标记结束不及时,导致堆已满,但老年代空间还没完成回收,就会触发 Full GC,所以触发并发标记的时机很重要。早期的 G1 调优中,通常会设置下面参数,但是很难给出一个普适的数值,往往要根据实际运行结果调整
-XX:InitiatingHeapOccupancyPercent

在 JDK 9 之后的 G1 实现中,这种调整需求会少很多,因为 JVM 只会将该参数作为初始值,会在运行时进行采样,获取统计数据,然后据此动态调整并发标记启动时机。对应的 JVM 参数如下,默认已经开启:

-XX:+G1UseAdaptiveIHOP
  • 在现有的资料中,大多指出 G1 的 Full GC 是最差劲的单线程串行 GC。其实,如果采用的是最新的 JDK,你会发现 Full GC 也是并行进行的了,在通用场景中的表现还优于 Parallel GC 的 Full GC 实现。

当然,还有很多其他的改变,比如更快的 Card Table 扫描等,这里不再展开介绍,因为它们并不带来行为的变化,基本不影响调优选择。

前面介绍了 G1 的内部机制,并且穿插了部分调优建议,下面从整体上给出一些调优的建议。

首先,建议尽量升级到较新的 JDK 版本,从上面介绍的改进就可以看到,很多人们常常讨论的问题,其实升级 JDK 就可以解决了。

第二,掌握 GC 调优信息收集途径。掌握尽量全面、详细、准确的信息,是各种调优的基础,不仅仅是 GC 调优。我们来看看打开 GC 日志,这似乎是很简单的事情,可是你确定真的掌握了吗?

除了常用的两个选项,

-XX:+PrintGCDetails
-XX:+PrintGCDateStamps

还有一些非常有用的日志选项,很多特定问题的诊断都是要依赖这些选项:

-XX:+PrintAdaptiveSizePolicy // 打印 G1 Ergonomics 相关信息

我们知道 GC 内部一些行为是适应性的触发的,利用 PrintAdaptiveSizePolicy,我们就可以知道为什么 JVM 做出了一些可能我们不希望发生的动作。例如,G1 调优的一个基本建议就是避免进行大量的 Humongous 对象分配,如果 Ergonomics 信息说明发生了这一点,那么就可以考虑要么增大堆的大小,要么直接将 region 大小提高。

如果是怀疑出现引用清理不及时的情况,则可以打开下面选项,掌握到底是哪里出现了堆积。

-XX:+PrintReferenceGC

另外,建议开启选项下面的选项进行并行引用处理。

-XX:+ParallelRefProcEnabled

需要注意的一点是,JDK 9 中 JVM 和 GC 日志机构进行了重构,其实我前面提到的PrintGCDetails 已经被标记为废弃,而PrintGCDateStamps 已经被移除,指定它会导致 JVM 无法启动。可以使用下面的命令查询新的配置参数。

java -Xlog:help

最后,来看一些通用实践,理解了前面介绍的内部结构和机制,很多结论就一目了然了,例如:

  • 如果发现 Young GC 非常耗时,这很可能就是因为新生代太大了,我们可以考虑减小新生代的最小比例。
-XX:G1NewSizePercent

降低其最大值同样对降低 Young GC 延迟有帮助。

-XX:G1MaxNewSizePercent

如果我们直接为 G1 设置较小的延迟目标值,也会起到减小新生代的效果,虽然会影响吞吐量。

  • 如果是 Mixed GC 延迟较长,我们应该怎么做呢?

还记得前面说的,部分 Old region 会被包含进 Mixed GC,减少一次处理的 region 个数,就是个直接的选择之一。
在上面已经介绍了 G1OldCSetRegionThresholdPercent 控制其最大值,还可以利用下面参数提高 Mixed GC 的个数,当前默认值是 8,Mixed GC 数量增多,意味着每次被包含的 region 减少。

-XX:G1MixedGCCountTarget

今天的内容算是抛砖引玉,更多内容你可以参考G1 调优指南等,远不是几句话可以囊括的。需要注意的是,也要避免过度调优,G1 对大堆非常友好,其运行机制也需要浪费一定的空间,有时候稍微多给堆一些空间,比进行苛刻的调优更加实用。

Java内存模型中的happen-before是什么?

典型回答

Happen-before 关系,是 Java 内存模型中保证多线程操作可见性的机制,也是对早期语言规范中含糊的可见性概念的一个精确定义。

它的具体表现形式,包括但远不止是我们直觉中的 synchronized、volatile、lock 操作顺序等方面,例如:

  • 线程内执行的每个操作,都保证 happen-before 后面的操作,这就保证了基本的程序顺序规则,这是开发者在书写程序时的基本约定。
  • 对于 volatile 变量,对它的写操作,保证 happen-before 在随后对该变量的读取操作。
  • 对于一个锁的解锁操作,保证 happen-before 加锁操作。
  • 对象构建完成,保证 happen-before 于 finalizer 的开始动作。
  • 甚至是类似线程内部操作的完成,保证 happen-before 其他 Thread.join() 的线程等。

这些 happen-before 关系是存在着传递性的,如果满足 a happen-before b 和 b happen-before c,那么 a happen-before c 也成立。

前面一直用 happen-before,而不是简单说前后,是因为它不仅仅是对执行时间的保证,也包括对内存读、写操作顺序的保证。仅仅是时钟顺序上的先后,并不能保证线程交互的可见性。

考点分析

这个问题是一个常见的考察 Java 内存模型基本概念的问题,给出的回答尽量选择了和日常开发相关的规则。

JMM 是面试的热点,可以看作是深入理解 Java 并发编程、编译器和 JVM 内部机制的必要条件,但这同时也是个容易让初学者无所适从的主题。对于学习 JMM,有一些建议:

  • 明确目的,克制住技术的诱惑。除非你是编译器或者 JVM 工程师,否则我建议不要一头扎进各种 CPU 体系结构,纠结于不同的缓存、流水线、执行单元等。这些东西虽然很酷,但其复杂性是超乎想象的,很可能会无谓增加学习难度,也未必有实践价值。
  • 克制住对“秘籍”的诱惑。有些时候,某些编程方式看起来能起到特定效果,但分不清是实现差异导致的“表现”,还是“规范”要求的行为,就不要依赖于这种“表现”去编程,尽量遵循语言规范进行,这样我们的应用行为才能更加可靠、可预计。

兼顾面试和编程实践,会结合例子梳理下面两点:

  • 为什么需要 JMM,它试图解决什么问题?
  • JMM 是如何解决可见性等各种问题的?类似 volatile,体现在具体用例中有什么效果?

注意,Java 内存模型就是特指 JSR-133 中重新定义的 JMM 规范。在特定的上下文里,也许会与 JVM(Java)内存结构等混淆,并不存在绝对的对错,但一定要清楚面试官的本意,有的面试官也会特意考察是否清楚这两种概念的区别。

知识扩展

为什么需要 JMM,它试图解决什么问题?

Java 是最早尝试提供内存模型的语言,这是简化多线程编程、保证程序可移植性的一个飞跃。早期类似 C、C++ 等语言,并不存在内存模型的概念(C++ 11 中也引入了标准内存模型),其行为依赖于处理器本身的内存一致性模型,但不同的处理器可能差异很大,所以一段 C++ 程序在处理器 A 上运行正常,并不能保证其在处理器 B 上也是一致的。

即使如此,最初的 Java 语言规范仍然是存在着缺陷的,当时的目标是,希望 Java 程序可以充分利用现代硬件的计算能力,同时保持“书写一次,到处执行”的能力。

但是,显然问题的复杂度被低估了,随着 Java 被运行在越来越多的平台上,人们发现,过于泛泛的内存模型定义,存在很多模棱两可之处,对 synchronized 或 volatile 等,类似指令重排序时的行为,并没有提供清晰规范。这里说的指令重排序,既可以是编译器优化行为,也可能是源自于现代处理器的乱序执行等。

换句话说:

  • 既不能保证一些多线程程序的正确性,例如最著名的就是双检锁(Double-Checked Locking,DCL)的失效问题,双检锁可能导致未完整初始化的对象被访问,理论上这叫并发编程中的安全发布(Safe Publication)失败。
  • 也不能保证同一段程序在不同的处理器架构上表现一致,例如有的处理器支持缓存一致性,有的不支持,各自都有自己的内存排序模型。

所以,Java 迫切需要一个完善的 JMM,能够让普通 Java 开发者和编译器、JVM 工程师,能够清晰地达成共识。换句话说,可以相对简单并准确地判断出,多线程程序什么样的执行序列是符合规范的。

所以:

  • 对于编译器、JVM 开发者,关注点可能是如何使用类似内存屏障(Memory-Barrier)之类技术,保证执行结果符合 JMM 的推断。
  • 对于 Java 应用开发者,则可能更加关注 volatile、synchronized 等语义,如何利用类似 happen-before 的规则,写出可靠的多线程应用,而不是利用一些“秘籍”去糊弄编译器、JVM。

画了一个简单的角色层次图,不同工程师分工合作,其实所处的层面是有区别的。JMM 为 Java 工程师隔离了不同处理器内存排序的区别,这也是为什么我通常不建议过早深入处理器体系结构,某种意义上来说,这样本就违背了 JMM 的初衷。

JMM 是怎么解决可见性等问题的呢?

在这里,有必要简要介绍一下典型的问题场景。

真正程序执行,实际是要跑在具体的处理器内核上。你可以简单理解为,把本地变量等数据从内存加载到缓存、寄存器,然后运算结束写回主内存。你可以从下面示意图,看这两种模型的对应。

看上去很美好,但是当多线程共享变量时,情况就复杂了。试想,如果处理器对某个共享变量进行了修改,可能只是体现在该内核的缓存里,这是个本地状态,而运行在其他内核上的线程,可能还是加载的旧状态,这很可能导致一致性的问题。从理论上来说,多线程共享引入了复杂的数据依赖性,不管编译器、处理器怎么做重排序,都必须尊重数据依赖性的要求,否则就打破了正确性!这就是 JMM 所要解决的问题。

JMM 内部的实现通常是依赖于所谓的内存屏障,通过禁止某些重排序的方式,提供内存可见性保证,也就是实现了各种 happen-before 规则。与此同时,更多复杂度在于,需要尽量确保各种编译器、各种体系结构的处理器,都能够提供一致的行为。

以 volatile 为例,看看如何利用内存屏障实现 JMM 定义的可见性?

对于一个 volatile 变量:

  • 对该变量的写操作之后,编译器会插入一个写屏障
  • 对该变量的读操作之前,编译器会插入一个读屏障

内存屏障能够在类似变量读、写操作之后,保证其他线程对 volatile 变量的修改对当前线程可见,或者本地修改对其他线程提供可见性。换句话说,线程写入,写屏障会通过类似强迫刷出处理器缓存的方式,让其他线程能够拿到最新数值。

如果你对更多内存屏障的细节感兴趣,或者想了解不同体系结构的处理器模型,建议参考 JSR-133相关文档,个人认为这些都是和特定硬件相关的,内存屏障之类只是实现 JMM 规范的技术手段,并不是规范的要求。

从应用开发者的角度,JMM 提供的可见性,体现在类似 volatile 上,具体行为是什么样呢?

这里循序渐进的举两个例子。

首先,请看下面的代码片段,希望达到的效果是,当 condition 被赋值为 false 时,线程 A 能够从循环中退出。

// Thread A
while (condition) {
}
 
// Thread B
condition = false;

这里就需要 condition 被定义为 volatile 变量,不然其数值变化,往往并不能被线程 A 感知,进而无法退出。当然,也可以在 while 中,添加能够直接或间接起到类似效果的代码。

第二,想举 Brian Goetz 提供的一个经典用例,使用 volatile 作为守卫对象,实现某种程度上轻量级的同步,请看代码片段:

Map configOptions;
char[] configText;
volatile boolean initialized = false;
 
// Thread A
configOptions = new HashMap();
configText = readConfigFile(fileName);
processConfigOptions(configText, configOptions);
initialized = true;
 
// Thread B
while (!initialized)
  sleep();
// use configOptions

JSR-133 重新定义的 JMM 模型,能够保证线程 B 获取的 configOptions 是更新后的数值。

也就是说 volatile 变量的可见性发生了增强,能够起到守护其上下文的作用。线程 A 对 volatile 变量的赋值,会强制将该变量自己和当时其他变量的状态都刷出缓存,为线程 B 提供可见性。当然,这也是以一定的性能开销作为代价的,但毕竟带来了更加简单的多线程行为。

我们经常会说 volatile 比 synchronized 之类更加轻量,但轻量也仅仅是相对的,volatile 的读、写仍然要比普通的读写要开销更大,所以如果你是在性能高度敏感的场景,除非你确定需要它的语义,不然慎用。

Java程序运行在Docker等容器环境有哪些新问题?

典型回答

对于 Java 来说,Docker 毕竟是一个较新的环境,例如,其内存、CPU 等资源限制是通过 CGroup(Control Group)实现的,早期的 JDK 版本(8u131 之前)并不能识别这些限制,进而会导致一些基础问题:

  • 如果未配置合适的 JVM 堆和元数据区、直接内存等参数,Java 就有可能试图使用超过容器限制的内存,最终被容器 OOM kill,或者自身发生 OOM。
  • 错误判断了可获取的 CPU 资源,例如,Docker 限制了 CPU 的核数,JVM 就可能设置不合适的 GC 并行线程数等。

从应用打包、发布等角度出发,JDK 自身就比较大,生成的镜像就更为臃肿,当我们的镜像非常多的时候,镜像的存储等开销就比较明显了。

如果考虑到微服务、Serverless 等新的架构和场景,Java 自身的大小、内存占用、启动速度,都存在一定局限性,因为 Java 早期的优化大多是针对长时间运行的大型服务器端应用。

考点分析

这个问题是个针对特定场景和知识点的问题,给出的回答简单总结了目前业界实践中发现的一些问题。

如果我是面试官,针对这种问题,如果你确实没有太多 Java 在 Docker 环境的使用经验,直接说不知道,也算是可以接受的,毕竟没有人能够掌握所有知识点嘛。

但我们要清楚,有经验的面试官,一般不会以纯粹偏僻的知识点作为面试考察的目的,更多是考察思考问题的思路和解决问题的方法。所以,如果有基础的话,可以从操作系统、容器原理、JVM 内部机制、软件开发实践等角度,展示系统性分析新问题、新场景的能力。毕竟,变化才是世界永远的主题,能够在新变化中找出共性与关键,是优秀工程师的必备能力。

今天会围绕下面几个方面展开:

  • 面试官可能会进一步问到,有没有想过为什么类似 Docker 这种容器环境,会有点“欺负”Java?从 JVM 内部机制来说,问题出现在哪里?
  • 注意到有种论调说“没人在容器环境用 Java”,不去争论这个观点正确与否,我会从工程实践出发,梳理问题原因和相关解决方案,并探讨下新场景下的最佳实践。

知识扩展

首先,我们先来搞清楚 Java 在容器环境的局限性来源,Docker 到底有什么特别

虽然看起来 Docker 之类容器和虚拟机非常相似,例如,它也有自己的 shell,能独立安装软件包,运行时与其他容器互不干扰。但是,如果深入分析你会发现,Docker 并不是一种完全的虚拟化技术,而更是一种轻量级的隔离技术。

上面的示意图,展示了 Docker 与虚拟机的区别。从技术角度,基于 namespace,Docker 为每个容器提供了单独的命名空间,对网络、PID、用户、IPC 通信、文件系统挂载点等实现了隔离。对于 CPU、内存、磁盘 IO 等计算资源,则是通过 CGroup 进行管理。如果你想了解更多 Docker 的细节,请参考相关技术文档

Docker 仅在类似 Linux 内核之上实现了有限的隔离和虚拟化,并不是像传统虚拟化软件那样,独立运行一个新的操作系统。如果是虚拟化的操作系统,不管是 Java 还是其他程序,只要调用的是同一个系统 API,都可以透明地获取所需的信息,基本不需要额外的兼容性改变。

容器虽然省略了虚拟操作系统的开销,实现了轻量级的目标,但也带来了额外复杂性,它限制对于应用不是透明的,需要用户理解 Docker 的新行为。所以,有专家曾经说过,“幸运的是 Docker 没有完全隐藏底层信息,但是不幸的也是 Docker 没有隐藏底层信息!”

对于 Java 平台来说,这些未隐藏的底层信息带来了很多意外的困难,主要体现在几个方面:

第一,容器环境对于计算资源的管理方式是全新的,CGroup 作为相对比较新的技术,历史版本的 Java 显然并不能自然地理解相应的资源限制。

第二,namespace 对于容器内的应用细节增加了一些微妙的差异,比如 jcmd、jstack 等工具会依赖于“/proc//”下面提供的部分信息,但是 Docker 的设计改变了这部分信息的原有结构,我们需要对原有工具进行修改以适应这种变化。

从 JVM 运行机制的角度,为什么这些“沟通障碍”会导致 OOM 等问题呢?

你可以思考一下,这个问题实际是反映了 JVM 如何根据系统资源(内存、CPU 等)情况,在启动时设置默认参数。

这就是所谓的Ergonomics机制,例如:

  • JVM 会大概根据检测到的内存大小,设置最初启动时的堆大小为系统内存的 1/64;并将堆最大值,设置为系统内存的 1/4。
  • 而 JVM 检测到系统的 CPU 核数,则直接影响到了 Parallel GC 的并行线程数目和 JIT complier 线程数目,甚至是我们应用中 ForkJoinPool 等机制的并行等级。

这些默认参数,是根据通用场景选择的初始值。但是由于容器环境的差异,Java 的判断很可能是基于错误信息而做出的。这就类似,我以为我住的是整栋别墅,实际上却只有一个房间是给我住的。

更加严重的是,JVM 的一些原有诊断或备用机制也会受到影响。为保证服务的可用性,一种常见的选择是依赖“-XX:OnOutOfMemoryError”功能,通过调用处理脚本的形式来做一些补救措施,比如自动重启服务等。但是,这种机制是基于 fork 实现的,当 Java 进程已经过度提交内存时,fork 新的进程往往已经不可能正常运行了。

根据前面的总结,似乎问题非常棘手,那我们在实践中,如何解决这些问题呢?

首先,如果你能够升级到最新的 JDK 版本,这个问题就迎刃而解了。

  • 针对这种情况,JDK 9 中引入了一些实验性的参数,以方便 Docker 和 Java“沟通”,例如针对内存限制,可以使用下面的参数设置:
-XX:+UnlockExperimentalVMOptions
-XX:+UseCGroupMemoryLimitForHeap

注意,这两个参数是顺序敏感的,并且只支持 Linux 环境。而对于 CPU 核心数限定,Java 已经被修正为可以正确理解“–cpuset-cpus”等设置,无需单独设置参数。

  • 如果你可以切换到 JDK 10 或者更新的版本,问题就更加简单了。Java 对容器(Docker)的支持已经比较完善,默认就会自适应各种资源限制和实现差异。前面提到的实验性参数“UseCGroupMemoryLimitForHeap”已经被标记为废弃。

与此同时,新增了参数用以明确指定 CPU 核心的数目。

-XX:ActiveProcessorCount=N

如果实践中发现有问题,也可以使用“-XX:-UseContainerSupport”,关闭 Java 的容器支持特性,这可以作为一种防御性机制,避免新特性破坏原有基础功能。当然,也欢迎你向 OpenJDK 社区反馈问题。

  • 幸运的是,JDK 9 中的实验性改进已经被移植到 Oracle JDK 8u131 之中,你可以直接下载相应镜像,并配置“UseCGroupMemoryLimitForHeap”,后续很有可能还会进一步将 JDK 10 中相关的增强,应用到 JDK 8 最新的更新中。

但是,如果我暂时只能使用老版本的 JDK 怎么办?

我这里有几个建议:

  • 明确设置堆、元数据区等内存区域大小,保证 Java 进程的总大小可控。

例如,我们可能在环境中,这样限制容器内存:

$ docker run -it --rm --name yourcontainer -p 8080:8080 -m 800M repo/your-java-container:openjdk

那么,就可以额外配置下面的环境变量,直接指定 JVM 堆大小。

-e JAVA_OPTIONS='-Xmx300m'
  • 明确配置 GC 和 JIT 并行线程数目,以避免二者占用过多计算资源。
-XX:ParallelGCThreads
-XX:CICompilerCount

除了前面介绍的 OOM 等问题,在很多场景中还发现 Java 在 Docker 环境中,似乎会意外使用 Swap。具体原因待查,但很有可能也是因为 Ergonomics 机制失效导致的,建议配置下面参数,明确告知 JVM 系统内存限额。

-XX:MaxRAM=`cat /sys/fs/cgroup/memory/memory.limit_in_bytes`

也可以指定 Docker 运行参数,例如:

--memory-swappiness=0

这是受操作系统Swappiness机制影响,当内存消耗达到一定门限,操作系统会试图将不活跃的进程换出(Swap out),上面的参数有显式关闭 Swap 的作用。所以可以看到,Java 在 Docker 中的使用,从操作系统、内核到 JVM 自身机制,需要综合运用我们所掌握的知识。

JVM 内存消耗远不止包括堆,很多时候仅仅设置 Xmx 是不够的,MaxRAM 也有助于 JVM 合理分配其他内存区域。如果应用需要设置更多 Java 启动参数,但又不确定什么数值合理,可以试试一些社区提供的工具,但要注意通用工具的局限性。

更进一步来说,对于容器镜像大小的问题,如果你使用的是 JDK 9 以后的版本,完全可以使用 jlink 工具定制最小依赖的 Java 运行环境,将 JDK 裁剪为几十 M 的大小,这样运行起来并不困难。

posted @ 2021-01-19 16:03  小萝卜鸭  阅读(201)  评论(0编辑  收藏  举报