Java Heap Allocation Schneller als C ++


13

Ich habe diese Frage bereits auf SO gepostet und sie ist in Ordnung. Es wurde leider geschlossen (es braucht nur eine Stimme, um es wieder zu öffnen), aber jemand schlug vor, dass ich es hier posten sollte, da es besser passt. Das Folgende ist also buchstäblich eine Kopie der Frage


Ich habe die Kommentare zu dieser Antwort gelesen und dieses Zitat gesehen.

Objektinstanziierung und objektorientierte Funktionen sind blitzschnell zu verwenden (in vielen Fällen schneller als C ++), da sie von Anfang an entwickelt wurden. und Sammlungen sind schnell. Standard-Java schlägt Standard-C / C ++ in diesem Bereich, auch für die meisten optimierten C-Code.

Ein Benutzer (mit wirklich hohen Wiederholungszahlen, möchte ich hinzufügen) hat diese Behauptung kühn verteidigt und dies behauptet

  1. Die Heap-Zuweisung in Java ist besser als in C ++

  2. und fügte diese Aussage hinzu, die die Sammlungen in Java verteidigt

    Und Java-Sammlungen sind im Vergleich zu C ++ - Sammlungen aufgrund der unterschiedlichen Speichersubsysteme schnell.

Meine Frage ist also, ob irgendetwas davon wirklich wahr ist, und wenn ja, warum ist Javas Heap-Allokation so viel schneller.


Sie können meine Antwort auf eine ähnliche Frage über SO nützlich / relevant finden.
Daniel Pryden

1
Es ist trivial: Mit Java (oder einer anderen verwalteten, eingeschränkten Umgebung) können Sie Objekte verschieben und Zeiger auf diese aktualisieren - dh für eine bessere Cache-Lokalität dynamisch optimieren. Mit C ++ und seiner Zeigerarithmetik mit unkontrollierten Bitcasts werden alle Objekte für immer an ihren Speicherort gebunden.
SK-logic

3
Ich hätte nie gedacht, dass jemand sagt, die Java-Speicherverwaltung sei schneller, weil sie den Speicher ständig kopiert. Seufzer.
gbjbaanb

1
@gbjbaanb, haben Sie jemals von Speicherhierarchie gehört? Cache Miss Strafe? Ist Ihnen klar, dass ein Allzweck-Allokator teuer ist, während eine Allokation der ersten Generation nur eine einzige Additionsoperation ist?
SK-logic

1
Dies mag in einigen Fällen etwas zutreffen, es geht jedoch an dem Punkt vorbei, dass Sie in Java alles auf dem Heap zuweisen und in c ++ viel Objekt auf dem Stack zuweisen, was noch sehr viel schneller sein kann.
JohnB

Antworten:


23

Dies ist eine interessante Frage, und die Antwort ist komplex.

Insgesamt kann ich mit Recht sagen, dass der JVM-Garbage Collector sehr gut konzipiert und äußerst effizient ist. Es ist wahrscheinlich das beste Allzweck- Speicherverwaltungssystem.

C ++ kann den JVM-GC mit speziellen Speicherzuordnern schlagen , die für bestimmte Zwecke entwickelt wurden. Beispiele könnten sein:

  • Speicherzuordnungen pro Frame, die in regelmäßigen Abständen den gesamten Speicherbereich löschen. Diese werden häufig in C ++ - Spielen verwendet, in denen ein temporärer Speicherbereich einmal pro Frame verwendet und sofort verworfen wird.
  • Benutzerdefinierte Zuweiser, die einen Pool von Objekten mit fester Größe verwalten
  • Stapelbasierte Zuordnung (obwohl zu beachten ist, dass die JVM dies auch unter verschiedenen Umständen durchführt, z. B. über eine Escape-Analyse )

Spezialisierte Speicherzuordnungen sind natürlich per Definition begrenzt. In der Regel gelten Einschränkungen hinsichtlich des Objektlebenszyklus und / oder des Objekttyps, der verwaltet werden kann. Die Speicherbereinigung ist wesentlich flexibler.

