写点什么

JVM 超神之路:跳槽必需的 JAVA 虚拟机笔记,被程序员转发百万次

发布于: 刚刚
JVM超神之路:跳槽必需的JAVA虚拟机笔记,被程序员转发百万次

今日分享开始啦,请大家多多指教~

初识 JVM

JAVA 虚拟机简介

JAVA 虚拟机:Java Virtual Machine,简称 JVM。JVM 可理解为是一台被定制过的现实当中不存在的计算机,模拟硬件执行字节码指令。

虚拟机:指通过软件模拟的具有完整硬件功能的、运行在一个完全隔离的环境中的完整计算机系统。

常见的虚拟机:JVM、VMwave、Virtual Box


JVM 和其他两个虚拟机的区别:

  • VMwave 与 VirtualBox 是通过软件模拟物理 CPU 的指令集,物理系统中会有很多的寄存器;

  • JVM 则是通过软件模拟 Java 字节码的指令集,JVM 中只是主要保留了 PC 寄存器,其他的寄存器都进行了裁剪。

了解 Java 程序的编译过程

我们在深入了解 JVM 前,需要大致了解一下 java 程序的一个加载过程。大致如下:

首先,我们的 java 程序经过 JDK 编译器执行编译为 class 字节码文件,然后通过类加载机制把字节码加载到 java 进程的方法区,在堆中生成一个 Class 类对象,作为方法区类信息的访问入口,随后,java 进程启动,这时候就会创建一个 Java 虚拟机,解释执行字节码指令(将字节码翻译为机器码),还存在 JIT 即时编译器(作用是把热点代码编译为机器码,之后就不用每次执行都翻译一遍,提高执行效率),最终还是申请系统调度 CPU 来执行机器码。

JAVA 内存区域与内存溢出异常

运行时数据区域

JVM 会在执行 Java 程序的过程中把它管理的内存划分为若干个不同的数据区域。这些数据区域各有各的用处,各有各的创建与销毁时间,有的区域随着 JVM 进程的启动而存在,有的区域则依赖用户线程的启动和结束而创建与销毁。一般来说,JVM 所管理的内存将会包含以下几个运行时数据区域:

  • 线程私有区域:程序计数器、Java 虚拟机栈、本地方法栈。

  • 线程共享区域:Java 堆、方法区、运行时常量池。

什么是线程私有?

由于 JVM 的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现,因此在任何一个确定的时刻,一个处理器(多核处理器则指的是一个内核)都只会执行一条线程中的指令。因此为了切换线程后能恢复到正确的执行位置,每条线程都需要独立的程序计数器,各条线程之间计数器互不影响,独立存储。我们就把类似这类区域称之为"线程私有"的内存。

程序计数器(线程私有)

程序计数器是一块比较小的内存空间,可以看做是当前线程所执行的字节码的行号指示器 (用来明确当线程切换出去后,要恢复时,下一个指令从哪个地方开始执行)。

如果当前线程正在执行的是一个 Java 方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是一个 Native 方法,这个计数器值为空。

程序计数器内存区域是唯一一个在 JVM 规范中没有规定任何 OOM(内存溢出)情况的区域!

Java 虚拟机栈(线程私有)

虚拟机栈描述的是 Java 方法执行的内存模型 : 每个方法执行的同时都会创建一个栈帧用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每一个方法从调用直至执行完成的过程,就对应一个栈帧在虚拟机栈中入栈和出栈的过程。java 虚拟机栈的生命周期与线程相同,线程启动,虚拟机栈就创建,线程销毁,虚拟机栈就销毁。

我们一直讲的 java 内存区域划分中的栈区域实际上就是此处的虚拟机栈,再详细一点,是虚拟机栈中的局部变量表部分。

局部变量表 :

局部变量表存放了编译期可知的各种 Java 虚拟机基本数据类型 (boolean、byte、char、short、int、float、long、double)、对象引用 (reference 类型,它并不等同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或者其他与此对象相关的位置)和 returnAddress 类型(指向了一条字节码指令的地址)。

局部变量表所需的内存空间在编译期间完成分配,当进入一个方法时,这个方法需要在帧中分配多大的局部变量空间是完全确定的,在执行期间不会改变局部变量表大小。

