在JVM厨房中烹饪字节码

我叫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) {


    /**
     *   
     *  @param ingredient -  
     */
    fun add(ingredient: Any) {
        ingredientsCount = ingredientsCount.inc()
        //- 
    }

    /**
     *   
     *  @param duration -   
     */
    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

  // access flags 0x11
  public final warmUp(I)V
    // annotable parameter count: 1 (visible)
    // annotable parameter count: 1 (invisible)
   L0
    LINENUMBER 19 L0
    ICONST_1
    ISTORE 2
...

乍一看-一套难以理解的说明。要了解它们的工作方式和功能,您需要深入JVM的内部厨房。

JVM厨房


让我们看一下JVM运行时内存:



可以说JVM是我们的厨房。接下来,考虑其余的参与者:

方法区域-食谱



方法区域存储每个函数的编译代码。通常,当线程开始执行功能时,它会从该区域接收指令。实际上,这是一本烹饪食谱书,详细介绍了如何烹饪所有东西,从炒鸡蛋到加泰罗尼亚菜。

线程1..N-库克团队



流严格遵循它们规定的指令(方法区域),为此,它们具有PC寄存器和JVM堆栈。您可以将每个流与执行分配给他的任务的厨师进行比较,严格按照食谱中的食谱进行操作。

PC寄存器-现场说明



程序计数器寄存器-我们的流的命令计数器。它存储正在执行的指令的地址。在厨房里,这些是我们现在食谱哪一页上的一些注释。

JVM堆栈


堆栈的框架。为每个函数分配一个框架,当前线程在其中使用变量和操作数。作为准备腌制类比的一部分,这可能是一组嵌套操作:

-> -> -> ...

框架-桌面



框架充当厨师的桌面,上面放着砧板和签名的容器。

局部变量-带符号的容器



这是一组局部变量(局部变量表),顾名思义,该数组存储局部变量的值,类型和范围。这类似于签名的容器,您可以在其中添加专业活动的中间结果。

操作数堆栈-切菜板



操作数堆栈存储JVM指令的参数。例如,加法运算的整数值,对堆对象的引用等

。我可以给出的最接近的示例是一个切菜板,在该切菜板上番茄和黄瓜在某一点上变成了沙拉。与局部变量不同,我们仅将执行下一条指令的内容放在板上。

堆-分配表



作为使用框架的一部分,我们对对象的链接进行操作;对象本身存储在堆中。一个重要的区别是该框架仅属于一个线程,而局部变量在该框架处于活动状态(执行该功能)时“处于活动状态”。并且堆可被其他流访问,并且一直存在直到垃圾收集器打开为止。类似于厨房,我们可以举一个带有分配表的示例,这很常见。然后由一个单独的清洁员团队对其进行清洁。

JVM厨房。从内部看。使用框架


让我们从函数开始warmUp

    /**
     *   
     *  @param duration -   
     */
    fun warmUp(duration: Int) {
        for (x in 1..duration)
            println("Warming...")
    }

反汇编字节码功能:

  public final warmUp(I)V
    // annotable parameter count: 1 (visible)
    // annotable parameter count: 1 (invisible)
   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堆栈流中创建一个框架。让我提醒您,该堆栈由局部变量数组和操作数堆栈组成。

  1. 为了使我们能够了解为该帧分配多少内存,编译器提供了有关此功能的元信息(代码注释中的解释):

        MAXSTACK = 2 //    2*32bit
        MAXLOCALS = 6 //    6*32bit
    
  2. 我们还提供有关局部变量数组的某些元素的信息:

        LOCALVARIABLE x I L2 L7 2 //  x  Int(I),      L2-L7   2
        LOCALVARIABLE this Lcom/company/Solenya; L0 L8 0
        LOCALVARIABLE duration I L0 L8 1
    
  3. 初始化框架时,函数的参数属于局部变量。在此示例中,持续时间值将被写入索引为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分钟”的任务。此外,我们专业领域的专家:

  1. 打开一个食谱(方法区域);
  2. 查找有关如何烧开水的说明(warmUp());
  3. 准备工作场所,分配热板(操作数堆栈)和容器(局部变量)以临时存储产品。

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(丢失更新问题)。

这种情况类似于一群厨师在碗中添加香料的平行工作。想像:

  1. 碟子(堆)放在上面有一个分配表。
  2. 厨房里有两名厨师(线程* 2)。
  3. 每个厨师都有自己的切割台,在这里准备各种香料(JVM Stack * 2)。
  4. 任务:在盘子里加两份香料。
  5. 在分配表上放着一张纸,他们可以阅读并在上面写上添加了哪个部分(ingredientsCount)。并且为了节省香料:
    • 在开始准备调料之前,厨师必须在纸上读到添加的调料数量还不够。
    • 在添加香料后,厨师可以写下将多少香料添加到菜中。

在这种情况下,可能会出现以下情况:

  1. 厨师#1读到添加了3份香料。
  2. 库克#2读到添加了3份香料。
  3. 他们俩都到办公桌前,准备各种香料。
  4. 两位厨师都在菜中加入香料(3 + 2)。
  5. 1号厨师写道,已添加4份香料。
  6. 第2位厨师写道,已添加4份香料。

最重要的是:产品丢失,菜品变得辛辣,等等。

为了避免这种情况,有各种工具,例如锁,线程安全功能等。

总结一下


除非这是特定于他的工作的,否则开发人员很少需要爬入字节码。同时,了解字节码的工作有助于更好地理解特定语言的多线程和好处,也有助于专业发展。

值得注意的是,它们与JVM的所有部分都相去甚远。还有更多有趣的“事物”,例如,常量池,字节码验证程序,JIT,代码缓存等。但是为了不使本文过多,我仅关注那些对共识有必要的要素。

有用的链接:


All Articles