Java 代码执行原理

专注于Java领域优质技术,欢迎关注

作者 | Alan

来源 | cnblogs.com/wangjiming/p/10455993.html

对于任何一门语言,要想达到精通的水平,研究它的执行原理(或者叫底层机制)不失为一种良好的方式。

在本篇文章中,将重点研究java源代码的执行原理,即从程序员编写JAVA源代码,到最终形成产品,在整个过程中,都经历了什么?每一步又是怎么执行的?执行原理又是什么?

一、编写java源程序

java源文件:指存储java源码的文件。

先来看看如下代码:

//MyTest被public修饰,故存储该java源码的文件名为MyTest

public class MyTest {

public static void main(String[] args){

System.out.println(\”Test Java execute process.\”);

}

}

//由于MyTest被public修饰了,故Class A不能用public修饰

class A{}

//由于MyTest被public修饰了,故Class B不能用public修饰

class B{}

1、java源文件名就是该源文件中public类的名称

2、一个java源文件可以包含多个类,但只允许一个类为public

二、编译java源代码

当java源程序编码结束后,就需要编译器编译。

安装好jdk后,我们打开jdk目录,有两个.exe文件,即javac.exe(编译源代码,xxx.java文件) 和 java.exe(执行字节码,xxx.class文件).

如下图所示:

1、切换到MyTest.java文件夹

2、javac.exe编译MyTest.java

编译后,发现e:\\Blogs 目录多了以class为后缀的文件:A.class,B.class和MyTest.class

Tip:当javac.exe编译java源代码时,java源代码有几个类,就会编译成一个对应的字节码文件(.class文件)

其中,字节码文件的文件名就是每个类的类名。需要注意的是,类即使不在源文件中定义,但被源文件引用,编译后,也会编程相应的字节码文件。

如类A引用类C,但类C不定义在类A的源文件中,编译后,类C也被编译成对应的字节码文件C.class

Tips:关注微信公众号:Java后端,每日获取技术博文推送。

三、执行java源文件

执行java源文件,用java.exe执行即可

到现在,java源程序基本执行结果,并正确打印我们期望的结果,那么,如上的步骤,我们可以总结如下:

如上总结,已经抽象化了在JVM中的执行。接下来,我们将分析字节码文件(.class文件)如何在虚拟机中一步一执行的。

四、JVM如何执行字节码文件

1、装载字节码文件

当 .java 源码被 javac.exe 编译器编译成 .class 字节码文件后,接下来的工作就交给JVM处理。

JVM首先通过类加载器(ClassLoader),将class文件和相关Java API加载装入JVM,以供JVM后续处理。

在该阶段中,涉及到如下一些基本概念和知识。

1)JDK,JRE和JVM关系

  • JDK(Java Development Kit),Java开发工具包,主要用于开发,在JDK7前,JDK包括JRE
  • JRE(Java Runtime Environment),Java程序运行的核心环境,包括JVM和一些核心库
  • JVM(Java Virtual Machine),VM是一种用于计算设备的规范,它是一个虚构出来的计算机,是通过在实际的计算机上仿真模拟各种计算机功能来实现的,是JRE核心模块。

2)JVM

JVM是一种用于计算设备的规范,它是一个虚构出来的计算机,是通过在实际的计算机上仿真模拟各种计算机功能来实现的。

Java虚拟机的主要任务是装载class文件,并执行其中的字节码,不同的Java虚拟机中,执行引擎可能有不同的实现。

大致有如下几种引擎:

  • 一次性解释字节码引擎
  • 即时编译引擎
  • 自适应优化器

关于虚拟机的实现方式,采用软件方式、硬件方式和软件硬件结合方式,这个要根据具体厂商而定。

3)什么是ClassLoader

虚拟机的主要任务是装载class文件并执行其中的字节码,而class文件是由虚拟机的类加载器(ClassLoader)完成的,在一个Java虚拟机中有可能存在多个类加载器。

任何java运用程序,可能会使用两种类加载器,即启动类加载器(bootstrap)和用户自定义类加载器。

