JUC并发编程系列(二):多线程并发、CAS、锁

前言

        在这篇文章中,荔枝将主要梳理在JUC并发学习中的有关Java多线程中有关共享变量的内存可见性、原子性问题、指令重排问题以及伪共享问题。希望能够对正在学习的小伙伴有帮助~~~


文章目录

前言

一、多线程并发与内存可见性问题的引入

1.1 并发和并行

1.2 多线程并发的场景引入以及带来的问题

1.3 共享变量的内存可见性问题

二、synchronized和volatile关键字

2.1 synchronized

2.2 volatile

三、CAS和Unsafe类

3.1 CAS原子操作

3.2 Unsafe类

四、指令重排序

五、伪共享问题 

总结


一、多线程并发与内存可见性问题的引入

1.1 并发和并行

        并发指的是在一段时间内同时处理多个线程任务,而并行指的是在单位时间内同时处理多个线程任务,这两个概念主要的区别在时间的尺度上面。我们知道在单核CPU上执行不同的线程任务会由于频繁的线程上下文的切换而导致性能的下降,而随着硬件资源的发展,多核CPU无疑提供了一种并行处理任务的机制,极大减少了线程上下文切换的开销。但线程任务数总是远大于硬件资源的CPU核数的,所以并发的使用相较于并行更多!因此我们总是听到一个词:多线程并发。

1.2 多线程并发的场景引入以及带来的问题

        多线程并发的场景是十分常见的,尤其是在高并发的场景下,但并发带来性能的提升的同时也会导致线程安全问题的出现。所谓的线程安全问题就是当多个线程同时去读写一个共享资源的同时不采用同步机制,从而导致出现脏数据等问题。线程安全问题很明显不是针对线程,而是一个内存中数据安全的概念,出现的主要原因就是修改数据的操作并不是原子操作,可能会被其它的线程修改数据。因此为了保证线程安全,我们要保证读写操作的原子性,通常我们采用加锁的机制来解决线程安全的问题。

1.3 共享变量的内存可见性问题

在梳理内存可见性问题之前我们需要了解一下CPU的系统架构:

JUC并发编程系列(二):多线程并发、CAS、锁_第1张图片

        我们可以看到上图中一级缓存Cache1和二级缓存Cache2就相当于是线程的工作内存,其中Cache1是核私有的,而Cache2是CPU共享的。在Java内存模型的规定下,我们可以来看这么一个读写数据的场景:线程A要修改一个共享变量x,由于两级缓存都没有命中,所以会从主内存中去加载,修改x的值为1,同时写入两级缓存中并刷到主内存中;又有一个线程B读取了x,这时候由于Cache2中有x变量的值,命中并返回给线程B,B修改x为2并同步刷到两级缓存和主内存中。这个时候如果A再次读取x的值,会在一级缓存被命中并返回,此时A拿到x的值是1。也就是说,两个线程对于共享变量的修改对于彼此是不可见的!这就是共享变量的内存可见性问题。

Java内存模型规定

将所有的变量都会存放在主内存中,当线程使用变量时,会把主内存里面的变量复制到自己的工作内存中,线程读写变量的操作是在自己的工作内存上进行的。


二、synchronized和volatile关键字

        前面已经讲道理共享变量的内存可见性问题,我们发现出现的根本原因是因为共享变量在工作内存中被直接命中返回而导致的。因此提出了以下两种解决问题的思路:第一个思路就是我们在每次请求读写共享变量的时候都会清空缓存中保留的数据并从主内存中去加载,每次修改完后再刷到主内存中;第二个思路就是我们直接绕过缓存,直接往主内存里面存取数据。这就是下面的两个关键字所带来的方案。

2.1 synchronized

        synchronized是Java提供的一种原子性内置锁,通过对共享变量加上synchronized关键字可以有效解决共享变量可见性问题,同时保障了原子性。synchronized的内存语义就是:在进入synchronized块使用到的变量就会从工作内存中清除,退出synchronized块的内存预计是把在synchronized修改的共享变量刷到主内存中,也就是上面的思路一。但由于synchronized关键字会加上独占锁,导致线程被阻塞挂起,带来频繁的上下文切换,所以这种操作太重了。

2.2 volatile

除了synchronized,Java还提供了一种弱形式的同步——volatile关键字。当一个变量被声明为volatile的时候,线程在写入该变量的时候就不会存在工作内存中,从而避免了共享变量的可见性问题。但是volatile并不能保证操作的原子性。

区别

可以看到,在保证共享变量可见性方面,使用volatile无疑是更好的。synchronize是采用一种独占锁的方式,而volatile是一种非阻塞算法实现,不会造成线程上下文开销。


三、CAS和Unsafe类

3.1 CAS原子操作

        我们知道,锁在处理高并发的时候显得比较重,虽然保证数据读写操作的原子性带来了一定的性能开销问题。JDK中Unsafe类提供了另外一种非阻塞的方式来保证并发下操作指令的原子性操作——Compare And Swap,也称为CAS,它通过硬件保证了比较更新操作的原子性。从源码上看,CAS的原理有点类似乐观锁,它是基于这样一种观点:冲突不太可能发生,所以直接进行修改,然后验证修改是否成功。如果发生冲突,则重试,直到成功为止。 

