耗时一周深入理解JVM虚拟机异常处理字节码性能优化,全网最全面的JVM原理通俗易懂(强烈建议收藏)

Posted 益达学长

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了耗时一周深入理解JVM虚拟机异常处理字节码性能优化,全网最全面的JVM原理通俗易懂(强烈建议收藏)相关的知识,希望对你有一定的参考价值。

精益、平均的JVM虚拟机

博主也在文末准备了一些JVM的资料,需要的看官可以自行白嫖

Java虚拟机的基本结构和功能介绍

在本专栏中,我想探讨有关 Java 内部工作原理的主题。每个月我都会专注于一个领域并试图揭开它的神秘面纱。我的目标是帮助程序员了解编译和运行 Java 程序时实际发生的情况。在这一部分中,我将介绍 Java 虚拟机的基本结构和功能。

什么是 Java 虚拟机?为什么会在这里?

Java 虚拟机或 JVM 是运行已编译 Java 程序的抽象计算机。JVM 是“虚拟的”,因为它通常是在“真实”硬件平台和操作系统之上的软件中实现的。所有 Java 程序都是为 JVM 编译的。因此,JVM 必须先在特定平台上实现,然后编译的 Java 程序才能在该平台上运行。


JVM 在使 Java 可移植方面发挥着核心作用。它在已编译的 Java 程序与底层硬件平台和操作系统之间提供了一个抽象层。JVM 是 Java 可移植性的核心,因为编译后的 Java 程序在 JVM 上运行,独立于特定 JVM 实现下的任何内容。

是什么让 JVM 精益求精?JVM 是精简的,因为它在软件中实现时很小。它被设计得很小,所以它可以放在尽可能多的地方——比如电视机、手机和个人电脑。JVM 之所以意味深长,是因为它的野心。“无处不在!” 是它的战斗口号。它想要无处不在,它的成功体现在用 Java 编写的程序在任何地方都能运行的程度。

Java字节码

Java 程序被编译成一种称为 Java 字节码的形式。JVM 执行 Java 字节码,因此 Java 字节码可以被认为是 JVM 的机器语言。Java 编译器读取 Java 语言源 (.java) 文件,将源转换为 Java 字节码,并将字节码放入类 (.class) 文件中。编译器为源中的每个类生成一个类文件。

对于 JVM 而言,字节码流是一个指令序列。每条指令由一个字节的操作码和零个或多个操作数组成。操作码告诉 JVM 采取什么行动。如果 JVM 需要更多信息来执行操作而不仅仅是操作码,则所需的信息会作为操作数紧跟在操作码之后。

每个字节码指令都定义了一个助记符。助记符可以被认为是 JVM 的汇编语言。例如,有一条指令会导致 JVM 将一个零压入堆栈。该指令的助记符是iconst_0,其字节码值为60十六进制。该指令不接受操作数。另一条指令使程序执行在内存中无条件地向前或向后跳转。该指令需要一个操作数,即距当前内存位置的 16 位有符号偏移量。通过将偏移量添加到当前内存位置,JVM 可以确定要跳转到的内存位置。该指令的助记符是goto,其字节码值为a7十六进制。

虚拟零件


Java 虚拟机的“虚拟硬件”可以分为四个基本部分:寄存器、堆栈、垃圾收集堆和方法区。这些部分是抽象的,就像它们组成的机器一样,但它们必须以某种形式存在于每个 JVM 实现中。

JVM 中地址的大小为 32 位。因此,JVM 最多可以寻址 4 GB(2 的 32 次方)内存,每个内存位置包含一个字节。JVM 中的每个寄存器存储一个 32 位地址。堆栈、垃圾收集堆和方法区位于 4 GB 可寻址内存中的某处。这些内存区域的确切位置由每个特定 JVM 的实现者决定。

Java 虚拟机中的一个字是 32 位。JVM 有少量的原始数据类型:byte(8 位)、short(16 位)、int(32 位)、long(64 位)、float(32 位)、double(64 位)和 char( 16 位)。除了 char 是无符号的 Unicode 字符外,所有数字类型都是有符号的。这些类型可以方便地映射到 Java 程序员可用的类型。另一种原始类型是对象句柄,它是一个 32 位地址,指向堆上的一个对象。

方法区,因为它包含字节码,所以在字节边界上对齐。堆栈和垃圾收集堆在字(32 位)边界上对齐。

骄傲的,少数的,登记册

JVM 有一个程序计数器和三个管理堆栈的寄存器。它的寄存器很少,因为 JVM 的字节码指令主要在堆栈上运行。这种面向堆栈的设计有助于保持 JVM 的指令集和实现较小。

JVM 使用程序计数器或 pc 寄存器来跟踪它应该在内存中执行指令的位置。其他三个寄存器——optop 寄存器、帧寄存器和 vars 寄存器——指向当前执行方法的堆栈帧的各个部分。正在执行的方法的堆栈帧保存该方法的特定调用的状态(局部变量、计算的中间结果等)。

方法区和程序计数器

方法区是字节码所在的地方。程序计数器总是指向(包含)方法区中某个字节的地址。程序计数器用于跟踪执行线程。执行完字节码指令后,程序计数器将包含要执行的下一条指令的地址。执行一条指令后,JVM 将程序计数器设置为紧跟在前一条指令之后的指令的地址,除非前一条指令特别要求跳转。

Java 堆栈和相关寄存器

Java 堆栈用于存储字节码指令的参数和结果,向方法传递参数和从方法返回值,以及保持每个方法调用的状态。方法调用的状态称为其堆栈帧。vars、frame 和 optop 寄存器指向当前堆栈帧的不同部分。

