String
字符串作為一種特殊的引用類型,是迄今為止.NET程序中使用最多的類型。可以說是萬物皆可string
因此在分析dump的時(shí)候,大量字符串對(duì)象是很常見的現(xiàn)象
string的不可變性
string作為引用類型,那就意味是可以變化的.但在.NET中,它們默認(rèn)不可變。
也就是說行為類似值類型,實(shí)際上是引用類型的特殊情況。
但是,"字符串具有不可變性"僅在.NET平臺(tái)下成立,只是因?yàn)樵贐CL(Basic Class Library)中并未提供改變string內(nèi)容的方法而已。
在C/C++/F# 中,是可以改變的。因此,我們完全可以在底層實(shí)現(xiàn)修改字符串內(nèi)容
眼見為實(shí)
示例1
示例代碼
可以看到,string的值為aaa
通過算法:address + 0x10 + 2 * sizeof(char) ,我們直接修改內(nèi)存的內(nèi)容
可以看到,同一個(gè)內(nèi)存地址,里面的值已經(jīng)從"aaa"變成了"aab".
示例2
點(diǎn)擊查看代碼
字符串的可變行為
那么在日常使用中,我們需要大量字符串拼接的時(shí)候。如何改進(jìn)呢?
最常見的辦法就是使用Stringbuilder.
Stringbuilder源碼解析
public sealed partial class StringBuilder : ISerializable
{
internal char[] m_ChunkChars;
internal StringBuilder? m_ChunkPrevious;
public StringBuilder(string? value, int startIndex, int length, int capacity)
{
ArgumentOutOfRangeException.ThrowIfNegative(capacity);
ArgumentOutOfRangeException.ThrowIfNegative(length);
ArgumentOutOfRangeException.ThrowIfNegative(startIndex);
value ??= string.Empty;
if (startIndex > value.Length - length)
{
throw new ArgumentOutOfRangeException(nameof(length), SR.ArgumentOutOfRange_IndexLength);
}
m_MaxCapacity = int.MaxValue;
if (capacity == 0)
{
capacity = DefaultCapacity;
}
capacity = Math.Max(capacity, length);
m_ChunkChars = GC.AllocateUninitializedArray<char>(capacity);
m_ChunkLength = length;
value.AsSpan(startIndex, length).CopyTo(m_ChunkChars);
}
public StringBuilder Append(char value, int repeatCount)
{
if (repeatCount == 0)
{
return this;
}
char[] chunkChars = m_ChunkChars;
int chunkLength = m_ChunkLength;
if (((nuint)(uint)chunkLength + (nuint)(uint)repeatCount) <= (nuint)(uint)chunkChars.Length)
{
chunkChars.AsSpan(chunkLength, repeatCount).Fill(value);
m_ChunkLength += repeatCount;
}
else
{
AppendWithExpansion(value, repeatCount);
}
return this;
}
public override string ToString()
{
string result = string.FastAllocateString(Length);
StringBuilder? chunk = this;
do
{
if (chunk.m_ChunkLength > 0)
{
char[] sourceArray = chunk.m_ChunkChars;
int chunkOffset = chunk.m_ChunkOffset;
int chunkLength = chunk.m_ChunkLength;
Buffer.Memmove(
ref Unsafe.Add(ref result.GetRawStringData(), chunkOffset),
ref MemoryMarshal.GetArrayDataReference(sourceArray),
(nuint)chunkLength);
}
chunk = chunk.m_ChunkPrevious;
}
while (chunk != null);
return result;
}
}
在Stringbuilder的內(nèi)部,內(nèi)部使用char[] m_ChunkChars將文本保存。并且使用Span方式直接高性能操作內(nèi)存。
避免對(duì)象分配是改進(jìn)代碼性能的最常見方法
string.format/string.join/$"name={name}" 等常見函數(shù)均已在內(nèi)部實(shí)現(xiàn)Stringbuilder
字符串為什么不可變?
那么既然string的反直覺,那么為什么要這么設(shè)計(jì)呢?原因有如下幾點(diǎn)
- 安全性
string的使用范圍太廣了,比如new Dictionary<string, string>(),用戶token,文件路徑。它們的用途都代表一個(gè)key,如果這個(gè)key能被程序隨意修改。那么將毫無安全性可言。 - 并發(fā)性
正因?yàn)閟tring使用范圍大,所以很多場景都可能存在并發(fā)訪問,如果可變,那么需要承擔(dān)額外的同步開銷。
為什么string不是一個(gè)結(jié)構(gòu)?
上面說了這么多,結(jié)構(gòu)完美滿足了不可變/并發(fā)安全 這兩個(gè)條件,那為什么不把string定義為結(jié)構(gòu)?
其核心原因在于,結(jié)構(gòu)的傳值語義會(huì)導(dǎo)致頻繁復(fù)制字符串
而復(fù)制大字符串的開銷太大了,因此使用傳引用語義要高效得多
JSON 的序列化/反序列化就是一個(gè)典型的例子
字符串暫存
.NET Rumtime內(nèi)部有一個(gè)string interning 機(jī)制
當(dāng)兩個(gè)字符串一模一樣的時(shí)候,不需要在內(nèi)存中存兩份。只保留一份即可
但字符串暫存有個(gè)限制,默認(rèn)情況下是只暫存靜態(tài)創(chuàng)建的字符串的。也就是靜態(tài)值才會(huì)被暫存起來.由JIT來判斷是否暫存
舉個(gè)例子
究其原因是因?yàn)檫@樣做開銷巨大,創(chuàng)建一個(gè)新字符串時(shí),runtime需要?jiǎng)討B(tài)的檢測(cè)它是否已被暫存。如果被檢測(cè)的字符串相當(dāng)龐大或數(shù)量特別多,那么花銷同樣也很大。
FCL提供了顯式API string.IsInterned/string.Intern 來讓我們可以主動(dòng)暫存字符串。
字符串被暫存在哪里?
https://github.com/dotnet/runtime/blob/main/src/coreclr/vm/stringliteralmap.cpp
這時(shí)大家可以思考一下,暫存的字符串跟靜態(tài)變量有什么區(qū)別? 都是永遠(yuǎn)不會(huì)被釋放的對(duì)象
因此可以猜到。字符串應(yīng)該是被暫存在AppDomain中。與高頻堆應(yīng)該相鄰在一起.
在.NET內(nèi)部Appdomain中,有一個(gè)私有堆叫String Literal Map的對(duì)象,內(nèi)部存儲(chǔ)著字符串的hash與一個(gè)內(nèi)存地址。
內(nèi)存地址指向另外一個(gè)數(shù)據(jù)結(jié)構(gòu)LargeHeapHandleTable .位于LOH堆中,LargeHeapHandleTable內(nèi)部包含了對(duì)字符串實(shí)例的引用
在正常情況下,只有>85000字節(jié)的才會(huì)被分配到LOH堆中,LargeHeapHandleTable就是一個(gè)典型的例外。一些不會(huì)被回收/很難被回收的對(duì)象即使沒有超過85000也會(huì)分配在LOH堆中。因?yàn)檫@樣可以減少GC的工作量(不會(huì)升代,不會(huì)壓縮)
眼見為實(shí)
挖坑待埋,sos并未提供String Literal Map的堆地址,待我摸索幾天
安全字符串
在使用string的過程中,可能包含敏感對(duì)象。比如Password.
String對(duì)象內(nèi)部使用char[]來承載。因此攜帶敏感信息的string。被執(zhí)行了unsafe或者非托管代碼的時(shí)候。就有可能被掃描內(nèi)存。
只有對(duì)象被GC回收后,才是安全的。但是中間的時(shí)間差足夠被掃描N次了。
為了解決此問題,在FCL中添加了SecureString類。作為上位替代
- 內(nèi)部使用UnmanagedBuffer來代替char[]
public sealed partial class SecureString : IDisposable
{
private readonly object _methodLock = new object();
private UnmanagedBuffer? _buffer;
public SecureString()
{
_buffer = UnmanagedBuffer.Allocate(GetAlignedByteSize(value.Length));
_decryptedLength = value.Length;
SafeBuffer? bufferToRelease = null;
try
{
Span<char> span = AcquireSpan(ref bufferToRelease);
value.CopyTo(span);
}
finally
{
ProtectMemory();
bufferToRelease?.DangerousRelease();
}
}
public void AppendChar(char c)
{
lock (_methodLock)
{
EnsureNotDisposed();
EnsureNotReadOnly();
Debug.Assert(_buffer != null);
SafeBuffer? bufferToRelease = null;
try
{
UnprotectMemory();
EnsureCapacity(_decryptedLength + 1);
Span<char> span = AcquireSpan(ref bufferToRelease);
span[_decryptedLength] = c;
_decryptedLength++;
}
finally
{
ProtectMemory();
bufferToRelease?.DangerousRelease();
}
}
}
}
- 實(shí)現(xiàn)了IDisposable接口,開發(fā)可以手動(dòng)執(zhí)行Dispose().對(duì)內(nèi)存緩沖區(qū)直接清零,確保惡意代碼無法獲得敏感信息
public void Dispose()
{
lock (_methodLock)
{
if (_buffer != null)
{
_buffer.Dispose();
_buffer = null;
}
}
}
安全字符串真的安全嗎?
SecureString的目的是避免在進(jìn)程中使用純文本存儲(chǔ)機(jī)密信息
SecureString的底層本質(zhì)上也是一段未加密的char[],由FCL進(jìn)行數(shù)據(jù)加密/解密。
因此只有.NET Framework 中,內(nèi)部的char[]由windows提供支持,是加密的
但在.NET Core中,其他平臺(tái)并未提供系統(tǒng)層面的支持
https://github.com/dotnet/platform-compat/blob/master/docs/DE0001.md
因此,個(gè)人認(rèn)為真正的"銀彈". 是數(shù)據(jù)本身就是加密的。比如從數(shù)據(jù)庫中存儲(chǔ)就是加密內(nèi)容,或者配置文件中本身就是加密的。因?yàn)椴僮飨到y(tǒng)沒有安全字符串的概念。
惡意代碼只要能讀內(nèi)存,且內(nèi)存本身未加密。那么在CLR層上就是裸奔
轉(zhuǎn)自https://www.cnblogs.com/lmy5215006/p/18494483
該文章在 2024/10/28 11:54:36 編輯過