为何Dictionary.ContainsKey()与ToString()会引发GC分配?
Hey there, let's break down how to tackle those nasty GC allocation spikes you're seeing when scaling up to 1000 instances. Both the Dictionary.ContainsKey() calls and your Location.ToString() method are generating unnecessary garbage, so we'll address each issue with practical, low-GC solutions.
1. Low-GC Alternatives to Dictionary<TKey, TValue>
First, let's make sure your Location struct is properly set up for use as a key—this is critical regardless of which storage solution you choose. Default struct implementations of Equals() and GetHashCode() can be slow or unstable, leading to extra Dictionary overhead. Add these overrides to your Location struct first:
[Serializable] public struct Location { public double X; public double Y; public double Z; public Location(double x, double y, double z) : this() { X = x; Y = y; Z = z; } // Add these methods for proper key handling public override bool Equals(object obj) { return obj is Location loc && Equals(loc); } public bool Equals(Location other) { return X.Equals(other.X) && Y.Equals(other.Y) && Z.Equals(other.Z); } public override int GetHashCode() { unchecked // Allow overflow; hash codes don't need to be unique { int hash = 17; hash = hash * 23 + X.GetHashCode(); hash = hash * 23 + Y.GetHashCode(); hash = hash * 23 + Z.GetHashCode(); return hash; } } public static bool operator ==(Location left, Location right) => left.Equals(right); public static bool operator !=(Location left, Location right) => !left.Equals(right); // Your existing ToString() will be updated below }
Now, here are your low-GC storage options:
Option A: NativeHashMap (Unity-Specific, Zero GC)
Unity's NativeHashMap<TKey, TValue> from the Unity.Collections namespace uses unmanaged memory, so it generates zero GC allocations. It's fast, but requires a bit of setup (like managing memory disposal):
// Initialize in your awake/start (remember to dispose when done!) private NativeHashMap<Location, YourClassType> _nativeMap; void Awake() { _nativeMap = new NativeHashMap<Location, YourClassType>(1000, Allocator.Persistent); } void OnDestroy() { if (_nativeMap.IsCreated) _nativeMap.Dispose(); } // Usage (no GC!) if (_nativeMap.ContainsKey(key)) { var value = _nativeMap[key]; // Do something }
Option B: List<KeyValuePair<Location, TValue>> (Simple, Low GC)
If you can tolerate slightly slower lookup times (O(n) instead of O(1)), use a List of key-value pairs. This avoids all Dictionary-related GC since there's no internal resizing or boxing:
private List<KeyValuePair<Location, YourClassType>> _locationList = new List<KeyValuePair<Location, YourClassType>>(); // Check for key (no GC!) bool ContainsKey(Location key) { foreach (var pair in _locationList) { if (pair.Key == key) return true; } return false; } // Get value (no GC!) YourClassType GetValue(Location key) { foreach (var pair in _locationList) { if (pair.Key == key) return pair.Value; } return null; // Or default value }
Option C: SortedList<Location, TValue> (Balanced Speed & Low GC)
If you can implement IComparable<Location> on your struct, SortedList offers O(log n) lookups with minimal GC. Since we're using a struct with a strong-typed comparison, there's no boxing:
// Add IComparable<Location> to your Location struct public struct Location : IComparable<Location> { // ... existing fields and methods ... public int CompareTo(Location other) { // Compare X first, then Y, then Z (adjust logic to match your needs) int xCompare = X.CompareTo(other.X); if (xCompare != 0) return xCompare; int yCompare = Y.CompareTo(other.Y); if (yCompare != 0) return yCompare; return Z.CompareTo(other.Z); } } // Usage private SortedList<Location, YourClassType> _sortedList = new SortedList<Location, YourClassType>(); // No GC allocation for ContainsKey()! if (_sortedList.ContainsKey(key)) { var value = _sortedList[key]; }
2. Optimizing Location.ToString() to Reduce GC
Your current ToString() uses String.Format(), which creates a new string and allocates memory every time it's called. Here are two better approaches:
Option A: Reusable StringBuilder (Minimal GC)
Use a static StringBuilder to avoid allocating a new one on each call. This cuts down on GC significantly, though you'll still get a small allocation when calling ToString() (unavoidable unless you can cache the result):
private static readonly StringBuilder _stringBuilder = new StringBuilder(64); // Pre-allocate enough capacity public override string ToString() { _stringBuilder.Clear(); _stringBuilder.Append(X); _stringBuilder.Append(", "); _stringBuilder.Append(Y); _stringBuilder.Append(", "); _stringBuilder.Append(Z); return _stringBuilder.ToString(); }
Option B: Cache the String (Zero GC for Repeated Calls)
If your Location values don't change often, cache the formatted string directly in the struct. Since structs are value types, this only uses memory when the location is modified:
[Serializable] public struct Location { private double _x; private double _y; private double _z; private string _cachedString; public double X { get => _x; set { _x = value; _cachedString = null; // Invalidate cache when value changes } } // Repeat Y and Z properties with the same pattern... public Location(double x, double y, double z) { _x = x; _y = y; _z = z; _cachedString = null; } public override string ToString() { if (_cachedString == null) { // Use the StringBuilder method above to create the string once _cachedString = BuildString(); } return _cachedString; } private string BuildString() { _stringBuilder.Clear(); _stringBuilder.Append(X); _stringBuilder.Append(", "); _stringBuilder.Append(Y); _stringBuilder.Append(", "); _stringBuilder.Append(Z); return _stringBuilder.ToString(); } // ... existing Equals/GetHashCode methods ... }
Bonus: Avoid Unnecessary ToString() Calls
If you're setting part.name every frame or on every update, try only updating it when the Location actually changes. This eliminates unnecessary GC entirely for cases where the location doesn't modify.
内容的提问来源于stack exchange,提问作者DDeathlonger

