满满的一整篇,全是 JVM 核心知识点!
想要提高程序员自身的内功心法无非就是数据结构跟算法 + 操作系统 + 计网 + 底层,而所有的 Java 代码都是在 JVM 上运行的,了解了 JVM 好处就是:
写出更好更健壮的代码。
提高 Java 的性能,排除问题。
面试必问,要对知识有一定对深度。
简述JVM 内存模型
从宏观上来说 JVM 内存区域 分为三部分线程共享区域、线程私有区域、直接内存区域。
1.1、线程共享区域
堆区
堆区 Heap 是 JVM 中最大的一块内存区域,基本上所有的对象实例都是在堆上分配空间。堆区细分为年轻代和老年代,其中年轻代又分为 Eden、S0、S1 三个部分,他们默认的比例是 8:1:1 的大小。
方法区:
在 《Java 虚拟机规范》中只是规定了有方法区这么个概念跟它的作用。HotSpot 在 JDK8 之前 搞了个永久代把这个概念实现了。用来主要存储类信息、常量池、静态变量、JIT 编译后的代码等数据。
PermGen(永久代)中类的元数据信息在每次 FullGC 的时候可能会被收集,但成绩很难令人满意。而且为 PermGen 分配多大的空间因为存储上述多种数据很难确定大小。因此官方在 JDK8 剔除移除永久代。
官方解释移除永久代:
This is part of the JRockit and Hotspot convergence effort. JRockit customers do not need to configure the permanent generation (since JRockit does not have a permanent generation) and are accustomed to not configuring the permanent generation.
即:移除永久代是为融合 HotSpot JVM 与 JRockit VM 而做出的努力,因为 JRockit 没有永久代,不需要配置永久代。
元空间:
在 Java 中用永久代来存储类信息,常量,静态变量等数据不是好办法,因为这样很容易造成内存溢出。同时对永久代的性能调优也很困难,因此在 JDK8 中 把永久代去除了,引入了元空间 metaspace,原先的 class、field 等变量放入到 metaspace。
总结:
元空间的本质和永久代类似,都是对 JVM 规范中方法区的实现。不过元空间与永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存。因此,默认情况下,元空间的大小仅受本地内存限制,但可以通过参数来指定元空间的大小。
1.2、直接内存区域
直接内存:
一般使用 Native 函数操作 C++代码来实现直接分配堆外内存,不是虚拟机运行时数据区的一部分,也不是 Java 虚拟机规范中定义的内存区域。这块内存不受 Java 堆空间大小的限制,但是受本机总内存大小限制所以也会出现 OOM 异常。分配空间后避免了在 Java 堆区跟 Native 堆中来回复制数据,可以有效提高读写效率,但它的创建、销毁却比普通 Buffer 慢。
PS:如果使用了 NIO,本地内存区域会被频繁的使用,此时 jvm 内存 ≈ 方法区 + 堆 + 栈+ 直接内存
1.3、线程私有区域
程序计数器、虚拟机栈、本地方法栈跟线程的声明周期是一样的。
程序计数器
课堂上比如你正在看小说《诛仙》,看到 1412 章节时,老师喊你回答问题,这个时候你肯定要先应付老师的问题,回答完毕后继续接着看,这个时候你可以用书签也可以凭借记忆记住自己在看的位置,通过这样实现继续阅读。
线程私有:多线程情况下,在同一时刻所以为了让线程切换后依然能恢复到原位,每条线程都需要有各自独立的程序计数器。
没有规定 OutOfMemoryError:程序计数器存储的是字节码文件的行号,而这个范围是可知晓的,在一开始分配内存时就可以分配一个绝对不会溢出的内存。
执行Native方法时值为空:Native 方法大多是通过 C 实现,并未编译成需要执行的字节码指令,也就不需要去存储字节码文件的行号了。
虚拟机栈
方法的出入栈:调用的方法会被打包成栈桢,一个栈桢至少需要包含一个局部变量表、操作数栈、桢数据区、动态链接。
动态链接:
当栈帧内部包含一个指向运行时常量池引用前提下,类加载时候会进行符号引用到直接引用的解析跟链接替换。
局部变量表:
局部变量表是栈帧重要组中部分之一。他主要保存函数的参数以及局部的变量信息。局部变量表中的变量作用域是当前调用的函数。函数调用结束后,随着函数栈帧的销毁。局部变量表也会随之销毁,释放空间。
操作数栈:保存着Java虚拟机执行过程中数据
比如执行简单加减法:
public class ShowByteCode {
private String xx;
private static final int TEST = 1;
public ShowByteCode() {
}
public int calc() {
int a = 100;
int b = 200;
int c = 300;
return (a + b) * c;
}
}
执行javap -c *.class:
本地方法栈
跟虚拟机栈类似,只是为使用到的Native方法服务而已。
判断对象是否存活
JVM 空间不够就需要 Garbage Collection 了。
一般共享区的都要被回收比如堆区以及方法区。在进行内存回收之前要做的事情就是判断那些对象是死的,哪些是活的。常用方法有两种引用计数法跟可达性分析。
2.1、引用计数法
思路是给 Java 对象添加一个引用计数器,每当有一个地方引用它时,计数器 +1;引用失效则 -1,当计数器不为 0 时,判断该对象存活;否则判断为死亡(计数器 = 0)。
优点:实现简单,判断高效。
缺点:无法解决对象间相互循环引用的问题
class GcObject {
public Object instance = null;
}
public class GcDemo {
public static void main(String[] args) {
GcObject object1 = new GcObject(); // step 1
GcObject object2 = new GcObject(); // step 2
object1.instance = object2 ;//step 3
object2.instance = object1; //step 4
object1 = null; //step 5
object2 = null; // step 6
}
}
step1: GcObject实例1的引用计数+1,实例1引用数 = 1
step2: GcObject实例2的引用计数+1,实例2引用数 = 1
step3: GcObject实例2的引用计数+1,实例2引用数 = 2
step4: GcObject实例1的引用计数+1,实例1引用数 = 2
step5: GcObject实例1的引用计数-1,结果为 1
step6: GcObject实例2的引用计数-1,结果为 1
如上分析发现实例 1 跟实例 2 的引用数都不为 0 而又相互引用,这两个实例所占有的内存则无法释放。
2.2、可达性分析
很多主流商用语言(如 Java、C#)都采用引用链法判断对象是否存活,大致的思路就是将一系列的 GC Roots 对象作为起点,从这些起点开始向下搜索。
在 Java 语言中,可作为 GC Roots 的对象包含以下几种:
第二种是我们在类中定义了全局的静态的对象,也就是使用了 static 关键字,由于虚拟机栈是线程私有的,所以这种对象的引用会保存在共有的方法区中,显然将方法区中的静态引用作为 GC Roots 是必须的。
第三种便是常量引用,就是使用了 static final 关键字,由于这种引用初始化之后不会修改,所以方法区常量池里的引用的对象也应该作为 GC Roots。
-
第四种是在使用 JNI 技术时,有时候单纯的 Java 代码并不能满足我们的需求,我们可能需要在 Java 中调用 C 或 C++的代码,因此会使用 Native 方法,JVM 内存中专门有一块本地方法栈,用来保存这些对象的引用,所以本地方法栈中引用的对象也会被作为 GC Roots。
GC Root 步骤主要包含如下三步:
可达性分析
当一个对象到 GC Roots 没有任何引用链相连时,则判断该对象不可达。
注意: 可达性分析仅仅只是判断对象是否可达,但还不足以判断对象是否存活 / 死亡。
第一次标记 & 筛选
筛选的条件对象 如果没有重写 finalize 或者调用过 finalize,则将该对象加入到 F-Queue 中
第二次标记 & 筛选
当对象经过了第一次的标记 & 筛选,会被进行第二次标记 & 准备被进行筛选。经过 F-Queue 筛选后如果对象还没有跟 GC Root 建立引用关系则被回收,属于给个二次机会。
2.3、四大引用类型
强引用
强引用(StrongReference)是使用最普遍的引用。垃圾回收器绝对不会回收它,内存不足时宁愿抛出 OOM 导致程序异常,平常的 new 对象就是。
软引用
垃圾回收器在内存充足时不会回收软引用(SoftReference)对象,不足时会回收它,特别适合用于创建缓存。
弱引用
弱引用(WeakReference)是在扫描到该对象时无论内存是否充足都会回收该对象。ThreadLocal 的 Key 就是弱引用。
虚引用
如果一个对象只具有虚引用(PhantomReference)那么跟没有任何引用一样,任何适合都可以被回收。主要用跟踪对象跟垃圾回收器回收的活动。
垃圾回收算法
为了挥手回收垃圾操作系统一般会使用标记清除、复制算法、标记整理三种算法,这三种各有优劣。简单介绍下:
3.1、标记清除
原理:算法分为标记和清除两个阶段:首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。
缺点:标记清除之后会产生大量不连续的内存碎片,导致触发 GC。
3.2、标记复制
原理:将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。
缺点:这种算法的代价是将内存缩小为了原来的一半,还要来回移动数据。
3.3、标记整理
原理:首先标记出所有需要回收的对象,在标记完成后,后续步骤是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。
缺点:涉及到移动大量对象,效率不高。
总结:
指标 |
标记清理 |
标记整理 | 标记复制 |
速度 |
中等 |
最慢 |
快 |
空间开销 |
少(但会堆积碎片) |
少(不堆积碎片) |
通常需要活对象的2倍大小(不堆积碎片) |
移动对象 |
否 |
是 |
是 |
3.4 、三色标记跟读写屏障
前面说的三种回收算法都说到了先标记,问题是如何标记的呢?
接下来的知识点个人感觉面试应该问不到那么深了,但是为了装逼必须 Mark下!
CMS、G1 标记时候一般用的是三色标记法,根据可达性分析从 GC Roots 开始进行遍历访问,可达的则为存活对象,而最终不可达说明就是需要被 GC 对象。
大致流程是把遍历对象图过程中遇到的对象,按是否访问过这个条件标记成以下三种颜色:
白色:尚未访问过。
黑色:本对象已访问过,而且本对象引用到的其他对象,也全部访问过了。
灰色:本对象已访问过,但是本对象引用到的其他对象,尚未全部访问完。全部访问后会转换为黑色。
假设现在有白、灰、黑三个集合(表示当前对象的颜色),遍历访问过程:
初始时所有对象都在白色集合中。
将 GC Roots 直接引用到的对象挪到灰色集合中。
从灰色集合中获取对象:第一步将本对象引用到的其他对象,全部挪到灰色集合中,第二步将本对象挪到黑色集合里面。
重复步骤3,直至灰色集合为空时结束。
结束后仍在白色集合的对象即为 GC Roots 不可达,可以尝试进行回收。
当 STW 时,对象间的引用是不会发生变化的,可以轻松完成标记。当支持并发标记时,对象间的引用可能发生变化,多标和漏标的情况就有可能发生。
浮动垃圾
状况:GC 线程遍历到 E(E是灰色),一个业务线程执行了 D.E = null,此时 E 应该被回收的。但是 GC 线程已经认为 E 是灰色了会继续遍历,导致 E 没有被回收。
漏标
GC 线程遍历到 E(灰色了)。业务线程执行了 E-->G 断开,D-->G 链接的操作。GC 线程发现 E 无法到达 G,因为是黑色不会再遍历标记了。最终导致漏标 G。
漏标的必备两个条件:灰到白断开,黑到白建立。
Object G = E.G; // 第一步 :读
Object E.G = null; // 第二步:写
Object D.G = G; // 第三步:写
漏标解决方法:将对象 G 存储到特定集合中,等并发标记遍历完毕后再对集合中对象进行重新标记。
CMS方案
这里比如开始 B 指向 C,但是后来 B 不指向 C,A 指向 D,最简单的方法是将 A 变成灰色,等待下次进行再次遍历。CMS 中可能引发 ABA 问题:
回收线程 m1 正在标记 A,属性 A.1 标记完毕,正在标记属性 A.2。
业务线程 m2 把属性 1 指向了 C,由于 CMS 方案此时回收线程 m3 把 A 标记位灰色。
回收线程 m1 认为所有属性标记完毕,将 A 设置为黑色,结果 C 漏标。所以 CMS 阶段需要重新标记。
读写屏障
漏标的实现是有三步的,JVM 加入了读写屏障,其中读屏障则是拦截第一步,写屏障用于拦截第二和第三步。
写屏障 + SATB(原始快照) 来破坏 灰到白断开。
写屏障 + 增量更新 来破坏 黑到白建立。
读屏障 一种保守方式来破坏灰到白断开后白的存储,此时用读屏障 OK 的。
现代使用可达性分析的垃圾回收器几乎都借鉴了三色标记的算法思想,尽管实现的方式不尽相同。
对于读写屏障,以 Java HotSpot VM 为例,其并发标记时对漏标的处理方案如下:
CMS:写屏障 + 增量更新
G1:写屏障 + SATB
ZGC:读屏障
CMS 中使用的增量更新,在重新标记阶段除了需要遍历 写屏障的记录,还需要重新扫描遍历 GC Roots(标记过的不用再标记),这是由于 CMS 对于 astore_x 等指令不添加写屏障的原因。
GC 流程
核心思想就是根据各个年代的特点不同选用不同到垃圾收集算法。
年轻代:使用复制算法
老年代:使用标记整理或者标记清除算法。
为什么要有年轻代:分代的好处就是优化 GC 性能。如果没有分代每次扫描所有区域能累死 GC。因为很多对象几乎就是朝生夕死的,如果分代的话,我们把新创建的对象放到某一地方,当 GC 的时候先把这块存朝生夕死(80%以上)对象的区域进行回收,这样就会腾出很大的空间出来。
4.1、年轻代
HotSpot JVM 把年轻代分为了三部分:1 个 Eden 区和 2 个 Survivor 区(分别叫 from 和 to)。默认比例为 8:1:1。
一般情况下,新创建的对象都会被分配到 Eden 区(一些大对象特殊处理),这些对象经过第一次 Minor GC 后,如果仍然存活,将会被移到 Survivor 区。对象在 Survivor 区中每熬过一次 Minor GC 年龄就会增加 1 岁,当它的年龄增加到一定次数(默认 15 次)时,就会被移动到年老代中。年轻代的垃圾回收算法使用的是复制算法。
年轻代 GC 过程:GC 开始前,年轻代对象只会存在于 Eden 区和名为 From 的 Survivor 区,名为 To 的 Survivor 区永远是空的。如果新分配对象在 Eden 申请空间发现不足就会导致 GC。
yang GC:Eden 区中所有存活的对象都会被复制到 To,而在 From 区中,仍存活的对象会根据他们的年龄值来决定去向。年龄达到一定值(年龄阈值可以通过-XX:MaxTenuringThreshold 来设置)的对象会被移动到年老代中,没有达到阈值的对象会被复制到 To 区域。经过这次 GC 后,Eden 区和 From 区已经被清空。这个时候,From 和 To 会交换他们的角色,也就是新的 To 就是上次 GC 前的 From,新的 From 就是上次 GC 前的 To。
不管怎样都会保证名为 To 的 Survivor 区域是空的。Minor GC 会一直重复这样的过程,直到 To 区被填满,To 区被填满之后,会将所有对象移动到年老代中。
这里注意如果 yang GC 后空间还是不够用则会空间担保机制将数据送到 Old 区
卡表 Card Table:
为了支持高频率的新生代回收,虚拟机使用一种叫做卡表(Card Table)的数据结构,卡表作为一个比特位的集合,每一个比特位可以用来表示年老代的某一区域中的所有对象是否持有新生代对象的引用。
新生代 GC 时不用花大量的时间扫描所有年老代对象,来确定每一个对象的引用关系,先扫描卡表,只有卡表的标记位为1时,才需要扫描给定区域的年老代对象。而卡表位为 0 的所在区域的年老代对象,一定不包含有对新生代的引用。
4.2、老年代
老年代 GC 过程:老年代中存放的对象是存活了很久的,年龄大于 15 的对象 或者触发了老年代的分配担保机制存储的大对象。在老年代触发的 gc 叫 major gc ,也叫 full gc。full gc 会包含年轻代的 gc。full gc 采用的是标记-清除或标记整理。在执行 full gc 的情况下,会阻塞程序的正常运行。老年代的 gc 比年轻代的 gc 效率上慢 10 倍以上。对效率有很大的影响。所以一定要尽量避免老年代 GC!
4.3、元空间
永久代的回收会随着 full gc 进行移动,消耗性能。
每种类型的垃圾回收都需要特殊处理元数据。将元数据剥离出来,简化了垃圾收集,提高了效率。
-XX:MetaspaceSize 初始空间的大小。达到该值就会触发垃圾收集进行类型卸载,同时 GC 会对该值进行调整:
如果释放了大量的空间,就适当降低该值;如果释放了很少的空间,那么在不超过 MaxMetaspaceSize 时,适当提高该值。
-XX:MaxMetaspaceSize:最大空间,默认是没有限制的。
4.4 、垃圾回收流程总结
大致的 GC 回收流程如上图,还有一种设置就是大对象直接进入老年代:
如果在新生代分配失败且对象是一个不含任何对象引用的大数组,可被直接分配到老年代。通过在老年代的分配避免新生代的一次垃圾回收。
设置了-XX:PretenureSizeThreshold 值,任何比这个值大的对象都不会尝试在新生代分配,将在老年代分配内存。
内存回收跟分配策略
优先在 Eden 上分配对象,此区域垃圾回收频繁速度还快。
大对象直接进入老生代。
年长者(长期存活对象默认 15 次)跟 进入老生代。
在 Survivor 空间中相同年龄所有对象大小的总和大于 Survivor 空间的一半,年龄大于或等于该年龄的对象会群体进入老生代。
空间分配担保(担保 minorGC),如果 Minor GC 后 Survivor 区放不下新生代仍存活的对象,把 Suvivor 无法容纳的对象直接进人老年代。
垃圾收集器
5.1、垃圾收集器
堆 heap 是垃圾回收机制的重点区域。
我们知道垃圾回收机制有三种 minor gc、major gc 和 full gc。针对于堆的就是前两种。年轻代的叫 minor gc,老年代的叫 major gc。
JDK7、JDK8 默认垃圾收集器 Parallel Scavenge(新生代)+ Parallel Old(老年代)
JDK9 默认垃圾收集器 G1
服务端开发常见组合就是 ParNew + CMS
工程化使用的时候使用指定的垃圾收集器组合使用,讲解垃圾收集器前先普及几个重要知识点:
STW
java 中 Stop-The-World 机制简称 STW。是指执行垃圾收集算法时,Java 应用程序的其他所有线程都被挂起(除了垃圾收集帮助器之外)。是 Java 中一种全局暂停现象。全局停顿,所有 Java 代码停止,native 代码虽然可以执行但不能与 JVM 交互,如果发生了 STW 现象多半是由于 gc 引起。
吞吐量
吞吐量 = 运行用户代码时间 / ( 运行用户代码时间 + 垃圾收集时间 )。例如:虚拟机共运行 100 分钟,垃圾收集器花掉 1 分钟,那么吞吐量就是 99%
垃圾收集时间:垃圾回收频率 * 单次垃圾回收时间
并行收集:指多条垃圾收集线程并行工作,但此时用户线程仍处于等待状态。
并发收集:指用户线程与垃圾收集线程同时工作(不一定是并行的可能会交替执行)。用户程序在继续运行,而垃圾收集程序运行在另一个CPU上。
5.2、新生代
新生代有 Serial、ParNew、Parallel Scavenge 三种垃圾收集器。
名称 |
串行/并行/并发 |
回收算法 |
使用场景 | 可以跟 CMC 配合 |
Serial |
串行 |
复制 |
单CPU,Client模式下虚拟机 |
是 |
ParNew |
并行(Serial的并行版) |
复制 |
多CPU,常在Server模式 |
是 |
Parallel Scavenge |
并行 |
复制 |
多CPU且关注吞吐量 |
否 |
5.3、老年代
老年代有 Serial Old、Parallel Old、CMS 三种垃圾收集器。
名称 |
串行/并行/并行 |
回收算法 |
使用场景 |
组合年轻代 |
Serial Old |
串行 |
标记整理 |
单CPU |
Serial 、ParNew、Parallel Scavenge |
Parallel Old |
并行 |
标记整理 |
多CPU |
Parallel Scavenge |
CMS |
并发 |
标记清除 |
多CPU且关注吞吐量,常用 Server 端 |
Serial 、ParNew |
CMS
CMS(Concurrent Mark Sweep)比较重要这里 重点说一下。
CMS 的初衷和目的:为了消除 Throught 收集器和 Serial 收集器在 Full GC 周期中的长时间停顿。是一种以获取最短回收停顿时间为目标的收集器,具有自适应调整策略,适合互联网站 跟 B/S 服务应用。
CMS 的适用场景:如果你的应用需要更快的响应,不希望有长时间的停顿,同时你的 CPU 资源也比较丰富,就适合适用 CMS 收集器。比如常见的 Server 端任务。
优点:并发收集、低停顿。
缺点:
CMS 收集器对 CPU 资源非常敏感:在并发阶段,虽然不会导致用户线程停顿,但是会占用 CPU 资源而导致引用程序变慢,总吞吐量下降。
无法处理浮动垃圾:由于 CMS 并发清理阶段用户线程还在运行,伴随程序的运行自热会有新的垃圾不断产生,这一部分垃圾出现在标记过程之后, CMS 无法在本次收集中处理它们,只好留待下一次 GC 时将其清理掉。这一部分垃圾称为浮动垃圾。如果内存放不下浮动垃圾这时 JVM 启动 Serial Old 替代 CMS。
空间碎片:CMS 是基于标记-清除算法实现的收集器,使用标记-清除算法收集后,会产生大量碎片。
CMS 回收流程:
初始标记:引发 STW, 仅仅只是标记出 GC ROOTS 能直接关联到的对象,速度很快。
并发标记:不引发 STW,正常运行所有 Old 对象是否可链到 GC Roots
重新标记:引发 STW,为了修正并发标记期间,因用户程序继续运作而导致标记产生改变的标记。这个阶段的停顿时间会被初始标记阶段稍长,但比并发标记阶段要短。
并发清除:不引发 STW,正常运行,标记清除算法来清理删除掉标记阶段判断的已经死亡的对象。
总结:
5.4、G1
之前的 GC 收集器对 Heap 的划分:
以前垃圾回收器是新生代 + 老年代,用了 CMS 效果也不是很好,为了减少 STW 对系统的影响引入了 G1(Garbage-First Garbage Collector),G1 是一款面向服务端应用的垃圾收集器,具有如下特点:
并行与并发:G1 能充分利用多 CPU、多核环境下的硬件优势,可以通过并发的方式让 Java 程序继续执行。
分代收集:分代概念在 G1 中依然得以保留,它能够采用不同的方式去处理新创建的对象和已经存活了一段时间、熬过多次 GC 的旧对象来获得更好的收集效果。
空间整合:G1 从整体上看是基于标记-整理算法实现的,从局部(两个 Region 之间)上看是基于复制算法实现的,G1 运行期间不会产生内存空间碎片。
可预测停顿:G1 比 CMS 牛在能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为 M 毫秒的时间片段内,消耗在垃圾收集上的时间不得超过 N 毫秒。
G1 作为 JDK9 之后的服务端默认收集器,不再区分年轻代和老年代进行垃圾回收。
G1 默认把堆内存分为 N 个分区,每个 1~32M(总是 2 的幂次方)。并且提供了四种不同 Region 标签 Eden、Survivor 、Old、 Humongous。
H 区可以认为是 Old 区中一种特列专门用来存储大数据的,关于 H 区数据存储类型一般符合下面条件:
当 0.5 Region <= 当对象大小 <= 1 Region 时候将数据存储到 H 区
当对象大小 > 1 Region 存储到连续的 H 区。
同时 G1 中引入了 RememberSets、CollectionSets 帮助更好的执行 GC 。
RememberSets:RSet 记录了其他 Region 中的对象引用本 Region 中对象的关系,属于 points-into 结构(谁引用了我的对象)
CollectionSets:Csets 是一次 GC 中需要被清理的 regions 集合,注意 G1 每次 GC 不是全部 region 都参与的,可能只清理少数几个,这几个就被叫做 Csets。在 GC 的时候,对于 old -> young 和 old -> old 的跨代对象引用,只要扫描对应的 CSet 中的 RSet 即可。
G1 进行 GC 的时候一般分为 Yang GC 跟 Mixed GC。
Young GC:CSet 就是所有年轻代里面的 Region
Mixed GC:CSet 是所有年轻代里的 Region 加上在全局并发标记阶段标记出来的收益高的 Region
Yang GC
标准的年轻代 GC 算法,整体思路跟 CMS 中类似。
Mixed GC
G1 中是没有 Old GC 的,有一个把老年代跟新生代同时 GC 的 Mixed GC,它的回收流程:
初始标记:是 STW 事件,其完成工作是标记 GC ROOTS 直接可达的对象。标记位 RootRegion。
根区域扫描 :不是 STW 事件,拿来 RootRegion,扫描整个 Old 区所有 Region,看每个 Region 的 Rset 中是否有 RootRegion。有则标识出来。
并发标记 :同 CMS 并发标记不需要 STW,遍历范围减少,在此只需要遍历第二步被标记到引用老年代的对象 RSet。
最终标记 :同 CMS 重新标记会 STW ,用的 SATB 操作,速度更快。
清除 :STW 操作,用 复制清理算法,清点出有存活对象的 Region 和没有存活对象的 Region(Empty Region),更新 Rset。把 Empty Region收集起来到可分配 Region 队列。
回收总结:
经过 global concurrent marking,collector 就知道哪些 Region 有存活的对象。并将那些完全可回收的 Region(没有存活对象)收集起来加入到可分配 Region 队列,实现对该部分内存的回收。对于有存活对象的 Region,G1 会根据统计模型找处收益最高、开销不超过用户指定的上限的若干 Region 进行对象回收。这些选中被回收的 Region 组成的集合就叫做 collection set,简称 Cset!
在MIX GC 中的 Cset = 所有年轻代里的 region + 根据 global concurrent marking 统计得出收集收益高的若干 old region。
在 YGC 中的 Cset = 所有年轻代里的 region + 通过控制年轻代的region 个数来控制 young GC 的开销。
YGC 与 MIXGC 都是采用多线程复制清理,整个过程会 STW。G1 的低延迟原理在于其回收的区域变得精确并且范围变小了。
G1 提速点:
重新标记时 X 区域直接删除。
Rset 降低了扫描的范围,上题中两点。
重新标记阶段使用 SATB 速度比 CMS 快。
清理过程为选取部分存活率低的 Region 进行清理,不是全部,提高了清理的效率。
总结:
就像你妈让你把自己卧室打扫干净,你可能只把显眼而比较大的垃圾打扫了,犄角旮旯的你没打扫。关于 G1 还有很多细节其实没看到。
一句话总结 G1 思维:每次选择性的清理大部分垃圾来保证时效性跟系统的正常运行。
New 个对象
一个 Java 类从编码到最终完成执行,主要包括两个过程:编译、运行。
编译:将我们写好的.java 文件通过 Javac 命令编译成.class 文件。
运行:把编译生成的.class 文件交由 JVM 执行。
Jvm 运行 class 类的时候,并不是一次性将所有的类都加载到内存中,而是用到哪个就加载哪个,并且只加载一次。
6.1、类的生命周期
加载
加载指的是把 class 字节码文件从各个来源通过类加载器装载入内存中,这里有两个重点:
字节码来源:一般的加载来源包括从本地路径下编译生成的.class 文件,从 jar 包中的.class 文件,从远程网络,以及动态代理实时编译
类加载器:一般包括启动类加载器,扩展类加载器,应用类加载器,以及用户的自定义类加载器(加密解密那种)。
验证
主要是为了保证加载进来的字节流符合虚拟机规范,不会造成安全错误。文件格式验证、元数据验证、字节码验证、符号引用验证。
准备
给类静态变量分配内存空间,仅仅是分配空间,比如 public static int age = 14,在准备后 age = 0,在初始化阶段 age = 14,如果添加了 final 则在这个阶段直接赋值为14。
解析
将常量池内的符号引用替换为直接引用。
初始化
前面在加载类阶段用户应用程序可以通过自定义类加载器参与之外,其余动作完全由虚拟机主导和控制。此时才是真正开始执行类中定义的代码 :执行 static 代码块进行初始化,如果存在父类,先对父类进行初始化。
使用
类加载完毕后紧接着就是为对象分配内存空间和初始化了:
为对象分配合适大小的内存空间
为实例变量赋默认值
设置对象的头信息,对象 hash 码、GC 分代年龄、元数据信息等
执行构造函数(init)初始化。
卸载
最终没啥说等,就是通过 GC 算法回收对象了。
6.2、对象占据字节
关于对象头问题在 Synchronized 一文中已经详细写过了,一个对象头包含三部分对象头(MarkWord、classPointer)、实例数据Instance Data、对齐Padding,想看内存详细占用情况 IDEA 调用 jol-core 包即可。
问题一:new Object()占多少字节
markword 8字节 + classpointer 4 字节(默认用calssPointer压缩) + padding 4字节 = 16 字节
如果没开启 classpointer 压缩:markword 8 字节 + classpointer 8 字节 = 16 字节
问题二:User (int id,String name) User u = new User(1,"李四")
markword 8字节 + 开启classPointer压缩后classpointer 4 字节 + instance data int 4 字节 + 开启普通对象指针压缩后 String4 字节 + padding 4 = 24 字节
6.3、对象访问方式
Sun HotSpot 使用直接指针访问方式进行对象访问的。
对象一定创建在堆上吗
结论:不一定,看对象经过了逃逸分析后发现该变量只是用到方法区时,则 JVM 会自动优化,在栈上创建该对象。
7.1、逃逸分析
逃逸分析(Escape Analysis)简单来讲就是:Java Hotspot 虚拟机可以分析新创建对象的使用范围,并决定是否在 Java 堆上分配内存。
7.2、标量替换
标量替换:JVM 通过逃逸分析确定该对象不会被外部访问。那就通过将该对象标量替换分解在栈上分配内存,这样该对象所占用的内存空间就可以随栈帧出栈而销毁,就减轻了垃圾回收的压力。
标量:不可被进一步分解的量,而 JAVA 的基本数据类型就是标量
聚合量:在 JAVA 中对象就是可以被进一步分解的聚合量。
7.3、栈上分配
JVM 对象分配在堆中,当对象没有被引用时,依靠 GC 进行回收内存,如果对象数量较多会给 GC 带来较大压力,也间接影响了应用的性能。
为了减少临时对象在堆内分配的数量,JVM 通过逃逸分析确定该对象不会被外部访问。那就通过将该对象标量替换分解在栈上分配内存,这样该对象所占用的内存空间就可以随栈帧出栈而销毁,就减轻了垃圾回收的压力。
7.4、同步消除
同步消除是 java 虚拟机提供的一种优化技术。
通过逃逸分析,可以确定一个对象是否会被其他线程进行访问,如果对象没有出现线程逃逸,那该对象的读写就不会存在资源的竞争,不存在资源的竞争,则可以消除对该对象的同步锁。比如方法体内调用 StringBuffer。
逃逸分析结论:虽然经过逃逸分析可以做标量替换、栈上分配、和锁消除。但是逃逸分析自身也是需要进行一系列复杂的分析的,这其实也是一个相对耗时的过程。
如果对象经过层层分析后发现 无法进行逃逸分析优化则反而耗时了,因此慎用。
类加载器
在连接阶段一般是无法干预的,大部分干预类加载阶段,对于任意一个类,都需要由加载它的类加载器和这个类本身一同确立其在 Java 虚拟机中的唯一性,类加载时候重要三个方法:
loadClass() :加载目标类的入口,它首先会查找当前 ClassLoader 以及它的双亲里面是否已经加载了目标类,找到直接返回 。
findClass() :如果没有找到就会让双亲尝试加载,如果双亲都加载不了,就会调用 findClass() 让自定义加载器自己来加载目标类 。
defineClass() :拿到这个字节码之后再调用 defineClass() 方法将字节码转换成 Class 对象。
8.1、双亲委派机制
定义:当某个类加载器需要加载某个.class 文件时,首先把这个任务委托给他的上级类加载器,递归这个操作,如果上级的类加载器没有加载,自己才会去加载这个类。
作用:
可以防止重复加载同一个.class。通过委托去向上面问一问,加载过了,就不用再加载一遍。保证数据安全。
保证核心.class 不能被篡改,通过委托方式,不会去篡改核心.class。
类加载器:
BootstrapClassLoader(启动类加载器):c++编写,加载 java 核心库 java.*,JAVA_HOME/lib
ExtClassLoader (标准扩展类加载器):java 编写的加载扩展库,JAVA_HOME/lib/ext
AppClassLoader(系统类加载器):加载程序所在的目录,如 user.dir所在的位置的 ClassPath
CustomClassLoader(用户自定义类加载器):用户自定义的类加载器,可加载指定路径的 class 文件
8.2、关于加载机制
双亲委派机制只是 Java 类加载的一种常见模式,还有别的加载机制哦,比如Tomcat 总是先尝试去加载某个类,如果找不到再用上一级的加载器,跟双亲加载器顺序正好相反。再比如当使用第三方框架 JDBC 跟具体实现的时候,反而会引发错误,因为 JDK 自带的 JDBC 接口由启动类加载,而第三方实现接口由应用类加载。这样相互之间是不认识的,因此 JDK 引入了 SPI 机制,线程上下文加载器来实现加载(跟 Dubbo 的 SPI 不一样哦)。
OOM 、CPU100%
系统性能分析常用指令:
工具 |
用途 |
jps |
输出 JVM 中运行的进程状态信息 |
jstack |
生成虚拟机当前时刻的线程快照 |
jstat |
虚拟机统计信息监控工具 |
jinfo |
实时地查看和调整虚拟机各项参数 |
jmap |
生成虚拟机的内存转储快照,heapdump 文件 |
JConsole |
可视化管理工具,常用 |
9.1、OOM
为啥 OOM?:发生 OOM 简单来说可总结为两个原因:
分配给 JVM 的内存不够用。
分配内存够用,但代码写的不好,多余的内存没有释放,导致内存不够用。
三种类型OOM
堆内存溢出:此种情况最常见 Java heap space。一般是先通过内存映像工具对 Dump 出来的堆转储快照,然后辨别到底是内存泄漏还是内存溢出。
内存泄漏
通过工具查看泄漏对象到 GC Roots 的引用链。找到泄漏的对象是通过怎么样的路径与 GC Roots 相关联的导致垃圾回收机制无法将其回收,最终比较准确地定位泄漏代码的位置。
不存在泄漏
就是内存中的对象确实必须存活着,那么此时就需要通过虚拟机的堆参数,从代码上检查是否存在某些对象存活时间过长、持有时间过长的情况,尝试减少运行时内存的消耗。
虚拟机栈和本地方法栈溢出
在 HotSpot 虚拟机上不区分虚拟机栈和本地方法栈,因此栈容量只能由**-Xss**参数设定。在 Java 虚拟机规范中描述了两种异常:
StackOverflowError :线程请求的栈深度超过了虚拟机所允许的最大深度,就会抛出该异常。
OutOfMemoryError:虚拟机在拓展栈的时候无法申请到足够的空间,就会抛出该异常。
单线程环境下无论是由于栈帧太大还是虚拟机栈容量太小,当内存无法继续分配的时候,虚拟机抛出的都是 StackOverflowError 异常。
多线程环境下为每个线程的栈分配的内存越大,每个线程获得空间大则可建立的线程数减少了反而越容易产生 OOM 异常,因此,一般通过减少最大堆和减少栈容量来换取更多的线程数量。
永久代溢出:
PermGen space 即方法区溢出了。方法区用于存放 Class 的相关信息,如类名、访问修饰符、常量池、字段描述、方法描述等。当前的一些主流框架,如Spring、Hibernate,对于类进行增强的时候都会使用到 CGLib 这类字节码技术,增强的类越多,就需要越大的方法区来保证动态生成 Class 可以加载入内存,这样的情况下可能会造成方法区的 OOM 异常。
OOM 查看指令
通过命令查看对应的进程号:比如:jps 或者 ps -ef | grep 需要的任务
输入命令查看 gc 情况命令:jstat -gcutil 进程号刷新的毫秒数展示的记录数,比如:jstat -gcutil 1412 1000 10(查看进程号 1412,每隔 1 秒获取下,展示 10 条记录)
查看具体占用情况:命令:jmap -histo 进程号 | more(默认展示到控制台)
比如:jmap -histo 1412 | more 查看具体的classname,是否有开发人员的类,也可以输出到具体文件分析
9.3 CPU 100%
线上应用导致 CPU 占用 100%,出现这样问题一般情况下是代码进入了死循环,分析步骤如下:
找出对应服务进程 id:用 ps -ef | grep 运行的服务名字,直接 top 命令也可以看到各个进程 CPU 使用情况。
查询目标进程下所有线程的运行情况:top -Hp pid, -H 表示以线程的维度展示,默认以进程维度展示。
对目标线程进行 10 进制到 16 进制转换:printf ‘%x\n’ 线程 pid
用 jstack 进程 id | grep 16 进制线程 id 找到线程信息,具体分析:jstack 进程 ID | grep -A 20 16 进制线程 id
GC 调优
一般项目加个 xms 和 xmx 参数就够了。在没有全面监控、收集性能数据之前,调优就是瞎调。
出现了问题先看自身代码或者参数是否合理,毕竟不是谁都能写 JVM 底层代码的。一般要减少创建对象的数量,减少使用全局变量和大对象,GC 优化是到最后不得已才采用的手段。日常分析 GC 情况优化代码比优化 GC 参数要多得多。一般如下情况不用调优的:
minor GC 单次耗时 < 50ms,频率 10 秒以上。说明年轻代 OK。
Full GC 单次耗时 < 1 秒,频率 10 分钟以上,说明年老代 OK。
GC 调优目的:GC 时间够少,GC 次数够少。
调优建议:
-Xms5m 设置 JVM 初始堆为 5M,-Xmx5m 设置 JVM 最大堆为 5M。-Xms 跟-Xmx 值一样时可以避免每次垃圾回收完成后 JVM 重新分配内存。
-Xmn2g:设置年轻代大小为 2G,一般默认为整个堆区的 1/3 ~ 1/4。- Xss 每个线程栈空间设置。
-XX:SurvivorRatio,设置年轻代中 Eden 区与 Survivor 区的比值,默认=8,比值为 8:1:1。
-XX:+HeapDumpOnOutOfMemoryError 当 JVM 发生 OOM 时,自动生成 DUMP 文件。
-XX:PretenureSizeThreshold 当创建的对象超过指定大小时,直接把对象分配在老年代。
-XX:MaxTenuringThreshold 设定对象在 Survivor 区最大年龄阈值,超过阈值转移到老年代,默认 15。
开启 GC 日志对性能影响很小且能帮助我们定位问题,-XX:+PrintGCTimeStamps -XX:+PrintGCDetails -Xloggc:gc.log
更多精彩推荐
点分享 点点赞 点在看