Java面试准备之JVM系列三

hresh 603 0

Java面试准备之JVM系列三

Java 类加载器总结

JVM 中内置了三个重要的 ClassLoader,除了 BootstrapClassLoader,其他类加载器均由 Java 实现且全部继承自java.lang.ClassLoader

  1. BootstrapClassLoader(启动类加载器) :最顶层的加载类,由C++实现,负责加载 %JAVA_HOME%/lib目录下的 jar 包和类或者或被 -Xbootclasspath参数指定的路径中的所有类。
  2. ExtensionClassLoader(扩展类加载器) :主要负责加载目录 %JRE_HOME%/lib/ext 目录下的 jar 包和类,或被 java.ext.dirs 系统变量所指定的路径下的jar包。
  3. SystemClassLoader(应用程序类加载器) :面向我们用户的加载器,负责加载当前应用 classpath 下的所有 jar 包和类。

双亲委派模型

概念介绍

Java面试准备之JVM系列三

如上图所示的类加载器之间的这种层次关系,被称为类加载器的双亲委派模型。双亲委派模型要求除了顶层的启动类加载器外,其余的类加载器都应当有自己的父类加载器。这里的类加载器之间的父子关系一般不会以继承的关系来实现,而是都使用组合关系来复用父加载器的代码。

每一个类都有一个对应它的类加载器。系统中的 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.LauncherAppClassLoader@18b4aac2
The Parent of ClassLodarDemo's ClassLoader is sun.misc.LauncherExtClassLoader@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 经过逃逸分析,发现有些对象没有逃逸出方法,那么有可能堆内存分配会被优化成栈内存分配。需要注意的是并不是所有的对象都被优化成栈内存分配空间,仍然存在对象在堆上分配内存空间。

发表评论 取消回复
表情 图片 链接 代码

分享