Post

Java性能优化指南(一)

2015年在大物流项目中,给项目团队做了几次Java性能优化和问题排查的分享,不过效果都不是很好。一直觉得偏向技术实践类的东西,单纯的听和单纯的讲收获都很有限,最好的做法是阅读学习-理解-实践-总结,这样的方式。这一份原来是我在阅读《Java性能优化权威指南》时候的阅读笔记,最近整理后在这边做一下分享。

想要让程序运行得飞快,需要深入了解程序的工作原理,在Java世界里面,这既包括了特定的业务代码,也包括JVM和Java API的工作原理,只有在理解它们是如何工作的原理之后,才能理解为什么应用的某些行为这么糟糕,已经怎么排除那些导致性能低下的问题。

性能调优是科学和艺术的结合,即需要严格的数据收集和分析,有时候也需要给出一些直觉的判断,特别是在一些需要进行取舍权衡的地方。分析可以定位为题,解决问题,有时候需要一些经验和直觉的判断。

  1. 针对业务模型进行设计,保持业务逻辑的清晰,DDD
  2. 编写更好的算法——程序设计=算法+数据结构,合理的算法逻辑可以改变成功程序的运行过程
  3. 奥卡姆剃刀原理——如无必要,勿增实体
    1. 借助性能分析来优化代码,重点关注最耗时的方法
    2. 简单即有效,性能问题最可能最容易理解的,新代码>机器配置>JVM和操作系统Bug
    3. 历史的遗留分析,处理最为复杂,如果业务已经遗失,不要轻易进行重构
  4. 良好的编码习惯,良好的编码习惯能规避必然会影响性能的代码和设计
  5. 留意数据库的性能,在多数情况下,数据库的性能总有提升的空间,对于业务系统,大量时间会消耗在对数据库的操作上,对这部分的优化可以达到立竿见影的效果。

测试和衡量

测试

  1. 微基准测试,局部的代码测试,实现上多为针对特定方法的单元测试,可以测试一些特定方法或者代码逻辑的性能表现
    1. 微基准测试难以编写
    2. 很难模拟实际的程序运行场景
  2. 宏基准测试,衡量应用性能最好的就是应用本身,以及其用到的各种资源,实现上多为整体集成测试,可以反应系统各个模块之间的效率差
    1. 随着应用规模的增长,难以达到。一般需要一整套工具配套包括不仅限于:流量重放工具,调用链跟踪工具
    2. 资源的分配,进行一次测试,需要大量的资源和不同系统的协调,无法快速完成。
  3. 介基准测试,规模介于上面两者之间的测试,表现为一个特定模块或者独立功能的测试。具体可以表现为包含独立业务逻辑的单元测试,可以测试独立模块的性能表现
    1. 只是一个折中选择,无法准确反映性能表现

总结:

  • 微基准测试,有助于了解局部的性能特性,进行局部得代码优化,难以编写,价值有限,但可以快速了解代码的特性。
  • 介基准测试,在系统进行模块化隔离之后,用于测试局部的系统功能,对于开发团队而言是一个很好的输出检测。
  • 宏基准测试,是了解整体系统性能的唯一途径,是系统上线前的必备前提。

如果要对一个产品进行性能评估,找到问题点的时候,也可以分别从三面的三种测试。

系统整体性能瓶颈=>最核心的耗时模块=>模块分解为独立的子模块独立进行性能测试=>耗时代码=>微基准测试进行问题分析

不同的开发领域对上面三者的定义也是不一样的,一般参考比较的是所对应系统或者产品的自身特性来进行划分

时间衡量

首先Java世界中,性能测试需要先进行系统的热身。一方面是因为即时编译(JIT)的存在,另一方面也需要对一些缓存数据等进行预热

关注的指标: TPS、RPS、OPS、响应时间、90响应时间、95响应时间

统计方法

基准测试会通过多次测试、模拟真实的数据,衡量测试数据的变化,需要基线(baseline)和试样(specimen)

准确衡量测试的结果,需要利用统计学只是进行衡量,此处不进行拓展。

尽早频繁测试

尽量频繁的测试能够让我们今早发现问题和定位问题,但是测试的是有成本的,好的性能测试,需要反复多次运行,特别是在代码修改之后。

  1. 自动化:自动化的测试可以让测试过程更加规范,让每轮测试运行时保持相同的环境,使得测试可重复。
  2. 测试一切:理想的测试需要收集系统各方面的信息,包括不限于CPU使用率、磁盘使用率、网络使用率、内存使用率、堆栈信息、gc日志、数据库的AWR报告等等
  3. 在真实系统上运行:如果测试要如实反映真实系统的运行表现,需要基于相同的硬件条件进行测试

工具箱

为了了解程序的性能表现,需要获取大量的统计指标,通过操作系统和JDK自带的工具,可以使我们获取系统运行时各项指标

操作系统工具箱

性能分析可以先分析CPU,性能调优的目标是让CPU的使用率尽量提高,并且CPU的计算时间都运行在业务计算之上,在试图进行调优之前,应该先弄清楚为什么CPU的使用率较低

vmstat 1

CPU的空闲一般由于下面的原因

  1. 应用被同步原语阻塞、直至锁释放才能继续执行
  2. 应用在等待某些东西,例如数据库返回响应
  3. 应用确实没什么事情可以做

前两个可以通过降低竞争、提高外部系统性能来提高CPU的利用率。

CPU的利用率是基于时间来统计的,如果在1秒内,CPU被100%占用450ms,550ms没有被占用,那么这段时间的利用率就是45%

磁盘使用率

iostat -xm 5

如果没有进行有效的IO,磁盘写入数据的时候,统计数据会很低、 或者应用系统的IO过高达到了硬件的瓶颈。 此外虚拟内存swap区域也可能会导致磁盘的IO出现问题

网络使用率

nicstat 5

监控网络的吞吐,必要的时候进行抓包分析网络通信状况

JDK监控工具

获取JVM调优标志

jcmd process_id VM.flags [-all]

在程序的执行过程中改变标志[需要时manageable级别的标志]

jinfo -flag -PrintGCDetails process_id # turns off PrintGCDetails

jstack -l process_id

jcmd process_id Thread.print

实时GC分析:jconsole 事后堆转储:jmap -dump 然后使用jvisualvm、jhat、MAT等工具分析

性能采样

一种方式是进行数据采样,这里面存在一个矛盾,就是采样的过程本身会影响程序的性能表现。为了尽量得到更加接近现实的数据,我们队采样率的选择应该谨慎对待。

避免采样不均衡,如果系统有周期性的任务,而采样周期和系统运行周期错开那么就有可能遗漏很多重要的信息,为了防止采样失真,可以采用动态调整的采样率。

另一种对性能进行分析的方式是探测分析器,类似jvirtualvm这样的工具具有探测功能,相比采样分析,探测工具可以得到更加精确的系统运行信息,特别是对调用次数等。但是同样的,探测分析器会在类加载的时候,更改类的字节码,更加可能引入性能偏差

因此探测分析器应该仅在小代码区域,一些类和包中设置使用,以减少对应用整体性能的影响

同时Java的采样分析器只能在线程位于安全点时采集线程样本,也就是在JVM分配内存的时候。有一些方法不需要执行此步骤,可能无法采样

阻塞方法,在进行线程执行时间分析的时候,如果线程处于等待状态,那么线程对于CPU资源是没有消耗的,必须分析出阻塞的原因才能判断是不是性能问题、

使用JMC等工具,因为存在一定的商业授权限制,虽然JMC非常强大而方便,但是一般应用在测试环境中,常见的功能可以监控热点代码、获取GC事件,特别是GC事件,在JFR之外很难获得。

jcmd process_id JFR.start

各种工具非常重要,但是也各有擅长,聪明的调优应该结合不同的工具,争取能够洞悉系统的各个运行环节,有的放矢才能实现目标。

JIT 即时编译

在回答Java是编译型语言和解释型语言的时候,总是不免讨论JIT的问题。JIT也是对于Java执行性能影响最大的技术、也是Java能够应用在一些高性能核心领域和C/CPP竞争的武器之一。

编译型语言,程序以编译后的二进制形式交付,这个二进制文件中汇编码是针对特定的CPU指令集的。 解释型语言:在运行的是时候使用解析器,解释器将对应的程序代码转换成为二进制代码。

解释型语言强在可移植性,但是因为每次执行都需要对代码进行翻译,因此执行效率较差。并且由于编译型语言可以获取更多的程序信息,主要是程序的上下文关联(解释型语言一般是逐行解析的),因此可以编译出效率更高的二进制执行文件。

Java采用的是中间线路,Java应用会被编译,但是不是编译成特定CPU所专用的二进制码,而是一种理想化的汇编语言。然后该汇编语言也就是Java字节码,可以用Java运行。这使得Java是一种平台独立的解释型语言。同时,在Java程序运行的是字节码,JVM能够在代码执行的时候将其编译成为平台特定的二进制代码,这个过程就成为JIT。

衡量一段代码会不会被即时编译的标准就是,编译的代码执行更快,多次执行累计节约的时间超过了编译所花费的时间。这也是一种利弊的权衡。编译需要时间、如果编译了只少量执行的代码,那么这个时间花费显然是不划算的。

同时字节码在编译成为汇编语言的过程中会有大量的优化,如果字节码在解释运行一段时间之后,JVM会回去足够多的优化信息,通过对代码特性的辨别,可以使用诸如查找优化、寄存器等技术,可以极大地提高性能

Client 和Server

client和server是两种模式的编译器,主要的区别在于编译的时机不同,client编译器会早于server编译器开始编译。这意味着代码执行的开始阶段,client编译器比server编译器要快,因为它的编译代码相比server编译器而言要多得多,在server编译器还在使用解释型运行的时候,client编译器已经开始使用二进制汇编执行了。

但是server的长处在于,server在等待编译的时候,收集了更多的程序运行信息,因此在编译的时候可以更好地进行优化,由server编译器生成的代码要比client编译器生成的代码运行更快。

综合两者的优点,我们会选择在程序启动的时候使用client编译器,在程序运行过程中,随着代码逐渐变热使用server编译器,这种技术成为分层编译。

-server

-client

-XX:+TieredCompilation

在JDK8中,分层编译默认开启,在此之前需要手动开启

一般而言,开始分层编译都是一个比较好的默认选择

32位和64位的区别,目前的服务器大多数都是64位,并且在引入了压缩的普通对象指针之后,64位JVM在小规模内存堆中的表现也有所提升,可以使用超过4G的内存,也给未来提供了更多的优化可能性,因此大部分情况下采用64服务器和64位JVM、除非有明显的决策因素证明32位JVM更加适合

JVM编译代码的时候,会在代码缓存中保留编译之后的汇编语言指令集,如果缓存大小固定,那么一旦填满后就不能编译更多的代码了。

通过

-XX:ReservedCodeCacheSize=N

可以设置最大的缓存保留内存,这个值的大小一般要根据具体的硬件资源来设置,当设置这个最大值后,会作为保留内存预留出来。等待有需要的时候才进行分配

JVM判断是否进行编译的标准一般是运行的次数,当运行次数达到编译阈值的时候,编译器就认为获取了足够的信息可以对代码进行编译了

-XX:CompileThreshold=N

可以修改编译阈值,默认client N为1500 server N为10,000

这个次数统计基于两种JVM技术:方法调用计数器和方法中的循环回边计数器,回边实际上可以看作是循环完成执行的次数,包括continute这样的分支。

如果一个方法中的循环,执行了非常多次,其回边计数器就会达到编译阈值,但是方法的栈还没有退出。JVM可以在栈上对代码进行替换。这种技术成为栈上替换(OSR On-Stack Replacement)

较低的阈值,降低了编译优化的门槛。适当地降低阈值,优化的原理是可以提前对代码进行编译优化。又可以尽量保证优化出来的代码质量

并且每个计数器,也会周期性地减少,特别是JVM到达安全点之后,因此计数器并非累加,而只是最新热度的衡量。也是这个原因,使用分层编译可能更能照顾到一些不太频繁执行的代码的优化。

-XX:+PrintCompilation

检测编译的过程,每次编译一个方法会被输出

jstat -compiler process_id

jstat -printcompilation process_id 1000

可以了解编译的信息

在编码的过程中,我们也可以让代码结构更加简单,可以使得编译器能够更好地处理。

单核使用多线程,只会增加线程切换(寄存器、高速缓存的切换)

运行进程的信息被存储在处理器的寄存器和高速缓存(cache)中,执行的进程被加载到寄存器的数据集被称为上下文(context)

如果两个线程做同样的事情,那么切换这两个线程只会并不会带来性能的提升

内联:是编译器所能做的最有利的优化,特别是对属性封装良好的面向对象的代码,内联会将getter和setter方法简化为直接对属性的操作,也就是代码的合并,减少一个栈的调用深度。不过内联的细节信息很难查看。

逃逸分析:如果开启逃逸分析(-XX:+DoEscapeAnalysis 默认开启),编译器会执行一些非常激进的优化,这是编译器能够做得最复杂的优化,通常会简化锁的处理、减少中间对象的分配而只是追踪个别字段。这样的优化可能会给不正确的同步代码带来一些问题。通过简化相关的代码,可以让逃逸分析优化有更好的表现

逆优化

在一些情况下,编译器不得不撤销之前的某些编译

  1. 代码被丢弃,例如一个接口有两个实现类,但是已经编译优化后使用了其中的一个,当需要调用第二个的时候,就会使之前的优化失效。
  2. 代码先由cleint编译器编译,然后使用server编译器编译。中间也会存在一个丢弃和替换的过程。

对于final关键字,以前认为final会影响编译器的内联和其他的优化,让JIT编译器作出更好的选择。但是准确地说,只要有必要的时候,就应该使用final,比如你不打算改变的不可变对象或者原生值,内部类应用的外部参数(本质上也是一个不可改变的引用,为了保证语义上的一致,内部类不包括外部参数的引用,因此内部类实现对外部参数引用的时候选择创建一个变量引用外部变量但是这个变量修改之后无法同步到外部变量值中,因此干脆将外部变量设计为final)等。但是final关键字并不会影响应用的性能。

