jvm运行时数据区

jvm在执行java程序的过程中会把它所管理的内存划分为若干不同的数据区域。java虚拟机所管理的内存包括如下几个运行时数据区域:

1. 程序计数器

程序计数器是一块娇小的内存空间,可以视作当前线程所执行的字节码的行号。字节码解释器工作时就是通过改变此值来选取下一条需要执行的字节码指令。
多个线程中,每个线程都有一个独立的程序计数器,相互之间互不干扰,独立存储,线程私有。

2. java虚拟机栈

java虚拟机栈是线程私有的,它的生命周期和线程相同。
虚拟机栈描述的是java方法执行的线程内存模型,每个方法被执行的时候,java虚拟机栈都会同步创建一个栈帧用于存储局部变量表、操作数栈、动态连接、方法出口等。每个方法被调用、直至结束的过程对应该栈帧在虚拟机栈的入栈和出栈动作。

局部变量表中存放了编译期可知的各种java虚拟机基本数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference类型(指向对象起始地址的引用指针或指向代表对象位置的句柄)、和 returnAddress类型(指向字节码指令的地址))
局部变量表所需的内存在编译期就已经确定了,进入一个方法时,需要在虚拟机占中分配的栈帧大小也是确定的。

3. 本地方法栈

虚拟机栈为虚拟机执行java方法服务,而本地方法栈则为虚拟机执行本地方法服务(native标注)

4. java堆

java堆在虚拟机启动时创建,由所有线程共享。java堆存在的意义即是存放实例对象。

5. 方法区

方法区也是各个线程共享的内存区域,用于存储已经被虚拟机加载的类型信息,常量、静态变量、即时编译器变异后的代码缓存等数据。

6. 运行时常量池

运行时常量池是方法区的一部分, Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有就是 常量池表,用于存放编译期生成的各种字面量和符号引用,这部分内容在类加载后存放到方法区的运行时常量池中。

7. 直接内存

jdk1.4之后,NIO中引入了基于通道与缓冲区的IO方式,它可以直接使用native函数直接分配堆外内存,然后通过一个存储在java堆里面的directByteBuffer对象作为这块内存的引用进行操作。

垃圾收集器和内存分配策略

垃圾收集需要完成的三件事:

  1. 哪些内存需要回收?
  2. 什么时候回收?
  3. 如何回收?

程序计数器、虚拟机栈、本地方法栈这三个区域随线程生灭,这几个区域的内存分配和回收都具备确认性。当方法结束或者线程结束时,内存跟着回收。
jvm的内存回收主要针对 java堆和方法区。

垃圾回收机制

引用计数

在对象中添加一个引用计数器,每当有地方引用它时,计数器值加1,;引用时效时,计数器减一;任何计数器为零的对象就不会被再使用。
在java领域,主流的java虚拟机都没有选择引用计数算法进行内存管理。因为它必须配合大量额外处理才能确保能正常工作。同事它无法处理对象之间的循环引用问题。两个对象除相互引用外,没有其它对象引用,这两个对象无法被引用计数算法回收。

可达性分析

目前主流的商业程序语言的内存管理都是通过可达性分析算法reachability analysis来判定对象是否存活的。
通过一系列被称作GC ROOTS的根对象作为起始节点集,从这些节点根据引用关系向下搜索,搜索过程走过的路径称作引用链,如果一个对象到GC ROOTS间没有任何引用链相连,则证明此对象是不可能在被使用的。[图论说法: 从GC ROOTS到这个对象不可达]

java体系中,固定作为GC ROOTS的对象包括如下几种:

  1. 在虚拟机栈中引用的对象。例如 各线程被调用的方法堆栈中使用到的参数、局部变量、临时变量等。
  2. 在方法区中类静态属性的引用对象。比如java类的引用类型静态变量。
  3. 在方法区中常量引用的对象。比如字符串常量池String Table中的引用。
  4. 在本地方法栈中JNI引用的对象。
  5. java虚拟机内部的引用。比如基本数据类型对应的class对象,常驻的异常对象NPE、OOM等。
  6. 所有被同步锁持有的对象(synchronized)。
  7. 反映java虚拟机内部情况的jmxbean、本地代码缓存等。

