深入理解Java虚拟机--读书笔记

最近认识到自己的薄弱点,需要狠狠补一下! 🥕

花似雾中看,拨云见南山。
鲲鹏九万里,一览众山小。

Part 1

第一部分,走进java,讲述了jvm的发展史,这里就不多做赘述了。贴上作者在infoq的文章~
Java虚拟机家族考

Part 2

第二部分,自动内存管理机制

运行时数据区域

Java虚拟机运行时数据区

Program Counter Register(程序计数器)
  • 较小的内存空间,可以看做是当前线程所执行的字节码的行号指示器,字节码解释器工作时就是通过改变计数器的值来选择下一条的指令,分支、循环、跳转、异常处理、线程恢复也依赖计数器。
  • 每条线程独立拥有,且互不影响,独立存储(线程私有)
  • 如果线程正在执行Java方法,则计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是native方法,则这个计数器则为空。且是jvm中唯一没有规定OOM的区域。

这里对于计数器为何为空,转自R大的回答

这里的“pc寄存器”是在抽象的JVM层面上的概念——当执行Java方法时,这个抽象的“pc寄存器”存的是Java字节码的地址。实现上可能有两种形式,一种是相对该方法字节码开始处的偏移量,叫做bytecode index,简称bci;另一种是该Java字节码指令在内存里的地址,叫做bytecode pointer,简称bcp。

对native方法而言,它的方法体并不是由Java字节码构成的,自然无法应用上述的“Java字节码地址”的概念。所以JVM规范规定,如果当前执行的方法是native的,那么pc寄存器的值未定义——是什么值都可以。

上面是JVM规范所定义的抽象概念,那么实际实现呢?

Java线程总是需要以某种形式映射到OS线程上。映射模型可以是1:1(原生线程模型)、n:1(绿色线程/用户态线程模型)、m:n(混合模型)。

以HotSpot VM的实现为例,它目前在大多数平台上都使用1:1模型,也就是每个Java线程都直接映射到一个OS线程上执行。此时,native方法就由原生平台直接执行,并不需要理会抽象的JVM层面上的“pc寄存器”概念——原生的CPU上真正的PC寄存器是怎样就是怎样。就像一个用C或C++写的多线程程序,它在线程切换的时候是怎样的,Java的native方法也就是怎样的。

Java Virtual Machine Stacks(Java 虚拟机栈)
  • 同样为线程私有
  • 每个方法被执行的时候都会同时创建一个栈帧 (Stack Frame)用于存储局部变量表、操作栈、动态链接、方法出口等信息。
  • 这里的局部变量表存放了各种基本类型(比如int、float等)其中64位的long和double类型占用2个局部变量空间(Slot),其他占用为1个,对象引用(reference类型,它不等同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他与此对象相关的位置)和returnAddress类型(指向一条字节码指定的地址)
  • 其中如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常。以及在动态扩展内存的时候无法申请到足够的内存,就会抛出OOM异常
Native Method Stacks(本地方法栈)

与虚拟机栈非常相似,对应的是执行native 方法。

Java Heap(Java 堆)
  • 线程共享的内存区域,存放对象实例以及数组。
  • GC堆(采用分代收集算法)
  • 堆可以处于物理上不连续的内存空间,只要逻辑上是连续的就可以。空间可以通过-Xmx-Xms控制。
  • 如果在堆中没有内存完成实例分配,并且堆也无法再扩展时,将会抛出OOM异常
Method Area (方法区)
  • 线程共享的内存区域,存放jvm加载的类信息、常量、静态变量、即时编译器编译后的代码块等数据
  • 当方法区无法满足内存分配需求时,会抛出OOM异常
Runtime Constant Pool(运行时常量池)

Runtime Constant Pool 是方法区的一部分,Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是Constant Pool Table(常量池),用于存放编译期生成的各种字面量和符号引用

这里提一下String pool(字符串常量池),字符串常量池和运行时常量池不是一个概念(容易混淆),String pool是全局共享的,在GC堆外(native memory)。String pool的实现是一个StringTable类,它是一个Hash表。.在java7,8中使用 -XX:StringTableSize 参数设置字符串常量池的map大小。

再补充一点,对HotSpot VM来说,不受GC管理的内存都是native memory;受GC管理的内存叫做GC heap或者managed heap。

Metaspace

在jdk8中 PermGen被移除,方法区移至 Metaspace ,可以通过-XX:MaxMetaspaceSize调整大小。(针对于Hotspot)

首先Metaspace(元空间)是哪一块区域呢?官方的解释是:

