Java垃圾回收

       网上看了很多关于垃圾回收的资料,总觉得说的很隐晦,不是同俗易懂,现在自己也来整理下Java的垃圾回收机制;主要参考Java编程思想和深入浅出

JVM,JVM性能优化这几本书。

                                                                         垃圾回收的意义                                                                                                                      

       首先是要明白垃圾回收的意义,说简单一点,我们应该都玩过电脑吧,大家都知道,电脑的可用内存是越大运行速度就越快,

玩起游戏来也会很爽,但是我们的电脑内存不是无限的,所以我们的时常清理一下垃圾,或者卸载一些我们不想用的软件,从而来达到内存清理的目的。而在我们的Java程序里面,我们会把JVM当做一个虚拟的计算机,他也会有内存,也会有内存区域,这和我们电脑分为A,B,C,D盘是一样的,他的每块内存区域也都会有不同的效果。每当我们在Java程序里面创建对象的时候,就会往保存在内存区域,如果我们不及时的去清除内存区域就会造成程序运行性能的下降,有可能当我们内存满的时候会出现一些异常信息;所以这个时候我们就需要进行垃圾回收了,Java的垃圾回收不需要我们手动的去处理,因为他有Gc这个垃圾回收机制。

                                                                        Stop-the-world                                                                                                                         

      我觉得在了解这个机制前,必须的知道Stop-the-world的概念,因为不管Gc使用了哪种垃圾回收算法都会出现这种情景。Stop-the-world的中文意思是中止,停止一切,停止世界 ,没错Gc就是怎么霸道,Gc大喊一声,Stop-the-world,这意味着,JVM将要停止其他线程,只运行一个垃圾回收线程。被中断的任务将在GC任务完成后恢复执行。GC调优往往意味着减少stop-the-world的时间。

      那么什么是Gc啦,使用他有什么好处啦。垃圾回收是一种动态存储内存的管理技术,他自动释放不在被程序引用的对象,按照

特定的垃圾收集算法,来回收垃圾,从而让出内存,让后面的对象可以继续创建使用,以免造成内存泄露。使用Gc可以简化我们的开发,因为我们不用去考虑什么时候该进行垃圾回收,其次他也保护的程序的健壮性,使得我们不会因为错误的垃圾回收而导致程序的崩溃。

                                                                         垃圾收集的算法                                                                           

      其实不管使用什么算法,他们都必须要先去找出无引用的对象,将它标记成垃圾,然后再由我们的一个JVM系统级线程去释放

该内存。

     下面来看几个常用的垃圾收集算法;

大多数垃圾回收算法使用了根集(root set)这个概念;所谓根集就是正在执行的Java程序可以访问的引用变量的集合(包括局部变量、参数、类变量),程序可以使用引用变量访问对象的属性和调用对象的方法。垃圾回收首先需要确定从根开始哪些是可达的和哪些是不可达的,从根集可达的对象都是活动对象,它们不能作为垃圾被回收,这也包括从根集间接可达的对象。而根集通过任意路径不可达的对象符合垃圾收集的条件,应该被回收。下面介绍几个常用的算法。
  
1. 引用计数法(Reference Counting Collector)
  引用计数法是唯一没有使用根集的垃圾回收的法,该算法使用引用计数器来区分存活对象和不再使用的对象
。一般来说,堆中的每个对象对应一个引用计数器。当每一次创建一个对象并赋给一个变量时,引用计数器置为1。当对象被赋给任意变量时,引用计数器每次加1当对象出了作用域后(该对象丢弃不再使用),引用计数器减1,一旦引用计数器为0,对象就满足了垃圾收集的条件。
  基于引用计数器的垃圾收集器运行较快,不会长时间中断程序执行,适宜地必须实时运行的程序。但引用计数器增加了程序执行的开销,因为每次对象赋给新的变量,计数器加1,而每次现有对象出了作用域,计数器减1,但这样有一个缺陷就是当两个对象互相指向对方的时候,那么计算器永远不可能为0,这两个对象也永远不会被回收。
  
2. tracing算法(Tracing Collector)
  tracing算法是为了解决引用计数法的问题而提出,它使用了根集的概念。基于tracing算法的垃圾收集器从根集开始扫描,识别出哪些对象可达,哪些对象不可达,并用某种方式标记可达对象,例如对每个可达对象设置一个或多个位。在扫描识别过程中,基于tracing算法的垃圾收集也称为标记和清除(mark-and-sweep)
