JUC之CAS,volatile的学习

本文探讨了并发编程中的原子性、可见性和有序性概念,介绍了CAS机制及其在ABA问题中的应用,剖析了volatile关键字的语义和底层实现,以及Synchronized的同步原理。特别关注了volatile如何避免指令重排序和ABA问题的解决方案。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

一、并发编程中的三个概念

1.原子性

原子性:即一个操作或者多个操作 要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。

Java内存模型只保证了基本读取和赋值是原子性操作,如果要实现更大范围操作的原子性,可以通过synchronized和Lock来实现。由于synchronized和Lock能够保证任一时刻只有一个线程执行该代码块,那么自然就不存在原子性问题了,从而保证了原子性。

2.可见性

学习什么是可见性之前,可以先了解一下JMM,这里附上我写的JMM学习笔记链接:
Java内存模型(JMM)

可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。

对于可见性,Java提供了volatile关键字来保证可见性。
当一个共享变量被volatile修饰时,它会保证修改的值会立即被更新到主存,当有其他线程需要读取时,它会去内存中读取新值。
而普通的共享变量不能保证可见性,因为普通共享变量被修改之后,什么时候被写入主存是不确定的,当其他线程去读取时,此时内存中可能还是原来的旧值,因此无法保证可见性。

另外,通过synchronized和Lock也能够保证可见性,synchronized和Lock能保证同一时刻只有一个线程获取锁然后执行同步代码,并且在释放锁之前会将对变量的修改刷新到主存当中。因此可以保证可见性。

3.有序性

有序性:即程序执行的顺序按照代码的先后顺序执行。
但是JVM在执行代码的时候可能会发生指令重排序。

那么什么是指令重排序呢?
一般来说,处理器为了提高程序运行效率,可能会对输入代码进行优化,它不保证程序中各个语句的执行先后顺序同代码中的顺序一致,但是它会保证程序最终执行结果和代码顺序执行的结果是一致的。
注意:处理器在进行重排序时是会考虑指令之间的数据依赖性的,即它只会对不存在数据依赖性的指令进行重排序。

指令重排序不会影响单个线程的执行,但是会影响到线程并发执行的正确性。
也就是说,要想并发程序正确地执行,必须要保证原子性、可见性以及有序性。只要有一个没有被保证,就有可能会导致程序运行不正确。

在Java里面,可以通过volatile关键字来保证一定的“有序性”。另外可以通过synchronized和Lock来保证有序性,很显然,synchronized和Lock保证每个时刻是有一个线程执行同步代码,相当于是让线程顺序执行同步代码,自然就保证了有序性。

二、CAS机制

1.什么是CAS

CAS(Compare and swap)比较和替换是设计并发算法时用到的一种技术。cucurenct包中,CAS机制是它实现整个java包的基石。
简单来说,比较和替换是使用一个期望值和一个变量的当前值进行比较,如果当前变量的值与我们期望的值相等,就使用一个新值替换当前变量的值。

乐观锁用到的机制就是CAS,Compare and Swap。

CAS 操作包含三个操作数 —— 内存位置(V)、预期原值(A)和新值(B)。 如果内存位置的值与预期原值相匹配,那么处理器会自动将该位置值更新为新值 。否则,处理器不做任何操作。

类似于 CAS 的指令允许算法执行读-修改-写操作,而无需害怕其他线程同时 修改变量,因为如果其他线程修改变量,那么 CAS 会检测它(并失败),算法 可以对该操作重新计算。

2.CAS的好处

CAS有两大好处
1.无锁的方式没有锁竞争带来的系统开销,也没有不同线程间频繁调度的开销,所以性能比锁更优越
2.对死锁免疫

无锁是一种乐观的策略,它假设所有对资源的访问都是没有冲突的,因此所有线程都可以不用阻塞的持续执行,一旦遇到冲突,无锁采用CAS(Compare And Swap)来鉴别冲突,一旦检测到冲突,则重试当前操作直到没有冲突为止

注意,CAS在java中是用native方法来实现的,利用了系统本身提供的原子性操作。
CAS虽然很高效的解决原子操作,但CAS并不能解决ABA问题。

三、ABA问题

1.什么是ABA问题

ABA问题:因为CAS需要在操作值的时候检查下值有没有发生变化,如果没有发生变化则更新,但是如果一个值原来是A,变成了B,又变成了A,那么使用CAS进行检查时会发现它的值没有发生变化,所以CAS成功。但实际上这时的现场已经和最初不同了,尽管CAS成功,但可能存在潜藏的问题。

