JVM,看这个系列就够了
Posted 不能只会写代码
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了JVM,看这个系列就够了相关的知识,希望对你有一定的参考价值。
上一篇文章我们主要讲了「垃圾收集器」相关的知识点,没有看的小伙伴可以 回顾一下,今天我们来讲一下JVM中的对象以及类加载。
一、JVM中的对象
前面我们已经了解过Java虚拟机的运行时数据区域,接下来我们来更进一步了解一下,这些虚拟机内存中数据的其他细节,比如它们是如何创建、如何布局以及如何访问的。我们会以最常用的HotSpot虚拟机和最常用的内存区域Java堆为例,来说一下HotSpot虚拟机在Java堆中对象分配、布局和访问的全过程。
▍对象的创建
Java对象创建的过程大致如下:
类加载检查:虚拟机遇到⼀条new指令时,⾸先将去检查这个指令的参数是否能在常量池中定位到这个类的符号引⽤,并且检查这个符号引⽤代表的类是否已被加载过、解析和初始化过。如果没有,那必须先执⾏相应的类加载过程。
分配内存:在类加载检查通过后,接下来虚拟机将为新⽣对象分配内存。对象所需的内存⼤⼩在类加载完成后便可确定,为对象分配空间的任务就是,把⼀块确定⼤⼩的内存从Java堆中划分出来。分配⽅式有「指针碰撞」和「空闲列表」两种,选择那种分配⽅式由Java堆是否规整决定,⽽Java堆是否规整⼜由所采⽤的垃圾收集器是否带有压缩整理功能决定,即垃圾收集器采用的算法是“标记-清除”(不规整),还是“标记-整理”或者“复制”算法(规整)。
初始化零值:内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),这⼀步操作保证了对象的实例字段在Java代码中可以不赋初始值就直接使⽤,程序能访问到这些字段的数据类型所对应的零值。
设置对象头:初始化零值完成之后,虚拟机要对对象进⾏必要的设置,例如这个对象是那个类的实例、如何才能找到类的元数据信息、对象的哈希吗、对象的GC分代年龄等信息,这些信息都存放在对象头中。另外,根据虚拟机当前运⾏状态的不同,如是否启⽤偏向锁等,对象头会有不同的设置⽅式。
执⾏init⽅法:在上⾯⼯作都完成之后,从虚拟机的视⻆来看,⼀个新的对象已经产⽣了,但从Java 程序的视⻆来看,对象创建才刚开始,
⽅法还没有执⾏,所有的字段都还为零。所以⼀般来说,执⾏new指令之后会接着执⾏构造 ⽅法,把对象按照程序员的意愿进⾏初始化,这样⼀个真正可⽤的对象才算完全产⽣出来。
▍对象的内存布局
在HotSpot虚拟机里,对象在堆内存中的存储布局可以划分为三个部分:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。如下图:
HotSpot虚拟机对象的对象头部分包括3类信息:
第一类是用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等,这部分数据的长度在32位和64位的虚拟机(未开启压缩指针)中分别为32个比特和64个比特,官方称它为“Mark Word”。考虑到虚拟机的空间效率,Mark Word被设计成一个有着动态定义的数据结构,以便在极小的空间内存储尽量多的数据,根据对象的状态复用自己的存储空间。比如在64位的HotSpot虚拟机中,如对象未被同步锁锁定的状态下,Mark Word的64个比特存储空间中的31个比特用于存储对象哈希码,4个比特用于存储对象分代年龄,2个比特用于存储锁标志位,在其他状态(轻量级锁、重量级锁、偏向锁)下对象的存储内容变化如下图所示:
对象头的另外一部分是类型指针,即对象指向它的类型元数据的指针,Java虚拟机通过这个指针来确定该对象是哪个类的实例。并不是所有的虚拟机实现都必须在对象数据上保留类型指针,查找对象的元数据信息并不一定要经过对象本身。
如果对象是一个Java数组,那在对象头中还必须有一块用于记录数组长度的数据,因为虚拟机可以通过普通Java对象的元数据信息确定Java对象的大小,但是如果数组的长度是不确定的,将无法通过元数据中的信息推断出数组的大小。
实例数据(Instance Data)
实例数据部分是对象真正存储的有效信息,也就是我们在程序代码里面所定义的各种类型的字段内容,无论是从父类继承下来的,还是在子类中定义的都需要记录下来。这部分的存储顺序会受到虚拟机分配策略参数FieldsAllocationStyle,和字段在Java源码中定义顺序的影响。
HotSpot虚拟机默认的分配策略为longs/doubles、ints、shorts/chars、bytes/booleans、oops(Ordinary Object Pointers),从分配策略中可以看出,相同宽度的字段总是被分配到一起,在满足这个前提条件的情况下,在父类中定义的变量会出现在子类之前。如果CompactFields参数值为true(默认为true),那子类之中较窄的变量也可能会插入到父类变量的空隙之中。
对齐填充(Padding)
▍对象的访问定位
建⽴对象就是为了使⽤对象,我们的Java程序通过栈上的reference数据来操作堆上的具体对象。对象的访问⽅式由虚拟机的实现⽽定,⽬前主流的访问⽅式有2种,分别是:使⽤句柄、直接指针。
优势是,速度更快,它节省了一次指针定位的时间开销,由于对象访问在Java中非常频繁,因此这类开销积少成多也是一项极为可观的执行成本。
二、类加载
Java虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型,这个过程被称作虚拟机的类加载机制。
▍类加载过程
一个类,从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期将会经历:加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸载(Unloading)这7个阶段,其中验证、准备、解析三个部分统称为连接(Linking)。过程如下图:
1、加载:
加载(Loading)阶段是整个“类加载”(Class Loading)过程中的一个阶段,在加载阶段,Java虚拟机主要完成以下三件事情:
通过一个类的全限定名来获取定义此类的二进制字节流。
将这个字节流所代表的静态存储结构转化为堆的运行时数据结构。
在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。
加载阶段结束后,Java虚拟机外部的二进制字节流,就按照虚拟机所设定的格式存储在方法区之中了,方法区中的数据存储格式完全由虚拟机实现自行定义,《Java虚拟机规范》未规定此区域的具体数据结构。类型数据妥善安置在方法区之后,会在Java堆内存中实例化一个java.lang.Class类的对象,这个对象将作为程序访问方法区中的类型数据的外部接口。
2、验证:
验证是连接阶段的第一步,这一阶段的目的是确保Class文件的字节流中,包含的信息符合《Java虚拟机规范》的全部约束要求,保证这些信息被当作代码运行后不会危害虚拟机自身的安全,也就是安全性的校验。验证阶段大致上会完成4个阶段的检验动作:
文件格式验证:验证字节流是否符合Class文件格式的规范,并且能否被当前版本的虚拟机处理。需要验证魔数、版本号、常量池常量类型是否支持、指向常量的索引值等等。
元数据验证:对字节码描述的信息进行语义分析,以保证其描述的信息符合《Java语言规范》的要求,主要包括类是否有父类、父类是否继承了final修饰的类、非抽象类是否实现了父类定义的方法、类是否与父类有矛盾等等。
字节码验证:最为复杂的一步,主要是通过数据流分析和控制流分析,确定程序语义是合法的、符合逻辑的。
符号引用验证:主要验证类是否缺少或者被禁止访问它依赖的某些外部类、方法、字段等资源。它发生在虚拟机将符号引用转化为直接引用的时候,这个转化动作将在连接的第三阶段——解析阶段中发生。
3、准备:
准备阶段是正式为类中定义的变量(即静态变量,被static修饰的变量,不包括实例变量)分配内存并设置类变量初始值的阶段。在JDK 7及之前,这些变量的内存在方法区(永久代)中分配,在JDK 8及之后,静态变量则会随着Class对象一起存放在Java堆中。
4、解析:
解析阶段是Java虚拟机将常量池内的符号引用替换为直接引用的过程,先来看下什么是符号引用和直接引用:
符号引用(Symbolic References):符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。
直接引用(Direct References):直接引用是可以直接指向目标的指针、相对偏移量或者是一个能间接定位到目标的句柄。
符号引用和直接引用的最主要区别在于,能否被JVM「直接使用」。
5、初始化:
这个阶段会根据程序员通过程序编码制定的主观计划,去初始化类变量和其他资源,也是执行类构造器方法的阶段。我们上面说到的准备阶段,变量被赋予的是系统要求的零值,而在初始化阶段,赋予的是代码里编写的值。
▍类加载器
Java虚拟机设计团队有意把类加载阶段中的“通过一个类的全限定名来获取描述该类的二进制字节流”这个动作放到Java虚拟机外部去实现,以便让应用程序自己决定如何去获取所需的类,而实现这个动作的代码被称为“类加载器”(Class Loader)。
1、类与类加载器
类加载器虽然只用于实现类的加载动作,但它在Java程序中起到的作用却远超类加载阶段。对于任意一个类,都必须由加载它的类加载器和这个类本身一起共同确立其在Java虚拟机中的唯一性,每一个类加载器都拥有一个独立的类名称空间。这句话可以表达得更通俗一些:比较两个类是否“相等”,只有在这两个类是由同一个类加载器加载的前提下才有意义,否则即使这两个类来源于同一个Class文件,被同一个Java虚拟机加载,只要加载它们的类加载器不同,那这两个类就必定不相等。
2、双亲委派模型
JVM中内置了三个重要的ClassLoader:
启动类加载器(Bootstrap Class Loader): 由C++语言实现,是虚拟机自身的一部分。这个类加载器负责加载存放在 <JAVA_HOME>\lib目录,或者被-Xbootclasspath参数所指定的路径中存放的,而且是Java虚拟机能够识别的(按照文件名识别,如rt.jar、tools.jar,名字不符合的类库即使放在lib目录中也不会被加载)类库加载到虚拟机的内存中。通俗点记忆就是,负责加载Java的核心类(JRE/lib/rt.jar)。
扩展类加载器(Extension Class Loader):它在类sun.misc.Launcher$ExtClassLoader中以Java语言实现的。它负责加载<JAVA_HOME>\lib\ext目录中,或者被java.ext.dirs系统变量所指定的路径中所有的类库。通俗点记忆就是,负责加载JRE的扩展目录中jar的类包(JRE/lib/ext/*.jar)。
应用程序类加载器(Application Class Loader):它在类 sun.misc.Launcher$AppClassLoader 中由Java语言实现的。由于应用程序类加载器是ClassLoader类中的getSystem-ClassLoader()方法的返回值,所以有些场合中也称它为“系统类加载器”,它负责加载用户类路径(ClassPath)上所有的jar类包和类路径。
双亲委派模型:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到最顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去完成加载。如下图:
那么为什么要使用双亲委派模型来组织类加载器之间的关系呢?
一句话总结就是,为了安全,同时也避免了重复加载。采用双亲委派模型的话,Java中的类随着它的类加载器一起具备了一种带有优先级的层次关系,比如类java.lang.Object,它存放在rt.jar之中,无论哪一个类加载器要加载这个类,最终都是委派给处于模型最顶端的启动类加载器进行加载,因此Object类在程序的各种类加载器环境中都能够保证是同一个类。反之,如果没有使用双亲委派模型,都由各个类加载器自行去加载的话,如果用户自己也编写了一个名为java.lang.Object的类,并放在程序的ClassPath中,那系统中就会出现多个不同的Object类,Java类型体系中最基础的行为也就无从保证,应用程序将会变得一片混乱。
3、Tomcat类加载器架构
Tomcat是主流的Java Web服务器之一,为了实现一些特殊的功能需求,自定义了一些类加载器,Tomcat类加载器如下:
Tomcat实际上是破坏了双亲委派模型的。
Tomact是web容器,可能需要部署多个应用程序,不同的应用程序可能会依赖同一个第三方类库的不同版本,但是不同版本的类库中某一个类的全路径名可能是一样的。比如多个应用都要依赖hollis.jar,但是A应用需要依赖1.0.0版本,但是B应用需要依赖1.0.1版本,这两个版本中都有一个类是com.hollis.Test.class,如果采用默认的双亲委派类加载机制,那么无法加载多个相同的类。所以Tomcat破坏双亲委派原则,提供隔离的机制,为每个web容器单独提供一个WebAppClassLoader加载器。
Tomcat的类加载机制:为了实现隔离性,优先加载Web应用自己定义的类,所以没有遵照双亲委派的约定。每一个应用自己的类加载器——WebAppClassLoader负责加载本身的目录下的class文件,加载不到时再交给CommonClassLoader加载,这和双亲委派刚好相反。
所以呀,凡事没有绝对,适合的才是最好的,找到适中的平衡点最为关键!
end
由于微信取消了新账号的留言功能,只能曲线救国了,有什么疑问或者建议都可以在下面的小程序内留言哈~~~
▍参考资料
这些不可不知的JVM知识,我都用思维导图整理好了
https://www.cnblogs.com/three-fighter/p/14402197.html
以上是关于JVM,看这个系列就够了的主要内容,如果未能解决你的问题,请参考以下文章