垃圾收集器.

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

  这是最基础的垃圾回收算法,之所以说它是最基础的是因为它最容易实现,思想也是最简单的。标记-清除算法分为两个阶段:标记阶段和清除阶段。标记阶段的任务是标记出所有需要被回收的对象,清除阶段就是回收被标记的对象所占用的空间。具体过程如下图所示:

  从图中可以很容易看出标记-清除算法实现起来比较容易,但是有一个比较严重的问题就是容易产生内存碎片,碎片太多可能会导致后续过程中需要为大对象分配空间时无法找到足够的空间而提前触发新的一次垃圾收集动作。

  4.Copying(复制)算法

  为了解决Mark-Sweep算法的缺陷,Copying算法就被提了出来。它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用的内存空间一次清理掉,这样一来就不容易出现内存碎片的问题。具体过程如下图所示:

  这种算法虽然实现简单,运行高效且不容易产生内存碎片,但是却对内存空间的使用做出了高昂的代价,因为能够使用的内存缩减到原来的一半。

  很显然,Copying算法的效率跟存活对象的数目多少有很大的关系,如果存活对象很多,那么Copying算法的效率将会大大降低。

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

  为了解决Copying算法的缺陷,充分利用内存空间,提出了Mark-Compact算法。该算法标记阶段和Mark-Sweep一样,但是在完成标记之后,它不是直接清理可回收对象,而是将存活对象都向一端移动,然后清理掉端边界以外的内存。具体过程如下图所示:

Java垃圾回收_第1张图片

  

  5.Generational Collection(分代收集)算法

  分代收集算法是目前大部分JVM的垃圾收集器采用的算法。它的核心思想是根据对象存活的生命周期将内存划分为若干个不同的区域。一般情况下将堆区划分为老年代(Tenured Generation)和新生代(Young Generation),老年代的特点是每次垃圾收集时只有少量对象需要被回收,而新生代的特点是每次垃圾回收时都有大量的对象需要被回收,那么就可以根据不同代的特点采取最适合的收集算法。

  目前大部分垃圾收集器对于新生代都采取Copying算法,因为新生代中每次垃圾回收都要回收大部分对象,也就是说需要复制的操作次数较少,但是实际中并不是按照1:1的比例来划分新生代的空间的,一般来说是将新生代划分为一块较大的Eden空间和两块较小的Survivor空间,每次使用Eden空间和其中的一块Survivor空间,当进行回收时,将Eden和Survivor中还存活的对象复制到另一块Survivor空间中,然后清理掉Eden和刚才使用过的Survivor空间。

  而由于老年代的特点是每次回收都只回收少量对象,一般使用的是Mark-Compact算法。

  注意,在堆区之外还有一个代就是永久代(Permanet Generation),它用来存储class类、常量、方法描述等。对永久代的回收主要回收两部分内容:废弃常量和无用的 类。

 6. adaptive算法(Adaptive Collector)
  在特定的情况下,一些垃圾收集算法会优于其它算法。基于Adaptive算法的垃圾收集器就是监控当前堆的使用情况,并将选择适当算法的垃圾收集器。

7. System.gc()方法

      命令行参数透视垃圾收集器的运行
  使用System.gc()可以不管JVM使用的是哪一种垃圾回收的算法,都可以请求Java的垃圾回收。在命令行中有一个参数-verbosegc可以查看Java使用的堆内存的情况,它的格式如下:
  java -verbosegc classfile