接下来我们看一个例子:
有一个链表里面的数据是A->B->C,我们希望执行一个CAS操作,将A替换成D,生成链表D->B->C。考虑下面的步骤:
步骤一:线程a读取链表头部节点A。
步骤二:线程b将链表中的B节点删掉,链表变成了A->C,显然此时链表的头部节点仍然是A。
步骤三:线程a执行CAS操作,将A替换从D。
最后我们的到的链表是D->C,而不是D->B->C。

构成 ABA 问题有三个重要的条件:
某个线程需要重复读某个内存地址,并以内存地址的值变化作为该值是否变化的唯一判定依据;
重复读取的变量会被多线程共享,且存在『值回退』的可能,即值变化后有可能因为某个操作复归原值;
在多次读取间隔中,开发者没有采取有效的同步手段,比如上锁。
以上三个关键点构成了 ABA 问题的充要条件,我们只需要打破其中一个条件就可以解决 ABA 问题。

如果CAS操作是基于CPU内核的原子操作,那基本是不会出现ABA问题的,但是如果CAS本身操作不满足原子性,则会带来ABA问题。

2.如何解决ABA问题

对于ABA问题,比较有效的方案是引入版本号,内存中的值每发生一次变化,版本号都+1;在进行CAS操作时,不仅比较内存中的值,也会比较版本号,只有当二者都没有变化时,CAS才能执行成功。

使用数据版本(Version)记录机制实现,这是乐观锁最常用的一种实现方式。何谓数据版本?即为数据增加一个版本标识,一般是通过为数据库表增加一个数字类型的 “version” 字段来实现。当读取数据时,将version字段的值一同读出,数据每更新一次,对此version值加一。当我们提交更新的时候,判断数据库表对应记录的当前版本信息与第一次取出来的version值进行比对,如果数据库表当前版本号与第一次取出来的version值相等,则予以更新,否则认为是过期数据。用下面的一张图来说明:

看一看这篇博文

四、深入剖析volatile关键字

当对非 volatile 变量进行读写的时候,每个线程先从内存拷贝变量到CPU缓存中。如果计算机有多个CPU,每个线程可能在不同的CPU上被处理,这意味着每个线程可以拷贝到不同的 CPU cache 中。
而声明变量是 volatile 的,JVM 保证了每次读变量都从内存中读,跳过 CPU cache 这一步。

1.volatile关键字的两层语义

