應屆生/社招面試最愛問的幾道Java基礎問題

編程技匠 發佈 2020-01-11T09:24:36+00:00

一 為什麼 Java 中只有值傳遞?首先回顧一下在程序設計語言中有關將參數傳遞給方法(或函數)的一些專業術語。按值調用(call by value)表示方法接收的是調用者提供的值,而按引用調用(call by reference)表示方法接收的是調用者提供的變量地址。

一 為什麼 Java 中只有值傳遞?

首先回顧一下在程序設計語言中有關將參數傳遞給方法(或函數)的一些專業術語。按值調用(call by value)表示方法接收的是調用者提供的值,而按引用調用(call by reference)表示方法接收的是調用者提供的變量地址。一個方法可以修改傳遞引用所對應的變量值,而不能修改傳遞值調用所對應的變量值。 它用來描述各種程序設計語言(不只是 Java)中方法參數傳遞方式。

Java 程序設計語言總是採用按值調用。也就是說,方法得到的是所有參數值的一個拷貝,也就是說,方法不能修改傳遞給它的任何參數變量的內容。

下面通過 3 個例子來給大家說明

example 1

public static void main(String[] args) {
    int num1 = 10;
    int num2 = 20;

    swap(num1, num2);

    System.out.println("num1 = " + num1);
    System.out.println("num2 = " + num2);
}

public static void swap(int a, int b) {
    int temp = a;
    a = b;
    b = temp;

    System.out.println("a = " + a);
    System.out.println("b = " + b);
}

結果:

a = 20
b = 10
num1 = 10
num2 = 20

解析:

在 swap 方法中,a、b 的值進行交換,並不會影響到 num1、num2。因為,a、b 中的值,只是從 num1、num2 的複製過來的。也就是說,a、b 相當於 num1、num2 的副本,副本的內容無論怎麼修改,都不會影響到原件本身。

通過上面例子,我們已經知道了一個方法不能修改一個基本數據類型的參數,而對象引用作為參數就不一樣,請看 example2.

example 2

    public static void main(String[] args) {
        int[] arr = { 1, 2, 3, 4, 5 };
        System.out.println(arr[0]);
        change(arr);
        System.out.println(arr[0]);
    }

    public static void change(int[] array) {
        // 將數組的第一個元素變為0
        array[0] = 0;
    }

結果:

1
0

解析:

array 被初始化 arr 的拷貝也就是一個對象的引用,也就是說 array 和 arr 指向的是同一個數組對象。 因此,外部對引用對象的改變會反映到所對應的對象上。

通過 example2 我們已經看到,實現一個改變對象參數狀態的方法並不是一件難事。理由很簡單,方法得到的是對象引用的拷貝,對象引用及其他的拷貝同時引用同一個對象。

很多程序設計語言(特別是,C++和 Pascal)提供了兩種參數傳遞的方式:值調用和引用調用。有些程式設計師(甚至本書的作者)認為 Java 程序設計語言對對象採用的是引用調用,實際上,這種理解是不對的。由於這種誤解具有一定的普遍性,所以下面給出一個反例來詳細地闡述一下這個問題。

example 3

public class Test {

    public static void main(String[] args) {
        // TODO Auto-generated method stub
        Student s1 = new Student("小張");
        Student s2 = new Student("小李");
        Test.swap(s1, s2);
        System.out.println("s1:" + s1.getName());
        System.out.println("s2:" + s2.getName());
    }

    public static void swap(Student x, Student y) {
        Student temp = x;
        x = y;
        y = temp;
        System.out.println("x:" + x.getName());
        System.out.println("y:" + y.getName());
    }
}

結果:

x:小李
y:小張
s1:小張
s2:小李

解析:

交換之前:

交換之後:

通過上面兩張圖可以很清晰的看出: 方法並沒有改變存儲在變量 s1 和 s2 中的對象引用。swap 方法的參數 x 和 y 被初始化為兩個對象引用的拷貝,這個方法交換的是這兩個拷貝

總結

