Java内存管理

来源:互联网 发布:mac电脑如何制作铃声 编辑:程序博客网 时间:2024/05/02 04:22

垃圾收集需要考虑的三个问题:

1、哪些内存需要回收?
2、什么时候回收?
3、如何回收?
下面是Java虚拟机运行时数据区示意图:
这里写图片描述
Java内存运行时区域的各个部分,程序计数器、虚拟机栈、本地方法栈3个区域随线程生灭;栈中的栈帧随方法的进入和退出有条不紊的进栈出栈。这几个区域的内存分配和回收具备确定性,不需要过多考虑回收问题。
Java堆方法区(永久代),只有在程序运行期才能知道会创建哪些对象,这部分内存的分配和回收是动态的,垃圾收集器需要关注的是这部分内存。其中永久代的回收条件非常苛刻,在方法区进行垃圾回收“性价比”比较低,Java虚拟机规范明确说明可以不要求虚拟机在方法区实现垃圾收集。我们讨论的垃圾收集是在Java堆的内存回收。

1、引用计数算法

给对象添加一个引用计数器,对象被引用时,计数器加1;当引用失效时,引用计数器减1;任何时刻计数器为0的对象就代表不再被使用的“死”对象,需要被回收
引用计数算法实现简单,判定高效,C++智能指针、Objective-C的ARC机制等内存管理是使用这种算法管理内存。但是这种算法有个很大的缺点是循环引用问题,例如:objA与objB两个对象,已经不被任何其他地方使用,本应被回收;然而这两个对象相互持有,导致计数不为0,收集器无法回收它们。
引用计数循环引用问题
循环引用问题,需要程序员时刻注意,在出现循环引用的地方,其中一个对象使用弱引用不让引用计数加1。弱引用可以解决该问题,但是需要程序员时刻注意。因此Java虚拟机没有选用引用计数算法进行内存管理。

2、可达性分析算法