一旦一个共享变量(类的成员变量、类的静态成员变量)被volatile`修饰之后,那么就具备了两层语义:
1)保证了不同线程对这个变量进行操作时的可见性,即当一个共享变量被volatile修饰时,它会保证修改的值会立即被更新到主存,当有其他线程需要读取时,它会去内存中读取新值。
2)禁止进行指令重排序。

2.volatile如何保证有序性?

volatile关键字禁止指令重排序有两层意思:
1)当程序执行到volatile变量的读操作或者写操作时,在其前面的操作的更改肯定全部已经进行,且结果已经对后面的操作可见;在其后面的操作肯定还没有进行;
2)在进行指令优化时,不能将在对volatile变量访问的语句放在其后面执行,也不能把volatile变量后面的语句放到其前面执行。
可能上面说的比较绕,举个简单的例子:

//x、y为非volatile变量
//flag为volatile变量
 
x = 2;        //语句1
y = 0;        //语句2
flag = true;  //语句3
x = 4;         //语句4
y = -1;       //语句5

由于flag变量为volatile变量,那么在进行指令重排序的过程的时候,不会将语句3放到语句1、语句2前面,也不会讲语句3放到语句4、语句5后面。但是要注意语句1和语句2的顺序、语句4和语句5的顺序是不作任何保证的。
并且volatile关键字能保证,执行到语句3时,语句1和语句2必定是执行完毕了的,且语句1和语句2的执行结果对语句3、语句4、语句5是可见的。

在访问volatile变量时不会执行加锁操作,因此也就不会使执行线程阻塞,因此volatile变量是一种比sychronized关键字更轻量级的同步机制。

3.volatile的底层实现原理

对加了volatile修饰的变量进行写操作,JVM就会向处理器发送一条lock前缀的指令,将这个变量在缓存行的数据写回到系统内存。这时只是写回到系统内存,但其他处理器的缓存行中的数据还是旧的,要使其他处理器缓存行的数据也是新写回的系统内存的数据,就需要实现缓存一致性协议。即在一个处理器将自己缓存行的数据写回到系统内存后,其他的每个处理器就会通过嗅探在总线上传播的数据来检查自己缓存的数据是否已过期,当处理器发现自己缓存行对应的内存地址的数据被修改后,就会将自己缓存行缓存的数据设置为无效,当处理器要对这个数据进行修改操作的时候,会重新从系统内存中把数据读取到自己的缓存行,重新缓存。

总结下:volatile可见性的实现就是借助了CPU的lock指令,通过在写volatile的机器指令前加上lock前缀,使写volatile具有以下两个原则:
1.写volatile时处理器会将缓存写回到主内存。
2.一个处理器的缓存写回到内存会导致其他处理器的缓存失效。

4.一个指令重排的问题——被部分初始化的对象

补充:这个例子需要掌握,因为字节面试官问过我为何需要volatile,当时我就懵逼了!

一个懒加载的单例模式实现如下:

class Singleton {
	private static Singleton instance;
	private Singleton(){}
	public static Singleton getInstance() {
		if ( instance == null ) { //这里存在竞态条件
			instance = new Singleton();
		}
		return instance;
	}
}

竞态条件会导致instance引用被多次赋值,使用户得到两个不同的单例。

为了解决这个问题,可以使用synchronized关键字将getInstance方法改为同步方法;但这样串行化的单例效率是很低下的。这时Double Check Lock双重检查锁机制应运而生,使得大部分请求都不会进入阻塞代码块。

class Singleton {
	private static Singleton instance;
	private Singleton(){}
	public static Singleton getInstance() {
		if ( instance == null ) { //当instance不为null时,仍可能指向一个“被部分初始化的对象”
			synchronized (Singleton.class) {
				if ( instance == null ) {
					instance = new Singleton();
				}
			}
		}
		return instance;
	}
}

看起来”非常完美:既减少了阻塞,又避免了竞态条件。不错,但实际上仍然存在一个问题——当instance不为null时,仍可能指向一个 “被部分初始化的对象”
那什么是“被部分初始化的对象”呢?

我们看这条赋值语句:
instance = new Singleton();
它并不是一个原子操作。事实上,它可以”抽象“为下面几条JVM指令:

memory = allocate();	//1:分配对象的内存空间
initInstance(memory);	//2:初始化对象
instance = memory;		//3:设置instance指向刚分配的内存地址

上面操作2依赖于操作1,但是操作3并不依赖于操作2,所以JVM可以以“优化”为目的对它们进行重排序,经过重排序后如下:

memory = allocate();	//1:分配对象的内存空间
instance = memory;		//3:设置instance指向刚分配的内存地址(此时对象还未初始化)
ctorInstance(memory);	//2:初始化对象

可以看到指令重排之后,操作 3 排在了操作 2 之前,即引用instance指向内存memory时,这段崭新的内存还没有初始化——即,引用instance指向了一个"被部分初始化的对象"。此时,如果另一个线程调用getInstance方法,由于instance已经指向了一块内存空间,从而if条件判为false,方法返回instance引用,用户得到了没有完成初始化的“半个”单例。
解决这个该问题,只需要将instance声明为volatile变量。

private static volatile Singleton instance;

也就是说,在只有DCL双重检查锁而没有volatile的懒加载单例模式中,仍然存在着并发陷阱。我确实不会拿到两个不同的单例了,但我会拿到“半个”单例(未完成初始化)。

五、Synchronized的底层实现原理

1.Synchronized同步代码块

public class SynchronizedDemo {
    public void method() {
        synchronized (this) {
            System.out.println("synchronized 代码块");
        }
    }
}

在这里插入图片描述
从上面我们可以看出:
synchronized 同步代码块的实现使用的是 monitorenter 和 monitorexit 指令,其中 monitorenter 指令指向同步代码块的开始位置,monitorexit 指令则指明同步代码块的结束位置。

当执行 monitorenter 指令时,线程试图获取锁也就是获取对象监视器 monitor的持有权。如果锁的计数器为 0 则表示锁可以被获取,获取后将锁计数器设为1也就是加 1。

在执行 monitorexit 指令后,将锁计数器设为 0,表明锁被释放。如果获取对象锁失败,那当前线程就要阻塞等待,直到锁被另外一个线程释放为止。

2.Synchronized同步方法

public class SynchronizedDemo2 {
    public synchronized void method() {
        System.out.println("synchronized 方法");
    }
}

在这里插入图片描述
synchronized同步方法是 ACC_SYNCHRONIZED 标识,该标识指明了该方法是一个同步方法。JVM 通过该 ACC_SYNCHRONIZED 访问标志来辨别一个方法是否声明为同步方法,从而执行相应的同步调用。

总而言之,synchronized有两种形式上锁,一个是对方法上锁,一个是构造同步代码块。他们的底层实现其实都一样,在进入同步代码之前先获取锁,获取到锁之后锁的计数器+1,同步代码执行完锁的计数器-1,如果获取失败就阻塞式等待锁的释放。只是他们在同步块识别方式上有所不一样,从class字节码文件可以表现出来,一个是通过方法flags标志,一个是monitorenter和monitorexit指令操作。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值