Java基础(5)—垃圾回收机制 GC

image.png

垃圾回收机制(GC)

简介:

JVM的垃圾回收机制称为GC,众所周知,java语言不需要像c++那样需要自己申请内存,自己释放内存,它可以自动回收可以释放的内存资源。如果不进行垃圾回收,内存迟早都会被消耗空,因为我们在不断的分配内存空间而不进行回收。除非内存无限大,我们可以任性的分配而不回收,但是事实并非如此。所以,垃圾回收是必须的。

JVM内存运行时数据区:

结构图:

image.png

基础知识:

堆(heap):

最大的,最重要的一块区域,称为逻辑堆,主要用来存放对象实例与数组,对于所有的线程来说他是全局共享的,对于Heap堆区是动态分配内存的,所以空间大小和生命周期都不是明确的,而GC的主要作用就是自动释放逻辑堆里实例对象所占的内存,而在逻辑堆中还分为新生代与老年代,用来区分对象的存活时间,在新生代中还被细致的分为 Eden SurvivorFrom以及SurvivorTo这三部分。

方法区(Method Area):

主要存储(类加载器)ClassLoader加载的类信息,可理解为已经编译好的代码储存区,所以存储包括类的元数据、常量池、字段、静态变量与方法内的局部变量以及编译好的字节码等等。在Hotspot里将它称之为永生代。

栈(stack):

全称为虚拟机栈,主要存储基本数据类型,以及对象的引用,私有线程。在每一个对象被创建的时候,在堆栈区都有一个对他的引用。

Object obj = new Object();

左边的Object obj 等于在堆栈区申请了一个内存,也就是对类的引用,而 new Object()则是生成了一个实例,= 则是将对象的内容可通过obj进行访问,在Java里都是通过引用来操纵对象。

pc寄存器(PC Register):

在多线程中,系统需要给每一个线程分配一个编号,这个时候才会需要到寄存器。

四大引用状态(JDK 1.2之后):

1.强引用:

代码中普遍存在的类似"Object obj = new Object()"这类的引用,只要强引用还存在,除非程序员手动代码解除强引用,释放资源。否值,垃圾收集器永远不会回收掉被引用的对象。

2.软引用:

描述有些还有用但并非必需的对象。在系统将要发生内存溢出异常之前,将会把这些对象列进回收范围进行二次回收。如果这次回收还没有足够的内存,才会抛出内存溢出异常。Java中的类SoftReference表示软引用。

3.弱引用:

描述非必需对象。被弱引用关联的对象只能生存到下一次垃圾回收之前,垃圾收集器工作之后,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。Java中的类WeakReference表示弱引用。

4.虚引用:

这个引用存在的唯一目的就是在这个对象被收集器回收时收到一个系统通知,被虚引用关联的对象,和其生存时间完全没关系。Java中的类PhantomReference表示虚引用。

两大查找回收对象法:

1.引用计数法:

给对象中添加一个引用计数器,每当一个地方引用这个对象时,计数器值+1;当引用失效时,计数器值-1。任何时刻计数值为0的对象就是不可能再被使用的。这种算法使用场景很多,但是,Java中却没有使用这种算法,因为这种算法很难解决对象之间相互引用的情况。

/**
* 虚拟机参数:-verbose:gc
*/
public class ReferenceCountingGC
{
  private Object instance = null;
  private static final int _1MB = 1024 * 1024;    
  /** 这个成员属性唯一的作用就是占用一点内存 */
  private byte[] bigSize = new byte[2 * _1MB];    
  public static void main(String[] args)
  {
      ReferenceCountingGC objectA = new ReferenceCountingGC();
      ReferenceCountingGC objectB = new ReferenceCountingGC();
      objectA.instance = objectB;
      objectB.instance = objectA;
      objectA = null;
      objectB = null;
      
      System.gc();
  }
}
/*
运行结果:
[GC 4417K->288K(61440K), 0.0013498 secs]
[Full GC 288K->194K(61440K), 0.0094790 secs]
*/

两个对象相互引用着,但是虚拟机还是把这两个对象回收掉了,这也说明虚拟机并不是通过引用计数法来判定对象是否存活的。

