JVM之类加载

本文详细介绍了JVM的类加载过程,包括加载、链接、验证、准备和初始化阶段,并探讨了类加载器的工作原理,如启动类加载器、扩展类加载器和双亲委派模式。此外,文章还讲解了运行期优化,如即时编译、分层编译和方法内联,以及逃逸分析和反射优化,旨在帮助读者深入理解JVM的内部运作和性能提升策略。

1. 类加载阶段

加载

  • 将类的字节码载入方法区(1.8后为元空间, 在本地内存中)中, 内部采用 C++ 的 instanceKlass 描述 java 类, 它的重要 field 有:
    • _java_mirror 即 java 的类镜像, 例如对 String 来说, 它的镜像类就是 String.class, 作用是把 klass 暴露给 java 使用
    • _super 即父类
    • _fields 即成员变量
    • _methods 即方法
    • _constants 即常量池
    • _class_loader 即类加载器
    • _vtable 虚方法表
    • _itable 接口方法
  • 如果这个类还有父类没有加载, 先加载父类
  • 加载和链接可能是交替运行的

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-8tJcbM0w-1655547496468)(C:\Users\86133\Pictures\JVM\类加载\加载.png)]

  • instanceKlass保存在方法区。JDK 8以后, 方法区位于元空间中, 而元空间又位于本地内存中
  • _java_mirror则是保存在堆内存中
  • InstanceKlass和*.class(JAVA镜像类)互相保存了对方的地址
  • 类的对象在对象头中保存了*.class的地址。让对象可以通过其找到方法区中的instanceKlass, 从而获取类的各种信息

链接

  • 验证: 验证类是否符合JVM规范, 安全性检查
    用支持二进制的编辑器修改 HelloWorld.class 的魔数(CAFEBABE->CAFEBABA), 在控制台运行

    报错了, 这里说的是通过检查魔数发现这不是一个class文件, 所以不能运行

    Error: A JNI error has occurred, please check your installation and try again
    Exception in thread “main” java.lang.ClassFormatError: Incompatible magic value 1128351301 in class file HelloWorld
    at java.lang.ClassLoader.defineClass1(Native Method)
    at java.lang.ClassLoader.defineClass(ClassLoader.java:763)
    at java.security.SecureClassLoader.defineClass(SecureClassLoader.java:142)
    at java.net.URLClassLoader.defineClass(URLClassLoader.java:468)
    at java.net.URLClassLoader.access$100(URLClassLoader.java:74)
    at java.net.URLClassLoader$1.run(URLClassLoader.java:369)
    at java.net.URLClassLoader 1. r u n ( U R L C l a s s L o a d e r . j a v a : 363 ) a t j a v a . s e c u r i t y . A c c e s s C o n t r o l l e r . d o P r i v i l e g e d ( N a t i v e M e t h o d ) a t j a v a . n e t . U R L C l a s s L o a d e r . f i n d C l a s s ( U R L C l a s s L o a d e r . j a v a : 362 ) a t j a v a . l a n g . C l a s s L o a d e r . l o a d C l a s s ( C l a s s L o a d e r . j a v a : 424 ) a t s u n . m i s c . L a u n c h e r 1.run(URLClassLoader.java:363) at java.security.AccessController.doPrivileged(Native Method) at java.net.URLClassLoader.findClass(URLClassLoader.java:362) at java.lang.ClassLoader.loadClass(ClassLoader.java:424) at sun.misc.Launcher 1.run(URLClassLoader.java:363)atjava.security.AccessController.doPrivileged(NativeMethod)atjava.net.URLClassLoader.findClass(URLClassLoader.java:362)atjava.lang.ClassLoader.loadClass(ClassLoader.java:424)atsun.misc.LauncherAppClassLoader.loadClass(Launcher.java:349)
    at java.lang.ClassLoader.loadClass(ClassLoader.java:357)
    at sun.launcher.LauncherHelper.checkAndLoadMain(LauncherHelper.java:495)

  • 准备: 为 static 变量分配空间, 设置默认值

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

    将常量池中的符号引用解析为直接引用:

    public class Demo {
        public static void main(String[] args) throws ClassNotFoundException, IOException {
            ClassLoader classLoader = Demo.class.getClassLoader();
            Class<?> c = classLoader.loadClass("com.ayu4.C");
    
            // new C();
            System.in.read();
        }
    
    }
    
    class C {
        D d = new D();
    }
    
    class D {
    
    }
    

    使用 HSDB 工具可以看到使用 ClassLoader 加载类 C, 类 D 是不会被加载的, 并且在类 C 中, 类 D 还只是一个未被解析的常量符号

    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-EqfBUyjS-1655547496469)(C:\Users\86133\Pictures\JVM\类加载\解析1.png)]

    而使用new C() 的时候则会执行 C 里面的静态代码 D d = new D() , 所以类 C 和 类 D 都会被加载

    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-JtWosHqA-1655547496469)(C:\Users\86133\Pictures\JVM\类加载\解析2.png)]

