乐趣区

字符串太占内存了我想了各种奇思淫巧对它进行压缩

一:背景

1. 讲故事

在我们的一个全内存项目中,需要将一家大品牌店铺小千万的 trade 灌入到内存中,大家知道 trade 中一般会有 订单来源 , 省市区,当把这些字段灌进去后,你会发现他们特别侵蚀内存,因为都是字符串类型,不知道大家对内存侵蚀性是不是很清楚,我就问一个问题。

Answer: 一个空字符串占用多大内存?你知道吗?

思考之后,下面我们就一起验证下,使用 windbg 去托管堆一查究竟,代码如下:


        static void Main(string[] args)
        {
            string s = string.Empty;

            Console.ReadLine();}

0:000> !clrstack -l
OS Thread Id: 0x308c (0)
        Child SP               IP Call Site
ConsoleApp6.Program.Main(System.String[]) [C:\dream\Csharp\ConsoleApp1\ConsoleApp6\Program.cs @ 19]
    LOCALS:
        0x00000087391febd8 = 0x000002605da91420
0:000> !DumpObj /d 000002605da91420
Name:        System.String
String:      
Fields:
              MT    Field   Offset                 Type VT     Attr            Value Name
00007ff9eb2b85a0  4000281        8         System.Int32  1 instance                0 m_stringLength
00007ff9eb2b6838  4000282        c          System.Char  1 instance                0 m_firstChar
00007ff9eb2b59c0  4000286       d8        System.String  0   shared           static Empty
                                 >> Domain:Value  000002605beb2230:NotInit  <<
0:000> !objsize 000002605da91420
sizeof(000002605da91420) = 32 (0x20) bytes (System.String)

从图中你可以看到,仅仅一个空字符串就要占用 32byte,如果 500w 个空字符串就是:32byte x 500w = 152M,是不是不算不知道,一算吓一跳。。。这还仅仅是一个什么都没有的空字符串哦。

2. 回归到 Trade

问题也已经摆出来了,接下来回归到 Trade 中,为了方便演示,先模拟以文件的形式从数据库读取 20w 的 trade。

    class Program
    {static void Main(string[] args)
        {var trades = Enumerable.Range(0, 20 * 10000).Select(m => new Trade()
            {
                TradeID = m,
                TradeFrom = File.ReadLines(Environment.CurrentDirectory + "//orderfrom.txt")
                                 .ElementAt(m % 4)
            }).ToList();

            GC.Collect();  // 方便测试,把临时变量清掉
            Console.WriteLine("执行成功");
            Console.ReadLine();}
    }

    class Trade
    {public int TradeID { get; set;}
        public string TradeFrom {get; set;}
    }

然后用 windbg 去跑一下托管堆,再量一下 trades 的大小。


0:000> !dumpheap -stat
Statistics:
              MT    Count    TotalSize Class Name
00007ff9eb2b59c0   200200      7010246 System.String

0:000> !objsize 0x000001a5860629a8
sizeof(000001a5860629a8) = 16097216 (0xf59fc0) bytes (System.Collections.Generic.List`1[[ConsoleApp6.Trade, ConsoleApp6]])

从上面输出中可以看到托管堆有 200200 = 20w(程序分配)+ 200(系统分配) 个,然后再看 size:16097216/1024/1024= 15.35M,这就是展示的所有原始情况。

二:压缩技巧分析

1. 使用字典化处理

其实在托管堆上有 20w 个字符串,但你仔细观察一下会发现其实就是 4 种状态的重复显示,要么一淘,要么淘宝。。。这就给了我优化机会,何不在获取数据的时候构建好 OrderFrom 的字典,然后在 trade 中附增一个 TradeFromID 记录字典中的映射值,因为特征值少,用 byte 就可以了,有了这个思想,可以把代码修改如下:


    class Program
    {public static Dictionary<int, string> orderfromDict = new Dictionary<int, string>();

        static void Main(string[] args)
        {var trades = Enumerable.Range(0, 20 * 10000).Select(m =>
            {var tradefrom = File.ReadLines(Environment.CurrentDirectory + "//orderfrom.txt")
                                 .ElementAt(m % 4);

                var kv = orderfromDict.FirstOrDefault(k => k.Value == tradefrom);

                if (kv.Key == 0)
                {orderfromDict.Add(orderfromDict.Count + 1, tradefrom);
                }

                var trade = new Trade() { TradeID = m, TradeFromID = (byte)kv.Key };

                return trade;

            }).ToList();

            GC.Collect();  // 方便测试,把临时变量清掉

            Console.WriteLine("执行成功");

            Console.ReadLine();}
    }

    class Trade
    {public int TradeID { get; set;}

        public byte TradeFromID {get; set;}

        public string TradeFrom
        {
            get
            {return Program.orderfromDict[TradeFromID];
            }
        }
    }

代码还是很简单的,接下来用 windbg 看一下空间到底压缩了多少?

0:000> !dumpheap -stat
Statistics:
              MT    Count    TotalSize Class Name
00007ff9eb2b59c0      204        10386 System.String

0:000> !clrstack -l
OS Thread Id: 0x2ce4 (0)
        Child SP               IP Call Site
ConsoleApp6.Program.Main(System.String[]) [C:\dream\Csharp\ConsoleApp1\ConsoleApp6\Program.cs @ 42]
    LOCALS:
        0x0000006f4d9ff078 = 0x0000016fdcf82ab8

0000006f4d9ff288 00007ff9ecd96c93 [GCFrame: 0000006f4d9ff288] 
0:000> !objsize 0x0000016fdcf82ab8
sizeof(0000016fdcf82ab8) = 6897216 (0x693e40) bytes (System.Collections.Generic.List`1[[ConsoleApp6.Trade, ConsoleApp6]])

