垃圾收集(Garbage Collection ,GC),是一个长久以来就被思考的问题,当考虑GC的时候,我们必须思考3件事情:
哪些内存需要回收?
什么时候回收?
如何回收?
那么在Java中,我们要怎么来考虑GC呢?首先回想以下内存区域的划分,其中程序计数器、本地方法栈、虚拟机栈三个区域随线程而生,随线程释放,栈中的栈帧随着方法的进入和退出执行着出栈和入栈的操作,每一个栈帧分配多少内存基本是在类结构确定时就已经固定的(可能会进行一些优化,但是大体上已知),因此这几个区域就不需要考虑回收的问题,因为方法结束或者线程结束时,内存自然都被回收。不需要额外的GC算法等。
然而Java堆和方法区则不一样,一个接口所对应的多个实现类所需要的内存可能不一样,一个方法中的多个分支所需要的内存也可能不一样,我们只有在程序处于运行期间才能知道程序需要创建那些对象,这部分的内存的分配和回收是动态的,因此,垃圾收集器关注的是这方面的内存。
一. 如何确定对象可以回收
1.引用计数算法
最容易想到与理解的算法,即对于每一个对象,每当该对象被引用时,计数器值就+1,引用失效时,计数器就-1。因此,当对象的引用计数为0时,即为不可再被使用的。该算法也在一些领域被使用来进行内存管理,但是JAVA虚拟机中并没有选用该算法。主要是因为不能很好的解决循环引用的问题。
举个简单的例子来说明循环引用:1
2
3
4
5
6
7
8
9
10class Container{ public Object obj ;
}public class ReferTest { public static void main(String[] args){
Container c1 =new Container();
Container c2 =new Container();
c1.obj = c2 ;
c2.obj = c1 ;
c1 = null ;
c2 = null ; //此时c1 c1会被判定为死亡对象么? }
}
事实上会被判定为死亡对象,因为JAVA虚拟机不是采用引用计数来进行判断的,因此如果发生垃圾回收,c1,c2 都会被回收内存。
2.可达性分析
Java、C#的主流实现都是采用该种方式,来判断对象是否存活。
这个算法的基本思路就是一系列“GC Roots”作为起始点,从这些节点向下搜索,搜索到的所有引用链中的对象都是可达的,其余的对象都是不可达的,如上例,即使c1,c2互相引用,但是c1,c2都不属于GC Roots对象,因此都不可达。
Java中,以下几种对象可以作为GC Roots:
虚拟机栈(栈帧中的本地变量表)中引用的对象。
本地方法栈JNI方法引用的对象。
方法区类的静态属性引用的对象。
方法区常量引用的对象。
3.引用的分类
了解了GC Roots之后,我们可能会希望存在这么一种对象,内存够的时候不进行回收,当需要内存时再将其回收。JDK 1.2 中对引用进行了扩充。将引用分为了4种,从强到弱依次为;
强引用(Strong Reference)
我们一般情况下使用的都是强引用,如Object o = new Object(),之类的代码。只要强引用还在,垃圾收集器就永远不会回收被引用的对象。
软引用(Soft Reference)
SoftReference类来实现,用来描述一些还有用但是不必须的对象,在系统如果不回收就会发生OOM时才会对软引用进行内存回收。
弱引用(Weak Reference)
WeakReference类来实现,描述非必需的对象,强度弱,只能活到下一次发生垃圾回收前,无论那时内存是否短缺,都会对软引用对象进行内存回收
虚引用(Phantom Reference)
PhantomReference类实现,不会对生存时间发生任何影响,唯一目的时能在这个对象被收集器回收时得到一个通知。
4.其他
及其不建议使用finalize()方法,虽然可以在回收时被调用,但是finalize()方法的执行代价高昂,不确定性大,无法保证各个对象的调用顺序。使用finalize()能做的工作,使用try()finally()或其他方式可以执行的更好。大家可以忘记JAVA中有这个方法的存在。本身就是在JAVA刚诞生时向C/C++程序员做的妥协,但是未得到优化。
方法区(永久代)进行GC的效率极低,花费较大,但是在大量使用反射、动态代理等场景都需要虚拟机具备类卸载的功能,以保证永生代的空间。
二.垃圾收集算法
1.标记清除算法(Mark-Sweep)
算法分为两个阶段,标记与清除。
标记阶段:标记出所有需要回收的对象。回收阶段:将所有标记区域回收。由于该算法不对空间进行整理,因此会产生大量的内存碎片,内存空间碎片过多会导致在分配较大的对象时,因为没有连续的内存而不得不提前触发一个GC。另外,标记与清除的过程效率都不高。这也是最基础的GC算法。
2.复制算法(Copying)
将内存的总容量分为两块,每次只使用其中的一块,当这一块用完了,触发GC,此时将还存活的对象转移到另一块内存中,之前使用的那一块内存完全清理掉。这样每次对一个半区进行回收,也不会存在内存碎片,实现简单,运行高效,但是一次只能使用半块内存可能会造成浪费。
在新生代中,绝大部分的对象时“朝生夕死”的,因此,不需要按照1:1来划分空间。而是将内存分为一块较大的Eden区以及两个Survivor区,HotSpot虚拟机中,Eden:Survivor=8:1 ,每次使用一个Eden区以及一个Survivor区,90%的空间,触发GC后,将剩余的对象转移到未使用的Survivor中,然后清理Eden区和用过的Survivor区,空间不够时,会担保分配到老年代。这样一次可以使用90%的内存空间,极大的提高了内存的使用率。因此,新生代一般采用这种算法来回收。
3.标记整理算法(Mark-Compact)
如果回收时空间内的对象存活率较高,那么使用复制算法一次只能使用50%的空间(以应对所有对象都存活的情况),因此老年代采用标记整理算法。先对需要清理的对象进行标记,然后将存活的对象都向一端移动,直接清理掉端边界以外的内存。这种方式也不会留下内存碎片。
标记整理算法没有复制算法快。
三. Java垃圾收集器
(了解即可,需要时可以网上细查)
新生代收集器:Serial收集器、ParNew收集器(Serial的多线程版本)、Parallel Scanvenge收集器(控制吞吐量,提高相应速度)
老年代收集器:Serial Old收集器、Parallel Old收集器、CMS收集器(最短停顿)、G1(新生代、老年代都可回收)
四. 内存的分配与回收
新生代:即复制算法中提到的Eden区以及2个Survivor区。
老年代:新生代存活足够长时间后进入老年代。堆上的另一块区域。
Minor GC:发生在新生代的垃圾收集动作。因为Java对象存活时间一般较短,故Minor GC非常频繁,一般回收速度也较快。
Full GC:发生在老年代的垃圾收集动作,伴随着最少一次的Minor GC,且速度较慢(比Minor GC慢10倍以上)
1.空间的分配
1)对象优先在新生代Eden区分配。当Eden区没有足够空间时,将发动一次Minor GC.
2)较大对象需要连续的空间,如长字符串或数组,如果放在新生代会提前触发GC。故大对象直接进入老年代区域,避免频繁的GC。
3)长期存活的对象进入老年代,每个对象有一个年龄,在对象头Mark Word中记录,刚被创建时年龄为0,当它活过一次Minor GC,并且转移到Survivor中,年龄变为1,此后,在Survivor区中每活过一个Minor GC,年龄就会+1,当年龄达到某个程度(默认为15),就会晋升到老年代。
4)此外,为了适应内存的复杂情况,年龄不一定达到规定值才能进入老年代。当Survivor区的相同年龄所有对象大小大于Survivor区大小的一半时,此年龄就会被作为判定标准,大于等于该年龄的都会进入老年代。
2.空间的回收–GC
这里我用一张图来彻底解释清除:
需要解释的地方有:担保失败,这个的作用在图上已经解释的很清楚了,可以在JVM参数设置。
另外一个地方就是平均大小来作比较,因为有多少对象晋升到老年代是无法知道的,所以只好取之前每一次晋升到老年代的对象的容量的平均值大小来作为经验值,来决定是否进行Full GC来让老年代腾出更多空间。如果仍然失败,那么只能进行一次Full GC。在我个人开来,之所以使用担保,经验值来尽可能的只进行MinorGC,所有的一切,都是为了尽可能不执行Full GC的情况下将需要申请的内存空间搞定。
至于Full GC 和Minor GC的具体操作,请参考之前的标记整理算法和复制算法。