Java应用程序是如何启动的

开头说明:LZ是初学者,查阅了不少资料做出的总结,如有不对,欢迎指出

首先得普及Java中的三个概念:
JDK:简单的可以理解为Java开发环境,其中包括了JRE、JDB等等
JRE:Java应用程序最小运行环境,其中包括了JVM
JVM:Java虚拟机,为应用程序模拟出完整的硬件环境。
也就是说JDK>JRE>JVM。

那Java是如何做到跨平台的呢?
我们都知道是通过JVM的,根据时间推算,JVM是由大量的C以及汇编编写的。win64位系统有JVM for win64,win32位系统有JVM for win32,Linux64系统有JVM for Linux64等等。也就是说每个系统由其特定的JVM,具体系统的JVM实现是由差异的。我们将Java应用程序运行于JVM上,不直接与操作系统挂钩,从而实现跨平台。

很大一部分的JVM实现均使用32位计算机硬件的存储方式,当然实现自己的jvm也可以实现64位的。由于大部分jvm是32位对齐的,所以基本类型的占位如下:

byte 1
short 2 
char 2
int 4
long 8
float 8
object 4(对一个JavaObject对象的引用,2字节索引数据、2字节索引方法表)
returnAddress 4字节,jvm底层实现,开发者并不能使用

好像并没有看到boolean类型,这是因为jvm将boolean类型当成是integer来处理,而boolean类型数组则由byte数组表示。

我们都知道Java是解释型语言
高级语言大致分为两种:解释型语言和编译型语言。

  • 解释型语言:解释型语言在运行时,不需要编译,但是为了要让计算机识别,所以需要进行翻译。也就是说每运行一次程序,就得做一次翻译,从而导致效率比较低。
  • 编译型语言:程序在执行前需要进行编译,编译的结果直接生成机器语言文件。再次运行时,直接运行机器语言文件(比如exe文件)。这样的话,就翻译了一次。从而效率高。

Java作为解释器语言,在早期速度是低于C和C++的。由于近几年的发展,Java的速度渐渐提升上来,最新版本的速度大致上能与C++持平。

我们编写完入门程序的HelloWorld后,要运行该程序需要执行两条命令:javac HelloWorld.java和java HelloWorld。这两步究竟做了什么事?
首先是javac命令,执行完后,会生成一堆看不懂的文字,其实是HelloWorld.class文件。我们还是解读下该文件好了。
HelloWorld.java代码如下:

public class HelloWorld {
    public static void main(String[] args) {
        System.out.println("HelloWorld");
    }
}

生成的.class文件如下:

cafe babe 0000 0033 001c 0a00 0600 0f09
0010 0011 0800 120a 0013 0014 0700 1207
0015 0100 063c 696e 6974 3e01 0003 2829
5601 0004 436f 6465 0100 0f4c 696e 654e
756d 6265 7254 6162 6c65 0100 046d 6169
6e01 0016 285b 4c6a 6176 612f 6c61 6e67
2f53 7472 696e 673b 2956 0100 0a53 6f75
7263 6546 696c 6501 000f 4865 6c6c 6f57
6f72 6c64 2e6a 6176 610c 0007 0008 0700
160c 0017 0018 0100 0a48 656c 6c6f 576f
726c 6407 0019 0c00 1a00 1b01 0010 6a61
7661 2f6c 616e 672f 4f62 6a65 6374 0100
106a 6176 612f 6c61 6e67 2f53 7973 7465
6d01 0003 6f75 7401 0015 4c6a 6176 612f
696f 2f50 7269 6e74 5374 7265 616d 3b01
0013 6a61 7661 2f69 6f2f 5072 696e 7453
7472 6561 6d01 0007 7072 696e 746c 6e01
0015 284c 6a61 7661 2f6c 616e 672f 5374
7269 6e67 3b29 5600 2100 0500 0600 0000
0000 0200 0100 0700 0800 0100 0900 0000
1d00 0100 0100 0000 052a b700 01b1 0000
0001 000a 0000 0006 0001 0000 0001 0009
000b 000c 0001 0009 0000 0025 0002 0001
0000 0009 b200 0212 03b6 0004 b100 0000
0100 0a00 0000 0a00 0200 0000 0300 0800
0400 0100 0d00 0000 0200 0e

很容易看出这些内容是十六进制的。
这里先贴出class文件的结构

ClassFile {
    u4 magic;
    u2 minjor_version;
    u2 major_version;
    u2 constant pool count;
    cp info constant pool[constant pool count - 1];
    u2 access flags;
    u2 this Class;
    u2 super Class;
    u2 interfaces count;
    u2 interfaces[interfaces count];
    u2 fields count;
    fields info fields[fiedls count];
    u2 method count;
    method into methods[methods count];
    u2 attributes count;
    attribute info attributes[attribute count];
}

