1.概述

  前面三篇介绍了处理Java虚拟机内存问题的知识与工具,在处理实际项目的问题 时,除了知识与工具外,经验也是一个很重要的因素。因此本章将与读者分享几个比较 有代表性的实际案例。考虑到虚拟机故障处理和调优主要面向各类服务端应用,而大部 分Java程序员较少有机会直接接触生产环境的服务器,因此本章还准备了一个所有开发人员都能够进行“亲身实战”的练习,希望通过实践使读者获得故障处理和调优的经验。

2. 案例分析

  本章中的案例大部分来源于处理过的一些问题,还有一小部分来源于网上有特色和代表性的案例总结。出于对客户商业信息保护的目的,在不影响前后逻辑的前提 下,笔者对实际环境和用户业务做了一些屏蔽和精简。

  2.1 高性能硬件上的程序部署策略

  一个15万PV/天左右的在线文档类型网站最近更换了硬件系统,新的硬件为4个CPU、16GB物理内存,操作系统为64位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的堆,用户只感到访问 网站比较缓慢,但不会发生十分明显的停顿,因此才考虑升级硬件提升程序效能,如果 重新缩小给Java堆分配的内存,那么硬件上的投资就浪费了。

  在高性能硬件上部署程序,目前主要有两种方式:

  • 通过64位JDK来使用大内存。
  • 使用若干个32位虚拟机建立逻辑集群来利用硬件资源。

  此案例中的管理员采用了第一种部署方式。对于用户交互性强、对停顿时间敏感的 系统,可以给Java虚拟机分配超大堆的前提是有把握把应用程序的Full GC频率控制得足够低,至少要低到不会影响用户使用,譬如十几个小时乃至一天才出现一次Full GC, 这样可以通过在深夜执行定时任务的方式触发Full GC甚至自动重启应用服务器来将内存可用空间保持在一个稳定的水平。

  控制Full GC频率的关键是看应用中绝大多数对象能否符合“朝生夕灭”的原则, 即大多数对象的生存时间不应当太长,尤其是不能产生成批量的、长生存时间的大对 象,这样才能保障老年代空间的稳定。

  在大多数网站形式的应用里,主要对象的生存周期都应该是请求级或页面级的, 会话级和全局级的长生命对象相对很少。只要代码写得合理,应当都能实现在超大堆中正常使用而没有Full GC,这样的话,使用超大堆内存时,网站响应的速度才比较有保证。除此之外,如果计划使用64位JDK来管理大内存,还需要考虑下面可能面临的问题:

  • 内存回收导致的长时间停顿
  • 现阶段,64位JDK的性能测试接结果普遍低于32位JDK
  • 需要保证程序足够稳定,因为这种应用要是产生堆溢出几乎就无法产生堆转储快 照(因为要产生十几GB乃至更大的dump文件),哪怕产生了快照也几乎无法进 行分析。
  • 相同的程序在64位JDK中消耗的内存一般比32位JDK大,这是由指针膨胀及数据类型对齐补白等因素导致的。

  上面的问题听起来有点吓人,所以现阶段不少管理员还是选择第二种方式:使用若 干个32位虚拟机建立逻辑集群来利用硬件资源。具体做法是在一台物理机器上启动多 个应用服务器进程,给每个服务器进程分配不同的端口,然后在前端搭建一个负载均衡 器,以反向代理的方式来分配访问请求。读者不需要太在意均衡器转发所消耗的性能, 即使使用64位JDK,许多应用也不止有一台服务器,因此在许多应用中前端的均衡器 总是要存在的。

  考虑到在一台物理机器上建立逻辑集群的目的仅仅是尽可能地利用硬件资源,并不 需要关心状态保留、热转移之类的高可用性需求,也不需要保证每个虚拟机进程有绝对 准确的均衡负载,因此使用无Session复制的亲合式集群是一个相当不错的选择。我们 仅仅需要保障集群具备亲和性,也就是均衡器按一定的规则算法(一般根据SessionlD 分配)将一个固定的用户请求永远分配到固定的一个集群节点进行处理即可,这样程序开发阶段就基本不用为集群环境做什么特别的考虑。

  当然,很少有没有缺点的方案,如果读者计划使用逻辑集群的方式来部署程序,可 能会遇到下面一些问题:

  • 尽量避免节点竞争全局的资源,最典型的就是磁盘竞争,各个节点如果同时访问 某个磁盘文件的话(尤其是并发写操作容易出现问题),很容易导致IO异常。
  • 很难最高效率地利用某些资源池,譬如连接池,一般都是在各个节点建立自己 独立的连接池,这样有可能导致一些节点池满了而另外一些节点仍有较多空余。尽管可以使用集中式的JNDI,但这有一定的复杂性并且可能带来额外的 性能代价。
  • 大量使用本地缓存(如大量使用HashMap作为K/V缓存)的应用,在逻辑集群中会造成较大的内存浪费,因为每个逻辑节点上都有一份緩存,这时可以考虑把 本地缓存改为集中式缓存。
  • 各个节点仍然不可避免地受到32位的内存限制,在32位Windows平台中每个进程只能使用2GB的内存,考虑到堆以外的内存开销,堆一般最多只能开到1.5GB。在某些Linux, Unix系统(如Solaris)中,可以提升到3GB乃至接近4GB的内存,但32位中仍然受最高4GB (232)内存的限制。

  介绍完这两种部署方式,再重新回到这个案例之中,最后的部署方案调整为建立5个32位JDK的逻辑集群,每个进程按2GB内存计算(其中堆固定为1.5GB),占用了 10GB的内存。另外建立一个Apache服务作为前端均衡代理访问门户。考虑到用户对响 应速度比较关心,并且文档服务的主要压力集中在磁盘和内存访问上,CPU资源敏感度 较低,因此改为CMS收集器进行垃圾回收。部署方式调整后,服务再没有出现长时间 停顿,速度比硬件升级前有较大提升。

  2.2 集群间同步导致的内存溢出

  一个基于B/S的MIS系统,硬件为两台2个CPU、8GB内存的HP小型机,服务器 是WebLogic 9.2,每台机器启动了 3个WebLogic实例,构成一个6个节点的亲合式集 群。由于是亲合式集群,节点之间没有进行Session同步,但是有一些需求要实现部分 数据在各个节点间共享。开始这些数据存放在数据库中,但由于读写频繁竞争很激烈, 对性能的影响较大,后面使用JBossCache构建了一个全局缓存。全局缓存启用后,服务 正常使用了较长的一段时间。但最近不定期地多次出现内存溢出问题。

  在不出现内存溢出异常的时候,服务内存回收状况一直正常,每次内存回收后都能 恢复到一个稳定的可用空间,开始怀疑是程序的某些不常用的代码路径中存在内存泄 漏,但管理员反映最近程序并未更新或升级过,也没有进行什么特别的操作。只好让服 务带着-XX:+HeapDumpOnOutOfMemoryEiror参数运行了一段时间。在最近一次溢出之 后,管理员发回了 heapdump文件,发现里面存在着大量的org.jgroups.protocols.pbcast. NAKACK 对象。

  JBossCache是基于自家的JGroups进行集群间的数据通信,JGroups使用协议栈的方式来实现收发数据包的各种所需特性的自由组合,数据包接收和发送时要经过每层协议栈的up()和down()方法,其中的NAKACK栈用于保障各个包的有效顺序及重发,JBossCache的协议栈如图5-1所示。

  

  由于信息有传输失败需要重发的可能性,在确认所有注册在GMS (Group Membership Service)的节点都收到正确的信息前,发送的信息必须在内存中保留。而 此MIS的服务端中有一个负责安全校验的全局Filter,每当接收到请求时,均会更新一 次最后的操作时间,并且将这个时间同步到所有的节点中,使得一个用户在一段时间内 不能在多台机器上登录。在服务使用过程中,往往一个页面会产生数次乃至数十次的请 求,因此这个过滤器导致集群各个节点之间的网络交互非常频繁。当网络情况不能满足 传输要求时,重发数据在内存中不断地堆积,很快就产生了内存溢出。

  这个案例中的问题,既有JBossCache的缺陷,也有MIS系统实现方式上的缺陷。 JBossCache官方的maillist中讨论过很多次类似的内存溢出异常问题,据说后续版本有 了改进。而更重要的缺陷是这一类被集群共享的数据如果要使用类似JBossCache这种集群缓存来同步的话,可以允许读操作频繁,因为数据在本地内存有一份副本,读取的动 作不会耗费多少资源,但不应当有过于频繁的写操作,这会带来很大的网络同步的开销。

  2.3 堆外内存导致的溢出错误

  这是一个学校的小型项目:基于B/S的电子考试系统,为了实现客户端能实时地从服务端接收考试数据,系统使用了逆向AJAX技术(也成为Comet或Server Side Push),选用CometD 1.1.1 作为服务器推送框架,服务器是jetty 7.4.1,硬件为一台普通PC机,Core i5 CPU,4GB内存,运行32位Windows操作系统。

  测试期间发现服务端不定时拋出内存溢出异常,服务器不一定每次都会出现异常, 但假如正式考试时崩溃一次,那估计整场电子考试都会乱套,网站管理员尝试过把堆开 到最大,32位系统最多到1.6GB基本无法再加大了,而且开大了也基本没效果,抛出内 存溢出异常好像更加频繁了。加入-XXi+HeapDumpOnOutOfMemoryError,居然也没有 任何反应,抛出内存溢出异常时什么文件都没有产生。无奈之下只好挂着jstat使劲盯屏 幕,发现GC并不频繁,Eden区、Survivor区、老年代及永久代内存全部都表示“情绪 稳定,压力不大”,但照样不停地抛出内存溢出异常,管理员压力很大。最后,在内存 溢出后从系统日志中找到异常堆栈,如代码清单5-1所示。

  

  如果认真阅读过本书的第2章,看到异常堆栈就应该清楚这个内存溢出异常是怎么回事了。大家知道操作系统对每个进程能管理的内存是有限制的,这台服务器使用 的32位Windows平台的限制是2GB,其中给了 Java堆1.6GB,而Direct Memory并不算在1.6GB的堆之内,因此它只能在剩余的0.4GB空间中分出一部分。在此应用中导致溢出的关键是:垃圾收集进行时,虚拟机虽然会对Direct Memory进行回收,但 是Direct Memory却不能像新生代和老年代那样,发现空间不足了就通知收集器进行 垃圾回收,它只能等待老年代满了后Full GC,然后“顺便地”帮它清理掉内存的废弃 对象。否则,它只能等到抛出内存溢出异常时,先catch掉,再在catch块里面“大喊” 一声:“System.gc。! ”。要是虚拟机还是不听(譬如打开了-XX:+DisableExplicitGC 开关),那就只能眼睁睁地看着堆中还有许多空闲内存,自己却不得不抛出内存溢出异 常了。而本案例中使用的CometD 1.1.1框架,正好有大量的NIO操作需要用到Direct Memory。

  从实践的角度来讲,除了Java堆和永久代之外,我们注意到下面这些区域还会占用较多的内存,这里所有的内存总和会受到操作系统进程最大内存的限制。

  • Direct Memory :可通过-XX:MaxDirectMemorySize调整大小,内存不足时抛出 OutOfMemoryError 或 OutOfMemoryError: Direct buffer memory „
  • 线程堆栈:可通过-Xss调整大小,内存不足时抛出StackOverflowError (纵向无 法分配,即无法分配新的栈帧)或 OutOfMemoryError: unable to create new native thread (横向无法分配,即无法建立新的线程)。
  • Socket缓存区:每个Socket连接都Receive和Send两个缓存区,分别占大约 37KB和25KB的内存,连接多的话这块内存占用也比较可观。如果无法分配, 则可能会抛出 lOException: Too many open files 异常。
  • JNI代码:如果代码中使用JNI调用本地库,那本地库使用的内存也不在堆中。
  • 虚拟机和GC:虚拟机和GC的代码执行也要消耗一定的内存。

  2.4 外部命令导致系统缓慢

  这是一个来自网络的案例:一个数字校园应用系统,运行在一台4个CPU的 Solaris 10操作系统上,中间件为GlassFish服务器。系统在进行大并发压力测试的时 候,发现请求响应时间比较慢,通过操作系统的mpstat 工具发现CPU使用率很高,并 且占用绝大多数CPU资源的程序并不是应用系统本身。这是个不正常的现象,通常情况 下用户应用的CPU占用率应该占主要地位,才能说明系统是正常工作的。

  通过Solaris 10的Dtrace脚本可以査看当前情况下哪些系统调用花费了最多的CPU 资源,Dtrace运行后发现最消耗CPU资源的竟然是“fork”系统调用。众所周知,“fork” 系统调用是Linux用来产生新进程的,在Java虚拟机中,用户编写的Java代码最多只 有线程的概念,不应当有进程的产生。

  这是个非常异常的现象。通过本系统的开发人员最终找到了答案:每个用户请求的 处理都需要执行一个外部shell脚本来获得系统的一些信息。执行这个shell脚本是通过 Java的Runtime.getRuntime().exec()方法来调用的。这种调用方式可以达到目的,但是 它在Java虚拟机中非常消耗资源,即使外部命令本身能很快执行完毕,频繁调用时创 建进程的开销也非常可观。Java虚拟机执行这个命令的过程是:首先克隆一个和当前虚 拟机拥有一样环境变量的进程,再用这个新的进程去执行外部命令,最后再退出这个进程。如果频繁执行这个操作,系统的消耗会很大,不仅是CPU,内存的负担也很重。用户根据建议去掉这个shell脚本执行的语句,改为使用Java的API去获取这些信息后,系统很快就恢复了正常。

  2.5 服务器JVM进程崩溃

  一个基于B/S的MIS系统,硬件为两台2个CPU、8GB内存的HP系统,服务器是 WebLogic 9.2 (就是第二个案例中的那套系统)。正常运行一段时间后,最近发现在运行 期间频繁出现集群节点的虚拟机进程自动关闭的现象,留下了一个hs_err_pid###.log文 件后,进程就消失了,两台物理机器里的每个节点都出现过进程崩溃的现象。从系统日 志中注意到,每个节点的虚拟机进程在崩溃前不久,都发生过大量相同的异常,见代码 清单5-2。

  

  这是一个远端断开连接的异常,通过系统管理员了解到系统最近与一个OA门户做了集成,在MIS系统工作流的待办事项变化时,要通过Web服务通知OA门户系 统,把待办事项的变化同步到OA门户之中。通过SoapUI测试了一下同步待办事项的 几个Web服务,发现调用后竟然需要长达3分钟才能返回,并且返回的结果都是连接 中断。

  由于MIS系统的用户多,待办事项变化很快,为了不被OA系统的速度拖累,使用了异步的方式调用Web服务,但由于两边服务的速度完全不对等,时间越长就累积了越多Web服务没有调用完成,导致在等待的线程和Socket连接越来越多,最终超过虚拟 机的承受能力后使得虚拟机进程崩潰。通知OA门户方修复无法使用的集成接口,并将异步调用改为生产者/消费者模式的消息队列实现后,系统恢复正常。