In JDK 8, classes metadata is now stored in the native heap and this space is called Metaspace.

翻译过来就是:JDK 8 开始把类的元数据放到本地堆内存(native heap)中,这一块区域就叫 Metaspace。其中Oracle blog 提到的 native heap 应该是归属于 native memory(I guess)。

再了解一下PermGen:

PermGen(永久代)是Hotspot虚拟机特有的概念,是方法区的一种实现,别的JVM都没有这个东西。而方法区(Method area)只是JVM规范中定义的一个概念,用于存储类信息、常量池、静态变量、JIT编译后的代码等数据,具体放在哪里,不同的实现可以放在不同的地方。

以及被移除的原因:

  • 一部分是PermGen内存经常会溢出,导致OOM。
  • 另外移除PermGen 可以促进 HotSpot JVM 与 JRockit VM 的融合,因为 JRockit 并没有永久代。
Direct Memory (直接内存)
  • 不属于虚拟机运行时数据区的一部分,但是频繁被调用,也可能导致出现OOM异常,在配置虚拟机参数时也需要注意。(-XX:MaxDirectMemorySize)

Part 3

第三部分,垃圾收集器与内存分配策略

可达性分析算法

目前可达性分析算法是判定对象是否存活的主流实现方式,从GC Roots的对象作为起点向下搜索,搜索中的路径称为引用链,当一个对象到GC Roots没有任何引用链相连时,则证明此对象是不可用。

那么是如何来判断GC Roots是否有引用链呢?

所谓GC Roots,就是一组必须活跃的引用。

这些引用可能包括:

  • 所有Java线程当前活跃的栈帧里指向GC堆里的对象的引用;换句话说,当前所有正在被调用的方法的引用类型的参数/局部变量/临时值。
  • 虚拟机栈中的引用的对象
  • 本地方法栈中JNI的引用的对象
  • (看情况)所有当前被加载的Java类
  • (看情况)Java类的引用类型静态变量
  • (看情况)Java类的运行时常量池里的引用类型常量(String或Class类型)
  • (看情况)String常量池(StringTable)里的引用

另外GC Roots是否可以方便获取也至关重要,最初的虚拟机很多采用保守式GC,不记录这些信息,实现简单但是效率低。现在主流的虚拟机都采用准确式的GC,尽量早和方便地收集这些信息,加快整个标记的速度。

大致的实现思路是JVM采用了OopMap这个数据结构记录了GC Roots,GC的标记开始的时候,从OopMap就可以获得GC roots。OopMap记录了特定时刻栈上(内存)和寄存器(CPU)的哪些位置是引用,通过这些引用就可以找到堆中的对象,这些对象就是GC roots. 而不需要一个一个的去判断某个内存位置的值是不是引用。

有了OopMap就可以快速获得GC Roots,接着就可以开始标记了。标记的基本思路就是遍历一个有向图,节点是对象,边是引用,能被遍历到的(可到达的)对象就被判定为存活,其余对象就自然被判定为死亡。GC Roots的本质是通过找出所有活对象来把其余空间认定为“无用”,而不是找出所有死掉的对象并回收它们占用的空间。

而这些被判定为死亡的对象不是直接会被回收,而是至少经历两次标记过程。

  • 第一次标记并进行一次筛选,筛选的条件是此对象是否有必要执行finalize()方法。
  • 第二次标记,如果这个对象被判定为有必要执行finalize()方法,那么这个对象将会被放置在一个名为:F-Queue的队列之中,并在稍后由一条虚拟机自动建立的、低优先级的Finalizer线程去执行。如果对象要在finalize()中重新与引用链上的任何的一个对象建立关联将避免回收,反之则被回收。

Reference (引用)

  • 强引用: 用new 关键字修饰,只要强引用还存在,垃圾收集器永远不会回收掉被引用的对象
  • 软引用: 用来描述一些还有用但是不是必需的对象。在将要oom的时候会将这些对象列进回首范围进行第二次回收。
  • 弱引用: 用来描述非必需的对象,当垃圾收集器工作时,无论内存是否足够,都会回收掉被弱引用关联的对象。
  • 虚引用: 它是最弱的一种引用关系,为一个对象设置虚引用关联的唯一目的就是能在这个对象被收集器回收时收到一个系统通知。

demo戳 Reference demo

垃圾收集算法

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

“标记-清除”分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后统一回收掉所有被标记的对象

它的主要缺点有两个:

  • 一个是效率问题,标记和清除过程的效率都不高;
  • 另外一个是空间问题,标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致,当程序在以后的运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。

标记-清除算法

