发布时间:2023-02-09 文章分类:编程知识 投稿人:赵颖 字号: 默认 | | 超大 打印

一、概述

String类的一个最大特性是不可修改性,而导致其不可修改的原因是在String内部定义了一个常量数组,因此每次对字符串的操作实际上都会另外分配分配一个新的常量数组空间。

二、创建字符串对象的方式

2.1 四种方式

方式一:直接赋值(常用)

// 直接赋值方式创建对象是在方法区的常量池
String str1 = "hello word";

方式二:通过构造方法产生对象

// 通过构造方法创建字符串对象是在堆内存
String str2 = new String("hello word");

方式三:通过字符数组产生对象

char[] data = new char[]{‘a’ , ‘b’ ,‘c’};
String str3 = new String(data);

方式四:通过String的静态方法valueOf(任意数据类型) = >转为字符串(常用)

String str4 = String.valueOf(10);

2.2 实例化方式的比较

1). 编写代码比较

public class TestString {
    public static void main(String[] args) {
        String str1 = "Lance";
        String str2 = new String("Lance");
        String str3 = str2; // 引用传递,str3直接指向st2的堆内存地址
        String str4 = "Lance";
        /**
         *  ==:
         * 基本数据类型:比较的是基本数据类型的值是否相同
         * 引用数据类型:比较的是引用数据类型的地址值是否相同
         * 所以在这里的话:String类对象==比较,比较的是地址,而不是内容
         */
         System.out.println(str1 == str2); // false
         System.out.println(str1 == str3); // false
         System.out.println(str3 == str2); // true
         System.out.println(str1 == str4); // true
    }
}

2). 内存图分析

Java常用类之String源码分析

可能这里还是不够明显,构造方法实例化方式的内存图:String str = new String("Hello");

首先:

Java常用类之String源码分析

当我们再一次的new一个String对象时:

Java常用类之String源码分析

3). 字符串常量池

在字符串中,如果采用直接赋值的方式(String str = "Lance")进行对象的实例化,则会将匿名对象“Lance”放入对象池,每当下一次对不同的对象进行直接赋值的时候会直接利用池中原有的匿名对象,

这样,所有直接赋值的String对象,如果利用相同的“Lance”,则String对象==返回true

比如:对象手工入池

public class TestString {
    public static void main(String args[]) {
        // 对匿名对象"hello"进行手工入池操作
        String str = new String("Lance").intern();
        String str1 = "Lance";
        System.out.println(str == str1); // true
    }
}

4). 总结:两种实例化方式的区别

  1. 直接赋值(String str = "hello"):只开辟一块堆内存空间,并且会自动入池,不会产生垃圾。
  2. 构造方法(String str= new String("hello");):会开辟两块堆内存空间,其中一块堆内存会变成垃圾被系统回收,而且不能够自动入池,需要通过intern()方法进行手工入池。

在开发的过程中不会采用构造方法进行字符串的实例化。

5). 避免空指向

首先了解:==equals()比较字符串的区别

==在对字符串比较的时候,对比的是内存地址,而equals比较的是字符串内容,在开发的过程中,equals()通过接受参数,可以避免空指向。

举例:

String str = null;
if (str.equals("hello")) {// 此时会出现空指向异常
    // ...
}
if ("hello".equals(str)) {// 此时equals会处理null值,可以避免空指向异常
    //...
}

6). String类对象一旦声明则不可以改变;而改变的只是地址,原来的字符串还是存在的,并且产生垃圾

Java常用类之String源码分析

三、源码分析

3.1 成员变量

public final class String
        implements java.io.Serializable, Comparable<String>, CharSequence {
    /** String的属性值 */
    private final char value[];
    /** The offset is the first index of the storage that is used. */
    /** 数组被使用的开始位置 **/
    private final int offset;
    /** The count is the number of characters in the String. */
    /** String中元素的个数 **/
    private final int count;
    /** Cache the hash code for the string */
    /** String类型的hash值 **/
    private int hash; // Default to 0
    /** use serialVersionUID from JDK 1.0.2 for interoperability */
    private static final long serialVersionUID = -6849794470754667710L;
    /**
     * Class String is special cased within the Serialization Stream Protocol.
     *
     * A String instance is written into an ObjectOutputStream according to
     * <a href="https://www.cnblogs.com/ciel717/archive/2023/02/09/{@docRoot}/../platform/serialization/spec/output.html">
     * Object Serialization Specification, Section 6.2, "Stream Elements"</a>
     */
    private static final ObjectStreamField[] serialPersistentFields =
            new ObjectStreamField[0];
}

成员变量可以知道String类的值是final类型的,不能被改变的,所以只要一个值改变就会生成一个新的String类型对象,存储String数据也不一定从数组的第0个元素开始的,而是从offset所指的元素开始。

另外,JDK9JDK8的类声明比较也有差异,下面是JDK9的类描述源码部分:

public final class String
    implements java.io.Serializable, Comparable<String>, CharSequence {
    /** The value is used for character storage. */
    @Stable
    private final byte[] value;
    private final byte coder;
    @Native static final byte LATIN1 = 0;
    @Native static final byte UTF16  = 1;
    static final boolean COMPACT_STRINGS;
    static {
        COMPACT_STRINGS = true;
    }
}

而实际上,JDKString类的存储优化由来已久了:

Java常用类之String源码分析

3.2 构造方法

String()
          初始化一个新创建的 String 对象,使其表示一个空字符序列。 
String(byte[] bytes) 
          通过使用平台的默认字符集解码指定的 byte 数组,构造一个新的 String。 
String(byte[] bytes, Charset charset) 
          通过使用指定的 charset 解码指定的 byte 数组,构造一个新的 String。  
String(byte[] bytes, int offset, int length) 
          通过使用平台的默认字符集解码指定的 byte 子数组,构造一个新的 String。 
String(byte[] bytes, int offset, int length, Charset charset) 
          通过使用指定的 charset 解码指定的 byte 子数组,构造一个新的 String。 
String(byte[] bytes, int offset, int length, String charsetName) 
          通过使用指定的字符集解码指定的 byte 子数组,构造一个新的 String。 
String(byte[] bytes, String charsetName) 
          通过使用指定的 charset 解码指定的 byte 数组,构造一个新的 String。 
String(char[] value) 
          分配一个新的 String,使其表示字符数组参数中当前包含的字符序列。 
String(char[] value, int offset, int count) 
          分配一个新的 String,它包含取自字符数组参数一个子数组的字符。 
String(int[] codePoints, int offset, int count) 
          分配一个新的 String,它包含 Unicode 代码点数组参数一个子数组的字符。 
String(String original) 
          初始化一个新创建的 String 对象,使其表示一个与参数相同的字符序列;换句话说,新创建的字符串是该参数字符串的副本。 
String(StringBuffer buffer) 
          分配一个新的字符串,它包含字符串缓冲区参数中当前包含的字符序列。 
String(StringBuilder builder) 
          分配一个新的字符串,它包含字符串生成器参数中当前包含的字符序列。
构造方法 描述
String() 初始化一个新创建的String对象,使其表示一个空字符序列。
String(byte[] bytes) 通过使用平台的默认字符集解码指定的byte数组,构造一个新的String。
String(byte[] bytes, Charset charset) 通过使用指定的charset解码指定的byte数组,构造一个新的String。
String(byte[] bytes, int offset, int length) 通过使用平台的默认字符集解码指定的byte子数组,构造一个新的String。
String(byte[] bytes, int offset, int length, Charset charset) 通过使用指定的charset解码指定的byte子数组,构造一个新的String。
String(byte[] bytes, int offset, int length, String charsetName) 通过使用指定的字符集解码指定的byte子数组,构造一个新的String。
String(byte[] bytes, String charsetName) 通过使用指定的charset解码指定的byte数组,构造一个新的String。
String(char[] value) 分配一个新的 String,使其表示字符数组参数中当前包含的字符序列。
String(char[] value, int offset, int count) 分配一个新的String,它包含取自字符数组参数一个子数组的字符。
String(int[] codePoints, int offset, int count) 分配一个新的String,它包含Unicode代码点数组参数一个子数组的字符。
String(String original) 初始化一个新创建的String对象,使其表示一个与参数相同的字符序列;换句话说,新创建的字符串是该参数字符串的副本。
String(StringBuffer buffer) 分配一个新的字符串,它包含字符串缓冲区参数中当前包含的字符序列。
String(StringBuilder builder) 分配一个新的字符串,它包含字符串生成器参数中当前包含的字符序列。

3.3 常用方法

Java常用类之String源码分析

3.3.1 判断功能

1). 常用方法

boolean equals(Object obj):比较字符串的内容是否相同
boolean equalsIgnoreCase(String str): 比较字符串的内容是否相同,忽略大小写
boolean startsWith(String str): 判断字符串对象是否以指定的str开头
boolean endsWith(String str): 判断字符串对象是否以指定的str结尾

2).代码测试

public class TestString {
    public static void main(String[] args) {
        // 创建字符串对象
        String s1 = "hello";
        String s2 = "hello";
        String s3 = "Hello";
        // boolean equals(Object obj):比较字符串的内容是否相同
        System.out.println(s1.equals(s2));
        System.out.println(s1.equals(s3));
        System.out.println("-----------");
        // boolean equalsIgnoreCase(String str):比较字符串的内容是否相同,忽略大小写
        System.out.println(s1.equalsIgnoreCase(s2));
        System.out.println(s1.equalsIgnoreCase(s3));
        System.out.println("-----------");
        // boolean startsWith(String str):判断字符串对象是否以指定的str开头
        System.out.println(s1.startsWith("he"));
        System.out.println(s1.startsWith("ll"));
    }
}

结果:

true
false
-----------
true
true
-----------
true
false

3.3.2 获取功能

1)常用方法

int length(): 获取字符串的长度,其实也就是字符个数
char charAt(int index): 获取指定索引处的字符
int indexOf(String str): 获取str在字符串对象中第一次出现的索引
String substring(int start): 从start开始截取字符串
String substring(int start, int end): 从start开始,到end结束截取字符串。包括start,不包括end

2)代码测试

public class TestString {
    public static void main(String[] args) {
        // 创建字符串对象
        String s = "helloworld";
        // int length():获取字符串的长度,其实也就是字符个数
        System.out.println(s.length());
        System.out.println("--------");
        // char charAt(int index):获取指定索引处的字符
        System.out.println(s.charAt(0));
        System.out.println(s.charAt(1));
        System.out.println("--------");
        // int indexOf(String str):获取str在字符串对象中第一次出现的索引
        System.out.println(s.indexOf("l"));
        System.out.println(s.indexOf("owo"));
        System.out.println(s.indexOf("ak"));
        System.out.println("--------");
        // String substring(int start):从start开始截取字符串
        System.out.println(s.substring(0));
        System.out.println(s.substring(5));
        System.out.println("--------");
        // String substring(int start,int end):从start开始,到end结束截取字符串
        System.out.println(s.substring(0, s.length()));
        System.out.println(s.substring(3, 8));
    }
}

结果:

10
--------
h
e
--------
2
4
-1
--------
helloworld
world
--------
helloworld
lowor

3.3.3 转换功能

1)常用方法

char[] toCharArray():把字符串转换为字符数组
String toLowerCase():把字符串转换为小写字符串
String toUpperCase():把字符串转换为大写字符串

2)核心代码

public class TestString {
    public static void main(String[] args) {
         // 创建字符串对象
        String s = "abcde";
        // char[] toCharArray():把字符串转换为字符数组
        char[] chs = s.toCharArray();
        for (int x = 0; x < chs.length; x++) {
            System.out.println(chs[x]);
        }
        System.out.println("-----------");
        // String toLowerCase():把字符串转换为小写字符串
        System.out.println("HelloWorld".toLowerCase());
        // String toUpperCase():把字符串转换为大写字符串
        System.out.println("HelloWorld".toUpperCase());
    }
}

结果:

a
b
c
d
e
-----------
helloworld
HELLOWORLD

注意:

字符串的遍历有两种方式:一是length()加上charAt()。二是把字符串转换为字符数组,然后遍历数组。

3.3.4 其他常用方法

1)常用方法

String trim():去除字符串两端空格
String[] split(String str):按照指定符号分割字符串

2)核心代码

public class TestString {
    public static void main(String[] args) {
        // 创建字符串对象
        String s1 = "helloworld";
        String s2 = " helloworld ";
        String s3 = " hello world ";
        System.out.println("---" + s1 + "---");
        System.out.println("---" + s1.trim() + "---");
        System.out.println("---" + s2 + "---");
        System.out.println("---" + s2.trim() + "---");
        System.out.println("---" + s3 + "---");
        System.out.println("---" + s3.trim() + "---");
        System.out.println("-------------------");
        // String[] split(String str)
        // 创建字符串对象
        String s4 = "aa,bb,cc";
        String[] strArray = s4.split(",");
        for (int x = 0; x < strArray.length; x++) {
            System.out.println(strArray[x]);
        }
    }
}

结果:

---helloworld---
---helloworld---
--- helloworld ---
---helloworld---
--- hello world ---
---hello world---
-------------------
aa
bb
cc

四、String的不可变性(immutable)

当我们去阅读源代码的时候,会发现有这样的一句话:

Strings are constant; their values cannot be changed after they are created.

意思就是说:String是个常量,从一出生就注定不可变。

我想大家应该就知道为什么String不可变了,String类被final修饰,官方注释说明创建后不能被改变,但是为什么String要使用final修饰呢?

4.1 案例

了解一个经典的面试题:

public class Apple {
    public static void main(String[] args) {
        String a = "abc";
        String b = "abc";
        String c = new String("abc");
        System.out.println(a == b);       // true
        System.out.println(a.equals(b));  // true
        System.out.println(a == c);       // false
        System.out.println(a.equals(c));  // true
    }
}

内存图:

Java常用类之String源码分析

4.2 分析

因为String太过常用,JAVA类库的设计者在实现时做了个小小的变化,即采用了享元模式,每当生成一个新内容的字符串时,他们都被添加到一个共享池中,当第二次再次生成同样内容的字符串实例时,就共享此对象,而不是创建一个新对象,但是这样的做法仅仅适合于通过=符号进行的初始化。

需要说明一点的是,在object中,equals()是用来比较内存地址的,但是String重写了equals()方法,用来比较内容的,即使是不同地址,只要内容一致,也会返回true,这也就是为什么a.equals(c)返回true的原因了。

4.3 String不可变的好处

可以实现多个变量引用堆内存中的同一个字符串实例,避免创建的开销。

我们的程序中大量使用了String字符串,有可能是出于安全性考虑。

大家都知道HashMapkeyString类型,如果可变将变的多么可怕。

当我们在传参的时候,使用不可变类不需要去考虑谁可能会修改其内部的值,如果使用可变类的话,可能需要每次记得重新拷贝出里面的值,性能会有一定的损失。

五、字符串常量池

5.1 字符串常量池概述

1). 常量池表(Constant_Pool table)

Class文件中存储所有常量(包括字符串)的table
这是Class文件中的内容,还不是运行时的内容,不要理解它是个池子,其实就是Class文件中的字节码指令。

2). 运行时常量池(Runtime Constant Pool)

JVM内存中方法区的一部分,这是运行时的内容。
这部分内容(绝大部分)是随着JVM运行时候,从常量池转化而来,每个Class对应一个运行时常量池。
上一句中说绝大部分是因为:除了Class中常量池内容,还可能包括动态生成并加入这里的内容。

3). 字符串常量池(String Pool)

这部分也在方法区中,但与Runtime Constant Pool不是一个概念,String PoolJVM实例全局共享的,全局只有一个JVM规范要求进入这里的String实例叫“被驻留的interned string”,各个JVM可以有不同的实现,HotSpot是设置了一个哈希表StringTable来引用堆中的字符串实例,被引用就是被驻留。

5.2 亨元模式

其实字符串常量池这个问题涉及到一个设计模式,叫“享元模式”,顾名思义 - - - > 共享元素模式
也就是说:一个系统中如果有多处用到了相同的一个元素,那么我们应该只存储一份此元素,而让所有地方都引用这一个元素
JavaString部分就是根据享元模式设计的,而那个存储元素的地方就叫做“字符串常量池 - String Pool”

5.3 详细分析

举例:

int x  = 10;
String y = "hello";
  1. 首先,10"hello"会在经过javac(或者其他编译器)编译过后变为Class文件中constant_pool table的内容
  2. 当我们的程序运行时,也就是说JVM运行时,每个Classconstant_pool table中的内容会被加载到JVM内存中的方法区中各自Class<span>Runtime Constant Pool</span>。
  3. 一个没有被String Pool包含的Runtime Constant Pool中的字符串(这里是"hello")会被加入到String Pool中(HosSpot使用hashtable引用方式),步骤如下:
    一是:在Java Heap中根据"hello"字面量create一个字符串对象
    二是:将字面量"hello"与字符串对象的引用在hashtable中关联起来,键 - 值 形式是:"hello" = 对象的引用地址。

另外来说,当一个新的字符串出现在Runtime Constant Pool中时怎么判断需不需要在Java Heap中创建新对象呢?

策略是这样:会先去根据equals来比较Runtime Constant Pool中的这个字符串是否和String Pool中某一个是相等的(也就是找是否已经存在),如果有那么就不创建,直接使用其引用;反之,如上3

如此,就实现了享元模式,提高的内存利用效率。

六、总结

string对象在内存对中被创建后,就无法修改。如果需要一个可修改的字符串,应该使用StringBuffer或者StringBuilder。如果只需要创建一个字符串,可以使用引号的方式,如果在堆中创建一个新的对象,可以选择构造函数。

七、拓展

7.1 new String()会创建几个对象

String s = new String("hello");

会创建2个对象

首先,出现了字面量"hello",那么去String Pool中查找是否有相同字符串存在,因为程序就这一行代码所以肯定没有,那么就在Java Heap中用字面量"hello"首先创建1String对象。
接着,new String("hello"),关键字new又在Java Heap中创建了1个对象,然后调用接收String参数的构造器进行了初始化。最终s的引用是这个String对象。

7.2 String 真的不可变吗?

前面我们介绍了,String类是用final关键字修饰的,所以我们认为其是不可变对象。但是真的不可变吗?

每个字符串都是由许多单个字符组成的,我们知道其源码是由char[] value字符数组构成。

valuefinal修饰,只能保证引用不被改变,但是value所指向的堆中的数组,才是真实的数据,只要能够操作堆中的数组,依旧能改变数据。而且value是基本类型构成,那么一定是可变的,即使被声明为private,我们也可以通过反射来改变。

String str = "vae";
// 打印原字符串
System.out.println(str);//vae
// 获取String类中的value字段
Field fieldStr = String.class.getDeclaredField("value");
// 因为value是private声明的,这里修改其访问权限
fieldStr.setAccessible(true);
// 获取str对象上的value属性的值
char[] value = (char[]) fieldStr.get(str);
// 将第一个字符修改为V(小写改大写)
value[0] = 'V';
// 打印修改之后的字符串
System.out.println(str);//Vae

通过前后两次打印的结果,我们可以看到String被改变了,但是在代码里,几乎不会使用反射的机制去操作String字符串,所以,我们会认为String类型是不可变的。

那么,String类为什么要这样设计成不可变呢?我们可以从性能以及安全方面来考虑:

引发安全问题,譬如数据库的用户名、密码都是以字符串的形式传入来获得数据库的连接,或者在socket编程中,主机名和端口都是以字符串的形式传入。因为字符串是不可变的,所以它的值是不可改变的,如果改变字符串指向的对象的值,就会造成安全漏洞。

保证线程安全,在并发场景下,多个线程同时读写资源时,会引竞态条件,由于String是不可变的,不会引发线程的问题而保证了线程。

HashCode,当String被创建出来的时候,hashcode也会随之被缓存,hashcode的计算与value有关,若String可变,那么hashcode也会随之变化,针对于MapSet等容器,他们的键值需要保证唯一性和一致性,因此,String的不可变性使其比其他对象更适合当容器的键值。

当字符串是不可变时,字符串常量池才有意义。字符串常量池的出现,可以减少创建相同字面量的字符串,让不同的引用指向池中同一个字符串,为运行时节约很多的堆内存。若字符串可变,字符串常量池失去意义,基于常量池的String.intern()方法也失效,每次创建新的String将在堆内开辟出新的空间,占据更多的内存。

参考文章