JVM学习【二】

前言

书接上回,通过上篇博客的介绍,我们已经知道了虚拟机内部大概的内存情况,下面再来详细的了解一下HotSpot虚拟机再Java堆中对象分配,布局和访问的全过程。

对象的创建

创建过程如下:

step1:类加载检查

当虚拟机遇到一条new指令时,首先会去检查这个指令的参数能否再常量池中找到这个类的符号引用,并且检查这个符号引用代表的类是否被加载过,解析和初始化过,如果没有,那必须先执行相应的类加载过程

说白了就是先看之前是不是已经加载过一次了,有就复用,提高效率,没有的话就去加载

符号引用就是源代码转换为字节码文件时,编译器给某些类,方法,参数做的记号,可以根据这个记号得知原始代码的样子,给JVM看的东西。

step2:分配内存

在类加载检查通过后,虚拟机就为新生对象分配内存了,对象所需的内存大小在类加载完成后便可确定,为对象分配空间的任务等同于把一块确定大小的内存从 Java 堆中划分出来。

内存分配方法:

  • 指针碰撞
  • 空闲列表

指针碰撞:

  • 适用场景:适用于堆内存规整(没有内存碎片)的情况下
  • 原理:用过的内存放到一边,没用过的放到另一边,中间有一个分界指针,只需要向着没用过的内存的那一边将该指针移动对象内存大小位置即可。
  • 使用该分配方式的 GC 收集器:Serial, ParNew

空闲列表: 

  • 适用场景:适用于堆内存不规整的情况下(即有较多大小不一的内存碎片)
  • 原理: 虚拟机会维护一个列表,该列表会记录哪些内存块是可用的,大小是多少,当需要给新对象实例进行内存分配时,就根据这个列表找一块大小足够的内存块来划分给对象实例。
  • 使用该分配方式的 GC 收集器:CMS

选择哪种分配方式由 Java 堆是否规整决定,而 Java 堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。

step3:初始化零值

内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),这一步操作保证了对象的实例字段在 Java 代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。

