读书笔记——《深入理解Java虚拟机》系列之Java内存区域与常见内存溢出异常

第一次读这本书时,就被文中的一句话所折服:

“Java与C++之间有一堵由内存动态分配和垃圾收集技术所围成的‘高墙’,墙外面的人想进去,墙里面的人却想出来。”

的确,对于使用C++编程的程序员来说,他们肩负着对每一个对象所占内存空间的维护责任;而对于Java程序员来说,动态分配内存机制让我们无需对申请的内存进行free,不容易出现内存的泄露和溢出。但是如果不了解虚拟机内部内存的划分,或是虚拟机如何与操作系统的内存管理进行合作,那么当我们真正面对由于内存引发的错误时,我们将无从下手。

本系列博客希望能够总结下JVM虚拟机相关的知识,和大家一起分享。

1.Java SE 7 虚拟机运行时数据区域

Java虚拟机在执行Java程序时会将它管理的内存划分位几个不同的区域,让我们先来看一下Java SE 7版本的运行时数据区域,如下图:

d523f2904a594bce967386174a4a4bd2.png
图片来源

需要注意的是上面的内存区域都是虚拟机规范中对虚拟机内存区域的逻辑划分,具体各个虚拟机都可能有不同的实现。

  • 程序计数器

程序计数器(Program Counter Register) 可以被看作是当前线程所执行的字节码的行号指示器。由于在任一时刻,一个处理器(或一个内核)只能执行一条线程中的指令,因此每个线程都需要一个独立的程序计数器来保证CPU切换线程后能恢复到之前的执行位置。此内存区域是唯一一个在虚拟机规范中没有规定任何OOM情况的区域。

  • Java虚拟机栈

与程序计数器相同,Java虚拟机栈(Java Virtual Machine Stacks)也是线程私有的,它的生命周期与线程相同。虚拟机栈主要是用来描述Java方法执行的内存模型:每个方法执行时都会创建一个栈帧(Stack Frame)用于存储局部变量表,操作数栈,动态链接和方法出口等信息。一个方法从调用到执行完成,就是该方法所对应的栈帧从入栈到出栈的过程。

在栈帧中存放的局部变量表存放了各种编译期可知的基本数据类型(boolean,byte,char,short,int,float,long,double),对象引用(reference)和returnAddress类型(指向了一条字节码指令的地址)。

当进入一个方法时,这个方法需要在栈帧中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小。

在这个区域内,如果线程所请求的栈深度大于虚拟机所允许的栈的深度,将抛出StackOverflowError;如果虚拟机栈允许自动扩展,但却无法申请到足够的内存,就会抛出OutOfMemoryError异常。

  • 本地方法栈

本地方法栈与Java虚拟机栈十分相似,不过虚拟机栈执行的是Java的方法(字节码)服务,而本地方法栈执行的是虚拟机需要的Native的方法。

  • Java堆

Java堆是整个运行时数据区域中最大的一块的内存区域,它被所有线程共享,在虚拟机启动时创建。这个内存区域唯一的目的就是存放对象实例,我们在程序中new出的对象大多都存储在这个区域。

从JavaGC的角度来看,Java堆是发生垃圾收集的主要区域,它也可以被细分为新生代和老年代;在细分还有Eden空间,From Survivor空间, To Survivor空间等,如下图所示:

8b6faa2e9f104334adb7b9a4b3a179fa.png

具体的垃圾收集相关知识,将会在之后的博客中详细介绍,大家目前不用太担心它。

如果在Java堆中没有足够的内存来完成实例的分配,并且此时堆也无法自动扩展时,虚拟机将会抛出OutOfMemoryError异常。

  • 方法区

方法区与Java堆一样,是所有线程共享的内存区域,它用来存储已经被虚拟机加载的类信息,常量,静态变量等数据。对于使用HotSpot虚拟机的开发者而言,大家也将方法区称为“永久代(Permanent Generation)”,因为HotSpot虚拟机的设计者将GC分代收集扩展到了方法区,换句话说,他们用永久代去实现了方法区,这样HotSpot的GC就可以像管理Java堆一样管理方法区的内存了。但是在Java SE 8之后,虚拟机已经完成了“去永久代”,这个我们稍后也会讲到。

