1. 程式人生 > >Java虛擬機器之搜尋class檔案

Java虛擬機器之搜尋class檔案

Java命令

Java虛擬機器的工作是執行Java應用程式。和其他型別的應用程式一樣,Java應用程式也需要一個入口點,這個入口點就是我們熟知的main()方法。如果一個類包含main()方法,這個類就可以用來啟動Java應用程式,我們把這個類叫作主類。最簡單的Java程式是隻有一個main()方法的類,如著名的HelloWorld程式。

public class HelloWorld {

	public static void main(String[] args) {
		System.out.println("Hello, world!");
	}

}

  

那麼Java虛擬機器如何知道我們要從哪個類啟動應用程式呢?對此,Java虛擬機器規範沒有明確規定。也就是說,是由虛擬機器實現自行決定的。比如Oracle的Java虛擬機器實現是通過java命令來啟動的,主類名由命令列引數指定。java命令有如下4種形式:

java [-options] class [args]
java [-options] -jar jarfile [args]
javaw [-options] class [args]
javaw [-options] -jar jarfile [args]

  

可以向java命令傳遞三組引數:選項、主類名(或者JAR檔名)。

和main()方法引數。選項由減號“-”開頭。通常,第一個非選項引數給出主類的完全限定名(fully qualified class name)。但是如果使用者提供了–jar選項,則第一個非選項引數表示JAR檔名,java命令必須從這個JAR檔案中尋找主類。javaw命令和java命令幾乎一樣,唯一的差別在於,javaw命令不顯示命令列視窗,因此特別適合用於啟動GUI(圖形使用者介面)應用程式。

選項可以分為兩類:標準選項和非標準選項。標準選項比較穩定,不會輕易變動。非標準選項以-X開頭。非標準選項中有一部分是高階選項,以-XX開頭。表1-1列出了java命令常用的選項及其用途。

表1-1   java命令常用選項及其用途
選項 用途
-version 輸出版本資訊,然後退出
-?/-help 輸出幫助資訊,然後退出
-cp/-classpath 指定使用者類路徑
-Dproperty=value 設定Java系統屬性
-Xms<size> 設定初始堆空間大小
-Xmx<size> 設定最大堆空間大小
-Xss<size> 設定執行緒棧空間大小

編寫命令列工具

我們將以Go語言來闡述Java虛擬機器,先定義一個結構體來表示命令列選項和引數。在ch01目錄下建立cmd.go檔案,用你喜歡的文字編輯器開啟它,然後在其中定義Cmd結構體,程式碼如下:

package main

import "flag"
import "fmt"
import "os"

type Cmd struct {
	helpFlag    bool
	versionFlag bool
	cpOption    string
	class       string
	args        []string
}

  

在Java語言中,API一般以類庫的形式提供。在Go語言中,API則是以包(package)的形式提供。包可以向用戶提供常量、變數、結構體以及函式等。Java內建了豐富的類庫,Go也同樣內建了功能強大的包,如:fmt、os和flag包。

os包定義了一個Args變數,其中存放傳遞給命令列的全部引數。如果直接處理os.Args變數,需要寫很多程式碼。還好Go語言內建了flag包,這個包可以幫助我們處理命令列選項。有了flag包,我們的工作就簡單了很多。繼續編輯cmd.go檔案,在其中定義parseCmd()函式 ,程式碼如下:

func parseCmd() *Cmd {
	cmd := &Cmd{}
	flag.Usage = printUsage
	flag.BoolVar(&cmd.helpFlag, "help", false, "print help message")
	flag.BoolVar(&cmd.helpFlag, "?", false, "print help message")
	flag.BoolVar(&cmd.versionFlag, "version", false, "print version and exit")
	flag.StringVar(&cmd.cpOption, "classpath", "", "classpath")
	flag.StringVar(&cmd.cpOption, "cp", "", "classpath")
	flag.Parse()
	args := flag.Args()
	if len(args) > 0 {
		cmd.class = args[0]
		cmd.args = args[1:]
	}

	return cmd
}

  

首先設定flag.Usage變數,把printUsage()函式賦值給它;然後呼叫flag包提供的各種Var()函式設定需要解析的選項;接著呼叫Parse()函式解析選項。如果Parse()函式解析失敗,它就呼叫printUsage()函式把命令的用法列印到控制檯。printUsage()函式的程式碼如下:

func printUsage() {
	fmt.Printf("Usage: %s [-options] class [args...]\n", os.Args[0])
}

  

如果解析成功,呼叫flag.Args()函式可以捕獲其他沒有被解析的引數。其中第一個引數就是主類名,剩下的是要傳遞給主類的引數。這樣,用了不到40行程式碼,我們的命令列工具就編寫完了。下面來測試它。

在ch01目錄下建立main.go檔案,然後輸入下面的程式碼:

package main

import "fmt"

func main() {
	cmd := parseCmd()

	if cmd.versionFlag {
		fmt.Println("version 0.0.1")
	} else if cmd.helpFlag || cmd.class == "" {
		printUsage()
	} else {
		startJVM(cmd)
	}
}

func startJVM(cmd *Cmd) {
	fmt.Printf("classpath:%s class:%s args:%v\n",
		cmd.cpOption, cmd.class, cmd.args)
}

  

注意,與cmd.go檔案一樣,main.go檔案的包名也是main。在Go語言中,main是一個特殊的包,這個包所在的目錄(可以叫作任何名字)會被編譯為可執行檔案。Go程式的入口也是main()函式,但是不接收任何引數,也不能有返回值。main()函式先呼叫ParseCommand()函式解析命令列引數,如果一切正常,則呼叫startJVM()函式啟動Java虛擬機器。如果解析出現錯誤,或者使用者輸入了-help選項,則呼叫PrintUsage()函式打印出幫助資訊。如果使用者輸入了-version選項,則輸出版本資訊。

開啟命令列視窗,執行下面的命令:

go install jvmgo\ch01

  

開啟命令列視窗,執行命令go install jvmgo\ch01命令執行完畢後,如果沒有看到任何輸出就證明編譯成功了,此時在{GOPATH}\bin目錄下會出現ch01.exe檔案。現在,可以用各種引數進行測試:

D:\go_space\bin>ch01.exe
Usage: ch01.exe [-options] class [args...]

D:\go_space\bin>ch01.exe -version
version 0.0.1

D:\go_space\bin>ch01.exe -help
Usage: ch01.exe [-options] class [args...]

D:\go_space\bin>ch01.exe -version
version 0.0.1

D:\go_space\bin>ch01.exe -cp foo/bar MyApp arg1 arg2
classpath:foo/bar class:MyApp args:[arg1 arg2] 

 

類路徑

之前我們曾經介紹過HelloWorld類,載入HelloWorld類之前,首先要載入它的超類,也就是java.lang.Object。在呼叫main()方法之前,因為虛擬機器需要準備好引數陣列,所以需要載入java.lang.String和java.lang.String[]類。把字串列印到控制檯還需要載入java.lang.System類,等等。那麼,Java虛擬機器從哪裡尋找這些類呢?

Java虛擬機器規範並沒有規定虛擬機器應該從哪裡尋找類,因此不同的虛擬機器實現可以採用不同的方法。Oracle的Java虛擬機器實現根據類路徑(class path)來搜尋類。按照搜尋的先後順序,類路徑可以分為以下3個部分:

  • 啟動類路徑(bootstrap classpath)
  • 擴充套件類路徑(extension classpath)
  • 使用者類路徑(user classpath)

啟動類路徑預設對應jre\lib目錄,Java標準庫(大部分在rt.jar裡)位於該路徑。擴充套件類路徑預設對應jre\lib\ext目錄,使用Java擴充套件機制的類位於這個路徑。我們自己實現的類,以及第三方類庫則位於使用者類路徑。可以通過-Xbootclasspath選項修改啟動類路徑,不過通常並不需要這樣做。

