本文概述了Hotspot JVM用于提高性能的一些优化技术。首先,我将给出一个小例子,说明我在编写一个简单的基准测试时是如何遇到这些优化的。然后用一个简短的例子解释每个优化,最后给出一些如何分析自己代码的指针。
背景
很久以前,我用Java编写了一个简单的度量库,并使用了类似的实现。我希望他们的表现可以与之媲美,尽管有一点优势。在对这两个库进行基准测试后,我发现Java的速度要快得多。对于这样一件简单的事情来说,这没有多大意义,所以我研究了这两个库的运行时行为,并注意到Go实现大部分时间都在等待获取和释放锁。我在Java中没有看到这种行为。原因是由于我的测试设置,Java JIT编译器在运行时优化了代码。在本文中,我想提到Java运行时可能会做的一些优化。
简化示例
我将提供一个度量库的简化和人为实现,以说明JIT编译器的一些优化技术。度量标准库记录仪表。仪表是可以上升或下降的单个值,例如,活动会话。该库可按如下方式使用:
METRICS.gauge("sessions.active", Container.activeSessions());
METRICS.gauge()
可以通过几种方式实现,但现在让我们假设gauge()
方法获取锁,更新内部状态,然后释放锁:
public class Gauge {
public long timesModified = 0;
public long value = 0;
}
public class Metrics {
public static final Metrics METRICS = new Metrics();
private final Lock lock = new ReentrantLock();
private final Map<String, Gauge> state = new HashMap<>();
public void gauge(String name, long newValue) {
try {
lock.lock();
Gauge gauge = state.computeIfAbsent(name, n -> new Gauge());
gauge.timesModified++;
gauge.value = newValue;
} finally {
lock.unlock();
}
}
public Gauge getGauge(name) {
try {
lock.lock();
return state.get(name);
} finally {
lock.unlock();
}
}
}
现在,让我们添加一个性能测试来测量度量标准库的开销。测试只是在循环中多次调用gauge()
方法并打印持续时间(同样,这是一个人为的示例!):
@Before
public void warmUpJVM() {
// For example see: https://www.baeldung.com/java-jvm-warmup
}
@Test
public void testPerformanceSingleThreaded() {
int numIterations = 500000;
long start = System.currentTimeMillis();
for (int i = 0; i < numIterations; i++) {
METRICS.gauge("test.metric", i);
}
Gauge gauge = METRICS.getGauge("test.metric")
assertThat(gauge.getTimesCalled()).isEqualTo(numIterations);
assertThat(gauge.getValue()).isEqualTo(499999);
long duration = System.currentTimeMillis() - start;
System.out.println(String.format("Java %d iterations took = %d ms", numIterations, duration));
}
测试运行得非常快。想象一下,您还拥有这个库的Go实现。如果你建立了这个,输出结果很可能如下所示:
Java 500000 iterations took 30ms;
Golang 500000 iterations took 210ms;
为什么差别会这么大?经过一些调查,JVM似乎在运行时对代码进行了优化,而编译后的Golang代码则完全按照要求执行,并在每次迭代中获取锁。Golang这样做是因为它直接编译为本机代码。另一方面,Java虚拟机解释字节码,字节码可以进一步优化。
JIT优化
以下几节将讨论openjdk JVM的一些优化。这是Java源代码如何编译和执行的简要概述:
Java源代码被编译成字节码。字节码是一种可以由虚拟机解释的中间格式。
当JVM启动时,它会解释字节码并执行它。
JVM或JIT编译器监视运行时经常使用的字节码,即所谓的“热”代码。热代码可以进一步优化并编译成比解释快得多的平台本机代码。
内联
内联(Inlining)是最简单但应用最广泛的技术之一。它用方法体替换方法调用。这减少了方法调用的开销。
public long multiply(long x, long y) {
return x * y;
}
public void calculator() {
long result = multiply(10, 20);
}
乘法将是内联的最佳选择:
public void calculator() {
long result = 10 * 20;
}
锁粗化
JVM可以在运行时检测到在循环中调用了锁。然后,它可以决定自动重写代码,以便每n次迭代只获取一次锁,从而减少开销。这种技术被称为“锁粗化”。下面的代码给出了一个大概的想法,说明了这是如何实现的(注意,可以!)看:
// Runtime version of the gauge() method after lock coarsening
public void gauge(String eventName, long newValue) {
Gauge gauge = state.computeIfAbsent(name, n -> new Gauge());
gauge.timesModified++;
gauge.value = newValue;
}
// Possible runtime version of the loop inside the test
METRICS.lock.lock();
try {
for (int i = 0; i < 20; i++) {
METRICS.gauge("test.metric", i);
}
} finally {
METRICS.lock.unlock();
}
METRICS.lock.lock();
try {
for (int i = 20; i < 40; i++) {
METRICS.gauge("test.metric", i);
}
} finally {
METRICS.lock.unlock();
}
...
METRICS.lock.lock();
try {
for (int i = 499960; i < numIterations; i++) {
METRICS.gauge("test.metric", i);
}
} finally {
METRICS.lock.unlock();
}
锁粗化也适用于在一行中多次使用锁的代码。例如,在同一对象上调用多个同步方法时。编译器可以选择只执行一次,而不是释放并重新获取锁。
下面的代码让您了解这可能是什么样子:
lock.lock();
//do something
lock.unlock();
lock.lock();
//do another thing
lock.unlock();
这有时可以转化为:
lock.lock();
//do something
//do another thing
lock.unlock();
循环展开
如果必须在代码中多次执行同一操作,则使用循环。在每次迭代结束时,CPU需要跳回循环的开始。跳转对于CPU来说可能是一个代价高昂的操作,类似于缓存未命中,这意味着必须转到主内存。
JIT编译器可以通过展开“热代码”中的循环来优化它们。一个简单的循环:
for(int i = 0; i < numIterations; i++) {
METRICS.gauge("test.metric", i);
}
可以按如下方式展开:
for(int i = 0; i < numIterations; i += 5) {
METRICS.gauge("test.metric", i);
METRICS.gauge("test.metric", i+1);
METRICS.gauge("test.metric", i+2);
METRICS.gauge("test.metric", i+3);
METRICS.gauge("test.metric", i+4);
}
展开循环的主要好处是更少的跳跃,从而允许更高效的执行。
逃逸分析
JVM使用的另一种技术是逃逸分析。它本身并不是一种优化,但它使锁省略和标量替换等其他优化成为可能。
在转义分析期间,JVM会检查实际使用对象的位置。对象的分析结果可以是:
- GlobalEscape——对象离开当前方法,可能还有线程。(例如:对象从方法返回,存储在字段中)
- ArgEscape——该对象被传递给其他被调用的方法,但它不会像具有state GlobalEscape的对象那样逃逸。
- NoEscape——对象是局部的,不会“转义”方法。
标量置换
当对象具有状态NoEscape时,JIT编译器有时可以选择不分配对象,而是将对象的字段视为局部变量。对象的字段通常也可以存储在CPU寄存器中,从而使其速度更快。
下面的代码包含一个非常简单且精心设计的示例,其中方法newId()
创建一个永远不会离开newId()
范围的对象。这将被归类为NoEscape。
public class IdGenerator {
private long seed = System.nanoTime();
public long getId() {
return seed;
}
}
public class IdUtil {
public static long newId() {
IdGenerator generator = new IdGenerator();
return generator.getId();
}
}
在这种情况下,编译器可以选择不分配IdGenerator
对象,而只是将seed
的值放入寄存器中,并使用该值执行getId()
方法。这比内存访问快得多。未分配的重写代码可能如下所示:
public class IdUtil {
public static long newId() {
return System.nanoTime();
}
}
锁省略
转义分析还支持锁省略。当JVM看到锁只能从一个线程访问时,它可以完全移除锁!
我们的测试在循环中多次调用度量库。这意味着在我们的单线程性能测试中,锁可能会被优化掉!这显然对性能有好处,但也意味着我们的测试毫无价值,因为在生产环境中,度量库可能是从不同的线程而不是在循环中调用的。
在Java StringBuilder
的早期,由于StringBuffer
是同步的,所以它的性能比StringBuffer
好。由于转义分析和锁省略等技术,今天的性能差异要小得多。StringBuffer
对象通常不会转义到其他线程,可以省略同步。Vector
和ArrayList
也是如此。
检查JIT行为
如果想更深入地了解JVM的功能,可以通过分析热点JVM的运行时行为来实现。
参数-XX:+UnlockDiagnosticVMOptions
-XX:+TraceClassLoading
-XX:+LogCompilation
指示JVM将JIT操作写入日志文件。该日志文件名为hotspot_pid20172。记录pid后的数字是流程标识符。这个文件包含大量解释JVM正在做什么的XML。
这个文件可以用JITWatch打开。JITWatch(https://github.com/AdoptOpenJDK/jitwatch)有一个用户界面,可以显示:
- 内联决策
- JIT编译时间表
- 字节码大小和本机代码大小
- 例如,“内联故障原因”的顶级列表
- 内联方法大小、编译时间等的直方图。
- 消除分配报告(标量替换)
- 优化锁报告
- 源代码→ 字节码→ 装配
下图显示了用户界面:
编写benchmarks基准
如果您决定编写基准benchmarks测试,我强烈建议您使用JMH(Java微基准测试工具)。JMH是由与JVM本身相同的开发人员构建的。它负责预热JVM,并有助于减少编写良好基准测试的一些陷阱。
让我们看看是否可以使用JMH验证JIT编译器标量优化。
@Warmup(iterations = 3, time = 1, timeUnit = TimeUnit.SECONDS)
@Measurement(iterations = 3, time = 1, timeUnit = TimeUnit.SECONDS)
@Fork(3)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@State(Scope.Benchmark)
public class JITBenchmark {
public class IdGenerator {
private long seed = System.nanoTime();
public long getId() {
return seed;
}
}
@Benchmark
public long scalarReplacement() {
IdGenerator idGenerator = new IdGenerator();
return idGenerator.getId();
}
}
当使用-prof gc
运行此测试时,基准会打印分配率和gc计数:
Benchmark Mode Cnt Score Error Units
JITBenchmark.scalarReplacement avgt 9 19.110 ± 1.007 ns/op
JITBenchmark.scalarReplacement:·gc.alloc.rate avgt 9 ≈ 10⁻⁴ MB/sec
JITBenchmark.scalarReplacement:·gc.alloc.rate.norm avgt 9 ≈ 10⁻⁵ B/op
JITBenchmark.scalarReplacement:·gc.count avgt 9 ≈ 0 counts
如您所见,没有分配任何对象。
您可以查看此存储库,自己测试它:
git clone https://github.com/toefel18/jmh-jit.git
cd jmh-jit
mvn clean install
java -jar target/benchmarks.jar -prof gc
如果你想更多地了解JMH,一个好的起点是:oracle.com/technical-resources/articles/java/architect-benchmarking.html 和 shipilev.net/jvm/anatomy-quarks/18-scalar-replacement
结论
JIT编译器有许多很酷的技术可以在运行时优化Java代码。这是具有字节码的虚拟机优于机器代码的优势。通常你不必担心正在使用的优化,你可以享受更快的执行速度。然而,如果你曾经像我一样编写过一个幼稚的基准测试,你可能会遇到它们。这可能会让你误以为它的性能比在生产中要高得多。
本文的另一个优点是,过早地优化代码不太可能像JIT编译器应用的优化那样有效;首先编写干净的代码。
除特别注明外,本站所有文章均为老K的Java博客原创,转载请注明出处来自https://javakk.com/2587.html
暂无评论