JVM性能优化

来源:互联网 发布:适合初学者的php集成 编辑:程序博客网 时间:2024/04/28 07:44

JVM性能优化,Part 1 ―― JVM简介

众所周知,Java应用程序是运行在JVM上的,但是你对JVM有所了解么?作为这个系列文章的第一篇,本文将对经典Java虚拟机的运行机制做简单介绍,内容包括“一次编写,到处运行”的利弊、垃圾回收的基本原理、常用垃圾回收算法的示例和编译器优化等。后续的系列文章将会JVM性能优化的内容进行介绍,包括新一代JVM的设计思路,以及如何支持当今Java应用程序对高性能和高扩展性的要求。

这个系列文章主要面向那些想要了解JVM底层运行原理的Java程序员。文章立足于较高的层面展开讨论,内容涉及到垃圾回收和在不影响应用程序运行的情况下安全快速的释放/分配内存。你将对JVM的核心模块有所了解:垃圾回收、GC算法、编译器行为,以及一些常用优化技巧。此外,还会讨论为什么对Java做基准测试(benchmark)是件很困难的事,并提供一些建议来帮助做基准测试。最后,将会介绍一些JVM和GC的前沿技术,内容涉及到Azul的ZingJVM,IBMJVM和Oracle的GarbageFirst(G1)垃圾回收器。

希望在阅读此系列文章后,你能对影响Java伸缩性的因素有所了解,并且知道这些因素是如何影响Java开发的,如何使Java难以优化的。希望会你有那种发自内心的惊叹,并且能够激励你为Java做一点事情:拒绝限制,努力改变。

Java的性能与“一次编写,到处运行”的挑战

有不少人认为,Java平台本身就挺慢。其主要观点简单来说就是,Java性能低已经有些年头了―― 最早可以追溯到Java第一次用于企业级应用程序开发的时候。但这早就是老黄历了。事实是,如果你对不同的开发平台上运行简单的、静态的、确定性任务的运行结果做比较,你就会发现使用经过机器级优化(machine-optimized)代码的平台比任何使用虚拟环境进行运算的都要强,JVM也不例外。但是,在过去的10年中,Java的性能有了大幅提升。市场上不断增长的需求催生了垃圾回收算法的出现和编译技术的革新,在不断探索与优化的过程中,JVM茁壮成长。在这个系列文章中,我将介绍其中的一些内容。

JVM技术中最迷人的地方也正是其最具挑战性的地方:“一次编写,到处运行”。JVM并不对具体的用例、应用程序或用户负载进行优化,而是在应用程序运行过程中不断收集运行时信息,并以此为根据动态的进行优化。这种动态的运行时特性带来了很多动态问题。在设计优化方案时,以JVM为工作平台的程序无法依靠静态编译和可预测的内存分配速率(predictableallocation rates)对应用程序做性能评估,至少在对生产环境进行性能评估时是不行的。

机器级优化过的代码有时可以达到更好的性能,但它是以牺牲可移植性为代价的,在企业级应用程序中,动态负载和快速迭代更新是更加重要的。大多数企业会愿意牺牲一点机器级优化代码带来的性能,以此换取Java平台的诸多优势:

1、编码简单,易于实现(意味着可以更快的推向市场)

2、有很多非常有才的程序员

3、使用JavaAPI和标准库实现快速开发

4、可移植性 ―― 无需为每个平台都编写一套代码

从源代码到字节码

作为一名Java程序员,你可以已经对编码、编译和运行这一套流程比较熟悉了。假如说,现在你写了一个程序代码MyApp.java,准备编译运行。为了运行这个程序,首先,你需要使用JDK内建的Java语言编译器:javac,对这个文件进行编译,它可以将Java源代码编译为字节码。javac将根据Java程序的源代码生成对应的可执行字节码,并将其保存为同名类文件:MyApp.class。在经过编译阶段后,你就可以在命令行中使用java命令或其他启动脚本载入可执行的类文件来运行程序,并且可以为程序添加启动参数。之后,类会被载入到运行时(这里指的是正在运行的JVM),程序开始运行。

上面所描述的就是在运行Java应用程序时的表面过程,但现在,我们要深入挖掘一下,在调用Java命令时,到底发生了什么?JVM到底是什么?大多数程序员是通过不断的调优,即使用相应的启动参数,与JVM进行交互,使Java程序运行的更快,同时避免程序出现“OutofMemoryError”错误。但你是否想过,为什么我们必须要通过JVM来运行Java应用程序呢?

什么是JVM

简单来说,JVM是用于执行Java应用程序和字节码的软件模块,并且可以将字节码转换为特定硬件和特定操作系统的本地代码。正因如此,JVM使Java程序做到了“一次编写,到处运行”。Java语言的可移植性是得到企业级应用程序开发者青睐的关键:开发者无需因平台不同而把程序重新编写一遍,因为有JVM负责处理字节码到本地代码的转换和平台相关优化的工作。

基本上来说,JVM是一个虚拟运行环境,对于字节码来说就像是一个机器一样,可以执行任务,并通过底层实现执行内存相关的操作。 

JVM也可以在运行java应用程序时,很好的管理动态资源。这指的是他可以正确的分配、回收内存,在不同的机器上维护一个具有一致性的线程模型,并且可以为当前的CPU架构组织可执行指令。JVM解放了程序员,使程序员不必再关心对象的生命周期,使程序员不必再关心应该在何时释放内存。而这,正是使用着类似C语言的非动态语言的程序员心中永远的痛。

你可以将JVM当做是一种专为Java而生的特殊的操作系统,它的工作是管理运行Java应用程序的运行时环境。简单来说,JVM就是运行字节码指令的虚拟执行环境,并且可以分配执行任务,或通过底层实现对内存进行操作

JVM组件简介

关于JVM内部原理与性能优化有很多内容可写。作为这个系列的开篇文章,我简单介绍JVM的内部组件。这个简要介绍对于那些JVM新手比较有帮助,也是为后面的深入讨论做个铺垫。

从一种语言到另一种 ―― 关于Java编译器

编译器以一种语言为输入,生成另一种可执行语言作为输出。Java编译器主要完成2个任务:

1、实现Java语言的可移植性,不必局限于某一特定平台;

2、确保输出代码可以在目标平台能够有效率的运行。

编译器可以是静态的,也可以是动态的。静态编译器,如javac,它以Java源代码为输入,将其编译为字节码(一种可以运行JVM中的语言)。*静态编译器*解释输入的源代码,而生成可执行输出代码则会在程序真正运行时用到。因为输入是静态的,所有输出结果总是相同的。只有当你修改的源代码并重新编译时,才有可能看到不同的编译结果。

动态编译器,如使用Just-In-Time(JIT,即时编译)技术的编译器,会动态的将一种编程语言编译为另一种语言,这个过程是在程序运行中同时进行的。JIT编译器会收集程序的运行时数据(在程序中插入性能计数器),再根据运行时数据和当前运行环境数据动态规划编译方案。动态编译可以生成更好的序列指令,使用更有效率的指令集合替换原指令集合,或剔除冗余操作。收集到的运行时数据越多,动态编译的效果就越好;这通常称为代码优化或重编译。

动态编译使你的程序可以应对在不同负载和行为下对新优化的需求。这也是为什么动态编译器非常适合Java运行时。这里需要注意的地方是,动态编译器需要动用额外的数据结构、线程资源和CPU指令周期,才能收集运行时信息和优化的工作。若想完成更高级点的优化工作,就需要更多的资源。但是在大多数运行环境中,相对于获得的性能提升来说,动态编译的带来的性能损耗其实是非常小的―― 动态编译后的代码的运行效率可以比纯解释执行(即按照字节码运行,不做任何修改)快5到10倍。

内存分配与垃圾回收

内存分配是以线程为单位,在“Java进程专有内存地址空间”中,也就是Java堆中分配的。在普通的客户端Java应用程序中,内存分配都是单线程进行的。但是,在企业级应用程序和服务器端应用程序中,单线程内存分配却并不是个好办法,因为它无法充分利用现代多核时代的并行特性。

并行应用程序设计要求JVM确保多线程内存分配不会在同一时间将同一块地址空间分配给多个线程。你可以在整个内存空间中加锁来解决这个问题,但是这个方法(即所谓的“堆锁”)开销较大,因为它迫使所有线程在分配内存时逐个执行,对资源利用和应用程序性能有较大影响。多核程序的一个额外特点是需要有新的资源分配方案,避免出现单线程、序列化资源分配的性能瓶颈。

常用的解决方案是将堆划分为几个区域,每个区域都有适当的大小,当然具体的大小需要根据实际情况做相应的调整,因为不同应用程序之间,内存分配速率、对象大小和线程数量的差别是非常大的。ThreadLocal Allocation Buffer(TLAB),有时也称为ThraedLocal Area(TLA),是线程自己使用的专用内存分配区域,在使用的时候无需获取堆锁。当这个区域用满的时候,线程会申请新的区域,直到堆中所有预留的区域都用光了。当堆中没有足够的空间来分配内存时,堆就“满”了,即堆上剩余的空间装不下待分配空间的对象。当堆满了的时候,垃圾回收就开始了。

碎片化

使用TLAB的一个风险是,由于堆上内存碎片的增加,使用内存的效率会下降。如果应用程序创建的对象的大小无法填满TLAB,而这块TLAB中剩下的空间又太小,无法分配给新的对象,那么这块空间就被浪费了,这就是所谓的“碎片”。如果“碎片”周围已分配出去的内存长时间无法回收,那么这块碎片就长时间无法得到利用。

