关于字符串驻留和替代方案

14

我有一个大文件,实质上包含像这样的数据:

Netherlands,Noord-holland,Amsterdam,FooStreet,1,...,...
Netherlands,Noord-holland,Amsterdam,FooStreet,2,...,...
Netherlands,Noord-holland,Amsterdam,FooStreet,3,...,...
Netherlands,Noord-holland,Amsterdam,FooStreet,4,...,...
Netherlands,Noord-holland,Amsterdam,FooStreet,5,...,...
Netherlands,Noord-holland,Amsterdam,BarRoad,1,...,...
Netherlands,Noord-holland,Amsterdam,BarRoad,2,...,...
Netherlands,Noord-holland,Amsterdam,BarRoad,3,...,...
Netherlands,Noord-holland,Amsterdam,BarRoad,4,...,...
Netherlands,Noord-holland,Amstelveen,BazDrive,1,...,...
Netherlands,Noord-holland,Amstelveen,BazDrive,2,...,...
Netherlands,Noord-holland,Amstelveen,BazDrive,3,...,...
Netherlands,Zuid-holland,Rotterdam,LoremAve,1,...,...
Netherlands,Zuid-holland,Rotterdam,LoremAve,2,...,...
Netherlands,Zuid-holland,Rotterdam,LoremAve,3,...,...
...

这是一个多GB的文件。我有一个类来读取这个文件,并将这些行(记录)作为IEnumerable<MyObject>暴露出来。这个MyObject有几个属性(如Country,Province,City)等。
正如您所看到的,数据有很多重复。我想保持将基础数据暴露为IEnumerable<MyObject>。但是,另一个类可能会对这些数据进行一些分层查看/结构,例如:
Netherlands
    Noord-holland
        Amsterdam
            FooStreet [1, 2, 3, 4, 5]
            BarRoad [1, 2, 3, 4]
            ...
        Amstelveen
            BazDrive [1, 2, 3]
            ...
         ...
    Zuid-holland
        Rotterdam
            LoremAve [1, 2, 3]
            ...
        ...
    ...
...

阅读此文件时,我基本上执行以下操作:
foreach (line in myfile) {
    fields = line.split(",");
    yield return new MyObject {
        Country = fields[0],
        Province = fields[1],
        City = fields[2],
        Street = fields[3],
        //...other fields
    };
}

现在,来到实际问题:我可以使用string.Intern()来使Country、Province、City和Street这些字符串更加紧凑(它们是主要的“罪犯”,MyObject还有其他与问题无关的属性)。
foreach (line in myfile) {
    fields = line.split(",");
    yield return new MyObject {
        Country = string.Intern(fields[0]),
        Province = string.Intern(fields[1]),
        City = string.Intern(fields[2]),
        Street = string.Intern(fields[3]),
        //...other fields
    };
}

使用 string.Intern() 可以节省大约 42% 的内存(经过测试和测量),因为所有重复的字符串都将引用同一个字符串。此外,当使用许多 LINQ 的 .ToDictionary() 方法创建分层结构时,相应字典的键(国家、省份等)将更加高效。

然而,使用 string.Intern() 的缺点之一(除了轻微的性能损失,这不是问题)是这些字符串将不再被垃圾回收。但是当我完成数据时,我确实希望所有这些东西最终被垃圾回收。

我可以使用 Dictionary<string, string> 来“内部化”这些数据,但我不喜欢在实际上只对 key 感兴趣时有一个 keyvalue 的“开销”。我可以将 value 设置为 null 或使用相同的字符串作为值(这将导致 keyvalue 中的相同引用)。虽然只需要支付几个字节的小代价,但仍然需要支付代价。

HashSet<string> 这样的东西对我来说更有意义。然而,我无法获取 HashSet 中字符串的引用;我可以看到 HashSet 是否包含特定字符串,但无法获取位于 HashSet 中的该特定实例的引用。我可以为此实现自己的 HashSet,但我想知道其他聪明的 StackOverflower 可能会想出什么其他解决方案。

