Java中的锁策略与CAS以及死锁的成因与解决办法

前言

由于加锁是一个开销较大的行为,为了更好的运用锁,我们对锁进行分类,以便于在不同的情况下使用。

1. 锁的种类

  1. 乐观锁与悲观锁

    1. 乐观锁:假设数据一般情况下不会产生并发冲突,所以在数据进行提交更新的时候,才会正式对数据是否产生并发冲突进行检测,如果发现并发冲突了,则让返回用户错误的信息,让用户决定如何去做。
    2. 悲观锁:总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁。
  2. 重量级锁与轻量级锁
    锁的核心特性“原子性”,主要是依赖于CPU硬件设备提供。首先CPU 提供了 “原子操作指令”。操作系统基于 CPU 的原子指令, 实现了 mutex 互斥锁。JVM 基于操作系统提供的互斥锁, 实现了 synchronized 和 ReentrantLock 等关键字和类

    1. 重量级锁:对于重量级锁来说,就过多的依赖于OS提供的mutex,以至于涉及大量的内核态与用户态的交换,并且会引发线程的调度,成本较高。
    2. 轻量级锁 :对于轻量级锁来说,加锁机制尽可能不使用mutex,而是在用户态代码完成,此时我们的内核态用户态切换较少,也不太容易引发线程调度,成本较低。
  3. 公平锁与非公平锁

    1. 对于公平与非公平的定义就是,看得到锁的顺序是否按先来后到的规则进行,如果遵守先来后到则是公平锁,不遵守就是非公平锁。
  4. 自旋锁与挂起等待锁
    按之前的方式,线程在抢锁失败后进入阻塞状态,放弃 CPU,需要过很久才能再次被调度。

    1. 自旋锁:对于自旋锁来说,就不会放弃 CPU 而是不断地重复尝试获取锁,直到获取锁为止,这样只要锁被释放就能最早的获取到。但是这种行为如果锁距离释放的时间久,就会持续消耗CPU资源。
    2. 挂起等待锁: 如果尝试获取锁失败就不会重复去获取,而是放弃CPU资源,挂起等待CPU调度。这种情况就可能不会第一时间拿到锁,但是不会消耗CPU资源。
  5. 互斥锁与读写锁

    1. 互斥锁:一个线程拿到锁之后,另一个线程不能使用这是锁的基本特点。比如synchronized关键字,进入代码块加锁,出代码块解锁。
    2. 读写锁:当我们只是多个线程读同一个变量时,这个时候不涉及线程安全问题。此时直接互斥锁就会造成性能损耗,所以这种情况下就诞生来读写锁。
      约定:
      1.读锁与读锁之间不会锁竞争,不会产生阻塞等待。
      2.写锁与写锁之间会锁竞争。
      3.读锁与写锁之间也有锁竞争。
  6. 可重入锁与不可重入锁
    1. 可重入锁是允许同一个线程多次获取同一把锁。可重入锁也叫做递归锁。
    2. 不可重入锁不允许同一个线程多次获取同一把锁,会发生锁竞争,阻塞等待。
    注意: Java里只要以Reentrant开头命名的锁都是可重入锁

对synchronized的总结:

  1. 开始时是乐观锁, 如果锁冲突频繁, 就转换为悲观锁.
  2. 开始是轻量级锁实现, 如果锁被持有的时间较长, 就转换成重量级锁(参考后面的锁升级策略)。
  3. 实现轻量级锁的时候大概率用到的自旋锁策略。
  4. 是一种不公平锁。
  5. 是一种可重入锁。
  6. 不是读写锁,是互斥锁。

2. 锁策略

在Java中实现加锁操作主要通过synchronized关键字和JUC包下的Lock接口,Lock 是JDK1.5之后引入的线程同步工具 。接下来以synchronized为例,解释以下三种锁优化策略:锁升级、锁消除、锁粗化。

2.1 锁升级

  1. 无锁:synchronized 在加锁的过程中并不是直接给锁对象加锁。
  2. 偏向锁:第一个尝试加锁的线程,刚开始没有其他线程来竞争锁,就只给锁对象的对象头加一个偏向锁的标记,记录这个锁属于那个线程。
  3. 自旋锁:当此时如果有其他线程也要对此时同一个对象加锁,也会先尝试加偏向锁,此时发现已经有线程加了偏向锁标记,此时jvm就会通知加标记的线程锁升级。此时就是真正的加锁,加轻量级锁,一般由自旋锁实现。
  4. 重量级锁:当有许多线程都要来对同一个对象加锁时,此时会有许多自旋锁消耗大量cpu资源,所以此时可以直接升级为重量级锁,挂起等待之后的CPU调度。

