详解并发编程的CAS问题 && Synchronized优化

volatile自己虽然不能保证原子性,但是和CAS结合起来就可以保证原子性了。CAS+volatile一起用就可以同时解决并发编程中的三个问题了,保证并发安全。

CAS 是什么?

  • CAS:比较并交换compareAndSet,它是一条 CPU 并发原语,它的功能是判断内存某个位置的值是否为预期值,如果是则更改为新的值,这个过程是原子性的。

  • 例: AtomicInteger 的 compareAndSet('期望值','设置值') 方法,期望值与目标值一致时,修改目标变量为设置值,期望值与目标值不一致时,返回 false 和最新主存的变量值

  • CAS 的底层原理 例: AtomicInteger.getAndIncrement() 调用 Unsafe 类中的 CAS 方法,JVM 会帮我们实现出 CAS 汇编指令 这是一种完全依赖于硬件的功能,通过它实现原子操作。 原语的执行必须是连续的,在执行过程中不允许被中断,CAS 是 CUP 的一条原子指令。

  • CAS的思想就是乐观锁的思想

AtomicInteger

在JUC并发包中,CAS和AtomicInteger(原子类的value值都被volatile修饰了)一起保证了并发安全。下面我们以AtomicInteger.getAndIncrement() 方法讲一下。

/**
 * unsafe: rt.jar/sun/misc/Unsafe.class
 *   Unsafe 是 CAS 的核心类,由于 Java 无法直接访问底层系统,需要通过本地<native>方法来访问
 *	 Unsafe 相当于一个后门,基于该类可以直接操作特定内存的数据
 *	 Unsafe 其内部方法都是 native 修饰的,可以像 C 的指针一样直接操作内存
 *	 Java 中的 CAS 操作执行依赖于 Unsafe 的方法,直接调用操作系统底层资源执行程序
 *
 * this: 当前对象
 *	 变量 value 由 volatile 修饰,保证了多线程之间的内存可见性、禁止重排序
 *
 * valueOffset: 内存地址
 *	 表示该变量值在内存中的偏移地址,因为 Unsafe 就是根据内存偏移地址获取数据
 *
 * 1: 固定写死,原值加1
 */
public final int getAndIncrement(){
    return unsafe.getAndAddInt(this,valueOffset,1);
}

/**
 * Unsafe.getAndAddInt()
 * getIntVolatile: 通过内存地址去主存中取对应数据
 * 
 * while(!this.compareAndSwapInt(var1,var2,var5,var5 + var4)):
 * 	 将本地 value 与主存中取出的数据对比,如果相同,对其作运算,
 * 		此时返回 true,取反后 while 结束,返回最终值。
 * 	 如果不相同,此时返回 false,取反后 while 循环继续运行,此时为自旋锁<重复尝试>
 *		由于 value 是被 volatile 修饰的,所以拿到主存中最新值,再循环直至成功。
 */
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;
}

CAS 代码演示

package com.allen;

import java.util.concurrent.atomic.AtomicInteger;

/**
 * @author :jhys
 * @date :Created in 2021/7/21 10:31
 * @Description :
 */
public class CASDemo {
    public static void main(String[] args) {
        AtomicInteger num = new AtomicInteger(5);
        System.out.println(num.compareAndSet(5, 1024) + "\t current num " + num.get());
        System.out.println(num.compareAndSet(5, 2019) + "\t current num " + num.get());
    }
}

 输出结果:

true     current num 1024
false     current num 1024

CAS三大问题

  • 如果 CAS 长时间一直不成功,会给 CPU 带来很大的开销,在Java的实现中是一直通过while循环自旋CAS获取锁。

  • 只能保证一个共享变量的原子操作

  • 引出了 ABA 问题

ABA问题

什么是ABA问题?

package com.allen;

import java.util.concurrent.atomic.AtomicReference;

/** CAS引起的ABA问题
 * @author :jhys
 * @date :Created in 2021/7/21 10:58
 * @Description :
 */
public class CAS_ABA {

    static AtomicReference<Integer> num = new AtomicReference<>(100);

