文章目录
一、内存分配与释放
谈到内存回收,就需要先简单介绍一下内存分配,内存分配一般涉及静态分配和动态分配,静态内存分配一般不需要释放回收,不讨论。涉及内存回收释放是动态分配这一块,一个程序运行过程中,需要多少内存时不确定的,需要的时候分配内存,不需要的时候也应及时将这部分空间释放掉。比如在c/c++中,这部分内存的管理是由程序员手动干预,如malloc\calloc\realloc\free函数。
当应用程序变得复杂后,这部分内存的管理也会变得复杂起来,如果内存因为某种原因没有得到及时的释放,有可能造成内存的挤压,最终无内存可用,无法继续分配内存,直至应用程序crush,这一过程称之为内存泄漏。
为了尽可能避免这样的情况发生,内存自动管理机制问世,其中最重要的就是垃圾收集。
二、垃圾收集技术
内存垃圾回收是一个独立的技术方向,只是Java虚拟机对其做了实现而已,使得Java语言天生具有自动内存管理的优势,不同虚拟机的垃圾回收实现可能都不一样,具体需要根据实际的情况讨论,具体实现留给下一篇文章。
垃圾回收需要关注的三个问题:
- 哪些内存需要回收
- 什么时候回收
- 如何回收
在java虚拟机中,虚拟机栈,本地方法栈,程序计数器这部分内存无需回收。一般内存回收指的是堆内存和方法区回收,由于大量的对象实例分配都是在堆中分配的,而这部分内存则成为了内存分配的重中之重,方法区回收一般是一些类型的卸载,以及常量池的回收(1.8之前),回收条件比较苛刻。
1.堆内存回收
回收堆内存主要是针对对象实例占用空间的回收,对这部分空间的回收问题就转换成了如何判断对象实例是否存活,再进一步,如何确定当前的对象没有被任何内存区域所用
(1)引用计数法
引用计数法的原理相对简单:在每一个对象头中添加一个引用计数器,每次被引用,计数器加一,引用消失时,则引用减一。当计数器为0的时候,则表示当前对象没有被任何内存所引用。使用该方式虽然实现比较简单,内存消耗也不高,但是目前的所有java虚拟机都没有采用这种方法来决定对象是否需要回收。越简单的设计需要考虑的额外情况越多,比如没有办法解决循环引用的问题,例:
class Demo
{
void t_method
{
A a = new A();
B b = new B();
a.ref = b;
b.ref = a;
a = null,b = null;
//gc...实际对象已经没有用,但是在堆中,哥俩还在互相掐架
}
}
(2)可达性分析算法
当前主流的虚拟机都是采用可达性算法来判断对象是否存活
算法基本思路:选定一些列称为GC Roots的根节点作为起始节点,从这些节点根据引用向下遍历,有点像无规则的多路树,所有走过的路径称之为:“引用链”,对于任意一个节点,如果gc roots到该节点不可达时,则表示该对象没有对象引用,可以作为垃圾回收的对象。
如上图所示,红色节点到gc roots不可达,这部分对象是可回收的。;
GC root节点是固定的,一般会选用以下对象作为gc root:
- 在虚拟机栈中引用的对象
- 方法区中类静态属性引用的对象
- 方法区中常量引用的对象
- 本地方法栈中JNI引用的对象(JNI:Java Native Interface 调用c/c++接口会用到这玩意)
- java虚拟机内部的引用,eg:包装类,基本异常类,系统classloader
- 所有被同步锁持有的对象(synchronized)
- 反映java虚拟机内部情况的JMXBean JVMTI中注册的回调,本地代码缓存
除了以上固定的之外,根据不同的垃圾回收器和不同的回收内存区域还可以有其他对象临时性的加入,共同构成完整的gc roots set。换句话说,不同虚拟机的设计实现不同,内存回收设计也可能差别,哪些节点作为gc roots需要根据实际的设计来决定。
JDK1.2之后Java细化了引用的类型:
1,传统引用,即Object o = new Object(),也叫强引用
2,软引用,非必须对象,只有在内存不足的情况下才会回收这部分内存
3,弱引用,只能活到下一次gc
4,虚引用,不会对对象生存时间构成影响,只是为了对象被回收时收到通知
如果一个对象在可达性分析中不可达,该对象未必一定会被回收,如果对象重写了finalize()方法,虚拟机会调用该方法进行回收前的“准备”工作,如果在这个方法中为对象引用重新赋值,这个对象将会被移除回收队列,finalize()一个对象只会被调用一次。
2.方法区回收
方法区是否实现gc由虚拟机实现决定,这部分内存的回收主要是针对类型卸载、和常量池回收(假设常量池实现在方法区的话),且回收条件比较苛刻,只有在大量使用字节码生成技术的应用中才有用武之地,如动态代理,反射,JSP,OSGi等。
- 判断一个常量是否需要被回收:是否存在堆内存中的对象还在引用常量池中的字面量
- 判断一个类型是否需要被回收:
=>
1.该类所有相关的实例全部已经被回收,其中包括派生类
2.加载该类的类加载器已经被回收
3.该类对应的Class对象没有被其他任何地方引用
三、垃圾回收算法
1.分代回收理论
大多数垃圾回收器都继承了分代回收的理论,所谓分代回收即是根据不同对象的生命周期不同,存活时长不同,将他们划分在不同的内存区域中,根据不同的区域设计不同的回收方式,从而使得垃圾回收器在整体上获得不错的性价比。
两个假说:
- 弱分代假说:绝大多数对象都是朝生夕灭的
- 强分代假说:熬过越多次垃圾回收过程的对象就越难以消亡
一般会将Java堆划分为新生代和老年代两个区域,在新生代中,每次gc都会有大量的对象死去,只能存活少量对象,而老年代中的对象相对比较顽固,大多数情况下不会被轻易回收。新生代中每次gc后活下来的对象,年龄都会随之增长,随着新生代存活的对象年龄的增长,成年之后会被移动到老年代中存活。
仅仅通过划分区域堆内存进行回收是存在一些问题的,即对象不是孤立的,对象之间可能会存在跨代引用,新生代对象完全有可能被老年代中的对象引用,这个时候就需要遍历整个老年代继续分析可达性,反过来同理,这会对虚拟机造成额外的性能负担,故为此,提出了第三条假说,跨引用假说:跨代引用相对同代引用仅占少数。
解决这类问题的通用思想就是:空间换时间,即维护一个合理的数据结构,能够实现快速查找定位,避免全表遍历情况出现。
具体实现就是将老年代内存划分为若干个内存块,同时在新生代内存中设计一个集合(Remembered Set)能够记录这些内存块中是否存在跨代引用,这样存在跨代引用的情况下,只需要遍历存在跨代引用的内存块即可,毕竟只有极少数存在跨代引用。这个过程相当于将老年代的空间细粒度化,减少了遍历的对象域。需要注意的是,对象引用关系变化时需要维护集合的正确性,也会增加性能损失,但相对遍历整个老年代还是要划算一些。
根据分代回收原则分为:
- 部分回收(Partial GC):即对堆内存中部分区域进行回收动作,其中又分为:
1.Minor GC:新生代的垃圾回收
2.Major GC:老年代的垃圾回收
3.Mixed GC:混合回收,指整个新生代和部分老年代的垃圾回收 - 整堆回收(Full GC):整个Java堆和方法区的回收
2.三种垃圾回收算法
1.标记-清除算法
名如其意,即对每个对象进行标记,然后进行清除。标记出需要被回收的对象后,统一回收这部分空间。
使用该算法缺点是显而易见的,第一,当需要回收的对象比较多,会消耗大量资源去完成标记,清除过程。第二就是内存碎片比较严重,如果后续有比较大的对象要分配空间,没有办法找到连续的整块内存分配,只能再次触发gc,再有碎片,再gc… 向上链接成连续内存?增加复杂度,性能呢?收手吧,阿祖。
2.标记-复制算法
为了解决标记算法的缺点,出现了这种改进算法,原理是将内存等分为二,每次只是用其中的一块内存,当这块内存用完,就将这块内存还存活的对象一次性复制到另外一块上,然后再把原来的内存清理掉,这个问题解决了标记-清理算法的资源消耗过大问题,同时也不会产生内存随便。但缺点也是显而易见的,第一就是内存浪费,每次只能使用其中的一半。第二是内存的复制,虽然只是少了内存的复制,但是由于内存只能使用一半,自然也会增加内存回收的频率,频繁的内存复制也是不小的开销。
为了尽可能的优化以上的问题,需要对内存划分重新设计,由于新生代中的对象大多数都熬不过第一轮gc,那么内存划分可以不必按照1:1的原则。具体做法是将内存划分为一块较大的Eden区和两块较小的Survivor区,比例是8:1:1。发生gc时,将Eden和Survivor中存活的对象复制到另外一个Survivor中,然后清除掉Eden和已经使用掉的Survivor。通过以上划分,每次新生代可分配内存变成了整个内存的90%,可以容纳更多的对象分配,其次,只有10%的空间是被浪费掉的,相对于1:1划分,更加合理。但是这其中还有一个问题,每次存活的对象一定不会超过10%吗?这个谁也没办法保证,这个时候就需要增加兜底方案了,如果存活对象超过10%,超过部分则由其他内存区域进行担保分配,大多数情况下都是老年代。
3.标记-整理算法
以上算法一般都是针对新生代设计的算法,而针对老年代显然上述算法并不适用,即使是优化过后的标记-复制算法。由于老年代对象回收率较低,也就是大部分对象都是存活的,如果采用标记-复制算法,那么内存拷贝是一笔较大的开销,而且需要设计额外且更大的内存来处理对象的担保分配。
所以不同的内存回收区域,需要设计不同的算法来实现对象回收,针对老年代区域,设计了标记-整理算法,字面意思很好理解,即先对对象进行标记,然后将存活对象整理至内存的一端,最后清理掉存活对象边界以外的对象,
这种算法相对于标记-清除算法,虽然解决了内存碎片的问题,但是会产生大量的对象移动,尤其是在老年代中大量对象都存活的情况下,每次移动都需要重新更新对象的引用关系,而且这个过程不能和用户线程并发进行,整个过程中必须停止所有用户线程,但是如果像标记-清除算法那样,不移动对象,产生大量的内存碎片,只能通过更复杂的内存分配器和内存访问器来解决,无非就是将内存进一步在逻辑上虚拟,但是同样也会降低内存访问速度,从而降低应用程序的吞吐量。即使不移动对象会使得内存回收效率提高一些,但是内存分配频率远比垃圾回收频率高的多,这部分耗时的增加,总吞吐量自仍是下降的。
总结
现在看起来,三种内存回收算法看起来都无法做到完美,都有各自的“遗憾”。碎片化问题,内存拷贝,效率,尤其是用户线程停顿问题(Stop The World),成为了各个垃圾回收器设计中需要重点关注的key point。