浅析JVM之内存管理

这是一篇有关JVM内存管理的文章。这里将会简单的分析一下Java如何使用从物理内存上申请下来的内存,以及如何来划分它们,后面还会介绍JVM的核心技术:如何分配和回收内存。

JMM ( Java Memory Model )概要

JVM-JMM.png

要理解JVM的内存管理策略,首先就要熟悉Java的运行时数据区,如上图所示,在执行Java程序的时候,虚拟机会把它所管理的内存划分为多个不同的数据区,称为运行时数据区。在程序执行过程中对内存的分配、垃圾的回收都在运行时数据区中进行。对于Java程序员来说,其中最重要的就是堆区和JVM栈区了。注意图中的图形面积比例并不代表实际的内存比例

  • 绿色的区域代表被线程所共享
  • 黄色的区域代表被线程所独享

下面来简单的讲一下图中的区块。

  • 方法区:存储虚拟机运行时加载的类信息、常量、静态变量和即时编译的代码,因此可以把这一部分考虑为一个保存相对来说数据较为固定的部分,常量和静态变量在编译时就确定下来进入这部分内存,运行时类信息会直接加载到这部分内存,所以都是相对较早期进入内存的。

    - **运行时常量池**:在JVM规范中是这样定义运行时常量池这个数据结构的:Runtime Constant Pool代表运行时每个class文件的常量表。它包含几种常量:编译期的数字常量、方法和域的引用(在运行时解析)。它的功能类似于传统编程语言的符号表,尽管它包含的数据比典型的符号表要丰富得多。每个Runtime Constant Pool都是在JVM的Method area中分配的,每个Class或者Interface的Constant Pool都是在JVM创建class或接口时创建的。它是**属于方法区**的一部分,所以它的存储也受方法区的规范约束,如果常量池无法分配,同样会抛出OutOfMemoryError。
    
  • 堆区:是JVM所管理的内存中最大的一块。主要用于存放对象实例,每一个存储在堆中的Java对象都会是这个对象的类的一个副本,它会复制包括继承自它父类的所有非静态属性。而所谓的垃圾回收也主要是在堆区进行。 根据Java虚拟机规范的规定,Java堆可以处于物理上不连续的内存空间中,只要逻辑是上连续的即可,就像我们的磁盘空间一样。在实现上,既可以实现成固定大小的,也可以是可扩展的:

    - 如果是固定大小的,那么堆的大小在JVM启动时就一次向操作系统申请完成,旦分配完成,堆的大小就将固定,不能在内存不够时再向操作系统重新申请,同时当内存空闲时也不能将多余的空间交还给操作系统。
    - 如果是可扩展的,则通过 -Xmx和 -Xms两个选项来控制大小,Xmx来表示堆的最大大小,Xms表示初始大小。
    
  • JVM栈区:则主要存放一些对象的引用和编译期可知的基本数据类型,这个区域是线程私有的,即每个线程都有自己的栈。在Java虚拟机规范中,对这个区域规定了两种异常情况:

    • 如果线程请求的栈深度大于虚拟机锁所允许的深度,则抛出StackOverflowError异常
    • 如果虚拟机栈可以动态扩展,扩展时无法申请到足够的内存,就会抛出OutOfMemoryError异常
  • 本地方法栈:本地方法在运行时存储数据产生的栈区。为JVM运行Native方法准备的空间,它和前面介绍的Java栈的作用是类似的,由于很多Native方法都是C语言实现的,所以它通常又叫C栈。和虚拟机栈一样,也会抛出StackOverflowErrorOutOfMemoryError异常。
  • 程序计数器:则是用来记录程序运行到什么位置的,显然它应该是线程私有的,相信这个学过微机原理与接口课程的同学都应该能够理解的。

举个栗子

通常我们定义一个基本数据类型的变量,一个对象的引用,还有就是函数调用的现场保存都使用内存中的栈空间;而通过new关键字和构造器创建的对象放在堆空间;程序中的字面量(literal)如直接书写的100、“hello”和常量都是放在静态存储区中。栈空间操作最快但是也很小,通常大量的对象都是放在堆空间,整个内存包括硬盘上的虚拟内存都可以被当成堆空间来使用。

String str = new String(“hello”);
  • str 这个引用放在栈上
  • new 创建出来的对象实例放在堆上

JVM内存分配策略

在分析JVM内存分配策略之前,我们先介绍一下通常情况下操作系统都是采用哪些策略来分配内存的。

通常的内存分配策略

在操作系统中,将内存分配策略分为三种,分别是:

  • 静态内存分配
  • 栈内存分配
  • 堆内存分配

静态内存分配 是指在程序编译时锯能确定每个数据在运行时的存储空间需求,因此在编译时就可以给它们分配固定的内存空间。这种分配策略不允许在程序代码中有可变数据结构(如可变数组)的存在,也不允许有嵌套或者递归的结构出现,因为它们都会导致编译程序无法计算机准确的存储空间需求。

栈内存分配 也可称为动态存储分配,是由一个类似于堆栈的运行栈来实现的。和静态内存分配相反,在栈式内存方案执行宏,程序对数据区的需求在编译时是完全无知的,只有运行时才能知道,但是规定在运行中进入一个程序模块时,必须知道该程序模块所需数据区大小才能为其分配内存。和我们所数值的数据结构中的栈一样,栈式内存分配按照先进后出的原则进行分配。

堆内存分配 当程序真正运行到相应代码时才会知道空间大小。

Java中的内存分配一览

JVM内存分配主要基于两种:堆和栈。

先来说说

Java栈的分配是和线程绑定在一起的,当我们创建一个线程时,很显然,JVM就会为这个线程创建一个新的Java栈,一个线程的方法的调用和返回对应这个Java栈的压栈和出栈。当线程激活一个Java方法时,JVM就会在线程的Java栈里新压入一个帧,这个帧自然成了当前帧。在此方法执行期间,这个帧将用来保存参数、局部变量、中间计算过程和其他数据。

栈中主要存放一些基本类型的变量数据和对象句柄(引用)。存取速度比堆要快,仅次于寄存器,栈数据可以共享。缺点是,存在栈中的数据大小与生存期必须是确定的,这也导致缺乏了其灵活性。

Java的 是一个运行时数据区,它们不需要程序代码来显示地释放。堆是由垃圾回收来负责的,堆的优势是可以动态地分配内存大小,生存期也不必事先告诉编译器,因为它是在运行时动态分配内存的,Java的垃圾收集器会自动收走这些不再使用的数据。但缺点是,由于要运行时动态分配内存,存取速度慢。

从堆和栈的功能和作用通俗地比较,堆主要用来存放对象,栈主要用来执行程序,这种不同主要由堆和栈的特点决定的。

在编程中,如C/C++,所有的方法调用是通过栈进行的,所有的局部变量、形式参数都是从栈中分配内存空间的。实际上也不是什么分配,只是从栈向上用就行,就好像工厂中的传送带一样,栈指针会自动指引你到放东西的位置,你所要做的只是把东西放下来就行。在退出函数时,修改栈指针就可以把栈中的内润销毁。这样的模式速度最快,当然要用来运行程序了。需要注意的是,在分配时,如为一个即将要调用的程序模块分配数据区时,应事先知道这个数据区的大小,也就是说上虽然分配是在程序运行中进行的,但是分配的大小是确定的、不变的,而这个“大小多少”是在编译时确定的,而不是在运行时。

堆在应用程序运行时请求操作系统给自己分配内存,由于操作系统管理内存分配,所以在分配和销毁时都要占用时间,因此用堆的效率非常低。但是堆的优点在于,编译器不必知道从堆里分配多少存储空间,也不必知道存储的数据要在堆里停留多长时间。因此,用堆保存数据时会得到更大的灵活性,事实上,由于面向对象的多态性,堆内存分配是必不可少的,因为多态变量所需的存储空间只有在运行时创建了对象之后才能确定。在C++中,要求创建一个对象时,只需用new命令编制相关命令即可。执行这些代码时,会在堆里自动进行数据的保存。当然,为达到这种灵活性,必然会付出一定的代价——在堆里分配存储空间会花掉更长的时间。

