什么是 JVM

JVM 是 Java Virtual Machine 的缩写,意为 Java 虚拟机,一种可以运行 Java 编译后生成的字节码的虚拟机。有了 JVM 可以使我们不用修改代码就能运行在各个操作系统上。众所周知在 C/C++ 中是需要开发人员手动管理内存的申请以及释放,稍有不慎就造成了内存泄露或者内存溢出,而 JVM 提供的自动内存管理机制极大的简化了我们的开发过程。

而 JVM 不只有一种,大家都可以按照《Java虚拟机规范》开发自己的虚拟机,常见的虚拟机有:

其中HotSpot 是使用最广泛的虚拟机

注:JVM 可以运行任何符合规范的字节码文件,不仅限于 Java 语言编译后的字节码,其他语言可以参照规范自己实现编译器编译。

Java 内存区域

上面提到的 JVM 内存管理机制本质上就是在程序运行的过程中把内存分成若干不同的区,这就是 Java 内存区域。

Java 内存区域示意图

先来看这两块不属于 JVM 的内存区域

  • 本地内存

是随着 JVM 进程一起从操作系统申请的内存空间,也就是运行 JVM 所使用的内存。

  • 直接内存

直接内存是 NIO(New Input/Out)使用 Native 函数库开辟的内存空间,通过中的 DirectByteBuffer 与该内存空间进行操作。

上面两块内存区域不受 Java 堆的大小限制,只与机器的内存相关,但如果配置不当,也会使程序出现 OOM 异常。

以下是由 JVM 所管理的内存区域,也就是运行时数据区(Run-Time Data Areas)

线程共享的内存区域

堆是随 JVM 启动而创建的内存区域,是 JVM 所管理的最大的一块内存区域,几乎所有的对象实例和数组都在该区域分配内存,同时也是会触发垃圾回收机制的内存空间。

  • 方法区

方法区是一个用于存储被 JVM 加载的类型信息、常量、静态变量、即时编译后的代码缓存等数据。在《Java 虚拟机规范》中提到方法区逻辑上属于的一部分,但在具体实现上通常不会在此区域进行垃圾回收或者压缩操作,所以在 HotSpot 虚拟机实现的时候选则用永久代(PermGen)作为方法区的实现。

不过选择使用永久代实现方法区会使得程序更容易发生内存溢出的现象,所以从 JDK 1.7 开始逐步使用元空间(Meta Space)替换永久代作为方法区的实现,并把方法区移到了本地内存中。

  • 运行时常量池

运行时常量池是分配在方法区中的一块空间。主要用于存储 Class 文件中的常量池表(Constant Pool Table),也就是编译期生成的各种字面量与符号引用,这部分内容在类加载后创建。

线程私有的内存区域

  • 程序计数器

程序计数器可以理解为当前线程所执行的字节码的行号指示器,字节码解释器就是通过修改程序计数器的值来决定下一条要执行的字节码指令,像程序中的分支、跳转、循环和异常恢复等都是依赖程序计数器来完成的。

在多线程中应用中,为了线程切换能够恢复到正确的位置,每个线程都有一个互相独立、互不影响的程序计数器。

当一个线程执行的是 Java 方法,此时程序计数器的值指向的是正在执行的字节码指令地址;而当执行的是 Native 方法时,程序计数器的值为空(Undefined)

注:该区域是唯一一个不会出现 OOM 的内存区域

  • 虚拟机栈

每个线程内部都有一个虚拟机栈,随线程的创建而创建。虚拟机栈内部存储着若干栈帧,每执行一个方法,就会对应创建一个栈帧,用于记录局部变量表、操作数栈、动态连接和方法出口等信息。每一个方法调用到结束的过程,就对应一个栈帧在虚拟机栈中入栈到出栈的过程。

如果线程请求栈的深度大于虚拟机所允许的最大上限时,会抛出StackOverflowError异常。

  • 本地方法栈

本地方法栈和虚拟机栈功能类似,只不过记录的是 Native 方法。

方法区和永久代的区别

方法区是《Java 虚拟机规范》中所提及的一个规范,并没有规定如何实现,而永久代就是依据这一规范实现的。可以理解为方法区是接口,永久代实现了方法区这一接口,是具体的实现类。也就是说永久代只是 HotSpot 虚拟机根据《Java 虚拟机规范》中所描述的方法区规范实现出来的一个概念,在其他的虚拟机中不存在永久代这个概念。

为什么要用元空间替换永久代

  1. 在 JDK 1.8 合并 HotSpot 虚拟机和 JRockit 虚拟机时,JRockit 中不存在永久代的概念,所以顺势删除了永久代
  2. 永久代在方法区内部,受 JVM 整体内存大小受影响,相比之下放在本地内存的元空间不受 JVM 内存大小影响,只受系统内存空间影响,可以加载更多的类

对象的创建过程

1. 类加载检查

当虚拟机遇到 new 指令的时候会先去常量池中检查对应类的符号引用是否存在,并检查类是否已经加载过。如果没有,会先去执行对应的类加载过程。

2. 分配内存

当通过类加载检查后,会为这个类分配内存空间,一个类所需的内存空间在加载结束时就已经确定了,内存分配的过程就是在剩余的内存空间中找到一块放的下空间的过程。

内存的分配方式有两种:

  • 指针碰撞

当内存空间“规整”时使用,原理就是有一个指针指在已用过的内存和未使用的内存中间,当需要分配内存的时候,指针向后偏移所需的空间大小即可。

  • 空闲列表

当内存空间不怎么“规整”时,可能没办法直接找到合适的内存空间用于分配,所以虚拟机内部维护了一份可用空间的列表,内存分配的过程就是在列表上找到足够大的内存空间分配给对象实例。

具体采用哪种方式取决于虚拟机内存空间是否“规整”,而内存空间是否规整又取决于使用的垃圾回收算法是“标记-整理”还是“标记-清除”。

当虚拟机使用 Seial、ParNew等带有“整理”功能的垃圾回收器时,系统采用指针碰撞的方法分配内存,简单又高效;而当使用 CMS 这种基于“清除”的垃圾回收器时,就只能采用空闲列表的方式分配内存。

分配方式 适用场景 原理 垃圾回收器
指针碰撞 内存空间规整 有指针指向已用的空间和未使用的空间中间,更直接的定位到内存开始的地址 Serial、ParNew
空闲列表 内存空间不规整 利用额外的一个列表记录内存中空闲的内存空间 CMS

内存分配过程中会伴随着并发的问题,通常虚拟机有两种方式保证线程安全:

  • CAS + 重试:CAS 是一种乐观锁,也就是虚拟机会在分配内存过程中不加锁,如果发生冲突,那就重试到成功为止,虚拟机用这种方式保证内存分配的原子性。
  • TLAB:本地线程分配缓冲(Thread Local Allocation Buffer),是指预先在每个线程内部分配一小块内存空间,如果哪个线程需要分配内存,就在哪个线程的 本地缓冲区中分配,只有当线程的本地缓冲区用完了需要分配新的缓冲区时才同步锁定。

3. 初始化“零”值

在分配到内存空间之后,要将对象中除对象头以外的值赋予默认值,保证了对象的字段可以不赋值就被使用。

4. 设置对象头

当对象初始化“零”值之后,虚拟机会对对象进行必要的设置,比如对象是哪个类的实例、类的元数据信息、类的 GC 分代年龄、以及是否启动偏向锁等,这些信息存储在对象头中。

5. 执行 init 指令

到这里,从虚拟机的角度来说,一个对象以经创建完成,但从程序的角度来看,init 指令还没有执行,所以 new 指令后往往是 init 指令,执行完 init 指令,一个对象才算真正的创建完成。

对象的访问

访问堆中对象主流的方式有两种:

  • 句柄:在堆中开辟一块空间用于存储句柄池,reference 中存储的是对象的句柄地址,而句柄对象中包含了对象的对象实例地址和对象类型地址。
  • 直接指针:Java 栈上的 reference 中存储的直接就是对象的地址,这样节省了一次指针定位的开销

垃圾收集(Garbage Collection)

在 Java 内存区域中,程序计数器、虚拟机栈和本地方法栈随着线程创建而创建,随线程销毁而释放,这几个区域的内存分配具有确定性。

而在堆中,内存的分配具有严重的不确定性,一个接口的不同实现类需要的内存空间可能不一致,一个方法所执行的不同分支所需要的内存空间也可能不一样,只有在运行过程中我们才能知道到底需要创建多少对象,这部分内存的分配是动态的,越多的对象被创建就需要越多的内存。

可内存空间不是无限的,所以我们需要一种机制,帮我们在内存中找到那些可以被回收的对象并释放所占用的内存空间,保证程序的稳定运行。

如何判断对象可以回收

判断对象可以回收通常有两种办法:

引用计数法

从名字就可以看出引用计数法的原理,给每个对象开辟一个额外的内存空间用于存储引用计数器,每当对象有被引用时,引用计数器 + 1反之则 -1。这种方法简单高效,但也面临着一些问题,所以主流的虚拟机都没有使用该方法管理内存空间,比如引用计数法无法解决对象之间循环引用的问题。

