浅谈Java内存及GC

2/22/2017来源:ASP.NET技巧人气:1945

学习java的同学注意了!!!  学习过程中遇到什么问题或者想获取学习资源的话,欢迎加入Java学习交流群,群号码:456544752  我们一起学Java!

一、JAVA虚拟机规范与JAVA虚拟机

  内存,是指程序运行时的数据存储区域。

  Java虚拟机规范中,将内存划分为六大部分,分别是Java堆、方法区、运行时常量池、Java虚拟机栈、本地方法栈、PC寄存器。

  Java虚拟机规范是一种对Java虚拟机实现的规范要求,是由Oracle制定的,而我们平时常说的Java虚拟机一般是指最经常使用的Java虚拟机hotspot。

  JVM是Java Virtual Machine(Java虚拟机)的缩写。

二、JVM结构图

  

  1、程序计数器(PC寄存器,线程独有):如果线程正在执行java方法,程序计数器记录的是正在执行的虚拟机字节码的指令地址,如果正在执行的是native本地方法,则计数器会是一个空地址。分支、循环、跳转、异常处理、线程恢复等基础功能都需要这个计数器来完成。

  2、Java虚拟机栈(线程独有):Java虚拟机栈是在创建线程的同时创建的,每个方法在执行时都会创建一个栈帧用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。

  3、本地方法栈(线程独有):本地方法栈与虚拟机栈所发挥的作用十分相似,区别只是虚拟机栈为执行java方法服务,而本地方法栈为执行本地方法服务。

  4、Java堆(全局共享):它随着java虚拟机的启动创建,储存着所有对象实例以及数组对象,而且内置了垃圾搜集器(GC),java堆中的内存释放是不受开发人员控制的。由于现在垃圾收集器基本都采用分代收集算法,所以java堆也可以细分为新生代和老年代。

  5、方法区(全局共享):java虚拟机规范把方法区描述为堆的一个逻辑部分,用于存储已被虚拟机加载的类信息、常亮、静态变量、即时编译器编译后的代码等数据。对于习惯使用HotSpot虚拟机的开发者,很多人更愿意把方法区称为"永久代"。java虚拟机规范不强制要求实现GC。

  运行时常量池是方法区的一部分。Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池,用于存放编译器生成的各种字面量和符号引用。

  内存管理分为内存分配和内存释放,通常情况下,堆内存分配是要依赖于GC的策略与实现的,在分配的时候,就要考虑好到时候如何回收这部分内存。也是正因为如此,对于内存分配这一部分的讲解来说,我们必须得先了解内存是如何被回收的,才能更好的理解内存要怎么被分配。

三、GC策略与原理

  1、GC策略解决了哪些问题?

    >哪些对象可以被回收。

        >何时回收这些对象。

        >采用什么样的方式回收。

  2、GC策略采用的何种算法?

  (1)引用计数算法:

    给对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加1,当引用失效时,计数器就减1,任何时刻计数器为0的对象就是要被回收的。但是这个算法有一个致命的缺陷,那就是对于循环引用的对象无法进行回收。

复制代码
public class Object {

    Object field = null;
    
    public static void main(String[] args) {
        Thread thread = new Thread(new Runnable() {
            public void run() {
                Object objectA = new Object();
                Object objectB = new Object();//1
                objectA.field = objectB;
                objectB.field = objectA;//2
                //to do something
                objectA = null;
                objectB = null;//3
            }
        });
        thread.start();
        while (true);
    }
}
复制代码

分析:如果我们的GC采用上面所说的引用计数算法,则这两个对象永远不会被回收,即便我们在使用后显示的将对象归为空值也毫无作用。

         这里大致解释一下,在代码中标注了1、2、3三个数字,当第1个地方的语句执行完以后,两个对象的引用计数全部为1。当第2个地方的语句执行完以后,两个对象的引用计数就全部变成了2。当第3个地方的语句执行完以后,也就是将二者全部归为空值以后,二者的引用计数仍然为1。根据引用计数算法的回收规则,引用计数没有归0的时候是不会被回收的。

  (2)根搜索算法:

    设立若干"GC Roots"根对象,当任何一个根对象到某一个对象均不可达时,则认为这个对象是可以被回收的。

      

  分析:object1、object2、object3、object4都是可从根节点到达的,而object5、object6、object7是根节点不可达的,所以这3个对象是可回收的。

  在java语言中,可作为GC Roots的对象包括下面几种:

  1、虚拟机栈(栈帧中的本地变量表)中引用的对象。

  2、方法区中类静态属性引用的对象。

  3、方法区中常量引用的对象。

  4、本地方法栈中JNI(即一般说的本地方法)引用的对象。

  第一和第四种都是指的方法的本地变量表,第二种表达的意思比较清晰,第三种主要指的是声明为final的常量值。

