学习Java:垃圾回收的几种基本方式

有几种垃圾收集的基本策略:引用计数、标记-清除、标记-整理 (mark-compact) 和复制。此外,一些算法可以以 增量方式完成它们的工作(不需要一次收集整个堆,使得收集暂停时间更短),一些算法可以在用户程序运行时运行(并发收集)。

其他算法则必须在用户程序暂停时一次进行整个收集(即所谓的 stop-the-world收集器)。最后,还有混合型的收集器,如1.2 和以后版本的 JDK 使用的分代收集器,它对堆的不同区域使用不同的收集算法。

[@more @]


1.3 JDK 包括三种不同的垃圾收集策略,1.4.1 JDK 包括六种垃圾收集策略以及 12 种以上用于配置和优化垃圾收集的命令行选项。它们有什么不同?为什么需要有这么多选项?

不同的垃圾收集实现使用不同的策略来识别和收回不可到达的对象,它们与用户程序和调度器以不同的方式互动。不同类型的应用程序对于垃圾收集有不同的要求 ―― 实时应用程序会将要求收集暂停的持续时间短并且有限制,而企业应用程序可能允许更长时间和可预测性更低的暂停以获得更高的吞吐能力。

垃圾收集如何工作?

有几种垃圾收集的基本策略:引用计数、标记-清除、标记-整理 (mark-compact) 和复制。此外,一些算法可以以 增量 方式完成它们的工作(不需要一次收集整个堆,使得收集暂停时间更短),一些算法可以在用户程序运行时运行( 并发收集)。
其他算法则必须在用户程序暂停时一次进行整个收集(即所谓的 stop-the-world收集器)。最后,还有混合型的收集器,如1.2 和以后版本的 JDK 使用的分代收集器,它对堆的不同区域使用不同的收集算法。

在对垃圾收集算法进行评价时,我们可能要考虑以下所有标准:
暂停时间。收集器是否停止所有工作来进行垃圾收集?要停止多长时间?暂停是否有时间限制?
暂停的可预测性。垃圾收集暂停是否规划为在用户程序方便而不是垃圾收集器方便的时间发生?
CPU 占用。总的可用 CPU 时间用在垃圾收集上的百分比是多少?
内存大小。许多垃圾收集算法需要将堆分割成独立的内存空间,其中一些空间在某些时刻对用户程序是不可访问的。这意味着堆的实际大小可能比用户程序的最大堆驻留空间要大几倍。
虚拟内存交互。在具有有限物理内存的系统上,一个完整的垃圾收集在垃圾收集过程中可能会错误地将非常驻页面放到内存中来进行检查。因为页面错误的成本很高,所以垃圾收集器正确管理引用的区域性 (locality) 是很必要的。
缓存交互。即使在整个堆可以放到主内存中的系统上 ―― 实际上几乎所有 Java 应用程序都可以做到这一点,垃圾收集也常常会有将用户程序使用的数据冲出缓存的效果,从而影响用户程序的性能。
对程序区域性的影响。虽然一些人认为垃圾收集器的工作只是收回不可到达的内存,但是其他人认为垃圾收集器还应该尽量改进用户程序的引用区域性。整理收集器和复制收集器在收集过程中重新安排对象,这有可能改进区域性。
编译器和运行时影响。一些垃圾收集算法要求编译器或者运行时环境的重要配合,如当进行指针分配时更新引用计数。这增加了编译器的工作,因为它必须生成这些簿记指令,同时增加了运行时环境的开销,因为它必须执行这些额外的指令。这些要求对性能有什么影响呢?它是否会干扰编译时优化呢?

不管选择什么算法,硬件和软件的发展使垃圾收集更具有实用性。20 世纪 70 和 80 年代的经验研究表明,对于大型 Lisp 程序,垃圾收集消耗 25% 到 40% 的运行时。垃圾收集还不能做到完全不可见,这肯定还有很长的路要走。


基本算法
引用计数

最直观的垃圾收集策略是引用计数。引用计数很简单,但是需要编译器的重要配合,并且增加了赋值 函数 (mutator)的开销(这个术语是针对用户程序的,是从垃圾收集器的角度来看的)。每一个对象都有一个关联的引用计数 ―― 对该对象的活跃引用的数量。如果对象的引用计数是零,那么它就是垃圾(用户程序不可到达它),并可以回收。每次修改指针引用时(比如通过赋值语句),或者当引用超出范围时,编译器必须生成代码以更新引用的对象的引用计数。如果对象的引用计数变为零,那么运行时就可以立即收回这个块(并且减少被回收的块所引用的所有块的引用计数),或者将它放到迟延收集队列中。

引用计数很简单,很适用于增量收集,收集过程一般会得到好的引用区域性,但是出于几个理由,它很少在生产垃圾收集器中使用,如它不能回收不可到达的循环结构(彼此直接或者间接引用的几个对象,如循环链接的列表或者包含指向父节点的反向指针的树)。

