面试题一:类加载

  1. 简述java创建一个对象的过程?

    java对象的创建过程就是在堆上分配空间的过程,此处的对象的创建过程仅仅包含new关键字创建的普通java对象,不包含数组。

    过程步骤:

    1. 检测类是否已经被加载;

      当虚拟机遇到new指令时,首先回去方法区中的常量池根据类的全限定名查找对应的符号引用,并且检查是否完成了加载、验证、准备、解析的过程,如果没有,则需要先执行类加载的过程。

    2. 为对象分配内存;

      类加载完成后,虚拟机就开始为对象分配内存,此时所需的内存大小已经确定了,只需要在堆上分配相应的内存即可。

      分两种情况:

      1. 内存规整;

        这种情况只需要在内存已用区域和未用区域之间移动指针即可,这种方式也称为指针碰撞

      2. 内存不连续

        这种情况需要虚拟机维护一个列表,记录哪些内存是可用的。分配内存的时候找到一个可用的空间,并且在列表上记录下已被分配,这种方式称为空闲列表

      并发情况下的内存分配解决方案:

      1. 使用同步的方式,使用CAS来保证操作的原子性;
      2. TLAB,即每个线程都预先分配一小块内存,称为本地线程分配缓冲,分配的时候再TLAB上分配,互不干扰。可以通过jvm参数-XX:+/-UseTLAB参数决定。
    3. 为分配的内存空间初始化零值;

    4. 对对象进行其他设置;

    5. 执行init方法

    经过以上5步一个对象就被创建完成,具体流程图如下:

  2. 对象内存布局?

    对象的内存布局分为三个部分:

    1. 对象头

      对象头包含2部分内容:

      1. 存储对象自身的运行时数据,如哈希码、锁标志位、GC分代年龄、线程持有的锁等;
      2. 类型指针,对象指向类元数据的指针;
    2. 实例数据

      具体的数据

    3. 对齐填充

      没有实际意义,就是为了对齐

  3. 对象如何定位访问?

    对象的访问定位有2种方式,句柄定位直接指针访问

    句柄定位:堆中会画出一块专门的区域来作为句柄池,reference中存储的就是对象的句柄地址,句柄中包含了对象实例数据和类型数据的具体地址信息。

    直接指针访问:reference中存储的直接就是对象地址

    区别:

    • 句柄定位的好处是如果对象被移动了,改变的只是句柄中的对象实例数据指针,但是reference中的句柄地址不会发生改变;
    • 直接指针的好处是速度快,因为节省了一次指针定位的过程,java中默认使用该方式;
  4. 什么是类加载器?

    定义:实现通过一个类的全限定名来获得描述该类的二进制字节流的代码被称为“类加载器”;

    类加载器,用来将java类加载到java虚拟机中。加载的方式为:java源程序在经过编译器编译之后就被转换成java字节码文件。

    类加载器,负责加载字节码文件,并转换成java.lang.Class的一个实例。

    每个这样的实例用来表示一个java类,通过此实例的Class.newInstance()可以创建该类的一个对象;

    字节码文件不一定是经过java文件编译而来的,可能是工具动态生成的,也可能是通过网络下载的。

    如何比较两个类是否相等?

    比较两个类是否相同不仅要比较类本身,还要比较加载类的加载器,两者只要有一个不相等,那么两个类就不相等,即使两个类来自同一个class文件。

  5. 类加载发生的时机?

    java虚拟机规范对于什么时候执行第一步加载并没有严格规定,可以由虚拟机自由实现,但是规定了以下6种情况时必须对类进行初始化(而加载、验证、准备阶段必须在此之前开始)

    • 遇到newgetstaticputstaticinvokestatic四个指令时,如果类型没有执行过初始化,则必须先开始执行初始化;
    • 使用java.lang.reflect包的方法对类进行反射调用的时候,如果类型没有进行过初始化,需要先执行初始化操作;
    • 当初始化类的时候,如果发现当前类的父类没有初始化,需要先对父类进行初始化;
    • 当虚拟机启动时,用户需要制定一个主类(包含main方法的类),虚拟机会优先初始化该主类;
    • 使用JDK7的动态语言支持时,如果一个java.lang.invoke.MethodHandler实例最后的解析结果是REF_getStatic、REF_putStatic、REF_invokeStatic 的方法句柄,并且这个方法句柄所对应的类没有进行过初始化,则需要先触发其初始化;
    • 当一个接口中定义了jdk8新加入的默认方法时,如果有这个接口的实现类发生了初始化,那么这个接口要在此之气进行初始化;

    以上6种方式称为主动引用,当出现任何一种时都会触发类的初始化,其他的引用类型的方式都不会触发初始化,称为被动引用。

  6. 类加载器是如何加载class文件的?

    类加载的过程主要分为5个阶段,分别是加载、验证、准备、解析、初始化

    1. 加载

      加载是类加载过程的第一个阶段

      加载过程要完成的三件事情:

      1. 通过类的全限定名获取其定义的二进制字节流并加载到虚拟机中;
      2. 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构;
      3. 在java堆中生一个代表这个类的java.lang.Class对象,作为方法区中这些数据的访问入口。

      相对于类加载的其他阶段,加载是程序员可控性最强的一个阶段,可以使用系统提供的类加载器来加载类,也可以使用自定义的方式来加载类。

      加载阶段完成以后,虚拟机外部的二进制字节流按照虚拟机所需的格式存储在方法区之中,而且在java堆中也创建一个java.lang.Class对象,这样便可以通过该对象访问方法区中的这些数据。

    2. 验证

      确保被加载类的正确性

      验证是连接阶段的第一步,目的是为了保证class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。

      验证主要分为4个阶段:

      1. 文件格式验证:验证字节流是否符合class文件格式的规范;
      2. 元数据验证:对字节码描述的信息进行语义分析,以保证其描述的信息符合java语言规范的要求,例如是否有父类等;
      3. 字节码验证:通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的;
      4. 符号引用验证:保证解析动作能正确执行;

      验证阶段是重要的,但是非必须的,如果引用的类经过反复验证,那么可以使用-Xverifynone参数来关闭大部分的类验证措施,以缩短虚拟机类加载的时间。

    3. 准备

      正式为类的静态变量分配内存,并将其初始化为默认值的阶段,这些内存的分配都在方法区中进行,需要注意以下三点:

      1. 这时候分配内存的仅仅是静态变量,而不包含实例变量,实例变量会在对象实例化时随着对象一块分配到堆中;

      2. 初始化的值都是各种类型变量的默认值,如int = 0, boolean = false,而不是在代码中显示赋予的值,赋予显示指定的值是在初始化阶段才会执行。

        基本类型的类变量和全局变量,如果不显示赋值而直接引用,系统会赋予其默认值,但是对于基本类型的局部变量,必须显示的赋值,否则编译不通过;

        同时被finalstatic修饰的变量,声明时就必须显示的赋值,否则编译不通过;只被final修饰的变量,可以在声明时显示地赋值,也可以在初始化时显示的赋值,只要在使用前进行赋值即可;

        引用类型,如果没有显示赋值,那么会给予默认值null

        数据类型,如果初始化时没有对内部元素显示赋值,那么会给予对应类型的默认值。

      3. 如果类字段的字段属性表中存在ConstantValue属性,即同时被finalstatic关键字修饰,那么在准备阶段变量的值就会被初始化为声明的值。

    4. 解析

      将类中的符号引用转换成直接引用

      符号引用:就是一组符号来描述目标,可以是任何字面量;

      直接引用:直接指向目标的指针、偏移量或者一个间接定位到目标的句柄。

      解析过程就是虚拟机将方法区中的符号引用解析为直接引用的过程。解析动作主要包括类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符7类符号引用进行

    5. 初始化

      为类的静态变量赋予正确地初始值,jvm负责对类进行初始化,主要对类变量进行初始化

      对类变量初始化赋值有2种方式:

      1. 声明类变量时指定初始值;
      2. 使用静态代码块为类变量初始化值;

      jvm初始化步骤:

      1. 假如这个类还没有加载和连接,需要先加载和连接;
      2. 假如这个类的直接父类还没有被初始化,则先初始化其直接父类;
      3. 假如类中有初始化语句,则依次执行这些初始化语句;
  7. 什么是双亲委派模型?

    • 根加载器(BootstrapClassLoader)

      负责加载java的核心类,不是java.lang.ClassLoader的子类,是有虚拟机自身使用C语言实现的类加载器。

    • 扩展加载器(ExtensionClassLoader)

      加载JDK目录下的jre/lib/ext下的类,扩展类加载器的父类加载器是根类加载器,但是其getParent()返回null,这是因为根加载器不是使用java实现的原因

    • 系统加载器(SystemClassLoader)

      系统类加载器的加载路径是当前系统运行的当前路径。

      系统加载器主要用来加载虚拟机启动时的来自java命令的-classpath选项、java.class.path系统属性或者CLASSPATH环境变量所指定的jar包和类路径。

    该模型要求除了顶层的根类加载器,其余的类加载器都应该有自己的父类加载器。子类加载器和父类加载器不是通过继承的关系来实现的,而是通过组合的方式来复用父加载器的代码。

    每个类加载器都有自身的命名空间:

    • 在同一个命名空间中不会出现两个全限定名完全相同的类;
    • 在不同的命名空间中,可能会出现两个全限定名相同的类;
    • 类加载器负责加载所有的类,同一个类只会被加载一次。
  8. java虚拟机是如何判断两个类是相同的?

    java虚拟机不仅要看两个类的全限定名是否相同,还要看加载这两个类的类加载器是否相同。

    只有类的全限定名和类加载器两者都完全相同,才能判定这两个类相同

  9. 双亲委派模型的工作过程?

    1. 当前类加载器从自己已经加载的类中,查询当前类是否已经被加载,如果已经被加载过,那么返回已加载过的类;

      每个类加载器都有自己的缓存,当一个类被加载了以后就会放入到缓存中,下次加载的时候就可以直接返回了。

    2. 当前类加载器的缓存中未查找到被加载的类的时候

      委托自己的父类去加载,然后父类加载器使用同样的策略,首先从自己的缓存中查询,没有的话再委托父类的父类去加载,直到类加载器的getParent()返回null时,使用根加载器进行加载;

      当所有的父加载器都没有加载的时候,再由当前加载器进行加载,加载完成后放入自己的缓存中,以便下次使用。

    源代码:

    // java.lang.ClassLoader
    // 删除部分无关代码
    
    protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
        synchronized (getClassLoadingLock(name)) {
            // 首先,从缓存中获得 name 对应的类
            Class<?> c = findLoadedClass(name);
            if (c == null) { // 获得不到
                try {
                    // 其次,如果父类非空,使用它去加载类
                    if (parent != null) {
                        c = parent.loadClass(name, false);
                    // 其次,如果父类为空,使用 Bootstrap 去加载类
                    } else {
                        c = findBootstrapClassOrNull(name);
                    }
                } catch (ClassNotFoundException e) {
                }
    
                if (c == null) { // 还是加载不到
                    // 最差,使用自己去加载类
                    c = findClass(name);
                }
            }
            // 如果要解析类,则进行解析
            if (resolve) {
                resolveClass(c);
            }
            return c;
        }
    }
    
  10. 为什么优先使用父ClassLoader加载器?

    1. 共享功能

      可以避免重复加载,当类已经被父类加载器加载完成后,子类就不需要重复加载了。

    2. 隔离功能

      安全性,防止用户自行编写的类动态覆盖java的核心类;

      避免重复加载,jvm中区分不同类,不仅要根据类名,还要根据类加载器,相同的class文件被两个不同的类加载器加载就是不同的两个类,如果互相转型的话就会抛出java.lang.ClassCastException

      这也就是说,即使我们自己编写了一个java.lang.String类也不会被加载。

  11. 什么是破坏双亲委派模型?

    ClassLoader的源码可知,在加载一个类时,首先会查找当前类加载器的缓存,如果缓存中不存在,那么就会去使用父类加载器加载,也就是调用parent.loadClass(name,false),只要重写loadClass()使其不调用父类的加载即可破坏双亲委派模型。

    已知的破坏双亲模型有3次

    1. jdk1.2之后才引入了双亲委派模型,针对jdk1.2之前的代码,不得不作出一些妥协;
    2. 引入线程上下文类加载器,java中所有涉及SPI的加载动作基本都使用了该方式,如JDBC,JNDI等,打通了双亲委派模型的层次结构的逆向使用;
    3. 用户对程序的动态性追求,如模块热部署、代码热替换等。
  12. 如何自定义ClassLoader类?

    1. 继承ClassLoader
    2. 重写父类的findClass()方法,返回一个Class对象;
posted @ 2021-03-17 09:45  一步一年  阅读(211)  评论(0编辑  收藏  举报