2.可达性分析法:

通过一系列称为“GC Roots”的对象作为起始点,从这些节点向下搜索,搜索所走过的路径称为引用链,当一个对象到GC Roots没有任何引用链(即GC Roots到对象不可达)时,则证明此对象是不可用的。

GC Roots的对象包括下面几种:
1.虚拟机栈(栈帧中的局部变量区,也叫局部变量表)中的引用对象。
2.方法区中的类静态属性引用的对象。
3.方法区中常量引用的对象。
4.本地方法栈中JNI(Native方法)引用的对象。

image.png

由图可知,obj8、obj9、obj10都没有到GCRoots对象的引用链,即便obj9和obj10之间有引用链,他们还是会被当成垃圾处理,可以进行回收。

注意:

对于可达性分析算法而言,未到达的对象并非是“非死不可”的,若要宣判一个对象死亡,至少需要经历两次标记阶段。

1.如果对象在进行可达性分析后发现没有与GCRoots相连的引用链,则该对象被第一次标记并进行一次筛选,筛选条件为是否有必要执行该对象的finalize方法,若对象没有覆盖finalize方法或者该finalize方法是否已经被虚拟机执行过了,则均视作不必要执行该对象的finalize方法,即该对象将会被回收。反之,若对象覆盖了finalize方法并且该finalize方法并没有被执行过,那么,这个对象会被放置在一个叫F-Queue的队列中,之后会由虚拟机自动建立的、优先级低的Finalizer线程去执行,而虚拟机不必要等待该线程执行结束,即虚拟机只负责建立线程,其他的事情交给此线程去处理。

2.对F-Queue中对象进行第二次标记,如果对象在finalize方法中拯救了自己,即关联上了GCRoots引用链,如把this关键字赋值给其他变量,那么在第二次标记的时候该对象将从“即将回收”的集合中移除,如果对象还是没有拯救自己,那就会被回收。

如下代码演示了一个对象如何在finalize方法中拯救了自己,然而,它只能拯救自己一次,第二次就被回收了。

/*
* 此代码演示了两点:
* 1.对象可以再被GC时自我拯救
* 2.这种自救的机会只有一次,因为一个对象的finalize()方法最多只会被系统自动调用一次
* */
public class FinalizeEscapeGC {    
  public String name;
  public static FinalizeEscapeGC SAVE_HOOK = null;
  public FinalizeEscapeGC(String name) {
      this.name = name;
  }

  public void isAlive() {
      System.out.println("我还活着。)");
  }
  
  @Override
  protected void finalize() throws Throwable {
      super.finalize();
      System.out.println("finalize method 执行");
      System.out.println(this);
      FinalizeEscapeGC.SAVE_HOOK = this;
  }

  @Override
  public String toString() {
      return name;
  }

  public static void main(String[] args) throws InterruptedException {
      SAVE_HOOK = new FinalizeEscapeGC("jimyoungwei");
      System.out.println(SAVE_HOOK);
      // 对象第一次拯救自己
      SAVE_HOOK = null;
      System.out.println(SAVE_HOOK);
      System.gc();
      // 因为finalize方法优先级很低,所以暂停0.5秒以等待它
      Thread.sleep(500);
      if (SAVE_HOOK != null) {
          SAVE_HOOK.isAlive();
      } else {
          System.out.println("我已消亡。");
      }

      // 下面这段代码与上面的完全相同,但是这一次自救却失败了
      // 一个对象的finalize方法只会被调用一次
      SAVE_HOOK = null;
      System.gc();
      // 因为finalize方法优先级很低,所以暂停0.5秒以等待它
      Thread.sleep(500);
      if (SAVE_HOOK != null) {
          SAVE_HOOK.isAlive();
      } else {
          System.out.println("我已消亡。");
      }
  }
}
/*
运行结果:
jimyoungwei
null
finalize method 执行
jimyoungwei
我还活着。
我已消亡。
*/

对象的finalize方法最多被虚拟机调用一次,一个堆对象的this(放在局部变量表中的第一项)引用会永远存在,在方法体内可以将this引用赋值给其他变量,这样堆中对象就可以被其他变量所引用,即不会被回收。

四大垃圾收集算法:

1.标记-消除(Mark-Sweep)算法:

最基础的算法,分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,标记完成后统一回收所有被标记的对象。

不足:主要体现在效率和空间:从效率的角度讲,标记和清除两个过程的效率都不高;从空间的角度讲,标记清除后会产生大量不连续的内存碎片, 内存碎片太多可能会导致以后程序运行过程中在需要分配较大对象时,无法找到足够的连续内存而不得不提前触发一次垃圾收集动作。

标记-清除算法执行过程如图:


image.png

2.复制(Copying)算法:

复制算法是为了解决效率问题而出现的,它将可用的内存分为两块,每次只用其中一块,当这一块内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已经使用过的内存空间一次性清理掉。这样每次只需要对整个半区进行内存回收,内存分配时也不需要考虑内存碎片等复杂情况,只需要移动指针,按照顺序分配即可。

不足:内存缩小为了原来的一半。

复制算法执行过程如图:


image.png

现在的商用虚拟机都采用这种算法来回收新生代,但1:1的比例非常不科学,因此新生代的内存被划分为一块较大的Eden空间和两块较小的Survivor空间,每次使用Eden和其中一块Survivor。每次回收时,将Eden和Survivor中还存活着的对象一次性复制到另外一块Survivor空间上,最后清理掉Eden和刚才用过的Survivor空间。HotSpot虚拟机默认Eden区和Survivor区的比例为8:1,每次新生代中可用内存空间为整个新生代容量的90%。当Survivor空间不够用时,需要依赖老年代进行分配担保(Handle Promotion)。

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

复制算法在对象存活率较高的场景下要进行大量的复制操作,效率很低。万一对象100%存活,那么需要有额外的空间进行分配担保。老年代都是不易被回收的对象,对象存活率高,因此一般不能直接选用复制算法。根据老年代的特点,有人提出了另外一种标记-整理算法,过程与标记-清除算法一样,不过不是直接对可回收对象进行清理,而是让所有存活对象都向一端移动,然后直接清理掉边界以外的内存。

标志-整理算法执行图:


image.png

4.分代收集算法:

分代收集算法执行图:


image.png

小结:

根据对象的生命周期的不同将内存划分为几块,然后根据各块的特点采用最适当的收集算法。大批对象死去、少量对象存活的(新生代),使用复制算法,复制成本低;对象存活率高、没有额外空间进行分配担保的(老年代),采用标记-清理算法或者标记-整理算法。

常见垃圾收集器:

1.Serial收集器:

最基本、发展历史最久的收集器,这个收集器是一个采用复制算法的单线程的收集器,单线程一方面意味着它只会使用一个CPU或一条线程去完成垃圾收集工作,另一方面也意味着它进行垃圾收集时必须暂停其他线程的所有工作,直到它收集结束为止。后者意味着,在用户不可见的情况下要把用户正常工作的线程全部停掉,这对很多应用是难以接受的。

不过实际上到目前为止,Serial收集器依然是虚拟机运行在Client模式下的默认新生代收集器,因为它简单而高效。用户桌面应用场景中,分配给虚拟机管理的内存一般来说不会很大,收集几十兆甚至一两百兆的新生代停顿时间在几十毫秒最多一百毫秒,只要不是频繁发生,这点停顿是完全可以接受的。

Serial收集器运行过程如下图所示:


image.png

说明:
1.需要STW(Stop The World),停顿时间长。
2.简单高效,对于单个CPU环境而言,Serial收集器由于没有线程交互开销,可以获取最高的单线程收集效率。

2.ParNew收集器:

ParNew收集器其实就是Serial收集器的多线程版本,除了使用多条线程进行垃圾收集外,其余行为和Serial收集器完全一样,包括使用的也是复制算法。ParNew收集器除了多线程以外和Serial收集器并没有太多创新的地方,但是它却是Server模式下的虚拟机首选的新生代收集器,其中有一个很重要的和性能无关的原因是,除了Serial收集器外,目前只有它能与CMS收集器配合工作。

