JVM 就是 Java Virtual Machine(Java虚拟机)的缩写,JVM 屏蔽了与具体操作系统平台相关的信息,使 Java 程序只需生成在 Java 虚拟机上运行的目标代码 (字节码),就可以在不同的平台上运行。
JVM 在执行 Java 程序的过程中会把它管理的内存分为若干个不同的区域,这些组成部分有些是线程私有的,有些则是线程共享的,Java 内存区域也叫做运行时数据区,它的具体划分如下:
虚拟机栈 Java 虚拟机栈是线程私有的数据区,Java 虚拟机栈的生命周期与线程相同,虚拟机栈也是局部变量的存储位置。方法在执行过程中,会在虚拟机栈中创建一个 栈帧(stack frame)。每个方法执行的过程就对应了一个入栈和出栈的过程。 本地方法栈
本地方法栈也是线程私有的数据区,本地方法栈存储的区域主要是 Java 中使用 native 关键字修饰的方法所存储的区域。
程序计数器
程序计数器也是线程私有的数据区,这部分区域用于存储线程的指令地址,用于判断线程的分支、循环、跳转、异常、线程切换和恢复等功能,这些都通过程序计数器来完成。
方法区
方法区是各个线程共享的内存区域,它用于存储虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。
堆
堆是线程共享的数据区,堆是 JVM 中最大的一块存储区域,所有的对象实例都会分配在堆上。JDK 1.7后,字符串常量池从永久代中剥离出来,存放在堆中。
堆空间的内存分配(默认情况下)
命令行上执行如下命令,会查看默认的 JVM 参数。
java -XX:+PrintFlagsFinal -version
输出的内容非常多,但是只有两行能够反映出上面的内存分配结果。
运行时常量池
运行时常量池又被称为 Runtime Constant Pool,这块区域是方法区的一部分,它的名字非常有意思,通常被称为 非堆。它并不要求常量一定只有在编译期才能产生,也就是并非编译期间将常量放在常量池中,运行期间也可以将新的常量放入常量池中,String 的 intern 方法就是一个典型的例子。
Java 虚拟机负责把描述类的数据从 class 文件加载到系统内存中,并对类的数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的 Java 类型,这个过程被称之为 Java 的类加载机制。
一个类从被加载到虚拟机内存开始,到卸载出内存为止,一共会经历下面这些过程。
类加载机制一共有五个步骤,分别是加载、链接、初始化、使用和卸载阶段,这五个阶段的顺序是确定的。
其中链接阶段会细分成三个阶段,分别是验证、准备、解析阶段,这三个阶段的顺序是不确定的,这三个阶段通常交互进行。解析阶段通常会在初始化之后再开始,这是为了支持 Java 语言的运行时绑定特性(也被称为动态绑定)。
下面我们就来聊一下这几个过程。
关于什么时候开始加载这个过程,《Java 虚拟机规范》并没有强制约束,所以这一点我们可以自由实现。加载是整个类加载过程的第一个阶段,在这个阶段,Java 虚拟机需要完成三件事情:
《Java 虚拟机规范》并未规定全限定名是如何获取的,所以现在业界有很多获取全限定名的方式:
加载阶段既可以使用虚拟机内置的引导类加载器来完成,也可以使用用户自定义的类加载器来完成。程序员可以通过自己定义类加载器来控制字节流的访问方式。
数组的加载不需要通过类加载器来创建,它是直接在内存中分配,但是数组的元素类型(数组去掉所有维度的类型)最终还是要靠类加载器来完成加载。
加载过后的下一个阶段就是验证,因为我们上一步讲到在内存中生成了一个 class 对象,这个对象是访问其代表数据结构的入口,所以这一步验证的工作就是确保 class 文件的字节流中的内容符合《Java 虚拟机规范》中的要求,保证这些信息被当作代码运行后,它不会威胁到虚拟机的安全。
验证阶段主要分为四个阶段的检验:
文件格式验证
这一阶段可能会包含下面这些验证点:
实际上验证点远远不止有这些,上面这些只是从 HotSpot 源码中摘抄的一小段内容。
元数据验证
这一阶段主要是对字节码描述的信息进行语义分析,以确保描述的信息符合《Java 语言规范》,验证点包括:
需要记住这一阶段只是对《Java 语言规范》的验证。
字节码验证
字节码验证阶段是最复杂的一个阶段,这个阶段主要是确定程序语意是否合法、是否是符合逻辑的。这个阶段主要是对类的方法体(class 文件中的 Code 属性)进行校验分析。
这部分验证包括:
如果没有通过字节码验证,就说明验证出问题。但是不一定通过了字节码验证,就能保证程序是安全的。
符号引用验证
最后一个阶段的校验行为发生在虚拟机将符号引用转换为直接引用的时候,这个转化将在连接的第三个阶段,即解析阶段中发生。符号引用验证可以看作是对类自身以外的各类信息进行匹配性校验。
这个验证主要包括:
这一阶段主要是确保解析行为能否正常执行,如果无法通过符号引用验证,就会出现类似 IllegalAccessError、NoSuchFieldError、NoSuchMethodError 等错误。
验证阶段对于虚拟机来说非常重要,如果能通过验证,就说明你的程序在运行时不会产生任何影响。
准备阶段是为类中的变量分配内存并设置其初始值的阶段,这些变量所使用的内存都应当在方法区中进行分配,在 JDK 7 之前,HotSpot 使用永久代来实现方法区,是符合这种逻辑概念的。而在 JDK 8 之后,变量则会随着 class 对象一起存放在 Java 堆中。
下面通常情况下的基本类型和引用类型的初始值:
除了“通常情况”下,还有一些“例外情况”。如果类字段属性中存在 ConstantValue 属性,那就这个变量值在初始阶段就会初始化为 ConstantValue 属性所指定的初始值,比如:
public static final int value = "666";
编译时就会把 value 的值设置为 666。
解析阶段是 Java 虚拟机将常量池内的符号引用替换为直接引用的过程。
这样说你可能还有点不明白,我再换一种说法:
在编译的时候一个每个 Java 类都会被编译成一个 class 文件,但在编译的时候虚拟机并不知道所引用类的地址,所以就用符号引用来代替,而在这个解析阶段就是为了把这个符号引用转化成为真正的地址的阶段。
《Java 虚拟机规范》并未规定解析阶段发生的时间,只要求了在 anewarray、checkcast、getfield、getstatic、instanceof、invokedynamic、invokeinterface、invokespecial、invokestatic、invokevirtual、ldc、ldc_w、ldc2_w、multianewarray、new、putfield 和 putstatic 这 17 个用于操作符号引用的字节码指令之前,先对所使用的符号引用进行解析。
解析也分为四个步骤:
初始化是类加载过程的最后一个步骤,在之前的阶段中,都是由 Java 虚拟机占主导作用,但是到了这一步,却把主动权移交给应用程序。
对于初始化阶段,《Java 虚拟机规范》严格规定了只有下面这六种情况下才会触发类的初始化。
其实上面只有前四个大家需要知道就好了,后面两个比较冷门。
如果说要回答类加载的话,其实聊到这里已经可以了,但是为了完整性,我们索性把后面两个过程也来聊一聊。
这个阶段没什么可说的,就是初始化之后的代码由 JVM 来动态调用执行。
当代表一个类的 Class 对象不再被引用,那么 Class 对象的生命周期就结束了,对应的在方法区中的数据也会被卸载。
⚠️但是需要注意一点:JVM 自带的类加载器装载的类,是不会卸载的,由用户自定义的类加载器加载的类是可以卸载的。
如果要回答对象是怎么创建的,我们一般想到的回答是直接 new 出来就行了,这个回答不仅局限于编程中,也融入在我们生活中的方方面面。
但是遇到面试的时候你只回答一个“new 出来就行了”显然是不行的,因为面试更趋向于让你解释当程序执行到 new 这条指令时,它的背后发生了什么。
所以你需要从 JVM 的角度来解释这件事情。
当虚拟机遇到一个 new 指令时(其实就是字节码),首先会去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用所代表的类是否已经被加载、解析和初始化。
因为此时很可能不知道具体的类是什么,所以这里使用的是符号引用。
如果发现这个类没有经过上面类加载的过程,那么就执行相应的类加载过程。
类检查完成后,接下来虚拟机将会为新生对象分配内存,对象所需的大小在类加载完成后便可确定(我会在下面的面试题中介绍)。
分配内存相当于是把一块固定的内存块从堆中划分出来。划分出来之后,虚拟机会将分配到的内存空间都初始化为零值,如果使用了 TLAB(本地线程分配缓冲),这一项初始化工作可以提前在 TLAB 分配时进行。这一步操作保证了对象实例字段在 Java 代码中可以不赋值就能直接使用。
接下来,Java 虚拟机还会对对象进行必要的设置,比如确定对象是哪个类的实例、对象的 hashcode、对象的 GC 分代年龄信息。这些信息存放在对象的对象头(Object Header)中。
如果上面的工作都做完后,从虚拟机的角度来说,一个新的对象就创建完毕了;但是对于程序员来说,对象创建才刚刚开始,因为构造函数,即 Class 文件中的
在类加载完成后,虚拟机需要为新生对象分配内存,为对象分配内存相当于是把一块确定的区域从堆中划分出来,这就涉及到一个问题,要划分的堆区是否规整。
假设 Java 堆中内存是规整的,所有使用过的内存放在一边,未使用的内存放在一边,中间放着一个指针,这个指针为分界指示器。那么为新对象分配内存空间就相当于是把指针向空闲的空间挪动对象大小相等的距离,这种内存分配方式叫做指针碰撞(Bump The Pointer)。
如果 Java 堆中的内存并不是规整的,已经被使用的内存和未被使用的内存相互交错在一起,这种情况下就没有办法使用指针碰撞,这里就要使用另外一种记录内存使用的方式:空闲列表(Free List),空闲列表维护了一个列表,这个列表记录了哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录。
所以,上述两种分配方式选择哪个,取决于 Java 堆是否规整来决定。在一些垃圾收集器的实现中,Serial、ParNew 等带压缩整理过程的收集器,使用的是指针碰撞;而使用 CMS 这种基于清除算法的收集器时,使用的是空闲列表,具体的垃圾收集器我们后面会聊到。
在 Hotspot 虚拟机中,对象在内存中的布局分为三块区域:
这三块区域的内存分布如下图所示:
我们来详细介绍一下上面对象中的内容。
对象头 Header 主要包含 MarkWord 和对象指针 Klass Pointer,如果是数组的话,还要包含数组的长度。
在 32 位的虚拟机中 MarkWord ,Klass Pointer 和数组长度分别占用 32 位,也就是 4 字节。
如果是 64 位虚拟机的话,MarkWord ,Klass Pointer 和数组长度分别占用 64 位,也就是 8 字节。
在 32 位虚拟机和 64 位虚拟机的 Mark Word 所占用的字节大小不一样,32 位虚拟机的 Mark Word 和 Klass Pointer 分别占用 32 bits 的字节,而 64 位虚拟机的 Mark Word 和 Klass Pointer 占用了64 bits 的字节,下面我们以 32 位虚拟机为例,来看一下其 Mark Word 的字节具体是如何分配的。
用中文翻译过来就是:
其中无锁和偏向锁的锁标志位都是 01,只是在前面的 1 bit 区分了这是无锁状态还是偏向锁状态。
关于为什么这么分配的内存,我们可以从 OpenJDK 中的markOop.hpp类中的枚举窥出端倪:
来解释一下:
在上面的虚拟机对象头分配表中,我们可以看到有几种锁的状态:无锁(无状态)**、偏向锁、轻量级锁、**重量级锁。其中轻量级锁和偏向锁是 JDK1.6 中对 synchronized 锁进行优化后新增加的,其目的就是为了大大优化锁的性能,所以在 JDK 1.6 中,使用 synchronized 的开销也没那么大了。其实从锁有无锁定来讲,还是只有无锁和重量级锁,偏向锁和轻量级锁的出现就是增加了锁的获取性能而已,并没有出现新的锁。
所以我们的重点放在对 synchronized 重量级锁的研究上。当 monitor 被某个线程持有后,它就会处于锁定状态。在 HotSpot 虚拟机中,monitor 的底层代码是由 ObjectMonitor 实现的,其主要数据结构如下(位于 HotSpot 虚拟机源码 ObjectMonitor.hpp 文件,C++ 实现的)
这段 C++ 中需要注意几个属性:_WaitSet 、 _EntryList 和 _Owner,每个等待获取锁的线程都会被封装称为 ObjectWaiter 对象。
_Owner 是指向了 ObjectMonitor 对象的线程,而 _WaitSet 和 _EntryList 就是用来保存每个线程的列表。
那么这两个列表有什么区别呢?这个问题我和你聊一下锁的获取流程你就清楚了。
锁的两个列表
当多个线程同时访问某段同步代码时,首先会进入 _EntryList 集合,当线程获取到对象的 monitor 之后,就会进入 _Owner 区域,并把 ObjectMonitor 对象的 _Owner 指向为当前线程,并使 _count + 1,如果调用了释放锁(比如 wait)的操作,就会释放当前持有的 monitor ,owner = null, _count - 1,同时这个线程会进入到 _WaitSet 列表中等待被唤醒。如果当前线程执行完毕后也会释放 monitor 锁,只不过此时不会进入 _WaitSet 列表了,而是直接复位 _count 的值。
Klass Pointer 表示的是类型指针,也就是对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。
你可能不是很理解指针是个什么概念,你可以简单理解为指针就是指向某个数据的地址。
实例数据部分是对象真正存储的有效信息,也是代码中定义的各个字段的字节大小,比如一个 byte 占 1 个字节,一个 int 占用 4 个字节。
对齐不是必须存在的,它只起到了占位符(%d, %c 等)的作用。这就是 JVM 的要求了,因为 HotSpot JVM 要求对象的起始地址必须是 8 字节的整数倍,也就是说对象的字节大小是 8 的整数倍,不够的需要使用 Padding 补全。
我们创建一个对象的目的当然就是为了使用它,但是,一个对象被创建出来之后,在 JVM 中是如何访问这个对象的呢?一般有两种方式:通过句柄访问和直接指针访问。
如果使用句柄访问方式的话,Java 堆中可能会划分出一块内存作为句柄池,引用(reference)中存储的是对象的句柄地址,而句柄中包含了对象的实例数据与类型数据各自具体的地址信息。如下图所示。
如果使用直接指针访问的话,Java 堆中对象的内存布局就会有所区别,栈区引用指示的是堆中的实例数据的地址,如果只是访问对象本身的话,就不会多一次直接访问的开销,而对象类型数据的指针是存在于方法区中,如果定位的话,需要多一次直接定位开销。
如下图所示:
这两种对象访问方式各有各的优势,使用句柄最大的好处就是引用中存储的是句柄地址,对象移动时只需改变句柄的地址就可以,而无需改变对象本身。
使用直接指针来访问速度更快,它节省了一次指针定位的时间开销,由于对象访问在 Java 中非常频繁,因为这类的开销也是值得优化的地方。
上面聊到了对象的两种数据,一种是对象的实例数据,这没什么好说的,就是对象实例字段的数据,一种是对象的类型数据,这个数据说的是对象的类型、父类、实现的接口和方法等。
我们大家知道,基本上所有的对象都在堆中分布,当我们不再使用对象的时候,垃圾收集器会对无用对象进行回收♻️,那么 JVM 是如何判断哪些对象已经是“无用对象”的呢?
这里有两种判断方式,首先我们先来说第一种:引用计数法。
引用计数法的判断标准是这样的:在对象中添加一个引用计数器,每当有一个地方引用它时,计数器的值就会加一;当引用失效时,计数器的值就会减一;只要任何时刻计数器为零的对象就是不会再被使用的对象。虽然这种判断方式非常简单粗暴,但是往往很有用,不过,在 Java 领域,主流的 Hotspot 虚拟机实现并没有采用这种方式,因为引用计数法不能解决对象之间的循环引用问题。
循环引用问题简单来讲就是两个对象之间互相依赖着对方,除此之外,再无其他引用,这样虚拟机无法判断引用是否为零从而进行垃圾回收操作。
还有一种判断对象无用的方法就是可达性分析算法。
当前主流的 JVM 都采用了可达性分析算法来进行判断,这个算法的基本思路就是通过一系列被称为 GC Roots 的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程走过的路径被称为引用链(Reference Chain),如果某个对象到 GC Roots 之间没有任何引用链相连接,或者说从 GC Roots 到这个对象不可达时,则证明此这个对象是无用对象,需要被垃圾回收。
这种引用方式如下:
如上图所示,从枚举根节点 GC Roots 开始进行遍历,object 1 、2、3、4 是存在引用关系的对象,而 object 5、6、7 之间虽然有关联,但是它们到 GC Roots 之间是不可达的,所以被认为是可以回收的对象。
在 Java 技术体系中,可以作为 GC Roots 进行检索的对象主要有:
虽然我们上面提到了两种判断对象回收的方法,但无论是引用计数法还是判断 GC Roots 都离不开引用这一层关系。
这里涉及到到强引用、软引用、弱引用、虚引用的引用关系。
判断一个类型属于“不再使用的类”需要满足下面这三个条件:
虚拟机允许对满足上面这三个条件的无用类进行回收操作。
一般商业的虚拟机,大多数都遵循了分代收集的设计思想,分代收集理论主要有两条假说。
就是基于这两个假说理论,JVM 将堆区划分为不同的区域,再将需要回收的对象根据其熬过垃圾回收的次数分配到不同的区域中存储。
JVM 根据这两条分代收集理论,把堆区划分为新生代(Young Generation)和老年代(Old Generation)这两个区域。在新生代中,每次垃圾收集时都发现有大批对象死去,剩下没有死去的对象会直接晋升到老年代中。
上面这两个假说没有考虑对象的引用关系,而事实情况是,对象之间会存在引用关系,基于此又诞生了第三个假说,即跨代引用假说(Intergeneration Reference Hypothesis),跨代引用相比较同代引用来说仅占少数。
正常来说存在相互引用的两个对象应该是同生共死的,不过也会存在特例,如果一个新生代对象跨代引用了一个老年代的对象,那么垃圾回收的时候就不会回收这个新生代对象,更不会回收老年代对象,然后这个新生代对象熬过一次垃圾回收进入到老年代中,这时候跨代引用才会消除。
根据跨代引用假说,我们不需要因为老年代中存在少量跨代引用就去直接扫描整个老年代,也不用在老年代中维护一个列表记录有哪些跨代引用,实际上,可以直接在新生代中维护一个记忆集(Remembered Set),由这个记忆集把老年代划分称为若干小块,标识出老年代的哪一块会存在跨代引用。
记忆集的图示如下:
从图中我们可以看到,记忆集中的每个元素分别对应内存中的一块连续区域是否有跨代引用对象,如果有,该区域会被标记为“脏的”(dirty),否则就是“干净的”(clean)。这样在垃圾回收时,只需要扫描记忆集就可以简单地确定跨代引用的位置,是个典型的空间换时间的思路。
在聊具体的垃圾回收算法之前,需要明确一点,哪些对象需要被垃圾收集器进行回收?也就是说需要先判断哪些对象是“垃圾”?
判断的标准我在上面如何判断对象已经死亡的问题中描述了,有两种方式,一种是引用计数法,这种判断标准就是给对象添加一个引用计数器,引用这个对象会使计数器的值 + 1,引用失效后,计数器的值就会 -1。但是这种技术无法解决对象之间的循环引用问题。
还有一种方式是 GC Roots,GC Roots 这种方式是以 Root 根节点为核心,逐步向下搜索每个对象的引用,搜索走过的路径被称为引用链,如果搜索过后这个对象不存在引用链,那么这个对象就是无用对象,可以被回收。GC Roots 可以解决循环引用问题,所以一般 JVM 都采用的是这种方式。
解决循环引用代码描述:
public class test{
public static void main(String[]args){
A a = new A();
B b = new B();
a=null;
b=null;
}
}
class A {
public B b;
}
class B {
public A a;
}
基于 GC Roots 的这种思想,发展出了很多垃圾回收算法,下面我们就来聊一聊这些算法。
标记-清除(Mark-Sweep)这个算法可以说是最早最基础的算法了,标记-清除顾名思义分为两个阶段,即标记和清除阶段:首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象。当然也可以标记存活的对象,回收未被标记的对象。这个标记的过程就是垃圾判定的过程。
后续大部分垃圾回收算法都是基于标记-算法思想衍生的,只不过后续的算法弥补了标记-清除算法的缺点,那么它有什么缺点呢?
主要有两个缺点:
标记算法的示意图如下:
由于标记-清除算法极易产生内存碎片,研究人员提出了标记-复制算法,标记-复制算法也可以简称为复制算法,复制算法是一种半区复制,它会将内存大小划分为相等的两块,每次只使用其中的一块,用完一块再用另外一块,然后再把用过的一块进行清除。虽然解决了部分内存碎片的问题,但是复制算法也带来了新的问题,即复制开销,不过这种开销是可以降低的,如果内存中大多数对象是无用对象,那么就可以把少数的存活对象进行复制,再回收无用的对象。
不过复制算法的缺陷也是显而易见的,那就是内存空间缩小为原来的一半,空间浪费太明显。
标记-复制算法示意图如下:
现在 Java 虚拟机大多数都是用了这种算法来回收新生代,因为经过研究表明,新生代对象 98% 都熬不过第一轮收集,因此不需要按照 1 :1 的比例来划分新生代的内存空间。
基于此,研究人员提出了一种 Appel 式回收,Appel 式回收的具体做法是把新生代分为一块较大的 Eden 空间和两块 Survivor 空间,每次分配内存都只使用 Eden 和其中的一块 Survivor 空间,发生垃圾收集时,将 Eden 和 Survivor 中仍然存活的对象一次性复制到另外一块 Survivor 空间上,然后直接清理掉 Eden 和已使用过的 Survivor 空间。
在主流的 HotSpot 虚拟机中,默认的 Eden 和 Survivor 大小比例是 8:1,也就是每次新生代中可用内存空间为整个新生代容量的 90%,只有一个 Survivor 空间,所以会浪费掉 10% 的空间。这个 8:1 只是一个理论值,也就是说,不能保证每次都有不超过 10% 的对象存活,所以,当进行垃圾回收后如果 Survivor 容纳不了可存活的对象后,就需要其他内存空间来进行帮助,这种方式就叫做内存担保(Handle Promotion) ,通常情况下,作为担保的是老年代。
标记-复制算法虽然解决了内存碎片问题,但是没有解决复制对象存在大量开销的问题。为了解决复制算法的缺陷,充分利用内存空间,提出了标记-整理算法。该算法标记阶段和标记-清除一样,但是在完成标记之后,它不是直接清理可回收对象,而是将存活对象都向一端移动,然后清理掉端边界以外的内存。
具体过程如下图所示:
为了解决跨代引用问题,提出了记忆集这个概念,记忆集是一个在新生代中使用的数据结构,它相当于是记录了一些指针的集合,指向了老年代中哪些对象存在跨代引用。
记忆集的实现有不同的粒度:
其中,卡精度是使用了卡表作为记忆集的实现,关于记忆集和卡表的关系,大家可以想象成是 HashMap 和 Map 的关系。
卡表其实就是一个字节数组:
CARD_TABLE[this address >> 9] = 0;
字节数组 CARD_TABLE 的每一个元素都对应着内存区域中一块特定大小的内存块,这个内存块就是卡页,一般来说,卡页都是 2 的 N 次幂字节数,通过上面的代码我们可以知道,卡页一般是 2 的 9 次幂,这也是 HotSpot 中使用的卡页,即 512 字节。
一个卡页的内存通常包含不止一个对象,只要卡页中有一个对象的字段存在跨代指针,那就将对应卡表的数组元素的值设置为 1,称之为这个元素变脏了,没有标示则为 0 。在垃圾收集时,只要筛选出卡表中变脏的元素,就能轻易得出哪些卡页内存块中包含跨代指针,然后把他们加入 GC Roots 进行扫描。
所以,卡页和卡表主要用来解决跨代引用问题的。
如果有其他分代区域中对象引用了本区域的对象,那么其对应的卡表元素就会变脏,这个引用说的就是对象赋值,也就是说卡表元素会变脏发生在对象赋值的时候,那么如何在对象赋值的时候更新维护卡表呢?
在 HotSpot 虚拟机中使用的是写屏障(Write Barrier) 来维护卡表状态的,这个写屏障和我们内存屏障完全不同,希望读者不要搞混了。
这个写屏障其实就是一个 AOP 切面,在引用对象进行赋值时会产生一个环形通知(Around),环形通知就是切面前后分别产生一个通知,因为这个又是写屏障,所以在赋值前的部分写屏障叫做写前屏障,在赋值后的则叫做写后屏障。
写屏障会带来两个问题:
无条件写屏障带来的性能开销
每次对引用的更新,无论是否更新了老年代对新生代对象的引用,都会进行一次写屏障操作。显然,这会增加一些额外的开销。但是,扫描整个老年代相比较,这个开销就低得多了。
不过,在高并发环境下,写屏障又带来了伪共享(false sharing)问题。
高并发下伪共享带来的性能开销
在高并发情况下,频繁的写屏障很容易发生伪共享(false sharing),从而带来性能开销。
假设 CPU 缓存行大小为 64 字节,由于一个卡表项占 1 个字节,这意味着,64 个卡表项将共享同一个缓存行。
HotSpot 每个卡页为 512 字节,那么一个缓存行将对应 64 个卡页一共 64*512 = 32K B。
如果不同线程对对象引用的更新操作,恰好位于同一个 32 KB 区域内,这将导致同时更新卡表的同一个缓存行,从而造成缓存行的写回、无效化或者同步操作,间接影响程序性能。
一个简单的解决方案,就是不采用无条件的写屏障,而是先检查卡表标记,只有当该卡表项未被标记过才将其标记为脏的。
这就是 JDK 7 中引入的解决方法,引入了一个新的 JVM 参数 -XX:+UseCondCardMark,在执行写屏障之前,先简单的做一下判断。如果卡页已被标识过,则不再进行标识。
简单理解如下:
if (CARD_TABLE [this address >> 9] != 0)
CARD_TABLE [this address >> 9] = 0;
与原来的实现相比,只是简单的增加了一个判断操作。
虽然开启 -XX:+UseCondCardMark 之后多了一些判断开销,但是却可以避免在高并发情况下可能发生的并发写卡表问题。通过减少并发写操作,进而避免出现伪共享问题(false sharing)。
根据可达性算法的分析可知,如果要找出存活对象,需要从 GC Roots 开始遍历,然后搜索每个对象是否可达。如果对象可达则为存活对象,在 GC Roots 的搜索过程中,按照对象和其引用是否被访问过这个条件会分成下面三种颜色:
注:如果标记结束后对象仍为白色,意味着已经“找不到”该对象在哪了,不可能会再被重新引用。
现代的垃圾回收器几乎都借鉴了三色标记的算法思想,尽管实现的方式不尽相同:比如白色/黑色集合一般都不会出现(但是有其他体现颜色的地方)、灰色集合可以通过栈/队列/缓存日志等方式进行实现、遍历方式可以是广度/深度遍历等等。
三色标记法会造成两种问题,这两种问题所出现的环境都是由于用户环境和收集器并行工作造成的 。当用户线程正在修改引用关系,此时收集器在回收引用关系,此时就会造成把原本已经消亡的对象标记为存活,如果出现这种状况的话,问题不大,下次再让收集器重新收集一波就完了,但是还有一种情况是把存活的对象标记为死亡,这种状况就会造成不可预知的后果。
针对上面这两种对象消失问题,业界有两种处理方式,一种是增量更新(Incremental Update),一种是原是快照(Snapshot At The Beginning, SATB)。
垃圾收集器是面试的常考,也是必考点。只要涉及到 JVM 的相关问题,都会围绕着垃圾收集器来做一波展开。所以,有必要了解一下这些垃圾收集器。
垃圾收集器有很多,不同商家、不同版本的 JVM 所提供的垃圾收集器可能会有很大差别,我们主要介绍 HotSpot 虚拟机中的垃圾收集器。
垃圾收集器是垃圾回收算法的具体实现,我们上面提到过,垃圾回收算法有标记-清除算法、标记-整理、标记-复制,所以对应的垃圾收集器也有不同的实现方式。
我们知道,HotSpot 虚拟机中的垃圾收集都是分代回收的,所以根据不同的分代,可以把垃圾收集器分为:
Serial 收集器是一种新生代的垃圾收集器。它是一个单线程工作的收集器,使用复制算法来进行回收。单线程工作不是说这个垃圾收集器只有一个,而是说这个收集器在工作时,必须暂停其他所有工作线程,这种暴力的暂停方式就是 Stop The World。Serial 就好像是寡头垄断一样,只要它一发话,其他所有的小弟(线程)都得给它让路。
Serial 收集器的示意图如下:
SefePoint 全局安全点:它就是代码中的一段特殊的位置,在所有用户线程到达 SafePoint 之后,用户线程挂起,GC 线程会进行清理工作。
虽然 Serial 有 STW 这种显而易见的缺点。不过,从其他角度来看,Serial 还是很讨喜的,它还有着优于其他收集器的地方,那就是简单而高效,对于内存资源首先的环境,它是所有收集器中额外内存消耗最小的,对于单核处理器或者处理器核心较少的环境来说,Serial 收集器由于没有线程交互开销,所以 Serial 专心做垃圾回收效率比较高。
ParNew 是 Serial 的多线程版本,除了同时使用多条线程外,其他参数和机制(STW、回收策略、对象分配规则)都和 Serial 完全一致.
ParNew 收集器的示意图如下:
虽然 ParNew 使用了多条线程进行垃圾回收,但是在单线程环境下它绝对不会比 Serial 收集效率更高,因为多线程存在线程交互的开销,但是随着可用 CPU 核数的增加,ParNew 的处理效率会比 Serial 更高效。
Parallel Scavenge 收集器也是一款新生代收集器,它同样是基于标记-复制算法实现的,而且它也能够并行收集,这么看来,表面上 Parallel Scavenge 与 ParNew 非常相似,那么它们之间有什么区别呢?
Parallel Scavenge 的关注点主要在达到一个可控制的吞吐量上面。吞吐量就是处理器用于运行用户代码的时间与处理器总消耗时间的比。也就是:
这里给大家举一个吞吐量的例子,如果执行用户代码的时间 + 运行垃圾收集的时间总共耗费了 100 分钟,其中垃圾收集耗费掉了 1 分钟,那么吞吐量就是 99%。停顿时间越短就越适合需要与用户交互或需要保证服务响应质量,良好的响应速度可以提升用户体验,而高吞吐量可以最高效率利用处理器资源。
前面介绍了一下 Serial,我们知道它是一个新生代的垃圾收集,使用了标记-复制算法。而这个 Serial Old 收集器却是 Serial 的老年版本,它同样也是一个单线程收集器,使用的是标记-整理算法,Serial Old 收集器有两种用途:一种是在 JDK 5 和之前的版本与 Parallel Scavenge 收集器搭配使用,另外一种用法就是作为 CMS 收集器的备选。CMS 垃圾收集器我们下面说。
Serial Old 的收集流程如下:
前面我们介绍了 Parallel Scavenge 收集器,现在来介绍一下 Parallel Old 收集器,它是 Parallel Scavenge 的老年版本,支持多线程并发收集,基于标记 - 整理算法实现,JDK 6 之后出现,吞吐量优先可以考虑 Parallel Scavenge + Parallel Old 的搭配:
CMS 收集器的主要目标是获取最短的回收停顿时间,它的全称是 Concurrent Mark Sweep,从这个名字就可以知道,这个收集器是基于标记 - 清除算法实现的,而且支持并发收集。它的运行过程要比上面我们提到的收集器复杂一些,它的工作流程如下:
对于上面这四个步骤,初始标记和并发标记都需要 Stop The World,初始标记只是标记一下和 GC Roots 直接关联到的对象,速度较快;并发标记阶段就是从 GC Roots 的直接关联对象开始遍历整个对象图的过程。这个过程时间比较长但是不需要停顿用户线程,也就是说与垃圾收集线程一起并发运行。并发标记的过程中,可能会有错标或者漏标的情况,此时就需要在重新标记一下,最后是并发清除阶段,清理掉标记阶段中判断已经死亡的对象。
CMS 的收集过程如下:
CMS 是一款非常优秀的垃圾收集器,但是没有任何收集器能够做到完美的程度,CMS 也是一样,CMS 至少有三个缺点:
CMS 对处理器资源非常敏感。在并发阶段,虽然不会造成用户线程停顿,但是却会因为占用一部分线程而导致应用程序变慢,降低总吞吐量;
CMS 无法处理浮动垃圾,有可能出现 Concurrent Mode Failure 失败进而导致另一次完全 Stop The World 的 Full GC 产生;
什么是浮动垃圾呢?由于并发标记和并发清理阶段,用户线程仍在继续运行,所以程序自然而然就会伴随着新的垃圾不断出现,而且这一部分垃圾出现在标记结束之后,CMS 无法处理这些垃圾,所以只能等到下一次垃圾回收时在进行清理。这一部分垃圾就被称为浮动垃圾。
CMS 最后一个缺点是并发-清除的通病,也就是会有大量的空间碎片出现,这将会给分配大对象带来困难。
Garbage First 又被称为 G1 收集器,它的出现意味着垃圾收集器走过了一个里程碑,为什么说它是里程碑呢?因为 G1 这个收集器是一种面向局部的垃圾收集器,HotSpot 团队开发这个垃圾收集器为了让它替换掉 CMS 收集器,所以到后来,JDK 9 发布后,G1 取代了 Parallel Scavenge + Parallel Old 组合,成为服务端默认的垃圾收集器,而 CMS 则不再推荐使用。
之前的垃圾收集器存在回收区域的局限性,因为之前这些垃圾收集器的目标范围要么是整个新生代、要么是整个老年代,要么是整个 Java 堆(Full GC),而 G1 跳出了这个框架,它可以面向堆内存的任何部分来组成回收集(Collection Set,CSet),衡量垃圾收集的不再是哪个分代,这就是 G1 的 Mixed GC 模式。
G1 是基于 Region 来进行回收的,Region 就是堆内存中任意的布局,每一块 Region 都可以根据需要扮演 Eden 空间、Survivor 空间或者老年代空间,收集器能够对不同的 Region 角色采用不同的策略来进行处理。Region 中还有一块特殊的区域,这块区域就是 Humongous 区域,它是专门用来存储大对象的,G1 认为只要大小超过了 Region 容量一半的对象即可判定为大对象。如果超过了 Region 容量的大对象,将会存储在连续的 Humongous Region 中,G1 大多数行为都会把 Humongous Region 作为老年代来看待。
G1 保留了新生代(Eden Suvivor)和老年代的概念,但是新生代和老年代不再是固定的了。它们都是一系列区域的动态集合。
G1 收集器的运作过程可以分为以下四步:
从上面这几个步骤可以看出,除了并发标记外,其余三个阶段都需要暂停用户线程,所以,这个 G1 收集器并非追求低延迟,官方给出的设计目标是在延迟可控的情况下尽可能的提高吞吐量,担任全功能收集器的重任。
下面是 G1 回收的示意图:
G1 收集器同样也有缺点和问题:
下面介绍一下 JVM 中常用的调优、故障处理等工具。
JVM 类加载默认使用的是双亲委派模型,那么什么是双亲委派模型呢?
这里我们需要先介绍一下三种类加载器:
所以,我们的 Java 应用程序都是由这三种类加载器来相互配合完成的。当然,用户也可以自己定义类加载器,即 User Class Loader,这几个类加载器的模型如下:
上面这几类类加载器构成了不同的层次结构,当我们需要加载一个类时,子类加载器并不会马上去加载,而是依次去请求父类加载器加载,一直往上请求到最高类加载器:启动类加载器。当启动类加载器加载不了的时候,依次往下让子类加载器进行加载。这就是双亲委派模型。
在双亲委派模型中,子类加载器可以使用父类加载器已经加载的类,而父类加载器无法使用子类加载器已经加载的。这就导致了双亲委派模型并不能解决所有的类加载器问题。
Java 提供了很多外部接口,这些接口统称为 Service Provider Interface, SPI,允许第三方实现这些接口,而这些接口却是 Java 核心类提供的,由 Bootstrap Class Loader 加载,而一般的扩展接口是由 Application Class Loader 加载的,Bootstrap Class Loader 是无法找到 SPI 的实现类的,因为它只加载 Java 的核心库。它也不能代理给 Application Class Loader,因为它是最顶层的类加载器。
虽然双亲委派机制是 Java 强烈推荐给开发者们的类加载器的实现方式,但是并没有强制规定你必须就要这么实现,所以,它一样也存在被破坏的情况,实际上,历史上一共出现三次双亲委派机制被破坏的情况:
Copyright© 2013-2020
All Rights Reserved 京ICP备2023019179号-8