碎片化是指堆上存在了大量的碎片,由于这些小碎片的存在而使堆无法得到有效利用,浪费了堆空间。为应用程序设置TLAB的大小时,若是没有对应用程序中对象大小和生命周期和合理评估,导致TLAB的大小设置不当,就会是使堆逐渐碎片化。随着应用程序的运行,被浪费的碎片空间会逐渐增多,导致应用程序性能下降。这是因为系统无法为新线程和新对象分配空间,为防止出现OOM(out-of-memory)错误,而频繁GC的缘故。

对于TLAB产生的空间浪费这个问题,可以采用“曲线救国”的策略来解决。例如,可以根据应用程序的具体环境调整TLAB的大小。这个方法既可以临时,也可以彻底的避免堆空间的碎片化,但需要随着应用程序内存分配行为的变化而修改TLAB的值。此外,还可以使用一些复杂的JVM算法和其他的方法来组织堆空间来获得更有效率的内存分配行为。例如,JVM可以实现空闲列表(free-list),空闲列表中保存了堆中指定大小的空闲块。具有类似大小空闲块保存在一个空闲列表中,因此可以创建多个空闲列表,每个空闲列表保存某个范围内的空闲块。在某些事例中,使用空闲列表会比使用按实际大小分配内存的策略更有效率。线程为某个对象分配内存时,可以在空闲列表中寻找与对象大小最接近的空间块使用,相对于使用固定大小的TLAB,这种方法更有利于避免碎片化的出现。

GC往事:早期的垃圾回收器有多个老年代,但实际上,存在多个老年代是弊大于利的。

另一种对抗碎片化的方法是创建一个所谓的年轻代,在这个专有的堆空间中,保存了所有新创建的对象。堆空间中剩余的空间就是所谓的老年代。老年代用于保存具有较长生命周期的对象,即当对象能够挺过几轮GC而不被回收,或者对象本身很大(一般来说,大对象都具有较长的寿命周期)时,它们就会被保存到老年代。为了让你能够更好的理解这个方法,我们有必要谈谈垃圾回收。

垃圾回收与应用程序性能

垃圾回收就是JVM释放那些没有引用指向的堆内存的操作。当垃圾回收首次触发时,有引用指向的对象会被保存下来,那些没有引用指向的对象占用的空间会被回收。当所有可回收的内存都被回收后,这些空间就可以被分配给新的对象了。

垃圾回收不会回收仍有引用指向的对象;否则就会违反JVM规范。这个规则有一个例外,就是对软引用或弱引用的使用,当垃圾回收器发现内存快要用完时,会回收只有软引用或弱引用指向的对象所占用的内存。我的建议是,尽量避免使用弱引用,因为Java规范中存在的模糊的表述可能会使你对弱引用的使用产生误解。此外,Java本身是动态内存管理的,你没必要考虑什么时候该释放哪块内存。

对于垃圾回收来说,挑战在于,如何将垃圾回收对应用程序造成的影响降到最小。如果垃圾回收执行的不充分,那么应用程序迟早会发生OOM错误;如果垃圾回收执行的太频繁,会对应用程序的吞吐量和响应时间造成影响,当然,这都不是好的影响。

GC算法

目前已经出现了很多垃圾回收算法。在这个系列文章中将对其中的一些进行介绍。概括来说,垃圾回收主要有两种方式,引用计数(reference counting)和引用追踪(referencetracing)。

引用计数垃圾回收器会记录指向某个对象的引用的数目。当指向某个对象引用数位0时,该对象占用的内存就可以被回收了,这是引用计数垃圾回收的一个主要优点。使用引用计数垃圾回收需要克服的难点在于如何解决循环引用带来的问题,以及如何保证引用计数的实效性。

引用追踪垃圾回收器会标记所有仍有引用指向的对象,并从已标记的对象出发,继续标记这些对象指向的对象。当所有仍有引用指向的对象都被标记为“live”后,所有未标记的对象会被回收。这种方式可以解决循环引用结果带来的问题,但是大多数情况下,垃圾回收器必须等待标记完全结束才能开始进行垃圾回收。

上面提到的两种算法有多种不同的实现方法,其中最著名的是标记或拷贝算法(markingor copying algorithm)和并行或并发算法(parallelor concurrent algorithm)。我将在后续的文章中对它们进行介绍。

分代垃圾回收的意思是,将堆划分为几个不同的区域,分别用于存储新对象和老对象。其中“老对象”指的是挺过了几轮垃圾回收而不死的对象。将堆空间分为年轻代和老年代,分别用于存储新对象和老对象可以通过回收生命周期较短的对象,并将生命周期较长的对象从年轻代提升到老年代的方法来减少堆空间中的碎片,降低堆空间碎片化的风险。此外,使用年轻代还有一个好处是,它可以推出对老年代进行垃圾回收的需求(对老年代进行垃圾回收的代价比较大,因为老年代中那些生命周期较长的对象通常包含有更多的引用,遍历一次需要花费更多的时间),因那些生命周期较短的对通常会重用年轻代中的空间。

还有一个值得一提的算法改进是压缩,它可以用来管理堆空间中的碎片。基本上压缩就是将对象移动到一起,再释放掉较大的连续空间。如果你对磁盘碎片和处理磁盘碎片的工具比较熟悉的话你就会理解压缩的含义了,只不过这里的压缩是工作在Java堆空间中的。我将在该系列后续的内容中对压缩进行介绍。

结论:回顾与展望

JVM实现了可移植性(“一次编写,到处运行”)和动态内存管理,这两个特点也是其广受欢迎,并且具有较高生产力的原因。作为这个系列文章的第一篇,我介绍了编译器如何将字节码转换为平台相关指令的语言,以及如何动态优化Java程序的运行性能。不同的编译器迎合了不同应用程序的需要。此外,简单介绍了内存分配和垃圾回收的一点内容,及其与Java应用程序性能的关系。基本上将,Java应用程序运行的速度越快,填满Java堆所需的时间就越短,触发垃圾回收的频率也越高。这里遇到的问题就是,在应用程序出现OOM错误之前,如何在对应用程序造成的影响尽可能小的情况下,回收足够多的内存空间。

 

JVM性能优化,Part 2 ―― 编译器

作为JVM性能优化系列文章的第2篇,本文将着重介绍Java编译器,此外还将对JIT编译器常用的一些优化措施进行讨论。EvaAndreasson将对不同种类的编译器做介绍,并比较客户端、服务器端和层次编译产生的编译结果在性能上的区别,此外将对通用的JVM优化做介绍,包括死代码剔除、内联以及循环优化。

Java编译器存在是Java编程语言能独立于平台的根本原因。软件开发者可以尽全力编写程序,然后由Java编译器将源代码编译为针对于特定平台的高效、可运行的代码。不同类型的编译器适合于不同应用程序的需求,使编译结果可以满足期望的性能要求。对编译器基本原理了解得越多,在优化Java应用程序性能时就越能得心应手。

什么是编译器?

简单来说,编译器就是将一种编程语言作为输入,输出另一种可执行语言的工具。大家都熟悉的javac就是一个编译器,所有标准版的JDK中都带有这个工具。javac以Java源代码作为输入,将其翻译为可由JVM执行的字节码。翻译后的字节码存储在.class文件中,在启动Java进程的时候,被载入到Java运行时中。

标准CPU并不能识别字节码,它需要被转换为当前平台所能理解的本地指令。在JVM中,有专门的组件负责将字节码编译为平台相关指令,实际上,这也是一种编译器。有些JVM编译器可以处理多层级的编译工作,例如,编译器在最终将字节码转换为平台相关指令前,会为相关的字节码建立多层级的中间表示(intermediaterepresentation)。

以平台未知的角度看,我们希望尽可能的保持平台独立性,因此,最后一级的编译,也就是从最低级表示到实际机器码的转换,是与具体平台的处理器架构息息相关的。在最高级的表示上,会因使用静态编译器还是动态编译器而有所区别。

静态编译器与动态编译器

前文提到的javac就是使用静态编译器的例子。静态编译器解释输入的源代码,并输出程序运行时所需的可执行文件。如果你修改了源代码,那么就需要使用编译器来重新编译代码,否则输出的可执行性文件不会发生变化;这是因为静态编译器的输入是静态的普通文件。

使用静态编译器时,下面的Java代码

static int add7( int x ) {

     return x+7;

}

 

会生成类似如下的字节码:

iload0

bipush 7

iadd

ireturn

 

动态编译器会动态的将一种编程语言编译为另一种,即在程序运行时执行编译工作。动态编译与优化使运行时可以根据当前应用程序的负载情况而做出相应的调整。动态编译器非常适合用于Java运行时中,因为Java运行时通常运行在无法预测而又会随着运行而有所变动的环境中。大部分JVM都会使用诸如Just-In-Time编译器的动态编译器。这里面需要注意的是,大部分动态编译器和代码优化有时需要使用额外的数据结构、线程和CPU资源。要做的优化或字节码上下文分析越高级,编译过程所消耗的资源就越多。在大多数运行环境中,相比于经过动态编译和代码优化所获得的性能提升,这些损耗微不足道。

 JVM的多样性与Java平台的独立性

所有的JVM实现都有一个共同点,即它们都试图将应用程序的字节码转换为本地机器指令。一些JVM在载入应用程序后会解释执行应用程序,同时使用性能计数器来查找“热点”代码。还有一些JVM会调用解释执行的阶段,直接编译运行。资源密集型编译任务对应用程序来说可能会产生较大影响,尤其是那些客户端模式下运行的应用程序,但是资源密集型编译任务可以执行一些比较高级的优化任务。

