Java面试题~从一个线程并发安全场景谈谈Java的锁、CAS算法以及ABA问题
作者:
修罗debug
版权声明:本文为博主原创文章,遵循 CC 4.0 by-sa 版权协议,转载请附上原文出处链接和本声明。
摘要:
对于“并发安全”,想必各位小伙伴都有所耳闻,它指的是“系统中某一时刻多个线程并发访问一段或者一个有线程安全性问题的代码、方法后,最终出现不正确的结果或者并非最初所预料的结果”。
有问题并有对应的解决方案,而“并发安全性问题”对应的解决方案也无非是根据项目所处的环境而采取相应的措施,如单体环境下采用加synchronized同步锁、原子操作锁atomicXXX 以及 lock等方式;分布式环境下采取分布式锁(基于Redis、ZooKeeper、Redisson…)等方式加以解决。
本文我们将以一个简单的场景:“模拟 网站访问的计数统计”为案例,介绍在多线程并发的场景下出现的结果及其相应的解决方案,包括synchronized、atomicXXX、lock以及介绍CAS算法和ABA相关的问题。
内容:
所谓的“网站访问计数器”,其实也很好理解,就是统计用户在前端浏览器访问网站的次数,当然啦,在本文中,我们将尽量写一个简化版的“网站访问计数器”,用于演示“并发安全” 出现的场景。废话不多说,下面进入代码实战环节
一、 模拟“网站访问计数器”代码实战
(1)如下代码所示,我们直接在类中编写“访问计数”的核心代码(其实就是一个静态变量的共享、数值叠加罢了):
public class WebAccessTotal {
//方式一
private static int total;
public void incrementTotal(){
total++;
}
public static void main(String[] args) throws InterruptedException {
WebAccessTotal testCount = new WebAccessTotal();
//开启5个线程
for (int i = 0; i < 5; i++) {
new Thread(() -> {
try {
sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
//每个线程都让total增200,模拟访问 200次
for (int j = 0; j < 200; j++) {
testCount.incrementTotal();
}
}).start();
}
sleep(2000);
//正确的情况下会输出1000
System.out.println(total);
}
}
在上述代码中,我们采用了一个静态变量total模拟 统计网站的访问量(虽然粗糙了点~~~),右键点击运行main方法,观察控制台输出的结果,会发现其结果竟然并非我们所预料的1000,而是 < 1000,如下图所示,输出结果竟然是 412,有点令人大跌眼镜:
(2)不过,这个结果却又在情理之中,因为incrementTotal() 方法中的这段代码:total++在多线程并发访问时存在安全性 的问题,具体原因在于 ++ 操作将会被拆分为多个步骤执行,即:int tmp=total+1; total=tmp; 两个步骤:
假设total的初始值为 8,当并发的两个线程A,B同时调用 incrementTotal() 方法时,此时很有可能 A.total=8 ,B.total=8,在执行完该方法之后,A.tmp=9,B.tmp=9,最终该方法的输出结果为:9,而实际上其输出结果应该为:10(因为调用了两次哦)
而这个过程正是出现“线程不安全”的源头,既然出现了问题,那么就应当想办法进行解决,下面我们进入问题解决方案的实战!
二、 基于Synchronized同步代码块 保证线程安全
(1)首先,我们先采用 Synchronized 关键字同步代码块,试试其最终输出的结果,其源代码如下所示:
//方式二
private static int total;
public synchronized void incrementTotal(){
total++;
}
public static void main(String[] args) throws InterruptedException {
WebAccessTotal testCount = new WebAccessTotal();
//开启5个线程
for (int i = 0; i < 5; i++) {
new Thread(() -> {
try {
sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
//每个线程都让total增200,模拟访问 200次
for (int j = 0; j < 200; j++) {
testCount.incrementTotal();
}
}).start();
}
sleep(2000);
//正确的情况下会输出1000
System.out.println("输出结果:"+total);
}
点击运行,查看控制台的输出结果,会发现结果如我们所料,正是 1000 ,不多也不少:
(2)而加了 Synchronized 关键字 之所以能起到这种效果,主要在于它限定了瞬时访问该方法时多个线程的“同步操作”,即当并发的多个线程要想访问该方法时,需要获取得到该方法的“锁”,只有获取到了“锁”才能执行方法里面的代码逻辑,而同一时刻也就只能有一个线程可以获取得到(其他没获取到的需要堵塞式等待),从而也就导致了 total++ 变成了 一个 “原子性操作”,即要么都做了,要买都不做。
对于 Synchronized 关键字,我们都知道它是 Java内置的锁,属于悲观、同步锁的一种,其加锁的粒度是相当粗的,在高并发场景下其性能也不是很好。
三、 基于JUC的Lock锁和原子操作类AtoimicXX保证线程安全
(1)下面,我们基于JUC里面的Lock锁和原子操作类AtomicXXX 为上述方法加“锁”,看看是否可以保证线程访问的安全。
(2)首先是JUC开发工具包下的Lock接口及其实现类,其完整的代码如下所示:
//方式四
private static Lock lock=new ReentrantLock();
private static int total;
public void incrementTotal(){
total++;
}
public static void main(String[] args) throws InterruptedException {
WebAccessTotal testCount = new WebAccessTotal();
//开启5个线程
for (int i = 0; i < 5; i++) {
new Thread(() -> {
try {
sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
//每个线程都让total增200,模拟访问 200次
for (int j = 0; j < 200; j++) {
//获取锁-执行业务逻辑-释放锁
lock.lock();
testCount.incrementTotal();
lock.unlock();
}
}).start();
}
sleep(2000);
//正确的情况下会输出1000
System.out.println("输出结果:"+total);
}
而使用原子操作类AtoimicXX保证多个线程并发访问“共享的代码块”的完整源码如下所示:
//方式三
private static AtomicInteger total=new AtomicInteger(0);
public void incrementTotal(){
total.getAndIncrement();
}
public static void main(String[] args) throws InterruptedException {
WebAccessTotal testCount = new WebAccessTotal();
//开启5个线程
for (int i = 0; i < 5; i++) {
new Thread(() -> {
try {
sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
//每个线程都让total增200,模拟访问 200次
for (int j = 0; j < 200; j++) {
testCount.incrementTotal();
}
}).start();
}
sleep(2000);
//正确的情况下会输出1000
System.out.println("输出结果:"+total);
}
点击运行代码,会发现其输出结果均为:1000,说明这两种方式都可以实现线程并发访问时的安全性;
Lock是java里面juc包下的接口,其实现类有多种,如ReadLock、WriteLock、ReentrantLock,相对于synchronized而言,Lock的加锁和解锁需要配对出现,即需要手动释放锁,如果没有及时解锁(释放锁),那很可能会发生死锁;
除此之外,synchronized是一种同步堵塞锁,当并发时的某个线程获取不到锁时,它将永久等待下去,而Lock可以通过lock.tryLock();尝试获取锁,如果尝试获取不到锁,可以结束等待;另外,synchronized具有可重入、不可中断、非公平的特性,而Lock锁具有可重入、可以判断、可以公平!(公平:线程必须排队,必须先来后到;非公平:线程可以插队)
下面,我们重点分析一下JUC下的原子操作类AtomicXX为何也可以实现多线程并发访问时保证“线程安全性”问题!
在介绍原理之前,先来啰嗦一下“原子操作类”是什么玩意,所谓的“原子操作类”,它指的是JUC包下一系列以Atomic开头的包装类,如AtomicBoolean,AtomicInteger,AtomicLong,它们分别用于Boolean,Integer,Long类型的原子性操作,每个原子操作类内部都采取了CAS算法来保证线程安全性,那啥叫“CAS算法”呢?
四、CAS算法介绍
(1)CAS,是“Compare And Swap”的缩写,中文简称就是“比较并替换”,在CAS算法中,它使用了3个基本操作数:内存地址对应的值V,旧的预期值A(旧值),要修改的新值B(新值),
当且仅当预期值A和内存值V相同时,才将内存值修改为B,否则什么都不做,最后返回现在的V值。
简单的理解就是:“我认为V的值应该是A,如果是A的话我就把他改成B,如果不是A的话(那就证明被别人修改过了),那我就不修改了,避免多人 同时修改导致数据出错,换句话说:要想修改成功,必须保证A和V中的值是一样的,修改前有个对比的过程。”
(2)为了方便各位小伙伴理解,debug特意绘制了一个 CAS 算法底层的执行逻辑(在这里仍然是以 ++ 操作为案例):
在上图中,线程1首次更新失败后,并不会立即放弃,而是重新获取内存中最新的值,然后再进行下一步的尝试“提交更新”操作,直至成功,这个过程称为 “自旋”,而CAS的实现,底层其实是通过 volatile 关键字来实现的!
volatile 可以保证线程获取的当前值是内存中的最新值,即保证线程间的可见性!
值得一提的是,上图CAS机制的执行流程其实跟 debug在此之前讲过的“分布式锁实战课程”中的乐观锁实现机制一毛一样,只不过在那里我们是采用 “比较version” 的方式来实现乐观锁的代码。
跟synchronized相比,synchronized属于悲观锁,悲观的认为程序中的并发情况很严重,所以要严防死守,在高并发情况下效率相当低下(因为其降低了瞬时并发量)。
而CAS属于乐观锁,乐观的认为程序中的并发情况不那么严重,所以让线程不断地去重试更新,然后实际上synchronized已经改造了,带有锁升级的功能,效率现如今也可以拼得过CAS。
(3)当然,有优点,也就有缺点,下面罗列出CAS算法的几条缺点:
A.CPU开销可能过大
在并发比较大的时候,若多线程反复尝试更新某个变量,却又一直更新不成功,循环往复(自旋),会给CPU带来很大的压力
B.代码块的原子性
CAS机制所保证的只是一个变量的原子操作,而不能保证整个代码块的原子性,比如需要保证三个变量共同进行原子性的更新,就不得不使用synchronized或lock等机制了
C.ABA问题:下面简单介绍一下
五、ABA问题详解
(1)线程1准备通过CAS将变量的值由A替换为B,在此之前,线程2将变量的值由A替换为C、又由C替换为A。
(2)然后线程1执行CAS时发现变量的值仍是A,所以CAS成功,这么看似乎没啥问题,但是如果操作的对象是个链表的话,虽然值是一样,但是链表的位置却不一样了,“当年的那个A已经不是当时的那个A了” !
(3)深究其原因,其主要在于 我们在做 compare and swap 中的 swap 时仅仅只是比较了节点对象的内存值。为了预防这种问题的发生,我们一般都是采用在swap时带上 version版本号(而这其实就是乐观锁的实现)。
因此,任何一种技术都有其两面性存在,在用的很爽、没问题的同时,也势必会存在一些令人头疼的问题。
对于ABA问题,JDK也提供了两种方式加以解决,AtomicStampedReference、AtomicMarkableReference 便可以解决问题。至于这两者底层的实现机制,咱们抽空再找个时间讨论了!
总结:
本文开篇主要是通过一个“网站访问计数器”的场景案例,介绍多线程并发访问共享的代码块时所出现的“并发安全性”问题,紧接着我们先后采用了synchronize关键字、Lock锁和原子操作类AtomicXX保证实现该共享代码的“并发安全”,之后,我们还特意比较了synchronize关键字 和 其他两者的区别,可以说 前者是 悲观锁的典型代表;而后两者是 乐观机制的 一些实现。
除此之外,我们还介绍了原子操作类 AtomicXX 底层实现机制,即CAS算法,而CAS算法之所以可以实现,则在于 volatile 修饰了共享的变量,保证线程之间变量的可见性,核心伪代码为 unsafe.compareAndSwapInt(V,A,B);当然啦,宇宙万物向来都具有“阴阳两极”两种特性,AtomicXX 也不例外,其如果操作不当,将会带来 ABA 的问题,而AtomicStampedReference、AtomicMarkableReference则可以解决这种问题(毕竟 天无绝人之路)
补充:
1、若想学习其他的技术干货,可以前往Debug自建的技术社区进行学习观看,包括技术专栏、博客和课程哦:https://www.fightjava.com/
2、关注一下Debug的技术微信公众号,最新的技术文章、课程以及技术专栏将会第一时间在公众号发布哦!