Java位元組碼技術 static、final、volatile、synchronized關鍵字的位元組碼體現 轉
出處:
Java位元組碼技術(一)static、final、volatile、synchronized關鍵字的位元組碼體現
static、final、volatile關鍵字
static:static修飾的變數被所有類例項共享,靜態變數在其所在類被載入時進行初始化,靜態方法中不能引用非靜態變數或函式
final:final修飾的變數不可修改(基本型別值不能修改,引用型別引用不可修改),final修飾的方法,不可重寫、不可繼承
volatile:volatile修飾的成員變數在每次被執行緒訪問時,都從主記憶體中重新讀取該成員變數的值。而且,當成員變數發生變化時,強迫執行緒將變化值回寫到主記憶體
synchronized:Synchronized關鍵字就是用於程式碼同步,用於控制多執行緒同步訪問同一變數或方法
這些Java關鍵字的作用,大家或多或少都聽過,但是為什麼會有這種效果呢?本文從Java位元組碼層面做簡單分析
那麼什麼又是位元組碼呢?
什麼是位元組碼
Java之所以可以“一次編譯,到處執行”,一是因為JVM針對各種作業系統、平臺都進行了定製,二是因為無論在什麼平臺,都可以編譯生成固定格式的位元組碼(.class檔案)供JVM使用。因此,也可以看出位元組碼對於Java生態的重要性。之所以被稱之為位元組碼,是因為位元組碼檔案由十六進位制值組成,而JVM以兩個十六進位制值為一組,即以位元組為單位進行讀取。
.class檔案就是Java程式碼編譯後產生的位元組碼檔案,看下具體例項
用Sublime Text以文字檔案開啟,顯示如下
Javap命令檢視位元組碼檔案
先寫一段如下程式碼,非常簡單
定義一個抽象類JavaTestController
變數a為靜態成員變數(int)
變數b為普通成員變數(int)
變數c為volatile修飾的變數(int)
變數d為final修飾的變數(String)
變數s為字串(String)
變數o為Object型別(Object)
public abstract class JavaTestController { public static inta = 1; public int b = 2; public volatile int c = 3; public final int d = 4; private String s = "5"; private Object o = new Object(); public void test() { System.out.println("1"); } }
那麼問題來了,文字形式看到.class檔案全是十六進位制的程式碼,有沒更人性化的展示呢?
javap是jdk自帶的反解析工具。它的作用就是根據class位元組碼檔案,反解析出當前類對應的code區(彙編指令)、本地變量表、異常表和程式碼行偏移量對映表、常量池等等資訊
命令如下
javap -verbose class檔案路徑
看下這段程式碼使用javap命令輸出的的位元組碼
Classfile /Users/chenyin/IdeaProjects/spring-boot-api-project-seed/target/classes/com/company/project/biz/controller/JavaTestController.class Last modified 2019-9-19; size 883 bytes MD5 checksum 9ac63f28ebe7c6a65dd6c5a12913e064 Compiled from "JavaTestController.java" public abstract class com.company.project.biz.controller.JavaTestController minor version: 0 major version: 52 flags: ACC_PUBLIC, ACC_SUPER, ACC_ABSTRACT Constant pool: #1 = Methodref #7.#36 // java/lang/Object."<init>":()V #2 = Fieldref #13.#37 // com/company/project/biz/controller/JavaTestController.b:I #3 = Fieldref #13.#38 // com/company/project/biz/controller/JavaTestController.c:I #4 = Fieldref #13.#39 // com/company/project/biz/controller/JavaTestController.d:I #5 = String #40 // 5 #6 = Fieldref #13.#41 // com/company/project/biz/controller/JavaTestController.s:Ljava/lang/String; #7 = Class #42 // java/lang/Object #8 = Fieldref #13.#43 // com/company/project/biz/controller/JavaTestController.o:Ljava/lang/Object; #9 = Fieldref #44.#45 // java/lang/System.out:Ljava/io/PrintStream; #10 = String #46 // 1 #11 = Methodref #47.#48 // java/io/PrintStream.println:(Ljava/lang/String;)V #12 = Fieldref #13.#49 // com/company/project/biz/controller/JavaTestController.a:I #13 = Class #50 // com/company/project/biz/controller/JavaTestController #14 = Utf8 a #15 = Utf8 I #16 = Utf8 b #17 = Utf8 c #18 = Utf8 d #19 = Utf8 ConstantValue #20 = Integer 4 #21 = Utf8 s #22 = Utf8 Ljava/lang/String; #23 = Utf8 o #24 = Utf8 Ljava/lang/Object; #25 = Utf8 <init> #26 = Utf8 ()V #27 = Utf8 Code #28 = Utf8 LineNumberTable #29 = Utf8 LocalVariableTable #30 = Utf8 this #31 = Utf8 Lcom/company/project/biz/controller/JavaTestController; #32 = Utf8 test #33 = Utf8 <clinit> #34 = Utf8 SourceFile #35 = Utf8 JavaTestController.java #36 = NameAndType #25:#26 // "<init>":()V #37 = NameAndType #16:#15 // b:I #38 = NameAndType #17:#15 // c:I #39 = NameAndType #18:#15 // d:I #40 = Utf8 5 #41 = NameAndType #21:#22 // s:Ljava/lang/String; #42 = Utf8 java/lang/Object #43 = NameAndType #23:#24 // o:Ljava/lang/Object; #44 = Class #51 // java/lang/System #45 = NameAndType #52:#53 // out:Ljava/io/PrintStream; #46 = Utf8 1 #47 = Class #54 // java/io/PrintStream #48 = NameAndType #55:#56 // println:(Ljava/lang/String;)V #49 = NameAndType #14:#15 // a:I #50 = Utf8 com/company/project/biz/controller/JavaTestController #51 = Utf8 java/lang/System #52 = Utf8 out #53 = Utf8 Ljava/io/PrintStream; #54 = Utf8 java/io/PrintStream #55 = Utf8 println #56 = Utf8 (Ljava/lang/String;)V { public static int a; descriptor: I flags: ACC_PUBLIC, ACC_STATIC public int b; descriptor: I flags: ACC_PUBLIC public volatile int c; descriptor: I flags: ACC_PUBLIC, ACC_VOLATILE public final int d; descriptor: I flags: ACC_PUBLIC, ACC_FINAL ConstantValue: int 4 public com.company.project.biz.controller.JavaTestController(); descriptor: ()V flags: ACC_PUBLIC Code: stack=3, locals=1, args_size=1 0: aload_0 1: invokespecial #1 // Method java/lang/Object."<init>":()V 4: aload_0 5: iconst_2 6: putfield #2 // Field b:I 9: aload_0 10: iconst_3 11: putfield #3 // Field c:I 14: aload_0 15: iconst_4 16: putfield #4 // Field d:I 19: aload_0 20: ldc #5 // String 5 22: putfield #6 // Field s:Ljava/lang/String; 25: aload_0 26: new #7 // class java/lang/Object 29: dup 30: invokespecial #1 // Method java/lang/Object."<init>":()V 33: putfield #8 // Field o:Ljava/lang/Object; 36: return LineNumberTable: line 8: 0 line 10: 4 line 11: 9 line 12: 14 line 13: 19 line 14: 25 LocalVariableTable: Start Length Slot Name Signature 0 37 0 this Lcom/company/project/biz/controller/JavaTestController; public void test(); descriptor: ()V flags: ACC_PUBLIC Code: stack=2, locals=1, args_size=1 0: getstatic #9 // Field java/lang/System.out:Ljava/io/PrintStream; 3: ldc #10 // String 1 5: invokevirtual #11 // Method java/io/PrintStream.println:(Ljava/lang/String;)V 8: return LineNumberTable: line 17: 0 line 18: 8 LocalVariableTable: Start Length Slot Name Signature 0 9 0 this Lcom/company/project/biz/controller/JavaTestController; static {}; descriptor: ()V flags: ACC_STATIC Code: stack=1, locals=0, args_size=0 0: iconst_1 1: putstatic #12 // Field a:I 4: return LineNumberTable: line 9: 0 } SourceFile: "JavaTestController.java"
大家肯定覺得很長,怎麼解讀呢?
位元組碼檔案解讀
每個位元組碼檔案都是按照如下格式產生的,下面逐個分析
1.魔數
這需要在文字模式的class檔案中檢視,固定的字串“0XCAFEBABE”,標識其是一個class檔案,CAFEBABE英文意為咖啡寶貝,與Java圖示對應
2.版本號
minor version: 0 major version: 52
對應文字中的
0034轉化到10進位制就是52,52對應Java版本1.8
- 常量池
常量池中儲存兩類常量:字面量與符號引用。字面量為程式碼中宣告為Final的常量值,符號引用如類和介面的全侷限定名、欄位的名稱和描述符、方法的名稱和描述符
整體分為:常量池計數器以及常量池資料區
先看常量池計數器:
002a標識共有(57-1)=56個常量
對應到javap命令中的常量池,也是56個
再看常量池的資料區,即資料如何展示的
56個常量池資料項,以第一個為例做分析,即下面的資料是如何從16進位制轉化而來的
#1 = Methodref #7.#36 // java/lang/Object."<init>":()V
先來看一個結構圖
這是Methodref的常量池資料線位元組碼分佈圖,什麼意思呢?
即第一個位元組的16進位制標誌其tag為10,對應到下圖0a即標識接下來的常量池tag=10,是methodref型別
接下來的2byte為指向宣告方法的描述符索引項
0007轉化到十進位制也是7,即描述符下標為7,對應如圖,標識其是個Object型別
最後2byte資料為指向名稱及型別描述符的索引項
0024轉化到10進位制是36,標識呼叫了Object的初始化方法
當然剛才只是舉例展示了MethodRef常量型別的位元組碼分析,常量型別很多,但思路基本都類似,先通過tag確定其常量型別,後面連續幾個位元組確定其具體的值含義,型別及位元組含義圖如下
訪問標誌
訪問標識描述了類、介面的訪問型別
JVM定義瞭如下訪問標記
當前類全限定名
父類全限定名
如果有父類,後面會緊接著父類的全限定名,指向常量池中索引
介面資訊
描述了該類或父類實現的介面數量。緊接著的n個位元組是所有介面名稱的字串常量的索引值。例子裡沒有實現介面,所以沒有。
欄位表
記錄了當前類所定義的變數的總數量。包括類成員變數和類變數(靜態變數)
方法表
描述了方法名、訪問標識(ACC_PUBLIC) 等方法級別資訊
構造方法如下:執行了各個成員變數的初始化(注意這裡不包括靜態變數a)
test()方法
Code區是具體執行的JVM指令,即Java程式碼轉換後的JVM指令,像一些位元組碼增強框架,修改的就是Code區的部分
LineNumber將原始碼行號和位元組碼Code區中行號做了對映,比如test()中的code區
其中17代表Java程式碼中的輸出Print,0對應Code區中的行號
stack=2, locals=1, args_size=1 0: getstatic #9 // Field java/lang/System.out:Ljava/io/PrintStream; 3: ldc #10 // String 1 5: invokevirtual #11 // Method java/io/PrintStream.println:(Ljava/lang/String;)V 8: return LineNumberTable: line 17: 0 line 18: 8
LocalVariableTable:本地變量表,包含This和區域性變數
附加屬性表
位元組碼的最後一部分,該項存放了在該檔案中類或介面所定義屬性的基本資訊。
static、final、volatile在位元組碼中的體現
Static的位元組碼體現
在上面方法區的解析中,發現了靜態變數a並沒有在建構函式中進行初始化,那麼a在哪裡進行初始化呢?
發現程式碼區多了一段static的初始化程式碼,其中有a變數的初始化實現,這就是Java中的靜態程式碼塊,即靜態變數初始化先於成員變數
特點:隨著類的載入而執行,而且只執行一次
如果靜態方法能呼叫非靜態成員變數的話,那如果別人通過類名呼叫靜態方法時例項物件可能並不存在,導致異常出現
這就解釋了,靜態方法中為什麼不能呼叫非靜態本地成員變數的問題
假設我有一個靜態方法呢?加上靜態方法看看,程式碼里加上
public static void staticMethod() { System.out.println("static method"); }
看下位元組碼中方法區的解析
再看下普通的test方法的位元組碼解析
差別在哪?靜態方法沒有本地變量表,不持有JavaTestController的本地this指標
故靜態方法中不能出現this,super等關鍵字
Final、Volatile的位元組碼體現
看下Final、Volatile在位元組碼中的變數定義
那麼Volatile又是具體如何讓變數的修改直接寫回主存的呢?
Final又是如何讓基本型別值不能修改的呢?
其實現原理不在Java層面,而在JIT編譯生成的機器碼層面,這是stack-overflow上的回答
https://stackoverflow.com/questions/16898367/how-to-decompile-volatile-variable-in-java/16898432#16898432?newreg=4366ad45ce3f401a8dfa6b3d21bde635
故位元組碼中無法看到其實現原理,具體實現原理可以百度查
位元組碼層面來理解的話,只需明白:final和volatile定義的變數會在位元組碼中打上ACC_FINAL、ACC_VOLATILE標籤,在執行時會進行處理和優化
Synchorinized的位元組碼體現
編寫如下測試程式碼
分為三個方法
第一個為synchronized修飾普通方法(鎖當前呼叫物件)
第二個為synchronized、static修飾的靜態方法(鎖類)
第二個為靜態程式碼塊(鎖synchronized括號中的物件)
public class JavaTestController { public synchronized void test() { System.out.println("1"); } public static synchronized void test1() { System.out.println("1"); } public void test2() { synchronized (new Object()) { System.out.println(1); } } }
看下javap解析出來的方法區程式碼
synchronized修飾方法
先看test方法,可以看到flags中多了ACC_SYNCHRONIZED修飾符
public synchronized void test(); descriptor: ()V flags: ACC_PUBLIC, ACC_SYNCHRONIZED Code: stack=2, locals=1, args_size=1 0: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream; 3: ldc #3 // String 1 5: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V 8: return LineNumberTable: line 11: 0 line 12: 8 LocalVariableTable: Start Length Slot Name Signature 0 9 0 this Lcom/company/project/biz/controller/JavaTestController;
再看test1方法,也是多了ACC_SYNCHRONIZED修飾符
public static synchronized void test1(); descriptor: ()V flags: ACC_PUBLIC, ACC_STATIC, ACC_SYNCHRONIZED Code: stack=2, locals=0, args_size=0 0: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream; 3: ldc #3 // String 1 5: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V 8: return LineNumberTable: line 14: 0 line 15: 8
所以可以看出當synchronized修飾方法時,會在位元組碼中加上ACC_SYNCHRONIZED修飾符
ACC_SYNCHRONIZED是獲取監視器鎖的一種隱式實現(沒有顯示的呼叫monitorenter,monitorexit指令)
如果位元組碼方法區中的ACC_SYNCHRONIZED標誌被設定,那麼執行緒在執行方法前會先去獲取物件的monitor物件,如果獲取成功則執行方法程式碼,執行完畢後釋放monitor物件
synchronized同步程式碼塊
看下test2方法的位元組碼實現
public void test2(); descriptor: ()V flags: ACC_PUBLIC Code: stack=2, locals=3, args_size=1 0: new #5 // class java/lang/Object 3: dup 4: invokespecial #1 // Method java/lang/Object."<init>":()V 7: dup 8: astore_1 9: monitorenter 10: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream; 13: iconst_1 14: invokevirtual #6 // Method java/io/PrintStream.println:(I)V 17: aload_1 18: monitorexit 19: goto 27 22: astore_2 23: aload_1 24: monitorexit 25: aload_2 26: athrow 27: return
指令第9行:monitorenter表示獲取物件監視器鎖
指令第18行:monitorexit表示釋放物件監視器鎖
指令第24行:monitorexit表示釋放物件監視器鎖
有人可能會疑問,為什麼獲取了一次監視器鎖,卻指令中有兩次釋放監視器鎖的指令?
這是因為第二個monitorexit的位置實際是在丟擲異常的時候自動呼叫的(防止程式異常時,監視器鎖不會被釋放),athrow指令就是丟擲異常的地方
因此當synchronized修飾同步程式碼塊時,會顯示呼叫monitorenter爭搶監視器鎖,同步程式碼執行完後呼叫monitorexit指令釋放監視器鎖