作者:~小明学编程
文章专栏:JavaSE基础
格言:目之所及皆为回忆,心之所想皆为过往
目录
创建字符串
字符串的比较
字符串常量池
字符串常量池的构成
intern()方法
经典字符串比较题目
理解字符串不可变
这里首先介绍常见的创建字符串的三种方法。
public static void main(String[] args) {
String str1 = "Hello world";//方法一
String str2 = new String("Hi World");//方法二
char[] array = {'h','a','h','a'};
String str3 = new String(array);//方法三
System.out.println(str1);
System.out.println(str2);
System.out.println(str3);
}
下面我们来介绍一下String str1 = "Hello world";在内存中是如何布局的。
首先我们的str1会在栈区开辟一块空间存放我们的一个地址,这个地址指向的是堆区的一块空间我们的字符串“Hello world”就存在我们的堆区之中。我们的str1就是前面我们所说的引用类型,类似于C中的指针但是远没有指针那么灵活。
String str1 = "Hello world";
String str2 = str1;
上述这段代码在内存中的布局就如下所示了。
我们str2接受了str1的一个地址,最终指向的都是同一个地方。
下面我们进行如下的操作:
String str1 = "Hello world";
str1 = "haha";
我们将str1原本的"Hello world"改为"haha"将会如何呢?
这时候我们改变的只是我们str1中的地址并不是字符串里面的内容,简单来说就是改变了我们的指向关系。
再说字符串比较之前我们先来回顾一下我们普通类型变量之间的比较:
int x = 10 ;
int y = 10 ;
System.out.println(x == y);//true
这个结果显而易见,当我们对两个字符串进行比较那么会发生什么呢?它们比的又是什么呢?
先看例子:
//代码一
public static void main2(String[] args) {
String str1 = "hello";
String str2 = "hell0";
System.out.println(str1==str2);//true
}
这段代码的结构为true和大家想的答案肯定也一样,那么我们如果采用第二种创建字符串的方式结果是否还一样呢?
//代码二
public static void main2(String[] args) {
String str1 = "hello";
String str2 = new String("hell0");
System.out.println(str1==str2);//false
}
这里的结果就变成了false了,这里是为什么呢?我们同样都是创建字符串,而且都是同一个字符串只是我们创建的方式不一样而已结果为何会不相等?
带着这一系列的疑问在这里给大家介绍一一个新的知识点叫做字符串常量池。
String str1 = "hello" ;
String str2 = "hello" ;
String str3 = "hello" ;
System.out.println(str1 == str2); // true
System.out.println(str1 == str3); // true
System.out.println(str2 == str3); // true
对于这段代码想必大家也知道答案是什么,上述创建字符串的方式属于我们的第一种方式,也正是这种方式才会将我们的字符串放入字符串常量池之中。
如 "hello" 这样的字符串字面值常量, 也是需要一定的内存空间来存储的. 这样的常量具有一个特点, 就是不需要修改(常量嘛). 所以如果代码中有多个地方引用都需要使用 "Hello" 的话, 就直接引用到常量池的这个位置就行了, 而没必要把 "hello" 在内存中存储两次.
在介绍字符串常量池之前我们先来看一下我们Sting类的部分源码。
private final char value[];
/** Cache the hash code for the string */
private int hash; // Default to 0
我们要关注的是这两行的内容,其实我们的String类主要就是由这两个部分组成的,一个是value另外一个就是hash,value是一个数组里面装的是我们的字符串的内容,而hash则是生成的一个hash值,有什么用呢?用处就在于匹配我们的字符串常量池。
public static void main(String[] args) {
String str1 = "hello";
String str2 = "hello";
String str3 = new String("hello");
}
字符串常量池如图右所示,里面是一个又一个的地址,每一个地址指向一个节点这个节点中包含了三个部分,分别是哈希值,String类的对象,和next(这里涉及到哈希表就不作详细解释了),我们的这块内容就被维护起来了。当我们String str1 = "hello";的时候会创建我们0x88和0x77这两块内容,0x88中有一个hash值然后通过这个hash去常量池中找到相应的区域将我们的"hello"放入字符串常量池之中。
我们的str2同样也是创建了一个字符串"hello"这时候我们在字符串常量池中会搜索一下有没有"hello"这个字符串一旦发现有了,就会把0x88的地址给str2,这样我们str2就不需要再次开辟一块空间了,这样同时也节省了空间。
关于str3,采用构造方法
String str3 = new String("hello");
这里我们是new了一块空间了,所以将会在堆上开辟一块新的空间也就是我们上图中的0x55虽然我们value里面的值是一样的,但是我们还是开辟了一块堆空间造成了一定的空间浪费。
前面说到了我们使用构造方法的方式将不会将字符串入池,那么有没有什么办法强行让其入池呢?答案是有的,那就是我们的inter方法。
public static void main(String[] args) {
String str1 = new String("hello").intern();
String str2 = "hello";
System.out.println(str1==str2);
}//true
我们可以看到在我们调用完intern方法之后,我们的"hello"已经入池了,下面自然而然将会打印true.
public static void main(String[] args) {
String str1 = "hello";
String str2 = "hell"+"o";
System.out.println(str1==str2);
}
上述代码将会打印什么呢?
答案是我们的true。
我们的str2会率先将字符串进行一个拼接,拼接完了之后然后会去字符串常量池中检查一下拼接之后的字符串是否已经入池,如果是就不会再创建新的对象了,还会用原来的地址,所以我们打印的就是true。
public static void main(String[] args) {
String str1 = "hello";
String str2 = "hell";
String str3 = "o";
String str4 = str2+str3;
System.out.println(str1==str4);
}
这段代码的结果又是什么呢?
答案是false。
这是因为我们的str2和str3在编译的时候会被当作变量,根本不知道是啥,然后我们的str4将会创建一个新的对象然后将str2和str3进行一个拼接。
我们通过调试可以发现str1和str4里面的value值都不一样。
字符串是一种不可变对象,它的内容不可改变。
String 类的内部实现也是基于 char[] 来实现的, 但是 String 类并没有提供 set 方法之类的来修改内部的字符数组。
在我们刚刚展示的源码中我们也可以看到我们的value即被我们的final给修饰了禁止改动,同样也被我们的private修饰为私有了我们无法调用。那么形如下面代码该怎么去解释呢?
public static void main(String[] args) {
String str1 = "hello";
str1 += " world!";
System.out.println(str1);
}
//hello world!
这里我们的str1不是被改成了"hello world!"了吗?
答案并非如此,我们改动的只是我们的引用的地址我们的str1只是重新指向了一个新的位置,并非是被改动的。
那么如果实在需要修改字符串, 例如, 现有字符串 str = "Hello" , 想改成 str = "hello" , 该怎么办?
a) 常见办法: 借助原字符串, 创建新的字符串。
public static void main(String[] args) {
String str = "Hello";
str = "h" + str.substring(1);
System.out.println(str);
}
此时的str1就是"hello"了。
所以改变的并不是原来的字符串,而是value指向的地址。
特殊办法(选学): 使用 "反射" 这样的操作可以破坏封装, 访问一个类内部的 private 成员.
这里简单介绍
public static void main(String[] args) throws NoSuchFieldException, IllegalAccessException {
String str = "Hello";
// 获取 String 类中的 value 字段. 这个 value 和 String 源码中的 value 是匹配的.
Field valueField = String.class.getDeclaredField("value");
// 将这个字段的访问属性设为 true
valueField.setAccessible(true);
// 把 str 中的 value 属性获取到.
char[] value = (char[]) valueField.get(str);
// 修改 value 的值
value[0] = 'h';
System.out.println(str);
}
我们可以看到我们value的地址从始至终都没有改变。
关于反射
反射是面向对象编程的一种重要特性, 有些编程语言也称为 "自省".
指的是程序运行过程中, 获取/修改某个对象的详细信息(类型信息, 属性信息等), 相当于让一个对象更好的 "认清自己" .
Java 中使用反射比较麻烦一些. 我们后面的课程中会详细介绍反射的具体用法
为什么 String 要不可变?(不可变对象的好处是什么?) (选学)
1. 方便实现字符串对象池. 如果 String 可变, 那么对象池就需要考虑何时深拷贝字符串的问题了.
2. 不可变对象是线程安全的.
3. 不可变对象更方便缓存 hash code, 作为 key 时可以更高效的保存到 HashMap 中
我们来看一下下面的这段代码:
String str = "hello" ;
for(int x = 0; x < 1000; x++) {
str += x ;
}
System.out.println(str);
这段代码的问题很大,运行的过程中我们字符串的拼接会被优化为StringBuilder对象,也就是说在这个过程中我们将会创建大量的StringBuilder对象,这样既消耗了内存又消耗了时间,所以我们应该避免写出这种代码。