本文于2015年11月11日存档。其内容不再更新或维护。 按“原样”提供。 鉴于当今技术的飞速发展,某些步骤和插图可能已更改。
在Java技术的早期,分配对象的方式很糟糕。 有很多文章(包括该作者的一些文章)建议开发人员避免不必要地创建临时对象,因为分配(以及相应的垃圾收集开销)非常昂贵。 尽管这曾经是一个很好的建议(在性能非常重要的情况下),但它通常不再适用于所有对性能至关重要的情况,而是最重要的情况。
分配多少钱?
1.0和1.1 JDK使用标记清除收集器,该收集器对某些(但不是全部)收集进行压缩,这意味着堆可能在垃圾收集之后被碎片化。 因此,1.0和1.1 JVM中的内存分配成本与C或C ++中的内存分配成本相当,在C或C ++中,分配器使用启发式方法(例如“ first-first”或“ best-fit”)来管理可用堆空间。 取消分配成本也很高,因为标记清除收集器必须在每次收集时清除整个堆。 难怪我们被建议在分配器上轻松一些。
在热点的JVM(太阳JDK 1.2和更高版本),事情变得好多了-太阳的JDK移动到代收集器。 因为复制收集器用于年轻一代,所以堆中的可用空间始终是连续的,因此可以通过简单的指针添加来完成从堆中分配新对象的过程,如清单1所示。 Java应用程序比C语言便宜得多,许多开发者一开始就很难想象。 同样,由于复制收集器不会访问无效对象,因此具有大量临时对象的堆(这在Java应用程序中很常见)的收集成本非常低; 只需跟踪并将活动对象复制到幸存者空间,然后一举收回整个堆。 没有空闲列表,没有块合并,没有压缩-只需清除堆并重新开始。 因此,在JDK 1.2中,每个对象的分配和释放成本都下降了。
清单1.连续堆中的快速分配
void *malloc(int n) {
synchronized (heapLock) {
if (heapTop - heapStart > n)
doGarbageCollection();
void *wasStart = heapStart;
heapStart += n;
return wasStart;
}
}
性能建议通常具有较短的保质期。 分配曾经是昂贵的,但现在情况已不再如此。 实际上,它非常便宜,并且有一些非常耗费计算资源的异常,因此,性能考虑通常不再是避免分配的好理由。 Sun估计分配成本大约为10条机器指令 。 这几乎是免费的-绝对没有理由为了消除一些对象创建而使程序的结构复杂化或招致额外的维护风险。
当然,分配只是故事的一半-分配的大多数对象最终都是垃圾回收,这也有成本。 但是那里也有个好消息。 在下一次收集之前,大多数Java应用程序中的绝大多数对象都会变成垃圾。 小型垃圾回收的成本与年轻一代中活动对象的数量成正比,而不是自上次回收以来分配的对象数量。 由于只有很少的年轻对象能够存活到下一个集合,因此每次分配的集合摊销成本相当小(并且可以通过增加堆大小而变得更小,取决于可用内存的可用性)。
但是等等,它会变得更好
JIT编译器可以执行其他优化,从而可以将对象分配的成本降低到零。 考虑清单2中的代码,其中的getPosition()
方法创建一个临时对象来保存点的坐标,并且调用方法短暂地使用Point
对象,然后将其丢弃。 JIT可能会内联对getPosition()
的调用,并且使用一种称为转义分析的技术,可以识别出没有对Point
对象的引用离开doSomething()
方法。 知道了这一点,JIT便可以在堆栈上而不是堆上分配对象,或者甚至更好地完全优化分配,只需将Point的字段提升到寄存器中即可。 尽管当前的Sun JVM尚未执行此优化,但将来的JVM可能会执行。 将来分配会变得更便宜,而无需更改代码这一事实,这只是不减少程序的正确性或可维护性的又一个原因,从而避免了一些额外的分配。
清单2.转义分析可以完全消除许多临时分配
void doSomething() {
Point p = someObject.getPosition();
System.out.println("Object is at (" + p.x, + ", " + p.y + ")");
}
...
Point getPosition() {
return new Point(myX, myY);
}
分配器不是扩展性瓶颈吗?
清单1显示,虽然分配本身很快,但是必须在线程之间同步对堆结构的访问。 那么这是否会使分配器具有可伸缩性危害? JVM有许多巧妙的技巧可以大大降低这种成本。 IBM JVM使用一种称为线程本地堆的技术,通过该技术,每个线程向分配器请求一小块内存(约1K),并从该块中满足小对象分配。 如果程序请求的块大于使用小线程局部堆所不能满足的块,则使用全局分配器直接满足请求或分配新的线程局部堆。 通过这种技术,可以在不争夺共享堆锁的情况下满足很大比例的分配。 (Sun JVM使用类似的技术,而不是术语“本地分配块”。)
终结者不是你的朋友
与没有终结器的对象相比,带有终结器的对象(具有非平凡的finalize()
方法的对象)具有大量开销,因此应谨慎使用。 可完成终结的对象分配较慢且收集较慢。 在分配时,JVM必须向垃圾收集器注册任何可终结对象,并且(至少在HotSpot JVM实现中)可终结对象必须遵循比大多数其他对象慢的分配路径。 同样,可终结对象的收集也较慢。 在可以回收可终结对象之前,至少需要两个垃圾回收周期(在最佳情况下),并且垃圾回收器必须做额外的工作才能调用终结器。 结果是花费更多的时间分配和收集对象,并且对垃圾回收器施加了更大的压力,因为无法到达的可终结对象使用的内存保留了更长的时间。 结合这样的事实,即finalizer不能保证在任何可预测的时间范围内甚至根本无法运行,您可以看到在相对少的情况下使用finalizer是正确的工具。
如果必须使用终结器,则可以遵循一些准则来帮助抑制损坏。 限制可终结对象的数量,这将使必须承担终结工作的分配和收集成本的对象数量减至最少。 组织您的类,以使可终结对象不包含其他数据,这将使无法终结的对象中的可用内存量降至最低,因为在实际回收这些对象之前可能会存在很长的延迟。 特别是从标准库扩展可终结类时要当心。
帮助垃圾收集器。 。 。 不
由于一次分配和垃圾回收会给Java程序带来巨大的性能成本,因此开发了许多巧妙的技巧来降低这些成本,例如对象池和空值。 不幸的是,在许多情况下,这些技术对程序性能的弊大于利。
对象池
对象池是一个简单的概念-维护一个经常使用的对象池,并从该池中获取一个对象,而不是在需要时创建一个新对象。 从理论上讲,合并可将分配成本分散到更多用途上。 当对象创建成本很高时(例如,使用数据库连接或线程时),或者合并对象代表有限且昂贵的资源(例如使用数据库连接时),这是有道理的。 但是,适用这些条件的情况数量很少。
另外,对象池还有一些严重的缺点。 由于对象池通常在所有线程之间共享,因此来自对象池的分配可能是同步瓶颈。 池化还迫使您显式管理释放,这又重新引入了指针悬空的风险。 另外,必须正确调整池大小才能获得所需的性能结果。 如果太小,将不会阻止分配; 如果太大,则可以回收的资源将在池中闲置。 通过占用可以回收的内存,对象池的使用对垃圾收集器施加了额外的压力。 编写有效的池实现并不容易。
Cliff Click博士在JavaOne 2003上的“暴露的性能神话”演讲中,提供了具体的基准测试数据,该数据表明对象池对于现代JVM上除重量最大的对象之外的所有对象而言都是性能损失。 加上分配的序列化和指针悬空的风险,很明显,除了最极端的情况,都应避免合并。
显式归零
显式归零只是将引用对象完成后将其设置为null的一种做法。 空值背后的想法是,它通过使对象较早无法访问来协助垃圾收集器。 或至少这是理论。
在一种情况下,显式空值的使用不仅有帮助,而且实际上是必需的,并且在这种情况下,对对象的引用的范围比程序规范所使用或认为有效的范围更广。 这包括使用静态或实例字段存储对临时缓冲区的引用而不是局部变量的情况,或使用数组存储可能在运行时但不能通过程序的隐含语义访问的引用的情况。 考虑清单3中的类,它是由数组支持的简单有界堆栈的实现。 调用pop()
,如果在示例中没有显式清空,则该类可能会导致内存泄漏(更恰当地称为“意外对象保留”,有时也称为“对象游荡”),因为引用存储在stack[top+1]
不再可被程序访问,但仍被垃圾收集器视为可访问。
清单3.避免在堆栈实现中游荡对象
public class SimpleBoundedStack {
private static final int MAXLEN = 100;
private Object stack[] = new Object[MAXLEN];
private int top = -1;
public void push(Object p) { stack [++top] = p;}
public Object pop() {
Object p = stack [top];
stack [top--] = null; // explicit null
return p;
}
}
在1997年9月的“ Java开发人员连接技术提示”专栏(请参阅参考资料 )中,Sun警告了这种风险,并解释了在上述pop()
示例之类的情况下如何需要显式的null。 不幸的是,程序员经常采用显式的null来提供这种建议,以期帮助垃圾收集器。 但是在大多数情况下,它根本没有帮助垃圾收集器,在某些情况下,它实际上可能会损害程序的性能。
考虑清单4中的代码,其中结合了几个非常糟糕的主意。 清单是一个链表实现,它使用终结器来遍历列表并使所有前向链接无效。 我们已经讨论了为什么终结器不好。 这种情况甚至更糟,因为现在该类正在做额外的工作,表面上是为了帮助垃圾收集器,但这实际上并没有帮助-甚至可能会造成伤害。 遍历列表需要花费CPU周期,并将具有访问所有那些死对象并将它们拉入缓存的效果-垃圾收集器可以完全避免这种工作,因为复制收集器根本不会访问死对象。 清空引用对跟踪垃圾收集器毫无帮助。 如果列表的头部不可访问,则无论如何都不会跟踪列表的其余部分。
清单4.将终结器和显式的空值结合起来,以免造成整体性能损失-不要这样做!
public class LinkedList {
private static class ListElement {
private ListElement nextElement;
private Object value;
}
private ListElement head;
...
public void finalize() {
try {
ListElement p = head;
while (p != null) {
p.value = null;
ListElement q = p.nextElement;
p.nextElement = null;
p = q;
}
head = null;
}
finally {
super.finalize();
}
}
}
对于您的程序出于性能原因而颠覆正常作用域规则的情况,应保存显式归零,例如清单3中的堆栈示例(更正确的方法-但性能较差的实现是每次都重新分配并复制堆栈数组)它已更改)。
显式垃圾收集
开发人员经常错误地认为他们在帮助垃圾收集器的第三类是使用System.gc()
,它触发了垃圾收集(实际上,它只是暗示这可能是进行垃圾收集的好时机)。 不幸的是, System.gc()
触发了一个完整的集合,其中包括跟踪堆中的所有活动对象以及清除和压缩旧版本。 这可能是很多工作。 通常,最好让系统决定何时需要收集堆以及是否进行完整收集。 在大多数情况下,少量的收藏会完成这项工作。 更糟糕的是,对System.gc()
调用通常被深深地埋在开发人员可能不知道其存在的地方,并且在这些地方可能比需要的触发次数更多。 如果您担心应用程序可能隐藏了对库中埋藏的System.gc()
调用,则可以使用-XX:+DisableExplicitGC
选项来调用JVM,以防止对System.gc()
调用并触发垃圾回收。
不变性
没有某种形式的不变性插件, Java理论和实践将无法完成。 使对象不可变,消除了整个编程错误类别。 不能使类不可变的最常见原因之一是相信这样做会损害性能。 尽管有时是正确的,但事实并非如此-有时使用不可变对象具有明显的性能优势,也许令人惊讶。
许多对象用作引用其他对象的容器。 当引用的对象需要更改时,我们有两种选择:更新引用(就像在可变容器类中一样)或重新创建容器以容纳新的引用(就像在不可变容器类中一样)。 清单5显示了两种实现简单的holder类的方法。 假设包含对象很小(通常是这种情况)(例如Map
的Map.Entry
元素或链接列表元素),分配新的不可变对象具有一些隐藏的性能优势,这些优势来自分代垃圾收集器的工作方式,与物体的相对年龄有关。
清单5.可变和不可变对象持有人
public class MutableHolder {
private Object value;
public Object getValue() { return value; }
public void setValue(Object o) { value = o; }
}
public class ImmutableHolder {
private final Object value;
public ImmutableHolder(Object o) { value = o; }
public Object getValue() { return value; }
}
在大多数情况下,当更新持有人对象以引用其他对象时,新的引用对象是年轻对象。 如果我们通过调用setValue()
更新MutableHolder
,则会导致旧对象引用年轻对象的情况。 另一方面,通过创建新的ImmutableHolder
对象,较年轻的对象将引用较旧的对象。 后一种情况(大多数对象指向较旧的对象)在世代垃圾收集器上更为温和。 如果MutableHolder
生活在老一代突变,所有的卡上包含的对象MutableHolder
必须进行扫描,在接下来的次要收集旧到新的年轻的引用。 对寿命长的容器对象使用可变引用会增加在收集时跟踪旧引用的工作量。 (见上个月的文章和这个月的相关信息 ,这解释了用于实现由当前的Sun JVM使用的代收集器写入屏蔽卡标记算法)。
当好的绩效建议变得不好时
2003年7月的Java开发者杂志上的一则封面故事说明,仅仅由于未能充分确定应在何种情况下应用建议或要解决的问题,就容易将良好的性能建议变成不良的性能建议。 尽管本文包含了一些有用的分析,但弊大于利(不幸的是,太多基于性能的建议落入了同样的陷阱)。
本文首先介绍了实时环境中的一组要求,在这些环境中,无法预料的垃圾收集暂停是不可接受的,并且对允许的暂停时间有严格的操作要求。 然后,作者建议取消引用,对象池并安排显式垃圾回收以满足性能目标。 到目前为止,一切都很好-他们遇到了一个问题,他们想出了解决该问题的方法(尽管他们似乎未能确定这些做法的成本是多少,也没有探索出一些侵入性较小的替代方法,例如并发收集)。 不幸的是,文章的标题(“避免麻烦的垃圾收集暂停”)和演示文稿表明,此建议对广泛的应用程序(可能是所有 Java应用程序)很有用。 这是可怕的,危险的性能建议!
对于大多数应用程序,显式的空值,对象池和显式的垃圾回收将损害您的应用程序的吞吐量,而不是提高它的吞吐量–更不用说这些技术对程序设计的侵入性。 在某些情况下,为了达到可预测性而以吞吐量为代价是可以接受的,例如实时或嵌入式应用程序。 但是对于许多Java应用程序,包括大多数服务器端应用程序,您可能更希望具有吞吐量。
这个故事的寓意是,绩效咨询非常具有情境性(并且保质期很短)。 顾名思义,性能建议是被动的-它旨在解决在特定情况下发生的特定问题。 如果基础情况发生变化,或者根本不适合您的情况,则建议也可能不适用。 在对程序的设计进行改进以提高其性能之前,请首先确保您遇到了性能问题,并且遵循建议可以解决该问题。
摘要
在过去几年中,垃圾收集已经走了很长一段路。 现代JVM提供快速的分配,并且自己可以很好地完成工作,与以前的JVM相比,垃圾回收的暂停时间更短。 由于分配和垃圾回收的成本已大大降低,以前被认为是提高性能的明智技术(例如对象池或显式无效)不再是必需的或无用的(甚至可能是有害的)。
翻译自: https://www.ibm.com/developerworks/java/library/j-jtp01274/index.html