流行测验:哪种语言拥有更快的原始分配性能,Java语言或C / C ++? 答案可能会让您感到惊讶-现代JVM中的分配要比性能最佳的malloc
实现快得多。 HotSpot 1.4.2和更高版本中new Object()
的通用代码路径约为10条机器指令(数据由Sun提供;请参阅参考资料 ),而C中性能最佳的malloc
实现平均每次调用需要60至100条指令(Detlefs等人;参见相关主题 )。 分配性能并不是整体性能的重要组成部分-基准测试表明,许多现实世界的C和C ++程序(例如Perl和Ghostscript)将其总执行时间的20%到30%花费在malloc
和free
-远远超过了健康的Java应用程序的分配和垃圾回收开销(Zorn;请参阅参考资料 )。
您不必搜索太多的博客或Slashdot帖子就可以找到措辞自信的语句,例如“垃圾回收永远不会像直接内存管理那样高效”。 而且,从某种意义上说,这些陈述是正确的-动态内存管理不是那么快-它通常要快得多。 malloc/free
方法一次只处理一个内存块,而垃圾回收方法则倾向于大批量处理内存管理,从而产生了更多的优化机会(以牺牲可预测性为代价)。
数据证明了这种“合理性论点”,即整天清理一大堆垃圾要比整天拾起灰尘更容易。 一项研究(佐恩;参见相关主题 )还测量了更换的效果malloc
与保守勃姆-德默斯-韦瑟(BDW)的垃圾收集器的一些常见的C ++应用程序和运行时,结果是,有许多方案表现出加速比使用垃圾收集器而不是传统的分配器。 (并且BDW是一个保守的,固定的垃圾收集器,它极大地限制了其优化分配和回收并改善内存局部性的能力;准确地重定位收集器(例如JVM中使用的收集器)会做得更好。)
JVM中的分配并不总是那么快-早期的JVM确实具有较差的分配和垃圾回收性能,这几乎可以肯定是这个神话开始的地方。 在早期,我们看到了很多“分配缓慢”的建议-因为它是与早期JVM中的其他所有建议一起使用的-性能专家提出了各种避免分配的技巧,例如对象池。 (公共服务公告:对象池现在是除最重对象之外的所有对象的严重性能损失,即使这样,在不引入并发瓶颈的情况下正确执行也是很棘手的。)但是,自JDK 1.0天以来,发生了许多事情; 在JDK 1.2中引入了分代收集器,这使得分配方法更加简单,从而大大提高了性能。
分代垃圾回收器将堆分为多代; 大多数JVM使用两代,即“年轻”和“旧”一代。 对象是在年轻一代中分配的; 如果它们能够通过一定数量的垃圾收集程序而幸存下来,则它们被认为是“长寿的”,并被提升为老一代。
虽然HotSpot提供了三个年轻收集器(串行复制,并行复制和并行清除)的选择,但它们都是“复制”收集器的形式,并且具有几个重要的共同特征。 复制收集器将内存空间分成两半,一次仅使用一半。 最初,正在使用的一半形成了一大块可用内存; 分配器通过返回未使用部分的前N个字节,并移动一个将“已用”部分与“空闲”部分分开的指针来满足分配请求,如清单1中的伪代码所示。使用填充后,垃圾收集器会将所有活动对象(不是垃圾的活动对象)复制到另一半的底部(压缩堆),然后从另一半开始分配。
void *malloc(int n) {if (heapTop - heapStart < n)doGarbageCollection();void *wasStart = heapStart;heapStart += n;return wasStart;
}
从此伪代码中,您可以了解为什么复制收集器能够实现如此快速的分配-分配新对象无非就是检查堆中是否还有足够的空间,如果有的话则增加指针。 无需搜索空闲列表,最适合,最适合,后备列表-只需从堆中获取前N个字节即可。
但是分配只是内存管理的一半-释放是另一半。 事实证明,对于大多数对象而言,直接垃圾收集成本为零。 这是因为复制收集器不需要访问或复制死对象,而只需访问活动对象。 因此,分配后不久变成垃圾的对象不会对收集周期造成任何工作量。
事实证明,在典型的面向对象程序中,绝大多数对象(根据各种研究,介于92%到98%之间)“年轻死”,这意味着它们在分配后不久(通常在下一个垃圾回收之前)就变成垃圾。 (此属性称为世代假设 ,并经过经验测试,发现对许多面向对象的语言都是正确的。)因此,不仅分配很快,而且对于大多数对象来说,释放是免费的。
如果如清单1所示真正实现了分配器,则共享的heapStart
字段将很快成为重要的并发瓶颈,因为每次分配都将涉及获取保护该字段的锁。 为了避免此问题,大多数JVM使用线程本地分配块 ,其中每个线程从堆中分配更大的内存块,并从该线程本地块中依次服务小分配请求。 结果,大大减少了线程获取共享堆锁的次数,从而提高了并发性。 (在传统的malloc
实现的背景下解决此问题更加困难且成本更高;在平台中构建线程支持和垃圾回收都有助于实现这种协同作用。)
C ++为程序员提供了在堆或堆栈上分配对象的选择。 基于堆栈的分配更加有效:分配更便宜,重新分配成本真正为零,并且该语言在划分对象生命周期方面提供了帮助,从而降低了忘记释放对象的风险。 另一方面,在C ++中,发布或共享对基于堆栈的对象的引用时需要非常小心,因为在展开堆栈框架时会自动释放基于堆栈的对象,从而导致指针悬空。
基于堆栈的分配的另一个优点是它对缓存更友好。 在现代处理器上,高速缓存未命中的代价是巨大的,因此,如果语言和运行时可以帮助您的程序获得更好的数据局部性,性能将得到改善。 堆栈的顶部在缓存中几乎总是“热”,而堆的顶部几乎总是“冷”(因为自从使用该内存以来可能已经很长时间了)。 结果,与在堆栈上分配该对象相比,在堆上分配一个对象可能需要更多的高速缓存未命中。
更糟糕的是,在堆上分配对象时,高速缓存未命中具有特别讨厌的内存交互。 从堆中分配内存时,该内存的内容是垃圾-从上次使用该内存以来,剩下的任何位都是。 如果您在堆中分配的内存块尚未在缓存中,则当该内存的内容被带入缓存时,执行将停止。 然后,您将立即用零或其他初始值覆盖为带入缓存而支付的那些值,从而导致大量的内存活动浪费。 (某些处理器,例如Azul的Vega,包括用于加速堆分配的硬件支持。)
Java语言没有提供任何在堆栈上显式分配对象的方法,但是这一事实并不能阻止JVM在适当的地方仍然使用堆栈分配。 JVM可以使用一种称为转义分析的技术,通过这种技术,他们可以告诉我们某些对象在整个生命周期内都被限制在单个线程中,并且生命周期受给定堆栈帧的生命周期的限制。 这样的对象可以安全地分配在堆栈而不是堆上。 更好的是,对于小型对象,JVM可以完全优化分配,只需将对象的字段提升到寄存器中即可。
清单2显示了一个示例,其中可以使用转义分析来优化离开堆的分配。 getDistanceFrom()
首先获取另一个组件的位置,这涉及对象分配,然后使用getLocation()
返回的Point
的x
和y
字段计算两个组件之间的距离。
public class Point {private int x, y;public Point(int x, int y) {this.x = x; this.y = y;}public Point(Point p) { this(p.x, p.y); }public int getX() { return x; }public int getY() { return y; }
}public class Component {private Point location;public Point getLocation() { return new Point(location); }public double getDistanceFrom(Component other) {Point otherLocation = Location();int deltaX = X() - X();int deltaY = Y() - Y();return Math.sqrt(deltaX*deltaX + deltaY*deltaY);}
}
getLocation()
方法不知道其调用者将如何处理它返回的Point
。 它可能会保留对它的引用,例如将其放入集合中,因此getLocation()
被防御性地编码。 但是,在此示例中, getDistanceFrom()
不会执行此操作; 它只是要短暂使用Point
,然后将其丢弃,这似乎是在浪费一个完美的物体。
智能JVM可以查看正在发生的情况,并优化防御性副本的分配。 首先,将内联对getLocation()
的调用,以及对getX()
和getY()
的调用,导致getDistanceFrom()
行为与清单3相同。
public double getDistanceFrom(Component other) {Point otherLocation = new Point(other.x, other.y);int deltaX = otherLocation.x - location.x;int deltaY = otherLocation.y - location.y;return Math.sqrt(deltaX*deltaX + deltaY*deltaY);}
此时,转义分析可以显示在第一行中分配的对象永远不会从其基本块中逃逸,并且getDistanceFrom()
永远不会修改other
组件的状态。 (通过转义 ,我们的意思是未将对它的引用存储到堆中或传递给可能保留副本的未知代码。)鉴于Point
是真正的线程局部的,并且已知其生命周期受到基本块的限制在其中分配了它,可以对其进行堆栈分配或完全优化,如清单4所示。
public double getDistanceFrom(Component other) {int tempX = other.x, tempY = other.y;int deltaX = tempX - location.x;int deltaY = tempY - location.y;return Math.sqrt(deltaX*deltaX + deltaY*deltaY);}
结果是,在保持封装和防御性复制(以及其他安全编码技术)为我们提供的安全性的同时,我们获得的性能与所有领域都是公开的完全相同。
转义分析是已经讨论了很长时间的优化,终于在这里-当前的Mustang(Java SE 6)版本可以进行转义分析,并在适当的情况下将堆分配转换为堆栈分配(或不分配) 。 使用转义分析来消除某些分配会导致更快的平均分配时间,减少的内存占用以及更少的缓存未命中。 此外,优化分配可以减轻垃圾收集器的压力,并减少收集的运行频率。
即使在源代码中可能不可行,即使语言提供了选项,转义分析也可以找到堆栈分配的机会,因为特定分配是否得到优化取决于对象的结果如何-返回方法实际上是在特定的代码路径中使用的。 从getLocation()
返回的Point
可能并非在所有情况下都适合堆栈分配,但是一旦JVM内联getLocation()
,它便可以自由地分别优化每个调用,从而为我们提供了两全其美的优势:花费更少的时间即可获得最佳性能做出低级的性能调整决策。
JVM惊人地擅长弄清我们曾经假定只有开发人员才能知道的事情。 通过让JVM根据具体情况在堆栈分配和堆分配之间进行选择,我们可以获得堆栈分配的性能优势,而无需让程序员为在堆栈上分配还是在堆上分配分配而烦恼。
翻译自: .html
本文发布于:2024-02-01 07:47:58,感谢您对本站的认可!
本文链接:https://www.4u4v.net/it/170674488034986.html
版权声明:本站内容均来自互联网,仅供演示用,请勿用于商业和其他非法用途。如果侵犯了您的权益请与我们联系,我们将在24小时内删除。
留言与评论(共有 0 条评论) |