写点什么

从 JAVA 内存到垃圾回收,带你深入理解 JVM

发布于: 2021 年 01 月 26 日

【摘要】今天带你走进 JVM 的世界。


学过 Java 程序员对 JVM 应该并不陌生,如果你没有听过,没关系今天我带你走进 JVM 的世界。程序员为什么要学习 JVM 呢,其实不懂 JVM 也可以照样写出优质的代码,但是不懂 JVM 有可能别被面试官虐得体无完肤。


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


1.1 运行时数据区域


image


1.1.1 程序计数器


当前线程所执行的字节码的行号指示器,是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖程序计数器。内存较小。


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


此内存区域是唯一一个在《java 虚拟机规范》中没有规定任何 OOM 情况的区域。


1.1.2 java 虚拟机栈


线程私有,Java 虚拟机栈的生命周期与线程相同。


Java 虚拟机栈描述的是 Java 方法执行的线程内存模型:每个方法被执行的时候,Java 虚拟机都会同步创建一个栈帧用于保存 局部变量表、操作数栈、动态链接、方法出口等信息。每个方法被调用直至执行完毕的过程,对应了栈帧在虚拟机栈中入栈到出栈的过程。


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


Java 虚拟机栈会出现两种错误:StackOverFlowError 和 OutOfMemoryError。


• StackOverflowError: 若 Java 虚拟机栈的内存大小不允许动态扩展,那么当线程请求栈的深度超过当前 Java 虚拟机栈的最大深度的时候,就抛出 StackOverFlowError 错误。


OutOfMemoryError: 若 Java 虚拟机栈的内存大小允许动态扩展,且当线程请求栈时内存用完了,无法再动态扩展了,此时抛出 OutOfMemoryError 错误。


Java 方法有两种返回方式:return 语句;抛出异常,不管哪种返回方式都会导致栈帧被弹出。


参数-Xss


1.1.3 本地方法栈


和虚拟机栈所发挥的作用非常相似,区别是: 虚拟机栈为虚拟机执行 Java 方法 (也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。 在 HotSpot 虚拟机中和 Java 虚拟机栈合二为一。和虚拟机栈一样会产生 StackOverFlowError 和 OutOfMemoryError。


1.1.4 java 堆


Java 虚拟机所管理的内存中最大的一块,Java 堆是所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例以及数组都在这里分配内存。


Java 世界中“几乎”所有的对象都在堆中分配,但是,随着 JIT 编译期的发展与逃逸分析技术逐渐成熟,栈上分配、标量替换优化技术将会导致一些微妙的变化,所有的对象都分配到堆上也渐渐变得不那么“绝对”了。从 jdk 1.7 开始已经默认开启逃逸分析,如果某些方法中的对象引用没有被返回或者未被外面使用(也就是未逃逸出去),那么对象可以直接在栈上分配内存。


Java 堆是垃圾收集器管理的主要区域,因此也被称作 GC 堆(Garbage Collected Heap)。从垃圾回收的角度,由于现在收集器基本都采用分代垃圾收集算法,所以 Java 堆还可以细分为:新生代和老年代:再细致一点将新生代分为:Eden 空间、From Survivor、To Survivor 空间。进一步划分的目的是更好地回收内存,或者更快地分配内存。


在 JDK 7 版本及 JDK 7 版本之前,堆内存被通常被分为下面三部分:


  1. 新生代内存(YoungGeneration)

  2. 老生代(OldGeneration)

  3. 永生代(PermanentGeneration)


堆这里最容易出现的就是 OutOfMemoryError 错误,比如:


  1. OutOfMemoryError: GC Overhead Limit Exceeded : 当 JVM 花太多时间执行垃圾回收并且只能回收很少的堆空间时,就会发生此错误。

  2. java.lang.OutOfMemoryError: Java heap space :假如在创建新的对象时, 堆内存中的空间不足以存放新创建的对象, 就会引发 java.lang.OutOfMemoryError:Java heap space 错误。


1.1.5 方法区


线程共享,用于保存已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。虽然 Java 虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫做 Non-Heap(非堆),目的应该是与 Java 堆区分开来。


方法区和永久代的关系


《Java 虚拟机规范》只是规定了有方法区这个概念和它的作用,并没有规定如何去实现它。那么,在不同的 JVM 上方法区的实现肯定是不同的了。 方法区和永久代的关系很像 Java 中接口和类的关系,类实现了接口,而永久代就是 HotSpot 虚拟机对虚拟机规范中方法区的一种实现方式,当时的 HotSpot 虚拟机设计团队选择把收集器的分代设计扩展到方法区。 也就是说,永久代是 HotSpot 的概念,方法区是 Java 虚拟机规范中的定义,是一种规范,而永久代是一种实现,一个是标准一个是实现,其他的虚拟机实现并没有永久代这一说法。


为什么要将永久代 (PermGen) 替换为元空间 (MetaSpace) 呢?


  1. 整个永久代有一个 JVM 本身设置固定大小上限,无法进行调整,而元空间使用的是直接存,受本机可用内存的限制,虽然元空间仍旧可能溢出,但是比原来出现的几率会更小。


当元空间溢出时会得到如下错误: java.lang.OutOfMemoryError:MetaSpace


你可以使用 -XX:MaxMetaspaceSize 标志设置最大元空间大小,默认值为 unlimited,这意味着它只受系统内存的限制。-XX:MetaspaceSize 调整标志定义元空间的初始大小如果未指定此标志,则 Metaspace 将根据运行时的应用程序需求动态地重新调整大小。


  1. 元空间里面存放的是类的元数据,这样加载多少类的元数据就不由 MaxPermSize 控制了, 而由系统的实际可用空间来控制,这样能加载的类就更多了。

  2. 在 JDK8,合并 HotSpot 和 JRockit 的代码时, JRockit 从来没有一个叫永久代的概念, 合并之后就没有必要额外的设置这么一个永久代的地方了。


方法区的发展迁移过程


JDK 6 时,HotSpot 团队就有放弃永久代、逐步改为本地内存来实现方法区的计划了。JDK 7 ,已经把原本放在永久代的字符串常量池、静态变量等移除。JDK8,终于完全放弃了永久代,把 JDK 7 中永久代还剩余的内容(主要是类型信息)全部移到元空间中。


根据《Java 虚拟机规范》,如果方法区无法满足新的内存分配需求时,将抛出 OOM 异常。


1.1.6 运行时常量池


运行时常量池是方法区的一部分。Class 文件中除了有类的版本、字段、方法、接口等描述信息外,还有常量池表(用于存放编译期生成的各种字面量和符号引用)


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


JDK1.7 及之后版本的 JVM 已经将运行时常量池从方法区中移了出来,在 Java 堆(Heap)中开辟了一块区域存放运行时常量池。


  1. JDK1.7 之前运行时常量池逻辑包含字符串常量池存放在方法区, 此时 hotspot 虚拟机对方法区的实现为永久代

  2. JDK1.7 字符串常量池被从方法区拿到了堆中, 这里没有提到运行时常量池,也就是说字符串常量池被单独拿到堆,运行时常量池剩下的东西还在方法区, 也就是 hotspot 中的永久代 。

  3. JDK1.8 hotspot 移除了永久代用元空间(Metaspace)取而代之, 这时候字符串常量池还在堆, 运行时常量池还在方法区, 只不过方法区的实现从永久代变成了元空间(Metaspace)


1.1.7 直接内存


直接内存并不是虚拟机运行时数据区的一部分,也不是虚拟机规范中定义的内存区域,但是这部分内存也被频繁地使用。而且也可能导致 OutOfMemoryError 错误出现。


JDK1.4 中新加入的 NIO(NewInput/Output) 类,引入了一种基于通道(Channel) 与缓存区(Buffer) 的 I/O 方式,它可以直接使用 Native 函数库直接分配堆外内存,然后通过一个存储在 Java 堆中的 DirectByteBuffer 对象作为这块内存的引用进行操作。这样就能在一些场景中显著提高性能,因为避免了在 Java 堆和 Native 堆之间来回复制数据。


本机直接内存的分配不会受到 Java 堆的限制,但是,既然是内存就会受到本机总内存大小以及处理器寻址空间的限制。


2、垃圾回收


2.1 虚拟机如何判断对象是否存活?


1.引用计数算法


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


考虑一种情形:对象 objA 和 objB 都有字段 instance,赋值令 objA.instance=objB 和 objB.instance=objA;除此之外,这两个对象再无任何引用,实际上这两个对象以及不可能再被访问,但是它们因为互相引用着对方,导致它们的引用计数都不为 0,于是引用计数算法无法通知 GC 收集器回收它们。如果这个对象特别大,则会造成严重的内存泄露。


2.可达性分析算法


基本思想:通过一系列的称为”GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(ReferenceChain),当一个对象到 GC Roots 没有任何引用链时,则证明此对象是不可用的。


GC Roots 的对象包括下面几种:


• 虚拟机栈(栈帧中的本地变量表)中引用的对象。


• 方法区中类静态属性引用的对象。


• 方法区中常量引用的对象。


• 本地方法栈中 JNI 引用的对象。


垃圾收集算法


1.标记-清除算法(Mark-Sweep)


image


最基础的收集算法,分为”标记”和”清除”两个阶段:首先标记处所有需要回收的对象,在标记完成后统一回收所有被标记的对象。


主要不足有两个:一是效率问题,标记和清除两个过程的效率都不高;另一个是空间问题,标记清除之后会产生大量不连续的内存碎片,空间碎片大多可能会导致以后再程序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前出发另一次垃圾收集动作。


2.复制算法(Copying)


image


为了解决效率问题,一种称为”复制“的收集算法出现,它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。这样使得每次都是对整个半区进行内存回收,内存分配时也就不用考虑内存碎片等复杂情况,只要移动堆顶指正按顺序分配内存即可,实现简单,运行高效。只是这种算法的代价是将内存缩小为原来的一般,未免太高了一点。


现代的商业虚拟机都采用这种收集算法来回收新生代,IBM 公司研究表明,新生代的对象 98%都是”朝生夕死“的,所以并不需要 1:1 的比例来划分内存空间,而是将内存划分为一块较大的 Eden 空间和两块较小的 Survivor 空间。HotSpot 默认 Eden 和 Survivor 的大小比例是 8:1.如果 Survivor 空间不够用时,需要依赖其他内存(老年代)进行分配担保(HandlePromotion)。


3.标记-整理算法(Mark-Compact)


image


复制收集算法在对象存活率较高时就要进行较多的复制操作,效率将会变低。根据老年代的特点,提出此种算法,标记过程仍然与”标记-清除“算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界意外的内存。


4.分代收集算(GenerationalCollection)


当前商业虚拟机的垃圾收集都采用”分代收集“算法。一般是把 java 堆分成新生代和老年代。在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。而老年代中因为对象存活率高、没有额外空间对它进行分配担保,就必须使用”标记——清理“或者”标记——整理“算法来进行回收。


垃圾收集器


Serial 收集器


这是一个单线程的收集器,但它的“单线程”的意义并不仅仅说明它只会使用一个 CPU 或一条手机线程去完成垃圾手机工作,更重要的是在它进行垃圾收集时,必须暂停其他所有的工作线程,直到它收集结束。“Stop theworld”,由虚拟机在后台自动发起和自动完成的,在用户不可见的情况下把用户正常工作的线程全部停掉,这对很多应用来说都是难以接受的。它是虚拟机运行在 Client 模式下的默认新生代收集器。


优点:简单而高效(与其他收集器的单线程比),对于限定单个 CPU 的环境来说,Serial 收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得最高的单线程收集效率。


ParNew 收集器


ParNew 收集器其实就是 serial 收集器的多线程版本,除了使用多条线程进行垃圾收集之外,其余行为与 Serial 收集器一样。ParNew 收集器也是使用-XX:+UseConcMarkSweepGC 选项后的默认新生代收集器,也可以使用-XX:+UseParNewGC 选项来强制指定它。


ParallelScavenge 收集器


Parallel Scavenge 收集器也是一个新生代收集器,它也是使用复制算法的收集器,又是并行多线程收集器。parallelScavenge 收集器的特点是它的关注点与其他收集器不同,CMS 等收集器的关注点是尽可能地缩短垃圾收集时用户线程的停顿时间,而 parallelScavenge 收集器的目标则是达到一个可控制的吞吐量。吞吐量= 程序运行时间/(程序运行时间 + 垃圾收集时间),虚拟机总共运行了 100 分钟。其中垃圾收集花掉 1 分钟,那吞吐量就是 99%。


Parallel Scavenge 收集器提供了两个参数用于精确控制吞吐量,分别是控制最大垃圾收集停顿时间的-XX:MaxGCPauseMillis 参数以及直接设置吞吐量大小的-XX:GCTimeRatio 参数。


Serial Old 收集器


Serial Old 是 Serial 收集器的老年代版本,它同样使用一个单线程执行收集,使用“标记-整理”算法。主要使用在 Client 模式下的虚拟机。


ParallelOld 收集器


Parallel Old 是 ParallelScavenge 收集器的老年代版本,使用多线程和“标记-整理”算法。


CMS 收集器


CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。CMS 收集器是基于“标记-清除”算法实现的,整个收集过程大致分为 4 个步骤:


• 初始标记(CMSinitial mark)


• 并发标记(CMSconcurrenr mark)


• 重新标记(CMSremark)


• 并发清除(CMSconcurrent sweep)


其中初始标记、重新标记这两个步骤任然需要停顿其他用户线程。初始标记仅仅只是标记出 GC ROOTS 能直接关联到的对象,速度很快,并发标记阶段是进行 GC ROOTS 根搜索算法阶段,会判定对象是否存活。而重新标记阶段则是为了修正并发标记期间,因用户程序继续运行而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间会被初始标记阶段稍长,但比并发标记阶段要短。


由于整个过程中耗时最长的并发标记和并发清除过程中,收集器线程都可以与用户线程一起工作,所以整体来说,CMS 收集器的内存回收过程是与用户线程一起并发执行的。


CMS 收集器的优点:并发收集、低停顿,但是 CMS 还远远达不到完美,器主要有三个显著缺点:


CMS 收集器对 CPU 资源非常敏感。在并发阶段,虽然不会导致用户线程停顿,但是会占用 CPU 资源而导致引用程序变慢,总吞吐量下降。CMS 默认启动的回收线程数是:(CPU 数量+3) / 4。


CMS 收集器无法处理浮动垃圾,可能出现“ConcurrentMode Failure“,失败后而导致另一次 Full GC 的产生。由于 CMS 并发清理阶段用户线程还在运行,伴随程序的运行自热会有新的垃圾不断产生,这一部分垃圾出现在标记过程之后,CMS 无法在本次收集中处理它们,只好留待下一次 GC 时将其清理掉。这一部分垃圾称为“浮动垃圾”。也是由于在垃圾收集阶段用户线程还需要运行,即需要预留足够的内存空间给用户线程使用,因此 CMS 收集器不能像其他收集器那样等到老年代几乎完全被填满了再进行收集,需要预留一部分内存空间提供并发收集时的程序运作使用。在默认设置下,CMS 收集器在老年代使用了 68%的空间时就会被激活,也可以通过参数-XX:CMSInitiatingOccupancyFraction 的值来提供触发百分比,以降低内存回收次数提高性能。要是 CMS 运行期间预留的内存无法满足程序其他线程需要,就会出现“ConcurrentMode Failure”失败,这时候虚拟机将启动后备预案:临时启用 Serial Old 收集器来重新进行老年代的垃圾收集,这样停顿时间就很长了。所以说参数-XX:CMSInitiatingOccupancyFraction 设置的过高将会很容易导致“ConcurrentMode Failure”失败,性能反而降低。


最后一个缺点,CMS 是基于“标记-清除”算法实现的收集器,使用“标记-清除”算法收集后,会产生大量碎片。空间碎片太多时,将会给对象分配带来很多麻烦,比如说大对象,内存空间找不到连续的空间来分配不得不提前触发一次 Full GC。为了解决这个问题,CMS 收集器提供了一个-XX:UseCMSCompactAtFullCollection 开关参数,用于在 Full GC 之后增加一个碎片整理过程,还可通过-XX:CMSFullGCBeforeCompaction 参数设置执行多少次不压缩的 Full GC 之后,跟着来一次碎片整理过程。


G1 收集器(Garbage-First)


G1 是一款面向服务器应用垃圾收集器,与其他 GC 收集器想必,G1 具备以下特点:


• 并行与并发:G1 能充分利用多 CPU、多核环境下的硬件优势,使用多个 CPU 来缩短 Stop-The-World 停顿的时间,部分其他收集器原本需要停顿 Java 线程执行的 GC 动作,G1 收集器仍然可以通过并发的方式让 Java 程序继续执行。


• 分代收集:与其他收集器一样,分代概念在 G1 中依然得以保留。虽然 G1 可以不要其他收集器配合就能独立管理整个 GC 堆,但它能够采用不同的方式去处理新创建的对象和已经存活了一半时间、熬过多次 GC 的旧对象以获取更好的收集效果。


• 空间整合:与 CMS 的“标记-清理”算法不同,G1 从整体上看是基于“标记-整理”算法实现的收集器,从局部(两个 Region 之间)上来看是基于“复制”算法实现,无论如何,这两种算法都意味着 G1 运行期间不会产生内存空间碎片,收集后能提供规整的可用内存。


• 可预测的停顿:这是 G1 相对于 CMS 的另一个大优势,降低停顿时间是 G1 和 CMS 共同的关注点,但 G1 除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为 M 毫秒的时间片段内,小号在垃圾收集上的时间不能超过 N 毫秒,这几乎已经是实时 Java(RTSJ)的垃圾收集器的特征了。


在 G1 收集器中,Region 之间的对象引用以及其他收集器中的新生代与老年代之间的对象引用,虚拟机都是使用 RememberedSet 来避免全堆扫描的。G1 中每个 Region 都有一个与之对应的 Remebered Set,虚拟机发现程序在对 Reference 类型的数据进行写操作时,会产生一个 WriteBarrier 暂时中断写操作,检查 Reference 引用的对象是否处于不同的 Region 之中(在分代的例子中,就是检查是否老年代中的读写引用了新生代中的对象)。如果是,便通过 CardTable 把相关引用信息记录到被引用对象所属的 Region 的 Remembered Set 之中。当进行内存回收时,在 GC 根节点的枚举范围中加入 RememeredSet 即可保证不对全队扫描也不会有遗漏。


如果不计算维护 Remembered Set 的操作,G1 收集器的运作大致可划分为以下几个步骤:


• 初始标记


• 并发标记


• 最终标记


• 筛选标记


本文分享自华为云社区《深入理解JVM阅读笔记一》,原文作者:ayin 。


点击关注,第一时间了解华为云新鲜技术~


发布于: 2021 年 01 月 26 日阅读数: 36
用户头像

提供全面深入的云计算技术干货 2020.07.14 加入

华为云开发者社区,提供全面深入的云计算前景分析、丰富的技术干货、程序样例,分享华为云前沿资讯动态,方便开发者快速成长与发展,欢迎提问、互动,多方位了解云计算! 传送门:https://bbs.huaweicloud.com/

评论

发布
暂无评论
从JAVA内存到垃圾回收,带你深入理解JVM