JVM GC 算法

Java相比CPP而言最大的优势就是不需要显示地管理对象的生命周期,我们在需要的时候创建对象,对象不再被使用的时候,JVM会负责进行GC。

GC可以简单地分成三个步骤:查找不再使用的对象,以及释放这些对象所管理的内存、对堆的内存布局进行压缩整理。完成这些操作的时候,根据不同的GC算法会采用不同的方法。

C/CPP这类自定义构造和析构函数的编程语言,更加利于做内存管理,但是也存在更大的风险。性能、安全性、便利性之间总是要作出取舍,这种取舍在生活中比比皆是。

在GC过程中存在竞争,应用程序的线程和处理gc的线程,特别是在内存整理的时候,因为需要移动内存的位置,此时应用程序线程不应该访问到这些内存对象。所有应用线程停止运行被称为时空停顿(Stop The World)这是对应用的性能影响最大的一种操作。在进行GC的时候,减少这种停顿是最为关键的考量要素。

GC roots

一个对象被使用的判断依据是从GC Roots 出发,分析是否可以达到该对象,可以作为GC roots的引用点有

  1. 方法区中静态变量和常量引用的对象
  2. 活动线程
  3. 本地方法栈中的引用

当一个对象到GC roots没有任何引用链的时候(或者说GC roots到这个对象不可达)则说明这个对象是不可用的。

GC 分代

Java的GC对象主要是针对堆进行,大部分情况下JVM在堆上为应用分配内存对象,大部分的对象存活的时间非常短,少数对象会长期使用,针对这种情况,所有的GC收集器都采用了同一种方式,将堆分成不同的代(Generation),分别为老年代(Old Generation)和新生代(Yound Generation),新生代又进一步分为Eden空间和Survivor空间。

对象首先在新生代中分配,当新生代填满的时候,垃圾收集器会暂停所有的应用线程,回收新生代空间。不再使用的对象会被回收,仍然使用的对象会被移动到其他地方。这种操作成为Minor GC,在清理新生代的时候,都会 stop the world 不过这个时间通常都很短。

这种设计意味着,新生代只是堆的一部分,先比处理整个堆处理新生代的速度回更快,应用停顿的事件会更短,但是这也会导致更加频繁地发生停顿。同时,对象分配在Eden空间中,GC的时候,新生代空间会被清空,Eden空间中的对象要么被移走,或者被回收,所有的存活对象要么被移动到另一个Survivor空间要么被移动到老年代,相当于对新生代空间自动进行了一次压缩整理。

对象不断移动到老年代,当老年代也填满的时候,就需要对老年代进行GC,这是不同算法最大的不同。简单的算法会暂停所有的应用线程,对堆空间进行整理,成为Full GC,这会导致长时间的停顿。CMS和G1等算法,则可以在线程运行的同时找到不再使用的对象,将stop the world的可能性降低,这些算法也称为Concurrent GC算法或者低停顿GC算法

在一些定义中还存着Major GC,表示对老年代的GC,而Full GC则表示对永久代/元空间、老年代、新生代的全局性GC。但是在我看来Minor GC和Major GC、Full GC的定义其实不是很重要,更加应该关注的是这些GC会带来的停顿以及回收的内容,在这个定义中Full GC和Major GC并没有很大的区别,都会带来较长时间的停顿,并对老年代进行GC。 通过GC是在JVM需要的时候触发:新生代用尽的时候出发Minor GC、老年代用尽的时候出发Full GC、或者堆空间即将填满的时候出发Concurrent 垃圾收集(如果选择CMS或者G1收集器)。使用System.gc()会触发Full GC,但是行为不定,只是增加了JVM Full GC的概率。

GC算法

  1. Serial垃圾收集器

无论是Minor GC还是Full GC都会暂停应用线程,进行Full GC的时候还会对老年代的对象进行压缩整理。通过-XX:+UseSerialGC标志启动。也是client模式的JVM的默认GC算法

  1. Throughout垃圾收集器

使用对县城回收新生代空间,MinorGC的速度要比Serial收集器快。对于老年代也采用多线程GC,可以通过-XX:+UseParallelOldGC显示开启,JDK7u4之后已经显示开启。由于采用多线程GC,因此也被称为Parallel收集器,是server模式的JVM的默认GC算法。可以通过-XX:+UseParallelGC来显示开启。

  1. CMS收集器

设计的初衷是为了消除上面两种收集器在Full GC周期中的长时间停顿,CMS收集器在Minor GC的时候回暂停所有的应用线程,并以多线程的方式进行GC,但使用的算法和Throughout算法不同,使用-XX:+UseParNewGC来启用。 CMS收集器在GC的时候不再暂停应用线程,而是分成几个不同的周期使用若干后台线程定期对老年代进行扫描,及时回收其中不再使用的对象,应用线程只是在Minor GC以及后台线程扫描老年代的时候发生极短的停顿。

CMS收集付出的代价就是更高的CPU使用,必须有足够的CPU资源英语运行后台的GC线程以及引用线程。此外后台线程不会再进行任何压缩整理的工作,这是的堆会组件碎片化。如果CMS的后台无法获得足够的CPU资源、或者碎片化过于严重而无法获取连续的空间分配对象,CMS会退化到Serial收集器模式,之后恢复。通过-XX:+UseConcMarkSweepGC-XX:+UseParNewGC来启用CMS收集器

  1. G1垃圾收集器

设计的初衷是为了尽量缩短处理大堆产生的停顿,它将堆划分成若干个区域Region,G1收集器也属于分代收集器,新生代的GC仍然采用暂停所有应用线程,使用多线程,将存活对象移动到老年代或者Survivor空间的做法。

G1收集器也是Concurrent收集器,和CMS收集器的最大不同,在于老年代被划分到不同的区域,G1收集器通过将对象从一个区域复制到另一个区域,完成对象的清理工作,也就是在正常的处理过程中,G1收集器实现了堆的压缩整理,因此G1更加不容易出现碎片化的情况。

同样的G1收集器也需要消耗额外的CPU资源,通过-XX:+UseG1GC来启动

选择GC算法

  1. Serial收集器适用于应用程序的内存使用少于100MB的场景,但是大多数的情况下,只能作为其他GC收集器的替补选项。
  2. Throughput收集器处理批量任务的时候,能够最大限度利用CPU的处理能力(全力计算、全力GC)通常能够获得更好的性能一般指吞吐量。但是如果批量任务并没有使用机器上所有的可用CPU资源,那么使用Concurrent收集器往往能够取得更好的性能。
  3. 如果CPU计算资源不足,或者无法获取连续空间容纳对象的时候,采用CMS收集器的时候,可能会出现并发模式失效(Concurrent Mode Failure),这会使JVM退化成为单线程的Full GC模式,从而使得CPU利用率下降,导致长时间的Full GC停顿
  4. 相比Throughout收集器,CMS在99%响应时间上有巨大的优势,显然CMS介绍了Full GC的次数,从而减少了由于Full GC导致长时间停顿的次数。同时在90%响应时间上,Throughput则可能优于CMS收集器,一般而言在CPU资源充足的情况下,CMS的响应时间要由于CMS收集器。
  5. 如果选择Concurrent收集器,一般情况下堆空间小于4G选择CMS收集器的性能会比G1更好,因为CMS的算法相比G1更加简单,一般而言在堆较少的情况下运行速度回更快,而当堆较大的时候,G1收集可以分割工作区,不必像CMS一样扫描完整个老年代空间,通常比CMS收集器表现更好。同时G1收集器可以并行对老年代进行压缩整理更加不容易出现碎片化空间,从而降低Full GC出现的几率
  6. 无论是CMS还是G1都仍然可能出现并发模式失效的问题
  7. Throughout和CMS算法存在的事件比较差,经过了大量的优化,G1收集器存在的事件较短,实际上大部分Concurrent选择的时候还是选择CMS较多。未来G1应该会作为一个选项

