概述
Java和C++的一个很重要的差别在于对内存的管理。Java的自己主动内存管理及垃圾回收技术使得Java程序猿不须要释放废弃对象的内存。从而简化了编程的过程。同一时候也避免了因程序猿的疏漏而导致的内存泄露问题。
内存管理和垃圾回收是JVM很重要的一个部分。深入理解Java的内存管理和垃圾回收机制是避免及修复Java相关异常(OutOfMemoryError, StackOverflowError),理解Java对象创建过程,有效利用内存。构建高性能Java应用的前提。本文将先后介绍Java执行时内存区域,垃圾回收,对象创建过程。
Java执行时内存区域
Java执行时内存区域如图2-1所看到的,内存区域逻辑上被划分为:程序计数器,栈,本地方法栈,堆,方法区。
当中程序计数器,栈,本地方法栈都是线程私有的。堆和方法区被全部线程共享。
程序计数器用于指示当前线程运行的字节码的行号;栈用于描写叙述Java方法运行的内存模型。每当进入一个新的方法。JVM都会在栈中创建一个栈帧(存放本地变量,參数。返回地址,操作数栈)。本地方法栈是本地方法运行的内存模型。HotSpot虚拟机将栈和本地方法栈合二为一。
在提及到栈的时候。我们须要涉及两个异常:StackOverflowError和OutOfMemoryError,这两个异常的差别在于:当线程请求的栈深度超过虚拟机同意的栈深度时,会抛出StackOverflowError;当虚拟机无法为线程扩展栈分配足够的空间时,会抛出OutOfMemoryError。
设置栈深度的演示样例:-Xss128k ==>> 设置栈的深度为128KB
图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来限制方法区的最大内存。但事实上这并非一个非常好的选择,当载入的类比較多或者常量池比較大时。非常easy导致内存溢出。眼下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堆採用了新生代为复制算法,老年代为标记-整理或标记-清除算法的分代收集机制。
图2-2 标记 - 清除算法
图 2-3 复制算法
图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空间的一半,年龄大于或等于该年龄的对象直接进入老年代。