Java内存管理及垃圾回收总结

时间:2021-12-26 19:24:47

概述

Java和C++的一个非常重要的区别在于对内存的管理,Java的自动内存管理及垃圾回收技术使得Java程序员不需要释放废弃对象的内存,从而简化了编程的过程;同时也避免了因程序员的疏漏而导致的内存泄露问题。内存管理和垃圾回收是JVM非常重要的一个部分,深入理解Java的内存管理和垃圾回收机制是避免及修复Java相关异常(OutOfMemoryError, *Error),理解Java对象创建过程,有效利用内存,构建高性能Java应用的前提。本文将先后介绍Java运行时内存区域,垃圾回收,对象创建过程。

Java运行时内存区域

Java运行时内存区域如图2-1所示,内存区域逻辑上被划分为:程序计数器,栈,本地方法栈,堆,方法区。其中程序计数器,栈,本地方法栈都是线程私有的,堆和方法区被所有线程共享。
程序计数器用于指示当前线程执行的字节码的行号;栈用于描述Java方法执行的内存模型,每当进入一个新的方法,JVM都会在栈中创建一个栈帧(存放本地变量,参数,返回地址,操作数栈);本地方法栈是本地方法执行的内存模型,HotSpot虚拟机将栈和本地方法栈合二为一。
       在提及到栈的时候,我们需要涉及两个异常:*Error和OutOfMemoryError,这两个异常的区别在于:当线程请求的栈深度超过虚拟机允许的栈深度时,会抛出*Error;当虚拟机无法为线程扩展栈分配足够的空间时,会抛出OutOfMemoryError。
       设置栈深度的示例:-Xss128k    ==>>   设置栈的深度为128KB

Java内存管理及垃圾回收总结
Java内存管理及垃圾回收总结
图2-1

下面我们来看一下另一块非常重要的区域:堆,Java堆是用于存放Java对象实例的主要区域,通过new,clone,反序列化创建的对象都存放在堆中,为什么Java要把对象存放在堆中,而不是栈中呢?
       C++由于没有垃圾回收机制,所以当定义一个变量时,其内存是在栈中分配的,只有通过new显式的创建一个对象时,对象才会从堆中分配内存,并且此时需要通过delete显式的释放对象占用的内存,否则会造成内存泄露。Java中除了基本类型变量(boolean, byte, char, short, int, long, float, double),其他类型的变量基本都是通过new来创建,所以其内存都是从堆中分配,当对象废弃时,垃圾收集器会自动回收这部分内存。由于堆是各个线程共享的内存区域,所以把对象存放在堆中有利于线程之间的通信(共享内存)。正如之前我们在描述栈时所看到的,JVM会为每个方法创建一个栈帧,所以如果对象存放在栈中,方法调用的参数将需要从调用方法的栈帧拷贝到被调用方法的栈帧,如果对象存放在堆中,只需要拷贝指针或引用(此时,两个方法将指向同一个对象)。所以我们可以认为Java之所以把对象存放在堆中,其一是Java具有非常优秀的垃圾回收机制,其二把对象存放在堆中有利于线程之间共享数据及通信,其三是可以减少不必要的对象拷贝,提升方法调用的效率,同时也节约了内存。
       因为不同的Java对象生命周期可能不同,所以基于Java对象不同的生命周期,堆被分成了两个不同的区域:新生代和老年代,新生代中对象的生命周期短,存活率低,老年代中对象的生命周期长,存活率高。基于不同的存活率,这两个区域的垃圾收集也采用了不同的算法,新生代一般采用复制算法,老年代一般采用标记-删除或标记-整理算法。复制算法就是将存活下来的对象从一个区域复制到另一个区域,标记删除和标记整理就是将需要回收的对象标记出来,然后清除掉,标记整理算法还会对内存进行整理,这样可以避免内存碎片。将Java堆分成两个不同的年代并采用不同回收算法的垃圾收集方式被称为分代收集。下一节将详细介绍垃圾收集的机制以及常用垃圾收集器。
介绍完Java堆之后,我们来看一下方法区,方法区是用于存放类信息、常量(final, static final)、静态变量(static)、即时编译器编译后的代码的地方。HotSpot虚拟机把这部分区域称为永久代,因为HotSpot虚拟机把分代收集扩展到了方法区,或者我们可以说HotSpot虚拟机通过永久代来实现方法区;同时提供了参数-XX:MaxPermSize来限制方法区的最大内存。但其实这并不是一个很好的选择,当加载的类比较多或者常量池比较大时,很容易导致内存溢出。目前HotSpot官方团队已经在逐步采用本地内存实现方法区,JDK1.7已经将常量池移出永久代。当该区域的内存无法满足要求时,也会导致内存溢出。

垃圾回收

