JVM—深入理解内存模型与垃圾收集机制

803 阅读19分钟

前言

Java是一种跨平台的语言,当初其设计初衷也是为了解决各个平台编译环境具有差异,对程序移植性问题造成困难这一痛点,于是推出了Java语言。这么多年Java受业界追捧的原因除了其面向对象的特性以外就是其可移植性强,而可移植性这一特性正式建立在JVM虚拟机这一基础上的,JVM在其内存模型和垃圾回收机制的设计上堪称神作,了解JVM虚拟机是每一个Java开发工程师必备的技能。

你了解Java的内存模型吗

  • 内存简介

    要说清楚内存,首先要提计算机程序是如何运行的。计算机程序指的就是可以让计算机运行的一些指令集合,简单地说就是我们平时写的代码,而真正在计算机中运行的是进程进程=代码+数据,而要操作数据,则应该先将数据加载进内存中,才能对其进行进一步的操作。而内存就是一系列地址空间,地址空间又分为内核空间用户空间内核空间是计算机操作系统运行时所需的空间,如虚拟内存、联网、操作系统调度等所需的空间,而Java进程实际运行时使用的空间是我们的用户空间

  • JVM架构图

    JVM架构图

    类装载器(ClassLoader):依据特定格式,class文件加载到内存。
    执行引擎(Execution Engine):对命令进行解析。
    本地库接口(Native Interface):融合不同开发语言的原生库为Java所用。
    内存区域(Runtime Data Area):JVM内存空间结构模型。

  • 划分

    从Java的内存区域中可以看到,其分为五个区,分别是 1.程序计数器、2.虚拟机栈、3.本地方法栈、4.堆区、5.方法区(元空间)而在这五个区中,分为线程私有共享区域

    • 线程私有

      1.程序计数器(Program Counter Register):当前线程所执行字节码(class文件)行号指示器(逻辑),它改变自身的计数器的值来选取下一条需要执行的字节码指令,为了程序执行不互相冲突,所以每个线程必须私有程序计数器,保证程序运行不冲突。注:如果是执行Native方法,则计数器值为Undefined。 程序计数器中存储的数据所占空间的大小不会随程序的执行而发生改变, 所以此区域不会出现 OutOfMemoryError 的情况。

      2.Java虚拟机栈(Stack):Java虚拟机栈即我们平时所说的Java内存模型里的栈内存,其存放的最小单位为栈帧,Java虚拟机栈中的每个栈帧主要存储局部变量表、操作数栈、动态链接、返回地址,当方法调用结束时,该栈帧随即被销毁,栈帧内的局部变量也随即被销毁。这里说一下局部变量表操作栈局部变量表包含了方法执行过程中的所有变量,而操作数栈主要实现入栈、出栈、复制、交换、产生消费变量等。该区域会产生两种异常,即 当线程请求的栈大小超过栈的总深度,抛出StackOverflowError异常(例如递归),当栈进行扩展时无法得到足够的内存,则抛出OutOfMemoryError异常。

      3.本地方法栈(Native Method Stack):与虚拟机栈相似,主要存非Java语言的方法。同样会抛出StackOverflowError和OutOfMemoryError异常

    • 所有线程共享

      1.方法区(Method Area):方法区主要存储Class的相关信息,包括Method和field等等,说这个之前首先说元空间(MetaSpace)永久代(PermGen)的区别,在Java1.7后,将方法区中的字符串常量池移动到Java堆中,并且Java1.7之后将永久代变为元空间,它们两个最大的区别就是元空间使用本地内存而永久代使用JVM内存,这一改变最大的变化就是,不会再看到ParmGen出现内存溢出的异常了,而且字符串常量池存在永久代中,容易出现性能问题和内存溢出,类和方法的信息大小难以确定,给永久代的大小指定带来困难。
      2.Java堆(Heap):该区域是Java内存模型中最大的一块,该区域存储所有对象的实例,即我们在写代码时new出来的对象,都存在堆区,当堆无法再分配内存时,将会抛出OutOfMemoryError异常。该区域是GC管理的主要区域,因此Java堆又被称为GC堆,由于GC在垃圾回收的时候使用分代收集,所以堆内存也可以被分为新生代老年代,老年代占堆内存的2/3,新生代占1/3,新生代又可以细分为Eden区From区To区,Eden区的Eden伊甸园的意思,圣经记载,亚当和夏娃在伊甸园偷食禁果,所以伊甸区是人类的起源地,名字也就来源于此,我们在程序中new出的对象(除大对象,大对象直接进入老年代),都存在于Eden区,当多次GC后没有被回收,则会进入老年代,这一块在说垃圾回收机制的时候会细说,这里只要知道Java堆大概分为这几个区域即可。

