整体来说悲观锁和乐观锁是一种变成的思想,并不是实际的有这么一种类或者锁存在。
悲观锁和乐观锁最早是用于数据库设计,后续被引入Java 中,也逐渐扩散到其他领域。
悲观锁:
即认为每一次数据操作都有可能发生并发问题,不加锁就一定会出问题,做操作之前直接将数据锁定,在本次操作结束之前其他人无法完成操作。
例如:Java中 Synchronized 和 ReentrantLock(Lock的实现类) 等独占锁就是悲观锁思想的实现。
乐观锁:
与乐观锁对立,认为每一次数据操作都不会有并发问题,只有在更新数据的时候会检查当前操作的数据是否还是和原有保持一致,一致则执行自己的更新,如果原有数据被修改则不执行数据更新。
例如:Lock 是乐观锁的典型实现案例。
Synchronized 和 Lock的实现类 ReentrantLock 都是悲观,这里的以 ReentrantLock 为典例,该类中使用 lock() 就是加锁,unlock() 就是解锁。
处理资源前一定加锁并尝试获取锁,执行结束再释放。
原子类是乐观锁的典型案例,例如 AtomicInteger 在更新数据时,就使用了乐观锁的思想,多个线程可以同时操作同一个原子变量。
数据库中的悲观锁:
例如语句:SELECT * FROM 某张表 WHERE 某些条件 FOR UPDATE;
(这里和大小写无关,只是个人习惯 (lll¬ω¬))
这里的 SELECT … FOR UPDATE 就是悲观锁的一种实现,意义为会锁定本次查询的数据,在本次事务提交之前不允许其他人来修改,这就比较霸道了,所以对性能的损耗是很大的,用的不恰当可能会引起长时间的等待。
数据库中的乐观锁:
典型的在数据库表中增加一个标识字段,通常为 version 直译为版本 的字段,当然这个标识的名字可以随意更改,看自己的场景,但意义是一样的,每次成功改动后该字段发生变化或者自动加1。
乐观锁的情况下当我们查询和修改数据时都不会去做限制,只有当我们需要提交本次修改的时候需要检查当前的记录的这个标识字段是否和我们刚拿到时一样,例如刚开始拿到本条数据的时候 version 为 1,准备提交修改的时候也为1,那么就没有人动过,我们就可以执行更新操作,并将该字段加 1。
如果准备修改的时候发现该字段 version 变成 2 了,与原有不一致那么就不能提交,可以重新基于现有数据再次计算后,再次执行以上流程,直到执行成功。
Java 中乐观锁 CAS (CompareAndSwap)操作:
CAS (CompareAndSwap 比较并交换)
CAS是一个多线程同步的原子指令,CAS操作包含三个重要的信息,即内存位置、预期原值和新值。如果内存位置的值和预期的原值相等,那么把该位置的值更新为新值,否则不做修改。
补充说明: 虽然ReentrantLock也是通过CAS实现的,但是是悲观锁。
但 CAS 可能造成 ABA 问题,ABA 问题即 中间的内容可能发生了变化,类似与我买了一个行李箱,然后向里面放了很多东西,但有人趁我不注意把里面的东西换了或者拿走了,但当我需要使用的时候看到行李箱和原来是一样的,那么就还是认为没有什么问题。
JDK 1.5 提供了AtomicStampedReference 类也可以解决ABA的问题
此类维护了一个“版本号”Stamp,每次在比较时不止比较当前值还比较版本号,这样就解决了 ABA 的问题。
悲观锁大多数基于数据库本身的机制来实现,但对于时间比较久的操作来说,对于整体的耗损和性能会比较差,而乐观锁的开销相比之下就要小很多,即使是并发场景性能也比较不错。
乐观锁的实现大多依赖于我们自己的业务系统内部实现,当有第三方接入也来操作时可能会出现错误数据,此时对于数据库就不该直接开放给其他人,而是通过我们设计的过程来完成对数据库的操作。
悲观锁的性能一定不如乐观锁吗? 显然不是,要按照场景来尝试和区分。
悲观锁是重量级的,也不能多线程并行执行,甚至还有上下文切换,所以开销自然要比乐观锁开销大一点。
但悲观锁的开销是固定的,即使再多的请求也一样,但乐观锁不同,由于乐观锁的反复重试的机制,当竞争非常激烈的情况下,会消耗大量算力和资源,最终整体的性能和速度反而会降低。
悲观锁场景:
并发写入多,竞争十分激烈,临界区代码复杂,如果使用乐观锁可能造成大量无用的算力消耗,那么可以使用悲观锁来完成。
乐观锁场景:
并发读多,修改少的场景。在竞争不太激烈的情况下读多写多也可以考虑使用。
参考文献:https://juejin.cn/post/7087436837911789576
通过 WHERE 条件的不同,锁定的方式也不同,具体再需要学习数据库的行锁和表锁的机制等