Unity-Entwickler erreichen 2026 immer noch eine beunruhigende Anzahl an Frame-Einbrüchen in Spielen, obwohl die Technologie längst Fortschritte gemacht hat. Die Ursache liegt oft in vermeidbaren Speicherproblemen: Jede unnötige Speicherallokation im C#-Code summiert sich zu spürbaren Geschwindigkeitsverlusten. Doch es gibt Abhilfe.
Seit Jahren stehen Entwicklern moderne Tools wie Burst-Compiler, DOTS und asynchrone Muster zur Verfügung – trotzdem wird die fundamentale Bedeutung von Speicherdisziplin unterschätzt. Jede string-Konkatenation, jedes dynamische List-Vergrößern oder sogar harmlos wirkende LINQ-Abfragen führen zu Garbage Collection (GC)-Spitzen, die die Framerate abrupt bremsen. Die Lösung liegt in einem bewussten Umgang mit Speicher und der Nutzung von C#-Features wie Span, Memory und ArrayPool.
Warum Garbage Collection in Spielen zum Problem wird
Die verwaltete Speicherumgebung in C# erleichtert die Entwicklung, doch sie hat einen entscheidenden Nachteil: Jede Allokation auf dem Managed Heap muss später vom Garbage Collector (GC) aufgeräumt werden. Dieser Prozess verbraucht Rechenzeit und kann sogar den Haupt-Thread des Spiels kurzzeitig blockieren. Besonders in Echtzeitanwendungen wie Spielen führt das zu spürbaren Rucklern.
- Temporäre Objekte – etwa durch
string-Konkatenation oderList-Vergrößerung – erhöhen die GC-Last. - Häufige Allokationen in Hot-Code-Pfaden summieren sich zu spürbaren Performance-Einbußen.
- Der GC ist keine kostenlose Operation – selbst wenn er „unsichtbar“ läuft, kostet er CPU-Zyklen, die dem Spiel fehlen.
Ziel sollte es sein, die Anzahl der Allokationen auf ein Minimum zu reduzieren, insbesondere in Code-Abschnitten, die pro Frame oder häufig ausgeführt werden.
String-Operationen optimieren: Keine unnötigen Kopien
Strings in C# sind unveränderlich. Jede Veränderung oder Konkatenation mit dem + Operator erzeugt eine neue Zeichenkette im Speicher. Besonders in Logik-Funktionen wie Spielstatusmeldungen summieren sich diese Operationen schnell zu einem Problem.
Das klassische, aber ineffiziente Beispiel:
void LogPlayerScore(int playerId, int score) {
string log = "Spieler " + playerId + " hat " + score + " Punkte erzielt!";
Debug.Log(log);
}Jedes + erzeugt eine temporäre Zeichenkette. Besser eignet sich StringBuilder, der die Zeichenkette Stück für Stück zusammenbaut, ohne Zwischenkopien zu erzeugen.
Die optimierte Variante mit `StringBuilder`:
using System.Text;
private static readonly StringBuilder s_stringBuilder = new StringBuilder();
void LogPlayerScoreOptimized(int playerId, int score) {
s_stringBuilder.Clear();
s_stringBuilder
.Append("Spieler ")
.Append(playerId)
.Append(" hat ")
.Append(score)
.Append(" Punkte erzielt!");
Debug.Log(s_stringBuilder.ToString());
}StringBuildervermeidet temporäre String-Objekte.- Der statische
StringBuilderwird wiederverwendet und reduziert die Allokationen weiter.
Für Szenarien mit festen Puffergrößen oder direkter Speicherzugriff kann zusätzlich Span<char> eingesetzt werden, um komplett allokationsfrei zu arbeiten.
Dynamische Collections und Array-Pooling: Effizienter Umgang mit Listen
Ein häufiger Performance-Fallenfall ist die unkontrollierte Nutzung von List<T>. Jedes new List<T>() oder eine implizite Vergrößerung der Kapazität führt zu Speicherallokationen auf dem Heap. Besonders problematisch wird es, wenn solche Listen in Methoden erzeugt und zurückgegeben werden – die GC muss sie später aufräumen.
Das problematische Muster:
List<Vector3> GetNearbyPositions(Vector3 center, float radius) {
List<Vector3> positions = new List<Vector3>();
// ... Logik, um Positionen zu sammeln ...
return positions;
}- Jeder Aufruf dieser Methode erzeugt eine neue
List<T>. - Während der Füllung der Liste können zusätzliche Allokationen durch automatische Vergrößerung entstehen.
Die optimierte Lösung mit `ArrayPool<T>` und `Span<T>`:
using System.Buffers;
using UnityEngine;
void ProcessTemporaryVectorData(int count) {
// Puffer aus dem gemeinsamen Pool mieten – reduziert Allokationen
Vector3[] buffer = ArrayPool<Vector3>.Shared.Rent(count);
// Span<T> als Sicht auf den Puffer erstellen
Span<Vector3> data = buffer.AsSpan(0, count);
// Direkter Zugriff auf die Elemente ohne Zwischenkopien
for (int i = 0; i < data.Length; i++) {
data[i] = new Vector3(Mathf.Sin(i), Mathf.Cos(i), 0);
}
Debug.Log($"Verarbeitet {data.Length} Vektoren. Erster: {data[0]}");
// Puffer zurückgeben, um ihn wiederzuverwenden
ArrayPool<Vector3>.Shared.Return(buffer);
}ArrayPool<T>stellt wiederverwendbare Arrays bereit und reduziert Allokationen.Span<T>ermöglicht direkten, allokationsfreien Zugriff auf die Daten.- Der Puffer wird nach Gebrauch zurückgegeben, um Ressourcen freizugeben.
LINQ und seine versteckten Kosten: Wenn Abfragen zur Last werden
LINQ ist bequem und elegant, doch viele Standardmethoden wie Where, Select oder OrderBy erzeugen implizit neue Objekte. Dazu zählen Enumeratoren, temporäre Collections oder sogar Zwischenpuffer. In Performance-kritischen Code-Abschnitten summieren sich diese Overheads zu spürbaren Framerate-Einbrüchen.
Das klassische LINQ-Beispiel mit versteckten Allokationen:
using System.Linq;
IEnumerable<GameObject> GetActiveEnemies(List<GameObject> allObjects) {
return allObjects
.Where(obj => obj != null && obj.activeSelf && obj.CompareTag("Enemy"))
.ToList();
}Whereerzeugt einen Enumerator.ToList()erstellt eine neueList<T>.- Beide Schritte führen zu Allokationen, die der GC später bereinigen muss.
Die effizientere Alternative mit manueller Iteration:
void GetActiveEnemiesNoAlloc(List<GameObject> allObjects, List<GameObject> results) {
results.Clear();
foreach (var obj in allObjects) {
if (obj != null && obj.activeSelf && obj.CompareTag("Enemy")) {
results.Add(obj);
}
}
}- Die
results-Liste wird außerhalb der Methode verwaltet und wiederverwendet. - Keine temporären Collections, keine LINQ-Overheads.
- Klare Kontrolle über die verwendeten Ressourcen.
Fazit: Speicherdisziplin als Schlüssel zu flüssigen Spielen
Garbage Collection ist kein unvermeidliches Übel, sondern oft das Ergebnis von mangelnder Speicherdisziplin. Moderne C#-Features wie StringBuilder, ArrayPool und Span bieten Entwicklern die Werkzeuge, um Allokationen gezielt zu reduzieren – ohne auf Ausdrucksstärke oder Komfort verzichten zu müssen.
Der Wechsel zu einem datenorientierten Mindset, selbst in MonoBehaviour-Skripten, lohnt sich: Weniger Allokationen bedeuten weniger GC-Last und damit stabilere Frameraten. Spiele profitieren von kürzeren Ladezeiten, reibungsloserem Gameplay und einer besseren Spielerfahrung. Es ist an der Zeit, die Speichernutzung in Unity-Projekten ernst zu nehmen – für Performance, die mit der Hardware Schritt hält.
KI-Zusammenfassung
Learn how to stop Unity stutters with proven C# memory discipline. Use Span, ArrayPool, and StringBuilder to cut GC spikes and keep frame rates smooth in 2026.