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.