Die Garbage Collection bietet Ihnen auch einige signifikante Vorteile aus Sicht der Leistung:

  • Objekt Instanziierung ist in der Tat extrem schnell. Aufgrund der Art und Weise, wie neue Objekte sequentiell im Speicher zugewiesen werden, ist häufig nur eine Zeigeraddition erforderlich, was mit Sicherheit schneller ist als typische C ++ - Heap-Zuweisungsalgorithmen.
  • Sie vermeiden die Notwendigkeit von Lifecycle-Management-Kosten - z. B. ist die Referenzzählung (manchmal als Alternative zur GC verwendet) aus Performance-Sicht äußerst schlecht, da das häufige Inkrementieren und Dekrementieren von Referenzzählungen einen hohen Performance-Overhead mit sich bringt (in der Regel viel mehr als GC). .
  • Wenn Sie unveränderliche Objekte verwenden, können Sie die strukturelle Freigabe nutzen , um Speicherplatz zu sparen und die Cache-Effizienz zu verbessern. Dies wird häufig von funktionalen Sprachen in der JVM wie Scala und Clojure verwendet. Ohne GC ist dies sehr schwierig, da es äußerst schwierig ist, die Lebensdauer von gemeinsam genutzten Objekten zu verwalten. Wenn Sie (wie ich) der Meinung sind, dass Unveränderlichkeit und strukturelles Teilen der Schlüssel zum Erstellen großer gleichzeitiger Anwendungen sind, ist dies wahrscheinlich der größte Leistungsvorteil von GC.
  • Sie können das Kopieren vermeiden, wenn alle Objekttypen und ihre jeweiligen Lebenszyklen von demselben Garbage Collection-System verwaltet werden. Im Gegensatz zu C ++, wo Sie häufig vollständige Kopien von Daten erstellen müssen, weil das Ziel einen anderen Speicherverwaltungsansatz erfordert oder einen anderen Objektlebenszyklus hat.

Java GC hat einen großen Nachteil: Da das Sammeln von Datenmüll in regelmäßigen Abständen verschoben und in Stücken erledigt wird, werden bei gelegentlichen GC-Pausen Datenmüll gesammelt, was die Latenz beeinträchtigen kann. Dies ist normalerweise kein Problem für typische Anwendungen, kann jedoch Java in Situationen ausschließen, in denen harte Echtzeit erforderlich ist (z. B. Robotersteuerung). Weiche Echtzeit (z. B. Spiele, Multimedia) ist normalerweise in Ordnung.


Es gibt spezielle Bibliotheken im C ++ - Bereich, die sich mit diesem Problem befassen. Das wohl bekannteste Beispiel dafür ist SmartHeap.
Tobias Langner

5
Soft-Realtime bedeutet nicht, dass Sie normalerweise aufhören können . Es bedeutet nur, dass Sie in einer wirklich schlechten Situation - normalerweise unerwartet - pausieren / erneut versuchen können, anstatt anzuhalten / abstürzen / auszufallen. Niemand möchte einen normalerweise pausierenden Musik-Player verwenden. Das Problem der GC-Pause ist, dass sie normalerweise und unvorhersehbar auftritt . Auf diese Weise ist die GC-Pause auch für die Echtzeitanwendung nicht akzeptabel. Eine GC-Pause ist nur zulässig, wenn sich die Benutzer nicht um die Anwendungsqualität kümmern. Und heutzutage sind die Leute nicht mehr so ​​naiv.
Eonil

1
Bitte posten Sie einige Leistungsmessungen, um Ihre Behauptungen zu untermauern. Andernfalls vergleichen wir Äpfel und Orangen.
JBRWilkinson

1
@Demetri Aber in Wirklichkeit passiert das nur, wenn der Fall zu viel ist (und zwar sogar unvorhersehbar!), Es sei denn, Sie können einige unpraktische Einschränkungen erfüllen. Mit anderen Worten, C ++ ist für jede Echtzeitsituation viel einfacher.
Eonil

1
Der Vollständigkeit halber: Was die Leistung des GC betrifft, gibt es einen weiteren Nachteil: Wie bei den meisten vorhandenen GCs tritt das Freigeben von Speicher in einem anderen Thread auf, der wahrscheinlich auf einem anderen Kern ausgeführt wird. Dies bedeutet, dass für die Synchronisierung von GCs erhebliche Kosten für die Ungültigmachung des Cache anfallen L1 / L2-Caches zwischen verschiedenen Kernen; Außerdem müssen auf Servern, bei denen es sich überwiegend um NUMA handelt, auch L3-Caches synchronisiert werden (und über Hypertransport / QPI, autsch (!)).
No-Bugs Hare

