面试题-多线程


1. 说说synchronized的实现原理

  • 答案

    在 Java 中,每个对象都隐式包含一个 monitor(监视器)对象,加锁的过程其实就是竞争monitor 的过程,当线程进入字节码 monitorenter 指令之后,线程将持有 monitor 对象,执行 monitorexit 时释放 monitor 对象,当其他线程没有拿到 monitor 对象时,则需要阻塞等待获取该对象。


2. ReentrantLock与synchronized的区别

  • 答案

    ReentrantLock 有如下特点:

    1. 可重入

      ReentrantLock 和 syncronized 关键字一样,都是可重入锁,不过两者实现原理稍有差别,ReentrantLock 利用** AQS 的的 state **状态来判断资源是否已锁,同一线程重入加锁,state 的状态 +1 ; 同一线程重入解锁, state 状态 -1 (解锁必须为当前独占线程,否则异常); 当 state 为 0 时解锁成功。

    2. 需要手动加锁、解锁

      synchronized 关键字是自动进行加锁、解锁的,而 ReentrantLock 需要** lock() 和unlock() **方法配合 try/finally 语句块来完成,来手动加锁、解锁。

    3. 支持设置锁的超时时间

      synchronized 关键字无法设置锁的超时时间,如果一个获得锁的线程内部发生死锁,那么其他线程就会一直进入阻塞状态,而 ReentrantLock 提供** tryLock 方法**,允许设置线程获取锁的超时时间,如果超时,则跳过,不进行任何操作,避免死锁的发生。

    4. 支持公平/非公平锁

      synchronized 关键字是一种非公平锁先抢到锁的线程先执行。而ReentrantLock 的构造方法中允许设置 true/false 来实现公平、非公平锁,如果设置为 true ,则线程获取锁要遵循"先来后到"的规则,每次都会构造一个线程 Node ,然后到双向链表的"尾巴"后面排队,等待前面的 Node 释放锁资源。

    5. 可中断锁

      ReentrantLock 中的 lockInterruptibly() 方法使得线程可以在被阻塞时响应中断,比如一个线程 t1 通过 lockInterruptibly() 方法获取到一个可重入锁,并执行一个长时间的任务,另一个线程通过 interrupt() 方法就可以立刻打断 t1 线程的执行,来获取t1持有的那个可重入锁。而通过 ReentrantLock 的 lock() 方法或者Synchronized 持有锁的线程是不会响应其他线程的 interrupt() 方法的,直到该方法主动释放锁之后才会响应interrupt() 方法。


3. ReentrantLock实现原理(重要)

  • 答案

    https://blog.csdn.net/yanbin0830/article/details/107542529


4. Java原子类AtomicInteger实现原理(重要)

  • 答案


5. Java线程池实现原理(重要)

  • 答案


6. ThreadLocal实现原理(重要)

  • 答案


7. InheritableThreadLocal原理知道吗?

  • 答案

    https://www.jianshu.com/p/94ba4a918ff5


8. 说一下synchronized锁升级的过程?

  • 答案
    1. 偏向锁

      在JDK1.8中,其实默认是轻量级锁,但如果设定了参数 -XX: BiasedLockingStartupDelay = 0,那在对一个Object做synchronized的时候,会立即上一把偏向锁。当处于偏向锁状态时,markword会记录当前线程ID。

    2. 升级到轻量级锁

      当下一个线程参与到偏向锁竞争时,会先判断markword保存的线程ID是否与这个线程ID相等,如果不相等,会立即撤销偏向锁,升级为轻量级锁。每个线程在自己的线程栈中生成一个LockRecord(LR),然后每个线程通过CAS(自旋)的操作将锁对象头中的markwork设置为指向自己的LR的指针,哪个线程设置成功,就意味着获得锁。关于synchronized中此时执行的CAS操作是通过native的调用HotSpot中bytecodeInterpreter.cpp文件C++代码实现的,有兴趣的可以继续深挖。

    3. 升级到重量级锁

      如果锁竞争加剧(如线程自旋次数或者自旋的线程数超过某阈值,JDK1.6之后,由JVM自己控制该规则),就会升级为重量级锁。此时就会向操作系统申请资源,线程挂起,进入到操作系统内核态的等待队列中,等待操作系统调度,然后映射回用户态。在重量级锁中,由于需要做内核态到用户态的转换,而这个过程中需要消耗较多的时间,也就是“重”的原因之一。


