【问题标题】:Bitwise operator advantages in StringBuilderStringBuilder 中的位运算符优势
【发布时间】:2015-04-02 09:44:17
【问题描述】:

为什么StringBuffer/StringBuilder 类中的reverse() 方法使用按位运算符?

我想知道它的优点。

public AbstractStringBuilder reverse() {
    boolean hasSurrogate = false;
    int n = count - 1;
    for (int j = (n-1) >> 1; j >= 0; --j) {
        char temp = value[j];
        char temp2 = value[n - j];
        if (!hasSurrogate) {
            hasSurrogate = (temp >= Character.MIN_SURROGATE && temp <= Character.MAX_SURROGATE)
                || (temp2 >= Character.MIN_SURROGATE && temp2 <= Character.MAX_SURROGATE);
        }
        value[j] = temp2;
        value[n - j] = temp;
    }
    if (hasSurrogate) {
        // Reverse back all valid surrogate pairs
        for (int i = 0; i < count - 1; i++) {
            char c2 = value[i];
            if (Character.isLowSurrogate(c2)) {
                char c1 = value[i + 1];
                if (Character.isHighSurrogate(c1)) {
                    value[i++] = c1;
                    value[i] = c2;
                }
            }
        }
    }
    return this;
}    

【问题讨论】:

  • 类似问题在这里:stackoverflow.com/questions/6385792/…
  • 我刚刚意识到:在这种情况下,这是一个聪明的捷径:如果字符串只有 1 或 0 个字符长,您不必反转任何内容,因此您不想输入如果 count (n-1) / 2 并且 count = 1 则 n = -1 并且结果为 0,因此您将进入循环进行一次迭代。如果你使用 Bitshift:-1 &gt;&gt; 1 = -1 你完全跳过循环,你不需要额外的比较。所以它更聪明更快

标签: java algorithm bit-manipulation


【解决方案1】:

右移一位意味着除以二,我认为您不会注意到任何性能差异,编译器在编译时执行这些优化。

许多程序员习惯于在除法时右移两位而不是写/ 2,这是一种风格问题,或者也许有一天右移而不是写/ 2实际上更有效,(在优化之前)。编译器知道如何优化这样的事情,我不会浪费时间尝试编写其他程序员可能不清楚的事情(除非他们真的有所作为)。无论如何,循环相当于:

int n = count - 1;
for (int j = (n-1) / 2; j >= 0; --j)

正如@MarkoTopolnik 在他的评论中提到的那样,JDK 的编写根本没有考虑任何优化,这可以解释为什么他们明确地将数字右移一而不是明确划分它,如果他们考虑到优化的最大功率,他们可能会写/ 2


如果你想知道为什么它们是等价的,最好的解释是举例,考虑数字 32。假设 8 位,它的二进制表示是:

00100000

右移一位:

00010000

其值为 16 (1 * 24)

【讨论】:

  • JDK 代码通常在编写时尽可能少地假设运行时优化器的功能。这是一个完全合理的选择,因为 JDK 与我们编写的任何客户端代码不同,它面向各种平台,包括最简约的平台。解释代码的性能也值得关注。
  • @MarounMaroun 是的,看看 Doug Lea 在java.util.concurrent 中的代码——当考虑到全功率的 HotSpot 时,它充满了看似不必要的优化。
  • 谢谢 Maroun,这正是我想知道的。感谢您的及时回复。 :)
  • 值得注意的是,右移仅适用于非负数,因此优化器必须证明该值不能为负或在其前插入条件代码可以用右移代替除法。因此,即使在今天的系统上,在源代码中进行右移而不是划分可以产生影响。很明显,应用程序编写者应该更喜欢可读性而不是非关键代码的性能,但 JRE 开发人员无法预测一个方法是否会成为应用程序的关键。
  • 如果编译器在编译时无法知道n确实是正数,他就无法优化成简单的右移!它必须执行算术移位......即使那样我也不知道Java是否必须使用除法来保证32/64位VM上的相同行为......但是:(-7 >> 1)和(- 7 / 2 ) 在 Java 中产生不同的结果。所以编译器无法优化,Shift比全除快!
