1. 常见锁策略
1.1 乐观锁 & 悲观锁
乐观锁 & 悲观锁 也不是指具体某个锁,而是 “锁的一种特点”,描述了 “一类锁”
- 乐观锁:加锁的时候,假设出现锁冲突的概率不大 => 接下来围绕加锁要做的工作就会更少
- 悲观锁:加锁的时候,假设出现锁冲突的概率很大 => 接下来围绕加锁要做的工作就会更多
tip:
synchronized 是 “自适应” 的锁,使用 synchronized 时,初始情况下是乐观的(预估接下来锁冲突概率不大),同时会统计锁冲突了多少次,如果发现锁冲突的次数达到了一定程度,就会转变为悲观的
1.2 重量级锁 & 轻量级锁
效果和 乐观锁悲观锁 是重叠的,只是站在的角度不一样
- 重量级锁,加锁的开销比较大,要做更多的工作(往往悲观的时候会做的重)
- 轻量级锁,加锁的开销比较小,要做的工作相对较少(往往乐观的时候会做的轻)
tip:
虽然 重量轻量 和 悲观乐观 有重叠,但是不能认为是 100% 等价,乐观悲观是站在 “预估锁冲突” 角度;重量轻量是站在 “加锁开销” 角度
synchronized 也是自适应的
1.3 挂起等待锁 & 自旋锁
- 挂起等待锁,就是 悲观锁/重量级锁 的一种典型实现
- 自旋锁,就是 乐观锁/轻量级锁 的一种典型实现
例子:你去追女神,你说:女神我宣你,做我女票吧(你尝试对女神加锁);女神说:你是个好人,我有男票了(女神表示她的锁已经被别的线程加了)
你可以选择 “等待”,每天仍然给女神问候 “早安午安晚安吃了吗...”,这称为 ”自旋锁“,属于忙等,等待的过程中,不会释放 cpu 资源,不停的检测锁是否被释放,一旦释放就立即有机会能够获取到锁(注:这里需要假定锁冲突概率不高的情况下,才能忙等,如果好几个线程都在竞争同一个锁,一个线程拿到锁,其他线程都在忙等,总的 cpu 消耗就会非常高,而且由于竞争太激烈了,就可能导致有些线程要等很久才能拿到锁)
你也可以选择把女神删掉,先不联系了,若干年后听说女神分手了,再去练习,这称为 “挂起等待锁”,不联系相当于 “让出了 cpu 资源”,cpu 就可以用来做其他事情了(挂机等待锁就适合 悲观锁 这样的场景,锁竞争非常激烈,预测拿到锁的概率不大,不妨先把 cpu 让出来去做别的事)
tip:
- synchronized 是 “自适应” 的,轻量级锁就是基于 自旋 的方式实现的(JVM 内部,用户态代码实现)
- 重量级锁就是基于 挂起等待 的方式实现的(调用操作系统 api,在内核中实现)
1.4 公平锁 & 非公平锁
假设以下场景:
tip:
- 在计算机中,约定了 “先来后到” 为公平
- synchronized 属于 非公平锁,当 N 个线程竞争同一个锁,其中一个线程先拿到了,后续该线程释放锁之后,剩下的 N - 1 个线程需要重新竞争,谁拿到锁都不一定
- 如果想要使用公平锁,就需要做额外的操作,如:引入队列、记录每个线程加锁的顺序等等...
1.5 可重入锁 & 不可重入锁
死锁问题,如果一个线程针对一把锁连续加锁两次,就可能出现死锁,如果把锁设定为 “可重入”,就可以避免死锁了
原理:
1) 记录当前是哪个线程持有这把锁
2) 在加锁的时候判定,当前申请锁的线程,是否就是锁的持有者线程
3) 计数器,记录加锁的次数,从而确定何时真正释放锁
1.6 读写锁
多线程之间,数据的读取方之间不会产生线程安全问题,但数据的写入方互相之间以及和读者之间都需要进行互斥,而如果两种常见下都用同一个锁,就会产生极大的性能损耗,所有读写锁因此而产生
读写锁(readers-writer lock),在执行加锁操作时需要额外表明读写意图,复数读者之间并不互斥,而写者则要求与任何人互斥
一个线程对于数据的访问,主要存在两种操作:读数据 & 写数据
- 两个线程都只是读一个数据,此时并没有线程安全问题,直接并发读取即可
- 两个线程都要写一个数据,就会有线程安全问题
- 一个线程读,另一个线程写,也会有线程安全问题
读写锁就是把读操作和写操作区分对待,Java 标准库提供了 ReentrantReadWriteLock 类,实现了读写锁
- ReentrantReadWriteLock.ReadLock 类表示一个读锁,这个对象提供了 lock/unlock 方法进行加锁解锁
- ReentrantReadWriteLock.WriteLock 类表示一个写锁,这个对象也提供了 lock/unlock 方法进行加锁解锁
其中:
- 读加锁 和 读加锁之间,不互斥
- 写加锁 和 写加锁之间,互斥
- 读加锁 和 写加锁之间,互斥
tip:
- 读写锁特别适合 “频繁读,不频繁写” 的场景中
- synchronized 不是读写锁
1.7 synchronized 的加锁过程
当代码执行到 synchronized 代码块中,JVM 大概要做的事情:
锁升级的过程:刚开始使用 synchronized 加锁,锁会处于 “偏向锁” 状态,遇到线程之间的锁竞争,会升级到 “轻量级锁”,进一步统计锁竞争出现的频次,达到一定程度后,升级到 “重量级锁”
偏向锁:
偏向锁不是真的加锁(真的加锁开销可能会比较大),只是做个标记(标记的过程非常轻量高效)
对于当前 JVM 的实现来说,上述锁升级的过程属于 “不可逆” 的
1.8 锁消除 & 锁粗化
二者都是编译器的优化策略
锁消除:编译器会对 synchronized diamagnetic做出判定,判定这个地方是否确实需要加锁,如果这里没必要加锁的话,就能自动把 synchronized 给优化掉
锁粗化:
2. CAS
2.1 概念
CAS:全称 Compare and swap,字面意思 “比较并交换”
比较 内存 和 cpu 寄存器中的内容,如果发现相同,就进行交换(交换的是 内存 和 另一个寄存器 的内容),具体操作如下:
一个内存的数据和两个寄存器中的数据进行操作(寄存器 1 和寄存器 2)
1) 比较 内存 和 寄存器 1 中的值是否相等
2) 如果不相等,无事发生;如果相等,就交换 内存 和 寄存器 2 的值
3)返回操作是否成功
CAS 通过 “一个 cpu 指令” 完成了上述一系列的操作(原子的),因此 CAS 就可以给编写多线程代码带来新思路:“无锁化编程”
2.2 CAS 具体的使用场景
2.2.1 基于 CAS 实现 “原子类”
int / long 类型在进行 ++ -- 操作时,都不是原子的,基于 CAS 实现的原子类,对 int / long 等这些类型进行了封装,从而可以原子的完成 ++ -- 等操作
原子类在 Java 标准库中,也有实现:
import java.util.concurrent.atomic.AtomicInteger;
public class Demo34 {
private static AtomicInteger count = new AtomicInteger(0);
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
count.getAndIncrement(); //count++
count.incrementAndGet(); //++count
count.getAndDecrement(); //count--
count.decrementAndGet(); //--count
count.getAndAdd(10); //count += 10
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
count.getAndIncrement(); //count++
}
});
t1.start();
t2.start();
t1.join();
t2.join();
//通过 count.get() 拿到原子类内部持有的真实数据
System.out.println("count = " + count.get());
}
}
如何通过 CAS 实现的原子类(下面用一段伪代码来理解):
画图理解:
2.2.2 实现自旋锁
基于 CAS 实现更灵活的锁,获取到更多的控制权
自旋锁伪代码:
2.3 CAS 的 ABA 问题
CAS 之所以能保证线程安全,是因为在通过 CAS 比较的过程中来确认当前是否有其他线程插入进来执行
此处是通过判定值是否相同,来区分是否有其他线程修改过
但是 值相同 不等于 没有修改过,因为有可能有另一个线程修改了,又修改回去了
CAS 中确实存在 ABA 问题,但是大多数情况,ABA 问题并不会带来 bug
也有非常极端的场景:ATM 的转账功能,转账过程中通过 CAS 的方式来实现:
解决上述问题的方案:引入版本号
因为 “余额” 能加也能减,所以才会有 ABA 问题,所以引入版本号,是一个整数,设定为只能增加,如下:
3. JUC(java.util.concurrent)的常见类
3.1 Callable 接口
Callable 类似于 Rannable,只不过 Callable 中的 call 方法带有返回值,而 Rannable 中的 run 方法不带返回值(void),示例如下:
创建一个线程,让这个线程计算 1 + 2 + 3 +...+ 1000
public class Demo35 {
private static int result;
public static void main(String[] args) throws InterruptedException {
Thread t = new Thread(() -> {
int sum = 0;
for (int i = 0; i < 1000; i++) {
sum += i;
}
result = sum;
});
t.start();
t.join();
System.out.println("result = " + result);
}
}
该方法要想得到结果,必须引入一个成员变量,相当于引入了一个额外的依赖,让线程和成员变量及主线程和成员变量之间的产生了明显的耦合,虽然可以满足需求,但并不是一个很好的选择
而 Callable 就是解决上述问题的,如下:
完整代码:
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.FutureTask;
public class Demo36 {
public static void main(String[] args) throws ExecutionException, InterruptedException {
Callable<Integer> callable = new Callable<Integer>() {
@Override
public Integer call() throws Exception {
int sum = 0;
for (int i = 0; i < 1000; i++) {
sum += i;
}
return sum;
}
};
FutureTask<Integer> futureTask = new FutureTask<>(callable);
Thread t = new Thread(futureTask);
t.start();
//后续需要通过 FutureTask 拿到最终的结果
System.out.println(futureTask.get());
}
}
总结:创建线程的方式
1. 直接继承 Thread
2. 使用 Runnable
3. 使用 Callable
4. 使用 lambda
5. 使用线程池
3.2 ReentrantLock
可重入互斥锁,和 synchronized 定位类似,都是用来实现互斥效果,保证线程安全
synchronized 只是 Java 提供的一种加锁的方式
而 ReentarantLock 是一种经典风格的锁,通过 lock 和 unlock 方法来完成加锁解锁的
ReentrantLock 和 synchronized 的区别
1) synchronized 属于关键字(底层是通过 JVM 的 C++ 代码实现的);ReentrantLock 则是标准库提供的类,通过 Java 代码来实现的
2) synchronized 通过代码块控制加锁解锁;ReentrantLock 通过调用 lock、unlock 方法来完成(需将 unlock 放到 finally 中,防止意外执行不到)
3) RenntrantLock 提供了 tryLock 这样的加锁风格,前面学习的锁都是发现锁被别人占用了,就阻塞等待;tryLock 在加锁失败的时候,不会阻塞,而是直接返回,通过返回值来反馈是加锁成功还是失败
4) ReentrantLock 还提供了 公平锁 的实现(默认是非公平的,可以在构造方法中传入参数,设定为公平的)
5) ReentrantLock 还提供了功能更强的 “等待通知机制”,基于 Condition 类,能力比 wait、notify 更强一些
代码示例:
import java.util.concurrent.Semaphore;
import java.util.concurrent.locks.ReentrantLock;
public class Demo37 {
private static int count = 0;
public static void main(String[] args) throws InterruptedException {
ReentrantLock locker = new ReentrantLock(true);
Thread t1 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
locker.lock();
count++;
locker.unlock();
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
locker.lock();
count++;
locker.unlock();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("count = " +count);
}
}
3.3 信息量 Semaphone
信息量,用来表示“可用资源的个数”,本质上就是一个计数器
(acquire)申请资源,让计数器 - 1,也成为“P 操作”;(release)释放资源,让计数器 + 1,也成为“V 操作”,如果计数器为 0,继续申请就会出现阻塞
操作系统本身提供了信号量实现,JVM 将操作系统的信号量封装了以下,我们可以直接用:
代码示例:
import java.util.concurrent.Semaphore;
public class Demo38 {
public static void main(String[] args) throws InterruptedException {
//可用资源的个数,计数器的初始值
Semaphore semaphore = new Semaphore(3);
semaphore.acquire();
System.out.println("申请一个资源!");
semaphore.acquire();
System.out.println("申请一个资源!");
semaphore.acquire();
System.out.println("申请一个资源!"); //若连续申请 4 个资源,到这里就会阻塞等待
semaphore.release();
System.out.println("释放一个资源!"); //释放一个资源之后,才能申请下一个资源
semaphore.acquire();
System.out.println("申请一个资源!");
}
}
使用信息量改进 Demo37:
import java.util.concurrent.Semaphore;
import java.util.concurrent.locks.ReentrantLock;
public class Demo37 {
private static int count = 0;
public static void main(String[] args) throws InterruptedException {
Semaphore semaphore = new Semaphore(1);
Thread t1 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
try {
semaphore.acquire();
count++;
semaphore.release();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
try {
semaphore.acquire();
count++;
semaphore.release();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("count = " +count);
}
}
3.4 CountDownLatch
同时等待 N 个任务执行结束(类似于跑步比赛,10个选手依次就位,哨响同时出发,所有选手都通过中终点才公布成绩)
很多时候,需要把一个大的任务拆成多个小任务,通过多线程/线程池执行,如何衡量所有的任务都执行完毕呢?
比如:多线程下载
浏览器的下载一般是单线程的,下载速度是有限的(2~3MB/秒),但是可以通过多线程的方式提高下载速度,使用专门的下载工具,通过多个线程和服务器建立多个网络连接(服务器进行网速限制是针对一个连接做出的限制),如果创建出 10~20 个线程,下载的总速度就能大幅提高
多个线程,每个线程下载一部分,所有线程下载完毕再进行拼装
代码示例:
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class Demo39 {
public static void main(String[] args) throws InterruptedException {
ExecutorService executorService = Executors.newFixedThreadPool(4);
//构造方法的数字,就是拆分出来的任务个数
CountDownLatch countDownLatch = new CountDownLatch(20);
for (int i = 0; i < 20; i++) {
int id = i;
executorService.submit(() -> {
System.out.println("下载任务 " + id + " 开始执行");
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
System.out.println("下载任务 " + id + " 结束执行");
//执行完毕
countDownLatch.countDown();
});
}
//当 countDownLatch 收到了 20 个“完成”,所有的任务就都完成了
//await => all wait
//await 这个词也是计算机术语,在 python/js 中意思是 async wait(异步等待)
countDownLatch.await();
System.out.println("所有任务都完成!");
}
}
3.5 线程安全的集合类
ArrayList、Queue、HashMap...都是线程不安全的
Vector、Stack、Hashtable,虽然是线程安全的(内置了 synchronized),实际上并不推荐使用
因为 sunchronized 会造成线程阻塞,如果本来就没有线程安全问题,却使用了这些集合类,会造成程序效率大打折扣
3.5.1 解决方案:
1) 自己加锁
2) 如果需要使用 ArrayList / LinkedList 这样的结构,标准库中提供了一个带锁的 List
Collections.synchronizedList(new ArrayList);
相当于构造出一个核心方法自带 synchronized 的 List
还可以使用 CopyOnWrite 集合类
CopyOnWriteArrayList
这个集合类没有加锁,是通过 “写时拷贝” 来实现线程安全的,如下:
这个赋值引用的操作,本身是原子的,即使在这个过程中存在大量的读操作来读取内容,仍然能确保读到的数据是有效数据(读取的数据要么是旧版本数据,要么是新版本数据,不会是一个 “修改了一半” 的数据)
如果修改操作直接基于旧版本来修改,同时还有其他线程去读取,就容易读到 “修改了一半” 的数据(ArrayList 有的修改是原子的,也有一些修改不是原子的,如:插入/删除操作)
如果是针对多个线程同时写的情况,写时拷贝机制就难以应付了,因此当前的写时拷贝机制,主要是用来应对 “多个线程读,一个线程写” 这样的场景
实例场景:广告服务器
在广告服务器运行过程中是涉及到很多 “配置” 的,这些配置都是写到一个 “配置文件” 中,服务器运行的时候,加载配置文件中的配置项,根据配置项来启用/关闭/设置某些功能
如果在服务器运行过程中就想修改配置(地域投放 -> 设为关闭),只该文件是不够的,还需要让服务器重新加载,就需要 “重启”,如果不想重启,就可以通过 写时拷贝 这样的机制来实现
3) 想多线程环境下使用 队列:BlockingQueue
4) 多线程环境下使用 哈希表
Hashtable 虽然是可选项,但是更推荐 ConcurrentHashMap,这个数据结构相比于 HashMap 和 Hashtable 来说,改进力度非常大,改进如下:(高频面试题)
a) 优化了锁的粒度(最核心)
Hashtable 的加锁就是直接给 put、get 等方法加上 synchronized,就是给 this 加锁
整个 哈希表 对象就是一把锁,任何一个针对这个哈希表的操作都会触发锁竞争
而ConcurrentHashMap 则是给每个 hash 表中的 “链表” 进行加锁(不是一把锁,而是多把锁),称为 “锁桶”
上述设定方式是可以保证线程安全的,其次这个设定方式大大降低了锁冲突的概率,只有同时进行的两次修改恰好在修改同一个链表上的元素时,才会触发锁竞争(一个 hash 表上有很多链表)
b) ConcurrentHashMap 引入了 CAS 原子操作
针对像修改 size 这样的操作,直接借助 CAS 完成,并不会加锁
c) 针对读操作,做了特殊处理
上述的加锁只是针对写操作,对于读操作,通过 volatile 以及一些精巧的代码实现,确保读操作不会读到 “修改了一半的数据”
d) 针对 hash 表的扩容进行了特殊的优化
普通 hash 表扩容,需要创建新的 hash 表,把元素都搬运过去,这一系列操作很有可能在依次 put 就完成了,这就会使这次 put 的开销非常大,耗时非常长
ConcurrentHashMap 进行了 “化整为零”,不会在一次操作中进行所有数据的搬运,而是一次只搬运一部分,后续的每次操作都会触发一部分 key 的搬运,最终把所有的 key 都搬运完
当新旧同时存在的时候
1) 插入操作直接插入到新的空间中
2) 查询/修改/删除,都是需要同时查询旧的空间和新的空间
tip:分段锁
分段锁是 ConcurrentHashMap 早期的实现方式,和现在锁桶的思想是一样的,实现上有差别:
将所有的同分段,每一段有一个锁(一个锁管多个链表),缺点:
a) 这里降低锁冲突做的不够彻底
b) 分段锁的实现方式更复杂