JVM内存回收策略

基本术语

垃圾(Garbage)

即需要回收的对象。作为编写程序的人,是可以做出“这个对象已经不再需要了”这样的判断,但计算机是做不到的。因此,如果程序(通过某个变量等等)可能会直接或间接地引用一个对象,那么这个对象就被视为“存活”;与之相反,已经引用不到的对象被视为“死亡”。将这些“死亡”对象找出来,然后作为垃圾进行回收,这就是GC的本质。

根(Root)

即判断对象是否可被引用的起始点。至于哪里才是根,不同的语言和编译器都有不同的规定,但基本上是将变量和运行栈空间作为根。各位肯定会好奇根对象集合中都是些什么,下面就来简单的讲一讲:

  • 在方法中局部变量区的对象的引用
  • 在Java操作栈中的对象引用:有些对象是直接在操作栈中持有的,所以操作栈肯定也包含根对象集合。
  • 在常量池中的对象引用:每个类都会包含一个常量池,这些常量池中也会包含很多对象引用,如表示类名的字符串就保存在堆中,那么常量池只会持有这个字符串对象的引用。
  • 在本地方法中持有的对象引用:有些对象被传入本地方法中,但是这些对象还没有被释放。
  • 类Class对象:当每个类被JVM加载时都会创建一个代表这个类的唯一数据类型的Class对象,而这个Class对象也同样存放在堆中,当这个类不再被使用时,在方法去中类数据和这个Class对象同样需要被回收。
  • JVM在做GC时会检查堆中所有对象是否都会被这些根对象直接或间接引用,能够被引用的对象就是活动对象,否则就可以被垃圾收集器回收。

stop-the-world

  • 不管选择哪种GC算法,stop-the-world都是不可避免的。Stop-the-world意味着从应用中停下来并进入到GC执行过程中去。一旦Stop-the-world发生,除了GC所需的线程外,其他线程都将停止工作,中断了的线程直到GC任务结束才继续它们的任务。不然由于应用代码一直在运行中,会不断创建和修改对象,导致结果腐化。GC调优通常就是为了改善stop-the-world的时间。

内存的分配方法

指针碰撞

在连续剩余空间中分配内存。用一个指针指向内存已用区和空闲区的分界点,需要分配新的内存时候,只需要将指针向空闲区移动相应的距离即可。

空闲列表

在不规整的剩余空间中分配内存。如果剩余内存是不规整的,就需要用一个列表记录下哪些内存块是可用的,当需要分配内存的时候就需要在这个列表中查找,找到一个足够大的空间进行分配,然后在更新这个列表。

分配方式的选择

指针碰撞的分配方式明显要优于空闲列表的方式,但是使用哪种方式取决于堆内存是否规整,而堆内存是否规整则由使用的垃圾收集算法决定。如果堆内存是规整的,则采用指针碰撞的方式分配内存,而如果堆是不规整的,就会采用空闲列表的方式。

垃圾回收是如何进行的?

寻找垃圾

要对对象进行回收,首先需要找到哪些对象是垃圾,需要回收。有两种方法可以找到需要回收的对象,第一种叫做引用计数法

GCBA-RC.png

具体方法就是给对象添加一个引用计数器,计数器的值代表着这个对象被引用的次数,当计数器的值为0的时候,就代表没有引用指向这个对象,那么这个对象就是不可用的,所以就可以对它进行回收。但是有一个问题就是当对象之间循环引用时,比如这样:

public class Main {
   public static void main(String[] args) {
       MyObject object1 = new MyObject();
       MyObject object2 = new MyObject();

       object1.object = object2;
       object2.object = object1;
//最后面两句将object1和object2赋值为null,也就是说object1和object2指向的对象已经不可能再被访问,
//但是由于它们互相引用对方,导致它们的引用计数都不为0,那么垃圾收集器就永远不会回收它们。
       object1 = null;
       object2 = null;
   }
}

class MyObject{
   public Object object = null;
}

其中每个对象的引用计数器的值都不为0,但是这些对象又是作为一个孤立的整体在内存中存在,其他的对象不持有这些对象的引用,这种情况下这些对象就无法被回收,这也是主流的Java虚拟机没有选用这种方法的原因。

另一种方法就是把堆中的对象和对象之间的引用分别看作有向图的顶点和有向边——即可达性分析法。这样只需要从一些顶点开始,对有向图中的每个顶点进行可达性分析(深度优先遍历是有向图可达性算法的基础),这样就可以把不可达的对象找出来,这些不可达的对象还要再进行一次筛选,因为如果对象需要执行finalize()方法,那么它完全可以在finalize()方法中让自己变的可达。这个方法解决了对象之间循环引用的问题。上面提到了“从一些对象开始”进行可达性分析,这些起始对象被称为GC Roots,可以作为GC Roots的对象有:

  1. 栈区中引用的对象
  2. 方法区中静态属性或常量引用的对象

上文中提到的引用均是强引用,Java中还存在其他三种引用,分别是,软引用、弱引用和虚引用,当系统即将发生内存溢出时,才会对软引用所引用的对象进行回收;而被弱引用所引用的对象会在下一次触发GC时被回收;虚引用则仅仅是为了在对象被回收时能够收到系统通知。

生存还是死亡

即使在可达性分析算法中不可达的对象,也并非是“非死不可”的,这时候它们暂时处于“缓刑”阶段,要真正宣告一个对象死亡,至少要经历再次标记过程。

标记的前提是对象在进行可达性分析后发现没有与GC Roots相连接的引用链。

第一次标记并进行一次筛选

  • 筛选的条件是此对象是否有必要执行finalize()方法。
    当对象没有覆盖finalize方法,或者finzlize方法已经被虚拟机调用过,虚拟机将这两种情况都视为“没有必要执行”,对象被回收。

第二次标记

  • 如果这个对象被判定为有必要执行finalize()方法,那么这个对象将会被放置在一个名为:F-Queue的队列之中,并在稍后由一条虚拟机自动建立的、低优先级的Finalizer线程去执行。这里所谓的“执行”是指虚拟机会触发这个方法,但并不承诺会等待它运行结束。这样做的原因是,如果一个对象finalize()方法中执行缓慢,或者发生死循环(更极端的情况),将很可能会导致F-Queue队列中的其他对象永久处于等待状态,甚至导致整个内存回收系统崩溃。

Finalize()方法是对象脱逃死亡命运的最后一次机会,稍后GC将对F-Queue中的对象进行第二次小规模标记,如果对象要在finalize()中成功拯救自己————只要重新与引用链上的任何的一个对象建立关联即可,譬如把自己赋值给某个类变量或对象的成员变量,那在第二次标记时它将移除出“即将回收”的集合。如果对象这时候还没逃脱,那基本上它就真的被回收了。

JVM-GC-finalize.png

/**
 * 此代码演示了两点
 * 1、对象可以在被GC时自我拯救
 * 2、这种自救的机会只有一次,因为一个对象的finalize()方法最多只能被系统自动调用一次。
 */
public class FinalizeEscapeGC {
    public static FinalizeEscapeGC SAVE_HOOK = null;

    public void isAlive() {
        System.out.println("yes, I am still alive");
    }

    protected void finalize() throws Throwable {
        super.finalize();
        System.out.println("finalize method executed!");
        FinalizeEscapeGC.SAVE_HOOK = this;
    }

    public static void main(String[] args) throws InterruptedException {
        SAVE_HOOK = new FinalizeEscapeGC();

        //对象第一次成功拯救自己
        SAVE_HOOK = null;
        System.gc();

        //因为finalize方法优先级很低,所有暂停0.5秒以等待它
        Thread.sleep(500);
        if (SAVE_HOOK != null) {
            SAVE_HOOK.isAlive();
        } else {
            System.out.println("no ,I am dead QAQ!");
        }

        //-----------------------
        //以上代码与上面的完全相同,但这次自救却失败了!!!
        SAVE_HOOK = null;
        System.gc();

        //因为finalize方法优先级很低,所有暂停0.5秒以等待它
        Thread.sleep(500);
        if (SAVE_HOOK != null) {
            SAVE_HOOK.isAlive();
        } else {
            System.out.println("no ,I am dead QAQ!");
        }
    }
}