跟踪收集器

JDK 中的标准垃圾收集器都没有使用引用计数,相反,它们都使用某种形式的跟踪收集器 (tracing collector)。跟踪收集器停止所有工作(尽管不需要在收集的整个过程中都这样)并开始跟踪对象,从根集开始沿着引用跟踪,直到检查了所有可到达的对象。可以在程序注册表中、每一个线程堆栈中的(基于堆栈的)局部变量中以及静态变量中找到根。

标记-清除收集器

最早由 Lisp 的发明人 John McCarthy 于 1960 年提出的最基本的跟踪收集器形式是 标记―清除收集器,它停止所有工作,收集器从根开始访问每一个活跃的节点,标记它所访问的每一个节点。走过所有引用后,收集就完成了,然后就对堆进行清除(即对堆中的每一个对象进行检查),所有没有标记的对象都作为垃圾回收并返回空闲列表。

标记-清除实现起来很简单,可以容易地回收循环的结构,并且不像引用计数那样增加编译器或者赋值函数的负担。但是它也有不足 ―― 收集暂停可能会很长,在清除阶段整个堆都是可访问的,这对于可能有页面交换的堆的虚拟内存系统有非常负面的性能影响。

标记-清除的最大问题是,每一个活跃的(即已分配的)对象,不管是不是可到达的,在清除阶段都是可以访问的。因为很多对象都可能成为垃圾,这意思着收集器花费大量精力去检查并处理垃圾。标记-清除收集器还容易使堆产生碎片,这会产生区域性问题并可以造成分配失败,即使看来有足够的自由内存可用。


复制收集器
在另一种形式的跟踪收集器 ―― 复制收集器中,堆被分成两个大小相等的半空间,其中一个包含活跃的数据,另一个未使用。当活跃的空间占满以后,程序就会停止,活跃的对象被从活跃的空间复制到不活跃的空间中。空间的角色就会转换,原来不活跃的空间成为了新的活跃空间。

复制收集的优点是只访问活跃的对象,这意味着不会检查垃圾对象,也不需要将它们页交换到内存中或者送到缓存中。复制收集器的收集周期时间是由活跃对象的数量决定的。不过,复制收集器因为要将数据从一个空间复制到另一个空间、调整所有引用以指向新备份而增加了成本。特别是,长寿的对象在每次收集时都要来回复制。


堆整理

复制收集器有另一个好处,活跃对象集会被整理到堆的底部。这不仅改进了用户程序的引用区域性并消除了堆碎片,而且极大地减少了对象分配的成本 ―― 对象分配变成了在堆顶部的指针上增加指针。不需要维护自由列表或者后备列表,或者使用性能最佳或者第一合适的算法 ―― 分配 N 字节就是在堆顶部指针上加 N 并返回前一个值这么简单

为非垃圾收集语言实现了复杂内存管理方案的开发人员可能会对复制收集器中廉价的内存分配感到吃惊 ―― 就是指针加法这么简单。以前的 JVM 实现没有使用复制收集器 ―― 这可能是对象分配是昂贵的这一想法是如此普遍的原因之一,开发人员仍然下意识地假设分配成本与其他语言(如 C)类似,而事实上在 Java 运行时中可能要廉价得多。不但是分配成本减少了,而且对于在下次收集之前成为垃圾的对象,解除分配的成本为零,因为既不会访问也不会复制垃圾对象。


标记-整理收集器

复制算法的性能很优异,但是它有一个缺点是需要两倍于标记-清除收集器所需要的内存。 标记-整理 算法结合了标记-清除和复制,避免了这个问题,代价是增加了一些收集复杂性。与标记-清除类似,标记-整理是两阶段过程,在标记阶段访问并标记每个活跃对象。然后,复制标记的对象,使所有活跃对象被整理到堆的底部。如果每一次收集时进行彻底的整理,那么得到的堆就类似于复制收集器的结果 ―― 在堆的活跃部分与自由部分有明确的界线,这样分配成本与复制收集器相当。长寿的对象趋向于沉在堆的底部,这样就不会像在复制收集器中那样反复复制它们

选择哪一种呢?

那么 JDK 使用了哪种方式进行垃圾收集呢?在某种意义上,使用了所有的方式。早期的 JDK 使用了单线程的标记-清除或者
标记-清除-整理收集器。1.2 及以后的 JDK 使用了混合的方式,称为 分代收集,其中根据对象的年龄将堆分为几个部分,不同的代是用不同的收集算法收集的。分代收集证明是非常高效的,尽管在运行时它需要更多的簿记。

简单介绍了Java对象的内存管理,以及如何会发生内存泄露的。

同时介绍了一些检查内存泄露的工具

