带着新人看java虚拟机01

时间:2023-02-28 21:24:50

1.前言(基于JDK1.7)  

  最近想把一些java基础的东西整理一下,但是又不知道从哪里开始!想了好久,还是从最基本的jvm开始吧!这一节就简单过一遍基础知识,后面慢慢深入。。。

  水平有限,我自己也是很难把jvm将清楚的,我参考一本书《深入java虚拟机第二版》(版本比较老,其实很多大佬的博客都是参考的这本书的内容。。。),电子档pdf文件链接:https://pan.baidu.com/s/1bxs4i0gnVpz7Lkjl2fxS9g   提取码:n5ou  ,有兴趣的小伙伴可以自己下载自己好好看看;

  所谓jvm,又名java虚拟机。我们平常写java程序的时候几乎是感觉不到jvm的存在的,我们只需要根据java规范去编写类,然后就可以运行程序了,当然只有我们程序出现bug了,我们才有可能在控制台上看到一些jvm报错的信息,比如内存溢出异常等。

  java之所以能够跨平台,就是因为jvm屏蔽了各个操作系统之间的差异,举个形象的例子,我们手机要充电吧,但是充电的方式有很多种,你可以直接数据线插到插座充电,也可以用数据线插到电脑USB口充电,一个是电脑一个是插座,为什么都能给手机充电呢?原因就是有数据线屏蔽了插座和电脑的差异,对于手机来说,它是看不到数据线另外一头连接的是什么设备,只知道有电通过数据线向自己传过来就ok了,顺便一提,这也是所谓的适配器的原理!

  开始之前首先要明确一点,每一个java程序运行就会创建一个jvm实例!比如我同时在eclipse中同时运行三个程序,那么就会创建三个jvm实例,三个程序运行于自己的jvm中,互不干扰,当程序运行完毕,那么jvm也会销毁。

2.简单看看类加载过程

  大家知道一个类加载到jvm大概是经过了几个步骤的吧!编译成字节码文件,加载,链接(验证,准备,解析),初始化....,我就简单的用下面这个图一起看看;