根据前面对运行时内存区域的描述,我们知道垃圾回收主要集中在堆和方法区,方法区可以选择性实现垃圾回收,该区域的垃圾回收主要集中在回收废弃常量和类型卸载。前面我们已经提及了复制算法和标记整理算法,那在此之前我们如何知道哪些对象时废弃的,哪些对象时不能回收的呢?对于具备垃圾回收功能的语言,一般采用两种算法确定废弃对象:引用计数法(Python)和可达性分析算法(也被称为根搜索算法,C#,Lisp ),Java采用可达性分析算法。引用计数法通过跟踪对象的引用计数器来确定对象是否被废弃,当一个新的引用指向该对象时,引用计数加1,当一个引用不在指向该对象时,引用计数减1,当引用计数为0时,对象被废弃。该算法在遇到堆中两个对象循环引用时(即对象A中有一个字段指向对象B,对象B中有一个字段指向A),会导致内存泄露,即这部分内存永远不会被回收,因为这两个对象的引用计数永远不为0。该算法出现问题的原因在于没有区分指向对象的引用ref的来源,假如ref位于栈或者方法区中,说明该对象没有废弃;但假如ref位于堆中,则不能确定,此时我们可以继续判断指向ref所在对象的引用ref2所在的内存区域,通过这种方法不断回溯,如果最终可以到达栈,本地方法栈或者方法区中,则说明引用链中的对象都是没有废弃的,否则都是废弃的,这就是可达性分析算法。
      Java除了通过可达性分析算法判断哪些对象需要回收之外,还提供了不同的引用级别用于实现更加灵活的垃圾回收。Java一共提供了四种引用级别:强引用,软引用(SoftReference),弱引用(WeakReference),虚引用(PhantomReference)。强引用就是我们一般的引用方式,软引用指向对象在系统将要发生内存溢出时会被回收(可以用于实现缓存),弱引用指向的对象会在下一次垃圾回收时被回收,虚引用指向的对象只是会在垃圾回收时收到一个系统通知,对象的生命周期完全不会受虚引用的影响。
       下面我们来看一下复制算法(Copy)和标记-整理(Mark-Compact)算法的具体实现,其实这两个算法都是从标记-清除(Mark-Sweep)算法改进而来的,标记清除算法会遇到两个问题:第一个问题是效率问题,当对象存活率很低时,其实把存活对象找出来并整理到一个区域,效率会更高,这就是复制算法;第二个问题是当存活率比较高时,会出现内存碎片问题,所以出现了标记-整理算法。Java堆采用了新生代为复制算法,老年代为标记-整理或标记-清除算法的分代收集机制。
Java内存管理及垃圾回收总结
Java内存管理及垃圾回收总结
Java内存管理及垃圾回收总结
图2-2        标记 - 清除算法

Java内存管理及垃圾回收总结
Java内存管理及垃圾回收总结
Java内存管理及垃圾回收总结
图 2-3       复制算法
Java内存管理及垃圾回收总结
Java内存管理及垃圾回收总结

Java内存管理及垃圾回收总结
图2-3   标记 - 整理算法

       复制算法将新生代分成Eden,From Survivor,To Survivor三块区域,每次垃圾收集Eden,From Survivor中存活的对象都会被复制到To Survivor中。记住,这三个区域的划分只是逻辑上的,和物理划分无关,默认Eden和Survivor的大小比例为8 : 1,比例划分这么大是为了提高内存的利用率,在这种比例下可利用的内存其实只有90%;看到这里,我想很多人可能会问,假如10%的空间不够存放生存下来的对象怎么办?JVM提供了一种被称为分配担保(Handle Promotion)的机制,由老年代为To Survivor空间提供担保,假如To Survivor没有足够的空间存放生存下来的对象,这些对象直接存放到老年代,假如老年代还不够存放,就会抛出OutOfMemoryError异常。
       分析了垃圾收集算法的思想之后,我们来了解一下常用的垃圾收集器,新生代的垃圾收集器包括 Serial,ParNew,Parallel Scavenge,老年代的垃圾收集器包括Serial Old,CMS,ParOld。这些垃圾收集器的一个区别是单线程还是多线程,其中Serial,Serial Old是单线程的,其余是多线程的;第二个区别是垃圾收集线程和用户线程是否可以并发执行,CMS收集器可以分成初始标记,并发标记,重新标记,并发回收等过程,其中并发标记和并发回收可以与用户线程并发执行,所以它也是这些垃圾收集器中唯一真正意义上的并发收集器;Parallel Scavenge与ParOld以提高吞吐量为目的,其他收集器以减小停顿时间(Stop The World)为目的。

创建对象及内存分配

前面提到,Java创建对象的方式包括new、clone、deserialization,在虚拟机内部,这三种创建对象的方式其实是相同的。首先寻找或加载类信息,如果无法正常加载,则抛ClassNotFoundException,否则到java堆中分配内存,分配内存的方式根据内存是否规整(取决于垃圾回收算法,标记整理和复制算法的内存都是规整的,标记清除的内存不规整)有两种方式:指针碰撞和空闲列表。指针碰撞的方式中通过指针ptr将内存分成两个部分,ptr之前的部分都被使用,ptr之后的部分是空闲的,当对象需要的内存为size时,指针ptr = ptr + size。空闲列表是通过将空闲的区域通过链表连接起来,对象需要内存则遍历链表,直到遇到一个具有足够空间的元素为止。内存分配完之后就将所分配的内存初始化为0,每个对象都有一个对象头,这里保存着和对象相关的锁,对象的哈希码,对象的GC分代年龄,以及指向方法区中类型的相关引用。到此为止,对于虚拟机来说,已经成功创建了一个对象;但从Java程序来说,这才刚刚开始,接下来会执行<init>方法对所有字段进行初始化。
      对于不同的对象,所分配的内存的区域是不同的。一般来说,优先在Eden空间中分配内存;对于大对象,优先在老年代中分配内存(size大于PrenureSizeThreshold);当对象的年龄大于MaxTenuringThreshold时,对象也会被移动至老年代;如果Survivor空间中相同年龄的所有对象大小总和大于Survivor空间的一半,年龄大于或等于该年龄的对象直接进入老年代。