Musterlösungen
Laboraufgabe “Unterbrechbarkeit von Threads, die blockiert sind”
Ausgangslage mit synchronized
Ein Testlauf ergibt höchstwahrtscheinlich den folgenden Trace:
T1: startup
T1: working
T2: startup
T3: startup
T2: killing...
T3: killing...
T1: killing...
T1: interrupted
T1: finished
T3: working
T3: interrupted
T3: finished
T2: working
T2: interrupted
T2: finished
Interpretation der Ausgabe
Die Interrupted-Eigenschaft wirkt sich jeweils erst aus, wenn der Thread mit dem kritischen Abschnitt beginnt.
pp.TaskRegular: Umbau auf ReentrantLock mit lock()
Es wird eine neue Klassenvariable für den Lock eingeführt:
Es handelt sich um eine static-Variable, damit alle Instanzen von Task darauf zugreifen können und sich gegenseitig koordinieren können (vergleichbar mit dem Monitor Task.class statt this.
Mit der lokalen Variable lock wird unterschieden, ob die InterruptedException während einer Blockade (blocked == true) erfolgt ist. Ansonsten wird dem Verwendungsmuster von lock()/unlock() gefolgt.
@Override
public void run() {
System.out.printf("%s: startup\n", getName());
while (!this.stop) {
var blocked = true;
lock.lock();
try {
blocked = false;
System.out.printf("%s: working\n", getName());
Thread.sleep(8000);
} catch (InterruptedException e) {
if (blocked) {
System.out.printf("%s: blocked state interrupted\n", getName());
} else {
System.out.printf("%s: interrupted\n", getName());
}
} finally {
lock.unlock();
}
}
System.out.printf("%s: finished\n", getName());
}Ein Testlauf ergibt höchstwahrtscheinlich den folgenden Trace:
T1: startup
T1: working
T2: startup
T3: startup
T2: killing...
T3: killing...
T1: killing...
T1: interrupted
T1: finished
T2: working
T2: interrupted
T2: finished
T3: working
T3: interrupted
T3: finished
Interpretation der Ausgabe
Das Ergebnis ist ganz analog zur Ausgangslage (mit synchronized). Es wird hierdurch deutlich, dass blocked false ist, wenn sich der Interrupt auswirkt, d.h. also erst, wenn die Blockierung aufgehoben wurde und der Thread in den kritischen Abschnitt eingetreten ist.
pp.TaskInterruptably: Umbau auf ReentrantLock mit lockInterruptably()
Im Gegensatz zur vorigen Lösung wird statt lock() lockInterruptibly() verwendet. Dies kann aber (wenn es blockiert ist) eine InterruptedException werfen. Daher muss der Aufruf von lockInterruptibly() in einem eigenen try-catch erfolgen. Das finally mit lock.unlock() ist hier nicht zutreffend, da in dem Fall der Lock noch nicht geschlossen wäre.
@Override
public void run() {
System.out.printf("%s: startup\n", getName());
while (!this.stop) {
try {
lock.lockInterruptibly();
try {
System.out.printf("%s: working\n", getName());
Thread.sleep(8000);
} catch (InterruptedException e) {
System.out.printf("%s: interrupted (inner)\n", getName());
} finally {
lock.unlock();
}
} catch (InterruptedException e) {
System.out.printf("%s: interrupted (outer)\n", getName());
}
}
System.out.printf("%s: finished\n", getName());
}Ein Testlauf ergibt höchstwahrtscheinlich den folgenden Trace:
T1: startup
T1: working
T2: startup
T3: startup
T2: killing...
T3: killing...
T1: killing...
T3: interrupted (outer)
T3: finished
T2: interrupted (outer)
T2: finished
T1: interrupted (inner)
T1: finished
Interpretation der Ausgabe
Hier hat sich eine andere Situation ergeben: T3 und T2 werden bereits interruptet, während Sie noch aufgrund des Locks blockiert sind. T1 ist hingegen nicht blockiert, sondern arbeitet (warten auf das Ende von sleep), wenn es den Interruot bekommt und tatsächlich abgebrichen wird.
Laboraufgabe “Vergleich von ReentrantLock, ReentrantReadWriteLock, StampedLock und synchronized”
Lösung für synchronized (pp.ExpSynchronized)
Die beiden abstrakten Methoden werden mit dem synchronized-Schlüsselwort threadsicher gemacht. Die kurze Implementierung des kritischen Abschnitts ist wie in ExpUnsynchronized.
package pp;
public class ExpSynchronized extends Experiment {
@Override
public synchronized void incCounter() {
this.counter++;
}
@Override
public synchronized int getCounter() {
return this.counter;
}
public static void main(String... args) throws InterruptedException {
(new ExpSynchronized()).experimentPar();
}
}Lombok-Variante: @Synchronized
package pp;
import lombok.Synchronized;
public class ExpSynchronizedLombok extends Experiment {
@Override
@Synchronized
public void incCounter() {
this.counter++;
}
@Override
@Synchronized
public int getCounter() {
return this.counter;
}
public static void main(String... args) throws InterruptedException {
(new ExpSynchronized()).experimentPar();
}
}Lösung für ReentrantLock (pp.ExpReentrantLock)
Das Benutzungmuster für ReentrantLock wird auf die beiden kritischen Abschnitte wie in ExpSynchronized angewendet. Dafür wird eine neue Instanzvariable vom Typ ReentrantLock namens lock eingeführt, die im Konstruktor initialisiert wird. Der Bool’sche Parameter beim Konstruktor von ReentrantLock bestimmt die Fairness beim Wechsel zum jeweils nächsten von mehreren an diesem Lock blockierten Threads.
unlock() sollte immer in einem finally-Block ausgeführt werden, um auch mit unchecked Exceptions, also solchen, die von RuntimeException erben, umgehen zu können.
package pp;
import java.util.concurrent.locks.ReentrantLock;
public class ExpReentrantLock extends Experiment {
private final ReentrantLock lock;
public ExpReentrantLock() {
this.lock = new ReentrantLock(false); // fair==true => (noch) langsamer
}
@Override
public void incCounter() {
this.lock.lock();
try {
this.counter++;
} finally {
this.lock.unlock();
}
}
@Override
public int getCounter() {
this.lock.lock();
try {
return this.counter;
} finally {
this.lock.unlock();
}
}
public static void main(String... args) throws InterruptedException {
(new ExpReentrantLock()).experimentPar();
}
}Lombok-Variante: @Locked
package pp;
import lombok.Locked;
public class ExpReentrantLockLombok extends Experiment {
@Override
@Locked
public void incCounter() {
this.counter++;
}
@Override
@Locked
public int getCounter() {
return this.counter;
}
public static void main(String... args) throws InterruptedException {
(new ExpReentrantLock()).experimentPar();
}
}Lösung für ReadWriteLock (pp.ExpReadWriteLock)
Es wird eine neue Instanzvariable vom Typ ReadWriteLock namens lock eingeführt, die im Konstruktor initialisiert wird. Der Bool’sche Parameter beim Konstruktor von ReadWriteLock bestimmt wieder die Fairness beim Wechsel zum jeweils nächsten von mehreren an diesem Lock blockierten Threads.
Zusätzlich gibt es zwei Instanzvariablen vom Typ Lock, die im Konstruktor vom ReadWriteLock geholt werden: Eine mit readLock(), die andere mit writeLock(). Beim Modifizieren der zu schützenden Variable counter in der Methode incCounter wird der Write-Lock beim Lesen in getCounter der Read-Lock benutzt.
unlock() (sowohl auf den Write-Lock, als auch auf den Read-Lock) sollte immer in einem finally-Block ausgeführt werden, um auch mit unchecked Exceptions, also solchen, die von RuntimeException erben, umgehen zu können.
package pp;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;;
public class ExpReadWriteLock extends Experiment {
private final ReadWriteLock lock;
private final Lock rLock;
private final Lock wLock;
public ExpReadWriteLock() {
this.lock = new ReentrantReadWriteLock(false); // fair==true => (noch)
// langsamer
this.rLock = this.lock.readLock();
this.wLock = this.lock.writeLock();
}
@Override
public void incCounter() {
this.wLock.lock();
try {
this.counter++;
} finally {
this.wLock.unlock();
}
}
@Override
public int getCounter() {
this.rLock.lock();
try {
return this.counter;
} finally {
this.rLock.unlock();
}
}
public static void main(String... args) throws InterruptedException {
(new ExpReadWriteLock()).experimentPar();
}
}Lombok-Variante: @Locked.Read/@Locked.Write
package pp;
import lombok.Locked;
public class ExpReadWriteLockLombok extends Experiment {
@Override
@Locked.Write
public void incCounter() {
this.counter++;
}
@Override
@Locked.Read
public int getCounter() {
return this.counter;
}
public static void main(String... args) throws InterruptedException {
(new ExpReadWriteLock()).experimentPar();
}
}Lösung für StampedLock (pp.ExpStampedLock)
java.util.concurrent.locks.StampedLock gibt es erst ab Java 8. Es wird eine neue Instanzvariable vom Typ StampedLock namens lock eingeführt, die im Konstruktor initialisiert wird.
Da die vor konkurrierenden Zugriffen zu schützende Variable counter in incCounter() verändert wird, muss hier der writeLock(...) zum Zugriffssschutz verwendet werden. In getCounter() hingegen kann optimistisches Lesen benutzt werden.
unlock() (sowohl auf den Write-Lock, als auch auf den Read-Lock) sollte immer in einem finally-Block ausgeführt werden, um auch mit unchecked Exceptions, also solchen, die von RuntimeException erben, umgehen zu können.
package pp;
import java.util.concurrent.locks.StampedLock;;
public class ExpStampedLock extends Experiment {
private final StampedLock lock;
public ExpStampedLock() {
this.lock = new StampedLock();
}
@Override
public void incCounter() {
var stamp = this.lock.writeLock();
try {
this.counter++;
} finally {
this.lock.unlockWrite(stamp);
}
}
@Override
public int getCounter() {
var stamp = this.lock.tryOptimisticRead();
var result = this.counter;
if (!this.lock.validate(stamp)) {
// nicht erfolgreich
stamp = this.lock.readLock();
try {
result = this.counter;
} finally {
this.lock.unlockRead(stamp);
}
}
return result;
}
public static void main(String... args) throws InterruptedException {
(new ExpStampedLock()).experimentPar();
}
}Laufzeitvergleich
Auf einem Aurora-DX 40 / Fedora Silverblue 40 Linux (6.11.3-200.fc40.x86_64) Rechner mit AMD Ryzen 7 5700G CPU bei 4,67 GHz ergeben sich mit OpenJDK 64-Bit Server VM Homebrew (build 23.0.1, mixed mode, sharing) folgende Beobachtungen zur Laufzeit (Median aus je drei Messungen):
| 10 Mio | 10 Mio | 100 Mio | 100 Mio | |
| 99,99% Read | 50% Read | 99,99% Read | 50% Read | |
synchronized |
1431ms | 961ms | 16416ms | 10125ms |
@Synchronized |
1653ms | 987ms | 14190ms | 9227ms |
ReentrantLock |
435ms | 416ms | 5775ms | 4664ms |
@Locked |
454ms | 466ms | 4718ms | 4245ms |
ReadWriteLock |
1409ms | 931ms | 17414ms | 8888ms |
@Locked.Read/@Locked.Write |
1466ms | 934ms | 16462ms | 9357ms |
StampedLock |
17ms | 188ms | 99ms | 2153ms |
Gegenseitiger Ausschluss mit ReentrantLock ist sehr effizient. StampedLock kann jedoch durch optimistisches Lesen noch vorteilhafter sein, wenn es selten ändernde Zugriffe gibt.
Die Lombok-Varianten @Synchronized statt synchronized, @Locked statt @ReentrantLock und @Locked.Read/@Locked.Write statt ReentrantReadWriteLock haben im Vergleich zu den manuell programmierten Versionen von der Laufzeit her keinen Nachteil, sind aber im Quellcode deutlicher, übersichtlicher, kürzer und damit weniger fehleranfällig.