想要学习JVM就要对JVM有一个初步的认知。JVM可以理解为Java为了实现“一次编译,处处执行”的理念,对底层平台进行的抽象。JVM除了对内存进行了抽象外,还提供了许多方便程序员编程、Debug的工具,当然也让只想好好码代码的程序员可以忽略内存的申请、释放等繁琐的操作而专注于手头的工作。
内存
内存管理是JVM的一大重要任务。在JVM执行Java程序的过程中,会将管理的内存即运行时数据区分为下图中的这么几个区域。其中方法区和堆所有线程共享,每个线程维护私有的虚拟机栈、本地方法栈和程序计数器。
运行时数据区
程序计数器
程序计数器处在线程隔离的数据区,每个线程拥有自己的程序计数器。程序计数器当前线程所执行的字节码的行号指示器,粗略一点,也就是记录了当前程序运行到了哪一行代码。
当前线程在执行Java方法时,计数器记录的是行号;线程在执行Native方法时,这个计数器为空。(Native方法就是Java可调用的非Java实现的接口。详见https://blog.csdn.net/wike163/article/details/6635321)Java虚拟机栈
Java虚拟机栈描述的是Java方法执行的内存模型:每个方法在执行的同时都会创建一个栈帧,用于存储局部变量表、操作数栈、动态链接、方法出口等信息。
局部变量表存放了编译时可知的基本数据类型、对象引用和returnAddress类型。
Java虚拟机栈内存不够时会抛出StackOverFlow和OutOfMemoryError异常。本地方法栈
本地方法栈与Java虚拟机栈相似,只不过本地方法栈是为Native方法服务。同样会抛出StackOverFlow和OutOfMemoryError异常。Java堆
Java堆可以当成C/C++中的堆来理解,几乎所有的对象实例都在这里分配。堆由所有线程共享,并且也是垃圾收集器(GC,Garbage Collector)管理的主要区域。
从内存回收的角度,Java堆可以被分为新生代和老生代;从内存分配的角度,Java堆可能划分出多个线程私有的分配缓冲区(TLAB,Thread Local Allocation Buffer)。
Java堆可以通过-Xms
(初始堆的大小)和-Xmx
(最大堆的大小)来控制,会抛出OutOfMemoryError异常。方法区
方法区被所有线程共享,用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。
方法区会抛出OutOfMemoryError异常。运行时常量池
运行时常量池是方法区的一部分。Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池,用于存放编译期间生成的各种字面量和符号引用,这部分内容在类加载后进入方法区的运行时常量池存放。
运行时常量池相对于Class文件常量池的另一个特性是动态性,允许运行期间将新的常量放入池中,如String
类的intern()
方法。
运行时常量池会抛出OutOfMemoryError异常。直接内存
直接内存不是虚拟机运行时数据区的一部分,而是计算机概念上的内存。可以通过NIO
类使用Native方法直接分配堆外内存,通过一个存储在Java堆中的DirectByteBuffer
对象作为这块内存的引用进行访问。这样能够在某些场景中提高性能,因为避免了在Java堆和Native堆(是否为本地方法栈?)中来回复制数据。
直接内存会抛出OutOfMemoryError异常。
对象内存申请
当虚拟机遇到一条new指令时,首先检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并检查这个符号引用代表的类是否已被加载、解析和初始化过。
在检查通过后,虚拟机在堆中为新生对象分配内存。有两种分配方式:
-
指针碰撞(Bump the Pointer)
这种方式适用于已分配和未分配的内存均是连续的情况下。这两个内存块之间有一个指针进行划分,申请新内存时只用将指针往未分配的内存移动一段距离就行了。 -
空闲列表
当堆中的内存不是连续的时,就要通过列表记录哪些内存块是可用的,分配时查表并分配。
以上两种方式在多线程的情况下需要进行额外的处理来实现线程同步。虚拟机采用了CAS和失败重试的方法保证更新操作的原子性。
一种更好的方法是对每个线程分配一小块内存,即TLAB(Thread Local Allocation Buffer),只有当TLAB用完并分配新的TLAB时才需要同步锁定,这样就避免了指针或列表的并发访问。TLAB可以通过-XX:+/-UseTLAB
来指定。
对象内存填充
虚拟机为对象申请了内存后,将内存空间都初始化为零。
对象的内存布局分为3块区域:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。
对象内存划分图
对象头用于存储两部分信息:
- 存储对象自身的运行时数据,如哈希码,GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等。
- 存储类型指针,即对象指向它的类元数据的指针,虚拟机可以通过该指针确定这个对象是哪个类的实例。另外,如果对象是一个数组,对象头中还必须有一块记录数组长度的数据,来确定数组的大小。
实例数据存储对象的有效信息。这部分的存储顺序受到虚拟机分配策略参数和字段在Java源码中定义顺序的影响。
对齐填充仅仅七大占位符的作用,因为虚拟机自动内存管理系统要求对象的起始地址必须是8字节的整数倍,也就是说对象大小必须是8字节的整数倍,当对象实例数据没有对齐时,通过对齐填充来补全。
对象的访问
对象被创建出来后需要进行访问使用,目前主流的访问方式有两种:
-
使用句柄。在这种方法中,Java堆中会划分出一块内存作为句柄池,虚拟机栈中
reference
存储的就是对象的句柄地址。
-
使用直接指针。在这种方法中,Java堆对象的布局中就必须考虑如何防止访问类型数据的相关信息,而虚拟机栈中
reference
存储的直接就是对象地址。
使用句柄访问的好处就是在对象被移动时只会改变句柄中的实例数据指针;使用直接指针访问方式的好处是速度更快,节省了一次指针定位的开销。
由于对象访问在Java中非常频繁,这类开销积少成多后也是一项非常客观的执行成本。在Sun HotSpot中使用的是第二种方法。
垃圾收集器与内存分配
当JVM运行较长一段时间后,运行时数据区中就会出现一些无法再使用的对象(不存在任何引用),这时候就需要垃圾收集器来回收这些内存。
垃圾回收一般在堆上进行,也会在方法区中进行。由于方法区中进行内存回收的条件比较苛刻并且效用不大,因此我们先了解堆中的垃圾回收方法。
垃圾收集器
-
是否该死?
在进行垃圾回收之前要判断一个对象是否已经真的再也不能够被用户使用,主要与两种方法,分别是引用计数法和可达性分析法。-
引用计数法
引用计数法的原理很直观,即对一个对象引用个数进行存储,当引用个数降为0时,则表示该对象已经无法再被使用。当然,缺点也十分明显,当存在两个对象进行相互引用时,就算这两个对象不再被用户引用,但是也不会被垃圾回收器回收。public class TestObj{ public Object instance; public static vid testGC(){ TestObj objA=new TestObj(); TestObj objB=new TestObj(); objA.instance=objB; objB.instance=objA; objA=null;objB=null; } }
可达性分析法
可达性分析法是目前JVM判定对象是否存活的主流方法。主要思想是:通过“GC Roots”的对象为起点,从这些结点向下搜索,走过的路径称为引用链,当一个对象没有任何引用链相连时,则表示该结点不可达,即不可用。
GC Roots包含下面几种:
⑴ 虚拟机栈(栈帧中的本地变量表)中引用的对象
⑵ 本地方法栈中Native方法引用的对象
⑶ 方法区中静态属性引用的对象
⑷ 方法区中常量引用的对象引用的类型
Java中有四种不同类型的引用:
⑴ 强引用:类似Object o=new Object()
创建的引用,只要强引用还在,就不会被回收。
⑵ 软引用:描述一些有用但非必须的对象,在将要发生内存溢出时将会把这些对象回收。
⑶ 弱引用:描述非必须的对象,只能生存到下一次回收。
⑷ 虚引用:一个对象的虚引用的存在不会影响其生存时间,也无法通过虚引用获得一个对象的实例,其唯一作用就是在对象被回收时收到一个系统通知。方法区的回收
方法区主要回收废弃的常量和无用的类。对于废弃的常量比较容易判断,判断一个类是否无用需要判断其是否满足下面四个条件:
⑴ 该类的所有实例都被回收
⑵ 加载该类的ClassLoader已经被回收
⑶ 该类对应额java.lang.Class对象没有被引用,无法在任何地方通过反射访问该类的方法
-
-
缓刑?
一个对象的回收会经历两次标记的过程:如果对象不可达,会被第一次标记并依条件判断是否执行finalize()
方法(是否覆盖该方法或是否已被执行过)。如果需要执行则放入F-Queue中去执行。执行完毕后GC将对F-Queue中的对象进行第二次标记,如果此时对象被重新引用,则会被移出收集的对象集。此方法只能使用一次,因为同一个对象的finalize()
方法只会被执行一次。public class TestObj{ public static TestObj SAVE_HOOK=null; public void finalize() throws Throwable{ super.finalize(); //重新增加引用 TestObj.SAVE_HOOK=this; } }
-
如何执行死刑?
垃圾回收的算法多种多样,各有各的特性。- 标记-清除算法
首先通过标记标记出不再有用的对象,然后再将所有被标记的对象回收。这种方法有两个问题:一是标记和清除的效率不高;二是会产生大量碎片。 - 标记-整理算法
与上述方法相似,区别在于标记后将未标记的对象前移,使得所有可用的对象储存在连续的内存中。 - 复制算法
将新生代分为3块区域,分别是Edian、Survivor1、Survivor2,一般占比是80%、10%、10%。新生的对象存储在Edian中,熬过1次GC的对象存储在Survivor中。当进行一次GC后,将所有存活的对象放入另一个Survivor中。当Survivor无法存放所有的对象时,需要依赖其他内存(老年代)进行分配担保。 - 分代收集算法
即将对象按照存活周期进行分代,分为新生代和老年代。在新生代熬过几次GC的对象将会被放入老年代。
- 标记-清除算法
HotSpot的算法实现
- 枚举根节点
在可达性分析中,为了保证标记的一致性,需要将所有Java执行线程暂停(被称为“Stop The World”)。为了加快标记的过程,Java使用了一组OopMap(Ordinary Object Pointer)来快速得知哪些地方存放着哪些对象的引用。
导致引用变化的指令很多,并不是每次指令后都生成OopMap,只在安全点记录信息。安全点既不能太少使得GC等待时间太长,又不能太多导致增大运行时负荷。所以安全点一般会选择能够让程序长时间执行的特征点—方法调用、循环跳转、异常跳转等。
GC时有两种方法让线程停顿下来,一种是抢先式中断,在GC发生时把所有线程中断,如果一个线程不再安全点上,则继续运行到安全点再停止(目前几乎没有JVM使用这种方法);另一种是主动式中断,在GC发生时仅设置一个标记,所有线程主动轮询该标记,发现为真时就自己中断挂起。
对于安全点,如果一个线程“不执行”(Sleep或Blocked)时,就无法运行到安全点,此时需要安全区域来解决。安全区域是引用关系不会发生变化的一段代码,如果一个线程进入了安全区域,就标识自己,离开安全区域时检查系统是否完成了根节点枚举,再判断是否离开安全区域。 - 垃圾收集器
- Serial/Serial Old收集器
该收集器是单线程的收集器,在进行垃圾收集时需要暂停所有其他的线程。在新生代使用复制算法,在老生代使用标记-整理算法。该收集器简单而高效,在单线程小内存的情况下停顿时间可以控制得非常短。 - ParNew收集器
该收集器与Serial收集器几乎相同,除了使用多条线程进行垃圾收集。该收集器在多个CPU的情况下性能才会超过ParNew收集器。 - Parallel Scavenge收集器
该收集器与ParNew收集器相似,但是该收集器的目标是达到一个可控制的吞吐量(=运行用户代码时间/(运行用户代码时间+垃圾收集时间)
)。低停顿时间能够良好的响应用户,高吞吐可以高效的利用CPU时间,完成更多的任务。GC停顿时间的缩短是通过牺牲吞吐量和新生代空间换取的。
可以通过-XX:MaxGCPauseMillis
设置最大垃圾收集停顿时间、-XX:GCTimeRatio
设置吞吐量大小、-XX:UseAdapptiveSizePolicy
来开启根据系统当前信息动态调整新生代、老生代、Eden与Survivor比例来提供最合适的停顿时间或最大吞吐量的功能。(这也是与ParNew收集器不同的一点) - Parallel Old收集器
用于和Parallel Scavenge收集器共同实现以吞吐量优先的堆GC。 - CMS(Concurrent Mark Sweep)收集器
CMS收集器使用了标记-清除算法,是以获取最短回收停顿时间为目标的收集器,整个过程分为4个步骤:
⑴ 初始标记:Stop The World,标记GC Roots能直接关联到的对象
⑵ 并发标记:进行GC Roots Tracing
⑶ 重新标记:Stop The World,修正在并发标记阶段因用户程序继续运作而产生变动的标记记录
⑷ 并发清理
CMS收集器有3个缺点:
⑴对CPU资源非常敏感,当CPU数量不多时,执行CMS会需要一部分的CPU运算能力,导致用户程序执行速度降低。
⑵ 无法处理浮动垃圾,即在并发清理阶段用户线程产生的垃圾,可能会出现执行CMS失败,导致Full GC的产生。因此CMS在达到一定阈值时就需要启动(1.6中为92%)。
⑶ 会产生大量碎片 - G1收集器
G1收集器从整体看是基于标记-整理算法实现,从局部(两个Region之间)上来看是基于复制算法实现的。G1算法相较于CMS的一大优点是可预测的停顿。
G1收集器将整个Java堆划分为多个大小相等的独立区域(Region),并跟踪各个Region里面的垃圾堆积的价值大小,每次根据允许的收集时间,优先回收价值最大的Region。
一个Region的对象,如果被其他Region中的对象引用,那么就会将相关的引用信息放入被引用对象Region的Remembered Set中,当进行内存回收时,在GC根节点的枚举范围中加入Remembered Set即可保证不对全堆扫描也不会有遗漏。
G1收集器有4个步骤:
⑴ 初始标记
⑵ 并发标记
⑶ 最终标记:虚拟机将并发标记阶段对象变化记录在线程Remembered Set Logs中,该阶段需要将Log中的数据合并到Remembered Set中。
⑷ 筛选回收阶段:对各个Region的回收价值和成本进行排序,根据用户所期望的GC停顿时间来制定回收计划。 - 垃圾收集器参数
- Serial/Serial Old收集器
参数 | 描述 |
---|---|
UseSerialGC | 虚拟机运行在Client模式下的默认值,打开此开关或,使用Serial+Serial Old的收集器组合进行内存回收 |
UseParNewGC | 使用ParNew+Seial Old的收集器组合进行内存回收 |
UseConcMarkSweepGC | 使用ParNew+CMS+Serial Old,Serial Old用于CMS出现Concurrent Mode Failure失败后的后备收集器使用 |
UseParallelGC | 虚拟机运行在Server模式下的默认值,使用Parallel Scavenge+Serial Old |
UseParallelOldGC | 使用Parallel Scavenge+Parallel Old |
SurvivorRatio | Eden与Survivor的容量比值,默认为8 |
PretenureSizeThreshold | 大于这个参数的对象直接在老年代中分配 |
MaxTenuringThreshold | 晋升大老年代的对象年龄 |
UseAdaptiveSizePolicy | 动态调整Java堆中各个区域的大小以及进入老年代的年龄 |
HandlePromotionFailure | 是否允许分配担保失败 |
ParallelGCThreads | 设置并行GC时进行内存回收的线程数 |
GCTimeRatio | GC时间占总时间的比率 |
MaxGCPauseMillis | 设置GC的最大停顿时间 |
CMSInitiatingOccupancyFraction | CMS在老年代空间被使用多少后出发垃圾收集,默认86% |
UseCMSCompactAtFullCollection | 设置CMS完成垃圾收集后是否要进行一次碎片整理 |
CMSFullGCsBeforeCompaction | 设置CMS进行若干次垃圾收集后再启动一次碎片整理 |
内存分配与回收策略
- 对象优先在Eden分配,当空间不够时会进行Minor GC。
- 大对象直接进入老年代
- 长期存活的对象将进入老年代
- 动态对象年龄判断
如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一般,年龄大于或等于该年龄的对象就可以直接进入老年代 - 空间分配担保
在发生Minor GC前虚拟机会检查老生代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那么Minor GC可以确保是安全的。如果不成立,虚拟机会查看HandlePromotionFailure
是否允许担保失败,如果允许,那么会继续检查老年代i最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试进行一次Minor GC;如果小于或不允许冒险,那么进行一次Full GC。
虚拟机类加载机制
Java中所有的类被编译后均会生成Class文件。Class文件是一种以8字节为基础单位的二进制流,其存储信息的顺序有特定的要求。这里主要介绍虚拟机如何从Class文件中加载类的相关信息到内存中。
Java与C/C++不同,类型的加载、连接和初始化过程是在程序运行期间完成的,这种实现方式使得Java能够动态扩展。
类从加载到虚拟机内存至卸载出内存为一个生命周期,包括:加载、验证、准备、解析、初始化、使用、卸载。
类加载的时机
Java虚拟机规范中并没有强制要求什么时候加载,但严格规定了当且仅当发生在如下5个情况需要立即对类进行初始化(而加载、验证、准备自然需要在这之前完成):
⑴遇到new
、getstatic
、putstatic
、invokestatic
这4个字节码指令时,如果类没有进行过初始化,则先初始化。触发这种情况最常见的Java场景是:使用new
实例化对象、读取或设置一个静态字段(被final修饰、已在编译期把结果放入常量池的静态字段除外)、调用一个类的静态方法的时候。
⑵对类进行反射调用的时候,如果类没有初始化,则需要先初始化。
⑶当初始化一个类的时候,其父类还未初始化,则需要先初始化其父类。
⑷当虚拟机启动时,用户需要指定一个要执行的主类(包含main()
的类),虚拟机会先初始化这个主类。
⑸使用1.7JDK的动态语言支持,如果一个java.lang.invoke.MethodHandle
实例最后的解析结果REF_getStatic
、REF_putStatic
、REF_invokeStatic
的方法句柄,并且这个句柄所对应的类没有初始化,则先初始化该类。
类加载的过程
-
加载
在加载阶段,虚拟机需要完成3件事情:- 通过一个类的全限定名来获取定义此类的二进制字节流
- 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构
- 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口
获取二进制字节流的方法并没有被固定,可以从zip包(jar)中获取、从网络、运行时计算生成、由其他文件生成(JSP)等。
一个非数组类的加载(准确的说,是获取字节流的过程)既可以使用系统提供的引导类加载器来完成,也可以由用户自定义的类加载器完成。
对于数组类而言,它不通过类加载器创建,是由Java虚拟机直接创建的。一个数组类(简称C)创建过程遵循以下规则:- 如果数组的组件类型(即去掉一个纬度的类型)是引用类型,那么就加载这个组件类型,数组C将在加载该组件类型的类加载器的类名称空间上被标识
- 如果数组的组件类型不是引用类型(如
int[]
数组),Java虚拟机将会把数组C标记为与引导类加载器关联 - 数组类型的可见性与组件类型的可见性一致,如果不是引用类型,默认为
public
加载阶段和连接阶段是交叉进行的。
-
验证
这一阶段的目的是为了确保Class文件的字节流中包含信息复合当前虚拟机的要求,且不会危害虚拟机本身,如果不复合Class文件格式的约束,则抛出java.lang.VerifyError
的错误。验证大致会完成这4个阶段:- 文件格式验证
验证字节流是否复合Class文件格式的规范,可能包括魔数、版本号是否在虚拟机处理范围内、常量池中是否有不被支持的类型等。通过了这个阶段的验证后,字节流才会进入内存的方法区中进行存储,因此后面的3个验证阶段全部是基于方法区的存储结构进行的。 - 元数据验证
对字节码描述的信息进行语义分析,可能包括:是否有父类、是否继承了不允许被继承的类、如果不是抽象类是否实现了父类或接口要求实现的方法等。 - 字节码验证
这个阶段对类的方法进行校验分析,保证不会做出危险事件,包括:保证任意时刻操作数栈的数据类型与指令代码序列都能配合工作(int数组存放long数据)、保证跳转指令不会跳转到方法体以外的字节码指令上、保证方法体中的类型转换是有效的等。 - 符号引用验证
发生在虚拟机将符号引用转化为直接引用的时候,这个动作在解析阶段发生。符号引用验证可以看作是对类自身以外(常量池中的各种符号引用)的信息进行匹配性校验,包括:符号引用中通过字符串描述的全限定名是否能找到对应的类、指定类中是否存在符合方法的字段描述符以及简单名称所描述的方法和字段、符号引用中的类、字段、方法的可访问性(public、protected..)是否可以被当前类访问等。
- 文件格式验证
准备
准备阶段正式为类变量分配内存并设置变量初始值的阶段,这些变量所使用的内存都在方法区分配。这些变量仅包括类变量(static),而不包括实例变量,并且初始值通常情况下均为零值,之后在初始化阶段才会进行赋值。特殊情况就是如果变量是final
修饰的值,则会直接初始化为定义的值。-
解析
解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程,其定义如下:
符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可,引用的目标并不一定已经加载到内存中。
直接引用可以是直接指向目标的指针、相对偏移量或是一个能简介定位到目标的句柄。
解析主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符7类符号引用,这里主要介绍前4种。- 类或接口的解析
当前所处类为D,如果要将未解析过符号引用N解析为一个直接引用C,需要3个步骤:
⑴如果C不是一个数组类型,虚拟机会把代表N的全限定名传给D的类加载器去加载类C。
⑵如果C是一个数组类型,并且数组的元素类型为对象,那将会按照第1点的规则加载数组元素类型,接着虚拟机生成一个代表此数组纬度和元素的数组对象。
⑶如果上述步骤没有出现异常,那么C在虚拟机中已经成为一个有效的类或接口,但在解析完成之前还要进行符号引用验证。 - 字段解析
要解析一个未被解析过的字段符号引用,首先会对字段所属的类或接口的符号引用进行解析,然后将这个所属的类或接口用C表示,按照如下步骤对C进行后续字段的搜索:
⑴如果C本身就包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,查找结束。
⑵否则,如果在C中实现了接口,将会按照继承关系从下往上递归搜素各个接口和它的父接口,如果接口中包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,查找结束。
⑶否则,如果C不是java.lang.Object的话,将会按照继承关系从下往上递归搜索父类,如果父类包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,查找结束。
⑷否则,查找失败,抛出java.lang.NoSuchFieldError
异常。
查找成功后对字段进行权限验证,如果不具备访问权限,抛出java.lang.IllegalAccessError
异常。 - 类方法解析
类方法解析与字段解析相同,需要先对该类或接口C的符号引用进行解析,然后按照以下步骤进行后续的类方法搜索:
⑴如果C是一个接口,直接抛出java.lang.IncompatibleClassChangeError
异常。
⑵在类C中查找是否有简单名称和字段描述符都与目标相匹配的方法,则返回这个方法的直接引用,查找结束。
⑶否则,在C的父类中查找是否有简单名称和字段描述符都与目标相匹配的方法,则返回这个方法的直接引用,查找结束。
⑷否则,在C实现的接口列表及它们的父接口中递归查找是否有简单名称和字段描述符都与目标相匹配的方法,如果存在,则说明该类是抽象类,抛出java.lang.AbstractMMethodError
异常。
⑸否则,查找失败,抛出java.lang.NoSuchMethodError
。
查找成功后对方法进行权限验证,如果不具备访问权限,抛出java.lang.IllegalAccessError
异常。 - 接口方法解析
与类方法解析相似,如果C是类,则抛出java.lang.IncompatiableClassChangError
异常。否则在接口C、父类中递归查找,查找不到抛出java.lang.NoSuchMethodError
异常。
- 类或接口的解析
初始化
初始化阶段,变量根据程序员通过程序制定的主观计划去初始化一次系统要求的初始值,是执行类构造器<clinit>()
方法的过程。以下是<clinit>()
的特点:
⑴<clinit>()
方法是由编译器自动收集类中的所有静态变量的赋值动作和静态语句块(static{})中的语句合并产生的。静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块可以赋值,但是不能访问。
⑵<clinit>()
方法与类的构造函数不同,不需要显示地调用父类构造器,虚拟机会保证执行<clinit>()
前其父类的<clinit>()
已经执行完毕。
⑶父类的<clinit>()
方法先执行,则父类的静态语句块和变量赋值要先于子类。
⑷<clinit>()
方法对于类和接口来说不是必需的。
⑸接口中不能使用静态语句块,但仍然有变量初始化的赋值操作,所以执行接口的<clinit>()
方法时并不需要先执行父类的<clinit>()
方法。只有当父类接口中定义的变量使用时,父接口才会初始化。接口的实现类在初始化时也一样不会执行接口的<clinit>()
方法。
⑹虚拟机会保证一个类的<clinit>()
方法在多线程环境中被正确的加锁、同步。如果在一个类的<clinit>()
方法中有很耗时的操作,可能会造成多个进程阻塞。
类加载器
当判断两个类是否相同的时候,只有在这两个类是由同一个类加载器加载的前提下才有意义,否则一定不想等。这里判等的方法有Class对象的euqals()
方法、isAssignableFrom()
方法、isInstance()
方法、Instanceof
关键字等。
- 双亲委派模型
对于JVM而言,只存在两种类加载器:启动类加载器,使用C++实现,是虚拟机的一部分;所有其他的类加载器,由Java实现,独立于虚拟机外部。
对于程序员而言,可以更细分:
⑴启动类加载器:负责将<JAVA_HOME>\lib
或被-Xbootclasspath
参数所指定的路径中,并且是虚拟机识别的类库加载到虚拟机中。
⑵扩展类加载器:加载<JAVA_HOME>\lib\ext
或被java.ext.dirs
系统变量指定的路径中的所有类库。
⑶应用程序类加载器:是ClassLoader
中的getSystemClassLoader()
方法的返回值,一般也称为系统类加载器,负责加载用户路径上所指定的类库。如果应用程序中没有自定义过自己的类加载器,一般这个就是程序默认的类加载器。
加载器关系图
双亲委派模型要求除了顶层的启动类加载器外,其余的类加载器都应当有自己的父类加载器,一般以组合关系来复用父加载器。
双亲委派模型的工作过程是:如果一个类加载器收到了类加载的请求,首先会将请求委派给父类,只有当父类无法完成时,子加载器才会尝试自己去加载。