我们可以一一对照着来看。
由于class文件是十六进制的,每两位代表一字节。上述中的u2代表占用两字节。
magic:魔术,jvm规范规定了class文件一定得是0xCAFEBABE开头,用于鉴定该文件为jvm执行的文件。但这只是最初步的检测,JVM还提供了沙箱模型,之后会讲述。(读者可以擅自修改生成的class中的magic,在使用java命令。可以看看效果)
minjor_version:次版本号,用于声明该class文件运行的次jvm版本号
major_version:主版本号,用于声明该class文件运行的主jvm版本号
constant pool count:代表该应用程序所用到的常量池数量。有int、String、double等等
info constant pool[constant pool count - 1]:代表每个常量池具体的信息。其中每个常量池代表一种类型,每个常量池中有数组存储该类型的敞亮。
access flags:定义该文件是接口还是类,以及修饰词是public、private还是protected。
this Class:代表的是该类在jvm中常量池表项的索引。
super Class:该类的父类
interfaces count和interfaces[interfaces count]:接口的数量和接口具体的信息
fields count和info fields[fiedls count]:该类中所有的属性和属性具体的信息
method count和into methods[methods count]:该类中所有的方法和方法具体的信息
attributes count:和info attributes[attribute count]:LZ才疏学浅,不能理解这两个字段的意思。

我们解读完了class文件,可是class文件是怎样生成的呢?这里需要用到openjdk了,读者可以了解下openjdk和jdk之间的区别。javac命令存在于com.sun.tools.javac.main.JavaCompiler文件中,不过LZ看不懂就不研究了。
这里要讲述的是编译java文件,生成class文件的过程:
词法分析:通俗的来说就是检查每个单词是否拼写正确。
语法分析:通俗的来说就是检查这个单词是否符合上下文语境,比如说:final类不能被继承、final变量不能再次赋值等等
生成代码:补充不全的代码,比如说为该类补写无参数的构造器、将该类填充为全限定名(比如:com/example/HelloWorld)等等
当然具体的过程还得靠编译原理的解释,这里只讲述个大概。

上述编译过程已经完成了,我们也生成了我们的目标文件——class文件。接着我们需要执行它了。
java <类名>这句简简单单的命令做了什么呢?
首先为该应用程序启动一个jvm实例,也就是说每个Java应用程序均有一个jvm实例。
代码装入
jvm启动后,装入class文件的内容,该类存放于自己的名字空间中,类于类之间的访问只能通过名字空间进行访问,但是所有的类在同一块内存空间中,所有类相互引用的效率较高。
补充一点的是,应用程序由main函数开始执行。并不会一开始加载所有的类,而是需要用到什么类,jvm就去加载什么类。至于加载类的模型机制,是使用全盘委托+双亲委托机制实现。
首先我们需要了解下类装载器有哪些?

  • 启动类装载器
  • 标准扩展类装载器
  • 路径类装载器
  • 自定义装载器

从上至下,为父亲——孩子结构,也就是说启动类装载器是标准扩展类装载器的父亲。但是这里的父亲并不等同于类中的继承关系
启动类装载器由C或C++编写,无父类的,装载rt.jar(rt为运行环境的意思)
标准扩展类装载器,用于装载ext目录下面的类
路径类装载器,装载jar包和我们编写的类,
我们还可以自定义类装载器,在Java应用程序运行后,继续编译和装载不在该工程下的类。(这个具体在下面描述)。
刚刚提到jvm采用双亲委托机制,这样做有很大的好处。
假设我们编写了java.lang.String类,根据常识应该知道jdk源码中已经存在该类了,而且Java应用程序启动时就会装载该类(由启动类装载器装载的)。由于我们自己编写了java.lang.String类,那么我们在使用的时候,如果能装载进jvm中就可以访问同一包下面的所有类了,比如说Integer、Boolean等等,说不定我们还能访问特殊变量。但是,想得太美了。由于jvm识别到要使用String类,首先由路径类装载器委托标准扩展类装载器装载,标准扩展类装载器发现自己也没有,那么就委托启动类装载器装载。然而启动类装载器已经装载了同一全限定名的java.lang.String类了,就会将该结果返回给标准扩展类装载器,标准扩展类装载器就继而将结果返回给路径类装载器。路径类装载器接收到了启动类装载器装载的String类,那么就不会再去装载我们自己编写的String类了。采用双亲委托机制,可以防止恶心代码影响jvm。

问题又来了,如果我们申明了java.lang.Hello,能够访问java.lang.String(jdk自带)的特殊变量呢?结果肯定是不能的。jvm规范实现了:由同一装载器装载的类才能拥有特殊权限。而java.lang.Hello是由路径类装载器装载的,java.lang.String由启动类装载器装载的,所以两个类不能访问特殊权限的变量和方法。

