JVM、内存模型、类加载机制、对象的创建、垃圾回收机制、对象内存分配策略、JVM调优等。
JVM 即 java 虚拟机(Java Virtual Machine),JVM是一种用于计算设备的规范,是一个虚构出来的计算机。是通过在实际计算机上仿真模拟各种计算机功能来实现的。
JVM 本质上是一个程序,当它在命令行启动的时候就开始执行保存在字节码文件中的指令。java 语言的平台无关性就是因为 JVM 屏蔽了具体平台相关的信息。
JVM 由两个子系统和两个组件构成。两个子系统即 类加载子系统、执行引擎;两个组件即 运行时数据区、本地接口。
作用:
即 JVM 的运行机制。首先通过编译器将 java 文件转化为字节码文件,然后类加载器(Class Loader)将字节码加载到 JVM 内存中,并将其放到运行时数据区(Runtime Data Area)的方法区(Method Area)内,因为字节码文件只是 JVM 的一套指令集规范,并不能直接交由底层操作系统去执行,所以需要特定的命令解析器执行引擎(Excution Engine),将其翻译成底层系统指令,再交由 CPU 去执行,而这个过程中需要调用其它编程语言实现的本地接口(Native Interface)来完成程序的功能。
JVM 在执行 java 程序时会为它分配内存,并且把它所管理的内存区域划分为若干个不同的数据区域。这个区域有各自的用途,以及不同的创建和销毁时间。有些随着进程的启动而存在,有些随着线程的启动结束而创建销毁。
JVM 内存被划分为如上图所示的运行时数据区的五个区域,分别是:方法区、堆、虚拟机栈、本地方法栈和程序计数器。其中方法区和堆为线程共享数据区,而虚拟机栈、本地方法栈和程序计数器为线程隔离数据区。
方法区,即 Method Area,用来存储被加载到 jvm 中的类元信息(成员变量、方法等)、类加载器、运行时常量池等数据。为线程共享区域。在 jvm 启动时会创建方法区。
在 jvm 的规范中,方法区在逻辑上属于堆的一部分,但不同 jvm 厂商在实现 jvm 时并没有严格按照 jvm 规范进行实现。如 oracle 的 HotSpot jvm 在 1.6 中对方法区的实现叫做永久代(PermGen ),其在物理结构上属于堆内存。永久代用来存储类元信息、类加载器、运行时常量池,其中运行时常量池中又包含了串池(StringTable)。HotSpot jvm 在 1.8 的实现中将对方法区的实现由永久代改编成了元空间(Metaspace),且其物理结构从堆内存移出到了系统内存。元空间在存储的内容上也有所变化,其将运行时常量池中的串池(StringTable)移到了堆内存中。所以 jvm 内存模型中的方法区在逻辑上属于 jvm 的一种规范或概念,而像永久代、元空间这些属于其物理上的具体实现。
同时,jvm 也为方法区规定了内存溢出情况,其中在永久代实现中,内存溢出错误为 PermGen space,而在元空间实现中,内存溢出错误则为 Metaspace。
堆,即 Heap,用来存储几乎所有的 java 对象。其是 jvm 中占用内存最大的一块区域。其是线程共享区域,因此堆中的对象需要考虑线程安全问题。其会受到垃圾回收机制的管理。
虚拟机栈,即 java virtual machine stacks,用来存储方法执行是的局部变量表、操作数栈、动态链接、方法出口等信息。为线程隔离区,即线程私有。
每个线程启动后 jvm 都会为其分配虚拟机栈内存,默认分配的大小由操作系统决定,如 Linux、MacOS 默认分配的栈内存大小是 1024kb,Windows 系统默认分配的栈内存大小依赖于系统的虚拟内存。
虚拟机栈又可以理解为是描述 java 方法执行的内存模型,每个方法执行时会创建一个栈帧(Frame),每个战争对应着一次方法的调用,即每一个方法从调用到结束对应着一个栈帧在栈中的入栈和出栈的过程。所以,栈是由多个栈帧组成的。同时,每个栈内只能有一个活动栈帧,对应着该栈对应线程正在执行的方法,活动栈帧即栈顶的栈帧。
注:
因为每个栈的大小是固定的(默认 1MB),所以有些异常程序中,会出现栈内存溢出。栈内存溢出:
本地方法栈,即 Native Method Stacks,其和虚拟机栈的作用是一样的,只不过二者的服务对象不同。虚拟机栈服务的是 java 方法,而本地方法栈服务的是本地方法。
本地方法即用 C 或 C++ 语言编写的直接与操作系统交互的方法,在 java 中则体现为被 native 关键字修饰的方法。
程序计数器,即 Program Counter Register,其在物理上基于寄存器实现,用来记录当前线程所执行的字节码的行号指示器。字节码解析器的工作就是通过改变这个计数器的值,来选取下一条要执行的字节码指令。分支、循环、跳转、异常处理、线程恢复等基础功能都需要这个计数器来完成。
程序计数器是线程私有的,且该区域是 jvm 中唯一一个没有规定任何 OutOfMemoryError 情况的区域。
正在执行 java 方法的话,计数器记录的是当前虚拟机字节码指令的地址;若是本地方法,则为空。
堆 | 栈 | |
---|---|---|
物理地址 | 堆的物理地址分配对对象是不连续的,所以性能较慢,且在 GC 的时候要考虑由于不连续的分配而造成的内存碎片问题,所以有各种 GC 算法,如标记-清楚、复制、标记压缩等。 | 栈的物理地址分配是连续的,使用的是数据结构中的栈,先进先出,所以性能快。 |
内存分别 | 堆因为是不连续的,所以分配的内存是在运行期确认的,且大小是不固定,一般堆的大小要远大于栈大小。 | 栈因为是连续的,所以分配的内存实在编译器确认的,且大小是固定的。 |
存放内容 | 堆存放的是对象实例和数组,所以该区更关注数据的存储。 | 栈存放的是局部变量、操作数栈、返回结果等,所以该区更关注程序方法的执行。 |
可见度 | 堆是线程共享区域,即对整个程序都是可见、共享的。 | 栈是线程隔离区域,即是线程私有的,支队线程可见,且其生命周期和线程相同。 |
PS:静态变量放在方法区,静态对象还是放在堆区。
在 jvm 中,除却程序计数器外,其它区域都规定了内存溢出情况,在异常上分为 OutOfMemoryError 和 StackOverFlowError,分别表示内存溢出和栈内存溢出。
内存溢出
内存溢出是指 JVM 已经没有足够的内存空间来为你分配出你所需的内存。
内存泄漏是指不再被使用的对象或变量一直占据着内存空间,当这种对象或变量变多时,就会耗尽内存,导致内存溢出。
理论上来说 java 是由垃圾回收机制,也就是说,不再被使用的对象会被 GC 回收掉,自动从内存中清除。但即使这样,也依然存在内存泄漏的情况。java 中导致内存泄漏的原因很明确,即长生命周期的对象持有短生命周期的对象引用,尽管短生命周期已经不再被需要,但由于长生命周期持有它的引用而导致其不能被回收。这就是 java 中内存泄漏发生的场景。
栈内存溢出
栈是线程私有的,它的生命周期和线程相同。每个方法在执行的时候都会创建一个栈帧,用来存储局部变量表、操作数栈、动态链接、方法出口等信息。局部变量表又包含基本数据类型和对象引用类型。
如果线程请求的栈深度大于虚拟机最大栈深度,则会抛出 StackOverFlowError 异常。通常在方法递归调用中会出现这种情况。
如果虚拟机栈可以动态扩展,并且扩展的动作已经尝试过,但依然没有申请到足够的内存;或者在新创建线程时没有足够的内存去创建对应的虚拟机栈,则会抛出 OutOfMemoryError,即内存溢出。
常量池
常量池是属于 .class 文件中的一部分,可以将其理解为一张常量表,程序执行时会根据虚拟机指令在这张常量表中找到要执行的类名、方法名、参数类型、字面量等信息。
对于以下代码:
public class ConstantPoolTest {
public static void main(String[] args) {
System.out.println("Hello world");
}
}
其常量池则长下面这个样子:
Constant pool:
#1 = Methodref #6.#21 // java/lang/Object."":()V
#2 = Fieldref #22.#23 // java/lang/System.out:Ljava/io/PrintStream;
#3 = String #24 // Hello world
#4 = Methodref #25.#26 // java/io/PrintStream.println:(Ljava/lang/String;)V
#5 = Class #27 // org/xgllhz/test/test/ConstantPoolTest
#6 = Class #28 // java/lang/Object
#7 = Utf8
#8 = Utf8 ()V
#9 = Utf8 Code
#10 = Utf8 LineNumberTable
#11 = Utf8 LocalVariableTable
#12 = Utf8 this
#13 = Utf8 Lorg/xgllhz/test/test/ConstantPoolTest;
#14 = Utf8 main
#15 = Utf8 ([Ljava/lang/String;)V
#16 = Utf8 args
#17 = Utf8 [Ljava/lang/String;
#18 = Utf8 MethodParameters
#19 = Utf8 SourceFile
#20 = Utf8 ConstantPoolTest.java
#21 = NameAndType #7:#8 // "":()V
#22 = Class #29 // java/lang/System
#23 = NameAndType #30:#31 // out:Ljava/io/PrintStream;
#24 = Utf8 Hello world
#25 = Class #32 // java/io/PrintStream
#26 = NameAndType #33:#34 // println:(Ljava/lang/String;)V
#27 = Utf8 org/xgllhz/test/test/ConstantPoolTest
#28 = Utf8 java/lang/Object
#29 = Utf8 java/lang/System
#30 = Utf8 out
#31 = Utf8 Ljava/io/PrintStream;
#32 = Utf8 java/io/PrintStream
#33 = Utf8 println
#34 = Utf8 (Ljava/lang/String;)V
运行时常量池
上面说到常量池是属于 .class 文件的内容,当编译后的 java 文件被加载到 jvm 中时,其常量池中的内容将被放入运行时常量池。同时将其中的符号地址变为真实地址,也就是类加载阶段的第四步:解析,将符号引用转变为直接引用。
串池,即 StringTable,是 jvm 中用来存储字符串对象的一块区域,其在设计上采用了哈希表的数据结构。其存在以下特性:
对于串池在物理上的地址,不同的 jvm 实现可能也不相同。在 jdk 1.6 中,串池是存在于方法区(或永久代)中的运行时常量池内的;在 jdk 1.8 中,串池是存在于堆中的。同时,由于串池存在于永久代或堆中,所以其也是会受到垃圾回收管理的。
串池性能调优,因为串池在设计上使用了哈希表的数据结构,所以当程序中的字符串常量(字面量)过多时,可以考虑通过增大哈希桶的个数来降低哈希冲突的概率,以提高串池中字符串获取或放入的时间。串池哈希桶的个数可通过命令 -XX:StringTableSize=buckets 来调整。
// 以下代码简单描述了串池的作用
String s1 = "a"; // 执行此代码时 字符 a 将以字符串对象的方式入到串池中
String s2 = "b"; // 执行此代码时 字符 b 将以字符串对象的方式入到串池中
String s3 = "a" + "b"; // 此代码在编译期会被优化为 s3 = "ab" 即字符串常量(字面量)的拼接 执行此代码时 字符 ab 将以字符串对象的方式入到串池中
String s4 = s1 + s2; // 执行此代码时 实际上执行的是 new StringBuild().append("a").append("b").toString() 所以最终会在堆中创建一个字符串对象 "ab"
String s5 = "ab"; // 执行此代码时 字符 ab 将以字符串对象的方式入池 但因为第三行代码已经将 "ab" 入池 所以此处不会入池 返回串中 "ab" 对象的引用。
String s6 = s4.intern(); // 执行此代码时 因为在第三行代码中已经将 "ab" 对象入到池中 所以此处不会入池 返回池中 "ab" 对象的引用。
System.out.println(s3 == s4); // false s3 是池中对象 s4 是堆中对象 二者地址不相同
System.out.println(s3 == s5); // true s3 是池中对象 s5 也指向池中的 "ab" 对象 二者地址相同
System.out.println(s3 == s6); // true s3 是池中对象 s4 虽然是堆中对象 但其在调用 intern() 方法后 最后都会返回池中对象 "ab" 的地址 二者地址相同
String s1 = new String("a") + new String("b"); // 字符串变量拼接 堆中对象
String s2 = "ab"; // 字符串字面量 池中对象
s1.intern(); // 入池失败
System.out.println(s1 == s2); // false s1 堆中 s2 池中 二者地址不相同
// jdk 1.8 下
String s1 = new String("a") + new String("b"); // 字符串变量拼接 堆中对象
s1.intern(); // 入池成功 返回池中地址
String s2 = "ab"; // 字符串字面量 池中已有 池中对象
System.out.println(s1 == s2); // true s1 入池成功 故引用池中地址 s2 本引用池中地址 二者地址相同
// jdk 1.6 下
String s1 = new String("a") + new String("b"); // 字符串变量拼接 堆中对象
s1.intern(); // 入池成功 返回池中地址 但由于是将 s1 拷贝了一份再入的池 所以 s1 仍引用堆中对象
String s2 = "ab"; // 字符串字面量 池中已有 池中对象
System.out.println(s1 == s2); // flase s1 堆中对象 s2 池中对象 二者地址不相同
直接内存,即 DirectMemory。不属于 jvm 内存区域,是直接向系统申请的系统内存。如常见的 NIO 操作中的数据缓冲区即是 java 程序直接向系统申请的系统内存。直接内存的特点是读写性能高,但分配回收成本高;不受 jvm 内存管理;也会出现 OOM 的情况。
直接内存的分配和回收底层是使用了 Unsafe 类的 aollcateDirect() 和 freeMemory() 方法,前者负责分配直接内存,后者负责释放直接内存。在 ByteBuffer 的实现类中,使用了 Cleaner(虚引用)来检测 ByteBuffer 对象,当该对象被垃圾回收后,就会由 ReferenceHandler 线程通过 Cleaner 的 clean() 方法来释放直接内存(内部调用 freeMemory())。
JVM 类加载机制是指 JVM 通过类加载子系统将描述类信息的 .class 文件中字节码加载到内存中,并通过验证、准备、解析、初始化等阶段,最终产生能被 JVM 直接使用的 java.lang.Class 对象的过程。
JVM 类加载机制分为五个步 骤,分别是:加载、验证、准备、解析、初始化。
这一阶段的主要作用是将描述类信息的字节码文件从磁盘加载到内存中,并生成一个代表这个类的 java.lang.Class 对象,作为方法区这个类的各种数据的入口。
java 的类加载是动态的,它并不会一次性把所有的都加载后再运行,而是将保证程序运行的基础类加载到 jvm 中,其它的类,则是在需要的时候再加载,即懒加载。
类的加载方式有两种,即隐式加载和显式加载。隐式加载是指在程序运行过程中,当遇到通过 new 等方式创建对象时,才隐式的调用类加载器将对应的类加载到 jvm 中。显式加载是指通过 class.forName() 等方法,显式加载所需要的类,如反射。
jvm 中的类加载是由类加载器(Class Loader)和它的子类实现的,负责在运行时查找和载入类。类加载器包括根加载器(BootStrap)、扩展加载器(Extension)、系统加载器(System)、用户自定义加载器(java.lang.ClassLoader 的子类)。类加载过程采用了父亲委托机制,即类加载首先请求父加载器,父加载器无能为力时才由子加载器加载,即双亲委派模型。
打破双亲委派模型不仅要继承 ClassLoader 类,还要重写 loadClass 和 findClass 方法。
主动引用:
被动引用:
这一阶段的主要目的是为了确认加载到内存中的字节码信息是否符合当前虚拟机的要求,且是否会对虚拟机自身产生危害。
准备阶段是指在方法区中为类中的变量分配内存并设置变量初始值。
只会对 static 修饰的静态变量分配内存、赋默认值(如 0、0L、null、false)。
对 final 修饰的静态字面值常量直接赋初值(若不是字面值常量,则会赋默认值)。实际上在编译阶段会为 static final 修饰的变量生成 ConstantValue 属性,在准备阶段 jvm 会根据该属性将该变量的值赋为初值。
解析阶段是指 jvm 将常量池中的符号引用替换为直接引用的过程。
符号引用的引用目标不一定已经加载到内存中,其和具体虚拟机的实现有关。直接引用可以是指向目标的指针、内存中的地址,其引用目标必定是已经存在于内存中的。
符号引用即 .class 中的 CONSTANT_Class_info、CONSTANT_Field_info、CONSTANT_Method_info 等类型的常量。
初始化阶段主要目的是为类的静态变量赋予正确的初始值,jvm 对类进行初始化,主要对类变量进行初始化。
类变量初始化的方式有两种:
当虚拟机遇到一条 new 指令,首先检查常量池中是否已经加载到相应的类,若没有,则必须执行相应的类加载操作;接下来是分配内存,若堆内存是绝对规整的,则使用指针碰撞的方式分配,若不是,则从空闲列表中分配,即空闲列表方式;分配内存时还需要考虑并发问题,可以通过 CAS 同步处理或本地线程分配缓冲(Thread Local Allocation Buffer,TLAB)这两种方式来处理;然后进行内存空间初始化操作;接着进行一些必要的对象设置(元信息、哈希码);最后执行 < init > 方法。
java 中对象的创建方式:
创建方式 | 是否使用构造函数 |
---|---|
使用 new 关键字 | 调用了构造函数 |
使用 Class 的 newInstance 方法 | 调用了构造函数 |
使用 Constructor 类的 newInstance 方法 | 调用了构造函数 |
使用 clone 方法 | 没有调用构造函数 |
使用反序列化 | 没有调用构造函数 |
类加载完成后,接着会在堆内存中为对象分配内存,内存分配取决于堆内存是否规整,有两种方式:
采用那种方式分配内存是由堆内存是否规整决定的,而堆内存是否规整又是由采用的垃圾收集器是否带有压缩功能来决定的。
对象的创建在虚拟机中是一个频繁的操作,哪怕只是修改一个指针指向的位置,在并发情况下也是不安全的。如可能会出现正在给对象 A 分配内存,指针还没来得急修改,对象 B 又同时使用了原来的指针分配内存的情况。解决这种情况有两种方式:
java 程序需要通过 jvm 栈上的引用来访问堆中的具体对象。对象的访问方式取决于 jvm 虚拟机的具体实现,目前主流的访问方式有两种,分别是指针和句柄。
内存处理从某方面来说在程序中是非常重要的,它关乎着你的程序是否可以更高效的运行。且内存处理又是最容易被编程人员忽略或者出错的地方,忘记或者错误的内存回收可能会导致系统的不稳定甚至崩溃。jvm 提供的垃圾收集机制有效的解决了这个问题。
通常情况下,GC 是被动触发执行的,当然程序员也可以通过 System.gc() 方法来通知 GC 运行,但 java 语言规范并不能保证 GC 一定会运行。
垃圾回收机制有效的防止了内存泄漏,从某方面来说提高了内存的使用效率。
垃圾收集器在垃圾回收的时候,首先要判断那些内存是可以被回收的,即判断哪些对象是 存活 的,哪些对象已经 死掉 了。一般有两种判断方法:
四种引用:
强引用:
即类似于 A a = new A() 这种对象直接赋值的引用被称为强引用。其特点是只要有 GC Roots 强引用该对象,则该对象就不能被垃圾回收。
软引用:
即 SoftReference。其特点是内存够就不回收,内存不够就回收。可以配合引用队列来释放软引用自身。
弱引用:
即 WeakReference。其特点是只要发生垃圾回收,则其一定会被回收。可以配合引用队列来释放弱引用自身。
虚引用:
即 PhantomReference。当被引用的对象回收时,虚引用对象将被放入引用队列(ReferenceQueue),由 ReferenceHandler 线程来调用虚引用对象的相关方法释放虚引用自身所占用的内存。其必须配合引用队列来使用。
终结器引用:
即 FinalReference。在垃圾回收时,终结器引用对象将入列(此时终结器引用的对象还没有被回收),再由 Finalizer 线程通过终结器引用找到被引用的对象,然后调用其 finalize() 方法,在第二次垃圾回收时被终结器引用的对象将被回收。
堆内存分代使 jvm 能够更好的管理内存中的对象,且提到了内存空间的利用率。
jvm 堆内存从 GC 的角度还可以分为 新生代 和 老年代,其分别占堆内存空间的 1/3 和 2/3。新生代又可分为 Eden 区、From Survivor 区、To Survivor 区。其中 Eden、From、To 这三个区又分别占新生代的 8/10、1/10、1/10。
堆大小 = 新生代 + 老年代,新生代 = Eden 区 + From 区 + To 区。其中堆大小可通过 -Xms 和 -Xmx 参数来设置;新生代和老年代占比为 1 : 2,该比例可通过 -XX:NewRatio 参数来设置;Eden : From : To 为 8 : 1 : 1,可通过 -XX:SurvivorRatio 来设置。
MinorGC、MajorGC、FullGC:
GC 的常用算法有 标记清除算法、复制算法、标记压缩算法、分代收集算法。常用的垃圾收集器一般都采用分代收集算法。
垃圾收集算法是内存回收理论,垃圾收集器是内存回收的具体实现。常见的垃圾收集器有 Serial、ParNew、Parallel Scavenge、Serial Old、Parallel Old、CMS、G1。
对于经典的分代收集算法来说,Serial、ParNew、Parallel Scavenge 常用于新生代;Serial Old、Parallel Old、CMS 常用于老年代;G1 常用于整个堆内存的回收。它们之间的连线表示可以相互搭配使用。
收集器特点及对比:
注:STW,即 Stop-The-World,jvm 中一种机制,在垃圾收集时,java 应用程序的所有线程都被挂起(垃圾收集帮助器除外),一种全局停顿现象,所有 java 代码停止,native 代码可运行,但不能与 jvm 发生交互。这种现象多半由 GC 引起。
CMS 即 Concurrent Mark Sweep,并发标记清除垃圾收集器。其主要目标是获取最短的垃圾回收停顿时间,其使用标记清除算法。
CMS 垃圾收集器的工作流程可分为五个阶段,分别是:
初始标记:
暂停所有用户线程(STW),只标记被 GC Roots 直接引用的对象。因为只标记 GC Roots 直接引用的对象,所以这个过程耗时非常短。
并发标记:
在不暂停用户线程的情况下,从 GC Roots 向下遍历所有被关联的对象。虽然这个过程较为耗时,但垃圾回收线程是和用户线程并行工作的,所以并不会影响到用户线程。但由于在并发标记过程中用户线程仍在运行,所以标记结果和实际情况可能会有所出入。
重新标记:
重新标记是为了核对在并发标记期间用户线程的运行对标记结果产生的 “误标” 问题。所以这个阶段会导致所有用户线程暂停(STW),且停顿时间比初始标记时的停顿时间较长些。底层主要通过三色标记的增量更新算法实现。
并发清除:
即对 GC Roots 不可达的对象进行清理。同时,这个阶段垃圾回收线程也是和用户线程一起工作的,即在该阶段会产生新的垃圾,称之为浮动垃圾,当浮动垃圾过多时,CMS 会退化为 Serial Old。
并发重置:
重置本地 GC 过程中的被标记的数据。
并发标记阶段,垃圾回收线程与用户线程并发运行,此时会出现已经标记过的对象被用户线程修改其引用的情况。如 A a = b = c,即 a 引用 b,b 引用 c,在并发标记前 b 取消对 c 的引用,此时则变成 A a = b,并发标记时发现 c 未被引用则不标记 c,但在用户线程执行的同时又将 aa 的引用指向 c,即 A aa = c,但此时 c 已经被处理过了,且未被标记,即被定义为垃圾,这时就造成了漏标。为了解决这种漏标问题,CMS 在设计上使用了三色标记算法和读写屏障来解决。
三色标记法即,其使用黑、灰、白三种颜色来表示 GC Roots 可达性分析时的对象状态。
此时,也就是在并发标记结束后,仍然没有解决漏标的情况,于是就有了重新标记,同时使用读写屏障来解决漏标问题。
对于上文描述的漏标,理论上有两种解决办法,即增量更新(Incremental Update)和原始快照(Snapshot At The Begining 即 SATB)。
增量更新:
即当黑色对象(即已经可达性分析过且为存活对象)中增加了一个指向白色对象的引用时,则将这个黑色对象记录下来,待并发标记结束后重新标记时,再以记录下来的黑色对象为根重新进行可达性分析。简单讲即为若黑色对象多了对白色对象的引用则将其置为灰色。
原始快照:
即当要删除灰色对象的某个指向白色对象的引用时,将这个白色对象记录下来,待并发标记结束后重新标记时,再检查这些记录下来的对象,若引用,则视为垃圾。
对于增量更新和原始快照,虚拟机是通过读写屏障来实现的。类似于 aop。
// ?
以 HotSpot 虚拟机为例,各垃圾收集器在针对漏标的处理上如下:
在新生代进行 Minor GC 时,需要先进行初始标记,即首先要知道那些对象是 GC Roots 直接引用的对象,而对于新生代来说部分 GC Roots 是存在于老年代的,即老年代的某些对象引用了新生代的对象,这种现象称为跨代引用,在分代垃圾收集或区域垃圾收集中是非常常见的。
新生代初始标记 GC Roots 直接引用的对象时,为了避免直接扫描老年代的全部对象(老年代对象多,全扫耗时),对于这种会出现跨代引用的垃圾收集器,jvm 给其设计了卡表和记录表的功能。
卡表:
即将老年代分成固定大小的多个区域(一般每 512k 为一个区域),每个区域则称为一个卡。当某个卡中的对象引用了新生代的对象时,则将该卡标记为脏卡。这个改变是通过写屏障来实现的。
记录表:
对于新生代,其会维护一个记录集(Remember Set 数据结构,在多区域的堆内存设计中,每个区域的新生代都会维护一个记录集),记录了老年代对新生代的引用。
这样,当进行 Minor GC 时,若该区域的新生代的记录集中存在记录,则根据记录找到老年代的对应的脏卡,然后遍历脏卡中的对象,从而找到新生代对象在老年代中的根对象。通过这种方式提高了 Minor GC 的效率。
G1 即 Garbage First,和其它垃圾收集器不同的是,其作用于整个堆内存,且同时注重高吞吐量和低延迟。其是 jdk 9 默认使用的垃圾收集器(9 之前默认使用的是 CMS,故在 jdk 8 之前若要使用 G1 则需要使用 -XX:+UseG1GC 来开启)。其适用于大堆内存。
如上图所示,G1 垃圾收集器会将整个堆内存分为大小相等的多个 Region(区域)(可通过参数 -XX:G1HeapRegionSize = n 来设置每个 Region 的大小,且值必须为 2 的幂),每个 Region 都可单独作为 Eden 区、Survivor 区、Old 区、Humongous 区,其分别代表新生代中的对象出生区、新生代中的幸存区、老年代、大对象区。也就是说,在 G1 垃圾收集模式下,整个堆内存将被划分为多个大小相等的 Region ,这些 Region 共分为四个类型。且每个 Region 所承担的类型不是固定不变的,可能这次承担的是 Eden 区,在执行一次垃圾回收之后就承担老年区。
堆内存被分为多个 Region 之后,不同类型的 Region 将在物理上不再连续。如图中所有绿色 Region 都为 Eden 区,所有红色 Region 都为 Old 区。
默认情况下,新生代占整个堆内存的 5%,在系统运行期间 jvm 会动态的增加新生代的 Region 数,但新生代的总占比不会超过阈值 60%。新生代占比的阈值可通过参数 -XX:G1MaxNewSizePercent = n 来调整。同时,在所有新生代的 Region 中,依旧会按照 8:1:1 的比例给 Eden、From、To 分配。
Humongous 是 G1 专门为存储大对象准备的空间。其判断大对象的规则是当一个对象所占空间大于一个 Region 的 50% 时,则认为其为大对象,将被存储在 Humongous 类型的 Region 中。G1 不会对大对象进行复制,同时垃圾回收时优先考虑大对象,G1 会跟踪老年代对象的 incoming 引用,老年代大对象的 incoming 引用为 0 时会在新生代垃圾回收时进行回收。
同时 G1 规定了 STW 时间,默认为 200 ms,可通过参数 -XX:MaxGCPauseMillis = n 来设置。其整体上使用 标记整理 算法,两个 Region 之间使用 复制算法。
G1 垃圾收集器的工作流程为 Young GC -> Young GC + CM -> Mixed GC。并且这三个是一个闭环流程,即当 Mixed GC 结束后将再次进入 Young GC 阶段。其详细流程见下文。
Young GC,顾名思义,即新生代的垃圾收集,其大致流程为初始标记、幸存对象复制、晋升对象复制。且全过程会导致 STW。同时,Young GC 为 G1 垃圾收集器的第一个阶段。
初始标记:
即进行 GC Roots 标记,这里将会涉及到跨代引用问题(同 CMS 中描述到的跨代引用),解决办法亦为卡表与记录表。
幸存对象复制:
即将 Eden 区的幸存对象复制到 Survivor 区(To 区),同时将 Survivor 区(From 区)的幸存着也复制到 Survivor(To 区)。
晋升对象复制:
即在复制 Survivor 区(From 区)的幸存着时,若其年龄达到晋升标准,则将其复制到 Old 区。
Young GC + CM 即新生代垃圾收集和并发标记。这是 G1 垃圾收集器的第二个阶段,在一个阶段中,Young GC 会一直进行,也就是说可能会一直有长时间存活的对象晋升至 Old 区,当 Old 区的使用量达到堆空间的 45% 时(可通过参数 -XX:InitiatingHeapOccupancyPercent = n 来调整),就会进入 Young GC + CM 阶段。在 Young GC + CM 阶段,新生代垃圾收集和并发标记会同时执行,并发标记不会造成 STW。
这个阶段的主要任务是进行并发标记(并发标记依赖于初始标记的 GC Roots 对象,这个是在 Young GC 时产生的,且在进行并发标记的过程中也会继续进行 Young GC)。
Mixed GC 即混合回收,其会对 Eden、Survivor、Old 区域进行全面垃圾回收。当第二个阶段的并发标记结束后就会进入混合回收阶段。这个阶段共分为两个步骤,分别为最终标记和筛选回收。
最终标记:
最终标记的目的和 CMS 中重新标记的目的一样,是为了校验并发标记阶段用户线程对标记结果的 “误标”。但其解决手段和 CMS 稍有不同,CMS 是采用 写屏障 + 增量更新,而 G1 是采用 写屏障 + 原始快照。
筛选回收:
筛选回收也可分为两个阶段即先筛选,后回收。其筛选逻辑是先根据每个 Region 的回收价值和成本进行排序(即在最短时间内可回收到最大的空间);因为 G1 设置了 STW 时间,所以其会选择回收成本(时间)之和尽量接近 STW 时间且回收价值之和最高的几个 Region 来回收。其回收时也是采用复制算法,即将存活的对象复制到其它的空 Region 中。
在 Mixed GC 结束后,将又进入 Young GC 阶段,形成一个循环。注意,当垃圾产生的速度超过 G1 垃圾收集的速度时,将会退化为 Full GC。
G1 与 CMS 的区别如下:
内存模型:
CMS:延用了 jvm 的内存模型。
G1:堆内存分区,堆内存会被划分为大小相等(Region 大小为 2 的幂次方 MB)的区域,且存在分代概念。
作用范围:
CMS:作用于老年代,且可结合 Serial、ParNew、Serial Old 垃圾收集器使用。
G1:作用于整个堆,单独使用。
工作流程:
CMS:主要体现在并发清除阶段。该阶段会通过并发的方式释放掉不活跃对象所占用的空间。同时,在并发清除阶段,由于 GC 线程和用户线程时并发执行,所以此阶段会产生新的垃圾,称之为浮动垃圾,当浮动垃圾过多时,其会退化为 Serial Old。
G1:主要体现在筛选回收阶段。该阶段会预测回收成本并与 STW 比较,在 STW 范围内通过复制方式将存活对象转移到新 Region。该阶段是 STW 的。
算法类型:
CMS:使用标记清除算法,会产生内存碎片。
G1:使用标记整理算法,不会产生内存碎片。
ZGC 即 The Z Garbage Collector,是 jdk 11 推出的一款低延迟拉满的垃圾回收器。其源自于 Azul System 公司开发的 C4 垃圾回收器。其是在 G1 垃圾回收器在大堆场景下性能不足的背景下出现的。
如上图所示,ZGC 的四个首要目标是:
总之,ZGC 适用于低延迟、高吞吐量、大堆的应用服务。
如上图所示,其为 ZGC 内存模型示意图。与 G1 类似,ZGC 在内存模型的设计上并没有分代的概念,同时其也会将堆内存划分为一个个的小区域 Region,在 ZGC 中称之为 Page 即页面:
如上图所示,其为 ZGC 工作流程示意图。同 G1 一样,ZGC 在整体上也使用标记整理算法,各个 Region 之间也是标记复制算法。
标记复制算法可分为三个阶段:
ZGC 的工作流程可以分为七个阶段:
1、初始标记:STW,标记被 GC Roots 直接引用的对象。
2、并发标记:从被标记的 GC Roots 直接引用的对象开始遍历对象图进行可达性分析。该阶段会更新颜色指针(颜色指针是其实现低延迟的关键技术),G1 在该阶段更新的是对象头信息。
3、重新标记:STW,理论上在并发标记结束后所有待标记对象(活跃对象)都会被标记,但由于 GC 线程和用户线程是并发执行的,所以 GC 线程已标记的结果对象的引用可能会被用户线程修改,从而导致漏标。该阶段的主要作用就是再标记那些漏标的对象。同时,该阶段还会对非强根(软引用、虚引用等)进行标记。
4、并发转移准备:扫描所有 Region,根据特定的查询条件统计出本次 GC 需要清理的 Region,将这些 Region 组成重分配集 (Relocation Set)。
5、初始转移:STW,转移 GC Roots 直接引用的对象。
6、并发转移:将重分配集中存活的对象复制到新的 Region 中,并为重分配集中每个 Region 维护了一个转发表 (Forward Table),用来记录对象从旧地址到新地址的对应关系。并发转移阶段 GC 线程和用户线程是并发执行的,若某个活跃对象被 GC 线程移动到新的 Region 中,但该对象的引用的地址还未更新,此时,若用户线程访问该对象,则会访问到旧地址上,这样就发生了错误。ZGC 通过转发表和内存屏障解决了这个问题。当用户线程访问某个处于转发表中的对象时,就会被预置的内存屏障截获,然后根据转发表中的对应关系,将访问转到该对象的新地址上,同时修改该引用的值(ZGC 将这种行为称为 指针自愈能力 Self Healing)。ZGC 又是如何知道对象是否处于转发表中的呢,这和引用的地址有关,且其是由 颜色指针 和 读屏障 实现的。
注:因为指针自愈能力的存在,所以被记录在转发表中的对象只有在第一次访问时才会较慢。且一旦重分配集中的某个 Region 中的存活对象都被转移了,那这个 Region 就可以立即释放用于新对象的分配,但这个转发表还不能释放,因为其记录的部分对象的还未被访问。
7、并发重映射:该阶段的作用就是修正指向被转发表记录的对象的引用。因为转发表中记录的对象都被移动了,所以需要更新对象的引用。该阶段和指针自愈做的是同一件事,区别是指针自愈是被动的,即只有当对象被访问时才会修正,而并发重映射是主动的。因为有指针自愈的存在,所以该阶段的优先级并不高,且因为该阶段也需要遍历所有对象,所以 ZGC 将其放在了下一次 GC 时的并发标记阶段(并发标记阶段需要遍历所有对象),这样就节省了一次遍历对象图的开销。记录在转发表中的对象的引用部分会在并发转移阶段被指针自愈修正,剩下的则会在下一次 GC 的并发标记阶段修正,所以在并发标记执行结束时,上一次 GC 所产生记录在转发表中的对象的引用都会被修正,转发表也会被释放,同时也意味着上一次 GC 需要清理的 Region 也会被释放。
如上图所示,为 ZGC 颜色指针示意图。历史版本的垃圾回收器都是将 GC 信息保存在对象头中,而 ZGC 则是将 GC 信息保存在对象的指针中。这是 ZGC 的核心技术之一,同时也是其低延迟的重要原因之一。
颜色指针共有 64 位,故 ZGC 只支持 64 位操作系统。其各位用途如下:
其中表示 GC 信息的三位 M0、M1、Remapped 是互斥的,即对象指针在某一时刻只能处于其中一个标记位,如要么是 100,要么是 010,要么是 001。实际的业务含义则为要么改对象第一次被标记,要么改对象第二次被标记,要么改对象已经完成重映射。
颜色指针在 ZGC 各阶段中的轮转:
G1 在大堆情况下停顿时间长是因为其筛选回收阶段是 STW 的,即其转移存活对象是 STW 的,所以其 STW 的时间受限要移动的对象的数量。究其原因是因为 G1 未能解决对象被移动后的重映射问题,所以其在移动对象期间需要 STW,确保移动期间无用户线程访问对象。
ZGC 整个过程会出现三次 STW,分别是初始标记、重新标记、初始转移。其中初始标记与初始转移与 GC Roots 数量有关,而 GC Roots 数量相比而言又是很少的,所以停顿时间很短。重新标记所涉及到的对象很少,所以也很快。所以在 ZGC 解决了 G1 的痛点后,整个过程的 STW 很短了,且还与堆的大小或存活对象的数量无关。这也是其在大堆、超大堆环境下依旧能保持低延迟的原因。
ZGC 的触发时机有以下几种:
基于预热规则:
jvm 启动预热,如果从来都没发生过 GC,那么会在堆使用超过 10%、20%、30% 时分别触发一次。日志中的关键字为 Warmup。
基于分配速率的自适用算法:
最主要的触发方式,同时也是默认触发方式。其算法原理是 ZGC 会根据近期对象的分配速率和 GC 时间来,计算出内存使用何时达到阈值,以确定下次 GC 的时间。可通过参数 ZAllocationSpikeTolerance 来控制阈值大小,默认为 2,值越大越早触发 GC。日志中的关键字为 Allocation Rate。
基于固定时间间隔:
会在指定时间内触发。可通过参数 ZCollectionInterval 来设置时间间隔。
主动触发:
类似于基于固定时间间隔规则,但间隔时间不固定,是 ZGC 通过一定规则计算得出。如果已经设置基于固定时间间隔规则,那么可以通过参数 ZProactive 将主动触发关闭,以免 GC 频繁,影响服务可用性。
阻塞内存分配请求触发:
当垃圾来不及回收,堆内存快要占满时,部分线程会因此阻塞,此时会触发。但在实际使用当中要尽量避免这种情况的发生。日志中的关键字为 Allocation Stall。
显示触发:
以编程方式显示调用 System.gc() 触发。因为 ZGC 会统计垃圾回收信息从而调节垃圾回收过程,所以通过显示触发可能会导致部分功能不可用。G1 也是一样。日志中的关键字为 System.gc()。
元空间分配触发:
元空间内存不足时会触发,但一般无需关注。日志中的关键字为 Metadata GC Threshold。
ZGC 是在 G1 存在大堆情况下延迟升高的背景下出现的,二者之间的区别是:
内存模型:
G1:堆内存分区,堆内存会被划分为大小相等(Region 大小为 2 的幂次方 MB)的区域,且存在分代概念。
ZGC:堆内存分区,堆内存被划分为小、中、大三种类型的区域,不存在分代概念。
算法类型:
二者都在整体上采用标记整理算法,各个 Region 之间采用标记复制算法。
转移(复制)阶段:
G1:STW 转移。STW 时间取决于存活对象的多少。
ZGC:并发转移。这是 ZGC 低延迟的主要原因。
标记方式:
G1:标记对象头,即在标记过程中,会将 GC 信息标记在对象头中,因此会访问对象,内存访问。
ZGC:标记指针,即在标记过程中,通过更新颜色指针来表示 GC 信息,不会访问对象,且访问指针访问的是寄存器,要比访问内存更快。
STW:
G1:G1 中的 STW 时间主要来自于初始标记、重新标记、筛选、回收阶段的回收中。其中初始标记阶段的 STW 与 GC Roots 数量成正比,重新标记阶段只发生变化的对象有关,筛选阶段只是计算待回收 Region 的回收成本,回收阶段涉及到对象转移,会比较耗时。所以 G1 的 STW 时间与存活对象的数量和其复杂度成正比。
ZCG:ZGC 中的 STW 时间主要来自于初始标记、重新标记、初始转移阶段。其中初始标记、初始转移阶段的 STW 时间与 GC Roots 数量成正比,重新标记阶段的 STW 时间最多 1ms,超过 1ms 则进入并发标记阶段。所以 ZGC 中的 STW 时间与 GC Roots 数量成正比。不会随着堆大小或存活对象的数量而增加。
关键技术:
G1:三色标记法、卡表、记录表、读屏障、原始快照。
ZGC:颜色指针、读屏障。
不同的垃圾收集器在针对不同区域所发生的 GC 并不相同,但总得来说分为两中,分别是 Minor GC 和 Full GC。
SerialGC:
新生代空间不足发生 Minor GC。
老年代空间不足发生 Full GC。
ParallelGC:
新生代空间不足发生 Minor GC。
老年代空间不足发生 Full GC。
CMS:
新生代空间不足发生 Minor GC。
老年代空间不足,若垃圾产生的速度超过 CMS 垃圾收集的速度时,则会终止 CMS,选择 Full GC。
G1:
新生代空间不足发生 Minor GC。
老年代空间不足,若垃圾生产的速度超过 G1 垃圾收集的速度时,则会终止 G1,选择 Full GC。
除直接调用 System.gc() 之外,触发 Full GC 执行的情况话有以下四种:
内存管理最终要解决的也就是内存分配和内存回收这两个问题。
对于内存分配,通常是在 jvm 堆上分配的(随着虚拟机技术的发展,某些场景下也会在栈上进行分配),对象主要分配在堆内存的新生代的 Eden 区,少数会分配在来年代,如果开启了本地线程缓冲,则会按照线程,优先在 TLAB 上分配。总的来说,内存分配也不是百分百固定的,其细节取决于哪些垃圾收集器组合以及和虚拟机相关参数有关,尽管如此,但还是会遵守以下几种规则:
可以将调优目标、调优基础、调优领域称为 jvm 调优三要素(PS:当然是我自己这么叫的):
调优目标:
一般情况下会将 低延迟、高吞吐量 作为 jvm 调优的目的。
低延迟的垃圾收集器有 CMS、G1、ZGC,高吞吐量的垃圾收集器有 ParallelGC,这些都是 HotSpot 虚拟机相关的垃圾收集器。
还有一个貌似贼牛逼的 java 虚拟机 Zing,说是其 GC 可以达到 0 延迟、高吞吐量。
调优基础:
首先,你得了解相关的调优 jvm 参数,如堆大小调整、GC 设置相关的参数;
其次,你得了解如果知道程序当前的状态,如堆栈情况、GC 情况等等,可以通过 jmap、jconsole、jvisualvm 等工具来查看;
最后,调优跟应用程序代码、环境有关,需要通过剖析所有一切信息,准确定位后再作出响应调整。
调优领域:
调优通常调 内存、锁、CPU占用、io 等。
总之,最好不要让程序触发 GC,言外之意,尽可能少的制造垃圾(手动滑稽)。
常用的调优工具分为两类,一类是 jdk 自带的,如位于 jdk bin 目录下的 jconsole 和 jvisualvm;第二类是第三方工具,如 MAT、GChisto。
首先针对新生代的调优,得先了解新生代的特点:
根据新生代的特点,新生代调优时一般增大新生代的大小,但并不是越大越好,Oracle 官方建议新生代大小一般占堆空间的 四分之一 到 二分之一之间。同时,对追求吞吐量的服务来说,新生代空间小增一波吞吐量会明显提高,但增幅过大,吞吐量会有所下降。总的来说,可以下规则来调:
老年代嘛,空间越大越好,同时可通过参数设置 Full GC 的触发时机,让其提前发生,通过增加 Full GC 的频率来降低单次 Full GC 的耗时(当然是我自己设想的,可行度还有待商榷)。当然,也可通过设置老年代已用空间的占比,来设置 Full GC 的触发时机。
jdk 自带的监控和故障命令有 jps、jstat、jmap、jhat、jstack、jinfo。
### 堆栈配置相关 ###
-Xms300m # 初始化堆大小为 300m
-Xmx2g # 堆内存最大为 2g
-Xmn400m # 新生代大小为 400m
-Xss128k # 每个线程的堆栈大小为 128k
-XX:MaxPermSize=16m # 永久代大小为 16m
-XX:NewRatio=4 # 新生代与老年代比例为 1:4
-XX:SurvivorRatio=8 # 新生代的 Eden 区与 Survivor 区比例为 8:2
-XX:MaxTenuringThreshold=15 # 对象从新生代晋身到老年代的最大年龄 若超过 15 则进入老年代 若为 0 则对象不进入 survivor 区 直接进入老年代
### 垃圾回收相关 ###
-XX:+UseParNewGC # 指定新生代使用 ParNew 垃圾收集器
-XX:+UseParallelGC # 指定使用并行垃圾收集器
-XX:ParallelGCThreads=10 # 设计并行垃圾收集器的线程数
-XX:+UseConcMarkSweepGC # 指定使用 CMS 垃圾收集器
-XX:CMSFullGCBeforeCompaction=5 # 执行 5 次 CMS GC 后对内存碎片进行整理压缩
-XX:+UseCMSCompactionAtFullGCCollection # 开启对老年代内存碎片的压缩 可能会影响性能
### 打印 gc 相关 ###
-XX:+PrintGC # 打印 gc 信息
-XX:+PrintGCDetails # 打印 gc 详细信息
栈内存溢出诊断和堆内存溢出诊断。
栈内存溢出诊断:
当某个 java 程序占用 cpu 资源过高时,如何定位到具体的代码中?
1、top:使用 top 命令获取到 cpu 占用过高的 java 进程号。
2、ps:使用 ps 命令可以获取到占用 cpu 过高的 java 线程号。
加 H 参数可以打印相关信息;
加 -eo 参数可以打印指定的数据项,如 -eo pid, tid, %cpu 则表示打印出进程 id、线程 id、线程对 cpu 的占用情况;
加 grep 参数参数可以过滤输出结果,如 grep 1225 则表示只打印进程 1225 相关的信息。
如 ps H -eo pid,tid,%cpu | grep 1225 表示打印出进程 1225 中所有线程的 pid、tid、%cpu 这三项信息。
3、jstack pid:使用 jstack pid 命令可以查看指定进程的所有线程的详细信息。
结果中包含了线程名、线程号、线程状态、代码行号以及死锁相关的数据等等。(注:此结果中的线程号为十六进制,第二步获取到的线程号为十进制)。
通过以上步骤则可从 cpu 占用过高的 java 程序定位到具体的源码中。
堆内存溢出诊断:
1、jps: 查看系统中所有的 java 进程号。
2、jmap: 查看指定 java 进程某一时刻的堆内存信息。可通过 -heap pid 指定进程号。
3、jconsole: 查看指定 java 进程的实时信息,包括堆内存、类加载、cpu 占用率、线程等信息。其为图形界面工具、可实时检测、且是多功能的。
4、jvisualvm: jvm 可视化工具。它它娘的啥都能看,可实时检测 jvm 的运行情况。
可通过 堆dump 抓取某一时刻堆内存的详细信息,包括对象、对象数量等等。
@XGLLHZ - 陈奕迅 - 《明年今日》.mp3