Java 类加载器总结
JVM 中内置了三个重要的 ClassLoader,除了 BootstrapClassLoader,其他类加载器均由 Java 实现且全部继承自java.lang.ClassLoader
:
- BootstrapClassLoader(启动类加载器) :最顶层的加载类,由C++实现,负责加载
%JAVA_HOME%/lib
目录下的 jar 包和类或者或被-Xbootclasspath
参数指定的路径中的所有类。 - ExtensionClassLoader(扩展类加载器) :主要负责加载目录
%JRE_HOME%/lib/ext
目录下的 jar 包和类,或被java.ext.dirs
系统变量所指定的路径下的jar包。 - SystemClassLoader(应用程序类加载器) :面向我们用户的加载器,负责加载当前应用 classpath 下的所有 jar 包和类。
双亲委派模型
概念介绍
如上图所示的类加载器之间的这种层次关系,被称为类加载器的双亲委派模型。双亲委派模型要求除了顶层的启动类加载器外,其余的类加载器都应当有自己的父类加载器。这里的类加载器之间的父子关系一般不会以继承的关系来实现,而是都使用组合关系来复用父加载器的代码。
每一个类都有一个对应它的类加载器。系统中的 ClassLoder 在协同工作的时候会默认使用 双亲委派模型 。即在类加载的时候,系统会首先判断当前类是否被加载过。已经被加载的类会直接返回,否则才会尝试加载。加载的时候,首先会把该请求委派该父类加载器的 loadClass()
处理,因此所有的请求最终都应该传送到顶层的启动类加载器 BootstrapClassLoader
中。当父类加载器无法处理时,才由自己来处理。当父类加载器为 null 时,会使用启动类加载器 BootstrapClassLoader
作为父类加载器。
每个类加载都有一个父类加载器,我们通过下面的程序来验证。
public class ClassLoaderDemo {
public static void main(String[] args) {
System.out.println("ClassLodarDemo's ClassLoader is " + ClassLoaderDemo.class.getClassLoader());
System.out.println("The Parent of ClassLodarDemo's ClassLoader is " + ClassLoaderDemo.class.getClassLoader().getParent());
System.out.println("The GrandParent of ClassLodarDemo's ClassLoader is " + ClassLoaderDemo.class.getClassLoader().getParent().getParent());
}
}
执行结果为:
ClassLodarDemo's ClassLoader is sun.misc.Launcher$AppClassLoader@18b4aac2
The Parent of ClassLodarDemo's ClassLoader is sun.misc.Launcher$ExtClassLoader@60e53b93
The GrandParent of ClassLodarDemo's ClassLoader is null
AppClassLoader
的父类加载器为ExtClassLoader
ExtClassLoader
的父类加载器为null,null并不代表ExtClassLoader
没有父类加载器,而是 BootstrapClassLoader
。
双亲委派模型有什么好处?
比如位于rt.jar包中的类 java.lang.Object,无论哪个加载器加载这个类,最终都是委托给顶层的启动类加载器进行加载,确保了 Object 类在各种加载器环境中都是同一个类。
如果我们不想用双亲委派模型怎么办?
为了避免双亲委托机制,我们可以自己定义一个类加载器, 继承 ClassLoader
,然后重写 loadClass()
即可。
聊一聊逃逸分析
前言
Java 语言编译与解释并存
当 .class 字节码文件通过 JVM 转为机器可以执行的二进制机器码时,JVM 类加载器首先加载字节码文件,然后通过解释器逐行进行解释执行,这种方式的执行速度相对比较慢。而且有些方法和代码块是反复被调用的(也就是所谓的热点代码),所以后面引进了 JIT 编译器,而 JIT 属于运行时编译。当 JIT 编译器完成一次编译后,会将字节码对应的机器码保存下来,下次可以直接调用。这也解释了我们为什么经常会说 Java 是编译与解释共存的语言。
JIT 编译器
即时编译(Just-in-time Compilation,JIT)是一种通过在运行时将字节码翻译为机器码,从而改善字节码编译语言性能的技术。在 HotSpot 实现中有多种选择:C1、C2和C1+C2,分别对应 client、server 和分层编译。
1、C1编译速度快,优化方式比较保守;
2、C2编译速度慢,优化方式比较激进;
3、C1+C2在开始阶段采用C1编译,当代码运行到一定热度之后采用C2重新编译;
在 JDK1.8之前,分层编译默认是关闭的,可以添加-server -XX:+TieredCompilation
参数进行开启。
在编译期间,JIT会对代码做很多优化。其中有一部分优化的目的就是减少内存堆分配压力,其中一种重要的技术叫做逃逸分析。
逃逸分析
逃逸分析并不是直接的优化手段,而是一个代码分析,通过动态分析对象的作用域,为其它优化手段如栈上分配、标量替换和同步消除等提供依据,发生逃逸行为的情况有两种:方法逃逸和线程逃逸。
1、方法逃逸:当一个对象在方法中定义之后,作为参数传递到其它方法中;
如下述代码所示:
public static StringBuffer craeteStringBuffer(String s1, String s2) {
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
return sb;
}
public static String createStringBuffer(String s1, String s2) {
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
return sb.toString();
}
第一段代码中的sb
就逃逸了,而第二段代码中的sb
就没有逃逸。
2、线程逃逸:如类变量或实例变量,可能被其它线程访问到;
如果不存在逃逸行为,则可以对该对象进行如下优化:同步消除、标量替换和栈上分配。
同步消除
线程同步本身比较耗费资源,如果确定一个对象不会逃逸出线程,无法被其它线程访问到,那该对象的读写就不会存在竞争,则可以消除对该对象的同步锁,通过-XX:+EliminateLocks
可以开启同步消除。 这个取消同步的过程就叫同步消除,也叫锁消除。
标量替换
1、标量是指不可分割的量,如 Java 中基本数据类型和 reference 类型,相对的如果一个数据可以继续分解,则称为聚合量;
2、如果把一个对象拆散,将其成员变量恢复到基本类型来访问就叫做标量替换;
3、如果逃逸分析发现一个对象不会被外部访问,并且该对象可以被拆散,那么经过优化之后,并不直接生成该对象,而是在栈上创建若干个成员变量;
通过-XX:+EliminateAllocations
可以开启标量替换, -XX:+PrintEliminateAllocations
查看标量替换情况。
栈上分配
故名思议就是在栈上分配对象,其实目前 Hotspot 并没有实现真正意义上的栈上分配,实际上是标量替换。
在一般情况下,对象和数组元素的内存分配是在堆内存上进行的。但是随着JIT编译器的日渐成熟,很多优化使这种分配策略并不绝对。JIT编译器就可以在编译期间根据逃逸分析的结果,来决定是否可以将对象的内存分配从堆转化为栈。
推荐阅读:浅谈HotSpot逃逸分析 和 深入理解Java中的逃逸分析
扩展阅读: JVM优化之逃逸分析及锁消除
面试题:Java中的对象都是在堆中分配吗?说明为什么!
不一定,随着 JIT 编译器的发展 ,在编译期间,如果 JIT 经过逃逸分析,发现有些对象没有逃逸出方法,那么有可能堆内存分配会被优化成栈内存分配。需要注意的是并不是所有的对象都被优化成栈内存分配空间,仍然存在对象在堆上分配内存空间。