前言写了这么多年代码,你对java代码运行的全过程有清晰的认识吗?你以为当你在IDE中点击RUN按钮,我们写的代码会和我一开始写的一样吗?直接跑?俗话说,你觉得生活平静,其实只是有人替你挑起重担,而编译器和虚拟机默默承受了这一切。一个小小的RUN背后,是众多组件共同努力的结果。他们必须非常努力才能看起来毫不费力。今天就让我们花点时间好好聊一聊,Java代码运行的背后,那些默默付出的伟大英雄们。当我们写下一行代码的时候,我们在写什么?深夜,我们在屏幕上敲出一段优雅的代码,一边拧开浸泡过枸杞子的保温杯,喝一口热水,一边欣赏着我们诗意的代码,一边在心里默默地赞美自己:我无愧于此!第一个问题来了,计算机真的能看到我们写的“诗”吗?众所周知,Java是一门“writeonce,runanywhere”的语言,也就是所谓的平台无关性,无论在哪个平台上都可以运行,并且保证运行的结果与预期一致.(这是大学老师反复强调的)Java“平台无关性”的原理也很简单,就是利用中间格式进行过渡,也就是我们常说的字节码,通过将Java源代码转换成字节码,来确保JVM(Java虚拟机)必须读取它可以识别的字节码格式。一个通俗的解释:你不会说法语,法国人也不会说中文,但是你或多或少会说英语,用英语作为你的中间格式,保证双方都能听懂对方的意思。这就是所谓的跨平台。Java源代码首先被编译成字节码,而这个字节码是实现平台无关性的关键。不管你是什么类型的平台,只要安装一个可以识别字节码的JVM(JavaVirtualMachine),通过JVM通过解析字节码文件,并将字节码转换成特定平台上的机器指令,实现跨平台运行可以实现。所以不要让计算机底层读取我们写的“代码诗”,连Java虚拟机也拿不到我们的原始代码。在编译器的努力下,Java源代码已经变成了白话类文件了。所以啊,操作系统无法欣赏我们的“如诗如画的代码”。我们写的每一行代码都会成为一条指令。对于操作系统来说,它看到的不是编程的艺术,它需要的只是一系列已经完成的KPI。文字是代码?如果我们写一个内容相同的Java文件和txt文本,在文本编辑器中它们的长度是没有区别的。有一句名言:世界上最好的IDE是txt文本编辑器。现在我们可以顺利的使用IDE了。很多操作我们习惯让IDE给我们提示,依赖IDE的代码补全和快捷键。但传说中,有一群大佬,可以用记事本打出漂亮的代码。到了这个层次,就已经合二为一了,没有语法高亮,也没有补全提示,所有正确的语法都一目了然,每一行打出来的代码都是可以直接编译运行没有bug的好代码(doge)。有点牵强,不过开发功能确实可以用记事本实现。只要你敲的代码逻辑正确,没有语法错误,最后保存的后缀是.java,就可以作为代码运行。因此,从本质上讲,我们输入的txt文本和一开始的Java代码并没有太大区别,我们的.java后缀文件也可以用普通的文本编辑器打开。但是文本编辑器能做的仅限于看到.java文件中的代码文本。Java编译器是终极的,能够识别和理解.java文件的存在。Java代码要想运行,第一步就是要得到编译器的认可。编译器的任务很简单,就是将Java语言源代码编译成符合Java虚拟机规范的Class文件。如果输入的Java源代码不符合规范,则需要报错。可以说,编译过程是Java开发的第一步,但也是程序的一大步。下面介绍一下编译器在Java体系中的地位。JDK与JRE的爱恨情仇,我们初学java的时候,肯定安装过所谓的java环境。当我们满怀信心地进入Oracle的Java官网,看到了两个长得一模一样的安装包:我被骗了,我只是想安装一个Java环境,为什么会有两个奇怪的安装包,一个叫JDK,另一个是叫JRE,这两个安装包和俗称的“Java”有什么关系呢?我们先搞清楚所谓的JDK和JRE的区别。先看一张Java8架构图(https://docs.oracle.com/javase/8/docs/):jdk8架构图JDK全称是TheJavaDevelopmentKit(Java开发工具包),里面包含了各种工具forJava从开发到运行。JRE是指Java运行时环境(JavaRuntimeEnvironment),它包括基础类库和JVM虚拟机。上图展示了Java8的架构,最左边一栏清晰的展示了JDK和JRE各自的作用域。我们很容易发现JRE是JDK的一个子集。既然要搞开发,就必须保证自己写的代码能够运行,所以开发者在安装JDK的时候,已经包含了一个运行环境JRE来保证他的代码能够运行和验证。包含在JDK中。但是如果我们是普通用户,不关心开发,甚至根本看不懂代码,只想得到代码运行的结果,那么我们只需要一个本地的JRE运行环境即可。如果你用过几年的按键手机,就会深有体会。那时候很多手机软件都是用Java写的,你只需要一个JAR包就可以收获幸福。回想一下移动端的Java应用,既然可以安装JRE来运行JAVA代码,但是需要完整的JDK才能完成开发,那么它们之间的区别肯定与开发过程有关。那么接下来我们来讨论一下,为什么缺少这块内容只能成为运行环境,而不能成为开发功能呢?在JDK和JRE的区别中,我们可以看到几个熟悉的命令:javac:use用于编译java源代码,生成class文件;javap:用于反编译,根据class文件,反编译其中的汇编指令等信息;javadoc:用来生成java文档的命令。其中,我们最常用也是最重要的就是javac命令了。这是嵌入在JDK中的编译器。通过这个命令,可以将java源文件转换成class文件。这个javac编译器是JRE相比JDK缺乏开发功能的决定性因素!!下面用一个简单的例子来看看,开发者编写的java代码在完整的JDK架构流程下,是通过JDK、JRE和JVM运行的。可以看到java代码运行的简单例子。通过JDK中的javac命令,我们可以将java源代码编译成class文件。前面说了,这个class文件就是最后放到JVM运行的文件。我们把java源码到class文件的过程称为编译阶段,将class文件运行到JVM中得到结果的阶段称为运行阶段。因此,如果只有JRE而没有完整的JDK,就相当于缺少编译源代码的关键工具。你只能依靠别人传递的编译类代码来运行程序,无需修改和开发。能力。聪明的你很快就会发现,由于虚拟机其实是需要class文件来运行的,所以它并不关心前面用什么语言,只要支持生成JVM可以识别的字节码即可.难道……没错,恭喜你发现了JVM虚拟机的“跨语言”特性**。很多语言都依赖这个特性,自己编译源代码生成class文件,基于JVM虚拟机运行。比较常用的有Scala和Kotlin等,它们甚至可以和Java语言相互调用,因为最后都要编译成class文件在虚拟机中运行,所以即使是不同的语言在源码阶段,经过编译器,大家都变成了一样的字节码。多语言转换为字节码当然,如果再极端一点,由于class文件本质上就是一个二进制文件,只要你足够强大,可以手写出你需要的二进制文件,就不再需要编译器了(的狗头救了他的命)。不少读者马上要说:“我们是来学技术的,不是来学仙术的。”别笑,直接改字节码可不是天上掉馅饼的魔术,而是真正的技术。就像我们熟悉的lombok,我们可以根据自己写的注解生成字节码,实现字节码的修改和增强(但是lombok也使用了编译器的一些特性,在编译阶段触发操作)。同样,一些字节码增强技术如ASM,也是通过直接操作字节码来实现的。字节码增强技术可以实现热部署等操作,让您修改代码无需重启服务即可生效;还可以实现日志注入等功能,在不改变客户端调用方式的情况下完成添加缓存或给指定方法添加缓存。日志功能。但对于大多数普通开发者来说,编译器还是必不可少的。在编译阶段,当调用javac命令时,会触发java代码的编译过程,将.java文件编译成.class二进制文件。那么,在编译器中,源代码是如何一步步变化的呢?注意:javac是javac编译器的内置命令,但javac并不是市面上唯一的编译器,其他一些厂商也根据java标准开发了自己的编译器。比如Eclipse的ecj(theEclipseCompilerforJava)等等。只是大部分人使用的是JDK自带的javac编译器,所以下面的讨论都是基于javac编译器。可以理解为编译的过程就是“编译”和“翻译”。编辑:将java源代码的结构组织成合适的格式,包括编译时的抽象语法树和符号表,最后将源代码编码成class文件。翻译:解析源代码中的语义,准确翻译成另一种形式(字节码)。这一步既保证了原始格式的正确(Java源代码中的语法是正确的),又保证了翻译后的字节码与源代码表达的意思一致。也就是说,编译过程必须保证输入格式符合Java语言规范,输出格式符合Java虚拟机规范。这个过程说起来复杂,但读者可以回忆一下自己经历过的代码编译失败场景。每一次编译失败都是编译器默默工作的结果。在编译过程的不同阶段可能会发现并抛出不同的错误。出去。接下来,我们会一步步告诉大家编译的具体步骤,以及在编译过程的各个阶段抛出的不同编译异常。编译过程的调用图看起来像很多东西。概括起来可以分为以下几个步骤:1.词法分析&句法分析词法分析是第一步。主要作用是将源代码的字符流转化为一个Token集合,Token是指代码中具有独立语义且不能再分割的token。这里需要注意的是,一个Token并不是指单个字符,而是一个有实际意义的词。而且,编译器还会识别不同的词法类型,并为它们分配相应的Token类型。比如int会被识别为Token.INT,运算符也会被赋值给对应的Token类型。比如+就是Token.PLUS:词法分析代码解析成一系列Token集合之后,接下来就是进行语法分析了。语法分析是根据解析出的Token集合,解析出抽象语法树(AbstractSyntaxTree,AST),它包含了java代码的层次结构。小知识:在NLP等领域的研究中,语法树也是分析语法规则和原理的重要手段,这里不再赘述。语法分析1按照这种结构,代码中的所有变量、方法甚至注释等各种信息都可以分层显示。构建AST的过程将确定令牌的类型是否与其在树中的位置匹配。我们很好地理解了这一步。当你使用关键字作为变量名时,编译会失败,就在这一步被卡住了。比如你使用这样一段代码编译:publicclassHello{publicstaticvoidmain(String[]args){Stringenum="world";System.out.println("Helloworld");}}会报如下错误:error:asofrelease5,'enum'是关键字,可能不能用作标识符因为enum是关键字,在构建语法树时,发现关键字出现在标识符的位置,这是不可能的!因此,AST树构建失败,编译时报错。词法分析&句法分析是对源代码中文本的抽象。.java源码中的文本结构根据编译器的特定规则进行拆分分析,为后续的编译工作做铺垫,后续的操作都离不开这个AST。2.填充符号表符号表是由符号地址(位置)和符号信息组成的“表”,其中存储了标识符对应的类型、作用域等。这里说是“表”,可能会给读者造成一些误解。其实它并不是我们想象中的二维表,而是更接近于hashTable的键值对结构。符号表可以由数组、树组成,可以用形结构或栈等多种结构来实现。这个符号表可以在后续的很多步骤中发挥作用,例如:staticcharx;intfoo(){intx;愚蠢的计算机不能那么快地分辨出区别。为了在不使用冲突的情况下解析符号和类型时区分它们的范围,需要通过符号表来记录它们之间的关系。填充符号表的过程可以描述为:将每个AST的顶级节点放入待处理链表中,逐一处理;将所有类符号(类声明、名称)输出到外作用域的符号表中;如果有package-info.java文件(描述了整个包的信息和包中的常量),则将其顶级节点放入待处理列表中;阐明泛型的真实类型;如果没有Any构造函数,则添加一个默认的无参构造函数;将类中的符号导入到类自己的符号表中。这一步有点抽象,大家不用太纠结细节。您可以了解大致的过程和目的。你只需要明白,这一步是生成一个符号表,记录类中符号的类型和属性,方便后续在流程中的应用。强调5,学过java基础的都知道,如果一个类没有定义构造函数,那么默认会构建一个没有参数的默认构造函数,添加默认构造函数的操作也是在填充符号表的时候完成的.为什么?很简单,因为类的构造方法也需要记录在符号表中,不能为空。既然你没有指定,那我给你放一个默认的空参构造函数,然后在符号表中记录一下。相关源码在这里,有兴趣的可以深挖。http://hg.openjdk.java.net/jdk8u/jdk8u/langtools/file/2baeb96fa198/src/share/classes/com/sun/tools/javac/comp/Enter.java3.JDK5以来的注解处理,Java提供对注解的支持,现在在程序中使用注解是很常见的操作。但是,需要注意的是,并不是所有的注解都在编译期间起作用。我们平时使用反射处理的注解主要是指运行时注解。Runtimeannotations在编译过程中不受影响,编译后的class文件仍会保留,最终在class文件从JVM运行时生效。编译时注解是指@Retention(RetentionPolicy.SOURCE)定义的注解,在编译时进行处理。这种注解不会保留在类文件中。听上去很懵懂,但实际上,编译过程中的注解处理这一步其实已经无意中触及了很多次。比如常用的lombok就在这一步起作用。lombok在编译时使用了注解处理的方法,所以当我们编译带有lombok注解的.java文件时,打开生成的class文件可以看到lombok相关的注解已经消失了,对应的getter和setter方法都有了被注入到类文件中。上图中右图显示的不是一个class文件,而是相当于添加了lombok注解的源码。左右两边的代码生成的字节码是一致的。在这一步,lombok的注解处理器生效,增强了我们前面提到的抽象语法树AST。首先找到@Data注解所在类对应的语法树(AST),然后修改语法树(AST),添加getter和setter方法定义的对应树节点,实现我们需要的功能。这一步也是为数不多的,编译器给程序员留下了自己写代码的机会,影响了源代码的编译过程。注解处理完成后,可能会再次产生新的符号,所以如果进行了注解处理,还需要重新进行符号表的解析和填充操作(返回步骤2)。4.语义分析语义分析听起来和词法分析&句法分析的第一步很像,但实际上有很大的不同。我们用成语来类比解释一下:敖丙说:“你今天吃饭了吗?”。词法分析这一步相当于把这个句子拆分成了you,eat,today,meal,le,do,and?等词。每个字都不错但是在语义分析阶段,我们根据规则检查这句话的语义,发现这句话其实并不流畅。回到编译过程来解释,语义分析的作用是从结构和规则上检查源代码,包括声明检查和类型检查等。这里我们用周志明老师书中的一个例子来说明:假设有以下三个变量定义:inta=1;booleanb=false;charc=2;intd=a+c;inte=b+c;charf=a+c;这段代码可以通过第一步的词法分析和语法分析,形成正确的AST,但是在语义分析时会报错。因为编译器发现对变量e和f的运算不符合规范,运算中涉及的两个值的类型与运算符的逻辑不匹配。语义分析更进一步检查变量在上下文中的规范性,比如变量是否声明过,变量的数据类型是否匹配它参与的操作等等。如果要细分语义分析,可以分为以下几个子阶段:4.1注解检查就是刚才说的这一步,检查变量是否提前声明,操作类型是否匹配,以及对这个的处理步骤会影响AST结构:注意如图所示,我们首先要检查变量a是否已经声明(declarationcheck),还要检查a的类型(typecheck),这两个都需要用到什么我们之前填写??过。符号表,从符号表中查询变量的范围和类型,完成语义分析的检查。然后判断运算符和另一个运算值的类型,检查左右运算值的类型是否匹配,是否可以参与运算。看,这里AST和符号表一起工作。另外,注解检查步骤中有两个非常重要的操作:泛型方法类型的推导:在这一步中,需要明确泛型方法传递的真实类型是什么;常量折叠(ConstantFolding):这是一个非常有趣的操作,它会执行一些简单的常量计算,例如:inta=1+2;在这一步会优化成a=3,优化后还是可以看到int,a,1,+,2.;这些标记,但是这个表达式的值已经被计算并标记在AST上。也就是说,当前的AST不仅保留了表达式的结构,还记录了表达式的结果。字节码后续在虚拟机中执行时,由于编译时对常量折叠的优化,inta=3和inta=1+2的运行效率其实是一样的,因为执行的是这个常量的操作在编译时就已经完成了,运行时不会消耗额外的处理时间。一般的代码优化是在字节码生成后在虚拟机的解释器中进行的。常量折叠是javac编译器对源码做的极少数优化措施之一,也是编译时为数不多的对代码进行优化的操作之一。4.2数据流分析数据流分析是注解检查之后的进一步检查,主要检查局部变量在使用前是否确定性赋值,声明有返回值的方法是否有确定性返回值等。值得请注意,在此步骤中还会检查final变量的不可重新分配属性。如果一个final变量被重新赋值,编译器会发现并报错。正是由于这个特性,带有final关键字的局部变量只会在编译时进行验证,而不会对运行时产生任何影响。有如下例子://方法1publicvoidaobingTest(finalintnezha){finalinta=0;}//方法2publicvoidaobingTest(intnezha){inta=0;}这两种方法生成的字节码完全一样,没有任何区别。所以final的不可重复赋值的所有限制在编译时都已经检查过了。声明为final的局部变量如果被重复赋值,编译时会报错。如果最终重复赋值没有发现错误,则生成成功。字节码。因此,对于运行时来说,局部变量是否声明为final,不会有验证步骤(因为无论局部变量是否受final限制,生成的字节码都是一样的,不会保留在字节码中局部变量是否声明为final的信息)。5.解析语法糖简单来说,语法糖就是程序员方便的一种写法。这种语法不会对最终结果产生实际影响,但可以减少程序员的工作量。比如java中的自动拆箱打包功能,foreach循环功能等,都是为了程序员写出更简洁的代码而封装的语法糖。但是当程序运行时,这样的语法糖是计算机无法识别的。因此,需要在编译阶段解决语法糖,将语法恢复到原来“笨拙”的样子。例如,将包装器类型拆分为普通类型,并将增强的for循环替换为普通的for循环。6.生成Class文件终于到了生成最终需要的class文件的步骤了。这一步将前面构建的语法树、符号表等信息转化为字节码指令写入class文件中。此外,在语法树中添加了两个更重要的方法,和方法。请注意,这两个看起来像init的方法并没有引用类中的构造函数。该方法是一个类的构造函数,它的作用是初始化所有的静态变量并执行用static{}包裹的代码块,并且这个方法的收集是顺序的:这些类相关的初始化代码是按顺序收集的,函数是一起生成的并在加载类时按顺序运行,所以该方法相当于将静态代码打包在一起,等待后续统一执行。父类静态变量初始化父类静态语句块子类静态变量初始化子类静态语句块方法实际上是一个实例构造函数,它的作用是初始化类中的成员变量,比如成员变量的赋值操作,以及{}的代码由符号包裹的块,这些方法将被收敛到方法中,成为与对象初始化相关的方法。这个方法的集合也是有顺序的:父类代码块父类构造函数子类变量初始化子类代码块子类构造函数父类变量初始化一般来说,这两种方法都是把代码块和变量初始化的步骤分为按照静态和非静态两大类,按照一定的顺序打包,等待合适的时机执行。对于方法,合适的执行时间是类加载的时候;对于方法,执行时间是类的对象是新的时。由于类加载过程优先于对象实例化过程,所以方法必须在方法之前执行。因此,它们完整的执行顺序是:父类静态变量初始化父类静态语句块子类静态变量初始化子类静态语句块父类变量初始化父类语句块父类构造函数子类变量初始化子类语句块子类有构造函数被发现了吗?这是一道常见的面试题:“java代码的加载顺序”的标准答案。这个问题的本质在于,Java代码之所以能够保持加载顺序,是因为在生成class文件的时候,按照顺序将拼接的和方法添加到class文件中,然后执行在后续的运行过程中依次进行。你知道在以后的面试中怎么回答这个问题吗?除了生成构造器之外,在生成class文件的时候还会优化一些代码逻辑的实现,比如将字符串的+operation操作替换为StringBuffer或者StringBuilder的append()方法。至此,java源码到class文件的编译过程就告一段落了。由于篇幅原因,今天我们就简单说一下Java代码编译成class文件的过程。后面我们会继续研究class文件中的细节和字节码最终运行在JVM中的过程。有的思路是对的,还有一个问题可能是大家理解上的误区。很多人认为类文件=字节码,这是错误的,类文件不等于字节码。我们可以从类文件的结构中窥见端倪。类文件中记录了以下信息:结构信息:类文件格式的版本号;元数据:主要对应Java源码中的“声明”和“常量”信息,包括类声明信息、类中属性字段和方法的声明信息、常量池等;方法信息:主要对应Java源码中“语句”和“表达式”对应的信息,包括字节码、异常处理、设备表的大小、操作数栈、局部变量区等;现在很清楚了,字节码是Class文件的一个子集,它只是class文件的众多组成部分之一。乖,别再认为Class文件就是字节码了。