Java 堆栈帧包含三个部分:局部变量、执行环境和操作数堆栈。局部变量部分包含当前方法调用使用的所有局部变量。它由 vars 寄存器指向。执行环境部分用于维护堆栈本身的操作。它由帧寄存器指向。操作数栈被字节码指令用作工作空间。这里放置字节码指令的参数,找到字节码指令的结果。optop 寄存器指向操作数堆栈的顶部。

执行环境通常夹在局部变量和操作数堆栈之间。当前正在执行的方法的操作数堆栈始终是最顶部的堆栈部分,因此 optop 寄存器始终指向整个 Java 堆栈的顶部。

垃圾收集堆

堆是 Java 程序对象所在的地方。任何时候使用new运算符分配内存时,该内存都来自堆。Java 语言不允许您直接释放分配的内存。相反,运行时环境会跟踪对堆上每个对象的引用,并自动释放不再被引用的对象所占用的内存——这个过程称为垃圾回收。

永恒的数学:JVM 模拟

下面的小程序模拟了一个 JVM 执行一些字节码指令。模拟中的指令由 javac 编译器生成,给出以下 java 代码:

class Act { public static void doMathForever () { int i = 0 ; while ( true ) { 
            i += 1 ;*= 2 ; } } }  
       
         
            
        
    

模拟中的指令代表 doMathForever() 方法的主体。选择这些指令是因为它们是一个短字节码序列,可以在堆栈上做一些有趣的事情。该模拟为寄存器、堆栈和方法区加注星标。该字节码序列不涉及堆,因此它不显示为小程序用户界面的一部分。模拟中的所有数字均以十六进制显示。

当我们的故事开始时,程序计数器(pc 寄存器)指向一个iconst_0指令。该iconst_0指令是在方法区,其中字节码喜欢挂出。

当您按下 Step 按钮时,JVM 将执行程序计数器指向的单条指令。因此,当您第一次按下 Step 按钮时,将执行将零压入堆栈的iconst_0指令。这条指令执行后,程序计数器将指向下一条要执行的指令。随后按下Step按钮将执行后续指令,程序计数器将引导。按下重置按钮将使模拟从头开始。

每个寄存器的值以两种方式显示。每个寄存器的内容,一个 32 位地址,以十六进制显示在模拟的顶部。此外,我在堆栈或方法区中的地址旁边放置了一个指向每个寄存器中包含的地址的小指针。例如,程序计数器包含的地址在方法区中旁边有一个pc>。

Java 堆栈是基于单词的。每次将某些内容推送到 Java 堆栈时,它都会作为一个词继续(尽管 longs 和 doubles 实际上作为两个词继续)。在模拟中,Java 堆栈显示为一个倒置的单词塔。当单词被推到面板上时,它会沿着面板向下(在内存地址中向上)显示。随着单词从面板中弹出,堆栈向后退回面板。在 JVM 的这个实现中,optop 寄存器总是指向 Java 堆栈上的下一个可用槽。

当前正在执行的方法的堆栈帧的所有三个部分——局部变量、执行环境和操作数堆栈——都显示在模拟中。但是,只有局部变量和操作数堆栈参与此模拟。执行环境不涉及这个特定的字节码序列,所以它显示为填充零。

Java 堆栈的局部变量部分被视为从 vars 寄存器指向的位置开始的字数组。处理局部变量的字节码通常包括一个数组索引,它是 vars 寄存器的偏移量。第 n 个局部变量的地址是 (vars + (n * 4))。您必须将 n 乘以 4,因为每个字的长度为 4 个字节。

doMathForever() 方法只有一个局部变量,即。因此,它位于数组位置零处,并由 vars 寄存器直接指向。例如,iinc 指令采用两个字节大小的操作数、一个局部变量索引和一个数量。在模拟中,“iinc 0 1”将局部变量数组位置零处的整数加一。该指令实现了“i += 1;” 来自 doMathForever() 的语句。

有足够的耐心和点击 Step 按钮,你可以获得算术溢出。当 JVM 遇到这种情况时,它只会截断,如本模拟所示。不会抛出任何异常。(实际上,我只是在浏览器中显示由“真实”JVM 执行的乘法运算的结果。)

Java 虚拟机如何处理异常

包含类和方法示例的详细研究

Java 开发人员一瞥在他们运行的 Java 程序下点击和呼呼的神秘机制。检查 Java 虚拟机处理异常抛出和捕获的方式,包括相关字节码,继续讨论 Java 虚拟机的字节码指令集。本文不讨论finally子句——这是下个月的主题。后续文章将讨论字节码家族的其他成员。

例外

异常允许您顺利处理程序运行时发生的意外情况。为了演示 Java 虚拟机处理异常的方式,考虑一个名为

NitPickyMath

它提供了对整数执行加法、减法、乘法、除法和余数的方法。

NitPickyMath

执行这些数学运算与 Java 的“+”、“-”、“*”、“/”和“%”运算符提供的正常运算相同,除了

NitPickyMath

在上溢、下溢和被零除的情况下抛出已检查的异常。Java 虚拟机将抛出一个

ArithmeticException

在被零除的整数上,但不会在上溢和下溢时抛出任何异常。方法抛出的异常

NitPickyMath

定义如下:

class OverflowException extends Exception { } class UnderflowException extends Exception { } class DivideByZeroException extends Exception { }    

    

    

一个捕获和抛出异常的简单方法是remainderclass的方法NitPickyMath

静态整数余数(整数分红,整数除数)抛出DivideByZeroException { try {返回红利% divisor ; } catch ( ArithmeticException e ) { throw new DivideByZeroException (); } }  
      
     
        
    
      
          
    