四、垃圾收集算法

  1、标记-清除算法

  当堆中的有效内存空间(available memory)被耗尽的时候,就会停止整个程序(也被成为stop the world),然后进行两项工作,第一项则是标记,第二项则是清除。

       标记:遍历所有的GC Roots,然后将所有GC Roots可达的对象标记为存活的对象。

       清除:将遍历堆中所有的对象,将没有标记的对象全部清除。

  >基础对象状态,内存耗尽

  

  >标记

  

  >清除

  

  >唤醒停止的程序线程,让程序继续运行。

 

  分析:标记-清除算法

  >效率比较低(递归与全堆对象遍历),而且在进行GC的时候,需要停止应用程序,这会导致用户体验非常差劲,尤其对于交互式的应用程序来说简直是无法接受。

  >清理出来的空闲内存是不连续的,JVM就不得不维持一个内存的空闲列表,这又是一种开销。而且在分配数组对象的时候,寻找连续的内存空间会不太好找。

  

  2、复制算法

  复制算法将内存划分为两个区间,在任意时间点,所有动态分配的对象都只能分配在其中一个区间(称为活动区间),而另外一个区间(称为空闲区间)则是空闲的。当有效内存空间耗尽时,JVM将暂停程序运行,开启复制算法GC线程。接下来GC线程会将活动区间内的存活对象,全部复制到空闲区间,且严格按照内存地址依次排列,与此同时,GC线程将更新存活对象的内存引用地址指向新的内存地址。

  >基础对象状态,内存耗尽

  

  >GC后

  

  >唤醒停止的程序线程,让程序继续运行。

  

  分析:复制算法

  >弥补了标记/清除算法中,内存布局混乱的缺点。

  >浪费了一半的内存

  >如果对象的存活率很高,假设是100%存活,那么需要将所有对象都复制一遍,并将所有引用地址重置一遍。

   

  3、标记-整理算法

  标记:它的第一个阶段与标记-清除算法是一模一样的,均是遍历GC Roots,然后将存活的对象标记。

       整理:移动所有存活的对象,且按照内存地址次序依次排列,然后将末端内存地址以后的内存全部回收。因此,第二阶段才称为整理阶段。

  >基础对象状态,内存耗尽

  

  >标记

  

  >清除,整理

  

  >唤醒停止的程序线程,让程序继续运行。

 

  分析:标记-整理算法

  >标记-整理算法不仅可以弥补标记-清除算法当中,内存区域分散的缺点,也消除了复制算法当中,内存减半的高额代价。

  >效率不高,不仅要标记所有存活对象,还要整理所有存活对象的引用地址。从效率上来说,标记-整理算法要低于复制算法。

 

垃圾收集算法总结

  >三个算法都基于根搜索算法去判断一个对象是否应该被回收,而支撑根搜索算法可以正常工作的理论依据,就是语法中变量作用域的相关内容

  >在GC线程开启时,或者说GC过程开始时,它们都要暂停应用程序(stop the world)。

  >效率:复制算法>标记-整理算法>标记-清除算法(此处的效率只是简单的对比时间复杂度,实际情况不一定如此)。

  >内存整齐度:复制算法=标记-整理算法>标记-清除算法。

  >内存利用率:标记-整理算法=标记-清除算法>复制算法。

  >可以看到标记-清除算法是比较落后的算法了,但是后两种算法却是在此基础上建立的。

 

  4、分代收集算法(终极算法)

  分代收集算法,是目前JVM采用的算法。

  当前商业虚拟机的垃圾收集都采用"分代收集"(Generational Collection)算法,这种算法并没有什么新的思想,只是根据对象的存活周期的不同将内存划分为几块。一般是把Java堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法。在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,所以选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。 而老年代中因为对象存活率高、没有额外空间对它进行分配担保,就必须使用"标记-清理"或"标记-整理"算法来进行回收。方法区永久代,回收方法同老年代。

  复制算法:使用两块10%的内存作为空闲和活动区间,而另外80%的内存,则是用来给新建对象分配内存的。一旦发生GC,将10%的活动区间与另外80%中存活的对象转移到10%的空闲区间,接下来,将之前90%的内存全部释放,以此类推。

  

 

  新生代(java堆中):朝生夕灭的对象,如某一个方法的局域变量、循环内的临时变量等。 

  老年代(java堆中):这类对象一般活的比较久,如缓存对象、数据库连接对象、单例对象(单例模式)等。

  永久代(方法区):此类对象一般一旦出生就几乎不死了,如String池中的对象(享元模式)、加载过的类信息等。

  对于JAVA堆,JVM规范要求必须实现GC,对方法区,JVM规范并没有要求必须实现GC。 在新生代里的每一个对象,都会有一个年龄,当这些对象的年龄到达一定程度时(年龄就是熬过的GC次数,每次GC如果对象存活下来,则年龄加1),则会被转到年老代,而这个转入年老代的年龄值,一般在JVM中是可以设置的。 在新生代存活对象占用的内存超过10%时,则多余的对象会放入老年代,这种时候,年老代就是新生代的"备用仓库"。 针对老年代对象的特性,显然不再适合使用复制算法,因为它的存活率太高,而且不要忘了,如果老年代再使用复制算法,它可是没有备用仓库的。因此一般针对老不死对象只能采用标记-整理或者标记-清除算法。 