[@more@]

Java是如何管理内存的:
Java的内存管理就是对象的分配和释放问题。在Java中,程序员需要通过关键字new为每个对象申请内存空间 (基本类型除外),所有的对象都在堆 (Heap)中分配空间。另外,对象的释放是由GC决定和执行的。在Java中,内存的分配是由程序完成的,而内存的释放是有GC完成的,这种收支两条线的方法确实简化了程序员的工作。但同时,它也加重了JVM的工作。这也是Java程序运行速度较慢的原因之一。因为,GC为了能够正确释放对象,GC必须监控每一个对象的运行状态,包括对象的申请、引用、被引用、赋值等,GC都需要进行监控。

监视对象状态是为了更加准确地、及时地释放对象,而释放对象的根本原则就是该对象不再被引用。

Java使用有向图的方式进行内存管理,如果某个对象 与这个根顶点不可达,那么我们认为这个(这些)对象不再被引用,可以被GC回收。

用有向图的方式,可以消除引用循环的问题,例如有三个对象,相互引用,只要它们和根进程不可达的,那么GC也是可以回收它们的。这种方式的优点是管理内存的精度很高,但是效率较低。另外一种常用的内存管理技术是使用计数器,例如COM模型采用计数器方式管理构件,它与有向图相比,精度行低(很难处理循环引用的问题),但执行效率很高。

Java和c++中的内存泄露比较:

在Java中,内存泄漏就是存在一些被分配的对象,这些对象有下面两个特点,首先,这些对象是可达的,即在有向图中,存在通路可以与其相连;其次,这些对象是无用的,即程序以后不会再使用这些对象。如果对象满足这两个条件,这些对象就可以判定为Java中的内存泄漏,这些对象不会被GC所回收,然而它却占用内存。--无意识的内存保留(很多地方称为无意识对象保留)。

在C++中,内存泄漏的范围更大一些。有些对象被分配了内存空间,然后却不可达,由于C++中没有GC,这些内存将永远收不回来。在Java中,这些不可达的对象都由GC负责回收,因此程序员不需要考虑这部分的内存泄露。

GC:
通常,GC的线程的优先级别较低。JVM调用GC的策略也有很多种,有的是内存使用到达一定程度时,GC才开始工作,也有定时执行的,有的是平缓执行 GC,有的是中断式执行GC。但通常来说,我们不需要关心这些。除非在一些特定的场合,GC的执行影响应用程序的性能,例如对于基于Web的实时系统,如网络游戏等,用户不希望GC突然中断应用程序执行而进行垃圾回收那么我们需要调整GC的参数,让GC能够通过平缓的方式释放内存,例如将垃圾回收分解为一系列的小步骤执行,Sun提供的HotSpot JVM就支持这一特性。

如何检查内存泄露:

目前,我们通常使用一些工具来检查Java程序的内存泄漏问题。市场上已有几种专业检查Java内存泄漏的工具,它们的基本工作原理大同小异,都是通过监测Java程序运行时,所有对象的申请、释放等动作,将内存管理的所有信息进行统计、分析、可视化。开发人员将根据这些信息判断程序是否有内存泄漏问题。这些工具包括Optimizeit Profiler,JProbe Profiler,JinSight , Rational 公司的Purify等。

Optimizeit Profiler版本4.11支持Application,Applet,Servlet和Romote Application四类应用,并且可以支持大多数类型的JVM,包括SUN JDK系列,IBM的JDK系列,和Jbuilder的JVM等。并且,该软件是由Java编写,因此它支持多种操作系统。

Optimizeit系列还包括Thread Debugger和Code Coverage两个工具,分别用于监测运行时的线程状态和代码覆盖面。

当设置好所有的参数了,我们就可以在OptimizeIt环境下运行被测程序,在程序运行过程中,Optimizeit可以监视内存的使用曲线,包括JVM申请的堆(heap)的大小,和实际使用的内存大小。另外,在运行过程中,我们可以随时暂停程序的运行,甚至强行调用GC,让GC进行内存回收。通过内存使用曲线,我们可以整体了解程序使用内存的情况。这种监测对于长期运行的应用程序非常有必要,也很容易发现内存泄露。

在运行过程中,我们还可以从不同视角观查内存的使用情况,Optimizeit提供了四种方式:

* 堆视角。 这是一个全面的视角,我们可以了解堆中的所有的对象信息(数量和种类),并进行统计、排序,过滤。了解相关对象的变化情况。
* 方法视角。通过方法视角,我们可以得知每一种类的对象,都分配在哪些方法中,以及它们的数量。
* 对象视角。给定一个对象,通过对象视角,我们可以显示它的所有出引用和入引用对象,我们可以了解这个对象的所有引用关系。
* 引用图。 给定一个根,通过引用图,我们可以显示从该顶点出发的所有出引用。

在运行过程中,我们可以随时观察内存的使用情况,通过这种方式,我们可以很快找到那些长期不被释放,并且不再使用的对象。我们通过检查这些对象的生存周期,确认其是否为内存泄露。在实践当中,寻找内存泄露是一件非常麻烦的事情,它需要程序员对整个程序的代码比较清楚,并且需要丰富的调试经验,但是这个过程对于很多关键的Java程序都是十分重要的。在Java1.5中增加了enum类型,使用enum类型的一些注意点[@more@]

注意点:
1。所有创建的枚举类型都扩展于 java.lang.Enum. Enum 是在J2SE 5.0 里定义的一个新类, 它本身不是枚举类型.在创建枚举类型时,必须用enum 关键字,不能直接地定义一个继承Enum的类来创建一个枚举类型,尽管所有创建的枚举类型实际上都是Enum 的子类. 
2。枚举类型里定义的每一个值都是枚举类型的一个实例,缺省时都将映射到Enum(String name, int ordinal) 构造函数中.枚举类型可以使用参数为定义一些自己的构造函数。
另外要强调的两点: 
一是这些枚举类型的构造函数都是私有的.它是不能被其它的类或者其它的枚举类型调用的. 而且这个私有修饰符是由编译器自动加的,如果我们定义这些构造函数时,在前面加上public 修饰符, 就会导致编译错误, 
二是变量定义必须在枚举类型值定义之后
3。 枚举类型每一个值都是public, static and final的.也就是说,这些值是唯一的而且一旦定义了是不能被重写或修改.而且尽管在枚举类型每一个值声明时没有出现static关键字, 实际上值都是静态的, 而且我们不能在值前面加上static, public,final 修饰符
4。Switch语句里使用枚举类型时,一定不能在每一个枚举类型值的前面加上枚举类型的类名(case后面的值),否则编译器就会报错
5。 在J2SE 5.0 的java.util 程序包中提供两个新类:EnumMap 和 EnumSet,这两个类与枚举类型的结合应用可使以前非常繁琐的程序变得简单方便.EnumMap 类提供了java.util.Map 接口的一个特殊实现,该接口中的键(key)是一个枚举类型
6。特定于常量的类主体 :
提到枚举类型可以定义自己的函数,其实更进一步,枚举类型的每一个值都可以实现枚举类型里定义的抽象函数

------------------------------------------
enum Size {
Small(0.8),
Medium(1.0),
Large(1.2);
private double pricingFactor; // 符合2.2的要求,变量定义必须在枚举类型值定义之后
Size(double p) {
pricingFactor = p;
}
public double getPricingFactor() {
return pricingFactor;
}
}
---------------------------------------------
enum的预定义方法
完整的方法集合(E 表示枚举类型自身):
* public int compareTo(E e)
* public boolean equals(Object o)
* public final Class<E> getDeclaringClass()
* public int hashCode()
* public String name()
* public int ordinal()
* public String toString()
* public static <T extends Enum<T>> T valueOf(Class<T> enumType, String name)

一些方法看起来很熟悉,而其他一些方法则是特定于 Enum 类的。
compareTo ()、equals() 和 hashCode() 方法是典型的 Object 和 Comparable 方法,其中,compareTo() 报告声明元素的顺序。name() 和 ordinal() 方法返回构造函数参数,而 toString() 返回名称。

getDeclaringClass() 和 valueOf() 方法需要稍多一些解释。getDeclaringClass() 方法类似于 Object 的 getClass() 方法,但它没必要返回相同的类。根据这个方法的 Javadoc 的说明:对于具有特定于常量的类主体的 enum 常量,该方法返回的值可能不同于 Object.getClass() 方法返回的值。CopyOnWrite集合避免了在获取集合的iterator后,进行操作集合发生错误的情况[@more@]

CopyOnWriteArray集合有2个: CopyOnWriteArrayList 和 CopyOnWriteArraySet, 最适合于读操作通常大大超过写操作的情况

------------------------------------------------------------------
copy-on-write 模式:
实质上,这个模式声明了,为了维护对象的一致性快照,要依靠不可变性(immutability)来消除在协调读取不同的但是相关的属性时需要的同步。对于集合,这意味着如果有大量的读(即 get() ) 和迭代,不必同步操作以照顾偶尔的写(即 add() )调用.

----------------------------------------------------------
CopyOnWriteArrayList 和 CopyOnWriteArraySet 类,所有可变的(mutable)操作都首先取得后台数组的副本,对副本进行更改,然后替换副本。这种做法保证了在遍历自身更改的集合时,永远不会抛出 ConcurrentModificationException 。遍历集合会用原来的集合完成,而在以后的操作中使用更新后的集合。

转载于:https://my.oschina.net/liting/blog/422836

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值