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。