GC调优

堆空间调优

对JVM的GC进行调优,首先考虑的就是调整堆的大小。堆的大小不是越大越好,如果超过了机器的物理内存,操作系统会使用swap空间存储原本应该存储在内存中的对象,操作磁盘会导致严重的性能问题,特别是在进行Full GC或者Concurrent收集器后台回收堆的时候。较大的堆可以降低GC的频率,但是同时每次GC所需要的时间也会相应增加。

堆的调整是JVM调优的核心参数,通过堆的大小调整可以影响GC算法的行为。堆大小由两个参数值控制:分别是初始值(-Xms N)和最大值(-Xmx N),JVM的目标是找到一个合理的堆值,因此会自动在这两个值之间进行调整,一般都是根据GC消耗的时间来决定的。

对于Xmx的一个经验值是,在完成一次Full GC之后,应该释放出70%的空间,可以使用jconsole连接程序进行强制Full GC,观察对应的值。

另一个经验做法是,如果确切了解应用程序需要多大的堆,那么可以将堆的初始值和最大值直接设置成为对应的值,可以稍微提高GC的运行效率。-Xms4096m -Xmx4096m

XmsXmx对应为堆的堆空间的committed memorymax memory,同时存在used memoryused memory表示已经使用的内存,也就是实际堆中占有的内存空间;committed memory代表操作系统承诺可以被JVM使用的内存大小;max memeory最大可以被JVM使用的内存,如果这个值超过了可用内存的最大值,那么分配可能失败。

代空间调整

确认了堆的大小之后,就需要决定多少空间分配给新生代多少分配给永久代。这里也存在权衡,如果新生代比较大,Minor GC发生的频率就比较低,但是同时老年代就会比较小,容易填满而导致触发Full GC。

-XX:NewRatio=N 设置新生代和老年代的空间占用比率,默认为2,新生代空间大小则等于 堆空间大小 / (1 + NewRatio)也就是默认 1/3 的堆大小为新生代空间

-XX:NewSize=N 设置新生代空间的初始大小,优先级高于NewRatio

-XX:MaxNewSize=N 设置新生代空间的最大大小

-XmnNNewSizeMaxNewSize设置为同一个值,如果使用固定的堆大小,使用这个值设置即可。如果动态增长则建议使用NewRatio比例设置的方式

永久代和元空间的调整

JVM载入类的时候,需要记录这些类的元数据,在Java7里,这部分空间成为永久代Permgen,在Java8中,成为元空间Metaspace。这些空间和Heap对应也成为no-heap。

元数据,存储的是Java的字节码加载到JVM之后,运行过程中的数据,包含符号、字面变量、连接符等等一些类似“书签”的信息,这些信息只对编译器或者JVM的运行时有用。而XXX.class也是一个对象,作为一个实例存储在heap中。 在JDK6以及之前,永久代还保持着字符常量池,JDK7将String的分配和常量池移到Heap中,因为Permgen很难被拓展,存储其中的数据需要根据类的数量、常量池池String.intern,方法大小等进行评估,而这些很难进行,并且对Permgen的GC回收比较难奏效,字符池 String Pool 移到Heap中,可以统一GC回收的模型。 JDK8的时候彻底移除了永久代,而引入了Metaspace,在JDK7中还保留的和类数据无关的杂项对象(miscellaneous object),也被移到了普通的堆空间中。这么做的核心原因还是Permgen的优化困难导致的,每一种GC算法都需要特定的代码处理Permgen中的元数据。从Permgen中分离元数据,不进可以对元数据进行统一管理,也可以简化GC代码对这部分空间的GC操作。

评估永久代/元空间的大小,和程序使用的类的数量成比例关系,如果应用程序越复杂,使用对象越多,则所需要的空间越大。JDK8中,元空间默认使用尽可能大的空间,因为使用的是不是堆内存,而是直接使用原生内存,因此大小不受限制。JDK7以及之前则需要考虑Permgen的大小。

-XX:PermSize=N

--XX:MaxPermSize=N

--XX:MetaspaceSize=N

--XX:MaxMetaspaceSize=N

可以调整对应的永久代或者元空间的初始大小和最大值

调整这些空间会导致Full GC,如果系统启动的时候频繁Full GC导致启动过慢,可以考虑使用一个较大的初始值。如果使用JDK7还需要针对程序规模设置一个合适的MaxPermSize值。

这篇区域中依然存在垃圾回收操作,特别是在应用服务器中,使用不同的类加载器加载类,当一个类加载器不再被引用后,则它定义的任何类也不会再被引用,可以进行GC回收。在服务器运行的周期内,永久代或者元空间被新的类充满填满,老的类的元数据等待被回收。因此如果出现类加载器泄漏的情况可能导致此片区域被耗尽的情况。

jcmd pid GC.class_stats

可以输出类加载器相关的信息

并发控制

并发控制,本质上是要在尽量利用多核CPU计算力的基础上,尽量减少线程上下文的切换,从而将计算资源集中在真正有意义的计算上。

-XX:ParallelGCThreads=N参数用于控制GC在并发启动的线程数,包括

  1. -XX:+UseParallelGC收集新生代空间
  2. -XX:+UseParallelOldGC收集老年代空间
  3. -XX:+UseParallelNewGC收集新生代空间
  4. -XX:+UseG1GC收集新生代空间
  5. CMS收集器的”空间停顿”阶段(并非Full GC,初始标记和重新标记阶段)
  6. G1收集器的”空间停顿”阶段(并非Full GC)

这些GC操作会暂停应用线程,JVM为了加快执行速度,会使用尽量多的CPU资源,默认情况下JVM会在每个CPU上运行一个线程,最多8个,达到上限后每超出8/5个CPU个CPU启动一个新的线程。

优化的原则是,尽量不让GC线程发生争抢Throughput特别是在核心比较多的机器上运行多个实例的JVM,比较容易出现启动的GC线程过多的情况这时候应该适当减少GC的线程数量。

自适应调整

默认JVM会根据我们设置的参数和运行时的性能历史记录,自行调整参数,寻找优化性能的机会。

使用-XX:-UseAdaptiveSizePolicy标志可以在全局范围内关闭自适应调整功能(默认是开启的)。如果堆容量初始值和最大值设置成相同,同时新生代的初始值和最大值都设置成相同大小,自适应调整功能会自动关闭。

如果想知道JVM的空间是如何进行调整的,可以使用-XX:+PrintAdaptiveSizePolicy标志,打印出详细的调整信息

