Threadsicherheit (Threadsafety) ist ein häufig unterschätztes Thema bei Entwicklern. Dabei reichen einige wenige Heuristiken, um das Schlimmste zu vermeiden.

Das Problem

Die Threadsicherheit ist üblicherweise dann gefährdet, wenn mehrere Prozesse auf eine gemeinsame Ressource zugreifen und sich der Zustand dieser Ressource dabei ändert.

Keine Änderung

Ein Objekt, das sich nicht ändert, ist per se threadsicher. Aus diesem Vorteil (und einigen weiteren Vorteilen) haben sich die Paradigmen der unveränderlichen Objekte (immutable objects) und der zustandslosen Dienste (stateless services) entwickelt.

Wenn in einer Klasse also nichts verändert werden kann, dann ist sie Threadsicher.
Beispiel hierfür wäre ein Comparator, der zwei Strings ohne Beachtung der Groß- und Kleinschreibung vergleicht:

public class CaseInsentiveComparator implements Comparator<String> {
    @Override
    public int compare(String o1, String o2) {
        return o1.toLowerCase().compareToIgnoreCase(o2.toLowerCase());
    }
}

Da hier kein Zustand für den Comparator hinterlegt, geschweige denn geändert wird, kann theoretisch eine Instanz für alle Verwendungen eingesetzt werden und das ganze ist stets threadsicher:

class CaseInsentiveComparator implements Comparator<String> {
    public static final CaseInsentiveComparator INSTANCE = new CaseInsentiveComparator();
 
    @Override
    public int compare(String o1, String o2) {
        return o1.toLowerCase().compareToIgnoreCase(o2.toLowerCase());
    }
}
 
...
CaseInsentiveComparator.INSTANCE.compare("hallo", "Hallo");

Was tun bei Änderungen

Anders sieht es aus, wenn ein Zustand involviert ist. Angenommen, wir verwenden eine Collator-Instanz, um unsere Strings mit Berücksichtigung von deutschen Umlauten zu sortieren. Hierzu erhält unser Comparator eine Collator-Instanz:

class GermanStringComparator implements Comparator<String> {
    private final Collator collator = Collator.getInstance(Locale.GERMAN);
 
    @Override
    public int compare(String o1, String o2) {
        return collator.compare(o1, o2);
    }
}

Ist dieser Comparator nun threadsicher, obwohl nur lesend zugegriffen wird?

Das hängt davon ab, ob Collator selber threadsicher ist. Eine kurze Suche ergibt den Hinweis, dass es zumindest in einigen JDK-Versionen der Collator nicht threadsafe ist. Offensichtlich verwendet der Collator intern Zustände, die sich bei Verwendung ändern können.

Daher sollte man unseren Comparator entweder pro Anwendungsfall und Prozess instantiieren, oder ihn threadsicher machen.
In diesem Fall könnte man entweder bei jeder Benutzung eine Collator-Instanz erzeugen, eine Synchronisierung auf den Collator oder auf die Methode des Comparators machen:

Immer neu

Bei jeder Benutzung eine Collator-Instanz erzeugen:

class GermanStringComparator implements Comparator<String> {
 
    @Override
    public int compare(String o1, String o2) {
        final Collator collator = Collator.getInstance(Locale.GERMAN);
        return collator.compare(o1, o2);
    }
}

Diese Variante ist immer sicher, kann aber langsam und ineffizient sein, wenn das erzeugte Objekt komplex ist und viel Speicher oder andere Ressourcen benötigt.

Synchronisierung

Synchronisierung auf den Collator:

class GermanStringComparator implements Comparator<String> {
    private final Collator collator = Collator.getInstance(Locale.GERMAN);
 
    @Override
    public int compare(String o1, String o2) {
        synchronized(collator){
            return collator.compare(o1, o2);
        }
    }
}

Synchronisierung auf die Methode des Comparators:

class GermanStringComparator implements Comparator<String> {
    private final Collator collator = Collator.getInstance(Locale.GERMAN);
 
    @Override
    public synchonized int compare(String o1, String o2) {
        return collator.compare(o1, o2);
    }
}

Bei der Synchronisierung wird nur einem Prozess erlaubt, den synchronisierten Bereich gleichzeitig zu betreten. Das hat den Vorteil, dass man nicht ständig neue Objekte erzeugen muss, aber auch den Nachteil, dass mehrere Prozesse nicht gleichzeitig arbeiten können, eine Parallelisierung also ausgebremst wird.

