Musterlösungen

Laboraufgabe “Counter mit Atomics threadsicher machen”

Quellcode von ICounter

Das Interface ist hier nur dazu da, die Lösungen besser zu strukturieren.

package pp;

interface ICounter {

    void inc();

    void dec();

    int get();

}

Quellcode von CounterTest

Dieses Interface wird von einer Hilfsmethode in Test genutzt, um unterschiedliche Implementierungen von Counter zu prüfen.

private static void testIncrement(ICounter counter) {
    assertEquals(0, counter.get());
    var t1 = new Thread(() -> {
        for (var j = 0; j < RUNS; j++) {
            counter.inc();
        }
    });
    var t2 = new Thread(() -> {
        for (var j = 0; j < RUNS; j++) {
            counter.inc();
        }
    });
    t1.start();
    t2.start();
    try {
        t1.join();
        t2.join();
    } catch (InterruptedException ex) {
        Thread.currentThread().interrupt();
    }
    assertEquals(RUNS * 2, counter.get());
}

In dieser Methode werden zwei Threads gestartet, die beide nebenläufig auf eine Instanz der als Parameter übergebenen ICounter-Implementerung zugreifen und ständig inc() daran aurufen. Am Ende, wenn beide Threads fertig sind, wird mit get() auf das ICounter-Objekt zugegriffen un der dort abgerufene Wert der Instanzvariable mit dem erwarteten verglichen.

Wenn Transaktionen (inc) sich fälschlicherweise überlappt haben, kann es dazu kommen, dass die dort abgerufene Zahl nicht der erwarteten entspricht.

In Test werden unterschiedliche Implementierungen von ICounter auf diese Art getestet. Diese Unit-Tests selber sind immer gleich aufgebaut:

@Test
void testCounterIncrement() throws InterruptedException {
    testIncrement(new Counter(0));
}

Threadsicherheit mit synchronized

Die Threadsicherheit kann leicht als Intrinsic Lock mit dem synchronized-Schlüsselwort hergestellt werden:

package pp;

class SynchronizedCounter implements ICounter {
    private int c;

    public SynchronizedCounter(int init) {
        this.c = init;
    }

    @Override
    public synchronized void inc() {
        this.c++;
    }

    @Override
    public synchronized void dec() {
        this.c--;
    }

    @Override
    public synchronized int get() {
        return this.c;
    }
}

Der Test dazu in Test:

@Test
void testSynchronizedCounterIncrement() throws InterruptedException {
    testIncrement(new SynchronizedCounter(0));
}

Der befürchtete Nachteil, dass die beiden Threads fast gar nicht mehr parallel arbeiten können, bewahrheitet sich nicht bzw. die Implementierung von synchronized ist sehr effizient. Die Laufzeit auf einem Core i5-Rechner ist 45 ms.

Threadsicherheit durch AtomicInteger und compareAndSet

Statt in einem Bereich mit gegenseitigem Ausschluss mit ++ und -- zuzugreifen wird compareAndSet benutzt.

package pp;

import java.util.concurrent.atomic.AtomicInteger;

class AtomicCounter1 implements ICounter {
    private final AtomicInteger c;

    public AtomicCounter1(int init) {
        this.c = new AtomicInteger(init);
    }

    @Override
    public void inc() {
        var temp = this.c.get();
        while (!this.c.compareAndSet(temp, temp + 1)) {
            temp = this.c.get();
        }
    }

    @Override
    public void dec() {
        var temp = this.c.get();
        while (!this.c.compareAndSet(temp, temp - 1)) {
            temp = this.c.get();
        }
    }

    @Override
    public int get() {
        return this.c.get();
    }
}

Der Test dazu in Test:

@Test
void testAtomicCounter1Increment() throws InterruptedException {
    testIncrement(new AtomicCounter1(0));
}

Die Laufzeit auf einem Core i5-Rechner ist 65 ms.

Threadsicherheit durch AtomicInteger und incrementAndGet bzw. decrementAndGet

Statt in einem Bereich mit gegenseitigem Ausschluss mit ++ und -- zuzugreifen wird incrementAndGet bzw. decrementAndGetbenutzt.

package pp;

import java.util.concurrent.atomic.AtomicInteger;

class AtomicCounter2 implements ICounter {
    private final AtomicInteger c;

    public AtomicCounter2(int init) {
        this.c = new AtomicInteger(init);
    }

