learn-tech/专栏/代码精进之路/26有哪些招惹麻烦的性能陷阱?.md
2024-10-16 06:37:41 +08:00

19 KiB
Raw Blame History

                        因收到Google相关通知网站将会择期关闭。相关通知内容
                        
                        
                        26 有哪些招惹麻烦的性能陷阱?
                        前面,我们讨论了改善代码性能的最基本的办法。接下来,我们讨论一些最佳实践,让我们先从一些容易被忽略的性能陷阱开始。

使用性能测试工具

今天我们的讲解需要用到一个工具它就是JMH。JMH是为Java语言或者其他基于JVM的编程语言设计的一个基准测试工具。这一节我们会使用这个工具来分析一些性能的陷阱。这里我们简单地介绍下这个工具该怎么使用。

第一步使用Maven工具建立一个基准测试项目需要使用Maven工具

$ mvn archetype:generate
-DinteractiveMode=false
-DarchetypeGroupId=org.openjdk.jmh
-DarchetypeArtifactId=jmh-java-benchmark-archetype
-DgroupId=com.example
-DartifactId=myJmh
-Dversion=1.0

这个命令行会生成一个myJmh的工程目录和一个基准测试模板文件myJmh/src/main/java/com/example/MyBenchmark.java。通过更改这个测试模板就可以得到你想要的基准测试了。

比如你可以使用后面我们用到的基准测试代码替换掉模板中的基准测试方法measureStringApend

package com.example;

import org.openjdk.jmh.annotations.Benchmark;

public class MyBenchmark { @Benchmark public String measureStringApend() { String targetString = ""; for (int i = 0; i < 10000; i++) { targetString += "hello"; }

    return targetString;
}

}

第二步,编译基准测试:

$ cd myJmh $ mvn clean install

第三步,运行你的基准测试:

$ cd myJmh $ Java -jar target/benchmarks.jar

稍微等待基准测试结果就出来了。我们需要关注的是”Score”这一栏它表示的是每秒钟可以执行的基准测试方法的次数。

Benchmark Mode Cnt Score Error Units MyBenchmark.testMethod thrpt 25 35.945 ▒ 0.694 ops/s

这是JMH工具基本的使用流程有关这个工具更多的选项和更详细的使用需要你参考JMH的相关文档。

下面我们通过字符串连接操作和哈希值的例子来谈论一下这个工具要怎么使用以及对应的性能问题。同时我们再看看其他影响性能的一些小陷阱比如内存的泄露、未关闭的资源和遗漏的hashCode。

字符串的操作

在Java的核心类库里有三个字符串操作的类分别问String、StringBuilder和StringBuffer。通过下面的基准测试我们来了解下这三种不同的字符串操作的性能差异。为了方便我把JMH测试的数据标注在每个基准测试的方法注释里了。

// JMH throughput benchmark: about 32 operations per second
@Benchmark
public String measureStringApend() {
    String targetString = "";
    for (int i = 0; i < 10000; i++) {
        targetString += "hello";
    }

    return targetString;
}


// JMH throughput benchmark: about 5,600 operations per second
@Benchmark
public String measureStringBufferApend() {
    StringBuffer buffer = new StringBuffer();
    for (int i = 0; i < 10000; i++) {
        buffer.append("hello");
    }

    return buffer.toString();
}


// JMH throughput benchmark: about 21,000 operations per second
@Benchmark
public String measureStringBuilderApend() {
    StringBuilder builder = new StringBuilder();
    for (int i = 0; i < 10000; i++) {
        builder.append("hello");
    }

    return builder.toString();
}

对于字符串连接的操作这个基准测试结果显示使用StringBuffer的字符串连接操作比使用String的操作快了近200倍使用StringBuilder 的字符串连接操作比使用String的操作快了近700倍。

String的字符串连接操作为什么慢呢 这是因为每一个字符串连接的操作targetString += “hello”都需要创建一个新的String对象然后再销毁再创建。这种模式对CPU和内存消耗都比较大。

StringBuilder和StringBuffer为什么快呢因为StringBuilder和StringBuffer的内部实现预先分配了一定的内存。字符串操作时只有预分配内存不足才会扩展内存这就大幅度减少了内存分配、拷贝和释放的频率。

StringBuilder为什么比StringBuffer还要快呢StringBuffer的字符串操作是多线程安全的而StringBuilder的操作就不是。如果我们看这两个方法的实现代码除了线程安全的同步以外几乎没有差别。

public final class StringBuffer extends AbstractStringBuilder implements java.io.Serializable, Comparable, CharSequence { // snipped

@Override
@HotSpotIntrinsicCandidate
public synchronized StringBuffer append(String str) {
    toStringCache = null;
    super.append(str);
    return this;
}

//  snipped

}

