Java并发多线程高频面试题
Posted
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Java并发多线程高频面试题相关的知识,希望对你有一定的参考价值。
并发知识不管在学习、面试还是工作过程中都非常非常重要,看完本文,相信绝对能助你一臂之力。
1、线程和进程有什么区别?
线程是进程的子集,一个进程可以有很多线程。每个进程都有自己的内存空间,可执行代码和唯一进程标识符(PID)。
每条线程并行执行不同的任务。不同的进程使用不同的内存空间(线程自己的堆栈),而所有的线程共享一片相同的内存空间(进程主内存)。别把它和栈内存搞混,每个线程都拥有单独的栈内存用来存储本地数据。
2、实现多线程的方式有哪些?
- 继承Thread类:Java单继承,不推荐;
- 实现Runnable接口:Thread类也是继承Runnable接口,推荐;
- 实现Callable接口:实现Callable接口,配合FutureTask使用,有返回值;
- 使用线程池:复用,节约资源;
- 更多方式可以参考我的文章使用Java Executor框架实现多线程
3、用Runnable还是Thread?
这个问题是上题的后续,大家都知道我们可以通过继承Thread类或者调用Runnable接口来实现线程,问题是,那个方法更好呢?什么情况下使用它?这个问题很容易回答,如果你知道Java不支持类的多重继承,但允许你调用多个接口。所以如果你要继承其他类,当然是调用Runnable接口好了。
- Runnable和Thread两者最大的区别是Thread是类而Runnable是接口,至于用类还是用接口,取决于继承上的实际需要。Java类是单继承的,实现多个接口可以实现类似多继承的操作。
- 其次, Runnable就相当于一个作业,而Thread才是真正的处理线程,我们需要的只是定义这个作业,然后将作业交给线程去处理,这样就达到了松耦合,也符合面向对象里面组合的使用,另外也节省了函数开销,继承Thread的同时,不仅拥有了作业的方法run(),还继承了其他所有的方法。
- 当需要创建大量线程的时候,有以下不足:①线程生命周期的开销非常高;②资源消耗;③稳定性。
- 如果二者都可以选择不用,那就不用。因为Java这门语言发展到今天,在语言层面提供的多线程机制已经比较丰富且高级,完全不用在线程层面操作。直接使用Thread和Runnable这样的“裸线程”元素比较容易出错,还需要额外关注线程数等问题。建议:简单的多线程程序,使用Executor。复杂的多线程程序,使用一个Actor库,首推Akka。
- 如果一定要在Runnable和Thread中选择一个使用,选择Runnable。
4、Thread 类中的start() 和 run() 方法有什么区别?
这个问题经常被问到,但还是能从此区分出面试者对Java线程模型的理解程度。start()方法被用来启动新创建的线程,而且start()内部调用了run()方法,JDK 1.8源码中start方法的注释这样写到:Causes this thread to begin execution; the Java Virtual Machine calls the <code>run</code> method of this thread.这和直接调用run()方法的效果不一样。当你调用run()方法的时候,只会是在原来的线程中调用,没有新的线程启动,start()方法才会启动新线程,JDK 1.8源码中注释这样写:The result is that two threads are running concurrently: the current thread (which returns from the call to the <code>start</code> method) and the other thread (which executes its <code>run</code> method).。
new 一个 Thread,线程进入了新建状态;调用 start() 方法,会启动一个线程并使线程进入了就绪状态,当分配到时间片后就可以开始运行了。start() 会执行线程的相应准备工作,然后自动执行 run() 方法的内容,这是真正的多线程工作。而直接执行 run() 方法,会把 run 方法当成一个 main 线程下的普通方法去执行,并不会在某个线程中执行它,所以这并不是多线程工作。
总结:调用 start 方法方可启动线程并使线程进入就绪状态,而 run 方法只是 thread 的一个普通方法调用,还是在主线程里执行。
5、说说 sleep() 方法和 wait() 方法区别和共同点?
- 两者最主要的区别在于:sleep 方法没有释放锁,而 wait 方法释放了锁 。
- 两者都可以暂停线程的执行。
- Wait 通常被用于线程间交互/通信,sleep 通常被用于暂停执行。
- wait() 方法被调用后,线程不会自动苏醒,需要别的线程调用同一个对象上的 notify() 或者 notifyAll() 方法。sleep() 方法执行完成后,线程会自动苏醒。
6、说说并发与并行的区别?
- 并发: 同一时间段,多个任务都在执行 (单位时间内不一定同时执行);
- 并行: 单位时间内,多个任务同时执行。
7、说说线程的生命周期和状态?
Java 线程在运行的生命周期中的指定时刻只可能处于下面 6 种不同状态的其中一个状态(图源《Java 并发编程艺术》4.1.4 节)。
线程在生命周期中并不是固定处于某一个状态而是随着代码的执行在不同状态之间切换。Java 线程状态变迁如下图所示(图源《Java 并发编程艺术》4.1.4 节):
由上图可以看出:线程创建之后它将处于 NEW(新建) 状态,调用 start() 方法后开始运行,线程这时候处于 READY(可运行) 状态。可运行状态的线程获得了 CPU 时间片(timeslice)后就处于 RUNNING(运行) 状态。
操作系统隐藏 Java 虚拟机(JVM)中的 RUNNABLE 和 RUNNING 状态,它只能看到 RUNNABLE 状态(图源:HowToDoInJava:Java Thread Life Cycle and Thread States),所以 Java 系统一般将这两个状态统称为 RUNNABLE(运行中) 状态 。
当线程执行 wait()方法之后,线程进入 WAITING(等待)状态。进入等待状态的线程需要依靠其他线程的通知才能够返回到运行状态,而 TIME_WAITING(超时等待) 状态相当于在等待状态的基础上增加了超时限制,比如通过 sleep(long millis)方法或 wait(long millis)方法可以将 Java 线程置于 TIMED WAITING 状态。当超时时间到达后 Java 线程将会返回到 RUNNABLE 状态。当线程调用同步方法时,在没有获取到锁的情况下,线程将会进入到 BLOCKED(阻塞) 状态。线程在执行 Runnable 的run()方法之后将会进入到 TERMINATED(终止) 状态。
8、什么是线程死锁?
多个线程同时被阻塞,它们中的一个或者全部都在等待某个资源被释放。由于线程被无限期地阻塞,因此程序不可能正常终止。
如下图所示,线程 A 持有资源 2,线程 B 持有资源 1,他们同时都想申请对方的资源,所以这两个线程就会互相等待而进入死锁状态。
下面通过一个例子来说明线程死锁,代码模拟了上图的死锁的情况 (代码来源于《并发编程之美》):
public class DeadLockDemo
private static Object resource1 = new Object();//资源 1
private static Object resource2 = new Object();//资源 2
public static void main(String[] args)
new Thread(() ->
synchronized (resource1)
System.out.println(Thread.currentThread() + "get resource1");
try
Thread.sleep(1000);
catch (InterruptedException e)
e.printStackTrace();
System.out.println(Thread.currentThread() + "waiting get resource2");
synchronized (resource2)
System.out.println(Thread.currentThread() + "get resource2");
, "线程 1").start();
new Thread(() ->
synchronized (resource2)
System.out.println(Thread.currentThread() + "get resource2");
try
Thread.sleep(1000);
catch (InterruptedException e)
e.printStackTrace();
System.out.println(Thread.currentThread() + "waiting get resource1");
synchronized (resource1)
System.out.println(Thread.currentThread() + "get resource1");
, "线程 2").start();
输出:
Thread[线程 1,5,main]get resource1
Thread[线程 2,5,main]get resource2
Thread[线程 1,5,main]waiting get resource2
Thread[线程 2,5,main]waiting get resource1
线程 A 通过 synchronized (resource1) 获得 resource1 的监视器锁,然后通过 Thread.sleep(1000);让线程 A 休眠 1s 为的是让线程 B 得到执行然后获取到 resource2 的监视器锁。线程 A 和线程 B 休眠结束了都开始企图请求获取对方的资源,然后这两个线程就会陷入互相等待的状态,这也就产生了死锁。上面的例子符合产生死锁的四个必要条件。
学过操作系统的朋友都知道产生死锁必须具备以下四个条件:
- 互斥条件:该资源任意一个时刻只由一个线程占用。
- 请求与保持条件:一个进程因请求资源而阻塞时,对已获得的资源保持不放。
- 不剥夺条件:线程已获得的资源在末使用完之前不能被其他线程强行剥夺,只有自己使用完毕后才释放资源。
- 循环等待条件:若干进程之间形成一种头尾相接的循环等待资源关系。
9、如何避免线程死锁?
我们只要破坏产生死锁的四个条件中的其中一个就可以了。
- 破坏互斥条件:这个条件我们没有办法破坏,因为我们用锁本来就是想让他们互斥的(临界资源需要互斥访问)。
- 破坏请求与保持条件:一次性申请所有的资源。
- 破坏不剥夺条件:占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源。
- 破坏循环等待条件:靠按序申请资源来预防。按某一顺序申请资源,释放资源则反序释放。破坏循环等待条件。
我们对线程 2 的代码修改成下面这样就不会产生死锁了。
new Thread(() ->
synchronized (resource1)
System.out.println(Thread.currentThread() + "get resource1");
try
Thread.sleep(1000);
catch (InterruptedException e)
e.printStackTrace();
System.out.println(Thread.currentThread() + "waiting get resource2");
synchronized (resource2)
System.out.println(Thread.currentThread() + "get resource2");
, "线程 2").start();
输出:
Thread[线程 1,5,main]get resource1
Thread[线程 1,5,main]waiting get resource2
Thread[线程 1,5,main]get resource2
Thread[线程 2,5,main]get resource1
Thread[线程 2,5,main]waiting get resource2
Thread[线程 2,5,main]get resource2
Process finished with exit code 0
我们分析一下上面的代码为什么避免了死锁的发生?
线程 1 首先获得到 resource1 的监视器锁,这时候线程 2 就获取不到了。然后线程 1 再去获取 resource2 的监视器锁,可以获取到。然后线程 1 释放了对 resource1、resource2 的监视器锁的占用,线程 2 获取到就可以执行了。这样就破坏了破坏循环等待条件,因此避免了死锁。
10、什么是死锁,活锁?
- 死锁:多个线程都无法获得资源继续执行。可以通过避免一个线程获取多个锁;一个锁占用一个资源;使用定时锁;数据库加解锁在一个连接中。
- 死锁的必要条件:环路等待,不可剥夺,请求保持,互斥条件
- 活锁:线程之间相互谦让资源,都无法获取所有资源继续执行。
11、Java中CyclicBarrier 和 CountDownLatch有什么不同?
CyclicBarrier 和 CountDownLatch 都可以用来让一组线程等待其它线程。与 CyclicBarrier 不同的是,CountdownLatch 不能重新使用。
- CountDownLatch是一种灵活的闭锁实现,可以使一个或者多个线程等待一组事件发生。闭锁状态包括一个计数器,改计数器初始化为一个正数,表示需要等待的事件数量。countDown方法递减计数器,表示有一个事件发生了,而await方法等待计数器到达0,表示所有需要等待的事情都已经发生。如果计数器的值非零,那么await就会一直阻塞知道计数器的值为0,或者等待的线程中断,或者等待超时。
- CyclicBarrier适用于这样的情况:你希望创建一组任务,他们并行地执行工作,然后在进行下一个步骤之前等待,直至所有任务都完成。它使得所有的并行任务都将在栅栏出列队,因此可以一致的向前移动。这非常像CountDownLatch,只是CountDownLatch是只触发一次的事件,而CyclicBarrier可以多次重用。
12、Java中的同步集合与并发集合有什么区别?
- 同步集合与并发集合都为多线程和并发提供了合适的线程安全的集合,不过并发集合的可扩展性更高。在Java1.5之前程序员们只有同步集合来用且在多线程并发的时候会导致争用,阻碍了系统的扩展性。Java5介绍了并发集合像ConcurrentHashMap,不仅提供线程安全还用锁分离和内部分区等现代技术提高了可扩展性。
- 同步容器是线程安全的。同步容器将所有对容器状态的访问都串行化,以实现他们的线程安全性。这种方法的代价是严重降低并发性,当多个线程竞争容器的锁时,吞吐量将严重降低。并发容器是针对多个线程并发访问设计的,改进了同步容器的性能。通过并发容器来代替同步容器,可以极大地提高伸缩性并降低风险。
13、你如何在Java中获取线程堆栈?
对于不同的操作系统,有多种方法来获得Java进程的线程堆栈。当你获取线程堆栈时,JVM会把所有线程的状态存到日志文件或者输出到控制台。在Windows你可以使用Ctrl + Break组合键来获取线程堆栈,Linux下用kill -3命令。你也可以用jstack这个工具来获取,它对线程id进行操作,你可以用jps这个工具找到id。
14、Java中ConcurrentHashMap的并发度是什么?
- ConcurrentHashMap把实际map划分成若干部分来实现它的可扩展性和线程安全。这种划分是使用并发度获得的,它是ConcurrentHashMap类构造函数的一个可选参数,默认值为16,这样在多线程情况下就能避免争用。
- 并发度可以理解为程序运行时能够同时更新ConccurentHashMap且不产生锁竞争的最大线程数,实际上就是ConcurrentHashMap中的分段锁个数,即Segment[]的数组长度。ConcurrentHashMap默认的并发度为16,但用户也可以在构造函数中设置并发度。当用户设置并发度时,ConcurrentHashMap会使用大于等于该值的最小2幂指数作为实际并发度(假如用户设置并发度为17,实际并发度则为32)。运行时通过将key的高n位(n = 32 – segmentShift)和并发度减1(segmentMask)做位与运算定位到所在的Segment。segmentShift与segmentMask都是在构造过程中根据concurrency level被相应的计算出来。
- 如果并发度设置的过小,会带来严重的锁竞争问题;如果并发度设置的过大,原本位于同一个Segment内的访问会扩散到不同的Segment中,CPU cache命中率会下降,从而引起程序性能下降。
15、Java中的同步集合与并发集合有什么区别?
- 同步集合与并发集合都为多线程和并发提供了合适的线程安全的集合,不过并发集合的可扩展性更高。在Java1.5之前程序员们只有同步集合来用且在多线程并发的时候会导致争用,阻碍了系统的扩展性。Java5介绍了并发集合像ConcurrentHashMap,不仅提供线程安全还用锁分离和内部分区等现代技术提高了可扩展性。
- 同步容器是线程安全的。同步容器将所有对容器状态的访问都串行化,以实现他们的线程安全性。这种方法的代价是严重降低并发性,当多个线程竞争容器的锁时,吞吐量将严重降低。并发容器是针对多个线程并发访问设计的,改进了同步容器的性能。通过并发容器来代替同步容器,可以极大地提高伸缩性并降低风险。
16、Thread类中的yield方法有什么作用?
- Yield方法可以暂停当前正在执行的线程对象,让其它有相同优先级的线程执行。它是一个静态方法而且只保证当前线程放弃CPU占用而不能保证使其它线程一定能占用CPU,执行yield()的线程有可能在进入到暂停状态后马上又被执行。
- 线程让步:如果知道已经完成了在run()方法的循环的一次迭代过程中所需的工作,就可以给线程调度机制一个暗示:你的工作已经做得差不多了,可以让别的线程使用CPU了。这个暗示将通过调用yield()方法来做出(不过这只是一个暗示,没有任何机制保证它将会被采纳)。当调用yield()时,也是在建议具有相同优先级的其他线程可以运行。
- yield()的作用是让步。它能让当前线程由“运行状态”进入到“就绪状态”,从而让其它具有相同优先级的等待线程获取执行权;但是,并不能保证在当前线程调用yield()之后,其它具有相同优先级的线程就一定能获得执行权;也有可能是当前线程又进入到“运行状态”继续运行!
17、什么是ThreadLocal变量?
ThreadLocal是Java里一种特殊的变量。每个线程都有一个ThreadLocal就是每个线程都拥有了自己独立的一个变量,竞争条件被彻底消除了。它是为创建代价高昂的对象获取线程安全的好方法,比如你可以用ThreadLocal让SimpleDateFormat变成线程安全的,因为那个类创建代价高昂且每次调用都需要创建不同的实例所以不值得在局部范围使用它,如果为每个线程提供一个自己独有的变量拷贝,将大大提高效率。首先,通过复用减少了代价高昂的对象的创建个数。其次,你在没有使用高代价的同步或者不变性的情况下获得了线程安全。线程局部变量的另一个不错的例子是ThreadLocalRandom类,它在多线程环境中减少了创建代价高昂的Random对象的个数。
ThreadLocal是一种线程封闭技术。ThreadLocal提供了get和set等访问接口或方法,这些方法为每个使用该变量的线程都存有一份独立的副本,因此get总是返回由当前执行线程在调用set时设置的最新值。
** 18、Java内存模型是什么?**
Java内存模型规定和指引Java程序在不同的内存架构、CPU和操作系统间有确定性地行为。它在多线程的情况下尤其重要。Java内存模型对一个线程所做的变动能被其它线程可见提供了保证,它们之间是先行发生关系。这个关系定义了一些规则让程序员在并发编程时思路更清晰。比如,先行发生关系确保了:
- 线程内的代码能够按先后顺序执行,这被称为程序次序规则。
- 对于同一个锁,一个解锁操作一定要发生在时间上后发生的另一个锁定操作之前,也叫做管程锁定规则。
- 前一个对volatile的写操作在后一个volatile的读操作之前,也叫volatile变量规则。
- 一个线程内的任何操作必需在这个线程的start()调用之后,也叫作线程启动规则。
- 一个线程的所有操作都会在线程终止之前,线程终止规则。
- 一个对象的终结操作必需在这个对象构造完成之后,也叫对象终结规则。
- 可传递性
我强烈建议大家阅读《Java并发编程实践》第十六章来加深对Java内存模型的理解。
19、Java中的volatile 变量是什么?
volatile是一个特殊的修饰符,只有成员变量才能使用它。在Java并发程序缺少同步类的情况下,多线程对成员变量的操作对其它线程是透明的。volatile变量可以保证下一个读取操作会在前一个写操作之后发生,就是上一题的volatile变量规则。
Java语言提供了一种稍弱的同步机制,即volatile变量,用来确保将变量的更新操作通知到其他线程。当把变量声明为volatile类型后,编译器和运行时都会注意到这个变量是共享的,因此不会将变量上的操作和其他内存操作一起重排序。volatile变量不会被缓存在寄存器或者对其他处理器不可见的地方,因此在读取volatile类型的时候总会返回最新写入的值。
在访问volatile变量时不会执行加锁操作,因此也不会使执行线程阻塞,因此volatile变量是一种比synchronized关键字更轻量级的同步机制。
加锁机制既可以确保可见性又可以确保原子性,而volatile变量只能确保可见性。
20、volatile 变量和 atomic 变量有什么不同?
这是个有趣的问题。首先,volatile 变量和 atomic 变量看起来很像,但功能却不一样。Volatile变量可以确保先行关系,即写操作会发生在后续的读操作之前, 但它并不能保证原子性。例如用volatile修饰count变量那么 count++ 操作就不是原子性的。而AtomicInteger类提供的atomic方法可以让这种操作具有原子性如getAndIncrement()方法会原子性的进行增量操作把当前值加一,其它数据类型和引用变量也可以进行相似操作。
21、Java中Runnable和Callable有什么不同?
- Runnable和Callable都代表那些要在不同的线程中执行的任务。Runnable从JDK1.0开始就有了,Callable是在JDK1.5增加的。它们的主要区别是Callable的 call() 方法可以返回值和抛出异常,而Runnable的run()方法没有这些功能。Callable可以返回装载有计算结果的Future对象。
- Runnable是执行工作的独立任务,但是它不返回任何值。如果希望任务在完成的时候能够返回一个值,那么可以实现Callable接口而不是Runnable接口。在Java SE5中引入的Callable是一种具有类型参数的泛型,它的类型参数表示的是从方法call()(而不是run())中返回的值,并且必须使用ExecutorService.submit()方法调用它。submit()方法会产生Future对象,它用Callable返回结果的特定类型进行了参数化。
22、哪些操作释放锁,哪些不释放锁?
- sleep(): 释放资源,不释放锁,进入阻塞状态,唤醒随机线程,Thread类方法。
- wait(): 释放资源,释放锁,Object类方法。
- yield(): 不释放锁,进入可执行状态,选择优先级高的线程执行,Thread类方法。
- 如果线程产生的异常没有被捕获,会释放锁。
23、如何正确的终止线程?
- 使用共享变量,要用volatile关键字,保证可见性,能够及时终止。
- 使用interrupt()和isInterrupted()配合使用。
24、interrupt(), interrupted(), isInterrupted()的区别?
- interrupt():设置中断标志;
- interrupted():响应中断标志并复位中断标志;
- isInterrupted():响应中断标志;
25、synchronized的锁对象是哪些?
- 普通方法是当前实例对象;
- 同步方法快是括号中配置内容,可以是类Class对象,可以是实例对象;
- 静态方法是当前类Class对象。
- 只要不是同一个锁,就可以并行执行,同一个锁,只能串行执行。
- 更多参考我的文章Java中Synchronized关键字简介(译)
26、volatile和synchronized的区别是什么?
- volatile只能使用在变量上;而synchronized可以在类,变量,方法和代码块上。
- volatile至保证可见性;synchronized保证原子性与可见性。
- volatile禁用指令重排序;synchronized不会。
- volatile不会造成阻塞;synchronized会。
27、什么是缓存一致性协议?
因为CPU是运算很快,而主存的读写很忙,所以在程序运行中,会复制一份数据到高速缓存,处理完成在将结果保存主存.
这样存在一些问题,在多核CPU中多个线程,多个线程拷贝多份的高速缓存数据,最后在计算完成,刷到主存的数据就会出现覆盖
所以就出现了缓存一致性协议。最出名的就是Intel 的MESI协议,MESI协议保证了每个缓存中使用的共享变量的副本是一致的。它核心的思想是:当CPU写数据时,如果发现操作的变量是共享变量,即在其他CPU中也存在该变量的副本,会发出信号通知其他CPU将该变量的缓存行置为无效状态,因此当其他CPU需要读取这个变量时,发现自己缓存中缓存该变量的缓存行是无效的,那么它就会从内存重新读取。
28、Synchronized关键字、Lock,并解释它们之间的区别?
Synchronized 与Lock都是可重入锁,同一个线程再次进入同步代码的时候.可以使用自己已经获取到的锁
Synchronized是悲观锁机制,独占锁。而Locks.ReentrantLock是,每次不加锁而是假设没有冲突而去完成某项操作,如果因为冲突失败就重试,直到成功为止。ReentrantLock适用场景
某个线程在等待一个锁的控制权的这段时间需要中断
需要分开处理一些wait-notify,ReentrantLock里面的Condition应用,能够控制notify哪个线程,锁可以绑定多个条件。
具有公平锁功能,每个到来的线程都将排队等候。
29、Volatile如何保证内存可见性?
- 当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量刷新到主内存。
- 当读一个volatile变量时,JMM会把该线程对应的本地内存置为无效。线程接下来将从主内存中读取共享变量。
30、 Java中什么是竞态条件?
竞态条件会导致程序在并发情况下出现一些bugs。多线程对一些资源的竞争的时候就会产生竞态条件,如果首先要执行的程序竞争失败排到后面执行了,那么整个程序就会出现一些不确定的bugs。这种bugs很难发现而且会重复出现,因为线程间的随机竞争。
31、为什么wait, notify 和 notifyAll这些方法不在thread类里面?
明显的原因是JAVA提供的锁是对象级的而不是线程级的,每个对象都有锁,通过线程获得。如果线程需要等待某些锁那么调用对象中的wait()方法就有意义了。如果wait()方法定义在Thread类中,线程正在等待的是哪个锁就不明显了。简单的说,由于wait,notify和notifyAll都是锁级别的操作,所以把他们定义在Object类中因为锁属于对象。
32、Java中synchronized 和 ReentrantLock 有什么不同?
相似点:
这两种同步方式有很多相似之处,它们都是加锁方式同步,而且都是阻塞式的同步,也就是说当如果一个线程获得了对象锁,进入了同步块,其他访问该同步块的线程都必须阻塞在同步块外面等待,而进行线程阻塞和唤醒的代价是比较高的.
区别:
这两种方式最大区别就是对于Synchronized来说,它是java语言的关键字,是原生语法层面的互斥,需要jvm实现。而ReentrantLock它是JDK 1.5之后提供的API层面的互斥锁,需要lock()和unlock()方法配合try/finally语句块来完成。
Synchronized进过编译,会在同步块的前后分别形成monitorenter和monitorexit这个两个字节码指令。在执行monitorenter指令时,首先要尝试获取对象锁。如果这个对象没被锁定,或者当前线程已经拥有了那个对象锁,把锁的计算器加1,相应的,在执行monitorexit指令时会将锁计算器就减1,当计算器为0时,锁就被释放了。如果获取对象锁失败,那当前线程就要阻塞,直到对象锁被另一个线程释放为止。
由于ReentrantLock是java.util.concurrent包下提供的一套互斥锁,相比Synchronized,ReentrantLock类提供了一些高级功能,主要有以下3项:
- 等待可中断,持有锁的线程长期不释放的时候,正在等待的线程可以选择放弃等待,这相当于Synchronized来说可以避免出现死锁的情况。
- 公平锁,多个线程等待同一个锁时,必须按照申请锁的时间顺序获得锁,Synchronized锁非公平锁,ReentrantLock默认的构造函数是创建的非公平锁,可以通过参数true设为公平锁,但公平锁表现的性能不是很好。
- 锁绑定多个条件,一个ReentrantLock对象可以同时绑定对个对象。
33、Synchronized 用过吗,其原理是什么?
这是一道 Java 面试中几乎百分百会问到的问题,因为只要是程序员就一定会通过或者接触过Synchronized。
答:Synchronized 是由 JVM 实现的一种实现互斥同步的一种方式,如果 你查看被 Synchronized 修饰过的程序块编译后的字节码,会发现, 被 Synchronized 修饰过的程序块,在编译前后被编译器生成了monitorenter 和 monitorexit 两 个 字 节 码 指 令 。
这两个指令是什么意思呢?
在虚拟机执行到 monitorenter 指令时,首先要尝试获取对象的锁: 如果这个对象没有锁定,或者当前线程已经拥有了这个对象的锁,把锁 的计数器 +1;当执行 monitorexit 指令时将锁计数器 -1;当计数器 为 0 时,锁就被释放了。如果获取对象失败了,那当前线程就要阻塞等待,直到对象锁被另外一 个线程释放为止。
Java 中 Synchronize 通过在对象头设置标记,达到了获取锁和释放 锁的目的。
34、上面提到获取对象的锁,这个“锁”到底是什么?如何确定对象的锁?
答:“锁”的本质其实是 monitorenter 和 monitorexit 字节码指令的一 个 Reference 类型的参数,即要锁定和解锁的对象。我们知道,使用Synchronized 可以修饰不同的对象,因此,对应的对象锁可以这么确 定:
- 如果 Synchronized 明确指定了锁对象,比如 Synchronized(变量 名)、Synchronized(this) 等,说明加解锁对象为该对象。
- 如果没有明确指定:
- 若 Synchronized 修饰的方法为非静态方法,表示此方法对应的对象为 锁对象;
- 若 Synchronized 修饰的方法为静态方法,则表示此方法对应的类对象 为锁对象。
注意,当一个对象被锁住时,对象里面所有用 Synchronized 修饰的 方法都将产生堵塞,而对象里非 Synchronized 修饰的方法可正常被 调用,不受锁影响。
35、什么是可重入性,为什么说 Synchronized 是可重入锁?
先来看一下维基百科关于可重入锁的定义:
若一个程序或子程序可以“在任意时刻被中断然后操作系统调度执行另外一段代码,这段代码又调用了该子程序不会出错”,则称其为可重入(reentrant或re-entrant)的。即当该子程序正在运行时,执行线程可以再次进入并执行它,仍然获得符合设计时预期的结果。与多线程并发执行的线程安全不同,可重入强调对单个线程执行时重新进入同一个子程序仍然是安全的。
通俗来说:当线程请求一个由其它线程持有的对象锁时,该线程会阻塞,而当线程请求由自己持有的对象锁时,如果该锁是重入锁,请求就会成功,否则阻塞。
要证明synchronized是不是可重入锁,我们先来看一段代码:
package com.mzc.common.concurrent.synchronize;
/**
* <p class="detail">
* 功能: 证明synchronized为什么是可重入锁
* </p>
*
* @author Moore
* @ClassName Super class.
* @Version V1.0.
* @date 2020.02.07 15:34:12
*/
public class SuperClass
public synchronized void doSomething()
System.out.println("father is doing something,the thread name is:"+Thread.currentThread().getName());
package com.mzc.common.concurrent.synchronize;
/**
* <p class="detail">
* 功能: 证明synchronized为什么是可重入锁
* </p>
*
* @author Moore
* @ClassName Sub class.
* @Version V1.0.
* @date 2020.02.07 15:34:41
*/
public class SubClass extends SuperClass
public synchronized void doSomething()
System.out.println("child is doing doSomething,the thread name is:" + Thread.currentThread().getName());
// 调用自己类中其他的synchronized方法
doAnotherThing();
private synchronized void doAnotherThing()
// 调用父类的synchronized方法
super.doSomething();
System.out.println("child is doing anotherThing,the thread name is:" + Thread.currentThread().getName());
public static void main(String[] args)
SubClass child = new SubClass();
child.doSomething();
通过运行main方法,先一下结果:
child is doing doSomething,the thread name is:main
father is doing something,the thread name is:main
child is doing anotherThing,the thread name is:main
因为这些方法输出了相同的线程名称,表明即使递归使用synchronized也没有发生死锁,证明其是可重入的。
还看不懂?那我就再解释下!
这里的对象锁只有一个,就是 child 对象的锁,当执行 child.doSomething 时,该线程获得 child 对象的锁,在 doSomething 方法内执行 doAnotherThing 时再次请求child对象的锁,因为synchronized 是重入锁,所以可以得到该锁,继续在 doAnotherThing 里执行父类的 doSomething 方法时第三次请求 child 对象的锁,同样可得到。如果不是重入锁的话,那这后面这两次请求锁将会被一直阻塞,从而导致死锁。
所以在 java 内部,同一线程在调用自己类中其他 synchronized 方法/块或调用父类的 synchronized 方法/块都不会阻碍该线程的执行。就是说同一线程对同一个对象锁是可重入的,而且同一个线程可以获取同一把锁多次,也就是可以多次重入。因为java线程是基于“每线程(per-thread)”,而不是基于“每调用(per-invocation)”的(java中线程获得对象锁的操作是以线程为粒度的,per-invocation 互斥体获得对象锁的操作是以每调用作为粒度的)。
重入锁实现可重入性原理或机制是:每一个锁关联一个线程持有者和计数器,当计数器为 0 时表示该锁没有被任何线程持有,那么任何线程都可能获得该锁而调用相应的方法;当某一线程请求成功后,JVM会记下锁的持有线程,并且将计数器置为 1;此时其它线程请求该锁,则必须等待;而该持有锁的线程如果再次请求这个锁,就可以再次拿到这个锁,同时计数器会递增;当线程退出同步代码块时,计数器会递减,如果计数器为 0,则释放该锁。
36、JVM 对 Java 的原生锁做了哪些优化?
在 Java 6 之前,Monitor 的实现完全依赖底层操作系统的互斥锁来 实现,也就是我们刚才在问题二中所阐述的获取/释放锁的逻辑。
由于 Java 层面的线程与操作系统的原生线程有映射关系,如果要将一 个线程进行阻塞或唤起都需要操作系统的协助,这就需要从用户态切换 到内核态来执行,这种切换代价十分昂贵,很耗处理器时间,现代 JDK中做了大量的优化。一种优化是使用自旋锁,即在把线程进行阻塞操作之前先让线程自旋等待一段时间,可能在等待期间其他线程已经解锁,这时就无需再让线程 执行阻塞操作,避免了用户态到内核态的切换。
现代 JDK 中还提供了三种不同的 Monitor 实现,也就是三种不同的锁:
- 偏向锁(Biased Locking)
- 轻量级锁
- 重量级锁
这三种锁使得 JDK 得以优化 Synchronized 的运行,当 JVM 检测 到不同的竞争状况时,会自动切换到适合的锁实现,这就是锁的升级、 降级。
- 当没有竞争出现时,默认会使用偏向锁。JVM 会利用 CAS 操作,在对象头上的 Mark Word 部分设置线程ID,以表示这个对象偏向于当前线程,所以并不涉及真正的互斥锁,因 为在很多应用场景中,大部分对象生命周期中最多会被一个线程锁定, 使用偏斜锁可以降低无竞争开销。
- 如果有另一线程试图锁定某个被偏斜过的对象,JVM 就撤销偏斜锁, 切换到轻量级锁实现。
- 轻量级锁依赖 CAS 操作 Mark Word 来试图获取锁,如果重试成功, 就使用普通的轻量级锁;否则,进一步升级为重量级锁。
37、为什么说 Synchronized 是非公平锁?
答:非公平主要表现在获取锁的行为上,并非是按照申请锁的时间前后给等 待线程分配锁的,每当锁被释放后,任何一个线程都有机会竞争到锁, 这样做的目的是为了提高执行性能,缺点是可能会产生线程饥饿现象。
38、为什么说 Synchronized 是一个悲观锁?乐观锁的实现原理 又是什么?什么是 CAS,它有什么特性?
答:Synchronized 显然是一个悲观锁,因为它的并发策略是悲观的:不管是否会产生竞争,任何的数据操作都必须要加锁、用户态核心态转 换、维护锁计数器和检查是否有被阻塞的线程需要被唤醒等操作。
随着硬件指令集的发展,我们可以使用基于冲突检测的乐观并发策略。先进行操作,如果没有其他线程征用数据,那操作就成功了; 如果共享数据有征用,产生了冲突,那就再进行其他的补偿措施。这种 乐观的并发策略的许多实现不需要线程挂起,所以被称为非阻塞同步。
乐观锁的核心算法是 CAS(Compareand Swap,比较并交换),它涉 及到三个操作数:内存值、预期值、新值。当且仅当预期值和内存值相 等时才将内存值修改为新值。这样处理的逻辑是,首先检查某块内存的值是否跟之前我读取时的一 样,如不一样则表示期间此内存值已经被别的线程更改过,舍弃本次操 作,否则说明期间没有其他线程对此内存值操作,可以把新值设置给此 块内存。
CAS 具有原子性,它的原子性由CPU 硬件指令实现保证,即使用JNI 调用 Native 方法调用由 C++ 编写的硬件级别指令,JDK 中提 供了 Unsafe 类执行这些操作。
39、乐观锁一定就是好的吗?
答:乐观锁避免了悲观锁独占对象的现象,同时也提高了并发性能,但它也 有缺点:
- 乐观锁只能保证一个共享变量的原子操作。如果多一个或几个变量,乐 观锁将变得力不从心,但互斥锁能轻易解决,不管对象数量多少及对象 颗粒度大小。
- 长时间自旋可能导致开销大。假如 CAS 长时间不成功而一直自旋,会 给 CPU 带来很大的开销。
- ABA 问题。CAS 的核心思想是通过比对内存值与预期值是否一样而判 断内存值是否被改过,但这个判断逻辑不严谨,假如内存值原来是 A, 后来被一条线程改为 B,最后又被改成了 A,则 CAS 认为此内存值并 没有发生改变,但实际上是有被其他线程改过的,这种情况对依赖过程 值的情景的运算结果影响很大。解决的思路是引入版本号,每次变量更新都把版本号加一。
40、谈一谈AQS框架。
AQS(AbstractQueuedSynchronizer 类)是一个用来构建锁和同步器 的框架,各种Lock 包中的锁(常用的有 ReentrantLock、 ReadWriteLock) , 以 及 其 他 如 Semaphore、 CountDownLatch, 甚 至是早期的 FutureTask 等,都是基于 AQS 来构建。
- AQS 在内部定义了一个 volatile int state 变量,表示同步状态:当线 程调用 lock 方法时 ,如果 state=0,说明没有任何线程占有共享资源 的锁,可以获得锁并将 state=1;如果 state=1,则说明有线程目前正在 使用共享变量,其他线程必须加入同步队列进行等待。
- AQS 通过 Node 内部类构成的一个双向链表结构的同步队列,来完成线 程获取锁的排队工作,当有线程获取锁失败后,就被添加到队列末尾。Node 类是对要访问同步代码的线程的封装,包含了线程本身及其状态叫waitStatus(有五种不同 取值,分别表示是否被阻塞,是否等待唤醒, 是否已经被取消等),每个 Node 结点关联其 prev 结点和 next 结 点,方便线程释放锁后快速唤醒下一个在等待的线程,是一个 FIFO 的过 程。Node 类有两个常量,SHARED 和 EXCLUSIVE,分别代表共享模式和独 占模式。所谓共享模式是一个锁允许多条线程同时操作(信号量Semaphore 就是基于 AQS 的共享模式实现的),独占模式是同一个时 间段只能有一个线程对共享资源进行操作,多余的请求线程需要排队等待 ( 如 ReentranLock) 。
- AQS 通过内部类 ConditionObject 构建等待队列(可有多个),当Condition 调用 wait() 方法后,线程将会加入等待队列中,而当Condition 调用 signal() 方法后,线程将从等待队列转移动同步队列中进行锁竞争。
- AQS 和 Condition 各自维护了不同的队列,在使用 Lock 和Condition 的时候,其实就是两个队列的互相移动。
41、ReentrantLock 是如何实现可重入性的?
答:ReentrantLock 内部自定义了同步器 Sync(Sync 既实现了 AQS, 又实现了 AOS,而 AOS 提供了一种互斥锁持有的方式),其实就是 加锁的时候通过 CAS 算法,将线程对象放到一个双向链表中,每次获 取锁的时候,看下当前维护的那个线程 ID 和当前请求的线程 ID 是否 一样,一样就可重入了。
42、Java中Semaphore是什么?
Java中的Semaphore是一种新的同步类,它是一个计数信号。从概念上讲,从概念上讲,信号量维护了一个许可集合。如有必要,在许可可用前会阻塞每一个 acquire(),然后再获取该许可。每个 release()添加一个许可,从而可能释放一个正在阻塞的获取者。但是,不使用实际的许可对象,Semaphore只对可用许可的号码进行计数,并采取相应的行动。信号量常常用于多线程的代码中,比如数据库连接池。
package com.mzc.common.concurrent;
import java.util.concurrent.Semaphore;
/**
* <p class="detail">
* 功能: Semaphore Test
* </p>
*
* @author Moore
* @ClassName Test semaphore.
* @Version V1.0.
* @date 2020.02.07 20:11:00
*/
public class TestSemaphore
static class Worker extends Thread
private int num;
private Semaphore semaphore;
public Worker(int num,Semaphore semaphore)
this.num = num;
this.semaphore = semaphore;
@Override
public void run()
try
// 抢许可
semaphore.acquire();
Thread.sleep(2000);
// 释放许可
semaphore.release();
catch (InterruptedException e)
e.printStackTrace();
public static void main(String[] args)
// 机器数目,即5个许可
Semaphore semaphore = new Semaphore(5);
// 8个线程去抢许可
for (int i = 0; i < 8; i++)
new Worker(i,semaphore).start();
43、Java 中的线程池是如何实现的?
- 在 Java 中,所谓的线程池中的“线程”,其实是被抽象为了一个静态 内部类 Worker,它基于 AQS 实现,存放在线程池的HashSet<Worker> workers 成员变量中;
- 而需要执行的任务则存放在成员变量 workQueue(BlockingQueue<Runnable> workQueue)中。这样,整个线程池实现的基本思想就是:从 workQueue 中不断取出 需要执行的任务,放在 Workers 中进行处理。
44、线程池中的线程是怎么创建的?是一开始就随着线程池的启动创建好的吗?
答:显然不是的。线程池默认初始化后不启动 Worker,等待有请求时才启动。每当我们调用 execute() 方法添加一个任务时,线程池会做如下判 断:
- 如果正在运行的线程数量小于 corePoolSize,那么马上创建线程运行这个任务;
- 如果正在运行的线程数量大于或等于 corePoolSize,那么将这个任务放入队列;
- 如果这时候队列满了,而且正在运行的线程数量小于maximumPoolSize,那么还是要创建非核心线程立刻运行这个任务;
- 如果队列满了,而且正在运行的线程数量大于或等于maximumPoolSize,那么线程池会抛出异常RejectExecutionException。
当一个线程完成任务时,它会从队列中取下一个任务来执行。当一个线程无事可做,超过一定的时间(keepAliveTime)时,线程池会判断。
如果当前运行的线程数大于 corePoolSize,那么这个线程就被停掉。所以线程池的所有任务完成后,它最终会收缩到 corePoolSize 的大小。
45、什么是竞争条件?如何发现和解决竞争?
两个线程同步操作同一个对象,使这个对象的最终状态不明——叫做竞争条件。竞争条件可以在任何应该由程序员保证原子操作的,而又忘记使用synchronized的地方。
唯一的解决方案就是加锁。
Java有两种锁可供选择:
- 对象或者类(class)的锁。每一个对象或者类都有一个锁。使用synchronized关键字获取。 synchronized加到static方法上面就使用类锁,加到普通方法上面就用对象锁。除此之外synchronized还可以用于锁定关键区域块(Critical Section)。 synchronized之后要制定一个对象(锁的携带者),并把关键区域用大括号包裹起来。synchronized(this)// critical code。
- 显示构建的锁(java.util.concurrent.locks.Lock),调用lock的lock方法锁定关键代码。
46、很多人都说要慎用 ThreadLocal,谈谈你的理解,使用ThreadLocal 需要注意些什么?
答:使 用 ThreadLocal 要 注 意 remove!
ThreadLocal 的实现是基于一个所谓的 ThreadLocalMap,在ThreadLocalMap 中,它的 key 是一个弱引用。通常弱引用都会和引用队列配合清理机制使用,但是 ThreadLocal 是 个例外,它并没有这么做。这意味着,废弃项目的回收依赖于显式地触发,否则就要等待线程结 束,进而回收相应 ThreadLocalMap!这就是很多 OOM 的来源,所 以通常都会建议,应用一定要自己负责 remove,并且不要和线程池配 合,因为 worker 线程往往是不会退出的。
线程与锁
哲学家问题
问题描述:五位哲学家围绕一个圆桌就做,桌上在每两位哲学家之间摆着一支筷子。哲学家的状态可能是“思考”或者“饥饿”。如果饥饿,哲学家将拿起他两边的筷子就餐一段时间。进餐结束后,哲学家就会放回筷子。
代码实现:
public class Philosopher extends Thread
private Chopstick left;
private Chopstick right;
private Random random;
public Philosopher(Chopstick left, Chopstick right)
this.left = left;
this.right = right;
random = new Random();
@Override
public void run()
try
while (true)
Thread.sleep(random.nextInt(1000)); // 思考一会儿
synchronized (left) // 拿起左手的筷子
synchronized (right) // 拿起右手的筷子
Thread.sleep(random.nextInt(1000)); // 进餐
catch (InterruptedException e)
// handle exception
规避方法:
一个线程使用多把锁时,就需要考虑死锁的可能。幸运的是,如果总是按照一个全局的固定的顺序获得多把锁,就可以避开死锁。
public class Philosopher2 extends Thread
private Chopstick first;
private Chopstick second;
private Random random;
public Philosopher2(Chopstick left, Chopstick right)
if (left.getId() < right.getId())
first = left;
second = right;
else
first = right;
second = left;
random = new Random();
@Override
public void run()
try
while (true)
Thread.sleep(random.nextInt(1000)); // 思考一会儿
synchronized (first) // 拿起左手的筷子
synchronized (second) // 拿起右手的筷子
Thread.sleep(random.nextInt(1000)); // 进餐
catch (InterruptedException e)
// handle exception
外星方法
定义:调用这类方法时,调用者对方法的实现细节并不了解。
public class Downloader extends Thread
private InputStream in;
private OutputStream out;
private ArrayList<ProgressListener> listeners;
public Downloader(URL url, String outputFilename) throws IOException
in = url.openConnection().getInputStream();
out = new FileOutputStream(outputFilename);
listeners = new ArrayList<>();
public synchronized void addListener(ProgressListener listener)
listeners.add(listener);
public synchronized void removeListener(ProgressListener listener)
listeners.remove(listener);
private synchronized void updateProgress(int n)
for (ProgressListener listener : listeners)
listener.onProgress(n);
@Override
public void run()
// ...
这里 updateProgress(n) 方法调用了一个外星方法,这个外星方法可能做任何事,比如持有另外一把锁。
可以这样来修改:
private void updateProgress(int n)
ArrayList<ProgressListener> listenersCopy;
synchronized (this)
listenersCopy = (ArrayList<ProgressListener>) listeners.clone();
for (ProgressListener listener : listenersCopy)
listener.onProgress(n);
线程与锁模型带来的三个主要危害:
竞态条件
死锁
内存可见性
规避原则:
对共享变量的所有访问都需要同步化
读线程和写线程都需要同步化
按照约定的全局顺序来获取多把锁
当持有锁时避免调用外星方法
持有锁的时间应尽可能短
内置锁
内置锁限制:
无法中断 一个线程因为等待内置锁而进入阻塞之后,就无法中断该线程了;
无法超时 尝试获取内置锁时,无法设置超时;
不灵活 获得内置锁,必须使用 synchronized 块。
synchronized( object )
<<使用共享资源>>
ReentrantLock
其提供了显式的lock和unlock, 可以突破以上内置锁的几个限制。
ReentrantLock lock = new ReentrantLock();
lock.lock();
try
<<使用共享资源>>
finally
lock.unlock()
可中断
使用内置锁时,由于阻塞的线程无法被中断,程序不可能从死锁中恢复。
内置锁:制造一个死锁:
public class Uninterruptible
public static void main(String[] args) throws InterruptedException
final Object o1 = new Object();
final Object o2 = new Object();
Thread t1 = new Thread()
@Override
public void run()
try
synchronized (o1)
Thread.sleep(1000);
synchronized (o2)
catch (InterruptedException e)
System.out.println("Thread-1 interrupted");
;
Thread t2 = new Thread()
@Override
public void run()
try
synchronized (o2)
Thread.sleep(1000);
synchronized (o1)
catch (InterruptedException e)
System.out.println("Thread-2 interrupted");
;
t1.start();
t2.start();
Thread.sleep(2000);
t1.interrupt();
t2.interrupt();
t1.join();
t2.join();
ReentrantLock 替代内置锁:
public class Interruptible
public static void main(String[] args)
final ReentrantLock lock1 = new ReentrantLock();
final ReentrantLock lock2 = new ReentrantLock();
Thread t1 = new Thread()
@Override
public void run()
try
lock1.lockInterruptibly();
Thread.sleep(1000);
lock2.lockInterruptibly();
catch (InterruptedException e)
System.out.println("Thread-1 interrupted");
;
// ...
可超时
利用 ReentrantLock 超时设置解决哲学家问题:
public class Philosopher3 extends Thread
private ReentrantLock leftChopstick;
private ReentrantLock rightChopstick;
private Random random;
public Philosopher3(ReentrantLock leftChopstick, ReentrantLock rightChopstick)
this.leftChopstick = leftChopstick;
this.rightChopstick = rightChopstick;
random = new Random();
@Override
public void run()
try
while (true)
Thread.sleep(random.nextInt(1000)); // 思考一会儿
leftChopstick.lock();
try
// 获取右手边的筷子
if (rightChopstick.tryLock(1000, TimeUnit.MILLISECONDS))
try
Thread.sleep(random.nextInt(1000));
finally
rightChopstick.unlock();
else
// 没有获取到右手边的筷子,放弃并继续思考
finally
leftChopstick.unlock();
catch (InterruptedException e)
// ...
交替锁
场景:在链表中插入一个节点时,使用交替锁只锁住链表的一部分,而不是用锁保护整个链表。
线程安全链表:
public class ConcurrentSortedList // 降序有序链表
private class Node
int value;
Node pre;
Node next;
ReentrantLock lock = new ReentrantLock();
Node()
Node(int value, Node pre, Node next)
this.value = value;
this.pre = pre;
this.next = next;
private final Node head;
private final Node tail;
public ConcurrentSortedList()
this.head = new Node();
this.tail = new Node();
this.head.next = tail;
this.tail.pre = head;
public void insert(int value)
Node current = this.head;
current.lock.lock();
Node next = current.next;
try
while (true)
next.lock.lock();
try
if (next == tail || next.value < value)
Node newNode = new Node(value, current, next);
next.pre = newNode;
current.next = newNode;
return;
finally
current.lock.unlock();
current = next;
next = current.next;
finally
next.lock.unlock();
public int size()
Node current = tail; // 这里为什么要是从尾部开始遍历呢? 因为插入是从头部开始遍历的
int count = 0;
while (current != head)
ReentrantLock lock = current.lock;
lock.lock();
try
++count;
current = current.pre;
以上是关于Java并发多线程高频面试题的主要内容,如果未能解决你的问题,请参考以下文章