[Big Data Java Foundation - Java Concurrency 02] Deep Analysis of CAS

CAS, Compare And Swap, that is, compare and exchange. Doug lea uses CAS technology to implement concurrent Java multi-threaded operations in a number of synchronization components. The entire AQS synchronization component, Atomic atom class operations, and so on, are implemented in CAS, and even ConcurrentHashMap was adjusted to CAS+Synchronized in version 1.8. It can be said that CAS is the cornerstone of the entire JUC.

CAS analysis

  There are three parameters in CAS: the memory value V, the old expected value A, and the value B to be updated. If and only if the memory value V equals the old expected value A, the memory value V will be changed to B, otherwise nothing will be done. Its pseudocode is as follows:

if(this.value == A){
	this.value = B
	return true;
}else{
	return false;
}

The atomic classes under JUC are all implemented through CAS. The following is an example of AtomicInteger to illustrate the implementation of CAS. The following:

private static final Unsafe unsafe = Unsafe.getUnsafe();
    private static final long valueOffset;

    static {
        try {
            valueOffset = unsafe.objectFieldOffset
                (AtomicInteger.class.getDeclaredField("value"));
        } catch (Exception ex) { throw new Error(ex); }
    }

    private volatile int value;

Unsafe is the core class of CAS, and Java does not have direct access to the underlying operating system. Instead, it is accessed locally. Nevertheless, the JVM opens a back door: Unsafe, which provides hardware-level atomic operations.

valueOffset is the offset address of the variable value in memory, and unsafe is the offset address used to get the original value of the data.

The current value of value, decorated with volatile, ensures that the same is seen in a multithreaded environment.

Let's illustrate this with AtomicInteger's addAndGet() method, starting with the source code:

public final int addAndGet(int delta) {
        return unsafe.getAndAddInt(this, valueOffset, delta) + delta;
    }

    public final int getAndAddInt(Object var1, long var2, int var4) {
        int var5;
        do {
            var5 = this.getIntVolatile(var1, var2);
        } while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));

        return var5;
    }

Internally, unsafe's getAndAddInt method is called. In getAndAddInt method, we mainly look at compareAndSwapInt method:

public final native boolean compareAndSwapInt(Object var1, long var2, int var4, int var5);

This is a local method with four parameters: the object, the address of the object, the expected value, and the modified value (a partner told me what these four variables mean when he interviewed...++). The implementation of this method is not detailed here, and interested partners can see the source code for openjdk.

CAS guarantees that one read-rewrite operation is an atomic operation, which is easy to implement on a single processor, but slightly more complex to implement on multiple processors.

The CPU provides two ways to perform atomic operations on multiprocessors: bus or cache locking.

Bus Locking: Bus Locking is the use of a LOCK#signal provided by a processor. When a processor outputs this signal on the bus, requests from other processors will be blocked, and the processor can use shared memory exclusively. However, this is a bit of a domineering and unfair way of handling it. It locks communication between the CPU and memory, during which no other processor can handle data from other memory addresses, which is a little expensive. So there is a cache lock.

Cache Locking: In fact, for that situation, we just need to ensure that the operation on a memory address is atomic at the same time. Cache locking refers to data cached in the memory area. When it writes back to memory during a lock operation, the processor does not output the LOCK#signal, but modifies the internal memory address to ensure atomicity using the cache consistency protocol. The cache consistency mechanism ensures that only one processor can modify the data in the same memory area, that is, when CPU1 modifies the I in the cache row using cache locking, CPU2 cannot cache the i's cache row at the same time.

CAS Defects

CAS solves atomic operations efficiently, but there are still some drawbacks, mainly in three ways: too long cycle time, only one shared variable atomic operation can be guaranteed, ABA problem.

Too long cycle time

What if CAS has been unsuccessful? It is absolutely possible that this will happen, and if spin CAS is unsuccessful for a long time, it will cause very large CPU overhead. There are places in the JUC that limit the number of CAS spins, such as the SynchronousQueue of BlockingQueue.

Only one shared variable atomic operation is guaranteed

If you look at the implementation of CAS, you know that only one shared variable can be used. If you have multiple shared variables, you can only use locks. Of course, if you have a way to integrate multiple variables into one variable, CAS is also good. For example, the high status of state in read-write locks.

ABA Question

CAS needs to check if the operation values have changed and update if they have not. But there is a situation where if a value is A, B, then A, then there will be no change during the CAS check, but essentially it has changed, which is the so-called ABA problem. The solution to the ABA problem is to add a version number, that is, to add a version number to each variable, and to add 1 for each change, that is, A-> B-> A, to 1A-> 2B-> 3A.

Use an example to illustrate the impact of ABA problems.

There are the following chains

