深入浅出 java 虚拟机

用户头像
AI乔治
关注
发布于: 2020 年 09 月 07 日
深入浅出java虚拟机

Java虚拟机:内存模型详解

  我们都知道,当虚拟机执行Java代码的时候,首先要把字节码文件加载到内存,那么这些类的信息都存放在内存中的哪个区域呢?当我们创建一个对象实例的时候,虚拟机要为对象分配内存,Java虚拟机又是如何配分内存的呢?这些都涉及到Java虚拟机的内存划分机制,今天我们就来探究一下Java虚拟机的内存模型。

Java虚拟机在执行Java程序的过程中会把它所管理的内存划分为若干个不同的数据区域,这些区域都有各自的用途以及创建和销毁的时间,有的区域随着虚拟机进程的启动而存在,有些区域则依赖用户线程的启动和结束而建立和销毁。根据Java虚拟机规范的规定,Java虚拟机所管理的内存包括以下几个数据区域。如下图所示:

      



以上就是Java虚拟机运行时数据区域的划分,每一块内存区域都有它的职责,存放着不同的运行时数据。

虚拟机栈

Java虚拟机栈是线程私有的,每一个线程在这个区域都有一块所属的内存区域,它的生命周期与线程相同,随线程启动而生,随线程消亡而灭。虚拟机栈描述的是Java方法执行的内存模型,每一个线程都对应着虚拟机栈区域里的一个栈数据结构,由于一个线程的方法调用链可能会很长,每一个方法在执行时都会创建一个栈帧,栈帧就是线程对应的栈数据结构的栈元素,栈帧用于存储局部变量表、操作数栈、动态链接等信息。局部变量表存放了方法参数和方法内部定义的局部变量,包括各种基本数据类型和对象引用类型等信息。经常听到有的程序猿粗糙的把虚拟机内存划分为堆内存和栈内存,这种划分只能说明大多数程序猿比较关注的、与对象内存分配关系最密切的是这两块内存区域,其中的“栈内存”就是这里所说的虚拟机栈,而虚拟机栈里我们程序猿最为关注的就是局部变量表部分。

本地方法栈

本地方法栈也是线程私有的,它与虚拟机栈发挥的作用相似,它们之间的区别不过是虚拟机栈为虚拟机执行Java方法服务,而本地方法栈则为虚拟机使用到的Native方法(本地方法)服务。在Java虚拟机规范中并没有对本地方法栈的实现做强制规定,有的虚拟机甚至直接把虚拟机栈和本地方法栈合二为一。

Java堆是所有线程所共享的一块内存区域,也是Java虚拟机所管理的内存中最大的一块。这块内存的唯一目的就是存放对象实例,几乎所有的对象实例都在这里分配内存。Java堆是垃圾收集器管理的主要区域,从内存回收的角度看,由于现在收集器基本都采用分代收集算法,所以Java堆还可以细分为新生代和老年代。新生代还可以再细分为Eden区域、FromSurvivor区域和ToSurvivor区域。无论怎么划分,都与存放的内容无关,存储的任然都是对象实例。进一步划分的目的是为了更好的回收或者更快的分配内存。

方法区

方法区与Java堆一样,是线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、编译器编译后的代码等数据。方法区是一个逻辑区,具体属于哪一块物理内存根据不同的虚拟机实现而定。在HotSpot的实现中,方法区逻辑上与堆内存隔离,物理存储上却是是属于Java堆的一部分。很多人把方法区称为“永久代”,其实是HotSpot使用永久代来实现方法区而已。其他的虚拟机实现并没有永久代这一概念。Java虚拟机规范对方法区的限制非常宽松,除了和Java堆一样不需要连续的内存和可以选择固定大小或者可扩展外,还可以选择不实现垃圾回收。一般来说不会在方法区进行垃圾回收,在这一区域进行回收的效果很难让人满意。当方法区无法满足内存分配需求时会抛出内存溢出异常。

运行时常量池是方法区的一部分,用于存放编译期间生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池中存储。

程序计数器

程序计数器是一块很小的内存空间,它也是线程私有的。它可以看作是当前线程所执行的字节码的行号指示器,通过改变这个计数器的值来选取下一行需要执行的字节码指令。由于Java虚拟机的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的,在任何一个确定的时刻,一个处理器内核只会执行一条线程中的指令,因此,为了线程切换后能够恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间互不影响,独立存储。

