代码、内容参考来自于张秀宏大佬的自己动手写Java虚拟机 (Java核心技术系列)。
启动Java应用程序:
首先启动Java虚拟机,然后加载主类,最后调用主类的main()方法。但即使是最简单的“Hello,World”程序,也是无法独自运行的,该程序的代码如下:
public class HelloWorld {
public static void main(String[] args) {
System.out.println("Hello, world!");
}
}
1.类路径
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环境变量设置。第1章简单介绍过这个选项,这里再详细解释一下。-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\* ...
2.准备工作
从第2章开始,每章的代码都是建立在前一章的基础之上。把ch01目录复制一份,然后改名为ch02。因为本章要创建的源文件都在classpath包中,所以在ch02目录中创建一个classpath子目录。现在目录结构看起来应该是这样:

我们的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
}
3.实现类路径
可以把类路径想象成一个大的整体,它由启动类路径、扩展类路径和用户类路径三个小路径构成。三个小路径又分别由更小的路径构成。是不是很像组合模式(composite pattern)?没错,本节就套用组合模式来设计和实现类路径。
Entry接口
先定义一个接口来表示类路径项。在ch02\classpath目录下创建entry.go文件,在其中定义Entry接口,代码如下:
package classpath
import (
"os"
"strings"
)
const pathListSeparator = string(os.PathListSeparator)
type Entry interface {
readClass(className string) ([]byte, Entry, error)
String() string
}
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)
}
代码解释:
os.PathListSeparator
源码

- 常量pathListSeparator是string类型,存放路径分隔符,后面会用到。Entry接口中有两个方法。readClass()方法负责寻找和加载class文件;String()方法的作用相当于Java中的toString(),用于返回变量的字符串表示。
- readClass()方法的参数是class文件的相对路径,路径之间用斜线(/)分隔,文件名有.class后缀。比如要读取java.lang.Object类,传入的参数应该是java/lang/Object.class。返回值是读取到的字节数据、最终定位到class文件的Entry,以及错误信息。Go的函数或方法允许返回多个值,按照惯例,可以使用最后一个返回值作为错误信息。
- newEntry()函数根据参数创建不同类型的Entry实例。
- strings.Contains() :Contains() 方法用于判断字符串中是否包含指定的字符或字符串。

源码

- strings.HasSuffix():HasSuffix测试字符串s是否以suffix结尾。

源码

- Entry接口有4个实现,分别是DirEntry、ZipEntry、CompositeEntry和WildcardEntry。下面分别介绍每一种实现。
DirEntry
在4种实现中,DirEntry相对简单一些,表示目录形式的类路径。在ch02\classpath目录下创建entry_dir.go文件,在其中定义DirEntry结构体,代码如下:
package classpath
import (
"io/ioutil"
"path/filepath"
)
type DirEntry struct {
absDir string
}
func newDirEntry(path string) *DirEntry {
absDir, err := filepath.Abs(path)
if err != nil {
panic(err)
}
return &DirEntry{absDir}
}
func (self *DirEntry) readClass(className string) ([]byte, Entry, error) {
fileName := filepath.Join(self.absDir, className)
data, err := ioutil.ReadFile(fileName)
return data, self, err
}
func (self *DirEntry) String() string {
return self.absDir
}

在源码里调用os.ReadFile()。

这里建议使用os.ReadFile()
将代码改为(其实就是io/ioutil 改为os;ioutil.ReadFile改为os.ReadFile):
package classpath
import (
"os"
"path/filepath"
)
type DirEntry struct {
absDir string
}
func newDirEntry(path string) *DirEntry {
absDir, err := filepath.Abs(path)
if err != nil {
panic(err)
}
return &DirEntry{absDir}
}
func (self *DirEntry) readClass(className string) ([]byte, Entry, error) {
fileName := filepath.Join(self.absDir, className)
data, err := os.ReadFile(fileName)
return data, self, err
}
func (self *DirEntry) String() string {
return self.absDir
}
代码解释:
- filepath.Abs()如果传如的参数路径是一个绝对路径则直接返回。如果传如的参数路径不是绝对路径,它将与当前工作目录联接,以将其转换为绝对路径。不能保证给定文件的绝对路径名是唯一的。

源码:

