Java性能优化03-JVM调优

Posted by ZhouJ000 on January 10, 2019

Java性能优化01-程序优化
Java性能优化02-并行优化
Java性能优化03-JVM调优
Java性能优化04-调优工具

Java基础: GC(一) 介绍
Java基础: GC(三) G1垃圾收集器优化

JVM内存模式

程序计数器:是一块线程私有的很小内存空间。由于java是支持线程的语言,当线程数量超过CPU数量时,线程间根据时间片轮询抢夺CPU资源。每个线程都必须用一个独立的程序计数器,用于记录下一条要运行的指令。各个线程间计数器互不影响,独立工作

Java虚拟机栈:也是线程私有内存空间,它和Java线程在同一时间创建,生命周期与线程相同,它保存方法的局部变量、部分结果,并参与方法的调用和返回,描述的是Java方法执行的内存模型。Java虚拟机规范允许Java栈的大小是动态的或者固定的。在Java虚拟机规范中,定义了两种异常和栈空间有关:StackOverFlowError和OutOfMemoryError。如果线程在计算过程中,请求的栈深度大于最大可用的栈深度,则抛出StackOverFlowError;如果Java栈可以动态扩展,而在扩展栈的过程中,没有足够的内存空间来支持栈的扩展,则抛出OutOfMemoryError。在HotSpot虚拟机中,可以用-Xss参数设置栈大小,栈的大小直接决定了函数可达深度。虚拟机栈在运行时使用一种叫做栈帧的数据结构保存上下文数据。在栈帧中存放了方法的局部变量表、操作数栈、动态连接方法和返回地址等信息。每一个方法的调用都伴随着栈帧的入栈操作,相应的方法的返回则表示栈帧的出栈操作

在栈帧中,与性能调优关系最为密切的部分就是局部变量表。局部变量表用于存放方法的参数和方法内部的局部变量。局部变量表以”Slot字”为单位进行内存划分,一个字为32位长度。对于long和double变量占2个字,其余类型占1个字。局部变量表所需的内存空间在编译器完成分配。在方法执行时,这个方法在帧中分配多大的空间是完全确定的,虚拟机使用局部变量表完成方法的传递,对于非static方法,虚拟机还会将当前对象(this)作为参数通过局部变量表传递给当前方法。可以使用jclasslib工具深入研究Class类文件的结构

局部变量表中的字空间是可以重用的。因为在一个方法体内,局部变量的作用范围并不一定是整个方法体。局部变量表的字,对系统GC也有影响。如果一个局部变量被保存在局部变量表中,那么GC根就能引用到这个局部变量所指向的内存空间,从而在GC时,无法回收这部分的空间。所以如果在变量失效后,如果在函数体内未能被后续变量复用这个变量所占的字,那整个函数体中这部分内存就不会被回收,如果函数体后续的操作非常费时,则会对系统性能造成压力,这种情况下需要手动释放变量赋值为null,是一种有效的方法

本地方法栈:本地方法栈和Java虚拟机栈的功能很相似,Java虚拟机栈用于管理Java函数的调用,而本地方法栈用于管理本地方法的调用。本地方法并不是用Java实现的,而是使用C实现的。在HotSpot虚拟机中,不区分本地方法栈和虚拟机栈,因此和虚拟机栈一样,它也会抛出StackOverFlowError和OutOfMemoryError

Java堆:Java堆可以说是Java运行时内存中最重要的部分,是被所有线程共享的一块区域,几乎所有的对象和数组都是在堆空间中分配空间的,但是随着JIT编译器的发展与逃逸分析技术逐渐成熟,栈上分配、标量替换优化技术导致一些微妙的变化,所有对象堆上分配也渐渐变得不那么”绝对”了。Java堆分为新生代和老年代两个部分,新生代又可进一步分为eden、Survivor space0(from space)和survivor space1(to space)。eden意为伊甸园,即对象的出生地,s0和s1为survivor空间,可意为幸存者,如果幸存者的对象到了指定年龄仍未被回收,则有机会进入老年代(tenured)

