StringTable
1-String的基本特性
- String:字符串,使用一对 “” 引起来表示
String s1 = "hello"; // 字面量的定义方式
String s2 = new String("hello"); // new 对象的方式
- String 被声明为 final 的,不可被继承
- String 实现了 Serializable 接口:表示字符串是支持序列化的。实现了 Comparable 接口:表示 String 可以比较大小
- String 在 jdk8 及以前内部定义了
final char value[]
用于存储字符串数据。JDK9 时改为byte[]
String存储结构变更
官方文档:http://openjdk.java.net/jeps/254
// <=jdk8
private final char value[];
// >=jdk9
private final byte[] value
- String 类的当前实现将字符存储在 char 数组中,每个字符使用两个字节(16位)。
- 从许多不同的应用程序收集的数据表明,字符串是堆使用的主要组成部分,而且大多数字符串对象只包含拉丁字符(Latin-1)。这些字符只需要一个字节的存储空间,因此这些字符串对象的内部 char 数组中有一半的空间将不会使用,产生了大量浪费。
- 之前 String 类使用 UTF-16 的 char[] 数组存储,现在改为 byte[] 数组外加一个编码标识存储。该编码表示如果你的字符是 ISO-8859-1 或者 Latin-1,那么只需要一个字节存。如果你是其它字符集,比如 UTF-8,你仍然用两个字节存
结论:
-
String 再也不用 char[] 来存储了,改成了 byte [] 加上编码标记,节约了一些空间
-
同时基于 String 的数据结构,例如 StringBuffer 和 StringBuilder 也同样做了修改
基本特性
String:代表不可变的字符序列。简称:不可变性。
- 当对字符串重新赋值时,需要重写指定内存区域赋值,不能使用原有的 value 进行赋值。
@Test
public void test1() {
String s1 = "abc";//字面量定义的方式,"abc"存储在字符串常量池中
String s2 = "abc";
s1 = "hello";
System.out.println(s1 == s2);//判断地址:true --> false
System.out.println(s1);//
System.out.println(s2);//abc
}
- 当对现有的字符串进行连接操作时,也需要重新指定内存区域赋值,不能使用原有的 value 进行赋值。
@Test
public void test2() {
String s1 = "abc";
String s2 = "abc";
s2 += "def";
System.out.println(s2);//abcdef
System.out.println(s1);//abc
}
- 当调用 String 的 replace() 方法修改指定字符或字符串时,也需要重新指定内存区域赋值,不能使用原有的 value 进行赋值。
@Test
public void test3() {
String s1 = "abc";
String s2 = s1.replace('a', 'm');
System.out.println(s1);//abc
System.out.println(s2);//mbc
}
面试题
public class StringExer {
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) {
StringExer ex;
ex = new StringExer();
ex.change(ex.str, ex.ch);
System.out.println(ex.str);//good
System.out.println(ex.ch);//best
}
}
通过字节码可以看到, str 时仅仅是当前栈帧的局部变量表中, 并没有针对引用数据类型做修改的动作. 而数组则是采用了 castore
操作(具体细节说明结合规范), 切实地对引用数据类做操作.
通过字面量的方式(区别于new)给一个字符串赋值,此时的字符串值声明在字符串常量池中。
字符串常量池中是不会存储相同内容的字符串的
- String的String Pool(字符串常量池)是一个固定大小的Hashtable,默认值大小长度是 1009。如果放进 String Pool 的 String 非常多,就会造成Hash冲突严重,从而导致链表会很长,而链表长了后直接会造成的影响就是当调用 String.intern() 方法时性能会大幅下降。
- 使用
-XX:StringTablesize
可设置 StringTable 的长度 - 在 JDK6 中 StringTable 是固定的,就是 1009 的长度,所以如果常量池中的字符串过多就会导致效率下降很快,StringTablesize 设置没有要求
- 在 JDK7中,StringTable 的长度默认值是 60013,StringTablesize 设置没有要求
- 在 JDK8 中,StringTable 的长度默认值是 60013,StringTable 可以设置的最小值为 1009
jps #查看java进程
jinfo -flag StringTableSize 5432 #查看大小
2-String的内存分配
-
在 Java 语言中有 8 种基本数据类型和一种比较特殊的类型String。这些类型为了使它们在运行过程中速度更快、更节省内存,都提供了一种常量池的概念。
-
常量池就类似一个 Java 系统级别提供的缓存。8 种基本数据类型的常量池都是系统协调的,String 类型的常量池比较特殊。它的主要使用方法有两种。
-
直接使用双引号声明出来的 String 对象会直接存储在常量池中。
- 比如:
String info="hello";
- 比如:
-
如果不是用双引号声明的 String 对象,可以使用 String 提供的 intern() 方法。这个后面重点谈
-
-
Java 6 及以前,字符串常量池存放在永久代
-
Java 7 中 Oracle的工程师对字符串池的逻辑做了很大的改变,即将字符串常量池的位置调整到 Java 堆内
- 所有的字符串都保存在堆(Heap)中,和其他普通对象一样,这样可以让你在进行调优应用时仅需要调整堆大小就可以了。
- 字符串常量池概念原本使用得比较多,但是这个改动使得我们有足够的理由让我们重新考虑在 Java 7 中使用 String.intern()。
-
Java8 元空间,字符串常量在堆
StringTable为什么要调整?
官方文档:https://www.oracle.com/java/technologies/javase/jdk7-relnotes.html#jdk7changes
-
permSize 默认比较小
-
永久代垃圾回收频率低, 大量的字符串无法及时回收容易进行 Full GC 产生 STW 或者容易产生 OOM
实例demo
/**
* jdk6中:
* -XX:PermSize=6m -XX:MaxPermSize=6m -Xms6m -Xmx6m
*
* jdk8中:
* -XX:MetaspaceSize=6m -XX:MaxMetaspaceSize=6m -Xms6m -Xmx6m
*/
public class StringTest3 {
public static void main(String[] args) {
//使用Set保持着常量池引用,避免full gc回收常量池行为
Set<String> set = new HashSet<String>();
//在short可以取值的范围内足以让6MB的PermSize或heap产生OOM了。
short i = 0;
while(true){
set.add(String.valueOf(i++).intern());
}
}
}
JKD8 报错(由此也可见 Java8 元空间,字符串常量在堆)
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
at java.util.HashMap.resize(HashMap.java:703)
at java.util.HashMap.putVal(HashMap.java:662)
at java.util.HashMap.put(HashMap.java:611)
at java.util.HashSet.add(HashSet.java:219)
at com.atguigu.java.StringTest3.main(StringTest3.java:22)
Process finished with exit code 1
3-String的基本操作
demo1
Java 语言规范里要求完全相同的字符串字面量,应该包含同样的 Unicode 字符序列(包含同一份码点序列的常量),并且必须是指向同一个 String 实例。
public class StringTest4 {
public static void main(String[] args) {
System.out.println();//此处断点, 观察内存中String数量
System.out.println("1");//此处断点, 观察内存中String数量, 加了一个, 有一个换行符的增加
System.out.println("2");// 此处断点, 观察内存中String数量, 加了一个
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");// 此处断点, 观察内存中String数量, 与"1"相比增加了9个, 说明增加了1-9的字符串
//如下的字符串"1" 到 "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");//
}
}
demo2
class Memory {
public static void main(String[] args) {
int i = 1;
Object obj = new Object();
Memory mem = new Memory();
mem.foo(obj);
}
private void foo(Object param) {
String str = param.toString();
System.out.println(str);
}
}
4-字符串拼接操作
- 常量与常量的拼接结果在常量池,原理是编译期优化
- 常量池中不会存在相同内容的变量
- 拼接前后,只要其中有一个是变量,结果就在堆中。变量拼接的原理是 StringBuilder
- 如果拼接的结果调用 intern() 方法,根据该字符串是否在常量池中存在,分为:
- 如果存在,则返回字符串在常量池中的地址
- 如果字符串常量池中不存在该字符串,则在常量池中创建一份,并返回此对象的地址
demo1
常量与常量的拼接结果在常量池,原理是编译期优化
@Test
public void test1() {
String s1 = "a" + "b" + "c";//编译期优化:等同于"abc"
String s2 = "abc"; //"abc"一定是放在字符串常量池中,将此地址赋给s2
/*
* 最终.java编译成.class,再执行.class
* String s1 = "abc";
* String s2 = "abc"
*/
System.out.println(s1 == s2); //true
System.out.println(s1.equals(s2)); //true
}
demo2
拼接前后,只要其中有一个是变量,结果就在堆中
@Test
public void test2() {
String s1 = "javaEE";
String s2 = "hadoop";
String s3 = "javaEEhadoop";
String s4 = "javaEE" + "hadoop";//编译期优化
//如果拼接符号的前后出现了变量,则相当于在堆空间中new String(),具体的内容为拼接的结果:javaEEhadoop
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
//intern():判断字符串常量池中是否存在javaEEhadoop值,如果存在,则返回常量池中javaEEhadoop的地址;
//如果字符串常量池中不存在javaEEhadoop,则在常量池中加载一份javaEEhadoop,并返回次对象的地址。
String s8 = s6.intern();
System.out.println(s3 == s8);//true
}
字符串拼接底层细节
细节一
@Test
public void test3(){
String s1 = "a";
String s2 = "b";
String s3 = "ab";
String s4 = s1 + s2;//
System.out.println(s3 == s4);//false
}
细节二
字符串拼接不一定使用的都是 StringBuilder
- 如果拼接符号左右两边都是字符串常量或常量引用, 则仍然使用编译器优化, 即非 StringBuilder 的方式
- 针对于 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
}
针对上述练习题
@Test
public void test5() {
String s1 = "javaEEhadoop";
String s2 = "javaEE";
String s3 = s2 + "hadoop";
System.out.println(s1 == s3);
final String s4 = "javaEE";
String s5 = s4 + "hadoop";
System.out.println(s1 == s5);
}
拼接操作与 append 操作的效率对比
@Test
public void test6(){
long start = System.currentTimeMillis();
method1(100000);//6150
long end = System.currentTimeMillis();
System.out.println("+拼接花费的时间为:" + (end - start));
long s2 = System.currentTimeMillis();
method2(100000);//3
long e2 = System.currentTimeMillis();
System.out.println("append拼接花费的时间为:" + (e2 - s2));
}
public void method1(int highLevel){
String src = "";
for(int i = 0;i < highLevel;i++){
src = src + "a";//每次循环都会创建一个StringBuilder、String
}
}
public void method2(int highLevel){
//只需要创建一个StringBuilder
StringBuilder src = new StringBuilder();
for (int i = 0; i < highLevel; i++) {
src.append("a");
}
}
- 相差了 2000 多倍
- StringBuilder 的 append() 方式: 自始至终只创建过一个StringBuilder 对象; String 字符串拼接方式: 创建过多个StringBuilder 和 String 的对象
- String 字符串拼接方式: 内存中由于创建了较多的 StringBuilder 和 String 对象, 内存占用更大; 如果进行 GC, 需要花费额外的时间
- 在实际开发中, 如果基本确定前前后后添加的字符串长度不高于某个限定值 highLevel 的情况下, 建议使用构造器实例化
StringBuilder s = new StringBuilder(highLevel);
尽量避免底层不断进行扩容的情况
5-intern()的使用
-
如果不是用双引号声明的 String 对象,可以使用 String 提供的 intern 方法:intern 方法会从字符串常量池中查询当前字符串是否存在,若不存在就会将当前字符串放入常量池中。
- 比如:
String myInfo = new string("hello").intern();
- 比如:
-
也就是说,如果在任意字符串上调用 String.intern() 方法,那么其返回结果所指向的那个类实例,必须和直接以常量形式出现的字符串实例完全相同。因此,下列表达式的值必定是 true
demo1
new String(“ab”) 会创建几个对象?
@Test
public void test1() {
String str = new String("ab");
}
demo2
new String(“a”) + new String(“b”); 创建几个对象
demo3
@Test
public void test3() {
String s = new String("1");
s.intern(); // 此时字符串常量池中已经有 "1" 了
String s2 = "1";
System.out.println(s == s2);
String s3 = new String("1") + new String("1"); // 相当于new String("11")
// 此时常量池中不存在 "11"
s3.intern(); // 在常量池中生成"11", 如何理解: jdk6:在字符串常量池创建了一个新的对象"11", 也就有新的地址, 但是jdk7之后中字符串常量池记录的是new出来的地址值, 并没有创建"11"
String s4 = "11"; // s4变量记录的地址: 使用的是上一行代码执行时, 在常量池中生成的 "11" 的地址
System.out.println(s3 == s4);
}
拓展问题
@Test
public void test4() {
String s3 = new String("1") + new String("1");
String s4 = "11"; // 在字符串常量池中生成对象"11"
s3.intern(); // 实际没干啥
System.out.println(s3 == s4);
}
总结
- JDK1.6 中, 将这个字符对象尝试放入串池
- 如果串池有, 则并不会放入. 返回已有的串池中的对象的地址
- 如果没有, 则会把此对象复制一份, 放入串池, 并返回串池中的对象地址
- JDK1.7 起, 将这个字符串对象尝试放入串池
- 如果串池中有, 则并不会放入. 返回已有的串池中的对象的地址
- 如果没有, 则会把对象的引用地址复制一份, 放入串池, 并返回传池中的引用地址
补充练习
demo1
@Test
public void test5() {
String s = new String("a") + new String("b");
String s2 = s.intern();
System.out.println(s2 == "ab");
System.out.println(s == "ab");
}
土味解析(JDK8)
line3: s(new堆地址值,@h1, 注意!!此处不会在串池生成)
line4: s2(串池没有, 返回@st1)
demo2
@Test
public void test6() {
String s1 = new String("ab");
// String s1 = new String("a") + new String("b");
s1.intern();
String s2 = "ab";
System.out.println(s1 == s2);
}
土味解析(JDK8)
====情况1
line3: s1(串池生成@st1, 返回@h1)
line5: 无效
line6: s2(返回@st1)
line7: false
====情况2
line4: s1(返回@h1)
line5: 串池无,则生成 @st1 指向 @h, 即@h
line6: s2(返回@h)
line7: true
demo3(自测)
@Test
public void test5() {
String x = "ab";
String s = new String("a") + new String("b");
String s2 = s.intern();
System.out.println(s2 == "ab");
System.out.println(s == "ab");
}
intern() 的效率测试(空间角度)
static final int MAX_COUNT = 1000 * 10000;
static final String[] arr = new String[MAX_COUNT];
@Test
public void test8() {
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++) {
arr[i] = new String(String.valueOf(data[i % data.length])); // 写法1
// arr[i] = new String(String.valueOf(data[i % data.length])).intern(); // 写法2
}
long end = System.currentTimeMillis();
System.out.println("花费的时间为:" + (end - start));
try {
Thread.sleep(1000000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.gc();
}
对于程序中大量存在的字符串, 尤其其中存在很多重复字符串时, 使用intern() 可以节省内存空间
第一种方式
arr[i] = new String(String.valueOf(data[i % data.length]));
在堆中确实创建了 1000W 个对象, 并且存放在数组中, 也不会被回收
第二种方式
arr[i] = new String(String.valueOf(data[i % data.length])).intern();
返回给数组的是串池中的引用地址, 因此即使new的对象也可以即使被销毁
大的网站平台, 需要内存中存储大量的字符串. 比如社交网站, 很多人都存储: 北京市、海淀区等信息. 这时候如果字符串都调用 intern() 方法, 就会明显降低内存的大小
6-StringTable的垃圾回收
/**
* String的垃圾回收:
* -Xms15m -Xmx15m -XX:+PrintStringTableStatistics -XX:+PrintGCDetails
*/
public class StringGCTest {
public static void main(String[] args) {
for (int j = 0; j < 100000; j++) {
String.valueOf(j).intern();
}
}
}
不断调整j
大小, 观察GC情况
7-G1中的String去重操作
官方文档:http://openjdk.java.net/jeps/192
- 背景:对许多 Java 应用(有大的也有小的)做的测试得出以下结果:
- 堆存活数据集合里面 String 对象占了25%
- 堆存活数据集合里面重复的 String 对象有13.5%
- String 对象的平均长度是45
- 许多大规模的 Java 应用的瓶颈在于内存,测试表明,在这些类型的应用里面,Java 堆中存活的数据集合差不多25%是 String 对象。更进一步,这里面差不多一半 String 对象是重复的,重复的意思是说:
str1.equals(str2)= true
。堆上存在重复的 String 对象必然是一种内存的浪费。这个项目将在 G1 垃圾收集器中实现自动持续对重复的 String 对象进行去重,这样就能避免浪费内存。
实现
- 当垃圾收集器工作的时候,会访问堆上存活的对象。对每一个访问的对象都会检查是否是候选的要去重的 String 对象。
- 如果是,把这个对象的一个引用插入到队列中等待后续的处理。一个去重的线程在后台运行,处理这个队列。处理队列的一个元素意味着从队列删除这个元素,然后尝试去重它引用的 String 对象。
- 使用一个 Hashtable 来记录所有的被 String 对象使用的不重复的 char 数组。当去重的时候,会查这个 Hashtable,来看堆上是否已经存在一个一模一样的 char 数组。
- 如果存在,String 对象会被调整引用那个数组,释放对原来的数组的引用,最终会被垃圾收集器回收掉。
- 如果查找失败,char 数组会被插入到 Hashtable,这样以后的时候就可以共享这个数组了。
命令行选项
- UseStringDeduplication(bool) :开启 String 去重,默认是不开启的,需要手动开启。
- PrintStringDeduplicationStatistics(bool) :打印详细的去重统计信息
- stringDeduplicationAgeThreshold(uintx) :达到这个年龄的 String 对象被认为是去重的候选对象