关于Java内存模型的面试题

  • JVM三大性能调优参数-Xms -Xmx -Xss的含义

    答: 1.-Xss:规定了每个线程虚拟机栈(堆栈)的大小。
    2.-Xms:堆的初始值。
    3.-Xmx:堆能达到的最大值。通常将堆的初始值和最大值设为相同值,防止堆扩容时产生内存抖动问题。

  • Java内存模型中堆和栈的区别——内存分配策略

    静态存储:编译时确定每个数据目标在运行时的存储空间需求。
    栈式存储:数据区需求在编译时未知,运行时模块入口前确定。
    堆式存储:编译时或运行时模块入口都无法确定,动态存储。
    堆和栈的联系,引用对象、数组时,栈里定义变量保存堆中对象目标的首地址。
    堆和栈的不同,栈中的变量在方法运行结束后立即被清除(自动释放),而堆中的对象即使失去引用变为不可达对象,也需等待GC才会被清除,即清除时间时不确定的(需要GC)。
    栈的空间较堆空间小,且栈产生的碎片远小于堆。
    栈的效率比堆高。

    堆和栈

  • JDK6和JDK6之后的版本对intern()方法的区别

    JDK6:当调用intern方法时,如果字符串常量池先前已创建出该字符串对象,则返回池中的该字符串的引用。否则,将此字符串对象添加到字符串常量池中,并且返回该字符串对象的引用。

    JDK6+:当调用intern方法时,如果字符串常量池先前已创建出该字符串对象,则返回池中的该字符串的引用,否则,如果该字符串对象已经存在于Java堆中,则将堆中对此对象的引用添加到字符串常量池中,并且返回该引用;如果堆中不存在,则在池中创建该字符串并返回其引用。

    注:Java1.8中 已经将字符串常量池已经从方法区移动到堆中

Java垃圾回收机制

  • 对象被判定为垃圾的标准

    在垃圾回收机制中,把没有被其它对象引用的对象判定为垃圾,而垃圾回收机制的各种算法也是基于这一标准,主要的中心即放在如何判定一个对象是否被引用如何被回收

  • 引用计数算法

    引用计数算法中,主要是通过计算一个对象的引用数量来判断对象是否为垃圾,是否应该被回收。其实现方式是对存在于堆中的每一个对象都置一个引用数量计数器。当创建一个对象时,将该对象实例分配给一个引用对象,则将该对象的引用数量计数器的值加一,完成引用则减一。因此,当该实例对象的引用计数器值为0时,则可以将该对象视为垃圾,在GC调用时,则将会回收该对象的空间。
    引用计数算法的优劣:引用计数算法其优点是执行效率高,程序执行受影响较小,因为其运行时只需将引用数量计数器的值加一或减一,运算量极小,效率极高,可以交织在程序运行中。其缺点也是十分明显的,引用计数算法有一个致命的缺陷,就是它无法处理循环引用的情况,所谓循环引用就是当A引用B,B又引用A,两个对象互相引用,实际上这两个对象是可以被回收的,但由于其引用计数器的值均为1,所以造成了此种算法判定这两个对象为不可回收,导致内存泄漏。所以Java中的GC并不会采用此种算法。

    循环引用

  • 可达性分析算法

    可达性分析算法是通过判断对象的引用链是否可达,来决定对象是否可以被回收,该算法从离散数学中的图论引入,程序之间的引用关系可以看作是一个十分复杂的图,通过一系列的名为GC Root的节点作为起始点,向下搜索,搜索中走过的路径就被称为引用链(Reference Chain),当一个对象从GC Root没有任何的引用链,则证明该对象是不可达的,该对象就会被标记为垃圾。

    例如图中Object5、Object6、Object7均为不可达,所以这三个对象将会在下一次GC中被清除。

    可达性分析算法

    可作为GC Root的对象: 1.虚拟机栈中引用的对象(栈帧中的局部变量表)
    2.方法区中常量引用的对象
    3.方法区中类静态属性引用的对象
    4.本地方法栈中Native方法中的引用对象
    5.活跃线程的引用对象
    简单来说:就是所有被引用的对象(包括静态对象和非静态对象)+线程+Native方法中的对象,都可以作为GC Root的对象。

垃圾回收算法

