美文网首页
Java:常量池

Java:常量池

作者: 康小曹 | 来源:发表于2021-09-24 16:36 被阅读0次

    一、概念

    1. 常量池的概念

    • 基础概念

    常量池在 Java 用于保存在编译期已确定的,已编译的 .class 文件中的一份数据。

    类似于 oc 中编译 .m 文件之后生成的单个 mach-O 文件中的 __DATA、__DATA_CONST 段;

    它包括了关于类,方法,接口等中的常量,也包括字符串常量,如String s = "java"这种申明方式;当然也可扩充,执行器产生的常量也会放入常量池,故认为常量池是 JVM 的一块特殊的内存空间。

    • 每个 Java 文件都有自己的静态常量池

    每个 Java 文件编译为 .class 文件后,都将产生当前类独有的常量池,我们称之为静态常量池。

    class 文件中的常量池包含两部分:字面值(literal)和符号引用(Symbolic Reference)。

    其中字面值可以理解为 Java 中定义的字符串常量、final 常量、整数型常量等等。

    符号引用指的是一些字符串,这些字符串表示当前类引用的外部类、方法、变量等的引用地址的抽象表示形式,在类被 jvm 装载并第一次使用这些符号引用时,这些符号引用将会解析为直接引用。符号常量包含:

    1. 类和接口的全限定名(包名+类型)
    2. 字段的名称和描述符
    3. 方法的名称和描述符

    类似于 mach-O 文件中的符号表等信息;

    • JVM 装载类时,加载 .class 文件的静态常量池到方法区

    JVM 在进行类装载时,将 .class 文件中常量池部分的常量加载到方法区中,此时方法区中的保存常量的逻辑区域称之为运行时常量区。

    示例:

    package com.company;
    
    public class XKStringTest {
        static void Test(String param) {
            Integer a = 200;
            Integer b = 200;
            Integer c = 100;
    
            String s1 = "123";
        }
    }
    

    使用 javap -verbose 命令可以查看 class 字节码的详细信息,其中包含了编译期确定的静态常量池,提取出 constant pool 部分:

    静态常量池

    2. 常量池总结

    几个重点:

    1. Java 类单独拥有静态常量池;
    2. 常量池包含两部分:字面量和符号引用;
    3. 在 JVM 进行类加载时,类中的静态常量池统一被复制到方法区,程序运行过程中使用到的也正是方法区的常量池,类中的常量池是编译之后产生的,是静态的,方法区的常量池是动态的,属于运行时;

    二、包装类常量池的实现

    先说结论:

    8 种基础数据类型的包装类型中,除了 Float 和 Double ,其他 6 种都实现了常量池技术;

    1. 5种基础类型的实现

    Short/Long 都是 -128~127:

    private static class ShortCache {
            private ShortCache(){}
    
            static final Short cache[] = new Short[-(-128) + 127 + 1];
    
            static {
                for(int i = 0; i < cache.length; i++)
                    cache[i] = new Short((short)(i - 128));
            }
        }
    

    Character 为 0~127:

        private static class CharacterCache {
            private CharacterCache(){}
    
            static final Character cache[] = new Character[127 + 1];
    
            static {
                for (int i = 0; i < cache.length; i++)
                    cache[i] = new Character((char)i);
            }
        }
    

    Integer 默认为 -128~127,但是可配置,核心代码:

            static {
                // high value may be configured by property
                int h = 127;
                String integerCacheHighPropValue =
                    sun.misc.VM.getSavedProperty("java.lang.Integer.IntegerCache.high");
                if (integerCacheHighPropValue != null) {
                    try {
                        int i = parseInt(integerCacheHighPropValue);
                        i = Math.max(i, 127);
                        // Maximum array size is Integer.MAX_VALUE
                        h = Math.min(i, Integer.MAX_VALUE - (-low) -1);
                    } catch( NumberFormatException nfe) {
                        // If the property cannot be parsed into an int, ignore it.
                    }
                }
                high = h;
    
                cache = new Integer[(high - low) + 1];
                int j = low;
                for(int k = 0; k < cache.length; k++)
                    cache[k] = new Integer(j++);
    
                // range [-128, 127] must be interned (JLS7 5.1.7)
                assert IntegerCache.high >= 127;
            }
    

    根据代码:

    1. 取出 VM 的配置 high;
    2. 取 high 和 127 较大者 i;
    3. 取 i 和 (MaxInteger + 127)的较小者为 h;
    4. 创建 low ~ h 的 Integer 对象;

    验证:

    public static void main(String[] args) {
        Integer i1 = 127;
        Integer i2 = 127;
        System.out.println(i1 == i2); // true
    
        Integer i3 = 128;
        Integer i4 = 128;
        System.out.println(i3 == i4); // false
    
        Character c1 = '2';
        Character c2 = '2';
    
        System.out.println(c1 == c2); // true
    }
    

    从代码上可以看出来,这些类型的的常量池实现方式说白了就是缓存。使用 final 将对应的缓存存放到常量区了,常量池中的缓存不可改变,而 String 则是可以不断新增的;

    2. Bool类型也有常量池

    Bool 就不多说了吧,就 2 个值;

    Boolean b1 = true;
    Boolean b2 = true;
    Boolean b3 = false;
    Boolean b4 = false;
    
    // 内存地址相同
    System.out.println(System.identityHashCode(b1));
    System.out.println(System.identityHashCode(b2));
    
    // 内存地址相同
    System.out.println(System.identityHashCode(b3));
    System.out.println(System.identityHashCode(b4));
    
    System.out.println(b1 == b2); // ture
    System.out.println(b3 == b4); // true
    

    3. 总结

    包装类型常量池

    三、字符串常量池

    之所以把字符串常量池单独列出,是因为:

    1. 字符串常量使用太过于频繁;
    2. 字符串常量相对包装类而言,实现的常量池功能更加丰富;
    3. 字符串常量池可以新增,而上文的 5 中包装类型的常量池不能新增;

    在 C/C++ 中,编译器将多个编译期编译的文件链接成一个可执行文件或者 dll 文件,在链接阶段,符号引用被解析为实际地址,即相对文件首行的偏移。java 中这种链接是在程序运行时动态进行的。所以整个过程会产生很多常量,尤其是字符串常量,比如类名、方法名、包名等。这也是为什么字符串常量相对于包装类而言,具备更强大的功能;

    1. 字符串常量地址创建过程

    先说结论:

    1. 双引号的字符串如果在字符串常量池不存在则新创建;
    2. 新创建的字符串存在于堆中;
    3. 字符串常量池中存储的是堆中 String 对象的内存地址;
    4. 下次使用这个字符串时,检索到该字符串存在,则直接返回常量池中存储的引用(内存地址);

    举个例子🌰:

    String s = "123";
    String s2 = new String("123");
    

    这句代码做了几件事:

    1. 在字符串常量池检索是否存在 "123" 这个字符串;
    2. 第一次肯定不存在,此时使用字符串 "123" 在堆中创建一个字符串对象 mallocString;
    3. 在字符串常量池中添加 "123" 的索引,并将引用指向 mallocString;
    4. 将 mallocString 在堆中地址赋值给变量 s;
    5. new String("123") 实际调用 String(String original) 方法,需要传递一个字符串对象;
    6. 在字符串常量池中检索字符串 "123",这次能够找到,于是取出引用,赋值给 original 作为参数传递;
    7. 构造方法在堆中新生成一个字符串对象 mallocString2,并分配内存;
    8. 将 original 指向的对象,即 mallocString 的 value、hash 属性赋值给 mallocString2;
    9. 返回 mallocString2 的内存地址,赋值给 s2

    因此,整个过程中,创建了 2 个 String 对象;

    如果只有这一行代码:

    String s = new String("123");
    

    那么就这一句代码,就创建了两个 String 对象;

    2. + 号的几种情况

      1. String对象 + String对象

    创建一个 StringBuilder 对象,调用 append 方法,最后调用 toString 方法在堆中创建一个新的字符串对象;

      1. "xxx" + "xxx"

    两边都是双引号时,直接进行字符串拼接然后存入静态常量区,常量区中只有最终的字符串。相当于编译器识别到这种情况直接进行了字符串替换;

      1. String + "xxx"

    此时也是调用 StringBuilder 进行拼接后返回新的 String 对象。唯一区别就是 "xxx" 这里的对象是从常量池中取到的;

    如下代码:

    String s1 = "123";
    String s2 = "456";
    String s3 = s1 + s2;
    String s4 = "123" + "456";
    

    使用 javap -c xxx.class 反编译的结果中可以得到验证:

    反编译结果
    1. 第一和第二句代码都是直接读取出了 .class 的静态常量区中的字符串常量;
    2. 第三句代码则进行了4个步骤;
    3. 第四局代码依然是直接读取字符串常量;

    如果只有最后一句代码:

    String s4 = "123" + "456";
    

    反编译:

    反编译g

    如上,+ 的第二种情况会直接在静态常量区生成 1 个字符串常量,最终赋值给 s1;

    使用 javap -verbose 查看 .class 中的静态常量区:

    常量区

    如上图所示,只有 "123456" 这个常量,没有 "123" 和 "456",因为编译器直接给替换成了 String s1 = "123456";

    这里也可以通过 intern (后文会讲该方法)方法来验证:

    代码1:

    String s1 = "123" + "456";
    String s2 = new String("1");
    String s3 = new String("23");
    String s4 = s2 + s3;
    String s5 = s4.intern();
    
    System.out.println(System.identityHashCode(s4));
    System.out.println(System.identityHashCode(s5));
    

    输出:

    460141958
    460141958
    

    解释:
    s4 和 s5 相等,证明 intern 返回的是 s4 的地址,所以 intern 走的是常量区无 "123" 的逻辑,即先在字符串常量区创建“123”的索引,然后将这个索引指向 s4 的内存地址,最后返回 s4 的内存地址;

    代码2:

    String s1 = "123" + "456";
    String s2 = new String("1");
    String s3 = new String("23");
    String s4 = s2 + s3;
    String s = "123";
    String s5 = s4.intern();
    
    System.out.println(System.identityHashCode(s4));
    System.out.println(System.identityHashCode(s5));
    System.out.println(System.identityHashCode(s));
    

    输出:

    460141958
    1163157884
    1163157884
    

    解释:
    此时 s5 和 s4 不相等,而 s5 和 s 相等。证明 intern 走的是字符串常量区存在 "123" 的逻辑,即直接返回字符串"123" 指向的地址,也就是 s 指向的地址;

    打印时不能使用 string.hashCode 来判断内存地址是否相等,而应该使用 System.identityHashCode() ;因为 String 类重写了 Object 的 hashCode 方法,内部是对字符串进行 hash 算法并返回,所以字符串内容相等则 hashCode() 方法的返回值就都相等;而 System.identityHashCode() 无论对象是否重写了 hashCode 方法,都会调用 Object 中的 hashCode 方法;

    3. + 号的效率问题

    根源:

    1. 除了上述的第二种情况,"+" 号每次都会创建 StringBuilder 对象;
    2. StringBuilder 调用 append 方法最终调用 toString 方法,每次都会创建一个 String 对象;

    就以上两点成了 “+” 效率问题的核心,一旦涉及到大数据量的字符串拼接,则会生成很多 StringBuilder 和 String 对象;

    解决方案:

    • 使用 StringBuilder 的 append 方法,拼接完成后一次性生成新字符串对象;

    另外还有一个 StringBuffer,这个对象只是相对于 StringBuilder 添加了 synchronized 关键字,即:同步执行。所以:

    • 多线程下使用 StringBuffer 更安全,单线程下使用 StringBuilder 更加有效;

    具体源码和反编译源码自行查阅,不详细展开了;

    4. internal() 方法

    只有字符串有这个方法,8 种包装类都不具备此方法,这也是字符串优化的体现。5 种基础数据类型只会预先创建 256 个 Integer 对象存储在常量区,其余的不会存储进常量池,需要开发者自行管理堆内存(其实这个活也都被 GC 系统干了)。 而 String 需要动态添加的原因在于字符串使用实在太过于频繁;

    该方法的意义(流程)在于:

    1. 判断常量池是否有这个 String 对象对应的字符串;
    2. 如果有,则返回常量池中对应字符串的引用地址,而不是 String 自身的地址;
    3. 如果没有,则在常量池中建立该字符串的索引,并在常量池中引用 String 对象(方法调用者)自身的内存地址,然后返回;

    看一段代码:

    public static void main(String[] args) {
            String s1 = new String("caoxk");
            String s2 = new String("_test");
            // 这里不是通过双引号"caoxk_test"生成
            // 这里是通过append生成 char[] 之后调用 new String方法生成的
            // 所以常量池中只有"caoxk"和"_test",并没有"caoxk_tests"
            String s3 = s1 + s2;
            
            // 此时常量池中没有"caoxk_test"
            String s4 = s3.intern();
            String s5 = "caoxk_test";
    
            System.out.println(System.identityHashCode(s3));
            System.out.println(System.identityHashCode(s4));
            System.out.println(System.identityHashCode(s5));
            System.out.println("s3 == s4:" + (s3 == s4));
            System.out.println("s4 == s5:" + (s4 == s5));
    }
    

    输出结果:

    1846274136
    1846274136
    1846274136
    s3 == s4:true
    s4 == s5:true
    

    上文中,当调用 s3.internal() 时,因为常量池中不存在字符串 "caoxk_test" 的索引,所以此时建立索引并引用了 s3 的地址,即:

    • 常量池"caoxk_test"索引中存储的地址即为 s3 在堆中的地址;

    所以,当 s5 = "caoxk_test" 时,已经存在该字符串索引,于是将 s3 的地址返回,所以 s3 == s4 == s5;

    再来看一段代码:

    // 创建了两个 String 对象
    String s1 = new String("caoxk");
    // 创建了两个 String 对象
    String s2 = new String("_test");
    
    // 返回的不是 s1 的内存地址
    // 因为 "caoxk" 这里已经生成了一个对象并被常量池引用
    // 返回的是常量池中的引用,而不是 s1 的内存地址
    String s3 = s1.intern();
    // 返回的是常量池中的引用
    String s4 = "caoxk";
    
    System.out.println(System.identityHashCode(s1));
    System.out.println(System.identityHashCode(s3));
    System.out.println(System.identityHashCode(s4));
    System.out.println("s1 == s3:" + (s1 == s3));
    System.out.println("s3 == s4:" + (s3 == s4));
    

    解释看注释,就不详细解释了;

    四、总结

    1. 只要不是 "xxx" + "xxx" 这种形式,双引号修饰的字符串,都会存在于常量池中,因为没有时,会在堆中新创建一个对象并添加引用到常量池中;
    2. "xxx" + "xxx" 编译器识别后直接转换成了 "xxxxxx",所以最终只会生成一个字符串常量;
      • 号的本质是字符串拼接,只要左右任意一处有 String 对象,则会创建 StringBuilder 和 String 对象,这也是使用 + 号循环拼接字符串时效率低下的本质;
    3. 字符串循环拼接时可以先创建一个 StringBuilder 对象循环进行 append,完成后一次性生成一个 String 对象,如此只需要一个 StringBuild 和 一个 String 对象;
    4. 8 种举出数据类型中的 6 种都实现了运行时不可变的常量池,本质是常量区内存缓存(final);

    相关文章

      网友评论

          本文标题:Java:常量池

          本文链接:https://www.haomeiwen.com/subject/gspzgltx.html