带着新人看java虚拟机01

  在这里,我们重点看看字节码文件到jvm这一段,为什么字节码文件能够被加载到jvm中呢?类加载器又是什么呢?加载的具体过程又是什么呢?链接,初始化又具体的是在做些什么事呢?Class对象又是什么鬼?jvm中的具体结构又是什么样子的,各有什么用处?假如执行一个类中的方法,在jvm中到底是什么流程呢?等等很多问题

  这些问题有的是了解一点,有的是真不知道,反正就是迷迷糊糊的一个类就加载成功了,然后我们就能成功调用那些方法了,平常用起来很舒服,但是细细想来难道不觉得奇怪吗?

  反正我最初看到jvm的时候,最想吐槽的一句话就是:玛德,为什么啊?我感觉我已经要化身成十万个为什么了,咳咳,不说废话了,开始往后学吧!

  下面我大概说一下这些步骤到底是做了什么事,有个大概的流程,然后我们慢慢的深入探究每一个步骤到底是干了什么事!

  

  2.1 编译器编译

    这个没什么好说的,由于java是静态语言,在执行java程序之前会先把我们写的java文件给转化成特殊的二进制码的形式,编译器就是做这个转化的工作的工具,而且在我们写代码的时候,还没运行程序之前,就会报错,在某处代码下面会有红线标识,做这个工作的就是编译器,还有最重要的源文件中泛型,是会在编译器编译这个阶段就会进行擦除,所以字节码文件中是没有任何泛型信息的;

    顺便提一下动态语言,比如Python,我们写一个python程序运行,是不需要进行编译的,会读取第一行源文件中代码就运行这一行的代码,然后读取第二行代码,运行第二行代码...

  

  2.2 类加载器的分类和加载顺序

    什么是类加载器呢?我有一个很生动很形象的例子:假如字节码文件是一个人,而jvm就是地府,你说人死了会怎么进入地府呢?自己肯定找不到地府的位置,于是要让黑白无常请你过去了,类加载器在这里就是黑白无常!

    大概了解类加载器的用处之后,我们就随意看看类加载器的种类和运行原理;

    顺便提一下,我们还记得最开始配置的jdk环境变量吧!我的JAVA_HOME=D:\java\jdk1.7;

    话说大家知道jar包到底是什么吗?其实就是一种压缩文件的格式,跟zip,gz等压缩格式没有多大区别,可以用360压缩打开。。。

    进入正题,类加载器分为四种,启动类加载器(Bootstrap ClassLoader):最*的类加载器,还是用C++写的;在我们编写java程序的时候,编译器会自动的帮我们导入一下常用的jar包,用的就是这个类加载器,比如我们最熟悉的lang包下的Object,String,Integer等都是我们可以直接用的,而不需要我们手动导入;具体的会导入哪些jar包呢,这就需要我们配置环境变量JAVA_HOME,编译器会去环境变量中找%JAVA_HOME%\jre\lib  ,这下面所有jar包然后进行加载到内存中,注意不是加载在JVM中;而且出于安全考虑,启动类加载器只加载包名为java、javax、sun等开头的类

    扩展类加载器(Extension ClassLoader):父类加载器是启动类加载器,java语言实现,负责加载%JAVA_HOME%\jre\lib\ext  路径下的jar包,这个不会自动加载,只有在需要加载的时候才去加载。

    应用类加载器(Application ClassLoader):父类加载器是扩展类加载器,java语言实现,也可以叫做系统类加载器(System ClassLoader),这个类加载器主要是加载我们在写项目时编写的放在类路径下的类,比如maven项目中src/main/java/所有类

    自定义类加载器:需要我们自己实现,当特殊情况下我们需要自定义类加载器,只需要实现ClassLoader接口,然后重写findClass()方法,我们就能够自己实现一个类加载器,而且自己实现类加载器之后可以去加载任何地方的类。假如我新建一个类放在F盘的随便一个角落里也可以指定类路径去加载,有兴趣的小伙伴可以去试试。

    不考虑自定义类加载器,可以看到,启动、扩展、应用这三个加载器就像是爷爷,爸爸,儿子一样的关系,所以要加载一个类的话,选用哪个类加载器呢?肯定是有什么好吃的先让儿子吃呀,然而儿子又很有孝心,会把到手的好吃的给爸爸吃。爸爸又会给爷爷吃,爷爷会尝试着吃,假如一看这东西糖分太高于是就又给爸爸吃,爸爸也尝试着吃,发现这东西不好吃,于是最后还是给儿子吃....这就是类加载器的双亲委托机制,随便找了一幅图看看:

带着新人看java虚拟机01

  

  2.3.JVM内部结构

    其实大多数人对JVM是很熟悉了,不就是那几个块吗?本地方法栈,java栈,java堆,方法区,pc计数器,我这里就先大概说一下这几个部分的用处;

带着新人看java虚拟机01

    方法区:类加载器其实就是将字节码文件给丢到这里,并解析出字节码文件中包含的一些信息,比如全类名,类变量,方法有关的信息,父类信息,是不是接口等等这类信息

    由于方法区很重要,我就随意画个草图:

带着新人看java虚拟机01

    常量池(属于方法区):由于方法区比较厉害能把字节码文件中很多信息给解析出来,但其中可能有很多常量比如18,“helloworld”,以及一些符号引用,常量池就存这些东西;但是什么又是符号引用呢?我就大概说一下吧,假如两个类Animal和Dog,在Animal类中有个方法里面是这样的:Dog dog = new Dog();dog.run();  这个时候问题来了,在加载Animal类的时候发现了要用到Dog类,肯定是要去加载Dog类的,那么有两种做法,第一种先暂停Animal类的加载去加载Dog类,加载完之后再加载Dog类,第二种,Animal类继续加载的同时顺便加载Dog类,只是Animal中只要是用到了Dog类、方法、字段的所有地方我随便用xxx来表示,等Dog类加载完之后我再把xxx指向方法区Dog类对应的地址就ok了;我们当然用第二种方法啦,并且在这里我们随便用的xxx就是符号引用,而加载完成后方法区中的Dog类地址就是直接引用

    java堆:根据方法区中存的这么丰富的信息,这里就会创建每一个类的Class对象,话说这个Class对象用的最多的就是反射,那么这个Class对象到底是个什么呢?其实不用想的太难理解了,你就把它看作字节码文件在内存中的另外一种形式呗,就好像大米,在电饭煲里的表现形式就是米饭,在高压锅里的表现形式就是粥了.....;假如程序运行的话,还会在堆中创建对象并且存放在堆中,所有的同类型的类的实例对象共享一个Class对象,我也随意画了一个草图来看看如下所示,所以同一个类的不同实例对象的xx.getClass()都是一样的,而且根据获得的Class对象可以利用反射创建新的对象和获取其中的方法,可以说Class对象为我们程序员提供了一个操作堆中对象的一个安全通道

带着新人看java虚拟机01

    

    pc寄存器:对于多线程来说,你就可以把这个看作一个计数器,每个线程一个,里面写着1,2,3,4,5....记录着各个线程执行代码的行号,为什么要记这个行号呢?莫非是闲的蛋疼?当然不是!因为对于多线程来说,cpu首先执行一号线程,然后停止,去执行二号线程,又停止,又去执行一号线程...这个时候问题来了,cpu怎么知道上一次一号线程执行到哪里来了?于是啊,这个pc寄存器用处就来了,因为每个线程都有一个,而且记录着当前执行的行号,下次cpu来了根据这个行号就可以接着执行了啊!

    java栈:对象已经创建完毕放在堆中,然后我们调用一个java方法,就会在java栈中开辟一小块空间(就是所谓的压栈),俗称栈帧,栈帧可以有多个,因为一个方法中可以调用其他方法嘛!总之一个方法就对应一个栈帧,栈帧里面放着我们这个要运行方法内的局部变量,方法返回值等等参数,等这个方法执行完之后这个栈帧就退出去了(这就是所谓的弹栈),然后栈就恢复原样

    本地方法栈:不知道大家有没有打开JDK的一些类的源码看看,很多类都有Native方法(本地方法),我的理解是就是调用操作系统中一些c语言实现的方法或者其他语言实现的方法....

  2.4.加载

    说了这么久的类加载器的种类还有类加载器的使用顺序,然后也简单说了JVM内部结构以及各自的作用,现在就是选好了的类加载器去加载字节码文件丢到JVM中的方法区中了。

    用伪代码随便看看加载大概步骤,参数name就是我们传进去的类的全名:

public Class<?> loadClass(String name)  {
try {
if (parent != null) {
//如果存在父类加载器,就委派给父类加载器加载
c = parent.loadClass(name, false);
} else {
//如果不存在父类加载器,就检查是否是由启动类加载器加载的类, 通过调用本地方法native findBootstrapClass0
c = findBootstrapClass0(name);
}
} catch (ClassNotFoundException e) {
// 如果父类加载器和启动类加载器都不能完成加载任务,才调用自身的加载功能
c = findClass(name);
}

  所以假如我自定义一个类加载器MyClassLoader,那么就可以用这种方式去加载我随意放在F盘myclass目录里面,com.wyq.test包下的一个Student类:

MyClassLoader myClassLoader=new MyClassLoader("F:\\myclass");
Class c=myClassLoader.loadClass("com.wyq.test.Student");

  然后我们得到了这个类的Class对象就可以用反射对这个类为所欲为了,嘿嘿嘿嘿~

  2.5.链接

    链接中分为三步:验证,准备,解析;

    随便说说这三步大概干些什么,验证:这一步其实没什么大的用处,就是虚拟机会检查一下我们的字节码文件有没有问题,具体的就是看看你字节码文件格式有问题吗?语法有没有问题?等等

    准备:给类的静态变量分配内存空间,并设置初始值;大家都知道静态变量是放在方法区中的吧,比如我java类中有个静态变量static int age = 18     那么这这个阶段首先会分配4个字节的内存空间,然后设置初始值为0,八大基本数据类型都有初始值,可以了解了解

    解析:比较专业一点的说法就是,在解析阶段,JVM会把类的二进制数据中的符号引用替换为直接引用!这句话怎么理解请看上面介绍的常量池

  

  2.6 初始化

    还是用准备阶段那个静态变量,根据字节码文件,将准备那个阶段的初始值覆盖成真正的值18;

  顺便说一句,加载、链接、初始化三个步骤不是一定要按照这个顺序完成的,只是开始的顺序是这个,但是在执行过程中可能会有弯道超车的现象

 3.例子分析

  这里我们写一个最简单的例子来总结一下上面这么多知识;

public class Animal{
private int age=18;
public void run() {} } publci class Test{
public static void main(String[] args){
Animal animal = new Animal();
animal.run(); }
}

  运行这个main方法的步骤:

  1.首先是编译器会将这两个类都编译成字节码文件并放在你的项目存放路径

  2.Test这个类会以某种方式告诉JVM自己的类名“Test”,虚拟机就会以某种牛逼的方法可以找到你这个Test.class放在那个目录下面

  3.调用类加载器,采用双亲委托机制去加载这个类,最后不出意外应该是应用类加载器去加载这个Test.class,以二进制流的形式加载进JVM方法区

  4.在加载之后会去验证这个Test.class是否符合规范,没问题的话就会解析这个加载进来的Test.class,将其中很多信息都保存下来,常量和符号引用保存在常量池中,其他的比如访问修饰符,全类名,直接父类的全类名,方法和字段信息,除了常量以外的所有静态变量,以及指向类加载器和Class对象的指针等都存在常量池外面

  5.通过保存在方法区中的字节码,JVM可以执行main()方法,在执行这个方法的时候,会一直持有有一个指向Test的常量池的指针;

  6.在执行main方法的第一条指令的时候,就是告诉JVM为Test常量池的第一个类型分配足够内存;由于main方法一直持有执行Test常量池指针于是很迅速的找到了常量池第一项,发现它是一个对Animal类的符号引用,然后就会先检查方法区看有没有Animal类有没有被加载,假如没有的话就要去找到这个Animal类;这里就有了一个算法的小知识,怎么才能够让虚拟机最快速度找到Animal类所在位置呢?可以用散列表,搜索树等算法。

  7.加载Animal.class到方法区并提取其中有用的信息保存在方法区,然后替换Test常量池第一个类型的符号引用,变为直接引用;注意,这个时候还没有创建对象,直接引用指向的是方法区中Animal所在的地址

  8.JVM在堆中为创建Animal对象分配足够内存,怎么确定这个内存多大合适呢?其实JVM比较牛,已经设好了可以根据方法区中存放的信息确定一个类创建对象要用到多少堆空间;

  9.对象创建好了会设置Animal实例变量的默认初始值:age = 0

  10.创建一个栈帧(里面有一个指向Animal对象的引用),压入java栈中,到此main方法第一条指令就执行完毕;还记得一个方法一个栈帧么

  11.然后根据这个栈帧调用java代码,将age的值初始化为正确的值:18

  12.通过这个栈帧执行run()方法,又会开辟一个栈帧存放run()方法内部的所有信息

  13.run()方法执行完毕,释放这个栈帧;然后main()执行完毕,释放栈帧;然后就是程序执行完毕,清理回收堆中所有对象以及方法区

  大概就是这么一个流程,其中最后的那个清理回收过程其实很重要,由于java栈和方法区的清理内存效率非常好,我们可以不用在意,重点是在堆中清理内存,而且由于有的程序是会运行很久的,不可能每次都等程序执行完毕之后再一起清理,肯定是要一边运行程序一边清理堆内存中没用的对象,那么又该怎么进行处理呢?又会涉及到很多的算法以及堆内部到底是什么结构,后面我们会逐渐挖掘...