悲观锁有 & 乐观锁
首先,悲观锁与乐观锁是根据操作时是否锁住资源来判别的。悲观锁获取到锁时,必须要锁住资源;乐观锁则不会。一开始两线程争抢锁:
悲观锁
悲观锁之所以悲观,那是因为它觉得如果不锁住这个资源,别的线程就会来争抢,造成数据结果错误,所以悲观锁为了确保结果的正确性,会在每次获取并修改数据时,都把数据锁住,让其他线程无法访问该数据,这样就可以确保数据内容万无一失,从这点看悲观锁特别稳。
下面通过几张图,大概就能明白悲观锁的执行过程了:
接上面场景,如果 A 拿到锁,正在操作资源,B 就只能进入等待。
直至 A 执行完毕释放锁,CPU 唤醒等待此锁的线程 B。
线程 B 获取到了锁,就可以对同步资源进行自己的操作。这就是悲观锁的操作流程。
乐观锁
乐观锁顾名思义,比较乐观。相比于悲观锁,它是不锁住资源的,因为它觉得自己在操作资源时并不会有其他线程干扰。
因此,为了保障数据的正确性。它在操作之前,会先判断在自己操作期间,其他线程是否有操作。如果没有,直接操作;如果有,则根据业务选择报错或者重试。
下面来看看,乐观锁的执行过程:
乐观锁的这把锁,其实就是依赖的 CAS (compare and swap:比较并交换)算法。所以,它在操作资源之前并不需要获得锁,直接读取资源到自己的工作内存内操作:
操作完成,准备更新资源时。就会触发 CAS 算法,判断资源是否被其他线程修改过。
没有修改过,直接更新,线程执行完毕。
被修改过,根据业务逻辑走下一步,是重试还是报错?
典型应用
值得注意的是,不管是在 Java 还是数据库中都用到了。悲观锁、乐观锁的概念,只是实现方式稍有不同。下面介绍下 Java 中的悲观、乐观锁:
- 悲观锁:synchronized 关键字和 Lock 接口
这两够经典的,synchronized 必须要获取 mintor 锁才能进去操作资源;Lock 接口也是,必须显示调用 lock 才能操作资源。必须取到锁才能进行操作,这就是悲观锁的思想。
- 乐观锁:原子类
这类应该很常用,比如用作线程间的计数器。典型如 AtomicInteger 类在进行运算时,就使用了乐观锁的思想。使用 compareAndSet 方法更新数据,更新失败则重试。
数据库中的悲观、乐观锁:
- 典型的 select for update 语句,用的就是悲观锁思想。在提交之前不允许第三方来修改该数据。高并发环境吃不消。
- 利用 version 字段实现乐观锁,version 代表这条数据的版本。操作数据不需要获取锁,操作完准备更新时。对比版本号是不是和获取数据时一致?是:更新,否:重新计算,再尝试更新。
比如以下的 update 语句:
UPDATE people SET name = '狗哥', version = 2 WHERE id = 30624700 AND version = 1
使用场景
说了这么久,悲观锁乐观锁的区别我知道了。那这两种锁在啥样的场景下使用呢?
有人说悲观锁比乐观锁消耗大,因为悲观要锁、乐观不要锁(注意,这里我说不要是实际没锁住资源,它的锁其实是 CAS 算法)。是的,如果并发量很小的情况下,悲观锁确实比乐观锁消耗大。但如果并发量很高,导致乐观锁一直在重试,这时它消耗的资源比固定开销的悲观大,也是说不定的。
- 悲观锁适用于并发写入多,竞争激烈等场景,这些场景下,悲观锁确实会让得不到锁的线程阻塞,但这些开销是固定的。它可以避免后面更新时的无用反复尝试操作,节约开销。
- 乐观锁适用于大部分是读取,少部分是修改的场景,也适合虽然读写都很多,但是并发并不激烈的场景。在这些场景下,乐观锁不加锁的特点能让性能大幅提高。