一篇文章总结Java中的锁策略

15 篇文章 1 订阅


乐观锁&悲观锁

加锁,是一个开销比较大的事情,创建锁销毁锁都是会消耗资源的。我们希望在特定的场景下,针对场景做出一些取舍,好让锁更高效一些

乐观锁:假设锁冲突的概率比较低,甚至都没有冲突,就只是简单处理一下冲突

悲观锁:假设锁冲突的概率比较高,甚至于每次尝试加锁都会有冲突,此时就会愿意付出更多成本来解决冲突

乐观锁,假设一般情况下都不会产生锁冲突,因此就尝试直接访问数据,如果发现了锁冲突 ,然后再去处理。

悲观锁,假设一般情况下都会产生锁冲突,因此会进行处理,然后再尝试访问数据。

举个例子,同学A和同学B都想发QQ消息请教老师问题
同学A认为老师是比较忙的,所以请教问题前它先发了个消息问了一下老师有空吗
同学B认为老师应该比较闲,所以直接就发消息问了老师问题,如果老师有空就会回答它的问题,如果没空的话他就会等或者下次再问。
A同学是属于悲观的,B同学则是属于乐观的。
所以这两种思路说不上谁好,要根据场景的。
如果老师确实比较忙如果使用乐观锁就会"白问好几次",消耗额外资源
但老师确实比较闲用乐观锁是比较好的,使用悲观锁效率是会更低的。

而synchronized,其实是以悲观为主(有时候也是悲观的根据不同场景)

版本号机制

乐观锁可以使用基于版本号的机制,这也是乐观锁的一种典型实现。
版本号这种实现乐观锁,相对于单纯的互斥锁来说,要更轻量一些。(光改版本号,就只需要在用户态完成即可)。互斥锁会涉及到用户态内核态的切换,也会引起线程阻塞。如果提交的版本小于当前版本,说明其他线程已经把这个数据给修改了,就会修改失败。

