由“字符串常量”问题想到的
测试用的Java版本: 11
内存模型
当我们学习字符串内存模型的时候经常看到类似这样的图
这类图简明扼要得解释了字符串变量、堆、常量池的引用关系,然而我觉得表述的并不准确,深入思考时甚至常常被它误导。
众所周知字符串的数据是以字节数组的形式存储的,Java中除了八大基本数据类型外,都是以对象的形式存在,字节数组也不例外。只要是对象,必然拥有自己的独立存储空间,不会屈居在String对象的内存空间中:
通过查看String的构造方法即可证实s2的value和字节数组的关系。
这里有更多相关测试,方便感兴趣的同学查看:
https://gitee.com/ellipse/java_practices/tree/master/src/main/java/org/misty/practices/string
这张图只描述了常量池中已存在的情况。常量池中不存在的情况,需要借助动态生成字符串(拼接)来模拟。为了更好地理解,将先简单介绍一下字符串拼接。
字符串拼接 +
字面量连接
var helloworld = "Hello" + "World";
/*
0: ldc #10 // String HelloWorld
2: astore_0
*/
Javac会将"字面量连接"优化成完整字符串常量的调用。
字符串变量连接
// Java11
var hello = "Hello";
var helloworld = hello + "World";
/*
0: ldc #2 // String Hello
2: astore_0
3: aload_0
4: invokedynamic #3, 0 // InvokeDynamic #0:makeConcatWithConstants:(Ljava/lang/String;)Ljava/lang/String;
9: astore_1
*/
古时候,Javac会把字符串连接语句转译成StringBuilder调用,而在新版中变成了动态方法调用。那么这个#0:makeConcatWithConstants:(Ljava/lang/String;)Ljava/lang/String;是什么呢?
经过一番摸索,我在字节码的底部看到了它的声明,原来它是通过StringConcatFactory.makeConcatWithConstants方法创建的一个lambda表达式。它的原理是:
lambda表达式中包含一个pattern,这个pattern是通过解析字符串连接语句得来的:
hello + "World" => "\u0001World"
"Hello" + world => "Hello\u0001"
hello + world => "\u0001\u0001"
其中的\u0001是占位符。lambda表达式执行时,用传入的字符串变量(们)按顺序替换\u0001占位符,然后将结果返回,并入栈。
我们也可以通过编码实现这一过程:
public static void main(String[] args) throws Throwable {
var lookup = MethodHandles.lookup();
var methodType = MethodType.methodType(String.class, String.class);
var callSite = StringConcatFactory.makeConcatWithConstants(lookup, "makeConcatWithConstants", methodType, /*这里是pattern*/ "\u0001World");
var methodHandler = callSite.dynamicInvoker();
var res = methodHandler.invoke("Hello");
System.out.println(res); // HelloWorld
}
intern
了解了字符串拼接后,我们再回过头看看当常量池中不存在时,内存模型是什么样的。
为了使图片内容更清爽,图中省略了"a"的内存引用。由于这里的字节数组是由lambda表达式动态生成的,因此它储存在堆中。
接下来我们调用intern方法进行池化。新版本池化的逻辑是:如果常量池中不存在该字符串,则直接在常量池中创建一个对当前字符串(堆中)的引用。
创建了几个对象?
这是一道经典的问题:执行下面这条语句一共创建了几个对象?
String str = new String("HelloWorld");
标准答案是:1个或者2个。
- 如果常量池存在
"HelloWorld"则在堆中创建一个String,并指向常量池中的值(value)。 - 如果常量池不存在
"HelloWorld"则在常量池中创建一个值为HelloWorld的String,然后在堆中创建第二个String,并指向常量池中的。
然而你就从来没有怀疑过这个答案吗?
学习过类加载机制的同学们都知道,一句代码的执行要经历很多步骤:
编码 > 编译 > 装载(加载字节码 > 链接 > 初始化)> 运行
其中链接又可细分为校验>准备>解析。解析过程的描述是将常量池内的符号引用转换为直接引用。就是将字节码中Constant pool区域的数据储存到内存中相应的位置。字符串常量的创建也是在这个阶段进行。但由于存在延迟解析,即符号引用第一次被使用时进行解析,因此字符串常量的创建时机会被推迟到第一次使用的时候。测试代码:
我们回到主题,看看这行语句到底做了什么:
public static void main(String[] args) {
var str = new String("HelloWorld");
}
要想知道Java程序执行的结果,不能看源码怎么写,而是看虚拟机如何执行(字节码):
stack=3, locals=2, args_size=1
0: new #2 // class java/lang/String
3: dup
4: ldc #3 // String HelloWorld
6: invokespecial #4 // Method java/lang/String."<init>":(Ljava/lang/String;)V
9: astore_1
10: return
先简单解释一下这段字节码:
操作数栈大小为3,本地变量表大小为2(其中索引位置0被方法入参占用)
操作数栈 [ | | ] 本地变量表 [ args | ]
0 创建一个String对象,将引用入栈。这个对象未初始化
操作数栈 [ objRef | | ] 本地变量表 [ args | ]
3 dup复制栈顶元素并入栈
操作数栈 [ objRef | objRef | ] 本地变量表 [ args | ]
4 ldc 加载常量#3并入栈。#3即符号链接,会转换成"HelloWorld"的直接引用
操作数栈 [ consRef | objRef | objRef ] 本地变量表 [ args | ]
6 弹出栈顶两个元素,即"HelloWorld"字符串和String对象,执行构造器方法
操作数栈 [ objRef | | ] 本地变量表 [ args | ]
9 弹出栈顶元素,保存到本地变量表索引1位置
操作数栈 [ | | ] 本地变量表 [ args | objRef ]
通过分析字节码,我们会发现答案2的表述并不准确。
首先,在堆中创建一个未初始化的String对象,接着在常量池中查找"HelloWorld",如果不存在,由于这个符号链接是第一次使用,因此在常量池中创建"HelloWorld"对象(延迟解析)。最后用"HelloWorld"对象作参数初始化第一个String对象。
尾声
这篇文章,从早上9点,一直写到下午4点。其间花费了大量时间查找资料,设计编写测试代码,绘制模型图。文中观点大多经过代码测试,但依然存在无法测试,或需要阅读底层代码才能解释的部分,因为精力有限,暂时不再深入。如有错误欢迎批评指正。
测试代码:https://gitee.com/ellipse/java_practices/tree/master/src/main/java/org/misty/practices/string
参考资料
https://my.oschina.net/u/4519772/blog/4255335
https://www.jianshu.com/p/039d6df30fea
https://www.cnblogs.com/tiancai/p/9399530.html
以及其他介绍java字节码的文章,无法一一列举
附:用"abc"撑爆堆内存
前面我们了解了字符串连接会在堆里创建String对象和字节数组对象,无论拼接后的字符串是否存在常量池中。于是我们可以利用很小的,重复的字符串撑爆堆内存。无需编写复杂的算法来生成随机字符串。
public class StringOOM {
static String ABC = "abc";
/**
* VM Options -Xms100M -Xmx100M
*/
public static void main(String[] args) throws InterruptedException {
var list = new ArrayList<String>(10000);
var a = "A";
var b = "B";
var c = "C";
try {
while (true) {
list.add((a + b + c)); // 1946160
// list.add((a + b + c).intern()); // 14778652
}
} catch (Throwable e) {
System.out.println(list.size());
}
}
}