最后想说的是:请不要使用finalize()方法,使用try-finalize可以做的更好。这是一个历史遗留的问题——当年为了让C/C++程序员更好的接受它而做出的妥协。

垃圾收集算法

好了,我们找到了垃圾。来谈谈如何处理这些垃圾吧。

标记-清除算法

标记清除(Mark and Sweep)是最早开发出的GC算法(1960年)。它的原理非常简单,首先从根开始将可能被引用的对象用递归的方式进行标记,然后将没有标记到的对象作为垃圾进行回收。

GCBA-MAS.png

通过可达性分析算法找到可以回收的对象后,要对这些对象进行标记,代表它可以被回收了。标记完成之后就统一回收所有被标记的对象。这就完成了回收,但是这种方式会产生大量的内存碎片,就导致了可用内存不规整,于是分配新的内存时就需要采用空闲列表的方法,如果没有找到足够大的空间,那么就要提前触发下一次垃圾收集。

标记-整理算法

作为标记清除的变形,还有一种叫做标记整理(Mark and Compact)的算法。

GCBA-MC.png

标记的过程和标记-清除算法一样,但是标记完成之后,让所有存活的对象都向堆内存的一端移动,最后直接清除掉边界以外的内存。这样对内存进行回收之后,内存是规整的,于是可以使用指针碰撞的方式分配新的内存。

复制收集算法

“标记”系列的算法有一个缺点,就是在分配了大量对象,并且其中只有一小部分存活的情况下,所消耗的时间会大大超过必要的值,这是因为在清除阶段还需要对大量死亡对象进行扫描。复制收集(Copy and Collection)则试图克服这一缺点。在这种算法中,会将从根开始被引用的对象复制到另外的空间中,然后,再将复制的对象所能够引用的对象用递归的方式不断复制下去。

GCBA-CAC.png

  • 图2的(1)部分是GC开始前的内存状态,这和图1的(1)部分是一样的
  • 图2的(2)部分中,在旧对象所在的“旧空间”以外,再准备出一块“新空间”,并将可能从根被引用的对象复制到新空间中
  • 图2的(3)部分中,从已经复制的对象开始,再将可以被引用的对象像一串糖葫芦一样复制到新空间中。复制完成之后,“死亡”对象就被留在了旧空间中
  • 图2的(4)部分中,将旧空间废弃掉,就可以将死亡对象所占用的空间一口气全部释放出来,而没有必要再次扫描每个对象。下次GC的时候,现在的新空间也就变成了将来的旧空间

通过图2我们可以发现,复制收集方式中,只存在相当于标记清除方式中的标记阶段。由于清除阶段中需要对现存的所有对象进行扫描,在存在大量对象,且其中大部分都即将死亡的情况下,全部扫描一遍的开销实在是不小。而在复制收集方式中,就不存在这样的开销。

但是,和标记相比,将对象复制一份所需要的开销则比较大,因此在“存活”对象比例较高的情况下,反而会比较不利。这种算法的另一个好处是它具有局部性(Lo-cality)。在复制收集过程中,会按照对象被引用的顺序将对象复制到新空间中。于是,关系较近的对象被放在距离较近的内存空间中的可能性会提高,这被称为局部性。局部性高的情况下,内存缓存会更容易有效运作,程序的运行性能也能够得到提高。

基于分代技术的算法抉择

上文提到了几种GC算法,但是各自的各自的优点,必须放到适合的场景内才能发挥最大的效率。

在JVM堆里分有两部分:新生代(young generate)和老年代(old generation)。

JVM-stack.png

