JVM相关知识点总结

1、运行时数据区域

本地方法栈、虚拟机栈、程序计数器
方法区、堆

1.1、线程私有

1.1.1、程序计数器

当前线程所执行的字节码的行号指示器,字节码解释器通过改变这个值来选取下一个要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复的功能需要程序计数器来完成,java方法这个计数器才有值,native方法这个计数器是空的。唯一一个没有任何OutOfMemoryError情况的区域

1.1.2、虚拟机栈

 线程私有,生命和线程相同,每个方法执行的同时都会创建一个栈帧,用于存储局部变量表、操作数栈、动态链接(每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接(Dynamic Linking))、方法出口等信息,每一个方法从调用直到执行完毕的过程,就对应一个栈帧从虚拟机中入栈到出栈的过程,栈的大小和具体的jvm实现有关。

  • StackOverflowError:线程请求的栈深度大于虚拟机所允许的深度。
  • OutOfMemoryError:如果虚拟机栈可以动态扩展,而扩展是无法申请到足够的内存

1.1.3、本地方法栈

和虚拟机栈作用一样,只不过方法栈为虚拟机使用到的native方法服务,Hotspot没有此块区域,和虚拟机栈放一起

  • StackOverflowError:线程请求的栈深度大于虚拟机所允许的深度。
  • OutOfMemoryError:如果虚拟机栈可以动态扩展,而扩展是无法申请到足够的内存

1.2、线程共享
1.2.1、 堆

用于存放对象实例,一般是java虚拟机所管理的最大的一块区域,在虚拟机启动时创建。堆还可以分为新生代和老年代,再细致一点还有Eden区、From Survivior区、To Servivor区

  • OutOfMemoryError:如果堆中没有内存完成实例分配,并且堆也无法再扩展时,抛出该异常。

1.2.2、方法区

用于存储虚拟机加载的类的信息、常量、静态变量、即时编译器编译后的代码等数据。虚拟机规范中把它描述为堆的一个逻辑部分,在分代收集算法角度,Hotspot中方法区≈永久代,jdk7之后Hotspot就没有永久代这个概念了,会采用Native Memory 来实现方法区的规划了

1.2.3、运行时常量池

方法区的一部分。class文件中除了有类的版本信息、字段、方法、接口等描述信息外,还有一项信息就是常量池,用于存放编译期间生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池中,另外翻译出来的直接引用也会存储在这个区域中。另外一个特点是动态性,java并不要求常量就一定要在编译期间才产生,运行期间也可以在这个区域中放入新内容,
String.inten()方法就是这个特性的应用
    内存有限,无法申请时抛出 OutOfMemoryError。

1.3、直接内存

并不是虚拟机运行时数据区的一部分,也不是java虚拟机规范中定义的内存区域。但这部分内存也被频繁的使用,而且也可能导致内存溢出。jdk1.4增加的NIO,引入了一个基于管道和缓冲区的I/O方式,他可以使用Native函数库直接分配堆外内存,然后通过一个存储在java堆中的DirectByteBuffer对象作为这块内存的引用进行操作

  • OutOfMemoryError:会受到本机内存限制,如果内存区域总和大于物理内存限制从而导致动态扩展时出现该异常。

2、对象创建

方式:克隆、反序列化、反射、new关键字

