首頁  >  文章  >  Java  >  Java並發基礎常見面試題(總結)

Java並發基礎常見面試題(總結)

青灯夜游
青灯夜游轉載
2019-11-23 16:37:152227瀏覽

這篇文章為大家總結了一下Java並發基礎常見面試題,有一定的參考價值,有需要的朋友可以參考一下,希望對大家有所幫助。

Java並發基礎常見面試題(總結)

1. 什麼是執行緒與進程?

1.1. 何為進程?

進程是程式的一次執行過程,是系統運行程式的基本單位,因此進程是動態的。系統運行一個程式即是一個進程從創建,運行到消亡的過程。

在 Java 中,當我們啟動 main 函數時其實就是啟動了一個 JVM 的進程,而 main 函數所在的線程就是這個進程中的一個線程,也稱為主線程。

如下圖所示,在 windows 中透過查看任務管理器的方式,我們就可以清楚地看到 window 目前運行的進程(.exe 檔案的運作)。

Java並發基礎常見面試題(總結)

1.2. 何為執行緒?

執行緒與進程相似,但執行緒是一個比進程更小的執行單位。一個行程在執行的過程中可以產生多個執行緒。與行程不同的是同類的多個執行緒共享程序的方法區資源,但每個執行緒有自己的程式計數器虛擬機器堆疊本地方法堆疊,所以系統在產生一個線程,或是在各個線程之間作切換工作時,負擔要比進程小得多,也因為如此,線程也被稱為輕量級進程。

Java 程式天生就是多線程程序,我們可以透過 JMX 來看一個普通的 Java 程式有哪些線程,程式碼如下。

public class MultiThread {
    public static void main(String[] args) {
        // 获取 Java 线程管理 MXBean
    ThreadMXBean threadMXBean = ManagementFactory.getThreadMXBean();
        // 不需要获取同步的 monitor 和 synchronizer 信息,仅获取线程和线程堆栈信息
        ThreadInfo[] threadInfos = threadMXBean.dumpAllThreads(false, false);
        // 遍历线程信息,仅打印线程 ID 和线程名称信息
        for (ThreadInfo threadInfo : threadInfos) {
            System.out.println("[" + threadInfo.getThreadId() + "] " + threadInfo.getThreadName());
        }
    }
}

上述程式輸出如下(輸出內容可能不同,不用太糾結下面每個執行緒的作用,只用知道main 執行緒執行main 方法即可):

[5] Attach Listener //添加事件
[4] Signal Dispatcher // 分发处理给 JVM 信号的线程
[3] Finalizer //调用对象 finalize 方法的线程
[2] Reference Handler //清除 reference 线程
[1] main //main 线程,程序入口

從上面的輸出內容可以看出:一個Java 程式的運行是main 執行緒和多個其他執行緒同時執行

2. 請簡單描述執行緒與進程的關係,差異及優缺點?

從JVM 角度說進程和執行緒之間的關係

2.1. 圖解進程和執行緒的關係

下圖是Java 記憶體區域,透過下圖我們從JVM 的角度來說一下執行緒和進程之間的關係。如果你對 Java 記憶體區域(運行時資料區) 這部分知識不太了解的話可以閱讀一下這篇文章:《可能是把Java 內存區域講的最清楚的一篇文章》

 
Java並發基礎常見面試題(總結)

#從上圖可以看出:一個行程中可以有多個執行緒,多個執行緒共享程序的方法區(JDK1.8 之後的元空間)資源,但是每個執行緒有自己的程式計數器虛擬機器堆疊本地方法堆疊

總結: 執行緒 是 進程 分割成的更小的運行單位。執行緒和進程最大的不同在於基本上各進程是獨立的,而各執行緒則不一定,因為同一進程中的執行緒極有可能會相互影響。執行緒執行開銷小,但不利於資源的管理和保護;而進程正相反

下面是該知識點的擴展內容!

下面來思考這樣一個問題:為什麼程式計數器虛擬機器堆疊本地方法堆疊是執行緒私有的呢?為什麼堆和方法區是執行緒共享的呢?

2.2. 程式計數器為什麼是私有的?

#程式計數器主要有以下兩個作用:

  1. 字節碼解釋器透過改變程式計數器來依序讀取指令,從而實現程式碼的流程控制,如:順序執行、選擇、迴圈、異常處理。
  2. 在多執行緒的情況下,程式計數器用於記錄目前執行緒執行的位置,從而當執行緒被切換回來的時候能夠知道該執行緒上次運行到哪裡了。

要注意的是,如果執行的是 native 方法,那麼程式計數器記錄的是 undefined 位址,只有執行的是 Java 程式碼時程式計數器記錄的才是下一指令的位址。

所以,程式計數器私有主要是為了執行緒切換後能恢復到正確的執行位置