2.2 锁消除

以上讲的偏向锁是程序运行时,jvm做的优化,锁消除则是编译期间,检测当前代码是否需要加锁,如果不加锁但是写了加锁就在编译期间直接消除。
比如我们的StringBuffer类中的关键方法都加了synchronized关键字,当我们在单线程使用时就不需要锁,此时编译器检测到了就会帮我们消除锁。

2.3 锁粗化

锁粒度: 指的是我们synchronized修饰的代码块中的代码多少,如果代码越多锁的粒度就越大,代码越少锁的粒度就越小。
我们是不希望频繁的加锁解锁的,会消耗大量资源,所以当我们锁粒度很小时,隔几行代码就有一个锁,此时编译器就会把它优化成一个粒度更大的锁,也就是直接整体加锁,此时就是锁粗化策略。

3. CAS

什么是CAS?
CAS全称Compare and swap,字面意思:”比较并交换“,一个 CAS 涉及到以下操作:

我们假设内存中的原数据V,旧的预期值A,需要修改的新值B。

  1. 比较 A 与 V 是否相等。(比较)
  2. 如果比较相等,将 B 写入 V。(交换)
  3. 返回操作是否成功

CAS的伪代码实现:

boolean CAS(address, expectValue, swapValue) {
if (&address == expectedValue) {
&address = swapValue;
return true;
}
return false;
}

注意: 以上代码只是形象的解释我们CAS的底层工作原理,实际上CAS是CPU上的一条原子指令。

为什么有CAS?
由于CAS只用一条CPU指令,可以实现对变量值的更改,此时我们就可以用它实现原子类,不加锁就能保证线程安全。同样的CAS还能用来实现自旋锁。

3.1 CAS实现原子类

Java标准库中提供了 java.util.concurrent.atomic 包, 里面的类都是基于CAS来实现的。典型的就是 AtomicInteger 类。其中的getAndIncrement 相当于 i++ 操作,这个时候不加锁就能保证原子性实现多线程自增操作。
伪代码实现:

class AtomicInteger {
private int value;
public int getAndIncrement() {
int oldValue = value;
while ( CAS(value, oldValue, oldValue+1) != true) {
oldValue = value;
}
return oldValue;
}
}

解读: value是需要自增的变量,理解为内存中存储的值。oldValue可以视为寄存器,进入循环判断,寄存器的值是否是等于要自增的变量的值,如果是的,就说明修改不会出现问题,就将寄存器的值加1赋给value,循环结束。如果不等于,就说明此时该变量被别的线程修改了但是还没有记录到寄存器,此时就重新设置oldVlue的值,直到相等时,就说明可以自增了。

简而言之:此处的CAS就是为了确定当前的value是否变过,如果没有改变就可以直接自增,如果改变了就需要先更新再自增。

要想更加理解这个问题可以参考我的另一篇博客线程不安全的几种情况与解决办法,里面有关于原子性以及内存可见性的解说。

3.1.1 CAS中的aba问题

由于以上我们可以知道,CAS通过比较内存与寄存器中的值是否相等来判断,这个内存是否改变过。但是会出现一种情况,就是我们的值虽然相等,但是我们并不是没有改变过,而是从a->b->a
此时就有一定概率出现问题。

举个例子: 假设我们有100块钱,打算取50块钱。如果我们取款机创建两个线程来并发执行扣50的操作。
1)线程1获取到当前存款值为 100, 期望更新为 50; 线程2 获取到当前存款值为 100, 期望更新为 50。
2) 线程1 执行扣款成功, 存款被改成 50。 线程2 阻塞等待中。
3) 轮到线程2 执行了, 发现当前存款为 50, 和之前读到的 100 不相同, 执行失败。
但是如果此时有一个线程3向我转账,此时存款从50修改成了100,线程2再执行时发现存款还是100。就会进行-50,此时就会进行两次扣款。

