Запачкайте руки байт-кодом Scala JVM
Опубликовано: 2022-03-11Язык Scala продолжает набирать популярность в течение последних нескольких лет благодаря превосходному сочетанию функциональных и объектно-ориентированных принципов разработки программного обеспечения, а также его реализации поверх проверенной виртуальной машины Java (JVM).
Хотя Scala компилируется в байт-код Java, он предназначен для устранения многих очевидных недостатков языка Java. Предлагая полную функциональную поддержку программирования, базовый синтаксис Scala содержит множество неявных структур, которые программистам на Java приходится создавать явным образом, причем некоторые из них сопряжены со значительной сложностью.
Создание языка, который компилируется в байт-код Java, требует глубокого понимания внутренней работы виртуальной машины Java. Чтобы оценить, чего достигли разработчики Scala, необходимо заглянуть под капот и изучить, как исходный код Scala интерпретируется компилятором для создания эффективного и действенного байт-кода JVM.
Давайте посмотрим, как все это реализовано.
Предпосылки
Чтение этой статьи требует некоторого базового понимания байт-кода виртуальной машины Java. Полную спецификацию виртуальной машины можно получить из официальной документации Oracle. Чтение всей спецификации не критично для понимания этой статьи, поэтому для быстрого ознакомления с основами я подготовил краткое руководство внизу статьи.
Утилита необходима для дизассемблирования байт-кода Java, чтобы воспроизвести приведенные ниже примеры и продолжить дальнейшее исследование. Java Development Kit предоставляет собственную утилиту командной строки, javap
, которую мы будем здесь использовать. Быстрая демонстрация того, как работает javap
, включена в руководство внизу.
И, конечно же, работающая установка компилятора Scala необходима читателям, которые хотят следовать примерам. Эта статья была написана с использованием Scala 2.11.7. Разные версии Scala могут создавать немного отличающийся байт-код.
Геттеры и сеттеры по умолчанию
Хотя соглашение Java всегда предусматривает методы получения и установки для общедоступных атрибутов, программисты Java обязаны писать их сами, несмотря на то, что шаблон для каждого из них не менялся десятилетиями. Scala, напротив, предоставляет геттеры и сеттеры по умолчанию.
Давайте посмотрим на следующий пример:
class Person(val name:String) { }
Давайте заглянем внутрь класса Person
. Если мы скомпилируем этот файл с помощью scalac
, то запуск $ javap -p Person.class
даст нам:
Compiled from "Person.scala" public class Person { private final java.lang.String name; // field public java.lang.String name(); // getter method public Person(java.lang.String); // constructor }
Мы видим, что для каждого поля в классе Scala генерируется поле и его метод получения. Поле является закрытым и окончательным, а метод общедоступным.
Если мы заменим val
на var
в исходном коде Person
и перекомпилируем, то модификатор final
поля будет удален, а также будет добавлен метод установки:
Compiled from "Person.scala" public class Person { private java.lang.String name; // field public java.lang.String name(); // getter method public void name_$eq(java.lang.String); // setter method public Person(java.lang.String); // constructor }
Если какой-либо val
или var
определен внутри тела класса, то соответствующие частные поля и методы доступа создаются и инициализируются соответствующим образом при создании экземпляра.
Обратите внимание, что такая реализация полей val
и var
на уровне класса означает, что если некоторые переменные используются на уровне класса для хранения промежуточных значений и никогда не обращаются к ним напрямую программистом, инициализация каждого такого поля добавит от одного до двух методов в класс. след класса. Добавление private
модификатора для таких полей не означает, что соответствующие методы доступа будут удалены. Они просто станут частными.
Определения переменных и функций
Предположим, что у нас есть метод m()
, и создадим три разные ссылки в стиле Scala на эту функцию:
class Person(val name:String) { def m(): Int = { // ... return 0 } val m1 = m var m2 = m def m3 = m }
Как построена каждая из этих ссылок на m
? Когда m
выполняется в каждом случае? Давайте посмотрим на получившийся байт-код. Следующий вывод показывает результаты javap -v Person.class
(опуская много лишнего вывода):
Constant pool: #22 = Fieldref #2.#21 // Person.m1:I #24 = Fieldref #2.#23 // Person.m2:I #30 = Methodref #2.#29 // Person.m:()I #35 = Methodref #4.#34 // java/lang/Object."<init>":()V // ... public int m(); Code: // other methods refer to this method // ... public int m1(); Code: // get the value of field m1 and return it 0: aload_0 1: getfield #22 // Field m1:I 4: ireturn public int m2(); Code: // get the value of field m2 and return it 0: aload_0 1: getfield #24 // Field m2:I 4: ireturn public void m2_$eq(int); Code: // get the value of this method's input argument 0: aload_0 1: iload_1 // write it to the field m2 and return 2: putfield #24 // Field m2:I 5: return public int m3(); Code: // execute the instance method m(), and return 0: aload_0 1: invokevirtual #30 // Method m:()I 4: ireturn public Person(java.lang.String); Code: // instance constructor ... // execute the instance method m(), and write the result to field m1 9: aload_0 10: aload_0 11: invokevirtual #30 // Method m:()I 14: putfield #22 // Field m1:I // execute the instance method m(), and write the result to field m2 17: aload_0 18: aload_0 19: invokevirtual #30 // Method m:()I 22: putfield #24 // Field m2:I 25: return
В пуле констант мы видим, что ссылка на метод m()
хранится по индексу #30
. В коде конструктора мы видим, что этот метод вызывается дважды во время инициализации, при этом инструкция invokevirtual #30
появляется сначала по смещению 11 байт, а затем по смещению 19. За первым вызовом следует инструкция putfield #22
, которая присваивает результат этот метод в поле m1
, на которое ссылается индекс #22
в пуле констант. За вторым вызовом следует тот же шаблон, на этот раз значение присваивается полю m2
, проиндексированному как #24
в пуле констант.
Другими словами, назначение метода переменной, определенной с помощью val
или var
, только присваивает результат метода этой переменной. Мы видим, что созданные методы m1()
и m2()
являются просто геттерами для этих переменных. В случае var m2
мы также видим, что создается установщик m2_$eq(int)
, который ведет себя так же, как и любой другой установщик, перезаписывая значение в поле.
Однако использование ключевого слова def
дает другой результат. Вместо получения возвращаемого значения поля метод m3()
также включает в себя инструкцию invokevirtual #30
. То есть каждый раз, когда вызывается этот метод, он затем вызывает m()
и возвращает результат этого метода.
Итак, как мы видим, Scala предоставляет три способа работы с полями класса, и их легко указать с помощью ключевых слов val
, var
и def
. В Java нам пришлось бы явно реализовывать необходимые сеттеры и геттеры, и такой шаблонный код, написанный вручную, был бы гораздо менее выразительным и более подверженным ошибкам.
Ленивые значения
При объявлении ленивого значения создается более сложный код. Предположим, мы добавили следующее поле в ранее определенный класс:
lazy val m4 = m
Запуск javap -p -v Person.class
теперь покажет следующее:
Constant pool: #20 = Fieldref #2.#19 // Person.bitmap$0:Z #23 = Methodref #2.#22 // Person.m:()I #25 = Fieldref #2.#24 // Person.m4:I #31 = Fieldref #27.#30 // scala/runtime/BoxedUnit.UNIT:Lscala/runtime/BoxedUnit; #48 = Methodref #2.#47 // Person.m4$lzycompute:()I // ... private volatile boolean bitmap$0; private int m4$lzycompute(); Code: // lock the thread 0: aload_0 1: dup 2: astore_1 3: monitorenter // check the flag for whether this field has already been set 4: aload_0 5: getfield #20 // Field bitmap$0:Z // if it has, skip to position 24 (unlock the thread and return) 8: ifne 24 // if it hasn't, execute the method m() 11: aload_0 12: aload_0 13: invokevirtual #23 // Method m:()I // write the method to the field m4 16: putfield #25 // Field m4:I // set the flag indicating the field has been set 19: aload_0 20: iconst_1 21: putfield #20 // Field bitmap$0:Z // unlock the thread 24: getstatic #31 // Field scala/runtime/BoxedUnit.UNIT:Lscala/runtime/BoxedUnit; 27: pop 28: aload_1 29: monitorexit // get the value of field m4 and return it 30: aload_0 31: getfield #25 // Field m4:I 34: ireturn // ... public int m4(); Code: // check the flag for whether this field has already been set 0: aload_0 1: getfield #20 // Field bitmap$0:Z // if it hasn't, skip to position 14 (invoke lazy method and return) 4: ifeq 14 // if it has, get the value of field m4, then skip to position 18 (return) 7: aload_0 8: getfield #25 // Field m4:I 11: goto 18 // execute the method m4$lzycompute() to set the field 14: aload_0 15: invokespecial #48 // Method m4$lzycompute:()I // return 18: ireturn
В этом случае значение поля m4
не вычисляется до тех пор, пока оно не понадобится. Создан специальный закрытый метод m4$lzycompute()
для вычисления ленивого значения и поле bitmap$0
для отслеживания его состояния. Метод m4()
проверяет, равно ли значение этого поля 0, указывая, что m4
еще не инициализирован, и в этом случае m4$lzycompute()
, заполняя m4
и возвращая его значение. Этот закрытый метод также устанавливает значение bitmap$0
равным 1, так что при следующем вызове m4()
он пропустит вызов метода инициализации и вместо этого просто вернет значение m4
.
Создаваемый здесь байт-код Scala спроектирован так, чтобы быть потокобезопасным и эффективным. Чтобы быть потокобезопасным, метод ленивых вычислений использует пару инструкций monitorenter
/ monitorexit
. Этот метод остается эффективным, поскольку накладные расходы на производительность этой синхронизации возникают только при первом чтении отложенного значения.
Для указания состояния ленивого значения требуется только один бит. Таким образом, если ленивых значений не более 32, одно целое поле может отслеживать их все. Если в исходном коде определено более одного ленивого значения, приведенный выше байт-код будет изменен компилятором для реализации битовой маски для этой цели.
Опять же, Scala позволяет нам легко использовать преимущества определенного типа поведения, которые должны быть явно реализованы в Java, экономя усилия и снижая риск опечаток.
Функция как значение
Теперь давайте взглянем на следующий исходный код Scala:
class Printer(val output: String => Unit) { } object Hello { def main(arg: Array[String]) { val printer = new Printer( s => println(s) ); printer.output("Hello"); } }
Класс Printer
имеет одно поле output
с типом String => Unit
: функция, которая принимает String
и возвращает объект типа Unit
(аналогично void
в Java). В основном методе мы создаем один из этих объектов и назначаем это поле анонимной функцией, которая печатает заданную строку.
При компиляции этого кода создаются четыре файла классов:
Hello.class
— это класс-оболочка, основной метод которого просто вызывает Hello$.main()
:
public final class Hello // ... public static void main(java.lang.String[]); Code: 0: getstatic #16 // Field Hello$.MODULE$:LHello$; 3: aload_0 4: invokevirtual #18 // Method Hello$.main:([Ljava/lang/String;)V 7: return
Скрытый Hello$.class
содержит реальную реализацию основного метода. Чтобы взглянуть на его байт-код, убедитесь, что вы правильно экранируете $
в соответствии с правилами вашей командной оболочки, чтобы избежать его интерпретации как специального символа:
public final class Hello$ // ... public void main(java.lang.String[]); Code: // initialize Printer and anonymous function 0: new #16 // class Printer 3: dup 4: new #18 // class Hello$$anonfun$1 7: dup 8: invokespecial #19 // Method Hello$$anonfun$1."<init>":()V 11: invokespecial #22 // Method Printer."<init>":(Lscala/Function1;)V 14: astore_2 // load the anonymous function onto the stack 15: aload_2 16: invokevirtual #26 // Method Printer.output:()Lscala/Function1; // execute the anonymous function, passing the string "Hello" 19: ldc #28 // String Hello 21: invokeinterface #34, 2 // InterfaceMethod scala/Function1.apply:(Ljava/lang/Object;)Ljava/lang/Object; // return 26: pop 27: return
Метод создает Printer
. Затем он создает Hello$$anonfun$1
, который содержит нашу анонимную функцию s => println(s)
. Printer
инициализируется этим объектом в качестве поля output
. Затем это поле загружается в стек и выполняется с операндом "Hello"
.
Давайте взглянем на класс анонимной функции Hello$$anonfun$1.class
ниже. Мы можем видеть, что он расширяет Function1
Scala (как AbstractFunction1
), реализуя метод apply()
. На самом деле, он создает два метода apply()
, один из которых оборачивает другой, которые вместе выполняют проверку типов (в данном случае, что ввод является String
) и выполняют анонимную функцию (распечатывая ввод с помощью println()
).
public final class Hello$$anonfun$1 extends scala.runtime.AbstractFunction1<java.lang.String, scala.runtime.BoxedUnit> implements scala.Serializable // ... // Takes an argument of type String. Invoked second. public final void apply(java.lang.String); Code: // execute Scala's built-in method println(), passing the input argument 0: getstatic #25 // Field scala/Predef$.MODULE$:Lscala/Predef$; 3: aload_1 4: invokevirtual #29 // Method scala/Predef$.println:(Ljava/lang/Object;)V 7: return // Takes an argument of type Object. Invoked first. public final java.lang.Object apply(java.lang.Object); Code: 0: aload_0 // check that the input argument is a String (throws exception if not) 1: aload_1 2: checkcast #36 // class java/lang/String // invoke the method apply( String ), passing the input argument 5: invokevirtual #38 // Method apply:(Ljava/lang/String;)V // return the void type 8: getstatic #44 // Field scala/runtime/BoxedUnit.UNIT:Lscala/runtime/BoxedUnit; 11: areturn
Возвращаясь к приведенному выше методу Hello$.main()
, мы видим, что по смещению 21 выполнение анонимной функции запускается вызовом ее метода apply( Object )
.
Наконец, для полноты картины давайте посмотрим на байт-код для Printer.class
:
public class Printer // ... // field private final scala.Function1<java.lang.String, scala.runtime.BoxedUnit> output; // field getter public scala.Function1<java.lang.String, scala.runtime.BoxedUnit> output(); Code: 0: aload_0 1: getfield #14 // Field output:Lscala/Function1; 4: areturn // constructor public Printer(scala.Function1<java.lang.String, scala.runtime.BoxedUnit>); Code: 0: aload_0 1: aload_1 2: putfield #14 // Field output:Lscala/Function1; 5: aload_0 6: invokespecial #21 // Method java/lang/Object."<init>":()V 9: return
Мы видим, что анонимная функция здесь обрабатывается так же, как и любая переменная val
. Он сохраняется в поле output
класса, и создается геттер output()
. Единственное отличие состоит в том, что теперь эта переменная должна реализовывать интерфейс Scala scala.Function1
(что и делает AbstractFunction1
).
Таким образом, стоимость этой элегантной функции Scala заключается в базовых служебных классах, созданных для представления и выполнения одной анонимной функции, которую можно использовать в качестве значения. Вы должны принять во внимание количество таких функций, а также детали реализации вашей виртуальной машины, чтобы выяснить, что это означает для вашего конкретного приложения.
Особенности Скала
Черты Scala похожи на интерфейсы в Java. Следующий трейт определяет две сигнатуры метода и предоставляет реализацию по умолчанию для второй. Посмотрим, как это реализовано:
trait Similarity { def isSimilar(x: Any): Boolean def isNotSimilar(x: Any): Boolean = !isSimilar(x) }
Создаются две сущности: Similarity.class
, интерфейс, объявляющий оба метода, и синтетический класс, Similarity$class.class
, обеспечивающий реализацию по умолчанию:
public interface Similarity { public abstract boolean isSimilar(java.lang.Object); public abstract boolean isNotSimilar(java.lang.Object); }
public abstract class Similarity$class public static boolean isNotSimilar(Similarity, java.lang.Object); Code: 0: aload_0 // execute the instance method isSimilar() 1: aload_1 2: invokeinterface #13, 2 // InterfaceMethod Similarity.isSimilar:(Ljava/lang/Object;)Z // if the returned value is 0, skip to position 14 (return with value 1) 7: ifeq 14 // otherwise, return with value 0 10: iconst_0 11: goto 15 // return the value 1 14: iconst_1 15: ireturn public static void $init$(Similarity); Code: 0: return
Когда класс реализует этот трейт и вызывает метод isNotSimilar
, компилятор Scala генерирует инструкцию байт-кода invokestatic
для вызова статического метода, предоставляемого сопутствующим классом.
Сложный полиморфизм и структуры наследования могут быть созданы из признаков. Например, несколько трейтов, а также реализующий класс могут переопределять метод с одной и той же сигнатурой, вызывая super.methodName()
для передачи управления следующему трейту. Когда компилятор Scala встречает такие вызовы, он:

- Определяет, какой именно признак предполагается этим вызовом.
- Определяет имя сопровождающего класса, который предоставляет байт-код статического метода, определенный для типажа.
- Производит необходимую
invokestatic
.
Таким образом, мы видим, что мощная концепция трейтов реализована на уровне JVM таким образом, что это не приводит к значительным накладным расходам, и программисты Scala могут пользоваться этой функцией, не беспокоясь о том, что она будет слишком дорогой во время выполнения.
Одиночки
Scala обеспечивает явное определение одноэлементных классов с помощью ключевого слова object
. Рассмотрим следующий одноэлементный класс:
object Config { val home_dir = "/home/user" }
Компилятор создает два файла классов:
Config.class
довольно прост:
public final class Config public static java.lang.String home_dir(); Code: // execute the method Config$.home_dir() 0: getstatic #16 // Field Config$.MODULE$:LConfig$; 3: invokevirtual #18 // Method Config$.home_dir:()Ljava/lang/String; 6: areturn
Это всего лишь декоратор для синтетического класса Config$
, который реализует функциональность синглтона. Изучение этого класса с помощью javap -p -c
дает следующий байт-код:
public final class Config$ public static final Config$ MODULE$; // a public reference to the singleton object private final java.lang.String home_dir; // static initializer public static {}; Code: 0: new #2 // class Config$ 3: invokespecial #12 // Method "<init>":()V 6: return public java.lang.String home_dir(); Code: // get the value of field home_dir and return it 0: aload_0 1: getfield #17 // Field home_dir:Ljava/lang/String; 4: areturn private Config$(); Code: // initialize the object 0: aload_0 1: invokespecial #19 // Method java/lang/Object."<init>":()V // expose a public reference to this object in the synthetic variable MODULE$ 4: aload_0 5: putstatic #21 // Field MODULE$:LConfig$; // load the value "/home/user" and write it to the field home_dir 8: aload_0 9: ldc #23 // String /home/user 11: putfield #17 // Field home_dir:Ljava/lang/String; 14: return
Он состоит из следующего:
- Синтетическая переменная
MODULE$
, через которую другие объекты получают доступ к этому одноэлементному объекту. - Статический инициализатор
{}
(также известный как<clinit>
, инициализатор класса) и закрытый методConfig$
, используемый для инициализацииMODULE$
и установки его полей в значения по умолчанию. - Метод получения для статического поля
home_dir
. В данном случае это всего лишь один метод. Если у синглтона больше полей, у него будет больше геттеров, а также сеттеров для изменяемых полей.
Синглтон — это популярный и полезный шаблон проектирования. Язык Java не предоставляет прямого способа указать его на уровне языка; скорее, разработчик несет ответственность за его реализацию в исходном коде Java. Scala, с другой стороны, предоставляет четкий и удобный способ явного объявления синглтона с помощью ключевого слова object
. Как мы видим, заглянув под капот, реализовано это доступным и естественным образом.
Заключение
Теперь мы увидели, как Scala компилирует несколько неявных и функциональных возможностей программирования в сложные структуры байт-кода Java. Взглянув на внутреннюю работу Scala, мы сможем глубже оценить мощь Scala, что поможет нам максимально использовать возможности этого мощного языка.
Теперь у нас также есть инструменты для самостоятельного изучения языка. Есть много полезных функций синтаксиса Scala, которые не рассматриваются в этой статье, например классы case, каррирование и понимание списков. Я призываю вас самостоятельно исследовать реализацию этих структур в Scala, чтобы вы могли научиться быть ниндзя Scala следующего уровня!
Виртуальная машина Java: ускоренный курс
Как и компилятор Java, компилятор Scala преобразует исходный код в .class
, содержащие байт-код Java, который будет выполняться виртуальной машиной Java. Чтобы понять, как эти два языка различаются внутри, необходимо понять систему, на которую они оба нацелены. Здесь мы представляем краткий обзор некоторых основных элементов архитектуры виртуальной машины Java, структуры файлов классов и основ ассемблера.
Обратите внимание, что это руководство охватывает только минимум, позволяющий следовать приведенной выше статье. Хотя многие основные компоненты JVM здесь не обсуждаются, полную информацию можно найти в официальной документации здесь.
Декомпиляция файлов классов с помощью
javap
Постоянный пул
Таблицы полей и методов
Байт-код JVM
Вызовы методов и стек вызовов
Выполнение в стеке операндов
Локальные переменные
Return to Top
Декомпиляция файлов классов с помощью javap
Java поставляется с утилитой командной строки javap
, которая декомпилирует .class
в удобочитаемую форму. Поскольку файлы классов Scala и Java нацелены на одну и ту же JVM, javap
можно использовать для проверки файлов классов, скомпилированных Scala.
Скомпилируем следующий исходный код:
// RegularPolygon.scala class RegularPolygon( val numSides: Int ) { def getPerimeter( sideLength: Double ): Double = { println( "Calculating perimeter..." ) return sideLength * this.numSides } }
Компиляция этого с scalac RegularPolygon.scala
создаст RegularPolygon.class
. Если мы затем запустим javap RegularPolygon.class
, мы увидим следующее:
$ javap RegularPolygon.class Compiled from "RegularPolygon.scala" public class RegularPolygon { public int numSides(); public double getPerimeter(double); public RegularPolygon(int); }
Это очень простая разбивка файла класса, которая просто показывает имена и типы открытых членов класса. Добавление параметра -p
будет включать частные члены:
$ javap -p RegularPolygon.class Compiled from "RegularPolygon.scala" public class RegularPolygon { private final int numSides; public int numSides(); public double getPerimeter(double); public RegularPolygon(int); }
Это еще не очень много информации. Чтобы увидеть, как методы реализованы в байт-коде Java, добавим параметр -c
:
$ javap -p -c RegularPolygon.class Compiled from "RegularPolygon.scala" public class RegularPolygon { private final int numSides; public int numSides(); Code: 0: aload_0 1: getfield #13 // Field numSides:I 4: ireturn public double getPerimeter(double); Code: 0: getstatic #23 // Field scala/Predef$.MODULE$:Lscala/Predef$; 3: ldc #25 // String Calculating perimeter... 5: invokevirtual #29 // Method scala/Predef$.println:(Ljava/lang/Object;)V 8: dload_1 9: aload_0 10: invokevirtual #31 // Method numSides:()I 13: i2d 14: dmul 15: dreturn public RegularPolygon(int); Code: 0: aload_0 1: iload_1 2: putfield #13 // Field numSides:I 5: aload_0 6: invokespecial #38 // Method java/lang/Object."<init>":()V 9: return }
Это немного интереснее. Однако, чтобы получить полную картину, мы должны использовать опцию -v
или -verbose
, как в javap -p -v RegularPolygon.class
:
Здесь мы, наконец, видим, что на самом деле находится в файле класса. Что все это значит? Давайте взглянем на некоторые из наиболее важных частей.
Постоянный пул
Цикл разработки приложений на C++ включает этапы компиляции и компоновки. Цикл разработки для Java пропускает стадию явной компоновки, поскольку компоновка происходит во время выполнения. Файл класса должен поддерживать эту связь во время выполнения. Это означает, что когда исходный код ссылается на какое-либо поле или метод, результирующий байт-код должен хранить соответствующие ссылки в символической форме, готовые к разыменованию после загрузки приложения в память, а фактические адреса могут быть разрешены компоновщиком времени выполнения. Эта символическая форма должна содержать:
- имя класса
- имя поля или метода
- информация о типе
Спецификация формата файла класса включает раздел файла, называемый пулом констант , таблицей всех ссылок, необходимых компоновщику. Он содержит записи разных типов.
// ... Constant pool: #1 = Utf8 RegularPolygon #2 = Class #1 // RegularPolygon #3 = Utf8 java/lang/Object #4 = Class #3 // java/lang/Object // ...
Первый байт каждой записи представляет собой числовой тег, указывающий тип записи. Остальные байты предоставляют информацию о значении записи. Количество байтов и правила их интерпретации зависят от типа, указанного первым байтом.
Например, класс Java, который использует постоянное целое число 365
, может иметь запись пула констант со следующим байт-кодом:
x03 00 00 01 6D
Первый байт x03
идентифицирует тип записи CONSTANT_Integer
. Это информирует компоновщика о том, что следующие четыре байта содержат значение целого числа. (Обратите внимание, что 365 в шестнадцатеричном формате равно x16D
). Если это 14-я запись в пуле констант, javap -v
отобразит ее следующим образом:
#14 = Integer 365
Многие константные типы состоят из ссылок на более «примитивные» константные типы в другом месте пула констант. Например, код нашего примера содержит оператор:
println( "Calculating perimeter..." )
Использование строковой константы создаст две записи в пуле констант: одну запись с типом CONSTANT_String
и другую запись с типом CONSTANT_Utf8
. Запись типа Constant_UTF8
содержит фактическое представление строкового значения в кодировке UTF8. Запись типа CONSTANT_String
содержит ссылку на запись CONSTANT_Utf8
:
#24 = Utf8 Calculating perimeter... #25 = String #24 // Calculating perimeter...
Такое усложнение необходимо, поскольку существуют другие типы записей пула констант, которые ссылаются на записи типа Utf8
и не являются записями типа String
. Например, любая ссылка на атрибут класса создаст тип CONSTANT_Fieldref
, который содержит ряд ссылок на имя класса, имя атрибута и тип атрибута:
#1 = Utf8 RegularPolygon #2 = Class #1 // RegularPolygon #9 = Utf8 numSides #10 = Utf8 I #12 = NameAndType #9:#10 // numSides:I #13 = Fieldref #2.#12 // RegularPolygon.numSides:I
Дополнительные сведения о пуле констант см. в документации по JVM.
Таблицы полей и методов
Файл класса содержит таблицу полей , содержащую информацию о каждом поле (т. е. атрибуте), определенном в классе. Это ссылки на записи пула констант, которые описывают имя и тип поля, а также флаги управления доступом и другие соответствующие данные.
Аналогичная таблица методов присутствует в файле класса. Однако в дополнение к информации об имени и типе для каждого неабстрактного метода он содержит фактические инструкции байт-кода, которые должны выполняться JVM, а также структуры данных, используемые фреймом стека метода, описанные ниже.
Байт-код JVM
JVM использует собственный внутренний набор инструкций для выполнения скомпилированного кода. Запуск javap
с параметром -c
включает реализации скомпилированных методов в выходных данных. Если мы проверим наш файл RegularPolygon.class
таким образом, мы увидим следующий вывод для нашего getPerimeter()
:
public double getPerimeter(double); Code: 0: getstatic #23 // Field scala/Predef$.MODULE$:Lscala/Predef$; 3: ldc #25 // String Calculating perimeter... 5: invokevirtual #29 // Method scala/Predef$.println:(Ljava/lang/Object;)V 8: dload_1 9: aload_0 10: invokevirtual #31 // Method numSides:()I 13: i2d 14: dmul 15: dreturn
Фактический байт-код может выглядеть примерно так:
xB2 00 17 x12 19 xB6 00 1D x27 ...
Каждая инструкция начинается с однобайтового кода операции , идентифицирующего инструкцию JVM, за которым следует ноль или более операндов инструкции, над которыми нужно работать, в зависимости от формата конкретной инструкции. Обычно это либо постоянные значения, либо ссылки в постоянный пул. javap
услужливо переводит байт-код в удобочитаемую форму, отображая:
- Смещение или положение первого байта инструкции в коде.
- Удобочитаемое имя или мнемоника инструкции.
- Значение операнда, если оно есть.
Операнды, отображаемые со знаком решетки, например #23
, являются ссылками на записи в пуле констант. Как мы видим, javap
также создает полезные комментарии в выводе, определяя, на что именно ссылаются из пула.
Мы обсудим несколько общих инструкций ниже. Подробную информацию о полном наборе инструкций JVM см. в документации.
Вызовы методов и стек вызовов
Каждый вызов метода должен иметь возможность запускаться в своем собственном контексте, который включает в себя такие вещи, как локально объявленные переменные или аргументы, которые были переданы методу. Вместе они составляют кадр стека . При вызове метода создается новый фрейм, который помещается поверх стека вызовов . Когда метод возвращает значение, текущий кадр удаляется из стека вызовов и отбрасывается, а кадр, действовавший до вызова метода, восстанавливается.
Фрейм стека включает в себя несколько различных структур. Двумя важными из них являются стек операндов и таблица локальных переменных , которые обсуждаются далее.
Выполнение в стеке операндов
Многие инструкции JVM работают со стеком операндов своего фрейма. Вместо того, чтобы указывать постоянный операнд явно в байт-коде, эти инструкции вместо этого принимают значения на вершине стека операндов в качестве входных данных. Обычно эти значения удаляются из стека в процессе. Некоторые инструкции также помещают новые значения в верхнюю часть стека. Таким образом, инструкции JVM можно комбинировать для выполнения сложных операций. Например, выражение:
sideLength * this.numSides
компилируется следующим образом в нашем getPerimeter()
:
8: dload_1 9: aload_0 10: invokevirtual #31 // Method numSides:()I 13: i2d 14: dmul
- Первая инструкция,
dload_1
, помещает ссылку на объект из слота 1 таблицы локальных переменных (обсуждается далее) в стек операндов. В данном случае это аргумент методаsideLength
. Следующая инструкция,aload_0
, помещает ссылку на объект в слот 0 таблицы локальных переменных в стек операндов. На практике это почти всегда ссылка наthis
, текущий класс. - Это устанавливает стек для следующего вызова,
invokevirtual #31
, который выполняет метод экземпляраnumSides()
.invokevirtual
извлекает верхний операнд (ссылку наthis
) из стека, чтобы определить, из какого класса он должен вызывать метод. Как только метод возвращается, его результат помещается в стек. - В этом случае возвращаемое значение (
numSides
) имеет целочисленный формат. Его необходимо преобразовать в двойной формат с плавающей запятой, чтобы умножить его на другое двойное значение. Инструкцияi2d
извлекает целочисленное значение из стека, преобразует его в формат с плавающей запятой и помещает обратно в стек. - В этот момент стек содержит результат
this.numSides
с плавающей запятой сверху, за которым следует значение аргументаsideLength
, которое было передано методу.dmul
извлекает эти два верхних значения из стека, выполняет над ними умножение с плавающей запятой и помещает результат в стек.
При вызове метода создается новый стек операндов как часть его стекового фрейма, в котором будут выполняться операции. We must be careful with terminology here: the word “stack” may refer to the call stack , the stack of frames providing context for method execution, or to a particular frame's operand stack , upon which JVM instructions operate.
Local Variables
Each stack frame keeps a table of local variables . This typically includes a reference to this
object, any arguments that were passed when the method was called, and any local variables declared within the method body. Running javap
with the -v
option will include information about how each method's stack frame should be set up, including its local variable table:
public double getPerimeter(double); // ... Code: 0: getstatic #23 // Field scala/Predef$.MODULE$:Lscala/Predef$; 3: ldc #25 // String Calculating perimeter... // ... LocalVariableTable: Start Length Slot Name Signature 0 16 0 this LRegularPolygon; 0 16 1 sideLength D
In this example, there are two local variables. The variable in slot 0 is named this
, with the type RegularPolygon
. This is the reference to the method's own class. The variable in slot 1 is named sideLength
, with the type D
(indicating a double). This is the argument that is passed to our getPerimeter()
method.
Instructions such as iload_1
, fstore_2
, or aload [n]
, transfer different types of local variables between the operand stack and the local variable table. Since the first item in the table is usually the reference to this
, the instruction aload_0
is commonly seen in any method that operates on its own class.
This concludes our walkthrough of JVM basics.