从Java虚拟机的内存区域、垃圾收集器及内存分配原则谈Java的内存回收机制

2023-02-14,,,,

一、引言:

  在Java中我们只需要轻轻地new一下,就可以为实例化一个类,并分配对应的内存空间,而后似乎我们也可以不用去管它,Java自带垃圾回收器,到了对象死亡的时候垃圾回收器就会将死亡对象的内存回收。

  真的只要根据需要巴拉巴拉地new而不用管内存回收了吗?那为什么会存在这么多的内存溢出情况呢?下面我们就需要了解一下Java内存的回收机制,只有了解了其虚拟机的回收原理才能更好的管理内存,避免内存溢出。

二、Java虚拟机的内存区域

首先,我们得知道在我们的虚拟机中内存到底是怎么划分区域的,下面借用《深入理解Java虚拟机》一书中的一张图。

  我们首先是把上述5个内存区域划分为了左右两块,姑且假定左边的为区域A,右边的为区域B。这边我们将内存划分为左右两块是有依据的,依据是什么呢?依据主要是根据线程所有性来划分的,区域A中的方法区和堆是各个线程共享的内存区域,而与之对应的区域B中的虚拟机栈、本地方法栈、程序计数器都是线程私有的。

2.1 程序计数器

  程序计数器可以看做是当前线程所执行的字节码的行号指示器。字节码解释器通过改变这个计数器的值俩选取下一条要执行的字节码指令。

因为Java虚拟机的多线程是通过线程轮流切换并分配处理器执行时间的方式实现的,在任一时刻,一个处理器内核只会执行一条线程中的命令。因此,网络线程切换后能够恢复到特定位置,每个线程都需要有一个独立的程序计数器。

注:在程序计数器中没有规定任何的内存溢出错误。

2.2虚拟机栈

  虚拟机栈描述的是Java方法执行的内存模型:每个方法在执行过程中都会创建一个栈帧用于存储局部变量表、操作数栈、动态链接、方法出口灯信息。

每一个方法从调用到执行完毕就对应一个栈帧在虚拟机栈都从入栈到出栈的过程。

  局部变量表存放了编译期可知的各种基本数据类型、对象引用(可能是指向对象起始地址的引用指针,也可能是指向代表对象的句柄)和returnAddress(指向一条字节码指向的地址)

  在虚拟机栈中规定了栈溢出和内存溢出两种异常。

2.3 本地方法栈

本地方法栈的作用与虚拟机栈是类似的,只不过本地方法栈是为虚拟机用到的native方法服务的。同样在本地方法栈也规定了栈溢出和内存溢出两种异常。

2.4 Java堆

Java堆是Java虚拟机所管理的内存中最大的一块,Java堆是被所有线程共享的,它的功能很单一,就是存放对象实例。此外因为存放的是对象的实例,Java堆是垃圾回收器管理的主要区域,因此也被称为GC堆。Java堆可以处于物理上不连续的内存空间,只要求其逻辑上是连续的即可。一般而言,Java堆是可扩展的(当然也可以实现为固定的),通过-Xmx和-Xms来控制。当没有内存可供分配且堆也无法扩展的时候,就会抛出内存溢出异常

2.5 方法区

方法区用于存储已经被加载的类信息、常量、静态变量和即时编译器编译后的代码等。方法区也常被人们称为永久代,当然主要原因是因为在这块区域中发生垃圾收集行为比较少。在Jdk1.7已经着手去永久代了,而在Jdk1.8中已经将永久代替换为了元空间(Metaspace)

运行时常量池是方法区的一部分,用于存放编译器生成的各种字面量和符号引用。

因为是线程私有,程序计数器、虚拟机栈和本地方法栈随线程而生,随线程而死,每一个栈帧随着方法的进入和退出有序地执行出栈和入栈的操作,每一个栈帧分配的内存也是在编译期可知的,因此,因为这些区域的内存分配和回收具有确定性,所以我们不需要考虑回收的问题。(当方法或者线程结束的时候,内存自然也就回收了)