这里可能有人会蒙,刚才不是谈了垃圾回收算法了吗,怎么又开始说垃圾回收算法了,其实从这里开始,才是真正的垃圾回收算法,上面的两个算法可以算是垃圾回收前的准备工作,即对要回收的对象进行标记判断。这个对象是否应该被回收,是上面那两个算法的工作,而这个对象应该怎么被回收,回收后要对内存做哪些工作,这就是垃圾回收算法所要考虑的事情。

  • 标记-清除算法(Mark and Sweep)

    标记-清除算法将算法分为两个步骤,即标记清除,所谓标记,就是从根节点进行扫描,对存活的对象进行标记。所谓清除,就是对堆内存中从头到尾进行线性遍历,回收未被标记的对象内存,即不可达对象内存,最后将原来做过标记对象的标记清空,为下一次GC做准备

    标记-清除算法的优缺点:标记-清除算法的优势是其效率高,仅需扫描一遍内存即可将所有的垃圾进行回收。但是其缺陷也是十分的明显,在标记-清除算法中,只要某对象被标记为垃圾,则调用GC时就会直接进行回收,这势必会带来一个问题,就是内存的碎片化。所谓内存碎片化,即在GC过程中,由于垃圾所处的内存空间并不连续,导致回收过后会存在很多的不连续的内存空间。
    举个例子,有两个对象A and B,A占用1B内存,B占用1B内存,他们两个所处的位置并不连续,而当它们被同时标记为垃圾并被回收了之后,就会产生两块1B的内存,此时来了一个2B的对象,但是它就无法使用这两块不连续的1B存储空间了。如果此时内存已满,将会抛出OutOfMemoryException,这就是内存碎片所造成的后果。

    碎片化

  • 复制算法(Copying)

    复制算法,将内存分为对象面空闲面,对象只存在于对象面上。当复制算法运行时,首先会像标志-清除算法一样,对存在引用的对象做标记,然后将带有标记的对象复制到空闲面上,并且按照内存顺序存储,当全部带标记的对象都被移动到空闲面上后,将对象面的所有对象一并清除,然后将空闲面和对象面进行互相转换,即此时对象面变为空闲面,空闲面变为对象面。由于复制操作也存在效率问题,所以这种算法适用于对象存活率低的场景,因为这样就不会有很多的对象需要复制。实际上这种算法是应用在堆内存中的新生代中的,因为在大量的实践中证明,在新生代区的对象,最后存活下来的比例大概只有10%,所以相当适合这种算法。至于在新生代中这种算法的运行步骤是怎样的,放在下文中说。
    由于复制算法对复制后的对象按照内存顺序存储,所以它解决了标记-清除算法中内存碎片化的问题。

    复制算法

  • 标记-整理算法(Compacting)

    标记-整理算法采用和标记-清除算法一样的步骤,从根集合进行扫描,对存活的对象进行标记,但在清除时,这个算法会移动所有存活的对象,且按照内存地址次序依次进行排列,然后将末端内存地址以后的内存全部进行回收。由于此种算法在标记-清除的基础上,加之对对象进行整理,所以其效率更低,但解决了内存碎片化的问题。
    该算法由于一次GC会有较高的资源消耗,所以该算法适用于存活率高的场景,例如堆内存中的老年代

    标记整理算法

  • 分代收集算法(Generational Collector)

    有了上述三种的垃圾回收算法,有些同学可能心存疑虑,到底JVM中使用的是哪一种算法来对垃圾进行回收呢?其实JVM使用了上述几种算法的组合拳,即分代收集算法。从严格意义上来说,分代收集算法并不是一种新的算法,它只是将上述几种算法进行了一个整合。按照对象生命周期的不同划分区域,采用不同的垃圾回收算法。

    这里先说一下JVM内存模型对象生命周期之间的关系,在我们new一个普通对象时,这个对象会在Eden区被创建,假如在一次GC过后,这个对象没有被清除,则称这个对象是幸存者,将其年龄属性加一,而后将会移动到From 区或To区,这两个区域也被统称为Servivor区,(Eden区:From区:To区=8:1:1),当一个对象经历了15次GC后都没有被回收,则会直接被移动到堆区中的老年代,老年代中的对象被认为是回收可能性不大的对象,因为经历了15次GC都没有被回收的对象,经历150次GC被回收的可能性也不大。

    所以了解了这个原理之后,再说分代收集算法就将会变得简单。上文提到,复制算法由于其复制对象到空闲区需要消耗资源,所以适合对象存活率不高的场景,而新生代就很好地满足了这个条件,所以新新生代通常使用复制算法进行垃圾回收。在多次的实践中证明,一批被新建的对象,最终存活率大概在10%左右,所以这一批对象将会被复制到Servivor区,而复制完成后立即回收Eden区。而新生代中的From区和To区又和复制算法中的空闲区和对象区相对应。这就对复制算法的施行制造了很好的环境。

    老年代由于其存储的对象具有不易被GC这个特点,所以上文中提到的标记-整理算法将会变得十分合适,标记-整理算法由于需要在清除后对存活的对象进行一次整理以消除内存碎片化,所以如果有大量的内存碎片,将非常不利于这种算法的运行,而老年代则给了适合这种算法的土壤。

    在分代收集算法中还有两个重要的概念是未曾提到的Minor GCFull GC,存在于新生代的GC由于其垃圾回收范围较小,被称为MinorGC,而在老年代的GC中通常伴随着所有内存的GC,所以其又被称为Full GC。Full GC效率低,但是不常被触发。

    触发Full GC的条件

    1.老年代空间不足
    2.永久代空间不足(已移除)
    3.CMS GC时出现Promotion failed,concurrent mode failure
    4.Minor GC晋升到老年代的平均大小大于老年代的剩余空间
    5.调用System.gc()
    6.使用RMI进行RPC或管理的JDK应用,每小时执行一次Full GC

    常用的调优参数
    1.-XX:SurvivalRatio:Eden和Servivor的比值,默认8:1
    2.-XX:NewRatio:老年代和年轻代的内存大小的比例
    3.-XX:MaxTenuriingThreshold:对象从年轻代晋升到老年代经过GC的最大阈值

    堆内存