该remainder方法只是对作为参数传递的两个整数执行余数运算。如果余数运算ArithmeticException的除数为零,则余数运算会抛出。此方法捕获 thisArithmeticException并抛出一个DivideByZeroException.

aDivideByZero和ArithmeticException异常之间的区别在于DivideByZeroException是已检查的异常 和ArithmeticException是未检查的。因为ArithmeticException是未经检查的,所以即使方法可能会抛出异常,也不需要在 throws 子句中声明此异常。任何属于Error或未RuntimeException检查的子类的异常。(ArithmeticException是 的子类RuntimeException。) 通过捕获ArithmeticException然后抛出DivideByZeroException,该remainder方法强制其客户端处理被零除异常的可能性,通过捕获它或DivideByZeroException在他们自己的 throws 子句中声明。这是因为检查异常,例如DivideByZeroException, 在方法中抛出必须被方法捕获或在方法的 throws 子句中声明。ArithmeticException不需要在 throws 子句中捕获或声明未经检查的异常,例如。

javac为该remainder方法生成以下字节码序列:

主要字节码序列为剩余: 0 iload_0                //推局部变量0(ARG作为除数通过)1 iload_1                //推局部变量1(ARG作为被除数通过)2 IREM                   //流行除数,流行被除数,推剩余3 ireturn                / /堆栈(剩余部分)的顶部返回INT的字节码序列对所述锁扣(ArithmeticException )子句: 4弹出                   //弹出参考ArithmeticException
   
   
   
   
 
   
                           // 因为它没有被这个 catch 子句使用。5 new #5 <Class DivideByZeroException> // 创建并推送对类的新对象的引用// DivideByZeroException。DivideByZeroException 8 dup            // 复制对堆栈顶部新对象的引用// 因为它// 必须同时初始化// 并抛出。初始化将消耗// 由 dup 创建的引用副本。9 invokenonvirtual #9 <Method DivideByZeroException.<init>()V> // 调用 DivideByZeroException 的构造函数// 以初始化它。此指令// 将弹出对对象的顶部引用。
     
                           
                           

   
                           
                           
                           
                           
   
                           
                           
                           
  12 athrow                 // 弹出对 Throwable 对象的引用,在本例中// 为 DivideByZeroException,// 并抛出异常。
                           
                           

该remainder方法的字节码序列有两个独立的部分。第一部分是方法的正常执行路径。这部分从 pc 偏移量零到三。第二部分是 catch 子句,它从 pc 偏移量 4 到 12。

主字节码序列中的irem指令可能会抛出ArithmeticException. 如果发生这种情况,Java 虚拟机就知道通过在表中查找和查找异常来跳转到实现 catch 子句的字节码序列。每个捕获异常的方法都与一个异常表相关联,该异常表连同该方法的字节码序列一起在类文件中传递。对于每个 try 块捕获的每个异常,异常表都有一个条目。每个条目有四部分信息:起点和终点、要跳转到的字节码序列中的 pc 偏移量以及正在捕获的异常类的常量池索引。remainder类的方法的异常表NitPickyMath如下所示:

异常表:从   到目标类型
     0 4 4 < Class java . 朗。算术异常>
                 

上面的异常表表明从 pc 偏移量 0 到 3(含)ArithmeticException被捕获。try 块的端点值,列在标签“to”下的表中,总是比捕获异常的最后一个 pc 偏移量多一个。在这种情况下,端点值被列为 4,但捕获异常的最后一个 pc 偏移量是 3。这个范围(包括 0 到 3)对应于实现 的 try 块内的代码的字节码序列remainder。表中列出的目标是在 pc 偏移ArithmeticException量 0 和 3 之间抛出时要跳转到的 pc 偏移量,包括 0 和 3。

如果在方法执行过程中抛出异常,Java 虚拟机会在异常表中搜索匹配的条目。如果当前程序计数器在条目指定的范围内,并且抛出的异常类是条目指定的异常类(或者是指定异常类的子类),则异常表条目匹配。Java 虚拟机按照条目在表中出现的顺序搜索异常表。当找到第一个匹配项时,Java 虚拟机将程序计数器设置为新的 pc 偏移位置并在那里继续执行。如果未找到匹配项,Java 虚拟机将弹出当前堆栈帧并重新抛出相同的异常。当Java虚拟机弹出当前栈帧时,它有效地中止当前方法的执行并返回到调用此方法的方法。但是不是在前面的方法中正常继续执行,而是在那个方法中抛出同样的异常,导致Java虚拟机要经过同样的过程,去查找那个方法的异常表。

Java 程序员可以使用 throw 语句抛出异常,例如在 catch ( ArithmeticException) 子句中创建并抛出remaindera 的语句DivideByZeroException。执行抛出的字节码如下表所示:


所述athrow指令从堆栈中弹出顶部字和希望它是一个对象,它是一个子类的引用Throwable(或Throwable本身)。抛出的异常属于由弹出的对象引用定义的类型。

Play Ball!:Java 虚拟机模拟
下面的小程序演示了一个执行字节码序列的 Java 虚拟机。模拟中的字节码序列是由

java

为了

playBall

类的方法如下所示:

class Ball extends Exception { } class Pitcher { private static Ball ball = new Ball (); 静态无效播放球(){ int i = 0 ; while ( true ) { try { if ( i % 4 == 3 ) {扔球;} ++; }抓住(    

  
        
      
         
          
             
                     
                    
                
                
            
             球b ) { 
                i = 0 ; } } } }  
            
        
    