要求:

  • 我的 "FileReader" 类需要继续公开一个IEnumerable<MyObject>
  • 我的 "FileReader" 类可能会执行一些操作(例如 string.Intern()),以优化内存使用。
  • MyObject不能更改;我不会创建一个City类,Country类等,并将这些作为属性暴露给MyObject而不是简单的string属性。
  • 目标是通过去重大部分CountryProvinceCity等中的重复字符串使代码更加内存高效;如何实现(例如字符串内部化、内部哈希集/集合/结构)并不重要。但是:
  • 我知道可以将数据存储在数据库中或使用其他解决方案,但我对这些解决方案不感兴趣。
  • 速度只是次要问题;当然,越快越好,但在读取/迭代对象时略有性能损失也没有问题。
  • 由于这是一个长期运行的过程(例如:24/7/365 运行的 windows 服务),偶尔处理这些数据,因此在完成后希望对数据进行垃圾回收;字符串内部化效果很好,但久而久之会导致大量未使用的数据的字符串池。
  • 我希望任何解决方案都要“简单”;添加 15 个具有 P/Invokes 和内联装配(夸张)的类不值得一试。代码可维护性高居我的清单之首。

这更像是一个“理论”问题;我纯粹出于好奇/兴趣而问。没有“真正”的问题,但在类似情况下,我可以看到这可能成为某人的问题。


例如:我可以采取以下做法:

public class StringInterningObject
{
    private HashSet<string> _items;

    public StringInterningObject()
    {
        _items = new HashSet<string>();
    }

    public string Add(string value)
    {
        if (_items.Add(value))
            return value;  //New item added; return value since it wasn't in the HashSet
        //MEH... this will quickly go O(n)
        return _items.First(i => i.Equals(value)); //Find (and return) actual item from the HashSet and return it
    }
}

但是,如果有一组大量的(需要去重的)字符串,这将很快变得混乱。我可以查看 HashSet 字典等参考源代码,并构建一个类似的类,该类不会为 Add()方法返回布尔值,而是实际在内部/存储桶中找到的字符串。
到目前为止,我能想到的最好的解决方案是:
public class StringInterningObject
{
    private ConcurrentDictionary<string, string> _items;

    public StringInterningObject()
    {
        _items = new ConcurrentDictionary<string, string>();
    }

    public string Add(string value)
    {
        return _items.AddOrUpdate(value, value, (v, i) => i);
    }
}

这里的“罚款”是因为我只对Key感兴趣,但它还包括一个Value。不过仅仅几个字节,付出的代价微不足道。巧合的是,这也可以使内存使用量减少42%;与使用string.Intern()时得到的结果相同。

tolanj想到了System.Xml.NameTable:

public class StringInterningObject
{
    private System.Xml.NameTable nt = new System.Xml.NameTable();

    public string Add(string value)
    {
        return nt.Add(value);
    }
}

(我已经删除了 锁定和string.Empty检查 (后者是因为NameTable 已经做到了))

xanatos 提出了一个CachingEqualityComparer:

public class StringInterningObject
{
    private class CachingEqualityComparer<T> : IEqualityComparer<T> where T : class
    {
        public System.WeakReference X { get; private set; }
        public System.WeakReference Y { get; private set; }

        private readonly IEqualityComparer<T> Comparer;

        public CachingEqualityComparer()
        {
            Comparer = EqualityComparer<T>.Default;
        }

        public CachingEqualityComparer(IEqualityComparer<T> comparer)
        {
            Comparer = comparer;
        }

        public bool Equals(T x, T y)
        {
            bool result = Comparer.Equals(x, y);

            if (result)
            {
                X = new System.WeakReference(x);
                Y = new System.WeakReference(y);
            }

            return result;
        }

        public int GetHashCode(T obj)
        {
            return Comparer.GetHashCode(obj);
        }

        public T Other(T one)
        {
            if (object.ReferenceEquals(one, null))
            {
                return null;
            }

            object x = X.Target;
            object y = Y.Target;

            if (x != null && y != null)
            {
                if (object.ReferenceEquals(one, x))
                {
                    return (T)y;
                }
                else if (object.ReferenceEquals(one, y))
                {
                    return (T)x;
                }
            }

            return one;
        }
    }

    private CachingEqualityComparer<string> _cmp; 
    private HashSet<string> _hs;

    public StringInterningObject()
    {
        _cmp = new CachingEqualityComparer<string>();
        _hs = new HashSet<string>(_cmp);
    }

    public string Add(string item)
    {
        if (!_hs.Add(item))
            item = _cmp.Other(item);
        return item;
    }
}

根据Henk Holterman的要求稍作修改以适应我的“Add()接口”:

public class StringInterningObject
{
    private Dictionary<string, string> _items;

    public StringInterningObject()
    {
        _items = new Dictionary<string, string>();
    }

    public string Add(string value)
    {
        string result;
        if (!_items.TryGetValue(value, out result))
        {
            _items.Add(value, value);
            return value;
        }
        return result;
    }
}