使用者類路徑的預設值是當前目錄,也就是“.”。可以設定CLASSPATH環境變數來修改使用者類路徑,但是這樣做不夠靈活,所以不推薦使用。更好的辦法是給java命令傳遞-classpath(或簡寫為-cp)選項。-classpath/-cp選項的優先順序更高,可以覆蓋CLASSPATH環境變數設定。-classpath/-cp選項既可以指定目錄,也可以指定JAR檔案或者ZIP檔案,如下:

java -cp path\to\classes ...
java -cp path\to\lib1.jar ...
java -cp path\to\lib2.zip ...

  

還可以同時指定多個目錄或檔案,用分隔符分開即可。分隔符因作業系統而異。在Windows系統下是分號,在類UNIX(包括Linux、Mac OS X等)系統下是冒號。例如在Windows下:

java -cp path\to\classes;lib\a.jar;lib\b.jar;lib\c.zip ...

  

從Java 6開始,還可以使用萬用字元(*)指定某個目錄下的所有JAR檔案,格式如下:

java -cp classes;lib\* ...

  

把ch01目錄複製一份,然後改名為ch02。因為要建立的原始檔都在classpath包中,所以在ch02目錄中建立一個classpath子目錄。現在目錄結構看起來應該是這樣:

${GOPATH}\src
	|-jvmgo
	|-ch01
	|-ch02
		|-classpath
		|-cmd.go
		|-main.go

  

我們的Java虛擬機器將使用JDK的啟動類路徑來尋找和載入Java標準庫中的類,因此需要某種方式指定jre目錄的位置。命令列選項是個不錯的選擇,所以增加一個非標準選項-Xjre。開啟ch02\cmd.go,修改Cmd結構體,新增XjreOption欄位,程式碼如下:

type Cmd struct {
	helpFlag    bool
	versionFlag bool
	cpOption    string
	XjreOption  string
	class       string
	args        []string
}

  

parseCmd()函式也要相應修改,程式碼如下:

func parseCmd() *Cmd {
	cmd := &Cmd{}

	flag.Usage = printUsage
	flag.BoolVar(&cmd.helpFlag, "help", false, "print help message")
	flag.BoolVar(&cmd.helpFlag, "?", false, "print help message")
	flag.BoolVar(&cmd.versionFlag, "version", false, "print version and exit")
	flag.StringVar(&cmd.cpOption, "classpath", "", "classpath")
	flag.StringVar(&cmd.cpOption, "cp", "", "classpath")
	flag.StringVar(&cmd.XjreOption, "Xjre", "", "path to jre")
	flag.Parse()

	args := flag.Args()
	if len(args) > 0 {
		cmd.class = args[0]
		cmd.args = args[1:]
	}

	return cmd
}

  

實現類路徑

可以把類路徑想象成一個大的整體,它由啟動類路徑、擴充套件類路徑和使用者類路徑三個小路徑構成。三個小路徑又分別由更小的路徑構成,這很像組合模式(composite pattern)。

Entry介面

先定義一個介面來表示類路徑項。在ch02\classpath目錄下建立entry.go檔案,在其中定義Entry介面,程式碼如下:

package classpath

import "os"
import "strings"

const pathListSeparator = string(os.PathListSeparator)

type Entry interface {
	readClass(className string) ([]byte, Entry, error)
	String() string
}

func newEntry(path string) Entry {……}

  

常量pathListSeparator是string型別,存放路徑分隔符,後面會用到。Entry介面中有兩個方法。readClass()方法負責尋找和載入class檔案;String()方法的作用相當於Java中的toString(),用於返回變數的字串表示。

readClass()方法的引數是class檔案的相對路徑,路徑之間用斜線(/)分隔,檔名有.class字尾。比如要讀取java.lang.Object類,傳入的引數應該是java/lang/Object.class。返回值是讀取到的位元組資料、最終定位到class檔案的Entry,以及錯誤資訊。Go的函式或方法允許返回多個值,按照慣例,可以使用最後一個返回值作為錯誤資訊。

newEntry()函式根據引數建立不同型別的Entry例項,程式碼如下:

func newEntry(path string) Entry {
	if strings.Contains(path, pathListSeparator) {
		return newCompositeEntry(path)
	}

	if strings.HasSuffix(path, "*") {
		return newWildcardEntry(path)
	}

	if strings.HasSuffix(path, ".jar") || strings.HasSuffix(path, ".JAR") ||
		strings.HasSuffix(path, ".zip") || strings.HasSuffix(path, ".ZIP") {

		return newZipEntry(path)
	}

	return newDirEntry(path)
}

  

Entry介面有4個實現,分別是DirEntry、ZipEntry、CompositeEntry和WildcardEntry。下面分別介紹每一種實現。

DirEntry

在4種實現中,DirEntry相對簡單一些,表示目錄形式的類路徑。在ch02\classpath目錄下建立entry_dir.go檔案,在其中定義DirEntry結構體,程式碼如下:

package classpath

import "io/ioutil"
import "path/filepath"

type DirEntry struct {
	absDir string
}

func newDirEntry(path string) *DirEntry {……}

func (self *DirEntry) readClass(className string) ([]byte, Entry, error) {……}

func (self *DirEntry) String() string {……}

  

DirEntry只有一個欄位,用於存放目錄的絕對路徑。和Java語言不同,Go結構體不需要顯示實現介面,只要方法匹配即可。Go沒有專門的建構函式,可以使用new開頭的函式來建立結構體例項,並把這類函式稱為建構函式。newDirEntry()函式的程式碼如下:

func newDirEntry(path string) *DirEntry {
	absDir, err := filepath.Abs(path)
	if err != nil {
		panic(err)
	}
	return &DirEntry{absDir}
}

  

newDirEntry()先把引數轉換成絕對路徑,如果轉換過程出現錯誤,則呼叫panic()函式終止程式執行,否則建立DirEntry例項並返回。

下面介紹readClass()方法:

func (self *DirEntry) readClass(className string) ([]byte, Entry, error) {
	fileName := filepath.Join(self.absDir, className)
	data, err := ioutil.ReadFile(fileName)
	return data, self, err
}

  

readClass()先把目錄和class檔名拼成一個完整的路徑,然後呼叫ioutil包提供的ReadFile()函式讀取class檔案內容,最後返回。String()方法很簡單,直接返回目錄,程式碼如下:

func (self *DirEntry) String() string {
	return self.absDir
}

  

ZipEntry

ZipEntry表示ZIP或JAR檔案形式的類路徑。在ch02\classpath目錄下建立entry_zip.go檔案,在其中定義ZipEntry結構體,程式碼如下:

package classpath

import "archive/zip"
import "errors"
import "io/ioutil"
import "path/filepath"

type ZipEntry struct {
	absPath string
}

func newZipEntry(path string) *ZipEntry {……}

func (self *ZipEntry) readClass(className string) ([]byte, Entry, error) {……}

func (self *ZipEntry) String() string {……}

  

absPath欄位存放ZIP或JAR檔案的絕對路徑。建構函式和String()與DirEntry大同小異,程式碼如下:

func newZipEntry(path string) *ZipEntry {
	absPath, err := filepath.Abs(path)
	if err != nil {
		panic(err)
	}
	return &ZipEntry{absPath}
}

  

下面重點介紹如何從ZIP檔案中提取class檔案,程式碼如下:

func (self *ZipEntry) readClass(className string) ([]byte, Entry, error) {
	r, err := zip.OpenReader(self.absPath)
	if err != nil {
		return nil, nil, err
	}

	defer r.Close()
	for _, f := range r.File {
		if f.Name == className {
			rc, err := f.Open()
			if err != nil {
				return nil, nil, err
			}

			defer rc.Close()
			data, err := ioutil.ReadAll(rc)
			if err != nil {
				return nil, nil, err
			}

			return data, self, nil
		}
	}

	return nil, nil, errors.New("class not found: " + className)
}

  

CompositeEntry

在ch02\classpath目錄下建立entry_composite.go檔案,在其中定義CompositeEntry結構體,程式碼如下:

package classpath

import "errors"
import "strings"

type CompositeEntry []Entry

func newCompositeEntry(pathList string) CompositeEntry {……}