- 当出现error不等于nil的时候,说明出现某些错误了,需要我们对这个错误进行一些处理,而如果等于nil说明运行正常没有错误。那什么是nil呢?nil的意思是无,或者是零值。在Go语言中,如果你声明了一个变量但是没有对它进行赋值操作,那么这个变量就会有一个类型的默认零值。
if err != nil {
return err
}if err != nil {
panic(err)
}不想写if err != nil 的代码,方式之一就是用panic来替代他。通过panic的方式取代了return err的函数返回,自然其所关联的下游业务代码也就不需要编写 if err != nil 的代码:
每种类型对应的零值为:
bool -> false numbers -> 0 string -> "" pointers -> nil slices -> nil maps -> nil channels -> nil functions -> nil interfaces -> nil
- DirEntry只有一个字段,用于存放目录的绝对路径。和Java语言不同,Go结构体不需要显示实现接口,只要方法匹配即可。Go没有专门的构造函数,本文统一使用new开头的函数来创建结构体实例,并把这类函数称为构造函数。newDirEntry()先把参数转换成绝对路径,如果转换过程出现错误,则调用panic()函数终止程序执行,否则创建DirEntry实例并返回。
- readClass()先把目录和class文件名拼成一个完整的路径,然后调用os包提供的ReadFile()函数读取class文件内容,最后返回。
- filepath.Join() :Go语言中的filepath.Join()函数用于将任意数量的指定路径元素连接到单个路径中,并在必要时添加分隔符。此函数对结果调用Clean,所有空字符串都将被忽略。此外,此函数在路径包下定义。在这里,您需要导入“path/filepath”包才能使用这些函数。
如:
fmt.Println(filepath.Join("G", "F", "G"))
fmt.Println(filepath.Join("G/F", "G"))输出:
G/F/G G/F/G

- os.ReadFile:ReadFile 读取命名文件并返回内容。成功的调用返回 err == nil,而不是 err == EOF。因为ReadFile 读取整个文件,它不会将Read 中的EOF 视为要报告的错误。

- String()方法很简单,直接返回目录.
ZipEntry
ZipEntry表示ZIP或JAR文件形式的类路径。在ch02\classpath目录下创建entry_zip.go文件,在其中定义ZipEntry结构体,
注意:

源码:

所以我们直接调用io包里的函数.
代码如下:
package classpath
import (
"archive/zip"
"errors"
"io"
"path/filepath"
)
type ZipEntry struct {
absPath string
}
func newZipEntry(path string) *ZipEntry {
absPath, err := filepath.Abs(path)
if err != nil {
panic(err)
}
return &ZipEntry{absPath}
}
func (self *ZipEntry) String() string {
return self.absPath
}
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 := io.ReadAll(rc)
if err != nil {
return nil, nil, err
}
return data, self, nil
}
}
return nil, nil, errors.New("Class not found:" + className)
}absPath字段存放ZIP或JAR文件的绝对路径。构造函数和String()与DirEntry大同小异,就不多解释了。
readClass从ZIP文件中提取class文件。
首先打开ZIP文件,如果这一步出错的话,直接返回。然后遍历ZIP压缩包里的文件,看能否找到class文件。如果能找到,则打开class文件,把内容读取出来,并返回。如果找不到,或者出现其他错误,则返回错误信息。有两处使用了defer语句来确保打开的文件得以关闭。
readClass()方法每次都要打开和关闭ZIP文件,因此效率不是很高。
为此我们进行了优化:
在ch02\classpath目录下创建entry_zip2.go文件
package classpath
import (
"archive/zip"
"errors"
"io"
"path/filepath"
)
type ZipEntry2 struct {
absPath string
zipRC *zip.ReadCloser
}
func newZipEntry2(path string) *ZipEntry2 {
absPath, err := filepath.Abs(path)
if err != nil {
panic(err)
}
return &ZipEntry2{absPath, nil}
}
func (self *ZipEntry2) readClass(className string) ([]byte, Entry, error) {
if self.zipRC == nil {
err := self.openJar()
if err != nil {
return nil, nil, err
}
}
classFile := self.findClass(className)
if classFile == nil {
return nil, nil, errors.New("class not found: " + className)
}
data, err := readClass(classFile)
return data, self, err
}
// todo: close zip
func (self *ZipEntry2) openJar() error {
r, err := zip.OpenReader(self.absPath)
if err == nil {
self.zipRC = r
}
return err
}
func (self *ZipEntry2) findClass(className string) *zip.File {
for _, f := range self.zipRC.File {
if f.Name == className {
return f
}
}
return nil
}
func readClass(classFile *zip.File) ([]byte, error) {
rc, err := classFile.Open()
if err != nil {
return nil, err
}
// read class data
data, err := io.ReadAll(rc)
rc.Close()
if err != nil {
return nil, err
}
return data, nil
}
func (self *ZipEntry2) String() string {
return self.absPath
}代码解释:
- absPath字段存放ZIP或JAR文件的绝对路径。构造函数和String()与DirEntry大同小异,就不多解释了。
- 在结构体加入一个*zip.ReadCloser,通过对其操作,完成readClass()方法每次不再要打开和关闭ZIP文件
- readClass方法,先判断zipRC是否为空,为空就调用openJar()
- openJar()方法调用zip.OpenReader(),打开ZIP文件,成功了就对zipRC赋值
- findClass方法接收一个文件名,遍历ZIP文件,找到与接收的文件名一致,就返回ZipEntry2结构体的指针
- 再调用函数readClass,打开findClass方法找到的class文件,把内容读取出来,并返回。如果找不到,或者出现其他错误,则返回错误信息。
- 通过先判断zipRC是否为空,来确保不要每次打开和关闭ZIP文件
- zip.ReadCloser: 当不再需要时必须关闭的Reader。

