一、乐观锁和悲观锁
乐观锁和悲观锁并不是真实存在的锁,而是一种设计思想,乐观锁和悲观锁对于理解 Java 多线程和数据库来说至关重要。
悲观锁
- 悲观锁是一种悲观思想,总认为最坏的情况可能会出现
- 悲观锁认为数据很可能会被其他人修改,所以悲观锁在持有数据时,会把资源锁住,这样其他的线程请求这个资源的时候就会阻塞,直到悲观锁把资源释放
- 传统的关系型数据库就用到了很多这种锁机制,如:行锁,表锁,读锁,写锁,Java中的悲观锁实现往往依靠这种数据库本身的锁功能实现
- Java中的synchronized和reentrantlock等独占锁(排它锁)也是一种悲观锁思想的实现,因为synchronized和reentrantlock不管是否持有资源,都会尝试去加锁
乐观锁
- 与悲观锁相反,乐观锁总认为资源不会被别人修改,所以读取不会上锁,但是乐观锁在进行写入操作时候会判断当前资源是否被修改过。
- 乐观锁的实现一般有两种方案:版本号机制和CAS:比较并替换实现。Java中的java.util.concurrent.atomic包下的原子变量类就是使用了CAS实现的。
二、两种锁的使用场景
悲观锁
select * from emp where emp_id=1 for update;
- 一般的,悲观锁不仅对写操作加锁,还会对读操作加锁。emp_id=1的记录被加锁之后,其他写操作在这个事务提交之前,都不会对这条数据进行操作,起到了独占、排他的作用。
- 在互联网的三高(高性能、高并发、高可用)环境下,悲观锁性能低,用的越来越少。
乐观锁
- 常见于读多写少的场景,即很少发横冲突的场景,这样可以省去锁的开销,增大系统吞吐量。
- 乐观锁的使用场景:如成本系统,员工要对一笔金额做修改,为了保证数据的准确性和实效性,使用悲观锁锁住某个数据后,再遇到其他需要修改数据的操作,那么此操作就无法完成金额的修改,而对产品来说是灾难性的一刻(读多的情况下,这条数据被锁住了),使用乐观锁的版本号机制能够解决这个问题。
三、乐观锁实现方式
采用版本号机制 和 CAS(compare and swap,比较并替换)算法实现。
- 版本号机制:通过在数据表的记录上加上version字段实现,表示数据被修改的次数,
思考的问题
1.在修改的那一刻,又要先读后写,这在多机器环境下如何控制?
我的理解:这让我联想到了redis的单线程getset,但原理不同,redis是单线程操作完成的。这里的最后一刻“先读后写”也没问题,乐观锁就是这么定义的,这个先读后写是使用update+where条件在一个事务中实现的,因此,多机器环境下在db层也不需要控制
2.数据库中执行update时用到锁了吗?3.事务与锁什么关系?
答:用到了,事务要更新数据对象时,先申请该对象的U锁。对象加了U锁,允许其他事务对它加S锁。在最后写入时,再申请将U锁升级为X锁。而不必在全过程中加X锁,参考:事务和锁。
理想场景下
(1) 男员工开启事务1
begin
update 表 set 金额=120, version = verison+1 where 金额=100 and version=0;
在没提交事务的前提下
(2) 女员工开启事务2
begin
update 表 set 金额=50, version = verison+1 where 金额=100 and version=0;
(3) 现在,当事务1先完成事务提交,版本号version=1,此时女员工再提交version=0的版本已经不在,提交失败,通知前端发起重试。
-
CAS算法
一种有名的无锁算法。即不使用锁的情况下实现多线程之间的变量同步,也就是在没有线程被阻塞的情况下实现变量的同步,所以也叫非阻塞同步(Non-blocking Synchronization)涉及的三要素:需要读写的内存值V、进行比较的值A、拟写入的新值B,当且仅当预期值A和内存值V相同时,将内存值修改为B,否则什么都不做
-
JAVA对CAS的支持:在JDK1.5 中新添加 java.util.concurrent (J.U.C) 就是建立在 CAS 之上的。对于 synchronized 这种阻塞算法,CAS是非阻塞算法的一种实现。所以J.U.C在性能上有了很大的提升。
四、乐观锁的缺点
任何事情都是有利也有弊,乐观锁缺点如下:
-
ABA问题:ABA 问题说的是,如果一个变量第一次读取的值是 A,准备好需要对 A 进行写操作的时候,发现值还是 A,那么这种情况下,能认为 A 的值没有被改变过吗?可以是由 A -> B -> A 的这种情况,但是 AtomicInteger 却不会这么认为,它只相信它看到的,它看到的是什么就是什么。JDK1.5之后,AtomicStampedReference的compareAndSet方法,首先检查当前引用是否等于预期引用,并且当前标志是否等于预期标志,如果全部相等,则以源自方式将该引用和标志的值设置为给定的更新值。也可以采用CAS的一个变种DCAS来解决这个问题。DCAS,是对于每一个V增加一个引用的表示修改次数的标记符。对于每个V,如果引用修改了一次,这个计数器就加1。然后再这个变量需要update的时候,就同时检查变量的值和计数器的值
-
循环开销大
乐观锁在进行写操作时会判断是否能够写入成功,如果写入不成功将出发等待-重试机制,这种情况是一个自旋锁,适合于短期内获取不到,进行等待重试的锁,不适合长期获取不到锁的情况,另外,自旋循环对于性能来说开销比较大 -
实践记录【为了证明只使用数据库的u锁编程,是否存在并发隐患】
两个java程序都加了所谓的事务处理,但是没有版本控制,在100个线程并发的情况下,原始数据=0,每个线程加1,看最终结果是否为100?
第一种:
结果:可以达到预期第二种
很显然,第二种存在并发隐患,没有有效的控制读写
发现的其他问题及解决:mapper并不为空,但是发生了空指针,场景:多线程/并发下,除了一下参考的
1.关于在spring中使用多线程操作数据库时,遇到的mapper为null的问题
2.多线程时Autowired自动注入问题
3.解决多线程下@Autowired无法注入
4.Spring-Boot中如何使用多线程处理任务
如果还解决不了,那只能是依赖问题了(我是在github上下载了逆向工程),后来通过换依赖、删代码解决,依赖如下:
<!--MySQL JDBC驱动-->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid</artifactId>
<version>1.0.31</version>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid-spring-boot-starter</artifactId>
<version>1.1.9</version>
</dependency>
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<exclusions>
<exclusion>
<artifactId>spring-boot-starter-logging</artifactId>
<groupId>org.springframework.boot</groupId>
</exclusion>
</exclusions>
<version>1.3.0</version>
</dependency>
五、总结
- CAS与synchronized的使用场景:CAS适用于“写少多读”场景(多读场景,冲突比较少,自旋概率低),synchronized适用于写比较多的场景。
- JavaSE 1.6之后,synchronized 的底层实现主要依靠 Lock-Free 的队列,基本思路是 自旋后阻塞,竞争切换后继续竞争锁,稍微牺牲了公平性,但获得了高吞吐量。在线程冲突较少的情况下,可以获得和 CAS 类似的性能;而线程冲突严重的情况下,性能远高于CAS。
- synchronized 同步锁进行线程阻塞和唤醒切换以及用户态内核态间的切换操作额外消耗 cpu 资源;CAS 不需要切换线程,自旋概率小的场景下可以获得更高的性能。
参考文献
Java建设者公众号:看完你就应该能明白的悲观锁和乐观锁