数据结构论坛

首页 » 分类 » 问答 » 阿里面试官首次分享完整版多线程核心题,你
TUhjnbcbe - 2024/10/22 15:55:00
中科公益爱心 https://m.39.net/pf/a_6213643.html

今日分享开始啦,请大家多多指教~

今天给大家分享的是比较全面的多线程面试题,大家在面试的过程中不免会被问到很多专业性的问题,有的时候回答的并不是那么全面和精细,这仅仅代表个人观点。

1.如何预防死锁?

1.首先需要将死锁发生的是个必要条件讲出来:

互斥条件同一时间只能有一个线程获取资源。不可剥夺条件一个线程已经占有的资源,在释放之前不会被其它线程抢占请求和保持条件线程等待过程中不会释放已占有的资源循环等待条件多个线程互相等待对方释放资源2.死锁预防,那么就是需要破坏这四个必要条件:

由于资源互斥是资源使用的固有特性,无法改变,我们不讨论破坏不可剥夺条件一个进程不能获得所需要的全部资源时便处于等待状态,等待期间他占有的资源将被隐式地释放重新加入到系统的资源列表中,可以被其他的进程使用,而等待的进程只有重新获得自己原有的资源以及新申请的资源才可以重新启动,执行

破坏请求与保持条件第一种方法静态分配即每个进程在开始执行时就申请他所需要的全部资源,

第二种是动态分配即每个进程在申请所需要的资源时他本身不占用系统资源

破坏循环等待条件采用资源有序分配其基本思想是将系统中的所有资源顺序编号,将紧缺的,稀少地采用较大的编号,在申请资源时必须按照编号的顺序进行,一个进程只有获得较小编号的进程才能申请较大编号的进程。

2.多线程有哪几种创建方式?

实现Runnable,Runnable规定的方法是run(),无返回值,无法抛出异常实现Callable,Callable规定的方法是call(),任务执行后有返回值,可以抛出异常继承Thread类创建多线程:继承java.lang.Thread类,重写Thread类的run()方法,在run()方法中实现运行在线程上的代码,调用start()方法开启线程。Thread类本质上是实现了Runnable接口的一个实例,代表一个线程的实例。启动线程的唯一方法就是通过Thread类的start()实例方法。start()方法是一个native方法,它将启动一个新线程,并执行run()方法通过线程池创建线程.线程和数据库连接这些资源都是非常宝贵的资源。那么每次需要的时候创建,不需要的时候销毁,是非常浪费资源的。那么我们就可以使用缓存的策略,也就是使用线程池。3.描述一下线程安全活跃态问题,竞态条件?

线程安全的活跃性问题可以分为死锁、活锁、饥饿

1.活锁就是有时线程虽然没有发生阻塞,但是仍然会存在执行不下去的情况,活锁不会阻塞线程,线程会一直重复执行某个相同的操作,并且一直失败重试

我们开发中使用的异步消息队列就有可能造成活锁的问题,在消息队列的消费端如果没有正确的ack消息,并且执行过程中报错了,就会再次放回消息头,然后再拿出来执行,一直循环往复的失败。这个问题除了正确的ack之外,往往是通过将失败的消息放入到延时队列中,等到一定的延时再进行重试来解决。解决活锁的方案很简单,尝试等待一个随机的时间就可以,会按时间轮去重试2.饥饿就是线程因无法访问所需资源而无法执行下去的情况,

饥饿分为两种情况:

一种是其他的线程在临界区做了无限循环或无限制等待资源的操作,让其他的线程一直不能拿到锁进入临界区,对其他线程来说,就进入了饥饿状态另一种是因为线程优先级不合理的分配,导致部分线程始终无法获取到CPU资源而一直无法执行3.解决饥饿的问题有几种方案:

保证资源充足,很多场景下,资源的稀缺性无法解决公平分配资源,在并发编程里使用公平锁,例如FIFO策略,线程等待是有顺序的,排在等待队列前面的线程会优先获得资源避免持有锁的线程长时间执行,很多场景下,持有锁的线程的执行时间也很难缩短4.死锁线程在对同一把锁进行竞争的时候,未抢占到锁的线程会等待持有锁的线程释放锁后继续抢占,如果两个或两个以上的线程互相持有对方将要抢占的锁,互相等待对方先行释放锁就会进入到一个循环等待的过程,这个过程就叫做死锁

