声明:本文比较枯燥,适合对JVM有一定了解以及对JVM感兴趣的人阅读。
一、前言
实在不知道取什么名字好,取大了怕写不来,取小了怕没得写,于是随便叫了个名字。从去年开始,陆陆续续看了许多关于Java虚拟机方面的资料和书,目前感觉对JVM算是有一些了解,加上今天听了一天毕玄的讲课,顺便写篇博客,算是对自己这一年学的东西做一个总结吧。大致的可能会涉及到Java内存模型,垃圾回收,最后顺便简单提提OOM。
二、Java内存模型
Java内存大致分5个区域,PC寄存器,Java栈,堆,方法区和本地方法栈。
PC寄存器顾名思义就是指向下一条要执行的指令,相信大家都明白,不做多解释。
每一个线程对应一个Java栈,Java栈又由栈帧组成,一个方法调用对应一个栈帧,栈帧以栈的方式在Java栈中存放。栈帧里面又分局部数据区:存放方法里面的局部变量,以数组的形式存在;操作数栈,当局部变量作计算等时数值会在里面计算,计算结果在操作数栈顶存放,以栈的形式存在;还有帧数据区,保存一些数据来支持常量池解析,正常方法返回,异常等工作。在Java程序启动时可以加 -Xss 指定栈的大小,目前Sun JDK1.6 32位机默认是512k,64位机默认是1m。栈所占用的内存在哪里可以点这里查看讨论。
可以说Java里面所有的new操作都是在堆里面分配内存的。Sun JDK的堆又可分为新生代,旧生代和永久代。一般来说对象都会在新生代里面分配,当然如果要分配的对象大小超过新生代的大小等特殊情况下会直接在旧生代里分配,新生代分Eden,S0,S1三个区域,当在新生代里面的经过几次Minor GC后仍然存在或者Survivor空间(即S0,S1中的一个)不足垃圾收集器会将其移到旧生代。旧生代用来存放存活周期比较长的对象,当其空间不足时会触发Major GC(又称Full GC),GC后空间仍不足的话就会抛出OOM异常。参数 -Xms,-Xmx 分别设置堆启动内存大小和能占用的最大大小, -Xmn 设置新生代的大小,-XX:SurvivorRatio用来设置新生代里面Eden大小的Survivor大小的比例。
方法区又称永久代用来存放类的一些信息,比如类有哪些方法,类实现的接口、继承的类==,还有常量,静态变量等信息, -XX:PermSize,-XX:MaxPermSize 分别设置永久代的默认大小和最大值。
本地方法栈我很少用。
三、垃圾回收
垃圾回收主要是回收程序里面用不到,但在内存里面还存在的对象。
1、垃圾回收按种类分有串行收集、并行收集和并发收集。
串行收集就是暂停所有的应用,启用一个线程回收堆中的内存,在 client 模式(JDK在32机,64位机内存小于2G,内核小于2个时默认为 client 模式,也可通过编译参数 -client 或 -server 来指定)下默认为串行,参数 -XX:+UseSerialGC 可强制指定使用串行收集。这种收集方式在应用上没看出有什么优点,缺点很明显,浪费硬件资源。在Eden空间不足的时候会触发YGC(Young GC, Minor GC),会把Eden和From(S0或S1)区域中没有引用到的对象清除,将存活的对象复制到To区域,To区域放不了的,或者存活次数超过参数TenuringThreshold值的对象移到旧生代。当旧生代空间不足、永久代空间不足等情况下会触发FGC(Full GC, Major GC)。可通过 jstat 或者GC日志查看程序垃圾回收情况,串行收集是其他收集方式的理论基础,其他方式收集过程以及触发机制大致跟串行类似。
Server模式下并行收集默认为YGC:PS,FGC:Parallel MSC,也可以通过参数 -XX: +UseParallelGC 或 -XX:+UseOldParallelGC强制指定。JDK启动多线程并行的进行垃圾回收,因此优点在于回收比较高效,缺点表现在堆内存增大后,造成应用的暂停时间变长。
用参数-XX:+Use ConcMarkSweepGC可指定使用并发收集(CMS),并发收集启用多线程来收集垃圾,并且采用和并行收集不同的算法使得对旧生代回收时对应用造成的暂停时间特别短,适合对延迟要求比较高的应用场景,缺点也是很明显,会造成内存碎片、旧生代分配效率低、整个回收过程耗时过长、和应用程序争用CPU资源,还有一个致命的缺点,在CMS收集失败后,会使用串行MSC来回收。
至于什么情况下使用什么回收方式,一般的经验是 -XX:UseOldParallelGC 就够用了,可以持续监控运行状态,如果出现GC造成应用暂停时间过长再切换成CMS方式。
最后介绍一种未来可能会一统江湖的垃圾收集器:Garbage First(G1)。在JDK1.6 update14和JDK7里面都包含这种垃圾收集器,它的想法是让内存使用更简单,不用像现在一样有太多的参数去设置内存不同区域的大小,甚至它不是按代区别内存,而且在内存越来越大的情况下,改善目前垃圾回收时间过长的情况。按照构想,以后你要设置的内存参数只有四个:-Xms, -Xmm, -XX:MaxGCPauseMillis, -XX:GCPauseIntervalMillis,后两个参数的含义是在多少毫秒内GC引起的应用暂停占多少毫秒。构想是很伟大的,但目前G1的表现确是相当的惨不忍睹,不过相信G1能够成功。
前面提到的垃圾回收会引起应用的暂停,可能会有人对在系统运行的时候如何精准的暂停应用会感兴趣。Sun JDK用了一个比较巧妙的办法解决这个问题,它在编译的时候,引用切换的地方(比如A a = new B())生成一个safepoint,运行的时候safepoint会检查内存是否可读,如果不可读则抛出异常,应用挂起,JVM需要做的就是将内存设置为只读就可以了。
前面还有一个关键点没有提到,就是悲观策略问题(要注意悲观策略在官方文档上面是没有任何涉及的)。垃圾收集器会在每次新生代竞升到旧生代时记下本次有多大的对象竞升,然后记下这个平均值,在串行回收的YGC前,并行回收的YGC前和后都会检查旧生代剩余的大小是否大于这个平均值,如果答案是否的话会触发Full GC。
2、按算法分主要有标记-清除法、标记-整理法和复制法,还有理论上的一些算法:比如火车算法,计数法等。
标记-清除分两部分,先从根对象出发,遍历堆上的对象并标记,第二阶段清除堆里面未被标记的对象。这种方法简单高效,当然缺点也很明显,比如分配顺序为A--B--C--D,现在B和C不再存活被清除了,现在有请求分配E,但是D后面的内存不够E使用,A和D中间的内存也不够E使用,遇到这样的情况JVM则给E分配内存失败,实际上有可能A和D中间+D后面的内存是大于E的请求大小的,也就是会引起内存碎片的问题。
标记-整理很好的解决了上面的问题,在标记后把所有存活对象都向一端移动,然后直接清理掉端边界以外的内存,这样则将不存在内存碎片的问题,由于需要移动内存块,则增加了回收时所耗费的时间。
复制法将内存区域分为相同大小的两块:From,To(JVM新生代内存结构借鉴了这点)。从根对象开始在From块里面遍历并标记,然后将存活的块移动到To块并清除From块,也避免了内存碎片等问题,但缺点是比较浪费内存,有一半的内存经常处于闲置状态。
最后解释下上面提到的根对象,Sun JDK认为以下对象为根对象:当前运行线程的栈上引用的对象;常量及静态变量;本地方法handles;JVM handles。
四、OOM(OutOfMemoryError)
OOM主要有如下几种情况:
GC overhead limit exceeded
Java head Space
Unable to create new native thread
PermGen space
Out of swap space
如何解决OOM?可以用jmap等工具,Linux下输入 jmap -histo pid 可以查看运行的Java程序中占用内存大小的类型排名情况,运气好的话在排名前几个类型里面可以找到内存溢出的原因。比较标准的排查流程可以在程序启动参数中加上 -XX:+HeapDumpOnOutOfMemoryError 可以在出现OOM时把内存情况输出到日志文件,然后用 Eclipse mat 分析内存使用情况,根据内存使用情况猜测问题可能出现在哪里,然后编写btrace脚本运行找到这些问题地方的代码,可以参见这里,这个流程基本可以解决绝大部分OOM的问题,当然这个流程需要丰富的经验来准确猜测问题所在。
我们在写代码的时候注意以下几点也可以避开绝大多数产生OOM的可能(这里参考了毕玄的课件):
慎用ThreadLocal;
限制Collection/StringBuilder等的大小;
限制提交请求的大小,尤其是批量处理;
限制数据库返回数据的大小;
避免死循环。
五、总结
很多人会思考为什么要了解JVM,有些人认为了解JVM主要是为了性能调优,比如调整JDK启动参数分配不同的堆大小、使用不同的垃圾回收算法==。但是实际上或者说很多情况下参数的调整不会起到立竿见影的效果,一般都差别不大,有时候系统反而会变得更慢……我认为学习JVM是为了让我们更明白Java代码在执行的时候到底做了些什么,我们为什么要这样写,不要为了调优而调优,编写高质量的代码仍然是王道。