Vorsicht: Ein von mir hin und wieder beobachteter Fehler ist, dass bei Verwendung von synchronisierten statischen Methoden angenommen wird, dass sie genauso funktionieren wie synchronisierte Instanzmethoden.

class MyExample {
    private static MyResource myRessource;
 
    public synchronized Object getSomething() {
        myRessource.doSomething();
        return myRessource.getAnything();
    }
 
    public static synchronized Object getSomethingStatic() {
        myRessource.doSomething();
        return myRessource.getAnything();
    }
}

Es sieht aus, als wären beide Methoden gleichwertig, der Zugriff auf MyResource durch beide Methoden threadsafe. Doch der Schein trügt. Während getSomething() als Instanzmethode die Konkrete Instanz von MyExample (also das this) als Lock verwendet, kennt die statische Methode getSomethingStatic() die Instanz gar nicht, sondern synchronisiert auf der Klasse MyExample.class. Die Zugriffe auf die beiden Methoden schließen sich also nicht gegenseitig aus.

Wird mit synchronisierten Methoden gearbeitet, empfiehlt es sich, keine statischen Methoden zu synchronisieren, sondern die synchronisierten Instanzmethoden zu verwenden (oder anders herum):

class MyExample {
    private static MyResource myRessource;
 
    public synchronized Object getSomething() {
        myRessource.doSomething();
        return myRessource.getAnything();
    }
 
    public static Object getSomethingStatic() {
        return new MyExample().getSomething();
    }
}

Generell empfehle ich, insbesondere bei Singletons, alle Variablen als Instanzvariablen zu deklarieren und lediglich die Singleton-Instanzvariable statisch zu referenzieren.

Prozessgebundene Instanzen

In Java besteht die Möglichkeit, eine Objekt-Instanz mit einem Thread / Prozess zu verbinden, so dass dieser nur von dem entsprechenden Prozess verwendet werden kann. Hierzu existiert das ThreadLocal. Da die Instanz nur von einem Prozess verwendet wird, ist sie threadsicher.

class GermanStringComparator implements Comparator<String> {
    private final Collator collator = Collator.getInstance(Locale.GERMAN);
 
    @Override
    public synchonized int compare(String o1, String o2) {
        return collator.compare(o1, o2);
    }
}

Diese Variante hat den Vorteil, dass sie die Parallelisierung nicht abbremst und auch nur so viele Instanzen des Collators erzeugt, wie Threads existieren. Unter der Annahme, dass Threads wiederverwendet werden, und nicht ständig neu erzeugt, ist das recht effizient.

Weitere Beispiele

Weitere Beispiele für Klassen, die nicht threadsicher sind, sind Matcher (Pattern) oder
SimpleDateFormatter / DateFormatter.

Ein Klassiker ist es auch, einen threadsicheren Service zu schreiben, und dann durch neue Anforderungen um Zustände zu ergänzen, ohne diese abzusichern.
So könnte z. B. ein Service, der Daten aus der Datenbank lädt, um das Nachhalten von einigen Statistiken erweitert werden:

@Service
class LoadService  {
    @Autowired
    private MyClassDao dao;
 
    public MyClass loadByName(String name) {
        return dao.loadByName(name);
    } 
}

Nun “naive” Erweiterung um Statistik:

@Service
class LoadService  {
    @Autowired
    private MyClassDao dao;
 
    private int accessCounter = 0;
 
    public MyClass loadByName(String name) {
        accessCounter++;
        return dao.loadByName(name);
    }
}

Das dieser Service nun nicht mehr threadsicher ist, sollte klar sein. Der Zugriff auf die Variable accessCounter muss nun abgesichert werden.

Zusammenfassung

Es gilt also generell in Erfahrung zu bringen, ob eine Klasse threadsicher ist oder nicht.

Wenn man weiß, dass eine Klasse threadsicher ist, dann kann man eine einzelne Instanz verwenden.
Wenn man nicht weiß, ob eine Klasse threadsicher ist, sollte man sie als nicht-threadsicher behandeln.
Nicht threadsichere Klassen sollte man mit einem der verschiedenen Möglichkeiten threadsicher machen.

Referenzen & weiterführende Links