如果性能很重要,我应该使用Java的String.format()吗?

247
我们经常需要构建字符串以用于日志输出等。随着JDK的不断更新,我们学会了在什么情况下使用 StringBuffer(多次追加,线程安全)和 StringBuilder(多次追加,非线程安全)。
那么使用 String.format() 的建议是什么?它是否有效率,或者我们被迫在性能重要的单行连接中坚持使用串联?
例如,丑陋的老式写法:
String s = "What do you get if you multiply " + varSix + " by " + varNine + "?";

与整洁的新风格(例如可能更慢的String.format)相比较,

String s = String.format("What do you get if you multiply %d by %d?", varSix, varNine);

注意:我的具体用例是我代码中数百个单行日志字符串。它们不涉及循环,因此使用StringBuilder 太重了。我对String.format()特别感兴趣。


32
为什么不试试看? - Ed S.
1
如果您正在生成此输出,则我认为它必须以人类可以阅读的速度进行阅读。最多每秒10行。我认为您会发现,无论采取哪种方法,都不会有太大影响,如果概念上较慢,用户可能会欣赏它。;) 因此,在大多数情况下,StringBuilder并不是很重量级。 - Peter Lawrey
11
@Peter,不,这绝对不是用于供人类实时阅读的!它是为了在出现问题时帮助分析。日志输出通常每秒会有数千行,因此需要高效处理。 - Air
5
如果您每秒钟产生很多千行代码,我建议1)使用更短的文本,甚至没有文本,例如普通的CSV或二进制格式。2)完全不要使用String,在不创建任何对象(如文本或二进制)的情况下将数据写入ByteBuffer中。3)将数据写入磁盘或套接字的操作放到后台。您应该能够维持每秒大约100万行的速度(基本上取决于您的磁盘子系统)。您可以实现10倍于此的突发速率。 - Peter Lawrey
7
这与一般情况无关,但对于特定的日志记录而言,由原始的Log4j作者编写的LogBack具有一种参数化日志记录形式,可以解决这个确切的问题。详情请见 http://logback.qos.ch/manual/architecture.html#ParametrizedLogging - Matt Passell
显示剩余2条评论
13个回答

263

我借鉴了hhafez的代码,并增加了内存测试

private static void test() {
    Runtime runtime = Runtime.getRuntime();
    long memory;
    ...
    memory = runtime.freeMemory();
    // for loop code
    memory = memory-runtime.freeMemory();

我会为每种方法单独运行它们,即 '+' 运算符,String.format和StringBuilder(调用toString()),因此所使用的内存不会受到其他方法的影响。 我添加了更多的连接,使字符串变成“Blah”+ i +“Blah”+ i +“Blah”+ i +“Blah”。
结果如下(每个渐进均值):
+运算符:747毫秒,分配的内存为320,504
String.format:16484毫秒,分配的内存是373,312
StringBuilder:769毫秒,分配的内存为57,344
我们可以看出,String + 和 StringBuilder 在时间上几乎相同,但 StringBuilder 的内存使用效率要高得多。当我们在时间间隔很短的时间内有许多日志调用(或任何涉及字符串的语句)时,这一点非常重要,以至于垃圾收集器无法清理由 + 操作符产生的许多字符串实例。
顺便提一下,别忘了在构造消息之前检查日志级别。
结论:
我将继续使用StringBuilder。
我要么时间太多,要么生命太少。

9
“在构建消息之前不要忘记检查日志记录级别”是一条好建议,这应该至少针对调试信息执行,因为可能会有很多此类信息,并且它们不应在生产中启用。 - stivlo
50
不,这不正确。很抱歉直言,但它所吸引的赞数简直令人震惊。使用“+”运算符编译成等效的“StringBuilder”代码。像这样的微基准测试并不是衡量性能的好方法-为什么不使用jvisualvm呢,毕竟它在jdk中有原因。String.format()会更慢,但由于解析格式字符串的时间而不是任何对象分配。推迟创建日志记录工件直到确定需要它们确实是一个好建议,但如果它会产生性能影响,那么就放错了地方。 - CurtainDog
1
@CurtainDog,您的评论是在一篇四年前的帖子上发表的,您能否指出文档或创建一个单独的答案来解决这个差异? - kurtzbot
1
支持@CurtainDog评论的参考文献:https://dev59.com/JnI_5IYBdhLWcg3wFu_L#1532499。也就是说,在不涉及循环的情况下,+更受欢迎。 - apricot
顺便提一下,不要忘记在构造消息之前检查日志记录级别并不是好建议。假设我们特指java.util.logging.*,检查日志记录级别是指当您谈论执行高级处理时,会对程序产生不良影响,而当程序未将日志记录打开到适当的级别时,您不希望出现这种情况。字符串格式化根本不属于那种类型的处理。格式化是java.util.logging框架的一部分,记录器本身在调用格式化程序之前就会检查日志记录级别。 - searchengine27

135

我写了一个小类来测试哪个性能更好,使用+号比format快5到6倍。自己试试看。

import java.io.*;
import java.util.Date;

public class StringTest{

    public static void main( String[] args ){
    int i = 0;
    long prev_time = System.currentTimeMillis();
    long time;

    for( i = 0; i< 100000; i++){
        String s = "Blah" + i + "Blah";
    }
    time = System.currentTimeMillis() - prev_time;

    System.out.println("Time after for loop " + time);

    prev_time = System.currentTimeMillis();
    for( i = 0; i<100000; i++){
        String s = String.format("Blah %d Blah", i);
    }
    time = System.currentTimeMillis() - prev_time;
    System.out.println("Time after for loop " + time);

    }
}

运行上述代码以测试不同的N值,结果显示两者都呈现线性增长,但是String.format要慢5-30倍。

原因在于当前实现中,String.format首先使用正则表达式解析输入内容,然后再填充参数。另一方面,使用加号进行字符串拼接会被javac优化(而非JIT),直接使用StringBuilder.append方法。

Runtime comparison


14
这个测试中存在一个缺陷,即它并不能完全代表所有字符串格式。通常需要逻辑来确定包含什么内容以及将特定的值格式化为字符串的逻辑。任何真实的测试都应该考虑到现实世界的情况。 - Orion Adrian
9
关于+和StringBuffer的问题,Stack Overflow上有另一个帖子。在Java的最新版本中,如果可能的话,+被替换为StringBuffer,以确保性能没有差别。 - hhafez
26
这看起来非常像那种会被优化得毫无用处的微基准测试。 - David H. Clements
22
又一个实现不佳的微基准测试。这两种方法如何按数量级进行扩展?试试使用100、1000、10000、1000000次操作吧。如果你只在一个数量级上运行一次测试,并且应用程序没有在独立核心上运行,那么就无法确定差异中多少可以归因于上下文切换、后台进程等“副作用”。请注意,本翻译仅代表原文意思,未附加解释或其他内容。 - Evan Plaice
9
由于您永远不会离开主JIT,因此JIT无法启动。 - Jan Zyka
显示剩余5条评论

34

这里呈现的所有基准测试都存在一些缺陷,因此结果并不可靠。

我很惊讶没有人使用JMH进行基准测试,所以我使用了它。

结果:

Benchmark             Mode  Cnt     Score     Error  Units
MyBenchmark.testOld  thrpt   20  9645.834 ± 238.165  ops/s  // using +
MyBenchmark.testNew  thrpt   20   429.898 ±  10.551  ops/s  // using String.format

单位是每秒操作次数,数量越多越好。基准测试源代码。使用了OpenJDK IcedTea 2.5.4 Java虚拟机。

因此,旧式方法(使用+)速度更快。


21

你旧的丑陋样式将自动由JAVAC 1.6编译为:

StringBuilder sb = new StringBuilder("What do you get if you multiply ");
sb.append(varSix);
sb.append(" by ");
sb.append(varNine);
sb.append("?");
String s =  sb.toString();

所以使用String.format和使用StringBuilder是完全没有区别的。

使用String.format会更加复杂,因为它会创建一个新的Formatter,解析你的输入格式字符串,创建一个StringBuilder,并将其全部拼接起来并调用toString()方法。


就易读性而言,您发布的代码比String.format("What do you get if you multiply %d by %d?", varSix, varNine)要麻烦得多。 - dusktreader
17
实际上,+StringBuilder 之间没有区别。不幸的是,在这个帖子的其他答案中存在很多错误信息。我几乎想把问题改成“我应该如何不测量性能”。 - CurtainDog

12

Java的String.format工作方式如下:

  1. 它解析格式字符串,将其分解成格式块列表
  2. 它迭代格式块,并将其渲染到一个StringBuilder中,这基本上是一个数组,根据需要调整大小,方法是将其复制到一个新的数组中。这是必需的,因为我们还不知道要分配多大的最终字符串。
  3. StringBuilder.toString()将其内部缓冲区复制到一个新的字符串中

如果这些数据的最终目标是流(例如呈现网页或写入文件),则可以直接将格式块组装到流中:

new PrintStream(outputStream, autoFlush, encoding).format("hello {0}", "world");

我猜测优化器会优化掉格式化字符串的处理。如果是这样的话,你将得到与手动展开String.format为StringBuilder相同的分摊性能。


5
我不认为你对格式化字符串处理进行优化的猜测是正确的。在使用Java 7进行一些真实世界的测试中,我发现在内部循环中使用String.format(运行数百万次)导致超过10%的执行时间花费在java.util.Formatter.parse(String)上。这似乎表明,在内部循环中,应避免调用Formatter.format或任何调用它的函数,包括PrintStream.format(在我看来是Java标准库中的缺陷,特别是因为无法缓存解析后的格式字符串)。 - Andy MacKinlay

8
扩展/更正上面的第一个答案,实际上String.format并不会帮助翻译。当你打印日期/时间(或数字格式等)时,String.format将帮助处理本地化差异(即,某些国家将打印04Feb2009,而其他国家将打印Feb042009)。至于翻译,你只需要将任何可外部化的字符串(例如错误消息等)移入属性包中,以便你可以使用正确的语言包,使用ResourceBundle和MessageFormat。

综上所述,从性能的角度来看,String.format与纯连接取决于你的偏好。如果你喜欢调用.format而不是连接,请尽管使用它。毕竟,代码被阅读的次数比被编写的次数多得多。

1
我认为这是不正确的。就性能而言,字符串连接要好得多。有关更多详细信息,请查看我的答案。 - Adam Stelmaszczyk

7
在您的例子中,性能可能不会有太大差异,但还有其他问题需要考虑:即内存碎片化。即使是临时的连接操作,也会创建一个新字符串(它需要时间来进行垃圾回收,而且更加繁琐)。String.format() 更易读,并且涉及的碎片更少。
此外,如果您经常使用特定格式,请勿忘记可以直接使用 Formatter() 类(所有 String.format() 做的就是实例化一个一次性的 Formatter 实例)。
另外,还有一些需要注意的地方:要小心使用 substring()。例如:
String getSmallString() {
  String largeString = // load from file; say 2M in size
  return largeString.substring(100, 300);
}

那个大字符串仍然在内存中,因为这是Java子字符串的工作方式。更好的版本是:

  return new String(largeString.substring(100, 300));

或者

  return String.format("%s", largeString.substring(100, 300));

第二种形式可能更有用,如果您同时在做其他事情。

8
值得指出的是,“相关问题”实际上是关于C#的,因此不适用。 - Air
你用了哪个工具来测量内存碎片化,内存碎片化是否会影响RAM的速度? - kritzikratzi
值得一提的是,从Java 7+开始,substring方法已经发生了改变。现在它应该返回一个新的String表示,其中只包含子字符串中的字符。这意味着不需要返回一个调用String::new的字符串。 - João Rebelo

5
通常你应该使用String.Format,因为它相对较快并且支持全球化(假设你实际上正在尝试编写用户可读的内容)。如果你试图翻译一个字符串而不是每个语句中的3个或更多字符串(特别是对于语法结构截然不同的语言),它还可以使全球化更容易。
现在,如果你从不打算翻译任何东西,那么要么依靠Java将+运算符转换为StringBuilder,要么显式地使用Java的StringBuilder。

3

仅从日志记录的角度看问题的另一个视角。

我在这个帖子中看到了很多关于日志记录的讨论,所以想分享一下我的经验。也许有人会觉得有用。

我认为使用格式化程序记录日志的动机是为了避免字符串连接。基本上,如果你不打算记录它,你不想有字符串连接的开销。

除非你想记录日志,否则你不需要进行连接/格式化。比如说,如果我定义一个方法:

public void logDebug(String... args, Throwable t) {
    if(debugOn) {
       // call concat methods for all args
       //log the final debug message
    }
}

在这种方法中,如果它是一个调试信息并且debugOn=false,则实际上根本不会调用cancat/formatter。
虽然在这里使用StringBuilder仍然更好。主要的动机是避免任何一种情况。
与此同时,我不喜欢为每个日志记录语句添加“if”块,因为
- 它会影响可读性 - 减少了我的单元测试覆盖率——当你想确保每行都被测试时,这很令人困惑。
因此,我更喜欢创建一个日志实用程序类,具有上述方法,并在任何地方使用它,而不必担心性能损失和与之相关的任何其他问题。

你能利用现有的库,如slf4j-api吗?它声称通过参数化日志记录功能来解决这个问题。https://www.slf4j.org/faq.html#logging_performance - ammianus

2

我刚刚修改了hhafez的测试,增加了StringBuilder。在使用jdk 1.6.0_10客户端在XP上,StringBuilder比String.format快33倍。使用-server开关可以将这个因数降低到20。

public class StringTest {

   public static void main( String[] args ) {
      test();
      test();
   }

   private static void test() {
      int i = 0;
      long prev_time = System.currentTimeMillis();
      long time;

      for ( i = 0; i < 1000000; i++ ) {
         String s = "Blah" + i + "Blah";
      }
      time = System.currentTimeMillis() - prev_time;

      System.out.println("Time after for loop " + time);

      prev_time = System.currentTimeMillis();
      for ( i = 0; i < 1000000; i++ ) {
         String s = String.format("Blah %d Blah", i);
      }
      time = System.currentTimeMillis() - prev_time;
      System.out.println("Time after for loop " + time);

      prev_time = System.currentTimeMillis();
      for ( i = 0; i < 1000000; i++ ) {
         new StringBuilder("Blah").append(i).append("Blah");
      }
      time = System.currentTimeMillis() - prev_time;
      System.out.println("Time after for loop " + time);
   }
}

虽然这听起来有些严重,但我认为只有在极少数情况下才适用,因为绝对数字非常低:对于100万个简单的String.format调用,4秒钟还算可以——只要我将它们用于日志记录或类似用途。

更新:如评论中sjbotha所指出的那样,StringBuilder测试无效,因为缺少最后的.toString()

String.format(.)StringBuilder的正确加速倍数是23(使用-server开关时为16)。


1
你的测试无效,因为它没有考虑到仅仅有一个循环所消耗的时间。你应该把这个时间算进去,并从所有其他结果中减去,至少要这样做(是的,它可能占总时间的相当大比例)。 - cletus
1
我已经做了那个,for循环花费了0毫秒。但是即使它花费时间,这只会增加因素。 - the.duckman
4
StringBuilder的测试无效,因为它在结尾没有调用toString()方法,以便实际给您一个可以使用的字符串。我添加了这个方法,结果是StringBuilder花费的时间与使用"+"连接字符串大致相同。当增加追加的数量时,它最终会变得更加高效。 - Sarel Botha

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接