如何解决aba问题?

  1. 第一种情况,我们可以规定数据只能单方向变化,也就是只能增加或者减少,这个时候就不会出现a->b->a.
  2. 第二种情况,引入一个版本号变量,约定版本号只能增加,每次CAS对比的时候不再是对比值,而是对比版本号,此时就能保证数据既可以增加又可以减少,同时可以通过版本号来确定数据是否改变。

3.2 CAS实现自旋锁

可以利用CAS实现自旋锁。

自旋锁伪代码:

public class SpinLock {
private Thread owner = null;
public void lock(){
// 通过 CAS 看当前锁是否被某个线程持有.
// 如果这个锁已经被别的线程持有, 那么就自旋等待.
// 如果这个锁没有被别的线程持有, 那么就把 owner 设为当前尝试加锁的线程.
while(!CAS(this.owner, null, Thread.currentThread())){
}
}
public void unlock (){
this.owner = null;
}
}

4. 死锁

4.1 死锁形成的几种情况

死锁: 当多个线程同时被阻塞,线程中的一个或者多个又或者全部都在等待某个资源被释放,造成线程无限期的阻塞,导致程序不能正常终止就叫做死锁。

  1. 一个线程对一个对象加两次锁,此时如果是可重入锁就不会发上死锁,如果是不可重入锁就会发生死锁。
  2. 两个线程两把锁,如以下代码,t1线程先对locker1加锁,t2线程先对locker2加锁,接下来t1需要对locker2加锁,由于此时t2线程对locker2加锁,所以t1线程获取不到locker2的锁,必须得t2释放,又由于t2线程需要执行完获取locker1的锁才能释放,此时就会僵持不下,造成死锁阻塞。
    在这里插入图片描述
  3. N个线程M把锁,典型的情况哲学家就餐问题。
    1. 问题描述:
      有五个哲学家围在一张圆桌,分别坐在周围的五张椅子上,在圆桌上有五个筷子,他们的生活方式是交替的进行思考和进餐。平时,一个哲学家进行思考,饥饿时便试图取用其左右最靠近他的筷子,只有在他拿到两支筷子时才能进餐。进餐完毕后,放下筷子继续思考。我们认为筷子是锁,一个哲学家是一个线程。
      2. 当我们每个哲学家都拿起左边的筷子时,此时就会出现每个哲学家都在等待右边的筷子,然后都不能进餐,此时就是死锁。

4.2 死锁的四个必要条件

  1. 互斥使用,所得基本特点。
  2. 不可抢占,一个线程拿到锁之后,只能自己主动释放,不能被其他线程强行占有。
  3. 请求和保持,当资源请求者在请求其他的资源的同时保持对原有资源的占有,例如以上死锁的第二种情况。
  4. 循环等待,即存在一个等待队列:P1占有P2的资源,P2占有P3的资源,P3占有P1的资源。这样就形成了一个等待环路,比如以上的哲学家就餐问题,每一个人都等待着旁边一个人放下筷子。

4.3 死锁的解决办法

由以上死锁的必要条件可以看出,我们解除死锁最容易破坏的条件就是循环等待。

破坏循环等待:

最常用的一种死锁阻止技术就是锁排序. 假设有 N 个线程尝试获取 M 把锁, 就可以针对 M 把锁进行编号(1, 2, 3…M)。N 个线程尝试获取锁的时候, 都按照固定的按编号由小到大顺序来获取锁.。这样就可以避免环路等待。

举例:

  1. 比如上述死锁的第二种情况我们对locker1,locker2,编号,1,2并约定,按编号从小到大顺序取锁,此时就不会出现死锁。
    代码演示:
Object lock1 = new Object();
Object lock2 = new Object();
Thread t1 = new Thread() {
@Override
public void run() {
synchronized (lock1) {
synchronized (lock2) {

}}}};
t1.start();
Thread t2 = new Thread() {
@Override
public void run() {
synchronized (lock1) {
synchronized (lock2) {
}}}};
t2.start();
  1. 对于哲学家就餐问题我们可以·让他们每次取两边编号小的筷子就餐。此时2号老铁取了1筷子,3取2筷子,4取3筷子,5取4筷子,1号老铁此时就应该取1号筷子但是由于1号在使用,所以就等待,此时5号老铁就能再取上5号筷子,然后吃饭,放下筷子后,4号老铁此时也能拿起另外一只筷子,以此类推,所有的哲学家就都能吃上饭。
    在这里插入图片描述
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值