JAVA 虚拟机栈会产生以下两种异常:

  1. 如果线程请求的栈深度大于虚拟机所允许的深度(-Xss 设置栈容量),将会抛出 StackOverFlowError 异常。(比如使用递归时,如果逻辑错误,就会报此错误)

  2. 虚拟机在动态扩展时无法申请到足够的内存,会抛出 OOM(内存溢出)异常。

本地方法栈(线程私有)

本地方法栈(Native Method Stacks)与虚拟机栈所发挥的作用是非常相似的,其区别只是虚拟机栈为虚拟机执行 Java 方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的本地(Native)方法服务。在 HotSpot 虚拟机中,本地方法栈与虚拟机栈是同一块内存区域。与虚拟机栈一样,本地方法栈也会在栈深度溢 出或者栈扩展失败时分别抛出

StackOverflowError 和 OutOfMemoryError 异常。

Java 堆(线程共享)

对于 Java 应用程序来说,Java 堆(Java Heap)是虚拟机所管理的内存中最大的一块内存区域。Java 堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,Java 世界 里“几乎”所有的对象实例都在这里分配内存。

如果从分配内存的角度看,所有线程共享的 Java 堆中可以划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer,TLAB), 以提升对象分配时的效率。不过无论从什么角度,无论如何划分,都不 会改变 Java 堆中存储内容的共性,无论是哪个区域,存储的都只能是对象的实例,将 Java 堆细分的目的只是为了更好地回收内存,或者更快地分配内存。

Java 堆是垃圾回收器管理的主要区域,因此很多时候可以称之为"GC 堆"。根据 JVM 规范规定的内容,Java 堆可以处于物理上不连续的内存空间中。Java 堆在主流的虚拟机中都是可扩展的(-Xmx 设置最大值,-Xms 设置最小值)。如果在堆中没有足够的内存完成实例分配并且堆也无法再拓展时,将会抛出 OOM 错误。

方法区(线程共享)

方法区与 Java 堆一样,是各个线程共享的内存区域。它用于存储已被虚拟机加载的类信息、常量(必须是 static final 修饰的)、静态变量、即时编译器编译后的代码等数据。在 JDK8 以前的 HotSpot 虚拟机中,方法区也被称为"永久代"(JDK8 已经被元空间取代)。永久代并不意味着数据进入方法区就永久存在,此区域的内存回收主要是针对常量池的回收以及对类型的卸载。

JVM 规范规定:当方法区无法满足内存分配需求时,将抛出 OOM 异常。

注意:JDK1.7 之前,方法区就是永久代;JDK1.8 之后,方法区变成了元空间。

运行时常量池

运行时常量池(Runtime Constant Pool)是方法区的一部分。Class 文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池表(ConstantPoolTable),用于存放编译期生成的各种字面量与符号引用(这个地方指的是 class 文件常量池),这部分内容将在类加载后存放到方法区的运行时常量池中。

既然运行时常量池是方法区的一部分,自然受到方法区内存的限 制,当常量池无法再申请到内存时会抛出 OutOfMemoryError 异常。

字面量 : 字符串(JDK1.7 后移动到堆中) 、final 常量、基本数据类型的值。

符号引用 : 类和结构的完全限定名、字段的名称和描述符、方法的名称和描述符。

JAVA 堆内存溢出

当我们在进行创建变量,创建对象,类加载等操作时,需要在对应的内存区域分配一块内存空间。如果该区域内存不足,会先执行垃圾回收 GC,如果 GC 之后内存还是不够,就会出现内存溢出(OOM)的情况。

解决方案:

1.优化代码,提高代码的空间复杂度。

2.在 java 进程启动时,加大对应内存的空间的分配额度。(需要考虑系统内存够不够分配的问题)。

3.如果方法 2 中系统内存不足,可以加大系统内存分配。

内存泄漏

内存中,随着进程运行时间越来越长,存放的无用的数据(变量/常量值,对象,类型)越来越多,可用内存空间越来越少,如果某一进程一直运行,随着运行时间越来越长,最终一定会出现某个内存区域空间不足的问题,导致出现 OOM 报错。

解决方案:

(1)程序代码上优化:如设置超时时间,定时清理长期不用的数据(可以使用 jvm 的检测工具)。