javac为该playBall方法生成的字节码如下所示:

   
   0 iconst_0              // 推送常量 0 1 istore_0              // 弹出本地 var 0: int i = 0; // try 块从这里开始(参见下面的异常表)。2 iload_0               // 推送局部变量0 3 iconst_4              // 推送常量 4 4 irem                  // 计算前两个操作数的余数5 iconst_3              // 推送常量 3 6 if_icmpne 13 // 如果余数不等于 3,则跳转: if (i % 4 == 3) { // 在常量池位置 #5 处推送静态字段,// 这是Ball
   
                          
   
   
   
   
            
                          
                          异常渴望被抛出9 getstatic #5 <Field Pitcher.ball LBall;> 12 athrow                // 把它扔回家:扔球;13 iinc 0 1 // 将局部变量0 处的 int 增加 1:++i; // try 块到此结束(参见下面的异常表)。16 goto 2 // 总是跳回 2: while (true) {} // 以下字节码实现了 catch 子句:19 pop                   // 弹出异常引用,因为它未被使用20 iconst_0              // 推送常量 0 21 istore_0             
   
  
                
                          
                   
                          
  
  
  // 弹出局部变量 0: i = 0; 22 goto 2 // 总是跳回到 2:while (true) {}异常表:从   到目标类型
     2 16 19 < Class Ball >
                   

该playball方法永远循环。每经过四次循环,playball 就会抛出一个Ball并接住它,只是因为它很有趣。因为 try 块和 catch 子句都在无限的 while 循环中,所以乐趣永无止境。局部变量i从 0 开始并在每次循环时递增。当if语句 is 时true,每次i等于 3时都会发生,Ball抛出异常。

Java 虚拟机检查异常表并发现确实存在适用的条目。条目的有效范围为 2 到 15,包括 2 到 15,在 pc 偏移量 12 处抛出异常。条目捕获的异常属于 class Ball,抛出的异常属于 class Ball。鉴于这种完美匹配,Java 虚拟机将抛出的异常对象压入堆栈,并在 pc 偏移量 19 处继续执行。 catch 子句只是将int i重置为 0,然后循环重新开始。

要驱动模拟,只需按下“Step”按钮。每按一次“Step”按钮,Java 虚拟机就会执行一条字节码指令。要重新开始模拟,请按“重置”按钮。要让 Java 虚拟机重复执行字节码而不需要您进一步的哄骗,请按“运行”按钮。然后 Java 虚拟机将执行字节码,直到按下“停止”按钮。小程序底部的文本区域描述了要执行的下一条指令。快乐点击。

JVM怎么处理字节码

这篇文章涵盖了由字节码操作的原始类型、在类型之间转换的字节码以及在堆栈上操作的字节码。

字节码格式

字节码是 Java 虚拟机的机器语言。当 JVM 加载类文件时,它会为类中的每个方法获取一个字节码流。字节码流存储在 JVM 的方法区中。方法的字节码在程序运行过程中调用该方法时执行。它们可以通过解释、即时编译或特定 JVM 的设计者选择的任何其他技术来执行。

方法的字节码流是 Java 虚拟机的指令序列。每条指令都包含一个一字节的操作码,后跟零个或多个操作数。操作码指示要采取的操作。如果在 JVM 采取行动之前需要更多信息,那么该信息将被编码到一个或多个紧跟在操作码之后的操作数中。

每种类型的操作码都有一个助记符。在典型的汇编语言风格中,Java 字节码流可以通过它们的助记符后跟任何操作数值来表示。例如,以下字节码流可以分解为助记符:

//字节码流:03 3B 84 00 01 1A 05 68 3B FF A7 F9 //拆卸:
iconst_0       // 03 
istore_0       // 3B 
iinc 01 // 84 00 01 
iload_0        // 1A 
iconst_2       // 05 
IMUL           // 68 
istore_0       // 3b goto - 7 // a7 ff f9       

字节码指令集被设计为紧凑的。除了处理表跳转的两条指令外,所有指令都在字节边界上对齐。操作码的总数足够小,以至于操作码只占用一个字节。这有助于最小化在被 JVM 加载之前可能跨网络传输的类文件的大小。它还有助于保持 JVM 实现的规模较小。

JVM 中的所有计算都以堆栈为中心。由于 JVM 没有用于存储任意值的寄存器,因此所有内容都必须先压入堆栈,然后才能用于计算。因此,字节码指令主要在堆栈上运行。例如,在上面的字节码序列中,局部变量乘以 2,首先用iload_0指令将局部变量压入堆栈,然后用 将两个压入堆栈iconst_2。在两个整数都被压入堆栈后,该imul指令有效地将两个整数从堆栈中弹出,将它们相乘,然后将结果压回到堆栈上。结果从栈顶弹出并存储回局部变量istore_0操作说明。JVM 被设计为基于堆栈的机器而不是基于寄存器的机器,以促进在缺乏寄存器的体系结构(如 Intel 486)上的高效实现。

原始类型

JVM 支持七种原始数据类型。Java 程序员可以声明和使用这些数据类型的变量,Java 字节码对这些数据类型进行操作。下表列出了七种原始类型:


原始类型在字节码流中显示为操作数。所有占用超过 1 个字节的原始类型在字节码流中以大端顺序存储,这意味着高位字节在低位字节之前。例如,要将常量值 256(十六进制 0100)压入堆栈,您可以使用sipush操作码后跟一个短操作数。短代码出现在字节码流中,如下所示,为“01 00”,因为 JVM 是大端的。如果JVM 是little-endian,则short 将显示为“00 01”。

// 字节码流:17 01 00 // 反汇编:
sipush 256 ; // 17 01 00

Java 操作码通常指示其操作数的类型。这允许操作数只是它们自己,而无需向 JVM 标识它们的类型。例如,JVM 有多个操作码,而不是将局部变量压入堆栈的操作码。操作码iload、lload、fload和分别dload将 int、long、float 和 double 类型的局部变量压入堆栈。

将常量压入堆栈

许多操作码将常量压入堆栈。操作码指示以三种不同方式推送的常量值。常量值要么隐含在操作码本身中,作为操作数跟随字节码流中的操作码,要么从常量池中获取。

一些操作码本身指示要推送的类型和常量值。例如,iconst_1操作码告诉 JVM 推送整数值 1。这些字节码是为一些常见的各种类型的推送数字定义的。这些指令在字节码流中仅占用 1 个字节。它们提高了字节码执行的效率并减少了字节码流的大小。推送整数和浮点数的操作码如下表所示:


上表中显示的操作码推送整数和浮点数,它们是 32 位值。Java 堆栈上的每个插槽都是 32 位宽。因此,每次将 int 或 float 压入堆栈时,它都会占用一个槽。

下一个表中显示的操作码推动 longs 和 doubles。Long 和 double 值占用 64 位。每次将 long 或 double 压入堆栈时,其值会占用堆栈上的两个插槽。下表显示了指示要推送的特定 long 或 double 值的操作码:


另一种操作码将隐式常量值压入堆栈。aconst_null下表中显示的操作码将空对象引用压入堆栈。对象引用的格式取决于 JVM 实现。对象引用将以某种方式引用垃圾收集堆上的 Java 对象。空对象引用表示对象引用变量当前未引用任何有效对象。该aconst_null操作码中的一个对象引用变量分配空的过程中使用。


两个操作码指示使用紧跟在操作码之后的操作数推送的常量。下表中显示的这些操作码用于推送字节或短类型有效范围内的整数常量。跟在操作码后面的字节或短字节在被压入堆栈之前被扩展为一个整数,因为 Java 堆栈上的每个插槽都是 32 位宽。对压入堆栈的字节和短字节的操作实际上是在它们的 int 等价物上完成的。


三个操作码从常量池中推送常量。与类关联的所有常量,例如最终变量值,都存储在类的常量池中。从常量池中推送常量的操作码具有通过指定常量池索引来指示要推送哪个常量的操作数。Java 虚拟机将查找给定索引的常量,确定常量的类型,并将其压入堆栈。

常量池索引是一个无符号值,紧跟在字节码流中的操作码之后。操作码lcd1并将lcd232 位项目压入堆栈,例如 int 或 float。lcd1和之间的区别lcd2是lcd1只能引用常量池位置 1 到 255,因为它的索引只有 1 个字节。(常量池位置零未使用。)lcd2有一个 2 字节的索引,因此它可以引用任何常量池位置。lcd2w也有一个 2 字节的索引,它用于引用任何包含 long 或 double 的常量池位置,占用 64 位。从常量池中压入常量的操作码如下表所示:

将局部变量压入堆栈

局部变量存储在堆栈帧的一个特殊部分。堆栈帧是当前正在执行的方法正在使用的堆栈部分。每个栈帧由三部分组成——局部变量、执行环境和操作数栈。将局部变量压入堆栈实际上涉及将值从堆栈帧的局部变量部分移动到操作数部分。当前正在执行的方法的操作数部分总是在栈顶,因此将一个值压入当前栈帧的操作数部分与将一个值压入栈顶是一样的。

Java 堆栈是 32 位槽的后进先出堆栈。因为堆栈中的每个槽位都占用 32 位,所以所有局部变量至少要占用 32 位。long 和 double 类型的局部变量是 64 位数量,占用堆栈上的两个插槽。byte 或 short 类型的局部变量存储为 int 类型的局部变量,但具有对较小类型有效的值。例如,表示字节类型的 int 局部变量将始终包含对字节有效的值 (-128 <= value <= 127)。

方法的每个局部变量都有唯一的索引。方法堆栈帧的局部变量部分可以被认为是一个 32 位槽的数组,每个槽都可以通过数组索引寻址。占用两个槽的 long 或 double 类型的局部变量由两个槽索引中较低的一个引用。例如,占用第二位和第三位的双精度数将被索引为 2 引用。

存在几种将 int 和 float 局部变量压入操作数堆栈的操作码。一些操作码被定义为隐式引用一个常用的局部变量位置。例如,iload_0在位置零加载 int 局部变量。其他局部变量由操作码压入堆栈,该操作码从操作码后的第一个字节获取局部变量索引。该iload指令是此类操作码的一个示例。后面的第一个字节iload被解释为引用局部变量的无符号 8 位索引。

无符号的 8 位局部变量索引,例如跟在iload指令后面的索引,将方法中局部变量的数量限制为 256。单独的指令,称为wide,可以将 8 位索引再扩展 8 位。这将局部变量限制提高到 64 KB。该wide操作码后面是8位的操作数。的wide操作码和操作数可以先的指令,例如iload,其采用8位无符号局部变量索引。JVM 将wide指令的 8 位操作数与指令的 8 位操作数组合iload以产生 16 位无符号局部变量索引。

将 int 和 float 局部变量压入堆栈的操作码如下表所示:


下表显示了将 long 和 double 类型的局部变量压入堆栈的指令。这些指令将 64 位从堆栈帧的局部变量部分移动到操作数部分。


最后一组推送局部变量的操作码将 32 位对象引用从堆栈帧的局部变量部分移动到操作数部分。这些操作码如下表所示:

弹出到局部变量