public class ReferenceCountingGC {

    public Object instance = null;

    public static void circularReference() {
        ReferenceCountingGC objA = new ReferenceCountingGC();
        ReferenceCountingGC objB = new ReferenceCountingGC();
        // 让两个对象互相引用对方
        objA.instance = objB;
        objB.instance = objA;
        objA = null;
        objB = null;
        // 此时两个对象互相引用对方,但两个对象都不能为其他对象访问,此时就产生了循环引用
    }
}

可达性分析

可达性分析是通过一系列称为“GC Root”的对象出发,根据引用关系向下寻找,寻找过的路径被称为引用链,如果某个对象到 GC Roots 之间没有任何引用链,或者从图论的角度来看,GC Root 到这个对象不可达时,就说明该对象是不可能再被引用的。

可达性分析图例

如上图所示,Object1-3 就是引用可达的对象,而 Object4-6 虽然互相有引用,但 GC Root 到这些对象之间没有任何的引用链,所以是引用不可达的。

在 Java 中,可以被称为 GC Root 的对象有:

  • 在虚拟机栈栈帧中本地变量表的对象,比如方法的参数、局部变量、临时变量等
  • 方法区中类静态属性引用的对象
  • 方法区中常量引用的对象
  • Native 方法引用的对象
  • 被 Synchronized 关键字持有的对象

对象的引用类型

在 Java 中共有四种引用类型:

  • 强引用(Strongly):在代码中普遍存在的引用类型,比如 Object obj = new Object(),只要强引用关系在,垃圾回收器永远不会回收。
  • 软引用(Soft):描述一些有用但非必需的对象,在内存空间发生溢出前会把这些对象标记为下次回收的对象,使用SoftReference类实现软引用。
  • 弱引用(Weak):也是描述一些有用但非必需的对象,但这些对象会在下一次回收时被回收掉,使用WeakReference类实现弱引用。
  • 虚引用(Phantom):最弱的一种引用关系,无法通过虚引用获得任何对象,虚引用唯一的作用就是当对象被回收的时候会收到来自系统的通知,使用PhantomReference类实现虚引用。

以上四种引用强度依次减弱

方法区的回收

在《Java 虚拟机规范》中指出,方法区可以不实现垃圾回收,但不意味着在方法区没有垃圾回收。只不过与堆相比,方法区的垃圾回收效果甚微,性价比通常是比较低的。

在方法区主要回收两类对象:

  • 废弃的的常量:比如曾经创建了一个“java”的字符串常量,但现在没有任何一个字符串的值是“java”,此时“java”这个常量就算是废弃的常量,在下次方法区的垃圾回收时回被释放掉。
  • 不再使用的类型

判断一个类型是否属于不再使用的类型要同时满足以下三个条件:

  • 该类型的所有实例都已经被回收
  • 加载该类型的类加载器已经被回收
  • 该类的 Class 对象没有在任何地方被引用,即无法通过反射在访问到该类的方法

Java 虚拟机允许满足上面三个条件的类型进行回收,但并不一定会被回收

垃圾收集算法

分代收集理论

现在的垃圾收集器大多遵循“分代收集”理论进行设计,它建立在三条假说之上:

  • 弱分代假说:即大多数的对象的生命周期都很短

  • 强分代假说:经历过越多次的回收的对象越不容易被释放

  • 跨代引用假说:跨代引用的现象只是极少数

正是因为上面的假说才奠定了多数垃圾收集器一致的设计原则:Java 堆应该被划分成多个区域,根据对象的年龄(经历过垃圾收集的次数)放置到不同的区域。

从分代收集的理论看现代的虚拟机,大多都将分成两部分:新生代(Young Generation)和老年代(Old Generation)。在新生代区域中,每次发生垃圾收集会有大量对象被释放,只会留下少数对象,而这少数对象会逐步"升级"到老年代中。

标记-清除算法

最早出现的垃圾收集算法,思想就是标记内存中可以被释放的内存空间,然后再统一释放这些内存空间;也可以反过来标记不用被释放的内存,然后释放未被标记的内存空间。

后续出现的垃圾收集算法都是以标记-清除算法为基础,优化其缺点产生的。它的主要缺点有:

  • 执行效率不稳定:在面对大量对象时,标记和清除这两个过程执行效率会随着对象数量增长和降低。
  • 内存碎片化:标记、清除过后会产生大量不连续的内存空间,导致接下来分配大对象的时候可能没有足够的空间从而引发一次不必要的 GC。

