写点什么

JVM 及 GC 机制

作者:Java高工P7
  • 2021 年 11 月 11 日
  • 本文字数:7132 字

    阅读完需:约 23 分钟

方法区存放了要加载的类的信息(如类名,修饰符)、类中的静态变量、final 定义的常量、类中的 field、方法信息,当开发人员调用类对象中的 getName、isInterface 等方法来获取信息时,这些数据都来源于方法区。方法区是全局共享的,在一定条件下它也会被 GC。当方法区使用的内存超过它允许的大小时,就会抛出 OutOfMemory:PermGen Space 异常。


在 Hotspot 虚拟机中,这块区域对应的是 Permanent Generation(持久代),一般的,方法区上执行的垃圾收集是很少的,因此方法区又被称为持久代的原因之一,但这也不代表着在方法区上完全没有垃圾收集,其上的垃圾收集主要是针对常量池的内存回收和对已加载类的卸载。在方法区上进行垃圾收集,条件苛刻而且相当困难,关于其回后面再介绍。


运行时常量池(Runtime Constant Pool)是方法区的一部分,用于存储编译期就生成的字面常量、符号引用、翻译出来的直接引用(符号引用就是编码是用字符串表示某个变量、接口的位置,直接引用就是根据符号引用翻译出来的地址,将在类链接阶段完成翻译);运行时常量池除了存储编译期常量外,也可以存储在运行时间产生的常量,比如 String 类的 intern()方法,作用是 String 维护了一个常量池,如果调用的字符“abc”已经在常量池中,则返回池中的字符串地址,否则,新建一个常量加入池中,并返回地址。


JVM 方法区的相关参数,最小值:–XX:PermSize;最大值 --XX:MaxPermSize。


2.2 堆区


堆区是理解 JavaGC 机制最重要的区域。在 JVM 所管理的内存中,堆区是最大的一块,堆区也是 JavaGC 机制所管理的主要内存区域,堆区由所有线程共享,在虚拟机启动时创建。堆区用来存储对象实例及数组值,可以认为 java 中所有通过 new 创建的对象都在此分配。


对于堆区大小,可以通过参数-Xms 和-Xmx 来控制,-Xms 为 JVM 启动时申请的最新 heap 内存,默认为物理内存的 1/64 但小于 1GB;-Xmx 为 JVM 可申请的最大 Heap 内存,默认为物理内存的 1/4 但小于 1GB,默认当剩余堆空间小于 40%时,JVM 会增大 Heap 到-Xmx 大小,可通过-XX:MinHeapFreeRadio 参数来控制这个比例;当空余堆内存大于 70%时,JVM 会减小 Heap 大小到-Xms 指定大小,可通过-XX:MaxHeapFreeRatio 来指定这个比例。对于系统而言,为了避免在运行期间频繁的调整 Heap 大小,我们通常将-Xms 和-Xmx 设置成一样。


为了让内存回收更加高效(后面会具体讲为何要分代划分),从 Sun JDK 1.2 开始对堆采用了分代管理方式,如下图所示:



年轻代(Young Generation)


对象在被创建时,内存首先是在年轻代进行分配(注意,大对象可以直接在老年代分配)。当年轻代需要回收时会触发 Minor GC(也称作 Young GC)。


年轻代由 Eden Space 和两块相同大小的 Survivor Space(又称 S0 和 S1)构成,可通过-Xmn 参数来调整新生代大小,也可通过-XX:SurvivorRadio 来调整 Eden Space 和 Survivor Space 大小。不同的 GC 方式会按不同的方式来按此值划分 Eden Space 和 Survivor Space,有些 GC 方式还会根据运行状况来动态调整 Eden、S0、S1 的大小。


年轻代的 Eden 区内存是连续的,所以其分配会非常快;同样 Eden 区的回收也非常快(因为大部分情况下 Eden 区对象存活时间非常短,而 Eden 区采用的复制回收算法,此算法在存活对象比例很少的情况下非常高效,后面会详细介绍)。


如果在执行垃圾回收之后,仍没有足够的内存分配,也不能再扩展,将会抛出 OutOfMemoryError:Java Heap Space 异常。


老年代(Old Generation)


