前言
這一次要和大家分享的一個Tips是在字符串拼接場景使用的,我們經常會遇到有很多短小的字符串需要拼接的場景,在這種場景下及其的不推薦使用String.Concat
也就是使用+=
運算符。 目前來說官方最推薦的方案就是使用StringBuilder
來構建這些字符串,那么有什么更快內存占用更低的方式嗎?那就是今天要和大家介紹的ValueStringBuilder
。
ValueStringBuilder
ValueStringBuilder
不是一個公開的API,但是它被大量用于.NET的基礎類庫中,由于它是值類型的,所以它本身不會在堆上分配,不會有GC的壓力。 微軟提供的ValueStringBuilder
有兩種使用方式,一種是自己已經有了一塊內存空間可供字符串構建使用。這意味著你可以使用棧空間,也可以使用堆空間甚至非托管堆的空間,這對于GC來說是非常友好的,在高并發情況下能大大降低GC壓力。
//?構造函數:傳入一個Span的Buffer數組
public?ValueStringBuilder(Span<char>?initialBuffer);//?使用方式:
//?棧空間
var?vsb?=?new?ValueStringBuilder(stackalloc?char[512]);
//?普通數租
var?vsb?=?new?ValueStringBuilder(new?char[512]);
//?使用非托管堆
var?length?=?512;
var?ptr?=?NativeMemory.Alloc((nuint)(512?*?Unsafe.SizeOf<char>()));
var?span =?new?Span<char>(ptr,?length);
var?vsb?=?new?ValueStringBuilder(span);
.....
NativeMemory.Free(ptr);?//?非托管堆用完一定要Free
另外一種方式是指定一個容量,它會從默認的ArrayPool
的char
對象池中獲取緩沖空間,因為使用的是對象池,所以對于GC來說也是比較友好的,千萬需要注意,池中的對象一定要記得歸還。
//?傳入預計的容量
public?ValueStringBuilder(int?initialCapacity)??
{??//?從對象池中獲取緩沖區_arrayToReturnToPool?=?ArrayPool<char>.Shared.Rent(initialCapacity);??......
}
那么我們就來比較一下使用+=
、StringBuilder
和ValueStringBuilder
這幾種方式的性能吧。
//?一個簡單的類
public?class?SomeClass??
{??public?int?Value1;?public?int?Value2;?public?float?Value3;??public?double?Value4;?public?string??Value5;?public?decimal?Value6;??public?DateTime?Value7;?public?TimeOnly?Value8;?public?DateOnly?Value9;??public?int[]??Value10;??
}
//?Benchmark類
[MemoryDiagnoser]??
[HtmlExporter]??
[Orderer(SummaryOrderPolicy.FastestToSlowest)]??
public?class?StringBuilderBenchmark??
{??private?static?readonly?SomeClass?Data;??static?StringBuilderBenchmark()??{??var?baseTime?=?DateTime.Now;??Data?=?new?SomeClass??{??Value1?=?100,?Value2?=?200,?Value3?=?333,??Value4?=?400,?Value5?=?string.Join('-',?Enumerable.Range(0,?10000).Select(i?=>?i.ToString())),??Value6?=?655,?Value7?=?baseTime.AddHours(12),??Value8?=?TimeOnly.MinValue,?Value9?=?DateOnly.MaxValue,??Value10?=?Enumerable.Range(0,?5).ToArray()??};??}//?使用我們熟悉的StringBuilder[Benchmark(Baseline?=?true)]??public?string?StringBuilder()??{??var?data?=?Data;??var?sb?=?new?StringBuilder();??sb.Append("Value1:");?sb.Append(data.Value1);??if?(data.Value2?>?10)??{??sb.Append("?,Value2:");?sb.Append(data.Value2);??}??sb.Append("?,Value3:");?sb.Append(data.Value3);??sb.Append("?,Value4:");?sb.Append(data.Value4);??sb.Append("?,Value5:");?sb.Append(data.Value5);??if?(data.Value6?>?20)??{??sb.Append("?,Value6:");?sb.AppendFormat("{0:F2}",?data.Value6);??}??sb.Append("?,Value7:");?sb.AppendFormat("{0:yyyy-MM-dd?HH:mm:ss}",?data.Value7);??sb.Append("?,Value8:");?sb.AppendFormat("{0:HH:mm:ss}",?data.Value8);??sb.Append("?,Value9:");?sb.AppendFormat("{0:yyyy-MM-dd}",?data.Value9);??sb.Append("?,Value10:");??if?(data.Value10?is?null?or?{Length:?0})?return?sb.ToString();??for?(int?i?=?0;?i?<?data.Value10.Length;?i++)??{??sb.Append(data.Value10[i]);??}??return?sb.ToString();??}//?StringBuilder使用Capacity[Benchmark]??public?string?StringBuilderCapacity()??{??var?data?=?Data;??var?sb?=?new?StringBuilder(20480);??sb.Append("Value1:");?sb.Append(data.Value1);??if?(data.Value2?>?10)??{??sb.Append("?,Value2:");?sb.Append(data.Value2);??}??sb.Append("?,Value3:");?sb.Append(data.Value3);??sb.Append("?,Value4:");?sb.Append(data.Value4);??sb.Append("?,Value5:");?sb.Append(data.Value5);??if?(data.Value6?>?20)??{??sb.Append("?,Value6:");?sb.AppendFormat("{0:F2}",?data.Value6);??}??sb.Append("?,Value7:");?sb.AppendFormat("{0:yyyy-MM-dd?HH:mm:ss}",?data.Value7);??sb.Append("?,Value8:");?sb.AppendFormat("{0:HH:mm:ss}",?data.Value8);??sb.Append("?,Value9:");?sb.AppendFormat("{0:yyyy-MM-dd}",?data.Value9);??sb.Append("?,Value10:");??if?(data.Value10?is?null?or?{Length:?0})?return?sb.ToString();??for?(int?i?=?0;?i?<?data.Value10.Length;?i++)??{??sb.Append(data.Value10[i]);??}??return?sb.ToString();??}??//?直接使用+=拼接字符串[Benchmark]??public?string?StringConcat()??{??var?str?=?"";??var?data?=?Data;??str?+=?("Value1:");?str?+=?(data.Value1);??if?(data.Value2?>?10)??{??str?+=?"?,Value2:";?str?+=?data.Value2;??}??str?+=?"?,Value3:";?str?+=?(data.Value3);??str?+=?"?,Value4:";?str?+=?(data.Value4);??str?+=?"?,Value5:";?str?+=?(data.Value5);??if?(data.Value6?>?20)??{??str?+=?"?,Value6:";?str?+=?data.Value6.ToString("F2");??}??str?+=?"?,Value7:";?str?+=?data.Value7.ToString("yyyy-MM-dd?HH:mm:ss");??str?+=?"?,Value8:";?str?+=?data.Value8.ToString("HH:mm:ss");??str?+=?"?,Value9:";?str?+=?data.Value9.ToString("yyyy-MM-dd");??str?+=?"?,Value10:";??if?(data.Value10?is?not?null?&&?data.Value10.Length?>?0)??{??for?(int?i?=?0;?i?<?data.Value10.Length;?i++)??{??str?+=?(data.Value10[i]);??}?????}??return?str;??}??//?使用棧上分配的ValueStringBuilder[Benchmark]??public?string?ValueStringBuilderOnStack()??{??var?data?=?Data;??Span<char>?buffer?=?stackalloc?char[20480];??var?sb?=?new?ValueStringBuilder(buffer);??sb.Append("Value1:");?sb.AppendSpanFormattable(data.Value1);??if?(data.Value2?>?10)??{??sb.Append("?,Value2:");?sb.AppendSpanFormattable(data.Value2);??}??sb.Append("?,Value3:");?sb.AppendSpanFormattable(data.Value3);??sb.Append("?,Value4:");?sb.AppendSpanFormattable(data.Value4);??sb.Append("?,Value5:");?sb.Append(data.Value5);??if?(data.Value6?>?20)??{??sb.Append("?,Value6:");?sb.AppendSpanFormattable(data.Value6,?"F2");??}??sb.Append("?,Value7:");?sb.AppendSpanFormattable(data.Value7,?"yyyy-MM-dd?HH:mm:ss");??sb.Append("?,Value8:");?sb.AppendSpanFormattable(data.Value8,?"HH:mm:ss");??sb.Append("?,Value9:");?sb.AppendSpanFormattable(data.Value9,?"yyyy-MM-dd");??sb.Append("?,Value10:");??if?(data.Value10?is?not?null?&&?data.Value10.Length?>?0)??{??for?(int?i?=?0;?i?<?data.Value10.Length;?i++)??{??sb.AppendSpanFormattable(data.Value10[i]);??}?????}??return?sb.ToString();??}//?使用ArrayPool?堆上分配的StringBuilder[Benchmark]??public?string?ValueStringBuilderOnHeap()??{??var?data?=?Data;??var?sb?=?new?ValueStringBuilder(20480);??sb.Append("Value1:");?sb.AppendSpanFormattable(data.Value1);??if?(data.Value2?>?10)??{??sb.Append("?,Value2:");?sb.AppendSpanFormattable(data.Value2);??}??sb.Append("?,Value3:");?sb.AppendSpanFormattable(data.Value3);??sb.Append("?,Value4:");?sb.AppendSpanFormattable(data.Value4);??sb.Append("?,Value5:");?sb.Append(data.Value5);??if?(data.Value6?>?20)??{??sb.Append("?,Value6:");?sb.AppendSpanFormattable(data.Value6,?"F2");??}??sb.Append("?,Value7:");?sb.AppendSpanFormattable(data.Value7,?"yyyy-MM-dd?HH:mm:ss");??sb.Append("?,Value8:");?sb.AppendSpanFormattable(data.Value8,?"HH:mm:ss");??sb.Append("?,Value9:");?sb.AppendSpanFormattable(data.Value9,?"yyyy-MM-dd");??sb.Append("?,Value10:");??if?(data.Value10?is?not?null?&&?data.Value10.Length?>?0)??{??for?(int?i?=?0;?i?<?data.Value10.Length;?i++)??{??sb.AppendSpanFormattable(data.Value10[i]);??}?????}return?sb.ToString();??}}
結果如下所示。從上圖的結果中,我們可以得出如下的結論。
使用
StringConcat
是最慢的,這種方式是無論如何都不推薦的。使用
StringBuilder
要比使用StringConcat
快6.5倍,這是推薦的方法。設置了初始容量的
StringBuilder
要比直接使用StringBuilder
快25%,正如我在你應該為集合類型設置初始大小[1]一樣,設置初始大小絕對是相當推薦的做法。棧上分配的
ValueStringBuilder
比StringBuilder
要快50%,比設置了初始容量的StringBuilder
還快25%,另外它的GC次數是最低的。堆上分配的
ValueStringBuilder
比StringBuilder
要快55%,他的GC次數稍高與棧上分配。 從上面的結論中,我們可以發現ValueStringBuilder
的性能非常好,就算是在棧上分配緩沖區,性能也比StringBuilder
快25%。
源碼解析
ValueStringBuilder
的源碼不長,我們挑幾個重要的方法給大家分享一下,部分源碼如下。
//?使用?ref?struct?該對象只能在棧上分配
public?ref?struct?ValueStringBuilder
{//?如果從ArrayPool里分配buffer?那么需要存儲一下//?以便在Dispose時歸還private?char[]??_arrayToReturnToPool;//?暫存外部傳入的bufferprivate?Span<char>?_chars;//?當前字符串長度private?int?_pos;//?外部傳入bufferpublic?ValueStringBuilder(Span<char>?initialBuffer){//?使用外部傳入的buffer就不使用從pool里面讀取的了_arrayToReturnToPool?=?null;_chars?=?initialBuffer;_pos?=?0;}public?ValueStringBuilder(int?initialCapacity){//?如果外部傳入了capacity?那么從ArrayPool里面獲取_arrayToReturnToPool?=?ArrayPool<char>.Shared.Rent(initialCapacity);_chars?=?_arrayToReturnToPool;_pos?=?0;}//?返回字符串的Length?由于Length可讀可寫//?所以重復使用ValueStringBuilder只需將Length設置為0public?int?Length{get?=>?_pos;set{Debug.Assert(value?>=?0);Debug.Assert(value?<=?_chars.Length);_pos?=?value;}}......[MethodImpl(MethodImplOptions.AggressiveInlining)]public?void?Append(char?c){//?添加字符非常高效?直接設置到對應Span位置即可int?pos?=?_pos;if?((uint)?pos?<?(uint)?_chars.Length){_chars[pos]?=?c;_pos?=?pos?+?1;}else{//?如果buffer空間不足,那么會走GrowAndAppend(c);}}[MethodImpl(MethodImplOptions.AggressiveInlining)]public?void?Append(string??s){if?(s?==?null){return;}//?追加字符串也是一樣的高效int?pos?=?_pos;//?如果字符串長度為1?那么可以直接像追加字符一樣if?(s.Length?==?1?&&?(uint)?pos?<?(uint)?_chars?.Length){_chars[pos]?=?s[0];_pos?=?pos?+?1;}else{//?如果是多個字符?那么使用較慢的方法AppendSlow(s);}}private?void?AppendSlow(string?s){//?追加字符串?空間不夠先擴容//?然后使用Span復制?相當高效int?pos?=?_pos;if?(pos?>?_chars.Length?-?s.Length){Grow(s.Length);}s
#if?!NETCOREAPP.AsSpan()
#endif.CopyTo(_chars.Slice(pos));_pos?+=?s.Length;}//?對于需要格式化的對象特殊處理[MethodImpl(MethodImplOptions.AggressiveInlining)]public?void?AppendSpanFormattable<T>(T?value,?string??format?=?null,?IFormatProvider??provider?=?null)where?T?:?ISpanFormattable{//?ISpanFormattable非常高效if?(value.TryFormat(_chars.Slice(_pos),?out?int?charsWritten,?format,?provider)){_pos?+=?charsWritten;}else{Append(value.ToString(format,?provider));}}[MethodImpl(MethodImplOptions.NoInlining)]private?void?GrowAndAppend(char?c){//?單個字符擴容在添加Grow(1);Append(c);}//?擴容方法[MethodImpl(MethodImplOptions.NoInlining)]private?void?Grow(int?additionalCapacityBeyondPos){Debug.Assert(additionalCapacityBeyondPos?>?0);Debug.Assert(_pos?>?_chars.Length?-?additionalCapacityBeyondPos,"Grow?called?incorrectly,?no?resize?is?needed.");//?同樣也是2倍擴容,默認從對象池中獲取bufferchar[]?poolArray?=?ArrayPool<char>.Shared.Rent((int)?Math.Max((uint)?(_pos?+?additionalCapacityBeyondPos),(uint)?_chars.Length?*?2));_chars.Slice(0,?_pos).CopyTo(poolArray);char[]??toReturn?=?_arrayToReturnToPool;_chars?=?_arrayToReturnToPool?=?poolArray;if?(toReturn?!=?null){//?如果原本就是使用的對象池?那么必須歸還ArrayPool<char>.Shared.Return(toReturn);}}//?[MethodImpl(MethodImplOptions.AggressiveInlining)]public?void?Dispose(){char[]??toReturn?=?_arrayToReturnToPool;this?=?default;?//?為了安全,在釋放時置空當前對象if?(toReturn?!=?null){//?一定要記得歸還對象池ArrayPool<char>.Shared.Return(toReturn);}}
}
從上面的源碼我們可以總結出ValueStringBuilder
的幾個特征:
比起
StringBuilder
來說,實現方式非常簡單。一切都是為了高性能,比如各種
Span
的用法,各種內聯參數,以及使用對象池等等。內存占用非常低,它本身就是結構體類型,另外它是
ref struct
,意味著不會被裝箱,不會在堆上分配。
適用場景
ValueStringBuilder
是一種高性能的字符串創建方式,針對于不同的場景,可以有不同的使用方式。1.非常高頻次的字符串拼接的場景,并且字符串長度較小,此時可以使用棧上分配的ValueStringBuilder
。 大家都知道現在ASP.NET Core性能非常好,在其依賴的內部庫UrlBuilder[2]中,就使用棧上分配,因為棧上分配在當前方法結束后內存就會回收,所以不會造成任何GC壓力。2.非常高頻次的字符串拼接場景,但是字符串長度不可控,此時使用ArrayPool指定容量的
ValueStringBuilder
。比如在.NET BCL庫中有很多場景使用,比如動態方法的ToString[3]實現。從池中分配雖然沒有棧上分配那么高效,但是一樣的能降低內存占用和GC壓力。3. 非常高頻次的字符串拼接場景,但是字符串長度可控,此時可以棧上分配和ArrayPool分配聯合使用,比如正則表達式[4]解析類中,如果字符串長度較小那么使用棧空間,較大那么使用ArrayPool。
需要注意的場景
1.在async\await
中無法使用ValueStringBuilder
。原因大家也都知道,因為ValueStringBuilder
是ref struct
,它只能在棧上分配,async\await
會編譯成狀態機拆分await
前后的方法,所以ValueStringBuilder
不好在方法內傳遞,不過編譯器也會警告。2.無法將
ValueStringBuilder
作為返回值返回,因為在當前棧上分配,方法結束后它會被釋放,返回它將指向未知的地址。這個編譯器也會警告。3.如果要將
ValueStringBuilder
傳遞給其它方法,那么必須使用ref
傳遞,否則發生值拷貝會存在多個實例。這個編譯器不會警告,但是你必須非常注意。4. 如果使用棧上分配,那么Buffer大小控制在5KB內比較穩妥,至于為什么需要這樣,后面有機會在講一講。
總結
今天和大家分享了一下高性能幾乎無內存占用的字符串拼接結構體ValueStringBuilder
,在大多數的場景還是推薦大家使用。但是要非常注意上面提到的[5]的幾個場景,如果不符合條件,那么大家還是可以使用高效的StringBuilder
來進行字符串拼接。
本文源碼鏈接:?
https://github.com/InCerryGit/BlogCode-Use-ValueStringBuilder
參考資料
[1]
你應該為集合類型設置初始大小: https://www.cnblogs.com/InCerry/p/Dotnet-Opt-Perf-You-Should-Set-Capacity-For-Collection.html
[2]UrlBuilder: https://github.com/dotnet/runtime/blob/57bfe474518ab5b7cfe6bf7424a79ce3af9d6657/src/libraries/System.Private.Uri/src/System/UriBuilder.cs#L284-L362
[3]ToString: https://github.com/dotnet/runtime/blob/43dd0a74ab524278620d8c6a9d33a9b73b2d2228/src/coreclr/System.Private.CoreLib/src/System/Reflection/RuntimeMethodInfo.CoreCLR.cs#L137
[4]正則表達式: https://github.com/dotnet/runtime/blob/43dd0a74ab524278620d8c6a9d33a9b73b2d2228/src/libraries/System.Text.RegularExpressions/src/System/Text/RegularExpressions/RegexParser.cs#L150
[5]上面提到的: #需要注意的場景