1. 写在前面
本章节涉及JVM调优演示所使用的环境为JDk1.8
、IDEA2020
、Maven3.5
。因为在讲解JVM调优过程中,还会涉及到一些内存方面的知识,这里只简要概括下,如果在看的过程中觉得有吃力的地方,需要再去补充!
2. 内存
2.1. 堆与栈
栈与堆 是应用程序运行的关键。
栈是运行时单位,解决程序的运行问题,即程序如何执行,或者说如何处理数据;而堆是存储的单位,即数据怎么放、放在哪儿。
2.2. 内存模型
2.2.1. 程序计数器
线程私有的,互不影响、独立存储,是当前线程执行的字节码的行号指示器。让字节码解释器工作时可以通过改变这个计数器的值来选取下一执行的字节码的指令,分支、循环、跳转、异常、恢复都需要这个计数器来完成。
程序计数器是唯一一个不会出现 OutOfMemoryError
的内存区域。
2.2.2. 虚拟机栈
线程私有的,JAVA方法执行的动态内存模型,每个方法执行都会创建一个栈帧,伴随着方法创建到执行完成,用于存储局部变量表、操作数栈、动态链接、方法出口信息等组成。
局部变量表:局部变量表存放编译器可知的基础类型(包括int、boolean、byte、char、short、lang、double、float)以及对象引用(地址引用或者句柄引用)。大小在编译过程中以及分配,在方法执行中改变不了大小
虚拟机栈常见的两种异常有:
- StackOverFlowError:Java虚拟机栈的内存大小不允许动态扩展,当线程请求栈的深度超过当前Java虚拟机定义的空间,则抛出
StackOverFlowError
- OutOfMemoryError:Java虚拟机栈的内存大小许动态扩展,当线程请求栈的内存用完啦,则抛出
OutOfMemoryError
- StackOverFlowError:Java虚拟机栈的内存大小不允许动态扩展,当线程请求栈的深度超过当前Java虚拟机定义的空间,则抛出
2.2.3. 本地方法栈
与 虚拟机栈
类似,只不过它是虚拟机用到的 Native
方法服务的,也有栈帧,同样的也会抛出与 虚拟机栈
一样的异常。
2.2.4. 堆
这是虚拟机所管理的内存中最大的一块,所有对象实例均存储在这块区域,因此也被人称作GC堆,也是垃圾回收器重点工作的区域。分为:年轻代、老年代。
2.2.4.1. 年轻代
新生代也称年轻代(Young Gen)这里二者没有区别,所以在篇幅中我都会混着用。年轻代主要存放新创建的对象,内存大小相对较小,垃圾回收比较频繁,将年轻代 再细分,它有 Eden
和两个Suvivor
(也可以叫做from
和to
),二者之间默认比例为8:1
,至于为啥默认会是这样,后面会聊到。
2.2.4.2. 年老代
年老代主要存放JVM
认为生命周期比较长
的对象(经过几次的Young Gen
的垃圾回收后仍然存在),内存大小相对会比较大,垃圾回收也相对没有那么频繁(譬如可能几个小时一次)。年老代主要采用压缩的方式来避免内存碎片(将存活对象移动到内存片的一边,也就是内存整理)。当然,有些垃圾回收器(譬如CMS垃圾回收器)出于效率的原因,可能会不进行压缩。
2.2.4.3. 内存申请流程
- 一个对象在创建过程中,会优先考虑在
栈
上分配,这个原则栈上分配
,因为在栈中,用不着垃圾回收器,随着栈帧
消亡,就结束啦,省事。当然在栈上分配还有很多原则,这个在文中也有讲解! - 当对象无法在栈上分配的时候,就需要在堆中给它开辟空间,这时候还需要看这个对象的大小,对象过大,
JVM
将它直接丢尽年老代
; - 否则经过
TLAB
机制的选择,加速对象分配(); - 该对象写入
Eden
;当该对象经过第一次Minor GC
还Live
,将对象迁移到Suvivor
,对象在Suvivor
每经历一次Minor GC
,年龄(AGE
)都会加1
,当年龄AGE
超过一定数量,该对象直接被移到年老代。
以上内容的重点是年轻代中的GC
,JVM总要保证 Suvivor
(from
和to
)中 to
是空的。在 Minor GC
后,将Eden
存活的对象复制到 to
,而在 from
中的对象,根据年龄来决定,已到阈值的对象,直接移动到老年代;没有到达阈值的则被复制到 to
,这时候清空 Eden
和 from
,from
与to
的角色将调换,重复上述动作。
2.3. 对象的内存布局
对象在JVM中布局,主要分为两种:普通对象、一种是数组对象。
2.3.1. 普通对象
- MARK Word:固定大小,8 bytes,存储信息有:锁、Identified HashCode
- 类型指针:8 bytes(kclass Pointer),JDK1.8之后,默认开启指针压缩,大小 4bytes
- 实例数据:0
- 对齐填充: 0 bytes
分为两种情况,开启指针压缩的情况下,类型指针(4 bytes)加上 对齐填充(4 bytes),所以一共 16 bytes;未开启指针压缩的情况下,类型指针(8 bytes)加上 对齐填充(40 bytes),所以一共 16 bytes。
虽然最终大小一致,但是还是有区别。
2.3.2. 数组对象
- MARK Word:固定大小,8 bytes,存储信息有:锁、Identified HashCode
- 类型指针:8 bytes(kclass Pointer),JDK1.8之后,默认开启指针压缩,大小 4bytes
- 数组长度:0 bytes,只有数据类型对象特有
- 实例数据:0
- 对齐填充:
同理,这里也分 指针压缩
是否开启两种情况,原理同上!
2.4. 计算空对象大小
new 一个空对象,查看它占用的空间大小。这里会用一个依赖包jol
,加入依赖pom
1 | <dependency> |
利用ClassLayout.parseInstance($OBJECT$).toPrintable()
查看对象信息。
2.4.1. 计算空对象大小
2.4.1.1. 关闭指针压缩情况下
未开启指针压缩的情况下,一个空对象占用空间大小是 16 bytes。
1 | xyz.wongs.jvm.JvmObjSize object internals: |
2.4.1.2. 开启指针压缩情况下
MARK Word:8 bytes
类型指针:4 bytes(kclass Pointer)
数组长度:0 bytes
实例数据:0
对齐填充:4 bytes
1 | xyz.wongs.jvm.JvmObjSize object internals: |
2.4.2. 数组对象
只有数组对象,才有对齐填充
2.4.3. 普通对象
2.4.3.1. 开启指针压缩
MARK Word:8 bytes
类型指针:4 bytes(kclass Pointer)
数组长度:0 bytes
实例数据:4 bytes
对齐填充:0 bytes
1 | xyz.wongs.jvm.JvmObjSize02 object internals: |
2.4.3.2. 关闭指针压缩
MARK Word:8 bytes
类型指针:4 bytes(kclass Pointer)
数组长度:0 bytes
对齐填充:4 bytes
实例数据:4 bytes
对齐填充:4 bytes
1 | xyz.wongs.jvm.JvmObjSize02 object internals: |
2.5. 指针压缩
1 | C:\Users\WONGS>java -XX:+PrintCommandLineFlags -version |
2.5.1. 实现原理
2.5.2. 真实地址如何计算出来
2.5.3. 调优参数
-XX:+/-UseCompressedClassPointers 是否开启压缩指针类型,堆内存 超过32G,此项压缩就失效
-XX:+/-UseCompressedOops 是否开启压缩普通指针
2.5.4. oop,最大值是?
2.5.5. 压缩哪些东西,不压缩哪些?
2.6. 虚拟机栈溢出与调优
问题分析的方法
2.7. JAVA启动参数
参数 | 描述 | 例子 |
---|---|---|
Xmx | 设置最大堆内存 | -Xmx3550m |
Xms | 设置初始堆内存,很多时候Xms与Xmx设置相同,以避免每次垃圾回收完成后JVM重新分配内存,提高性能。 | -Xms3550m |
Xss | 设置每个线程的栈大小,JDK5以后每个线程栈大小为1M,之前每个线程栈大小为256K。在相同物理内存下,减小这个值能生成更多的线程。但是操作系统对一个进程内的线程数还是有限制的,不能无限生成,经验值在3000~5000左右。需要注意的是:当这个值被设置的较大(例如>2MB)时将会在很大程度上降低系统的性能。 | -Xss128k |
Xmn | 设置年轻代大小,在整个堆内存大小确定的情况下,增大年轻代将会减小年老代,反之亦然。此值关系到JVM垃圾回收,对系统性能影响较大,官方推荐配置为整个堆大小的3/8。 | -Xmn2g |
XX:NewSize | 设置年轻代初始值为1024M | -XX:NewSize=1024m |
XX:MaxNewSize | 设置年轻代最大值为1024M。 | -XX:MaxNewSize=1024m |
XX:PermSize | 设置持久代初始值为256M。 | -XX:PermSize=256m |
XX:MaxPermSize | 设置持久代最大值为256M。 | -XX:MaxPermSize |
XX:NewRatio | 设置年轻代(包括1个Eden和2个Survivor区)与年老代的比值。表示年轻代比年老代为1:4。 | -XX:NewRatio=4 |
XX:SurvivorRatio | 设置年轻代中Eden区与Survivor区的比值。表示2个Survivor区(JVM堆内存年轻代中默认有2个大小相等的Survivor区)与1个Eden区的比值为2:4,即1个Survivor区占整个年轻代大小的1/6。 | -XX:SurvivorRatio=4 |
XX:MaxTenuringThreshold | 表示一个对象如果在Survivor区(救助空间)移动了7次还没有被垃圾回收就进入年老代。如果设置为0的话,则年轻代对象不经过Survivor区,直接进入年老代,对于需要大量常驻内存的应用,这样做可以提高效率。如果将此值设置为一个较大值,则年轻代对象会在Survivor区进行多次复制,这样可以增加对象在年轻代存活时间,增加对象在年轻代被垃圾回收的概率,减少Full GC的频率,这样做可以在某种程度上提高服务稳定性。 | -XX:MaxTenuringThreshold=7 |
XX:PretenureSizeThreshold | 设置持大对象的大小,超过该大小将进入老年代。 | -XX:PretenureSizeThreshold=9M |
XX:HandlePromotionFailure | 空间担保 | -XX:+/-HandlePromotionFailure |
-Xmn,-XX:NewSize/-XX:MaxNewSize,-XX:NewRatio
3 组参数都可以影响年轻代的大小,混合使用的情况下,优先级是什么?
如下:
- 高优先级:
-XX:NewSize/-XX:MaxNewSize
- 中优先级:
-Xmn(默认等效 -Xmn=-XX:NewSize=-XX:MaxNewSize=?)
- 低优先级:
-XX:NewRatio
推荐使用 -Xmn
参数,原因是这个参数简洁,相当于一次设定 NewSize/MaxNewSIze
,而且两者相等,适用于生产环境。-Xmn
配合 -Xms/-Xmx
,即可将堆内存布局完成
2.7.1. 垃圾回收参数
- -XX:+ScavengeBeforeFullGC:年轻代GC优于Full GC执行。
- -XX:-DisableExplicitGC:不响应 System.gc() 代码。
- -XX:+UseThreadPriorities:启用本地线程优先级API。即使 java.lang.Thread.setPriority() 生效,不启用则无效。
- -XX:SoftRefLRUPolicyMSPerMB=0:软引用对象在最后一次被访问后能存活0毫秒(JVM默认为1000毫秒)。
- -XX:TargetSurvivorRatio=90:允许90%的Survivor区被占用(JVM默认为50%)。提高对于Survivor区的使用率。
2.7.2. 辅助信息参数设置
- -XX:-CITime:打印消耗在JIT编译的时间。
- -XX:ErrorFile=./hs_err_pid.log:保存错误日志或数据到指定文件中。
- -XX:HeapDumpPath=./java_pid.hprof:指定Dump堆内存时的路径。
- -XX:HeapDumpOnOutOfMemoryError:当首次遭遇内存溢出时Dump出此时的堆内存。
- -XX:OnError=”;”:出现致命ERROR后运行自定义命令。
- -XX:OnOutOfMemoryError=”;”:当首次遭遇内存溢出时执行自定义命令。
- -XX:-PrintClassHistogram:按下 Ctrl+Break 后打印堆内存中类实例的柱状信息,同JDK的 jmap -histo 命令。
- -XX:-PrintConcurrentLocks:按下 Ctrl+Break 后打印线程栈中并发锁的相关信息,同JDK的 jstack -l 命令。
- -XX:-PrintCompilation:当一个方法被编译时打印相关信息。
- -XX:-PrintGC:每次GC时打印相关信息。
- -XX:-PrintGCDetails:每次GC时打印详细信息。JAVA11中不提供使用,
- -XX:-PrintGCTimeStamps:打印每次GC的时间戳。
- -XX:-TraceClassLoading:跟踪类的加载信息。
- -XX:-TraceClassLoadingPreorder:跟踪被引用到的所有类的加载信息。
- -XX:-TraceClassResolution:跟踪常量池。
- -XX:-TraceClassUnloading:跟踪类的卸载信息。
3. 垃圾回收
3.1. 垃圾回收算法
3.1.1. 按基本回收策略
3.1.1.1. 引用计数(Reference Counting)
原理就是维持对象的一个引用,即增加一个计数,删除一个引用则减少一个计数。垃圾回收时,只用收集计数为0的对象。此算法最致命的是无法处理循环引用的问题。
3.1.1.2. 标记-清除(Mark-Sweep)
此算法执行分两阶段:
- 阶段I:从引用根节点开始标记所有被引用的对象;
- 阶段II:遍历整个堆,把未标记的对象清除。此算法需要暂停整个应用,同时,会产生内存碎片。
3.1.1.3. 标记-整理(Mark-Compact)
标记-整理
集合 标记-清除
和 复制
两个算法的优点,也是分两阶段:
- 阶段I:从根节点开始标记所有被引用对象;
- 阶段II:遍历整个堆,把清除未标记对象并且把存活对象“压缩”到堆的其中一块,按顺序排放。此算法避免了“标记-清除”的碎片问题,同时也避免了“复制”算法的空间问题。
3.1.2. 按分区对待的方式
3.1.2.1. 增量收集(Incremental Collecting)
实时垃圾回收算法,即:在应用进行的同时进行垃圾回收。不知道什么原因JDK5.0中的收集器没有使用这种算法的。
3.1.2.2. 分代收集(Generational Collecting)
基于对对象生命周期分析后得出的垃圾回收算法。把对象分为年青代、年老代、持久代,对不同生命周期的对象使用不同的算法(上述方式中的一个)进行回收。现在的垃圾回收器(从J2SE1.2开始)都是使用此算法的。
3.1.3. 按系统线程分
3.1.3.1. 串行收集
串行收集使用单线程处理所有垃圾回收工作,因为无需多线程交互,实现容易,而且效率比较高。但是,其局限性也比较明显,即无法使用多处理器的优势,所以此收集适合单处理器机器。当然,此收集器也可以用在小数据量(100M左右)情况下的多处理器机器上。
3.1.3.2. 并行收集
并行收集使用多线程处理垃圾回收工作,因而速度快,效率高。而且理论上CPU数目越多,越能体现出并行收集器的优势。
3.1.3.3. 并发收集
相对于串行收集和并行收集而言,前面两个在进行垃圾回收工作时,需要暂停整个运行环境,而只有垃圾回收程序在运行,因此,系统在垃圾回收时会有明显的暂停,而且暂停时间会因为堆越大而越长。
3.2. 垃圾回收器
行收集器、并行收集器、并发收集器。串行收集器只适用于小数据量的情况,所以生产环境的选择主要是并行收集器和并发收集器。在JDK5.0以前都是使用串行收集器,如果想使用其他收集器需要在启动时加入相应参数。JDK5.0以后,JVM会根据当前系统配置进行智能判断。
3.2.1. 串行收集器(Serial Garbage Collector)
3.2.1.1. Serial
Serial
历史悠久,它是为单线程环境而设计的垃圾收集器,适合桌面应用,内存较小的场景,它在工作中会产生停顿,
- 适用场景:数据量比较小(100M左右);单处理器下并且对响应时间无要求的应用。
- 缺点:只能用于小型应用
设置:
1 | -XX:+UseSerialGC |
3.2.1.2. ParNew
ParNew
其实是也是Serial
,唯一的区别就是将Serial
单线程改为多线程执行。
- 新生代并行,老年代串行;
- 新生代复制算法、老年代标记-压缩
设置:
1 | -XX:+UseParNewGC |
3.2.2. 并发收集器(响应时间优先)
并发收集器 适合 “对响应时间有高要求”,多CPU、对应用响应时间有较高要求的中、大型应用。举例:Web服务器/应用服务器、电信交换、集成开发环境。
- -XX:+UseConcMarkSweepGC:即CMS收集,设置年老代为并发收集。CMS收集是JDK1.4后期版本开始引入的新GC算法。它的主要适合场景是对响应时间的重要性需求大于对吞吐量的需求,能够承受垃圾回收线程和- 应用线程共享CPU资源,并且应用中存在比较多的长生命周期对象。CMS收集的目标是尽量减少应用的暂停时间,减少Full GC发生的几率,利用和应用程序线程并发的垃圾回收线程来标记清除年老代内存。
- XX:+UseParNewGC:设置年轻代为并发收集。可与CMS收集同时使用。JDK5.0以上,JVM会根据系统配置自行设置,所以无需再设置此参数。
- -XX:CMSFullGCsBeforeCompaction=0:由于并发收集器不对内存空间进行压缩和整理,所以运行一段时间并行收集以后会产生内存碎片,内存使用效率降低。此参数设置运行0次Full GC后对内存空间进行压缩和- 整理,即每次Full GC后立刻开始压缩和整理内存。
- -XX:+UseCMSCompactAtFullCollection:打开内存空间的压缩和整理,在Full GC后执行。可能会影响性能,但可以消除内存碎片。
- -XX:+CMSIncrementalMode:设置为增量收集模式。一般适用于单CPU情况。
- -XX:CMSInitiatingOccupancyFraction=70:表示年老代内存空间使用到70%时就开始执行CMS收集,以确保年老代有足够的空间接纳来自年轻代的对象,避免Full GC的发生。
3.2.2.1. CMS收集器 Concurrent Mark Sweep
工作过程
- 初始标记
- 并发标记
- 重新标记
- 并发清理
优点
- 并发收集
- 低停顿
缺点
- 占用大量CPU资源
- 无法处理浮动垃圾
- 空间碎片
- 出现
Concurrent Mode Failure
3.2.3. 并行收集器(吞吐量优先)
“对吞吐量有高要求”,多CPU、对应用响应时间无要求的中、大型应用。举例:后台处理、科学计算。缺点就是垃圾收集过程中应用响应时间可能加长
- -XX:+UseParallelGC:设置为并行收集器。此配置仅对年轻代有效。即年轻代使用并行收集,而年老代仍使用串行收集。
- -XX:ParallelGCThreads=20:配置并行收集器的线程数,即:同时有多少个线程一起进行垃圾回收。此值建议配置与CPU数目相等。
- -XX:+UseParallelOldGC:配置年老代垃圾收集方式为并行收集。JDK6.0开始支持对年老代并行收集。
- -XX:MaxGCPauseMillis=100:设置每次年轻代垃圾回收的最长时间(单位毫秒)。如果无法满足此时间,JVM会自动调整年轻代大小,以满足此时间。
- -XX:+UseAdaptiveSizePolicy:设置此选项后,并行收集器会自动调整年轻代Eden区大小和Survivor区大小的比例,以达成目标系统规定的最低响应时间或者收集频率等指标。此参数建议在使用并行收集器时,一直打开。
Parallel Sacvenge
- 复制算法
- 多线程收集器
- 达到可控制的吞吐量
- -XX:MaxGCPauseMills 垃圾回收器停顿时间:
- -XX:CGTimeRatio:
3.2.3.1. G1收集器
Garbage First
简称G1,完全是为了大型应用而准备的,详细可以参考G1,这篇文章写的不错,值得推荐!
工作过程
- 初始标记
- 并发标记
- 最终标记
- 筛选回收
优势
- 支持很大的堆
- 并行与并发
- 分代收集
- 空间整合
- 可预测的停顿
3.2.4. 其它垃圾回收参数
- -XX:+ScavengeBeforeFullGC:年轻代GC优于Full GC执行。
- -XX:-DisableExplicitGC:不响应 System.gc() 代码。
- -XX:+UseThreadPriorities:启用本地线程优先级API。即使 java.lang.Thread.setPriority() 生效,不启用则无效。
- -XX:SoftRefLRUPolicyMSPerMB=0:软引用对象在最后一次被访问后能存活0毫秒(JVM默认为1000毫秒)。
- -XX:TargetSurvivorRatio=90:允许90%的Survivor区被占用(JVM默认为50%)。提高对于Survivor区的使用率。
3.3. 空间分配担保
在新生代已经无法实现分配内存的时候,这时候JVM
采用将新生代的对象转移到老年代,腾出来的空间让新加入的对象存储在新生代。
乍一看可能不太好理解,我在网上看一例子,觉得挺好,我们就用它来模拟,增强我们的理解。
尝试分配3个2MB的对象和一个4MB的对象,然后我们通过JVM参数 -Xms20M、-Xmx20M、-Xmn10M 把Java堆大小设置为20MB,不可扩展,详细JVM参数如下:
3.3.1. JVM启动参数
注意 这里 JDK版本为1.8,垃圾收集器是 Serial+Serial Old
模式,不同JDk版本以及垃圾回收器,实际效果可能会有差异!!!
1 | -verbose:gc |
3.3.2. JAVA代码
1 |
|
3.3.3. GC日志
1 | [GC (Allocation Failure) [DefNew: 6266K->660K(9216K), 0.0047403 secs] 6266K->4756K(19456K), 0.0076457 secs] [Times: user=0.00 sys=0.00, real=0.01 secs] |
3.3.4. 分析
[GC (Allocation Failure) [DefNew: 6266K->660K(9216K), 0.0047403 secs] 6266K->4756K(19456K), 0.0076457 secs]
其中 [DefNew: 6266K->660K(9216K), 0.0047403 secs]
表示GC前该内存区域已使用容量->GC后该内存区域已使用容量,后面圆括号里面的9216K
为该内存区域的总容量;6266K->4756K(19456K), 0.0076457 secs
表示GC前Java堆已使用容量->GC后Java堆已使用容量,后面圆括号里面的19456K
为Java堆总容量。
- 由于在启动参数中指定新生代
-Xmn10M
,堆总大小20M情况下不变情况下,老年代空间大小也是 10M; -XX:SurvivorRatio=8
参数,所以Eden
空间大小8M
,剩下2M
由Survivor0
和Survivor1
各一半;- 在
bytes1
=>bytes3
内存分配过程,已经占用6M
,且都在Eden
中;当bytes4
再来的时候申请的时候JVM
会发现新生代 自身空间也不够装得下它(简单计算如,DefNew
总大小9216K
已用6M
),这样子bytes4
无法放入Eden
,于是乎,就用了GC
的介入 GC
过程中发现bytes1
=>bytes3
都无法放入Survivor
空间,此时JVM
就启动内存分配担保机制
,将bytes4
转移到tenured
的老年代,4096/10240=0.5,也就是40%:
1 | tenured generation total 10240K, used 4096K [0x00000000ff600000, 0x0000000100000000, 0x0000000100000000) |
3.3.5. 进阶篇
下面来玩个另类的, 以上操作演示我们都是建立 Serial+Serial Old
垃圾回收器,这时候我们换Parallel Scavenge+Serial Old
演示下:
1 | -verbose:gc |
- 在
bytes4
对象依然是4M
情况下:
1 | [GC (Allocation Failure) [PSYoungGen: 6266K->792K(9216K)] 6266K->4896K(19456K), 0.0141220 secs] [Times: user=0.00 sys=0.02, real=0.02 secs] |
- 在
bytes4
对象依然是3M
情况下:
1 | [GC (Allocation Failure) [PSYoungGen: 6266K->760K(9216K)] 6266K->4864K(19456K), 0.0035288 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] |
3.4. 逃逸分析与栈上分配
分析对象的作用域,只有对象的作用的是成员变量,才会发生逃逸。作用域在方法体内,不会发生逃逸。
逃逸的三种情况:
- 通过方法为成员变量赋值,也会发生逃逸
- 引用成员变量的值,发生逃逸
- 为成员变量赋值,发生逃逸
对象的作用域仅在当前方法体内,不会发生逃逸,对象的内存分配在栈内存,这样效率高!