读了啥:JVM内存调优

读了啥

周志明的深入理解Java虚拟机中的调优案例。

第一个案例

背景

一个网站部署在JVM上,而Java堆大小固定在了12G,但是总会出现长时间无法响应的情况。

使用了吞吐量优先收集器:可能是Parallel Scavenge和Parallel Old收集器。

问题

网站直接从磁盘拷贝文档到堆内存中,文档过大导致进入老年代,频繁操作很快占满Java堆,导致Full GC被触发。

网站以前部署在小内存的机器上,反而Full GC造成的停顿不明显了。所以,如今机器升级意义也不大。

经验

老年代的占用值得关注,不然Full GC会造成延迟。最起码程序中的绝大多数对象生存时间不能太长。

因为64位JVM使用到了压缩指针(像32位JVM一样管理内存,避免内存滥用,但需要额外的计算去处理指针)、缓存行对齐(缓存行就是构成计算机高速缓存的基本单位,JVM需要额外计算进行对齐)等原因,性能表现并没有预期中那样强于32位JVM。

大内存进行快照转储难度很大——因为没地方放,要么使用JMC(Java任务管理器)监控运行时的内存使用状态。

或者不用大内存,改成同时起用多个JVM实例,每个实例拥有小份实际内存。并打散上游来的请求,比较平均地分配给每个JVM实例上的应用。在这种情况下,可以用ID标明用户请求,让均衡器进行路由,使同个用户的不同请求固定地在同一台实例上的应用上处理,也就是所谓集群的亲合性。但是,这种情况可能导致共享资源的竞争,如磁盘、资源池等。同时,每个实例使用单独的缓存也会浪费资源,可以用一个共享缓存池(最典型的就是Redis这种中间件了)来代替。

如何解决

一:改用垃圾收集器。现面世的收集器中有以控制延迟为目标的,如Shenandoah、ZGC等。

二:沿用老的收集器,但改造程序。使其不会快速占满空间,然后在特定时间点统一Full GC。

在案例中,最终用多个JVM实例,并改用CMS收集器(分多阶段,尽量与应用程序并发执行,标记后回收)进行回收。

第二个案例

背景

是一个亲合式集群(前文提到过),为了共享数据使用了JBoss(一个开源工具,用于构建分布式的缓存,当然缓存彼此的数据是保持一致的。有点Redis的意思)。

但是总报OOM,且程序没有发生过频繁更新。

问题

内存中存在大量类型为NAKACK的对象。

JGroups(被JBoss使用,是一个用于模拟分组广播来实现群组通信的开源工具)有个叫NAKACK的类,它用于记录协议栈,并实现了up()和down()方法用于表示每层协议在数据包接受和发送时产生的作用

该工具必须保证数据包是有序的,但数据包一定存在失败重传的可能。在保证成功前,数据包会一直放在内存里。

应用需要控制用户在某时段只能在一台机器上登录(也就是亲合性),所以应用中有一个过滤器,它会向所有集群发送"必要信息"以实现这个功能,每来一个请求就这样做一次。

可想而知,请求越多,必要的信息也就越多。而底层的JGroups又将这些信息存到内存里。在网络条件变差时,信息就会堆积,类型NAKACK的对象就越来越多了。

直到OOM。

经验

使用参数HeapDumpOnOutOfMemoryError在JVM上,这样如果再发生OOM,会自动生成转储文件。

JBoss适合读操作频繁的工作,但不太适合写操作频繁的场合。

如何解决

这种情况要么修改"必要信息"的发送方式,要么更换缓存的框架。

第三个案例

背景

 一个应用运行时报OOM,调大内存后继续报OOM。尝试在异常发生时拍摄一个“堆转储快照”(使用-XX:+HeapDumpOnOutOfMemoryError命令),但发生OOM时依然获取不到快照文件。

使用jstat(一个JVM自带的工具,可以用来监控应用运行的内存状态)监控,也没有发现问题,但OOM还是时而发生。

问题

JVM允许直接堆外空间进行信息处理,但是堆外空间的大小终究还是受到物理内存空间的限制。

