PHP: Was tun bei "Allowed memory size exhausted"?

Fatal error: Allowed memory size of 8388608 bytes exhausted
(tried to allocate 12288 bytes)

Die erste Antwort, die man dazu findet, lautet: Limit hochsetzen. Das ist auch manchmal richtig. Meistens ist es aber die Reaktion darauf, dass eine Datei komplett in den Speicher geladen wird, die man auch zeilenweise lesen könnte — und der Unterschied ist Faktor tausend.

Erst einmal: Wo stehe ich?

Bevor man irgendetwas ändert, lohnt sich ein Blick auf die Zahlen:

<?php
  echo ini_get('memory_limit'), "\n";
  echo memory_get_usage(), "\n";
  echo memory_get_usage(true), "\n";
  echo memory_get_peak_usage(true), "\n";
?>
memory_limit           : 512M
memory_get_usage()     : 0.46 MiB
memory_get_usage(true) : 2.00 MiB

Der Unterschied zwischen den beiden ist erklärungsbedürftig. Ohne Parameter bekommst du, was PHP für deine Variablen verwendet. Mit true bekommst du, was PHP beim Betriebssystem angefordert hat — und das ist der Wert, an dem das Limit gemessen wird. Für die Frage “wie nahe bin ich dran” ist also true der ehrlichere.

memory_get_peak_usage() bleibt auf dem Höchststand stehen, auch wenn der Speicher längst wieder frei ist:

Spitze bisher : 6.28 MiB
aktuell       : 0.46 MiB

Das ist der Wert, den du am Ende eines Skripts ins Log schreiben willst. Denn wenn dein Skript abstürzt, ist es die Spitze gewesen, die das Limit gerissen hat — nicht der Verbrauch am Ende.

Der eine Punkt, an dem es sich meistens entscheidet

Dieselbe Datei, 11,63 MiB groß, 200.000 Zeilen. Drei Arten, sie zu lesen:

file_get_contents        11.64 MiB
file() als Array         22.31 MiB
fgets() Zeile fuer Zeile  0.01 MiB

Die dritte Zeile ist kein Tippfehler. Zehn Kilobyte.

file_get_contents() legt die ganze Datei als einen String ab, also erwartungsgemäß etwa die Dateigröße. file() braucht fast das Doppelte, weil jede der 200.000 Zeilen ein eigener String mit eigenem Verwaltungsaufwand wird. Und der Streaming-Weg braucht praktisch nichts, weil zu jedem Zeitpunkt nur eine einzige Zeile im Speicher liegt.

<?php
  $fh = fopen($datei, 'r');
  while (($zeile = fgets($fh)) !== false) {
    // eine Zeile verarbeiten
  }
  fclose($fh);
?>

Bei einer 12-MiB-Datei ist das noch egal. Bei einer 2-GiB-Logdatei entscheidet es darüber, ob dein Skript läuft oder nicht — und da hilft auch kein memory_limit = -1, weil der Server den Speicher schlicht nicht hat.

Dasselbe Prinzip gilt an anderen Stellen:

  • Datenbankabfragen: nicht fetchAll(), sondern in einer Schleife fetch().
  • CSV: fgetcsv() statt die Datei einlesen und selbst zerlegen.
  • JSON: bei großen Dokumenten einen Streaming-Parser, denn json_decode() baut das komplette Dokument im Speicher auf.
  • Bilder: GD hält ein Bild unkomprimiert im Speicher, vier Byte je Bildpunkt. Ein 4000×3000-Foto ist als JPEG vielleicht 2 MiB groß — gemessen belegt es in GD 46,91 MiB, was der Rechnung 4000 × 3000 × 4 Byte entspricht. Bei einem Standardlimit von 128 MiB sind drei solche Bilder gleichzeitig schon zu viel.

Was ein Array wirklich kostet

Damit die Zahlen nicht so überraschend sind:

range(1, 100000)      : 2.00 MiB   -> 21.0 Byte pro Integer
100.000 kurze Strings : 5.82 MiB

Ein Integer belegt für sich 8 Byte. Im Array kostet er 21, weil PHP zu jedem Element noch Hash-Tabelleneinträge und Typinformationen verwaltet.

Das erklärt, warum 50.000 Datenbankzeilen mit je zehn Spalten so viel mehr Speicher brauchen, als die Summe der Feldinhalte vermuten lässt.