对于每个将局部变量压入堆栈的操作码,都存在一个相应的操作码,用于将堆栈顶部弹出回局部变量。这些操作码的名称可以通过将推送操作码名称中的“加载”替换为“存储”来形成。下表列出了将整数和浮点数从操作数堆栈顶部弹出到局部变量的操作码。这些操作码中的每一个都将一个 32 位值从堆栈顶部移动到一个局部变量。


下表显示了将 long 和 double 类型的值弹出到局部变量中的指令。这些指令将 64 位值从操作数堆栈的顶部移动到局部变量。

弹出到局部变量的最后一组操作码如下表所示。这些操作码从操作数堆栈的顶部弹出一个 32 位对象引用到一个局部变量。

类型转换

Java 虚拟机有许多操作码,可以将一种原始类型转换为另一种原始类型。字节码流中的转换操作码后面没有操作数。要转换的值取自堆栈顶部。JVM 弹出堆栈顶部的值,对其进行转换,然后将结果压回到堆栈上。在 int、long、float 和 double 之间转换的操作码如下表所示。这四种类型的每种可能的从到组合都有一个操作码:


下表显示了从 int 转换为小于 int 的类型的操作码。不存在直接从 long、float 或 double 转换为小于 int 的类型的操作码。因此,例如,从浮点数转换为字节需要两个步骤。首先必须将浮点数转换为 int f2i,然后可以将生成的 int 转换为字节int2byte。

尽管存在将 int 转换为小于 int 的原始类型(byte、short 和 char)的操作码,但不存在反向转换的操作码。这是因为任何字节、shorts 或 chars 在被压入堆栈之前都有效地转换为 int。对字节、短型和字符的算术运算是通过首先将值转换为 int,对 int 执行算术运算,然后对 int 结果感到满意来完成的。这意味着如果您添加 2 个字节,您将获得一个 int,如果您想要一个字节结果,您必须明确地将 int 结果转换回一个字节。例如,以下代码将无法编译:

 类BadArithmetic {字节addOneAndOne (){字节一个= 1 ; 字节b = 1 ; 字节c = a + b ; 返回c ; } }  
         
                 
                 
                
                
        





当出现上述代码时,javac 对象带有以下注释:

 坏算术。的java (7 ):不兼容的类型为声明。将int转换为byte所需的显式转换。字节c = a + b ; ^  
                

为了补救这种情况,Java 程序员必须将 a + b 相加的 int 结果显式转换回字节,如下面的代码所示:

 class GoodArithmetic { byte addOneAndOne () { byte a = 1 ; 字节b = 1 ; 字节c = (字节) ( a + b ); 返回c ; } }  
         
                 
                 
                  
                
        
     

这让 javac 非常高兴,它删除了一个 GoodArithmetic.class 文件,其中包含 addOneAndOne() 方法的以下字节码序列:

iconst_1           // 推送 int 常量 1。
istore_1           // 弹出局部变量 1,即 a: byte a = 1; 
iconst_1           // 再次推送 int 常量 1。
istore_2           // 弹出局部变量2,即b: byte b = 1; 
iload_1            // 推送 a(a 已经作为 int 存储在局部变量 1 中)。
iload_2            // 推送 b(b 已经作为 int 存储在局部变量 2 中)。
iadd               // 执行加法。栈顶现在是 (a + b),一个 int。
int2byte           // 将 int 结果转换为 byte(结果仍占 32 位)。
istore_3          // 弹出局部变量3,也就是byte c: byte c = (byte) (a + b); 
iload_3            // 推送 c 的值,以便它可以返回。
ireturn            // 自豪地返回加法的结果:return c;          

转换转移:JVM 模拟

下面的小程序演示了一个 JVM 执行字节码序列。模拟中的字节码序列是由

javac

对于如下所示的类的 Convert() 方法:

 class Diversion { static void Convert () { byte imByte = 0 ; int imInt = 125 ; while ( true ) { ++ imInt ; 
            imByte = (字节) imInt ; 
            imInt *= - 1 ; 
            imByte = (字节) imInt ; 
            imInt *= - 1 ;  
       
         
         
          
                
        } } }

javacfor Convert()生成的实际字节码如下所示:

iconst_0           // 推送 int 常量 0。
istore_0           // 弹出到局部变量 0,即 imByte: byte imByte = 0; 
bipush 125 // 将字节常量 125 扩展为 int 并推送。
istore_1           // 弹出到局部变量 1,即 imInt: int imInt = 125; 
iinc 1 1 // 将局部变量 1 (imInt) 增加 1: ++imInt; 
iload_1            // 推送局部变量 1 (imInt)。
int2byte           // 对栈顶进行截断和符号扩展,使其具有有效的字节值。
istore_0           // 弹出到局部变量 0 (imByte): imByte = (byte) imInt; 
加载_1                              // 再次推送局部变量 1 (imInt)。
iconst_m1          // 推送整数 -1。
imul               // 弹出前两个整数,相乘,压入结果。
istore_1           // 与局部变量 1 相乘的结果 (imInt): imInt *= -1; 
iload_1            // 推送局部变量 1 (imInt)。
int2byte           // 对栈顶进行截断和符号扩展,使其具有有效的字节值。
istore_0           // 弹出到局部变量 0 (imByte): imByte = (byte) imInt; 
iload_1            // 再次推送局部变量 1 (imInt)。
iconst_m1          // 推送整数 -1。
imul               // 弹出前两个整数,相乘,压入结果。
istore_1           // 与局部变量 1 相乘的结果 (imInt): imInt *= -1; goto 5 // 跳回 iinc 指令:while (true) {}

Convert() 方法演示了 JVM 从 int 转换为 byte 的方式。imInt从 125 开始。每次通过 while 循环,它都会递增并转换为一个字节。然后乘以 -1 并再次转换为一个字节。模拟快速显示在字节类型的有效范围边缘发生的情况。