监控工具

  1. -verbose:gc或者-XX:+PrintGC能创建基本的GC日志
  2. -XX:+PrintGCDetails会创建详细的GC日志
  3. -XX:+PrintGCTimeStamps打印出GC发生的时间
  4. -Xloggc:filename修改日志输出到某个文件
  5. -XX:+UseGCLogfileRotation -XX:NumberOfGCLogfiles=N -XX:GCLogfileSize=N-XX:+UseGCLogfileRotation控制日志文件的循环,-XX:NumberOfGCLogfiles=N控制数量,-XX:GCLogfileSize=N控制文件的大小,默认不做限制

jstat -gcutil process_id 1000

可以统计GC的一些历史信息

GC日志的开销很小,一般情况下都建议在生产环境中添加,JVM调优一般都需要根据这些GC日志来进行。

GC算法

Throughput收集器

Throughput收集器,会进行两种操作,Minor GC和Full GC。所有的调优都围绕着停顿时间进行,主要就是堆的大小、新生代和老年代的大小之间的平衡。

  1. 时间和空间,也就是GC时间和内存空间的取舍
  2. GC时间的分布,增大堆减少Full GC的频率,但是增加了其时间,影响平均响应时间。同样新生代分配更多的空间,可以缩短Full GC的停顿时间,但是增加老年代GC的频率。

如果使用JVM自动调整的策略,我们可以设置一些性能指标-XX:MaxGCPauseMillis=N-XX:GCTimeRatio=NXX:MaxGCPauseMillis=N标志用于设定应用可承受的最大停顿时间,如果这个标志设置得非常小,那么应用的老年代最终会非常小,从而频繁触发Full GC,默认情况下我们不对这个值进行设置,但是要注意这个参数优先级最高,一旦设置了这个值,JVM就会对新生代和老年代的大小进行不断调整直到满足停顿目标。-XX:GCTimeRatio=N标志设置你喜欢程序在GC上花费的时间,默认值为99,其数值计算的方式throughputGoal = 1 - 1 / ( 1+ GCTimeRatio ) 也就是默认程序运行时间占用99%,1%的时间用在GC上。对于GC占用时间,一般在3%到6%之间,表现就非常好,对应的-XX:GCTimeRatio=19,程序也会尽量优化达到这个参数,如果采用自动调整,那么设置这个标识是一个很好的选择。

CMS收集器

CMS收集过程

  1. 对新生代进行Minor GC
  2. 在应用运行过程中,对老年代进行并发GC
  3. 出现并发模式失效的时候,触发Full GC

JVM会在堆的使用达到某个程度的时候,启动并发回收

  1. 初始标记[CMS-initial-mark](stw)
1
 [GC [1 CMS-initial-mark: 2905437K(4096000K)] 3134625K(5916480K), 0.2551680 secs] [Times: user=0.26 sys=0.00, real=0.25 secs]

主要任务是找到堆中所有的垃圾回收根节点对象

  1. 并发标记[CMS-concurrent-mark]
1
2
[CMS-concurrent-mark-start]
[CMS-concurrent-mark: 2.787/3.329 secs] [Times: user=12.12 sys=0.64, real=3.33 secs]

初始标记的对象,并发标记对象,不会对堆的使用情况产生实质性的改变

  1. 并发预删除[CMS-concurrent-preclean]
1
2
[CMS-concurrent-preclean-start]
[CMS-concurrent-preclean: 0.342/0.477 secs] [Times: user=1.79 sys=0.10, real=0.48 secs]

并发预清理完成的工作和重新标记类似,主要是在上一个并发标记和应用线程是并发执行的,因此有些对象状态在标记后会发生变化,这个阶段主要发现从新生代晋升的对象、分配到老年代的对象和在并发标记阶段被修改的对象。

  1. 重新标记(stw)
1
2
3
4
5
6
[CMS-concurrent-abortable-preclean-start]
[CMS-concurrent-abortable-preclean: 0.920/1.083 secs] [Times: user=4.06 sys=0.20, real=1.08 secs]
[GC[YG occupancy: 777901 K (1820480 K)]
[Rescan (parallel) , 0.1361120 secs]
[weak refs processing, 0.0005370 secs] [scrub string table, 0.0044130 secs]
[1 CMS-remark: 3034451K(4096000K)] 3812352K(5916480K), 0.1412750 secs] [Times: user=0.54 sys=0.00, real=0.14 secs]

重新标记覆盖多个阶段,是CMS中比较复杂的一个步骤,

  • 并发可中断的重标记[CMS-concurrent-abortable-preclean]

在进行重新标记的时候,需要扫描所有的新生代和老年代,这个阶段会很慢,为了能够加速这个阶段,能想到的就是在之前进行一次 minor gc,从而减少新生代的扫描数量。但是minor gc 是会stw,为了避免minor gc 一次暂停,重新标记又一次暂停这样的连续暂停。这个minor gc 是可以被提前放弃的,也就是如果预计这个minor gc 需要 4 s 那么可能在 2s 之后就放弃了这次回收。这个数量可以通过CMS 有两个参数:CMSScheduleRemarkEdenSizeThreshold、CMSScheduleRemarkEdenPenetration,默认值分别是2M、50%。两个参数组合起来的意思是预清理后,eden空间使用超过2M时启动可中断的并发预清理(CMS-concurrent-abortable-preclean),直到eden空间使用率达到50%时中断,进入remark阶段。

  • Rescan

