探究JDK8下在循环中使用+号和StringBuilder拼接多个字符串的优劣

本文详细比较了Java中三种字符串拼接方法在循环中的性能,发现method1(+号拼接)和method3(循环内创建StringBuilder)相比,method2(循环外创建StringBuilder)在某些情况下内存消耗更大且可能触发更多次垃圾回收,特别是当循环次数较高时。
摘要由CSDN通过智能技术生成

1 简介

日常开发中经常涉及给集合中对象多个字段赋值的情况,难免会遇到循环中拼接多个字符串的场景,为了不再纠结于哪种拼接方法更好,这次我们深入地测试、分析一下。

2 代码

2.1 先模拟一个数据对象

public class Data {
    private int id;
    private String name;
    private String desc;
    private String text;

    public int getId() {
        return id;
    }

    public void setId(int id) {
        this.id = id;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public String getDesc() {
        return desc;
    }

    public void setDesc(String desc) {
        this.desc = desc;
    }

    public String getText() {
        return text;
    }

    public void setText(String text) {
        this.text = text;
    }
}

2.2 然后写一个测试类,分别模拟3种常见的循环中拼接多个字符串的代码

import java.util.LinkedList;
import java.util.List;
import java.util.Random;


public class AppendTest {

    private static int length = 1000;

    private static String[] arr = {"A","B","C","D","赵","钱","孙","李","1","2","3","4",};

    public static void main(String[] args) {
        long millis1 = System.currentTimeMillis();
        method1();
//        method2();
//        method3();
        System.out.println(System.currentTimeMillis() - millis1);
    }

    public static List<Data> method1() {
        List<Data> list = new LinkedList<>();
        Random random = new Random();
        int index = random.nextInt(arr.length);
        for (int i = 0; i < length; i++) {
            Data data = new Data();
            data.setName("name" + arr[index]);
            data.setDesc("desc" + arr[index]);
            data.setText("text" + arr[index]);
            list.add(data);
        }
        return list;
    }

    public static List<Data> method2() {
        List<Data> list = new LinkedList<>();
        Random random = new Random();
        int index = random.nextInt(arr.length);
        StringBuilder stringBuilder = new StringBuilder();
        for (int i = 0; i < length; i++) {
            Data data = new Data();
            stringBuilder.setLength(0);
            data.setName(stringBuilder.append("name").append(arr[index]).toString());
            stringBuilder.setLength(0);
            data.setDesc(stringBuilder.append("desc").append(arr[index]).toString());
            stringBuilder.setLength(0);
            data.setText(stringBuilder.append("text").append(arr[index]).toString());
            list.add(data);
        }
        return list;
    }

    public static List<Data> method3() {
        List<Data> list = new LinkedList<>();
        Random random = new Random();
        int index = random.nextInt(arr.length);
        for (int i = 0; i < length; i++) {
            Data data = new Data();
            data.setName(append("name", arr[index]));
            data.setDesc(append("desc", arr[index]));
            data.setText(append("text", arr[index]));
            list.add(data);
        }
        return list;
    }