ParNew收集器在单CPU的环境中绝对不会有比Serial收集器更好的效果,甚至由于线程交互的开销,该收集器在两个CPU的环境中都不能百分之百保证可以超越Serial收集器。随着可用CPU数量的增加,它对于GC时系统资源的有效利用还是很有好处的。它默认开启的收集线程数与CPU数量相同,在CPU数量非常多的情况下,可以使用-XX:ParallelGCThreads参数来限制垃圾收集的线程数。

ParNew收集器运行过程如下图所示:


image.png

3.Parallel Scavenge收集器:

Parallel Scavenge收集器也是一个新生代收集器,也是用复制算法的收集器,也是并行的多线程收集器。CMS等收集器的关注点是尽可能缩短垃圾收集时用户线程的停顿时间,而Parallel Scavenge收集器的目标则是达到一个可控制的吞吐量

吞吐量:CPU用于运行用户代码时间与CPU总消耗时间的比值,即吞吐量=运行用户代码时间/(运行用户代码时间+垃圾收集时间),虚拟机总运行100分钟,垃圾收集1分钟,那吞吐量就是99%。

Parallel Scavenge收集器是虚拟机运行在Server模式下的默认垃圾收集器

4.Serial Old收集器:

Serial收集器的老年代版本,同样是一个单线程收集器,使用“标记-整理算法”,这个收集器的主要意义也是在于给Client模式下的虚拟机使用。

5.Parallel Old收集器:

Parallel Scavenge收集器的老年代版本,使用多线程和“标记-整理”算法。在注重吞吐量以及CPU资源敏感的场合,都可以优先考虑Parallel Scavenge收集器+Parallel Old收集器的组合。

运行过程如下图所示:


image.png

6.CMS收集器:

CMS(Conrrurent Mark Sweep)收集器是以获取最短回收停顿时间为目标的收集器。使用标记 - 清除算法,收集过程分为如下四步:

1.初始标记,标记GCRoots能直接关联到的对象,时间很短。
2.并发标记,进行GCRoots Tracing(可达性分析)过程,时间很长。
3.重新标记,修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,时间较长。
4.并发清除,回收内存空间,时间很长。
其中,并发标记与并发清除两个阶段耗时最长,但是可以与用户线程并发执行。

运行过程如下图所示:


image.png

说明:
1.对CPU资源非常敏感,可能会导致应用程序变慢,吞吐率下降。

2.无法处理浮动垃圾,因为在并发清理阶段用户线程还在运行,自然就会产生新的垃圾,而在此次收集中无法收集他们,只能留到下次收集,这部分垃圾为浮动垃圾,同时,由于用户线程并发执行,所以需要预留一部分老年代空间提供并发收集时程序运行使用。

3.由于采用的标记 - 清除算法,会产生大量的内存碎片,不利于大对象的分配,可能会提前触发一次Full GC。虚拟机提供了-XX:+UseCMSCompactAtFullCollection参数来进行碎片的合并整理过程,这样会使得停顿时间变长,虚拟机还提供了一个参数配置,-XX:+CMSFullGCsBeforeCompaction,用于设置执行多少次不压缩的Full GC后,接着来一次带压缩的GC。

7.G1收集器:

与其他GC收集器相比,G1收集器有以下特点:
1.并行和并发。使用多个CPU来缩短Stop The World停顿时间,与用户线程并发执行。
2.分代收集。独立管理整个堆,但是能够采用不同的方式去处理新创建对象和已经存活了一段时间、熬过多次GC的旧对象,以获取更好的收集效果。
3.空间整合。基于标记 - 整理算法,无内存碎片产生。
4.可预测的停顿。能简历可预测的停顿时间模型,能让使用者明确指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间不得超过N毫秒。

在G1之前的垃圾收集器,收集的范围都是整个新生代或者老年代,而G1不再是这样。使用G1收集器时,Java堆的内存布局与其他收集器有很大差别,它将整个Java堆划分为多个大小相等的独立区域(Region),虽然还保留有新生代和老年代的概念,但新生代和老年代不再是物理隔离的了,它们都是一部分(可以不连续)Region的集合。