如果你是Java初学者,JVM本身错综复杂结构会让你晕头转向的。不过,好消息是你无需精通JVM。JVM自己会做好代码编译和优化的工作,所以你无需关心如何针对目标平台架构来编写应用程序才能编译、优化,从而生成更好的本地机器指令。

从字节码到可运行的程序

当你编写完Java源代码并将之编译为字节码后,下一步就是将字节码指令编译为本地机器指令。这一步会由解释器或编译器完成。

解释

解释是最简单的字节码编译形式。解释器查找每条字节码指令对应的硬件指令,再由CPU执行相应的硬件指令。你可以将解释器想象为一个字典:每个单词(字节码指令)都有准确的解释(本地机器指令)。由于解释器每次读取一个字节码指令并立即执行,因此它就没有机会对某个指令集合进行优化。由于每次执行字节码时,解释器都需要做相应的解释工作,因此程序运行起来就很慢。解释执行可以准确执行字节码,但是未经优化而输出的指令集难以发挥目标平台处理器的最佳性能。

编译

另一方面,编译执行应用程序时,*编译器*会将加载运行时用到的全部代码。因为编译器可以将字节码编译为本地代码,因此它可以获取到完整或部分运行时上下文信息,并依据收集到的信息决定到底应该如何编译字节码。编译器是根据诸如指令的不同执行分支和运行时上下文数据等代码信息来指定决策的。

当字节码序列被编译为机器代码指令集合时,就可以对这个指令集合做一些优化操作了,优化后的指令集合会被存储到成为codecache的数据结构中。当下一次执行这部分字节码序列时,就会执行这些经过优化后被存储到codecache的指令集合。在某些情况下,性能计数器会失效,并覆盖掉先前所做的优化,这时,编译器会执行一次新的优化过程。使用codecache的好处是优化后的指令集可以立即执行—— 无需像解释器一样再经过查找的过程或编译过程!这可以加速程序运行,尤其是像Java应用程序这种同一个方法会被多次调用应用程序。

优化  

随着动态编译器一起出现的是性能计数器。例如,编译器会插入性能计数器,以统计每个字节码块(对应与某个被调用的方法)的调用次数。在进行相关优化时,编译器会使用收集到的数据来判断某个字节码块有多“热”,这样可以最大程度的降低对当前应用程序的影响。运行时数据监控有助于编译器完成多种代码优化工作,进一步提升代码执行性能。随着收集到的运行时数据越来越多,编译器就可以完成一些额外的、更加复杂的代码优化工作,例如编译出更高质量的目标代码,使用运行效率更高的代码替换原代码,甚至是剔除冗余操作等。

示例

考虑如下代码:

static int add7( int x ) {

     return x+7;

}

 

这段代码经过javac编译后会产生如下的字节码:

iload0

bipush 7

iadd

ireturn

 

当调用这段代码时,字节码块会被动态的编译为本地机器指令。当性能计数器(如果这段代码应用了性能计数器的话)发现这段代码的运行次数超过了某个阈值后,动态编译器会对这段代码进行优化编译。后带的代码可能会是下面这个样子:

lea rax,[rdx+7]

ret

 

各擅胜场

不同的Java应用程序需要满足不同的需求。相对来说,企业级服务器端应用程序需要长时间运行,因此可以做更多的优化,而稍小点的客户端应用程序可能要求快速启动运行,占资源少。接下来我们考察三种编译器设置及其各自的优缺点。

客户端编译器

即大家熟知的优化编译器C1。在启动应用程序时,添加JVM启动参数“-client”可以启用C1编译器。正如启动参数所表示的,C1是一个客户端编译器,它专为客户端应用程序而设计,资源消耗更少,并且在大多数情况下,对应用程序的启动时间很敏感。C1编译器使用性能计数器来收集代码的运行时信息,执行一些简单、无侵入的代码优化任务。

服务器端编译器

对于那些需要长时间运行的应用程序,例如服务器端的企业级Java应用程序来说,客户端编译器所实现的功能还略有不足,因此服务器端的编译会使用类似C2这类的编译器。启动应用程序时添加命令行参数“-server”可以启用C2编译器。由于大多数服务器端应用程序都会长时间运行,因此相对于运行时间稍短的轻量级客户端应用程序,在服务器端应用程序中启用C2编译器可以收集到更多的运行时数据,也就可以执行一些更高级的编译技术与算法。

提示:给服务器端编译器热身

对于服务器端编译器来说,在应用程序开始运行之后,编译器可能会在一段时间之后才开始优化“热点”代码,所以服务器端编译器通常需要经过一个“热身”阶段。在服务器端编译器执行性能优化任务之前,要确保应用程序的各项准备工作都已就绪。给予编译器足够多的时间来完成编译、优化的工作才能取得更好的效果。

在执行编译任务优化时,服务器端编译器要比客户端编译器综合考虑更多的运行时信息,执行更复杂的分支分析,即对哪种优化路径能取得更好的效果作出判断。获取的运行时数据越多,编译优化所产生的效果越好。当然,要完成一些复杂的、高级的性能分析任务,编译器就需要消耗更多的资源。使用了C2编译器的JVM会消耗更多的资源,例如更多的线程,更多的CPU指令周期,以及更大的codecache等。

层次编译

层次编译综合了服务器端编译器和客户端编译器的特点。Azul首先在其ZingJVM中实现了层次编译。最近(就是JavaSE 7版本),OracleJava HotSpot VM也采用了这种设计。在应用程序启动阶段,客户端编译器最为活跃,执行一些由较低的性能计数器阈值出发的性能优化任务。此外,客户端编译器还会插入性能计数器,为一些更复杂的性能优化任务准备指令集,这些任务将在后续的阶段中由服务器端编译器完成。层次编译可以更有效的利用资源,因为编译器在执行一些对应用程序影响较小的编译活动时仍可以继续收集运行时信息,而这些信息可以在将来用于完成更高级的优化任务。使用层次编译可以比解释性的代码性能计数器手机到更多的信息。


Figure1中展示了纯解释运行、客户端模式运行、服务器端模式运行和层次编译模式运行下性能之间的区别。X轴表示运行时间(单位时间)Y轴表示性能(每单位时间内的操作数)。

编译性能对比

相比于纯解释运行的的代码,以客户端模式编译运行的代码在性能(指单位时间执行的操作)上可以达到约5到10倍,因此提升了应用程序的运行性能。其间的区别主要在于编译器的效率、编译器所作的优化,以及应用程序在设计实现时针对目标平台做了何种程度的优化。实际上,最后一条不在Java程序员的考虑之列。

相比于客户端编译器,使用服务器端编译器通常会有30%到50%的性能提升。在大多数情况下,这种程度的性能提升足以弥补使用服务器端编译所带来的额外资源消耗。

层次编译综合了服务器端编译器和客户端编译器的优点,使用客户端编译模式实现快速启动和快速优化,使用服务器端编译模式在后续的执行周期中完成高级优化的编译任务。

常用编译优化手段

到目前为止,已经介绍了优化代码的价值,以及常用JVM编译器是如何以及何时编译代码的。接下来,将用一些实际的例子做个总结。JVM所作的性能优化通常在字节码这一层级(或者是更底层的语言表示),但这里我将使用Java编程语言对优化措施进行介绍。

死代码剔除

死代码剔除指的是,将无法被调用的代码(即“死代码”)从源代码中剔除。如果编译器在运行时发现某些指令是不必要的,它会简单的将其从可执行指令集中剔除。例如,在Listing1中,变量被赋予了确定值,却从未被使用,因此可以在执行时将其完全忽略掉。在字节码这一层级,也就不会有将数值载入到寄存器的操作。没有载入操作意味着可以更少的CPU时间,更好的运行性能,尤其是当这段代码是“热点”代码的时候。

Listing 1中展示了示例代码,其中被赋予了固定值的代码从未被使用,属于无用不必要的操作。

Listing 1. Dead code

int timeToScaleMyApp(boolean endlessOfResources) {

   int reArchitect = 24;

   int patchByClustering = 15;

  int useZing = 2;

   if(endlessOfResources)

       return reArchitect + useZing;

   else

       return useZing;

}

 

在字节码这一层级,如果变量被载入但从未使用,编译器会检测到并剔除这个死代码,如Listing2所示。剔除死代码可以节省CPU时间,从而提升应用程序的运行速度。

Listing 2. The same code followingoptimization

int timeToScaleMyApp(boolean endlessOfResources) {

   int reArchitect = 24;

   //unnecessary operation removed here...

   int useZing = 2;

    if(endlessOfResources)

       return reArchitect + useZing;

   else

       return useZing;

}

 

冗余剔除是一种类似的优化手段,通过剔除掉重复的指令来提升应用程序性能。

内联

许多优化手段都试图消除机器级跳转指令(例如,x86架构的JMP指令)。跳转指令会修改指令指针寄存器,因此而改变了执行流程。相比于其他汇编指令,跳转指令是一个代价高昂的指令,这也是为什么大多数优化手段会试图减少甚至是消除跳转指令。内联是一种家喻户晓而且好评如潮的优化手段,这是因为跳转指令代价高昂,而内联技术可以将经常调用的、具有不容入口地址的小方法整合到调用方法中。Listing3到Listing5中的Java代码展示了使用内联的用法。

Listing 3. Caller method

int whenToEvaluateZing(int y) {

   return daysLeft(y) + daysLeft(0) + daysLeft(y+1);

}

 

Listing 4. Called method

int daysLeft(int x){

   if (x == 0)

      return 0;

   else

      return x - 1;

}

 

