来源|阿里开发者公众号
作者|伍玉莹(姬无)
关注【阿里开发者】公众号查看更多精品技术文章或精品电子书。
一、JAVA语言的特点
在进入正题之前,先问一个老生常谈的问题,相较于C,JAVA语言的优势是什么?相信学过JAVA的人都知道,无论是大学时的第一堂课还是JAVA相关书籍的第一章也都会讲到:一次编写、到处运行;真正意义上的实现了跨平台。那再问一个问题,为什么Java可以跨平台?大多数人都知道Java可以跨平台得益于 JVM(java虚拟机)。在这之前,我了解到的java跨平台得益于不同版本的JVM,那么它的底层原理是什么呢?“一次编译,到处运行” 是Java的跨平台特性。像 C 、C++ 这样的编程语言没有它。通过下面的介绍,相信你会有一个近一步的了解。Java是一种可以跨平台的编程语言。首先,我们需要知道什么是平台。我们把CPU处理器与操作系统的整体叫平台。CPU相当于计算机的大脑,指令集是CPU中用来计算和控制计算机系统的一套指令的集合。指令集分为精简指令集(RISC)和复杂指令集(CISC)。每个CPU都有自己的特定指令集。要开发一个程序,我们必须首先知道程序运行在什么CPU上,也就是说,我们必须知道CPU使用的指令集。操作系统是用户与计算机之间的接口软件。不同的操作系统支持不同的CPU。严格来说,不同的操作系统支持不同的CPU指令集。但问题是,原来的Mac操作系统只支持PowerPC,不能安装在英特尔上。我们该怎么办?因此,苹果必须重写其Mac操作系统来支持这一变化。最后,我们应该知道不同的操作系统支持不同的CPU指令集。现在windows、Linux、MAC和Solaris都支持Intel和AMD CPU指令集。如果你想开发一个程序,首先应该确定:
- CPU类型,即指令集类型;
- 操作系统;我们称之为软硬件平台的结合。也可以说“平台=CPU+OS”。而且由于主流操作系统支持主流CPU,有时操作系统也被称为平台。
二、如何实现跨平台
通常,我们编写的Java源代码在编译后会生成一个Class文件,称为字节码文件。Java虚拟机负责将字节码文件翻译成特定平台下的机器代码,然后运行。简言之,java的跨平台就是因为不同版本的 JVM。换句话说,只要在不同的平台上安装相应的JVM,就可以运行字节码文件(.class)并运行我们编写的Java程序。在这个过程中,我们编写的Java程序没有做任何改动,只是通过JVM的“中间层”,就可以在不同的平台上运行,真正实现了“一次编译,到处运行”的目的。JVM是跨平台的桥梁和中间件,是实现跨平台的关键。首先将Java代码编译成字节码文件,然后通过JVM将其翻译成机器语言,从而达到运行Java程序的目的。因此,运行Java程序必须有JVM的支持,因为编译的结果不是机器代码,必须在执行前由JVM再次翻译。即使您将Java程序打包成可执行文件(例如。Exe),仍然需要JVM的支持。注意:编译的结果不是生成机器代码,而是生成字节码。字节码不能直接运行,必须由JVM转换成机器码。编译生成的字节码在不同的平台上是相同的,但是JVM翻译的机器码是不同的。
三、JVM简介
JVM------Java Virtual Machine.JVM是Java平台的基础,与实际机器一样,他有自己的指令集(类似CPU通过指令操作程序运行),并在运行时操作不同的内存区域(JVM内存体系)。Java虚拟机位于操作系统之上(如下图所示),将通过JAVAC命令编译后的字节码加载到其内存区域,通过解释器将字节码翻译成CPU能识别的机器码行。每一条Java指令,Java虚拟机规范中都有详细定义,如怎么取操作数,怎么处理操作数,处理结果放在哪里。
JVM是运行在操作系统之上的,它与硬件没有直接交互。
四、JVM的内存结构
JAVA源代码文件通过编译后变成虚拟机可以识别的字节码,JAVA程序在执行时,会通过类加载器把字节码加载到虚拟机的内存中(虚拟机的内存是一个逻辑概念,相当于是对主内存的一个抽象,实际上真实的数据还是存放在主存中),详见下图。
Java 虚拟机在执行 Java 程序的过程中会把它管理的内存划分为若干个不同的数据区域。每个区域都有各自的作用。分析 JVM 内存结构,主要就是分析JVM 运行时数据存储区域。JVM 的运行时数据区主要包括:堆、栈、方法区、程序计数器等。而 JVM 的优化问题主要在线程共享的数据区中:堆、方法区。
4.1、方法区
又称非堆(non-heap),方法区用于存储已被虚拟机加载的类信息,常量、静态变量,即时编译后的代码等数据。方法区中最著名的就是CLASS对象,CLASS对象中存放了类的元数据信息,包括:类的名称、类的加载器、类的方法、类的注解等。当我们new一个新对象或者引用静态成员变量时,Java虚拟机(JVM)中的类加载器子系统会将对应Class对象加载到JVM中,然后JVM再根据这个类型信息相关的Class对象创建我们需要实例对象或者提供静态变量的引用值。注意,我们定义的一个类,无论创建多少个实例对象,在JVM中都只有一个Class对象与其对应,即:在内存中每个类有且只有一个相对应的Class对象,如图:
实际上所有的类都是在对其第一次使用时动态加载到JVM中的,当程序创建第一个对类的静态成员引用时,就会加载这个被使用的类(实际上加载的就是这个类的字节码文件)。注:使用new创建类的新实例对象也会被当作对类的静态成员的引用(构造函数也是类的静态方法)由此看来Java程序在它们开始运行之前并非被完全加载到内存的,其各个部分是按需加载,所以在使用该类时,类加载器首先会检查这个类的Class对象是否已被加载(类的实例对象创建时依据Class对象中类型信息完成的),如果还没有加载,默认的类加载器就会先根据类名查找.class文件(编译后Class对象被保存在同名的.class文件中),在这个类的字节码文件被加载时,它们必须接受相关验证,以确保其没有被破坏并且不包含不良Java代码(这是java的安全机制检测),完全没有问题后就会被动态加载到内存中,此时相当于Class对象也就被载入内存了(毕竟.class字节码文件保存的就是Class对象),同时也就可以根据这个类的Class对象来创建这个类的所有实例对象。
4.2、堆
所有创建出来的实例对象还有数组都是存放在堆内存中,堆是Java虚拟机所管理的内存中最大的一块存储区域,堆内存被所有线程共享。垃圾收集器就是根据GC算法,收集堆上对象所占用的内存空间,堆上又分为了新生代和老年代,针对不同的分代又会有对象的垃圾回收器和相应的回收算法(GC章节中会详细介绍)。
4.3、栈
JVM 中的栈包括 Java 虚拟机栈和本地方法栈,两者的区别就是,Java 虚拟机栈为 JVM 执行 Java 方法服务,本地方法栈则为 JVM 使用到的 Native 方法服务。两者作用是极其相似的,本文主要介绍 Java 虚拟机栈,以下简称栈。栈属于线程私有的数据区域,与线程同时创建,总数与线程关联,代表Java方法执行的内存模型。每个方法执行时都会创建一个栈帧来存储方法的的局部变量表、操作数栈、动态链接方法、方法返回值、返回地址等信息。每个方法从调用值结束就对于一个栈桢在虚拟机栈中的入栈和出栈过程,栈帧中的局部变量表可以存放基本类型,也可以存放指向对象的引用,当在某个方法中new Object()时,会在当前方法栈帧中的局部变量表存放一个指向堆内存实例对象的引用,详见下图。
4.4、程序计数器
是一块较小的内存空间,用来存储虚拟机下一条执行的字节码指令地址,和CPU中的程序计数器是一样的概念。
五 、JAVA程序在JVM内是如何执行的
上文已介绍了JVM的内存结构,接下来再看一下这个程序在JVM内部是怎么运行的:
1.JAVA程序的执行过程简单来说包括:
2.JAVA源代码编译成字节码;
3.字节码校验并把JAVA程序通过类加载器加载到JVM内存中;
4.在加载到内存后针对每个类创建Class对象并放到方法区;
5.字节码指令和数据初始化到内存中;
6.找到main方法,并创建栈帧;
7.初始化程序计数器内部的值为main方法的内存地址;
8.程序计数器不断递增,逐条执行JAVA字节码指令,把指令执行过程的数据存放到操作数栈中(入栈),执行完成后从操作数栈取出后放到局部变量表中,遇到创建对象,则在堆内存中分配一段连续的空间存储对象,栈内存中的局部变量表存放指向堆内存的引用;遇到方法调用则再创建一个栈帧,压到当前栈帧的上面。下面以一段实际的代码举例,来看一下,程序在JVM内部的执行过程。
我们先通过JAVAP命令,展示上述代码对应的字节码,下图是JVM把类加载到内存以后在方法区的常量池中初始化好的Class对象和各种方法引用,这里面需要重点关注一下前面的#1,#2,#5这些符号,这些数字保存的是和Class对象以及方法的引用关系,后面的字节码中会用到。
随后执行引擎中的解释器会率先启动,对ClassFile字节码采用逐行解释的方式加载机器码,并配合运行时数据区的程序计数器与操作数栈来支持。下图是main方法的字节码指令,我们结合JVM内存情况对代码做逐一分析。
上图中stack=3,local=2:stack=3代表栈的深度为3,local=2代表局部变量表中的变量数量。
程序样例执行详解
下图是main方法中的字节码执行到detail.Sum方法前的JVM内存结构。
具体执行流程如下: 首先会在JAVA栈中压入main方法的栈帧,然后程序计数器中的值更新成字节码new所在的内存地址,样例中为了方便起见就直接以0表示,程序计数器逐条解析字节码,其中new(new后面的#5中有讲到,对应的是JvmDetailClass的Class对象),dup,invokespecial三个字节码指令分别代表创建对象、赋值引用、调用构造方法,astore_1代表是把操作数(引用)放入操作数栈,aload_1代表是把操作数(引用)出栈,并放到局部变量表中。Iconst_3,iconst_5分别代表把操作数3,5入栈放到操作数栈中。接下来我们再看一下方法调用时JVM的内存结构是怎么做的,上面的代码中涉及到2块代码调用,一个是detail.Sum,一个是detail.getSum,这里我们detail.getSum是个带有返回值的方法,比较典型,我们直接以detail.getSum的调用为样例,看一下JVM内部是怎么执行的。当解释器执行到方法调用时,会修改程序计数器中的值为调用的方法内部第一行指令,同时在栈中压入getSum方法的栈帧,压入栈帧后会在局部变量表中初始化一个当前方法所属的对象的引用this,如果调用方法涉及到传参的情况下,则会在局部变量表中存入传递的参数。当getSum方法执行完成后,会做两步动作:程序计数器又会修改为main方法调用getSum处的下一行指令的地址。方法返回值写入main方法栈帧中的操作数栈中。