除上述固定的GC Roots外,还可以有其他对象临时性的加入。

引用分类
  1. 强引用:代码中普遍存在的引用赋值,任何情况下,只要强引用关系还在,就不会被gc
  2. 软引用: 用以描述还有用但非必须的对象。只被软引用引用的对象,在系统将要发生内存溢出前,会把这些对象列入回收范围中进行第二次回收。如果仍没有足够的内存,才会抛出内存溢出异常。
  3. 弱引用:被弱引用关联的对象只能生存到下一次垃圾收集发生为止。gc开始后,无论当前内存是否足够,都会回收掉目前只被弱引用关联的对象。
  4. 虚引用:为对象设置虚引用关联的唯一目的是为了能在这个对象被收集器回收时收到一个系统通知。
回收时机

在可达性分析算法中判定为不可达对象后,要真正宣告一个对象死亡,至少要经历两次标记过程: 如果对象在进行可达性分析后发现没有与GC Roots相连接的引用链,那么它将被第一次标记。然后进行一次筛选,筛选此对象是否有必要执行finalize()方法。加入对象没有覆盖finalize()方法,或者已经被虚拟机调用过,虚拟机将视作没有必要执行;
如果确有必要执行finalize()方法,那么该对象将会被放置在一个名为F-Queue队列之中,并在稍后由一条由虚拟机自动建立的、低调度优先级的Finalizer线程去执行他们的finalize()方法。
如果在finalize()方法中将对象与引用链上的任一对象关联即可免除被回收。
另外要注意的是,任意一个对象的finalize()方法都只会被系统自动调用一次。如果对象面临下一次回收,finalize()方法将不会被执行。

方法区的回收

在java堆中,尤其是新生代,对常规应用进行一次垃圾收集通常可以回收70%-99%的内存空间;而其他区域额定回收成果则远低于此。
方法区的垃圾收集主要包含两个部分:

  1. 废弃的常量。
  2. 不再使用的类型。(类卸载)

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

  1. 该类所有的实例都已经被回收。即java堆中不存在该类及其任何派生子类的实例。
  2. 加载该类的类加载器已经被回收。除非是经过精心设计的可替换类加载器的场景,如OSGi、JSP的重加载等,否则很难满足。
  3. 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

垃圾收集算法

从如何判定对象消亡的角度,垃圾收集算法可以划分为 引用计数式垃圾收集 和 追踪式垃圾收集,也被称作 直接垃圾收集 和 间接垃圾收集。由于主流java虚拟机中均未涉及引用计数式垃圾收集,下面仅介绍追踪式垃圾收集算法。

  1. 分代收集理论
    分代收集建立在两个假说之上:
  • 弱分带假说: 绝大多数对象都是朝生夕灭的。
  • 强分代假说:熬过月多次垃圾收集过程的对象就越难以消亡。

垃圾收集器的一致设计原则: 垃圾收集器应当将java堆划分出不同的区域,然后将回收对象依据年龄分配到不同的区域中存储。
故而在垃圾回收时,可以更加关注哪些对象将继续存活而不是标记那些大量将要被回收的对象,以此能以较低代价回收大量空间。如果剩下的都是难以消亡的对象,则将他们集中放在一块,收集器以更低的频率回收此区域,以增加其回收的效率。

划分出不同的区域后,,垃圾收集器才可以每次只回收其中某一个或者某些区域,才有了 Minor GC| Major GC|Full GC这样的回收类型的划分。 也才能够针对不同的区域安排与里面存储的对象存亡特征相匹配的垃圾收集算法: 标记-复制算法、标记-清除算法、标记-整理算法。

分带收集理论的第三个假说:
跨代占用假说: 跨代引用相对于同代引用来说仅占极少数: 即存在相互引用关系的两个对象,是倾向于同时存在或者同时消亡的。
以此,在进行局限于新生代区域的收集时 minor gc, 就不必考虑老年代中对新生代对象的引用,也不必支付遍历整个老年代对象的开销。只需要在新生代中建立一个全局的数据结构(记忆集,将老年代划分为若干个小块,标识出哪一块中存在跨代引用)。在执行minor gc时,只有包含了跨代引用的小块内存里的对象才会被加入 GC Roots中用以进行可达性分析。

