文章目录
常见的锁策略
1.1 乐观锁 vs 悲观锁
乐观锁: 预测该场景中, 不太会出现锁冲突的情况. 假设数据一般情况下不会产生并发冲突,所以在数据进行提交更新的时候,才会正式对数据是否产生并发冲突进行检测,如果发现并发冲突了,则让返回用户错误的信息,让用户决定如何去做.
悲观锁: 预测该场景中, 容易出现锁冲突. 总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁.
举个例子: 同学A和同学B想请教老师一个问题.
同学 A 认为 “老师是比较忙的, 我来问问题, 老师不一定有空解答”. 因此同学 A 会先给老师发消息: “老师你忙嘛? 我下午两点能来找你问个问题嘛?” (相当于加锁操作) 得到肯定的答复之后, 才会真的来问问题. 如果得到了否定的答复, 那就等一段时间, 下次再来和老师确定时间. 这个是悲观锁.
同学 B 认为 “老师是比较闲的, 我来问问题, 老师大概率是有空解答的”. 因此同学 B 直接就来找老师.(没加锁, 直接访问资源) 如果老师确实比较闲, 那么直接问题就解决了. 如果老师这会确实很忙, 那么同学 B 也不会打扰老师, 就下次再来(虽然没加锁, 但是能识别出数据访问冲突). 这个是乐观锁.
这两种思路不能说谁优谁劣, 而是看当前的场景是否合适.
如果当前老师确实比较忙, 那么使用悲观锁的策略更合适, 使用乐观锁会导致 “白跑很多趟”, 耗费额外的资源.
如果当前老师确实比较闲, 那么使用乐观锁的策略更合适, 使用悲观锁会让效率比较低.
1.2 读写锁
多线程之间,数据的读取方之间不会产生线程安全问题,但数据的写入方互相之间以及和读者之间都需要进行互斥。如果两种场景下都用同一个锁,就会产生极大的性能损耗。所以读写锁因此而产生。
一个线程对于数据的访问, 主要存在两种操作: 读数据 和 写数据.
- 两个线程都只是读一个数据, 此时并没有线程安全问题. 直接并发的读取即可.
- 两个线程都要写一个数据, 有线程安全问题, 需要加锁.
- 一个线程读另外一个线程写, 也有线程安全问题, 需要加锁.
读写锁, 就是把读操作和写操作区分对待. Java标准库提供了ReentrantReadWriteLock
类, 实现了读写锁.
ReentrantReadWriteLock.ReadLock
类表示一个读锁. 这个对象提供了 lock / unlock 方法进行加锁解锁.ReentrantReadWriteLock.WriteLock
类表示一个写锁. 这个对象也提供了 lock / unlock 方法进行加锁解锁.
其中,
- 读加锁和读加锁之间, 不互斥.
- 写加锁和写加锁之间, 互斥.
- 读加锁和写加锁之间, 互斥.
在实际开发中, “频繁读, 不频繁写” 的场景是非常广泛存在的.所以这样做, 可以提高多线程的并发执行效率.
1.3 重量级锁 vs 轻量级锁
重量级锁: 加锁开销比较大(花的时间多, 占用资源多), 一个悲观锁很可能是一个重量级锁.
轻量级锁: 加锁开销比较小(花的时间少, 占用资源少), 一个乐观锁很可能是一个轻量级锁.
悲观乐观, 是在枷锁之前, 对锁冲突概率的预测, 决定工作的多少.
重量轻量, 是在加锁之后, 考量实际的锁开销.
1.4 自旋锁(Spin Lock) vs 挂起等待锁
自旋锁, 是轻量级锁的典型实现.
- 在用户态下, 通过自旋的方式(例如while循环), 实现类似于加锁的效果.
- 如果获取锁失败, 立即再尝试获取锁, 无限循环, 直到获取到锁为止. 第一次获取锁失败, 第二次的尝试会在极短的时间内到来.
- 这样可以在锁释放的时候第一时间拿到锁, 也会消耗一定的CPU资源.
挂起等待锁, 是重量级锁的典型实现
- 通过内核态, 借助系统提供的锁机制, 当出现锁冲突时, 会牵扯到内核对于线程的调度, 使冲突的线程出现挂起(阻塞)等待.
- 如果获取锁失败, 那么该线程会被阻塞, 内核去调度其他线程了, 直到有内核重新调度该线程, 再去尝试获取锁.
- 这样的锁会消耗较少的CPU资源
1.5 公平锁 vs 非公平锁
假设三个线程 A, B, C. A先尝试获取锁, 获取成功. 然后B再尝试获取锁, 获取失败, 阻塞等待; 然后C也尝试获取锁, C也获取失败, 也阻塞等待.
当线程 A 释放锁的时候, 会发生啥呢?
公平锁: 遵守 “先来后到”. B比C先来的. 当A释放锁的之后, B就能先于C获取到锁.
非公平: 不遵守 “先来后到”. B和C都有可能获取到锁.
系统自带的锁是非公平锁. 如果想实现公平锁, 就需要有一些额外的数据结构来支持(比如需要有方法记录每个线程的阻塞等待时间)
1.6 可重入锁 vs 不可重入锁
如果一个线程没有释放锁, 然后又尝试加锁, 若没有产生死锁, 那么该锁就是可重入锁; 如果产生死锁, 那么该锁就是不可重入锁.
理解死锁:
public synchronized void increase() { synchronized(this) { count++; } }
这样一个代码运行的时候会出现什么问题呢?
- 调用方法, 先对this加锁, 假设加锁成功了.
- 执行到下面的
synchronized
, 此时, 还是针对this进行加锁. 此时, 线程就会阻塞, 一直阻塞到锁被释放, 才有机会拿到锁. 但是this上的锁要在increase
方法执行完毕后才能释放, 但要想让increase
方法执行完, 就得需要第二次加锁成功, 方法才能继续执行… 这样就会陷入无限循环, 永远卡在这里.这就是死锁的一种表现形式.
如果是一个不可重入锁, 这把锁不会保存是哪个线程加的, 只要它处于加锁状态时, 收到了"加锁"请求, 就会拒绝当前加锁, 而不管请求的是哪个线程, 就会产生死锁.
如果是一个重入锁, 他就会保存是哪个线程加的锁, 后续收到加锁请求时, 就会先对比一下, 看看请求加锁的线程是不是当前已经加锁的线程, 这就不会产生死锁.
synchronized
是一个可重入锁, 所以上面的代码不会出现死锁的情况.
还有一种情况:
synchronized(this){ synchronized(this){ synchronized(this){ .... } } }
第一个
synchronized
真正加了锁, 下面的那些, 判定了持有线程就是当前线程, 就并没有真的加锁.一直执行到第五行, 出了这个代码块后, 是否要释放锁?
如果释放, 那么再执行下面的代码时, 就没有锁了, 也就不能保证线程安全了.
所以真正释放锁的地方是第七行.
如果加锁了n层, 在遇到’}‘时, JVM如何知晓当前这个’}'是不是最后一层呢?
让锁持有一个计数器就行了, 让锁对象不光记录是哪个线程持有的锁, 同时在通过一个整型变量记录当前线程加了几次锁. 每遇到一个加锁操作, 计数器+1, 每遇到一个解锁操作, 计数器-1. 当计数器减为0时, 才真正释放锁.
这样的计数器, 被称为"引用计数"