标记-复制算法

半区复制

主要解决了“标记-清除”算法的执行效率不稳定。它将内存空间分成相等的两份,每次分配空间时只在其中一个空间分配,当这一半空间用完时,把存活的对象复制到“另一半”空间中,释放掉可以释放的内存空间。这种算法简单高效,但有个致命的缺点:可用的内存空间被减少了一半

IBM 曾研究过新生代中的对象,发现 98% 的对象在第一次垃圾回收时就被释放掉了,因此现在的商用虚拟机大多都优先采用这种算法回收新生代,只不过并不是按照 1:1 分配的。

Appel 式回收

Appel 式回收是将新生代分为一个较大的 Eden 区以及两个较小的 Survivor 区,每次分配内存时仅使用 Eden 区和其中的一个 Survivor 区。发生垃圾回收时,把 Eden 和 Survivor 中存活的对象都丢到另一个 Survivor 区中,并清空 Eden 和已使用的 Survivor 区。

标记-整理算法

“标记-整理”算法和“标记-清除”算法类似,都会先标记内存中可以被释放的空间,但标记-整理算法不会直接释放这些空间,而是让存活的对象都向内存空间的一端移动,然后再清理哪些可以被释放的空间。

垃圾收集器

新生代可用

  • Serial 收集器:一个单线程的采用标记-复制算法的垃圾收集器,在其工作时会暂停所有的用户线程,但简单而高效(没有多线程交互的开销)。
  • ParNew 收集器:和 Serial 收集器类似,不过时垃圾收集是多线程工作,也需要暂停所有用户线程,同样采用了标记-复制算法。
  • Parallel Scavenge 收集器

老年代可用

  • Serial Old 收集器:Serial 的老年代收集器,不同的是采用标记-整理收集算法。
  • Parallel Old 收集器
  • Concurrent Mark Sweep(CMS)收集器:从名字就能看出来是基于标记-清除算法实现的。CMS 收集器是一个追求最短的回收停顿时间的垃圾收集器而且 CMS 的工作线程可以与用户线程并行。

“全年龄”可用

  • Garbage First 收集器:和其他收集器针对收集特定的新生代或者老年代不同,Garbage First 收集器将整个堆内存划分成若干个大小相等的独立区域(Region),每个 Region 都可以根据需求变成新生代的 Eden 区、Survivor 区或者是老年代,无论是新创建的对象还是存活了很长时间的对象都能有很好的收集效果。

JDK 默认的垃圾回收算法

  • 在 JDK 5 中新生代默认使用的是 Serial(串行化复制收集),老年代默认的是 Serial Old(串行化标记整理)
  • 在 JDK 7/8 中新生代默认使用 Parallel Scavenge(一个注重吞吐量的垃圾回收算法),老年代(8中的元空间)默认的是 Parallel Old
  • 在 JDK 9 中默认使用 G1(全区垃圾收集)

JVM 调优

什么时候要调优

  • 堆内存持续上涨
  • Full GC 频繁
  • GC 时间过长
  • 出现 OOM
  • 系统吞吐量下降

调优的原则

  • 大多数应用不需要调优
  • 多数导致 GC 的问题都是代码问题
  • 减少对象的创建
  • 减少使用大对象和全局变量

调优的目标

  • GC 低频率, GC 低停顿
  • 低内存占用
  • 高吞吐量

调优涉及到的参数

  • -Xms:初始化堆内存大小
  • -Xmx:堆内存最大大小
  • -Xmn:新生代大小
  • -XX:SurvivorRatio:Eden 区与每个 Survivor 区域的比值
  • -XX:+DisableExplicitGC:禁止显示触发 GC
  • -XX:CMSInitiatingOccupancyFraction:老年代回收阈值
  • -XX:ConcGCThreads:CMS 回收器线程数
  • -XX:ParallelGCThreads:新生代并行收集器线程数
  • -XX:CMSFullGCsBeforeCompaction:经过多少次 Full GC,CMS 才会整理内存空间

堆内存设置的参考依据

默认初始化堆内存大小设置为总内存的 $1/64$,默认堆内存最大大小 $1/4$。

通常设置初始化堆内存大小和堆内存最大大小相等,建议为最大可用内存的 80%。

怎么选择合适的垃圾回收算法

  • 调整堆内存大小,让 JVM 自动选择
  • 如果内存小于 100,选择串行化
  • 单核且没有停顿时间要求,选择串行化
  • 要求响应时间,选择并发收集器