调优案例分析与实战

通过案例来分析Java部署中相关的优化措施。

5.2案例分析

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

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

  1. 通过64位JDK来使用大内存:

    ​ 对于用户交互性强、对停顿时间敏感的系统给虚拟机分配超大堆的前提是有把握把应用程序的Full GC频率控制得足够低,譬如十几个小时甚至一天才出现一次Full GC。而控制Full GC的频率关键看应用中绝大多数对象能否符合“朝生夕灭”的原则,即大多数对象的生存时间不应太长,尤其是不能有成批量的、长生存时间的大对象产生,这样才能保障老年代空间的稳定。同时还需要面临的问题:

    ​ 内存回收导致的长时间停顿。

    ​ 现阶段64位JDK的性能测试结果普遍低于32位JDK。

    ​ 需要保证程序足够稳定,因为这种应用要是产生堆溢出几乎就无法产生堆转储快照(因为要产生十几GB乃至更大的Dump文件),哪怕产生了快照也无法分析。

    ​ 相同程序在64位JDK消耗的内存一般比32位JDK大,这是由于指针膨胀和数据类型对齐补白等因素导致的。

  2. 使用若干个32位虚拟机建立逻辑集群来利用硬件资源:

    ​ 现阶段大多采用该方法,具体做法是在一台物理机器上启动多个应用服务器进程,每个服务器进程分配不同的端口,然后在前端搭建一个负载均衡器以反向代理的方式来分配访问请求。这种方案可能会遇到的问题:

    ​ 尽量避免节点竞争全局的资源,最典型的就是磁盘竞争,各个节点如果同时访问某个磁盘文件的话(尤其式并发写操作容易出现问题),很容易导致IO异常。

    ​ 很难高效地利用某些资源池,譬如连接池,一般都是在各个节点建立自己独立的连接池,这样有可能一些节点池满了而另外一些节点仍有较多空余。尽管可以使用集中式的JNDI(理解为同一资源池),但有一定复杂性并且可能带来额外的性能开销。

    ​ 各节点仍然不可避免地受到32位的内存限制,32位windows系统每个进程只能使用2GB的内存,若考虑堆以外的内存开销则堆一般最多只能开到1.5GB。在某些Linux或Unix系统中可以提升至3GB甚至接近4GB的内存,但32位中仍然受最高4GB内存的限制。

    ​ 大量使用本地缓存的应用在逻辑集群中会造成较大的内存浪费,因为每个逻辑节点上都有一份缓存,这时候可以考虑把本地缓存改为集中式缓存。

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

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

​ 查看生成的dump 文件发现里面有大量的org.jgroups.protocols.pbcast.NAKACK对象。JBossCatch基于自家的JGroups进行集群间的数据通信,JGroups使用协议栈的方式来实现收发数据包的各种所需特性自由组合。由于信息有传输失败需要重发的可能性,在确认所有注册在GMS(Group Membership Service)的节点都收到正确的信息前,发送信息必须在内存中保留。MIS服务端有一个负责安全校验的全局Filter,每当收到请求均更新一次最后操作时间并将其同步到所有节点,使一个用户一段时间内不能在多台机器上登录。服务器使用过程中往往一个页面产生数次乃至数十次的请求,因此这个过滤器导致集群节点之间网络交互非常频繁。当网络情况不能蛮子传输要求时,重发数据在内存中不断堆积,很快产生了内存溢出。若使用JBossCatch缓存来同步的话可以允许读操作频繁,但不应当有过于频繁的写操作。

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

​ 例子:学校小型项目,基于B/S的电子考试系统,为了实现客户端能实时地从服务器端接收考试数据系统使用了逆向AJAX技术。测试期间发现服务端不定时抛出内存溢出异常,服务器不一定每次都会抛出异常。管理员尝试过把堆卡到最大,32位系统堆最多到1.6G就基本无法再大了,而且开大了基本没什么效果,异常好像还更加频繁了。想通过参数使其产生dump文件,发现什么文件也没有产生。最后在内存溢出后从系统日志中找到异常堆栈。

​ 服务器使用32位Windows平台的限制是2GB,其中划了1.6GB给Java堆,而Direct Memory内存并不算入1.6GB的堆之内,因此最大也只能在剩余的0.4GB中分出一部分。此应用中导致溢出的原因为:垃圾收集时虚拟机虽然会对Direct Memory进行回收,但是Direct Memory不像新生代、老年代那样发现空间不足了就通知收集器进行垃圾回收,它只能等待老年代满了以后Full GC,然后顺便地帮他清理掉废弃的对象。否则它只能一直等到抛出内存溢出异常时先catch掉,再在catch块中调用System.gc(),要是此使虚拟机还不进行gc,那就不得不抛出内存溢出异常了(虽然有可能堆中还有很多空闲内存)。本案例中使用的CometD 1.1.1框架,正好有大量的NIO操作要使用到Direct Memory内存。