2.3. 虛擬機器堆疊和本機方法堆疊為什麼是私有的?

  • 虛擬機棧: 每個 Java 方法在執行的同時會建立一個堆疊幀用於儲存局部變數表、操作數棧、常數池參考等資訊。從方法呼叫至執行完成的過程,就對應一個堆疊幀在 Java 虛擬機器棧中入棧和出棧的過程。
  • 本機方法堆疊: 和虛擬機器堆疊所扮演的角色非常相似,差異是: 虛擬機器堆疊為虛擬機器執行Java 方法(也就是字節碼)服務,而本機方法堆疊則為虛擬機器使用到的Native 方法服務。 在 HotSpot 虛擬機器中和 Java 虛擬機器堆疊合而為一。

所以,為了保證執行緒中的局部變數不會被別的執行緒存取到,虛擬機器堆疊和本地方法堆疊是執行緒私有的。

2.4. 一句話簡單地了解堆和方法區

#堆和方法區是所有執行緒共享的資源,其中堆是進程中最大的一塊內存,主要用於存放新創建的對象(所有對像都在這裡分配內存),方法區主要用於存放已被加載的類信息、常量、靜態變量、即時編譯器編譯後的代碼等數據。

3. 說說並發與並行的區別?

  • #並發: 同一時間段,多個任務都在執行(單位時間內不一定同時執行);
  • 並行: 單位時間內,多個任務同時執行。

4. 為什麼要使用多執行緒呢?

#先從整體上來說:

  • #從電腦底層來說: 執行緒可以比喻為是輕量級的進程,是程式執行的最小單位,執行緒間的切換和調度的成本遠小於進程。另外,多核心 CPU 時代意味著多個執行緒可以同時運行,這減少了執行緒上下文切換的開銷。
  • 從當代互聯網發展趨勢來說: 現在的系統動不動就要求百萬級甚至千萬級的並發量,而多線程並發編程正是開發高並發系統的基礎,利用好多執行緒機制可以大幅提升系統整體的並發能力以及效能。

再深入到電腦底層來探討:

  • 單核心時代​​: 在單核心時代​​多執行緒主要是為了提高CPU 和IO 設備的綜合利用率。舉個例子:當只有一個執行緒的時候會導致 CPU 運算時,IO 設備空閒;進行 IO 操作時,CPU 空閒。我們可以簡單地說這兩者的利用率目前都是 50%左右。但當有兩個執行緒的時候就不一樣了,當一個執行緒執行 CPU 運算時,另外一個執行緒可以進行 IO 操作,這樣兩個的利用率就可以在理想情況下達到 100%了。
  • 多核心時代: 多核心時代多執行緒主要是為了提高 CPU 使用率。舉個例子:如果我們要計算一個複雜的任務,我們只用一個線程的話,CPU 只會一個CPU 核心被利用到,而創建多個線程就可以讓多個CPU 核心被利用到,這樣就提高了CPU 的使用率。

5. 使用多執行緒可能帶來什麼問題?

#並發程式設計的目的就是為了能提高程式的執行效率提高程式運行速度,但是並發程式設計並不總是能提高程式運行速度的,而且並發程式設計可能會遇到很多問題,例如:記憶體洩漏、上下文切換、死鎖還有受限於硬體和軟體的資源閒置問題。

6. 說說執行緒的生命週期和狀態?

#Java 執行緒在執行的生命週期中的指定時刻只可能處於下面6 種不同狀態的其中一個狀態(圖源《Java 並發程式設計藝術》4.1.4 節)。

Java並發基礎常見面試題(總結)

執行緒在生命週期中並不是固定處於某一個狀態而是隨著程式碼的執行在不同狀態之間切換。 Java 執行緒狀態變遷如下圖所示(圖源《Java 並發程式設計藝術》4.1.4 節):

Java並發基礎常見面試題(總結)

由上圖可以看出:在執行緒建立之後它將處於NEW(新建) 狀態,呼叫start() 方法後開始執行,執行緒這時候處於READY(可運行) 狀態。可運行狀態的執行緒獲得了 CPU 時間片(timeslice)後就處於 RUNNING(運行) 狀態。

操作系统隐藏 Java 虚拟机(JVM)中的 RUNNABLE 和 RUNNING 状态,它只能看到 RUNNABLE 状态(图源:HowToDoInJavaJava Thread Life Cycle and Thread States),所以 Java 系统一般将这两个状态统称为 RUNNABLE(运行中) 状态 。

Java並發基礎常見面試題(總結)

