1. 乐观锁 VS 悲观锁
锁冲突: 两个线程尝试获取一把锁
一个线程能获取成功, 另一个线程阻塞等待
锁冲突的概率大 还是 小, 对后续工作是有一定影响的
乐观锁: 预测该场景中, 不太会出现锁冲突的情况, 后续做的工作会更少
悲观锁: 预测该场景中, 非常容易出现锁冲突的情况, 后续做的工作会更多
总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁。
假设数据一般情况下不会产生并发冲突,所以在数据进行提交更新的时候,才会正式对数据是否产生并发冲突进行检测,如果发现并发冲突了,则让返回用户错误的信息,让用户决定如何去做。
假设我们需要多线程修改 "用户账户余额".

2) 线程 A 操作的过程中并从其帐户余额中扣除 50( 100-50 ),线程 B 从其帐户余额中扣除 20



Synchronized 初始使用乐观锁策略. 当发现锁竞争比较频繁的时候, 就会自动切换成悲观锁策略.
2. 重量级锁 VS 轻量级锁
重量级锁: 加锁的开销是比较大的(花的时间多, 占用系统资源多)
一个悲观锁, 很可能是重量级锁 (不绝对)
轻量级锁: 加锁开销比较小的 (花的时间少, 占用系统资源少)
一个乐观锁, 也可能是轻量级锁 (不绝对)
悲观乐观, 是在加锁之前, 对锁冲突概率的预测, 决定工作的多少
重量轻量, 是在加锁之后, 考量实际的锁的开销
正因为这样的概念存在重合, 针对一个具体的锁, 可能把它叫做乐观锁, 也可能叫做轻量锁
那什么叫做 做的工作多 , 做的工作少呢 ?
我们一般认为 , 锁这个东西要保持互斥的 , 那保持互斥要有力量来源的
我们 Java 中实现一把锁 , 需要使用 synchronized 关键字 (后续还会讲解 ReentrantLock)
那 Java 里面实现锁 , 主要是 JVM 提供的 synchronized 和 ReentrantLock 这两个机制
那 JVM 之所以能够实现锁机制 , 是因为操作系统提供了 mutex 互斥锁
操作系统之所以能够加锁 , 是因为 CPU 提供了一些用来加锁的 , 能够保证原子操作的指令重量级锁主要是依赖了操作系统提供的锁 , 使用操作系统提供的锁 , 就很容易产生阻塞等待
轻量级锁主要是尽量的避免使用操作系统提供的锁 , 尽量在用户态完成功能 , 也就是尽量的避免用户态和内核态的切换 , 尽量避免挂起等待 (阻塞等待)
大量的内核态用户态切换很容易引发线程的调度
少量的内核态用户态切换.不太容易引发线程调度.
synchronized 是自适应锁 , 既是轻量级锁 , 又是重量级锁
也是根据锁冲突的情况来决定的
冲突的不高就是轻量级锁 , 冲突的很高就是重量级锁
3. 自旋锁 VS 挂起等待锁
自旋锁, 是轻量级锁的一种典型实现
while (抢锁(lock) == 失败) {}
自旋锁 : 当我们发现锁冲突的时候 , 不会挂起等待 , 它会迅速再次尝试这个锁能不能获取到 (超级舔狗)
他就相当于是一个 while 循环一直获取锁的状态
一旦锁被释放 , 就可以第一时间获取到
如果锁一直不释放 , 就会消耗大量的 CPU
自旋锁是更加轻量的 , 效率更高的
挂起等待锁, 是重量级锁的一种典型实现
通过内核态, 借助系统提供的锁机制, 当出现锁冲突的时候会牵扯到内核对于线程的调度. 使冲突线程出现挂起 (阻塞等待)
挂起等待锁 : 发现锁冲突 , 就挂起等待
一旦锁被释放 , 不能第一时间获取到
在锁被其他线程占用的时候 , 会放弃 CPU 资源
挂起等待锁是更加重量的 , 效率更低的
synchronized 中的轻量级锁策略大概率就是通过自旋锁的方式实现的.
4. 普通的互斥锁 VS 读写锁
读写锁, 把读操作和写操作加锁分开了, 一个事实: 多线程同时去读同一个变量, 不涉及到线程安全问题~~
我们数据库事务, 隔离级别中学的 (读操作加锁 和 写操作加锁) 还不太一样
事务中的读加锁, 写加锁, 要比此处谈到的读写锁, 粒度更细情况分的更多
写加锁: 写的时候不能读
读加锁: 读的时候不能写
如果两个线程, 一个线程读加锁, 另一个线程也是读加锁, 不会产生锁竞争
如果两个线程, 一个线程写加锁, 另一个线程也是写加锁, 会产生锁竞争
如果两个线程, 一个线程写加锁, 另一个线程读加锁, 也会产生锁竞争
实际开发中, 读操作的频率, 往往比写操作高很多
java标准库里, 也提供了现成的读写锁~~
读写锁就是把读操作和写操作区分对待 , Java 标准库提供了 ReentrantReadWriteLock类 , 实现了读写锁.
ReentrantReadWriteLock.ReadLock 类表示一个读锁 . 这个对象提供了 lock / unlock方法进行加锁解锁 .
ReentrantReadWriteLock.WriteLock 类表示一个写锁 . 这个对象也提供了 lock / unlock方法进行加锁解锁 .
Synchronized 不是读写锁
5. 公平锁 VS 非公平锁
公平锁: 遵守 "先来后到". B 比 C 先来的. 当 A 释放锁的之后, B 就能先于 C 获取到锁.非公平锁: 不遵守 "先来后到". B 和 C 都有可能获取到锁.
啥样的情况才算是公平呢 ?
符合 “先来后到” 这样的规则 , 就是公平
先来的 , 先排在前面 . 来晚的 , 就在后面排着
非公平锁, 看起来使概率均等, 但是实际上是不公平 (每个线程阻塞时间是不一样的)
操作系统自带的锁(pthread_mutex) 属于是非公平锁
要想实现公平锁, 就需要有一些额外的数据结构来支持. (比如需要有办法纪录每个线程的阻塞等待时间)
6. 可重入锁 vs 不可重入锁
问题: 引用计数
可重入锁, 是让锁记录了当前是哪个线程持有了锁~~
synchronized(this) { // 真正加了锁
synchronized(this) { // 没有加锁 只是判定了持有线程就是当前线程
synchronized(this) { // 没有加锁, 只是判定了持有线程就是当前线程
....
} // 所以执行到这个代码, 出了这个代码块的时候, 刚才加上的锁不应该释放
} // 如果最里层的} 处释放了锁, 意味着最外面的synchronized 和 中间的synchronized 后续的代
// 码部分就没有处在锁的保护之中了
} // 真正释放锁的地方
如果加锁是N层, 在遇到 } , JVM咋知道当前这个 } 是最后一个(最外层真正释放锁的地方呢)
让锁这里持有一个 "计数器" 就行了
让锁对象不光要记录是哪个线程持有锁, 同时再通过一个整形变量记录当前这个线程加了几次锁
每遇到一个加锁操作, 计数器就 +1, 每遇到一个解锁操作, 就 -1
当计数器被减为 0 的时候, 才真正执行释放锁操作, 其他时候不释放~~
相关面试题:
1) 你是怎么理解乐观锁和悲观锁的,具体怎么实现呢?
悲观锁认为多个线程访问同一个共享变量冲突的概率较大, 会在每次访问共享变量之前都去真正加锁.乐观锁认为多个线程访问同一个共享变量冲突的概率不大. 并不会真的加锁, 而是直接尝试访问数据. 在访问的同时识别当前的数据是否出现访问冲突.悲观锁的实现就是先加锁(比如借助操作系统提供的 mutex), 获取到锁再操作数据. 获取不到锁就等待.乐观锁的实现可以引入一个版本号. 借助版本号识别出当前的数据访问是否冲突. (实现细节参考上面的图).
2) 介绍下读写锁?
读写锁就是把读操作和写操作分别进行加锁.读锁和读锁之间不互斥.写锁和写锁之间互斥.写锁和读锁之间互斥.读写锁最主要用在 "频繁读, 不频繁写" 的场景中.
3) 什么是自旋锁,为什么要使用自旋锁策略呢,缺点是什么
如果获取锁失败, 立即再尝试获取锁, 无限循环, 直到获取到锁为止. 第一次获取锁失败, 第二次的尝试会在极短的时间内到来. 一旦锁被其他线程释放, 就能第一时间获取到锁.相比于挂起等待锁,优点: 没有放弃 CPU 资源, 一旦锁被释放就能第一时间获取到锁, 更高效. 在锁持有时间比较短的场景下非常有用.缺点: 如果锁的持有时间较长, 就会浪费 CPU 资源
4) synchronized 是可重入锁么
是可重入锁.可重入锁指的就是连续两次加锁不会导致死锁.实现的方式是在锁中记录该锁持有的线程身份, 以及一个计数器(记录加锁次数). 如果发现当前加锁的线程就是持有锁的线程, 则直接计数自增