9. 了解过什么是“伪共享”吗?

  • 答案

    CPU缓存从内存读数据时,是按缓存行读取的,即使只用到一个变量,也要将整行数据进行读取,这行数据量可能包含其他变量。当多个线程同时修改同一个缓存行里的不同变量时,由于同时只能有一个线程在操作,所以相比将每个变量放到不同缓存行里,性能会有所下降。多个线程同时修改了同一个缓存行上的不同变量,由于不能并发修改,所以称为“伪共享”。


10. “伪共享”出现的原因是什么?

  • 答案

    因为CPU缓存和内存交换数据的单位是缓存行,而同一个缓存行里的多个变量不能同时被多个线程修改。


11. 如何避免“伪共享”?

  • 答案
    1. 字节填充(创建变量时,使用字段对其进行填充,避免多个变量被分派到同一个缓存行里)。
    2. JDK8提供了一个Contended注解来解决伪共享。

12. Java里的线程有哪些状态?

  • 答案
    1. 初始(NEW):新创建了一个线程对象,但还没有调用start()方法。
    2. 运行(RUNNABLE):Java线程中将就绪(ready)和运行中(running)两种状态笼统的称为“运行”。线程对象创建后,其他线程(比如main线程)调用了该对象的start()方法。该状态的线程位于可运行线程池中,等待被线程调度选中,获取CPU的使用权,此时处于就绪状态(ready)。就绪状态的线程在获得CPU时间片后变为运行中状态(running)。
    3. 阻塞(BLOCKED):表示线程阻塞于锁。
    4. 等待(WAITING):进入该状态的线程需要等待其他线程做出一些特定动作(通知或中断)。
    5. 超时等待(TIMED_WAITING):该状态不同于WAITING,它可以在指定的时间后自行返回。
    6. 终止(TERMINATED):表示该线程已经执行完毕。

13. 什么是悲观锁?什么是乐观锁?

  • 答案

    CAS+Synchronized


14. 说一下你对volatile的理解?

  • 答案

15. 并发编程的三要素?

  • 答案

    1)原子性

    原子性指的是一个或者多个操作,要么全部执行并且在执行的过程中不被其他操作打断,要么就全部都不执行。

    2)可见性

    可见性是多个线程操作一个共享变量时,其中一个线程对变量进行修改后,其他线程可以立即看到修改的结果。

    3)有序性

    有序性即程序的执行顺序按照代码的先后顺序来执行。


16. 创建线程有哪些方式?

  • 答案
    • 继承Thread类创建线程类
    • 通过Runnable接口创建线程类
    • 通过Callable和Future创建线程
    • 通过线程池创建

17. 线程池的优点?

  • 答案

    → 重用存在的线程,减少对象频繁创建和销毁的开销

    → 可有效的控制最大并发线程数,提高系统资源的使用率,同时避免过多资源竞争,避免堵塞

    → 提供定时执行、定期执行、单线程、并发数控制等功能


18. CyclicBarrier和CountDownLatch的区别

  • 答案

    1)CountDownLatch简单的说就是一个线程等待,直到他所等待的其他线程都执行完成并且调用countDown()方法发出通知后,当前线程才可以继续执行。

    2)cyclicBarrier是所有线程都进行等待,直到所有线程都准备好进入await()方法之后,所有线程同时开始执行!

    3)CountDownLatch的计数器只能使用一次。而CyclicBarrier的计数器可以使用reset() 方法重置。所以CyclicBarrier能处理更为复杂的业务场景,比如如果计算发生错误,可以重置计数器,并让线程们重新执行一次。

    4)CyclicBarrier还提供其他有用的方法,比如getNumberWaiting方法可以获得CyclicBarrier阻塞的线程数量。isBroken方法用来知道阻塞的线程是否被中断。如果被中断返回true,否则返回false。


