重谈字符串连接性能(下):分析优化

JerryXia 发表于 , 阅读 (1,339)

经过之间的性能比较,我们得知StringBuilder的性能并非时时最优,再经过实现分析,我们大致了解了StringBuilder的实现方式。虽然在此之前,大家也基本已经了解StringBuilder的实现原理,也有不少朋友指出了它性能缺陷的原因。不过“严谨”起见,寻找性能问题的方式应该是进行Profiling,然后找出性能关键再进行优化——而不是纯粹进行“阅读”这种静态分析方式。

那么,假设我们还是使用原来的方式使用StringBuilder连接字符串:

static void Main()
{ for (int i = 0; i < 100 * 100 * 20; i++) StringBuilder(1024);
} private static readonly string STR = "0123456789"; private static string StringBuilder(int count)
{ var builder = new StringBuilder(); for (int i = 0; i < count; i++)
        builder.Append(STR); return builder.ToString();
}

我们对这段代码进行Profiling,便可以得到这样的结果:

从结果上可以看出,几乎所有时间都是消耗在Append操作上的(这是废话)。而在Append方法中,AppendInPlace和GetNewString方法都占用了较多的比例。从上次的代码分析中我们知道,AppendInPlace方法是将新的字符串复制到原字符序列(也就是那个“容器”)的后面,而GetNewString的作用便是创建一个新的,容量加倍字符串(容器)——它的主要消耗都在GetStringForStringBuilder方法上。

AppendInPlace的作用是复制字符串,消耗无法节省下来。但是,我们可以尽可能避免GetNewString的开销,只要减少“创建新容器”的次数即可。这意味着我们可以在一开始指定容量更大的StringBuilder。于是乎,我们尝试将StringBuilder的使用改写为如下形式:

private static string NewStringBuilder(int count)
{ var builder = new StringBuilder(count * STR.Length); for (int i = 0; i < count; i++)
        builder.Append(STR); return builder.ToString();
}

再次进行Profiling,结果如下:

由于我们一下子提供了足够的容量,因此在NewStringBuilder方法中一次“扩容”都不需要,因此也就不会调用GetNewString方法了。从上图中可以看出,此时AppendInPlace方法占用的比例增加了。与此对应的是StringBuilder的构造函数开销也增大了——因为需要一下子开辟较多的空间。由于总时间消耗地少,因此采样总数也比之前有所减少——这些结果都符合我们的推测。

于是我们将NewStringBuilder和之前的StringConcat以及StringListBuilder进行比较。公平起见,我也相应提高StringListBuilder中List<string>的容量,避免“扩容操作”:

class Program { static void Main()
    { CodeTimer.Initialize(); for (int i = 2; i <= 4096; i *= 2)
        { CodeTimer.Time( String.Format("StringBuilder ({0})", i),
                10000,
                () => NewStringBuilder(i)); CodeTimer.Time( String.Format("String.Concat ({0})", i),
                10000,
                () => StringConcat(i)); CodeTimer.Time( String.Format("StringListBuilder ({0})", i),
                10000,
                () => StringListBuilder(i));
        }
    } private static readonly string STR = "0123456789"; private static string NewStringBuilder(int count)
    { var builder = new StringBuilder(count * STR.Length); for (int i = 0; i < count; i++)
            builder.Append(STR); return builder.ToString();
    } private static string StringConcat(int count)
    { var array = new string[count]; for (int i = 0; i < count; i++) array[i] = STR; return String.Concat(array);
    } private static string StringListBuilder(int count)
    { var builder = new StringListBuilder(count); for (int i = 0; i < count; i++) builder.Append(STR); return builder.ToString();
    }
} public class StringListBuilder { private List<string> m_list; public StringListBuilder(int capacity)
    { this.m_list = new List<string>(capacity);
    } public StringListBuilder Append(string s)
    { this.m_list.Add(s); return this;
    } public string ToString()
    { return String.Concat(this.m_list.ToArray());
    }
}

结果如下:

绘制成图表:

终于,StringBuilder翻身了。由于避免了不断扩容,不断复制的过程,因此StringBuilder的性能已经成为三者中性能最高的作法。事实上,String.Concat高性能的原因,也正是事先知道了目标字符串的长度,实现了最高效的构造方法。而StringListBuilder,它比String.Concat需要进行更多List<string>和数组方面的维护,因此性能略低一些。

那么,经过了这三篇文章的比较和分析之后,我们是否可以知道哪种字符串连接方式性能最高呢?自然这需要根据情况而定:

  • StringBuilder:如果能够确定目标字符串的最终长度,则可以使用StringBuilder。如果不能确定的话,也可以在一开始指定更大的容量,减少扩容的次数。
  • String.Concat:如果不能确定最终长度,但是能够确定字符串的个数,可以将它们放在一个数组中,并调用String.Concat进行连接。
  • StringListBuilder:折衷方案,与String.Concat相比其优势在于无需确定字符串个数,与StringBuilder相比其优势在于“扩容”操作只需复制一些引用即可。

我的“重谈”之旅到此就告一段落了,您是否觉得哪里还意犹未尽呢?

添加新评论