Java性能优化阅读笔记

Scroll Down

HotSpot VM运行时

HotSpot VM运行时环境担当了许多职责,包括命令行选项解析、VM生命周期管理、类加载、字节码解释、异常处理、同步、线程管理、Java本地接口、VM致命错误处理和C++(非Java)堆管理。

VM生命周期

启动HotSpot VM组件是启动器。HotspotVM有若干个启动器。
启动器启动Hotspot VM时会执行一些列操作。步骤概述如下:
(1)、解析命令行选项。启动器会直接处理一些命令行选项。
(2)、设置堆大小和JIT编译器。如果命令行没有明确设置堆大小和JIT编译器,启动器则通过自动优化进行配置。自动优化的默认设定因底层系统配置和操作系统而有所不同。
(3)、设定环境变量如LD_LIBRARY和CLASSPATH。
(4)、如果命令行有-jar选项,启动器则从指定JAR的manifest中查找Main-Class。否则从命令行读取Main-Class。
(5)、使用标准Java本地接口(Java Native Interface,JNI)方法JNI_CreateJavaVM在新创建的线程中创建Hotspot VM。
(6)、一旦创建并初始化好Hotspot VM,就会加载Java Main-Class,启动器也会从Java Main-Class中得到Java main方法的参数。
(7)、Hotspot VM通过JNI方法CallStaticVoidMethod调用Java main方法,并将命令行选项传给他。
至此,Hotspot VM开始正式执行命令行指定的Java程序了。

VM类加载

Hotspot VM负责解析常量池符号,这个过程需要加载、链接,然后初始化Java类和Java接口。术语类加载用以描述类名或者接口名映射到类(Class)对象的整个过程。Java Virtual Machine Specification则更明确的定义了类加载的3个阶段:加载、链接和初始化。类加载的最佳时机是在解析Java字节码类文件中常量池符号的时候。Java API如Class.forName()、ClassLoader.loadClass()、反射API和JNI_FindClass都可以引发类加载。Hotspot本身也可以引发类加载。Hotspot VM启动时,除了加载许多普通类,也会引发诸如java.lang.Object和java.lang.Thread类这样的核心类。加载类时需要加载它的所有java超类和所有Java超接口。此外,作为链接阶段的一部分,类文件验证也需要加载一些其他的类。实际上,加载阶段是Hotspot VM和特定类加载器如java.lang.ClassLoader之间相互协作的过程。

类加载阶段
对于给定的java类和接口,类加载时会依据它的名字找到Java类的二进制文件,定义Java类,然后创建代表这个类或者接口的Java.lang.Class对象。如果没有找到java类或接口的二进制表示,就会抛出NoClassDefFound。此外,类加载阶段会对类的格式进行语法校验,如果有错,则会抛出ClassFormatError或UnsupportedClassVersionError。Java类加载前,Hotspot VM必须先加载它的所有超类和超接口。如果类的继承层次有错,Hotspot VM则会抛出ClassCircularityError。如果所引用的直接超接口本身并不是接口,或者直接超类实际上是接口,Hotspot则会抛出IncompatibleClassChangeError。
链接的第一步是验证,检查类文件的语义、常量池符号以及类型。如果检查有错,就会抛出VerifyError。连接的下一步是准备,它会创建静态字段,初始化为标准默认值,以及分配方法表。请注意,此时还没有执行任何Java代码。接下来解析符号引用,这一步是可选的。然后初始化类,运行类构造器。值得注意的是,初始化类需要首先初始化超类(不会初始化超接口)。
Java virtual Machine Specification规定首次使用类时进行类初始化,而Java Language Specification则允许在链接阶段符号解析时灵活处理,只要保持语言的语义不变,JVM依次执行加载、链接和初始化,保证及时抛出错误即可。出于性能优化的考虑,通常直到类初始化时Hotspot VM才会加载和链接类。这意味着,类A引用类B,加载A不一定导致加载B(除非B需要验证),执行B的第一条指令会导致初始化B,从而加载和链接B。

类加载器委派
当请求类加载器查找和加载某个类时,该类加载器可以转而请求别的类加载器来加载。这被称为类加载器委派。类的首个类加载器称为初始类加载器(Initiating Class Loader),最终定义类的类加载器称为定义类加载器(Defining Class Loader)。就字节码解析而言,某个类的初始类加载器是指对该类进行常量池符号解析的类加载器。
类加载器之间是层级化关系,每个类加载器都可以委派给上一级类加载器。这种委派关系定义了二进制类的查找顺序。Java SE类加载器的层级查找顺序为启动类加载器、扩展类加载器及系统类加载器。系统类加载器是默认的应用程序类加载器,它加载Java类的main方法并从classpatch上加载类。应用程序类加载器可以是JavaSE系统自带的类加载器,或者由应用程序开发人员提供。扩展类加载器则由JavaSE系统实现,它负责从JRE的lib/ext目录下加载类。

启动类加载器
启动类加载器由Hotspot VM实现,负责加载BOOTCLASSPATH路径中的类,如包含Java SE类库的rt.jar。

