浅析JVM以内存管理

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

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

/**
 * 此代码演示了两点
 * 一、对象能够在被GC时自我拯救
 * 二、这种自救的机会只有一次,由于一个对象的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过于频繁。

相关文章
相关标签/搜索