Java 程序設計語言對對象採用的不是引用調用,實際上,對象引用是按
值傳遞的。

下面再總結一下 Java 中方法參數的使用情況:

  • 一個方法不能修改一個基本數據類型的參數(即數值型或布爾型)。
  • 一個方法可以改變一個對象參數的狀態。
  • 一個方法不能讓對象參數引用一個新的對象。

參考:

《Java 核心技術卷 Ⅰ》基礎知識第十版第四章 4.5 小節

二 ==與 equals(重要)

== : 它的作用是判斷兩個對象的地址是不是相等。即,判斷兩個對象是不是同一個對象。(基本數據類型==比較的是值,引用數據類型==比較的是內存地址)

equals() : 它的作用也是判斷兩個對象是否相等。但它一般有兩種使用情況:

  • 情況 1:類沒有覆蓋 equals()方法。則通過 equals()比較該類的兩個對象時,等價於通過「==」比較這兩個對象。
  • 情況 2:類覆蓋了 equals()方法。一般,我們都覆蓋 equals()方法來兩個對象的內容相等;若它們的內容相等,則返回 true(即,認為這兩個對象相等)。

舉個例子:

public class test1 {
    public static void main(String[] args) {
        String a = new String("ab"); // a 為一個引用
        String b = new String("ab"); // b為另一個引用,對象的內容一樣
        String aa = "ab"; // 放在常量池中
        String bb = "ab"; // 從常量池中查找
        if (aa == bb) // true
            System.out.println("aa==bb");
        if (a == b) // false,非同一對象
            System.out.println("a==b");
        if (a.equals(b)) // true
            System.out.println("aEQb");
        if (42 == 42.0) { // true
            System.out.println("true");
        }
    }
}

說明:

  • String 中的 equals 方法是被重寫過的,因為 object 的 equals 方法是比較的對象的內存地址,而 String 的 equals 方法比較的是對象的值。
  • 當創建 String 類型的對象時,虛擬機會在常量池中查找有沒有已經存在的值和要創建的值相同的對象,如果有就把它賦給當前引用。如果沒有就在常量池中重新創建一個 String 對象。

三 hashCode 與 equals(重要)

面試官可能會問你:「你重寫過 hashcode 和 equals 麼,為什麼重寫 equals 時必須重寫 hashCode 方法?」

3.1 hashCode()介紹

hashCode() 的作用是獲取哈希碼,也稱為散列碼;它實際上是返回一個 int 整數。這個哈希碼的作用是確定該對象在哈希表中的索引位置。hashCode() 定義在 JDK 的 Object.java 中,這就意味著 Java 中的任何類都包含有 hashCode() 函數。另外需要注意的是: Object 的 hashcode 方法是本地方法,也就是用 c 語言或 c++ 實現的,該方法通常用來將對象的 內存地址 轉換為整數之後返回。

    /**
     * Returns a hash code value for the object. This method is
     * supported for the benefit of hash tables such as those provided by
     * {@link java.util.HashMap}.
     * <p>
     * As much as is reasonably practical, the hashCode method defined by
     * class {@code Object} does return distinct integers for distinct
     * objects. (This is typically implemented by converting the internal
     * address of the object into an integer, but this implementation
     * technique is not required by the
     * Java™ programming language.)
     *
     * @return  a hash code value for this object.
     * @see     java.lang.Object#equals(java.lang.Object)
     * @see     java.lang.System#identityHashCode
     */
    public native int hashCode();

散列表存儲的是鍵值對(key-value),它的特點是:能根據「鍵」快速的檢索出對應的「值」。這其中就利用到了散列碼!(可以快速找到所需要的對象)

3.2 為什麼要有 hashCode

我們以「HashSet 如何檢查重複」為例子來說明為什麼要有 hashCode:

當你把對象加入 HashSet 時,HashSet 會先計算對象的 hashcode 值來判斷對象加入的位置,同時也會與其他已經加入的對象的 hashcode 值作比較,如果沒有相符的 hashcode,HashSet 會假設對象沒有重複出現。但是如果發現有相同 hashcode 值的對象,這時會調用 equals()方法來檢查 hashcode 相等的對象是否真的相同。如果兩者相同,HashSet 就不會讓其加入操作成功。如果不同的話,就會重新散列到其他位置。(摘自我的 Java 啟蒙書《Head fist java》第二版)。這樣我們就大大減少了 equals 的次數,相應就大大提高了執行速度。

3.3 hashCode()與 equals()的相關規定

  1. 如果兩個對象相等,則 hashcode 一定也是相同的
  2. 兩個對象相等,對兩個對象分別調用 equals 方法都返回 true
  3. 兩個對象有相同的 hashcode 值,它們也不一定是相等的
  4. 因此,equals 方法被覆蓋過,則 hashCode 方法也必須被覆蓋
  5. hashCode()的默認行為是對堆上的對象產生獨特值。如果沒有重寫 hashCode(),則該 class 的兩個對象無論如何都不會相等(即使這兩個對象指向相同的數據)

3.4 為什麼兩個對象有相同的 hashcode 值,它們也不一定是相等的?

在這裡解釋一位小夥伴的問題。以下內容摘自《Head Fisrt Java》。

因為 hashCode() 所使用的雜湊算法也許剛好會讓多個對象傳回相同的雜湊值。越糟糕的雜湊算法越容易碰撞,但這也與數據值域分布的特性有關(所謂碰撞也就是指的是不同的對象得到相同的 hashCode)。

我們剛剛也提到了 HashSet,如果 HashSet 在對比的時候,同樣的 hashcode 有多個對象,它會使用 equals() 來判斷是否真的相同。也就是說 hashcode 只是用來縮小查找成本。

四 String 和 StringBuffer、StringBuilder 的區別是什麼?String 為什麼是不可變的?

可變性

簡單的來說:String 類中使用 final 關鍵字修飾字符數組來保存字符串,private final char value[],所以 String 對象是不可變的。而 StringBuilder 與 StringBuffer 都繼承自 AbstractStringBuilder 類,在 AbstractStringBuilder 中也是使用字符數組保存字符串char[]value 但是沒有用 final 關鍵字修飾,所以這兩種對象都是可變的。

StringBuilder 與 StringBuffer 的構造方法都是調用父類構造方法也就是 AbstractStringBuilder 實現的,大家可以自行查閱源碼。

AbstractStringBuilder.java

abstract class AbstractStringBuilder implements Appendable, CharSequence {
    char[] value;
    int count;
    AbstractStringBuilder() {
    }
    AbstractStringBuilder(int capacity) {
        value = new char[capacity];
    }

線程安全性

String 中的對象是不可變的,也就可以理解為常量,線程安全。AbstractStringBuilder 是 StringBuilder 與 StringBuffer 的公共父類,定義了一些字符串的基本操作,如 expandCapacity、append、insert、indexOf 等公共方法。StringBuffer 對方法加了同步鎖或者對調用的方法加了同步鎖,所以是線程安全的。StringBuilder 並沒有對方法進行加同步鎖,所以是非線程安全的。

性能

每次對 String 類型進行改變的時候,都會生成一個新的 String 對象,然後將指針指向新的 String 對象。StringBuffer 每次都會對 StringBuffer 對象本身進行操作,而不是生成新的對象並改變對象引用。相同情況下使用 StringBuilder 相比使用 StringBuffer 僅能獲得 10%~15% 左右的性能提升,但卻要冒多線程不安全的風險。

對於三者使用的總結:

  1. 操作少量的數據: 適用 String
  2. 單線程操作字符串緩衝區下操作大量數據: 適用 StringBuilder
  3. 多線程操作字符串緩衝區下操作大量數據: 適用 StringBuffer

String 為什麼是不可變的嗎?

簡單來說就是 String 類利用了 final 修飾的 char 類型數組存儲字符,源碼如下圖所以:

    /** The value is used for character storage. */
    private final char value[];

String 真的是不可變的嗎?

我覺得如果別人問這個問題的話,回答不可變就可以了。
下面只是給大家看兩個有代表性的例子:

1) String 不可變但不代表引用不可以變

        String str = "Hello";
        str = str + " World";
        System.out.println("str=" + str);

結果:

str=Hello World

解析:

實際上,原來 String 的內容是不變的,只是 str 由原來指向"Hello"的內存地址轉為指向"Hello World"的內存地址而已,也就是說多開闢了一塊內存區域給"Hello World"字符串。

2) 通過反射是可以修改所謂的「不可變」對象

        // 創建字符串"Hello World", 並賦給引用s
        String s = "Hello World";

        System.out.println("s = " + s); // Hello World

        // 獲取String類中的value欄位
        Field valueFieldOfString = String.class.getDeclaredField("value");

        // 改變value屬性的訪問權限
        valueFieldOfString.setAccessible(true);

        // 獲取s對象上的value屬性的值
        char[] value = (char[]) valueFieldOfString.get(s);

        // 改變value所引用的數組中的第5個字符
        value[5] = '_';

        System.out.println("s = " + s); // Hello_World

結果:

s = Hello World
s = Hello_World

解析:

用反射可以訪問私有成員, 然後反射出 String 對象中的 value 屬性, 進而改變通過獲得的 value 引用改變數組的結構。但是一般我們不會這麼做,這裡只是簡單提一下有這個東西。

五 什麼是反射機制?反射機制的應用場景有哪些?

5.1 反射機制介紹

JAVA 反射機制是在運行狀態中,對於任意一個類,都能夠知道這個類的所有屬性和方法;對於任意一個對象,都能夠調用它的任意一個方法和屬性;這種動態獲取的信息以及動態調用對象的方法的功能稱為 java 語言的反射機制。

5.2 靜態編譯和動態編譯

  • 靜態編譯:在編譯時確定類型,綁定對象
  • 動態編譯:運行時確定類型,綁定對象

5.3 反射機制優缺點

  • 優點: 運行期類型的判斷,動態加載類,提高代碼靈活度。
  • 缺點: 性能瓶頸:反射相當於一系列解釋操作,通知 JVM 要做的事情,性能比直接的 java 代碼要慢很多。

5.4 反射的應用場景

反射是框架設計的靈魂。

在我們平時的項目開發過程中,基本上很少會直接使用到反射機制,但這不能說明反射機制沒有用,實際上有很多設計、開發都與反射機制有關,例如模塊化的開發,通過反射去調用對應的字節碼;動態代理設計模式也採用了反射機制,還有我們日常使用的 Spring/Hibernate 等框架也大量使用到了反射機制。

舉例:① 我們在使用 JDBC 連接資料庫時使用 Class.forName()通過反射加載資料庫的驅動程序;②Spring 框架也用到很多反射機制,最經典的就是 xml 的配置模式。Spring 通過 XML 配置模式裝載 Bean 的過程:1) 將程序內所有 XML 或 Properties 配置文件加載入內存中;
2)Java 類裡面解析 xml 或 properties 裡面的內容,得到對應實體類的字節碼字符串以及相關的屬性信息; 3)使用反射機制,根據這個字符串獲得某個類的 Class 實例; 4)動態配置實例的屬性

推薦閱讀:

  • Reflection:Java 反射機制的應用場景
  • Java 基礎之—反射(非常重要)

六 什麼是 JDK?什麼是 JRE?什麼是 JVM?三者之間的聯繫與區別

6.1 JVM

Java 虛擬機(JVM)是運行 Java 字節碼的虛擬機。JVM 有針對不同系統的特定實現(Windows,Linux,macOS),目的是使用相同的字節碼,它們都會給出相同的結果。

什麼是字節碼?採用字節碼的好處是什麼?

在 Java 中,JVM 可以理解的代碼就叫做字節碼(即擴展名為 .class 的文件),它不面向任何特定的處理器,只面向虛擬機。Java 語言通過字節碼的方式,在一定程度上解決了傳統解釋型語言執行效率低的問題,同時又保留了解釋型語言可移植的特點。所以 Java 程序運行時比較高效,而且,由於字節碼並不針對一種特定的機器,因此,Java 程序無須重新編譯便可在多種不同作業系統的計算機上運行。

