2年前 (2022-02-28)  jvm |   抢沙发  346 
文章评分 0 次,平均分 0.0
[收起] 文章目录

本文概述了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会检查实际使用对象的位置。对象的分析结果可以是:

  1. GlobalEscape——对象离开当前方法,可能还有线程。(例如:对象从方法返回,存储在字段中)
  2. ArgEscape——该对象被传递给其他被调用的方法,但它不会像具有state GlobalEscape的对象那样逃逸。
  3. 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对象通常不会转义到其他线程,可以省略同步。VectorArrayList也是如此。

检查JIT行为

如果想更深入地了解JVM的功能,可以通过分析热点JVM的运行时行为来实现。

参数-XX:+UnlockDiagnosticVMOptions -XX:+TraceClassLoading -XX:+LogCompilation指示JVM将JIT操作写入日志文件。该日志文件名为hotspot_pid20172。记录pid后的数字是流程标识符。这个文件包含大量解释JVM正在做什么的XML。

这个文件可以用JITWatch打开。JITWatch(https://github.com/AdoptOpenJDK/jitwatch)有一个用户界面,可以显示:

  • 内联决策
  • JIT编译时间表
  • 字节码大小和本机代码大小
  • 例如,“内联故障原因”的顶级列表
  • 内联方法大小、编译时间等的直方图。
  • 消除分配报告(标量替换)
  • 优化锁报告
  • 源代码→ 字节码→ 装配

下图显示了用户界面:

JVM是如何优化代码的

编写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.htmlshipilev.net/jvm/anatomy-quarks/18-scalar-replacement

结论

JIT编译器有许多很酷的技术可以在运行时优化Java代码。这是具有字节码的虚拟机优于机器代码的优势。通常你不必担心正在使用的优化,你可以享受更快的执行速度。然而,如果你曾经像我一样编写过一个幼稚的基准测试,你可能会遇到它们。这可能会让你误以为它的性能比在生产中要高得多。

本文的另一个优点是,过早地优化代码不太可能像JIT编译器应用的优化那样有效;首先编写干净的代码。

 

除特别注明外,本站所有文章均为老K的Java博客原创,转载请注明出处来自https://javakk.com/2587.html

关于

发表评论

表情 格式

暂无评论

登录

忘记密码 ?

切换登录

注册