类型安全
Java类或接口的名字为限定名(包括包名)。Java的类型由全限定名和类加载器唯一确定。换言之,类加载器定义了命名空间,这意味着两个不同的类加载器加载的类,即便全限定名相同,仍然是两个不同的类型。如果有用户类加载器,Hotspot VM则需要确保类型安全不被恶意的类加载器破坏。

Hotspot类元数据
类加载时,Hotspot VM会在永久代创建类的内部表示instanceKlass或arrayKlass。instanceKlass引用了与之对应的java.lang.Class实例,后者是前者的Java镜像。Hotspot VM内部使用klassOop是引用java.lang.Class的Hotspot内部抽象,它是指向Klass的普通对象指针。

内部的类加载数据

类加载过程中,Hotspot维护了3张散列表。SystemDictinary包含已加载的类,它将建立类名/类加载器(包括初始类加载器和定义类加载器)与klassOop对象之间的映射。

同步

广义上说,同步是一种并发操作机制,用来预防、避免对资源不适当的交替使用(通常称为竞争),保障交替使用资源的安全。Java用称为线程的结构来实现并发。互斥(Mutual Exclusion)是同步的特殊情况,即同一时刻最多只允许一个线程访问受保护的代码或数据。Hotspot VM用monitor对象来保障线程运行代码之间的互斥。Java的monitor对象可以锁定或者解锁,但任何时刻只能有一个线程拥有该monitor对象。只有获得monitor对象的所有权后,线程才可以进入它所保护的临界区。Java中临界区由同步块(synchronized Block)表示,代码中用synchronized语句表示。
线程试图锁定处于解锁状态的monitor对象时,可以立即获得所有权。由于已经锁定,如果随后有其他线程试图获取该monitor对象的所有权时,可以立即获得所有权。由于已经锁定,如果随后有其他线程试图获取该monitor对象的所有权,就只能等到所有者释放该锁后,才能进入临界区,从而获得互斥锁。说明一下,进入monitor是指获得monitor对象的互斥所有权进入相关的临界区。退出monitor是指释放monitor对象的所有权并退出临界区。此外,锁住monitor对象的线程拥有该monitor。非竞争指的是统一线程里在其他无主monitor对象上的同步操作。
Hotspot VM吸收了非竞争和竞争性同步操作的最先进技术,极大地提高了同步性能。多数同步操作作为非竞争性同步,可以在常量时间内实现。Java5 Hotspot VM中引入了偏向锁,最好的情况下成本甚至为零。既然大多数对象在其生命周期中最多只会有一个线程锁住,那就可以开启-XX:+UseBiasedLocking允许线程自身使用偏向锁。一旦开启偏向锁,该线程不需要借助昂贵的原子指令就可以对该对象进行锁定和解锁了。
即便是有大量锁竞争的程序,竞争性同步操作也能用高级自适应自旋锁技术来改善吞吐量。
Hotspot VM内部表示Java对象的第一个字(Word),包含了Java对象的同步状态编码,通常被称为标记字(Mark Word)。为了节约空间,标记字会依据不同状态,复用存储空间,包含其他的同步元数据。Hotspot VM的标记字中可能存放一下对象同步状态。

  • 中立:已解锁
  • 偏向:已锁定/已解锁且无共享
  • 栈锁:已锁定且共享,但非竞争。
  • 膨胀:已锁定/已解锁且共享和竞争。线程在monitor-enter或者wait()时被阻塞。该标记指向一个重型的object-monitor结构。

线程管理

线程管理涉及从线程创建到终止的整个生命周期,以及Hotspot VM线程间的协调。线程管理包括Java代码创建的线程(无论他们是由应用程序代码还是Java库所创建)、直接与Hotspot VM关联的本地线程,以及Hotspot为其他目的而创建的内部线程。虽然线程管理多数内容独立于平台,但实现细节仍然依赖底层的操作系统。
1、线程模型
Hotspot VM的线程模型中,Java线程(java.lang.Thread实例)被一对一映射为本地操作系统线程。Java线程启动时会创建一个本地操作系统线程,当该Java线程终止时,这个操作系统线程也会被回收。操作系统调度所有的线程并将他们分配给可用的CPU。Java线程的优先级和操作系统线程的优先级之间关系复杂,各个系统之间不尽相同。
2、线程创建和销毁
Hotspot VM有两种引入线程的方式,执行Java代码时调用java.lang.Thread对象的start()方法,或者用JNI将已存在的本地线程关联到hotspot VM上。Hotspot内部的许多对象,包括C++和Java对象,都与特定的线程关联,如下所示:

  • java.lang.Thread实例以Java代码的形式表示线程。
  • Hotspot VM内部以C++类JavaThread的实例表示java.lang.Thread实例,它包含其他的线程状态追踪信息。JavaThread以普通对象指针的方式保存了它所关联的java.lang.Thread对象,java.lang.Thread对象也以原始整数形式保存了它到JavaThread的引用。JavaThread也保存了它所关联的OSThread实例的引用。
  • OSThread实例代表操作系统线程,它包含了其他操作系统级别的线程状态追踪信息。OSThread也包含了平台特定的句柄用以标识操作系统的实际线程。

当java.lang.Thread启动时,Hotspot VM创建与之相关联的JavaThread和OSThread对象,最后是本地线程。所有的Hotspot VM状态(如线程本地存储和分配缓存,同步对象等)准备好后,启动本地线程。本地线程初始化后开始执行启动方法,执行java.lang.Thread对象的run()方法,当它返回时,先处理所有未捕获的异常,之后终止该线程,然后与Hotspot VM交互,检查终止该线程是否就要终止整个Hotspot VM。终止线程会释放所有已分配的资源,并从已知线程列表中移除JavaThread,然后调用OSThread和JavaThread的析构函数,当它的初始启动方法完成时,最终停止运行。
3、线程状态

Hotspot VM垃圾收集器

Java虚拟机(JVM)规范要求所有JVM的具体实现必须包括能够回收闲置内存(如不可达对象)的垃圾收集器(Garbage Collector)。

分代垃圾收集

Hotspot VM使用分代垃圾收集器,这个为人熟知的垃圾收集算法基于以下两个观察的事实。

  • 大多数分配的对象的存活时间很短。
  • 存活很久的对象很少引用存活时间短的对象。

上述两个观察事实统称为弱分代假设,就Java应用而言,这个假设通常成立。基于此假设,Hotspot VM将堆分成两个物理区(也称为空间),这就是分代。

  • 新生代:大多数新创建的对象被分配在新生代中,与整个Java堆相比,通常新生代收集(Minor GC)之后存活的对象很少。因为Minor GC关注小并且有大量垃圾对象的空间,所以通常垃圾收集的效率很高。
  • 老年代:新生代中长期存活的对象最后会被提升(Promote)或晋升到老年代。通常来说,老年代的空间比新生代打,而空间占用的增长速度比新生代慢。因此,相比Minor GC而言,老年代收集(也称为主要垃圾收集或者完全垃圾收集,记作Full GC)的执行频率比较低,但是一旦发生,执行时间就会很长。
  • 永久代:这是Hotspot VM内存中的第3块区域。虽然被称为代,但实际上不应该把它看作分代层次的一部分(也就是说,用户程序创建的对象最终并不会从老年代移送到永久代上)。相反Hotspot VM只是用它来存储元数据,例如类的数据结构、保留字符串(Interned String)等。

垃圾收集器不需要扫描整个(可能比新生代更大)老年代就能识别新生代中的存活对象,从而缩短Minor GC的时间。Hotspot VM的垃圾收集器使用称为卡表(Card Table)的数据结构来达到这个目的。老年代以512字节为块划分成若干张卡。卡表是个单字节数组,每个数据元素对应堆中的一张卡。每次老年代对象中某个引用新生代的字段发生变化时,Hotspot VM就必须将该卡对应的卡表元素设置为适当的值,从而将该引用字段所在的卡标记为脏。在minor GC过程中,垃圾收集器只会在脏卡中扫描老年代-新生代引用。
Hotspot VM的字节码解释器和JIT编译器使用写屏障(Write Barrier)维护卡表。写屏障是一小段将卡状态设置为脏的代码。解释器每次执行更新引用的字节码时,都会执行一段写屏障;JIT编译器在生成更新引用的代码后,也会生成一段写屏障。虽然写屏障使得应用线程增加了一些开销,但Minor GC变快了许多,整体的垃圾收集效率也提高了许多,通常应用的吞吐量也会有所改善。

分代垃圾收集的一大优点是,每个分代都可以依据其特性使用最适当的垃圾收集算法。新生代通常使用速度快的垃圾收集器,因为Minor GC频繁。这种垃圾收集器会浪费一点空间,但新生代通常只是Java堆中的一小部分,所以不是什么大问题。另外一方面,老年代通常使用空间效率高的垃圾收集器,因为老年代要占用大部分Java堆。这种垃圾收集器不会很快,不过Full GC不会很频繁,所以对性能也不会有很大影响。
分代垃圾收集器基于弱分代假设,要想充分发挥分代垃圾收集的威力,应用就必须符合该假设。对于那些不符合该假设的Java应用来说,分代垃圾收集只会增加更多开销,只不过实践中这样的应用很少见。

新生代

Hotspot VM新生代的布局如下,分为3个独立区域(或空间)。
jvm-note-001.drawio
jvm-note-002.drawio

  • Eden:大多数新对象分配在这里(不是所有,因为大对象可能直接分配到老年代)。Minor GC后Eden几乎总是空的。
  • Survivor(一对):这里存放的对象至少经历了一次Minor GC,它们在提升到老年代之前还有一次被收集的机会。

Minor GC后Eden中的存活对象被复制到未使用的Survivor。被占用Survivor里不够老(即还有在新生代中被收集的机会)的存活对象也复制到未使用的Survivor。最后,被占用Survivor里足够老的存活对象被提升到老年代。
Minor GC之后,两个Survivor交换角色。Eden完全为空,仍然只是用一个Survivor;老年代的占用略微增长。因为收集过程中复制存活对象,所以这种垃圾收集器称为复制垃圾收集器(Copy Garbage Collector)。
需要指出的是,在Minor GC过程中,Survivor可能不足以容纳Eden和另一个Survivor中存活的对象。如果survivor中的存活对象。如果survivor中的存活对象溢出,多余的对象将被移到老年代。这种称为过早提升。这会导致老年代中短期存活的对象的增长,可能会引发严重的性能问题。再进一步说,在Minor GC过程中,如果老年代满了而无法容纳更多的对象,Minor GC之后通常会进行Full GC,这将导致遍历整个Java堆。这称为提升失败。

快速内存分配

对象内存分配器的操作需要和垃圾收集器紧密配合。垃圾收集器必须记录它回收的空间,而分配器在重用堆空间之前需要找到可以满足分配需求的空闲空间。垃圾收集器以复制方式回收Hotspot VM新生代,其好处在于回收以后Eden总为空,在Eden中运用被称为指针碰撞的技术可以有效地分配空间。这种技术追踪最后一个分配的对象,当有新的分配请求时,分配器只需要检查top和Eden末端之间的空间是否能容纳。如果能够容纳,top则跳到新近分配对象的末端。
重要的Java应用大多是多线程的,因此内存分配的操作需要考虑多线程安全。如果只用全局锁,在Eden中的分配操作就会成为瓶颈因而降低性能。Hotspot VM没有采用这种方式,而是以一种称为线程本地分配缓冲区(TLAB)技术。为每个线程设置各自的缓冲区(即Eden的一小块),以此改善多线程分配的吞吐量。因为每个TLAB都只有一个线程从中分配对象,所以可以使用指针碰撞技术快速分配而不需要任何锁。然而,当线程的TLAB填满需要获取新的空间时,它就需要采用多线程安全的方式了。大部分时候,Hotspot VM的new Object()操作只需要大约十条指令。垃圾收集器清空Eden区域,然后就可以支持快速内存分配了。

垃圾收集器

Hotspot VM已经有3种不同的垃圾收集器。每种垃圾收集器都是针对某种类型的应用。

Serial收集器

新生代中使用Serial收集器时,采用之前的方式运行,而老年代中使用时则采用滑动压缩标记-清除(Sliding Compacting Mark-Sweep)算法,也称为标记-压缩(Mark-COMPACT)垃圾收集器。它的Minor GC和Full GC都是以Stop-The-World方式(即收集时应用程序停止运行)运行,只有等垃圾收集结束后,应用程序才会继续执行。
标记-压缩收集器首先找出老年代中有哪些依然存活的对象,然后将它们滑向堆的头部,从而将所有的空闲空间留在堆尾部的连续块中。这使得将来任何在老年代中的分配操作都可以使用快速的指针碰撞技术。

Parallel收集器

现在许多重要的Java应用都运行在有大量物理内存和多处理器的服务器上。在理想情况下,垃圾收集器应该充分利用所有可用的处理器资源,并且当它进行垃圾收集器时也不会让多数处理器空闲。
为了减少垃圾收集器的开销从而增加服务类应用的吞吐量,Hotspot VM自带了Parallel收集器也称为Throughput收集器。它的操作和Serial收集器类似(即新生代采用Stop-The-World方式收集,而老年代采用标记-压缩方式)。然而,Minor GC和Full GC都是并行的,使用所有可用的处理器资源。

Mostly-Concurrent收集器