我在想是否有更简洁/更好/更酷的方法来“解决”我的(不是真正的)问题。现在我想已经有足够多的选择了wink


这里是一些我为一些简单、短暂、初步测试想出来的数字:


未优化
内存:约4.5GB
加载时间:约52秒


StringInterningObject(请参见上文,ConcurrentDictionary变体)
内存:约2.6GB
加载时间:约49秒


string.Intern()
内存:约2.3GB
加载时间:约45秒


System.Xml.NameTable
内存:约2.3GB
加载时间:约41秒


CachingEqualityComparer
内存:约2.3GB
加载时间:约58秒


StringInterningObject(请参见上文,非并发Dictionary变体),根据Henk Holterman的请求
内存:约2.3GB
加载时间:约39秒

虽然数字没有非常明确,但事实上使用未优化版本的许多内存分配比使用 string.Intern() 或上述的 StringInterningObjects 会导致(稍微)更长的加载时间。<<参见更新。


“只需要支付几个字节的小代价” - 没错。你已经在这里找到了解决方案,那个开销是可以忽略不计的。 - H H
1
我之所以展示了解决方案并解释了(最小)开销,是因为这是一个好的解决方案(而且可以正常工作)。但由于我正在解决这个问题,我只是想知道是否有人能够提出更好的替代方案,同时也能削减掉这些最后几个字节(不要增加太多复杂性,因为:可维护性)。我猜想,.Net BCL 是否有一个类似 HashSet 的替代品,在这方面会有所帮助,而我却错过了或者其他什么。或者,也许我只是在胡言乱语,想到了一些编译器指令可以帮助解决问题。 - RobIII
1
我在一月份开始了一个项目,主要是处理这个问题,但同时涉及到几种不同的情况(由 string.Intern 支持或不支持、是否弱引用、以每个操作成本为代价的并发性能与以不可重入为代价的更快速度)。我真的必须重新回到它并发布它。同时,编写自己的哈希集合来返回内部化的项并不困难,我会选择这样做。 - Jon Hanna
1
这是使用像Sqlite或SQL Compact这样的小型数据库提供程序的合理替代方案吗?我不这么认为,字符串池化只会导致内存泄漏。 - Hans Passant
1
我不需要持久性,也不想依赖外部进程。另外:这只是一个理论问题(也许可以尝试将其作为智力游戏/谜题来解决?)关于内存、GC等,正如我在问题中提到的:“我知道我可以把数据塞进数据库或使用其他类似的解决方案;我对这些解决方案不感兴趣”。关于“字符串驻留只是一种内存泄漏”:这也在我的问题中得到了解决。 - RobIII
显示剩余4条评论
3个回答

3
如果不确定,就作弊!:-)
public class CachingEqualityComparer<T> : IEqualityComparer<T> where  T : class
{
    public T X { get; private set; }
    public T Y { get; private set; }

    public IEqualityComparer<T> DefaultComparer = EqualityComparer<T>.Default;

    public bool Equals(T x, T y)
    {
        bool result = DefaultComparer.Equals(x, y);

        if (result)
        {
            X = x;
            Y = y;
        }

        return result;
    }

    public int GetHashCode(T obj)
    {
        return DefaultComparer.GetHashCode(obj);
    }

    public T Other(T one)
    {
        if (object.ReferenceEquals(one, X))
        {
            return Y;
        }

        if (object.ReferenceEquals(one, Y))
        {
            return X;
        }

        throw new ArgumentException("one");
    }

    public void Reset()
    {
        X = default(T);
        Y = default(T);
    }
}

使用示例:

var comparer = new CachingEqualityComparer<string>();
var hs = new HashSet<string>(comparer);

string str = "Hello";

string st1 = str.Substring(2);
hs.Add(st1);

string st2 = str.Substring(2);

// st1 and st2 are distinct strings!
if (object.ReferenceEquals(st1, st2))
{
    throw new Exception();
}

comparer.Reset();

if (hs.Contains(st2))
{
    string cached = comparer.Other(st2);
    Console.WriteLine("Found!");

    // cached is st1
    if (!object.ReferenceEquals(cached, st1))
    {
        throw new Exception();
    }
}

我已经创建了一个“缓存”最后一个分析的Equal条件的相等比较器 :-)
然后,一切都可以封装在HashSet<T>的子类中。
/// <summary>
/// An HashSet&lt;T;gt; that, thorough a clever use of an internal
/// comparer, can have a AddOrGet and a TryGet
/// </summary>
/// <typeparam name="T"></typeparam>
public class HashSetEx<T> : HashSet<T> where T : class
{