(2)临时方案:有时候有些老旧的大型项目,不太好优化(即使使用内存泄漏的检测工具,也不好定位。万能重启大法:隔一定时间,重启 java 进程。如果重启间隔的时间觉得太短,加内存(java 进程内存,如果系统内存不满足 java 内存需求,还要加大系统内存)。

内存泄漏,随着使用时间越来越长,最终一定出现 OOM,但是 OOM 不是一定由内存泄漏引起~~

垃圾收集 Garbage Collection

GC 简介

JAVA 语言经过半个世纪的发展,今天的内存动态分配与内存回收技术已经相当成熟,一切看起来都进入了“自动化”时代,那为什么我们还要去了解垃圾收集和内存分配?答案很简单:当需要排查各种内存溢出、内存泄漏问题时,当垃圾收集成为系统达到更高并发量的瓶颈时,我们就必须对这些“自动化”的技术实施必要的监控和调节。

在上一篇博客中介绍了 Java 内存运行时区域的各个部分,其中程序计数 器、虚拟机栈、本地方法栈 3 个区域随线程而生,随线程而灭,栈中的栈帧随着方法的进入和退出而有条不紊地执行着出栈和入栈操作。每一 个栈帧中分配多少内存基本上是在类结构确定下来时就已知的(尽管在运行期会由即时编译器进行一些优化,但在基于概念模型的讨论里,大 体上可以认为是编译期可知的),因此这几个区域的内存分配和回收都具备确定性,在这几个区域内就不需要过多考虑如何回收的问题,当方法结束或者线程结束时,内存自然就跟随着回收了。

而 Java 堆和方法区这两个区域则有着很显著的不确定性:一个接口的多个实现类需要的内存可能会不一样,一个方法所执行的不同条件分支所需要的内存也可能不一样,只有处于运行期间,我们才能知道程序究竟会创建哪些对象,创建多少个对象,这部分内存的分配和回收是动态的,垃圾收集器所关注的正是这部分内存该如何管理。

如何判断对象已“死”?

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

引用计数算法

原理:在对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加 1;当引用失效时,计数器值就减 1;任何时刻计数器为零的对象就是不可能再被使用的。

客观地说,引用计数算法(Reference Counting)虽然占用了一些额外的内存空间来进行计数,但它的原理简单,判定效率也很高,在大多数情况下它都是一个不错的算法。但是,在 Java 领域,至少主流的 Java 虚拟机里面都没有选用引用计数算法来管理内存,主要原因是,这个看似简单的算法有很多例外情况要考虑,必须要配合大量额外处理才 能保证正确地工作,譬如单纯的引用计数就很难解决对象之间相互循环引用的问题。

参考代码:

GC 日志:

[GC (System.gc()) 6092K->856K(125952K), 0.0007504 secs]

从结果可以看出,GC 日志包含" 6092K->856K(125952K)",意味着虚拟机并没有因为这两个对象互相引用就不回收他们。即 JVM 并不使用引用计数法来判断对象是否存活。

可达性分析算法

原理:通过 一系列称为“GC Roots”的根对象作为起始节点集,从这些节点开始,根 据引用关系向下搜索,搜索过程所走过的路径称为“引用链”(Reference Chain),如果某个对象到 GC Roots 间没有任何引用链相连,或者用图论的话来说就是从 GC Roots 到这个对象不可达时,则证明此对象是不可能再被使用的。 如下图所示,对象 object 5、object 6、object 7 虽然互有关联,但是它们到 GC Roots 是不可达的,因此它们将会被判定为可回收的对象。

在 Java 技术体系里面,固定可作为 GC Roots 的对象包括以下几种:

  • 在虚拟机栈(栈帧中的本地变量表)中引用的对象,譬如各个线程被调用的方法堆栈中使用到的参数、局部变量、临时变量等。

  • 在方法区中类静态属性引用的对象,譬如 Java 类的引用类型静态变量。

  • 在方法区中常量引用的对象,譬如字符串常量池(String Table)里的引用。

  • 在本地方法栈中 JNI(即通常所说的 Native 方法)引用的对象。

  • Java 虚拟机内部的引用,如基本数据类型对应的 Class 对象,一些常驻的异常对象(比如 NullPointExcepiton、OutOfMemoryError)等,还 有系统类加载器。

  • 所有被同步锁(synchronized 关键字)持有的对象。

  • 反映 Java 虚拟机内部情况的 JMXBean、JVMTI 中注册的回调、本地代码缓存等。

除了这些固定的 GC Roots 集合以外,根据用户所选用的垃圾收集器 以及当前回收的内存区域不同,还可以有其他对象“临时性”地加入,共 同构成完整 GC Roots 集合。譬如分代收集和局部回收 (Partial GC),如果只针对 Java 堆中某一块区域发起垃圾收集时(如最典型的只针对新生代的垃圾收集),必须考虑到内存区域是虚拟机自己 的实现细节(在用户视角里任何内存区域都是不可见的),更不是孤立封闭的。

所以某个区域里的对象完全有可能被位于堆中其他区域的对象所引用,这时候就需要将这些关联区域的对象也一并加入 GC Roots 集合 中去,才能保证可达性分析的正确性。

生存还是死亡?

即使在可达性分析算法中判定为不可达的对象,也不是“非死不可”的,这时候它们暂时还处于“缓刑”阶段,要真正宣告一个对象死亡,至少要经历两次标记过程:

  • 如果对象在进行可达性分析后发现没有与 GC Roots 相连接的引用链,那它将会被第一次标记,随后进行一次筛选,筛选的条件是此对象是否有必要执行 finalize()方法。

  • 假如对象没有覆盖 finalize()方法,或者 finalize()方法已经被虚拟机调用过,那么虚拟机将这两种情况都视为“没有必要执行”。

  • 如果这个对象被判定为确有必要执行 finalize()方法,那么该对象将会被放置在一个名为 F-Queue 的队列之中,并在稍后由一条由虚拟机自动建立的、低调度优先级的 Finalizer 线程去执行它们的 finalize()方法。

这里所说的“执行”是指虚拟机会触发这个方法开始运行,但并不承诺一 定会等待它运行结束。这样做的原因是,如果某个对象的 finalize()方法执行缓慢,或者更极端地发生了死循环,将很可能导致 F-Queue 队列中的其他对象永久处于等待,甚至导致整个内存回收子系统的崩溃。

  • finalize()方法是对象逃脱死亡命运的最后一次机会,稍后收集器将对 F-Queue 中的对象进行第二次小规模的标记,如果对象要在 finalize()中成功拯救自己,只要重新与引用链上的任何一个对象建立关联即可,譬如把自己(this 关键字)赋值给某个类变量或者对象的成员变量,那在第二次标记时它将被移出“即将回收”的集合;如果对象这时候还没有逃脱,那基本上它就真的要被回收了。

finalize()能做的所有工作, 使用 try-finally 或者其他方式都可以做得更好、更及时,所以笔者大家可以完全可以忘掉 Java 语言里面的这个方法。

再谈引用

无论是通过引用计数算法判断对象的引用数量,还是通过可达性分析算法判断对象是否引用链可达,判定对象是否存活都和“引用”离不开 关系。在 JDK 1.2 版之前,Java 里面的引用是很传统的定义:如果 reference 类型的数据中存储的数值代表的是另外一块内存的起始地址, 就称该 reference 数据是代表某块内存、某个对象的引用。

这种定义并没 有什么不对,只是现在看来有些过于狭隘了,一个对象在这种定义下只有“被引用”或者“未被引用”两种状态,对于描述一些“食之无味,弃之 可惜”的对象就显得无能为力。譬如我们希望能描述一类对象:当内存空间还足够时,能保留在内存之中,如果内存空间在进行垃圾收集后仍然非常紧张,那就可以抛弃这些对象。

在 JDK 1.2 版之后,Java 对引用的概念进行了扩充,将引用分为强引用(Strongly Re-ference)、软引用(Soft Reference)、弱引用(Weak Reference)和虚引用(Phantom Reference)4 种,这 4 种引用强度依次逐渐减弱。

  • 强引用是最传统的“引用”的定义,是指在程序代码之中普遍存在的引用赋值,即类似“Object obj=new Object()”这种引用关系。无论任何情况下,只要强引用关系还存在,垃圾收集器就永远不会回收掉被引用的对象。

  • 软引用是用来描述一些还有用,但非必须的对象。只被软引用关联着的对象,在系统将要发生内存溢出异常前,会把这些对象列进回收范围之中进行第二次回收,如果这次回收还没有足够的内存,才会抛出内存溢出异常。在 JDK1.2 版之后提供了 SoftReference 类来实现软引用。

  • 弱引用也是用来描述那些非必须对象,但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生为止。当垃圾收集器开始工作,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。在 JDK 1.2 版之后提供了 WeakReference 类来实现弱引用。

  • 虚引用也称为“幽灵引用”或者“幻影引用”,它是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的 唯一目的只是为了能在这个对象被收集器回收时收到一个系统通知。在 JDK 1.2 版之后提供了 PhantomReference 类来实现虚引用。

回收方法区

方法区的垃圾收集主要回收两部分内容:废弃的常量和不再使用的类型。

回收废弃常量与回收 Java 堆中的对象非常类似。举个常量池中字面量回收的例子,假如一个字符串“java”曾经进入常量池中,但是当前系统又没有任何一个字符串对象的值是“java”,换句话说,已经没有任何字符串对象引用常量池中的“java”常量,且虚拟机中也没有其他地方引用这个字面量。如果在这时发生内存回收,而且垃圾收集器判断确有必要的话,这个“java”常量就将会被系统清理出常量池。常量池中其他类(接口)、方法、字段的符号引用也与此类似。

判定一个常量是否“废弃”还是相对简单,而要判定一个类型是否属 于“不再被使用的类”的条件就比较苛刻了。需要同时满足下面三个条件:

  • 该类所有的实例都已经被回收,也就是 Java 堆中不存在该类及其任何派生子类的实例。

  • 加载该类的类加载器已经被回收,这个条件除非是经过精心设计的可替换类加载器的场景,如 OSGi、JSP 的重加载等,否则通常是很难达成的。

  • 该类对应的 java.lang.Class 对象没有在任何地方被引用,无法在任 何地方通过反射访问该类的方法。

Java 虚拟机被允许对满足上述三个条件的无用类进行回收,这里说 的仅仅是“被允许”,而并不是和对象一样,没有引用了就必然会回收。关于是否要对类型进行回收,HotSpot 虚拟机提供了-Xnoclassgc 参数进行控制,还可以使用-verbose:class 以及-XX:+TraceClass-Loading、- XX:+TraceClassUnLoading 查看类加载和卸载信息。

分代收集理论

当前商业虚拟机的垃圾收集器,大多数都遵循了“分代收集”(Generational Collection)的理论进行设计,分代收集名为理论, 实质是一套符合大多数程序运行实际情况的经验法则,它建立在两个分代假说之上:

  • 弱分代假说(Weak Generational Hypothesis):绝大多数对象都是朝生夕灭的。

  • 强分代假说(Strong Generational Hypothesis):熬过越多次垃圾收集过程的对象就越难以消亡。

这两个分代假说共同奠定了多款常用的垃圾收集器的一致的设计原则:

收集器应该将 Java 堆划分出不同的区域,然后将回收对象依据对象熬过垃圾收集过程的次数分配到不同的区域之中存储。显而易见,如果一个区域中大多数对象都是朝生夕灭,难以熬过垃圾收集过程的话,那么把它们集中放在一起,每次回收时只关注如何保留少量存活而不是去标记那些大量将要被回收的对象,就能以较低代价回收到大量的空间;如果剩下的都是难以消亡的对象,那把它们集中放在一块,虚拟机便可以使用较低的频率来回收这个区域,这就同时兼顾了垃圾收集的时间开销和内存的空间有效利用。

在 Java 堆划分出不同的区域之后,垃圾收集器才可以每次只回收其中某一个或者某些部分的区域,因而才有了“Minor GC” “Major GC” “Full GC”这样的回收类型的划分;也才能够针对不同的区域安排与 里面存储对象存亡特征相匹配的垃圾收集算法,因而发展出了“标记- 复制算法”“标记-清除算法”“标记-整理算法”等针对性的垃圾收集算法。

把分代收集理论具体应用到现在的商用 Java 虚拟机里,设计者一般至少会把 Java 堆划分为新生代(Young Generation)和 老年代(Old Generation)两个区域,其中,新生代又可以分为 Eden 空间、From Survivor 空间、To Survivor 空间。顾名思义,在新生代中,每次垃圾收集时都发现有大批对象死去,而每次回收后存活的少量对象,将会逐步晋升到老年代中存放。

名词解释:

部分收集(Partial GC):指目标不是完整收集整个 Java 堆的垃圾收集,其中又分为:

新生代收集(Minor GC/Young GC):指目标只是新生代的垃圾收集。

老年代收集(Major GC/Old GC):指目标只是老年代的垃圾收集。目前只有 CMS 收集器会有单独收集老年代的行为。

混合收集(Mixed GC):指目标是收集整个新生代以及部分老年代的垃圾收集。目前只有 G1 收集器会有这种行为。

整堆收集(Full GC):收集整个 Java 堆和方法区的垃圾收集。

垃圾回收算法

从如何判定对象消亡的角度出发,垃圾收集算法可以划分为“引用计数式垃圾收集”(Reference Counting GC)和“追踪式垃圾收集”(Tracing GC)两大类,这两类也常被称作“直接垃圾收集”和“间接 垃圾收集”。

标记-清除算法(老年代)

最早出现也是最基础的垃圾收集算法是“标记-清除”(Mark- Sweep)算法,在 1960 年由 Lisp 之父 John McCarthy 所提出。

如它的名字 一样,算法分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象,也可以反过来, 标记存活的对象,统一回收所有未被标记的对象。标记过程就是对象是否属于垃圾的判定过程,这在上面讲述垃圾对象标记判定算法时其实已经介绍过了。

之所以说它是最基础的收集算法,是因为后续的收集算法大多都是以标记-清除算法为基础,对其缺点进行改进而得到的。它的主要缺点有两个:

  • 第一个是执行效率不稳定,如果 Java 堆中包含大量对象,而且其中大部分是需要被回收的,这时必须进行大量标记和清除的动作,导致标记和清除两个过程的执行效率都随对象数量增长而降低;

  • 第二个是内存空间的碎片化问题,标记、清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致当以后在程序运行过程中需要分配较大象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。

标记-清除算法的执行过程如下图所示:

标记-复制算法(新生代)

标记-复制算法是为了解决标记-清除算法面对大量可回收对象时执行效率低的问题,1969 年 Fenichel 提出了一种称 为“半区复制”(Semispace Copying)的垃圾收集算法。

它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。如果内存中多数对象都是存活的,这种算法将会产生大量的内存间复制的开销,但对于多数对象都是可回收的情况, 算法需要复制的就是占少数的存活对象,而且每次都是针对整个半区进行内存回收,分配内存时也就不用考虑有空间碎片的复杂情况,只要移动堆顶指针,按顺序分配即可。这样实现简单,运行高效,不过其缺陷也显而易见,这种复制回收算法的代价是将可用内存缩小为了原来的一 半,空间浪费未免太多了一点。

标记-复制算法的执行过程如下图所示:

标记-整理算法(老年代)

标记-复制算法在对象存活率较高时就要进行较多的复制操作,效率将会降低。更关键的是,如果不想浪费 50%的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都 100%存活的极端情况,所以在老年代一般不能直接选用这种算法。

针对老年代对象的存亡特征,1974 年 Edward Lueders 提出了另外一 种有针对性的“标记-整理”(Mark-Compact)算法,其中的标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内存。

标记-清除算法与标记-整理算法的本质差异在于前者是一种非移动式的回收算法,而后者是移动式的。是否移动回收后的存活对象是一项优缺点并存的风险决策:

  • 如果移动存活对象,尤其是在老年代这种每次回收都有大量对象存活区域,移动存活对象并更新所有引用这些对象的地方将会是一种极负重的操作,而且这种对象移动操作必须全程暂停用户应用程序才能进行;

  • 但如果跟标记-清除算法那样完全不考虑移动和整理存活对象的话,弥散于堆中的存活对象导致的空间碎片化问题就只能依赖更为复杂的内存分配器和内存访问器来解决。譬如通过“分区空闲分配链表”来解决内存分配问题。内存的访问是用户程序最频繁的操作,甚至都没有之一,假如在这个环节上增加了额外的负担,势必会直接影响应用程序的吞吐量。

基于以上两点,是否移动对象都存在弊端,移动则内存回收时会更复杂,不移动则内存分配时会更复杂。从垃圾收集的停顿时间来看,不移动对象停顿时间会更短,甚至可以不需要停顿,但是从整个程序的吞吐量来看,移动对象会更划算。

“标记-整理”算法的示意图如下图所示:

今日份分享已结束,请大家多多包涵和指点!

用户头像

还未添加个人签名 2021.04.20 加入

Java工具与相关资料获取等WX: pfx950924(备注来源)

评论

发布
暂无评论
JVM超神之路:跳槽必需的JAVA虚拟机笔记,被程序员转发百万次