0

My problem is:

I have class:

public class AtomicStringBuilder {
    private final AtomicReference<StringBuilder> sbRef;
}

I need to add new characters to StringBuilder concurrently and atomically. But problem is, only last 128 characters should be in this object. I can't use StringBuffer, because operation should be non-blocking.

So,there are two operations:

First: check if StringBuilder already has 128 chars.

Second: if it has not -> add new char, if it has -> delete first char and add new char.

Is there a way to make this two or three operations atomic?

I made this method, but it doesn't work:

public void append(String string) {
        this.sbRef.getAndUpdate(ref -> {
            if (ref.length() < 128) {
                ref.append(string);
            } else {
                ref.append(string).delete(0, ref.length() - 128);
            }
            return ref;
        });
    }

For testing I created this method:

public void test() {
AtomicStringBuilder atomicStringBuilder = new AtomicStringBuilder();
Random random = new Random();
Stream<Integer> infiniteStream = Stream.iterate(0, i -> random.nextInt(10));

infiniteStream.parallel()
.limit(100000)
.forEach(integer -> atomicStringBuilder.append(String.valueOf(integer)));

assertEquals(128, atomicStringBuilder.getSb().get().length());
}

This is not a real prolem, I can change AtomicReference with anything else which will work. The task is to create operation that will be lock-free and without race conditions

  • Make `append` `synchronized`. – tgdavies Dec 13 '21 at 06:51
  • I can't. As I said, operation should be lock-free, so "synchronized" is not allowed – AlchemistAction Dec 13 '21 at 06:54
  • 2
    Is this an assignment or a real world problem? AtomicReference won't work because your function uses a side-effect instead of returning a new reference. – tgdavies Dec 13 '21 at 07:01
  • Its assignment and I can change everything here. I only need String with 128 chars in the end, and I need do it without race conditions and locks – AlchemistAction Dec 13 '21 at 07:05
  • 2
    You can't even modify a StringBuilder across multiple threads without locking, without a race condition. If you tried, you wouldn't establish a happens-before relationship with the action. The two main ways to establish happens-before are with locks (which you say you can't use) or volatile writes (which wouldn't be atomic with respect to the StringBuilder actions, even if it were only one action per logical mutation). Do you have to use a StringBuilder at all, or could you do a compareAndSet with immutable Strings? – yshavit Dec 13 '21 at 07:16
  • Yes, I can use Strings, but I thought it would be bad idea in terms of performance, because with every iteration new String object would be created – AlchemistAction Dec 13 '21 at 07:20
  • 2
    It would be, yes; but if you want lock-free, that's really the best approach. There _may_ be other possibilities, but they'd be very complex, and probably slower and buggier in the end. Often with multithreading, you have to take some single-thread performance hits for the sake of increased parallelism. This is almost definitely one such case. – yshavit Dec 13 '21 at 07:24

1 Answers1

3

Here's a solution with immutable Strings.

If you use AtomicReference you need to return a new reference rather than mutating the object the reference points to. Atomically comparing the current and expected value of the reference is the only way to know that it hasn't been updated by another thread.

getAndUpdate does this:

  1. get the current reference
  2. apply the lambda to the reference, getting a new reference
  3. if the current reference hasn't changed, atomically set it to the new reference, otherwise go back to 1.
public class App {
    static class AtomicStringBuilder {
        public final AtomicInteger counter = new AtomicInteger();

        public final AtomicReference<String> sbRef = new AtomicReference<>("");

        public void append(String string) {
            this.sbRef.getAndUpdate(ref -> {
                counter.getAndIncrement();
                if (ref.length() < 128) {
                    return ref + string;
                } else {
                    String s = ref + string;
                    return s.substring(s.length() - 128);
                }
            });
        }
    }

    static void test() {
        AtomicStringBuilder atomicStringBuilder = new AtomicStringBuilder();
        Random random = new Random();
        Stream<Integer> infiniteStream = Stream.iterate(0, i -> random.nextInt(10));

        infiniteStream.parallel()
                .limit(100000)
                .forEach(integer -> atomicStringBuilder.append(String.valueOf(integer)));

        if (128 != atomicStringBuilder.sbRef.get().length()) {
            System.out.println("failed ");
        }
        System.out.println(atomicStringBuilder.sbRef.get());
        System.out.println(atomicStringBuilder.counter.get());
    }

    public static void main(String[] args) {
        test();
    }
}

I've added a counter to the lambda. The value it shows after running this program will be more than 100,000, because concurrent updates forced retries.

tgdavies
  • 10,307
  • 4
  • 35
  • 40
  • 1
    `getAndAccumulate` may be a slightly nicer option. You could implement a package-private `String appendAndTruncate(String previous, String toAppend)` which you'd separately unit test, and then the public append is just `atomicRef.getAndAccumulate(string, this::appendAndTruncate)`. – yshavit Dec 13 '21 at 07:30
  • Thank you for answer. Could you give me a little explanation on how lambda in atomics work. Its not about this example, but in general. For example, there is huge lambda and only in the end of it a CAS operation. If a thread fails to pass CAS, it goes to a loop. But does it go through the hole lambda, or only CAS section? – AlchemistAction Dec 13 '21 at 07:31
  • 2
    @AlchemistAction The Javadoc tells you: "The function should be side-effect-free, since it may be re-applied when attempted updates fail due to contention among threads." There is no CAS in the lambda itself: the lambda returns a brand new value, and if the CAS fails, the method will get the latest value and re-apply the [whole] lambda. – yshavit Dec 13 '21 at 07:35