当我们编写Java应用程序时,我们通常不需要考虑内存管理的细节,因为Java虚拟机(JVM)会自动处理内存分配和垃圾回收。垃圾回收是JVM的一个重要功能,它负责在应用程序运行时自动回收不再使用的内存。在这篇技术博客中,我们将深入探讨JVM垃圾回收机制的工作原理,以及如何优化垃圾回收以提高Java应用程序的性能。
目录
什么是垃圾?
如何定位垃圾?
引用计数算法
根可达算法
常见垃圾回收算法
分代
分区
新生代和老年代
常用垃圾回收器
Serial
Parallel Scavenge
CMS
三色标记算法
特殊情况
第一种是CMS的解决方案:Incremental Update
CMS解决方案的缺陷
第二种是G1方案:SATB Snapshot At the Beginning
Rset
实战操作
GC常用参数:
G1常用参数
不管我们写什么语言,我们都需要向内存当中分配数据,在C语言当中,我们使用malloc方法来给变量分配内存。在C++中我们new一个对象来分配内存。然而,所有语言都会面临一个问题:当我分配了一块内存空间之后,我们需要把它回收掉。
在C/C++中我们一般称之为手工回收。也就是需要开发人员自己回收内存。手工回收在调试程序的时候有巨大的困难。比如假设我们忘记了回收内存,别的变量就无法再占有这块内存,这就造成了内存泄漏。再比如我们判断失误多回收了一次,及会造成不可预估的损失。
当我们再内存中分配之后再也用不到的空间,就是我们所谓的垃圾。由于C/C++是手工回收非常容易出错,所以java中设置一个“垃圾管理员”GC来完成回收任务。
当我们要回收内存当中的垃圾的时候,首先需要定位垃圾的位置,常用的垃圾定位算法有两种:
引用计数(reference count)和根可达(RootSearching)
引用计数算法是一种简单的垃圾定位算法,它通过在对象中添加一个计数器来跟踪对象的引用数。当引用数为0时,对象就可以被回收。
但是,这种算法存在一个问题,即无法处理循环引用的情况。例如,如果对象A引用了对象B,而对象B又引用了对象A,那么它们的引用计数永远不会为0,即使它们已经不再被程序使用了。
为了解决这个问题,JVM使用可达性分析算法。该算法从一组称为“GC Roots”的对象开始,然后通过这些对象的引用链来查找所有可达对象。所有不可达对象都可以被回收。GC Roots包括虚拟机栈中的对象、方法区中的类静态属性引用的对象以及本地方法栈中JNI(Native方法)引用的对象等。
通过可达性分析算法,JVM可以正确地处理循环引用的情况,并且可以准确地确定哪些对象可以被回收,从而有效地管理内存。
常见的垃圾回收算法有以下几种:
标记-清除算法(Mark and Sweep):该算法分为两个阶段,首先标记所有活动对象,然后清除所有未标记的对象。该算法的缺点是碎片化,会产生内存碎片,导致内存空间的利用率降低。
复制算法(Copying):该算法将内存空间分为两个区域,每次只使用其中一个区域。当这个区域用完后,将其中的存活对象复制到另一个区域中,然后清除原来的区域。该算法的优点是不会产生内存碎片,但是需要额外的空间来存储复制对象,空间利用率较低。
标记-压缩算法(Mark and Compact):该算法在标记阶段和标记-清除算法类似,但是在清除阶段,它会将所有存活对象移动到内存的一端,然后清除另一端的所有对象。该算法的缺点是需要移动对象,因此效率较低。
在JVM当中,GC围绕着这三种算法的排列组合,结合着对于内存的分代或分区的管理方式,到现在为止,JVM一共产生了十种垃圾回收器
将内存一分为二,一部分是新生代,一部分是老年代。新创建的对象会被分配到年轻代,而长时间存活的对象则会被移动到老年代。年轻代使用复制算法,而老年代使用标记-整理或标记-压缩算法。分代可以进一步提高垃圾回收的效率,因为不同代的对象具有不同的生命周期和特性,可以针对性地选择合适的垃圾回收算法和参数。
分区模型是另一种内存管理方式,它将内存划分为多个固定大小的区域,每个区域可以分配给不同的进程或线程使用。
如图,分区是把内存分为一个个小块。
在JVM的内存管理中,JDK8及以前采用的都是分代模型,JDK9采用的默认垃圾回收器G1首次采用了分区模型。
在分代模型中,新生代中的对象通常具有较短的生命周期,而老年代中的对象则具有较长的生命周期。当新生代中的对象经过多次垃圾回收仍然存活时,它们就会被转移到老年代中。
具体来说,在新生代中,通常将内存分为一个Eden空间和两个Survivor空间。当Eden空间满时,会触发一次垃圾回收,将存活的对象移动到其中一个Survivor空间中。当这个Survivor空间也满时,会将存活的对象移动到另一个Survivor空间中。当第二个Survivor空间也满时,会将存活的对象移动到老年代中。
因此,新生代中的对象需要经过多次垃圾回收才能转移到老年代中,而且只有在对象的生命周期比较长或者内存空间不足时,才会触发这种转移。如果新生代中的对象可以在垃圾回收后被释放,那么它们就不会被转移到老年代中。
左边六种回收器中,连虚线的可以配合使用,上面三个用于年轻代,下面三个用于老年代
Serial回收器是Java虚拟机中的一种垃圾回收器,它是最早也是最简单的一种垃圾回收器。Serial回收器主要用于单线程环境下的垃圾回收。
假设你,你女友,男友是三个线程,你们在房间里玩耍制造了不少的垃圾,屋里没地方了,这时候你妈妈(Serial)走了进来,说来你们哥儿仨靠墙角站好(STW:stop the world,业务线程完全停止),等我收拾完了再继续。
在JDK1.0版本,内存只有16M,这种方法还是很高效的,但是随着版本更新,内存越来越大,回收,业务停止的时间会非常长。这就是卡顿的来源。
Parallel Scavenge是一种并行垃圾回收器,适用于多核CPU环境下的垃圾回收。
随着JVM内存的增大,如果线程数过多,大量的资源被用于上下文切换,效率反而会变低。
CMS(Concurrent Mark Sweep)主要用于处理大型堆内存的垃圾回收。相比于传统的标记-清除算法,CMS算法具有更低的停顿时间,可以在不影响应用程序正常运行的情况下进行垃圾回收。
这就好比你和男友女友在屋里玩耍,这时你爸爸妈妈爷爷奶奶全都进来,你们仨一边玩他们一边清理。
CMS算法由JDK1.4诞生,至今还在被广泛使用,但CMS的缺点是需要实时跟踪程序的运行过程来判断哪些是垃圾,会占用一定的CPU资源。
三色标记算法(Tricolor Marking Algorithm)是一种用于垃圾回收的算法,它是基于可达性分析的算法之一。该算法将对象分为三个颜色:白色、灰色和黑色,用于表示对象的可达性状态。
我们首先要理解:每一个垃圾回收线程都只是工作一小段时间,其他线程在它的基础上继续工作,扫过的对象不会再扫第二遍。
在三色标记算法中,初始时所有对象都被标记为白色,表示它们尚未被访问过。然后,从根对象开始,将其标记为灰色,表示它们正在被访问。接着,遍历灰色对象的引用,将其指向的对象标记为灰色,然后将灰色对象标记为黑色,表示它们已经被访问过。最后,所有剩余的白色对象都被认为是不可达的垃圾对象,可以被回收。
在回收线程扫描的同时,业务线程还在不停的给对象添加逻辑,就可能会出现没有遍历到的结点。所以说在第一遍垃圾回收时很有可能会有遗漏,但是问题不大,第二遍垃圾回收就可以给清掉了
如果在B与D断开的同时A与D产生了引用,此时由于A已经被标记为黑色,GC不会再扫描A,但此时D仍为白色,会被误清理掉。这种漏标问题的解决方案有两种
这种方案指的是,在整个程序的运行过程中,如果发现了一个被标记为黑色的对象的成员变量指向了白色对象,那么就把这个黑色变成灰色。这个操作由JVM自己完成。
这个CMS的解决方案看似完美,但是隐藏着一个严重的bug,这个bug导致CMS在JDK的任何一个版本都没有被设置为默认的垃圾回收器,我们来看看CMS的这个缺陷
如图,假设现在A,B,D是白色,有一个垃圾回收线程m1正在标记A的时候,它标记A的时候已经标完A的第一个属性了,第二个属性还没标完 ,此时A的颜色是灰色,然后此时A把属性1指向了一个白色对象D。此时业务线程运行,A.1指向了B,B.1指向了null。我们的回收线程m1此时回到A,站在m1的角度,A.1已经标完了,再扫描完A.2之后,A就会被标为黑色,此时D被漏标。
要解决这个问题,CMS的remark阶段,也就是开始清理之前,必须从头再扫描一遍(此时需要STW)。
在B->D消失的时候,把该引用推到GC的堆栈。只要灰色对象指向白色对象的引用消失,就把该引用推到堆栈当中。当垃圾回收线程再次回来的时候,首先要在堆栈当中找,找出该白色对象看看此时是否有对象引用D (白色),如果没有,认定为垃圾,如果有,去扫描。
那么G1如何找到谁引用了D呢,这就归功于G1的一个内部处理机制:Rset
在G1中,堆被分为多个大小相等的区域(Region),每个区域可以是Eden、Survivor或Old区域。当一个对象从一个区域引用另一个区域的对象时,就会产生跨区域引用。
为了快速地进行垃圾回收,G1需要知道哪些区域之间存在跨区域引用。这个信息被记录在每个区域的RSet中。RSet是一个位图,它记录了当前区域中的对象引用了哪些其他区域的对象。当G1进行垃圾回收时,它会扫描每个区域的RSet,找到所有的跨区域引用,并将这些引用标记为灰色。
每个区间头部(大概10%~15%)有一张表格记录了有哪些区间的对象记录了这个区间的对象
首先我们打开cmd,运行指令:java -XX:+PrintCommandLineFlags -version
这是一个Java命令,用于查看Java虚拟机的命令行参数和版本信息。该命令会输出Java虚拟机的命令行参数以及Java版本信息,包括Java版本号、Java虚拟机版本号、Java虚拟机的厂商信息等。
其中,java
表示要执行的Java程序,-XX:+PrintCommandLineFlags
表示要输出Java虚拟机的命令行参数,-version
表示要输出Java版本信息。
InitialHeapSize:最小堆大小,MaxHeapSize:最大堆大小,UseCompressedPointers:使用压缩类指针,UseCompressedOops:使用压缩普通对象指针,UseG1GC:使用G1垃圾回收器。
以下是一些常用的Java虚拟机的垃圾回收参数:
-Xms
:初始堆大小,指定Java虚拟机启动时堆的最小值。-Xmx
:最大堆大小,指定Java虚拟机堆的最大值。-XX:NewSize
:新生代大小,指定新生代的大小。-XX:MaxNewSize
:最大新生代大小,指定新生代的最大大小。-XX:SurvivorRatio
:新生代中Eden区域和Survivor区域的比例。-XX:MaxTenuringThreshold
:对象晋升老年代的年龄。-XX:PermSize
:永久代大小,指定永久代的大小。-XX:MaxPermSize
:最大永久代大小,指定永久代的最大大小。-XX:+UseParallelGC
:使用并行垃圾回收器。-XX:+UseConcMarkSweepGC
:使用CMS垃圾回收器。-XX:+UseG1GC
:使用G1垃圾回收器。-XX:+PrintGC
:输出GC日志。-XX:+PrintGCDetails
:输出详细的GC日志。-XX:+PrintGCDateStamps
:输出GC日志的时间戳。-XX:+PrintHeapAtGC
:在GC前后输出堆的使用情况。下面是一些G1垃圾回收器的常用参数:
-XX:+UseG1GC
:启用G1垃圾回收器。-XX:G1HeapRegionSize=n
:设置堆区域的大小,n的值为2的幂,范围为1MB到32MB,默认值为1MB。-XX:MaxGCPauseMillis=n
:设置最大垃圾回收停顿时间,单位为毫秒,默认值为200毫秒。-XX:G1NewSizePercent=n
:设置新生代大小占整个堆大小的百分比,默认值为5%。-XX:G1MaxNewSizePercent=n
:设置新生代最大大小占整个堆大小的百分比,默认值为60%。-XX:ParallelGCThreads=n
:设置并行垃圾回收线程数,默认值为与CPU核心数相同。除了上述常用参数,G1垃圾回收器还有很多其他的参数可以配置,具体可以查看官方文档。
注意:G1不要指定年轻代大小,这是G1动态分配的。