8. finalize()方法

      在JVM垃圾回收器收集一个对象之前,一般要求程序调用适当的方法释放资源,但在没有明确释放资源的情况下,Java提供了缺省机制来终止该对象心释放资源,这个方法就是finalize()。它的原型为:
  protected void finalize() throws Throwable
  在finalize()方法返回之后,对象消失,垃圾收集开始执行。原型中的throws Throwable表示它可以抛出任何类型的异常。
  之所以要使用finalize(),是存在着垃圾回收器不能处理的特殊情况。假定你的对象(并非使用new方法)获得了一块“特殊”的内存区域,由于垃圾回收器只知道那些显示地经由new分配的内存空间,所以它不知道该如何释放这块“特殊”的内存区域,那么这个时候java允许在类中定义一个由finalize()方法。

      特殊的区域例如:1)由于在分配内存的时候可能采用了类似 C语言的做法,而非JAVA的通常new做法。这种情况主要发生在native method中,比如native method调用了C/C++方法malloc()函数系列来分配存储空间,但是除非调用free()函数,否则这些内存空间将不会得到释放,那么这个时候就可能造成内存泄漏。但是由于free()方法是在C/C++中的函数,所以finalize()中可以用本地方法来调用它。以释放这些“特殊”的内存空间。2)又或者打开的文件资源,这些资源不属于垃圾回收器的回收范围。
      换言之,finalize()的主要用途是释放一些其他做法开辟的内存空间,以及做一些清理工作。因为在JAVA中并没有提够像“析构”函数或者类似概念的函数,要做一些类似清理工作的时候,必须自己动手创建一个执行清理工作的普通方法,也就是override Object这个类中的finalize()方法。例如,假设某一个对象在创建过程中会将自己绘制到屏幕上,如果不是明确地从屏幕上将其擦出,它可能永远都不会被清理。如果在finalize()加入某一种擦除功能,当GC工作时,finalize()得到了调用,图像就会被擦除。要是GC没有发生,那么这个图像就会

被一直保存下来。

      一旦垃圾回收器准备好释放对象占用的存储空间,首先会去调用finalize()方法进行一些必要的清理工作。只有到下一次再进行垃圾回收动作的时候,才会真正释放这个对象所占用的内存空间。
  在普通的清除工作中,为清除一个对象,那个对象的用户必须在希望进行清除的地点调用一个清除方法。这与C++"析构函数"的概念稍有抵触。在C++中,所有对象都会破坏(清除)。或者换句话说,所有对象都"应该"破坏。若将C++对象创建成一个本地对象,比如在堆栈中创建(在Java中是不可能的,Java都在堆中),那么清除或破坏工作就会在"结束花括号"所代表的、创建这个对象的作用域的末尾进行。若对象是用new创建的(类似于Java),那么当程序员调用C++的 delete命令时(Java没有这个命令),就会调用相应的析构函数。若程序员忘记了,那么永远不会调用析构函数,我们最终得到的将是一个内存"漏洞",另外还包括对象的其他部分永远不会得到清除。
  相反,Java不允许我们创建本地(局部)对象--无论如何都要使用new。但在Java中,没有"delete"命令来释放对象,因为垃圾回收器会帮助我们自动释放存储空间。所以如果站在比较简化的立场,我们可以说正是由于存在垃圾回收机制,所以Java没有析构函数。然而,随着以后学习的深入,就会知道垃圾收集器的存在并不能完全消除对析构函数的需要,或者说不能消除对析构函数代表的那种机制的需要(原因见下一段。另外finalize()函数是在垃圾回收器准备释放对象占用的存储空间的时候被调用的,绝对不能直接调用finalize(),所以应尽量避免用它)。若希望执行除释放存储空间之外的其他某种形式的清除工作,仍然必须调用Java中的一个方法。它等价于C++的析构函数,只是没后者方便。
      在C++中所有的对象运用delete()一定会被销毁,而JAVA里的对象并非总会被垃圾回收器回收。In another word, 1 对象可能不被垃圾回收,2 垃圾回收并不等于“析构”,3 垃圾回收只与内存有关。也就是说,并不是如果一个对象不再被使用,是不是要在finalize()中释放这个对象中含有的其它对象呢?不是的。因为无论对象是如何创建的,垃圾回收器都会负责释放那些对象占有的内存。

9. 触发主GC(Garbage Collector)的条件

  JVM进行次GC的频率很高,但因为这种GC占用时间极短,所以对系统产生的影响不大。更值得关注的是主GC的触发条件,因为它对系统影响很明显。总的来说,有两个条件会触发主GC:

  1)当应用程序空闲时,即没有应用线程在运行时,GC会被调用。因为GC在优先级最低的线程中进行,所以当应用忙时,GC线程就不会被调用,但以下条件除外。

  2)Java堆内存不足时,GC会被调用。当应用线程在运行,并在运行过程中创建新对象,若这时内存空间不足,JVM就会强制地调用GC线程,以便回收内存用于新的分配。若GC一次之后仍不能满足内存分配的要求,JVM会再进行两次GC作进一步的尝试,若仍无法满足要求,则 JVM将报“out of memory”的错误,Java应用将停止。

  由于是否进行主GC由JVM根据系统环境决定,而系统环境在不断的变化当中,所以主GC的运行具有不确定性,无法预计它何时必然出现,但可以确定的是对一个长期运行的应用来说,其主GC是反复进行的。