部分名词说明:

  1. Partial GC: 部分收集,只对java堆的部分区域进行垃圾回收
    • minor gc/young gc: 目标只是新生代的垃圾收集
    • major gc/old gc: 值目标只是老年代的垃圾收集 目前只有cms收集器会单独对老年代进行垃圾回收
    • mixed gc:目标是整个新生代以及部分老年代的垃圾回收,目前只有g1 收集器有这种行为。
  2. full gc:收集整个java堆和方法区的垃圾收集。
标记-清除算法

它是出现最早且最基础的垃圾收集算法: 首先标记出所有需要回收的对象,标记完成后,统一回收调所有被标记的对象;也可以反过来,标记存活的对象,统一回收未被标记的对象。
标记-清除算法有如下缺点:

  1. 执行效率不稳定: 如果java堆中包含大量对象,且大部分是需要被回收,必须进行大量标记和清除动作,导致标记和清除两个过程的执行效率都随着对象数量增长而降低。
  2. 内存空间的碎片化问题: 标记-清除后会产生大量不连续的内存碎片,空间碎片太多,加入程序在运行过程中需要分配较大空间时,无法找到足够的连续内存空间而不得不提前触发下一次垃圾收集动作。
标记-复制算法

为了解决标记-清除算法面对大量可回收对象执行效率低的问题,该算法提出 半区复制的垃圾收集算法,它将可用内存按照容量划分为大小相等的两块,每次只使用其中的一块。当其中一块内存用完了,就将还存活的对象复制到另外一块上,再将已经使用过的内存空间一次清理掉。
如果内存中的对象大量存活,就需要为此支付较高的对象复制开销;而多数对象可回收时,就只需要复制少数存活的对象。而每次都是针对半区进行内存回收,分配内存是就不用考虑有空间碎片的复杂问题。
正如你所见,标记-复制算法的代价是将可用内存缩小为了原来的一半,浪费了一半可用的内存空间。

为此,IBM公司的研究表明,新生代的对象中有98%熬不过第一轮垃圾收集。

针对具备这种 朝生夕灭 特点的对象,提出了一种更优化的半区复制分代策略,称作 Appel式回收
其做法是:将新生代分为一块较大的 eden 空间和两块较小的 survivor 空间,每次分配内存只是用eden和其中一块survivor。发生垃圾收集时,将eden 和survivor1中尚存活的对象复制到survivor2中,然后直接清理掉eden 和 survivor1 空间。
hotspot虚拟机默认eden:survivor1:survivor2 = 8:1:1。
以上都是基于98%的对象逃不过第一次收集,但是如果出现 survivor2 不足以容纳 一次 minor gc后存活的对象时,可以依赖其它内存区域进行分配担保。 (handle promotion) 这些对象将直接进入老年代。

标记-整理算法

标记-复制算法在对象存活率较高时就需要进行较多的赋值操作,效率将会降。
同时,如果不想浪费50%的空间,就需要有额外的空间进行分配担保,以应对被使用的内尺寸中的所有对象都100%存活的情况,所以在老年代一般不能直接选择此算法。

标记整理算法的特点是,在垃圾收集完毕后,并非和标记复制算法一样清理掉可回收的对象,而是将所有的存活对象向向内存空间的一端移动,然后直接清除掉边界以外的内存。

标记整理算法是一种移动存活对象的算法,它优缺点并存,也存在一种权衡trade-off。