对于许多应用来说,快速响应比端到端的吞吐量更为重要。在Stop-The-World模式中,应用线程在垃圾收集器开始时停止运行,直到垃圾收集结束后才继续运行和处理外部请求。Minor GC通常不会导致长时间的停顿,然而Full GC或压缩式垃圾收集,即使不频繁,也会导致时间停顿,特别是Java堆比较大的时候。
为了应对这种情形,Hotspot VM引入了Mostly-Concurrent收集器,也称为并发标记清除收集器(Concurrent Mark-Sweep GC,CMS收集器)。它管理新生代的方式与Parallel收集器和Serial收集器相同,而它在老年代则是尽可能的并发执行,每个垃圾收周期只有2次短的停顿。
开始的时候有一个停顿,称为并发标记(Initial Mark),它标记那些从外部直接可达的老年代对象。然后,在并发标记阶段(Concurrent Marking Phase),它标记所有从这些对象可达的存活对象。因为在标记期间应用可能正在运行并更新引用,所以到并发标记阶段结束时,未必所有存活的对象都能确保被标记。为了应对这种情况,应用需要再次停顿,称为重新标记(Remark),重新遍历所有在并发标记期间有变动的对象并进行最后的标记。追踪更改的对象可以重用数据结构卡表。因为重新标记比初始标记更为重要,所以并发执行以提高效率。
为了进一步减少重新标记时的工作量,CMS收集器引入了并发预清除(Pre-Cleaning)阶段。预清除在并发标记之后和重新标记之前,完成一些原本要在重新标记阶段完成的工作,即重新遍历那些在标记期间因并发而被改掉的对象。虽然标记结束前仍然需要重新标记(因为程序在预清除节点仍有可能改变对象),但预清除依然可以减少重新标记时需要遍历的对党,有时甚至能非常有效地减少重新标记导致的停顿。
在重新标记结束时,所有Java堆中存活的对象已保证被标记。既然预清除和重新标记阶段的重新遍历对象会增加垃圾收集器的工作量(相比而言,Parallel收集器只在标记期间遍历一次),CMS整体的开销相应增加了。对于大多数垃圾收集器来说,这是典型的为了力图减少停顿时间而做的权衡。
找到了老年代中所有的存活对象之后,垃圾收集器的最后阶段就是并发清除(Concurrent Sweeping),清除整个Java堆,释放没有迁移的垃圾对象。
CMS与前两个垃圾收集器相比还有一个缺点,就是需要更大的堆。这有一些原因。首先,CMS的周期时间长于Stop-The-World垃圾收集器所用的时间。同时只有在清除阶段,空间才会真的回收。假使允许应用在标记时继续运行,也就允许它继续分配内存,因而在标记阶段老年代的占用有可能会有所增加,而只有到清除阶段才会减少。此外,尽管垃圾收集器确保在标记阶段标识所有存活的对象,但实际上它无法找出所有的垃圾对象。标记阶段成为垃圾的对象在周期内可能被收集也可能不被收集。如果没有,则它将在下一周期被收集。垃圾收集期间没有找出的垃圾对象通常被称为浮动垃圾。
最后,缺乏压缩会形成空间碎片化,这将导致垃圾收集器无法最大程度地利用所有可用空间。在回收周期中,如果尚未回收到足够多空间之前,老年代满了。CMS就会退而求其次,使用代价昂贵的Stop-The-World进行空间压缩,就像Parallel收集器和Serial收集器那样。
应该注意到,在最新的Hotspot VM中,CMS的并发(标记和清除)阶段是与用户线程并行的。在高度并行的硬件上运行时,这种方式会很有用。否则,单个并发CMS线程将无法应对许多应用的线程。
与Parallel收集器相比,CMS老年代停顿变短了,但代价是新生代停顿略微拉长、吞吐量有所降低,堆的大小有所增长,并且由于并发,垃圾收集器还会占用应用的CPU周期。需要快速响应的应用可以从中受益。

JVM性能调优

垃圾收集调优基础

性能属性

  • 吞吐量:是评价垃圾收集器能力的重要指标之一,指不考虑垃圾收集引起的停顿时间或内存消耗,垃圾收集器能支撑应用程序达到的最高性能指标。
  • 延迟:也是评价垃圾收集器能力的重要指标,度量标准是缩短由于垃圾收集器引起的停顿时间或完全消除因垃圾收集所引起的停顿,避免应用程序运行时发生抖动。
  • 内存占用:垃圾收集器流畅运行所需要的内存数量。

这其中任何一个属性性能的提高几乎都是以另一个或两个属性性能的损失作代价的。换句话说,某一个属性上的性能提高总是会牺牲另一个或两个属性。然而,对大多数的应用而言,极少出现这三个属性的重要程度同等的情况。很多时候,某一个或两个属性的性能要比另一个重要。
我们需要了解对应用程序而言哪些系统需求是最重要的,也需要知道对应用程序而言这三个性能属性哪些是最重要的。确定哪些属性最重要,并将其映射到应用程序的系统需求,对应用程序而言非常重要。

原则

谈到JVM垃圾收集器调优也有三个需要理解的基本原则。

  • 每次Minor GC都尽可能多地收集垃圾对象。我们把这称作Minor GC回收原则。遵守这一原则可以减少应用程序发生Full GC的频率。Full GC的持续时间总是最长的,是应用程序无法达到其延迟或吞吐量要求的罪魁祸首。
  • 处理吞吐量和延迟问题时,垃圾处理器能使用的内存越大,即Java堆空间越大,垃圾收集的效果越好,应用程序运行也越流畅。我们称之为GC内存最大化原则。
  • 在这三个属性(吞吐量、延迟、内存占用)中任意选择两个进行JVM垃圾收集器调优。我们称之为GC调优的3选2原则。

确定内存占用

活跃数据的大小是指,应用程序稳定运行时长期存活对象所占用的Java堆内存量。换句话说,它是应用程序运行于稳定态时,Full GC之后Java堆所占用的空间大小。

约束

这一步的输入是JVM可以使用的物理内存量。同时JVM部署模式的选择也扮演了重要的角色。下面的列表可以帮助确定需要给各个JVM分配多少物理内存。

  • Java应用程序部署的机器上是否只有一个JVM并且只有该应用运行吗?如果采用这种方式,该机器的所有物理内存都可以分配给JVM。
  • Java应用程序会部署到同一台机器的多个JVM上吗?或者该机器会有其他进程或Java应用共享吗?如果是这种情况,你必须为每个进程及JVM设定可以使用的物理内存量。

无论上面提到的哪种场景,我们都需要为操作系统预留一部分内存。

Hotspot VM堆的布局