方法区:与堆空间类似,也是被JVM中所有的线程共享的,主要用于存储类的信息、常量池、方法数据、方法代码等。方法区逻辑上属于堆的一部分,但是为了与堆进行区分,通常又叫“非堆”。在JDK1.8之前HotSpot的实现中是用永久代实现的,虽然叫永久代但同样也是可以被GC回收的,只是对于GC的表现也和堆空间略有不同,通常对永久代GC的回收从两个方面分析,一是GC对永久代常量池的回收,二是永久代对类元数据的回收(需要虚拟机确认该类的所有实例已被回收并不会再被使用,并且加载该类的ClassLoader已经被回收,GC就有可能回收该类型);而在JDK1.8之后永久代被抛弃,使用元空间

JVM内存分配参数

大小分配

最大堆内存:-Xmx,指新生代和老年代的大小之和的最大值
最小堆内存:-Xms,Java应用程序运行时,首先会被分配的内存大小,无法满足时会向操作系统申请更多的内存。JVM会试图将系统内存尽可能限制在-Xms中,因此当内存实际使用量触及-Xms指定大小时,会触发Full GC,因此把-Xms设置为-Xmx时,可以在系统运行初期减少GC次数和耗时

新生代:-Xmn,设置新生代的大小会影响老年代的大小,这个参数对系统性能与GC有很大的影响。一般新生代设置为整个堆内存空间的1/4到1/3左右。在HotSpot中,可以用-XX:NewSize和-XX:MaxNewSize分别设置新生代初始值和最大值,但是一般用-Xmn统一设置就足够了
永久代:-XX:PermSize和-XX:MaxPermSize,在JDK1.8之前,可以分别设置永久代的初始大小和最大值
元空间:-XX:MetaspaceSize和-XX:MaxMetaspaceSize,在JDK1.8之后使用元空间,元空间的大小仅受本地内存限制,但可以分别设置元空间的初始大小和最大值(默认没有限制)

每个线程堆栈:-Xss,在线程中进行局部变量分配,函数调用时都需要在栈中开辟空间。如果栈的空间分配太小,那线程运行中可能没有足够空间分配局部变量或达不到足够的深度导致异常退出;如果栈空间过大,那么开设线程所需的内存成本上升,系统所能支持的线程总数也会下降

Direct内存:-XX:MaxDirectMemorySize,javaNIO中通过Direct内存来提高性能,这个区域的大小默认是64M,在适当的场景可以设置大一些

比例分配

新生代:-XX:SurvivorRatio,用于设置新生代中,eden空间和s0空间的比例关系,其中s0与s1空间大小是相同的,只能也是一样的,并在MinorGC之后会互换角色,因此值为eden/s0 = eden/s1
新生代与老年代:-XX:NewRatio,用来设置老年代/新生代之间的比例

survivior使用率:-XX:TargetSurvivorRatio,设置survivior区的可使用率,当使用率达到这个数值时,会将对象送入老年代
新生代存活次数:-XX:MaxTenuringThreshold,在新生代中对象存活次数(经过Minor GC的次数)后仍然存活,就会晋升到老年代
直接进入老年代:-XX:PretenureSizeThreshold,设置大对象直接进入老年代的阈值,当对象的大小超过这个值时直接在老年代分配
堆空闲比例:-XX:MinHeapFreeRatio与-XX:MaxHeapFreeRatio,设置堆空间的对最小/最大空闲比例。当堆空间的空闲内存大于/小于对应值时,JVM便会扩展/压缩堆空间大小
元空间空闲比例:-XX:MinMetaspaceFreeRatio与-XX:MaxMetaspaceFreeRatio,设置最小/最大的Metaspace剩余空间容量的百分比,Metaspace GC之后,用来控制扩展/压缩元空间大小

可以使用-XX:PrintGCDetails来打印出堆的实际大小

垃圾收集

算法与思想