启动类加载器是Java虚拟机唯一实现的一部分,它又可分为原始类装载器,系统类装载器或默认类装载器。它的主要作用是从操作系统的磁盘装载相应的类,如Java API类等。

用户自定义装载类,即按照用户自定义的方式来装载类。

2、将字节码文件存储在JVM内存区

当JAVA虚拟机运行一个程序时,它需要内存来存储许多东西。

比如如字节码,程序创建的对象,传递给方法的参数,返回值,局部变量以及运算的中间结果等,这些相关信息被组织到“运行时数据区”。

根据厂商的不同,在Java虚拟机中,运行时数据区也有所不同。有些运行时数据区由线程共享,有些只能由某个特定线程共享。

运行时数据区大致可分几个区:方法区,堆区,栈区,PC寄存器区和本地方法栈区。

在该阶段中,涉及到如下基本概念和知识。

1)方法区

方法区用来存储解析被加载的class文件的相关信息。

当虚拟装载一个class文件后,它会从这个class文件包含的二进制数据中解析类型信息,然后将该相关信息存储到方法区中。

2)堆

堆是用来存储相关引用类型的,如new对象。当程序运行时,虚拟机会把所有该程序在运行时创建的对象都放到堆中。

3)PC寄存器

PC寄存器主要用来存储线程。当新创建一个线程时,该线程都将得到一个自己的PC寄存器(程序计数器)以及一个java栈。

Java虚拟机没有寄存器,其指令集使用Java栈来存储中间数据。

4)栈区

栈区主要用来存储值类型的,如基本数据类型。需要注意的是,String为引用类型,是存在堆中的。

Java栈是由许多栈帧组成的,一个栈帧包含一个Java方法调用的状态,当线程调用一个方法时,虚拟机压入一个新的栈帧到该线程的Java栈中,当该方法返回时,这个栈帧从Java栈中弹出。

3、执行引擎与运行时数据区交互

运行时数据区为执行引擎提供了执行环境和相关数据,执行引擎通过与运行时数据区交互,从而获取执行时需要的相关信息,存储执行的中间结果等

4、执行引擎与本地方法接口

当要执行本地方法时,执行引擎将调用本地方法接口来获取相关OS本地方法。

需要注意的是,本地方法与操作系统强耦合的。

5、JVM在具体操作系统上执行

JVM通过调用本地接口来获取本地方法,从而实现在具体的平台上执行。比如在Linux系统上执行,在Window系统上执行和在Unix系统上执行。

最近发现了一个非常适合小白人工智能入门的教程,不仅通俗易懂而且还很风趣幽默。忍不住分享一下给大家。点下面链接可以跳转到教程。

听说你还不知道Java代码是怎么运行的?

作者:Jay_huaxiao

作为一名Java程序员,我们需要知道Java代码是怎么运行的。最近复习了深入理解Java虚拟机这本书,做了一下笔记,希望对大家有帮助,如果有不正确的地方,欢迎提出,感激不尽。

java 代码运行主要流程

本文主要讲解流程如下:

  • java源文件编译为class字节码
  • 类加载器把字节码加载到虚拟机的方法区。
  • 运行时创建对象
  • 方法调用,执行引擎解释为机器码
  • CPU执行指令
  • 多线程切换上下文

编译

我们都知道,java代码是运行在Java虚拟机上的。但是java是一门面向对象的高级语言,它不仅语法非常复杂,抽象程度也非常高,并不能直接运行在计算机硬件机器上。

Java虚拟机(Java Virtual Machine 简称JVM)是运行所有Java程序的抽象计算机,是Java语言的运行环境。

因此,在运行Java程序之前,需要编译器把代码编译成java虚拟机所能识别的指令程序,这就是Java字节码,即class文件。

所以,Java代码运行的第一步是:把Java源代码编译成.class 字节码文件。

类加载

在Class文件中描述的各种信息,需要被加载到虚拟机之后才能运行和使用。因此,需要把class字节码文件加载到Java虚拟机来。

虚拟机把描述类的数据从 Class 文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的 Java 类型,这就是虚拟机的类加载机制。

加载

