AngelikaLanger.com - CopyOnWriteArrayList - Angelika Langer Training/Consulting (original) (raw)

CopyOnWriteArrayList

Java Memory Model CopyOnWriteArrayList Java Magazin, Dezember 2009 Klaus Kreft & Angelika Langer Dies ist das Manuskript eines Artikels, der im Rahmen einer Kolumne mit dem Titel "Effective Java" im Java Magazin erschienen ist. Die übrigen Artikel dieser Serie sind ebenfalls verfügbar (click here). Die ganze Serie zum "Java Memory Modell" als PDF(985 KB).

In diesem Beitrag wollen wir die CopyOnWriteArrayList aus dem JDK hernehmen und an ihrem Beispiel das Zusammenspiel der Speichereffekte von Synchronisation, volatile, und atomaren Referenzen noch einmal abschließend erläutern.

In unserer Serie über das Java Memory Modells haben wir eine Reihe von Aspekten angeschaut, mit denen sich die Verwendung von Synchronisation reduzieren läßt. Dabei soll mit der Vermeidung der Synchronisation in der Regel die Performance der betroffenen Abstraktion verbessert und der Durchsatz erhöht werden, weil ohne Synchronisation mehrere Threads ungehindert parallel arbeiten können, ohne aufeinander warten zu müssen. Wir haben die Effekte von final-, volatile- und atomaren Variablen betrachtet und an kleineren Beispielen demonstriert. Dieses Mal wollen wir zeigen, wie diese Sprachmittel in einer realistischen Abstraktion in der Praxis genutzt werden. Dazu wollen wir uns die Implementierung der CopyOnWriteArrayList aus dem JDK-Package java.util.concurrent genauer ansehen, weil sie ein geschicktes Zusammenspiel von volatile und Synchronisation zeigt.

Was ist eine CopyOnWriteArrayList?

Die CopyOnWriteArrayList ist eine thread-sichere Implementierung einer Liste, die man - ähnlich wie eine synchonisierte Liste - mehreren Thread zum gemeinsamen, konkurrierenden Zugriff zur Verfügung stellen kann. Während die synchronisierte Liste, die man über Collections.synchronizedList() bekommt, alle Zugriffsmethoden per Synchronisation auf dem Mutex des Listenobjekts schützt, ist die CopyOnWriteArrayList dahingehend optimiert, dass sie einen schnelleren Lesezugriff bietet, der ganz ohne Synchronisation auskommt. Im Gegenzug sind die modifizierenden Methoden relativ teuer, weil sie die Modifikation auf einer Kopie des unterliegenden Arrays ausführen und anschließend das alte gegen das neue modifizierte Array austauschen. Man bezahlt also die schnellen Lesezugriffe durch das relativ teure Kopieren bei den Schreibzugriffen. Wenn Modifikationen an der Liste selten und Lesezugriffe wesentlich häufiger vorkommen, dann ist die CopyOnWriteArrayList performanter als die normale synchronisierte Liste.

Wie funktioniert eine CopyOnWriteArrayList?

Die CopyOnWriteArrayList besteht aus einem Array, in dem die Referenzen auf die Elemente der Liste abgelegt sind. Die wesentliche Idee bei der CopyOnWriteArrayList ist, dass dieses Array unveränderlich ist. Es wird von keiner Methode der CopyOnWriteArrayList modifiziert und deshalb können alle lesenden Zugriffe ohne Synchronisation erfolgen. Hier ist ein Ausschnitt aus der Implementierung, der zunächst einmal nur eine lesende Methode, nämlich die get()-Methode, und die relevanten Felder der CopyOnWriteArrayList zeigt:

public class CopyOnWriteArrayList implements List {

private volatile transient Object[] array;

private final Object[] getArray() {
return array;
}

public E get(int index) {
return (E)(getArray()[index]);
}
}

Man sieht, dass die Referenz auf das Array volatile ist und dass die get()-Methode über diese Referenz auf das benötigte Array-Element zugreift. Man sieht außerdem, dass die get()-Methode nicht synchronisiert ist. Sie kann also gleichzeitig mit anderen lesenden oder modifizierenden Methoden auf das Array der CopyOnWriteArrayList zugreifen. Warum das keine Race Conditions ergibt, nicht einmal wenn die get()-Methode gleichzeitig mit einer modifizierenden Methode wie set() abläuft, sehen wir uns später im Zusammenhang mit der set()-Methode an.

