什么是垃圾?
垃圾是指在运行程序中没有任何指针指向的对象,这个对象就是需要被回收的垃圾。
为什么需要GC?
如果不及时的对垃圾进行清理,那么这些垃圾对象所占用的对象,一直会保留到应用程序结束,并且垃圾对象所占用的这部分空间不能被其它对象所使用,这样如果程序中的对象比较多的时候,可能会导致内存溢出。并且这样也是比较浪费空间的。
在早期的C/C++时代,垃圾回收基本上是手工进行的。开发人员可以使用new关键字进行内存申请,并使用delete关键字进行内存释放。这种方式可以灵活控制内存释放的事件,但是会给开发人员带来频繁申请和释放内存的管理负担。倘若有一处内存区间由于程序员编码的问题忘记被回收,那么就会产生内存泄漏,垃圾对象永远无法被清除,随着系统运行时间的不断增长,垃圾对象所耗内存可能持续上升,直到出现内存溢出并造成应用程序崩溃。
现在,除了Java以外,C#,Python,Ruby等语言都使用了自动垃圾回收的思想,也是未来发展趋势。可以说,这种自动化的内存分配和垃圾回收的方式已经成为现代开发语言必备的标准。
自动内存管理,无需开发人员手动参与内存的分配与回收,这样降低内存泄漏和内存溢出的风险。没有垃圾回收器,java也会和cpp一样,各种悬垂指针,野指针,泄漏问题让你头疼不已。
自动内存管理机制,将程序员从繁重的内存管理中释放出来,可以更专心地专注于业务开发。
对于Java开发人员而言,自动内存管理就像是一个黑匣子,如果过度依赖于"自动",那么这将会是一场灾难,最严重的就会弱化Java开发人员在程序出现内存溢出时定位问题和解决问题的能力。
此时,了解JVM的自动内存分配和内存回收原理就显得非常重要,只有在真正了解JVM是如何管理内存后,我们才能够在遇见OutOfMemoryError时,快速地根据错误异常日志定位问题和解决问题。
当需要排查各种内存溢出,内存泄漏问题时,当垃圾收集成为系统达到更高并发量的瓶颈时,我们就必须对这些"自动化"的技术实施必要的监控和调节。
垃圾回收器可以对年轻代回收,也可以对老年代回收,甚至是全堆和方法区的回收,其中Java堆是垃圾收集器的工作重点。
从次数上讲:
频繁收集Young区
较少收集Old区
基本不动Perm区(或元空间)
1.在堆里存放着几乎所有的Java对象实例,在GC执行垃圾回收之前,首先需要区分出内存中哪些是存活对象,哪些是已经死亡的对象。只有被标记为已经死亡的对象,GC才会在执行垃圾回收时,释放掉其所占用的内存空间,因此这个过程我们可以成为垃圾标记阶段。
2.那么在JVM中究竟是如何标记一个死亡对象呢?简单来说,当一个对象已经不再被任何的存活对象继续引用时,就可以宣判为已经死亡。
3.判断对象存活一般有两种方式:引用计数算法和可达性分析算法。
引用计数算法(Reference Counting)比较简单,对每个对象保存一个整型的引用计数器属性。用于记录对象被引用的情况。
对于一个对象A,只要有任何一个对象引用了A,则A的引用计数器就加1;当引用失效时,引用计数器就减1。只要对象A的引用计数器的值为0,即表示对象A不可能再被使用,可进行回收。
优点:实现简单,垃圾对象便于辨识;判定效率高,回收没有延迟性。
缺点:
1.它需要单独的字段存储计数器,这样的做法增加了存储空间的开销。
2.每次赋值都需要更新计数器,伴随着加法和减法操作,这增加了时间开销。
3.引用计数器有一个严重的问题,即无法处理循环引用的情况。这是一条致命缺陷。导致在Java的垃圾回收器中没有使用这类算法。什么叫做无法处理循环引用呢?如下图:
内存中的对象不再使用,但是这段内存的空间又释放不掉,这中情况叫做内存泄漏。
每次都会从根部进行扫描,如果发现一个对象没有直接或间接的和根部相连,那么这个对象就会被标记为死亡。在GC垃圾回收的时候,就会把这个对象回收掉。只有和根部直接或间接相连的对象才是存活的对象。
可达性分析算法有叫做跟搜索算法或追踪性垃圾收集算法
1.相对于引用计数算法而言,可达性分析算法不仅同样具备实现简单和执行高效等特点,更重要的是该算法可以有效地解决在引用计数算法中循环引用的问题,防止内存泄漏的发生。
2.相较于引用计数算法,这里的可达性分析就是Java,C#选择的。这种类型的垃圾收集通常也叫做追踪性垃圾收集(Tracing Garbage Collection)。
基本思路:
1.可达性分析算法是以根对象集合(GC Roots)为起始点,按照从上至下的方式搜索被根对象集合所连接的目标对象是否可达。
2.使用可达性分析算法后,内存中的存活对象都会被跟对象集合直接或间接连接着,搜索所走过的路径称为引用链(Reference Chain)
3.如果目标对象没有任何引用链相连,则是不可达的,就意味着该对象已经死亡,可以标记为垃圾对象
4.在可达性分析算法中,只有能够被跟对象集合直接或者间接连接的对象才是存活对象。
所谓GC Roots跟集合就是一组必须活跃的引用。
在Java语言中,GC Roots包括以下几类元素:
1.虚拟机栈中引用的对象,比如:各个线程被调用的方法中使用到的参数,局部变量等
2.本地方法栈内JNI(通常说的本地方法)引用的对象
3.方法区中类静态属性引用的对象,比如:Java类的引用类型静态变量
4.方法区中常量引用的对象,比如:字符串常量池(String Table)里的引用
5.所有被同步锁synchronized持有的对象
6.Java虚拟机内部的引用,基本数据类型对应的Class对象,一些常驻的异常对象(如:NullPointerException,OutOfMemoryError),系统类加载器。
7.反映java虚拟机内部情况的JMXBean,JVMTI中注册的回调,本地代码缓存等
此算法有两个阶段,第一个阶段是标记阶段,就是会从根节点扫描所有的对象,如果发现某个对象有被引用,就在对象的Header中记录为可达对象;第二个阶段是清除阶段,对堆内存从头到尾线性遍历,如果发现对象的Header中没有被标记为可达对象,则将其回收。不过需要注意的时,当执行这两个阶段的工作的时候,需要先把整个程序停止也被称为stop the world,然后再进行这两项工作。
当成功区分内存中存活对象和死亡对象后,GC接下来的任务就是执行垃圾回收,释放掉无用对象所占用的内存空间,以便有足够的可用内存空间为新对象分配内存。
目前在JVM中比较常见的三种垃圾收集算法是标记-清除算法(Sweep),复制算法(Copying),标记-压缩算法(Mark-Compact)
背景:标记-清除算法(Mark-Sweep)是一种非常基础和常见的垃圾收集算法,该算法被J.McCarthy等人在1960年提出并应用于Lisp语言。
执行过程:当堆中的有效内存空间(available memory)被耗尽的时候,就会停止整个程序(也被称为stop the world),然后进行两项工作,第一项则是标记(标记的是非垃圾对象也叫做可达对象),第二项则是清除
标记:Collector从引用根节点开始遍历,标记所有被引用的对象。一般是在对象的Header中记录为可达对象。
清除:Collector对堆内存从头到尾进行线性的遍历,如果发现某个对象在其Header中没有标记为可达对象,则将其回收。
缺点:
1.效率不算高
2.在进行GC的时候,需要停止整个应用程序,导致用户体验差
因为标记-清除算法的效率比较低,所以为了解决这个问题,就出现了复制算法。就是使用双倍的内存空间,然后其中一个内存空间是空的,里面没有存放对象,每次垃圾回收的时候就扫描非空内存空间内的全部对象,如果扫描到的对象有被引用,就复制到那个空的内存空间内,最后把最初的那个非空的内存空间整体回收掉,这样时间效率虽然高,但是空间效率比较低,因为使用了双倍的空间,这是典型的用空间换时间的思想。
背景:为了解决标记-清除算法在垃圾收集效率方面的缺陷,M.L.Minsky于1963年发表了著名的论文,“使用双存储区的Lisp语言垃圾收集器CA LISP Garbage Collector Algorithm Using Serial Secondary Storage”。M.L.Minsky在该论文中描述的算法被人们称为复制(Copying)算法,它也被M.L.Minsky本人成功地引入到了Lisp语言的一个实现版本中。
核心思想:将活着的内存空间分为两块,每次只使用其中一块,在垃圾回收时将正在使用的内存中的存活对象复制到未被使用的内存块中,之后清除正在使用的内存块中的所有对象,交换两个内存的角色,最后完成垃圾回收,如下图:
优点:
1.没有标记和清除过程,实现简单,运行高效
2.复制过去以后保证空间的连续性,不会出现"碎片"问题
缺点:
1.此算法的缺点也是明显的,就是需要两倍的内存空间
2.对于G1这种分拆成为大量region的GC,复制而不是移动,意味着GC需要维护region之间对象引用关系,不管是内存占用或者时间开销也不小。
对于复制算法来说,适合运用在存活对象少,垃圾对象多的情景下,这种情况在新生区经常发生,但是在老年代呢?这种算法就不适合了,那我们需要使用哪种算法呢?我们可以使用标记-清除算法,但是这种算法效率太低了,因此我们又创建了一种算法,就做标记-压缩算法,这个算法的执行过程分为三个阶段,第一阶段的标记阶段和复制算法一样,先从根节点开始标记所有被引用的对象;然后第二阶段是把所有被引用的对象移到内存空间的一端,按顺序排放;第三阶段是清理内存空间的另一端中所有的没有被引用的垃圾对象。
背景:复制算法的高效性是建立在存活对象少,垃圾对象多的前提下的。这种情况在新生代经常发生,但是在老年代,更常见的情况是大部分对象都是存活对象。如果依然使用复制算法,由于存活对象较多,复制的成本也将很高。因此,基于老年代垃圾回收的特性,需要使用其它的算法。
标记-清除算法的确可以应用在老年代中,但是该算法不仅执行效率低下,而且在执行完内存回收后还会产生内存碎片,所以JVM的设计者需要在此基础之上进行改进。标记-压缩(Mark-Compact)算法由此诞生。
执行过程:
第一阶段和标记-清除算法一样,从根节点开始标记所有被引用对象。
第二阶段将所有的存活对象压缩到内存的一端,按顺序排放。
之后,清理便捷外所有的空间。
标记-压缩算法的最终效果等同于标记-清除算法执行完成后,再进行一次内存碎片整理,因此,也可有把它称为标记-清除-压缩(Mark-Sweep-Compact)算法。
二者的本质差异在于标记-清除算法是一种非移动式的回收算法,标记-压缩是移动式的。是否移动回收后的存活对象是一项优缺点并存的风险决策。
可以看到,标记的存活对象将会被整理,按照内存地址一次排列,而未被标记的内存会被清理掉。如此一来,当我们需要给新对象分配内存时,JVM只需要持有一个内存的其实地址即可,这比维护一个空闲列表显然少了许多开销。
优点:
1.清楚了标记-清除算法当中,内存区域分散的缺点,我们需要给新对象分配内存时,JVM只需要持有一个内存的起始地址即可。
2.消除了复制算法当中,内存减半的高额代价
缺点:
1.从效率上来说,标记-整理算法要低于复制算法
2.移动对象的同时,如果对象被其它对象引用,则还需要调整引用的地址
3.移动过程中,需要全程暂停用户应用程序。即:STW(stop the world)执行算法的时候需要先停止整个程序运行