JVM——垃圾回收
垃圾回收
在Java虚拟机(JVM)的自动内存管理中,垃圾回收(Garbage Collection, GC)是其核心组件之一。它负责回收堆内存中不再使用的对象所占用的内存空间,以供新对象的分配使用。下面我们将深入探讨JVM中的垃圾回收机制。
垃圾回收的基本概念
垃圾回收的主要目标是自动回收不再使用的内存,从而避免内存泄漏和手动内存管理带来的错误。在JVM中,垃圾回收器会定期扫描堆内存,识别并回收那些不再被程序使用的对象。这个过程中涉及到的关键问题是:如何判断一个对象是否已经“死亡”,即是否可以被回收。
引用计数法与可达性分析
(一)引用计数法
引用计数法是一种古老的垃圾回收算法,其基本思想是为每个对象维护一个计数器,用于记录指向该对象的引用数量。每当有一个新的引用指向该对象时,计数器加1;当引用不再存在时,计数器减1。当计数器的值为0时,表示该对象不再被引用,可以被回收。
-
优点 :实现简单,能够及时回收不再被引用的对象。
-
缺点 :需要额外的空间来存储计数器,并且在引用关系频繁变化时,计数器的更新操作会带来一定的性能开销。最严重的缺陷是无法处理循环引用问题,即两个或多个对象相互引用,导致它们的计数器始终大于0,即使它们实际上已经不再被程序使用,也无法被回收。
(二)可达性分析
可达性分析是现代JVM中主流的垃圾回收算法。它的核心思想是从一组称为GC Roots的对象开始,这些对象是垃圾回收的起点。然后,算法会从GC Roots出发,递归地遍历所有可以直接或间接引用的对象,标记这些存活的对象。最终,未被标记的对象即为可以回收的垃圾对象。
-
GC Roots的组成 :在JVM中,GC Roots通常包括以下几类对象:
-
Java方法栈中的局部变量 :这些变量可能引用堆中的对象,作为GC Roots的起点。
-
已加载类的静态变量 :类的静态变量在类加载时会被分配内存,它们可能引用堆中的对象。
-
JNI handles :Java Native Interface(JNI)允许Java代码与本地代码(如C/C++)交互,JNI handles是本地代码中引用Java对象的句柄。
-
已启动且未停止的Java线程 :线程本身可能引用堆中的对象,如线程的堆栈中的局部变量等。
-
-
优点 :能够解决引用计数法无法处理的循环引用问题,准确地识别出不再使用的对象。
-
缺点 :需要暂停应用程序的执行,进行全堆扫描以确定GC Roots,并递归遍历引用对象,这会导致Stop-the-World(STW)现象,影响应用程序的性能。
Stop-the-World(STW)现象
在垃圾回收过程中,传统的垃圾回收算法需要确保在标记存活对象的过程中,堆栈的状态不会发生改变,否则可能导致标记结果不准确,从而引发误报或漏报问题。为了解决这个问题,JVM采用了Stop-the-World机制,即在垃圾回收时暂停所有非垃圾回收线程的工作,直到垃圾回收完成。这期间,应用程序的执行被暂停,等待垃圾回收线程完成其任务,这就是所谓的STW现象。
STW现象会导致应用程序的暂停,影响用户体验和系统的吞吐量。尤其是在处理大数据量或高并发场景时,长时间的STW暂停可能是不可接受的。因此,现代垃圾回收器致力于减少STW暂停的时间,提高垃圾回收的效率。
安全点机制
为了实现Stop-the-World,JVM引入了安全点(safepoint)机制。安全点是一个程序执行的状态点,在这个状态下,堆栈不会发生变化,垃圾回收器可以“安全”地执行垃圾回收操作。当JVM需要触发垃圾回收时,它会等待所有线程都到达安全点后,暂停这些线程,然后进行垃圾回收。
-
安全点的设置 :在JVM中,安全点的设置需要考虑不同执行状态的线程。例如:
-
解释执行字节码 :字节码与字节码之间都可以作为安全点。当有安全点请求时,JVM会在执行每条字节码后进行安全点检测。
-
执行即时编译器生成的机器码 :由于这些代码直接运行在底层硬件上,不受JVM直接控制,因此即时编译器需要在生成的机器码中插入安全点检测代码。通常在方法出口、非计数循环的循环回边等位置插入安全点检测。
-
JNI本地代码执行 :当Java程序通过JNI调用本地代码时,如果这段代码不访问Java对象、不调用Java方法或不返回Java方法,那么它可以被视为一个安全点。JVM只需在JNI API的入口处进行安全点检测,即可在必要时挂起当前线程。
-
-
安全点检测的开销 :虽然安全点检测本身会带来一定的性能开销,但JVM通过优化设计,将其简化为一个内存访问操作。在需要进行安全点检测时,JVM会将访问的内存所在的页设置为不可读,并定义一个segfault处理器来截获访问该不可读内存的线程,将其挂起。
不同即时编译器插入安全点检测的位置可能不同。例如,Graal编译器除了在方法出口和非计数循环回边处插入安全点检测外,还会在计数循环的循环回边处插入检测点,以确保更频繁地进入安全点,减少垃圾回收的暂停时间。
垃圾回收的三种方式
在完成存活对象的标记后,垃圾回收器需要对死亡对象的内存进行回收。现代垃圾回收器通常采用以下三种基本回收方式中的一种或多种组合:
(一)清除(Sweep)
-
原理 :将死亡对象所占据的内存标记为空闲内存,并记录在一个空闲列表中。当需要分配新对象时,内存管理模块从空闲列表中查找足够大小的空闲内存块进行分配。
-
优点 :实现简单,回收速度快。
-
缺点 :容易造成内存碎片。随着时间的推移,堆内存中的空闲空间可能变得零散,无法满足大对象的分配需求,导致内存分配失败。
(二)压缩(Compact)
-
原理 :将存活的对象移动到内存区域的起始位置,消除内存碎片,形成一段连续的空闲内存空间。
-
优点 :解决了内存碎片问题,提高了内存的利用率。
-
缺点 :需要移动对象并更新引用,这会增加垃圾回收的暂停时间,影响应用程序的性能。
(三)复制(Copy)
-
原理 :将内存区域分为两个相等的部分,称为from空间和to空间。新对象在from空间中分配内存。当from空间填满时,触发垃圾回收,将存活对象复制到to空间中,然后交换from和to空间的角色。
-
优点 :能够有效解决内存碎片问题,且垃圾回收过程简单高效。
-
缺点 :内存利用率较低,因为只有一半的内存空间用于对象分配。
现代垃圾回收器通常会结合这些回收方式的优点,根据不同的场景和需求,灵活地选择和组合回收策略。例如,在新生代垃圾回收中,通常采用复制算法,因为它能够快速回收大量短生命周期的对象;而在老年代垃圾回收中,可能采用标记 - 清除或标记 - 压缩算法,以减少暂停时间并提高内存利用率。
Java虚拟机的堆划分
JVM的堆内存是垃圾回收的主要区域,其划分策略对垃圾回收的效率有着重要影响。JVM将堆分为新生代和老年代,这种分代回收思想基于一个重要的观察:大多数Java对象的生命周期很短,只有少数对象会存活较长时间。
(一)新生代
新生代用于存储新创建的对象。新生代的内存划分为一个Eden区和两个大小相同的Survivor区(通常称为S0和S1)。
-
对象分配 :大多数新创建的对象首先在Eden区分配内存。当Eden区填满时,会触发Minor GC。
-
Minor GC :在Minor GC过程中,垃圾回收器会将Eden区和当前使用的Survivor区(假设为S0)中存活的对象复制到另一个Survivor区(S1),然后交换S0和S1的角色。如果一个对象在多次Minor GC中都存活下来,并且其在Survivor区中的复制次数达到一定的阈值(默认为15次),该对象将被晋升到老年代。
(二)老年代
老年代用于存储从新生代晋升过来的长期存活对象,以及直接在老年代分配的大对象(通过-XX:PretenureSizeThreshold参数设置大对象的大小阈值)。
-
对象分配 :当对象的大小超过-XX:PretenureSizeThreshold参数设置的值时,或者对象无法在新生代中分配时(如新生代内存不足),对象会直接分配在老年代。
-
Major GC :针对老年代的垃圾回收称为Major GC。由于老年代的对象通常具有较长的生命周期,Major GC发生的频率较低,但其暂停时间往往较长。现代垃圾回收器致力于通过并发收集等技术减少Major GC的暂停时间。
(三)TLAB(Thread Local Allocation Buffer)
为了提高内存分配的效率,减少线程间的内存分配竞争,JVM引入了TLAB技术。
-
原理 :每个线程可以向JVM申请一段连续的内存作为TLAB。线程在TLAB中为新对象分配内存时,通过简单的指针加法(pointer bumping)操作即可完成,无需进行同步操作,大大提高了内存分配的速度。
-
TLAB的管理 :线程维护两个指针,一个指向TLAB中空闲内存的起始位置,另一个指向TLAB的末尾。当为新对象分配内存时,线程会检查起始指针加上所需内存大小是否小于等于末尾指针。如果是,则分配成功;否则,线程需要申请一个新的TLAB。
-
优点 :显著提高了多线程环境下内存分配的效率,减少了线程间的竞争和同步开销。
-
缺点 :如果TLAB的大小设置不合理,可能会导致内存浪费。如果TLAB过大,可能会造成内存利用率低下;如果TLAB过小,则会增加申请新TLAB的频率,增加开销。
卡表
为了避免在Minor GC时扫描整个老年代以寻找对新生代对象的引用,JVM引入了卡表(Card Table)技术。
-
原理 :将整个堆内存划分为大小为512字节的卡。卡表是一个数组,每个元素对应一张卡的标识位。当老年代中的对象引用新生代中的对象时,对应的卡会被标记为“脏卡”。
-
写屏障 :为了维护卡表的准确性,JVM在编译器生成的机器码中插入写屏障(write barrier)代码。写屏障会在更新引用型实例变量时执行,通过计算引用地址所在的卡,并将该卡标记为脏卡。写屏障的实现需要尽可能简洁高效,以减少对程序性能的影响。
-
Minor GC中的卡表应用 :在Minor GC过程中,垃圾回收器只需要扫描卡表中被标记为脏卡的区域,检查这些区域中的对象是否引用了新生代中的存活对象。这些引用会被加入到GC Roots中,确保新生代中的存活对象不会被错误地回收。扫描完成后,卡表中的脏卡标识位会被清零。
卡表技术有效地减少了Minor GC时需要扫描的老年代内存范围,提高了垃圾回收的效率。然而,写屏障的使用也会带来一定的性能开销,尤其是在高并发环境下,可能会引发虚共享(false sharing)问题。为了解决这一问题,HotSpot引入了-XX:+UseCondCardMark参数,通过优化写屏障逻辑,减少不必要的卡表标记操作。
垃圾回收器详解
在JVM中,有多种垃圾回收器可供选择。不同的垃圾回收器适用于不同的应用场景和性能需求。
(一)新生代垃圾回收器
-
Serial :是一个单线程的垃圾回收器,采用标记 - 复制算法。其特点是简单高效,适用于单核处理器或CPU资源受限的环境。由于其单线程特性,在多核环境下可能无法充分利用CPU资源。
-
Parallel Scavenge :是Serial的多线程版本,注重吞吐量。吞吐量是指应用程序运行时间与总时间(包括垃圾回收时间)的比值。Parallel Scavenge适用于对吞吐量要求较高的场景,如批处理任务等。
-
Parallel New :同样是Serial的多线程版本,但更注重垃圾回收的暂停时间。适用于对暂停时间敏感的应用,如用户交互型应用等。
(二)老年代垃圾回收器
-
Serial Old :是Serial的同伴,用于老年代的垃圾回收。采用标记 - 压缩算法,单线程工作。适用于单核处理器或小规模应用的老年代垃圾回收。
-
Parallel Old :是Serial Old的多线程版本,适用于多核环境下老年代的垃圾回收。通过多线程并行工作,减少垃圾回收的暂停时间,提高吞吐量。
-
CMS(Concurrent Mark - Sweep) :采用标记 - 清除算法,并且是并发的。CMS垃圾回收器在垃圾回收过程中,除了初始标记和最终标记阶段需要短暂的STW外,其余阶段(如并发标记和并发清除)可以与应用程序线程并发执行。这使得CMS能够在应用程序运行过程中进行垃圾回收,减少暂停时间。然而,CMS可能会产生内存碎片,并且在并发收集失败时需要使用其他垃圾回收器进行全堆回收。在Java 9中,CMS已被废弃。
(三)G1(Garbage First)垃圾回收器
-
原理 :G1将堆内存划分为多个大小相等的区域(Region)。每个区域可以充当Eden区、Survivor区或老年代的一部分。G1根据垃圾回收的优先级选择区域进行回收,优先回收死亡对象较多的区域。
-
特点 :能够预测垃圾回收的停顿时间,并在指定时间内完成垃圾回收任务。适用于大内存和对停顿时间有严格要求的应用场景。
-
优势 :通过将堆划分为区域,G1可以灵活地进行垃圾回收,减少全堆扫描的可能性。同时,G1可以更好地平衡垃圾回收的吞吐量和暂停时间。
(四)ZGC(Z Garbage Collector)
-
特点 :ZGC是Java 11中引入的一种低延迟垃圾回收器,其目标是将垃圾回收的暂停时间控制在10毫秒以内。ZGC采用了一系列先进的技术,如写屏障、内存映射等,以实现这一目标。
-
适用场景 :适用于对延迟要求极高的场景,如实时交易系统、高频金融应用等。
选择合适的垃圾回收器和调整其参数对于优化Java应用程序的性能至关重要。不同的应用场景对吞吐量、延迟和暂停时间有不同的要求,因此需要根据具体情况进行选择和调整。
垃圾回收的性能优化实践
(一)调整新生代和老年代的大小
-
新生代大小 :通过-XX:NewRatio参数可以调整新生代和老年代的比例。例如,-XX:NewRatio=3表示新生代占堆内存的1/4,老年代占3/4。增大新生代的大小可以减少Minor GC的频率,但可能会增加Minor GC的暂停时间;减小新生代的大小则相反。
-
老年代大小 :通过-XX:MaxNewSize和-XX:MinNewSize参数可以设置新生代的最大和最小大小。合理调整新生代的大小可以确保老年代有足够的空间存储长期存活的对象,同时避免频繁的Major GC。
(二)优化TLAB的大小
-
TLAB的大小 :通过-XX:ThreadLocalAllocationBufferMaxSize参数可以设置TLAB的最大大小。增大TLAB的大小可以减少线程申请新TLAB的频率,提高内存分配效率;但过大的TLAB可能导致内存浪费。可以通过-XX:TLABSize参数设置TLAB的初始大小,并根据应用程序的实际情况进行调整。
(三)调整 Survivor 区的大小和晋升阈值
-
Survivor 区大小 :通过-XX:SurvivorRatio参数可以调整Eden区和Survivor区的比例。例如,-XX:SurvivorRatio=8表示Eden区的大小是每个Survivor区的8倍。增大Survivor区的大小可以容纳更多的存活对象,减少对象晋升到老年代的频率;但会减少Eden区的大小,可能增加Minor GC的频率。
-
晋升阈值 :通过-XX:MaxTenuringThreshold参数可以设置对象在Survivor区中复制的最大次数。增大该值可以使对象在新生代中停留更长时间,减少对象晋升到老年代的频率;但可能会增加Minor GC的暂停时间。
(四)选择合适的垃圾回收器
-
根据应用场景选择 :对于对吞吐量要求较高的批处理任务,可以选择Parallel Scavenge和Parallel Old的组合;对于对延迟敏感的用户交互型应用,可以选择G1或ZGC垃圾回收器。
-
参数调优 :每种垃圾回收器都有许多可调参数,通过合理设置这些参数可以进一步优化垃圾回收的性能。例如,在G1中可以通过-XX:MaxGCPauseMillis参数设置最大垃圾回收暂停时间的目标值,JVM会根据该值自动调整垃圾回收的策略。
(五)监控和分析垃圾回收行为
-
垃圾回收日志 :启用垃圾回收日志(如-XX:+PrintGC、-XX:+PrintGCDetails等)可以记录垃圾回收的详细信息,包括垃圾回收的次数、暂停时间、回收的内存大小等。通过分析这些日志,可以了解垃圾回收器的工作情况,发现性能瓶颈。
-
性能分析工具 :使用性能分析工具(如JProfiler、VisualVM等)可以直观地查看应用程序的内存使用情况、垃圾回收活动、线程状态等信息。这些工具可以帮助开发者快速定位性能问题,进行有针对性的优化。
垃圾回收的未来发展趋势
随着Java技术的不断发展和应用场景的日益多样化,垃圾回收技术也在不断进化。未来,垃圾回收器将更加智能化、自动化,能够根据应用程序的运行时特性动态调整垃圾回收策略,减少人工调优的成本。例如,自适应调节算法可以根据应用程序的内存分配模式和垃圾回收开销,自动调整新生代和老年代的大小、垃圾回收器的类型等参数。此外,新型垃圾回收器(如Shenandoah、ZGC等)将继续优化延迟和吞吐量的平衡,满足对实时性和响应速度要求极高的应用场景的需求。同时,随着硬件技术的进步,如非易失性内存(NVM)的普及,垃圾回收器的设计也将充分考虑新硬件特性,进一步提升性能和效率。
总结
垃圾回收作为Java虚拟机自动内存管理的核心,其性能与效率对Java应用程序的整体表现有着举足轻重的影响。从基础的引用计数法与可达性分析,到复杂的卡表与写屏障机制,JVM的垃圾回收体系融合了多种算法与优化策略。需要通过深入了解垃圾回收的各个环节,才能够更加精准地优化程序性能,选择和配置最适合的垃圾回收器。