[JVM_7]JVM调优实战

587 阅读12分钟

前面6篇Blog我们完成了最初的JVM内存区域,对象的回收与分配的理论加常用工具(jvisualvm无敌!)的学习,今天我们来实战一下!

案例1 高性能硬件上的程序部署策略

一个15万pv((Page View)/天的在线文档类的网站,最近更换了硬件系统,新的硬件系统为4个CPU,16G物理内存,64位CentOS 5.4,Resin作为WEB服务器,没有别的应用.管理员为了尽量利用资源,选择64位的JDK,-Xms-Xms将heap固定为12G禁止扩容,问题:经常不定期产生长时间失去响应

监控服务器进行情况后,如使用jstat -gcutil 1748来看各阶段GC时间,总GC时间,使用了PS(复制)+PSOLD(标记整理)收集器,回收12GB的堆,一次FGC停顿高达14s

由于程序的原因,访问文档时要讲文档序列化进入内存,产生大对象,大对象在YGC中复制的时候,由于Survivor放不下,启动老年代的担保直接进入老年代,随着老年代容量的填满引发FGC,引发停顿

即核心问题是:过大的堆内存进行回收时,带来的长时间停顿

在高性能硬件上部署程序,主要有2种方式

  1. 通过64位JDK使用大内存
  2. 通过若干个32位JVM建立逻辑集群,如nginx反向代理

本案例中,使用了第一种方式,但是分配超大堆的前提是能够控制好FGC频率足够低,而控制FGC频率的关键就是绝大多数对象符合朝生夕灭,尤其是不能有成批量的,长时间生存的大对象,这样才能保证老年代空间的稳定

在大多数网站应用里,主要的对象都是请求级的或者页面级的,会话级和全局级的长生命对象很少,按照这个原则,才能在超大堆中正常使用没有FGC,网站速度才能有保证

64位JDK的问题

  • 内存回收导致的STW
  • 64位性能普遍低于32位
  • 需要保持足够稳定,dump巨大无法分析
  • 相同程序64位消耗的内存比32位大

所以说现阶段不少管理员采用若干32位虚拟机建立逻辑集群来利用硬件资源,如使用nginx反向代理分配请求

逻辑集群的问题

  • 节点竞争全局资源,如竞争磁盘IO
  • 很难高效利用某些资源池,如数据库连接池
  • 受到32位内存限制,4GB(2^32)
  • 使用本地缓存的话,在每个节点都要有缓存,造成内存浪费,可以使用集中式缓存

最终解决方案

由于本网站主要是文档门户,cpu不敏感,所以没必要用PS+PSOld吞吐量组合,换成ParNew+CMS,降低STW,部署则调整为建立5个32位JDK逻辑集群,每个进程2GB内存,heap固定1.5G,效果改善明显

从这个案例我们学到了

1.64位JVM虽然可以使用更大的heap,但是对超大堆的GC就要更久,我们要把握住程序中大对象的生命周期,避免长期占用老年代,引发FGC导致停顿 2.IO密集型不关注吞吐量,关注反应时间,所以使用ParNew+CMS

案例二 集群间同步导致内存溢出

案例五 服务器JVM进程崩溃(这两个环境一样,一起说)

有一个基于B/S的MIS(管理信息系统(Management Information System)系统,硬件为两台,2个CPU,8G内存的HP小型机,服务器weblogic9.2,每台启动了3个weblogic实例,构成一个6个节点的亲和式集群,节点之间没有session同步,但是有一些数据需要同步,开始这些数据放在数据库中,由于读写频繁竞争激烈,所以使用JBOSSCaChe构建了一个全局缓存,全局缓存启用后,服务正常了一段较长的时间,但是最近不定期的出现了多次内存溢出的问题

有一天它又坏了,出现了JVM进程自动关闭的现象 异常堆栈信息为:java.net.SocketException:Connection reset 这时一个远端断开连接的异常,由于系统中使用Socket做了通知服务,而对方的反应极慢(或者直接连接中断),这边速度很快,所以使用了异步的方式去调用,时间越长就积累了越多Web调用没有完成,等待线程和socket连接越来越多,最终超过JVM承受能力使得JVM崩溃 解决办法 通知对方修复,且将异步调用改为生产者,消费者模式的消息队列后恢复正常 学到了 Socket连接积累过多导致JVM崩溃

session粘性 这种方式也成为亲和式集群,给session创造粘性,意思是让用户每次都访问的同一个应用服务器 这样就要在前端服务器apache中记录下,用户首次访问的是哪个tomcat,将用户后面发送的请求都发送到这个tomcat上去 这样带来的后果是,各个服务器负载不均衡,因为只在用户首次访问的时候,采用了负载均衡分发,但是这个影响也不会那么明显

处理思路

由于是OOM导致的宕机,那么我们需要抓获OOM当时的情况,在进行分析 -XX:+HeapDumpOnOutOfMemoryError,导出当OOM发生时,heap的情况

问题

问题出在使用集群共享的数据,可以允许读操作频繁,因为数据在本地内存有副本,但是不应该有频繁的写操作,会带来很大的网络同步开销,当网络受阻,由于JBOSS框架的原因,传输失败后,发送的信息会在内存中保留,导致大量内存占用,引发OOM

学到了

OOM出现时的处理思路,即输出发生时的dump,分析大量占用内存的对象

案例三 堆外内存导致的溢出错误

一个学校的小型项目:基于B/S的电子考试系统,硬件为普通PC,测试期间发现不定期抛出内存溢出异常,尝试把heap调大没有效果,加入-XX:+HeapDumpOnOutOfMemory没反应,使用jvisualVM,发现gc不频繁,各区无压力 最后从系统日志中找到异常堆栈

//标志性的堆外使用---NIO
...Unsafe.allocateMemory(Native method)//标志性的堆外溢出

解决思路

首先肯定是打印dump分析,查看jvisualVM,都看不出来就要去日志等地方找错误的堆栈,这里就找到了是堆外内存溢出,回忆下堆外内存,它不分配在堆中,默认与堆同样大小,这里进程2G,堆拉到了1.6G,那堆外就去0.4G里分一点

Direct Memory的内存回收:GC时,虚拟机虽然会对直接内存回收,但是不是像新生代老年代那样,空间不足了就回收,而是等老年代满了,FGC时,顺便收一下直接内存

OOM为什么有时候不打印堆栈信息 强制打印使用参数 -XX:-OmitStackTraceInFastThrow 不打印的时候,是JVM进行了优化 优化是,当第一次发生异常(通常为NullPointerException)时,将打印完整的堆栈跟踪,并且JVM会记住堆栈跟踪(或者可能只是代码的位置)。当该异常经常发生时,将不再打印堆栈跟踪,这既可以实现更好的性能,又不会使相同的堆栈跟踪充满日志。

为什么是堆栈信息? 堆栈跟踪(stack trace)是一个方法调用过程的列表,发生异常的时候e.printStackTrace(); 是把产生该异常的一系列方法的调用过程打印出来(最后调用的方法最先打出来),按照方法的调用过程查找异常出现的原因会很方便。 举个小例子,main()方法中按顺序调用了a()方法和b()方法,其中b()方法中又调用了c()方法,c()方法中产生了一个异常,打印后的结果类似 c():产生异常的行号 b():c方法调用的行号 main():b方法调用的行号 结合之前的知识,堆栈是方法的内存模型,执行方法时push,完毕pop

除了Java堆和MetaSpace,下面的区域也会占用较多内存

  • Direct Memory :可以通过 -XX:MaxDirectMemorySize调整大小,未设置的话默认是最大heap大小,内存不足时抛出OOM,或者OOM:Direct buffer memory
  • 线程堆栈 -Xss,内存不足时抛出StackOverFlowError或者OOM:unable to create new native thread
  • Socket缓冲区:每个Socket连接都Receive和Send两个缓冲区,分别占用37KB和25KB内存,连接多的话这一块内存占用也比较可观,无法分配,抛出IOException:Too many open files
  • JNI代码 使用Java native interface去调用native方法,native方法使用的内存也不再heap中
  • 虚拟机和GC:消耗一定内存

学到了

OOM不仅仅针对heap,寻找各方面的堆栈信息,然后总和考虑各个占用内存的因素,解决OOM

案例四 外部命令导致系统缓慢

一个数字校园应用系统,运行在一台4个CPU的Solaris 10系统上,中间件为GlassFish服务器,做大并发压测,请求响应慢,使用mpstat发现CPU占用率很高,并且占用大多数CPU资源的并不是系统本身,这是个不正常的现象

//mpstat
[root@iZ2ze38tf0alwqik6mufu7Z ~]# mpstat
Linux 3.10.0-1062.1.2.el7.x86_64 (iZ2ze38tf0alwqik6mufu7Z) 	12/12/2019 	_x86_64_	(1 CPU)

05:33:38 PM  CPU    %usr   %nice    %sys %iowait    %irq   %soft  %steal  %guest  %gnice   %idle
05:33:38 PM  all    0.25    0.00    0.29    0.01    0.00    0.00    0.00    0.00    0.00   99.46

解决思路

在centos上使用一个很酷的工具 top/htop来查看当前是哪个进程占用了最多的CPU资源,它还可以按照每一个列表头排序,比如这里我用CPU降序显示 image.png

在这个案例中,发现最消耗CPU资源的是fork系统调用,而Java语言中只会产生新的线程,而不是进程,那么这一定是发生了外部shell脚本调用

外部shell脚本调用:Java通过Runtime.getRuntime().exec()方法调用,它在JVM中非常的消耗资源,即使外部的shell脚本很快执行完毕,频繁调用时产生的创建进程的开销非常可观,JVM执行这个命令的过程是,首先克隆一个和当前JVM拥有一样的环境变量的进程,再用这个新的进程去执行外部命令,最后再退出这个进程,如果频繁操作嘛,系统消耗极大

fork调用在Linux中是用来产生新++进程++的

那么结果是我们去掉Shell执行的语句,使用JavaAPI来实现相同的效果

学到了

CPU占用率工具 mpstat 详细占用查看工具 top/htop Java执行外部shell脚本,需要创建进程,消耗较大

案例六 不恰当的数据结构导致内存占用过大

RPC,就是Remote Procedure Call的简称呀,翻译成中文就是远程过程调用

有一个后台RPC服务器,使用64位JVM,内存配置为-Xms4g -Xmx8g -Xmn1g,使用ParNew和CMS,平时YGC时间在30ms以内,但是业务上每10分钟加载一个80MB的数据文件到内存中进行数据分析,这些数据会在内存中形成超过100w个HashMap<Long,Long> Entry,这段时间YGC就会造成500ms的停顿,这个时间就接受不了了

解决思路

首先看这一对收集器ParNew(并行,复制算法)+CMS(标记清除算法),ParNew的高效建立在Eden区对象的朝生夕灭,像这种情况,存活对象太多,复制并维持正确引用负担沉重 我们可以删掉Survivor区,即-XX:SurvivorRetio=65536,即直升老年代,-XX:MaxTenuringThreshold=0 晋升老年代年龄为0,即一次YGC直升老年代 -XX:+AlwaysTenure 表示没有幸存区,所有对象在第一次gc时,会晋升到老年代 但是指标不治本,老年代满了引发FGC

根本原因还是HashMap<Long,Long>这个结构存储文件效率太低 实际耗费的内存未 Long(24B*2)+Entry(32B)+HashMapRef(8B)=88B,空间效率为18% 对象头是8B的倍数时不用填充,所以这里Long 8B MarkWord 8B 指向类元数据8B 当形成Entry 对象头16B next字段 8B int的hashcode 4B 对齐填充 4B Hashmap中对这个entry 8B的引用,这样算出来空间效率不高

学到了

根据收集器先考虑问题,考虑大对象的数据结构空间效率,总是JVM大敌就是大对象,出问题优先考虑他们就对了

对象的组成

  1. 对象头(Header) 1.1 储存自身运行时数据,即MarkWord(32位32bit,64位64bit) 1.1.1 hash code 1.1.2 GC分代年龄 1.1.3 锁状态标志 1.1.4 线程持有的锁 1.1.5 偏向线程ID 1.1.6 偏向时间戳 1.2 类型指针 对象指向它类元数据的指针
  2. 实例数据(Instance Data) 真正储存的有效信息
  3. 对齐填充(Padding) 起到占位符的作用,保证是8字节的整数倍

案例七 由Windows虚拟内存导致的长时间停顿

一个Windows下的GUI桌面程序,每15s会发送一次心跳检测信号,问题是偶尔出现间隔1分钟的无日志输出

解决思路

一看STW了就想到了GC停顿,使用-XX:+PrintGCApplicationStoppedTime配合-Xloggc:gc.log 打印每次GC停顿时间并输出为log,看到确实是gc导致了程序停顿

使用-XX:+PrintReferenceGC可以看到真正GC的启动时间,这里看出从准备开始gc到真正gc,所消耗的时间占了大部分

还有一个特点就是最小化的时候,占用内存大幅度减少,怀疑是最小化的时候,交换到硬盘虚拟的内存空间中去了,这样发生GC就因为恢复页面导致不正常的GC停顿

可以加入参数 "-Dsun.awt.keepWorkingSetOnMinimize"保持一直在内存中,恢复最小化时立即响应.