10. 减少GC开销的措施

  根据上述GC的机制,程序的运行会直接影响系统环境的变化,从而影响GC的触发。若不针对GC的特点进行设计和编码,就会出现内存驻留等一系列负面影响。为了避免这些影响,基本的原则就是尽可能地减少垃圾和减少GC过程中的开销。具体措施包括以下几个方面:

  (1)不要显式调用System.gc()

  此函数建议JVM进行主GC,虽然只是建议而非一定,但很多情况下它会触发主GC,从而增加主GC的频率,也即增加了间歇性停顿的次数。

  (2)尽量减少临时对象的使用

  临时对象在跳出函数调用后,会成为垃圾,少用临时变量就相当于减少了垃圾的产生,从而延长了出现上述第二个触发条件出现的时间,减少了主GC的机会。

  (3)对象不用时最好显式置为Null

  一般而言,为Null的对象都会被作为垃圾处理,所以将不用的对象显式地设为Null,有利于GC收集器判定垃圾,从而提高了GC的效率。

  (4)尽量使用StringBuffer,而不用String来累加字符串

  由于String是固定长的字符串对象,累加String对象时,并非在一个String对象中扩增,而是重新创建新的String对象,如Str5=Str1+Str2+Str3+Str4,这条语句执行过程中会产生多个垃圾对象,因为对次作“+”操作时都必须创建新的String对象,但这些过渡对象对系统来说是没有实际意义的,只会增加更多的垃圾。避免这种情况可以改用StringBuffer来累加字符串,因StringBuffer是可变长的,它在原有基础上进行扩增,不会产生中间对象。

  (5)能用基本类型如Int,Long,就不用Integer,Long对象

  基本类型变量占用的内存资源比相应对象占用的少得多,如果没有必要,最好使用基本变量。

  (6)尽量少用静态对象变量

  静态变量属于全局变量,不会被GC回收,它们会一直占用内存。

  (7)分散对象创建或删除的时间

  集中在短时间内大量创建新对象,特别是大对象,会导致突然需要大量内存,JVM在面临这种情况时,只能进行主GC,以回收内存或整合内存碎片,从而增加主GC的频率。集中删除对象,道理也是一样的。它使得突然出现了大量的垃圾对象,空闲空间必然减少,从而大大增加了下一次创建新对象时强制主GC的机会。

 

      下面这个例子向大家展示了垃圾收集所经历的过程,并对前面的陈述进行了总结。

     

[java] view plain copy print ?
  1. class Chair {  
  2.   static boolean gcrun = false;  
  3.   static boolean f = false;  
  4.   static int created = 0;  
  5.   static int finalized = 0;  
  6.   int i;  
  7.   Chair() {  
  8.       i = ++created;  
  9.       if(created == 47)  
  10.          System.out.println("Created 47");  
  11.   }  
  12.   protected void finalize() {  
  13.       if(!gcrun) {  
  14.          gcrun = true;  
  15.          System.out.println("Beginning to finalize after " + created + " Chairs have been created");  
  16.       }  
  17.       if(i == 47) {  
  18.          System.out.println("Finalizing Chair #47, " +"Setting flag to stop Chair creation");  
  19.          f = true;  
  20.       }  
  21.       finalized++;  
  22.       if(finalized >= created)  
  23.          System.out.println("All " + finalized + " finalized");  
  24.   }  
  25. }  
  26.   
  27. public class Garbage {  
  28.   public static void main(String[] args) {  
  29.   if(args.length == 0) {  
  30.      System.err.println("Usage: /n" + "java Garbage before/n or:/n" + "java Garbage after");  
  31.      return;  
  32.   }  
  33.   while(!Chair.f) {  
  34.      new Chair();  
  35.      new String("To take up space");  
  36.   }  
  37.   System.out.println("After all Chairs have been created:/n" + "total created = " + Chair.created +  
  38.   ", total finalized = " + Chair.finalized);  
  39.   if(args[0].equals("before")) {  
  40.     System.out.println("gc():");  
  41.     System.gc();  
  42.     System.out.println("runFinalization():");  
  43.     System.runFinalization();  
  44.   }  
  45.   System.out.println("bye!");  
  46.   if(args[0].equals("after"))  
  47.      System.runFinalizersOnExit(true);  
  48.   }  
  49. }  

