四、JVM性能调优
1、JVM调优目标:使用较小的内存占用来获得较高的吞吐量或者较低的延迟。
程序在上线前的测试或运行中有时会出现一些大大小小的JVM问题,比如cpu load过高、请求延迟、tps降低等,甚至出现内存泄漏(每次垃圾收集使用的时间越来越长,垃圾收集频率越来越高,每次垃圾收集清理掉的垃圾数据越来越少)、内存溢出导致系统崩溃,因此需要对JVM进行调优,使得程序在正常运行的前提下,获得更高的用户体验和运行效率。
这里有几个比较重要的指标:
内存占用:程序正常运行需要的内存大小。
延迟:由于垃圾收集而引起的程序停顿时间。
吞吐量:用户程序运行时间占用户程序和垃圾收集占用总时间的比值。
当然,和CAP原则一样,同时满足一个程序内存占用小、延迟低、高吞吐量是不可能的,程序的目标不同,调优时所考虑的方向也不同,在调优之前,必须要结合实际场景,有明确的的优化目标,找到性能瓶颈,对瓶颈有针对性的优化,最后进行测试,通过各种监控工具确认调优后的结果是否符合目标。
2、JVM调优工具
(1)调优可以依赖、参考的数据有系统运行日志、堆栈错误信息、gc日志、线程快照、堆转储快照等。
① 系统运行日志:系统运行日志就是在程序代码中打印出的日志,描述了代码级别的系统运行轨迹(执行的方法、入参、返回值等),一般系统出现问题,系统运行日志是首先要查看的日志。
② 堆栈错误信息:当系统出现异常后,可以根据堆栈信息初步定位问题所在,比如根据“java.lang.OutOfMemoryError: Java heap space”可以判断是堆内存溢出;根据“java.lang.StackOverflowError”可以判断是栈溢出;根据“java.lang.OutOfMemoryError: PermGen space”可以判断是方法区溢出等。
③ GC日志:程序启动时用 -XX:+PrintGCDetails 和 -Xloggc:/data/jvm/gc.log 可以在程序运行时把gc的详细过程记录下来,或者直接配置“-verbose:gc”参数把gc日志打印到控制台,通过记录的gc日志可以分析每块内存区域gc的频率、时间等,从而发现问题,进行有针对性的优化。
比如如下一段GC日志:
2018-08-02T14:39:11.560-0800: 10.171: [GC [PSYoungGen: 30128K->4091K(30208K)] 51092K->50790K(98816K), 0.0140970 secs] [Times: user=0.02 sys=0.03, real=0.01 secs] 2018-08-02T14:39:11.574-0800: 10.185: [Full GC [PSYoungGen: 4091K->0K(30208K)] [ParOldGen: 46698K->50669K(68608K)] 50790K->50669K(98816K) [PSPermGen: 2635K->2634K(21504K)], 0.0160030 secs] [Times: user=0.03 sys=0.00, real=0.02 secs] 2018-08-02T14:39:14.045-0800: 12.656: [GC [PSYoungGen: 14097K->4064K(30208K)] 64766K->64536K(98816K), 0.0117690 secs] [Times: user=0.02 sys=0.01, real=0.01 secs] 2018-08-02T14:39:14.057-0800: 12.668: [Full GC [PSYoungGen: 4064K->0K(30208K)] [ParOldGen: 60471K->401K(68608K)] 64536K->401K(98816K) [PSPermGen: 2634K->2634K(21504K)], 0.0102020 secs] [Times: user=0.01 sys=0.00, real=0.01 secs]
上面一共是4条GC日志,来看第一行日志,“2018-08-02T14:39:11.560-0800”是精确到了毫秒级别的UTC 通用标准时间格式,配置了“-XX:+PrintGCDateStamps”这个参数可以跟随gc日志打印出这种时间戳,“10.171”是从JVM启动到发生gc经过的秒数。第一行日志正文开头的“[GC”说明这次GC没有发生Stop-The-World(用户线程停顿),第二行日志正文开头的“[Full GC”说明这次GC发生了Stop-The-World,所以说,[GC和[Full GC跟新生代和老年代没关系,和垃圾收集器的类型有关系,如果直接调用System.gc(),将显示[Full GC(System)。接下来的“[PSYoungGen”、“[ParOldGen”表示GC发生的区域,具体显示什么名字也跟垃圾收集器有关,比如这里的“[PSYoungGen”表示Parallel Scavenge收集器,“[ParOldGen”表示Serial Old收集器,此外,Serial收集器显示“[DefNew”,ParNew收集器显示“[ParNew”等。再往后的“30128K->4091K(30208K)”表示进行了这次gc后,该区域的内存使用空间由30128K减小到4091K,总内存大小为30208K。每个区域gc描述后面的“51092K->50790K(98816K), 0.0140970 secs”进行了这次垃圾收集后,整个堆内存的内存使用空间由51092K减小到50790K,整个堆内存总空间为98816K,gc耗时0.0140970秒。
④ 线程快照:顾名思义,根据线程快照可以看到线程在某一时刻的状态,当系统中可能存在请求超时、死循环、死锁等情况是,可以根据线程快照来进一步确定问题。通过执行虚拟机自带的“jstack pid”命令,可以dump出当前进程中线程的快照信息,更详细的使用和分析网上有很多例,这篇文章写到这里已经很长了就不过多叙述了,贴一篇博客供参考:http://www.cnblogs.com/kongzhongqijing/articles/3630264.html
⑤ 堆转储快照:程序启动时可以使用 “-XX:+HeapDumpOnOutOfMemory” 和 “-XX:HeapDumpPath=/data/jvm/dumpfile.hprof”,当程序发生内存溢出时,把当时的内存快照以文件形式进行转储(也可以直接用jmap命令转储程序运行时任意时刻的内存快照),事后对当时的内存使用情况进行分析。
(2)JVM调优工具
① 用 jps(JVM process Status)可以查看虚拟机启动的所有进程、执行主类的全名、JVM启动参数,比如当执行了JPSTest类中的main方法后(main方法持续执行),执行 jps -l可看到下面的JPSTest类的pid为31354,加上-v参数还可以看到JVM启动参数。
3265 32914 sun.tools.jps.Jps 31353 org.jetbrains.jps.cmdline.Launcher 31354 com.danny.test.code.jvm.JPSTest 380
② 用jstat(JVM Statistics Monitoring Tool)监视虚拟机信息
jstat -gc pid 500 10 :每500毫秒打印一次Java堆状况(各个区的容量、使用容量、gc时间等信息),打印10次
S0C S1C S0U S1U EC EU OC OU MC MU CCSC CCSU YGC YGCT FGC FGCT GCT 11264.0 11264.0 11202.7 0.0 11776.0 1154.3 68608.0 36238.7 - - - - 14 0.077 7 0.049 0.126 11264.0 11264.0 11202.7 0.0 11776.0 4037.0 68608.0 36238.7 - - - - 14 0.077 7 0.049 0.126 11264.0 11264.0 11202.7 0.0 11776.0 6604.5 68608.0 36238.7 - - - - 14 0.077 7 0.049 0.126 11264.0 11264.0 11202.7 0.0 11776.0 9487.2 68608.0 36238.7 - - - - 14 0.077 7 0.049 0.126 11264.0 11264.0 0.0 0.0 11776.0 258.1 68608.0 58983.4 - - - - 15 0.082 8 0.059 0.141 11264.0 11264.0 0.0 0.0 11776.0 3076.8 68608.0 58983.4 - - - - 15 0.082 8 0.059 0.141 11264.0 11264.0 0.0 0.0 11776.0 0.0 68608.0 390.0 - - - - 16 0.084 9 0.066 0.149 11264.0 11264.0 0.0 0.0 11776.0 0.0 68608.0 390.0 - - - - 16 0.084 9 0.066 0.149 11264.0 11264.0 0.0 0.0 11776.0 258.1 68608.0 390.0 - - - - 16 0.084 9 0.066 0.149 11264.0 11264.0 0.0 0.0 11776.0 3012.8 68608.0 390.0 - - - - 16 0.084 9 0.066 0.149
S0C:第一个幸存区的大小
S1C:第二个幸存区的大小
S0U:第一个幸存区的使用大小
S1U:第二个幸存区的使用大小
EC:伊甸园区的大小
EU:伊甸园区的使用大小
OC:老年代大小
OU:老年代使用大小
MC:方法区大小
MU:方法区使用大小
CCSC:压缩类空间大小
CCSU:压缩类空间使用大小
YGC:年轻代垃圾回收次数
YGCT:年轻代垃圾回收消耗时间
FGC:老年代垃圾回收次数
FGCT:老年代垃圾回收消耗时间
GCT:垃圾回收消耗总时间
jstat还可以以其他角度监视各区内存大小、监视类装载信息等,具体可以google jstat的详细用法。
jstat命令使用 https://www.cnblogs.com/lizhonghua34/p/7307139.html
③ 用jmap(Memory Map for Java)查看堆内存信息
执行jmap -histo pid可以打印出当前堆中所有每个类的实例数量和内存占用,如下,class name是每个类的类名([B是byte类型,[C是char类型,[I是int类型),bytes是这个类的所有示例占用内存大小,instances是这个类的实例数量:
num #instances #bytes class name ---------------------------------------------- 1: 2291 29274080 [B 2: 15252 1961040 <methodKlass> 3: 15252 1871400 <constMethodKlass> 4: 18038 721520 java.util.TreeMap$Entry 5: 6182 530088 [C 6: 11391 273384 java.lang.Long 7: 5576 267648 java.util.TreeMap 8: 50 155872 [I 9: 6124 146976 java.lang.String 10: 3330 133200 java.util.LinkedHashMap$Entry 11: 5544 133056 javax.management.openmbean.CompositeDataSupport
执行 jmap -dump 可以转储堆内存快照到指定文件,比如执行 jmap -dump:format=b,file=/data/jvm/dumpfile_jmap.hprof 3361 可以把当前堆内存的快照转储到dumpfile_jmap.hprof文件中,然后可以对内存快照进行分析。
④ 利用jconsole、jvisualvm分析内存信息(各个区如Eden、Survivor、Old等内存变化情况),如果查看的是远程服务器的JVM,程序启动需要加上如下参数:
"-Dcom.sun.management.jmxremote=true" "-Djava.rmi.server.hostname=12.34.56.78" "-Dcom.sun.management.jmxremote.port=18181" "-Dcom.sun.management.jmxremote.authenticate=false" "-Dcom.sun.management.jmxremote.ssl=false"
下图是jconsole界面,概览选项可以观测堆内存使用量、线程数、类加载数和CPU占用率;内存选项可以查看堆中各个区域的内存使用量和左下角的详细描述(内存大小、GC情况等);线程选项可以查看当前JVM加载的线程,查看每个线程的堆栈信息,还可以检测死锁;VM概要描述了虚拟机的各种详细参数。(jconsole功能演示)
下图是jvisualvm的界面,功能比jconsole略丰富一些,不过大部分功能都需要安装插件。概述跟jconsole的VM概要差不多,描述的是jvm的详细参数和程序启动参数;监视展示的和jconsole的概览界面差不多(CPU、堆/方法区、类加载、线程);线程和jconsole的线程界面差不多;抽样器可以展示当前占用内存的类的排行榜及其实例的个数;Visual GC可以更丰富地展示当前各个区域的内存占用大小及历史信息(下图)。(jvisualvm功能演示)
⑤ 分析堆转储快照
前面说到配置了 “-XX:+HeapDumpOnOutOfMemory” 参数可以在程序发生内存溢出时dump出当前的内存快照,也可以用jmap命令随时dump出当时内存状态的快照信息,dump的内存快照一般是以.hprof为后缀的二进制格式文件。
可以直接用 jhat(JVM Heap Analysis Tool) 命令来分析内存快照,它的本质实际上内嵌了一个微型的服务器,可以通过浏览器来分析对应的内存快照,比如执行 jhat -port 9810 -J-Xmx4G /data/jvm/dumpfile_jmap.hprof 表示以9810端口启动 jhat 内嵌的服务器:
Reading from /Users/dannyhoo/data/jvm/dumpfile_jmap.hprof... Dump file created Fri Aug 03 15:48:27 CST 2018 Snapshot read, resolving... Resolving 276472 objects... Chasing references, expect 55 dots....................................................... Eliminating duplicate references....................................................... Snapshot resolved. Started HTTP server on port 9810 Server is ready.
在控制台可以看到服务器启动了,访问 http://127.0.0.1:9810/ 可以看到对快照中的每个类进行分析的结果(界面略low),下图是我随便选择了一个类的信息,有这个类的父类,加载这个类的类加载器和占用的空间大小,下面还有这个类的每个实例(References)及其内存地址和大小,点进去会显示这个实例的一些成员变量等信息:
jvisualvm也可以分析内存快照,在jvisualvm菜单的“文件”-“装入”,选择堆内存快照,快照中的信息就以图形界面展示出来了,如下,主要可以查看每个类占用的空间、实例的数量和实例的详情等:
还有很多分析内存快照的第三方工具,比如eclipse mat,它比jvisualvm功能更专业,出了查看每个类及对应实例占用的空间、数量,还可以查询对象之间的调用链,可以查看某个实例到GC Root之间的链,等等。可以在eclipse中安装mat插件,也可以下载独立的版本(http://www.eclipse.org/mat/downloads.php ),我在mac上安装后运行起来老卡死~下面是在windows上的截图(MAT功能演示):
(3)JVM调优经验
垃圾收集器方面,Serial可以直接排除掉,现在最普通的服务器也有双核64位\8G内存,默认的收集器是PS Scavenge和PS MarkSweep。所以在并行(Parallel)和并发(Concurrent)两者之间选择。如果系统对峰值处理要求不高,而对一两秒的停顿可以接受,则使用(-XX:+UseParallelGC);如果应用对响应有更高的要求,停顿最好小于一秒,则使用(-XX:+UseConcMarkSweepGC)。
JVM配置方面,一般情况可以先用默认配置(基本的一些初始参数可以保证一般的应用跑的比较稳定了),在测试中根据系统运行状况(会话并发情况、会话时间等),结合gc日志、内存监控、使用的垃圾收集器等进行合理的调整,当老年代内存过小时可能引起频繁Full GC,当内存过大时Full GC时间会特别长。
那么JVM的配置比如新生代、老年代应该配置多大最合适呢?答案是不一定,调优就是找答案的过程,物理内存一定的情况下,新生代设置越大,老年代就越小,Full GC频率就越高,但Full GC时间越短;相反新生代设置越小,老年代就越大,Full GC频率就越低,但每次Full GC消耗的时间越大。建议如下:
-Xms和-Xmx的值设置成相等,堆大小默认为-Xms指定的大小,默认空闲堆内存小于40%时,JVM会扩大堆到-Xmx指定的大小;空闲堆内存大于70%时,JVM会减小堆到-Xms指定的大小。如果在Full GC后满足不了内存需求会动态调整,这个阶段比较耗费资源。
新生代尽量设置大一些,让对象在新生代多存活一段时间,每次Minor GC 都要尽可能多的收集垃圾对象,防止或延迟对象进入老年代的机会,以减少应用程序发生Full GC的频率。
老年代如果使用CMS收集器,新生代可以不用太大,因为CMS的并行收集速度也很快,收集过程比较耗时的并发标记和并发清除阶段都可以与用户线程并发执行。
方法区大小的设置,1.6之前的需要考虑系统运行时动态增加的常量、静态变量等,1.7只要差不多能装下启动时和后期动态加载的类信息就行。
代码实现方面,性能出现问题比如程序等待、内存泄漏除了JVM配置可能存在问题,代码实现上也有很大关系:
避免创建过大的对象及数组:过大的对象或数组在新生代没有足够空间容纳时会直接进入老年代,如果是短命的大对象,会提前出发Full GC。
避免同时加载大量数据,如一次从数据库中取出大量数据,或者一次从Excel中读取大量记录,可以分批读取,用完尽快清空引用。
当集合中有对象的引用,这些对象使用完之后要尽快把集合中的引用清空,这些无用对象尽快回收避免进入老年代。
可以在合适的场景(如实现缓存)采用软引用、弱引用,比如用软引用来为ObjectA分配实例:SoftReference objectA=new SoftReference(); 在发生内存溢出前,会将objectA列入回收范围进行二次回收,如果这次回收还没有足够内存,才会抛出内存溢出的异常。
避免产生死循环,产生死循环后,循环体内可能重复产生大量实例,导致内存空间被迅速占满。
尽量避免长时间等待外部资源(数据库、网络、设备资源等)的情况,缩小对象的生命周期,避免进入老年代,如果不能及时返回结果可以适当采用异步处理的方式等。
(4)JVM问题排查记录案例
JVM服务问题排查 https://blog.csdn.net/jacin1/article/details/44837595
次让人难以忘怀的排查频繁Full GC过程 http://caogen81.iteye.com/blog/1513345
线上FullGC频繁的排查 https://blog.csdn.net/wilsonpeng3/article/details/70064336/
【JVM】线上应用故障排查 https://www.cnblogs.com/Dhouse/p/7839810.html
一次JVM中FullGC问题排查过程 http://iamzhongyong.iteye.com/blog/1830265
JVM内存溢出导致的CPU过高问题排查案例 https://blog.csdn.net/nielinqi520/article/details/78455614
一个java内存泄漏的排查案例 https://blog.csdn.net/aasgis6u/article/details/54928744
Java内存泄露的问题调查定位 https://www.cnblogs.com/2714585551summer/p/5745748.html
(5)常用JVM参数参考:
五、类加载
编写的Java代码需要经过编译器编译为class文件(从本地机器码转变为字节码的过程),class文件是一组以8位字节为基础的二进制流,这些二进制流分别以一定形式表示着魔数(用于标识是否是一个能被虚拟机接收的Class文件)、版本号、字段表、访问标识等内容。代码编译为class文件后,需要通过类加载器把class文件加载到虚拟机中才能运行和使用。
####1、类加载步骤
类从被加载到内存到使用完成被卸载出内存,需要经历加载、连接、初始化、使用、卸载这几个过程,其中连接又可以细分为验证、准备、解析。
(1)加载
在加载阶段,虚拟机主要完成三件事情:
① 通过一个类的全限定名(比如com.danny.framework.MyClassLoader)来获取定义该类的二进制流;
② 将这个字节流所代表的静态存储结构转化为方法区的运行时存储结构;
③ 在内存中生成一个代表这个类的java.lang.Class对象,作为程序访问方法区中这个类的外部接口。
(2)验证
验证的目的是为了确保class文件的字节流包含的内容符合虚拟机的要求,且不会危害虚拟机的安全。
文件格式验证:主要验证class文件中二进制字节流的格式,比如魔数是否已0xCAFEBABY开头、版本号是否正确等。
元数据验证:主要对字节码描述的信息进行语义分析,保证其符合Java语言规范,比如验证这个类是否有父类(java.lang.Object除外),如果这个类不是抽象类,是否实现了父类或接口中没有实现的方法,等等。
字节码验证:字节码验证更为高级,通过数据流和控制流分析,确保程序是合法的、符合逻辑的。
符号引用验证:对类自身以外的信息进行匹配性校验,举个栗子,比如通过类的全限定名能否找到对应类、在类中能否找到字段名/方法名对应的字段/方法,如果符号引用验证失败,将抛出“java.lang.NoSuchFieldError”、“java.lang.NoSuchMethodError”等异常。
(3)准备
正式为【类变量】分配内存并设置类变量【初始值】,这些变量所使用的内存都分配在方法区。注意分配内存的对象是“类变量”而不是实例变量,而且为其分配的是“初始值”,一般数值类型的初始值都为0,char类型的初始值为’\u0000’(常量池中一个表示Nul的字符串),boolean类型初始值为false,引用类型初始值为null。
但是加上final关键字比如public static final int value=123;在准备阶段会初始化value的值为123;
(4)解析
解析是将常量池中【符号引用】替换为【直接引用】的过程。
符号引用是以一组符号来描述所引用的目标,符号引用与虚拟机实现的内存布局无关,引用的目标不一定已经加载到内存中。比如在com.danny.framework.LoggerFactory类引用了com.danny.framework.Logger,但在编译期间是不知道Logger类的内存地址的,所以只能先用com.danny.framework.Logger(假设是这个,实际上是由类似于CONSTANT_Class_info的常量来表示的)来表示Logger类的地址,这就是符号引用。
直接引用可以是直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄。直接引用和虚拟机实现的内存布局有关,如果有了直接引用,那引用的目标一定在内存中存在。
解析的时候class已经被加载到方法区的内存中,因此要把符号引用转化为直接引用,也就是能直接找到该类实际内存地址的引用。
(5)初始化
在准备阶段,已经为类变量赋了初始值,在初始化阶段,则根据程序员通过程序定制的主观计划去初始化类变量的和其他资源,也可以从另一个角度来理解:初始化阶段是执行类构造器()方法的过程,那()到底是什么呢?
我的理解是,java在生成字节码时,如果类中有静态代码块或静态变量的赋值操作,会将类构造器()方法和实例构造器 () 方法添加到语法树中(可以理解为在编译阶段自动为类添加了两个隐藏的方法:类构造器——()方法和实例构造器——()方法,可以用javap命令查看),()主要用来构造类,比如初始化类变量(静态变量),执行静态代码块(statis{})等,该方法只执行一次;()方法主要用来构造实例,在构造实例的过程中,会首先执行(),这时对象中的所有成员变量都会被设置为默认值(每种数据类型的默认值和类加载准备阶段描述的一样),然后才会执行实例的构造函数(会先执行父类的构造方法,再执行非静态代码块,最后执行构造函数)。
下面看段代码来理解下:
public class Parent { static { System.out.println("Parent-静态代码块执行"); } public Parent() { System.out.println("Parent-构造方法执行"); } { System.out.println("Parent-非静态代码块执行"); } } public class Child extends Parent{ private static int staticValue = 123; private int noStaticValue=456; static { System.out.println("Child-静态代码块执行"); } public Child() { System.out.println("Child-构造方法执行"); } { System.out.println("Child-非静态代码块执行"); } public static void main(String[] args) { Child child = new Child(); } }
看下面的运行结果之前可以先猜测一下结果是什么,运行结果如下:
Parent-静态代码块执行 Child-静态代码块执行 Parent-非静态代码块执行 Parent-构造方法执行 Child-非静态代码块执行 Child-构造方法执行
上面的例子中可以看到一个类从加载到实例化的过程中,静态代码块、构造方法、非静态代码块的加载顺序。无法看到静态变量和非静态变量初始化的时间,静态变量的初始化和静态代码块的执行都是在类的初始化阶段(())完成,非静态变量和非静态代码块都是在实例的初始化阶段(())完成。
2、类加载器
(1)类加载器的作用
加载class:类加载的加载阶段的第一个步骤,就是通过类加载器来完成的,类加载器的主要任务就是“通过一个类的全限定名来获取描述此类的二进制字节流”,在这里,类加载器加载的二进制流并不一定要从class文件中获取,还可以从其他格式如zip文件中读取、从网络或数据库中读取、运行时动态生成、由其他文件生成(比如jsp生成class类文件)等。
从程序员的角度来看,类加载器动态加载class文件到虚拟机中,并生成一个java.lang.Class实例,每个实例都代表一个java类,可以根据该实例得到该类的信息,还可以通过newInstance()方法生成该类的一个对象。
确定类的唯一性:类加载器除了有加载类的作用,还有一个举足轻重的作用,对于每一个类,都需要由加载它的加载器和这个类本身共同确立这个类在Java虚拟机中的唯一性。也就是说,两个相同的类,只有是在同一个加载器加载的情况下才“相等”,这里的“相等”是指代表类的Class对象的equals()方法、isAssignableFrom()方法、isInstance()方法的返回结果,也包括instanceof关键字对对象所属关系的判定结果。
(2)类加载器的分类
以开发人员的角度来看,类加载器分为如下几种:启动类加载器(Bootstrap ClassLoader)、扩展类加载器(Extension ClassLoader)、应用程序类加载器(Application ClassLoader)和自定义类加载器(User ClassLoader),其中启动类加载器属于JVM的一部分,其他类加载器都用java实现,并且最终都继承自java.lang.ClassLoader。
① 启动类加载器(Bootstrap ClassLoader)是由C/C++编译而来的,看不到源码,所以在java.lang.ClassLoader源码中看到的Bootstrap ClassLoader的定义是native的“private native Class findBootstrapClass(String name);”。启动类加载器主要负责加载JAVA_HOME\lib目录或者被-Xbootclasspath参数指定目录中的部分类,具体加载哪些类可以通过“System.getProperty(“sun.boot.class.path”)”来查看。
② 扩展类加载器(Extension ClassLoader)由sun.misc.Launcher.ExtClassLoader实现,负责加载JAVA_HOME\lib\ext目录或者被java.ext.dirs系统变量指定的路径中的所有类库,可以用通过“System.getProperty(“java.ext.dirs”)”来查看具体都加载哪些类。
③ 应用程序类加载器(Application ClassLoader)由sun.misc.Launcher.AppClassLoader实现,负责加载用户类路径(我们通常指定的classpath)上的类,如果程序中没有自定义类加载器,应用程序类加载器就是程序默认的类加载器。
④ 自定义类加载器(User ClassLoader),JVM提供的类加载器只能加载指定目录的类(jar和class),如果我们想从其他地方甚至网络上获取class文件,就需要自定义类加载器来实现,自定义类加载器主要都是通过继承ClassLoader或者它的子类来实现,但无论是通过继承ClassLoader还是它的子类,最终自定义类加载器的父加载器都是应用程序类加载器,因为不管调用哪个父类加载器,创建的对象都必须最终调用java.lang.ClassLoader.getSystemClassLoader()作为父加载器,getSystemClassLoader()方法的返回值是sun.misc.Launcher.AppClassLoader即应用程序类加载器。
(3)ClassLoader与双亲委派模型
ClassLoader主要负责的是类加载过程中的第一个步骤——加载,下面看一下类加载器java.lang.ClassLoader中的核心逻辑loadClass()方法:
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException { synchronized (getClassLoadingLock(name)) { // 检查该类是否已经加载过 Class c = findLoadedClass(name); if (c == null) { long t0 = System.nanoTime(); try { if (parent != null) {//如果父加载器不为空,就用父加载器加载类 c = parent.loadClass(name, false); } else {//如果父加载器为空,就用启动类加载器加载类 c = findBootstrapClassOrNull(name); } } catch (ClassNotFoundException e) { } if (c == null) {//如果上面用父加载器还没加载到类,就自己尝试加载 long t1 = System.nanoTime(); c = findClass(name); sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0); sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1); sun.misc.PerfCounter.getFindClasses().increment(); } } if (resolve) { resolveClass(c); } return c; } }
这段代码的主要意思就是当一个类加载器加载类的时候,如果有父加载器就先尝试让父加载器加载,如果父加载器还有父加载器就一直往上抛,一直把类加载的任务交给启动类加载器,然后启动类加载器如果加载不到类就会抛出ClassNotFoundException异常,之后把类加载的任务往下抛,如下图:
通过上图的类加载过程,就引出了一个比较重要的概念——双亲委派模型,如下图展示的层次关系,双亲委派模型要求除了顶层的启动类加载器之外,其他的类加载器都应该有一个父类加载器,但是这种父子关系并不是继承关系,而是像上面代码所示的组合关系。
双亲委派模型的工作过程是,如果一个类加载器收到了类加载的请求,它首先不会加载类,而是把这个请求委派给它上一层的父加载器,每层都如此,所以最终请求会传到启动类加载器,然后从启动类加载器开始尝试加载类,如果加载不到(要加载的类不在当前类加载器的加载范围),就让它的子类尝试加载,每层都是如此。
那么双亲委派模型有什么好处呢?最大的好处就是它让Java中的类跟类加载器一样有了“优先级”。前面说到了对于每一个类,都需要由加载它的加载器和这个类本身共同确立这个类在Java虚拟机中的唯一性,比如java.lang.Object类(存放在JAVA_HOME\lib\rt.jar中),如果用户自己写了一个java.lang.Object类并且由自定义类加载器加载,那么在程序中是不是就是两个类?所以双亲委派模型对保证Java稳定运行至关重要。