Java並行篇:6個必備的Java並行面試種子題目

2023-07-26 12:00:53

執行緒建立和生命週期

執行緒的建立和生命週期涉及到執行緒的產生、執行和結束過程。讓我們繼續深入探索這個主題:

執行緒的建立方式有多種,你可以選擇適合你場景的方式:

繼承Thread類: 建立一個類,繼承自Thread類,並重寫run()方法。通過範例化這個類的物件,並呼叫start()方法,系統會自動呼叫run()方法執行執行緒邏輯。

public class MyThread extends Thread {
    public void run() {
        // 執行緒邏輯程式碼
    }
}
// 建立並啟動執行緒
MyThread thread = new MyThread();
thread.start();

實現Runnable介面: 建立一個類,實現Runnable介面,並實現run()方法。通過將實現了Runnable介面的物件作為引數傳遞給Thread類別建構函式,然後呼叫start()方法啟動執行緒。

public class MyRunnable implements Runnable {
    public void run() {
        // 執行緒邏輯程式碼
    }
}
// 建立並啟動執行緒
MyRunnable runnable = new MyRunnable();
Thread thread = new Thread(runnable);
thread.start();

實現Callable介面: 建立一個類,實現Callable介面,並實現call()方法。通過建立一個FutureTask物件,將Callable物件作為引數傳遞給FutureTask建構函式,然後將FutureTask物件傳遞給Thread類別建構函式,最後呼叫start()方法啟動執行緒。

public class MyCallable implements Callable<Integer> {
    public Integer call() {
        // 執行緒邏輯程式碼
        return 1; 
    }
}
// 建立並啟動執行緒
MyCallable callable = new MyCallable();
FutureTask<Integer> futureTask = new FutureTask<>(callable);
Thread thread = new Thread(futureTask);
thread.start();

通過執行緒池建立執行緒: 使用Java的執行緒池ExecutorService來管理執行緒的生命週期。通過提交Runnable或Callable任務給執行緒池,執行緒池會負責建立、執行和終止執行緒。

ExecutorService executorService = Executors.newFixedThreadPool(5);
executorService.execute(new Runnable() {
    public void run() {
        // 執行緒邏輯程式碼
    }
});
executorService.shutdown();

執行緒的生命週期經歷以下幾個狀態:

  • 建立狀態: 通過範例化Thread物件或者執行緒池來建立執行緒。此時執行緒處於新建狀態。
  • 就緒狀態: 執行緒被建立後,呼叫start()方法使其進入就緒狀態。在就緒狀態下,執行緒等待系統分配執行的時間片。
  • 執行狀態: 一旦執行緒獲取到CPU的時間片,就進入執行狀態,執行run()方法中的執行緒邏輯。
  • 阻塞狀態(Blocked/Waiting/Sleeping): 在某些情況下,執行緒需要暫時放棄CPU的執行權,進入阻塞狀態。阻塞狀態可以分為多種情況:
  • 中斷狀態: 可以通過呼叫執行緒的interrupt()方法將執行緒從執行狀態轉移到中斷狀態。執行緒可以檢查自身是否被中斷,並根據需要作出適當的處理。
  • 終止狀態: 執行緒執行完run()方法中的邏輯或者通過呼叫stop()方法被終止後,執行緒進入終止狀態。終止的執行緒不能再次啟動。

理解執行緒的建立和生命週期對於處理並行程式設計非常重要。通過選擇合適的建立方式和正確地管理執行緒的生命週期,可以確保執行緒安全、高效地執行,從而優化程式效能。

深入剖析synchronized

synchronized關鍵字在Java中用於實現執行緒安全的程式碼塊,在其背後使用JVM底層內建的鎖機制。synchronized的設計考慮了各種並行情況,因此具有以下優點:

  • 優點: 由於官方對synchronized進行升級優化,如當前鎖升級機制,因此它具有不斷改進的潛力。JVM會進行鎖的升級優化,以提高並行效能。
    然而,synchronized也有一些缺點:
  • 缺點: 如果使用不當,可能會導致鎖粒度過大或鎖失效的問題。此外,synchronized只適用於單機情況,對於分散式叢集環境的鎖機制不適用。

synchronized的鎖機制包括以下幾個階段的升級過程:

  • 無鎖狀態: 初始狀態為無鎖狀態,多個執行緒可以同時存取臨界區。
  • 偏向鎖: 當只有一個執行緒存取臨界區時,JVM會將鎖升級為偏向鎖,以提高效能。在偏向鎖狀態下,偏向執行緒可以直接獲取鎖,無需競爭。
  • (自旋)輕量級鎖: 當多個執行緒競爭同一個鎖時,偏向鎖會升級為輕量級鎖。在輕量級鎖狀態下,執行緒會自旋一定次數,嘗試獲取鎖,而不是直接阻塞。
  • 重量級鎖: 當自旋次數超過閾值或者存在多個執行緒競爭同一個鎖時,輕量級鎖會升級為重量級鎖。重量級鎖使用了傳統的互斥量機制,需要進行阻塞和喚醒操作。