Listing 5. Inlined method

int whenToEvaluateZing(int y){

   int temp = 0;

    if(y == 0) temp += 0; else temp += y - 1;

   if(0 == 0) temp += 0; else temp += 0 - 1;

   if(y+1 == 0) temp += 0; else temp += (y + 1) - 1;

    return temp; 

}

 

在Listing3到Listing5的代码中,展示了将调用3次小方法进行内联的示例,这里我们认为使用内联比跳转有更多的优势。如果被内联的方法本身就很少被调用的话,那么使用内联也没什么意义,但是对频繁调用的“热点”方法进行内联在性能上会有很大的提升。此外,经过内联处理后,就可以对内联后的代码进行进一步的优化,正如Listing6中所展示的那样。

Listing 6. After inlining, moreoptimizations can be applied

int whenToEvaluateZing(int y){

   if(y == 0) return y;

   else if (y == -1) return y - 1;

   else return y + y - 1;

}

 

循环优化

当涉及到需要减少执行循环时的性能损耗时,循环优化起着举足轻重的作用。执行循环时的性能损耗包括代价高昂的跳转操作,大量的条件检查,和未经优化的指令流水线(即引起CPU空操作或额外周期的指令序列)等。循环优化可以分为很多种,在各种优化手段中占有重要比重。其中值得注意的包括以下几种:

1、合并循环:当两个相邻循环的迭代次数相同时,编译器会尝试将两个循环体进行合并。当两个循环体中没有相互引用的情况,即各自独立时,可以同时执行(并行执行)。

2、反转循环:基本上将就是用do-while循环体换掉常规的while循环,这个do-while循环嵌套在if语句块中。这个替换操作可以节省两次跳转操作,但是,会增加一个条件检查的操作,因此增加的代码量。这种优化方式完美的展示了以少量增加代码量为代价换取较大性能的提升—— 编译器需要在运行时需要权衡这种得与失,并制定编译策略。

3、分块循环:重新组织循环体,以便迭代数据块时,便于缓存的应用。

4、展开循环:减少判断循环条件和跳转的次数。你可以将之理解为将一些迭代的循环体“内联”到一起,而无需跨越循环条件。展开循环是有风险的,它有可能会降低应用程序的运行性能,因为它会影响流水线的运行,导致产生了冗余指令。再强调一遍,展开循环是编译器在运行时根据各种信息来决定是否使用的优化手段,如果有足够的收益的话,那么即使有些性能损耗也是值得的。

至此,已经简要介绍了编译器对字节码层级(以及更底层)进行优化,以提升应用程序在目标平台的执行性能的几种方式。这里介绍的几种优化手段是比较常用的几种,只是众多优化技术中的几种。在介绍优化方法时配以简单示例和相关解释,希望可以洗发你进行深度探索的兴趣。

总结:回顾

为满足不同需要而使用不同的编译器。解释是将字节码转换为本地机器指令的最简单方式,其工作方式是基于对本地机器指令表的查找。编译器可以基于性能计数器进行性能优化,但是需要消耗更多的资源(如codecache,优化线程等)。相比于纯解释执行代码,客户端编译器可以将应用程序的执行性能提升一个数量级(约5到10倍)。相比于客户端编译器,服务器端编译器可以将应用程序的执行性能提升30%到50%,但会消耗更多的资源。层次编译综合了客户端编译器和服务器端编译器的优点,既可以像客户端编译器那样快速启动,又可以像服务器端编译器那样,在长时间收集运行时信息的基础上,优化应用程序的性能。

目前,已经出现了很多代码优化的手段。对编译器来说,一个主要的任务就是分析所有的可能性,权衡使用某种优化手段的利弊,在此基础上编译代码,优化应用程序的性能。

 

JVM性能优化,Part 3 —— 垃圾回收

Java平台的垃圾回收机制大大提高的开发人员的生产力,但实现糟糕的垃圾回收器却会大大消耗应用程序的资源。本文作为JVM性能优化系列的第3篇,EvaAndeasson将为Java初学者介绍Java平台的内存模型和GC机制。她将解释为什么碎片化(不是GC)是Java应用程序出现性能问题的主要原因,以及为什么当前主要通过分代垃圾回收和压缩,而不是其他最具创意的方法,来解决Java应用程序中碎片化的问题。

垃圾回收(GC)是旨在释放不可达Java对象所占用的内存的过程,是Java virtual machine(JVM)中动态内存管理系统的核心组成部分。在一个典型的垃圾回收周期中,所有仍被引用的对象,即可达对象,会被保留。没有被引用的Java对象所占用的内存会被释放并回收,以便分配给新创建的对象。

为了更好的理解垃圾回收与各种不同的GC算法,你首先需要了解一些关于Java平台内存模型的内容。

垃圾回收与Java平台内存模型

当你在启动Java应用程序时指定了启动参数_-Xmx_(例如,java-Xmx2g MyApp),则相应大小的内存会被分配给Java进程。这块内存即所谓的*Java堆*(或简称为*堆*)。这块专用的内存地址空间用于存储Java应用程序(有时是JVM)所创建的对象。随着Java应用程序的运行,会不断的创建新对象并为之分配内存,Java堆(即地址空间)会逐渐被填满。最后,Java堆会被填满,这就是说想要申请内存的线程无法获得一块足够大的连续空闲空间来存放新创建的对象。此时,JVM判断需要启动垃圾回收器来回收内存了。当Java程序调用System.gc()方法时,也有可能会触发垃圾回收器以执行垃圾回收的工作。使用System.gc()方法并不能保证垃圾回收工作肯定会被执行。在执行垃圾回收前,垃圾回收机制首先会检查当前是否是一个“恰当的时机”,而“恰当的时机”指所有的应用程序活动线程都处于安全点(safepoint),以便启动垃圾回收。简单举例,为对象分配内存时,或正在优化CPU指令时,就不是“恰当的时机”,因为你可能会丢失上下文信息,从而得到混乱的结果。

垃圾回收不应该回收当前有活动引用指向的对象所占用的内存;因为这样做将违反JVM规范。在JVM规范中,并没有强制要求垃圾回收器立即回收已死对象(deadobject)。已死对象最终会在后续的垃圾回收周期中被释放掉。目前,已经有多种垃圾回收的实现,它们都包含两个沟通的假设。对垃圾回收来说,真正的挑战在于标识出所有活动对象(即仍有引用指向的对象),回收所有不可达对象所占用的内存,并尽可能不对正在运行的应用程序产生影响。因此,垃圾回收器运行的两个目标:

1、快速释放不可达对象所占用的内存,防止应用程序出现OOM错误。

2、回收内存时,对应用程序的性能(指延迟和吞吐量)的影响要紧性能小。

两类垃圾回收

Java的2种主要的垃圾回收方式,引用计数(referencecounting)引用追踪(tracingcollector)。这里,我将深入这两种垃圾回收方式,并介绍用于生产环境的实现了引用追踪的垃圾回收方式的相关算法。
引用计数垃圾回收器

引用计数垃圾回收器会对指向每个Java对象的引用数进行跟踪。一旦发现指向某个对象的引用数为0,则立即回收该对象所占用的内存。引用计数垃圾回收的主要优点就在于可以立即访问被回收的内存。垃圾回收器维护未被引用的内存并不需要消耗很大的资源,但是保持并不断更新引用计数却代价不菲。

使用引用计数方式执行垃圾回收的主要困难在于保持引用计数的准确性,而另一个众所周知的问题在于解决循环引用结构所带来的麻烦。如果两个对象互相引用,并且没有其他存活东西引用它们,那么这两个对象所占用的内存将永远不会被释放,两个对象都会因引用计数不为0而永远存活下去(引用计数的难点→相互引用)。要解决循环引用带来的问题需要,而这会使算法复杂度增加,从而影响应用程序的运行性能。 

引用跟踪垃圾回收

引用跟踪垃圾回收器基于这样一种假设,所有存活对象都可以通过迭代地跟踪从已知存活对象集中对象发出的引用及引用的引用来找到。可以通过对寄存器、全局域、以及触发垃圾回收时栈帧的分析来确定初始存活对象的集合(称为“根对象”,或简称为“根”)。在确定了初始存活对象集后,引用跟踪垃圾回收器会跟踪从这些对象中发出的引用,并将找到的对象标记为“活的(live)”。标记所有找到的对象意味着已知存活对象的集合会随时间而增长。这个过程会一直持续到所有被引用的对象(因此是“存活的”对象)都被标记。当引用跟踪垃圾回收器找到所有存活的对象后,就会开始回收未被标记的对象。

不同于引用计数垃圾回收器,引用跟踪垃圾回收器可以解决循环引用的问题。由于标记阶段的存在,大多数引用跟踪垃圾回收器无法立即释放“已死”对象所占用的内存。

引用跟踪垃圾回收器广泛用于动态语言的内存管理;到目前为止,在Java编程语言的视线中也是应用最广的,并且在多年的商业生产环境中,已经证明其实用性。在本文余下的内容中,我将从一些相关的实现算法开始,介绍引用跟踪垃圾回收器,

引用跟踪垃圾回收器算法

拷贝和*标记-清理*垃圾回收算法并非新近发明,但仍然是当今实现引用跟踪垃圾回收器最常用的两种算法。

拷贝垃圾回收器

传统的拷贝垃圾回收器会使用一个“from”区和一个“to”区,它们是堆中两个不同的地址空间。在执行垃圾回收时,from区中存活对象会被拷贝到to区。当from区中所有的存活对象都被拷贝到to后,垃圾回收器会回收整个from区。当再次分配内存时,会首先从to区中的空闲地址开始分配。

在该算法的早期实现中,from区和to区会在垃圾回收周期后进行交换,即当to区被填满后,将再次启动垃圾回收,这是to区会“变成”from区。如图Figure1所示。


在该算法的近期实现中,可以将堆中任意地址空间指定为from区和to区,这样就不再需要交换from区和to区,堆中任意地址空间都可以成为from区或to区。

拷贝垃圾回收器的一个优点是存活对象的位置会被to区中重新分配,紧凑存放,可以完全消除碎片化。碎片化是其他垃圾回收算法所要面临的一大问题,这点会在后续讨论。

拷贝垃圾回收的缺陷:通常来说,拷贝垃圾回收器是“stop-the-world”式的,即在垃圾回收周期内,应用程序是被挂起的,无法工作。在“stop-the-world”式的实现中,所需要拷贝的区域越大,对应用程序的性能所造成的影响也越大。对于那些非常注重响应时间的应用程序来说,这是难以接受的。使用拷贝垃圾回收时,你还需要考虑一下最坏情况,即当from区中所有的对象都是存活对象的时候。因此,你不得不给存活对象预留出足够的空间,也就是说to区必须足够大,大到可以将from区中所有的对象都放进去。正是由于这个缺陷,拷贝垃圾回收算法在内存使用效率上略有不足。

标记-清理垃圾回收器

大多数部署在企业生产环境的商业JVM都使用了标记-清理(或标记)垃圾回收器,这种垃圾回收器并不会想拷贝垃圾回收器那样对应用程序的性能有那么大的影响。其中最著名的几款是CMS、G1、GenPar和DeterministicGC。

标记-清理垃圾回收器会跟踪引用,并使用标记位将每个找到的对象标记位“live”。通常来说,每个标记位都关联着一个地址或堆上的一个地址集合。例如,标记位可能是对象头(objectheader)中一位,一个位向量,或是一个位图。当所有的存活对象都被标记位“live”后,将会开始*清理*阶段。一般来说,垃圾回收器的清理阶段包含了通过再次遍历堆(不仅仅是标记位live的对象集合,而是整个堆)来定位内存地址空间中未被标记的区域,并将其回收。然后,垃圾回收器会将这些被回收的区域保存到空闲列表(freelist)中。在垃圾回收器中可以同时存在多个空闲列表——通常会按照保存的内存块的大小进行划分。某些JVM(例如JRockit实时系统,JRockit Real Time System)在实现垃圾回收器时会给予应用程序分析数据和对象大小统计数据来动态调整空闲列表所保存的区域块的大小范围。

当清理阶段结束后,应用程序就可以再次启动了。给新创建的对象分配内存时会从空闲列表中查找,而空闲列表中内存块的大小需要匹配于新创建的对象大小、某个线程中平均对象大小,或应用程序所设置的TLAB的大小。从空闲列表中为新创建的对象找到大小合适的内存区域块有助于优化内存的使用,减少内存中的碎片。

标记-清理垃圾回收器的缺陷标记阶段的时长取决于堆中存活对象的总量,而清理阶段的时长则依赖于堆的大小。由于在*标记*阶段和*清理*阶段完成前,你无事可做,因此对于那些具有较大的堆和较多存活对象的应用程序来说,使用此算法需要想办法解决暂停时间(pause-time)较长这个问题。

对于那些内存消耗较大的应用程序来说,你可以使用一些GC调优选项来满足其在某些场景下的特殊需求。很多时候,调优至少可以将标记-清理阶段给应用程序或性能要求(SLA,SLA指定了应用程序需要达到的响应时间的要求,即延迟)所带来的风险推后。当负载和应用程序发生改变后,需要重新调优,因为某次调优只对特定的工作负载和内存分配速率有效。

标记-清理算法的实现

目前,标记-清理垃圾回收算法至少已有2种商业实现,并且都已在生产环境中被证明有效。其一是并行垃圾回收,另一个是并发(或多数时间并发)垃圾回收。

并行垃圾回收器

并行垃圾回收指的是垃圾回收是多线程并行完成的。大多数商业实现的并行垃圾回收器都是stop-the-world式的垃圾回收器,即在整个垃圾回收周期结束前,所有应用程序线程都会被挂起。挂起所有应用程序线程使垃圾回收器可以以并行的方式,更有效的完成标记和清理工作。并行使得效率大大提高,通常可以在像SPECjbb这样的吞吐量基准测试中跑出高分。如果你的应用程序好似有限考虑吞吐量的,那么并行垃圾回收是你最好的选择。对于大多数并行垃圾回收器来说,尤其是考虑到应用于生产环境中,最大的问题是,像拷贝垃圾回收算法一样,在垃圾回收周期内应用程序无法工作。使用stop-the-world式的并行垃圾回收会对优先考虑响应时间的应用程序产生较大影响,尤其是当你有大量的引用需要跟踪,而此时恰好又有大量的、具有复杂结构的对象存活于堆中的时候,情况将更加糟糕。(记住,标记-清理垃圾回收器回收内存的时间取决于跟踪存活对象中所有引用的时间与遍历整个堆的时间之和。)以并行方式执行垃圾回收所导致的应用程序暂停会一直持续到整个垃圾回收周期结束。

并发垃圾回收器

并发垃圾回收器更适用于那些对响应时间比较敏感的应用程序。并发指的是一些(或大多数)垃圾回收工作可以与应用程序线程同时运行。由于并非所有的资源都由垃圾回收器使用,因此这里所面临的问题如何决定何时开始执行垃圾回收,可以保证垃圾回收顺利完成。这里需要足够的时间来跟踪存活对象的引用,并在应用程序出现OOM错误前回收内存。如果垃圾回收器无法及时完成,则应用程序就会抛出OOM错误。此外,一直做垃圾回收也不好,会不必要的消耗应用程序资源,从而影响应用程序吞吐量。要想在动态环境中保持这种平衡就需要一些技巧,因此设计了启发式方法来决定何时开始垃圾回收,何时执行不同的垃圾回收优化任务,以及一次执行多少垃圾回收优化任务等。

并发垃圾回收器所面临的另一个挑战是如何决定何时执行一个需要完整堆快照的操作时安全的,例如,你需要知道是何时标记所有存活对象,这样才能转而进入清理阶段。在大多数并行垃圾回收器采用的stop-the-world方式中,*阶段转换(phase-switching)*并不需要什么技巧,因为世界已静止(堆上对象暂时不会发生变化)。但是,在并发垃圾回收中,转换阶段时可能并不是安全的。例如,如果应用程序修改了一块垃圾回收器已经标记过的区域,可能会涉及到一些新的或未被标记的引用,而这些引用使其指向的对象成为存活状态。在某些并发垃圾回收的实现中,这种情况有可能会使应用程序陷入长时间运行重标记(re-mark)的循环,因此当应用程序需要分配内存时无法得到足够做的空闲内存。

到目前为止的讨论中,已经介绍了各种垃圾回收器和垃圾回收算法,他们各自适用于不同的场景,满足不同应用程序的需求。各种垃圾回收方式不仅在算法上有所区别,在具体实现上也不尽相同。所以,在命令行中指定垃圾回收器之前,最好能了解应用程序的需求及其自身特点。在下一节中,将介绍Java平台内存模型中的陷阱,在这里,陷阱指的是在动态生产环境中,Java程序员常常做出的一些中使性能更糟,而非更好的假设。

为什么调优无法取代垃圾回收

大多数Java程序员都知道,有不少方法可以最大化Java程序的性能。而当今众多的JVM实现,垃圾回收器实现,以及多到令人头晕的调优选项都可能会让开发人员将大量的时间消耗在无穷无尽的性能调优上。这种情况催生了这样一种结论,“GC是糟糕的,努力调优以降低GC的频率或时长才是王道”。但是,真这么做是有风险的。

考虑一下针对指定的应用程序需求做调优意味着什么。大多数调优参数,如内存分配速率,对象大小,响应时间,以及对象死亡速度等,都是针对特定的情况而来设定的,例如测试环境下的工作负载。例如。调优结果可能有以下两种:

测试时正常,上线就失败。一旦应用程序本身,或工作负载发生改变,就需要全部重调。

调优是需要不断往复的。使用并发垃圾回收器需要做很多调优工作,尤其是在生产环境中。为满足应用程序的需求,你需要不断挑战可能要面对的最差情况。这样做的结果就是,最终形成的配置非常刻板,而且在这个过程中也浪费了大量的资源。这种调优方式(试图通过调优来消除GC)是一种堂吉诃德式探索——以根本不存在的理由去挑战一个假想敌。而事实是,你针对某个特定的负载而垃圾回收器做的调优越多,你距离Java运行时的动态特性就越远。毕竟,有多少应用程序的工作负载能保持不变呢?你所预估的工作负载可靠性又有多高呢?

那么,如果不从调优入手又该怎么办呢?有什么其他的办法可以防止应用程序出现OOM错误,并降低响应时间呢?这里,首先要做的是明确影响Java应用程序性能的真正因素。

碎片化

影响Java应用程序性能的罪魁祸首并不是垃圾回收器本身,而是碎片化,以及垃圾回收器如何处理碎片。碎片是Java堆中空闲空间,但由于连续空间不够大而无法容纳将要创建的对象。碎片可能是TLAB中的剩余空间,也可能是(这种情况比较多)被释放掉的具有较长生命周期的小对象所占用的空间。

随着应用程序的运行,这种无法使用的碎片会遍布于整个堆空间。在某些情况下,这种状态会因静态调优选项(如提升速率和空闲列表等)更糟糕,以至于无法满足应用程序的原定需求。这些剩下的空间(也就是碎片)无法被应用程序有效利用起来。如果你对此放任自流,就会导致不断垃圾回收,垃圾回收器会不断的释放内存以便创建新对象时使用。在最差情况下,甚至垃圾回收也无法腾出足够的内存空间(因为碎片太多),JVM会强制抛出OOM(outof memory)错误当然,你也可以重启应用程序来消除碎片,这样可以使Java堆焕然一新,于是就又可以为对象分配内存了。但是,重新启动会导致服务器停机,另外,一段时间之后,堆将再次充满碎片,你也不得不再次重启。

OOM错误(OutOfMemoryErrors)会挂起进程,日志中显示的垃圾回收器很忙,是垃圾回收器努力释放内存的标志,也说明了堆中碎片非常多。一些开发人员通过重新调优垃圾回收器来解决碎片化的问题,但我觉着在解决碎片问题成为垃圾回收的使命之前应该用一些更有新意的方法来解决这个问题。本文后面的内容将聚焦于能有效解决碎片化问题的方法:分代式垃圾回收和压缩

分代式垃圾回收

这个理论你可以已经听说过,即在生产环境中,大部分对象的生命周期都很短。分代式垃圾回收就源于这个理论。在分代式垃圾回收中,堆被分为两个不同的空间(或成为“代”),每个空间存放具有不同年龄的对象,在这里,年龄是指该对象所经历的垃圾回收的次数(也就是该对象挺过了多少次垃圾回收而没有死掉)。

当新创建的对象所处的空间(*年轻代*)被对象填满后,该空间中仍然存活的对象会被移动到老年代。(译者注,以HotSpot为例,这里应该是挺过若干次GC而不死的,才会被搬到老年代,而一些比较大的对象会直接放到老年代。)大多数的实现都将堆会分为两代,年轻代和老年代。通常来说,分代式垃圾回收器都是单向拷贝的,即从年轻代向老年代拷贝。近几年出现的年轻代垃圾回收器已经可以实现并行垃圾回收,当然也可以实现一些其他的垃圾回收算法实现对年轻代和老年代的垃圾回收。如果你使用拷贝垃圾回收器(可能具有并行收集功能)对年轻代进行垃圾回收,那垃圾回收是stop-the-world式的。

分代式垃圾回收的缺陷:在分代式垃圾回收中,老年代执行垃圾回收的频率较低,而年轻代较高,垃圾回收的时间较短,侵入性也较低。但在某些情况下,年轻代的存在会是老年代的垃圾回收更加频繁。典型的例子是,相比于Java堆的大小,年轻代被设置的太大,而应用程序中对象的生命周期又很长(又或者给年轻代对象提升速率设了一个“不正确”的值)。在这种情况下,老年代因太小而放不下所有的存活对象,因此垃圾回收器就会忙于释放内存以便存放从年轻代提升上来的对象。但一般来说,使用分代式垃圾回收器可以使应用程序的性能和系统延迟保持在一个合适的水平。

使用分代式垃圾回收器的一个额外效果是部分解决了碎片化的问题,或者说,发生最差情况的时间被推迟了。可能造成碎片的小对象被分配于年轻代,也在年轻代被释放掉。老年代中的对象分布会相对紧凑一些,因为这些对象在从年轻代中提升上来的时候会被会紧凑存放。但随着应用程序的运行,如果运行时间够长的话,老年代也会充满碎片的。这时就需要对年轻代和老年代执行一次或多次stop-the-world式的全垃圾回收,导致JVM抛出OOM错误,或者表明提升失败的错误。但年轻代的存在使这种情况的出现被推迟了,对某些应用程序来说,这就足够了。(在某些情况下,这种糟糕情况会被推迟到应用程序完全不关心GC的时候。)对大多数应用程序来说,对于大多数使用年轻代作为缓冲的应用程序来说,年轻代的存在可以降低出现stop-the-world式垃圾回收频率,减少抛出OOM错误的次数。

调优分代式垃圾回收

正如上面提到的,由于使用了分代式垃圾回收,你需要针对每个新版本的应用程序和不同的工作负载来调整年轻代大小和对象提升速度。我无法完整评估出固定运行时的代价:由于针对某个指定工作负载而设置了一系列优化参数,垃圾回收器应对动态变化的能力降低了,而变化是不可避免的。

对于调整年轻代大小来说,最重要的规则是要确保年轻代的大小不应该使因执行stop-the-world式垃圾回收而导致的暂停过长。(假设年轻代中使用的并行垃圾回收器。)还要记住的是,你要在堆中为老年代留出足够的空间来存放那些生命周期较长的对象。下面还有一些在调优分代式垃圾回收器时需要考虑的因素:

大多数年轻代垃圾回收都是stop-the-world式的,年轻代越大,相应的暂停时间越长。所以,对于那些受GC暂停影响较大的应用程序来说,应该仔细斟酌年轻代的大小。

你可以综合考虑不同代的垃圾回收算法。可以在年轻代使用并行垃圾回收,而在老年代使用并行垃圾回收。

当提升失败频繁发生时,这通常说明老年代中的碎片较多。提升失败指的是老年代中没有足够大的空间来存放年轻代中的存活对象。当出现提示失败时,你可以微调对象提升速率(即调整对象提升时年龄),或者确保老年代垃圾回收算法会将对象进行压缩(将在下一节讨论),并以一种适合当前应用程序工作负载的方式调整压缩。你也可以增大堆和各个代的大小,但这会使老年代垃圾回收的暂停时间延长——记住,碎片化是不可避免的。

分代式垃圾回收最适用于那些具有大量短生命周期对象的应用程序,这些对象的生命周期短到活不过一次垃圾回收周期。在这种场景中,分代式垃圾回收可有效的减缓碎片化的趋势,主要是将碎片化随带来的影响推出到将来,而那时可能应用程序对此毫不关心。

压缩

尽管分代式垃圾回收推出了碎片化和OOM错误出现的时机,但压缩仍然是唯一真正解决碎片化的方法。*压缩*是将对象移动到一起,以便释放掉大块连续内存空间的GC策略。因此,压缩可以生成足够大的空间来存放新创建的对象。

移动对象并修改相关引用是一个stop-the-world式的操作,这会对应用程序的性能造成影响。(只有一种情况是个例外,将在本系列的下一篇文章中讨论。)存活对象越多,垃圾回收造成的暂停也越长。假如堆中的空间所剩无几,而且碎片化又比较严重(这通常是由于应用程序运行的时间很长了),那么对一块存活对象多的区域进行压缩可能会耗费数秒的时间。而如果因出现OOM而导致应用程序无法运行,因此而对整个堆进行压缩时,所消耗的时间可达数十秒。

压缩导致的暂停时间的长短取决于需要移动的存活对象所占用的内存有多大以及有多少引用需要更新。当堆比较大时,从统计上讲,存活对象和需要更新的引用都会很多。从已观察到的数据看,每压缩1到2GB存活数据的需要约1秒钟。所以,对于4GB的堆来说,很可能会有至少25%的存活数据,从而导致约1秒钟的暂停。

压缩与应用程序内存墙

应用程序内存墙涉及到在GC暂停时间对应用程序的影响大到无法达到满足预定需求之前所能设置的的堆的最大值。目前,大部分Java应用程序在碰到内存墙时,每个JVM实例的堆大小介于4GB到20GB之间,具体数值依赖于具体的环境和应用程序本身。这也是大多数企业及应用程序会部署多个小堆JVM而不是部署少数大堆(50到60GB)JVM的原因之一。在这里,我们需要思考一下:现代企业中有多少Java应用程序的设计与部署架构受制于JVM中的压缩?在这种情况下,我们接受多个小实例的部署方案,以增加管理维护时间为代价,绕开为处理充满碎片的堆而执行stop-the-world式垃圾回收所带来的问题。考虑到现今的硬件性能和企业级Java应用程序中对内存越来越多的访问要求,这种方案是在非常奇怪。为什么仅仅只能给每个JVM实例设置这么小的堆?并发压缩是一种可选方法,它可以降低内存墙带来的影响,这将是本系列中下一篇文章的主题。

从已观察到的数据看,每压缩1到2GB存活数据的需要约1秒钟。所以,对于4GB的堆来说,很可能会有至少25%的存活数据,从而导致约1秒钟的暂停。

总结:回顾

本文对垃圾回收做了总体介绍,目的是为了使你能了解垃圾回收的相关概念和基本知识。希望本文能激发你继续深入阅读相关文章的兴趣。这里所介绍的大部分内容,它们。在下一篇文章中,我将介绍一些较新颖的概念,并发压缩,目前只有Azul公司的ZingJVM实现了这一技术。并发压缩是对GC技术的综合运用,这些技术试图重新构建Java内存模型,考虑当今内存容量与处理能力的不断提升,这一点尤为重要。

现在,回顾一下本文中所介绍的关于垃圾回收的一些内容:

1、不同的垃圾回收算法的方式是为满足不同的应用程序需求而设计。目前在商业环境中,应用最为广泛的是引用跟踪垃圾回收器

2、并行垃圾回收器会并行使用可用资源执行垃圾回收任务。这种策略的常用实现是stop-the-world式垃圾回收器,使所有可用系统资源快速完成垃圾回收任务。因此,并行垃圾回收可以提供较高的吞吐量,但在垃圾回收的过程中,所有应用程序线程都会被挂起,对延迟有较大影响。

3、并发垃圾回收器可以与应用程序并发工作。使用并发垃圾回收器时要注意的是,确保在应用程序发生OOM错误之前完成垃圾回收。

4、分代式垃圾回收可以推迟碎片化的出现,但并不能消除碎片化。它将堆分为两块空间,一块用于存放“年轻对象”,另一块用于存放从年轻代中存活下来的存活对象。对于那些使用了很多具有较短生命周期活不过几次垃圾回收周期的Java应用程序来说,使用分代式垃圾回收是非常合适的

5、压缩是可以完全解决碎片化的唯一方法。大多数垃圾回收器在压缩的时候是都stop-the-world式的。应用程序运行的时间越长,对象间的引就用越复杂,对象大小的异质性也越高。相应的,完成压缩所需要的时间也越长。如果堆的大小较大的话也会对压缩所产生的暂停有影响,因为较大的堆就会有更多的活动数据和更多的引用需要处理。

6、调优可以推迟OOM错误的出现,但过度调优是无意义的。在通过试错方式初始调优前,一定要明确生产环境负载的动态性,以及应用程序中的对象类型和对象间的引用情况。在动态负载下,过于刻板的配置很容会失效。在设置非动态调优选项前一定要清楚这样做后果。

 

JVM 性能优化,Part 4: C4 垃圾回收

到目前为止,本系列的文章将stop-the-world式的垃圾回收视为影响Java应用程序伸缩性的一大障碍,而伸缩性又是现代企业级Java应用程序开发的基础要求,因此这一问题亟待改善。幸运的是,针对此问题,JVM中已经出现了一些新特性,所使用的方式或是对stop-the-world式的垃圾回收做微调,或是消除冗长的暂停(这样更好些)。在一些多核系统中,内存不再是稀缺资源,因此,JVM的一些新特性就充分利用多核系统的潜在优势来增强Java应用程序的伸缩性。

在本文中,我将着重介绍C4算法,该算法是AzulSystem公司中无暂停垃圾回收算法的新成果,目前只在ZingJVM上得到实现。此外,本文还将对Oracle公司的G1垃圾回收算法和IBM公司的BalancedGarbage Collection Policy算法做简单介绍。希望通过对这些垃圾回收算法的学习可以扩展你对Java内存管理模型和Java应用程序伸缩性的理解,并激发你对这方面内容的兴趣以便更深入的学习相关知识。至少,你可以学习到在选择JVM时有哪些需要关注的方面,以及在不同应用程序场景下要注意的事项。

C4算法中的并发性

AzulSystem公司的C4(ConcurrentContinuously Compacting Collector,译者注,Azul官网给出的名字是ContinuouslyConcurrent Compacting Collector)算法使用独一无二而又非常有趣的方法来实现低延迟的分代式垃圾回收。相比于大多数分代式垃圾回收器,C4的不同之处在于它认为垃圾回收并不是什么坏事(即应用程序产生垃圾很正常),而压缩是不可避免的。在设计之初,C4就是要牺牲各种动态内存管理的需求,以满足需要长时间运行的服务器端应用程序的需求。

C4算法将释放内存的过程从应用程序行为和内存分配速率中分离出来,并加以区分。这样就实现了并发运行,即应用程序可以持续运行,而不必等待垃圾回收的完成。其中的并发性是关键所在,正是由于并发性的存在才可以使暂停时间不受垃圾回收周期内堆上活动数据数量和需要跟踪与更新的引用数量的影响,将暂停时间保持在较低的水平。大多数垃圾回收器在工作周期内都包含了stop-the-world式的压缩过程,这就是说应用程序的暂停时间会随活动数据总量和堆中对象间引用的复杂度的上升而增加。使用C4算法的垃圾回收器可以并发的执行压缩操作,即压缩与应用程序线程同时工作,从而解决了影响JVM伸缩性的最大难题。

实际上,为了实现并发性,C4算法改变了现代Java企业级架构和部署模型的基本假设。想象一下拥有数百GB内存的JVM会是什么样的:

1、部署Java应用程序时,对伸缩性的要求无需要多个JVM配合,在单一JVM实例中即可完成。这时的部署是什么样呢?

2、有哪些以往因GC限制而无法在内存存储的对象?

3、那些分布式集群(如缓存服务器、区域服务器,或其他类型的服务器节点)会有什么变化?当可以增加JVM内存而不会对应用程序响应时间造成负面影响时,传统的节点数量、节点死亡和缓存丢失的计算会有什么变化呢?

C4算法的3的阶段

C4算法的一个基本假设是“垃圾回收不是坏事”和“压缩不可避免”。C4算法的设计目标是实现垃圾回收的并发与协作,剔除stop-the-world式的垃圾回收。C4垃圾回收算法包含一下3个阶段:

1、标记(Marking)— 找到活动对象

2、重定位(Relocation)— 将存活对象移动到一起,以便可以释放较大的连续空间,这个阶段也可称为“压缩(compaction)”

3、重映射(Remapping)— 更新被移动的对象的引用。

C4算法中的标记阶段

在C4算法中,标记阶段(markingphase)使用了并发标记(concurrentmarking)和引用跟踪(reference-tracing)的方法来标记活动对象。在标记阶段中,GC线程会从线程栈和寄存器中的活动对象开始,遍历所有的引用,标记找到的对象,这些GC线程会遍历堆上所有的可达(reachable)对象。在这个阶段,C4算法与其他并发标记器的工作方式非常相似。

C4算法的标记器与其他并发标记器的区别也是始于并发标记阶段的。在并发标记阶段中,如果应用程序线程修改未标记的对象,那么该对象会被放到一个队列中,以备遍历。这就保证了该对象最终会被标记,也因为如此,C4垃圾回收器或另一个应用程序线程不会重复遍历该对象。这样就节省了标记时间,消除了递归重标记(recursive remark)的风险。(注意,长时间的递归重标记有可能会使应用程序因无法获得足够的内存而抛出OOM错误,这也是大部分垃圾回收场景中的普遍问题。)


如果C4算法的实现是基于脏卡表(dirty-cardtables)或其他对已经遍历过的堆区域的读写操作进行记录的方法,那垃圾回收线程就需要重新访问这些区域做重标记。在极端条件下,垃圾回收线程会陷入到永无止境的重标记中—— 至少这个过程可能会长到使应用程序因无法分配到新的内存而抛出OOM错误。但C4算法是基于LVB(loadvalue barrier)实现的,LVB具有自愈能力,可以使应用程序线程迅速查明某个引用是否已经被标记过了。如果这个引用没有被标记过,那么应用程序会将其添加到GC队列中。一旦该引用被放入到队列中,它就不会再被重标记了。应用程序线程可以继续做它自己的事。

脏对象(dirty object)和卡表(cardtable)

由于某些原因(例如在一个并发垃圾回收周期中,对象被修改了),垃圾回收器需要重新访问某些对象,那么这些对象脏对象(dirtyobject)。这这些脏对象,或堆中脏区域的引用,通过会记录在一个专门的数据结构中,这就是卡表。

在C4算法中,并没有重标记(re-marking)这个阶段,在第一次便利整个堆时就会将所有可达对象做标记。因为运行时不需要做重标记,也就不会陷入无限循环的重标记陷阱中,由此而降低了应用程序因无法分配到内存而抛出OOM错误的风险。

C4算法中的重定位—— 应用程序线程与GC的协作

C4算法中,*重定位阶段(reloacationphase)*是由GC线程和应用程序线程以协作的方式,并发完成的。这是因为GC线程和应用程序线程会同时工作,而且无论哪个线程先访问将被移动的对象,都会以协作的方式帮助完成该对象的移动任务。因此,应用程序线程可以继续执行自己的任务,而不必等待整个垃圾回收周期的完成。

正如Figure 2所示,碎片内存页中的活动对象会被重定位。在这个例子中,应用程序线程先访问了要被移动的对象,那么应用程序线程也会帮助完成移动该对象的工作的初始部分,这样,它就可以很快的继续做自己的任务。虚拟地址(指相关引用)可以指向新的正确位置,内存也可以快速回收。


如果是GC线程先访问到了将被移动的对象,那事情就简单多了,GC线程会执行移动操作的。如果在重映射阶段(re-mappingphase,后续会提到)也访问这个对象,那么它必须检查该对象是否是要被移动的。如果是,那么应用程序线程会重新定位这个对象的位置,以便可以继续完成自己任务。(对大对象的移动是通过将该对象打碎再移动完成的。)当所有的活动对象都从某个内存也中移出后,剩下的就都是垃圾数据了,这个内存页也就可以被整体回收了,正如Figure2中所示。

关于清理

在C4算法中并没有清理阶段(sweepphase),因此也就不需要这个在大多数垃圾回收算法中比较常用的操作。在指向被移动的对象的引用都更新为指向新的位置之前,from页中的虚拟地址空间必须被完整保留。所以C4算法的实现保证了,在所有指向这个页的引用处于稳定状态前,所有的虚拟地址空间都会被锁定。然后,算法会立即回收物理内存页。

很明显,无需执行stop-the-world式的移动对象是有很大好处的。由于在重定位阶段,所有活动对象都是并发移动的,因此它们可以被更有效率的放入到相邻的地址中,并且可以充分的压缩。通过并发执行重定位操作,堆被压缩为连续空间,也无需挂起所有的应用程序线程。这种方式消除了Java应用程序访问内存的传统限制。

经过上述的过程后,如何更新引用呢?如何实现一个非stop-the-world式的操作呢?

C4算法中的重映射

