3年前 (2022-02-11)  Java系列 |   抢沙发  312 
文章评分 0 次,平均分 0.0
[收起] 文章目录

底层的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不在堆栈中存储任何对象。它确实在堆栈上存储局部变量:intboolean等基本类型。它还存储指向堆栈上对象的指针。但它不会将对象本身存储在堆栈上。使用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

关于

发表评论

表情 格式

暂无评论

登录

忘记密码 ?

切换登录

注册