func (self CompositeEntry) readClass(className string) ([]byte, Entry, error) {……}

func (self CompositeEntry) String() string {……}

  

如前所述,CompositeEntry由更小的Entry組成,正好可以表示成[]Entry。在Go語言中,陣列屬於比較低層的資料結構,很少直接使用。大部分情況下,使用更便利的slice型別。建構函式把引數(路徑列表)按分隔符分成小路徑,然後把每個小路徑都轉換成具體的Entry例項,程式碼如下:

func newCompositeEntry(pathList string) CompositeEntry {
	compositeEntry := []Entry{}

	for _, path := range strings.Split(pathList, pathListSeparator) {
		entry := newEntry(path)
		compositeEntry = append(compositeEntry, entry)
	}

	return compositeEntry
}

  

相信讀者已經想到readClass()方法的程式碼了:依次呼叫每一個子路徑的readClass()方法,如果成功讀取到class資料,返回資料即可;如果收到錯誤資訊,則繼續;如果遍歷完所有的子路徑還沒有找到class檔案,則返回錯誤。readClass()方法的程式碼如下:

func (self CompositeEntry) readClass(className string) ([]byte, Entry, error) {
	for _, entry := range self {
		data, from, err := entry.readClass(className)
		if err == nil {
			return data, from, nil
		}
	}

	return nil, nil, errors.New("class not found: " + className)
}

  

String()方法也不復雜。呼叫每一個子路徑的String()方法,然後把得到的字串用路徑分隔符拼接起來即可,程式碼如下:

func (self CompositeEntry) String() string {
	strs := make([]string, len(self))

	for i, entry := range self {
		strs[i] = entry.String()
	}

	return strings.Join(strs, pathListSeparator)
}

  

WildcardEntry

WildcardEntry實際上也是CompositeEntry,所以就不再定義新的型別了。在ch02\classpath目錄下建立entry_wildcard.go檔案,在其中定義newWildcardEntry()函式,程式碼如下:

package classpath

import "os"
import "path/filepath"
import "strings"

func newWildcardEntry(path string) CompositeEntry {
	baseDir := path[:len(path)-1] // remove *
	compositeEntry := []Entry{}

	walkFn := func(path string, info os.FileInfo, err error) error {……}

	filepath.Walk(baseDir, walkFn)

	return compositeEntry
}

  

首先把路徑末尾的星號去掉,得到baseDir,然後呼叫filepath包的Walk()函式遍歷baseDir建立ZipEntry。Walk()函式的第二個引數也是一個函式,walkFn變數的定義如下:

walkFn := func(path string, info os.FileInfo, err error) error {
	if err != nil {
		return err
	}
	if info.IsDir() && path != baseDir {
		return filepath.SkipDir
	}
	if strings.HasSuffix(path, ".jar") || strings.HasSuffix(path, ".JAR") {
		jarEntry := newZipEntry(path)
		compositeEntry = append(compositeEntry, jarEntry)
	}
	return nil
}

  

在walkFn中,根據字尾名選出JAR檔案,並且返回SkipDir跳過子目錄(萬用字元類路徑不能遞迴匹配子目錄下的JAR檔案)。

Classpath

Entry介面和4個實現介紹完了,接下來實現Classpath結構體。還是在ch02\classpath目錄下建立classpath.go檔案,把下面的程式碼輸入進去。

package classpath

import "os"
import "path/filepath"

type Classpath struct {
	bootClasspath Entry
	extClasspath  Entry
	userClasspath Entry
}

func Parse(jreOption, cpOption string) *Classpath {……}

func (self *Classpath) ReadClass(className string) ([]byte, Entry, error) {……}

func (self *Classpath) String() string {……}

  

Classpath結構體有三個欄位,分別存放三種類路徑。Parse()函式使用-Xjre選項解析啟動類路徑和擴充套件類路徑,使用-classpath/-cp選項解析使用者類路徑,程式碼如下:

func Parse(jreOption, cpOption string) *Classpath {
	cp := &Classpath{}
	cp.parseBootAndExtClasspath(jreOption)
	cp.parseUserClasspath(cpOption)
	return cp
}

  