    private static String append(String... strings) {
        StringBuilder stringBuilder = new StringBuilder();
        for (String string : strings) {
            stringBuilder.append(string);
        }
        return stringBuilder.toString();
    }
}

3个method方法含义:
method1:使用+号拼接字符串
method2:循环外创建1个StringBuilder,循环中多次执行setLength(0)
method3:循环中调用方法,方法内创建1个StringBuilder

3 测试一

3.1 测试流程

步骤1:设置初始循环次数为1000,分别执行3个method方法各3次
步骤2:循环次数每次*10,继续步骤1

测试过程省略不写,这里直接放测试结果(有兴趣的同学可以自己测试)

3.2 测试结果1

统计数据单位为毫秒

在这里插入图片描述

3.3 分析1

初步比较数据可知,method3在多个循环次数下,执行耗时普遍比method1、method2更久。method3方法中每次循环都调用了append方法,需要进行额外的入栈、出栈操作,这可能是method3耗时更久的原因。

继续比较method1与method2,当循环次数为1000至10万时,method2执行耗时普遍比method1小一点,但是当循环次数升至100万时,method1执行耗时只是小幅度增加,而method2执行耗时增涨巨大,远大于method1的耗时。 更加奇怪的是,当循环次数升至1000万时,method2执行耗时又小于method1,且两个方法耗时相差不算太大,与1000至10万时的情况相同。

于是,又针对method1、method2进行了进一步的测试

3.4 测试结果2

统计数据单位为毫秒

在这里插入图片描述

3.5 分析2

增加循环次数为50万、80万次的数据,可知在循环次数由50万提升至80万时,method2耗时比method1更久。存在1个次数,当达到这个次数时,method2执行了额外的操作,而method1没有执行,导致method2耗时更久。

我们知道循环期间JVM创建了很多对象,保存在堆中,当堆中的对象创建的过多,导致存放新的对象时堆内存不足时,会进行垃圾回收。

所以推测:执行method1方法使用的堆内存小于执行method2方法使用的堆内存,当循环到达一定次数,堆中创建的对象过多,导致method2方法使用的内存超了限制,执行了垃圾回收,而method1方法正好没超限制,没有回收,所以method2耗时比method1长了。

4 验证

4.1 介绍

通过Java VisualVM工具,监控程序执行期间的堆内存使用情况和垃圾回收活动,进行验证。

4.2 代码修改

执行前后休眠10秒,方便监测结果

public static void main(String[] args) throws InterruptedException {
        Thread.sleep(1000 * 10);
        long millis1 = System.currentTimeMillis();
        method1();
//        method2();
//        method3();
        System.out.println(System.currentTimeMillis() - millis1);
        Thread.sleep(1000 * 10);
    }

4.3 结果

经过测试,当循环次数为60万时,method2耗时比method1更久

以下为详细数据:

method1:
耗时:182
监测图:
在这里插入图片描述

method2:
耗时:1317
监测图:
在这里插入图片描述

4.4 分析

查看垃圾回收活动统计图,发现method2执行期间垃圾回收活动百分比更多,也就是说执行method2方法会导致JVM进行更多的垃圾回收

查看堆内存监测图,发现2个方法都导致了堆内存扩容,但是method2会导致堆内存扩容量更大,也就说明执行method2方法所消耗的堆内存空间更大

这里由于没有修改启动参数,JVM内存配置使用的默认配置,当堆空间不足时会动态扩容。以下是本次测试时的一些堆的相关配置:
size_t InitialHeapSize = 266338304
size_t MaxHeapSize = 4238344192
size_t NewSize = 1363144
uintx MinHeapFreeRatio = 40
uintx NewRatio = 2
uintx SurvivorRatio = 8

查看JVM参数命令:java -XX:+PrintFlagsInitial
在这里插入图片描述

5 垃圾回收详情

5.1 介绍

以上的验证只能证明执行method2方法更消耗堆内存,会导致更多的垃圾回收,并没有具体的数据体现,为了进一步清楚现象的本质,接下来使用 jstat 命令来监测垃圾回收情况。

5.2 代码修改

执行method方法前先休眠20秒,留出我们输入命令的时间。

public static void main(String[] args) throws InterruptedException {
        Thread.sleep(1000 * 20);
        long millis1 = System.currentTimeMillis();
        method1();
//        method2();
//        method3();
        System.out.println(System.currentTimeMillis() - millis1);
        Thread.sleep(1000 * 10);
    }

5.3 测试流程

步骤1:执行main方法
步骤2:执行 jps 命令查看测试程序的进程ID
步骤3:执行 jstat 命令监测测试程序的垃圾回收情况

5.4 结果

method1:
在这里插入图片描述

method2:
在这里插入图片描述

对比数据可以发现,method1导致执行了2次YGC,method2导致执行了2次YGC、1次FGC,而FGC相对YGC特别耗时,这就是导致method2方法执行耗时暴增的原因。

6 结论一

在集合中给对象多个字段拼接字符串的场景下,更推荐使用 + 号,因为在耗时相差不多的情况下,消耗的堆内存更少,更不容易触发FGC。

同时,应该尽量减少FGC的次数。避免一次性处理超大量的数据,可以将数据拆分开,分多次处理。尽量让处理的数据对象都保存在年轻代,不进入老年代,当处理完成后,对象不再使用,只需要执行1次YGC就可回收这些内存。

7 补充

其实我们仔细思考的话,以上代码包括测试、结论并不严谨。

我们的目的是比较字符串拼接的优劣,为什么还要不停的创建Data对象,给Data赋值,占用了庞大的内存?
我们只是比较了循环60万次的内存消耗,为什么就能得出method2占用内存更大,如果是循环1000次呢?
我们每次循环中都是固定拼接了3个字符串,如果拼接的字符串个数不同又会怎样?

8 优化代码

针对以上问题,重新优化了测试代码,我们化繁为简,只保留与实际场景中对应的字符串拼接部分,并且添加“拼接字符串个数”为变量,为求更加全面的比较各种情况下的优劣。

public class AppendTest {
    private static int length = 1000; //循环次数

    private static int index1 = 0;
    private static int index2 = 5;

    private static int count = 1; //拼接的字符串个数

    private static String[] arr = {"A","B","C","D","赵","钱","孙","李","1","2","3","4",};

    public static void main(String[] args) throws InterruptedException {
        Thread.sleep(1000 * 20);
        long millis1 = System.currentTimeMillis();
        method1();
//        method2();
//        method3();
        System.out.println(System.currentTimeMillis() - millis1);
        Thread.sleep(1000 * 10);
    }

    public static void method1() {
        for (int i = 0; i < length; i++) {
            for (int j = 0; j < count; j++) {
                String str = arr[index1] + arr[index2];
            }
        }
    }

    public static void method2() {
        StringBuilder stringBuilder = new StringBuilder();
        for (int i = 0; i < length; i++) {
            for (int j = 0; j < count; j++) {
                stringBuilder.setLength(0);
                String str = stringBuilder.append(arr[index1]).append(arr[index2]).toString();
            }
        }
    }

