目录
一、JUC工具类
🍅1、Callable接口
🍅2、ReentrantLock
🍅3、原子类
🍅4、Semaphore信号量
🍅5、CountDownLatch
二、线程安全的集合类
🍅1、多线程环境下,怎么使用线程安全的类?
🍅2、多线程环境下使用队列
🍅3、多线程环境下使用哈希表
三、死锁
🍅1、死锁是什么?
🍅2、发生死锁的原因及解决方案
🍅补充:ThreadLocal
一、JUC工具类
JUC是java.util.concurrent包的简称,在JDK1.5之后对多线程的一种实现,这个包下存放的类都和多线程有关,提供了很多工具类。
🍅1、Callable接口
1、演示用Callable接口进行变量的累加。(1 + 2 + 3 + ... + 5)
public static void main(String[] args) throws ExecutionException, InterruptedException {
//1、定义一个线程的任务,重写Callable接口中的call方法
Callable<Integer> callable = new Callable<Integer>() {
@Override
public Integer call() throws Exception {
//2、对变量进行累加
int sum = 0;
for (int i = 0; i < 5; i++) {
sum = sum + i;
}
//3、返回结果
return sum;
}
};
//4、通过FutureTask类来创建一个对象,这个对象持有callable
FutureTask<Integer> futureTask = new FutureTask<>(callable);
//5、创建线程并指定任务
Thread thread = new Thread(futureTask);
//6、启动线程,让线程执行定义好的任务
thread.start();
//7、获取线程执行的结果
System.out.println("等待结果...");
Integer result = futureTask.get();
//8、打印结果
System.out.println(result);
}
总结上述过程:
(1)创建一个匿名内部类,实现Callable接口。Callable接口带有泛型参数,泛型参数表示返回值类型;
(2)重写Callable接口的call方法,实现累加过程,并返回计算结果;
(3)将callable实例用FutureTask对象包装一下;
(4)创建线程,线程的构造方法传入FutureTask,此时新线程就会执行FutureTask内部的Callable接口中的call方法,完成计算。计算结果就放在了FutureTask对象中;
(5)在主线程中调用futureTask.get()能够阻塞等待新线程计算完毕,并获取到FutureTask中的结果。
❓ 面试题1:Callable接口与Runnable接口的区别?
(1)Callable接口实现的是call方法,Runnable实现的是run方法;
(2)Callable可以返回一个结果,Runnable不可以;
(3)Callable要配合FutureTask一起使用;
(4)Callable可以抛出异常,Runnable不可以。
❓ 面试题2:创建线程有几种方式?
(1)继承Thread类,实现run方法;
(2)实现Runnable接口,实现run方法;
(3)实现Callable接口,实现call方法
(4)通过线程池创建线程。
🍅2、ReentrantLock
ReentrantLock是可重入互斥锁,和 synchronized 定位类似,都是用来实现互斥效果, 保证线程安全。
ReentrantLock 的用法:
(1) lock():加锁,如果获取不到锁就死等.
(2) trylock(long): 尝试加锁。如果获取不到锁, 等待一定的时间之后就放弃加锁。
(3) unlock(): 解锁
代码演示:
❓面试题3:ReentrantLock与synchronized的区别?
ReentrantLock synchronized 使用的时候 需要手动释放锁。使用更灵活,但经常容易忘掉unlock。 不需要手动释放锁。 在申请锁失败的时候 一直等待锁资源。死等。 通过trylock的方式等待一段时间就放弃 是否为公平锁 默认是非公平锁。可以通过构造方法传入True开启公平锁模式。 是非公平锁。 实现方式不同 是标准库的一个类,基于Java JUC实现。 是一个关键字,JVM内部实现。 是否是可重入锁 是可重入锁 是可重入锁 唤醒机制 搭配Condition类实现唤醒等待,可以更精确的控制唤醒某个指定的线程。 搭配Object类的wait/notify实现唤醒等待,每次唤醒的都是一个随机等待的线程。
问题:如何选择使用哪个锁?
(1)锁竞争不激烈的时候,使用synchronized,效率更高,自动释放更加方便;
(2)锁竞争激烈的时候,使用ReentrantLock,搭配trylock更加灵活的控制加锁的行为,而不是死等;
(3)如果需要使用公平锁,使用RenntrantLock。
🍅3、原子类
原子类内部使用的是CAS实现,所以性能比加锁来实现i++性能要高很多。原子类主要有以下几种:
🍅4、Semaphore信号量
信号量用来表示“可用资源的个数”,本质上就是一个计数器。
🌰理解信号量
—停车场:当前车位有100个,表示有100个可用资源。
(1)当停车场进入一辆车,车位的个数就-1;(信号量的V操作,表示释放资源,资源数+1)
(2)当停车场开出一辆车,车位的个数就+1;(信号量的P操作,表示申请资源,资源数-1)
(3)停车场中的所有车位就是可以显示的最大有效值。
当申请资源的时候,如果资源已经被用完没有资源了(当前停车场的车位为0 ),那么当前申请资源的线程必须要阻塞等待,直到其他线程释放资源。
信号量的使用演示:
(1)定义:private static Semaphore semaphore = new Semaphore(容量);
(2) acquire表示申请资源
(3)release表示释放资源(一般搭配使用)。
应用场景:
如果以后的业务中需要指定有限的资源个数的时候,可以考虑使用Semaphore来进行处理。
//1、定义一个信号变量,表示可用资源的个数
private static Semaphore semaphore = new Semaphore(3);
public static void main(String[] args){
//2、定义任务:用来申请资源和释放资源
//创建 20 个线程, 每个线程都尝试申请资源, sleep 1秒之后, 释放资源. 观察程序的执行效果.
Runnable runnable = new Runnable() {
@Override
public void run() {
try {
//3、任务是:让每个线程都尝试申请资源
System.out.println(Thread.currentThread().getName()+"[.]申请资源");
//4、调用acquire,让可用资源数-1
semaphore.acquire();
System.out.println(Thread.currentThread().getName()+"[+]申请到了资源");
//5、休眠1s,释放资源
semaphore.release();
System.out.println(Thread.currentThread().getName()+"[-]释放资源");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
};
//创建多个线程并启动,执行任务
for (int i = 0; i < 20; i++) {
Thread thread = new Thread(runnable);
thread.start();
}
}
🍅5、CountDownLatch
CountDownLatch的作用是:可以设置所有的线程都必须到达某一个关键点之后再执行后续的操作。
CountDownLatch的使用:
(1)定义:CountDownLatch countDownLatch = new CountDownLatch(指定容量);
(2)指定容量计数-1:countDownLatch.countDown();
(3)一直等待countDownLatch维护的值为0,才会继续运行后面的代码:countDownLatch.await();
应用场景:
当把一个大任务分成若干个小任务,或者是等待一些前置资源的时候,就可以考虑使用CountDownLatch。
补充: CyclicBarrier是CountDownLatch的进阶版,表示循环栅栏,可以实现进程之间的相互等待,计数重置。
模拟实现运动员比赛过程,10个运动员必须都到达终点后才会执行后面的颁奖仪式~
//1、定义一个CountDownLatch
private static CountDownLatch countDownLatch = new CountDownLatch(10);
public static void main(String[] args) throws InterruptedException {
System.out.println("所有选手已经就位...");
//2、创建线程模拟跑步比赛
for (int i = 0; i < 10; i++) {
Thread thread = new Thread(()->{
//3、10个线程开始出发
System.out.println(Thread.currentThread().getName()+"出发");
try{
//4、等待5s开始陆续到达终点
TimeUnit.SECONDS.sleep(5);
} catch (InterruptedException e) {
e.printStackTrace();
}
//5、每到达一个线程,计数-1
System.out.println(Thread.currentThread().getName()+"到达终点");
countDownLatch.countDown();
},"player"+(i+1));
//6、启动线程
thread.start();
}
//7、等待所有的线程都到达终点后,开始执行后面的任务
countDownLatch.await();
//后面的任务就是颁奖啦~
System.out.println("颁奖中...");
}
二、线程安全的集合类
原来的集合类,大多都不是线程安全的。
演示:
🍅1、多线程环境下,怎么使用线程安全的类?
问题:在多线程环境下,怎么使用线程安全的集合类?
(1)Vector,Stack,HashTable是线程安全的,是JDK中提供的线程安全的类,但是强烈不推荐使用。
(2)自己使用同步机制(synchronized或者ReentrantLock)。和(1)的效果差不多,也不推荐。
(3)使用工具类转换Collections.synchronizedList(new ArrayList),也不推荐。
实现方式是在普通集合类对象外面又包裹了一层synchronized完成的线程安全。
(4)使用CopyOnWriteArrayList。(多线程环境下使用集合类优先考虑使用)
它是JUC下面的一个类,使用的是“写时复制技术”来实现的。
写时复制技术指的是:
当要修改一个集合时,先复制这个集合的副本;
修改副本的数据,修改完成之后,用副本来覆盖原始集合。
优点:在读多写少的场景下,性能很高,不需要加锁竞争;
缺点:(1)占用的内存较多:因为复制了一份新的数据进行修改;
(2)新写的数据不能第一时间被读取到。
🍅2、多线程环境下使用队列
🍅3、多线程环境下使用哈希表
❓问题2:HashMap,Hashtable与ConcurrentHashMap的区别?
1、HashMap在正常的单线程情况下使用HashMap是没有问题的。但是由于没有加锁处理,在多线程环境下是线程不安全的。(不推荐)key允许为null。
2、Hashtable是线程安全的。(不推荐)key不允许为null。
实现方式:通过synchronized来加锁实现线程安全。给this加锁,也就是自己加锁。不过在进行读写的时候由于都加锁了,所以效率就很低。
(1)当多线程访问同一个Hashtable时,就会造成锁冲突;
(2)size属性也是通过synchronized来同步控制,也比较慢;
(3)一旦触发扩容,就由该线程完成整个扩容机制,这个扩容过程中会涉及到大量的元素拷贝,效率会非常低。
(3)ConcurrentHashMap是线程安全的(推荐)key不允许为null。
多线程环境下强烈推荐使用这种方式来保证线程安全,它与HashTable不同,不是通过synchronized关键字来实现加锁的,而是通过JUC包下的ReentrantLock来实现加锁。
也就是使用CSA,用户态来实现加锁。
ConcurrentHashMap对Hashtable做出的优化:
(1)更小的锁力度
Hashtable加锁的方式:是对所有的操作全部加锁,必然会影响到性能。
ConcurrentHashMap,是对每一个哈希桶来进行加锁,提高并发能力。也就是说,每次只锁定一个桶位,就意味着,Hash桶的数组长度有多少个,就可以支持多少个并发。
(2)只给写加锁,不给读加锁。加锁的方式使用的是ReentrantLock,大量运用的是CAS操作。并且共享变量使用的volatile修饰。
(3)充分利用CAS操作。比如size属性通过CAS来更新,避免出现重量级锁的情况。
(4)对扩容机制进行了优化。
对于需要扩容的操作,新建一个Hash桶,随后的每次操作都搬运一些元素去新的Hash桶。在扩容还没有完成的时候,两个Hash桶同时存在;
每次写入时只写入新的Hash桶;
每次读取时需要新旧的Hash桶同时读取;
等所有的元素都搬运完成之后,将旧的Hash桶删除。(是一个典型的空间换时间的用例)
注意:ConcurrentHashMap在jdk1.8中做出的优化:
取消了分段锁,直接给每个哈希桶(每个链表)分配了一个锁,将原来的数组+链表的实现方式改进为数组+链表/红黑树的方式。当链表较长的时候(大于等于8个元素的时候)就会转化为红黑树。
三、死锁
🍅1、死锁是什么?
死锁就是一个线程加上锁之后不运行也不释放锁,导致程序无法继续运行,是一个非常严重的BUG。
🌰举例
1、一个线程一把锁:一个线程对一把锁加锁两次:如果是可重入锁就不会产生死锁;如果是不可重入锁,就会产生死锁。(但其实如果该锁是不可重入锁,那么也没有办法加两次锁,也就谈不上死锁)。
2、两个线程两把锁:比如车钥匙锁在家里了,家里的钥匙锁车里了。就是一个死锁。
🍅2、发生死锁的原因及解决方案
❓问题3:发生死锁的原因?
(1)互斥使用:锁A被线程1占用了,线程2就不能用了;
(2)不可抢占:锁A被线程1占用了,线程2不能主动将锁A抢过来,除非线程1主动释放;
(3)请求保持:有多把锁,线程1拿到了锁A之后,不释放而且还要继续拿锁B;
(4)循环等待:线程1等待线程2释放锁,线程2要释放锁要先等待线程3释放锁,线程3释放锁要先等待线程1释放锁...形成了循环关系。
❓问题4:怎么避免死锁?
以上四条是形成死锁的必要条件,打破以上4条任意一个就行,逐一分析:
(1)互斥使用:这个不能打破,这个是锁的基本性质;
(2)不可抢占:这个不能打破,这个是锁的基本性质;
(3)请求保持:这个有可能打破,取决于代码的写法;
(4)循环等待:约定好加锁顺序就可以将循环打破。
🌰小丑吃瓜问题
如果所有人都先拿左手的筷子再拿右手的筷子,就会死锁。
现在,重新安排拿筷子的顺序:
(1)要求每个人先去拿编号小的筷子,拿到之后再拿编号大的筷子;
(2)此时125号都会拿到一个筷子,3号和4号会抢占筷子1号,而筷子1号只有一个人能够拿到,拿不到的那个人就要等待;
(3)如果4号小丑拿到了筷子1,那左边的5号筷子没人拿,4号小丑就把5号筷子也拿上;
(4)等4号吃完之后,放下筷子,3号再拿筷子1号吃瓜。
注意:在操作系统课程中针对死锁给出的解决方案是“银行家算法”,将所有的资源进行统筹分配,也可以避免死锁。
补充:ThreadLocal
1、ThreadLocal类用来提供线程内部的局部变量,不同的线程之间不会相互干扰。
2、这种变量在多线程环境下访问(通过get和set方法访问)时能保证各个线程的变量相对独立于其他线程内的变量。
演示:
//1、初始化一个ThreadLocal private static ThreadLocal<Integer> threadLocal = new ThreadLocal<>(); public static void main(String[] args) { Thread thread1 = new Thread(()->{ //2、统计人数-一班 int count = 35; threadLocal.set(count); print(); }); Thread thread2 = new Thread(()->{ //统计人数-二班 int count = 40; threadLocal.set(count); print(); }); thread1.start(); thread2.start(); } //3、定制校服 private static void print() { Integer value = threadLocal.get(); System.out.println(Thread.currentThread().getName()+"需要定制"+value+"套校服"); }
继续加油~