线程安全的竞态条件问题同一个程序多线程访问同一个资源,如果对资源的访问顺序敏感,就称存在竞态条件,代码区成为临界区。大多数并发错误一样,竞态条件不总是会产生问题,还需要不恰当的执行时序

最常见的竞态条件为先检测后执行执行依赖于检测的结果,而检测结果依赖于多个线程的执行时序,而多个线程的执行时序通常情况下是不固定不可判断的,从而导致执行结果出现各种问题,见一种可能的解决办法就是:在一个线程修改访问一个状态时,要防止其他线程访问修改,也就是加锁机制,保证原子性延迟初始化(典型为单例)4.Java中的wait和sleep的区别与联系?

1.所属类:首先,这两个方法来自不同的类分别是Thread和Object,wait是Object的方法,sleep是Thread的方法

sleep方法属于Thread类中方法,表示让一个线程进入睡眠状态,等待一定的时间之后,自动醒来进入到可运行状态,不会马上进入运行状态,因为线程调度机制恢复线程的运行也需要时间,一个线程对象调用了sleep方法之后,并不会释放他所持有的所有对象锁,所以也就不会影响其他进程对象的运行。但在sleep的过程中过程中有可能被其他对象调用它的interrupt(),产生InterruptedException异常,如果你的程序不捕获这个异常,线程就会异常终止,进入TERMINATED状态,如果你的程序捕获了这个异常,那么程序就会继续执行catch语句块(可能还有finally语句块)以及以后的代码

2.作用范围:sleep方法没有释放锁,只是休眠,而wait释放了锁,使得其他线程可以使用同步控制块或方法

3.使用范围:wait,notify和notifyAll只能在同步控制方法或者同步控制块里面使用,而sleep可以在任何地方使用

4.异常范围:sleep必须捕获异常,而wait,notify和notifyAll不需要捕获异常

5.描述一下进程与线程区别?

1.进程(Process)

是系统进行资源分配和调度的基本单位,是操作系统结构的基础。在当代面向线程设计的计算机结构中,进程是线程的容器。程序是指令、数据及其组织形式的描述,进程是程序的实体。是计算机中的程序关于某数据集合上的一次运行活动,是系统进行资源分配和调度的基本单位,是操作系统结构的基础。程序是指令、数据及其组织形式的描述,进程是程序的实体。总结:j进程是指在系统中正在运行的一个应用程序;程序一旦运行就是进程;进程——资源分配的最小单位

2.线程

操作系统能够进行运算调度的最小单位。它被包含在进程之中,是进程中的实际运作单位。一条线程指的是进程中一个单一顺序的控制流,一个进程中可以并发多个线程,每条线程并行执行不同的任务。总结:系统分配处理器时间资源的基本单元,或者说进程之内独立执行的一个单元执行流。线程——程序执行的最小单位

6.描述一下Java线程的生命周期?

大致包括5个阶段

新建就是刚使用new方法,new出来的线程;就绪就是调用的线程的start()方法后,这时候线程处于等待CPU分配资源阶段,谁先抢的CPU资源,谁开始执行;运行当就绪的线程被调度并获得CPU资源时,便进入运行状态,run方法定义了线程的操作和功能;阻塞在运行状态的时候,可能因为某些原因导致运行状态的线程变成了阻塞状态,比如sleep()、wait()之后线程就处于了阻塞状态,这个时候需要其他机制将处于阻塞状态的线程唤醒,比如调用notify或者notifyAll()方法。唤醒的线程不会立刻执行run方法,它们要再次等待CPU分配资源进入运行状态;销毁如果线程正常执行完毕后或线程被提前强制性的终止或出现异常导致结束,那么线程就要被销毁,释放资源

1.按JDK的源码分析来看,Thread的状态分为:

NEW:尚未启动的线程的线程状态RUNNABLE:处于可运行状态的线程正在Java虚拟机中执行,但它可能正在等待来自操作系统(例如处理器)的其他资源BLOCKED:线程的线程状态被阻塞,等待监视器锁定。处于阻塞状态的线程正在等待监视器锁定以输入同步的块方法或在调用后重新输入同步的块方法,通过Object#wait()进入阻塞WAITING:处于等待状态的线程正在等待另一个线程执行特定操作:例如:在对象上调用了Object.wait()的线程正在等待另一个线程调用Object.notify()或者Object.notifyAll(),调用了Thread.join()的线程正在等待指定的线程终止TIMED_WAITING:具有指定等待时间的等待线程的线程状态。由于以指定的正等待时间调用以下方法之一,因此线程处于定时等待状态:Thread.sleep(long)Object#wait(long)Thread.join(long)LockSupport.parkNanos(long…)LockSupport.parkUntil(long…)2.TERMINATED:终止线程的线程状态。线程已完成执行

7.程序开多少线程合适?

1.这里需要区别下应用是什么样的程序:

CPU密集型程序,一个完整请求,I/O操作可以在很短时间内完成,CPU还有很多运算要处理,也就是说CPU计算的比例占很大一部分,线程等待时间接近0

单核CPU:一个完整请求,I/O操作可以在很短时间内完成,CPU还有很多运算要处理,也就是说CPU计算的比例占很大一部分,线程等待时间接近0。单核CPU处理CPU密集型程序,这种情况并不太适合使用多线程多核:如果是多核CPU处理CPU密集型程序,我们完全可以最大化地利用CPU核心数,应用并发编程来提高效率。CPU密集型程序的最佳线程数就是:因此对于CPU密集型来说,理论上线程数量=CPU核数(逻辑),但是实际上,数量一般会设置为CPU核数(逻辑)+1(经验值)计算(CPU)密集型的线程恰好在某时因为发生一个页错误或者因其他原因而暂停,刚好有一个“额外”的线程,可以确保在这种情况下CPU周期不会中断工作2.I/O密集型程序,与CPU密集型程序相对,一个完整请求,CPU运算操作完成之后还有很多I/O操作要做,也就是说I/O操作占比很大部分,等待时间较长,线程等待时间所占比例越高,需要越多线程;线程CPU时间所占比例越高,需要越少线程

I/O密集型程序的最佳线程数就是:最佳线程数=CPU核心数(1/CPU利用率)=CPU核心数(1+(I/O耗时/CPU耗时))如果几乎全是I/O耗时,那么CPU耗时就无限趋近于0,所以纯理论你就可以说是2N(N=CPU核数),当然也有说2N+1的,1应该是backup一般我们说2N+1就即可8.描述一下notify和notifyAll区别?

1.首先最好说一下锁池和等待池的概念

锁池:假设线程A已经拥有了某个对象(注意:不是类)的锁,而其它的线程想要调用这个对象的某个synchronized方法(或者synchronized块),由于这些线程在进入对象的synchronized方法之前必须先获得该对象的锁的拥有权,但是该对象的锁目前正被线程A拥有,所以这些线程就进入了该对象的锁池中。等待池:假设一个线程A调用了某个对象的wait()方法,线程A就会释放该对象的锁(因为wait()方法必须出现在synchronized中,这样自然在执行wait()方法之前线程A就已经拥有了该对象的锁),同时线程A就进入到了该对象的等待池中。如果另外的一个线程调用了相同对象的notifyAll()方法,那么处于该对象的等待池中的线程就会全部进入该对象的锁池中,准备争夺锁的拥有权。如果另外的一个线程调用了相同对象的notify()方法,那么仅仅有一个处于该对象的等待池中的线程(随机)会进入该对象的锁池.2.如果线程调用了对象的wait()方法,那么线程便会处于该对象的等待池中,等待池中的线程不会去竞争该对象的锁

3.当有线程调用了对象的notifyAll()方法(唤醒所有wait线程)或notify()方法(只随机唤醒一个wait线程),被唤醒的的线程便会进入该对象的锁池中,锁池中的线程会去竞争该对象锁。也就是说,调用了notify后只要一个线程会由等待池进入锁池,而otifyAll会将该对象等待池内的所有线程移动到锁池中,等待锁竞争

4.所谓唤醒线程,另一种解释可以说是将线程由等待池移动到锁池,notifyAll调用后,会将全部线程由等待池移到锁池,然后参与锁的竞争,竞争成功则继续执行,如果不成功则留在锁池等待锁被释放后再次参与竞争。而notify只会唤醒一个线程。

9.描述一下synchronized和lock区别?

如下表

可以再多说下synchronized的加锁流程

由于HotSpot的作者经过研究发现,大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低从而引入偏向锁。偏向锁在获取资源的时候会在锁对象头上记录当前线程ID,偏向锁并不会主动释放,这样每次偏向锁进入的时候都会判断锁对象头中线程ID是否为自己,如果是当前线程重入,直接进入同步操作,不需要额外的操作。默认在开启偏向锁和轻量锁的情况下,当线程进来时,首先会加上偏向锁,其实这里只是用一个状态来控制,会记录加锁的线程,如果是线程重入,则不会进行锁升级。获取偏向锁

流程:

1.判断是否为可偏向状态--MarkWord中锁标志是否为‘01’,是否偏向锁是否为‘1’

2.如果是可偏向状态,则查看线程ID是否为当前线程,如果是,则进入步骤V,否则进入步骤‘III’

3.通过CAS操作竞争锁,如果竞争成功,则将MarkWord中线程ID设置为当前线程ID,然后执行‘V’;竞争失败,则执行‘IV’

4.CAS获取偏向锁失败表示有竞争。当达到safepoint时获得偏向锁的线程被挂起,偏向锁升级为轻量级锁,然后被阻塞在安全点的线程继续往下执行同步代码块

5.执行同步代码

轻量级锁是相对于重量级锁需要阻塞/唤醒涉及上下文切换而言,主要针对多个线程在不同时间请求同一把锁的场景。轻量级锁获取过程:

进行加锁操作时,jvm会判断是否已经是重量级锁,如果不是,则会在当前线程栈帧中划出一块空间,作为该锁的锁记录,并且将锁对象MarkWord复制到该锁记录中复制成功之后,jvm使用CAS操作将对象头MarkWord更新为指向锁记录的指针,并将锁记录里的owner指针指向对象头的MarkWord。如果成功,则执行‘III’,否则执行‘IV’更新成功,则当前线程持有该对象锁,并且对象MarkWord锁标志设置为‘00’,即表示此对象处于轻量级锁状态更新失败,jvm先检查对象MarkWord是否指向当前线程栈帧中的锁记录,如果是则执行‘V’,否则执行‘VI’表示锁重入;然后当前线程栈帧中增加一个锁记录第一部分(DisplacedMarkWord)为null,并指向MarkWord的锁对象,起到一个重入计数器的作用表示该锁对象已经被其他线程抢占,则进行自旋等待(默认10次),等待次数达到阈值仍未获取到锁,则升级为重量级锁当有多个锁竞争轻量级锁则会升级为重量级锁,重量级锁正常会进入一个cxq的队列,在调用wait方法之后,则会进入一个waitSet的队列park等待,而当调用notify方法唤醒之后,则有可能进入EntryList。重量级锁加锁过程:

分配一个ObjectMonitor对象,把MarkWord锁标志置为‘10’,然后MarkWord存储指向ObjectMonitor对象的指针。ObjectMonitor对象有两个队列和一个指针,每个需要获取锁的线程都包装成ObjectWaiter对象多个线程同时执行同一段同步代码时,ObjectWaiter先进入EntryList队列,当某个线程获取到对象的monitor以后进入Owner区域,并把monitor中的owner变量设置为当前线程同时monitor中的计数器count+1;10.简单描述一下ABA问题?

1.有两个线程同时去修改一个变量的值,比如线程1、线程2,都更新变量值,将变量值从A更新成B。

2.首先线程1、获取到CPU的时间片,线程2由于某些原因发生阻塞进行等待,此时线程1进行比较更新(CompareAndSwap),成功将变量的值从A更新成B。

3.更新完毕之后,恰好又有线程3进来想要把变量的值从B更新成A,线程3进行比较更新,成功将变量的值从B更新成A。4.线程2获取到CPU的时间片,然后进行比较更新,发现值是预期的A,然后又更新成了B。但是线程1并不知道,该值已经有了A-B-A这个过程,这也就是我们常说的ABA问题。