    public static void main(String[] args) {
        new Thread(() -> {
            num.compareAndSet(100, 101);
            num.compareAndSet(101, 100);
        }, "Thread A").start();

        new Thread(() -> {
            try {
                //保证A线程已经修改完
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            boolean b = num.compareAndSet(100, 2019);
            System.out.println(b + "\t 当前最新值" + num.get().toString());
        },"Thread B").start();
    }
}

结果:

true     当前最新值2019

CAS 会导致 ABA 问题:

例: A、B线程从主存取出变量 value

-> A 在 N次计算中改变 value 的值 -> A 最终计算结果还原 value 最初的值 -> B 计算后,比较主存值与自身 value 值一致,修改成功

尽管各个线程的 CAS 都操作成功,但是并不代表这个过程就是没有问题的。

package com.allen;

import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicStampedReference;

/**
 * @author :jhys
 * @date :Created in 2021/7/21 11:07
 * @Description :
 */
public class CAS_ABA2 {

    static AtomicStampedReference<Integer> num = new AtomicStampedReference<>(100, 1);

    public static void main(String[] args) {
        int stamp = num.getStamp();

        new Thread(() -> {
            num.compareAndSet(100, 101, num.getStamp(), num.getStamp() + 1);
            System.out.println(Thread.currentThread().getName() + "\t 版本号" + num.getStamp());
            num.compareAndSet(101,100,num.getStamp(),num.getStamp() + 1);
            System.out.println(Thread.currentThread().getName() + "\t 版本号" + num.getStamp());
        }, "Thread A").start();

        new Thread(() ->{
            try {
                TimeUnit.SECONDS.sleep(3);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            boolean b = num.compareAndSet(100, 209, stamp, num.getStamp() + 1);
            System.out.println(b + "\t 当前版本号: \t" + num.getStamp());
            System.out.println("当前最新值 \t" + num.getReference().toString());
        },"线程B").start();
    }
}

思想很简单,可以很明显的看出来用版本号的方式解决了ABA的问题。

  • 除了对象值,AtomicStampedReference内部还维护了一个“状态戳”。
  • 状态戳可类比为时间戳,是一个整数值,每一次修改对象值的同时,也要修改状态戳,从而区分相同对象值的不同状态。
  • 当AtomicStampedReference设置对象值时,对象值以及状态戳都必须满足期望值,写入才会成功。

只能保证一个共享变量的原子操作

  • 当对一个共享变量执行操作时,我们可以使用循环CAS的方式来保证原子操作,但是多个共享变量操作时,循环CAS就无法保证操作的原子性,这个时候就可以用锁。还有一个方法,就是把多个共享变量合并成一个共享变量来操作。比如,有两个共享变量i=2,j=a合并一下ij=2a,然后用CAS来操作ij。从java1.5开始,JDK提供了AtomicReference类来保证引用对象之间的原子性,就可以把多个变量放在一个对象里来进行CAS操作。
  • 所以一般来说为了同时解决ABA问题和只能保证一个共享变量,原子类使用时大部分使用的是AtomicStampedReference

UnSafe

Unsafe类是在sun.misc包下,不属于Java标准。但是很多Java的基础类库,包括一些被广泛使用的高性能开发库都是基于Unsafe类开发的,比如Netty、Cassandra、Hadoop、Kafka等。Unsafe类在提升Java运行效率,增强Java语言底层操作能力方面起了很大的作用。

Java和C++语言的一个重要区别就是Java中我们无法直接操作一块内存区域,不能像C++中那样可以自己申请内存和释放内存。 Java中的Unsafe类为我们提供了类似C++手动管理内存的能力,同时也有了指针的问题。

首先,Unsafe类是"final"的,不允许继承。且构造函数是private的:

public final class Unsafe {
    private static final Unsafe theUnsafe;
    public static final int INVALID_FIELD_OFFSET = -1;

    private static native void registerNatives();

    private Unsafe() {
    }
    ...

}

因此我们无法在外部对Unsafe进行实例化。

获取Unsafe

Unsafe无法实例化,那么怎么获取Unsafe呢?答案就是通过反射来获取Unsafe:

public Unsafe getUnsafe() throws IllegalAccessException {
    Field unsafeField = Unsafe.class.getDeclaredFields()[0];
    unsafeField.setAccessible(true);
    Unsafe unsafe = (Unsafe) unsafeField.get(null);
    return unsafe;
}

CAS相关

JUC中大量运用了CAS操作,可以说CAS操作是JUC的基础,因此CAS操作是非常重要的。Unsafe中提供了int,long和Object的CAS操作:

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

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

public final native boolean compareAndSwapLong(Object var1, long var2, long var4, long var6);

偏移量相关

public native long staticFieldOffset(Field var1);

public native long objectFieldOffset(Field var1);

