类的编译加载以及JVM内存布局

众所周知,在Java中类的编译以及加载等操作都是由JVM完成的.
那么到底是什么样的一系列变化,使我们在代码中可以轻松的直接new出一个对象呢?

jvm工作流程

编译->(装载->验证->准备->解析->初始化)->使用->卸载

编译阶段

源代码文件.java -> 词法分析器 -> tokens流 -> 语法分析器 -> 语法树/抽象语法树 -> 语义分析器 -> 注解抽象语法树 -> 字节码生成器 -> JVM字节码文件*.class*
这个阶段主要的目的就是源码文件编译成JVM可以解析的类文件(java文件转成class文件)
会经过词法分析,语法分析,语义分析,生成字节码几个阶段.
最后剩成的JVM字节码文件,使用命令“javap -c test”可以查看test.class的字节码信息,主要包含三项内容:

  • 结构信息:class文件相关信息。
  • 元数据:Java源码中的声明和常量信息。
  • 方法信息:Java源码语句和表达式对应的字节码。
    Magic Number: 魔数,class文件头4个字节,CA FE BA BE作用等同于我们文件的后缀.txt ,只有符合这个标准才能被JVM解读 ;
    编译过程中涉及到一下信息 :
    版本号: 编译class文件的JDK版本号,此版本时乡下兼容的;

常量池: 存放着字面量(文本字符串,final的常量)和符号引用(类和接口全限定名,字段名称和描述符、 方法名称和描述符,方法句柄和方法类型,动态调用点和动态常量)

访问标志: 访问权限信息(public ,final ,abstract)。

类索引: 类索引、父类索引、接口索引(类的全然限定名)。

字段表集合: 用于描述接口或者类中声明的变量信息(修饰作用域、实例变量还是类变量,是否可变、是否可序列化、并发可见性、字段类型、字段名)

方法表集合: 用于保存描述方法的信息(方法的名称,访问权限,返回值,参数类型等信息)。

指令码: 方法体中的代码经过编译后,最终变成了可执行的系统指令码保存在Code属性中,Code是属于方法集合中的一个属性。

装载阶段

简单来说,加载指的是把class字节码文件从各个来源通过类加载器装载入内存中。

这里有两个重点:

  • 字节码来源。一般的加载来源包括从本地路径下编译生成的.class文件,从jar包中的.class文件,从远程网络,以及动态代理实时编译
  • 类加载器。一般包括启动类加载器,扩展类加载器,应用类加载器,以及用户的自定义类加载器。

注:为什么会有自定义类加载器?

  • 一方面是由于java代码很容易被反编译,如果需要对自己的代码加密的话,可以对编译后的代码进行加密,然后再通过实现自己的自定义类加载器进行解密,最后再加载。
  • 另一方面也有可能从非标准的来源加载代码,比如从网络来源,那就需要自己实现一个类加载器,从指定源进行加载。

验证

主要是为了保证加载进来的字节流符合虚拟机规范,不会造成安全错误。

包括对于文件格式的验证,比如常量中是否有不被支持的常量?文件中是否有不规范的或者附加的其他信息?

对于元数据的验证,比如该类是否继承了被final修饰的类?类中的字段,方法是否与父类冲突?是否出现了不合理的重载?

对于字节码的验证,保证程序语义的合理性,比如要保证类型转换的合理性。

对于符号引用的验证,比如校验符号引用中通过全限定名是否能够找到对应的类?校验符号引用中的访问性(private,public等)是否可被当前类访问?

准备

主要是为类变量(注意,不是实例变量)分配内存,并且赋予初值。

特别需要注意,初值,不是代码中具体写的初始化的值,而是Java虚拟机根据不同变量类型的默认初始值。

比如8种基本类型的初值,默认为0;引用类型的初值则为null;常量的初值即为代码中设置的值,final static tmp = 456, 那么该阶段tmp的初值就是456

解析

将常量池内的符号引用替换为直接引用的过程。

两个重点:

  • 符号引用。即一个字符串,但是这个字符串给出了一些能够唯一性识别一个方法,一个变量,一个类的相关信息。
  • 直接引用。可以理解为一个内存地址,或者一个偏移量。比如类方法,类变量的直接引用是指向方法区的指针;而实例方法,实例变量的直接引用则是从实例的头指针开始算起到这个实例变量位置的偏移量

举个例子来说,现在调用方法hello(),这个方法的地址是1234567,那么hello就是符号引用,1234567就是直接引用。

