🔥 Zapisy zamknięte, ale możesz pobrać Roadmapę .NET i dołączyć do listy oczekujących — Pobierz i dołącz do Listy VIP →

Array vs List w C# — pamięć i wydajność

Kąt widzenia: mechanizmy niskopoziomowe — jak T[] i List<T> działają w pamięci, kiedy rośnie GC pressure, jak to zmierzyć i jak tego uniknąć
Przykład przewodni: serwis przetwarzający telemetrię czujników w czasie zbliżonym do rzeczywistego


Skąd się bierze różnica w wydajności

Intuicja mówi: „List jest wolniejszy bo jest dynamiczny.” To prawda, ale nie wyjaśnia dlaczego. Żeby podjąć właściwą decyzję w kodzie produkcyjnym, musisz wiedzieć, co konkretnie kosztuje.

T[] — ciągły blok pamięci bez narzutu

double[] readings = new double[1024];

W pamięci: jeden nieprzerwany blok 1024 * 8 = 8192 bajtów. Dostęp do readings[i] to dosłownie jedno dodawanie adresu bazowego i przesuniętia:

address = base_address + (index * sizeof(double))

CPU cache prefetcher rozumie ten wzorzec — potrafi z wyprzedzeniem ładować kolejne elementy. Iteracja po tablicy jest cache-friendly, bo dane siedzą obok siebie.

Rozmiar jest stały — po new double[1024] nie możesz dodać 1025 elementu. Bez możliwości zmiany rozmiaru — brak realokacji, brak kopowania, brak dodatkowej pracy GC.

List<T> — tablica z metadanymi i mechanizmem wzrostu

public class List<T>
{
    private T[] _items;      // wewnętrzna tablica
    private int _size;       // Count — ile elementów faktycznie jest
    // ...
}

List<T> to opakowanie na tablicę. Gdy robisz list[i] — w środku to _items[i]. Dostęp przez indeks jest tak samo szybki jak w tablicy.

Koszt pojawia się przy Add:

public void Add(T item)
{
    if (_size == _items.Length)
        Grow(_size + 1);       // ← tu zaczyna się problem
    
    _items[_size++] = item;
}

private void Grow(int capacity)
{
    int newCapacity = _items.Length == 0 ? DefaultCapacity : 2 * _items.Length;
    // alokacja nowej tablicy + Array.Copy() wszystkich elementów
}

Strategia podwajania (2 * _items.Length) oznacza, że realokacje zdarzają się logarytmicznie: przy pojemnościach 4, 8, 16, 32, 64 … Ale każda realokacja to:

  1. Alokacja nowego bloku w heap
  2. Array.Copy wszystkich istniejących elementów
  3. Stara tablica trafia do GC

Przy małych listach to niezauważalne. Przy tysiącach Add w gorącej ścieżce — GC.Collect potrafi cię zaskoczyć.


Przykład: serwis telemetrii czujników

Wyobraź sobie serwis przyjmujący pakiety UDP od czujników temperatury w hali produkcyjnej. Każdy pakiet to tablica 256 odczytów double. Przychodzi 500 pakietów na sekundę.

Naiwna implementacja (problematyczna)

public class TelemetryProcessor
{
    public List<double> ProcessPacket(byte[] rawPacket)
    {
        var readings = new List<double>();  // ← alokacja przy każdym pakiecie
        
        for (int i = 0; i < rawPacket.Length; i += 8)
        {
            readings.Add(BitConverter.ToDouble(rawPacket, i));  // ← potencjalne resize
        }
        
        return readings;
    }
    
    public double CalculateAverage(List<double> readings)
    {
        double sum = 0;
        foreach (var r in readings) sum += r;
        return sum / readings.Count;
    }
}

Przy 500 pakietach/s tworzymy 500 obiektów List<double> na sekundę. Każdy z nich zawiera wewnętrzną tablicę. GC musi je wszystkie zbierać. Przy dłuższej pracy — GC Gen2, pauzy, problemy z latencją.

Poprawna implementacja z T[] i Capacity

Jeśli wiesz z góry rozmiar — użyj tablicy:

public class TelemetryProcessor
{
    private const int ReadingsPerPacket = 256;

    public double[] ProcessPacket(byte[] rawPacket)
    {
        // Rozmiar znany z góry — jedna alokacja, żadnych resize
        var readings = new double[ReadingsPerPacket];

        for (int i = 0; i < ReadingsPerPacket; i++)
        {
            readings[i] = BitConverter.ToDouble(rawPacket, i * 8);
        }

        return readings;
    }

    public double CalculateAverage(double[] readings)
    {
        double sum = 0;
        // Iteracja po T[] — cache-friendly, CLR optymalizuje bounds checking
        for (int i = 0; i < readings.Length; i++)
            sum += readings[i];
        return sum / readings.Length;
    }
}

ArrayPool<T> — gdy T[] też alokuje za dużo

Tablica rozwiązuje problem resize, ale przy 500 pakietach/s nadal tworzysz 500 double[256] na sekundę. Każda z nich trafia do GC.

Rozwiązanie: wypożycz tablicę, zamiast alokować nową.

public class TelemetryProcessor
{
    private const int ReadingsPerPacket = 256;
    
    // Singleton współdzielony w całej aplikacji
    private static readonly ArrayPool<double> _pool = ArrayPool<double>.Shared;

    public void ProcessPacket(byte[] rawPacket, out double average)
    {
        // Wypożyczamy tablicę z puli — może być większa niż żądana
        double[] readings = _pool.Rent(ReadingsPerPacket);
        
        try
        {
            for (int i = 0; i < ReadingsPerPacket; i++)
                readings[i] = BitConverter.ToDouble(rawPacket, i * 8);
            
            average = CalculateAverage(readings, ReadingsPerPacket);
        }
        finally
        {
            // Oddajemy tablicę do puli — GC nie musi jej zbierać
            _pool.Return(readings, clearArray: false);
        }
    }

    private static double CalculateAverage(double[] readings, int count)
    {
        double sum = 0;
        for (int i = 0; i < count; i++)
            sum += readings[i];
        return sum / count;
    }
}

Uwaga krytyczna: _pool.Rent(256) może zwrócić tablicę o rozmiarze 512 lub 1024 — pula trzyma tablice w „wiaderkach” potęg dwójki. Zawsze używaj count, nie readings.Length.

Uwaga bezpieczeństwa: zwróconej tablicy nie wolno zatrzymywać — następny Rent może zwrócić tę samą tablicę komuś innemu. Wzorzec try/finally jest tu obowiązkowy.


Span<T> — przetwarzanie bez alokacji

Gdy chcesz przetworzyć fragment tablicy bez kopiowania:

public void ProcessBatch(byte[] rawPackets, int packetCount)
{
    // Span to widok na istniejącą pamięć — zero alokacji
    ReadOnlySpan<byte> data = rawPackets;
    
    for (int p = 0; p < packetCount; p++)
    {
        // Wycinek bez kopiowania danych
        ReadOnlySpan<byte> packetSlice = data.Slice(p * 2048, 2048);
        ProcessSinglePacket(packetSlice);
    }
}

private void ProcessSinglePacket(ReadOnlySpan<byte> packet)
{
    // Span<double> z reinterpretacją pamięci — nadal zero alokacji
    ReadOnlySpan<double> readings = MemoryMarshal.Cast<byte, double>(packet);
    
    double sum = 0;
    foreach (double r in readings)
        sum += r;
    
    // ... przetwarzanie
}

MemoryMarshal.Cast reinterpretuje bajty jako doubles bez żadnego kopiowania. Całe przetwarzanie jednego pakietu — zero alokacji na stercie.

Ograniczenie Span<T>: nie możesz go przechowywać w polach klasy (tylko Memory<T>) i nie możesz użyć go po operacjach async.


Benchmark — żeby nie zgadywać

Zamiast intuicji — dane. BenchmarkDotNet:

[MemoryDiagnoser]
[SimpleJob(RuntimeMoniker.Net90)]
public class CollectionBenchmarks
{
    private const int N = 1024;
    private static readonly ArrayPool<double> Pool = ArrayPool<double>.Shared;

    [Benchmark(Baseline = true)]
    public double ListNoCapacity()
    {
        var list = new List<double>();
        for (int i = 0; i < N; i++) list.Add(i * 1.5);
        return list[N - 1];
    }

    [Benchmark]
    public double ListWithCapacity()
    {
        var list = new List<double>(N);
        for (int i = 0; i < N; i++) list.Add(i * 1.5);
        return list[N - 1];
    }

    [Benchmark]
    public double Array()
    {
        var arr = new double[N];
        for (int i = 0; i < N; i++) arr[i] = i * 1.5;
        return arr[N - 1];
    }

    [Benchmark]
    public double ArrayPooled()
    {
        var arr = Pool.Rent(N);
        try
        {
            for (int i = 0; i < N; i++) arr[i] = i * 1.5;
            return arr[N - 1];
        }
        finally
        {
            Pool.Return(arr);
        }
    }
}

Typowe wyniki (N=1024, .NET 9):

MethodMeanAllocated
ListNoCapacity3.21 µs17.1 KB
ListWithCapacity1.18 µs8.1 KB
Array0.89 µs8.0 KB
ArrayPooled0.31 µs0 B

ArrayPooled nie alokuje nic — bo tablica pochodzi z puli. To właśnie oznacza 0 B w kolumnie Allocated. W gorącej ścieżce przekłada się to bezpośrednio na mniejszą częstotliwość GC.


Count vs Capacity — co naprawdę zużywa pamięć

var list = new List<int>();
for (int i = 0; i < 5; i++) list.Add(i);

Console.WriteLine(list.Count);    // 5  — ile elementów masz
Console.WriteLine(list.Capacity); // 8  — ile miejsca zarezerwowano

Pojemność po 5 Add: 4 → 8 (podwojenie przy czwartym Add). Pięć elementów zajmuje miejsce dla ośmiu. Strata 37.5% zarezerwowanej pamięci.

Jeśli tworzysz listę, wypełniasz ją i trzymasz długo (cache, singleton):

var list = BuildLargeList(); // powiedzmy 10 000 elementów, Capacity = 16384
list.TrimExcess();           // zwalnia nadmiar — Capacity == Count

TrimExcess() alokuje nową tablicę o rozmiarze Count i kopiuje dane. Sens tylko gdy lista żyje długo i nie będzie już modyfikowana.


Kiedy co

ScenariuszWybórDlaczego
Znany rozmiar, dane statyczneT[]Jedna alokacja, brak narzutu
Nieznany rozmiar, budowanie stopniowoList<T>(capacity)Capacity eliminuje resize
Gorąca ścieżka, 1000+/sArrayPool<T>Zero alokacji na GC heap
Przetwarzanie fragmentu bez kopiiSpan<T> / Memory<T>Zero alokacji
API publiczne (metoda zwraca kolekcję)IReadOnlyList<T>Kontrakt bez exposowania Add/Remove

Uwaga: IReadOnlyList<T> i ImmutableArray<T> to temat na osobny post — dotyczą kontraktu API, nie wydajności alokacji.


Interesuje Cię ta sama decyzja z perspektywy projektowania API, nie tylko wydajności? Zobacz Array vs List w C# — API design i decyzje. Podstawy samej listy znajdziesz w List<T> w C#.

Podsumowanie

T[] i List<T> różnią się głównie w jednym miejscu: co dzieje się, gdy kolekcja musi rosnąć. Tablica nie rośnie — jedna alokacja, koniec. Lista rośnie przez podwajanie — koszt kopiowania + GC pressure przy każdej realokacji.

W praktyce produkcyjnej trzy zasady:

  1. Znasz rozmiar z góry → T[]
  2. Nie znasz rozmiaru, ale masz przybliżenie → new List<T>(capacity)
  3. Gorąca ścieżka, alokacje się liczą → ArrayPool<T> + Span<T>

Stopwatch i intuicja kłamią — BenchmarkDotNet z [MemoryDiagnoser] mówi prawdę.


Następny post: Array vs List w C# — API Design i Decyhttps://dev-hobby.pl/csharp/array-vs-list/zje Projektowe — nie wydajność, ale kontrakt: co zwracać z metod publicznych, kiedy IReadOnlyList<T>, a kiedy ImmutableArray<T>, i jak uniknąć wycieków List<T> przez interfejsy.

👨‍💻
Mariusz Jurczenko
Senior .NET Developer · 10+ lat doświadczenia komercyjnego

Programista .NET z doświadczeniem komercyjnym w firmach takich jak NFZ, Kamsoft, Diagnostyka, Hermes Reply Polska czy Etisoft Smart Solutions. Twórca kursów, z których skorzystało już ponad 11 000 osób w Strefie Kursów i ponad 1 000 kursantów na dev-hobby.pl.

Specjalizacja: Clean Code, Clean Architecture i uczenie programowania tak, żeby dało się je naprawdę zrozumieć — nie wykuć.

🚀 Co dalej?

Zobacz to w praktyce na wideo i pobierz darmową roadmapę, żeby ułożyć naukę w spójną ścieżkę do pierwszej pracy.

114 comments

  1. Thanks for your whole work on this website. My niece takes pleasure in working on research and it is simple to grasp why. We all notice all relating to the powerful ways you render vital tricks via your website and therefore welcome response from website visitors on this topic while our own simple princess is without question understanding so much. Take pleasure in the rest of the year. Your performing a very good job. Robina Peadar Darrin

  2. thanks Jill. I always wonder how insights into the making of writing are viewed, especially ones that draw on personal circumstances. Are they too immature? Should they be hidden from sight? But they help me understand not only the process of writing but the process of living, and how the two are connected. Pepita Travus Aholla

  3. Czy nie masz nic przeciwko, jeśli zacytuję kilka Twoich postów, o ile podam informacje o autorach i źródłach z powrotem do Twojej witryny? Mój blog znajduje się dokładnie w tej samej niszy, co Twoja, a moi goście z pewnością skorzystaliby z wielu informacji, które tu podajesz. Daj mi znać, jeśli ci to odpowiada. Pozdrowienia!

  4. Dziś surfuję po sieci ponad 4 godziny, ale nigdy nie znalazłem interesującego artykułu takiego jak Twój. To jest dla mnie wystarczająco warte. Osobiście, jeśli wszyscy właściciele witryn i blogerzy stworzyli dobre treści, tak jak Ty, Internet będzie o wiele bardziej przydatny niż kiedykolwiek wcześniej.

  5. Cześć. Odkryłem Twoją witrynę za pośrednictwem Google, gdy szukałem podobnej sprawy, Twoja witryna pojawiła się. Wygląda na to, że jest dobra. Dodałem go do zakładek w moich zakładkach Google, aby ją odwiedzić.

Dodaj komentarz

czytanie to początek

Zamień wiedzę w umiejętności

Pobierz darmową Roadmapę .NET i ułóż takie tematy jak ten w spójną ścieżkę do pierwszej pracy.

Pobieram roadmapę →