最近离职在家开始回顾一些java基础知识,相比初学时有了更深入和广泛的了解。不过以下皆为个人理解,可能有些地方理解有误,欢迎指出问题。
String的不可变性?
1. String为什么是不可变的?
直接的String不可变解释
1. String类使用final定义的,不允许继承。
2. String内部用于保存字符的char数组也定义为final的,初始化后便不在允许修改(此处仅char数组的引用地址不能改变,依旧可以通过反射改变数组内部的值)。
3. 未对外部提供修改内部char数组的方法,如setChar();
4. 提供的所有涉及修改的方法都是返回的新对象,如subString,concat。
2. 为什么要这么设计?
String类设计为不可变的原因很多,这里我挑几点常见的和容易理解的进行讲解。
1. 线程安全。不可变类自带线程安全属性。对字符串的操作将会在内存中重新生成一个字符串对象并返回内存地址,避免了多线程环境中操作同一个字符串对象从而产生的线程安全问题。
2. 哈希码缓存。开发中在使用如HashMap之类的hash结构时,通常是使用字符串作为键,字符串不可变,那同一个字符串对于同一个hash结构的hash算法来说,其值就是永远固定的,那么就可以将hash值缓存起来,避免了哈希值的重复计算,提高效率。同时我们都知道如HashMap的底层为数据 + 链表/红黑树,元素的存放位置是先根据hash函数算出来的哈希码确定放在哪一个数组位置的,如果作为键的String对象可变,那么这个改变后的String对象的哈希码也会改变,此时我们根据新的哈希码去找之前的元素,会出现找不到的情况。
3. 字符串常量池优化。String设计为不可变类则可以实现字符串常量池优化字符串使用,即复用相同的字符串。为什么只能不可变才能实现字符串常量池复用优化?可变不行吗?假设字符串对象是可变对象,首先我们都知道引入了字符串常量池的使用之后,例如String s = “hello”,会先去字符串常量池中查找“hello”这个字面量,如果存在则直接返回其引用(内存地址),如果不存在则新建一个”hello“字面量然后返回其引用。假设此时我们有两个线程,线程1和线程2,线程1首先执行代码,String s1 = ”hello“,此时字符串常量池中不存在”hello“字面量,会新建一个”hello“字面量并返回其引用(假设内存地址为0x110),然后cpu调度切换至线程2,线程2指定代码String s2 = ”hello“,由于此时字符串常量池中存在”hello“字面量,因此会直接返回其引用(也就是内存地址0x110),然后cpu再次调度至线程1执行代码,s1 = ”hello world”,由于这里假设String为可变类,那么0x110地址的常量池字面量内容将被修改为“hello word”,线程1继续执行代码System.out.println(s1),输出“hello world“没有问题,但是此时如果切换回线程2,线程2执行代码System.out.println(s2),此时输出的内容也是”hello world”,但是我们在线程2中明明定义的s2为“hello”。因此如果Stirng类设计将会使字符串常量池的复用功能变得无法使用(这里有点类似于线程不安全问题)同时如果String类不是不可变类,即便是在单线程环境中,String s1 = "hello"; String s2 = "hello";s1.toUpperCase(); System.out.println(s2); // 输出 "HELLO"也会出现问题。
String s = “hello” 和 String s = new String(“hello”)的区别?
1. String s = ”hello“
首先在字符串常量池中查找是否存在”hello”字面量,如果不存在则新建一个“hello”字面量并返回其引用,如果已经存在则直接返回其引用。
2. String s = new String("hello")
首先在字符串常量池中查找是否存在”hello”字面量,如果不存在则新建一个“hello”字面量然后在堆内存中新建一个内容为“hello”的String对象并返回堆内存中该String对象的引用,如果字符串常量池中已经存在“hello“字面量,则直接在堆内存中新建一个内容为“hello”的String对象并返回堆内存中该String对象的引用。(也就是说new String的方式不管怎么样都会在堆内存中创建一个对应的String对象,返回的也是这个堆内存中的对象引用,只是会先去字符串常量池中检查一下是否存在这个字面量,不存在则新建,存在则不做操作。)
3. 为什么String s = ”hello“ 和 String s = new String("hello") 要设计为两种不同的方式?而不是都直接使用字符串常量池,直接返回字符串常量池中字面量的引用?这样的设计有什么考量和作用?
灵活性。提供两种不同的方式供开发者使用自然可以提高业务的灵活性,因为我们都知道字符串常量池中的字面量是全局共享的,这会导致只要是内容相同的引用指向的都是用同一个地址。但是可能有时业务需求需要我们比较字符串时,必须严格按照内存地址去比较,内存地址相同的引用对象才能返回true。此时我们如果都按照字符串常量池去返回的引用内存地址,就会导致这个业务需求无法实现。简单来说就是一个班级内有两个名叫张三的同学,这两个同学的名字一样,但是他们并不是同一个人。
为什么字符串拼接要引入StringBulider
要说这一点我们可以先谈谈字符串常量池在jvm中的内存分配和生命周期。首先字符串常量池在jdk1.7以前,是放在永久代的,在jdk1.7及以后,是放在堆内存中的,将字符串常量池从永久代中移出,就是为了防止字符串常量池占用内存空间过大导致永久代发生oom(内存溢出),因此我们可以就可以知道字符串常量池的内存空间是宝贵的,尽可能不要随意的放一些无意义的字面量进去。虽然现在将字符串常量池移入了堆中,可以实现gc回收一部分无用的字符串常量,但是由于部分字符串常量的生命周期可能非常长,也会导致gc难以回收而浪费字符串常量池的空间。例如由方法区中运行时常量池引用的字面量或者静态区中静态变量引用的字面量,会在类加载时加入字符串常量池,并且要在类对应的类加载器被回收,且类被卸载时才会被gc回收,而这样的条件极为严苛,可以说这样的字面量会在字符串常量池中存活至jvm关闭。因此我们需尽可能不要放一些无意义的字面量进字符串常量池。
假设我们在拼接字符串String s = ”hello“ + ”world" 或者 String s = new String("hello") + String("world") 的方式进行拼接,将会在字符串常量池中创建两个字面量,分别为“hello”,“world”,堆内存中创建一个String对象其值为“helloword”,而对于我们来说“hello”和“world”都是无意义的中间临时字面量。(此处不考虑编译器优化,编译器会自动将代码优化为String s = ”helloworld" 或者 String s = new String("helloworld"。)
当我们使用循环的方式拼接字符串,循环方式就会产生许多无用的临时字面量,例如一个字符串数组,其值假设为["a","b","c"..."z"],长度为26,将其循环拼接为一个字符串。此时每一个a,b,c,d...z共26个字符串都将被添加到字符串常量池中,并且中间过程产生的拼接字符串,如ab,abc,abcd都会在堆内存中生成一个String对象,不仅无意义的占用字面量空间,并且还会浪费堆内存空间。
String[] arr = {"a", "b", "c", ..., "z"}; // 假设前端传递的数组包含 26 个字母
String result = "";
for (String s : arr) {
result += s; // 等价于 result = new StringBuilder().append(result).append(s).toString();
}
此时我们就需要用到StringBuilder缓存类,StringBuilder缓存类专门用于需要处理字符串拼接的业务场景,其内部是一个动态可变的char数组,调用append方法时只会修改该buffer对象内部的char数组,而不会在字符串常量池中产生字面量,并且直到最后,调用toString方式时,也仅是从堆中新建一个根据内部char数组的最终值在堆内存新建一个String对象并返回其引用,全程不会在字符串常量池中产生字面量。
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 100; i++) {
sb.append(i);
}
String s = sb.toString(); // 最终结果不入池
最后补充一个知识点,String对象的intern方法,这个方法的作用是显示的查找字符串常量池中对应的字面量,如果存在则直接返回该常量池中字面量的引用,不存在则在常量池中新建一个字面量并返回其引用。
再补充一个容易混淆的知识点,字符串常量池的检查与缓存时机?
首先动态的字符串对象是不会在字符串常量池中生成字面量的,常见如我们接口中接收两个字符串参数并进行操作,这里看似有对两个字符串对象进行操作,但是这两个字符串对象都是经过反序列化而来的动态字符串,他们是不会在字符串常量池中去生成字面量的,这也是java对字符串常量池的一个内存优化,防止大量的字符串常量导致的内存溢出,也就是说这里是不会去执行字符串常量池的检查和缓存的。
void fun(String fileName, String suffix){
fullFileName = fileName+ suffix; // 将前端传递的文件名和文件后缀拼接为完整文件名
}
那么何时会触发字符串常量池的检查和缓存呢?
我们需要先了解一个叫做类常量池的概念,这个常量池是存放一些类的元数据信息,存储类的元数据,包括符号引用(类名、方法名、字段名)以及字面量,这个类常量池是存储在类的.clss文件中的,其中就包括类编写时代码中写好的固定的字面量,如下示例。
public class demo{
private String str = "demo";
pirvate void sayHello(){
System.out.println("hello")
}
}
以上两个字面量,"demo"和“hello”都是在类编写(代码中)时就写好的固定的字面量,因此在将该java文件编译为.clss文件时,这两个字面量就会存放到.clss文件中的类常量池。当进行类加载的时候,jvm将会读取类常量池中的这些字面量,将其放入元空间(jdk1.8+)运行时常量池中(由于类常量池时存放在.clss文件按中的,也就是磁盘中,现在需要将其加载到jvm也就是内存中),然后会进行字面量的解析,也就是进行字符串常量池的缓存和检查,检查并生成字符串常量池中对应的字面量对象,运行时常量池中的字面量符号引用将转换为对象引用,并指向字符串常量池中的字面量对象。那如我们想在代码的运行过程中手动进行字符串常量池的检查和缓存怎么办呢?我们可以使用上面提到的String对象的intern()方法,该方法的作用就是手动继续字符串常量池的检查和缓存。也就是说正常开发中,除开我们在代码(类文件)中直接写好的字面量,除非我们手动调用intern方法,否则基本上是不会涉及到向字符串常量池中添加字面量的。(部分jvm可能会选择延迟解析,也就是类加载时不会去进行符号引用的解析,而是在实际访问时才回去进行符号引用的解析)