文章目录
- 内存布局
- 直接内存
- 执行引擎
- 解释器
- JIT 即时编译器
- JIT 分类
- AOT 静态提前编译器(Ahead Of Time Compiler)
- GC
- 什么是垃圾
- 为什么要GC
- 垃圾回收行为
- Java GC 主要关注的区域
- 对象的 finalization 机制
- GC 相关算法
- 引用计数算法(Reference Counting)
- 可达性分析算法
- GC Roots
- Stop The World
- 标记-清除算法(mark-sweep)
- 复制算法(Copying)
- 标记-压缩(标记-整理)算法
- 分代垃圾回收
- 增量收集算法
- 分区算法
内存布局
- 对象头(Header)
- 运行时元数据(Mark word):哈希值、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳
- 类型指针 :指向类元数据,确定对象所属类型。如果是数组还要记录数组的长度
- 实例数据(Instance Data):类中的各类型变量以及父类的相关类型数据。
- 对齐填充(Padding):非必须,也没有特殊含义,仅起到占位符的作用
直接内存
- 直接内存不是JVM 运行时数据区的一部分,是 Java 堆外的,系统的内存区间。NIO 通过存在堆中的 DirectByteBuffer 操作 Native 内存。通常情况下,直接内存的运行效率优于 Java 堆,读写频繁的场合,如果NIO 库就允许 Java 程序使用直接内存。
直接内存在 Java 堆外,因此是不受 -Xmx 的限制的,但操作系统内存是有限的
- -XX:MaxDirectMemorySize=1G,表示设置 NIO 可以操作的直接内存最大大小为 1G,默认为 0,表示 JVM 自动选择 NIO 可以操作的直接内存大小。
直接内存也会 OOM。
执行引擎
执行引擎(Execution Engine),将字节码指令解释/编译(注意与 Java 文件编译为 .class 文件的编译区分,有的地方称之为后端编译)为对应平台上的本地机器指令,实际就是将高级编程语言翻译为机器指令。
解释器
当 Java 虚拟机启动时会根据预定义的规范对字节码采用逐行解释的方式执行,将每条字节码文件内容“翻译”为对应平台的本地机器指令。此过程由解释器执行。
- 字节码解释器:纯软件代码模拟字节码执行,效率低下。
- 模板解释器:每一条字节码和一个模板函数相关联,模板函数直接产生这条字节码执行时的机器码,效率高。
基于解释器来执行,还是相对低效的,所以又有了 JIT 即时编译器。
JIT 即时编译器
JIT(Just In Time Compiler)即时编译器:就是 JVM 将源代码直接编译成和本地机器相关的机器语言。
- 根据代码被调用的执行频率来确定是否需要启动 JIT 来进行编译,这些需要被 JIT 编译为本地指令的代码,称为“热点代码”,JIT 在运行时会针对这些热点代码做深度优化,以提高 Java 程序的性能。
- 栈上替换:一个多次被调用的方法,或者一个方法体内部的循环次数较多的循环体,都可以称为“热点代码”,他们都可以通过 JIT 编译为本地机器指令。由于此过程发生在方法执行过程中,因此也称为栈上替换(OSR 编译)On Stack Replacement。
- 热点探测功能:HotSpot 基于计数器的方式来进行热点探测热点代码被调用的次数。Client 模式 1500次,Server 模式 10000 次,才是热点代码,才切换为 JIT 编译。
- 指令缓存:JIT 编译为本机指令代码后,会进行代码缓存,以提高性能。
此外我们还可以通过 java 命令设置,让程序使用纯解释器或纯 JIT 编译器执行,或者两者的混合执行的模式。
- -Xint 纯解释器模式
- -Xcomp 纯编译器模式
- -Xmixed 混合模式(默认)
JIT 分类
JIT 分为如下两类编译器:
- C1(Client Compiler)编译器:运行在 -client 模式下,C1 编译器会对字节码进行简单和可靠的优化,耗时短。
- 方法内联
- 去虚拟化
- 冗余消除
- C2(Server Compiler)编译器:运行在 -server 模式下,C2 进行耗时较长的优化,以及激进优化。但优化后的代码执行效率高。
- 标量替换
- 栈上分配
- 同步消除
- Graal 编译器:JDK 10+ 才加入的全新的即时编译器。
AOT 静态提前编译器(Ahead Of Time Compiler)
JIT 是程序运行中执行的,AOT 编译是在程序运行之前执行,将字节码转换为机器码的过程。好处:预编译.class 文件为 .os 文件
GC
什么是垃圾
垃圾是指在运行程序中没有任何指针指向的对象。如果不及时堆垃圾进行清理,这些垃圾会一直占用内存空间,直到程序运行结束,在这期间这些对象所占用的内存无法使用,造成极大的资源浪费。
为什么要GC
如果不 GC ,内存迟早会消耗完,导致新的对象无法分配内存,所以没有 GC 程序就可能无法正常执行。
垃圾回收行为
- 手动GC:C / C++ 需要开发人员手动进行内存的申请和回收,这样做的好处是灵活,但坏处是操作太频繁,而且对开发人员的要求较高,相对增加了开发人员的负担。
- 自动GC:Java 采用的是自动 GC 的机制。自动管理内存,无需开发人员手动分配和回收。坏处是弱化了开发人员对内存的管理,只能通过监控和调节相关参数来优化。
Java GC 主要关注的区域
- 方法区(注:方法区对应永久代或元空间,很多 JVM 没有方法区的 GC)
- 堆区
Java GC 的主要特点为:频繁收集 Young 区(年轻代),较少收集 Old 区(老年代),基本不收集 Perm 区(老年代、元空间)
对象的 finalization 机制
当垃圾回收器对垃圾对象进行垃圾回收之前,会先调用该对象的 finalize 方法,该方法在 Object 类中定义,可以被重写,主要用于在对象被回收时进行资源释放。我们通常在此方法中进行一些资源释放的操作和清理的操作,比如:File 、IO 操作的关闭、Socket 操作的关闭、数据库连接的关闭等等。
注:不要在程序中主动调用 finalize 方法,该方法仅提供给垃圾回收器调用
- 在 finalize 时,可能导致对象复活
- finalize 方法执行时间是没有保障的,它有 GC 线程决定,若不发生 GC,则不会执行。GC 时调用 finalize 方法是由单独的优先级较低一点的线程(Finalizer)来执行。执行前放在执行队列中(因为 GC 是有多个对象的 finalize 方法需要调用)
- finalize 方法还可能导致 GC 失败,所以在重写该方法时要注意执行效率等。
由于 finalize 方法,对象可能会出现如下几个状态:
- 可触及:该对象可达。(可达性算法分析)
- 可复活:对象不可达,但对象可能调用 finalize 方法后复活。(在 finalize 方法中使当前对象跟引用链中任何一个对象建立联系,就会导致对象复活。但之后再次不可用,则不会调用 finalize 方法了。finalize 方法只调用一次)
- 不可触及:finalize 方法被调用成功,且对象没有复活。只有此状态的对象才可不垃圾回收。
GC 相关算法
GC 分为两个阶段:标记阶段和清除阶段,每个阶段都有对应的算法,这些算法可以统称为垃圾回收算法。
- 标记阶段:判断对象是否存活。其对应的算法有引用计数算法和可达性分析算法
- 清除阶段:在判断对象释放存活之后,GC 接下来就会对死亡的对象进行垃圾回收,释放内存空间。目前常用的算法有,标记-清除算法(mark-sweep)、复制算法(Copying)、标记-压缩算法(mark-Compact)
引用计数算法(Reference Counting)
每个对象保存一个整型引用计数器,记录该对象被引用的情况。只要有任何一个地方引用了该对象,则该对象的计数器值 +1 ,如果不再引用了,则计数器值 -1,只要该对象的引用计数器的值为 0,则表示该对象死亡,可以被 GC 回收。
- 优点:实现简单,垃圾对象判断简单,判断效率高,回收没有延迟性。
- 缺点
- 需要独立的字段存储计数器,增加内存开销
- 任何引用的变动都需要更新计数器的值
- 无法处理循环引用(这是个致命的问题,所以目前的 JVM 中都没有使用此算法了)
可达性分析算法
可达性分析算法又叫根搜索算法或跟踪性垃圾收集,相对引用计数算法而言,可达性分析算法不仅同样具备实现简单和执行高效等特点而且可以有效的解决循环引用问题,防止内存泄漏的问题发生。Java 就是选择的可达性分析算法。
- 可达性分析算法是以根对象集合(GC Roots)为起始点,按照从上到下的方式搜索被根对象集合所连接的目标对象是否可达。
- 使用可达性分析算法后,内存中的存活对象都会被根对象集合直接或间接连接着,搜索走过的路径称为引用链(Reference Chain)
- 如果目标对象没有任何引用链相连,则对象释不可达的,可以标记为垃圾对象。只有直接或间接被根对象连接的对象才是存活对象。
GC Roots
GC Roots 包含如下几类元素:
- JVM 中引用的对象
- 如:各个线程被调用的方法中使用的参数、局部变量等。
- 本地方法栈内 JNI (本地方法)引用的对象
- 方法区中静态属性引用的对象
- 如:对象类型的静态变量
- 方法区中常量引用的对象
- 如:字符串常量池中的引用对象
- 被同步锁 synchronized 持有的对象
- JVM 内部的引用
- 如:系统类加载器、Class 对象等
- 反应 JVM 内部情况的 JMXBean 、JVMTI 中的注册回调、本地代码缓存等。
Stop The World
如果要使用可达性分析算法来判断内存是否可以回收,那么分析工作必须在一个能保障一致性的快照中进行,否则分析结果无法保证完全正确。所以在 JVM 在进行 GC 时,必须 “Stop The World” 用户线程出现停顿。
标记-清除算法(mark-sweep)
标记-清除算法就分为标记和清除两个阶段:
- 标记:收集器从根节点开始遍历,标记所有被引用的对象(注意:这里是标记的可用对象,标记的内容标记在对象头 Header 中)
- 清除:收集器从堆内存从头到尾的线性遍历,如果发现某个对象没有标记为可用对象,则将其回收。
标记-清除算法简单明了,但在进行 GC 的时候需要停止整个应用程序,而且清理出来的内存空间是不连续的,容易产生内存碎。可能导致可用空间碎片化,不能整体存放大对象。而且该算法需要经历标记-清除两步,意味着需要两次遍历对象。
标记-清除算法的清除并不是真的清除,只是将可回收的对象的地址进行记录(放在空闲列表),当有新对象来的时候,进行覆盖。
复制算法(Copying)
将内存空间分为两块,每次只使用其中一块,在垃圾回收时将正在使用的内存中的存活对象复制到未被使用的内存块中,之后清除正在使用的内存块中的所有对象,然后交换两个内存块的角色,最后完成垃圾回收。(这里是不是想起了什么?我们的两个幸存者区就是使用的该方式)
优点:一次遍历,更高效,复制后内存连续,没有碎片化问题。
缺点:需要两份内存空间,且任何时刻都有一个空间不使用,而且对象需要复制,当存活对象很多的情况下,复制所占用的系统资源也不少。对于存活对象不多的情况比较适用。(想想为什么在年轻代中的幸存者区使用该算法)
标记-压缩(标记-整理)算法
在年轻代中,一般只有少部分对象存活,使用复制算法,可以有效利用复制算法的优点,但在老年代中,存活对象更多,采用复制算法就会放大其缺点,所以 JVM 的开发者,在标记-清除算法的基础上改进,形成了标记-压缩算法。
标记-压缩算法也是分为两步:
- 第一阶段和标记-清除算法一样,标记所有被引用的对象。
- 将所有存活的对象压缩到内存的一端,按顺序排放,然后清理空间。
- 优点:解决了标记-清除算法的碎片化问题,消除了复制算法的内存减半的代价。
- 缺点:效率上来说还是低于复制算法,甚至低于标记-清除算法。对比标记-清除算法,标记-压缩算法有对象的移动,对应的引用地址就会涉及修改等。
标记-压缩算法的开销
- 标记(mark)阶段的开销与存活对象的数量成正比。
- 清除(sweep)阶段的开销与所管理的区域大小成正比。
- 压缩(Compact)阶段的开销与存活对象的数量成正比
分代垃圾回收
- 年轻代:区域相对老年代较小,对象生命周期短,存活率低,回收频繁。基于此特点,采用了复制算法进行垃圾回收,速度快,效率高,而且年轻代中两个幸存者区就是为了利用复制算法来划分的。
- 老年代:区域相对较大,对象存活率高,生命周期较长,回收不及年轻代频繁。基于此特点,采用的是标记-清除算法和标记-压缩算法混合使用的方式实现的垃圾回收。
增量收集算法
标记-清除、标记-压缩、复制算法,都或多或少的会有 stop the world 的出现,如果垃圾收集时间过长,应用程序会被挂起时间过长,影响用户体验。基于此情况,又诞生了增量收集算法。
如果一次性进行垃圾收集,将所有的垃圾进行处理,可能导致时间过长,所以增量收集算法就采用了垃圾收集线程和应用程序线程交替执行的思想,垃圾收集线程每次执行只收集一小片区域的内存空间,然后切换到应用程序线程执行,反复执行直到垃圾收集完成。(其本质上还是我们上面说到的垃圾收集算法,只是将垃圾收集和应用程序线程交替执行,以减少 stop the world 的时间)
- 优点:减少了 stop the world 的时间
- 缺点:交替执行线程,因为线程和线程上下文的切换的消耗,会使得垃圾回收的总成本上升,造成系统吞吐量下降。
分区算法
分区算法将堆空间划分为更小的区间,分代算法按照对象的生命周期长短划分成两个部分,分区算法将整个堆空间划分成连续的不同区域。每一个区域都独立使用,独立回收。这样一次回收多个小空间,降低了 stop the World 的时间。