努力经营当下 直至未来明朗
1. 谈谈进程和线程之间的区别【高频】
答:① 进程包含线程;
② 线程比进程更轻量,创建更快、销毁也更快;
③ 同一个进程的多个线程之间共用一份内存和文件资源,而进程和进程之间则是独立的文件和内存资源;线程共用资源就省去了线程分配资源的过程
④ 进程是资源分配的基本单位,线程是调度执行的基本单位
2. java中有哪些方式来创建线程?
① 继承Thread类,重写run方法,run方法是新线程的入口
② 实现Runnable接口,重写run
③ 使用匿名内部类,实现创建Thread子类的方式
④ 使用匿名内部类,实现 实现Runnable接口的方式
⑤ 使用Lambda表达式(lambda本质上是一个“匿名函数”)
(其实lambda表达式一般用于一个方法的实现上,该方法可以作为参数传入)
⑥ 使用线程池
⑦ 使用Callable(中间类FutureTask进行辅助,是获取结果的凭证)
3. run和start的区别【经典面试题】
答:① 直接调用run并没有创建线程,只是在原来的线程中运行的代码,只是相当于调用方法
② 调用start则是创建了线程,在新线程中执行代码(和原来的线程是并发执行的(并发+并行))
4. Java线程的状态
① NEW:Thread 对象创建出来了,但是内核的PCB还没有创建,也就是说:还没有真正创建线程。
② TERMINATED:内核的PCB销毁了,但是Thread 对象还在。
③ RUNNABLE:就绪状态(正在CPU上运行+在就绪队列中排队)
④ TIMED_WAITING:按照一定的时间进行阻塞。 调用sleep、join这类带时间的都是TIMED_WAITING
⑤ WAITING:特殊的阻塞状态,调用wait等
⑥ BLOCKED:等待锁的时候进入的状态
(①②都是Alive状态)
5. 【线程不安全的原因】
① 抢占式执行:多个线程调度执行过程可以视为是“全随机”的(也不能理解成纯随机的,但是确实在应用层程序上是没有规律的)(所以:在写代码的时候,就需要考虑到在任意一种调度的情况下都是能够运行出正确结果的)
(内核实现的,我们无能为力)
② 多个线程修改同一个变量:
【String是不可变对象(也就是不能修改String的内容,这并不是说用final修饰,而是把set系列方法给藏起来了),设计成不可变的好处之一就是“线程安全”】
(有时候可以通过调整代码来规避线程安全问题,但是普适性不高)
③ 修改操作不是原子的:
CPU执行指令都是以“ 一个指令”为单位进行执行,一个指令就相当于CPU上的“最小单位”了,不会说该条指令还没执行完就把线程调度走了。
(eg. count++就是三条指令
而像是有的修改操作如int的赋值就是单个CPU指令,安全一些)
注:解决线程安全问题最常见的方法就是从这里入手:把多个操作通过特殊手段打包成一个原子操作 (一个线程是否安全的判定是复杂的)
④ 内存可见性问题:JVM的代码优化(逻辑等价条件下提高效率)引入的bug
⑤ 指令重排序
(以上并不是线程不安全的全部原因)
6. 就以count++为例:一个线程加锁、一个线程不加锁,此时能否保证线程的安全呢?
1)线程安全,不是加了锁就一定安全的;而是通过加锁让并发修改同一个变量变为串行修改同一个变量,此时才是安全的。 而不正确的加锁方式并不一定能够解决线程安全问题。
2) 所以:是不能保证线程安全的。一个线程加锁并不会涉及锁竞争,也就不会阻塞等待,也就不会由并发修改同一变量变为串行修改同一变量,故是不安全的。
7. 要加锁的代码如果不是在一个方法里,怎么办呢?
1) synchronized不仅可以修饰方法,还可以修饰代码块。所以可以将要加锁的代码放到一个代码块中。
2) 在synchronized修饰代码块时,()括号中的内容是我们所要针对的加锁对象,成为“锁对象”。
3) 在使用锁的时候,一定要明确 当前是针对哪个对象加锁。这很重要,会直接影响后面锁操作是否会触发阻塞。我们关心的是 是否存在(同一个)锁对象,是否存在锁竞争。
8. synchronized的特性:
- 互斥:也就是 加锁/解锁。
(上一个线程解锁之后, 下一个线程并不是立即就能获取到锁.) - 刷新内存(存疑)
- 可重入【不好理解】:
1) 一个线程针对同一把锁连续加锁两次就可能造成死锁。
而在加锁两次之后不会产生死锁的就叫做**“可重入锁”,会产生死锁的叫“不可重入锁”。
2)可重入锁的底层实现其实很简单:只要 让锁里面记录好是哪个线程持有的这把锁。
如:t线程尝试对this对象来加锁,this这个锁里面就记录了是t线程持有了它;第二次进行加锁的时候如果该this锁发现是原来的t线程,则直接通过**,没有任何负面影响,不会阻塞等待。
3) 那么在何时进行解锁又是一个问题:引入计数器。
每次加锁,计数器++, 每次解锁,计数器–。 只有在计数器为0的时候才能够真正加锁和解锁
可重入锁的实现要点:
①让锁里持有线程对象,记录是谁加了锁;
②维护一个计数器,用来衡量啥时候真加锁,啥时候真解锁,啥时候又是直接放行。
4) 在加锁代码中出现异常,如果没人catch就会脱离之前的代码块,脱离一层代码块计数器就-1,然后经过多次脱离 则计数器会最终减到0;所以是不会在加锁代码中出现异常时死锁的,无论如何解锁代码都是可以执行到的。
5) 可重入已经在synchronized中处理好了,也就不会发生死锁了。
9. Java库中常见安全线程和不安全线程
1)不安全线程:
ArrayList、LinkedList、HashMap、TreeMap、HashSet、TreeSet、StringBuilder
2)安全线程:
Vector (不推荐使用) :相当于线程安全的ArrayList
HashTable (不推荐使用):相当于线程安全的HashMap
ConcurrentHashMap
StringBuffer:StringBuffer 的核心方法都带有synchronized
3)虽然没有加锁, 但是不涉及 “修改”, 仍然是线程安全的:String
10. volatile的特性
- synchronized(加锁)保证了线程的可重入性和原子性!! 而volatile是保证“内存可见性”的,但是不保证原子性。
- 针对一个线程读,一个线程修改 的场景,使用volatile是合适的; 针对两个线程修改的场景,volatile是无能为力的,因为没有原子性
(volatile可以多加,但是千万不要少加)
11. 在面试中一旦谈到了volatile,多半是脱离不了JMM(Java Memory Model:java内存模型)。
① volatile是会禁止编译器优化的,避免直接读取了CPU寄存器中缓存的数据,而是每次都重新读取内存。
② 站在JMM的角度来看待volatile:
正常程序执行过程中会把主内存(也就是所说的内存)的数据先加载到工作内存(也就是所说的CPU寄存器,不是真的内存)中,然后再进行计算处理; 编译器优化可能会导致不是每次都真的读取主内存,而是直接取从工作内存中缓存的数据,这就可能会导致内存可见性问题; volatile起到的效果 就是保证每次读取内存都是真的从主内存中重新读取
12. 【面试题】 wait和sleep的对比:
答: ① wait 需要搭配 synchronized 使用没有synchronized就会抛异常; sleep 不需要。
② wait 是 Object 的方法, sleep 是 Thread 的静态方法.
其实理论上 wait 和 sleep 完全是没有可比性的,因为一个是用于线程之间的通信的,一个是让线程阻塞一段时间。
唯一的相同点就是都可以让线程放弃执行一段时间。
13. wait和notify
1)wait和notify用来调配线程线程执行的顺序
2)wait是Object方法,而Object是java所有类的祖宗,因此可以使用任意类的实例来调用wait方法; wait可能会抛出InterruptedException异常,这个异常就是被interrupt方法唤醒的。
3)当线程执行到wait的时候就会发生阻塞,直到另一个线程调用notify才会把这个wait唤醒,然后继续往下走。
3)wait操作 在内部本质上是做三件事:
①释放当前锁;
②进行等待通知;
③满足一定条件的时候(也就是别的线程调用notify)被唤醒,然后重新获取锁
所以等待通知的前提是释放锁,而释放锁的前提是先加锁
wait的第一步操作是释放锁,保证其他线程能够正常往下执行
wai和加锁操作密不可分。(也就是synchronized之后才可以wait)
wait这里可以记住类似举例:ATM取钱
4)notify也是要包含在synchronized里面的。
线程1没有释放锁的话,线程2也就无法调用notify(因为锁在阻塞等待);线程1调用wait,在wait中就释放锁了,这个时候虽然线程1代码阻塞在synchronized里面,但是此时的锁还是在释放状态,线程2 就可以拿到锁。
其他线程也是需要上锁才能调用notify,调用了notify就会唤醒wait,wait就会尝试重新加锁,但是wait加锁可能需要阻塞一会儿,直到notify所在的线程释放锁完成后wait才加锁成功。
5)要保证:加锁的对象和调用wait的对象是同一个对象;另外同时还要保证:调用wait的对象和调用notify的对象也是同一对象!!
6)多个线程都在wait时,notify是随机唤醒一个线程; notifyAll则是全都唤醒,但是即使是唤醒了所有的wait,这些wait也是需要重新竞争锁的,而重新竞争锁的过程仍然是串行的,所以这个其实并不是很常用。
7)wait, notify, notifyAll 都是 Object 类的方法。
8)wait 要搭配 synchronized 来使用, 脱离 synchronized 使用 wait 会直接抛出异常。
9)方法notify()【即:唤醒等待】也要在同步方法或同步块【即:使用synchronized修饰】中调用。
14. 【单例模式】懒汉和饿汉模式,谁才是线程安全的?
答:懒汉模式线程不安全,饿汉模式线程安全。
① 考虑某个模式是否线程安全,本质上是在考虑多个线程同时调用getInstance的时候是否会有问题。
② 饿汉模式获取实例getInstance的操作只是单纯的“读数据”,不涉及到修改,因为饿汉模式在类加载的时候就已经创建好实例对象了。
③ 懒汉模式获取实例getInstance的操作既涉及到读,又涉及到修改,此时线程就是不安全的。
(单例模式:某个类有且只有一个实例)
15. 如何解决指令重排序带来的问题呢?
答:办法就是:禁止指令重排序。那么如何禁止呢?就是使用volatile关键字,既能保证内存可见性(读、修改线程并发,但是其实细想是不会存在的:因为每个线程有各自工作的一套CPU寄存器,有各自的上下文),又能禁止指令重排序(避免得到不完全对象,内存数据无效)。
16. 理解双重 if 判定 / volatile:
① 加锁 / 解锁是一件开销比较高的事情, 而懒汉模式的线程不安全只是发生在首次创建实例的时候。
因此后续使用的时候, 不必再进行加锁了。
② 外层的 if 就是判定下看当前是否已经把 instance 实例创建出来了。
③ 同时为了避免 “内存可见性” 导致读取的 instance 出现偏差, 于是补充上 volatile 。
④ 当多线程首次调用 getInstance, 大家可能都发现 instance 为 null, 于是又继续往下执行来竞争锁, 其中竞争成功的线程, 再完成创建实例的操作。
⑤ 当这个实例创建完了之后, 其他竞争到锁的线程就被里层 if 挡住了, 也就不会继续创建其他实例。
⑥ 这样会降低操作开销。
17. 【了解】非先进先出队列
队列:先进先出;然而,并不是所有的队列都是“先进先出”,“先进先出”针对的是普通的队列,复杂队列就不一定“先进先出”。
如:非“先进先出”:
① 优先级队列PriorityQueue
② 消息队列(在队列元素中引入一个“类型”,此时的“类型”是指业务上的类型):入队列的时候没啥,但是出队列的时候会指定某个类型的元素先出。
18. 阻塞队列特点及典型应用场景
- 阻塞队列是一个特殊的队列,但是其确实是 “先进先出” 的。
- 阻塞队列特点:
① 线程安全
② 带有阻塞功能:
A)如果队列满了还继续入队列,此时入队操作就会阻塞;直到队列不满,入队列才能成功
B)如果队列空了还继续出队列,此时出队操作就会阻塞;直到队列不空,出队列才能成功 - 阻塞队列的典型应用场景:生产者-消费者模型(描述的是多线程协同工作的一种方式),该模型能够较好地解决锁冲突问题。 (举例:包饺子)
19. 生产者-消费者模型/阻塞队列的好处
① 使用阻塞队列,有利于代码 “解耦合”
耦合:两个模块之间的关联关系,关系越紧密则耦合性越高。
【如:两个服务器直接通信则关联性强,互相影响;
但是如果在两个服务器之间加上一个阻塞队列就有效降低了两个服务器的关联性,耦合性降低,并且这样的话增加/删除服务器也较为方便】
也就是说: 生产者和消费者彼此之间不直接通讯,而通过阻塞队列来进行通讯,此时的阻塞队列就类似于“缓冲区”
② 削峰填谷:(举例:三峡水库)
如果按照没有 生产者消费者模型的写法,外面流量过来的压力就会直接压在每个服务器上,如果某个服务器抗压能力不太行就容易挂。
为什么一个服务器同一时刻收到很多请求就挂了?
理由:服务器每处理一个请求都是需要消耗一定的硬件资源,这些硬件资源包括但不限于CPU、内存、硬盘、宽带等,同一时刻请求越多则消耗的资源越多;而一台主机的硬件资源是有限的,一旦某个硬件资源耗尽了,此时机器也就挂了。
【而所谓的分布式系统,本质上就是加入了更多的硬件资源】
如果使用阻塞队列,当流量骤增的时候,生产者和阻塞队列就承受了压力,而其余消费者还是按照原来的节奏来消费数据,即对消费者的冲击就不大。
20. 阻塞队列的具体使用
(队列有三个基本操作:入队列、出队列、取 队首元素)
- 阻塞队列提供给了带有阻塞的入队列和出队列方法,但是没有提供带有阻塞的取队首元素方法。
- 标准库的阻塞队列
1)BlockingQueue 也有 offer, poll, peek 等方法, 但是这些方法不带有阻塞特性。而put、take方法带有阻塞。
2) BlockingQueue是接口。 - 自己实现一个阻塞队列
1) 先实现一个普通队列
(队列的实现有两个版本:基于链表、基于数组)
① 循环队列:下标head/tail 如果head == tail就是null/满,如果存入数据则tail++,并且队列中有效元素范围是[head,tail); 而如果到达末尾就又回到开头,达到循环
② 如何区分该队列是空还是满呢?空和满都是head==tail,即指针重合,区分方法就是:要么加一个记录个数的变量,要么浪费一个空间
2)加上线程安全 (线程安全就是加锁synchronized)
3)加上阻塞实现(队列为空则出队列阻塞,队列满则入队列阻塞)
这里注意稳妥的写法,wait不一定是另一个线程中的notify来唤醒的,也可能是interrupt来唤醒的,如果是interrupt唤醒可能条件就还不成熟,所以需要循环再判断。
(wait被唤醒之后也是要去竞争锁的)
21. 标准库中的定时器Timer
- 标准库中的定时器 Timer类(使用的是java.util中的)。
- 而在Timer类中的一个重要的方法是
schedule(TimerTask task, long delay)
。
① 参数1 TimerTask task是要安排的任务,其实就是一个Runnable接口,我们要做的就是继承这个TimerTask,然后重写run方法,从而指定要执行的任务。【注意!参数1是 new TimerTask() {…重写run方法},不是new Runnable() {…}】
② 参数2 long delay 就是指经过delay (ms)之后开始执行参数1任务task。 - 一个定时器可以同时安排多个任务!
- 执行代码会发现,在执行完task任务之后进程并没有退出,理由:Timer内部需要一组线程来执行注册的任务task,而这里的线程是前台线程,会影响进程退出。
22. (了解)小结定时器实现:
① MyTimer 类提供的核心方法为 schedule, 用于注册一个任务, 并指定这个任务多长时间后执行。
② MyTask 类用于描述一个任务, 里面包含一个 Runnable 对象和一个 time(毫秒时间戳),也就是schedule的俩参数。
这个对象需要放到 优先队列 中, 因此需要实现 Comparable 接口。(时间短的先执行)
③ MyTimer 实例中, 通过 PriorityBlockingQueue 来组织若干个 MyTask 对象,通过 schedule 来往队列中插入一个个 MyTask 对象。(优先队列线程不安全,阻塞队列线程安全)
④ MyTimer 类中存在一个扫描线程, 一直不停的扫描队首元素, 看看是否能执行这个任务。(所谓 “能执行” 指的是该任务设定的时间已经到达了)
⑤ 引入一个对象, 借助该对象的 wait / notify 来解决 while (true) 的忙等问题(也就是不停扫描,浪费CPU)
修改 扫描线程 的 run 方法, 引入 wait, 等待一定的时间;
修改 MyTimer 的 schedule 方法, 每次有新任务到来的时候唤醒一下 扫描 线程. (因为新插入的任务可能是需要马上执行的)。
23. 为啥从池子里取比创建新线程快?
答: 创建线程还是会申请点资源的,但是这个资源已经很少了,速度已经很快了,暂可以忽略不计。
1)【原因】创建线程是要在操作系统内核中完成的,涉及到用户态到内核态之间的切换操作!这个操作是存在一定的开销的。(Ps:加锁也涉及到 用户态到内核态之间的切换)
2)一般来说,纯用户态速度更快,即:使用线程池是纯用户态操作,要比创建线程(要经历内核态)速度更快
24. (了解)Executors.newCachedThreadPool()创建线程池
- 使用
Executors.newCachedThreadPool()
来创建线程池,使用ExecutorService来接收
- 线程池使用submit方法把任务提交到线程池中即可,线程池中就会有一些线程来完成这里的任务。
25. 如何把N个任务分配给M个线程呢?
答: 生产者消费者模型。
① 先搞一个阻塞队列,每个被提交的任务都放到阻塞队列中;搞M个线程来取队列元素,如果队列空则M个线程就进行阻塞等待;但是如果队列不为空,每个线程都取一个任务,执行任务完成后再来取下一个…直到队列空,线程继续阻塞。
② 不能平均分:因为每个线程执行时间都是不一样的
③ 不用结束,因为无法判定啥时候会有新的线程过来;如果非要结束,那就单独写一个shutdown方法强制中断interrupt所有的工作线程
26. 标准库里提供的ThreadPoolExecutor的构造方法以及拒绝策略
标准库里提供的ThreadPoolExecutor其实是更复杂一些的,尤其是构造方法,可以支持很多参数,可以支持很多选项,让我们创建出不同风格的线程池
-
构造方法【常见面试题!!】
1) 查看ThreadPoolExecutor里的构造方法:java.util.concurrent(并发) -> ThreadPoolExecutor (线程池)
2) 此处只分析最后一个构造方法:
① corePoolSize:核心线程数
② maximumPoolSize:最大线程数
(任务数量是不太确定的,有时候任务多了,核心线程处理不过来,此时就需要更多的线程来帮助一起处理任务;当任务处理完之后,这些除了核心线程外的线程在一定时间的空闲之后就可以销毁了;但是核心线程即使空闲也不会销毁。
灵活调配这两个数值,可以做到既能够处理任务巅峰,又能够在空闲的时候节省资源。)
③ keepAliveTime:运行的额外线程空闲的最大时间,也就是空闲上限。
④ unit:时间的单位
⑤ workQueue:手动给线程池传入一个任务队列。其实在线程池中是有自己的队列的(如果不自己手动传入就会在线程池内部自己创建),但是有时候代码的业务逻辑中本身就有一个队列来保存这里的任务,此时如果把自己队列中的任务再拷贝到线程池内部就是画蛇添足了,直接就让线程池消费业务逻辑中已有的队列即可!
⑥ threadFactory:描述了线程是如何创建的。工厂对象就负责创建线程,程序员可以手动指定线程的创建策略。
⑦ RejectedExecutionHandler handler:【重点!常考!】线程池的拒绝策略。线程池的任务队列已经满了(工作线程忙不过来了),如果又添加了别的新任务,那该怎么办呢?
——这个拒绝策略对于实现“高并发”服务器也是非常有意义的。 -
以下就是标准库中提供的拒绝策略:
① AbortPolicy:中断策略,直接抛异常 handler(回调,处理方法)
② CallerRunsPolicy:调用者来执行,而不是被调用者来执行(按理来说是被调用者执行);如果调用者也不执行就丢弃该任务
③ DiscardOldestPolicy:丢弃最老的未处理请求
④ DiscardPolicy:直接丢弃最新的任务
(实际开发中,需要根据请求来决定使用哪种策略)
【面试官考察你对于ThreadPoolExecutor的理解,其实主要就是在考察拒绝策略】
27. 延伸问题:线程池不是可以自定义线程数目吗,那么在实际开发中,线程池的数目如何确定?设定为几计较合适呢?2?4?…
—— 网上大部分说法是错误的。只要你具体说出一个数字都是错误的! 因为我们在这里是不可以确定出具体的个数的。
理由:① 主机的CPU的配置不确定;
② 程序的执行特点(也就是:代码里具体都干了啥?是CPU密集型的任务还是IO密集型的任务)也是不确定的。
执行特点:也就是代码里具体都干了啥?是CPU密集型的任务(做了大量的算术运算和逻辑运算)还是IO密集型的任务(做了大量的读写网卡/读写硬盘)
有些程序代码里既需要进行很多的CPU密集型任务,又需要很多的IO任务,则此时是很难量化该进程的两种任务的比例的。
1)如果任务100%是CPU密集型的话,线程说明最多也就是N,更大的话其实已经没意义了,因为此时CPU已经被占满了
2)如果线程只有10%是CPU密集型,其余90%都是在操作IO(不使用CPU),那么此时线程数目设置成10N也是没关系的,因此此时所有线程中只有10%是在使用CPU的。
工作中实际的处理方案就是进行验证,也就是针对进程做性能测试:分别给线程池设置成不同的数目,如0.5N,N,1.5N,2N…都试试,分别记录每种情况下该线程的一些核心性能指标和系统负载情况,最终选择一个你认为比较合适的配置。
【其实面试官考察的关键是如何设置线程池数目的方法(实验+压测)】
28. 多线程的案例:单例模式、阻塞队列、定时器、线程池都是日常开发中常用的多线程相关的基础组件,务必要重点掌握。
29. 描述一下线程池的执行流程和拒绝策略有哪些?【面试题!】
① 线程池执行流程:
当任务来了之后,线程池的执行流程是:先判断当前线程数是否大于核心线程数?如果结果为 false,则新建线程并执行任务;如果结果为 true,则判断任务队列是否已满?如果结果为 false,则把任务添加到任务队列中等待线程执行,否则则判断当前线程数量是否超过最大线程数?如果结果为 false,则新建线程执行此任务,否则将执行线程池的拒绝策略
② 拒绝策略:
当任务过多且线程池的任务队列已满时,此时就会执行线程池的拒绝策略,线程池的拒绝策略默认有以下 4 种:
AbortPolicy:中止策略,线程池会抛出异常并中止执行此任务;
CallerRunsPolicy:把任务交给添加此任务的(main)线程来执行;
DiscardPolicy:忽略此任务,忽略最新的一个任务;
DiscardOldestPolicy:忽略最早的任务,最先加入队列的任务。
默认的拒绝策略为 AbortPolicy 中止策略。
30. 锁相关
- 锁的核心特性 “原子性”, 这样的机制追根溯源是 CPU 这样的硬件设备提供的。
- 操作系统默认的锁的调度是非公平锁。但是如果要想实现公平锁就需要引入额外的数据结构来记录加锁的顺序,此时就需要一定的额外开销。
- Java里只要以Reentrant开头命名的锁都是可重入锁,而且JDK提供的所有现成的Lock实现类,包括synchronized关键字锁都是可重入的;而 Linux 系统提供的 mutex 是不可重入锁。
- 锁内容参考:锁和CAS
31. 你是怎么理解乐观锁和悲观锁的,具体怎么实现呢?
答: ① 悲观锁认为多个线程访问同一个共享变量冲突的概率较大, 会在每次访问共享变量之前都去真正加锁。
② 乐观锁认为多个线程访问同一个共享变量冲突的概率不大, 并不会真的加锁, 而是直接尝试访问数据。 在访问的同时识别当前的数据是否出现访问冲突。
③ 悲观锁的实现就是先加锁(比如借助操作系统提供的 mutex), 获取到锁再操作数据, 获取不到锁就等待。
④ 乐观锁的实现可以引入一个版本号,借助版本号识别出当前的数据访问是否冲突。
32. 介绍下读写锁?
答: ① 读写锁就是把读操作和写操作分别进行加锁.
② 读锁和读锁之间不互斥.
③ 写锁和写锁之间互斥.
④ 写锁和读锁之间互斥.
⑤ 读写锁最主要用在 “频繁读, 不频繁写” 的场景中.
33. 什么是自旋锁,为什么要使用自旋锁策略呢,缺点是什么?
答: ① 自旋锁即:如果获取锁失败, 立即再尝试获取锁, 无限循环, 直到获取到锁为止。 第一次获取锁失败, 第二次的尝试会在极短的时间内到来; 一旦锁被其他线程释放, 就能第一时间获取到锁。
② 相比于挂起等待锁,
优点: 没有放弃 CPU 资源, 一旦锁被释放就能第一时间获取到锁, 更高效。在锁持有时间比较短的场景下非常有用。
缺点: 如果锁的持有时间较长, 就会浪费 CPU 资源。
34. synchronized 是可重入锁么?
答: ① 是可重入锁.
② 可重入锁指的就是连续两次加锁不会导致死锁.(同一个线程针对同一把锁)
③ 实现的方式是在锁中记录该锁持有的线程身份, 以及一个计数器(记录加锁次数)。如果发现当前加锁的线程就是持有锁的线程, 则直接计数自增。
35. 【补】synchronized特点
- 对于synchronized:
① 开始时是乐观锁, 如果锁冲突频繁, 就转换为悲观锁.
② 开始是轻量级锁实现, 如果锁被持有的时间较长, 就转换成重量级锁
③乐观锁/轻量级锁的部分是基于自旋锁实现的,悲观锁的部分是基于挂起等待锁实现的
④不是读写锁,而是普通互斥锁
⑤是非公平锁
⑥是可重入锁
(在标准库中是有另外的其他锁能够实现④⑤的) - 所以:
synchronized是自适应的:初始使用的时候是乐观锁/轻量级锁/自旋锁,如果锁竞争不激烈就保持上述状态不变;但是如果锁竞争激烈了,synchronized就会自动升级成悲观锁/重量级锁/挂起等待锁。
36. CAS了解
- CAS 可以视为是一种乐观锁. (或者可以理解成 CAS 是乐观锁的一种实现方式)
- CAS即:【 compare and swap】即比较并交换:把内存中的某个值M和CPU寄存器A中的值进行比较,如果两个值相同,就把另一个寄存器B中的值和内存的值M进行交换(把内存的值M放到寄存器B,同时把寄存器B的值写给内存;其实这是一个“写内存”操作,更关心的是交换后内存值M)
(返回值是是否操作成功) - CAS的操作是由CPU的一条指令原子性完成的,所以是线程安全的,效率也较高。(高效是因为没有锁冲突和线程等待)
- CAS应用:实现原子类、实现自旋锁。
37. CAS的ABA问题
- CAS的ABA问题【面试的时候谈到CAS,十有八九就会谈到ABA】
1)这是CAS的一个小缺陷
2)在CAS中进行比较的时候,如果此时的寄存器A和内存M的值相同,你无法判定是内存M的值始终不变还是M变了,但是又变回来了
3)CAS的ABA问题其实在大部分情况下也不是事儿,不会出现bug;但是在极端情况下是会出现问题的。
4)ABA在极端情况下是啥效果?
举例:可能会导致一次取钱,两次扣款(也就是在第一个线程完成取款之后,又有人转账,而第二个线程进行CAS检查时候发现数值相同,就进行扣款操作) - 如何解决ABA带来的问题?
只要有一个记录能够记录上内存的变化就可以解决ABA问题了。
那么如何进行记录呢?
——另外搞一个内存,保存内存M的“修改次数”(版本号)或者是“上次修改时间”,通过这两种方法都是可以解决ABA问题的。
“修改次数”“上次修改时间”都是【只增不减】,也就是说无法再跳回去。
此时比较的就不是寄存器A和内存M了,而是比较版本号/上次修改时间。 -
相关面试题
1)讲解下你自己理解的 CAS 机制
答:CAS全称 compare and swap, 即 “比较并交换”。 相当于通过一个原子的操作, 同时完成 “读取内存, 比较是否相等, 修改内存” 这三个步骤。本质上需要 CPU 指令的支撑。
2)ABA问题怎么解决?
答: ① 给要修改的数据引入版本号。在 CAS 比较数据当前值和旧值的同时, 也要比较版本号是否符合预期。
② 如果发现当前版本号和之前读到的版本号一致, 就真正执行修改操作, 并让版本号自增; 如果发现当前版本号比之前读到的版本号大, 就认为操作失败。
38. synchronized
- JVM 将 synchronized 锁分为 无锁、偏向锁、轻量级锁、重量级锁 状态。会根据情况,进行依次升级。
- synchronized加锁的具体过:
1)涉及锁升级/锁膨胀: 无竞争,偏向锁;有竞争,轻量级锁; 竞争激烈,重量级锁。
2)锁自适应:实现了轻量级锁和重量级锁的“自适应”操作。
如果当前场景中锁竞争不激烈,则是以轻量级锁状态来进行工作(轻量级锁是通过自旋来实现的,可以第一时间拿到锁);
如果当前场景中锁竞争激烈,则是以重量级锁状态来进行工作的(重量级锁通过挂起等待来实现,可能拿到锁每那么及时,但是节省了CPU的开销)
- synchronized的优化手段:
1)锁消除:锁消除是一种编译器优化的行为,但是编译器的判定不是特别准,此时如果不是编译器有十足/100%的把握都是不会进行synchronized的自动消除的。 也就是说:锁消除只是在编译器/JVM有十足的把握的时候才进行。
2)锁粗化:实际开发过程中, 使用细粒度锁, 是期望释放锁的时候其他线程能使用锁。但是实际上可能并没有其他线程来抢占这个锁, 这种情况 JVM 就会自动把锁粗化, 避免频繁申请释放锁。(锁粗化的前提是代码的逻辑不变)
3)锁升级/膨胀
39. 什么是偏向锁?
答: 偏向锁不是真的加锁, 而只是在锁的对象头中记录一个标记(记录该锁所属的线程)。 如果没有其他线程参与竞争锁, 那么就不会真正执行加锁操作, 从而降低程序开销。 一旦真的涉及到其他的线程竞争, 再取消偏向锁状态, 进入轻量级锁状态。
40. synchronized 实现原理 是什么?
答: 参考【synchronized原理】所有内容:特点+加锁过程+优化手段。
41. 介绍下 Callable 是什么?
答: ① Callable 是一个 interface 。相当于把线程封装了一个 “返回值”, 方便程序员借助多线程的方式计算结果。
② Callable 和 Runnable 相对, 都是描述一个 “任务”。Callable 描述的是带有返回值的任务, Runnable 描述的是不带返回值的任务。
③ Callable 通常需要搭配 FutureTask 来使用, FutureTask 用来保存 Callable 的返回结果。 因为Callable 往往是在另一个线程中执行的, 啥时候执行完并不确定。 FutureTask 就可以负责这个等待结果出来的工作。
42. 面试中常见问题:说说synchronized和ReentrantLock的区别
- ReentrantLock类的核心用法(三个方法):
①lock()加锁;
②unlock()解锁;
③tryLock()试试看能否加锁,成功即加锁,不成功就不加 - ReentrantLock的 缺点:如果在lock() 和 unlock() 之间有return或者是有异常,就可能执行不到unlock了;而synchronized没有该风险,只要代码出了代码块就一定执行解锁。
(即:synchronized 使用时不需要手动释放锁。ReentrantLock 使用时需要手动释放. 使用起来更灵活, 但是也容易遗漏 unlock) - 有些特定的功能是synchronized做不到的。也就是ReentrantLock优势:
1)tryLock()试试看能否加锁,试成功即加锁,试失败就放弃不加;并且还可以设定加锁的等待超时时间。(实际开发中使用“死等”策略要慎重)
(即:synchronized 在申请锁失败时, 会死等。ReentrantLock 可以通过 trylock 的方式等待一段时间就放弃)
2)ReentrantLock 可以实现公平锁,本来默认是非公平锁。传入一个参数true就是公平锁(“先来后到”)
3)synchronized是搭配wait/notify实现等待通知机制的,唤醒操作是随机唤醒一个等待的线程。而 ReentrantLock搭配Condition类实现的,唤醒操作是可以指定哪个线程等待的线程被唤醒的。 - 网上资料还有一个区别:synchronized是java关键字,底层是JVM实现的(也就是大概率通过C++实现的);而ReentrantLock是标准库中的一个类,底层是基于java实现的。
43. Executors (工厂类)创建线程池的几种方式
① newFixedThreadPool: 创建固定线程数的线程池
② newCachedThreadPool: 创建线程数目动态增长的线程池.
③ newSingleThreadExecutor: 创建只包含单个线程的线程池.
④ newScheduledThreadPool: 设定延迟时间后执行命令,或者定期执行命令。 是进阶版的 Timer.
Executors 本质上是 ThreadPoolExecutor
类的封装
44. 信号量Semaphore
- 信号量本身是一个计数器,表示可用资源的个数。
① P操作申请一个资源,可用资源数-1
② V操作释放一个资源,可用资源数+1. - 当计数为0的时候继续P操作就会产生阻塞,阻塞等待到其他线程V操作了为止(基于信号量也是可以实现“生产者消费者模型”)
- 当计数为0的时候继续P操作就会产生阻塞,阻塞等待到其他线程V操作了为止(基于信号量也是可以实现“生产者消费者模型”)
- 注意:标准库中的acquire就是P操作申请资源, release就是V操作释放资源
- 当需求中就是有多个可用资源的时候,就要记得使用信号量Semaphore
45. JUC:标准库提供的多线程安全相关的包
1.Callable声明一个带返回值的任务,需要搭配FutureTask
2.ReentrantLock容易遗漏解锁操作,可以实现公平锁,还可以实现tryLock,还可以搭配Condition来唤醒指定的等待线程
3.原子类:基于CAS实现的,能够比较高效的完成线程安全的自增自减
4.线程池
5.信号量Semaphore:这是广义的锁,相当于计数器,描述了可用资源的个数。
P操作:申请资源,计数器-1; V操作:释放资源,计数器+1。
如果计数器的值被减成0时,继续P操作,则会产生阻塞。
- CountDownLatch
1)使用CountDownLatch就是类似于跑步比赛,使用的时候先设定选手/线程的个数,每个选手撞线(完成工作)就调用以下countDown方法,当撞线次数达到选手的个数就结束比赛。(也就是说,要等到最后一个选手到达/最后一个线程也完成任务 才真正结束)
2)如使用多线程完成任务:要下载一个很大的文件,此时就切分成很多个部分,每个线程负责下载其中的一部分,当所有的线程都下载完毕,整个文件才算下载完成。
46. 线程同步的方式有哪些?
答:synchronized, ReentrantLock, Semaphore 等都可以用于线程同步。
47. 为什么有了 synchronized 还需要 juc 下的 lock?
答:以 juc 的 ReentrantLock 为例,
① synchronized 使用时不需要手动释放锁,ReentrantLock 使用时需要手动释放, 使用起来更灵活。
② synchronized 在申请锁失败时, 会死等。 ReentrantLock 可以通过 trylock 的方式等待一段时间就放弃。
③ synchronized 是非公平锁, ReentrantLock 默认是非公平锁. 可以通过构造方法传入一个true 开启公平锁模式。
④ synchronized 是通过 Object 的 wait / notify 实现等待-唤醒, 每次唤醒的是一个随机等待的线程。 ReentrantLock 搭配 Condition 类实现等待-唤醒, 可以更精确控制唤醒某个指定的线程。
48. AtomicInteger 的实现原理是什么?
答: 参考:CAS的应用:原子类
主要会用于类似于count++的形式
(主要看伪代码那儿:比较相同并赋值)
49. 信号量听说过么?之前都用在过哪些场景下?
答:① 信号量, 用来表示 “可用资源的个数”, 本质上就是一个计数器。
② 使用信号量可以实现 “共享锁”, 比如某个资源允许 3 个线程同时使用, 那么就可以使用 P 操作作为加锁, V 操作作为解锁, 前三个线程的 P 操作都能顺利返回, 后续线程再进行 P 操作就会阻塞等待, 直到前面的线程执行了 V 操作。
50. 多线程环境使用哈希表
- HashTable是线程安全的,但是不推荐使用,其是属于无脑给各种方法加synchronized
- ConcurrentHashMap线程安全的,推荐使用,背后做了很多优化。(多线程下直接无脑使用,单线程使用HashMap)
- HashTable只是简单的把关键方法加上了 synchronized 关键字。
51. ConcurrentHashMap的优化策略
-
锁粒度的控制
1) HashTable直接在方法上加synchronized,相当于是对this加锁,即相当于是针对哈希表对象来加锁的,所以,一个哈希表只有一个锁。多个线程的时候,无论这些线程是如何操作这个哈希表的,都会产生锁冲突。
2) HashTable只有一把锁,而ConcurrentHashMap不是一把锁,而是多把锁,然后给每个哈希桶(哈希桶就是哈希表下的一个链表)都加一把锁。
3) 如果两个线程同时访问一个哈希桶的时候才会锁冲突,但如果不是同一个哈希桶就不会锁冲突了。
4)由于哈希桶个数很多,此时恰好两个线程操作同一个哈希桶的概率就大大降低了,因此锁冲突的概率就降低了,大大提升了性能。(锁冲突对性能影响是很大的。)
ConcurrentHashMap的每个哈希桶都有自己的锁,大大降低了锁冲突的概率,性能也就大大提升了。 - ConcurrentHashMap做了一个激进的操作:只给写操作加锁,读操作不加锁。
1)也就是说:两个线程同时修改才会有锁冲突。两个线程同时读是没有锁冲突的; 一个线程读、一个线程写也是没有锁冲突的。
2) 那么 “一个线程读、一个线程写没有锁冲突” 这个操作是否会有线程安全问题?
不一定,我们主要担心的是读的结果是一个修改了一半的数据。
但是ConcurrentHashMap在设计的时候慎重考虑了这一点,在读的时候能够保证读到的是囫囵个的数据,也就是说要么是旧版本、要么是新版本,不可能是写到一半的数据。
另外,读操作中也广泛使用了volatile关键字来保证读到的数据是及时的。
-
充分利用了CAS的特性
1)比如像维护元素个数,都是通过CAS来实现的,而不是加锁; 包括还有一些地方也是直接使用CAS实现的轻量级锁来实现。
2)虽然synchronized内部已经有很多的优化了,但是终究这里的优化是JVM内部的,程序员不可控; 而ConcurrentHashMap的思路就是能不加锁就不加锁)
3)则ConcurrentHashMap的核心优化思路:尽一切可能降低锁冲突的概率!
但凡涉及到很多加锁操作,代码就基本和“高性能”/运行效率/高并发 无缘了。
(性能固然重要,但是相比之下,代码的正确性、开发效率才是更重要的) - ConcurrentHashMap对于扩容操作也进行了特殊的优化:化整为零(有点儿类似拷贝)
1)HashTable的扩容:当put元素的时候,如果发现当前的负载因子(元素个数/哈希桶的个数)已经超过阈值就需要进行扩容,即申请一个更大的数组,把之前旧的数据给搬运到新的数组上。有一个大问题:如果元素个数很多,则搬运操作就会开销很大。执行一个put操作,正常一个put瞬间完成(哈希表的O(1)特性),但是触发扩容的这一下put可能就会卡很久。
2)ConcurrentHashMap在扩容的时候就不是一次性搬运了,而是一次搬运一点儿。
在触发扩容的时候创建一个新的数组,在扩容期间内,旧的和新的会同时存在一段时间,每次进行哈希表操作的时候都会把旧的内存上的元素搬运一部分到新的空间上,直到最终搬运完成,然后再释放旧的空间。
在这个过程中如果要查询元素,则旧的和新的一起查;如果是插入元素,直接往新的上面插;如果是删除元素,直接删了就不用搬运了。
52. HashMap、HashTable、ConcurrentHashMap的区别:(顺序一定要搞对,①②最重要!!)
答:① HashMap线程不安全,HashTable、ConcurrentHashMap 线程安全。
② HashTable、ConcurrentHashMap虽然都是线程安全,但是有很多差别:锁粒度的控制(一把、很多锁),ConcurrentHashMap写操作加锁、读操作不加锁,ConcurrentHashMap利用了CAS特性,ConcurrentHashMap扩容优化:化整为零。
③ 旧版本(jdk1.8之前不包含1.8)的ConcurrentHashMap的实现是分段锁,而新版本(jdk1.8开始)的ConcurrentHashMap是每个链表分一个锁。 【分段锁:好几个链表共用同一把锁。 但是分段锁的锁冲突概率要比每个链表加一把锁更高,代码实现也更复杂】
④ HashMap的 key 允许为null(HashMap是无序的!!TreeMap是有序的),HashTable、ConcurrentHashMap的key不能为null。
53. ConcurrentHashMap的读是否要加锁,为什么?
答: 读操作没有加锁。目的是为了进一步降低锁冲突的概率, 为了保证读到刚修改的数据, 搭配了volatile 关键字。
54. 介绍下 ConcurrentHashMap的锁分段技术?
答:① 这个是 Java1.7 中采取的技术, Java1.8 中已经不再使用了。
② 简单的说就是把若干个哈希桶分成一个"段" (Segment), 针对每个段分别加锁。
③ 目的也是为了降低锁竞争的概率。 当两个线程访问的数据恰好在同一个段上的时候, 才触发锁竞争。
55. ConcurrentHashMap在jdk1.8做了哪些优化?
答: ① 取消了分段锁, 直接给每个哈希桶(每个链表)分配了一个锁(就是以每个链表的头结点对象作为锁对象)。
② 将原来 数组 + 链表 的实现方式改进成 数组 + 链表 / 红黑树 的方式. 当链表较长的时候(大于等于8 个元素)就转换成红黑树。
56. 【教科书给出死锁的四个必要条件】
① 互斥使用:锁A被线程1占用,线程2就用不了 (打破不了,锁的基本特性)
② 不可抢占:锁A被线程1占用,线程2就不能把锁A给抢过来,除非线程1释放锁(打破不了,锁的基本特性)
③ 请求和保持:有多把锁,线程1拿到锁A之后,不想释放锁A,还想请求再拿到一个锁B(取决于代码:获取锁B的时候是否释放锁A,有可能打破,但是不普适。主要看需求场景是否允许这么写)
④ 循环等待:线程1等待线程2释放锁,线程2释放锁得等待线程3释放锁,线程3释放锁得等待线程1释放锁 (有把握打破:约定好加锁顺序就可以打破循环等待)
57. 死锁相关
- 死锁:一个线程加上锁之后解不开了,也就是程序僵住了。
- 死锁多个线程、多把锁,更容易死锁。 描述该死锁场景的一个典型问题:哲学家就餐。
- 学校操作系统针对死锁给出的方案是“银行家算法”(把所有资源统一进行统筹分配),也能避免死锁,是一个更普适的方案,但是比较复杂,不太适合实际开发
58. 谈谈 volatile关键字的用法?
答:volatile 能够保证内存可见性, 强制从主内存中读取数据。 此时如果有其他线程修改被 volatile 修饰的变量, 可以第一时间读取到最新的值。
59. Java多线程是如何实现数据共享的?
答:① JVM 把内存分成了这几个区域:方法区, 堆区, 栈区, 程序计数器。
② 其中堆区这个内存区域是多个线程之间共享的。 只要把某个数据放到堆内存中, 就可以让多个线程都能访问到。
60. Java创建线程池的接口是什么?参数LinkedBlockingQueue 的作用是什么?
答:1)创建线程池主要有两种方式:
① 通过 Executors 工厂类创建, 创建方式比较简单, 但是定制能力有限.
② 通过 ThreadPoolExecutor 创建, 创建方式比较复杂, 但是定制能力强.
2)LinkedBlockingQueue 表示线程池的任务队列, 用户通过 submit / execute 向这个任务队列中添加任务, 再由线程池中的工作线程来执行任务。
61. Java线程共有几种状态?状态之间怎么切换的?
答:① NEW: 安排了工作, 还未开始行动。 新创建的线程, 还没有调用 start 方法时处在这个状态.
② RUNNABLE: 可工作的。 又可以分成正在工作中和即将开始工作。 调用 start 方法之后, 并正在CPU 上运行/在即将准备运行 的状态。
③ BLOCKED: 使用 synchronized 的时候, 如果锁被其他线程占用, 就会阻塞等待, 从而进入该状态.
④ WAITING: 调用 wait 方法会进入该状态.
⑤ TIMED_WAITING: 调用 sleep 方法或者 wait(超时时间) 会进入该状态.
⑥ TERMINATED: 工作完成了。 当线程 run 方法执行完毕后, 会处于这个状态.(线程销毁,对象还在)
62. 在多线程下,如果对一个数进行叠加,该怎么做?
答:① 使用 synchronized / ReentrantLock 加锁
② 使用 AtomInteger 原子操作
63. Servlet是否是线程安全的?
答: Servlet 本身是工作在多线程环境下。
如果在 Servlet 中创建了某个成员变量, 此时如果有多个请求到达服务器, 服务器就会多线程进行操作, 是可能出现线程不安全的情况的。
64. Thread和Runnable的区别和联系?
答:① Thread 类描述了一个线程,Runnable 描述了一个任务。
② 在创建线程的时候需要指定线程完成的任务, 可以直接重写 Thread 的 run 方法, 也可以使用Runnable 来描述这个任务。
65. 多次start一个线程会怎么样?
答: ① 第一次调用 start 可以成功调用.
② 后续再调用 start 会抛出java.lang.IllegalThreadStateException 异常。
66. 有synchronized两个方法,两个线程分别同时用这个方法,请问会发生什么?
答:① synchronized 加在非静态方法上, 相当于针对当前对象加锁。
② 如果这两个方法属于同一个实例:
线程1 能够获取到锁, 并执行方法. 线程2 会阻塞等待, 直到线程1 执行完毕释放锁, 线程2 获取到锁之后才能执行方法内容。
③ 如果这两个方法属于不同实例:
两者能并发执行, 互不干扰。文章来源:https://www.toymoban.com/news/detail-842011.html
67. 进程和线程的区别?
答: ① 进程是包含线程的, 每个进程至少有一个线程存在,即主线程。
② 进程和进程之间不共享内存空间, 同一个进程的线程之间共享同一个内存空间。
③ 进程是系统分配资源的最小单位,线程是系统调度的最小单位。文章来源地址https://www.toymoban.com/news/detail-842011.html
到了这里,关于【Java】常见面试题:多线程的文章就介绍完了。如果您还想了解更多内容,请在右上角搜索TOY模板网以前的文章或继续浏览下面的相关文章,希望大家以后多多支持TOY模板网!