本应用所在的计算机使用32位系统,最多只能管理4GB大小的内存,而案例中更是只拥有2GB内存。1.6GB内存划给了JVM堆,只剩下0.4GB内存能作为堆外空间使用。

Full GC是正常清理堆外空间的唯一方法,或者在发生OOM后,在Java程序中触发异常捕获来执行回收(使用System.gc())。但这两种情况都不会触发系统报OOM。

如果应用恰好打开了-XX:+DisableExplicitGC,则上述的第二种方法也会失效,OOM就会抛出。在本例中,应用恰好有使用到CometD(一个Web事件的路由总线,用于编写网络应用)给予的库函数,它会进行大量NIO操作,从而对堆外空间进行了大量占用。

经验

开发或管理依赖内存不充足的应用时,需要关注其内存使用的情况。除了上述对堆外空间的使用会造成隐患外,还有几种情况需要考虑:

  • 线程堆栈
  • Socket缓存区——存在两个缓存区用于接收和发送网络信息,连接越多,占用的缓存也就越多
  • JNI代码——对本地方法的调用会间接使用到堆外空间
  • JVM和GC也可能会用到堆外的空间

如何解决

加强对堆外空间的管理,选用这方面性能更好的工具加入到程序中。

第四个案例

背景

一个应用在进行大并发压力测试时,发现请求响应时间比预期的长。用mpstat(Linux自带的实时系统监控工具,可以用于查看每个CPU核心的统计数据)查看后发现有其他程序占用了绝大部分的CPU算力,而应用却只持有一小部分,这是不合理的。

使用dtrace(一个Solaris系统下才有的工具,其特性似乎已被Linux的更高版本所吸收)检查系统调用对CPU算力的占用份额,发现份额最多的时fork调用,该调用用于产生新进程。

问题

通过Runtime.getRuntime().exec(),该应用主动调用了Shell脚本,而调用的过程涉及到了进程的创建:复制当前进程-新进程执行命令-退出新进程。反复操作导致fork调用频发,从而占用了CPU算力。

经验

尽量不要有直接调用OS的命令的操作,尤其是不要频繁进行这种操作。

如何解决

应用改用Java的API来实现相同功能,解决了问题。

第五个案例

背景

一个MIS(信息管理系统)发生了JVM自动关闭的情况,并且在关闭前发生过大量异常,异常显示的是网络连接断开。

问题

该MIS与一个OA(办公自动化系统)系统通过网络交互,并且MIS使用了异步调用的方式去调用的OA系统的服务。但是OA系统的处理速度很慢,而MIS创建完一次调用需要的资源(主要是线程和Socket连接)后,资源就会一直待在MIS使用的JVM内存里。

随着调用越来越多,资源在JVM内存里堆积得也越来越多,JVM会频繁面临对这些资源堆满内存时的临界处理,直到完全崩溃。

经验

使用通信中间件,使服务调用方和服务方进行解耦,避免直接调用带来的性能问题。

如何解决

改用消息队列来实现OA系统与MIS之间的交互。

第六个案例

背景

一个应用所在的JVM在Minor GC(指对新生代进行的垃圾收集)时,会出现500毫秒的停顿,一般来说对于网络服务,停顿毫秒数在两位数左右才比较能让人接受。

该应用所在的JVM使用的收集器是ParNew+CMS(在JVM的启动参数里开启这两个收集器的使用,并配置参数规定其执行)。

问题

应用每十分钟加载文件进行分析,会形成一个HashMap,该HashMap至少有100万个元素在内。

对GC日志进行观察,发现Minor GC发生的前后对内存的占用变化不大,说明有效资源比无效资源要多得多。

ParNew使用的是复制算法,这个算法会对正在被引用的新生代资源进行复制操作,可想而知在这样的资源很多的时候,复制操作会占用很多时间。且在生命周期未到达可以进入老年代之前,这样的复制会一直进行。

经验

调整JVM参数,让资源在第一次Minor GC后直接进老年代,等到Major GC时再清理它们。

使用-XX:SurviviorRatio=65536(survivor区和eden区的比例为1:65536,则survivor区几乎无法使用),-XX:MaxTenuringThreshold=0(设置堆内资源复制进其他区的次数超过设定值时,就直接进入老年代。设定值为0,说明第一次复制就可以直接进入老年代),或-XX:+Always-Tenure(也是第一次复制就进入老年代)。右侧命令任选其一。