- zip.OpenReader:OpenReader将打开名称指定的Zip文件并返回readclose。

- zip.File:文件是ZIP归档中的单个文件。文件信息在内嵌的FileHeader中。可以通过调用Open来访问文件内容。

- classFile.Open():Open返回一个readclose,提供对文件内容的访问。多个文件可以并发读取。

- rc.Close():Close关闭Zip文件,使其无法用于I/O。

- ReadAll从r开始读取,直到出现错误或EOF,并返回所读取的数据。一个成功的调用返回err == nil。

CompositeEntry
在ch02\classpath目录下创建entry_composite.go文件,在其中定义CompositeEntry结构体,代码如下:
package classpath
import (
"errors"
"strings"
)
type CompositeEntry []Entry
func newCompositeEntry(pathList string) CompositeEntry {
compositeEntry := []Entry{}
for _, path := range strings.Split(pathList, pathListSeparator) {
entry := newEntry(path)
compositeEntry = append(compositeEntry, entry)
}
return compositeEntry
}
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)
}
func (self CompositeEntry) String() string {
strs := make([]string, len(self))
for i, entry := range self {
strs[i] = entry.String()
}
return strings.Join(strs, pathListSeparator)
}代码解释:
- 如前所述,CompositeEntry由更小的Entry组成,正好可以表示成[]Entry。在Go语言中,数组属于比较低层的数据结构,很少直接使用。大部分情况下,使用更便利的slice类型。构造函数把参数(路径列表)按分隔符分成小路径,然后把每个小路径都转换成具体的Entry实例。
- 相信你已经想到readClass()方法的代码了:依次调用每一个子路径的readClass()方法,如果成功读取到class数据,返回数据即可;如果收到错误信息,则继续;如果遍历完所有的子路径还没有找到class文件,则返回错误。
- String()方法也不复杂。调用每一个子路径的String()方法,然后把得到的字符串用路径分隔符拼接起来即可。
- Go语言的内建函数 append() 可以为切片动态添加元素,代码如下所示:
- strings.Split函数用于将指定的分隔符切割字符串,并返回切割后的字符串切片。
- Go语言中new和make是内建的两个函数,主要用来创建分配类型内存。
- make(Type, len, cap)是Go语言内存分配的内置函数,默认有三个参数:Type:数据类型,必要参数,Type 的值只能是 slice、 map、 channel 这三种数据类型。
len:数据类型实际占用的内存空间长度,map、 channel 是可选参数,slice 是必要参数。
cap:为数据类型提前预留的内存空间长度,可选参数。所谓的提前预留是当前为数据类型申请内存空间的时候,提前申请好额外的内存空间,这样可以避免二次分配内存带来的开销,大大提高程序的性能。
WildcardEntry
WildcardEntry实际上也是CompositeEntry,所以就不再定义新的类型了。在ch02\classpath目录下创建entry_wildcard.go文件,在其中定义newWildcardEntry()函数,代码如下:
package classpath
import (
"os"
"path/filepath"
"strings"
)
func newWildcardEntry(path string) CompositeEntry {
baseDir := path[:len(path)-1] // remove*
compositeEntry := []Entry{}
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
}
filepath.Walk(baseDir, walkFn)
return compositeEntry
}- 首先把路径末尾的星号去掉,得到baseDir,然后调用filepath包的Walk()函数遍历baseDir创建ZipEntry。Walk()函数的第二个参数也是一个函数,了解函数式编程的读者应该一眼就可以认出这种用法(即函数可作为参数)。
- 在walkFn中,根据后缀名选出JAR文件,并且返回SkipDir跳过子目录(通配符类路径不能递归匹配子目录下的JAR文件)。
- walk方法会遍历root下的所有文件(包含root)并对每一个目录和文件都调用walkFn方法。在访问文件和目录时发生的错误都会通过error参数传递给WalkFn方法。文件是按照词法顺序进行遍历的,这个通常让输出更漂亮,但是也会导致处理非常大的目录时效率会降低。另外,Walk函数不会遍历符号链接。

