Java锁之乐观锁、悲观锁、自旋锁

java锁分为三大类乐观锁、悲观锁、自旋锁

乐观锁:乐观锁是一种乐观思想,即认为读多写少,遇到并发写的可能性低,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,采取在写时先读出当前版本号,然后加锁操作(比较跟上一次的版本号,如果一样则更新),如果失败则要重复读-比较-写的操作。java 中的乐观锁基本都是通过 CAS 操作实现的,CAS 是一种更新的原子操作,比较当前值跟传入值是否一样,一样则更新,否则失败

悲观锁:悲观锁是就是悲观思想,即认为写多,遇到并发写的可能性高,每次去拿数据的时候都认为别人会修改,所以每次在读写数据的时候都会上锁,这样别人想读写这个数据就会 block 直到拿到锁。java中的悲观锁就是Synchronized,AQS框架下的锁则是先尝试cas乐观锁去获取锁,获取不到,才会转换为悲观锁,如 RetreenLock

自旋锁:自旋锁原理非常简单,如果持有锁的线程能在很短时间内释放锁资源,那么那些等待竞争锁的线程就不需要做内核态和用户态之间的切换进入阻塞挂起状态,它们只需要等一等(自旋),等持有锁的线程释放锁后即可立即获取锁,这样就避免用户线程和内核的切换的消耗。线程自旋是需要消耗 cup 的,说白了就是让 cup 在做无用功,如果一直获取不到锁,那线程也不能一直占用 cup 自旋做无用功,所以需要设定一个自旋等待的最大时间。如果持有锁的线程执行的时间超过自旋等待的最大时间扔没有释放锁,就会导致其它争用锁的线程在最大等待时间内还是获取不到锁,这时争用线程会停止自旋进入阻塞状态。

自旋锁的优缺点:

  自旋锁尽可能的减少线程的阻塞,这对于锁竞争不激烈,且占用锁时间非常短的代码块来说性能大幅度提升,因为自旋锁的消耗会小于线程阻塞挂起在唤醒的消耗,这些操作会导致线程发生两次线程上下文切换。

  但是如果锁的竞争激烈,或者持有锁的线程需要长时间占用锁执行同步块,这时候就不适合使用自旋锁了,因为自旋锁在获取锁前一直都是占用 cpu 做无用功,占着 XX 不 XX,同时有大量线程在竞争一个锁,会导致获取锁的时间很长,线程自旋的消耗大于线程阻塞挂起操作的消耗,其它需要 cup 的线程又不能获取到 cpu,造成 cpu 的浪费。所以这种情况下我们要关闭自旋锁;

 

自旋锁时间阈值(1.6 引入了适应性自旋锁)

  自旋锁目的是为了占着cpu的资源不释放,等到获取到锁立即进行处理,但是如何去选择自旋锁的执行时间?如果自旋时间太长,会有大量的线程处于自旋状态占用cpu资源,进而会影响整个系统的性能,因为自旋周期很重要 - -~。

Jvm对于自旋周期在1.5的时候是写死的在1.6的时候后引入了适应性自旋锁,适应性自旋锁意味着自旋的时间不在是固定的了,而是由前一次在同一个锁上的自旋时间以及锁的拥有者的状态来决定,基本认为一个线程上下文切换的时间是最佳的一个时间,同时 JVM 还针对当前 CPU 的负荷情况做了较多的优化,如果平均负载小于 CPUs 则一直自旋,如果有超过(CPUs/2)
个线程正在自旋,则后来线程直接阻塞,如果正在自旋的线程发现 Owner 发生了变化则延迟自旋时间(自旋计数)或进入阻塞,如果 CPU 处于节电模式则停止自旋,自旋时间的最坏情况是 CPU的存储延迟(CPU A 存储了一个数据,到 CPU B 得知这个数据直接的时间差),自旋时会适当放弃线程优先级之间的差异。

自旋锁的开启
JDK1.6 中-XX:+UseSpinning 开启;-XX:PreBlockSpin=10 为自旋次数;JDK1.7 后,去掉此参数,由 jvm 控制;

 

 

CAS算法是什么

CAS(比较与交换,Compare and swap) 是一种有名的无锁算法(后面会提到什么是无锁(lock-free))。CAS是一种CPU级别的指令,在大多数处理器架构,包括IA32、Space中采用的都是CAS指令。顾名思义这个算法分为两部分,比较和交换,一共有三个数据,A数据,B数据,C数据

A数据是内存的最新值,是volatile类型的,对于所有线程都是可见的
B是相当于是A的快照数据(一份缓存)
C是A期望的值,也就是结果值

开始有两个线程,同时需要修改内存中的同一个变量S,两个变量要同时对S的值加一
正常情况下,第一个线程拿到内存中的最新值放到A(对于所有线程是可见的)中,同时将这个最新值打一个快照将值放到B中,然后对B中的值进行加一操作结果放到C中,判断A中的值和B中的值是否相等,这时A的值和B的值是相等的,将C的值放到A中,其他线程实时看到A的最新值,第二个线程在第一个线程之后执行相同的操作没什么问题。
上图理解一下:
在这里插入图片描述

但是在并发情况下,第一个线程拿到内存中的最新值放到A中,第二个线程同样拿到内存中的罪行值放到A中,两个线程同时将这个最新值打一个快照将值放到B中,然后对B中的值进行加一操作结果放到C中,线程一判断A中的值和B中的值是否相等,这时A的值和B的值是相等的,将C的值放到A中,让其他线程实时看到A的最新值,但是这个时候线程二发现A的值已经和B的值不相等了,那么线程二中之前的操作全部作废,重新再来一遍,线程二再次拿到最新的值放到A中,同时将快照放到B中,加完一之后的值放到C中,判断A和B的值,此时没有人修改内存中的值,A和B的值相等,将C的值放到A中,修改内存中的变量。

上图理解一下:
在这里插入图片描述

通过上面的比较和交换,附加回滚操作,使得多线程之间的并发问题得以解决。
当通过以上方式理解透彻了之后,就可以明白实际上CAS操作是通过对内存中的值缓存(B),然后将操作好的数据缓存(C),判断内存中最新的值是否与自身缓存的值(B)相等,如果相等说明自己缓存的是最新的值,如果不相等,说明自己缓存的值已经是过期的数据,需要重新执行一遍刚才的操作,这就是CAS算法(是不是很简单,虽然这样说很简单,但是在实际使用过程中用起来还是比较麻烦的)。

实际上java.util.concurrent.atomic中的AtomicXXX,都使用了这些类似的算法保证了原子操作保证了多线程下并发的问题。

CAS算法的性质

通过对CAS算法的理解再理解CAS的性质就非常的容易了
1.属于乐观锁,说到乐观锁就得提一下锁的概念了,锁就是两个人同时上厕所,但是厕所只有一个,第一个人上厕所把门锁住了,第二个人就不能上,这个门锁就是我们程序中所谓的锁。

在这个基础上又产生出了乐观锁和悲观锁,还拿上面的例子举例,悲观锁就是,第一个人上厕所的时候,第二个人只能在外面等待,只能憋着什么都不做;而乐观锁就是,第一个人上厕所的时候,第二个人不断的敲门问里面的人是否上完了,这就是乐观锁。总结一下就是悲观锁悲观的认为共享的东西是有并发的,需要将共享的东西锁住,而乐观锁就是乐观的认为共享的东西是没有并发的,只有我一个人进行修改,只不过是失败了就进行不断的尝试,直至达到预期,说白了就是不断的进行想要达到效果的操作。

2.非阻塞的,因为在不断的尝试当中是没有线程被阻塞的,所有的线程都在轮循自己的任务,没有线程在进行等待,所以是非阻塞的。

3.无锁(lock-free),就是没有锁进行控制,所有的线程都执行自己的任务,都去尝试执行自己的操作。
说到无锁就不得不提wait-free,可以看到lock-free下还是有线程有可能在一直循环,导致死循环而不能退出,但是wait-free是保证可以在有限的循环内必定会结束,也就是在lock-free死循环尝试的情况下可以在有限的步骤内执行完毕(可能是10,100,1000…)。这是我的理解,而且现在关于wait-free的算法或者实现不是很多,或者wait-free的效率还不如lock-free的效率,因为wait-free虽然说是有限的步骤,但是这个有限的步骤可能非常大,导致执行起来反倒会慢,所以我也没做过多的研究。

ABA问题

其实ABA问题很简单,就是有两个线程同时修改共享变量,线程一获取到内存最新值为1,快照值也为1,想要修改为2,这时线程二也获取到最新值为1,快照为1,想要修改为2,线程二执行完成之后,将内存改为2,之后线程二又要执行一个操作,想要将值减1,这时线程二有获取最新值为2,快照值为2,想要修改为1,执行完毕后将内存最新的值修改为1,这时线程一发现内存最新值和快照值相等,将内存值修改为2。可以发现线程二执行减1的操作对于线程一来说丢失了,此时就会产生问题,达不到我们预期想要达到的效果。

在这里插入图片描述

可能有的人可能要问了,线程一想要进行加1的操作,线程二想要加1后减1,最后的结果不应该是2么,应该没什么问题,但是如果线程一和线程二修改的是引用中的内容呢,线程二已经将引用修改了,但是线程一感知不到,还要去修改原来引用中的内容,这时就可能导致访问野指针,进而访问不到数据。实际上产生这个问题的原因就是在进行CAS的过程中无法判断这个变量是否是被别人进行了加一减一的操作,从而导致判断失误。
所以在解决此类问题的时候会在获取和赋值的时候给变量加一个版本号的东西,这时候线程一再进行判断的时候发现虽然值相同但是版本号不同也会去重新获取最新数据进行CAS操作。

 

posted @ 2021-02-21 19:04  abcdefghijklmnop  阅读(502)  评论(0编辑  收藏  举报