欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

深入理解Java虚拟机-Java内存区域与内存溢出异常(二)

程序员文章站 2022-07-15 14:46:42
...

深入理解Java虚拟机第二版学习笔记。


第二章 Java内存区域与内存溢出异常

2.2 运行时数据区域

       Java虚拟机在执行java程序的过程中会把它所管理的内存划分为若干不同的数据区域。

深入理解Java虚拟机-Java内存区域与内存溢出异常(二)


2.2.1 程序计数器

       程序计数器(program Counter Register)是一块较小的内存空间,可以看做是当前线程所执行的字节码的行号指示器。字节码解释器工作时是通过改变这个计数器的值来选取下一条要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。

       由于java虚拟机的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的,在任何一个确定的时刻,一个处理器(对于多核处理器就是一个内核)都只会执行一条线程中的指令。因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,个线程之间计数器互不影响,独立存储,我们称这类区域为“线程私有”的内存。

      

2.2.2 java虚拟机栈

       与程序计数器一样,Java虚拟机栈也是线程私有的,它的生命周期与线程相同。虚拟机栈描述的是java方法执行的内存模型:每个方法在执行的同时都会创建一个栈帧(stackframe)用于存储局部变量表、操作数栈、动态链接、方法出口等信息。

       通常把java内存区分为堆内存(heap)、栈内存(stack),这种分法较笼统,java内存区域的划分远比这复杂。其中的“栈”就是指的虚拟机栈,或者说虚拟机栈中局部变量表部分。

       局部变量表存放了编译期可知的各种基本数据类型(boolean,byte,char,short,int,float,long,double),对象引用(reference类型,不等于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他与此对象相关的位置),return address类型。

       这个区域有两种异常情况:如果线程请求的栈深度大于虚拟机允许的深度,抛出*Error异常;如果虚拟机动态扩展时无法申请到足够内存,抛出OutofmemoryError异常。

      

       2.2.3本地方法栈

       本地方法栈(Native method stack)与虚拟机栈的作用类似,区别是虚拟机栈为虚拟机执行java方法(也就是字节码)服务,本地方法栈则为虚拟机使用到的native方法服务。

 

       2.2.4Java堆

       Java堆(heap)是虚拟机管理的内存中最大的一块。是被所有线程共享的一块内存区域,在虚拟机启动时创建。这块内存存放对象的实例,几乎所有的对象实例都在这里分配内存。

       Java堆可以处在物理上不连续的内存空间中,只要逻辑上是连续的即可。如果在堆中没有内存可完成实例分配,且堆也无法在扩展时,会抛出outofMemoryError异常。

 

       2.2.5方法区

       方法区跟java堆一样,是个线程共享的内存区域,用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。

 

       2.2.6运行时常量池

       运行时常量池(runtime constant pool)是方法区的一部分。Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池,用于存放编译期生成的各种字面量和符号引用,这部分内容在类加载后进入方法区的运行时常量池中存放。

--------

 堆很灵活,但是不安全。对于对象,要动态地创建、销毁,对象的销毁可能在任意时间,即使后面创建的对象还在,前面创建的对象也可能被销毁,所以Java中用堆来存储对象。
而一旦堆中的对象被销毁,再继续引用这个对象的话,就会出现NullPointerException,这就是堆的缺点——错误的引用逻辑只有在运行时才会被发现。

栈不灵活,但是很严格,是安全的,易于管理。因为后进先出的原则,只要上面的引用没有销毁,下面引用就一定还在,所以,在栈中,上面的引用永远可以通过下面的引用来查找对象,同时如果确认某一区间的内容会一起存在、一起销毁,也可以上下互相引用。在大部分程序中,都是先定义的变量、引用先进栈,后定义的后进栈,同时,区块内部的变量、引用在进入区块时压栈,区块结束时出栈,
这种机制,就是各种编程语言的作用域的概念,同时这也是栈的优点——错误的引用逻辑在编译时就可以被发现。

总之,就是变量和对象的引用存储在栈区中,而对象在存储在堆中

 

2.3 hosspot虚拟机

探讨Hotspot虚拟机在java堆中对象分配、布局、访问的全过程。

 

2.3.1 对象创建

虚拟机在遇到一条new指令时,首先去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析、初始化过。如果没有,必须先执行相应的类加载过程。