老年代用于存放在年轻代中经多次垃圾回收仍然存活的对象,可以理解为比较老一点的对象,例如缓存对象;新建的对象也有可能在老年代上直接分配内存,这主要有两种情况:一种为大对象,可以通过启动参数设置-XX:PretenureSizeThreshold=1024,表示超过多大时就不在年轻代分配,而是直接在老年代分配。此参数在年轻代采用 Parallel Scavenge GC 时无效,因为其会根据运行情况自己决定什么对象直接在老年代上分配内存;另一种为大的数组对象,且数组对象中无引用外部对象。


当老年代满了的时候就需要对老年代进行垃圾回收,老年代的垃圾回收称作 Major GC(也称作 Full GC)。


老年代所占用的内存大小为-Xmx 对应的值减去-Xmn 对应的值。


2.3 本地方法栈(Native Method Stack)


本地方法栈用于支持 native 方法的执行,存储了每个 native 方法调用的状态。本地方法栈和虚拟机方法栈运行机制一致,它们唯一的区别就是,虚拟机栈是执行 Java 方法的,而本地方法栈是用来执行 native 方法的,在很多虚拟机中(如 Sun 的 JDK 默认的 HotSpot 虚拟机),会将本地方法栈与虚拟机栈放在一起使用。


2.4 程序计数器(Program Counter Register)


程序计数器是一个比较小的内存区域,可能是 CPU 寄存器或者操作系统内存,其主要用于指示当前线程所执行的字节码执行到了第几行,可以理解为是当前线程的行号指示器。字节码解释器在工作时,会通过改变这个计数器的值来取下一条语句指令。 每个程序计数器只用来记录一个线程的行号,所以它是线程私有(一个线程就有一个程序计数器)的。


如果程序执行的是一个 Java 方法,则计数器记录的是正在执行的虚拟机字节码指令地址;如果正在执行的是一个本地(native,由 C 语言编写完成)方法,则计数器的值为 Undefined,由于程序计数器只是记录当前指令地址,所以不存在内存溢出的情况,因此,程序计数器也是所有 JVM 内存区域中唯一一个没有定义 OutOfMemoryError 的区域。


2.5 虚拟机栈(JVM Stack)


虚拟机栈占用的是操作系统内存,每个线程都对应着一个虚拟机栈,它是线程私有的,而且分配非常高效。一个线程的每个方法在执行的同时,都会创建一个栈帧(Statck Frame),栈帧中存储的有局部变量表、操作站、动态链接、方法出口等,当方法被调用时,栈帧在 JVM 栈中入栈,当方法执行完成时,栈帧出栈。


局部变量表中存储着方法的相关局部变量,包括各种基本数据类型,对象的引用,返回地址等。在局部变量表中,只有 long 和 double 类型会占用 2 个局部变量空间(Slot,对于 32 位机器,一个 Slot 就是 32 个 bit),其它都是 1 个 Slot。需要注意的是,局部变量表是在编译时就已经确定好的,方法运行所需要分配的空间在栈帧中是完全确定的,在方法的生命周期内都不会改变。


虚拟机栈中定义了两种异常,如果线程调用的栈深度大于虚拟机允许的最大深度,则抛出 StatckOverFlowError(栈溢出);不过多数 Java 虚拟机都允许动态扩展虚拟机栈的大小(有少部分是固定长度的),所以线程可以一直申请栈,直到内存不足,此时,会抛出 OutOfMemoryError(内存溢出)。


2.6 Java 对象访问方式


一般来说,一个 Java 的引用访问涉及到 3 个内存区域:JVM 栈,堆,方法区。以最简单的本地变量引用:Object objRef = new Object()为例:


Object objRef 表示一个本地引用,存储在 JVM 栈的本地变量表中,表示一个 reference 类型数据;


new Object()作为实例对象数据存储在堆中;


堆中还记录了能够查询到此 Object 对象的类型数据(接口、方法、field、对象类型等)的地址,实际的数据则存储在方法区中;


在 Java 虚拟机规范中,只规定了指向对象的引用,对于通过 reference 类型引用访问具体对象的方式并未做规定,不过目前主流的实现方式主要有两种:


2.6.1 通过句柄访问