3

Dies ist keine wissenschaftliche Behauptung. Ich gebe nur ein paar Denkanstöße zu diesem Thema.

Eine visuelle Analogie ist folgende: Sie erhalten eine Wohnung (eine Wohneinheit), die mit Teppichboden ausgelegt ist. Der Teppich ist schmutzig. Was ist der schnellste Weg (in Stunden), um den Boden der Wohnung blitzsauber zu machen?

Antwort: Rollen Sie einfach den alten Teppich auf. wegschmeißen; und einen neuen Teppich ausrollen.

Was vernachlässigen wir hier?

  • Die Kosten für den Umzug von vorhandenen persönlichen Gegenständen und den anschließenden Einzug.
    • Dies wird als "Stop-the-World" -Kosten der Müllabfuhr bezeichnet.
  • Die Kosten für den neuen Teppich.
    • Was für RAM zufällig kostenlos ist.

Die Speicherbereinigung ist ein großes Thema, und sowohl in Programmers.SE als auch in StackOverflow gibt es viele Fragen.

Ein C / C ++ - Zuordnungsmanager namens TCMalloc zusammen mit der Objektreferenzzählung kann theoretisch die besten Leistungsansprüche aller GC-Systeme erfüllen.


Eigentlich hat C ++ 11 sogar eine Garbage Collection ABI , dies ist ziemlich ähnlich zu einigen der Antworten, die ich auf SO
Aaronman 18.08.13

Es ist die Angst, bestehende C / C ++ - Programme (Code-Basen wie Linux-Kernel und archaisch_noch_wirtschaftlich_wichtige Bibliotheken wie libtiff) zu zerstören, die den Fortschritt der Sprachinnovation in C ++ behinderten.
Rwong

Sinnvoll, würde ich vermuten, dass es mit c ++ 17 vollständiger sein wird, aber wenn Sie erst einmal wirklich gelernt haben, wie man in c ++ programmiert, wollen Sie es nicht mehr. Vielleicht können sie einen Weg finden, die beiden Redewendungen zu kombinieren schön
Aaronman

Wissen Sie, dass es Müllsammler gibt, die die Welt nicht aufhalten? Haben Sie die Auswirkungen der Komprimierung (auf der GC-Seite) und der Heap-Fragmentierung (für generische C ++ - Allokatoren) auf die Leistung berücksichtigt?
SK-logic

2
Ich denke, der Hauptfehler in dieser Analogie ist, dass GC tatsächlich die schmutzigen Teile findet, sie ausschneidet und dann die restlichen Teile wieder zusammensägt, um einen neuen Teppich zu erstellen.
Svick

3

Der Hauptgrund ist, dass Java, wenn Sie nach einem neuen Speicherblock fragen, direkt zum Ende des Heaps gelangt und Ihnen einen Block gibt. Auf diese Weise erfolgt die Speicherzuweisung genauso schnell wie die Zuweisung auf dem Stapel (wie Sie es die meiste Zeit in C / C ++ tun, aber abgesehen davon ..)

Allokationen sind also so schnell wie nichts anderes als ... das zählt nicht die Kosten für die Freigabe des Speichers. Nur weil Sie erst viel später etwas freigeben, bedeutet dies nicht, dass es nicht viel kostet, und im Fall eines GC-Systems sind die Kosten viel höher als bei "normalen" Heap-Zuweisungen - nicht nur bei den GC muss alle Objekte durchlaufen, um zu sehen, ob sie leben oder nicht, und sie müssen dann auch freigegeben werden, und (der hohe Preis) den Speicher herumkopieren, um den Heap zu komprimieren - damit Sie die schnelle Zuordnung am Ende haben Mechanismus (oder wenn Ihnen der Arbeitsspeicher ausgeht, durchsucht C / C ++ beispielsweise jede Zuordnung nach dem nächsten freien Speicherblock, der für das Objekt geeignet ist).

Dies ist einer der Gründe, warum Java / .NET-Benchmarks eine so gute Leistung aufweisen, während reale Anwendungen eine so schlechte Leistung aufweisen. Ich muss mir nur die Apps auf meinem Handy ansehen - die wirklich schnellen, reaktionsschnellen werden alle mit dem NDK geschrieben, so sehr, dass selbst ich überrascht war.