4.可以通过加版本号或者加时间戳解决,或者保证单向递增或者递减就不会存在此类问题。

11.实现一下DCL?

12.实现一个阻塞队列(用Condition写生产者与消费者就)?

13.实现多个线程顺序打印abc?

14.服务器CPU数量及线程池线程数量的关系?

首先确认业务是CPU密集型还是IO密集型的,

如果是CPU密集型的,那么就应该尽量少的线程数量,一般为CPU的核数+1;

如果是IO密集型:所以可多分配一点cpu核数*2也可以使用公式:CPU核数/(1-阻塞系数);其中阻塞系数在0.8~0.9之间。

15.多线程之间是如何通信的?

1、通过共享变量,变量需要volatile修饰

2、使用wait()和notifyAll()方法,但是由于需要使用同一把锁,所以必须通知线程释放锁,被通知线程才能获取到锁,这样导致通知不及时。

3、使用CountDownLatch实现,通知线程到指定条件,调用countDownLatch.countDown(),被通知线程进行countDownLatch.await()。

4、使用Condition的await()和signalAll()方法。

16.synchronized关键字加在静态方法和实例方法的区别?

修饰静态方法,是对类进行加锁,如果该类中有methodA和methodB都是被synchronized修饰的静态方法,此时有两个线程T1、T2分别调用methodA()和methodB(),则T2会阻塞等待直到T1执行完成之后才能执行。

修饰实例方法时,是对实例进行加锁,锁的是实例对象的对象头,如果调用同一个对象的两个不同的被synchronized修饰的实例方法时,看到的效果和上面的一样,如果调用不同对象的两个不同的被synchronized修饰的实例方法时,则不会阻塞。

17.countdownlatch的用法?

两种用法:

1、让主线程await,业务线程进行业务处理,处理完成时调用countdownLatch.countDown(),CountDownLatch实例化的时候需要根据业务去选择CountDownLatch的count;

2、让业务线程await,主线程处理完数据之后进行countdownLatch.countDown(),此时业务线程被唤醒,然后去主线程拿数据,或者执行自己的业务逻辑。

18.线程池问题:

(1)Executor提供了几种线程池

1、newCachedThreadPool()(工作队列使用的是SynchronousQueue)

创建一个线程池,如果线程池中的线程数量过大,它可以有效地回收多余的线程,如果线程数不足,那么它可以创建新的线程。

不足:这种方式虽然可以根据业务场景自动的扩展线程数来处理我们的业务,但是最多需要多少个线程同时处理却是我们无法控制的。

优点:如果当第二个任务开始,第一个任务已经执行结束,那么第二个任务会复用第一个任务创建的线程,并不会重新创建新的线程,提高了线程的复用率。

作用:该方法返回一个可以根据实际情况调整线程池中线程的数量的线程池。即该线程池中的线程数量不确定,是根据实际情况动态调整的。

2、newFixedThreadPool()(工作队列使用的是LinkedBlockingQueue)

这种方式可以指定线程池中的线程数。如果满了后又来了新任务,此时只能排队等待。

优点:newFixedThreadPool的线程数是可以进行控制的,因此我们可以通过控制最大线程来使我们的服务器达到最大的使用率,同时又可以保证即使流量突然增大也不会占用服务器过多的资源。

作用:该方法返回一个固定线程数量的线程池,该线程池中的线程数量始终不变,即不会再创建新的线程,也不会销毁已经创建好的线程,自始自终都是那几个固定的线程在工作,所以该线程池可以控制线程的最大并发数

3、newScheduledThreadPool()

该线程池支持定时,以及周期性的任务执行,我们可以延迟任务的执行时间,也可以设置一个周期性的时间让任务重复执行。该线程池中有以下两种延迟的方法。

scheduleAtFixedRate不同的地方是任务的执行时间,如果间隔时间大于任务的执行时间,任务不受执行时间的影响。如果间隔时间小于任务的执行时间,那么任务执行结束之后,会立马执行,至此间隔时间就会被打乱。

