多线程进阶
1. 常见的锁策略
1.0 锁的核心特性
1.1 乐观锁和悲观锁
- 乐观锁:在考虑冲突概率的时候,认为一般不会发生冲突。如果真的发生了冲突,再去解决冲突。
- 悲观锁:在考虑冲突概率的时候,认为发生冲突的概率很大,所以会提前加锁。
乐观锁发生冲突了怎么办?
我们可以引入一个版本号来处理。假设我们需要多线程修改 “用户账户余额”,设当前余额为 100.,引入一个版本号 version, 初始值为 1.,并且我们规定 “提交版本必须大于记录当前版本才能执行更新余额”
(具体图示见课件)
1.2 读写锁
读写操作的线程安全分析:
当多个线程尝试读一个变量时,是不会有线程安全问题的; 但是当多个线程尝试修改一个变量时,或者一个线程写一个线程读时,就会有线程安全问题。
在有些场景中,写操作本来就少,主要以读操作为主。为了提高并发效率,我们就不能一味的加锁,应该根据不同的场景来给读和写分别加锁。
Synchronized锁是没有对读和写进行区分的,只要使用就一定互斥了。
Java中专门提供了一个类:
场景举例:
由上可知:在写少读多时,使用读写锁可以极大的减少冲突,冲突减少即阻塞等待的线程就减少了,这就极大的提高了程序执行的效率。
1.3 重量级锁和轻量级锁
区分重量级锁和轻量级锁的主要依据就是:看加锁解锁开销大不大。
加锁解锁开销大就是代表频繁的开锁加锁。
重量级锁:加锁解锁的开销很大,往往需要内核态来完成。
轻量级锁:加锁解锁的开销不大,只需要在用户态就能完成
1.4 自旋锁
应用场景:
当一个线程在竞争一个锁时,没有竞争成功,但是过不了多久,这个锁就会被释放,所以这个线程没必要进入阻塞状态并且放弃这个CPU。
自旋锁的伪代码:
while (抢锁(lock) == 失败) {}
自旋锁 vs 挂起等待锁:
想象一下, 去追求一个女神. 当男生向女神表白后, 女神说: 你是个好人, 但是我有男朋友了~~
挂起等待锁: 陷入沉沦不能自拔… 过了很久很久之后, 突然女神发来消息, “咱俩要不试试?” (注意, 这个很长的时间间隔里, 女神可能已经换了好几个男票了).
自旋锁: 死皮赖脸坚韧不拔. 仍然每天持续的和女神说早安晚安. 一旦女神和上一任分手, 那么就能立刻抓住机会上位.
自旋锁是一种轻量级锁的实现方式:
- 优点:没有放弃CPU进行阻塞等待,而是不断地进行尝试获取,一旦锁被释放就能第一时间获取到锁。
- 缺点:如果锁被其他其他线程持有地时间比较久,那么就会持续地消耗CPU资源(而挂起等待的时候是不消耗CPU的)
【重点】synchronized锁中的轻量级锁,大概率就是通过自旋锁的方式进行实现的。
1.5 公平锁和非公平锁
注意:一般情况下的锁都是非公平锁,要想实现公平锁,就要加上一定的数据结构进行限制来达到约定的公平。
1.6 可重入锁和不可重入锁
2. CAS
2.0 什么是CAS
- CAS 全称为 compare and swap,即“比较并交换”的意思,它通过一次CPU的占用就可以同时完成 “读取内存, 比较是否相等, 修改内存” 这三个步骤,达到“比较并交换”效果。
- 它的底层是一个原子的硬件指令。
2.1 CAS的应用
在Java中,CAS应用很多。
- 实现原子类
典型的就是 AtomicInteger 类. 其中的 getAndIncrement 相当于 i++ 操作。
AtomicInteger atomicInteger = new AtomicInteger(0);
// 相当于 i++
atomicInteger.getAndIncrement();
伪代码:
class AtomicInteger {
private int value;
public int getAndIncrement() {
int oldValue = value;
while ( CAS(value, oldValue, oldValue+1) != true) {
oldValue = value;
}
return oldValue;
}
}
原理分析图:
- 实现自旋锁
基于 CAS 实现更灵活的锁, 获取到更多的控制权.
伪代码:
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;
}
}
2.2 CAS 中的ABA问题
- 什么是ABA问题?
- 如何解决ABA问题
ABA问题是一种小概率事件,但是我们也应该重视它并解决它,解决方法很简单,和乐观锁一样,就是加上版本号
3. Synchronized原理
3.0 重新认识Synchronized
Synchronized是一种自适应锁,经过上面对各种锁的认识,我们对Synchronized也有了更近一步的认识:
- Synchronized在初始状态为乐观锁。随着冲突的增多,就变为悲观锁
- Synchronized不是读写锁
- Synchronized初始状态为轻量级锁,如果锁被某些线程持有的时间过长/锁的冲突概率比较高时,就会变成重量级锁。
- Synchronized是非公平锁
- Synchronized是可重入锁
- Synchronized为轻量级锁时,大概率时一个自旋锁;为重量级锁的时候大概率为一个挂起等待锁。
3.1 Synchronized——锁的升级
3.2 Synchronized——锁的优化
4. Callable接口
4.1 Callable的作用
Callable是一个接口,它相当于把线程封装了一个返回值。
4.2 Callable和Runnable的区别
Runnable:只是描述一个过程,不关注结果,没有返回值。
Callable:也是描述一个过程,但是有返回值。
Callable中包含一个call方法,相当于Runnable中的run方法,都是用来描述一个具体的任务,不同的是:call方法是带有返回值的
4.3 不应Callable接口来求和
思路:
- 对于主线程来说,其必须得等t线程执行完之后在进行打印,才符合要求得思路
- 在主线程中进行等待(wait),等到 t 执行完循环之后,再通知(notify)主线程
public class ThreadDemo28 {
static class Result{//设置静态内部类,就可以在实例化时,不需要创建外部类
public int sum=0;
public Object locker = new Object();
}
public static void main(String[] args) throws InterruptedException {
Result result = new Result();
Thread t = new Thread(){
@Override
public void run() {
for(int i=1;i<=1000;i++){//这里存在优化,sum的值不能及时同步到内存当中
result.sum+=i;
}
synchronized (result.locker){
result.locker.notify();
}
}
};
t.start();
synchronized (result.locker){
while(result.sum==0){
//当在t线程中的求和运算结果没有出来时,内存中sum的值一直就是0,直到运算结束才会同步到内存
result.locker.wait();
}
}
System.out.println(result.sum);
}
}
4.4 用Callable进行求和
public class ThreadDemo29 {
public static void main(String[] args) throws ExecutionException, InterruptedException {
//创建一个匿名内部类, 实现 Callable 接口
Callable<Integer> callable = new Callable<Integer>() {
@Override
//重写 Callable 的 call 方法, 完成累加的过程
public Integer call() throws Exception {
int sum=0;
for(int i=0;i<=1000;i++){
sum+=i;
}
return sum;
}
};
//把 callable 实例使用 FutureTask 包装一下
FutureTask<Integer> futureTask = new FutureTask<>(callable);
//创建线程, 线程的构造方法传入 FutureTask . 此时新线程就会执行 FutureTask 内部的 Callable 的
//call 方法, 完成计算. 计算结果就放到了 FutureTask 对象中
Thread t = new Thread(futureTask);
t.start();
//调用 futureTask.get() 能够阻塞等待新线程计算完毕. 并获取到 FutureTask 中的结果.
int result = futureTask.get();
System.out.println(result);
}
}
在这里为什么要构造一个FutureTask实例?
对于Thread类来说,其构造方法为要求放入的是一个Runnable,所以不能直接把Callable作为参数传到Thread中,所以就需要将Callable转换成符合要求的参数。
因此可以通过FutureTask的构造方法,传入Callable接口的实例,构造FutureTask对象,由于FutureTask间接实现了Runnable接口,将FutureTask作为Thread的参数即可满足需要。
这里的接口为什么进行了new?
Callable<Integer> callable = new Callable<Integer>() {
@Override
//重写 Callable 的 call 方法, 完成累加的过程
public Integer call() throws Exception {
int sum=0;
for(int i=0;i<=1000;i++){
sum+=i;
}
return sum;
}
};
这里的 new 看似是对接口进行了实例化,其实并不是这样,接口并不能进行实例化,这只是匿名内部类的一种实现方式,所表达的意思就是一个匿名的类实现了Callable这个接口。
5. JUC(java.util.concurrent) 的常见类
5.1 ReentrantLock
可重入互斥锁,其定位于与synchronized类似,都是用来达到互斥效果,保证现成安全。
ReentrantLock 也是可重入锁. “Reentrant” 这个单词的原意就是 “可重入”
public class ThreadDemo30 {
public static void main(String[] args) {
ReentrantLock reentrantLock = new ReentrantLock();
reentrantLock.lock();//加锁,如果获取不到锁就死等
reentrantLock.tryLock();//加锁,如果湖区不到锁,等待一段时间之后,就放弃加锁
reentrantLock.unlock();//解锁
}
}
【面试】ReentrantLock和Synchronized的区别
5.2 原子类
5.3 信号量(Semaphore)
信号量就是用来表示可用资源个数的一个计数器
理解信号量
可以把信号量想象成是停车场的展示牌: 当前有车位 100 个. 表示有 100 个可用资源.
当有车开进去的时候, 就相当于申请一个可用资源, 可用车位就 -1 (这个称为信号量的 P 操作)
当有车开出来的时候, 就相当于释放一个可用资源, 可用车位就 +1 (这个称为信号量的 V 操作)
如果计数器的值已经为 0 了, 还尝试申请资源, 就会阻塞等待, 直到有其他线程释放资源.
public static void main(String[] args) {
Semaphore semaphore = new Semaphore(4);//表示有四个可用资源
Runnable runnable = new Runnable() {
@Override
public void run() {
try {
System.out.println("申请资源");
semaphore.acquire();
System.out.println("申请资源成功");
Thread.sleep(3000);
System.out.println("释放资源");
semaphore.release();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
};
for(int i=0;i<10;i++){
Thread t = new Thread(runnable);
t.start();
}
}
运行结果:
5.4 CountDownLatch
同时等待 N 个任务执行结束.
好像跑步比赛,10个选手依次就位,哨声响才同时出发;所有选手都通过终点,才能公布成绩。
构造 CountDownLatch 实例, 初始化 10 表示有 10 个任务需要完成.
每个任务执行完毕, 都调用 latch.countDown() . 在 CountDownLatch 内部的计数器同时自减.
主线程中使用 latch.await(); 阻塞等待所有任务执行完毕. 相当于计数器为 0 了.
public static void main(String[] args) throws InterruptedException {
//表示有十个任务需要完成
CountDownLatch latch =new CountDownLatch(10);
Runnable runnable = new Runnable() {
@Override
public void run() {
try {
long time=(long) (Math.random() * 10000);
Thread.sleep(time);
System.out.println("比赛用时:"+time);
latch.countDown();//完成任务,任务数就减1
} catch (Exception e) {
e.printStackTrace();
}
}
};
for(int i=0;i<10;i++){
Thread t = new Thread(runnable);
t.start();
}
latch.await();
System.out.println("比赛结束");
}
运行结果:
【面试】有了 synchronized 还需要 juc 下的 lock?
以 juc 的 ReentrantLock 为例,
- synchronized 使用时不需要手动释放锁. ReentrantLock 使用时需要手动释放. 使用起来更灵活,
- synchronized 在申请锁失败时, 会死等. ReentrantLock 可以通过 trylock 的方式等待一段时间就放弃.
- synchronized 是非公平锁, ReentrantLock 默认是非公平锁. 可以通过构造方法传入一个true 开启公平锁模式.
- synchronized 是通过 Object 的 wait / notify 实现等待-唤醒. 每次唤醒的是一个随机等待的线程. ReentrantLock 搭配 Condition 类实现等待-唤醒, 可以更精确控制唤醒某个指定的线程.
【面试】信号量听说过么?之前都用在过哪些场景下?
信号量, 用来表示 “可用资源的个数”. 本质上就是一个计数器.
使用信号量可以实现 “共享锁”, 比如某个资源允许 3 个线程同时使用, 那么就可以使用 P 操作作为加锁, V 操作作为解锁, 前三个线程的 P 操作都能顺利返回, 后续线程再进行 P 操作就会阻塞等待, 直到前面的线程执行了 V 操作.
6. 多线程安全的集合类
原来的集合类, 大部分都不是线程安全的.
Vector, Stack, HashTable, 是线程安全的(不建议用), 其他的集合类不是线程安全的.
6.1 多线程环境使用 ArrayList
6.2 多线程环境使用队列
- ArrayBlockingQueue
基于数组实现的阻塞队列 - LinkedBlockingQueue
基于链表实现的阻塞队列 - PriorityBlockingQueue
基于堆实现的带优先级的阻塞队列 - TransferQueue
最多只包含一个元素的阻塞队列
6.3 多线程环境使用哈希表
HashMap 本身不是线程安全的.
在多线程环境下使用哈希表可以使用:
- Hashtable
- ConcurrentHashMap