一、Java 内存区域详解(重点)
本篇讨论的是 HotSpot 虚拟机
相比于 C++ 而言,程序员不需要对每个 new 操作都写对应的 delete / free 操作,这些操作我们会交给虚拟机去做。因此,如果不了解虚拟机的原理,一旦出现内存泄漏或者溢出方面的问题,排查错误将是一个非常艰巨的任务。
1、运行时数据区
Java 虚拟机在执行 Java 程序的时候将它管理的内存划分为若干个不同的数据区域。
JDK 1.8 和之前的版本略有不同,分为 JDK 1.7 和 JDK 1.8 来进行介绍。
JDK 1.7
JDK 1.8
线程私有的:
1、虚拟机栈
2、本地方法栈
3、程序计数器
线程共享的:
1、堆
2、方法区
3、直接内存(非运行时数据区的一部分)
程序计数器
程序计数器是一块较小的内存空间,可以看作是当前线程所执行到的指令的行号。字节码解释器工作时需要通过程序计数器来选取下一条需要执行的字节码指令。
为了在线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各线程都需要有一个独立的程序计数器,各计数器之间互不影响,独立存储。(所以它是线程私有的)
作用:
1、通过改变程序计数器来依次读取指令,完成流程控制。
2、线程切换时,记录线程现在运行的位置,切换回来的时候知道上次运行到哪儿了。
程序计数器的生命周期随着线程的创建而创建,随着线程的结束而死亡。
虚拟机栈
Java 虚拟机栈也是线程私有的,它的生命周期和线程相同。
除了一些 Native 方法的调用需要使用到 本地方法栈,其他所有方法的调用都是通过栈来实现的(也需要和其他运行时数据区如程序计数器进行配合使用)。
方法调用的数据需要通过栈进行传递,每一次方法调用都会有一个对应的栈帧被压入栈中,每一个方法调用结束后就会将栈帧弹出。。
栈由一个个栈帧组成,而每个栈帧中都存在:局部变量表、操作数栈、动态链接、方法返回地址。栈是先进后出的数据结构,只支持压栈和弹栈两种操作。
局部变量表:主要存放了编译期可知的各种数据类型和对象引用(reference 类型)
操作数表:主要作为方法调用的中转站使用,用于存放计算过程中的临时变量和中间计算结果。
动态链接:作用是将符号引用转换为调用方法的直接引用,这个过程也被叫做动态链接。
方法返回地址
栈空间虽然不是无限的,但一般正常调用的情况下是不会出现问题的。但如果函数陷入无限循环的话,就会导致栈中被压入太多栈帧而占用太多空间,导致栈空间过深。当线程请求帧的深度超过当前 Java 虚拟机栈的最大深度的时候,就会抛出 StackOverFlowError
Java 方法返回有两种方法,一种是 return 语句正常返回,一种是抛出异常。这两种方式都会导致栈帧被弹出。也就是说,栈帧随方法创建而产生,随方法结束而消亡。对于方法结束,正常返回和抛出异常都算是方法结束。
除了 StackOverFlowError 错误之外,栈可能存在 OutOfMemoryError 错误,这是因为栈的内存大小可以动态拓展,如果虚拟机在拓展的时候没有申请到足够的空间就会抛出后面这种错误。前面那种错误一般是由于死循环而产生的(栈的大小不允许动态拓展)。
本地方法栈
和虚拟机栈类似,虚拟机栈为虚拟机服务(方法调用);本地方法栈为 native 方法调用服务。在 HotSpot 虚拟机中,本地方法栈和虚拟机栈合二为一。
当 Native 方法被执行的时候,本地方法栈中压入栈帧,包括操作数表、局部变量表、动态链接和出口信息。其余和虚拟机栈类似。
堆
JDK 7 ,堆内存被分为下面三个部分:
1、新生代(Young Generation):新生代又可以分为 Eden 和两个 Survivor 区
2、老年代(Old Generation)
3、永久代(Permanent Generation)
JDK 8 之后 永久代(Permanent Generation)被 元空间(MetaSpace)取代,元空间使用的是本地内存。
对象在 Eden 区被分配,在一次新生代垃圾回收后进入 s0 或者 s1,并且对象的年龄会 +1,当它的年龄到达一个阈值的时候(默认15)就会进入到老年代。这个阈值可以通过参数 -XX:MaxTenuringThreshold 来设置,不过设置的值应该在 0 - 15(因为记录年龄的区域在对象头中,这个区域大小为四位,最大也只能为 1111,也就是15)
对象的布局
在 HotSpot 虚拟机中,对象在内存中存储的布局可以分为三块区域:对象头(Header)、实例数据(Instance Data)、对齐填充(Padding)。其中对象头包括两部分:标记字段(Mark Word)和类型指针(Klass Word)。
年龄信息被放在对象头的标记字段部分,标记字段还存放了例如哈希码、锁状态信息等
* HotSpot 遍历所有对象,按照年龄从小到大,对它们占用的空间进行累加。当累加空间超过 Survivor 区的大小一半的时候,则取这个年龄。将这个年龄和 15 进行比较,取更小的值作为新的阈值。
方法区
永久代 或 元空间 是 HotSpot 虚拟机对于方法区这个逻辑概念的两种不同实现。
为什么需要将 永久代 替换为 元空间?
1、永久代有一个 JVM 设置的固定大小上限,且无法进行调整;而元空间使用的是本地内存,受本机可用内存的限制,更加灵活且不易溢出。
元空间溢出时的提示:java.lang.OutOfMemoryError: MetaSpace
2、元空间里面存放的是类的元数据(类的结构信息,例如类名称、字段、方法、接口等),这样加载多少类的元数据不由 MaxPermSize 控制,而由系统的实际可用空间控制,这样可以加载更多的类。
3、JDK 8 中,HotSpot 和 JRockit 的代码进行合并,而 JRockit 中并不存在 永久代这一概念,所以合并之后它就不需要存在了(因为它没有元空间好)
4、永久代会为 GC 带来不必要的复杂度,回收效率低。
方法区常用参数
1.8 之前
-XX:PermSize=N //方法区 (永久代) 初始大小
-XX:MaxPermSize=N //方法区 (永久代) 最大大小,超过这个值将会抛出 OutOfMemoryError 异常:java.lang.OutOfMemoryError: PermGen
1.8
-XX:MetaspaceSize=N //设置 Metaspace 的初始(和最小大小)
-XX:MaxMetaspaceSize=N //设置 Metaspace 的最大大小
与永久代不同,元空间如果不指定大小,它默认为 unlimited(无限制),虚拟机可能会耗尽所有可用的系统内存。
运行时常量池
字面量是源代码中固定值的表示法,即通过字面我们就能知道其值的含义。字面量包括整数、浮点数、字符串。常见的符号引用包括类符号引用、字段符号引用、方法符号引用、接口方法符号。
我的理解:符号引用就是用符号对引用的对象进行描述,但不保存那个真正的对象;而直接引用是直接指向目标的指针,相当于它直接保存了对象。
常量池会在类加载后存放到方法区的运行时常量池中。运行时常量池的功能类似于传统编程语言的符号表,尽管它包含了比典型符号表更广泛的数据。当常量池无法再申请到内存的时候会抛出 OutOfMemoryError 错误。
字符串常量池
字符串常量池 是 JVM 为了提升性能和减少内存消耗针对字符串(String 类)专门开辟的一块区域,主要目的就是为了避免字符串的重复创建。
// 在堆中创建字符串对象”ab“
// 将字符串对象”ab“的引用保存在字符串常量池中
String aa = "ab";
// 直接返回字符串常量池中字符串对象”ab“的引用
String bb = "ab";
System.out.println(aa==bb);// true
JDK 1.7 之前,字符串常量池存放在永久代,1.8 之后字符串常量池和静态变量从永久代移动到了 Java 堆中。
为什么要将字符串常量池移动到堆中?
答:因为永久代中的 GC 效率非常低,只有在 Full GC 的时候才会被执行 GC。而 Java 程序中通常有大量的被创建的字符串等待回收,所以我们将字符串常量池放在堆中,这样可以更高效地回收字符串内存。
直接内存
直接内存是一种特殊的内存缓冲区,并不是在 Java 堆或方法区中分配,而是通过 JNI 的方式在本地内存上分配的。
直接内存并不是虚拟机运行时数据区的一部分,也不是虚拟机规范中定义的内存区域,但是这部分内存也被频繁地实现。而且也可能导致 OutOfMemory 错误出现。
JDK 1.4 中新加入的 NIO(Non-Blocking I/O),引入了一种基于通道与缓冲区的 IO 方式,它可以直接使用 Native 函数库直接分配堆外内存,然后通过一个存储在 Java 堆中 DirectByteBuffer 对象作为这块内存的引用来进行操作。这样就能在一些场景中显著提升性能,避免了在 Java 堆和 Native 堆中进行复制数据。
2、HotSpot 虚拟机对象
详细了解一下 HotSpot 虚拟机在 Java 堆中进行分配、布局和访问的全过程。
对象的创建
1、类加载检查:当虚拟机遇到一条 new 指令的时候,首先在常量池中检查是否能够定位到这个类的符号引用,并且检查这个类是否被加载过、解析和初始化过。如果没有,必须先执行相应的类加载过程。
2、分配内存:类加载检查通过后,虚拟机将为新生对象分配内存。对象所需的内存大小在类加载后就可以确定了。内存分配相当于从堆中划分一块确定大小的内存。分配方式有:“指针碰撞”和“空列表”两种,选择哪种分配方式取决于 Java 堆的内存是否比较连续,而 Java 堆内存的连续性取决于所采用的垃圾收集器是否带有压缩整理的功能决定。
内存分配的两种方式:
(1)指针碰撞:
使用场景:堆内存规整的情况(没有内存碎片)
原理:‘’用过的内存整合到一边,没用过的放在另一边,中间有一个分界指针,只需要向着没用过的内存方向将该指针移动对象内存大小即可。
GC:Serial ParNew
(2)空闲列表:
使用场景:堆内存不规整的情况(有内存碎片)
原理:虚拟机会维护一个列表,该列表记录哪些内存块是可用的,在分配的时候找一块足够大的内存来创建对象,然后更新列表记录。
GC:CMS
选择以上两种方式中的哪一种,取决于 Java 堆内存是否规整。而 Java 堆内存是否规整,取决于 GC 的算法是“标记-清除”还是“标记-整理”,值得注意的是,复制算法内存也是规整的。
内存分配并发问题(补充内容)
在创建对象的时候有一个问题就是,线程安全,在实际开发中,创建对象是很频繁的事情,作为虚拟机来说,必须要保证线程安全。一般来说有两种办法:
1、CAS+失败重试:CAS 是乐观锁的一种实现方式、乐观锁就是,每次不加锁而是假设没有冲突而去完成某项操作,如果因为冲突失败就重试,直到成功为止。
2、TLAB:为每个线程预先在 Eden 区分配一块儿内存,JVM 在给线程中的对象分配内存的时候,首先在 TLAB 分配,当对象大于 TLAB 中的内存的时候,再采用 CAS + 失败重试。
3、初始化零值:内存分配完成后,虚拟机需要将分配到的内存空间初始化为零值(不包括对象头),这一步操作保证对象的实例字段在 Java 代码中不赋值就可以直接使用,程序可以访问到这些字段的数据类型所对应的零值。
4、设置对象头:初始化零值之后,虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的 GC 分代年龄。这些信息放在对象头中。
5、执行 init 方法:上面工作完成后,在虚拟机看来新的对象已经产生了,但是 Java 程序角度来说对象的创建才刚刚开始,init 方法还没有执行,所有的字段都为零。所以一般来说,执行 new 指令后会继续执行 init 方法,把对象按照程序员的意愿进行初始化。
对象的内存布局:
对象头(Header)、示例数据(Instance Data)、对齐填充(Padding)
对象头包括两部分:
1、标记字段:用于存储对象自身的运行时数据,包括哈希码、GC 分代年龄、锁状态、线程持有的锁、偏向线程ID、偏向时间戳等。
2、类型指针:用于存储对象指向它的类元数据的指针,虚拟机通过这个指针来确定对象是哪个类的实例。
实例数据部分是对象真正存储的有效信息,也是在程序中定义的字段内容。
对齐填充不一定存在,没有特别的含义,仅仅起占位作用。(即对象的大小必须是 8 字节的整数倍)
对象的访问定位
主流的访问方式:使用句柄、直接指针
如果使用句柄,Java 堆中会划分出一块内存来作为句柄池,reference 中存储的就是对象的句柄地址,而句柄中包含了对象的实例数据与对象类型数据各自的具体地址信息。
如果使用直接指针,reference 中存储的就是对象的实例数据和类信息的地址。
二、JVM 垃圾回收详解(重点)
该篇中同样讲解的是 HotSpot 虚拟机。
当我们需要排查内存溢出、垃圾回收成为系统达到更高并发的瓶颈时,我们需要对“自动化”的JVM 垃圾回收机制进行必要的监控和调节。
常见面试题
- 如何判断对象是否死亡(两种方法)。
- 简单的介绍一下强引用、软引用、弱引用、虚引用(虚引用与软引用和弱引用的区别、使用软引用能带来的好处)。
- 如何判断一个常量是废弃常量
- 如何判断一个类是无用的类
- 垃圾收集有哪些算法,各自的特点?
- HotSpot 为什么要分为新生代和老年代?
- 常见的垃圾回收器有哪些?
- 介绍一下 CMS,G1 收集器。
- Minor Gc 和 Full GC 有什么不同呢?
堆空间的基本结构
Java 的自动回收机制主要是针对对象内存的回收和分配。同时,Java 自动内存管理最核心的功能是 堆 内存中对象的回收和分配。
Java 堆,也被称为 GC 堆。
1.7:新生代、老年代、永久代(新生代分为两个 Survivor 和一个 Eden 区)
1.8:新生代、老年代、元空间(直接内存)
内存分配和回收原则
一般来说,对象在 Eden 区域被创建(新生代中),当 Eden 区域的内存不足时,会进行一次 Minor GC。
参数:-XX:+PrintGCDetails
对象优先在 Eden 区进行分配
具体过程:
当 Eden 区域没有足够空间进行分配的时候,就会进行一次 Minor GC。GC 期间,虚拟机发现 Survivor 区域的内存也不足以存放该对象,通过 分配担保机制 将对象转移到老年代。此时发现老年代的空间足够,所以不会进行 Full GC。执行 Minor GC 后,如果后面分配的对象能够存在 Eden 区的话,还是会在 Eden 区进行分配。
大对象直接进入老年代
大对象是需要大量连续内存空间的对象。(字符串、数组)
大对象直接进入老年代是由虚拟机决定的,它与具体选择的垃圾回收器和相关参数有关。大对象直接进入老年代是一种优化策略,旨在避免大对象放入新生代,从而减少新生代的垃圾回收频率和成本。
G1:通过堆内存大小和阈值进行决定
Parallel Scavenge:没有固定的阈值,动态通过虚拟机进行决定。
长期存活的对象进入老年代
虚拟机设置了对象年龄计数器(对象头中)
对象在 Eden 出生,当进行 Minor GC 后进入 Survivor 区,年龄设置为1。当它每进程一次 Minor GC,年龄 +1;当年龄增加到一定程度(默认为 15 岁),认为该对象是长期存活的,将其晋升到老年代。
关于默认晋升年龄为 15,说法来自《深入理解 Java 虚拟机》这本书;但这个说法并不准确,这个是要区分选择的垃圾回收器的,CMS 默认为 6。
主要进行 GC 的区域
Partial GC:并不收集整个堆空间,而是只收集一部分
Young GC:只收集新生代的 GC。
Old GC:只收集老年代的 GC。只有 CMS 的 concurrent collection 是这个模式
Mixed GC:收集整个新生代 以及 部分老年代的 GC。只有 G1 有这个模式
Full GC:收集整个堆空间,包括新生代、老年代、永久代(1.7)
Major GC 通常跟 Full GC 等价,收集整个 GC 堆。
young GC:Eden 区域空间不足
Full GC:当准备触发一次 young GC 的时候,虚拟机发现 young GC 的平均晋升大小比 old GC 中的剩余大小要大,就会触发一次 Full GC。(因为除了 HotSpot 的 GC里,除了 CMS 的 concurrent collection 会单独回收 old GC,其他的都会同时手机整个 GC 堆,包括 young GC,所以不需要单独的 young GC)
除此之外,Parallel Scavenge 框架下,默认在触发 full GC 前先执行一次 young GC,并且在两次 GC 之间会让应用程序稍微运行一小下,来降低 GC 的暂停时间。
空间分配担保
空间分配担保是为了确保在 Minor GC 前,老年代还有容纳新生代所有对象的剩余空间。
so why?
死亡对象判断方法
堆中存放着基本上所有的对象实例,进行 GC 的时候首先就要判断哪些对象死亡哪些存活,我们只能对死亡的对象进行收集!
引用计数法
给对象添加一个引用计数器:
每当一个地方引用它,计数器 +1
每当一个引用失效,计数器 -1
任何时候计数器为 0 的对象就是不可能再被使用的
这个方法实现简单、效率高,但目前主流的虚拟机中并未使用这个算法来管理内存,主要原因是它很那解决对象之间循环引用的问题。
循环引用:
因为对象 1 和 对象 2 都互相引用对方,所以它们俩永远不会被回收。
可达性分析算法
这个算法的基本思想就是通过一系列的 “GC Roots” 的对象作为起点,从这些节点开始向下搜索,节点走过的路径成为引用链,当一个对象到 GC Roots 没有任何引用链的话,则证明该对象是不可用的,需要被回收。
右侧树结构,虽然它们之间存在引用,但是不存在引用链,所以它们会被回收。(解决上述引用计数法的问题)
哪些对象可以作为 GC Roots 呢?
- 虚拟机栈(栈帧中的局部变量表)中引用的对象
- 本地方法栈(Native 方法)中引用的对象
- 方法区中类静态属性引用的对象
- 方法区中常量引用的对象
- 所有被同步锁持有的对象
- JNI(Java Native Interface)引用的对象
对象可以被回收,就一定会被回收吗?
即使是在可达性分析中不可达(不存在引用链),也不一定会被回收,它们暂时处于“缓刑”,要真正宣告一个对象死亡,至少要经历两次标记过程;可达性分析中不可达的对象进行第一次标记,并且进行一次筛选,筛选的条件是此对象是否有必要执行 finalize 方法——当对象没有覆盖 finalize 方法,或者 finalize 方法已经被调用过,那么虚拟机将这两种情况视为没有必要执行。
被判定为需要执行的对象会被放入一个队列并进行二次标记,除非这个对象与引用链上的任何一个对象建立关联,否则就会被真的回收!
finalize 方法(请忘记它):
引用类型总结
引用计数法和可达性分析法都需要判断对象引用数量。
1.2 之前,如果 reference 类型的数据存储的是另一块内存的起始地址,这块内存代表一个引用。
1.2 之后,引用的概念进行了拓展,分为强引用、软引用、弱引用、虚引用(引用强度降序)
1、强引用
最普遍的引用。如果一个对象存在强引用,那么它就不会被 GC 回收。当内存空间不足,虚拟机会抛出 OutOfMemoryError,而不会回收它们所占用的空间。
2、软引用
如果一个对象只有软引用,当内存空间足够的时候,GC 不会回收它;当内存空间不足的时候,GC 就会回收它们所占用的内存。只有 GC 没有回收它,该对象就可以被程序使用。(可用来实现内存敏感的高速缓存)
软引用可以和一个引用队列(ReferenceQueue)一起使用,如果软引用引用的对象被回收,JVM 就会把这个软引用加入到与之关联的引用队列中。
3、弱引用
弱引用和软引用的区别在于:只具有弱引用的对象拥有更短暂的生命周期。当 GC 扫描到了只具有弱引用的对象时,无论内存是否充足,GC 都会进行回收。
弱引用可以和一个引用队列(ReferenceQueue)一起使用,如果软引用引用的对象被回收,JVM 就会把这个软引用加入到与之关联的引用队列中。
4、虚引用
形同虚设,虚引用并不决定对象的生命周期。如果一个对象仅持有虚引用,那就相当于没有持有引用,任何时候都可能被 GC 回收。
so 虚引用 有什么用?
虚引用主要用来跟踪对象被垃圾回收的活动。
虚引用和弱引用的区别:虚引用必须和引用队列(ReferenceQueue)联合使用。当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象的内存之前,把这个虚引用加入到与之关联的引用队列中。
程序设计中一班很少使用弱引用和虚引用,一般使用软引用,因为软引用可以加速 JVM 对 GC 的回收速度,维护系统的运行安全,避免 OutOfMemory 等问题的产生。
如何判断一个常量是废弃常量?
运行时常量池会回收常量。所以如何判断一个常量是废弃的?
假如在字符串常量池中,一个字符串常量没有被任何 String 对象进行引用,说明其是废弃的,如果此时发生 GC 且有必要的话,它就会被清理。
如何判断一个类是无用的?
方法区主要回收的是无用的类。所以如何判断一个类是无用的?
需要同时满足下列三个条件:
1、该类所有实例都被回收
2、加载该类的 ClassLoader 已被回收
3、该类对应的 java.lang.Class 对象没有任何引用,无法在任何地方通过反射访问该类
满足上述三个条件的类是 “可回收的”,但不一定会被回收。
垃圾收集算法
标记-清除算法
标记-清除(Mark-and-Sweep)算法分为“标记”和“清除”两个阶段:首先标记出所有不需要回收的对象,在标记完成后统一回收掉所有乜有被标记的对象。
它是最基础的算法,后续算法都是在它的基础上进行优化和改进得到的。这种垃圾收集算法有两个问题:
1、效率问题:标记和清除两个阶段效率都不是很高
2、空间问题:清楚后内存中会产生大量的内存碎片
复制算法
为了解决标记-清除算法的效率和内存碎片的问题,复制(Copying)收集算法出现了。它将内存分为大小相同的两块区域,每次使用其中的一块。当这一块的内存使用完后,就将仍然存活的对象复制到另外一个区域去,然后再把使用的空间一次性进行清理。
依旧存在的问题:
1、可用内存变小
2、不适合老年代(如果存活对象数量比较大,复制性能会变得很差)
标记整理算法
根据老年代的特点提出的一种标记算法,标记过程与“标记-清除”算法一样,但后续步骤不是直接对可回收对象回收,而是让所有存活的对象向一端移动,然后直接清理掉端边界以外的内存。
由于多了整理这一步,因此效率也不是很高,适合老年代这种 GC 频率不是很高的场景。
分代收集算法(为什么 HotSpot 虚拟机要分为新生代和老年代?)
当前虚拟机的 GC 均采用分带手机算法,这种算法只是根据对象的存活时间将内存分为几块。
新生代中,每次收集都有大量的对象死去,所以可以选用“标记-复制”算法,只需要付出少量对象的复制成本就可以完成本次 GC。
而老年代的对象存活几率较高,没有额外的空间对它进行分配担保,所以我们必须选择“标记-清除”或者“标记-整理”算法进行垃圾回收。
垃圾收集器
如果说垃圾收集算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现。
我们需要做的事情是根据自己的需求选择适合自己的垃圾收集器。(如果存在完美的垃圾收集器,为什么 HotSpot 还要提供那么多种垃圾收集器呢?)
JDK 默认垃圾收集器
JDK 8 :Parallel Scavenge(新生代)+ Parallel Old(老年代)
JDK 9 :G1
Serial 收集器
Serial(串行)收集器是最基本和历史最悠久的收集器,这不仅仅是因为它只会使用一条垃圾收集线程去完成 GC;同时,在它进行垃圾回收的时候,其他线程都会被阻塞(Stop The World),直到它收集结束。
新生代采用 标记-复制算法
老年代采用 标记-整理算法
这我们当然可以看出,Stop The World 的垃圾回收会对性能和用户体验造成不好的影响,所以在后续的垃圾收集器设计中停顿时间不断缩短(仍然存在停顿)。
优点:简单而高效(与其他收集器的单线程相比)
ParNew 收集器
ParNew 收集器 是 Serial 收集器的多线程版本,除了使用多线程进行垃圾收集之外,其余行为(控制参数、收集算法、回收策略)与 Serial 收集器完全一致。
新生代采用 标记-复制算法
老年代采用 标记-整理算法
它是许多运行在 Server 模式下的虚拟机的首要选择,除了 Serial 收集器外,只有它能与 CMS 收集器(真正意义上的并发收集器,后面会介绍到)配合工作。
Parallel Scavenge 收集器(JDK 1.8 默认收集器)
Parallel Scavenge 收集器也是使用标记-复制算法的多线程收集器,它看上去与 Par New 收集器几乎一致。
特别之处:它重点关注吞吐量(高效率利用 CPU),吞吐量的定义为,CPU 中用于运行用户代码的时间和 CPU 总消耗时间的比值。Parallel Scavenge 收集器提供了很多参数供用户去寻找到最合适的停顿时间或最大吞吐量。
新生代采用 标记-复制算法
老年代采用 标记-整理算法
Serial Old 收集器
这是 Serial 的老年代版本。两大用途:一种是在 JDK 1.5 之前配合 Parallel Scavenge 使用,另一种是作为 CMS 收集器的后备方案。
Parallel Old 收集器
这是 Parallel Scavenge 的老年代版本,使用多线程和 标记-整理 算法。在注重吞吐量以及 CPU 资源的场景下,都可以考虑 Parallel Scavenge 和 Parallel Old 收集器。
CMS 收集器
Concurrent Mark Sweep 并发标记清除
它的目标是获得最短的回收停顿时间,注重用户体验。
CMS 收集器是 HotSpot 虚拟机第一个 并发收集器,它真正实现了垃圾收集和用户线程(基本上)同时工作。
步骤:
1、初始标记:暂停所有的其他线程,记录下直接与 root 相连的对象,速度很快。
2、并发标记:同时开启 GC 和 用户线程,记录可达对象。但在这个阶段结束,闭包结构不能保证包含所有的可达对象。
3、重新标记:修正并发标记阶段因为用户线程继续运行而导致标记发生改变的部分。速度比第一阶段慢一些
4、并发清除:开启用户线程,GC 现成对未被标记的进行清理
优点:并发收集、停顿时间短(用户体验好)
缺点:对 CPU 资源敏感;无法处理浮动垃圾;它使用标记-清除算法存在大量内存碎片。
CMS 在 Java 9 中已经被标记为过时(deprecated),并在 Java 14中被移除。
G1 收集器
G1 (Garbage-First) 是一款面向服务器的垃圾收集器,主要针对配备多颗处理器及大容量内存的机器. 以极高概率满足 GC 停顿时间要求的同时,还具备高吞吐量性能特征.
被视为 JDK 1.7 中 HotSpot 虚拟机的一个重要进化特征,具备以下特点:
1、并行与并发:G1 可以充分利用多核 CPU,使用多个 CPU(或核心)来缩短 STW 的时间。部分其他收集器原本需要停顿 Java 线程执行的 GC 操作,G1 可以通过并发来使其继续运行
2、分代收集:G1 不需要配合就可以完成 GC,但仍然保留了分代
3、空间整合:整体使用 标记-整理;部分使用 标记-复制
4、可预测的停顿:G1 相比于 CMS 的一大又是,G1 除了追求低停顿外,还可以建立可预测的停顿时间模型,能让使用者明确指定在一个长度为 M 毫秒的时间片段内,消耗在垃圾收集上的时间不得超过 N 毫秒。
G1 的运行步骤:
1、初始标记
2、并发标记
3、最终标记
4、筛选回收
优先列表——根据允许回收的时间,对回收价值最大的先进行回收。
ZGC 收集器
新一代垃圾回收器
三、类文件结构详解
Class 文件结构总结
类似于结构体:
ClassFile {
u4 magic; //Class 文件的标志
u2 minor_version;//Class 的小版本号
u2 major_version;//Class 的大版本号
u2 constant_pool_count;//常量池的数量
cp_info constant_pool[constant_pool_count-1];//常量池
u2 access_flags;//Class 的访问标记
u2 this_class;//当前类
u2 super_class;//父类
u2 interfaces_count;//接口数量
u2 interfaces[interfaces_count];//一个类可以实现多个接口
u2 fields_count;//字段数量
field_info fields[fields_count];//一个类可以有多个字段
u2 methods_count;//方法数量
method_info methods[methods_count];//一个类可以有个多个方法
u2 attributes_count;//此类的属性表中的属性数
attribute_info attributes[attributes_count];//属性表集合
}
感觉不太会考?
四、类加载过程详解
类的生命周期
类的生命周期包括,加载、连接、初始化、使用、卸载。其中连接又包括验证、准备、解析三个步骤。
类加载过程
Class 文件需要加载到虚拟机中才可以运行和使用,那么虚拟机是如何加载这些 Class 文件的呢?
系统加载 Class 类型的文件主要分三步:加载、连接、初始化
连接分为三步:验证、准备、解析
加载
类加载的第一步,就叫做加载,主要完成如下 3 件事:
1、通过全类名获取定义此类的二进制字节流
2、将字节流所代表的静态数据结构转换为方法区内的运行时数据结构
3、在内存中生成一个代表该类的 Class 对象,作为方法区这些数据的访问入口
在虚拟机规范中,以上三条并不具体,而是非常灵活的。
加载这一步,需要用到后面一章提到的类加载器,类加载器的选择通过双亲委派模型决定。(我们也可以打破双亲委派模型)
每个 Java 类都有一个引用指向它的 ClassLoader。不过数组不是通过 ClassLoader 进行加载的,而是 JVM 在需要的时候自动创建的,数组通过 getClassLoader() 方法获取 ClassLoader 的时候,和该数组的元素类型的 ClassLoader 一致。
一个非数组的创建是可控的,我们可以通过自定义类加载器区控制字节流的获取方法(重写类加载器的 loadClass() 方法)。
加载和连接阶段的部分动作是交叉进行的,也就是说加载阶段未完成的时候已经开始连接阶段了。
验证
验证是连接的第一步,这一阶段的目的是确保 Class 文件是符合规范的,主要是为了保证安全,避免运行恶意代码。但这个步骤是可以被禁止的。
如果程序运行的代码被反复验证和使用,那么我们可以考虑使用参数 -Xverify:none 来关闭大部分的类验证措施,提高类加载的速度。
验证阶段通常由四个检验阶段组成:
1、文件格式验证(Class 文件格式检查)
2、元数据验证(字节码语义检查)
3、字节码验证(程序语义检查)
4、符号引用验证(类的正确性检查)
除了文件格式验证是基于二进制字节流的,其他都是基于方法区的存储结构的,不会再读取和操作字节流了。
符号引用验证发生在类加载中的解析阶段,具体说就是 JVM 将符号引用转化为直接引用的时候。
准备
准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些内存都将在方法区中被分配。
注意:
1、此时内存分配的仅为类变量,不包括实例变量。实例变量会在对象实例化的时候随着对象一起分配在 Java 堆中。
2、类变量所使用的内存都应该在 方法区 中进行分配。
3、这里的初始值默认是零值。
解析
解析阶段是虚拟机将常量池中的符号引用转换为直接引用的过程。解析主要针对类、接口、字段、类方法、接口方法、方法类型、方法句柄和调用限定符 7 类符号引用进行。(得到指针或偏移量)
初始化
初始化阶段是执行初始化方法 clinit() 方法的过程,是类加载的最后一步,这一步 JVM 才开始真正执行类中定义的 Java 程序代码。
<clinit> () 方法是编译后自动生成的。
虚拟机严格规范了,有且只有 6 中情况下,必须对类进行初始化:
类卸载
卸载即该类的 Class 对象被 GC
卸载类需要满足三个条件:
1、该类没有实例对象
2、类没有被引用
3、该类的类加载器实例被 GC
所以,在 JVM 生命周期内,由 jvm 自带的类加载器加载的类是不会被卸载的。但是由我们自定义的类加载器加载的类是可能被卸载的。
五、类加载器详解
类加载器
类加载器是一个负责加载类的对象。ClassLoader 是一个抽象类。
赋予 Java 类可以被动态加载到 JVM 中并执行的能力。
class Class<T> {
...
private final ClassLoader classLoader;
@CallerSensitive
public ClassLoader getClassLoader() {
//...
}
...
}
类加载器加载规则
JVM 启动时,并不会加载所有的类,而是动态加载类。大部分类在使用时才会去加载,减小了内存的压力。
对于已经加载的类会被放在 ClassLoader 中。在类加载的时候,首先判断该类是否被加载过,如果被加载过会直接返回,如果没有才进行加载。
类加载器总结
除了启动类加载器、拓展类加载器、应用程序类加载器之外,我们可以自定义类加载器进行拓展,以满足特殊需求。
BootStrapClassLoader 是 JVM 自身的一部分,其他类加载器都是在 JVM 外部实现的,均继承自 ClassLoader 抽象类。
我们可以通过 getParent() 方法来获取 父类加载器,如果为 null,则说明该加载器为启动类加载器。
自定义类加载器
如果不想打破双亲委派模型,重写 findClass() 方法;如果想打破就重写 loadClass() 方法。
双亲委派模型(Parents Delegation Model)
类加载器有很多种,当我们想要加载一个类的时候,具体是哪个类加载器进行加载?这需要提到双亲委派模型了。
1、ClassLoader 类使用委托模型来搜索类和资源
2、该模型要求除了启动类加载器,其他类必须有父类加载器
3、ClassLoader 实例在试图查找类或资源的时候,先将其委派给其父类加载器
且类加载器之间的父子关系一般通过组合表现,而不是继承。
* 组合
组合把旧类对象作为新类对象的成员变量组合进来,用以实现新类的功能
public abstract class ClassLoader {
...
// 组合
private final ClassLoader parent;
protected ClassLoader(ClassLoader parent) {
this(checkCreateClassLoader(), parent);
}
...
}
组合表示的是 has-a 关系(Person类 has-a Arm类的实例)
双亲委派模型的执行流程
双亲委派模型的实现代码,集中在 ClassLoader 类的 loadClass() 方法中,代码如下。
protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException
{
synchronized (getClassLoadingLock(name)) {
//首先,检查该类是否已经加载过
Class c = findLoadedClass(name);
if (c == null) {
//如果 c 为 null,则说明该类没有被加载过
long t0 = System.nanoTime();
try {
if (parent != null) {
//当父类的加载器不为空,则通过父类的loadClass来加载该类
c = parent.loadClass(name, false);
} else {
//当父类的加载器为空,则调用启动类加载器来加载该类
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
//非空父类的类加载器无法找到相应的类,则抛出异常
}
if (c == null) {
//当父类加载器无法加载时,则调用findClass方法来加载该类
//用户可通过覆写该方法,来自定义类加载器
long t1 = System.nanoTime();
c = findClass(name);
//用于统计类加载器相关的信息
sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
sun.misc.PerfCounter.getFindClasses().increment();
}
}
if (resolve) {
//对类进行link操作
resolveClass(c);
}
return c;
}
}
执行流程:
1、首先判断类是否加载过,如果加载过直接返回,如果没有才进行加载。
2、先将加载请求委派给父类加载器去完成
3、如果父类加载器无法完成,调用自己的 findClass() 方法来加载
4、如果子类加载器也无法加载,那么会抛出异常 ClassNotFoundException
如何判断两个 Java 类是否相同:JVM 不仅看类的全名是否相同,还要看加载该类的类加载器是否相同。只有两者都相同,JVM 才认为它们是相同的。
双亲委派模型的优点
保证 Java 程序的稳定运行,避免类的重复加载,保证 Java 的核心 API 不被篡改。
打破双亲委派模型的方法
如果我们不想打破双亲委派模型,就重写 ClassLoader
类中的 findClass()
方法即可,无法被父类加载器加载的类最终会通过这个方法被加载。但是,如果想打破双亲委派模型则需要重写 loadClass()
方法。
为什么重写 loadClass() 方法就可以打破呢?因为在前面源码中,loadClass() 方法默认实现会将其交给父类加载器进行加载,如果我们希望打破这个就对它进行重写即可。