初始化

()V 方法

初始化即调用 ()V , 虚拟机会保证这个类的构造方法的线程安全

发生的时机

概括得说, 类初始化是懒惰的

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

不会导致类初始化的情况

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

测试代码:

public class Demo {
    static {
        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("com.ayu4.B");
        // 5. 不会初始化类 B, 但会加载 B、A
        // ClassLoader c2 = Thread.currentThread().getContextClassLoader();
        // Class.forName("com.ayu4.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("com.ayu4.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");
    }
}

练习

从字节码分析, 使用 a, b, c 这三个常量是否会导致 E 初始化

public class Load {
    public static void main(String[] args) {
        System.out.println(E.a);
        System.out.println(E.b);
        // 会导致 E 类初始化, 因为 Integer 是包装类
        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;

    static {
        System.out.println("E cinit");
    }
}

典型应用 - 完成懒惰初始化单例模式

public class Load {
    public static void main(String[] args) {
        Singleton.getInstance();
    }
}

class Singleton {
    //私有化构造方法
    private Singleton() {
    }

    //内部类保存单例
    private static class LazyHolder {
        private static final Singleton SINGLETON = new Singleton();
        static {
            System.out.println("LazyHolder init");
        }
    }

    //调用getInstance方法, 才会导致内部类加载和初始化其静态成员
    public static Singleton getInstance() {
        return LazyHolder.SINGLETON;
    }
}

以上的实现特点是:

  • 懒惰实例化
  • 初始化时的线程安全是有保障的

2. 类加载器

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

以 JDK8 为例:

名称加载哪的类说明
Bootstrap ClassLoaderJAVA_HOME/jre/lib无法直接访问
Extension ClassLoaderJAVA_HOME/jre/lib/ext上级为 Bootstrap, 显示为 null
Application ClassLoaderclasspath上级为 Extension
自定义加载器自定义上级为 Application

启动类加载器

用 Bootstrap 类加载器加载类:

public class F {
    static {
    	System.out.println("bootstrap F init");
    }
}

public class Load {
    public static void main(String[] args) throws ClassNotFoundException {
        Class<?> aClass = Class.forName("com.ayu.F");
        System.out.println(aClass.getClassLoader());
    }
}

因为需要加参数, 需要在控制台与运行:

java -Xbootclasspath/a:. com.ayu.Load

bootstrap F init
null

因为 Bootstrap ClassLoader 是用 C++ 写的, 打印 null 就说明使用了Bootstrap ClassLoader

  • -Xbootclasspath 表示设置 bootclasspath
  • 其中 /a:. 表示将当前目录追加至 bootclasspath 之后
  • 可以用这个办法替换核心类
    • java -Xbootclasspath:
    • java -Xbootclasspath/a:<追加路径>
    • java -Xbootclasspath/p:<追加路径>

扩展类加载器

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

public class G {
    static {
    	System.out.println("classpath G init");
    }
}

public class Load {
    public static void main(String[] args) throws ClassNotFoundException {
        Class<?> aClass = Class.forName("com.ayu.G");
        System.out.println(aClass.getClassLoader());
    }
}

输出:

classpath G init
sun.misc.Launcher$AppClassLoader@18b4aac2

再写一类 G:

public class G {
    static {
    	System.out.println("ext G init");
    }
}

打个 jar 包

jar -cvf my.jar com/ayu/G.class

将 jar 包拷贝到 JAVA_HOME/jre/lib/ext

重新执行

ext G init
sun.misc.Launcher$ExtClassLoader@29453f44

双亲委派模式

双亲委派模式, 即调用类加载器ClassLoader 的 loadClass 方法时, 查找类的规则

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);
                } 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;
    }
}

