Java类加载阶段的一些细节

注意,本文所说的加载,是类加载这个过程的第一个阶段,而不是指的类加载的全部过程

加载(Loading)

从不同的来源将类文件(.class)转化为二进制流加载到内存中,来源可以是本地磁盘、jar包中的类、甚至可以从网络上动态下载类。然后会通过这个二进制流将数据转化成一个代表类的Class对象,并且在元空间中存储该类的信息

这里有一个容易混淆的概念,方法区,永久代以及元空间的关系。方法区只是一个规范,而永久代和元空间是对这套规范的实现,永久代是JDK8之前的实现方式,而元空间是JDK8及之后的实现方式。永久代和元空间的区别在于,永久代在JVM的堆内存中,而元空间在本地内存中,即操作系统中可用内存大小,其大小不受-Xms-Xmx的限制,减少了OOM发生的频率。

静态常量池

编译期确定,存储在.class文件中,可以通过javap -v -c XXX.class查看

动态常量池

又称运行时常量池,是JVM在加载类时,将静态常量池加载到元空间后的产物,这意味着它是位于元空间的。动态常量池中的数据与静态常量池中的数据基本相同,但是有着更加灵活的特性,包括可实时动态向其中添加新的对象。

动态常量池还与动态解析相关,其中存放的符号引用会在解析成功后替换为直接引用,若解析失败,则会被JVM标记,防止下一次再次尝试解析

字符串常量池

字符串常量池在不同的JDK版本中有细微差异,如下图:

不同版本jdk的内存对比

上图中的String Object为实际的对象,s1-s3是引用

我们来看以下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
public class Main {

public static void main(String[] args) {

String s1 = new StringBuilder().append("think").append("123").toString();

System.out.println(s1.intern() == s1);

String s2 = new StringBuilder().append("ja").append("va").toString();

System.out.println(s2.intern() == s2);
}
}

上述代码来自:https://generalthink.github.io/2020/08/26/analysis-string-intern/

这段代码的输出如下:

  • JDK6 : false false
  • JDK8 : true false
  • JDK12: true true

WHY?

在开始分析前,一定要牢记一下几点,否则会很容易迷失:

  • 无论哪个JDK版本,字符串常量池中始终存放的是字符串对象的引用,而不是字符串对象本身。
  • JVM在执行时,每遇到一个字符串字面量,都会在某个内存区域内(JDK7之前是在永久代中,JDK7及其之后是在堆中),为每个字面量都创建一个字符串对象,并且将这个对象的引用加入字符串常量池

我们用x表示引用,*x表示该引用指向的对象本身,以免引起混淆

intern方法的作用:对于调用sa.intern(),若字符串常量池中存在一个字符串对象的引用sb,满足sa.equals(sb),那么直接返回sb,否则将sa加入字符串常量池中(此处的“加入”行为随着JDK版本不同而不同,后面会详细解释),并返回sa

  • JDK6:字符串常量池位于永久代中,与堆处于不同的内存区域,intern方法此时的行为是:对于调用sa.intern(),若在字符串常量池中存在一个sb,满足sa.equals(sb),那么直接返回sb,否则将*sa复制到永久代(注意不是复制到永久代中的字符串常量池,字符串常量池只存储引用,并不存储对象本身),记这个副本为*sc,然后返回sc

    现在再来看上述程序的行为,虽然*s1被创建出来后,其中的字符串的值为”think123”,但是由于”think123”并不是编译时期就能确定的字面量,而是运行时通过StringBuilder拼出来的,所以在永久代中并没有值为”think123”的字符串对象(当然字符串常量池中也没有值为”think123”的字符串对象的引用),所以在调用s1.intern()时,会把*s1拷贝到永久代中,记这个副本为*s1’,然后把s1’添加到字符串常量池中,并返回s1’,那么自然s1’并不等于s1,所以输出false。后面的s2也是同样的道理

  • JDK7:JDK7中的字符串常量池不再位于永久代中,而是位于堆中,也就意味着intern的行为将发生改变,此时intern的行为是:对于调用sa.intern(),若在字符串常量池中存在一个sb,满足sa.equals(sb),那么直接返回sb,否则将sa加入字符串常量池中,并返回sa。可以看到相比JDK6,少了一个复制的步骤,这是因为此时字符串对象和字符串常量池都在堆中,没有必要进行复制,减少了内存占用,加快了执行时间。

    再来分析一下上述程序,首先创建了一个值为”think123”的字符串对象*s1,此时s1并没有被加入字符串常量池中。接着调用s1.intern(),发现s1不在字符串常量池中,那么会将s1加入字符串常量池,然后返回s1s1 == s1成立,故输出true。那为什么对于s2输出的是false呢?不是应该与s1的情况一样吗?这其实是因为在Java的sun.misc.Version中,存在这个”java”字符串字面量!这个类是Java内部的,会在很早就加载进来,那么自然就会创建它的字符串对象并将其引用加入字符串常量池了,所以与我们自己创建的”java”字符串对象不是同一个,自然就返回false

  • JDK12:在JDK12中,”java”这个字面量被从sun.misc.Version中移除了,所以对于s2的情况也会输出true