深入理解java虚拟机---->java内存区域与内存溢出异常

 

2. java内存区域于内存溢出异常

 

2.1 概述:

  对于C/C++而言,内存管理具有最高的权利,既拥有每一个对象的“所有权”,又担负着每一个对象生命开始到结束的维护责任。

   对于java而言,则把内存控制的权利交给了java虚拟机,不再需要为每一个new操作去写配对的delete/free代码,不容易出现内存泄露和溢出问题。但是,一旦出现内存泄露和溢出方面的问题,如果不了解虚拟机内存运行过程,排查会很艰难。以下是整个java虚拟机运行的基本结构。

 

2-1

 

 

2.2 运行时数据区域

 

  java虚拟机运行时数据区域如下图所示。其中,虚拟机栈、本地方法栈、程序计数器为线程隔离的数据区。而本地库接口、堆、执行引擎和方法区则是所有线程共享的区域。

 

2-2

 

 

2.2.1  程序计数器

 

  程序计数器是一块很小的内存空间,是当前线程所执行的字节码的行号指示器。利用它来选取下一条执行的字节码的行号指令。(分支、循环、跳转、异常处理、线程恢复等都依赖这个计数器来完成)

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

  如果线程正在执行一个java方法,则这个计数器记录的是正在执行的虚拟机字节码指令的地址。如果正在执行Native方法(本地方法),这个计数器则为空(undefined)。此内存区域是唯一一个在java虚拟机规范中没有规定OutOfMemoryError情况的区域。

  

2.2.2  java虚拟机栈

 

  java虚拟机栈(Java Virtual Machine Starks)也是线程私有的,它的生命周期与线程相同。虚拟机栈描述的是java方法执行的内存模型:每个方法在执行时会创建一个栈帧(Stack Fram)用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每一个方法调用直至完成过程,就对应着一个栈在虚拟机中入栈到出栈的过程。

  虚拟机栈中的局部变量表,存放了编译期可知的各种基本数据类型(boolean、byte、char、short、int、float、double)、对象引用(reference类型,它不等同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或者其他与此对象相关的位置)、returnAddress类型(指向一条字节码指令的地址)。其中,64位长度的long和double类型的数据会占用2个局部变量空间(Slot),其余的数据类型只占用1个。局部变量表所需的内存空间在编译期间完成分配,当进入一个方法时,这个方法需要在帧中分配多大的局部变量空间完全确定,方法运行期间不会改变局部变量表的大小。

  java虚拟机栈中规定了两种异常处理状况:如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常;如果虚拟机栈可以动态扩展(目前大部分虚拟机都可以动态扩展),当扩展时无法申请到足够的内存,就会抛出OutOfMemoryError异常。

 

2.2.3 本地方法栈(Native Method Stack)

 

  本地方法栈(Native Method Stack)和虚拟机栈所发挥的作用是相似的。唯一的区别是,虚拟机栈为虚拟机执行java方法(也就是字节码)服务,而本地方法栈则为虚拟机使用到的Native方法服务。

   有的虚拟机直接把本地方法栈和虚拟机栈合二为一。和虚拟机栈一样,本地方法栈也会抛出StackOverflowError和OutOfMemoryError异常。

  注:  native方法:一个Native Method就是一个java调用非java代码的接口。一个Native Method是这样一个java的方法:该方法的实现由非java语言实现,比如C。这个特征并非java所特有,很多其它的编程语言都有这一机制,比如在C++中,你可以用extern "C"告知C++编译器去调用一个C的函数。  有时java应用需要与java外面的环境交互。这是本地方法存在的主要原因,你可以想想java需要与一些底层系统如操作系统或某些硬件交换信息时的情况。本地方法正是这样一种交流机制:它为我们提供了一个非常简洁的接口,而且我们无需去了解java应用之外的繁琐的细节。

 

2.2.4 Java堆(Java Heap)

 

  一般而言,java堆(java heap)是java虚拟机所管理的内存中最大的一块。java堆被所有线程共享的一个内存区域,在虚拟机启动时被创建。这部分内存创建的唯一目的就是存放对象实例,几乎所有的对象实例都在这里分配内存。java虚拟机规范中描述是“所有的对象实例和数组都要在堆上分配”。

  java堆是垃圾收集器管理的主要区域,因此很多时候也被称之为“GC堆”(Garbage Collected Heap)

  java堆可以处于物理上不连续的内存空间中,只需要逻辑上是连续的既可,就像我们的磁盘空间一样。实现时,可以时固定大小的,也可以是可扩展来实现的。如果堆中没有完成实例分配,并且堆也无法再扩展时,将会抛出OutOfMemoryError异常。

 