Sammlungen können heutzutage schnell sein, wenn alle Objekte lokal zugeordnet werden, z. B. in einem einzigen zusammenhängenden Block. In Java erhalten Sie jetzt einfach keine zusammenhängenden Blöcke mehr, da die Objekte nacheinander vom freien Ende des Heapspeichers zugewiesen werden. Sie können glücklich nebeneinander enden, aber nur durch Glück (dh nach Lust und Laune der GC-Komprimierungsroutinen und wie Objekte kopiert werden). C / C ++ unterstützt dagegen explizit zusammenhängende Zuordnungen (über den Stack natürlich). Im Allgemeinen unterscheiden sich Heap-Objekte in C / C ++ nicht von Javas BTW.

Mit C / C ++ können Sie jetzt besser werden als mit den Standardzuordnern, mit denen Speicher gespart und effizient genutzt werden soll. Sie können den Zuweiser durch eine Reihe von Pools mit festen Blöcken ersetzen, sodass Sie immer einen Block finden, der genau die richtige Größe für das zuzuweisende Objekt hat. Das Gehen über den Haufen ist nur eine Frage der Bitmap-Suche, um festzustellen, wo sich ein freier Block befindet, und das Aufheben der Zuordnung setzt einfach ein Bit in dieser Bitmap zurück. Die Kosten sind, dass Sie mehr Speicher verwenden, wenn Sie Blöcke mit fester Größe zuweisen, sodass Sie einen Heap von 4-Byte-Blöcken, einen weiteren für 16-Byte-Blöcke usw. haben.


2
Sieht so aus, als ob Sie GCs überhaupt nicht verstehen. Stellen Sie sich das typischste Szenario vor: Hunderte kleiner Objekte werden ständig zugewiesen, aber nur ein Dutzend davon überlebt länger als eine Sekunde. Auf diese Weise entstehen keinerlei Kosten für die Freigabe des Speichers - dieses Dutzend wird von der jungen Generation kopiert (und als zusätzlicher Vorteil komprimiert) und der Rest wird kostenlos entsorgt. Übrigens hat der pathetische Dalvik GC nichts mit den modernen, hochmodernen GCs zu tun, die Sie in den richtigen JVM-Implementierungen finden.
SK-logic

1
Befindet sich eines dieser freigegebenen Objekte in der Mitte des Heapspeichers, wird der Rest des Heapspeichers komprimiert, um den Speicherplatz freizugeben. Oder sagen Sie, dass die GC-Verdichtung nur dann stattfindet, wenn dies der beste Fall ist, den Sie beschreiben? Ich weiß, dass Generationen-GCs hier viel besser abschneiden, es sei denn, Sie geben ein Objekt in der Mitte der späteren Generationen frei. In diesem Fall kann die Auswirkung relativ groß sein. Es gab etwas, das von einem Microsoftie geschrieben wurde, der an seiner GC gearbeitet hat. Ich habe gelesen, dass die GC-Kompromisse bei der Erstellung einer GC für Generationen beschrieben wurden. Ich werde sehen, ob ich sie wieder finde.
gbjbaanb

1
Von was für einem "Haufen" redest du? Der größte Teil des Mülls wird in der jungen Generation zurückgewonnen, und der größte Teil der Leistungsvorteile ergibt sich genau aus dieser Verdichtung. Natürlich ist es meistens in einem Speicherzuordnungsprofil sichtbar, das für die Funktionsprogrammierung typisch ist (viele kurzlebige kleine Objekte). Und natürlich gibt es zahlreiche Optimierungsmöglichkeiten, die noch nicht vollständig erforscht sind - zum Beispiel eine dynamische Regionsanalyse, die Heap-Zuweisungen in einem bestimmten Pfad automatisch in Stack- oder Pool-Zuweisungen umwandelt.
SK-logic

3
Ich bin mit Ihrer Behauptung nicht einverstanden, dass die Heap-Zuweisung so schnell ist wie der Stack.
JBRWilkinson

1
Ich denke schon, aber mit Java und .net sehen Sie meinen Punkt - Sie müssen nicht den Haufen laufen, um den nächsten freien Block zu finden, also ist es in dieser Hinsicht bedeutend schneller, aber ja - Sie haben Recht, es muss sein gesperrt, was Threaded-Apps schaden wird.
gbjbaanb

