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