加载阶段,虚拟机需要完成以下3件事情:

  • 通过一个类的全限定名来获取定义此类的二进制字节流。
  • 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
  • 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口

加载阶段完成后,这些二进制字节流按照虚拟机所需的格式存储在方法区之中。

验证

为了确保Class文件的字节流中包含的信息符合当前虚拟机的要求,不会危害虚拟机的安全,Java虚拟机对输入的字节流走验证过程。

验证阶段包括四个阶段:文件格式验证、元数据验证、字节码验证、符号引用验证。

  • 文件格式验证: 验证字节流是否符合Class文件格式规范,如:是否以魔数0xCAFEBABE开头。
  • 元数据验证: 对字节码描述的信息进行语义分析,如:这个类的父类是否继承了不允许被继承的类(被final修饰的类);
  • 字节码验证: 主要目的是通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的。如:保证跳转指令不会跳转到方法体以外的字节码指令上。
  • 符号引用验证: 发生在虚拟机将符号引用转化为直接引用的时候,如:校验符号引用中通过字符串描述的全限定名是否能找到对应的类。

准备

准备阶段是正式为类变量分配内存并设置类变量初始值,这些变量所使用的内存都将在方法区中进行分配。如:

public static int value =123;

变量value在准备阶段过后的初始值是0而不是123。

解析

解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。

比如:com.User类引用com.Tool类,在编译时,User类不知道Tool类的实际内存地址,因此只能使用符号com.Tool(假设)来表示。而在类加载加载User类的时候,可以通过虚拟机获取Tool类的实际内存地址,因此便可以将符号com.Tool替换为Tool类的实际内存地址,即直接引用地址。

解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符 7 类符号引用进行。

初始化

到了初始化阶段,才真正开始执行类中定义的Java字节码。在这个阶段,则根据程序员通过程序制定的主观计划去初始化类变量和其他资源。

创建对象

Java虚拟机是如何执行字节码的呢?我们先来看一下运行时创建对象。

Java是面向对象的编程语言,程序的运行是以对象为调用单位的。

  • 字节码文件加载到虚拟机的方法区后,在程序运行过程,通过 class字节码文件创建与其对应的对象信息 。
  • 创建对象的方式有:new关键字,反射等。
  • Java堆内存是线程共享的区域,创建后的对象信息就保存在Java堆内存中。

方法调用

JVM的调用单位是对象,但是真正执行功能性的代码还是对象上的方法。

在运行过程中,每当调用进入一个java方法,java虚拟机会在当前线程的java方法栈中生成一个栈帧,用以存放局部变量以及字节码的操作数。方法栈内存是线程私有的,每个线程都有自己的方法栈。如果对应的方法是本地方法,则对应的就是本地方法栈。

java运行时数据区域如下:

解释

当调用Java对象的某个方法时,JVM执行引擎会将该方法的字节码文件翻译成计算机所能识别的机器码,机器码信息保存在方法区中。翻译有解释执行和即时编译两种方式。

两种翻译方式的区别如下:

解释执行来一行代码,解释一行,大部分不常用的代码,都是采用这种方式。

即使编译

对于部分热点代码,将一个方法包含的所有字节码翻译成机器指令,以提高java虚拟机的运行效率。

即时编译是建立经典的二八定律上,即20%代码占据了80%的计算资源。

执行指令

  • Java程序被加载入内存后,指令也在内存中了。
  • 指令的指令寄存器IP,指向下一条待执行指令的地址。
  • CPU的控制单元根据IP寄存器的指向,将主存中的指令装载到指令寄存器,这些加载的指令就是一串二进制码,还需要译码器进行解码。
  • 解码后,如果需要获取操作数,则从内存中取数据,调用运算单元进行计算。

多线程上下文切换

CPU一通上电,就会周而复始从内存中获取指令、译码、执行。

  • 为了支持多任务,CPU 将执行时间这个资源划分成时间片,每个程序执行一段时间。
  • java虚拟机的多线程是通过线程轮流切换分配处理执行时间的方式来实现的,在任何一个确定的时刻,一个处理器(对于多核处理器来说是一个内核)都只会执行一条程序中的指令。
  • 假设当前线程在运行中,CPU分配的时间执行完了,总得保存运行过的结果信息吧,要不然白白浪费之前的工作了,因此,程序计数器(PC寄存器)作用体现出来了,它是一块较小的内存空间,线程私有,可以看作当前线程执行的字节码的行号指示器。当CPU又给它分配时间跑的时候,可以把数据恢复,接着上一次执行到的位置继续执行就可以了。

原文:https://juejin.im/entry/5e6ccc05e51d4527110aa25f

Java类是如何加载的?

有小伙伴最近在面试过程中遇到这样一个问题:

Java 中的类是如何加载的?

这个问题还是很有意思,今天松哥来尝试和大伙梳理一下。

整体上来说,类的加载主要是下面这几个步骤:

上面这张图就是一个类的完整生命周期了,一共要经历加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)、卸载(Unloading)七个不同的步骤。

这七个步骤中,验证、准备和解析一般又统一称之为 Linking。

这是整体的流程,接下来,松哥就和大家来分析每一个具体的步骤都干了啥。

首先第一步 Loading,也就是加载类。

这里如果被面试官细问,有两个方向:

  1. 什么时候加载?
  2. 怎么加载?

先说类的加载时机。

如果需要一个权威的文档来说明问题,抱歉,官方没有任何文档来说明类在什么时候会被加载。但是,官方文档给出了六种类必须进行初始化的场景,毫无疑问,如果需要对类进行初始化,那么就必须先 Loading。

这六种场景分别是:

  1. new 一个类或者使用某一个类的静态属性/静态方法,给某个类的静态属性赋值等等,不过对于被 final 修饰的的 static 变量除外。
  2. 通过反射调用某个类的时候。
  3. 当要初始化某个类,但是发现其父类尚未初始化,那么就要去初始化父类(如果一个接口在初始化的时候发现其父类未初始化,这个时候并不会初始化其父类,只有在真正用到了其父类的时候,才会初始化)。
  4. main 方法所在的主类。
  5. 对于含有 default 方法的接口,如果该接口的实现类需要进行初始化,那么就会触发该类的加载。
  6. 最后一种情况和动态语言相关的,跟我们 Java 关系不大,这里就不讨论了(因为 Java 虚拟机不仅能跑 Java,也能跑 Groovy、Kotlin 等,所以虚拟机支持的内容会更加广泛一些)。

只有这六种场景会触发类的初始化,凡是不符合这六种情况的,都不会触发类的初始化。

这是类的加载时机问题。

那么怎么加载呢?这就涉及到类加载的双亲委派问题,这个问题网上有很多文章介绍,内容本身也不算难,这里松哥就不啰嗦了。

通过双亲委派找到具体的类加载器之后,接下来就要开始执行加载了。

加载主要干三件事。

  1. 通过类的全限定名来获取定义该类的二进制字节流。

全限定名也就是类的全路径,例如 org.javaboy.HelloWorld 这种,通过这个名字去获取类的二进制字节流。去哪里获取呢?可以从磁盘上获取,这是我们最容易想到的,除了从磁盘上获取之外,也可以从网络获取,甚至可以在运行时通过动态计算生成,我们所熟知的 Java 动态代理就属于这种情况。

  1. 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
  2. 在内存中生成一个代表这个类的 Class 对象,作为方法区这个类的各种数据的访问入口。

Linking 这个环节分为三个步骤,分别是:

  1. Verification
  2. Preparing
  3. Resolution

我们分别来看。

验证这个环节就要就是检查输入的二进制字节流是否符合要求。

正常来说,我们的 Java 代码写完之后会进行编译,有问题的话,编译阶段就报错了,等不到类加载阶段。

不过由于 JVM 读取的二进制字节流不一定是通过 Java 源代码编译后获取到的,也有可能是其他语言编译得到的,甚至可能有某个大神直接用二进制编辑器 0、1、0、1 这样敲出来的,所以站在 JVM 的角度,必须要对输入的二进制流进行校验,确保读取的数据没有问题。

验证的内容主要有这些:

  1. 魔数是否以 0xCAFEBABE 开始。

