多线程之五(JUC+线程安全的集合类+死锁)
目录
1. JUC(java.util.concurrent)的常见类
1.1 callable 接口
1.2 Reentrantlock
1.3 Reentrantlock 和 synchronized 的区别
1.4 原子类(atomic)
1.5 线程池(ExecutorService)
1.6 信号量(semaphore)
1.7 同时等待N个任务执行结束(CountDownLatch)
2. 线程安全的集合类
2.1 多线程环境使用ArrayList
2.2 多线程环境使用队列
2.3 多线程环境使用哈希表(ConcurrentHashMap)
2.4 面试题谈谈HashMap、HashTable、ConcurrentHashMap之间的区别
3. 死锁(*)
1. JUC(java.util.concurrent)的常见类
JUC是一个缩写,JUC是一个包 java.util.concurrent
并发,这个包中放的都是和多线程是相关的
1.1 callable 接口
Callable接口类似于Runnable
Runnable 描述的任务,不带返回值
Callable 描述的任务是带返回值的
如果当前多线程完成的任务,需要带上结果,使用Callable就比较方便
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.FutureTask;
public class demo01 {
//创建线程,通过线程来计算 1 + 2 + 3 + ... + 1000
public static void main(String[] args) throws ExecutionException, InterruptedException {
//使用Callable 定义一个任务
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 的构造方法,不能直接传callable,还需要一个中间的类
Thread t = new Thread(futureTask);
t.start();
//获取线程的计算结果
//get方法产生阻塞,直到call方法计算完成,get方法才会返回
System.out.println(futureTask.get());
}
}
1.2 Reentrantlock
Reentrantlock 可重入锁
Reentrantlock 和 synchronized都是可重入锁
虽然synchronized已经非常强了,但还是有些操作是做不到的
Reentrantlock是对synchronized的一个补充
Reentrantlock核心用法,三个方法
(1)lock() 加锁
(2)unlock() 解锁
(3)tryLock(超时时间)加锁, 如果获取不到锁, 等待一定的时间之后就放弃加锁
public class demo02 {
public static void main(String[] args) {
ReentrantLock locker = new ReentrantLock(true);
try{
//加锁
locker.lock();
} finally {
//解锁
locker.unlock();
}
}
}
1.3 Reentrantlock 和 synchronized 的区别
ReentrantLock的缺点:synchronized是只要代码出来代码块,就一定执行结束,而ReentrantLock相比于synchronized就没有这个优势了
但总体来看,reentrantLock的有些特定功能,synchronized做不到的
优点:
(1)tryLock,能够先试试加锁,试成功了,就加锁成功
试失败了,就等待一定时间后就放弃加锁
这种优点,对于“死等的策略”提供了更多的可能
(2)ReentrantLock可以实现公平锁(默认是非公平的,构造时传入一个参数,就成了公平锁)
ReentrantLock locker = new ReentrantLock(true);
(3)synchronized是搭配wait/notify 实现等待通知机制,唤醒操作时随机唤醒一个等待的进程
ReentrantLock搭配 Condition 类实现的,唤醒操作是可以指定唤醒哪个等待的线程的
还有一个区别是:
synchronized 是java关键字,底层是JVM实现的(通过C++实现的)
ReentrantLock 标准库中的一个类,底层是基于java实现的
总结就是:
(1)用法不同:synchronized 可以用来修饰普通方法、静态方法和代码块,而ReentrantLock只能用于代码块
(2)锁类型不同:synchronized 是非公平锁,而 ReentrantLock 默认为非公平锁,也可以手动指定为公平锁(构造时传入一个参数,就成了公平锁(true))
(3)获取锁和释放锁的机制不同:synchronized是自动加锁和释放锁的(搭配 wait/ notify 实现等待通知机制,唤醒操作时随机唤醒一个等待进程),而 ReentrantLock 需要手动加锁和释放锁(tryLock试试加锁能不能成功,如果失败了,就等待一定时间后就放弃加锁。使用Condition可以将唤醒操作指定唤醒哪个等待的线程的)
(4)响应时间不同:ReentrantLock 可以响应中断,解决死锁问题,而 synchronized 不能响应中断
(5)底层实现不同:synchroized 是JVM 层面通过监视器实现的,而 ReentrantLock 是基于AQS 实现的
1.4 原子类(atomic)
原子类的底层,是基于CAS实现的
java已经封装好了,可以直接来使用
//相当于 count++
count.getAndIncrement();
//相当于 ++count
count.incrementAndGet();
//相当于 count--
count.getAndDecrement();
//相当于 --count
count.decrementAndGet();
import java.util.concurrent.atomic.AtomicInteger;
/**
* Created with IntelliJ IDEA.
* Description:
* User: 28463
* Date: 2022—09—26
* Time: 17:00
*/
public class demo03 {
public static void main(String[] args) throws InterruptedException {
AtomicInteger count = new AtomicInteger(0);
Thread t1 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
//相当于 count++
count.getAndIncrement();
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
//相当于 count++
count.getAndIncrement();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
//get() 获取到内部的值
System.out.println(count.get());
}
}
1.5 线程池(ExecutorService)
这块知识点可以看我前一篇博客,里面有很好的讲解
多线程之三(【多线程案例】单例模式+阻塞式队列+定时器+线程池)
1.6 信号量(semaphore)
信号量的基本操作时两个:
a)P 操作,申请一个资源
c)V 操作,释放一个资源
信号量可用视为是一个更广义的锁
锁就是一个特殊的信号量(可用资源只有1的信号量)
信号量本身是一个计数器,表示可用资源的个数
P 操作申请一个资源,可用资源数就-1
V 操作释放一个资源,可用资源数就+1
当计数为0的时候,继续P操作,就会产生阻塞等待,阻塞等待到其他线程V操作了为止
比如,去饭店吃饭,正常可以同时坐100人
每次有人进来,就是P操作,剩余位置-1
每次有人出去,就是V操作,剩余位置+1
如果当时空余位置是0,你还想进去吃饭,进不去的,只能等待排队/放弃
当需求中,就是有多个可用资源的时候,就是记得使用信号量
Java标准库提供了Semaphore这个类,也就是把 操作系统 提供的信号量封装了一下
//这是 P 操作,申请资源,计数器 -1
semaphore.acquire();
//这是 V 操作,释放资源,计数器 +1
semaphore.release();
import java.util.concurrent.Semaphore;
public class demo04 {
public static void main(String[] args) throws InterruptedException {
//构造时需要指定初始值,计数器的初始值,表示有几个可用资源
Semaphore semaphore = new Semaphore(4);
//这是 P 操作,申请资源,计数器 -1
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();
System.out.println("p 操作");
//这是 V 操作,释放资源,计数器 +1
semaphore.release();
}
}
可以看到,输入了五次p操作,结果就执行了4次,第5次阻塞了
1.7 同时等待N个任务执行结束(CountDownLatch)
同时等待 N 个任务执行结束
比如,赛车比赛中
当比赛开始后,只有当最后一个赛车选手,抵达终点后,才结束
而对比CountDownLatch使用效果就是
使用CountDownLatch时,先设置一下有几个选手
每个选手抵达终点了,就调用一下countDown方法
当抵达终点的次数达到了选手的个数,就可以认为比赛结束了
import java.util.concurrent.CountDownLatch;
/**
* Created with IntelliJ IDEA.
* Description:
* User: 28463
* Date: 2022—09—26
* Time: 18:08
*/
public class CountDownLatch1 {
public static void main(String[] args) throws InterruptedException {
// 有 20 个选手参加了比赛
CountDownLatch countDownLatch = new CountDownLatch(20);
for (int i = 0; i < 20; i++) {
//创建 20个线程来执行一批任务
Thread t = new Thread(() -> {
System.out.println("选手出发" + Thread.currentThread().getName());
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("选手到达" + Thread.currentThread().getName());
// 相当于 ”抵达终点“
countDownLatch.countDown();
});
t.start();
}
// await 是进行阻塞等待,会等到所有的选手都撞线之后,才解除阻塞
countDownLatch.await();
System.out.println("比赛结束");
}
}
等到所有,选手到达后,比赛结束
2. 线程安全的集合类
标准库中大部分的集合类,都是线程不安全的
少数几个安全的
vector、Stack、HashTable,不太推荐用
最好的还是自己来加锁
2.1 多线程环境使用ArrayList
(1)synchronizedList 是标准库提供的一个基于 synchronized 进行线程同步的 List.
synchronizedList 的关键操作上都带有 synchronized
Collections.synchronizedList(new ArrayList);
(2)使用CopyOnWriteArrayList ,这个是不加锁就可以保证线程安全的
使用的情况有限,一写多读,写的频率比较低
CopyOnWrite ,写(修改)时拷贝(复制)
修改时,不直接修改,而是修改这个新拷贝到的数据
这个CopyOnWriteArrayList,操作范围非常有限
如果元素特别多,或者修改特别频繁,就不太适合这种方式了
2.2 多线程环境使用队列
(1)ArrayBlockingQueue
基于数组实现的阻塞队列
(2)LinkedBlockingQueue
基于链表实现的阻塞队列
(3)PriorityBlockingQueue
基于堆实现的带优先级的阻塞队列
(4)TransferQueue
最多只包含一个元素的阻塞队列
2.3 多线程环境使用哈希表(ConcurrentHashMap)
HashMap 本身不是线程安全的.
在多线程环境下使用哈希表可以使用:
(1)Hashtable
(2)ConcurrentHashMap
(1)Hashtable(不推荐,因为这个给各种方法都加synchronized)
(2)ConcurrentHashMap (推荐,因为内部有很多的优化策略)
优化策略:
a)锁粒度的控制
HashTable 直接在方法上加 synchronized ,相当于是对 this 加锁
也就是针对哈希表对象来加锁,一个哈希表只有一个锁
多个线程,无论这些线程,是如何来操作这个哈希表,都会产生锁冲突
ConcurrentHashMap就不是加一把锁了,而是加多把锁,给每个哈希桶都分配一把锁
也就是只有当两个线程访问同一个哈希桶的时候,才有锁冲突
这样就降低了锁冲突的概率,性能也就提高了
b)ConcurrentHashMap 只给写操作加锁,读操作没加锁
如果两个线程同时修改,才会有锁冲突
如果两个线程同时读,就不会有锁冲突
如果一个线程读,一个线程写,也是不会有锁冲突的
(这个操作也是可能会锁冲突的,因为有可能,读的结果是一个修改了一半的数据
不过ConcurrentHashMap在设计时,就考虑到这一点,就能够保证读出来的一定时一个“完整的数据”,要么是旧版本数据,要么是新版本数据,不会是读到改了一半的数据;而且读操作中也使用到了volatile保证读到的数据是最新的)
c)充分利用到了CAS的特性
比如更新元素个数,都是通过CAS来实现的,而不是加锁
d)ConcurrentHashMap 对于扩容操作,进行了特殊优化
HashTable的扩容是这样:当put元素的时候,发现当前的负载因子已经超过阀值了,就触发扩容。
扩容操作时这样:申请一个更大的数组,然后把这之前旧的数据给搬运到新的数组上
但这样的操作会存在这样的问题:如果元素个数特别多,那么搬运的操作就会开销很大
执行一个put操作,正常一个put会瞬间完成O(1)
但是触发扩容的这一下put,可能就会卡很久(正常情况下服务器都没问题,但也有极小概率会发生请求超时(put卡了,导致请求超时),虽然是极小概率,但是在大量数据下,就不是小问题了)
ConcurrentHashMap 在扩容时,就不再是直接一次性完成搬运了
而是搬运一点,具体是这样的
扩容过程中,旧的和新的会同时存在一段时间,每次进行哈希表的操作,都会把旧的内存上的元素搬运一部分到新的空间上,直到最终搬运完成,就释放旧的空间
在这个过程中如果要查询元素,旧的和新的一起查询;如果要插入元素,直接在新的上插入
;如果是要删除元素,那就直接删就可以了
ConcurrentHashMap 就是能不加锁就不加锁
核心优化思路:尽一切方法,降低锁冲突的概率
2.4 面试题谈谈HashMap、HashTable、ConcurrentHashMap之间的区别
回答的思路就是 线程安全 =》锁粒度等多线程下的优化
HashMap key 允许为null
HashTable 和 ConcurrentHashMap key 不能为null
3. 死锁(*)
死锁就是,一个线程加上锁之后,解不开了,一直在等着
多个线程同时被阻塞,它们中的一个或者全部都在等待某个资源被释放。由于线
程被无限期地阻塞,因此程序不可能正常终止。
a)一个线程,一把锁,线程连续加锁两次
如果这个锁时不可重入锁,那肯定会死锁了
synchronized 是可重入锁,这个不影响
b)两个线程,两把锁,都获取到第一把锁后,在释放之前获取另一把锁
比如,家门钥匙锁车里了,而车钥匙锁家里了
public class demo03 {
//这两个线程都是获取到一把锁后,释放之前获取到的另一把锁
//不是拿到锁释放了,再拿第二把锁(这种情况不会死锁)
public static void main(String[] args) {
Object locker1 = new Object();
Object locker2 = new Object();
Thread t1 = new Thread(() -> {
System.out.println("t1 尝试获取 locker1");
synchronized (locker1) {
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("t1 尝试获取 locker2");
synchronized (locker2) {
System.out.println("t1 获取两把锁成功!");
}
}
});
Thread t2 = new Thread(() -> {
System.out.println("t2 尝试获取 locker2");
synchronized (locker2) {
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("t2 尝试获取 locker1");
synchronized (locker1) {
System.out.println("t2 获取两把锁成功!");
}
}
});
t1.start();
t2.start();
}
}
可以看到死锁了
c)多个线程多把锁,更容易死锁
这个死锁常见,就有一个经典的模型,哲学家就餐问题
该问题描述的是五个哲学家共用一张圆桌,分别坐在周围的五张椅子上,在圆桌上有五个碗和五只筷子,他们的生活方式是交替的进行思考和进餐。平时,一个哲学家进行思考,饥饿时便试图取用其左右最靠近他的筷子,只有在他拿到两只筷子(叉子)时才能进餐。进餐完毕,放下筷子继续思考。
死锁的四个必要条件:
(1)互斥使用,锁A被线程1占用,线程2就用不了
(2)不可抢占,锁A别线程1占用,线程2不能把锁A抢过来,除非线程1主动释放
(3)请求和保持,有多把锁,线程1拿到锁A之后,不想释放锁A,还想拿到锁B
(4)循环等待,线程1等待线程2释放锁,线程2要想释放锁得等待线程3释放锁,线程3释放锁得等待线程1释放锁
所以死锁问题只要解决上面,四个条件中一个就可以了
(1)和(2)都是锁的基本特性,我们解决不了
(3)是取决于代码的写法,获取锁B的时候是不是先释放锁A了,这个(3)是有可能打破的,
主要是看需求场景是否允许这样写
而(4)是有把握解决的,只要约定好加锁的顺序,就可以打破循环等待
比如,给锁编号,约定加多个锁时,必须先加编号小的锁,后加编号大的锁
现在修改一下前面的死锁代码,可以看到解决死锁问题了