线程上下文类加载器

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

Class.forName("com.mysql.jdbc.Driver")

也是可以让 com.mysql.jdbc.Driver 正确加载的, 你知道是怎么做的吗?
看一下 DriverManager 的源码:

// 注册驱动的集合
private final static CopyOnWriteArrayList<DriverInfo> registeredDrivers = new CopyOnWriteArrayList<>();
// 初始化驱动
static {
    loadInitialDrivers();
    println("JDBC DriverManager initialized");
}

先来看一下 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 呢?

继续看 loadInitialDrivers() 方法:

private static void loadInitialDrivers() {
    String drivers;
    try {
        drivers = AccessController.doPrivileged(new PrivilegedAction<String>() {
            public String run() {
            	return System.getProperty("jdbc.drivers");
            }
        });
    } catch (Exception ex) {
    	drivers = null;
    }
    // 1)使用 ServiceLoader 机制加载驱动, 即 SPI
    AccessController.doPrivileged(new PrivilegedAction<Void>() {
        public Void run() {
            ServiceLoader<Driver> loadedDrivers = ServiceLoader.load(Driver.class);
            Iterator<Driver> driversIterator = loadedDrivers.iterator();
            try{
                while(driversIterator.hasNext()) {
                    driversIterator.next();
                }
            } catch(Throwable t) {
            // Do nothing
            }
            return null;
        }
    });
    println("DriverManager.initialize: jdbc.drivers = " + drivers);
    // 2)使用 jdbc.drivers 定义的驱动名加载驱动
    if (drivers == null || drivers.equals("")) {
    	return;
    }
    String[] driversList = drivers.split(":");
    println("number of Drivers:" + driversList.length);
    for (String aDriver : driversList) {
        try {
            println("DriverManager.Initialize: loading " + aDriver);
            // 这里的 ClassLoader.getSystemClassLoader() 就是应用程序类加载器
            Class.forName(aDriver, true,
            ClassLoader.getSystemClassLoader());
        } catch (Exception ex) {
        	println("DriverManager.Initialize: load failed: " + ex);
        }
    }
}

先看 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();
}

来得到实现类, 体现的是 面向接口编程+解耦 的思想, 在下面一些框架中都运用了此思想:

  • JDBC
  • Servlet 初始化器
  • Spring 容器
  • Dubbo (对 SPI 进行了扩展)

接着看 ServiceLoader.load 方法:

public static <S> ServiceLoader<S> load(Class<S> service) {
    // 获取线程上下文类加载器
    ClassLoader cl = Thread.currentThread().getContextClassLoader();
    return ServiceLoader.load(service, cl);
}

线程上下文类加载器是当前线程使用的类加载器, 默认就是应用程序类加载器, 它内部又是由 Class.forName 调用了线程上下文类加载器完成类加载, 具体代码在 ServiceLoader 的内部类 LazyIterator 中:

private S nextService() {
    if (!hasNextService())
    	throw new NoSuchElementException();
    String cn = nextName;
    nextName = null;
    Class<?> c = null;
    try {
    	c = Class.forName(cn, false, loader);
    } catch (ClassNotFoundException x) {
    	fail(service, "Provider " + cn + " not found");
    }
    if (!service.isAssignableFrom(c)) {
        fail(service,
        "Provider " + cn + " not a subtype");
    }
    try {
        S p = service.cast(c.newInstance());
        providers.put(cn, p);
        return p;
    } catch (Throwable x) {
        fail(service,
        "Provider " + cn + " could not be instantiated", x);
    }
    throw new Error(); // This cannot happen
}

自定义类加载器

场景:

  • 想加载非 classpath 随意路径中的类文件
  • 通过接口来使用实现, 希望解耦时, 常用在框架设计
  • 这些类希望予以隔离, 不同应用的同名类都可以加载, 不冲突, 常见于 tomcat 容器

步骤:

  • 继承 ClassLoader 父类
  • 要遵从双亲委派机制, 重写 findClass 方法
    不是重写 loadClass 方法, 否则不会走双亲委派机制
  • 读取类文件的字节码
  • 调用父类的 defineClass 方法来加载类
  • 使用者调用该类加载器的 loadClass 方法

