简单总结一下自身对于锁策略的理解.
首先锁策略并非只针对某一种编程语言, 不同的编辑语言都可以使用同一套锁策略. 常见的锁策略有:
乐观锁和悲观锁
乐观锁, 即认为锁的竞争并非非常激烈. 悲观锁反之. 换句话说, 假设期末来临. 乐观态度的学生认为复习的很好, 问题不大. 而悲观的学生则反复复习, 总怕遗漏知识点. 其次, 乐观锁与悲观锁无法去准确的区分界定, 或者说, 乐观锁与悲观锁是可以相互转变的. 主要就是预测锁竞争是否激烈.
轻量级锁与重量级锁
一般认为线程是轻量级进程. 因为其开销小于进程. 这里的锁也是如此, 轻量级锁上锁解锁开销都小于重量级锁.
自旋锁和挂起等待锁
当我们在开灯的时候, 按下开关, 可能灯并没有亮. 这种情况下, 第一反应是多按几次. 自旋锁跟按开关差不多. 所谓自旋就是会反复尝试去加锁, 直到加上锁为止. 若未加上, 就反复尝试. 因此它的加锁解锁开销都并不大, 是轻量级锁. 但是, 由于要时时刻刻都去尝试加锁, 因此会一直占用一部分系统资源.
挂起等待锁则反之. 开不了灯, 那我就不开了, 等什么时候想起来再来开一下试试. 与自旋锁相对应, 不会一直占用系统资源去尝试加锁, 进而是一个重量级锁.
互斥锁与读写锁
互斥锁一般是有两个操作, 即加锁与解锁, 如果一个线程已经加锁, 另一个线程想要加锁, 就得阻塞等待解锁. 在解锁之前, 加锁与加锁是互斥的.
读写锁则是三个操作, 针对读加锁, 针对写加锁, 以及解锁. 在多线程中, 如果多个线程同时读一个数据, 显然是不会造成线程安全问题的. 因为只读不改, 变量并未改变. 但写操作并非如此. 因此, 如果使用互斥锁就得将整个读写代码都给加锁, 而使用读写锁, 可以针对代码中的写部分单独加锁.
公平锁与非公平锁
这里涉及到顺序问题. 在众多线程中, 先到的线程先加锁, 即为公平锁. 但是这样实现起来其实极其麻烦, 可能得搞一个队列来存放每一个线程任务. 因此大多操作系统都提供的是非公平锁, 雨露均沾. 加锁依赖于线程调度的顺序, 调度顺序本来就是随机的, 不会考虑谁先到谁后到的问题.
可重入锁与不可重入锁.
这个就是看其能不能加锁之后再套一个锁.
以synchronized为例, 描述任何编程语言的任何一个锁都可以以上为修饰词描述: 它是既是悲观锁也是乐观锁; 既是轻量级锁也是重量级锁; 可以自旋可以挂起; 是互斥锁; 是非公平锁; 也是可重入锁.
接着来说一下CAS(compare and swap)
即比较并交换. 其原理是, 比较A C二者的值的大小, 若相同, 则将其中一个值替换为另一个值B. 若不同则无事发生.
很简单易懂的原理. 但是其最大特点是: 整个CAS过程是不可分割的. 也就是说其具有原子性. 多线程中加锁, 就是为了避免非原子性的读写操作造成线程安全. 而CAS可以有效避免这种情况. 因此, CAS的一大用武之地就是实现原子类:
import java.util.concurrent.atomic.AtomicInteger;
public class Main {
public static void main(String[] args) throws InterruptedException {
AtomicInteger count = new AtomicInteger(0);
Thread t1 = new Thread(() -> {
for (int i = 0; i < 1000; i++) {
count.getAndIncrement();
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 1000; i++) {
count.getAndIncrement();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(count.get());
}
}
在这个代码中, 由于实现了原子类, 不再需要对线程加锁. 从而就能得到准确的结果.
假设两个线程同时执行到读入. 由于CAS本身具有原子性, 故CAS只能在不同线程中一个个执行. 在线程2中先判定出, 值与预估值相同, 故要将值交换为1. 随后到线程1执行时, 比较发现与预估值不同, 则无事发生, 继续往后执行.
但是要注意, 即便CAS与加锁相比似乎更为方便, 可其只属于特殊情况特殊处理的特殊方法. 相比于synchronized加锁而言, 使用范围要小得多, 因此更多时候还是考虑使用加锁这个通用方法.
public class SpinLock {
private Thread owner = null;
public void lock(){
while(!CAS(this.owner, null, Thread.currentThread())){
}
}
public void unlock (){
this.owner = null;
}
}
第二个用法就是实现自旋锁. 以一段伪代码来大致描述其过程, 因为是伪代码, 拿到Idea上肯定是会报错的. 在CAS实现自旋锁的时候, 会进行判定, 判定当前的owner是否为空, 而owner是用来判定是否加锁的, 若为空即为未加锁. 因此, 为空时与判定值相同, 就进行交换把当前线程的引用赋值给owner, 即加锁成功, 返回true, 从而结束循环. 若owner不为null, 即锁已被别的线程占用, 返回false, 循环判定而实现自旋.
CAS也存在一定问题. 最典型的就是ABA问题. 假设存入的值是A, 预期值为C, 修改值为B. 也就是说, 先将A与C进行比较, 若二者不同, 令A等于B, 以此实现交换. 那在这里完全可能存在一种极端情况, 有没有可能A通过CAS后改成了B, 后又改成了A? 这样说可能过于抽象, 举一个极端的例子:
平常超市购物结账的时候, 一般都是手机展示付款码, 收银员扫码. 假设有一天去购物, 微信钱包中余额为一千, 购物花费五百. 那么在付款的时候, 就可以视为CAS的过程, 即A为1000, C为1000, B为500, 扫码完成后, 按照流程, 微信钱包显示的余额应该改为500. 这就相当于是AC相比, 相同, 再将C的值赋给A, 即将余额改为500.
但是假如第一次扫码时出现某种不可抵抗的意外导致为弹出已付款的提示, 导致收银员误以为没有扫上码从而再扫一次. 也就是说总共扫了两次. 但由于CAS的特性, 第二次的A已改为500, 与C不同, 因此无事发生.
但假设刚好这个时候, 有人往该微信账户转账五百呢? 这个时候就出问题了, 扫两次, CAS两次发现A都为1000, 那就可能会发生二次扣款. 当然这只是假设, 真正的扣款操作可能不是CAS原理达成的.
这种情况发生概率极小, 但是不排除其发生的可能性. 解决方法也很简单, 加一个版本号即可. 每次CAS都将版本号+1予以区分. 不以金额为准而以版本号为准, 确保万无一失.
两个线程中, synchronized在对同一个对象加锁时, 必然会出现阻塞等待. 虽然这个等待是必须的. 但是必然会一定程度上降低效率, 因此其内部存在一些优化机制.
第一个是锁升级机制.
锁升级分为几个阶段: 无锁, 偏向锁, 轻量级锁, 重量级锁.
当代码执行到有synchronized时, 就会由无锁转为偏向锁. 偏向锁实际上还并未加锁, 只是占了个位置, 有加锁的趋向. 直到发生锁竞争后, 就会转为轻量级锁, 也就是自旋锁. 如果自旋尝试重新加锁发现迟迟都未解锁, 则会转为重量级锁也就是挂起等待锁. 大概是这么一个过程. 但要注意, 锁可以升级, 却不能降级.
举一个不太恰当的例子来描述一个过程. 假设我喜欢一个妹子, 这个妹子也是单身, 也就是无锁状态. 这个时候我主动出击. 但是我不想确定男女关系, 因为确定关系后对她加了锁, 别的男性不能靠近她, 同时也是对我自己加锁, 这样我就没法接近其他妹子. 因此, 我跟她搞暧昧, 迟迟不表白. 虽然经常跟她一起出去玩, 但关系一直没捅破. 结果时间一长, 妹子感觉我是渣男故意钓着她. 反手跟别的男生表白, 被加上了锁.
但是我又忘不掉她, 于是每天反反复复的对她嘘寒问暖. 盼望着她能分手解锁, 好让我上位. 这个时候我就是轻量级锁, 反复判定是否解锁, 若解锁, 就直接表白上锁. 结果时间一长,我发现这妹子好像无动于衷, 没有要解锁的迹象. 那我何必在这里浪费时间, 天天嘘寒问暖还不如去干些别的事, 于是我就变成重量级的挂起等待锁, 想起来了再来看看有没有解锁. 大概就是这么个过程.
第二个锁升级机制是锁消除
即编译器智能判定是否需要加锁. 若不需要加锁而程序员加了锁, 就会自动把锁给销毁掉. 典型的案例就是StringBuffer的关键方法都带有锁, 但实际上在有些算法题中使用时, 是将其剔除掉的. 因为单线程编程不存在加不加锁的问题. 加了也是浪费资源.
第三个锁升级机制是锁粗化
这里涉及到锁的粒度这一概念. synchronized所包含的代码越多, 则粒度越粗. 一般来说粒度越细越好. 这不难理解, 因为越多意味着不能并发执行的代码就越多. 这样执行的效率就越慢. 但如果加锁与加锁之间间隙比较短呢? 这就好比网购. 买三样东西, 一般都是选好了后一起付钱. 而不是一个个付钱付三次. 同样的道理, 加锁与解锁都是非常消耗系统资源的. 如果两此加锁间隙过小, 就直接搞一个粗化的锁. 这样比解锁再加锁更节省资源.
-----------------------------------最后编辑于2023.6.16