為什麼 Java 中 2 * (i * i) 比 2 * i * i 快?
1. 概述
優化程式碼時,即使表達式語法的微小差異也會影響效能。一個這樣的例子是 Java 中2 * (i * i)和2 * i * i之間的差異。乍一看,這兩個表達式可能看起來相同,但它們評估方式的細微差別可能會導致效能差異。
在本教程中,我們將探討為什麼2 * (i * i)通常比2 * i * i更快,並深入探討根本原因。讓我們開始吧。
2. 理解表達方式
讓我們來分解這兩個表達式。在此表達式中,先進行i * i的乘法,然後將結果乘以2 :
2 * (i * i)
在此表達式中,計算從左到右進行:
2 * i * i
首先計算2 * i ,然後將結果乘以i 。
3. 效能比較
儘管兩個表達式理論上產生相同的結果,但操作順序會影響效能。
3.1.編譯器最佳化
Java 編譯器(例如 JVM 中的即時 (JIT) 編譯器)非常複雜,可以在執行時間最佳化程式碼。然而,編譯器在很大程度上依賴程式碼的清晰度來進行最佳化:
-
2 * (i * i):括號清楚定義了運算順序,使編譯器更容易最佳化乘法。 -
2 * i * i:較不明確的操作順序可能會導致最佳化效率較低。編譯器可能無法像2 * (i * i)那樣有效地最佳化程式碼。
本質上, 2 * (i * i)為編譯器提供如何執行計算的明確指示,這可以產生更好優化的字節碼。
3.2.整數溢位注意事項
當計算產生的值大於int可以儲存的最大值(32 位元整數為 2^31 – 1)時,就會發生整數溢位。雖然這兩個表達式本質上都比另一個表達式更容易導致溢出,但計算的結構方式會影響溢出的處理方式:
-
2 * i * i:如果i很大,2 * i的中間結果可能會接近溢出閾值。這可能會導致最終乘以i期間出現潛在問題。 -
2 * (i * i):在這個表達式中,我們更能理解在對 i 平方後乘以2是否會導致溢位i.因此,這使得表達式在涉及大值的場景中稍微安全一些。
3.3. CPU級執行
在 CPU 級別,指令會依照特定順序執行,這些順序會根據操作的分組方式而變化:
-
2 * (i * i):CPU 可能會更好地最佳化此操作。因為平方(i * i)比較簡單。 -
2 * i * i:CPU 可能需要額外的週期來處理2 * i的中間結果,特別是當這個結果很大時。
在大多數現實場景中,對於較小的i值, 2 * (i * i)和2 * i * i之間的效能差異可能很小。然而,當i很大或在效能關鍵程式碼段中重複執行此操作時,差異可能會變得很大。
4. 使用 JMH 進行效能測試
現在,為了證明這個理論,讓我們用實際數據來玩一下。更準確地說,我們將展示最常見的集合操作的 JMH(Java Microbenchmark Harness)測試結果。
首先,我們將介紹基準測試的主要參數:
@State(Scope.Thread)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@Warmup(iterations = 3, time = 1, timeUnit = TimeUnit.SECONDS)
@Measurement(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS)
@Fork(1)
public class MultiplicationBenchmark {
}
然後我們將預熱迭代次數設定為3 。
現在,是時候為以下小值和大值添加基準測試了:
private int smallValue = 255;
private int largeValue = 2187657;
@Benchmark
public int testSmallValueWithParentheses() {
return 2 * (smallValue * smallValue);
}
@Benchmark
public int testSmallValueWithoutParentheses() {
return 2 * smallValue * smallValue;
}
@Benchmark
public int testLargeValueWithParentheses() {
return 2 * (largeValue * largeValue);
}
@Benchmark
public int testLargeValueWithoutParentheses() {
return 2 * largeValue * largeValue;
}
以下是我們帶括號和不帶括號計算的測試結果:
Benchmark Mode Cnt Score Error Units
MultiplicationBenchmark.largeValueWithParentheses avgt 5 1.066 ± 0.168 ns/op
MultiplicationBenchmark.largeValueWithoutParentheses avgt 5 1.283 ± 0.392 ns/op
MultiplicationBenchmark.smallValueWithParentheses avgt 5 1.173 ± 0.218 ns/op
MultiplicationBenchmark.smallValueWithoutParentheses avgt 5 1.222 ± 0.287 ns/op
結果顯示基於是否存在括號的不同乘法場景所花費的平均時間(每次運算以奈秒為單位, ns/op )。
這是一個細分:
MultiplicationBenchmark.largeValueWithParentheses (1.066 ± 0.168 ns/op):
- 這表示將大值與括號相乘。
- 平均耗時 1.066 奈秒,誤差範圍為 ±0.168。
MultiplicationBenchmark.largeValueWithoutParentheses (1.283 ± 0.392 ns/op):
- 這表示不帶括號的大值相乘。
- 平均時間為 1.283 奈秒,誤差範圍為 ±0.392。
MultiplicationBenchmark.smallValueWithParentheses (1.173 ± 0.218 ns/op):
- 這表示將小值與括號相乘。
- 平均時間為 1.173 奈秒,誤差範圍為 ±0.218。
MultiplicationBenchmark.smallValueWithoutParentheses (1.222 ± 0.287 ns/op):
- 這表示不帶括號的小值相乘。
- 平均時間為 1.222 奈秒,誤差範圍為 ±0.287。
更快的方法:涉及帶括號的大值的乘法是最快的(1.066 ns/op)。
較慢的方法:不帶括號的大值花費最多時間(1.283 ns/op)。
5. 結論
在本文中,我們看到雖然2 * (i * i)和2 * i * i產生相同的結果,但2 * (i * i)通常更快。括號在大值和小值乘法中提供了輕微的速度優勢,儘管差異很小並且在較小值的誤差範圍內。
這表明括號可能會導致乘法稍微更優化,但效能差異很小。它提供了更可預測的中間結果、更好的編譯器最佳化機會、降低的溢位風險以及更有效率的 CPU 執行。在編寫效能關鍵的程式碼時,我們不僅應該考慮正確性,還應該考慮程式碼的執行方式。微小的變化(例如括號的放置)可以顯著影響效能,這表明理解語言和硬體機制的重要性。
與往常一樣,所有這些範例的原始程式碼都可以 在 GitHub 上取得。