19. 什么是CAS?

  • 答案

    CAS是compare and swap 的缩写,即我们所说的比较交换。

    CAS是一种基于锁的操作,而且是乐观锁。在Java中锁分为乐观锁和悲观锁。悲观锁是将资源锁住,等一个之前获得锁的线程释放锁之后,下一个线程才可以访问。而乐观锁采取了一种宽泛的态度,通过某种方式不加锁来处理资源,比如通过给记录加version来获取数据,性能较悲观锁有很大的提高。

    CAS操作包含三个操作数 —— 内存位置(V)、预期原值(A)和新值(B)。如果内存地址里面的值和A的值是一样的,那么就将内存里面的值更新成B。CAS是通过无限循环来获取数据的,如果在第一轮循环中,a线程获取地址里面的值被b线程修改了,那么a线程需要自旋,到下次循环才有可能有机会执行。

    java.util.concurrent.atomic包下的类大多是使用CAS操作来实现的。


20. CAS的问题

  • 答案

    1)CAS容易造成ABA问题

    一个线程a将数值改成了b,接着又改成了a,此时CAS认为是没有变化,其实是已经变化过了,而这个问题的解决方案可以使用版本号标识,每操作一次version加1。在java5中,已经提供了AtomicStampedReference来解决问题。

    2) 不能保证代码块的原子性

    CAS机制所保证的只是一个变量的原子性操作,而不能保证整个代码块的原子性。比如需要保证3个变量共同进行原子性的更新,就不得不使用synchronized了。

    3)CAS造成CPU利用率增加

    之前说过了CAS里面是一个循环判断的过程,如果线程一直没有获取到状态,cpu资源会一直被占用。


21. 什么是AQS?

  • 答案

    AQS是AbstractQueuedSynchronizer的简称,它是一个Java提高的底层同步工具类,用一个int类型的变量表示同步状态,并提供了一系列的CAS操作来管理这个同步状态。

    AQS是一个用来构建锁和同步器的框架,使用AQS能简单且高效地构造出应用广泛的大量的同步器,比如我们提到的ReentrantLock,Semaphore,其他的诸如ReentrantReadWriteLock,SynchronousQueue,FutureTask等等皆是基于AQS的。


22. 什么是自旋锁?

  • 答案

    自旋锁是SMP架构中的一种low-level的同步机制。

    当线程A想要获取一把自旋锁而该锁又被其他线程锁持有时,线程A会在一个循环中自旋以检测锁是不是可用了。

    自旋需要注意的是:

    由于自旋时不释放CPU,因而持有自旋锁的线程应该尽快释放自旋锁,否则等待该自旋锁的线程会一直在那里自旋,这就会浪费CPU时间。

    持有自旋锁的线程在sleep之前应该释放自旋锁以便其他线程可以获得自旋锁。


23. 什么是多线程的上下文切换?

  • 答案

    即使是单核CPU也支持多线程执行代码,CPU通过给每个线程分配CPU时间片来实现这个机制。时间片是CPU分配给各个线程的时间,因为时间片非常短,所以CPU通过不停地切换线程执行,让我们感觉多个线程时同时执行的,时间片一般是几十毫秒(ms)。

    上下文切换过程中,CPU会停止处理当前运行的程序,并保存当前程序运行的具体位置以便之后继续运行CPU通过时间片分配算法来循环执行任务,当前任务执行一个时间片后会切换到下一个任务。

    但是,在切换前会保存上一个任务的状态,以便下次切换回这个任务时,可以再次加载这个任务的状态从任务保存到再加载的过程就是一次上下文切换。


24. 什么是线程和进程?

  • 答案

    进程:在操作系统中能够独立运行,并且作为资源分配的基本单位。它表示运行中的程序。系统运行一个程序就是一个进程从创建、运行到消亡的过程。

    线程:是一个比进程更小的执行单位,能够完成进程中的一个功能,也被称为轻量级进程。一个进程在其执行的过程中可以产生多个线程。

    线程与进程不同的是:同类的多个线程共享进程的堆和方法区资源,但每个线程有自己的程序计数器、虚拟机栈和本地方法栈,所以系统在产生一个线程,或是在各个线程之间作切换工作时,负担要比进程小得多。


