1)、定义:
Program Counter Register 程序计数器(寄存器)
2)、作用
解释器会解释指令为机器码交给 cpu 执行,程序计数器会记录下一条指令的地址行号,这样下一次解释器会从程序计数器拿到指令然后进行解释执行。
多线程的环境下,如果两个线程发生了上下文切换,那么程序计数器会记录线程下一行指令的地址行号,以便于接着往下执行。
1)、定义
栈是先进后出,拥有参数、局部变量、返回地址。
可以理解为一个方法代表一个栈帧
2)、问题辨析
垃圾回收是否涉及栈内存?
答:不会,栈帧内存在每次方法结束后都会弹出栈,自动回收掉
栈内存分配越大越好吗?
答:不对,因为物理内存是一定的,栈内存越大,可以支持更多的递归调用,但是可执行的线程数目就会越少。
方法内的局部变量是否线程安全?
3)、栈内存溢出
3.1、java.lang.stackOverflowError 问题出现原因:
3.2、优化:一般默认的就行
也可设置栈内存大小:-Xss1024k
带有 native 关键字的方法就是需要 JAVA 去调用本地的C或者C++方法,因为 JAVA 没法直接和操作系统底层交互,所以需要用到本地方法栈,服务于带 native 关键字的方法。
1)、定义
特点:
2)、堆内存溢出
java.lang.OutofMemoryError :java heap space
出现原因:一直创建对象,没法回收
如:一直创建String,并存在list,因为是一直用的,所以没法回收,
指定堆内存大小:
3)、堆内存诊断工具
直接新开终端输入即可
1)、定义
Java 虚拟机有一个在所有 Java 虚拟机线程之间共享的方法区域。方法区域类似于用于传统语言的编译代码的存储区域,或者类似于操作系统进程中的“文本”段。它存储每个类的结构,例如运行时常量池、字段和方法数据,以及方法和构造函数的代码,包括特殊方法,用于类和实例初始化以及接口初始化方法区域是在虚拟机启动时创建的。尽管方法区域在逻辑上是堆的一部分,但简单的实现可能不会选择垃圾收集或压缩它。此规范不强制指定方法区的位置或用于管理已编译代码的策略。方法区域可以具有固定的大小,或者可以根据计算的需要进行扩展,并且如果不需要更大的方法区域,则可以收缩。方法区域的内存不需要是连续的!
2)、组成
方法区存放的数据主要是被类加载器加载后的类信息,运行时常量池等等。
3)、方法区内存溢出
一般不会出现,可用 -XX:MaxMetaspaceSize=8m 指定元空间大小
4)、运行时常量池
例如:
终端输入 javap -v HelloWorld.class
即可查看
5)、StringTable
先看几道面试题,如果能全部答对并知道为什么,这节可跳过
String s1 = "a";
String s2 = "b";
String s3 = "a" + "b";
String s4 = s1 + s2;
String s5 = "ab";
String s6 = s4.intern();
// 问
System.out.println(s3 == s4);
System.out.println(s3 == s5);
System.out.println(s3 == s6);
String x2 = new String("c") + new String("d");
String x1 = "cd";
x2.intern();
// 问,如果调换了【最后两行代码】的位置呢,如果是jdk1.6呢
System.out.println(x1 == x2);
分析:
1、a、b、ab 都是常量,直接加到常量池里
2、变量s1+变量s2 拼接相当于 StringBuilder
而StringBuilder的toString方法会创建一个新String,也就是 new String(“ab”),说明是存在堆里面的
说明 以下判断是 false,因为s3对象是存在常量池的,s4 new了一个对象存在堆里的,位置不一样,是两个对象
3、常量 a + 常量b 拼接,发现延用了常量池已有的ab字符串对象
说明常量拼接结果为true
4、字符串延迟加载
jdk1.6 StringTable 位置是在永久代中,1.8 StringTable 位置是在堆中。
8)、StringTable垃圾回收
-Xmx10m 指定堆内存大小
-XX:+PrintStringTableStatistics 打印字符串常量池信息
-XX:+PrintGCDetails
-verbose:gc 打印 gc 的次数,耗费时间等信息
9)、StringTable 性能调优
1)、定义
这部分比较浅,有兴趣的自行去了解一下NIO
直接内存使用与传统io的时间,以下拷贝案例
流程理解:
正常文件读写流程:
因为 java 不能直接操作文件管理,需要切换到内核态,使用本地方法进行操作,然后读取磁盘文件,会在系统内存中创建一个缓冲区,将数据读到系统缓冲区, 然后在将系统缓冲区数据,复制到 java缓冲区存在堆内存中。缺点是数据存储了两份,在系统内存中有一份,java 堆中有一份,造成了不必要的复制。
直接内存是操作系统和 Java 代码都可以访问的一块区域,无需将代码从系统内存复制到 Java 堆内存,从而提高了效率。
2、分配和回收原理
当一个对象被引用时,就当引用对象的值加一,当值为 0 时,就表示该对象不被引用,可以被垃圾收集器回收。
这个引用计数法听起来不错,但是有一个弊端,如下图所示,循环引用时,两个对象的计数都为1,导致两个对象都无法被释放。
演示:哪些对象可以作为根对象
步骤2:使用 jmap -dump:format=b,live,file=1.bin 21384 命令转储文件 断点 到list=null 再次转储为2.bin
dump:转储文件
format=b:二进制文件,live抓存活的
file:文件名
21384:进程的id
步骤3:使用Eclipse Memory Analyzer工具 对 1.bin 文件进行分析。
gc roots分析的1.bin,找到了 ArrayList 对象,然后将 list 置为null,再次转储,也就是2.bin文件,发现arrayList没有了,说明 list 对象被回收。
特点: 只要不为null,GC时,永远不会被回收
强引用有引用变量指向时永远不会被垃圾回收,JVM宁愿抛出OutOfMemory错误也不会回收这种对象。可以将引用赋值为null,这样一来的话,JVM在合适的时间就会回收该对象。
比如:
Object object = new Object();
String str = "hello"
强引用也是导致内存泄露的主要原因
特点: 内存不足时(自动触发GC),会被回收
仅有软引用引用该对象时,在垃圾回收后,内存仍不足时会再次出发垃圾回收,回收软引用
对象
可以配合引用队列来释放软引用自身
软引用可用来实现内存敏感的高速缓存,比如网页缓存、图片缓存等。使用软引用能防止内存泄露,增强程序的健壮性。
示例:
设置参数:-Xms10m -Xmx10m -XX:+PrintGCDetails
演示1:强引用,发生内存溢出,方便与软引用比较
软引用示例:说明内存不够时,会回收软引用
演示2:软引用配合引用队列,来释放软引用自身
特点: 无论内存是否充足,只要进行GC,都会被回收
特点: 如同虚设,和没有引用没什么区别
虚引用和前面的软引用、弱引用不同,它并不影响对象的生命周期。在java中用java.lang.ref.PhantomReference类表示。如果一个对象与虚引用关联,则跟没有引用与之关联一样,在任何时候都可能被垃圾回收器回收。
无需手动编码,但其内部配合引用队列使用,在垃圾回收时,终结器引用入队(被引用对象
暂时没有被回收),再由 Finalizer 线程通过终结器引用找到被引用对象并调用它的 finalize
方法,第二次 GC 时才能回收被引用对象
标记清除法是先找到内存里的存活对象并对其进行标记,然后统一把未标记的对象统一的清理。
特点:
标记整理法分为标记和整理两个阶段:
1、标记阶段会先把存活的对象和可回收的对象标记出来;
2、标记完再对内存对象进行整理
标记复制法算是完美的补齐了标记清除法的短板,既解决了空间碎片的问题,又适合使用在大部分对象都是可回收的场景。 不过标记复制法也有不完美的地方,一方面是需要空闲出一块内存空间用来腾挪对象,另外一方面它在存活对象比较多的场景也不是太适合,而存活对象多的场景通常适合使用标记清除法,但是标记清除法会产生空间碎片又是一个无法忍受的问题。
在GC开始的时候,对象只会存在于Eden区和名为“From”的Survivor区,Survivor区“To”是空的。紧接着进行GC,Eden区中所有存活的对象都会被复制到“To”,而在“From”区中,仍存活的对象会根据他们的年龄值来决定去向。年龄达到一定值(年龄阈值,可以通过-XX:MaxTenuringThreshold来设置)的对象会被移动到年老代中,没有达到阈值的对象会被复制到“To”区域。经过这次GC后,Eden区和From区已经被清空。这个时候,“From”和“To”会交换他们的角色,也就是新的“To”就是上次GC前的“From”,新的“From”就是上次GC前的“To”。不管怎样,都会保证名为To的Survivor区域是空的。
特点:
含义 | 参数 |
---|---|
堆初始大小 | -Xms |
堆最大大小 | -Xmx 或 -XX:MaxHeapSize=size |
新生代大小 | -Xmn 或 (-XX:NewSize=size + -XX:MaxNewSize=size ) |
幸存区比例(动态) | -XX:InitialSurvivorRatio=ratio 和 -XX:+UseAdaptiveSizePolicy |
幸存区比例 | -XX:SurvivorRatio=ratio |
晋升阈值 | -XX:MaxTenuringThreshold=threshold |
晋升详情 | -XX:+PrintTenuringDistribution |
GC详情 | -XX:+PrintGCDetails -verbose:gc |
FullGC 前 MinorGC | -XX:+ScavengeBeforeFullGC |
设置参数 -Xms20m -Xmx20m -Xmn10m -XX:+UseSerialGC -XX:+PrintGCDetails -verbose:gc
没运行任何代码时,堆内存占用情况
加了7M后,垃圾回收发生的变化
图中展示了7种作用于不同分代的收集器,如果两个收集器之间存在连线,则说明它们可以搭配使用。虚拟机所处的区域则表示它是属于新生代还是老年代收集器。
新生代收集器: Serial、ParNew、Parallel Scavenge
老年代收集器: CMS、Serial Old、Parallel Old
整堆收集器: G1
几个相关概念:
并行收集:指多条垃圾收集线程并行工作,但此时用户线程仍处于等待状态。
并发收集:指用户线程与垃圾收集线程同时工作(不一定是并行的可能会交替执行)。用户程序在继续运行,而垃圾收集程序运行在另一个CPU上。
吞吐量:即CPU用于运行用户代码的时间与CPU总消耗时间的比值(吞吐量 = 运行用户代码时间 / ( 运行用户代码时间 + 垃圾收集时间 ))。例如:虚拟机共运行100分钟,垃圾收集器花掉1分钟,那么吞吐量就是99%
Serial收集器是最基本的、发展历史最悠久的收集器。
特点: 单线程、简单高效(与其他收集器的单线程相比),对于限定单个CPU的环境来说,Serial收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得最高的单线程收集效率。收集器进行垃圾回收时,必须暂停其他所有的工作线程,直到它结束(Stop The World)。
应用场景: 适用于Client模式下的虚拟机。
Serial / Serial Old收集器运行示意图
ParNew收集器其实就是Serial收集器的多线程版本。除了使用多线程外其余行为均和Serial收集器一模一样(参数控制、收集算法、Stop The World、对象分配规则、回收策略等)。
特点: 多线程、ParNew收集器默认开启的收集线程数与CPU的数量相同,在CPU非常多的环境中,可以使用-XX:ParallelGCThreads参数来限制垃圾收集的线程数。
和Serial收集器一样存在Stop The World问题
应用场景: ParNew收集器是许多运行在Server模式下的虚拟机中首选的新生代收集器,因为它是除了Serial收集器外,唯一一个能与CMS收集器配合工作的。
ParNew/Serial Old组合收集器运行示意图如下:
与吞吐量关系密切,故也称为吞吐量优先收集器。
特点: 属于新生代收集器也是采用复制算法的收集器,又是并行的多线程收集器(与ParNew收集器类似)。
Parallel Scavenge收集器使用两个参数控制吞吐量:
Serial Old是Serial收集器的老年代版本。
特点: 同样是单线程收集器,采用标记-整理算法。
应用场景: 主要也是使用在Client模式下的虚拟机中。也可在Server模式下使用。
Server模式下主要的两大用途:
Serial / Serial Old收集器工作过程图(Serial收集器图示相同):
是Parallel Scavenge收集器的老年代版本。
特点: 多线程,采用标记-整理算法。
应用场景: 注重高吞吐量以及CPU资源敏感的场合,都可以优先考虑Parallel Scavenge+Parallel Old 收集器。
Parallel Scavenge/Parallel Old收集器工作过程图:
一种以获取最短回收停顿时间为目标的收集器。
特点: 基于标记-清除算法实现。收集区域在老年代,并发收集、低停顿。
应用场景: 适用于注重服务的响应速度,希望系统停顿时间最短,给用户带来更好的体验等场景下。如web程序、b/s服务。
CMS收集器的运行过程分为下列4步:
初始标记:标记GC Roots能直接到的对象。速度很快但是仍存在Stop The World问题。
并发标记:进行GC Roots Tracing 的过程,找出存活对象且用户线程可并发执行。
重新标记:为了修正并发标记期间因用户程序继续运行而导致标记产生变动的那一部分对象的标记记录。仍然存在Stop The World问题。
并发清除:对标记的对象进行清除回收。
CMS收集器的内存回收过程是与用户线程一起并发执行的。
CMS收集器的缺点:
标记清除法有个缺点就是存在内存碎片的问题,那么CMS有个参数设置-XX:+UseCMSCompactAtFullCollecion可以使CMS回收完成之后进行一次碎片整理。
1)、简介
G1首先吸取了CMS优良的思路,还是使用并发收集的模式,但是更重要的是G1摒弃了原来的物理分区,而是把整个内存分成若干个大小的Region区域,然后由不同的Region在逻辑上来组合成各个分代,这样做的好处是G1进行垃圾回收的时候就可以用Region作为单位来进行更细粒度的回收了,每次回收可以只针对某一个或多个Region来进行回收。
适用场景:
相关 JVM 参数:
2)、G1垃圾回收阶段
2.1)、Young Collection(新生代)
会Stop the world
1、在新生代,当伊甸园(E)区满了后,会用复制算法,将对象复制到幸存区(S),
2、再工作一段时间,幸存区也多了,再触发垃圾回收,不够年龄的拷贝到另一个幸存区也就是from和eden区拷贝到to区,若超过一定年龄的,会晋升到老年代。
2.2)、Young Collection +CM(新生代和并发标记)
-XX:InitiatingHeapOccupancyPercent=percent (默认45%)
2.3)、Mixed Collection(混合回收)
会对 E、S、O 进行全面垃圾回收
-XX:MaxGCPauseMillis=ms
3)、Full GC
1.新生代内存不足发生的垃圾收集 - minor gc
2.老年代内存不足发生的垃圾收集- full gc
1.新生代内存不足发生的垃圾收集 - minor gc
2.老年代内存不足发生的垃圾收集- full gc
1.新生代内存不足发生的垃圾收集 - minor gc
2.老年代内存不足时(老年代所占内存超过阈值,阈值可用 -XX:CMSInitiatingoccupancyFraction来指定,默认为68%)
如果垃圾产生速度慢于垃圾回收速度,不会触发 Full GC,还是并发地进行清理
如果垃圾产生速度快于垃圾回收速度,便会触发 Full GC,然后退化成 serial Old 收集器串行的收集,这会导致应用程序中断,直到垃圾回收完成后才会正常工作。
1.新生代内存不足发生的垃圾收集 - minor gc
2.老年代内存不足时(老年代所占内存超过阈值)
如果垃圾产生速度慢于垃圾回收速度,不会触发 Full GC,还是并发地进行清理
如果垃圾产生速度快于垃圾回收速度,便会触发 Full GC,然后退化成 serial Old 收集器串行的收集,这会导致应用程序中断,直到垃圾回收完成后才会正常工作。
4)、Young Collection跨代引用
新生代回收的跨代引用(老年代引用新生代)问题。
就是用可达性分析找到存活对象,复制到幸存区的时候要时候要找到新生代的根对象,根对象有一部分存活在了老年代,如果遍历老年代寻找根对象,效率就很低,因此采用了一种卡表
的技术,把老年代的区细分,分成一个个card,每个car大约是512k,如果老年代其中有一个对象引用了新生代,那么就标记为脏卡
,这样就不用去找老年代了,只需要找这些标记的。
卡表 与 Remembered Set
Remembered Set 存在于E中,用于保存新生代对象对应的脏卡
脏卡:O 被划分为多个区域(一个区域512K),如果该区域引用了新生代对象,则该区域被称为脏卡
在引用变更时通过 post-write barried + dirty card queue
concurrent refinement threads 更新 Remembered Set
5)、Remark(重新标记阶段)
重新标记阶段采用3色标记法
就是用3种颜色来标记对象
1)白色:未被标记的对象
2)灰色;自身被标记,成员变量未被标记
3)黑色:自身和成员变量都已标记完成(代表存活对象)
标记最大的难题就是边标记垃圾,边生产垃圾,即并发标记。并发标记会产生2个问题:浮动垃圾和漏标
1)多标-浮动垃圾
在并发标记的时候,标记了GCRoot这个对象为起点向下搜索引用的对象,这个时候栈帧出栈了,那么其引用的对象之前已经标记为非垃圾对象,浮动垃圾下次再收集。
比如:栈帧 --引用对象A --引用对象B
GC线程从GCRoot开始标记,标记到对象B结束。认为A、B是活对象。突然间应用线程把栈帧出栈了。
2)漏标
由于并发的原因,原本是存活的对象,却被GC线程回收了。
JVM采用了3色标记法,解决标记的2大难题
步骤1:初始化阶段,所有对象都是白色,并记录在白色集合里面。
步骤2:处理GCRoot直接引用对象,把GC Roots直接引用到的A、B对象挪到灰色集合中
步骤3:将灰色集合的A、B挪到黑色集合中,然后把A、B引用的其他对象(C),全部挪到灰色集合中。
步骤4:递归将灰色集合的C挪到黑色集合中,然后把C引用的其他对象D、E全部挪到灰色集合中。
步骤5:递归将灰色集合的D、E挪到黑色集合中,由于D、E没有其他引用的对象,故标记结束。
经过以上的标注后,黑色集合A、B、C、D、E为存活对象,白色集合F、G、H为不可达对象(可回收对象)
写屏障+SATB,解决漏标的问题(G1技术方案):
SATB的全称是Snapchat At The Beginning,原理是,当GC开始之前,复制一份引用关系快照,即当成员变量的引用改变时,记录该成员旧的引用对象,保存到satb_mark_queue中
把E存起来是增量更新,把objC.fieldE存起来是SATB
例如,上图中,当对象C、B的成员变量E改变时,采用写屏障把对象E记录到satb_mark_queue队列中。
每条GC线程都自带一个satb_mark_queue队列,在并发阶段会处理satb_mark_queue中的对象,处理的方法是把satb_mark_queue队列中的对象当做根重新扫描一遍,以解决白色对象引用被修改产生的漏标问题。
缺点:
如果被修改引用的白色对象(例如E对象)就是要被收集的垃圾,SATB的标记会让它躲过GC,这就是浮动垃圾。因为SATB的做法精度比较低,所以造成的浮动垃圾也会比较多。
6)、JDK 8u20字符串去重
开启:-XX:+UseStringDeduplication
String s1 = new String("hello"); // char[]{'h','e','l','l','o'}
String s2 = new String("hello"); // char[]{'h','e','l','l','o'}
过程:
7)、JDK 8u40 并发标记类卸载
所有对象都经过并发标记后,就能知道哪些类不再被使用,当一个类加载器的所有类都不再使用,则卸
载它所加载的所有类
-XX:+ClassUnloadingWithConcurrentMark 默认启用
8)、 JDK 8u60 回收巨型对象
9)、 JDK 9 并发标记起始时间的调整
查看虚拟机运行的相关垃圾回收的参数
"C:\Program Files\Java\jdk1.8.0_281\bin\java" -XX:+PrintFlagsFinal -version | findstr "GC"
【低延迟】还是【高吞吐量】,选择合适的回收器
查看 FullGC 前后的内存占用,考虑下面几个问题:
数据是不是太多?
数据表示是否太臃肿?
是否存在内存泄漏?
排除代码问题后,再来进行调优,建议从新生代开始
新生代的特点:
调优参数:-Xmn
设置新生代的大小
设置越大越好吗?
oracle建议你的新生代内存大于整个堆的25%。小于堆的50%。
调优要考虑条件:
以 CMS 为例:
案例1:Full GC 和Minor GC 频繁
说明空间紧张,如果是新生代空间紧张,当业务高峰期来了,大量对象被创建,很快新生代空间满,会经常Minor GC,而原本很多生存周期短的对象也会被晋升到老年代,老年代存了大量的生存周期短的对象,导致频繁触发Full GC,所以应该先调整新生代的内存空间大一点,让对象尽可能的在新生代。
案例2:请求高峰期发生Full GC,单次暂停时间特别长。(CMS)
分析在哪一部分耗时较长,通过查看GC日志,比较慢的通常会是在重新标记阶段,重新标记会扫描整个堆内存,根据对象找引用,所以解决办法是在重新标记之前,先在新生代进行垃圾回收,这样就会减少重新标记的耗时时间,通过 -XX:+CMSScavengeBeforeRemark 参数在重新标记之前进行垃圾回收
注意:
instanceKlass 这样的【元数据】是存储在方法区(1.8 后的元空间内),但 _java_mirror是存储在堆中
可以通过前面介绍的 HSDB 工具查看
验证
验证类是否符合 JVM规范,安全性检查
用 UE 等支持二进制的编辑器修改 HelloWorld.class 的魔数,在控制台运行
准备
为 static 变量分配空间,设置默认值
解析
将常量池中的符号引用解析为直接引用
()V 方法
初始化即调用 ()V ,虚拟机会保证这个类的『构造方法』的线程安全
发生的时机
概括得说,类初始化是【懒惰的】
不会导致类初始化的情况
以 JDK 8 为例:
名称 | 加载哪的类 | 说明 |
---|---|---|
Bootstrap ClassLoader(启动类加载器) | JAVA_HOME/jre/lib | 无法直接访问 |
Extension ClassLoader(扩展类加载器) | JAVA_HOME/jre/lib/ext | 上级为 Bootstrap,显示为 null |
Application ClassLoader(应用程序加载器) | classpath | 上级为 Extension |
自定义类加载器 | 自定义 | 上级为 Application |
用 Bootstrap 类加载器加载类:
public class F {
static {
System.out.println("bootstrap F init");
}
}
执行
public class Load5_1 {
public static void main(String[] args) throws ClassNotFoundException {
Class<?> aClass = Class.forName("cn.itcast.jvm.t3.load.F");
System.out.println(aClass.getClassLoader());
}
}
输出
E:\git\jvm\out\production\jvm>java -Xbootclasspath/a:.
cn.itcast.jvm.t3.load.Load5
bootstrap F init
null
用扩展类加载器加载类:
classpath 和 JAVA_HOME/jre/lib/ext 下有同名类 G,执行会发现加载了扩展类的加载器
定义: 当一个类加载器收到了类加载的请求的时候,他不会直接去加载指定的类,而是把这个请求委托给自己的父加载器去加载。只有父加载器无法加载这个类的时候,才会由当前这个加载器来负责类的加载。
所谓的双亲委派,就是指调用类加载器的 loadClass 方法时,查找类的规则
注意:这里的双亲,翻译为上级似乎更为合适,因为它们并没有继承关系
我们在使用 JDBC 时,都需要加载 Driver 驱动,不知道你注意到没有,不写
Class.forName("com.mysql.jdbc.Driver")
也是可以让 com.mysql.jdbc.Driver 正确加载的,你知道是怎么做的吗?
让我们追踪一下源码:
先不看别的,看看 DriverManager 的类加载器:
System.out.println(DriverManager.class.getClassLoader());
打印 null,表示它的类加载器是 Bootstrap ClassLoader,会到 JAVA_HOME/jre/lib 下搜索类,但JAVA_HOME/jre/lib 下显然没有 mysql-connector-java-5.1.47.jar 包,这样问题来了,在DriverManager 的静态代码块中,怎么能正确加载 com.mysql.jdbc.Driver 呢?
先看 2)发现它最后是使用 Class.forName 完成类的加载和初始化,关联的是应用程序类加载器,因此
可以顺利完成类加载
再看 1)它就是大名鼎鼎的 Service Provider Interface (SPI)
约定如下,在 jar 包的 META-INF/services 包下,以接口全限定名名为文件,文件内容是实现类名称
ServiceLoader<接口类型> allImpls = ServiceLoader.load(接口类型.class);
Iterator<接口类型> iter = allImpls.iterator();
while(iter.hasNext()) {
iter.next();
}
来得到实现类,体现的是【面向接口编程+解耦】的思想,在下面一些框架中都运用了此思想:
接着看 ServiceLoader.load 方法:
线程上下文类加载器是当前线程使用的类加载器,默认就是应用程序类加载器,它内部又是由
Class.forName 调用了线程上下文类加载器完成类加载,具体代码在 ServiceLoader 的内部类
LazyIterator 中:
问问自己,什么时候需要自定义类加载器
步骤:
示例:加载此路径下的class文件