Das Fehlen jeglicher Synchronisation bedeutet, dass wir in der get()-Methode einen unsynchronisierten Zugriff auf die Array-Referenz haben. Bei unsynchronisierten Zugriffen stellt sich stets die Frage nach der Sichtbarkeit von Modifikationen, die ein anderer Thread gemacht hat. Kann die get()-Methode alle Modifikationen am Array sehen, die beispielsweise eine set()-Methode zuvor in einem anderen Thread ausgelöst hat?

Die Referenz auf das Array der CopyOnWriteArrayList ist als volatile deklariert und die get()-Methode greift lesend auf die Referenz zu (über die Hilfs-Methode getArray()). Weil die Array-Referenz volatile ist, ist garantiert, dass die get()-Methode alle Modifikationen sehen kann, die ein anderer Thread vor einem schreibenden Zugriff auf die Array-Referenz gemacht hat. Sehen wir uns also eine modifizierende Methode näher an. Hier ist ein größerer Ausschnitt aus der Implementierung, der zusätzlich zur get()-Methode auch die set()-Methode zeigt:

public class CopyOnWriteArrayList implements List {

/** The lock protecting all mutators */
transient final ReentrantLock lock = new ReentrantLock();

/** The array, accessed only via getArray/setArray. */
private volatile transient Object[] array;

private final Object[] getArray() {
return array;
}

private final void setArray(Object[] a) {
array = a;
}

public E get(int index) {
return (E)(getArray()[index]);
}

public E set(int index, E element) {
final ReentrantLock lock = this.lock;
lock.lock();
try {
Object[] elements = getArray();
Object oldValue = elements[index];

if (oldValue != element) {
int len = elements.length;
Object[] newElements = Arrays.copyOf (elements, len); // 1
newElements[index] = element; // 2
setArray(newElements); // 3
} else {
// Not quite a no-op; ensures volatile write semantics
setArray(elements); // 3
}
return (E)oldValue;
} finally {
lock.unlock();
}
}
}

Sehen wir uns als erstes einmal die Funktionalität der set()-Methode an. Sie kopiert das Array (siehe Zeile //1), modifiziert die Kopie (siehe //2) und legt am Ende die Adresse der Kopie in der volatile Referenzvariablen array ab (siehe //3). Dieser schreibende Zugriff (über die Methode setArray()) auf die volatile Referenz löst eine Memory Barrier aus, bei der alle Modifikationen, die in der set()-Methode bis dahin vorgenommen wurden, im Hauptspeicher sichtbar gemacht werden für Methoden die später lesend auf die volatile Referenzvariable array zugreifen. Hierbei ist wichtig, dass der schreibende Zugriff auf die volatile Array-Referenz erst ganz am Ende nach der Modifikation des kopierten Arrays erfolgt, sonst wäre nicht gewährleistet, dass neben der Array-Adresse auch die Array-Elemente für die anderen Threads sichtbar werden.

Man kann nun auch sehen, warum der konkurriende unsynchronisierte Ablauf von set()- und get()-Methode keine Race Condition ist. Das Array, das die get()-Methode liest, wird von der set()-Methode nicht verändert, denn sie verändert nur eine lokale Kopie des Arrays. Die einzige Modifikation, die die set()-Methode an den Feldern der CopyOnWriteArrayList vornimmt, ist die Zuweisung der Adresse des neu erstellten Arrays an die Referenzvariable array (siehe //3). Diese Adresszuweisung ist atomar, also unkritisch, weil sie ununterbrechbar ist. Die get()-Methode erwischt also die Adresse des unveränderten Arrays vor dem Aufruf von setArray() oder die Adresse der modifizierten Kopie nach dem Aufruf von setArray(). Auf irgendwelche inkonsistenten Zwischenzustände des Arrays hat die get()-Methode keinen Zugriff.

Der konkurrierende Ablauf von zwei set()-Methode wäre hingegen sehr wohl eine Race Condition. Wenn sich beide set()-Methoden die Adresse des Arrays holen, beide eine lokale Kopie anlegen und ihre jeweilige Modifikation an der lokalen Kopie machen, dann wird die eine set()-Methode die Modifikation der anderen set()-Methode überschreiben, wenn sie die Referenzvariable array überschreibt. Nach einem unsynchronisierten Ablauf beider set()-Methoden bliebe nur eine der beiden Modifikationen übrig; wir hätte also ein Lost-Update-Problem. Die modifizierenden Methoden der CopyOnWriteArrayList sind deshalb alle gegeneinander synchronisiert. Dazu wird ein explizites Lock verwendet.

Die übrigen lesenden und modifizierenden Methoden der CopyOnWriteArrayList sehen analog aus. Interessant ist noch der Iterator. Die Beschreibung sagt, der Iterator iteriert über die Liste, so wie im Augenblick der Konstruktion des Iterators ausgesehen hat. Deshalb ist von einem Snapshot die Rede. Was hat es mit dem Snapshot auf sich?

Wie funktioniert der Iterator der CopyOnWriteArrayList

Der Iterator der CopyOnWriteArrayList ist ein reiner Lese-Iterator. Modifikationen läßt er nicht zu; modifizierende Iterator-Methoden wie remove() werfen immer eine UnsupportedOperationException. Sämtliche Methoden des Iterators sind unsynchronisiert, das heißt, der Iterator iteriert konkurrierend mit lesenden und schreibenden Methoden und anderen Iteratoren. Er ist - anders als der Iterator einer synchronisierten Liste - kein Fast-Fail-Iterator und wirft keine ConcurrentModificationException, wenn während der Iteratierung Modifikationen an der Liste vorgenommen werden.

Hier ist ein Auszug aus der Implementierung des Iterators der CopyOnWriteArrayList:

public class CopyOnWriteArrayList

private volatile transient Object[] array;

private final Object[] getArray() {
return array;
}
public Iterator iterator() {
return new COWIterator(getArray(), 0);
}

private static class COWIterator implements ListIterator {
/* Snapshot of the array **/
private final Object[] snapshot;
/* Index of element to be returned by subsequent call to next.*/
private int cursor;

private COWIterator(Object[] elements, int initialCursor) {
cursor = initialCursor;
snapshot = elements;
}

public boolean hasNext() {
return cursor < snapshot.length;
}

public E next() {
if (! hasNext())
throw new NoSuchElementException();
return (E) snapshot[cursor++];
}

/* Not supported. Always throws UnsupportedOperationException. */
public void remove() {
throw new UnsupportedOperationException();
}
...
}
...
}

Der Iterator enthält zwei Felder: die Referenz auf das Array der CopyOnWriteArrayList und einen Index, der seine Anfangsposition repräsentiert. Beide Felder werden bei der Konstruktion des Iterators initialisiert. Das heißt, der Iterator iteriert über das Array, das zum Zeitpunkt der Konstruktion des Iterators gerade aktuell war. Solange die CopyOnWriteArrayList nicht modifiziert wird, ist klar, dass die unsynchronisierte Iterierung kein Problem ist und keine Race Condition produziert. Wie ist das aber, wenn die CopyOnWriteArrayList modifiziert wird, beispielsweise mit der set()-Methode?

Wie wir schon gesehen haben, legt die set()-Methode eine Kopie des Arrays an, modifiziert die Kopie und überschreibt dann die Array-Referenz in der CopyOnWriteArrayList mit der Adresse der Kopie. Der Iterator bekommt davon nichts mit. Er wird weiterhin auf dem alten Array iterieren. In diesem Sinne arbeitet der Iterator der CopyOnWriteArrayList auf einem Snapshot und dieser Snapshot ist, nachdem eine Änderung an der Liste vorgenommen wurde, eine "alte" Kopie der Liste.

Weil das Array der CopyOnWriteArrayList nie verändert wird, wird im Iterator keine Synchronisation gebraucht. Auch der konkurrierende Ablauf einer Iteration und einer Modifikation der Liste ist kein Problem. Auf diese Weise hat die CopyOnWriteArrayList einen höchst effizienten Iterator, der ein Maximum an konkurrierenden Zugriffen auf die Liste zuläßt.

Allerdings hat der Iterator der CopyOnWriteArrayList eine andere Semantik als der Fast-Fail-Iterator einer synchronisierten Liste. Er zeigt unter Umständen einen alten Stand (stale value) der Liste - eine Situation, die beim Iterieren über eine synchronisierte Liste nicht auftreten kann. Bei der synchronisierten Liste ist die konkurrierende Modifikation ein Fehler, der sich in der ConcurrentModificationException des Iterators ausdrückt. Bei der CopyOnWriteArrayList ist die konkurrierende Modifikation hingegen ein Feature.

Eine andere Anwendung desselben Prinzips

Die grundlegende Idee der Implementierung der CopyOnWriteArrayList besteht darin, dass sich das Array selbst nie ändert; es wird lediglich bei Bedarf durch ein neues ersetzt. Betrachten wir zur Illustration dieses Prinzip ein weiteres Beispiel: ein Interval. Hier ein Auszug aus einer denkbaren Implementierung, die aber nicht thread-safe ist:

public class Interval { // Vorsicht: nicht thread-safe !!!
// INVARIANT: lower <= upper
private volatile int lower = 0
private volatile int upper = 0;

public void setLower(int i) {
if (i > upper.)
throw new IllegalArgumentException(
"can't set lower to " + i + " > upper");
lower = i;
}

public int getLower() {
return lower;
}

public boolean isInRange(int i) {
return (i >= lower && i <= upper);
}

}

Diese Implementierung ist nicht thread-safe, weil die beiden Felder lower und upper eine Invariante bilden: lower muss immer kleiner als upper sein. Für den Erhalt der Invariante ist es erforderlich, dass Read-Check-Modify-Sequenzen wie in der setLower()-Methode ununterbrechbar sind: erst wird der aktuelle Wert von upper gelesen, dann wird er mit dem neuen Wert von lower verglichen, und nur wenn alles in Ordnung ist, wird der neue Wert für lower gesetzt. Wenn diese Sequenz unterbrochen würde, könnten sinnlose Intervalle entstehen, bei denen die Untergrenze größer als die Obergrenze ist.

Um diese Interval-Klasse thread-safe zu machen, kann man dasselbe Prinzip anwenden, das auch in der CopyOnWriteArrayList verwendet wird: man sorgt dafür, dass die eigentlichen Daten des Intervals (also das Paar von Unter- und Obergrenze) unveränderlich sind und lediglich bei Bedarf neue Daten erzeugt und gegen die alten ausgetauscht werden.

Hier ist eine korrigierte Fassung:

@ThreadSafe
public class Interval {

@Immutable
private static class IntPair {
private final int lower;
private final int upper;
public IntPair(int l, int u) {
lower =l;
upper =u;
}
}

private final AtomicReference values =
new AtomicReference(new IntPair(0, 0));

public void setLower(int i) {
while (true) {
IntPair oldv = values.get();
if (i > oldv.upper)
throw new IllegalArgumentException(
"Can't set lower to " + i + " > upper");
IntPair newv = new IntPair(i, oldv.upper);
if (values.compareAndSet(oldv, newv))
return;
}
}

public int getLower() {
return values.get().lower;
}

public boolean isInRange(int i) {
IntPair v = values.get();
return (i >= v.lower && i <= v.upper);
}


}

Das Paar von Unter- und Obergrenze wird nie verändert. Wenn das Interval geändert werden soll, wie etwa in der setLower()-Methode, dann wird ein neues Paar erzeugt und gegen das alte ausgetauscht. Dazu genügt es aber nicht, lediglich die Referenz auf das Paar auszutauschen, sondern wir brauchen wegen der Invarianten noch immer die ununterbrechbare Read-Check-Modify-Sequenz - ähnlich wie man in der set()-Methode der CopyOnWriteArrayList eine ununterbrechbare Read-Calculate-Modify-Sequenz brauchte. Das könnte man wie in der CopyOnWriteArrayList mit Synchronisation erreichen, aber um eine effizientere Lösung zu bekommen, verwenden wir eine AtomicReference, die eine ununterbrechbare CAS (= compare-and-swap) Methode hat.

Das Verhalten ist bei dieser Lösung ähnlich wie bei der CopyOnWriteArrayList: konkurreirende Lesezugriffe sind sowieso kein Problem; Modifikationen, die konkurrierend zu lesenden Zugriffen passieren, sind auch kein Problem. Der Leser sieht stets ein gültiges Interval, das zu irgendeinem Zeitpunkt einmal existiert hat (und niemals ein fehlerhaftes, bei dem die Untergrenze größer als die Obergrenze ist); schlimmstenfalls sieht er einen älteren Zustand des Intervals (vor der letzten aktuellen Änderung). Sogar konkurrierende Schreibzugriffe kommen wegen der AtomicReference ohne Synchronisation aus; schlimmstenfalls muss ein Thread seinen Modifikationsversuch mehrmals wiederholen.

Zusammenfassung

Die Implementierung der CopyOnWriteArrayList illustriert, wie man den Bedarf an Synchronisatoin senken und die Zahl der konkurrierenden Zugriffe auf eine Abstraktion erhöhen kann. Wir haben dasselbe Prinzip auch noch am Beispiel einer Interval-Klasse gezeigt. Das Idiom hat folgende Elemente:

Literaturverweise

/JCP/ Java Concurrency in PracticeBrian Goetz et.al.Addison-Wesley 2006

Die gesamte Serie über das Java Memory Model:

/JMM1/ Einführung in das Java Memory Model: Wozu braucht man volatile? Klaus Kreft & Angelika Langer, Java Magazin, Juli 2008 URL: http://www.AngelikaLanger.com/Articles/EffectiveJava/37.JMM-Introduction/37.JMM-Introduction.html
/JMM2/ Überblick über das Java Memory Model Klaus Kreft & Angelika Langer, Java Magazin, August 2008 URL: http://www.AngelikaLanger.com/Articles/EffectiveJava/38.JMM-Overview/38.JMM-Overview.html
/JMM3/ Die Kosten der Synchronisation Klaus Kreft & Angelika Langer, Java Magazin, September 2008 URL: http://www.AngelikaLanger.com/Articles/EffectiveJava/39.JMM-CostOfSynchronization/39.JMM-CostOfSynchronization.html
/JMM4/ Details zu volatile-Variablen Klaus Kreft & Angelika Langer, Java Magazin, Oktober 2008 URL: http://www.AngelikaLanger.com/Articles/EffectiveJava/40.JMM-volatileDetails/40.JMM-volatileDetails.html
/JMM5/ volatile und das Double-Check-Idiom Klaus Kreft & Angelika Langer, Java Magazin, November 2008 URL: http://www.AngelikaLanger.com/Articles/EffectiveJava/41.JMM-DoubleCheck/41.JMM-DoubleCheck.html
/JMM6/ Regeln für die Verwendung von volatile Klaus Kreft & Angelika Langer, Java Magazin, Dezember 2008 URL: http://www.AngelikaLanger.com/Articles/EffectiveJava/42.JMM-volatileIdioms/42.JMM-volatileIdioms.html
/JMM7/ Die Initialisation-Safety-Garantie für final-Felder von primitivem Typ Klaus Kreft & Angelika Langer, Java Magazin, Februar 2009 URL: http://www.AngelikaLanger.com/Articles/EffectiveJava/43.JMM-InitializationSafety.1/43.JMM-InitializationSafety.1.html
/JMM8/ Die Initialisation-Safety-Garantie für final-Felder von einem Referenztyp Klaus Kreft & Angelika Langer, Java Magazin, April 2009 URL: http://www.AngelikaLanger.com/Articles/EffectiveJava/44.JMM-InitializationSafety.2/44.JMM-InitializationSafety.2.htm l
/JMM9/ Über die Gefahren allzu aggressiver Optimierungen Klaus Kreft & Angelika Langer, Java Magazin, Juni 2009 URL: http://www.AngelikaLanger.com/Articles/EffectiveJava/45.JMM-AggressiveOpt/45.JMM-AggressiveOpt.html
/JMM10/ Atomic Scalars Klaus Kreft & Angelika Langer, Java Magazin, August 2009 URL: http://www.AngelikaLanger.com/Articles/EffectiveJava/46.JMM-AtomicScalar/46.JMM-AtomicScalar.html
/JMM11/ Atomic References Klaus Kreft & Angelika Langer, Java Magazin, Oktober 2009 URL: http://www.AngelikaLanger.com/Articles/EffectiveJava/47.JMM-AtomicReference/47.JMM-AtomicReference.html
/JMM12/ CopyOnWriteArrayList Klaus Kreft & Angelika Langer, Java Magazin, Dezember 2009 URL: http://www.AngelikaLanger.com/Articles/EffectiveJava/48.JMM-CopyOnWriteArrayList/48.JMM-CopyOnWriteArrayList.html
If you are interested to hear more about this and related topics you might want to check out the following seminar:
Seminar Concurrent Java - An in-depth seminar covering all that is worth knowing about concurrent programming in Java, from basics such as synchronization over the Java 5.0 concurrency utilities to the intricacies of the Java Memory Model (JMM). 4 day seminar (open enrollment and on-site)