2.1、虚拟机创建对象过程:

  1. 虚拟机遇到一条new指令,首先去检查这个指令的参数是否在常量池中定位一个类的符号引用,并检查这个符号引用代表的类是否已被加载、解析和初始化。如果没有,那必须先执行累的初始化过程。
  2. 类加载检查通过后,虚拟机将为新生对象分配内存。对象所需内存大小在类加载完成后便可以完全确定,为对象分配空间无非是从java堆中划分一个快确定大小的内存而已。注意两个问题:
    1. 如果内存是规整的,虚拟机会采用的是指针碰撞发来为对象分配内存。意思是所有用过的内存在一边,空闲的内存在另一边,中间放着一个指针作为分界点的指示器,分配内存就仅仅是把指针像空闲那边挪动一段和对象大小相等的距离罢了。如果垃圾收集器选择的是Serial、ParNew这种基于压缩算法的,虚拟机采用这种分配方式;如果内存不是规整的,已使用的内存和未使用的内存相互交错,那么虚拟机将采用空闲列表法来为对象分配内存。虚拟机维护一个列表,记录上哪些内存块是可用的,在分配到时候从列表中找到一个足够大的空间划分给对象实例,并更新列表上的内容。如果垃圾收集齐选择的是CMS这种基于标记-清除算法的,虚拟机会采用这种分配方式
    2. 另外一个问题是及时保证new对象时候的线程安全性。虚拟机采用CAS配上失败重试的方式保证更新操作的原子性和TLAB两种方式解决这个问题;TLAB的全称是Thread Local Allocation Buffer,即线程本地分配缓存区,这是一个线程专用的内存分配区域。 
  3. 内存分配结束,虚拟机将分配的内存空间初始化为零(不包括对象头)。这一步保证了对象的实例字段在java代码中可以不用赋初始值就可以直接使用,程序能访问到这些字段的数据类型所对应的零值
  4. 对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到了类的元数据信息、对象的哈希码、对象的GC分代年龄等信息,这些数据存放在对象头中
  5. 执行<init>方法,把对象按照程序员的意愿进行初始化,这样一个真正可以用的对象就算完全产生了

2.2、对象内存布局

对象头(Head)、实例数据(Instance Data)、对齐填充(Padding)

对象头(Head):包含两部分,第一部分用于存储对象自身的运行时数据,如哈希码、GC分代年龄、锁状态标志、线程持有的锁偏向线程ID、偏向时间戳等,32位虚拟机占32bit,64位虚拟机占64bit。官方称为‘Mark Word’。第二部分是类型指针,即对象指向他的类的元数据指针,虚拟机通过这个指针确定这个对象是哪个类的实例。另外,如果是Java数组,对象头中还必须有一块记录数组长度的数据,因为普通对象可以通过java对象元数据确定大小,而数组不可以

实例数据(Instance Data):程序代码中所定义的各种类型字段内容(包含父类继承下来的和子类中定义的)。

对齐填充(Padding):不是必须,主要占位,保证对象大小是某个字节的整数倍

2.3、对象定位方式

java程序需要通过栈上的reference(引用)数据来操作堆上的具体对象

2.3.1、对象访问方式主流有两种:

  1. 句柄访问:java堆中划分出一块句柄池,reference 存储的是句柄地址,obj指向的是对象的句柄地址,句柄中则包含了类数据的地址和实例数据地址
  2. 指针访问:对象中存储所有的实例数据和数据地址,reference 中直接存储对象地址,obj指向的是这个对象

2.3.2、比较

使用句柄的最大好处是reference中存储的是稳定的句柄地址,在对象移动(GC)是只改变实例数据指针地址,reference自身不需要修改。直接指针访问的最大好处是速度快,节省了一次指针定位的时间开销。对象访问频繁则指针访问好,对象GC频繁则句柄好

3、垃圾回收器与内存分配策略

3.1、判断对象是否可回收

引用计数法、 可达性分析法 GC Roots

3.1.1、引用计数法

给对象添加一个引用计数器,每当一个地方引用这个对象时,计数器+1;当引用失效时,计数器-1.任何时刻计数器为零的对象就是不可能在被使用的。但java未采用,因为难以解决循环引用问题

3.1.2、可达性分析法

通过一系列的‘GC Roots’的对象作为起始点,从这些节点出发所走过的路径称为引用链。当一个对象到 GC Roots 没有任何引用链相连接的时候说明对象不可用。
可作为GC Roots的对象;

  • 虚拟机栈(栈帧中的本地变量表)中的引用对象
  • 方法区中类静态属性引用的对象
  • 方法区中常量引用的对象
  • 本地方法栈JNI引用的对象

4、四种引用状态(强引用、软引用、弱引用、虚引用)

当内存还充足时,则能保留在内存中;如果内存空间在进行垃圾收集后还是非常紧张,则抛弃这些对象

4.1、强引用

只要强引用还存在,垃圾收集器永远不会回收掉被引用对象

4.2、软引用(SoftReference)

描述有些还有用但并非必须的对象。在系统将要发生内存溢出异常之前,将会把在这些对象进回收范围进行二次回收。如果这次回收还没有足够的内存,才会抛出异常

4.3、弱引用(WeakReference)

描述非必须对象。被弱引用关联的对象只能生存到下次垃圾回收之前,垃圾收集器工作之后,无论当前内存是否足够,都会回收掉只有被弱引用关联的对象。

4.4、虚引用(PhantomReference)

这个引用存在的唯一目的就是在这个对象收集器回收时收到一个系统通知,被虚引用关联的对象,和其生存时间完全没关系

5、真正GC之前

即使在可达性分析算法中不可达的对象,也并非是立即进行回收,这时候他们暂时处于”缓刑“阶段,一个对象的真正死亡至少经历两次标记过程:如果对象在进行可达性分析后发现没有与GC Roots相连的引用链,那他将会被第一次标记并且进行一次筛选,筛选条件是此对象是否有必要执行finalize()方法。当对象没有覆盖finaize()方法,或者finalize()方法已被虚拟机调用过,虚拟机将这两种情况视为"没必要执行",即该对象将会被回收。

反之,如果这个对象覆盖finalize()方法并且finalize()方法没被虚拟机调用过,那么这个对象就会放置在一个叫F-Queue的队列,并在稍后由一个由虚拟机自动创建的、低优先级的Finalizer线程去执行他。这里所谓的“执行”是指虚拟机会触发这个方法,并不承诺或等待他运行结束。finalize()方法是对象逃脱死亡命运的最后一次机会,稍后GC将对F-Queue中的对象进行二次小规模标记,如果对象在finalize()方法中成功解救自己--只要重新和引用链上的任何一个对象建立联系即可(finalize()方法只会被系统自动调用一次)

6、回收方法区

永久代垃圾回收主要两部分内容:废弃的常量和无用的类
判断废弃常量:一般是判断有没有该常量的引用
判断无用的类:

  • 该类所有的实例都已经回收,也就是Java堆中不存在该类的任何实例
  • 加载该类的ClassLoader已经被回收
  • 该类对应的java.lang.Class对象没有任何地方引用,无任何地方通过反射访问该类的方法

大量使用反射、动态代理、CGLib等ByteCode框架、动态生成JSP以及OSGI这类频繁自定义ClassLoader的场景都需要虚拟机具备类的卸载功能,以确保方法区不溢出

7、垃圾回收算法

7.1、标记-清除算法(Mark-Sweep)

这是最基础的算法,标记-清除算法就如同它的名字一样,分为标记和清除两个阶段:首先标记出所有需要回收的对象,标记完成后统一回收所有被标记的对象。

这种算法的不足之处主要体现在效率和空间上,从效率的角度讲,标记和清除两个阶段的效率都不高;从空间的角度讲,标记清除后会产生大量不连续的内存碎片,内存碎片太多可能导致以后程序运行过程中需要分配大对象时,无法找到足够的连续内存而不得不提前触发一次垃圾收集动作。

7.2、复制算法(copying)    

复制算法是为了解决效率问题而出现的,它将可用内存分为两块,每一次只使用其中一块,当这一块内存使用完,就将还存活的对象复制到另一块上面,然后再把已经使用过的内存空间一次清理掉。这样每次只需要对整个搬去进行内存回收,内存分配时也不需要考虑内存碎片等复杂情况,只需要移动指针,按照顺序分配即可。复制算法的执行过程如图:  

不过这种算法有个缺点,内存缩小为原来的一半,这样的代价也太高了。现在的商用虚拟机都采用这种算法来回收新生代,不过研究表明1:1的比例并不科学,因此新生代的内存被划分为一块较大的Eden空间和两块较小的Survivor空间,每次使用Eden和其中一块Survivor中还存活的对象一次性复制到另一块Survivor空间上,最后清理掉Eden 和刚才使用过的Survivor空间。HotSpot虚拟机默认的Eden和Survivor的比例为8:1,意思是每次新生代中可用内存为整个新生代容量的90%。当然,我们没有办法保证每次回收都只有不多于10%的存活对象,当Survivor空间不够用时,需要依赖老年代进行分配担保(Handle Promotion)

7.3、标记-整理算法(Mark-Compact)

复制算法在对象存活率较高的场景下进行大量的复制操作,效率很低。万一对象100%存活,那么需要有额外的空间进行分配担保。老年代都是不易被回收的对象,对象存活率高,因此一般不能直接选用复制算法。

根据老年代的特点,有人提出了另外一种标记-整理算法,过程和标记-清除算法一样,不过不是直接对可回收对象进行清理,而是让所有存活对象都向一端移动,然后直接清理掉边界以外的内存。

7.4、分代收集算法

根据对象的生命周期不同将内存划分为几块,然后根据各块的特点采用最适当的收集算法。新生代,大批对象死去,少量对象存活,使用复制算法,复制成本低;老年代对象存活率高、没有额外空间进行分配担保的,采用标记清除算法或标记整理算法。

7.5、垃圾回收器

说明:如果两个收集器之间存在连线说明他们之间可以搭配使用

7.5.1、Serial 收集器

单线程收集器,
 

7.5.2、ParNew收集器

是Serial收集器的多线程版本
 

7.5.3、Parallel Scavenge 收集器

新生代收集器,复制算法,并行的多线程收集器
CMS 等收集器的关注点是尽可能地缩短垃圾收集时用户线程所停顿的时间,而 Parallel Scavenge 收集器的目的是达到一个可控制的吞吐量(Throughput = 运行用户代码时间 / (运行用户代码时间 + 垃圾收集时间))。
作为一个吞吐量优先的收集器,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整停顿时间。这就是 GC 的自适应调整策略(GC Ergonomics)。

7.5.4、Serial Old 收集器

Serial 收集器老年代版本,单线程,使用标记-整理算法

7.5.5、Parallel Old 收集器

Parallel Scavenge 老年代版本,多线程,使用标记整理算法

7.5.6、CMS收集器

以获取最短回收停顿时间为目的的收集器,基于标记-清除算法
缺点:对 CPU 资源敏感、无法收集浮动垃圾、标记 —— 清除 算法带来的空间碎片
运作步骤:
初始标记(CMS initial mark):标记GC Roots能直接关联到的对象
并发标记(CMS concurrent mark):进行GC Roots Tracing
重新标记(CMS remark):修正并标记期间的变动部分
并发清除(CMS concurrent sweep)

7.5.7、G1收集器

面向服务端的,并行并发、分代收集、空间整合、可预测停顿
运作步骤:
初始标记(Initial Marking)
并发标记(Concurrent Marking)
最终标记(Final Marking)
筛选回收(Live Data Counting and Evacuation)

8、内存分配与回收策略

TLAB(Thread Local Allcation Buffer,本地线程分配缓存)。内存分配的动作,可以按照线程划分在不同空间中进行,即每个线程在Java堆中预先分配一块内存,称为本店线程分配缓存。哪个线程需要分配内存就在那个线程TLAB上分配。这么做的目的之一,也是为了并发创建一个对象时,保证创建对象的线程安全性。TLAB比较小,直接在TLAB中分配内存的方式称为快速分配方式,而TLAB大小不够,导致内存别分配在Eden区的内存分配方式称为慢速分配方式

8.1、对象优先分配在Eden区上

对象通常在新生代的Eden区进行分配,当Eden区没有足够空间进行分配时,虚拟机将发起一次Minor GC,与Minor GC 对应的是Major GC、Full GC

  • Minor GC:只发生在新生代的垃圾收集动作,非常频繁,速度较快
  • Major GC:指发生在老年代的GC,出现Major GC,经常会伴随一次Minor GC,Minor GC同时也会引起Major GC,一般在GC日志中统称为GC,不频繁
  • Full GC:指发生在老年代和新生代的GC,速度很慢,需要Stop The World

8.2、大对象直接进入老年代

需要大量连续内存空间的Java对象成为大对象,大对象的出现会导致提前出发垃圾收集以获取更大的连续的空间进行大对象的分配,虚拟机提供了-XX:PretenureSizeThreshold参数来设置大对象的阈值,超过阈值的对象直接分配到老年代。

8.3、长期存活的对象进入老年代

每个对象有一个对象年龄计数器,与前面对象的储存布局中的GC分代年龄对应。对象出生在Eden区。经过一次Minor GC后仍然存活,并且被Survivor容纳,设置年龄为1,对象在Survivor区每经过一次Minor GC,年龄就加1,当年龄达到一定程度(默认15),就晋升到老年代,虚拟机提供了-XX:MaxTenuringThreshold来进行设置

8.4、动态对象年龄分配

对象的年龄到达了MaxTenuringThreshold可以进入老年代,同时,如果在Survivor区中相同年龄所有对象大小的综合大于survivor区的一半,年龄大于等于该年龄的对象可以直接进入老年代,无需等到MaxTenuringThreshold中要求的年龄

8.5、空间分配担保

在发生Minor GC时,虚拟机会检查老年代连续的空闲区域是否大于新生代所有对象的总和,若成立,则说明Minor GC是安全的,否则,虚拟机需要查看HandlePromotionFailure的值,查看是否允许担保失败,若允许,则虚拟机继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,若大于,则进行一次Minor GC;若小于或者HandlerPromotionFailure设置不运行冒险,那此时将改成一次Full GC,以上是JDK Update 24之前的策略,之后策略改变了只要老年代的连续空间大于新生代对象总大小或者历次晋升的平均大小就会进行Minor GC,否则进行Full GC。

冒险是指经过一次Minor GC后有大量对象存活,而新生代的survivor区很小,放不下这些大量存活的对象,所以需要老年代进行分配担保,把survivor区无法容纳的对象直接进入老年代。

9、类加载机制

9.1、类加载过程

类从被加载到虚拟机中开始,到卸载出内存,整个生命周期包括:加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initiallization)、使用(Using)和卸载(Unloading)这七个阶段。其中验证、准备、解析3部分统称为连接(Linking),这七个阶段的发生顺序如下图:

类加载的过程包括了加载、验证、准备、解析、初始化五个阶段。在这五个阶段中,加载、验证、准备和初始化这四个阶段发生的顺序是确定的,而解析阶段不一定,它在某些情况下可以再初始化阶段之后开始,这是为了支持java语言的运行时绑定(也称为动态绑定或晚期绑定)。另外注意这里的几个阶段是按顺序开始的,而不是按顺序进行或完成的,因为这些阶段通常是相互交叉的混合进行的,通常在一个阶段执行过程中激活或调用另一个阶段。

9.1.1、加载

加载是类加载的第一个阶段,加载阶段做了三件事:

  1. 获取.class文件的二进制流
  2. 将类信息、静态变量、字节码、常量这些.class文件中的内容放入方法区中
  3. 在内存中生成一个代表这个.class文件的java.lang.class对象,作为方法区这个类的各种数据问问入口,一般这个class是在堆中,不过Hotspot虚拟机比较特殊,放在方法区中

二进制流来源:

  • 从zip包中获取,这就是jar、ear、war格式的基础
  • 从网络中获取,典型应用是Applet
  • 运行时计算生成,典型应用就是动态代理
  • 有其他文件生成,典型应用就是JSP,即由JSP中生成对应的.class文件
  • 从数据库中读取

9.1.2、验证

验证阶段会完成以下4个检验动作:文件格式验证、元数据验证、字节码验证、符号引用验证。

对于虚拟机的类加载机制来说,验证阶段是非常重要的,但是不一定必要(因为对程序运行期没有影响)的阶段。如果全部代码都已经被反复使用和验证过,那么在实施阶段就可以考虑使用Xverify:none参数来关闭大部分的类验证措施,以缩短虚拟机类加载的时间    

9.1.3、准备

准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些内存都将在方法区中分配。对于该阶段有以下几点需要注意:

  1. 这时候进行内存分配的仅包括类变量(static),而不包括实例变量,实例变量会在对象实例化时随着对象一块分配在Java堆中。
  2. 这里所设置的初始值通常情况下是数据类型默认的零值(如0、0L、null、false等),而不是被在Java代码中被显式地赋予的值。这里还需要注意如下几点:
    1. 对基本数据类型来说,对于类变量(static)和全局变量,如果不显式地对其赋值而直接使用,则系统会为其赋予默认的零值,而对于局部变量来说,在使用前必须显式地为其赋值,否则编译时不通过。
    2. 对于同时被static和final修饰的常量,必须在声明的时候就为其显式地赋值,否则编译时不通过;而只被final修饰的常量则既可以在声明时显式地为其赋值,也可以在类初始化时显式地为其赋值,总之,在使用前必须为其显式地赋值,系统不会为其赋予默认零值。
    3. 对于引用数据类型reference来说,如数组引用、对象引用等,如果没有对其进行显式地赋值而直接使用,系统都会为其赋予默认的零值,即null。
    4. 如果在数组初始化时没有对数组中的各元素赋值,那么其中的元素将根据对应的数据类型而被赋予默认的零值。
  3.  如果类字段的字段属性表中存在ConstantValue属性,即同时被final和static修饰,那么在准备阶段变量value就会被初始化为ConstValue属性所指定的值。

9.1.4、解析

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

1、符号引用。这个其实是属于编译原理方面的概念,符号引用包括了下面三类常量:

  •  类和接口的全限定名
  • 字段的名称和描述符
  • 方法的名称和描述符

2、直接引用

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

9.1.5、初始化

初始化阶段是类加载过程的最后一步,初始化阶段是真正执行类中定义的Java程序代码(或者说是字节码)的过程。初始化过程是一个执行类构造器<clinit>()方法的过程,根据程序员通过程序制定的主观计划去初始化类变量和其它资源。把这句话说白一点,其实初始化阶段做的事就是给static变量赋予用户指定的值以及执行静态代码块。

注意一下,虚拟机会保证类的初始化在多线程环境中被正确地加锁、同步,即如果多个线程同时去初始化一个类,那么只会有一个类去执行这个类的<clinit>()方法,其他线程都要阻塞等待,直至活动线程执行<clinit>()方法完毕。因此如果在一个类的<clinit>()方法中有耗时很长的操作,就可能造成多个进程阻塞。不过其他线程虽然会阻塞,但是执行<clinit>()方法的那条线程退出<clinit>()方法后,其他线程不会再次进入<clinit>()方法了,因为同一个类加载器下,一个类只会初始化一次。实际应用中这种阻塞往往是比较隐蔽的,要小心。

初始化,为类的静态变量赋予正确的初始值,JVM负责对类进行初始化,主要对类变量进行初始化。在Java中对类变量进行初始值设定有两种方式:

  1. 声明类变量时指定初始值
  2. 使用静态代码块为类变量指定初始值

JVM初始化步骤

  1. 假如这个类还没有被加载和连接,则程序先加载并连接该类。
  2. 假如该类的直接父类还没有被初始化,则先初始化其直接父类。
  3. 假如类中有初始化语句,则系统依次执行这些初始化语句。

类初始化时机:只有当对类主动使用的时候才会导致类的初始化,类的主动使用包括以下四种:

  • 使用new关键字实例化对象、读取或者设置一个类的静态字段(被final修饰的静态字段除外)、调用一个类的静态方法的时候。
  • 使用java.lang.reflect包中的方法对类进行反射调用的时候。
  • 初始化一个类,发现其父类还没有初始化过的时候。
  • 虚拟机启动的时候,虚拟机会先初始化用户指定的包含main()方法的那个类。

以上四种情况称为主动使用,其他的情况均称为被动使用,被动使用不会导致初始化。

9.1.6、结束生命周期

在如下几种情况下,Java虚拟机将结束生命周期

  • 执行了System.exit()方法
  • 程序正常执行结束
  • 程序在执行过程中遇到了异常或错误而异常终止
  • 由于操作系统出现错误而导致Java虚拟机进程终止

9.2、类加载器