三、垃圾收集器

在将垃圾收集器之前,我们首先需要明确的是:哪些内存需要回收?什么时候回收?怎么回收?

3.1 哪些垃圾需要回收?

  我们怎么判断哪些是需要回收的垃圾呢?正如前面提到的,GC的重点是在Java堆中,那么在垃圾收集器在对堆中的对象进行回收前,第一件需要判断的就是哪些对象已死(即不可能再被任何途径使用的对象)

  怎么判断对象是否存活,不得不提一下广为人知的引用计数法,即给一个对象添加一个引用计数器,每引用一次,计数器值加1,引用失效时,计数器值减1,当值为0时,该对象死亡。然而这个方法固然高效,但却存在一个很大的问题,它很难解决对象间的相互循环引用,即A引用B,B引用A,但其实二者都没有其他地方被引用,其二者已经不可能被访问到了,从合理性角度,这两个对象已经死了。

下面请出我们要介绍了主角,也是在Java虚拟机中所采用的方法---可达性分析算法

这个算法的基本思想就是通过一系列被称为“GC Roots”对象作为起始点,从这些节点向下所示,走过的路径被称为引用链,游离在外的对象即为不可用的对象。

那么显然这个方法的关键在于那些对象是可以作为GC Root:

1)虚拟机栈中引用的对象

2)方法区中类静态属性引用的对象

3)方法区中常量引用的对象

4)本地方法栈中native方法引用的对象。

3.2 什么时候需要回收?

  从一般来讲什么时候需要回收,即当对象已死的时候需要回收,但从严格意义上来讲,真正宣告一个对象死亡需要经历上述两次不可达的标记才会导致这个对象被收集。

当对象第一次被标记为不可达时,会对它进行一次筛选,判断其是否有必要执行finalize方法,当对象没有覆盖finalize方法或者finalize方法已经被虚拟机调用过了,则不会执行finalize方法。

那我们就可以在finalize方法中对对象进行最后的自救了,即在finalize方法中为对象和GC ROOT的引用链中的任一对象建立关联即可。

  除了这个,因为考虑到内存的有限性,不仅仅是对象死亡后才需要回收,为了有效利用内存,我们还需要有一些具有类似性质的对象,在内存足够时可以保留在内存中,当内存不够时即可被回收。这个特效其实就是很多系统缓存中用到的。

  为了实现上述特效,Java中对引用进行了扩展,将引用分为了强引用、软引用、弱引用和虚引用4种,其引用强度依次减弱。

    强引用:即我们一般的new出来的对象引用即为强引用
    软引用:用来描述一些还有用但不必需的对象,对于软引用关联的对象,当系统内存不足时会将这些对象列入到回收范围内进行回收。通过SoftReference类实现软引用
    弱引用:用来描述非必需的对象,被弱引用关联的对象只能生产到下一次垃圾收集发生之前,但是因为垃圾收集器的线程优先级低,所以他也不一定会被回收。通过WeakReference类实现弱引用
    虚引用:最弱的引用,一个对象是否有虚引用对其生存时间毫无影响,也无法通过虚引用来获取到一个对象实例,它的唯一作用就是能够在这个对象被回收时收到一个系统通知。通过PhantomReference类实现虚引用。

3.3 怎么回收?

  利用垃圾收集器进行回收。不同的垃圾收集器采用的收集算法或许不同,而这也会使得其收集时的细节不同。

3.3.1下面主要描述几种常用的收集算法:

(1)标记-清除算法:

  1)标记:标记出所有需要回收的对象

  2)清除:在标记完成后统一回收被标记的对象。

  上述图片其实将这个算法的主要缺点暴露无遗,可以发现回收后会产生大量不连续的内存碎片,空间碎片太多会导致以后在程序分配较大对象时无法找到连续内存而提前出发另一次垃圾回收,此外标记和清除过程效率也不高。

(2)复制算法

  复制算法将可用内存分为了大小相等的两块,每次我们只使用其中的一块,当这一块内存用完了,我们将这一块还存活的对象复制到另一块中,然后将已使用过的那一块内存空间一次性清空。

这样我们相当于每次只对其中一块进行内存回收,并且不会产生碎片,只需要一移动堆顶的指针,按顺序分配内存即可。但缺点很明显:我们可以使用的内存缩小为原来的一半。

  现在的商业虚拟机都采用这种算法来回收新生代,不过与之不同的是,它是将新生代的内存分为较大的Eden空间和两块较小的Survivor控件,每次使用Eden和其中一块Survivor空间。当回收时,将Eden和Survivor中还存活的对象一次性复制到另外一块Survivor上,最后清理到前面两个空间中的对象。

  当然了,我们无法保证每次一块Survivor中可以供所有存活的对象存活,所有依赖于老年代的内存进行分配担保。

(3)标记-整理算法

  在标记清除算法的基础上,提出了标记整理算法,这个算法在标记清除算法的基础上,在标记完可回收对象后,将所有存活的对象向一端移动,然后直接清理掉端边界以外的内存。

(4)分代收集算法

  当前虚拟机的垃圾收集都采用分代收集的思想,其实这个算法的核心在于根据对象存活的周期不同将内存划分为几块,一般分为新生代和老年代,这样就可以根据各个年代的特点选择合适的算法收集。

  1)在新生代中,对象朝生夕死,只有少量存活,可以选择复制算法。当新生代中的内存空间不够时,可以依赖老年代的内存空间。(即老年代为新生代进行分配担保)

  2)在老年代中对象存活率高。没有额外空间进行分配担保,就必须使用“标记-清除”或者是“标记-整理”算法。

3.3.2 Java的回收策略:

  为了更好了解Java的回收策略,我们得首先对Java的内存分配规则。

  1)Java的内存分配规则

  Java对象的内存分配,即在堆上进行分配,对象主要被分配在新生代的Eden区(如果启动了本地线程分配缓存,将按线程优先在TLAB上分配),少数情况下直接分配在老年代中。下面是几条规则:

  (1)对象优先在Eden分配:

  大多数情况下,对象在新生代Eden区分配,当Eden去没有足够空间分配时,虚拟机将发起一次Minor GC。

  (2)大对象直接进行老年代:

  所谓的大对象即是需要大量练习内存空间的Java对象,最典型的大对象就是那种很长的字符串以及数组。

  (3)长期存活的对象将进入老年代:

  虚拟机为每个对象定义了一个Age计数器,当对象在Eden出生,并经过一次Minor GC仍然存活并能够被Survivor容纳,将被移动到Survivor空间中,当其在Survivor区中每熬过一次Minor GC,年龄加1,当年龄到一定岁数后即会升级到老年代中,这是第一种升级的方法。

  第二种升级发方法是如果在Survivor空间中相同年龄的所有对象大小总和大于Survivor控件的一半,年龄大于等于这个阈值的对象就可以进入老年代。

  2)垃圾回收

  上面提到了Minor GC,什么是Minor GC,Minor GC是指发生在新生代的垃圾回收动作。而与之对应的Major/Full GC是指发生了老年代的GC。

前面提到了Minor GC的触发条件,即Eden没有足够空间分配内存的时候,那什么时候会触发Major GC。

一般而言,当老年代的连续空间大于新生代对象总大小或者历次升级到老年代的平均大小就会进行Minor GC,否则才会进行Major GC。

这其中就涉及到一个先前提到的概念,分配担保。因为老年代需要为新生代分配内存做担保,当老年代无法为新生代的对象分配空间进行担保时,就可能会触发Major GC,从而腾出一定空间给新生代的对象升级。

从Java虚拟机的内存区域、垃圾收集器及内存分配原则谈Java的内存回收机制的相关教程结束。

《从Java虚拟机的内存区域、垃圾收集器及内存分配原则谈Java的内存回收机制.doc》

下载本文的Word格式文档,以方便收藏与打印。