黑马程序员jvm笔记(五)--类加载部分心得

11.类加载阶段

第一阶段:加载
  • 将类的字节码载入

    方法区

    (1.8后为元空间,在本地内存中)中,内部采用 C++ 的 instanceKlass 描述 java 类,它的重要 field 有:

    • _java_mirror 即 java 的类镜像,例如对 String 来说,它的镜像类就是 String.class,作用是把 klass 暴露给 java 使用
    • _super 即父类
    • _fields 即成员变量
    • _methods 即方法
    • _constants 即常量池
    • _class_loader 即类加载器
    • _vtable 虚方法表
    • _itable 接口方法

QQ截图20220219174206

  • 如果这个类还有父类没有加载,先加载父类
  • 加载和链接可能是交替运行
  • instanceKlass保存在方法区。JDK 8以后,方法区位于元空间中,而元空间又位于本地内存中
  • _java_mirror则是保存在堆内存
  • InstanceKlass和*.class(JAVA镜像类)互相保存了对方的地址
  • 类的对象在对象头中保存了*.class的地址。让对象可以通过其找到方法区中的instanceKlass,从而获取类的各种信息
第二阶段:链接
验证

验证类是否符合 JVM规范,安全性检查,(对字节码魔数以及格式进行检查)

准备

为 static 变量分配空间,设置默认值

制备常量池但不赋值,但下面特殊情况下也会为static赋值

  • static变量在JDK 7以前是存储与instanceKlass末尾。但在JDK 7以后就存储在_java_mirror末尾了
  • static变量在分配空间和赋值是在两个阶段完成的。分配空间在准备阶段完成,赋值在初始化阶段完成
  • 如果 static 变量是 final 的基本类型,以及字符串常量,那么编译阶段值就确定了,赋值在准备阶段完成
  • 如果 static 变量是 final 的,但属于引用类型,那么赋值也会在初始化阶段完成
解析

简单理解就是:这个阶段会对常量池进行内存地址的匹配,此时才开始为常量分配空间,才真正存在于内存中。

测试

public class Demo1 {
   public static void main(String[] args) throws IOException, ClassNotFoundException {
      ClassLoader loader = Demo1.class.getClassLoader();
      //只加载不解析
      Class<?> c = loader.loadClass("com.nyima.JVM.day8.C");
      //用于阻塞主线程
      System.in.read();
   }
}

class C {
   D d = new D();
}

class D {

}

QQ截图20220219174846

对上面的程序,分别测试它解析后和没解析:

QQ截图20220219174958

第三阶段:初始化

QQ截图20220219175216

类的初始化的懒惰的,以下情况会初始化

  • main 方法所在的类,总会被首先初始化
  • 首次访问这个类的静态变量或静态方法时
  • 子类初始化,如果父类还没初始化,会引发
  • 子类访问父类的静态变量,只会触发父类的初始化
  • Class.forName
  • new 会导致初始化

以下情况不会初始化

  • 访问类的 static final 静态常量(基本类型和字符串)
  • 类对象.class 不会触发初始化
  • 创建该类对象的数组
  • 类加载器的.loadClass方法
  • Class.forNamed的参数2为false时

对上述准则的验证(注释下逐个验证)

public class Load3 {
    static {
        //在main类前面的静态代码块会优先初始化
        System.out.println("main init");
    }
public static void main(String[] args) throws ClassNotFoundException {
    // 1. 静态常量(基本类型和字符串)不会触发初始化 
    System.out.println(B.b); 
    // 2. 类对象.class 不会触发初始化 
    System.out.println(B.class);
    // 3. 创建该类的数组不会触发初始化 
    System.out.println(new B[0]);
    // 4. 不会初始化类 B,但会加载 B、A 
    ClassLoader cl = Thread.currentThread().getContextClassLoader();
    cl.loadClass("cn.itcast.jvm.t3.B"); 
    // 5. 不会初始化类 B,但会加载 B、A 
    ClassLoader c2 = Thread.currentThread().getContextClassLoader();
    Class.forName("cn.itcast.jvm.t3.B", false, c2); 
    
    // 1. 首次访问这个类的静态变量或静态方法时 
    System.out.println(A.a); 
    // 2. 子类初始化,如果父类还没初始化,会引发父类的初始化
    System.out.println(B.c); 
    // 3. 子类访问父类静态变量,只触发父类初始化 
    System.out.println(B.a); 
    // 4. 会初始化类 B,并先初始化类 A 
    Class.forName("cn.itcast.jvm.t3.B");
}
  }
