目录
🌳二、JUC(java.util.concurrent) 的常见类
哥几个来学多线程啦~~
🌲一、Callable 接口
🍋1. Callable 的用法
Callable和Runnable类似,它也是一个接口,与Runnable不同的是,它将线程封装了一个“返回值”,方便程序员使用线程计算结果。Callable 描述的是带有返回值的任务,Runnable 描述的是不带返回值的任务。
代码示栗🌰:
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.FutureTask;
public class Demo22 {
public static void main(String[] args) throws ExecutionException, InterruptedException {
//使用匿名内部类
Callable<Integer> callable = new Callable<Integer>() {//1
@Override//重写call方法
public Integer call() throws Exception {//2
int ret = 0;
for (int i = 0;i <= 1000; i++) {
ret += i;
}
return ret;
}
};
//FutureTask类是用来接收callable的结果的
FutureTask<Integer> futureTask = new FutureTask<>(callable);//3
//创建一个线程,传入futureTask对象
Thread thread = new Thread(futureTask);
//要启动了才能有结果
thread.start();
//结果是存在futureTask中的,要用get()方法获取
System.out.println(futureTask.get());
}
}
🍕1.由于Callable可以将线程封装一个“返回值”,那么我们就需要知道它的返回值是什么类型,因此实现的匿名内部类Callable需要是一个泛型类,尖括号<>里需要填入返回值类型
🍔2.Callable接口里的call()方法需要返回一个值,那么它的方法名前也要有返回类型
🍟3.FutureTask类是用来接收callable的返回结果的,因此它也需要在尖括号<>内填入返回值类型
Callable 通常需要搭配 FutureTask 来使用。 FutureTask 用来保存 Callable 的返回结果。因为 Callable 往往是在另一个线程中执行的,啥时候执行完并不确定。FutureTask 就可以负责这个等待结果出来的工作~~
🍈2.相关面试题
介绍下 Callable 是什么?
参考上文🥰
🌳二、JUC(java.util.concurrent) 的常见类
🍱1. ReentrantLock
ReentrantLock是可重入互斥锁,与synchronized类似,都是用来实现互斥效果,保证线程安全的。
ReentrantLock的用法:
- lock():加锁,获取不到锁就死等
- trylock(超时时间):加锁,如果在一定时间内获取不到锁就放弃获取锁
- unlock():释放锁
ReentrantLock reentrantLock = new ReentrantLock();
try {
reentrantLock.lock();
} finally {
reentrantLock.unlock();
}
ReentrantLock和synchronized的区别:
🍕1.synchronized是一个关键字,是JVM内部实现的(大概率是基于C++实现的)。ReentrantLock 是标准库的一个类,在JVM外实现的(基于Java实现)。
🍔2.synchronized 使用的时候不需要手动释放锁。ReentrantLock 使用时需要手动释放,使用起来更加灵活,但是也更容易遗漏 unlock。
🍟3.synchronized 在申请失败时,会死等。 ReentrantLock可以通过 trylock的方式等待一段时间就放弃。
🌭4.synchronized 是非公平锁。ReentrantLock 默认是非公平锁,可以通过构造函数传入一个true开启公平锁模式。
ReentrantLock reentrantLock = new ReentrantLock(true);
构造函数:
🍿 5.更加强大的唤醒机制。synchronized 是通过 Object 的wait / notify 实现等待-唤醒,每次唤醒的是一个随机等待的线程。ReentrantLock 搭配 Condition 类实现等待-唤醒,可以更精准控制某个指定的线程。
具体示例大家可以康康大佬的这篇文章:(10条消息) Java :ReentrantLock类和Condition类_AlgebraFly的博客-CSDN博客
如何选择锁?
- 锁竞争不激烈的时候,使用 synchronized,效率更高,自动释放更方便。
- 锁竞争激烈的时候,使用 ReentrantLock,搭配 trylock 更加灵活控制加锁行为,而不死等。
- 如果需要使用公平锁,使用 ReentrantLock。
🥡2.原子类
原子类内部是使用CAS实现的,所以性能要比加锁好很多,原子类有以下几个:
- AtomicBoolean
- AtomicInteger
- AtomicIntegerArray
- AtomicLong
- AtomicReference
- AtomicStampedReference
以 AtomicInteger 举例,常用方法有:
AtomicInteger atomicInteger = new AtomicInteger(0);
atomicInteger.incrementAndGet();//++atomicInteger
atomicInteger.getAndIncrement();//atomicInteger++
atomicInteger.decrementAndGet();//--atomicInteger
atomicInteger.getAndDecrement();//atomicInteger--
atomicInteger.addAndGet(3);//atomicInteger += 3
🌴三、线程池
在多线程初阶我们学习到,线程池可以用来解决线程频繁地创建销毁而导致资源开销大的问题。
🏉1.ThreadPoolExecutor
ThreadPoolExecutor提供了更多参数,可以进一步细化线程池的设定。
ThreadPoolExecutor的构造函数:
理解 ThreadPoolExecutor 构造函数里的参数:
我们可以把线程池理解为一个公司,而线程就是一个个员工
- corePoolSize:正式员工数量。(一旦录用,永不辞退)
- maximunPoolSize:正式员工 + 临时员工的数量。(临时员工:公司在一段时间里会比较空闲,那么临时员工就会被辞退)
- keepAliveTime:临时工允许的空闲时间。
- unit:keepAliveTime的时间单位是秒、分钟,还是其他值。
- workQueue:传递任务的阻塞队列
- threadFactory:创建线程的工厂,参与具体的创建线程工作。
- RejectedExecutionHandler:拒绝策略,如果任务超出公司的负荷了接下来怎么处理。
- AbortPolicy():超过负荷,直接抛出异常
- CallerRunsPolicy():调用者负责处理
- DiscardOldestPolicy():丢弃队列中最老的任务
- DiscardPolicy():丢弃新来的任务
使用示栗🌰:
import java.util.concurrent.*;
public class Demo24 {
public static void main(String[] args) {
ExecutorService pool = new ThreadPoolExecutor(1, 2, 1000, TimeUnit.MILLISECONDS,
new SynchronousQueue<Runnable>(), Executors.defaultThreadFactory(),
new ThreadPoolExecutor.AbortPolicy());
for (int i = 0; i < 2; i++) {
pool.submit(new Runnable() {
@Override
public void run() {
System.out.println("hello");
}
});
}
}
}
执行结果:
但是如果将循环体内的 i < 2 改为 i < 3,那么就会报错:
原因就是 maximunPoolSize(正式员工的数量 + 临时员工的数量)为2,但是循环体执行了3次,也就是添加了3次线程,那么会出现错误。
线程池工作流程:
🏀2.信号量 Semaphore
信号量,用来表示“可用资源的个数”,实质上是一个计数器。
理解信号量:
可以把信号量想象成停车场的展示牌:假设当前有100个车位,就表示有100个可用资源。
如果有车开进停车场,那么展示牌的数字就 - 1,也就是可用资源 - 1。(称为信号量的P操作)
如果有车开出停车场,那么展示牌的数字就 + 1,也就是可用资源 + 1。(称为信号量的V操作)
如果展示牌为0了,也就是可用资源为0了,那么如果再申请资源,就会报错。
- Semaphore 的 PV操作中的加减计数器操作都是原子的,在多线程状态下可以直接使用。
import java.util.concurrent.Semaphore;
public class Demo25 {
public static void main(String[] args) {
Semaphore semaphore = new Semaphore(4);//创建一个有四个资源的信号量
Runnable runnable = new Runnable() {
@Override
public void run() {
try {
System.out.println(Thread.currentThread().getName() + "申请资源");
semaphore.acquire();
System.out.println(Thread.currentThread().getName() + "获取到资源啦~~先睡1000毫秒");
Thread.sleep(1000);
System.out.println("释放资源");
semaphore.release();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
};
for (int i = 0; i < 10; i++) {
Thread thread = new Thread(runnable, "Thread - " + i);
thread.start();
}
}
}
我们发现每个线程都获取到了资源,并且都释放过资源。
那么如果我们将释放资源这两行代码注释掉,那么会发生什么事呢?
结果我们发现,只有四个线程获取到了资源,而且是先申请资源的那四个,原因就是我们的资源只设置了四个,如果四个申请到资源的线程都没有释放资源,那么其他线程就不会获得资源,就会阻塞等待~~
🥎3.CountDownLatch
CountDownLatch 就是等待多个任务全部执行结束。
就像我们跑步比赛,要等到最后一名冲过终点,才公布成绩。
import java.util.Random;
import java.util.concurrent.CountDownLatch;
public class Demo26 {
public static void main(String[] args) throws InterruptedException {
CountDownLatch countDownLatch = new CountDownLatch(10);
System.out.println("10人进行跑步比赛");
Random random = new Random();
Runnable runnable = new Runnable() {
@Override
public void run() {
try {
int time = 1000 * random.nextInt(10);
Thread.sleep(time);
System.out.println(Thread.currentThread().getName() + "跑了" + time + "毫秒");
countDownLatch.countDown();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
};
for (int i = 0; i < 10; i++) {
Thread thread = new Thread(runnable);
thread.start();
}
countDownLatch.await();
System.out.println("比赛结束");
}
}
我们在主线程使用方法来记录
被调用了多少次,当这10次都执行完后,主线程就停止阻塞,开始执行后续代码。
⚾4.相关面试题
1) 线程同步的方式有哪些?
synchronized、ReentrantLock、Semaphore 等都可以用于线程同步。
2) 为什么有了 synchronized 还需要 juc 下的 lock?
以 juc 的 ReentrantLock 为例:
- synchronized 使用时不需要手动释放锁。ReentrantLock 使用时需要手动释放,使用起来更灵活。
- synchronized 在申请锁失败时,会死等. ReentrantLock 可以通过 trylock 的方式等待一段时间就放弃。
- synchronized 是非公平锁, ReentrantLock 默认是非公平锁。可以通过构造方法传入一个 true 开启公平锁模式。
- synchronized 是通过 Object 的 wait / notify 实现等待-唤醒。每次唤醒的是一个随机等待的线程。 ReentrantLock 搭配 Condition 类实现等待-唤醒,可以更精确控制唤醒某个指定的线程。
3) AtomicInteger 的实现原理是什么?
基于CAS实现,伪代码如下:
class AtomicInteger {
private int value;
public int getAndIncrement() {
int oldValue = value;
while ( CAS(value, oldValue, oldValue+1) != true) {
oldValue = value;
}
return oldValue;
}
}
4) 信号量听说过么?之前都用在过哪些场景下?
信号量,用来表示 "可用资源的个数"。本质上就是一个计数器。
使用信号量可以实现 "共享锁",比如某个资源允许 3 个线程同时使用,那么就可以使用 P 操作作为 加锁, V 操作作为解锁,前三个线程的 P 操作都能顺利返回,后续线程再进行 P 操作就会阻塞等待,直到前面的线程执行了 V 操作。
5) 解释一下 ThreadPoolExecutor 构造方法的参数的含义
参考上文🥰
🌵四、线程安全的集合类
🌹1.ArrayList
🍕1) 自己使用同步机制 (synchronized 或者 ReentrantLock)
🍔2)Collections.synchronizedList(new ArrayList);
synchronizedList 是标准库提供的一个基于 synchronized 进行线程同步的 List。
synchronizedList 的关键操作上都带有 synchronized
🍟3) 使用 CopyOnWriteArrayList
CopyOnWrite容器即写入数据时复制容器
- 它会在线程写入数据的时候复制一个新的容器,写入数据的时候不写入当前容器,而是写入新的容器。
- 写入完数据之后,再将原容器的引用指向新容器。
这样做的好处就是可以并发地执行读操作,而不用加锁,因为不会向原容器写入数据。
所以CopyOnWrite容器用的一种经典的读写分离的思想,读和写使用不同容器。
优点:
在读多写少的情况下性能很高,因为不涉及到加锁。
缺点:
🍐1.占用内存多。
🍏2.新写入的数据不能第一时间被读到。
🌺2.多线程环境使用队列
1) ArrayBlockingQueue
基于数组实现的阻塞队列
2) LinkedBlockingQueue
基于链表实现的阻塞队列
3) PriorityBlockingQueue
基于堆实现的带优先级的阻塞队列
4) TransferQueue
最多只包含一个元素的阻塞队列
🌻3.多线程环境使用哈希表
HashMap在多线程状态下是不安全的
要保证线程安全可以使用 Hashtable 或 ConcurrentHashMap
🍁3.1Hashtable
Hashtable只是简单地把关键从操作加上 synchronized 关键字而已
这相当于直接针对Hashtable对象本身加锁:
- 如果多线程访问同一个Hashtable就会直接造成锁冲突
- size属性也是通过 synchronized 来控制同步,也是比较慢的
- 一旦触发扩容,就由该线程完成整个扩容过程,这个过程涉及到大量的元素拷贝,效率会非常低。
一个Hashtable只有一把锁,两个线程访问这个Hashtable任意数据都会触发锁竞争。
🍂3.2ConcurrentHashMap
相比于 Hash Table 做出了一系列的改进和优化,以 Java1.8为例:
- 读操作没有加锁(但是使用了volatile保证从内存读取结果),只对写操作进行加锁。加锁的方式仍然使用synchronized,但不是锁整个对象,而是“锁桶”(每个链表的头结点作为锁对象),只有访问了这个头节点里的内容,才涉及到锁操作,大大降低了锁冲突的概率。
- 充分利用 CAS 特性。比如 size 属性通过 CAS 来更新,避免出现重量级锁的情况。
- 优化了扩容方式:化整为零。具体如下:
- 发现需要扩容的线程,只需要创建一个新的数组,同时只般几个元素过去。
- 扩容期间,新老数组同时存在
- 后续每个来操作ConcurrentHashMap的线程,都会参与搬家的过程,即每个操作负责搬运一小部分元素。
- 搬完这最后一个元素再把老数组删掉。
- 这个期间,插入只往新数组加。
- 这个期间,查找时需要同时查新数组和老数组。
每个ConcurrentHashMap里的桶都有有一把锁,两个线程访问这个ConcurrentHashMap同一个桶里的数据才会触发锁竞争。
🌷4.相关面试题
1) ConcurrentHashMap的读是否要加锁,为什么?
读操作没有加锁,目的是为了进一步降低锁冲突的概率,为了保证读到刚修改的数据,搭配了 volatile 关键字。
2) 介绍下 ConcurrentHashMap的锁分段技术?
这个是 Java1.7 中采取的技术,Java1.8 中已经不再使用了。简单的说就是把若干个哈希桶分成一个 "段" (Segment),针对每个段分别加锁。
目的也是为了降低锁竞争的概率。当两个线程访问的数据恰好在同一个段上的时候,才触发锁竞争。
3) ConcurrentHashMap在jdk1.8做了哪些优化?
取消了分段锁,直接给每个哈希桶(每个链表)分配了一个锁(就是以每个链表的头结点对象作为锁对 象)。
将原来 数组 + 链表 的实现方式改进成 数组 + 链表 / 红黑树 的方式。当链表较长的时候(大于等于 8 个元素)就转换成红黑树。
4) Hashtable和HashMap、ConcurrentHashMap 之间的区别?
HashMap: 线程不安全。key 允许为 null。
Hashtable: 线程安全。使用 synchronized 锁 Hashtable 对象,效率较低. key 不允许为 null。
ConcurrentHashMap: 线程安全。使用 synchronized 锁每个链表头结点,锁冲突概率低,充分利用 CAS 机制。优化了扩容方式。key 不允许为 null。
🍀五、死锁
🍨1.死锁是什么
🍕1)一个线程一把锁:
一个线程对一把锁加锁多次,如果这把锁不是可重入锁,那么就会死锁。
🍔2)两个线程两把锁:
线程1获得锁A
线程2获得锁B
线程1尝试获得锁B
线程2尝试获得锁A
这样,线程1无法获得线程2未释放的锁B,线程2无法获得线程1未释放的锁A,那么就会死锁。
🍟3)M个线程获取N把锁:
这里不得不提到经典的哲学家问题:
一共有5名哲学家,每两名哲学家之间有一根筷子。哲学家只会做两件事:思考人生(思考人生的时候会放下筷子),吃饺子(吃饺子的时候需要用到两根筷子,先拿左边的筷子,再拿右边的筷子)
如果哲学家发现筷子拿不起来了,就会阻塞等待
关键在此处:如果哲学家们同时拿起左手边的筷子,再尝试拿起右边的筷子,发现右边的筷子都被占用了,且五个哲学家互不相让,那么就会导致死锁。
🍧2.如何避免死锁
死锁是一种非常严重的BUG,会使程序的线程“卡死”,无法正常运行。
生产死锁的四个必要条件:
🍕1.互斥使用,即当资源被一个线程使用(占有时),别的线程不能使用。
🍔2.不可抢占,资源请求者不能强制从资源占有者中夺取资源,资源只能由资源占有者主动释放。
🍟3.请求和保持,即当资源请求者在请求其他的资源的同时保持对原有资源的占有。
🌭4.循环等待,即存在一个等待队列:P1占有P2的资源,P2占用P3的资源,P3占用P1的资源。这样就形成了一个等待环路。
当上述四个条件都成立的时候,便形成死锁。当然,死锁的情况下如果打破上述任何一个条件,便可让 死锁消失。其中最容易破坏的就是循环等待。
如何破坏循环等待:
我们给每双筷子编号,并且规定哲学家只能先拿编号小的筷子,再拿编号大的筷子:
每个哲学家都拿了一根筷子
拿第二根的时候只能拿比第一根筷子编号大的
那么此时我们发现左上角的哲学家可以拿到两根筷子:
代码体现:
最常用的一种死锁阻止技术就是锁排序。假设有 N 个线程尝试获取 M 把锁,就可以针对 M 把锁进行编号 (1, 2, 3...M)。
N 个线程尝试获取锁的时候,都按照固定的按编号由小到大顺序来获取锁。这样就可以避免环路等待。
可能产生环路等待的代码:两个线程对于加锁的顺序没有约定,就容易产生环路等待。
import java.util.Hashtable;
public class Main1 {
public static void main(String[] args) {
Object lock1 = new Object();
Object lock2 = new Object();
Thread t1 = new Thread() {
@Override
public void run() {
synchronized (lock1) {
synchronized (lock2) {
// do something...
}
}
}
};
t1.start();
Thread t2 = new Thread() {
@Override
public void run() {
synchronized (lock2) {
synchronized (lock1) {
// do something...
}
}
}
};
t2.start();
}
}
不会产生环路等待的代码:约定好先获取 lock1,再获取 lock2,就不会环路等待。
import java.util.Hashtable;
public class Main1 {
public static void main(String[] args) {
Object lock1 = new Object();
Object lock2 = new Object();
Thread t1 = new Thread() {
@Override
public void run() {
synchronized (lock1) {
synchronized (lock2) {
// do something...
}
}
}
};
t1.start();
Thread t2 = new Thread() {
@Override
public void run() {
synchronized (lock1) {//约定好先获取编号小的锁
synchronized (lock2) {
// do something...
}
}
}
};
t2.start();
}
}
🍦3.相关面试题
谈谈死锁是什么,如何避免死锁,避免算法? 实际解决过没有?
参考上文🥰
🍃六、其他常见面试题
🍕1) 谈谈 volatile关键字的用法?
volatile 能够保证内存可见性,强制从主内存中读取数据。此时如果有其他线程修改被 volatile 修饰 的变量,可以第一时间读取到最新的值。
🍔2) Java多线程是如何实现数据共享的?
JVM 把内存分成了这几个区域:
方法区,堆区,栈区,程序计数器。
其中堆区这个内存区域是多个线程之间共享的。只要把某个数据放到堆内存中,就可以让多个线程都能访问到。
🍟3) Java创建线程池的接口是什么?参数 LinkedBlockingQueue 的作用是什么?
创建线程池主要有两种方式:
- 通过 Executors 工厂类创建。创建方式比较简单,但是定制能力有限。
- 通过 ThreadPoolExecutor 创建。创建方式比较复杂,但是定制能力强。
LinkedBlockingQueue 表示线程池的任务队列。用户通过 submit / execute 向这个任务队列中添加任务,再由线程池中的工作线程来执行任务。
🌭4) Java线程共有几种状态?状态之间怎么切换的?
- NEW: 安排了工作,还未开始行动。新创建的线程,还没有调用 start 方法时处在这个状态。
- RUNNABLE: 可工作的。又可以分成正在工作中和即将开始工作。调用 start 方法之后,并正在 CPU 上运行/在即将准备运行的状态。
- BLOCKED: 使用 synchronized 的时候,如果锁被其他线程占用,就会阻塞等待,从而进入该状态。
- WAITING: 调用 wait 方法会进入该状态。
- TIMED_WAITING: 调用 sleep 方法或者 wait(超时时间) 会进入该状态。
- TERMINATED: 工作完成了。当线程 run 方法执行完毕后,会处于这个状态。
🍿5) 在多线程下,如果对一个数进行叠加,该怎么做?
- 使用 synchronized / ReentrantLock 加锁
- 使用 AtomInteger 原子操作
🥓6) Servlet是否是线程安全的?
Servlet 本身是工作在多线程环境下。
如果在 Servlet 中创建了某个成员变量,此时如果有多个请求到达服务器,服务器就会多线程进行 操作,是可能出现线程不安全的情况的。
🧇7) Thread和Runnable的区别和联系?
- Thread 类描述了一个线程。
- Runnable 描述了一个任务。
在创建线程的时候需要指定线程完成的任务,可以直接重写 Thread 的 run 方法,也可以使用 Runnable 来描述这个任务。
🥞8) 多次start一个线程会怎么样?
第一次调用 start 可以成功调用。后续再调用 start 会抛出 java.lang.IllegalThreadStateException 异常。
🍞9) 有synchronized两个方法,两个线程分别同时用这个方法,请问会发生什么?
synchronized 加在非静态方法上,相当于针对当前对象加锁。
如果这两个方法属于同一个实例: 线程1 能够获取到锁,并执行方法。线程2 会阻塞等待,直到线程1 执行完毕,释放锁,线程2 获取到锁之后才能执行方法内容。
如果这两个方法属于不同实例: 两者能并发执行,互不干扰。
🥐10) 进程和线程的区别?
- 进程是包含线程的。每个进程至少有一个线程存在,即主线程。
- 进程和进程之间不共享内存空间。同一个进程的线程之间共享同一个内存空间。
- 进程是系统分配资源的最小单位,线程是系统调度的最小单位。