假设我们需要多线程来修改"账户余额",账户的初始余额(balance = 1000),版本号为(version = 1)。提交版本必须大于记录当前版本才能执行更新余额
线程1读取账户余额和版本号(balance = 1000,version = 1),线程2也读取账户余额和版本号((balance = 1000,version = 1)
线程1从账户余额中取走200块并更新版本号(balance = 800,version = 2),线程2从账户余额中取走500块也更新了版本号(balance = 500,version = 2)
线程1先完成了所有操作把数据写回到了内存中,此时的账户余额(balance = 800),版本号(version = 2).此时线程2也完成了所有操作,准备把数据写回到内存中,对比版本号发现线程2中版本号为2,内存中的数据也为2,不满足大于的条件,就会认为线程2这次操作是失败的

像一些用户量少的,小众网站,就可以考虑乐观锁。

读写锁

多个线程同时尝试修改同一个变量,这是线程不安全的。如果是多个线程同时读取同一个变量是线程安全的。

但是在有些场景中,本来就是写比较少,读比较多的情况,两个读线程之间其实不存在线程安全问题,就没有必要互斥。但如果是两个写线程之间其实存在线程安全问题,就需要互斥。同样一个线程读一个线程写,也是存在安全问题,也需要互斥.因此根据读写的不同场景,给读和写分别加锁。

注意:synchronized没有对读写进行区分,只要使用就一定会发生互斥。

注意:

  • 读加锁和读加锁之间不互斥

  • 写加锁和写加锁之间,互斥

  • 读加锁和写加锁之间,互斥

    需要注意的是:只要是涉及到“互斥”,就会产生线程的挂起等待,一旦线程挂起,再次被唤醒就不确定要隔多久
    因此尽量减少"互斥"的机会,就是提高效率的重要途径

重量级锁&轻量级锁

重量级锁:加锁开销很大,往往是通过内核来完成的

轻量级锁:加锁解锁开销更小,往往是只在用户态完成的

悲观锁,做的工作往往更多,因此开销也更大,悲观锁也有很大可能是重量级锁

乐观锁,做的工作往往更少,因此开销也更小乐观锁也有很大可能是轻量级锁。

悲观锁和乐观锁,描述的是应用场景,锁冲突概率高不高

重量和轻量级锁,和应用场景每关系,看解锁和解锁的开销大不大

注意:很有肯能并不是100%

加锁的"互斥"能力,是哪里来的?

归根结底,都是CPU的能力,CPU提供了一些特殊指令,通过这些指令来完成互斥,操作系统内核在对这些指令进行了封装,并实现了阻塞等待。

CPU提供了一些特殊指令(原子操作的指令),操作系统对这些指令封装了一层,提供了mutex(互斥量),像Linux就会提供一个mutex接口让用户代码进行加锁和解锁。

如果是当前的锁,就是通过内核的mutex来完成的,此时这样的锁往往就开销比较大。

如果当前的锁,是在用户态,通过一些其他的手段来完成的,这样的锁往往就开销更小。

synchronized,即使一个轻量级锁,也是一个重量级锁,它是根据场景自适应的。

自旋锁&挂起等待锁

自旋锁:如果线程获取不到锁,不是阻塞等待,而是循环的快速再试一次,因此就节省了操作系统调度线程的开销,要比挂起等待锁更能及时的获取到锁(如果线程1拿到锁,线程2就会快速循环的来尝试获取锁,一旦线程1把锁释放,线程2就能第一时间那到锁)。所以自选锁是更浪费CPU资源的。

挂起等待锁:如果线程获取不到锁,就会阻塞等待,啥时候结束阻塞,就取决于操作系统具体的调度。当线程挂起的时候,是不占用CPU资源的

啥时候用自旋锁?啥时候用挂起等待锁?

  • 如果锁的冲突概率比较低,使用自旋锁更合适
  • 如果线程持有锁的时间比较短,也可以使用自旋锁
  • 如果对CPU比较敏感,不希望池太多CPU资源,那么久不适合用自旋锁

这个自旋锁和挂起等待锁,这样的策略在synchronized中内置,根据场景自适应了

公平锁和非公平锁

公平锁:遵循先来后到的规则,先来的线程先获取到锁

非公平锁:不遵守先来后到的规则,不会在等先来后到,完全取决于操作系统的调度。

比如线程1先来发现锁被其它线程在占用就阻塞等待,此时线程2又来竞争锁也发现锁在被占用也阻塞等待,等占用锁的线程把锁释放了。如果是公平锁那么就是线程1获取到锁,但如果是非公平锁,谁能获取到锁就不好说了,完全取决于操作系统的调度。

对于操作系统的调度来说,默认就是不公平的,默认就是概率均等的调度(当然概率均等是不考虑线程优先级的概率均等)

要想实现公平锁,就需要有额外的数据结构,比如用一个阻塞队列来保证先来后到、

啥时候用公平锁?啥时候用非公平锁?需要看具体场景和需求。大部分情况下,使用非公平锁就够用了。synchronized是非公平锁。

可重入锁&不可重入锁

如果针对同一把锁,加两次锁。
如果是不可重入锁,就会出现"死锁"
如果是可重入锁,就不会死锁。
就比如下面这个代码如果是不可重入锁就会发送死锁

class Test {
    int count = 0;
    public synchronized void count() {
        synchronized (this) {
            count++;
        }
    }
}

引入重入锁就是为了解决死锁问题。
重入锁的实现原理是:

为每个锁关联一个获取计数器和一个所有者线程,当计数器值为0时,这个锁就被认为是没有被任何线程持有。当线程请求一个未被持有的锁时,JVM将记下锁的持有者,并且将获取计数值置为1.如果同一个线程再次获取这把锁,计数器就会递增,而当线程退出同步代码块时,计数器就会相应的递减,当计数器为0的时候,这个锁就会被释放。synchronized就是一个可以重入锁。

CAS

CAS: CAS全称 Compare and swap,是“比较交换”的意思。

CPU提供了一组CAS相关的指令,使用一条这样的指令就可以完成上面的比较交换过程。
CAS包含了3个操作数,需要读写的内存位置V、进行比较的值A和要修改的值B,而且仅当V的值等于A时,CAS才会通过原子方式用新值B来更新V的值,否则不会执行任何操作。无论位置V的值是否等于A,都将返回V原有的值

Java标准库中的CAS方法

this.compareAndSwapInt(var1, var2, var5, var5 + var4)

AtomicInteger类

Java标准库中,也提供了原子类,典型是AtomicInteger类.

public static void main(String[] args) {
        AtomicInteger atomicInteger = new AtomicInteger(0);
        //类似于i++操作
        atomicInteger.getAndIncrement();
        System.out.println(atomicInteger);
    }

public final int getAndAddInt(Object var1, long var2, int var4) {
        int var5;
        do {
            var5 = this.getIntVolatile(var1, var2);
        } while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));

        return var5;
    }

CAS其实就是在感知,这两个操作之间,是否掺杂了其它线程的操作
是否其它线程在这个过程中偷偷修改了数据
如果没有其它线程修改,此时就能直接把数据给改了
如果其它线程已经修改,那么久重新读取旧值,交给下次循环来判定。

基于CAS实现了自旋锁

CPU指令,来一次性的完成了 比较 和 交换 这样的过程,尤其是可以不加锁也能保证线程安全。

队列中有一种特殊的队列,无锁队列,无锁队列如何保证线程安全?那就是CAS

这里的无锁准确来说,不是真的完全没有加锁,而是没有使用 操作系统 提供的那个重量的锁mutex. 而是通过CAS,直接在用户态实现了一个轻量级的自旋锁,通过这个自旋锁来保证线程安全

ABA问题

比如我在非正规平台上买了一部手机,我无法区分它是全新机还是翻新机。类似的,使用CAS的时候也无法区分,是这个数据始终没有变,还是这个数据从A变成了B,又变回了A。

举个列子:

我的账户余额里有1000块钱,我从ATM中取出100块,取的时候ATM卡了一下,我按了两下取钱。

这时候有两个线程,线程1和线程2都尝试进行 -100操作

线程1 获取到当前的账户余额为1000,线程2 也获取到当前的账户余额为 1000

