Java class 檔案格式
編譯後的 Java bytecode,由 JVM 執行。
- MIME 類型
- application/java-vm
- 副檔名
- .class
- 分類
- 執行檔與位元碼
Magic number(檔案簽章)
| 0x00000000 | CAFEBABE | ···· | 'CAFEBABE' magic |
關於 Java class 檔格式
.class 檔案是一個編譯後的 Java 型別——class、interface、enum 或 record——放在 JVM 自己的容器格式裡,而它幾乎整份都是同一個結構:constant pool。類別名稱、方法名稱、欄位描述子、字串字面值和每一個符號參照都以編號項目的形式住在裡面,檔案其他部分則用索引而不是文字去引用它們。這解釋了為什麼 class 檔在 JAR 裡這麼好壓、為什麼改一個類別名稱會讓檔案到處的位元組都變、以及為什麼 constant pool 之後每個位移都是浮動的:pool 的長度完全取決於這個型別提到了多少名稱。
檔頭結構,一個位元組一個位元組看
| 位移 | 長度 | 欄位 | 意義 |
|---|---|---|---|
| 0 | 4 | magic | CA FE BA BE,是 James Gosling 在 Java 還沒有名字之前選的,而且尷尬地和 Apple 的 universal binary 撞號。它是整個檔案裡唯一數值固定的欄位。 |
| 4 | 2 | minor_version | 幾乎永遠是 0。65535 這個值有特殊意義:這個類別使用了預覽功能,只會在編譯它的那個 JDK 上載入——預覽功能的類別明明主版本相符卻載不起來,真正的解釋就是這個。 |
| 6 | 2 | major_version | class 檔版本,也是每一次 UnsupportedClassVersionError 的來源。從 Java 5 起它就是功能版本號加 44——Java 8 是 52、Java 17 是 61、Java 21 是 65——執行環境在讀任何其他東西之前,先拿這兩個位元組和自己的上限比。 |
| 8 | 2 | constant_pool_count | pool 項目數再加一,因為 pool 是從 1 開始編號的,索引 0 保留。一個小工具類別大概幾十個;大型框架類別可以逼近 65535 的上限,而產生出來的程式碼有時真的會撞到。 |
| 10 | variable | constant_pool | 項目一個接一個,每個以 1 位元組的 tag 開頭、由 tag 決定長度:tag 1 是帶 2 位元組長度前綴的 UTF-8 字串、tag 7 是類別參照、tag 10 是方法參照、tag 8 是字串。tag 5 和 6(long 與 double)各佔掉兩個索引格——這條規則搞壞的手寫解析器,比這個格式其他任何部分都多。 |
| variable | 2 | access_flags | pool 之後的第一個欄位。0x0001 public、0x0010 final、0x0200 interface、0x4000 enum,而 0x0020(ACC_SUPER)從 1997 年以後編譯的東西幾乎都會設,用來取得舊有的 invokespecial 語意——一個單純的 public class 通常讀到 0x0021。 |
| variable | 2 | this_class | 指向「指名這個型別」的那個 pool 項目的索引,用的是內部形式,分隔符號是斜線不是點。它必須和這個檔案在 JAR 裡的路徑一致,所以把磁碟上的 .class 改名會得到一個同時列出兩種寫法的 NoClassDefFoundError。 |
| variable | 2 | super_class | 父類別項目的索引,通常是 java/lang/Object。只有 java/lang/Object 自己是 0,全世界唯一沒有父類別的類別。 |
| variable | 2 | interfaces_count | 後面接著幾個直接父介面,每個是一個 2 位元組的 pool 索引。再來是欄位、方法與類別屬性,每一個都帶長度前綴,所以從 pool 之後整份檔案只能循序讀——這裡沒有目錄可查。 |
怎麼跟長得像的格式分開
| 容易搞混的 | 怎麼分 |
|---|---|
| Mach-O(universal/fat) | 兩者開頭都是 CA FE BA BE。去讀位元組 4-7:class 檔是 minor_version 接 major_version,major_version 至少是 45(Java 1.1),目前落在六十幾到七十幾;universal binary 那個位置放的是 nfat_arch,一個小小的架構數量,高位那半是 0。位元組 6-7 讀到大於 44 的非零值,就是 class 檔。 |
| Java JAR | JAR 的位移 0 不會是 class 檔;它是 ZIP,開頭 50 4B 03 04,裡面裝著壓縮過的 class 檔。在 JAR 的位元組裡看到 CA FE BA BE 是理所當然的——只是它不在最前面,而真正用來辨識這個壓縮檔的是中央目錄裡的項目名稱。 |
| Android DEX | Android 的工具鏈會先編譯出 .class 再把它們丟掉:d8 把它們合併成 DEX 檔,開頭是 'dex' 加一個換行,用的是暫存器式的指令集,不是 JVM 的堆疊機器。所以 APK 裡完全沒有 .class 項目,這也是分辨 APK 與「剛好以 Android 為目標的 JAR」最快的方法。 |
| Java source (.java) | 會特別提,是因為建置目錄裡兩者混在一起:.java 是純文字,而 .class 開頭那個位元組根本不是合法的 UTF-8 文字。任何工具打開 .class 之後能顯示出可讀的類別與方法名稱,讀的是 constant pool 裡的 UTF-8 項目,不是原始碼。 |
常見的壞法
- major_version 比執行環境還新。這根本不是損壞,卻是大家最常在看的東西:JVM 讀位元組 6-7,發現版本超過自己的上限,在解析任何其他東西之前就丟出 UnsupportedClassVersionError。用 release 21 編譯、拿到 Java 17 上跑,得到的就是這個。
- 被截斷。因為 constant pool 之後的一切都是帶長度前綴、循序排列的,被切掉的 class 檔會在它當時正讀到的那個結構上丟出 ClassFormatError,而訊息很少指得到真正的原因。「檔案短了一截」這件事比那句訊息好判斷得多。
- constant pool 裡有位元組被改過。把字串字面值改成不同長度卻沒有修正它那 2 位元組的長度前綴,後面每個項目就全部失去同步,解析器會從某個名稱的中間讀出一個 tag,然後報未知的常數 tag。這就是為什麼要修補 class 檔得用函式庫,不能用 hex 編輯器。
- 文字模式傳輸。老舊建置管線裡的 FTP 老問題到現在還碰得到 class 檔:CR LF 轉換讓第一次出現之後的東西全部位移,而因為 constant pool 是一串帶長度前綴的項目,失敗會表現成一個莫名其妙的 tag,而不是乾脆的 magic 不符。
這個格式現在還在用嗎
class 檔是 JVM 真正的介面:Java、Kotlin、Scala、Groovy 和 Clojure 都編譯到它,所以它們是同一個生態系而不是五個;而這個格式的穩定性,正是 2005 年編譯出來的程式庫今天還能和新編的程式碼連結的原因。它很少被單獨處理——class 都住在 JAR、WAR、模組映像與容器層裡——但每一套 bytecode 工具、混淆器、覆蓋率代理程式,以及在執行期產生代理類別的框架,實際操作的就是它。Android 是顯眼的例外:它在建置期吃掉 class 檔,出貨時換成 DEX。對拿著 hex 編輯器的人來說,好用的性質是 class 檔在很大程度上會自我描述:constant pool 把這個型別碰到的每個類別、方法與欄位都拼了出來,所以不用反編譯就讀得出它的相依性。
在這個站上打開 Java class 檔
這些全部在你的瀏覽器裡跑,檔案不會離開你的電腦。
相關格式
容易和 Java class 檔搞混的格式,可以比對它們的開頭位元組。
| 格式 | 開頭位元組 | 副檔名 |
|---|---|---|
| Java JAR | 50 4B 03 04 | .jar |
| Mach-O(universal/fat) | CA FE BA BE | .dylib |
| Android DEX | 64 65 78 0A | .dex |