关于面试:即使技术再精面试时一问这个必挂

29次阅读

共计 4820 个字符,预计需要花费 13 分钟才能阅读完成。

写在后面

在前几年面试 Java 高级程序员的时候,只有是会一点 JVM 的基础知识,根本就都可能面试通过了。最近几年,对 Java 工程师的要求越来越严格,对于中级 Java 工程师来说,也须要把握 JVM 相干的常识了。这不,一名读者进来面试 Java 岗位,就被问及了 JVM 相干的类的加载、链接和初始化的问题。后果凉凉了,明天,咱们就一起来具体探讨下这个问题。

文章已收录到:

https://github.com/sunshinelyz/technology-binghe

https://gitee.com/binghe001/technology-binghe

概述

本文咱们一起探讨 Java 类的加载、链接和初始化。Java 字节代码的表现形式是字节数组(byte[]),而 Java 类在 JVM 中的表现形式是 java.lang.Class 类 的对象。一个 Java 类从字节代码到可能在 JVM 中被应用,须要通过加载、链接和初始化这三个步骤。这三个步骤中,对开发人员间接可见的是 Java 类的加 载,通过应用 Java 类加载器(class loader)能够在运行时刻动静的加载一个 Java 类;而链接和初始化则是在应用 Java 类之前会产生的动作。本文会具体介绍 Java 类的加载、链接和 初始化的过程。

Java 类的加载

Java 类的加载是由类加载器来实现的。

一般来说,类加载器分成两类:启动类加载器(bootstrap)和用户自定义的类加载器(user-defined)。

两者的区别在于启动类加载器是由 JVM 的原生代码实现的,而用户自定义的类加载器都继承自 Java 中的 java.lang.ClassLoader 类。在用户自定义类加载器的局部,个别 JVM 都会提供一些根本实现。应用程序的开发人员也能够依据须要编写本人的类加载器。JVM 中最常应用的是零碎类加载器(system),它用来启动 Java 应用程序的加载。通过 java.lang.ClassLoader 的 getSystemClassLoader() 办法能够获取到该类加载器对象。

类加载器须要实现的最终性能是定义一个 Java 类,即把 Java 字节代码转换成 JVM 中的 java.lang.Class 类的对象。然而类加载的过程并不是这么简略。

Java 类加载器有两个比拟重要的特色:档次组织构造和代理模式。

档次组织构造指的是每个类加载器都有一个父类加载器,通过 getParent() 办法能够获取到。类加载器通过这种父亲 - 后辈的形式组织在一起,造成树状层次结构。代理模式则指的是一个类加载器既能够本人实现 Java 类的定义工作,也能够代理给其它的类加载器来实现。因为代理模式的存在,启动一个类的加载过程的类加载器和最终定义这个类的类加载器可能并不是一个。前者称为初始类加载器,而后者称为定义类加载器。

两者的关联在于:一个 Java 类的定义类加载器是该类所导入的其它 Java 类的初始类加载器。比方类 A 通过 import 导入了类 B,那么由类 A 的定义类加载器负责启动类 B 的加载过程。个别的类加载器在尝试本人去加载某个 Java 类之前,会首先代理给其父类加载器。当父类加载器找不到的时候,才会尝试本人加载。这个逻辑是封装在 java.lang.ClassLoader 类的 loadClass() 办法中的。一般来说,父类优先的策略就足够好了。在某些状况下,可能须要采取相同的策略,即先尝试本人加载,找不到的时候再代理给父类加载器。这种做法在 Java 的 Web 容器中比拟常见,也是 Servlet 标准举荐的做法。比方,Apache Tomcat 为每个 Web 利用都提供一个独立的类加载器,应用的就是本人优先加载的策略。IBM WebSphere Application Server 则容许 Web 利用抉择。

类加载器应用的策略

类加载器的一个重要用处是在 JVM 中为雷同名称的 Java 类创立隔离空间。在 JVM 中,判断两个类是否雷同,不仅是依据该类的二进制名称,还须要依据两个类的定义类加载器。只有两者齐全一样,才认为两个类是雷同的。因而,即使是同样的 Java 字节代码,被两个不同的类加载器定义之后,所失去的 Java 类也是不同的。如果试图在两个类的对象之间进行赋值操作,会抛出 java.lang.ClassCastException。这个个性为同样名称的 Java 类在 JVM 中共存发明了条件。在理论的利用中,可能会要求同一名称的 Java 类的不同版本在 JVM 中能够同时存在。通过类加载器就能够满足这种需要。这种技术在 OSGi 中失去了宽泛的利用

Java 类的链接

Java 类的链接指的是将 Java 类的二进制代码合并到 JVM 的运行状态之中的过程。在链接之前,这个类必须被胜利加载。类的链接包含验证、筹备和解析等几个步骤。验证是用来确保 Java 类的二进制示意在结构上是完全正确的。如果验证过程呈现谬误的话,会抛出 java.lang.VerifyError 谬误。

筹备过程则是创立 Java 类中的动态域,并将这些域的值设为默认值。筹备过程并不会执行代码。在一个 Java 类中会蕴含对其它类或接口的模式援用,包含它的父类、所实现的接口、办法的形式参数和返回值的 Java 类等。解析的过程就是确保这些被援用的类能被正确的找到。解析的过程可能会导致其它的 Java 类被加载。不同的 JVM 实现可能抉择不同的解析策略。