    public HashSetEx()
        : base(new CachingEqualityComparer<T>())
    {
    }

    public HashSetEx(IEqualityComparer<T> comparer)
        : base(new CachingEqualityComparer<T>(comparer))
    {
    }

    public T AddOrGet(T item)
    {
        if (!Add(item))
        {
            var comparer = (CachingEqualityComparer<T>)Comparer;

            item = comparer.Other(item);
        }

        return item;
    }

    public bool TryGet(T item, out T item2)
    {
        if (Contains(item))
        {
            var comparer = (CachingEqualityComparer<T>)Comparer;

            item2 = comparer.Other(item);
            return true;
        }

        item2 = default(T);
        return false;
    }

    private class CachingEqualityComparer<T> : IEqualityComparer<T> where T : class
    {
        public WeakReference X { get; private set; }
        public WeakReference Y { get; private set; }

        private readonly IEqualityComparer<T> Comparer;

        public CachingEqualityComparer()
        {
            Comparer = EqualityComparer<T>.Default;
        }

        public CachingEqualityComparer(IEqualityComparer<T> comparer)
        {
            Comparer = comparer;
        }

        public bool Equals(T x, T y)
        {
            bool result = Comparer.Equals(x, y);

            if (result)
            {
                X = new WeakReference(x);
                Y = new WeakReference(y);
            }

            return result;
        }

        public int GetHashCode(T obj)
        {
            return Comparer.GetHashCode(obj);
        }

        public T Other(T one)
        {
            if (object.ReferenceEquals(one, null))
            {
                return null;
            }

            object x = X.Target;
            object y = Y.Target;

            if (x != null && y != null)
            {
                if (object.ReferenceEquals(one, x))
                {
                    return (T)y;
                }
                else if (object.ReferenceEquals(one, y))
                {
                    return (T)x;
                }
            }

            return one;
        }
    }
}

注意使用WeakReference,以避免对可能阻止垃圾回收的对象产生无用引用。
使用示例:
var hs = new HashSetEx<string>();

string str = "Hello";

string st1 = str.Substring(2);
hs.Add(st1);

string st2 = str.Substring(2);

// st1 and st2 are distinct strings!
if (object.ReferenceEquals(st1, st2))
{
    throw new Exception();
}

string stFinal = hs.AddOrGet(st2);

if (!object.ReferenceEquals(stFinal, st1))
{
    throw new Exception();
}

string stFinal2;
bool result = hs.TryGet(st1, out stFinal2);

if (!object.ReferenceEquals(stFinal2, st1))
{
    throw new Exception();
}

if (!result)
{
    throw new Exception();
}

那个踩贴的人至少可以留下一条评论。我认为“扩展”HashSet<>是一个相当聪明的想法。我对此感到非常高兴,我确实认为这是我本周最美妙的想法。 - xanatos
只是为了明确:我没有投反对票。然而,虽然我还没有仔细查看代码,但句子""caches" the last Equal terms it analyzed" 让我想到读取 "Amsterdam"、"New york"、"Amsterdam" 会在内存中产生两个不同的 "Amsterdam" 字符串?由于(巨大的)性能影响,我不能保证文件中字符串的顺序,并且也不想进行排序。不过,我的理解可能有误;我今天稍后会更深入地研究一下代码。 - RobIII
@RobIII 不,第一个类可以用来构建 GetOrAdd 或者 TryGet(正如短例子和更长的完整的 HashSet<> 子类所示)。 - xanatos
聪明并且具有创新思维。我很好奇它与其他选项相比表现如何。 - Brandon
我接受了这个答案,但我在那个答案和你的答案之间犹豫不决。最终,我选择了另一个答案,但我仍然要感谢你与我一起进行头脑风暴,并提出如此创意的解决方案,尽管它可能会稍微慢一些,更加复杂。请参见此评论获取更多信息。 - RobIII
显示剩余4条评论

3

我曾有过这样的需求,确实在 SO 上提问了,但是没有像你的问题一样详细的解释,也没有有用的回答。其中一个内建的选项是 (System.Xml).NameTable,它本质上是一个字符串原子化对象,正是你所需要的东西,我们使用过(实际上我们已经转向 Intern,因为我们需要将这些字符串保留到应用程序生命周期)。

