深入理解java虚拟机学习笔记(三)

来源:互联网 发布:linux gdb调试core文件 编辑:程序博客网 时间:2024/06/06 19:29

第五章 调优案例分析

5.1概述

这里作者介绍几个比较有代表性的实际案例。

5.2案例分析

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

例如,一个15万PV/天左右的在线文档类型网站最近更换了硬件系统,新的硬件为4个CPU、16GB物理内存,操作系统为64位CentOS5.4,Resin作为Web服务器。整个服务器暂时没有部署别的应用,所有硬件资源都可以提供给这访问量不算太大的网站使用。管理人员为了尽量利用硬件资源选用了64位的JDK1.5,并通过-Xms和-Xmx参数将Java堆固定在12GB。使用一段时间后发现使用效果并不理想,网站经常不定期出现长时间失去响应的情况。

监控服务器运行状况后发现网站失去响应是由GC停顿造成的,虚拟机运行在Server模式,默认使用吞吐量优先收集器,回收12GB的堆,一次Full GC的停顿时间高达14秒。并且由于程序设计的关系,访问文档时要把文档从磁盘提取到内存中,导致内存中出现很多由于文档序列化产生的大对象,这些大对象很多都进入了老年代,没有在Minor GC中清理掉。这种情况即使有12GB的堆,内存很快也会被耗尽,由此导致每隔十几分钟甚至十几秒的停顿,领网站开发人员和管理人员感到很沮丧。

这里暂不讨论代码的问题,程序部署上的问题显然是过大的堆内存进行回收时导致长时间的停顿,硬件升级前使用32位系统1.5GB的堆,用户只感觉到使用网站比较缓慢,但不会出现十分明显的停顿,因此才考虑升级硬件以提升程序效能,如果重新缩小给Java堆分配的内存,那么硬件上的资源就显得很浪费。

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

使用64为JDK来使用大内存。

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

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

控制Full GC频率的关键是看应用中绝大多数对象是否符合“朝生夕死”的原则,即大多数对象的生存时间不应太长,尤其是不能有成批量的、长生存时间的大对象产生,这样才能保证老年代空间的稳定。在大多数网站形式的应用里,主要对象的生存周期都应该是请求级或者页面级的,会话级和全局级的长生命对象相对较少。只要代码写的合理,应当都能实现在超大堆中正常使用而没有Full GC,这样的话,使用超大堆内存时,网站响应速度才会有保证。除此之外,如果计划使用64位的JDK来管理大内存,还需要考虑下面可能遇到的问题:

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

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

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

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

上面的问题听起来有点吓人,所以现阶段不少管理员还是选择第二种方式:使用若干个32位虚拟机建立逻辑集群来利用硬件资源。具体做法是在一台物理机上启动多个应用服务器进程,每个服务器进程分配不同的端口,然后在前端搭建一个负载均衡器,以反向代理的方式来分配访问请求。

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

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

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

很难最高效率的利用某些资源池,比如连接池,一般都是在各个节点建立独立的连接池,这样有可能导致一些节点池满了而另外一些节点仍有较多空余,尽管可以使用集中式的JNDI,但这个有一定的复杂性并可能带来额外的性能开销。

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

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

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

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

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

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

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

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

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

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

例如,一个学校的小型项目:基于B/S的电子考试系统,为了实现客户端能实时地从服务器端接收考试数据,系统使用了逆向AJAX技术(也称为Comet或者Service Side Push),选用Comet1.1.1作为服务端推送框架,服务器是Jetty7.1.4,硬件为一个普通PC机,Corei5CPU,4GB内存,运行32位Windows操作系统。

测试期间发现服务端不定时抛出内存溢出异常,服务器不一定每次都会出现异常,但假如正式考试时崩溃一次,那估计整场电子考试都会乱套,网站管理员尝试把堆开到最大,而32位系统最多到1.6GB就基本无法再加大了,而且开大了基本没效果,抛出内存溢出异常好像更加频繁了。加入-XX:+HeapDumpOnOutOfMemoryError,居然也没有任何反应,抛出内存溢出异常时什么文件都没有产生。无奈之下只好挂着jstat并一直紧盯着屏幕,发现GC并不频繁,Eden区,Survivor区、老年代以及永久代全部都比较稳定,但就是照样不停的抛出内存溢出异常。最后,在内存溢出后从系统日志中找到异常堆栈,是DirectNIOBuffer处抛得异常。