2

Eden Space

Meine Frage ist also, ob irgendetwas davon wirklich wahr ist, und wenn ja, warum ist Javas Heap-Zuordnung so viel schneller.

Ich habe ein bisschen darüber nachgedacht, wie Java GC funktioniert, da es für mich sehr interessant ist. Ich versuche immer, meine Sammlung von Speicherzuweisungsstrategien in C und C ++ zu erweitern (ich möchte versuchen, etwas Ähnliches in C zu implementieren), und es ist eine sehr, sehr schnelle Möglichkeit, viele Objekte in einem Burst von a zuzuweisen praktische Perspektive, aber vor allem durch Multithreading.

Bei der Java GC-Zuweisung wird eine äußerst kostengünstige Zuweisungsstrategie verwendet, um Objekte zunächst dem Bereich "Eden" zuzuweisen. Soweit ich weiß, wird ein sequentieller Pool-Allokator verwendet.

Das ist viel schneller, nur was den Algorithmus und die Reduzierung von obligatorischen Seitenfehlern angeht, als dies mallocin C für allgemeine Zwecke oder operator newin C ++ für Standardzwecke der Fall ist.

Sequenzielle Allokatoren haben jedoch eine große Schwäche: Sie können Chunks mit variabler Größe zuweisen, aber sie können keine einzelnen Chunks freigeben. Sie ordnen nur gerade nacheinander mit Auffüllen für die Ausrichtung zu und können nur den gesamten von ihnen zugewiesenen Speicher auf einmal löschen. Sie sind in der Regel in C und C ++ nützlich, um Datenstrukturen zu erstellen, die nur Einfügungen und keine Entfernung von Elementen erfordern, z. B. einen Suchbaum, der nur einmal beim Start eines Programms erstellt werden muss und dann wiederholt durchsucht wird oder nur neue Schlüssel hinzugefügt werden ( keine Schlüssel entfernt).

Sie können auch für Datenstrukturen verwendet werden, mit denen Elemente entfernt werden können. Diese Elemente werden jedoch nicht aus dem Arbeitsspeicher freigegeben, da sie nicht einzeln freigegeben werden können. Solch eine Struktur, die einen sequentiellen Allokator verwendet, würde immer mehr Speicher verbrauchen, es sei denn , die Daten wurden mit einem separaten sequentiellen Allokator in eine neue, komprimierte Kopie kopiert. Dies ist manchmal eine sehr effektive Technik, wenn ein fester Allokator gewinnt Das ist aus irgendeinem Grund nicht möglich. Ordnen Sie einfach nacheinander eine neue Kopie der Datenstruktur zu und sichern Sie den gesamten Speicher der alten.

Sammlung

Wie im obigen Beispiel für Datenstruktur / sequentiellen Pool wäre es ein großes Problem, wenn Java GC nur auf diese Weise allokiert würde, obwohl es für eine Burst-Allokation vieler einzelner Chunks superschnell ist. Es wäre nicht in der Lage, irgendetwas freizugeben, bis die Software heruntergefahren wird. Zu diesem Zeitpunkt könnte es alle Speicherpools auf einmal freisetzen (bereinigen).

Stattdessen wird nach einem einzelnen GC-Zyklus ein Durchlauf durch vorhandene Objekte im "Eden" -Raum durchgeführt (sequentiell zugewiesen), und diejenigen, auf die noch verwiesen wird, werden dann mithilfe eines Allokators für allgemeine Zwecke zugewiesen, der einzelne Blöcke freigeben kann. Diejenigen, auf die nicht mehr verwiesen wird, werden beim Löschen einfach freigegeben. Im Grunde ist es also "Objekte aus dem Eden-Raum kopieren, wenn sie noch referenziert sind, und dann löschen".

Dies wäre normalerweise ziemlich teuer, daher wird es in einem separaten Hintergrundthread ausgeführt, um zu vermeiden, dass der Thread, der ursprünglich den gesamten Speicher zugewiesen hat, erheblich blockiert.

Sobald der Speicher aus dem Eden-Speicher kopiert und mithilfe dieses teureren Schemas zugewiesen wurde, mit dem einzelne Blöcke nach einem anfänglichen GC-Zyklus freigegeben werden können, werden die Objekte in einen beständigeren Speicherbereich verschoben. Diese einzelnen Chunks werden dann in nachfolgenden GC-Zyklen freigegeben, wenn sie nicht mehr referenziert werden.