在解析阶段,虚拟机会把所有的类名,方法名,字段名这些符号引用替换为具体的内存地址或偏移量,也就是直接引用。

初始化

这个阶段主要是对类变量初始化,是执行类构造器的过程。

换句话说,只对static修饰的变量或语句进行初始化。

如果初始化一个类的时候,其父类尚未初始化,则优先初始化其父类。

如果同时包含多个静态变量和静态代码块,则按照自上而下的顺序依次执行。

JVM内存布局

类的编译加载以及JVM内存布局_第1张图片
我们一个一个说起

方法区

方法区属于逻辑区域,JVM规范指定方法区是用于保存已经被虚拟机加载的类信息、类变量等数据, 此区域属于线程共享区域。

永久代/元数据

  • 他们都是Hotspot针对方法区的一种实现,两者最大的区别在于PermGen space
    是分配在虚拟机内存的,而Metaspace则分配在本地内存。
  • 这样做的好处是Metaspace空间的大小不会受限于虚拟机分配的内存大小,只会受限于机器内存,可分配的内存大了那么就不会那么容易出现内存溢。
  • 字符换存在永久代中容易出现溢出问题,因为永久代的大小是受限于虚拟机的内存大小,所以如果永久代设置太大那么其他区域所分配的内存就相对会小(比如说堆),那么其他区域造成溢出的可能性就会增大,反之如果设置太小,就容易出现方法区内存溢出,因为本身存储的类信息属于不确定大小,类信息在我们运行的时候可以动态加载。
    jdk1.7以前为永久代,1.8正式改为了元数据.

虚拟机栈

栈这部分区域主要是用于线程运行方法的区域,此区域属于线程隔离区,每一个线程创建后都会申请一个自己单独的空间来运行方法,每个方法运行时候会创建一个栈帧,栈帧存储着方法的局部变量表、操作数栈、动态连接和方法返回地址等信息,每调用一个方法都会生成一个新的栈帧,调用方法就是一个压栈和出栈的过程,遵循先进后出的原则。

本地方法栈

由于java需要与一些底层系统如操作系统或某些硬件交换信息时的情况。本地方法正是这样一种交流机制,本地方法栈和虚拟机栈功差不多,区别在于本地方法栈是虚拟机调用native方法时使用的。

程序计数器

程序计数器是一块较小的内存空间,它的作用可以看做是当前线程所执行的字节码的行号指示器,程序计数器是记录某个线程当前执行指令的位置,此区域属于线程隔离区。

堆内存

堆内存主要是用来存放我们运行过程中创建的对象数据,此区域属于线程共享区。

堆内存区域

类的编译加载以及JVM内存布局_第2张图片

新生代/老年代分区逻辑

1、大部分对象朝生夕死
对于JVM而言,大部分对象都是属于一个朝生夕死的状态,这部分对象随着方法的调用而创建,方法的结束而消亡,只有少部分的对象会长久的留在JVM 内存中。

2、大对象创建和回收比较耗时
对象本身创建就需要经过内存申请、初始化等一系列的操作,然后回收对象的时候需要对其进行标记清理,而对象越大这个过程需要的时间也就越长。

基于对象朝生夕死的特性,一般情况对象创建都会放到新生代中,只有经过一定次数的GC后还没有被回收的对象,我们认为这部分对象在未来也会长时间存在,所以会把这部分的对象转移到老年代的区域中去。

另外基于对象的大其创建和回收越耗时的特性,我们的大对象创建后就会被分配到老年代,避免大对象频繁的创建和回收造成性能损耗。

新生代

对于新生代区域包含Eden区,Survior区
Survior区包含Survior0,Survior1区.
新生代中的对象属于普通对象(未满足GC次数).符合这一条件的直接会在新生代中存活.
但是我们了解到上面新生代的分区逻辑,是为了解决空间碎片问题:
经过GC后的内存中会有很多不连续的内存空间(空间碎片),这些不连续的内存空间会让我们新创建的对象内存申请变得麻烦,每次申请内存前JVM都需要根据对象的大小去寻找合适的空间。
一次一次GC,保证能放置的内存碎片足够用,会一次次的迭代,Eden区,Survior0区,Survior1区,便是为了保证内存碎片的完整性而存在,一次一次移动从Eden到Survior0再至Survior1区的复制移动,来保证Eden区中的碎片完整性.

老年代

从新生代中的对象经过一定次数的GC或者新创建的大对象会进入老年代区.

你可能感兴趣的:(java开发记录)