乐观锁和悲观锁
悲观锁:总是假设最坏的情况,每次获取数据时都会认为有其他线程对该数据进行修改,所以需要阻塞其他线程,直到自己释放锁。synchronized关键字就是典例。
悲观锁机制存在以下问题:
- 在多线程竞争下,加锁、释放锁会导致比较多的上下文切换和调度延时,引起性能问题。
- 一个线程持有锁会导致其它所有需要此锁的线程挂起。
- 如果一个优先级高的线程等待一个优先级低的线程释放锁会导致优先级倒置,引起性能风险。
乐观锁假设数据一般情况下不会产生并发冲突,再读取数据时不作操作,在更新数据的时候进行冲突并发检测,如果并发冲突了,则返回错误信息
乐观锁实现:CAS
需要三个数据:目标地址的值,目标地址期望值,自己对目标地址的更新值。
拿到目标地址的值,和期望值做对比,如果相同,则说明没有线程修改目标地址的值,那么自己对目标地址的值进行更新。
CAS实例:不加锁实现线程安全,针对单一共享变量
public class AtomicInteger extends Number implements java.io.Serializable {
private volatile int value;
public final int get() {
return value;
}
public final int getAndIncrement() {
for (;;) {
int current = get();
int next = current + 1;
if (compareAndSet(current, next))
return current;
}
}
public final boolean compareAndSet(int expect, int update) {
return unsafe.compareAndSwapInt(this, valueOffset, expect, update);
}
}
get()方法得到volatile修饰的变量,作为expect值,使用next作为更新后的值,借用JNI完成CPU指令操作,CPU指令支持CAS操作。所以底层仍然依赖CPU。
CAS缺点
- ABB问题
因为CAS需要在操作值的时候检查下值有没有发生变化,如果没有发生变化则更新,但是如果一个值原来是A,变成了B,又变成了A,那么使用CAS进行检查时会发现它的值没有发生变化,但是实际上却变化了。ABA问题的解决思路就是使用版本号。在变量前面追加上版本号,每次变量更新的时候把版本号加一,那么A-B-A 就会变成1A-2B-3A。
从Java1.5开始JDK的atomic包里提供了一个类AtomicStampedReference来解决ABA问题。这个类的compareAndSet方法作用是首先检查当前引用是否等于预期引用,并且当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的更新值。
- 循环时间长,开销大
- 只能保证一个共享变量的原子操作
解决:使用锁或者将多个变量合并成一个变量进行操作。或者使用AtomicReference类来保证引用对象的原子性。
CAS与Synchronized的使用情景:
1、对于资源竞争较少(线程冲突较轻)的情况,使用synchronized同步锁进行线程阻塞和唤醒切换以及用户态内核态间的切换操作额外浪费消耗cpu资源;而CAS基于硬件实现,不需要进入内核,不需要切换线程,操作自旋几率较少,因此可以获得更高的性能。
2、对于资源竞争严重(线程冲突严重)的情况,CAS自旋的概率会比较大,从而浪费更多的CPU资源,效率低于synchronized。
补充: synchronized在jdk1.6之后,已经改进优化。synchronized的底层实现主要依靠Lock-Free的队列,基本思路是自旋后阻塞,竞争切换后继续竞争锁,稍微牺牲了公平性,但获得了高吞吐量。在线程冲突较少的情况下,可以获得和CAS类似的性能;而线程冲突严重的情况下,性能远高于CAS。
自旋锁
定义
当一个线程在获取到锁的时候,如果该锁已被其他线程获取,那么该线程将循环等待,然后不断地判断锁是否能成功获取,直到获取锁才退出循环。
public class SpinLock {
private AtomicReference<Thread> cas = new AtomicReference<Thread>();
public void lock() {
Thread current = Thread.currentThread();
// 利用CAS
while (!cas.compareAndSet(null, current)) {
// DO nothing
//像不像线程在旋转的感觉
}
}
public void unlock() {
Thread current = Thread.currentThread();
cas.compareAndSet(current, null);
}
}
compareAndSet如下:
public final boolean compareAndSet(int expect, int update) {
return unsafe.compareAndSwapInt(this, valueOffset, expect, update);
}
而compareAndSwapInt类似下述逻辑:
if (this == expect) {
this = update
return true;
} else {
return false;
}
即cas.compareAndSet(null, current)
在判断锁是否有线程在占用,如果有线程在占用,则返回false,那么进入while循环,开始自旋。
unlock()方法类似。
优点
- 自旋锁不会使线程状态发生切换,一直处于用户态,即线程一直都是active的;不会使线程进入阻塞状态,减少了不必要的上下文切换,执行速度快
- 非自旋锁在获取不到锁的时候会进入阻塞状态,从而进入内核态,当获取到锁的时候需要从内核态恢复,需要线程上下文切换。 (线程被阻塞后便进入内核(Linux)调度状态,这个会导致系统在用户态与内核态之间来回切换,严重影响锁的性能)
自旋锁存在的问题
- 长时间未获取到锁,即长时间自旋,会消耗CPU资源,使用不当会造成CPU使用率极高。(线程如果无限循环,而且还占用CPU不干事儿,不是白吃饭不干活吗)
- 自旋锁不是公平的,无法满足等待时间最长的线程有限获取锁,可能存在线程饥饿问题。
可重入自旋锁和不可重入自旋锁
上述实现自旋锁的代码,观察lock()的while代码段,如果自己已经获取到该锁,就不可能再次获取到该锁。因为第二次获取到该锁时会进行是否有线程已获取到该锁的判断,该判断必定返回false,则必定进入while进行循环等待。即便第二次重新获取到该锁,在进行unlock()方法释放锁的时候也会将获取到的锁全部释放。
为了实现可重入锁,需要引入锁的计数器
public class ReentrantSpinLock {
private AtomicReference<Thread> cas = new AtomicReference<Thread>();
private int count;
public void lock() {
Thread current = Thread.currentThread();
if (current == cas.get()) { // 如果当前线程已经获取到了锁,线程数增加一,然后返回
count++;
return;
}
// 如果没获取到锁,则通过CAS自旋
while (!cas.compareAndSet(null, current)) {
// DO nothing
}
}
public void unlock() {
Thread cur = Thread.currentThread();
if (cur == cas.get()) {
if (count > 0) {// 如果大于0,表示当前线程多次获取了该锁,释放锁通过count减一来模拟
count--;
} else {// 如果count==0,可以将锁释放,这样就能保证获取锁的次数与释放锁的次数是一致的了。
cas.compareAndSet(cur, null);
}
}
}
}
对比之前的不可重入锁的实现,主要改变如下:
- 获取锁时,验证当前线程是否已经获取到该锁,如果已经获取到该锁,直接返回并且计数器加1,跳过自旋代码段。
- 释放锁时,验证当前线程是否多次获取到该锁,只有当前线程仅获取到1次该锁时,才真正进行锁释放,否则技术器减1.
总结:
- 悲观锁的缺点
- 乐观锁的实现CAS
- CAS和synchronized场景,即乐观锁和悲观锁使用的选择。
- 自旋锁与可重入自旋锁的实现。
参考: