Java性能优化技巧:如何避免常见的陷阱

举报
_陈哈哈 发表于 2022/01/21 23:32:43 2022/01/21
【摘要】         在本文中,我将带你了解一些Java性能优化技巧。通过专门研究Java程序中的某些操作。这些技巧仅真正适用于特定的高性能方案,因此,由于速度差异很小,因此无需使用这种方法编写所有代码。但是,在热代码路径中,它们可能会产生很大的不同。    目录: 使用探查器...

        在本文中,我将带你了解一些Java性能优化技巧。通过专门研究Java程序中的某些操作。这些技巧仅真正适用于特定的高性能方案,因此,由于速度差异很小,因此无需使用这种方法编写所有代码。但是,在热代码路径中,它们可能会产生很大的不同。

  

目录:

  • 使用探查器
  • 退后一步思考问题的解决方法
  • Streams API与可信赖的for循环
  • 日期传输和操作
  • 字符串运算

 

1.使用探查器


在执行任何优化之前,任何开发人员必须做的首要任务是检查他们对性能的假设是否正确。实际上,他们认为是慢速部分的代码实际上可能是掩盖了真正的慢速部分,导致任何改进的影响都可以忽略不计。他们还必须有一个比较点,才能知道他们的改进是否有所改善,如果有改善,则改善了多少。

实现这两个目标的最简单方法是使用探查器。探查器将为你提供工具,以查找代码的哪一部分实际上很慢以及花费多长时间。我可以推荐的一些探查器是  VisualVM  (免费)和  JProfiler  (付费 - 值得)。

有了这些知识,你就可以确信自己正在优化代码的正确部分,并且所做的更改具有可衡量的效果。

 

2.退后一步思考问题的解决方法


在尝试对特定代码路径进行微优化之前,值得考虑一下当前采用的方法。有时,基本方法可能有缺陷,这意味着即使你花了很大的力气并通过执行所有可能的优化设法只能使其运行速度提高了25%,而更改方法(使用更好的算法)也可能会导致更高数量级的性能增加。这通常发生在要更改的数据规模上时  --  编写一个现在可以很好运行的解决方案很简单,但是当你获得真实数据时,它就会开始崩溃。

有时,这就像更改要存储数据的数据结构那样简单。举一个人为的例子,如果你的数据访问模式主要是随机访问,而你使用的是LinkedList,则仅切换到ArrayList可能会非常快促进。对于大型数据集和对性能敏感的工作,为数据的形状和对其执行的操作选择正确的数据结构至关重要。

总是值得退后一步,考虑你正在优化的代码是否已经高效并且由于编写方式而缓慢,或者由于采用的方法次优而缓慢。

 

3.Streams API与可信赖的 for 循环


流(Streams),是Java语言的重要补充,可让你轻松地将容易出错的模式从for循环提升为具有一致保证的通用,可重用的代码块。但是这种便利并不是免费的。使用流会产生性能成本。值得庆幸的是,这种成本似乎并不太高-普通操作的速度要快几个百分点,再慢10-30%,但这是需要注意的。

99%的时间因使用Streams而导致的性能损失远远超过了代码清晰度的提高。但是对于你可能在热循环内使用流的那1%的时间来说,值得了解性能的取舍。这对任何非常高吞吐量应用尤其如此,从增加的内存分配流API(根据这个StackOverflow的发布每个过滤器增加了88个字节使用的内存),可引起足够增加了内存的压力,需要更频繁的GC运行造成重击在性能上。

并行流是另一回事,尽管它们易于使用,但它们仅应在极少数情况下使用,并且只有在你对并行和串行操作进行了分析以确认并行操作实际上更快之后,才可以使用。在较小的数据集上(流计算的成本决定了构成较小数据集的成本),将工作拆分,在其他线程上进行调度并在处理完流后将其重新缝合在一起的成本,将使运行该流的速度相形见war。并行计算。

你还必须考虑代码在其中运行的执行环境的类型,如果它正在运行已经高度并行化的环境(例如网站),那么你甚至不可能获得并行运行流的加速。实际上,在负载下,这可能比非并行执行更糟。这是因为工作负载的并行性质很可能已经尽可能多地利用了其余CPU内核,这意味着你要付出拆分数据的代价,而不会增加可用计算能力。

我执行的基准测试样本。的testList 是数字1至100,000转换为字符串的100000元件阵列,混洗。


  
  1. // ~1,500 op/s
  2. public void testStream(ArrayState state) {
  3. List<String> collect = state.testList
  4. .stream()
  5. .filter(s -> s.length() > 5)
  6. .map(s -> "Value: " + s)
  7. .sorted(String::compareTo)
  8. .collect(Collectors.toList());
  9. }
  10. // ~1,500 op/s
  11. public void testFor(ArrayState state) {
  12. ArrayList<String> results = new ArrayList<>();
  13. for (int i = 0;i < state.testList.size();i++) {
  14. String s = state.testList.get(i);
  15. if (s.length() > 5) {
  16. results.add("Value: " + s);
  17. }
  18. }
  19. results.sort(String::compareTo);
  20. }
  21. // ~8,000 op/s
  22. // 注意,由于数组大小为10,000,并且我的CPU负载更多,因此它的速度是testStream的1/3。
  23. public void testStreamParrallel(ArrayState state) {
  24. List<String> collect = state.testList
  25. .stream()
  26. .parallel()
  27. .filter(s -> s.length() > 5)
  28. .map(s -> "Value: " + s)
  29. .sorted(String::compareTo)
  30. .collect(Collectors.toList());
  31. }

总之,流在代码维护和可读性方面是一个伟大的胜利,在大多数情况下对性能的影响都可以忽略不计,但是需要注意的是,在少数情况下,你确实需要拧紧额外的性能,这是非常必要的一环。

 

4.日期传输和操作


不要低估将日期字符串解析为日期对象并将日期对象格式化为日期字符串的成本。想象一下一个场景,其中有一百万个对象的列表(直接是字符串,或者是表示某个项目的对象,这些项目的日期字段都由字符串作为后缀),并且你必须对它们的日期进行调整。在此日期表示为字符串的上下文中,你首先必须将其从该字符串解析为Date对象,更新Date对象,然后将其格式化为字符串。如果日期已经表示为Unix时间戳(或Date对象,因为它实际上只是Unix时间戳的包装),那么你要做的就是执行简单的加法或减法操作。

根据我的测试结果,与必须解析字符串或将其格式化为字符串相比,仅操作date对象的速度最多快500倍。即使仅执行解析步骤,也可以使速度提高约100倍。这似乎是一个人为的示例,但是我敢肯定,你已经看到过以下情况:日期在数据库中以字符串形式存储或在API响应中以字符串形式返回。


  
  1. // ~800,000 op/s
  2. public void dateParsingWithFormat(DateState state) throws ParseException {
  3. Date date = state.formatter.parse("20-09-2017 00:00:00");
  4. date = new Date(date.getTime() + 24 * state.oneHour);
  5. state.formatter.format(date);
  6. }
  7. // ~3,200,000 op/s
  8. public void dateLongWithFormat(DateState state) {
  9. long newTime = state.time + 24 * state.oneHour;
  10. state.formatter.format(new Date(newTime));
  11. }
  12. // ~400,000,000 op/s
  13. public long dateLong(DateState state) {
  14. long newTime = state.time + 24 * state.oneHour;
  15. return newTime;
  16. }

总而言之,请始终注意解析和格式化日期对象的成本,除非你当时需要此字符串,否则最好将它表示为Unix时间戳。

 

5.字符串运算

 

字符串操作可能是任何程序中最常见的操作之一。但是,如果操作不正确,这可能是一个昂贵的操作,这就是为什么我在这些Java性能优化技巧中着重于字符串操作的原因。我将在下面列出一些常见的陷阱。但是,我想指出的是,这些问题仅在非常快速的代码路径中出现,或者在相当多的字符串中出现,在99%的情况下,以下任何一项都不重要。但是当他们这样做时,他们可能成为性能杀手。

简单的串联可以使用时使用String.format
一个非常简单的String.format调用比手动将值连接到字符串中要慢100倍。在大多数情况下,这是很好的,因为我们仍在谈论我的机器上每秒进行100万次操作,但是在对数百万个元素进行操作的紧密循环内,性能损失可能会很大。

在高性能环境中,应该使用字符串格式而不是串联的一个实例是调试日志记录。进行以下两个调试日志记录调用:

logger.debug("the value is: " + x);

logger.debug("the value is: %d", x);

第二种情况乍一看似乎违反常规,但在生产环境中可能会更快。由于不太可能在生产服务器上启用调试日志记录,因此首先导致分配了新字符串,然后从未使用过(因为从不输出日志)。第二个要求加载常量字符串,然后将跳过格式化步骤。


  
  1. // ~1,300,000 op/s
  2. public String stringFormat() {
  3. String foo = "foo";
  4. String formattedString = String.format("%s = %d", foo, 2);
  5. return formattedString;
  6. }
  7. // ~115,000,000 op/s
  8. public String stringConcat() {
  9. String foo = "foo";
  10. String concattedString = foo + " = " + 2;
  11. return concattedString;
  12. }

不在循环内使用字符串生成器
如果你在循环内使用字符串生成器,则会浪费很多潜在的性能。在循环内附加到字​​符串的简单方法是使用+=将字符串的新部分附加到旧字符串。这种方法的问题在于,它将在循环的每次迭代中导致分配新字符串,并且需要将旧字符串复制到新字符串中。这本身是一项昂贵的操作,甚至没有考虑到创建和丢弃这么多字符串会带来额外的垃圾收集压力。使用StringBuilder将限制内存分配的数量,从而提高性能。在我的测试中,使用StringBuilder可以使速度提高500倍以上。

进一步说明,(几乎)始终使用StringBuilder而不是StringBuffer。StringBuffer专为在多线程环境中使用而设计,因此具有内部同步,即使只在单线程环境中使用,也必须支付执行同步的费用。如果确实需要从多个线程中追加一个字符串(例如在日志记录实现中),则这是应使用StringBuffer而不是StringBuilder的少数情况之一。


  
  1. // ~11 operations p/s
  2. public String stringAppendLoop() {
  3. String s = "";
  4. for (int i = 0;i < 10_000;i++) {
  5. if (s.length() > 0) s += ", ";
  6. s += "bar";
  7. }
  8. return s;
  9. }
  10. // ~7,000 operations p/s
  11. public String stringAppendBuilderLoop() {
  12. StringBuilder sb = new StringBuilder();
  13. for (int i = 0;i < 10_000;i++) {
  14. if (sb.length() > 0) sb.append(", ");
  15. sb.append("bar");
  16. }
  17. return sb.toString();
  18. }


在平时使用String还是StringBuilder效率高?
这是我在互联网上看到的推荐内容,似乎很有意义。但是我的测试表明,它比使用String的 “+=” 慢3倍;即使不在循环中也是如此。即使在这种情况下使用 “+=” 由javac转换为StringBuilder调用,它似乎比直接使用StringBuilder快得多,这让我感到惊讶。


  
  1. // ~20,000,000 operations p/s
  2. public String stringAppend() {
  3. String s = "foo";
  4. s += ", bar";
  5. s += ", baz";
  6. s += ", qux";
  7. s += ", bar";
  8. s += ", bar";
  9. s += ", bar";
  10. s += ", bar";
  11. s += ", bar";
  12. s += ", bar";
  13. s += ", baz";
  14. s += ", qux";
  15. s += ", baz";
  16. s += ", qux";
  17. s += ", baz";
  18. s += ", qux";
  19. s += ", baz";
  20. s += ", qux";
  21. s += ", baz";
  22. s += ", qux";
  23. s += ", baz";
  24. s += ", qux";
  25. return s;
  26. }
  27. // ~7,000,000 operations p/s
  28. public String stringAppendBuilder() {
  29. StringBuilder sb = new StringBuilder();
  30. sb.append("foo");
  31. sb.append(", bar");
  32. sb.append(", bar");
  33. sb.append(", baz");
  34. sb.append(", qux");
  35. sb.append(", baz");
  36. sb.append(", qux");
  37. sb.append(", baz");
  38. sb.append(", qux");
  39. sb.append(", baz");
  40. sb.append(", qux");
  41. sb.append(", baz");
  42. sb.append(", qux");
  43. sb.append(", baz");
  44. sb.append(", qux");
  45. sb.append(", baz");
  46. sb.append(", qux");
  47. sb.append(", baz");
  48. sb.append(", qux");
  49. sb.append(", baz");
  50. sb.append(", qux");
  51. sb.append(", baz");
  52. sb.append(", qux");
  53. return sb.toString();
  54. }

-------------------------------------------------------------------------------------------------------------------------------------------------------

但后来经过多次测试我发现:

String通过"+"来拼接,如果拼接的字符串是常量,则效率会非常高,因为会进行编译时优化,这个时候StringBuilder的append()是达不到的。

如果将String的"+"放在循环中,会创建很多的StringBuilder对象,并且执行之后会调用toString()生成新的String对象,这些对象会占用大量的内存空间
而导致频繁的GC,从而效率变慢。

StringBuilder.append()中间过程中产生的垃圾内存大多数都是小块的内存,锁产生的垃圾就是拼接的对象以及扩容原来的空间(当发生String的"+"操作时,
前一次String的"+"操作的结果就成了内存垃圾,垃圾会越来越多,最后扩容也会产生很多垃圾)

注意的是,并不是String的"+"操作本身慢,而是因为大循环中大量的内存使用,开销比较大,会导致频繁的GC,并且很多时候程序慢是因为频繁GC导致的
而且更多的是FULL GC,效率才会下降。

如果是少量的小字符串叠加,那么采用append()提升效率并不明显,但是遇到大量的字符串叠加或者大字符串叠加的时候,使用append的效率会高很多。

最后有一个优化常识:
在JVM中,提倡的重点是让这个"线程内所使用的内存"尽快结束,以便让JVM认为它是垃圾,在Young空间就尽量释放掉,尽量不要让其进入Old区域,一个
重要的因素是代码是否跑得够快,其次是分配的空间要足够小。

 

总之,字符串创建有一定的开销,应尽可能避免在循环中进行。这可以通过在循环内部使用StringBuilder轻松实现。

我希望这篇文章为你提供了一些有用的Java性能优化技巧。我想再次强调一下,这篇文章中的所有信息对于大多数正在执行的代码都无关紧要,如果你可以将字符串格式设置为每秒一百万次或每秒格式化八千万次,则没有任何区别。只做了几次。但实际上,在那些关键的热路径上,你可以进行数百万次的操作,使80倍的加速比可以节省长时间运行的工作量。

本文只是对优化Java应用程序以实现高性能的深入了解。

文章来源: chensj.blog.csdn.net,作者:_陈哈哈,版权归原作者所有,如需转载,请联系作者。

原文链接:chensj.blog.csdn.net/article/details/104452652

【版权声明】本文为华为云社区用户转载文章,如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。