在新生代中长期存活的对象会逐渐向老年代过渡,新生代中的对象每经历一次GC,年龄就增加一岁,当年龄超过一定值时,就会被移动到老年代。

新生代

大部分的新创建对象分配在新生代。因为大部分对象很快就会变得不可达,所以它们被分配在新生代,然后消失不再。当对象从新生代移除时,我们称之为"Minor GC"。新生代使用的是复制收集算法

新生代划分为三个部分:分别为Eden、Survivor from、Survivor to,大小比例为8:1:1(为了防止复制收集算法的浪费内存过大)。每次只使用Eden和其中的一块Survivor,回收时将存活的对象复制到另一块Survivor中,这样就只有10%的内存被浪费,但是如果存活的对象总大小超过了Survivor的大小,那么就把多出的对象放入老年代中。

在三个区域中有两个是Survivor区。对象在三个区域中的存活过程如下:

  1. 大多数新生对象都被分配在Eden区。
  2. 第一次GC过后Eden中还存活的对象被移到其中一个Survivor区。
  3. 再次GC过程中,Eden中还存活的对象会被移到之前已移入对象的Survivor区。
  4. 一旦该Survivor区域无空间可用时,还存活的对象会从当前Survivor区移到另一个空的Survivor区。而当前Survivor区就会再次置为空状态。
  5. 经过数次(默认是15次)在两个Survivor区域移动后还存活的对象最后会被移动到老年代。

如上所述,两个Survivor区域在任何时候必定有一个保持空白。如果同时有数据存在于两个Survivor区或者两个区域的的使用量都是0,则意味着你的系统可能出现了运行错误。

老年代

存活在新生代中但未变为不可达的对象会被复制到老年代。一般来说老年代的内存空间比新生代大,所以在老年代GC发生的频率较新生代低一些。当对象从老年代被移除时,我们称之为 "Major GC"(或者Full GC)。 老年代使用标记-清理或标记-整理算法

老年代里放着什么?
  • new 出来的大对象
  • 长期存活的对象(前面说过)
  • 如果在Survivor空间中相同年龄所有对象的综合大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无须等待MaxTenuringThreshold中要求的年龄(默认是15)。
空间分配担保

在发生Minor GC前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象总空间。

  1. 如果大于,那么Minor GC可以确保是安全的。
  2. 如果小于,虚拟机会查看HandlePromotionFailure设置值是否允许担任失败。

    • 如果允许,那么会继续检查老年代最大可用连续空间是否大于历次晋升老年代对象的平均大小

      • 如果大于,将尝试着进行一次Minor GC,尽管这次Minor GC是有风险的
      • .如果小于,进行一次Full GC.
    • 如果不允许,也要改为进行一次Full GC.

前面提到过,新生代使用复制收集算法,但为了内存利用率,只使用其中一个Survivor空间来作为轮换备份,因此当出现大量对象在Minor GC后仍然存活的情况时(最极端就是内存回收后新生代中所有对象都存活),就需要老年代进行分配担保,让Survivor无法容纳的对象直接进入老年代。与生活中的贷款担保类似,老年代要进行这样的担保,前提是老年代本身还有容纳这些对象的剩余空间,一共有多少对象会活下来,在实际完成内存回收之前是无法明确知道的,所以只好取之前每一次回收晋升到老年代对象容量的平均大小值作为经验值,与老年代的剩余空间进行比较,决定是否进行Full GC来让老年代腾出更多空间。

取平均值进行比较其实仍然是一种动态概率的手段,也就是说如果某次Minor GC存活后的对象突增,远远高于平均值的话,依然会导致担保失败(Handle Promotion Failure)。如果出现了HandlePromotionFailure失败,那就只好在失败后重新发起一次Full GC。虽然担保失败时绕的圈子是最大的,但大部分情况下都还是会将HandlePromotionFailure开关打开,避免Full GC过于频繁。

阅读 7.7k

推荐阅读
泊浮说
用户专栏

作者是个热爱分享交流的人,所以有了这个专栏。你的点赞是我最大的更新动力。

56 人关注
45 篇文章
专栏主页
目录