从一道面试题来讲 JVM


一道面试题

你能不能谈谈,java GC 是在什么时候,在什么地方,对什么东西,做了什么事情?

JVM 的内存模型

首先区分两个概念,Java 内存模型JVM 内存模型,有很多文章把这两个概念当成一个概念来讲,标题是 Java 内存模型xxxx,内容讲的确实堆栈内存那一套,其实二者是完全不同的两个概念:

  • Java 内存模型:
    Java内存模型(Java Memory Model ,JMM)就是一种符合内存模型规范的,屏蔽了各种硬件和操作系统的访问差异的,保证了Java程序在各种平台下对内存的访问都能得到一致效果的机制及规范。目的是解决由于多线程通过共享内存进行通信时,存在的原子性、可见性(缓存一致性)以及有序性问题。Java 内存模型和并发变成相关
  • JVM 内存模型
    JVM 内存模型,就是指在程序运行期间使用到的存储不同类型数据的区域,这些区域有些是全局共享的,随着虚拟机的启动而创建,随着虚拟机的退出而销毁,有的一些区域是线程私有的,随着线程开始和结束而创建和销毁。JVM 内存模型和 Java 代码执行时内存的占用相关

这里不讲 Java 内存模型,从 JVM 开始说起:

什么是 JVM

我们在学习 Java 语言的时候,老师会讲,Java 语言需要将 .java 文件编译成 .class 文件,然后再由 JVM 去解释执行,这样可以实现跨平台运行巴拉巴拉

似乎 Java 虚拟机(JVM)就是 Java 语言的一部分,其实不是这样的。

实际上,所谓的虚拟机是一个十分宽泛的概念,大致可以分为:

  • 系统虚拟机:例如我们常见的 VirtualBox、VMware,它们可以模拟一个操作系统。
  • 程序虚拟机:例如 JVM,它并不会模拟整个操作系统,只是提供一些特定的指令集和运行时内存区域的运行环境。
  • 操作系统层虚拟化:例如 Docker,它不提供完整的操作系统环境,将母机内核分给多个独立空间的应用程序,不同于系统虚拟机需要完整的操作系统,也不像程序虚拟机运行特定的变成语言。

以上分类摘自维基百科 虛擬機器

从上面可以看到,程序虚拟机提供一些特定指令集和运行时内存区域,这个特定指令集就是由 《Java虚拟机规范》 下载地址来规定的。

这个规定只对虚拟机的执行效果做了规定,对于其内部实现却不关心,也就是说,只要对字节码文件的解释结果能够通过测试,那么你就是 Java 虚拟机。JVM 并不是只有 Oracle 一家,只要满足这个规范的虚拟机,都可以称之为 Java 虚拟机,常见的有 Oracle 公司的 JRockit VM、阿里巴巴公司的 TaobaoJVM 等等,这些虚拟机都是满足规范规定并通过测试用例测试的。

所以说,虽然名字中有一个 Java 字样,但却并不是 Java 语言独有的,其他语言的字节码文件只要能够满足虚拟机的执行要求,并且结果正确,那么它就可以在虚拟机中运行。

至此我们对 JVM 有了一个新的认识。

JVM 的运行时内存区域

上面说过,Java 虚拟机规范规定了运行时内存区域,也就是之前老生常谈的堆、栈那一堆概念:

Java 虚拟机规范规定了若干种数据区,大致可以分为以下两种:

  • 进程独享,这些数据区的生命周期随着虚拟机创建而创建,虚拟机退出而销毁
  • 线程独享,这些数据区的生命周期随着程序中线程的创建而创建,线程的结束而销毁

线程独享的内存区域有以下几种:

  • PC 寄存器,也有人称之为程序计数器:Java 虚拟机可以支持多个线程同时执行,每一条线程都有自己的 PC 寄存器。一条线程在同一时刻只会执行一个方法中的代码,这个方法也叫该线程的当前方法,如果这个方法是 Native 的,那么该片内存中存的值就是 undefined,也就是为空,如果是 Java 方法,那么则存的是该方法的字节码指令的地址,也可以看做是字节码的行号。

该内存是 JVM 内存划分区域中,唯一一个没有规定溢出异常的区域

  • Java 虚拟机栈(有的地方简称为栈,其实并不准确):每一条虚拟机线程都拥有自己独享的虚拟机栈,用于存储局部变量与一些过程结果的地方。
    栈内部存储的基础单位是 栈帧

