ETJava Beta | Java    注册   登录
  • 搜索:
  • 几张图带你了解.NET String

    发表于      阅读(1)     博客类别:Crawler     转自:https://www.cnblogs.com/lmy5215006/p/18494483
    如有侵权 请联系我们删除  (页面底部联系我们)  

    String

    字符串作为一种特殊的引用类型,是迄今为止.NET程序中使用最多的类型。可以说是万物皆可string

    因此在分析dump的时候,大量字符串对象是很常见的现象

    string的不可变性

    string作为引用类型,那就意味是可以变化的.但在.NET中,它们默认不可变。
    也就是说行为类似值类型,实际上是引用类型的特殊情况。
    image

    但是,"字符串具有不可变性"仅在.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();
            }
    

    image
    可以看到,string的值为aaa

    image
    通过算法:address + 0x10 + 2 * sizeof(char) ,我们直接修改内存的内容

    image

    可以看到,同一个内存地址,里面的值已经从"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方式直接高性能操作内存。
    image

    避免对象分配是改进代码性能的最常见方法
    string.format/string.join/$"name={name}" 等常见函数均已在内部实现Stringbuilder

    字符串为什么不可变?

    那么既然string的反直觉,那么为什么要这么设计呢?原因有如下几点

    1. 安全性
      string的使用范围太广了,比如new Dictionary<string, string>(),用户token,文件路径。它们的用途都代表一个key,如果这个key能被程序随意修改。那么将毫无安全性可言。
    2. 并发性
      正因为string使用范围大,所以很多场景都可能存在并发访问,如果可变,那么需要承担额外的同步开销。

    为什么string不是一个结构?

    上面说了这么多,结构完美满足了不可变/并发安全 这两个条件,那为什么不把string定义为结构?
    其核心原因在于,结构的传值语义会导致频繁复制字符串
    而复制大字符串的开销太大了,因此使用传引用语义要高效得多

    JSON 的序列化/反序列化就是一个典型的例子

    字符串暂存

    .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 来让我们可以主动暂存字符串。

    字符串被暂存在哪里?

    https://github.com/dotnet/runtime/blob/main/src/coreclr/vm/stringliteralmap.cpp

    这时大家可以思考一下,暂存的字符串跟静态变量有什么区别? 都是永远不会被释放的对象
    因此可以猜到。字符串应该是被暂存在AppDomain中。与高频堆应该相邻在一起.

    在.NET内部Appdomain中,有一个私有堆叫String Literal Map的对象,内部存储着字符串的hash与一个内存地址。
    内存地址指向另外一个数据结构LargeHeapHandleTable .位于LOH堆中,LargeHeapHandleTable内部包含了对字符串实例的引用
    image

    在正常情况下,只有>85000字节的才会被分配到LOH堆中,LargeHeapHandleTable就是一个典型的例外。一些不会被回收/很难被回收的对象即使没有超过85000也会分配在LOH堆中。因为这样可以减少GC的工作量(不会升代,不会压缩)

    眼见为实

    挖坑待埋,sos并未提供String Literal Map的堆地址,待我摸索几天
    image

    安全字符串

    在使用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();
                    }
                }
            }
    }
    
    1. 实现了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

    因此,个人认为真正的"银弹". 是数据本身就是加密的。比如从数据库中存储就是加密内容,或者配置文件中本身就是加密的。因为操作系统没有安全字符串的概念。

    恶意代码只要能读内存,且内存本身未加密。那么在CLR层上就是裸奔