理解GC日志:

[GC [DefNew: 310K->194K(2368K), 0.0269163 secs] 310K->194K(7680K), 0.0269513 secs] [Times: user=0.00 sys=0.00, real=0.03 secs] 
[GC [DefNew: 2242K->0K(2368K), 0.0018814 secs] 2242K->2241K(7680K), 0.0019172 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[Full GC (System) [Tenured: 2241K->193K(5312K), 0.0056517 secs] 4289K->193K(7680K), [Perm : 2950K->2950K(21248K)], 0.0057094 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
Heap
def new generation   total 2432K, used 43K [0x00000000052a0000, 0x0000000005540000, 0x0000000006ea0000)
eden space 2176K,   2% used [0x00000000052a0000, 0x00000000052aaeb8, 0x00000000054c0000)
from space 256K,   0% used [0x00000000054c0000, 0x00000000054c0000, 0x0000000005500000)
to   space 256K,   0% used [0x0000000005500000, 0x0000000005500000, 0x0000000005540000)
tenured generation   total 5312K, used 193K [0x0000000006ea0000, 0x00000000073d0000, 0x000000000a6a0000)
the space 5312K,   3% used [0x0000000006ea0000, 0x0000000006ed0730, 0x0000000006ed0800, 0x00000000073d0000)
compacting perm gen  total 21248K, used 2982K [0x000000000a6a0000, 0x000000000bb60000, 0x000000000faa0000)
the space 21248K,  14% used [0x000000000a6a0000, 0x000000000a989980, 0x000000000a989a00, 0x000000000bb60000)
No shared spaces configured.

说明:

1.日志的开头“GC”、“Full GC”表示这次垃圾收集的停顿类型,而不是用来区分新生代GC还是老年代GC的。如果有Full,则说明本次GC停止了其他所有工作线程(Stop-The-World)。看到Full GC的写法是“Full GC(System)”,这说明是调用System.gc()方法所触发的GC。

2.“GC”中接下来的“[DefNew”表示GC发生的区域,这里显示的区域名称与使用的GC收集器是密切相关的,例如上面样例所使用的Serial收集器中的新生代名为“Default New Generation”,所以显示的是“[DefNew”。如果是ParNew收集器,新生代名称就会变为“[ParNew”,意为“Parallel New Generation”。如果采用Parallel Scavenge收集器,那它配套的新生代称为“PSYoungGen”,老年代和永久代同理,名称也是由收集器决定的。

3.后面方括号内部的“310K->194K(2368K)”,指的是该区域已使用的容量->GC后该内存区域已使用的容量(该内存区总容量)。方括号外面的“310K->194K(7680K)”,则指的是GC前Java堆已使用的容量->GC后Java堆已使用的容量(Java堆总容量)

4.再往后“0.0269163 secs”表示该内存区域GC所占用的时间,单位是秒。最后的“[Times: user=0.00 sys=0.00 real=0.03 secs]”则更具体了,user表示用户态消耗的CPU时间、内核态消耗的CPU时间、操作从开始到结束经过的墙钟时间。后面两个的区别是,墙钟时间包括各种非运算的等待消耗,比如等待磁盘I/O、等待线程阻塞,而CPU时间不包括这些耗时,但当系统有多CPU或者多核的话,多线程操作会叠加这些CPU时间,所以如果看到user或sys时间超过real时间是完全正常的。

5.“Heap”后面就列举出堆内存目前各个年代的区域的内存情况。


上一篇:Java基础(4)—异常与内部类
下一篇:Java基础(6)—Java虚拟机 JVM

精彩内容不够看?更多精彩内容,请到微信搜索 “危君子频道” 订阅号,每天更新,欢迎大家关注订阅!

image

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 213,099评论 6 492
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,828评论 3 387
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 158,540评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,848评论 1 285
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,971评论 6 385
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,132评论 1 291
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,193评论 3 412
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,934评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,376评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,687评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,846评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,537评论 4 335
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,175评论 3 317
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,887评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,134评论 1 267
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,674评论 2 362
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,741评论 2 351