
Нравится нам это или нет, но Java является одной из наиболее распространенных и широко используемых языки программирования. Однако не каждый Java-разработчик любознателен настолько, чтобы заглянуть под капот и посмотреть, как работает JVM.
Я попытаюсь написать игрушечную (и неполную) JVM, чтобы продемонстрировать основные принципы. strong> его работы. Я надеюсь, что эта статья вызоветваш интерес и вдохновит на дальнейшее изучение JVM. сильный>.
Наша скромная цель
Начнем с простого:
public class Add {
public static int add(int a, int b) {
return a + b;
}
}
Мы компилируем наш класс с "javac Add.java", в результате чего получается Add.class. Этот файл класса представляет собой двоичный файл, который может выполнять JVM. Все, что нам нужно сделать сейчас, это создать JVM, которая выполнит его правильно.
Если мы заглянем внутрь Add.class, используя шестнадцатеричный дамп, нас, вероятно, это не сильно впечатлит:
00000000 ca fe ba be 00 00 00 34 00 0f 0a 00 03 00 0c 07 |.......4........| 00000010 00 0d 07 00 0e 01 00 06 3c 69 6e 69 74 3e 01 00 |........<init>..| 00000020 03 28 29 56 01 00 04 43 6f 64 65 01 00 0f 4c 69 |.()V...Code...Li| 00000030 6e 65 4e 75 6d 62 65 72 54 61 62 6c 65 01 00 03 |neNumberTable...| 00000040 61 64 64 01 00 05 28 49 49 29 49 01 00 0a 53 6f |add...(II)I...So| 00000050 75 72 63 65 46 69 6c 65 01 00 08 41 64 64 2e 6a |urceFile...Add.j| 00000060 61 76 61 0c 00 04 00 05 01 00 03 41 64 64 01 00 |ava........Add..| 00000070 10 6a 61 76 61 2f 6c 61 6e 67 2f 4f 62 6a 65 63 |.java/lang/Objec| 00000080 74 00 21 00 02 00 03 00 00 00 00 00 02 00 01 00 |t.!.............| 00000090 04 00 05 00 01 00 06 00 00 00 1d 00 01 00 01 00 |................| 000000a0 00 00 05 2a b7 00 01 b1 00 00 00 01 00 07 00 00 |...*............| 000000b0 00 06 00 01 00 00 00 01 00 09 00 08 00 09 00 01 |................| 000000c0 00 06 00 00 00 1c 00 02 00 02 00 00 00 04 1a 1b |................| 000000d0 60 ac 00 00 00 01 00 07 00 00 00 06 00 01 00 00 |`...............| 000000e0 00 03 00 01 00 0a 00 00 00 02 00 0b |............|
Хотя мы пока не видим здесь четкой структуры, нам нужно найти способ разобрать ее: что делает ()V и (II)Я имею в виду, что такое ‹init› и почему файл начинается с «cafe babe» ?
Вы, вероятно, знакомы с другим способом создания дампа файлов классов, который часто оказывается более полезным:
$ javap -c Add
Compiled from "Add.java"
public class Add {
public Add();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
public static int add(int, int);
Code:
0: iload_0
1: iload_1
2: iadd
3: ireturn
}
Теперь мы можем видеть наш класс, его конструктор и метод. И конструктор, и метод содержат несколько инструкций. Становится более-менее понятно, что делает наш метод add(): он загружаетдва аргумента(iload_0 и iload_1), добавляет их и возвращаетрезультат. JVM — это стековая машина, поэтому она не имеет регистров; все аргументы инструкций хранятся во внутреннем стеке, и результаты также помещаются в стек.
Загрузчик классов
Как мы можем добиться того же результата, что показала программа javap? Как мы можем разобрать файл класса?
Если мы обратимся к спецификации JVM, мы узнаем о структуре формата файла класса. Он всегда начинается с 4-байтовой подписи (CAFEBABE), за которой следует 2+2 байта для версии. Звучит просто!
Так как нам придется читать байты, короткие числа, целые числа и последовательности байтов из двоичного файла, давайте начнем реализацию нашего загрузчика следующим образом:
type loader struct {
r io.Reader
err error
}
func (l *loader) bytes(n int) []byte {
b := make([]byte, n, n)
// we don’t want to process errors on every step,
// so, we will just save the first found error until the end
// and we do nothing if a error was found
if l.err == nil {
_, l.err = io.ReadFull(l.r, b)
}
return b
}
func (l *loader) u1() uint8 { return l.bytes(1)[0] }
func (l *loader) u2() uint16 { return binary.BigEndian.Uint16(l.bytes(2)) }
func (l *loader) u4() uint32 { return binary.BigEndian.Uint32(l.bytes(4)) }
func (l *loader) u8() uint64 { return binary.BigEndian.Uint64(l.bytes(8)) }
// Usage:
f, _ := os.Open("Add.class")
loader := &loader{r: f}
cafebabe := loader.u4()
major := loader.u2()
minor := loader.u2()
Затем спецификация говорит нам, что нам нужно проанализировать константный пул. Что это такое? Это специальная часть файла класса, которая содержит константы, необходимые для запуска класса. Там хранятся все строковые, числовые константы и ссылки, каждая из которых имеет уникальный uint16индекс (таким образом, класс может иметь до 64K констант).
В пуле есть несколько типов констант, каждая из которых имеет собственный набор значений. Нас могут заинтересовать:
- UTF8: простой строковый литерал
- Класс: индекс строки, содержащей имя класса (косвенная ссылка).
- Имя и тип: индекс имени типа и дескриптора, используемого для полей и методов.
- Ссылки на поля и методы: указатели, относящиеся к классам и константам имени и типа.
Как видите, константы в пуле часто ссылаются друг на друга. Поскольку мы пишем JVM на Go и здесь нет объединенных типов, давайте создадим тип Const, который будет содержать различные возможные постоянные поля:
type Const struct {
Tag byte
NameIndex uint16
ClassIndex uint16
NameAndTypeIndex uint16
StringIndex uint16
DescIndex uint16
String string
}
type ConstPool []Const
Затем, следуя спецификации JVM, мы могли бы извлечь данные постоянного пула следующим образом:
func (l *loader) cpinfo() (constPool ConstPool) {
constPoolCount := l.u2()
// Valid pool indexes start from 1
for i := uint16(1); i < constPoolCount; i++ {
c := Const{Tag: l.u1()}
switch c.Tag {
case 0x01: // UTF8 string literal, 2 bytes length + data
c.String = string(l.bytes(int(l.u2())))
case 0x07: // Class index
c.NameIndex = l.u2()
case 0x08: // String reference index
c.StringIndex = l.u2()
case 0x09, 0x0a: // Field and method: class index + NaT index
c.ClassIndex = l.u2()
c.NameAndTypeIndex = l.u2()
case 0x0c: // Name-and-type
c.NameIndex, c.DescIndex = l.u2(), l.u2()
default:
l.err = fmt.Errorf("unsupported tag: %d", c.Tag)
}
constPool = append(constPool, c)
}
return constPool
}
Сейчас мы сильно упрощаем нашу задачу, но в настоящей JVM нам пришлось бы обрабатывать long и doubleконстантные типы равномерно, вставляя дополнительный неиспользуемая запись пула констант, как говорит нам спецификация JVM (поскольку записи пула констант считаются 32-битными значениями).
Чтобы упростить получение строковых литералов по индексу, давайте реализуем строковый метод Resolve(index uint16):
func (cp ConstPool) Resolve(index uint16) string {
if cp[index-1].Tag == 0x01 {
return cp[index-1].String
}
return ""
}
Теперь нам нужно добавить аналогичные хелперы для парсингасписка интерфейсов, полей и методов классов и их атрибуты:
func (l *loader) interfaces(cp ConstPool) (interfaces []string) {
interfaceCount := l.u2()
for i := uint16(0); i < interfaceCount; i++ {
interfaces = append(interfaces, cp.Resolve(l.u2()))
}
return interfaces
}
// Type field is used for fields and methods
type Field struct {
Flags uint16
Name string
Descriptor string
Attributes []Attribute
}
// Attributes contain additional information about fields and classes
// The most useful one is Code. It contains actual byte code
type Attribute struct {
Name string
Data []byte
}
func (l *loader) fields(cp ConstPool) (fields []Field) {
fieldsCount := l.u2()
for i := uint16(0); i < fieldsCount; i++ {
fields = append(fields, Field{
Flags: l.u2(),
Name: cp.Resolve(l.u2()),
Descriptor: cp.Resolve(l.u2()),
Attributes: l.attrs(cp),
})
}
return fields
}
func (l *loader) attrs(cp ConstPool) (attrs []Attribute) {
attributesCount := l.u2()
for i := uint16(0); i < attributesCount; i++ {
attrs = append(attrs, Attribute{
Name: cp.Resolve(l.u2()),
Data: l.bytes(int(l.u4())),
})
}
return attrs
}
И поля, и методы представлены в виде полей, что очень удобно и экономит время. Наконец, мы можем собрать все это вместе и полностью разобрать наш класс:
type Class struct {
ConstPool ConstPool
Name string
Super string
Flags uint16
Interfaces []string
Fields []Field
Methods []Field
Attributes []Attribute
}
func Load(r io.Reader) (Class, error) {
loader := &loader{r: r}
c := Class{}
loader.u8() // magic (u32), minor (u16), major (u16)
cp := loader.cpinfo() // const pool info
c.ConstPool = cp
c.Flags = loader.u2() // access flags
c.Name = cp.Resolve(loader.u2()) // this class
c.Super = cp.Resolve(loader.u2()) // super class
c.Interfaces = loader.interfaces(cp)
c.Fields = loader.fields(cp) // fields
c.Methods = loader.fields(cp) // methods
c.Attributes = loader.attrs(cp) // methods
return c, loader.err
}
Теперь, если мы посмотрим на полученную информацию о классе, мы увидим, что он не имеет полей и два метода — ‹init›:()V и добавить:(II)I. Что это за римские цифры в скобках? Это дескрипторы. Они определяют, какие типы аргументов принимает метод и что он возвращает. В этом случае ‹init› (синтетический метод, используемый для инициализацииобъектов при их создании) не принимает аргументов и ничего не возвращает. (V=void), тогда как метод «добавить» принимает два типа данных int (I=int32) и возвращает целое число.
Байт-код
Если мы присмотримся, то увидим, что каждыйметод в нашем проанализированном классе имеет один атрибут с именем «Код». Этот атрибут содержит часть байтов как полезную полезную нагрузку. Байты:
<init>: [0 1 0 1 0 0 0 5 42 183 0 1 177 0 0 0 1 0 7 0 0 0 6 0 1 0 0 0 1] add: [0 2 0 2 0 0 0 4 26 27 96 172 0 0 0 1 0 7 0 0 0 6 0 1 0 0 0 3]
В спецификации, на этот раз в разделе байт-кода, мы прочитаем, что атрибут Код начинается со значения maxstack (2 байта), за которым следует maxlocals (2 байта), длина кода (4 байта), а затем фактический код. Итак, наши атрибуты можно прочитать следующим образом:
<init>: maxstack: 1, maxlocals: 1, code: [42 183 0 1 177] add: maxstack: 2, maxlocals: 2, code: [26 27 96 172]
Да, у нас всего 4 и 5 байт кода в каждом методе. Что означают эти байты?
Как я упоминал ранее, JVM — это стековая машина. Каждая инструкция кодируется как один байт, за которым следуют необязательные аргументы. Глядя на спецификацию, мы видим, что метод «добавить» имеет следующие инструкции:
26 = iload_0 27 = iload_1 96 = iadd 172 = ireturn
Инструкции точно такие же, как мы видели в выходных данных javap ранее! Но как мы это делаем?
Фреймы JVM
Когда метод выполняется внутри JVM, у него есть собственный стек для временных операндов, собственные локальные переменные. и фрагмент кода для выполнения. Все эти параметры хранятся в одном исполнительном кадре. Кроме того, кадры содержатуказатель на текущую инструкцию (насколько далеко мы продвинулись в выполнении байт-кода) и указатель на >класс, содержащий метод. Последний нужен для доступа к пулу констант класса, а также для других деталей.
Давайте создадим метод, который создает фрейм для определенного метода, вызываемого с заданными аргументами. Я буду использовать интерфейс type{} в качестве типа Value здесь, хотя использование надлежащего типа объединения, безусловно, было бы более безопасным.
type Frame struct {
Class Class
IP uint32
Code []byte
Locals []interface{}
Stack []interface{}
}
func (c Class) Frame(method string, args ...interface{}) Frame {
for _, m := range c.Methods {
if m.Name == method {
for _, a := range m.Attributes {
if a.Name == "Code" && len(a.Data) > 8 {
maxLocals := binary.BigEndian.Uint16(a.Data[2:4])
frame := Frame{
Class: c,
Code: a.Data[8:],
Locals: make(
[]interface{},
maxLocals,
maxLocals
),
}
for i := 0; i < len(args); i++ {
frame.Locals[i] = args[i]
}
return frame
}
}
}
}
panic("method not found")
}
Итак, мы получили Фрейм с инициализированными локальными переменными, пустым стеком и предзагруженным байт-кодом. Пришло время выполнить байт-код:
func Exec(f Frame) interface{} {
for {
op := f.Code[f.IP]
log.Printf("OP:%02x STACK:%v", op, f.Stack)
n := len(f.Stack)
switch op {
case 26: // iload_0
f.Stack = append(f.Stack, f.Locals[0])
case 27: // iload_1
f.Stack = append(f.Stack, f.Locals[1])
case 96:
a := f.Stack[n-1].(int32)
b := f.Stack[n-2].(int32)
f.Stack[n-2] = a + b
f.Stack = f.Stack[:n-1]
case 172: // ireturn
v := f.Stack[n-1]
f.Stack = f.Stack[:n-1]
return v
}
f.IP++
}
}
Наконец, мы можем собрать все это вместе и запустить, вызвав наш метод add():
f, _ := os.Open("Add.class")
class, _ := Load(f)
frame := class.Frame("add", int32(2), int32(3))
result := Exec(frame)
log.Println(result)
// OUTPUT:
OP:1a STACK:[]
OP:1b STACK:[2]
OP:60 STACK:[2 3]
OP:ac STACK:[5]
5
Итак, все работает. Да, это очень слабая JVM по минимальным системным требованиям, но она все же делает то, что должна делать JVM: загружаетбайт-код и интерпретирует его (конечно, настоящая JVM делает гораздо больше).
Чего не хватает?
Остальные 200 инструкций, среды выполнения, объектно-ориентированное программированиесистем типов и некоторые другие вещи.
Есть 11 групп инструкций, большинство из которых тривиальны:
- Константы (поместите в стек null, небольшое число или значения из пула констант).
- Загружает (помещает локальные переменные в стек). Существует 32 таких типа инструкций.
- Хранит (перемещается из стека в локальные переменные). Еще 32 скучные инструкции.
- Стек (извлечение/дублирование/своп), как и в любой машине стека.
- Математика (сложение/подчинение/деление/множество/рем/сдвиг/логика). Для разных типов значений всего 36 инструкций.
- Конверсии (int в short, int в float и т. д.).
- Сравнения (eq/ne/le/…). Полезно для создания условных выражений, таких как if/else.
- Элемент управления (переход/возврат). Полезно для циклов и подпрограмм.
- Ссылки. Самое интересное, поля и методы, исключения и мониторы объектов.
- Расширенный. На первый взгляд, это не очень элегантное решение. И, вероятно, со временем это не изменится.
- Зарезервировано. Здесь находится инструкция точки останова 0xca.
Большинство инструкций легко реализовать: они берут один или два аргумента из стека, выполняют над ними какую-либо операцию и отправляют результат. Единственное, что здесь следует иметь в виду, это то, что инструкции для типов long и double предполагают, что каждое значение занимает два слота в стеке, поэтому вам может понадобиться дополнительная push () и pop(), что усложняет группировку инструкций.
Чтобы реализовать ссылки, вам нужно подумать об объектной модели: как вы хотите хранитьобъекты и их классы, как представлять >наследование, где хранить поля экземпляра и класса. Кроме того, вы должны быть осторожны с отправкой методов — есть несколькоинструкций «invoke», и они ведут себя по-разному:
- invokestatic: вызывает статический метод в классе, никаких сюрпризов.
- invokespecial: прямой вызов метода экземпляра, в основном используемый для искусственных методов, таких как ‹init›, или закрытых методов.
- invokevirtual: вызывает метод экземпляра на основе иерархии классов.
- invokeinterface: вызывает метод интерфейса, аналогичный invokevirtual, но выполняет дополнительные проверки и оптимизацию.
- invokedynamic: вызывает динамически вычисляемый сайт вызова, полезный для динамических методов и MethodHandles в новой версии Java 7.
Если вы создаете JVM на языке без сборки мусора, вам нужно подумать о том, как ее выполнить: подсчет ссылок, алгоритм пометки и очистки и т. д. Обработка исключений путем реализации athrow, их распространение через фреймы и обработка с помощью таблиц исключений — еще одна интересная тема.
Наконец, ваша JVM будет бесполезна без классов времени выполнения. Без java/lang/Object вы даже не увидите, как работает новая инструкция при создании новых объектов. Ваша среда выполнения может предоставлять некоторые общие классы JRE из пакетов java.lang, java.io и java.util, или она может быть что-то более специфичное для домена. Скорее всего, некоторые методы в классах должны быть реализованы изначально, не на Java. Это поднимает вопрос о том, как найти и выполнять такие методы, и становится еще одним крайним случаем для вашей JVM.
Другими словами, создать правильную JVM не так просто, но понять, как она работает, не сложно.
полный код вы можете найти здесь.