class A { 
    static int a = 0;
    static {
        System.out.println("a init"); 
           }
         }

class B extends A {
    final static double b = 5.0; 
    static boolean c = false; 
    static {
        System.out.println("b init");
           } 
       }

利用初始化的例子–懒惰单例

QQ截图20220219153629

public final class Singleton { 
    private Singleton() { } 
    // 内部类中保存单例 
    private static class LazyHolder { static final Singleton INSTANCE = new Singleton(); }
    // 第一次调用 getInstance 方法,才会导致内部类加载和初始化其静态成员 
    public static Singleton getInstance() { return LazyHolder.INSTANCE; } 
}

练习:判断下列是否能初始化

public class Load4 {
    public static void main(String[] args) { 
        System.out.println(E.a);//不能初始化,final在准备阶段就已经赋值了
        System.out.println(E.b);//不能初始化,final在准备阶段就已经赋值了
        System.out.println(E.c); //能够初始化,因为封装和拆箱要涉及到初始化
    } }
class E {
    public static final int a = 10;         
    public static final String b = "hello";
    public static final Integer c = 20;
}
类加载器

Java虚拟机设计团队有意把类加载阶段中的**“通过一个类的全限定名来获取描述该类的二进制字节流”这个动作放到Java虚拟机外部去实现,以便让应用程序自己决定如何去获取所需的类。实现这个动作的代码被称为“类加载器”**(ClassLoader)

类与类加载器

类加载器虽然只用于实现类的加载动作,但它在Java程序中起到的作用却远超类加载阶段

对于任意一个类,都必须由加载它的类加载器和这个类本身一起共同确立其在Java虚拟机中的唯一性,每一个类加载器,都拥有一个独立的类名称空间。这句话可以表达得更通俗一些:比较两个类是否“相等”,只有在这两个类是由同一个类加载器加载的前提下才有意义,否则,即使这两个类来源于同一个Class文件,被同一个Java虚拟机加载,只要加载它们的类加载器不同,那这两个类就必定不相等

以JDK 8为例

名称加载的类说明
Bootstrap ClassLoader(启动类加载器)JAVA_HOME/jre/lib无法直接访问
Extension ClassLoader(拓展类加载器)JAVA_HOME/jre/lib/ext上级为Bootstrap,显示为null
Application ClassLoader(应用程序类加载器)classpath上级为Extension
自定义类加载器自定义上级为Application

启动类加载器

可通过在控制台输入指令,使得类被启动类加器加载

QQ截图20220219193628

要注意,因为bootstrap加载器加载时部分需要使用c++的代码帮忙运行,而没办法把类加载器名称及地址正确返回,返回null就代表了时启动类加载器。

拓展类加载器

如果classpath和JAVA_HOME/jre/lib/ext 下有同名类,加载时会使用拓展类加载器加载。当应用程序类加载器发现拓展类加载器已将该同名类加载过了,则不会再次加载

QQ截图20220219193930

双亲委派机制

双亲委派机制,指的是类加载时会向上级加载器报备,优先上级加载器加载的一种模式。

我们更加详细的来查看下:

public class Load5_3 {
    public static void main(String[] args) throws ClassNotFoundException { 
        Class<?> aClass = Load5_3.class.getClassLoader() .loadClass("cn.itcast.jvm.t3.load.H");  System.out.println(aClass.getClassLoader());           
    } }

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 {
                //看是否被它的上级加载器加载过了 Extension的上级是Bootstarp,但它显示为null
                if (parent != null) {
                    c = parent.loadClass(name, false);
                } 
                //加载到bootstrap加载器时,执行else部分
                else {
                    //看是否被启动类加载器加载过
                    c = findBootstrapClassOrNull(name);
                }
            } catch (ClassNotFoundException e) {
                // ClassNotFoundException thrown if class not found
                // from the non-null parent class loader
                //捕获异常,但不做任何处理
            }

            if (c == null) {
                //如果还是没有找到,先让拓展类加载器调用findClass方法去找到该类,如果还是没找到,就抛出异常,诸暨下找
                //然后让应用类加载器去找classpath下找该类
                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;
    }
}

QQ截图20220220164617

不使用双亲委派机制的特例–线程上下文类加载器

我们在使用 JDBC 时,都需要加载 Driver 驱动,不知道你注意到没有,不写loadClass

QQ截图20220220164834

QQ截图20220220164946

自定义类加载器
使用场景
  • 想加载非 classpath 随意路径中的类文件
  • 通过接口来使用实现,希望解耦时,常用在框架设计
  • 这些类希望予以隔离,不同应用的同名类都可以加载,不冲突,常见于 tomcat 容器
步骤
  • 继承ClassLoader父类
  • 要遵从双亲委派机制,重写 findClass 方法
    • 不是重写loadClass方法,否则不会走双亲委派机制
  • 读取类文件的字节码
  • 调用父类的 defineClass 方法来加载类
  • 使用者调用该类加载器的 loadClass 方法
例子

代码:

QQ截图20220220165301

测试:

QQ截图20220220165319

运行期优化
分层编译

JVM 将执行状态分成了 5 个层次:

  • 0层:解释执行,用解释器将字节码翻译为机器码
  • 1层:使用 C1 即时编译器编译执行(不带 profiling)
  • 2层:使用 C1 即时编译器编译执行(带基本的profiling)
  • 3层:使用 C1 即时编译器编译执行(带完全的profiling)
  • 4层:使用 C2 即时编译器编译执行

profiling 是指在运行过程中收集一些程序执行状态的数据,例如【方法的调用次数】,【循环的 回边次数】等

即时编译器(JIT)《just in time》与解释器的区别
  • 解释器
    • 将字节码解释为机器码,下次即使遇到相同的字节码,仍会执行重复的解释
    • 是将字节码解释为针对所有平台都通用的机器码
  • 即时编译器
    • 将一些字节码编译为机器码,并存入 Code Cache,下次遇到相同的代码,直接执行,无需再编译
    • 根据平台类型,生成平台特定的机器码

对于大部分的不常用的代码,我们无需耗费时间将其编译成机器码,而是采取解释执行的方式运行;另一方面,对于仅占据小部分的热点代码,我们则可以将其编译成机器码,以达到理想的运行速度。 执行效率上简单比较一下 Interpreter < C1 < C2,总的目标是发现热点代码(hotspot名称的由 来),并优化这些热点代码

简单来说:就是执行次数多的代码保留机械码,多使用

例子1 :逃逸

public class JIT1 { 
    public static void main(String[] args) { 
        for (int i = 0; i < 200; i++) { 
            long start = System.nanoTime(); 
            for (int j = 0; j < 1000; j++) { 
                new Object(); 
            }
            long end = System.nanoTime(); 
            System.out.printf("%d\t%d\n",i,(end - start));
        } 
    }
}

上面例子创建了200次1000个object对象,因为这些对象没有外界引用,所以运行到一定次数,jit会进行优化,直接不创建对象,提高效率。

结果:

QQ截图20220220170039

逃逸分析

逃逸分析(Escape Analysis)简单来讲就是,Java Hotspot 虚拟机可以分析新创建对象的使用范围,并决定是否在 Java 堆上分配内存的一项技术

逃逸分析的 JVM 参数如下:

  • 开启逃逸分析:-XX:+DoEscapeAnalysis
  • 关闭逃逸分析:-XX:-DoEscapeAnalysis
  • 显示分析结果:-XX:+PrintEscapeAnalysis

逃逸分析技术在 Java SE 6u23+ 开始支持,并默认设置为启用状态,可以不用额外加这个参数

对象逃逸状态

全局逃逸(GlobalEscape)

  • 即一个对象的作用范围逃出了当前方法或者当前线程,有以下几种场景:
    • 对象是一个静态变量
    • 对象是一个已经发生逃逸的对象
    • 对象作为当前方法的返回值

参数逃逸(ArgEscape)

  • 即一个对象被作为方法参数传递或者被参数引用,但在调用过程中不会发生全局逃逸,这个状态是通过被调方法的字节码确定的

没有逃逸

  • 即方法中的对象没有发生逃逸

逃逸分析优化

针对上面第三点,当一个对象没有逃逸时,可以得到以下几个虚拟机的优化

逃逸分析(Escape Analysis)简单来讲就是,Java Hotspot 虚拟机可以分析新创建对象的使用范围,并决定是否在 Java 堆上分配内存的一项技术

逃逸分析的 JVM 参数如下:

  • 开启逃逸分析:-XX:+DoEscapeAnalysis
  • 关闭逃逸分析:-XX:-DoEscapeAnalysis
  • 显示分析结果:-XX:+PrintEscapeAnalysis

逃逸分析技术在 Java SE 6u23+ 开始支持,并默认设置为启用状态,可以不用额外加这个参数

对象逃逸状态

全局逃逸(GlobalEscape)

  • 即一个对象的作用范围逃出了当前方法或者当前线程,有以下几种场景:
    • 对象是一个静态变量
    • 对象是一个已经发生逃逸的对象
    • 对象作为当前方法的返回值

参数逃逸(ArgEscape)

  • 即一个对象被作为方法参数传递或者被参数引用,但在调用过程中不会发生全局逃逸,这个状态是通过被调方法的字节码确定的

没有逃逸

  • 即方法中的对象没有发生逃逸

逃逸分析优化

针对上面第三点,当一个对象没有逃逸时,可以得到以下几个虚拟机的优化

锁消除

我们知道线程同步锁是非常牺牲性能的,当编译器确定当前对象只有当前线程使用,那么就会移除该对象的同步锁

例如,StringBuffer 和 Vector 都是用 synchronized 修饰线程安全的,但大部分情况下,它们都只是在当前线程中用到,这样编译器就会优化移除掉这些锁操作

锁消除的 JVM 参数如下:

  • 开启锁消除:-XX:+EliminateLocks
  • 关闭锁消除:-XX:-EliminateLocks

锁消除在 JDK8 中都是默认开启的,并且锁消除都要建立在逃逸分析的基础上

标量替换

首先要明白标量和聚合量,基础类型对象的引用可以理解为标量,它们不能被进一步分解。而能被进一步分解的量就是聚合量,比如:对象

对象是聚合量,它又可以被进一步分解成标量,将其成员变量分解为分散的变量,这就叫做标量替换

这样,如果一个对象没有发生逃逸,那压根就不用创建它,只会在栈或者寄存器上创建它用到的成员标量,节省了内存空间,也提升了应用程序性能

标量替换的 JVM 参数如下:

  • 开启标量替换:-XX:+EliminateAllocations
  • 关闭标量替换:-XX:-EliminateAllocations
  • 显示标量替换详情:-XX:+PrintEliminateAllocations

标量替换同样在 JDK8 中都是默认开启的,并且都要建立在逃逸分析的基础上

栈上分配

当对象没有发生逃逸时,该对象就可以通过标量替换分解成成员标量分配在栈内存中,和方法的生命周期一致,随着栈帧出栈时销毁,减少了 GC 压力,提高了应用程序性能

例子2:方法内联

QQ截图20220220170225

QQ截图20220220170548

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值