【解决方案2】:

总结:

  • Java 中的&gt;&gt; 运算符称为符号扩展右位移位 运算符。
  • 对于 X 的所有严格正值,X &gt;&gt; 1 在数学上等同于 X / 2
  • X &gt;&gt; 1总是比 X / 2,比率大约为 1:16,尽管在实际基准测试中差异可能不那么显着,因为现代处理器架构。
  • 所有主流 JVM都可以正确执行此类优化,但未优化的字节码将在这些优化真正发生之前以解释模式执行数千次。
  • JRE 源代码使用了很多优化习惯用法,因为它们对以解释模式执行的代码(最重要的是,在 JVM 启动时)产生了重要影响。
  • 系统地使用被整个开发团队接受的行之有效的代码优化习惯并不是过早的优化

长答案

以下讨论试图正确解决此页面上其他 cmets 中提出的所有问题和疑问。之所以这么长,是因为我觉得有必要强调为什么某些方法更好,而不是炫耀个人的基准测试结果、信念和实践,因为在这些情况下,不同的人可能会有很大的不同。下一个。

让我们一次一个地回答问题。

1. Java 中的X &gt;&gt; 1(或X &lt;&lt; 1,或X &gt;&gt;&gt; 1)是什么意思?

&gt;&gt;&lt;&lt;&gt;&gt;&gt; 统称为 Bit Shift 运算符。 &gt;&gt; 通常称为符号扩展右位移,或算术右位移&gt;&gt;&gt;无符号扩展右位移(也称为逻辑右位移),&lt;&lt; 只是左位移(符号扩展不适用于该方向,因此不需要 logicalarithmetic 变体。

Bit Shift 运算符在许多编程语言中都可用(尽管有不同的表示法)(实际上,根据我的快速调查,几乎所有语言都或多或少是 C 语言的后代,加上其他一些)。位移位是基本的二进制操作,因此,几乎所有创建的 CPU 都为这些操作提供汇编指令。 Bit Shifters 也是电子设计中的经典积木,在给定合理数量的晶体管的情况下,它可以在一个步骤中提供最终结果,并具有恒定且可预测的稳定周期时间。

具体来说,bit shift 运算符通过将数字的所有位移动 n 个位置(向左或向右)来转换一个数字。 掉出的部分被遗忘了; “进来”的位被强制为 0,除了 符号扩展右位移 的情况,其中最左边的位保留其值(因此保留其符号)。请参阅Wikipedia 以获取其中的一些图形。

2。 X &gt;&gt; 1 是否等于 X / 2

可以,只要保证股息为正数。

更笼统地说:

  • 左移N 相当于乘以2<sup>N</sup>
  • N 的逻辑右移相当于2<sup>N</sup>无符号整数除法
  • 算术右移 N 相当于 非整数 除以 2<sup>N</sup>,向负无穷方向舍入为整数(这也相当于 有符号整数除法 2<sup>N</sup> 对于任何严格的正整数)。

3。在 CPU 级别上,位移是否比等效的算术运算更快?

是的。

首先,我们可以很容易地断言,在 CPU 级别,位移确实比等效的算术运算需要更少的工作。对于乘法和除法都是如此,原因很简单:整数乘法和整数除法电路本身都包含几个位移位器。换句话说:位移单元仅代表乘法或除法单元复杂度的一小部分。因此,可以保证执行简单的位移而不是完整的算术运算需要更少的能量。然而,最后,除非您监控 CPU 的耗电量或散热量,否则我怀疑您是否会注意到您的 CPU 正在使用更多能量的事实。

现在,让我们谈谈速度。在具有相当简单架构的处理器上(大致是在 Pentium 或 PowerPC 之前设计的任何处理器,加上不具有某种形式的执行管道的最新处理器),通常实现整数除法(和较小程度的乘法)通过迭代其中一个操作数上的位(实际上是一组位,称为基数)。每次迭代需要一个 CPU 周期,这意味着 32 位处理器上的整数除法将需要(最多)16 个周期(假设 Radix 2 SRT 除法单元,在假设的处理器)。乘法单元通常一次处理更多位,因此 32 位处理器可能在 4 到 8 个周期内完成整数乘法。这些单元可能使用某种形式的可变位移位器来快速跳过连续零序列,因此在乘以或除以 simple 操作数(例如 2 的正幂)时可能会快速终止;在这种情况下,算术运算将在更少的周期内完成,但仍然需要的不仅仅是简单的位移运算。

显然,处理器设计之间的指令时序有所不同,但前面的比率(位移 = 1,乘法 = 4,除法 = 16)是这些指令实际性能的合理近似值。作为参考,在 Intel 486 上,SHR、IMUL 和 IDIV 指令(对于 32 位,假设寄存器为常数)分别需要 2、13-42 和 43 个周期(参见here 了解 486 条指令及其时序的列表)。

现代计算机中的 CPU 怎么样?这些处理器是围绕流水线架构设计的,允许同时执行多条指令;结果是现在大多数指令只需要一个专用时间周期。但这具有误导性,因为指令在释放之前实际上会在流水线中保留几个周期,在此期间它们可能会阻止其他指令完成。在此期间整数乘法或除法单元保持“保留”,因此任何进一步的除法都将被阻止。这在短循环中尤其成问题,其中单个乘法或除法最终将被尚未完成的自身先前调用所停止。位移指令不会遭受这种风险:大多数“复杂”处理器可以访问多个位移单元,并且不需要将它们保留很长时间(尽管由于流水线体系结构固有的原因,通常至少需要 2 个周期)。实际上,用数字表示,快速查看 Atom 的 Intel Optimization Reference Manual 似乎表明 SHR、IMUL 和 IDIV(与上述相同的参数)分别具有 2、5 和 57 个延迟周期;对于 64 位操作数,它是 8、14 和 197 个周期。类似的延迟适用于最新的英特尔处理器。

所以,是的,位移比等效的算术运算更快,即使在某些情况下,在现代处理器上,它实际上可能完全没有区别。但在大多数情况下,它是非常重要的。

4. Java 虚拟机会为我执行这样的优化吗?

当然,会的。嗯……当然,而且……最终。

与大多数语言编译器不同,常规 Java 编译器不执行优化。人们认为 Java 虚拟机最适合决定如何针对特定的执行上下文优化程序。这确实在实践中提供了良好的结果。 JIT 编译器对代码的动态有非常深刻的理解,并利用这些知识来选择和应用大量的小代码转换,以生成非常高效的本机代码。

但是将字节码编译成优化的原生方法需要大量的时间和内存。这就是为什么 JVM 甚至不会考虑在代码块被执行数千次之前对其进行优化。然后,即使已安排代码块进行优化,编译器线程实际处理该方法可能还需要很长时间。之后,各种条件可能会导致优化的代码块被丢弃,恢复为字节码解释。

虽然 JSE API 的设计目的是让各种供应商都可以实现,但声称 JRE 也是如此是不正确的。 Oracle JRE 作为参考实现提供给其他人,但不鼓励将其与其他 JVM 一起使用(实际上,在 Oracle 开源 JRE 源代码之前不久,它就被禁止了)。

JRE 源代码中的优化是 JRE 开发人员采用的约定和优化努力的结果,即使在 JIT 优化尚未或根本无法提供帮助的情况下也能提供合理的性能。例如,在调用 main 方法之前会加载数百个类。那时,JIT 编译器还没有获得足够的信息来正确优化代码。在这种情况下,手工优化会产生重要影响。

5.这不是过早的优化吗?

是的,除非有理由不这样做。

现代生活中的一个事实是,每当一个程序员在某处演示代码优化时,另一个程序员都会反对 Donald Knuth 关于优化的引用(嗯,是他的吗?谁知道......)甚至被许多人认为是Knuth 明确断言我们永远不应该尝试优化代码。不幸的是,这是对 Knuth 在过去几十年中对计算机科学的重要贡献的一个重大误解:Knuth 实际上撰写了数千页关于实用代码优化的知识。

正如 Knuth 所说:

程序员会浪费大量时间来思考或担心程序中非关键部分的速度,而在考虑调试和维护时,这些提高效率的尝试实际上会产生强烈的负面影响。我们应该忘记小的效率,比如大约 97% 的时间:过早优化是万恶之源。然而,我们不应该放弃那关键的 3% 的机会。

— Donald E. Knuth,“使用 Goto 语句进行结构化编程”

Knuth 被称为过早优化的是需要大量思考的优化并且仅适用于程序的非关键部分,并且对调试和维护有很大的负面影响.现在,所有这些都可以争论很长时间,但我们不要这样做。

但是应该理解,已被证明有效(即,至少在总体上平均而言)不会对程序的整体构造产生负面影响的小型局部优化不会减少代码的可维护性,并且不需要额外的思考,这根本不是一件坏事。这样的优化实际上很好,因为它们不花钱,我们不应该放弃这样的机会。

然而,最重要的是要记住,在一种情况下对程序员来说微不足道的优化可能在另一种情况下对程序员来说是难以理解的语境。出于这个原因,位移和屏蔽习语尤其成问题。知道该习语的程序员可以不假思索地阅读和使用它,并且这些优化的有效性得到了证明,尽管除非代码包含数百次出现,否则通常微不足道。这些习语很少是错误的实际来源。尽管如此,不熟悉特定习语的程序员会花时间理解特定代码 sn-p 的作用、原因和方式。

最后,是否支持这种优化,以及应该使用哪些习语实际上是团队决策和代码上下文的问题。我个人认为一定数量的习语是所有情况下的最佳实践,任何加入我团队的新程序员都会很快掌握这些习语。更多的习语保留给关键代码路径。放入内部共享代码库的所有代码都被视为关键代码路径,因为它们可能会被这样的关键代码路径调用。无论如何,这是我个人的做法,你的频率可能会有所不同。

【讨论】:

  • 感谢您提供深入合理的答案,该答案解决了其他答案讨论中的所有问题。我认为,如果您添加具有本质的 TLDR,那就完美了!
【解决方案3】:

它使用(n-1) &gt;&gt; 1 而不是(n-1)/2 来查找要反转的内部数组的中间索引。按位移位运算符通常比除法运算符更有效。

【讨论】:

  • 我希望看到一些支持您的主张的基准
  • 这并不是一个随机的说法,它相当普遍。
  • @EvanKnowles 然后给我一些基准的指针应该相当简单。
  • 它来自于 CPU 指令除法(数十个滴答声)和位移位(1 个滴答声)的“执行时间”
  • @SvetlinZarev 这是在 JMH 上:gist.github.com/mtopolnik/465ad156c68816ea5c91 结果是相同的。
【解决方案4】:

在这个方法中,只有这个表达式:(n-1) &gt;&gt; 1。我认为这是您所指的表达方式。这称为右移/移位。它等同于(n-1)/2,但通常被认为更快、更高效。它也经常在许多其他语言中使用(例如在 C/C++ 中)。

请注意,即使您使用像 (n-1)/2 这样的除法,现代编译器也会优化您的代码。所以使用右移没有明显的好处。这更像是编码偏好、风格、习惯的问题。

另见:

Is shifting bits faster than multiplying and dividing in Java? .NET?

【讨论】:

    猜你喜欢
    • 1970-01-01
    • 2017-05-04
    • 2014-07-15
    • 2013-09-17
    • 2011-06-06
    • 2017-01-06
    • 2016-09-27
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多