深入理解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堆分为新生代和老年代,在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。
而老年代中因为对象存活率高、没有额外空间对它进行分配担保,就必须使用“标记-清理”或“标记-整理”算法来进行回收。

垃圾收集器

CMS 收集器 (Concurrent Mark Sweep)

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

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

  • 初始标记(CMS initial mark)
  • 并发标记(CMS concurrent mark)
  • 重新标记(CMS remark)
  • 并发清除(CMS concurrent sweep)。

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

而CMS也有明显的缺点:

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

理解gc日志

阅读GC日志是处理jvm的基础技能,可以配置上-XX:+PrintGCDetails -XX:+PrintGCTimeStamps -Xloggc:/logs/gc.log,jvm就能为我们将日志输出到指定的路径下。

1
2
4.533: [GC (Allocation Failure) 4.533: [ParNew: 1041729K->77803K(1061696K), 0.0825433 secs] 1041729K->87450K(3027776K), 0.0826023 secs] [Times: user=0.16 sys=0.03, real=0.08 secs]
99.631: [Full GC (System.gc()) 99.631: [CMS: 9646K->53928K(1966080K), 0.2198137 secs] 883651K->53928K(3027776K), [Metaspace: 45355K->45355K(1089536K)], 0.2201145 secs] [Times: user=0.24 sys=0.00, real=0.22 secs]

最前的数字,4.53399.631代表GC发生的时间,是从jvm启动以来经过的秒数。

后面的GCFull GC是用来区分收集区域的。GC说明只收集GC堆的部分区域。通常就是minor GC,只收集young gen。Full GC说明收集了整个GC堆的所有区域,包括young、old、perm(如果有perm)。后面中的Allocation FailureSystem.gc()是触发的原因。

然后ParNewCMS是分代的垃圾收集器,上面有提及过。1041729K->77803K(1061696K)表示GC前该区域已使用容量->GC后该区域已使用容量

而在方括号外面的1041729K->87450K(3027776K) 表示GC前Java堆已使用容量->GC后java堆已使用的容量(Java堆总容量)

从以上的信息我们可以计算出在垃圾收集期间, JVM中的内存使用情况。在垃圾收集之前, 堆内存总的使用了0.99G(1041729K),其中,年轻代使用了0.99G(1041729K),老年代还没开始使用,说明是刚刚开始的gc日志。而从(1041729K-77803K)-(1041729K-87450K)=9647(9.42M)可以知道有9.42M年轻代对象被提升到老年代中。

再往后[Times: user=0.16 sys=0.03, real=0.08 secs]与Linux命令所输出的时间含义一致,分别代表用户态消耗的CPU时间、内核态消耗的CPU时间和操作从开始到结束锁经过的墙钟时间(Wall Clock Time)。墙钟时间与 CPU时间的区别是,墙钟时间包括各种非运算的等待耗时,例如等待磁盘I/O、等待线程紫塞。而CPU时间不包括这些,但是多CPU或者多核,多线程操作会叠加这些CPU时间。

内存分配与回收策略

对象优先分配Eden区

大多数情况下,对象在新生代Eden区中分配。当Eden空间不足,则会发生一次Minor GC

大对象直接进入老年代

大对象是指需要大量连续内存空间的java对象(典型的如长字符串和数组),当新生代不足以存放大对象,就会跳过新生代直接老年代。

长期存活的对象将进入老年代

Jvm给每个对象定义了一个对象年龄(Age)计数器。如果对象在Eden出生并经过第一次Minor GC后仍然存活,并且能被Survivor容纳的话,将被移动到Survivor空间中,并且对象年龄设为1。对象在Survivor区中每熬过一次Minor GC,年龄就增加1岁。当它的年龄增加到一定程度(默认是15岁),将会被晋升到老年代。可以通过-XX:MaxTenuringThreshold参数设置晋升老年代的阈值

动态对象年龄判定

为了能更好地适应不同程序的内存情况,Jvm并不总是要求对象的年龄必须达到MaxTenuringThreshold才能晋升老年代。如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代。

空间分配担保

空间分配担保

图中提到了是否允许担保失败,当新生代使用复制收集算法,但是为了内存利用率,只使用其中一个Survivor空间来作为轮换备份,因此当出现大量对象在Minor GC后依然存活(最极端就是内存回收后新生代中所有对象都存活),就需要老年代进行分配担保,把Survivor无法容纳的对象直接进入老年代。但是多少会晋升到老年代在实际内存回收前是无法明确知道的,所以只好取之前每一次回收晋升到老年代对象容量的平均值作为经验值,与老年代剩余空间比较,决定是否进行Full GC来让老年代腾出更多空间。

取平均值任然是一种动态概率的手段,依然会出现担保失败,那时绕的圈子是最大的,但大部分情况下还是会将HandlePromotionFailure打开,避免Full GC过于频繁。


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

Fork me on GitHub