if (name == null) return null;
if (name == "") return string.Empty; 
lock (m_nameTable)
{
      return m_nameTable.Add(name);
}

在一个私有的NameTable中 http://referencesource.microsoft.com/#System.Xml/System/Xml/NameTable.cs,c71b9d3a7bc2d2af 显示它实现为简单的哈希表,即每个字符串只存储一个引用。
缺点是它完全针对字符串。如果您进行内存/速度交叉测试,我很想看到结果。我们已经大量使用System.Xml,当然如果您没有使用过,可能不太自然。

太棒了!由于我还有我的测试项目,我会尝试一下并查看此选项对内存/加载时间的影响。我会将结果添加到我的问题中。我喜欢这种“创意”思维方式。我也会查看参考来源,看看能从中学到什么。(供以后参考:我快速浏览了一下你的问题)。编辑:注意到if (name == "")...不是必要的;NameTable 已经做到了 - RobIII
哇喔,它是当前的赢家! - tolanj
它(至少在速度上,内存方面则相当)。然而:我真的需要运行几个测试来平均“分数”。话虽如此:干得好!酷毙了!非常有创意(并且“出人意料”)。我已经将锁和你发布的代码中的两个“值检查”放回去了;这使得加载时间增加了一秒钟到42秒,但这些测量并不是非常精确,所以差异可能是可以忽略不计的。 - RobIII
注意:我还有其他方法可以节省大量内存,但它们更具情境性。你实际上可以将数据保存在“反向链接树”中,这有点奇怪,在读取时稍微慢一些(不是非常慢),但例如对于3个文本“字段”... - tolanj
接受此答案并非因为它表现最佳,而是因为它是最具创意、超越传统思维的解决方案(请参见原始问题,该问题已更新多次以包含其他/更好的解决方案)。 - RobIII
显示剩余2条评论

0

编辑3:

与对字符串进行索引相比,将它们放入非重复列表中将节省更多的内存。

在 MyObjectOptimized 类中,我们有 int 索引。访问是即时的。 如果列表很短(如1000个项目),设置值的速度不会被注意到。

i assumed every string will have 5 character . 

this will reduce memory usage
  percentage   : 110 byte /16byte  = 9x gain 
  total        : 5gb/9 = 0.7 gb  +  sizeof(Country_li , Province_li etc ) 

  with int16 index (will further halve ram usage )  
  *note:* int16 capacity is -32768 to +32767 ,
          make sure your  list  is not bigger than 32 767

用法相同,但将使用 MyObjectOptimized 类。

main()
{

    // you can use same code
    foreach (line in myfile) {
    fields = line.split(",");
    yield 
    return 
        new MyObjectOptimized {
            Country = fields[0],
            Province = fields[1],
            City = fields[2],
            Street = fields[3],
            //...other fields
        };
    }

} 

所需的类

// single string size :  18 bytes (empty string size) + 2 bytes per char allocated  
//1 class instance ram cost : 4 * (18 + 2* charCount ) 
// ie charcounts are at least 5
//   cost: 4*(18+2*5)  = 110 byte 
class MyObject 
{
    string Country ;
    string Province ;
    string City ;
    string Street ;
}


public static class Exts
{
    public static int AddDistinct_and_GetIndex(this List<string> list ,string value)
    {
        if( !list.Contains(value)  ) {
            list.Add(value);
        }
        return list.IndexOf(value);
    }
}

// 1 class instance ram cost : 4*4 byte = 16 byte
class MyObjectOptimized
{
    //those int's could be int16 depends on your distinct item counts
    int Country_index ;
    int Province_index ;
    int City_index ;
    int Street_index ;

    // manuallly implemented properties  will not increase memory size
    // whereas field WILL increase 
    public string Country{ 
        get {return Country_li[Country_index]; }
        set {  Country_index = Country_li.AddDistinct_and_GetIndex(value); }
    }
    public string Province{ 
        get {return Province_li[Province_index]; }
        set {  Province_index = Province_li.AddDistinct_and_GetIndex(value); }
    }
    public string City{ 
        get {return City_li[City_index]; }
        set {  City_index = City_li.AddDistinct_and_GetIndex(value); }
    }
    public string Street{ 
        get {return Street_li[Street_index]; }
        set {  Street_index = Street_li.AddDistinct_and_GetIndex(value); }
    }


    //beware they are static.   
    static List<string> Country_li ;
    static List<string> Province_li ;
    static List<string> City_li ;
    static List<string> Street_li ;
}

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接