并发编程特性,悲观锁与乐观锁

并发编程的三大特性:原子性,有序性,可见性

在学习完多线程的基本概念之后,我们必须了解并发编程的三大特性。在做并发编程时要保证者三大特性。这三大特性分别是原子性,有序性,可见性。

原子性

任何一个同步代码块,要么全部执行,要么不执行,是一个不可分割的单位。
举个例子:int i = 1,j = 2;线程A : i = 4,j = 1;如果线程A只执行i = 4,j = 1 没有操作,最后的结果绝对不符合我们的预期。
当然哪怕只是一行代码,也是由多个指令完成,所以这一行代码本身,并不具备原子性

非原子性的代码,先获取i,在对i进行+1,再赋值给i,这是三步操作。
i++;

有序性

程序执行的顺序按照代码的先后顺序执行。
首先我们必须清楚,JVM并不是顺序运行指令,JVM可以对指令进行重排序,对于单线程来说不会有任何影响。但是对于多线程来说,对结果的影响是巨大的。
单线程环境下:

int i = 0// 1
int j = 2// 2
i++;// 3
j++;// 4

第一行和第二行并没有依赖,所以谁先执行是不影响结果的,所以在JVM中,先执行第1行还是先执行第二行是由JVM决定的。这也是一种编译优化。
但是在多线程环境中就会引起意想不到的错误

boolean flag = true; 
flag = false; // 0
int a = 0;
//线程A执行 1、2 代码
a = 1   // 1
flag = true; // 2    

//线程B执行 3、4、5、6 代码
while(!flag){ // 3
 a = a + 1; // 4
} // 5
System.out.println(a); // 6

那么最后a的结果是多少?这是未知的,因为我们不知道是线程A执行还是线程B线程先执行。

可见性

当一个线程对一个共享变量进行操作时,对其他线程也必须可见
可见性这一问题主要是缓存导致的,在JVM中,并不是线程一个一个执行,这跟我们的JVM缓存有关系。好比我们的电脑CPU,CPU与磁盘交互时,CPU一个进程一个进程处理,效率太慢,因为有上下文的切换。所以创建3个缓存区,直接将多个进程传入缓存区中进行执行。而CPU对缓存中的任务进行操作即可。这大大加快了CPU的处理速度。
但是也会引起一个问题,共享变量不一致了。

在这里插入图片描述
int i = 0;
缓存区A:i++;
缓存区B:i++;
缓存区C:i++;
最后的结果时多少?i = 1。因为在缓存从主内存中获得 i 的时候 i 就等于0,缓存区中处理完之后直接刷新到主内存,三个缓存区执行完还是i++;
我们任务预期时i = 3;
所以在对这种共享变量进行操作的时候,当缓存区A对 i 进行操作之后,缓存B会立马接收到通知,此时的 i 改动为1。这就保证了可见性。
所以在JVM种类似,当我们对共享变量进行操作时,我们也要保证可见性。

Volatile关键字

Volatile修饰的变量,可以直接保证此变量的可见性。
Volatile关键字修饰的共享变量,一旦发生改变,就会强制刷新到主内存中,并且其他线程就会接收到通知,此变量已经改动,重新读取。
并且,Volatile还可以防止指令重排序,让此共享变量的代码顺序执行。

Volatile关键字的作用:1:保证共享变量的可见性 2.防止指令重排序

悲观锁

悲观锁:悲观锁顾名思义,这个锁非常的悲观,当我们获取这个锁对应的资源时,它总认为别人会来争抢这个资源。所以一旦拿到对应的资源,就直接上锁。
(tips:由于每一次拿到资源都会上锁的机制,所以适用于锁的竞争激烈的情况,因为如果不上锁使用乐观锁实现同步,那么就会浪费大量CPU的资源。所以当写操作远大于读操作时,悲观锁的实现使更好的选择。)

悲观锁的实现之一 Synchronized

