JVM学习纪要
一、内存区域的划分
如果只是粗略地理解,大致可以分为堆和栈。但是这样的划分是不足以全面地理解JVM的。更详细的划分应该是:
- 堆
- 方法区
- 虚拟机栈
- 程序计数器
- 本地方法栈
程序计数器
程序计数器是一个不起眼但很重要的部分,字节码解释器会根据这个部分选取下一条需要执行的指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖程序计数器。
由于虚拟机的多线程本质上是时分复用CPU内核的时间片实现的,任何一个内核在指定时间点都只会执行一条线程指令,所以每条线程都必须有自己独立的程序计数器以保证线程获得时间片时处在正确的上下文中。
换言之,程序计数器线程私有
虚拟机栈
生命周期与其所在的线程完全相同。它描述了Java方法执行的内存模型:
- JVM创建一个栈帧(stack frame),存储着局部变量表,操作数栈,动态链接,方法出口等信息
- 每一个方法被调用至执行结束,就是一个栈帧在虚拟机栈入栈->出栈的过程。
虚拟机栈显然是线程私有的,否则不同参数进入同一个方法必然内存污染。
局部变量表
存放编译期间可知的各种Java虚拟机基本数据类型,对象引用,和returnAddress。
这些数据类型在虚拟机用局部变量槽(slot)实现,64位机long和double需要两个slot,其余数据类型1个。
因此,局部变量表需要的内存空间在编译期间就决定了
不同的虚拟机给不同的槽分配多大的物理内存是不同的,但槽位相同。
本地方法栈
不重要,可以理解为调外部程序运行,实际用的不太多
Java堆
GC的主要目标。几乎所有的对象实例都在这里分配内存。但随着栈上分配技术的进步发展,对象也不一定都在堆里,也可能经由标量替换后直接放在栈里。
java堆可以物理不连续,但必须逻辑连续。它的大小可以固定,也可动态扩展。当前主流的虚拟机都是动态扩展的。
Java堆是线程公有的,因为实际上这个部分可以理解为公共仓库,存储各个线程的不能在编译期间分配空间的实例。
方法区
也是线程共享的。用于存储已经被虚拟机加载的类型信息,常量,静态变量,即时编译后的代码缓存等数据。
运行时常量池也在其中。
二、对象创建
过程
- 虚拟机遇到字节码指令new时,先检查能否在常量池找到这个类的符号引用,并检查是否被加载、解析、初始化。如果没有则先执行类加载。
- 虚拟机将为即将创建的实例分配内存。类加载完成后,所需的内存大小即完全确定。为其分配内存实际上就是从堆中划出一块指定大小的空间给新实例。
- 分配新实例的过程可能是指针碰撞(形象的理解为实例一个接着一个,完全杜绝散碎内存),也可能是空闲列表(由虚拟机挑一块足够大的内存给这个对象)。究竟是哪一种,要看具体的虚拟机实现。
- 内存修改的安全问题(A线程申请到了内存区x1,还没锁定,B也申请了这里),有两种方案:一,CAS+失败重试;二,本地线程分配缓冲(TLAB)。
- 将对象头以外的内存空间初始化为0,设置其所属的类,类的元数据信息,哈希码(可能是懒加载,hashCode()被调用时才计算),GC年龄,是否启用偏向锁等。这些信息放在对象头中。
- init(),根据构造器中的各种代码真正把程序员指定的实例构造出来。
内存布局
对象在堆内存中的布局:
- 对象头
- 实例数据
- 对齐填充
对象头
存储对象自身的运行时数据。包括哈希码,gc分代,锁状态,线程持有的锁,偏向线程id,偏向时间戳等,这部分数据在32、64位机中长度不同,分别为32bit和64bit,官方称之为mark word。
对象头除了markword还有一个部分是类型指针,指向实例所属的类。如果实例是数组,还有一块记录数组长度。
实例数据
真正存储信息的地方。
对齐填充
补齐实例长度,不重要
对象访问
两种:
- 句柄
- 指针
句柄好处是句柄地址稳定,无论实例怎么移动,引用只需要找到句柄,不需要关心实例的真正位置。但代价是额外的内存开销和定位搜索时间开销;指针的好处是速度快,直达,但一旦实例移动,指针就要变化,需要额外的操作维护指针状态。
三、垃圾与垃圾回收
引用
- 强引用,最常见的引用赋值:A a = new A();
- 软引用,还有用但非必须的对象
- 弱引用,只能生存到下一次GC前
- 虚引用,其实根本不指向实例,只是为了在这块内存区被清理时收到虚拟机的通知
这些引用本质上都是类,可以向泛型一样使用
对象的死亡判定
引用计数
由于不能解决循环引用的问题,实际上没啥用
引用可达
整体思想:从某些GC ROOT开始遍历堆内存实例,没走到的实例、实例组就被判断为垃圾。
可以作为GC ROOT的对象:
- 虚拟机栈中引用的对象,譬如各个线程被调用的方法堆栈中使用到的参数、局部变量、临时变量
- 方法区中类静态属性引用的对象
- 方法区中常量引用的对象,如字符串常量池
- 本地方法栈中JNI引用的对象
- JVM内部的引用,如基本数据类型对应的Class对象,一些常驻的异常对象,还有系统类加载器
- 所有被同步锁持有的对象
- 反映JVM内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等
引用可达法的本质,就是图论的遍历算法,从上述节点开始,遍历这个图结构,没被走到的地方,就是垃圾
死亡与清理
一个对象在可达性分析后被判为不可达,也并非立即就要被回收。
引用可达性分析是第一次标记,只有在第二次标记后,基本就确定要回收了。
第二次标记:
第一次标记后会进行一次筛选,条件是这个对象是否要执行finalize方法。假如对象没有覆盖finalize方法或这个方法已经被虚拟机调用过,则判断为“无需执行”。如果有必要执行,则这个对象会被放在F-Queue中,排队被虚拟机的Finalizer执行销毁。
需要注意,虚拟机并不承诺这个执行一定会完成,因为如果某个对象的执行卡住了,可能会导致阻塞,使得整个垃圾回收系统崩溃。
fianlize()方法是对象拯救自己的最后一次机会,只要对象可以在这里重新挂靠到某个引用链,则可以逃脱被回收的命运。
如果不能,则会被二次标记。
finalize只会执行一次,即使类在这里有拯救自己的操作,但也只有这一次机会。
方法区的回收
实际上方法区的回收不是每一款虚拟机都实现了的,JVM规范声明方法区的回收“非必须”,JDK11中的ZGC就不支持类卸载。
回收方法区主要是回收常量和类。
回收常量比较简单,如果没有任何一个字符串对象的值和待判断的值相同,且虚拟机任何其他地方也没有引用这个字面量,则这个常量就可以回收。
类的回收则比较复杂。
类的回收
类的回收需要满足:
- 所有实例和子类实例都被回收
- 类加载器已经被回收(极难达成)
- 该类的的Class对象没有被引用,无法通过反射访问这个类
即使满足这三个很难达成的条件,类也只是“允许被回收”,而不是像实例一样没有引用一定回收。
垃圾收集器理论与实现
理论
- 弱分代假说:大多数对象都是迅速消亡的
- 强分代假说:熬过越多次垃圾收集的对象越难以消亡
- 跨代引用假说:跨代引用对于同代引用仅占极少数
这两个假说奠定了垃圾收集器的一致设计原则:
应当对Java堆分代,将不同年龄(熬过垃圾收集的次数)的对象放入不同的时间代,进行不同的处理。
新生代收集:youngGC/minorGC
老年代收集:oldGC/majorGC
整堆收集:fullGC
G1收集器特有的:mixedGC
三种实现思路
- 标记清除
- 标记复制
- 标记整理
标记清除法:
标记所有需要回收的对象,在标记完成后,统一回收所有被标记的对象。
缺陷:
- 执行效率不稳定,随着堆上实例越来越多,需要标记和清理的对象也越来越多,执行效率会迅速下降;
- 清理后的空间会碎片化,堆上会形成众多的内存碎片,这会导致系统需要分配大对象时找不到合适的空间,进而提前触发新的GC
标记复制:
第一阶段:
半区复制。首先把内存分为两块;其次每次为对象分配空间时只使用其中的一块;再次当这一块内存满时,直接把还存活的对象按顺序复制到另一半。
这样一来,计算效率大大提高,且不再有内存碎片问题
第二阶段:
分代复制。由于能够快速收集的对象比例非常高,甚至达到98%,所以完全没有必要给一半内存这么大的空间。Hotspot的虚拟机策略是按照8:1:1来设计年轻代和老年代。在初始分配对象时只使用年轻代和第一个老年代。发生垃圾收集时,直接把存活的放到第二个老年代。
缺陷:
空间浪费太多
标记整理
第一步:标记
第二步:把所有存活的对象集中起来(移动存活的对象)
实现
根节点枚举
从GC ROOT开始排查所有的堆上实例固然可以精确地找出垃圾,但问题是这样耗费大量的计算资源。根节点枚举必然需要一个相对稳态的环境来分析所有的堆内存对象,这就会导致一个非常致命的问题:Stop The World,世界停顿。
如果代码的性能又没有优化到一定的水平,这个时停就会频繁且漫长——基本等于服务器挂了。
这种情况下,OOPMAP出现了。
关于oopmap,可以参考这些内容:
oopmap详解
这里要先介绍两个概念:
- 保守GC:从一些已知位置开始扫描内存,扫描到一个数字就大致判断它是不是可能指向GC堆中的一个指针。这种大致判断可能是通过对齐检查的方式进行的,也可能是其它什么方式,总之不是精确地判断出一个指针。这种方式很快,但可能误诊,也不能改动指针(不知道是不是真的是指针)
- 准确GC:何为准确式GC?就是我们准确的知道,某个位置上面是否是指针,对于java来说,就是知道对于某个位置上的数据是什么类型的,这样就可以判断出所有的位置上的数据是不是指向GC堆的引用,包括栈和寄存器里的数据。在java中实现的方式是:从外部记录下类型信息,存成映射表,在HotSpot中把这种映射表称之为OopMap,不同的虚拟机名称可能不一样。实现这种功能,需要虚拟机的解释器和JIT编译器支持,由他们来生成OopMap。生成这样的映射表一般有两种方式:
A. 每次都遍历原始的映射表,循环的一个个偏移量扫描过去;这种用法也叫“解释式”;
B. 为每个映射表生成一块定制的扫描代码(想像扫描映射表的循环被展开的样子),以后每次要用映射表就直接执行生成的扫描代码;这种用法也叫“编译式”。
总而言之,GC开始的时候,就通过OopMap这样的一个映射表知道,在对象内的什么偏移量上是什么类型的数据,而且特定的位置记录下栈和寄存器中哪些位置是引用。
安全点
在方法执行的过程中, 可能会导致引用关系发生变化,那么保存的OopMap就要随着变化。如果每次引用关系发生了变化都要去修改OopMap的话,这又是一件成本很高的事情。所以这里就引入了安全点的概念。
- 什么是安全点?:OopMap的作用是为了在GC的时候,快速进行可达性分析,所以OopMap并不需要一发生改变就去更新这个映射表。只要这个更新在GC发生之前就可以了。所以OopMap只需要在预先选定的一些位置上记录变化的OopMap就行了。这些特定的点就是SafePoint(安全点)。由此也可以知道,程序并不是在所有的位置上都可以进行GC的,只有在达到这样的安全点才能暂停下来进行GC。
- 安全点太多或者太少都不好;太多会浪费性能,太少会让GC长时间等待。如何选择安全点?
- 0循环的末尾;1方法返回前、调用方法的call指令后;2可能抛异常的位置
- 如何让程序在安全点前停下?:主动式中断,在GC的时候,不会主动去中断线程,仅仅是设置一个标志,当程序运行到安全点时就去轮询该位置,发现该位置被设置为真时就自己中断挂起。所以轮训标志的地方是和安全点重合的,另外创建对象需要分配内存的地方也需要轮询该位置。
安全区
确保在某一段代码中引用关系不会发生变化,因此在这个区域中任意地点收集垃圾都是安全的。用户线程执行到安全区域中的代码时,会标识自己进入了安全区域。如果此时虚拟机要发起垃圾收集,就不用去管那些已经声明自己在安全区域内的线程。当线程要离开时,会检查虚拟机是否正在进行某些需要线程停顿的垃圾收集操作,如果没有则无事发生继续执行,如果有则必须等着。
记忆集和卡表
本质上是为了解决新生代中某个对象存在老年代的对象的引用,进而使得年轻代对象无法回收的问题。
记忆集
记忆集是一种用于记录从非收集区域指向收集区域的指针集合的抽象数据结构。如果我们不考虑 效率和成本的话,最简单的实现可以用非收集区域中所有含跨代引用的对象数组来实现这个数据结构。
Class RememberedSet {
Object[] set[OBJECT_INTERGENERATIONAL_REFERENCE_SIZE];
}
这种记录全部含跨代引用对象的实现方案,无论是空间占用还是维护成本都相当高昂。而在垃圾收集的场景中,收集器只需要通过记忆集判断出某一块非收集区域是否存在有指向了收集区域的指针就可以了,并不需要了解这些跨代指针的全部细节。那设计者在实现记忆集的时候,便可以选择更为粗犷的记录粒度来节省记忆集的存储和维护成本,下面列举了一些可供选择(当然也可以选择这个范围以外的)的记录精度:
- 字长精度:精确到一个机器字长,该字包含跨代指针
- 对象精度:每个记录精确到一个对象,该对象里有字段含有跨代指针
- 卡精度:每个记录精确到一块内存区域,该区域内有对象含有跨代指针
第三种“卡精度”所指的是用一种称为“卡表”(Card Table)的方式去实现记忆集,这也是目前最常用的一种记忆集实现形式。
卡表
卡表就是记忆集的一种具体实现,它定义了记忆集的记录精度、与堆内存的映射关系等。 关于卡表与记忆集的关系,不妨按照Java语言中HashMap与Map的关系来类比理解。
卡表最简单的形式可以只是一个字节数组,而HotSpot虚拟机确实也是这样做的。以下这行代码是HotSpot默认的卡表标记逻辑:
CARD_TABLE [this address >> 9] = 0;
字节数组CARD_TABLE的每一个元素都对应着其标识的内存区域中一块特定大小的内存块,这个内存块被称作“卡页”(Card Page)。一般来说,卡页大小都是以2的N次幂的字节数,通过上面代码可以看出HotSpot中使用的卡页是2的9次幂,即512字节(地址右移9位,相当于用地址除以512)。那如果卡表标识内存区域的起始地址是0x0000的话,数组CARD_TABLE的第0、1、2号元素,分别对应了地址范围为0x0000~0x01FF、0x0200~0x03FF、0x0400~0x05FF的卡页内存块
分析:卡表就是一个地址表,每一个地址都是一个内存区域;如果某一个区域发生了跨代引用,则标记为1,称为dirty,在GC的时候就可以将之加入GC遍历,否则直接略过即可。
那么,如何判断一个内存区域是否dirty,又如何改写响应的状态呢?
写屏障
就是给对象的引用实际赋值这个动作的AOP,本质上实现了在给对象赋值时进行卡表状态的维护
并发的可达性分析
由于堆内存和方法区(及其中的常量池)是线程共享的,那势必在GC的时候面临并发问题。在此处称为“对象消失问题”
以三色标记来推导:
黑球:已经被扫过
灰球:正在被扫(有的引用被扫过,有的引用没扫过)
白球:待扫
第一种情况:
先扫描了黑球1号,然后扫描黑球2号时,黑球2和白球1的引用关系被另一个线程切断了;此时黑球1又和白球1产生了关联,按理说白球1不该被回收,但此时扫描不可能回头再来一次,白球1号就可怜的消失了
第二种情况:
开始扫描时,某白球和黑球的关联被切断了;当扫描经过后,这种关联又被另一个线程加上了,但扫描仍旧不可重来,就会出现多个消失的对象。
理论总结:
- 赋值器插入了一条或多条从黑球到白球的引用
- 赋值器删除了从某个灰球(正在扫描的球)到某个白球的全部引用(直接或者间接)
针对这两个问题产生了两种解决措施:
增量更新:黑球如果发生了新增的引用关系,就重新变灰,待一轮扫描结束,从扫过但不是黑球的球开始重扫;
原始快照:当扫描开始后发生任何引用删除时,记录一个快照,这一次扫描,只按照这个快照走
目前常见的垃圾收集器
serial
最基础、历史最悠久。
这是一个单线程工作的收集器,最大的缺陷在于:一定会导致或长或短时间的stop the world.
优点:简单高效,仍旧是hotspot虚拟机的默认垃圾收集器。
parnew
serial的并行版本,其他性能与serial完全一致。
parnew可以和另外一款收集器——CMS同时工作,真正实现用户线程和收集线程并发运行。
G1
但随着CMS的替代产品G1出炉后,parnew合并入CMS,推出了历史舞台。
G1是面向全堆的垃圾收集器,根本不需要和其它收集器协同工作,所以以前parnew-CMS的模式也已完全消失。
parallel scavenge
这款收集器非常特别,它关注的是降低:
运行业务代码的时间/(运行业务代码的时间 + 垃圾收集的时间)
serial old / parallel old
分别是对应收集器的老年代版本。
CMS详述
Concurrent Mark sweep收集器是一款专注“最短回收停顿时间”的收集器。
四个阶段:
- 初始标记(停顿)
- 并发标记
- 重新标记(停顿)
- 并发清除
初始标记的时停很短,因为它只是标记一下GC Root能直接关联到的对象;
并发标记耗时很长,这个阶段是真正的遍历图结构,所以需要很长时间,但它可以和用户线程并发运行,所以没有停顿,这里使用了原始快照;
重新标记对应增量更新,在扫描中如果出现了新的引用关系,则需要从新的被引用的内存开始重新扫描;
并发清除,真正地回收掉内存。这一步也很慢,但它也可以和用户线程同步运行,所以也不需要停顿。
CMS是一款非常优秀的收集器,官方描述是:并发低停顿收集器。这是Hotspot第一次成功的完成低停顿收集器的目标。
但受制于时代局限,他还不算完美,至少有以下缺点:
- 对处理器资源敏感,由于需要一定的并发资源,所以会导致在某些场合减缓用户业务线程的运行。如果用户机处理器数量不够多(四个以下),会对应用程序造成巨大的负担,回收线程甚至可能占用25%以上的资源;
- 无法处理浮动垃圾。所谓浮动垃圾,是指在本次垃圾收集的过程中产生的垃圾。由于本次收集只针对开始时的快照,所以开始后产生的垃圾一概不能处理,只能留待下次垃圾收集。这就导致CMS不敢像其它收集器那样等着老年代几乎耗尽再开始收集,它必须预留一部分空间给用户线程。这进一步收窄了可用内存空间,事实上提高了垃圾回收的频率。如果预留的这部分空间不足够大,还会导致“并发失败”错误,触发一次full gc,这就会耗费更多的资源;
- 它基于标记清除法实现,就必然有标记清除法的固有弊端:内存碎片化,总会在某一时刻,需要移动内存来保证内存空间是整齐的。
所以CMS也终将被淘汰。
G1详述
oracle花费了数年时间打造了这一款面向全堆的垃圾收集器。官方称之为:“全功能垃圾收集器”。开发团队的期望是用g1完全代替CMS。
g1的目标是:在指定的M毫秒内,垃圾收集的时长大概率不超过N秒。也就是“停顿时间模型”。
在这个目标下,g1的整体设计思路发生了根本转变。在他之前,所有收集器的目标要么是新生代收集(minor gc),要么是老年代收集(major gc),要么是全堆收集(full gc)。
而g1的目标是,对任意一个内存区域,如果它的回收收益高,就收集,几乎完全剥离了分代理论。
具体实现
- 将内存区域划分为数个region,大小范围1-32mb之间2的N次幂。
- 每个region根据需要在不同时刻成为eden或survivor或old。
- 这种思路有效避免了整个java堆full gc,它维护了一张价值(回收获得的空间和回收所需的时间的综合评价)表,用于记录每个区域回收所能得到的收益,每次回收,优先回收价值更大的部分
仍待优化的问题
- 划分多个region后需要更多的资源来维护更为复杂的双向卡表结构
- 并发情况下,仍有可能出现CMS中回收速度赶不上收集速度而导致的full gc
- 每个步骤都有时停:初始标记,并发标记,最终标记,筛选回收
实验期垃圾收集器
shenandoah收集器
只有openjdk有,oracle商业版不提供这个功能。它有点像升级的G1。由于连源码都被oracle排除在外,这里暂且不讨论。
ZGC
这是oracle主推的正统低延迟垃圾收集器。目前仍有一定的实验性质,它可以做到:无论堆内存大小,
只要堆内存可管理,就可以保证毫秒级垃圾收集速度。
ZGC也存在region,但是它的region是动态的——动态创建,动态销毁。ZGC还为region提供了预分类,将其分为大中小三种。小型固定2mb,中型固定32mb,大型不固定,随需求变化,但必须是2mb的整数倍。
染色指针
在分配对象的时候,无论jvm做了什么封装,归根究底对对象的引用还是指针。染色指针技术,就是把相关的标记信息直接附加在指针上,所以称为染色指针。
在AMD64架构中实际上只支持52位地址总线和48位虚拟地址空间,操作系统侧还会增加自己的约束,64位的linux系统实际上只支持46位(64TB)的物理地址空间。
由于目前商业需求还达不到64TB这么大,染色指针技术就从46位里借了4位高位来进行“染色”。
染色信息包括:
- 三色信息(堆中的实例已被遍历,正在被遍历,还未遍历到)
- 是否进入重分配集
- 是否只能通过finalize方法访问等
由于受到剩余位置宽度的限制,ZGC虽说面向仍以大小的可管理内存,实际上还是有限制的,就是4TB。
染色指针的三大优势:
- 一旦某个区域的存活对象被移走,这个区域就能立即被回收,不需要和其他区域中的跨域指针协同
- 极大减少了内存屏障的使用。由于引用的变动信息直接附着在指针上,就不需要写屏障来维护引用,这节省了很大一笔资源开销。到目前为止,染色指针没有使用任何写屏障,只有读屏障。
- 还存在一定的扩展性。目前还有可能开发的宽度是18位,如果以后能把相关区域开发利用上,ZGC能控制管理的内存大小就会变成64TB
染色指针四个阶段
- 并发标记
- 并发预备重分配
- 并发重分配
- 并发重映射
(未完待续)