Java[从菜鸟到高手演变]之JVM内存管理及垃圾回收

原文:http://blog.csdn.net/zhangerqing/article/details/8214365


很多Java面试的时候,都会问到有关Java垃圾回收的问题,提到垃圾回收肯定要涉及到JVM内存管理机制,Java语言的执行效率一直被C、C++程序员所嘲笑,其实,事实就是这样,Java在执行效率方面确实很低,一方面,Java语言采用面向对象思想,这也决定了其必然是开发效率高,执行效率低。另一方面,Java语言对程序员做了一个美好的承诺:程序员无需去管理内存,因为JVM有垃圾回收(GC),会去自动进行垃圾回收。


其实不然:

1、垃圾回收并不会按照程序员的要求,随时进行GC。

2、垃圾回收并不会及时的清理内存,尽管有时程序需要额外的内存。

3、程序员不能对垃圾回收进行控制。


因为上面这些事实,以致我们在写程序的时候,只能根据垃圾回收的规律,合理安排内存,这就要求我们必须彻底了解JVM的内存管理机制,这样才能随心所欲,将程序控制于鼓掌之中!学完本章知识,读者对JVM就会有基本的了解。


先看一看JVM的内部结构——


如图所示,JVM主要包括两个子系统和两个组件。两个子系统分别是Class loader子系统和Execution engine(执行引擎) 子系统;两个组件分别是Runtime data area (运行时数据区域)组件和Native interface(本地接口)组件。
 
Class loader子系统的作用:根据给定的全限定名类名(如 java.lang.Object)来装载class文件的内容到 Runtime data area中的method area(方法区域)。Java程序员可以extends java.lang.ClassLoader类来写自己的Class loader。  

Execution engine子系统的作用:执行classes中的指令。任何JVM specification实现(JDK)的核心都是Execution engine,不同的JDK例如Sun 的JDK 和IBM的JDK好坏主要就取决于他们各自实现的Execution engine的好坏。

Native interface组件:与native libraries交互,是其它编程语言交互的接口。当调用native方法的时候,就进入了一个全新的并且不再受虚拟机限制的世界,所以也很容易出现JVM无法控制的native heap OutOfMemory。

Runtime Data Area组件:这就是我们常说的JVM的内存了。它主要分为五个部分——
1、Heap (堆):一个Java虚拟实例中只存在一个堆空间
2、Method Area(方法区域):被装载的class的信息存储在Method area的内存中。当虚拟机装载某个类型时,它使用类装载器定位相应的class文件,然后读入这个class文件内容并把它传输到虚拟机中。
3、Java Stack(java的栈):虚拟机只会直接对Java stack执行两种操作:以帧为单位的压栈或出栈
4、Program Counter(程序计数器):每一个线程都有它自己的PC寄存器,也是该线程启动时创建的。PC寄存器的内容总是指向下一条将被执行指令的饿地址,这里的地址可以是一个本地指针,也可以是在方法区中相对应于该方法起始指令的偏移量。  
5、Native method stack(本地方法栈):保存native方法进入区域的地址

以上五部分只有Heap 和Method Area是被所有线程的共享使用的;而Java stack, Program counter 和Native method stack是以线程为粒度的,每个线程独自拥有自己的部分。



前言:JAVA代码编译和执行过程 

Java代码编译是由Java源码编译器来完成,流程图如下所示:

技术分享


Java字节码的执行是由JVM执行引擎来完成,流程图如下所示:

技术分享

java代码编译和执行的整个过程包含了以下三个重要的机制:

  •        1、Java源码编译机制

  •        2、类加载机制

  •        3、类执行机制

Java源码编译机制

Java 源码编译由以下三个过程组成:

  • 1、分析和输入到符号表

  • 2、注解处理

  • 3、语义分析和生成class文件

流程图如下所示:

技术分享


最后生成的class文件由以下部分组成:

  • 1、结构信息:包括class文件格式版本号及各部分的数量与大小的信息

  • 2、元数据:对应于Java源码中声明与常量的信息。包含类/继承的超类/实现的接口的声明信息、域与方法声明信息和常量池

  • 3、方法信息:对应Java源码中语句和表达式对应的信息。包含字节码、异常处理器表、求值栈与局部变量区大小、求值栈的类型记录、调试符号信息