虚拟机设计团队把类加载阶段张的"通过一个类的全限定名来获取此类的二进制字节流"这个动作放到Java虚拟机外部去实现,以便让应用程序自己决定如何去获取所需要的类。实现这个动作的代码模块称为"类加载器"。类加载器虽然只用于实现类的加载动作,但它在Java程序中起到的作用却远远不限定于类加载阶段。对于任意一个类,都需要由加载它的类加载器和这个类本身一同确立其在Java虚拟机中的唯一性,每一个类加载器,都拥有一个独立的类名称空间。这句话表达地再简单一点就是:比较两个类是否"相等",只有在这两个类是由同一个类加载器加载的前提下才有意义,否则即使这两个类来源于同一个.class文件,被同一个虚拟机加载,只要加载它们的类加载器不同,这两个类必定不相等。

关于这张图首先说两点:

  1. 这三个层次的类加载器并不是继承关系,而只是层次上的定义
  2. 它并不是一个强制性的约束模型,而是Java设计者推荐给开发者的一种类加载器实现方式

9.2.1、启动类加载器Bootstrap ClassLoader

这是一个嵌在JVM内核中的加载器。它负责加载的是JAVA_HOME/lib下的类库,系统类加载器无法被Java程序直接应用

9.2.2、扩展类加载器Extension ClassLoader

这个类加载器由sun.misc.Launcher$ExtClassLoader实现,它负责用于加载JAVA_HOME/lib/ext目录中的,或者被java.ext.dirs系统变量指定所指定的路径中所有类库,开发者可以直接使用扩展类加载器。java.ext.dirs系统变量所指定的路径的可以通过程序来查看

9.2.3、应用程序类加载器Application ClassLoader

这个类加载器由sun.misc.Launcher$AppClassLoader实现。这个类也一般被称为系统类加载器;应用程序都是由这三种类加载器互相配合进行加载的,如果有必要,我们还可以加入自定义的类加载器。因为JVM自带的ClassLoader只是懂得从本地文件系统加载标准的java class文件,因此如果编写了自己的ClassLoader,便可以做到如下几点:

  1. 在执行非置信代码之前,自动验证数字签名。
  2. 动态地创建符合用户特定需要的定制化构建类。
  3. 从特定的场所取得java class,例如数据库中和网络中。

9.2.4、JVM类加载机制

  • 全盘负责,当一个类加载器负责加载某个Class时,该Class所依赖的和引用的其他Class也将由该类加载器负责载入,除非显示使用另外一个类加载器来载入。
  • 父类委托,先让父类加载器试图加载该类,只有在父类加载器无法加载该类时才尝试从自己的类路径中加载该类。
  • 缓存机制,缓存机制将会保证所有加载过的Class都会被缓存,当程序中需要使用某个Class时,类加载器先从缓存区寻找该Class,只有缓存区不存在,系统才会读取该类对应的二进制数据,并将其转换成Class对象,存入缓存区。这就是为什么修改了Class后,必须重启JVM,程序的修改才会生效。

类加载有三种方式:

  1. 命令行启动应用时候由JVM初始化加载
  2. 通过Class.forName()方法动态加载
  3. 通过ClassLoader.loadClass()方法动态加载

9.2.5、双亲委派模型      

双亲委派模型的工作流程是:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把请求委托给父加载器去完成,依次向上,因此,所有的类加载请求最终都应该被传递到顶层的启动类加载器中,只有当父加载器在它的搜索范围中没有找到所需的类时,即无法完成该加载,子加载器才会尝试自己去加载该类。

双亲委派机制:

  1. 当AppClassLoader加载一个class时,它首先不会自己去尝试加载这个类,而是把类加载请求委派给父类加载器ExtClassLoader去完成。
  2. 当ExtClassLoader加载一个class时,它首先也不会自己去尝试加载这个类,而是把类加载请求委派给BootStrapClassLoader去完成。
  3. 如果BootStrapClassLoader加载失败(例如在$JAVA_HOME/jre/lib里未查找到该class),会使用ExtClassLoader来尝试加载;
  4. 若ExtClassLoader也加载失败,则会使用AppClassLoader来加载,如果AppClassLoader也加载失败,则会报出异常ClassNotFoundException。

双亲委派模型意义:

  • 系统类防止内存中出现多份同样的字节码
  • 保证Java程序安全稳定运行

 

已标记关键词 清除标记
©️2020 CSDN 皮肤主题: 大白 设计师:CSDN官方博客 返回首页