五、回收的时机

          JVM在进行GC时,并非每次都对上面三个内存区域一起回收的,大部分时候回收的都是指新生代。因此GC按照回收的区域又分了两种类型,一种是普通GC(minor GC),一种是全局GC(major GC or Full GC),它们所针对的区域如下。

         普通GC(minor GC):只针对新生代区域的GC。

         全局GC(major GC or Full GC):针对老年代的GC,偶尔伴随对新生代的GC以及对永久代的GC。

         由于年老代与永久代相对来说GC效果不好,而且二者的内存使用增长速度也慢,因此一般情况下,需要经过好几次普通GC,才会触发一次全局GC。

六、垃圾搜集器

  垃圾搜集器大致分为以下三类:

        串行搜集器(serial collector):它只有一条GC线程,且就像前面说的,它在运行的时候需要暂停用户程序(stop the world)。

        并行搜集器(parallel collector):它有多条GC线程,且它也需要暂停用户程序(stop the world)。

        并发搜集器(concurrent collector):它有一条或多条GC线程,且它需要在部分阶段暂停用户程序(stop the world),部分阶段与用户程序并发执行。

  并发就是两个任务A和B需要相互独立的运行,并且A任务先开始后,B任务在A任务结束之前开始了。并行必须在多核多处理器或者分布式系统(本质还是多核多处理器)的前提下才能发生,而交替执行或者说时间片切换是在单核的处理器上发生的。

 

  hotspot中的垃圾搜集器  

  串行搜集器的实现:serial(用于新生代,采用复制算法)、serial old(用于年老代,采用标记-整理算法)

       并行搜集器的实现:ParNew(用于新生代,采用复制算法)、Parallel Scavenge(用于新生代,采用复制算法)、Parallel old(用于年老代,采用标记-整理算法)

       并发搜集器的实现:concurrent mark sweep[CMS](用于年老代,采用标记-清除算法)

  事实上,这六种垃圾搜集器只有六种选择,因为有的垃圾搜集器由于具体实现的方式等一系列原因无法在一起工作,如下图:

  

client模式与server模式

          JVM的两种模式,一种是client模式,一种是server模式。我们平时开发使用的模式默认是client模式,也可以使用命令行参数-server强制开启server模式,两者最大的区别在于在server模式下JVM做了很多优化。server模式下的JAVA应用程序启动较慢,不过由于server模式下JVM所做的优化,在程序长时间运行下,运行速度将会越来越快。相反,client模式下的JAVA应用程序虽然启动快,但不适合长时间运行,若是运行时间较长的话,则会在性能上明显低于server模式。

