概述
与其他高级语言(如 C 和C++)不太一样,在Java 中我们基本上不会显式地调用分配内存的函数,我们甚至不用关心到底哪些程序指令需要分配内存、哪些不需要分配内存。因为在Java 中,分配内存和回收内存都由 JVM自动完成了,很少会遇到像 C++程序中那样令人头疼的内存泄漏问题。
虽然 Java语言的这些特点很容易“惯坏”开发人员,使得我们不需要太关心到底程序是怎么使用内存的,使用了多少内存。但是我们最好也了解Java 是如何管理内存的,当我们真的遇到 OutOfMemoryError时不会奇怪地问,为什么 Java 也有内存泄漏。要快速地知道到底什么地方导致了 OutOfMemoryError,并能根据错误日志快速地定位出错原因。
本章首先从操作系统层面简单介绍物理内存的分配和Java 运行的内存分配之间的关系,也就是先搞明白在 Java 中使用的内存与物理内存有何区别。其次介绍 Java 如何使用从物理内存申请下来的内存,以及如何来划分它们,后面还会介绍Java 的核心技术:如何分配和回收内存。最后通过一些例子介绍如何解决OutOfMemoryError,并提供一些处理这类问题的常用手段。
物理内存与虚拟内存
所谓物理内存就是我们通常所说的 RAM(随机存储器)。在计算机中,还有一个存储单元叫寄存器,它用于存储计算单元执行指令(如浮点、整数等运算时)的中间结果。寄存器的大小决定了一次计算可使用的最大数值。
连接处理器和 RAM或者处理器和寄存器的是地址总线,这个地址总线的宽度影响了物理地址的索引范围,因为总线的宽度决定了处理器一次可以从寄存器或者内存中获取多少个bit。同时也决定了处理器最大可以寻址的地址空间,如32 位地址总线可以寻址的范围为 0x0000 0000~0xffff ffff。这个范围是232=4 294 967 296 个内存位置,每个地址会引用一个字节,所以32 位总线宽度可以有 4GB的内存空间。
通常情况下,地址总线和寄存器或者RAM 有相同的位数,因为这样更容易传输数据,但是也有不一致的情况,如x86 的 32位寄存器宽度的物理地址可能有两种大小,分别是32位物理地址和 36位物理地址,拥有 36 位物理地址的是Pentium Pro和更高型号。
除了在学校的编译原理的实践课或者要开发硬件程序的驱动程序时需要直接通过程序访问存储器外,我们大部分情况下都调用操作系统提供的接口来访问内存,在Java 中甚至不需要写和内存相关的代码。
不管是在 Windows系统还是 Linux系统下,我们要运行程序,都要向操作系统先申请内存地址。通常操作系统管理内存的申请空间是按照进程来管理的,每个进程拥有一段独立的地址空间,每个进程之间不会相互重合,操作系统也会保证每个进程只能访问自己的内存空间。这主要是从程序的安全性来考虑的,也便于操作系统来管理物理内存。
其实上面所说的进程的内存空间的独立主要是指逻辑上独立,也就是这个独立是由操作系统来保证的,但是真正的物理空间是不是只能由一个进程来使用就不一定了。因为随着程序越来越庞大和设计的多任务性,物理内存无法满足程序的需求,在这种情况下就有了虚拟内存的出现。
虚拟内存的出现使得多个进程在同时运行时可以共享物理内存,这里的共享只是空间上共享,在逻辑上它们仍然是不能相互访问的。虚拟地址不但可以让进程共享物理内存、提高内存利用率,而且还能够扩展内存的地址空间,如一个虚拟地址可能被映射到一段物理内存、文件或者其他可以寻址的存储上。一个进程在不活动的情况下,操作系统将这个物理内存中的数据移到一个磁盘文件中(也就是通常Windows 系统上的页面文件,或者Linux系统上的交换分区),而真正高效的物理内存留给正在活动的程序使用。在这种情况下,在我们重新唤醒一个很长时间没有使用的程序时,磁盘会吱吱作响,并且会有一个短暂的停顿得到印证,这时操作系统又会把磁盘上的数据重新交互到物理内存中。但是我们必须要避免这种情况的经常出现,如果操作系统频繁地交互物理内存的数据和磁盘数据,则效率将会非常低,尤其是在Linux 服务器上,我们要关注Linux 中swap 的分区的活跃度。如果swap 分区被频繁使用,系统将会非常缓慢,很可能意味着物理内存已经严重不足或者某些程序没有及时释放内存。
内核空间与用户空间
一个计算机通常有一定大小的内存空间,如使用的计算机是4GB 的地址空间,但是程序并不能完全使用这些地址空间,因为这些地址空间被划分为内核空间和用户空间。程序只能使用用户空间的内存,这里所说的使用是指程序能够申请的内存空间,并不是程序真正访问的地址空间。
内核空间主要是指操作系统运行时所使用的用于程序调度、虚拟内存的使用或者连接硬件资源等的程序逻辑。为何需要内存空间和用户空间的划分呢?很显然和前面所说的每个进程都独立使用属于自己的内存一样,为了保证操作系统的稳定性,运行在操作系统中的用户程序不能访问操作系统所使用的内存空间。这也是从安全性上考虑的,如访问硬件资源只能由操作系统来发起,用户程序不允许直接访问硬件资源。如果用户程序需要访问硬件资源,如网络连接等,可以调用操作系统提供的接口来实现,这个调用接口的过程也就是系统调用。每一次系统调用都会存在两个内存空间的切换,通常的网络传输也是一次系统调用,通过网络传输的数据先是从内核空间接收到远程主机的数据,然后再从内核空间复制到用户空间,供用户程序使用。这种从内核空间到用户空间的数据复制很费时,虽然保住了程序运行的安全性和稳定性,但是也牺牲了一部分效率。但是现在已经出现了很多其他技术能够减少这种从内核空间到用户空间的数据复制的方式,如Linux系统提供了sendfile文件传输方式。
内核空间和用户空间的大小如何分配也是一个问题,是更多地分配给用户空间供用户程序使用,还是首先保住内核有足够的空间来运行,这要平衡一下。如果是一台登录服务器,很显然,要分配更多的内核空间,因为每一个登录用户操作系统都会初始化一个用户进程,这个进程大部分都在内核空间里运行。在当前的Windows 32位操作系统中默认内核空间和用户空间的比例是 1:1(2GB的内核空间,2GB的用户空间),而在 32位 Linux系统中默认的比例是1:3(1GB的内核空间,3GB的用户空间)。
在 Java 中哪些组件需要使用内存
Java启动后也作为一个进程运行在操作系统中,那么这个进程有哪些部分需要分配内存空间呢?
Java 堆
Java堆是用于存储 Java对象的内存区域,堆的大小在 JVM 启动时就一次向操作系统申请完成,通过-Xmx 和 -Xms两个选项来控制大小,Xmx表示堆的最大大小,Xms表示初始大小。一旦分配完成,堆的大小就将固定,不能在内存不够时再向操作系统重新申请,同时当内存空闲时也不能将多余的空间交还给操作系统。
在 Java堆中内存空间的管理由 JVM 来控制,对象创建由Java 应用程序控制,但是对象所占的空间释放由管理堆内存的垃圾收集器来完成。根据垃圾收集(GC)算法的不同,内存回收的方式和时机也会不同。
线程
JVM运行实际程序的实体是线程,当然线程需要内存空间来存储一些必要的数据。每个线程创建时JVM 都会为它创建一个堆栈,堆栈的大小根据不同的 JVM 实现而不同,通常在256KB~756KB之间。
线程所占空间相比堆空间来说比较小。但是如果线程过多,线程堆栈的总内存使用量可能也非常大。当前有很多应用程序根据CPU 的核数来分配创建的线程数, 如果运行的应用程序的线程数量比可用于处理它们的处理器数量多,效率通常很低,并且可能导致比较差的性能和更高的内存占用率。
类和类加载器
在Java中的类和加载类的类加载器本身同样需要存储空间,在Sun JDK中它们也被
存储在堆中,这个区域叫做永久代(PermGen区)。
需要注意的一点是 JVM 是按需来加载类的,曾经有个疑问:JVM如果要加载一个 jar包是否把这个jar 包中的所有类都加载到内存中?显然不是的。JVM只会加载那些在你的应用程序中明确使用的类到内存中。要查看JVM 到底加载了哪些类,可以在启动参数上加上-verbose:class。
理论上使用的 Java类越多,需要占用的内存也会越多,还有一种情况是可能会重复加载同一个类。通常情况下JVM 只会加载一个类到内存一次,但是如果是自己实现的类加载器会出现重复加载的情况,如果PermGen区不能对已经失效的类做卸载,可能会导致PermGen区内存泄漏。所以需要注意 PermGen 区的内存回收问题。通常一个类能够被卸载,有如下条件需要被满足。
-
◎ 在Java 堆中没有对表示该类加载器的 java.lang.ClassLoader对象的引用。
-
◎ Java堆没有对表示类加载器加载的类的任何java.lang.Class对象的引用。
◎ 在Java 堆上该类加载器加载的任何类的所有对象都不再存活(被引用)。
需要注意的是,JVM所创建的 3个默认类加载器 Bootstrap ClassLoader、ExtClassLoader和AppClassLoader都不可能满足这些条件,因此,任何系统类(如 java.lang.String)或通过应用程序类加载器加载的任何应用程序类都不能在运行时释放。
NIO
直接 ByteBuffer对象会自动清理本机缓冲区,但这个过程只能作为Java 堆 GC的一部分来执行,因此它们不会自动响应施加在本机堆上的压力。GC仅在 Java堆被填满,以至于无法为堆分配请求提供服务时发生,或者在Java 应用程序中显示请求时发生。当前在很多 NIO 框架中都在代码中显式地调用System.gc()来释放NIO 持有的内存。但是这种方式会影响应用程序的性能,因为会增加GC 的次数,一般情况下通过设置 -XX:+DisableExplicitGC来控制System.gc()的影响,但是又会导致NIO direct memory内存泄漏问题。
JNI
JNI技术使得本机代码(如 C 语言程序)可以调用Java 方法,也就是通常所说的 nativememory。实际上Java 运行时本身也依赖于 JNI代码来实现类库功能,如文件操作、网络I/O操作或者其他系统调用。所以 JNI 也会增加Java 运行时的本机内存占用。
JVM 内存结构
前面介绍了内存的不同形态:物理内存和虚拟内存。介绍了内存的使用形式:内核空间和用户空间。接着又介绍了Java 有哪些组件需要使用内存。下面着重介绍在 JVM 中是如何使用内存的。
JVM是按照运行时数据的存储结构来划分内存结构的,JVM在运行 Java程序时,将它们划分成几种不同格式的数据,分别存储在不同的区域,这些数据统一称为运行时数据(Runtime Data)。运行时数据包括Java 程序本身的数据信息和 JVM运行 Java程序需要的
额外数据信息,如要记录当前程序指令执行的指针(又称为PC 指针)等。在 Java虚拟机规范中将 Java运行时数据划分为 6 种,分别为:
◎PC 寄存器数据;
-
◎ Java栈;
-
◎ 堆;
-
◎ 方法区;
-
◎ 本地方法区;
-
◎ 运行时常量池。
PC 寄存器
PC寄存器严格来说是一个数据结构,它用于保存当前正常执行的程序的内存地址。同时Java 程序是多线程执行的,所以不可能一直都按照线性执行下去,当有多个线程交叉执行时,被中断线程的程序当前执行到哪条的内存地址必然要保存下来,以便于它被恢复执行时再按照被中断时的指令地址继续执行下去。这很好理解,它就像一个记事员一样记录下哪个线程当前执行到哪条指令了。
但是 JVM规范只定义了 Java方法需要记录指针信息,而对于 Native 方法,并没有要求记录执行的指针地址。
Java 栈
Java栈总是和线程关联在一起,每当创建一个线程时,JVM就会为这个线程创建一个对应的 Java 栈,在这个 Java栈中又会含有多个栈帧(Frames),这些栈帧是与每个方法关联起来的,每运行一个方法就创建一个栈帧,每个栈帧会含有一些内部变量(在方法内定义的变量)、操作栈和方法返回值等信息。
每当一个方法执行完成时,这个栈帧就会弹出栈帧的元素作为这个方法的返回值,并清除这个栈帧,Java栈的栈顶的栈帧就是当前正在执行的活动栈,也就是当前正在执行的方法,PC寄存器也会指向这个地址。只有这个活动的栈帧的本地变量可以被操作栈使用,当在这个栈帧中调用另外一个方法时,与之对应的一个新的栈帧又被创建,这个新创建的栈帧又被放到Java 栈的顶部,变为当前的活动栈帧。同样现在只有这个栈帧的本地变量才能被使用,当在这个栈帧中所有指令执行完成时这个栈帧移出Java 栈,刚才的那个栈帧又变为活动栈帧,前面的栈帧的返回值又变为这个栈帧的操作栈中的一个操作数。如果前面的栈帧没有返回值,那么当前的栈帧的操作栈的操作数没有变化。
由于 Java栈是与 Java线程对应起来的,这个数据不是线程共享的,所以我们不用关心它的数据一致性问题,也不会存在同步锁的问题。
堆
堆是存储 Java对象的地方,它是 JVM 管理Java 对象的核心存储区域,堆是 Java程序员最应该关心的,因为它是我们的应用程序与内存关系最密切的存储区域。
每一个存储在堆中的 Java对象都会是这个对象的类的一个副本,它会复制包括继承自它父类的所有非静态属性。
堆是被所有 Java线程所共享的,所以对它的访问需要注意同步问题,方法和对应的属性都需要保证一致性。
方法区
JVM方法区是用于存储类结构信息的地方,如在第7 章介绍的将一个 class文件解析成 JVM能识别的几个部分,这些不同的部分在这个class被加载到 JVM时,会被存储在不同的数据结构中,其中的常量池、域、方法数据、方法体、构造函数,包括类中的专用方法、实例初始化、接口初始化都存储在这个区域。
方法区这个存储区域也属于后面介绍的 Java 堆中的一部分,也就是我们通常所说的Java堆中的永久区。这个区域可以被所有的线程共享,并且它的大小可以通过参数来设置。
这个方法区存储区域的大小一般在程序启动后的一段时间内就是固定的了,JVM运行一段时间后,需要加载的类通常都已经加载到JVM 中了。但是有一种情况是需要注意的,那就是在项目中如果存在对类的动态编译,而且是同样一个类的多次编译,那么需要观察方法区的大小是否能满足类存储。
方法区这个区域有点特殊,由于它不像其他Java 堆一样会频繁地被 GC回收器回收,它存储的信息相对比较稳定,但是它仍然占用了Java 堆的空间,所以仍然会被 JVM的 GC回收器来管理。在一些特殊的场合下,有时通常需要缓存一块内容,这个内容也很少变动,但是如果把它置于Java 堆中它会不停地被 GC回收器扫描,直到经过很长的时间后会进入Old区。在这种情况下,通常是能控制这个缓存区域中数据的生命周期的,我们不希望它被JVM 内存管理,但是又希望它在内存中。面对这种情况,淘宝正在开发一种技术用于在JVM 中分配另外一个内存存储区域,它不需要GC 回收器来回收,但是可以和其他内存中对象一样来使用。
运行时常量池
在 JVM规范中是这样定义运行时常量池这个数据结构的:Runtime Constant Pool代表运行时每个 class文件中的常量表。它包括几种常量:编译期的数字常量、方法或者域的引用(在运行时解析)。Runtime Constant Pool的功能类似于传统编程语言的符号表,尽管它包含的数据比典型的符号表要丰富得多。每个Runtime Constant pool都是在 JVM的 Method area中分配的,每个Class 或者Interface 的 Constant Pool都是在 JVM创建 class或接口时创建的。
上面的描述可能使你有点迷惑,这个常量池与前面方法区的常量池是否是一回事?答案是肯定的。它是方法区的一部分,所以它的存储也受方法区的规范约束,如果常量池无法分配,同样会抛出OutOfMemoryError。
本地方法栈
本地方法栈是为 JVM运行 Native方法准备的空间,它和前面介绍的 Java 栈的作用是类似的,由于很多 Native方法都是用 C语言实现的,所以它通常又叫 C 栈,除了在我们的代码中包含的常规的Native方法会使用这个存储空间,在 JVM 利用JIT 技术时会将一些 Java方法重新编译为 Native Code代码,这些编译后的本地代码通常也是利用这个栈来跟踪方法的执行状态的。
在 JVM规范中没有对这个区域的严格限制,它可以由不同的JVM 实现者自由实现,但是它和其他存储区一样也会抛出OutOfMemoryError和 StackOverflowError。
JVM 内存分配策略
在分析 JVM内存分配策略之前我们先介绍一下通常情况下操作系统都是采用哪些策略来分配内存的。
通常的内存分配策略
我想大家都学过操作系统,在操作系统中将内存分配策略分为三种,分别是:
◎静态内存分配;
◎栈内存分配;
◎堆内存分配。
静态内存分配是指在程序编译时就能确定每个数据在运行时的存储空间需求,因此在编译时就可以给它们分配固定的内存空间。这种分配策略不允许在程序代码中有可变数据结构(如可变数组)的存在,也不允许有嵌套或者递归的结构出现,因为它们都会导致编译程序无法计算准确的存储空间需求。
栈式内存分配也可称为动态存储分配,是由一个类似于堆栈的运行栈来实现的。和静态内存分配相反,在栈式内存方案中,程序对数据区的需求在编译时是完全未知的,只有到运行时才能知道,但是规定在运行中进入一个程序模块时,必须知道该程序模块所需的数据区大小才能够为其分配内存。和我们所熟知的数据结构中的栈一样,栈式内存分配按照先进后出的原则进行分配。
在编写程序时除了在编译时能确定数据的存储空间和在程序入口处能知道存储空间外,还有一种情况就是当程序真正运行到相应代码时才会知道空间大小,在这种情况下我们就需要堆这种分配策略。
这几种内存分配策略中,很明显堆分配策略是最自由的,但是这种分配策略对操作系统和内存管理程序来说是一种挑战。另外,这个动态的内存分配是在程序运行时才执行的,它的运行效率也是比较差的。
Java 中的内存分配详解
从前面的 JVM内存结构的分析我们可知,JVM内存分配主要基于两种,分别是堆和栈。先来说说Java 栈是如何分配的。
Java栈的分配是和线程绑定在一起的,当我们创建一个线程时,很显然,JVM就会为这个线程创建一个新的Java 栈,一个线程的方法的调用和返回对应于这个Java 栈的压栈和出栈。当线程激活一个Java 方法时,JVM就会在线程的 Java堆栈里新压入一个帧,这个帧自然成了当前帧。在此方法执行期间,这个帧将用来保存参数、局部变量、中间计算过程和其他数据。
栈中主要存放一些基本类型的变量数据(int、short、long、byte、float、double、boolean、char)和对象句柄(引用)。存取速度比堆要快,仅次于寄存器,栈数据可以共享。缺点是,存在栈中的数据大小与生存期必须是确定的,这也导致缺乏了其灵活性。
如下面这段代码:
public void stack(String[] arg) {
String str = "junshan";
if (str.equals("junshan")) {
int i = 3;
while (i > 0) {
long j = 1;
i--; }
} else {
char b = 'a';
System.out.println(b);
}
}
这段代码的 stack方法中定义了多个变量,这些变量在运行时需要存储空间,同时在执行指令时JVM 也需要知道操作栈的大小,这些数据都会在 Javac 编译这段代码时就已经确定,下面是这个方法对应的class字节码:
public void stack(java.lang.String[]);
Code:
Stack=2, Locals=6, Args_size=2 0: ldc #3; //String junshan
2: astore_2
3: aload_2
4: ldc #3; //String junshan
6: invokevirtual #4; //Method java/lang/String.equals:(Ljava/lang/ Object;)Z
9: ifeq 30
12: iconst_3
13: istore_3
14: iload_3
15: ifle 27
18: lconst_1
19: lstore 4
21: iinc 3,-1 24: goto 14 27: goto 40 30: bipush 97 32: istore_3
33: getstatic
36: iload_3
37: invokevirtual
40: return
#5; //Field java/lang/System.out:Ljava/io/PrintStream; #6; //Method java/io/PrintStream.println:(C)V
LineNumberTable:
line 15: 0
line 16: 3
line 17: 12
line 18: 14
line 19: 18
line 20: 21
line 21: 24
line 22: 27
line 23: 30
line 24: 33
line 26: 40
LocalVariableTable:
Start Length Slot Name Signature
21 3 4 j J
14 13 3 i I
33 7 3 b C
0 41 0 this Lheap/StackSize; 0 41 1 arg [Ljava/lang/String; 3 38 2 str Ljava/lang/String;
在这个方法的 attribute中就已经知道了 stack和 local variable的大小,分别是 2和 6。还有一点不得不提,就是这里的大小指定的是最大值,为什么是最大值呢?因为JVM 在真正执行时分配的stack 和local variable的空间是可以共用的。举例来说,上面的 6 个localvariable 除去变量 0是 this指针外,其他 5个都是在这个方法中定义的,这 6个变量需要的 Slot是 1+1+1+1+2+1=7,但是实际上使用的Slot 只有4 个,这是因为不同的变量作用范围如果没有重合,Slot则可以重复使用。
每个 Java应用都唯一对应一个 JVM 实例,每个实例唯一对应一个堆。应用程序在运行中所创建的所有类实例或数组都放在这个堆中,并由应用程序所有的线程共享。在Java中分配堆内存是自动初始化的,所有对象的存储空间都是在堆中分配的,但是这个对象的引用却是在堆栈中分配的。也就是说在建立一个对象时两个地方都分配内存,在堆中分配的内存实际建立这个对象,而在堆栈中分配的内存只是一个指向这个堆对象的指针(引用)而已。
Java的堆是一个运行时数据区,这些对象通过new、newarray、anewarray和multianewarray等指令建立,它们不需要程序代码来显式地释放。堆是由垃圾回收来负责的,堆的优势是可以动态地分配内存大小,生存期也不必事先告诉编译器,因为它是在运行时动态分配内存的,Java的垃圾收集器会自动收走这些不再使用的数据。但缺点是,由于要在运行时动态分配内存,存取速度较慢。
如下代码描述新对象是如何在堆上分配内存的:
public static void main(String[] args) {
String str = new String("hello world!") ;
}
上面的代码创建了一个 String 对象,这个 String对象将会在堆上分配内存,JVM创建对象的字节码指令如下:
public static void main(java.lang.String[]);
Code:
Stack=3, Locals=2, Args_size=1
0: new #7; //class java/lang/String
3: dup
4: ldc #8; //String hello world!
6: invokespecial #9; //Method java/lang/String."<init>":(Ljava/lang/
String;)V
9: astore_1
10: return
LineNumberTable:
line 29: 0
line 35: 10
LocalVariableTable:
Start Length Slot Name Signature
0 11 0 args [Ljava/lang/String; 10 1 1 str Ljava/lang/String;
先执行 new指令,这个 new指令根据后面的 16位的“#7”常量池索引创建指定类型的对象,而该#7索引所指向的入口类型首先必须是 CONSTANT_Class_info,也就是它必须是类类型,然后JVM 会为这个类的新对象分配一个空间,这个新对象的属性值都设置为默认值,最后将执行这个新对象的objectref引用压入栈顶。
new指令执行完成后,得到的对象还没有初始化,所以这个新对象并没有创建完成,这个对象的引用在这时不应该赋值给str 变量,而应该接下去就调用这个类的构造函数初始化类,这时就必须将objectref引用复制一份,在新对象初始化完成后再将这个引用赋值给本地变量。调用构造函数是通过invokespecial指令完成的,构造函数如果有参数要传递,则先将参数压栈。构造函数执行完成后再将objectref的对象引用赋值给本地变量 1,这样一个新对象才创建完成。
上面的内存分配策略定义从编译原理的教材中总结而来,除静态内存分配之外,都显得很呆板和难以理解,下面撇开静态内存分配,集中比较堆和栈。
从堆和栈的功能和作用来通俗地比较,堆主要用来存放对象,栈主要用来执行程序,这种不同主要是由堆和栈的特点决定的。
在编程中,如 C/C++中,所有的方法调用都是通过栈来进行的,所有的局部变量、形式参数都是从栈中分配内存空间的。实际上也不是什么分配,只是从栈顶向上用就行,就好像工厂中的传送带一样,栈指针会自动指引你到放东西的位置,你所要做的只是把东西放下来就行。在退出函数时,修改栈指针就可以把栈中的内容销毁。这样的模式速度最快,当然要用来运行程序了。需要注意的是,在分配时,如为一个即将要调用的程序模块分配数据区时,应事先知道这个数据区的大小,也就说是虽然分配是在程序运行时进行的,但是分配的大小是确定的、不变的,而这个“大小多少”是在编译时确定的,而不是在运行时。
堆在应用程序运行时请求操作系统给自己分配内存,由于操作系统管理内存分配,所以在分配和销毁时都要占用时间,因此用堆的效率非常低。但是堆的优点在于,编译器不必知道要从堆里分配多少存储空间,也不必知道存储的数据要在堆里停留多长时间,因此,用堆保存数据时会得到更大的灵活性。事实上,由于面向对象的多态性,堆内存分配是必不可少的,因为多态变量所需的存储空间只有在运行时创建了对象之后才能确定。在C++中,要求创建一个对象时,只需用new 命令编制相关的代码即可。执行这些代码时,会在堆里自动进行数据的保存。当然,为达到这种灵活性,必然会付出一定的代价——在堆里分配存储空间时会花掉更长的时间。
JVM 内存回收策略
Java语言和其他语言的一个很大不同之处就是Java 开发人员不需要了解内存这个概念,因为在 Java 中没有什么语法和内存直接有联系,不像在 C 或C++中有malloc这种语法直接操作内存。但是程序执行都需要内存空间来支持,不然我们的那些数据存在哪里?Java语言没有提供直接操作内存的语法,那我们的数据又是如何申请内存的呢?就Java语言本身来说,通常显式的内存申请有两种:一种是静态内存分配,另一种是动态内存分配。
静态内存分配和回收
public void staticData(int arg){
String s="String";
long l=1;
Long lg=1L;
Object o = new Object();
Integer i = 0;
}
其中参数 arg、l是原生的数据类型,s、o和 i是指向对象的引用。在 Javac 编译时就已经确定了这些变量的静态内存空间。其中arg 会分配 4个字节,long会分配 8个字节,String、 Long、Object和 Integer是对象的类型,它们的引用会占用 4个字节空间,所以这个方法占用的静态内存空间是 4+4+8+4+4+4=28字节。
静态内存空间当这段代码运行结束时回收,根据第7 章的介绍,我们知道这些静态内存空间是在 Java 栈上分配的,当这个方法运行结束时,对应的栈帧也就撤销,所以分配的静态内存空间也就回收了。
动态内存分配和回收
在前面的例子中变量 lg 和i 存储与值虽然与 l和 arg变量一样,但是它们存储的位置是不一样的,后者是原生数据类型,它们存储在Java 栈中,方法执行结束就会消失,而前者是对象类型,它们存储在Java 堆中,它们是可以被共享的,也不一定随着方法执行结束而消失。变量l 和 lg的内存空间大小显然也是不一样的,l在 Java栈中被分配 8个字节空间,而 lg被分配 4个字节的地址指针空间,这个地址指针指向这个对象在堆中的地址。很显然在堆中long 类型数字 1肯定不只 8个字节,所以 Long代表的数字肯定比 long类型占用的空间要大很多。
在 Java中对象的内存空间是动态分配的,所谓的动态分配就是在程序执行时才知道要分配的存储空间大小,而不是在编译时就能够确定的。lg代表的 Long对象,只有 JVM在解析Long 类时才知道在这个类中有哪些信息,这些信息都是哪些类型,然后再为这些信息分配相应的存储空间存储相应的值。而这个对象什么时候被回收也是不确定的,只有等到这个对象不再使用时才会被回收。
从前面的分析可知内存的分配是在对象创建时发生的,而内存的回收是以对象不再引用为前提的。这种动态内存的分配和回收是和Java 中一些数据类型关联的,Java程序员根本不需要关注内存的分配和回收,只需关注这些数据类型的使用就行了。
那么如何确定这个对象什么时候不被使用,又如何来回收它们,这正是JVM 的一个很重要的组件——垃圾收集器要解决的问题。
如何检测垃圾
垃圾收集器必须能够完成两件事情:一件是能够正确地检测出垃圾对象,另一件是能够释放垃圾对象占用的内存空间。其中如何检测出垃圾是垃圾收集器的关键所在。
从前面的分析已经知道,只要是某个对象不再被其他活动对象引用,那么这个对象就可以被回收了。这里的活动对象指的是能够被一个根对象集合到达的对象,如图8-1 所示。
在图 8-1中除了 f和 h对象之外,其他都可以称为活动对象,因为它们都可以被根对象集合到达。h对象虽然也被 f对象引用,但是 h对象不能够被根对象集合达到,所以它们都是非活动对象,可以被垃圾收集器回收。
那么在这个根对象集合中又都是些什么呢?虽然根对象集合和JVM 的具体实现也有关系,但是大都会包含如下一些元素。
-
◎ 在方法中局部变量区的对象的引用:如在前面的staticData方法中定义的 lg和 o等对象的引用就是根对象集合中的一个根对象,这些根对象直接存储在栈帧的局部变量区中。
-
◎ 在Java 操作栈中的对象引用:有些对象是直接在操作栈中持有的,所以操作栈肯定也包含根对象集合。
-
◎ 在常量池中的对象引用:每个类都会包含一个常量池,这些常用池中也会包含很多对象引用,如表示类名的字符串就保存在堆中,那么常量池中只会持有这个字符串对象的引用。
-
◎ 在本地方法中持有的对象引用:有些对象被传入本地方法中,但是这些对象还没有被释放。
-
◎类的 Class对象:当每个类被 JVM加载时都会创建一个代表这个类的唯一数据类型的 Class对象,而这个 Class对象也同样存放在堆中,当这个类不再被使用时,在方法区中类数据和这个Class 对象同样需要被回收。
JVM在做垃圾回收时会检查堆中的所有对象是否都会被这些根对象直接或者间接引用,能够被引用的对象就是活动对象,否则就可以被垃圾收集器回收。
基于分代的垃圾收集算法
经过这么长时间的发展,垃圾收集算法已经有很多种,算法各有优缺点,这里将主要介绍在hotspot 中使用的基于分代的垃圾收集方式。
该算法的设计思路是:把对象按照寿命长短来分组,分为年轻代和年老代,新创建的对象被分在年轻代,如果对象经过几次回收后仍然存活,那么再把这个对象划分到年老代。年老代的收集频度不像年轻代那么频繁,这样就减少了每次垃圾收集时所要扫描的对象的数量,从而提高了垃圾回收效率。
这种设计的思路是把堆划分成若干个子堆,每个子堆对应一个年龄代,如图8-2 所示。
JVM将整个堆划分为 Young区、Old区和 Perm区,分别存放不同年龄的对象,这三个区存放的对象有如下区别。
在 Sun 的 JVM 中提供了一个 visualvm 工具,其中有个 Visual GC 插件可以观察到 JVM 的不同代的垃圾回收情况,如图 8-3 所示。◎Young区又分为 Eden区和两个 Survivor区,其中所有新创建的对象都在 Eden 区,当Eden 区满后会触发minor GC 将 Eden区仍然存活的对象复制到其中一个 Survivor区中,另外一个 Survivor区中的存活对象也复制到这个 Survivor中,以保证始终有一个 Survivor区是空的。
◎ Old区存放的是 Young区的 Survivor满后触发 minor GC后仍然存活的对象,当Eden区满后会将对象存放到 Survivor区中,如果 Survivor区仍然存不下这些对象,GC收集器会将这些对象直接存放到 Old区。如果在 Survivor区中的对象足够老,也直接存放到 Old区。如果 Old区也满了,将会触发 Full GC,回收整个堆内存。
◎ Perm区存放的主要是类的 Class对象,如果一个类被频繁地加载,也可能会导致Perm区满,Perm区的垃圾回收也是由 Full GC触发的。
-
◎ Serial Collector;
-
◎ Parallel Collector;
-
◎ CMS Collector。
1.Serial Collector
Serial Collector是 JVM在 client模式下默认的 GC方式。可以通过 JVM配置参数-XX:+UseSerialGC来指定 GC使用该收集算法。我们指定所有的对象都在Young区的 Eden中创建,但是如果创建的对象超过Eden 区的总大小,或者超过了 PretenureSizeThreshold配置参数配置的大小,就只能在Old 区分配了,如-XX:PretenureSizeThreshold= 30720在实际使用中很少发生。
当 Eden空间不足时就触发了 Minor GC,触发Minor GC时首先会检查之前每次 MinorGC 时晋升到 Old区的平均对象大小是否大于 Old 区的剩余空间,如果大于,则将直接触发Full GC,如果小于,则要看HandlePromotionFailure参数(-XX:-HandlePromotionFailure)的值。如果为true,仅触发Minor GC,否则再触发一次Full GC。其实这个规则很好理解,如果每次晋升的对象大小都超过了Old 区的剩余空间,那么说明当前的 Old区的空间已经不能满足新对象所占空间的大小,只有触发Full GC才能获得更多的内存空间。
如这个例子:
public static void main2(String[] args) throws Exception {
int m = 1024 * 1024;
byte[] b = new byte[2*m];
byte[] b2 = new byte[2*m];
byte[] b3 = new byte[2*m];
byte[] b4 = new byte[2*m];
byte[] b5 = new byte[2*m];
byte[] b6 = new byte[2*m];
byte[] b7 = new byte[2*m];
}
Java参数为 java -Xms20M -Xmx20M -Xmn10M -XX:+UseSerialGC -XX:+PrintGCDetails。
GC日志如下:
[DefNew: 6979K->305K(9216K), 0.0108985 secs] 6979K->6449K(19456K), 0.0110814 secs] [Times: user=0.02 sys=0.00, real=0.01 secs]
[Full GC [Tenured: 6144K->8192K(10240K), 0.0182885 secs] 12767K->12594K (19456K), [Perm : 2555K->2555K(12288K)], 0.0184352 secs] [Times: user=0.00 sys=0.02, real=0.02 secs]
Heap
def new generation total 9216K, used 6587K [0x03ab0000, 0x044b0000,0x044b0000)
eden space 8192K, 80% used [0x03ab0000, 0x0411ee70, 0x042b0000) fromspace1024K, 0%used[0x043b0000,0x043b0000,0x044b0000) to space1024K, 0%used[0x042b0000,0x042b0000,0x043b0000)
tenured generation total 10240K, used 8192K [0x044b0000, 0x04eb0000, 0x04eb0000)
the space 10240K, 80% used [0x044b0000, 0x04cb0040, 0x04cb0200, 0x04eb0000)
compacting perm gen total 12288K, used 2563K [0x04eb0000, 0x05ab0000, 0x08eb0000)
the space 12288K, 20% used [0x04eb0000, 0x05130e90, 0x05131000, 0x05ab0000]
从 GC日志可以看出,Minor GC每晋升到 Old区的大小为(6979KB-305KB)(6979KB-6449 KB)=6144 KB,而Old 区的剩余空间为 10240 KB6144 KB =4096 KB,显然前者大于后者,所以直接触发了一次Full GC。
当 Minor GC时,除了将 Eden区的非活动对象回收以外,还会把一些老对象也复制到Old区中。这个老对象的定义是通过配置参数MaxTenuringThreshold来控制的,如-XX:MaxTenuringThreshold=10,则如果这个对象已经被Minor GC回收过10次后仍然存活,那么这个对象在这次Minor GC后直接放入Old区。还有一种情况,当这次Minor GC时Survivor区中的 To Space放不下这些对象时,这些对象也将直接放入Old 区。如果 Old区或者 Perm区空间不足,将会触发 Full GC,Full GC会检查 Heap堆中的所有对象,清除所有垃圾对象,如果是Perm 区,会清除已经被卸载的 classloader中加载的类的信息。
JVM在做 GC时由于是串行的,所以这些动作都是单线程完成的,在JVM 中的其他应用程序会全部停止。
2.Parallel Collector
Parallel GC
根据
Minor GC
和
Full GC
的不同分为三种,分别是
ParNewGC
、
ParallelGC
和
ParallelOldGC
。
1)ParNewGC
可以通过-XX:+UseParNewGC参数来指定,它的对象分配和回收策略与SerialCollector类似,只是回收的线程不是单线程的,而是多线程并行回收。在Parallel Collector中还有一个UseAdaptiveSizePolicy配置参数,这个参数是用来动态控制Eden、From Space 和To Space的TenuringThreshold大小的,以便于控制哪些对象经过多少次回收后可以直接放入Old 区。
2)ParallelGC
在 Server下默认的 GC方式,可以通过-XX:+UseParallelGC参数来强制指定,并行回收的线程数可以通过-XX:ParallelGCThreads来指定,这个值有个计算公式,如果CPU 和核数小于 8,线程数可以和核数一样,如果大于8,值为3+(cpu core*5)/8。
可以通过-Xmn来控制 Young区的大小,如-Xman10m,即设置Young区的大小为 10MB。在Young区内的 Eden、From Space 和 To Space 的大小控制可以通过 SurvivorRatio参数来完成,如设置成-XX:SurvivorRatio=8,表示Eden区与From Space的大小为8:1,如果Young区的总大小为10 MB,那么Eden、s0和 s1的大小分别为 8 MB、1 MB 和 1 MB。但在默认情况下以-XX:InitialSurivivorRatio设置的为准,这个值默认也为 8,表示的是Young:s0为 8:1。
当在 Eden区中申请内存空间时,如果 Eden 区不够,那么看当前申请的空间是否大于等于 Eden 的一半,如果大于则这次申请的空间直接在 Old 中分配,如果小于则触发MinorGC。在触发GC 之前首先会检查每次晋升到 Old区的平均大小是否大于 Old 区的剩余空间,如大于则再触发Full GC。在这次触发GC后仍然会按照这个规则重新检查一次。也就是如果满足上面这个规则,Full GC 会执行两次。
如下面这个例子:
public static void main(String[] args) throws Exception {
int m = 1024 * 1024;
byte[] b = new byte[2*m];
byte[] b2 = new byte[2*m];
byte[] b3 = new byte[2*m];
byte[] b4 = new byte[2*m];
byte[] b5 = new byte[2*m];
byte[] b6 = new byte[2*m];
byte[] b7 = new byte[2*m];
}
JVM参数为java -Xms20M -Xmx20M -Xmn10M -XX:+UseParallelGC -XX:+ PrintGCDetails。
GC日志如下:
[PSYoungGen: 6912K->368K(8960K)] 6912K->6512K(19200K), 0.0054194 secs]
[Times: user=0.06 sys=0.00, real=0.01 secs]
[Full GC [PSYoungGen: 368K->0K(8960K)] [PSOldGen: 6144K->6450K(10240K)]
6512K->6450K(19200K) [PSPermGen: 2548K->2548K(12288K)], 0.0142088 secs] [Times: user=0.01 sys=0.00, real=0.01 secs]
[Full GC [PSYoungGen: 6227K->4096K(8960K)] [PSOldGen: 6450K->8498K(10240K)] 12677K->12594K(19200K) [PSPermGen: 2554K->2554K(12288K)], 0.0145918 secs] [Times: user=0.02 sys=0.00, real=0.02 secs]
Heap
PSYoungGen total 8960K, used 6529K [0x084a0000, 0x08ea0000, 0x08ea0000)
eden space 7680K, 85% used [0x084a0000,0x08b007e8,0x08c20000) from space 1280K, 0% used [0x08c20000,0x08c20000,0x08d60000) to space1280K,0%used[0x08d60000,0x08d60000,0x08ea0000)
PSOldGen total 10240K, used 8498K [0x07aa0000, 0x084a0000, 0x084a0000) object space 10240K, 82% used [0x07aa0000,0x082ec850,0x084a0000)
PSPermGen total 12288K, used 2563K [0x03aa0000, 0x046a0000, 0x07aa0000) object space 12288K, 20% used [0x03aa0000,0x03d20f78,0x046a0000]
从 GC日志可以看出,Minor GC每次晋升到 Old区的大小为(6912KB368 KB)(6912KB6512 KB)= 6144 KB,而Old 区的剩余空间为 10240 KB6227 KB =4013 KB,显然前者大于后者,所以直接触发了两次Full GC。
在 Young区的对象经过多次 GC 后有可能仍然存活,那么它们晋升到Old 区的规则可以通过如下参数来控制:AlwaysTenure,默认为false,表示只要Minor GC时存活就晋升到 old;NeverTenure,默认为false,表示永远晋升到old 区。如果在上面两个都没设置的情况下设置 UseAdaptiveSizePolicy,启动时以InitialTenuringThreshold值作为存活次数的阈值,在每次 GC 后会动态调整,如果不想使用UseAdaptiveSizePolicy,则以MaxTenuringThreshold为准,不使用UseAdaptiveSizePolicy可以设置为-XX:- UseAdaptiveSizePolicy。如果MinorGC时 To Space不够,对象也将会直接放到 Old 区。
当 Old或者 Perm区空间不足时会触发 Full GC,如果配置了参数ScavengeBeforeFullGC,在Full GC之前会先触发 Minor GC。
3)ParallelOldGC
可以通过-XX:+UseParallelOldGC参数来强制指定,并行回收的线程数可以通过-XX:ParallelGCThreads来指定,这个数字的值有个计算公式,如果CPU 和核数小于 8,线程数可以和核数一样,如果大于8,值为3+(cpu core*5)/8。
它与ParallelGC有何不同呢?其实不同之处在Full GC上,前者Full GC进行的动作为清空整个Heap 堆中的垃圾对象,清除 Perm区中已经被卸载的类信息,并进行压缩。而后者是清除Heap 堆中的部分垃圾对象,并进行部分的空间压缩。
GC垃圾回收都是以多线程方式进行的,同样也将暂停所有的应用程序。
3.CMS Collector
可通过-XX:+UseConcMarkSweepGC来指定,并发的线程数默认为 4(并行GC 线程数+3),也可通过ParallelCMSThreads来指定。
CMS GC与上面讨论的 GC不太一样,它既不是上面所说的 Minor GC,也不是Full GC,它是基于这两种GC 之间的一种 GC。它的触发规则是检查Old 区或者 Perm区的使用率,当达到一定比例时就会触发CMS GC,触发时会回收Old 区中的内存空间。这个比例可以通过 CMSInitiatingOccupancyFraction参数来指定,默认是 92%,这个默认值是通过((100MinHeapFreeRatio)+(double)(CMSTriggerRatio*MinHeapFreeRatio)/100.0)/100.0计算出来的,其中的 MinHeapFreeRatio为 40、CMSTriggerRatio为 80。如果让Perm 区也使用 CMSGC可以通过-XX:+CMSClassUnloadingEnabled来设定,Perm区的比例默认值也是 92%,这个值可以通过CMSInitiatingPermOccupancyFraction设定。这个默认值也是通过一个公式计算 出 来 的 :((100MinHeapFreeRatio)+(double)(CMSTriggerPermRatio*MinHeapFreeRatio)/100.0)/100.0,其中MinHeapFreeRatio为 40,CMSTriggerPermRatio为 80。
触发 CMS GC时回收的只是 Old区或者 Perm区的垃圾对象,在回收时和前面所说的Minor GC和 Full GC基本没有关系。
在这个模式下的 Minor GC触发规则和回收规则与 Serial Collector基本一致,不同之处只是 GC 回收的线程是多线程而已。
触发Full GC是在这两种情况下发生的:一种是Eden分配失败,Minor GC后分配到 ToSpace,To Space 不够再分配到 Old 区,Old区不够则触发 Full GC;另外一种情况是,当CMS GC 正在进行时向 Old 申请内存失败则会直接触发Full GC。
这里还需要特别提醒一下,在 Hotspot 1.6 中使用这种 GC方式时在程序中显式地调用了 System.gc,且设置了ExplicitGCInvokesConcurrent参数,那么使用 NIO时可能会引发内存泄漏,这个内存泄漏将在后面介绍。
CMS GC何时执行 JVM还会有一些时机选择,如当前的 CPU 是否繁忙等因素,因此它会有一个计算规则,并根据这个规则来动态调整。但是这也会给JVM 带来另外的开销,如果要去掉这个动态调整功能,禁止JVM 自行触发 CMS GC,可以通过配置参数-XX:+UseCMSInitiatingOccupancyOnly来实现。
4.组合使用这三种GC(如表8-1 所示)
表 8-1三种 GC
5.GC参数列表集合(如表8-2 所示)
表 8-2 GC参数列表集合
6.三种GC 优缺点对比(如表8-3 所示)
表 8-3三种 GC 的优缺点对比
内存问题分析
GC 日志分析
有时候我们可能并不知道何时会发生内存溢出,但是当溢出已经发生时我们却并不知道原因,所以在JVM 启动时就加上一些参数来控制,当 JVM 出问题时能记下一些当时的情况。还有就是记录下来的GC 的日志,我们可以观察 GC的频度以及每次 GC都回收了哪些内存。
GC的日志输出如下参数。
-
◎ -verbose:gc,可以辅助输出一些详细的GC 信息。
-
◎ -XX:+PrintGCDetails,输出GC 的详细信息。
-
◎ -XX:+PrintGCApplicationStoppedTime,输出GC 造成应用程序暂停的时间。
-
◎ -XX:+PrintGCDateStamps,GC发生的时间信息。
-
◎ -XX:+PrintHeapAtGC,在GC 前后输出堆中各个区域的大小。
-
◎ -Xloggc:[file],将GC 信息输出到单独的文件中。每种 GC的日志形式如表 8-4所示。
除 CMS的日志与其他 GC的日志差别较大外,它们都可以抽象成如下格式:
[GC [<collector>: <starting occupancy1> -> <ending occupancy1>(total size1), <pause time1> secs] <starting occupancy2> -> <ending occupancy2>(total size2), <pause time2> secs]
其中说明如下:
◎<collector>GC表示收集器的名称。
-
◎ <starting occupancy1>表示Young区在 GC前占用的内存。
-
◎ <ending occupancy1>表示Young区在 GC后占用的内存。
-
◎ <pause time1>表示Young区局部收集时 JVM暂停处理的时间。
-
◎ <starting occupancy2>表示JVM Heap在 GC前占用的内存。
-
◎ <ending occupancy2>表示 JVM Heap在 GC后占用的内存。
-
◎ <pause time2>表示在GC 过程中 JVM暂停处理的总时间。
可以根据日志来判断是否有内存在泄漏,如果<ending occupancy1>-<startingoccupancy1>=<ending occupancy2>-<starting occupancy2>,则表明这次 GC 对象100%被回收,没有对象进入 Old 区或者 Perm 区。如果等号前面的值大于等号后面的值,那么差值就是这次回收对象进入 Old 区或者 Perm 区的大小。如果随着时间的延长,<endingoccupancy2>的值一直在增长,而且 Full GC 很频繁,那么很可能就是内存泄漏了。
除去日志文件分析外,还可以直接通过 JVM 自带的一些工具分析,如 jstat,使用格式为 jstat –gcutil [pid] [intervel] [count],如下面这个日志:
在上面日志中的参数含义如下:
-
◎ S0表示 Heap上的 Survivor space 0区已使用空间的百分比。
-
◎ S1表示 Heap上的 Survivor space 1区已使用空间的百分比。
-
◎ E表示 Heap上的 Eden space区已使用空间的百分比。
-
◎ O表示 Heap上的 Old space区已使用空间的百分比。
-
◎ P表示 Perm space区已使用空间的百分比。
-
◎ YGC表示从应用程序启动到采样时发生 Young GC 的次数。
-
◎ YGCT表示从应用程序启动到采样时 Young GC 所用的时间(单位为秒)。
-
◎ FGC表示从应用程序启动到采样时发生 Full GC 的次数。
-
◎ FGCT表示从应用程序启动到采样时 Full GC 所用的时间(单位为秒)。
-
◎ GCT表示从应用程序启动到采样时用于垃圾回收的总时间(单位为秒)。
堆快照文件分析
可通过命令 jmap –dump:format=b,file=[filename] [pid]来记录下堆的内存快照,然后利用第三方工具(如mat)来分析整个Heap 的对象关联情况。
如果内存耗尽那么可能导致 JVM直接垮掉,可以通过参数:-XX:+HeapDumpOnOutOfMemoryError来配置当内存耗尽时记录下内存快照,可以通过-XX:HeapDumpPath来指定文件的路径,这个文件的命名格式如 java_[pid].hprof。
JVM Crash 日志分析
JVM有时也会因为一些原因而导致直接垮掉,因为JVM 本身也是一个正在运行的程序,这个程序本身也会有很多情况直接出问题,如JVM 本身也有一些 Bug,这些Bug 可能会导致 JVM异常退出。JVM退出一般会在工作目录下产生一个日志文件,也可以通过JVM参数来设定,如-XX:ErrorFile=/tmp/log/hs_error_%p.log。
下面是一个日志文件:
在这个文件中的信息主要分为 4 种:退出原因分类、导致退出的Thread信息、退出时的 Process状态信息、退出时与操作系统相关信息。
JVM 退出一般有三种主要原因,在上面这个例子中是 SIGSEGV(0xb),这三种原因分别如下。
1.EXCEPTION_ACCESS_VIOLATION
正在运行 JVM自己的代码,而不是外部的 Java代码或其他类库代码。这种情况很可能是JVM 自己的Bug,遇到这种错误时,可以根据出错信息到http://bugreport.sun.com/bugreport/index.jsp去搜索一下已经发行的Bug。
在大部分情况下是由于 JVM 的内存回收导致的,所以可以观察Process部分的信息,查看堆的内存占用情况。
2.SIGSEGV
JVM
正在执行本地或
JNI
的代码,出这种错误很可能是第三方的本地库有问题,可以
通过
gbd
和
core
文件来分析出错原因。
3.EXCEPTION_STACK_OVERFLOW
这是个栈溢出的错误,注意 JVM 在执行Java 线程时出现的栈溢出通常不会导致 JVM退出,而是抛出java.lang.StackOverflowError,但是在Java 虚拟机中,Java的代码和本地C或 C++代码共用相同的栈,这时如果出现栈溢出的话,就有可能直接导致JVM 退出。建议将 JVM的栈尺寸调大,主要涉及两个参数:-Xss和-XX:StackShadowPages=n。
日志文件的 Thread部分的信息对我们排查这个问题的原因最有帮助,这部分有两个关系信息,包括Machine Instructions和 Thread Stack。Mchine Instructions 是当前系统执行的机器指令,是16 进制的。我们可以将它转成指令,通过udis86 工具来转换,该工具可以在 http://udis86.sourceforge.net/ 下载,安装在 Linux 中,将上面的 16 进制数字复制到命令行中用如下方式执行转换:
可以得到汇编指令,由于是 64位机器,所以是 udcli -64 –x,如果是32 机器,则改成udcli -32 –x。可以通过这个指令来判断当前正在执行什么指令而导致了垮掉。例如,如果当前在访问寄存器地址,那么这个地址是否合法,以及如果是除法指令,操作数是否合法等。
而 Stack信息最直接,可以帮助我们看到到底是哪个库的哪行代码出错,如在上面的错误信息中显示的是由于执行Oracle的 Java驱动程序引起出错的。我们还可以通过生成的core 文件来更详细地看出是执行到哪行代码出错的,如下所示:
通过gdb来调试core文件可以看到更详细的信息,还可以通过frame n和info local组合命令来更进一步地查看这一行所包含的local变量值,但这只能是程序使用-g命名编译的结果,也就是编译后的程序包含debug信息。
日志文件的第三部分包含的是 Process 信息,这里详细列出了该程序产生的所有线程,以及线程正处于的状态。由于在同一时刻只能有一个线程具有CPU 使用权,所以可以看到,其他所有线程的状态都是_thread_blocked,而执行的正是那个出错的线程。
这部分最有价值的部分就是记录下来了当前JVM 的堆信息,如下所示:
通过每个分区当前所使用的空间大小,尤其是 Old区的空间是否已经满了,可以判断出当前的 GC是否正常。
还有一个信息也比较有价值,那就是当前JVM 的启动参数,设置的堆大小和使用的GC方式等都可以从这里看出。
最后一部分是 System信息,这部分主要记录了当前操作系统的状态信息,如操作系统的CPU 信息和内存情况等。
实例 1
这里有一个 JVM内存泄漏的实例,是在淘宝的一个系统中发生的,这个问题是由一个模板引擎导致的,这个模板引擎在后面的章节中再介绍。
当时的情况是系统 load 偏高,达到了 6左右,而平时基本在 1 左右,整个系统响应较慢,但是重启系统之后就恢复正常。于是查看GC 的情况,发现 FGC明显超出正常情况,并且在 GC 过程中出现concurrent mode failure。如下日志所示:
这说明在 Old区分配内存时出现分配失败的现象,而且整个内存占用达到了6GB 左右,超出了平时的 4GB,于是得出可能是有内存泄漏的问题。
通过 jmap –dump:format=b,file=[filename] [pid]命令查看Heap,再通过MAT 工具分析,如图 8-4所示。
图 8-4中最大的一个对象占用了 900 多兆内存,这显然有问题。图8-5 是 MAT给出的可能有问题的对象的说明,指出了Map 集合占用了55%的空间。
再看一下到底这个对象都持有了哪些对象,如图8-6 所示。
在图 8-6中第一列是类名,第二列 Shallow Heap表示这个对象本身的大小,所谓对象本身大小就是在这个对象的一些域中直接分配的存储空间,如定义byte[] byte=newbyte[1024],这个byte 属性的大小就直接包含在这个对象的 Shallow 中。而如果这个对象的某些属性指向一个对象,那么所指向的那个对象的大小就计算在Retained Heap中。
图 8-6中 SketchCompileContext对象持有一个 Map集合,这个 Map集合所占用的空间很大,仔细查看后发现这个Map 持有一个 DO对象,这个对象的确是一个大对象,它的大小并没有超出我们的预期,仔细查看其他集合,没有发现所持有对象有什么不对的地方。但是仔细计算整个对象集合的大小发现,虽然所有的对象都是应该存在的,但是比我们计算的正常大小多了将近一倍,于是我们想到可能是持有了两份同样的对象。
按照这个思路仔细搜索这个 Map 集合中的对象的数值,果然发现同样一个数值有两个不同的DO 对象对应,但是为什么有两个 DO对象呢?正常情况应该单一啊,怎么会产生两份DO 对象?后面仔细检查这个 DO对象的业务逻辑,原来是这个 DO 要在每天凌晨两点更新一次,更新后老对象会自动释放,但是我们这个新引擎是要保存这些对象,以便于做编译优化,不能及时地释放这个更新后的老对象,所以导致这个大对象在内存中保存了两份。
实例 2
这个例子和前面介绍的 CMS GC 回收方式的一个 JVM的 Bug相关,淘宝的某应用在某天突然导致线上部分机器报警,Java的内存使用非常严重,达到了 6GB,超过了平时的4GB,而且有几台机器进行一段时间后导致OOM、JVM退出。当时相关人员的第一反应是重启部分机器,保留几台有问题的机器来寻找原因。
观察重启后的机器,发现应用恢复正常,但是发现JVM 进程占用的内存一直在增长,可以大体推断出是 JVM 有内存泄漏。然后检查最近是否有系统改动,是否是Java 代码问题导致了内存泄漏。检查后发现最近一周 Java 代码改动很少,而且也没有发现有内存泄漏问题。
同时检查GC的日志,发现有问题的机器的Full GC没有异常,甚至Full GC比平时还少,CMS GC 也很少。从日志中没有发现可能有内存问题。
为了进一步确认 GC是否正常,我们找出 JVM 的Heap,用MAT 分析堆文件,堆的使用情况如图 8-7所示。
可以看出,整个 Heap只有不到 1GB的空间,而且从 Leak Suspects给出的报告中可以看到占有最大空间的对象是一个DO 对象,如图 8-8所示。
而这个对象的大小也符合我们的预期,所以可以得出判断,不是JVM 的堆内存有问题。但是既然JVM 的堆占有的内存并不多,那么Java 进程为什么占用那么多内存呢?
我们于是想到了可能是堆外分配的内存有泄漏,从前面的分析中我们已经知道,JVM除了堆需要内存外还有很多方面也需要在运行时使用内存,如JVM 本身 JIT编译需要内存,JVM的栈也需要内存,JNI调用本地代码也需要内存,还有 NIO 方式也会使用DirectBuffer来申请内存。
从这些因素中我们推断可能是 Direct Buffer导致的,因为在上次发布中引入过一个Apache的Mina包,在这个包中肯定使用了Direct Buffer,但是为什么Direct Buffer没有正常回收呢?很奇怪。
这时想到了可能是 JVM 的一个Bug,详见http://bugs.sun.com/bugdatabase/view_bug.do?bug_id=6919638。外部调用了System.gc,且设置了-XX:+DisableExplicitGC,所以导致System.gc()变成了空调用,而应用GC却很少,这里的GC包括CMS GC和Full GC。所以Direct Buffer对象还没有被及时回收,相应的nativememory不能被释放。为了验证这一点,相关人员还写了一个工具来检查当前JVM 中 NIO direct memory的使用情况,如下所示:
可以看出一段时间后 NIO direct memory的确增长了很多,所以可以肯定是 NIO directmemory没有释放从而导致 Java 进程占用的内存持续增长。
这个问题的解决办法是去掉 -XX:+DisableExplicitGC,换上 -XX:+ExplicitGCInvokesConcurrent,使得外部的显示System.gc调用生效,这样即使 Java GC 不频繁时也可以通过外部主动调用 System.gc 来回收 NIO direct memory分配的内存。
实例 3
下面再介绍一个 NIO direct memory发生内存泄漏的例子。
JVM的配置参数如下:
问题表现如下所述。
(1)一个系统在运行20~30分钟后,系统 swap区突增,直到 swap区使用率达到 100%,
机器死机,如图 8-9所示。
(2)系统内存已经达到3.5GB,已经超过了Heap 堆设置的上线,但是 GC却很少,如图8-10 所示。
(3)Old区的空间也几乎没有变化,如图 8-11 所示。
首先 Java进程内存增长非常迅速,进行压力测试20 分钟后就将 2GB内存用光,并且将内存耗光后开始使用swap 区,很快消耗了swap 区的空间,最终导致机器死机,所以可以肯定发生了内存泄漏。
但是通过 jstat分析 JVM Heap堆情况和 GC统计信息,发现 GC很少,尤其是 Full GC几乎没有,如果是JVM 堆内存被耗光,Full GC应该非常频繁,所以初步判断这次内存泄漏不在JVM 堆中。
但是为了进一步排除是 JVM 堆的内存问题,通过jmap dump出内存快照,通过 MAT分析内存数据占用情况,如图8-12 所示。
这个堆只使用了近 500MB 内存,和 jstat得出的堆信息是一致的,而两个最大的对象内容如图8-13 所示。
两个都是 org.apache.mina.transport.socket.nio.SocketSessionImpl对象,由于占用的空间不大(100MB)、持有的对象数也不多,没有引起注意。
于是可以得出:要么是 NIO direct memory,要么就是native memory泄漏。使用查看NIO direct memory的工具检查 direct memory占用的空间大小,如图 8-14所示。
显示只有 25MB左右,在这当中还使用了 gcore 命令 dump出 java进程的 core文件,然后通过 jmap将 core dump转化成 Heap dump,转化后的Heap dump文件和前面的类似。另外通过 jstack dump 出内存也没有发现有线程堵塞情况,所以怀疑是 native memory出现了泄漏,于是开始往这个方向考虑。
想使用 Oprofiler热点分析工具分析当前系统执行的热点代码,如果是当前的native memory有泄漏,那么肯定会出现分配内存的代码是热点的情况,用Oprofiler分析的 CPU的消耗情况如图 8-15所示。
如图 8-15所示,和预想的情况并不吻合,一时找不到更好的办法,于是使用土办法,一部分、一部分地去掉功能模块,看看到底是哪个模块导致的内存泄漏,进一步缩小范围。
通过删除可能会出问题的几个模块后,最后确定是调用mina 框架给 varnish发送失效请求时导致的,而且发送的请求数频率越高内存泄漏越严重,但是mina 框架没有使用native memory的地方,于是又陷入僵局。
使用Perftools来分析JVM的native Memory分配情况,通过Perftools得到的分析结果如图8-16 所示。
图 8-16显示内存的分配和使用都是在操作系统中,没有发现和应用代码相关的情况,也排除了已知的误用Inflater/Deflater的 native memory的问题。还是没有找到问题所在!
于是又回到 Java代码,这时发现在代码中使用 org.apache.mina.filter.codec.textline.TextLineEncoder类来发送和序列化发送的数据,并且这个类使用的是 direct memory内存:
ByteBuffer buf = ByteBuffer.allocate(value.length()).setAutoExpand(true);
将这个类的代码改成使用 JVM Heap 来存放数据:
ByteBuffer.allocate(value.length(),false);
按照这个思路,也就是将可能发生的direct memory转变成 Heap堆内存泄漏,如果真是这个代码有问题,必然会导致JVM Heap暴涨,这样我们也能通过 MAT 来分析JVM 堆中的对象情况。
修改代码后再运行,果不其然,当达到JVM 堆配置的上限时,GC非常频繁,使用MAT分析 dump下来的堆,如图 8-17所示。
这时显示堆空间都被 SocketSessionImpl的 writeRequestQueue队列持有,这个队列是mina的写队列,也就是 mina 不能及时地将数据发送出去,导致数据都堵在了这个队列中,进而导致了内存泄漏。所以根据这个分析认为,还是使用mina 导致了 direct memory泄漏。
总结
本章介绍了 JVM的内存结构、JVM的内存分配策略、JVM的内存回收策略及常见的内存问题,最后列举3 个在实际使用中遇到的 JVM内存泄漏的例子,并介绍了排查这些问题的方法。
最后
以上就是酷酷蛋挞为你收集整理的java JVM内存管理物理内存与虚拟内存 内核空间与用户空间 在 Java 中哪些组件需要使用内存 JVM 内存结构 JVM 内存分配策略 JVM 内存回收策略 内存问题分析 实例 1 实例 2 实例 3 总结 的全部内容,希望文章能够帮你解决java JVM内存管理物理内存与虚拟内存 内核空间与用户空间 在 Java 中哪些组件需要使用内存 JVM 内存结构 JVM 内存分配策略 JVM 内存回收策略 内存问题分析 实例 1 实例 2 实例 3 总结 所遇到的程序开发问题。
如果觉得靠谱客网站的内容还不错,欢迎将靠谱客网站推荐给程序员好友。
发表评论 取消回复