类加载机制

JVM的类加载是通过ClassLoader及其子类来完成的,类的层次关系和加载顺序可以由下图来描述:

技术分享

1)Bootstrap ClassLoader

负责加载$JAVA_HOME中jre/lib/rt.jar里所有的class,由C++实现,不是ClassLoader子类

2)Extension ClassLoader

负责加载java平台中扩展功能的一些jar包,包括$JAVA_HOME中jre/lib/*.jar或-Djava.ext.dirs指定目录下的jar包

3)App ClassLoader

负责记载classpath中指定的jar包及目录中class

4)Custom ClassLoader

    属于应用程序根据自身需要自定义的ClassLoader,如tomcat、jboss都会根据j2ee规范自行实现ClassLoader加载过程中会先检查类是否被已加载,检查顺序是自底向上,从Custom ClassLoader到BootStrap ClassLoader逐层检查,只要某个classloader已加载就视为已加载此类,保证此类只所有ClassLoader加载一次。而加载的顺序是自顶向下,也就是由上层来逐层尝试加载此类。


类执行机制

JVM是基于栈的体系结构来执行class字节码的。线程创建后,都会产生程序计数器(PC)和栈(Stack),程序计数器存放下一条要执行的指令在方法内的偏移量,栈中存放一个个栈帧,每个栈帧对应着每个方法的每次调用,而栈帧又是有局部变量区和操作数栈两部分组成,局部变量区用于存放方法中的局部变量和参数,操作数栈中用于存放方法执行过程中产生的中间结果。栈的结构如下图所示:

技术分享




一、JVM内存的构成


Java虚拟机会将内存分为几个不同的管理区,这些区域各自有各自的用途,根据不同的特点,承担不同的任务以及在垃圾回收时运用不同的算法。总体分为下面几个部分:


程序计数器(Program Counter Register)、JVM虚拟机栈(JVM Stacks)、本地方法栈(Native Method Stacks)、堆(Heap)、方法区(Method Area)


其中,方法区和堆是所有线程共享的。


如下图:


1、程序计数器(Program Counter Register)

这是一块比较小的内存,不在Ram上,而是直接划分在CPU上的,程序员无法直接操作它,它的作用是:JVM在解释字节码文件(.class)时,存储当前线程所执行的字节码的行号,只是一种概念模型,各种JVM所采用的方式不同,字节码解释器工作时,就是通过改变程序计数器的值来选取下一条要执行的指令,分支、循环、跳转、等基础功能都是依赖此技术区完成的。还有一种情况,就是我们常说的Java多线程方面的,多线程就是通过现程轮流切换而达到的,同一时刻,一个内核只能执行一个指令,所以,对于每一个程序来说,必须有一个计数器来记录程序的执行进度,这样,当现程恢复执行的时候,才能从正确的地方开始,所以,每个线程都必须有一个独立的程序计数器,这类计数器为线程私有的内存。如果一个线程正在执行一个Java方法,则计数器记录的是字节码的指令的地址,如果执行的一个Native方法,则计数器的记录为空,此内存区是唯一一个在Java规范中没有任何OutOfMemoryError情况的区域。


2、JVM虚拟机栈(JVM Stacks)

JVM虚拟机栈就是我们常说的堆栈的栈(我们常常把内存粗略分为堆和栈),和程序计数器一样,也是线程私有的,生命周期和线程一样,每个方法被执行的时候会产生一个栈帧,用于存储局部变量表、动态链接、操作数、方法出口等信息。方法的执行过程就是栈帧在JVM中出栈和入栈的过程。局部变量表中存放的是各种基本数据类型,如boolean、byte、char、等8种,及引用类型(存放的是指向各个对象的内存地址),因此,它有一个特点:内存空间可以在编译期间就确定,运行期不在改变。这个内存区域会有两种可能的Java异常:StackOverFlowError和OutOfMemoryError。


3、本地方法栈(Native Method Stacks) -- 一些基本类型的变量和对象的引用变量都是在函数的栈内存中分配

从名字即可看出,本地方法栈就是用来处理Java中的本地方法的,Java类的祖先类Object中有众多Native方法,如hashCode()、wait()等,他们的执行很多时候是借助于操作系统,但是JVM需要对他们做一些规范,来处理他们的执行过程。此区域,可以有不同的实现方法,向我们常用的Sun的JVM就是本地方法栈和JVM虚拟机栈是同一个。


4、堆(Heap)-- 存放由new创建的对象和数组

堆内存是内存中最重要的一块,也是最有必要进行深究的一部分。因为Java性能的优化,主要就是针对这部分内存的。所有的对象实例及数组都是在堆上面分配的(随着JIT技术的逐渐成熟,这句话视乎有些绝对,不过至少目前还基本是这样的),可通过-Xmx和-Xms来控制堆的大小。JIT技术的发展产生了新的技术,如栈上分配和标量替换,也许在不久的几年里,即时编译会诞生及成熟,那个时候,“所有的对象实例及数组都是在堆上面分配的”这句话就应该稍微改改了。堆内存是垃圾回收的主要区域,所以在下文垃圾回收板块会重点介绍,此处只做概念方面的解释。在32位系统上最大为2G,64位系统上无限制。可通过-Xms和-Xmx控制,-Xms为JVM启动时申请的最小Heap内存,-Xmx为JVM可申请的最大Heap内存。


5、方法区(Method Area)

 方法区是所有线程共享的内存区域,用于存储已经被JVM加载的类信息、常量、静态变量等数据,一般来说,方法区属于持久代(关于持久代,会在GC部分详细介绍,除了持久代,还有新生代和旧生代),也难怪Java规范将方法区描述为堆的一个逻辑部分,但是它不是堆。方法区的垃圾回收比较棘手,就算是Sun的HotSpot VM在这方面也没有做得多么完美。此处引入方法区中一个重要的概念:运行时常量池。主要用于存放在编译过程中产生的字面量(字面量简单理解就是常量)和引用。一般情况,常量的内存分配在编译期间就能确定,但不一定全是,有一些可能就是运行时也可将常量放入常量池中,如String类中有个Native方法intern()<关于intern()的详细说明,请看另一篇文章:http://blog.csdn.net/zhangerqing/article/details/8093919>

此处补充一个在JVM内存管理之外的一个内存区:直接内存。在JDK1.4中新加入类NIO类,引入了一种基于通道与缓冲区的I/O方式,它可以使用Native函数库直接分配堆外内存,即我们所说的直接内存,这样在某些场景中会提高程序的性能。


二、垃圾回收


有句话说的好:Java和C++之间有一堵有内存分配和垃圾回收技术围成的墙,墙外的人想进去,墙里的人想出去!这句话的意思,请读者自己去琢磨。总的来说,C、C++程序员有时苦于内存泄露,内存管理是件令人头痛的事儿,但是Java程序员呢,又羡慕C++程序员,自己可以控制一切,这样就不会在内存管理方面显得束手无策,的却如此,作为Java程序员我们很难去控制JVM的内存回收,只能根据它的原理去适应,尽量提高程序的性能。下面开始讲解Java垃圾回收,即Garbage Collection,GC。从以下四个方面进行:


1、为什么要进行垃圾回收?

随着程序的运行,内存中存在的实例对象、变量等信息占据的内存越来越多,如果不及时进行垃圾回收,必然会带来程序性能的下降,甚至会因为可用内存不足造成一些不必要的系统异常。


2、哪些“垃圾”需要回收?

在我们上面介绍的五大区中,有三个是不需要进行垃圾回收的:程序计数器、JVM栈、本地方法栈。因为它们的生命周期是和线程同步的,随着线程的销毁,它们占用的内存会自动释放,所以只有方法区和堆需要进行GC。具体到哪些对象的话,简单概况一句话:如果某个对象已经不存在任何引用,那么它可以被回收。通俗解释一下就是说,如果一个对象,已经没有什么作用了,就可以被当废弃物被回收了。


3、什么时候进行垃圾回收?

根据一个经典的引用计数算法,每个对象添加一个引用计数器,每被引用一次,计数器加1,失去引用,计数器减1,当计数器在一段时间内保持为0时,该对象就认为是可以被回收得了。但是,这个算法有明显的缺陷:当两个对象相互引用,但是二者已经没有作用时,按照常规,应该对其进行垃圾回收,但是其相互引用,又不符合垃圾回收的条件,因此无法完美处理这块内存清理,因此Sun的JVM并没有采用引用计数算法来进行垃圾回收。而是采用一个叫:根搜索算法,如下图:

基本思想就是:从一个叫GC Roots的对象开始,向下搜索,如果一个对象不能到达GC Roots对象的时候,说明它已经不再被引用,即可被进行垃圾回收(此处 暂且这样理解,其实事实还有一些不同,当一个对象不再被引用时,并没有完全“死亡”,如果类重写了finalize()方法,且没有被系统调用过,那么系统会调用一次finalize()方法,以完成最后的工作,在这期间,如果可以将对象重新与任何一个和GC Roots有引用的对象相关联,则该对象可以“重生”,如果不可以,那么就说明彻底可以被回收了),如上图中的Object5、Object6、Object7,虽然它们3个依然可能相互引用,但是总体来说,它们已经没有作用了,这样就解决了引用计数算法无法解决的问题。


补充引用的概念JDK 1.2之后,对引用进行了扩充,引入了强、软、若、虚四种引用,被标记为这四种引用的对象,在GC时分别有不同的意义:

    a> 强引用(Strong Reference).就是为刚被new出来的对象所加的引用,它的特点就是,永远不会被回收。

    b> 软引用(Soft Reference).声明为软引用的类,是可被回收的对象,如果JVM内存并不紧张,这类对象可以不被回收,如果内存紧张,则会被回       收。此处有一个问题,既然被引用为软引用的对象可以回收,为什么不去回收呢?其实我们知道,Java中是存在缓存机制的,就拿字面量缓存来           说,有些时候,缓存的对象就是当前可有可无的,只是留在内存中如果还有需要,则不需要重新分配内存即可使用,因此,这些对象即可被引用为       软引用,方便使用,提高程序性能。

    c> 弱引用(Weak Reference).弱引用的对象就是一定需要进行垃圾回收的,不管内存是否紧张,当进行GC时,标记为弱引用的对象一定会被清理     回收。

    d> 虚引用(Phantom Reference).虚引用弱的可以忽略不计,JVM完全不会在乎虚引用,其唯一作用就是做一些跟踪记录,辅助finalize函数的使         用。


最后总结,什么样的类需要回收呢?无用的类,何为无用的类?需满足如下要求:

    1> 该类的所有实例对象都已经被回收。

    2> 加载该类的ClassLoader已经被回收。

    3> 该类对应的反射类java.lang.Class对象没有被任何地方引用。


4、如何进行垃圾回收?


本块内容以介绍垃圾回收算法为主,因为我们前面有介绍,内存主要被分为三块,新生代、旧生代、持久代。三代的特点不同,造就了他们所用的GC算法不同,新生代适合那些生命周期较短,频繁创建及销毁的对象,旧生代适合生命周期相对较长的对象,持久代在Sun HotSpot中就是指方法区(有些JVM中根本就没有持久代这中说法)。首先介绍下新生代、旧生代、持久代的概念及特点:

新生代:New Generation或者Young Generation。上面大致分为Eden区和Survivor区,Survivor区又分为大小相同的两部分:FromSpace 和ToSpace。新建的对象都是用新生代分配内存,Eden空间不足的时候,会把存活的对象转移到Survivor中,新生代的大小可以由-Xmn来控制,也可以用-XX:SurvivorRatio来控制Eden和Survivor的比例.
旧生代:Old Generation。用于存放新生代中经过多次垃圾回收仍然存活的对象,例如缓存对象。旧生代占用大小为-Xmx值减去-Xmn对应的值。

持久代:Permanent Generation。在Sun的JVM中就是方法区的意思,尽管有些JVM大多没有这一代。主要存放常量及类的一些信息默认最小值为16MB,最大值为64MB,可通过-XX:PermSize及-XX:MaxPermSize来设置最小值和最大值。


常见的GC算法:详见三


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

最基础的GC算法,将需要进行回收的对象做标记,之后扫描,有标记的进行回收,这样就产生两个步骤:标记和清除。这个算法效率不高,而且在清理完成后会产生内存碎片,这样,如果有大对象需要连续的内存空间时,还需要进行碎片整理,所以,此算法需要改进。


复制算法(Copying)

前面我们谈过,新生代内存分为了三份,Eden区和2块Survivor区,一般Sun的JVM会将Eden区和Survivor区的比例调为8:1,保证有一块Survivor区是空闲的,这样,在垃圾回收的时候,将不需要进行回收的对象放在空闲的Survivor区,然后将Eden区和第一块Survivor区进行完全清理,这样有一个问题,就是如果第二块Survivor区的空间不够大怎么办?这个时候,就需要当Survivor区不够用的时候,暂时借持久代的内存用一下。此算法适用于新生代


标记-整理(或叫压缩)算法(Mark-Compact)

和标记-清楚算法前半段一样,只是在标记了不需要进行回收的对象后,将标记过的对象移动到一起,使得内存连续,这样,只要将标记边界以外的内存清理就行了。此算法适用于持久代


常见的垃圾收集器: 


根据上面说的诸多算法,每天JVM都有不同的实现,我们先来看看常见的一些垃圾收集器:


首先介绍三种实际的垃圾回收器:串行GC(SerialGC)、并行回收GC(Parallel Scavenge)和并行GC(ParNew)。


1、Serial GC。是最基本、最古老的收集器,但是现在依然被广泛使用,是一种单线程垃圾回收机制,而且不仅如此,它最大的特点就是在进行垃圾回收的时候,需要将所有正在执行的线程暂停(Stop The World),对于有些应用这是难以接受的,但是我们可以这样想,只要我们能够做到将它所停顿的时间控制在N个毫秒范围内,大多数应用我们还是可以接受的,而且事实是它并没有让我们失望,几十毫米的停顿我们作为客户机(Client)是完全可以接受的,该收集器适用于单CPU、新生代空间较小及对暂停时间要求不是非常高的应用上,是client级别默认的GC方式,可以通过-XX:+UseSerialGC来强制指定。


2、ParNew GC。基本和Serial GC一样,但本质区别是加入了多线程机制,提高了效率,这样它就可以被用在服务器端(Server)上,同时它可以与CMS GC配合,所以,更加有理由将它置于Server端。


3、Parallel Scavenge GC。在整个扫描和复制过程采用多线程的方式来进行,适用于多CPU、对暂停时间要求较短的应用上,是server级别默认采用的GC方式,可用-XX:+UseParallelGC来强制指定,用-XX:ParallelGCThreads=4来指定线程数。以下给出几组使用组合:


4、CMS (Concurrent Mark Sweep)收集器。该收集器目标就是解决Serial GC 的停顿问题,以达到最短回收时间。常见的B/S架构的应用就适合用这种收集器,因为其高并发、高响应的特点。CMS收集器是基于“标记-清除”算法实现的,整个收集过程大致分为4个步骤:

初始标记(CMS initial mark)、并发标记(CMS concurrenr mark)、重新标记(CMS remark)、并发清除(CMS concurrent sweep)。

其中初始标记、重新标记这两个步骤任然需要停顿其他用户线程。初始标记仅仅只是标记出GC ROOTS能直接关联到的对象,速度很快,并发标记阶段是进行GC ROOTS 根搜索算法阶段,会判定对象是否存活。而重新标记阶段则是为了修正并发标记期间,因用户程序继续运行而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间会被初始标记阶段稍长,但比并发标记阶段要短。由于整个过程中耗时最长的并发标记和并发清除过程中,收集器线程都可以与用户线程一起工作,所以整体来说,CMS收集器的内存回收过程是与用户线程一起并发执行的。


CMS收集器的优点:并发收集、低停顿,但是CMS还远远达不到完美。


CMS收集器主要有三个显著缺点

a>.CMS收集器对CPU资源非常敏感。在并发阶段,虽然不会导致用户线程停顿,但是会占用CPU资源而导致引用程序变慢,总吞吐量下降。CMS默认启动的回收线程数是:(CPU数量+3) / 4。

b>.CMS收集器无法处理浮动垃圾,可能出现“Concurrent Mode Failure“,失败后而导致另一次Full GC的产生。由于CMS并发清理阶段用户线程还在运行,伴随程序的运行自热会有新的垃圾不断产生,这一部分垃圾出现在标记过程之后,CMS无法在本次收集中处理它们,只好留待下一次GC时将其清理掉。这一部分垃圾称为“浮动垃圾”。也是由于在垃圾收集阶段用户线程还需要运行,即需要预留足够的内存空间给用户线程使用,因此CMS收集器不能像其他收集器那样等到老年代几乎完全被填满了再进行收集,需要预留一部分内存空间提供并发收集时的程序运作使用。在默认设置下,CMS收集器在老年代使用了68%的空间时就会被激活,也可以通过参数-XX:CMSInitiatingOccupancyFraction的值来提供触发百分比,以降低内存回收次数提高性能。要是CMS运行期间预留的内存无法满足程序其他线程需要,就会出现“Concurrent Mode Failure”失败,这时候虚拟机将启动后备预案:临时启用Serial Old收集器来重新进行老年代的垃圾收集,这样停顿时间就很长了。所以说参数-XX:CMSInitiatingOccupancyFraction设置的过高将会很容易导致“Concurrent Mode Failure”失败,性能反而降低。

c>.最后一个缺点,CMS是基于“标记-清除”算法实现的收集器,使用“标记-清除”算法收集后,会产生大量碎片。空间碎片太多时,将会给对象分配带来很多麻烦,比如说大对象,内存空间找不到连续的空间来分配不得不提前触发一次Full GC。为了解决这个问题,CMS收集器提供了一个-XX:UseCMSCompactAtFullCollection开关参数,用于在Full GC之后增加一个碎片整理过程,还可通过-XX:CMSFullGCBeforeCompaction参数设置执行多少次不压缩的Full GC之后,跟着来一次碎片整理过程。


5、G1收集器。相比CMS收集器有不少改进,首先基于标记-整理算法,不会产生内存碎片问题,其次,可以比较精确的控制停顿,此处不再详细介绍。


6、Serial Old。Serial Old是Serial收集器的老年代版本,它同样使用一个单线程执行收集,使用“标记-整理”算法。主要使用在Client模式下的虚拟机。


7、Parallel Old。Parallel Old是Parallel Scavenge收集器的老年代版本,使用多线程和“标记-整理”算法。


8、RTSJ垃圾收集器,用于Java实时编程,后续会补充介绍。


三、内存的回收方式

JVM通过GC来回收堆和方法区中的内存,这个过程是自动执行的。说到Java GC机制,其主要完成3件事:确定哪些内存需要回收;确定什么时候需要执行GC;如何执行GC。JVM主要采用收集器的方式实现GC,主要的收集器有引用计数收集器和跟踪收集器。

1、引用计数收集器

引用计数器采用分散式管理方式,通过计数器记录对象是否被引用。当计数器为0时,说明此对象已经不再被使用,可进行回收,如图所示:

这里写图片描述

在上图中,ObjectA释放了对ObjectB的引用后,ObjectB的引用计数器变为0,此时可回收ObjectB所占有的内存。

引用计数器需要在每次对象赋值时进行引用计数器的增减,他有一定消耗。另外,引用计数器对于循环引用的场景没有办法实现回收。例如在上面的例子中,如果ObjectB和ObjectC互相引用,那么即使ObjectA释放了对ObjectB和ObjectC的引用,也无法回收ObjectB、ObjectC,因此对于java这种会形成复杂引用关系的语言而言,引用计数器是非常不适合的,SunJDK在实现GC时也未采用这种方式。

2、跟踪收集器

跟踪收集器采用的为集中式的管理方式,会全局记录数据引用的状态。基于一定条件的触发(例如定时、空间不足时),执行时需要从根集合来扫描对象的引用关系,这可能会造成应用程序暂停。主要有复制(Copying)标记-清除(Mark-Sweep)标记-压缩(Mark-Compact)三种实现算法。

复制(Copying)

复制采用的方式为从根集合扫描出存活的对象,并将找到的存活的对象复制到一块新的完全未被使用的空间中,如图所示:

这里写图片描述

复制收集器方式仅需要从根集合扫描所有存活对象,当要回收的空间中存活对象较少时,复制算法会比较高效(年轻代的Eden区就是采用这个算法),其带来的成本是要增加一块空的内存空间及进行对象的移动。

标记-清除(Marking-Deleting)

标记-清除采用的方式为从根集合开始扫描,对存活的对象进行标记,标记完毕后,再扫描整个空间中未标记的对象,并进行清除,标记和清除过程如下图所示:

这里写图片描述

上图中蓝色的部分是有被引用的存活的对象,褐色部分没被引用的可回收的对象。在marking阶段为了mark对象,所有的对象都会被扫描一遍,扫描这个过程是比较耗时的。

这里写图片描述

清除阶段回收的是没有被引用的对象,存活的对象被保留。内存分配器会持有空闲空间的引用列表,当有分配请求时会查询空闲空间引用列表进行分配。

标记-清除动作不需要进行对象移动,且仅对其不存活的对象进行处理。在空间中存活对象较多的情况下较为高效,但由于标记-清除直接回收不存活对象占用的内存,因此会造成内存碎片。

标记-压缩(Mark-Compact)

标记-压缩和标记-清除一样,是对活的对象进行标记,但是在清除后的处理不一样,标记-压缩在清除对象占用的内存后,会把所有活的对象向左端空闲空间移动,然后再更新引用其对象的指针,如下图所示:

这里写图片描述

很明显,标记-压缩在标记-清除的基础上对存活的对象进行了移动规整动作,解决了内存碎片问题,得到更多连续的内存空间以提高分配效率,但由于需要对对象进行移动,因此成本也比较高。

四、详解虚拟机中的GC过程

1、为什么要分代回收?

在一开始的时候,JVM的GC就是采用标记-清除-压缩方式进行的,这么做并不是很高效,因为当对象分配的越来越多时,对象列表也越来也大,扫描和移动越来越耗时,造成了内存回收越来越慢。然而,经过根据对java应用的分析,发现大部分对象的存活时间都非常短,只有少部分数据存活周期是比较长的,请看下面对java对象内存存活时间的统计:

这里写图片描述

从图表中可以看出,大部分对象存活时间是非常短的,随着时间的推移,被分配的对象越来越少。

2、虚拟机中GC的过程

经过上面介绍,我们已经知道了JVM为何要分代回收,下面我们就详细看一下整个回收过程。

  1. 在初始阶段,新创建的对象被分配到Eden区,survivor的两块空间都为空。 
    这里写图片描述

  2. 当Eden区满了的时候,minor garbage 被触发 
    这里写图片描述

  3. 经过扫描与标记,存活的对象被复制到S0,不存活的对象被回收 
    这里写图片描述

  4. 在下一次的Minor GC中,Eden区的情况和上面一致,没有引用的对象被回收,存活的对象被复制到survivor区。然而在survivor区,S0的所有的数据都被复制到S1,需要注意的是,在上次minor GC过程中移动到S0中的两个对象在复制到S1后其年龄要加1。此时Eden区S0区被清空,所有存活的数据都复制到了S1区,并且S1区存在着年龄不一样的对象,过程如下图所示: 
    这里写图片描述

  5. 再下一次MinorGC则重复这个过程,这一次survivor的两个区对换,存活的对象被复制到S0,存活的对象年龄加1,Eden区和另一个survivor区被清空。 
    这里写图片描述

  6. 下面演示一下Promotion过程,再经过几次Minor GC之后,当存活对象的年龄达到一个阈值之后(可通过参数配置,默认是8),就会被从年轻代Promotion到老年代。 
    这里写图片描述

  7. 随着MinorGC一次又一次的进行,不断会有新的对象被promote到老年代。 
    这里写图片描述

  8. 上面基本上覆盖了整个年轻代所有的回收过程。最终,MajorGC将会在老年代发生,老年代的空间将会被清除和压缩。 
    这里写图片描述

从上面的过程可以看出,Eden区是连续的空间,且Survivor总有一个为空。经过一次GC和复制,一个Survivor中保存着当前还活着的对象,而Eden区和另一个Survivor区的内容都不再需要了,可以直接清空,到下一次GC时,两个Survivor的角色再互换。因此,这种方式分配内存和清理内存的效率都极高,这种垃圾回收的方式就是著名的“停止-复制(Stop-and-copy)”清理法(将Eden区和一个Survivor中仍然存活的对象拷贝到另一个Survivor中),这不代表着停止复制清理法很高效,其实,它也只在这种情况下(基于大部分对象存活周期很短的事实)高效,如果在老年代采用停止复制,则是非常不合适的。

老年代存储的对象比年轻代多得多,而且不乏大对象,对老年代进行内存清理时,如果使用停止-复制算法,则相当低效。一般,老年代用的算法是标记-压缩算法,即:标记出仍然存活的对象(存在引用的),将所有存活的对象向一端移动,以保证内存的连续。在发生Minor GC时,虚拟机会检查每次晋升进入老年代的大小是否大于老年代的剩余空间大小,如果大于,则直接触发一次Full GC,否则,就查看是否设置了-XX:+HandlePromotionFailure(允许担保失败),如果允许,则只会进行MinorGC,此时可以容忍内存分配失败;如果不允许,则仍然进行Full GC(这代表着如果设置-XX:+Handle PromotionFailure,则触发MinorGC就会同时触发Full GC,哪怕老年代还有很多内存,所以,最好不要这样做)。

关于方法区即永久代的回收,永久代的回收有两种:常量池中的常量,无用的类信息,常量的回收很简单,没有引用了就可以被回收。对于无用的类进行回收,必须保证3点:

1. 类的所有实例都已经被回收
2. 加载类的ClassLoader已经被回收
3. 类对象的Class对象没有被引用(即没有通过反射引用该类的地方)

 
 
  • 1
  • 2
  • 3
  • 4

永久代的回收并不是必须的,可以通过参数来设置是否对类进行回收。



五、Java程序性能优化


gc()的调用

调用gc 方法暗示着Java 虚拟机做了一些努力来回收未用对象,以便能够快速地重用这些对象当前占用的内存。当控制权从方法调用中返回时,虚拟机已经尽最大努力从所有丢弃的对象中回收了空间,调用System.gc() 等效于调用Runtime.getRuntime().gc()。


finalize()的调用及重写

gc 只能清除在堆上分配的内存(纯java语言的所有对象都在堆上使用new分配内存),而不能清除栈上分配的内存(当使用JNI技术时,可能会在栈上分配内存,例如java调用c程序,而该c程序使用malloc分配内存时)。因此,如果某些对象被分配了栈上的内存区域,那gc就管不着了,对栈上的对象进行内存回收就要靠finalize()。举个例子来说,当java 调用非java方法时(这种方法可能是c或是c++的),在非java代码内部也许调用了c的malloc()函数来分配内存,而且除非调用那个了 free() 否则不会释放内存(因为free()是c的函数),这个时候要进行释放内存的工作,gc是不起作用的,因而需要在finalize()内部的一个固有方法调用free()。


优秀的编程习惯

(1)避免在循环体中创建对象,即使该对象占用内存空间不大。
(2)尽量及时使对象符合垃圾回收标准。
(3)不要采用过深的继承层次。
(4)访问本地变量优于访问类中的变量。

本版块会不断更新!


六、常见问题


1、内存溢出

就是你要求分配的java虚拟机内存超出了系统能给你的,系统不能满足需求,于是产生溢出。


2、内存泄漏

是指你向系统申请分配内存进行使用(new),可是使用完了以后却不归还(delete),结果你申请到的那块内存你自己也不能再访问,该块已分配出来的内存也无法再使用,随着服务器内存的不断消耗,而无法使用的内存越来越多,系统也不能再次将它分配给需要的程序,产生泄露。一直下去,程序也逐渐无内存使用,就会溢出。


本章内容以理论为主,后续我会不断地增加一些实际的操作,如验证垃圾回收效果、或者内存监测什么的,同时也希望读者会不断给出指导、建议,如有任何问题,请联系:egg:


评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值