魔数是 Class 文件的开始标记,这个位置是一个固定的字符,CAFE BABE。

松哥这里随便用二进制编辑器打开一个 Class 文件给大家看下:

  1. 主次版本号是否在当前 Java 虚拟机所能接受的范围内。

CAFEBABE 后面紧跟着的是次版本号,次版本号后面紧跟着的是主版本号。以上图为例,次版本号为 0,主版本号 3D 转为十进制是 61。高版本的 JDK 可以向下兼容以前旧版本的 Class 文件,但是无法运行以后版本的 Class 文件,Class 文件的主版本号和 JDK 的关系如下图。

  1. 常量池中是否有不被支持的常量类型
  2. 当前类是否存在父类(所有类都应当有父类)?当前类是否继承了 final 类(不应当继承 final 类)?如果当前类不是抽象类,是否实现了其父类或者接口中要求实现的方法等等。
  3. 对字节码进行校验。
  4. 符号引用能否找到对应的类,符号引用中涉及到的类、字段、方法等的访问性是否满足要求。

由于验证这块的环节非常复杂,流程也多,因此,如果自己有办法确认自己的代码是 OK 的,那么也可以使用 -Xverify:none 来关闭大部分的类验证,这样可以缩短虚拟机加载类的时间。

这里检查的内容其实非常多,官方文档足足有 100 多页,松哥这里就不逐一列举了,小伙伴们主要是知道这里的核心目的是检查并确保读入到内存中的字节流是没有问题的。

这一阶段主要是给类中的静态属性设置初始值。

例如定义了 public static int a = 5;,那么就会为该变量在内存中(堆)分配存储空间,并设置初始值(int 类型初始值是 0),注意这个时候并不会将 a 设置为 5,因为还没到最终的初始化阶段。

但是如果属性在定义的时候就已经定义为常量了,例如 public final static int a = 5;,则会直接给属性最终赋值。

接下来是解析,解析主要是将常量池内的符号引用替换为直接引用的过程。

什么是符号引用呢?

符号引用是以一组符号来描述引用的目标,因为在编译阶段,虚拟机并不知道所引用的类的具体位置,因此就使用符号引用来代替。符号可以是任何字面量,只要在使用时能够无歧义的定位到目标即可。

什么是直接引用呢?

直接引用就是一个可以直接指向目标的指针,相对偏移量等。

所以,符号引用转为直接引用其实就是原本是通过字符去引用某个变量,现在直接改为通过内存地址来访问该变量了。

解析的符号主要有七种,分别是类或者接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符。

松哥这里以一个类的解析为例,和小伙伴们简单说明一下这个解析过程。

假设当前类是 C1,当前类中存在一个符号引用 F,我们要将这个符号引用 F 解析为一个类 C2,那么流程是这样:

  1. 如果 C2 是一个普通对象而不是接口,那么 JVM 会把代表 F 的全限定名传递给 C1 的类加载器去加载这个类,当然,这个加载过程又是一整套的类加载流程。
  2. 如果 C2 是一个对象数组,那么首先按照第一步的方式先去加载数组中的元素类型,然后由虚拟机去生成一个代表该数组的对象。

就这样简单两个步骤,当然,在这个流程中,也会去检查 C1 是否具备对 C2 的访问权限,这个主要是检查 module 访问权限和类的访问权限。

接下来就是类的初始化阶段了,如果想让这个阶段更加具象化,那么这个阶段实际上是调用类的 clinit 方法,这个方法并不是开发者写的,而是由 javac 编译器自动生成的。

javac 自动生成的 clinit 方法主要是将静态变量赋值和静态代码块的相关内容合并起来。在执行 clinit 方法的过程中,并不会显式的调用父类的 clinit 方法,而是由虚拟机去确保在执行子类的 clinit 方法之前,父类的 clinit 方法已经被执行过了。

例如为 static 类型的变量赋值,就是在这个环节完成的。

最后就是 Using 和 Unloading 了,这块就简单了,不多说。

本文作者及来源:Renderbus瑞云渲染农场https://www.renderbus.com

点赞 0
收藏 0

文章为作者独立观点不代本网立场,未经允许不得转载。