栈帧是用来存储数据和部分过程结果的数据结构,同时也用来处理动态链接、方法返回值和异常分派。每个栈帧都有自己的局部变量表、操作数栈和指向当前方法所属类的运行时常量池。
一个方法被调用,一个栈帧也会随之创建执行入栈操作,在一个线程当中,只有目前正在执行的那个方法的栈帧是活动的,这个栈帧也被称之为当前栈帧,这个栈帧对应的方法称之为当前方法,定义这个方法的类就被称之为当前类,栈帧随着方法结束而销毁,方法完成,栈帧出栈。
Java 虚拟机规范为 Java 虚拟机栈定义了异常情况:

    • 如果线程请求分配的栈容量超过 Java 虚拟机栈允许的最大容量,Java 虚拟机会抛出一个 StackOverflowError 异常。
    • 如果 Java 虚拟机栈可以动态扩展,并且已经尝试过扩展操作,但是无法申请到足够的内存去完成扩展,或者新建线程时没有足够的内存去创建对应的虚拟机栈,那么 Java 虚拟机将会抛出 OutOfMemoryError 异常
    • 本地方法栈,这部分内存用于存储 Native 方法的执行,这个栈和 Java 虚拟机栈类似,只不过它存储的是其他语言来实现指令集时使用的方法数据,异常抛出也和 Java 虚拟机栈相同

    这就是为什么把 Java 虚拟机栈称之为栈是不准确的,准确的应该是 Java 虚拟机栈和本地方法栈统称为栈

    • ,这片内存区域是线程之间共享的,也是供所有类的实例和数组对象分配内存的区域,这片内存在虚拟机启动时就会创建,它存储了被 GC 管理的各种对象。

      • 如果实际所需的堆超过了自动内存管理系统能提供的最大容量,那么 Java 虚拟机将抛出 OutMemoryError 异常。
    • 方法区,这片内存也是各个线程之间共享的内存区域,在虚拟机启动时就被创建。它存储了每个类的结构信息,例如运行时常量池、字段和方法数据、构造函数和普通方法的字节码内容,还包括一些在类、实例、接口初始化时用到的特殊方法

      • 如果方法区的内存空间不能满足内存分配请求,那么 Java 虚拟机将抛出 OutMemoryError 异常。

    上面大致列出了 Java 虚拟机在运行时的内存分配情况,更详细的内容可以查看《Java 虚拟机规范》,用一张图表示这个内存分配,差不多就是这样:

    JVM 中的垃圾回收(GC)

    Java 虚拟机所占内存是固定的,随着时间的推移,内存中数据越来越多,总有一天会将内存占满,所以就需要将无用的内存占用释放掉,这个操作是由 GC 来操作的。

    上面我们将 Java 虚拟机运行时的内存占用区域划分为五片,其中,有 3 个是不需要进行垃圾回收的:

    • 本地方法栈
    • 程序计数器
    • 虚拟机栈。

    因为它们的生命周期是依赖于线程的,线程生,空间创,线程终,空间放。Java 堆和方法区中,一个接口中的多个实现类需要的内存可能不一样,一个方法中的多个分支需要的内存也可能不一样,只有在程序运行期间才能知道会创建哪些对象,这部分内存的分配和回收都是动态的,所谓的垃圾回收(gc)主要是讲这在这两个不会的内存回收

    GC 是怎么工作的?

    在学习 Java 的时候,老师告诉我们,除非直接调用 System.gc() 方法强制进行 gc 操作,否则只能等待系统在不确定时间的情况下自动回收,但实际上,系统的回收还是有一定规律可循的。

    先说是如何判定对象无效、其内存可以被回收的,主要有以下两种方法:

    • 引用计数法:给对象添加一个引用计数器,当有一个地方引用这个对象,计数器 +1,引用失效,计数器 -1,当该对象计数器为 0 时候,则表示这个对象没用了,可以被回收掉。
      但这个方法在相互引用的情况下是无效的,例如:

      public class Demo {
      
          private Object instance = null;
      
          public static void main(String[] args) {
              Demo a = new Demo();
              Demo b = new Demo();
              a.instance = b;
              b.instance = a;
              
              a = null;
              b = null;
              System.gc();
          }
      }

    在上面那个例子中,a、b 两个对象相互引用,但依旧会被回收掉。

    • 可达性分析:这个方法是现在主流判断对象是否存活的方法,其思路就是通过一系列称之为 GC Roots 的对象作为起点,从这些起点向下搜索,搜索走过的路径称之为 引用链,当一个对象到 GC Roots 没有任何引用连的时候,则证明这个对象是不可用的,其占用的内存可以回收,在 Java 中,可以作为 GC Roots 的对象包括以下几种:

      • 虚拟机栈中引用的对象
      • 方法去中类静态属性引用的对象
      • 方法区中常量引用的对象
      • 本地方法栈中 JNI 引用的对象

    注意,当对象被判定为“可以被回收的”,也并不意味着这块内存马上被释放,只是暂时的被标记为“可以被回收”,而一个对象真正宣告死亡、那片内存被回收,至少要经历两次标记过程:

    • 在可达性算法中,对象被判定为不可达对象,进行一次标记
    • 在第一次标记之后,会进行一次筛选,筛选的条件是该对象是否有必要去执行 finalize 方法:

      • 如果这个对象被判定有比较执行 finalize 方法,则对象会被放置在一个叫做 F-Queue 的队列之中,并在稍候由一个虚拟机自动创建的、低优先级的 Finalizer 线程去执行它,这个时候如果在 finalize 方法中给这个对象与引用链上的任何一个对象建立联系,则该对象则会被移除第一次的标记
      • 当对象没有覆盖 finalize 方法或者该方法已经被虚拟机调用过,虚拟机就认为这个对象“应该被回收”

    finalize 相当于对象的一块免死金牌,只能执行一次,在这个方法中,可以重新建立引用避免被回收,但实际编程中,并不建议使用该方法。

    上面讲解了一个对象在什么情况下被回收,那么什么时候 JVM 会对内存进行检测呢?真的是随机的吗?其实并不是,下面将什么时候会 gc 操作。

    什么时候进行 gc

    Java 虚拟机将堆内存区域分为以下两个部分:

    • Young Gen(新生代),新生代又划分为以下几个区域:

      • Eden Space(伊甸园)
      • Survivor Space(幸存者区),幸存者区又可以划分为以下两个部分:

        • To Survivor
        • From Survivor
    • Old Gen(老年代)

    默认情况下,新生代空间占对内存大小的 1/3,老年代占堆内存的 2/3,在新生代中,伊甸园区占新生代容量的 8/10,幸存者区占新生代容量的 2/10,幸存者区中的 to 和 from 会平分这 2/10

    也就是说,假如堆内存空间是 100M,那么会将这 300M 划分为:

    • 新生代(100M)

      • 伊甸园区(80M)
      • 幸存者区(20M)

        • to (10M)
        • from(10M)
    • 老年代(200M)

    每个代所占内存比例,以及幸存者区的子个数都是可以通过配置来修改的,上述只是默认情况。

    大多数情况下,对象创建会直接在伊甸园区分配。当伊甸园区内存已满的时候,会进行第一次 GC 操作,称之为 Minor GC,在这次 GC 操作之后,不能被回收的对象会被放入到幸存者区

    幸存者区被划成平分的二等分,每次伊甸园区中不被回收的对象都会放入到幸存者区域划分的两个区域中的其中一个。
    那么会放到幸存者区中两个分区中的哪一个?是这样的,实际上 Minor GC 是在整个新生代区域中进行的,扫描到伊甸园区有存活对象,会选择一个空的幸存者区存放,同时将另一个幸存者区中的存活对象也存放到这个幸存者区中。

    也就是说,总有一个幸存者区是空着的。

    每个对象都有一个 age 属性,每一次 Minor GC 过后留下来的存活对象的 age 值都会 +1,当这个 age 属性达到设定数值(默认为 15)后,这些存活对象会被转移到老年代中。

    注意,一些大的对象,初始化就会进入到老年代中。

    老年代中又是如何进行 GC 的呢?主要有以下几种情况:

    • 调用 System.gc() 方法,系统会建议进行 GC 操作,但是不一定会执行
    • 老年代空间不足
    • 方法区空间不足

    GC 算法

    上面讲了 GC 在堆内存中的运行机制,那么他们是采用什么方法对内存进行转移之类的操作的呢?下面介绍几种方法:

    • 标记-清除算法
      理解起来很简单,就是先将需要回收的对象做标记,标记完成后,对需要回收的对象内存统一进行释放,标记过程上面已经讲过了。

    这种方法有两个缺点:

    1. 效率不高,标记、清除两个过程都耗费资源。
    2. 清除之后,会产生大量不连续的内存碎片,碎片太多会导致之后的创建对象无法找到连续的、足够分配的内存空间,从而引发新的 GC 操作。
    • 复制算法
      就是将可用内存划分为大小相等的两块,每次只使用其中一块,但这块内存被占满,就把还存活的对象复制到另一个上,在把刚才被占满的那块内存一次性清理掉,这样就无须考虑内存碎片问题了,缺点就是永远有一块内存是空闲的,有点儿浪费,但对于现在这种配置冗余内存白菜价来说,几乎可以忽略。

    这种算法是不是很熟悉?幸存者区当中就是这么做的嘛,事实上,新生代也是采用的这种算法,只不过并不是按照 1:1 这种比例来的,因为有研究表明,新生代中 98% 的对象都生命周期都不长,所以无需按照 1:1 来划分,而是划分了一块比较大的伊甸园区和两块比较小的幸存者区

    • 标记 - 整理算法
      复制算法如果在存活率较高的空间进行复制操作,效率就会变低(因为需要复制大量的对象嘛),所以在老年代中使用复制算法就不理想了,于是有了这个标记-整理的思路,和标记清楚类似,只不过在标记之后不是清除操作,而是让存活对象都向前移动,最后把最后的内存清理掉
    • 分代收集算法
      其实就是在新生代使用复制算法,老年代使用标记整理算法而已