一个字节的最大值为 127。最小值为 -128。在此范围内的 int 类型值直接转换为字节。然而,一旦 int 超出字节的有效范围,事情就会变得有趣。

JVM 通过截断和符号扩展将 int 转换为字节。longs、ints、shorts 和 bytes 的最高位,即“符号位”指示整数值是正数还是负数。如果符号位为零,则值为正。如果符号位为 1,则值为负。字节值的第 7 位是其符号位。要将 int 转换为字节,将 int 的第 7 位复制到第 8 位到第 31 位。这将生成一个 int,其数值与 int 的最低字节被解释为字节类型时具有的数值相同。在截断和符号扩展之后,int 将包含一个有效的字节值。

模拟小程序显示当一个刚好超出字节类型有效范围的 int 被转换为一个字节时会发生什么。例如,当 imInt 变量的值为 128 (0x00000080) 并转换为字节时,生成的字节值为 -128 (0xffffff80)。稍后,当 imInt 变量的值为 -129 (0xffffff7f) 并转换为字节时,生成的字节值为 127 (0x0000007f)。

要驱动模拟,只需按下“Step”按钮。每按一次“Step”按钮,JVM 就会执行一条字节码指令。要重新开始模拟,请按“重置”按钮。小程序底部有一个文本区域,描述要执行的下一条指令。快乐点击。

深入JVM性能优化

Java 应用程序运行在 JVM 上,但您对 JVM 技术了解多少?本文是系列文章的第一篇,概述了经典 Java 虚拟机的工作原理,例如 Java 一次编写、随处运行引擎的优缺点、垃圾收集基础知识,以及常见 GC 算法和编译器优化的示例. 后面的文章将转向 JVM 性能优化,包括更新的 JVM 设计,以支持当今高度并发的 Java 应用程序的性能和可伸缩性。

如果您是一名程序员,那么当您的思维过程中点亮一盏灯时,当这些神经元最终建立连接时,您无疑会体验到那种特殊的感觉,并且您将以前的思维模式打开到一个新的视角。我个人喜欢那种学习新事物的感觉。我在 Java 虚拟机 (JVM) 技术的工作中多次遇到过这样的时刻,尤其是在垃圾收集和 JVM 性能优化方面。在这个新的 JavaWorld 系列中,我希望与您分享一些启发。希望您对了解 JVM 性能会像我写的一样兴奋!

本系列是为任何有兴趣了解更多关于 JVM 底层和 JVM 真正功能的 Java 开发人员编写的。我将在高层次上讨论垃圾收集以及在不影响正在运行的应用程序的情况下安全快速地释放内存的永无止境的追求。您将了解 JVM 的关键组件:垃圾收集和 GC 算法、编译器风格和一些常见的优化。我还将讨论为什么 Java 基准测试如此困难,并提供衡量性能时要考虑的技巧。最后,我将介绍 JVM 和 GC 技术中的一些较新的创新,包括来自 Azul 的 Zing JVM、IBM JVM 和 Oracle 的 Garbage First (G1) 垃圾收集器的亮点。

我希望您在结束本系列文章后,能够更深入地了解当今限制 Java 可扩展性的因素,以及这些限制如何迫使我们以非最佳方式构建 Java 部署。希望你会体验到一些啊哈!并受到启发为 Java 做点好事:停止接受限制并为改变而努力!如果您还不是开源贡献者,那么本系列可能会鼓励您朝这个方向前进。

JVM 性能和“一劳永逸”的挑战

对于那些坚持认为 Java 平台本质上很慢的想法的人,我有一个消息。认为 JVM 是导致 Java 性能不佳的罪魁祸首的信念已有数十年历史——它始于 Java 首次用于企业应用程序,但已经过时了!它是确实,如果您比较在不同开发平台上运行简单静态和确定性任务的结果,您很可能会看到使用机器优化代码比使用任何虚拟化环境(包括 JVM)执行得更好。但是 Java 的性能在过去 10 年中取得了重大飞跃。Java 行业的市场需求和增长导致了一些垃圾收集算法和新的编译创新,并且随着 JVM 技术的进步,出现了大量启发式和优化。我将在本系列的后面介绍其中的一些。

JVM 技术的美妙之处也是它最大的挑战:“一次编写,随处运行”的应用程序无法假设任何事情。JVM 不是针对一个用例、一个应用程序和一个特定的用户负载进行优化,而是不断跟踪 Java 应用程序中发生的事情并相应地动态优化。这种动态运行时间会导致动态问题集。在 JVM 上工作的开发人员在设计创新时不能依赖静态编译和可预测的分配率,至少如果我们想要在生产环境中获得性能的话!

JVM 性能的职业生涯

在我职业生涯的早期,我意识到垃圾收集很难“解决”,从那时起我就对 JVM 和中间件技术着迷。我对 JVM 的热情始于我在JRockit团队工作,编写一种新颖的方法来实现自学习、自调整垃圾收集算法(请参阅参考资料)。这个项目变成了 JRockit 的一个实验性功能,并为确定性垃圾收集算法奠定了基础,开始了我的 JVM 技术之旅。我曾在 BEA Systems 工作,与 Intel 和 Sun 合作,并在 Oracle 收购 BEA Systems 后短暂受雇于 Oracle。后来我加入了 Azul Systems 的团队来管理Zing JVM,今天我在Cloudera工作.

