深入理解Java虚拟机5.2章案例总结
案例1:
某一在线文档类型网站进行了硬件升级,并使用-Xmx和-Xms参数将1.5G堆内存改为12G。
出现的问题:网站经常不定期出现长时间失去响应。原因:
由于垃圾回收导致的。网站程序设计时,访问文档会将文档从磁盘读到内存,而文档为大对象,直接进入了老年代,没有在Minor GC中被清理掉。
案例分析:
对于用户交互性强、对停顿时间敏感、内存又较大的系统,应当以控制延迟为目标的垃圾收集器作为最优先考虑的方案,比如Shenandoah、ZGC,如果使用Parallel Scavenge/Old这类以可控制的吞吐量为目标的收集器,并且给Java虚拟机分配较大的堆内存,应当保证FULL GC频率足够低,譬如十几个小时乃至一整天都不出现一次Full GC,这样可以通过在深夜执行定时任务的方式触发Full GC甚至是自动重启应用服务器来保持内存可用空间在一个稳定的水平。
停顿时间越短就越适合需要与用户交互的程序,良好的响应速度能提升用户体验,而高吞吐量则可以高效的利用CPU时间,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务。目前单体应用在较大内存的硬件上主要的部署方式有两种:
1)通过一个单独的Java虚拟机实例来管理大量的Java堆内存。
2)同时使用若干个Java虚拟机,建立逻辑集群来利用硬件资源。如果用第一种方法,需要考虑以下问题:
1.回收大块堆内存而导致的长时间停顿(G1可以缓解,ZGC和Shenandoah收集器成熟之后才得到相对彻底地解决)
2.大内存必须有64位Java虚拟机的支持(32位最大只有4G虚拟内存哦)
3.64位虚拟机消耗内存大于32位,且由于压缩指针、处理器缓存行容量(Cache Line)等因素,性能弱于32位。
4.需要保证程序足够稳定(否则出现异常比如堆内存溢出,几乎无法产生堆转储快照,生成了也很难分析,太大啦)如果使用第二种方法,需要考虑以下问题:
1.经典的并发问题(节点竞争全局的资源,最典型的就是磁盘竞争,各个节点如果同时访问某个磁盘文件的话(尤其是并发写操作容易出现问题),很容易导致I/O异常。)
2.很难最高效率地利用某些资源池,比如连接池(可以用集中式的JNDI来解决,但是会带来复杂性提升和额外性能的代价)
- 受到内存的限制(在32位Windows平台中每个进程只能使用2GB的内存,考虑到堆以外的内存开销,堆最多一般只能开到1.5GB。)
4.大量使用本地缓存(如大量使用HashMap作为K/V缓存)的应用,在逻辑集群中会造成较大的内存浪费(可以使用集中式缓存(比如redis?)来解决)
5.需要加上负载均衡啦(在一台物理机器上启动多个应用服务器进程,为每个服务器进程分配不同端口,然后在前端搭建一个负载均衡器,以反向代理的方式来分配访问请求。)pps:其实使用第一个部署方案,多数应用也不止有一台服务器,因此应用中前端的负载均衡器总是免不了的解决方案:
至此,我们选择第二种部署方式作为优化的方案。
具体解决方案如下:
调整为建立5个32位JDK的逻辑集群,每个进程按2GB内存计算(其中堆固定为1.5GB),占用了10GB内存。
另外建立一个Apache服务作为前端均衡代理作为访问门户。
考虑到用户对响应速度比较关心,并且文档服务的主要压力集中在磁盘和内存访问,处理器资源敏感度较低,因此改为CMS收集器进行垃圾回收。
案例2:
一个集群式的管理信息系统,需求要实现部分数据在各个节点间共享,使用JBossCache构建了一个全局缓存,全局缓存启用后,服务正常使用了一段较长的时间。但在最近不定期出现多次的内存溢出问题。
服务内存回收状况一直正常,每次内存回收后都能恢复到一个稳定的可用空间。开始分析:
1.让服务带着-XX:+HeapDumpOnOutOfMemoryError参数运行了一段时间。(参数表示当JVM发生OOM时,自动生成DUMP文件)
2.在最近一次溢出之后,发回了堆转储快照,发现里面存在着大量org.jgroups.protocols.pbcast.NAKACK对象。
3.NAKACK栈用于保障各个包的有效顺序以及重发。原因:
由于信息有传输失败需要重发的可能性,在确认所有注册在GMS(GroupMembership Service)的节点都收到正确的信息前,发送的信息必须在内存中保留。
而此MIS的服务端中有一个负责安全校验的全局过滤器,每当接收到请求时,均会更新一次最后操作时间,并且将这个时间同步到所有的节点中去,防止一个用户在一段时间内在多台机器上重复登录。
在服务使用过程中,往往一个页面会产生数次乃至数十次的请求,因此这个过滤器导致集群各个节点之间网络交互非常频繁。当网络情况不能满足传输要求时,重发数据在内存中不断堆积,很快就产生了内存溢出。
案例3
一个学校电子考试的小项目,Core i5 CPU,4GB内存,运行32位Windows操作系统。
服务端不定时抛出内存溢出异常,尝试过把堆内存调到最大,32位系统最多到1.6GB基本无法再加大了,而且开大了基本没效果,抛出内存溢出异常好像还更加频繁。分析:
加入-XX:+HeapDumpOnOutOfMemoryError参数,居然也没有任何反应,抛出内存溢出异常时什么文件都没有产生。
image.png
只能挂着jstat紧盯屏幕,发现垃圾收集并不频繁,Eden区、Survivor区、老年代以及方法区的内存全部都很稳定,压力并不大,但就是照样不停抛出内存溢出异常。最后,在内存溢出后从系统日志中找到异常堆栈如代码清单
注意关键词Direct Memory(堆外内存)
这台服务器使用的32位Windows平台的限制是2GB,其中划了1.6GB给Java堆,而Direct Memory耗用的内存并不算入这1.6GB的堆之内,因此它最大也只能在剩余的0.4GB空间中再分出一部分而已。
在此应用中导致溢出的关键是垃圾收集进行时,虚拟机虽然会对直接内存进行回收,但是直接内存却不能像新生代、老年代那样,发现空间不足了就主动通知收集器进行垃圾回收,它只能等待老年代满后Full GC出现后,“顺便”帮它清理掉内存的废弃对象。
(GC在工作时会扫描DirectByteBuffer对象是否有影响到GC的引用,如果没有则回收DirectByteBuffer对象的同时回收其占用的堆外内存.但是堆外内存的回收并不归GC负责)收到进程最大内存限制的地方:
在处理小内存或者32位的应用问题时,除了Java堆和方法区之外,我们注意到下面这些区域还会占用较多的内存,这里所有的内存总和受到操作系统进程最大内存的限制:
·直接内存(堆外内存):可通过-XX:MaxDirectMemorySize调整大小,内存不足时抛出OutOf-MemoryError或者OutOfMemoryError:Direct buffermemory。
·线程堆栈:可通过-Xss调整大小,内存不足时抛出StackOverflowError(如果线程请求的栈深度大于虚拟机所允许的深度)或者OutOfMemoryError(如果Java虚拟机栈容量可以动态扩展,当栈扩展时无法申请到足够的内存)。
·Socket缓存区:每个Socket连接都Receive和Send两个缓存区,分别占大约37KB和25KB内存,连接多的话这块内存占用也比较可观。如果无法分配,可能会抛出IOException:Too many open files异常。
JNI代码:如果代码中使用了JNI调用本地库,那本地库使用的内存也不在堆中,而是占用Java虚拟机的本地方法栈和本地内存的。·
虚拟机和垃圾收集器:虚拟机、垃圾收集器的工作也是要消耗一定数量的内存的。
案例4:
一个数字校园应用系统,运行在一台四路处理器的Solaris 10操作系统上,中间件为GlassFish服务器。
系统在做大并发压力测试的时候,发现请求响应时间比较慢,通过操作系统的mpstat工具发现处理器使用率很高,但是系统中占用绝大多数处理器资源的程序并不是该应用本身。这是个不正常的现象,通常情况下用户应用的处理器占用率应该占主要地位,才能说明系统是在正常工作。分析:
1.通过Solaris 10的dtrace脚本可以查看当前情况下哪些系统调用花费了最多的处理器资源,dtrace运行后发现最消耗处理器资源的竟然是“fork”系统调用。众所周知,“fork”系统调用是Linux用来产生新进程的,在Java虚拟机中,用户编写的Java代码通常最多只会创建新的线程,不应当有进程的产生,这又是个相当不正常的现象。
2.联系开发人员了解到,每个用户请求的处理都需要执行一个外部Shell脚本来获得系统的一些信息。执行这个Shell脚本是通过Java的Runtime.getRuntime().exec()方法来调用的。这种调用方式可以达到执行Shell脚本的目的,但是它在Java虚拟机中是非常消耗资源的操作,即使外部命令本身能很快执行完毕,频繁调用时创建进程的开销也会非常可观。
2.1 Java虚拟机执行这个命令的过程是首先复制一个和当前虚拟机拥有一样环境变量的进程,再用这个新的进程去执行外部命令,最后再退出这个进程。如果频繁执行这个操作,系统的消耗必然会很大,而且不仅是处理器消耗,内存负担也很重。
案例5
与案例2中为同一个系统,集群式的管理信息系统(MIS系统),运行期间频繁出现集群节点的虚拟机进程自动关闭的现象,留下了一个hs_err_pid###.log文件后,虚拟机进程就消失了,两台物理机器里的每个节点都出现过进程崩溃的现象。
image.png分析:
这是一个远端断开连接的异常,了解到系统最近与一个OA门户做了集成,由于OA系统速度慢,经常导致超时导致的连接中断。所以采用了异步的方式调用Web服务,但由于两边服务速度的完全不对等,时间越长就累积了越多Web服务没有调用完成(超时),导致在等待的线程和Socket连接越来越多,最终超过虚拟机的承受能力后导致虚拟机进程崩溃。
解决方案:
1.通知OA门户方修复无法使用的集成接口。
2.将异步调用改为生产者/消费者模式的消息队列(RocketMQ)实现。
案例6
一个后台RPC服务器,使用64位Java虚拟机,内存配置为-Xms4g-Xmx8g-Xmn1g,使用ParNew加CMS的收集器组合。
平时对外服务的Minor GC时间约在30毫秒以内,完全可以接受。但业务上需要每10分钟加载一个约80MB的数据文件到内存进行数据分析,这些数据会在内存中形成超过100万个HashMap<LongLong>Entry,在这段时间里面Minor GC就会造成超过500毫秒的停顿。分析:
观察日志发现,平时Minor GC时间很短,原因是新生代的绝大部分对象都是可清除的,在Minor GC之后Eden和Survivor基本上处于完全空闲的状态。
但是在分析数据文件期间,800MB的Eden空间很快被填满引发垃圾收集,但Minor GC之后,新生代中绝大部分对象依然是存活的。我们知道ParNew收集器使用的是复制算法,这个算法的高效是建立在大部分对象都“朝生夕灭”的特性上的,如果存活对象过多,把这些对象复制到Survivor并维持这些对象引用的正确性就成为一个沉重的负担,因此导致垃圾收集的暂停时间明显变长。最主要的原因!!:
HashMap<Long,Long>结构来存储数据文件空间效率太低
在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%,这确实太低了。仅从GC调优角度解决方案:
可以考虑直接将Survivor空间(就是from和to区)去掉(加入参数-XX:SurvivorRatio=65536、-XX:MaxTenuringThreshold=0或者-XX:+Always-Tenure),让新生代中存活的对象在第一次Minor GC后立即进入老年代,等到Major GC(不同书中的Major GC概念有混淆,有的专指老年代手机,有的就是指Full GC,这里指老年代)的时候再去清理它们。
实际上应该修改代码层面,GC调优治标不治本
案例7:
有一个比较大的承担公共计算任务的离线HBase集群,运行在JDK 8上,使用G1收集器。每天都有大量的MapReduce或Spark离线分析任务对其进行访问,同时有很多其他在线集群Replication过来的数据写入,因为集群读写压力较大,而离线分析任务对延迟又不会特别敏感,所以将-XX:MaxGCPauseMillis(最大暂停时间)参数设置到了500毫秒。不过运行一段时间后发现垃圾收集的停顿经常达到3秒以上,而且实际垃圾收集器进行回收的动作就只占其中的几百毫秒。
image.png
日志显示这次垃圾收集一共花费了0.14秒,但其中用户线程却足足停顿了有2.26秒,两者差距已经远远超出了正常的TTSP(Time To Safepoint)耗时的范畴。
分析:先加入参数-XX:+PrintSafepointStatistics和-XX:PrintSafepointStatisticsCount=1去查看安全点日志,具体如下所示:
image.png
日志显示当前虚拟机的操作(VM Operation,VMOP)是等待所有用户线程进入到安全点,但是有两个线程特别慢,导致发生了很长时间的自旋等待。日志中的2255毫秒自旋(Spin)时间就是指由于部分线程已经走到了安全点,但还有一些特别慢的线程并没有到,所以垃圾收集线程无法开始工作,只能空转(自旋)等待。解决问题:
1.第一步是把这两个特别慢的线程给找出来,添加-XX:+SafepointTimeout和-XX:SafepointTimeoutDelay=2000两个参数,让虚拟机在等到线程进入安全点的时间超过2000毫秒时就认定为超时,这样就会输出导致问题的线程名称,得到的日志如下所示:
image.png
从错误日志中顺利得到了导致问题的线程名称为“RpcServer.listener,port=24600”。2.我们已经知道安全点是以“是否具有让程序长时间执行的特征”为原则进行选定的,所以方法调用、循环跳转、异常跳转这些位置都可能会设置有安全点,但是HotSpot虚拟机为了避免安全点过多带来过重的负担,对循环还有一项优化措施,认为循环次数较少的话,执行时间应该也不会太长,所以使用int类型或范围更小的数据类型作为索引值的循环默认是不会被放置安全点的。这种循环被称为可数循环(Counted Loop),相对应地,使用long或者范围更大的数据类型作为索引值的循环就被称为不可数循环(Uncounted Loop),将会被放置安全点。通常情况下这个优化措施是可行的,但循环执行的时间不单单是由其次数决定,如果循环体单次执行就特别慢,那即使是可数循环也可能会耗费很多的时间。
3.HotSpot原本提供了-XX:+UseCountedLoopSafepoints参数去强制在可数循环中也放置安全点,不过这个参数在JDK 8下有Bug,有导致虚拟机崩溃的风险,所以就不得不找到RpcServer线程里面的缓慢代码来进行修改。最终查明导致这个问题是HBase中一个连接超时清理的函数,由于集群会有多个MapReduce或Spark任务进行访问,而每个任务又会同时起多个Mapper/Reducer/Executer,其每一个都会作为一个HBase的客户端,这就导致了同时连接的数量会非常多。更为关键的是,清理连接的索引值就是int类型,所以这是一个可数循环,HotSpot不会在循环中插入安全点。当垃圾收集发生时,如果RpcServer的Listener线程刚好执行到该函数里的可数循环时,则必须等待循环全部跑完才能进入安全点,此时其他线程也必须一起等着,所以从现象上看就是长时间的停顿。找到了问题,解决起来就非常简单了,把循环索引的数据类型从int改为long即可。
总结:
1.单个java虚拟机管理大量内存时候需要注意FullGC的时间和系统的位数(32位最大内存只有4G,64位为128G),可以通过修改GC处理器、集群部署等方式解决。
2.使用-XX:+HeapDumpOnOutOfMemoryError可以让系统发生OOM的时候打印出DUMP文件来分析。
3.处理OOM时可能还需要考虑堆外内存的情况,32位windows系统下每个进程2GB,一般堆划给1.5G。
4.外部命令也可能导致系统缓慢,Runtime.getRuntime().exec()会导致系统函数fork的调用,建议使用自带的API去获取调用服务器命令脚本的信息。
5.异步调用超时后,可能会导致等待的线程和socket连接越多,从而导致虚拟机进程崩溃,可以使用消息队列来解决问题。
6.不恰当的数据结构也会导致内存占用过大,必要时可以取消Survive区,如果存活对象过多,把这些对象复制到Survivor并维持这些对象引用的正确性会一个沉重的负担
7.G1收集器的最大暂停时间设置可能会由于安全点的原因,暂停时间远远超出设置的值。
(垃圾收集会等所有线程到达安全点后开始,使用int类型或范围更小的数据类型作为索引值的循环默认是不会被放置安全点的)