JVM OOM如何java oom排查思路?

1、JVM内存结构JVM 的运行时数据区主要包括:堆、栈、方法区、程序计数器等1.1、程序计数器(PC寄存器)程序计数器(Program Counter Register)是一块较小的内存空间,可以看作是当前线程所执行字节码的行号指示器,指向下一个将要执行的指令代码,由执行引擎来读取下一条指令。更确切的说,一个线程的执行,是通过字节码解释器改变当前线程的计数器的值,来获取下一条需要执行的字节码指令,从而确保线程的正确执行。为了确保线程切换后(上下文切换)能恢复到正确的执行位置,每个线程都有一个独立的程序计数器,各个线程的计数器互不影响,独立存储。也就是说程序计数器是线程私有的内存。如果线程执行 Java 方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果执行的是 Native 方法,计数器值为Undefined。PS:唯一不会发生OOM的区域1.2、栈JVM 中的栈包括 Java 虚拟机栈和本地方法栈,两者的区别就是,Java 虚拟机栈为 JVM 执行 Java 方法服务,本地方法栈则为 JVM 使用到的 Native 方法服务。定义: 限定仅在表头进行插入和删除操作的线性表。即压栈(入栈)和弹栈(出栈)都是对栈顶元素进行操作的。所以栈是后进先出的。栈是线程私有的,他的生命周期与线程相同。每个线程都会分配一个栈的空间,即每个线程拥有独立的栈空间。栈中存储的是 栈帧。栈帧是栈的元素。每个方法在执行时都会创建一个栈帧。栈帧中存储了局部变量表、操作数栈、动态连接和方法出口等信息。每个方法从调用到运行结束的过程,就对应着一个栈帧在栈中压栈到出栈的过程。如果线程请求的栈深度大于虚拟机所允许的深度, 将抛出StackOverflowError异常;只要线程申请栈空间成功了就不会有OOM, 但是如果申请时就失败, 仍然是会出现OOM异常的。1.2.1、局部变量表栈帧中,由一个局部变量表存储数据。局部变量表中存储了基本数据类型(boolean、byte、char、short、int、float、long、double)的局部变量(包括参数)、和对象的引用(String、数组、对象等),但是不存储对象的内容。局部变量表所需的内存空间在编译期间完成分配,在方法运行期间不会改变局部变量表的大小。局部变量的容量以变量槽(Variable Slot)为最小单位,每个变量槽最大存储32位的数据类型。对于64位的数据类型(long、double),JVM 会为其分配两个连续的变量槽来存储。以下简称 Slot 。JVM 通过索引定位的方式使用局部变量表,索引的范围从0开始至局部变量表中最大的 Slot 数量。普通方法与 static 方法在第 0 个槽位的存储有所不同。非 static 方法的第 0 个槽位存储方法所属对象实例的引用。Slot复用: 为了尽可能的节省栈帧空间,局部变量表中的 Slot 是可以复用的。方法中定义的局部变量,其作用域不一定会覆盖整个方法。当方法运行时,如果已经超出了某个变量的作用域,即变量失效了,那这个变量对应的 Slot 就可以交给其他变量使用,也就是所谓的 Slot 复用。Slot 复用虽然节省了栈帧空间,但是会伴随一些额外的副作用。比如,Slot 的复用会直接影响到系统的垃圾收集行为。(Slot复用时 无别的变量使用的话 垃圾回收则无法回收该空间)1.2.2、操作数栈操作数栈是一个后进先出栈。操作数栈的元素可以是任意的Java数据类型。方法刚开始执行时,操作数栈是空的,在方法执行过程中,通过字节码指令对操作数栈进行压栈和出栈的操作。通常进行算数运算的时候是通过操作数栈来进行的,又或者是在调用其他方法的时候通过操作数栈进行参数传递。操作数栈可以理解为栈帧中用于计算的临时数据存储区。栈中可能出现哪些异常?StackOverflowError:栈溢出错误如果一个线程在计算时所需要用到栈大小 > 配置允许最大的栈大小,那么Java虚拟机将抛出 StackOverflowErrorOutOfMemoryError:内存不足栈进行动态扩展时如果无法申请到足够内存,会抛出 OutOfMemoryError 异常。使用 -Xss 设置栈大小,通常几百K就够用了。由于栈是线程私有的,线程数越多,占用栈空间越大。栈决定了函数调用的深度。这也是慎用递归调用的原因。递归调用时,每次调用方法都会创建栈帧并压栈。当调用一定次数之后,所需栈的大小已经超过了虚拟机运行配置的最大栈参数,就会抛出 StackOverflowError 异常。1.3、堆堆是Java虚拟机所管理的内存中最大的一块存储区域。堆内存被所有线程共享。主要存放使用new关键字创建的对象。所有对象实例以及数组都要在堆上分配。垃圾收集器就是根据GC算法,收集堆上对象所占用的内存空间(收集的是对象占用的空间而不是对象本身)。Java堆分为年轻代(Young Generation)和老年代(Old Generation);年轻代又分为伊甸园(Eden)和幸存区(Survivor区);幸存区又分为From Survivor空间和 To Survivor空间。年轻代存储“新生对象”,我们新创建的对象存储在年轻代中。当年轻内存占满后,会触发Minor GC,清理年轻代内存空间。老年代存储长期存活的对象和大对象。年轻代中存储的对象,经过多次GC后仍然存活的对象会移动到老年代中进行存储。老年代空间占满后,会触发Full GC。PS:堆上空间都是线程共享的吗?答案是否定的:TLAB,如果从分配内存的角度看, 所有线程共享的Java堆中可以划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer, TLAB) , 以提升对象分配时的效率。 不过无论从什么角度, 无论如何划分, 都不会改变Java堆中存储内容的共性, 无论是哪个区域, 存储的都只能是对象的实例, 将Java堆细分的目的只是为了更好地回收内存, 或者更快地分配内存。1.3.1、方法区方法区(Method Area) 与Java堆一样, 是各个线程共享的内存区域, 它用于存储已被虚拟机加载的类型信息、 常量、 静态变量、 即时编译器编译后的代码缓存等数据。 虽然《Java虚拟机规范》 中把方法区描述为堆的一个逻辑部分, 但是它却有一个别名叫作“非堆”(Non-Heap) , 目的是与Java堆区分开来。1.3.2、运行时常量池运行时常量池(Runtime Constant Pool) 是方法区的一部分。 Class文件中除了有类的版本、 字段、 方法、 接口等描述信息外, 还有一项信息是常量池表(Constant Pool Table) , 用于存放编译期生成的各种字面量与符号引用, 这部分内容将在类加载后存放到方法区的运行时常量池中。1.3.3、动态连接每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用, 持有这个引用是为了支持方法调用过程中的动态连接(Dynamic Linking) 。1.3.4、方法返回值当一个方法开始执行后, 只有两种方式退出这个方法。执行引擎遇到任意一个方法返回的字节码指令, 这时候可能会有返回值传递给上层的方法调用者(调用当前方法的方法称为调用者或者主调方法) , 方法是否有返回值以及返回值的类型将根据遇到何种方法返回指令来决定, 这种退出方法的方式称为“正常调用完成”(Normal Method Invocation Completion) 。方法执行的过程中遇到了异常, 并且这个异常没有在方法体内得到妥善处理。 无论是Java虚拟机内部产生的异常, 还是代码中使用athrow字节码指令产生的异常, 只要在本方法的异常表中没有搜索到匹配的异常处理器, 就会导致方法退出, 这种退出方法的方式称为“异常调用完成(Abrupt Method Invocation Completion) ”。 一个方法使用异常完成出口的方式退出, 是不会给它的上层调用者提供任何返回值的。方法退出的过程实际上等同于把当前栈帧出栈, 因此退出时可能执行的操作有: 恢复上层方法的局部变量表和操作数栈, 把返回值(如果有的话) 压入调用者栈帧的操作数栈中, 调整PC计数器的值以指向方法调用指令后面的一条指令等。1.4、直接内存直接内存(Direct Memory) 并不是虚拟机运行时数据区的一部分, 也不是《Java虚拟机规范》 中定义的内存区域。 但是这部分内存也被频繁地使用, 而且也可能导致OutOfMemoryError异常出现。在JDK 1.4中新加入了NIO(New Input/Output) 类, 引入了一种基于通道(Channel) 与缓冲区(Buffer) 的I/O方式, 它可以使用Native函数库直接分配堆外内存, 然后通过一个存储在Java堆里面的DirectByteBuffer对象作为这块内存的引用进行操作。 这样能在一些场景中显著提高性能, 因为避免了在Java堆和Native堆中来回复制数据。显然, 本机直接内存的分配不会受到Java堆大小的限制, 但是, 既然是内存, 则肯定还是会受到本机总内存(包括物理内存、 SWAP分区或者分页文件) 大小以及处理器寻址空间的限制, 一般服务器管理员配置虚拟机参数时, 会根据实际内存去设置-Xmx等参数信息, 但经常忽略掉直接内存, 使得各个内存区域总和大于物理内存限制(包括物理的和操作系统级的限制) , 从而导致动态扩展时出现OutOfMemoryError异常。2、内存溢出2.1、Java堆溢出Java堆内存的OutOfMemoryError异常是实际应用中最常见的内存溢出异常情况。 出现Java堆内存溢出时, 异常堆栈信息“java.lang.OutOfMemoryError”会跟随进一步提示“Java heap space”。问题排查: 要解决这个内存区域的异常, 常规的处理方法是首先通过内存映像分析工具(如Eclipse Memory Analyzer) 对Dump出来的堆转储快照进行分析。 第一步首先应确认内存中导致OOM的对象是否是必要的, 也就是要先分清楚到底是出现了内存泄漏(Memory Leak) 还是内存溢出(Memory Overflow);如果是内存泄漏, 可进一步通过工具查看泄漏对象到GC Roots的引用链, 找到泄漏对象是通过怎样的引用路径、 与哪些GC Roots相关联, 才导致垃圾收集器无法回收它们, 根据泄漏对象的类型信息以及它到GC Roots引用链的信息, 一般可以比较准确地定位到这些对象创建的位置, 进而找出产生内存泄漏的代码的具体位置;如果不是内存泄漏, 换句话说就是内存中的对象确实都是必须存活的, 那就应当检查Java虚拟机的堆参数(-Xmx与-Xms) 设置, 与机器的内存对比, 看看是否还有向上调整的空间。 再从代码上检查是否存在某些对象生命周期过长、 持有状态时间过长、 存储结构设计不合理等情况, 尽量减少程序运行期的内存消耗。2.2、虚拟机栈和本地方法栈溢出由于HotSpot虚拟机中并不区分虚拟机栈和本地方法栈, 因此对于HotSpot来说, -Xoss参数(设置本地方法栈大小) 虽然存在, 但实际上是没有任何效果的, 栈容量只能由-Xss参数来设定;如果线程请求的栈深度大于虚拟机所允许的最大深度,将抛出StackOverflowError异常;如果虚拟机的栈内存允许动态扩展, 当扩展栈容量无法申请到足够的内存时,将抛出OutOfMemoryError异常。《Java虚拟机规范》明确允许Java虚拟机实现自行选择是否支持栈的动态扩展,而HotSpot虚拟机选择是不支持扩展,所以除非在创建线程申请内存时就因无法获得足够内存而出现OutOfMemoryError异常, 否则在线程运行时是不会因为扩展而导致内存溢出的, 只会因为栈容量无法容纳新的栈帧而导致StackOverflowError异常。不限于单线程, 通过不断建立线程的方式, 在HotSpot上也是可以产生内存溢出异常的。但是这样产生的内存溢出异常和栈空间是否足够并不存在任何直接的关系, 主要取决于操作系统本身的内存使用状态。 甚至可以说, 在这种情况下, 给每个线程的栈分配的内存越大, 反而越容易产生内存溢出异常。原因: 操作系统分配给每个进程的内存是有限制的, 譬如32位Windows的单个进程最大内存限制为2GB。 HotSpot虚拟机提供了参数可以控制Java堆和方法区这两部分的内存的最大值,那剩余的内存即为2GB(操作系统限制) 减去最大堆容量, 再减去最大方法区容量, 由于程序计数器消耗内存很小, 可以忽略掉, 如果把直接内存和虚拟机进程本身耗费的内存也去掉的话, 剩下的内存就由虚拟机栈和本地方法栈来分配了。 因此为每个线程分配到的栈内存越大, 可以建立的线程数量自然就越少, 建立线程时就越容易把剩下的内存耗尽。问题排查: 出现StackOverflowError异常时, 会有明确错误堆栈可供分析, 相对而言比较容易定位到问题所在。 如果使用HotSpot虚拟机默认参数, 栈深度在大多数情况下(因为每个方法压入栈的帧大小并不是一样的, 所以只能说大多数情况下) 到达1000~2000是完全没有问题, 对于正常的方法调用(包括不能做尾递归优化的递归调用) , 这个深度应该完全够用了。 但是, 如果是建立过多线程导致的内存溢出, 在不能减少线程数量或者更换64位虚拟机的情况下, 就只能通过减少最大堆和减少栈容量来换取更多的线程。2.3、方法区和运行时常量池溢出String::intern()是一个本地方法, 它的作用是如果字符串常量池中已经包含一个等于此String对象的字符串, 则返回代表池中这个字符串的String对象的引用; 否则, 会将此String对象包含的字符串添加到常量池中, 并且返回此String对象的引用。在JDK 6或更早之前的HotSpot虚拟机中, 常量池都是分配在永久代中, 我们可以通过-XX:PermSize和-XX:MaxPermSize限制永久代的大小, 即可间接限制其中常量池的容量。运行时常量池溢出: 运行时常量池溢出时, 在OutOfMemoryError异常后面跟随的提示信息是“PermGen space”, 说明运行时常量池的确是属于方法区(即JDK 6的HotSpot虚拟机中的永久代) 的一部分;自JDK 7起, 原本存放在永久代的字符串常量池被移至Java堆之中。方法区溢出: 方法区溢出也是一种常见的内存溢出异常, 一个类如果要被垃圾收集器回收, 要达成的条件是比较苛刻的。 在经常运行时生成大量动态类的应用场景里, 就应该特别关注这些类的回收状况。 这类场景除了之前提到的程序使用了CGLib字节码增强和动态语言外, 常见的还有: 大量JSP或动态产生JSP文件的应用(JSP第一次运行时需要编译为Java类) 、 基于OSGi的应用(即使是同一个类文件, 被不同的加载器加载也会视为不同的类) 等;在JDK 8以后, 永久代便完全退出了历史舞台, 元空间作为其替代者登场。HotSpot还是提供了一些参数作为元空间的防御措施, 主要包括:-XX:MaxMetaspaceSize:设置元空间最大值,默认是-1,即不限制,或者说只受限于本地内存大小;-XX:MetaspaceSize:指定元空间的初始空间大小, 以字节为单位, 达到该值就会触发垃圾收集进行类型卸载, 同时收集器会对该值进行调整: 如果释放了大量的空间, 就适当降低该值; 如果释放了很少的空间, 那么在不超过-XX:MaxMetaspaceSize(如果设置了的话) 的情况下, 适当提高该值;-XX:MinMetaspaceFreeRatio:作用是在垃圾收集之后控制最小的元空间剩余容量的百分比, 可减少因为元空间不足导致的垃圾收集的频率。 类似的还有-XX:MaxMetaspaceFreeRatio, 用于控制最大的元空间剩余容量的百分比。2.4、本机直接溢出直接内存(Direct Memory) 的容量大小可通过-XX:MaxDirectMemorySize参数来指定, 如果不去指定, 则默认与Java堆最大值(由-Xmx指定)一致。由直接内存导致的内存溢出, 一个明显的特征是在Heap Dump文件中不会看见有什么明显的异常情况, 如果读者发现内存溢出之后产生的Dump文件很小, 而程序中又直接或间接使用了DirectMemory(典型的间接使用就是NIO) , 那就可以考虑重点检查一下直接内存方面的原因了;

我要回帖

更多关于 java oom排查思路 的文章