    @Override
    public void inc() {
        this.c.incrementAndGet();
    }

    @Override
    public void dec() {
        this.c.decrementAndGet();
    }

    @Override
    public int get() {
        return this.c.get();
    }
}

Der Test dazu in Test:

@Test
void testAtomicCounter2Increment() throws InterruptedException {
    testIncrement(new AtomicCounter2(0));
}

Die Laufzeit auf einem Core i5-Rechner ist 31 ms.

Weitere Lösungsmöglichkeit für Threadsicherheit durch AtomicInteger

Diese Lösung ist analog zu AtomicCounter1, jedoch sind bei dieser Implementierung die Schleifen etwas anders gebaut, um quasi Quelltext-Gleichheit mit der internen Implementierung von incrementAndGet() bzw. decrementAndGet() zu erreichen1.

package pp;

import java.util.concurrent.atomic.AtomicInteger;

class AtomicCounter3 implements ICounter {
    private final AtomicInteger c;

    public AtomicCounter3(int init) {
        this.c = new AtomicInteger(init);
    }

    @Override
    public void inc() {
        for (;;) {
            var current = this.c.get();
            var next = current + 1;
            if (this.c.compareAndSet(current, next)) {
                break;
            }
        }
    }

    @Override
    public void dec() {
        for (;;) {
            var current = this.c.get();
            var next = current - 1;
            if (this.c.compareAndSet(current, next)) {
                break;
            }
        }
    }

    @Override
    public int get() {
        return this.c.get();
    }
}

Der Test dazu in Test:

@Test
void testAtomicCounter3Increment() throws InterruptedException {
    testIncrement(new AtomicCounter3(0));
}

Die Laufzeit auf einem Core i5-Rechner ist 58 ms.

Eine weitere Variante mit übergebenen Funktionen in Form von Lambda-Ausdrücken ist in AtomicCounter4:

package pp;

import java.util.concurrent.atomic.AtomicInteger;

class AtomicCounter4 implements ICounter {
    private final AtomicInteger c;

    public AtomicCounter4(int init) {
        this.c = new AtomicInteger(init);
    }

    @Override
    public void inc() {
        this.c.updateAndGet(i -> i + 1);
    }

    @Override
    public void dec() {
        this.c.updateAndGet(i -> i - 1);
    }

    @Override
    public int get() {
        return this.c.get();
    }
}

Der Test dazu in Test:

@Test
void testAtomicCounter4Increment() throws InterruptedException {
  testIncrement(new AtomicCounter4(0));
}

Die Laufzeit auf einem Core i5-Rechner ist 43 ms.

Weitere Tests

In der zweiten Hälfte von Test wird dazu analog das nebenläufige Dekrementieren getestet:

private static void testDecrement(ICounter counter) {
    assertEquals(RUNS * 2, counter.get());
    var t1 = new Thread(() -> {
        for (var j = 0; j < RUNS; j++) {
            counter.dec();
        }
    });
    var t2 = new Thread(() -> {
        for (var j = 0; j < RUNS; j++) {
            counter.dec();
        }
    });
    t1.start();
    t2.start();
    try {
        t1.join();
        t2.join();
    } catch (InterruptedException ex) {
        Thread.currentThread().interrupt();
    }
    assertEquals(0, counter.get());
}

@Test
void testCounterDecrement() throws InterruptedException {
    testDecrement(new Counter(RUNS * 2));
}

@Test
void testSynchronizedCounterDecrement() throws InterruptedException {
    testDecrement(new SynchronizedCounter(RUNS * 2));
}

@Test
void testAtomicCounter1Decrement() throws InterruptedException {
    testDecrement(new AtomicCounter1(RUNS * 2));
}

@Test
void testAtomicCounter2Decrement() throws InterruptedException {
    testDecrement(new AtomicCounter2(RUNS * 2));
}

@Test
void testAtomicCounter3Decrement() throws InterruptedException {
    testDecrement(new AtomicCounter3(RUNS * 2));
}

@Test
void testAtomicCounter4Decrement() throws InterruptedException {
    testDecrement(new AtomicCounter4(RUNS * 2));
}

  1. s. Implementierung von AtomicInteger im OpenJDK Github-Repository: https://github.com/openjdk-mirror/jdk7u-jdk/blob/master/src/share/classes/java/util/concurrent/atomic/AtomicInteger.java (letzter Zugriff: 23.10.2024)↩︎