一、概念
1. 常量池的概念
- 基础概念
常量池在 Java 用于保存在编译期已确定的,已编译的 .class 文件中的一份数据。
类似于 oc 中编译 .m 文件之后生成的单个 mach-O 文件中的 __DATA、__DATA_CONST 段;
它包括了关于类,方法,接口等中的常量,也包括字符串常量,如String s = "java"这种申明方式;当然也可扩充,执行器产生的常量也会放入常量池,故认为常量池是 JVM 的一块特殊的内存空间。
- 每个 Java 文件都有自己的静态常量池
每个 Java 文件编译为 .class 文件后,都将产生当前类独有的常量池,我们称之为静态常量池。
class 文件中的常量池包含两部分:字面值(literal)和符号引用(Symbolic Reference)。
其中字面值可以理解为 Java 中定义的字符串常量、final 常量、整数型常量等等。
符号引用指的是一些字符串,这些字符串表示当前类引用的外部类、方法、变量等的引用地址的抽象表示形式,在类被 jvm 装载并第一次使用这些符号引用时,这些符号引用将会解析为直接引用。符号常量包含:
- 类和接口的全限定名(包名+类型)
- 字段的名称和描述符
- 方法的名称和描述符
类似于 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. 常量池总结
几个重点:
- Java 类单独拥有静态常量池;
- 常量池包含两部分:字面量和符号引用;
- 在 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;
}
根据代码:
- 取出 VM 的配置 high;
- 取 high 和 127 较大者 i;
- 取 i 和 (MaxInteger + 127)的较小者为 h;
- 创建 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. 总结
包装类型常量池三、字符串常量池
之所以把字符串常量池单独列出,是因为:
- 字符串常量使用太过于频繁;
- 字符串常量相对包装类而言,实现的常量池功能更加丰富;
- 字符串常量池可以新增,而上文的 5 中包装类型的常量池不能新增;
在 C/C++ 中,编译器将多个编译期编译的文件链接成一个可执行文件或者 dll 文件,在链接阶段,符号引用被解析为实际地址,即相对文件首行的偏移。java 中这种链接是在程序运行时动态进行的。所以整个过程会产生很多常量,尤其是字符串常量,比如类名、方法名、包名等。这也是为什么字符串常量相对于包装类而言,具备更强大的功能;
1. 字符串常量地址创建过程
先说结论:
- 双引号的字符串如果在字符串常量池不存在则新创建;
- 新创建的字符串存在于堆中;
- 字符串常量池中存储的是堆中 String 对象的内存地址;
- 下次使用这个字符串时,检索到该字符串存在,则直接返回常量池中存储的引用(内存地址);
举个例子🌰:
String s = "123";
String s2 = new String("123");
这句代码做了几件事:
- 在字符串常量池检索是否存在 "123" 这个字符串;
- 第一次肯定不存在,此时使用字符串 "123" 在堆中创建一个字符串对象 mallocString;
- 在字符串常量池中添加 "123" 的索引,并将引用指向 mallocString;
- 将 mallocString 在堆中地址赋值给变量 s;
- new String("123") 实际调用 String(String original) 方法,需要传递一个字符串对象;
- 在字符串常量池中检索字符串 "123",这次能够找到,于是取出引用,赋值给 original 作为参数传递;
- 构造方法在堆中新生成一个字符串对象 mallocString2,并分配内存;
- 将 original 指向的对象,即 mallocString 的 value、hash 属性赋值给 mallocString2;
- 返回 mallocString2 的内存地址,赋值给 s2
因此,整个过程中,创建了 2 个 String 对象;
如果只有这一行代码:
String s = new String("123");
那么就这一句代码,就创建了两个 String 对象;
2. + 号的几种情况
- String对象 + String对象
创建一个 StringBuilder 对象,调用 append 方法,最后调用 toString 方法在堆中创建一个新的字符串对象;
- "xxx" + "xxx"
两边都是双引号时,直接进行字符串拼接然后存入静态常量区,常量区中只有最终的字符串。相当于编译器识别到这种情况直接进行了字符串替换;
- String + "xxx"
此时也是调用 StringBuilder 进行拼接后返回新的 String 对象。唯一区别就是 "xxx" 这里的对象是从常量池中取到的;
如下代码:
String s1 = "123";
String s2 = "456";
String s3 = s1 + s2;
String s4 = "123" + "456";
使用 javap -c xxx.class 反编译的结果中可以得到验证:
反编译结果- 第一和第二句代码都是直接读取出了 .class 的静态常量区中的字符串常量;
- 第三句代码则进行了4个步骤;
- 第四局代码依然是直接读取字符串常量;
如果只有最后一句代码:
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. + 号的效率问题
根源:
- 除了上述的第二种情况,"+" 号每次都会创建 StringBuilder 对象;
- StringBuilder 调用 append 方法最终调用 toString 方法,每次都会创建一个 String 对象;
就以上两点成了 “+” 效率问题的核心,一旦涉及到大数据量的字符串拼接,则会生成很多 StringBuilder 和 String 对象;
解决方案:
- 使用 StringBuilder 的 append 方法,拼接完成后一次性生成新字符串对象;
另外还有一个 StringBuffer,这个对象只是相对于 StringBuilder 添加了 synchronized 关键字,即:同步执行。所以:
- 多线程下使用 StringBuffer 更安全,单线程下使用 StringBuilder 更加有效;
具体源码和反编译源码自行查阅,不详细展开了;
4. internal() 方法
只有字符串有这个方法,8 种包装类都不具备此方法,这也是字符串优化的体现。5 种基础数据类型只会预先创建 256 个 Integer 对象存储在常量区,其余的不会存储进常量池,需要开发者自行管理堆内存(其实这个活也都被 GC 系统干了)。 而 String 需要动态添加的原因在于字符串使用实在太过于频繁;
该方法的意义(流程)在于:
- 判断常量池是否有这个 String 对象对应的字符串;
- 如果有,则返回常量池中对应字符串的引用地址,而不是 String 自身的地址;
- 如果没有,则在常量池中建立该字符串的索引,并在常量池中引用 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));
解释看注释,就不详细解释了;
四、总结
- 只要不是 "xxx" + "xxx" 这种形式,双引号修饰的字符串,都会存在于常量池中,因为没有时,会在堆中新创建一个对象并添加引用到常量池中;
- "xxx" + "xxx" 编译器识别后直接转换成了 "xxxxxx",所以最终只会生成一个字符串常量;
- 号的本质是字符串拼接,只要左右任意一处有 String 对象,则会创建 StringBuilder 和 String 对象,这也是使用 + 号循环拼接字符串时效率低下的本质;
- 字符串循环拼接时可以先创建一个 StringBuilder 对象循环进行 append,完成后一次性生成一个 String 对象,如此只需要一个 StringBuild 和 一个 String 对象;
- 8 种举出数据类型中的 6 种都实现了运行时不可变的常量池,本质是常量区内存缓存(final);
网友评论