Java 程序從原始碼到運行一般有下面 3 步:

我們需要格外注意的是 .class->機器碼 這一步。在這一步 JVM 類加載器首先加載字節碼文件,然後通過解釋器逐行解釋執行,這種方式的執行速度會相對比較慢。而且,有些方法和代碼塊是經常需要被調用的(也就是所謂的熱點代碼),所以後面引進了 JIT 編譯器,而 JIT 屬於運行時編譯。當 JIT 編譯器完成第一次編譯後,其會將字節碼對應的機器碼保存下來,下次可以直接使用。而我們知道,機器碼的運行效率肯定是高於 Java 解釋器的。這也解釋了我們為什麼經常會說 Java 是編譯與解釋共存的語言。

HotSpot 採用了惰性評估(Lazy Evaluation)的做法,根據二八定律,消耗大部分系統資源的只有那一小部分的代碼(熱點代碼),而這也就是 JIT 所需要編譯的部分。JVM 會根據代碼每次被執行的情況收集信息並相應地做出一些優化,因此執行的次數越多,它的速度就越快。JDK 9 引入了一種新的編譯模式 AOT(Ahead of Time Compilation),它是直接將字節碼編譯成機器碼,這樣就避免了 JIT 預熱等各方面的開銷。JDK 支持分層編譯和 AOT 協作使用。但是 ,AOT 編譯器的編譯質量是肯定比不上 JIT 編譯器的。

總結:

Java 虛擬機(JVM)是運行 Java 字節碼的虛擬機。JVM 有針對不同系統的特定實現(Windows,Linux,macOS),目的是使用相同的字節碼,它們都會給出相同的結果。字節碼和不同系統的 JVM 實現是 Java 語言「一次編譯,隨處可以運行」的關鍵所在。

6.2 JDK 和 JRE

JDK 是 Java Development Kit,它是功能齊全的 Java SDK。它擁有 JRE 所擁有的一切,還有編譯器(javac)和工具(如 javadoc 和 jdb)。它能夠創建和編譯程序。

JRE 是 Java 運行時環境。它是運行已編譯 Java 程序所需的所有內容的集合,包括 Java 虛擬機(JVM),Java 類庫,java 命令和其他的一些基礎構件。但是,它不能用於創建新程序。

如果你只是為了運行一下 Java 程序的話,那麼你只需要安裝 JRE 就可以了。如果你需要進行一些 Java 編程方面的工作,那麼你就需要安裝 JDK 了。但是,這不是絕對的。有時,即使您不打算在計算機上進行任何 Java 開發,仍然需要安裝 JDK。例如,如果要使用 JSP 部署 Web 應用程式,那麼從技術上講,您只是在應用程式伺服器中運行 Java 程序。那你為什麼需要 JDK 呢?因為應用程式伺服器會將 JSP 轉換為 Java servlet,並且需要使用 JDK 來編譯 servlet。

七 什麼是字節碼?採用字節碼的最大好處是什麼?

先看下 java 中的編譯器和解釋器:

Java 中引入了虛擬機的概念,即在機器和編譯程序之間加入了一層抽象的虛擬的機器。這台虛擬的機器在任何平台上都提供給編譯程序一個的共同的接口。編譯程序只需要面向虛擬機,生成虛擬機能夠理解的代碼,然後由解釋器來將虛擬機代碼轉換為特定系統的機器碼執行。在 Java 中,這種供虛擬機理解的代碼叫做字節碼(即擴展名為.class的文件),它不面向任何特定的處理器,只面向虛擬機。每一種平台的解釋器是不同的,但是實現的虛擬機是相同的。Java 源程序經過編譯器編譯後變成字節碼,字節碼由虛擬機解釋執行,虛擬機將每一條要執行的字節碼送給解釋器,解釋器將其翻譯成特定機器上的機器碼,然後在特定的機器上運行。這也就是解釋了 Java 的編譯與解釋並存的特點。