Geschwindigkeit

Der Grund, warum der Java GC C oder C ++ bei der direkten Heap-Zuweisung sehr gut übertreffen könnte, ist, dass er die billigste, vollständig entartete Zuweisungsstrategie im Thread verwendet, der die Zuweisung von Speicher anfordert. Dann spart es die teurere Arbeit, die normalerweise bei Verwendung eines allgemeineren Allokators wie Straight-Up erforderlich istmalloc für einen anderen Thread verwenden würden.

Konzeptionell muss der GC also insgesamt mehr Arbeit leisten, verteilt diese jedoch auf mehrere Threads, sodass die vollen Kosten nicht von einem einzelnen Thread im Voraus bezahlt werden. Dies ermöglicht es dem Thread, Speicher zuzuweisen, es supergünstig zu machen und dann die wahren Kosten aufzuschieben, die erforderlich sind, um die Dinge richtig zu machen, so dass einzelne Objekte tatsächlich zu einem anderen Thread freigegeben werden können. Wenn wir in C oder C ++ mallocanrufen operator new, müssen wir die vollen Kosten im Voraus innerhalb desselben Threads bezahlen.

Dies ist der Hauptunterschied, und warum Java C oder C ++ sehr gut übertreffen kann, wenn es nur naive Aufrufe für mallocoder operator newzum Zuweisen einer Gruppe von Teeny Chunks verwendet. Natürlich wird es in der Regel einige atomare Operationen und eine mögliche Blockierung geben, wenn der GC-Zyklus einsetzt, aber wahrscheinlich ist er ziemlich optimiert.

Grundsätzlich läuft die einfache Erklärung darauf hinaus, dass in einem einzelnen Thread höhere Kosten anfallen ( malloc) und in einem anderen Thread niedrigere Kosten anfallen und dann die höheren Kosten anfallen, die parallel ausgeführt werden können ( GC). Als Nachteil bedeutet dies, dass Sie zwei Indirektionen benötigen, um von der Objektreferenz zur Objektreferenz zu gelangen, damit der Allokator den Speicher kopieren / verschieben kann, ohne vorhandene Objektreferenzen ungültig zu machen. Außerdem können Sie die räumliche Lokalität verlieren, sobald der Objektspeicher leer ist aus dem "Eden" -Raum gezogen.

Last but not least ist der Vergleich etwas unfair, da C ++ - Code normalerweise keine Bootsladung von Objekten einzeln auf dem Heap zuweist. Ordentlicher C ++ - Code neigt dazu, Speicher für viele Elemente in zusammenhängenden Blöcken oder auf dem Stapel zu reservieren. Wenn es eine Schiffsladung winziger Objekte nacheinander in den freien Laden legt, ist der Code beschissen.


0

Es kommt darauf an, wer die Geschwindigkeit misst, welche Implementierungsgeschwindigkeit sie messen und was sie beweisen wollen. Und was sie vergleichen.

Wenn Sie sich nur das Zuweisen / Aufheben der Zuweisung ansehen, könnten Sie in C ++ 1.000.000 Aufrufe an malloc und 1.000.000 Aufrufe an free () haben. In Java würden 1.000.000 Aufrufe von new () und ein Garbage Collector in einer Schleife ausgeführt, um 1.000.000 Objekte zu finden, die freigegeben werden können. Die Schleife kann schneller sein als der free () -Aufruf.

Andererseits hat malloc / free die andere Zeit verkürzt, und malloc / free setzt normalerweise nur ein Bit in einer separaten Datenstruktur und ist für das Auftreten von malloc / free im selben Thread optimiert, sodass in einer Multithread-Umgebung keine gemeinsam genutzten Speichervariablen vorhanden sind werden in vielen Fällen verwendet (und Sperr- oder gemeinsam genutzte Speichervariablen sind sehr teuer).

Auf der dritten Seite gibt es Dinge wie das Referenzzählen, die Sie möglicherweise ohne Garbage Collection benötigen, und das ist nicht kostenlos.

Durch die Nutzung unserer Website bestätigen Sie, dass Sie unsere Cookie-Richtlinie und Datenschutzrichtlinie gelesen und verstanden haben.
Licensed under cc by-sa 3.0 with attribution required.