机器优化代码可能会提供更好的性能,但它以不灵活为代价,这对于具有动态负载和快速功能变化的企业应用程序来说不是可行的权衡。大多数企业愿意为了 Java 的好处而牺牲机器优化代码的狭隘完美的性能:

  • 易于编码和功能开发(意味着更快的上市时间)
  • 接触知识渊博的程序员
  • 使用 Java API 和标准库进行快速开发
  • 可移植性——无需为每个新平台重写 Java 应用程序

从 Java 代码到字节码

作为 Java 程序员,您可能熟悉编码、编译和执行 Java 应用程序。为了举例,让我们假设您有一个程序,MyApp.java并且您想运行它。要执行此程序,您首先需要使用javacJDK 的内置静态 Java 语言到字节码编译器对其进行编译。基于Java代码,javac生成对应的可执行字节码并保存到同名类文件中:MyApp.class. 将 Java 代码编译成字节码后,您就可以通过使用java命令行或启动脚本中的命令启动可执行类文件来运行您的应用程序,无论是否有启动选项。该类被加载到运行时(即正在运行的 Java 虚拟机)中,并且您的程序开始执行。

这就是日常应用程序执行场景表面上发生的事情,但现在让我们探索调用该命令时真正发生的事情java。Java 虚拟机这个东西叫什么?大多数开发人员都通过持续的调优过程与 JVM 交互——也就是选择和赋值启动选项,以使您的 Java 程序运行得更快,同时巧妙地避免臭名昭著的 JVM“内存不足”错误。但是您有没有想过为什么我们首先需要一个 JVM 来运行 Java 应用程序?

什么是 Java 虚拟机?

简单地说,JVM 是执行 Java 应用程序字节码并将字节码转换为硬件和操作系统特定指令的软件模块。通过这样做,JVM 使 Java 程序能够在与最初编写它们的环境不同的环境中执行,而无需对原始应用程序代码进行任何更改。Java 的可移植性是其作为企业应用程序语言流行的关键:开发人员不必为每个平台重写应用程序代码,因为 JVM 处理转换和平台优化。

JVM 基本上是一个虚拟执行环境,充当字节码指令的机器,同时通过与底层交互来分配执行任务和执行内存操作。

JVM 还负责运行 Java 应用程序的动态资源管理。这意味着它处理分配和取消分配内存,在每个平台上维护一致的线程模型,并以适合执行应用程序的 CPU 架构的方式组织可执行指令。JVM 使程序员无需跟踪对象之间的引用,也无需知道它们应该在系统中保留多长时间。它还使我们不必确切地决定何时发出明确的指令来释放内存——这是非动态编程语言(如 C)公认的痛点。

您可以将 JVM 视为 Java 的专用操作系统;它的工作是管理 Java 应用程序的运行时环境。JVM 基本上是一个虚拟执行环境,充当字节码指令的机器,同时通过与底层交互来分配执行任务和执行内存操作。

JVM 组件概述

关于 JVM 内部和性能优化,还有很多要写的。作为本系列后续文章的基础,我将以 JVM 组件的概述结束。这个简短的导览对于刚接触 JVM 的开发人员特别有帮助,并且应该激发您对本系列后面更深入讨论的兴趣。

从一种语言到另一种语言——关于 Java 编译器

甲编译器需要一个语言作为输入,并产生一个可执行语言作为输出。Java 编译器有两个主要任务:

  1. 使 Java 语言更具可移植性,首次编写时不会绑定到任何特定平台
  2. 确保结果是预期目标执行平台的高效执行代码

编译器要么是静态的,要么是动态的。静态编译器的一个例子是javac. 它将 Java 代码作为输入并将其转换为字节码——一种可由 Java 虚拟机执行的语言。静态编译器将输入代码解释一次,输出可执行文件采用程序执行时将使用的形式。由于输入是静态的,您将始终看到相同的结果。只有当您对原始源代码进行更改并重新编译时,您才会看到不同的结果。

动态编译器,例如即时 (JIT)编译器,动态地执行从一种语言到另一种语言的翻译,这意味着它们在代码执行时进行。JIT 编译器允许您收集或创建运行时分析数据(通过插入性能计数器的方式)并使用手头的环境数据即时做出编译器决策。动态编译可以更好地对编译语言中的指令进行排序,用更高效的指令集替换指令集,甚至消除冗余操作。随着时间的推移,您可以收集更多的代码分析数据,并做出更多更好的编译决策;这通常被称为代码优化和重新编译。

动态编译使您能够适应行为或应用程序负载随时间的动态变化,从而推动对新优化的需求。这就是动态编译器非常适合 Java 运行时的原因。问题在于动态编译器可能需要额外的数据结构、线程资源和 CPU 周期来进行分析和优化。对于更高级的优化,您将需要更多资源。然而,在大多数环境中,获得的执行性能改进的开销非常小 —— 性能比纯解释(即按原样执行字节码,无需修改)所获得的性能高 5 或 10 倍。

分配导致垃圾收集

分配是在每个“Java 进程专用内存地址空间”(也称为 Java 堆或简称为堆)中的每个线程基础上完成的。单线程分配在 Java 的客户端应用程序世界中很常见。然而,单线程分配在企业应用程序和工作负载服务端很快变得非最佳,因为它没有利用现

以上是关于耗时一周深入理解JVM虚拟机异常处理字节码性能优化,全网最全面的JVM原理通俗易懂(强烈建议收藏)的主要内容,如果未能解决你的问题,请参考以下文章

深入理解JVM学习笔记——-8虚拟机字节码执行引擎

深入理解JVM学习笔记——-8虚拟机字节码执行引擎

深入理解JVM

深入理解JVM虚拟机5:虚拟机字节码执行引擎

深入理解 Java 虚拟机之学习笔记

性能优化 | JVM与性能优化知识点综合整理