最全最详细的Java垃圾回收机制

编程入门 行业动态 更新时间:2024-10-25 18:24:48

<a href=https://www.elefans.com/category/jswz/34/1768819.html style=最全最详细的Java垃圾回收机制"/>

最全最详细的Java垃圾回收机制

Java垃圾回收机制

  • 1、JVM 内存介绍
    • 1.1、程序计数器
    • 1.2、虚拟机栈
    • 1.3、本地方法栈
    • 1.4、堆
    • 1.5、方法区
    • 1.6、直接内存
  • 2、垃圾回收
    • 2.1、哪些内存需要回收
      • 2.1.1、引用计数算法
      • 2.1.2、可达性分析算法
    • 2.2、回收方法区
    • 2.3、堆中的垃圾回收算法
      • 2.3.1、分代收集理论
      • 2.3.2、标记清除算法
      • 2.3.3、标记复制算法
      • 2.3.4、标记整理算法

为了面试,最近一直在看Java相关知识点。发现除了Java基础知识,另外两个相对比较“高级”的点就是垃圾回收机制和Java多线程。这次趁这个机会学习一下,并且做一个小小的总结方便日后查询。中间参考了很多书本知识和优质博客,在此就不一一列出啦。
本篇文章主要介绍JVM的垃圾回收机制。要想弄懂JVM的垃圾回收机制,一定要先了解JVM执行Java程序的运行时数据区,或者也可以称之为JVM内存结构吧。

1、JVM 内存介绍

JVM在执行Java程序的时候,会把它管理的内存划分成不同的区域,如下图所示,每个区域都有不同的功能。

上图中,方法区(Method Area)和堆(Heap)是所有线程共享的数据区域,虚拟机栈(VM Stack)、本地方法栈(Native Method Stack)和程序计数器(Program Counter Register)是线程私有的数据区域。

1.1、程序计数器

程序计数器是一块较小的内存区域,它可以看作当前线程所执行的字节码的行号指示器。在Java虚拟机里的概念模型里,字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,它是程序控制流的指示器。在多线程环境中,为了线程切换后能恢复到正确的执行位置,每个线程都需要一个独立的程序计数器。如果线程正在执行的是一个Java方法,程序计数器记录的是正在执行的虚拟机字节码指令的地址。如果正在执行的是本地方法,计数器的值则为空。
java方法:是由java语言编写,编译成字节码,存储在class文件中的。java方法是与平台无关的。
本地方法:本地方法是由其他语言(如C、C++ 或其他汇编语言)编写,编译成和处理器相关的代码。本地方法保存在动态连接库中,格式是各个平台专用的,运行中的java程序调用本地方法时,虚拟机装载包含这个本地方法的动态库,并调用这个方法。java的本地方法接口JNI,使得本地方法可以在特定主机系统上的任何一个java平台上实现运行。

此内存区域是JVM内存中唯一一个不存在OOM的区域

1.2、虚拟机栈

虚拟机栈为执行Java方法(字节码)服务,每个方法被执行的时候,JVM都会同步创建一个栈帧,用来存储局部变量表、操作数栈、动态连接、方法出口等信息。每一个方法被调用直置完毕的过程,对应着一个栈帧从虚拟机栈中进栈到出栈的过程。
虚拟机栈的局部变量表部分存放了各种JVM基本数据类型,引用变量和returnAddress类型(指向了一条字节码指令的地址)。
在《Java虚拟机规范》中,这个内存区域规定了两种内存溢出。如果线程请求的栈深度大于JVM所允许的深度,将抛出StackOverflowError异常。如果虚拟机栈的容量可以动态拓展,当栈拓展时无法申请到足够的内存将抛出OutOfMemoryError异常。

1.3、本地方法栈

本地方法栈为JVM使用到的本地方法服务。《Java虚拟机规范》对本地方法栈中方法使用的语言、使用方式与数据结构并没有任何强制规定,因此具体的虚拟机可以根据需要自由实现它,甚至有的Java虚拟机(譬如Hot-Spot虚拟机)直接就把本地方法栈和虚拟机栈合二为一。与虚拟机栈一样,本地方法栈也会在栈深度溢出或者栈扩展失败时分别抛出StackOverflowError和OutOfMemoryError异常。

1.4、堆

堆是虚拟机所管理的内存中最大的一块,被所有线程共享,在JVM启动时创建。此内存区域的唯一目的就是存放对象实例。
堆是垃圾收集器管理的内存区域。从***内存回收***角度说,由于经典的垃圾回收器都是基于分代收集理论,所以Java堆中经常会出现新生代,老年代等名词,将堆划分成不同的区域,因此堆内的垃圾回收需要新生代垃圾收集器和老年代垃圾收集器搭配。但是到今天,垃圾收集器技术与十年前不可同日而语,Hotspot里面也出现了不采用分代设计的新垃圾收集器。从***分配内存***的角度说,堆中可以划分出多个线程私有的分配缓冲区(TLAB)以提升对象分配时的效率。
Java堆可以被实现成固定大小的,也可以是可拓展的,不过当前主流的Java虚拟机都是按照可拓展实现的(通过参数-Xmx和-Xms设定)。如果在堆中没有内存完成实例分配,并且堆也无法拓展时,JVM将会抛出OOM异常。