Java 原始碼---->編譯器---->jvm 可執行的 Java 字節碼(即虛擬指令)---->jvm---->jvm 中解釋器----->機器可執行的二進位機器碼---->程序運行。

採用字節碼的好處:

Java 語言通過字節碼的方式,在一定程度上解決了傳統解釋型語言執行效率低的問題,同時又保留了解釋型語言可移植的特點。所以 Java 程序運行時比較高效,而且,由於字節碼並不專對一種特定的機器,因此,Java 程序無須重新編譯便可在多種不同的計算機上運行。

八 接口和抽象類的區別是什麼?

  1. 接口的方法默認是 public,所有方法在接口中不能有實現,抽象類可以有非抽象的方法
  2. 接口中的實例變量默認是 final 類型的,而抽象類中則不一定
  3. 一個類可以實現多個接口,但最多只能實現一個抽象類
  4. 一個類實現接口的話要實現接口的所有方法,而抽象類不一定
  5. 接口不能用 new 實例化,但可以聲明,但是必須引用一個實現該接口的對象 從設計層面來說,抽象是對類的抽象,是一種模板設計,接口是行為的抽象,是一種行為的規範。

注意:Java8 後接口可以有默認實現( default )。

九 重載和重寫的區別

重載

發生在同一個類中,方法名必須相同,參數類型不同、個數不同、順序不同,方法返回值和訪問修飾符可以不同。

下面是《Java 核心技術》對重載這個概念的介紹:

重寫

重寫是子類對父類的允許訪問的方法的實現過程進行重新編寫,發生在子類中,方法名、參數列表必須相同,返回值範圍小於等於父類,拋出的異常範圍小於等於父類,訪問修飾符範圍大於等於父類。另外,如果父類方法訪問修飾符為 private 則子類就不能重寫該方法。也就是說方法提供的行為改變,而方法的外貌並沒有改變。

十. Java 面向對象編程三大特性: 封裝 繼承 多態

封裝

封裝把一個對象的屬性私有化,同時提供一些可以被外界訪問的屬性的方法,如果屬性不想被外界訪問,我們大可不必提供方法給外界訪問。但是如果一個類沒有提供給外界訪問的方法,那麼這個類也沒有什麼意義了。

繼承

繼承是使用已存在的類的定義作為基礎建立新類的技術,新類的定義可以增加新的數據或新的功能,也可以用父類的功能,但不能選擇性地繼承父類。通過使用繼承我們能夠非常方便地復用以前的代碼。

關於繼承如下 3 點請記住:

  1. 子類擁有父類對象所有的屬性和方法(包括私有屬性和私有方法),但是父類中的私有屬性和方法子類是無法訪問,只是擁有
  2. 子類可以擁有自己屬性和方法,即子類可以對父類進行擴展。
  3. 子類可以用自己的方式實現父類的方法。(以後介紹)。

多態

所謂多態就是指程序中定義的引用變量所指向的具體類型和通過該引用變量發出的方法調用在編程時並不確定,而是在程序運行期間才確定,即一個引用變量到底會指向哪個類的實例對象,該引用變量發出的方法調用到底是哪個類中實現的方法,必須在由程序運行期間才能決定。

在 Java 中有兩種形式可以實現多態:繼承(多個子類對同一方法的重寫)和接口(實現接口並覆蓋接口中同一方法)。

十一. 什麼是線程和進程?

11.1 何為進程?

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

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

如下圖所示,在 windows 中通過查看任務管理器的方式,我們就可以清楚看到 window 當前運行的進程(.exe 文件的運行)。

11.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 線程和多個其他線程同時運行

十二. 請簡要描述線程與進程的關係,區別及優缺點?

從 JVM 角度說進程和線程之間的關係

12.1 圖解進程和線程的關係

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

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

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

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

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

12.2 程序計數器為什麼是私有的?

程序計數器主要有下面兩個作用:

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

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

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

