5.1 概述
5.2 案例分析
5.2.1 高性能硬件在程序部署上的策略
一个每天 PV 为 15 万左右的在线文档类型的网站更换了硬件系统,参数为 4 个 CPU、16 GB内存,操作系统为 centos 5.4,Resin做为 web 服务器。选用 64 位 的jdk 1.5,通过 -Xmx 和 -Xms 参数将 Java堆固定到 12GB。使用一段时间后效果不理想,网站经常出现长时间失去响应的情况。
监控服务器运行状况后发现网站失去响应是因为 GC 导致的,虚拟机运行在 server 模式,默认使用的吞吐量优先收集器,回收 12GB 的堆内存,一次 Full GC 的停顿时间高达 14 秒。并且由于程序设计问题,访问文档时要把文档从磁盘提取到内存,导致内存出现由文档序列化产生的大对象,这些大对象直接进入老年代,没有在 Minor GC 中清理掉。这情况下即使有 12GB 的堆,内存也很快就被耗尽,由此出现每个十几分钟出现十几秒的停顿。
程序部署上的主要问题,是由于过大的堆内存进行回收时带来的长时间停顿。硬件升级前使用 32 位系统 1.5GB 的堆,用户只是感觉使用网站比较缓慢,还不会发现明显的停顿。
在高性能上部署程序,主要有两种方式:
通过 64 位 JDK 来使用大内存。
使用若干个 32 位虚拟机建立逻辑集群来利用硬件资源。
最后的部署方案调整为建立 5 个32 位 JDK的逻辑集群,每个进程按 2GB 内存计算(其中固定堆为 1.5GB),占用 10GB 内存。另外建立一个 Apache 服务器作为前端负载均衡代理访问门户。同时考虑到用户对响应速度要求较高,并且文档服务器的主要压力集中在磁盘和内存访问,CPU 资源敏感度较低,因此改为 CMS 收集器。
5.2.2 集群之间同步导致的溢出
一个基于 B/S 的 MIS 系统,硬件为两台 2 个CPU,8 GB内存的 HP 小型机器,服务器使用 weblogic,每台机器启动三个 weblogic 实例,构成 6 节点的亲和式集群。
亲和式集群:由于http请求是无状态的,那么对于会话级别的事务,如何保持用户的状态?在单个服务器中,提供了session-sessionID的机制来保存用户的状态那么现在有多台服务器,如何记录用户的状态?有两个大方向:session粘性、共享session。其中 session 粘性这种方式也成为亲和式集群,给session创造粘性,意思是让用户每次都访问的同一个应用服务器这样就要在前端服务器apache中记录下,用户首次访问的是哪个tomcat,将用户后面发送的请求都发送到这个tomcat上去这样带来的后果是,各个服务器负载不均衡,因为只在用户首次访问的时候,采用了负载均衡分发,但是这个影响也不会那么明显。参考资料:web服务器集群(亲和式集群,集群中保持用户状态)
服务器启动一段时间后,不定期的出现内存溢出问题。在服务启动时增加 -XX:+HeapDumpOnOutOfMemoryError 参数运行一段时间,分析出很多大量的 NAKACK 对象。这是因为程序为共享数据引入了 JBossCache 包,其中有个过滤器filter,每当请求来时,都会同步操作时间到各个节点,导致集群各个节点的网络交互频繁。当网络不稳定时,重发数据在内存会出现大量的堆积,很快就导致内存溢出了。
5.2.3 堆外内存导致的溢出错误
当出现内存溢出时,试着调大堆内存但是却不管用,同时开启了 -XX:+HeapDumpOnOutOfMemoryError 参数,也没有堆转储文件生成。而且使用 jstat 监控程序发现 GC 不频繁,Eden、survivor 区、老年代以及永久代显示压力不大,那么就需要查看异常信息中内存溢出的信息,发现是 DirectByteBuffer.java 方法抛出的异常信息。说明这是堆外内存不足导致的溢出问题。
5.2.4 外部命令导致系统缓慢
一个应用系统在做压力测试时,通过操作系统的 mpstat 工具发现 CPU 使用率很高,并且系统占用绝大多数的 CPU 资源的程序并不是应用系统本身。这不是正常现象。
通过 Solaris 系统的 dtrace 脚本发现当前情况下花费 cpu 资源最多的系统是 fork 系统调用。这个 fork 系统调用是 Linux 用来产生新进程的,在 Java 虚拟机中,用户编写的Java 代码最多只有线程概念,不会出现进程。
经过排查发现,这个系统中每个用户的处理都会调用外部的 shell 脚本来获取系统信息。执行这个脚本是通过 Java 的 Runtime.getRuntime().exec() 方法来调用的。这种方法在 Java 虚拟机中是非常消耗资源的操作,即使外部命令可以很快的执行完毕,但是频繁调用创建进程的开销也很厉害。Java 虚拟机执行这个命令的过程是:首先克隆一个和当前虚拟机一样环境变量的进程,然后用这个新的进程去执行外部命令,最后在退出这个进程。频繁的执行这个操作,不仅仅 cpu 还有内存的压力很大。
改进:去掉调用这个 shell 的方法,改用 Java 的 api 方式获取这些信息。
5.2.5 服务器 JVM 进程崩溃
一个系统运行一段时间后,发现在运行期间频繁的出现集群节点的虚拟机进程自动关闭的现象,留下一个 hs_err_pid###.log 文件,进程就消失了。从系统日志中发现,每个节点的虚拟机在崩溃前不久都会出现下的异常信息:java.net.SocketException: Connection reset 。
这是一个远程断开连接的异常,发现这是最近最近集成了 OA 系统。当工作流的待办事项发生变化时,要通过 web 服务通知 OA 系统。由于该系统使用的人数多,待办事项也变化快,为了不被 OA 系统系统拖累,采用了异步的方式调用 Web 服务。但是由于两边的服务处理速度不对等,时间越长就积累了越多的 web 调用,导致等待的线程和 socket 连接越来越多,最终在超过虚拟机的承受能力后使得虚拟机进程崩溃。
解决方法:将异步调用改为生产者/消费者的消息队列实现。
5.2.6 不恰当的数据结构导致内存占用过大
一个使用 ParNew + CMS 的收集器,使用 -Xms4g -Xmx8g 虚拟机参数的 Java 系统,有一个业务,每 10 分钟加载一次 80MB 的数据文件到内存中进行数据分析,这些数据在内存中形成超过 100 万个 HashMap<Long,Long> Entry,在这段时间里面 Minor GC 就会造成超过 500ms 的停顿时间,这个时间是接受不了的。
观察这个案例,发现平时的 minor gc 时间很短,原因是新生代的对象绝大多数是可清除的,在 minor gc 后 Eden 和 survivor 区基本都是出于空闲状态。而在分析数据阶段,800MB 的 Eden 很快被填满从而引发 GC,但 minor gc 之后,新生代中绝大部分对象存活下来了。我们知道 ParNew 收集器采用复制算法,这个算法的高效是建立在大多数对象都是“朝生夕灭”的特性上的,如果存活对象过多,把这些对象复制到 survivor 区并维持这些对象正确的引用就成了一个沉重的负担,因此导致 GC 时间明显变长。
治标方案:考虑去掉 survivor 空间,加入以下参数 -XX:SurvivorRatio=65535、-XX:MaxTenuringThreshold=0 或者 -XX:+AlwaysTenure。让新生代对象在第一次 minor gc 后进入老年代,等待 major gc 时在清理掉它们。
治本方法:这里产生问题的本质原因是因为用 HashMap<Long,Long> 结构来存储文件数据效率太低。
分析下空间效率,在 HashMap<Long,Long> 结构中,只有 key 和 value 所存放的两个长整型数据是有效的,共 16B(2 * 8B)。这两个长整型数据包装成 java.lang.Long 对象之后,就分别具有了 8B 的 MarkWord 和 8B 的 Klass 指针,再加 8B 的存储数据的 long 值。在这两个 Long 的对象组成 Map.Entry 之后,又多了 16B 的对象头,然后一个 8B 的 next 字段和 4B 的 int 型的 hash 字段,为了对齐,还必须添加 4B 的空白填充,最后还有 HashMap 这个对 Entry 的 8B 的引用,这样增加两个长整型数字,实际消耗的内存为 (Long(24B * 2)+ Entry(32B)+ HashMap Ref(8B))= 88B,空间效率为 16B/88B = 18%,太低了!
5.2.7 由 Windows 虚拟内存导致的长时间停顿
一个 GUI 桌面程序,每 15 秒会发送一次心跳检测,如果对方 30 秒以内都没有信号返回,那么就认为和对方程序的连接已经断开。
上线后程序偶然出现心跳检测误报的情况,查询日志发现误报的原因是程序偶尔会出现间隔一分钟左右的时间完全没有日志输出,处于停顿状态。
通过加入参数 -XX:+PrintGCApplicationStoppedTime -XX:+PrintGCDateStamps -Xloggc:gclog.log 后,从 GC 日志中确认了停顿确实是由于 GC 导致。从 GC 日志中找到长时间停顿的具体信息(添加 -XX:+PrintReferenceGC 参数),发现真正执行 GC 的时间不是很长,但是从准备 GC,到真正开始 GC 之间所消耗的时间占据了绝大部分时间。
除了 GC 日志之外,还观察到这个 GUI 程序内存变化的一个特点,当它最小化时,资源管理器中显示的占用内存大幅度减小,但是虚拟内存则没有变化,因此怀疑是程序最小化的时候它的工作内存被自动交换到磁盘的页面文件中了,这样发生 GC 时就有可能因为恢复页面文件的操作而导致不正常的 GC 停顿。
从 MSDN 上查证后确认了这种猜想。因此在 Java 的 GUI 程序中要避免这种现象,可以加入参数 -Dsun.awt.keepWorkingSetOnMinimize=true 来解决
5.3 实战 eclipse 运行速度调优
5.3.1 调优前的程序运行状态
运行平台 32 位 Windows7 系统,虚拟机为 hotspot VM 1.5 b64,硬件为 ThinkPad X201,i5处理器,4GB 内存,优化前 eclipse 设置的最大堆内存 512MB,开启了 JMX 管理。
整个启动过程耗时 15 秒
最后一次启动的数据中,垃圾收集总耗时 4.1 秒,其中:
Full GC 被触发 19 次,共耗时 3.166 秒
Minor GC 被触发 378 次,共耗时 0.983 秒
加载类 9115 个,耗时 4.114 秒
JIT 编译时间为 1.999 秒
虚拟机 512MB 的堆内存被分配为 40MB 的新生代(31.5 的 Eden 区和两个 4MB 的 survivor 空间)以及 472 MB 的老年代
主要问题在于非用户程序时间(编译时间、类加载时间、垃圾收集时间)非常高,有恨大的优化空间。
5.3.2 升级 JDK 1.6 的性能变化问题
升级了 jdk 1.6 后发现 eclipse 使用几分钟后出现了内存溢出异常,通过 visual VM 中的内存曲线发现永久代监控图中最大内存已经满了,无法再继续扩容。最大容量为 67MB,我们通过指定 -XX:MaxPermSize 参数明确指定永久代的最大容量。
5.3.3 编译时间和类加载时间优化
我们通过 jps 查找 Java 线程,再通过 jstat -class ${Java线程id} 来统计出类加载的信息,包括时间等。
发现两个 jdk 在字节码验证部分耗时差距严重,使用参数 -Xverify:none 禁止字节码校验过程。
JIT 编译时间是指虚拟机的 JIT编译器(just in time compiler)编译热点代码(Hotspot)的耗时。参数 -Xint 禁止编译器运作,强制虚拟机对字节码采用纯解释执行的方式执行。我们一般不设置这个参数。
5.3.4 调整内存设置控制垃圾收集频率
eclipse 启动时间内发生了 19 次 Full GC 和 378 次的 minor gc 导致 4 秒的停顿。
新生代频繁发生 minor gc 是因为虚拟机分配给新生代的空间不足,Eden 区和 survivor 区还不到 35MB,因此需要参数 -Xmn 调整新生代的大小。老年代的 full gc 不多,但是从gc 日志上发现每次 gc 完毕后总的空间扩容了,从 150KB 扩容到了 400MB,所以我们可以免去老年代扩容占用的时间。
由此得出结论,我们可以把 -Xms 和 -XX:PermSize 参数设置成和 -Xmx 和 -XX:MaxPermSize 参数值一样。
我们再加入参数 -XX:+DisableExplicitGC 屏蔽掉 System.gc() 。
5.3.5 选择收集器降低延迟
考虑到 eclipse 是交互性很强的程序,需要要使用响应速度快的垃圾收集器,这里我们使用老年的 CMS 收集器。设置参数 -XX:+UseConcMarkSweepGC 收集器。