Copy on write

Ein Effekt, der die Suche nach der Ursache erschwert:

$kopie = $gross          : 0.00 MiB   <- noch keine Kopie
nach einer Aenderung     : 4.00 MiB   <- jetzt wurde kopiert

Die Zuweisung kostet nichts. PHP merkt sich nur, dass zwei Variablen auf dieselben Daten zeigen. Erst wenn du eine der beiden änderst, wird tatsächlich kopiert.

Für die Fehlersuche heißt das: Der Speicher explodiert nicht an der Zeile mit der Zuweisung, sondern irgendwo weiter unten, an einer harmlos aussehenden Änderung. Wer das nicht weiß, sucht an der falschen Stelle.

Den Fehler abfangen — geht nicht, aber auslesen geht

Der naheliegende Versuch:

<?php
  try {
    // viel Speicher verbrauchen
  } catch (Throwable $e) {
    echo 'gefangen';   // wird nie erreicht
  }
?>

Funktioniert nicht. Das Überschreiten des Limits ist ein Fatal Error und kein Error-Objekt, also gibt es nichts zu fangen. Der catch-Zweig wird nicht betreten, das Skript ist an dieser Stelle einfach zu Ende.

Was aber geht:

<?php
  register_shutdown_function(function (): void {
    $f = error_get_last();
    if ($f !== null && $f['type'] === E_ERROR) {
      // hier protokollieren, aufraeumen, Fehlerseite ausliefern
      error_log($f['message'] . ' in ' . $f['file'] . ':' . $f['line']);
    }
  });
?>
[shutdown] Allowed memory size of 8388608 bytes exhausted (tried to allocate 12288 bytes)
[shutdown] in sprengen.php Zeile 16

Die Funktion läuft auch nach einem Fatal Error noch. Datei und Zeile bekommst du also — und das ist genau die Information, die du brauchst. Nur weiterlaufen lassen kannst du das Skript nicht.

Der Exit-Code des Prozesses ist übrigens 255, derselbe wie bei jedem anderen Fatal Error.

Und wann setzt man das Limit doch hoch?

Es gibt legitime Fälle. Ein Import-Skript, das einmal im Monat läuft und tatsächlich einen großen Datenbestand im Speicher halten muss, darf mehr bekommen als eine Webseite:

<?php
  ini_set('memory_limit', '512M');
?>
vorher : 512M -> nachher: 256M -> wieder : 64M

Das funktioniert zur Laufzeit, in beide Richtungen. Zwei Einschränkungen: Bei einem Parse Error hilft es nicht, weil die Zeile nie ausgeführt wird (dazu mehr im Artikel über Fehlermeldungen), und manche Hoster sperren die Direktive.

Was ich nicht empfehle, ist memory_limit = -1 auf einem Webserver. Das Limit ist ja nicht dazu da, dich zu ärgern, sondern damit ein einzelner Fehler — eine Endlosschleife, eine Abfrage ohne LIMIT — nicht den ganzen Server mitnimmt. Ohne Limit hast du statt eines Fatal Errors in einem Prozess einen Server, der zu swappen beginnt oder vom OOM-Killer beendet wird.

Für ein Kommandozeilenskript, das du selbst startest, ist -1 dagegen völlig in Ordnung.

Zusammenfassung

  • Zuerst messen: memory_get_peak_usage(true) am Ende des Skripts loggen.
  • Dateien zeilenweise lesen statt am Stück — im Test 0,01 MiB statt 11,64 MiB.
  • file() braucht das Doppelte der Dateigröße.
  • Ein Integer im Array kostet 21 Byte, nicht 8.
  • Copy on write: Der Speicher wird bei der Änderung belegt, nicht bei der Zuweisung.
  • Der Fehler ist nicht fangbar, aber über register_shutdown_function() und error_get_last() auslesbar.
  • memory_limit = -1 gehört auf die Kommandozeile, nicht auf einen Webserver.

Hinweis zu Netcup (Werbung)

Der deutsche Hoster Netcup bietet unter anderem günstige und zugleich leistungsstarke Webhosting Pakete, KVM-basierte Root Server und dezidierte Server an. Mit unseren Gutscheincodes kannst du noch mehr Geld sparen (6€ bei deiner ersten Bestellung, 30% Rabatt auf alle KVM-basierten Root Server, ...).