Rescan 要重新扫描 新生代和老年代(因为需要确定对象是真的存活,这个阶段可能会非常慢,为了改进这个问题,产生了前面的可中断预清除,在这个阶段才是完成重标记,重标记是要暂停应用程序的,由于前面的并发标记,并且使用可中断的预清理最大化减少了需要扫描的数量,在此处消耗的时间相对比比较少。

  1. 并发清除
1
2
[CMS-concurrent-sweep-start]
[CMS-concurrent-sweep: 5.656/6.900 secs] [Times: user=25.88 sys=1.28, real=6.90 secs]

重新唤醒应用线程,并发将标记为需要清除的对象清除

  1. 并发重置
1
2
  [CMS-concurrent-reset-start]
  [CMS-concurrent-reset: 0.010/0.010 secs] [Times: user=0.04 sys=0.00, real=0.01 secs]

完成CMS垃圾回收的周期,老年代空间中没有被应用的对象被回收,同时清理CMS内部状态

从这边可以看到CMS收集的的并发GC过程并不完全等于Full GC,只有包含了Stop the world的阶段才包含在Full GC中,一次CMS GC过程会包括两次Full GC统计次数,因此如果采用CMS并发收集,除了关注Full GC的统计次数,更应该关注Full GC的运行时间更加有参考意义

在这边额外关注一下 concurrent mode failure

  1. 老年代没有足够的大小容纳新生代提升上来的数据时,就会触发并发模式失效,从而退化成单线程的Serial GC进行Full GC,这就是一些情况下出现CPU 100%的原因
1
2
3
4
5
6
7
8
 [GC 267.006: [ParNew: 629120K->629120K(629120K), 0.0000200 secs] 
 267.006: [CMS267.350: [CMS-concurrent-mark: 2.683/2.804 secs] 
 [Times: user=4.81 sys=0.02, real=2.80 secs] 
 (concurrent mode failure): 
 1378132K->1366755K(1398144K), 5.6213320 secs] 
 2007252K->1366755K(2027264K), 
 [CMS Perm : 57231K->57222K(95548K)], 5.6215150 secs] 
 [Times: user=5.63 sys=0.00, real=5.62 secs]
  1. 老年代中有足够的空间,但是由于空闲空间不连续,而导致晋升失败,也同样会引起一次Full GC 进行压缩整理操作
1
2
3
4
5
6
7
8
9
10
11
 [GC 6043.903: 
        [ParNew (promotion failed): 614254K->629120K(629120K), 0.1619839 secs] 
        6044.217: [CMS: 1342523K->1336533K(2027264K), 30.7884210 secs] 
        2004251K->1336533K(1398144K), 
        [CMS Perm : 57231K->57231K(95548K)], 28.1361340 secs] 
        [Times: user=28.13 sys=0.38, real=28.13 secs]
 [Full GC 279.803: 
                [CMS: 88569K->68870K(1398144K), 0.6714090 secs] 
                558070K->68870K(2027264K), 
                [CMS Perm : 81919K->77654K(81920K)], 
                0.6716570 secs]

默认情况下CMS是不对Metaspace或永久代进行资源回收的,只有Full gc的时候会进行这个操作,因此当出现并发模式失败的时候,也会对该区域进行GC,所有没有被引用的类都会被回收

CMS调优

从上面的GC日志中可以看到,当并发模式失败的时候,付出的GC时间代价是最大的,而并发模式失效往往是因为CMS不能以足够快的速度清理老年代,默认情况下当老年代空间占用达到70%的时候,并发回收就开始,在剩下的30%空间用尽之前,如果CMS无法及时回收空间,那么就会出现并发失效。

CMS优化的核心目标就是减少并发失效,主要有三种做法

  1. 增大老年代空间
  2. 以更高的频率运行gc
  3. 使用更多的后台回收线程

CMS收集器只有在Full GC的时候,才会调整堆和代的大小

第一个选项的平衡之前已经谈到,这边重点描述后两个做法。

  1. 提高频率 如果在老年代占用达到60%的时候就启动并发回收,无疑完成GC的几率更大,通过-XX:CMSInitiatingOccupancyFraction=N-XX:+UseCMSInitiatingOccupancyOnly,前者确定比例,后者确定是否使用这个比例,如果-XX:+UseCMSInitiatingOccupancyOnly开启,默认的比例为70%,如果不启动,那么CMS会根据更加复杂的算法来判断何时启动并发回收。 一般经验来说,要设置这个比例,需要观察上一次并发失效的时候,老年代的占用情况,将比例设置得比这个失败值小。 同时要要考虑到频繁进行CMS并发GC带来的CPU压力,以及评分的CMS并发GC带来的停顿影响

  2. 调整CMS后台线程数量 -XX:ConGCThreads=N 增加后台线程的数目,同样调整这个参数要考虑可用的CPU,如果设置偏大,可能会占用原来应用线程的CPU,造成应用程序的停顿,如果没有频繁早于并发模式失败,在CPU数量较大的机器上可以适当减少这个值,以腾出更多的CPU给应用线程使用。默认值为 (3 + ParallelGCThreads) / 4

  3. 提高效率 前面提到在重新标记中会触发一次可中断的Minor GC目的是减少重新标记过程中要扫描的内存数量。因为这个步骤要暂停应用线程,所以如果观察到重新标记的GC停顿过长,可以通过-XX:+CMSScavengeBeforeRemark,强制在重标记之前进行一次Minor GC,来减少GC的停顿时长。

  4. 提高remark的执行速度 如果发现remakr的执行速度较慢,那么可以开启-XX:+CMSScavengeBeforeRemark在remark开始之前,强制执行一次minor gc

默认情况下JDK7的CMS收集器不会对永久代进行回收,如果永久代空间用尽,会发起一次Full GC来进行回收。可以通过-XX:+CMSPermGenSweepingEnabled标志来开启,开启之后,会通过一组后台线程并发地回收永久代。-XX:CMSinitiatingPermOccupancyFraction=N参数可以设置永久代占用比例达到阈值后启动回收线程,默认值为80%。如果要回收不被使用的类,还需要-XX:+CMSClassUnloadingEnabled开启。在JDK8中,这个参数是默认开启的。

CMS还有一个增量式的CMS收集器,通过-XX:+CMSIncreamentalMode开启,其最大的好处是后台线程会间歇性停顿,让出一部分CPU给应用程序线程运行,使得在有限CPU资源的机器上也可以运行低延迟的GC收集器。但是现在普遍是多核机器为主流,如果CPU资源确实非常有限,可以考虑使用G1收集器。

G1收集器

G1收集器的特点是堆进行分区(Region),分区既可以属于老年代,又可以属于新生代,在需要的时候,G1收集器会强制指定空分区用于任何需要的代,默认一个堆分成2048个分区。同一个代的分区不需要保持物理连续,只需要保留逻辑关联。这样设计的初衷是可以让G1收集器在回收老年代的时候,优先回收垃圾较多的分区,也是其名称的由来。但是在回收新生代的时候,还是和其他算法一样,整个新生代要么被回收或者被晋升。新生代也采用分区的原因是因为可以通过预定义的分区来调整代的大小。

G1收集器的收集活动包括4种操作

  1. 新生代GC
1
2
3
4
5
[GC pause (young), 0.23094400 secs] 
... 
   [Eden: 1286M(1286M)->0B(1212M) 
        Survivors: 78M->152M Heap: 1454M(4096M)->242M(4096M)] 
   [Times: user=0.85 sys=0.05, real=0.23 secs]

Eden空间耗尽会触发G1收集器进行新生代进行GC,Eden空间被清空,一部分晋升到Survivor空间,一部分移到老年代。

  1. 后台收集,并发周期[concurrent G1 cycle]

    在并发周期中,可以发生一次或者多次新生代的GC,过程中Eden空间的分区,可以填充新分配的对象。 其次,一些老年代的分歧会被标记,这就是标记周期(marking cycle)找出的包含最多垃圾的分区。标记周期不会实际是否老年代中的对象,而仅仅是锁定了那些垃圾最多的分区,这些分区中的垃圾数据会在后续被释放。下面简述整个标记的过程 * 初始标记,这个阶段会暂停所有的应用线程

    1
    2
    3
    4
    
     [GC pause (young) (initial-mark), 0.27767100 secs] 
     [Eden: 1220M(1220M)->0B(1220M) 
         Survivors: 144M->144M Heap: 3242M(4096M)->2093M(4096M)] 
     [Times: user=1.02 sys=0.04, real=0.28 secs]
    

    可以从日志中看到G1收集器重用了新生代GC周期,来完成暂停应用线程这个操作,这会稍微增加新生代周期的CPU开销,但是总体还是有利的。 * 扫描根分区(root region)

    1
    2
    
    [GC concurrent-root-region-scan-start] 
    [GC concurrent-root-region-scan-end, 0.5890230]
    

    扫描过程,可以并发执行,不需要暂停应用线程。不过这个过程为了避免新生代晋升到老年代,不允许进行新生代GC,如果新生代刚好用尽,那么新生代GC会暂停所有的应用线程,等待根扫描结束。 [GC pause (young) 351.093: [GC concurrent-root-region-scan-end, 0.6100090] 351.093: [GC concurrent-mark-start], 0.37559600 secs] 出现这种情况会使新生代停顿时间更长,可能需要进行优化。 * 并发标记,这个阶段在后台并发完成,并且是可中断的,因此可以在这个过程中发生新生代GC。

    1
    2
    3
    
    [GC concurrent-mark-start] 
    .... 
    [GC concurrent-mark-end, 9.5225160 sec]
    
    1
    2
    3
    4
    5
    
       * 重新标记(remarking),会暂停应用线程,但是时间一般都很短 ````shell [GC remark 120.959: 
     [GC ref-PRC, 0.0000890 secs], 0.0718990 secs] 
     [Times: user=0.23 sys=0.01, real=0.08 secs]  [GC cleanup 3510M->3434M(4096M), 0.0111040 secs] 
     [Times: user=0.04 sys=0.00, real=0.01 secs] ````
       * 并发清理,该阶段回收的内存数量很少,主要是完成待收集分区的定位,到这边整个标记过程就已经完成了。 ````shell [GC concurrent-cleanup-start]  [GC concurrent-cleanup-end, 0.0004520] ````
    
  2. 混合式GC (mixed GC) 这个阶段被称为混合式GC,即同时回收新生代以及前面后台扫描标记的分区中的一部分分区。在这个阶段,被回收的活跃数据会被移动到另一个分区,相当于是进行了一次额压缩和碎片整理。这也是相比CMS收集器较少出现碎片化的原因。
    1
    2
    3
    4
    5
    
     [GC pause (mixed), 0.26161600 secs] 
    .... 
    [Eden: 1222M(1222M)->0B(1220M) 
         Survivors: 142M->144M Heap: 3200M(4096M)->1964M(4096M)] 
    [Times: user=1.01 sys=0.00, real=0.26 secs]
    

    混合式GC会持续运行到几乎所有被标记的分区都被回收,然后开启下一次并发周期

  3. 必要时的Full GC 和CMS收集器一样,G1收集器也可能出现并发模式失效的情况
    • 老年代在标记周期完成之前被填满,G1收集器放弃标记周期
      1
      2
      3
      4
      
      [GC concurrent-mark-start] 
      [Full GC 4095M->1395M(4096M), 6.1963770 secs] 
      [Times: user=7.87 sys=0.00, real=6.20 secs] 
      [GC concurrent-mark-abort]
      

      这种失败意味着需要增大堆空间,或者让G1收集器更早开始,或者增加后台处理的线程数

  • 晋升失败,完成了标记阶段,启动混合回收,但是老年代空间在释放出足够的内存之前被耗尽,一般表现为混合收集后开始一次Full GC
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    
     2226.224: [GC pause (mixed) 
            2226.440: [SoftReference, 0 refs, 0.0000060 secs] 
            2226.441: [WeakReference, 0 refs, 0.0000020 secs] 
            2226.441: [FinalReference, 0 refs, 0.0000010 secs] 
            2226.441: [PhantomReference, 0 refs, 0.0000010 secs] 
            2226.441: [JNI Weak Reference, 0.0000030 secs] 
                    (to-space exhausted), 0.2390040 secs] 
    .... 
        [Eden: 0.0B(400.0M)->0.0B(400.0M) 
            Survivors: 0.0B->0.0B Heap: 2006.4M(2048.0M)->2006.4M(2048.0M)] 
        [Times: user=1.70 sys=0.04, real=0.26 secs] 
    2226.510: [Full GC (Allocation Failure) 
            2227.519: [SoftReference, 4329 refs, 0.0005520 secs] 
            2227.520: [WeakReference, 12646 refs, 0.0010510 secs] 
            2227.521: [FinalReference, 7538 refs, 0.0005660 secs] 
            2227.521: [PhantomReference, 168 refs, 0.0000120 secs] 
            2227.521: [JNI Weak Reference, 0.0000020 secs] 
                    2006M->907M(2048M), 4.1615450 secs] 
        [Times: user=6.76 sys=0.01, real=4.16 secs]
    

    此时需要更快完成GC,或者提前开始GC

  • 疏散失败,在进行新生代GC的时候,Survivor空间和老年代中没有足够的空间容纳幸存对象,一般这种情况是堆已经被用尽、或者碎片化过于严重。G1会使用Full GC来处理,处理方法可以增加堆的大小

  • 大对象分配失败,在分配巨大对象的时候,会出现和上面类似的问题

G1收集器调优

同样的G1收集器的主要调优目标也是避免并发模式失败和疏散失败等导致的Full GC,并减少整个回收周期中的停顿时间。可采取的策略主要如下

  • 增大堆空间、调整老年代和新生代的比例关系
  • 增加后台线程的数量,加入CPU资源充足
  • 提高G1收集器的GC频率
  • 在混合式GC周期中完成更多的GC工作(处理更多的分区)

最常见的调优策略是通过设置停顿时间-XX:MaxGCPauseMillis=N,默认值为200ms,让JVM自己进行调优。

手工调整则可

  1. 调整后台线程数量 ParallelGCThreads 设置在应用线程暂停时候的线程数量,ConcGCThreads 设置并发运行阶段的线程数量。默认的比例为ConcGCThreads = (ParallelGCThreads + 2)/ 4
  2. 调整运行频率 -XX:InitiatingHeapOccupancyPercent=N设置启动阈值,默认情况下为45%,这个阈值和CMS收集器不太一样,根据的是整个堆的内存使用情况,而不单是老年代
  3. 调整混合GC周期 在混合GC周期完成前,无法启动新的并发周期,因此可以在混合式GC周期内处理更多的分区,来减少混合式GC的次数。 混合式GC的工作量主要取决于,有多少分区被认为是可以回收的,也就是分区中垃圾对象的比例;另一个是最大混合式GC周期数-XX:G1MixedGCCountTarget=N,默认为8,减少这个数值,可以帮助解决晋升失败的问题,代价是单次停顿的变短更长。如果增加这个值,则每停顿的时间会变短,但是可能会延迟下一次G1并发周期的到来,引发并发模式失败;MaxGCPauseMillis也会影响混合式周期的执行,增大这个时间,可以使每次混合式GC回收更多的老年代分区。减少这个值,可以更早地启动并发周期。

G1分区的大小

G1收集器将堆分成一定数量的分区,每个分区的大小是固定的,并且不是动态变化。最小值为1MB,如果堆的大小超过2GB,则为1 << log(heap_size / 2048),最大不超过64MB。 G1分区的大小可以通过-XX:G1HeapRegionSize=N来设置,默认为0,也就是根据上面的公式计算。这个参数应该是2的幂,否则会去小于并且最接近这个数的2的幂。在调优这个参数的时候,我们应该尽量让分区的数量接近2048,这个是G1算法设计时候期望的分区数量。 此外增大G1分区的大小,能够让G1收集器更好地处理直接分配到老年代的超大对象,避免超大对象无法获取连续分区导致的Full GC。可以通过GC日志观察这些现象进行调优。

其他调优

Survivor

为了让对象在新生代中有足够的机会被回收,而不是等到Full GC的时候回收,因此将新生代分成三个区域,一个Eden空间和两个Survivor空间。一般情况下,在Eden分配的对象都会很快被回收,如果在一次Minor GC过程中,对象还存活,那么会从Eden区或者Survivor区移到另一个Survivor区。对象如果在Survivor中经历了GC周期达到上限,则会被移动到老年代中;如果Survivor区域无法直接容纳Eden中的活跃对象,那么也会分配到老年代中。

Survivor空间的初始大小通过-XX:InitialSurvivorRatio=N标志来决定,其具体的大小=新生代大小 / (initial_survivor_ratio + 2)默认情况下其值为8,也就是每个Survivor空间占新生代空间的10%。

Survivor空间的最大值通过-XX:MinSurvivorRatio=N来控制,这个参数默认为3,计算公式和上面一直,也就是最大允许占用20%。

JVM自动调整Survivor空间大小的时候,以GC之后,有-XX:TargetSurvivorRatio=N大小的空间是空闲为目标,默认这个值是50%

如果要设置为固定值,则通过-XX:SurvivorRatio=N来设置,并且同时关闭-XX:-UseAdaptiveSizePolicy

晋升到老年代的阈值设置,初始值为-XX:InitialTenuringThreshold=N(Throughout和G1默认为7,CMS为6)最大值为-XX:MaxTenuringThreshold=N(Throughout和G1默认为15,CMS为6)。实际上晋升阈值为1~MaxTenuringThreshold中的一个值,因此只要设置后者即可。

对于Survivor空间的调优,一般也是通过调整大小来进行,通过-XX:+PrintTenuringDistribution可以手机到晋升的统计信息,如果Survivor空间过小,对象会直接晋升到老年代,导致更多的老年代GC,如果堆的大小无法增加,那么Minor GC和老年代GC之间存在一个取舍。有点情况下,需要避免对象晋升到老年代,可以提升晋升阈值或者增加Survivor的大小。

分配大对象

在Eden空间中,对象能够快速分配对象是因为每一个线程都有一个固定的内存区域用于分配对象,这个区域成为TLAB(线程本地分配缓冲区 Thread Local Allocation Buffer)。线程独占避免了线程在同一片区域中分配空间带来的竞争和同步开销,和Java中的ThreadLocal变量起到相同的作用。 默认情况下TLAB是开启的,如果一个对象无法在TLAB中分配,我们称之为大对象,大对象需要直接在堆上分配,因此需要额外的同步开销。如果一个对象没有办法在TLAB上分配,由于TLAB是Eden空间上的一个区段,因此我们可以丢弃这个TLAB,重新分配一个TLAB,将数据复制过去,旧的TLAB会被回收,这种做法可能带来一些空间浪费。另一种做法是直接在堆中分配这个对象,保留原来的TLAB。这些主要取决于TLAB的大小,而TLAB的大小,在默认情况下取决于:应用程序的线程数、Eden空间的代销和线程的分配率。 我们很难准确预测TLAB的大小,一般的经验是通过监控TLAB的分配情况,如果大量的对象分配发生在TLAB之外,那么可以调整对象的大小,或者调整TLAB的大小。 使用JFR可以很好地获取TLAB分配信息 或者通过-XX:+PrintTLAB,这样每次Minor GC的时候,会输出包含该线程TLAB使用情况以及整体的TLAB使用情况。

TLAB的大小是基于Eden空间的,如果增大Eden空间就会自动增大TLAB的大小,更常用的优化方式是,显示指定TLAB值,并关闭每次GC时候的自动调整。-XX:TLABSize=N设置初始值默认这个值是0,以及-XX:-ResizeTLAB,默认情况下这个标识是开启的,一个可用的参考值是256KB

当一个对象无法直接在当前的TLAB上分配的时候,JVM通过refill waste阈值,来决定是丢弃当前的TLAB重新分配一个新的TLAB还是直接在堆上分配。如果超过这个阈值,则会在堆上分配,否则新分配TLAB,回收老的TLAB空间。默认这个值是TLAB大小的1%,或者--XX:TLABWasteTargetPercent=N来设置特定的值。每当在堆上分配一个对象的时候,这个值就会增大一点,增量由-XX:TLABWasteIncrement=N 控制,默认为4。这样可以避免达到了阈值之后,连续在堆上分配对象。随着TargetPercentage的增加,TLAB空间被回收的几率也在增加。

TLAB空间的最小值为-XX:MinTLABSize=N默认2KB,最大容量略小于1G。一般情况下,更加建议使用较小的对象,来避免使用大TLAB值。根据GC日志来进行调整是比较明智的选择。

而对于特大对象的分配,Eden区无法容纳,只能在老年代中分配,特别是这个对象还是一个短期对象,这会对GC产生非常负面的影响。应该从程序上避免这种情况的出现。

一个配置说明

-Xms4096M -Xmx4096M -Xmn512M -XX:MetaspaceSize=512M -XX:MaxMetaspaceSize=512M -Xss256K -XX:+TieredCompilation -XX:+CMSParallelRemarkEnabled -XX:+UseParNewGC -XX:+UseConcMarkSweepGC -XX:+UseFastEmptyMethods -XX:+UseFastAccessorMethods -XX:+ExplicitGCInvokesConcurrent -XX:ParallelCMSThreads=4 -XX:+CMSScavengeBeforeRemark -XX:+CMSClassUnloadingEnabled -XX:CMSInitiatingOccupancyFraction=80 -XX:-ResizeTLAB -XX:TLABSize=256K -XX:+PrintGCDetails -XX:+PrintGCTimeStamps -XX:+UseGCLogFileRotation -XX:NumberOfGCLogFiles=10 -XX:GCLogFileSize=100M -Xloggc:/home/data/logs/app/gc.log -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/home/data/logs/app/oom.hporf

JVM的参数配置,一般没有固定,也不存在标准的参数,需要根据不同的应用调整测试,上面是我比较经常使用的JVM配置,采用CMS收集器,主要的调整项是第一部分各个分代和分区的大小,这是对应用性能影响最大的因素。其他参数中比较值得说一下的是CMSInitiatingOccupancyFraction=80,这个参数会在老年代空间达到80%的时候,才开始并发回收周期,比默认的70%稍大,这样设置的主要考虑是,这份设置中当老年代使用达到80%的时候,剩余空间为(4096-512)* 0.2 = 716M,这个大小也大于整个新生代的值,相对还是比较不容易出现并发失效的,这样设置可以降低并发回收的周期。-XX:+ExplicitGCInvokesConcurrent则是代替了一般-XX:+DisableExplicitGC将显式的System.gc()调用从失效,变成调用一次CMS并发回收周期,算是一种折中选择。其他的优化则是一些常规的优化,部分参数和JVM的默认设置是重复的,如果需要参考,可以用jcmd process_id VM.flags -all 读取对应的参数设置,将重复的取出。

This post is licensed under CC BY 4.0 by the author.