class Chair {   static boolean gcrun = false;   static boolean f = false;   static int created = 0;   static int finalized = 0;   int i;   Chair() {    i = ++created;    if(created == 47)     System.out.println("Created 47");   }   protected void finalize() {    if(!gcrun) {     gcrun = true;     System.out.println("Beginning to finalize after " + created + " Chairs have been created");    }    if(i == 47) {     System.out.println("Finalizing Chair #47, " +"Setting flag to stop Chair creation");     f = true;    }    finalized++;    if(finalized >= created)     System.out.println("All " + finalized + " finalized");   } } public class Garbage {   public static void main(String[] args) {   if(args.length == 0) {     System.err.println("Usage: /n" + "java Garbage before/n or:/n" + "java Garbage after");     return;   }   while(!Chair.f) {     new Chair();     new String("To take up space");   }   System.out.println("After all Chairs have been created:/n" + "total created = " + Chair.created +   ", total finalized = " + Chair.finalized);   if(args[0].equals("before")) {     System.out.println("gc():");     System.gc();     System.out.println("runFinalization():");     System.runFinalization();   }   System.out.println("bye!");   if(args[0].equals("after"))     System.runFinalizersOnExit(true);   } }

      上面这个程序创建了许多Chair对象,而且在垃圾收集器开始运行后的某些时候,程序会停止创建Chair。由于垃圾收集器可能在任何时间运行,所以我们不能准确知道它在何时启动。因此,程序用一个名为gcrun的标记来指出垃圾收集器是否已经开始运行。利用第二个标记f,Chair可告诉main()它应停止对象的生成。这两个标记都是在finalize()内部设置的,它调用于垃圾收集期间。另两个static变量--created以及 finalized--分别用于跟踪已创建的对象数量以及垃圾收集器已进行完收尾工作的对象数量。最后,每个Chair都有它自己的(非 static)int i,所以能跟踪了解它具体的编号是多少。编号为47的Chair进行完收尾工作后,标记会设为true,最终结束Chair对象的创建过程。

11. 关于垃圾回收的几点补充
  经过上述的说明,可以发现垃圾回收有以下的几个特点
  (1)垃圾收集发生的不可预知性:由于实现了不同的垃圾回收算法和采用了不同的收集机制,所以它有可能是定时发生,有可能是当出现系统空闲CPU资源时发生,也有可能是和原始的垃圾收集一样,等到内存消耗出现极限时发生,这与垃圾收集器的选择和具体的设置都有关系。
  (2)垃圾收集的精确性:主要包括2 个方面:(a)垃圾收集器能够精确标记活着的对象;(b)垃圾收集器能够精确地定位对象之间的引用关系。前者是完全地回收所有废弃对象的前提,否则就可能造成内存泄漏。而后者则是实现归并和复制等算法的必要条件。所有不可达对象都能够可靠地得到回收,所有对象都能够重新分配,允许对象的复制和对象内存的缩并,这样就有效地防止内存的支离破碎。
  (3)现在有许多种不同的垃圾收集器,每种有其算法且其表现各异,既有当垃圾收集开始时就停止应用程序的运行,又有当垃圾收集开始时也允许应用程序的线程运行,还有在同一时间垃圾收集多线程运行。
  (4)垃圾收集的实现和具体的JVM 以及JVM的内存模型有非常紧密的关系。不同的JVM 可能采用不同的垃圾收集,而JVM 的内存模型决定着该JVM可以采用哪些类型垃圾收集。现在,HotSpot 系列JVM中的内存系统都采用先进的面向对象的框架设计,这使得该系列JVM都可以采用最先进的垃圾收集。
  (5)随着技术的发展,现代垃圾收集技术提供许多可选的垃圾收集器,而且在配置每种收集器的时候又可以设置不同的参数,这就使得根据不同的应用环境获得最优的应用性能成为可能。
  针对以上特点,我们在使用的时候要注意
  (1)不要试图去假定垃圾收集发生的时间,这一切都是未知的。比如,方法中的一个临时对象在方法调用完毕后就变成了无用对象,这个时候它的内存就可以被释放。
  (2)Java中提供了一些和垃圾收集打交道的类,而且提供了一种强行执行垃圾收集的方法--调用System.gc(),但这同样是个不确定的方法。Java 中并不保证每次调用该方法就一定能够启动垃圾收集,它只不过会向JVM发出这样一个申请,到底是否真正执行垃圾收集,一切都是个未知数。
  (3)挑选适合自己的垃圾收集器。一般来说,如果系统没有特殊和苛刻的性能要求,可以采用JVM的缺省选项。否则可以考虑使用有针对性的垃圾收集器,比如增量收集器就比较适合实时性要求较高的系统之中。系统具有较高的配置,有比较多的闲置资源,可以考虑使用并行标记/清除收集器。
  (4)关键的也是难把握的问题是内存泄漏。良好的编程习惯和严谨的编程态度永远是最重要的,不要让自己的一个小错误导致内存出现大漏洞。
  (5)尽早释放无用对象的引用。大多数程序员在使用临时变量的时候,都是让引用变量在退出活动域(scope)后,自动设置为null,暗示垃圾收集器来收集该对象,还必须注意该引用的对象是否被监听,如果有,则要去掉监听器,然后再赋空值。

 


                                                                                               按代的垃圾回收                                                                                                 


     我们说大部分新创建的对象都会在短时间内变得不可达,只有极少数的老对象(创建时间比较长的对象)会指向新生对象的引用。

