在之前的博客 Javac 编译原理与 class 文件结构 中,我有提及 Javac 编译器将 Java 源码编译成了机器可以接受的指令,解决了高级语言与底层硬件架构的耦合(现在的操作系统几乎完全向用户屏蔽了硬件,也就可以说编译器解决了高级语言与不同操作系统之间的耦合)。

JVM 屏蔽了各个计算机平台相关的软件或者硬件之间的差异,使 Java 代码可以 全平台运行。本篇以 《深入分析 Java Web 技术内幕》 第七章 JVM 体系结构与工作方式 及第八章 JVM 内存管理 的内容为参考,讲解 JVM 的基本结构、工作方式、内存分配策略及垃圾回收策略等。

JVM 体系结构

计算机与 Java 虚拟机

在了解 JVM 这台虚拟计算机之前,我们先看看一台真实的计算机的体系结构:

计算机组成部分 功能
指令集 计算机所能识别的机器语言的命令集合
计算单元 能识别并控制指令执行的功能模块
寄存器 操作数寄存器、控制寄存器、变址寄存器等
存储单元 存储操作数和保存操作结构的单元,内存和磁盘等
寻址方式 地址位数、最小最大地址范围、地址的运行规则等

指令集 是在 CPU 中用来计算和控制计算机系统的一套指令的集合,在 CPU 设计时规定的一系列与硬件电路相配合的指令系统。可以说指令集是可以直接被机器识别的机器码,必须以二进制格式存在于计算机中,而 汇编语言 是为了让人能够更容易地记住机器指令而使用的助记符,每一条汇编指令都可以直接翻译成一个机器指令。

汇编语言是对寄存器和段直接操作的命令,不同的芯片架构(包括寄存器和段)设计一定会对应到不同的机器指令集合,现在不同的芯片厂商也都会采用兼容的方式来进行设计。JVM 作为一台虚拟机,也有一套合适的指令集能够被解析执行,这个指令集就是 JVM 字节码指令集,JVM 的体系结构如下所示:

JVM 组成部分 功能
类加载器 在 JVM 启动时或在类运行时将需要的 class 加载到 JVM 中
执行引擎 执行 class 文件中包含的字节码指令,相当于 CPU
内存区 被划分为若干个区来实现机器的存储、记录和功能模块的调度
本地方法调用 调用 C 或 C++ 实现的本地方法的代码返回结果

值得一提的是,JVM 是一个抽象规范(The Java Virtual Machine Specification),不同的厂商可以根据这个规范具体的实现。每一个运行中的 Java 程序都是一个 JVM 实例,程序退出或者关闭,则虚拟机实例消亡,多个虚拟机实例之间数据不能共享。

基于栈架构的 JVM

和一般的基于寄存器的架构不同,JVM 选择了基于栈的架构,也就是所有的操作数必须先入栈,然后根据指令中的操作码选择从栈顶弹出若干个元素进行计算后再将结果压入栈中。相比于基于寄存器的架构,基于栈的架构在执行字节码的时候需要 较多数据移动的操作(将所有的操作数多次出栈和入栈),那 JVM 为何设计成基于栈的架构呢?

  • JVM 要设计成 平台无关 的,即在没有或者有很少寄存器的机器上也要能正确执行 Java 代码;寄存器是没有规律的,很难设计通用的基于寄存器的指令,即基于寄存器的架构很难做到通用。

  • 更好地优化代码,编译器一般采用以栈为基础的结构向连接器或者优化器传递这种编译的中间结果,JVM 这种以栈为中心的体系结构可以将运行时的优化工作与执行即时编译或者自适应优化的执行引擎结合起来,更好地优化执行 Java 字节码指令。(JIT:JVM 执行程序次数到一个阈值,JIT 就会编译这个方法为本地代码)

  • 为了指令的紧凑性。

JVM 内存管理

与其他高级语言如 C 和 C++ 不同,Java 基本不会显式地调用分配内存的函数,开发者不用关心内存的分配和回收,也很少会遇到像 C++ 程序中那样令人头疼的内存泄漏问题。但我们也最好了解一下 Java 是如何管理内存的,以便在遇到了 OutOfMemoryError 后能根据错误日志快速定位出错的原因。

