C#的内存分配探索

    • C++中默认的operator new底层调用的是malloc,在分配内存时会带上上下cookie用于内存回收和内存碎片合并,现在到了C#中,因为C#有独有的垃圾回收机制,那我就比较好奇C#中对于对象内存的排列是怎么样的,还有没有上下cookie存在呢?所以我进行了下面的测试

      public class MyClassInt
      {
          private long a;
      }
      
      public class MyClassLong
      {
          private int a;
      }
      
      static class Program
      {
          for (int i = 0; i < 3; i++)
          {
              printMemory(new MyClassInt());
          }
          Console.WriteLine();
          for (int i = 0; i < 3; i++)
          {
              printMemory(new MyClassLong());
          }
      
          public static void printMemory(object o) // 获取引用类型的内存地址方法  
          {
              GCHandle h = GCHandle.Alloc(o, GCHandleType.WeakTrackResurrection);
      
              IntPtr addr = GCHandle.ToIntPtr(h);
      
              Console.WriteLine("0x" + addr.ToString("X"));
          }
          /*
          输出
          0xF010F8
          0xF010F4
          0xF010F0
      
          0xF010EC
          0xF010E8
          0xF010E4
          */
      }
      

      起初对于MyClassInt的输出我很惊讶,因为int的size刚好是4,我本来认为是C#对于内存分配器进行了特殊的管理,比如类似gunc的pool_alloc这样的设计,去掉了cookie,使得对象的大小就是纯粹的size大小间隔,结果想想不太对劲,还是不能靠猜,在试试其他的,一试就试出来了。。。换成了long以后按推理方式来说应该是间隔8,才是纯粹的size大小,结果一看并不是,还是4...fuck,所以其实这个C#中通过new内存并没有带上对象的地址,所以可以看出来打印的应该是引用的地址段,每个地址段4字节,没有cookie,至于指针指向的地方有没有cookie?不得而知,不过这里可以看出这这这样的设计和C#的垃圾回收器有很大关系。且还有个好玩的点,不同类型的内存地址也是连续的,用的是一同一个地址段。接下来再进行一个测试:

      for (int i = 0; i < 100000; i++)
      {
          printMemory(new MyClassInt());
      }
      /*
      其他代码不变
      0x12F10F8
      0x12F10F4
      0x12F10F0
      ...
      0x12F1000
      0x12F14FC
      0x12F14F8
      0x12F14F4
      ...
      0x12F1400
      0x12F15FC
      0x12F15F8
      ...
      0x12FFF08
      0x12FFF04
      0x12FFF00
      0x31E10FC
      0x31E10F8
      0x31E10F4
      ...
      0x31EFF00
      0x31F10FC
      ...
      0x31FFF00
      0x57E10FC
      */
      

      这里我想知道一次分配器分配的大小是多少,不够的时候是什么时候要新的内存块?所以我进行了这个测试,测试下来结果我总结了一下:

      • 首先先是开始的时候总是以XXXXF8开始的,十进制是248

      • 然后新分配的内存会递减这个内存段,直到变成00

      • 变成00后会去要当前这个地址段前三位(F10)后最近的可用的地址段,比如当前是F10就去看看F11可用吗,不行就找F12,依次,比如例中找到了F10用完找到了F14,F14用完找F15依次内推

      • 找到后分配的大小是从FC开始,252,对比于256差了4,很可能用了这个4记录了这个内存块用于GC的信息,比如这个内存块的引用计数。

      • 然后当倒数第五到第三这两位也用完后(变为FFF),那就会分配新的地址段,也是递增搜寻。所以新地址是31E,默认从E10FC开始(921836),结束是FFF00(1048320),可用126484,每个对象为4的话就是31621个对对象

    • 继续探索,如果想要继续往下的话就得去深挖看看GCHandle.Alloc的分配

      • 如果深入研究GCHandle.Alloc,您会看到它调用了一个本地方法InternalAlloc

        [System.Security.SecurityCritical]  // auto-generated
        [MethodImplAttribute(MethodImplOptions.InternalCall)]
        [ResourceExposure(ResourceScope.None)]
        internal static extern IntPtr InternalAlloc(Object value, GCHandleType type);
        

        其中InternalAlloc是CLR公共库的代码,其中就有这里核心的一个部分,CLR 垃圾回收器

        其中InternalAlloc的核心就是这一句:hnd = GetAppDomain()->CreateTypedHandle(objRef, type);

        依次调用ObjectHandle::CreateTypedHandle-> HandleTable::HndCreateHandle-> HandleTableCache->TableAllocSingleHandleFromCache,如果缓存堆中存在则返回,不存在则分配,这里方法调用的时候我已经new了这个对象了,所以对象是存在的,存在会返回这个对象的IntPtr。在托管堆中发生的唯一分配是IntPtr,它保存指向表中地址的指针。所以我疯狂print的都是这个指针的地址,所以我探究的也是这个intPtr的分配策略,当我明白这一点的时候,我就发现,诶嘿,我又可以水一篇C#GC探索的文章了,所以我跟到了CLR的库,开始研究CLR的GC原理,看看没有官方的解释和源码来论证我上述的猜想,所以...下期再见~

posted @ 2021-01-22 12:42  陌冉  阅读(333)  评论(0编辑  收藏  举报