前言大家好,我是鲲哥。从今天开始,我打算写一系列关于Java的进阶基础文章。从高楼开始,打好基础才能走得更好。比如我之前在吴哥的卡夫卡文章中看到这样一句话。此外,页面缓存(pageCache)还有一个巨大的优势。用过Java的都知道,如果不使用pagecache,而使用JVM进程中的缓存,对象的内存开销是非常大的(通常是真实数据的几倍甚至更多)”,如果不理解了Java对象的表示,看到这里你会一头雾水:对象的开销到底有多大?另一方面,如果掌握了Java中的对象布局、GC、NIO等原理,就不难做到了解这些框架的原理和设计思路,另外一个让我决定写这个系列的原因是经常有读者问学习路线,我之前写了一些大纲,但是没有从点层入手。详细说说,然后整理成pdf,这样别人再问,把这个pdf丢给他们就完了。^_^每个系列都会图文并茂的讲解,这样就可以解释了简单的说,比如上面我们说的t对象的开销非常高。它有多大?我会用图一步步分析。看完之后相信你会明白为什么int[128][2],int[256]这两个数组看起来一样,但实际上前者比后者多了246%的开销。比如我们都知道当Eden区或者tenured(老年代区)满了,就会触发yonggc或者oldgc,但是会导致gc停顿时间长的原因其实有很多。如果你看了我总结的大概思路,相信你可以根据这套理论快速排查问题。本系列干货很多,相信对大家提升Java内功有很大帮助,记得在文末点赞支持^_^Java系列大纲如下:在本文中,我们将首先学习字节码。毕竟这才是Java能够跨平台的根本原因,而通过了解字节码也可以彻底揭开JVM运行程序的秘密,全程以问答的形式进行讲解。能简单介绍一下Java的特点吗?Java是一种面向对象的静态类型语言,具有跨平台特性。与需要手动内存管理和编译型语言的C和C++不同,它是解释型和跨平台的。平台和自动垃圾回收,那么它是如何实现跨平台的呢?我们知道,计算机只能识别以二进制代码表示的机器语言,所以无论使用什么高级语言,都必须先翻译成机器语言,才能被CPU识别并执行。对于C++等编译型语言,直接一步转化为对应平台的可执行文件(即机器语言指令)。对于Java,源文件首先被编译器编译成字节。代码,然后由虚拟机(JVM)解释成机器指令在运行时执行,我们可以看到下图:也就是说,Java的跨平台其实是先生成字节码,再由JVM实现对于每个平台解释执行实现,JVM屏蔽了OS的差异。我们知道Java项目都是部署在Jar包分发(类文件的集合)中,也就是说jar包可以运行在各种平台上(通过相应平台的JVM可以解释执行),这就是为什么Java可以实现跨平台。这就是为什么JVM可以运行Scala、Groovy、Kotlin等语言的原因。并不是JVM直接执行这些语言,而是这些语言最终会生成符合JVM规范的字节码,然后被JVM执行。不知道你有没有注意到。字节码的使用也利用了计算机科学中的分层概念。通过增加字节码等中间层,有效屏蔽了与上层的交互差异。JVM是如何执行字节码的在这之前,我们先看一下JVM整体的内存结构,对它有个宏观的认识,再看看JVM是如何执行字节码的。JVM内存结构JVM内存主要分为“栈”、“堆”、“非堆”和JVM本身。堆主要用于分配类实例和数组,非堆包括“方法区”、“JVM内部处理或优化所需的内存(如JIT编译代码缓存)”、per-class结构(如runtime常量池、字段和方法数据),以及方法和构造函数的代码。我们主要关注堆栈。我们知道线程是cpu调度的最小单位。一旦在JVM中创建了一个线程,就会为其分配一个线程栈。线程会一个一个调用方法,每个方法都会对应一个栈帧。在线程栈中,JVM中的栈内存结构如下:JVM栈内存结构至此我们终于接近了JVM执行的真相。JVM以栈帧为单位执行,栈帧由以下四部分组成:返回值局部变量表(LocalVariables):存放方法使用的局部变量。动态链接:在字节码中,所有的变量和方法都以符号引用的形式存放在类文件的常量池中。例如,一个方法调用另一个方法是通过指向常量池中方法的符号引用来表示的。动态链接的作用就是将这些符号引用转化为对调用方法的直接引用。可能有些人还不明白,那我们先执行javap-verbose。demo.class命令查看字节码中的常量池长什么样注:以上只列出了常量池中的一些符号引用。可以看到Object的init方法用#4.#16表示,#4指向#19,#19表示Object,#16指向#7.#8,#7指向方法名,#8指向()V(表示方法的返回值为void,没有方法参数),字节码加载后,类信息会被加载到metaspace中的方法区(Java8之后),而动态链接会将这些符号引用替换为对调用方法的直接引用,如下图所示:那为什么要提供动态链接呢,具体的执行方法位于通过上述方法走了几条弯路之后。效率是不是低了很多?其实主要是为了支持Java多态。比如我们声明了一个Fatherf=newSon()变量,但是在执行f.method()的时候,会绑定到son的方法(如果有的话),这就是使用了动态链接技术,可以位于运行时定位具体调用的方法,动态链接也叫后期绑定,相对于静态链接(也叫早期绑定),即对象的方法在编译时和运行时都保持不变,发生静态链接在编译时,也就是说,在程序执行之前就绑定了方法。java中只有final、static、private和constructor方法是早期绑定的。虽然动态链接发生在运行时,但几乎所有方法都是在运行时绑定的。让我们举个例子看看两者之间的区别。classAnimal{publicvoideat(){System.out.println("animaleats");}}classCattextendsAnimal{@Overridepublicvoideat(){super.eat();//表现为早期绑定(静态链接)System.out.println("Cateats");}}publicclassAnimalTest{publicvoidshowAnimal(Animalanimal){animal.eat();//后期绑定(动态链接)}}操作数栈(OperandStack):程序主要由指令和操作数组成,指令是用来解释这个操作是做什么的,比如加法或者乘法,操作数就是指令要执行的数据,那么指令是如何获取数据的,指令集架构模型分为基于栈的指令集架构和基于寄存器的指令集架构有两种类型。JVM中的指令集属于前者,也就是说任何操作都是由栈管理的。基于栈指令,可以更好的实现跨平台。栈是在内存中分配的,而寄存器往往是和硬件挂钩的。不同的硬件架构不同,不利于跨平台。当然,基于栈的指令集架构的缺点也很明显。基于栈的实现需要更多的指令来完成(因为栈只是一个FILO结构,需要频繁的压栈和出栈),寄存器在CPU的缓存区。相比之下,基于栈的速度要慢很多。这也是为了跨平台牺牲了一点性能。毕竟,鱼和熊掌不可兼得。Java字节码技术介绍注意线程中还有一个“PC程序计数器”,它是每个线程唯一的,记录了当前线程执行的字节码的行号指示符,即指向线程??的地址nextinstruction,这是将要执行的指令代码。下一条指令由执行引擎读取。我们先来看看字节码长什么样。假设我们有如下Java代码:packagecom.mahai;publicclassDemo{privateinta=1;publicstaticvoidfoo(){inta=1;intb=2;intc=(a+b)*5;}}执行javacDemo后就可以看到.java字节码如下:字节码是给JVM读取的,所以我们需要将其翻译成可以理解的代码。好在JDK提供了反解析工具javap,可以反解析代码区(汇编指令)、局部变量表、异常表、代码行偏移映射表、常量池等信息。下面我们执行下面的命令,看看文件根据字节码反解析是什么样子的(更详细的可以执行javap-verbose命令,本例中我们关注的是Code区是如何执行的,所以我们使用javap-c来执行.javap-cDemo.class转换成这种形式可读性强很多,那么aload_0,invokespecial是什么意思,javap是如何根据字节码解析这些指令的!首先我们要明白什么是指令,instruction=opcode+operand,opcode表示这条指令做了什么,比如加减乘除,operand就是opcode操作的数,比如1+2,opcode其实就是加法,1,2是操作数。在Java中,每个操作码用一个字节表示。每个操作码都有对应的助记符,如aload_0,invokespecial,iconst_1。有些操作码已经包含了操作数。例如字节码0x04对应的助记符是iconst_1,表示将int类型1压入栈顶。这些操作码相当于指令,有些操作码需要和操作数配合才能构成指令。例如字节码0x10表示bipush,后面跟着一个操作数,表示将单字节常量值(-128~127)压入栈顶。下面列举了一些字节码和助记符的例子:字节码助记符表示含义0x04iconst_1pushinttype1tothetop0xb7invokespecialcallsuperclassconstructionmethod,instanceinitializationmethod,privatemethod0x1aiload_0pushthefirstintlocalvariabletothe栈顶0x10bipush将单字节常量值(-128~127)压入栈顶至此我们就很容易理解javap的作用了,主要是找到对应的字节码然后助记符显示在在我们面前。下面简单看一下上述默认构造方法是如何根据字节码映射成助记词,最终呈现给我们的:最左边的数是Code区中每个字节的偏移量,存储在该程序的程序计数中以PC为例,如果当前指令指向1,则下一条指令指向4。另外,你不难发现,在源码中,我们其实并没有定义默认构造函数,而是生成了在字节码中,你会发现我们在源码中定义了privateinta=1;但是这个变量赋值的操作却是在构造方法中执行的(下面会分析),这就是理解字节码的意义:它能反映JVM执行程序的真实逻辑,而源码只是一个外观,还得看字节码深入分析!接下来我们看一下构造方法对应的指令是如何执行的。首先,我们看一下指令在JVM中是如何执行的。首先,JVM会为每个方法分配一个对应的局部变量表,可以认为是一个数组,每个坑(我们称之为槽)就是方法中分配的一个变量。如果是实例方法,这些局部变量可以是this,方法参数,方法中分配的局部变量,这些局部变量的类型就是我们熟悉的int、long等八种基础,还有引用和返回地址。每个slot是4个字节,所以像Long和Double这样的8个字节占用2个slot。如果方法是实例方法,第一个槽是this指针,如果是静态方法,则没有this指针。分配局部变量表后,如果方法中涉及到赋值、加减乘除等操作,这些指令的操作需要依赖操作数栈,将这些指令对应的操作数压入并弹出以完成指令。执行。比如有inti=69这样的指令,对应的字节码指令如下:0:bipush692:istore_0在内存中的操作过程如下:可以看到主要有两个步骤:第一步就是先把69的int值压栈,然后出栈,把69出栈到局部变量表中i对应的位置,istore_0表示出栈,从操作数栈中出栈,把整数存入局部变量,0表示局部变量在局部变量表的第0槽。理解了上面的操作之后,我们再来看看默认构造函数对应的字节码指令是如何执行的:首先,我们需要理解上面的指令:aload_0:从局部变量表中加载第0个slot对象指的是top操作数栈的,其中0表示第0个位置,也就是这个。invokespecial:用于调用构造函数,但也可以用于调用同一个类中的私有方法,以及可见的超类方法,在这种情况下,它意味着调用父类的构造函数(因为#1符号引用指向相应的init方法)。iconst_1:将int类型1压入栈顶。putfield:它接受一个操作数,该操作数引用运行时常量池中的一个字段,其中这个字段是a。执行该指令时,分配给该字段的值以及包含该字段的对象引用将从操作数堆栈的顶部弹出。前面的aload_0指令已经将包含这个字段的对象(this)压入操作数栈,后面的iconst_1指令将1压入栈中。最后putfield指令会将这两个值从栈顶弹出。执行的结果是这个对象的字段a的值被更新为1。接下来我们详细解释一下上面助记符的含义:第一个命令aload_0表示加载从第0个槽位开始的对象引用局部变量表到操作数栈顶,也就是把this加载到栈顶如下:第二步invokespecial#1表示出栈,执行#1对应的方法。#1的意思从旁边的解释可以看出(#Methodjava/lang/Object."":()V)即调用父类的初始化方法,也印证了那句话:当子类初始化后,父类之后的命令aload_0、iconst_1、putfied会被初始化#2示意图如下:有些人可能有点奇怪,上面6:putfield#2命令中的#2是怎么表示的Demo的私有成员a?这就涉及到字节码中常量池的概念。我们可以通过执行javap-verbosepath/Demo.class来查看这些文字的含义。#1、#2这种数字表示也称为符号引用,程序运行时会将符号引用转换为直接引用。可以看出,#2代表了Demo类的a属性,如下:从最后的叶子节点可以看出,#2最终代表了Demo类中一个名为a的变量,类型为int(I代表int代表int类型)。下面用动画来看一下foo的执行过程,相信你现在已经能明白它的意思了。唯一需要注意的是本例中的foo是一个静态方法,所以局部变量区是没有this的。相信大家不难发现,JVM执行字节码的过程和CPU执行机器码的过程是一模一样的。他们经历了“取指令”、“解码”、“执行”、“存储计算结果”四个步骤。首先程序计数器指向下一条要执行的指令,然后JVM拿到指令,本地执行引擎将字节码操作数转换成机器码(译码)执行,并将值存入局部变量区(存储计算结果)执行后。最后推荐两个字节码的工具:一个是HexFiend,一个不错的十六进制编辑器,可以用来查看和编辑字节码,另一个是jclasslibBytecodeviewer,一个IntellijIdea的插件,可以帮你显示javap-verbose命令对应的常量池、接口、Code等数据,非常直观,对分析字节码很有帮助,如下:.转载本文请联系码海公众号。