通过句柄访问的实现方式中,JVM 堆中会划分单独一块内存区域作为句柄池,句柄池中存储了对象实例数据(在堆中)和对象类型数据(在方法区中)的指针。这种实现方法由于用句柄表示地址,因此十分稳定。



2.6.2 通过直接指针访问


通过直接指针访问的方式中,reference 中存储的就是对象在堆中的实际地址,在堆中存储的对象信息中包含了在方法区中的相应类型数据。这种方法最大的优势是速度快,在 HotSpot 虚拟机中用的就是这种方式。



三、JVM 内存分配


Java 对象所占用的内存主要在堆上实现,因为堆是线程共享的,因此在堆上分配内存时需要进行加锁,这就导致了创建对象的开销比较大。当堆上空间不足时,会出发 GC,如果 GC 后空间仍然不足,则会抛出 OutOfMemory 异常。


为了提升内存分配效率,在年轻代的 Eden 区 HotSpot 虚拟机使用了两种技术来加快内存分配 ,分别是 bump-the-pointer 和 TLAB(Thread-Local Allocation Buffers)。由于 Eden 区是连续的,因此 bump-the-pointer 技术的核心就是跟踪最后创建的一个对象,在对象创建时,只需要检查最后一个对象后面是否有足够的内存即可,从而大大加快内存分配速度;而对于 TLAB 技术是对于多线程而言的, 它会为每个新创建的线程在新生代的 Eden Space 上分配一块独立的空间,这块空间称为 TLAB(Thread Local Allocation Buffer),其大小由 JVM 根据运行情况计算而得。可通过-XX:TLABWasteTargetPercent 来设置其可占用的 Eden Space 的百分比,默认是 1%。在 TLAB 上分配内存不需要加锁,一般 JVM 会优先在 TLAB 上分配内存,如果对象过大或者 TLAB 空间已经用完,则仍然在堆上进行分配。因此,在编写程序时,多个小对象比大的对象分配起来效率更高。可在启动参数上增加-XX:+PrintTLAB 来查看 TLAB 空间的使用情况。



对象如果在年轻代存活了足够长的时间而没有被清理掉(即在几次 Minor GC 后存活了下来),则会被复制到年老代,年老代的空间一般比年轻代大,能存放更多的对象,在年老代上发生的 GC 次数也比年轻代少。当年老代内存不足时,将执行 Major GC,也叫 Full GC。


可以使用-XX:+UseAdaptiveSizePolicy 开关来控制是否采用动态控制策略,如果动态控制,则动态调整 Java 堆中各个区域的大小以及进入老年代的年龄。


如果对象比较大(比如长字符串或大数组),年轻代空间不足,则大对象会直接分配到老年代上(大对象可能触发提前 GC,应少用,更应避免使用短命的大对象)。用 -XX:PretenureSizeThreshold 来控制直接升入老年代的对象大小,大于这个值的对象会直接分配在老年代上。


四、内存的回收方式


JVM 通过 GC 来回收堆和方法区中的内存,这个过程是自动执行的。说到 Java GC 机制,其主要完成 3 件事:确定哪些内存需要回收;确定什么时候需要执行 GC;如何执行 GC。JVM 主要采用收集器的方式实现 GC,主要的收集器有引用计数收集器和跟踪收集器。


4.1 引用计数收集器


引用计数器采用分散式管理方式,通过计数器记录对象是否被引用。当计数器为 0 时,说明此对象已经不再被使用,可进行回收,如图所示:



在上图中,ObjectA 释放了对 ObjectB 的引用后,ObjectB 的引用计数器变为 0,此时可回收 ObjectB 所占有的内存。


引用计数器需要在每次对象赋值时进行引用计数器的增减,他有一定消耗。另外,引用计数器对于循环引用的场景没有办法实现回收。例如在上面的例子中,如果 ObjectB 和 ObjectC 互相引用,那么即使 ObjectA 释放了对 ObjectB 和 ObjectC 的引用,也无法回收 ObjectB、ObjectC,因此对于 java 这种会形成复杂引用关系的语言而言,引用计数器是非常不适合的,SunJDK 在实现 GC 时也未采用这种方式。


4.2 跟踪收集器


