zoukankan      html  css  js  c++  java
  • GC垃圾回收

    JVM的GC机制
        内存作为系统中重要的资源,对系统的稳定运行和高效运行起到了关键作用。虽然Java和C之类的语言不同,不需要开发人员来分配内存和回收内存,而是由JVM来管理对象内存的分配以及回收(又称垃圾回收,GC),对开发人员来说确实大大降低了编写程序的难度,但是它也带来了一个副作用。就是当系统运行过程中出现JVM抛出内存异常(例如OutOfMemoryError)的时候,很难知道原因是什么。另外要编写高性能的程序,我们是必须要借助内存来提升性能的,所以如何合理的使用内存以及让JVM合理的进行内存回收也是我们必须掌握的知识。在本文中,我们就从JVM的内存结构,JVM的GC机制以及GC的常用算法这三个方面来详细的了解一下JVM的垃圾收集机制。
    
    1.JVM的内存结构
    
    JVM内存结构模型
    
    1.1 JVM运行
    JVM的运行过程:CLASS LOADER  RUNTIME DATA AREA  EXECUTION ENGINE  NATIVE INTERFAXE  NATIVE LIBRARIES.
    
    CLASS LOADER:类加载器。负责加载类文件到内存。比如编写好了一个HelloWorld.java程序,通过javac编译成class文件,之后Class Loader负责将这个Class文件加载到内存中。
    RUNNTIME DATA AREA:运行数据区。这是整个JVM的重点,后面具体介绍。
    EXECUTION ENGINE:执行引擎。也叫做解释器(Interpreter),负责解释命令,提交操作系统执行。
    NATIVE INTERFAXE:本地接口。负责融合不同的编程语言为Java所用。内存中专门有一块区域标记为native的代码,通过在Native Method Stack中等级native方法,在Executive Engine执行是加载native libraries。
    NATIVE LIBRARIES:本地方法库。
    
    1.2 RUNTIME DATA AREA (运行数据区)
        运行数据区,也就是内存区域,这是整个JVM的重点。我们所有写的程序都被加载到这里,之后才开始运行。
    区域        存储那类数据
    Method Area        线程共享的内存区域
    非堆主要区域
    存储类信息、常量、静态变量、即时编译器
    Java Stack        线程私有,存储局部变量表,操作栈,动态链接,方法出口
    Native Method Stack        为虚拟机使用到的Native方法服务
    Heap        线程共享
    所有的对象实例以及数组都要在堆上分配
    回收器主要管理的对象
    Program Counter Register        线程私有、指向下一条要执行的指令
    下面对各个区域进行更加详细的描述。
    1.2.1  Program Counter Register(程序计数器)
    程序计数器是一块较小的内存空间,作用是:当前线程所执行的字节码的行号指示器。在虚拟机的概念模型里,字节码解释器工作时是通过改变这个计数器的值来选取下一条需要执行的字节码指令。分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。它是线程私有的内存。
    
    1.2.2  Method Area(方法区)
        方法区,用于存储虚拟机加载的类信息、常量、静态变量、是各个线程共享的内存区域。JVM规范把方法区描述为堆的一个逻辑部分,但是它又与堆不同,所以为了与堆区分开来,它有一个别名叫“非堆”。
    运行时常量池:是方法区的一部分,Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池,用于存放编译器生成的各种符号引用,这部分内容将在类加载后放到方法区的运行时常量池中。
    
    1.2.3  Java Stack(Java虚拟机栈)
    虚拟机栈描述的是Java方法执行的内存模型:每个方法被执行的时候都会同时常见一个栈帧用于存储局部变量表、操作栈、动态链接、方法出口等信息。每一个方法被调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。生命周期与线程相同,是线程私有的。
    局部变量表存储了各种基本的数据类型和对象引用类型。
    
    1.2.4  Native Method Stack(本地方法栈)
    与虚拟机栈作用相似,其区别是:虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的Native 方法服务。
    
    1.2.5  Heap(堆)
    堆是Java虚拟机所管理的内存中最大的一块内存区域,也是被各个线程共享的内存区域,在JVM启动时创建。该内存区存储对象实例及数组(所有new的对象)。
    堆是垃圾收集器管理的主要区域,因此很多时候被称为“GC堆”。如果从内存回收的角度看,由于现在收集器基本都是采用的分代收集算法,所以Java 堆中还可以细分为:新生代、老年代和持久代;再细致一点的有Eden 空间、From Survivor 空间、To Survivor 空间等。如果从内存分配的角度看,线程共享的Java 堆中可能划分出多个线程私有的分配缓冲区(Thread LocalAllocation Buffer,TLAB)。不过,无论如何划分,都与存放内容无关,无论哪个区域,存储的都仍然是对象实例,进一步划分的目的是为了更好地回收内存,或者更快地分配内存。
    
    2. JVM的GC机制
    垃圾收集(Garbage Collected)提供了内存管理的机制,使得应用程序不需要再关注内存如何释放,内存用完后,垃圾收集会进行收集,这样就减轻了因为人为的管理内存而造成的错误,比如在C++中,出现内存泄露是很常见的。
    常见的GC策略
    所有的垃圾收集算法都面临同一个问题,那就是找出应用程序中不可到达的内存块,将其释放。这里的不可到达主要是指:应用程序已经没有内存块的引用了。在Java中,某个对象对应用程序是可达的是指:这个对象被根(根主要是指类的静态变量,或者活跃在所有线程栈的对象的引用)引用或者对象被另一个可到达的对象引用。
    垃圾收集首先需要确定从根开始哪些是可达的和哪些是不可达的,从根可达的对象都是活动对象,他们不能作为垃圾被回收,这也包括从根间接可达的对象。而根通过任意路径不可达的对象符合垃圾收集的条件,应该被回收。
    3. GC的常用算法
    下面是几种常见的GC算法。
    
    3.1 引用计数收集器(Reference Counting)
    引用计数是垃圾回收的早期一种策略,是最简单直接的一种方式。
    其思想是:
    堆中每一个对象都有一个引用计数。一个对象被创建了,并且指向该对象的引用被分配给一个变量,这个对象的引用计数被置为1。当任何其他变量被赋值为对这个对象的引用时,计数加1。当一个对象的引用超过了生存期或者被设置一个新的值时,对象的引用计数减1。任何引用计数为0的对象可以被当作垃圾收集。当一个对象被垃圾收集的时候,它引用的任何对象计数值减1。在这种方法中,一个对象被垃圾收集后可能导致后续其他对象的垃圾收集行动。
    优点:
    简单,直接,不需要暂停整个应用,引用计数收集器可以交织在程序的运行之中很快地执行。这种特性对于程序不能被长时间打断的实时环境很有利。
    缺点:
    1)引用计数无法检测出循环,即两个或者更多的对象互相引用。如父对象有一个对子对象的引用,子对象又反过来引用父对象,这些对象永远都不可能计数为0,就算他们已经无法被执行程序的根对象可触及。
    2)需要编译器的配合,编译器要生成特殊的指令来进行引用计数的操作。比如每次将对象赋值给新的引用,或者对象的引用超出了作用域等。
    
    3.2 标记-清除收集器(Mark-Sweep)或追踪回收器(Tracing Collector)
    这种算法是为了解决引用计数法的问题而提出的,它使用了根集的概念。
    其思想是:
    从根集开始扫描,识别出哪些对象可达,哪些对象不可达,并用某种方式标记可达对象,例如在对象本身设置标记,或者是用一个独立的位图来设置标记。当扫描结束时,未被标记的对象就是无法触及的,从而可以被回收。垃圾回收过程分两个阶段:标记阶段:垃圾收集器遍历引用树,标记每一个遇到的对象;在清除阶段,未被标记的对象被释放了,使用的内存被返回到正在执行的程序中。
    优点:
    1)解决循环引用的问题。
    2)不需要编译器的配合,从而就不执行额外的指令。
    缺点:
    1)每个活跃的对象都要进行扫描,收集暂停时间比较长。
    2)这种收集器一般使用单线程工作并停止其他操作。
    
    3.3 基于标记-清除的压缩收集器(Compacting Collector)
    这种算法是为了解决堆碎片的问题,基于Tracing的垃圾回收吸收了Compacting算法的思想。
    其思想是:
    标记阶段同标记-清除收集器。在清除阶段,将所有的对象移到堆的一端,堆的另一端就变成了一个相邻的空闲内存区,收集器会对它移动的所有对象的所有引用进行更新,使得这些应用在新的位置能识别原来的对象。
    更新被移动的对象的引用有时候通过一个间接对象引用层可以变得更简单。不直接引用堆中的对象,对象的引用实际上指向一个对象句柄表。对象句柄才指向堆中对象的实际位置。当对象被移动了,只有这个句柄需要被更新为新位置。所有的程序中对这个对象的引用仍然指向这个具有新值的句柄,而句柄本身没有移动。
    优点:极大的减少了内存碎片。
    缺点:工作期间暂停其他操作,每一次对象访问都带来了性能损失。
    
    3.4 拷贝收集器(Coping Collector)
    典型的一种拷贝收集器算法是stop-and-copy算法,该算法的提出是为了克服句柄的开销和解决堆碎片的问题。
    其思想是:堆被分为两个区域:对象面和空闲面,任何时候都只能使用其中的一个区域。程序从对象面为对象分配空间,当对象满了,就从根集中扫描活动对象,并将每个活动对象复制到空闲面(使得活动对象所占的内存之间没有空闲洞),这样空闲面变成了对象面,原来的对象面变成了空闲面,程序会在新的对象面中分配内存。
    优点:克服了句柄的开销,并且解决了堆碎片的问题。
    缺点:对于指定大小的堆来说,任何时候都只能使用其中的一半,并且工作期间暂停其他操作,程序等待时间较长。
    
    3.5 按代收集器(Generational Collector)
    stop-and-copy算法的一个缺陷是收集器必须复制所有的活动对象,这增加了程序等待时间,这是coping算法低效的原因。而程序设计中有这样的规律:多数对象存在的时间比较短,少数的存在时间比较长。因此我们提出了按代收集器。
    3.5.1 按代收集器的思想
    基于不同对象的生命周期是不一样的这样一个事实,按代收集的收集器通过把对象按照寿命来分组解决这个效率低下的问题,更多地收集那些短暂出现的年幼对象,而非寿命较长的对象。在这种方法里,堆被划分成两个或者更多的子堆,每一个子堆为一“代”对象服务。最年幼的那一代进行最频繁的垃圾收集。因为大多数对象都是短促出现的,只有很小部分的年幼对象可以在它们经历第一次收集后还存活。如果一个最年幼的对象经历了好几次垃圾收集后仍然存活,那么这个对象就成长为寿命更高的一代:它被转移到另外一个子堆中去。年龄更高的每一代的收集都没有年径的那一代来得频繁。每当对象在它所属的年龄层(代)中变得成熟(逃过了多次垃圾收集)之后,它们就被转移到更高的年龄层中去。 
    
    3.5.2 如何分代
    在JVM内存结构的时候,我们也介绍过堆分为三个代:年轻代(Young Generation)、年老点(Old Generation)和持久代(Permanent Generation) 。
    年轻代:
    所有新生成的对象首先都是放在年轻代的。年轻代的目标就是尽可能快速的收集掉那些生命周期短的对象。年轻代分三个区。一个Eden区,两个 Survivor区(一般而言)。大部分对象在Eden区中生成。当Eden区满时,还存活的对象将被复制到Survivor区(两个中的一个),当这个 Survivor区满时,此区的存活对象将被复制到另外一个Survivor区,当这个Survivor去也满了的时候,从第一个Survivor区复制 过来的并且此时还存活的对象,将被复制“年老区(Tenured)”。需要注意,Survivor的两个区是对称的,没先后关系,所以同一个区中可能同时 存在从Eden复制过来 对象,和从前一个Survivor复制过来的对象,而复制到年老区的只有从第一个Survivor去过来的对象。而且,Survivor区总有一个是空 的。同时,根据程序需要,Survivor区是可以配置为多个的(多于两个),这样可以增加对象在年轻代中的存在时间,减少被放到年老代的可能。
    年老代:
    在年轻代中经历了N次垃圾回收后仍然存活的对象,就会被放到年老代中。因此,可以认为年老代中存放的都是一些生命周期较长的对象。
    持久代:
    用于存放静态文件,如今Java类、方法等。持久代对垃圾回收没有显著影响,但是有些应用可能动态生成或者调用一些class,例如Hibernate 等,在这种时候需要设置一个比较大的持久代空间来存放这些运行过程中新增的类。
    3.5.3 什么情况下触发垃圾回收
    由于对象进行了分代处理,因此垃圾回收区域、时间也不一样。GC有两种类型:Scavenge GC 和Full GC 。
    Scavenge GC
          一般情况下,当新对象生成,并且在Eden申请空间失败时,就会触发Scavenge GC,对Eden区域进行GC,清除非存活对象,并且把尚且存活的对象移动到Survivor区。然后整理Survivor的两个区。这种方式的GC是对 年轻代的Eden区进行,不会影响到年老代。因为大部分对象都是从Eden区开始的,同时Eden区不会分配的很大,所以Eden区的GC会频繁进行。因 而,一般在这里需要使用速度快、效率高的算法,使Eden去能尽快空闲出来。
    Full GC
          对整个堆进行整理,包括Young、Tenured和Perm。Full GC因为需要对整个对进行回收,所以比Scavenge GC要慢,因此应该尽可能减少Full GC的次数。在对JVM调优的过程中,很大一部分工作就是对于FullGC的调节。有如下原因可能导致Full GC:
          年老代(Tenured)被写满
          持久代(Perm)被写满
          System.gc()被显示调用
         上一次GC之后Heap的各域分配策略动态变化
    
    总结:
          引用计数收集器 基本上已经不被使用了。 标记-清除收集器 是目前寻找垃圾对象的主流策略,但是它对回收后产生的内存碎片却无能为力,因此我们必须采用一些合并空闲内存的算法,这就产生了基于标记-清除的 压缩收集器 和 拷贝收集器 。而基于性能原因,拷贝收集器是比较受欢迎的。但是它有一个比较大的弱点:对于寿命较长的对象会不停的拷贝,这就付出了不必要的代价。因此, 按代收集器 的出现 改进了这一性能。而我们的JDK中的JVM就采用了按代收集策略。   
    
    有关垃圾回收的几个补充问题:
    1.垃圾回收的起点在哪里?
    除引用计数收集器之外,垃圾回收的起点都是一些根对象(Java栈,静态变量,寄存器…)。而最简单的Java栈就是Java程序执行的main函数。
    2.如何解决同时存在的对象创建和对象回收问题?
        垃圾回收线程是回收内存的,而程序运行线程则是消耗(或分配)内存的,一个回收内存,一个分配内存 ,从这点看,两者是矛盾的。因此,在现有的垃圾回收方式中,要进行垃圾回收前,一般都需要暂停整个应用(即:暂停内存的分配),然后进行垃圾回收,回收完成后再继续应用。这种实现方式是最直接,而且最有效的解决二者矛盾的方式。
          但是这种方式有一个很明显的弊端,就是当堆空间持续增大时,垃圾回收的时间也将会相应的持续增大,对应应用暂停的时间也会相应的增大 。 一些对相应时间要求很高的应用,比如最大暂停时间要求是几百毫秒,那么当堆空间大于几个G时,就很有可能超过这个限制,在这种情况下,垃圾回收将会成为系统运行的一个瓶颈。为解决这种矛盾,有了并发垃圾回收算法 ,使用这种算法,垃圾回收线程与程序运行线程同时运行。在这种方式下,解决了暂停的问题,但是因为需要在新生成对象的同时又要回收对象,算法复杂性会大大增加,系统的处理能力也会相应降低,同时,“碎片”问题将会比较难解决。
    
    
    4 新一代的垃圾回收算法
    4.1 垃圾回收的瓶颈
    传统分代垃圾回收方式,已经在一定程度上把垃圾回收给应用带来的负担降到了最小,把应用的吞吐量推到了一个极限。但是他无法解决的一个问题,就是Full GC所带来的应用暂停。在一些对实时性要求很高的应用场景下,GC暂停所带来的请求堆积和请求失败是无法接受的。这类应用可能要求请求的返回时间在几百甚至几十毫秒以内,如果分代垃圾回收方式要达到这个指标,只能把最大堆的设置限制在一个相对较小范围内,但是这样有限制了应用本身的处理能力,同样也是不可接收的。
    分代垃圾回收方式确实也考虑了实时性要求而提供了并发回收器,支持最大暂停时间的设置,但是受限于分代垃圾回收的内存划分模型,其效果也不是很理想。
    为了达到实时性的要求(其实Java语言最初的设计也是在嵌入式系统上的),一种新垃圾回收方式呼之欲出,它既支持短的暂停时间,又支持大的内存空间分配。可以很好的解决传统分代方式带来的问题。
    4.2 增量收集的演进
    增量收集的方式在理论上可以解决传统分代方式带来的问题。增量收集把对堆空间划分成一系列内存块,使用时,先使用其中一部分(不会全部用完),垃圾收集时 把之前用掉的部分中的存活对象再放到后面没有用的空间中,这样可以实现一直边使用边收集的效果,避免了传统分代方式整个使用完了再暂停的回收的情况。
    当然,传统分代收集方式也提供了并发收集,但是他有一个很致命的地方,就是把整个堆做为一个内存块,这样一方面会造成碎片(无法压缩),另一方面他的每次 收集都是对整个堆的收集,无法进行选择,在暂停时间的控制上还是很弱。而增量方式,通过内存空间的分块,恰恰可以解决上面问题。
    4.3 Garbage First(G1)
    1)目标
    支持很大的堆
    高吞吐量
          --支持多CPU和垃圾回收线程
          --在主线程暂停的情况下,使用并行收集
          --在主线程运行的情况下,使用并发收集
    实时目标: 可配置在N毫秒内最多只占用M毫秒的时间进行垃圾回收
    
    2)算法详解
    
    G1可谓博采众家之长,力求到达一种完美。他吸取了增量收集优点,把整个堆划分为一个一个等大小的区域(region)。内存的回收和划分都以 region为单位;同时,他也吸取了CMS的特点,把这个垃圾回收过程分为几个阶段,分散一个垃圾回收过程;而且,G1也认同分代垃圾回收的思想,认为 不同对象的生命周期不同,可以采取不同收集方式,因此,它也支持分代的垃圾回收。为了达到对回收时间的可预计性,G1在扫描了region以后,对其中的 活跃对象的大小进行排序,首先会收集那些活跃对象小的region,以便快速回收空间(要复制的活跃对象少了),因为活跃对象小,里面可以认为多数都是垃 圾,所以这种方式被称为Garbage First(G1)的垃圾回收算法,即:垃圾优先的回收。
    3)回收步骤:
        初始标记(Initial Marking)
         G1对于每个region都保存了两个标识用的bitmap,一个为previous marking bitmap,一个为next marking bitmap,bitmap中包含了一个bit的地址信息来指向对象的起始点。
    开始Initial Marking之前,首先并发的清空next marking bitmap,然后停止所有应用线程,并扫描标识出每个region中root可直接访问到的对象,将region中top的值放入next top at mark start(TAMS)中,之后恢复所有应用线程。
    触发这个步骤执行的条件为:
    G1定义了一个JVM Heap大小的百分比的阀值,称为h,另外还有一个H,H的值为(1-h)*Heap Size,目前这个h的值是固定的,后续G1也许会将其改为动态的,根据jvm的运行情况来动态的调整,在分代方式下,G1还定义了一个u以及soft limit,soft limit的值为H-u*Heap Size,当Heap中使用的内存超过了soft limit值时,就会在一次clean up执行完毕后在应用允许的GC暂停时间范围内尽快的执行此步骤;
    在pure方式下,G1将marking与clean up组成一个环,以便clean up能充分的使用marking的信息,当clean up开始回收时,首先回收能够带来最多内存空间的regions,当经过多次的clean up,回收到没多少空间的regions时,G1重新初始化一个新的marking与clean up构成的环。
    并发标记(Concurrent Marking)
    按照之前Initial Marking扫描到的对象进行遍历,以识别这些对象的下层对象的活跃状态,对于在此期间应用线程并发修改的对象的以来关系则记录到remembered set logs中,新创建的对象则放入比top值更高的地址区间中,这些新创建的对象默认状态即为活跃的,同时修改top值。
    最终标记暂停(Final Marking Pause)
    当应用线程的remembered set logs未满时,是不会放入filled RS buffers中的,在这样的情况下,这些remebered set logs中记录的card的修改就会被更新了,因此需要这一步,这一步要做的就是把应用线程中存在的remembered set logs的内容进行处理,并相应的修改remembered sets,这一步需要暂停应用,并行的运行。
    存活对象计算及清除(Live Data Counting and Cleanup)
    值得注意的是,在G1中,并不是说Final Marking Pause执行完了,就肯定执行Cleanup这步的,由于这步需要暂停应用,G1为了能够达到准实时的要求,需要根据用户指定的最大的GC造成的暂停时间来合理的规划什么时候执行Cleanup,另外还有几种情况也是会触发这个步骤的执行的:
    G1采用的是复制方法来进行收集,必须保证每次的”to space”的空间都是够的,因此G1采取的策略是当已经使用的内存空间达到了H时,就执行Cleanup这个步骤;
    对于full-young和partially-young的分代模式的G1而言,则还有情况会触发Cleanup的执行,full-young模式下,G1根据应用可接受的暂停时间、回收young regions需要消耗的时间来估算出一个yound regions的数量值,当JVM中分配对象的young regions的数量达到此值时,Cleanup就会执行;partially-young模式下,则会尽量频繁的在应用可接受的暂停时间范围内执行Cleanup,并最大限度的去执行non-young regions的Cleanup。
        G1为了能够尽量的做到准实时的响应,例如估算暂停时间的算法、对于经常被引用的对象的特殊处理等,G1为了能够让GC既能够充分的回收内存,又能够尽量少的导致应用的暂停,可谓费尽心思,从G1的论文中的性能评测来看效果也是不错的,不过如果G1能允许开发人员在编写代码时指定哪些对象是不用mark的就更完美了,这对于有巨大缓存的应用而言,会有很大的帮助。
    

  • 相关阅读:
    第四章 基础命令的学习
    7-7命令总结
    第三章 虚拟机的简单使用及其xshell远程工具的使用
    第二章 虚拟机的软件及其安装
    在VMware中安装CentOS系统步骤
    Pytest02-用法和调用
    Pytest01-从官方文档开始学习
    浅谈mock技术
    C/C++ malloc、calloc、realloc和alloca
    Go 知识汇总
  • 原文地址:https://www.cnblogs.com/dengshiwei/p/4258446.html
Copyright © 2011-2022 走看看