引用计数法:最经典也是最古老的一种垃圾收集方法。引用计数器,只需要为每个对象配备一个整形的计数器即可,但是引用计数器有个严重的问题,即无法处理循环引用问题。因此在Java的垃圾回收器中没有使用这种算法

标记-清除算法(Mark-Sweep):是现代垃圾回收算法的思想基础。将垃圾回收分为两个阶段,标记阶段和清除阶段。一种可行的实现是,在标记阶段通过根节点标记所有从根节点开始可达的对象,然后在清除节点清除所有未被标记的垃圾对象。标记-清除算法可能产生的最大问题就是空间碎片,因为回收后的空间是不连续的

复制算法(Copying):与标记-清除算法相比,复制算法是一种相对高效的回收方法。核心思想是将原有的内存空间分为两块,每次只使用一块,在垃圾回收时,将正在使用的内存中的存活对象复制到未使用的内存中,之后清除正在使用的内存块的所有对象,交换两个内存的角色,完成垃圾回收。如果系统中垃圾对象多,复制算法需要复制的对象数量不会太多,又由于对象被复制到新的内存空间,所以确保没有碎片。但是复制算法缺点是将系统内存折半,因此单纯的复制算法难以让人接受

在Java的新生代串行垃圾回收期中,使用了复制算法的思想,将新生代分为eden空间、from空间和to空间3个部分。其中from和to空间可以视为用于复制的两块大小相同、地位相等且可角色互换的空间块。复制算法比较适合新生代,因为在新生代中,垃圾对象通常会多于存活对象,复制算法的效果会比较好

标记-压缩算法(Mark-Compact):复制算法的高效建立在存活对象少、垃圾对象多的场景下,因此适合年轻代。但是对于老年代,更常见的是大部分对象都是存活对象,因此基于这种特性,需要使用新的算法。标记-压缩算法在标记-清除的基础上做了一些优化。与标记-清除一样从根节点开始对所有可达对象做一次标记,但是之后清理未标记对象时,将存活对象压缩到内存的一端,然后清理边界外的所有空间。这种方法既避免碎片的产生,又不需要两块相同的内存空间,因此性价比较高

增量算法(Incremental Collecting):对大部分垃圾回收算法来说,在回收过程应用程序都处于stop the world状态,所有线程都会挂起,暂停一切正常工作等待垃圾回收完成。如果回收时间很长就会严重影响用户体验和系统稳定性。增量算法的基本思想是,如果一次性将所有垃圾进行处理,需要造成系统长时间停顿,那么就可以让垃圾回收线程和应用程序线程交替执行。每次垃圾收集线程只收集一小块的内存空间,然后切换到应用程序线程,如此反复直到垃圾收集完成。这种方式下,能间断性地执行应用程序代码,所以能减少系统停顿时间,但是由于线程切换和上下文转换的消耗,会使得垃圾回收总成本上升,造成系统吞吐量下降

分代(Generational Collecting):所有的算法都无法完全替代其他算法,都具有独特的优势和特点,因此根据垃圾回收对象的特性,使用合适的算法才是明智之举。分代就是基于这种思想,将内存区间根据对象的特点分成几块,根据每块内存的特点选择不同的回收算法,提高回收效率。几乎所有的垃圾回收器都区分年轻代和老年代

分区算法(Region):分代算法是按照对象的生命周期长短划分成两个部分,分区算法是将整个堆空间划分为连续的不同小区间,每个小区间都独立使用,独立回收。这种算法的好处是可以控制一次回收多少个小区间,一般来说堆空间越大,相同条件下一次GC所需的时间就越长,那么每次合理回收若干个小区块,从而可以减少一次GC所产生的停顿

分类

按线程数分,可以分为串行垃圾回收器和并行垃圾回收器;按工作模式分,可以分为并发式垃圾回收器和独占式垃圾回收器;按碎片处理方式,可以分为压缩式垃圾回收器和非压缩式垃圾回收器;按工作的内存区间,可以分为新生代垃圾回收器和年老代垃圾回收器