在方法区中有一块区域叫做运行时常量池(Runtime Constant Pool)。Class 文件中除了有类的版本,字段,方法,接口等描述信息之外,还有一项是常量池(Constant Pool Table),用于存放编译期生成的更重字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池中存放。但从JDK1.7开始已经在逐渐去除永久代了,存储在永久代的部分数据就已经转移到了Java Heap或者是 Native Heap。譬如符号引用(Symbols)转移到了native heap;字面量(interned strings)转移到了java heap;类的静态变量(class statics)转移到了java heap,但永久代仍存在于JDK1.7中,并没完全移除。

当方法区无法满足内存分配需求时,将抛出OutOfMemoryError异常。

  • 直接内存

严格来讲,直接内存(Direct Memory)并不是虚拟机运行时数据区的一部分,甚至不是Java虚拟机规范中定义的内存区域,它本质上就是运行Java虚拟机的进程被分配的本机内存,在之前所提到的Java虚拟机栈或堆需要自动扩展时,实际上就会向直接内存申请空间,因此如果各个内存区域的总和大于了本机内存的限制,就会发生OutOfMemoryError异常。

2.Java SE 8 虚拟机“去永久代”

从Java SE 7就已经在推动“去永久代”了,到了Java SE 8,虚拟机的设计团队终于彻底将永久代废除了,如下图:

0b272d2329ab442093bbdacff3b4f789.jpg

从图中我们可以看到,永久代已经被替换为了在本地内存存储的Metaspace 元空间

在上节中我们提到,Java SE 7使用永久代去实现方法区,但是使用永久代去实现方法区的一个问题就是,如果永久代的内存不够(比如存放了大量的类信息)则会抛出java.lang.OutOfMemoryError: PermGen异常。而元空间与永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存,理论上只受限于操作系统的虚拟内存大小

我们可以通过参数来配置元空间的大小:

1.MetaspaceSize

初始化的Metaspace大小,控制元空间发生GC的阈值。GC后,动态增加或降低MetaspaceSize。在默认情况下,这个值大小根据不同的平台在12M到20M浮动。使用Java -XX:+PrintFlagsInitial命令查看本机的初始化参数

2.MaxMetaspaceSize

限制Metaspace增长的上限,防止因为某些情况导致Metaspace无限的使用本地内存,影响到其他程序。在本机上该参数的默认值为4294967295B(大约4096MB)。

3.MinMetaspaceFreeRatio

当进行过Metaspace GC之后,会计算当前Metaspace的空闲空间比,如果空闲比小于这个参数(即实际非空闲占比过大,内存不够用),那么虚拟机将增长Metaspace的大小。默认值为40,也就是40%。设置该参数可以控制Metaspace的增长的速度,太小的值会导致Metaspace增长的缓慢,Metaspace的使用逐渐趋于饱和,可能会影响之后类的加载。而太大的值会导致Metaspace增长的过快,浪费内存。

4.MaxMetasaceFreeRatio

当进行过Metaspace GC之后, 会计算当前Metaspace的空闲空间比,如果空闲比大于这个参数,那么虚拟机会释放Metaspace的部分空间。默认值为70,也就是70%。

5.MaxMetaspaceExpansion

Metaspace增长时的最大幅度。在本机上该参数的默认值为5452592B(大约为5MB)。

6.MinMetaspaceExpansion

Metaspace增长时的最小幅度。在本机上该参数的默认值为340784B(大约330KB为)。

3. 内存溢出实例

本节中博主和大家一起写一些例子来测试如何能使Java虚拟机内存溢出。

  • 3.1 Java堆溢出

Java堆是用来为新建对象分配内存空间的地方,因此只要不断新建对象,同时保持GC Roots到对象之间的可达路径(防止该对象被垃圾回收),很快就会达到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());
		  }
	  }
  }

我们在运行这个Java程序时,限制以下Java虚拟机堆的大小,并让虚拟机在出现内存时Dump出当前内存的堆存储快照以便事后分析:

java -Xms20m -Xmx20m -XX:+HeapDumpOnOutOfMemoryError HeapOOM

运行结果为下图:

cf4b3faa979f43c5be7b97ed189038c3-heapDump.png

我们看到OutOfMemoryError异常之后进一步提示出了“Java heap space”区域发生的异常

  • 3.2 虚拟机栈溢出

在Hotspot虚拟机中并不区分虚拟机栈和本地方法栈,我们只需要通过设置 -Xss参数设置虚拟机栈即可。