跟踪收集器采用的为集中式的管理方式,会全局记录数据引用的状态。基于一定条件的触发(例如定时、空间不足时),执行时需要从根集合来扫描对象的引用关系,这可能会造成应用程序暂停。主要有复制(Copying)、标记-清除(Mark-Sweep)和标记-压缩(Mark-Compact)三种实现算法。


复制(Copying)


复制采用的方式为从根集合扫描出存活的对象,并将找到的存活的对象复制到一块新的完全未被使用的空间中,如图所示:



复制收集器方式仅需要从根集合扫描所有存活对象,当要回收的空间中存活对象较少时,复制算法会比较高效(年轻代的 Eden 区就是采用这个算法),其带来的成本是要增加一块空的内存空间及进行对象的移动。


标记-清除(Marking-Deleting)


标记-清除采用的方式为从根集合开始扫描,对存活的对象进行标记,标记完毕后,再扫描整个空间中未标记的对象,并进行清除,标记和清除过程如下图所示:



上图中蓝色的部分是有被引用的存活的对象,褐色部分没被引用的可回收的对象。在 marking 阶段为了 mark 对象,所有的对象都会被扫描一遍,扫描这个过程是比较耗时的。



清除阶段回收的是没有被引用的对象,存活的对象被保留。内存分配器会持有空闲空间的引用列表,当有分配请求时会查询空闲空间引用列表进行分配。


标记-清除动作不需要进行对象移动,且仅对其不存活的对象进行处理。在空间中存活对象较多的情况下较为高效,但由于标记-清除直接回收不存活对象占用的内存,因此会造成内存碎片。


标记-压缩(Mark-Compact)


标记-压缩和标记-清除一样,是对活的对象进行标记,但是在清除后的处理不一样,标记-压缩在清除对象占用的内存后,会把所有活的对象向左端空闲空间移动,然后再更新引用其对象的指针,如下图所示:



很明显,标记-压缩在标记-清除的基础上对存活的对象进行了移动规整动作,解决了内存碎片问题,得到更多连续的内存空间以提高分配效率,但由于需要对对象进行移动,因此成本也比较高。


五、虚拟机中的 GC 过程


5.1 为什么要分代回收?


在一开始的时候,JVM 的 GC 就是采用标记-清除-压缩方式进行的,这么做并不是很高效,因为当对象分配的越来越多时,对象列表也越来也大,扫描和移动越来越耗时,造成了内存回收越来越慢。然而,经过根据对 java 应用的分析,发现大部分对象的存活时间都非常短,只有少部分数据存活周期是比较长的,请看下面对 java 对象内存存活时间的统计:



从图表中可以看出,大部分对象存活时间是非常短的,随着时间的推移,被分配的对象越来越少。


5.2 虚拟机中 GC 的过程


经过上面介绍,我们已经知道了 JVM 为何要分代回收,下面我们就详细看一下整个回收过程。


在初始阶段,新创建的对象被分配到 Eden 区,survivor 的两块空间都为空。



当 Eden 区满了的时候,minor garbage 被触发



经过扫描与标记,存活的对象被复制到 S0,不存活的对象被回收



在下一次的 Minor GC 中,Eden 区的情况和上面一致,没有引用的对象被回收,存活的对象被复制到 survivor 区。然而在 survivor 区,S0 的所有的数据都被复制到 S1,需要注意的是,在上次 minor GC 过程中移动到 S0 中的两个对象在复制到 S1 后其年龄要加 1。此时 Eden 区 S0 区被清空,所有存活的数据都复制到了 S1 区,并且 S1 区存在着年龄不一样的对象,过程如下图所示:



再下一次 MinorGC 则重复这个过程,这一次 survivor 的两个区对换,存活的对象被复制到 S0,存活的对象年龄加 1,Eden 区和另一个 survivor 区被清空。



下面演示一下 Promotion 过程,再经过几次 Minor GC 之后,当存活对象的年龄达到一个阈值之后(可通过参数配置,默认是 8),就会被从年轻代 Promotion 到老年代。



随着 MinorGC 一次又一次的进行,不断会有新的对象被 promote 到老年代。