上述降到了双亲委托,那么接下来是全盘委托。首先要清楚Java应用程序运行并不会一开始加载所有的类,而是需要用到什么类,jvm就去加载什么类。比如说Hello类中用到了Hello1类,那么就会由Hello装载器类去装载Hello1类,假设Hello类由路径类装载器装载的,那么首先由路径类装载器装载Hello1类。这就是全盘委托机制。

代码校验
生成的class文件,可能会经过人为的修改,那么jvm就必须得对该class文件进行检验。

  • 首先检验该class文件结构是否满足要求。比如说:以魔术oxCAFEBABE作为开头。从主版本号和次版本号信息检测该jvm能否运行该类。是否符合ClassFile的结构(上述已将代码贴出,并做了讲解)。
  • 检测语义是否正确,比如说方法的返回类型和函数的声明是否一直、函数的参数是否匹配等等。
  • 字节码验证,从字节流进行分析,这些字节流代表着类的方法。
  • 符号引用验证,扫描需要用到的类,采用全盘委托的方式装载需要的类、将符号引用替换为直接引用等等。

代码执行
接着就开始运行代码了,将class文件内容转换为Java指令集——相当于是Java程序的汇编语言。
至此,编译与运行的过程大致如上所示。如有问题,欢迎指正。
终于程序开始运行了,程序一运行,会开启守护线程——GC回收线程(我们也可以通过调用Thread.setDaemon设置我们自定义的线程为守护线程)。
守护线程——优先级极低的线程,与用户线程没有本质区别,一直运行与jvm后台。当函数调用了System.exit(0)或所有用户线程执行完毕后,所有守护线程立刻退出了。
用户线程——优先级较高的线程,直到所有的用户线程执行完毕,整个应用程序才算是结束。但是System.exit(0)方法是退出jvm,所有会导致整个应用程序退出。

GC回收线程优先级很低,我们人为的调用了System.gc()也只是通知GC回收线程要回收垃圾了,但是具体什么时候回收,是在某一时刻,并不能确定。
回收算法包括了标记——复制、标记——清除等算法。由于所知有限,就不赘述。
jvm会通过root根节点开始搜索所有的对象,如果对象不可达,则会回收该对象占用的内存空间。

Java应用是基于栈的,为什么这么说呢?首先所有线程共享的资源是堆、文件等。GC回收主要的内存空间是堆。那么线程独有的资源呢?就是栈、优先级、程序计数器等等了。
在jvm中栈由栈帧组成,一个线程可能由多个方法组成,而每个方法都由自己的栈帧。每个栈帧由独有的局部变量区、操作数栈、操作符栈(毕竟Java由自己的指令集)、数据区等等。jvm堆栈帧也有大小的规定,如果递归不能终止,会抛出java.lang.StackOverflowError异常。
而程序计数器就是用来指明线程接下来要执行第几个指令了。如果下一个指令指向的是本地方法,那么该值会是undefine。
千万不要依赖定义线程的优先级来实现线程之间的同步和异步,优先级只是一个参考值,jvm具体会执行哪个线程,会参考多个值的

特殊的类——Class类
可能大家觉得Object类是很特殊的,那当然了Object类是所有类的父类。但是这里我想说的是Class类,我们可以将Class类理解为是用于维护类的类。jvm将class文件装入后,会为该类生成静态变量class。我们可以通过该class对象,获取该类所有的属性、方法、接口、装载器等等。

上述说道,我们可以自定义类装载器装载器,这也是Java框架实现的方法之一。比如实现web服务器,Java应用程序肯定不知道用户有多少类、类的文件都在哪里、类的名字又是什么。但是我们可以先定义一个Controller类,用于拦截所有的请求,定义了doGet和doPost方法。web服务器一启动,我们就通过递归搜索所有的.java文件编译为.class文件(使用JavaCompiler类实现),再将.class文件装载进虚拟机(使用ClassLoader子类实现)。如果浏览器访问:127.0.0.1/HelloWorld/index,就去查找HelloWorld目录下与index字符串结合的Controller的子类,并调用doGet或doPost方法。还可以考虑SpringMVC的思想,自定义注解,通过反射机制更加方便快捷的查找到相应的Controller子类。当然这只是大概思想,实现起来还是非常有难度的。

还是要重申下,LZ是初学者,查阅了不少资料做出的总结,如有不对,欢迎指出
PS:好像忘记写类装载完后,jvm会对该类做初始化的步骤了,不写了。好长。。。

猜你喜欢

转载自blog.csdn.net/new_Aiden/article/details/52836643