以上就是Java虚拟机的内存模型划分,这是我们程序猿必须掌握的原理,弄清Java虚拟机的内存模型,是理解虚拟机内存分配和垃圾回收的基础,以此作为总结。

Java虚拟机:如何判定哪些对象可回收?

在堆内存中存放着Java程序中几乎所有的对象实例,堆内存的容量是有限的,Java虚拟机会对堆内存进行管理,回收已经“死去”的对象(即不可能再被任何途径使用的对象),释放内存。垃圾收集器在对堆内存进行回收前,首先要做的第一件事就是确定这些对象中哪些还存活着,哪些已经死去。Java虚拟机是如何判断对象是否可以被回收的呢?

引用计数算法

引用计数算法的原理是这样的:给对象添加一个引用计数器,每当有一个地方引用它时,计数器值就加1;当引用失效时,计数器值就减1;在任何时刻计数器的值为0的对象就是不可能再被使用的,也就是可被回收的对象。

引用计数算法的效率很高,但是主流的JVM并没有选用这种算法来判定可回收对象,因为它有一个致命的缺陷,那就是它无法解决对象之间相互循环引用的的问题,对于循环引用的对象它无法进行回收。

假设有这样一段代码:

public class Object {​ public Object instance;​ public static void main(String[] args) {​ // 1 Object objectA = new Object(); Object objectB = new Object();​ // 2 objectA.instance = objectB; objectB.instance = objectA;​ // 3 objectA = null; objectB = null;​ }



 程序启动后,objectA和objectB两个对象被创建并在堆中分配内存,这两个对象都相互持有对方的引用,除此之外,这两个对象再无任何其他引用,实际上这两个对象已经不可能再被访问(引用被置空,无法访问),但是它们因为相互引用着对方,导致它们的引用计数器都不为0,于是引用计数算法无法通知GC收集器回收它们。

实际上,当第1步执行时,两个对象的引用计数器值都为1;当第2步执行时,两个对象的引用计数器都为2;当第3步执行时,二者都清为空值,引用计数器值都变为1。根据引用计数算法的思想,值不为0的对象被认为是存活的,不会被回收;而事实上这两个对象已经不可能再被访问了,应该被回收。

可达性分析算法

在主流的JVM实现中,都是通过可达性分析算法来判定对象是否存活的。可达性分析算法的基本思想是:通过一系列被称为"GC Roots"的对象作为起始点,从这些节点开始向下搜索,搜索走过的路径称为引用链,当一个对象到GC Roots对象没有任何引用链相连,就认为GC Roots到这个对象是不可达的,判定此对象为不可用对象,可以被回收。       



在上图中,objectA、objectB、objectC是可达的,不会被回收;objectD、objectE虽然有关联,但是它们到GC Roots是不可达的,所以它们将会被判定为是可回收的对象。

在Java中,可作为GC Roots的对象包括下面几种:

1、虚拟机栈中引用的对象;

2、方法区中类静态属性引用的对象;

3、方法区中常量引用的对象;

4、本地方法栈中Native方法引用的对象。



Java虚拟机:GC算法深度解析

在上面的内容里里介绍了可达性分析算法,它为我们解决了判定哪些对象可以回收的问题,接下来就该我们的垃圾收集算法出场了。不同的垃圾收集算法有各自不同的优缺点,在JVM实现中,往往不是采用单一的一种算法进行回收,而是采用几种不同的算法组合使用,来达到最好的收集效果。接下来详细介绍几种垃圾收集算法的思想及发展过程。

最基础的收集算法 —— 标记/清除算法

之所以说标记/清除算法是几种GC算法中最基础的算法,是因为后续的收集算法都是基于这种思路并对其不足进行改进而得到的。标记/清除算法的基本思想就跟它的名字一样,分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。

标记阶段:标记的过程其实就是前面介绍的可达性分析算法的过程,遍历所有的GC Roots对象,对从GC Roots对象可达的对象都打上一个标识,一般是在对象的header中,将其记录为可达对象;

清除阶段:清除的过程是对堆内存进行遍历,如果发现某个对象没有被标记为可达对象(通过读取对象header信息),则将其回收。

       



上图是标记/清除算法的示意图,在标记阶段,从对象GC Root 1可以访问到B对象,从B对象又可以访问到E对象,因此从GC Root 1到B、E都是可达的,同理,对象F、G、J、K都是可达对象;到了清除阶段,所有不可达对象都会被回收。

在垃圾收集器进行GC时,必须停止所有Java执行线程(也称"Stop The World"),原因是在标记阶段进行可达性分析时,不可以出现分析过程中对象引用关系还在不断变化的情况,否则的话可达性分析结果的准确性就无法得到保证。在等待标记清除结束后,应用线程才会恢复运行。

前面刚提过,后续的收集算法是在标记/清除算法的基础上进行改进而来的,那也就是说标记/清除算法有它的不足。其实了解了它的原理,其缺点也就不难看出了。

1、效率问题。标记和清除两个阶段的效率都不高,因为这两个阶段都需要遍历内存中的对象,很多时候内存中的对象实例数量是非常庞大的,这无疑很耗费时间,而且GC时需要停止应用程序,这会导致非常差的用户体验。

2、空间问题。标记清除之后会产生大量不连续的内存碎片(从上图可以看出),内存空间碎片太多可能会导致以后在程序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾回收动作。

既然标记/清除算法有这么多的缺点,那它还有存在的意义吗?别急,一个算法有缺陷,人们肯定会想办法去完善它,接下来的两个算法就是在标记/清除算法的基础上完善而来的。

复制算法

为了解决效率问题,复制算法出现了。复制算法的原理是:将可用内存按容量划分为大小相等的两块,每次使用其中的一块。当这一块的内存用完了,就将还存活的对象复制到另一块内存上,然后把这一块内存所有的对象一次性清理掉。用图说明如下:

 回收前:

       



回收后:

       



复制算法每次都是对整个半区进行内存回收,这样就减少了标记对象遍历的时间,在清除使用区域对象时,不用进行遍历,直接清空整个区域内存,而且在将存活对象复制到保留区域时也是按地址顺序存储的,这样就解决了内存碎片的问题,在分配对象内存时不用考虑内存碎片等复杂问题,只需要按顺序分配内存即可。

复制算法简单高效,优化了标记/清除算法的效率低、内存碎片多的问题。但是它的缺点也很明显:

1、将内存缩小为原来的一半,浪费了一半的内存空间,代价太高;

2、如果对象的存活率很高,极端一点的情况假设对象存活率为100%,那么我们需要将所有存活的对象复制一遍,耗费的时间代价也是不可忽视的。

基于以上复制算法的缺点,由于新生代中的对象几乎都是“朝生夕死”的(达到98%),现在的商业虚拟机都采用复制算法来回收新生代。由于新生代的对象存活率低,所以并不需要按照1:1的比例来划分内存空间,而是将内存分为一块较大的Eden空间和两块较小的From Survivor空间、To Survivor空间,三者的比例为8:1:1。每次使用Eden和From Survivor区域,To Survivor作为保留空间。

GC开始时,对象只会存在于Eden区和From Survivor区,To Survivor区是空的。GC进行时,Eden区中所有存活的对象都会被复制到To Survivor区,而在From Survivor区中,仍存活的对象会根据它们的年龄值决定去向,年龄值达到年龄阀值(默认为15,新生代中的对象每熬过一轮垃圾回收,年龄值就加1)的对象会被移到老年代中,没有达到阀值的对象会被复制到To Survivor区。接着清空Eden区和From Survivor区,新生代中存活的对象都在To Survivor区。接着, From Survivor区和To Survivor区会交换它们的角色,也就是新的ToSurvivor区就是上次GC清空的From Survivor区,新的From Survivor区就是上次GC的ToSurvivor区,

总之,不管怎样都会保证To Survivor区在一轮GC后是空的。GC时当To Survivor区没有足够的空间存放上一次新生代收集下来的存活对象时,需要依赖老年代进行分配担保,将这些对象存放在老年代中。

标记/整理算法

复制算法在对象存活率较高时要进行较多的复制操作,效率会变得很低,更关键的是,如果不想浪费50%的内存空间,就需要有额外的内存空间进行分配担保,以应对内存中对象100%存活的极端情况,因此,在老年代中由于对象的存活率非常高,复制算法就不合适了。根据老年代的特点,高人们提出了另一种算法:标记/整理算法。从名字上看,这种算法与标记/清除算法很像,事实上,标记/整理算法的标记过程任然与标记/清除算法一样,但后续步骤不是直接对可回收对象进行回收,而是让所有存活的对象都向一端移动,然后直接清理掉端边线以外的内存。

回收前:       





回收后:





可以看到,回收后可回收对象被清理掉了,存活的对象按规则排列存放在内存中。这样一来,当我们给新对象分配内存时,jvm只需要持有内存的起始地址即可。标记/整理算法不仅弥补了标记/清除算法存在内存碎片的问题,也消除了复制算法内存减半的高额代价,可谓一举两得。但任何算法都有缺点,就像人无完人,标记/整理算法的缺点就是效率也不高,不仅要标记存活对象,还要整理所有存活对象的引用地址,在效率上不如复制算法。

弄清了以上三种算法的原理,下面我们来从几个方面对这几种算法做一个简单排行。

效率:复制算法 > 标记/整理算法 > 标记/清除算法(标记/清除算法有内存碎片问题,给大对象分配内存时可能会触发新一轮垃圾回收)

内存整齐率:复制算法 = 标记/整理算法 > 标记/清除算法

内存利用率:标记/整理算法 = 标记/清除算法 > 复制算法

从上面简单的评估可以看出,标记/清除算法已经比较落后了,但是吃水不忘挖井人,它是后面几种算法的前辈、是基础,在某些场景下它也有用武之地。

终极算法 —— 分代收集算法

当前商业虚拟机都采用分代收集算法,说它是终极算法,是因为它结合了前几种算法的优点,将算法组合使用进行垃圾回收,与其说它是一种新的算法,不如说它是对前几种算法的实际应用。分代收集算法的思想是按对象的存活周期不同将内存划分为几块,一般是把Java堆分为新生代和老年代(还有一个永久代,是HotSpot特有的实现,其他的虚拟机实现没有这一概念,永久代的收集效果很差,一般很少对永久代进行垃圾回收),这样就可以根据各个年代的特点采用最合适的收集算法。

新生代:朝生夕灭,存活时间很短。

老年代:经过多次Minor GC而存活下来,存活周期长。

在新生代中每次垃圾回收都发现有大量的对象死去,只有少量存活,因此采用复制算法回收新生代,只需要付出少量对象的复制成本就可以完成收集;而老年代中对象的存活率高,不适合采用复制算法,而且如果老年代采用复制算法,它是没有额外的空间进行分配担保的,因此必须使用标记/清理算法或者标记/整理算法来进行回收。

总结一下就是,分代收集算法的原理是采用复制算法来收集新生代,采用标记/清理算法或者标记/整理算法收集老年代。

以上内容介绍了几种收集算法的原理、优缺点以及使用场景,它们的共同点是:当GC线程启动时(即进行垃圾收集),应用程序都要暂停(Stop The World)。理解了这些知识,为我们研究垃圾收集器的运行原理打下了基础。



Java虚拟机:JVM内存分代策略

Java虚拟机根据对象存活的周期不同,把堆内存划分为几块,一般分为新生代、老年代和永久代(对HotSpot虚拟机而言),这就是JVM的内存分代策略。

为什么要分代?

堆内存是虚拟机管理的内存中最大的一块,也是垃圾回收最频繁的一块区域,我们程序所有的对象实例都存放在堆内存中。给堆内存分代是为了提高对象内存分配和垃圾回收的效率。试想一下,如果堆内存没有区域划分,所有的新创建的对象和生命周期很长的对象放在一起,随着程序的执行,堆内存需要频繁进行垃圾收集,而每次回收都要遍历所有的对象,遍历这些对象所花费的时间代价是巨大的,会严重影响我们的GC效率,这简直太可怕了。

有了内存分代,情况就不同了,新创建的对象会在新生代中分配内存,经过多次回收仍然存活下来的对象存放在老年代中,静态属性、类信息等存放在永久代中,新生代中的对象存活时间短,只需要在新生代区域中频繁进行GC,老年代中对象生命周期长,内存回收的频率相对较低,不需要频繁进行回收,永久代中回收效果太差,一般不进行垃圾回收,还可以根据不同年代的特点采用合适的垃圾收集算法。分代收集大大提升了收集效率,这些都是内存分代带来的好处。

内存分代划分

Java虚拟机将堆内存划分为新生代、老年代和永久代,永久代是HotSpot虚拟机特有的概念,它采用永久代的方式来实现方法区,其他的虚拟机实现没有这一概念,而且HotSpot也有取消永久代的趋势,在JDK 1.7中HotSpot已经开始了“去永久化”,把原本放在永久代的字符串常量池移出。永久代主要存放常量、类信息、静态变量等数据,与垃圾回收关系不大,新生代和老年代是垃圾回收的主要区域。内存分代示意图如下:

        



新生代(Young)

新生成的对象优先存放在新生代中,新生代对象朝生夕死,存活率很低,在新生代中,常规应用进行一次垃圾收集一般可以回收70% ~ 95% 的空间,回收效率很高。

HotSpot将新生代划分为三块,一块较大的Eden空间和两块较小的Survivor空间,默认比例为8:1:1。划分的目的是因为HotSpot采用复制算法来回收新生代,设置这个比例是为了充分利用内存空间,减少浪费。新生成的对象在Eden区分配(大对象除外,大对象直接进入老年代),当Eden区没有足够的空间进行分配时,虚拟机将发起一次Minor GC。

GC开始时,对象只会存在于Eden区和From Survivor区,To Survivor区是空的(作为保留区域)。GC进行时,Eden区中所有存活的对象都会被复制到To Survivor区,而在From Survivor区中,仍存活的对象会根据它们的年龄值决定去向,年龄值达到年龄阀值(默认为15,新生代中的对象每熬过一轮垃圾回收,年龄值就加1,GC分代年龄存储在对象的header中)的对象会被移到老年代中,没有达到阀值的对象会被复制到To Survivor区。接着清空Eden区和From Survivor区,新生代中存活的对象都在To Survivor区。接着, From Survivor区和To Survivor区会交换它们的角色,也就是新的To Survivor区就是上次GC清空的From Survivor区,新的From Survivor区就是上次GC的To Survivor区,总之,不管怎样都会保证To Survivor区在一轮GC后是空的。GC时当To Survivor区没有足够的空间存放上一次新生代收集下来的存活对象时,需要依赖老年代进行分配担保,将这些对象存放在老年代中。

老年代(Old)

在新生代中经历了多次(具体看虚拟机配置的阀值)GC后仍然存活下来的对象会进入老年代中。老年代中的对象生命周期较长,存活率比较高,在老年代中进行GC的频率相对而言较低,而且回收的速度也比较慢。

永久代(Permanent)

永久代存储类信息、常量、静态变量、即时编译器编译后的代码等数据,对这一区域而言,Java虚拟机规范指出可以不进行垃圾收集,一般而言不会进行垃圾回收。

Minor GC 和 Full GC的区别

新生代GC(Minor GC):Minor GC指发生在新生代的GC,因为新生代的Java对象大多都是朝生夕死,所以Minor GC非常频繁,一般回收速度也比较快。当Eden空间不足以为对象分配内存时,会触发Minor GC。

老年代GC(Full GC/Major GC):Full GC指发生在老年代的GC,出现了Full GC一般会伴随着至少一次的Minor GC(老年代的对象大部分是Minor GC过程中从新生代进入老年代),比如:分配担保失败。Full GC的速度一般会比Minor GC慢10倍以上。当老年代内存不足或者显式调用System.gc()方法时,会触发Full GC。



Java虚拟机:类加载机制详解

大家知道,我们的Java程序被编译器编译成class文件,在class文件中描述的各种信息,最终都需要加载到虚拟机内存才能运行和使用,那么虚拟机是如何加载这些class文件的呢?在加载class文件的过程中虚拟机又干了哪些事呢?今天我们来解密虚拟机的类加载机制。

虚拟机把class文件加载到内存,并对数据进行校验、解析和初始化,最终形成可以被虚拟机直接使用的Java类型(Class对象),这就是虚拟机的类加载机制。

类从被加载到虚拟机内存开始,到卸载出内存为止,它的整个生命周期包括:加载、验证、准备、解析、初始化、使用和卸载7个阶段。,其中验证、准备和解析3个阶段统称为连接阶段。如图:       



前面的5个阶段就是类加载的过程。其中加载、验证、准备和初始化这几个阶段的顺序是确定的,而解析阶段则不一定,在某些情况下它可以在初始化阶段以后才进行。那么,在类加载的每一个步骤中,虚拟机都进行了那些工作呢?

加载



1、通过类的全限定名来获取定义这个类的二进制字节流。简单来说就是,通过类的包名加类名来定位到此类的class文件的位置,相当于一个资源定位的过程。

2、将这个字节流代表的静态存储结构转化为方法区的运行时数据结构。也就是将类中定义的静态变量、常量等信息存储在方法区中。

3、在堆内存中生成一个代表这个类的java.lang.Class对象,作为方法区中这个类的各种数据的访问入口。

总结一下,加载阶段的主要工作就是,把class二进制文件加载到内存后,将类中定义的静态变量、常量、类信息等数据存放到方法区,并在堆内存中创建一个代表这个类的Class对象,作为方法区中这个类的数据信息的访问入口,程序猿可以持有这个Class对象。

验证

验证是连接阶段的第一步,验证阶段的目的是确保class文件中包含的信息符合虚拟机的要求,并且不会危害到虚拟机自身的安全。验证的内容主要包含以下几个方面:

1、文件格式验证。主要目的是保证输入的字节流能正确的解析并存储在方法区中,格式上符合一个Java类型信息的要求。这个阶段的验证是基于二进制字节流进行的,只有通过了这个阶段的验证,字节流才能进入方法区进行存储,所有后面的3个阶段的验证都是基于方法区的存储结构进行的,不会直接操作字节流。

2、元数据验证。这一阶段的主要目的是对类的元数据(定义数据的数据)信息进行语义校验,确保不存在不符合Java语言规范的元数据信息。包括:该类是否有父类、该类的父类是否继承了不允许被继承的类、该类中的字段和方法是否与父类产生矛盾等等。

3、字节码验证。目的是通过数据流和控制流分析,确定程序语义是否合法、符合逻辑。在第二阶段对元数据信息的数据类型做完校验后,这个阶段将对类的方法体进行分析,保证被校验的类的方法在运行时不会危害虚拟机的安全。

4、符号引用验证。符号引用验证发生在虚拟机将符号引用转化为直接引用的时候,这个转化动作是在连接的第三阶段——解析阶段中进行的。符号引用验证的目的是确保解析动作能够正常执行。

对于类加载机制而言,验证阶段是一个非常重要、但不是一定必要的阶段。如果所运行的全部代码都已经被反复使用和验证过了,那么就可以使用虚拟机参数-Xverify:none来关闭大部分的类验证措施,以缩短类加载的时间。

准备

准备阶段的主要工作是为类的静态变量分配内存并设置变量的初始默认值。这些变量所使用的内存都在方法区中分配。这里有两个问题需要说明:

1、这一阶段进行内存分配的仅包括静态变量,而不包括实例变量(静态变量是所有对象共有的,实例变量是对象私有的),实例变量将会在对象实例化时随着对象一起分配在Java堆中。

2、这里说的为对象赋初始值是各数据类型对应的零值。假设有一个静态变量定义为public static int a = 1; 那变量a的初始值就是0而不是1,初始值1在初始化阶段赋给变量a。如果是引用类型初始默认值就是null。

解析

解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。

符号引用:符号引用以一组符号来描述所引用的目标,符号可以使任何形式的字面量,只要使用时能无歧义的定位到目标即可。符号引用的字面量形式在Java虚拟机规范的Class文件格式中有明确定义。

直接引用:直接引用可以是直接指向目标的指针、相对偏移量或者是一个能间接定位到目标的句柄。直接引用是和虚拟机实现的内存布局有关的,同一个符号引用在不同虚拟机实例上翻译出来的直接引用一般不会相同。如果有了直接引用,那么引用的目标必定已经在内存中存在。

解析动作主要针对类或接口、字段、静态方法、接口方法、方法类型、方法句柄和调用点限定符这几类符号引用进行。

初始化

初始化阶段是类加载过程的最后一步。在前面的类加载过程中,除了加载阶段我们可以通过自定义的类加载器参与之外,其余的阶段都是虚拟机自动完成的。到了初始化阶段,才真正开始执行我们程序中定义的Java代码。初始化阶段的主要工作是给类的静态变量赋予我们程序中指定的初始值。也就是上面准备阶段提到的,变量a的值从0变为1的过程。这个阶段我们程序指定初始值包括两种手段:

1、声明静态变量时显式的复制。例如:public static int a = 1; 在初始化阶段会把1赋给变量a。

2、通过静态代码块赋值。例如:static { a = 2 }; 变量a 的初始值赋为2。

这两种方式的赋值顺序是由语句在源文件中出现的顺序来决定的。 

以上就是Java虚拟机类加载机制的整个过程以及在每个阶段虚拟机所执行的动作。

双亲委派模型

前面提到过,在类加载的整个过程中,除了加载阶段我们可以通过自定义的类加载器参与之外,其他的阶段都是虚拟机帮我们完成的。虚拟机设计团队把加载这个动作放到Java虚拟机外部去实现,实现这个动作的代码模块称为“类加载器”。这样做的目的是让应用程序自己去决定如何获取所需要的类。

除了我们自己可以定义类加载器,Java虚拟机也为我们提供了系统自带的类加载器。主要可以分为以下三种:

根类加载器(Bootstrap ClassLoader):这个类加载器负责加载存放在<JAVA_HOME>\lib目录中的,或者通过参数-Xbootclasspath所指定的路径中的类。

扩展类加载器(Extension ClassLoader):这个加载器负责加载<JAVA_HOME>\lib\ext目录中的,或者被java.ext.dirs系统变量所指定的路径中的所有类库。

应用类加载器(Application ClassLoader):它负责加载用户设置的ClassPath路径上所指定的类库。如果应用程序中没有自定义的类加载器,一般情况下这个就是程序默认的类加载器。

我们的应用程序都是由这3种类加载器相互配合进行加载的,如果有必要,还可以定义自己的类加载器。这些类加载器之间的关系如下:

      



上图中展示的类加载器之间的层次关系,称为类加载器的双亲委派模型。双亲委派模型要求除了顶层的根类加载器外,其余的类加载器都应当有自己的父类加载器。双亲委派模型的工作过程是:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的类加载请求最终都应该传送到根类加载器中,只有当父加载器反馈自己无法完成这个加载请求(搜索范围中没有找到所需的类)时,子加载器才会尝试自己去加载。

类加载器虽然只用于实现类的加载动作,但是它在Java程序中起的作用却不仅仅是进行类加载。对于任意一个类,都需要由加载它的类加载器和这个类本身一同确立它在Java虚拟机中的唯一性。简单来说就是,一个类的class文件被不同的两个类加载器加载,那么加载后的这两个类就不“相等”,不是相同的类。

使用双亲委派模型,有一个显而易见的好处就是Java类随着它的类加载器一起具备了一种带有优先级的层次关系。例如java.lang.Object类,它存放在rt.jar中,无论哪一个类加载器要加载这个类,最终都会委派给模型最顶端的根类加载器进行加载,因此Object类在程序的各种类加载器环境中都是同一个类(始终被根类加载器加载)。相反,如果不使用双亲委派模型,由各个类加载器自己去加载的话,假如用户编写了一个称为java.lang.Object的类,并放在ClassPath中,那系统中会出现多个不同的Object类,应用程序也会变的一片混乱。



本文来自:微信公众号



原文链接https://mp.weixin.qq.com/s?__biz=MzU0MjYwNzEzOQ==&mid=2247485203&idx=1&sn=67b66516905673446429359f9bac2d52&chksm=fb19555fcc6edc498ea4f1c5977a25cc7de67fbb7b607a36fb064c03707053f4567c28550a57&token=140280806&lang=zh_CN#rd



发布于: 2020 年 09 月 07 日 阅读数: 69
用户头像

AI乔治

关注

分享后端技术干货。公众号【 Java烂猪皮】 2019.06.30 加入

一名默默无闻的扫地僧!

评论 (1 条评论)

发布
用户头像
图解+文字,写的不错,期待更好的文章

2020 年 09 月 07 日 23:59
回复
没有更多了
深入浅出java虚拟机