破坏双亲委派模式

  • 双亲委派模型的第一次“被破坏”其实发生在双亲委派模型出现之前——即JDK1.2面世以前的“远古”时代
    • 建议用户重写findClass()方法, 在类加载器中的loadClass()方法中也会调用该方法
  • 双亲委派模型的第二次“被破坏”是由这个模型自身的缺陷导致的
    • 如果有基础类型又要调用回用户的代码, 此时也会破坏双亲委派模式
  • 双亲委派模型的第三次“被破坏”是由于用户对程序动态性的追求而导致的
    • 这里所说的“动态性”指的是一些非常“热”门的名词: 代码热替换(Hot Swap)、模块热部署(Hot Deployment)等

3. 运行期优化

即时编译

分层编译

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

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

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

即时编译器(JIT)与解释器的区别:

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

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

逃逸分析
逃逸分析(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 压力, 提高了应用程序性能

方法内联

内联函数
内联函数就是在程序编译时, 编译器将程序中出现的内联函数的调用表达式用内联函数的函数体来直接进行替换

例如:

private static int square(final int i) {
	return i * i;
}
System.out.println(square(9));

如果发现 square 是热点方法, 并且长度不太长时, 会进行内联, 所谓的内联就是把方法内代码拷贝粘贴到调用者的位置:

System.out.println(9 * 9);

还能够进行常量折叠(constant folding)的优化

System.out.println(81);
字段优化

创建 maven 工程:

<dependency>
    <groupId>org.openjdk.jmh</groupId>
    <artifactId>jmh-core</artifactId>
    <version>1.21</version>
</dependency>
<dependency>
    <groupId>org.openjdk.jmh</groupId>
    <artifactId>jmh-generator-annprocess</artifactId>
    <version>1.21</version>
    <scope>provided</scope>
</dependency>

测试代码:

@Warmup(iterations = 2, time = 1) // 预热代码, 让JIT编译器对代码进行充分优化
@Measurement(iterations = 5, time = 1) // 测试轮数 5 次
@State(Scope.Benchmark)
public class BenchmarkTest {
    
    int[] elements = randomInts(1_000);

    private static int[] randomInts(int size) {
        Random random = ThreadLocalRandom.current();
        int[] values = new int[size];
        for (int i = 0; i < size; i++) {
            values[i] = random.nextInt();
        }
        return values;
    }

    @Benchmark
    public void test1() {
        for (int i = 0; i < elements.length; i++) {
            doSum(elements[i]);
        }
    }

    @Benchmark
    public void test2() {
        int[] local = this.elements;
        for (int i = 0; i < local.length; i++) {
            doSum(local[i]);
        }
    }

    @Benchmark
    public void test3() {
        for (int element : elements) {
            doSum(element);
        }
    }

    static int sum = 0;

    @CompilerControl(CompilerControl.Mode.INLINE)
    static void doSum(int x) {
        sum += x;
    }

    public static void main(String[] args) throws RunnerException {
        Options opt = new OptionsBuilder()
                .include(BenchmarkTest.class.getSimpleName())
                .forks(1)
                .build();
        new Runner(opt).run();

    }
}

测试结果如下(每秒吞吐量, 分数越高的更好):

Benchmark             Mode  Cnt        Score        Error  Units
BenchmarkTest.test1  thrpt    5  2267054.312 ± 418537.351  ops/s
BenchmarkTest.test2  thrpt    5  2285712.531 ± 172019.809  ops/s
BenchmarkTest.test3  thrpt    5  2327367.824 ± 238476.579  ops/s

接下来禁用 doSum 方法内联

@CompilerControl(CompilerControl.Mode.DONT_INLINE)  // 控制调用方法时是不是要进行方法内联;不允许内联
static void doSum(int x) { 
    sum += x;
}

测试结果如下(得分明显下降):

Benchmark             Mode  Cnt       Score       Error  Units
BenchmarkTest.test1  thrpt    5  288147.413 ± 67562.733  ops/s
BenchmarkTest.test2  thrpt    5  278199.128 ± 32071.212  ops/s
BenchmarkTest.test3  thrpt    5  301989.359 ± 23505.462  ops/s

分析:

在刚才的示例中, doSum 方法是否内联会影响 elements 成员变量读取的优化

如果 doSum 方法内联了, 刚才的 test1 方法会被优化成下面的样子(伪代码):

@Benchmark
public void test1() {
    // elements.length 首次读取会缓存起来 -> int[] local
    for (int i = 0; i < elements.length; i++) { // 后续 999 次 求长度 <- local
    	sum += elements[i]; // 1000 次取下标 i 的元素 <- local
    }
}

可以节省 1999 次 Field 读取操作, 但如果 doSum 方法没有内联, 则不会进行上面的优化

本地变量访问长度、数据时, 不需要去 class 元数据那里找, 在本地变量就可以找到了, 相当于手动优化。但是方法内联是由虚拟机来优化的。所以, test3 方法与test2 方法是等价的, test1 方法是运行期间优化了, test2 方法是手动优化了, test3 方法的 foreach 是编译期间优化了。

反射优化

测试代码:

public class ReflectTest {
    public static void foo() {
        System.out.println("foo...");
    }
    public static void main(String[] args) throws Exception {
        Method foo = ReflectTest.class.getMethod("foo");
        for (int i = 0; i <= 16; i++) {
            System.out.printf("%d\t", i);
            foo.invoke(null);
        }
        System.in.read();
    }
}

foo.invoke 前面 0 ~ 15 (共16) 次调用使用的是 MethodAccessor 的 NativeMethodAccessorImpl 实现

class NativeMethodAccessorImpl extends MethodAccessorImpl {
    private final Method method;
    private DelegatingMethodAccessorImpl parent;
    private int numInvocations;

    NativeMethodAccessorImpl(Method var1) {
        this.method = var1;
    }

    public Object invoke(Object var1, Object[] var2) 
        throws IllegalArgumentException, InvocationTargetException {
        // inflationThreshold 膨胀阈值, 默认 15
        if (++this.numInvocations > ReflectionFactory.inflationThreshold() && 
            !ReflectUtil.isVMAnonymousClass(this.method.getDeclaringClass())) {
            // 使用 ASM 动态生成的新实现代替本地实现, 速度较本地实现快 20 倍左右
            MethodAccessorImpl var3 = (MethodAccessorImpl)(new MethodAccessorGenerator())
                .generateMethod(this.method.getDeclaringClass(), 
                                this.method.getName(), 
                                this.method.getParameterTypes(), 
                                this.method.getReturnType(), 
                                this.method.getExceptionTypes(), 
                                this.method.getModifiers());
            					this.parent.setDelegate(var3);
        }

        // 调用本地实现
        return invoke0(this.method, var1, var2);
    }

    void setParent(DelegatingMethodAccessorImpl var1) {
        this.parent = var1;
    }

    private static native Object invoke0(Method var0, Object var1, Object[] var2);
}

下面, 我们从源码的角度来分析反射底层是如何实现优化的:

  1. 先在 foo.invoke(null) 处打上断点, debug 模式启动

    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ahJpDhFV-1655547496472)(C:\Users\86133\Pictures\JVM\类加载\1.png)]

  2. 点击进去 invoke() 方法实现, 找到 MethodAccessor 的实现类, 在 invoke() 方法上打上新断点

    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-gSIXDT5d-1655547496475)(C:\Users\86133\Pictures\JVM\类加载\2.png)]

  3. 通过 Idea 查看 Evaluate, 来查看反射多次调用后, 由 JVM 动态生成的实现类名 - GeneratedMethodAccessor1, 这个名字在后面需要用的。且此时 this.numInvocations 已经到了 16了

    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-VBUCdEGQ-1655547496476)(C:\Users\86133\Pictures\JVM\类加载\3.png)]

接下来, 我们通过阿里的 arthas 工具来进行调试:

  1. run 模式运行代码, 会停在 System.in.read()

  2. 运行 arthas: java -jar arthas-boot.jar

    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-2b59iiFQ-1655547496477)(C:\Users\86133\Pictures\JVM\类加载\启动arthas.png)]

  3. 通过 jad sun.reflect.GeneratedMethodAccessor1 将 JVM 对反射优化后的类的字节码反编译出来

    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-GFz90fWz-1655547496478)(C:\Users\86133\Pictures\JVM\类加载\反编译.png)]

注意:

通过查看 ReflectionFactory 源码可知

  • sun.reflect.noInflation 可以用来禁用膨胀(直接生成 GeneratedMethodAccessor1, 但首次生成比较耗时, 如果仅反射调用一次, 不划算)
  • sun.reflect.inflationThreshold 可以修改膨胀阈值

我的个人主页: www.ayu.link
本文连接: ┏ (゜ω゜)=☞

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值