详解Java中的悲观锁与乐观锁
作者:resumebb
一、悲观锁
悲观锁顾名思义是从悲观的角度去思考问题,解决问题。它总是会假设当前情况是最坏的情况,在每次去拿数据的时候,都会认为数据会被别人改变,因此在每次进行拿数据操作的时候都会加锁,如此一来,如果此时有别人也来拿这个数据的时候就会阻塞知道它拿到锁。在Java中,Synchronized和ReentrantLock等独占锁的实现机制就是基于悲观锁思想。在数据库中也经常用到这种锁机制,如行锁,表锁,读写锁等,都是在操作之前先上锁,保证共享资源只能给一个操作(一个线程)使用。
由于悲观锁的频繁加锁,因此导致了一些问题的出现:比如在多线程竞争下,频繁加锁、释放锁导致频繁的上下文切换和调度延时,一个线程持有锁会导致其他线程进入阻塞状态,从而引起性能问题。
二、乐观锁
乐观锁从字面上看是从积极,乐观的角度去看待问题,因此它认为数据一般不会产生冲突,因此一般不加锁,当数据进行提交更新时,才会真正对数据是否产生冲突进行监测。如果发生冲突,就返回给用户错误信息,由用户来决定如何去做,主要有两个步骤:冲突检测和数据更新。
三、CAS
CAS(compare and set),比较和更新。CAS是乐观锁的技术实现,当多个线程尝试使用CAS同时来更新同一个变量,只有一个线程能够更新变量值,而其他的线程都会失败,失败的线程并不会被挂起,告知这次竞争失败,可以再次尝试。
CAS操作包含三个操作数:
- 需要读写的内存位置(V)
- 需要比较的预期原值(A)
- 拟写入的新值(B)
如果内存位置V的值与原预期值A相匹配,那么处理器就会自动将该位置更新为新值B,否则处理器不做任何处理。乐观锁是一种思想,CAS是这种思想的一种实现方法。Java中对CAS支持,在jdk1.5之后新增java.util.concurrent(J.U.C)就是建立CAS基础上,CAS是一种非阻塞的实现,例如:Atomic
四、AtomicXXX
在Java中,提供了一些原子化的操作类型,如下操作
private volatile int value; public final int get() { return value; }
读取的值,value是声明为volatile的,就可以保证在没有锁的情况下,线程可见性
在涉及到数据变更,以incrementAndGet实例:++i操作
public final int incrementAndGet() { for (;;) { int current = get(); int next = current + 1; if (compareAndSet(current, next)) return next; } }
采用的CAS的操作,每次读取内存中的数据,让后将数据+1的结果进行CAS操作,如果成功就返回结果,负责重试指导成功为止,这里调用compareAndSet是CAS所依赖的JNI的实现的乐观锁 。
public final boolean compareAndSet(int expect, int update) { return unsafe.compareAndSwapInt(this, valueOffset, expect, update); }
Atomic就是volatile的使用场景,也是CAS的使用场景。
五、CAS中的ABA问题
CAS使用起来能够提高性能,但会引起ABA的问题
假如如下事件序列:
1、线程1从内次位置V来获取值A
2、线程2从内存位置V获取A
3、线程2进行一些操作,将B写入到V
4、线程2将A写入位置V
5、线程1进行CAS操作,发现位置V的值任然为A,操作成功了
6、线程1尽管CAS操作成功了,该过程有可能出现问题,对于线程1,线程2做的处理就可能丢失了
举例说明:一个链表ABA的例子
1、现有一个用单向链表实现的堆栈,栈顶为A。这时线程T1已经知道A.next为B,然后希望用CAS将栈顶替换为B:
1head.compareAndSet(A,B);
2、在T1执行上面这条指令之前,线程T2介入,将A、B出栈,再依次入栈D、C、A,而对象B此时处于游离状态。
3、此时轮到线程T1执行CAS操作,检测发现栈顶仍为A,所以CAS成功,栈顶变为B。但实际上B.next为null,此时堆栈中只有B一个元素,C和D组成的链表不再存在于堆栈中,C、D被丢掉了。
六、ABA问题解决方案
ABA问题解决思路就是使用版本号,在变量前面追加版本号,每次对变量你进行更新的时候对版本进行加1,对于A->B->A 就会变成1A ->2B->3A
七、使用CAS会引起的问题
1.ABA问题
ABA问题可以使用版本号解决
2.循环时间长开销大
自旋CAS如果长时间不成功,CPU带来非常大的执行开销,需要考虑长时间循环问题,给每个线程循环给定循环次数阈值,让当前线程释放CPU的使用权,进入阻塞中
3.只能保证一个共享变量的原子操作
八、Synchronized锁优化
JDK1.5之前, Synchronized称之为“重量级锁”,对该做了各种所有,分别为偏向锁、轻量级锁、重量级锁
Java对象内存布局:
说到 synchronized 加锁原理与Java对象在内存中的布局有很大关系, Java 对象内存布局如下:
如上图所示,在创建一个对象后,在 JVM 虚拟机( HotSpot )中,对象在 Java 内存中的存储布局 可分为三块:
对象头区域
存放锁信息,对象年龄等信息
实例数据区域
此处存储的是对象真正有效的信息,比如对象中所有字段的内容
对齐填充区域
JVM 的实现 HostSpot 规定对象的起始地址必须是 8 字节的整数倍,换句话来说,现在 64 位的 OS 往外读取数据的时候一次性读取 64bit 整数倍的数据,也就是 8 个字节,所以 HotSpot 为了高效读取对象,就做了"对齐",如果一个对象实际占的内存大小不是 8byte 的整数倍时,就"补位"到 8byte 的整数倍。所以对齐填充区域的大小不是固定的。
synchronized用的锁是存在Java对象头里的,如果对象是数组类型,则虚拟机用3个字宽(Word)存储对象头,如果对象是非数组类型,则用2字宽存储对象头。在32位虚拟机中,1字宽等于4字节,即32bit,如下图:
Java对象头里的Mark Word里默认存储对象的HashCode、分代年龄和锁标记位。32位JVM的Mark Word的默认存储结构如下图所示:
在Java SE 1.6中,锁一共有4种状态,级别从低到高依次是:无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态,这几个状态会随着竞争情况逐渐升级。锁可以升级但不能降级,意味着偏向锁升级成轻量级锁后不能降级成偏向锁。这种锁升级却不能降级的策略,目的是为了提高获得锁和释放锁的效率。
九、偏向锁
偏向锁的操作根本没有去找操作系统, 每个对象都有对象头,看看这个account对象的所谓“对象头”,其中有个叫做Mark Word:里边有几个标识位,还有其他数据。
JVM使用CAS操作把线程ID记录到了这个Mark Word当中,修改了标识位,当前线程就拥有这把锁了
可以看出:JVM不用和操作系统协商设置Mutex,它只记录下线程ID,就表示当前线程拥有这把锁了,不用操作系统介入
这时线程获得了锁,可以执行synchronized修饰的代码块。
当线程再次执行到这个synchronized的时候,JVM通过锁对象account的Mark Word判断:“当前线程ID还在,还持有着这个对象的锁,就可以继续进入临界区执行
这就是偏向锁,在没有别的线程竞争的时候,一直偏向当前线程,当前线程可以一直执行
十、轻量级锁
继续沿着偏向锁思路研究
另一个线程0x3704也要进入这个代码块执行,但是锁对象account 保存的是当前线程ID,他是没法进入临界区的。
这时也不需要和操作系统交流,JVM可以对偏向锁升级一下,变成一个轻量级的锁。
JVM把锁对象account恢复成无锁状态,在当前两线程的栈帧中各自分配了一个空间,叫做Lock Record,把锁对象account的Mark Word在俩线程的栈帧中各自复制了一份,叫做Displaced Mark Word
然后当前线程的Lock Record的地址使用CAS放到了Mark Word当中,并且把锁标志位改为00, 这意味着当前线程也已经获得了这个轻量级的锁了,可以继续进入临界区执行。
0x3704线程没有获得锁,但不阻塞,JVM让他自旋几次,等待一会儿。等当前退出临界区,释放锁的时候,需要把这个Displaced markd word 使用CAS复制回去。接下来他就可以加锁了。
两线程交替着进入临界区,执行这段代码,相安无事,很少出现真正的竞争。
即使是出现了竞争,想获得锁的线程只要自旋几次,等待一会儿,锁就可能释放了。
很明显,如果没有竞争或者轻度的竞争,轻量级锁仅仅使用CAS操作和Lock record就避免了重量级互斥锁的开销
十一、重量级锁
再次分析:轻量级锁运行时,一线程0x3704 正在持有锁。另一线程自旋了好多次,0x3704还是没释放锁。 这时候JVM考虑自旋次数太多了浪费CPU。接则升级为重量级锁!
重量级锁需要操作系统的介入,依赖操作系统底层的Mutex Lock。
JVM创建了一个monitor 对象,把这个对象的地址更新到了Mark word当中。
在持有锁运行,而另一线程则切换进程状态至:阻塞
到此这篇关于详解Java中的悲观锁与乐观锁的文章就介绍到这了,更多相关悲观锁与乐观锁内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!