内存的基本概念

物理内存

不管是在 Windows 系统还是 Linux 系统下,程序运行都要先向操作系统申请内存地址。操作系统 按照进程 来管理内存,每个进程拥有一段独立的地址空间,保证每个进程只能访问自己的内存空间。这主要是从程序的安全性来考虑的,也便于操作系统来管理物理内存。

虚拟内存

虚拟内存的出现使得多个进程在同时运行时可以 共享物理内存,这里的共享只是空间上共享,在逻辑上它们仍然是不能相互访问的。此外虚拟内存还能扩展内存的地址空间,映射到物理内存、文件或者其他可以寻址的存储上,比如一个进程不活动时将数据映射到一个磁盘文件上,真正高效的物理内存留给正在活动的程序使用;当我们重新唤醒一个很长时间没有使用的程序时,磁盘会吱吱作响,这时操作系统又会把磁盘上的数据重新交互到物理内存中。但这种物理内存和磁盘数据的 交互效率非常低,应该尽量避免。

内核空间与用户空间

在之前的博客 Java:线程池原理、源码分析 就提过用户级和内核级的内存概念,运行在操作系统中的用户程序不能访问操作系统所使用的内存空间,从安全性上考虑的。用户想要访问硬件资源只能通过 系统调用 来实现。每次系统调用都会存在两个内存空间的切换,从内核空间到用户空间的数据复制也很费时,在保住程序运行安全性和稳定性的情况下 牺牲了一部分效率

内存分配策略

JVM 内存结构

JVM 在运行 Java 程序时按照运行时数据的格式将它们分别存储在几个不同的区域,JVM 规范将 Java 运行时数据划分为以下六种:

运行时数据类型 存储的具体数据内容
PC 寄存器 保存当前正常执行的程序的内存地址(多线程切换)
Java 栈 线程专享:局部变量、操作数、引用指针等
线程共享:Java 对象
方法区 类结构信息:方法体、构造函数、方法体等
本地方法区 为运行 Native 方法准备的空间(包括 JIT)
运行时常量池 class 中定义的常量:数字、引用、字符串等

JVM 的内存结构如下图所示:

内存分配策略

通常情况下操作系统的内存分配策略有下表所示的三种,很明显堆分配策略是最自由的,但这种分配策略对操作系统的内存管理是一种挑战,这种在程序运行时动态的内存分配,在运行效率上也是比较差的。

内存策略
静态内存分配 在程序编译时就能确定每个数据在运行时需要的存储空间,不允许代码中有可变数据结构的存在,也不允许嵌套或者递归结构出现。
栈式内存分配 动态存储分配,只有到运行时(在程序入口处)才知道程序对数据区的需求,分配也是按照先进后出的原则。
堆式内存分配 当程序真正运行到相应代码时才知道空间大小。

JVM 的内存分配主要基于两种,分别是堆和栈。

  • Java 栈的分配和线程是绑定在一起的,JVM 会在线程创建时创建一个新的 Java 栈。当 Java 线程激活一个方法时,JVM 就会在线程的栈中新压入一个栈帧,存放基本的数据类型和对象句柄(引用),栈的数据 存取速度比堆要快,仅次于寄存器,缺点是数据大小与生存期必须是确定的,缺少了灵活性

  • 每个 Java 实例对应一个堆,存储程序运行中创建的所有类实例或数组,并由所有的线程共享,新建对象时会在栈中建立一个该对象的引用,在堆中分配这个对象所需的内存,Java 的垃圾收集器会自动收走不再使用的数据。缺点:动态分配内存,存取速度较慢。

  • 堆在程序运行时请求操作系统给自己分配内存,由于操作系统管理内存分配,所以在分配和销毁时都要占用时间,因此 堆的效率非常低。优点在于更大的灵活性,编译器不必知道要从堆中分配多少存储空间,也不必时间存储的数据在堆里停留多长时间(多态变量所需的存储空间只有在运行时创建了对象之后才能确定)。

  • 动态的内存分配对操作系统和内存管理程序是一种挑战,在程序运行时才执行的这种内存分配在效率上也是比较差的。

JVM 内存回收策略

目前使用范围最广的 Java 虚拟机 HotSpot 采用了基于分代的垃圾收集算法。它将对象按照寿命长短来分为年轻代和年老代,新创建的对象被分在年轻代(除非对象特别大,会直接被分配在年老代),经过几次回收仍然存活的对象会被划分到年老代,对年轻代和年老代采取不同的垃圾回收算法。因年老代的收集频度不像年轻代那么频繁,这样就 减少了每次垃圾收集所要扫描对象的数量,从而提高了垃圾回收的效率。

GC 的种类

  1. Minor GC / Young GC:新生代采用的垃圾回收,使用复制算法,当 Eden 满的时候触发,会清空 Eden 和 ServivorFrom 至 ServivorTo,原 ServivorTo 会成为下一次 GC 的 ServivorFrom,如此 Survivor 永远有一个是空的,一个是有东西的。
  2. Major GC / Full GC(整个堆):发生在年老代,采用标记清除算法,minor GC 时新生代的对象晋升进入老年代,导致老年代空间不够用时触发,当无法找到足够大的连续空间分配给新创建的大对象时也会触发一次 Full GC。耗时较长,要扫描再回收,会产生内存碎片,为了减少内存损耗,一般需要进行合并或者标记出来方便下次直接分配。
  3. 永久代与元空间:永久代指内存的永久保存区域,主要存放 Class 和元数据的信息,它和存放实例的区域不同,GC 不会 在主程序运行期对永久区域进行清理,导致了永久代的区域会随着加载的 Class 的增多而胀满,最终抛出 OOM 异常。Java 1.8 后永久代被元空间所取代,最大的区别是 元空间并不在虚拟机中,而是使用本地内存,这样加载多少类的元数据就不再由 MaxPermSize 控制,而是由系统的实际可用空间来控制。

垃圾回收算法

  1. 引用计数法:一个对象如果没有任何与之关联的引用,即它们的引用计数都为 0,则说明对象不太可能再被用到,那么这个对象就是可回收的对象。
  2. 可达性分析:为解决引用计数法的循环引用问题,可达性分析通过 GC Roots 作为起点搜索,如果在 GC Roots 和对象之间没有可达路径,标记两次后将面临被回收。 GC Roots 有:线程方法栈的引用对象、方法区中类静态属性和常量对象的引用、本地方法栈中 JNI 对象的引用、其他代的对象等。
  3. 标记清除算法 Mark-Sweep:标记 + 清除,会导致内存碎片化,后续可能发生大对象找不到可用空间的问题。
  4. 复制算法 Copying:为了解决 Mark-sweep 内存碎片化的问题,复制算法将内存容量划分为等大小的两块,每次只使用其中一块,当这一块内存满后将尚存活的对象复制到另一块上去,把已使用的内存清掉。这种算法实现简单,内存效率高,不易产生碎片,但最大的问题是可用内存被压缩到了原来的一半。
  5. 标记整理算法 Mark-Compact:结合以上两个算法,标记后不是清理对象,而是将存活对象移向内存的一端,然后清除端边界外的对象。
  6. 分代收集算法 vs. 分区收集算法:分代可以根据各年代对象的特点分别采用最适当的 GC 算法(新生代大批对象朝生夕死,只有少量存活,选用复制算法只需要付出少量存活对象的复制成本就可以完成收集,老年代对象存活率高,采用标记清理算法来进行回收,不必进行内存复制,可以腾出空闲内存);分区算法将整个堆空间划分为连续的不同小区间,每个小区间独立使用,独立回收。好处是可以控制依次回收多少个小区间,从而减少一次 GC 所产生的停顿。

Java 四种引用类型

  1. 强引用:把对象赋给引用变量,对象处于可达状态就不能被垃圾回收,因此强引用是造成 Java 内存泄漏的主要原因之一。
  2. 软引用:需要使用 SoftReference 来实现,系统内存足够时不会被回收,系统内存不足时会被回收,软引用通常在对内存敏感的程序中。
  3. 弱引用:需要使用 WeakReference 实现,比弱引用的生存期更短,只要垃圾回收一运行,不管 JVM 的内存空间是否足够,都会回收该对象占用的内存。
  4. 虚引用:需要使用 PhantomReference 来实现,必须和引用队列联合使用,不能单独使用,主要作用为跟踪对象被垃圾回收的状态。