CAS中的ABA问题

        普通的CAS操作还存在这一个小坑,也就是在线程a获取变量x的值为A并尝试执行修改x的值为B的时候,又有一个线程b获取x的值A,并CAS操作修改为B,再次CAS操作修改为A,这样子当a线程准备执行CAS修改的时候,发现的x的值其实是修改过的A而不是原来的A,这就可能会出现一些不可预料的情况。

ABA问题的解决

ABA问题出现是因为修改变量值是环状变换的,JDK中的AtomicStamptedReference类为每一个状态值都配备了一个时间戳来解决ABA问题。

3.2 Unsafe类

        Unsafe是JDK提供的一个硬件级别的原子操作类,具体的路径在Java JDK8中的sun.misc包下。需要注意的是我们没有权限按照正常方式实例化该类对象,这是因为该类默认是通过Bootstrap类加载器加载的,而我们日常的测试类是通过AppClassLoader加载的,但可以通过反射的方式来访问Unsafe类。下面是一些主要方法:

//获取指定变量在所属类中的内存偏移地址
public native long objectFieldOffset(Field var1);
//获取数组中第一个元素地址
public native int arrayBaseOffset(Class var1);
//获取数组中一个元素占用的字节
public native int arrayIndexScale(Class var1);
// obj对象指定偏移地址的变量值是否为var4,相等的话就需要使用var5更新并返回true
public final native boolean compareAndSwapObject(Object var1, long var2, Object var4, Object var5);
//设置obj对象中偏移量为offset的变量对应的volatile的值
public native long getLongVolatile(Object var1, long var2);
//一个有延迟的putLongVolatile方法
public native void putOrderedLong(Object var1, long var2, long var4);
//唤醒park阻塞的线程
public native void unpark(Object var1);
//阻塞当前线程,isAbsolute=false并且time=0会一直阻塞;isAbsolute=1并且time>0阻塞直到time时刻
public native void park(boolean isAbsolute, long time);

/*
* JDK8新增
*/
//获取object对象中偏移量为offset变量的当前volatile的值offset,并设置其语义值为new
public final long getAndSetLong(Object object, long offset, long new) {
        long var6;
        do {
            var6 = this.getLongVolatile(object, offset);
        } while(!this.compareAndSwapLong(object, offset, var6, new));

        return var6;
    }
//获取当前变量的偏移量为offset变量的当前volatile的值var2,并设置其语义值为var4+原始值
public final long getAndAddLong(Object var1, long var2, long var4) {
        long var6;
        do {
            var6 = this.getLongVolatile(var1, var2);
        } while(!this.compareAndSwapLong(var1, var2, var6, var6 + var4));

        return var6;
    }

四、指令重排序

Java的内存模型允许编译器和处理器对指令重排序以提高运行性能,并只会对不存在的数据依赖性的指令重排序。

什么是“不存在的数据依赖性的指令”呢?

        简单来说,如果两条指令之间没有数据依赖关系,就意味着它们的执行顺序可以被改变而不会影响到程序的正确性。这里的“数据依赖”通常意味着一条指令的结果会被另一条指令所使用。如果两条指令间没有这样的关系,那么它们之间的执行顺序就可以被重排序。

int a = 0;
int b = 0;
int c = a+b;

        这里的a、b的定义就是可以被指令重排的,而c则必须在a、b的依赖后面,这是因为存在依赖关系。指令重排看起来在单线程下面完全没有问题,但在多线程的情况下,可能会导致意想不到的情况。同样的指令重排可以通过用volatile关键字来对共享变量进行修饰来避免。

五、伪共享问题 

        在前面梳理共享变量可见性问题的时候,我们提及CPU系统架构,也清楚在不加关键字的情况下线程对于共享变量的读写还同时有着读写Cache的操作。而CPU在首次访问某个变量的时候,会将该变量所在内存区域的一个Cache行大小的内存复制到Cache行中,因此缓存行中存储的可能是多个变量。当多线程同时修改一个缓存行里面的多个变量时,由于缓存行同时只能由一个线程来操作,所以相比于将单个变量来存储可能会导致性能的下降,这就是伪共享问题。

避免伪共享问题

        解决问题的思路很简单,从问题的描述中我们可以看到,伪共享的出现是因为多个共享变量被存储到了同一个缓存行中。那么我们可以通过填充的方式来实现同一个缓存行只填充一个共享变量即可。在JDK8中提供了一种通过注解@sun.misc.Contended注解来避免伪共享问题的方式。


有关锁的部分知识详见荔枝的另一篇博文:https://blog.csdn.net/qq_62706049/article/details/131966895 

这里补充一下独占锁和共享锁的概念:

        独占锁和共享锁是对于锁资源的使用权限上的一种界定。其中独占是一种悲观锁,比如ReentrantLock,会限制并发性但却能保证数据的强一致性;而共享锁比如读写锁ReadWriteLock 则是一种乐观锁,它放宽了加锁条件允许多个线程同时执行读操作。


总结

        看着这些概念的出现其实都是为了解决问题,学习的目的也不外乎是具备解决业务问题能力!从解决问题的角度在来看专业的书籍会得到不同的感受,理解也能更快,希望能帮助到大噶~~~

今朝已然成为过去,明日依然向往未来!我是荔枝,在技术成长之路上与您相伴~~~

如果博文对您有帮助的话,可以给荔枝一键三连嘿,您的支持和鼓励是荔枝最大的动力!

如果博文内容有误,也欢迎各位大佬在下方评论区批评指正!!!

你可能感兴趣的:(JUC并发编程学习,java,CAS,多线程并发,伪共享,锁)