public final class StringBuilder extends AbstractStringBuilder implements java.io.Serializable, Comparable, CharSequence { // snipped

@Override
@HotSpotIntrinsicCandidate
public StringBuilder append(String str) {
    super.append(str);
    return this;
}

// snipped

}

JMH的基准测试并没有涉及到线程同步问题难道使用synchronized关键字也会有性能损耗吗

我们再来看看另外一个基准测试。这个基准测试使用线程不安全的StringBuilder以及同步的字符串连接部分模拟了线程安全的StringBuffer.append()方法的实现。为了方便你对比,我把没有使用同步的代码也拷贝在下面。

// JMH throughput benchmark: about 21,000 operations per second
@Benchmark
public String measureStringBuilderApend() {
    StringBuilder builder = new StringBuilder();
    for (int i = 0; i < 10000; i++) {
        builder.append("hello");
    }

    return builder.toString();
}


// JMH throughput benchmark: about 16,000 operations per second
@Benchmark
public String measureStringBuilderSynchronizedApend() {
    StringBuilder builder = new StringBuilder();
    for (int i = 0; i < 10000; i++) {
        synchronized (this) {
            builder.append("hello");
        }
    }

    return builder.toString();
}

这个基准测试结果显示虽然基准测试并没有使用多个线程但是使用了线程同步的代码比不使用线程同步的代码慢。线程同步就是StringBuffer比StringBuilder慢的原因之一。

通过上面的基准测试,我们可以得出这样的结论:

频繁的对象创建、销毁,有损代码的效率;

减少内存分配、拷贝、释放的频率,可以提高代码的效率;

即使是单线程环境,使用线程同步依然有损代码的效率。

从上面的基准测试结果是不是可以得出结论我们应该使用StringBuilder来进行字符串操作呢我们再来看几个基准测试的例子。

下面的例子测试的是常量字符串的连接操作。从测试结果我们可以看出使用String的连接操作要比使用StringBuilder的字符串连接快5万倍这是一个让人惊讶的性能差异。

// JMH throughput benchmark: about 1,440,000,000 operations per second
@Benchmark
public void measureSimpleStringApend() {
    for (int i = 0; i < 10000; i++) {
        String targetString = "Hello, " + "world!";
    }
}


// JMH throughput benchmark: about 26,000 operations per second
@Benchmark
public void measureSimpleStringBuilderApend() {
    for (int i = 0; i < 10000; i++) {
        StringBuilder builder = new StringBuilder();
        builder.append("hello, ");
        builder.append("world!");
    }
}

这个巨大的差异主要来自于Java编译器和JVM对字符串处理的优化。” Hello, “ + ” world! “ 这样的表达式并没有真正执行字符串连接。编译器会把它处理成一个连接好的常量字符串”Hello, world!“。这样,也就不存在反复的对象创建和销毁了,常量字符串的连接显示了超高的效率。

如果字符串的连接里出现了变量编译器和JVM就没有办法进行优化了。这时候StringBuilder的效率优势才能体现出来。下面的两个基准测试结果就显示了变量对于字符长连接操作效率的影响。

// JMH throughput benchmark: about 9,000 operations per second
@Benchmark
public void measureVariableStringApend() {
    for (int i = 0; i < 10000; i++) {
        String targetString = "Hello, " + getAppendix();
    }
}


// JMH throughput benchmark: about 26,000 operations per second
@Benchmark
public void measureVariableStringBuilderApend() {
    for (int i = 0; i < 10000; i++) {
        StringBuilder builder = new StringBuilder();
        builder.append("hello, ");
        builder.append(getAppendix());
    }
}



private String getAppendix() {
   return "World!";
}

通过上面的基准测试,我们可以总结出下面的几条最佳实践:

Java的编译器会优化常量字符串的连接我们可以放心地把长的字符串换成多行

带有变量的字符串连接StringBuilder效率更高。如果效率敏感的代码建议使用StringBuilder。String的连接操作可读性更高效率不敏感的代码可以使用比如异常信息、调试日志、使用不频繁的代码

如果涉及大量的字符串操作使用StringBuilder效率更高

除非有线程安全的需求不推荐使用线程安全的StringBuffer。

内存的泄露

内存泄漏是C语言的一个大问题。为了更好地管理内存Java提供了自动的内存管理和垃圾回收机制。但是Java依然会泄露内存。这种内存泄漏的主要表现是如果一个对象不再有用处而且它的引用还没有清零垃圾回收器就意识不到这个对象需要及时回收这时候就引发了内存泄露。

生命周期长的集合是Java容易发生内存泄漏的地方。比如可以扩张的静态的集合或者存活时间长的缓存等。如果不能及时清理掉集合里没有用处的对象就会造成内存的持续增加引发内存泄漏问题。

比如下面这两个例子,就容易发生内存泄露。

静态的集合:

static final List