    public static void method3() {
        for (int i = 0; i < length; i++) {
            for (int j = 0; j < count; j++) {
                String str = append(arr[index1], append(arr[index2]));
            }
        }
    }

    private static String append(String... strings) {
        StringBuilder stringBuilder = new StringBuilder();
        for (String string : strings) {
            stringBuilder.append(string);
        }
        return stringBuilder.toString();
    }
}

9 测试二

9.1 测试方式

使用 jstat -gc 命令检测堆内存使用情况
在这里插入图片描述

JVM启动参数添加 -Xloggc:gc.log 检查gc回收情况
在这里插入图片描述

9.2 统计数据

经过多次测试,统计数据做成了折线图,如下:

纵轴是执行3个方法分别消耗的内存,单位是 KB(千字节)
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

9.3 分析

可以看出,method2 方法拼接字符串消耗内存最少,涨幅也最小,其次是 method1、method3。

当循环次数超过20万次后,method3 随着循环次数增加,内存消耗大幅增大,涨幅巨大,非常不推荐用

虽然只测了拼接 1 ~ 3 个字符串,但是看图可以发现,随着拼接字符串个数的增加,3 种方法消耗内存的涨幅是基本不变的,增加的只是消耗内存的多少

10 结论二

经过上面的测试,我们发现,在循环中只拼接字符串时,使用 method2(循环外创建1个StringBuilder,循环中多次执行setLength(0))方法性能最好。 而在给集合中对象字段拼接字符串时,使用 method1(使用+号拼接字符串) 更不容易触发 FGC

这两结论貌似是矛盾的?
为什么method2明明消耗内存更少,但是更容易触发FGC?

11 测试三

为了理清这个问题,我又重新测试了集合中对象字段拼接字符串的场景,并且加入打印GC日志的启动参数 -XX:+PrintGCDetails,和 jstat -gc 的监测数据结合起来分析、梳理,最终整理出下面2张表。

按照从上到下的顺序变化
蓝色背景表示的是业务线程执行期间
浅灰色背景表示的是YGC期间
深灰色背景表示的是FGC期间
红色数字表示即将清除的内存大小
绿色数字表示存活对象复制后的内存大小

下图是method1执行期间的堆内存变化
在这里插入图片描述

下图是method2执行期间的堆内存变化
在这里插入图片描述

解释一下:

1 业务线程先执行

method1、method2在执行过程中,当创建的对象在Eden区满了之后(图中占满65024KB),创建新的对象发现放不下时,触发YGC。

2 第一次YGC

我这里启动参数中没有指定垃圾收集器,jdk 8 默认使用的是 Parallel 收集,也就是新生代使用 Parallel Scavenge收集器 + 老年代使用 Parallel Old收集器组合的方式。从下图gc日志也能看出来。
在这里插入图片描述

我们结合之前的结论二,method2 比 method1 消耗的堆内存更少(因为不会每次循环多执行1次 new char[16],关于StringBuilder的源码我另写了一篇博客,有兴趣可以看看),所以在 Eden 区满了之后,method2 可回收的对象比 method1 可回收的对象少很多(GC时,正在使用的集合中的对象是不能回收的,而每次循环中拼接字符串创建的那些char[] 是可以回收的)。

新生代使用复制算法,在 Survivor 1 复制满了之后,放不下的对象复制到 Old,因为 method2 比 method1 存活的对象多,所以复制到 Old 的对象更多。图中 method2 复制了 50216KB,method1 复制了 21632KB。最后清空 Eden区。

3 业务线程再次执行

Eden 区第2次满了,触发YGC。

4 第二次YGC

这次YGC,直接将Survivor1 的对象复制到了 Survivor0,Eden 区 65024KB 的对象复制到了 Old,(具体的顺序有待确定)。不过根据监测数据可以肯定的是,method1 和 method2 的Old 区的使用内存直接增加了 65024KB 的大小。最后清空 Eden、Survivor1 区。

5 差异

method1继续执行业务线程,Eden 区又使用了 35701.6KB,直到程序运行结束。
而method2导致了FGC,原因是 method2 的 Old 可用空间为58328KB(173568KB - 115240KB),小于新生代的总空间。

12 总结

到这里,我们就发现原因了。

正因为method2中每次循环产生的垃圾对象更少,所以在 Eden 满了触发YGC的时候,存活的对象就更多,Eden 存活更多的对象会导致 Survivor 满了之后进入 Old 的对象更多,Old 更多就会越早触发 FGC。

所以怎么办呢?

如果集合长度小,对象占用内存少,能保证1次YGC就能全部回收掉,那就用method2(循环外创建1个StringBuilder,循环中多次执行setLength(0))。

如果不能保证1次YGC就全部回收掉,那就用method1(使用+号拼接字符串),至少可以保证1次YGC的时候多回收点垃圾,减少进入老年代的对象。

以上适用于 Java 8 默认的 Parallel GC

  • 7
    点赞
  • 27
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值