需要注意的是,如果在輕量級鎖狀態下,有執行緒獲取物件的HashCode時,會直接升級為重量級鎖。這是因為鎖升級過程中使用的mark頭將HashCode部分隱去,以確保鎖升級過程的正確性。

底層實現中,synchronized使用了monitor enter和monitor exit指令來進行進入鎖和退出鎖的同步操作。對於使用者來說,這些操作是不可見的。synchronized鎖的等待佇列儲存在物件的waitset屬性中,用於執行緒的等待和喚醒操作。

雙重檢查單例模式解析

範例程式碼:

public class Singleton {
    private static volatile Singleton instance;

private Singleton() {
    // 私有構造方法
}

public static Singleton getInstance() {
    if (instance == null) {
        synchronized (Singleton.class) {
            if (instance == null) {
                instance = new Singleton();
            }
        }
    }
    return instance;
}
}

為什麼需要使用volatile: 雖然synchronized關鍵字可以確保執行緒安全,但是如果沒有volatile修飾,可能會發生指令重排的問題。volatile關鍵字的主要作用是防止指令重排,保證可見性和有序性。盡
管在實際工作中很少遇到指令重排導致的問題,但是理論上存在這種可能性,因此使用volatile修飾變數可以避免出現意外情況。

指令重排原因及影響: 指令重排是為了優化程式的執行速度,由於CPU的工作速度遠大於記憶體的工作速度,為了充分利用CPU資源,處理器會對指令進行重新排序。例如在建立一個物件的過程中,通常被拆分為三個步驟:1)申請空間並初始化,2)賦值,3)建立地址連結關係。如果沒有考慮逃逸分析,可能會發生指令重排的情況。

這種重排可能導致的問題是,當一個執行緒在某個時刻執行到步驟2,而另一個執行緒在此時獲取到了物件的參照,但是這個物件還沒有完成初始化,導致使用到未完全初始化的物件,可能會出現異常或不正確的結果。通過使用volatile關鍵字,可以禁止指令重排,確保物件的完全初始化後再進行賦值操作。

抽象佇列同步器(Abstract Queued Synchronizer)解析

抽象佇列同步器(Abstract Queued Synchronizer)是Java並行程式設計中非常重要的同步框架,被廣泛應用於各種鎖實現類,如ReentrantLock、CountDownLatch等。AQS提供了基於雙端佇列的同步機制,支援獨佔模式和共用模式,並提供了一些基本的操作方法。

在AQS中,用來表示是否是獨佔鎖的Exclusive屬性物件非常重要。它可以控制同一時間只有一個執行緒能夠獲取鎖,並且支援重入機制。另外,AQS的state屬性也非常關鍵,state的含義和具體用途是由具體的子類決定的。子類可以通過對state屬性的操作來實現不同的同步邏輯。例如,在ReentrantLock中,state表示鎖的持有數;在CountDownLatch中,state表示還需要等待的執行緒數。

此外,AQS還使用兩個Node節點來表示雙端佇列,用於儲存被阻塞的執行緒。這些節點會根據執行緒的不同狀態(如等待獲取鎖、等待釋放鎖)被新增到佇列的不同位置,從而實現執行緒同步和排程。

以下是一個簡化的範例程式碼,展示瞭如何使用ReentrantLock和AQS進行執行緒同步:

import java.util.concurrent.locks.ReentrantLock;

public class Example {
    private static final ReentrantLock lock = new ReentrantLock();

public static void main(String[] args) {
    Thread thread1 = new Thread(() -> {
        lock.lock();
        try {
            // 執行執行緒1的邏輯
        } finally {
            lock.unlock();
        }
    });

    Thread thread2 = new Thread(() -> {
        lock.lock();
        try {
            // 執行執行緒2的邏輯
        } finally {
            lock.unlock();
        }
    });

    thread1.start();
    thread2.start();
}

}

在上述程式碼中,我們使用了ReentrantLock作為鎖工具,它內部使用了AQS來實現執行緒同步。通過呼叫lock()方法獲取鎖,並在finally塊中呼叫unlock()方法釋放鎖,確保執行緒安全執行。這樣,只有一個執行緒能夠獲取到鎖,並執行相應的邏輯。

總之,AQS作為Java執行緒同步的核心框架,在並行程式設計中起到了至關重要的作用。它提供了強大的同步機制,可以支援各種鎖的實現,幫助我們實現執行緒安全的程式碼。

利用Java執行緒池