如果移动存活对象,尤其是对老年代这种每次回收都有大量存活对象的区域,移动存活对象并更新所有引用这些对象的地方是一种极为负重的操作,而且这种对象移动操作必须暂停用户应用程序才能进行。
但如果完全不考虑移动和整理存活对象的话,堆中内存的碎片化就只能依赖更为复杂的内存分配器和内存访问器来解决,比如 分区空闲分配链表来解决内存分配问题。 但是内存访问是用户程序最频繁的访问,如果在此缓解增加额外负担,势必会直接影响应用程序的吞吐量。
是否移动对象都存在弊端,移动则内存回收时更复杂。不移动则内存分配时更复杂。
从垃圾收集的频率看,不移动对象停顿时间会更短。
但从整个程序的吞吐量来看,移动对象会更划算。(此处吞吐量是赋值器和垃圾收集器效率的总和)
hotspot中关注吞吐量的 Parallel scavenge是基于 标记-整理算法的, 而 关注时延的cms收集器是基于标记-清除算法的。

hotspot算法细节

根节点枚举

迄今为止,所有收集器在根节点枚举这一步骤时都必须暂停用户线程。
即使可达性分析算法中耗时最长的查找引用链的过程已经能和用户线程并发执行。
根节点枚举时必须在一个能保障一致性的快照中进行。
由于当前主流java虚拟机使用的都是准确式垃圾收集,所以在用户线程停顿时,不需要一个不漏的检查完所有执行上下文和全局变量的引用位置。虚拟机中应当有办法直接得到哪些地方存着对象的引用的。
Hotspot中使用 一组 称作 oopmap的数据结构,一旦类加载完成是,hotspot就会把对象内的什么偏移量上是什么类型的数据计算出来。
在即时编译过程中,也会在特定位置记录下栈中、寄存机里哪些位置是引用。故而垃圾收集器扫描时就可以直接得知这些信息了,不需要一个不漏的存方法区等gc roots开始查找了。

安全点

上述过程中 引入 oopmap 完成 gc roots的枚举,那oopmap中存储的是什么呢?
hotspot中没有为每一条指令生成oopmap, 上述提到的特定位置记录了这些信息,这些位置被称为 安全点。
也即 用户程序执行时并非在代码指令流的任意位置都能停顿下来执行垃圾收集,而是强制要求必须执行到 安全点之后才能暂停。
安全点的选择基本是 以 是否具有让程序长时间执行的特征为标准选定的。通常需要指令长时间执行,其最明显的特征就是指令序列的复用,比如方法调用、循环跳转、异常跳转等都属于指令序列复用。所以只有具有这些功能的指令才回产生安全点。

对于安全点而言,需要考虑如何在垃圾收集发生时让所有线程都跑到最近的安全点,然后停下来。(不包括执行jni调用的线程)
可供选择的有两种:

  1. 抢先式中断:不需要线程的执行代码主动配合,在垃圾收集时,系统先把所有线程全部中断,如果发现有用户线程中断的地方不在安全点上,就恢复这条线程执行,过一会再中断,直到它跑到安全点上
  2. 主动式中断:当垃圾收集需要中断线程时,不直接对线程操作,仅仅简单的设置一个标志位,各个线程执行过程中不断主动轮训该标志,一旦发现中断标志为真就自己在最近的安全点上主动中断挂起。

hotspot中使用内存保护陷阱的方式,把轮训操作精简至一条汇编指令。

安全区域

似乎安全点的设计已经完美解决如何停顿用户线程,让虚拟机进入垃圾回收状态了。
但是加入程序不执行呢? 比如用户线程 sleep,此时线程无法响应虚拟机的中断请求,不能走到安全的地方挂起自己。此时虚拟机不可能等待线程被重新被激活分配时间处理器,此时必须引入 安全区域 来解决。
安全区域能够保证在一段代码片段内,引用关系不会发生变化。

当用户线程执行到安全区域内的代码时,首先会标记自己已经进入安全区域,此时如果虚拟机发起垃圾收集就不需要处理这些已经声明自己在安全区域的线程了。当线程离开安全区域时,它要检查虚拟机是否已经完成了根枚举。如果完成了,则线程就当做无事发生;否则必须一直等待,知道收到可以离开安全区域信号为止。

记忆集和卡表

