底层的CPU优化对我们的应用有影响。此外,它们证明了大多数程序员的错觉是错误的。是的,你使用的是高级语言。但这并不意味着所有这些抽象层都能完全屏蔽CPU。我在一个Java程序上演示了CPU缓存的效果。
CPU的发展速度比主存快得多。如今,CPU大部分时间都在等待。现代CPU可以在每个CPU周期执行多条指令。但如果它必须访问主内存,绕过三个缓存中的每一个,它必须等待大约100个周期。
Java中的逃逸分析
自Java6以来,JVM使用了一种完全惊人的优化技术来减少内存占用。有趣的是,围绕这项技术有很多城市神话。他们说逃逸分析是一种将内存从堆转移到堆栈的工具。这是胡说八道,但这是有趣的胡说八道,所以我将在本文中介绍这个想法。
事实证明,逃逸分析并不是一种优化技术。它“只是”一个分析工具,可以进行有趣的优化。Escape analysis检查变量,查看其使用位置,并检测其是否在特定范围外使用。如果它没有超出这个范围,我们知道它是一个局部变量,我们可以更仔细地检查它。仅在有限范围内使用的变量允许进行许多优化。
锁省略
这些优化之一是锁省略。你有没有遇到过多个线程访问一个公共变量的问题?如果是这样的话,您可能会用一个同步块来包围程序的许多部分。有时你甚至不得不这么做,因为你不知道你的方法将如何被使用。StringBuffer
实现的一个经典示例。StringBuffer的方法经常使用synchronized
,以防万一:
public synchronized int length() {
return count;
}
事实是,几乎每个应用程序都以单线程方式使用StringBuffer
。换句话说,获取锁并保护StringBuffer
不受其他线程的同时访问是一种昂贵的时间浪费。
逃逸分析解决了这个问题。在大多数情况下,它可以识别StringBuffer由一个方法使用。所以它肯定只有一个线程访问它。换句话说:synchronized
语句是多余的。可以放心地忽略它。
与此同时,Aleksey Shipilёv发表了一篇文章,用性能度量和生成的汇编代码演示了优化的效果。查看Aleksey撰写的文章,了解所有细节(https://shipilev.net/jvm/anatomy-quarks/19-lock-elision/)。
将对象从堆移动到栈
许多消息来源称,逃避分析将Java对象从堆移动到堆栈。正如Aleksey Shipilёv在其关于标量替换的文章(https://shipilev.net/jvm/anatomy-quarks/18-scalar-replacement/)中指出的那样,JVM不执行这种实现。这只是一种误解。但这是一个有趣的问题,我想知道为什么JVM没有实现它。
逃避分析告诉我们什么?它告诉我们哪些变量是在特定范围内专用的。例如,它告诉我们一个变量只被用作一个方法的局部变量,而这个变量不会被另一个方法使用。特别是,它不是一个返回值。
除此之外,这意味着该变量不被任何其他线程使用。因此,我们可以安全地将其存储在线程本地内存中。
除了CPU寄存器,最快的线程本地内存是堆栈。
栈是什么?为什么它比堆快?
基本上,堆栈是存储函数调用返回地址的地方。在调用方法或函数之前,CPU将当前指令指针存储在一个特殊的内存位置,即堆栈上。如果以后调用嵌套函数,它会将新的指令指针堆积在旧的指令指针之上。所以你有一堆回信地址。在函数结束时,CPU从堆栈中提取(或“弹出”)最顶层的地址,将其存储在指令指针中,并使用它来确定在何处继续执行程序。
显然,堆栈在任何程序中都扮演着重要的角色。因此,堆栈操作被高度优化也就不足为奇了。堆栈几乎总是在快速一级缓存中。从缓存中存储和检索数据的操作非常快。此外,指向缓存的指针是一个CPU寄存器,这将进一步加快缓存访问速度。
将局部变量放入栈
同时,我所知道的每个CPU都允许程序员将用户数据存储在缓存中。这是实现局部变量的一种非常方便的方法。我在关于Java字节码的文章和关于汇编语言的文章中简要描述了这个想法。如今,现代编译器尽最大努力避免使用堆栈,而是使用寄存器,但基本思想很简单:将所有局部变量放在堆栈上。在从函数返回之前,只需从堆栈中删除所有局部变量。基本上,这只是向堆栈指针添加一个数字,所以这只是一个CPU周期。之后,局部变量就不可访问了——这正是它应该的方式。
作为一个副作用,你不必处理垃圾收集。对于可能被多次使用的对象,需要进行垃圾收集。对于局部变量,您肯定知道局部变量只使用一次。因此,您可以删除它,无需进一步的麻烦。这是一个主要的性能优势。
Java中的对象分解
然而,这正是Java没有做到的(至少Java 8没有做到,如果我没有弄错的话,Java 9也没有做到)。更准确地说:Java不在堆栈中存储任何对象。它确实在堆栈上存储局部变量:int
或boolean
等基本类型。它还存储指向堆栈上对象的指针。但它不会将对象本身存储在堆栈上。使用new
创建的任何内容都始终在堆上创建。
相反,自从Java6以来,它做了更令人兴奋的事情:标量替换。
考虑这个代码:
public static int main(String... args) {
MyObject o = new MyObject(x);
return o.x;
}
static class MyObject {
final int x;
public MyObject(int x) {
this.x = x;
}
}
仔细看一下这段代码,就会发现对象MyObject
从未真正被使用过。真正需要的是对象的变量x
。这是JVM的逃逸分析算法可以检测到的事情之一。
实际上,它不会在我的算法版本中检测到它,因为为了简单起见,我使用了main
方法。换句话说,该方法只被调用一次。Aleksey最初的算法使用了一种常规的方法,他称之为数千次。这很重要,因为JVM编译器和优化器只有在达到一定阈值后才会启动。根据经验,在代码完全优化之前,它最多需要一万次迭代。具体细节取决于JVM的配置方式和Java代码。
标量替换
让我们假设代码已完全优化。在这种情况下,JVM检测到对象MyObject
没有在main
方法之外使用。接下来,它检测到对象本身从未被使用。唯一真正使用的是对象的变量x。
因此JVM从等式中删除了对象。它会停止创建新对象。它只适用于整数x。太棒了!注意:需要在堆上创建对象。我们在上面已经看到,在堆栈上创建它就足够了,但这是Java不能做到的。即使是这样,用一个简单的32位整数替换一个完整的对象也是一个巨大的性能提升。
比使用堆栈更好
在这种特殊情况下,标量替换甚至比将对象放在堆栈上更好。首先,对象根本没有被创建。就像程序员没有定义一样。第二,只有一个整数。JVM可以轻松地将该值存储在CPU寄存器中。因此,对象和变量x都不会在真实RAM中具体化。它留在CPU中。
标量替换的局限性
对bad Aleksey来说,这也说明了JVM优化器的局限性。一个简单的if
语句足以让优化器感到困惑。所以我想知道这种特殊的优化在实际应用中有多好。
尽管如此,逃逸分析及其利用的优化给我留下了深刻的印象。我想未来还会有更多。目前,许多科学家正在研究这个话题。例如,看看Lukas Stadler对Java的部分转义分析和标量替换(http://ssw.jku.at/Teaching/PhDTheses/Stadler/index.html)
除特别注明外,本站所有文章均为老K的Java博客原创,转载请注明出处来自https://javakk.com/2576.html
暂无评论