2.2.5 方法区

 

  方法区(Method Area)与Java堆一样,是各个线程共享的内存区域,它用于存储已经被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。java虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却有一个别名“No-Heap”(非堆),目的是与java堆区分开。

  java虚拟机的方法区,除了和java堆一样不需要连续的内存和可以选择固定大小或者可扩展外,还可以选择不实现垃圾收集。垃圾收集的行为在这个区域是比较少见的,但并不是数据进入了方法区就相当于“永远存在了”。这个区域的回收目标主要是针对常量池的回收和对类型的卸载。

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

 

2.2.6 运行时常量池

 

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

  常量池是方法区的一部分,所以,当内存池无法申请到方法时会抛出OutOfMemoryError异常。

 

2.2.7 直接内存

 

  直接内存并不是虚拟机运行数据的一部分,也不是java虚拟机规范中定义的内存区域,为了以示区分,写在这里。

  在JDK1.4中新加入了NIO(new input/output)类,引入了一种基于通道(Channel)和缓冲区(Buffer)的I/O方式,它可以使用Native函数库直接分配堆外内存,然后通过一个存储在java堆中的DirectByteBuffer对象作为这块内存的引用进行操作。这样通过一个存储在java堆中的DirectByteBuffer对象作为这块内存的引用进行操作。避免了在java堆和native堆中来回复制数据,进而提高了性能。

 

2.3  HotSpot虚拟机对象探秘

 

2.3.1 对象的创建

 

  以下是整个虚拟机对象创建的全过程。其中,虚拟机为新生对象分配内存的方法分为两种:第一种,java堆是绝对规整的,所有用过的内存都放在一边,空闲内存放在另外一边,中间放着一个指针视作为分界点的指示器,那所分配的内存就仅仅是把那个指针指向空闲空间那边挪动一段于对象大小相等的距离,这种分配方法称为“指针碰撞”(Bump the Point);第二种,java堆中的内存并不规整,已使用的内存和空闲的内存相互交错,虚拟机必须维护一个列表,记录上哪些内存块是可用的,分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录,这种分配方式称之为“空闲列表”(Free List)。选择哪种分配方式由java堆是否规整决定,而java堆是否规整又由所采用垃圾收集器是否带有压缩整理功能决定。此外,在划分可用空间的过程中可能对多个对象进行分配内存,正在对对象A分配内存的时候,指针还没来得及修改,对象B同时使用了原来的指针分配内存。解决这个问题有两个方案:方案一:内存空间的动作进行同步处理;方案二:把内存分配动作按照线程划分在不同的空间中进行,即每个线程在java堆中预先分配一小块内存,称之为本地线程分配缓冲(Thread Local Allocation Buffer,TLAB)。

 

2-3

 

2.3.2 对象的内存布局

 

  在hotspot虚拟机中,对象在内存中存储的布局可以分为3块区域:对象头(Header)、实例数据(Instance Data)、和对齐填充(padding)。

  HotSpot虚拟机的对象头包括两部分信息,第一部分用于存储对象自身的运行时数据,如:哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等。

 

表2-3-2 HotSpot虚拟机对象头 Mark Word

存储内容 标志位 状态
对象哈希码、对象分代年龄 01 未锁定
指向锁记录的指针 00 轻量级锁定
指向重量级锁的指针 10 膨胀(重量级锁定)
空,不需要记录信息 11 GC标志

偏向线程ID、偏向时间戳、对象分代年龄 

01 可偏向

 

  对象头的另外一部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。如果对象是一个java属数组,那在对象头中还必须有一块用于记录数组长度的数据,因为虚拟机可以通过普通java对象的元数据信息确定java对象的大小,但是从数组的元数据中却无法确定数组的大小。

  实例数据部分是对象真正存储的有效信息,也是程序代码中所定义的各种类型的字段内容。无论是从父类继承下来的,还是子类中定义的,都需要记录起来。

  对齐填充并不是必然存在的,没有什么特殊意义,它仅仅是起着占位符的作用。由于HotSpot VM的自动内存管理系统要求对象起始地址必须是8字节的整数倍。也就是说,对象的大小必须是8字节的整数被。因此,当对象实例数据部分没有对齐时,就需要通过对齐填充来补全。

 

