✏️作者:银河罐头
系列专栏:JavaEE
“种一棵树最好的时间是十年前,其次是现在”
这不是两把具体的锁,而是两类锁。
乐观锁:预测锁竞争不是很激烈(做的工作相对更少)
悲观锁:预测锁竞争会很激烈(做的工作可能相对更多)
乐观和悲观,唯一的区分是预测锁竞争激烈程度。
举个栗子:
疫情放开后,乐观锁认为新冠毒性减弱,可以随意旅游到处吃喝玩乐,阳了也没事。而悲观锁仍然宅在家里并屯粮屯药,做好防护。
轻量级锁加锁解锁开销小,效率更高;
重量级锁加锁解锁开销大,效率更低。
多数情况下,乐观锁也是一个轻量级锁;悲观锁也是一个重量级锁(不能完全保证)
自旋锁是一种典型的轻量级锁,挂起等待锁是一种典型的重量级锁。
举个栗子:张三喜欢上一个妹子,当张三和妹子表白后,妹子说:你是个好人,我已经有男朋友了。
自旋锁:张三不死心,死皮赖脸,每天和妹子说早安晚安,只要妹子和男朋友分手了,张三就立刻抓住机会上位。
挂起等待锁:张三说我愿意等,不打扰妹子,张三让妹子如果分手了就告诉他。过了很久很久,妹子想起张三了,表示愿意和张三试试,这个很长的时间段里,妹子可能已经换过好几个男朋友了。
自旋锁:一旦锁被释放,就能第一时间感知到,从而有机会获取到锁,很明显,自旋锁占用了大量的系统资源。
挂起等待锁:不占用CPU,CPU可以干别的事。
锁策略,是你是实现锁的时候,出现了竞争,怎么办。
互斥锁:就像前面学过的 synchronized 这样的锁,提供加锁,解锁两种操作,如果一个线程加锁了,另一个线程尝试加锁就会阻塞等待。
读写锁:提供了3种操作:1.针对读加锁 2.针对写加锁 3.解锁
多线程针对同一个变量并发读,这个时候是没有线程安全问题的,也不需要加锁控制。
读锁和读锁之间没有互斥;写锁和写锁之间存在互斥;写锁和读锁之间存在互斥。
当前代码中如果只有读操作,加读锁,有写操作,加写锁。
假设有一组线程都去读(加读锁),这些线程之间是没有锁竞争的,也没有线程安全问题(又快又准)
假设这组线程有读又有写,才会有锁竞争。
在开发场景中,读操作非常频繁,比写操作频率高很多。
在 Java 标准库里面也提供了读写锁的具体实现。(两个类,读锁类,写锁类)
这里的公平意思是"先来后到"。
举个栗子:
公平锁:当妹子分手后,就由等待队列中最早追妹子的舔狗上位。
非公平锁:“雨露均沾”,3个舔狗一拥而上,各凭本事
操作系统和 Java synchronized 原生都是"非公平锁",操作系统这里针对加锁的控制,本身就依赖于线程调度顺序的,这个调度顺序是随机的,不会考虑到这个线程等待锁多久了。
要想实现公平锁,就要在这个基础上引入一些额外的东西,比如队列,让这些加锁的线程去排队。
不可重入锁:一个线程针对一把锁,连续加锁2次,出现死锁。
可重入锁:一个线程针对一把锁,连续加锁多次都不会出现死锁。
CAS: 全称Compare and swap,字面意思:”比较并交换“
假设内存中的原数据V,旧的预期值A,需要修改的新值B。
- 比较 A 与 V 是否相等。(比较)
- 如果比较相等,将 B 写入 V。(交换)
- 返回操作是否成功。
// CAS 伪代码
boolean CAS(address, expectValue, swapValue) {
if (&address == expectedValue) {
&address = swapValue;
return true;
}
return false;
}
在上述交换过程中,其实不太关心 B 的后续情况,更关心 V 这个变量的情况,说是交换,也可以理解为是赋值
如果 V == A 就把 B 的值赋给 A ;如果 V != A , 则不进行交换操作。
上述这个 CAS 的过程,并非是通过一段代码实现的,而是通过一条 CPU 指令完成的。也就是说 CAS 操作是原子的。那么就可以在一定程度上回避线程安全问题。
这样一来,我们解决线程安全问题除了加锁,还可以考虑 CAS 这种思路
原子类,Java标准库里提供的类
import java.util.concurrent.atomic.AtomicInteger;
public class ThreadDemo {
public static void main(String[] args) throws InterruptedException {
//这些原子类就是基于 CAS 实现了自增,自减等操作,此时进行这类操作不加锁也是线程安全的。
AtomicInteger count = new AtomicInteger(0);
Thread t1 = new Thread(()->{
for(int i = 0;i < 50000;i++){
// Java 不支持运算符重载,所以只能使用普通方法来自增自减
count.getAndIncrement();//count++;
//count.incrementAndGet();//++count;
//count.getAndDecrement();//count--;
//count.decrementAndGet();//--count;
}
});
Thread t2 = new Thread(()->{
for(int i = 0;i < 50000;i++){
count.getAndIncrement();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(count.get());
}
}
//输出:
100000
伪代码实现:
另一个线程改的是内存 value,寄存器是每个线程都有自己的一份上下文
原子类的实现,每次修改之前都要确认这个值是否符合要求。
CAS这个方法属于特殊方法,只是特定场景能使用,没有那么通用;而 synchronized 属于通用方法,各种场景都能使用。
//自旋锁伪代码
public class SpinLock {
private Thread owner = null;//当前的锁是谁加的
public void lock(){
// 通过 CAS 看当前锁是否被某个线程持有.
// 如果这个锁已经被别的线程持有, 那么就自旋等待.
// 如果这个锁没有被别的线程持有, 那么就把 owner 设为当前尝试加锁的线程.
while(!CAS(this.owner, null, Thread.currentThread())){
}
}
public void unlock (){
this.owner = null;
}
}
//检测当前 owner 是否为 null,是 null 就交换,也就是把当前线程的引用赋给owner,复制成功,CAS()返回 true,循环结束;如果锁已经被别的线程占用了,owner不是null,则 CAS 不会进行赋值,CAS()返回 false,循环继续,继续进行下一次判定,
Java并不是直接提供了一个方法叫 CAS , Java原生提供的 CAS 相关的方法比较复杂,此处的 CAS 相当于是一个简化的表示方式。
CAS 在运行中的核心,就是检查 value 和 oldValue 是否一致,如果一致就认为 value 中途没有被修改过,所以就进行下一步交换操作。
然鹅,这里可能是没有被修改过,有可能是修改过又还原回来了。
设 value = A ; 可能 value 始终为 A,也有可能是 value 本来是 A,被改成了 B,又被还原成了 A
ABA这个情况,大部分情况下,其实不会对代码/逻辑产生太大影响。但是不排除一些"极端情况",也是可能造成影响的。
有一种极端情况,实际开发中概率非常低。
举个栗子:
张三要去 ATM 取钱,假设张三当前账户余额为 1000,张三准备取 500。当按下取款这一瞬间,机器卡了下,张三忍不住多按了几下,可能会产生 bug。可能会触发重复扣款的操作。
针对当前问题,采取的方案是加入一个版本号,想象成初始版本号是1,每次修改版本号都 +1,然后进行 CAS 的时候,不是以金额为基准了,而是以版本号为基准。
此时,版本号要是没变,就是一定没有发生改变(版本号不能降低,只能增长)
基于版本号的方式也是乐观锁的一种典型实现方式。
结合上面的锁策略, 我们就可以总结出, Synchronized 具有以下特性:
synchronized 默认情况下是乐观锁,如果发现当前锁冲突很激烈的情况下就会变成悲观锁。
synchronized 默认情况下是轻量级锁,如果发现当前锁冲突很激烈的情况下就会变成重量级锁。
synchronized 这里的轻量级锁是基于自旋锁实现的;
synchronized 这里的重量级锁是基于挂起等待锁实现的。
synchronized 不是读写锁
synchronized 是非公平锁
synchronized 是可重入锁
上述 6 种锁策略,可以视为是"锁的形容词"
synchronized 内部还有一些优化机制,存在的目的是为了让这个锁更高效,更好用。
1.锁升级/锁膨胀
1)无锁
2)偏向锁
3)轻量级锁
4)重量级锁
synchronized(locker){
}
//当代码执行到这个代码块之后,加锁过程可能会经历前面说的这几个阶段
进行加锁的时候,首先会经历 偏向锁 状态,
偏向锁并不是真正的加锁,而只是占个位置,有需要就加锁,没需要就算了。
举个栗子:
假设张三是个妹子,当她谈了一个男朋友,谈的久了就想换。
如何和男朋友分手?
挑对方毛病上纲上线,使劲作。技术活,成本太高,来得慢。
张三想到了一个更加高效的方式,只和小哥哥暧昧,不和他确认关系(有情侣之实,无情侣之名),这样一来张三想换男朋友成本就非常低了。
但是这么做就有个风险。
无情侣之名(没有对小哥哥加锁),如果有竞争对手来了(其他妹子来抢小哥哥)。
如果有竞争对手出现,我就立即和小哥哥确认关系,立刻官宣,加锁,让小哥哥离别的妹子远点
上述这个过程,就是"偏向锁"的过程,相当于"懒汉模式"的"懒加载"一样。
“非必要,不加锁”
synchronized 并不是真正加锁,,先偏向锁状态,做个标记(这个过程是非常轻量的),如果整个使用锁的过程中都没有出现锁竞争,那么就不用进行其他同步操作了(避免了加锁解锁的开销)
如果后续有其他线程来竞争该锁(刚才已经在锁对象中记录了当前锁属于哪个线程了, 很容易识别当前申请锁的线程是不是之前记录的线程), 那就取消原来的偏向锁状态, 进入一般的轻量级锁状态.。另一个线程也只能阻塞等待了。
偏向锁是 synchronized 内部的实现,也就是 JVM 里面 C++ 代码实现的,不是咱们在 Java 代码中能写出来/能看到的。
偏向锁也是有开销的, 所以懒汉模式双重判断 null 就是为了防止偏向锁的添加?
是的,开销高低都是相对的,再低也肯定不如一个 if 判定低
当 synchronized 发生锁竞争时,就会从偏向锁变成轻量级锁(自旋锁)。此处的轻量级锁就是通过 CAS 来实现的。
如果别人很快就释放锁了,自旋是划算的,如果迟迟不释放锁,一直自旋并不划算。
自旋操作一直让 CPU 空转,比较浪费 CPU 资源。
因此这里的 自旋不会一直进行,而是达到一定时间/重试次数,就不再自旋了。也就是所谓的"自适应"
如果竞争进一步加剧,自旋不能快速获取到锁,就会膨胀为重量级锁。
重量级锁则是基于操作系统原生的 API 来进行加锁。
Linux 原生提供了 mutex 一组 API。操作系统内核提供的加锁功能。
这个锁会影响到线程的调度。
此时,如果线程加了重量级锁,并且发生锁竞争,此时线程就会被放到阻塞队列中,暂时不参与 CPU 调度了。
直到锁被释放了,线程才有可能被调度到,有机会获取到锁。
当前锁只能升级,只要是指定的锁对象,已经被升级了,就回不了头了。
除非是另外搞一个其他的锁对象,还是重复刚才的偏向锁,轻量级锁,重量级锁的过程。
编译器智能的判定,看当前代码是否需要加锁,如果这个场景不需要加锁,人为加了,就自动把锁给清除掉。
StringBuffer 关键方法中都带有 synchronized ,但是如果在单线程中使用 StringBuffer ,synchronized 加了也白加,此时编译器就会把加锁操作给干掉。
锁的粒度:synchronized 包含的代码越多,锁的粒度就越粗;包含的代码越少,粒度就越细。
通常情况下认为锁的粒度细一点比较好,锁的粒度越细,能并发的代码就越多。
但是有些情况下,锁的粒度粗一点比较好,eg:两次加锁解锁之间间隙非常小,此时不如一次大锁就搞定了。
举个栗子:
张三给领导汇报工作:
1.打电话,汇报工作,挂电话;
2.打电话,汇报工作,挂电话;
3.打电话,汇报工作,挂电话;
…
这样频繁的打电话,领导也烦了。
最好的方式是:
打电话,汇报工作, 汇报工作, 汇报工作,挂电话
JUC(java.util.concurrent) ,放了并发编程(多线程编程)相关的组件。
各种集合类,scanner,random…
并发编程,更广义的概念。多线程编程是实现并发编程的一种具体方式(Java提供的默认的方式)。
除了这种方式,还有很多其他方式(其他并发编程模型)
类似于 Runnable 一样,
Runnable 用来描述一个任务,没有返回值。
Callable 也是用来描述一个任务,有返回值。
如果需要一个线程单独的计算出某个结果来, 用 Callable 比较合适
这里不能直接把 callable 传入到 Thread 的构造方法里,需要套上一层其他的辅助类
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.FutureTask;
public class ThreadDemo{
public static void main(String[] args) throws ExecutionException, InterruptedException {
//使用 callable 来计算 1 + 2 + 3 + ... + 1000
Callable callable = new Callable() {
@Override
public Integer call() throws Exception {
int sum = 0;
for(int i = 1;i <= 1000;i++){
sum += i;
}
return sum;
}
};
FutureTask futureTask = new FutureTask<>(callable);
Thread t = new Thread(futureTask);//新线程执行futureTask内部callable的call方法,把计算结果保存到futureTask对象中,futureTask.get()一直阻塞到计算完成获取到结果
t.start();
Integer result = futureTask.get();//get 就是获取结果,直到 callable 执行完毕,get 才阻塞完成,才获取到结果。
System.out.println(result);
}
}
创建线程的方式又进一步的扩充了
entry 条目/入口
entrant , entry 的变形
ReentrantLock 是标准库提供的另一种锁,“可重入锁”
synchronized 是基于代码块来加锁解锁的,而 ReentrantLock 是使用了 lock 方法和 unlock 方法来加锁解锁
ReentrantLock reentrantLock = new ReentrantLock();
reentrantLock.lock();
reentrantLock.unlock();
//这样的写法可能会导致最后的 unlock()执行不到(如果中间执行了return 或抛异常等操作)
解决办法是把 unlock() 放到 finally{} 中
上述是 ReentrantLock 的劣势,但是也是有优势的
ReentrantLock reentrantLock = new ReentrantLock(true);//参数为 true => 公平锁
//不加或参数为 false 则是非公平锁
而 synchronized 是非公平锁,要想变成公平锁得在内部引入阻塞队列等操作保证"先来后到"。
2. 对于 synchronized 来说,提供的加锁操作就是死等,只要获取不到锁,就一直等;
而 ReentantLock 提供了更灵活的等待方式,tryLock().
1)无参数版本:能加锁就加锁,加不上锁就放弃。
2)有参数版本: 指定一个等待时间,等待时间到了还没加上锁就放弃。
tryLock()有返回值,返回 true加锁成功就解锁 ; 没加上锁返回false就不解锁了。
synchronized 搭配的是 wait , notify. notify是随机唤醒一个 wait 的线程。
ReentantLock 搭配的是一个 Condition 类,进行唤醒的时候可以唤醒指定的线程。
原子类内部用的是 CAS 实现,所以性能要比加锁实现 i++ 高很多。原子类有以下几个
AtomicBoolean
AtomicInteger
AtomicIntegerArray
AtomicLong
AtomicReference
AtomicStampedReference
以 AtomicInteger 举例,常见方法有
addAndGet(int delta); i += delta;
decrementAndGet(); --i;
getAndDecrement(); i--;
incrementAndGet(); ++i;
getAndIncrement(); i++;
基于 CAS ,确实是更高效的解决了线程安全问题,但时候 CAS 不能代替锁,CAS 适用范围是有限的,不像锁适用范围那么广
举个栗子:
停车场
停车场的车位是有上限的。很多停车场会在入口这里显示个牌子,牌子上写:当前空闲车位有 XX 个。
每次有车从入口进去,计数器就 -1;每次有车从出口出来,计数器就 +1.
如果当前停车场的车满了,计数器就为0。
这个时候,如果还有车想停,
1)在这里等
2)不等了,去找别的停车场
信号量,本质上就是一个计数器,描述了可用资源的个数。
P 操作:申请一个可用资源,计数器 -1;
V 操作:释放一个可用资源,计数器 +1.
如果计数器为0了还进行 P 操作就会阻塞等待。
考虑一个计数初始值为 1 的信号量,针对这个信号量的值只有 0 和 1 两种情况(信号量不能是负值)
执行一次 P 操作,计数器:1 -> 0;
执行一次 V 操作,计数器:0 -> 1;
如果已经进行了一次 P 操作,那么在进行一次 P 操作就会阻塞等待。
锁可以视为计数器为 1 的二元信号量(只有0 1两种取值)。
锁是信号量的一种特殊情况,信号量是锁的一般表达
代码中也是可以用 Semaphore 来实现类似于 锁 的效果来保证线程安全。
P 操作一般用 acquire 申请
V 操作一般用 release 释放
Semaphore semaphore = new Semaphore(3);
semaphore.acquire();
System.out.println("执行一次 P 操作");
semaphore.acquire();
System.out.println("执行一次 P 操作");
semaphore.acquire();
System.out.println("执行一次 P 操作");
semaphore.acquire();
System.out.println("执行一次 P 操作");
//semaphore.acquire(2);
//acquire() 还可以传参数,一次申请多个
计数器为0了还进行 P 操作 , 阻塞等待
用的不多,特定场景用的小工具。
举个栗子:
想想有一个跑步比赛,开始时间是明确的(裁判的发令枪),结束时间则是不明确的(要等所有选手都冲过终点线)
为了等待这个跑步比赛结束,就引入了这个 CountDownLatch
主要是2个方法。
await (wait 是等待,a => all),主线程来调用这个方法。
countDown 表示选手冲过了终点线。
countDown在构造的时候,指定一个计数(选手的个数)。
例如,指定四个选手去比赛。初始时调用await方法,就会堵塞。每个选手冲过终点,都会调用countDown方法。前三次调用countDown,await没有任何影响。第四次调用countDown,await就会被唤醒,解除阻塞,此时就可以认为是整个比赛都结束了。
比如下载一个大文件
视频文件好几个G。多线程下载,把一个大的文件切分成几个小的文件。安排多个线程分别下载。当前家用的带宽下载速度是很快。很多时候是应用程序代码本身不能充分利用这样的带宽。
多线程下载不是充分利用了CPU,而是充分利用了带宽。下载是IO操作,和CPU关系不大。此处就可以使用countDownLatch来判断是不是整体都下载完了。
Java标准库里的大部分集合类都是线程不安全的。多个线程使用同一个集合类对象很有可能出现问题。
Vector , Stack , HashTable 这几个类是少数的线程安全的集合类(不建议用), 关键方法带有synchronized.
自己加锁,自己使用synchronized 或 ReentrantLock[常见]
Collections.synchronizedList 这里会提供一些 ArrayList 相关的方法,同时是带锁的,使用这个方法把集合类套一层。
CopyOnWriteArrayList
简称 COW,也叫做"写时拷贝"。如果针对这个ArrayList进行读操作,不做任何额外的工作。如果进行写操作,则拷贝一份新的ArrayList,针对新的进行修改,修改过程中如果有读操作,就继续读旧的这份数据,当修改完毕了,使用新的替换旧的,本质上就是一个引用之间的赋值(原子的).
很明显,这种方案优点是不需要加锁,缺点则是要求这个ArrayList不能太大,只是适用于这种数组比较小的情况下.
服务器程序的配置维护。
(mysql, my.ini)
一个程序可能包含很多个子功能,有的功能想要使用,有的不想要使用,有的希望功能应用不同的形态,就可以使用一系列的开关选项来控制这个程序的工作状态。服务器程序的配置文件可能会需要进行修改,修改配置可能就需要重启服务器才能生效。
但是重启操作可能成本比较高,假设一个服务器重启需要花5min(往小了说的)。如果你有20台这样的服务器,总的重启时间就得有100min.
因此很多服务器都提供了"热加载"(reload)这样的功能,通过这样的功能就可以不重启服务器,实现配置的更新。热加载的实现就可以使用刚才的 写时拷贝 的思路。
新的配置放到新的对象中。加载过程中,请求仍然基于旧配置进行工作。当新的对象加载完毕,使用新对象替代旧对象(替换完成之后,旧对象就可以释放了)
HashMap 是线程不安全的。
HashTable 是线程安全的(给关键方法加了 synchronized)
更推荐使用的是 ConcurrentHashMap , 更优化的线程安全哈希表。
ConcurrentHashMap 进行了哪些优化?比 HashTable 好在哪里?和 HashTable 之间的区别是啥?
HashTable 是直接在方法上加 synchronized,相当于是给 this 加锁。只要操作操作哈希表上的任何元素都可能会发生锁冲突。
但是实际上,仔细思考不难发现,基于哈希表的结构特点,有些元素在进行并发操作的时候是不会发生线程安全问题的,也就不需要使用锁控制。
此时元素 1 2在同一个链表上,如果线程 A 修改元素 1,线程 B修改元素 2。(修改可能包含增删改)
这种情况需要加锁,会有线程安全问题。比如这2个元素相邻,此时并发的插入/删除,就需要修改这两节点相邻节点的next指向。
如果线程 A 修改元素 3,线程 B 修改 元素 4,这种情况不需要加锁。这个情况就相当于多个线程修改不同的变量。
HashTable 锁冲突的概率太大了,任何两个元素的操作都会有锁冲突,即使是在不同的链表上。(这也是不用HashTable的主要原因)
ConcurrentHashMap 的做法是每个链表有各自的锁(而不是大家共用一把锁了)
具体来说就是用每个链表的头结点作为锁对象(两个线程针对同一个所对象加锁才会有锁竞争,才会有阻塞等待)
此时把锁的粒度变小了。
针对 1 2 这种情况,是针对同一个对象加锁,会有锁竞争,要保证线程安全。
针对 3 4 这种情况,是针对不同对象加锁,不会有锁竞争,没有阻塞等待
上面谈到的情况是 JDK1.8 以后的情况。
在 1.7 和之前 ConcurrentHashMap 使用的是"分段锁"
"分段锁"本质上也是缩小锁的范围,从而降低锁冲突的概率,但是这种做法不够彻底。一方面粒度切分的还不够细,另一方面代码的实现也更繁琐。
读和读之间没有冲突,写和写之间有冲突,读和写之间也没有冲突。很多场景下,读写之间不加锁控制,可能会读到一个写了一半的结果,如果写操作不是原子的(volatile + 原子的写操作),此时读就可能会读到写了一半的数据,相当于脏读了
ConcurrentHashMap 内部充分使用了 CAS, 通过这个进一步削减加锁操作的数目,比如维护元素个数
针对扩容,采取了"化整为零"的方式
HashMap/HashTable 扩容:创建一个更大的数组空间,把旧的数组上的链表上的每个元素搬运到新数组上(插入+ 删除)。这个扩容操作会在某次 put 的时候进行触发。如果元素个数特别多,就会导致搬运操作特别耗时。就会出现某次 put 比平时的 put 卡很多倍
ConcurrentHashMap 中 ,扩容采用的是每次搬运一小部分元素的方式。创建新的数组,旧的数组也保留。每次put 操作都往新数组上添加,同时进行一部分搬运(把一部分旧的元素搬运到新数组上)。每次get的时候,旧数组和新数组都查询。每次remove的时候,把元素删了就行了。
…
经过一段时间,所有的元素都搬运好了,旧数组就可以释放了。