日韩性视频-久久久蜜桃-www中文字幕-在线中文字幕av-亚洲欧美一区二区三区四区-撸久久-香蕉视频一区-久久无码精品丰满人妻-国产高潮av-激情福利社-日韩av网址大全-国产精品久久999-日本五十路在线-性欧美在线-久久99精品波多结衣一区-男女午夜免费视频-黑人极品ⅴideos精品欧美棵-人人妻人人澡人人爽精品欧美一区-日韩一区在线看-欧美a级在线免费观看

歡迎訪問 生活随笔!

生活随笔

當前位置: 首頁 > 编程语言 > java >内容正文

java

JVM笔记:Java虚拟机的字节码指令详解

發布時間:2023/12/14 java 29 豆豆
生活随笔 收集整理的這篇文章主要介紹了 JVM笔记:Java虚拟机的字节码指令详解 小編覺得挺不錯的,現在分享給大家,幫大家做個參考.

1.字節碼

Java能發展到現在,其“一次編譯,多處運行”的功能功不可沒,這里最主要的功勞就是JVM和字節碼了,在不同平臺和操作系統上根據JVM規范的定制JVM可以運行相同字節碼(.Class文件),并得到相同的結果。之所以被稱之為字節碼,是因為字節碼文件由十六進制值組成,而JVM以兩個十六進制值為一組,即以字節為單位進行讀取。在Java中一般是用javac命令編譯源代碼為字節碼文件,將java文件編譯后生成.class文件交由Java虛擬機去執行,在android上,class文件被包裝成.dex文件交由DVM執行。

通過學習Java字節碼指令可以對代碼的底層運行結構有所了解,能更深層次了解代碼背后的實現原理,例如字符串的相加的實現原理就是通過StringBuilder的append進行相加。用過字節碼的視角看它的執行步驟,對Java代碼的也能有更深的了解,知其然,也要知其所以然。

通過學習字節碼知識還可以實現字節碼插樁功能,例如用ASM 、AspectJ等工具對字節碼層面的代碼進行操作,實現一些Java代碼不好操作的功能。

1. 字節碼的格式

下面舉個簡單的例子,分析其字節碼的結構

public class Main {public static void main(String[] args) {System.out.println("HelloWorld");} }

上圖中純數字字母就是字節碼,右邊的是具體代碼執行的字節碼指令。

上面看似一堆亂碼,但是JVM對字節碼是有規范的,下面一點一點分析其代碼結構

1.1魔數(Magic Number)

魔數唯一的作用是確定這個文件是否為一個能被虛擬機接收的Class文件。很多文件存儲標準中都使用魔數來進行身份識別,譬如gif和jpeg文件頭中都有魔數。魔數的定義可以隨意,只要這個魔數還沒有被廣泛采用同時又不容易引起混淆即可。