由于虚拟机栈是用来描述方法调用的内存模型,每次方法调用都会向虚拟机栈中插入一个栈帧,因此只要写一个没有退出条件的递归函数,即可将栈空间占满。

	public class JavaVMStackSOF{
		private int stackLength = 1;
		public void stackLeak(){
			stackLength++;
			stackLeak();
		}
		public static void main (String[] args){
			JavaVMStackSOF oom = new JavaVMStackSOF();
			try{
				oom.stackLeak();
			}catch(Throwable e){
				System.out.println("stack length: "+oom.stackLength);
				throw e;
			}
		}
	}

我们在运行这个程序的时候,限制虚拟机栈的大小:

java -Xss256K JavaVMStackSOF

运行结果为下图:
679fde1376a64ccea3b43602b3071893-stackOverFlow.png

  • 3.3 方法区溢出

方法区用于存放Class相关信息,如类名方法描述符等,因此我们只需要生成大量的类去填满方法区即可。下面的代码使用CGLib来在运行时生成大量的动态类:

  public class JavaMethodAreaOOM {  
      public static void main(String[] args) {  
          while (true) {  
              Enhancer enhancer = new Enhancer();  
              enhancer.setSuperclass(OOM.class);  
              enhancer.setUseCache(false);  
             enhancer.setCallback(new MethodInterceptor() {  
   
                 @Override  
                  public Object intercept(Object obj, Method arg1, Object[] args, MethodProxy proxy) throws Throwable {  
                      return proxy.invokeSuper(obj, args);  
                  }  
              });  
              OOM oom = (OOM) enhancer.create();  
                
          }  
      }  
    
      static class OOM {  
          
      }  
  }

或者使用Jdk自带的动态代理来生成大量的类去堆满方法区(有想要了解代理模式动态代理源码的同学,可以直接看博主这两篇文章):

public interface HelloService {
	void sayHello();
}

public class HelloServiceImpl implements HelloService {

	@Override
	public void sayHello() {
		// TODO Auto-generated method stub
		System.out.println("dasd");
	}

}
public class MyInvocationHandler implements InvocationHandler {
	
	private Object target;

	public MyInvocationHandler(Object target) {
		this.target = target;
	}

	@Override
	public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
		// TODO Auto-generated method stub
		return method.invoke(target, args);
	}

}
public class Test {
	private static Map<String, HelloService> classLeakingMap = new HashMap<String, HelloService>();
	public static void main(String[] args) throws MalformedURLException {
		// TODO Auto-generated method stub
		 for (int i = 0; i < 5000; i++) {

             String className = "file:" + i + ".class";

             URL[] url = new URL[] { new URL(className) };

             URLClassLoader loader = new URLClassLoader(url);

             HelloService t = (HelloService) Proxy.newProxyInstance(loader,
                     new Class<?>[] { HelloService.class },
                     new MyInvocationHandler(new HelloServiceImpl()));

             classLeakingMap.put(className, t);
         }
	}

}

我们在运行这两个程序的时候,限制永久代的大小

java -XX:PermSize=5M -XX:MaxPermSize=5M JavaMethodAreaOOM

楼主在测试方法区溢出的例子上浪费了很长的时间,由于jdk1.7的方法区仍然是由永久代(PermGen Space)实现的,因此我们写的方法区溢出的实例理论上应该出现下图的错误:

right.png

但无论博主如何尝试,在不同jdk1.7版本都试过….,都没有办法复现这个错误,如果有哪位同学能够成功在jdk1.7环境下复现上图的错误烦请留言告知博主一声,在此先行谢过了。下图是我自己的demo爆出的错误:

wrong.png

正如我们之前所说到的,jdk8之后已经完全去永久代了,取而代之的是存储在直接内存上的Metaspace,因此将上面的测试程序使用jdk1.8进行测试的话,显示的将会是MetaspaceOOM了:

在jdk1.8之后,虚拟机参数已经将PermSize和MaxPermSize去除掉了,如果继续在1.8上使用这两个参数会提示错误。取而代之的是MetaspaceSize和MaxMetaspaceSize两个参数:

java -XX:MetaspaceSize=8m -XX:MaxMetaspaceSize=8m Test 

4. 总结

在本篇博客中,博主和大家一起学习了虚拟机中的运行时内存是如何划分的,以及每部分区域的作用。博主同时用一些Java代码模拟出了三个内存区域发生溢出的情形。但是上面的模拟还是很简单的demo模拟,尽管Java拥有GC机制,但是在实际编程中遇到内存溢出还是很常见的。希望同学们能记住这些内存区域的特点,当在某个区域发生内存溢出时,能够更好地定位问题。本篇博客到这里就结束了,下次见~。

validate