不过以上的做法只是权宜之计。

调整程序数据结构,降低数据结构的占用空间。在本例中,主要还是因为HashMap的空间效率有限导致的,在键值对均为long类型的情况下进行了包装,导致冗余度极高。

对于基本类型的数据不要有过度包装,尤其是在其数量很多时。

如何解决

使用空间利用效率更高的数据结构。

第七个案例

背景

一个Windows上的桌面程序,发现偶尔会出现一分钟左右的停顿。

问题

停顿由GC造成,在加入-XX:+PrintReferenceGC参数后进行观察时发现准备收集到开始收集的阶段占据了绝大部分的回收时间。

进一步发现该程序在最小化时,工作内存被交换进磁盘空间。从而可能在GC时再次发生交换,而IO操作就会花费大量时间。

经验

调试时可以加入-XX:+PrintGCApplicationStoppedTime -XX:+PrintGCDateStamps -Xloggc:gclog.log,这里是三个独立的参数,前两个分别用来打印GC的停顿时长和发生时刻,而第三个将打印出来的信息输入到文件中。

如何解决

加入参数-Dsun.awt.keepWorkingSetOnMinimize=true,该参数允许Java程序在最小化时也可以占有工作内存,这一点也可以被应用在各种Java编辑器上。

第八个案例

背景

一个应用用于离线分析任务,设定了-XX:MaxGCPauseMillis=500ms(GC会尽可能将时间压缩在这个限制下,但不保证)。但分析日志发现实际运行发现停顿在3000ms以上,且回收的操作只占了几百ms。

问题

应用内存在多个应用线程,只有当所有应用线程都抵达安全点(JVM根据自身机制设定的,勒令全部线程暂停工作的时刻)后,GC才开始。

检查应用线程抵达安全点的状态,发现部分线程抵达安全点后,仍在等待其他很慢的应用线程抵达安全点。按理说,那些"很慢的线程"中的安全点应该设置得妥当,才能保证即时暂停下来。

对于JVM而言,它会寻找线程的程序中可以放置安全点的地方进行放置,且存在以下规则:对于一个循环体,如果该循环是可数循环(以int类型或更小类型作为索引),则内部不会有安全点;如果该循环是不可数循环(以long类型或者更大类型作为索引),则内部存在安全点。

对应用的代码进行检查,发现那段代码中恰好有一处使用int类型作为索引的循环,但循环的操作涉及网络连接,耗时很多。

经验

分析GC时,应该对GC总时间和GC实际工作时间进行对比,这两个值相近才是比较合理的。如果相差过大(一般只会是总时间>>实际工作时间),说明GC的准备工作中存在问题。

分析GC可以从分析安全点入手来了解情况,主要使用参数-XX:+PrintSafepointStatistics-XX:PrintSafepointStatisticsCount=1来记录安全点的一些信息(同时使用两个参数)。主要是观察在存在线程抵达安全点时,是否有尚未抵达安全点的线程。

一般来说,它的信息会统计各类状态的线程数,在日志中体现为[threads: total(STW发生时的线程总数) initially_running(STW发生时正在运行的线程数) wait_to_block(STW发生时需要堵塞的线程数,这部分线程就正在执行,直到抵达安全点)]

也会记录时间的使用情况[time: spin(GC线程自旋花费的时间,也就是在等待工作线程堵塞花费的时间) block sync cleanup vmop]。

如果要找寻线程的具体信息,可以通过参数-XX:+SafepointTimeout-XX:SafepointTimeoutDelay=指定值来进行,前者指定JVM对线程在约定进入安全点的时刻之后,延迟一段时间进入会触发超时记录,后者指定这个延迟的具体值。如果触发记录,则线程的具体信息会被记录下来,可供进一步调查。

对于循环体中执行重型操作(如处理网络连接)中,索引可以修改成long类型或以上,方便安全点在循环执行中设置。

如何解决

将该处循环的索引设置为long类型,问题解决。

热门相关:盛华   盛宠之嫡女医妃   龙组使命   总裁大人,又又又吻我了   榴绽朱门