垃圾搜集器详解:    

  1、Serial Garbage Collector

          算法:采用复制算法

         内存区域:针对新生代设计

         执行方式:单线程、串行

         执行过程:当新生代内存不够用时,先暂停全部用户程序,然后开启一条GC线程使用复制算法对垃圾进行回收,这一过程中可能会有一些对象提升到年老代

         特点:由于单线程运行,且整个GC阶段都要暂停用户程序,因此会造成应用程序停顿时间较长,但对于小规模的程序来说,却非常适合。

         适用场景:平时的开发与调试程序使用,以及桌面应用交互程序。

         开启参数:-XX:+UseSerialGC(client模式默认值)    

   

  2、Serial Old Garbage Collector

         这里针对serial old搜集器不再列举各个维度的特点,因为它与serial搜集器是一样的,区别是它是针对年老代而设计的,因此采用标记-整理算法。对于其余的维度特点,serial old与serial搜集器一模一样。

 

  3、ParNew Garbage Collector

          算法:采用复制算法

         内存区域:针对新生代设计

         执行方式:多线程、并行

         执行过程:当新生代内存不够用时,先暂停全部用户程序,然后开启若干条GC线程使用复制算法并行进行垃圾回收,这一过程中可能会有一些对象提升到年老代

         特点:采用多线程并行运行,因此会对系统的内核处理器数目比较敏感,至少需要多于一个的处理器,有几个处理器就会开几个线程(不过线程数是可以使用参数-XX:ParallelGCThreads=<N>控制的),因此只适合于多核多处理器的系统。尽管整个GC阶段还是要暂停用户程序,但多线程并行处理并不会造成太长的停顿时间。因此就吞吐量来说,ParNew要大于serial,在处理器越多的时候,效果越明显。但是这并非绝对,对于单个处理器来说,由于并行执行的开销(比如同步),ParNew的性能将会低于serial搜集器。不仅是单个处理器的时候,如果在容量较小的堆上,甚至在两个处理器的情况下,ParNew的性能都并非一定可以高过serial。

         适用场景:在中到大型的堆上,且系统处理器至少多于一个的情况

         开启参数:-XX:+UseParNewGC

 

  4、Parallel Scavenge Garbage Collector

         这个搜集器与ParNew几乎一模一样,都是针对新生代设计,采用复制算法的并行搜集器。它与ParNew最大的不同就是可设置的参数不一样,它可以让我们更精确的控制GC停顿时间以及吞吐量。

        parallel scavenge搜集器提供参数主要包括控制最大的停顿时间(使用-XX:MaxGCPauseMillis=<N>),以及控制吞吐量(使用-XX:GCTimeRatio=<N>)。由此可以看出,parallel scavenge就是为了提供吞吐量控制的搜集器。

        不过千万不要以为把最大停顿时间调的越小越好,或者吞吐量越大越好,在使用parallel scavenge搜集器时,主要有三个性能指标,最大停顿时间、吞吐量以及新生代区域的最小值。

        parallel scavenge搜集器具有相应的调节策略,它将会优先满足最大停顿时间的目标,次之是吞吐量,最后才是新生代区域的最小值。

        因此,如果将最大停顿时间调的过小,将会牺牲整体的吞吐量以及新生代大小来满足你的私欲。手心手背都是肉,我们最好还是不要这么干。不过parallel scavenge有一个参数可以让parallel scavenge搜集器全权接手内存区域大小的调节,这其中还包括了晋升为年老代(可使用-XX:MaxTenuringThreshold=n调节)的年龄,也就是使用-XX:UseAdaptiveSizePolicy打开内存区域大小自适应策略。

        parallel scavenge搜集器可使用参数-XX:+UseParallelGC开启,同时它也是server模式下默认的新生代搜集器。

 

  5、Parallel Old Garbage Collector

          Parallel Old与ParNew或者Parallel Scavenge的关系就好似serial与serial old一样,相互之间的区别并不大,只不过parallel old是针对年老代设计的并行搜集器而已,因此它采用标记-整理算法。

         Parallel Old搜集器还有一个重要的意义就是,它是除了serial old以外唯一一个可以与parallel scavenge搭配工作的年老代搜集器,因此为了避免serial old影响parallel scavenge可控制吞吐量的名声,parallel old就作为了parallel scavenge真正意义上的搭档。

         它可以使用参数-XX:-UseParallelOldGC开启,不过在JDK6以后,它也是在开启parallel scavenge之后默认的年老代搜集器。

 

  6、Concurrent Mark Sweep Garbage Collector

          concurrent mark sweep(以下简称CMS)搜集器是唯一一个真正意义上实现了应用程序与GC线程一起工作(一起是针对客户而言,而并不一定是真正的一起,有可能是快速交替)的搜集器。

         CMS是针对年老代设计的搜集器,并采用标记-清除算法,它也是唯一一个在年老代采用标记-清除算法的搜集器。

         采用标记-清除算法是因为它特殊的处理方式造成的,它的处理分为四个阶段。

         1、初始标记:需要暂停应用程序,快速标记存活对象。

         2、并发标记:恢复应用程序,并发跟踪GC Roots。

         3、重新标记:需要暂停应用程序,重新标记跟踪遗漏的对象。

         4、并发清除:恢复应用程序,并发清除未标记的垃圾对象。

         它比原来的标记-清除算法复杂了点,主要表现在并发标记和并发清除这两个阶段,而这两个阶段也是整个GC阶段中耗时最长的阶段,不过由于这两个阶段皆是与应用程序并发执行的,因此CMS搜集器造成的停顿时间是非常短暂的。这点还是比较好理解的。

         不过它的缺点也是要简单提一下的,主要有以下几点。

 

         1、由于GC线程与应用程序并发执行时会抢占CPU资源,因此会造成整体的吞吐量下降。也就是说,从吞吐量的指标上来说,CMS搜集器是要弱于parallel scavenge搜集器的。

        2、标记-清除很大的一个缺点,那就是内存碎片的存在。因此JVM提供了-XX:+UseCMSCompactAtFullCollection参数用于在全局GC(full GC)后进行一次碎片整理的工作,由于每次全局GC后都进行碎片整理会较大的影响停顿时间,JVM又提供了参数-XX:CMSFullGCsBeforeCompaction去控制在几次全局GC后会进行碎片整理。

         3、CMS最后一个缺点涉及到一个术语---并发模式失败(Concurrent Mode Failure)。

         这里LZ个人的理解是,年老代填满之前无法完成对象回收是指年老代在并发清除阶段清除不及时,因此造成的空闲内存不足。而不能满足内存的分配请求,则主要指的是新生代在提升到年老代时,由于年老代的内存碎片过多,导致一些分配由于没有连续的内存无法满足。

         实际上,在并发模式失败的情况下,serial old会作为备选搜集器,进行一次全局GC(Full GC),因此serial old也算是CMS的"替补"。显然,由于serial old的介入,会造成较大的停顿时间。

         为了尽量避免并发模式失败发生,我们可以调节-XX:CMSInitiatingOccupancyFraction=<N>参数,去控制当年老代的内存占用达到多少的时候(N%),便开启并发搜集器开始回收年老代。

  

  常见的3种收集器的组合:

   1、serial & serial old

          这个组合是我们最常见的组合之一,也是client模式下的默认垃圾搜集器组合,也可以使用参数-XX:+UseSerialGC强制开启。

         由于它实现相对简单,没有线程相关的额外开销(主要指线程切换与同步),因此非常适合运行于客户端PC的小型应用程序,或者桌面应用程序(比如swing编写的用户界面程序),以及我们平时的开发、调试、测试等。

         上面三种情况都有共同的特点。

         1、由于都是在PC上运行,因此配置一般不会太高,或者说处理器个数不会太多。

         2、上面几种情况的应用程序都不会运行太久。

         3、规模不会太大,也就是说,堆相对较小,搜集起来也比较快,停顿时间会比较短。     

  2、Parallel Scavenge & Parallel Old

          这个组合我们并不常见,毕竟它不会出现在我们平时的开发当中,但是它却是很多对吞吐量(throughout)要求较高或者对停顿时间(pause time)要求不高的应用程序的首选,并且这个组合是server模式下的默认组合(JDK6或JDK6之后)。当然,它也可以使用-XX:+UseParallelGC参数强制开启。

         该组合无论是新生代还是年老代都采用并行搜集,因此停顿时间较短,系统的整体吞吐量较高。它适用于一些需要长期运行且对吞吐量有一定要求的后台程序。

         这些运行于后台的程序都有以下特点。

         1、系统配置较高,通常情况下至少四核(以目前的硬件水平为准)。

         2、对吞吐量要求较高,或需要达到一定的量。

         3、应用程序运行时间较长。

         4、应用程序规模较大,一般是中到大型的堆。

   3、ParNew & CMS(Serial Old作为替补) 

         这个组合与上面的并行组合一样,在平时的开发当中都不常见,而它则是对相应时间(response time)要求较高的应用程序的首选。该组合需要使用参数-XX:+UseConcMarkSweepGC开启。

         该组合在新生代采用并行搜集器,因此新生代的GC速度会非常快,停顿时间很短。而年老代的GC采用并发搜集,大部分垃圾搜集的时间里,GC线程都是与应用程序并发执行的,因此造成的停顿时间依然很短。它适用于一些需要长期运行且对相应时间有一定要求的后台程序。

         这些运行于后台的程序的特点与并行模式下的后台程序十分类似,不同的是第二点,采用ParNew & CMS组合的后台应用程序,一般都对相应时间有一定要求,最典型的就是我们的WEB应用程序。

 

结束语:

  以上内容,纯属个人见解,如有错误,望不吝指正,也希望能给大家提供一点帮助。

学习Java的同学注意了!!!  学习过程中遇到什么问题或者想获取学习资源的话,欢迎加入Java学习交流群,群号码:456544752  我们一起学Java!