Java性能权威指南-总结15
- 堆内存最佳实践
- 对象生命周期管理
- 弱引用、软引用与其他引用
- 小结
堆内存最佳实践
对象生命周期管理
弱引用、软引用与其他引用
在Java中,弱引用和软引用也支持对象重用,不过作为开发者,并不会经常从重用的角度看待它们,并一般性地将其称作非确定引用。这些类引用更多用于缓存一个较长的计算或者一个数据库查询的结果,而非用于重用对象。比如,在股票Servlet中,可以用一个非确定引用来缓存getHistory()
方法(该方法需要很长的计算,或者需要很长的数据库调用)的结果。这个结果只是一个对象,当通过非确定引用来缓存它时,只是简单地重用了该对象,不然的话,初始化开销会很高。
术语说明
引用(Reference)
引用(或者说对象引用)可以是任何类型的引用:强引用、弱引用、软引用等。指向一个对象的普通引用实例变量就是一个强引用。
非确定引用(Indefinite reference)
使用这个术语来区分强引用和其他特殊引用(比如软引用或弱引用)。一个非确定引用其实是一个对象实例(比如,SoftReference类的一个实例)。
所引对象(Referent)
非确定引用的工作方式是,在非确定引用类的实例内,嵌入另一个引用(几乎总是嵌入一个强引用)。被封装的对象称作“所引对象”。
该术语也反映出这样一点:没有人说“缓存”一个线程用于重用,但是将在缓存数据库操作结果方面探索非确定引用的重用。
与对象池或线程局部变量相比,非确定引用的优势在于,它们最终会被垃圾收集器回收。 如果对象池中包含了已经执行的最后10000个股票查询,堆的运行就会变慢,应用也会受牵连:去掉那10000个元素所占据的堆,剩下的就是应用可以使用的其余堆了。如果这些查询是通过非确定引用保存的,JVM就可以释放一些空间(取决于引用的类型),从而获得更好的GC吞吐量。
非确定引用的缺点是对垃圾收集器的效率会有轻微影响。图一对比了不使用与使用非确定引用时内存的使用情况(这里用的是弱引用)。
被缓存的对象占了512字节。在左侧的就是消耗的所有内存(没有指向对象的实例变量占据的内存)。在右侧,对象被缓存在一个SoftReference
内,额外增加了40字节的内存消耗。**非确定引用和其他任何对象一样:它们也消耗内存,而且其他变量(图中右侧的cachedValue变量)也是通过强引用引用它们。**所以对垃圾收集器的第一个影响是,非确定引用会导致应用使用更多内存。对垃圾收集器的更大的影响体现为,垃圾收集器要回收非确定引用,至少需要两个GC周期。
下图说明了当一个所引对象不再被强引用时(即lastViewed
被设置为null
),会发生什么。如果没有对StockHistory
对象的引用,在下一次GC期间,该对象会被释放。所以图二的左侧现在消耗的内存为0字节。
在图的右侧,仍然有内存消耗。所引对象被释放的精确时机,会随非确定引用类型的不同而有所不同,暂时只考虑软引用的情况。所引对象将仍然逗留在内存中,直到JVM确定近期不会再使用它。当这个条件出现时,第一次GC会释放所引对象,但不是非确定引用本身。应用最终的内存状态如图三所示。
现在,对于非确定引用对象本身,(至少)有两个强引用指向它:由应用创建的原始的强引用,再就是由JVM创建的、在所引对象队列上的一个新的强引用。在非确定引用对象本身被垃圾收集器回收之前,必须先清理掉所有这些强引用。
这种代码通常是由处理引用队列的代码处理的。如果在队列上有新对象创建,代码会得到通知,并立即移除指向该对象的所有强引用。之后,在下一个GC期间,非确定引用对象会被释放。最糟糕的情况是,引用队列没有立即被处理,有可能要经过多个GC周期,才能将一切清理干净。然而即便在最好的情况下,非确定引用在释放之前也必须经历两个GC周期。
依赖于非确定引用的类型,处理算法也有较大差异,但是所有的非确定引用某种程度上都有这类性能损失。
- 软引用
如果问题中的对象以后有很大的机会重用,可以使用软引用,但是如果该对象近期一直没有使用到(计算时也会考虑堆还有多少内存可用),垃圾收集器会回收它。软引用本质上是一个比较大的、最近最久未用(LRU)的对象池。获得较好性能的关键是确保它们会被及时清理。
来看一个例子。股票Servlet可以设置一个股票历史的全局缓存,以股票代码(或者代码与日期)为键。比如有请求要获取TPKS从2013年6月1日到2013年8月31日之间的股价历史,可以先看看缓存,其中是不是有以前类似请求的结果。
之所以要缓存数据,原因是对某类数据的请求往往会比其他数据更多。 如果对TPKS这支股票的请求最多,就可以考虑将其保存到软引用缓存中。另一方面,查询一次KENG这支股票,其结果也会在缓存中停留一段时间,但最终会被回收。对于随时间变化的请求,也是如此:对DNLD的一群请求,可以利用第一次请求的结果。如果用户意识到DNLD是笔糟糕的投资,那些缓存的条目最终会从堆中去掉。
精确地讲,一个软引用何时会被释放呢?首先,所引对象一定不能有其他的强引用。如果软引用是指向其所引对象的唯一引用,而且该软引用最近没有被访问过,则所引对象会在下一次GC周期释放。 具体而言,其关系可以用如下伪代码表示:
long ns = SoftRefLRUPolicyMSPerMB * AnountofFreeMemoryInMB;
if (now - last_access_to_reference > ms)
free the reference
这里有两个关键值。第一个是由-XX:SoftRefLRUPolicyMSPerMB=N
标志设置的,默认值为1000。
第二个是堆中空闲内存的数量(在一个GC周期完成之后)。因为堆的大小是动态变化的,在计算堆中有多少内存可用时,JVM有两个选择:堆中目前的空闲内存数量,或者堆扩展到最大容量后的空闲内存数量。这些值的选择是由所用的编译器确定的。client编译器是基于当前堆中的可用值,而server编译器堆的最大可能值。
那这都是怎么工作的呢?以使用server编译器、堆空间为4GB的JVM为例,在一次Full GC(或一个并发周期)之后,堆可能被占用了50%,因此空闲堆是2GB。SoftRefLRUPolicyMSPerMB的默认值(1000)意味着在过去的2048秒(2048000毫秒)内没有访问到的任何软引用都会被清理:空闲堆是2048(MB),再乘以1000:
long ms = 2048000;// 1000 * 2048
if (System.currentTimeMillis() - last_access_to_reference_in_ms > ms)
free the reference
**如果4GB的堆占用了75%,则过去的1024秒内没有访问到的对象会被回收,以此类推。**要更频繁地回收软引用,可以降低SoftReflRUPolicyMSPerNB标志的值。将该值设置为500,意味着堆大小为4GB的JVM如果占用了75%,则会回收过去512秒没有访问到的对象。
如果堆很快就会被软引用填满,则调优该标志往往是必要的。假设堆有2GB空闲,应用开始创建软引用。如果它在不到2048秒(大概是34分钟)创建了1.7GB的软引用,则这些软引用都不满足回收条件。这样,堆中留给其他对象的空间就只有300 MB了;这会导致GC频繁进行(对整体性能影响很坏)。
如果JVM完全耗尽了内存,则会出现非常严重的颠簸(thrashing),它会清理掉所有的软引用,否则会抛出OutofMemoryError
。 不抛出错误当然好,但是不分青红皂白地丢掉所有缓存的结果,可能也不理想。因此,另一个降低SoftRefLRUPolicyMSPerMB的值的时机是,当引用处理日志说明有大量软引用意外被清理时。另一方面,对于长期运行的应用,如果满足如下两个条件,可以考虑增大SoftRefLRUPolicyMSPerMB
的值:
- 有很多空闲堆可用;
- 软引用会频繁访问。
这种情况非常罕见。这与设置GC策略所讨论的情况类似:可以想象,如果增加了软引用策略的值,就是告诉JVM,不到万不得已不要释放软引用。确实如此,但是这同时也告诉JVM,堆中不要给正常操作留任何空间,结果很可能会导致把很多时间花在GC上。
应该注意的是,不要使用太多软引用,因为它们很容易填满整个堆。与提防创建包含太多实例的对象池相比,这一点更应该注意:如果对象的数目不是特别大,软引用就会工作得很好。否则,就要考虑用更传统的、固定大小的对象池来实现一个LRU缓存。
- 弱引用
**当问题中的所引对象会同时被几个线程使用时,应该考虑弱引用。否则,弱引用很可能会被垃圾收集器回收:只有弱引用的对象在每个GC周期都可以回收。**这意味着,弱引用绝对不会进入图二所示的软引用的状态。当强引用被移除时,弱引用会立即释放。因此程序的状态直接从图一到达图三。
这里有个有趣的现象,弱引用会在堆中终结。引用对象就和其他Java对象一样:在年轻代中创建,最终会被提升到老年代。如果弱引用本身仍然在年轻代中,而弱引用的所引对象被释放了,则弱引用可以快速释放(下一次Minor GC时)。如果弱引用的所引对象存在了足够长的时间,被提升到了老年代中,则弱引用在下一次并发或Full GC周期内才会释放。
以股票Servlet的缓存为例,假设知道某个特定用户会在其会话期间访问TPKS,他几乎总会再次访问。在该用户的HTTP会话中,用一个强引用来保存股票的值是有意义的:它会一直存在,一旦用户登出,HTTP会话就会被清理,而内存也会被回收。
如果另一个用户来了,而且也需要TPKS的数据,那如何找到数据呢?因为对象在内存中的某个地方,不希望程序重新去查找,但是Servlet代码不能搜索其他用户的会话数据,因此,除了在第一个用户的HTTP会话中保存一个指向TPKS数据的强引用外,在一个全局缓存中保存一个弱引用,指向那个数据,也是有意义的。现在第二个用户就能查找TPKS数据了,当然这是以第一个用户没有登出并清理会话为前提的。
这就是所谓的同时访问。这就好比是告诉JVM:“嘿,只要有其他人对这个对象感兴趣,就让我知道它在哪儿,但是如果他们不再需要它了,就把它丢弃,我自己会重新创建。”比较弱引用与软引用,软引用基本像是在说:“嘿,只要有足够的内存,而且看上去有人会偶尔访问它,那就保留着它。”
如果不理解这种区别,在使用弱引用时就经常会出现性能问题。不要认为除了释放更快,弱引用和软引用就是一样的,别犯这种错误:软引用的对象通常可以存活几分钟甚至几小时,但是只要所引对象仍然存在,弱引用对象就一直存活。(下一个GC周期会清理。)
快速小结
- 非确定引用(包括软引用、弱引用、虚引用和最终引用)会改变Java对象正常的生命周期,与池或线程局部变量相比,它可以以对GC更为友好的方式实现对象重用。
- 当应用对某个对象感兴趣,而且该对象在应用中的其他地方有强引用时,才应该使用弱引用。
- 软引用保存可能长期存在的对象,提供了一个简单的、对GC友好的LRU缓存。
-
非确定引用自身会消耗内存,而且会长时间抓住其他对象的内存;应该谨慎使用。
小结
内存管理对Java程序的快慢至关重要。调优GC非常重要,但是要获得最好的性能,在应用内必须有效地利用内存。
目前的硬件趋势往往不鼓励开发者考虑内存:编程中通常的时间和空间之间的取舍,有可能会变成时间和空间与时间(time/space-and-time)之间的取舍:使用太多堆空间可能会降低性能,因为需要更多GC。在Java中,管理堆仍然非常重要。
多数管理问题都围绕何时以及如何使用特殊的内存技术展开:对象池、线程局部变量和非确定引用。明智地使用这些技术可以极大改进应用性能,但是过度使用也很容易引起性能下降。在限量的情况下,也就是问题中的对象数目很少,而且有个边界时,使用这些内存技术会非常高效。