在类加载检查通过后,接下来虚拟机为新生对象分配内存。对象所需内存大小在类加载完成后可确定,为对象分配空间的任务等同于把一块确定大小的内存从java堆中划分出来。假设Java堆中内存是规整的,用过的内存在一边,空闲的内存在另一边,中间放着一个指针作为分界点的指示器,那么所分配内存就是仅仅把那个指针向空闲空间那边挪动一段与对象大小相等的距离,这种分配方式成为“指针碰撞”(Bump the pointer)。如果java堆中内存不规整,已使用内存和空闲内存相互交错,就没办法简单地进行指针碰撞了,虚拟机必须维护一个列表,记录那些内存块是可用的,在分配时从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录,这种分配方式成为“空闲列表”(Free list)。选择哪种分配方式由Java堆是否规整决定,java堆是否规整由所采用的垃圾收集器是否带有压缩整理功能决定。

       除如何规划可用空间外,另一个需要考虑的问题是对象创建在虚拟机中是非常频繁的行为,即使仅仅修改一个指针所指向的位置,在并发情况下也不是线程安全的,可能出现正在给对象A分配内存,指针还没来得及修改,对象B又同时使用了原来的指针来分配内存。解决这个问题有两种方案,一是对分配内存空间的动作进行同步处理;另一种是把内存分配的动作按照线程划分在不同的空间中进行,即每个线程在Java堆中预先分配一小块内存,成为本地线程分配缓冲(thread local allocation buffer)TLAB。哪个线程要分配内存,就在哪个线程的本地线程分配缓冲上分配,只有这块缓冲用完要并分配新的缓冲时,才需要同步锁定。虚拟机可以通过-XX:+/-UseTLAB 参数设定是否使用线程缓冲。

       内存分配完成后,虚拟机将分配到的内存空间都初始化为零(不包括对象头)。接着对对象进行必要的设置,如对象是那个类的实例、如何能找到类的元数据信息、对象的哈希码、对象的GC代年龄等信息。这些信息存放在对象的对象头(object header)之中。

       上面的工作完成后,从虚拟机的视角看,一个新的对象已经产生了,但是从Java程序的视角看,对象创建才刚刚开始<init>方法还没执行,所有的字段都还是零。所以一般来说(由字节码中是否跟随invokespecial指令所决定),执行new指令之后会接着执行<init>方法,把对象按照程序员的意愿初始化,这样一个真正的可用对象才算完全生成出来。

 

       2.3.2对象的内存布局

       在HotSpot虚拟机中,对象在内存中存储的分布,分为3块区域:对象头(header),实例数据(Instance data)和对齐填充(padding)。

       对象头包括两部分信息,一部分用于存储对象自身的运行时数据,如哈希码、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等;另一部分是类型指针,对象指向他的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。

       实例数据部分是对象真正存储的有效信息,也是在程序代码中所定义的各种类型的字段内容。

       对齐填充不是必然存在的,也没特别的含义,起占位符的作用。

 

       2.3.3对象的访问定位

       Java程序需要通过栈上的reference数据操作堆上的具体对象。Reference类型在Java虚拟机中只规定了一个指向对象的应用,没有定义这个引用应该通过何种方式去定位、访问堆中的对象的具体位置。主流的访问方式有使用句柄、直接指针两种。

       如果使用句柄访问,java堆中将会划出一块内存来作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信息。

深入理解Java虚拟机-Java内存区域与内存溢出异常(二)

如果使用指针访问,Java堆对象的布局中就必须考虑如何放置访问类型数据的相关信息,而reference中存储的直接就是对象地址。

深入理解Java虚拟机-Java内存区域与内存溢出异常(二)

使用句柄访问的好处是reference中存储的是稳定的句柄地址,在对象被移动(GC时移动对象是非常普遍的行为)时只会改变句柄中的实例数据指针,而reference本身不需要修改。

       使用直接指针访问的好处是速度更快,节省了一次指针定位的时间开销,由于对象的访问在Java中非常频繁,这类开销积少成多后也是一项可观的执行成本。

 

       2.4OutOfMemoryError异常

以下代码段设置了不同的虚拟机启动参数(VM Args后面的参数),这些参数直接影响实验结果。

      

       2.4.1Java堆溢出

       Java堆用于存储对象实例,只要不断的创建对象,并且保证GC Roots到对象之间有可达路径来避免垃圾回收机制清除这些对象,那么在对象数量到达最大堆的容量限制后就会产生内存溢出。

       限制Java堆的大小是20M,不可扩展(将堆的最小值-Xms和最大值-Xmx参数设置为一样即可避免堆自动扩展),参数-XX:+HeapDumpOnOutOfMemoryError可以让虚拟机在出现内存溢出异常时dump出当前的内存堆转储快照以便事后分析。

