Java内存管理基础
2016 年 02 月 20 日
jvm

    众所周之,Java最强大的地方之一就是其自动内存管理机制,也就不需要像C/C++语言那样,需要开发人员手动进行内存管理,需要时刻关注内存应该何时正确地被释放自动内存管理机制将程序员从内存管理的凶险解脱出来,这将大大提升Java开发人员的生产力,但作为Java开发人员,即使不用直接面对内存管理,但却应该对JVM的内存管理机制有所掌握,对自己的编程能力也会有潜移默化的提升。本文将对JVM内存管理进行一番解读,实现主要是HotSpot Virtual Machine(J2SE 1.5)

  • 手动内存管理 VS 自动内存管理

  • 内存管理的一个主要目标就是识别那些分配的对象不再需要,并且释放掉其所占用的内存空间,以供后续的内存分配使用。有一些编程语言,如C++,开发人员手动管理内存,这将很容易出现很多引起程序错误和崩溃的问题,比如悬挂引用内存泄漏等,因此开发人员需要花费大量时间来调试和修复。

    现在大多数现代面向对象编程语言中,已被广泛应用的另一种内存管理方法:自动内存管理, 其通过垃圾收集器(Garbage Collector)来实现内存管理。只要对象还有被引用,垃圾收集器将不会对其进行收集,从而避免了悬挂引用问题,一旦对象不再被引用,垃圾收集器将会在合适的时候对其进行收集,并释放对应的内存空间,因此避免了内存泄漏问题。

  • 垃圾收集原理

  • 通常垃圾收集器总体需要负责三件事情: 为对象分配内存保证任何被引用的对象仍在内存中清理程序中不可达的对象。当为新对象分配内存时,需要在堆中找出具有一定大小且未被使用的内存块,这是一件比较困难的事情,大多数动态内存分配算法的主要困难在于避免内存碎片化,并且同时需要保证内存分配和释放的性能

  • 理想的垃圾收集器

  • 垃圾收集器必须是安全且全面的。也就是说,存活对象绝不能被错误释放,并且不允许垃圾对象经过多个垃圾收集周期仍然未被释放

    垃圾收集器也应该能够有效地运行,以至于不应该导致应用暂停太长时间。然而,对于大多数计算机相关的系统,都需要在时间空间和频率等因素作出一些权衡。例如,若JVM堆设置过小,虽然每次垃圾收集会变得更快,但是总的垃圾收集次数也会更多;若JVM堆设置过大,虽然总的垃圾收集次数有所减少,但每次垃圾收集耗时也会更长。

    垃圾收集器也需要针对内存碎片作一些处理,在垃圾收集过程中,对一些小对象回收后,有可能会产生很多空间较小的内存块,这些小内存块不能容纳下大对象,最坏的情况也就是即便内存还未使用完,但却不能再分配对象。清理碎片的过程叫压缩(Compaction)

    另外,可扩展性也是相当重要的,在多处理器系统中,对于多线程应用,内存分配不应该成为可扩展性瓶颈收集操作也不应该成为这样的瓶颈。

  • 如何设计或选择垃圾收集器

  • 在设计或选择垃圾收集器时,通常需要作出一些选择:

  • 串行 VS 并行

  • 对于串行收集,同一时刻只能作一件事,比如,即使有多个CPU可用时,只有其中一个才能用于执行垃圾收集。当使用并行收集时,垃圾收集任务将被分成若干个子任务,并同时在不同的CPU上执行,并行执行使得垃圾收集执行得更快,但这也导致了额外的复杂度和内存碎片问题

  • 并发 VS Stop-the-world

  • stop-the-world垃圾收集正在执行时,整个应用将被挂起,这对于强交互应用(要求低暂停时间短)并不友好,比如Web应用。相比stop-the-world,有一些垃圾收集器的收集任务可以与应用同时执行。典型的并发垃圾收集器会并发执行大部分垃圾工作,但仍然会有短暂的stop-the-world。显然,Stop-the-world垃圾收集并发垃圾收集更简单,因为在整个收集过程中,JVM堆会被冻结,因而对象的状态将不会发生改变,但缺点也很明显,收集过程会挂起应用。相对于使用并发垃圾收集器时,应用暂停时间将被缩短,但需要额外注意一些细节,垃圾收集器在操作对象时,与此同时,有可能应用程序也在操作相同的对象,为了保证垃圾收集器不会错误地回收对象,则要增加一些额外影响性能的工作,并且需要更大的堆内存。

  • 压缩 VS 复制

  • 垃圾收集器已经决定哪些对象存活及哪些对象需要回收之后,可以进行内存压缩,即将所有存活的对象移动到一起(连续),然后完全释放掉需要回收的内存。内存压缩之后,就能够很容易且很快地将新对象分配在第一块空间内存中了,一个简单的指针就能跟踪下一个内存分配的位置。与内存压缩型收集器相比,非内存压缩收集器则利用额外的内存空间来回收垃圾对象,即不会像内存压缩型收集器一样将所有存活对象移动一起,以便释放出一大块空闲内存,这样的好处是垃圾收集时间更短,缺点则是潜在的内存碎片问题。通常来说,从堆中分配对象比压缩堆更昂贵,因为为了分配新对象,可能有必要在堆中寻找一段足够大的连续空间。第三个可选的则是复制收集器,即复制存活的对象到另一个内存区,这样的好处是保持源内存区是空闲的,可以很容易且很快地用于后续的对象分配,但缺点是需要额外的时间来执行复制操作额外的内存空间

  • 性能指标

  • 通常会使用几个指标来评估一个垃圾收集器的性能,包括:

    指标类型 描述
    吞吐量 应用程序执行时间 / (应用程序执行时间 + 垃圾收集执行时间)
    垃圾收集开销 垃圾收集执行时间 / (应用程序执行时间 + 垃圾收集执行时间)
    停顿时间 当垃圾收集正在执行时,应用程序被挂起的总时间
    垃圾收集频率 相对于应用程序,垃圾收集多久发生一次
    占用空间 垃圾收集所占用的内存大小
    及时性 一个对象从被标记为垃圾对象到该对象所占空间变得可用的时间

    对于交互式应用,可能要求更低的停顿时间,然而对于非交互式应用总执行时间显得更重要。一个实时应用程序将要求在暂停时间垃圾收集占用时间都具有较小的值。占用较少空间则可能是一些小的个人电脑或嵌入式系统所关心的主要问题。

  • 分代回收

  • 当使用分代回收时,JVM堆将被分为不同的分代(容纳不同年龄的对象)。比如,最广泛使用的分代有:年轻对象老对象

    在不同分代上,可以使用不同的算法执行垃圾回收,每种算法会针对不同的分代作特定优化,之所以采用分代垃圾回收,主要利用了以下特性(称作弱代假设):

    大多数对象不会被长期引用,即存活时间短
    很少有老对象会引用年轻对象

    年轻代回收触发会比较频繁,但执行很快,因为年轻代通常内存空间较小,且可能包含了大量不再被引用的对象。而经过几次年轻代回收存活下来的对象,将晋升到老年代老年代通常比年轻代大很多,其内存占用率会慢慢增加,因此触发不会很频繁,但每次耗时会比较长,如图:

    由于年轻代回收触发相对频繁,因此年轻代更倾向于使用收集速度快的算法。而老年代通常使用空间高效的收集算法,因为老年代占用了整个JVM堆的大部分,并且老年代收集算法还能较好地处理内存碎片问题。

  • HotSpot JVM中的垃圾收集器

  • HotSpot JVM中包含了四个垃圾收集器,这些收集器都是分代的,下面将一一介绍。

  • HotSpot分代

  • HotSpot JVM中堆被分为了三个代:

    分代类型 描述
    年轻代 大多数新对象将被分配在年轻代上。
    老年代 经过若干次年轻代垃圾回收存活下来的对象会晋升到老年代,或者有些大对象会直接分配到老年代上。
    永久代 存放一些类或方法等元数据。

    年轻代由1个Eden区和2个Survivor区组成,如下图。大多数新对象会首先被分配在Eden区(但某些大对象会直接分配到老年代),Survivor区用于存放至少经历过一次年轻代收集的对象,这些对象在被晋升到老年代之前,也可能被收集,这些对象将存放在其中一个Survivor区(From区)中,另一个Survivor区(To区)直到下一次收集前将保持为清空状态

  • 垃圾收集类型

  • 年轻代被填充满后,会触发年轻代垃圾回收(也称为Minor GC)。当老年代永久代填充满后,会触发完全垃圾回收(也称为Major GC),这时所有代都会执行垃圾回收,如果内存压缩被触发,将在不同代上单独执行。

    有时,由于老年代太满而不能接受来自年轻代晋升的老对象,这种情况下,老年代垃圾收集算法将在整个JVM堆上执行(年轻代收集算法不会执行)。(除了当老年代使用CMS垃圾收集器时,因为CMS垃圾收集器不能收集年轻代)。

  • 快速分配

  • 从下文对垃圾收集器的描述中可以看出,在多数情况下,内存中都有大块的连续空闲空间用以分配新对象。这种情形下使用简单的bump-the-pointer技术,将使得分配操作效率很高。按照这种技术,JVM内部维护一个指针allocatedTail,它始终指向先前已分配对象的尾部,当新对象分配请求到来时,只需检查代中剩余空间是否足以容纳该对象,若成功分配对象,则会更新allocatedTail指针,并初始化对象。

    对于多线程应用,对象分配操作必须保证线程安全。如果使用全局锁来保证线程安全内存分配势必成为瓶颈并降低性能。HotSpot JVM内部使用了Thread-Local Allocation Buffers (TLABs)技术来提升多线程内存分配的吞吐量TLAB作为线程私有的内存区,分配新对象时不需要额外加锁,使得分配操作很快。偶尔当线程填充满了TLAB后,JVM会重新分配一个TLAB,此时则必须使用同步。同时,为了减少TLAB所带来的空间消耗,还使用了一些其他技术,例如,分配器能够把TLAB的平均大小限制在Eden区的1%以下。通过组合使用TLABbump-the-pointer线性分配技术,就使得一次内存分配足够高效,仅10条机器指令。

  • 串行收集器(Serial Collector)

  • 使用串行收集器时,年轻代收集老年代收集都以stop-the-world方式串行执行(单个CPU),收集过程中应用程序将被挂起。

  • 年轻代使用串行收集器

  • 下面以图展示年轻代使用串行收集器时发生GC的过程:

    Eden区未填满时,并未发生GC,两个Survivor区老年代均为空:
    Eden区逐渐消耗内存,已没有足够内存容纳新对象时,则触发一次Minor GC,在Eden区标记出存活对象垃圾对象,然后将存活对象复制到Survivor From区,并将存活对象的生存年龄+1,最后清空Eden区:
    Eden区继续分配新对象,直到再一次内存不足,触发Minor GC,在Eden区Survivor From区标记出存活对象垃圾对象,然后将存活对象复制到Survivor To区,并将存活对象的生存年龄+1,最后清空Eden区Survivor From区,并将Survivor From区Survivor To区置换:
    在不断的Minor GC后,存活对象的生存年龄逐渐增加至晋升年龄阈值(可通过-XX:MaxTenuringThreshold=n设置)时,存活对象将晋升到老年代:
    除了上述这种正常的老对象晋升情况外,其他一些情况也可能将存活对象晋升到老年代,如当Survivor To区空间不足已容纳存活对象时(可通过–XX:TargetSurvivorRatio设置Survivor区可使用率,当存放的对象超过这个百分比时,则对象会向晋升到老年代),存活对象将直接复制到老年代(即使存活对象年龄没有达到晋升年龄阈值),又如当分配的新对象太大(可通过-XX:PetenureSizeThreshold设置大对象阈值,当对象的大小超过这个值时,将直接在年老代分配,该参数只对串行收集器年轻代并行收集器有效,并行回收收集器不识别这个参数),导致Eden区不能容下,也会直接晋升到老年代:
  • 老年代使用串行收集器

  • 当使用串行收集器时,老年代永久代使用标记-清除-压缩(Mark-Sweep-Compact)算法执行垃圾回收。在标记阶段,收集器标识出存活对象。在清除阶段,收集器标识出垃圾对象。然后,收集器执行滑动压缩过程,将存活对象移到老年代起始端(永久代亦如此),然后留出一块连续的内存块,这样后续就能在老年代永久代使用bump-the-pointer技术,加快内存分配:

  • 何时使用串行收集器?

  • 对于大多数运行在客户端模式的应用,并不要求低暂停时间,因此可以使用串行收集器可作为垃圾收集器。按照现今的硬件水平,串行收集器可以高效地管理许多使用64MB堆空间、最长停顿时间不能超过半秒的重要应用。

  • 选择串行收集器

  • 在J2SE 5.0版本中,对于非服务器级机器而言,串行收集器作为默认的垃圾收集器;对于其他硬件平台,则可以通过命令行选项-XX:+UseSerialGC进行显示的选用。

  • 并行收集器(Parallel Collector)

  • 目前,许多Java应用都运行在大都包含很大物理内存和多个CPU的平台上。并行收集器,也被称作吞吐量收集器,被开发出来的主要目的就是为了充分利用CPU资源,而不是只让一个CPU去执行垃圾收集,而其他CPU却空闲着。

  • 年轻代使用并行收集器

  • 并行收集器作为串行收集器的并行版本实现,但仍然是一个stop-the-world复制收集器,只不过利用了多个CPU,并行执行年轻代回收,这样就减少了垃圾回收占用时间,从而提高了应用吞吐量,如图所示:

  • 老年代使用并行收集器

  • 并行收集器老年代使用的垃圾收集算法和串行收集器一样,使用的是标记-清除-压缩(Mark-Sweep-Compact)算法。

  • 何时使用并行收集器

  • 能够受益于并行收集器的应用程序,必定运行在多CPU机器上,并且对停顿时间不能有特别要求,因为持续时间较长的老年代收集还是会偶尔会发生。适于采用并行收集器的典型应用包括批处理记帐工资单科学计算等。你可能更倾向于选择并行压缩收集器(Parallel Compacting Collector)(见下文)而不是并行收集器,因为并行压缩收集器对所有代(而不只是年轻代)的收集都采用并行方式进行。

  • 选择并行收集器

  • 在J2SE 5.0版本中,对于服务器级硬件而言,将使用并行收集器作为默认的垃圾收集器;对于其他硬件平台,则可以通过命令行选项-XX:+UseParallelGC进行显示的选用。

  • 并行收集器 VS 串行收集器

  • 可通过以下程序,直观感受下并行收集器串行收集器性能比较:

    public class SerialVSParallel {
    
        // 1M
        private static int M_1 = 1024 * 1024;
    
        public static void main(String[] args) {
    
            // 保存存活对象
            Set<byte[]> lives = new HashSet<byte[]>();
            byte[] dead = null;
    
            // 分配 0.001M * 100
            for (int i=0; i<100 * 1000; i++){
                if (i % 3 == 0){
                    lives.add(new byte[M_1 / 1000]);
                } else {
                    dead = new byte[M_1 / 1000];
                }
            }
            System.out.println("allocated 100m");
    
            // 分配 0.001M * 100
            for (int i=0; i<100 * 1000; i++){
                if (i % 3 == 0){
                    lives.add(new byte[M_1 / 1000]);
                } else {
                    dead = new byte[M_1 / 1000];
                }
            }
            System.out.println("allocated 100m");
    
            // 分配 2M * 50
            for (int i=0; i<50; i++){
                if (i % 3 == 0){
                    lives.add(new byte[M_1 * 2]);
                } else {
                    dead = new byte[M_1 * 2];
                }
            }
            System.out.println("allocated 100m");
        }
    }
        

    分别使用串行收集器并行收集器:

    使用串行收集器时:
    -Xms1g -Xmx1g -XX:+UseSerialGC -XX:+PrintGCDetails -XX:+PrintGCDateStamps 
            
    2016-03-26T12:08:57.121-0800: [GC2016-03-26T12:08:57.121-0800: [DefNew: 279027K->34944K(314560K), 0.1057200 secs] 279027K->92679K(1013632K), 0.2057840 secs] [Times: user=0.09 sys=0.06, real=0.20 secs]
            
    使用并行收集器时:
    -Xms1g -Xmx1g -XX:+UseParallelGC -XX:+PrintGCDetails -XX:+PrintGCDateStamps 
            
    2016-03-26T12:10:35.951-0800: [GC [PSYoungGen: 260742K->43488K(306176K)] 260742K->90240K(1005568K), 0.0524450 secs] [Times: user=0.08 sys=0.07, real=0.05 secs]
            
    -Xms1g -Xmx1g -XX:+UseParallelGC -XX:ParallelGCThreads=1 -XX:+PrintGCDetails -XX:+PrintGCDateStamps 
            
    2016-03-26T12:13:55.803-0800: [GC [PSYoungGen: 260742K->43504K(306176K)] 260742K->90224K(1005568K), 0.1011770 secs] [Times: user=0.06 sys=0.04, real=0.10 secs] 
            

    的确,并行收集器通过多线程并行的方式,显著提升了GC性能。

  • 并行压缩收集器(Parallel Compacting Collector)

  • 并行压缩收集器在J2SE 5.0 Update 6中引入,与并行收集器的区别在于,其对老年代的收集它使用了全新的算法(并行压缩收集器终将取代并行收集器)。

  • 年轻代使用并行压缩收集器

  • 年轻代使用并行压缩收集器时,收集算法与并行收集器时一样。

  • 老年代使用并行压缩收集器

  • 使用并行压缩收集器时,对老年代永久代将采用stop-the-world不完全并行的滑动压缩的方式进行垃圾回收。并行压缩收集器会将每个代逻辑上分为大小固定的区域,每个区域的相关信息保存在收集器维护的内部数据结构中,随后会执行三个阶段:标记(Marking)汇总(Summary)压缩(Compaction)

    标记(Marking)阶段根引用集(根存活对象集)被划分给多个垃圾收集线程,然后以并行的方式对存活对象进行追踪和标记。在对象被标记为存活时,该对象所在区域的数据也将被同步更新,以反映该存活对象的大小和位置信息

    汇总(Summary)阶段,操作不再基于对象,而是区域。考虑到之前垃圾收集压缩的结果,每个代空间中位于左侧的某一部分通常是比较密集的,主要包含了存活对象。能从这些密集区块中回收的内存空间已经不多,使得它们并不值得被压缩。因此汇总阶段的首要任务就是检查区域的密集度,从最左边一个区域开始,直到找到这样的一个区域,使得在该区域及其右侧所有区域中可被回收的内存空间抵得上对它们进行压缩的成本,该区域左侧的所有区域就被称为密集前置区,没有对象会被移入其中,该区域及其右侧所有区域会被压缩,以消除所有死空间汇总阶段的下一个任务就是计算并保存每个被压缩区域中存活数据的首字节在压缩后的新位置。需要注意的是:汇总阶段在目前被实现为一个串行阶段,这也是不完全并行的由来,并行实现也是可能的,只是与标记压缩阶段的并行化相比,它对性能的影响不大。

    压缩(Compaction)阶段垃圾收集线程使用汇总阶段的数据确定需要被填充的区域,然后它们就可以并行地把对象拷贝到这些区域中,而不再需要额外的同步。这就产生了一个堆,堆空间的一端塞满了存活对象,而另一端则是一个单一且连续的空闲内存块

  • 何时使用并行压缩收集器

  • 并行收集器一样,并行压缩收集器同样有益于在多CPU机器上运行的应用程序。除此之外,老年代收集的并行化操作方式还减少了停顿时间,使得并行压缩收集器并行收集器更为适合那些有停顿时间限制的应用。不过,对于运行在大型共享主机(SunRays)上的应用来说,并行压缩收集器也许并不太合适,因为任何单一应用都不应长时间独占几个CPU,在这样的机器上,要么考虑通过命令行选项-XX:ParallelGCThreads=n减少垃圾收集线程的数目,要么考虑选择一种不同的收集器。

  • 选择并行压缩收集器

  • 并行压缩收集器只能通过命令行选项-XX:+UseParallelOldGC进行显示的选用。

  • 并发-标记-清理收集器(Concurrent Mark-Sweep (CMS) Collector)

  • 对于许多应用来说,端到端的吞吐量并不像快速响应时间那么重要。通常来讲,对年轻代的收集并不会引起太长时间的停顿。但是对老年代的收集,虽然不常发生,却可能导致停顿时间过长的状况,特别在堆空间很大时尤其明显。为了解决这个问题,HotSpot JVM提供了一个名叫并发-标记-清理(CMS)的收集器,它也被称为低延迟收集器

  • 年轻代使用CMS收集器

  • CMS收集器收集年轻代时,同并行收集器

  • 老年代使用CMS

  • 采用CMS收集器收集老年代时,大部分收集任务与应用程序并发执行

    CMS收集器的收集周期开始于初始标记(Initial Marking),它采用stop-the-world方式进行,用于确定根引用集(根存活对象集)。随后进入并发标记(Concurrent Marking)阶段,完成对所有存活对象的追踪和标记,以并发并行方式进行。由于在并发标记过程中应用程序正在执行并可能更新了一些对象的引用,因此并发标记过程结束时并非所有活动对象都已确保被标记出来。为了处理这种情况,应用程序会再次暂停,收集过程进入再标记(Remarking)阶段,它采用并行、stop-the-world方式进行,通过对并发标记过程中被修改对象的再次遍历,最终完成整个标记过程。

    再标记(Remarking)阶段完成后,所有存活对象都已确保被标记,随后进入并发清理阶段,它采用串行、并发方式进行,就地回收所有垃圾对象。下图展示了串行的标记-清理-压缩收集器CMS收集器在收集老年代时的区别:

    因为某些任务(例如再标记(Remarking)过程中对被修改对象的再次遍历)增加了收集器的工作量,CMS收集器的总体开销自然会增大,但对于大多数试图减少停顿时间的收集器来说,这是一种典型的折衷方案。

    CMS收集器是唯一一个不使用内存压缩(Compaction)技术的收集器。也就是说,在垃圾对象所占用的空间被释放以后,收集器并不会把存活对象全部整理到代空间的某一端去。见下图:

    这种方式节省了回收时间,但却因为空闲空间不再连续,收集器也就不再可能只使用一个简单指针即可指示出可分配给新对象的空闲空间位置,相反,它现在需要使用空闲空间列表。也就是说,收集器创建并通过一组列表把内存中尚未分配的区域连接起来,每当有对象需要分配空间时,通过链表查询到一块足以放下该对象的空闲区域。与使用bump-the-pointer技术相比,老年代中的分配操作变得更加昂贵。同时这也给年轻代收集带来了额外的开销,因为在其收集过程中每晋升一个对象都会触发一次老年代中的分配操作。

    CMS收集器的另一个缺点是与其他收集器相比它需要更大的堆空间。一方面,由于在标记(Marking)阶段应用程序被允许运行,它就可能继续分配内存,从而可能使老年代空间不断地增长,另一方面,虽然标记(Marking)阶段完成后所有存活对象都已确保被标记,但是在标记过程中一些对象却可能变为垃圾对象,而且直到下次老年代收集之前它们不会被回收,这样的对象也被称为浮动垃圾

    CMS收集器的最后一个缺点是由于缺乏压缩,这可能引发碎片化问题。为了处理碎片化,CMS收集器会跟踪对象的常用大小,评估未来内存分配需求,并为满足需求还可能分割或合并空闲内存块

    不像其他垃圾收集器CMS收集器并不是等到老年代填满后才启动老年代收集,而是尝试尽早启动老年代收集,以便在老年代被填满之前收集过程可以完成。否则的话,CMS收集器将采用在串行和并行收集器中使用的标记-清理-压缩算法(尽管该算法也使用stop-the-world方式),这将更耗时。为避免这种情况的发生,CMS收集器对之前收集所耗时间和代空间填满所耗时间进行统计,并据此统计信息确定收集启动时间。另外,当老年代的空间占用率超过启动占用率(initiating occupancy)时,CMS收集器也将启动一次收集。启动占用率的值可以通过命令行选项-XX:CMSInitiatingOccupancyFraction=n进行设定,其中n表示年长代空间大小的百分比。缺省值为68。

    总体来说,与并行收集器相比,CMS收集器(有时甚至显著地)减少了老年代收集的停顿时间,而代价是略有增加的年轻代收集的停顿时间(想一想年轻代对象晋升至老年代时,老年代需要通过链表找到一块足够大的空闲内存块,而不是像并行收集器使用了bump-the-pointer技术)、吞吐量的一些损失额外的堆空间需求

  • 增量模式

  • CMS收集器可以采用让并发阶段增量完成的模式运行。这种模式,通过对并发阶段的周期性暂停把CPU让给应用程序,以减少并发阶段持续时间过长所带来的不利影响。收集工作被分成许多小的时间块,它们在年轻代收集的间歇期被调度,当应用程序既需要CMS收集器提供的低停顿时间,又只能在很少的CPU(1到2个)上运行时,这个特性就相当有用。

  • 何时使用CMS收集器?

  • 如果应用程序需要更短的垃圾收集停顿时间并且能够承担在运行时和垃圾收集器共享处理器资源,那么就可以使用CMS收集器(由于其并发性,在垃圾收集过程中CMS收集器将和应用程序抢夺CPU周期)。通常来说,具有长时间存活的数据集并且运行在2个或多个CPU上的应用程序,更容易受益于CMS收集器的使用,一个典型的例子就是WEB应用。对于任何需要低停顿时间的应用程序来说,CMS收集器都值得考虑。对于老年代大小适中并且运行在单一处理器上的交互式应用程序来说,使用CMS收集器同样可能取得不错的效果。

  • 选择CMS收集器

  • CMS收集器只能通过命令行选项-XX:+UseConcMarkSweepGC进行显示的选用。如果希望CMS收集器在增量模式下运行,还需要通过命令行选项-XX:+CMSIncrementalMode启用该模式。

  • HotSpot JVM中一些自动选择机制和调优

  • 在J2SE 5.0版本中, HotSpot JVM会根据应用程序运行时所在的平台和操作系统,来设置默认的垃圾收集器, 堆大小, client或server VM类型。这些自动选择机制将更好地匹配不同应用类型的需求,与比之前的版本相比,却只需要更少的命令行参数,这正是JVM的工程学精髓。

  • JVM默认选择的垃圾收集器,堆大小和虚拟机类型

  • 首先需要了解下服务器级硬件的定义(适用于除了32位Windows之外的所有平台):

    拥有2个或以上的物理处理器
    拥有2GB或以上的物理内存

    对于服务器级硬件非服务器级硬件HotSpot JVM会作如下的默认选择:

    服务器级别 / 选项配置 VM类型 垃圾收集器 初始堆大小 最大堆大小
    服务器级硬件 server 并行垃圾收集器 物理内存/64(上限1G) 物理内存/4(上限1G)
    非服务器级硬件 client 串行垃圾收集器 4M 64M

    注意,在服务器级硬件上,无论用户是否手动设置-server-client,默认垃圾收集器均为并行垃圾收集器

  • 并行垃圾收集器调优

  • 在J2SE 5.0版本中, 对并行垃圾收集器加入了一些可选参数,用户可以通过配置一些可选项,来使应用达到预期的暂停时间吞吐量目标:

  • 最大暂停时间

  • 用户可以通过-XX:MaxGCPauseMillis=n设置在执行垃圾收集时,应用程序被暂停的最大时间(毫秒)。并行垃圾收集器会通过调整堆大小其他垃圾回收相关的参数来保证垃圾收集暂停时间小于n毫秒,这可能会降低应用程序的吞吐量,在某些情况下,暂停时间目标可能无法满足。

    最大暂停时间是针对不同代独立起作用的。比较典型的是,当最大暂停时间目标未达到时,垃圾收集器会减少分代的内存空间,来尝试达到该目标。MaxGCPauseMillis默认不会设置。

  • 吞吐量

  • 吞吐量可以通过-XX:GCTimeRatio=n参数来设置垃圾收集时间占比,该值为1 / (1 + n)。若设置-XX:GCTimeRatio=19,则垃圾收集时间占5%,若该目标未达到,垃圾收集器会增大分代的内存空间,以增加应用程序在两次垃圾收集之间的运行时间。GCTimeRatio默认为99,即垃圾收集时间占1%。

  • 空间占用

  • 最大暂停时间吞吐量目标均达到后,垃圾收集器会减少堆空间,来使其中一个目标无法达到(但总是吞吐量目标)。

  • 优先级

  • 并行垃圾收集器会优先尝试达成最大暂停时间目标,然后再尝试达成吞吐量目标,同样,前两个目标达成后,则会尝试达成空间占用

  • 建议

  • 对于垃圾收集器的选择,最简单的建议就是什么也不用做,尽量让JVM根据系统和平台自由选择,然后测试应用性能,若性能可接受,即暂停时间吞吐量都能达到预期,就可以不作任何配置。

    如果应用确实出现了与垃圾收集器相关的性能问题,最直接的想法应该是想想默认的垃圾收集器是否适合当前的应用。然后尝试选择认为合适的垃圾收集器,并测试应用性能是否达到预期。

  • 何时选用不同的垃圾收集器

  • 正如上面所说,当应用出现了与垃圾收集器相关的性能问题时,则可以选择不同的垃圾收集器进行性能测试,可以用如下的参数启用不同的垃圾收集器:

    启用串行收集器–XX:+UseSerialGC
    启用并行收集器–XX:+UseParallelGC
    老年代启用并行压缩收集器–XX:+UseParallelOldGC
    老年代启用并发标记清理收集器-XX:+UseConcMarkSweepGC
  • 堆大小

  • 通常默认的堆大小在生产环境是不够用的,需要作出一些调整,可以通过–Xmx来设置,建议可以将–Xms也设置和–Xmx一样,比如都设置为2G或4G等。若不是暂停时间过长问题,可将堆大小设置大一些,足够大的堆是影响垃圾收集性能最大的因素。

    当决定了使用的堆大小后,可以先使用默认的分代大小分配策略,进行性能观察,若未能达到预期,可以尝试调整不同分代的内存大小。影响垃圾收集的性能的第二个最有影响力的因素是堆中年轻代的占有比例。若不是发现老年代收集过于频繁或者暂停时间过长,可以分配多一些内存给年轻代。若垃圾收集器使用的串行收集器,年轻代大小不应该超过堆的一半。

    若垃圾收集器使用的是并行收集器时,最好设置预期的性能参数,而不是确切的堆大小或分代占用比例,让垃圾收集器自动动态地调整堆大小来满足预期的性能。

  • 并行收集器调优

  • 当使用并行垃圾收集器并行压缩垃圾收集器时,最直观的是定义一个吞吐量目标,剩下的就交给收集器自由调整来达到该目标。

    若堆已经增加到最大(Xmx),但仍未达到吞吐量目标,可以将堆大小设置到接近物理内存,若最后仍不能达到吞吐量目标,则该吞吐量目标对于应用所在服务器设置偏高。

    吞吐量目标目标达成,但暂停时间过长,可以设置最大暂停时间,这势必会降低吞吐量,所以这是一个权衡的过程。

  • 如何处理OutOfMemoryError

  • java.lang.OutOfMemoryError算是一个常见的问题,这个错误在没有足够的空间分配对象时抛出。也就是说,垃圾收集器不能再使用一些空间来容纳一个新对象,并且堆空间不能再扩展。OutOfMemoryError错误不一定意味着内存泄露。它可能是一个简单的配置错误,例如为应用程序配置不足的内存(或默认值没有设置)。诊断OutOfMemoryError错误的第一步是检查完整的错误信息,在异常信息中,有一些额外信息在java.lang.OutOfMemoryError之后,下面一些常见的可能的补充信息,以及他们是什么意思,针对他们该做些什么:

    Java heap space

    这表明对象不能在堆中分配,这个可能仅仅是一个配置问题。例如,通过命令行参数-Xmx指定的(或者默认的)最大堆内存对于应用来说是过小,你就会遇到这个错误。它也可能是一种迹象,不再需要的对象由于应用程序无意的引用着而不能被垃圾收集器回收,也可以说是内存泄露HAT工具可以用来查看所有可引用到的对象,以及理解这些对象是如何被引用的。另一个可能导致这个错误的原因可能是应用过多的使用终结器(finalizers)以至于执行终结的线程跟不上加入终结队列的速度,jconsole工具可以用来监测等待终结的对象数量。

    PermGen space

    这表明永久代满了,如前所述,这个区域是JVM用来存放元数据信息的,如果应用程序加载了大量的类,则需要增加永久代大小,可以通过命令行参数–XX:MaxPermSize=n来设置永久代大小。

    Requested array size exceeds VM limit

    这表明应用分配的数组对象大小比堆还大,例如,应用程序尝试分配一个512MB的数组,但是堆最大才256MB,就会出现这个错误。大部分情况下,这个错误要不就是因为堆太小,要不就是因为应用程序计算数组的大小时发生错误引起了一个bug。

    Unable to create new native thread

    这个之前服务器上出现过,当时是Linux的nproc(用户最大进程数,线程也属于轻量级进程)限制,导致创建线程失败,因此需要配置nproc,如:

    # vim /etc/security/limits.conf
    <user_name> soft nproc 10240
    <user_name> hard nproc 10240
            
  • 评估垃圾收集器性能的工具

  • 日常中,可以用不同的调试和监控工具来评估垃圾收集性能,下面将介绍一些常用的工具。

  • -XX:+PrintGCDetails

  • -XX:+PrintGCDetails用于打印每次垃圾收集的相关信息,如不同分代在垃圾收集前后的存活对象大小不同分代的可用空间垃圾收集耗时等。

  • -XX:+PrintGCTimeStamps

  • -XX:+PrintGCTimeStamps用于打印每次垃圾收集开始的时间戳

    为了当生产线上出现OOM等崩溃问题时,会建议开启一些帮助解决问题的选项,如:

    -XX:+PrintGCDetails -XX:+PrintGCDateStamps -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/path/to/dump_dir -Xloggc:gc.log
    	
  • 其他常用工具

  • 用来输出JVM的内存相关的统计, 如果没有任何选项参数, 它会输出加载的对象列表. 需要更详细的信息, 可以使用-heap, -histo, -permstat参数。

    用来输出程序运行时的资源占用和性能信息,它可以用来诊断性能问题, 特别是跟堆大小以及垃圾回收相关的问题。

    是一个简单的profiler agent。它是一个使用Java Virtual Machine Tools Interface (JVM TI) JVM动态链接库接口。它可以输出一些监测信息到文件中, 这个文件以后可以被性能分析工具用来分析性能问题。

    HAT

    可以用来分析对象的引用关系。结合hprof文件分析,解决一些内存泄露的问题。

  • 垃圾收集相关的关键参数

  • JVM提供了一些命令行选项来选择垃圾收集器定义堆或分代的大小调整垃圾收集的行为获取垃圾收集数据等。下面将介绍一些常用的选项:

    垃圾收集器选择
    选项 垃圾收集器
    –XX:+UseSerialGC 串行收集器
    –XX:+UseParallelGC 并行收集器
    –XX:+UseParallelOldGC 并行压缩收集器,针对老年代
    –XX:+UseConcMarkSweepGC 并发-标记-清理收集器(CMS),针对老年代
    垃圾收集器统计
    选项 描述
    –XX:+PrintGC 每次垃圾收集的基本信息
    –XX:+PrintGCDetails 每次垃圾收集的详细信息
    –XX:+PrintGCTimeStamp 每次垃圾收集开始的时间戳
    堆和分代大小
    选项 默认值 描述
    –Xmsn 见这里 起始堆大小
    –Xmxn 见这里 最大堆大小
    -XX:MinHeapFreeRatio=n 40 针对每一个代,空闲内存最小占比,当空间内存占比小于该值时,分代大小将扩大
    -XX:MaxHeapFreeRatio=n 70 针对每一个代,空闲内存最大占比,当空间内存占比大于该值时,分代大小将缩小
    –XX:NewSize=n 平台依赖 年轻代初始大小
    –XX:NewRatio=n client VM时,为2;server VM时,为8 年轻代与老年代比例。若n=3,则年轻代占堆大小的1/4
    –XX:SurvivorRatio=n 32 年轻代中每一个Survivor区Eden区占比。若n=7,则From:To:Eden=1:1:7
    –XX:MaxPermSize=n 平台依赖 永久代最大大小
    并行收集器和并行压缩收集器
    选项 默认值 描述
    –XX:ParallelGCThreads=n CPU个数 垃圾收集线程数
    –XX:MaxGCPauseMillis=n 垃圾收集最大暂停时间
    –XX:GCTimeRatio=n 99 垃圾收集所占时间比: n / (n+1)
    CMS收集器
    选项 默认值 描述
    XX:+CMSIncrementalMode 开启并发阶段增量模式,周期性停止执行并发阶段,将CPU让给应用程序
    –XX:+CMSIncrementalPacing 当JVM在运行时,依据收集的统计信息启用增量模式自动调节占空比
    –XX:ParallelGCThreads=n CPU个数 年轻代并行收集的线程数和老年代并发阶段的线程数

    以上则是Java内存管理的一些基础知识,将有助于你在遇到内存相关问题时,提供一些指引,遇到这类问题,不用慌张,往往问题没有想象那么复杂,而大多数情况只是配置问题或者程序问题。

  • 参考文献

  • Java 内存管理白皮书
    Java GC Java并行压缩收集算法 Java CMS调优 JVM垃圾收集

好人,一生平安。