本文是基于周志明的《深入理解Java虚拟机》
对从事C和C++的程序员来说,在内存管理方面,他们既是拥有最高权利的人,也是从事最基础工作的“劳动人民”。
而对于Java程序员来说,JVM自动进行内存管理,程序员不再需要为每一个new操作去写配对的delete/free代码,不容易出现内存泄露和内存溢出问题。
但是,正因为JVM帮我们管理了内存,一旦出现内存泄露或溢出问题,如果不了解虚拟机是怎么管理内存的,那么排查错误会成为一项异常艰难的工作
Java虚拟机在执行Java程序的过程中会把它所管理的内存划分若干个不同的数据区域。这个区域都各自的用途,以及创建的销毁的时间,有的区域随着虚拟机进程的启动而存在,有些区域则依赖用户线程的启动和结束而建立销毁。Java虚拟机所管理的内存将会包括以下几个运行时数据区域。
其中程序计数器、虚拟机栈、本地方法栈3个区域会随线程线程而生,随线程而灭,所以这一块上的代码是线程安全的;栈中的栈帧随着方法的进入和退出而有条不紊地执行着出栈和入栈操作。因此这几个 区域的内存分配和回收都具备确定性,在这几个区域就不需要过多考虑回收的问题,因为方法结束或者线程时,内存自然就跟随着回收了。
运行时数据区域
程序计数器(Program Counter Register)
或者叫:程序计数寄存器、PC寄存器,学过计算机组成原理应该就懂。
程序计数器(Program Counter Register)是一块较小的内存空间,它的作用可以看做是当前线程所执行的字节码的行号指示器。在虚拟机的概念模型里(仅是概念模型,各种虚拟机可能会通过一些更高效的方式去实现),字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。
由于Java虚拟机的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的,在任何一个确定的时刻,一个处理器(对于多核处理器来说是一个内核)只会执行一条线程中的指令。因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间的计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存。也就是说程序计数器用于保存当前执行指令的地址,一旦指令执行,pc寄存器将更新到下一条指令。
如果线程正在执行的是一个Java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是Natvie方法,这个计数器值则为空(Undefined)。
注意:这块内存是JVM规范中唯一没有规定任何OutOfMemoryError的区域
虚拟机栈(Virtual Machine Stacks)
与程序计数器一样,也是线程私有的,其生命周期和线程一样,每个Java线程有一个虚拟机栈。平常我们讲的“栈内存”就是虚拟机栈,或者说是虚拟机栈中局部变量表部分。
作用:
虚拟机栈描述的是Java方法执行的内存模型,即:每个方法在执行的时候都会创建一个栈帧(Stack Frame),栈帧中存储:
1).局部变量表
存放了编译期就可知的:各种基本数据类型(8个基本数据类型)、对象引用(reference类型)、returnAddress类型(指向一条字节码指令地址)
其中64位长度的long和double类型的数据会占用2个局部变量空间(Slot),其余的数据类型只占用1个。局部变量表所需的内存大小在编译期就完成了分配,也就是说当进入一个方法时,此方法需要在栈帧中分配多大的局部变量表空间时完全确定的,运行期不会改变
2).操作数栈
3).动态链接
4).方法出口等
方法从调用到执行完成的过程,就对应了,一个栈帧在虚拟机栈中的入栈和出栈的过程
有两种异常:
1).如果线程请求的栈深度大于JVM所允许的深度,将抛出StackOverflowError异常
2).如果栈扩展时无法申请到足够的内存,将抛出OutOfMemoryError(OOM)异常
本地方法栈(Native Method Stack)
作用:
作用和虚拟机栈非常相似,区别:
虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则为虚拟机使用到的Native方法服务
JVM规范没有强制规定本地方法栈中的方法使用的语言、使用方式、数据结构,所以具体JVM不同实现。
有的虚拟机如HotSpot虚拟机直接把虚拟机栈和本地方法栈合二为一了
有两种异常:同虚拟机栈
Java堆(Java Heap)
根据Java虚拟机规范的规定,Java堆可以处于物理上不连续的内存空间中,只在逻辑上是连续的即可。对于大多数应用来说,Java堆是虚拟机管理的内存中最大的一块。是被所有线程共享的一块区域,在虚拟机启动时创建,通过参数“-Xmx和-Xms”控制。
作用:
此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例都在这里分配内存(当然也有例外):对象实例以及对应的实例变量、数组。
Java堆是垃圾回收器管理的主要区域,因此很多时候也被称做“GC堆”。
分类:
下面是一些具体的细分,但是不论如何分类,其存储的仍然是对象实例,进一步划分的目的是为了更好的回收、更快的分配内存。
1).从内存回收的角度看
由于现代GC基本都采用分带收集算法,所以Java堆还可以细分为:
①.新生代
②.老年代
再细分一下还可分为:
①.Eden空间
②.From Survivor空间
③.To Survivor空间
这样划分的目的是为了使 JVM 能够更好的管理堆内存中的对象,包括内存的分配以及回收。
从图中可以看出: 堆大小 = 新生代 + 老年代。其中,堆的大小可以通过参数 –Xms、-Xmx 来指定。
默认的,新生代 ( Young ) 与老年代 ( Old ) 的比例的值为 1:2 ( 该值可以通过参数 –XX:NewRatio 来指定 ),
默认的,Edem : from : to = 8 : 1 : 1 ( 可以通过参数 –XX:SurvivorRatio 来设定 )
JVM 每次只会使用 Eden 和其中的一块 Survivor 区域来为对象服务,所以无论什么时候,总是有一块 Survivor 区域是空闲着的。因此,新生代实际可用的内存空间为 9/10 ( 即90% )的新生代空间。
堆大小设置
JVM 中最大堆大小有三方面限制:
1).相关操作系统的数据模型(32-bt还是64-bit)限制;
2).系统的可用虚拟内存限制;
3).系统的可用物理内存限制。
32位系统下,一般限制在1.5G~2G;64为操作系统对内存无限制。
-Xmx Java Heap最大值,默认值为物理内存的1/4,最佳设值视物理内存大小及计算机内其他内存开销而定
-Xms Java Heap初始值,Server端JVM最好将-Xms和-Xmx设为相同值,开发测试机JVM可以保留默认值;
-Xmn Java Heap Young区大小,不熟悉最好保留默认值;
-Xss 每个线程的Stack大小,不熟悉最好保留默认值;
其默认空间(即-Xms)是物理内存的1/64,最大空间(-Xmx)是物理内存的1/4。如果内存剩余不到40%,JVM就会增大堆到Xmx设置的值,内存剩余超过70%,JVM就会减小堆到Xms设置的值。所以服务器的Xmx和Xms设置一般应该设置相同避免每次GC后都要调整虚拟机堆的大小。假设物理内存无限大,那么JVM内存的最大值跟操作系统有关,一般32位机是1.5g到3g之间,而64位的就不会有限制了。
2).从内存分配角度看
线程共享的Java堆中可能划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer,TLAB)
异常状况:
如果堆中内存不够继续进行实例分配,且堆也无法再扩展时,将会抛出OutOfMemoryError
GC回收动作
Java 中的堆也是 GC 收集垃圾的主要区域。GC 分为两种:Minor GC、Full GC ( 或称为 Major GC )。
Minor GC 是发生在新生代中的垃圾收集动作,所采用的是复制算法。
新生代几乎是所有 Java 对象出生的地方,即 Java 对象申请的内存以及存放都是在这个地方。Java 中的大部分对象通常不需长久存活,具有朝生夕灭的性质。
当一个对象被判定为 “死亡” 的时候,GC 就有责任来回收掉这部分对象的内存空间。新生代是 GC 收集垃圾的频繁区域。
当对象在 Eden ( 包括一个 Survivor 区域,这里假设是 from 区域 ) 出生后,在经过一次 Minor GC 后,如果对象还存活,并且能够被另外一块 Survivor 区域所容纳( 上面已经假设为 from 区域,这里应为 to 区域,即 to 区域有足够的内存空间来存储 Eden 和 from 区域中存活的对象 ),则使用复制算法将这些仍然还存活的对象复制到另外一块 Survivor 区域 ( 即 to 区域 ) 中,然后清理所使用过的 Eden 以及 Survivor 区域 ( 即 from 区域 ),并且将这些对象的年龄设置为1,以后对象在 Survivor 区每熬过一次 Minor GC,就将对象的年龄 + 1,当对象的年龄达到某个值时 ( 默认是 15 岁,可以通过参数 -XX:MaxTenuringThreshold 来设定 ),这些对象就会成为老年代。
但这也不是一定的,对于一些较大的对象 ( 即需要分配一块较大的连续内存空间 ) 则是直接进入到老年代。
Full GC 是发生在老年代的垃圾收集动作,所采用的是标记-清除算法。
现实的生活中,老年代的人通常会比新生代的人 “早死”。堆内存中的老年代(Old)不同于这个,老年代里面的对象几乎个个都是在 Survivor 区域中熬过来的,它们是不会那么容易就 “死掉” 了的。因此,Full GC 发生的次数不会有 Minor GC 那么频繁,并且做一次 Full GC 要比进行一次 Minor GC 的时间更长。
另外,标记-清除算法收集垃圾的时候会产生许多的内存碎片 ( 即不连续的内存空间 ),此后需要为较大的对象分配内存空间时,若无法找到足够的连续的内存空间,就会提前触发一次 GC 的收集动作。
方法区(Method Area)
方法区和Java堆一样,是各个线程共享的内存区域。
作用:
用于存储已被虚拟机加载的
1).类信息(class metadata)
2).常量(包括interned Strings)
3).静态变量(类变量 class static variables)
4).即时编译器编译后的代码等
虽然JVM规范把方法区描述为堆的一个逻辑部分,但是它确有一个别名叫Non-Heap,目的应该是与Java堆区分开来
永久代(HotSpot特有,但现已经被移除)
对于使用HotSpot VM的程序员来说,很多人把方法区称之为“永久代(Permanent Generation)”
为什么叫永久代?按内存回收的角度,有新生代、老年代,所以就有了这里的“永久代”。另外,其它虚拟机是没有永久代这个概念的
方法区与永久代本质上两者不等价,仅仅是因为HotSpot团队把GC分代收集扩展到了方法区(或者说用永久代这种方式来实现方法区),这样的话GC就可以像管理Java堆一样管理这部分内存,省去了为方法区编写内存管理代码的工作
坏处:
如何实现方法区JVM规范并没有强制规定,但是现在看来“永久代”并不是一个好主意
①.更容易遇到内存溢出问题
②.因为有参数“-XX:MaxPermSize”的上限限制,其它虚拟机只要不达到进程可用内存上限,例如32系统的2GB,就不会出现内存溢出
③.有极少数方法(如String.intern()),会因此导致在不同的JVM下有不同表现
现状:
在JDk1.7的HotSpot中,字符串常量池已经被从永久代中移除了,那现在如何处理?
在Java8中,根据JEP122,永久代PermanentGeneration已经被从HotSpot中removed.这是JDK1.8中JRockit和HotSpot合并的成果
垃圾回收:
JVM规范对方法区限制非常宽松,甚至可以选择不实现垃圾收集
但并不是如其“永久代”的名字一样,方法区的垃圾回收只是比较少出现。回收目标是:类信息的卸载、常量池的回收
异常状况:
当方法区无法满足内存分配需求时,将抛出OutOfMemoryError异常
运行时常量池(Run-Time Constant Pool)
运行时常量池其实是方法区的一部分。
class文件中有一项信息是常量池表(constant_pool table),用于存放编译期生成的“字面量”和“符号引用”,这部分内容将在类加载后进入方法区的运行时常量池中(Run-Time Constant Pool)存放
也就是说:每一个class都会根据constant_pool table 来1:1创建一个此class对应的Run-Time Constant Pool
作用:
就是运行时所需要的常量数据的容器
JVM规范对class文件的每一部分(包括constant_pool table)都有严格的规范,但是对于运行时常量池却没有做任何细节要求,不过一般来说,除了class文件中的符号引用外,直接引用也会存储在运行时常量池中
运行时常量池具备动态性,Java语言并没有要求常量一定只能编译期产生,运行期也可以将新常量放入池中。这个特性用的较多的便是String类的intern()方法
异常状况:
既然运行时常量池是方法区的一部分,自然受到方法区限制,当运行时常量池无法再申请到内存时,将抛出OutOfMemoryError异常
直接内存(Direct Memory)
直接内存不是JVM规范中定义的内存区域,也不是运行时数据区的内容(我现在理解为:直接控制的属于物理机的内存,不属于JVM线程使用的内存)
但是,这部分内从也被频繁的使用,且可能导致OutOfMemoryError异常,所以罗列为在这里叙述
缘由:
JDK1.4中加入的NIO(New Input/Output)类,引入了一种基于通道(Channel)与缓冲区(Buffer)的I/O方式,这个类可以使用Native函数库直接分配堆外内存,然后通过一个存储在堆中的DirectByteBuffer对象作为这块内存的引用进行操作。这样能在一些场合显著提高性能,因为避免了在Java堆和Native堆中来回复制数据。
异常状况:
直接内存不会受到Java堆大小限制,但是会受到本机总内存大小,以及处理器寻址空间的限制。JVM管理员在配置JVM参数时,会根据本机实际内存设置(如-Xmx等参数),但是经常忽略了要被使用的这一份“直接内存”。最终使得各个内存总和大于物理内存限制,从而导致动态扩展时出现OutOfMemoryError异常
虚拟机对象探秘
对象的创建
Java是一门面向对象的编程语言,Java程序运行过程中无时无刻都有对象被创建出来。在语言层面上,创建对象通常(例外:克隆、反序列化)仅仅是一个new关键字而已,而在虚拟机中,对象(本文中讨论的对象限于普通Java对象,不包括数组和Class对象等)的创建又是怎样一个过程呢?
虚拟机遇到一条new指令时,
大体分为三个部分,如下
1.分配内存空间
2.初始化对象
3.将内存空间的地址赋值给对应的引用
但是由于重排序的缘故,步骤2、3可能会发生重排序。所以写单例模式的时候要注意。JMM中的从JMM角度分析DCL/)
详细步骤如下:
1).首先检验:
a.检查这个指令的参数是否能在常量池中定位到一个类的符号引用
b.并且检查这个符号引用代表的类是否已被加载、解析和初始化过的。
如果没有,那必须先执行相应的类加载过程。
2).在类加载查通过后,接下来虚拟机将为新生对象分配内存。 准备阶段
对象所需内存的大小在类加载完成后便可完全确定(如何确定在下一节对象内存布局时再详细讲解),为对象分配空间的任务具体便等同于一块确定大小的内存从Java堆中划分出来,怎么划呢?
a.假设Java堆中内存是绝对规整的,所有用过的内存都被放在一边,空闲的内存被放在另一边,中间放着一个指针作为分界点的指示器,那所分配内存就仅仅是把那个指针向空闲空间那边挪动一段与对象大小相等的距离,这种分配方式称为“指针碰撞”(Bump The Pointer)。
b.如果Java堆中的内存并不是规整的,已被使用的内存和空闲的内存相互交错,那就没有办法简单的进行指针碰撞了,虚拟机就必须维护一个列表,记录上哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录,这种分配方式称为“空闲列表”(Free List)。
选择哪种分配方式由Java堆是否规整决定,而Java堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。
因此在使用Serial、ParNew等带Compact过程的收集器时,系统采用的分配算法是指针碰撞,而使用CMS这种基于Mark-Sweep算法的收集器时,就通常采用空闲列表。
3).考虑并发情况下线程安全问题
除如何划分可用空间之外,还有另外一个需要考虑的问题是对象创建在虚拟机中是非常频繁的行为,即使是仅仅修改一个指针所指向的位置,在并发情况下也并不是线程安全的,可能出现正在给对象A分配内存,指针还没来得及修改,对象B又同时使用了原来的指针来分配内存。
解决这个问题有两个方案,
a.一种是对分配内存空间的动作进行同步——实际上虚拟机是采用CAS配上失败重试的方式保证更新操作的原子性;
b.另外一种是把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在Java堆中预先分配一小块内存,称为本地线程分配缓冲,(TLAB ,Thread Local Allocation Buffer)。
哪个线程要分配内存,就在哪个线程的TLAB上分配,只有TLAB用完,分配新的TLAB时才需要同步锁定。虚拟机是否使用TLAB,可以通过-XX:+/-UseTLAB参数来设定。
4).内存空间初始化为0 对应类加载的初始化
内存分配完成之后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),如果使用TLAB的话,这一个工作也可以提前至TLAB分配时进行。这步操作保证了对象的实例字段在Java代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。
5).对象头的设置
接下来,虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的GC分代年龄等信息。这些信息存放在对象的对象头(Object Header)之中。根据虚拟机当前的运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式。关于对象头的具体内容,在下一节再详细介绍。
6).执行init()方法
在上面工作都完成之后,在虚拟机的视角来看,一个新的对象已经产生了。但是在Java程序的视角看来,对象创建才刚刚开始——< init>方法还没有执行,所有的字段都为零呢。所以一般来说(由字节码中是否跟随有invokespecial指令所决定),new指令之后会接着就是执行< init>方法,把对象按照程序员的意愿进行初始化。
这样一个真正可用的对象才算完全产生出来。
对象的内存布局
HotSpot虚拟机中,对象在内存中存储的布局可以分为三块区域:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。
1.对象头:HotSpot虚拟机的对象头包括两部分信息
a.第一部分用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等等,这部分数据的长度在32位和64位的虚拟机(暂不考虑开启压缩指针的场景)中分别为32个和64个Bits,官方称它为“Mark Word”。对象需要存储的运行时数据很多,其实已经超出了32、64位Bitmap结构所能记录的限度,但是对象头信息是与对象自身定义的数据无关的额外存储成本,考虑到虚拟机的空间效率,Mark Word被设计成一个非固定的数据结构以便在极小的空间内存储尽量多的信息,它会根据对象的状态复用自己的存储空间。例如在32位的HotSpot虚拟机中对象未被锁定的状态下,Mark Word的32个Bits空间中的25Bits用于存储对象哈希码(HashCode),4Bits用于存储对象分代年龄, 2Bits用于存储锁标志位,1Bit固定为0,在其他状态(轻量级锁定、重量级锁定、GC标记、可偏向)下对象的存储内容如下表所示。
| 存储内容 | 标志位 | 状态 |
| ————————————– | :——– | :——————– |
| 对象哈希码、对象分代年龄 | 01 | 未锁定 |
| 指向锁记录的指针 | 00 | 轻量级锁定 |
| 指向重量级锁的指针 | 10 | 膨胀(重量级锁定) |
| 空,不需要记录信息 | 11 | GC标记 |
| 偏向线程ID、偏向时间戳、对象分代年龄 | 01 | 可偏向 |
b.另外一部分是类型指针,即是对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。并不是所有的虚拟机实现都必须在对象数据上保留类型指针,换句话说查找对象的元数据信息并不一定要经过对象本身,这点我们在下一节讨论。另外,如果对象是一个Java数组,那在对象头中还必须有一块用于记录数组长度的数据,因为虚拟机可以通过普通Java对象的元数据信息确定Java对象的大小,但是从数组的元数据中无法确定数组的大小。
2.实例数据:对象真正存储的有效信息
实例数据部分是对象真正存储的有效信息,也既是我们在程序代码里面所定义的各种类型的字段内容,无论是从父类继承下来的,还是在子类中定义的都需要记录袭来。这部分的存储顺序会受到虚拟机分配策略参数(FieldsAllocationStyle)和字段在Java源码中定义顺序的影响。HotSpot虚拟机默认的分配策略为longs/doubles、ints、shorts/chars、bytes/booleans、oops(Ordinary Object Pointers),从分配策略中可以看出,相同宽度的字段总是被分配到一起。在满足这个前提条件的情况下,在父类中定义的变量会出现在子类之前。如果CompactFields参数值为true(默认为true),那子类之中较窄的变量也可能会插入到父类变量的空隙之中。
3.对齐填充:并不是必然存在的,也没有特别的含义,它仅仅起着占位符的作用。
由于HotSpot VM的自动内存管理系统要求对象起始地址必须是8字节的整数倍,换句话说就是对象的大小必须是8字节的整数倍。对象头部分正好似8字节的倍数(1倍或者2倍),因此当对象实例数据部分没有对齐的话,就需要通过对齐填充来补全。
对象的访问定位
建立对象是为了使用对象,我们的Java程序需要通过栈上的reference数据来操作堆上的具体对象。由于reference类型在Java虚拟机规范里面只规定了是一个指向对象的引用,并没有定义这个引用应该通过什么种方式去定位、访问到堆中的对象的具体位置,对象访问方式也是取决于虚拟机实现而定的。
主流的访问方式有使用句柄和直接指针两种。
1.通过句柄访问对象
如果使用句柄访问的话,Java堆中将会划分出一块内存来作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据的具体各自的地址信息。如图1所示。
2.通过直接指针访问对象
如果使用直接指针访问的话,Java堆对象的布局中就必须考虑如何放置访问类型数据的相关信息,reference中存储的直接就是对象地址,如图所示。
这两种对象访问方式各有优势,使用句柄来访问的最大好处就是reference中存储的是稳定句柄地址,在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中的实例数据指针,而reference本身不需要被修改。
使用直接指针来访问最大的好处就是速度更快,它节省了一次指针定位的时间开销,由于对象访问的在Java中非常频繁,因此这类开销积小成多也是一项非常可观的执行成本。从上一部分讲解的对象内存布局可以看出,就虚拟机HotSpot而言,它是使用第二种方式进行对象访问,但在整个软件开发的范围来看,各种语言、框架中使用句柄来访问的情况也十分常见
内存溢出
Java堆溢出
Java堆用于存储对象实例,只要不断地创建对象,并且保证GC Roots到对象之间有可达路径来避免垃圾回收机制清除这些对象,那么在对象数量到达最大堆的容量限制后就会产生内存溢出异常。
-XX:+HeapDumpOnOutOfMemoryError 可以让虚拟机在出现内存溢出异常时Dump出当前的内存堆栈转储快照以便事后进行分析。
1 2 3 4 5 6 7 8 9 10 11 12 | //VM Args: -Xms20m -Xmx20m -XX:+HeapDumpOnOutOfMemoryError //Java堆溢出异常测试 public class HeapOOM { static class OOMObject {} public static void main(String[] args) { List<OOMObject> list = new ArrayList<OOMObject>(); while (true) { list.add(new OOMObject()); } } } |
Exception in thread “main” java.lang.OutOfMemoryError: Java heap space
内存溢出 out of memory,是指程序在申请内存时,没有足够的内存空间供其使用,出现out of memory;比如申请了一个integer,但给它存了long才能存下的数,那就是内存溢出。
内存泄露 memory leak,是指程序在申请内存后,无法释放已申请的内存空间,一次内存泄露危害可以忽略,但内存泄露堆积后果很严重,无论多少内存,迟早会被占光。
处理方式:
1).内存泄露
如果是内存泄露,可用Eclipse Memory Analyzer工具查看泄露对象到GC Roots的引用链。于是就能找到泄露对象是通过怎样的路径与GC Roots相关联并导致垃圾收集器无法自动回收他们的。掌握了泄露对象的类型信息及GC Roots引用链的信息,就可以比较准确地定位出泄露代码的位置。
2).内存不泄露 也就是内存溢出
如果内存不泄露,也就是说,就是内存中的对象确实都还必须都活着,则
①.检查虚拟机的堆参数(-Xmx与-Xms),与机器物理内存对比看是否还可以调大。
JVM内存分配设置的参数有四个:
-Xmx Java Heap最大值,默认值为物理内存的1/4,最佳设值视物理内存大小及计算机内其他内存开销而定
-Xms Java Heap初始值,Server端JVM最好将-Xms和-Xmx设为相同值,开发测试机JVM可以保留默认值;
-Xmn Java Heap Young区大小,不熟悉最好保留默认值;
-Xss 每个线程的Stack大小,不熟悉最好保留默认值;
②.从代码上检查是否存在某些对象生命周期过长、持有状态时间过长的情况,尝试减少程序运行期的内存消耗。
虚拟机栈和本地方法栈溢出
由于在HotSpot虚拟机中并不区分虚拟机栈和本地方法栈,因此,对于HotSpot来说,虽然-Xoss参数(设置本地方法栈大小)存在,但实际上是无效的,栈容量只由-Xss参数设定。关于虚拟机栈和本地方法栈,在Java虚拟机规划中描述了两种异常:
①.如果线程请求的栈深度大于虚拟机所允许的最大尝试,将抛出抛出StackOverflowError异常
②.如果虚拟机在扩展栈时无法申请到足够的内存空间,则抛出OutOfMemoryError异常
虽然分了两种情况,其实存在互相重叠的地方:当栈空间无法继续分配时,到底是内存太小,还是已使用的栈空间太大,其本质只是对同一件事情的两种描述而已。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 | /** * VM Args: -Xss128k * * 1.使用-Xss参数减少栈内存容量。结果:抛出StackOverflowError, * 异常出现时输出的堆栈尝试相应缩小。 * 2.定义了大量的本地变量,增大此方法帧中本地变量表的长度。 * 结果:抛出StackOverflowError,异常出现时输出的堆栈尝试相应缩小。 */ public class JavaVMStackSOF { private int stackLength = 1; public void stackLeak() { stackLength++; stackLeak(); } public static void main(String[] args) throws Throwable { JavaVMStackSOF oom = new JavaVMStackSOF(); try { oom.stackLeak(); } catch (Throwable e){ System.out.println("stack length:" + oom.stackLength); throw e; } } } |
stack length:11411Exception in thread “main” java.lang.StackOverflowError
at com.changwen.javabase.JVM.OutOfMemoryError.JavaVMStackSOF.stackLeak(JavaVMStackSOF.java:12)
at com.changwen.javabase.JVM.OutOfMemoryError.JavaVMStackSOF.stackLeak(JavaVMStackSOF.java:13)
at com.changwen.javabase.JVM.OutOfMemoryError.JavaVMStackSOF.stackLeak(JavaVMStackSOF.java:13)…….(最后程序还是会停止的)
在单线程下,无论是由于栈帧太大还是虚拟机容量太小,当内存无法分配时,虚拟机都是抛出StackOverflowError异常。
如果测试时不限于单线程,通过不断地建立线程的方式倒是可以产生内存溢出异常。但是这样产生的内存溢出异常与栈空间是否足够大并不存在任何联系,准确地说,在这种情况下,为每个线程的栈分配的内存越大,反而越容易产生内存溢出异常。所以在多线程开发的应用时需要特别注意,如果出现StackOverflowError异常时有错误堆栈可以阅读,相对来说,比较容易找到错误问题所在。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 | /** * VM Args: -Xss2M(这时候不妨设置大些) * * 如果要尝试运行上面这段代码,记得要先保存当前的工作。 * 由于在Windows平台的虚拟机中,Java的线程是映射到操作系统的内核线程上的, * 因此上述代码执行时有较大的风险,可能会导致操作系统假死。 */ public class JavaVMStackOOM { private void dontStop() { while(true){} } public void stackLeakByThread() { while(true) { Thread thread = new Thread(new Runnable() { public void run() { dontStop(); } }); } } public static void main(String[] args) { JavaVMStackOOM oom = new JavaVMStackOOM(); oom.stackLeakByThread(); } } |
Exception in thread “main” java.lang.OutOfMemoryError: unable to create new native method
方法区和运行时常量池溢出
由于运行时常量池是方法区的一部分,因此这两个区域的溢出测试就放在一起进行。由于JDK1.7开始逐步“去永久代”,所有下面的测试一定要注意JDK的版本。
String.intern()是一个Native方法,它的作用:如果字符串常量池中已经包含一个等于此String对象的字符串,则返回代表池中这个字符串的String对象;否则,将此String对象包含的字符串添加到常量池中,并返回此String对象的引用。
在JDK1.6及之前的版本中,由于常量池分配在永久代内,我们可以通过-XX:PermSize和MaxPermSize限制方法区大小,从而间接限制其中常量池的容量。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | /** * VM Args: -XX:PermSize=10M -XX:MaxPermSize=10M * @version jdk1.6 */ public class RuntimeConstantPoolOOM { public static void main(String[] args) { //使用List保持着常量池引用,避免Full GC回收常量池行为 List<String> list = new ArrayList<String>(); //10M的PermSize在integer范围内足够产生OOM了 int i = 0; while (true) { list.add(String.valueOf(i++).intern()); } } } |
Exception in thread “main” java.lang.OutOfMemoryError: PermGen space
at java.lang.String.intern(Native Method)
从结果上,运行时常量池溢出,会有“PermGen space”,说明运行时常量池属于方法区的(HotSpot虚拟机中的永久代)一部分
而使用JDK1.7运行这段程序就不会得到相同的结果,while循环将一直进行下去, 因为方法区已被移出。
本地直接内存溢出
虽然使用DirectByteBuffer分配内存也会抛出内存溢出异常,但它抛出异常时并没有真正向操作系统申请分配内存,而是通过计算得知内存无法分配,于是手动抛出异常,真正申请分配内存的方法是 unsafe.allocateMemory().
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | /** * VM Args -Xmx20M -XX:MaxDirectMemorySize=10M */ public class DirectMemoryOOM { private static final int _1MB = 1024 * 1024; public static void main(String[] args) throws IllegalAccessException { //通过反射获取实例 Field unsafeField = Unsafe.class.getDeclaredFields()[0]; unsafeField.setAccessible(true); Unsafe unsafe = (Unsafe) unsafeField.get(null); while (true) { //真正申请分配内存的方法 unsafe.allocateMemory(_1MB); } } } |
Exception in thread “main” java.lang.OutOfMemoryError
at sun.misc.Unsafe.allocateMemory(Native Method)
由DirectMemory导致的内存溢出,一个明显的特征是在Heap Dump文件中不会看见明显的异常,但如果发现OOM之Dump文件很小,而程序又直接使用了NIO,那就可以考虑一下是不是这方面的原因了。
补充知识:堆栈具体内存分配
1.Java的内存机制
Java 把内存划分成两种:一种是栈内存,另一种是堆内存。在函数中定义的一些基本类型的变量和对象的引用变量都是在函数的栈内存中分配,当在一段代码块定义一个变量时,Java 就在栈中为这个变量分配内存空间,当超过变量的作用域后(比如,在函数A中调用函数B,在函数B中定义变量a,变量a的作用域只是函数B,在函数B运行完以后,变量a会自动被销毁。分配给它的内存会被回收),Java 会自动释放掉为该变量分配的内存空间,该内存空间可以立即被另作它用。
堆内存用来存放由 new 创建的对象和数组,在堆中分配的内存,由 Java 虚拟机的自动垃圾回收器来管理。在堆中产生了一个数组或者对象之后,还可以在栈中定义一个特殊的变量,让栈中的这个变量的取值等于数组或对象在堆内存中的首地址,栈中的这个变量就成了数组或对象的引用变量,以后就可以在程序中使用栈中的引用变量来访问堆中的数组或者对象,引用变量就相当于是为数组或者对象起的一个名称。引用变量是普通的变量,定义时在栈中分配,引用变量在程序运行到其作用域之外后被释放。而数组和对象本身在堆中分配,即使程序运行到使用 new 产生数组或者对象的语句所在的代码块之外,数组和对象本身占据的内存不会被释放,数组和对象在没有引用变量指向它的时候,才变为垃圾,不能在被使用,但仍然占据内存空间不放,在随后的一个不确定的时间被垃圾回收器收走(释放掉)。这也是 Java 比较占内存的原因,实际上,栈中的变量指向堆内存中的变量,这就是 Java 中的指针!
对象类:
1 2 3 4 5 6 7 | public class Person { String name; int age; public void tell() { System.out.println("姓名:"+name+",年龄: "+age); } } |
1.单个对象创建
1 2 3 4 5 | public class Test01 { public static void main(String[] args) { Person per = new Person(); } } |
在上述程序中实例化了一个对象per,在实例化对象的过程中需要在内存中开辟空间,这其中就包括栈内存和对内存。具体的内存分配如下图所示:
我们可以从上图中发现,对象名称per被保存在了栈内存中(更加准确的说法是,在栈内存中保存的是堆内存空间的访问地址),而对象的具体内容,比如属性name和age,被保存在了堆内存中。因为per对象只是被实例化,还没有具体被赋值,所以都是默认值。字符串的默认值为null,int类型的默认值为0。前面也已经提到,堆内存空间必须使用new关键字才能开辟。
2、多个对象创建
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | public class Test02 { public static void main(String[] args) { Person per1=new Person(); Person per2=new Person(); per1.name="张三"; per1.age=30; per2.name="李四"; per2.age=33; per1.tell(); per2.tell(); } } |
关键概念:类跟数组一样,都是属于引用类型,引用类型就是指一个堆内存可以同时被多个栈内存指向。下面来看一下引用传递的简单实例。
3、对象引用传递1
1 2 3 4 5 6 7 8 9 10 11 12 13 | public class Test03 { public static void main(String[] args) { Person per1=new Person(); Person per2=per1; per1.name="张三"; per1.age=30; per2.age=33; per1.tell(); per2.tell(); } } |
姓名:张三,年龄:33
姓名:张三,年龄:33
从程序的运行结果可以发现,两个对象输出的内容一样,实际上所谓的引用传递,就是将一个堆内存空间的使用权交个多个栈内存空间,每个栈内存空间都可以修改堆内存空间的内容,此程序的内存分配图如下所示:
注意:上述实例中对象per2没有堆内存空间,这是因为对象per2只进行了声明操作,也没有进行实例化操作。只有使用new关键字实例化以后才会有对内存空间。
4、对象引用传递2
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | public class Test04 { public static void main(String[] args) { Person per1=new Person(); Person per2=new Person(); per1.name="张三"; per1.age=30; per2.name="李四"; per2.age=33; per2=per1; per1.tell(); per2.tell(); } } |
姓名:张三,年龄:30
姓名:张三,年龄:30
从程序的输出结果可以发现可Test03一样。不过内存分配发生了一些变化,具体如下所示:
注意点:
1.Java本身提供垃圾收集机制(Garbage Collection,GC),会不定期施放不用的内存空间,只要对象不用了,就会等待GC释放空间,如上面堆内存中的name=”李四”;age=33。
2.一个栈内存只能指向一个对内存空间,如果要想再指向其他的堆内存空间,则必须先断开已有的指向才能分配新的指向。