虚拟机学习之一:java内存区域与内存溢出异常

1.运行时数据区域

java虚拟机在执行java程序的过程中会把它所管理的内存划分为若干个不同的数据区域。这些区域都有各自的用途和创建、销毁时间,有的区域伴随虚拟机进程的启动而存在,有些区域则依赖用户线程的启动和结束而建立和销毁。

1.1程序计数器

程序计数器(Program Counter Register)是一块较小的内存空间,它可以看成是当前线程所执行的字节码的行号指示器。在虚拟机的概念模型里,字节码解释器工作就是通过改变这个计数器的值来选取下一条要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等都依赖与这个计数器完成。

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

如果线程正在执行的是java方法,这个计数器记录的就是正在执行的虚拟机字节码指令的地址。如果线程正在执行的是Native方法,计数器值为空。此区域是java虚拟机规范中唯一一个没有规定任何“OutOfMemoryError”情况的区域。

1.2java虚拟机栈

与程序计数器一样,java虚拟机栈也是线程私有的,他的声明周期与线程相同。虚拟机栈描述的是java方法执行的内存模型:每个方法执行的同时都会创建一个栈帧用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每个方法从调用到执行完成的过程,都对应着一个栈帧在虚拟机中从入栈到出栈的过程。

局部变量表存放了编译期可知的各种基本数据类型(boolean、byte、char、short、int、float、long、double)、对象引用和returnAddress类型。其中long和double数据会占用两个局部变量空间,其他的占用一个局部变量空间。局部变量表所需的内存空间在编译期间完成分配,当进入一个方法时需要在栈中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小。

在java虚拟机规范中对虚拟机栈规定了两种异常状况:如果线程请求深度大于虚拟机运行的请求深度,抛出StackOverflowError异常;如果虚拟栈可以扩展,当扩展时无法申请到足够的内存空间,抛出OutOfMemoryError异常。

1.3本地方法栈

本地方法栈与java虚拟栈非常相似,他们之间的区别只不过是java虚拟机栈是为虚拟机执行java方法服务的,而本地方法栈是为虚拟机执行Native方法服务的。

1.4java堆

对于大多数应用来说,java堆是java虚拟机所管理的内存中最大的一块。java堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。次内存区域的唯一目的就是存放对象实例,几乎所有的对象实例都在这里分片内存。

java堆也是垃圾收集器管理的主要内存区域,由于现在的收集器基本都采用分代收集算法,所以java堆可以再划分为新生代和老年代;再细致一点可以划分为Eden空间、From Survivor空间、To Survivor空间等。

在实现时java堆既可以是固定大小的内存区域,也可以是可扩展的,当前主流的虚拟机都是可扩展的(通过-Xmx和-Xms控制)。如果堆中内存用完,且堆也无法再扩展时,将会抛出OutOfMemoryError异常。

1.5方法区

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

不同的虚拟机在这块的实现不一样。

在java虚拟机规范的规定,当方法区无法满足内存分配需求时,将抛出OutOfMemoryError异常。

1.6运行时常量池

运行时常量池是方法区的一部分。主要用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池中存放。java语言并不要求常量一定只有在编译期才能产生,也就是说并非预置入Class文件中的常量池内容才能进入方法区运行时常量池,运行期间也可能将新的常量放入池中。这种特性被开发人员利用的比较多的是String的intern()方法。

当运行时常量池在无法申请到内存时也会抛出OutOfMemoryError异常。

1.7直接内存

直接内存(Direct Memory)并不是虚拟机运行时数据区的一部分,也不是java虚拟机规范中定义的内存区域。但是这块内存也被频繁的使用,而且也可能导致OutOfMemoryError异常出现。

被使用的例子:NIO类引入了一种基于通道与缓冲区的I/O方式。它可以使用Native函数库直接分配堆外内存,然后通过存储在java堆中的一个DirectByteBuffer对象作为这块内存的引用进行操作。这样就避免了java堆和Native堆之间来回复制数据。

2.java中对象的创建和使用

