悲观锁顾名思义是从悲观的角度去思考问题,解决问题。它总是会假设当前情况是最坏的情况,在每次去拿数据的时候,都会认为数据会被别人改变,因此在每次进行拿数据操作的时候都会加锁,如此一来,如果此时有别人也来拿这个数据的时候就会阻塞知道它拿到锁。在Java中,Synchronized和ReentrantLock等独占锁的实现机制就是基于悲观锁思想。在数据库中也经常用到这种锁机制,如行锁,表锁,读写锁等,都是在操作之前先上锁,保证共享资源只能给一个操作(一个线程)使用。
由于悲观锁的频繁加锁,因此导致了一些问题的出现:比如在多线程竞争下,频繁加锁、释放锁导致频繁的上下文切换和调度延时,一个线程持有锁会导致其他线程进入阻塞状态,从而引起性能问题。
乐观锁从字面上看是从积极,乐观的角度去看待问题,因此它认为数据一般不会产生冲突,因此一般不加锁,当数据进行提交更新时,才会真正对数据是否产生冲突进行监测。如果发生冲突,就返回给用户错误信息,由用户来决定如何去做,主要有两个步骤:冲突检测和数据更新。
CAS(compare and swap),比较和更新。CAS是乐观锁的技术实现,当多个线程尝试使用CAS同时来更新同一个变量,只有一个线程能够更新变量值,而其他的线程都会失败,失败的线程并不会被挂起,告知这次竞争失败,可以再次尝试。
CAS操作包含三个操作数:
如果内存位置V的值与原预期值A相匹配,那么处理器就会自动将该位置更新为新值B,否则处理器不做任何处理。乐观锁是一种思想,CAS是这种思想的一种实现方法。Java中对CAS支持,在jdk1.5之后新增java.util.concurrent(J.U.C)就是建立CAS基础上,CAS是一种非阻塞的实现,例如:Atomic
Atomic(具有原子操作特征的类)指一个操作是不可中断的,即使是在多个线程一起执行的时候,一个操作一旦开始,就不会被其他线程干扰。
并发包 java.util.concurrent
的原子类都存放在java.util.concurrent.atomic
下,通常被简称为J.U.C,其主要有以下方法:
基本类型
使用原子的方式更新基本类型
数组类型
使用原子的方式更新数组里的某个元素
引用类型
对象的属性修改类型
在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的问题
假如如下事件序列:
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问题解决思路就是使用版本号,在变量前面追加版本号,每次对变量你进行更新的时候对版本进行加1,对于A->B->A 就会变成1A ->2B->3A
ABA问题可以使用版本号解决
自旋CAS如果长时间不成功,CPU带来非常大的执行开销,需要考虑长时间循环问题,给每个线程循环给定循环次数阈值,让当前线程释放CPU的使用权,进入阻塞中
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当中。
在持有锁运行,而另一线程则切换进程状态至:阻塞