复制算法(Copying)

为了解决效率问题,一种称为“复制”(Copying)的收集算法出现了,它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。
当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。

一般被用于新生代,新生代大部分的对象都是朝生夕死,所以内存分为一块较大的Eden空间和两块较小的Survivor空间,比例为8:1:1。
不过这个也要看实际情况来分配,当Survivor空间不够用时,需要依赖其他内存(这里指老年代)进行分配担保(Handle Promotion)。

复制算法

标记-整理算法(Mark-Compact)

根据老年代的特点,有人提出了另外一种“标记-整理”(Mark-Compact)算法,标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存

标记-整理算法

分代收集算法

该算法是根据对象的存活周期的不同将内存划分为几块,一般是把Java堆分为新生代和老年代,在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。
而老年代中因为对象存活率高、没有额外空间对它进行分配担保,就必须使用“标记-清理”或“标记-整理”算法来进行回收。

垃圾收集器

Serial 收集器

Serial收集器是最基本、历史最悠久的收集器,曾经(在JDK 1.3.1之前)是虚拟机新生代收集的唯一选择。

就像它的名字,这个收集器是一个单线程的收集器,但它的“单线程”的意义并不仅仅是说明它只会使用一个CPU或一条收集线程去完成垃圾收集工作,更重要的是在它进行垃圾收集时,必须暂停其他所有的工作线程(Sun将这件事情称之为“Stop The World”),直到它收集结束。Serial收集器的运行过程如下图:

Serial

ParNew 收集器

ParNew收集器其实就是Serial收集器的多线程版本,除了使用多条线程进行垃圾收集之外,其余行为包括Serial收集器可用的所有控制参数(例如:-XX:SurvivorRatio、 -XX:PretenureSizeThreshold、-XX:HandlePromotionFailure等)、收集算法、Stop The World、对象分配规则、回收策略等都与Serial收集器完全一样,实现上这两种收集器也共用了相当多的代码。

ParNew收集器的运行过程如下图:

ParNew

Parallel Scavenge 收集器

Parallel Scavenge收集器也是一个新生代收集器,它也是使用复制算法的收集器,又是并行的多线程收集器

Parallel Scavenge收集器的特点是它的关注点与其他收集器不同,CMS等收集器的关注点尽可能地缩短垃圾收集时用户线程的停顿时间,而Parallel Scavenge收集器的目标则是达到一个可控制的吞吐量(Throughput)。所谓吞吐量就是CPU用于运行用户代码的时间与CPU总消耗时间的比值,即吞吐量 = 运行用户代码时间 /(运行用户代码时间 + 垃圾收集时间),虚拟机总共运行了100分钟,其中垃圾收集花掉1分钟,那吞吐量就是99%。停顿时间越短就越适合需要与用户交互的程序,良好的响应速度能提升用户的体验;而高吞吐量则可以最高效率地利用CPU时间,尽快地完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务。

Serial Old 收集器

Serial Old是Serial收集器的老年代版本,它同样是一个单线程收集器,使用“标记-整理”算法。

这个收集器的主要意义也是被Client模式下的虚拟机使用。如果在Server模式下,它主要还有两大用途:一个是在JDK 1.5及之前的版本中与Parallel Scavenge收集器搭配使用,另外一个就是作为CMS收集器的后备预案,在并发收集发生Concurrent Mode Failure的时候使用。Serial Old收集器的工作过程如下图:

Serial Old

Parallel Old 收集器

Parallel Old是Parallel Scavenge收集器的老年代版本,使用多线程和“标记-整理”算法。

Parallel Old的出现让”吞吐量优先”收集器终于有了比较名副其实的应用组合,在注重吞吐量及CPU资源敏感的场合,都可以优先考虑Parallel Scavenge加Parallel Old收集器。Parallel Old收集器的工作过程如下图:

Parallel Old

CMS 收集器 (Concurrent Mark Sweep)

CMS收集器是一种以获取最短回收停顿时间为目标的收集器,基于标记-清除算法实现,作用于老年代。

对于CMS 收集器,整个过程可以分为:

cms

  • 初始标记(CMS initial mark):STW,单线程,由于是从GCRoot寻找直达的对象,速度快。
  • 并发标记(CMS concurrent mark):与应用线程一起运行,是CMS最主要的工作阶段,通过直达对象,扫描全部的对象,进行标记。
  • 重新标记(CMS remark):STW,修正并发标记时由于应用程序还在并发运行产生的对象的修改,多线程,速度快,需要全局停顿。
  • 并发清除(CMS concurrent sweep):与应用程序一起运行。

CMS 收集器主要优点是并发收集、低停顿。
其中初始标记和重新标记是需要Stop The World,初始标记仅仅只是标记一下GC Roots能直接关联到的对象,速度很快,并发标记阶段就是进行GC Roots Tracing的过程,而重新标记阶段则是为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段稍长一些,但远比并发标记的时间短。
由于整个过程中耗时最长的并发标记和并发清除过程中,收集器线程都可以与用户线程一起工作,所以总体上来说,CMS收集器的内存回收过程是与用户线程一起并发地执行的。

而CMS也有明显的缺点:

  • CMS收集器对CPU资源非常敏感。(并发阶段,它虽然不会导致用户线程停顿,但是会因为占用了一部分线程或者说CPU资源而导致应用程序变慢,总吞吐量会降低。)
  • CMS收集器无法处理浮动垃圾(Floating Garbage) (由于CMS并发清理阶段用户线程还在运行着,伴随程序的运行自然还会有新的垃圾不断产生,这一部分垃圾出现在标记过程之后,CMS无法在本次收集中处理掉它们,只好留待下一次GC时再将其清理掉,这一部分垃圾就称为“浮动垃圾”。)
  • 产生大量空间碎片 (这是标记-清除算法所造成的)

那CMS为何要采用标记-清除算法?

CMS主要关注低延迟,因而采用并发方式,清理垃圾时,应用程序还在运行,如果采用整理算法,则涉及到要移动应用程序的存活对象,此时不停顿,是很难处理的,一般需要停顿下,移动存活对象,再让应用程序继续运行,但这样停顿时间变长,延迟变大

而新的G1(Garbage-First)把整个GC堆划分为许多小区域(region),通过每次GC只选择收集很少量region来控制移动对象带来的暂停时间。这样既能实现低延迟也不会受碎片化的影响。

G1 收集器 (Garbage-First)

G1 收集器可以支持并行与并发,单个收集器就可以管理整个GC堆(分代算法),与CMS不同的是基于标记-整理算法,以及支持可预测的停顿。

对于G1 收集器 ,整个过程大致可以分为:

  • 初始标记(Initial Marking)
  • 并发标记(Concurrent Marking)
  • 最终标记(Final Marking)
  • 筛选回收(Live Data Counting and Evacuation)。

与CMS收集器非常相近,初始标记阶段仅仅是标记一下GC Roots能直接关联到的对象,并修改TAMS的值,让下一阶段用户程序并发运行时,能正确可用的Region中创建新对象,这阶段需要停顿线程,但耗时很短。并发阶段是从GC Roots开始对堆中对象进行可达性分析,耗时较长,可与用户线程并发。
而最终标记阶段是为了修正在并发标记期间因用户程序继续运作而导致标记产生变动的那一部分标记记录。虚拟机将这段时间对象变化记录在线程Remenbered Set Logs里面,最终标记阶段需要把Remembered Set Logs的数据合并到Remembered Set Logs里面,最终标记阶段需要把Remembered Set Logs的数据合并到Remembered Set中,这一阶段需要停顿线程,但是可并行执行。最后在筛选回收阶段首先对各个Region的回收价值和成本进行排序,根据用户所期望的GC停顿时间来制定回收计划。

G1收集器是垃圾收集器理论进一步发展的产物,它具备以下的特点:

  • 并行与并发(G1能充分利用CPU、多核环境下的硬件优势,使用多个CPU或者CPU核心来缩短STP停顿时间。)
  • 分代收集(虽然G1可以不需要其他收集器配合就能独立管理整个GC堆,但是还是保留了分代的概念。)
  • 空间整合(G1收集器是基于标记-整理算法实现的收集器,也就是说它不会产生空间碎片,这对于长时间运行的应用系统来说非常重要。)
  • 可预测的停顿 (它可以非常精确地控制停顿,既能让使用者明确指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间不得超过N毫秒,这几乎已经是实时Java(RTSJ)的垃圾收集器的特征了。)
总结

总的来说,除了G1目前都可分为新生代和老年代算法。这些算法有两个性能侧重点:1. 回收停顿时间;2. 吞吐量。
偏向前者的有 CMS 和 G1,CMS 属于老年代收集器,常与 CMS 搭配使用的是 ParNew 收集器。
偏重吞吐量的算法是 Parallel Scavenge(复制算法),这是个新生代收集器,常与之搭配的老生代算法是 Parallel Old(多线程、标记-整理算法)。


Github 不要吝啬你的star ^.^
更多精彩 戳我

Follow me on GitHub