25. 程序计数器为什么是私有的?

  • 答案

    程序计数器主要有下面两个作用:
    字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制,如:顺序执
    行、选择、循环、异常处理。

    在多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来的时候能够知道该线程上次运行到哪儿了。

    (需要注意的是,如果执行的是 native 方法,那么程序计数器记录的是 undefined 地址,只有执行的是 Java 代码时程序计数器记录的才是下一条指令的地址。)

    所以,程序计数器私有主要是为了线程切换后能恢复到正确的执行位置。


26. 虚拟机栈和本地方法栈为什么是私有的?

  • 答案

    虚拟机栈: 每个 Java 方法在执行的同时会创建一个栈帧用于存储局部变量表、操作数栈、常量池引用等信息。从方法调用直至执行完成的过程,就对应着一个栈帧在 Java 虚拟机栈中入栈和出栈的过程。

    本地方法栈: 和虚拟机栈所发挥的作用非常相似,区别是: 虚拟机栈为虚拟机执行 Java 方法(也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。 在HotSpot 虚拟机中和 Java 虚拟机栈合二为一。

    所以,为了保证线程中的局部变量不被别的线程访问到,虚拟机栈和本地方法栈是线程私有的。


27. 并发和并行的区别?

  • 答案

    并发指的是多个任务交替进行,并行则是指真正意义上的“同时进行”。

    实际上,如果系统内只有一个CPU,使用多线程时,在真实系统环境下不能并行,只能通过切换时间片的方式交替进行,从而并发执行任务。真正的并行只能出现在拥有多个CPU的系统中。


28. 什么是线程死锁?如何避免死锁?

  • 答案

    多个线程同时被阻塞,它们中的一个或者全部都在等待某个资源被释放。由于线程被无限期地阻塞,因此程序不可能正常终止。

    假如线程 A 持有资源 2,线程 B 持有资源 1,他们同时都想申请对方的资源,所以这两个线程就会互相等待而进入死锁状态。

    避免死锁的几个常见方法:

    • 避免一个线程同时获取多个锁

    • 避免一个线程在锁内同时占用多个资源,尽量保证每个锁只占用一个资源。

    • 尝试使用定时锁,使用 lock.tryLock(timeout) 来代替使用内部锁机制。

    • 对于数据库锁,加锁和解锁必须在一个数据库连接里,否则会出现解锁失败的情况。


29. sleep()方法和wait()方法的区别和共同点?

  • 答案

    相同点:

    两者都可以暂停线程的执行,都会让线程进入等待状态

    不同点:

    sleep()方法没有释放锁,而 wait()方法释放了锁。

    sleep()方法属于Thread类的静态方法,作用于当前线程;而wait()方法是Object类的实例方法,作用于对象本身。执行sleep()方法后,可以通过超时或者调用interrupt()方法唤醒休眠中的线程;执行wait()方法后,通过调用notify()或notifyAll()方法唤醒等待线程。


30. 如何解决线程安全问题?

  • 答案

    1)尽量不使用共享变量,将不必要的共享变量变成局部变量来使用。

    2)使用synchronized关键字同步代码块,或者使用jdk包中提供的Lock为操作进行加锁。

    3)使用ThreadLocal为每一个线程建立一个变量的副本,各个线程间独立操作,互不影响。


31. synchronized关键字和volatile关键字的区别

  • 答案

    volatile关键字是线程同步的轻量级实现,所以volatile性能比synchronized关键字要好。但是volatile关键字只能用于变量而synchronized关键字可以修饰方法以及代码块。
    多线程访问volatile关键字不会发生阻塞,而synchronized关键字可能会发生阻塞。

    volatile关键字主要用于解决变量在多个线程之间的可见性,而 synchronized关键字解决的是多个线程之间访问资源的同步性

    volatile关键字能保证数据的可见性,但不能保证数据的原子性。synchronized关键字两者都能保证。


32. 说一说几种常见的线程池及适用场景?

  • 答案

    FixedThreadPool:可重用固定线程数的线程池。(适用于负载比较重的服务器
    FixedThreadPool使用无界队列LinkedBlockingQueue作为线程池的工作队列该线程池中的线程数量始终不变。当有一个新的任务提交时,线程池中若有空闲线程,则立即执行。若没有,则新的任务会被暂存在一个任务队列中,待有线程空闲时,便处理在任务队列中的任务。

    SingleThreadExecutor:只会创建一个线程执行任务。(适用于需要保证顺序执行各个任务;并且在任意时间点,没有多线程活动的场景。)SingleThreadExecutor也使用无界队列LinkedBlockingQueue作为工作队列若多余一个任务被提交到该线程池,任务会被保存在一个任务队列中,待线程空闲,按先入先出的顺序执行队列中的任务。

    CachedThreadPool:是一个会根据需要调整线程数量的线程池。(大小无界,适用于执行很多的短期异步任务的小程序,或负载较轻的服务器)CachedThreadPool使用没有容量的SynchronousQueue作为线程池的工作队列,但CachedThreadPool的maximumPool是无界的。线程池的线程数量不确定,但若有空闲线程可以复用,则会优先使用可复用的线程。若所有线程均在工作,又有新的任务提交,则会创建新的线程处理任务。所有线程在当前任务执行完毕后,将返回线程池进行复用。

    ScheduledThreadPool:继承自ThreadPoolExecutor。它主要用来在给定的延迟之后运行任务,或者定期执行任务。使用DelayQueue作为任务队列。


33. 线程池都有哪几种工作队列

  • 答案
    • ArrayBlockingQueue:是一个基于数组结构的有界阻塞队列,此队列按FIFO(先进先出)原则对元素进行排序。
    • LinkedBlockingQueue:是一个基于链表结构的阻塞队列,此队列按FIFO排序元素,吞吐量通常要高于ArrayBlockingQueue。静态工厂方法Executors.newFixedThreadPool()使用了这个队列。
    • SynchronousQueue:是一个不存储元素的阻塞队列。每个插入操作必须等到另一个线程调用移除操作,否则插入操作一直处于阻塞状态,吞吐量通常要高于Linked-BlockingQueue,静态工厂方法Executors.newCachedThreadPool使用了这个队列。
    • PriorityBlockingQueue:一个具有优先级的无限阻塞队列。

34. Java中如何获取到线程dump文件

  • 答案

    死循环、死锁、阻塞、页面打开慢等问题,打线程dump是最好的解决问题的途径。所谓线程dump也就是线程堆栈,获取到线程堆栈有两步:

    1)获取到线程的pid,可以通过使用jps命令,在Linux环境下还可以使用

    ps -ef | grep java

    2)打印线程堆栈,可以通过使用jstack pid命令,在Linux环境下还可以使用

    kill -3 pid

    另外提一点,Thread类提供了一个getStackTrace()方法也可以用于获取线程堆栈。这是一个实例方法,因此此方法是和具体线程实例绑定的,每次获取获取到的是具体某个线程当前运行的堆栈。


35. 同步方法和同步块,哪个是更好的选择?

  • 答案

    同步块,这意味着同步块之外的代码是异步执行的,这比同步整个方法更提升代码的效率。请知道一条原则:同步的范围越小越好。

    虽说同步的范围越少越好,但是在Java虚拟机中还是存在着一种叫做锁粗化的优化方法,这种方法就是把同步范围变大。这是有用的,比方说StringBuffer,它是一个线程安全的类,自然最常用的append()方法是一个同步方法,我们写代码的时候会反复append字符串,这意味着要进行反复的加锁->解锁,这对性能不利,因为这意味着Java虚拟机在这条线程上要反复地在内核态和用户态之间进行切换,因此Java虚拟机会将多次append方法调用的代码进行一个锁粗化的操作,将多次的append的操作扩展到append方法的头尾,变成一个大的同步块,这样就减少了加锁–>解锁的次数,有效地提升了代码执行的效率。