scheduleWithFixedDelay的间隔时间不会受任务执行时间长短的影响。

作用:该方法返回一个可以控制线程池内线程定时或周期性执行某任务的线程池。

4、newSingleThreadExecutor()

这是一个单线程池,至始至终都由一个线程来执行。

作用:该方法返回一个只有一个线程的线程池,即每次只能执行一个线程任务,多余的任务会保存到一个任务队列中,等待这一个线程空闲,当这个线程空闲了再按FIFO方式顺序执行任务队列中的任务。

5、newSingleThreadScheduledExecutor()只有一个线程,用来调度任务在指定时间执行。作用:该方法返回一个可以控制线程池内线程定时或周期性执行某任务的线程池。只不过和上面的区别是该线程池大小为1,而上面的可以指定线程池的大小。

(2)线程池的参数

intcorePoolSize,//线程池核心线程大小

intmaximumPoolSize,//线程池最大线程数量

longkeepAliveTime,//空闲线程存活时间

TimeUnitunit,//空闲线程存活时间单位,一共有七种静态属性(TimeUnit.DAYS天,TimeUnit.HOURS小时,TimeUnit.MINUTES分钟,TimeUnit.SECONDS秒,TimeUnit.MILLISECONDS毫秒,TimeUnit.MICROSECONDS微妙,TimeUnit.NANOSECONDS纳秒)

BlockingQueueworkQueue,//工作队列

ThreadFactorythreadFactory,//线程工厂,主要用来创建线程(默认的工厂方法是:Executors.defaultThreadFactory()对线程进行安全检查并命名)

RejectedExecutionHandlerhandler//拒绝策略(默认是:ThreadPoolExecutor.AbortPolicy不执行并抛出异常)

(3)拒绝策略

当工作队列中的任务已到达最大限制,并且线程池中的线程数量也达到最大限制,这时如果有新任务提交进来,就会执行拒绝策略。

jdk中提供了4种拒绝策略:

①ThreadPoolExecutor.CallerRunsPolicy:该策略下,在调用者线程中直接执行被拒绝任务的run方法,除非线程池已经shutdown,则直接抛弃任务。

②ThreadPoolExecutor.AbortPolicy:该策略下,直接丢弃任务,并抛出RejectedExecutionException异常。

③ThreadPoolExecutor.DiscardPolicy:该策略下,直接丢弃任务,什么都不做。

④ThreadPoolExecutor.DiscardOldestPolicy:该策略下,抛弃进入队列最早的那个任务,然后尝试把这次拒绝的任务放入队列。

除此之外,还可以根据应用场景需要来实现RejectedExecutionHandler接口自定义策略。

(4)任务放置的顺序过程

任务调度是线程池的主要入口,当用户提交了一个任务,接下来这个任务将如何执行都是由这个阶段决定的。了解这部分就相当于了解了线程池的核心运行机制。

首先,所有任务的调度都是由execute方法完成的,这部分完成的工作是:检查现在线程池的运行状态、运行线程数、运行策略,决定接下来执行的流程,是直接申请线程执行,或是缓冲到队列中执行,亦或是直接拒绝该任务。其执行过程如下:

首先检测线程池运行状态,如果不是RUNNING,则直接拒绝,线程池要保证在RUNNING的状态下执行任务。

如果workerCountcorePoolSize,则创建并启动一个线程来执行新提交的任务。

如果workerCount=corePoolSize,且线程池内的阻塞队列未满,则将任务添加到该阻塞队列中。

如果workerCount=corePoolSizeworkerCountmaximumPoolSize,且线程池内的阻塞队列已满,则创建并启动一个线程来执行新提交的任务。

如果workerCount=maximumPoolSize,并且线程池内的阻塞队列已满,则根据拒绝策略来处理该任务,默认的处理方式是直接抛异常。

其执行流程如下图所示:

(5)任务结束后会不会回收线程

finalvoidrunWorker(Workerw){Threadwt=Thread.currentThread();Runnabletask=w.firstTask;w.firstTask=null;w.unlock();//allowinterruptsboolean

1
查看完整版本: 阿里面试官首次分享完整版多线程核心题,你