被Synchronized修饰的代码块,一旦获得对应的锁,那么就直接上锁,不让其他线程来获取。
Synchronized代码块可以直接实现并发编程的三大特性。
原子性,Synchronized代码块中的代码必须全部执行完,才会释放锁,让其他线程获取锁。
有序性,有序性是一个变量在同一个时刻只允许一个线程对其进行加锁。这就说明了持有一个锁的两个同步块只能串行进行。
可见性,synchronized主要对变量读写,或者代码块的执行进行加锁,在未释放锁之前,其它现场无法操作synchronized修饰的变量或者代码块。并且,在释放锁之前会讲修改的变量值写到内存中,其它线程进来时读取到的就是新的值了。

乐观锁

乐观锁:也是顾名思义,它认为拿到锁对应的资源之后,其他线程并不会来争抢,所以先不上锁,当自己操作完之后,更新的时候会判断一下在此期间别人有没有去更新这个数据。
(Tips:由于不上锁的机制,适合于锁的竞争并不激烈的情况,所以当对数据的读操作原源大于写操作时,乐观锁是较好的选择)

乐观锁的实现:CAS ,Version

CAS(Compare And Swap):CAS操作时有三个参数是必要的(目标地址,期望值,修改值),首先访问目标地址,对比目标地址的当前值是否和期望值一致,如果一致,那么就交换 修改值和目标地址的值。最后在准备刷新数据到内存时,查看当前目标地址的值和期望值是否一致,如果一致,证明没有其他线程修改数据,那么就刷新到主内存中;如果不一致,那么就说明已经有其他线程修改目标地址的值了,那么就再执行CAS操作,直到没有其他线程修改为止。
在Java中 java.util.concurrent.atomic 包下面的原子变量类就是使用了乐观锁的一种实现方式CAS实现的。

那么CAS是如何保证并发编程的三大特性呢?

我们通过java.util.concurrent.atomic 的任意一个子类去查看,我们都会发现CompareAndSet方法中的第二个参数,也就是期望值,都是用volatile修饰的。如此以来就可以保证对数据修改的可见性,而CAS执行结束之前会判断当前的数据是否有人修改过,没修改则刷新,修改则重新执行CAS,那么就说明共享变量同一时刻只有一个线程来操作它,所以可以保证有序性,由于concurrent.atomic包下类的操作优势原子操作,所以CAS的实现类的方法都可以看作是一种原子操作。

Version

Version叫做版本号机制,当获得资源时先不上锁,并且获得当前数据的版本号,操作结束之后,会对比现在数据的版本号是否和最初获取时一致,如果一致,那么就说明没有其他线程修改,此时将版本号进行修改,刷新到内存中;如果不一致,那么当前的操作就会被驳回。
举个例子,
假设数据库中帐户信息表中有一个 version 字段,当前值为 1 ;而当前帐户余额字段( balance )为 $100 。当需要对账户信息表进行更新的时候,需要首先读取version字段。

操作员 A 此时将其读出( version=1 ),并从其帐户余额中扣除 $50( $100-$50 )。
在操作员 A 操作的过程中,操作员B 也读入此用户信息( version=1 ),并从其帐户余额中扣除 $20 ( $100-$20 )。

操作员 A 完成了修改工作,提交更新之前会先看数据库的版本和自己读取到的版本是否一致,一致的话,就会将数据版本号加1( version=2 ),连同帐户扣除后余额( balance=$50 ),提交至数据库更新,此时由于提交数据版本大于数据库记录当前版本,数据被更新,数据库记录 version 更新为 2 。

操作员 B 完成了操作,提交更新之前会先看数据库的版本和自己读取到的版本是否一致,但此时比对数据库记录版本时发现,操作员 B 提交的数据版本号为 2 ,而自己读取到的版本号为1 ,不满足 “ 当前最后更新的version与操作员第一次读取的版本号相等 “ 的乐观锁策略,因此,操作员 B 的提交被驳回。

这样,就避免了操作员 B 用基于 version=1 的旧数据修改的结果覆盖操作员A 的操作结果的可能。

原文链接

原创文章 139 获赞 23 访问量 5927

猜你喜欢

转载自blog.csdn.net/weixin_44916741/article/details/104367869