Vor einiger Zeit habe ich einen kleinen Vortrag zum Thema Threadsicherheit (Threadsafety) in Form eines Quiz erstellt, dass helfen soll, weitere Achtsamkeit gegenüber potenziellen Konkurrenz-Problemen zu entwickeln.
Der Vortrag ist in Form von mehreren Regeln organisiert, welche helfen sollen, dass Vorliegen von Konkurrenz-Problemen schneller zu identifizieren. Zu jeder Regel gibt es ein Beispiel, das die Regel illustriert.
Regel 1 - Shared Resource #
Regel 1 - Shared Resource
Sobald eine lokale Variable in einer Klasse verwendet und verändert wird, kann ein Threadsicherheitsproblem auftauchen (Shared Resource, geteilter Zustand / Resource).
Ohne einen geteilten Zustand kann es keine Konkurrenzprobleme geben.
Beispiel:
class ReadMe {
private String text = "Hello World";
String getText() {
return text;
}
}
Ist diese Klasse threadsicher?
Ja, denn der Zustand der Variable text
wird nie geändert.
Im Prinzip ist die Klasse ReadMe
ein Immutable-Objekt. Diese sind per se threadsicher.
Regel 1.1 - Immutables #
Regel 1.1 - Immutables
Aus Regel 1 folgt: Unveränderliche Objekte (Immutables) sind per se threadsicher.
class ChangeMe {
private String text;
String getText() {
return text;
}
String setText(String text) {
this.text = text;
}
}
Ist diese Klasse threadsicher?
Nein, der Zustand der Variable text
kann geändert werden. Wie sieht dass dann aus?
Hier ein Thread, der die Klasse ChangeMe
verwendet:
class Thread {
private final ChangeMe changeMe;
private final int id;
public Thread(ChangeMe changeMe, int id) {
this.changeMe = changeMe;
this.id = id;
}
run() {
changeMe.setText("Hello Thread" + id);
System.out.println(changeMe.getText());
}
}
Die Ausführung dann wie folgt:
ChangeMe changeMe = new ChangeMe();
new Thread(changeMe, 1).run();
new Thread(changeMe, 2).run();
In der Ausführung passiert dann z. B. folgendes:
T1 -> changeMe.setText("Hello Thread1");
T2 -> changeMe.setText("Hello Thread2");
T1 -> System.out.println(changeMe.getText()); // Hello Thread2
T2 -> System.out.println(changeMe.getText()); // Hello Thread2
Threads können jederzeit von der VM angehalten oder wieder ausgeführt werden.
Im oberen Fall überschreibt T2 den von T1 vorgegebenen Zustand. Ausgegeben wird zweimal “Hello Thread2”. Die VM stoppt T1 nach der Zeile
changeMe.setText("Hello Thread" + id);
und übergibt die Kontrolle an T2. Nach der Ausführung von der Zeile
changeMe.setText("Hello Thread" + id);
durch T2 wird wieder auf T1 geswitcht etc.
Die Reihenfolge der Ausführungen wird durch die VM bestimmt und kann auch anders erfolgen. Der Fehler erscheint also nicht immer.
Wenn man aber die nicht-threadsichere Klasse nicht teilt, ist es dann threadsicher?
new Thread(new ChangeMe(), 1).run();
new Thread(new ChangeMe(), 2).run();
Ja, keine gemeinsamen Zustände (gekapselt durch jeweils eine eigene Instanz pro Thread) = keine Probleme.
Regel 1.2 - Threadlocal #
Regel 1.2 - Threadlocal
Aus Regel 1 folgt: Pro Thread definierte Zustände (Threadlocal), die nur von diesem verändert werden können, sind per se threadsicher.
class ChangeMe2 {
private String text;
String setAndGetText(String text) {
this.text = text;
return this.text;
}
}
Ist diese Klasse threadsicher?
Nein, der Zustand von text
kann weiterhin von mehreren Prozessen gleichzeitig geändert werden.
In diesem Beispiel kann die Ausführung zweier Threads nach dem Ausführen von this.text = text;
erfolgen und damit falsche Ergebnisse produzieren.
Der Threadwechsel durch die VM kann sogar während eines Increments stattfinden. Increments sind in Java nicht atomar, wird also aus Sicht der VM in mehreren Schritten ausgeführt, die unterbrochen werden können. Referenzzuweisungen sind Atomar, aber nicht threadübergreifend sichtbar und damit nicht threadsicher (kompliziertes Thema).
Regel 0 - Atomarität #
Regel 0 - Atomarität
Jedes Statement in Java (inklusive Zuweisungen und Increments) ist nicht threadsicher.
Diese Regel erhält die übergeordnete Position 0, da sie die Grundlage für alle anderen Regeln bildet.
class ChangeMe3 {
String getText(String text) {
String resultText = text + text;
return resultText;
}
}
Ist diese Klasse threadsicher?
Ja, denn Zustände / Variablen, die innerhalb des Scopes Methode erzeugt und dann verändert werden, sind nur für den aufrufenden Thread gültig.
Regel 1.2.1 - Method Scoped #
Regel 1.2.1 - Method Scoped
Aus 1.2 folgt: Innerhalb von Methoden definierte Variablen, welche diese Methode nicht verlassen, können nur von dem Thread verändert werden, der sie initialisiert hat, und sind daher Threadsicher.
Regel 1.2.2 - Functional #
Regel 1.2.2 - Functional
** Aus 1.2.1 resultiert: Funktionale Methoden (Methoden, die alle benötigte Information als unveränderliche Parameter erhalten) sind per se threadsicher.**
Das trifft beispielsweise auf alle rein funktionalen Sprachen, wie Common Lisp, Erlang, Haskell, Clojure etc. zu. Man kann auch in Java funktional programmieren.
Viele Stateless Services sind auch funktional, da sie keine Zustände speichern.
class Writer {
private SimpleDateFormat formatter = new SimpleDateFormat("dd.MM.yyyy");
void printDate(Date myDate) {
System.out.println(formatter.format(myDate));
}
}
Ist diese Klasse threadsicher?
Das hängt davon ab, ob SimpleDateFormat
selber threadsicher ist.
Die statische Natur der Klasse lässt vermuten, dass sie threadsicher wäre.
Eine Googlesuche ergibt folgendes Zitat aus der Javadoc:
Date formats are not synchronized. It is recommended to create separate format instances for each thread. If multiple threads access a format concurrently, it must be synchronized externally.
Die Klasse ist also nicht threadsicher.
Regel 2 - Transitivität #
Regel 2 - Transitivität
Regel 1 trifft auch auf alle Objekte in einer Klasse zu, die verwendet werden, wenn wir nicht wissen, ob diese Objekte selber Variablen haben, die sie verändern.
class Writer2 {
private FastDateFormat formatter = FastDateFormat.getInstance("dd.MM.yyyy", TimeZone.getTimeZone("UTC"), Locale.DE);
void printDate(Date myDate) {
System.out.println(formatter.format(myDate));
}
}
Ist diese Klasse threadsicher?
Das hängt nach Regel 2 davon ab, ob FastDateFormat
selber threadsicher ist.
Die Javadoc zu FastDateFormat
sagt hierzu:
FastDateFormat is a fast and thread-safe version of SimpleDateFormat.
Also ja, threadsicher.
Regel 2.1 - Know your dependencies #
Regel 2.1 - Know your dependencies
Bei der Nutzung von anderen Klassen als lokale Objekte vergewissern, dass diese threadsicher sind.
Threadsicherheit herstellen #
In einigen Fällen möchte man eine gemeinsam genutzte Resource benutzen. Beispielsweise einen naiven Zähler für Metriken.
class ImportantService {
private long calls = 0L;
void doImportantStuff(Data myData) {
incrementCalls();
}
private void incrementCalls() {
calls++;
}
}
Ist diese Klasse threadsicher?
Nein, calls
kann von zwei Threads parallel ungünstig verändert werden:
calls = 10
T1 -> read calls (10)
T2 -> read calls (10)
T1 -> calls + 1 (11)
T2 -> calls + 1 (11)
T1 -> calls = 11
T2 -> calls = 11
In diesem Verlauf wurden nur 11 Aufrufe gezählt, obwohl es 12 waren. Das Inkrement von T1 wurde von T2 überschrieben, da er den Zustand von T1 nicht kannte und berücksichtigte.
Bedenke: Regel 0 gilt auch für Increments, sie sind nicht atomar (hier als mehrere Schritte dargestellt).
Um die Stelle threadsicher zu machen, verwenden wir das Schlüsselwort synchronized
.
Regel 3.0 - Synchronized #
Regel 3.0 - Synchronized
Mit
synchronized
markierte Blöcke können nur von einem einzigen Thread betreten werden.
class ImportantService2 {
private long calls = 0L;
void doImportantStuff(Data myData) {
incrementCalls();
}
private synchronized incrementCalls() {
calls++;
}
}
Ist diese Klasse threadsicher?
Ja, calls
kann nur von einem Thread auf einmal gelesen und geändert werden.
class ImportantService3 {
private long calls = 0L;
void doImportantStuff(Data myData) {
synchronized(this) {
incrementCalls();
}
}
private void incrementCalls() {
calls++;
}
}
Ist diese Klasse threadsicher?
Ja, auch hier kann calls
nur von einem Thread auf einmal gelesen und geändert werden.
class ImportantService4 {
private long calls = 0L;
void doImportantStuff(Data myData) {
incrementCalls();
}
private void incrementCalls() {
synchronized(this) {
calls++;
}
}
}
Ist diese Klasse threadsicher?
Ja, calls
kann nur von einem Thread auf einmal gelesen und geändert werden.
Dies ist analog zu ImportantService3
, lediglich die Synchronisations-Klammer ist verschoben.
Regel 3 - Synchronisationlock #
Eine Synchronisation findet immer auf einem Objekt statt. Das Objekt ist die Sperre, an diesem Objekt wird nachgehalten, ob ein Thread bereits in einem synchronisierten Block ist oder nicht.
Regel 3 - Synchronisationlock
Synchronisation findet immer auf einem Objekt statt (Monitorlock, Intrinsic Lock, Lock, Semaphore)
private synchronized void incrementCalls() {
calls++;
}
ist synonym zu
private void incrementCalls() {
synchornized(this) {
calls++;
}
}
und synchronisiert auf this
.
Regel 3.1 - Synchronisation auf this
#
Regel 3.1 - Synchronisation auf
this
synchronized
an nicht-statischen Methoden entsprichtsynchronized (this)
class ImportantService5 {
private long otherCalls = 0L;
private long calls = 0L;
private final Object semaphoreA = new Object();
private final Object semaphoreB = new Object();
void doImportantStuff(Data myData) {
incrementCalls();
incrementOtherCalls();
}
private void incrementCalls() {
synchronized(semaphoreA) {
calls++;
}
}
private void incrementOtherCalls() {
synchronized(semaphoreB) {
calls++;
otherCalls++;
}
}
}
Ist diese Klasse threadsicher?
Nein. Während das Hochzählen der Variablen für sich für einzelne Threads gesperrt ist, haben wir
zwei unterschiedliche Sperren, die auf die gleiche Resource zugreifen, nämlich calls
.
Regel 3.2 - Verschiedene Locks, verschiedene Threads #
Regel 3.2 - Verschiedene Locks, verschiedene Threads
Verschiedene Locks bedeuten je ein verschiedener Thread kann sich in den jeweiligen gesperrten Bereichen befinden. Als Lock kann jedes beliebige Objekt verwendet werden.
class ImportantService6 {
private static long otherCalls = 0L;
private static long calls = 0L;
void doImportantStuff(Data myData) {
incrementCalls();
incrementOtherCalls();
}
private synchronized void incrementCalls() {
calls++;
}
private static synchronized void incrementOtherCalls() {
calls++;
otherCalls++;
}
}
Ist diese Klasse threadsicher?
Nein. Wichtig ist hier zu sehen, welche Objekte als Lock benutzt werden.
private synchronized void incrementCalls()
-> this
(also ein Objekt von ImportantService6
)
private static synchronized void incrementOtherCalls()
-> Hier gibt es
kein this
, da es sich um eine statische Methode handelt.
Es wird ImportantService6.class
als Lock verwendet!
Wir haben es also mit zwei verschiedenen Locks zu tun, die auf die gleiche Resource zugreifen.
Regel 3.3 - Synchronisation bei static
#
Regel 3.3 - Synchronisation bei
static
synchronized
an statischen Methoden entsprichtsynchronized (MeineKlasse.class)
Threadsicherheit ohne synchronized #
Java bringt einige Klassen mit, die bereits Threadsicher sind. Hierzu gehörten
die Atomic-Klassen (java.util.concurrent.atomic
)
class ImportantService7 {
private AtomicLong calls = new AtomicLong(0L);
void doImportantStuff(Data myData) {
incrementCalls();
}
private void incrementCalls() {
calls.getAndIncrement();
}
}
Ist diese Klasse threadsicher?
Ja. Nach Regel 2 (Transitivität) ist die Klasse threadsicher, wenn AtomicLong threadsicher ist. Die Atomic Klassen versichern, dass alle Methoden atomar, also an einem Stück ausgeführt werden und damit threadsicher sind.
Ein häufiger Einsatz für eine gemeinsame Ressource ist eine Map
, die als Ablage für Daten und
als Cache verwendet werden kann. Im Folgenden wird eine Map als Speicher für Statistiken verwendet.
Es werden die Aufrufe pro Parameter gezählt.
class ImportantService8 {
enum Type{typeA, typeB, typeC}
private ConcurrentHashMap<Type, Long> calls = new ConcurrentHashMap<>();
void doImportantStuff(Data data, Type type) {
incrementCalls(type);
}
private void incrementCalls(Type type) {
calls.compute(type, { key, value -> value == null ? 1 : value + 1 });
}
}
Ist diese Klasse threadsicher?
Ja. Nach Regel 2 (Transitivität) ist die Klasse threadsicher, wenn ConcurrentHashMap#compute
threadsicher ist.
Die Doku sagt hierzu:
The entire method invocation is performed atomically.
Die ConcurrentHashMap#compute
Javadoc versichert, dass die Methode atomar, also an einem Stück ausgeführt wird
und damit threadsicher ist.
Im Prinzip wird hier eine Berechnungsfunktion (key, value) -> value == null ? 1 : value + 1
als Parameter
übergeben und innerhalb einer (wie auch immer) abgesicherten Methode ausgeführt.
Im Folgenden eine abgewandelte Klasse aus produktivem Code.
class ImportantService9 {
enum Type{typeA, typeB, typeC}
private ConcurrentHashMap<Type, Long> cache = new ConcurrentHashMap<>();
void doImportantStuff(Data data, Type type) {
initCacheIfNecessary();
updateCache(typeA, ThreadLocalRandom.current().nextInt(2));
List<Long> values = findByValueGreaterThanOrEqual(3);
}
private void initCacheIfNecessary() {
synchronized (cache) {
if (cache.isEmpty()){
cache.put(Type.typeA, 0L);
cache.put(Type.typeB, 1L);
cache.put(Type.typeC, 2L);
}
}
}
private void updateCache(Type type, long value) {
try {
cache.put(type, value);
} catch (IllegalArgumentException e) {
cache.remove(type);
}
}
private List<Long> findByValueGreaterThanOrEqual(long minValue) {
return cache.values().stream().filter(value -> value >= minValue).toList();
}
}
Ist diese Klasse threadsicher?
Die einzige Instanzvariable, die geändert wird, ist cache
. Also gilt es diese genauer zu überprüfen.
Alle Stellen, die darauf Zugreifen müssen unter die Lupe genommen werden.
private void initCacheIfNecessary() {
synchronized (cache) {
if (cache.isEmpty()){
cache.putAll(Map.of(Type.typeA, 0L, Type.typeB, 1L, Type.typeC, 2L);
}
}
}
Der Code in dem der Cache geprüft und befüllt wird, ist in einem gesperrten Block. Als Lock dient die Map selber. Sieht gut aus.
private void updateCache(Type type, long value) {
try {
cache.put(type, value);
} catch (IllegalArgumentException e) {
cache.remove(type);
}
}
Hier wird etwas in den Cache hinzugefügt oder entfernt. Es gibt keinen synchronized
Block.
Damit kann ein Thread ein Update durchführen, während die Initialisierung des Caches läuft und
damit inkonsistenzen erzeugen. Schauen wir dennoch weiter.
Sichert uns wenigstens ConcurrentHashMap
zu, dass die Methoden put
, get
und remove
atomar ausgeführt werden?
However, even though all operations are thread-safe, retrieval operations do not entail locking, and there is not any support for locking the entire table in a way that prevents all access.
Retrieval operations (including get) generally do not block, so may overlap with update operations (including put and remove). Retrievals reflect the results of the most recently completed update operations holding upon their onset. ConcurrentHashMap is “concurrent”. A concurrent collection is thread-safe, but not governed by a single exclusion lock. In the particular case of ConcurrentHashMap, it safely permits any number of concurrent reads as well as a tunable number of concurrent writes. “Synchronized” classes can be useful when you need to prevent all access to a collection via a single lock, at the expense of poorer scalability. In other cases in which multiple threads are expected to access a common collection, “concurrent” versions are normally preferable. And unsynchronized collections are preferable when either collections are unshared, or are accessible only when holding other locks.
Es ist also etwas komplexer angelegt, aber nehmen wir an, dass get
und put
atomar sind.
initCacheIfNecessary
benutzt die Map als Lock. Was benutzt cache.get / put
als Lock?
Laut Javadoc: Es findet nicht immer ein Locking statt.
Sourcecode: 11 synchronized Blöcke, alle auf einzelne Knoten der Map (ConcurrentHashMap.Node
)
Stellt man sich vor, dass die Map als eine Baumstruktur realisiert ist, macht das auch Sinn. Man braucht nicht den kompletten Baum sperren, wenn man etwas ändern will, es reicht einen Teilbaum zu sperren (Segmentation).
Für uns heißt das, dass wir zwei verschiedene Locks haben und damit initCacheIfNecessary
und die Codestellen
mit cache.get/put
von zwei verschiedenen Threads ausgeführt werden können.
Das ist nicht threadsicher nach Regel 3.2.
Um es threadsicher zu machen, könnte man jedoch ein synchronized (cache)
um das get
und das put
machen.
Schauen wir noch in die letzte Methode rein, welche cache
verwendet:
private List<Long> findByValueGreaterThanOrEqual(long minValue) {
return cache.values().stream().filter(value -> value >= minValue).toList();
}
Dies ist wieder ein komplexer Abruf. Die erste Information die uns interessiert:
Was passiert beim Aufruf von values()
?
Returns a Collection view of the values contained in this map. The collection is backed by the map, so changes to the map are reflected in the collection, and vice- versa.
Das klingt schon nicht optimal, da Änderungen an anderen Stellen unsere Collection ändern könnten.
Normalerweise gäbe das eine ConcurrentModificationException
.
Most concurrent Collection implementations (including most Queues) also differ from the usual java.util conventions in that their Iterators and Spliterators provide weakly consistent rather than fast-fail traversal:
- they may proceed concurrently with other operations
- they will never throw ConcurrentModificationException
- they are guaranteed to traverse elements as they existed upon construction exactly once, and may (but are not guaranteed to) reflect any modifications subsequent to construction.
Es bleibt also kompliziert.
Eine einfache Lösung hierfür wäre wieder ein synchronized-Block.
private List<Long> findByValueGreaterThanOrEqual(long minValue) {
final List<NodeDto> values;
synchronized(cache) {
values = new ArrayList(cache.values());
}
return values.stream().filter(value -> value >= minValue).toList();
}
Da ein synchronized
für die Ausführung des kompletten Blocks alle bis auf einen Thread ausschließt,
kann das auf die Performanz gehen, wenn man komplexe oder lange Berechnungen in einem synchronized
-Block
ausführt. Das umgehen wir hier, in dem wir uns im synchronized
-Block nur eine Arbeitskopie der Daten
in eine eigene Liste erstellen.
Regel 4 - Make it short #
Regel 4 - Make it short
Synchronisierte Blöcke reduzieren die Performanz und sollten möglichst kurz in der Ausführungszeit sein.
Ende? #
Haben wir damit alles abgedeckt?
Nein. Aussen vor habe ich gelassen:
- Verwendung Thread Klasse Deadlocks
- Das Schlüsselwort
volatile
und - CPU Caching
- Umsetzung von aktivem und passivem Warten via
wait
undnotify
- Hilfsklassen aus
java.concurrent.lock
- Hilfsklassen aus
java.concurrent
, wieCyclicBarrier
,CountDownLatch
,Semaphore
,Future
etc. - Hilfsklassen aus
java.lang
, wieThreadLocal
und damit verbundene Techniken zur Threadsicherheit - Serialisierung und Locks
- Ungeeignete Lockobjekte
- Testing mit Nebenläufigkeit
- Inherent threadsichere Konstruktoren
- Viele weitere Fehler, die man machen kann
Übersicht aller Regeln #
Regel 0 - Atomarität: Jedes Statement in Java (inklusive Zuweisungen und Increments) ist nicht threadsicher.
Regel 1 - Shared Resource: Sobald eine lokale Variable in einer Klasse verwendet und verändert wird, kann ein Threadsicherheitsproblem auftauchen.
Regel 1.1 - Immutables: Aus Regel 1 folgt: Unveränderliche Objekte sind per se Threadsicher.
Regel 1.2 - Threadlocal: Aus Regel 1 folgt: Pro Thread definierte Zustände, die nur von diesem verändert werden können, sind per se threadsicher.
Regel 1.2.1 - Method Scoped: Aus Regel 1.2 folgt: Innerhalb von Methoden definierte Variablen, welche diese Methode nicht verlassen, können nur von dem Thread verändert werden, der sie initialisiert hat und sind daher Threadsicher.
Regel 1.2.2 - Functional: Aus Regel 1.2.1 resultiert: Funktionale Methoden (Methoden, die alle benötigte Informationan als unveränderliche Paramteter erhalten) sind per se threadsicher.
Regel 2 - Transitivität: Regel 1 trifft auch auf alle Objekte in einer Klasse zu, die verwendet werden, wenn wir nicht wissen, ob diese Objekte selber Variablen haben, die sie verändern.
Regel 2.1 - Know your dependencies: Bei der Nutzung von anderen Klassen als lokale Objekte vergewissern, dass diese threadsicher sind.
Regel 3 - Synchronisationlock: Synchronisation findet immer auf einem Objekt statt (Monitorlock, Intrinsic Lock, Lock, Semaphore).
Regel 3.1 - Synchronisation auf this
: synchronized
an nicht-statischen Methoden entspricht synchronized (this)
.
Regel 3.2 - Verschiedene Locks, verschiedene Threads: Verschiedene Locks bedeuten je ein verschiedener Thread kann sich in den jeweiligen gesperrten Bereichen befinden. Als Lock kann jedes beliebige Objekt verwendet werden.
Regel 3.3 - Synchronisation bei static
: synchronized
an statischen Methoden entspricht synchronized (MeineKlasse.class)
.
Regel 4 - Make it short: Synchronisierte Blöcke reduzieren die Performanz und sollten möglichst kurz in der Ausführungszeit sein.
Referenzen & weiterführende Links #
- “Understanding Immutability and Pure Functions (for OOP)” - Einstiegsartikel mit Beispielen aus der Java-Welt von David Raab
- “Java’s SimpleDateFormat is not thread-safe, Use carefully in multi-threaded environments” - Artikel von Rajeev Singh
- Javadoc Class SimpleDateFormat
- Javadoc Class FastDateFormat
- Javadoc Class ConcurrentHashMap
- “What is Thread-Safety and How to Achieve it?” - Artikel von baeldung
- “Concurrent Programming Fundamentals— Thread Safety” - Artikel von Gowthamy Vaseekaran