常用的垃圾收集器

在说垃圾收集器之前,先得明白两个概念

  • Stop-the-World

    什么是Stop-the-World?JVM由于要执行GC,而停止应用程序的执行,这就是Stop-the-World,这种现象在任何一种GC算法中都会发生,所以如何让Stop-the-World发生的次数越来越少,以优化GC性能,是大多数垃圾收集器优化GC的策略。

  • Safe Point

    这个词相对来说很好理解,在GC过程中,会有程序不断地产生垃圾对象,这会造成一边打扫一边扔的效果,所以GC是以快照方式进行垃圾回收的,在程序运行到特定位置时,例如跳转,会生成一个Safe Point,而GC将会根据这个Safe Point中的垃圾进行回收。

  • 垃圾收集器

常用的垃圾收集器
上图中上半部分为新生代垃圾收集器,下半部分为老年代垃圾收集器。
两个垃圾收集器之间如果有连线,代表可以配合使用。

新生代垃圾收集器

Serial收集器是目前JVM运行在Client模式下的默认收集器,使用复制算法。因为它是单线程收集的,进行垃圾收集时必须暂停所有工作线程。
ParNew收集器 是多线程垃圾收集器,除了多线程这个特点,其余的行为、特点和Serial收集器一样。它是Server模式下JVM默认的垃圾收集器。

老年代垃圾收集器

Serial Old收集器 使用标记-整理算法,单线程收集,进行垃圾收集时,必须暂停所有工作线程。简单高效,是Client模式下默认的老年代垃圾收集器。
CMS收集器 使用标记-整理算法,多线程收集,GC线程几乎可以和工作线程同时工作。

GC相关面试题

  • Object的finalize()方法作用是否与C++的析构函数作用相同
    答:Object的finalize()方法不能保证在调用时立即回收目标对象,而是要等一次GC才能开始回收,因此它是不确定的。而C++中的析构函数是确定的。

  • Java中的强引用、软引用、弱引用、虚引用有什么用。
    强引用:指该对象存在至少一个引用对象引用的情况,这时GC绝不会回收该对象,当内存不足时,即使报OutOfMemoryException也不会回收该对象。
    软引用:对象处于有用但非必须的状态,只有当内存不足时,GC才会回收该引用的内存。可用来实现内存敏感的高速缓存,因为在内存不足就被回收这一特性,我们不用太担心OutOfMenoryException这一异常 用法:

    String str = new String("abc");//强引用
    SoftReference<String> softRef = new SoftReference<String>(str);//软引用
    

    弱引用:非必须的对象,比软引用更弱一些,GC时会被回收。被回收的概率也不大,因为GC线程优先级比较低,适用于偶尔被使用且不影响垃圾收集的对象。
    用法:

    String str = new String("abc");//强引用
    WeakReference<String> weakRef = new WeakReferences<String>(str);//弱引用
    

    虚引用:不会决定对象的生命周期,在任何时候都可能被垃圾收集器回收,它可以跟踪对象被垃圾收集器回收的活动。必须与ReferenceQueue联用。
    用法:

    String str = new String("abc");
    ReferenceQueue queue = new ReferenceQueue();
    PhantomReference ref = new PhantomReference(str,queue);
    

    综上,强引用>软引用>弱引用>虚引用

结语

在写这篇之前,我看过一篇文章,名字记不太清了,大致是,《面试官:求求你们了,再问你们Java内存模型不要再和我说堆区和栈区了》,当时我了解的JVM也仅限于此,看完那篇文后就有了去了解JVM的想法,只有自己实际了解过之后,才意识到自己是“学然后知不足,教然后知困”。也许在以后再回过头来看这篇文章,我依然会有这种感觉,但我希望可以有那个时候。

本文图片来自网络,侵删。

欢迎大家访问我的个人博客:Object's Blog