评价指标:
1、吞吐量:指在应用程序的生命周期内,应用程序所花费的时间和系统总运行时间(应用耗时+GC耗时)的比值
2、垃圾回收器负载:与吞吐量相反,是垃圾回收器耗时与系统运行总时间的比值
3、停顿时间:指垃圾回收器正在运行时,应用程序的暂停时间
4、垃圾回收频率:指垃圾回收器多长时间会运行一次
5、反应时间:指当一个对象成为垃圾后,多长时间它所占用的内存会被释放
6、堆分配:不同垃圾回收器对堆内存的分配方式可能不同,一个良好的垃圾回收期需要有一个合理的堆内存区间划分

新生代串行收集器 Serial

是最古老的一种,也是JDK最基本的垃圾收集器之一。主要有两个特点:第一,它仅仅使用单线程进行垃圾回收;第二,它是独占式的垃圾回收。因此垃圾收集器运行时,应用所有线程都停止工作进行等待。虽然如此,但是串行收集器是一个成熟、经过长时间考验的极为高效的收集器。新生代串行收集器使用复制算法,实现相对简单,逻辑处理高效,而且没有线程切换的开销,在诸如单CPU或较小应用内存等硬件的平台,它的性能可以超过并行回收器和并发回收器

-XX:+UseSerialGC,指定使用新生代串行收集器和年老代串行收集器。当JVM在Client模式下时,它是默认的垃圾收集器

老年代串行收集器 Serial Old

采用的是标记-压缩算法,和新生代串行收集器一样,是串行的、独占式的垃圾回收器。由于老年代垃圾回收器通常比新生代垃圾回收器使用更长的时间,因此在堆空间较大的应用中,一旦老年串行收集器启动,应用程序将会因此停顿几秒甚至更长。虽然如此,作为老牌的垃圾回收器,老年代串行收集器可以和多种新生代回收器配合使用,同时它也作为CMS回收器的备用回收器

-XX:+UseSerialGC,新生代、老年代都使用串行回收器
-XX:+UseParNewGC,新生代使用并行收集器,老年代使用串行收集器
-XX:+UseParallelGC,新生代使用并行回收收集器,老年代使用串行收集器

新生代并行收集器 ParNew

并行收集器是工作在新生代的垃圾收集器,它只是简单地将串行回收器多线程化,它的回收策略、算法以及参数和串行回收器一样,并行回收器也是独占式的回收器。但由于多线程进行垃圾回收,因此在并发能力较强的CPU上,暂停时间短于串行回收器

-XX:ParallelGCThreads,指定工作时的线程数量,一般与CPU数量相当
-XX:+UseParNewGC,新生代使用并行收集器,老年代使用串行收集器
-XX:+UseConcMarkSweepGC,新生代使用并行收集器,老年代使用CMS

新生代并行回收收集器 Parallel Scavenge

新生代并行回收器也是使用复制算法的收集器,表面上和并行收集器一样,都是多线程、独占式的收集器。但是它有一个重要的特点,就是它非常关注系统的吞吐量。除此之外,并行回收收集器与并行收集器另一个不同之处在于,它还支持一种自适应的GC调节策略,这种模式下,新生代的大小、eden和survivor的比例、晋升老年代的对象年龄等参数都会被自动调整,已达到堆大小、吞吐量和停顿时间的平衡点,在手动调优比较难的场合可以使用这种自适应的方式

-XX:+UseParallelGC,新生代使用并行回收收集器,老年代使用串行收集器
-XX:+UseParallelOldGC,新生代和老年代都使用并行回收收集器
-XX:MaxGCPauseMillis,设置最大垃圾收集停顿时间,值为大于0的整数。收集器在工作时,会调整Java堆大小或其他参数,尽可能把停顿时间控制在范围内。如果把值设置过小,可能JVM会使用很小的堆(小堆回收更快),这将导致垃圾回收变得频繁,反而增加总时间,降低吞吐量
-XX:GCTimeRatio,设置吞吐量大小,值是0~100之间的整数
-XX:+UseAdaptiveSizePolicy,打开自适应GC策略

老年代并行回收收集器 Parallel Old

老年代并行回收收集器也是一种多线程并发的收集器,和新生代并行回收收集器一样,也是一种关注吞吐量的收集器。老年代并行回收收集器使用标记-压缩算法

-XX:+UseParallelOldGC,新生代和老年代都使用并行回收收集器

CMS收集器

与并行回收收集器不同,CMS收集器主要关注系统停顿时间,是一种以获取最短回收停顿时间为目标的收集器。是Concurrent Mark Sweep的缩写,意为并发标记清除,因此它使用的是标记-清除算法,同时也是一个使用多线程并行回收的垃圾收集器。只会回收老年代和永久带(1.8开始为元数据区,需要设置CMSClassUnloadingEnabled),不会收集年轻代。CMS收集器的工作过程略显复杂,主要步骤有:初始标记、并发标记、重新标记、并发清除和并发重置(回收完成后重新初始化CMS数据结构和数据)。其中初始标记和重新标记是独占系统资源的,而并发标记、并发清除和并发重置是可以和用户线程一起并发执行的。因此不算是独占式的,可以在应用程序运行过程中进行垃圾回收

-XX:+UseConcMarkSweepGC,使用CMS收集器
-XX:ParallelGCThreads,设置CMS的线程数量
-XX:CMSInitiatingOccupancyFraction,设置老年代使用率回收阈值,因为CMS回收时,应用持续工作,因此会有新的垃圾产生,而这些垃圾在CMS回收过程中无法清除,因此CMS回收过程中还需要保证有足够的内存可用,这样就不等待堆内存饱和再进行回收,而是当堆内存使用率达到某一阈值后就进行回收
-XX:UseCMSCompactAtFullCollection,开关使CMS在垃圾收集完成后,进行一次内存碎片整理,内存碎片整理不是并发进行的
-XX:CMSFullGCsBeforeCompaction,指定进行多少次CMS回收后进行一次内存压缩

图解CMS垃圾回收机制

G1收集器

是目前最新的垃圾回收器,目标是作为一款服务端的垃圾收集器,因此在吞吐量和停顿控制上,预期要优于CMS收集器。与CMS相比,G收集器是基于标记-压缩算法的,因此不会产生空间碎片,G1收集器还可以进行非常精确的停顿控制。G1能充分利用CPU、多核环境下的硬件优势,使用多个CPU(CPU或者CPU核心)来缩短stop-The-World停顿时间。部分其他收集器原本需要停顿Java线程执行的GC动作,G1收集器仍然可以通过并发的方式让java程序继续执行。虽然G1可以不需要其他收集器配合就能独立管理整个GC堆,但是还是保留了分代的概念。降低停顿时间是G1和CMS共同的关注点,但G1除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为M毫秒的时间片段内。G1的运行步骤有1、初始标记;2、并发标记;3、最终标记;4、筛选回收

-XX:+UseG1GC,启用G1回收器
-XX:+UnlockExperimentalVMOptions,允许使用实验性参数
-XX:GCPauseIntervalMillis,设置停顿间隔时间
-XX:MaxGCPauseMillis,设置最大垃圾收集停顿时间

常见调优方法

将新对象预留在新生代:由于Full GC的成本要远远高于Minor GC,因此尽可能将对象分配在新生代是一项明智的做法。虽然大部分情况下,JVM会尝试在eden区分配对象,但是由于空间紧张等问题,很可能不得不将部分年轻代对象提前先老年代压缩。因此在JVM参数调优时,可以为应用分配一个合理的新生代空间,最大限度避免新对象直接进入老年代的情况

-Xmn,-XX:NewRatio,-XX:SurvivorRatio

大对象进入老年代:大部分情况下将对象分配到新生代是合理的,但是对于大对象,很可能扰乱新生代GC,并破坏新生代原有的对象结构。因为尝试在新生代分配大对象,可能导致空间不足,JVM不得不将新生代中的年轻对象移动到年老代。因为大对象占用空间大,所以可能需要移动大量小的年轻对象进入老年代,这对GC来说是相当不利的。但是如果大对象是个短命的对象,这种情况出现比较频繁,那对GC也是一种灾难,扰乱了分代内存回收的思想,因此应该尽可能避免使用短命的大对象

-XX:PretenureSizeThreshold

设置对象进入老年代的年龄:对象在eden区经过一次GC后还存活,就移到survivior区并年龄加1,直到达到阈值进入老年代。默认值是15,但不意味着必须要达到这个年龄才能进入老年代。实际上,对象实际进入老年代的年龄是虚拟机在运行时根据内存使用情况动态计算的,参数只是可以指定阈值年龄的最大值,即实际晋升年龄取阈值与动态计算年龄中的最小值

-XX:MaxTenuringThreshold

稳定与震荡堆大小:稳定的堆大小对垃圾回收是有利的,获得稳定堆大小的方式就是将-Xms和-Xmx设为大小一致。这样运行时堆大小恒定,稳定的堆空间可以减少GC次数。但是不稳定的堆也并不是毫无用处,稳定的堆虽然减少GC次数,但是也可能增加每次GC的时间。当系统不需要大内存时,让堆大小在一个区间中震荡,压缩堆空间,使GC应对一个较小的堆,可以加快单次GC速度

-XX:MinHeapFreeRatio,-XX:MaxHeapFreeRatio

吞吐量优先案例:-Xms与-Xmx一致,使用-XX:+UseParallelGC新生代使用并行回收收集器并设置线程数,-XX:UseParallelOldGC老年代也使用并行回收收集器

使用大页案例:-XX:LargePageSizeInBytes设置大页的大小,使用大的内存分页可以增强CPU的内存寻址能力,从而提高系统性能

降低停顿案例:首先考虑使用关注系统停顿时间的CMS回收器,其次考虑减少Full GC次数,应尽可能将对象预留在新生代

其他实用JVM参数

JIT编译参数

JVM的JIT编译器,可以在运行时将字节码编译为本地代码,从而提高函数的执行效率,JIT编译会花费一定时间,但未来运行中这些时间会被赚回来

-XX:CompileThreshold,JIT编译阈值,当函数调用次数超过该值,JIT就将字节码编译为本地机器码。在Client模式默认1500,Server模式下默认10000
-XX:+CITime,打印JIT编译的耗时
-XX:+PrintCompilation,打印JIT编译的信息

堆快照

-XX:+HeapDumpOnOutOfMemoryError,将当前的堆信息保存到文件中,对于排查问题是很有帮助的
-XX:HeapDumpPath,指定堆快照保存位置

取得GC信息

-verbose:gc或-XX:+PrintGC,获取简要的GC信息
-XX:+PrintGCDetails,获取详细GC信息

类和对象跟踪

-XX:+TraceClassLoading,跟踪类加载信息
-XX:+TraceClassUnloading,跟踪类卸载信息
-verbose:class,同时打开类加载和类卸载信息

控制GC

-XX:+DisableExplicitGC,禁止在程序中使用System.gc()触发Full GC
-Xnoclassgc,不需要回收类
-Xincgc,开启后系统会进行增量式的GC,增量式GC使用特定算法让GC线程与应用线程交叉执行,从而减小停顿时间

Solaris下线程控制

-XX:+UseBoundThreads,绑定所有用户线程到内核线程,减少线程进饥饿状态的次数
-XX:+UseLWPSynchronization,使用内核线程代替线程同步
-XX:+UseVMInterruptibleIO,允许运行时中断线程

使用大页

-XX:+UseLargePages,启用大页
-XX:LargePageSizeInBytes,设置大页的大小

引用:《Java程序性能优化》、《深入理解Java虚拟机》