开始度量内存占用之前,理解Hotspot VM中Java堆的布局非常重要。它可以帮助我们确定应用程序使用Java堆的大小、微调影响垃圾收集器性能的空间大小。
Hotspot VM有三个主要的空间,分别是:新生代、老年代以及永久代。
jvm-note-003.drawio
Java应用程序分配Java对象时,首先会在新生代空间中分配对象。存活下来的对象,即经历几次Minor GC之后还保持活跃的对象会被提升进入到老年代空间。永久代空间中存放VM和Java类的元数据以及驻留的Strings和类静态变量。
-Xmx和-Xms命令行选项指定了新生代和老年代空间大小的初始值和最大值。初始值及最大值也被称为Java堆的大小。-Xms设定了初始及最小值,-Xmx可以设定最大值。当-Xms指定的值小于-Xmx的值时,新生代及老年代空间的大小可以根据应用程序的需要动态地扩展或缩减。Java堆的扩展最大不会高于-Xmx设定的值,缩减也不会超过-Xms设定的值。关注吞吐量及延迟的Java应用程序应该将-Xms和-Xmx设定为同一值。这是因为无论扩展还是缩减新生代空间或老年代空间都需要进行Full GC,而Full GC会降低程序的吞吐量并导致更长的延迟。
新生代可以通过下面任何一个命令行选项设置。

  • -XX:NewSize=<n>[g|m|k] 新生代空间的大小的初始值,也是最小值。<n>为设定大小.[g|m|k]指大小的度量单位。-XX:NewSize使用该选项时,应当同时指定-XX:MaxNewSize选项。
  • -XX:MaxNewSize=<n>[g|m|k]新生代空间的最大值。
  • -Xmn [g|m|k]设置新生代空间的初始值、最小和最大值。

通过-Xmn可以很方便的设定新生代空间的初始值和最大值。有一点需要特别注意,如果-Xms和-Xmx并没有设定为同一值,使用-Xmn选项时,Java堆大小变化不会影响新生代空间,即新生代空间的大小总保持恒定,而不是随着Java堆大小的扩展或缩减做相应的调整。因此,请注意,只有在-Xms和-Xmx设定为同一值时才使用-Xmn选项。
老年代空间的大小会根据新生代的大小隐式设定。老年代空间的初始值为-Xmx的值减去-XX:NewSize的值。老年代空间的最小值为-Xmx的值减去-XX:MaxNewSize的值。如果-Xms与-Xmx设置为同一值,同时使用了-Xmn,或者-XX:NewSize与-XX:MaxNewSize一样,则老年代的大小为-Xmx的值减去-Xmn。
永久代空间可以使用下面命令行指定:

  • -XX:PermSize=<n>[g|m|k]永久代空间的初始值及最大值
  • -XX:MaxPermSize=<n>[g|m|k]永久代空间的最大值

新生代、老年代或永久代这三个空间中的任何一个不能满足内存分配请求时,就会发生垃圾收集,理解这一点非常重要。换句话说,这三个空间中任何一个被用尽,同时又有新的空间请求无法满足时就会触发垃圾收集。新生代没有足够的空间满足Java对象分配请求时,Hotspot VM会进行Minor GC以释放空间。Minor GC相对于Full GC而言,持续时间要短。
经过几次Minor GC之后仍然活跃的对象最终会被提升到老年代。老年代空间不足以容纳新提升的对象时,Hotspot VM就会进行Full GC。实际上,当Hotspot VM发现当前可用空间不足以容纳下一次Minor GC提升的对象时就会进行Full GC。与因空间导致的Minor GC过程中的对象提升失败比较起来,这种方式的代价要小的多。从失败对象提升中恢复是一个很昂贵的操作。永久代没有足够的空间存储新的VM或类元数据时也会发生Full GC。
如果Full GC缘于老年代空间已满,即使永久代空间并没有用尽,老年代和永久代都会进行垃圾收集。同样,如果Full GC由永久代引起,老年代和永久代都会进行垃圾收集,无论老年代是否还有空闲空间。开启-XX:+UseParallelGC或-XX:+UseParallelOldGC时,如果关闭-XX:-ScavengeBeforeFullGC,Hotspot VM在Full GC之前不会进行Minor GC,但Full GC过程中依然会收集新生代;如果开启-XX:+ScavengeBeforeFullGC,Hotspot VM会在Full GC前会先做一次Minor GC,分担一部分Full GC原本要做的工作。

计算活跃数据大小

活跃数据大小是应用程序运行于稳定态时,长期存活的对象在Java堆中占用的空间大小。换句话说,活跃数据大小是应用程序运行于稳定状态,Full GC之后Java堆中老年代和永久代占用的空间大小。

初始堆空间大小配置

根据活跃数据大小定义初始Java堆大小时,还需要考虑Full GC的影响,推荐的做法是基于最差延迟进行估算。
通用法则一,将Java堆的初始值-Xms和最大值-Xmx设置为老年代活跃数据大小的3-4倍。
通用法则二,永久代的初始值-XX:PermSize及最大值-XX:maxPermSize应该是永久代活跃数据的1.2-1.5倍。
补充法则,新生代空间应该为老年代空间活跃数据的1-1.5倍。
如果Java堆的初始值及最大值为活跃数据大小的3-4倍,新生代为活跃数据的1-1.5被,老年代应该设置为活跃数据大小的2-3倍。

调优延迟/响应性

优化新生代的大小

Minor GC需要的时间与新生代中可访问的对象数直接相关,通常情况下,新生代空间越小,Minor GC持续的时间越短。不考虑这对于Minor GC持续时间的影响,减少新生代空间又会增大Minor GC的频率。这是因为以同样的对象分配频率,较小的新生代空间在很短的时间内就会被填满,增大新生代空间可以减少Minor GC的频率。
分析GC数据时,如果发现Minor GC的间隔时间过长,修正方法是减少新生代空间。如果Minor GC频率太高,修正的方法是增加新生代空间。
调整新生代空间时,需要谨记下面几个准则。

  • 老年代空间大小不应该小于活跃数据大小的1.5倍。
  • 新生代空间至少应为Java堆大小的10%,通过-Xms和-Xmx可以设定该值。新生代过小可能适得其反,会导致频繁的Minor GC。
  • 增大Java堆大小时,需要注意不要超过JVM可用的物理内存数。堆占用过多的内存将导致底层系统交换到虚拟内存,反而会造成垃圾收集器和应用程序的性能低下。

这个阶段中,如果只考虑Minor GC引起的延迟,而调整新生代的大小又无法满足应用程序的平均停顿时间或延迟性要求,就只能修改应用程序或者改变JVM的部署模式,在多个JVM上部署应用程序,或者修改应用程序的平均延迟性要求。

优化老年代的大小

为CMS调优延迟

使用CMS收集器时,老年代垃圾收集线程与应用程序能实现最大的并行度。这为我们同时降低最差延迟出现的频率以及最差延迟的持续时间,避免发生长时间的GC提供了机会。CMS并不进行压缩,所以这一效果主要是通过避免老年代空间发生STW压缩式垃圾来收集实现的。一旦老年代溢出就会触发STW压缩式垃圾收集。

STW这样的压缩式GC和FullGC之间存在着微妙的区别。在CMS中,如果老年代没有足够的空间处理来自新生代空间的对象晋升,只会在老年代空间触发一次STW的压缩式GC。发生Full GC时,除非使用-XX:-ScavengeBeforeFullGC选项,否则老年代和新生代的空间都会进行垃圾收集。

调优CMS收集器的目的是避免发生STW的压缩式GC。
从Throughput收集器迁移到CMS时,如果发生从新生代至老年代的对象提升,可能会经历较长的Minor GC持续时间,这是由于对象提升到老年代变得更慢了。
CMS在老年代空间从空闲列表中分配内存。与之相反,Throughput收集器只需要在线程本地分配的提升缓存中移动指针即可。另外,由于老年代垃圾收集线程能够与应用程序实现最大程度的并发执行,所以可以预期应用程序的吞吐量会更低。然而发生这种最差延迟的几率并不是很大,因为应用程序运行时老年代中的不可达对象会进行垃圾收集,从而避免了老年代空间被填满。
使用CMS时,如果老年代空间用尽,就会触发一个单线程的STW压缩式的垃圾收集。相对于Throughput收集器的Full GC而言,CMS垃圾收集通常的持续时间更长。因此,采用CMS的绝对最差延迟要比Throughput收集器的最差延迟时间长。老年代空间耗尽并因此触发STW压缩式收集时,由于应用程序长时间无法响应,会引起引起应用程序关注。因此,尽量避免用尽老年代空间是非常重要的。从Throughput收集器迁移到CMS收集器时,需要遵守的一个通用原则就是,将老年代的空间增大20%-30%,这样才能更有效地运行CMS收集器。
几方面因素使得CMS收集器的调优非常具有挑战性。一个是对象从新生代提升至老年代的速率。另一个是并行老年代垃圾收集线程回收空间的速率。第三个是由于CMS收集器回收位于对象之间的垃圾对象而造成老年代空间的碎片化。回收操作会在老年代的可达对象之间形成空洞,从而引起可用空间的碎片化。
有多种方法都可以解决碎片化问题。其中之一是压缩老年代空间。通过STW压缩式GC对老年代空间进行压缩。如前所述,STW压缩式GC耗时较长,是应该避免的时间,因为对于应用程序的最差延迟时间,他很可能是最大也是最重要的贡献者。这个方法不能从根本上解决碎片化问题,但是它可以推迟老年代空间碎片化到必须进行压缩的时间。通常情况下,老年代空间的内存越多,处理碎片压缩的时间就越长。应用程序的生命周期中努力达到的一个目标是,让老年代空间大到足以避免由堆内存碎片引起的STW压缩,换句话说,就是为GC申请最大内存的原则。处理碎片问题的另一个方法是减少对象从新生代提升至老年代的比率,即Minor GC回收原则。晋升阈值控制新生代中的对象何时提升至老年代。晋升阈值是Hotspot VM根据新生代空间占用情况,更确切的说,是根据Survivor空间占用的大小内部计算的结果。

Survivor空间介绍