  • staticFieldOffset方法用于获取静态属性Field在对象中的偏移量,读写静态属性时必须获取其偏移量。
  • objectFieldOffset方法用于获取非静态属性Field在对象实例中的偏移量,读写对象的非静态属性时会用到这个偏移量

类加载

public native Class<?> defineClass(String var1, byte[] var2, int var3, int var4, ClassLoader var5, ProtectionDomain var6);

public native Class<?> defineAnonymousClass(Class<?> var1, byte[] var2, Object[] var3);

public native Object allocateInstance(Class<?> var1) throws InstantiationException;

public native boolean shouldBeInitialized(Class<?> var1);

public native void ensureClassInitialized(Class<?> var1);
  • defineClass方法定义一个类,用于动态地创建类。
  • defineAnonymousClass用于动态的创建一个匿名内部类。
  • allocateInstance方法用于创建一个类的实例,但是不会调用这个实例的构造方法,如果这个类还未被初始化,则初始化这个类。
  • shouldBeInitialized方法用于判断是否需要初始化一个类。
  • ensureClassInitialized方法用于保证已经初始化过一个类。

synchronized优化

synchronized可以同时保证可见性,有序性,原子性。这个东西就不讲了

从JDk 1.6开始,JVM就对synchronized锁进行了很多的优化。synchronized说是锁,但是他的底层加锁的方式可能不同,偏向锁的方式来加锁,自旋锁的方式来加锁,轻量级锁的方式来加锁

锁消除

锁消除是JIT编译器对synchronized锁做的优化,在编译的时候,JIT会通过逃逸分析技术,来分析synchronized锁对象,是不是只可能被一个线程来加锁,没有其他的线程来竞争加锁,这个时候编译就不用加入monitorenter和monitorexit的指令。这就是,仅仅一个线程争用锁的时候,就可以消除这个锁了,提升这段代码的执行的效率,因为可能就只有一个线程会来加锁,不涉及到多个线程竞争锁

锁粗化

synchronized(this) {

}

 

synchronized(this) { 

}

 

synchronized(this) {

}

这个意思就是,JIT编译器如果发现有代码里连续多次加锁释放锁的代码,会给合并为一个锁,就是锁粗化,把一个锁给搞粗了,避免频繁多次加锁释放锁

偏向锁

这个意思就是说,monitorenter和monitorexit是要使用CAS操作加锁和释放锁的,开销较大,因此如果发现大概率只有一个线程会主要竞争一个锁,那么会给这个锁维护一个偏好(Bias),后面他加锁和释放锁,基于Bias来执行,不需要通过CAS,性能会提升很多。但是如果有偏好之外的线程来竞争锁,就要收回之前分配的偏好。可能只有一个线程会来竞争一个锁,但是也有可能会有其他的线程来竞争这个锁,但是其他线程唉竞争锁的概率很小。如果有其他的线程来竞争这个锁,此时就会收回之前那个线程分配的那个Bias偏好

轻量级锁

如果偏向锁没能成功实现,就是因为不同线程竞争锁太频繁了,此时就会尝试采用轻量级锁的方式来加锁,就是将对象头的Mark Word里有一个轻量级锁指针,尝试指向持有锁的线程,然后判断一下是不是自己加的锁,如果是自己加的锁,那就执行代码就好了。如果不是自己加的锁,那就是加锁失败,说明有其他人加了锁,这个时候就是升级为重量级锁

适应性锁

这是JIT编译器对锁做的另外一个优化,如果各个线程持有锁的时间很短,那么一个线程竞争锁不到,就会暂停,发生上下文切换,让其他线程来执行。但是其他线程很快释放锁了,然后暂停的线程再次被唤醒。也就是说在这种情况下,线程会频繁的上下文切换,导致开销过大。所以对这种线程持有锁时间很短的情况,是可以采取忙等策略的,也就是一个线程没竞争到锁,进入一个while循环不停等待,不会暂停不会发生线程上下文切换,等到机会获取锁就继续执行好了

参考资料:https://github.com/youthlql/JavaYouth/tree/3c12ef45858d2d9e1af348bccb5eb8cd4a510fdc

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值