这个时候我们就会把内存从物理上划分为新生代和老年代。绝大多数刚创建的对象都会被分配到新生代这里,由于大部分对象在创建后

很快就变得不可达,我们说这就执行了第一次Gc我们称之为minorGc.在新生代里面,采用了"bump-the-pointer"和"TLABs"这两种

技术来加快内存分配.首先"bump-the-pointer"会去跟踪在新生代里面创建的最后一个对象,检查是否还有内容空间,如果有内存空间

的话,则把这个对象放在最前端.下次再创建新对象的时候会再次执行.但是如果我们在多线程的情况下,事情将截然不同,大家都知道

线程需要加锁解锁,这样会极大地影响性能,而使用TLABs这种技术则会去在新生代里面为每个线程单独分配一块内存空间,这样和

bump-the-pointer相结合可以在不加锁的情况下分配内存.

     当我们的对象从新生代里面清除之后会跑到幸存区里面,一般会有两块幸存区,首先会把从新生代里面gc过来的对象放到同一块幸存

区里面,当第一块幸存区满了之后,会存放到第二块幸存区里面,并且已经满了的幸存区会执行第二次Gc,把它里面的内存清空,而

从幸存区里面存活下来的对象会被保存在老年代。

   老年代里面占用的内存比较多,存活的对象也比较复杂,并且发生在老年带的GC要比新生代少的多,对象从老年带中消失的过程

我们称之为"full GC";

   另外还有一个持久代,也被称为方法区,里面保存类常量,和字符串常量,这个区域也可能发生GC,我们称为major GC;

   如果老年代里面要引用一个新的对象,该怎么办拉,为了解决这个问题,老年代里面会有一个叫card table 的512byte大小的块,

所有老年代对象调用,新生代对象都会纪录在这个块里面,当我们需要对新生代进行GC的时候,就可以查询这个块。老年代

GC基本上是在内存已满时发生,当然你也可以执行System.gc,或者把对象清空,但是并不推荐这样。

执行老年代GC的方法也会有不同,算法也会不同,不过大体上都有如下几种:

1.Serial GC 算法是标记,汇总,压缩;

2.Parallel Gc 算法同上,只不过是多线程执行,因此效率更高

3.Parallel Old Gc  算法同上,只不过在汇总清理的时候算法相对复杂;

4.CMS GC 算法最复杂,优点时延迟GC,GC的时间会非常短。缺点占用更多内存和CPU;

5.G1 Gc 性能很好,算法跟上面不同;



你可能感兴趣的:(Java垃圾回收)