使用Java執行緒池是一種優化並行性的有效方式。執行緒池可以管理和複用執行緒,減少了執行緒建立和銷燬的開銷,提高了系統的效能和資源利用率。

在Java中,可以使用ExecutorService介面來建立和管理執行緒池。ExecutorService提供了一些方法來提交任務並返回Future物件,可以用於獲取任務的執行結果。

在建立執行緒池時,可以根據實際需求選擇不同的執行緒池型別。常用的執行緒池型別包括:

  • FixedThreadPool:固定大小的執行緒池,執行緒數固定不變。
  • CachedThreadPool:可根據需要自動調整執行緒數的執行緒池。
  • SingleThreadExecutor:只有一個執行緒的執行緒池,適用於順序執行任務的場景。
  • ScheduledThreadPool:用於定時執行任務的執行緒池。

使用執行緒池時,可以將任務分解為多個小任務,提交給執行緒池並行執行。這樣可以充分利用系統資源,提高任務執行的並行性。

同時,執行緒池還可以控制並行執行緒的數量,避免系統資源耗盡和任務過載的問題。通過設定合適的執行緒池大小,可以平衡系統的並行能力和資源消耗。

探索Java中的Fork/Join框架

Fork/Join框架是Java中用於處理並行任務的一個強大工具。它基於分治的思想,將大任務劃分成小任務,並利用多執行緒並行執行這些小任務,最後將結果合併。

在Fork/Join框架中,主要有兩個核心類:ForkJoinTask和ForkJoinPool。ForkJoinTask是一個可以被分割成更小任務的任務,我們需要繼承ForkJoinTask類並實現compute()方法來定義具體的任務邏輯。ForkJoinPool是一個執行緒池,用於管理和排程ForkJoinTask。

下面是一個簡單的例子,展示如何使用Fork/Join框架來計算一個整數陣列的總和:

import java.util.concurrent.*;

public class SumTask extends RecursiveTask<Integer> {
    private static final int THRESHOLD = 10;
    private int[] array;
    private int start;
    private int end;


public SumTask(int[] array, int start, int end) {
    this.array = array;
    this.start = start;
    this.end = end;
}

@Override
protected Integer compute() {
    if (end - start <= THRESHOLD) {
        int sum = 0;
        for (int i = start; i < end; i++) {
            sum += array[i];
        }
        return sum;
    } else {
        int mid = (start + end) / 2;
        SumTask leftTask = new SumTask(array, start, mid);
        SumTask rightTask = new SumTask(array, mid, end);
        leftTask.fork(); // 將左半部分任務提交到執行緒池
        rightTask.fork(); // 將右半部分任務提交到執行緒池
        int leftResult = leftTask.join(); // 等待左半部分任務的完成並獲取結果
        int rightResult = rightTask.join(); // 等待右半部分任務的完成並獲取結果
        return leftResult + rightResult;
    }
}

public static void main(String[] args) {
    int[] array = new int[100];
    for (int i = 0; i < array.length; i++) {
        array[i] = i + 1;
    }
    ForkJoinPool forkJoinPool = new ForkJoinPool();
    SumTask sumTask = new SumTask(array, 0, array.length);
    int result = forkJoinPool.invoke(sumTask); // 使用執行緒池來執行任務
    System.out.println("Sum: " + result);
}


}

在這個例子中,我們定義了一個SumTask類,繼承自RecursiveTask類,並實現了compute()方法。在compute()方法中,我們判斷任務的大小是否小於閾值,如果是,則直接計算陣列的總和;如果不是,則將任務劃分成兩個子任務,並使用fork()方法將子任務提交到執行緒池中,然後使用join()方法等待子任務的完成並獲取結果,最後返回子任務結果的和。

在main()方法中,我們建立了一個ForkJoinPool物件,然後建立了一個SumTask物件,並使用invoke()方法來執行任務。最後列印出結果。

通過使用Fork/Join框架,我們可以方便地處理並行任務,並利用多核處理器的效能優勢。這個框架在處理一些需要遞迴分解的問題時非常高效。

總結

文章涉及了幾個常見的並行程式設計相關的主題。首先,執行緒的建立和生命週期是面試中常被問及的話題,面試官可能會詢問如何建立執行緒、執行緒的狀態轉換以及如何控制執行緒的執行順序等。其次,synchronized關鍵字是用於實現執行緒同步的重要工具,面試中可能會涉及到它的使用場景以及與其他同步機制的比較。此外,抽象佇列同步器(AQS)是Java並行程式設計中的核心概念,瞭解其原理和應用場景可以展示對並行程式設計的深入理解。最後,面試中可能會考察對Java執行緒池和Fork/Join框架的瞭解,包括它們的使用方法、優勢和適用場景等。種子題目務必學會