扫一扫
分享文章到微信
扫一扫
关注官方公众号
至顶头条
Java EE(Java企业开发平台)应用程序,无论应用程序服务器如何部署,所面对的一系列问题大致相同。作为一个JAVAEE问题解决专家,我曾经面对过众多的环境同时也写了不少常见问题的观察报告。在这方面,我觉得我很象一个汽车修理工人:你告诉修理工人发动机有声音,他就会询问你一系列的问题,帮你回忆发动机运行的情形。从这些信息中,他寻找到可能引起问题的原因。
众多解决问题的方法思路基本相同,第一天我同要解决问题的客户接触,接触的时候,我会寻找已经出现的问题以及造成的负面的影响。了解应用程序的体系结构和问题表现出的症状,这些工作很够很大程度上提高我解决问题的几率。在这一节,我分享我在这个领域遇过的常见问题和他们的症状。希望这篇文章能成为你JAVAEE的故障检测手册。
内存溢出错误
最常见的折磨着企业级应用程序的错误是让人恐惧的outofmemoryError(内存溢出错误)
这个错误引起下面这些典型的症状:
不管症状是什么,如果你想让程序恢复正常运行,你一般都需要重新启动应用服务器。
引发out-of-memory 错误的原因
在你打算解决out-of-memory 错误之前,首先了解为什么会引发这个错误对你有很大的帮助。如果JVM里运行的程序, 它的内存堆和持久存储区域的都满了,这个时候程序还想创建对象实例的话,垃圾收集器就会启动,试图释放足够的内存来创建这个对象。这个时候如果垃圾收集器没有能力释放出足够的内存,它就会抛出OutOfMemoryError内存溢出错误。
Out-of-memory错误一般是JAVA内存泄漏引起的。回忆上面所讨论的内容,内存泄漏的原因是一个对象虽然不被使用了,但是依然还有对象引用他。当一个对象不再被使用时,但是依然有一个或多个对象引用这个对象,因此垃圾收集器就不会释放它所占据的内存。这块内存就被占用了,堆中也就少了块可用的空间。在WEB REQUESTS中这种类型的的内存泄漏很典型,一两个内存对象的泄漏可能不会导致程序服务器的崩溃,但是10000或者20000个就可能会导致这个恶果。而且,大多数这些泄漏的对象并不是象DOUBLE或者INTEGER这样的简单对象,而可能是存在于堆中一系列相关的对象。例如,你可能在不经意间引用了一个Person对象,但是这个对象包含一个Profile对象,此对象还包含了许多拥有一系列数据的PerformanceReview对象。这样不只是丢失了那个Person对象所占据的100 bytes的内存,你丢失了这一系列相关对象所占据的内存空间,可能是高达500KB甚至更多。
为了寻找这个问题的真正根源,你需要判断是内存泄漏还是以OutOfMemoryError形式出现的其他一些故障。我使用以下2种方法来判断:
不同JVM(JAVA虚拟机)的调整程序的运作方式是不相同的,例如SUN和IBM的JVM,但都有相同的的地方。
SUN JVM的内存管理方式
SUN的JVM是类似人类家族,也就是在一个地方创建对象,在它长期占据空间之前给它多次死亡的机会。
SUN JVM会划分为:
图1 解释了SUN 堆的家族和空间的详细分类
对象在EDEN出生就是被创建,当EDEN满了的时候,垃圾收集器就把所有在EDEN中的对象扫描一次,把所有有效的对象拷贝到第一个幸存者空间,同时把无效的对象所占用的空间释放。当EDEN再次变满了的时候,就启动移动程序把EDEN中有效的对象拷贝到第二个幸存者空间,同时,也将第一个幸存者空间中的有效对象拷贝到第二个幸存者空间。如果填充到第二个生存者空间中的有效对象被第一个生存者空间或EDEN中的对象引用,那么这些对象就是长期存在的(也就是说,他们被拷贝到老一代)。若垃圾收集器依据这种小幅度的调整收集(minor collection)不能找出足够的空间,就是象这样的拷贝收集(copy collection),就运行大幅度的收集,就是让所有的东西停止(stop-the-world collection)。运行这个大幅度的调整收集时,垃圾收集器就停止所有在堆中运行的线程并执行清除动作(mark-and-sweep collection),把新一代空间释放空并准备重启程序。
图2和图3展示的是了小幅度收集如何运行
图2。对象在EDEN被创建一直到这个空间变满。
图3。处理的顺序十分重要:垃圾收集器首先扫描EDEN和生存者空间,这就保证了占据空间的对象有足够的机会证明自己是有效的。
图4展示了一个小幅度调整是如何运行的
图4:当垃圾收集器释放所有的无效的对象并把有效的对象移动到一个更紧凑整齐的新空间,它将EDEN和生存者空间清空。
以上就是SUN实现的垃圾收集器机制,你可以看出在老一代中的对象会被大幅度调整器收集清除。长生命周期的对象的清除花费的代价很高,因此如果你希望生命周期短的对象在占据空间前及时的死亡,就需要一个主垃圾收集器去回收他们的内存。
上面所讲解的东西是为了更好的帮助我们识别出内存泄漏。当JAVA中的一个对象包含了一个并不想要的一个指向其他对象的引用的时候,内存就会泄漏,这个引用阻止了垃圾收集器去回收它所占据的内存。采用这种机制的SUN 虚拟机,对象不会被丢弃,而是利用自己特有的方法把他们从乐园和幸存者空间移动到老一代地区。因此,在一个基于多用户的WEB环境,如果许多请求造成了泄漏,你就会发现老一代的增长。
图5显示了那些潜在可能造成泄漏的对象:主收集器收集后遗留下来占据空间的对象会越来越多。不是所有的占据空间的对象都造成内存泄漏,但是造成内存泄漏的对象最终都占据者空间。如果内存泄漏的确存在,这些造成泄漏的对象就会不断的占据空间,直至造成内存溢出。
因此,我们需要去跟踪垃圾收集器在处理老一代中的运行:每次垃圾收集器大幅度收集运行时,有多少内存被释放?老一代内容是不是按一定的原理来增长?
图5。阴影表示在经过大幅度的收集后幸存下来的对象,这些对象是潜在可能引发内存泄漏的对象
一部分这些相关的信息是可以通过跟踪API得到,更详细的信息通过详细的垃圾收集器的日志也可以看到。和所有的跟踪技术一样,日值记录详细的程度影响着JVM的性能,你想得到的信息越详细,付出的代价也就越高。为了能够判断内存是否泄漏,我使用了能够显示辈分之间所有的不同的较权威的技术来显示他们的区别,并以此来得到结果。SUN 的日志报告提供的信息比这个详细的程度超过5%,我的很多客户都一直使用那些设置来保证他们管理和调整垃圾收集器。下面的这个设置能够给你提供足够的分析数据:
–verbose:gc –xloggc:gc.log –XX:+PrintGCDetails –XX:+PrintGCTimeStamps
明确发现在整个堆中存在有潜在可能泄漏内存的情况,用老一代增长的速率才比较有说服力。切记调查不能决定些什么:为了能够最终确定你内存泄漏,你需要离线在内存模拟器中运行你的应用程序。
IBM JVM内存管理模式
IBM的JVM的机制有一点不同。它不是运行在一个巨大的继承HEAP中,它仅在一个单一的地区维护了所有的对象同时随着堆的增长来释放内存。这个堆是这样运行的:在一开始运行的时候,它会很小,随着对象实例不断的填充,在需要执行垃圾收集的地方清除掉无效的对象同时把所有有效的对象紧凑的放置到堆的底部。因此你可能猜测到了,如果想寻找可能发生的内存泄漏应该观察堆中所有的动作,堆的使用率是在提高?
如何分析内存泄漏
内存泄漏非常难确定,如果你能够确定是请求导致的,那你的工作就非常简单了。把你的程序放入到运行环境中,并在内存模拟器中运行,按下面的步骤来:
这个时候,你需要去和开发者交流,告诉他你所碰到的棘手的请求,他们可以判断究竟是对象泄漏还是为了某个目的特地让对象保留下来的。如果执行完后并没有发现内存泄漏的情况,我一般会转到步骤4再进行多次类似的跟踪。比如,我可能会将我的请求反复运行17次,希望我的泄漏分析能够得到17个情况(或更多)。这个方法不一定总有用,但如果是因为请求引起的对象泄漏的话,就会有很大的帮助。
如果你无法明确的判断泄漏是因为请求引发的,你有2个选择:
第一个选项在小应用程序中是确实可用的或者你非常走运的解决了问题,但对大型应用程序不太有用。如果你有跟踪工具的话第二个选择是比较有用的。这些工具利用字节流工具跟踪对象的创建和销毁的数量,他们可以报告特定类中的对象的数量状态,例如把Collections类作为特定的请求。例如,一个跟踪工具可以跟踪/action/login.do请求,并在它完成后将其中的100个对象放入HASHMAP中。这个报告并不能告诉你造成泄漏的是代码还是某个对象,而是告诉你在内存模拟器中应该留意那些类型的请求。把程序服务器放到产品环境中并不会使他们变敏感,而是跟踪性能的工具可以使你的工作变的更简单化。
虚假内存泄漏
少数的一些问题看起来是内存泄漏实际上并非如此。
我将这些情况称为假泄漏,表现在下面几种情况:
这章节对这些假泄漏都进行了调查,描述了如何去判断这些情况以及如何处理.
不要过早分析
为了在寻找内存泄漏的时候尽量减少出现判断错误的可能性,你应当在适当的时候分析堆。危险是:一些生命周期长的对象需要装载到堆中,因此在堆达到稳定状态且包含了核心对象之前具有很大的欺骗性。在分析堆之前,应该让应用程序达到稳定状态。
为了判断是否过早的对堆进行分析,持续2个小时对跟踪到的分析快照进行分析,看堆的使用率是上升还是下降。如果是下降,保存这个时候的内存记录。如果是上升,这个时候就需要分析内存中的SESSION了。
发生泄漏的session
WEB请求经常导致内存泄漏,在一个WEB请求中,对象会被限制存储在有限的几个区域。这些区域就是:
当实现一些JSP(JAVASERVER页面)时,在页面上声明的变量在页面结束的时候就被释放,这些变量仅仅在这个单独的页面存在时存在。WEB服务器会向应用程序服务器传送一系列参数和属性,也就是在SERVLET和JSP之间传输HttpServletRequest中的对象。你的动态页面依靠HttpServletRequest在不同的组件之间传输信息,但当请求完成或者socket结束的时候,SERVLET控制器会释放所有在HttpServletRequest 中的对象。这些对象仅在他们的请求的生命周期内存在。
HTTP是无状态的,这意味着客户向服务器发送一个请求,服务器回应这个请求,这个传递就完成了,就是会话结束了。我们应该感激WEB页面帮我们做的日志,这样我们就能向购物车放置东西,并去检查它,服务器能够定义一个跨越多请求的扩展对话。属性和参数被放在各自用户的HttpSession对象中,并通过它让程序的SERVLET和JSP交流。利用这种办法,页面存储你的信息并把他们添加到HttpSession中,因此你可以用购物车购买东西,并检查商品和使用信用卡付帐。作为一个无状态的协议,它总是客户端发起连接请求,服务器需要知道一个会话存在多长时间,到时候就应该释放这个用户的数据。超过这个会话的最长时间就是会话超时,他们在程序服务器中设置。除非明确的要求释放对象或者这个会话失效,否则在会话超时之前会话中的对象会一直存在。
正如session是为每个用户管理对象一样,ServletContext为整个程序管理对象。ServletContext的有效范围是整个程序,因此你可以利用Servlet中的ServletContext或者JSP应用程序对象在所有的Servlet和JSP之间让在这个程序中的所有用户共享数据。ServletContext是最主要的存放程序配置信息和缓存程序数据的地方,例如JNDI的信息。
如果数据不是存储这个四个地方(页面范围,请求范围,会话范围,程序范围)那就可能存储在下面的对象中:
每个类的静态变量被JVM(JAVA虚拟机)所控制,他们存在与否和类是否已经被初始化无关。一个类的所有实例共用一个存储静态变量的地方,因此在任何一个实例中修改静态变量会影响这个类的其他实例。因此,如果一个程序在静态变量中存放了一个对象,如果这个变量生命周期没有到,那么这个对象就不会被JVM释放。这些静态对象是造成内存泄漏的主要原因。
最后,对象能够被放到内部数据类型或者长生命周期类中的成员变量中,例如SERVLET。当一个SERVLET被创建并且被装载到内存,它在内存中仅有一个实例,采用多线程去访问这个SERVLET实例。如果在INIT()方法中装载配置信息,将他存储于类变量中,那么当需要维护的时候就可以随时读出这些信息,这样所有的对象就用相同的配置。我常碰到的一个问题就是利用SERVLET类变量去存储象页面缓存这样的信息。在他们自己内部本身存贮这些缓存配置是个不错的选择,但存贮在SERVLET中是最糟糕的情况。如果你需要使用缓存,你最好使用第三方控制插件,例如 TANGOSOL的COHERENCE。
当在页面或者请求范围中利用变量存放对象的时候,在他们结束的时候这些对象会自动释放。同样,在SESSION中存放对象的时候,当程序明确说明此SESSION失效的或者会话执行超时的时候,这些对象才会自动被释放。
很多看起来象内存泄漏的情况都是上面的那些会话中的泄漏。一个造成泄漏的会话并不是泄漏了内存而是类似于泄漏,它消耗了内存,但最终这些内存都会被释放的。如果程序服务器发生内存溢出,判断是内存泄漏还是内存缺乏的最好的方法就是:停止所有向这个服务器所发的请求的对象,等待会话超时,看内存时候会被释放出来。这虽然不会一定能够达到你要的目的,但是这是最好的分段处理方法,当你装载测试器的时候,你应该先挂断你内容巨大的会话而不是先去寻找内存泄漏。
通常来说,如果你执行了一个很大的会话,你应该尽量去减少它所占用的内存空间,如果可以的话最好能重构程序,以减少session所占据的内存空间。下面2种方法可以降低大会话和内存的冲突:
一个巨大的堆会导致垃圾回收花费更多的时间,因此这不是一个好解决方法,但总比发生OutofMemoryError强。增加足够的堆空间以使它能够存储所有应该保存的有效值,也意味着你必须有足够的内存去存储所有访问你站点的用户的有效会话。如果商业规则允许的话最好能缩短会话超时的时间,以减少堆占用空间的冲突。
总结下,你应该依据合理性和重要性按下面的步骤依次去执行:
无论如何,不要让程序范围级的变量,静态变量,长生命周期的类存储对象,事实上,你需要在内存模拟器中去分析泄漏。
异常的持久空间
容易误解JVM为持久空间分配内存的目的。堆仅仅存储类的实例,但JVM在堆中创建类实例之前,它必须把字节流文件(.class文件)装载到程序内存中。它利用内存中的字节流在堆中创建类的实例。JVM利用程序的内存来装载字节流文件,这个内存空间称为持久空间。图6显示了持久空间和堆的关系:它存在于JVM程序中,并不是堆的一部分。
Figure 6. The relationship between the permanent space and the heap
通常,你可能想让你的持久空间足够大以便于它能够装载你程序所有的类,因为很明显,从文件系统中读取类文件比从内存中装载代价高很多。JVM提供了一个参数让你不的程序不卸载已经装载到持久空间中的类文件:
–noclassgc
这个参数选项告诉JVM不要跑到持久空间去执行垃圾收集释放其中已经装载的类文件。这个参数选项很聪明,但是会引起一个问题:当持久空间满了以后依然需要装载新文件的时候JVM会怎么处理呢?我观测到的资料说明:如果JVM检测到持久空间还需要内存,就会调用主垃圾收集程序。垃圾收集器清除堆,但它并不会对持久空间进行任何操作,因此它的努力是白费的。于是JVM就再重新检测持久空间,看它是否满,然后再次执行程序,一遍的一遍重复。
我第一次碰到这种问题的时候,用户抱怨说程序性能很差劲,并且在运行了几次后就出现了问题,可能是内存溢出问题。在我调查了详细的关于堆和程序内存利用的收集器的记录后,我迅速发觉堆的状态非常正常,但程序确发生了内存溢出。这个用户维持了数千的JSP页面,在装载到内存前把他们都编译成了字节流文件放入持久空间。他的环境已经造成了持久空间溢出,但是在堆中由于用了 -noclassgc 选项,于是JVM并不去释放类文件来装载新的类文件。于是就导致了内存溢出错误,我把他的持久空间改为512M大小,并去掉了 -noclassgc 参数。
正像图7显示的,当持久空间变满了的时候,就引发垃圾收集,清理了乐园和幸存者空间,但是并不释放持久空间中的一点内存。
Figure 7. Garbage collection behavior when the permanent space becomes full. Click on thumbnail to view full-sized image.
注意
当设置持久空间大小时候,一般考虑128M,除非你的程序有很多的类文件,这个时候,你就可以考虑使用256M大小。如果你想让他能够装载所有的类的时候,就会导致一个典型的结构错误。设置成512M就足够了,它仅仅是暂时的时间的花费。把持久空间设置成512M大小就象给一个脚痛的人吃止痛药,虽然暂时缓解了痛,但是脚还是没有好,依然需要医生把痛治疗好,否则只是把问题延迟了而已。
线程池
外界同WEB或程序服务器连接的主要方法就是向他们发送请求,这些请求被放置到程序的执行次序队列中。和内存最大的冲突就是程序服务器所设置的线程池的大小。线程池的大小就是程序可以同时处理的请求的数量。如果池太小,请求就需要在队列中等待程序处理,如果太大,CPU就需要花费太多的时间在这些众多的线程之间来回的切换。
每个服务器都有一个SOCKET负责监听。程序把接受到的请求放到待执行队列中,然后将这个请求从队列移动到线程中被程序处理。
图8显示了服务器的处理程序。
Figure 8. 服务器处理请求的次序结构
线程池太小
每当我碰到有人抱怨装载速度的性能随着装载的数量的增加变的越来越糟糕的时候,我会首先检查线程池。特别是,我在看到下面这些信息的时候:
当一个线程池被待处理的请求装满的时候,响应的时间就变的极其糟糕,因为这些在队列中等待处理的请求会消耗很多的额外时间。这个时候,CPU的利用率会非常低,因为程序服务器没有时间去指挥CPU工作。这个时候,我会按一定幅度增加调节池的大小,并在未处理请求的数量减少前一直监视程序的吞吐量,你需要一个合理甚至更好的负载量者,一个精确的负载量测试工具可以准确的帮你测试出结果。当你观测吞吐量的时候,如果你发现吞吐量降低了,你就应该把池的大小下调一个幅度,一直到找到让它保持最大吞吐量的大小为止。
图9显示了连接池太小的情况
Figure 9. 所有的线程都被占用了,请求就只能在队列中等待
每当我阅读性能调整手册的时候,最让我头疼的就是他们从来不告诉你特殊情况下线程池应该是多大。由于这些值非常依赖程序的行为,他们只告诉你大普通情况下正确的大小,但是他们给了你一个范围内的值,这对用户很有利的。例如考虑下面2种情况::
但是,很多程序并没有在这种情况下动态的去调整的功能。多数情况下是做相同的事,但是应该为他们划分范围。因此,我建议你为一个CPU分配50到75个左右的线程。对一些程序来说,这个数量可能太少,对另一个些来说可能太多,我刚开始为每个CPU分配50到75个线程,然后根据吞吐量和CPU的性能,并做适当的调整。
线程池太大
除了线程池数量太小之外的情况外,环境也可能把线程数量配置的过大。当这些环境中的负载量不断增大的时候,CPU的使用率会持续无法降低,就没有什么响应请求的时间了,因为CPU只顾的在众多的线程之间来回的切换跳动,没时间让线程去做他们应该做的事了。
连接池过大的最主要的迹象就是CPU的使用率一直很高。有些时候,垃圾收集也可能导致CPU使用率很高,但是垃圾收集导致的CPU使用率很高和池过大导致的使用率有一个主要的区别就是:垃圾收集引起的只是短时间的高使用率就象个钉子,而池过大导致的就是一直持续很高呈线性。
这个情况发生的时候,请求会被放在队列中不被处理,但是不会始终如此,因为请求占用CPU的情况和程序占用的情况造成的后果不同。降低线程池的大小可能会让请求等待,但是让请求等待总比为了处理请求而让CPU忙不过来的好。让CPU保持持续的高使用率,同时性能不降低,新请求到来的时候放入到队列中,这是最理想的程序。考虑下面这个很类似的情况:很多高速公里有交通灯来保证车辆进入到拥挤的公里中。在我看来,这些交通灯根本没用,道理很充分。比如你来了,在交通灯后面的安全线上等待进入到高速公路上。如果所有的车辆都同时涌向公里,我们就动弹不得,但是只要减缓涌向高速公路车辆的速度,交通迟早会畅通。事实上,很多的大城市都有这样功能,
设置一个饱和的池,然后逐步减少连接池大小,一直到CPU占用率为75%到85%之间,同时用户负载正常。如果等待队列大小实在无法控制,考虑下面2中建议:
如果你的用户负载超过了环境能承受的范围,你应该考虑修正代码减少和CPU的冲突或者增加CPU。
JDBC连接池
很多JAVA EE 程序连接到一个后台数据源,大多数是通过JDBC(JAVA DATABASE CONNECTIVITY)将程序和后台连接起来。由于创建数据库连接的代价很高,程序服务器让在同一个程序服务器实例下的所有程序共享特定数量的一些连接。如果一个请求需要连接到数据库,但是数据库的连接池无法为这个请求创建一个新连接,这个时候请求就会停下来等待连接池完成自己的操作再给她分配一个连接。反过来,如果数据库连接池太大程序服务器就会浪费资源,并且程序有可能强迫数据库承受过量的负荷。我们调试的目的就是尽量减少请求的等待时间和饱和的资源之间之间的冲突,让一个请求在数据库外等待要比强迫数据库好的多。
一个程序服务器如果设置连接的数量不合理就会有下面这些特征:
JDBC prepared statements
和JDBC相关的另一个重要的设置就是:为JDBC使用的statement 所预设的缓存的大小。当你的程序在数据库中运行SQL statement 的时候三下面3个步骤进行:
在准备阶段,数据库驱动器让数据库完成队列中的执行计划。执行的时候,数据库执行语句并返回指向结果的引用。在返回的时候,程序重新描述这些结果并描述出这些被请求的信息。
数据库驱动会这样优化程序:首先,你需要去准备一个statement ,这个statement 它会让数据库做好执行和缓存结果的准备。在此同时,数据库驱动会从缓存中装载已经准备好的statement ,而不用直接连接到数据库。
如果prepared statement 设置太小,数据库驱动器会被迫去查询没有装载进缓存区的statement ,这就会增加额外的连接到数据库的时间。prepared statement 缓存区设置不恰当最主要的症状就是花费大量的时间去连接相同的statement。这段被浪费的时间本来是为了让它去装载后面的调用的。
事情变的稍微复杂了点,缓存prepared statement 是每个statement的基础,就是说在一个statement连接之前都应当缓存起来。这个增加的复杂性就产生了一个冲突:如果你有100个prepared statement需要去缓存,但你的连接池中有50个数据库连接,这个时候你就需要有存放5000条预备语句的内存。
通过跟踪性能,确定出你程序所执行的不重复的statement 的数量,并从这些statement 中找出哪些条是频繁执行的。
Entity bean(实体BEAN)和stateful session bean的缓冲
无状态(stateless)对象可以被放入到池中共享,但象Entity beans和 stateful session bean这样的有状态的对象就需要被缓存,因为这些bean的每个实例都是不相同的。当你需要一个有状态对象时,你需要明确创建这个对象的特定实例,普通的实例是不能满足的。类似的,你考虑一个超市类似的情况,你需要个售货员但他叫什么并不重要,任何售货员都可以满足你。也就是,售货员被放入池中共享,因为你只需要是售货员就可以,而不是一个叫做史缔夫的这个售货员。当你离开超市的时候,你需要带上你的孩子,不是其他人的孩子,而是你自己的。这个时候,孩子就需要被缓存。
Figure 10. The application requests an object from the cache that is in the cache, so a reference to that object is returned without making a network trip to the database
如果您非常迫切的想了解IT领域最新产品与技术信息,那么订阅至顶网技术邮件将是您的最佳途径之一。
现场直击|2021世界人工智能大会
直击5G创新地带,就在2021MWC上海
5G已至 转型当时——服务提供商如何把握转型的绝佳时机
寻找自己的Flag
华为开发者大会2020(Cloud)- 科技行者