常见面试问题整理系列之--多线程
Posted yuanfei1110111
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了常见面试问题整理系列之--多线程相关的知识,希望对你有一定的参考价值。
1. 线程的优先级分几级,默认级别是什么
优先级分为1-10共10个等级,1表示最低优先级,5是默认级别;
t.setPriority()用来设定线程的优先级,需要在线程开始方法被调用之前进行设定;
可以使用MIN_PRIORITY(1),MAX_PRIORITY(10),NORM_PRIORITY(5)来设定优先级。
2. 如何实现线程同步
(1)同步方法
即有synchronized关键字修饰的方法;
由于Java的每个对象都有一个内置锁,当用此关键字修饰方法时,内置锁会保护整个方法。在调用该方法之前,需要获取内置锁,否则就处于阻塞状态。
(2)同步代码块
即有synchronized关键字修饰的语句块;
代码如:
synchronized(object)
注:同步是一种高开销的操作,因此应该尽量减少同步的内容。通常没有必要同步整个方法,使用synchronized代码块同步关键代码即可。
(3)使用重入锁实现线程同步
在Java5中新增了java.util.concurrent包来支持同步。ReentrantLock类是可重入、互斥、实现了Lock接口的锁,它与使用synchronized方法和块具有相同的基本行为和语义,并且扩展了其能力。
ReentrantLock类的常用方法有:
ReentrantLock():创建一个ReentrantLock实例;
lock():获得锁;
unlock():释放锁;
注:ReentrantLock还有一个可以创建公平锁的构造方法,但由于会大幅降低程序运行效率,不推荐使用。
(4)使用ThreadLocal实现线程同步
使用ThreadLocal管理变量,则每个使用该变量的线程都获得该变量的一个副本,副本之间相互独立,这样每个线程都可以随意更改自己的变量副本,而不会对其它线程产生影响。
3. HashTable的size方法中明明只有一条语句"return count",为什么还要做同步
(1)同一时间只能有一个线程执行固定类的同步方法,但是对于类的非同步方法,可以多条线程同时方法。
所以,这样就出现了问题,而给size添加了同步之后,意味着线程B调用size方法只有在线程A调用put方法之后,这样就保证了线程安全;
(2)CPU执行的代码,不是Java代码,Java代码最终是被翻译成机器码执行的,机器码才是真正可以和硬件电路交互的代码。即使你看到Java代码只有一行,甚至你看到Java代码编译之后生成的字节码也只有一行,也不意味着对于底层来说这句语句只有一行。一句"return count"假设被翻译成了三句汇编语句执行,一句汇编语句和其机器码做对应,完全可能执行完第一句,线程就切换了。
4. 如何停止一个正在运行的线程
(1)使用退出标志,使线程正常退出,也就是run方法完成后线程终止;
(2)使用stop方法强行终止线程,不推荐,因为和suspend、resume等一样,都是过期作废的方法,可能产生不可预料的结果;
(3)使用interrupt方法,而interrupt方法并未真正停止线程,只不过在线程中打了一个标记;此时可以使用抛异常的方式,也可以使用return的方式结束线程,不过推荐使用前者,因为可以继续向上抛,具有更好的连通性。
5. Runnable与Callable的区别
java.lang.Runnable:
public interface Runnable
public abstract void run ;
由于run方法返回值为void,所以在执行完任务后无法返回任何结果;
java.util.concurrent.Callable<V>:
public interface Callable<V>
V call () throws Exception;
可以看到,这是一个泛型接口,call函数返回的类型就是传递进来的V类型。并且call方法可抛出异常(若抛异常则Future的get方法就什么也取不到了),run方法不能抛出异常。
6. 什么是线程安全,Servlet、变量是线程安全的吗
线程安全:如果你的代码在多线程下执行和单线程下执行永远都能获得一样的结果,那么你的代码就是线程安全的。
线程安全问题都是由全局变量及静态变量引起。若每个线程中对全局变量、静态变量只有读操作,而无写操作,一般来说,这是线程安全的;若有多个线程同时执行写操作,一般都需要考虑线程同步,否则就可能影响线程安全。
Servlet不是线程安全的,Servlet是单实例多线程的,当多个线程同时访问同一个方法,是不能保证共享变量的线程安全的;
静态变量:线程非安全。static变量被所有实例共享,当声明类变量时,不会生成static变量的副本,而是类的所有实例共享同一个static变量。一旦值被修改,其它对象均对修改可见,因此线程非安全。
实例变量:单例时线程非安全,非单例时线程安全。实例变量是实例对象私有的,若系统中只存在一个实例对象,则在多线程环境下,如果值改变,则对其它对象都可见,所以是线程非安全的;如果每个线程都在不同实例对象中执行,则对象之间的修改互不影响,线程安全。
局部变量:线程安全。定义在方法内部的变量,线程间不共享。
静态方法:方法中如果没有使用静态变量,就没有线程安全问题;静态方法内部的变量是局部变量。
7. 对守护线程的理解
在Java中有两类线程:User Thread(用户线程)、Daemon Thread(守护线程)
用个比较通俗的比喻,任何一个守护线程都是整个JVM中所有非守护线程的保姆:只要当前JVM实例中尚存在任何一个非守护线程没有结束,守护线程就全部工作;只有当最后一个非守护线程结束时,守护线程随着JVM一同结束工作。
退出的先后顺序:非守护线程 > 守护线程 > jvm。
Daemon的作用是为其它线程的运行提供便利服务,守护线程最典型的应用就是 GC (垃圾回收器),它就是一个很称职的守护者。
User和Daemon两者几乎没有区别,唯一的不同之处就在于虚拟机的离开:如果 User Thread已经全部退出运行了,只剩下Daemon Thread存在了,虚拟机也就退出了。 因为没有了被守护者,Daemon也就没有工作可做了,也就没有继续运行程序的必要了。
优先级:守护线程的优先级比较低,用于为系统中的其它对象和线程提供服务。
值得一提的是,守护线程并非只有虚拟机内部提供,用户在编写程序时也可以自己设置守护线程。
这里有几点需要注意:
(1) thread.setDaemon(true)必须在thread.start()之前设置,否则会抛出一个IllegalThreadStateException异常。你不能把正在运行的常规线程设置为守护线程。
(2) 在Daemon线程中产生的新线程也是Daemon的。
(3) 不要认为所有的应用都可以分配给Daemon来进行服务,比如读写操作或者计算逻辑。
8. 线程是否可以被多次调用start方法
Java的线程是不允许启动两次的,第二次调用必然会抛出IllegalThreadStateException,这是一种运行时异常,多次调用start被认为是编程错误。
9. 对死锁的理解
定义:如果一个进程集合里的每个进程都在等待这个集合中的其它一个进程(包括自身)才能继续往下执行,若无外力将无法推进,这种情况称为死锁。
产生死锁的四个必要条件:
(1)互斥条件:进程对所分配到的资源不允许其它进程进行访问,若其它进程访问该资源,只能等待,直至占有该资源的进程使用完成后释放该资源;
(2)请求和保持条件:进程获得一定的资源之后,又对其它资源发出请求,但是该资源可能被其它进程占有,此时请求阻塞,但又对自己获得的资源保持不放;
(3)不可剥夺条件:是指进程已获得的资源,在未完成使用之前,不可被剥夺,只能在使用完后自己释放;
(4)环路等待条件:是指进程发生死锁后,必然存在一个进程--资源之间的环形链。
处理死锁的基本方法:
(1)预防死锁:通过设置一些限制条件,去破坏产生死锁的必要条件;
(2)避免死锁:在资源分配过程中,使用某种方法避免系统进入不安全的状态,从而避免发生死锁;
(3)检测死锁:允许死锁的发生,但是通过系统的检测之后,采取一些措施,将死锁清除掉;
(4)解除死锁:该方法与检测死锁配合使用。
死锁举例(可能会要求手写一个死锁示例):
/** * 一个简单的死锁类 * 当DeadLock类的对象flag=1时(td1),先锁定o1,睡眠500ms, * 而td1在睡眠的时候另一个flag=0的对象(td2)线程启动,先锁定o2,睡眠500ms, * td1睡眠结束后需要锁定o2才能继续执行,而此时o2已经被td2锁定, * td2睡眠结束后需要锁定o1才能继续执行,而此时o1已经被td1锁定, * td1与td2互相等待,都需要得到对方锁定的资源才能继续执行,从而死锁。 */ public class Test public static void main(String[] args) new Thread(new DeadLock(0)).start(); new Thread(new DeadLock(1)).start(); class DeadLock implements Runnable private int flag; public DeadLock(int flag) this.flag = flag; private static Object o1 = new Object(), o2 = new Object(); @Override public void run() if (0 == flag) synchronized (o1) System.out.println("Flag0 in"); try Thread.sleep(500); catch (InterruptedException e) e.printStackTrace(); synchronized (o2) System.out.println("Flag1 in"); System.out.println("Flag1 out"); System.out.println("Flag0 out"); if (0 != flag) synchronized (o2) System.out.println("Flag1 in!"); try Thread.sleep(500); catch (InterruptedException e) e.printStackTrace(); synchronized (o1) System.out.println("Flag0 in!"); System.out.println("Flag0 out!"); System.out.println("Flag1 out!");
10. 线程池的工作原理
(1)线程池可以解决两个不同问题:由于减少了每个任务调用的开销,它们通常可以在执行大量异步任务时提供增强的性能,并且还可以提供绑定和管理资源(包括执行任务集时使用的线程)的方法。每个ThreadPoolExecutor还维护着一些基本的统计数据,如完成的任务数;
(2)ThreadPoolExecutor作为java.util.concurrent包对外提供的基础实现,以内部线程池的形式对外提供管理任务执行、线程调度、线程池管理等等服务;
(3)Executors方法提供的线程服务,都是通过参数设置来实现不同的线程池机制。如:
newFixedThreadPool(int nThreads):创建固定数目线程的线程池,以共享的无界队列方式来运行这些线程;
newCachedThreadPool():创建一个可缓存的线程池,调用execute将重用以前构造的线程(如果线程可用),如果当前没有可用线程,则创建一个新线程并添加到池中,终止并从缓存中移除那些已有60s钟未被使用的线程;
newSingleThreadExecutor():创建一个单线程化的Executor,只会用唯一的工作线程来执行任务,包含一个无界队列,保证所有任务按照指定顺序(FIFO/LIFO/优先级)执行;
newScheduledThreadPool(int corePoolSize):创建一个支持定时及周期性任务执行的定长线程池,多数情况下可用来代替Timer类。
以上方法返回的类型为ExecutorService,调用的构造方法为ThreadPoolExecutor(......);
Executor框架是在Java5中引入的,其内部使用了线程池机制,在java.util.concurrent包下,通过该框架来控制线程的启动、执行和关闭,可以简化并发编程的操作。因此,在Java5之后,通过Executor来启动线程比使用Thread的start方法更好,除了更易管理,效率更好外,还有关键的一点:有助于避免this逃逸问题:在构造器构造还未彻底完成之前,将自身this引用向外抛出并被其它线程访问,可能会被访问到还未被初始化到的变量,甚至可能会造成更严重的问题。
ExecutorService接口继承自Executor接口,它提供了更丰富的实现多线程的方法,比如,可以调用ExecutorService的shutdown方法来平滑地关闭ExecutorService,调用该方法后,将导致ExecutorService停止接收任何新的任务且等待已经提交的任务执行完成(已经提交的任务会分为两类,一类是已经在执行的,另一类是还没有开始执行的),当所有已经提交的任务执行完毕后将会关闭ExecutorService,因此我们一般用该接口来实现和管理多线程。
ExecutorService的生命周期包括三种状态:运行、关闭、终止。创建后便进入运行状态,当调用了shutdown方法时,便进入关闭状态,此时意味着ExecutorService不再接收新的任务,但它还在执行已经提交了的任务,当所有已经提交了的任务执行完成后,便达到终止状态。如果不调用shutdown方法,ExecutorService会一直处于运行状态,不断接收新的任务,服务器一般不需要关闭它,保持一直运行即可。
Executor接口定义了execute方法用来接收一个Runnable接口的对象,而ExecutorService接口中的submit方法可以接收Runnable和Callable接口的对象。
核心构造方法ThreadPoolExecutor(......)参数讲解:
corePoolSize:核心线程池大小;
maximumPoolSize:最大线程池大小;
keepAliveTime:线程池中超过corePoolSize数目的空闲线程最大存活时间;
TimeUnit:keepAliveTime的时间单位;
workQueue:阻塞任务队列;
threadFactory(可选):新建线程工厂;
RejectedExecutionHandler(可选):当提交任务数量超过maximumPoolSize+workQueue之和时,任务会交给RejectedExecutionHandler处理。
其中比较容易让人误解的是:corePoolSize、maximumPoolSize、workQueue之间的关系:
(1)当线程池小于corePoolSize时,新提交任务将创建一个新线程执行任务,即使此时线程池中存在空闲线程;
(2)当线程池达到corePoolSize时,新提交任务将被放入workQueue中,等待线程池中任务调度执行;
(3)当workQueue已满,且maximumPoolSize>CorePoolSize时,新提交的任务会创建新线程执行任务;
(4)当提交任务数超过maximumPoolSize时,新提交任务由RejectedExecutionHandler处理;
(5)当线程池中超过corePoolSize线程,空闲时间达到keepAliveTime时,关闭空闲线程;
(6)当设置allowCoreThreadTimeOut(true)时,线程池中corePoolSize线程空闲时间达到keepAliveTime也将关闭。
11. 如何终止线程池
shutdown:当线程池调用该方法时,线程池的状态立刻变为SHUTDOWN状态。此时,不能再往线程池中添加任何任务,否则将会抛出RejectedExecutionException。但是,此时线程池不会立刻退出,直到添加到线程池中的任务都已经处理完成,才会退出。
shutdownNow:执行该方法,拒绝接收新提交的任务,(1)线程池的状态立即变为STOP,(2)并试图阻止所有正在执行的线程,(3)不再处理还在线程池队列中等待的任务,当然,它会返回那些未执行的任务。
它试图阻止线程的方法是通过调用Thread.interrupt方法来实现的,但是这种方法的作用有限,如果线程中没有sleep、wait、Condition、定时锁等应用,interrupt是无法中断当前线程的。所以,shutdownNow并不代表线程池一定会立刻退出,它可能需要等待所有正在执行的任务都执行完毕才会退出。
12. synchronized与Lock的比较
(1)Lock是一个接口,而synchronized是Java中的关键字,synchronized是内置的语言实现;
(2)synchronized在发生异常时,会自动释放线程占有的锁,因此不会导致死锁现象发生;而Lock在发生异常时,如果没有主动通过unLock()去释放锁,则很可能造成死锁现象,因此使用Lock时需要在finally块中释放锁;
(3)Lock可以让等待锁的线程响应中断,而synchronized却不行,使用synchronized时,等待的线程会一直等待下去,不能够响应中断;
(4)通过Lock可以知道有没有成功获取锁,而synchronized却无法办到;
(5)Lock可以提高多个线程进行读操作的效率。
在性能上来说,如果竞争资源不激烈,两者的性能是差不多的,而当竞争资源非常激烈时(即有大量线程同时竞争),此时Lock的性能要远远优于synchronized。所以说,在具体使用时要根据适当情况选择。
13. synchronized与ThreadLocal的比较
(1)ThreadLocal使用场合主要解决多线程中数据因并发产生的不一致问题。ThreadLocal为每个线程中并发访问的数据提供一个副本,通过访问副本来运行业务,这样的结果是耗费了内存,但大大减少了线程同步所带来的性能消耗,也减少了线程并发控制的复杂度;
(2)ThreadLocal不能使用原子类型,只能使用Object类型。ThreadLocal的使用要比synchronized简单得多;
(3)ThreadLocal和synchronized都用于解决多线程并发的访问,但是二者有本质区别:synchronized是利用锁的机制,使变量或代码块在某一时刻只能被一个线程访问,而ThreadLocal为每一个线程都提供了变量的副本,使每个线程在某一时间访问到的并不是同一个对象,这样就隔离了多个线程对数据的共享。而synchronized却正好相反,它用于多个线程间通信时能够获取数据共享;
(4)同步会带来巨大的性能开销,所以同步操作应该是细粒度的(对象中的不同元素使用不同的锁,而不是整个对象一个锁),如果同步使用得当,带来的性能开销是微不足道的,使用同步真正的风险是复杂性和可能破坏资源安全,而不是性能;
(5)synchronized用于线程间的数据共享,ThreadLocal用于线程间的数据隔离。
ThreadLocal:数据隔离,适合多个线程需要多次使用同一个对象,并且需要该对象具有相同的初始化值时;
synchronized:数据同步,当多个线程想访问或修改同一个对象,需要阻塞其它线程从而只允许其中一个线程对其进行访问与修改。
14. 乐观锁与悲观锁的比较
悲观锁,就是思想很悲观,每次去拿数据的时候都认为别人会去修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会block直到它拿到锁。传统的关系型数据库中就用到了很多悲观锁的机制,比如行锁,表锁,读锁,写锁等,都是在做操作前先上锁,synchronized也是悲观锁。
乐观锁,就是思想很乐观,每次去拿数据的时候都认为别人不会去修改,所以不会上锁,但是在更新的时候会去判断一下在此期间别人有没有去更新这个数据,可以使用版本号或时间戳等机制(提交版本必须大于记录当前版本才能执行更新)。像数据库如果提供类似于write_condition机制的其实都是提供的乐观锁,CAS思想也是乐观锁。
两种锁各有优缺点,乐观锁适用于写比较少读比较多的情况,即冲突真的很少发生的时候,这样可以省去了锁的开销,加大了整个系统的吞吐量。但如果经常产生冲突,上层应用不断进行retry,这样反而降低了性能,所以这种情况下悲观锁比较合适。
乐观锁事实上并没有使用锁机制。
15. CyclicBarrier与CountDownLatch的比较
CountDownLatch:一个或多个线程等待另外N个线程完成某个事情之后才能继续执行。
CyclicBarrier:N个线程相互等待,任何一个线程完成之前,所有的线程都必须等待。
对于CountDownLatch来说,重点的是那个“一个线程”,是它在等待,而另外那N个线程在把“某个事情”做完之后可以继续等待,可以终止;而对于CyclicBarrier来说,重点是那“N个线程”,它们之间任何一个没有完成,所有的线程都必须等待。
CountDownLatch是计数器,线程完成一个就计一个,就像报数一样,只不过是递减的;
CyclicBarrier更像一个水闸,线程执行就像水流,在水闸处就会堵住,等到水满(线程到齐)了,才开始泄流。
CountDownLatch不可重复利用,CyclicBarrier可重复利用。
16. sleep与wait的比较
(1)wait是Object类中的方法,sleep是Thread类中的方法;
(2)sleep是Thread类的静态方法,谁调用,谁睡觉;
(3)sleep方法调用之后并没有释放锁,使得线程仍然可以同步控制,sleep不会让出系统资源;
(4)wait是进入线程等待池中等待,让出系统资源;
(5)调用wait方法的线程,不会自己唤醒,需要线程调用notify/notifyAll方法唤醒等待池中的所有线程,
才会进入就绪队列中等待系统分配资源。sleep方法会自动唤醒,如果时间不到,想要唤醒,可以使用interrupt方法强行打断;
(6)sleep可以在任何地方使用,而wait/notify/notifyAll只能在同步控制方法或者同步控制块中使用;
(7)sleep和wait必须捕获异常,notify/notifyAll不需要捕获异常;
(8)wait通常被用于线程间交互,sleep通常被用于暂停执行。
17. sleep与yield的比较
(1)sleep方法给其它线程运行机会时不考虑线程的优先级,因此会给低优先级的线程以运行的机会;
yield方法只会给相同或更高优先级的线程以运行的机会;
(2)sleep方法之后转入阻塞状态,yield方法之后转入就绪状态;
(3)sleep方法声明抛出InterruptedException,而yield方法没有声明任何异常;
(4)sleep方法具有更好的可移植性(yield不好控制,只是瞬间放弃CPU的执行权,有可能马上又抢回
接着执行,而sleep更容易被控制);
(5)另外,Thread类的sleep和yield方法将在当前正在执行的线程上运行,所以在其它处于等待状态的
线程上调用这些方法是没有意义的。这就是为什么这些方法是静态的。它们可以在当前正在执行的线程中
工作,并避免程序员错误地认为可以在其它非运行线程调用这些方法。
18. 为什么wait/notify/notifyAll都必须放在同步方法/同步块中
简单地说,由于wait、notify、notifyAll都是锁级别的操作,所以把它们定义在Object类中,因为锁属于对象。
19. 对ReadWriteLock的理解
ReadWriteLock同Lock一样也是一个接口,提供了readLock和writeLock两种锁的操作机制,一个是只读的锁,一个是写锁;
读锁可以在没有写锁的时候被多个线程同时持有,写锁是独占的(排它的)。每次只能有一个写线程,但是可以有多个线程并发地读数据;
所有读写锁的实现必须确保写操作对读操作的内存影响,换句话说,一个获得了读锁的线程必须能够看到前一个释放的写锁所更新的内容;
理论上,读写锁比互斥锁允许对于共享数据更大程度的并发。与互斥锁相比,读写锁是否能够提高性能取决于读写数据的频率、读取和写入操作的持续时间以及读线程和写线程之间的竞争。
使用场景:假如程序中涉及到一些共享资源的读写操作,并且写操作没有读操作那么频繁。例如,最初填充有数据,然后很少修改的集合,同时频繁搜索,是使用读写锁的理想候选项。
互斥原则:
读-读能共存,
读-写不能共存,
写-写不能共存。
public interface ReadWriteLock
Lock readLock();
Lock writeLock();
20. 同步集合与并发集合的比较
同步集合:可以简单地理解为通过synchronized实现同步的集合。如果有多个线程调用同步集合的方法,它们将会串行执行;
并发集合:jdk5的重要特征,增加了并发包java.util.concurrent.*,以CAS为基础。
常见的并发集合:
ConcurrentHashMap:线程安全的HashMap实现(ConcurrentHashMap不允许空值或空键,HashMap可以);
CopyOnWriteArrayList:线程安全且在读操作时无锁的ArrayList;
CopyOnWriteArraySet:基于CopyOnWriteArrayList,不添加重复元素;
ArrayBlockingQueue:基于数组,先进先出,线程安全,可实现指定时间的阻塞读写,并且容量可以限制;
LinkedBlockingQueue:基于链表实现,读写各用一把锁,在高并发读写操作的情况下,性能优于ArrayBlockingQueue;
同步集合比并发集合慢得多,主要原因是锁,同步集合会对整个Map或List加锁。
以上是关于常见面试问题整理系列之--多线程的主要内容,如果未能解决你的问题,请参考以下文章