从上面的输出中可以看到,托管堆上 string 现在是:204 = 4(程序分配)+ 200(系统分配)个,这 4 个就是字典中的 4 个哦,空间的话:6897216 /1024/1024= 6.57M,对应之前的 15.35M优化了将近 60%。

虽然优化了 60%,但这种优化是破坏性的优化,需要修改我的 Trade 结构,同时还要定义个 Dictionary,而且还有不小幅度的修改业务逻辑,<font color=”red”> 大家都知道线上的代码是能不改则不改,不改肯定没错,改出问题肯定是你兜着走 </font>,是吧,那问题就来了,如何最小化的修改而且还能压缩空间,有这样两全其美的事情吗???

2. 利用字符串驻留池

貌似一说出来,大家都如梦初醒,驻留池的出现就是为了解决这个问题,CLR 会在内部维护了一个我刚才定义的字典机制,重复的字符串就不需要在堆上再次分配,直接存它的引用地址即可,如果你不清楚驻留池,建议看一下我这篇:https://www.cnblogs.com/huang…

接下来只需要在 tradefrom 字段包一层 string.Intern 即可,改动不要太小,代码如下:


        static void Main(string[] args)
        {var trades = Enumerable.Range(0, 20 * 10000).Select(m => new Trade()
            {
                TradeID = m,
                TradeFrom = string.Intern(File.ReadLines(Environment.CurrentDirectory + "//orderfrom.txt")
                                 .ElementAt(m % 4)),   // 包一层 string.Intern
            }).ToList();

            GC.Collect();  // 方便测试,把临时变量清掉
            Console.WriteLine("执行成功");
            Console.ReadLine();}

然后用 windbg 抓一下托管堆。


0:000> !dumpheap -stat 
Statistics:
              MT    Count    TotalSize Class Name
00007ff9eb2b59c0      204        10386 System.String

0:000> !clrstack -l
OS Thread Id: 0x13f0 (0)
        Child SP               IP Call Site

ConsoleApp6.Program.Main(System.String[]) [C:\dream\Csharp\ConsoleApp1\ConsoleApp6\Program.cs @ 27]
    LOCALS:
        0x0000005e4d3ff0a8 = 0x000001f8a15129a8

0000005e4d3ff2b8 00007ff9ecd96c93 [GCFrame: 0000005e4d3ff2b8] 
0:000> !objsize 0x000001f8a15129a8
sizeof(000001f8a15129a8) = 8497368 (0x81a8d8) bytes (System.Collections.Generic.List`1[[ConsoleApp6.Trade, ConsoleApp6]])

观察后发现,当用了驻留池之后空间为:8497368 /1024/1024 =8.1M,你可能有疑问,为什么和字典化相比内存要大 24% 呢?仔细观察你会发现,当用驻留池后,List<Trade> 中的 TradeFrom 存的是 string 在堆中的内存地址,在 x64 机器上要占用 8 个字节,而字典化方式内存堆上 Trade 是不分配 TradeFrom,而是用了一个 byte 来替代,总体来说相当于一个 trade 省了7byte 的空间,然后用 windbg 看一下。


0:000> !da -length 1 -details 000001f8b16f9b68
Name:        ConsoleApp6.Trade[]
Size:        2097176(0x200018) bytes
Array:       Rank 1, Number of elements 262144, Type CLASS

    Fields:
                      MT    Field   Offset                 Type VT     Attr            Value Name
        00007ff9eb2b85a0  4000001       10             System.Int32      1     instance                    0     <TradeID>k__BackingField
        00007ff9eb2b59c0  4000002        8            System.String      0     instance     000001f8a1516030     <TradeFrom>k__BackingField

0:000> !DumpObj /d 000001f8a1516030
Name:        System.String
String:      WAP

可以看到, 000001f8a1516030 就是 堆上 string=Wap的引用地址,这个地址占用了 8byte 空间。

再回头 dump 一下使用字典化方式的 Trade, 可以看到它是没有 <TradeFrom>k__BackingField 字段的。


0:000> !da -length 1 -details 000001ed52759ac0
Name:        ConsoleApp6.Trade[]
Size:        262168(0x40018) bytes
Array:       Rank 1, Number of elements 32768, Type CLASS
    Fields:
                      MT    Field   Offset                 Type VT     Attr            Value Name
        00007ff9eb2b85a0  4000002        8             System.Int32      1     instance                    0     <TradeID>k__BackingField
        00007ff9eb2b7d20  4000003        c              System.Byte      1     instance                    0     <TradeFromID>k__BackingField

三:总结

大家可以根据自己的情况使用,使用驻留池方式是改变最小的,简单粗暴,自己构建字典化虽然最省内存,但需要修正业务逻辑,这个风险自担哦。。。

退出移动版