当线程执行 wait()方法之后,线程进入 WAITING(等待) 状态。进入等待状态的线程需要依靠其他线程的通知才能够返回到运行状态,而 TIME_WAITING(超时等待) 状态相当于在等待状态的基础上增加了超时限制,比如通过 sleep(long millis)方法或 wait(long millis)方法可以将 Java 线程置于 TIMED WAITING 状态。当超时时间到达后 Java 线程将会返回到 RUNNABLE 状态。当线程调用同步方法时,在没有获取到锁的情况下,线程将会进入到 BLOCKED(阻塞) 状态。线程在执行 Runnable 的 run() 方法之后将会进入到 TERMINATED(终止) 状态。

7. 什么是上下文切换?

多线程编程中一般线程的个数都大于 CPU 核心的个数,而一个 CPU 核心在任意时刻只能被一个线程使用,为了让这些线程都能得到有效执行,CPU 采取的策略是为每个线程分配时间片并轮转的形式。当一个线程的时间片用完的时候就会重新处于就绪状态让给其他线程使用,这个过程就属于一次上下文切换。

概括来说就是:当前任务在执行完 CPU 时间片切换到另一个任务之前会先保存自己的状态,以便下次再切换回这个任务时,可以再加载这个任务的状态。任务从保存到再加载的过程就是一次上下文切换

上下文切换通常是计算密集型的。也就是说,它需要相当可观的处理器时间,在每秒几十上百次的切换中,每次切换都需要纳秒量级的时间。所以,上下文切换对系统来说意味着消耗大量的 CPU 时间,事实上,可能是操作系统中时间消耗最大的操作。

Linux 相比与其他操作系统(包括其他类 Unix 系统)有很多的优点,其中有一项就是,其上下文切换和模式切换的时间消耗非常少。

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

8.1. 认识线程死锁

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

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

Java並發基礎常見面試題(總結)

下面通过一个例子来说明线程死锁,代码模拟了上图的死锁的情况 (代码来源于《并发编程之美》):

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();
    }
}

Output

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 休眠结束了都开始企图请求获取对方的资源,然后这两个线程就会陷入互相等待的状态,这也就产生了死锁。上面的例子符合产生死锁的四个必要条件。

学过操作系统的朋友都知道产生死锁必须具备以下四个条件:

  1. 互斥条件:该资源任意一个时刻只由一个线程占用。
  2. 请求与保持条件:一个进程因请求资源而阻塞时,对已获得的资源保持不放。
  3. 不剥夺条件:线程已获得的资源在末使用完之前不能被其他线程强行剥夺,只有自己使用完毕后才释放资源。
  4. 循环等待条件:若干进程之间形成一种头尾相接的循环等待资源关系。

8.2. 如何避免线程死锁?

我们只要破坏产生死锁的四个条件中的其中一个就可以了。

破坏互斥条件

这个条件我们没有办法破坏,因为我们用锁本来就是想让他们互斥的(临界资源需要互斥访问)。

破坏请求与保持条件

一次性申请所有的资源。

破坏不剥夺条件

占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源。

破坏循环等待条件

靠按序申请资源来预防。按某一顺序申请资源,释放资源则反序释放。破坏循环等待条件。

我们对线程 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();

Output

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 获取到就可以执行了。这样就破坏了破坏循环等待条件,因此避免了死锁。

9. 说说 sleep() 方法和 wait() 方法区别和共同点?

  • 两者最主要的区别在于:sleep 方法没有释放锁,而 wait 方法释放了锁
  • 两者都可以暂停线程的执行。
  • Wait 通常被用于线程间交互/通信,sleep 通常被用于暂停执行。
  • wait() 方法被调用后,线程不会自动苏醒,需要别的线程调用同一个对象上的 notify() 或者 notifyAll() 方法。sleep() 方法执行完成后,线程会自动苏醒。或者可以使用wait(long timeout)超时后线程会自动苏醒。

10. 为什么我们调用 start() 方法时会执行 run() 方法,为什么我们不能直接调用 run() 方法?

这是另一个非常经典的 java 多线程面试问题,而且在面试中会经常被问到。很简单,但是很多人都会答不上来!

new 一个 Thread,线程进入了新建状态;调用 start() 方法,会启动一个线程并使线程进入了就绪状态,当分配到时间片后就可以开始运行了。 start() 会执行线程的相应准备工作,然后自动执行 run() 方法的内容,这是真正的多线程工作。 而直接执行 run() 方法,会把 run 方法当成一个 main 线程下的普通方法去执行,并不会在某个线程中执行它,所以这并不是多线程工作。

总结: 调用 start 方法方可启动线程并使线程进入就绪状态,而 run 方法只是 thread 的一个普通方法调用,还是在主线程里执行。

推荐教程:java教程

以上是Java並發基礎常見面試題(總結)的詳細內容。更多資訊請關注PHP中文網其他相關文章!

陳述:
本文轉載於:segmentfault.com。如有侵權,請聯絡admin@php.cn刪除