在所有的Hotspot垃圾收集器中,新生代空间都被划分成一个Eden空间和2个Survivor空间。
当Eden空间被填满时,就会发生Minor GC。活跃对象会从Eden空间复制到标记To的Survivor空间中。同时FROM survivor空间中存活下来的对象也会复制到To Survivor空间中。一旦完成Minor GC,Eden空间会被清空,FROM survivor空间也变为空,而To Survivor空间保存了活跃对象。之后,Survivor空间将相互交换标记下一次的Minor GC作准备。
如果Minor GC时,To Survivor空间不足以容纳所有的从Eden空间 From Survivor空间中复制过来的活跃对象,超出部分会提升至老年代。溢出至老年代空间会导致非计划的老年代空间消耗加速,最终导致STW压缩式Full GC。再次提醒,针对Java应用程序的低延迟性要求,我们要尽量避免STW压缩式Full GC。
调整Survivor空间的大小,让其有足够的空间容纳存活对象足够长的时间,直到几个周期之后对象老化,就能避免发生Survivor空间溢出。有效的老化方法可以使老年代中只保存长期活跃的对象。

解析晋升阈值

新生代中的有效对象老化可以避免不成熟的对象提升到老年代空间,减少了老年代空间的占用率增长。同时,他还降低了CMS垃圾收集的执行频率,同时减少了可能得空间碎片。

不建议将最大晋升阈值设置为0,这会造成刚刚分配的对象在紧接着的Minor GC中直接从新生代提升到老年代,同时造成老年代空间的迅速增长,引起频繁的Full GC。此外我们也不建议将最大晋升阈值设置的远远大于实际可能得最大值。这会造成对象长期存在于Survivor空间中,直到最后溢出。一旦发生溢出,对象将被全部提升至老年代,不再依据其实际年龄进行提升。这样会造成短期存在的对象在长期存在的对象之前被晋升到老年代,严重影响对象老化机制的有效性。

通常情况下,观察到新的晋升阈值持续小于最大晋升阈值,或者观察到Survivor空间大小小于总的存活对象大小都表明Survivor空间过小。

调整Survivor空间的容量

调整Survivor空间容量的一个应该谨记于心的重要原则:调整Survivor空间容量时,如果新生代空间大小不变,增大Survivor空间会减少Eden空间;而减少Eden空间会增加Minor GC的频率。因此,为了同时满足应用程序的Minor GC频率的要求,就需要增大当前新生代空间的大小;即增大Survivor空间的大小的同时,Eden空间的大小应该保持不变。换句话说,每当Survivor空间增加时,新生代空间都应该增大。如果可以增大Minor GC频率,你可以选择用一部分Eden空间来增大Survivor空间,或者直接增大新生代空间的大小。如果内存够,相对于减少Eden空间,增加新生代空间大小通常是最好的选择。保持Eden大小恒定,Minor GC的频率就不会由于Survivor空间的增大而发生变化。

初始化CMS收集周期

STW压缩式垃圾收集是引入延迟的最大的垃圾收集。
成功的CMS收集器调优要能以对象从新生代提升到老年代的同等速度对老年代中的对象进行垃圾收集。达不到这个标准则称之为失速。失速的结果就是会发生STW压缩式垃圾收集。避免失速的关键就是要结合足够大的老年代空间和足够快地初始化CMS垃圾收集周期,让它以比提升速率更快的速度回收空间。
CMS周期的初始化基于老年代空间的占用情况。如果CMS周期开始的太晚,就会发生失速。如果它无法以足够快的速度回收对象,就无法避免老年代空间用尽。但是CMS周期开始的过早,又会引起无用的消耗,影响应用程序的吞吐量。通常,早启动CMS周期要比晚起动CMS好,因为启动太晚的结果比启动过早的结果要恶劣的多。
如果你碰到了STW压缩式收集,可以尝试调节CMS周期的启动时间。CMS中发生的STW压缩式垃圾收集日志中可以通过查找并发模式失效(concurrent Mode failure)定位。
可以通过-XX:CMSInitiatingOccupancyFraction=<percent>
设定值是CMS垃圾收集周期在老年代空间占用达到多少百分比时启动。另一个与-XX:CMSInitiatingOccupancyFraction=<percent>一起使用的是-XX:+UseCMSInitiatingOccupancuOnly告知JVM总是使用-XX:CMSInitiatingOccupancyFraction=<percent>设定的值作为启动CMS周期的老年代空间占用阈值。不使用-XX:+UseCMSInitiatingOccupancuOnly,Hotspot VM仅在启动的第一个CMS周期使用-XX:CMSInitiatingOccupancyFraction设定的值作为占用比率,之后的周期中又转向自适应地启动CMS周期。即第一次CMS周期之后就不再使用-XX:CMSInitiatingOccupancyFraction设定的值。
选项-XX:CMSInitiatingOccupancyFraction设定的空间占用值应该大于老年代占用空间和活跃数据大小之比。

调优CMS停顿时间

CMS周期中有两个阶段是STW的,处于这两个阶段的应用程序线程会被阻塞。这两个阶段分别是初始标记和重新标记阶段。虽然初始标记阶段是单线程的,却极少占用很长的时间,通常情况下远小于其他的垃圾收集停顿。重新标记阶段是多线程的。通过下面的Hotspot VM命令选项可以控制重新标记阶段使用的线程数。
-XX:ParallelGCThreads=<n>