文章目录
String的基本特性
-
在了解字符串常量池之前,先看一下String的一些基本特性
-
String:字符串,使用一对""引起来表示。
// 字面量的定义方式 string sl = "hello"; // new的方式 String s2 = new string ("hello");
-
string声明为final的,不可被继承
-
string实现了Serializable接口: 表示字符串是支持序列化的,支持在网络中传输。
-
实现了Comparable接口: 表示string可以比较大小
-
string在jdk8及以前内部定义了final char[] value用于存储字符串数据。jdk9改为byte[] value,节约了一些空间,同时StringBuffer和StringBuilder也同样发生的改变。
- 具Oracle统计String大多数存储的为英文、拉丁文等只占用一个字节的字符,更改为byte以一个字节为存储单位,而char占用两个字节
-
从JDK7开始,字符串常量池中从方法区移动到了堆区
String的不可变性
-
String:代表不可变的字符序列。简称:不可变性。
-
当对字符串进行重新赋值、字符串进行连接操作、调用string的replace ()方法修改指定字符或字符串时,都需要重新在字符串常量池中进行创建,不能使用原有的value进行赋值。
-
通过字面量的方式(区别于new)给一个字符串赋值,此时的字符串值声明在字符串常量池中,字符串常量池中是不会存储相同内容的字符串的。
-
另一个理解角度:底层是使用数组去实现的,数组的长度一旦确定则不可再修改
/**
* 证明String的不可变性
*
* @author wcong
* @version 1.0
* @date 2021-01-09 11:42
*/
public class StringTests {
/**
* 测试赋值操作
*/
public static void test1(){
String s1 = "aaa";
String s2 = s2;
s1 = "hello";
System.out.println("s1: " + s1); // hello
System.out.println("s2: " + s2); // aaa
System.out.println(s1 == s2); // 判断地址: false
}
/**
* 测试拼接操作
*/
public static void test2(){
String s1 = "aaa";
String s2 = s1;
s1 += "hello";
System.out.println("s1: " + s1); // aaahello
System.out.println("s2: " + s2); // aaa
}
/**
* 测试替换操作
*/
public static void test3(){
String s1 = "aaa";
String s2 = s1.replace("a","b");
System.out.println("s1: " + s1); // aaa
System.out.println("s2: " + s2); // bbb
}
}
可能上面的例子看起来比较简单,通过下面这个例子相信你会有很深的印象
public class StringTest2 {
public static void main(String[] args) {
String str = new String("aaa");
char[] chars = {'t', 'e', 's', 't'};
// 调用下面的测试方法
test1(str,chars);
System.out.println("str: " + str); // aaa
System.out.println("chars: " + new String(chars)); // best
}
public static void test1(String str,char[] chars){
str = "hello word";
chars[0] = 'b';
}
}
字符串常量池的理解
《Java语言规范》里要求完全相同的字符串字面量(区别与new),应该包含同样的Unicode字符序列(包含同一份码点序列的常量),并且必须是指向同一个在字符串常量池中的String类实例。
在字符串常量池中是不会存储相同内容的字符串的,那么字符串常量池是怎么实现的呢?是否存在大小呢?怎么设置大小?如果超出了最大的容量会怎样?
-
String Pool是一个固定大小的HashTable,在jdk1.7以前默认值长度是1009,从jdk1.7开始调整为了60013。
-
如果放进String Pool的string非常多,就会造成Hash冲突严重,从而导致链表会很长,而链表长了后直接会造成的影响当调用string.intern()时性能会大幅下降。
-
使用
-XX:StringTableSize
可设置StringTable(StringPool)的长度- 在jdk6中StringTable是固定的,就是1009的长度,所以如果常量池中的字符串过多就会导致效率下降很快。
- 在jdk7中,StringTable的长度默认值是60013
- Jdk8开始,StringTable的长度默认值是60013,设置StringTable的长度时1009是可设置的最小值,若设置的值小于1009,则会抛出异常
Error: Could not create the Java Virtual Machine. Error: A fatal exception has occurred. Program will exit. StringTable size of 1000 is invalid; must be between 1009 and 2305843009213693951
-
如果超出了StringTable的最大容量会进行垃圾回收
字符串拼接操作
- 字面量的拼接:字面量与字面量的拼接在编译期成class文件后就完成了,拼接结果直接放入常量池,其中字符串常量也可以看成是字面量。
- 变量的拼接: 只要其中有一个是变量,创建的对象只在堆中。变量拼接的原理是StringBuilder.toString()
验证
- 验证字符串的拼接,在字节码中只有将该字符串加入局部变量表中的指令,并没有创建StringBuilder的过程(其中字符串常量也可以看成是字面量)
- 验证变量的拼接
main方法对应的字节码如下,可以看到每次变量的拼接的过程中都创建了一个StringBuilder对象,并调用了append方法
0 ldc #2 <a>
2 astore_1
3 new #3 <java/lang/StringBuilder>
6 dup
7 invokespecial #4 <java/lang/StringBuilder.<init>>
10 aload_1
11 invokevirtual #5 <java/lang/StringBuilder.append>
14 ldc #6 <b>
16 invokevirtual #5 <java/lang/StringBuilder.append>
19 invokevirtual #7 <java/lang/StringBuilder.toString>
22 astore_2
23 new #3 <java/lang/StringBuilder>
26 dup
27 invokespecial #4 <java/lang/StringBuilder.<init>>
30 aload_2
31 invokevirtual #5 <java/lang/StringBuilder.append>
34 ldc #8 <c>
36 invokevirtual #5 <java/lang/StringBuilder.append>
39 invokevirtual #7 <java/lang/StringBuilder.toString>
42 astore_3
43 return
关于StringBuilder的拼接操作
StringBuilder.toString()方法返回的字符串存对象地址存储在常量池中吗?
-
关于intern()方法,intern()是String中的静态方法,调用该方法会先从字符串常量池中判断该字符串是否存在,若存在则直接返回字符串常量池中的地址,若不存在则在字符串常量池中创建这个字符串对象,然后再返回。
-
从jdk7开始,字符串常量池从方法区移动到堆中,
-
jdk6及以前:调用intern方法符合上面的概述。
-
jdk7及以后:在调用intern方法时,若在字符串常量池中不存在这个字符串对象,但是在堆中存在这个字符串对象,这个时候并不会在字符串常量池中创建这个对象,而是直接引用这堆中这个字符串对象的地址,以达到节省空间的效果。,
public static void main(String[] args) {
// 以字面量的方式定义字符串,会在字符串常量池中创建对象
String abc = new StringBuilder("abc").toString();
// StringBuilder.toString(): 不会在字符串常量池中创建"def"字符串对象,但在堆中创建了这个"def"字符串对象
// 在字符串常量池中"de"和"f"对象是存在的
String def = new StringBuilder("de").append("f").toString();
// jdk7及以后: 若在堆中存在这个字符串对象,但在字符串常量池中不存在
// 这个时候会直接引用堆中字符串对象的地址
abc.intern();
def.intern();
System.out.println(abc == "abc"); // false
System.out.println(def == "def"); // true
}
-
通过上面的例子可以证明StringBuilder中的toString方法并没有将字符串创建在字符串常量池中
-
查看StringBuilder中的toString方法字节码,可以看到并没有
ldc
指令
0 new #80 <java/lang/String>
3 dup
4 aload_0
5 getfield #234 <java/lang/StringBuilder.value>
8 iconst_0
9 aload_0
10 getfield #233 <java/lang/StringBuilder.count>
13 invokespecial #291 <java/lang/String.<init>>
16 areturn
StringBuilder的效率问题
- 通过StringBuilder的append()的方法拼接字符串的效率要远远高于直接使用String的字符串拼接方式
- StringBuilder的append()方法从头到尾只创建了一个StringBuilder对象
- 使用String字符串的拼接方式创建了多个StringBuilder和String对象
- 使用String字符串的拼接方式在内存中由于创建了较多的StringBuilder和String对象,内存占用更大,如果进行GC,需要花费较长的时间。
public static void main(String[] args) {
long start = System.currentTimeMillis();
// stringConn(100000); // ===20057ms
stringAppend(100000); // ===9ms
long end = System.currentTimeMillis();
System.out.println("===" + (end - start) + "ms");
}
public static void stringConn(int count){
String str = "a";
for (int i = 0; i < count; i++) {
str += i;
}
}
public static void stringAppend(int count){
StringBuilder str = new StringBuilder();
for (int i = 0; i < count; i++) {
str.append(i);
}
}
- StringBuilder拼接字符串的提升空间: 在实际开发中,如果基本确定要添加的字符串长度不会高于某个限定值的情况下,可以在构造器中传入内存创建char数组的长度(**默认长度为
new char[16]
),可以避免扩容带来的性能消耗。
经典面试题
public static void main(String[] args) {
String s1 = "javaEE";
String s2 = "hadoop";
String s3 = "javaEE" + "hadoop";
String s4 = "javaEEhadoop";
String s5 = "javaEE" + s2;
String s6 = s1 + "hadoop";
String s7 = s1 + s2;
System.out.println(s3 == s4);
System.out.println(s3 == s5);
System.out.println(s3 == s6);
System.out.println(s3 == s7);
System.out.println(s5 == s6);
System.out.println(s6 == s7);
String s8 = s6.intern();
System.out.println(s3 == s8);
}
答案:true、false、false、false、false、false、true
- 第一个为true的原因:字面量的拼接操作在编译成class文件就已经完成了。
- 第二到六位false的原因:如果拼接字符中出现了变量,则相当于在堆空间中new String(),所以地址不相等。
- 最后一个为true的原因:intern(): 判断字符串常量池中是否存在javaEEhadoop值,如果存在则返回常量池中的地址,如果不存在则在常量池中加载一份该字符串,并返回加载后的地址。
关于intern()的效率问题
- 关于intern方法的作用及在不同jdk版本中的变化上面已经做过讲解,这里不再进行复述,下面主要对intern方法进行简单的效率测试
场景一:大的网站平台,需要内存中存储大量的字符串。比如社交网站存储的: 北京市、海淀区等信息。这时候如果字符串都调用intern()方法,就会明显降低内存的大小。
1. 从时间的角度
/**
* 测试intern:之时间角度
*
* 只有到字符串很多的时候,才会有比较明显的效果,平常效率还不如valueOf()
*
* @author wcong
* @version 1.0
* @date 2021-01-11 20:13
*/
public class Intern效率测试1 {
static final int COUNT = 1000 * 10000;
static final String[] ARR = new String[COUNT];
public static void main(String[] args) throws InterruptedException {
Integer[] data = new Integer[]{1,2,3,4,5,6,8,9,0};
int len = data.length;
long start = System.currentTimeMillis();
for (int i = 0; i < COUNT; i++) {
/**
* 100 * 10000: 耗时: 39ms
* 1000 * 10000: 耗时: 3933ms
*/
// ARR[i] = String.valueOf(data[i % len]);
/**
* 100 * 10000: 耗时: 120ms
* 1000 * 10000: 耗时: 1900ms
*/
ARR[i] = String.valueOf(data[i % len]).intern();
}
long end = System.currentTimeMillis();
System.out.println("耗时: " + (end - start) + "ms");
Thread.sleep(1000000);
}
}
2. 从空间的角度
/**
* 测试intern
*
* 时间角度:只有到字符串很多的时候,才会有比较明显的效果,平常效率还不如valueOf()
* 空间角度:当程序中存在很多重复的字符串时,使用intern方法可以很大程度上的节省内存空间
* @author wcong
* @version 1.0
* @date 2021-01-11 20:13
*/
public class Intern效率测试1 {
static final int COUNT = 1000 * 10000;
static final String[] ARR = new String[COUNT];
public static void main(String[] args) throws InterruptedException {
Integer[] data = new Integer[]{1,2,3,4,5,6,8,9,0};
int len = data.length;
long start = System.currentTimeMillis();
for (int i = 0; i < COUNT; i++) {
// ARR[i] = String.valueOf(data[i % len]);
ARR[i] = String.valueOf(data[i % len]).intern();
}
long end = System.currentTimeMillis();
System.out.println("耗时: " + (end - start) + "ms");
Thread.sleep(1000000);
}
}
- 不使用intern方法
- 使用intern方法
G1垃圾收集器对String的去重操作
背景: 对许多Java应用(有大的也有小的)做的测试得出以下结果:
- 堆存活数据集合里面string对象占了25%
- 堆存活数据集合里面重复的string对象有13.5%
- String对象的平均长度是45
许多大规模的Java应用的瓶颈在于内存,测试表明,在这些类型的应用里面,Java堆中存活的数据集合差不多25%是string对象。更进一步,这里面差不多一半string对象是重复的,重复的意思是说: stringl.equals(string2) =t rue
。堆上存在重复的string对象必然是一种内存的浪费。这个项目将在G1垃圾收集器中实现自动持续对重复的string对象进行去重,这样就能避免浪费内存。
大致实现过程
- 当垃圾收集器工作的时候,会访问堆上存活的对象。对每一个访问的对象都会检查是否是需要去重的string对象。
- 如果是,把这个对象的一个引用插入到队列中等待后续的处理。一个去重的线程在后台运行,用来处理这个队列。处理队列的一个元素意味着从队列删除这个元素,然后尝试去重它引用的string对象。
- 使用一个hashtable来记录所有的被string对象使用的不重复的char数组。当去重的时候,会查这个hashtable,来看堆上是否已经存在一个一模一样的char数组。
- 如果存在,string对象会被调整引用那个数组,释放对原来的数组的引用,最终会被垃圾收集器回收掉。
- 如果查找失败,char数组会被插入到hashtable,这样以后的时候就可以共享这个数组了。
相关JVM参数
UseStringDeduplication (bool)
: 开启string去重,默认是不开启的,需要手动开启。PrintStringDeduplicationStatistics (bool)
: 打印详细的去重统计信息StringDeduplicationAgeThreshold (uintx)
: 达到这个年龄的string对象被认为是去重的候选对象