好久没有写博客了,深感惭愧,今天聊一下 Java 的内存管理
Java 相比传统语言 (C,C++) 的一个优势在于其能够自动管理内存,从而将开发者管理内存任务剥离开来。 本文大体描述了 J2SE 5.0 release 中 JVM 对于内存是如何管理的。并且为选择和配置对应的收集器,配置收集器的参数提供了一些建议和参考。
内存管理是能够识别哪些释放的对象不再使用,释放掉这些对象所占用空间的一个过程。在很多编程语言中,内存管理是开发者的责任。但是管理内存的任务具有一定的复杂性,会导致很多错误,影响到应用的行为,并且令程序崩溃掉。结果,开发者很大的一部分时间都是在 debug 和修正这些错误。
在手动内存管理中一个经常发生的问题就是 dangling references。很有可能当在释放某个对象占用的空间时,仍然包含其他对该销毁对象的引用,在这个时候,当这些引用指向了新的对象时,运行结果是无法预期的。 另一个常见的问题就是 space leaks。产生泄露的原因在于,当内存被分配了,但是却没有引用的情况下,以后就无法再次释放掉了。举个例子,如果开发者试着释放一个链表,但是写的程序出了点小 bug,值释放了头结点,那么链表中后面的对象就无法找到了。也就再被回收掉。一旦泄露过多,整个内存就会崩溃掉。
而相对于手动管理内存,在面向对象编程语言中,通常使用是自动管理内存技术,也称之为垃圾收集器。自动的内存管理对接口进行了更高层次的抽象。
垃圾收集器解决了 dangling reference 问题,因为如果一个对象还被引用的话,是不会被垃圾收集器回收掉的。同时,垃圾收集器也解决了 space leaks 问题,因为那些泄露的空间,属于没有被引用的对象,会被垃圾收集器回收掉。
垃圾收集器主要有一下一些职责:
有引用对象通常称之为存活的对象。没有引用的对象通常称之为死亡对象,也被认为是垃圾。检索和释放掉死亡对象的过程就称之为垃圾收集。
垃圾收集器解决了很多很多的内存管理问题,但是并不是全部。当然了,开发者可以持续不断的创建对象,并且始终保持对他们的引用,直到没有可用的内存为止。垃圾收集本身也是一个复杂的任务,需要消耗相当的时间和资源的。
关于组织对象,分配和释放空间的算法都是由垃圾收集器处理的,是被隐藏在开发者的视线之外的。空间通常是从一个很大的内存池来释放的,称之为堆。
垃圾收集的调度通常是取决于垃圾收集器本身的。通常来说,整个堆或者堆的子集会在其填充满或者到达一定占比阈值的时候进行垃圾回收。
分配的任务包含在堆中找到一块没有使用的内存,当然,这一任务并不简单。这个动态分配空间的算法主要的问题就是避免碎片,尽量保证分配空间和释放空间的高效。
垃圾收集器必须既保证安全,并且充分理解代码。也就意味着,存活的数据必须不能够被错误的释放,而垃圾不应该在几个回收周期之后,仍然存活。
当然,如果垃圾收集器能够高效的运行,不会在应用正在执行的过程中,进行长时间的停顿,肯定是非常好的。然而,在绝大多数的系统中,通常都会需要在空间,时间,频率上做出权衡的。举个例子,如果堆空间很小的话,垃圾收集的速度会很快,但是堆会更快的充满对象,也会需要进行更为频繁的垃圾收集。相反,如果堆空间配置的较大的话,那么堆充满需要的时间会更久,垃圾收集也不会执行的很频繁,但是单次的垃圾回收需要的时间会更久。
垃圾回收如果能够有效限制分片的话,无疑也是非常好的。当回收掉部分垃圾对象所占用的内存空间之后,空闲的空间可能以小块的形式存在于多个区域的。当出现这种情况时,当再次为一个较大的对象申请空间的时候,可能会无法获得足够的空间。一种消除碎片的方式叫做叫做内存紧缩。
扩展性同样是垃圾收集器所需要的。分配操作不应该成为多进程,多线程应用的扩展性瓶颈,收集操作同样不应该成为瓶颈。
在设计和选择垃圾回收算法的时候,通常需要作出一些抉择:
在考虑垃圾收集器性能的时候,有以下一些方面需要考虑:
交互式的应用需要较低的暂停时间,而总执行时间要比非交互式的应用要求要搞。而实时应用会在垃圾回收的暂停时间和垃圾回收的时间占比上都有较高的要求。而在个人计算机或者是嵌入式系统中,占用空间可能是应用更应该考虑的问题。
当使用了分代收集技术的时候,内存是分成不同的代的,也就是将不同年纪的对象分放到不同的对象池中。举个例子,Java 中最常使用的配置有两个不同的年代:年轻代,老年代,分别用来存放年轻的对象和年老的对象。
在每个不同的代中,可以使用不同的垃圾回收算法,而每个算法可以在其自己的年代中根据该年代的特性进行优化。每一代的垃圾收集器都有如下的一种假设,称之为 weak generational hypothesis,认为多数语言中实现的应用(包括 Java), 有如下特点:
如图所示:
年轻代进行的垃圾回收相对来说,会相对更频繁,并且执行也更迅速,因为年轻代对象通常较小,并且会引用很多生命周期很短的对象。
而一些对象在几次年轻代回收都没有回收掉的话,就会晋升成为老年代对象。如下图:老年代通常比年轻代要大,其占用的增长速度会变慢。所以,老年代垃圾回收不会很频繁,但是回收的时间要更久一些。
为年轻代选择的垃圾回收算法通常会优先考虑速度,因为年轻代的回收通常来说是更频繁。另一方面,老年代考虑的算法通常是更考虑空间的有效性,因为老年代会占用更多的堆内空间,老年代算法需要更好的处理低密度垃圾回收。
J2SE JVM 中包含四种垃圾收集器。所有的垃圾收集器都是分代的。本节描述了回收的分代和类型,以及讨论为何对象的空间分配通常是高效和迅速的。然后为每种垃圾收集器提供了详细的信息。
在 JVM 中,内存被分成三代来管理的,分别是前面提到的年轻代,老年代以及永久代。绝大多数对象都是被初始化到年轻代的。而老年代中包含的对象通常是多次回收都没有回收掉的年轻代对象,以及部分很大的对象,这些对象是直接分配到老年代的。永久代中包含一些对 JVM 方便进行垃圾收集管理的信息,比如描述类和方法的对象,还有类和方法本身。
年轻代包含一个叫做 Eden 的区域和两个稍小的 survivor 区域,如下图。
大多数对象都是直接初始化在 Eden 区域的。(前面提到过,少数很大的对象可能直接分配到老年代的)survivor 空间持有那些至少一次从年轻代垃圾回收下存活的对象。垃圾收集器会给这些对象再进入老年代之前一些机会,让他们在进入老年代之前仍然在年轻代中,可以被回收掉。在任何给定的时间,一个 Survivor 的空间(从图中标记为 From)持有这样的对象,而另一个直到下一次垃圾回收之前都是空的。
当年轻代对象空间慢了,年轻代的垃圾收集就开始了(有的时候,也称之为 minorGC)。当老年代或者永久代对象空间慢了,执行的垃圾回收称之为 majorGC。通常来说,年轻代是优先收集的,使用的回收算法也是根据其年代的特点来特别设计的,因为通常年轻代对垃圾的识别和回收对效率要求更高。老年代的回收算法是同时运行在老年代和永久代的。一旦发生内存压缩,每一代都是分别进行内存压缩的。
有的时候,老年代已经空间不足,无法继续接受年轻代的对象了。在这种情况下,除了 CMS 收集器,全部的手机都不会执行,年轻代的回收算法也不会执行。相反,会在整个堆上使用老年代回收算法。(CMS 老年代算法属于特殊情况,因为它不会对年轻代进行收集)
在很多情况下,内存中都有很大的连续空间用来给对象使用。这些内存块的空间分配是配合简单的 bump-the-pointer 技术是十分高效的。bump-the-pointer 技术就是通过一个指针来跟踪上一次释放对象空间的结尾。当新的分配请求过来的时候,JVM 只需要判断指针和当前代结尾之间的空间是否足够就可以了,如果可以的话,就挪动指针,并且初始化对象。
对于多线程应用来说,分配操作是必须保证线程安全的。如果使用全局锁来保证分配操作是线程安全的,那么分配操作进入某一代将会成为一个性能上的瓶颈。相反,JVM 使用了一个技术叫做 Thread-Local Allocation Buffer 技术(TLABs). 该技术会将分配操作先写入线程本身的缓冲区中,来提高多线程分配操作的吞吐量。因为,一旦每个线程将分配操作写入到自己的缓冲区的话,就可以使用 bump-the-pointer 技术实现快速分配,并且全程是不需要锁来进行阻塞操作的。当然,偶然的情况下,当线程内部的缓冲区已经填满了,无法写入更多的对象的时候,就必须使用同步操作来保证分配的线程安全性了。当然,使用 TLABs 同时也有一些减少空间浪费的技术。TLABs 的空间的浪费平均不到 Eden 区的 1%。使用 TLABs 技术和 bump-the-pointer 技术令分配操作性能很高,大概只需要 10 个本地指令的时间。
当使用串行收集器的时候,无论是年轻代还有老年代的手机,都是串行收集的(使用一个 CPU),收集的过程中,会停止应用的一切执行。
下图展示了年轻代使用串行收集器收集的一些操作。存货的对象从 Eden 拷贝到空的 survivor 空间,也就是图中的
区域,当然,如果对象太大是不会进入到
- TO
区域的,而是直接进入老年代。在 survivor 中
- To
区域的对象中,仍然相对年轻的对象拷贝到
- From
空间,而比较老的对象会进入到老年代。注意,如果
- To
空间满了,没有拷贝到
- To
区域的 Eden 和
- To
区域的对象将直接进入老年代,而不会管这些对象到底经过了多少次年轻代的回收。而其他没有拷贝的
- From
和
- Eden
区域的对象,就不再是存活的对象了。
- From
在一次年轻代收集完成之后,无论是 Eden 还是 survivor 的
区域,就都是空的了,只有 survivor 的
- From
区域还有存活的对象,这个时候
- To
和
- From
两者的职责会调换过来,参考下图:
- To
老年代使用串行收集器收集的算法是 mark-sweep-compact 收集算法,在 mark 阶段,收集器识别出所有的存活的对象。而在 sweep 阶段,会清除垃圾。收集器会执行滑动压缩,将存活的对象依次向老年代空间的起始位置滑动(永久代也一样),而在老年代的末尾处留出较大的连续空间。当回收完毕以后,老年代仍然支持 bump-the-pointer 技术来实现快速分配。参考下图:
串行收集器一般来说只有运行在 Client 端的应用,并且这些应用对于应用暂停时长没有太多的需求的情况下会使用。以今天的设备来说,串行收集器可以在不到半秒的时间内,收集 64M 的堆空间。
J2SE5.0 的发布时间为 2005 年的 6 月,上面的测试结果以当时的硬件性能为准。
在 J2SE 5.0 中,在非服务器的 JVM 中,默认的收集器就是串行收集器。如果使用其他的 JVM 的话,可以通过如下参数来指定使用串行收集器:
- -XX:+UseSerialGC
来源: http://blog.csdn.net/ethanwhite/article/details/70304590