常见的锁策略
乐观锁与悲观锁
乐观锁:既假设锁冲突的概率比较低基本没有冲突,简单的处理冲突。
悲观锁:既假设锁冲突的概率比较高基本每次尝试加锁都会产生锁冲突,付出更多的成本处理冲突。
Synchronized初始使用乐观锁策略。当发现锁竞争比较频繁的时候就会自动切换成悲观锁策略。
乐观锁的一个重要功能就是检测数据是否发生冲突。引入“版本号”解决。
如修改账户余额:提交版本号必须大于记录当前版本号才能执行更新
读写锁
线程对于数据的访问,主要存在两种操作:读操作和写操作。
- 两个读线程之间,其实不存在线程安全,就不必互斥。
- 两个写线程之间,存在线程安全,需要互斥。
- 一个读线程一个写线程,存在线程安全,就需要互斥。
在有些场景中,本来就写比较少,读比较多。
Synchronized并没有对读写进行区分,只要使用就一定互斥,像这种读比较多写比较少的情景效率就比较低。此时就需要读写锁。
Java标准库里就提供了这个类:
- ReentrantReadWriteLock.ReadLock能够构造一个读锁实例,提供了lock/unlock方法进行加锁解锁.
- ReentrantReadWriteLock.WriteLock能够构造一个写锁实例,提供 lock/unlock方法进行加速解锁.
假设有t1 t2两个读线程,t3 t4两个写线程。使用ReentrantReadWriteLock.ReadLock和
ReentrantReadWriteLock.WriteLock这两个类来进行加锁。
- t1和t2两个读线程同时访问数据,此时两个读锁之间不会互斥,完全并发执行。
- t1和t3一个读线程一个写线程同时访问数据,此时读锁和写锁之间就会互斥。要么读完再写,要么写完再读
- t3和t4两个写线程同时访问,此时写锁和写锁之间会互斥,一定是一个线程写完另一个线程再写。
Synchronized不是读写锁
重量级锁与轻量级锁
首先我们需要知道锁的核心特征“原子性”,这样的机制是CPU这样的硬件设备提供的。
- CPU提供“原子操作指令”
- 操作系统基于CPU的原子指令实现mutex互斥锁
- JVM基于操作系统提供的互斥锁,实现了Synchronized和ReentrantLock等关键字和类。
重量级锁:加锁机制依赖操作系统提供的mutex。
- 大量的内核态用户态切换
- 很容易引发线程调度
轻量级锁:加锁机制很少使用mutex而是尽量在用户态代码完成。
- 少量的内核态用户态切换
- 不太容易引发线程调度
Synchronized开始是一个轻量级锁。如果锁冲突比较严重就会变成重量级锁。
自旋锁与挂起等待锁
自旋锁:如果线程获取不到锁,不是阻塞等待而是循环的快速的再试一次,因此就节省了操作系统调度线程的开销,比挂起等待锁更能及时的获取到锁。但是更浪费CPU资源。
挂起等待锁:如果线程获取不到锁,就会阻塞等待,什么时候结束阻塞,取决于操作系统的调度。当线程挂起的时候不占用CPU。
使用自旋锁与挂起等待锁的大原则:
- 如果锁冲突的概率比较低,使用自旋锁比挂起等待锁更合适。
- 如果线程持有锁的时间比较短,使用自旋锁比挂起等待锁更合适。
- 如果对CPU比较敏感,比希望吃太多CPU资源就适合使用挂起等待锁。
Synchronized根据情景在自旋锁和挂起等待锁之间转换。
公平锁与非公平锁
公平锁:遵循“先来后到”如张三比李四先喜欢女神,此时如果女神分手就会先考虑与张三在一起。
非公平锁:不遵循“先来后到”如张三比李四先喜欢女神,此时如果女神分手女神会选择一个看着比较顺眼的,不按照先后顺序选择。
操作系统内部的线程调度默认是不公平的,随机的。如果要实现公平锁,就需要依赖额外的数据结果(如队列,通过队列来记录先来后到的过程),来记录线程的先后顺序。
Synchronized是非公平锁
可重入锁与不可重入锁
可重入锁:允许同一个线程多次获取同一把锁。
实现方式:在锁中记录锁持有的线程身份,以及一个计数器。如果发现,当前有同一个线程尝试获取锁,这个时候不会阻塞等待而是继续运行,每次加锁记数器++,每次解锁,计数器–,直到计数器为0,此时才是真正的释放锁,其他线程才能获取到这个锁。
不可重入锁:同一个线程只能获取一把锁,如果多次获取锁会出现死锁情况。
Synchronized是可重入锁