记忆集是一种用于记录从非收集区域指向收集区域的指针集合的抽象数据结构,用以避免将整个老年代加入到GC Roots扫描范围。
记忆集的数据结构需要考虑存储和维护成本,有如下几种选择:

  1. 字长精度: 每个记录精确到一个字长。
  2. 对象精度: 每个记录精确到一个对象,该对象中有字段含有跨代指针。
  3. 卡精度: 每个记录精确到一块内存区域,该区域内有对象含有跨代指针。
    卡精度作为最常用的记忆集实现方式,使用card table 卡表的方式实现记忆集。
    hotspot虚拟机中使用字节数组,字节数据中的每一元素都标识其内存区域中一块特定大小的内存块(卡页,card page)。hotspot中是 512字节。

一个卡页的内存中通常包含不止一个对象,只要卡页中有一个对象的字段包含跨代指针,那么就将对应卡表的数组元素值标记为1,称此元素变脏了。
在垃圾收集时,只要筛选出变脏的元素,就能得出哪些卡页内存块中包含跨代指针,把它们加入到GC Roots中一并扫描。

写屏障

写屏障用以解决卡表元素的维护问题。

卡表元素变脏: 有其他分代区域中的对象引用了本区域对象时,对应的卡表元素就会变脏。
如何变脏:如果是解释处理的字节码,虚拟机负责每条字节码的执行,能介入处理;如果是编译执行则不然,经过即时编译后的代码已经是纯粹的机器指令流了,故而必须在机器码层面,把维护卡表的动作放在每一个赋值操作中。

hotspot虚拟机中使用 写屏障来维护卡表状态。可以将其视作在虚拟机层面对 引用类型字段复制动作的 AOP切面,在引用对象赋值时产生一个环形通知,供程序执行额外的动作,赋值前后都在写屏障的范畴内。赋值前的部分称作写前屏障,赋值后的部分称作写后屏障

除了写屏障的开销外,卡表咋高并发场景下还面临伪共享的问题。
缓存系统中是以缓存行为单位存储的,当多个线程修改相互独立的变量时,如果恰好这些变量在同一个缓存行中,就会影响彼此(写会、无效化、同步等),从而导致性能降低。

jdk7之后,hotspot虚拟机增加了参数-XX:+UserCondCardMark,用以绝地帮是否开启卡表更新的条件判断,开启后会先检查卡表标记,只有当该卡表元素未被标记才将其标记为变脏。

可达性的并发分析

想解决或降低用户线程的停顿,需要在一个能保障一致性的快照上才能进行对象图遍历。

三色标记法描述此问题:

  • 白色: 标识对象未被垃圾收集器访问过。如果扫描结束后,仍是白色,则表明白色不可达
  • 黑色: 标识对象已经被垃圾收集器访问过,且这个对象的所有引用都已经扫描过。黑色对象标识已经扫描过,它是安全存活的。如果有其他对象引用执行了黑色对象,无需重新扫描以便。
  • 灰色:标识对象已经被垃圾收集器访问过,但该对象上至少存在一个引用还没有被扫描过。

用户线程和垃圾收集器并发执行时,会存在用户线程修改引用关系,可能会导致如下后果:

  1. 把原本消亡的对象错误标记为存活
  2. 把原本存活的对象误标记为消亡,这会导致严重后果。
    wilson于1994年证明,当且仅当如下两个条件成立时,会产生对象消失的问题:
  • 赋值器插入了一条或者多条从黑色对象到白色对象的新引用;
  • 赋值器删除了全部从灰色对象到该白色对象的直接引用和间接引用;

要解决并发扫描的对象消失问题,破坏如上两个条件中的一个即可。

  • 增量更新:破坏第一个条件 当黑色对象插入新的白色对象的引用关系时,就将这个新插入的引用记录下来,等并发扫描结束后,再将这些记录过的引用关系中的黑色对象为根,重新扫描一次。(也即黑色对象变为灰色对象,此时可以再次扫描)
  • 原始快照:当灰色对象要删除指向白色对象的引用关系时,就将这个要删除的引用记录下来,在并发扫描结束后,再将这些记录过的引用关系中的灰色对象为根,重新扫描一次。

[md,好难懂]

引用

1. JVM系列-读懂 GC 日志
[2. 深入理解java虚拟机]
3. 深入理解堆外内存 Metaspace
4. G1大对象致Old区占用率高