一种做法是在链接的时候,就递归的把所有依赖的模式援用都进行解析。而另外的做法则可能是只在一个模式援用真正须要的时候才进行解析。也就是说如果一个 Java 类只是被援用了,然而并没有被真正用到,那么这个类有可能就不会被解析。思考上面的代码:

public class LinkTest {public static void main(String[] args) {
        ToBeLinked toBeLinked = null;
        System.out.println("Test link.");
    }
}

类 LinkTest 援用了类 ToBeLinked,然而并没有真正应用它,只是申明了一个变量,并没有创立该类的实例或是拜访其中的动态域。

在 Oracle 的 JDK 6 中,如果把编译好的 ToBeLinked 的 Java 字节代码删除之后,再运行 LinkTest,程序不会抛出谬误。这是因为 ToBeLinked 类没有被真正用到,而 Oracle 的 JDK 6 所采纳的链接策略使得 ToBeLinked 类不会被加载,因而也不会发现 ToBeLinked 的 Java 字节代码实际上是不存在的。如果把代码改成 ToBeLinked toBeLinked = new ToBeLinked(); 之后,再依照雷同的办法运行,就会抛出异样了。因为这个时候 ToBeLinked 这个类被真正应用到了,会须要加载这个类。

Java 类的初始化

当一个 Java 类第一次被真正应用到的时候,JVM 会进行该类的初始化操作。初始化过程的次要操作是执行动态代码块和初始化动态域。在一个类被初始化之前,它的间接父类也须要被初始化。然而,一个接口的初始化,不会引起其父接口的初始化。在初始化的时候,会依照源代码中从上到下的程序顺次执行动态代码块和初始化动态域。思考上面的代码:

public class StaticTest {
    public static int X = 10;
    public static void main(String[] args) {System.out.println(Y); // 输入 60
    }
    static {X = 30;}
    public static int Y = X * 2;
}

在下面的代码中,在初始化的时候,动态域的初始化和动态代码块的执行会从上到下顺次执行。因而变量 X 的值首先初始化成 10,起初又被赋值成 30;而变量 Y 的值则被初始化成 60。

Java 类和接口的初始化机会

Java 类和接口的初始化只有在特定的机会才会产生,这些机会包含:

  • 创立一个 Java 类的实例。如
MyClass obj = new MyClass()
  • 调用一个 Java 类中的静态方法。如
MyClass.sayHello()
  • 给 Java 类或接口中申明的动态域赋值。如
MyClass.value = 10
  • 拜访 Java 类或接口中申明的动态域,并且该域不是常值变量。如
int value = MyClass.value
  • 在顶层 Java 类中执行 assert 语句。
assert true;

通过 Java 反射 API 也可能造成类和接口的初始化。须要留神的是,当拜访一个 Java 类或接口中的动态域的时候,只有真正申明这个域的类或接口才会被初始化。如上面的代码所示。

package io.mykit.binghe.test;
 
class B {
    static int value = 100;
    static {System.out.println("Class B is initialized."); // 输入
    }
}
 
class A extends B {
    static {System.out.println("Class A is initialized."); // 不会输入
    }
}
 
public class InitTest {public static void main(String[] args) {System.out.println(A.value); // 输入 100
    }
}

在上述代码中,类 InitTest 通过 A.value 援用了类 B 中申明的动态域 value。因为 value 是在类 B 中申明的,只有类 B 会被初始化,而类 A 则不会被初始化。

创立本人的类加载器

在 Java 利用开发过程中,可能会须要创立利用本人的类加载器。典型的场景包含实现特定的 Java 字节代码查找形式、对字节代码进行加密 / 解密以及实现同名 Java 类的隔离等。创立 本人的 类加载 器并不 是 一件简单 的事件,只须要继承自 java.lang.ClassLoader 类并覆写对应的办法即可。java.lang.ClassLoader 中提供的办法有不少,上面介绍几个创立类加载器时须要思考的:

  • defineClass():这个办法用来实现从 Java 字节代码的字节数组到 java.lang.Class 的转换。这个办法是不能被覆写的,个别是用原生代码来实现的。
  • findLoadedClass():这个办法用来依据名称查找曾经加载过的 Java 类。一个类加载器不会反复加载同一名称的类。
  • findClass():这个办法用来依据名称查找并加载 Java 类。
  • loadClass():这个办法用来依据名称加载 Java 类。
  • resolveClass():这个办法用来链接一个 Java 类。

这里比拟 容易混同的是 findClass() 办法和 loadClass() 办法的作用。后面提到过,在 Java 类的链接过程中,会须要对 Java 类进行解析,而解析可能会导致以后 Java 类所援用的其它 Java 类被加载。在这个时候,JVM 就是通过调用以后类的定义类加载器的 loadClass() 办法来加载其它类的。findClass() 办法则是利用创立的类加载器的扩大点。利用本人的类加载器应该覆写 findClass() 办法来增加自定义的类加载逻辑。loadClass() 办法的默认实现会负责调用 findClass() 办法。后面提到,类加载器的代理模式默认应用的是父类优先的策略。这个策略的实现是封装在 loadClass() 办法中的。如果心愿批改此策略,就须要覆写 loadClass() 办法。

上面的代码给出了自定义的类加载的常见实现模式

public class MyClassLoader extends ClassLoader {protected Class<?> findClass(String name) throws ClassNotFoundException {byte[] b = null; // 查找或生成 Java 类的字节代码
        return defineClass(name, b, 0, b.length);
    }
}

好了,明天就到这儿吧,我是冰河,大家有啥问题能够在下方留言,一起交换技术,一起进阶,一起牛逼~~

正文完
 0