51工具盒子

依楼听风雨
笑看云卷云舒,淡观潮起潮落

Java 面试之 JVM

谈谈你对 Java 的理解 {#谈谈你对-java-的理解}

这个问题比较开放,宽泛,答案并不唯一。它考察的是多个方面,面试官是想通过此问题考察我们是否真的掌握了 Java,对基础知识的理解是否清楚,对主要的模块和运行原理是否理解等,另外也会对我们给出的回答进行深究。

要回答此需要对 Java 语言特性做一下汇总,并将显著的点列出来。

参考回答如下:

Java 是一种面向对象的编程语言 ,具有跨平台性和可移植性 ,即一次编译到处运行。它通过 Java 虚拟机(JVM)在不同的操作系统上运行,采用垃圾回收机制管理内存(GC) ,从而不需要像 C++ 那样需要手动释放堆内存。语言特性 包括封装、继承、多态、泛型、Lamda 表达式等。Java 还有丰富的类库 ,包括集合、并发库、网络库、IO 以及 NIO 等。此外, Java 还有异常处理机制,提供了对错误情况的有效处理和反馈机制,有助于我们编写更健壮,可靠的代码。

Java 如何做到编译一次,到处运行? {#java-如何做到编译一次到处运行}

话不多说,直接上图。Default Canvas

Java 分为编译时和运行时。在编译时使用 javac命令将源代码文件编译成对应的字节码 .class文件,然后不同平台的 JVM 读取生成的字节码文件内容并转成相应的平台指令执行。

看下面这个例子:

public class ByteCodeSample {
    public static void main(String[] args) {
        int i = 1, j = 5;
        i++;
        ++j;
        System.out.println(i);
        System.out.println(j);
    }
}

使用 javac命令编译:

image-20240119231320090

果然生成了对应的 .class文件。

image-20240119231415635

.class文件内容就是 java 源代码翻译成的字节码内容,其中存储了源码文件中的类属性、方法、常量等信息。

有了这个 .class文件,就可以执行我们编写的代码了:

12fdhlw-0

那么这个 .class文件实际都存储了哪些内容?是不是真的如上面所说包含源代码的类属性、方法、常量等信息?

想要查看 .class文件中的内容不能使用平常的文本编辑器打开,否则你只会得到满屏的乱码。

专业的事情就得交给专业的工具去做,要想查看 .class文件内容需要使用 JDK 自带的反编译工具 javap

javap 是 Java 编程语言中的一个命令行工具,用于反编译 Java 类文件。它能够显示 Java 类文件的字节码指令、常量池、方法和字段等信息。通过运行 javap 命令,开发人员可以查看编译后的 Java 类文件的内容,这对于理解代码、调试和分析 Java 程序非常有用。

image-20240119233545808

试着用 javap -c指令反编译刚刚生成的 .class文件:

image-20240119233842062

执行命令后输出了一连串的指令,这些指令就是 Java 虚指令, 供 JVM 使用,程序运行时被 JVM 转换成具体平台的机器指令后再执行。

 // 表示从 ByteCodeSample.java 文件编译而来
Compiled from "ByteCodeSample.java"
// 定义 ByteCodeSample,带有包名
public class cool.ldw.javabasic.bytecode.ByteCodeSample {
  // 编译器自动生成的无参构造函数
  public cool.ldw.javabasic.bytecode.ByteCodeSample();
    // Code 表示要执行的内容
    Code:
       // 将 this 加载到操作数栈上
       0: aload_0
       // 调用父类 Object 的构造方法做初始化操作
       1: invokespecial #1                  // Method java/lang/Object."<init>":()V
       // 退出构造函数方法执行
       4: return
  // main 函数,参数的是字符串数组
  public static void main(java.lang.String[]);
    // Code 表示要执行的内容
    Code:
       // 将常量 1 放入栈顶
       0: iconst_1
       // 将栈顶的值放入局部变量1中
       1: istore_1
       // 将常量 5 放入栈顶
       2: iconst_5
       // 将栈顶的值放入局部变量2中
       3: istore_2
       // 将局部变量 1 加 1
       4: iinc          1, 1
       // 将局部变量 2 加 1
       7: iinc          2, 1
      // 获取 PrintStream 静态域对象并压入栈顶
      10: getstatic     #7                  // Field java/lang/System.out:Ljava/io/PrintStream;
      // 将局部变量1的值压入栈顶
      13: iload_1
      // 使用 PrintStream 的 println 打印局部变量1的值
      14: invokevirtual #13                 // Method java/io/PrintStream.println:(I)V
      // 后面三个操作同以上三个操作
      17: getstatic     #7                  // Field java/lang/System.out:Ljava/io/PrintStream;
      20: iload_2                                                   
      21: invokevirtual #13                 // Method java/io/PrintStream.println:(I)V
      // 退出方法执行
      24: return                                                    
}

查看完生成的虚指令后可以得出,当没有写类的构造函数时,编译器会默认生成一个无参的构造函数。

接着来验证下 .class是否可以运行在别的平台上。

将其拷贝到一台装有 Ubuntu 22.04 的 Linux 机器上并运行,注意:需要装有相同版本的 JDK 或者 JRE,并且需要创建相同的包结构,否则无法执行。

image-20240120001917046

由此证明确实能够跨平台执行!

如果所在目录不对或者直接执行的话:

image-20240120002307901

最后总结下 Java 是如何做到编译一次,到处运行的:

Java 源码首先被编译成字节码,再由不同平台的 JVM 进行解析,Java 语言在不同的平台上运行时不需要重新编译,Java 虚拟机在执行字节码的时候,把字节码转换成具体平台的机器指令后再执行。

为什么 JVM 不直接将源码解析成机器码执行?

  • 准备工作:每次执行源码都要再重复的进行编译,而且编译时需要校验补全等操作,会降低性能。
  • 兼容性:还可以脱离 Java 的束缚,将别的语言如 groovy ,Scala 解析成字节码由 JVM 解释执行,增加平台的兼容扩展能力,符合软件设计的中庸之道。

JVM 如何加载 .class 文件? {#jvm-如何加载-class-文件}

要想知道这个问题的答案,得先了解一下 Java虚拟机(JVM)的组成。

JVM 就像是一台虚拟计算机,它在实际计算机的基础上模拟计算机的各种功能。JVM不仅有自己的一套"硬件",包括处理器、堆栈、寄存器等,还有一套自己的指令系统。它可以屏蔽掉与具体操作系统平台相关的信息,因此我们写的 Java 程序仅需生成对应的 .class字节码文件,就可以在各种平台上跑起来。

JVM 实际上就是内存中的虚拟机,JVM 存储就是内存,我们编写的类、方法、常量、变量等都在这个内存中。它直接影响程序的运行是否健壮和高效。

对于我们来说需要重点关注两个方面,JVM 的内存结构和垃圾回收机制(GC)。这两方面不仅是面试的重点,也是程序调优的关键。

整个 JVM 大致分为四个部分,分别是:

  • Class Loader:依据特定格式,加载 .class文件到内存。
  • Execution Engine:对命令进行解析,解析完之后就提交到具体的操作系统中执行。
  • Native Interface:融合不同开发语言的原生库为 Java 所用,就是可以调用其它语言的库。
  • Runtime Data Area:JVM 内存空间结构模型。该模型的设计堪称神作。

image-20240120013018296

对于 Native Interface 我们来刷个副本看一个实际的例子,如 Class.forName()方法:返回给定字符串的类或接口的 Class 对象。点进它的源码看一下:

image-20240120014301473

它调用了 forName0()方法,继续点进去看看:

image-20240120014357693

发现 forName0()是一个被 native关键字修饰的接口,也就是上面说的 Native Interface,由此证明 Java 的原生方法会用到一些本地的库函数,它以这样的方式去调用 C++ 或别的语言的库函数。

在Java中,native 是一个关键字,用于表示一个方法是用非Java语言(通常是C、C++或其他本地语言)实现的,并且其实现由外部库或系统提供。这种方法称为本地方法(Native Method)。

使用 native 关键字的方法声明没有具体的实现,而是通过在Java代码中声明该方法,然后在外部使用本地语言编写的代码中实现它。在运行时,Java虚拟机通过本地方法接口(JNI)与外部本地库进行交互,以执行该方法。

使用 native 关键字的主要场景包括:

  1. 与底层系统交互:通过本地方法,Java程序可以调用底层系统的功能,如操作系统的API或硬件特性。
  2. 性能优化:有些任务用本地语言实现可能比Java更高效,通过 native 方法,可以在Java程序中调用这些性能关键的本地实现。

需要注意的是,使用 native 关键字会使得代码失去了平台无关性,因为本地方法的实现通常是与特定平台相关的。此外,使用 native 方法需要谨慎,因为它涉及到与本地代码的交互,可能引入不安全的操作,需要确保本地实现的正确性和安全性。

最后来总结下 JVM 如何加载 .class 文件:

JVM 主要由 Class Loader、Runtime Data Area、Execution Engine、Native Interface 四个部分组成,主要通过 Class Loader 将符合其格式的 .class文件加载到内存中,然后使用 Execution Engine 解析其中的字节码,最终提交给操作系统执行。

谈谈反射 {#谈谈反射}

Java 反射机制是在运行状态中,对于任意一个类,都能够知道这个类的所有属性和方法;对于任意一个对象,都能够调用它的任意属性和方法;这种动态获取信息以及动态调用对象方法的功能称为 Java 语言的反射机制。

列举出几个常用的反射函数或写一个反射的例子 {#列举出几个常用的反射函数或写一个反射的例子}

写个 demo 回忆下都有哪些常用的反射函数。

首先定义一个类,包含一个私有属性,一个公有方法和一个私有方法来供我们反射使用:

public class Robot {
    private String name;

    public void sayHi(String helloSentence) {
        System.out.println(helloSentence + " " + name);
    }

    private String throwHello(String tag) {
        return "Hello " + tag;
    }
}

编写测试反射的类:

public class ReflectSample {
    public static void main(String[] args) throws ClassNotFoundException, InstantiationException, IllegalAccessException, NoSuchMethodException, InvocationTargetException, NoSuchFieldException {
        // 获取 Robot 类
        Class<?> rc = Class.forName("cool.ldw.javabasic.reflect.Robot");
        // 创建 Robot 实例
        Robot r = (Robot) rc.newInstance();
        // 打印下类的名称
        System.out.println("Class name is " + rc.getName());

        /*
        getDeclaredMethod 可以获取该类的所有方法包括私有和公共的方法,但是无法获取继承来的或实现接口的方法。
         */
        // 反射获取私有方法 throwHello,第二个参数是目标方法即 throwHello 方法的参数
        Method getHello = rc.getDeclaredMethod("throwHello", String.class);
        // 由于 throwHello 是私有方法,需要设置其为可访问
        getHello.setAccessible(true);
        // 调用 throwHello 方法
        Object str = getHello.invoke(r, "Bob");
        System.out.println("getHello result is " + str);

        /*
        getMethod 只能获取该类的公共方法,但是还能获取继承得到或实现接口的公共方法。
         */
        // 反射获取公共方法 sayHi,第二个参数是目标方法即 sayHi 方法的参数
        Method getSayHi = rc.getMethod("sayHi", String.class);
        getSayHi.invoke(r, "Welcome");

        // 获取私有属性
        Field name = rc.getDeclaredField("name");
        name.setAccessible(true);
        name.set(r, "Alice");
        getSayHi.invoke(r, "Welcome");
    }
    /* 输出:
    Class name is cool.ldw.javabasic.reflect.Robot
    getHello result is Hello Bob
    Welcome null
    Welcome Alice
     */
}

常用的反射方法有:

  • Class.forName():根据类的全限定名加载类。
  • newInstance(): 创建类的一个新实例。
  • invoke(Object obj, Object... args): 调用方法。
  • getDeclaredMethod / getDeclaredMethodS:用于获取类的所有方法,包括公共、保护、默认(包内可见)和私有方法,但不包括从父类继承的方法。
  • getMethod / getMethods:用于获取类的公共方法(public方法),包括从父类继承的公共方法。
  • setAccessible:设置对字段、方法或构造方法的访问权限
  • getDeclaredField / getDeclaredFields: 用于获取类的所有字段,包括公共、保护、默认(包内可见)和私有字段,但不包括从父类继承的字段。
  • getField / getFields: 用于获取类的公共字段(public字段),包括从父类继承的公共字段。

类从编译到执行的过程 {#类从编译到执行的过程}

拿上面创建的 Robot.java举例:

  1. 编译器将 Robot.java源文件编译为 Robot.class字节码文件。
  2. ClassLoader 将字节码转换为 JVM 中的 Class<Robot>对象。
  3. JVM 利用 Class<Robot>类对象实例化为 Robot对象。

谈谈什么是 ClassLoader? {#谈谈什么是-classloader}

ClassLoader 在 Java 中有着非常重要的作用,它主要工作在 Class 加载阶段,其主要作用是从系统外部获得 Class 二进制数据流。它是 Java 的核心组件,所有的 .class字节码文件都是由 ClassLoader 进行加载的 。

ClassLoader 负责通过将 .class文件里的二进制数据流加载进系统,然后交给 Java 虚拟机进行连接、初始化等操作。

了解完作用之后,接着来看 ClassLoader 都有哪些种类。

  • BootStrapClassLoader

    主要作用是加载 JVM 运行时需要的核心类库核心库 java.*(如 Java.lang 包),这个加载器是虚拟机自身的一部分,并且在 JVM 启动时就被初始化,由 C++ 编写,有了它才会有后面其它种类的 ClassLoader 存在。

    这个类中有非常多的方法,其中最重要的是 loadClass()方法,作用是加载指定的类,即给定一个类名,会去找到这个类,然后加载这个类。

    寻找类的功能由 findClass()方法实现,加载类的功能由 defineClass()方法实现,详细用法可看下面自定义 ClassLoader 中的 demo。

    image-20240123000503656

  • ExtClassLoader

    主要作用是加载 Java 虚拟机的扩展类库 javax. *,由 Java 编写,所以这个类不像上面的 BootStrapClassLoader 无法看到。

    直接使用 IDEA 搜索 ExtClassLoader就可以看到它的源码。

    image-20240122223302772

    从源码中能看到它获取 .class字节码文件的路径是 System.getProperty("java.ext.dirs"),打印下看看:

    System.out.println(System.getProperty("java.ext.dirs"));
    /* 输出:
    C:\Program Files\Java\jdk1.8.0_211\jre\lib\ext;C:\Windows\Sun\Java\lib\ext
    */
    

    ExtClassLoader就会到这些路径下查看有无 .class文件,有的话就会将其加载,但并不是一次性全部都加载完,只有用到的时候才会按需加载。

  • AppClassLoader

    主要作用是加载应用程序中的类,即我们自己在项目中编写的类,也是由 Java 编写的。

    照葫芦画瓢,也来查看下它的源码,看看它都加载哪些路径下的 .class文件。

    image-20240122224206140

    打印出来的结果如下:

    System.out.println(System.getProperty("java.class.path"));
    /* 输出:
    C:\Program Files\Java\jdk1.8.0_211\jre\lib\charsets.jar;C:\Program Files\Java\jdk1.8.0_211\jre\lib\deploy.jar;C:\Program Files\Java\jdk1.8.0_211\jre\lib\ext\access-bridge-64.jar;C:\Program Files\Java\jdk1.8.0_211\jre\lib\ext\cldrdata.jar;C:\Program Files\Java\jdk1.8.0_211\jre\lib\ext\dnsns.jar;C:\Program Files\Java\jdk1.8.0_211\jre\lib\ext\jaccess.jar;C:\Program Files\Java\jdk1.8.0_211\jre\lib\ext\jfxrt.jar;C:\Program Files\Java\jdk1.8.0_211\jre\lib\ext\localedata.jar;C:\Program Files\Java\jdk1.8.0_211\jre\lib\ext\nashorn.jar;C:\Program Files\Java\jdk1.8.0_211\jre\lib\ext\sunec.jar;C:\Program Files\Java\jdk1.8.0_211\jre\lib\ext\sunjce_provider.jar;C:\Program Files\Java\jdk1.8.0_211\jre\lib\ext\sunmscapi.jar;C:\Program Files\Java\jdk1.8.0_211\jre\lib\ext\sunpkcs11.jar;C:\Program Files\Java\jdk1.8.0_211\jre\lib\ext\zipfs.jar;C:\Program Files\Java\jdk1.8.0_211\jre\lib\javaws.jar;C:\Program Files\Java\jdk1.8.0_211\jre\lib\jce.jar;C:\Program Files\Java\jdk1.8.0_211\jre\lib\jfr.jar;C:\Program Files\Java\jdk1.8.0_211\jre\lib\jfxswt.jar;C:\Program Files\Java\jdk1.8.0_211\jre\lib\jsse.jar;C:\Program Files\Java\jdk1.8.0_211\jre\lib\management-agent.jar;C:\Program Files\Java\jdk1.8.0_211\jre\lib\plugin.jar;C:\Program Files\Java\jdk1.8.0_211\jre\lib\resources.jar;C:\Program Files\Java\jdk1.8.0_211\jre\lib\rt.jar;D:\javabasic\out\production\javabasic;C:\Program Files\JetBrains\IntelliJ IDEA 2023.2.3\lib\idea_rt.jar
    */
    

    所以它默认会加载以上这么多路径下的 .class文件。

    其中包含了当前项目的路径,倒数第二个 D:\javabasic\out\production\javabasic,这里面就存放了当前项目下的 .class文件。

    image-20240122224927330

  • 自定义 ClassLoader

    由 Java 编写,允许我们实现特定的类加载逻辑,例如从非标准位置加载类文件、加密类文件、从网络获取类文件等。

    下面我们就来动手实现一个简单的 demo,编写自己的 ClassLoader。

在编写自定义 ClassLoader 之前需要了解两个关键的函数:

  • findClass(),这个函数作用是寻找 .class文件 ,包括如何读取 .class文件的二进制流并如何处理,最终返回 Class 对象。

image-20240122230727252

  • defineClass(),这个函数作用是加载类,读取的字节码传递给这个方法,就可以得到对应的 Class 类对象。

image-20240122233341511

通过重写以上两个方法就能够编写出自定义的 ClassLoader

具体步骤是:

  1. 首先在 findClass()函数中找到目标 .class文件,使用文件流读取并转化成字节数组。
  2. 然后将字节数组传递给 defineClass()函数进行解析,获取 Class 对象。

光说不练假把式,现在我们就着手实操一下。

大致步骤是:先在项目外的任意位置创建一个类并使用 javac编译生成 .class文件,然后使用项目中的编写的自定义 ClassLoader 读取这个 .class类文件,并最终获得其 Class对象

在项目外创建一个测试类(以D盘根目录为例):

image-20240122232543232

代码很简单,只是在类初始化的时候打印 Hello Wali

使用 javac命令进行编译。

image-20240122232848420

在项目中编写自定义 ClassLoader 类。

public class MyClassLoader extends ClassLoader {

    /**
     * 加载那个目录下的文件
     */
    private final String path;

    public MyClassLoader(String path) {
        this.path = path;
    }

    /**
     * 用于寻找类文件
     *
     * @param name 类文件名称
     * @return Class 对象
     */
    @Override
    protected Class<?> findClass(String name) {
        byte[] b = loadClassData(name);
        return defineClass(name, b, 0, b.length);
    }

    /**
     * 用于加载类文件,并转成字节数组
     *
     * @param name 类文件名称
     * @return 加载后的字节数据
     */
    private byte[] loadClassData(String name) {
        // 拼接 .class 文件绝对路径
        name = path + name + ".class";

        // 读取 .class 文件并转成字节数组返回
        try (
                InputStream in = Files.newInputStream(Paths.get(name));
                ByteArrayOutputStream out = new ByteArrayOutputStream()
        ) {
            int i;
            while ((i = in.read()) != -1) {
                out.write(i);
            }
            // 返回字节数组
            return out.toByteArray();
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }
}

最后编写测试类:

public class ClassLoaderChecker {
    public static void main(String[] args) throws ClassNotFoundException, InstantiationException, IllegalAccessException {
        MyClassLoader m = new MyClassLoader("D:\\");
        // 调用父类中的 loadClass 方法,它会调用我们刚刚覆盖的 findClass 方法,获取 Wali 类对象
        Class c = m.loadClass("Wali");
        System.out.println(c.getClassLoader());

        // 触发其中的 static 静态代码块
        c.newInstance();
    }
    /* 输出:
    cool.ldw.javabasic.reflect.MyClassLoader@4eec7777
    Hello Wali
     */
}

从执行结果看,编写的自定义 ClassLoader 成功加载了 Wali类,并成功触发其静态代码块中的内容。

在实现 findClass()函数时,不仅可以读取本地目录下的 .class文件,也可以通过网络远程加载 .class文件,只要传递给 defineClass的是合法的二进制流字节数组即可。它有如下使用场景:加密敏感的 .class文件,使用时再通过 findClass()对其进行解密;还可以对二进制流做手脚,修改其信息增加其功能等,这也是字节码增强技术(ASM)的原理。

ASM(Objectweb ASM)是一个用于在字节码层面操作Java类文件的框架。它允许开发者以程序化的方式访问和修改Java类的字节码,包括添加、删除、替换类的方法、字段,以及调整类的结构。ASM 的全称是 "Abstract Syntax Model",它提供了一种抽象的语法模型,用于表示和操作字节码。

以下是 ASM 的一些主要特点和用途:

  1. 底层字节码操作: ASM 允许在字节码层面进行底层的操作,而不需要加载类到内存中。这使得开发者可以直接处理字节码,进行精细的控制和修改。
  2. 轻量级: ASM 是一个轻量级的库,它的设计目标是高性能和低内存占用。它避免了一些其他框架可能引入的不必要的开销。
  3. 广泛用于字节码工程: ASM 在许多字节码工程和代码生成的场景中得到广泛应用,包括编译器、代码生成工具、AOP(面向切面编程)框架等。
  4. 扩展性: ASM 提供了丰富的 API,允许开发者根据需要定制字节码操作的逻辑。它支持访问和修改类的结构、字段、方法,以及操作指令等。
  5. 适用于动态代理和字节码增强: ASM 在创建动态代理、实现字节码增强(如在运行时生成新的类或修改已有类的字节码)方面非常强大,被很多框架(如Spring)用于实现各种增强功能。

ASM 的使用需要对字节码有一定的了解,因为它提供了一种底层的、灵活的方式来操作字节码。由于其性能和灵活性,ASM 在一些需要对Java字节码进行深度定制的场景中得到广泛应用。

什么是双亲委派机制 {#什么是双亲委派机制}

首先来看下它的原理图:

Default Canvas

从图中可以看出它的加载方式,先是自底向上检查类是否已经被加载过,有则直接返回,没有再自顶向下尝试加载目标类,如果到最后一步都没能成功加载类就会抛出 ClassNotFoundException异常。

比如我们需要加载 Robot.class,首先会从自定义 ClassLoader 也就是图中的 Custom ClassLoader开始检查曾经有没有加载过 Robot.class类,如果有加载过就会直接返回,若是没有加载过就会委派给它的 parent 也就是 App ClassLoader进行检查 ,如此类推到最顶层由 C++ 实现的 Bootstrap ClassLoader检查曾经是否加载过该类。

此时,如果还是没有检查到曾经加载过该类,那么就会由 Bootstrap ClassLoader尝试从 JRE\lib\rt.jar或从 -Xbootclasspath参数指定的目录下尝试加载 Robot.class文件,同样的步骤再往下类推,直到最终抛出 ClassNotFoundException异常。

看完原理图再来探究其源码,验证上图中流程的真伪。使用 IDEA 打开 java.lang.ClassLoader类。

查看其中加载类的关键方法 loadClass()

public Class<?> loadClass(String name) throws ClassNotFoundException {
    return loadClass(name, false);
}

它调用了其重载方法,重点来看这个重载方法。

protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException { // 可能会有多有线程调用同一个 ClassLoader 加载同一个类,所以这里加同步锁 synchronized (getClassLoadingLock(name)) { // 查找本身是否加载过 class 类,如果加载过再加上传递过来的 resolve 参数为 false,就会直接返回这个 c Class<?> c = findLoadedClass(name);
if (c == null) {
long t0 = System.nanoTime();
try {
// 如果没有加载过,就会调用其 parent(即App ClassLoader) 的 loadClass 方法,再次执行相同的代码段,以此类推直到 BootstrapClassLoader
if (parent != null) {
c = parent.loadClass(name, false);
} else {
// 由于 Bootstrap ClassLoader 是由 C++ 实现的,无法使用 Java 对象表示
// 所以当 parent == null 时就直接调用 Bootstrap ClassLoader
// 首先会检查是否加载过该类,若有则直接返回,没有则会尝试从其对应目录下寻找该类,如果有就将其加载进来,没有就返回上层告诉 Extension ClassLoader 没有
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// 捕获 parent 中 loadClass 方法抛出的 ClassNotFoundException 异常
// 因为它说没找到不代表真的没有该类,不能直接抛出异常中断运行,得等到 Custom ClassLoader 也说没找到才说明真的没有该类
}

if (c == null) {
            long t1 = System.nanoTime();
            // 分别调用 Extension ClassLoader、App ClassLoader、Custom ClassLoader 自定义的 findClass 方法寻找该类是否存在
            // 关于 Custom ClassLoader 如何编写 findClass 方法进行覆盖可参考上一小节的 demo
            // 如果最后的 Custom ClassLoader 的 findClass 都没找到则抛出 ClassNotFoundException
            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;
}

}

看完这个方法的源码解析,不知道大家有没有这样一个疑惑,如何确定 Custom ClassLoader上层就是 App ClassLoader -> Extension ClassLoader -> Bootstrap ClassLoader

正好,通过在上节编写的 Custom ClassLoader案例,打印其 parent属性就能得到上层究竟是什么 ClassLoader

public static void main(String[] args) throws ClassNotFoundException {
    MyClassLoader m = new MyClassLoader("D:\\");
    Class c = m.loadClass("Wali");
    System.out.println(c.getClassLoader());
    System.out.println(c.getClassLoader().getParent());
    System.out.println(c.getClassLoader().getParent().getParent());
    System.out.println(c.getClassLoader().getParent().getParent().getParent());
}
/* 输出:
cool.ldw.javabasic.reflect.MyClassLoader@4eec7777
sun.misc.Launcher$AppClassLoader@18b4aac2
sun.misc.Launcher$ExtClassLoader@4554617c
null
 */

最后一个输出为 null是因为 Bootstrap ClassLoader是由 C++ 实现的,无法使用 Java 对象表示。

此外我还有一个疑惑,如何确定 c = findBootstrapClassOrNull(name);就是调用了 BootstrapClass?我们继续点进去这个方法中看看。

private Class<?> findBootstrapClassOrNull(String name)
{
    if (!checkName(name)) return null;

    return findBootstrapClass(name);
}

再点入 findBootstrapClass方法看看。

private native Class<?> findBootstrapClass(String name);

发现又是一个 native方法,没办法再往下点了。

还记得 native修饰的方法吗?我们来回忆下:

在Java中,native 是一个关键字,用于表示一个方法是用非Java语言(通常是C、C++或其他本地语言)实现的,并且其实现由外部库或系统提供。这种方法称为本地方法(Native Method)。

使用 native 关键字的方法声明没有具体的实现,而是通过在Java代码中声明该方法,然后在外部使用本地语言编写的代码中实现它。在运行时,Java虚拟机通过本地方法接口(JNI)与外部本地库进行交互,以执行该方法。

使用 native 关键字的主要场景包括:

  1. 与底层系统交互:通过本地方法,Java程序可以调用底层系统的功能,如操作系统的API或硬件特性。
  2. 性能优化:有些任务用本地语言实现可能比Java更高效,通过 native 方法,可以在Java程序中调用这些性能关键的本地实现。

需要注意的是,使用 native 关键字会使得代码失去了平台无关性,因为本地方法的实现通常是与特定平台相关的。此外,使用 native 方法需要谨慎,因为它涉及到与本地代码的交互,可能引入不安全的操作,需要确保本地实现的正确性和安全性。

不让继续点下去这就有点不爽了,但是别慌还有办法。

访问这个网址:https://hg.openjdk.org/jdk8u/jdk8u/jdk,可以查看 openJDK8 的源码。

image-20240123214532414

我们进入到 ClassLoader所在的目录,其中 share目录表示存放不依赖特定平台的代码。

image-20240123214802641

可以看到 ClassLoader是由 C 语言实现的,搜索其中的 findBootstrapClass方法。

image-20240123215029687

具体代码逻辑就不细究了,在最后可以看到调用了 JVM_FindClassFromBootLoader,这个方法最终就会调用 C++ 代码,从 Bootstrap ClassLoader中查找目标类。

通过以上的一顿操作,相信大家一定明白了双亲委派机制。

为什么使用双亲委派机制去加载类 {#为什么使用双亲委派机制去加载类}

  • 避免多份同样字节码的加载,内存是宝贵的,没必要保存相同的 class 对象。

loadClass 和 forName 的区别 {#loadclass-和-forname-的区别}

在探究二者的区别之前,首先来了解下类的两种加载方式

  • 隐式加载:我们最常用的使用 new关键字创建类对象的方式。
  • 显示加载:使用 loadClassforName等方法加载类的方式。

使用 new关键字隐式加载,可直接获取类对象的实例,并且还可以使用有参构造器生成对象实例,而使用 loadClass()forName()方法获取的 Class实例需要使用 newInstance()方法并且不支持传入参数,需要通过反射调用构造器对象的 newInstance()方法才支持传递参数。

了解完类的两种加载方式之后,我们再来简单了解下类的装载过程。

这里规定装载 表示 Class对象的生成过程,加载为其一个部分。

装载过程如下:

  1. 加载:通过 ClassLoader加载 .class文件字节码,生成 Class对象。
  2. 链接:分为三个小步骤。
    1. 校验:检查加载的 class 的正确性和安全性。
    2. 准备:为类变量(即静态变量)分配存储空间并设置类变量初始值(初始值表示静态变量类型的默认值而不是实际要赋的值)。
    3. 解析(可选):JVM 将常量池内的符号引用转换为直接引用。
  3. 初始化:执行类变量赋值和静态代码块。

了解完类的装载过程后,我们来看下 ClassLoaderforName都是如何装载类的。

首先看看 ClassLoader中的 loadClass方法。

public Class<?> loadClass(String name) throws ClassNotFoundException {
    return loadClass(name, false);
}

依旧是看 loadClass的重载方法。

image-20240123223227618

这次重点来看其中的 resolve参数,从其方法注释中可以了解到这个参数的作用是决定是否解析(即链接)Class类对象。

如果为 true则交由 resolveClass方法来链接 Class

image-20240123223432061

由于传递过来的 resolve参数值为 false表明 loadClass(String name)不会链接 Class

看完 loadClass再来看 forName

image-20240123223712271

在其源码中能看到传递给 forName0方法中的 initialize参数的值为 true,表示要进行初始化操作,即装载过程的第三步

由此可以得出:

  • Class.forName得到的 Class是已经初始化完成的。
  • ClassLodaer.loadClass得到的 class是还没有链接的。

通过下面这个 demo 进一步验证二者的区别。

改造之前的 Robot.java,加载一段静态代码块(因为初始化的时候会执行静态代码块),内容如下:

public class Robot {
    private String name;

    public void sayHi(String helloSentence) {
        System.out.println(helloSentence + " " + name);
    }

    private String throwHello(String tag) {
        return "Hello " + tag;
    }

    static {
        System.out.println("Hello Robot");
    }
}

首先使用 ClassLoader加载类,没有任何输出,表明 Robot.java其中的静态代码块内容没有被执行。

public static void main(String[] args) {
    ClassLoader cl = Robot.class.getClassLoader();
}

再使用 Class.forName加载类,其中的静态代码块中的内容被执行了,表明 Class.forName会对类进行初始化。

public static void main(String[] args) throws ClassNotFoundException {
    Class<?> r = Class.forName("cool.ldw.javabasic.reflect.Robot");
}
/* 输出:
Hello Robot
 */

这里我们已经知道了它俩的区别,但是光知道区别还不够,还得知道区别的作用。

对于 Class.forName以 MySQL 驱动为例,在 com.mysql.jdbc.Driver中注册数据库驱动的代码是写在静态代码块中的,所以就得需要 Class.forName来加载此 Driver类。

public class Driver extends NonRegisteringDriver implements java.sql.Driver {
	public Driver() throws SQLException {   
    }
  
    static {
        try {
            DriverManager.registerDriver(new Driver());
        } catch (SQLException E) {
            throw new RuntimeException("Can't register driver!");
        }
    }
  
}

对于 ClassLoader以 Spring IOC 为例,Spring IOC 为了加快初始化速度,大量使用了延时加载技术,而使用 ClassLoader不需要对类进行链接和初始化步骤,将其留到实际使用时再做,因此可以大幅提升加载速度。

最后来总结下 loadClassforName的区别:

  • 相同点:都能在运行时加载类,都能获取类的所有属性和方法,对于任意一个对象都能调用其任意属性和方法。
  • 不同点:通过 loadClass加载类时,类的静态代码块不会被执行,不会进行链接和初始化的步骤,而通过 forName加载类时,类的静态代码块会被执行,类也会被初始化。

你了解 Java 的内存模型吗? {#你了解-java-的内存模型吗}

拿之前小节的一张图来看:

image-20240120013018296

图中的Runtime Data Area代表的就是 Java 内存模型。

这里基于 JDK8 为例,以线程和存储的角度分别来研究 Java 的内存模型结构。

Java 内存模型之线程独占部分 {#java-内存模型之线程独占部分}

首先以线程的角度来看,根据线程私有和线程共享的区域来分类。

image-20240124200248571

  • 线程私有:程序计数器、虚拟机栈、本地方法栈
  • 线程共享:MetaSpace、Java 堆

将每一个模块单独拎出来分析。

首先来看程序计数器(Program Counter Register)

程序计数器是一块较小的空间,表示的是当前线程所执行字节码的行号指示器,它是逻辑计数器而非物理计数器,通过改变其值来选取下一条需要执行的字节码指令,包括分支、循环、跳转、异常处理、线程恢复等基础功能。

由于 JVM 的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的,在任何一个时刻处理器只会执行一个线程中的一条指令,因此为了线程切换后能恢复到正确的执行位置,所以每个线程需要独立的程序计数器,各条线程之间的计数器独立存储,互不影响。

如果线程正在执行 Java 方法则程序计数器的值为 Java 虚拟机正在执行字节码指令的地址(行号),如果正在执行 Native 方法则计数器的值为 Underfined,也就是说只为 Java 方法计数。

程序计数器只是用来记录字节码的行号,所以不会发生内存泄漏的问题。

看完程序计数器接着来看 Java 虚拟机栈(Stack)

Java 虚拟机栈(Stack) 是 Java 方法执行的内存模型,每个 Java 方法执行时都会创建一个栈帧(即方法运行过程中的基础数据结构),所以 Java 虚拟机栈(Stack) 中会包含多个栈帧,栈帧用于存储局部变量表、操作栈、动态连接、返回地址(方法出口)等。

每个 Java 方法执行都对应着虚拟机栈帧从入栈到出栈的过程,Java 虚拟机栈(Stack) 用于存储栈帧,而栈帧持有局部变量和部分结果以及参与方法的调用与返回,当方法调用结束时帧才会被销毁。

image-20240124222140200

局部变量表和操作数栈的区别:

  • 局部变量表:包含方法执行过程中的所有变量;
  • 操作数栈:在执行字节码指令过程中会用到,类似原生 CPU 中的寄存器,大部分 JVM 字节码把时间花费在操作数栈的操作上,包括入栈、出栈、复制、交换、产生消费变量。

因此局部变量数组和操作数栈之间的交换变量指令操作通过字节码频繁执行,由于栈后进先出的特性,当前执行的方法会在栈的顶部,每次方法中的指令执行时都会创建新的栈帧并压栈到栈顶,当方法执行完成或遇到未捕获的异常时就会出栈,除了压栈和出栈之外,栈不能被直接操作。

说了这么多,来看一个简单的例子,看看字节码指令是如何执行的,顺便回顾之前小节的例子。

先编写一个简单的类 ByteCodeSample

image-20240124232128839

进入到 src目录下使用 javac命令进行编译:javac cool/ldw/javabasic/jvm/model/ByteCodeSample.java

编译完成之后,使用 javap命令对生成的 .class文件反编译,查看其 JVM 指令,在之前的小节中有使用过 -c参数进行反编译,这里我们换一个参数,使用 -verbose参数进行反编译,这个参数的意思是使用口语化的形式描述 .class文件内容。

执行 javap -verbose cool/ldw/javabasic/jvm/model/ByteCodeSample,得到如下结果:

image-20240124230548490

尝试解析一下其中的内容,看看能不能看懂:

// 文件信息,包含是从哪里编译来的
Classfile /home/ldwcool/javabasic/src/cool/ldw/javabasic/jvm/model/ByteCodeSample.class
  Last modified 2024-1-24; size 299 bytes
  MD5 checksum ba760d200056f891c269fb6f13900006
  Compiled from "ByteCodeSample.java"
// 描述类的信息
public class cool.ldw.javabasic.jvm.model.ByteCodeSample
  minor version: 0
  major version: 52
  // 表示是共有的且继承 Object
  flags: ACC_PUBLIC, ACC_SUPER
// 常量池信息
Constant pool:
   #1 = Methodref          #3.#12         // java/lang/Object."<init>":()V
   #2 = Class              #13            // cool/ldw/javabasic/jvm/model/ByteCodeSample
   #3 = Class              #14            // java/lang/Object
   #4 = Utf8               <init>
   #5 = Utf8               ()V
   #6 = Utf8               Code
   #7 = Utf8               LineNumberTable
   #8 = Utf8               add
   #9 = Utf8               (II)I
  #10 = Utf8               SourceFile
  #11 = Utf8               ByteCodeSample.java
  #12 = NameAndType        #4:#5          // "<init>":()V
  #13 = Utf8               cool/ldw/javabasic/jvm/model/ByteCodeSample
  #14 = Utf8               java/lang/Object
{
  // 编译器自动生成的无参构造函数,之前有解析过
  public cool.ldw.javabasic.jvm.model.ByteCodeSample();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=1, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."<init>":()V
         4: return
      LineNumberTable:
        line 3: 0
  
  // 这是本次要重点研究的方法
  public static int add(int, int);
    // 对方法的描述,表示接收两个 int 类型参数,返回 int 类型结果
    descriptor: (II)I
    // 表示方法是 public 和 static
    flags: ACC_PUBLIC, ACC_STATIC
    // 重要信息在下面
    Code:
      // 操作数栈深度为 2;局部变量数组容量是 3;方法参数的个数为 2 个
      stack=2, locals=3, args_size=2
         // 下面的是 JVM 指令,前面的序号表示字节码的行数。通过后面的图我们再详细介绍这部分
         0: iconst_0
         1: istore_2
         2: iload_0
         3: iload_1
         4: iadd
         5: istore_2
         6: iload_2
         7: ireturn
      // 行号对应
      LineNumberTable:
        // 代码第 5 行对应字节码的第 0 行,下同
        line 5: 0
        line 6: 2
        line 7: 6
}
SourceFile: "ByteCodeSample.java"

对于其中的 JVM 指令我们通过下图来详细说明:

假设执行了 add(1, 2)

PixPin_2024-01-24_23-33-27

在一一分析之前先整体看一下。

局部变量数组容量是 3;操作数栈深度为 2;一整个长方形代表一个栈帧,共 7 个栈帧。

虚拟机栈会按照程序计数器从大到小依次压栈,上图中从右往左的顺序,由于栈后进先出的特性,所以执行的时候就会从小到大依次执行。

具体执行步骤如下:

  1. 首先执行 icont_0指令,将 int 值 0 压入操作数栈中;同时方法入参是 1 和 2,所以局部变量表中就包含了 2 个变量,index_0(表示第 0 个变量,下同) 的变量值是 1,index_1 的变量值是 2。
  2. 执行 istore_2指令,将操作数栈栈顶元素 0 出栈存入到局部变量表 index_2 的位置。
  3. 执行 iload_0指令,将局部变量表 index_0 的值压入操作数栈。
  4. 执行 iload_1指令,将局部变量表 index_1 的值再次压入操作数栈。
  5. 执行 iadd指令,出栈两个操作数并执行加法运算,将计算结果 3 再压入操作数栈中。
  6. 执行 istore_2指令,将操作数栈栈顶元素 3 出栈存入到局部变量表 index_2 的位置,替换了之前为 0 的值。
  7. 执行 iload_2指令,将局部变量表 index_2 的值 3 压入操作数栈。
  8. 执行 ireturn指令,将操作数栈栈顶元素返回,结束方法执行,销毁所有的栈帧。

上面的解析,很清楚的描述了局部变量表和操作数栈之间是如何交互的,所以局部变量表主要是为操作数栈提供必要的数据支撑。

理解了上面的内容之后,我们来思考下这个问题:递归为什么会引发 java.lang.StackOverflowError异常?

写一个 demo 复现此异常的场景,以斐波那契数列为例。

当提到斐波那契数列时,通常指的是一个数学数列,其定义如下:

[ F(0) = 0, ] [ F(1) = 1, ] [ F(n) = F(n-1) + F(n-2) ]

换句话说,斐波那契数列的第 n 个数是前两个数之和,其中第0和第1个数分别为0和1。

斐波那契数列的前几个数是:0, 1, 1, 2, 3, 5, 8, 13, 21, 34, ... 以此类推。

image-20240125000232814

引发这个异常的原因也不难猜就是递归层数过多,每次执行递归都会创建一个栈帧压入栈,当栈帧数超出了虚拟机栈深度,就会抛出 StackOverflowError

解决思路是限制递归的次数或者使用循环代替递归。

另外,当虚拟机栈过多会引发 java.lang.OutOfMemoryError异常。

可使用下面的代码来复现 OutOfMemoryError但要注意可能会使 Windows 电脑死机。

public void stackLeakByThread() {
    while (true) {
        new Thread() {
            public void run() {
                while (true) {
                }
            }
        }.start();
    }
}

看完 Java 虚拟机栈(Stack) 我们再来看 本地方法栈。

一句话带过,就不细看了:它与虚拟机栈相似,主要作用于 native关键字修饰的方法。

Java 内存模型之线程共享部分 {#java-内存模型之线程共享部分}

继续以线程的角度来分类,线程共享的区域有**元空间(MetaSpace)和堆(Heap)**两个部分。

首先来看元空间(MetaSpace)。

什么是元空间(MetaSpace)?在 JDK8 及以后开始将类对象的信息(方法,属性等)存放在本地堆内存中,这块区域就叫做元空间(MetaSpace)。

JDK7 中原先位于方法区里的字符串常量池被移动到了堆中,并且在 JDK8 中使用元空间替代了之前的永久代。

元空间和永久代都是用来存放类对象的各种信息,它们都是方法区(Method Area)的一种实现。(方法区只是 JVM 的一种规范)

那么元空间(MetaSpace)和永久代(PermGen)的区别是什么,使用元空间替代永久代的好处又是什么?

两者最大的区别是元空间使用本地内存,而永久代使用的是 JVM 的内存 ,这样做的好处是解决了内存空间不足的问题,类元素的空间分配将只受本地内存的限制,本地内存剩余多少理论上元空间就可以有多大,所以老版本中的 java.lang.OutOfMemoryError:PermGen space错误将不复存在。

但是实际运行中也不会放任元空间无限放大,JVM 在运行时会根据需要动态的设置其大小。

MetaSpace 相比 PermGen的优势:

  • 字符串常量池存在永久代中,容易出现性能问题和内存溢出。
  • 类和方法的信息大小难以确定,给永久的大小指定带来困难,太小容易出现永久代溢出,太大容易导致老年代溢出。
  • 永久代会为 GC 带来不必要的复杂性,且回收效率偏低。
  • 方便 HotSpot 与其它 JVM 如 Jrockit 的集成。永久代是 HotSpot 特有的,别的 JVM 没有永久代这一说。

对于元空间和永久代只需要重点记住:元空间使用本地内存,而且没有了字符串常量池,其它存储部分如类文件,JVM 运行时的数据结构大体上与永久代相同,只是划分上更趋于合理了。

看完元空间后,再来看重头戏 Java 堆(Heap)

一般来说 Java 堆是 JVM 管理的内存空间中最大的一块,它是所有线程共享的一块区域,在 JVM 启动时创建,此区域的唯一作用就是存放对象实例,几乎所有的对象实例都在此区域中分配内存。

以实际内存为 4GB 的机器为例,在运行 Java 程序的过程中,它的内存分配可以为下图所示:

image-20240125203830945

操作系统和 C 运行时大约占用 1GB ,JVM 和 Native Heap 也会占用一定的空间,而 Java 堆的占用将近有 2 GB,是大头部分。

根据 JVM 规范,Java 堆可以处于物理上不连续的内存空间中,只要逻辑上连续即可。

如果堆中没有内存可完成实例分配,且堆也无法再扩展时,将会抛出 OutOfMemoryError异常。

Java 堆是 GC 管理的主要区域,所以很多时候也会叫其 GC 堆。

Java 内存模型常考题 {#java-内存模型常考题}

以下对Java 内存模型常考题做解析。

JVM 三大性能调优参数 -Xms -Xmx -Xss 的含义 {#jvm-三大性能调优参数--xms--xmx--xss-的含义}

在调用 Java 执行执行程序的时候,可以通过传入 -Xms-Xmx-Xss三个参数分别调整 Java 堆和线程所占内存的大小。如:

java -Xms128m -Xmx128m -Xss256k -jar xxxx.jar
  • -Xss:规定了每个线程虚拟机栈(堆栈)的大小。
  • -Xms:堆的初始值。
  • -Xmx:堆能达到的最大值。

-Xss一般来说给定 256K是足够了。这个参数将会影响当前进程并发线程数的大小。

-Xms指定当前进程刚被创建时的初始 Java 堆大小,一旦对象容量超过了初始容量所设置的值,就会自动扩容到 -Xmx参数所指定的大小。

通常会将 -Xms-Xmx的值设置为同样大小,因为在进行扩容时会发生内存抖动从而影响程序的稳定性。

Java 内存模型中堆和栈的区别 {#java-内存模型中堆和栈的区别}

要想讲清楚这个问题得先搞明白程序运行时的内存分配策略,共有三种:静态的、栈式的、堆式的。

  • 静态存储:编译时就确定每个数据目标在运行时的数据区需求。要求程序代码中不允许出现可变数据结构存在,也不允许嵌套或递归的结构出现。因为它们都会导致编译程序无法计算准确的存储空间。
  • 栈式存储:数据区需求在编译时未知,运行时在进入模块入口前确定。按照先进后出的原则进行分配,属于动态分配。
  • 堆式存储:编译时和运行时进入模块入口前都无法确定数据区需求,动态分配。堆由大片的可利用块或空闲块组成,可以按照任意的顺序分配和释放。

再来看它们之间的联系:

引用对象或数组时,栈里定义的变量保存其在堆中首地址。

如下图所示:

image-20240131203527380

它们的区别如下:

  • 管理方式:栈自动释放,堆需要 GC。
  • 空间大小:栈比堆小。
  • 碎片相关:栈产生的碎片远小于堆。
  • 分配方式:栈支持静态和动态分配,而堆仅支持动态分配。
  • 效率:栈的效率比堆高。

元空间、堆、线程独占部分间的联系 ------ 内存角度 {#元空间堆线程独占部分间的联系--内存角度}

以下面的例子来说明

image-20240131205114202

HelloWorld类中各个内容的存储情况如下:

  1. 当类被加载时元空间会保存 HelloWorld这个类的对象及其 sayHellosetNamemain这三个方法和成员变量 name。除此之外还会保存 System这个类对象以及该类中的成员变量和方法。
  2. HelloWorld对象被创建时,堆中主要保存 HelloWord类对象实例和字符串 test
  3. 当程序执行时,main线程会分配虚拟机栈、本地栈、程序计数器,栈中会存储字符串 test,类对象 hw的地址引用;此外还会保存局部变量 a的值 1,以及系统自带的 lineNo(行号)记录代码的执行情况,方便对程序的执行进行追踪。

image-20240131205302309

不同 JDK 版本之间的 intern() 方法的区别 ------ JDK6 VS JDK6+ {#不同-jdk-版本之间的-intern-方法的区别--jdk6-vs-jdk6}

它的差异主要体现在 JDK6 和 JDK6之后的版本,如 JDK 7 8 9等。

**JDK6:**当调用 intern方法时,如果字符串常量池先前已创建出该字符串对象,则返回池中的该字符串的引用。否则,将此字符串对象添加到字符串常量池中,并且返回该字符串对象的引用。

JDK6+ :当调用 intern方法时,如果字符串常量池先前已创建出该字符串对象,则返回池中的该字符串的引用。否则,如果该字符串对象已经存在于 Java 堆中,则将堆中对此对象的引用添加到字符串常量池中,然后返回该引用;如果堆中不存在,则在池中创建出该字符串并返回其引用。

前面说过,在 JDK7+后字符串常量池由存储在永久代中移动到了 Java 堆中,永久代的内存极为有限,如果在 JDK6中频繁调用 intern方法在字符串常量池中创建字符串对象会使得字符串常量池被挤爆,进而引发 java.lang.OutOfMemoryError: PermGen space

如果要测试的话可以写一段代码随机生成字符串,循环 1000000次,并使用参数 -XX:MaxPermSize=6M -XX:PermSize=6M指定永久代的初始容量和最大容量。

image-20240131221422783

接下通过一个例子看看 intern()在不同 JDK 中的表现:

public static void main(String[] args) {
    String s = new String("a");
    s.intern();
    String s2 = "a";
    System.out.println(s == s2);  // 比较地址

    String s3 = new String("a") + new String("a");
    s3.intern();
    String s4 = "aa";
    System.out.println(s3 == s4);  // 比较地址
}
/* JDK6 输出:
false
false
 */

/* JDK6+ 输出:
false
true
 */

为什么会出现不同的结果呢?

首先来分析 JDK6 中的情况。

首先需要一些前置知识:

  1. 在双引号中声明的字符串都会在字符串常量池中创建。
  2. 使用 new关键字创建出的字符串对象都会在堆中创建。
  3. 使用堆中的字符串常量地址和字符串常量池中的字符串常量地址比较肯定是不相同的。

在 JDK6 中:

public static void main(String[] args) {
    // 首先使用 "a" 声明了一个字符串,会将其放在永久代的字符串常量池中
    // 然后使用new String("a")会在堆中创建字符串 "a"
    String s = new String("a");
    // 将堆中的字符串 "a" 尝试放一个副本到字符串常量池中,但是字符串常量池中已经有了,所以放不进去
    s.intern();
    // s2引用的是字符串常量池中的 "a"
    String s2 = "a";
    // 由于堆中 "a" 的地址和字符串常量池中 "a" 的地址不同,输出:false
    System.out.println(s == s2);

    // 由于字符串常量池中已经有 "a" 了,所以不会再放到字符串常量池中
    // 在堆中生成 "aa"
    String s3 = new String("a") + new String("a");
    // 将字符串 "aa" 的副本成功放到字符串常量池中,但是放的是副本,所以堆中的 "aa" 和放入字符串常量池的 "aa" 的地址还是不同
    s3.intern();
    // s3引用的是字符串常量池中的 "aa"
    String s4 = "aa";
    // 由于堆中的 "aa" 和放入字符串常量池的 "aa" 的地址不同,输出:false
    System.out.println(s3 == s4);
}

image-20240131223619013

在 JDK6+ 中:

最主要的不同是 JDK6 从堆向字符串常量池中塞的是副本,而 JDK6+ 之后向字符串常量池中塞的是堆中字符串的地址引用。

public static void main(String[] args) {
    // 首先使用 "a" 声明了一个字符串,会将其放在字符串常量池中
    // 然后使用new String("a")会在堆中创建字符串 "a"
    String s = new String("a");
  
    // 将堆中的字符串 "a" 尝试将其地址引用放到到字符串常量池中,但是字符串常量池中已经有了,所以放不进去
    // 注意:这里尝试向字符串常量池中放的是堆中字符串 "a" 的地址引用
    s.intern();
  
    String s2 = "a";
    // 由于堆中 "a" 的地址和字符串常量池中 "a" 的地址不同,输出:false
    System.out.println(s == s2);

    // 由于字符串常量池中已经有 "a" 了,所以不会再放到字符串常量池中
    // 在堆中生成 "aa"
    String s3 = new String("a") + new String("a");
  
    // 将字符串 "aa" 的地址引用成功放到字符串常量池中,但是放的是副本,所以堆中的 "aa" 和放入字符串常量池的 "aa" 的地址还是不同
    // 注意:这里成功向字符串常量池中放的是堆中字符串 "aa" 的地址引用
    s3.intern();
  
    // s3引用的是字符串常量池中的 "aa"
    String s4 = "aa";
    // 由于堆中的 "aa" 和放入字符串常量池的 "aa" 的地址相同,输出:true
    System.out.println(s3 == s4);
}

image-20240131223715454

参考资料 {#参考资料}

赞(3)
未经允许不得转载:工具盒子 » Java 面试之 JVM