2.3.3 对象的访问定位

 

  建立对象是为了使用对象,我们的java程序需要通过栈上的reference数据来操作堆上的具体对象。由于reference类型在java虚拟机中规定了一个指向对象的引用,并没有定义这个引用应该通过何种方式去定位、访问堆中的对象的具体位置,所以对象的访问方式也是取决于虚拟机实现而定的。目前主流的对象访问方式有使用句柄和直接指针两种。

  (1)句柄访问对象

  如果使用句柄访问的话,那么java堆中将会划分一块内存来作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了对象实例数据和类型数据各自的具体地址信息。如下图所示:(ps:句柄(reference)是一种特殊的智能指针,当一个应用程序要引用其他系统(如数据库、操作系统)所管理的内存块或对象时,就要使用句柄,在C++中句柄称为引用)。

 

2-4

 

  (2) 直接指针访问

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

2-5

 

  (2) 比较两种访问方式的优势

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

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

内存泄露:指程序中动态分配内存给一些临时对象,但是对象不会被GC所回收,它始终占用内存。即被分配的对象可达但已无用。
作者: yeiqing000
链接:http://www.imooc.com/article/15379
来源:慕课网

 

注解内存泄露:指程序中动态分配内存给一些临时对象,但是,对象不会被GC所回收,它始终占用内存,即分配的对象可达但已无用;

内存溢出:是指程序运行过程中,无法申请到足够的内存而发生的一种错误;

 

2.4  实战:OutOfMemoryError 异常(简称“OOM”异常)

 

2.4.1 java堆溢出

 

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

  代码清单中代码限制java堆的大小为20MB,不可扩展(将堆的最小值-Xms参数与最大值-Xmx参数设置为一样既可避免堆自动扩展),通过参数-XX:+HeapDumpOnOutOfMemoryError可以让虚拟机在出现内存溢出异常时Dump出当前的内存堆转储快照一边事后分析。

/**
*VM Args: -Xms20m -Xmx20m -XX:+HeapDumpOnOutOfMemoryError
*
*/


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

  运行结果:

java.lang.OutOfMemoryError:java Heap Space
Dumping heap to java_pid3404.hprof ...
Heap dump file created [22045981 bytes in 0.663 secs]

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

 

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

 

  HotSpot虚拟机中并不区分虚拟机栈和本地方法栈,因此,对于HotSpot来说虽然-Xoss参数(本地方法栈大小)存在,但实际上是无效的,栈容量只由-Xss参数设定。关于虚拟机栈和本地方法栈,在java虚拟机中描述了两种异常:

  1. 如果线程请求的栈深度大于虚拟机所允许的最大深度,将抛出StarkOverflowError异常;
  2. 如果虚拟机无法申请到足够大的内存空间,将抛出OutOfMemoryError异常;
  3. 使用-Xss参数减少栈内存容量。结果抛出StarkOverflowError异常,异常出现时输出的堆栈深度相应缩小;
  4. 定义了大量的本地变量,增大此方法帧中本地变量表的长度。结果:抛出StarkOverflowError异常使输出的对堆栈深度相应缩小。

 

  此处代码测试第一点;

/**
 *VM Args: -Xss128k
 *
 */


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:2402
Exception in thread "main" java.lang.StackOverflowError
                ......

  结果表明:在单个线程下,无论由于栈帧太大还是虚拟机栈容量太小,当内存无发分配的时候,虚拟机抛出的都是StackOverflowError异常。

  如果创建的不限于单线程,通过不断的建立多线程倒是可以产生内存溢出的异常,如下代码清单所示:

/**
 *VM Args: -Xss2M(这个时候可以设置大一些)
 *
 */

public class javaVMStackOOM{ private void dontStop() { while(true){} }
public void stackLeakByThread() { while(ture){ Thread thread = new Thread (new Runnable() { @override public void run() { dontStop(); } }); thread.start(); } public static void main(String[] args) throws Throwable { JavaVMStackOOM oom = new JavaVMStackOOM(); oom.stackLeakByThread(); }
}

   运行结果为:

Exception in thread "main" java.lang.OutOfMemoryError:unable to create new native thread.

  多线程产生的内存溢出(OutOfMemoryError)与栈空间的大小不存在任何联系,准确地说,在这种情况下,为每个线程的栈分配的内存越大,反而越容易产生产生内存溢出异常(因为栈内存越大,java堆内存变小,能够分配的线程数量就越少,越容易造成内存溢出OutOfMemoryError)。而栈溢出(stackoverflow)才与栈分配内存大小直接相关。