- filepath.SkipDir:SkipDir用作WalkFn的返回值,以指示调用中指定的目录将被跳过。它不会被任何函数作为错误返回。
源码:


- info.IsDir()判断所给路径是否为文件夹
源码:

Classpath
Entry接口和4个实现介绍完了,接下来实现Classpath结构体。还是在ch02\classpath目录下创建classpath.go文件,把下面的代码输入进去。
package classpath
import (
"os"
"path/filepath"
)
type Classpath struct {
bootClasspath Entry
extClasspath Entry
userClasspath Entry
}
func Parse(jreOption, cpOption string) *Classpath {
cp := &Classpath{}
cp.parseBootAndExtClasspath(jreOption)
cp.parseUserClasspath(cpOption)
return cp
}
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)
}
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!")
}
func exists(path string) bool {
if _, err := os.Stat(path); err != nil {
if os.IsNotExist(err) {
return false
}
}
return true
}
func (self *Classpath) parseUserClasspath(cpOption string) {
if cpOption == "" {
cpOption = "."
}
self.userClasspath = newEntry(cpOption)
}
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)
}
func (self *Classpath) String() string {
return self.userClasspath.String()
}
- Classpath结构体有三个字段,分别存放三种类路径。
- Parse()函数使用-Xjre选项解析启动类路径和扩展类路径,使用-classpath/-cp选项解析用户类路径。
- getJreDir()函数:优先使用用户输入的-Xjre选项作为jre目录。如果没有输入该选项,则在当前目录下寻找jre目录。如果找不到,尝试使用JAVA_HOME环境变量。
- os.Getenv功能:获取系统key的环境变量,如果没有环境变量就返回空

- exists()函数用于判断目录是否存在
golang判断文件或文件夹是否存在的方法为使用os.Stat()函数返回的错误值进行判断:
- 如果返回的错误为nil,说明文件或文件夹存在
- 如果返回的错误类型使用os.IsNotExist()或者 os.IsExist() 判断为true,说明文件或文件夹不存在
- 如果返回的错误为其它类型,则不确定是否在存在
- 如果用户没有提供-classpath/-cp选项,则使用当前目录作为用户类路径。ReadClass()方法依次从启动类路径、扩展类路径和用户类路径中搜索class文件,
- 注意,传递给ReadClass()方法的类名不包含“.class”后缀。最后,String()方法返回用户类路径的字符串表示
至此,整个类路径都实现了。
4.测试代码
打开ch02/main.go文件,添加两条import语句,代码如下:
package main
import (
"fmt"
"jvmgo/ch02/classpath"
"strings"
)
func main() {
cmd := parseCmd()
if cmd.versionFlag {
fmt.Println("java version 0.0.1")
} else if cmd.helpFlag || cmd.class == "" {
printUsage()
} else {
startJVM(cmd)
}
}
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虚拟机,不过相比第1章,已经有了很大的进步。打开命令行窗口,执行下面的命令编译本章代码。
go install jvmgo\ch02
编译成功后,在D:\go\workspace\bin目录下出现ch02.exe文件。执行ch02.exe,指定好-Xjre选项和类名,就可以把class文件的内容打印出来。

如图:还可以启动自带参数,这样直接在main()方法右键运行就行。输出一样。

5.参考
尚硅谷宋红康:JVM全套教程:https://www.bilibili.com/video/BV1PJ411n7xZ
周志明:深入理解java虚拟机
张秀宏:自己动手写Java虚拟机 (Java核心技术系列)