大家知道操作系统对每个进程能管理的内存是有限制的,这台服务器使用的32位Windows平台的限制是2GB,其中划了1.6GB给Java堆,而Direct Memory内存并不算入1.6GB的堆之内,因此它最大也之内在剩余的0.4GB空间中分出一部分。在此应用中导致溢出的关键是:垃圾收集进行时,虽然虚拟机会堆Direct Memory进行回收,但是DirectMemory却不能像新生代、老年代那样,发现空间不足了就通知收集器进行垃圾回收,它只能等待老年代满了之后进行Full GC,然后顺便的帮它清理掉内存的废弃对象。否则它只能一直等到抛出内存溢出异常时,先catch掉,再往catch块里进行System.gc(),要是虚拟机还是不管(比如开了-XX:+DisableExplicitGC开关),就只能看着堆中还有许多空闲内存,自己却不得不抛出内存溢出异常了。而本例中使用的Comet1.1.1框架,正好有大量的NIO操作需要使用到Direct Memory内存。

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

DirectMemory:可通过-XX:MaxDirectMemorySize调整大小,内存不足时抛出OutOfMemoryError或者OutOfMemoryError:Direct buffer memory。

线程堆栈:可通过-Xss调整大小,内存不足时抛出StackOverFlowError(纵向无法分配即无法分配新的栈帧)或者OutOfMemoryError:unable tocreate new native thread(横向无法分配,即无法建立新的线程)。

Socket缓存区:每个Socket连接都有Receive和Send缓冲区,分别占大约37KB和25KB内存,连接多的话这块内存占用也比较可观。如果无法分配,则可能抛出IOException:Too manyopenfiles异常。

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

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

5.2.4外部命令导致系统缓慢

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

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

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

用户根据建议去掉这个Shell脚本执行的语句,改为使用Java的API去获取这些信息后,系统很快恢复了正常。

5.2.5服务器JVM进程崩溃

例如,一个基于B/S的MIS系统,硬件为两台2个CPU、8GB内存的HP系统,服务器是WebLogic9.2。正常运行一段时间后,最近发现在运行期间频繁出现集群节点的虚拟机进程自动关闭的现象,最后留下了一个hs_err_pid###.log文件后,进程就消失了,两台物理机器里的每个节点都出现过崩溃的现象。从系统日志可以看出,每个节点的虚拟机进程在崩溃前不久,都发生过大量相同的异常:“java.net.SocketException:Connection reset…”

这是一个远端断开连接的异常,通过系统管理员了解到系统最近与一个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毫秒的停顿,对于这个停顿时间就接受不了了。观察这个案例,发现平时的Minor GC时间很短,原因是新生代的绝大多数对象都是可清除的,在Minor GC之后Eden和Survivor基本上处于完全空闲的状态。而在分析数据文件期间,800MB的Eden空间很快被填满引发GC,但Minor GC之后,新生代中绝大多数对象依然是存活的。我们知道ParNew收集器使用的是复制算法,这个算法的高效是建立在大部分对象都是“朝生夕死”的特性上的,如果存活对象过多,把这些对象复制到Survivor并维持这些对象的引用的正确就称为了一个负担,因此导致GC暂停时间明显变长。

如果不修改程序,仅从GC调优的角度去解决这个问题,可以考虑将Survivor空间去掉(加入参数-XX:SurvivorRatio=65536、-XX:MaxTenuringThreshold=0或者-XX:+AlwaysTenure),让新生代存活的对象在第一次MinorGC后立即进入老年代,等到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秒以内没有信号返回,那就认为和对方程序的连接已断开。程序上线后发现心跳检测有误报的概率,查询日志发现误报的原因是程序会偶尔出现间隔约一分钟左右的时间完全无日志输出,处于停顿状态。

因为是桌面程序,所需内存并不大(-Xmx256m),所以开始并没有想到是GC导致的程序停顿,但是加入参数-XX:+PrintGCApplicationStoppedTime –XX:+PrintGCDateStamps  -Xloggc:gclog.log后,从GC日志文件中确认了停顿确实是由GC导致的,大部分GC时间都控制在100ms以内,但偶尔会出现接近一分钟的GC。

从GC日志中找到长时间停顿的具体日志信息(添加了-XX:+PrintReferenceGC参数),从日志中可以看出,真正执行GC动作的时间不是很长,但从准备开始GC,到真正开始GC之间所消耗的时间却占了绝大部分。

除GC日志之外,还观察到这个GUI程序内存变化的一个特点,当它最小化的时候,资源管理器中显示的占用内存大幅度减小,但是虚拟机内存则没有变化,因此怀疑程序在最小化时它的工作内存被自动交换到磁盘的页面文件中了,这样发生GC时就有可能因为恢复页面文件的操作而导致不正常的GC停顿。

因此,在Java的GUI程序中要避免这样的情况,还可以加入参数“-Dsun.awt.KeepWorkingSetOnMinimize=ture”来解决。这个参数在许多AWT的程序上都有应用,例如JDK自带的Visual VM,用于保证程序在恢复最小化时能够立即响应。在这个案例中加入该参数之后,问题得到解决。