线程1执行 -100操作,对比当前账户余额是一致的取钱成功,此时余额变成900,此时线程2就会在阻塞状态。

线程1释放锁后,线程2也尝试进行-100操作发现账户余额和当前自己读取的不一致就不会进行取钱操作。

此时相当于线程1修改成功,线程2修改失败(虽然卡了一下,出现了两个线程,但是由于CAS操作机制,保证了最终的结果是对的)

假设一个极端的情况

线程1 获取到当前的账户余额为1000,线程2 也获取到当前的账户余额为 1000

线程1执行 -100操作,对比当前账户余额是一致的取钱成功,如果在取钱成功之后立马我的朋友又给我打了100块钱,接着线程2又执行 -100操作(此处不区分余额是否改变)

线程2发现此时的账户余额和刚刚读取的余额一致,于是就又扣了一次钱。

这就是CAS的ABA所引用起的问题。

解决ABA问题

解决ABA问题我们可以引入版本号机制

假设账户余额的版本号为1
当线程1和线程2都读取账户余额1000和版本号1
线程1进行-100操作,发现发现账户余额和版本号都一致,就直接执行-100操作同时版本号+1变成了2,账户余额变成了900
此时在线程2执行-100之前,又有可一个线程先执行了+100操作,执行成功后版本号又+1变成了3,余额又变成了1000
接着线程2执行-100操作的时候,发现账户余额是一致的,但是版本号不相同,这个-100操作就不会执行

此时通过版本号机制就解决了ABA问题

ReentrantLock

ReentrantLock和synchronized是并列关系,也是一个可重入锁
ReentrantLock把加锁和解锁拆分开了

static class Counter {
        ReentrantLock lock = new ReentrantLock();
        private int count = 0;
        public void count() {
            lock.lock();
            count++;
            lock.unlock();
        }
    }

ReentrantLock相当于对synchronized进行了补充

  1. ReentrantLock把加锁和解锁拆成两个方法,确实存在,遗忘了解锁的风险,但是可以让代码更加灵活(比如把加锁解锁的代码分别放在两个方法中)

  2. ReentrantLock除了lock 和 unlock方法之外,还提供了一个 tryLock方法

    对于lock()方法来说,如果尝试加锁失败,就会阻塞等待。

    对于tryLock方法来说,如果尝试加锁失败,就直接放回出错,不会阻塞等待

  3. synchronized是一个非公平锁,ReentrantLock支持两种模式,既可以支持公平锁,也能支持非公平锁

  4. ReentrantLock提供了比 synchronized更强大的等待唤醒机制。

    synchronized是搭配wait() 和notify()

    ReentrantLock是搭配另外一个Condition类来完成等待唤醒(能够显示指定唤醒哪个等待线程)

信号量(Semaphore)

信号量,是一个计数器(int 整数),功能就是描述可用资源的个数。
举个列子:

停车场,门口有一个牌子:显示剩余xx个车位

这样的标记,其实就是信号量。

申请资源:每次有车开进来,数字就-1,P操作

释放资源:每次有车开出去,数字就+1,V操作

此处的+1和-1都是原子操作

信号量的数值,能否小于0?

信号量的计数的值,应该是一个 >= 0 的值,当值为0的时候,如果再进行P操作就会发送阻塞等待

也可以用信号量来控制线程安全,创建信号量的时候,设置一个初始值(可用资源的个数)

如果把初始值设为1,此时这个信号量就只有 0 1 两种取值。称为"二元型号量",二元型号量和锁是类似的。

Java标准库中提供了一个 Semaphore类,acquire()方法表示申请资源操作,release方法表示释放资源。

public static void main(String[] args) {
        Semaphore semaphore = new Semaphore(3);//表示有3个可用资源

        for (int i = 0; i < 10; i++) {
            Thread t = new Thread(new Runnable() {
                @Override
                public void run() {
                    System.out.println("准备申请资源");
                    try {
                        semaphore.acquire();
                        System.out.println("成功申请资源");
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    try {
                        Thread.sleep(1000);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    semaphore.release();
                    System.out.println("成功释放资源");
                }
            });
            t.start();
        }
    }

CountDownLatch

同时等待N个任务执行结束

就类似跑步比赛,5个选手,所有选手都通过终点,才能公布成绩。
CountDownLatch的构造方法指定任务的数量
每次调用 countDown()方法,CountDownLatch内部的计数器就减1
await()如果任务还没有执行完毕就会一直阻塞

public static void main(String[] args) throws InterruptedException {
        //总共有5个任务
        CountDownLatch countDownLatch = new CountDownLatch(5);
        Runnable runnable = new Runnable() {
            @Override
            public void run() {
                try {
                    System.out.println("开始起跑");
                    Thread.sleep((long) (Math.random()*10000));
                    System.out.println("抵达终点");
                    //每次执行完一个任务,调用countDown就直接在CountDownLatch自动减一
                    countDownLatch.countDown();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        };
        for (int i = 0; i < 5; i++) {
            new Thread(runnable).start();
        }
        //如果任务还没执行完就一直阻塞
        countDownLatch.await();
        System.out.println("比赛结束");
    }

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

爱敲代码的三毛

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值