1.5、方法区

方法区与堆一样,是各个线程共享的内存区域,它用于存储已经被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。虽然《Java虚拟机规范》中把方法区描述为堆得一个逻辑部分,但是它却有一个别名叫作“非堆”,目的是与堆分开。

《Java虚拟机规范》对方法区的约束是非常宽松的,除了和Java堆一样不需要连续的内存和可以选择固定大小或者可扩展外,甚至还可以选择不实现垃圾收集。相对而言,垃圾收集行为在这个区域的确是比较少出现的,但并非数据进入了方法区就如永久代的名字一样“永久”存在了。这区域的内存回收目标主要是针对常量池的回收和对类型的卸载,一般来说这个区域的回收效果比较难令人满意,尤其是类型的卸载,条件相当苛刻,但是这部分区域的回收有时又确实是必要的。

运行时常量池是方法区的一部分。Class文件除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池表,用于存放编译器生成的各种字面量与符号引用。这部分内容将在类加载后存放到方法区的运行时常量池中。
JVM对于Class文件的每一部分的格式都有严格规定,如每一个字节用于存储哪种数据都必须符合规范上的要求才会被JVM认可、加载和执行。但是对于运行时常量池,《Java虚拟机规范》并没有做任何细节要求,不同提供商实现的JVM可以按照自己的需要来实现这个内存区域,不过一般来说,除了保存Class文件中描述的符号引用外,还会把由符号引用翻译出来的直接引用也存储在运行时常量池中。运行时常量池对于Class文件常量池的另一个重要特征是具备动态性,Java语言并不要求常量一定是只有编译器才能产生,也就说,运行期间也可以将新的常量放入池中,这种特性被开发人员利用的较多的便是String类的intern()方法。
既然运行时常量池是方法区的一部分,自然受到内存的限制,当常量池无法申请到内存时就会抛出OOM异常。

字面量可以理解为实际值,int a = 8中的8和String a = "hello"中的hello都是字面量
符号引用就是一个字符串,只要我们在代码中引用了一个非字面量的东西,不管它是变量还是常量,它都只是由一个字符串定义的符号,这个字符串存在常量池里,类加载的时候第一次加载到这个符号时,就会将这个符号引用(字符串)解析成直接引用(指针)

1.6、直接内存

直接内存并不是虚拟机运行数据区的一部分,但是这部分也被频繁的使用,而且也可能导致OOM。
在JDK 1.4中新加入了NIO(New Input/Output)类,引入了一种基于通道(Channel)与缓冲区(Buffer)的I/O方式,它可以使用Native函数库直接分配堆外内存,然后通过一个存储在Java堆里面的DirectByteBuffer对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在Java堆和Native堆中来回复制数据。
本机直接内存的分配不会受到Java堆大小的限制,但是,既然是内存,则肯定还是会受到本机总内存(包括物理内存、SWAP分区或者分页文件)大小以及处理器寻址空间的限制,一般服务器管理员配置虚拟机参数时,会根据实际内存去设置-Xmx等参数信息,但经常忽略掉直接内存,使得各个内存区域总和大于物理内存限制(包括物理的和操作系统级的限制),从而导致动态扩展时出现OOM异常。

2、垃圾回收

在弄清楚Java的内存以后,我们就要开始讲解垃圾回收了。垃圾回收的回收的就是对象。说到垃圾回收我们需要弄清楚的三个问题分别是:哪些内存(对象)需要回收?什么时候回收?怎么回收?。

2.1、哪些内存需要回收

在堆里面存放着Java世界中几乎所有的对象实例,垃圾收集器在对堆进行回收前,第一件事情就是要确定这些对象之中哪些还“存活”着,哪些已经“死去”(“死去”即不可能再被任何途径使用的对象)了。

2.1.1、引用计数算法

在对象中添加一个引用计数器,每当有一个地方引用它时,计数器的值就加一;当引用失效时,计数器的值就减一;任何时刻计数器的值为零的对象就是不可能再被使用的。
引用计数器虽然占用了一些额外的内存空间来进行计数,但它的原理简单,判定效率也很高,在大多数情况下它都是一个不错的算法。
但是在Java领域,至少主流的Java虚拟机里面都没有选用引用计数器来管理内存,主要原因是,这个看似简单的算法有很多例外情况要考虑,必须要配合大量额外处理才能保证正确处理,譬如单纯的引用计数就很难解决对象之间相互循环引用的问题。

2.1.2、可达性分析算法

当前主流的商用程序语言的内存管理子系统,都是通过可达性分心算法来判定对象是否存活。
算法的基本思路就是通过一系列称为“GC Roots”的根对象作为起始节点集,从这些根节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为“引用链”(Reference Chain),如果某个对象到GC Roots间没有任何引用链相连,或者用图论的话来说就是从GC Roots到这个对象不可达时,则证明此对象是不可能再被使用的。
如下图所示,对象object 5、object 6、object 7虽然互有关联,但是它们到GC Roots是不可达的,因此它们将会被判定为可回收的对象。

在Java技术体系里面,固定可作为GC Roots的对象包括以下几种:·
(1)在虚拟机栈(栈帧中的本地变量表)中引用的对象,譬如各个线程被调用的方法堆栈中使用到的参数、局部变量、临时变量等。·
(2)在方法区中类静态属性引用的对象,譬如Java类的引用类型静态变量。·在方法区中常量引用的对象,譬如字符串常量池(String Table)里的引用。·
(3)在本地方法栈中JNI(即通常所说的Native方法)引用的对象。·
(4)Java虚拟机内部的引用,如基本数据类型对应的Class对象,一些常驻的异常对象(比如NullPointExcepiton、OutOfMemoryError)等,还有系统类加载器。·
(5)所有被同步锁(synchronized关键字)持有的对象。·
(6)反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等。

2.2、回收方法区

有些人认为方法区(如HotSpot虚拟机中的元空间或者永久代)是没有垃圾收集行为的,《Java虚拟机规范》中提到过可以不要求虚拟机在方法区中实现垃圾收集,事实上也确实有未实现或未能完整实现方法区类型卸载的收集器存在(如JDK 11时期的ZGC收集器就不支持类卸载),方法区垃圾收集的“性价比”通常也是比较低的:在Java堆中,尤其是在新生代中,对常规应用进行一次垃圾收集通常可以回收70%至99%的内存空间,相比之下,方法区回收囿于苛刻的判定条件,其区域垃圾收集的回收成果往往远低于此。
方法区的垃圾收集主要回收两部分内容废弃的常量和不再使用的类型
回收废弃常量与回收Java堆中的对象非常类似。举个常量池中字面量回收的例子,假如一个字符串“java”曾经进入常量池中,但是当前系统又没有任何一个字符串对象的值是“java”,换句话说,已经没有任何字符串对象引用常量池中的“java”常量,且虚拟机中也没有其他地方引用这个字面量。如果在这时发生内存回收,而且垃圾收集器判断确有必要的话,这个“java”常量就将会被系统清理出常量池。常量池中其他类(接口)、方法、字段的符号引用也与此类似。
判定一个常量是否“废弃”还是相对简单,而要判定一个类型是否属于“不再被使用的类”的条件就比较苛刻了。需要同时满足下面三个条件:
(1)该类所有的实例都已经被回收,也就是Java堆中不存在该类及其任何派生子类的实例。
(2)加载该类的类加载器已经被回收,这个条件除非是经过精心设计的可替换类加载器的场景,如OSGi、JSP的重加载等,否则通常是很难达成的。(3)该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

2.3、堆中的垃圾回收算法

2.3.1、分代收集理论

当前商业虚拟机的垃圾收集器,大多数都遵循了“分代收集” 的理论进行设计,它建立在两个分代假说之上:
1)弱分代假说(Weak Generational Hypothesis):绝大多数对象都是朝生夕灭的。
2)强分代假说(Strong Generational Hypothesis):熬过越多次垃圾收集过程的对象就越难以消亡。

这两个分代假说共同奠定了多款常用的垃圾收集器的一致的设计原则:收集器应该将Java堆划分出不同的区域,然后将回收对象依据其年龄(年龄即对象熬过垃圾收集过程的次数)分配到不同的区域之中存储。
显而易见,如果一个区域中大多数对象都是朝生夕灭,难以熬过垃圾收集过程的话,那么把它们集中放在一起,每次回收时只关注如何保留少量存活而不是去标记那些大量将要被回收的对象,就能以较低代价回收到大量的空间;如果剩下的都是难以消亡的对象,那把它们集中放在一块,虚拟机便可以使用较低的频率来回收这个区域,这就同时兼顾了垃圾收集的时间开销和内存的空间有效利用。
把分代收集理论具体放到现在的商用Java虚拟机里,设计者一般至少会把Java堆划分为新生代(Young Generation)和老年代(OldGeneration)两个区域。
分代收集并非只是简单划分一下内存区域那么容易,它至少存在一个明显的困难:对象不是孤立的,对象之间会存在跨代引用。为了解决这个问题,就需要对分代收集理论添加第三条经验法则:
3)跨代引用假说(Intergenerational Reference Hypothesis):跨代引用相对于同代引用来说仅占极少数。
依据这条假说,我们就不应再为了少量的跨代引用去扫描整个老年代,也不必浪费空间专门记录每一个对象是否存在及存在哪些跨代引用,只需在新生代上建立一个全局的数据结构(该结构被称为“记忆集”,Remembered Set),这个结构把老年代划分成若干小块,标识出老年代的哪一块内存会存在跨代引用。此后当发生Minor GC时,只有包含了跨代引用的小块内存里的对象才会被加入到GC Roots进行扫描。虽然这种方法需要在对象改变引用关系(如将自己或者某个属性赋值)时维护记录数据的正确性,会增加一些运行时的开销,但比起收集时扫描整个老年代来说仍然是划算的。

2.3.2、标记清除算法

算法分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象,也可以反过来,标记存活的对象,统一回收所有未被标记的对象。标记过程就是对象是否属于垃圾的判定过程,可以采用可达性分析算法。它是最基础的收集算法,后续的收集算法大多都是以标记-清除算法为基础。
它的主要缺点有两个:第一个是执行效率不稳定,如果Java堆中包含大量对象,而且其中大部分是需要被回收的,这时必须进行大量标记和清除的动作,导致标记和清除两个过程的执行效率都随对象数量增长而降低;第二个是内存空间的碎片化问题,标记、清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致当以后在程序运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。

2.3.3、标记复制算法

主要运用在年轻代。
标记复制算法是为了解决标记-清除算法面对大量可回收对象时执行效率低的问题,它的发展也经历了两步。1969年Fenichel提出了一种称为“半区复制”(Semispace Copying)的垃圾收集算法,它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。如果内存中多数对象都是存活的,这种算法将会产生大量的内存间复制的开销,但对于多数对象都是可回收的情况,算法需要复制的就是占少数的存活对象,而且每次都是针对整个半区进行内存回收,分配内存时也就不用考虑有空间碎片的复杂情况,只要移动堆顶指针,按顺序分配即可。这样实现简单,运行高效,不过其缺陷也显而易见,这种复制回收算法的代价是将可用内存缩小为了原来的一半,空间浪费未免太多了一点。标记-复制算法的执行过程如下图所示。

但是新生代中的对象有98%熬不过第一轮收集。因此并不需要按照1∶1的比例来划分新生代的内存空间。
在1989年,Andrew Appel针对具备“朝生夕灭”特点的对象,提出了一种更优化的半区复制分代策略,现在称为“Appel式回收”。HotSpot虚拟机的Serial、ParNew等新生代收集器均采用了这种策略来设计新生代的内存布局[插图]。Appel式回收的具体做法是把新生代分为一块较大的Eden空间和两块较小的Survivor空间,每次分配内存只使用Eden和其中一块Survivor。发生垃圾搜集时,将Eden和Survivor中仍然存活的对象一次性复制到另外一块Survivor空间上,然后直接清理掉Eden和已用过的那块Survivor空间。HotSpot虚拟机默认Eden和Survivor的大小比例是8∶1,也即每次新生代中可用内存空间为整个新生代容量的90%(Eden的80%加上一个Survivor的10%),只有一个Survivor空间,即10%的新生代是会被“浪费”的。
当然,98%的对象可被回收仅仅是“普通场景”下测得的数据,任何人都没有办法百分百保证每次回收都只有不多于10%的对象存活,因此Appel式回收还有一个充当罕见情况的“逃生门”的安全设计,当Survivor空间不足以容纳一次Minor GC之后存活的对象时,就需要依赖其他内存区域(实际上大多就是老年代)进行分配担保(Handle Promotion)。

2.3.4、标记整理算法

主要运用在老年代。
标记-复制算法在对象存活率较高时就要进行较多的复制操作,效率将会降低。更关键的是,如果不想浪费50%的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都100%存活的极端情况,所以在老年代一般不能直接选用这种算法。
针对老年代对象的存亡特征,1974年Edward Lueders提出了另外一种有针对性的“标记-整理”(Mark-Compact)算法,其中的标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内存,“标记-整理”算法的示意图如下图所示。

MinorGC(标记复制)年轻代垃圾回收
fullGC(标记整理)老年代垃圾回收

更多推荐

最全最详细的Java垃圾回收机制

本文发布于:2024-03-10 02:53:25,感谢您对本站的认可!
本文链接:https://www.elefans.com/category/jswz/34/1726827.html
版权声明:本站内容均来自互联网,仅供演示用,请勿用于商业和其他非法用途。如果侵犯了您的权益请与我们联系,我们将在24小时内删除。
本文标签:最全   机制   垃圾   详细   Java

发布评论

评论列表 (有 0 条评论)
草根站长

>www.elefans.com

编程频道|电子爱好者 - 技术资讯及电子产品介绍!