這里字節碼中的魔數為0xCafeBabe(咖啡寶貝),這個魔數值在Java還被稱作Oak語言的時候就已經確定下來了,據原開發成員所說是為了尋找一些好玩的、容易記憶的東西,選擇0xCafeBabe是因為它象征著著名咖啡品牌Peet`s Coffee中深受喜歡的Baristas咖啡,咖啡同樣也是Java的logo標志。

1.2版本號(Version Number)

緊接著魔數的四個字節(00 00 00 33)存儲的是Class文件的版本號。前兩個是次版本號(Minor Version),轉化為十進制為0;后兩個為主版本號(Major Version),轉化為十進制為52,序號52對應的主版本號為1.8,所以編譯該文件的Java版本號為1.8.0。高版本的JDK能向下兼容以前的版本的Class文件,但不能運行以后版本的Class文件,及時文件格式并未發生變化,虛擬機也必須拒絕執行超過其版本號的Class文件。

1.3常量池(Constant Pool)

這部分內容前面做了一個簡要的筆記,感興趣的可以去看看。

緊接著版本號之后的是常量池入口,常量池可以理解為Class文件之中的資源倉庫,它是Class文件結構中與其他項目關聯最多的數據結構,也是占用Class文件控件最大的數據項目之一,同事也是在Class文件中第一個出現的表類型數據項目。

常量池的前兩個字節(00 22)代表的是常量池容量計數器,與Java中語言習慣不一樣的是,這個容量計數是從1開始的,這里的22轉換成十進制后為34,去除一個下標計數即表示常量池中有33個常量,這一點從字節碼中的Constant pool也可以看到,最后一個是#33 = Utf8 (Ljava/lang/String;)V

容量計數器后存儲的是常量池的數據。 常量池中存儲兩類常量:字面量與符號引用。字面量為代碼中聲明為Final的常量值(例如字符串),符號引用如類和接口的全局限定名、字段的名稱和描述符、方法的名稱和描述符,當虛擬機運行時,需要從常量池獲得對應的符號引用,再在類創建時或者運行時解析、翻譯到內存地址中。如下圖。

常量池的每一項常量都是一個表,在JDK71.7之前共有11中結構不同的表結構數據,在JDK1.7之后為了更好底支持動態語言調用,又額外增加了三種(CONSTANT_MethodHandle_info、CONSTANT_MethodType_info和CONSTANT_InvokeDynamic_info),總計14中,表結構如下圖

上圖中tag是標志位,用于區分常量類型,length表示這個UTF-8編碼的字符串長度是多少節,它后面緊更著的長度為length字節的連續數據是一個使用UTF-8縮略編碼表示的字符串。上圖的u1,u2,u4,u8表示比特數量,分別為1,2,4,8個byte。

UTF-8縮略編碼與普通UTF-8編碼的區別是:從\u0001到\u007f之間的字符(相當于1-127的ASCII碼)的縮略編碼使用一個字節表示,從\u0080到\u07ff之間的所有字符的縮略編碼用兩個字節表示,從\u0800到\uffff之間的所有字符的縮略編碼就按照普通UTF-8編碼規則使用三個字節表示,這么做的主要目的還是為了節省空間。

由于Class文件中方法、字段等都需要引用CONSTANT_Utf8_info型常量來描述名稱,所以CONSTANT_Utf8_info型常量的最大長度就是Java中的方法、字段名的最大長度。這里的最大長度就是length的最大值,即u2類型能表達的最大值65535,所以Java程序中如果定義了超過64K英文字符的變量或發放名,將會無法編譯。

回到上面那個例子,00 22后面跟著的是 0A 0006 0014,第一個字節0A轉化為十進制為10,表示的常量類型為CONSTANT_Methodref_info,這從常量表中可以看到這個類型后面會兩個u2來表示index,分別表示CONSTANT_Class_info和CONSTANT_NameAndType_info。所以0006和0014轉化為10進制分別是6和20。這里可能不知道這些數字指代什么意思,下面展示的是編譯后的字節碼指令就可以清楚了。

Constant pool:#1 = Methodref #6.#20 // java/lang/Object."<init>":()V#2 = Fieldref #21.#22 // java/lang/System.out:Ljava/io/PrintStream;#3 = String #23 // HelloWorld#4 = Methodref #24.#25 // java/io/PrintStream.println:(Ljava/lang/String;)V#5 = Class #26 // com/verzqli/snake/Main#6 = Class #27 // java/lang/Object#7 = Utf8 <init>#8 = Utf8 ()V#9 = Utf8 Code#10 = Utf8 LineNumberTable#11 = Utf8 LocalVariableTable#12 = Utf8 this#13 = Utf8 Lcom/verzqli/snake/Main;#14 = Utf8 main#15 = Utf8 ([Ljava/lang/String;)V#16 = Utf8 args#17 = Utf8 [Ljava/lang/String;#18 = Utf8 SourceFile#19 = Utf8 Main.java#20 = NameAndType #7:#8 // "<init>":()V#21 = Class #28 // java/lang/System#22 = NameAndType #29:#30 // out:Ljava/io/PrintStream;#23 = Utf8 HelloWorld#24 = Class #31 // java/io/PrintStream#25 = NameAndType #32:#33 // println:(Ljava/lang/String;)V#26 = Utf8 com/verzqli/snake/Main#27 = Utf8 java/lang/Object#28 = Utf8 java/lang/System#29 = Utf8 out#30 = Utf8 Ljava/io/PrintStream;#31 = Utf8 java/io/PrintStream#32 = Utf8 println#33 = Utf8 (Ljava/lang/String;)V

從上面可以看到Constant pool中一共有33個常量,第一個常量類型為Methodref,他其實指代的是這個Main類,它是最基礎的Object類,然后這里它有兩個索引分別指向6和20,分別是Class和NameAndType類型,和上面十六進制字節碼描述的一樣。

1.4訪問標志(Access Flags)

在常量池結束后,緊接著的兩個字節代表訪問標志,這個標志用于識別一些類或者接口層次的訪問信息,包括:這個Class是類還是接口;是否定義為public類型;是否定義為abstract類型,如果是類的話,是否被聲明為final等,具體的標志位以及標志的含義見下表。

標志名稱標志值含義
ACC_PUBLIC0x0001標識是否為public類型
ACC_FINAL0x0010標識是否被聲明為final,只有類可設置
ACC_SUPER0x0020用于兼容早期編譯器,新編譯器都設置改標志,以在使用invokespecial指令時對子類方法做特殊處理
ACC_SYNTHETIC0x1000標識這個類并非由用戶代碼產生,而是由編譯器產生
ACC_INTERFACE0x0200標識是否為一個接口,接口默認同事設置ACC_ABSTRACT
ACC_ABSTRACT0x0400標識是否為一個抽象類,不可與ACC_FINAL同時設置
ACC_ANNOTATION0x2000標識這是否是一個注解類
ACC_ENUM0x4000標識這是否是一個枚舉

ACCESS_FLAGS中一共有16個標志位可用,當前只定義了其中8個(上面顯示了比8個多,是因為ACC_PRIVATE,ACC_PROTECTED,ACC_STATIC,ACC_VOLATILE,ACC_TRANSTENT并不是修飾類的,這里寫出來是讓大家知道還有這么些標志符),對于沒有使用到的標志位要求一律為0。Java不會窮舉上面所有標志的組合,而是同|運算來組合表示,至于這些標志位是如何表示各種狀態,可以看這篇文章,講的很清楚。

我們繼續回到例子

例子中只是一個簡單的Main類,所以他的標志是ACC_PUBLIC和ACC_SUPER,其他標志都不存在,所以它的訪問標志為0x0001|0x0020=0x0021。

1.5 類索引、父類索引、接口索引

類索引和父類索引都是一個u2類型的數據,接口索引是一組u2類型的數據的集合,Class文件中由著三項數據來確定這個類的繼承關系。這三者按順序排列在訪問標志之后,本文例子中他們分別是:0005,0006,0000,也就是類索引為5,父類索引為6,接口索引集合大小為0 ,查詢上面字節碼指令的常量池可以一一對應(5對應com/verzqli/snake/Main,6對應java/lang/Object)。

類索引確定這個類的全限定名,父類索引確定這個類的父類全限定 名,因為Java不允許多重繼承,所以父類索引只有一個,除了Object外,所有的類都有其父類,也就是其父類索引不為0.接口索引即可用來描述這個類實現了哪些接口,這些被實現的接口按implements(如果這個類本身就是一個接口,則應當是extends語句)后的接口順序從左到右排列在接口索引集合中。

1.6 字段表集合(Field Info)

字段表用于描述類和接口中聲明的變量,包含類級別的變量以及實例變量。但是不包含方法內部聲明的局部變量。在Java中描述一個字段可能包含一下信息:

  • 字段的作用域(public,private,protected修飾符)
  • 是實例變量還是類變量(static修飾符)
  • 是否可變(final修飾符)
  • 并發可見 (vlolatile修飾符,是否強制從主內存中讀寫)
  • 是否可悲序列化(transient修飾符)
  • 字段數據基本類型(基本類型、對象、數組)
  • 字段名稱
    上述信息中,每個修飾符都是bool值,要么有要么沒有,很適合用和訪問標志一樣的標志位來表示。而字段名稱,字段數據類型只能引用常量池中的常量來描述。其中字段修飾符的訪問標志和含義如下表。
標志名稱標志值含義
ACC_PUBLIC0x0001標識是否為private類型
ACC_PRIVATE0x0002標識是否為private類型
ACC_PROTECTED0x0004標識是否為protectes類型
ACC_STATIC0x0008標識是否為靜態類型
ACC_FINAL0x0010標識是否被聲明為final,只有類可設置
ACC_VOLATILE0x0040標識是否被聲明volatile
ACC_TRANSIENT0x0080標識是否被聲明transient
ACC_SYNTHETIC0x1000標識這個類并非由用戶代碼產生,而是由編譯器產生
ACC_ENUM0x4000標識這是否是一個枚舉

字段表的結構分為兩部分,第一部分為兩個字節,描述字段個數(fields_count);第二部分是每個字段的詳細信息(fields_info),按順序排列分別是訪問標志(access_flags)、字段名稱索引(name_index)、字段的描述符索引(descriptor_index)、屬性表計數器(attribute_count)和屬性信息列表(attributes)。除了最后未知的屬性信息,其他都是u2的數據類型。

繼續看例子,這個例子選的有點尷尬,忘記往里面放一個變量,所以在類索引后面的第一個u2 數據為0000 表示字段個數為0,所以后續的數據也沒有了。只能假設一組數據來看看字段表的結構

字節碼00 0100 0200 0300 0700 00
描述字段表個數訪問標志字段名稱索引字段的描述符索引屬性個數
內容1ACC_PRIVATE370

字段表集合中不會列出從超類或者父類接口中繼承而來的字段,但有可能列出原本Java代碼之中不存在的字段,譬如在內部類中為了保持對外部類的訪問性,會自動添加指向外部類實例的字段。另外,在Java中字段是無法重載的,對于字節碼來講,只有兩個字段的描述符不一致,該字段才是合法的。

為了便于理解,這里對上面提到的一些名詞進行一下解釋

  • 全限定名:本文中的Main類的全限定名為com/verzqlisnake/Main,僅僅把包名中的.替換成/即可為了使連續的多個全限定名補償混淆,一般在使用時最后會假如一個;,表示全限定名結束。
  • 簡單名詞:值得是沒有類型和參數修飾的方法或字段名稱,例如public void fun()和private int a的簡單名稱就為fun和a。
  • 方法和字段的描述符:描述符的作用是用來描述字段的數據類型或方法的參數列表(數量、類型和順序)和返回值。描述符包含基本數據類型和無返回值的void,主要表示為下表中形式。
描述字符含義
描述字段表個數
I基本類型int
S基本類型short
J基本類型long,這里注意不是L,L是最后一個
F基本類型float
D基本類型double
B基本類型byte
C基本類型char
Z基本類型boolean
V特殊類型void
L對象類型,例如Ljava/lang/String

對于數組類型,每一位度使用一個前置的[來描述,例如String[]數組將被記錄為[Ljava/lang/String,String[][]數組被記錄為[[Ljava/lang/String ;int[]數組被記錄為[I。

用描述符來描述方法時,要先按照參數列表,后返回值的順序描述,參數列表按照參數的嚴格順序放在一組小括號()之中。例如方法void fun()的描述符為()V,String.toString()的描述符為()Ljava/lang/String。public void multi(int i,String j,float[] c)的描述符為(ILjava/lang/String;[F)V。

1.7 方法表集合(Field Info)

方法表的結構和字段表的結構幾乎完全一致,存儲的格式和描述也非常相似。方法表的結構和字段表一樣,包含兩部分。第一部分為方法計數器,第二部分為每個方法的詳細信息,依次包含了訪問標志(access_flags)、方法名稱索引(name_index)、方法的描述符索引(descriptor_index)、屬性表計數器(attribute_count)和屬性信息列表(attributes)。這些數據的含義也和字段表非常相似,僅在訪問標志和屬性表集合的可選項中有所區別。

類型名稱數量
u2access_flags1
u2name_index1
u2descriptor_index1
u2attribute_count1
attribute_infoattribute_infoattribute_count

因為volatile和transient關鍵字不能修飾方法,所以方法標的訪問標志中也就沒有這兩項標志,與之對應的,synchronized、native、strictfp、abstract可以修飾方法,所以方發表的訪問標志中增加了這幾類標志,如下表

標志名稱標志值含義
ACC_PUBLIC0x0001標識方法是否為private
ACC_PRIVATE0x0002標識方法是否為private
ACC_PROTECTED0x0004標識方法是否為protectes
ACC_STATIC0x0008標識方法是否為靜態
ACC_FINAL0x0010標識方法是否被聲明為final
ACC_SYNCHRONIZED0x0020標識方法是否被聲明synchronized
ACC_BRIDGE0x0040標識方法是否由編譯器產生的橋接方法
ACC_VARARGS0x0080標識這個類是否接受不定參數
ACC_NATIVE0x0100標識方法是否為native
ACC_ABSTRACT0x0400標識方法是否為abstract
ACC_STRICTFP0x0800標識方法是否為strictfp
ACC_SYNTHETIC0x1000標識方法是否由編譯器自動產生的

繼續分析本文例子,方法表數據在字段表之后的數據 0002 0001 0007 0008 0001 0009

字節碼00 0200 0100 0700 0800 010009
描述方法表個數訪問標志方法名稱索引方法的描述符索引屬性表計數器屬性名稱索引
內容1ACC_PUBLIC7819

從上表可以看到方法表中有兩個方法,分別是編譯器添加的實例構造器<init>和代碼中的main()方法。第一個方法的訪問標志為ACC_PUBLIC,方法名稱索引為7(對應<init>),方法描述符索引為8(對應()V),符合前面的常量池中的數據。

#7 = Utf8 <init>#8 = Utf8 ()V#9 = Utf8 Code

接著屬性表計數器的值為1,表示此方法的屬性表集合有一箱屬性,屬性名稱索引為9,對應常量池中為Code,說明此屬性是方法的字節碼描述。

方法重寫 : 如果父類方法在子類中沒有被重寫(Override),方法表集合中就不會出現來自父類的方法信息。但同樣的,有可能會出現由編譯器自動添加的方法,最典型的便是類構造器<clinit>方法和實例構造器<init>方法。
方法重載:在Java中藥重載(OverLoad)一個方法,除了要與原方法遇有相同的簡單名詞外,還需要要有一個與原方法完全不同的特征簽名。特征簽名是一個方法中各個參數在常量池中的字段符號引用的集合,返回值并不會包含在前面中,因此無法僅僅依靠返回值不同來重載一個方法。
但是在Class文件中,特征簽名的范圍更大一些,只要描述符不是完全一致的兩個方法也是可以共存的。也就是說,如果兩個方法有相同的名稱和特征簽名,但返回值不同,那么也是可以合法共存于同一個Class文件的,也就是說Java語法不支持,但是Class文件支持。

1.8 屬性表集合(attribute Info)

屬性表在前面的講解中已經出現過數次,在Class文件、字段表、方法表都可以攜帶自己的屬性表集合,已用于描述某些場景專有的信息
與Class文件中其他的數據項目要求嚴格的順序、長度和內容不同,屬性表集合的限制稍微寬松了一些,不在要求各個屬性表具有嚴格的順序,只要不與已有的屬性名重復,任何人實現的編譯器都可以想屬性表中寫入自己定義的屬性信息:Java虛擬機運行時會忽略掉它不認識的屬性,具體的預定義屬性入下表。

屬性名稱使用位置含義
Code方法表Java代碼編譯成的字節碼指令
ConstantValue字段表final關鍵字定義的常量池
Deprecated類,方法,字段表被聲明為deprecated的方法和字段
Exceptions方法表方法拋出的異常
EnclosingMethod類文件僅當一個類為局部類或者匿名類是才能擁有這個屬性,這個屬性用于標識這個類所在的外圍方法
InnerClass類文件內部類列表
LineNumberTableCode屬性Java源碼的行號與字節碼指令的對應關系
LocalVariableTableCode屬性方法的局部變量描述
StackMapTableCode屬性JDK1.6中新增的屬性,供新的類型檢查檢驗器檢查和處理目標方法的局部變量和操作數有所需要的類是否匹配
Signature類,方法表,字段表JDK1.5中新增的屬性,用于支持泛型情況下的方法簽名。任何類,接口,初始化方法或成員的泛型前面如果包含了類型變量(Type Variables)或參數化類型(Parameterized Type),則signature屬性會為它記錄泛型前面信息,由于Java的泛型采用擦除法實現,在為了便面類型信息被擦除后導致簽名混亂,需要這個屬性記錄泛型中的相關信息。
SourceFile類文件記錄源文件名稱
SourceDebugExtension類文件JDK1.6中新增的屬性,用于存儲額外的調試信息
Synthetic類,方法表,字段表標志方法或字段為編譯器自動生成的
LocalVariableTypeTableJDK1.5中新增的屬性,使用特征簽名代替描述符,是為了引入泛型語法之后能描述泛型參數化類型而添加
RuntimeVisibleAnnotations類,方法表,字段表JDK1.5中新增的屬性,為動態注解提供支持 ,用于指明那些注解是運行時(運行時就是進行反射調用)可見的
RuntimeInvisibleAnnotations表,方法表,字段表JDK1.5中新增的屬性,和上面剛好相反,用于指明哪些注解是運行時不可見的
RuntimeVisibleParameterAnnotation方法表JDK1.5中新增的屬性,作用與RuntimeVisibleAnnotations屬性類似,只不過作用對象為方法
RuntimeInvisibleParameterAnnotation方法表JDK1.5中新增的屬性,作用與RuntimeInvisibleAnnotations屬性類似,作用對象哪個為方法參數
AnnotationDefault方法表JDK1.5中新增的屬性,用于記錄注解類元素的默認值
BootstrapMethods類文件JDK1.7中新增的屬性,用于保存invokeddynamic指令引用的引導方式限定符

對于每個屬性,它的名稱需要從常量池中應用一個CONSTANT_Utf8_info類型的常量來標書,而屬性值的結構則是完全子墩醫德,只需要通過一個u4的長度屬性去說明屬性值做占用的位數即可,其符合規則的結構如下圖。

類型名稱數量
u2attribute_name_index1
u4attribute_length1
u1infoattribute_length

因為屬性表中的屬性包含二十多種,下面只對幾個屬性做一個簡要描述。

  • 1.8.1 Code 屬性

Java程序方法體中的代碼經過Javac編譯器處理后,最終變為字節碼指令存儲在Code屬性內,Code屬性出現在方法表的屬性集合之中,但并未所有的方法表都必須存在這個屬性:接口或者抽象類中的方法就不存在Code屬性。如果方法表有Code屬性,那么它的結構將如下表所示。

類型名稱數量
u2attribute_name_index1
u4attribute_length1
u2max_stack1
u2max_locals1
u4code_length1
u1codecode_length
u2exception_table_length1
exception_info exception_tableexception_length
u2attributes_count1
attribute_infoattributesattributes_count

attribute_name_index:一項指向CONSTANT_Utf8_info型常量的索引,常量值固定為“Code”,他代表了該屬性的名稱。
attribute_length: 屬性值得長度,由于屬性名稱索引和長度一共為6字節,所以屬性值長度固定為整個屬性表長度減去6個字節。
max_stack:操作數棧深度的最大值,裝虛擬機運行的時候需要根據這個值來分配棧幀中的操作棧深度,沒有定義好回歸的遞歸發生的棧溢出就是超過了這個值。
max_locals:局部變量表所需的存儲空間。這里的單位是Slot,Slot是虛擬機為局部變量表分配內存所使用得最小單位。對于byte、char、float、int、short、boolean、returnAddress這些長度不超過32位的整型數據,每個局部變量占用一個Slot。像double和float兩種64位的數據類型需要兩個Slot來存放位置。**方法參數(實例方法中隱藏的this)、顯示異常處理器的參數(就是try-catch語句中catch鎖定義的異常)、放大提中定義的局部變量都需要使用局部變量表來存放。**因為Slot可以重用,所以這個最大值并不是所有的Slot之和,當代碼執行超過一個局部變量的作用于時,這個局部變量所占用的Slot可以被其他局部變量使用,所以該值主要根據變量的所用域來計算大小。
code_length:字節碼長度。雖然是u4長度,但是虛擬機規定了一個方法中的字節碼指令條數不超過u2(65535)條,超過的話編譯器會拒絕編譯。
code:存儲編譯后生成的字節碼指令。每個字節碼指令是一個u1類型的單字節。當虛擬機督導一個字節碼時,可以找到這個字節碼代碼的指令,并可以知道這個指令后面是否需要跟隨參數以及參數的意思。一個u1數據的取值范圍為0x00~0xff,也就是一共可以表達256條指令,目前,Java虛擬機以及定義了其中200多條編碼值對應的指令含義,具體指令可以看虛擬機字節碼指令表。
因為異常表對于Code屬性不是必須存在的,后面幾個類型也沒有太大的重要性,這里就暫時略過。

  • 1.8.2 Exceptions屬性

    這里的Exceptions屬性是在方法表中與Code屬性評級的一項屬性,Exceptions屬性的作用是列舉出方法中可能拋出的受查異常(Checked Exceptions),也就是方法描述時在throws關鍵詞后面列舉的異常,其結構如下圖。
類型名稱數量
u2attribute_name_index1
u2attribute_lrngth1
u2number_of_exception1
u2exception_index_tablenumber_of_exceptions

number_of_exception:表示方法可能拋出此項值數值的受查異常,每一種受查異常exception_index_table表示。
exception_index_table:表示一個指向常量池中CONSTANT_Class_indo型常量的索引,所以,代表了該種受查異常的類型。

  • 1.8.3 SourceFile屬性

SourceFile屬性用于記錄生成這個Class文件的源碼文件名稱。可以使用Javac的-g:none和-g:source選項來關閉或者生成這項信息。對于大多數類來說,類名和文件名是一致的,但是例如內部類等一些特殊情況就會不一樣。如果不生成這個屬性,當拋出異常時,堆棧中將不會顯示出錯代碼所屬的文件名,其結構入下表:

類型名稱數量
u2attribute_name_index1
u4attribute_length1
u2sourcefile_index1

sourcefile_index:指向常量池中的CONSTANT_Utf8_indo型常量,常量值是源碼文件的文件名。

  • 1.8.3 InnerClass屬性

InnerClass屬性用于記錄內部類與宿主之間的關聯,如果一個類中定義了內部類,那編譯器將會為他以及它所包含的內部類生成InnerClasses屬性,其表結構如下圖:

類型名稱數量
u2attribute_name_index1
u4attribute_length1
u2number_of_classes1
inner_classes_infoinner_classesnumber_of_classes

number_of_classes:表示內部類信息的個數。每一個內部類的信息都由一inner_classes_info表進行描述,改表結果如下:

類型名稱數量
u2inner_class_info_index1
u2outer_class_info_index1
u2inner_name_index1
u2inner_class_access_flags1

inner_class_info_index:指向常量池中的CONSTANT_Class_indo型常量的索引,表示內部類的符號引用。
outer_class_info_index:指向常量池中的CONSTANT_Class_indo型常量的索引,表示宿主類的符號引用。
inner_class_access_flags:內部類的訪問標志,類似于類的access_flags。

  • 1.8.4 ConstantValue屬性

    ConstantValue屬性的作用是通知虛擬機自動為靜態變量賦值。只有被static關鍵字修飾的變量(類變量)才可以使用這項屬性,例如int a=1和static int a=1,虛擬機對這兩種變量的賦值方式和時刻都有所不同。對于前者的賦值是在實例構造器方法中進行的,換而言之就是一個類的構造的方法沒有被執行前,該類的成員變量是還沒賦值的;而對于后者,則有兩種方式可以選擇:在類構造器方法中或者使用ConstantValue屬性。目前Javac編譯器的選擇是如果同時使用final和static來修飾一個變量,并且這個變量的數據類型是基本類型或者字符串類型時,就生成ConstantValue屬性來初始化,如果這個變量沒有被final修飾,或者并非基本類型變量或字符串,則會選擇在<clinit>方法中進行初始化

    <clinit>:類構造器。在jvm第一次加載class文件時調用,因為是類級別的,所以只加載一次,是編譯器自動收集類中所有類變量(static修飾的變量)和靜態語句塊(static{}),中的語句合并產生的,編譯器收集的順序,是由程序員在寫在源文件中的代碼的順序決定的。
    <init>:實例構造器方法,在實例創建出來的時候調用,包括調用new操作符;調用Class或java.lang.reflect.Constructor對象的newInstance()方法;調用任何現有對象的clone()方法;通過java.io.ObjectInputStream類的getObject()方法反序列化。

<clinit>方法和類的構造函數不同,它不需要顯示調用父類的構造方法,虛擬機會保證子類的<clinit>方法執行之前,父類的此方法已經執行完畢,因此虛擬機中第一個被執行的方法的類肯定是java.lang.Object。言而言之就是先需要<clinit>完成類級別的變量和代碼塊的加載,再進行對象級別的加載信息,所以經常看的面試題子類和父類哪個語句先被執行就是這些決定的。

public class Main {static final int a=1; } 字節碼:static final int a;descriptor: Iflags: ACC_STATIC, ACC_FINALConstantValue: int 1 未添加final public class Main {static int a=1; } 字節碼:public com.verzqli.snake.Main();descriptor: ()Vflags: ACC_PUBLICCode:stack=1, locals=1, args_size=10: aload_01: invokespecial #1 // Method java/lang/Object."<init>":()V4: returnLineNumberTable:line 12: 0LocalVariableTable:Start Length Slot Name Signature0 5 0 this Lcom/verzqli/snake/Main;//可以看到 這里的初始化放在了Main的類構造器中static {};descriptor: ()Vflags: ACC_STATICCode:stack=1, locals=0, args_size=00: iconst_11: putstatic #2 // Field a:I4: returnLineNumberTable:line 13: 0 }public class Main {int a=1; } 字節碼://可以看到 這里的初始化放在了Main的實例構造器中public com.verzqli.snake.Main();descriptor: ()Vflags: ACC_PUBLICCode:stack=2, locals=1, args_size=10: aload_01: invokespecial #1 // Method java/lang/Object."<init>":()V4: aload_05: iconst_16: putfield #2 // Field a:I9: return }

2. 字節碼指令

字節碼指令是一個字節長度的,代表著某種特點操作含義的數字,總數不超過256條(全部字節碼指令匯編),。對于大部分與數據類型相關的字節碼指令,它們的操作碼助記符中都有特殊字符來表明專門為那種數據類型服務,如下表:

描述字符含義
i基本類型int
s基本類型short
l基本類型long,這里注意不是L,L是最后一個
f基本類型float
d基本類型double
b基本類型byte
c基本類型char
b基本類型boolean
a對象類型引用reference

這里有一個注意的點,這對于不是整數類型的byte、char、short、boolean。編譯器會在編譯器或運行期將byte和short類型的數據帶符號擴展(Sign-extend)為相應的int類型數據,將boolean和char類型數據零位擴展(Zero-extend)為相應的int數據。同樣在處理上訴類型的數組數據是,也會轉換為使用int類型的字節碼指令來處理。

2.1 加載和存儲指令。

加載和存儲指令用于將數據在棧幀中的局部變量表和操作數棧之間來回傳輸。

<類型>load_<下標>:將一個局部變量加載到操作數棧。例如iload_1,將一個int類型局部變量(下標為1,0一般為this)從局部變量表加載到操作棧,其他的也都類似,例如:dload_2,fload_3。
<類型>store_<下標>:將一個數值從操作數棧棧頂存儲到局部變量表。例如istore_3,將一個int類型的數值從操作數棧棧頂存儲到局部變量3中,后綴為3,證明局部變量表中已經存在了兩個值。
<類型>const_<具體的值>:將一個常量加載到操作數棧。例如iconst_3,將常量3加載到操作數棧。
wide擴展:當上述的下標志超過3時,就不用下劃線的方式了,而是使用istore 6,load的寫法也是一樣。
bipush、sipush、ldc :當上述的const指令后面的值變得很大時,該指令也會改變。

  • 當 int 取值 -1~5 時,JVM 采用 iconst 指令將常量壓入棧中。
  • 當 int 取值 -128~127 時,JVM 采用 bipush 指令將常量壓入棧中。
  • 當 int 取值 -32768~32767 時,JVM 采用 sipush 指令將常量壓入棧中。
  • 當 int 取值 -2147483648~2147483647 時,JVM 采用 ldc 指令將常量壓入棧中。

看例子:

public void save() {int a = 1;int b = 6;int c = 128;int d = 32768 ;float f = 2.0f;} 字節碼:Code:stack=1, locals=6, args_size=10: iconst_1 //將常量1入棧,1: istore_1 //將棧頂的1存入局部變量表,下標為1,因為0存儲了整個類的this2: bipush 6 //將常量6入棧,同時也是以wide擴展的形式4: istore_2 //將棧頂的6存入局部變量表,下標為25: sipush 128 //將常量128入棧,8: istore_3 //將棧頂的128存入局部變量表,下標為3 ,后面一樣的意思9: ldc #2 // int 3276811: istore 413: fconst_214: fstore 516: return

2.2 運算指令。

運算主要分為兩種:對征信數據進行運算的指令和對浮點型數據運算的指令,和前面說的一樣,對于byte、char、short、和 boolean類型的算數質量都使用int類型的指令替代。整數和浮點數的運算指令在移除和被領出的時候也有各自不同的表現行為。具體的指令也是在運算指令前加上對應的類型即可,例如加法指令:iadd,ladd,fadd,dadd。

  • 加法指令:(i,l,f,d)add
  • 減法指令:(i,l,f,d)sub
  • 乘法法指令:(i,l,f,d)mul
  • 除法指令:(i,l,f,d)div
  • 求余指令:(i,l,f,d)rem
  • 取反指令:(i,l,f,d)neg
  • 位移指令: ishl、ishr、iushr、lshl、lshr、lushr
  • 按位或指令:ior、lor
  • 按位與指令:iand、land
  • 按位異或指令: ixor、lxor
  • 局部變量自增: iinc(例如for循環中i++)
  • 比較指令: dcmpg、dcmpl、fcmpg、fcmpl、lcmp

上面的指令沒必要強記,需要的時候查找一下即可,看多了也自然就熟悉了。至于浮點數運算的精度損失之類的這里就不多做贅述了。

2.3 類型轉換指令。

類型轉換指令可以將兩種不同的數值類型進行相互轉換,這些轉換一般用于實現用戶代碼中的顯示類型轉換操作。

Java虛擬機直接支持寬化數據類型轉換(小范圍數據轉換為大數據類型),不需要顯示的轉換指令,例如int轉換long,float和double。舉例:int a=10;long b =a

Java虛擬機轉換窄化數據類型轉換時,必須顯示的調用轉化指令。舉例:long b=10;int a = (long)b。

類型轉換的字節碼指令其實就比較簡單了,<前類型>2<后類型>,例如i2l,l2i,i2f,i2d。當然這里舉的都是基本數據類型,如果是對象,當類似寬化數據類型時就直接使用,當類似窄化數據類型時,需要checkcast指令。

public class Main {public static void main(String[] args) {int a = 1;long b = a;Parent Parent = new Parent();Son son = (Son) Parent;} } 字節碼:Code:stack=2, locals=6, args_size=10: iconst_11: istore_12: iload_13: i2l4: lstore_25: new #2 // class com/verzqli/snake/Parent8: dup9: invokespecial #3 // Method com/verzqli/snake/Parent."<init>":()V12: astore 414: aload 416: checkcast #4 // class com/verzqli/snake/Son19: astore 521: return

注意上面這個轉換時錯誤的,父類是不能轉化為子類的,編譯期正常,但是運行是會報錯的,這就是checkcast指令的原因。

2.4 對象創建和訪問指令

雖然累實例和數組都是對象,但Java蘇尼基對類實例和數組的創建與操作使用了不同的字節碼指令。對象創建后,就可以通過對象訪問指令獲取對象實例或者數組實例中的字段或者數組元素,這些指令如下。

  • new:創建類實例的指令
  • newarray、anewarray、multianewarray:創建數組的指令
  • getfield、putfield、getstatic、putstatic:訪問類字段(static字段,被稱為類變量)和實例字段(非static字段,)。
  • (b、c、s、i、l、f、d、a)aload:很明顯,就是基礎數據類型加上aload,將一個數組元素加載到操作數棧。
  • (b、c、s、i、l、f、d、a)astore:同上面一樣的原理,將操作數棧棧頂的值存儲到數組元素中。
  • arraylength:取數組長度
  • instanceof、checkcast:檢查類實例類型的指令。

2.4 操作數棧管理指令

如同操作一個普通數據結構中的堆棧那樣,Java虛擬機提供了一些直接操作操作數棧的指令。

  • pop、pop2:將操作數棧棧頂的一個或兩個元素出棧。
  • dup、dup2、dup_x1、dup2_x1、dup_x2、dup2_x2:服戰棧頂一個或兩個數值并將期值復制一份或兩份后重新壓入棧頂。
  • swap:將棧頂兩個數互換。

2.5 方法調用和返回指令。

方法調用的指令只要包含下面這5條

  • invokespecial:用于調用一些需要特殊處理的實例方法,包括實例初始化方法、私有方法和父類方法。
  • invokestatic:用于調用static方法。
  • invokeinterface:用于調用接口方法,他會在運行時搜索一個實現了這個接口方法的對象,找出合適的方法進行調用。
  • invokevirtual:用于調用對象的實例方法,根據對象的實際類型進行分派。
  • invokedynamic:用于在運行時動態解析出調用點限定符所引用的方法,并執行該方法。前面4條指令的分派邏輯都固話在Java虛擬機內部,而此條指令的分派邏輯是由用戶設定的引導方法決定的。
  • (i,l,f,d, 空)return:根據前面的類型來確定返回的數據類型,為空時表示void

2.5 異常處理指令。

在Java程序中顯示拋出異常的操作(throw語句)都由athrow指令來實現。但是處理異常(catch語句)不是由字節碼指令來實現的,而是采用異常表來完成的,如下例子。

public class Main {public static void main(String[] args) throws Exception{try {Main a=new Main();}catch (Exception e){e.printStackTrace();}} } 字節碼: public static void main(java.lang.String[]) throws java.lang.Exception;descriptor: ([Ljava/lang/String;)Vflags: ACC_PUBLIC, ACC_STATICCode:stack=2, locals=2, args_size=10: new #2 // class com/verzqli/snake/Main3: dup4: invokespecial #3 // Method "<init>":()V7: astore_18: goto 1611: astore_112: aload_113: invokevirtual #5 // Method java/lang/Exception.printStackTrace:()V16: return

2.6 同步指令

Java虛擬機可以支持方法級的同步和方法內部一段指令序列的同步,這兩種同步結構都是使用Monitor
實現的。
正常情況下Java運行是同步的,無需使用字節碼控制。虛擬機可以從方法常量池的方法表結構中的ACC_SYNCHRONIZE訪問標志得知一個方法是否聲明為同步方法。當方法調用時,調用指令將會檢查方法的ACC_SYNCHRONIZE訪問表示是否被設置,如果設置了,執行線程就要求先持有Monitor,然后才能執行方法,最后當方法完成時釋放Monitor。在方法執行期間,執行線程持有了Monitor,其他任何一個線程都無法在獲取到同一個Monitor。如果一個同步方法執行期間拋出了異常,并且在方法內部無法處理次異常,那么這個同步方法所持有的Monitor將在異常拋出到同步方法之外時自動釋放。
同步一段指令集序列通常是由synchronized語句塊來表示的,Java虛擬機指令集中有monitorenter和monitorexit兩條指令來支持synchronized關鍵字。如下例子

public class Main {public void main() {synchronized (Main.class) {System.out.println("synchronized");}function();}private void function() {System.out.printf("function");} }字節碼:Code:stack=3, locals=3, args_size=10: ldc #2 // class com/verzqli/snake/Main 將Main引用入棧2: dup // 復制棧頂引用 Main3: astore_1 // 將棧頂應用存入到局部變量astore1中4: monitorenter // 將棧頂元素(Main)作為鎖,開始同步5: getstatic #3 // Field java/lang/System.out:Ljava/io/PrintStream;8: ldc #4 // String synchronized ldc指令在運行時創建這個字符串10: invokevirtual #5 // Method java/io/PrintStream.println:(Ljava/lang/String;)V13: aload_1 // 將局部變量表的astore1入棧(Main)14: monitorexit //退出同步15: goto 23 // 方法正常結束,跳轉到2318: astore_2 //這里是出現異常走的路徑,將棧頂元素存入局部變量表19: aload_1 // 將局部變量表的astore1入棧(Main)20: monitorexit //退出同步21: aload_2 //將前面存入局部變量的異常astore2入棧22: athrow // 把異常對象長線拋出給main方法的調用者23: aload_0 // 將類this入棧,以便下面調用類的方法24: invokespecial #6 // Method function:()V27: return

編譯器必須確保無論方法通過何種方式完成,方法中調用過的每條monitorenter指令都必須執行其對應的monitorexit指令,無論這個方法是正常結束還是異常結束。

3 實例

前面說了一堆,空看理論既枯燥又難懂,理論就圖一樂,真懂還得看例子。

例一:

相信面試過的人基本地看過這個面試題,然后還扯過值傳遞還是引用傳遞這個問題,下面從字節碼的角度來分析這個問題。

public class Main {String str="newStr";String[] array={"newArray1","newArray2"};public static void main(String[] args) {Main main=new Main();main.change(main.str, main.array);System.out.println(main.str);System.out.println(Arrays.toString(main.array));}private void change(String str, String[] array) {str="newStrEdit";array[0]="newArray1Edit";} } 輸出結果: newStr [newArray1Edit, newArray2] 字節碼:private void change(java.lang.String, java.lang.String[]);descriptor: (Ljava/lang/String;[Ljava/lang/String;)Vflags: ACC_PRIVATECode:stack=3, locals=3, args_size=30: ldc #14 // String newStrEdit2: astore_13: aload_24: iconst_05: ldc #15 // String newArray1Edit7: aastore8: return}

這里main方法的字節碼內容可以忽略,主要看這個change方法,下面用圖來表示。

這是剛進入這個方法的情況,這時候還沒有執行方法的內容,局部變量表存了三個值,第一個是this指代這個類,在普通方法內之所以可以拿到外部的全局變量就是因為方法內部的局部變量表的第一個就是類的this,當獲取外部變量時,先將這個this入棧aload_0,然后就可以獲取到這個類所有的成員變量(也就是外部全局變量)了。
因為這個方法傳進來了兩個值,這里局部變量表存儲的是這兩個對象的引用,也就是在堆上的內存地址。

上面執行了str = "newStrEdit";這條語句,先ldc指令創建了newStrEdit(0xaaa)字符串入棧,然后astore_1指令將棧頂的值保存再局部變量1中,覆蓋了原來的地址,所以這里對局部變量表的修改完全沒有影響外面的值。

上面執行array[0] = "newArrar1Edit";這條語句,將array的地址入棧,再將要修改的數組下標0入棧,最后創建newArray1Edit字符串入棧。最后調用aastore指令將棧頂的引用型數值(newArray1Edit)、數組下標(0)、數組引用(0xfff)依次出棧,最后將數值存入對應的數組元素中,這里可以看到對這個數組的操作一直都是這個0xfff地址,這個地址和外面的array指向的是同一個數組對象,所以這里修改了,外界的那個array也就同樣修改了內容。

例二:

看過前面那個例子應該對局部變量表是什么有所了解,下面這個例子就不繪制上面那個圖了。這個例子也是一個常見的面試題,判斷try-catch-finally-return的執行順序。

finally是一個最終都會執行的代碼塊,finally里面的return會覆蓋try和catch里面的return,同時在finally里面修改局部變量不會影響try和catch里面的局部變量值,除非trycatch里面返回的值是一個引用類型。

public static void main(String[] args) {Main a=new Main();System.out.println("args = [" + a.testFinally() + "]");;}public int testFinally(){int i=0;try{i=2;return i;}catch(Exception e){i=4;return i;}finally{i=6;} 字節碼: public int testFinally();descriptor: ()Iflags: ACC_PUBLICCode:stack=1, locals=5, args_size=10: iconst_0 // 常量0入棧1: istore_1 // 賦值給內存變量1(i) i=02: iconst_2 // 常量2入棧3: istore_1 // 賦值給內存變量1(i) i=24: iload_1 // 內存變量1(i)入棧5: istore_2 // 將數據存儲在內存變量2 這里原因下面說明6: bipush 6 // 常量6入棧8: istore_1 // 保存再內存變量19: iload_2 // 加載內存變量210: ireturn // 返回上一句加載的內存變量2(i) i=211: astore_2 // 看最下面的異常表,如果2-6發生異常,就從11開始,下面就是發生異常后進入catch的內容12: iconst_4 // 常量4入棧13: istore_1 // 保存在局部變量114: iload_1 // 加載局部變量115: istore_3 // 將局部變量1內容保存到局部變量3,原因和上面5一樣16: bipush 6 // 常量6入棧 (進入了catch最后也會執行finally,所以這里會重新再執行一遍finally)18: istore_1 // 保存在局部變量119: iload_3 // 加載局部變量3并返回20: ireturn //上面類似的語句,不過是catch-finally的路徑21: astore 4 // finally 生成的冗余代碼,這里發生的異常會拋出去23: bipush 625: istore_126: aload 428: athrowException table:from to target type2 6 11 Class java/lang/Exception //如果2-6發生指定的Exception異常(try),就從11開始2 6 21 any //如果2-6發生任何其他異常(finally),就從21開始11 16 21 any //如果11-16發生任何其他異常(catch),就從21開始21 23 21 any //其實這里有點不太能理解為什么會循環,如果有知道的大佬可以解答一下

在Java1.4之后 Javac編譯器 已經不再為 finally 語句生成 jsr 和 ret 指令了, 當異常處理存在finally語句塊時,編譯器會自動在每一段可能的分支路徑之后都將finally語句塊的內容冗余生成一遍來實現finally語義。(21~28)。但我們Java代碼中,finally語句塊是在最后的,編譯器在生成字節碼時候,其實將finally語句塊的執行指令移到了ireturn指令之前,指令重排序了。所以,從字節碼層面,我們解釋了,為什么finally語句總會執行!

如果try中有return,會在return之前執行finally中的代碼,但是會保存一個副本變量(第五和第十五行)。finally修改原來的變量,但try中return返回的是副本變量,所以如果是賦值操作,即使執行了finally中的代碼,變量也不一定會改變,需要看變量是基本類型還是引用類型。
但是如果在finally里面添加一個return,那么第9行和第19行加載的就是finally塊里修改的值(iload_1),再在最后添加一個iload_1和ireturn,感興趣的可以自己去看一下字節碼。

例三:

還是上面那個類似的例子,這里做一下改變

public static void main(String[] args) {Main a = new Main();System.out.println("args = [" + a.testFinally1() + "]");System.out.println("args = [" + a.testFinally2() + "]");}public StringBuilder testFinally1() {StringBuilder a = new StringBuilder("start");try {a.append("try");return a;} catch (Exception e) {a.append("catch");return a;} finally {a.append("finally");}}public String testFinally2() {StringBuilder a = new StringBuilder("start");try {a.append("try");return a.toString();} catch (Exception e) {a.append("catch");return a.toString();} finally {a.append("finally");}}輸出結果: args = [starttryfinally] args = [starttry]

這里就不列舉全局字節碼了,兩個方法有點多,大家可以自己嘗試去看一下。這里做一下說明為什么第一個返回的結果沒有finally。
首先這個方法的局部變量表1里面存儲了一個StringBuilder地址,執行到try~finally這一部分沒什么區別,都是復制了一份變量1的地址到變量3,注意,這兩個地址是一樣的。
那為什么第二個返回方法少了finally呢,那是因為s.toString()方法這個看起來是在return后面,但其實這個方法屬于這個try代碼塊,分為兩步,先調用toString()生成了一個新的字符串starttry然后返回,所以這里的字節碼邏輯就如下:

17: aload_118: invokevirtual #12 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;21: astore_222: aload_123: ldc #18 // String finally25: invokevirtual #8 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;28: pop29: aload_230: areturn

可以很清楚的看到 調用append方法拼接“start”和“try”后,先調用了toString()方法然后將值存入局部變量2。這時候finally沒有和上面那樣復制一份變量,而是繼續使用局部變量1的引用來繼續append,最后的結果也存入了局部變量1中,最后返回的是局部變量2中的值starttry,但是要注意此時局部變量1中指向的StringBuilder的值卻是starttryfinally,所以這也就是方法1中返回的值。

4.如何快捷查看字節碼

如果是ide的話,應該都可以,通過``Setting->Tools->External Tools進入 然后創建一個自定義的tools。

如上圖,新建一個External Tools,第一行輸入你電腦的javap.exe地址,第二行是你想要的命令符,第三行是顯示位置,設置好后要對著代碼右鍵即可一鍵查看字節碼指令,方便快捷。

5.Tips(后續有就繼續更新)

5.1 對象被new指令創建后為什么會執行一個dup(將棧頂的數據復制一份并壓入棧)?

對象被new之后還需要調用invokespecial <init>來初始化,這里需要拿到一份new指令分配的內存地址,然后棧中還存在的一份地址是供這個對象給其他地方調用的,否則棧中如果不存在這個引用之后,任何地方都訪問不到這個類了,所以就算這個類沒有被任何地方調用,棧中還是會存在一份它的引用。

6. 總結

本來只是想寫點字節碼指令的筆記,結果越記越多,本文大部分理論知識來自于《深入理解Java虛擬機–周志明》,寫得多了,錯誤在所難免,如果有發現的還望指出,謝謝。

總結

以上是生活随笔為你收集整理的JVM笔记:Java虚拟机的字节码指令详解的全部內容,希望文章能夠幫你解決所遇到的問題。

如果覺得生活随笔網站內容還不錯,歡迎將生活随笔推薦給好友。