深入理解java虚拟机(一):Java 内存区域与内存溢出异常
参考博客
http://blog.csdn.net/dongyuxu342719/article/details/78809049
一、运行时数据区域
1、线程隔离的数据区
- 程序计数器(Program Counter Register)
- 当前线程所执行代码的行号指示器
- 每个线程有一个独立的程序计数器
- 虚拟机栈(VM Stack)
- 线程私有,生命周期等同于线程
- 每个方法在执行的同时会创建一个帧栈
- 储存局部变量表、操作数栈、动态链接、方法出口等信息。最重要的是局部变量表,也是大家常常讨论的“栈”空间。
- 本地方法栈(Native Method Stack)
- 本地方法栈类似于虚拟机栈,只不过虚拟机栈为虚拟机执行的java方法服务,本地方法栈为虚拟机使用到的Native方法服务
Notice:
- HotPot 的实现合并了本地方法栈和虚拟机栈
2、由线程共享的数据区
- 堆(Heap)
- 线程共享,虚拟机不关闭生命就不会结束。因此需要对此空间进行管理,是垃圾收集机制(GC)的主要区域。
- 绝大部分对象实例及数组都要在堆上分配内存
- 方法区(Method Area)
- 线程共享,用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。
- 运行时常量池(Runtime Constant Pool)
- 方法区的一部分,用于存放编译器生成的各种字面量和符号引用
- 直接内存(Direct Memory)
- 直接分配堆外内存,不属于虚拟机运行时数据区。
- 避免在Java堆和Native堆中来回复制数据,在某些场景能显著提高性能。如NIO中的通道(Channel)与缓冲区(Buffer)。
Notice:
- 为什么GC的主要区域是Heap:动态创建的对象都在Heap上为其实例分配内存。Stack在方法和局部代码调用完成后释放帧栈空间,但Heap生命周期与虚拟机相同,内存不能自动释放。C/C++中程序员往往在代码中显示调用 free/delete 释放堆中对象的空间,操作繁琐,且容易发生内存泄漏。java 虚拟机的自动垃圾收集机制则能自动释放不需要用到的对象实例,实现堆内存的自动管理。
- 已加载类的基本信息和方法储存在方法区
- 常量(final)和静态变量(static)储存在方法区。问题:局部变量声明final,储存在哪个区域(堆、方法区、虚拟机栈)。
- Object 和 Array 的实例在堆中分配内存,并在相应的位置(如栈)创建引用。没有有效引用将被GC。
- HotPot 的实现把方法区合并到了堆的永久代区(Permanent Generation)。
- 如何实现方法区是虚拟机的技术实现细节,但是使用永久代实现方法区现在看来并不是一个好主意,因为这样更容易遇到内存泄漏问题。JDK 1.7 的 HotPot 中,已经把原本放在永久代的字符串常量池移出。
二、HotPot 虚拟机对象探秘
1、对象的内存布局
- 对象头(Header)
- (Mark Word)对象自身的运行时数据:如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等。
- 类型指针:对象指向元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。数组还会记录数组的长度。
- 实例数据(Instance Data)
1.各种成员变量,包括父类继承的和子类定义的。 - 对齐填充(Padding)
- HotPot VM 要求对象起始地址必须是8字节的整数倍
- 对象实例数据没有对齐时,用对齐填充补全。
2、对象的访问定位
- 句柄
- 如果使用句柄访问,Java堆中将会划分出一块内存来作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了对象实例数据和类型数据各自的具体地址信息。
- 句柄来访问的最大好处是reference中存储的是稳定的句柄地址,在对象被移动(垃圾收集时移动对象是很普遍的行为)时,只会修改句柄中的实例数据的指针,而reference本身不需要修改。
- 直接指针
- 如果使用直接指针访问,那么Java堆对象的布局中就必须考虑如何存放访问类型数据的相关信息,而reference中存储的直接就是对象地址。
- 使用直接指针访问方式的最大好处就是速度更快,它节省了一次指针定位的时间开销,由于对象的访问在Java中非常频繁,因此这类开销积少成多也是一项非常可观的执行成本。
- 就HotSpot虚拟机而言,它是使用直接指针进行对象访问的。
三、堆栈溢出异常
1、对应参数:
- -Xms 堆的最小值
- -Xmx 堆的最大值
- -Xss 栈容量
- -Xoss 本地方法栈(HotPot 无效,因为没有设置单独的本地方法栈)
- -XX:PermSize=10M -XX:MaxPermSize=10M 永久代
- -XX:MaxDirectMemorySize=10M 直接内存
- -XX:+HeapDumpOnOutOfMemoryError 让虚拟机在出现内存溢出异常时Dump出当前的内存堆转储快照以便事后分析。
2、Java 堆溢出
-
java.lang.OutOfMemoryError:Javaheap space
-
要解决这个区域的异常,一般的手段是先通过内存映像分析工具(如Eclipse Memory Analyzer)堆Dump出来的堆转储快照进行分析,重点是确认内存中的对象是否是必要的,也就是要分清楚到底是内存泄露还是内存溢出。
-
如果是内存泄露,可进一步通过工具查看泄露对象到GCRoots的引用链。于是就能找到泄露对象是通过怎样的路径与GCRoots相关联并导致垃圾收集器无法对他们进行回收的。掌握了泄露对象的类型信息及GC Roots引用链信息,就可以比较准确的定位到泄露代码的位置。
-
如果不存在泄露,换句话说,就是内存中的对象确实都是必须要存活的,那就应当检查虚拟机的堆参数(-Xms和-Xmx),与机器的物理内存对比看是否还可以调大,从代码上检查是否存在某些对象生命周期过长,持有状态过长的情况,尝试减少程序运行期的内存消耗。
3、虚拟机栈和本地方法栈溢出
-
java.lang.StackOverflowError :某线程中线程请求的栈深度大于虚拟机所允许的最大深度
-
java.lang.OutOfMemoryError:unable to create new native thread:申请多线程时栈容量不够
-
因为操作系统分配给每个进程的内存是有限制的,比如32位Windows系统限制为2GB。虚拟机提供了参数来限制Java堆和方法区这两部分的最大值。剩余的内存为2GB(操作系统限制)减去Xmx(最大堆容量),再减去MaxPermSize(最大方法区容量),程序计数器消耗内存很小,可以忽略掉。如果虚拟机本身消耗的内存不计算在内,剩余的内存就是由虚拟机栈和本地方法栈瓜分掉了,每个线程分配到的栈容量越大,可以建立的线程数量自然就越少,建立线程时就越容易把内存耗尽。
-
所以,如果是建立过多线程导致的内存溢出,在不能减少线程数量或更换64位操作系统的情况下,可以通过减小最大堆和减小栈容量来换取更多的线程。如果没有这方面的处理经验,这种通过“减少内存”的手段来解决内存溢出的方式会很难想到。
4、方法区和运行时常量池溢出
-
java.lang.OutOfMemoryError:PermGen space
-
HotPot中方法区和常量池都存放在永久代。
-
运行时常量池:字符串和整型等常量池数据的存放方式在 JDK 1.7 中有一定的调整,因此表现与 JDK 1.6 会有所不同。如 intern()方法。
-
方法区溢出:方法区溢出也是一种常见的内存溢出异常,一个类要被垃圾回收器回收的条件是非常苛刻的。在经常生成大量Class的应用中,需要特别主要类的回收情况。比如:程序中使用了CGLib字节码增强和动态语言(Spring、Hibernate等主流框架)、大量JSP或动态生成JSP文件的应用(JSP第一次运行时需要编译为Java类),基于OSGi的应用(即使是同一个类文件被不同的类加载器加载也会被视为不同的类)等。
5、本机直接内存溢出
-
java.lang.OutOfMemoryError
-
DirectMemory容量可通过-XX:MaxDirectMemorySize指定,如果不指定,则默认与Java堆最大值(-Xmx指定)一样,可以直接通过反射获取Unsafe实例进行内存分配(Unsafe类的getUnsa()方法限制了只有引导类加载器才会返回实例,也就是设计者希望只有rt.jar中的类才能使用Unsafe的功能)。
-
由DirectMemory导致的内存溢出,一个明显的特征是在Heap Dump文件中不会看见明显的异常,如果发现OOM之后Dump文件很小,而程序中又直接或间接的使用了NIO,那就可以考虑检查一下是不是这方面的原因。
四、Intern() 方法
不必太纠结于 intern() 方法,这个例子只是告诉我们,虚拟机底层的不同实现,会影响某些代码的结果。高版本的虚拟机对低版本做了一些优化,效率更高。
public class RunTimeConstantPool {
public static void main(String [] args){
String str1=new StringBuilder("计算机").append("软件").toString();
System.out.println(str1.intern()==str1);
String str2=new StringBuilder("ja").append("va").toString();
System.out.println(str2.intern()==str2);
}
}
这段代码在JDK1.6中运行,会得到两个false,而在JDK1.7及以后的版本运行,会得到一个true和一个false。 产生差异的原因是:在JDK1.6中intern()会把首次出现的字符串复制到永久代中,返回的也是永久代中这个字符串实例的引用,而由StringBuilder创建的字符串实例在堆中,所以必然不是同一个引用,将返回false。而在JDK1.7中的intern()实现不会再复制实例,只是在常量池中记录首次出现的实例引用,因此intern()返回的引用和StringBuilder创建的那个字符串是同一个。对str2比较返回false是因为”java”这个字符串在执行StringBuilder.toString()之前已经出现过,字符串常量池中指向的是第一次出现的引用,所以和str2不相同。
JDK 1.6:intern()
image- new str1:对应步骤1,在堆中为字符串分配内存,并在栈中创建引用
- str1.intern():对应步骤2,在JDK1.6中intern()会把首次出现的字符串复制到永久代中(从实例2 copy 实例3),返回的也是永久代中这个字符串(str.intern()返回实例3)。
- new str2:对应步骤3,在堆中为字符串分配内存,并在栈中创建引用
- str2.intern():对应步骤4,由于实例4不是首次出现的”java”字符串对象,因此永久代的实例5是从首次出现的实例1复制而来,str2.intern() 返回实例5。
JDK 1.7:intern()
image- new str1:对应步骤1,在堆中为字符串分配内存,并在栈中创建引用
- str1.intern():对应步骤2,JDK1.7中的intern()实现不会再复制实例,只是在常量池中记录首次出现的实例引用,如图在永久代记录实例2的引用,str1.intern() 返回引用3
- new str2:对应步骤3,在堆中为字符串分配内存,并在栈中创建引用
- str2.intern():对应步骤4,由于实例4不是首次出现的”java”字符串对象,因此永久代记录实例1的引用,str2.intern() 返回引用4。