主流的商业程序语言(Java、C#)都是通过可达性分析来判断对象是否存活的。这种算法的基本思想是通过一系列“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链。如果一个对象到GC Roots没有任何引用链相连,则说明此对象不可用。整个可达性分析期间,整个执行系统需要”冻结”在某个时间点,避免出现分析过程中对象的引用关系还在不断变化的情况,如果不满足的话发分析结果的准确性无法得到保证,这是导致GC进行时必须停顿所有Java线程的重要原因
可达性分析示意图
Java语言中,可以作为GC Roots的对象包括下面几种:
(1)虚拟机栈的本地变量表中引用的对象;
(2)方法区中类静态属性引用的对象;
(3)方法区中常量引用的对象;
(4)本地方法栈中JNI引用的对象。
即使在可达性分析算法中不可达的对象,也不是立即回收,这时候还处于“缓刑”阶段,要真正宣告对象死亡,至少要经历两次标记。
如果对象没有到GC Roots的引用链,它会被进行一次标记筛选,如果对象有覆盖finalize()方法,并且没有执行过该方法,那么这个对象会被放置到一个叫做F-Queue的队列中,并且由虚拟机自建的低优先级finalizer线程去执行它finalize()。finalize()方法是对象逃脱死亡命运的最后一次机会,GC会将F-Queue队列中的对象进行第二次标记,如果对象要在finalize()方法中拯救自己,只需要重新与引用链建立连接,如果对象这时候还没有逃脱,就真的被回收了。

/* * 演示 对象在finalize()方法中自救(不建议这么使用) */public class FinalizeEscapeGC{    public static FinalizeEscapeGC saveMyself = null;    public void isAlive() {        System.out.println("我还活在!");    }    @Override    protected void finalize() throws Throwable{        super.finalize();        System.out.println("finalize() 方法被执行,我来拯救自己!");        FinalizeEscapeGC.saveMyself = FinalizeEscapeGC.this;    }    public static void main(String[] args) throws InterruptedException {        FinalizeEscapeGC.saveMyself = new FinalizeEscapeGC();        //对象第一次拯救自己,拯救成功        FinalizeEscapeGC.saveMyself = null;        System.gc();        Thread.sleep(100);        System.out.println("第一次成功拯救自己");        if (FinalizeEscapeGC.saveMyself != null) {            FinalizeEscapeGC.saveMyself.isAlive();        }else {            System.out.println("FinalizeEscapeGC 对象已死");        }        //对象第二次拯救自己,拯救失败        FinalizeEscapeGC.saveMyself = null;        System.gc();        Thread.sleep(100);        System.out.println("第二次拯救自己失败");        if (FinalizeEscapeGC.saveMyself != null) {            FinalizeEscapeGC.saveMyself.isAlive();        }else {            System.out.println("FinalizeEscapeGC 对象已死");        }    }}

两段一样的代码,执行结果是第一次逃脱成功,第二次失败,因为任何一个对象的finalize()方法都只会被执行一次。finalize()自救的方法不建议使用,因为它运行代价高,不确定性大,无法保证各个对象的调用顺序。

3、垃圾回收算法

3.1 标记-清除算法

该算法分为“标记”、“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后统一回收被标记的对象。
存在问题:1、效率问题,标记与清除的效率都不高(地址空间分散,需要执行多次清理操作);2、空间问题,标记清除后存在大量不连续的内存碎片。
标记-清除算法

3.2 复制算法

将内存按容量均分为两块,每次只使用其中一块。当一块内存用完,就将还存活的对象复制到另一块,然后把使用过的内存块一次清理掉。这样每次都是半块内存回收,内存分配时就不用考虑内存碎片的问题,实现简单,运行高效,但是内存利用率不高,按1:1分配时只有50%的内存被有效使用,适用于每次整理只有少量对象存活的情况。
这里写图片描述

3.3 标记-整理算法

标记整理算法与标记清理算法类似,但后续不是直接对可回收对象清理,而是让所有存活对象向一端移动,然后清理端边界以外的内存。
这里写图片描述

3.4 分代收集算法

从内存回收的角度来看,收集器基本都采用分代收集算法。Java堆可细分为:新生代和老年代。新生代每次垃圾收集都发现大批对象死去,只有少量存活,选用复制算法,只需要付出少量存活对象的复制成本就可以完成复制。老年代中因为对象存活率高,使用复制算法效率会很低,而且没有额外空间对它进行分配担保,就必须使用标记清理算法或者标记整理算法进行回收。
新生代中的对象绝大部分“朝生夕死”,使用复制算法时并不需要1:1划分内存空间,而是将内存分为一块较大的Eden空间和两块较小的Survivor空间(比例8:1:1)。这样划分的目的是更好地回收内存,或者更快地分配内存。
每次使用Eden和其中一块Survivor。当回收内存时,将Eden和Survivor中还存活的对象一次性复制到另一个Survivor上,最后清理掉Eden和刚才用过的Survivor空间。当Survivor空间不足时,需要依赖老年代进行分配担保。如果另一块Survivor空间没有足够的空间存放上一次新生代存活的对象,这些对象将直接通过分配担保机制进入老年代。
这里写图片描述

4、内存分配与回收策略

新对象优先在Eden区分配,当Eden区空间不足时,将触发一次Minor GC(新生代GC)。
大对象直接进入老年代,这样做的目的是避免在Eden区及两个Survivor区之间发生大量的内存复制。
长期存活的对象将进入老年代,对象在Survivor每熬过一次MinorGC,年龄+1,当年龄增加到一定程度(默认15),就会晋升到老年代中。
老年代GC(MajorGC/Full GC),出现Full GC通常至少会伴随一次MinorGC,MajorGC的速度一般会比MinorGC慢10倍以上。

总结

Java内存回收主要关注Java堆,为了内存管理的方便,一般将Java堆分为新生代、老年代。
新生代的特点是大部分对象“朝生夕死”存活率低,适合使用复制算法。将新生代分为一个较大的Eden区和两个较小的Survivor区,分配比例一般为8:1:1,这样新生代最大可用内存为新生代容量的90%,我们没有办法保证每次回收都只有少于10%的对象存活,当Survivor区空间不够保留存活对象时,依赖其他内存(老年代)进行分配担保。长期存活的新生代对象,达到一定“年龄”后会进入老年代中。
老年代中的对象存活率高,复制算法需要进行较多的复制操作,效率将会变低,而且需要分配担保,因此复制算法不适用与老年代,而是选用标记整理算法。老年代GC速度一般比新生代慢10倍以上,但是老年代一般不会频繁回收内存。
我们进行Java编程时,尽量不要分配短命的大对象,这样会频繁触发老年代GC,严重影响运行效率。能使用普通类型,不要使用类类型,例如表示整数,尽可能使用int类型,而不是Integer类。Java堆也不是越大越好,Java堆越大,GC的频繁降低,但是单次GC的耗时大大增加,影响用户体验。

原创粉丝点击