以HotSpot虚拟机为例介绍java堆内存对象的创建、内存布局以及访问定位。

2.1对象的创建

在java语言中对象的创建通常是通过new关键字进行创建,但在虚拟机中时一个什么过程?

1.虚拟机接受到new指令时,首先检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并检查这个符号引用代表的类是否已被加载、解析和初始化。(如果没有先进行类加载)

2.虚拟机将为新对象分配内存,在类加载之后已经确定对象需要的内存大小。这个过程等同于从堆内存中为对象划分出一块确定大小的内存空间。

分配方式

指针碰撞:如果java堆中内存时绝对规整的,所有用过的内存存放在一边,空闲内存在另一边,中间放着一个指针作为分界点指示器。那分配内存就是把指针向空闲内存方向移动对象大小相等的距离。

空闲列表:如果java堆中内存并不规整,虚拟机就必须维护一个列表,在列表上记录那些内存时可用的,在分配的时候就从列表中找一块足够大的空间分给对象实例,并更新列表上的记录。

分配时线程安全问题也有两种方案

一种是在分配内存空间的动作进行同步处理,实际上虚拟机采用CAS配上失败重试的方式保证更新操作的原子性,

另一种是为每个线程预先在java堆中分配一小块内存空间(线程分配缓冲)哪个线程需要分配内存就在该线程事先分好的内存空间进行。

3.内存分配完成之后,虚拟机需要将分配到的内存空间都初始化为零值。

4.虚拟机对对象进行必要的设置,会在对象头中设置例如该对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的GC分代年龄等信息。

5.对象进行初始化,一般会执行<init>方法按照程序员的意愿为对象初始化。这样一个真正可用的对象才算创建出来。

2.2对象的内存布局

在HotSpot虚拟机中对象在内存中的布局可以划分为3块区域:对象头、实例数据、对齐填充。

对象头:对象头又分为两部分信息,第一部分存储对象自身运行时数据如哈希码、GC分代年龄、所状态标志、线程所持有的锁、偏向线程ID、偏向时间戳等;第二部分是类型指针,对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。(不是所有虚拟机实现都需要保留类型指针,当然如果是数组类型在对象头中还要有一块用于存储数组长度)。

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

对齐填充:这部分并不是必然存在的,因为HotSpot虚拟机要去对象起始地址必须是8字节的整数倍,换句话说也就是对象大小必须是8字节的整数倍,而对象头部分正好是8字节的倍数,因此当对象实例数据内容没有对齐时,就需要通过对齐填充来补全。

2.3对象的访问定位

建立对象是为了使用对象,java程序需要通过栈上的reference数据来操作堆上具体对象。对象的访问方式根据虚拟机实现主要有两种:使用句柄访问和指针访问。

句柄访问:如果使用句柄访问那么java堆中就必须划分出一块内存来作为句柄池,reference中存储的就是句柄池中对象句柄的地址。而句柄中就要保存对象实例数据地址和对象类型数据地址。

指针访问:如果使用直接指针访问,在java堆中对象的内存布局就要考虑如何放置对象类型数据的相关信息。

使用句柄的优势就是reference中存储的是稳定的句柄地址,在对象被移动是只会改变句柄中实例数据指针,而reference本身不需要修改。

使用直接指针访问方式的好处就是速度更快,它节省了一次指针定位的时间开销。

3.实战:OutOfMemoryError异常

3.1java堆溢出

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

我们可以通过实例来演示:首先限制java堆内存大小,不可扩展然后不停创建对象。

虚拟机运行参数配置:

Dfile.encoding=UTF-8 -verbose.gc -Xms20m -Xmx20m -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=D:\data -XX:SurvivorRatio=8

代码:

public class VmTest {

	static class OOMObject{
		
	}
	
	public static void main(String[] args) {
		List<OOMObject> list = new ArrayList<>();
		while(true){
			list.add(new OOMObject());
		}
	}
}

运行结果:

java.lang.OutOfMemoryError: Java heap space
Dumping heap to D:\data\java_pid8168.hprof ...
Heap dump file created [28070894 bytes in 0.145 secs]
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
	at java.util.Arrays.copyOf(Arrays.java:3210)
	at java.util.Arrays.copyOf(Arrays.java:3181)
	at java.util.ArrayList.grow(ArrayList.java:261)
	at java.util.ArrayList.ensureExplicitCapacity(ArrayList.java:235)
	at java.util.ArrayList.ensureCapacityInternal(ArrayList.java:227)
	at java.util.ArrayList.add(ArrayList.java:458)
	at com.sean.esapi.client.VmTest.main(VmTest.java:15)

java堆的内存OOM异常是实际应用中常见的内存溢出异常情况。当出现java堆内存溢出时,异常堆栈信息“java.lang.OutOfMemoryError”会跟着进一步提示“Java heap space”。

要解决这个区的异常主要就是确认内存中的对象是否必要的,也就是要分清楚是内存泄露还是内存溢出。如果是内存泄露就要找到泄露对象到GC Roots的引用链,于是就能找到泄露对象是通过怎样的路径与GC Roots相关联并导致垃圾收集器无法回收他们的。

如果不存内存泄露就要一方面根据物理机内存对比看看是否可以扩充java堆内存空间。另一方面从代码层面看看是否有对象生命周期过长或持有状态时间过长的情况,尝试减少程序运行期的内存消耗。

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

由于在HotSpot虚拟机中并不区分虚拟机栈和本地方法栈,因此对于HotSpot来说虽然-Xoss参数(设置本地方法栈大小)存在但实际上无效。虚拟机提供了为单个线程设置栈容量的参数设置-Xss。

java虚拟机规范中描述有两种异常:

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

OutOfMemoryError:如果虚拟机在扩展栈时无法申请到足够的内存空间,抛出OutOfMemoryError异常。

在单线程模式下通过递归方法测试设置-Xss参数减小栈内存的容量,出现StackOverflowError异常,减小-Xss参数之后再次运行发现出现StackOverflowError异常时递归的深度变小。说明当栈内存缩小时虚拟机允许的最大深度相应缩小。

设置:

-Xss128k

运行:

public class VMStackTest {
	
	private int stackLength = 1;
	
	public void stackLeak() {
		stackLength ++;
		System.out.println(stackLength);
		stackLeak();
	}
	
	public static void main(String[] args) {
		VMStackTest vmst = new VMStackTest();
		try {
			vmst.stackLeak();
		} catch (Exception e) {
			e.printStackTrace();
		}
	}

}

结果:

1
...
974
975
976
977
Exception in thread "main" java.lang.StackOverflowError
	at sun.nio.cs.UTF_8.updatePositions(UTF_8.java:77)
	at sun.nio.cs.UTF_8.access$200(UTF_8.java:57)
	at sun.nio.cs.UTF_8$Encoder.encodeArrayLoop(UTF_8.java:636)
	at sun.nio.cs.UTF_8$Encoder.encodeLoop(UTF_8.java:691)
	at java.nio.charset.CharsetEncoder.encode(CharsetEncoder.java:579)
	at sun.nio.cs.StreamEncoder.implWrite(StreamEncoder.java:271)
	at sun.nio.cs.StreamEncoder.write(StreamEncoder.java:125)
	at java.io.OutputStreamWriter.write(OutputStreamWriter.java:207)
	at java.io.BufferedWriter.flushBuffer(BufferedWriter.java:129)
	at java.io.PrintStream.write(PrintStream.java:526)
	at java.io.PrintStream.print(PrintStream.java:597)
	at java.io.PrintStream.println(PrintStream.java:736)

重新设置-Xss参数:

-Xss512k

同样运行上面代码

结果:

1
...
5064
5065
5066
5067
Exception in thread "main" java.lang.StackOverflowError
	at java.io.FileOutputStream.write(FileOutputStream.java:326)
	at java.io.BufferedOutputStream.flushBuffer(BufferedOutputStream.java:82)
	at java.io.BufferedOutputStream.flush(BufferedOutputStream.java:140)
	at java.io.PrintStream.write(PrintStream.java:482)
	at sun.nio.cs.StreamEncoder.writeBytes(StreamEncoder.java:221)
	at sun.nio.cs.StreamEncoder.implFlushBuffer(StreamEncoder.java:291)
	at sun.nio.cs.StreamEncoder.flushBuffer(StreamEncoder.java:104)
	at java.io.OutputStreamWriter.flushBuffer(OutputStreamWriter.java:185)
	at java.io.PrintStream.write(PrintStream.java:527)
	at java.io.PrintStream.print(PrintStream.java:597)

当定义大量的本地变量,增大方法帧中本地变量表的长度,在较小的调用深度就会出现抛出StackOverflowError异常。

JVM未提供设置整个虚拟机栈占用内存的配置参数。虚拟机栈的最大内存大致上等于“JVM进程能占用的最大内存(依赖于具体操作系统) - 最大堆内存 - 最大方法区内存 - 程序计数器内存(可以忽略不计) - JVM进程本身消耗内存”。当虚拟机栈能够使用的最大内存被耗尽后,便会抛出OutOfMemoryError,可以通过不断开启新的线程来模拟这种异常(这种方式容易耗尽操作系统资源导致宕机)

配置:

-Xss128k

运行如下代码没有得到OutOfMemoryError异常:

public class VMStackTest {

	public void stackLeak() {
		String name;
		while (true) {
			name = "nihao";
			try {
				Thread.sleep(100000);
			} catch (InterruptedException e) {
				e.printStackTrace();
			}
		}

	}
	
	public void stackLeakByThread(){
		int count = 0;
		while (true) {
			count ++;
			Thread t = new Thread(new Runnable(){
				@Override
				public void run() {
					stackLeak();
				}
			});
			t.start();
			System.out.println(count);
		}
	}

	public static void main(String[] args) {
		VMStackTest vmst = new VMStackTest();
		vmst.stackLeakByThread();
	}

}

3.3方法区和运行时常量池溢出

方法区用于存放Class的相关信息,如类名、访问修饰符、常量池、字符描述、方法描述等。对于这些区域的测试,基本思路就是运行时产生大量的类去填满方法区,直到溢出。这里通过CGLib直接操作字节码运行时生成了大量的动态类。

注意JVM8中把运行时常量池、静态变量也移到堆区进行存储。方法区方法区主要是存储类的元数据的,如虚拟机加载的类信息、编译后的代码等。JDK8之前方法区的实现是被称为一种“永久代”的区域,这部分区域使用JVM内存,但是JDK8的时候便移除了“永久代(Per Gen)”,转而使用“元空间(MetaSpace)”的实现,而且很大的不同就是元空间不在共用JVM内存,而是使用的系统内存。

3.4本机直接内存溢出

DirectMemory容量可通过-XX:MaxDirectMemorySize指定,如果不指定,则默认为与java堆最大值(-Xmx指定)一样,我们测试时直接越过DirectByteBuffer类,直接通过反射获取Unsafe实例进行内存分配。因为虽然使用DirectByteBuffer分配内存也会出现内存溢出异常,但它抛出异常时并没有去向操作系统申请内存,而是通过计算得知内存无法分配手动抛出内存溢出异常。真正申请分配内存的方法是unsafe.allocateMemory()。

配置:

-Xmx20M -XX:MaxDirectMemorySize=10M

运行:

public class DirectMeoryOOM {

	private static final int _1MB = 1024 * 1024;
	
	public static void main(String[] args) throws Exception {
        //通过反射获取到unsafe实例,再通过unsafe实例申请内存
		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)
	at com.sean.esapi.client.DirectMeoryOOM.main(DirectMeoryOOM.java:16)

由DirectMemory导致内存溢出的一个明显特征就是在Heap Dump文件中不会看到明显的异常,如果发现OOM之后Dump文件有很小,程序直接或间接又使用了NIO,可以考虑这方面原因。

猜你喜欢

转载自my.oschina.net/u/3100849/blog/2250218