12.3 虛擬機棧和本地方法棧為什麼是私有的?

  • 虛擬機棧: 每個 Java 方法在執行的同時會創建一個棧幀用於存儲局部變量表、操作數棧、常量池引用等信息。從方法調用直至執行完成的過程,就對應著一個棧幀在 Java 虛擬機棧中入棧和出棧的過程。
  • 本地方法棧: 和虛擬機棧所發揮的作用非常相似,區別是: 虛擬機棧為虛擬機執行 Java 方法 (也就是字節碼)服務,而本地方法棧則為虛擬機使用到的 Native 方法服務。 在 HotSpot 虛擬機中和 Java 虛擬機棧合二為一。

所以,為了保證線程中的局部變量不被別的線程訪問到,虛擬機棧和本地方法棧是線程私有的。

12.4 一句話簡單了解堆和方法區

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

十三. 說說並發與並行的區別?

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

十四. 什麼是上下文切換?

多線程編程中一般線程的個數都大於 CPU 核心的個數,而一個 CPU 核心在任意時刻只能被一個線程使用,為了讓這些線程都能得到有效執行,CPU 採取的策略是為每個線程分配時間片並輪轉的形式。當一個線程的時間片用完的時候就會重新處於就緒狀態讓給其他線程使用,這個過程就屬於一次上下文切換。

概括來說就是:當前任務在執行完 CPU 時間片切換到另一個任務之前會先保存自己的狀態,以便下次再切換回這個任務時,可以再加載這個任務的狀態。任務從保存到再加載的過程就是一次上下文切換

上下文切換通常是計算密集型的。也就是說,它需要相當可觀的處理器時間,在每秒幾十上百次的切換中,每次切換都需要納秒量級的時間。所以,上下文切換對系統來說意味著消耗大量的 CPU 時間,事實上,可能是作業系統中時間消耗最大的操作。

Linux 相比與其他作業系統(包括其他類 Unix 系統)有很多的優點,其中有一項就是,其上下文切換和模式切換的時間消耗非常少。

十五. 什麼是線程死鎖?如何避免死鎖?

15.1. 認識線程死鎖

多個線程同時被阻塞,它們中的一個或者全部都在等待某個資源被釋放。由於線程被無限期地阻塞,因此程序不可能正常終止。

如下圖所示,線程 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();
    }
}

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. 循環等待條件:若干進程之間形成一種頭尾相接的循環等待資源關係。

15.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 獲取到就可以執行了。這樣就破壞了破壞循環等待條件,因此避免了死鎖。

十六. 說說 sleep() 方法和 wait() 方法區別和共同點?

  • 兩者最主要的區別在於:sleep 方法沒有釋放鎖,而 wait 方法釋放了鎖
  • 兩者都可以暫停線程的執行。
  • Wait 通常被用於線程間交互/通信,sleep 通常被用於暫停執行。
  • wait() 方法被調用後,線程不會自動甦醒,需要別的線程調用同一個對象上的 notify() 或者 notifyAll() 方法。sleep() 方法執行完成後,線程會自動甦醒。或者可以使用 wait(long timeout)超時後線程會自動甦醒。

十七. 為什麼我們調用 start() 方法時會執行 run() 方法,為什麼我們不能直接調用 run() 方法?

這是另一個非常經典的 java 多線程面試問題,而且在面試中會經常被問到。很簡單,但是很多人都會答不上來!

new 一個 Thread,線程進入了新建狀態;調用 start() 方法,會啟動一個線程並使線程進入了就緒狀態,當分配到時間片後就可以開始運行了。 start() 會執行線程的相應準備工作,然後自動執行 run() 方法的內容,這是真正的多線程工作。 而直接執行 run() 方法,會把 run 方法當成一個 main 線程下的普通方法去執行,並不會在某個線程中執行它,所以這並不是多線程工作。

總結: 調用 start 方法方可啟動線程並使線程進入就緒狀態,而 run 方法只是 thread 的一個普通方法調用,還是在主線程里執行。

關鍵字: