多线程面试题与知识总结
JUC概述
什么是JUC
Java中一个线程处理的工具包java.util.concurrent的简称,从JDK1.5开始出现。
进程和线程
进程是资源分配的最小单元;线程是系统分配处理器时间资源的基本单位,是程序执行的最小单位;
线程的状态
线程状态枚举类
// Thread.State
public enum State{
NEW,(新建)
RUNABLE,(准备就绪)
BLOCKED,(阻塞)
WAITTING,(不见不散)
TIMED_WAITTING,(过时不候)
TERMINATED,(终结)
}
wait和sleep区别
- sleep是Thread的静态方法,wait是Object的方法,任何对象都可以调用;
- sleep不会释放锁,但它不需要占用锁;wait会释放锁,但调用它的前提是当前线程占有锁(即代码在synchronized中);
- 他们都可以被interrupted方法中断;
并发和并行
- 并发:同一时刻多个线程访问同一个资源;如:秒杀,抢票
- 并行:多项工作一起执行,最后进行汇总;如:泡面,一边电水壶烧水,一边撕开料包放入桶中
管程(即监视器 Monitor,俗称锁)
监视器是一种同步机制,它保证同一时间,只有一个线程可以访问被保护的数据或者代码;
JVM同步基于进入和退出,是通过管程来实现的;
Lock接口
synchronized关键字
synchronized是Java中的一个关键字,是一种同步锁,他修饰的对象有以下几种:
- 修饰代码块,被修饰的代码块被称为同步代码块,作用范围是被大括号{}括起来的代码,作用的对象是调用这个代码块的对象;
- 修饰方法,被修饰的方法被称为同步方法,作用范围是整个方法,作用的对象是调用这个方法的对象;
- 修饰静态方法,作用范围是整个静态方法,作用的对象是这个类的所有对象;
- 修饰类,作用范围是整个类,作用的对象是这个类的所有对象;
synchronized实现同步的基础:Java中的每一个对象都可以作为锁;
具体表现为以下3种形式:
- 对于普通同步方法(即仅被synchronized修饰),锁的是当前实例对象;
- 对于静态同步方法(即被static synchronized修饰),锁的是当前类的Class对象;
- 对于同步代码块,锁的是synchronized括号里配置的对象;
Lock
位置:java.util.concurrent.locks
所有已知实现类:ReentrantLock, ReentrantReadWriteLock.ReadLock, ReentrantReadWriteLock.WriteLock
Lock和synchronized区别
- Lock不是Java内置的,synchronized是Java关键字,因此是内置特性。Lock是一个类,通过这个类可以实现同步访问;
- Lock与synchronized最大的一点不同是:synchronized不需要用户手动释放锁,当synchronized方法或者synchronized代码块执行完之后系统会自动让线程释放对锁的占用;而Lock必须要用户手动释放锁,如果没有主动释放锁,就可能导致死锁现象;
- synchronized在发生异常时,会自动释放线程占用的锁,因此不会造成死锁现象;而Lock在发生异常时,如果没有通过unlock主动释放锁,就可能造成死锁现象,因此使用Lock时需要在finally中释放锁;
- Lock可以让等待锁的线程响应中断,而synchronized不行,使用synchronized时,等待的线程会一直等待下去,不能够响应中断;
- 通过Lock可以知道有没有成功获取锁,而synchronized办不到;
- Lock可以提高多个线程进行读操作的效率;
- 在性能上来说,如果资源竞争不激烈,那两者的性能相差不大,而当资源竞争非常激烈时,此时Lock的性能要远远优于synchronized;
集合的线程安全问题
ArrayList集合的线程安全问题及解决方案
ArrayList是非线程安全的,因为它的修改操作(如:add()、remove()方法)是非同步的,因此在多线程情况下可能抛出ConcurrentModificationException异常;
解决方案:(使用以下类或方法进行替换)
- Vector(太旧,已被抛弃,不推荐使用)
- synchronizedList(Collections工具包下的静态方法,不推荐使用)
- CopyOnWriteArrayList(写时复制技术,推荐使用)
- CopyOnWriteArrayList使用写时复制技术,其底层原理是当线程执行读操作时可以支持并发读操作,当执行写操作时,会将旧数组复制一份并在复制的副本上执行写操作,当写操作完成后该副本将替换旧数组,此时旧数组失效
HashSet和HashMap线程安全问题及解决方案
HashSet和HashMap与ArrayList一样,它们都是非线程安全的,在多线程情况下可能抛出ConcurrentModificationException异常;
解决方案:
- HashSet推荐使用CopyOnWriteArraySet类进行替换;
- HashMap推荐使用ConcurrentHashMap类进行替换;
公平锁和非公平锁
/**
* 参数为true表示使用公平锁
* 参数为false表示使用非公平锁(默认为false)
*/
ReentrantLock lock = new ReentrantLock(true);
两者区别:
- 非公平锁:
- 效率高
- 可能造成线程被饿死,即只有一个或者少数线程干活;
- 公平锁:
- 效率相对较低
- 所有线程都有活干,即线程利用率高(不绝对)
可重入锁(ReentrantLock,又称:递归锁)
synchronized和Lock都是可重入锁,但是synchronized是隐式的,因为它可以自动上锁和解锁,而Lock是显式的,因为它需要我们主动上锁和解锁。
死锁
什么是死锁?
两个或者两个以上进程(或线程)在执行过程中,因为争夺资源而造成一种互相等待的现象,如果没有外力干涉,它们无法再执行下去。
产生死锁的原因
- 系统资源不足
- 进程的推进顺序不合适
- 资源分配不当
产生死锁的四个必要条件
- 互斥条件:该资源任意一个时刻只由一个线程占用。
- 请求与保持条件:一个进程因请求资源而阻塞时,对已获得的资源保持不放。
- 不剥夺条件:线程已获得的资源在未使用完之前不能被其他线程强行剥夺,只有自己使用完毕后才释放资源。
- 循环等待条件:若干进程之间形成一种头尾相接的循环等待资源关系。
如何预防和避免线程死锁
如何预防死锁?
破坏死锁的产生的必要条件即可:
- 破坏请求与保持条件 :一次性申请所有的资源。
- 破坏不剥夺条件 :占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源。
- 破坏循环等待条件 :靠按序申请资源来预防。按某一顺序申请资源,释放资源则反序释放。破坏循环等待条件。
如何避免死锁?
避免死锁就是在资源分配时,借助于算法(比如银行家算法)对资源分配进行计算评估,使其进入安全状态。
安全状态 指的是系统能够按照某种进程推进顺序(P1、P2、P3.....Pn)来为每个进程分配所需资源,直到满足每个进程对资源的最大需求,使每个进程都可顺利完成。称<P1、P2、P3.....Pn>序列为安全序列。
验证是否有死锁
- 使用
jps -l
查看需要判断是否存在死锁的线程ID(jps
类似于Linux中的ps -ef
,利用它可以查看当前运行的Java线程信息,jps
位于jdk/bin/jps.exe
) - 使用
jstack 线程ID
查看线程信息(jstack
位于jdk/bin/jstack.exe
)
Callable接口
创建线程的4种方式
- 继承Thread类
- 实现Runnable接口
- 实现Callable接口
- 使用线程池的方式
Runnable接口和Callable接口的区别
Runnable接口 | Callable接口 | |
---|---|---|
是否有返回值 | × | √ |
是否抛出异常 | × | √ |
实现方法名称 | run() | call() |
JUC辅助类
- CountDownLatch(减少计数)
- CyclicBarrier(循环栅栏)
- Semaphore(信号灯)
ReentrantReadWriteLock读写锁
锁的分类
- 悲观锁:同一时刻只有一个线程可以执行,不支持并发执行;
- 乐观锁:同一时刻可以有多个线程执行,但是只有一个线程能够成功执行,支持并发执行;
- 表锁:线程操作时会锁住整张表,不会发生死锁;
- 行锁:每个线程只会锁住一行或多行,不会锁住整张表,可能发生死锁;
- 读锁:共享锁,即多个线程可以同时读,当两个线程读取的同时修改同一条数据的情况下,可能会发生死锁;
- 写锁:独占锁,即同一时刻只有一个线程可以修改数据,当两个线程对两条不同的数据执行写操作的同时操作对方的数据的情况下,可能会发生死锁;
读写锁:一个资源可以被多个读线程访问,或者可以被一个写线程访问,但是不能同时存在读写线程,读写是互斥的,而读读是共享的。
BlockingQueue 阻塞队列
常用阻塞队列
ArrayBlockingQueue
(数组阻塞队列)
- 基于数组实现;
- 使用单锁实现控制;
- 固定长度;
- 出入队列遵循FIFO(先进先出原则);
LinkedBlockingQueue
(链表阻塞队列)
- 基于单链表实现;
- 使用双锁(
putLock
和takePlock
)分离基于可中断锁(lockInterruptibly
)实现控制; - 默认长度为
Integer.MAX_VALUE
,这样做很可能会导致队列还没有满,但是内存却已经满了的情况(内存溢出); - 出入队列遵循FIFO(先进先出原则);
ThreadPool 线程池
什么是线程池?
线程池是一种线程的使用模式。线程过多会带来调度开销,进而影响缓存局部和整体性能。而线程池维护着多个线程,等待着监督管理者分配可并发执行的任务,这避免了在处理短时间任务时创建与销毁线程的代价。线程池不仅能够保证内核的充分利用,还能防止过分调度。
线程池的优势
线程池做的工作主要是控制线程数量,处理过程中将任务放入队列,然后在线程创建后启动这些任务,如果线程数量超过了最大数据,超出数量的线程需要排队等候,等其他线程执行完毕,再从队列中取出任务来执行。
主要特点
- 降低资源消耗:通过重复利用已创建的线程降低线程创建和销毁造成的资源消耗;
- 提高响应速度:当任务到达时,任务可以不需要等待线程创建就能立即执行;
- 提高线程的可管理性:线程是稀缺资源,如果无限制的创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一的分配,调优和监控;
Java中的线程池是通过Executor框架实现的,该框架中用到了Executor,Executors,ExecutorService,ThreadPoolExecutor
这几个类;
3种创建线程池的方法
- 一池N线程:
Executors.newFixedThreadPool(int)
- 一池一线程:
Executors.newSingleThreadPool()
- 动态线程池(线程池根据需要创建线程,可扩容,遇强则强):
Executors.newCacheThreadPool()
这3中线程池底层都是通过调用ThreadPoolExecutor()
方法来创建线程;
为什么不推荐使用这3种方法创建线程池
FixedThreadPool 使用无界队列 LinkedBlockingQueue(队列的容量为 Integer.MAX_VALUE)作为线程池的工作队列,会对线程池带来如下影响 :
1.当线程池中的线程数达到 corePoolSize 后,新任务将在无界队列中等待,因此线程池中的线程数不会超过 corePoolSize;
2.由于使用无界队列时 maximumPoolSize 将是一个无效参数,因为不可能存在任务队列满的情况。所以,通过创建 FixedThreadPool的源码可以看出创建的 FixedThreadPool 的 corePoolSize 和 maximumPoolSize 被设置为同一个值。
由于 1 和 2,使用无界队列时 keepAliveTime 将是一个无效参数;
运行中的 FixedThreadPool(未执行 shutdown()或 shutdownNow())不会拒绝任务,在任务比较多的时候会导致 OOM(内存溢出)。
SingleThreadExecutor 使用无界队列 LinkedBlockingQueue 作为线程池的工作队列(队列的容量为 Intger.MAX_VALUE)。SingleThreadExecutor 使用无界队列作为线程池的工作队列会对线程池带来的影响与 FixedThreadPool 相同。说简单点就是可能会导致 OOM;
CachedThreadPool允许创建的线程数量为 Integer.MAX_VALUE ,可能会创建大量线程,从而导致 OOM。
ThreadPoolExecutor方法的7个参数的含义
public ThreadPoolExecutor(int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue<Runnable> workQueue,
ThreadFactory threadFactory,
RejectedExecutionHandler handler)
-
corePoolSize:核心线程数量,也叫常驻线程数量,即线程池中存活的最少线程数;
-
maximumPoolSize:最大线程数量,即线程池扩容后可容纳的最大线程数;
-
keepAliveTime:线程存活时间,比如:一开始的核心线程数量是5,某一时刻业务需求突然增加,此时线程池需要扩容到最大线程数量(假设为100),一段时间后业务需求回归到原始状态(即扩容后的很多线程此时被闲置),此时线程池不会立刻销毁多余的线程,而是等待一段时间后再销毁多余的线程,这里的
keepAliveTime
参数就是用来设置这个等待时间的; -
unit:等待时间的单位,即
keepAliveTime
的时间单位; -
workQueue:阻塞队列,即当核心线程被用完后,新来的任务被放到该阻塞队列中等待;
-
threadFactory:线程工厂,被用来创建线程;
-
handler:拒绝策略,即当最大线程数的线程都被占用时,新来的任务的处理策略;
内置的拒绝策略
AbortPolicy
(默认):直接抛出RejectExecutionException
异常阻止系统正常运行;CallerRunsPolicy
:“调用者运行”一种调节机制,该策略既不会抛弃任务,也不会抛出异常,而是将某些任务回退到调用者,从而降低新任务的流量;DiscardOldestPolicy
:抛弃队列中等待最久的任务,然后把当前任务加入队列中尝试再次提交当前任务;DiscardPolicy
:该策略默默地丢弃无法处理的任务,不予任何处理也不抛出异常,如果允许任务丢失,这是最好的一种策略;
线程池的运行流程
假设一开始线程池中的任务为空;
- 当新任务到来,首先由核心线程池来处理;
- 当核心线程池任务满载后,新到的任务被放入阻塞队列进行等待;
- 当阻塞队列满载后,线程池自动扩容,刚到的新任务优先被新创建的线程处理(而不是优先处理阻塞队列中的任务);
- 当线程池已经扩容到最大容量后,如果还有新的任务到达,那么线程池将执行拒绝策略
线程的状态和方法
状态
-
新建(NEW):新创建了一个线程对象,但还没有调用
start()
方法。 -
运行(RUNNABLE):Java线程中将就绪(ready)和运行(running)两种状态笼统称为“运行”。
线程对象创建后,其他线程(如
main
线程)调用了该对象的start()
方法。该状态的线程位于可运行线程池中,等待线程被调度选中,获取CPU的使用权,此时处于就绪状态(ready)。就绪状态的线程在获得CPU时间片后变为运行状态(running)。 -
阻塞(BLOCKED):表示线程被锁阻塞,暂时停止运行。
-
等待(WAITING):进入该状态的线程需要被其他线程通过
notify()
或notifyAll()
方法唤醒。 -
超时等待(TIME_WAITING):该状态不同于WAITING,他可以在指定的时间内自行唤醒。
-
终止(TERMINATED):表示该线程已经执行完毕。
方法
Thread类中的实例方法:
-
start()方法
使该线程开始执行,Java 虚拟机调用该线程的 run 方法。要注意,调用start方法的顺序不代表线程启动的顺序,也就是cpu执行哪个线程的代码具有不确定性。
-
run()方法
这个方法是线程类调用start后所执行的方法,如果直接调用run而不是start方法,那么和普通方法一样没有区别。
-
sleep()方法
sleep相当于让线程睡眠,交出CPU,让CPU去执行其他的任务。但是有一点要非常注意,sleep方法不会释放锁,也就是说如果当前线程持有对某个对象的锁,则即使调用sleep方法,其他线程也无法访问这个对象。
sleep方法有两个重载版本:
sleep(long millis) //参数为毫秒 sleep(long millis,int nanoseconds) //第一参数为毫秒,第二个参数为纳秒
-
join()方法
假如在main线程中,调用thread.join方法,则main方法会等待thread线程执行完毕或者等待一定的时间。如果调用的是无参join方法,则等待thread执行完毕。如果调用的是指定了时间参数的join方法,则等待一定的时间。
join方法有三个重载版本:
join() join(long millis) //参数为毫秒 join(long millis,int nanoseconds) //第一参数为毫秒,第二个参数为纳秒
-
interrupt()方法
中断线程。单独调用interrupt方法可以使得处于阻塞状态的线程抛出一个异常,也就说,它可以用来中断一个正处于阻塞状态的线程;另外,通过interrupt方法来停止正在运行的线程。
-
yield()方法
调用yield方法会让当前线程交出CPU权限,让CPU去执行其他的线程。它跟sleep方法类似,同样不会释放锁。但是yield不能控制具体的交出CPU的时间,另外,yield方法只能让拥有相同优先级的线程有获取CPU执行时间的机会。
注意,调用yield方法并不会让线程进入阻塞状态,而是让线程重回就绪状态,它只需要等待重新获取CPU执行时间,这一点是和sleep方法不一样的。
-
stop()方法
stop方法已经是一个废弃的方法,它是一个不安全的方法。因为调用stop方法会直接终止run方法的调用,并且会抛出一个ThreadDeath错误,如果线程持有某个对象锁的话,会完全释放锁,导致对象状态不一致。所以stop方法基本是不会被用到的。
-
destroy()方法
destroy方法也是废弃的方法。基本不会被使用到。
设置/获取线程属性的方法:
- getId()方法:获得线程ID
- getName()和setName()方法:用来得到或者设置线程名称
- getPriority()和setPriority()方法:用来获取和设置线程优先级
- setDaemon()和isDaemon()方法:用来设置线程是否成为守护线程和判断线程是否是守护线程
- currentThread()方法:用来获取当前线程
守护线程和用户线程的区别在于:守护线程依赖于创建它的线程,而用户线程则不依赖。举个简单的例子:如果在main线程中创建了一个守护线程,当main方法运行完毕之后,守护线程也会随着消亡。而用户线程则不会,用户线程会一直运行直到其运行完毕。在JVM中,像垃圾收集器线程就是守护线程。
细节小问题
1.Thread.sleep(0) 的作用
- 使用场景:Java 采用抢占式的线程调度算法,因此可能会出现某条线程常常获取到 CPU 控制权的情况。
- 解决方案:为让某些优先级较低的线程也能获取到 CPU 控制权,可使用 Thread.sleep(0) 触发一次操作系统分配时间片的操作,这也是平衡 CPU 控制权的一种操作。
评论区