代码清单:

VM Args: -Xms20M -Xmx20M -XX:+HeapDumpOnOutOfMemoryError
public class HeapOOM {
	static class OOMObject{		
	}	
	public HeapOOM() {
	}
	public static void main(String[] args) {
		//用list保持着常量池的引用,避免FullGC时回收常量池行为。
		List<OOMObject> list= new ArrayList<OOMObject>();
		int index =0;
		while(true){
			index++;
			list.add(new OOMObject());
		}
	}
}

运行结果:

java.lang.OutOfMemoryError: Javaheap space

Dumping heap to java_pid1764.hprof...

Heap dump file created [27996477bytes in 0.228 secs]

Exception in thread "main"java.lang.OutOfMemoryError: Java heap space

    atjava.util.Arrays.copyOf(Unknown Source)

    atjava.util.Arrays.copyOf(Unknown Source)

    atjava.util.ArrayList.grow(Unknown Source)

    atjava.util.ArrayList.ensureExplicitCapacity(Unknown Source)

    atjava.util.ArrayList.ensureCapacityInternal(Unknown Source)

    atjava.util.ArrayList.add(Unknown Source)

    atHeapOOM.main(HeapOOM.java:15)



2.4.2 虚拟机栈和本地方法溢出

    栈容量由 –Xss 参数设定。

    如果线程请求的栈深度大于虚拟机所允许的最大深度,抛出*Error异常。

    如果虚拟机在扩展栈时无法申请到足够的内存空间,抛出OutOfMemoryError异常。这两种情况,可能本质上是同一件事情。

    使用 –Xss 参数减少栈内存容量,定义大量本地变量,增大此方法帧中本地变量表的长度。结果抛出*Error异常。

代码清单:


public class VM*Error {
	private int stackLength =1;

	public VM*Error() {
	}
	
	public void stackLeak(){
		stackLength++;
		if(stackLength < 2287){
			//stackLeak();//这里就不会抛出栈溢出error,小于栈深度。
		}	
		stackLeak();
	}

	public static void main(String[] args) {
		VM*Error sof = new VM*Error();
		try{
			sof.stackLeak();
		}catch (Throwable e){
			System.out.println("stack length:"+sof.stackLength);
			throw e;
		}
	}
}


运行结果:

stack length:2264

Exception in thread "main"java.lang.*Error

    atVM*Error.stackLeak(VM*Error.java:9)

    atVM*Error.stackLeak(VM*Error.java:13)

    atVM*Error.stackLeak(VM*Error.java:13)

    atVM*Error.stackLeak(VM*Error.java:13)

……


运行结果表明,单线程下,无论是栈帧太大,还是虚拟机栈容量太小,当内存无法分配时,抛出的都是*Error。

   

    如果不限于单线程,不断地建立线程的方式倒是可以产生内存溢出异常,但是这样的内存溢出跟栈空间是否足够大并不存在任何联系,在这种情况下,为每个线程的栈分配的内存越大,反而越容易产生内存溢出。

    原因是操作系统分配给每个进程的内存是有限制的。譬如32位windows限制时2GB。虚拟机提供了参数控制Java堆和方法区的这两部分内存的最大值。剩余内存是2GB减去Xmx(最大堆容量),在减去MaxpermSize(最大方法区容量)。剩下的内存就由虚拟机栈和本地方法栈瓜分。每个线程分配到栈容量越大,可以建立的线程数量自然就越少,建立线程时就越容易吧剩下的内存耗尽。

    如果是建立过多线程导致的内存溢出,在不能减少线程数或者更换64位虚拟机的情况下,只能通过减少最大堆和减少栈容量来换取更多的线程。

创建多个线程导致的内存溢出代码:

VM args:-Xss64k
public class VM*Error {
	private int stackLength =1;

	public VM*Error() {
	}
	private void dontStop(){
		while(true){
			System.out.println("dontStop()");
		}
	}
	public void stackLeakByThread(){
		while(true){
			Thread thread = new Thread(new Runnable(){
				@Override
				public void run(){
					dontStop();
				}
			});	
			thread.start();
		}
	}
	public static void main(String[] args) {
		VM*Error sof = new VM*Error();
		sof.stackLeakByThread();
	}
}

运行结果:

# There is insufficientmemory for the Java Runtime Environment tocontinue.

# Nativememory allocation (malloc) failedto allocate 32756bytes for ChunkPool::allocate

# An error report file with more informationis saved as:

#D:\workspace\JavaVM-*\hs_err_pid16788.log