在重定位阶段,某些指向被移动的对象的引用会自动更新。但是,在重定位阶段,那些指向了被移动的对象的引用并没有更新,仍然指向原处,所以它们需要在后续完成更新操作。C4算法中的重映射阶段(re-mappingphase)负责完成对那些活动对象已经移出,但仍指向那些的引用进行更新。当然,重映射也是一个协作式的并发操作。

Figure 3中,在重定位阶段,活动对象已经被移动到了一个新的内存页中。在重定位之后,GC线程立即开始更新那些仍然指向之前的虚拟地址空间的引用,将它们指向那些被移动的对象的新地址。垃圾回收器会一直执行此项任务,直到所有的引用都被更新,这样原先虚拟内存空间就可以被整体回收了。


但如果在GC完成对所有引用的更新之前,应用程序线程想要访问这些引用的话,会出现什么情况呢?在C4算法中,应用程序线程可以很方便的帮助完成对引用进行更新的工作。如果在重映射阶段,应用程序线程访问了处于非稳定状态的引用,它会找到该引用的正确指向。如果应用程序线程找到了正确的引用,它会更新该引用的指向。当完成更新后,应用程序线程会继续自己的工作。

协作式的重映射保证了引用只会被更新一次,该引用下的子引用也都可以指向正确的新地址。此外,在大多数其他GC实现中,引用指向的地址不会被存储在该对象被移动之前的位置;相反,这些地址被存储在一个堆外结构(off-heapstructure)中。这样,无需在对所有引用的更新完成之前,再花费精力保持整个内存页完好无损,这个内存页可以被整体回收。

C4算法真的是无暂停的么?

在C4算法的重映射阶段,正在跟踪引用的线程仅会被中断一次,而这次中断仅仅会持续到对该引用的检索和更新完成,在这次中断后,线程会继续运行。相比于其他并发算法来说,这种实现会带来巨大的性能提升,因为其他的并发立即回收算法需要等到每个线程都运行到一个安全点(safepoint),然后同时挂起所有线程,再开始对所有的引用进行更新,完成后再恢复所有线程的运行。

对于并发压缩垃圾回收器来说,由于垃圾回收所引起的暂停从来都不是问题。在C4算法的重定位阶段中,也不会有再出现更糟的碎片化场景了。实现了C4算法的垃圾回收器也不会出现背靠背(back-to-back)式的垃圾回收周期,或者是因垃圾回收而使应用程序暂停数秒甚至数分钟。如果你曾经体验过这种stop-the-world式的垃圾回收,那么很有可能是你给应用程序设置的内存太小了。你可以试用一下实现了C4算法的垃圾回收器,并为其分配足够多的内存,而完全不必担心暂停时间过长的问题。

评估C4算法和其他可选方案

像往常一样,你需要针对应用程序的需求选择一款JVM和垃圾回收器。C4算法在设计之初就是无论堆中活动数据有多少,只要应用程序还有足够的内存可用,暂停时间都始终保持在较低的水平。正因如此,对于那些有大量内存可用,而对响应时间比较敏感的应用程来说,选择实现了C4算法的垃圾回收器正是不二之选

而对于那些要求快速启动,内存有限的客户端应用程序来说,C4就不是那么适用。而对于那些对吞吐量有较高要求的应用程序来说,C4也并不适用。真正能够发挥C4威力的是那些为了提升应用程序工作负载而在每台服务器上部署了4到16个JVM实例的场景。此外,如果你经常要对垃圾回收器做调优的话,那么不妨考虑一下使用C4算法。综上所述,当响应时间比吞吐量占有更高的优先级时,C4是个不错的选择。而对那些不能接受长时间暂停的应用程序来说,C4是个理想的选择。

如果你正考虑在生产环境中使用C4,那么你可能还需要重新考虑一下如何部署应用程序。例如,不必为每个服务器配置16个具有2GB堆的JVM实例,而是使用一个64GB的JVM实例(或者增加一个作为热备份)。C4需要尽可能大的内存来保证始终有一个空闲内存页来为新创建的对象分配内存。(记住,内存不再是昂贵的资源了!)如果你没有64GB,128GB,或1TB(或更多)内存可用,那么分布式的多JVM部署可能是一个更好的选择。在这种场景中,你可以考虑使用OracleHotSpot JVM的G1垃圾回收器,或者IBM JVM的平衡垃圾回收策略(BalancedGarbage Collection Policy)。下面将对这两种垃圾回收器做简单介绍。

Gargabe-First(G1)垃圾回收器:G1垃圾回收器是新近才出现的垃圾回收器,是OracleHotSpot JVM的一部分,在最近的JDK1.6版本中首次出现。在启动OracleJDK时附加命令行选项-XX:+UseG1GC,可以启动G1垃圾回收器。与C4类似,这款标记-清理(mark-and-sweep)垃圾回收器也可作为对低延迟有要求的应用程序的备选方案。G1算法将堆分为固定大小区域,垃圾回收会作用于其中的某些区域。在应用程序线程运行的同时,启用后台线程,并发的完成标记工作。这点与其他并发标记算法相似。

G1增量方法可以使暂停时间更短,但更频繁,而这对一些力求避免长时间暂停的应用程序来说已经足够了。另一方面,使用G1垃圾回收器需要针对应用程序的实际需求做长时间的调优,而其GC中断又是stop-the-world式的。所以对那些对低延迟有很高要求的应用程序来说,G1并不是一个好的选择。进一步说,从暂停时间总长来看,G1长于CMS(OracleJVM中广为人知的并发垃圾回收器)。

G1使用拷贝算法完成部分垃圾回收任务。这样,每次垃圾回收器后,都会产生完全可用的空闲空间。G1垃圾回收器定义了一些区域的集合作为年轻代,剩下的作为老年代。G1已经吸引了足够多的注意,引起了不小的轰动,但是它真正的挑战在于如何应对现实世界的需求。正确的调优就是其中一个挑战—— 回忆一下,对于动态应用程序负载来说,没有永远“正确的调优”。一个问题是如何处理与分区大小相近的大对象,因为剩余的空间会成为碎片而无法使用。还有一个性能问题始终困扰着低延迟垃圾回收器,那就是垃圾回收器必须管理额外的数据结构。就我来说,使用G1的关键问题在于如何解决stop-the-world式垃圾回收器引起的暂停。Stop-the-world式的垃圾回收引起的暂停使任何垃圾回收器的能力都受制于堆大小和活动数据数量的增长,对企业级Java应用程序的伸缩性来说是一大困扰。

IBMJVM的平衡垃圾回收策略(Balanced Garbage CollectionPolicy):IBM JVM的平衡垃圾回收(BalancedGarbage Collection BGC)策略通过在启动IBM JDK时指定命令行选项-Xgcpolicy:balanced来启用。乍一看,BGC很像G1,它也是将Java堆划分成相同大小的空间,称为区间(region),执行垃圾回收时会对每个区间单独回收。为了达到最佳性能,在选择要执行垃圾回收的区间时使用了一些启发性算法。BGC中关于代的划分也与G1相似。

IBM的平衡垃圾回收策略仅在64位平台得到实现,是一种NUMA架构(Non-UniformMemory Architecture),设计之初是为了用于具有4GB以上堆的应用程序。由于拷贝算法或压缩算法的需要,BGC的部分垃圾回收工作是stop-the-world式的,并非完全并发完成。所以,归根结底,BGC也会遇到与G1和其他没有实现并发压缩选法的垃圾回收器相似的问题。

结论:回顾

C4是基于引用跟踪的、分代式的、并发的、协作式垃圾回收算法,目前只在AzulSystem公司的ZingJVM得到实现。C4算法的真正价值在于:

1、消除了重标记可能引起的重标记无限循环,也就消除了在标记阶段出现OOM错误的风险。

2、压缩,以自动、且不断重定位的方式消除了固有限制:堆中活动数据越多,压缩所引起的暂停越长。

3、垃圾回收不再是stop-the-world式的,大大降低垃圾回收对应用程序响应时间造成的影响。

4、没有了清理阶段,降低了在完成GC之前就因为空闲内存不足而出现OOM错误的风险。

5、内存可以以页为单位立即回收,使那些需要使用较多内存的Java应用程序有足够的内存可用。

并发压缩是C4独一无二的优势。使应用程序线程GC线程协作运行,保证了应用程序不会因GC而被阻塞。C4将内存分配和提供足够连续空闲内存的能力完全区分开。C4使你可以为JVM实例分配尽可能大的内存,而无需为应用程序暂停而烦恼。使用得当的话,这将是JVM技术的一项革新,它可以借助于当今的多核、TB级内存的硬件优势,大大提升低延迟Java应用程序的运行速度。如果你不介意一遍又一遍的调优,以及频繁的重启的话,如果你的应用程序适用于水平部署模型的话(即部署几百个小堆JVM实例而不是几个大堆JVM实例),G1也是个不错的选择。对于动态低延迟启发性自适应(dynamiclow-latency heuristic adaption)算法而言,BGC是一项革新,JVM研究者对此算法已经研究了几十年。该算法可以应用于较大的堆。而动态自调优算法(dynamic self-tuning algorithm)的缺陷是,它无法跟上突然出现的负载高峰。那时,你将不得不面对最糟糕的场景,并根据实际情况再分配相关资源。

最后,为你的应用程序选择最适合的JVM和垃圾回收器时,最重要的考虑因素是应用程序中吞吐量和暂停时间的优先级次序。你想把时间和金钱花在哪?从纯粹的技术角度说,基于我十年来对垃圾回收的经验,我一直在寻找更多关于并发压缩的革新性技术,或其他可以以较小代价完成移动对象或重定位的方法。我想影响企业级Java应用程序伸缩性的关键就在于并发性。


0 0