這都Java15了,Java7特性還沒整明白?

我沒有三顆心臟發表於2020-08-18

  • 「MoreThanJava」 宣揚的是 「學習,不止 CODE」,本系列 Java 基礎教程是自己在結合各方面的知識之後,對 Java 基礎的一個總回顧,旨在 「幫助新朋友快速高質量的學習」
  • 當然 不論新老朋友 我相信您都可以 從中獲益。如果覺得 「不錯」 的朋友,歡迎 「關注 + 留言 + 分享」,文末有完整的獲取連結,您的支援是我前進的最大的動力!

特性總覽

以下是 Java 7 中引入的部分新特性,關於 Java 7 更詳細的介紹可參考官方文件

  • java.lang

    • Java 7 多執行緒下自定義類載入器的優化
  • Java 語言特性

    • 改進的型別推斷;
    • 使用 try-with-resources 進行自動資源管理
    • switch 支援 String
    • catch 多個異常;
    • 數字格式增強(允許數字字面量下劃線分割);
    • 二進位制字面量;
    • 增強的檔案系統;
    • Fork/Join 框架;
  • Java 虛擬機器 (JVM)

    • 提供新的 G1 收集器;
    • 加強對動態呼叫的支援;
    • 新增分層編譯支援;
    • 壓縮 Oops;
    • 其他優化;
  • 其他;

多執行緒下自定義類載入器的優化

在 Java 7 之前,某些情況下的自定義類載入器容易出現死鎖問題。下面?來簡單分析演示一下官方給的例子 (下面用中文虛擬碼還原了一下)

// 類的繼承情況:
class A extends B
class C extends D

// 類載入器:
Custom Classloader CL1:
    直接載入類 A
    委託 CL2 載入類 B
Custom Classloader CL2:
    直接載入類 C
    委託 CL1 載入類 D
    
// 多執行緒下的情況:
Thread 1:
    使用 CL1 載入類 A
    → 定義類 A 的時候會觸發 loadClass(B),這時會嘗試 鎖住? CL2    
Thread 2:
    使用 CL2 載入類 C
    → 定義 C 的時候會觸發 loadClass(D),這時會嘗試 鎖住? CL1
➡️ 造成 死鎖☠️

造成死鎖的重要原因出在 JDK 預設的 java.lang.ClassLoader.loadClass() 方法上:

JDK 7 和 JDK 6 loadClass 方法的對比

可以看到,JDK 6 及之前的 loadClass()synchronized 關鍵字是加在方法級別的,那麼這就意味載入類時獲取到的是一個 ClassLoader 級別的鎖。

我們來描述一下死鎖產生的情況:

文字版的描述如下:

  • 執行緒1:CL1 去 loadClass(A) 獲取到了 CL1 物件鎖,因為 A 繼承了類 B,defineClass(A) 會觸發 loadClass(B),嘗試獲取 CL2 物件鎖;
  • 執行緒2:CL2 去 loadClass(C) 獲取到了 CL2 物件鎖,因為 C 繼承了類 D,defineClass(C) 會觸發 loadClass(D),嘗試獲取 CL1 物件鎖
  • 執行緒1 嘗試獲取 CL2 物件鎖的時候,CL2 物件鎖已經被 執行緒2 拿到了,那麼 執行緒1 等待 執行緒2 釋放 CL2 物件鎖。
  • 執行緒2 嘗試獲取 CL1 對像鎖的時候,CL1 對像鎖已經被 執行緒1 拿到了,那麼 執行緒2 等待 執行緒1 釋放 CL1 對像鎖。
  • 然後兩個執行緒一直在互相等中…從而產生了死鎖現象...

究其原因就是因為 ClassLoader 的鎖太粗粒度了。在 Java 7 中,在使用具有並行功能的類載入器的時候,將專門用一個帶有 類載入器和類名稱組合的物件 用於進行同步操作。(感興趣可以看一下 loadClass() 內部的 getClassLoadingLock(name) 方法)

Java 7 之後,之前執行緒死鎖的情況將不存在:

執行緒1:
  使用CL1載入類A(鎖定CL1 + A)
    defineClass A觸發
      loadClass B(鎖定CL2 + B)

執行緒2:
  使用CL2載入類C(鎖定CL2 + C)
    defineClass C觸發
      loadClass D(鎖定CL1 + D)

改進的型別推斷

在 Java 7 之前,使用泛型時,您必須為變數型別及其實際型別提供型別引數:

Map<String, List<String>> map = new HashMap<String, List<String>>();

在 Java 7 之後,編譯器可以通過識別空白菱形推斷出在宣告在左側定義的型別:

Map<String, List<String>> map = new HashMap<>();

自動資源管理

在 Java 7 之前,我們必須使用 finally 塊來清理資源,但防止系統崩壞的清理資源的操作並不是強制性的。在 Java 7 中,我們無需顯式的資源清理,它允許我們使用 try-with-resrouces 語句來藉由 JVM 自動完成清理工作。

Java 7 之前:

BufferedReader br = null;
try {
    br = new BufferedReader(new FileReader(path));
    return br.readLine();
} catch (Exception e) {
    log.error("BufferedReader Exception", e);
} finally {
    if (br != null) {
        try {
            br.close();
        } catch (Exception e) {
            log.error("BufferedReader close Exception", e);
        }
    }
}

Java 7 及之後的寫法:

try (BufferedReader br = new BufferedReader(new FileReader(path)) {
    return br.readLine();
} catch (Exception e) {
    log.error("BufferedReader Exception", e);
}

switch 支援 String

switch 在 Java 7 中能夠接受 String 型別的引數,例項如下:

String s = ...
switch(s) {
case "condition1":
    processCondition1(s);
    break;
case "condition2":
    processCondition2(s);
    break;
default:
    processDefault(s);
    break;
} 

catch 多個異常

自Java 7開始,catch 中可以一次性捕捉多個異常做統一處理。示例如下:

public void handle() {
    ExceptionThrower thrower = new ExceptionThrower();
    try {
        thrower.manyExceptions();
    } catch (ExceptionA | ExceptionB ab) {
        System.out.println(ab.getClass());
    } catch (ExceptionC c) {
        System.out.println(c.getClass());
    }
}

請注意:如果 catch 塊處理多個異常型別,則 catch 引數隱式為 final 型別,這意味著,您不能在 catch 塊中為其分配任何值。

數字格式增強

為了解決長數字可讀性不好的問題,在 Java 7 中支援了使用下劃線分割的數字表達形式:

/**
 * Supported in int
 * */
int improvedInt = 10_00_000;
/**
 * Supported in float
 * */
float improvedFloat = 10_00_000f;
/**
 * Supported in long
 * */
float improvedLong = 10_00_000l;
/**
 * Supported in double
 * */
float improvedDouble = 10_00_000; 

二進位制字面量

在 Java 7 中,您可以使用整型型別 (byteshortintlong) 並加上字首 0b (或 0B) 來建立二進位制字面量。這在 Java 7 之前,您只能使用八進位制值 (字首為 0) 或十六進位制值 (字首為 0x 或者 0X) 來建立:

int sameVarOne = 0b01010000101;
int sameVarTwo = 0B01_010_000_101;
byte byteVar = (byte) 0b01010000101;
short shortVar = (short) 0b01010000101  

增強的檔案系統

Java 7 推出了全新的NIO 2.0 API以此改變針對檔案管理的不便,使得在java.nio.file包下使用PathPathsFilesWatchServiceFileSystem等常用型別可以很好的簡化開發人員對檔案管理的編碼工作。

1 - Path 介面 和 Paths 類

Path介面的某些功能其實可以和java.io包下的File類等價,當然這些功能僅限於只讀操作。在實際開發過程中,開發人員可以聯用Path介面和Paths類,從而獲取檔案的一系列上下文資訊。

  • int getNameCount(): 獲取當前檔案節點數
  • Path getFileName(): 獲取當前檔名稱
  • Path getRoot(): 獲取當前檔案根目錄
  • Path getParent(): 獲取當前檔案上級關聯目錄

聯用Path介面和Paths型別獲取檔案資訊:

Path path = Paths.get("G:/test/test.xml");
System.out.println("檔案節點數:" + path.getNameCount());
System.out.println("檔名稱:" + path.getFileName());
System.out.println("檔案根目錄:" + path.getRoot());
System.out.println("檔案上級關聯目錄:" + path.getParent());

2 - Files 類

聯用Path介面和Paths類可以很方便的訪問到目標檔案的上下文資訊。當然這些操作全都是隻讀的,如果開發人員想對檔案進行其它非只讀操作,比如檔案的建立、修改、刪除等操作,則可以使用Files型別進行操作。

Files型別常用方法如下:

  • Path createFile(): 在指定的目標目錄建立新檔案
  • void delete(): 刪除指定目標路徑的檔案或資料夾
  • Path copy(): 將指定目標路徑的檔案拷貝到另一個檔案中
  • Path move(): 將指定目標路徑的檔案轉移到其他路徑下,並刪除原始檔

使用Files型別複製、貼上檔案示例:

Files.copy(Paths.get("/test/src.xml"), Paths.get("/test/target.xml"));

使用 Files 型別來管理檔案,相對於傳統的 I/O 方式來說更加方便和簡單。因為具體的操作實現將全部移交給 NIO 2.0 API,開發人員則無需關注。

3 - WatchService

Java 7 還為開發人員提供了一套全新的檔案系統功能,那就是檔案監測。 在此或許有很多朋友並不知曉檔案監測有何意義及目,那麼請大家回想下除錯成熱釋出功能後的 Web 容器。當專案迭代後並重新部署時,開發人員無需對其進行手動重啟,因為 Web 容器一旦監測到檔案發生改變後,便會自動去適應這些“變化”並重新進行內部裝載。Web 容器的熱釋出功能同樣也是基於檔案監測功能,所以不得不承認,檔案監測功能的出現對於 Java 檔案系統來說是具有重大意義的。

檔案監測是基於事件驅動的,事件觸發是作為監測的先決條件。開發人員可以使用java.nio.file包下的StandardWatchEventKinds型別提供的3種字面常量來定義監測事件型別,值得注意的是監測事件需要和WatchService例項一起進行註冊。

StandardWatchEventKinds型別提供的監測事件:

  • ENTRY_CREATE:檔案或資料夾新建事件;
  • ENTRY_DELETE:檔案或資料夾刪除事件;
  • ENTRY_MODIFY:檔案或資料夾貼上事件;

使用WatchService類實現檔案監控完整示例:

public static void testWatch() {
    /* 監控目標路徑 */
    Path path = Paths.get("G:/");
    try {
        /* 建立檔案監控物件. */
        WatchService watchService = FileSystems.getDefault().newWatchService();

        /* 註冊檔案監控的所有事件型別. */
        path.register(watchService, StandardWatchEventKinds.ENTRY_CREATE, StandardWatchEventKinds.ENTRY_DELETE,
                StandardWatchEventKinds.ENTRY_MODIFY);

        /* 迴圈監測檔案. */
        while (true) {
            WatchKey watchKey = watchService.take();

            /* 迭代觸發事件的所有檔案 */
            for (WatchEvent<?> event : watchKey.pollEvents()) {
                System.out.println(event.context().toString() + " 事件型別:" + event.kind());
            }

            if (!watchKey.reset()) {
                return;
            }
        }
    } catch (Exception e) {
        e.printStackTrace();
    }
}

通過上述程式示例我們可以看出,使用WatchService介面進行檔案監控非常簡單和方便。首先我們需要定義好目標監控路徑,然後呼叫FileSystems型別的newWatchService()方法建立WatchService物件。接下來我們還需使用Path介面的register()方法註冊WatchService例項及監控事件。當這些基礎作業層全部準備好後,我們再編寫外圍實時監測迴圈。最後迭代WatchKey來獲取所有觸發監控事件的檔案即可。

Fork/ Join 框架

1 - 什麼是 Fork/ Join 框架

Java 7 提供的一個用於並行執行任務的框架,是一個把大任務分割成若干個小任務,最終彙總每個小任務結果後得到大任務結果的框架。比如我們要計算 1 + 2 + .....+ 10000,就可以分割成 10 個子任務,讓每個子任務分別對 1000 個數進行運算,最終彙總這 10 個子任務的結果。

Fork/Join 的執行流程圖如下:

2 - 工作竊取演算法

工作竊取 (work-stealing) 演算法是指某個執行緒從其他佇列裡竊取任務來執行。核心思想是:自己的活幹完了去看看別人有沒有沒有幹完的活兒,如果有就拿過來幫他幹。

工作竊取的執行流程圖如下:

工作竊取演算法的優點是充分利用執行緒進行平行計算,並減少了執行緒間的競爭,其缺點是在某些情況下還是存在競爭,比如雙端佇列裡只有一個任務時。並且消耗了更多的系統資源,比如建立多個執行緒和多個雙端佇列。

3 - 簡單示例

讓我們通過一個簡單的需求來使用下Fork/Join框架,需求是:計算1 + 2 + 3 + 4的結果。

使用Fork/Join框架首先要考慮到的是如何分割任務,如果我們希望每個子任務最多執行兩個數的相加,那麼我們設定分割的閾值是2,由於是4個數字相加,所以Fork/Join框架會把這個任務fork成兩個子任務,子任務一負責計算1 + 2,子任務二負責計算3 + 4,然後再join兩個子任務的結果。

因為是有結果的任務,所以必須繼承RecursiveTask,實現程式碼如下:

import java.util.concurrent.ExecutionException;
import java.util.concurrent.ForkJoinPool;
import java.util.concurrent.Future;
import java.util.concurrent.RecursiveTask;

/**
 * CountTask.
 *
 * @author blinkfox on 2018-01-03.
 * @originalRef http://blinkfox.com/2018/11/12/hou-duan/java/java7-xin-te-xing-ji-shi-yong/#toc-heading-5
 */
public class CountTask extends RecursiveTask<Integer> {

    /** 閾值. */
    public static final int THRESHOLD = 2;

    /** 計算的開始值. */
    private int start;

    /** 計算的結束值. */
    private int end;

    /**
     * 構造方法.
     *
     * @param start 計算的開始值
     * @param end 計算的結束值
     */
    public CountTask(int start, int end) {
        this.start = start;
        this.end = end;
    }

    /**
     * 執行計算的方法.
     *
     * @return int型結果
     */
    @Override
    protected Integer compute() {
        int sum = 0;

        // 如果任務足夠小就計算任務.
        if ((end - start) <= THRESHOLD) {
            for (int i = start; i <= end; i++) {
                sum += i;
            }
        } else {
            // 如果任務大於閾值,就分裂成兩個子任務來計算.
            int middle = (start + end) / 2;
            CountTask leftTask = new CountTask(start, middle);
            CountTask rightTask = new CountTask(middle + 1, end);

            // 等待子任務執行完,並得到結果,再合併執行結果.
            leftTask.fork();
            rightTask.fork();
            sum = leftTask.join() + rightTask.join();
        }
        return sum;
    }

    /**
     * main方法.
     *
     * @param args 陣列引數
     */
    public static void main(String[] args) throws ExecutionException, InterruptedException {
        ForkJoinPool fkPool = new ForkJoinPool();
        CountTask task = new CountTask(1, 4);
        Future<Integer> result = fkPool.submit(task);
        System.out.println("result:" + result.get());
    }

}

虛擬機器增強

Oracle 官網介紹:https://docs.oracle.com/javase/7/docs/technotes/guides/vm/enhancements-7.html

1 - 提供新的 G1 收集器

Java 7 引入了一個被稱為 Garbage-First (G1) 的垃圾收集器。G1 是伺服器式的垃圾收集器 (設計初衷是儘量縮短處理超大堆——大於 4GB——時產生的停頓),適用於具有大記憶體多處理器的計算機。

與之前收集器不同的是 G1 沒有使用 Java 7 之前連續的記憶體模型:

而是將整個 堆空間 劃分為了多個大小相等的獨立區域 (Region),雖然還保留有新生代和老年代的概念,但新生代和老年代不再是物理隔閡了,它們都是一部分 (可以不連續) Region的集合:

G1 完全可以預測停頓時間,並且可以為記憶體密集型應用程式提供更高的吞吐量。

⚠️ 對於 G1 和垃圾收集器不熟悉的同學趕緊來這裡補課啦!!!

2 - 加強對動態呼叫的支援

Java 7 之前位元組碼指令集中,四條方法呼叫指令 (invokevirtualinvokespeicialinvokestaticinvokeinterface) 的第一個引數都是 被呼叫方法的符號引用,但動態型別的語言只有在 執行期 才能確定接受的引數型別。這樣,在 Java 虛擬機器上實現的動態型別語言就不得不使用“曲線救國”的方式 (如編譯時留個佔位符型別,執行時動態生成位元組碼實現具體型別到佔位符型別的適配) 來實現,這樣勢必讓動態型別語言實現的複雜度增加,也可能帶來額外的效能或者記憶體開銷。

為了從 JVM 底層解決這個問題 (早在 1997 年出版的《Java 虛擬機器規範》第一版中就規劃了這樣一個願景:“在未來,我們會對 Java 虛擬機器進行適當的擴充套件,以便更好的支援其他語言執行於 Java 虛擬機器之上”), Java 7 新引入了 invokedynamic 指令以及 java.lang.invoke 包。

想進一步瞭解可以閱讀:

3 - 分層編譯

Java 7 中引入的 分層編譯 為伺服器 VM 帶來了客戶端一般的啟動速度。通常,伺服器 VM 使用 直譯器 來收集有關「提供給 編譯器 的方法」的分析資訊。在分層模式中,除了 直譯器 之外,客戶端編譯器 還用於生成方法的編譯版本,這些方法收集關於自身的分析資訊。由於編譯後的程式碼比 直譯器 要快得多,程式在分析階段執行時會有更好的效能。在許多情況下,可以實現比客戶機 VM 更快的啟動,因為伺服器編譯器生成的最終程式碼可能在應用程式初始化的早期階段就已經可用了。分層模式還可以獲得比常規伺服器 VM 更好的峰值效能,因為更快的分析階段允許更長的分析週期,這可能產生更好的優化。(ps: 官方文件如是說...)

支援 32 位和 64 位模式,以及壓縮 Oops。在 java 命令中使用 -XX:+TieredCompilation 標誌來啟用分層編譯。

(ps: 這在 Java 8 是預設開啟的)

4 - 壓縮 Oops (CompressOops)

HotSpot JVM 使用名為 oopsOrdinary Object Pointers 的資料結構來表示物件。這些 oops 等同於本地C指標。 instanceOops 是一種特殊的 oop,表示 Java 中的物件例項。

32 位的系統中,物件頭指標佔 4 位元組,只能引用 4 GB 的記憶體,在 64 位系統中,物件頭指標佔 8 位元組。更大的指標尺寸帶來了問題:

  1. 更容易 GC,因為佔用空間更大了;
  2. 降低了 CPU 快取命中率,因為一條 cache line 中能存放的指標數變少了;

為了能夠保持 32 位的效能,oop 必須保留 32 位。那麼,如何用 32oop 來引用更大的堆記憶體呢?答案是——壓縮指標 (CompressedOops)。JVM 被設計為硬體友好,物件都是按照 8 位元組對齊填充的,這意味著使用指標時的偏移量只會是 8 的倍數,而不會是下面中的 1-7,只會是 0 或者 8

mem:  | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
        ^                               ^

這就允許了我們不再保留所有的引用,而是每隔 8 個位元組儲存一個引用:

mem:  | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
        ^                               ^
        |    ___________________________|
        |   |
heap: | 0 | 1 |

CompressedOops,可以讓跑在 64 位平臺下的 JVM,不需要因為更寬的定址,而付出 Heap 容量損失的代價 (其中還涉及零基壓縮優化——Zero-Based Compressed OOPs 技術)。 不過它的實現方式是在機器碼中植入壓縮與解壓指令,可能會給 JVM 增加額外的開銷。

想要了解更多戳這裡:

其他優化

將 interned 字串移出 perm gen

在 JDK 7 中,interned 字串不再在 Java 堆的永久生成中分配,而是在 Java 堆的主要部分 (稱為年輕代和年老代) 中分配,與應用程式建立的其他物件一起分配。這一更改將導致駐留在主 Java 堆中的資料更多,而駐留在永久生成中的資料更少,因此可能需要調整堆大小。由於這一變化,大多數應用程式在堆使用方面只會看到相對較小的差異,但載入許多類或大量使用 String.intern() 方法的較大應用程式將看到更顯著的差異。

(ps: String.intern() 方法是執行期擴充套件方法區常量池的一種手段)

NUMA 收集器增強

Java 7 對 Parallel Scavenger 垃圾收集器進行了擴充套件,以利用具有 NUMA (非統一記憶體訪問) 體系結構的計算機的優勢。大多數現代計算機都基於 NUMA 架構,在這種架構中,訪問記憶體的不同部分需要花費不同的時間。通常,系統中的每個處理器都具有提供低訪問延遲和高頻寬的本地記憶體,以及訪問速度相當慢的遠端記憶體。

在 Java HotSpot 虛擬機器中,已實現了 NUMA 感知的分配器,以利用此類系統併為 Java 應用程式提供自動記憶體放置優化。分配器控制堆的年輕代的 eden 空間,在其中建立大多數新物件。分配器將空間劃分為多個區域,每個區域都放置在特定節點的記憶體中。分配器基於以下假設:分配物件的執行緒將最有可能使用該物件。為了確保最快地訪問新物件,分配器將其放置在分配執行緒本地的區域中。可以動態調整區域的大小,以反映在不同節點上執行的應用程式執行緒的分配率。這甚至可以提高單執行緒應用程式的效能。另外,年輕一代,老一代和永久一代的“從”和“到”倖存者空間為其開啟了頁面交錯。這樣可以確保所有執行緒平均平均具有對這些空間的相等的訪問延遲。

版本號大於 50 的類檔案必須使用 typechecker 進行驗證

從 Java 6 開始,Oracle 的編譯器使用 StackMapTable 製作類檔案。基本思想是,編譯器可以顯式指定物件的型別,而不是讓執行時執行此操作。這樣可以在執行時提供極小的加速,以換取編譯期間的一些額外時間和已編譯的類檔案 (前面提到的 StackMapTable) 中的某些複雜性。

作為一項實驗功能,Java 6 編譯器預設未啟用它。 如果不存在 StackMapTable,則執行時預設會驗證物件型別本身。

版本號為 51 的類檔案 (也就是 Java 7 的類檔案) 是使用型別檢查驗證程式專門驗證的,因此,方法在適當時必須具有 StackMapTable 屬性。對於版本 50 的類檔案,如果檔案中的堆疊對映丟失或不正確,則 HotSpot JVM 將故障轉移到型別推斷驗證程式。對於版本為 51 (JDK 7 預設版本) 的類檔案,不會發生此故障轉移行為。

參考資料

  1. Oracle 官方文件 - https://www.oracle.com/java/technologies/javase/jdk7-relnotes.html
  2. 閃爍之狐 - Java7新特性及使用 - http://blinkfox.com/2018/11/12/hou-duan/java/java7-xin-te-xing-ji-shi-yong/#toc-heading-5
  3. JVM - 指標壓縮 - https://chanjarster.github.io/post/jvm/oop-compress/
  • 本文已收錄至我的 Github 程式設計師成長系列 【More Than Java】,學習,不止 Code,歡迎 star:https://github.com/wmyskxz/MoreThanJava
  • 個人公眾號 :wmyskxz,個人獨立域名部落格:wmyskxz.com,堅持原創輸出,下方掃碼關注,2020,與您共同成長!

非常感謝各位人才能 看到這裡,如果覺得本篇文章寫得不錯,覺得 「我沒有三顆心臟」有點東西 的話,求點贊,求關注,求分享,求留言!

創作不易,各位的支援和認可,就是我創作的最大動力,我們下篇文章見!

相關文章