算法 自动采集列表(Java应用程序分配对象的方式很糟糕,分配多少钱?)
优采云 发布时间: 2022-02-24 02:20算法 自动采集列表(Java应用程序分配对象的方式很糟糕,分配多少钱?)
本文于 2015 年 11 月 11 日存档。其内容不再更新或维护。“按原样”提供。鉴于当今技术的快速发展,一些步骤和插图可能已经改变。
在 Java 技术的早期,对象的分配方式很糟糕。有许多 文章s(包括作者的一些)建议开发人员避免不必要地创建临时对象,因为分配(以及相应的垃圾采集开销)很昂贵。虽然这曾经是一个很好的建议(在性能非常重要的情况下),但它通常不再适用于所有性能关键的情况,而是最重要的情况。
分配多少?
1.0 和 1.1 JDK 使用标记清除采集器,它压缩一些(但不是全部)采集,这意味着在垃圾采集后堆可能会变得碎片化。因此,1.0 和 1.1 JVM 中的内存分配成本与 C 或 C++ 中的内存分配成本相当,其中分配器使用启发式(例如“first-first”或“best-fit”)来管理可用堆空间。释放也很昂贵,因为标记清除采集器必须在每次采集时清除整个堆。难怪我们被建议在分配器上放轻松。
在热点 JVM(Sun JDK 1. 2 及更高版本)上,情况变得更好 - Sun 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);
}
分配器不是可扩展性瓶颈吗?
表明虽然分配本身很快,但对堆结构的访问必须在线程之间同步。那么这是否会使分配器成为可伸缩性风险?JVM 有许多巧妙的技巧可以大大降低这种成本。IBM JVM 使用一种称为线程局部堆的技术,每个线程通过该技术从分配器请求一小块内存 (~1K),并从该块中满足小对象分配。如果程序请求的块比使用小的线程局部堆无法满足,则使用全局分配器直接满足请求或分配新的线程局部堆。使用这种技术,可以在不竞争共享堆锁的情况下满足大部分分配。(Sun JVM 使用类似的技术,而不是术语“本地分配块”。)
终结者不是你的朋友
与没有终结器的对象相比,具有终结器的对象(具有非平凡 finalize() 方法的对象)具有显着的开销,应谨慎使用。可终结对象的分配和采集速度较慢。在分配时,JVM 必须向垃圾采集器注册任何可终结对象,并且(至少在 HotSpot JVM 实现中)可终结对象必须遵循比大多数其他对象更慢的分配路径。此*敏*感*词*内运行,甚至根本不能保证运行,您可以看到在相对较少的情况下使用终结器是正确的工具。
如果您必须使用终结器,您可以遵循一些准则来帮助抑制损坏。限制 finalizable 对象的数量,从而最小化必须承担 finalization 的分配和采集成本的对象的数量。组织您的类以使可终结对象不收录其他数据将最大限度地减少无法终结的对象中可用的内存量,因为在这些对象实际回收之前可能存在很长的延迟。从标准库扩展可终结的类时要特别小心。
帮助垃圾采集器。. . 不要
由于单次分配和垃圾回收会给 Java 程序带来巨大的性能成本,因此开发了许多巧妙的技巧来降低这些成本,例如对象池和空值。不幸的是,在许多情况下,这些技术对程序性能弊大于利。
对象池
对象池是一个简单的概念 - 维护一个经常使用的对象池并从该池中获取一个对象,而不是在需要时创建一个新对象。从理论上讲,整合可以将分销成本分散到更多用途上。当对象的创建成本很高(例如使用数据库连接或线程时),或者当合并对象代表有限且昂贵的资源(例如使用数据库连接时)时,这是有意义的。但是,适用这些条件的情况很少。
此外,对象池有一些严重的缺点。由于对象池通常在所有线程之间共享,因此来自对象池的分配可能成为同步瓶颈。池化还强制您显式管理释放,这重新引入了悬空指针的风险。此外,必须适当调整池的大小以获得所需的性能结果。如果太小,分配将不会被阻塞;如果太大,可回收的资源将在池中闲置。对象池的使用通过占用可回收的内存给垃圾采集器带来了额外的压力。编写一个高效的池实现并不容易。
Cliff Click 博士在他的 JavaOne 2003 演讲“Performance Myths Exposed”中提供了具体的基准数据,表明对象池是现代 JVM 上除了最重的对象之外的所有对象的性能损失。加上分配的序列化和悬空指针的风险,很明显,除了最极端的情况外,应该避免合并。
显式归零
显式归零只是在完成后将引用对象设置为 null 的一种做法。null 背后的想法是它通过使对象更早不可访问来帮助垃圾采集器。或者至少这是理论。
在对对象的引用具有比程序规范使用或认为有效的范围更广的情况下,使用显式 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。不幸的是,程序员经常求助于明确的空值来提供这个建议,希望能帮助垃圾采集器。但在大多数情况下,它根本无法帮助垃圾采集器,在某些情况下,它实际上会损害程序的性能。
考虑清单 4 中的代码,它结合了几个非常糟糕的想法。List 是一个链表实现,它使用终结器来遍历列表并使所有前向链接无效。我们已经讨论了为什么终结器不好。更糟糕的是,因为现在班级正在做额外的工作,表面上是为了帮助垃圾采集器,但实际上并没有帮助 - 甚至可能造成伤害。遍历列表需要 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();
}
}
}
对于您的程序出于性能原因破坏正常范围规则的情况,您应该保存显式归零,例如堆栈示例(更正确的方法 - 但性能较低的实现是每次重新分配和复制堆栈数组)它已经改变)。
显式垃圾采集
开发人员经常错误地认为他们正在帮助垃圾采集器的第三类是使用 System.gc() ,它会触发垃圾采集(实际上,它只是暗示它可能是这样做的好时机)。不幸的是,System.gc() 触发了一个完整的集合,包括跟踪堆中的所有活动对象以及清除和压缩旧版本。这可能是很多工作。一般来说,最好让系统决定何时需要采集堆以及是否进行全采集。在大多数情况下,一个小的集合就可以完成这项工作。更糟糕的是,对 System.gc() 的调用通常深埋在开发人员可能不知道它存在的地方,并且在这些地方可能比需要的更频繁地触发。如果您担心您的应用程序可能隐藏了对隐藏在库中的 System.gc() 的调用,
不变性
如果没有某种形式的不变性插件,Java 理论和实践就无法完成。使对象不可变可以消除一整类编程错误。不使类不可变的最常见原因之一是认为这样做会损害性能。虽然有时是正确的,但事实并非如此——有时使用不可变对象有明显的、也许令人惊讶的性能优势。
许多对象用作引用其他对象的容器。当被引用的对象需要改变时,我们有两种选择:更新引用(如可变容器类)或重新创建容器以保存新引用(如不可变容器类)。清单 5 展示了两种实现简单持有者类的方法。假设收录对象很小(通常是这种情况)(例如 Map 的 Map.Entry 元素或链表元素),分配一个新的不可变对象具有一些隐藏的性能优势,这些优势来自于分代垃圾采集器的工作方式,即与物体的相对年龄有关。
List5. 可变和不可变对象持有者
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 的所有对象,在下一个二级中采集旧的到新的年轻引用。对长期存在的容器对象使用可变引用会增加在采集时跟踪旧引用的工作量。(参见上个月的 文章 和本月的,
当好的性能建议变坏时
2003 年 7 月 Java Developer's Magazine 的一个封面故事说明,很容易将良好的性能建议变成糟糕的性能建议,因为未能充分确定何时应该应用建议或要解决的问题。虽然本文收录一些有用的分析,但它弊大于利(不幸的是,太多基于性能的建议落入了同一个陷阱)。
本文首先描述了一组实时环境中的要求,在这些实时环境中,不可预见的垃圾采集暂停是不可接受的,并且对允许的暂停时间有严格的操作要求。然后,作者建议取消引用、对象池和调度显式垃圾采集来满足性能目标。到目前为止一切都很好——他们遇到了一个问题,他们找到了解决问题的方法(尽管他们似乎无法确定这些做法的成本或探索一些侵入性较小的替代方案,例如并发集合)。不幸的是,文章 的标题(“避免麻烦的垃圾采集暂停”)和介绍表明,该建议对广泛的应用程序(可能所有 Java 应用程序)都很有用。这是可怕的、危险的性能建议!
对于大多数应用程序,显式空值、对象池和显式垃圾回收会损害应用程序的吞吐量,而不是提高它——更不用说这些技术对程序设计的侵入性了。在某些情况下,吞吐量的权衡对于可预测性是可以接受的,例如实时或嵌入式应用程序。但是对于许多 Java 应用程序,包括大多数服务器端应用程序,您可能更喜欢吞吐量。
这个故事的寓意是绩效咨询是非常情境化的(并且保质期很短)。顾名思义,性能建议是被动的——它旨在解决在特定情况下发生的特定问题。如果基本情况发生变化,或者根本不适用于您的情况,建议也可能不适用。在对程序设计进行改进以提高其性能之前,首先要确保您遇到了性能问题并且遵循建议可以解决问题。
概括
垃圾采集在过去几年中取得了长足的进步。现代 JVM 提供快速分配并自行完成工作,与以前的 JVM 相比,垃圾采集的暂停时间更短。由于分配和垃圾采集的成本已大大降低,以前认为可以提高性能的技术(例如对象池或显式失效)不再必要或无用(甚至可能有害)。
翻译自: