Bootstrap

JVM-- String详解,JVM中的String

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()方法。这个后面重点谈
  • Java 6及以前,字符串常量池存放在永久代。
  • Java 7中Oracle的工程师对字符串池的逻辑做了很大的改变,即将字符串常量池的位置调整到Java堆内
    • 所有的字符串都保存在堆(Heap)中,和其他普通对象一样,这样可以让你在进行调优应用时仅需要调整堆大小就可以了。
    • 字符串常量池概念原本使用得比较多,但是这个改动使得我们有足够的理由让我们重新考虑在Java 7中使用String.intern()
  • Java8元空间,字符串常量在堆

2.1 为什么StringTable要进行调整

  1. permSize默认比较小,容易出现OOM
  2. 永久代垃圾回收频率低

3. String 的基本操作

Java语言规范要求完全相同的字符串字面量,应该包含相同的Unicode字符序列(包含同一份码点序列的常量),并且必须是指向同一个String类实例。

3.1 测试字符串是否共用:

在这里插入图片描述

  1. 可以看到此时有2293个字符串
    在这里插入图片描述

  2. 可以看到此时有2294个字符串
    在这里插入图片描述

  3. 输出10的时候是2304个字符串:
    在这里插入图片描述

  4. 输出相同字符串时,数量并没有变
    在这里插入图片描述

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 结论:

  1. 常量与常量的拼接结果在常量池,原理是编译期优化
  2. 常量池中不会存在相同内容的常量。
  3. 只要其中有一个是变量,结果就在堆中(堆中常量池以外的位置)。变量拼接的原理是StringBuilder
  4. 如果拼接的结果调用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字节码指令:

  1. 字符串常量池的a
  2. 将a放在局部变量表1的位置(0的位置存放的是this)
  3. 字符串常量池的b
  4. 将b放在局部变量表2的位置
  5. 字符串常量池的ab
  6. 将ab放在局部标量表3的位置
  7. new一个StringBuilder,开辟空间
  8. 默认赋值
  9. 调用构造器
  10. 取出局部变量表1位置的数据a
  11. 调用StringBuilder的append方法,拼接a
  12. 取出2位置的数据b
  13. 调用append拼接b
  14. 调用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()方法没有在字符串常量池中生成字符串常量

代码:
在这里插入图片描述

  1. 第一个断点常量数量:
    在这里插入图片描述
  2. 第二个断点常量数量:
    在这里插入图片描述
  3. 第一个断点常量数量:
    在这里插入图片描述
  4. 第一个断点常量数量:
    在这里插入图片描述

可见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中图示:

  1. 首先在堆中生成一个ab的对象,地址是0x1122
  2. 调用s.intern()接口,在常量池中生成了ab的字符串常量,并将s2指向次常量地址0x2233
    在这里插入图片描述

jdk7图示:

  1. 首先在堆中生成一个ab的对象,地址是0x1122
  2. 调用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。

;