上面基本上覆盖了整个年轻代所有的回收过程。最终,MajorGC 将会在老年代发生,老年代的空间将会被清除和压缩。



从上面的过程可以看出,Eden 区是连续的空间,且 Survivor 总有一个为空。经过一次 GC 和复制,一个 Survivor 中保存着当前还活着的对象,而 Eden 区和另一个 Survivor 区的内容都不再需要了,可以直接清空,到下一次 GC 时,两个 Survivor 的角色再互换。因此,这种方式分配内存和清理内存的效率都极高,这种垃圾回收的方式就是著名的“停止-复制(Stop-and-copy)”清理法(将 Eden 区和一个 Survivor 中仍然存活的对象拷贝到另一个 Survivor 中),这不代表着停止复制清理法很高效,其实,它也只在这种情况下(基于大部分对象存活周期很短的事实)高效,如果在老年代采用停止复制,则是非常不合适的。


老年代存储的对象比年轻代多得多,而且不乏大对象,对老年代进行内存清理时,如果使用停止-复制算法,则相当低效。一般,老年代用的算法是标记-压缩算法,即:标记出仍然存活的对象(存在引用的),将所有存活的对象向一端移动,以保证内存的连续。在发生 Minor GC 时,虚拟机会检查每次晋升进入老年代的大小是否大于老年代的剩余空间大小,如果大于,则直接触发一次 Full GC,否则,就查看是否设置了-XX:+HandlePromotionFailure(允许担保失败),如果允许,则只会进行 MinorGC,此时可以容忍内存分配失败;如果不允许,则仍然进行 Full GC(这代表着如果设置-XX:+Handle PromotionFailure,则触发 MinorGC 就会同时触发 Full GC,哪怕老年代还有很多内存,所以,最好不要这样做)。


关于方法区即永久代的回收,永久代的回收有两种:常量池中的常量,无用的类信息,常量的回收很简单,没有引用了就可以被回收。对于无用的类进行回收,必须保证 3 点:


  1. 类的所有实例都已经被回收

  2. 加载类的 ClassLoader 已经被回收

  3. 类对象的 Class 对象没有被引用(即没有通过反射引用该类的地方)


永久代的回收并不是必


【一线大厂Java面试题解析+后端开发学习笔记+最新架构讲解视频+实战项目源码讲义】
浏览器打开:qq.cn.hn/FTf 免费领取
复制代码


须的,可以通过参数来设置是否对类进行回收。


六、垃圾收集器


通过上面的介绍,我们已经了解到了 JVM 的内存回收过程,而在虚拟机中,GC 是由垃圾回收器来具体执行的,所以,在实际应用场景中我们需要根据应用情况选择合适的垃圾收集器,下面我们就介绍一下垃圾收集器。


6.1 串行(Serial)收集器


串行收集器 JavaSE5 和 6 中客户端虚拟机所采用的默认配置,它是最简单的收集器,比较适合于只有一个处理器的系统。在串行收集器中,minor 和 major GC 过程都是用一个线程进行垃圾回收。


使用场景


首先,串行 GC 一般用在对应用暂停要求不是很高和运行在客户端模式的场景,它仅仅利用一个 CPU 核心来进行垃圾回收。在现在的硬件条件下,串行 GC 可以管理很多小内存的应用,并且能够保证相对较小的暂停(在 Full GC 的情况下大约需要几秒的时间)。另一个通常采用串行 GC 的场景就是一台机器运行多个 JVM 虚拟机的情况(JVM 虚拟机个数大于 CPU 核心数),在这种场景下,当一个 JVM 进行垃圾回收时只利用一个处理器,不会对其它 JVM 造成较大的影响。最后,在一些内存比较小和 CPU 核心数比较少的硬件设备中也比较适合采用串行收集器。


相关参数命令


1 启用串行收集器: -XX:+UseSerialGC


2 命令行示例:


java -Xmx12m -Xms3m -Xmn1m -XX:PermSize=20m -XX:MaxPermSize=20m -XX:+UseSerialGC -jar c:\javademos\demo\jfc\Java2D\Java2demo.jar

用户头像

Java高工P7

关注

还未添加个人签名 2021.11.08 加入

还未添加个人简介

评论

发布
暂无评论
JVM及GC机制