高级JAVA开发 JVM部分
参考和摘自:
《深入理解Java虚拟机(第2版)》
7种垃圾收集器
JVM调优总结
Java虚拟机(JVM)你只要看这一篇就够了!
JVM - 参数简介
详解 JVM Garbage First(G1) 垃圾收集器
Java 类的加载机制
Jvm 系列(三):GC 算法 垃圾收集器
G1调优常用参数及其作用
G1收集器-调优(翻译自官方文档)
JVM内存结构
- 程序计数器(Program Counter Register):
当前线程执行的字节码的行号指示器。线程私有,此区域没有OOMError。- 线程正在执行Java方法:记录正在执行的虚拟机字节码指令地址。
- 线程正在执行Native方法:计数器值为空(Undefined)。
- Java虚拟机栈:
线程私有,生命周期与线程相同,有StackOverflowError、OOMError(如果虚拟机栈可以动态扩展的话)。方法在执行时创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态链接、方法出口等信息。方法调用执行过程对应着一个栈帧在虚拟机栈中入栈到出栈的过程。
局部变量表存放了编译期可知的基本数据类型(boolean、bety、char、short、int、float、long、double)、对象引用(指向 对象起始地址的指针 或 代表对象的句柄 或 与此对象相关的位置)、returnAddress类型(一条字节码指令的地址)。局部变量表所需内存空间在编译期间完成分配,在方法运行期间不会改变。 - 本地方法栈(Native Method Stack):
和虚拟机栈类似。为Native方法服务。有StackOverflowError、OOMError。 - Java堆:
所有线程共享,虚拟机启动时创建,存放对象实例。垃圾收集器管理的主要区域。有OOMError。 - 方法区:
线程共享,存放已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据,有OOMError。目前,HotSpot虚拟机用永久代实现方法区。
运行时常量池:方法区的一部分,有OOMError,在类加载后存放编译期生成的字面量和符号引用。 - 直接内存(Direct Memory):不是虚拟机运行时数据区的一部分,但是在运行时可能会引用到计算机直接内存,比如NIO。
垃圾回收(GC)
“引用”的概念:对象的强、软、弱和虚引用
- 强引用(StrongReference)
垃圾回收器不会回收强引用。当内存空间不足时,虚拟机抛出OutOfMemoryError也不会回收强引用对象。普通new的对象就是强引用类型。 - 软引用(SoftReference)
内存空间不足时回收。只要垃圾回收器没有回收它,该对象就可以被程序使用。 - 弱引用(WeakReference)
被弱引用关联的对象,在垃圾回收时,如果这个对象只被弱引用关联(没有任何强引用关联他),那么这个对象就会被回收。。 - 虚引用(PhantomReference)
它像是一个幽灵,无法通过虚引用来取得一个对象的实例,它存在的意义就是这个对象被收集器回收时会收到一个系统通知。
如何判断对象已“死”
- 引用计数算法:对象有一个引用计数器,对象有一个引用,计数器值+1,删除一个引计数器值-1。垃圾回收时,只用收集计数器为0的对象。此算法缺点是无法处理循环引用的问题(A持有B对象的引用,B也对象持有A对象的引用)。
- 可达性分析算法:这像是一棵存活对象的“树”。根据引用关系从根节点(GC Roots)寻找存活对象。在此“树”之外的对象为可以回收的对象。
GC Roots包含以下几种:- 虚拟机栈(栈帧中的本地变量表)中引用的对象。
- 方法区中类静态属性引用的对象。
- 方法区中常量引用的对象。
- 本地方法栈中JNI(即一般说的Native方法)引用的对象。
垃圾回收算法
以下列举几种基础回收算法,垃圾收集器大多基于下列算法进行工作的:
标记 - 清除(Mark-Sweep)
第一阶段:标记已“死”对象。
第二阶段:回收已“死”对象。
缺点:产生内存碎片,在为大对象分配空间不足时会触发新一次垃圾回收。
复制(Copying)
将内存分成大小相同的两块,当这块用完了,就把存活对象复制到另一块上,清空当前半块。存活对象较多时效率较低,所以使用在存活对象较少的场景。大多应用于分代算法(后面会介绍)的新生代。
优点:不产生内存碎片
缺点:可使用空间为原空间的一半
标记-整理(Mark-Compact)
标记存活对象,把存活对象移动到一端,之后把端尾后的空间全都清理掉。大多应用于分代算法(后面会介绍)的老年代。
分代收集算法
不同的对象的生命周期是不一样的。因此,将不同生命周期的对象分区域存放并采取上述不同的收集算法回收,这样可以提高GC效率。
分代算法将内存分为三个区域:
新生代(Young Generation):新生代分成一个Eden区,两个Survivor区(一般而言,可配置多于两个)
老年代(Old Generation)
持久代(Permanent Generation)
工作原理:
- 新生代(Young Generation):
新生成对象放到Eden区,Eden区满时把其中存活对象复制到一个Survivor区并把Eden区清空,当前Survivor区满时将存活对象复制到另一个Survivor区并将当前Survivor区清空。Survivor区始终保持一个是清空的(可以理解为,在复制过程中只有一个Survivor区是活动的直到它满了切换成另一个,Eden区只往活动的Survivor区复制对象,Survivor区存放着从Eden区复制过来的对象和另一个Survivor区复制过来的对象)。如果一个对象被复制过N次(Eden -> Survivor -> Survivor -> Survivor … 默认15次,最大15,因为对象头里边记录年龄的空间为4bit,CMS收集器默认6),那么下次Survivor区垃圾回收时它将会被复制到老年代(Old Generation)而不会进入另一个Survivor区。根据程序需要,Survivor区是可以配置为多个的(多于两个),这样可以增加对象在新生代中的存在时间,减少被放到老年代的可能。
Eden区频繁GC,所以Eden区不会分配的很大 - 老年代(Old Generation):
在新生代中经历了N次垃圾回收后仍然存活的对象,就会被放到老年代中。因此,可以认为老年代中存放的都是一些生命周期较长的对象。针对老年代,不同垃圾回收器采用不同的垃圾回收算法(下文介绍),据上文所说,标记 - 清除算法会产生碎片,但是更快,复制算法只能使用一半的内存,存活对象较多时会慢一些,标记 - 整理算法不会产生内存碎片,效率尚可。需要根据业务特性选择合适的收集器(在快、内存使用率高中做抉择)。大多数收集器在老年代采用标记 - 整理算法(这里强调大多数,下文有各种垃圾收集器采用不同算法的比较)。 - 持久代:
用于存放静态文件,如Java类、方法等。持久代对垃圾回收没有显著影响,但是有些应用可能动态生成或者调用一些class,例如Hibernate等,在这种时候需要设置一个比较大的持久代空间来存放这些运行过程中新增的类。持久代大小通过-XX:MaxPermSize=进行设置。
垃圾收集器
这里有必要说一下安全点(SafePoint):程序运行时不停地产生新对象,对象的引用关系也不停的变化,即将要垃圾回收时要保证内存空间不会产生变化时再开始,工作线程不能任意时刻都暂停,在达到了临近的安全点(SafePoint)时停下,工作线程全部暂停时就产生了“Stop The World”效果,开始垃圾回收,回收完毕工作线程继续运行。这种方式在回收较大的内存空间时应用停顿时间较长,为了弥补缺点,产生了并发垃圾回收模式,即工作内存一边产生新对象,一边进行对象标记或回收,这极大地增加了算法复杂度,系统工作效率也因此降低,回收阶段还会产生“碎片”问题。
另一个概念Full GC:对整个堆进行整理,包括Young、Tenured和Perm。Full GC需要对整个对进行回收,过程很慢,因此应该尽可能减少Full GC的次数。在对JVM调优的过程中,很大一部分工作就是对于Full GC的调节。有如下原因可能导致Full GC:
- 年老代(Tenured)被写满
- 持久代(Perm)被写满
- System.gc()被显示调用
- 上一次GC之后Heap的各域分配策略动态变化
Serial 收集器
关键词:新生代、单线程、串行、复制、“Stop The World”
HotSpot虚拟机运行在Client模式下的默认的新生代收集器。它的特点:简单而高效(与其他收集器的单线程相比),对于限定单个CPU的环境来说,Serial收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得更高的单线程收集效率。
-XX:-UseSerialGC:使用串行垃圾回收器回收新生代
Serial Old 收集器
关键词:老年代、单线程、串行、标记-整理
Serial Old是Serial收集器的老年代版本,此收集器的主要意义是给Client模式下的虚拟机使用。如果在Server模式下,它还有两大用途:
1.在JDK1.5 以及之前版本(Parallel Old诞生以前)中与Parallel Scavenge收集器搭配使用。
2.作为CMS收集器的后备预案,在并发收集发生Concurrent Mode Failure时使用。
ParNew 收集器
关键词:新生代、并行、多线程、复制
ParNew收集器就是Serial收集器的多线程版本,它也是一个新生代收集器。Server模式下的虚拟机中首选的新生代收集器,除了Serial收集器外,目前只有它能和CMS收集器(Concurrent Mark Sweep)配合工作。ParNew 收集器在单CPU的环境中绝对不会有比Serial收集器有更好的效果,随着CPU数量增加,它对于GC时系统资源的有效利用是很有好处的。它默认开启的收集线程数与CPU的数量相同。
-XX:+UseParNewGC:使用并行垃圾回收器回收新生代
-XX:ParallerGCThreads:设定GC的线程数,默认与CPU核数相同。
Parallel Scavenge 收集器
关键词:新生代、并行、多线程、复制、吞吐量
Parallel Scavenge收集器关注点和其他收集器不同,CMS等收集器尽可能地缩短垃圾收集时用户线程停顿的时间,Parallel Scavenge收集器的目标是达到可控的吞吐量(吞吐量=运行用户代码时间/(运行用户代码时间+垃圾收集 时间)),Parallel Scavenge和后文介绍的G1收集器没有使用传统的GC代码框架,而另外独立实现。高吞吐量可以高效率的利用CPU时间,主要适合在后台运算而不需要太多交互的任务。
-XX:-UseParallelGC:使用Parallel Scavenge垃圾回收器,例如:-XX:UseParallelGC = “Parallel Scavenge” + “Serial Old”
-XX:MaxGCPauseMillis:毫秒,指定垃圾回收时最长暂停时间,如果指定了此值堆大小和垃圾回收相关参数会进行调整以达到指定值。
-XX:GCTimeRatio:吞吐量为垃圾回收时间与非垃圾回收时间的比值,公式为1/(1+N)
Parallel Old 收集器
关键词:老年代、并行、多线程、标记-整理、吞吐量
Parallel Scavenge的老年代版本,吞吐量优先,和Parallel Scavenge配合使用。
-XX:-UseParallelOldGC:用并行垃圾回收器进行full gc,例如:-XX:UseParallelOldGC = “Parallel Scavenge” + “Parallel Old”
CMS( Concurrent Mark Sweep) 收集器(并发低停顿收集器)
关键词:老年代、并发、多线程、标记-清除
一种以获取最短回收停顿时间为目标的收集器。适用于互联网或B/S系统服务器(注重响应速度,希望系统停顿时间最短)。
运作过程包含4个步骤:
- 初始标记(CMS initial mark):
需要“Stop The World”,标记GC Roots能直接关联到 的对象,速度很快。 - 并发标记(CMS concurrent mark):
GC Roots Tracing的过程,三个标记步骤中停顿时间最长。 - 重新标记(CMS remark):
需要“Stop The World”,修正并发标记期间由用户程序运作而导致标记记录产生变动的部分,停顿时间比初始标记稍长,远比并发标记短。 - 并发清除(CMS concurrent sweep)
缺点:
- 在并发阶段因占用一部分CUP资源而导致应用变慢,吞吐量降低。默认启动回收线程数是(CPU数量+3)/4,少于4个CPU时影响较大。
- 浮动垃圾(Floating Garbage)问题可能出现“Concurrent Mode Failure”导致Full GC:并发清理阶段会有新的未被标记的垃圾(浮动垃圾)不断产生,只能等到下一次GC再清理。需要预留内存空间保证并发垃圾收集阶段给用户线程使用,如果预留的内存不足就会出现“Concurrent Mode Failure”,虚拟机临时启用Serial Old收集器重新对老年代垃圾回收,这样停顿时间就很长了。
- 基于标记-清除算法会有内存碎片,内存不足时触发“合并整理”或FullGC,此过程无法并发,停顿时间变长。
-XX:-UseConcMarkSweepGC:使用CMS做为垃圾回收器,当前常见的垃圾回收器组合是:-XX:+UseConcMarkSweepGC -XX:+UseParNewGC
-XX:ParallelGCThreads:设置年轻代的并行收集线程数, 年轻代的并行收集线程数默认是(cpu<=8)?cpu:3+((cpu*5) / 8)。
-XX:ParallelCMSThreads:设置CMS收集线程数,CMS默认启动的回收线程数目 (ParallelGCThreads+3)/4)
-XX:+UseCMSCompactAtFullCollection:CMS是不会整理堆碎片的,因此为了防止堆碎片引起full gc,通过会开启CMS阶段进行合并碎片选项. 为了减少第二次暂停的时间,开启并行remark:-XX:+CMSParallelRemarkEnabled。如果remark还是过长的话,可以开启**-XX:+CMSScavengeBeforeRemark**选项,强制remark之前开始一次minor gc,减少remark的暂停时间,但是在remark之后也将立即开始又一次minor gc.
-XX:+CMSClassUnloadingEnabled-XX:+CMSPermGenSweepingEnabled:一般情况下,持久代是不会进行GC的,通过以上参数进行强制设置。
-XX+UseCMSCompactAtFullCollection:在FULL GC的时候, 对年老代的压缩
-XX:CMSFullGCsBeforeCompaction:对年老代的压缩开启的情况下,多少次FULL GC后进行内存压缩整理,默认值为0,表示每次进入Full GC时都进行 碎片整理
-XX:+UseCMSInitiatingOccupancyOnly:指示只有在old generation在使用了初始化的比例后concurrentcollector启动收集
-XX:CMSInitiatingOccupancyFraction:默认80。默认CMS是在tenured generation占68%的时候开始进行CMS收集, 如果你的年老代增长不是那么快,并且希望降低CMS次数的话,可以适当调高此值,过高将更容易出现“Concurrent Mode Failure”,得不偿失。
-XX:+AggressiveHeap:试图是使用大量的物理内存,长时间大内存使用的优化,CMS收集生效
-XX:+CMSIncrementalMode:设置为增量模式, 单CPU情况使用。
G1(Garbage- First)收集器
关键词:新生代 + 老年代、并发、多线程、标记-整理 + 复制
最新垃圾收集器,有望在将来替代CMS。使用G1收集器时,Java堆的内存布局与其他收集器有很大差别,它将整个Java堆划分为多个大小相等的独立区域(Region),保留新生代和老年代的概念,但新生代和老年代不再是物理隔离的,它们是一部分Region(不需要连续)的集合。G1收集器能建立可预测的停顿时间模型,它可以有计划地避免在整个Java堆中进行全区域垃圾收集。G1跟踪各个Region里面的垃圾堆积的价值大小(回收所获得的空间大小以及回收所需时间的经验值),在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的Region(这也就是Garbage-First名称的来由)。这种使用Region划分内存空间以及有优先级的区域回收方式,保证了G1收集器在有限的时间内可以获取尽可能高的收集效率。
运作过程包含以下步骤:
- 初始标记(InitialMarking):
需要“Stop The World”,标记GC Roots能直接关联到的对象,并且修改TAMS(Next Top at Mark Start)的值,让下一阶段用户程序并发运行时,能在正确可用的Region中创建新对象,这阶段需要停顿线程,但耗时很短。 - 并发标记(ConcurrentMarking):
从GCRoot开始对堆中对象进行可达性分析,找出存活的对象,这阶段耗时较长,但可与用户程序并发执行。 - 最终标记(FinalMarking):
修正在并发标记期间因用户程序继续运作而导致标记产生变动的那一部分标记记录,虚拟机将这段时间对象变化记录在线程Remembered Set Logs里面,最终标记阶段需要把Remembered Set Logs的数据合并到Remembered Set中,这阶段需要停顿线程,但是可并行执行。 - 筛选回收(LiveDataCountingandEvacuation):
首先对各个Region的回收价值和成本进行排序,根据用户所期望的GC停顿时间来制定回收计划,这个阶段可以与用户程序一起并发执行,但是因为只回收一部分Region,时间是用户可控制的,而且停顿用户线程将大幅提高收集效率。
具体移步:
详解 JVM Garbage First(G1) 垃圾收集器
Jvm 系列(三):GC 算法 垃圾收集器
总结
收集器 | 串行/并行/并发 | 新生代/老年代 | 算法 | 目标 | 适用场景 |
---|---|---|---|---|---|
Serial | 串行 | 新生代 | 复制算法 | 响应速度优先 | 单CPU环境下的Client模式 |
Serial Old | 串行 | 老年代 | 标记-整理 | 响应速度优先 | 单CPU环境下的Client模式、CMS的后备预案 |
ParNew | 并行 | 新生代 | 复制算法 | 响应速度优先 | 多CPU环境时在Server模式下与CMS配合 |
Parallel Scavenge | 并行 | 新生代 | 复制算法 | 吞吐量优先 | 在后台运算而不需要太多交互的任务 |
Parallel Old | 并行 | 老年代 | 标记-整理 | 吞吐量优先 | 在后台运算而不需要太多交互的任务 |
CMS | 并发 | 老年代 | 标记-清除 | 响应速度优先 | 集中在互联网站或B/S系统服务端上的Java应用 |
G1 | 并发 | 新生代 + 老年代 | 标记-整理+复制算法 | 响应速度优先 | 面向服务端应用,将来替换CMS |
引自:7种垃圾收集器
垃圾收集器搭配使用关系
连线代表可以搭配使用。
常用的收集器组合:
组合 | 新生代GC | 老年老代GC | 说明 |
---|---|---|---|
组合1 | Serial | Serial Old | Serial和Serial Old都是单线程进行GC,特点就是GC时暂停所有应用线程。 |
组合2 | Serial | CMS+Serial Old | CMS(Concurrent Mark Sweep)是并发GC,实现GC线程和应用线程并发工作,不需要暂停所有应用线程。另外,当CMS进行GC失败时,会自动使用Serial Old策略进行GC。 |
组合3 | ParNew | CMS+Serial Old | 使用-XX:+UseParNewGC 选项来开启。ParNew是Serial的并行版本,可以指定GC线程数,默认GC线程数为CPU的数量。可以使用-XX:ParallelGCThreads 选项指定GC的线程数。如果指定了选项-XX:+UseConcMarkSweepGC 选项,则新生代默认使用ParNew GC策略。 |
组合4 | ParNew | Serial Old | 使用-XX:+UseParNewGC 选项来开启。新生代使用ParNew GC策略,年老代默认使用Serial Old GC策略。 |
组合5 | Parallel Scavenge | Serial Old | Parallel Scavenge策略主要是关注一个可控的吞吐量:应用程序运行时间 / (应用程序运行时间 + GC时间),可见这会使得CPU的利用率尽可能的高,适用于后台持久运行的应用程序,而不适用于交互较多的应用程序。 |
组合6 | Parallel Scavenge | Parallel Old | 吞吐量优先的配置 |
组合7 | G1 | G1 | -XX:+UnlockExperimentalVMOptions -XX:+UseG1GC #开启; -XX:MaxGCPauseMillis=50 #暂停时间目标;-XX:GCPauseIntervalMillis =200 #暂停间隔目标;-XX:+G1YoungGenSize=512m #年轻代大小;-XX:SurvivorRatio=6 #幸存区比例 |
表引自:Jvm 系列(三):GC 算法 垃圾收集器,有一定的修正。
Java 类加载机制
类从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期包括:加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸载(Unloading)7个阶段。其中验证、准备、解析3个部分统称为连接(Linking)。
-
加载
加载阶段虚拟机需要完成三件事:- 通过一个类的全限定名来获取其定义的二进制字节流。
- 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
- 在Java堆中生成一个代表这个类的java.lang.Class对象,作为对方法区中这些数据的访问入口。
可以理解为,读取二进制字节流(读.class,从jar、ear、war、网络上、zip包、还是动态代理生成的都可以)生成java.lang.Class对象的过程。
-
连接阶段第一步:验证
确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。四个阶段:- 文件格式验证:字节流是否符合Class文件格式规范,并且能被当前版本虚拟机处理。例如:是否以0xCAFEBABE开头、主次版本号是否在当前虚拟机的处理范围之内、常量池中的常量是否有不被支持的类型。
- 元数据验证:对字节码描述的信息进行语义分析,以保证其描述的信息符合Java语言规范的要求。例如:这个类是否有父类(除了java.lang.Object之外),是否继承了final修饰的类,不是抽象类的类是否实现了全部方法等。
- 字节码验证:通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的。
- 符号引用验证:确保解析动作能正确执行。对类自身以外(常量池中的各种符号引用)的信息进行匹配性校验。
验证阶段是非常重要的,但不是必须的,它对程序运行期没有影响,如果所引用的类经过反复验证,那么可以考虑采用-Xverifynone参数来关闭大部分的类验证措施,以缩短虚拟机类加载的时间。
-
连接阶段第二步:准备
为类变量(static修饰的变量)在方法区分配内存并设置初始值。- 不包括实例变量(实例变量在实例化时分配在堆中)。
- 初始值指的是数据类型的默认值:
byte = (byte)0
shot = (shot)0
int = 0
long = 0L
float = 0.0f
double = 0.0d
char = ‘\u0000’
boolean = false
reference = null
如果是ConstantValue(static final)则初始化为属性所指定的值
(例如public static final int value = 3则被赋值为3而不是0)。
-
连接阶段第三步:解析
将常量池中的符号引用替换为直接引用。
解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符7类符号引用进行。
符号引用就是一组符号来描述目标,可以是任何字面量。直接引用可以是直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄。 -
初始化
初始化,为类的静态变量赋予正确的初始值,JVM负责对类进行初始化,主要对类变量进行初始化。在Java中对类变量进行初始值设定有两种方式:
1、声明类变量是指定初始值
2、使用静态代码块为类变量指定初始值JVM初始化步骤
1、假如这个类还没有被加载和连接,则程序先加载并连接该类
2、假如该类的直接父类还没有被初始化,则先初始化其直接父类
3、假如类中有初始化语句,则系统依次执行这些初始化语句初始化 时机:
- 创建类的实例,也就是new的方式
- 访问某个类或接口的静态变量,或者对该静态变量赋值
- 调用类的静态方法
- 用java.lang.reflect包的方法对类进行反射调用
- 初始化某个类,其父类会先被初始化
- Java虚拟机启动时main方法所在的类
- 当使用JDK1.7的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果REF_getStatic、REF_putStatic、REF_invokeStatic的方法句柄,并且这个方法句柄所对应的类没有进行过初始化,则需要先触发其初始化。
类加载器(ClassLoader)
类加载机制中第一阶段的“加载”放到虚拟机外部实现,可据此自定义实现类从“哪”加载。
在同一虚拟机下,不同ClassLoader加载的同一个类是不相等的(equals()、instanceof、isAssignableFrom()、isInstance())。
类加载器分为三类:
Bootstrap ClassLoader(启动类加载器):使用C++实现,负责加载存放在%JAVA_HOME%\jre\lib下或被-Xbootclasspath参数指定路径中的,并且能被虚拟机识别的类库(如rt.jar,所有的java.*开头的类均被Bootstrap ClassLoader加载)。启动类加载器无法被Java程序直接引用。如果需要把加载请求委派给启动类加载器,直接使用null替代即可。
Extension ClassLoader(扩展类加载器):该加载器由sun.misc.Launcher$ExtClassLoader实现,它负责加载%JAVA_HOME%\jre\lib\ext目录中,或者由java.ext.dirs系统变量指定的路径中的所有类库(如javax.*开头的类),开发者可以直接使用扩展类加载器。
Application ClassLoader(应用程序类加载器):该类加载器由sun.misc.Launcher $AppClassLoader来实现,它负责加载用户类路径(ClassPath)所指定的类,开发者可以直接使用该类加载器,如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。
工作原理(双亲委派模型):
如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去加载。
AppClassLoader加载一个class时,它首先不会自己去尝试加载这个类,而是把类加载请求委派给父类加载器ExtClassLoader去完成。ExtClassLoader加载一个class时,它首先也不会自己去尝试加载这个类,而是把类加载请求委派给BootStrapClassLoader去完成。如果BootStrapClassLoader加载失败(例如在$JAVA_HOME/jre/lib里未查找到该class),会使用ExtClassLoader来尝试加载;若ExtClassLoader也加载失败,则会使用AppClassLoader来加载,如果AppClassLoader也加载失败,则会报出异常ClassNotFoundException。
示例(java.lang.ClassLoader.loadClass):
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
synchronized (getClassLoadingLock(name)) {
// First, check if the class has already been loaded
// 检查类是否被加载过了
Class<?> c = findLoadedClass(name);
if (c == null) {
long t0 = System.nanoTime();
try {
if (parent != null) {
// 调用父类加载器
c = parent.loadClass(name, false);
} else {
// 调用BootstrapClass
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// ClassNotFoundException thrown if class not found
// from the non-null parent class loader
// 父类加载器无法加载时抛出ClassNotFoundException 异常
}
if (c == null) {
// If still not found, then invoke findClass in order
// to find the class.
// 父类加载器没加载的情况下调用本地的findClass加载
long t1 = System.nanoTime();
c = findClass(name);
// this is the defining class loader; record the stats
sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
sun.misc.PerfCounter.getFindClasses().increment();
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
}
双亲委派模型意义:
- 系统类防止内存中出现多份同样的字节码
- 保证Java程序安全稳定运行
类的加载
类加载有三种方式:
- 命令行启动应用时候由JVM初始化加载
- 通过Class.forName()方法动态加载
- 通过ClassLoader.loadClass()方法动态加载
Class.forName()和ClassLoader.loadClass()区别:
Class.forName():将类的.class文件加载到jvm中之外,还会对类进行解释,执行类中的static块。
ClassLoader.loadClass():只干一件事情,就是将.class文件加载到jvm中,不会执行static中的内容,只有在newInstance才会去执行static块。
注:Class.forName(name, initialize, loader)带参函数也可控制是否加载static块。并且只有调用了newInstance()方法采用调用构造函数,创建类的对象 。
public class loaderTest {
public static void main(String[] args) throws ClassNotFoundException {
ClassLoader loader = HelloWorld.class.getClassLoader();
System.out.println(loader);
//使用ClassLoader.loadClass()来加载类,不会执行初始化块
loader.loadClass("Test2");
//使用Class.forName()来加载类,默认会执行初始化块
// Class.forName("Test2");
//使用Class.forName()来加载类,并指定ClassLoader,初始化时不执行静态块
// Class.forName("Test2", false, loader);
}
}
// 测试对象类
public class Test2 {
static {
System.out.println("静态初始化块执行了!");
}
}
自定义类加载器
自定义类加载器一般都是继承自 ClassLoader 类,只需要重写 findClass() 方法即可。
import java.io.*;
public class MyClassLoader extends ClassLoader {
private String root;
protected Class<?> findClass(String name) throws ClassNotFoundException {
byte[] classData = loadClassData(name);
if (classData == null) {
throw new ClassNotFoundException();
} else {
return defineClass(name, classData, 0, classData.length);
}
}
private byte[] loadClassData(String className) {
String fileName = root + File.separatorChar
+ className.replace('.', File.separatorChar) + ".class";
try {
InputStream ins = new FileInputStream(fileName);
ByteArrayOutputStream baos = new ByteArrayOutputStream();
int bufferSize = 1024;
byte[] buffer = new byte[bufferSize];
int length = 0;
while ((length = ins.read(buffer)) != -1) {
baos.write(buffer, 0, length);
}
return baos.toByteArray();
} catch (IOException e) {
e.printStackTrace();
}
return null;
}
public String getRoot() {
return root;
}
public void setRoot(String root) {
this.root = root;
}
public static void main(String[] args) {
MyClassLoader classLoader = new MyClassLoader();
classLoader.setRoot("E:\\temp");
Class<?> testClass = null;
try {
testClass = classLoader.loadClass("com.neo.classloader.Test2");
Object object = testClass.newInstance();
System.out.println(object.getClass().getClassLoader());
} catch (ClassNotFoundException e) {
e.printStackTrace();
} catch (InstantiationException e) {
e.printStackTrace();
} catch (IllegalAccessException e) {
e.printStackTrace();
}
}
}
自定义类加载器的核心在于对字节码文件的获取,如果是加密的字节码则需要在该类中对文件进行解密。由于这里只是演示,我并未对class文件进行加密,因此没有解密的过程。这里有几点需要注意:
1、这里传递的文件名需要是类的全限定性名称,即com.paddx.test.classloading.Test格式的,因为 defineClass 方法是按这种格式进行处理的。
2、最好不要重写loadClass方法,因为这样容易破坏双亲委托模式。
3、这类Test 类本身可以被 AppClassLoader 类加载,因此我们不能把 com/paddx/test/classloading/Test.class 放在类路径下。否则,由于双亲委托机制的存在,会直接导致该类由AppClassLoader 加载,而不会通过我们自定义类加载器来加载。
部分例子和内容引自:Java 类的加载机制,感谢!