1. String的基本特性
注意:String s2 = new String(“hello”)方式创建字符串,堆中存放值为hello的对象,同时会在字符串常量池中创建一个hello字符串常量,堆中的hello对象指向字符串常量池中的hello字符串常量。
- String:字符串,使用一对""引起来表示。有两种实例化方式:
String sl = "hello";
//字面量的定义方式String s2 = new String("hello");
- String声明为final的, 不可被继承
- String实现了Serializable接口:表示字符串是支持序列化的。实现了Comparable接口:表示String可以比较大小
- String在jdk8及以前内部定义了
final char value[]
用于存储字符串数据。jdk9时改为final byte[] value
结论: String再也不用char[] 来存储啦,改成了byte[] 加上编码标记,节约了一些空间。因为char占用2个字符,而经过实践发现大部分堆空间的String对象都是拉丁文字或者ISO-8859-1,只占用一个字符,所以用byte一个字符就能节约空间。如果是UTF-8之类的字符就用byte加上编码标记来存储。
同时:StringBuffer和StringBuilder也做了一些修改 - String:代表不可变的字符序列。简称:不可变性。
- 当对字符串重新赋值时,需要重写指定内存区域赋值,不能使用原有的value进行赋值。
- 当对现有的字符串进行连接操作时,也需要重新指定内存区域赋值,不能使用原有的value进行赋值。
- 当调用String的replace()方法修改指定字符或字符串时,也需要重新指定内存区域赋值,不能使用原有的value进行赋值。
- 通过字面量的方式(区别于new)给一个字符串赋值,此时的字符串值声明在字符串常量池中。
1.1 String不可变代码示例:
/**
* String的基本使用:体现String的不可变性
*/
public class StringTest1 {
@Test
public void test1() {
String s1 = "abc";//字面量定义的方式,"abc"存储在字符串常量池中
String s2 = "abc";
s1 = "hello";
System.out.println(s1 == s2);//判断地址:true --> false
System.out.println(s1);//hello
System.out.println(s2);//abc
}
@Test
public void test2() {
String s1 = "abc";
String s2 = "abc";
s2 += "def";
System.out.println(s2);//abcdef
System.out.println(s1);//abc
}
@Test
public void test3() {
String s1 = "abc";
String s2 = s1.replace('a', 'm');
System.out.println(s1);//abc
System.out.println(s2);//mbc
}
}
1.2 字符串常量池:
字符串常量池通过固定大小的Hashtable实现,通过Hash值类判断是否有相同字符串,如果这个hash值过小,就会导致有多个字符串具体相同的hash值,就会以链表的形式存放,如果链表过长就会导致效率变低。
StringTable具有GC操作。
- 字符串常量池中是不会存储相同内容的字符串的。
- String的StringPool是一个固定大小的Hashtable,默认值大小长度是1009。如果放进StringPool的String非常多, 就会造成Hash冲突严重,从而导致链表会很长,而链表长了后直接会造成的影响就是当调用
String.intern()
时性能会大幅下降。 - 使用
-XX:StringTableSize
可设置StringTable的长度 - 在jdk6中StringTable是固定的,就是1009的长度,所以如果常量池中的字符串过多就会导致效率下降很快。StringTableSize设置没有要求
- 在jdk7中,StringTable的长度默认值是60013,大小无要求
- jdk8开始,1009是StringTable长度可设置的最小值
如果设置的值不在指定范围内,就会报如下错误:
2. String的内存分配
- 在Java语言中有8种基本数据类型和一种比较特殊的类型String。这些类型为了使它们在运行过程中速度更快、更节省内存,都提供了一种常量池的概念。
- 常量池就类似一个Java系统级别提供的缓存。8种基本数据类型的常量池都是系统协调的,String类型的常量池比较特殊。它的主要使用方法有两种:
- 直接使用双引号声明出来的String对象会直接存储在常量池中。
- 比如:
String info = "abc"
;
- 比如:
- 如果不是用双引号声明的String对象,可以使用String提供的
intern()
方法。这个后面重点谈
- 直接使用双引号声明出来的String对象会直接存储在常量池中。
- Java 6及以前,字符串常量池存放在永久代。
- Java 7中Oracle的工程师对字符串池的逻辑做了很大的改变,即将字符串常量池的位置调整到Java堆内。
- 所有的字符串都保存在堆(Heap)中,和其他普通对象一样,这样可以让你在进行调优应用时仅需要调整堆大小就可以了。
- 字符串常量池概念原本使用得比较多,但是这个改动使得我们有足够的理由让我们重新考虑在Java 7中使用
String.intern()
。
- Java8元空间,字符串常量在堆
2.1 为什么StringTable要进行调整
- permSize默认比较小,容易出现OOM
- 永久代垃圾回收频率低
3. String 的基本操作
Java语言规范要求完全相同的字符串字面量,应该包含相同的Unicode字符序列(包含同一份码点序列的常量),并且必须是指向同一个String类实例。
3.1 测试字符串是否共用:
-
可以看到此时有2293个字符串
-
可以看到此时有2294个字符串
-
输出10的时候是2304个字符串:
-
输出相同字符串时,数量并没有变
3.2 查看包含字符串的方法在堆中的存放:
代码:
class Memory {
public static void main(String[] args) {//line 1
int i = 1;//line 2
Object obj = new Object();//line 3
Memory mem = new Memory();//line 4
mem.foo(obj);//line 5
}//line 9
private void foo(Object param) {//line 6
String str = param.toString();//line 7
System.out.println(str);
}//line 8
}
对应内存图:
4. 字符串的拼接操作
4.1 结论:
- 常量与常量的拼接结果在常量池,原理是编译期优化
- 常量池中不会存在相同内容的常量。
- 只要其中有一个是变量,结果就在堆中(堆中常量池以外的位置)。变量拼接的原理是StringBuilder
- 如果拼接的结果调用intern()方法,则主动将常量池中还没有的字符串对象放入池中,并返回此对象地址。
4.2 案例:
public class StringTest2 {
@Test
public void test1() {
String s1 = "a" + "b" + "c"; // 等同于“abc”
String s2 = "abc"; // "abc"一定是放在字符串常量池中,将此地址赋给s2
/**
* 编译器优化
* 最终.java编译成.class,再执行.class;
* 可以看到.class文件中直接就是:
* String s1 = "abc";
* String s2 = "abc"
*/
System.out.println(s1 == s2); // true
System.out.println(s1.equals(s2)); // true
}
@Test
public void test2() {
String s1 = "s1";
String s2 = "s2";
String s3 = "s1s2";
String s4 = "s1" + "s2";// 编译期优化
// 如果拼接符号的前后出现了变量,则相当于在堆空间中new String(),
// 具体的内容为拼接的结果
String s5 = s1 + "s2";
String s6 = "s1" + 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
// intern():判断字符串常量池中是否存在此值,如果存在,则返回常量池中此值的地址
// 如果不存在,则在常量池中创建一份,并返回此字符串值的地址
String s8 = s6.intern();
System.out.println(s3 == s8); // true
String s9 = new String("s123");
String s10 = s9.intern();
String s11 = "s123";
System.out.println(s9 == s10); // false
System.out.println(s11 == s10);// true
}
}
4.3 字符串变量拼接的底层原理:
4.3.1 代码案例1:
@Test
public void test3() {
String s1 = "a";
String s2 = "b";
String s3 = "ab";
/**
* 如下s1+s2的执行细节:(变量s是临时定义的)
* 1.StringBuilder s = new StringBuilder();
* 2.s.append("a");
* 3.s.append("b");
* 4.s.toString(); --> 约等于 new String("ab");
*
* 补充:
* 在jdk5.0之后使用的是StringBuilder,
* 在jdk5.0之前使用的是StringBuffer
*/
// 返回的是new的对象在堆中的地址,对象再指向字符串常量池中的地址
String s4 = s1 + s2;
System.out.println(s3 == s4); //false
}
jclasslib字节码指令:
- 字符串常量池的a
- 将a放在局部变量表1的位置(0的位置存放的是this)
- 字符串常量池的b
- 将b放在局部变量表2的位置
- 字符串常量池的ab
- 将ab放在局部标量表3的位置
- new一个StringBuilder,开辟空间
- 默认赋值
- 调用构造器
- 取出局部变量表1位置的数据a
- 调用StringBuilder的append方法,拼接a
- 取出2位置的数据b
- 调用append拼接b
- 调用toString方法
4.3.2 代码案例2:
/**
* 字符串拼接操作不一定使用的是StringBuilder
* 如果拼接符号左右两边都是字符串常量或常量引用,则仍然使用编译器优化。
*
* ps:针对final修饰类,方法,基本类型,变量, 等结构时,能加上final,就加上final
*/
@Test
public void test4() {
final String s1 = "a";
final String s2 = "b";
String s3 = "ab";
String s4 = s1 + s2;
System.out.println(s3 == s4); // true
}
4.3.3 拼接操作与append操作效率对比:
/**
* 从结果得到:使用StringBuilder的append方法拼接字符串的效率远高于String的拼接方式
* 详情:
* 1.StringBuilder的append方法自始至终都只有一个StringBuilder对象
* 使用String的字符串拼接方法,创建多个StringBuilder和String对象
* (可以看上面的案例,如何创建StringBuilder和String对象)
* 2.使用String拼接方法:内存中创建了过多的对象,内存占用更大,GC消耗时间
* 改进空间:
* 实际开发中,字符串拼接的次数不高于某个限定值,建议使用含参构造指定byte数组长度
* (源码中查看可以看出会有数组扩容操作)
*/
@Test
public void test5() {
long start = System.currentTimeMillis();
method1(100000); // 2380ms
//method2(100000); // 7ms
long end = System.currentTimeMillis();
System.out.println("花费的时间为:" + (end - start));
}
public void method1(int times) {
String src = "";
for (int i = 0; i < times; i++) {
src = src + "a"; // 每次循环都会创建一个StringBuilder、String
}
}
public void method2(int times) {
StringBuilder src = new StringBuilder();
for (int i = 0; i < times; i++) {
src.append("a");
}
}
5. intern()的使用
如果不是用双引号声明的String对象,可以使用String提供的intern方法: intern方法会从字符串常量池中查询当前字符串是否存在,若不存在就会将当前字符串放入常量池中。
- 比如:
String myInfo = new String("I love u").intern();
也就是说,如果在任意字符串上调用String. intern方法,那么其返回结果所指向的那个类实例,必须和直接以常量形式出现的字符串实例完全相同。因此,下列表达式的值必定是true:
("a" + "b" + "c").intern() == "abc";
通俗点讲,Interned String就是确保字符串在内存里只有一份拷贝,这样可以节约内存空间,加快字符串操作任务的执行速度。注意,这个值会被存放在字符串内部池(String Intern Pool)。
我对上述话的理解:(这里不理解可以先看下面的案例)
- 如果字符串常量池中存在常量,则返回此常量的地址,
- 如果字符串常量池中没有此字符串常量,但是堆中有此字符串的对象地址,则将此字符串的对象地址存入字符串常量池,保证池中和堆中的唯一性(即上述说的保证字符串在内存中只有一份拷贝)
5.1 面试题一:
5.1.1 new String(“abc”)会创建几个对象?
答案:
- 如果字符串常量池中有abc,则创建一个对象;
- 如果没有,就是2个对象(一个在堆中(new关键字在堆中创建的),一个在常量池中创建一个字符串常量abc)。
代码1:
public static void main(String[] args) {
String a = new String("abc");
}
字节码解释:
5.1.2 拓展:new String(a) + new String(b)创建了多少对象?
答案:
- 对象1:new StringBuilder()
- 对象2:new String(“a”)
- 对象3:常量池中的a
- 对象4:new String(“b”)
- 对象5:常量池中的b
- 对象6:StringBuilder的toString()创建了一个String对象
注意:toString()方法,本质是调用new String()重新创建一个String对象,创建的ab字符串不是常量,所以不会放入常量池,String类的本质是char(byte)数组。
代码:
String b = new String("a") + new String("b");
字节码解释:
toString的字节码:
5.1.3 拓展2:toString()方法没有在字符串常量池中生成字符串常量
代码:
- 第一个断点常量数量:
- 第二个断点常量数量:
- 第一个断点常量数量:
- 第一个断点常量数量:
可见toString()方法并没有在字符串常量池中生成字符串常量。
5.2 面试题二:重要理解
public static void main(String[] args) {
// 此时s1指向的是堆中的地址
String s1 = new String("1");
// 调用此方法之前,字符串常量池中已经存在了"1"
// 这里如果s1 = s1.intern(),就是true了。
s1.intern();
// s2指向的是常量池中的值
String s2 = "1";
// jdk6 :false jdk7及以后:false
System.out.println(s1 == s2);
// s3变量记录的地址为:new String("11")
String s3 = new String("1") + new String("1");
// 执行完上一行代码后,字符串常量池中不存在"11"
s3.intern(); // 在字符串常量池中生成"11",生成的是s3的引用,不是"11"这个常量
// s4变量记录的地址是:上一行代码执行时,在常量池中生成的"11"的地址
String s4 = "11";
/**
* jdk6 :false jdk7及以后:true
* 原因:s3.intern()方法在:
* jdk6:创建了一个新的对象"11",也就有新的地址
* jdk7及8:此时堆中有一个"11"的对象,
* 所以字符串常量池中并不是生成一个新的"11",而是创建一个指向堆中"11"对象的地址,
* 即字符串常量池中存放的是一个地址,而不是字符串"11"
*/
System.out.println(s3 == s4);
}
下述图示请结合代码注释查看:jdk6和jdk7的区别主要在于如果堆中已有字符串,但是常量池中没有,在调用intern()方法时,jdk6会在常量池中生成字符串,而jdk7之后会将已有字符串对象的地址存放到常量池中
5.2.1 拓展:
将上面的案例的String s4 = "11"和s3.intern()位置对调,结果则为false。因为先调用String s4 = "11";
则直接在字符串常量池中创建的‘11’这个字符,上述的s3.intern()在常量池中存放的是‘11’的地址的引用。
String s3 = new String("1") + new String("1");
// 在常量池中生成11
String s4 = "11";
String s5 = s3.intern();
System.out.println(s3 == s4); // false
System.out.println(s5 == s4); // true
5.3 面试题三:
public class StringExer1 {
public static void main(String[] args) {
//String x = "ab";
String s = new String("a") + new String("b");//类似于new String("ab")
//在上一行代码执行完以后,字符串常量池中并没有"ab",只有a和b
String s2 = s.intern();//jdk6中:在串池中创建一个字符串"ab"
//jdk8中:串池中没有创建字符串"ab",而是创建一个引用,指向new String("ab"),将此引用返回
System.out.println(s2 == "ab");//jdk6:true jdk8:true
System.out.println(s == "ab");//jdk6:false jdk8:true
}
}
jdk6中图示:
- 首先在堆中生成一个ab的对象,地址是0x1122
- 调用s.intern()接口,在常量池中生成了ab的字符串常量,并将s2指向次常量地址0x2233
jdk7图示:
- 首先在堆中生成一个ab的对象,地址是0x1122
- 调用s.intern()接口,由于在堆中已经有字符串ab的对象,所以直接将此对象的地址0x1122存入字符串常量池中,将s2指向次字符串常量池中ab的地址即0x1122
public class StringExer2 {
public static void main(String[] args) {
String s1 = new String("ab");//执行完以后,会在字符串常量池中会生成"ab"
// String s1 = new String("a") + new String("b");执行完以后,不会在字符串常量池中会生成"ab"
s1.intern();
String s2 = "ab";
System.out.println(s1 == s2); //false
}
}
5.4 总结String的intern()的使用:
- jdk1.6中,将这个字符串对象尝试放入串池。
- 如果字符串常量池中有,则并不会放入。返回已有的串池中的对象的地址
- 如果没有,会把此对象复制一份,放入串池,并返回串池中的对象地址
- Jdk1.7起,将这个字符串对象尝试放入串池。
- 如果字符串常量池中有,则并不会放入。返回已有的串池中的对象的地址
- 如果没有,则会把对象的引用地址复制一份,放入串池,并返回串池中的引用地址
5.5 应用场景
大的网站平台,需要内存中存储大量的字符串。比如社交网站,很多人都存储:北京市、海淀区等信息。这时候如果字符串都调用 intern()方法,就会明显降低内存的大小。
测试:
/**
* 使用intern()测试执行效率:空间使用上
* 结论:对于程序中大量存在存在的字符串,尤其其中存在很多重复字符串时,使用intern()可以节省内存空间。
*
*/
public class StringIntern2 {
static final int MAX_COUNT = 1000 * 10000;
static final String[] arr = new String[MAX_COUNT];
public static void main(String[] args) {
Integer[] data = new Integer[]{1,2,3,4,5,6,7,8,9,10};
long start = System.currentTimeMillis();
for (int i = 0; i < MAX_COUNT; i++) {
// 7307ms
// arr[i] = new String(String.valueOf(data[i % data.length]));
// 1128ms
arr[i] = new String(String.valueOf(data[i % data.length])).intern();
}
long end = System.currentTimeMillis();
System.out.println("花费的时间为:" + (end - start));
try {
Thread.sleep(1000000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.gc();
}
}
6. StringTable的垃圾回收
/**
* String的垃圾回收:
* -Xms15m -Xmx15m -XX:+PrintStringTableStatistics -XX:+PrintGCDetails
*
*/
public class StringGCTest {
public static void main(String[] args) {
// for (int j = 0; j < 100; j++) {
// String.valueOf(j).intern();
// }
//发生垃圾回收行为
for (int j = 0; j < 100000; j++) {
String.valueOf(j).intern();
}
}
}
7. G1的String去重操作(String对象)
-
背景:对许多Java应用(有大的也有小的)做的测试得出以下结果:
- 堆存活数据集合里面String对象占了25%
- 堆存活数据集合里面重复的String对象有13.5%
- String对象的平均长度是45
-
许多大规模的Java应用的瓶颈在于内存,测试表明,在这些类型的应用里面,Java堆中存活的数据集合差不多25%是String对象。更进一一步,这里面差不多一半String对象是重复的,重复的思是说:
string1.equals(string2)=true
。堆上存在重复的string对象必然是一种内存的浪费。这个项目将在G1垃圾收集器中实现自动持续对重复的String对象进行去重,这样就能避免浪费内存。 -
实现:
- 当垃圾收集器工作的时候,会访问堆上存活的对象。对每一个访问的对象都会检查是否是候选的要去重的String对象。
- 如果是,把这个对象的一个引用插入到队列中等待后续的处理。一个去重的线程在后台运行,处理这个队列。处理队列的一个元素意味着从队列删除这个元素,然后尝试去重它引用的String对象。
- 使用一个hashtable来记录所有的被String对象使用的不重复的char数组。当去重的时候,会查这个hashtable,来看堆上是否已经存在一个一模一样的char数组。
- 如果存在,String对象会被调整引用那个数组,释放对原来的数组的引用,最终会被垃圾收集器回收掉。
- 如果查找失败,char数组会被插入到hashtable,这样以后的时候就可以共享这个数组了。
-
命令行选项:
- UseStringDeduplication (bool) :开启String去重,默认是不开启的,需要手动开启。
- PrintStringDedupl icationStatistics (bool) :打印详细的去重统计信息,
- StringDedupl icationAgeThreshold (uintx) :达到这个年龄的string对象被认.为是去重的候选对象
8. 面试题
8.1 题一:
public static void main(String[] args) {
String a = "aaa";
String b = "aaa";
System.out.println(a == b); // true
System.out.println(a.equals(b));// true
String c = new String("ccc");
String d = new String("ccc");
System.out.println(c == d); // false
System.out.println(c.equals(d));// true
}
8.2 题二:
public class StringTest {
String str = new String("good");
char[] ch = {'t', 'e', 's', 't'};
public void change (String str, char ch[]) {
str = "test ok";
ch[0] = 'b';
}
public static void main(String[] args) {
StringTest test = new StringTest();
test.change(test.str, test.ch);
System.out.println(test.str); // good
System.out.println(test.ch); // best
}
}
解释:
概念:java传参只有按值传递(也就是把实参的值拷贝给形参,这个值可以是普通的数值,也可以是地址值),java中的对象只能通过指向它的引用来操作,这个引用本身也是变量,不要与C/C++中的传值与传址混淆了,java中没有显式的指针。
分析:change函数被调用时,第一个形参str接收了类的成员变量str的值(虽然名称都是str,但是却是两个独立的String类型的引用变量),注意这两个str自身都是变量且都指向了堆内存中的String对象"good",当我们在change函数内部将str指向了另一个String对象"test ok"后,类的成员变量str仍然保持指向"good",所以最终打印出来就是"good";对于第二个形参ch,它也是接收了类的成员变量ch的值拷贝,这一点和str没有差别,即两个ch都指向了字符数组{ ‘a’, ‘b’, ‘c’ }的首地址,但是ch[0]表示的是字符数组中’a’的地址,修改了它也就修改了字符数组的第一个元素,这个改变在change函数返回之后也会存在。
所以本题中两个形参传参的本质区别在于,修改str只是将形参指向了新的对象,对外部的实参没有任何影响,而修改ch[0]是实实在在的修改了字符数组的首元素。
如果将ch[0] = 'b’改为:ch = new char[]{‘b’};那么test.ch仍为test。