我叫Alexander Kotsyuruba,我领导DomKlik内部服务的开发。许多有经验的Java开发人员开始了解JVM的内部结构。为了促进Java Samurai的这一旅程,我决定布置Java虚拟机(JVM)的基础知识,并以一种简单的语言使用字节码。什么是神秘的字节码,它住在哪里?我将尝试以酸洗为例来回答这个问题。为什么需要JVM和字节码?
JVM的源头是Sun Microsystems的“随时随地写入一次”(WORA)。不同的是一次编写,到处编译(WOCA)的概念,WORA意味着虚拟机为每个操作系统执行once-编译代码(字节码)。在任何地方运行一次写入(WORA)随时随地编写一次编译(WOCA)JVM和字节码是WORA概念的基础,使我们免于细微差别和针对每个OS进行编译的需要。字节码
要了解什么是字节码,我们来看一个示例。当然,此代码没有任何用处,只会用于进一步分析。资源:class Solenya(val jarForPickles: Any? = Any(), var ingredientsCount: Int = 0) {
fun add(ingredient: Any) {
ingredientsCount = ingredientsCount.inc()
}
fun warmUp(duration: Int) {
for (x in 1..duration)
println("Warming")
}
init {
val jarForPickles = takeJarForPickles()
val pickles = Any()
val water = Any()
add(pickles)
add(water)
warmUp(10)
}
private fun takeJarForPickles(): Any = openLocker()
private fun openLocker(): Any = takeKeyForLocker()
private fun takeKeyForLocker(): Any = {}
}
使用内置的Intellij IDEA工具(工具-> Kotlin- >显示Kotlin字节码),我们获得了反汇编的字节码(示例中仅显示了一部分):...
INVOKEVIRTUAL java/io/PrintStream.println (Ljava/lang/Object;)V
L5
L6
LINENUMBER 12 L6
RETURN
L7
LOCALVARIABLE this Lcom/company/Solenya; L0 L7 0
LOCALVARIABLE ingredient Ljava/lang/Object; L0 L7 1
LOCALVARIABLE $i$f$add I L1 L7 2
MAXSTACK = 2
MAXLOCALS = 5
public final warmUp(I)V
L0
LINENUMBER 19 L0
ICONST_1
ISTORE 2
...
乍一看-一套难以理解的说明。要了解它们的工作方式和功能,您需要深入JVM的内部厨房。JVM厨房
让我们看一下JVM运行时内存:可以说JVM是我们的厨房。接下来,考虑其余的参与者:方法区域-食谱
方法区域存储每个函数的编译代码。通常,当线程开始执行功能时,它会从该区域接收指令。实际上,这是一本烹饪食谱书,详细介绍了如何烹饪所有东西,从炒鸡蛋到加泰罗尼亚菜。线程1..N-库克团队
流严格遵循它们规定的指令(方法区域),为此,它们具有PC寄存器和JVM堆栈。您可以将每个流与执行分配给他的任务的厨师进行比较,严格按照食谱中的食谱进行操作。PC寄存器-现场说明
程序计数器寄存器-我们的流的命令计数器。它存储正在执行的指令的地址。在厨房里,这些是我们现在食谱哪一页上的一些注释。JVM堆栈
堆栈的框架。为每个函数分配一个框架,当前线程在其中使用变量和操作数。作为准备腌制类比的一部分,这可能是一组嵌套操作:-> -> -> ...
框架-桌面
框架充当厨师的桌面,上面放着砧板和签名的容器。局部变量-带符号的容器
这是一组局部变量(局部变量表),顾名思义,该数组存储局部变量的值,类型和范围。这类似于签名的容器,您可以在其中添加专业活动的中间结果。操作数堆栈-切菜板
操作数堆栈存储JVM指令的参数。例如,加法运算的整数值,对堆对象的引用等。我可以给出的最接近的示例是一个切菜板,在该切菜板上番茄和黄瓜在某一点上变成了沙拉。与局部变量不同,我们仅将执行下一条指令的内容放在板上。堆-分配表
作为使用框架的一部分,我们对对象的链接进行操作;对象本身存储在堆中。一个重要的区别是该框架仅属于一个线程,而局部变量在该框架处于活动状态(执行该功能)时“处于活动状态”。并且堆可被其他流访问,并且一直存在直到垃圾收集器打开为止。类似于厨房,我们可以举一个带有分配表的示例,这很常见。然后由一个单独的清洁员团队对其进行清洁。JVM厨房。从内部看。使用框架
让我们从函数开始warmUp
:
fun warmUp(duration: Int) {
for (x in 1..duration)
println("Warming...")
}
反汇编字节码功能: public final warmUp(I)V
L0
LINENUMBER 19 L0
ICONST_1
ISTORE 2
ILOAD 1
ISTORE 3
ILOAD 2
ILOAD 3
IF_ICMPGT L1
L2
LINENUMBER 20 L2
LDC "Warming..."
ASTORE 4
L3
ICONST_0
ISTORE 5
L4
GETSTATIC java/lang/System.out : Ljava/io/PrintStream;
ALOAD 4
INVOKEVIRTUAL java/io/PrintStream.println (Ljava/lang/Object;)V
L5
L6
LINENUMBER 19 L6
ILOAD 2
ILOAD 3
IF_ICMPEQ L1
IINC 2 1
L7
GOTO L2
L1
LINENUMBER 21 L1
RETURN
L8
LOCALVARIABLE x I L2 L7 2
LOCALVARIABLE this Lcom/company/Solenya; L0 L8 0
LOCALVARIABLE duration I L0 L8 1
MAXSTACK = 2
MAXLOCALS = 6
框架初始化-工作场所准备
要执行此功能,将在JVM堆栈流中创建一个框架。让我提醒您,该堆栈由局部变量数组和操作数堆栈组成。- 为了使我们能够了解为该帧分配多少内存,编译器提供了有关此功能的元信息(代码注释中的解释):
MAXSTACK = 2
MAXLOCALS = 6
- 我们还提供有关局部变量数组的某些元素的信息:
LOCALVARIABLE x I L2 L7 2
LOCALVARIABLE this Lcom/company/Solenya; L0 L8 0
LOCALVARIABLE duration I L0 L8 1
- 初始化框架时,函数的参数属于局部变量。在此示例中,持续时间值将被写入索引为1的数组。
因此,最初的框架将如下所示:开始执行指令
要了解框架是如何工作的,只需准备一系列JVM指令(Java字节码指令清单)并逐步浏览标签L0
: L0
LINENUMBER 19 L0
ICONST_1
ISTORE 2
ILOAD 1
ISTORE 3
ILOAD 2
ILOAD 3
IF_ICMPGT L1
ICONST_1-1
在操作数堆栈中添加(Int):
ISTORE 2-拉取操作数堆栈的值(Int类型)并写入具有索引2的局部变量:
这两个操作可以用Java代码解释:int x = 1
。ILOAD 1-在操作数堆栈中加载索引为1的局部变量的值:
ISTORE 3-操作数堆栈的拉值(Int类型)并以3的索引写入局部变量:
这两个操作可以用Java代码解释:int var3 = duration
。ILOAD 2-从操作数堆栈中索引为2的局部变量中加载值。ILOAD 3-从操作数堆栈中索引为3的局部变量中加载值:
IF_ICMPGT L1-用于比较堆栈中两个整数值的指令。如果“下部”值大于“上部”,则转到标签L1
。执行该指令后,堆栈将为空。这些Java字节码行如下所示: int x = 1;
int var3 = duration;
if (x > var3) {
....L1...
我们使用Intellij IDEA沿着Kotlin- > Java路径对代码进行反编译: public final void warmUp(int duration) {
int x = 1;
int var3 = duration;
if (x <= duration) {
while(true) {
String var4 = "Warming";
boolean var5 = false;
System.out.println(var4);
if (x == var3) {
break;
}
++x;
}
}
}
在这里您可以看到未使用的变量(var5
)和没有函数调用println()
。不用担心,这是由于编译内联函数(println()
)和lambda表达式的细节所致。这些指令的执行几乎没有任何开销,此外,借助JIT,可以删除无效代码。这是一个有趣的话题,应该专门撰写一篇单独的文章。类似于厨房,此功能可描述为厨师“煮沸10分钟”的任务。此外,我们专业领域的专家:- 打开一个食谱(方法区域);
- 查找有关如何烧开水的说明(
warmUp()
); - 准备工作场所,分配热板(操作数堆栈)和容器(局部变量)以临时存储产品。
JVM厨房。从内部看。使用堆
考虑代码:val pickles = Any()
反汇编的字节码: NEW java/lang/Object
DUP
INVOKESPECIAL java/lang/Object.<init> ()V
ASTORE 3
新的java / lang / Object-Object
来自堆的类对象的内存分配。对象本身不会放在堆栈中,而是堆中指向它的链接:DUP-复制堆栈的“ top”元素。需要一个链接来初始化对象,第二个链接将其保存在局部变量中:INVOKESPECIAL java / lang / Object。<init>()V-Object
通过堆栈中的链接初始化对应类()的对象:ASTORE 3是最后一步,将对对象的引用保存在带有索引3的局部变量中。与厨房类似,我将比较在共享表(堆)上创建类和烹饪对象的过程。为此,您需要在分配表上为自己分配足够的空间,返回工作场所并在适当的容器(本地变量)中添加带有地址(引用)的注释。并且只有在那之后才开始创建类的对象。JVM厨房。从内部看。多线程
现在考虑以下示例: fun add(ingredient: Any) {
ingredientsCount = ingredientsCount.inc()
}
这是线程问题的经典示例。我们有成分计数ingredientsCount
。函数add
除了添加成分外,还执行增量ingredientsCount
。反汇编的字节码如下所示: ALOAD 0
ALOAD 0
GETFIELD com/company/Solenya.ingredientsCount : I
ICONST_1
IADD
PUTFIELD com/company/Solenya.ingredientsCount : I
执行指令时操作数堆栈的状态:在一个线程中工作时,所有内容都会正确执行。如果有多个线程,则可能会出现以下问题。想象一下,两个线程同时获取了字段值ingredientsCount
并将其写入堆栈。然后,操作数堆栈和字段的状态ingredientsCount
可能如下所示:该函数执行了两次(每个线程执行一次),该值ingredientsCount
应等于2。但是实际上,其中一个线程使用了过时的值ingredientsCount
,因此实际结果为1(丢失更新问题)。这种情况类似于一群厨师在碗中添加香料的平行工作。想像:- 碟子(堆)放在上面有一个分配表。
- 厨房里有两名厨师(线程* 2)。
- 每个厨师都有自己的切割台,在这里准备各种香料(JVM Stack * 2)。
- 任务:在盘子里加两份香料。
- 在分配表上放着一张纸,他们可以阅读并在上面写上添加了哪个部分(
ingredientsCount
)。并且为了节省香料:
- 在开始准备调料之前,厨师必须在纸上读到添加的调料数量还不够。
- 在添加香料后,厨师可以写下将多少香料添加到菜中。
在这种情况下,可能会出现以下情况:- 厨师#1读到添加了3份香料。
- 库克#2读到添加了3份香料。
- 他们俩都到办公桌前,准备各种香料。
- 两位厨师都在菜中加入香料(3 + 2)。
- 1号厨师写道,已添加4份香料。
- 第2位厨师写道,已添加4份香料。
最重要的是:产品丢失,菜品变得辛辣,等等。为了避免这种情况,有各种工具,例如锁,线程安全功能等。总结一下
除非这是特定于他的工作的,否则开发人员很少需要爬入字节码。同时,了解字节码的工作有助于更好地理解特定语言的多线程和好处,也有助于专业发展。值得注意的是,它们与JVM的所有部分都相去甚远。还有更多有趣的“事物”,例如,常量池,字节码验证程序,JIT,代码缓存等。但是为了不使本文过多,我仅关注那些对共识有必要的要素。有用的链接: