深入理解 JVM ------ 调优案例分析与实战
Posted lalala
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了深入理解 JVM ------ 调优案例分析与实战相关的知识,希望对你有一定的参考价值。
1、大内存硬件上的程序部署策略
网站失去响应是由垃圾收集停顿所导致的,在该系统软硬件条件下, HotSpot虚拟机是以服务端模式运行,默认使用的是吞吐量优先收集器,回收12GB的Java堆,一次Full GC的停顿时间就高达14秒(太大会导致回收停顿时间过长。再加上直接进入老年代,Full GC 次数多)。
由于程序设计的原因,访问文档时会把文档从磁盘提取到内存中,导致内存中出现很多由 文档序列化产生的大对象,这些大对象大多在分配时就直接进入了老年代,没有在 Minor GC中被清理掉。这种情况下即使有12GB的堆,内存也很快会被消耗殆尽。
将硬件升级到64位系统、16GB内存希望能提升程序效能,却反而出现了停顿问题,尝试 过将Java堆分配的内存 重新缩小到1.5GB或者2GB,这样的确可以 避免长时间停顿,但是在硬件上的投 资就显得非常浪费。
对于用户交互性强、对停顿时间敏感、内存又较大的 系统,并不是一定要使用Shenandoah、ZGC这些明确以控制延迟为目标的垃圾收集器才能解决问题 (当然不可否认,如果情况允许的话,这是最值得考虑的方案),使用Parallel Scavenge/Old收集器,并 且给Java虚拟机分配较大的堆内存也是有很多运行得很成功的案例的,但前提是必须把应用的Full GC 频率控制得足够低。
控制Full GC频率的关键是老年代的相对稳定,这主要取决于应用中绝大多数对象能否符合“朝生夕灭”的原则,即大多数对象的生存时间不应当太长,尤其是不能有成批量的、长生存时间的大对象产生,这样才能保障老年代空间的稳定。
在许多网站和B/S形式的应用里,多数对象的生存周期都应该是请求级或者页面级的,会话级和全局级的长生命对象相对较少。只要代码写得合理,实现在超大堆中正常使用没有Full GC应当并不困难,这样的话,使用超大堆内存时,应用响应速度才可能会有所保证。
除此之外,如果读者计划使用 单个Java虚拟机实例来管理大内存,还需要考虑下面可能面临的问题:
-
- 回收大块堆内存而导致的长时间停顿,自从G1收集器的出现,增量回收(Region 组成的回收集 ,可以新生代老年代混合回收。而在这之前要么是 年轻代 minor GC 要么是老年代 Major GC 以及 Full GC。以及用户可控的停顿时间。)得到比较好的应用, 这个问题有所缓解,但要到ZGC和Shenandoah收集器成熟之后才得到相对彻底地解决。
- 大内存必须有64位Java虚拟机的支持(操作系统分配给每个进程的内存是有限制的,32 windows 分配给每个进程的最大只有 2GB。64位硬件支持的最大内存 256GB,操作系统施加限制后,64 Linux 64 TB , 64 Windows 16TB 进程物理地址空间)。但由于压缩指针、处理器缓存行容量(Cache Line)等因素,64位虚拟机的性能测试结果普遍略低于相同版本的32位虚拟。
- 必须保证应用程序足够稳定,因为这种大型单体应用要是发生了堆内存溢出,几乎无法产生堆转储快照(要产生十几GB乃至更大的快照文件),哪怕成功生成了快照也难以进行分析;如果确实出了 问题要进行诊断,可能就必须应用JMC这种能够在生产环境中进行的运维工具。
- 相同的程序在64位虚拟机中消耗的内存一般比32位虚拟机要大,这是由于指针膨胀,以及数据类型对齐补白等因素导致的,可以开启(默认即开启)压缩指针功能来缓解。
使用若干个虚拟机建立逻辑集群来尽可能利用硬件资源。均衡器按一定的规则算法(譬如根据Session ID分配)将一个固定的用 户请求永远分配到一个固定的集群节点进行处理即可,这样程序开发阶段就几乎不必为集群环境做任何特别的考虑。
当然,第二种部署方案也不是没有缺点的,如果读者计划使用逻辑集群的方式来部署程序,可能会遇到下面这些问题:
- 节点竞争全局的资源,最典型的就是磁盘竞争,各个节点如果同时访问某个磁盘文件的话(尤其是并发写操作容易出现问题),很容易导致I/O异常。
- 很难最高效率地利用某些资源池,譬如连接池,一般都是在各个节点建立自己独立的连接池,这 样有可能导致一些节点的连接池已经满了,而另外一些节点仍有较多空余。尽管可以使用集中式的 JNDI来解决,但这个方案有一定复杂性并且可能带来额外的性能代价。
- .......
2、集群间同步导致的内存溢出
前情 https://www.cnblogs.com/suBlog/p/16853285.html# 分布式缓存 --> 复制式缓存 一节,JBossCache 是复制式缓存,每个节点都保存了一份数据的副本,每当写入数据是要通知每个节点进行数据变更。这一类被集群共享的数据要使用类似JBossCache这种非集中式的集群缓存来同步的话,可以允许读操作频繁,因为数据在本地内存有一份副本,读取的动作不会耗费多少资源,但不应当有过于频繁的写操作,会带来很大的网络同步的开销。
JBossCache 缓存启用后,服务正常使用了一段较长的时间。但在最近不定期出现多次的内存溢出问题。
让服务带着-XX:+HeapDumpOnOutOfMemoryError 参数运行了一段时间。在最近一次溢出之后,管理员发回了堆转储快照,发现里面存在着大量的org.jgroups.protocols.pbcast.NAKACK 对象。
由于信息有传输失败需要重发的可能性,在确认所有注册在GMS(Group Membership Service)的节点都收到正确的信息前,发送的信息必须在内存中保留。
在服务使用过程中,往往一个页面会 产生数次乃至数十次的请求,因此这个过滤器导致集群各个节点之间网络交互非常频繁。当网络情况不能满足传输要求时,重发数据在内存中不断堆积,很快就产生了内存溢出。
3、堆外内存导致的溢出错误
这是一个学校的小型项目:基于B/S的电子考试系统,为了实现客户端能实时地从服务器端接收考 试数据,系统使用了逆向AJAX技术(也称为Comet或者Server Side Push),选用CometD 1.1.1作为服 务端推送框架。
加入-XX: +HeapDumpOnOutOfMemoryError参数,居然也没有任何反应,抛出内存溢出异常时什么文件都没有 产生。无奈之下只好挂着jstat紧盯屏幕,发现垃圾收集并不频繁,Eden区、Survivor区、老年代以及方 法区的内存全部都很稳定,压力并不大,但就是照样不停抛出内存溢出异常。最后,在内存溢出后从 系统日志中找到异常堆栈如代码清单5-1所示。
[org.eclipse.jetty.util.log] handle failed java.lang.OutOfMemoryError: null at sun.misc.Unsafe.allocateMemory(Native Method) at java.nio.DirectByteBuffer.<init>(DirectByteBuffer.java:99) at java.nio.ByteBuffer.allocateDirect(ByteBuffer.java:288) at org.eclipse.jetty.io.nio.DirectNIOBuffer.<init> ……
每个进程能管理的内存是有限制的,这台服务器使用的32位Windows平台的限制是 2GB,其中划了1.6GB给Java堆,而Direct Memory耗用的内存并不算入这1.6GB的堆之内,因此它最大也只能在剩余的0.4GB空间中再分出一部分而已。
在此应用中导致溢出的关键是垃圾收集进行时,虚拟机虽然会对直接内存进行回收,但是直接内存却不能像新生代、老年代那样,发现空间不足了就主动通知收集器进行垃圾回收,它只能等待老年代满后Full GC出现后,“顺便”帮它清理掉内存的废弃对象。否则就不得不一直等到抛出内存溢出异常时,先捕获到异常,再在Catch块里面通过System.gc()命 令来触发垃圾收集。但如果Java虚拟机再打开了-XX:+DisableExplicitGC开关,禁止了人工触发垃圾 收集的话,那就只能眼睁睁看着堆中还有许多空闲内存,自己却不得不抛出内存溢出异常了。
而本案 例中使用的CometD 1.1.1框架,正好有大量的NIO操作需要使用到直接内存。
从实践经验的角度出发,在处理小内存或者32位的应用问题时,除了Java堆和方法区之外,我们 注意到下面这些区域还会占用较多的内存,这里所有的内存总和受到操作系统进程最大内存的限制:
-
- 直接内存:可通过-XX:MaxDirectMemorySize调整大小,内存不足时抛出OutOf-MemoryError或 者OutOfMemoryError:Direct buffer memory。
- 线程堆栈:可通过-Xss调整大小,内存不足时抛出StackOverflowError(如果线程请求的栈深度大 于虚拟机所允许的深度)或者OutOfMemoryError(如果Java虚拟机栈容量可以动态扩展,当栈扩展时 无法申请到足够的内存)。
- Socket缓存区:每个Socket连接都Receive和Send两个缓存区,分别占大约37KB和25KB内存,连接多的话这块内存占用也比较可观。如果无法分配,可能会抛出IOException:Too many open files异常。
- JNI代码:如果代码中使用了JNI调用本地库,那本地库使用的内存也不在堆中,而是占用Java虚 拟机的本地方法栈和本地内存的。 ·虚拟机和垃圾收集器:虚拟机、垃圾收集器的工作也是要消耗一定数量的内存的。
4、外部命令导致系统缓慢
通过操作系统的mpstat工具发现处 理器使用率很高,但是系统中占用绝大多数处理器资源的程序并不是该应用本身。这是个不正常的现 象,通常情况下用户应用的处理器占用率应该占主要地位,才能说明系统是在正常工作。
通过Solaris 10的dtrace脚本可以查看当前情况下哪些系统调用花费了最多的处理器资源,dtrace运 行后发现最消耗处理器资源的竟然是“fork”系统调用。众所周知,“fork”系统调用是Linux用来产生新进程的,在Java虚拟机中,用户编写的Java代码通常最多只会创建新的线程,不应当有进程的产生,这又 是个相当不正常的现象。
每个用户请求的处理都需要执行一个外部Shell脚本来获得系统的一些信息。执行这个Shell脚本是通过Java的Runtime.getRuntime().exec()方法来调用的。 这种调用方式可以达到执行Shell脚本的目的,Java虚拟机执行这个命令的过程是首先复制一个和当前虚拟机拥有一样环境变量的进程,再用这个新的进程去执行外部命令,最后再退出这个进程。如果频繁执行这个操作,系统的消耗必然会很大,即使外部命令本身能很快执行完毕,频繁调用时创建进程的开销也会非常可观,而且不仅是处理器消耗,内存 负担也很重。 用户根据建议去掉这个Shell脚本执行的语句,改为使用Java的API去获取这些信息后,系统很快恢 复了正常。
5、服务器虚拟机进程奔溃
在运行期间频繁出现集群 节点的虚拟机进程自动关闭的现象,留下了一个hs_err_pid###.log文件后,虚拟机进程就消失了,两台 物理机器里的每个节点都出现过进程崩溃的现象。从系统日志中注意到,每个节点的虚拟机进程在崩溃之前,都发生过大量相同的异常。
java.net.SocketException: Connection reset at java.net.SocketInputStream.read(SocketInputStream.java:168) at java.io.BufferedInputStream.fill(BufferedInputStream.java:218) at java.io.BufferedInputStream.read(BufferedInputStream.java:235) at org.apache.axis.transport.http.HTTPSender.readHeadersFromSocket(HTTPSender.java:583) at org.apache.axis.transport.http.HTTPSender.invoke(HTTPSender.java:143) ... 99 more
由于MIS系统的用户多,待办事项变化很快,为了不被OA系统速度拖累,使用了异步的方式调用 Web服务,但由于两边服务速度的完全不对等,时间越长就累积了越多Web服务没有调用完成,导致在等待的线程和Socket连接越来越多,最终超过虚拟机的承受能力后导致虚拟机进程崩溃。通知OA门户 方修复无法使用的集成接口,并将异步调用改为生产者/消费者模式的消息队列实现后,系统恢复正常。
6、不恰当的数据结构导致内存占用过大
内存配置为-Xms4g-Xmx8g-Xmn1g,使用ParNew加 CMS的收集器组合。平时对外服务的Minor GC时间约在30毫秒以内,完全可以接受。但业务上需要每 10分钟加载一个约80MB的数据文件到内存进行数据分析,这些数据会在内存中形成超过100万个 HashMap<long,long>Entry,在这段时间里面Minor GC就会造成超过500毫秒的停顿。
Heap before GC invocations=95 (full 4): par new generation total 903168K, used 803142K [0x00002aaaae770000, 0x00002aaaebb70000, 0x00002aaaebb70000) eden space 802816K, 100% used [0x00002aaaae770000, 0x00002aaadf770000, 0x00002aaadf770000) from space 100352K, 0% used [0x00002aaae5970000, 0x00002aaae59c1910, 0x00002aaaebb70000) to space 100352K, 0% used [0x00002aaadf770000, 0x00002aaadf770000, 0x00002aaae5970000) concurrent mark-sweep generation total 5845540K, used 3898978K [0x00002aaaebb70000, 0x00002aac507f9000, 0x00002aacae770000) concurrent-mark-sweep perm gen total 65536K, used 40333K [0x00002aacae770000, 0x00002aacb2770000, 0x00002aacb2770000) 2011-10-28T11:40:45.162+0800: 226.504: [GC 226.504: [ParNew: 803142K-> 100352K(903168K), 0.5995670 secs] 4702120K->4056332K(6748708K), 0.5997560 secs] [Times: user=1.46 sys=0.04, real=0.60 secs] Heap after GC invocations=96 (full 4): par new generation total 903168K, used 100352K [0x00002aaaae770000, 0x00002-aaaebb70000, 0x00002aaaebb70000) eden space 802816K, 0% used [0x00002aaaae770000, 0x00002aaaae770000, 0x00002aaadf770000) from space 100352K, 100% used [0x00002aaadf770000, 0x00002aaae5970000, 0x00002aaae5970000) to space 100352K, 0% used [0x00002aaae5970000, 0x00002aaae5970000, 0x00002aaaebb70000) concurrent mark-sweep generation total 5845540K, used 3955980K [0x00002aaaebb70000, 0x00002aac507f9000, 0x00002aacae770000) concurrent-mark-sweep perm gen total 65536K, used 40333K [0x00002aacae770000, 0x00002aacb2770000, 0x00002aacb2770000) Total time for which application threads were stopped: 0.6070570 seconds
在分析数据文件期间,800MB的Eden 空间很快被填满引发垃圾收集,但Minor GC之后,新生代中绝大部分对象依然是存活的。我们知道 ParNew收集器使用的是复制算法,这个算法的高效是建立在大部分对象都“朝生夕灭”的特性上的,如 果存活对象过多,把这些对象复制到Survivor并维持这些对象引用的正确性就成为一个沉重的负担,因 此导致垃圾收集的暂停时间明显变长。
如果不修改程序,仅从GC调优的角度去解决这个问题,可以考虑直接将Survivor空间去掉(加入 参数-XX:SurvivorRatio=65536、-XX:MaxTenuringThreshold=0或者-XX:+Always-Tenure),让新生代中存活的对象在第一次Minor GC后立即进入老年代(减少复制到 Survivor 的开销),等到Major GC的时候再去清理它们。这种措施只可以治标。
我们具体分析一下HashMap空间效率,在HashMap<long,long>结构中,只有Key和Value所存放的两个长整型数据是有效数据,共16字节(2×8字节)。这两个长整型数据包装成java.lang.Long对象之 后,就分别具有8字节的Mark Word、8字节的Klass指针,再加8字节存储数据的long值。然后这2个 Long对象组成Map.Entry之后,又多了16字节的对象头,然后一个8字节的next字段和4字节的int型的 hash字段,为了对齐,还必须添加4字节的空白填充,最后还有HashMap中对这个Entry的8字节的引 用,这样增加两个长整型数字,实际耗费的内存为(Long(24byte)×2)+Entry(32byte)+HashMap Ref(8byte)=88byte,空间效率为有效数据除以全部内存空间,即16字节/88字节=18%,这确实太低了。
7、由 Windows 虚拟内存导致的长时间停顿
因为是桌面程序,所需的内存并不大(-Xmx256m),所以开始并没有想到是垃圾收集导致的程序停顿,但是加入参数-XX:+PrintGCApplicationStoppedTime-XX:+PrintGCDate-Stamps-Xloggc: gclog.log后,从收集器日志文件中确认了停顿确实是由垃圾收集导致的,大部分收集时间都控制在100 毫秒以内,但偶尔就出现一次接近1分钟的长时间收集过程。
Total time for which application threads were stopped: 0.0112389 seconds Total time for which application threads were stopped: 0.0001335 seconds Total time for which application threads were stopped: 0.0003246 seconds Total time for which application threads were stopped: 41.4731411 seconds Total time for which application threads were stopped: 0.0489481 seconds Total time for which application threads were stopped: 0.1110761 seconds Total time for which application threads were stopped: 0.0007286 seconds Total time for which application threads were stopped: 0.0001268 seconds
从收集器日志中找到长时间停顿的具体日志信息(再添加了-XX:+PrintReferenceGC参数),找 到的日志片段如下所示。从日志中看到,真正执行垃圾收集动作的时间不是很长,但从准备开始收集,到真正开始收集之间所消耗的时间却占了绝大部分。
2012-08-29T19:14:30.968+0800:10069.800:[GC10099.225:[SoftReference,0 refs,0.0000109 secs]10099.226:[WeakReference,4072 refs,0.0012099 secs]10099.227:[FinalReference,984 refs,1.5822450 secs]10100.809:[PhantomReference,251 refs,0.0001394 secs]10100.809:[JNI Weak Reference,0.0994015 secs][PSYoungGen:175672K->8528K(167360K)]251523K->100182K(353152K),31.1580402 secs][Times:user=0.61 sys=0.52,real=31.16 secs]
除收集器日志之外,还观察到这个GUI程序内存变化的一个特点,当它最小化的时候,资源管理中显示的占用内存大幅度减小,但是虚拟内存则没有变化,因此怀疑程序在最小化时它的工作内存被自动交换到磁盘的页面文件之中了,这样发生垃圾收集时就有可能因为恢复页面文件的操作导致不正常的垃圾收集停顿。在Java的GUI程序中要避免这种现象,可以加入参数“- Dsun.awt.keepWorkingSetOnMinimize=true”来解决。这个参数在许多AWT的程序上都有应用。
8、由安全点导致的长时间停顿
运行在JDK 8上,使用G1收集器。将-XX: MaxGCPauseMillis 参数设置到了500毫秒。不过运行一段时间后发现垃圾收集的停顿经常达到3秒以 上,而且实际垃圾收集器进行回收的动作就只占其中的几百毫秒,现象如以下日志所示。
[Times: user=1.51 sys=0.67, real=0.14 secs] 2019-06-25T 12:12:43.376+0800: 3448319.277: Total time for which application threads were stopped: 2.2645818 seconds
- user:进程执行用户态代码所耗费的处理器时间。(处理器时间,多核的话是多核的总和)
- sys:进程执行核心态代码所耗费的处理器时间。(处理器时间,多核的话是多核的总和)
- real:执行动作从开始到结束耗费的时钟时间。(时钟时间,现实世界)
在垃圾收集调优时,我们主要依据real时间为目标来优化程序,因为最终用户只关心发出请求到得 到响应所花费的时间,也就是响应速度,而不太关心程序到底使用了多少个线程或者处理器来完成任务。
日志显示这次垃圾收集一共花费了0.14秒,但其中用户线程却足足停顿了有2.26秒,user 和 sys 停顿耗时两者的差距是TTSP(Time To Safepoint)耗时,而已经远远超出了正常的安全点耗时范畴。所以先加入参数-XX: +PrintSafepointStatistics和-XX:PrintSafepointStatisticsCount=1去查看安全点日志,具体如下所示:
vmop [threads: total initially_running wait_to_block] 65968.203: ForceAsyncSafepoint [931 1 2] [time: spin block sync cleanup vmop] page_trap_count [2255 0 2255 11 0] 1
日志显示当前虚拟机的操作(VM Operation,VMOP)是等待所有用户线程进入到安全点,但是 有两个线程特别慢,导致发生了很长时间的自旋等待。日志中的2255毫秒自旋(Spin)时间就是指由于部分线程已经走到了安全点,但还有一些特别慢的线程并没有到,所以垃圾收集线程无法开始工作,只能空转(自旋)等待。
解决问题的第一步是把这两个特别慢的线程给找出来,这个倒不困难,添加-XX: +SafepointTimeout和-XX:SafepointTimeoutDelay=2000两个参数,让虚拟机在等到线程进入安全点的 时间超过2000毫秒时就认定为超时,这样就会输出导致问题的线程名称,得到的日志如下所示:
# SafepointSynchronize::begin: Timeout detected: # SafepointSynchronize::begin: Timed out while spinning to reach a safepoint. # SafepointSynchronize::begin: Threads which did not reach the safepoint: # "RpcServer.listener,port=24600" #32 daemon prio=5 os_prio=0 tid=0x00007f4c14b22840 nid=0xa621 runnable [0x0000000000000000] java.lang.Thread.State: RUNNABLE # SafepointSynchronize::begin: (End of list)
有什么因素可以阻止线程进入安全点?在第3章关于安全点的介绍中,我们已经知道安全点是以“是否具有让程序长时间执行的特征”为原则进行选定的,所以方法调用、循环跳转、异常跳转这些位置都可能会设置有安全点。
但是HotSpot虚拟机为了避免安全点过多带来过重的负担,对循环还有一项优化措施,认为循环次数较少的话,执行时间应该也不会太长,所以使用int类型或范围更小的数据类型作为索引值的循环默认是不会被放置安全点的。这种循环被称为可数循环(Counted Loop),相对应地,使用long或者范围更大的数据类型作为索引值的循环就被称为不可数循环 (Uncounted Loop),将会被放置安全点。
通常情况下这个优化措施是可行的,但循环执行的时间不单单是由其次数决定,如果循环体单次执行就特别慢,那即使是可数循环也可能会耗费很多的时间。
HotSpot原本提供了-XX:+UseCountedLoopSafepoints参数去强制在可数循环中也放置安全点,不过这个参数在JDK 8下有Bug,有导致虚拟机崩溃的风险,所以就不得不找到RpcServer线程里面的缓慢代码来进行修改。
最终查明导致这个问题是HBase中一个连接超时清理的函数,由于集群会有多 个MapReduce或Spark任务进行访问,而每个任务又会同时起多个Mapper/Reducer/Executer,其每一个 都会作为一个HBase的客户端,这就导致了同时连接的数量会非常多。更为关键的是,清理连接的索引值就是int类型,所以这是一个可数循环,HotSpot不会在循环中插入安全点。当垃圾收集发生时, 如果RpcServer的Listener线程刚好执行到该函数里的可数循环时,则必须等待循环全部跑完才能进入安全点,此时其他线程也必须一起等着,所以从现象上看就是长时间的停顿。找到了问题,解决起来就非常简单了,把循环索引的数据类型从int改为long即可,但如果不具备安全点和垃圾收集的知识,这 种问题是很难处理的。
Eclipse调优
打开VisualVM,监视页 签中的内存曲线部分如图5-6、图5-7所示。
在Java堆中监视曲线里,“堆大小”的曲线与“使用的堆”的曲线一直都有很大的间隔距离,每当两条曲线开始出现互相靠近的趋势时,“堆大小”的曲线就会快速向上转向,而“使用的堆”的曲线会向下 转向。“堆大小”的曲线向上代表的是虚拟机内部在进行堆扩容,因为运行参数中并没有指定初始(最小)堆(- Xms)的值与最大堆(-Xmx)相等(题外, -Xmn 是新生代大小,老年代大小是可用减新生)所以堆容量一开始并没有扩展到最大值,而是根据使用情况进行伸缩扩展。“使用的堆”的曲线向下是因为虚拟机内部触发了一次垃圾收集,一些废弃对象的空间被回收后,内存用量相应减少。从图形上看,Java堆运作是完全正常的。
但永久代的监视曲线就很明显有 问题了,“PermGen大小”的曲线与“使用的PermGen”的曲线几乎完全重合在一起。这说明永久代中已经没有可回收的资源了,所以“使用的PermGen”的曲线不会向下发展,并且永久代中也没有空间可以扩展了,所以“PermGen大小”的曲线不能向上发展,说明这次内存溢出很明显是永久代导致的内存溢出。
世界三大商用虚拟机中只有Sun公司的虚拟机才有永久代的概念,也就是只有JDK 8以前的HotSpot虚拟机才需要设置这个参数,JRockit虚拟机和J9虚拟 机都是不需要设置的,所以这个参数才会有检测虚拟机后进行设置的过程。 2010年4月10日,Oracle正式完成对Sun公司的收购,此后无论是网页还是具体程序产品,提供商 都从Sun变为了Oracle,而eclipse.exe就是根据程序提供商来判断是否Sun公司的虚拟机的(收购后判断不出来 Sun 公司的虚拟机了,公司变为 oracle 了)。
调整内存设置控制垃圾收集频率
三大块非用户程序时间:类加载时间、类编译时间、垃圾收集时间
在绝大多数的应用中,都不可能出现持续不断的类被加载和卸载。在程序运行一段时间后,随着热点方法被不断编译,新的热点方法数量也总会下降,这都会让类加载和即时编译的影响随运行时间增长而下降,但是垃圾收集则是随着程序运行而持续运作的,所以它对性能的影响才显得最为重要。
在Eclipse启动的原始数据样本中,短短15秒,类共发生了19次Full GC和378次Minor GC,一共397 次GC共造成了超过4秒的停顿,也就是超过1/4的时间都是在做垃圾收集,这样的运行数据看起来实在 太糟糕了。
首先来解决新生代中的Minor GC,尽管垃圾收集的总时间只有不到1秒,但却发生了378次之多。 从VisualGC的线程监视中看到Eclipse启动期间一共发起了超过70条线程,同时在运行的线程数超过25 条,每当发生一次垃圾收集,所有用户线程都必须跑到最近的一个安全点然后挂起线程来等待垃圾回收。这样过于频繁的垃圾收集就会导致很多没有必要的线程挂起及恢复动作。
新生代垃圾收集频繁发生,很明显是由于虚拟机分配给新生代的空间太小导致,Eden区加上一个 Survivor区的总大小还不到35MB。所以完全有必要使用-Xmn参数手工调整新生代的大小。
再来看一看那19次Full GC,看起来19次相对于378次Minor GC来说并“不多”,但总耗时有3.166 秒,占了绝大部分的垃圾收集时间,降低垃圾收集停顿时间的主要目标就是要降低Full GC这部分时间。下面是启动最开始的2.5秒内发生的10次Full GC记录。
0.278: [GC 0.278: [DefNew: 574K->33K(576K), 0.0012562 secs]0.279: [Tenured: 1467K->997K(1536K), 0.0181775 secs] 1920K->997K(2112K), 0.0195257 secs] 0.312: [GC 0.312: [DefNew: 575K->64K(576K), 0.0004974 secs]0.312: [Tenured: 1544K->1608K(1664K), 0.0191592 secs] 1980K->1608K(2240K), 0.0197396 secs] 0.590: [GC 0.590: [DefNew: 576K->64K(576K), 0.0006360 secs]0.590: [Tenured: 2675K->2219K(2684K), 0.0256020 secs] 3090K->2219K(3260K), 0.0263501 secs] 0.958: [GC 0.958: [DefNew: 551K->64K(576K), 0.0011433 secs]0.959: [Tenured: 3979K->3470K(4084K), 0.0419335 secs] 4222K->3470K(4660K), 0.0431992 secs] 1.575: [Full GC 1.575: [Tenured: 4800K->5046K(5784K), 0.0543136 secs] 5189K->5046K(6360K), [Perm : 12287K->12287K(12288K)], 0.0544163 secs] 1.703: [GC 1.703: [DefNew: 703K->63K(704K), 0.0012609 secs]1.705: [Tenured: 8441K->8505K(8540K), 0.0607638 secs] 8691K->8505K(9244K), 0.0621470 secs] 1.837: [GC 1.837: [DefNew: 1151K->64K(1152K), 0.0020698 secs]1.839: [Tenured: 14616K->14680K(14688K), 0.0708748 secs] 15035K->14680K(15840K), 0.0730947 secs] 2.144: [GC 2.144: [DefNew: 1856K->191K(1856K), 0.0026810 secs]2.147: [Tenured: 25092K->24656K(25108K), 0.1112429 secs] 26172K->24656K(26964K), 0.1141099 secs] 2.337: [GC 2.337: [DefNew: 1914K->0K(3136K), 0.0009697 secs]2.338: [Tenured: 41779K->27347K(42056K), 0.0954341 secs] 42733K->27347K(45192K), 0.0965513 secs] 2.465: [GC 2.465: [DefNew: 2490K->0K(3456K), 0.0011044 secs]2.466: [Tenured: 46379K->27635K(46828K), 0.0956937 secs] 42621K->27635K(50284K), 0.0969918 secs]
括号中加粗的数字代表着老年代的容量,这组GC日志显示,10次Full GC发生的原因全部都是老 年代空间耗尽,每发生一次Full GC都伴随着一次老年代空间扩容:1536KB→1664KB→2684KB→… →42056KB→46828KB。10次GC以后老年代容量从起始的1536KB扩大到46828KB,当15秒后Eclipse启 动完成时,老年代容量扩大到了103428KB,代码编译开始后,老年代容量到达顶峰473MB,整个Java 堆到达最大容量512MB
由上述分析可以得出结论:Eclipse启动时Full GC大多数是由于老年代容量扩展而导致的,由永久代空间扩展而导致的也有一部分。为了避免这些扩展所带来的性能浪费,我们可以把-Xms(堆初始容量)和-XX:PermSize (永久代初始容量)参数值设置为-Xmx (堆最大容量)和-XX:MaxPermSize(永久代最大容量)参数值一样,这样就强制虚拟机在启动的时候就把老年代和永久代的容量固定下来,避免运行时自动扩展。
配置后,垃圾收集的次数已经大幅度降低,一分钟的监视曲线, 只发生了8次Minor GC和 4次Full GC,总耗时为1.928秒。但从Old Gen的曲线上看,老年代直接固定在 384MB,而内存使用量只有66MB,并且一直很平滑,完全不应该发生Full GC才对,那4次Full GC是 怎么来的?使用 jstat-gccause 查询一下最近一次GC的原因
C:\\Users\\IcyFenix>jps 9772 Jps 4068 org.eclipse.equinox.launcher_1.0.201.R35x_v20090715.jar C:\\Users\\IcyFenix>jstat -gccause 4068 S0 S1 E O P YGC YGCT FGC FGCT GCT LGCC GCC 0.00 0.00 1.00 14.81 39.29 6 0.422 20 5.992 6.414 System.gc() No GC
从 LGCC(Last GC Cause)中看到原来是代码调用System.gc()显式触发的垃圾收集,在内存设置调整后,这种显式垃圾收集不符合我们的期望,因此在eclipse.ini中加入参数-XX:+DisableExplicitGC屏 蔽掉System.gc()。再次测试发现启动期间的Full GC已经完全没有了,只发生了6次Minor GC,总共耗 时417毫秒,
深入理解JVM - G1调优简述
深入理解JVM - G1调优简述
前言
G1收集器是一个不太好调优的收集器,因为他不能像固定分代的收集器那样可以自己想划分多少就划分多少,更多的分配动作是由收集器动作,由于region是一块块的,同时自动增长也是由G1控制,好像确实不太好调优。
这篇文章更多的是提供调优的一个大致方向,更多的内容需要后续介绍JVM的工具进行讲解。
案例实战
这次使用一个在线的教育平台作为案例解释G1是如何进行优化的。
在线教育平台的压力来自于哪里?首先孩子白天需要上学同时家长也需要上班,所以白天的访问量不会很大,同时主要的业务也不在在线教育平台处理。但是一旦到了晚上,机器的压力就上来了,同时孩子也会在线进行听课上课,这时候用户量会暴增,会有上万人同时在线听课。这时候可以发现在线教育平台的压力在于直播,而直播的流量高峰在于课堂的互动环节,为什么是互动环节呢,因为孩子不喜欢枯燥的课堂,为了带动课堂氛围,课堂中的游戏一定是活跃气氛的关键,也是系统压力的核心,这时系统需要记录各种数据,比如活动时长,得分,积分奖励等等,同时也会出现大量的对象分配,为了保证直播的流畅,系统要求十分低的延迟响应时间。
所以最终的结论是:在线教育平台大概在直播的互动环节压力会倍增,系统要求十分低的延迟响应时间。
经过了上面的情况分析,我们假设单台机器每秒大概有600个请求,假设每一个请求占用10KB,则是6000KB的大小占用也就是最终6M左右的内存占用。同时部署在一个4核心8G的系统上面。
同样的,这种案例也只是模拟和假设,具体情况受到各种因素的限制,切勿过于深究细节。
如何分析系统
传统的分代概念
我们用传统分代的概念部署一下这个系统,根据每一秒的请求为6M的对象大小,同时根据系统4核心8的配置,那么给JVM的内存大概是4G,我们知道直播的互动环节产生的积分,奖励,计算等对象基本都是朝生夕死的小对象,所以我们不太需要给老年代过大的空间,所以出去方法区和虚拟机线程栈的内存,我们给大约会给新生代3G和老年代1G左右的内容。
如果每秒产生6M的对象,那么一分钟就是300多M,按照默认的新生代配比8:1:1则Eden区域大概为2.4G的大小,survior区域为两个300M的大小空间。一分钟300M,那么基本上8分钟左右新生代就会满,此时假设有300M左右对象存活进入Survior区域,这时候Survior区域虽然可以装的下,但是由于超过了50%的配比,最终还是有约150M的对象进入老年代。
这时候再推算,老年代每8分钟进入150M的对象,大约40分钟左右就会整个系统停顿一次,这个停顿时间还是乐观估计,因为系统不可能只运作这一块的内容,单单是推算直播互动这一块就会产生这样的效果,可想而知加上整个系统的其他模块,实际上5、6分钟可能会停顿一次!!!这样肯定是不行的,试想下你玩游戏隔几分钟就停一下,对于小孩子来说突然卡一下导致丢分最后成绩不佳孩子又哭又闹,家长这时候不用想肯定会大量的投诉,被骂也不远了......
使用G1收集器
我们接着使用G1的收集器进行替换,系统部署在一个4核心8G的系统上面,假设机器在JVM上分配4G给堆,新生代默认初始化比例为5%,最大占比为60%,JAVA线程堆栈为1M,则大约开启几百个线程需要200,300M的空间,而方法区占用256M够用。
在上一节在文章中介绍过,可以把G1的工作机制想象成收盘子,但是放到系统上就很头疼了,G1什么时候回来收垃圾是我们无法预测的!!这里就需要通过一些辅助手段,同时这部分的监控操作需要工具和日志进行解读,所以将会放到后续的专门一篇文章进行解读。
如何计算Region的占用和大小:
按照4096M/2048 每个region是2M,如果按照新生代初始为5%,则根据参数5%新生代的大小为100个Region左右,4G的内存机器结果可以得出新生代初始200M左右的内存占用大小
至关重要的参数:
-XX:MaxGCPauseMills 参数:默认值为200,代表了200MS,表示最大的停顿时间为200MS。
如果使用G1收集器,这个参数直接影响了整个JVM系统的性能,如果这个数值过大,会导致垃圾收集的时间过长而导致前台卡顿,也容易导致新生代来不及触发垃圾回收就满了,或者导致老年代内存无法及时的回收。
多久会触发新生代回收操作
根据之前的说明,新生代最大可以使用60%的空间,同时也说明了新生代使用复制算法,根据8:1:1的规则,大概到达新生代的80%左右就会触发垃圾的回收操作?这种做法显然不符合G1基于全堆以及混合回收的操作。所以不能用固定大小的回收思路思考g1的回收操作。
正确做法: G1会根据200MS的要求,定时去判定当前的新生代是否可以符合200MS的收集操作要求,大意就是当新生代的垃圾回收需要耗时200MS的时候,就会触发新生代的回收。
这里也可以直接按照之前的理解餐厅的服务员定时过来收盘子的操作理解新生代多久进行一次回收操作。
如何优化:
上面的讨论几点之后,这就头疼了,这要这么优化?这时我们需要用上一些压测的工具以及GC日志和内存分析工具来考虑了,但是也不要让GC挺多停顿时间预设值太大了导致GC停顿时间太长,应该给个合理的值。
Mixed gc如何优化?
既然新生代的优化都已经很麻烦了,更不用说老年代回收了。而老年代的回收本身也没有了Old GC,取而代之的是Mixed GC,所以需要小心对待,我们从根本上还是需要防止让对象进入到老年代不断扩展导致mixed gc,这更加需要关注时间停顿模型这个参数。
最终分析
新生代:
- 由于是复制算法,所以需要从根本上处理的话依然需要控制新生代的存活对象进入survior的大小,同时控制在50%以内。尽量避免GC之后对象直接进入老年代。另外60%的新生代空间通常也不用怎么调整,除非业务对象频繁创建新生代会产生大量对象才需要考虑。
- 新生代有一个参数是存活对象大于85%的时候不需要进行拷贝,这个值如果设置小一点可能会提高回收效率,但是有可能造成大量的短命对象进入老年代的风险。
老年代:
- 按照G1的最后一个步骤,垃圾回收和系统回交替8次,同时在回到5%的region的时候停止收集,这个参数其实可以适当调大一些:G1HeapWastePercent
- 45%的老年代占用触发垃圾回收的机制,这个参数也不需要大改,因为JDK设置这个参数肯定是经过了很多测试和考量之后的结果。
总结
这一篇更多的是提供优化思路,JVM调优没有万金油的解决方案,特别是G1收集器这种算法细节十分复杂的收集器,调优需要更多的精力和时间测试调优效果。
写在最后
下一篇章会做一个整个系列到目前为止的小节,温故而知新,人的遗忘曲线更加需要我们反复的巩固知识和内容。
以上是关于深入理解 JVM ------ 调优案例分析与实战的主要内容,如果未能解决你的问题,请参考以下文章