面试官:请你聊聊并发中的cas机制吧
小明:嗯,cas是吧,好像听说过...我想想哈(大脑飞速思考)
2分钟过去了...
空气是死一般的沉静...
面试官坐不住了,清了清嗓子:咳咳... 那个,能简单说说吗?
小明憨憨一笑:嘿嘿,我好像忘记了...
面试官:哦,没关系,今天的面试就到这吧,你回去等通知吧
小明垂头丧气地离开了...
别笑,小明其实是很多人的影子,在面试过程中尬聊的同学不在少数,当然我也包括在内,其实这反映出一个很残酷的现实:基础不扎实!
那么问题来了,如何在面试中吊打面试官,稳如磐石?
学呀!光说有啥用,你得学啊,买的书你得看啊,买的课你得跟着练啊,别光打游戏追剧了,想要变强,唯有秃头!
现在是北京时间0:08,我在码字写文章,你呢?
一个小例子说说什么是线程安全 并发是java编程的基础,在我们日常的工作中,很多时候都会跟并发打交道,当然,这也是面试考察的重点。在并发编程中,被提起最多的概念是线程安全,下面我们先来看一段代码,看看运行后会发生什么:
public class test { private static int inc = 0; public static void main(string[] args) { // 设置栅栏,保证主线程能获取到程序各个线程全部执行完之后的值 countdownlatch countdownlatch = new countdownlatch(1000000); // 设置100个线程同时执行 for (int i = 0; i < 100; i++) { new thread(() -> { // 循环10000次,对inc实现 +1 操作 for (int j = 0; j < 10000; j++) { inc++; countdownlatch.countdown(); } }).start(); } try { countdownlatch.await(); } catch (interruptedexception e) { e.printstacktrace(); } // 运行完毕,期望获取的结果是 1000000 system.out.println("执行完毕,inc的值为:" + inc); }}
程序中,我创建了100个线程,每个线程中对共享变量inc进行累加10000次的操作,如果是同步执行的话,inc最终的值应该是1000000,但我们知道在多线程中,程序是并发执行的,也就是说不同的线程可能会同时读取到主内存相同的值,比如这样的场景:
线程a在某一个瞬间读取了主内存的inc值为1000,它在自己的工作内存 +1,inc变成了1001;线程b在同样的瞬间读取到了主内存的inc值为1000,它也在自己的工作内存中对inc的值 +1, inc变成了1001;他们要往主内存写入inc的值的时候并没有做任何的同步控制,所以他们都有可能把自己工作内存的1001写入到主内存;那么很显然主内存在进行两次 +1 操作后,实际的结果只进行了一次 +1,变成了1001。这就是一个很典型的多线程并发修改共享变量带来的问题,那么很显然,它的运行结果也如我们分析的那样,某些情况下达不到1000000:
执行完毕,inc的值为:962370
有些人说通过volatile关键字可以解决这个问题,因为volatile可以保证线程之间的可见性,也就是说线程可以读取到主内存最新的变量值,然后对其进行操作。
注意了,volatile只能保证线程的可见性,而不能保证线程操作的原子性,虽然线程读取到了主内存的inc的最新值,但是 读取、inc+1、写入主内存 是三步操作,所以volatile无法解决共享变量线程安全的问题。
那么如何解决这个问题?java为我们提供下面几种解决方案。
几种保证线程安全的方案 1. 通过synchronized关键字实现同步:public class test { private static int inc = 0; public static void main(string[] args) { // 设置栅栏,保证主线程能获取到程序各个线程全部执行完之后的值 countdownlatch countdownlatch = new countdownlatch(1000000); // 设置100个线程同时执行 for (int i = 0; i < 100; i++) { new thread(() -> { // 循环10000次,对inc实现 +1 操作 for (int j = 0; j < 10000; j++) { // 设置同步机制,让inc按照顺序执行 synchronized (test.class) { inc++; } countdownlatch.countdown(); } }).start(); } try { countdownlatch.await(); } catch (interruptedexception e) { e.printstacktrace(); } system.out.println("执行完毕,inc的值为:" + inc); }}
在上面的代码中,我们给 inc ++ 外面加了一层代码,使用 synchronized 设置类锁,保证了代码的同步执行,这是一种基于jvm自身的机制来保障线程的安全性,如果在并发量比较大的情况下,synchronized 会升级为重量级的锁,效率很低。synchronized无法获取当前线程的锁状态,发生异常的情况下会自动解锁,但是如果线程发生阻塞,它是不会释放锁的
执行结果:
执行完毕,inc的值为:1000000
可以看到,这种方式是可以保证线程安全的。
2. 通过lock锁实现同步public class test { private static int inc = 0; private static lock lock = new reentrantlock(); public static void main(string[] args) { // 设置栅栏,保证主线程能获取到程序各个线程全部执行完之后的值 countdownlatch countdownlatch = new countdownlatch(1000000); // 设置100个线程同时执行 for (int i = 0; i < 100; i++) { new thread(() -> { // 循环10000次,对inc实现 +1 操作 for (int j = 0; j < 10000; j++) { // 设置锁 lock.lock(); try { inc++; } finally { // 解锁 lock.unlock(); } countdownlatch.countdown(); } }).start(); } try { countdownlatch.await(); } catch (interruptedexception e) { e.printstacktrace(); } system.out.println("执行完毕,inc的值为:" + inc); }}
reentrantlock的底层是通过aqs + cas来实现的,在并发量比较小的情况下,它的性能不如 synchronized,但是随着并发量的增大,它的性能会越来越好,达到一定量级会完全碾压synchronized。并且lock是可以尝试获取锁的,它通过代码手动去控制解锁,这点需要格外注意。
执行结果:
执行完毕,inc的值为:1000000
3. 使用 atomic 原子类public class test { private static atomicinteger inc = new atomicinteger(); public static void main(string[] args) { // 设置栅栏,保证主线程能获取到程序各个线程全部执行完之后的值 countdownlatch countdownlatch = new countdownlatch(1000000); // 设置100个线程同时执行 for (int i = 0; i < 100; i++) { new thread(() -> { // 循环10000次,对inc实现 +1 操作 for (int j = 0; j < 10000; j++) { inc.getandadd(1); countdownlatch.countdown(); } }).start(); } try { countdownlatch.await(); } catch (interruptedexception e) { e.printstacktrace(); } system.out.println("执行完毕,inc的值为:" + inc.get()); }}
atomicinteger 底层是基于 cas 的乐观锁实现的,cas是一种无锁技术,相对于前面的方案,它的效率更高一些,在下面会详细介绍。
执行结果:
执行完毕,inc的值为:1000000
4. 使用 longadder 原子类public class test { private static longadder inc = new longadder(); public static void main(string[] args) { // 设置栅栏,保证主线程能获取到程序各个线程全部执行完之后的值 countdownlatch countdownlatch = new countdownlatch(1000000); // 设置100个线程同时执行 for (int i = 0; i < 100; i++) { new thread(() -> { // 循环10000次,对inc实现 +1 操作 for (int j = 0; j < 10000; j++) { inc.increment(); countdownlatch.countdown(); } }).start(); } try { countdownlatch.await(); } catch (interruptedexception e) { e.printstacktrace(); } system.out.println("执行完毕,inc的值为:" + inc.intvalue()); }}
longadder 原子类在 jdk1.8 中新增的类,其底层也是基于 cas 机制实现的。适合于高并发场景下,特别是写大于读的场景,相较于 atomicinteger、atomiclong 性能更好,代价是消耗更多的空间,以空间换时间。
执行结果:
执行完毕,inc的值为:1000000
cas理论 讲到现在,终于我们今天的主角要登场了,她就是cas。
cas的意思是比较与交换(compare and swap),它是乐观锁的一种实现机制。
什么是乐观锁?通俗的来说就是它比较乐观,每次在修改变量的值之前不认为别的线程会修改变量,每次都会尝试去获得锁,如果获取失败了,它也会一直等待,直到获取锁为止。说白了,它就是打不死的小强。
而悲观锁呢,顾名思义,就比较悲观了,每次在修改变量前都会认为别人会动这个变量,所以它会把变量锁起来,独占,直到自己修改完毕才会释放锁。说白了,就是比较自私,把好东西藏起来自己偷偷享用,完事了再拿出来给别人。像之前的synchronized关键字就是悲观锁的一种实现。
cas是一种无锁原子算法,它的操作包括三个操作数:需要读写的内存位置(v)、预期原值(a)、新值(b)。仅当 v值等于a值时,才会将v的值设为b,如果v值和a值不同,则说明已经有其他线程做了更新,则当前线程继续循环等待。最后,cas 返回当前v的真实值。cas 操作时抱着乐观的态度进行的,它总是认为自己可以成功完成操作。
cas的实现 在java中,juc的atomic包下提供了大量基于cas实现的原子类:
我们以atomicinteger来举例说明。
atomicinteger类内部通过一个unsafe类型的静态不可变的变量unsafe来引用unsafe的实例。
// setup to use unsafe.compareandswapint for updatesprivate static final unsafe unsafe = unsafe.getunsafe();
然后,atomicinteger类用value保存自身的数值,并用get()方法对外提供。注意,它的value是使用volatile修饰的,保证了线程的可见性。
private volatile int value;/** * creates a new atomicinteger with the given initial value. * * @param initialvalue the initial value */public atomicinteger(int initialvalue) { value = initialvalue;}/** * gets the current value. * * @return the current value */public final int get() { return value;}
一路跟踪incrementandget方法到的末尾可以看到是一个native的方法:
/** * atomically increments by one the current value. * * @return the updated value */public final int incrementandget() { return unsafe.getandaddint(this, valueoffset, 1) + 1;}// getandaddint 方法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;}// compareandset方法public final native boolean compareandswapint(object var1, long var2, int var4, int var5);
可以看到其实incrementandget内部的原理就是通过compareandswapint调用底层的机器指令不断比较内存旧值和期望的值,如果比较返回false就继续循环比较,如果返回true则将当前的新值赋给内存里的值,本次处理完毕。
由此我们知道,原子类实现的自增操作可以保证原子性的根本原因在于硬件(处理器)的相关指令支持。将语义上需要多步操作的行为通过一条指令来完成,cas指令可以达到这个目的。
cas的缺点 作为乐观锁的一种实现,当多线程竞争资源激烈的情况下,多个线程会发生自旋等待,会消耗一定的 cpu资源。cas不可避免会出现aba的问题,关于aba问题的阐释和解决方案,可以参考我的这篇文章:面试官问你:你知道什么是aba问题吗?
好了,本期关于cas的分享就到这里结束了。并发作为java编程的基石,是一个非常重要的知识点,如果同学们对这块的掌握比较薄弱,希望在读完文章后能自己动手敲敲代码,思考一下什么是cas,有哪些优缺点,实现方式有哪些。当然,并发是一个非常大的概念,这里只是抛砖引玉,提到了其中的一个小知识点,给出了我自己学习的一点心得体会。如果有阐述不到位或者错误的地方,请私信我一起讨论,感谢!
我是程序员青戈,本期的面试问题分享到这里就结束了,想提升自己,进阶大厂的同学一定要关注我的公众号:java学习指南,在这里每天会从实际的面试出发带你学习和总结java相关的知识,帮助你扩充技术栈,提升个人实力。我们下期见~
以上就是面试真题:请你聊聊并发中的cas 机制的详细内容。