除了Java堆和永久代外,下面区域还会占用较多的内存:

​ Direct Memory:可通过参数-XX:MaxDirectMemorySize进行大小的调整。

​ 线程堆栈:可通过-Xss调整大小,内存不足时抛出StackOverflowError(栈深不足,纵向)和OutOfMemoryError(无法建立新线程,横向)。

​ Socket缓存区:每个Socket连接都有Receive和Send两个缓存区,分别占37KB和25KB,连接多的话这部分内存也比较可观,如无法分配则可能抛出IOException:Too many open files异常。

​ JNI代码:使用JNI调用本地库,使用的内存也不在堆中。

​ 虚拟机和GC:虚拟机、GC的代码执行也要消耗一定的内存。

5.2.4 外部命令导致系统缓慢

​ 例子:一个数字校园应用系统运行在Solaris操作系统上,中间件为GlassFish服务器。系统做大并发压力测试发现请求响应时间较慢,使用mpstat发现CPU使用率高并且系统占用绝大多数的CPU资源。后来使用Dtrace发现最消耗CPU资源的是“fork”系统调用。

​ 原因是:每个用户请求的处理都需要执行一个外部shell脚本来获得系统的信息,执行这个shell脚本是通过Java的Runtime.getRuntime().exec()方法来调用的。这种调用方式在虚拟机中非常消耗资源,频繁调用创建进程开销非常可观,系统消耗会很大,不仅是CPU,内存负荷也很重。

5.2.5 服务器JVM进程崩溃

​ 例子:一个基于B/S的MIS系统正常运行一段时间后发现在运行期间频繁出现集群节点的虚拟机进程自动关闭现象。从系统日志文件可看出发生过大量的远端断开连接的异常,通过系统管理员了解到系统最近与一个OA门户做了集成,在MIS系统工作流的代办事项变化时要通过Web服务通知OA门户系统,把代办事项的变化同步到OA门户中。通过SoapUI测试了同步待办事项的Web服务,发现调用以后需要3分钟才能返回,并且返回结果都是连接中断。

​ 由于MIS系统用户多、待办事项变化快,为了不被OA系统拖累使用了异步方式调用Web服务,由于两边速度不对等,时间久了就积累了越多Web服务没有调用完成,导致等待的线程和Socket来连接越来越多,最终超过虚拟机的承受范围使其崩溃。

​ 解决方法:通知OA门户方修复无法使用的集成接口,并将以不调用改为生产者/消费者模式的消息队列后,系统恢复正常。

5.2.6 不恰当数据结构导致内存占用过大

​ 例子:一个后台RPC服务器,64位虚拟机内存配置-Xms4g -Xmx8g -Xmn1g,使用ParNew+CMS的收集器组合平时Minor GC时间30毫秒以内,能接收。但业务上需每10分钟加载一个约80MB的数据文件到内存,这些数据在内存中形成超过100万个HashMap<Long, long> Entry,在这段时间里Minor GC会造成超过500毫秒停顿。原因在于:分析数据文件期间,800MB的Eden空间很快被填满从而引发GC,但是Minor GC以后新生代绝大多数对象还存活。ParNew收集器使用复制算法,对于这种情况将它们复制到Survivor并维持这些对象的引用成为一个沉重的负担,导致GC暂停时间明显变长。

​ 如果不修改程序仅从GC调优角度去解决这个问题,可以考虑将Survivor空间去掉(通过参数),让新生代中存活的对象在第一次Minor GC后立即进入老年代,等到Major GC的时候再清理他们。这种措施治标不治本,治本的话需要修改程序,因为产生这种情况的根本原因是HashMap<Long, Long> 结构来存储数据文件空间效率太低。

5.2.7 由Windows虚拟内存导致的长时间停顿

​ 例子:带心跳检测功能的GUI桌面程序,每15秒发送一次心跳检测信号,若30秒内没收到返回信息,就认为和对方程序的连接已经断开。程序上线后发现检测有误报的概率,查询日志发现原因是程序偶尔出现间隔一分钟左右的时间完全无日志输出,处于停顿状态。

​ 从GC日志文件中确认了停顿原因是由GC导致的,偶尔会出现一次接近一分钟的GC。从日志片段中可以看出,真正执行GC动作的时间不是很长,但从准备开始GC,到真正开始GC之间所消耗的时间却占了绝大多数。[Times:user=0.61 sys=0.52,real=31.16 secs]可以看出。

​ 除GC日志之外,还观察到这个GUI程序内存变化的一个特点,当它最小化的时候资源管理显示的占用内存大幅度减小,但是虚拟内存则没有变化,因此怀疑程序最小化时工作内存被交换到了磁盘的页面文件之中,这样发生GC时就有可能因为恢复页面而导致不正常的GC停顿,可以通过参数来解决(保证程序在最小化时能立即响应)。

0%