  多线程导致的内存溢出,在不能减少线程数或者更换64位虚拟机情况下,就只能通过减少最大堆和减少栈容量来换取更多的线程。因为,栈的容量减小,java堆就能够获得更多的内存,也就可以分配更多的线程;此外,最大堆减小,java堆上剩余的堆空间就越多,同样可以创建更多的线程。(参见JVM内部原理图可知,图2-1/图2-2)。

 

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

 

  由于运行时常量池是方法区的一部分,因此这两个区域的溢出测试就放在一起进行。

  String.intern()是一个native方法(C/C++写的),他的作用是:如果字符串常量池中已经包含一个等于此String对象的字符串,则返回代表池中这个字符串的String对象,否则,将此String对象包含的字符串添加到常量池中,并且返回此String对象的引用。

  运行时常量池导致的内存溢出异常:(JDK1.6)

/**
 *VM Args: -XX:PermSize = 10M   -XX:MaxPermSize = 10M
 *
 */


public class RuntimeConstantPoolOOM{
public static void main(String[] args) {
            List<String> list = new ArrayList<String>();     //使用List保持着常量池的引用,避免Full GC回收常量池行为
       int i = 0;                       //10MB的Permsize在integer范围内足够产生OOM了
       while(ture){
          list.add(String.valueOf(i++).intern());
    }
   }
}

  运行结果:

Exception in thread ”main“ java.lang.OutOfMemoryError:PermGen space
  at java.lang.String.intern(Native Method)
  at org.fenixsoft.oom.RuntimeConstantPoolOOM.main(RuntimeConstantPoolOOM.java:18

  运行结果可以看到,OutOfMemoryError后面紧跟的提示信息是”PermGen space“,说明运行时常量池属于方法区的一部分。

  

  String.intern()返回引用的测试:(JDK1.7)

public class RuntimeConstantPoolOOM {
    
    public static void main (String[] args) {
                        public void main(String[] args) {
                        String str1 = new StringBuilder ("计算机").append("软件").toString();
                        System.out.println (str2.intern () == str1);

                        String str2 = new StringBuilder("ja").append("va").ToString();
                        System.out.println(str2.intern() == str2);
                        }
                }
}

   JDK1.6中运行会得到2个false,而JDK1.7会得到一个ture和一个false。产生差异的原因是在JDK1.6中,intern()方法会把首次遇到的字符实例复制到永久代中,返回的也会是永久代中这个实例的引用,而由StringBuilder创建的字符串实例在java堆上,所以必然不是同一个引用,将返回false。而JDK1.7中的intern()实现不会再复制实例,只是在常量池记录首次出现的实例引用,因此intern()返回的引用和由StringBuilder创建的字符串是同一个。

 

2.4.4 本机直接内存溢出

 

  DirectMemory容量可以通过-XX:MaxDirectMemorySize指定,如果不指定,则默认与java堆最大值(-Xmx指定)一样。

  使用unsafe分配本机内存:

 

/**
 *VM Args: -Xmx20M -xx:MaxDirectMemorySize = 10M
 *
 */

public class DirectMemoryOOM {

            private static final int_1MB = 1024*1024;

            public static void main(String[] args) throws Exception {
                           Filed unsafeFiled =Unsafe.class.getDeclaredFields () [0];
                           unsafeField.setAccessible(ture);
                           Unsafe unsafe = (Unsafe) unsafeFiled.get(null);        
         while(ture){
          unsafe.allocateMemory (_1MB);
        }
   }
}

 

  运行结果:

Exception in thread "main" java.langOutOfMemoryError
    ... ..

  上述代码越过了DirectByteBuffer类,直接通过反射获取unsafe实例进行内存分配。虽然使用DirectByteBuffer分配内存也会抛出内存异常溢出异常,但它抛出异常时并没有真正向操作系统申请分配内存,而是通过计算得知内存无法满足需求(无法分配),于是手动抛出异常,真正申请分配内存的方法是 unsafe.allocateMemory()。

 

   由DirectMemory导致的内存溢出,一个明显的特征是在HeapDump文件中不会看见明显的异常,如果读者发现内存溢出(OOM)之后的Dump文件很小,而程序又直接或者间接使用了NIO,那就考虑一下是否是这方面的原因。

 

posted @ 2017-11-15 09:55  穿格子衫的孔乙彼  阅读(833)  评论(3编辑  收藏  举报