在下拙见,这里的初始化零值的阶段相当于类加载过程中的“准备”阶段,不过区别在于,前者是为实例变量分配内存并赋予默认值的时刻,发生在类的对象被实例化时,而后者是为静态变量分配内存并赋予默认值的时刻(静态变量的生成是要先于实例对象的创建,静态变量的生成与赋值早在step1时就完成了

step4:设置对象头

初始化零值完成之后,虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的 GC 分代年龄等信息。 这些信息存放在对象头(下文介绍)中。 另外,根据虚拟机当前运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式。

step5:执行init方法

在上面工作都完成之后,从虚拟机的视角来看,一个新的对象已经产生了,但从 Java 程序的视角来看,对象创建才刚开始,<init> 方法还没有执行,所有的字段都还为零。所以一般来说,执行 new 指令之后会接着执行 <init> 方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全产生出来。

对象的内存布局 

 对象的内存区域分为3块:对象头(Header),实例数据(Instance Data)和对其填充(Padding)

对象头:

包括两部分信息:

  • 标记字段(Mark Word):用于存储对象自身的运行时数据, 如哈希码(HashCode)、GC 分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等等。
  • 类型指针(Klass Word):对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。

实例数据:

存储对象真正的有效信息,也是在程序中所定义的各种类型的字段内容。

对其填充:

非必须,无特别含义,仅仅起到占位的作用。

因为 Hotspot 虚拟机的自动内存管理系统要求对象起始地址必须是 8 字节的整数倍,换句话说就是对象的大小必须是 8 字节的整数倍。而对象头部分正好是 8 字节的倍数(1 倍或 2 倍),因此,当对象实例数据部分没有对齐时,就需要通过对齐填充来补全

对象的访问定位

对象创建出来后,我们如何使用呢,答案就是通过虚拟机栈上的reference 数据来操作堆上的具体对象。

对象的访问方式由虚拟机实现而定,目前主流的访问方式有:使用句柄直接指针

句柄:

如果使用句柄的话,那么 Java 堆中将会划分出一块内存来作为句柄池,reference 中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与对象类型数据各自的具体地址信息。 

直接指针:

如果使用直接指针访问,reference 中存储的直接就是对象的地址。 

这两种对象访问方式各有优势。

使用句柄的访问方式

  • 最大好处是 reference 中存储的是稳定的句柄地址,在对象被移动时只会改变句柄中的实例数据指针,而 reference 本身不需要修改
  • 缺点就是每次访问对象都是间接访问,涉及两次指针查找。

使用直接指针访问方式

  • 最大的好处就是速度快,它节省了一次指针定位的时间开销
  • 缺点是如果对象发生移动,所有引用这个对象的变量都需要被更新,以反映对象的新地址。

HotSpot 虚拟机主要使用的就是这种方式(直接指针)来进行对象访问。

类的生命周期

类从被加载到虚拟机内存开始到卸载出内存结束为止,生命周期可以简单概括为7个阶段

  • 加载
  • 验证
  • 准备
  • 解析
  • 初始化
  • 使用
  • 卸载

其中 验证,准备,解析这三个阶段可以被统称为连接。 

类加载过程

Class 文件需要加载到虚拟机中之后才能运行和使用,那么虚拟机是如何加载这些 Class 文件呢?

系统加载 Class 类型的文件主要三步:加载->连接->初始化

连接过程又可分为三步:验证->准备->解析

加载:

主要完成以下三步:

  1. 通过全类名获取定义此类的二进制字节流
  2. 将字节流所表示的静态存储结构转换为方法区运行时的数据结构
  3. 在内存中生成一个代表该类的Class对象,作为方法区这些数据的访问入口

 加载这一步主要是依靠类加载器完成的,后面会讲到。

验证:

验证是连接的第一步,这一阶段的目的是为了确保字节码文件的字节流中的信息符合(Java 虚拟机规范》的)全部约束要求,保证这些信息被当作代码运行后不会危害虚拟机自身的安全。

验证阶段主要由四个阶段组成:

  1. 文件格式验证(Class 文件格式检查)
  2. 元数据验证(字节码语义检查)
  3. 字节码验证(程序语义检查)
  4. 符号引用验证(类的正确性检查)

文件格式验证这一阶段是基于该类的二进制字节流进行的,主要目的是保证输入的字节流能正确地解析并存储于方法区之内,格式上符合描述一个 Java 类型信息的要求。从这也能看出加载阶段与验证阶段的部分验证动作是交叉进行的,加载尚未结束,连接就已经开始了。

除了这一阶段之外,其余三个验证阶段都是基于方法区的存储结构上进行的,不会再直接读取、操作字节流了。

方法区属于是 JVM 运行时数据区域的一块逻辑区域,是各个线程共享的内存区域。当虚拟机要使用一个类时,它需要读取并解析 Class 文件获取相关信息,再将信息存入到方法区。方法区会存储已被虚拟机加载的 类信息、字段信息、方法信息、常量、静态变量、即时编译器编译后的代码缓存等数据

详细介绍还请看——JVM学习【一】

准备:

准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,不包括实例变量(目前我们所在的阶段处于对象创建的step1,实例变量的实例化与设置初始值在对象创建的step2和3

类变量,即静态变量,被 static 关键字修饰的变量,只与类有关,因此也叫做类变量

 类变量最终存放的位置是在堆中,因为JDK7及以后,字符串常量池和静态变量都移动到了堆中,这个时候类变量则会随着 Class 对象一起存放在 Java 堆中

还有一点,这里所设置的初始值"通常情况"下是数据类型默认的零值(如 0、0L、null、false 等),比如我们定义了public static int value=200 ,那么 value 变量在准备阶段的初始值就是 0 而不是 200(初始化阶段才会赋值)。特殊情况:比如给 value 变量加上了 final 关键字public static final int value=200,那么准备阶段 value 的值就被赋值为 200。

基本数据类型的零值 

数据类型零值
int0
long0L
short(short)0
char'\u0000'
byte(byte) 0
booleanfalse
float0.0f
double0.0d
referencenull

解析:

解析阶段是将常量池中的符号引用替换为直接引用的过程

这里的解析阶段虚拟机栈中的动态链接虽然都是将符号引用替换为直接引用的过程,但是他们发生的时机和具体的对象不同

解析阶段:

  • 时机:类加载连接的最后一个阶段
  • 作用对象:主要针对常量池中的符号引用,将其解析为直接引用。这些符号引用指向类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用限定符 7 类符号引用进行。
  • 静态解析:解析阶段的引用替换是静态的,也就是说,这个解析是在类加载时完成的,所有符号引用在这一阶段都被替换成了确定的直接引用(如内存地址、方法表地址)。它不会随程序运行时的逻辑变化。

动态链接:

  • 时机:动态连接发生在方法调用时,即在程序运行过程中。JVM 在执行方法调用时,可能需要将符号引用转换为具体的直接引用。
  • 作用对象:主要针对方法调用,尤其是在方法的调用过程根据实际运行的对象类型来决定要调用哪个具体方法时,这个过程就是动态连接的部分。
  • 动态分派:程序在运行时根据实际对象的类型决定调用哪个方法。这就是所谓的动态分派,它通常依赖于虚方法表(Virtual Method Table,V-Table),这个表记录了不同类的具体方法实现,以便在运行时进行查找。

暂时了解下就行,只要知道两个名词的含义不同就行,能区分出来。 

关于解析阶段,举个简单例子,在程序执行方法时,系统需要明确知道这个方法所在的位置。Java 虚拟机为每个类都准备了一张方法表来存放类中所有的方法。当需要调用一个类的方法的时候,只要知道这个方法在方法表中的偏移量就可以直接调用该方法了。通过解析操作符号引用就可以直接转变为目标方法在类中方法表的位置,从而使得方法可以被调用。

综上,解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程,也就是得到类或者字段、方法在内存中的指针或者偏移量。

初始化:

初始化阶段是执行初始化方法 <clinit> ()方法的过程,是类加载的最后一步,这一步 JVM 才开始真正执行类中定义的 Java 程序代码(字节码)。

说明:<clinit> ()方法是编译之后自动生成的。

 对于<clinit> () 方法的调用,虚拟机会自己确保其在多线程环境中的安全性。因为 <clinit> () 方法是带锁线程安全,所以在多线程环境下进行类初始化的话可能会引起多个线程阻塞,并且这种阻塞很难被发现。

卸载:

这一步已经不属于类加载过程了,属于类的生命周期

卸载类即该类的 Class 对象被 GC。

卸载类需要满足三个要求:

  1. 该类的所有实例对象都已经被GC,即Java堆中不存在该类的实例对象
  2. 该类没有在任何地方被引用
  3. 该类的类加载器实例已经被GC,即他的具体创造者也没了

所以,在 JVM 生命周期内,由 jvm 自带的类加载器加载的类是不会被卸载的。但是由我们自定义的类加载器加载的类是可能被卸载的。

只要想通一点就好了,JDK 自带的 BootstrapClassLoader, ExtClassLoader, AppClassLoader 负责加载 JDK 提供的类,所以它们(类加载器的实例)肯定不会被回收。而我们自定义的类加载器的实例是可以被回收的,所以使用我们自定义加载器加载的类是可以被卸载掉的。

类加载器(ClassLoader)

介绍

关于类加载器的介绍主要就三点:

  • 类加载器是一个负责加载类的对象,用于实现类加载过程的第一步-加载
  • 每个Java类都有一个引用指向加载他的ClassLoader
  • 数组类不是通过ClassLoader创建的(数组类没有对应的二进制字节流),是由JVM直接生成的

数组类通过getClassLoader()方法获取 ClassLoader 的时候和该数组的元素类型的ClassLoader 是一致的。

类加载器是负责加载 .class 文件的,他们在文件开头会有特定的文件标识,将这些class文件字节码内容加载到内存中,并且转换为方法区中的运行时数据结构,ClassLoader 只负责class文件的加载,是否能运行要由 Execution Engine 来决定。

简单来讲,类加载器的主要作用就是加载 Java 类的字节码( .class 文件)到 JVM 中(在内存中生成一个代表该类的 Class 对象),不过类加载器的功能很强大,除了加载类以外,还可以加载Java应用中所需要的其他资源,如视频,文本,图像,配置文件等等文件资源,这里主要讨论其核心功能:加载类

加载规则

JVM 在启动时并不会立即加载所有类,而是根据程序执行的需要动态加载类,大部分类在具体用到的时候才会去加载,这样对内存更加友好。

这里我感觉有点懒加载的意思,懒加载是一种按需加载的技术,意味着只有在需要使用某个资源(如类、对象等)时,才去加载或初始化它,而不是在程序启动时一次性加载所有资源。这样可以节省内存,并减少不必要的资源占用,提高程序的效率。

对于已经加载的类会被放在 ClassLoader 中。在类加载的时候,系统会首先判断当前类是否被加载过。已经被加载的类会直接返回,否则才会尝试加载。(也是为了节省资源)也就是说,对于一个类加载器来说,相同二进制名称的类只会被加载一次。

总结

JVM中内置了三个重要的ClassLoader:

  1. Bootstrap ClassLoader(启动类加载器):最顶层的类加载类,由C++实现,它不属于 Java 类,通常表示为null,并且没有父级,主要用来加载JDK内部的核心类库(%JAVA_HOME%/lib目录下的 rt.jarresources.jarcharsets.jar等 jar 包和类)以及被 -Xbootclasspath参数指定的路径下的所有类(这个是用命令行方式启动执行时的参数)。
  2. Extension ClassLoader(扩展类加载器):负责加载 %JAVA_HOME%/lib/ext 目录中的类,属于Java类,继承自ClassLoader
  3. Application ClassLoader(应用程序类加载器):最常用的类加载器,用于加载用户的应用程序类,负责加载当前应用 classpath 下的所有 jar 包和类,也属于Java类,继承自ClassLoader。

 %JAVA_HOME% 是你设置的环境变量,用来指向你本机安装的JDK的路径,他为操作系统和其他应用程序提供了一个统一的位置来查找JDK,是的你可以在任意位置编译和运行你的Java程序

扩展:

  • rt 代表“RunTime”,rt.jar是 Java 基础类库,包含 Java doc 里面看到的所有的类的类文件。也就是说,我们常用内置库 java.xxx.*都在里面,比如java.util.*java.io.*java.nio.*java.lang.*java.sql.*java.math.*
  • Java 9 引入了模块系统,并且略微更改了上述的类加载器。扩展类加载器被改名为平台类加载器(platform class loader)。Java SE 中除了少数几个关键模块,比如说 java.base 是由启动类加载器加载之外,其他的模块均由平台类加载器所加载

这三个 ClassLoader 形成了一个 双亲委派模型,即类加载时会先请求父类加载器进行加载,只有当父类加载器无法加载时,才由当前类加载器加载。

除了 BootstrapClassLoader 是JVM自身的一部分以外,其他所有的类加载器都是在JVM外部实现的,并且全部继承于ClassLoader抽象类,这样做的好处是,用户可以自定义类加载器,以便让应用程序自己决定如何去获取所需的类。

每个 ClassLoader 都可以通过 getParent() 方法获得其父ClassLoader,如果获得到的ClassLoader为null,说明该类是通过BootstrapClassLoader加载的。

为什么呢?
这是因为 BootstrapClassLoader  由 C++ 实现,由于这个 C++ 实现的类加载器在 Java 中是没有与之对应的类的,所以拿到的结果是 null。 

自定义类加载器

上边提到,除了 BootstrapClassLoader 其它类加载器均由Java实现并且全部继承自ClassLoader抽象类,如果我们要自定义自己的类加载器,很明显要继承ClassLoader抽象类。

其中有两个很重要的方法:

  • protected Class loadClass(string name, boolean resolve):
  • protected Class findClass(string name)

根据命名我们也能猜到大概意思,第一个方法是用来加载的,第二个方法是用来查找的。

loadClass方法加载指定二进制名称的类,实现了双亲委派机制,第二个参数如果为true,加载时调用 resolveClass(class<?> c)方法解析该类。

findClass方法是根据类的二进制名称来查找类,默认实现是空方法

如果我们不想打破双亲委派模型,就重写 ClassLoader 类中的 findClass() 方法即可,无法被父类加载器加载的类最终会通过这个方法被加载。但是,如果想打破双亲委派模型则需要重写 loadClass() 方法。

一个非数组类的加载阶段(加载阶段获取类的二进制字节流的动作)是可控性最强的阶段,这一步我们可以去完成还可以自定义类加载器去控制字节流的获取方式(重写一个类加载器的 loadClass() 方法)。

双亲委派模型

模型介绍:

要点主要就以下三点:

  1. ClassLoader类使用委托模型来搜索类和资源
  2. 双亲委派模型要求除了顶层的启动类加载器外(Bootstrap ClassLoader),其他的类加载器实例都有自己的父加载类
  3. ClassLoader实例会在尝试亲自查找类或资源之前,将搜索类和资源的任务委托给其父类加载器

类加载器层次关系图 

 

 有几点要说明的:

这个图展示了各种类加载器之间的层次关系,这个层次关系被称为类加载器的“双亲委派模型(Parents Delegation Model)”。在这个模型中,自顶向下尝试加载类,自底向上查找判断类是否被加载。自定义类加载器可以有同一个类加载器,自定义类加载的父加载器同样可以是自定义类加载器。

值得注意的是,类加载器之间的父子关系一般不是以继承的关系来实现的,而是通常使用组合关系来复用父加载器的代码。
在面向对象编程中,有一条非常经典的设计原则:组合优于继承,多用组合少用继承。

模型执行流程:

简单总结为以下:

  1. 首先在类加载过程中,系统会先去确认下当前类是否被加载过,已经被加载的类直接返回,否则才回去尝试加载(即便委派给父类加载器也是如此)。
  2. 在类加载器进行类加载的过程中,它首先不会自己去尝试加载,而是将这个请求委派给父类加载器去完成(调用父类加载器的loadClass()方法来加载类)。这样的话按照层次模型,所有请求最终都会传送到顶层的启动类加载器(BootstrapClassLoader)。
  3. 只有当父类加载器反馈自己无法完成这个加载请求(他的搜索范围内没有找到所需类)时,子类加载器才会自己尝试去加载(调用自己的 findClass() 方法来加载类)。
  4. 如果子类加载器也无法加载这个类,那么就抛出一个 ClassNotFoundException 异常。

拓展:JVM 判定两个 Java 类是否相同的具体规则
JVM 不仅要看类的全名是否相同,还要看加载此类的类加载器是否一样。只有两者都相同的情况,才认为两个类是相同的。即使两个类来源于同一个 Class 文件,被同一个虚拟机加载,只要加载它们的类加载器不同,那这两个类就必定不相同。 

模型好处:

双亲委派模型保证了 Java 程序的稳定运行,可以避免类的重复加载(JVM 区分不同类的方式不仅仅根据类名,相同的类文件被不同的类加载器加载产生的是两个不同的类),也保证了 Java 的核心 API 不被篡改。

打破双亲委派模型方法:

自定义加载器的话,需要继承 ClassLoader。如果我们不想打破双亲委派模型,就重写 ClassLoader类中的 findClass() 方法即可,无法被父类加载器加载的类最终会通过这个方法被加载。但是,如果想打破双亲委派模型则需要重写 loadClass() 方法。

在模型执行流程处提到了,这是因为类加载进行类加载时,首先不会自己去尝试加载,会委派给父类加载器,而具体就是调用父类加载器的 loadClass() 方法

重写 loadClass()方法之后,我们就可以改变传统双亲委派模型的执行流程。例如,子类加载器可以在委派给父类加载器之前,先自己尝试加载这个类,或者在父类加载器返回之后,再尝试从其他地方加载这个类。具体的规则由我们自己实现,根据项目需求定制化。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值