parseBootAndExtClasspath()方法的程式碼如下:

func (self *Classpath) parseBootAndExtClasspath(jreOption string) {
	jreDir := getJreDir(jreOption)

	// jre/lib/*
	jreLibPath := filepath.Join(jreDir, "lib", "*")
	self.bootClasspath = newWildcardEntry(jreLibPath)

	// jre/lib/ext/*
	jreExtPath := filepath.Join(jreDir, "lib", "ext", "*")
	self.extClasspath = newWildcardEntry(jreExtPath)
}

  

優先使用使用者輸入的-Xjre選項作為jre目錄。如果沒有輸入該選項,則在當前目錄下尋找jre目錄。如果找不到,嘗試使用JAVA_HOME環境變數。getJreDir()函式的程式碼如下:

func getJreDir(jreOption string) string {
	if jreOption != "" && exists(jreOption) {
		return jreOption
	}
	if exists("./jre") {
		return "./jre"
	}
	if jh := os.Getenv("JAVA_HOME"); jh != "" {
		return filepath.Join(jh, "jre")
	}
	panic("Can not find jre folder!")
}

  

exists()函式用於判斷目錄是否存在,程式碼如下:

func exists(path string) bool {
	if _, err := os.Stat(path); err != nil {
		if os.IsNotExist(err) {
			return false
		}
	}
	return true
}

  

parseUserClasspath()方法的程式碼相對簡單一些,如下:

func (self *Classpath) parseUserClasspath(cpOption string) {
	if cpOption == "" {
		cpOption = "."
	}
	self.userClasspath = newEntry(cpOption)
}

  

如果使用者沒有提供-classpath/-cp選項,則使用當前目錄作為使用者類路徑。ReadClass()方法依次從啟動類路徑、擴充套件類路徑和使用者類路徑中搜索class檔案,程式碼如下:

func (self *Classpath) ReadClass(className string) ([]byte, Entry, error) {
	className = className + ".class"
	if data, entry, err := self.bootClasspath.readClass(className); err == nil {
		return data, entry, err
	}
	if data, entry, err := self.extClasspath.readClass(className); err == nil {
		return data, entry, err
	}
	return self.userClasspath.readClass(className)
}

  

注意,傳遞給ReadClass()方法的類名不包含“.class”字尾。最後,String()方法返回使用者類路徑的字串表示,程式碼如下:

func (self *Classpath) String() string {
	return self.userClasspath.String()
}

  

現在,我們來測試一下ch02的程式碼,開啟ch02/main.go檔案,新增兩條import語句,程式碼如下:

package main

import "fmt"
import "strings"
import "jvmgo/ch02/classpath"

func main() {……}

func startJVM(cmd *Cmd) {……}

  

main()函式不用變,重寫startJVM()函式,程式碼如下:

func startJVM(cmd *Cmd) {
	cp := classpath.Parse(cmd.XjreOption, cmd.cpOption)
	fmt.Printf("classpath:%v class:%v args:%v\n",
		cp, cmd.class, cmd.args)

	className := strings.Replace(cmd.class, ".", "/", -1)
	classData, _, err := cp.ReadClass(className)
	if err != nil {
		fmt.Printf("Could not find or load main class %s\n", cmd.class)
		return
	}

	fmt.Printf("class data:%v\n", classData)
}

  

startJVM()先打印出命令列引數,然後讀取主類資料,並列印到控制檯,雖然還是無法真正啟動Java虛擬機器。開啟命令列視窗,執行下面的命令編譯程式碼:

D:\go_space\src>go install jvmgo\ch02

  

編譯成功後,在{GOPATH}\bin目錄下出現ch02.exe檔案。執行ch02.exe,指定好-Xjre選項和類名,就可以把class檔案的內容打印出來:

D:\go_space\bin>ch02.exe -Xjre "D:\JDK\jdk1.8.0_77\jre" java.lang.Object
classpath:D:\go_space\bin class:java.lang.Object args:[]
class data:[202 254 186……2 0 77]