JVM之StringTable
1. String的基本特性
-
使用一对""引起来表示。
-
声明为final的,不可被继承。也不可变,对String重新赋值,会重新分配内存,重新指向。原本的value不变。
-
实现了Serializable接口,表示字符串是支持序列化的。实现了 Comparable接口,表示String可以比较大小。
-
在jdk8及以前内部定义了
final char[] value
用于存储字符串数据。jdk9时改为final byte[] value
。节省空间。String类的当前实现在一个char数组中存储字符,每个字符使用两个字节(16位)。从许多不同应用程序收集的数据表明,字符串占的内存是堆的“大头”,而且大多数String对象只包含Latin-1字符。这种字符只需要一个字节的存储空间,因此这些String对象内部的char数组中的一半空间未被使用。来源:https://openjdk.org/jeps/254
-
字符串常量池中是不会存储相同内容的字符串的。
-
常量池是一个固定大小的Hashtable。jdk6中表长是固定为1009,元素个数没有限制。jdk7中默认长度是60013,可以任意调整。jdk8中1009是可设置的最小值。可以通过-XX:StringTableSize设置长度。
测试了一下,如果小于1009会出现,下面的错误提示:
Error: Could not create the Java Virtual Machine.
Error: A fatal exception has occurred. Program will exit.
StringTable size of 1008 is invalid; must be between 1009 and 2305843009213693951
-
2. String的内存分配
常量池
在Java语言中有8种基本数据类型和一种比较特殊的类型String。这些类型为了使它们在运行过程中速度更快、更节省内存,都提供了一种常量池的概念。
常量池就类似一个Java系统级别提供的缓存。8种基本数据类型的常量池都是系统协调的, String类型的常量池比较特殊。它的主要使用方法有两种:
- 直接使用双引号声明出来的 string对象会直接存储在常量池中。比如:
String info="hello, my friend;"
- 如果不是用双引号声明的String对象,可以使用String提供的intern()方法。
在JVM中的位置
- Java 6及以前,字符串常量池存放在永久代。
- Java7中Oracle的工程师对字符串池的逻辑做了很大的改变,即将字符串常量池的位置调整到Java堆内。
- 所有的字符串都保存在堆(Heap)中,和其他普通对象一样,这样可以让你在进行调优应用时仅需要调整堆大小就可以了。
- 字符串常量池概念原本使用得比较多,但是这个改动使得我们有足够的理由让我们重新考虑在Java 7中使用String.intern()。
- Java8元空间,字符串常量也在堆中。
StringTable为什么要调整?官方:https://www.oracle.com/java/technologies/javase/jdk7-relnotes.html
在JDK 7中,经过intern()方法处理的字符串对象不再分配在Java堆的永久代(permanent generation),而是分配在Java堆的主要部分(年轻代和老年代),与应用程序创建的其他对象一起。这个变化将导致主要Java堆中存储更多的数据,而永久代中的数据减少,因此可能需要调整堆的大小。大多数应用程序的堆内存不会因为这个变化而产生太大的差异,只有加载许多类或者大量使用String.intern()方法才会看到明显的差异。
通过一段代码来证明一下 Java 8中,字符串常量在堆中。
/**
* jdk6中:
* -XX:PermSize=6m -XX:MaxPermSize=6m -Xms6m -Xmx6m
* jdk8中
* -XX:MetaspaceSize=20m -XX:MaxMetaspaceSize=20m -Xms20m -Xmx20m
**/
public class Main {
public static void main(String[] args) {
Set<String> set=new HashSet<>();
int i=0;
while (true){
set.add(String.valueOf(i++).intern());
}
}
}
//输出
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
at java.util.HashMap.resize(HashMap.java:704)
at java.util.HashMap.putVal(HashMap.java:663)
at java.util.HashMap.put(HashMap.java:612)
at java.util.HashSet.add(HashSet.java:220)
at cn.lucas.test.day28.Main.main(Main.java:17)
3. String的基本操作
Java语言规范里要求完全相同的字符串字面量,应该包含同样的Unicode字符序列(包含同一份码点序列的常量),并且必须指向同一个String类实例。
public class Main2 {
public static void main(String[] args) {
System.out.println("1");
System.out.println("2");
System.out.println("3");
System.out.println("4");
System.out.println("5");
System.out.println("6");
System.out.println("7");
System.out.println("8");
System.out.println("9");
System.out.println("10");
System.out.println("1");
System.out.println("2");
System.out.println("3");
System.out.println("4");
System.out.println("5");
System.out.println("6");
System.out.println("7");
System.out.println("8");
System.out.println("9");
System.out.println("10");
}
}
在IDEA中执行上面这段代码,用debug方式查看Memeory可以看到:从最刚开始的1
到10
,java.lang.String
的count(数量)在不断增加,也即会在字符串常量池中创建字符串,但是下一次到1
以后,由于常量池中已经有了这些字符串,因此count不再增加。
4. 字符串拼接操作
结论先行:
- 常量与常量的拼接结果在常量池中,原理是编译器优化。
- 常量池中不会存在相同内容的常量。
- 只要其中有一个是变量,结果就在堆中。变量拼接的原理是StringBuilder。
- 如果拼接的结果调用了intern()方法,则主动将常量池中还没有的字符串对象放入池中,并返回此对象地址。
首先我们来看第一个示例:
public class Main4 {
public static void main(String[] args) {
String s1 = "a" + "b" + "c";
String s2 = "abc";
System.out.println(s1 == s2);//true
System.out.println(s1.equals(s2));//true
}
}
反编译后字节码文件:
public static void main(String[] args) {
String s1 = "abc";
String s2 = "abc";
System.out.println(s1 == s2);
System.out.println(s1.equals(s2));
}
因此可以,在编译阶段"a" + "b" + "c"
直接变成了"abc"
。因此我们得到结论1:常量与常量的拼接结果在常量池中,原理是编译器优化。
我们再看第二个示例:
public class Main5 {
public static void main(String[] args) {
String s1 = "javaEE";
String s2 = "hadoop";
String s3 = "javaEEhadoop";
String s4 = "javaEE" + "hadoop";
String s5 = s1 + "hadoop";
String s6 = "javaEE" + s2;
String s7 = s1 + s2;
System.out.println(s3 == s4);//true
System.out.println(s3 == s5);//false
System.out.println(s3 == s6);//false
System.out.println(s3 == s7);//false
System.out.println(s5 == s6);//false
System.out.println(s5 == s7);//false
System.out.println(s6 == s7);//false
String s8 = s6.intern();
System.out.println(s3 == s8);//true
}
}
原因如下:
在Java中,使用"=="运算符比较字符串时,它比较的是字符串对象的引用地址,即比较它们是否指向同一个对象。
在代码中,对于s3、s4、s5、s6、s7这些字符串变量的比较:
- s3和s4都是通过字符串字面值直接赋值的,因此它们指向的是字符串池中的同一个字符串对象,所以s3 == s4为true。
- s3与s5、s3与s6、s3与s7不是直接使用字符串字面值赋值的,它们是通过字符串拼接操作生成的新字符串对象,所以它们指向的是不同的对象,所以s3 == s5、s3 == s6、s3 == s7都为false。
另外,s5、s6、s7之间的比较:
- s5是将s1与"hadoop"拼接而成的,s6是将"javaEE"与s2拼接而成的,s7是将s1与s2拼接而成的。
- 因为字符串拼接操作会生成新的字符串对象,所以s5、s6、s7都指向不同的对象,它们之间的比较都为false。
最后,s8是通过调用s6.intern()方法得到的字符串对象,它会在字符串池中查找是否存在相同值的字符串对象,如果存在则返回该对象的引用,如果不存在则将当前字符串对象添加到字符串池中并返回该引用。
- 在代码中,s8和s3的值相同,并且s3已经存在于字符串池中,因此s8会直接返回s3的引用,所以s3 == s8为true。
只要拼接过程中出现了变量,那么对象就会在堆中生成,因此通过“==”比较得到的答案就是false(比较地址,一个在堆中,一个在常量池中,内存地址不同)。
通过jclasslib
(IDEA插件),可以从字节码文件中看到变量拼接的原理是StringBuilder。
//...
17 invokespecial #6 <java/lang/StringBuilder.<init>>
20 aload_1
21 invokevirtual #7 <java/lang/StringBuilder.append>
//..
因此我们得出了结论3:只要其中有一个是变量,结果就在堆中。变量拼接的原理是StringBuilder。
我们看第三个示例
public class Main6 {
public static void main(String[] args) {
final String s1 = "a";
final String s2 = "b";
String s3 = "ab";
String s4 = s1 + s2;
System.out.println(s3 == s4);//true
}
}
被final修饰的字符串变量,会被编译器优化,直接变为字面量。下面为被反编译后的字节码文件。
public class Main6 {
public Main6() {
}
public static void main(String[] args) {
String s1 = "a";
String s2 = "b";
String s3 = "ab";
String s4 = "ab";
System.out.println(s3 == s4);
}
}
我们来看第四个示例:字符串拼接的原理就是利用StringBuilder的append()方法,但每一次都会创建一个StringBuilder对象,再通过toString()方法产生一个String对象,这会产生极大的资源浪费。我们做个对比:
public class Main7 {
public static void main(String[] args) {
Main7 main7 = new Main7();
long start = System.currentTimeMillis();
//main7.method1(100000); //5466
main7.method2(100000); //16
long end = System.currentTimeMillis();
System.out.println(end - start);
}
void method1(int highLevel) {
String src = "";
for (int i = 0; i < highLevel; i++) {
src += "a";
}
}
void method2(int highLevel) {
StringBuilder src = new StringBuilder();
for (int i = 0; i < highLevel; i++) {
src.append("a");
}
}
}
可以看到效率的差距还是非常大的。因此StringBuilder进行字符串拼接操作的效率更高。
5. intern()的使用
intern()方法会在字符串常量池中与这个字符串对象字面量相同的字符串,如果有就返回这个字符串的地址,如果没有就加入字符串常量池,同样返回地址。因此只要是字面量相同的字符串对象,调用intern()方法进行比较都必定为true。
一个例子:
public class StringIntern1 {
public static void main(String[] args) {
String s = new String("1");
s.intern();
String s2 = "1";
System.out.println(s == s2);
String s3 = new String("1") + new String("1");
s3.intern();
String s4 = "11";
System.out.println(s3 == s4);
}
}
在jdk6中,执行结果为false false
,在jdk7/8中,执行结果为false true
。原因在于6中的常量池在永久代。而7以后,常量池就放到了堆中,为了减少内存的占用,常量池中会存放对象的引用,因此执行String s4="11"时,返回的其实是s3的引用,因此结果相等。
故总结一下:
- jdk1.6中,将这个字符串对象尝试放入串池
- 如果串池中有,则并不会放入。返回己有的串池中的对象的地址。
- 如果没有,会把此对象复制一份,放入串池,并返回串池中的对象地址。
- jdk1.7起,将这个字符串对象尝试放入串池
- 如果串池中有,则并不会放入。返回己有的串池中的对象的地址。
- 如果没有,则会把对象的引用地址复制一份,放入串池,并返回串池中的引用地址。
6. intern()空间效率测试
public class StringIntern2 {
static final int MAX = 1000 * 10000;
static final String[] arr = new String[MAX];
public static void main(String[] args) throws Exception {
Integer[] DB_DATA = new Integer[10];
Random random = new Random(10 * 10000);
for (int i = 0; i < DB_DATA.length; i++) {
DB_DATA[i] = random.nextInt();
}
long t = System.currentTimeMillis();
for (int i = 0; i < MAX; i++) {
//arr[i] = new String(String.valueOf(DB_DATA[i % DB_DATA.length]));
arr[i] = new String(String.valueOf(DB_DATA[i % DB_DATA.length])).intern();
}
Thread.sleep(1000000);
System.out.println((System.currentTimeMillis() - t) + "ms");
System.gc();
}
}
执行上面这段代码,并通过jvisualvm
进行观察,可以得出,第一种会创建出一千万个String对象,然后将引用赋值给数组,虽然第二种方式也会同样多的对象,但通过intern方法,返回的是字符串1到10的引用,即数组只引用了10个String对象,剩下没有引用的会被垃圾回收掉。因此,第二种方法占用的空间更少。