内存管理与垃圾回收
内存区域划分
运行时数据区(Runtime Data Area)是指Java虚拟机在执行Java程序时,为了保证程序的正确运行而动态分配和使用的内存区域。这部分内存区域随着线程的创建和销毁而动态改变,是JVM在执行Java代码时实际使用的内存部分。
运行时数据区
程序计数器(Program Counter Register):线程私有,用于记录当前线程所执行的字节码指令的位置。
Java虚拟机栈(Java Virtual Machine Stack):线程私有,用于存储局部变量、操作数栈、动态链接和方法出口等信息,每个方法被调用时都会创建一个新的栈帧。
本地方法栈(Native Method Stack):线程私有,与Java虚拟机栈类似,但用于支持Native方法的调用。
Java堆(Java Heap):线程共享,用于存储所有实例对象和数组。这是垃圾收集器管理的主要区域。
方法区(Method Area):线程共享,存储已加载的类信息、常量、静态变量、即时编译后的代码等数据。
这些区域构成了JVM运行时内存模型的基础,其中,程序计数器、虚拟机栈和本地方法栈都是线程私有的,而Java堆和方法区则是所有线程共享的。运行时数据区的管理对于确保Java程序的正确执行和性能优化至关重要。例如,垃圾收集机制主要针对Java堆进行,而方法区的管理则涉及到类的加载和卸载过程。
下面是一个经典的JVM内存区域划分图(JDK1.8以前):
JDK 1.8 之前的版本(如 JDK 1.6 和 JDK 1.7)和 JDK 1.8 在内存区域划分上有显著的不同,尤其是涉及到方法区的实现。JDK8前后的运行时内存变化(Hotspot):
方法区(在JDK 1.8中更改为元数据区/Metaspace)用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译后的代码等数据。在JDK 1.7及以前的版本中,方法区被称为永久代(PermGen space),并位于堆中。从JDK 1.8开始,方法区被移动到本机内存中,称为Metaspace,它不在Java堆中,也不受垃圾回收的影响,而是受到系统本机内存限制。
程序计数器
程序计数器( Program Counter Register ) : 负责跟踪当前线程所执行的字节码指令的地址。尽管程序计数器在JVM的整体内存模型中占据的空间相对较小,但它在确保线程切换和恢复执行的准确性方面发挥着至关重要的作用。其主要功能与作用如下:
指令跟踪:
程序计数器存储了下一条要执行的字节码指令的地址。在多线程环境中,每个线程都有自己的程序计数器,确保了线程之间执行的独立性和顺序性。线程切换:
当线程被暂停或等待CPU时间片时,程序计数器的值会被保存,以便在线程恢复执行时能准确地从上次停止的地方继续运行。分支和循环控制与异常处理:
对于条件语句(如if-else)、循环语句(如for、while)和跳转指令,程序计数器会根据控制流逻辑更新其值,指向适当的指令地址。
在遇到异常时,程序计数器可以帮助定位异常发生的指令位置,这对于异常处理和调试非常重要。
特点与细节
线程私有:
每个线程在其生命周期内都拥有独立的程序计数器,这保证了线程之间的独立执行和隔离。Native方法执行:
当线程执行Native方法时,程序计数器的值是未定义的(Undefined)。这是因为Native方法不在JVM内部执行,而是调用操作系统或其他原生库的函数。异常处理:
如果在执行过程中抛出异常并且没有相应的异常处理器,那么程序计数器的值将被设置为指向异常处理器的入口点。
程序计数器通过跟踪字节码指令的执行位置,确保了多线程环境下程序的正确执行流程。它的线程私有属性和对指令执行的精确控制,使得JVM能够高效地管理多个并发线程,同时保证每个线程的执行独立性和安全性。
虚拟机栈
Java虚拟机栈(Java Virtual Machine Stack,简称JVM栈)负责管理线程的局部变量和方法调用过程中的临时数据。每个线程在其生命周期中都会拥有一个独立的JVM栈。
当一个方法被调用时,JVM栈会创建一个新的栈帧(Stack Frame),用于存储该方法的局部变量、操作数栈、动态链接信息和返回地址等。当方法执行完毕,其对应的栈帧会被弹出,释放占用的资源。
每一个方法从调用直到执行完成的过程就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。
栈帧是JVM栈的基本单元,通常包含以下部分:
- 局部变量表(Local Variable Table):存储方法参数和局部变量。
- 操作数栈(Operand Stack):用于暂存中间计算结果,以及执行算术运算、方法调用等操作。
- 动态链接信息:支持方法调用过程中对常量池中符号引用的解析,即将符号引用转换为直接引用的过程。
- 返回地址:指出方法返回后应继续执行的下一条指令的位置。
- 附加信息:可能还包括一些辅助信息,如线程锁、异常处理器等。
基于栈的设计模式与基于寄存器的设计模式
基于栈的设计模式
在基于栈的设计模式中,操作数(如数值、引用等)被存储在栈中,而操作指令(如加法、减法、函数调用等)则从栈中弹出所需的操作数,执行操作后,将结果再压入栈中。这种方式使得JVM能够:
- 简化指令集:大多数操作指令只需要指定操作类型,而不必显式指定源和目标寄存器,因为所有的操作数都隐式地从操作数栈中获取。
- 易于移植:基于栈的架构减少了对底层硬件的依赖,使得JVM能够在各种不同架构的处理器上运行,而无需对指令集进行重大修改。
- 便于垃圾回收:栈上的数据结构是局部的,当方法调用结束时,相关的栈帧(包括操作数栈)会被自动销毁,从而简化了内存管理。
基于寄存器的设计模式
在基于寄存器的设计模式中(大多数现代处理器采用基于寄存器的指令集),操作数通常存储在一组专用的寄存器中,而指令集中的指令通常需要显式指定源寄存器和目标寄存器。这种方法提供了几个优势:
- 高速访问:寄存器通常比内存快得多,因为它们是处理器的一部分,直接连接到CPU。
- 减少内存访问:由于操作数可以直接在寄存器间传递,因此减少了从内存读取和写入操作数的需要。
- 复杂的指令集:基于寄存器的架构允许更复杂和高效的指令,因为指令可以直接操作寄存器,而无需间接访问栈。
基于栈的设计模式和基于寄存器的设计模式各有优缺点,基于寄存器的设计也有其局限性,比如寄存器数量有限,这可能限制了编译器优化的能力,同时也可能增加指令集的复杂性。相比之下,基于栈的设计模式通过牺牲一定的性能换取了更好的移植性和简化的指令集设计。
JVM选择了基于栈的设计,主要是因为它提供了更好的跨平台兼容性和简化了内存管理。基于栈的设计模式曾经是Java性能的一个瓶颈,但随着技术的进步,这一因素的影响已经大大减弱。现代JIT(Just-In-Time)编译器可以在运行时将基于栈的字节码转换为基于寄存器的机器代码,以利用目标平台的特性,从而获得更好的性能。现代的Java虚拟机通过多种优化手段,能够提供接近甚至超过一些编译型语言的性能表现,尤其是在大规模、高性能的应用场景中。
例:在JVM中 执行 c = a + b
的字节码执行过程中操作数栈以及局部变量表的变化如下图所示
public static void main(String[] args) {
int a = 11;
int b = 12;
int c = a + b;
}
第一步:将变量存储到局部变量表bipush
用于将一个小整数推入操作数栈,istore
用于从操作数栈弹出一个整数并存储到局部变量表中。
第二步:加载a和b的值 (iload
用于将局部变量表中的整数加载到操作数栈)
- 当JVM执行到
iload_1
时,它会从局部变量表中加载变量a
的值(11)到操作数栈。 - 接着执行第
iload_2
,从局部变量表中加载变量b
的值(12)到操作数栈。
此时,操作数栈顶部有两个值:12 和 11(注意栈的后进先出特性)。
第三步:执行加法操作 (iadd
用于执行两个整数的加法操作)
- 执行
iadd
指令时,JVM会从操作数栈中弹出顶部的两个值(12 和 11),执行加法操作,得到结果 23。 - 加法操作的结果(23)被重新压入操作数栈。
第四步:存储结果
执行 istore_3
指令,从操作数栈中弹出结果 23,并将其存储到局部变量表中索引为3的位置,即变量 c
。
栈溢出与栈大小
JVM栈的大小可以通过JVM启动参数进行配置,如-Xss
用于设置线程栈的大小。如果一个线程的JVM栈耗尽,即方法调用深度过深或局部变量过多,将会抛出StackOverflowError
异常。另一方面,如果JVM栈的大小设置得过小,频繁的栈溢出也可能导致性能问题。
- 垃圾回收是否涉及栈内存?
- 不需要。因为虚拟机栈中是由一个个栈帧组成的,在方法执行完毕后,对应的栈帧就会被弹出栈。所以无需通过垃圾回收机制去回收内存。
- 栈内存的分配越大越好吗?
- 不是。因为物理内存是一定的,栈内存越大,可以支持更多的递归调用,但是可执行的线程数就会越少。
- 方法内的局部变量是否是线程安全的?
- 如果方法内局部变量没有逃离方法的作用范围,则是线程安全的
- 如果 局部变量引用了对象,并 逃离了方法的作用范围,则需要考虑线程安全问题
Java.lang.stackOverflowError 栈内存溢出,发生原因 :
- 虚拟机栈中,栈帧过多(如无限递归)
- 每个栈帧所占用内存空间过大
本地方法栈
本地方法栈(Native Method Stack)的主要作用是支持本地方法(Native Methods)的执行。当Java代码中调用了本地方法时,JVM会利用本地方法栈来管理和协调本地方法的执行。这包括方法调用的上下文切换、参数传递、返回值接收等。
本地方法栈与虚拟机栈的区别:
服务对象不同:虚拟机栈服务于Java方法,负责执行字节码;而本地方法栈服务于本地方法,这些方法不是用Java编写的,而是用其他语言(如C/C++)编写并被编译成机器码的。
实现方式差异:由于本地方法栈的服务对象不同,它的实现方式也没有严格的规定。有些JVM实现可能将本地方法栈和虚拟机栈合并在一起,如HotSpot JVM;而有些JVM则可能保持独立的本地方法栈。
本地方法栈的特性
线程私有:和虚拟机栈一样,本地方法栈也是线程私有的。这意味着每个线程都有自己的本地方法栈实例,用于处理线程内调用的本地方法。
异常抛出:本地方法栈也会在遇到栈深度溢出或栈扩展失败时抛出
StackOverflowError
和OutOfMemoryError
异常。
本地方法通常用于以下几种情况:
- 需要高性能的底层操作,如密集型数学计算或文件I/O。
- 与操作系统或硬件紧密相关的功能,如图形界面或设备驱动。
- 需要调用已有的C/C++库,以重用现有代码或集成第三方组件。
堆(Heap)
堆(Heap)主要用于存储Java对象实例和数组。 Java堆是被所 有线程共享 的一块内存区域, 在虚拟机启动时创建。
在JDK 1.8及之前的版本中,堆内存被划分为几个主要的区域(从内存回收的角度来看,由于现在收集器基本都采用分代收集算法,所以Java堆可以细分为):
- 年轻代(Young Generation)
- 老年代(Old Generation)
- 永久代(Permanent Generation)
从JDK 1.8开始,永久代被元空间(Metaspace)取代,元空间使用的是本机内存而不是堆内存。因此,堆内存划分变为:年轻代(Young Generation)和老年代(Old Generation)
在Java8以后,方法区的内存不在分配在Java堆上,而是存储于本地内存元空间Metaspace中
Heap
年轻代(Young Generation):
年轻代主要存放新创建的对象,内存大小相对会比较小,垃圾回收会比较频繁。年轻代分 成1个 Eden Space 和2个 Suvivor Space(from 和to)
- Eden Space:这是年轻代中最大的部分,新创建的对象首先在这里分配。
- Survivor Spaces:分为两个相等大小的部分,S0和S1(在HotSpot JVM中,分别叫做From和To空间)。每次Minor GC后,存活的对象会被移动到另一个空的Survivor空间中,或者如果对象足够大或存活时间足够长,则直接进入老年代。
老年代(Old Generation / Tenured Generation):
老年代用于存储长期存活的对象,或者在年轻代中无法容纳的大对象(Large Objects)。内存大小相对会比较大,垃圾回收也相对没有那么频繁。
永久代(Permanent Generation):
用于存储类的元数据、静态变量、常量池等信息。在JDK 1.8中,永久代被元空间(Metaspace)取代。
配置新生代和老年代堆结构占比 :
堆大小 = 新生代 + 老年代
。其中,堆的大小可以通过参数–Xms
、-Xmx
来指定新生代和老年代堆结构占比: 默认
-XX:NewRatio=2
, 标识新生代占1 , 老年代占2 ,新生代占整个堆的1/3。 修改占比-XX:NewPatio=4
, 标识新生代占1 , 老年代占4 , 新生代占整个堆的1/5Eden空间和另外两个Survivor空间占比分别为8:1:1 ,可以通过操作选项
-XX:SurvivorRatio
调整这个空间比例。 比如-XX:SurvivorRatio=8
几乎所有的java对象都在Eden区创建, 但80%的对象生命周期都很短,创建出来就会被销毁
对象提升和复制
当Eden Space满时,触发Minor GC。在Minor GC过程中,Eden Space和当前正在使用的Survivor Space中的存活对象会被复制到另一个空闲的Survivor Space中,或者如果对象满足一定的年龄条件,直接提升到老年代(Old Generation)。这个过程结束后,之前使用的Survivor Space被清空,下次Minor GC时作为目标Survivor Space使用。
如果对象太大,无法在Eden Space或Survivor Spaces中完全存放,那么这些大对象会直接在老年代中分配。
JVM 每次只会使用 Eden 和其中的一块 Survivor 区域来为对象服务,所以无论什么时候,总是有一块 Survivor 区域是空闲着的。因此,新生代实际可用的内存空间为 9/10 ( 即90% )的新生代空间。这个设计是为了通过对象复制算法(如Copying GC)高效地回收年轻代中的短生命周期对象,同时减少内存碎片。
堆(Heap)的特点总结:
- 堆是Java虚拟机所管理的内存中最大的一块,在虚拟机启动的时候创建。几乎所有的对象实例以及数组都要在这里分配内存。
- 堆是jvm所有线程共享的。 堆中也包含私有的线程缓冲区 Thread Local Allocation Buffer (TLAB)
- 堆是垃圾收集器管理的主要区域,因此很多时候java堆也被称为“GC堆”(Garbage Collected Heap)。
- 堆是计算机物理存储上不连续的、逻辑上是连续的,也是大小可调节的(通过-Xms和-Xmx控制)。
- 方法结束后,堆中对象不会马上移出仅仅在垃圾回收的时候时候才移除。
- 如果在堆中没有内存完成实例的分配,并且堆也无法再扩展时,将会抛出OutOfMemoryError异常,即:堆内存溢出:java.lang.OutofMemoryError : java heap space.
产生 OutOfMemoryError
异常的常见原因:
- 内存中加载的数据过多,如一次从数据库中取出过多数据;
- 集合对对象引用过多且使用完后没有清空;
- 代码中存在死循环或循环产生过多重复对象;
- 堆内存分配不合理
对象分配过程
当new关键字用于创建一个新的对象时,JVM首先会在Eden Space中查找是否有足够的空间来分配新的对象。
当
Eden Space
已满时,JVM的垃圾回收器将对Eden Space
进行垃圾回收(Minor GC),将Eden Space
中不再被其他对象引用的对象进行销毁如果Minor GC后仍然没有足够的空间,或者对象太大,JVM会尝试在老年代分配对象。
年轻代中的对象如果在多次Minor GC后仍然存活,或者存活时间超过了设定的阈值,会被提升到老年代(默认为15次,可以通过设置参数调整阈值
-XX:MaxTenuringThreshold=N
)老年代内存不足时, 会再次出发GC:Major GC 进行老年代的内存清理
如果老年代执行了Major GC后仍然没有办法进行对象的保存,就会报OOM异常
方法区
方法区(Method Area)是运行时数据区的一部分,用于存储每个类的信息(Class Metadata)、常量、静态变量、即时编译器编译后的代码等数据。方法区有时(对HotSpot而言)也被称为非堆(Non-Heap)或元数据区。
方法区只是一个规范,其实现方式在jdk1.7及之前为永久代,jdk1.8则为元空间(MetaSpace),且元空间存在于本地内存(Native Memory)
方法区在Java8之后的变化
- 移除了永久代( PermGen ),替换为元空间(Metaspace )
- 永久代中的class metadata(类元信息)转移到了native memory (本地内存,而不是虚拟机)
- 永久代中的 interned Strings(字符串常量池)和 class static variables(类静态变量) 转移到了Java heap (JDK1.7)
- 永久代参数(PermSize MaxPermSize ) -→元空间参数(MetaspaceSize MaxMetaspaceSize )
Java8为什么要将永久代替换成Metaspace ?
- 字符串存在永久代中,容易出现性能问题和内存溢出
- 类及方法的信息等比较难确定其大小,因此对于永久代的大小指定比较困难,太小容易出现永久代溢出,太大则容易导致老年代溢出
- 永久代会为GC带来不必要的复杂度,并且回收效率偏低。
永久代和元空间
永久代(Permanent Generation)
在JDK 1.8之前,方法区的实现通常被称为永久代(Permanent Generation),永久代是 HotSpot 虚拟机对方法区的具体实现,永久代本身也存在于虚拟机堆中。
-xx:Permsize
: 设置永久代初始分配空间。默认值是20.75M-XX:MaxPermsize
: 设定永久代最大可分配空间。32位机器默认是64M,64位机器模式是82M。当JVM加载的类信息容量超过了这个值,会报异常OutofMemoryError:PermGen space
。
元空间(Meta Space)
从JDK 1.8开始,永久代被移除,方法区的实现改名为元空间(Metaspace)。元空间与永久代最大的区别在于,它不再使用堆内存,而是直接使用本机内存(Native Memory)。这意味着元空间的大小不再受JVM堆大小的限制,而是受限于系统可用的物理内存。
元数据区大小可以使用参数 -XX:MetaspaceSize
和 -XX:MaxMetaspaceSize
指定 默认值依赖于平台。windows下,-XX:MetaspaceSize
是21M,-XX:MaxMetaspaceSize
的值是 -1,即没有限制。
初始高水位线:
-XX:MetaspaceSize
设置了元空间的初始容量。当元空间的使用首次达到这个值时,JVM 将会尝试进行垃圾回收,特别是类卸载,以释放不再需要的类元数据。高水位线的动态调整:
如果在 Full GC 后,元空间中释放的空间较少,那么高水位线会根据释放的空间量适当上调,以减少未来的类卸载频率。这种动态调整机制旨在优化元空间的使用,避免频繁的类卸载操作,从而提高性能。最大高水位线:
-XX:MaxMetaspaceSize
定义了元空间的最大容量。即使在 Full GC 后,高水位线的上调也不会超过这个最大值。如果没有指定-XX:MaxMetaspaceSize
,则默认为物理内存的一定比例,通常情况下是不受限的,仅受制于系统的物理内存。
与永久代不同,如果不指定大小,默认情况下,虚拟机会耗尽所有的可用系统内存。如果元数据区发生溢出,虚拟机一样会抛出异常 OutOfMemoryError:Metaspace
查看永久代和元空间相关参数的命令:
jps # 是java提供的一个显示当前所有java进程pid的命令
jinfo -flag PermSize 进程号 #查看进程的PermSize初始化空间大小
jinfo -flag MaxPermSize 进程号 #查看PermSize最大空间
jinfo -flag MetaspaceSize 进程号 #查看Metaspace 最大分配内存空间
jinfo -flag MaxMetaspaceSize 进程号 #查看Metaspace最大空间
关于永久代和元空间的历史简介
在JDK 8以前,许多Java程序员都习惯在HotSpot虚拟机上开发、部署程序,很多人都更愿意把方法区称呼为“永久代”(Permanent Generation),或将两者混为一谈。
本质上这两者并不是等价的,因为仅仅是当时的HotSpot虚拟机设计团队选择把收集器的分代设计扩展至方法区,或者说使用永久代来实现方法区而已,这样使得HotSpot的垃圾收集器能够像管理Java堆一样管理这部分内存,省去专门为方法区编写内存管理代码的工作。
但是对于其他虚拟机实现,譬如BEA JRockit、IBM J9等来说,是不存在永久代的概念的。原则上如何实现方法区属于虚拟机实现细节,不受《Java虚拟机规范》管束,并不要求统一。
但现在回头来看,当年使用永久代来实现方法区的决定并不是一个好主意,这种设计导致了Java应用更容易遇到内存溢出的问题(永久代有-XX:MaxPermSize的上限,即使不设置也有默认大小,而J9和JRockit只要没有触碰到进程可用内存的上限,例如32位系统中的4GB限制,就不会出问题),而且有极少数方法(例如String::intern()
)会因永久代的原因而导致不同虚拟机下有不同的表现。
当Oracle收购BEA获得了JRockit的所有权后,准备把JRockit中的优秀功能,譬如Java MissionControl管理工具,移植到HotSpot虚拟机时,但因为两者对方法区实现的差异而面临诸多困难。考虑到HotSpot未来的发展,在JDK 6的时候HotSpot开发团队就有放弃永久代,逐步改为采用本地内存(Native Memory)来实现方法区的计划了,到了JDK 7的HotSpot,已经把原本放在永久代的字符串常量池、静态变量等移出,而到了JDK 8,终于完全废弃了永久代的概念,改用与JRockit、J9一样在本地内存中实现的 元空间(Meta-space) 来代替,把JDK 7中永久代还剩余的内容(主要是类型信息)全部移到元空间中。
常量池(Constant Pool)与串池(String Table)
常量池(Constant Pool)
常量池是每个.class
文件的一部分,它在编译期由编译器创建,用于存储类或接口中出现的各类常量信息,包括直接常量(如字面量和符号引用)和对其他类、字段和方法的引用。每个类都有自己的常量池,它在类加载到JVM时被读取。
运行时常量池(Runtime Constant Pool)
运行时常量池是方法区的一部分,当类被加载到JVM时,其常量池中的内容会被复制到运行时常量池中。运行时常态池是每个类或接口的运行时表示的一部分,它包含类结构的所有常量信息,而且它在运行时可以被修改,允许添加新的常量(例如,通过String.intern()
方法添加的字符串)
字符串池(String Pool / String Table)
字符串池是运行时常量池的一部分,它用于存储字符串字面量。当一个字符串字面量被创建时,它会被放入字符串池中。如果字符串池中已存在一个相等的字符串,则不会创建新的字符串对象,而是返回已存在的字符串引用。
关于intern()方法在不同JDK版本中的区别:
- 在JDK 1.6及之前版本中,如果串池中已经存在则直返回,不存在则复制到串池中并返回其引用
- JDK 1.7后的intern方法实现不会再复制一份字符串实例到串池,而是直接在串池中保存了对这个堆上的字符串实例的引用,这样可以减少内存的占用。
String ab = new String("a") + new String("b"); //在堆中创建"a","b","ab";
//先判断StringTable中是否有"ab",如果有直接返回StringTable中的地址
String ab2 = ab.intern(); // 如果没有则将该对象放入StringTable中(注意不同版本的区别点)
String ab3 = "ab"; //因为StringTable中有"ab",此时直接返回StringTable中地址。
System.out.println( ab2 == ab3 ); // true
System.out.println( ab == ab3 ); // JDK1.6为 false ,在JDK 1.7以后版本结果是true
直接内存
直接内存(Direct Memory)是一个特殊的内存区域,它不属于JVM的标准堆内存或方法区,而是直接向操作系统申请的堆外内存。直接内存的引入主要是为了支持高性能的I/O操作,尤其是在Java NIO(New IO)框架中,通过使用直接内存可以显著提高数据传输的效率。
堆外内存:
直接内存是在JVM堆之外分配的,它不受垃圾回收(GC)的影响,因此不会在常规的垃圾收集过程中被回收,除非通过特定的机制显式回收。性能优势:
使用直接内存可以避免在Java堆和本机堆之间复制数据,因为数据可以直接在本机堆和设备(如磁盘或网络接口)之间传输,这在大数据量的I/O操作中尤其重要,可以减少CPU的复制操作,提高性能。分配与回收:
直接内存的分配和回收成本较高,分配时需要通过JNI(Java Native Interface)或sun.misc.Unsafe
类来完成。回收通常需要手动进行,或者当DirectByteBuffer
对象不再被引用时,JVM会触发一个清理操作,通知操作系统释放内存。
在Java中,直接内存可以通过java.nio.ByteBuffer
的allocateDirect()
方法来分配和使用。以下是一个简单的示例:
import java.nio.ByteBuffer;
public class DirectMemoryExample {
public static void main(String[] args) {
ByteBuffer buffer = ByteBuffer.allocateDirect(1024);
// 使用buffer...
}
}
在这个例子中,ByteBuffer.allocateDirect(1024)
会直接向操作系统申请1024字节的内存,并返回一个DirectByteBuffer
对象,该对象充当了直接内存的引用。
直接内存的限制与风险
内存限制:
直接内存的大小可以通过JVM参数-XX:MaxDirectMemorySize
来设置,如果不设置,默认值可能与-Xmx
(最大堆内存)相同,但实际大小受到操作系统的限制。资源泄露:
如果不恰当地管理直接内存,可能会导致资源泄露,因为直接内存的回收依赖于DirectByteBuffer
对象的生命周期。如果对象不再使用但未被正确回收,直接内存可能会持续占用,最终导致内存不足。OOM(Out of Memory Error):
当直接内存消耗过多时,可能会抛出OutOfMemoryError
异常,提示“Direct buffer memory”。
直接内存是JVM提供的一种堆外内存管理机制,主要用于优化I/O操作的性能。它不受常规的垃圾回收机制影响,因此需要程序员更加谨慎地管理其分配和回收,以避免潜在的内存泄漏和资源浪费问题。
垃圾回收基本概念
垃圾回收(Garbage Collection,简称GC)是现代编程语言和运行环境中的一个重要组成部分,尤其是在Java这样的自动内存管理语言中。
Java中堆是进行 GC 的主要区域,堆里面存放着Java世界中几乎所有的对象实例,垃圾收集器在对堆进行回收前,第一件事情就是要确定哪些对象是需要进行垃圾回收的。
垃圾回收机制通过识别和回收不可达对象来管理内存,利用代际假说来优化回收策略,通过可达性分析算法来确定哪些对象是活动的。这些机制共同作用,使得Java程序无需手动管理内存,减少了内存泄漏和野指针等问题,提高了程序的健壮性和可维护性。
可达性分析
可达性分析(Reachability Analysis) 是垃圾回收算法用来确定对象是否可达的方法。
它通常从一组 根对象(GC Roots) 开始,这组根对象集通常包括:
- 所有活动线程的栈帧中的局部变量。
- 方法区(即永久代或元空间)中的静态变量。
- 方法区中的某些常量引用。
- 本地方法栈中JNI(Java Native Interface)的引用。
可达性分析算法会从这些根对象集开始,沿着对象之间的引用链进行深度优先或广度优先搜索,标记所有能从根对象直接或间接引用到的对象。未被标记的对象被视为不可达,可以被垃圾回收器回收。
关于引用计数法
当前主流的商用程序语言(Java、C#,上溯至前面提到的古老的Lisp)的内存管理子系统,都是通过 可达性分析(Reachability Analysis) 算法来判定对象是否存活的。
Java 虚拟机(JVM)不使用引用计数法来确定对象是否可回收,主要原因有以下几点:
1. 循环引用问题
引用计数法的一个主要缺陷是无法处理循环引用。假设有两个对象互相引用彼此,即使它们不再被外部代码引用,它们的引用计数也不会降为零,因此也不会被回收。下面是一个简单的例子:
class Node {
Node next;
}
Node a = new Node();
Node b = new Node();
a.next = b;
b.next = a;
// Now, both 'a' and 'b' are referenced by each other,
// but not reachable from any GC roots
a = null;
b = null;
System.gc();
在这种情况下,a
和 b
对象虽然断开了与GC Root的连接,但由于它们互相引用,引用计数永远不会降为零,从而导致内存泄露。
而可达性分析依赖于从GC Roots出发遍历对象引用链,通过标记所有可达对象,从而有效地处理了循环引用问题。
2. 性能问题
引用计数法需要在每次对象引用发生变化时都需要进行加减操作。这意味着,每当一个引用被创建或销毁时,即便是赋值操作也需要更新计数器,这会引入额外的性能开销。
3. 碎片化问题
引用计数不仅会增加时间上的性能开销,还会导致内存碎片化。因为引用计数法不能有效地整理和压缩内存,长期运行的系统容易产生大量碎片,降低内存的利用效率。
不可达对象
不可达对象(Unreachable Objects) 是指那些不再被任何有效引用所指向的对象。这意味着如果一个对象不能从任何活动线程的根对象集(GC Roots)通过一系列引用链访问到,那么这个对象就被视为不可达,从而成为垃圾回收的候选对象。
一旦对象变为不可达,GC就可以在适当的时机回收它所占用的内存,以便重新分配给其他对象使用。
代际假说理论
代际假说(Generational Hypothesis) 是垃圾回收领域的一个重要理论,它基于以下观察:
- 新创建的对象往往寿命较短,很快就会变得不可达,成为垃圾。
- 如果一个对象在多次垃圾回收后仍然存活,那么它很可能会继续存活很长时间。
基于这个假说,JVM将堆内存划分为几个代(Generation):
- 年轻代(Young Generation):新创建的对象首先在这里分配,通常这里发生频繁的垃圾回收。
- 老年代(Old Generation):经过多次年轻代GC后存活下来的对象会被提升到老年代,这里发生垃圾回收的频率较低。
其他相关概念
Minor GC、Major GC、Full GC以及Mixed GC等概念,很大程度上源自HotSpot虚拟机的内部实现和相关文档。HotSpot是Oracle JDK和OpenJDK默认的JVM实现,它的垃圾回收策略和命名约定在Java社区中非常普及,以至于这些术语在讨论垃圾回收时常常被广泛使用。
垃圾回收相关概念
部分收集器(Partial GC)
部分收集器专注于堆内存的某个特定部分,而不是整个堆。它进一步细分为以下几种类型:
Minor GC / Young GC:这是年轻代(Young Generation)的垃圾回收过程,主要发生在Eden区。当Eden区满时,Minor GC会被触发,将存活的对象移动到Survivor区或晋升到老年代。Minor GC通常频率较高,停顿时间较短。
Major GC / Old GC:针对老年代(Old Generation)进行垃圾回收,通常是因为老年代空间不足。
整堆收集器(Full GC)
整堆收集器对整个堆内存进行垃圾回收,包括年轻代、老年代以及永久代(PermGen)或元空间(Metaspace)。Full GC通常在以下情况发生:
- 当老年代空间不足,且之前的Minor GC未能释放足够的空间时。
- 当永久代或元空间空间不足。
- 显式调用
System.gc()
时,尽管这并不保证立即执行Full GC,且通常不推荐这样做。
Mixed GC
Mixed GC是一个比较新的概念,主要出现在现代的垃圾回收器如G1(Garbage First)中。它是一种混合了年轻代和老年代回收的策略,在清理年轻代的同时,也会清理一部分老年代区域,这种策略有助于减少Full GC的发生,降低停顿时间。
这些概念是垃圾回收领域普遍存在的,并且在不同的JVM实现中,也有类似的功能和行为,尽管具体实现细节可能有所不同。
垃圾回收算法
垃圾回收算法是自动内存管理系统的关键部分,用于识别和回收不再使用的内存,防止内存泄漏,提高程序的稳定性和性能。
标记-清除算法/复制算法/标记-整理算法的区别
内存碎片:标记-清除算法容易产生内存碎片,而标记-压缩算法通过整理内存,可以有效减少碎片。
内存利用率:复制算法由于需要两倍的内存空间,因此在内存利用率上有劣势,而标记-压缩算法和标记-清除算法在理论上可以更高效地使用内存。
对象移动:标记-清除算法不会移动对象,而标记-压缩算法和复制算法都需要移动对象。对象移动增加了额外的开销,但可以避免内存碎片。
停顿时间:标记-清除算法和标记-压缩算法都可能引起较长的停顿时间,因为它们需要遍历整个堆空间。复制算法如果在内存有限的年轻代使用,则可以快速完成,因为涉及的内存空间相对较小。
并发性:标记-压缩算法的某些阶段可以与应用程序并发执行,而复制算法通常不能。
现代的垃圾回收器,如G1、ZGC和Shenandoah,往往采用复合算法,结合上述各种算法的优点,以适应复杂多变的运行环境。
标记-清除算法
标记-清除(Mark and Sweep) 是最基本的垃圾回收算法,分为两个阶段:
- 标记阶段:从根对象(GC Roots)开始,遍历整个对象图,将所有可达对象标记为“活着”的状态。这通常通过深度优先搜索或广度优先搜索完成。
- 清除阶段:回收未被标记的对象所占用的内存空间。所有未标记的对象被认为是垃圾,可以被回收。虽然内存空间被释放,但由于对象位置不变,可能会留下很多小块的空闲空间,造成内存碎片。
优缺点
优点:算法简单,易于实现。
缺点:
- 效率问题:标记和清除过程都需要遍历整个堆,可能导致较长的停顿时间。
- 内存碎片化:回收后的空闲内存可能会散布在堆中,导致分配大对象时找不到足够大的连续内存空间。
标记-复制算法
标记-复制算法常被简称为复制算法(Copying)。现在的商用Java虚拟机大多都优先采用了这种收集算法去回收新生代
复制算法将可用内存分为两个相等的部分,每次只使用其中一个部分。当这部分的内存用完时,垃圾回收器会检查这部分内存中的对象,将存活的对象复制到另一部分的内存中,然后放弃原来的那部分内存。
优缺点
优点:
- 解决了内存碎片化问题,因为每次回收后都会得到一块连续的内存空间。
- 高效的回收过程,只需要处理当前使用的那一半内存。
缺点:
- 内存利用率只有50%,因为一半的内存总是空闲的。
- 如果存活对象过多,复制成本会很高。
标记-整理算法
标记-整理(Mark-Compact) 算法,有时也被称为标记-压缩算法,是在标记-清除算法
的基础上增加了整理(Compact)步骤。
标记阶段:算法会像标记-清除算法那样,从根节点开始遍历整个对象图,标记所有可达的对象。
压缩阶段:与复制算法类似,但并非将存活对象复制到另一个区域,而是将它们移动到当前内存区域的一端,从而整理内存空间,消除碎片。这一过程中,所有存活对象的地址会被更新,以反映它们的新位置。
优缺点
优点:
- 解决了标记-清除的内存碎片问题。
- 与复制算法相比,内存利用率更高。
缺点:
- 对象移动可能会导致额外的开销,尤其是当存活对象很多时。
- 仍需要遍历整个堆,可能会有较长的停顿时间。
分代收集算法
分代收集(Generational Collection) 基于“代际假说”:新创建的对象倾向于较快地变成垃圾,而存活时间长的对象则倾向于继续存活。JVM将堆分为几代:
- 年轻代(Young Generation):新对象首先分配在此,包含Eden和两个Survivor空间。
- 老年代(Old Generation):多次存活的对象最终会被提升到这里。
年轻代使用复制算法,而老年代通常使用标记-清除
或 标记-整理
算法。
优缺点
优点:
- 高效地处理年轻代中的短暂生命周期对象。
- 减少了老年代的回收频率,降低系统整体的停顿时间。
增量/并行/并发收集
增量收集(Incremental Collection)): 逐步进行垃圾回收以减少停顿时间,而不是一次性处理整个堆
并行收集(Parallel Collection): 多个CPU同时进行垃圾回收工作,利用多核处理器的优势加快回收速度
并发收集(Concurrent Collection): 允许部分GC过程与应用程序并行执行,进一步减少了停顿时间,例如CMS(Concurrent Mark Sweep)和G1(Garbage First)收集器。
优缺点
优点:显著降低了应用程序的停顿时间,提高了用户感知的响应速度。
缺点:实现复杂,需要额外的资源管理和协调机制。
每种垃圾回收算法都有其特定的适用场景和优化目标,现代的JVM通常会结合多种算法来提供更高效和灵活的内存管理。
常见垃圾回收器
Java虚拟机的HotSpot实现提供了多种垃圾收集器和算法,每种都有其特定的使用场景和优化目标。
可以使用-XX:+PrintCommandLineFlags
选项来查看JVM启动时使用的默认参数,包括垃圾收集器的信息。例如:
# 查看JVM启动时使用的默认参数,包括垃圾收集器的信息
java -XX:+PrintCommandLineFlags -version
# 查看JVM支持的所有垃圾收集器选项
java -XX:+PrintFlagsFinal -version | grep Use
Serial
Serial系列的垃圾回收器是Sun Microsystems(后来被Oracle收购)的HotSpot虚拟机团队开发的,主要用于单线程的环境中,尤其是在客户端应用或者小型系统中。它们的设计简单,易于理解和维护,但在多核处理器的现代系统上效率较低。
Serial系列主要包括两个主要的收集器:Serial收集器和Serial Old收集器
Serial & Serial Old
Serial收集器
Serial收集器主要用于 年轻代(Young Generation) 的垃圾回收。它的主要特点包括:
- 单线程处理:在整个垃圾回收过程中只使用一个CPU核心或一条线程来完成所有工作。
- 复制算法:在年轻代中,Serial收集器使用复制算法进行垃圾回收。这意味着它会将年轻代分为两个相等的部分(Eden区和Survivor区),在每次垃圾回收时,存活的对象会被复制到另一个区域,而当前区域则被清空。
- “Stop-the-World”机制:在垃圾回收过程中,所有的应用线程都会被暂停,直到垃圾回收过程完成。这是因为垃圾收集需要独占访问堆内存,以确保数据的一致性和安全性。
Serial Old收集器
Serial Old收集器是Serial收集器的老年代版本,用于 老年代(Old Generation) 的垃圾回收。它的特性与Serial收集器相似,但也有一些不同之处:
- 标记-整理算法:在老年代中,Serial Old收集器通常使用标记-压缩(Mark-Sweep-Compact)算法。这一算法首先标记出所有活着的对象,然后将它们移动到内存的一端,同时压缩空闲空间,以减少内存碎片。
- 单线程:与Serial收集器一样,Serial Old收集器也是单线程的,这意味着它在执行垃圾回收时也会导致“Stop-the-World”。
Serial系列的垃圾回收器最适合于配置较低的机器或者是对响应时间要求不高的应用场景。因为它们使用单线程,所以在多核处理器的环境中可能不会充分利用硬件资源,但对于小型应用或者简单的测试环境,它们的简单性和低开销使其成为合理的选择。
在JVM启动参数中,可以通过以下方式启用Serial系列的垃圾回收器:
-XX:+UseSerialGC
:启用整个Serial GC,即同时使用Serial收集器和Serial Old收集器-XX:+UseSerialCollector
:仅启用年轻代的Serial收集器,老年代的收集器将由JVM自动选择-XX:+UseSerialOldGC
:仅启用老年代的Serial Old收集器,年轻代的收集器将由JVM自动选择
Serial系列的垃圾回收器虽然在性能上不如现代的并行或多线程垃圾回收器,但它们在某些特定场景下仍然有其价值,特别是对于那些不需要高端性能且希望保持简单性和稳定性的应用。
ParNew
ParNew系列的垃圾回收器同样是Sun Microsystems(现为Oracle)的HotSpot虚拟机团队的成果,它作为Serial收集器的多线程版本,专为年轻代(Young Generation)设计,以提升多核处理器环境下垃圾回收的效率。ParNew的引入是为了应对现代计算架构中并行处理能力的增长,从而提高整体系统的吞吐量和响应性。
ParNew收集器
ParNew收集器专注于年轻代的垃圾回收,其关键特性包括:
- 多线程处理:与Serial收集器的单线程模式不同,ParNew利用多线程技术在年轻代执行并行的垃圾回收,以减少停顿时间和提升回收效率。
- 复制算法:ParNew同样采用复制算法进行垃圾回收,将年轻代划分为Eden区和两个Survivor区,但通过并行化复制过程,显著加快了垃圾回收速度。
- “Stop-the-World”机制:尽管ParNew使用了多线程,但在垃圾回收期间,所有应用线程仍会被暂停,以确保数据一致性。不过,由于并行处理,停顿时间相对缩短。
ParNew收集器通常与Concurrent Mark Sweep (CMS) 或者G1等老年代收集器结合使用,形成一套完整的垃圾回收方案。它特别适合于需要快速响应和较低延迟的应用场景,如Web服务器和交互式应用。
在JVM启动参数中,ParNew收集器的启用方式如下:
-XX:+UseParNewGC
:启用ParNew作为年轻代的垃圾收集器,通常会与指定的老年代收集器配合使用。
ParNew收集器通过并行化垃圾回收过程,显著提升了年轻代的垃圾回收效率,尤其在多核处理器系统中表现突出。尽管如此,随着JVM技术的发展,如G1和ZGC等更为先进的垃圾收集器逐步成熟,ParNew的应用场景逐渐受到限制,但它在并行垃圾回收领域的历史地位不可忽视。
Parallel
Parallel系列的垃圾回收器也是由HotSpot虚拟机团队开发,与Serial系列形成鲜明对比,旨在充分利用多核处理器的并行处理能力,提高垃圾回收效率,特别是在服务器端应用或高性能计算环境中。Parallel系列包括两个主要组件:Parallel Scavenge收集器和Parallel Old收集器,共同致力于优化吞吐量和系统资源利用率。
Parallel & Parallel Old
Parallel Scavenge收集器
Parallel Scavenge收集器专注于年轻代(Young Generation)的垃圾回收,其特性包括:
- 多线程并行处理:与Serial收集器的单线程模式不同,Parallel Scavenge利用多线程并行回收机制,显著加快了垃圾回收速度,减少了应用程序的暂停时间。
- 复制算法:同样采用复制算法进行垃圾回收,但通过并行化技术,极大地提高了年轻代的回收效率。
- 可控制的吞吐量:Parallel Scavenge允许用户设定目标吞吐量,即应用程序运行时间与垃圾回收时间的比例,以适应不同的性能需求。
Parallel Old收集器
Parallel Old收集器是Parallel Scavenge的老年代对应版本,用于老年代(Old Generation)的垃圾回收,其特点包括:
- 标记-整理算法:与Serial Old类似,使用标记-压缩算法,但通过并行化该过程,大大减少了老年代的垃圾回收时间。
- 多线程并行处理:在老年代也实现了并行化的垃圾回收,提高了整个系统的吞吐量和资源利用率。
- 减少停顿时间:尽管仍采用“Stop-the-World”机制,但通过并行化,有效降低了单次停顿的时间长度。
在JVM启动参数中,启用Parallel系列的垃圾回收器方式如下:
-XX:+UseParallelGC
:启用Parallel GC,即同时使用Parallel Scavenge收集器和Parallel Old收集器。-XX:+UseParallelOldGC
:仅启用Parallel Old收集器,年轻代的收集器将由JVM自动选择,通常为Parallel Scavenge。-XX:ParallelGCThreads=<N>
:设置Parallel GC使用的线程数,<N>
表示具体的线程数量。
Parallel系列的垃圾回收器,通过并行化垃圾回收过程,极大提升了系统的整体性能和资源利用率,特别适合于那些对吞吐量有高要求的大型服务器应用(如大型数据库服务、科学计算和大规模数据处理系统)。它们代表了HotSpot虚拟机团队在多核处理器时代对垃圾回收技术的创新和优化。
CMS
CMS (Concurrent Mark Sweep)收集器,由Sun Microsystems的HotSpot虚拟机团队设计开发,旨在最小化垃圾回收对应用程序的中断时间,特别适合于对响应时间敏感的应用场景,如Web服务器和交互式系统,其中低延迟比高吞吐量更为重要。
CMS (Concurrent Mark Sweep)
CMS收集器
CMS收集器专注于 老年代(Old Generation) 的垃圾回收,具有以下特点:
- 并发标记和清除:CMS的主要优势在于大部分的垃圾回收过程(如标记和清除阶段)都是与应用程序线程并发执行的,只有在 标记的重新标记阶段 和 清除的紧凑阶段 需要短暂的“Stop-the-World”。
- 不进行内存压缩:为了保持并发执行,CMS不执行内存压缩,这意味着垃圾回收后可能会留下内存碎片。
- 高吞吐量模式下的性能下降:虽然CMS旨在减少停顿时间,但在高负载和高吞吐量的环境下,频繁的垃圾回收可能导致性能下降。
年轻代收集器配合
CMS收集器通常与ParNew收集器配对使用,ParNew是一个多线程的年轻代收集器,可以有效地减少年轻代的垃圾回收时间,从而更好地支持CMS的老年代垃圾回收策略。
CMS收集器在追求低延迟的同时,牺牲了一定程度的内存碎片管理,这在某些情况下可能需要额外的内存管理策略,如使用G1收集器的混合策略,或者在CMS无法有效管理碎片时触发Full GC。
在JVM启动参数中,启用CMS收集器的方式如下:
-XX:+UseConcMarkSweepGC
:启用CMS作为老年代的垃圾收集器。-XX:CMSInitiatingOccupancyFraction=<N>
:设置堆内存占用达到多少百分比时触发CMS的阈值,默认为92%。-XX:ParallelCMSThreads=<N>
:设置CMS并发标记和清除阶段的线程数。
CMS收集器以其低延迟特性在业界得到了广泛的应用,特别是在那些对用户体验高度敏感的环境中。然而,随着更先进收集器(如G1和ZGC)的出现,CMS的一些局限性(如内存碎片问题)得到了改善。
G1(Garbage-First)
G1收集器(Garbage-First Collector)是HotSpot虚拟机团队开发的一款面向服务端应用的垃圾回收器,设计目标是在可控的停顿时间内高效地回收垃圾,同时能够充分利用多核处理器的并行处理能力。
Garbage-First Collector
G1收集器打破了传统的年轻代和老年代的界限,通过Region的概念实现了动态的内存分配和回收,这使得G1能够更灵活地应对不同的垃圾回收需求,同时也简化了垃圾回收的管理。
G1收集器的主要特性
- 分区堆管理:G1的分区堆管理改变了堆区的Eden、Survivor和Old区的传统比例概念。G1将堆内存划分成多个大小相同的独立区域(Region),每个Region都可以是Eden、Survivor或Old区。这种设计允许G1在不同代之间更加灵活地分配内存,以及在必要时合并或转换Region的角色。
- 并行与并发:G1收集器能够同时利用多个CPU核心执行垃圾回收,同时部分阶段可以与应用程序并发执行,减少垃圾回收对应用的中断时间。
- 预测性控制:G1能够预测垃圾回收的停顿时间,并尝试在预定的时间内完成回收任务,这对于需要低延迟的应用场景尤为重要。
- 内存碎片整理:G1内置了内存碎片整理功能,能够在回收过程中避免内存碎片的产生,提高内存的使用效率。
G1的年轻代和老年代之间的界限也变得模糊,因为它可以根据需要动态地调整不同区域的作用。例如,如果需要更多的空间用于老年代,G1可以将一些原本用作年轻代的Region转换为老年代的Region(可以认为G1只在逻辑上进行分代,物理上不分代)。这使得G1能够更有效地利用内存,减少内存碎片,并允许更细粒度的垃圾回收控制。
G1收集器使用的垃圾回收算法
标记-整理(Mark-Compact)算法:全局标记阶段使用标记-整理算法来识别活对象。在G1的全局标记阶段,它会并发地标记所有存活的对象,然后在局部区域进行整理,将存活的对象移动到区域的一端,释放出连续的空间。
复制(Copying)算法:在年轻代区域,G1使用复制算法进行垃圾回收。但是,与传统的年轻代(如Serial或ParNew收集器所使用的)不同,G1的年轻代没有明确的Eden区和Survivor区划分。相反,G1将堆划分为多个大小相同的Region,其中一些Region充当Eden,而其他Region可以充当Survivor。
标记-清除(Mark-Sweep)算法:在G1的并发标记阶段,它会标记出所有存活的对象,但这部分类似于标记-清除算法,不过G1并不会立即清除未标记的对象,而是等到后续的回收阶段。
G1通过其独特的分区堆管理和算法组合,提供了更好的垃圾回收性能,特别是在处理大堆和多核处理器的现代系统上,它能够提供更短的停顿时间和更高的吞吐量。
G1自JDK9起成为默认的垃圾收集器,在年轻代和老年代之间提供了一种统一的垃圾回收解决方案
在JVM启动参数中,启用G1收集器的方式如下:
-XX:+UseG1GC
:启用G1作为垃圾收集器。-XX:MaxGCPauseMillis=<N>
:设置最大垃圾回收停顿时间的目标,<N>
表示毫秒数。-XX:G1HeapRegionSize=<N>
:设置每个Region的大小,<N>
表示字节大小。
G1收集器的引入标志着Java垃圾回收技术的一个重大进步,它不仅解决了过去垃圾收集器在多核处理器环境下效率不高的问题,还通过其独特的设计,提供了更佳的性能和更可控的延迟,特别适合于现代大型服务端应用的需求。随着JDK的不断演进,G1收集器已成为许多服务端应用的首选垃圾回收方案。
ZGC
ZGC(Z Garbage Collector)是Oracle为解决大规模堆内存管理问题而设计的一种低延迟、可扩展的垃圾回收器,特别适用于拥有大量内存的现代服务器环境。
ZGC自JDK 11开始作为实验性特性加入,并在后续版本中逐渐成熟,其目标是在巨大的堆大小下也能保持极低的垃圾回收停顿时间。
Z Garbage Collector
ZGC的主要特性
- 并行与并发:ZGC利用现代多核处理器的优势,通过并行与并发的方式执行垃圾回收,减少停顿时间。
- 低延迟:ZGC设计的目标之一就是将垃圾回收的停顿时间控制在毫秒级别,即使在数百GB的大堆上也是如此。
- 无指针压缩:ZGC采用了无指针压缩技术,这意味着在进行垃圾回收时,对象的引用不需要更新,这大大减少了停顿时间。
- 分代垃圾回收:尽管ZGC在内部使用了分代的概念,但它对外部隐藏了这些细节,用户无需关心年轻代和老年代的具体管理。
- 可扩展性:ZGC设计时就考虑了大堆内存的管理,它可以在非常大的堆上提供稳定的性能。
分区堆管理与算法
ZGC同样使用了分区堆管理,但与G1不同,ZGC的区域大小更大,而且区域之间没有固定的角色分配。ZGC在堆中使用了更少但更大的区域,这有助于减少管理开销。
ZGC使用了多种算法来实现其目标,包括并行的标记和清除算法,以及一种新颖的无指针压缩算法,这使得ZGC能够在不停顿的情况下进行内存整理,避免了内存碎片问题。
在JVM启动参数中,启用ZGC收集器的方式如下:
-XX:+UnlockExperimentalVMOptions
-XX:+UseZGC
ZGC收集器代表了垃圾回收技术的前沿,它解决了大堆环境下垃圾回收的挑战,特别是对于那些对延迟极其敏感的应用场景,如金融交易系统、大数据处理平台等。尽管ZGC在JDK中作为实验性特性引入,但其性能和稳定性已经得到了社区的广泛认可,成为现代服务端应用中一个强有力的选择。
Shenandoah
Shenandoah是一款低延迟垃圾回收器,旨在提供与G1和ZGC类似的低停顿时间,同时在不同规模的堆上都能保持良好的性能。Shenandoah特别适合那些对响应时间有严格要求的应用程序,它能在大规模堆内存环境下保持微秒级的停顿时间。
Shenandoah垃圾回收器最初由Red Hat公司开发,并在2014年贡献给了OpenJDK。它随后成为了Adoptium项目的一部分,与其他垃圾回收器如(G1和ZGC)一同为Java开发者提供了更多性能优化和延迟控制的选择。
Shenandoah Collector
Shenandoah收集器的主要特性
- 并发与并行:Shenandoah充分利用了多核处理器的能力,通过并发与并行执行垃圾回收的各个阶段,以减少对应用程序的干扰。
- 低停顿时间:Shenandoah的设计目标是在数十GB甚至更大的堆上,也能将垃圾回收的停顿时间控制在几毫秒或更低。
- 指针更新:不同于ZGC的无指针压缩,Shenandoah在垃圾回收过程中需要更新对象间的引用,但它通过并发执行和精细的锁机制,将更新操作的影响降到最低。
- 分区堆管理:Shenandoah将堆划分为多个较小的区域,类似于G1,但它的区域划分和管理策略更加灵活,能够适应不同大小的堆和不同的垃圾回收需求。
并发标记与并发清理
Shenandoah使用并发标记和并发清理算法,这使得大多数垃圾回收工作可以在应用程序运行时进行,只有在标记和清理的最后阶段,才会有一个短暂的“Stop-the-World”事件,用于最终的指针更新和对象移动。
在JVM启动参数中,启用Shenandoah收集器的方式如下:
-XX:+UnlockDiagnosticVMOptions
-XX:+UnlockExperimentalVMOptions
-XX:+UseShenandoahGC
Shenandoah收集器是为满足现代应用程序对低延迟和高性能的需求而设计的,它不仅适用于服务端应用,也适用于需要快速响应的客户端应用。Shenandoah在OpenJDK中的引入,为开发者提供了另一种在不同堆大小和硬件配置下都能保持良好性能的垃圾回收选择。