Suppose we want to replace B with A, which is compareAndSet(this,A,B). Thread 1 performs B instead of A. Thread 2 mainly performs the following actions. A and B go out of the stack, then C and A go into the stack. Finally, the list of chains is as follows:

 

Thread 1 finds that thread 1 is still A, then compareAndSet(this,A,B) succeeds, but there is a problem when B.next = null,compareAndSet(this,A,B) causes C to be lost, the stack has only one B element, and C is lost for no reason.

The solution to the underlying ABA problem in CAS is the version number, and Java provides AtomicStampedReference to solve it. AtomicStampedReference avoids ABA problems by marking version stamp s on objects by wrapping tuples of [E,Integer]. Thread 1 should fail for the above case.

The compareAndSet() method of AtomicStampedReference is defined as follows:

 public boolean compareAndSet(V   expectedReference,
                                 V   newReference,
                                 int expectedStamp,
                                 int newStamp) {
        Pair<V> current = pair;
        return
            expectedReference == current.reference &&
            expectedStamp == current.stamp &&
            ((newReference == current.reference &&
              newStamp == current.stamp) ||
             casPair(current, Pair.of(newReference, newStamp)));
    }

  The compareAndSet has four parameters, which represent the expected reference, the updated reference, the expected flag, and the updated flag. The Source Department understands well that the expected reference==the current reference, the expected identity==the current identity, and returns true directly if the updated reference and flag are equal to the current reference and flag, otherwise a new pair object is generated through Pair to replace the current pair CAS. Pair is an internal class of AtomicStampedReference that records reference and version stamp information (identities), defined as follows:

 private static class Pair<T> {
        final T reference;
        final int stamp;
        private Pair(T reference, int stamp) {
            this.reference = reference;
            this.stamp = stamp;
        }
        static <T> Pair<T> of(T reference, int stamp) {
            return new Pair<T>(reference, stamp);
        }
    }

    private volatile Pair<V> pair;

Pair keeps track of object references and version stamps, which are int s and grow by themselves. Pair is also an immutable object whose properties are all defined as final, providing an of method that returns a newly created Pari s object. The pair object is defined as volatile to ensure visibility in a multithreaded environment. In AtomicStampedReference, most methods generate a new Pair object by calling Pair's of method and assign it to the variable pair. For example, set method:

 public void set(V newReference, int newStamp) {
        Pair<V> current = pair;
        if (newReference != current.reference || newStamp != current.stamp)
            this.pair = Pair.of(newReference, newStamp);
    }

Here's an example to show the difference between AtomicStampedReference and AtomicInteger. We define two threads, Thread 1 responsible for executing 100 -> 110 -> 100 and Thread 2 executing 100 -> 120, to see the difference between the two.

public class Test {
    private static AtomicInteger atomicInteger = new AtomicInteger(100);
    private static AtomicStampedReference atomicStampedReference = new AtomicStampedReference(100,1);

    public static void main(String[] args) throws InterruptedException {

        //AtomicInteger
        Thread at1 = new Thread(new Runnable() {
            @Override
            public void run() {
                atomicInteger.compareAndSet(100,110);
                atomicInteger.compareAndSet(110,100);
            }
        });

        Thread at2 = new Thread(new Runnable() {
            @Override
            public void run() {
                try {
                    TimeUnit.SECONDS.sleep(2);      // at1, done executing
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println("AtomicInteger:" + atomicInteger.compareAndSet(100,120));
            }
        });

        at1.start();
        at2.start();

        at1.join();
        at2.join();

        //AtomicStampedReference

        Thread tsf1 = new Thread(new Runnable() {
            @Override
            public void run() {
                try {
                    //Let tsf2 get stamp first, resulting in inconsistent expected timestamps
                    TimeUnit.SECONDS.sleep(2);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                // Expected reference: 100, Updated reference: 110, Expected identity getStamp() Updated identity getStamp() + 1
                atomicStampedReference.compareAndSet(100,110,atomicStampedReference.getStamp(),atomicStampedReference.getStamp() + 1);
                atomicStampedReference.compareAndSet(110,100,atomicStampedReference.getStamp(),atomicStampedReference.getStamp() + 1);
            }
        });

        Thread tsf2 = new Thread(new Runnable() {
            @Override
            public void run() {
                int stamp = atomicStampedReference.getStamp();

                try {
                    TimeUnit.SECONDS.sleep(2);      //Thread tsf1 finished executing
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println("AtomicStampedReference:" +atomicStampedReference.compareAndSet(100,120,stamp,stamp + 1));
            }
        });

        tsf1.start();
        tsf2.start();
    }

}

Run result:

The results fully demonstrate the ABA problem of AtomicInteger and the ABA problem solved by AtomicStampedReference.

 

Tags: Java Big Data

Posted on Tue, 26 Oct 2021 12:38:43 -0400 by jlryan