GC 垃圾收集器

  1. Serial GC(单线程、复制算法):是 JVM 在 client 模式下默认的新生代垃圾收集器,曾在 JDK 1.3.1 前是新生代唯一的垃圾收集器,只适用一个 CPU完成垃圾收集的工作,进行时必须暂停其他所有的工作线程(STW : Stop the world),但简单高效。

  2. ParNew GC(Serial + 多线程):是很多运行在 Server 模式下的 JVM 新生代的默认垃圾收集器, 除了使用多线程进行垃圾收集之外,其余的行为和 Serial 收集器完全一样,在垃圾收集过程中同样也要暂停所有其他的工作线程。

  3. Parallel GC(多线程复制算法、高效):新生代垃圾收集器,同样使用了多线程 + 复制算法,特点在于程序拥有一个可控的吞吐量(运行用户代码时间/(运行用户代码时间+垃圾收集时间))+ 自适应调节策略。适用于在后台运算而不需要太多交互的任务。

  4. Serial Old GC(单线程、标记整理算法):Serial 垃圾收集器的老年代版本,是 JVM 在 client 模式下默认的老年代垃圾收集器,Server 模式下,JDK 1.5 之前与新生代的 Parallel Scavenge 收集器搭配使用。

  5. Parallel Old GC(多线程、标记整理算法):是 Parallel GC 的老年代版本,在老年代同样提供吞吐量优先的垃圾收集器,如果系统对吞吐量要求比较高,可以优先考虑新生代 Parallel GC + 老年代 Parallel Old 收集器的搭配策略。

  6. 以上所有收集器运行时都会触发 STW,暂停所有的用户线程。

  7. CMS GC(多线程、标记清除算法):Concurrent mark sweep,目的是 获取最短垃圾回收停顿时间,其四个工作阶段如下表所示,由于耗时最长的并发标记和并发清除过程,垃圾收集线程和用户线程可以一起并发工作,所以总体上来看 CMS 收集器的内存回收和用户线程是一起并发地执行的。

    CMS 阶段 工作内容
    初始标记 只是标记一下 GC Roots 能 直接 关联的对象,速度很快,仍然需要 STW。
    并发标记 进行 GC Roots 跟踪的过程,和用户线程一起工作,不需要暂停工作线程
    重新标记 修正在并发标记期间,因程序运行而导致标记产生变动的那一部分对象,需要 STW。
    并发清除 清除 GC Roots 不可达对象,和用户线程一起工作,不需要暂停工作线程
  8. G1 GC:Garbage First 是目前垃圾收集器理论发展的最前沿成果,相比于 CMS 收集器,G1 最突出的改进有:基于标记整理算法,不产生内存碎片;可以非常精确控制停顿时间,在不牺牲吞吐量前提下,实现低停顿垃圾回收。G1 收集器避免全区域垃圾收集,它把堆内存划分为大小固定的几个独立区域,并且跟踪这些区域的垃圾收集进度,同时在后台维护一个优先级列表,每次根据所允许的收集时间,优先回收垃圾最多的区域。区域划分 + 优先级区域回收机制,确保 G1 收集器可以在有限时间获得最高的垃圾收集效率。

内存问题分析

  1. 遇到 Java 内存问题后,应该立刻记录当时的堆快照,-XX:+HeapDumpOnOutOfMemoryError 在内存异常的时候自动生成快照。
  2. 使用可视化工具进行分析,如 JProfiler,VisualVM 等。
  3. 也可以在 JVM 的启动日志上加参数,打印出 GC 的日志进行分析
  4. jstat -gcutil [pid][intervel][count] JVM 自带的分析工具
  5. jps 拿到当前进程后 jmap -dump:format=b,file[filename][pid] 生成快照 –> hprof mat
  6. 推荐阅读:Plumbr 垃圾回收算法与原理,GC 调优的原则 + Oracle 官网的,Java 9 的 Java 虚拟机的调优指南