简介
string 作为一种特殊的引用类型,是迄今为止.NET 程序中使用最多的类型。其本质就是 unicode 格式的 char[] 分配在托管堆上
因此在分析 dump 的时候,大量的 string,char[]是很常见的现象
String 的内存布局
https://github.com/dotnet/runtime/blob/main/src/coreclr/nativeaot/Runtime.Base/src/System/String.cs
从源码中看到,String 对象内存布局应该如下
眼见为实
static void Main(string[] args)
{
string name = "Lewis";
Console.WriteLine(name);
Debugger.Break();
}
复制代码
Strig 的池化
.NET Rumtime 内部有一个 string interning 机制当两个字符串一模一样的时候,不需要在内存中存两份。只保留一份即可
但字符串暂存有个限制,默认情况下是只暂存静态创建的字符串的。也就是静态值才会被暂存起来.由 JIT 来判断是否暂存
举个例子
static void Main(string[] args)
{
var s1 = "hello world";
var s2 = "hello ";
var s3 = "world";
Console.WriteLine(string.ReferenceEquals(global,s1)); //True ,两者一致,只保留一个变量
Console.WriteLine(string.ReferenceEquals(s1, s2 + s3));//False s2+s3是动态的,不暂存
Console.ReadLine();
}
复制代码
究其原因是因为这样做开销巨大,创建一个新字符串时,runtime 需要动态的检测它是否已被暂存。如果被检测的字符串相当庞大或数量特别多,那么花销同样也很大。
FCL 提供了显式 API string.IsInterned/string.Intern 来让我们可以主动暂存字符串。
眼见为实
示例代码
static void Main(string[] args)
{
string str1 = "asdfghjkl";
string str2 = "asdfghjkl";
string str3 = "asdfghjkl";
string str4 = "asdfghjkl";
string str5 = "asdfghjkl";
Console.WriteLine("done");
Debugger.Break();
}
复制代码
示例一
IL_0001: ldstr "asdfghjkl"
IL_0006: stloc.0
// string text2 = "asdfghjkl";
IL_0007: ldstr "asdfghjkl"
IL_000c: stloc.1
// string text3 = "asdfghjkl";
IL_000d: ldstr "asdfghjkl"
IL_0012: stloc.2
// string text4 = "asdfghjkl";
IL_0013: ldstr "asdfghjkl"
IL_0018: stloc.3
// string text5 = "asdfghjkl";
IL_0019: ldstr "asdfghjkl"
IL_001e: stloc.s 4
// Console.WriteLine("done");
IL_0020: ldstr "done"
IL_0025: call void [System.Console]System.Console::WriteLine(string)
复制代码
熟悉 IL 代码的人都知道,IL 在创建引用类型的实例的时候,都是使用 newobj 命令。但是面对 string,使用的 ldstr 命令。
示例二
在 windbg 中也是引用同一个内存地址
字符串被池化在哪里?
https://github.com/dotnet/runtime/blob/main/src/coreclr/vm/stringliteralmap.cpp
这时大家可以思考一下,暂存的字符串跟静态变量有什么区别? 都是永远不会被释放的对象因此可以猜到。字符串应该是被暂存在 AppDomain 中。与高频堆应该相邻在一起.
在.NET 内部 Appdomain 中,有一个私有堆叫 String Literal Map 的对象,内部存储着字符串的 hash 与一个内存地址。内存地址指向另外一个数据结构 LargeHeapHandleTable .位于 LOH 堆中,LargeHeapHandleTable 内部包含了对字符串实例的引用
在正常情况下,只有>85000 字节的才会被分配到 LOH 堆中,LargeHeapHandleTable 就是一个典型的例外。一些不会被回收/很难被回收的对象即使没有超过 85000 也会分配在 LOH 堆中。因为这样可以减少 GC 的工作量(不会升代,不会压缩)
眼见为实
可以看到,被暂存的字符串有一个 gc 根引用着它,并且还是一个固定句柄
.NET 8 中的极致优化
在.NET 8 中,新增了一个堆,叫 Non-GC Heap。简单来说,在.NET 8 之前。string 被作为固定句柄。虽然内存地址不会变,但依旧受到 GC 管理,并且固定句柄还会导致代降级。因此还是有一定开销。在.NET 8 之后,直接将编译期间就能确定的数据放置到 Non-GC Heap 中,完全不受 GC 管理。极大提高性能
眼见为实
同一段代码 string name = "Lewis";在.net 8 中的汇编
00007FFD54B37773 mov rcx,21018006808h
00007FFD54B3777D mov qword ptr [rbp+28h],rcx
复制代码
在.net 6 中的汇编
00007FFD5D0558B3 mov rcx,21018006808h
00007FFD5D0558BD mov rcx,qword ptr [rcx]
00007FFD5D0558C0 mov qword ptr [rbp+28h],rcx
复制代码
可以看到,在.net 8 中。直接跳过了间接寻址操作,将 rcx 寄存器的值直接推入栈中
String 的不可变性
string 作为引用类型,那就意味是可以变化的.但在.NET 中,它们默认不可变,也就是说行为类似值类型,实际上是引用类型的特殊情况。
这会带来一种情况,对字符串的任何操作,修改/追加都会导致重新创建一个新的 string 对象。而且因为动态创建的。所以存放在托管堆中。
但是,"字符串具有不可变性"仅在.NET 平台下成立,只是因为在 BCL(Basic Class Library)中并未提供改变 string 内容的方法而已。在 C/C++/F# 中,是可以改变的。因此,我们完全可以在底层实现修改字符串内容
眼见为实
示例 1
示例代码
static void Main(string[] args)
{
var teststr = "aaa";
Debugger.Break();
Console.WriteLine(teststr);
Console.ReadLine();
}
复制代码
可以看到,string 的值为 aaa
通过算法:address + 0x10 + 2 * sizeof(char) ,我们直接修改内存的内容
可以看到,同一个内存地址,里面的值已经从"aaa"变成了"aab".
示例 2
点击查看代码
static void Main(string[] args)
{
var str1 = "aaa";
ref var c0 = ref MemoryMarshal.GetReference<char>(str1.AsSpan(0));
c0 = '0';
ref var c1 = ref MemoryMarshal.GetReference<char>(str1.AsSpan(1));
c1 = '1';
Console.WriteLine(str1);//从aaa变成了01a
}
复制代码
字符串的可变行为
那么在日常使用中,我们需要大量字符串拼接的时候。如何改进呢?最常见的办法就是使用 Stringbuilder.
Stringbuilder 源码解析
public sealed partial class StringBuilder : ISerializable
{
//存储字符串的char[]
internal char[] m_ChunkChars;
//StringBuilder之间使用链表来关联
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;
// 尝试在当前块中放入所有重复字符
// 使用与 Span<T>.Slice 相同的检查,以便在 64 位系统中进行折叠
// 因为 repeatCount 不能为负数,所以在 32 位系统中不会溢出
if (((nuint)(uint)chunkLength + (nuint)(uint)repeatCount) <= (nuint)(uint)chunkChars.Length)
{
//使用Span高性能填充char[]
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;
// 使用内存移动复制数据到result中
Buffer.Memmove(
ref Unsafe.Add(ref result.GetRawStringData(), chunkOffset),
ref MemoryMarshal.GetArrayDataReference(sourceArray),
(nuint)chunkLength);
}
//移动到上一个StringBuilder中,链表式读取
chunk = chunk.m_ChunkPrevious;
}
while (chunk != null);
return result;
}
}
复制代码
在 Stringbuilder 的内部,内部使用 char[] m_ChunkChars 将文本保存。并且使用 Span 方式直接高性能操作内存。
避免对象分配是改进代码性能的最常见方法 string.format/string.join/$"name={name}" 等常见函数均已在内部实现 Stringbuilder
字符串为什么不可变?
那么既然 string 的反直觉,那么为什么要这么设计呢?原因有如下几点
1、安全性
string 的使用范围太广了,比如 new Dictionary<string, string>(),用户 token,文件路径。它们的用途都代表一个 key,如果这个 key 能被程序随意修改。那么将毫无安全性可言。
2、并发性
正因为 string 使用范围大,所以很多场景都可能存在并发访问,如果可变,那么需要承担额外的同步开销。
为什么 string 不是一个结构?
上面说了这么多,结构完美满足了不可变/并发安全 这两个条件,那为什么不把 string 定义为结构?其核心原因在于,结构的传值语义会导致频繁复制字符串而复制大字符串的开销太大了,因此使用传引用语义要高效得多
JSON 的序列化/反序列化就是一个典型的例子
安全字符串
在使用 string 的过程中,可能包含敏感对象。比如 Password.String 对象内部使用 char[]来承载。因此携带敏感信息的 string。被执行了 unsafe 或者非托管代码的时候。就有可能被扫描内存。只有对象被 GC 回收后,才是安全的。但是中间的时间差足够被扫描 N 次了。
为了解决此问题,在 FCL 中添加了 SecureString 类。作为上位替代
1、内部使用 UnmanagedBuffer 来代替 char[]
public sealed partial class SecureString : IDisposable
{
private readonly object _methodLock = new object();//同步锁
private UnmanagedBuffer? _buffer; //使用UnmanagedBuffer代替char[]
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();
}
}
}
}
复制代码
2、实现了 IDisposable 接口,开发可以手动执行 Dispose().对内存缓冲区直接清零,确保恶意代码无法获得敏感信息
public void Dispose()
{
lock (_methodLock)
{
if (_buffer != null)
{
_buffer.Dispose();
_buffer = null;
}
}
}
复制代码
安全字符串真的安全吗?
SecureString 的目的是避免在进程中使用纯文本存储机密信息 SecureString 的底层本质上也是一段未加密的 char[],由 FCL 进行数据加密/解密。因此只有.NET Framework 中,内部的 char[]由 windows 提供支持,是加密的但在.NET Core 中,其他平台并未提供系统层面的支持
https://github.com/dotnet/platform-compat/blob/master/docs/DE0001.md
因此,个人认为真正的"银弹". 是数据本身就是加密的。比如从数据库中存储就是加密内容,或者配置文件中本身就是加密的。因为操作系统没有安全字符串的概念。
文章转载自:叫我安不理
原文链接:https://www.cnblogs.com/lmy5215006/p/18494483
体验地址:http://www.jnpfsoft.com/?from=infoq
评论