1.Java数组

1.1 掌握Java数组

  • 数组是一个对象,它包含了一组固定数量的元素,并且这些元素的类型是相同的。数组会按照索引的方式将元素放在指定的位置上,意味着我们可以通过索引来访问这些元素。在 Java 中,索引是从 0 开始的。

数组的声明与初始化:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 声明数组:
int[] anArray; //推荐

int anOtherArray[];

// 数组初始化:
int[] anArray = new int[10];
/*
上面这行代码中使用了 new 关键字,这就意味着数组的确是一个对象,只有对象的创建才会用到 new 关键字,基本数据类型是不用的。然后,我们需要在方括号中指定数组的长度。
这时候,数组中的每个元素都会被初始化为默认值,int 类型的就为 0,Object 类型的就为 null。 不同数据类型的默认值不同
*/

// 另外,还可以使用大括号的方式,直接初始化数组中的元素:

int anOtherArray[] = new int[] {1, 2, 3, 4, 5};

访问数组:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 可以通过索引来访问数组的元素,如果索引的值超出了数组的界限,就会抛出 ArrayIndexOutOfBoundException。

anArray[0] = 10;

// 当数组的元素非常多的时候,逐个访问数组就太辛苦了,所以需要通过遍历的方式。

// 第一种,使用 for 循环:

int anOtherArray[] = new int[] {1, 2, 3, 4, 5};
for (int i = 0; i < anOtherArray.length; i++) {
System.out.println(anOtherArray[i]);
}

// 第二种,使用 for-each 循环:

for (int element : anOtherArray) {
System.out.println(element);
}

// 如果不需要关心索引的话(意味着不需要修改数组的某个元素),使用 for-each 遍历更简洁一些。当然,也可以使用 while 和 do-while 循环。

数组转List

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 最原始的方式,就是通过遍历数组的方式,一个个将数组添加到 List 中。

int[] anArray = new int[] {1, 2, 3, 4, 5};

List<Integer> aList = new ArrayList<>();
for (int element : anArray) {
aList.add(element);
}

// 更优雅的方式是通过 Arrays 类的 asList() 方法:

List<Integer> aList = Arrays.asList(anArray);

// 不过需要注意的是,Arrays.asList 的参数需要是 Integer 数组,而 anArray 目前是 int 类型,我们需要换另外一种方式:

List<Integer> aList = Arrays.stream(anArray).boxed().collect(Collectors.toList());

对数组进行排序

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 如果想对数组进行排序的话,可以使用 Arrays 类提供的 sort() 方法。

int[] anArray = new int[] {5, 2, 1, 4, 8};
Arrays.sort(anArray);

// 排序后的结果:[1, 2, 4, 5, 8]

String[] yetAnotherArray = new String[] {"A", "E", "Z", "B", "C"};
Arrays.sort(yetAnotherArray, 1, 3,
Comparator.comparing(String::toString).reversed());

// 只对 1-3 位置上的元素进行反序,结果:[A, Z, E, B, C]

// 如果数组提前进行了排序,就可以使用二分查找法,这样效率就会更高一些。Arrays.binarySearch() 方法可供我们使用,它需要传递一个数组,和要查找的元素。

int[] anArray = new int[] {1, 2, 3, 4, 5};
int index = Arrays.binarySearch(anArray, 4);

1.2掌握Java二维数组

  • 二维数组是一种数据类型,可以存储多行和多列的数据。它由一系列的行和列组成,每个元素都可以通过一个行索引和列索引来访问。
1
2
3
4
5
6
7
// 例如,一个3行4列的二维数组可以表示为以下形式:

array = [
[a, b, c, d],
[e, f, g, h],
[i, j, k, l]
]

创建二维数组

  • 要在 Java 中创建二维数组,你必须指定要存储在数组中的数据类型,后跟两个方括号和数组的名称。
1
2
3
4
5
6
// 语法如下所示:

data_type[][] array_name;
// 代码示例:

int[][] oddNumbers = { {1, 3, 5, 7}, {9, 11, 13, 15} };

访问二维数组中的元素

1
2
3
4
5
6
7
8
9
10
11
12
13
14
int[][] oddNumbers = { {1, 3, 5, 7}, {9, 11, 13, 15}, {17, 19, 21, 23} };

System.out.println(oddNumbers[2][2]);
// 21

// 可以使用嵌套循环,遍历二维数组中的所有项目:

int[][] oddNumbers = { {1, 3, 5, 7}, {9, 11, 13, 15}, {17, 19, 21, 23} };

for(int i = 0; i < oddNumbers.length; i++){
for(int j = 0; j < oddNumbers[i].length; j++){
System.out.println(oddNumbers[i][j]);
}
}

1.3 打印Java数组

  • {% span blue, 数组是一个对象 %}

为什么不能直接打印数组

1
2
3
4
5
6
7
8
9
10
11
12
String [] cmowers = {"这是","一个","Java数组"};
System.out.println(cmowers);

// 程序打印的结果是:[Ljava.lang.String;@3d075dc0
// [Ljava.lang.String; 表示字符串数组的 Class 名,@ 后面的是十六进制的 hashCode

// java.lang.Object 类的 toString():

public String toString() {
return getClass().getName() + "@" + Integer.toHexString(hashCode());
}
// 这证明了,数组虽然没有显式定义成一个类,但它的确是一个对象,继承了祖先类 Object 的所有方法。

stream 流打印 Java 数组

1
2
3
4
5
6
7
8
9
10
11
// 第一种形式:

Arrays.asList(cmowers).stream().forEach(s -> System.out.println(s));

// 第二种形式:

Stream.of(cmowers).forEach(System.out::println);

// 第三种形式:

Arrays.stream(cmowers).forEach(System.out::println);

for 循环打印 Java 数组

1
2
3
4
5
6
7
for(int i = 0; i < cmowers.length; i++){
System.out.println(cmowers[i]);
}

for (String s : cmowers) {
System.out.println(s);
}

Arrays 工具类打印 Java 数组

  • {% span blue, Arrays.toString() 是打印数组的最佳方式,没有之一!%}Arrays.toString() 可以将任意类型的数组转成字符串,包括基本类型数组和引用类型数组。
1
2
3
4
5
String [] cmowers = {"这是","一个","Java数组"};
System.out.println(Arrays.toString(cmowers));
程序打印结果:

[这是, 一个, Java数组]

Arrays工具类打印二维数组

1
2
3
4
5
6
7
// 可以使用 Arrays.deepToString() 方法:

String[][] deepArray = new String[][] {{"这是", "一个"}, {"Java二维数组"}};
System.out.println(Arrays.deepToString(deepArray));
// 打印结果如下所示。

// [[这是, 一个], [Java二维数组]]

  • POJO打印规约:POJO类必须写toString方法,使用IDE中的工具:source>generate toString时,如果继承了另一个POJO类,注意在前面加super.toString。
  • POJO,就是 Plain Ordinary Java Object 的缩写,一般在 Web 应用程序中建立一个数据库的映射对象时,我们称它为 POJO,这类对象不继承或不实现任何其它 Java 框架的类或接口。

2.String类

2.1 解读String类源码

String类的声明

1
2
3
public final class String
implements java.io.Serializable, Comparable<String>, CharSequence {
}
  1. String 类是 final 的,意味着它不能被子类继承。

  2. String 类实现了 Serializable 接口,意味着它可以序列化。

  3. String 类实现了 Comparable 接口,意味着最好不要用‘==’来比较两个字符串是否相等,而应该用 compareTo() 方法去比较,{% span blue, 因为 == 是用来比较两个对象的地址。%}如果只是说比较字符串内容的话,可以使用 String 类的 equals 方法。

  4. StringBuffer、StringBuilder 和 String 一样,都实现了 CharSequence 接口,所以它们仨属于近亲。由于 String 是不可变的,所以遇到字符串拼接的时候就可以考虑一下 String 的另外两个好兄弟,StringBuffer 和 StringBuilder,它俩是可变的。

String 类的底层实现

  • private final char value[];
  1. Java 9 以前,String 是用 char 型数组实现的,之后改成了 byte 型数组实现,并增加了 coder 来表示编码。这样做的好处是在 Latin1 字符为主的程序里,可以把 String 占用的内存减少一半。当然,天下没有免费的午餐,这个改进在节省内存的同时引入了编码检测的开销。
1
2
3
4
5
6
7
public final class String
implements java.io.Serializable, Comparable<String>, CharSequence {
@Stable
private final byte[] value;
private final byte coder;
private int hash;
}
  • 从 char[] 到 byte[],最主要的目的是节省字符串占用的内存空间。内存占用减少带来的另外一个好处,就是 GC 次数也会减少。(从 char[] 到 byte[],中文是两个字节,纯英文是一个字节,在此之前,中文是两个字节,英文也是两个字节。)

String 类的 hashCode 方法

  1. 每一个字符串都会有一个 hash 值,这个哈希值在很大概率是不会重复的,因此 String 很适合来作为 HashMap 的键值。
  • hashCode 方法首先检查是否已经计算过哈希码,如果已经计算过,则直接返回缓存的哈希码。否则,方法将使用一个循环遍历字符串的所有字符,并使用一个乘法和加法的组合计算哈希码。这种计算方法被称为“31 倍哈希法”。计算完成后,将得到的哈希值存储在 hash 成员变量中,以便下次调用 hashCode 方法时直接返回该值,而不需要重新计算。这是一种缓存优化,称为“惰性计算”。

  • 31倍哈希法(31-Hash)是一种简单有效的字符串哈希算法,常用于对字符串进行哈希处理。该算法的基本思想是将字符串中的每个字符乘以一个固定的质数31的幂次方,并将它们相加得到哈希值。具体地,假设字符串为s,长度为n,则31倍哈希值计算公式如下:

  • H(s) = (s[0] * 31^(n-1)) + (s[1] * 31^(n-2)) + … + (s[n-1] * 31^0)
    其中,s[i]表示字符串s中第i个字符的ASCII码值,^表示幂运算。

  • 31倍哈希法的优点在于简单易实现,计算速度快,同时也比较均匀地分布在哈希表中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// String 类的 hashCode 方法:

private int hash; // Cache the hash code for the string

public int hashCode() {
int h = hash;
if (h == 0 && value.length > 0) {
char val[] = value;

for (int i = 0; i < value.length; i++) {
h = 31 * h + val[i];
}
hash = h;
}
return h;
}

String 类的 substring 方法

1
2
3
4
5
6
7
8
9
10
public String substring(int beginIndex) {
if (beginIndex < 0) {
throw new StringIndexOutOfBoundsException(beginIndex);
}
int subLen = value.length - beginIndex;
if (subLen < 0) {
throw new StringIndexOutOfBoundsException(subLen);
}
return (beginIndex == 0) ? this : new String(value, beginIndex, subLen);
}
  • substring 方法首先检查参数的有效性,如果参数无效,则抛出 StringIndexOutOfBoundsException 异常。接下来,方法根据参数计算子字符串的长度。如果子字符串长度小于零,抛出StringIndexOutOfBoundsException异常。

  • 如果 beginIndex 为 0,且 endIndex 等于字符串的长度,说明子串与原字符串相同,因此直接返回原字符串。否则,使用 value 数组(原字符串的字符数组)的一部分创建一个新的 String 对象并返回。

  • 几个使用 substring 方法的示例:

1
2
3
String str = "Hello, world!";
String subStr = str.substring(7, 12); // 从第7个字符(包括)提取到第12个字符(不包括)
System.out.println(subStr); // 输出 "world"
1
2
3
String str = "Hello, world!";
String prefix = str.substring(0, 5); // 提取前5个字符,即 "Hello,"
String suffix = str.substring(7); // 提取从第7个字符开始的所有字符,即 "world!"
1
2
3
4
5
String str = "   Hello,   world!  ";
String trimmed = str.trim(); // 去除字符串开头和结尾的空格
String[] words = trimmed.split("\\s+"); // 将字符串按照空格分隔成单词数组
String firstWord = words[0].substring(0, 1); // 提取第一个单词的首字母
System.out.println(firstWord); // 输出 "H"
1
2
3
4
String str = "1234-5678-9012-3456";
String[] parts = str.split("-"); // 将字符串按照连字符分隔成四个部分
String last4Digits = parts[3].substring(1); // 提取最后一个部分的后三位数字
System.out.println(last4Digits); // 输出 "456"

String 类的 indexOf 方法

  • indexOf 方法用于查找一个子字符串在原字符串中第一次出现的位置,并返回该位置的索引。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
/*
* 查找字符数组 target 在字符数组 source 中第一次出现的位置。
* sourceOffset 和 sourceCount 参数指定 source 数组中要搜索的范围,
* targetOffset 和 targetCount 参数指定 target 数组中要搜索的范围,
* fromIndex 参数指定开始搜索的位置。
* 如果找到了 target 数组,则返回它在 source 数组中的位置索引(从0开始),
* 否则返回-1。
*/
static int indexOf(char[] source, int sourceOffset, int sourceCount,
char[] target, int targetOffset, int targetCount,
int fromIndex) {
// 如果开始搜索的位置已经超出 source 数组的范围,则直接返回-1(如果 target 数组为空,则返回 sourceCount)
if (fromIndex >= sourceCount) {
return (targetCount == 0 ? sourceCount : -1);
}
// 如果开始搜索的位置小于0,则从0开始搜索
if (fromIndex < 0) {
fromIndex = 0;
}
// 如果 target 数组为空,则直接返回开始搜索的位置
if (targetCount == 0) {
return fromIndex;
}

// 查找 target 数组的第一个字符在 source 数组中的位置
char first = target[targetOffset];
int max = sourceOffset + (sourceCount - targetCount);

// 循环查找 target 数组在 source 数组中的位置
for (int i = sourceOffset + fromIndex; i <= max; i++) {
/* Look for first character. */
// 如果 source 数组中当前位置的字符不是 target 数组的第一个字符,则在 source 数组中继续查找 target 数组的第一个字符
if (source[i] != first) {
while (++i <= max && source[i] != first);
}

/* Found first character, now look at the rest of v2 */
// 如果在 source 数组中找到了 target 数组的第一个字符,则继续查找 target 数组的剩余部分是否匹配
if (i <= max) {
int j = i + 1;
int end = j + targetCount - 1;
for (int k = targetOffset + 1; j < end && source[j]
== target[k]; j++, k++);

// 如果 target 数组全部匹配,则返回在 source 数组中的位置索引
if (j == end) {
/* Found whole string. */
return i - sourceOffset;
}
}
}
// 没有找到 target 数组,则返回-1
return -1;
}
  • 几个使用 indexOf 方法的示例:
1
2
3
String str = "Hello, world!";
int index = str.indexOf("world"); // 查找 "world" 子字符串在 str 中第一次出现的位置
System.out.println(index); // 输出 7
1
2
3
String str = "Hello, world!";
int index = str.indexOf(","); // 查找逗号在 str 中第一次出现的位置
System.out.println(index); // 输出 5
1
2
3
String str = "Hello, world!";
int index = str.indexOf("l", 3); // 从索引为3的位置开始查找 "l" 子字符串在 str 中第一次出现的位置
System.out.println(index); // 输出 3
1
2
3
4
5
String str = "Hello, world!";
int index1 = str.indexOf("o"); // 查找 "o" 子字符串在 str 中第一次出现的位置
int index2 = str.indexOf("o", 5); // 从索引为5的位置开始查找 "o" 子字符串在 str 中第一次出现的位置
System.out.println(index1); // 输出 4
System.out.println(index2); // 输出 8

String 类的其他方法

  • length() 用于返回字符串长度。

  • isEmpty() 用于判断字符串是否为空。

  • charAt() 用于返回指定索引处的字符。

  • getBytes() 用于返回字符串的字节数组,可以指定编码方式:System.out.println(Arrays.toString(text.getBytes(StandardCharsets.UTF_8)));

  • rim() 用于去除字符串两侧的空白字符

1
2
3
4
5
6
7
8
9
10
11
12
13
public String trim() {
int len = value.length;
int st = 0;
char[] val = value; /* avoid getfield opcode */

while ((st < len) && (val[st] <= ' ')) {
st++;
}
while ((st < len) && (val[len - 1] <= ' ')) {
len--;
}
return ((st > 0) || (len < value.length)) ? substring(st, len) : this;
}
  • 除此之外,还有 split、equals、join 等这些方法。

2.2 String类为什么不可变

  • String 类被 final 关键字修饰,所以它不会有子类,这就意味着没有子类可以重写它的方法,改变它的行为。
  • String 类的数据存储在 char[] 数组中,而这个数组也被 final 关键字修饰了,这就表示 String 对象是没法被修改的,只要初始化一次,值就确定了。
1
2
3
4
5
public final class String
implements java.io.Serializable, Comparable<String>, CharSequence {
/** The value is used for character storage. */
private final char value[];
}
  1. 可以保证 String 对象的安全性,避免被篡改,毕竟像密码这种隐私信息一般就是用字符串存储的。
  2. 保证哈希值不会频繁变更。毕竟要经常作为哈希表的键值,经常变更的话,哈希表的性能就会很差劲。
  3. 可以实现字符串常量池,Java 会将相同内容的字符串存储在字符串常量池中。这样,具有相同内容的字符串变量可以指向同一个 String 对象,节省内存空间。由于字符串的不可变性,String 类的一些方法实现最终都返回了新的字符串对象。
  • {% span blue, String 对象一旦被创建后就固定不变了,对 String 对象的任何修改都不会影响到原来的字符串对象,都会生成新的字符串对象。%}

2.3 深入理解Java字符串常量池

  1. String s = new String(“Java”);new String(Java)创建了几个对象?
  • 使用 new 关键字创建一个字符串对象时,Java 虚拟机会先在字符串常量池中查找有没有‘Java’这个字符串对象,如果有,就不会在字符串常量池中创建‘Java’这个对象了,直接在堆中创建一个‘Java’的字符串对象,然后将堆中这个‘Java’的对象地址返回赋值给变量 s。
  • 如果没有,先在字符串常量池中创建一个‘Java’的字符串对象,然后再在堆中创建一个‘Java’的字符串对象,然后将堆中这个‘Java’的字符串对象地址返回赋值给变量 s。
  1. 为什么要先在字符串常量池中创建对象,然后再在堆上创建呢?
  • 由于字符串的使用频率实在是太高了,所以 Java 虚拟机为了提高性能和减少内存开销,在创建字符串对象的时候进行了一些优化,特意为字符串开辟了一块空间——也就是字符串常量池。

  • 通常情况下,我们会采用双引号的方式来创建字符串对象,而不是通过 new 关键字的方式,这样就不会多此一举:String s = “Java”;

  • 当执行 String s = “Java” 时,Java 虚拟机会先在字符串常量池中查找有没有“Java”这个字符串对象,如果有,则不创建任何对象,直接将字符串常量池中这个“Java”的对象地址返回,赋给变量 s;如果没有,在字符串常量池中创建“Java”这个对象,然后将其地址返回,赋给变量 s。

字符串常量池的作用

  • 有了字符串常量池,就可以通过双引号的方式直接创建字符串对象,不用再通过 new 的方式在堆中创建对象了
  • {% span blue, new 的方式始终会创建一个对象,不管字符串的内容是否已经存在,而双引号的方式会重复利用字符串常量池中已经存在的对象。%}

字符串常量池在内存中的位置

  1. Java 7 之前
  • 在 Java 7 之前,字符串常量池位于永久代(Permanent Generation)的内存区域中,主要用来存储一些字符串常量(静态数据的一种)。永久代是 Java 堆(Java Heap)的一部分,用于存储类信息、方法信息、常量池信息等静态数据。

  • 而 Java 堆是 JVM 中存储对象实例和数组的内存区域,也就是说,永久代是 Java 堆的一个子区域。

  • 换句话说,永久代中存储的静态数据与堆中存储的对象实例和数组是分开的,它们有不同的生命周期和分配方式。

  • 但是,永久代和堆的大小是相互影响的,因为它们都使用了 JVM 堆内存,因此它们的大小都受到 JVM 堆大小的限制。

  • 于是,当我们创建一个字符串常量时,它会被储存在永久代的字符串常量池中。如果我们创建一个普通字符串对象,则它将被储存在堆中。如果字符串对象的内容是一个已经存在于字符串常量池中的字符串常量,那么这个对象会指向已经存在的字符串常量,而不是重新创建一个新的字符串对象。

  1. Java 7
  • 永久代的大小是有限的,并且很难准确地确定一个应用程序需要多少永久代空间。如果我们在应用程序中使用了大量的类、方法、常量等静态数据,就有可能导致永久代空间不足。这种情况下,JVM 就会抛出 OutOfMemoryError 错误。

  • 因此,从 Java 7 开始,为了解决永久代空间不足的问题,将字符串常量池从永久代中移动到堆中。这个改变也是为了更好地支持动态语言的运行时特性。

  1. Java 8
  • 到了 Java 8,永久代(PermGen)被取消,并由元空间(Metaspace)取代。元空间是一块本机内存区域,和 JVM 内存区域是分开的。不过,元空间的作用依然和之前的永久代一样,用于存储类信息、方法信息、常量池信息等静态数据。
  • 与永久代不同,元空间具有一些优点,例如:
1
2
3
4
5
它不会导致 OutOfMemoryError 错误,因为元空间的大小可以动态调整。

元空间使用本机内存,而不是 JVM 堆内存,这可以避免堆内存的碎片化问题。

元空间中的垃圾收集与堆中的垃圾收集是分离的,这可以避免应用程序在运行过程中因为进行类加载和卸载而频繁地触发 Full GC。

永久代、方法区、元空间

  • 方法区是 Java 虚拟机规范中的一个概念,就像是一个接口;
  • 永久代是 HotSpot 虚拟机中对方法区的一个实现,就像是接口的实现类;
  • Java 8 的时候,移除了永久代,取而代之的是元空间,是方法区的另外一种实现,更灵活了
  • 永久代是放在运行时数据区中的,所以它的大小受到 Java 虚拟机本身大小的限制,所以 Java 8 之前,会经常遇到 java.lang.OutOfMemoryError: PremGen Space 的异常,PremGen Space 就是方法区的意思;而元空间是直接放在内存中的,所以只受本机可用内存的限制。

2.4 详解 String.intern() 方法

  • Java 7 之前,执行 String.intern() 方法的时候,不管对象在堆中是否已经创建,字符串常量池中仍然会创建一个内容完全相同的新对象; Java 7 之后呢,由于字符串常量池放在了堆中,执行 String.intern() 方法的时候,如果对象在堆中已经创建了,字符串常量池中就不需要再创建新的对象了,而是直接保存堆中对象的引用,也就节省了一部分的内存空间。
1
2
3
4
5
6
7
8
9
10
String s1 = new String("Java");
String s2 = s1.intern();
System.out.println(s1 == s2);

// 输出false
// 第一行代码,字符串常量池中会先创建一个“Java”的对象,然后堆中会再创建一个“Java”的对象,s1 引用的是堆中的对象。

// 第二行代码,对 s1 执行 intern() 方法,该方法会从字符串常量池中查找“Java”这个字符串是否存在,此时是存在的,所以 s2 引用的是字符串常量池中的对象。

// 也就意味着 s1 和 s2 的引用地址是不同的,一个来自堆,一个来自字符串常量池,所以输出的结果为 false。
1
2
3
4
5
6
7
8
9
10
11
12
13
String s1 = new String("Java") + new String("基础");
String s2 = s1.intern();
System.out.println(s1 == s2);

// 输出true
// 第一行代码,会在字符串常量池中创建两个对象,一个是“Java”,一个是“基础”,然后在堆中会创建两个匿名对象“Java”和“基础”,最后还有一个“Java基础”的对象,s1 引用的是堆中“Java基础”这个对象。

// 第二行代码,对 s1 执行 intern() 方法,该方法会从字符串常量池中查找“Java基础”这个对象是否存在,此时不存在的,但堆中已经存在了,所以字符串常量池中保存的是堆中这个“Java基础”对象的引用,也就是说,s2 和 s1 的引用地址是相同的,所以输出的结果为 true。


new String("Java") + new String("基础") 这行代码编译为以下代码:

new StringBuilder().append("Java").append("基础").toString();
  • 不过需要注意的是,尽管 intern 可以确保所有具有相同内容的字符串共享相同的内存空间,但也不要烂用 intern,因为任何的缓存池都是有大小限制的,不能无缘无故就占用了相对稀缺的缓存空间,导致其他字符串没有坑位可占。

  • 另外,字符串常量池本质上是一个固定大小的 StringTable,如果放进去的字符串过多,就会造成严重的哈希冲突,从而导致链表变长,链表变长也就意味着字符串常量池的性能会大幅下降,因为要一个一个找是需要花费时间的。

2.5 String、StringBuilder、StringBuffer

  • 由于字符串是不可变的,所以当遇到字符串拼接(尤其是使用+号操作符)的时候,就需要考量性能的问题,你不能毫无顾虑地生产太多 String 对象,对珍贵的内存造成不必要的压力。于是 Java 就设计了一个专门用来解决此问题的 StringBuffer 类:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public final class StringBuffer extends AbstractStringBuilder implements Serializable, CharSequence {

public StringBuffer() {
super(16);
}

public synchronized StringBuffer append(String str) {
super.append(str);
return this;
}

public synchronized String toString() {
return new String(value, 0, count);
}

// 其他方法
}
  • 不过,由于 StringBuffer 操作字符串的方法加了 synchronized 关键字进行了同步,主要是考虑到多线程环境下的安全问题,所以执行效率会比较低。

  • 于是 Java 就给 StringBuffer “生了个兄弟”,名叫 StringBuilder,说,“孩子,你别管线程安全了,你就在单线程环境下使用,这样效率会高得多,如果要在多线程环境下修改字符串,你到时候可以使用 ThreadLocal 来避免多线程冲突。”

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public final class StringBuilder extends AbstractStringBuilder
implements java.io.Serializable, CharSequence
{
// ...

public StringBuilder append(String str) {
super.append(str);
return this;
}

public String toString() {
// Create a copy, don't share the array
return new String(value, 0, count);
}

// ...
}
  • 除了类名不同,方法没有加 synchronized,基本上完全一样。
  • 在 StringBuilder 对象创建时,会为 value 分配一定的内存空间(初始容量 16),用于存储字符串。
  • 随着字符串的拼接,value 数组的长度会不断增加,因此在 StringBuilder 对象的实现中,value 数组的长度是可以动态扩展的,就像ArrayList那样。如果需要扩容,则会调用 ensureCapacityInternal(int minimumCapacity)方法进行扩容。扩容之后,将指定字符串的字符拷贝到字符序列中。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
private void ensureCapacityInternal(int minimumCapacity) {
// overflow-conscious code
if (minimumCapacity - value.length > 0)
expandCapacity(minimumCapacity);
}

void expandCapacity(int minimumCapacity) {
int newCapacity = value.length * 2 + 2;
if (newCapacity - minimumCapacity < 0)
newCapacity = minimumCapacity;
if (newCapacity < 0) {
if (minimumCapacity < 0) // overflow
throw new OutOfMemoryError();
newCapacity = Integer.MAX_VALUE;
}
value = Arrays.copyOf(value, newCapacity);
}
  • ensureCapacityInternal(int minimumCapacity) 方法用于确保当前字符序列的容量至少等于指定的最小容量 minimumCapacity。如果当前容量小于指定的容量,就会为字符序列分配一个新的内部数组。新容量的计算方式如下:
  1. 如果指定的最小容量大于当前容量,则新容量为两倍的旧容量加上 2;
  2. 如果指定的最小容量小于等于当前容量,则不会进行扩容,直接返回当前对象。
  • 在进行扩容之前,ensureCapacityInternal(int minimumCapacity) 方法会先检查当前字符序列的容量是否足够,如果不足就会调用 expandCapacity(int minimumCapacity) 方法进行扩容。expandCapacity(int minimumCapacity) 方法首先计算出新容量,然后使用 Arrays.copyOf(char[] original, int newLength) 方法将原字符数组扩容到新容量的大小。

2.6 String相等判断

.equals() 和 ‘==’ 操作符有什么区别

  • ==”操作符用于比较两个对象的地址是否相等。

  • .equals() 方法用于比较两个对象的内容是否相等。

  • Java 8 中的 equals 方法源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public boolean equals(Object anObject) {
// 判断是否为同一对象
if (this == anObject) {
return true;
}
// 判断对象是否为 String 类型
if (anObject instanceof String) {
String anotherString = (String)anObject;
int n = value.length;
// 判断字符串长度是否相等
if (n == anotherString.value.length) {
char v1[] = value;
char v2[] = anotherString.value;
int i = 0;
// 判断每个字符是否相等
while (n-- != 0) {
if (v1[i] != v2[i])
return false;
i++;
}
return true;
}
}
return false;
}
  • 如果要进行两个字符串对象的内容比较,除了 .equals() 方法,还有其他两个可选的方案:
1
2
3
4
5
6
7
// Objects.equals() 这个静态方法的优势在于不需要在调用之前判空。

public static boolean equals(Object a, Object b) {
return (a == b) || (a != null && a.equals(b));
}

// 如果直接使用 a.equals(b),则需要在调用之前对 a 进行判空,否则可能会抛出空指针 java.lang.NullPointerException。Objects.equals() 用起来就完全没有这个担心。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// .contentEquals() 的优势在于可以将字符串与任何的字符序列(StringBuffer、StringBuilder、String、CharSequence)进行比较。

public boolean contentEquals(CharSequence cs) {
// Argument is a StringBuffer, StringBuilder
if (cs instanceof AbstractStringBuilder) {
if (cs instanceof StringBuffer) {
synchronized(cs) {
return nonSyncContentEquals((AbstractStringBuilder)cs);
}
} else {
return nonSyncContentEquals((AbstractStringBuilder)cs);
}
}
// Argument is a String
if (cs instanceof String) {
return equals(cs);
}
// Argument is a generic CharSequence
int n = cs.length();
if (n != length()) {
return false;
}
byte[] val = this.value;
if (isLatin1()) {
for (int i = 0; i < n; i++) {
if ((val[i] & 0xff) != cs.charAt(i)) {
return false;
}
}
} else {
if (!StringUTF16.contentEquals(val, cs, n)) {
return false;
}
}
return true;
}
  • 总体上还是 Objects.equals() 方法更好一些

2.7 String拼接

  • {% span blue, 循环体内,拼接字符串最好使用 StringBuilder 的 append() 方法,而不是 + 号操作符。%}

javap 探究+号操作符拼接字符串的本质

  • 在Java 8 环境下,编译的时候把“+”号操作符替换成了 StringBuilder 的 append() 方法;
  • Java 9 以后,JDK 用了另外一种方法来动态解释 + 号操作符,具体的实现方式在字节码指令层面已经看不到了(其实是没看懂……)

为什么要编译为 StringBuilder.append

  • 循环体内,拼接字符串最好使用 StringBuilder 的 append() 方法,而不是 + 号操作符。原因就在于循环体内如果用 + 号操作符的话,就会产生大量的 StringBuilder 对象,不仅占用了更多的内存空间,还会让 Java 虚拟机不停的进行垃圾回收,从而降低了程序的性能。
  • 更好的写法就是在循环的外部新建一个 StringBuilder 对象,然后使用 append() 方法将循环体内的字符串添加进来:
1
2
3
4
5
6
7
8
9
10
11
12
class Demo {
public static void main(String[] args) {
StringBuilder sb = new StringBuilder();
for (int i = 1; i < 10; i++) {
String s1 = "Java";
String s2 = "基础";
sb.append(s1);
sb.append(s2);
}
System.out.println(sb);
}
}

append方法源码解析

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// StringBuilder 类的 append() 方法的源码:

public StringBuilder append(String str) {
super.append(str);
return this;
}

// 父类 AbstractStringBuilder 的 append() 方法:

public AbstractStringBuilder append(String str) {
if (str == null)
return appendNull();
int len = str.length();
ensureCapacityInternal(count + len);
str.getChars(0, len, value, count);
count += len;
return this;
}
  1. 判断拼接的字符串是不是 null,如果是,当做字符串“null”来处理。
  2. 获取字符串的长度。
  3. ensureCapacityInternal() 方法,由于字符串内部是用数组实现的,所以需要先判断拼接后的字符数组长度是否超过当前数组的长度,如果超过,先对数组进行扩容,然后把原有的值复制到新的数组中。
  4. 将拼接的字符串 str 复制到目标数组 value 中。
  5. 更新数组的长度 count。

String.concat 拼接字符串

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 用法示例:
String s1 = "Java";
String s2 = "基础";
System.out.println(s1.concat(s2));

// 方法源码:
public String concat(String str) {
int otherLen = str.length();
if (otherLen == 0) {
return this;
}
int len = value.length;
char buf[] = Arrays.copyOf(value, len + otherLen);
str.getChars(buf, len);
return new String(buf, true);
}
  1. 如果拼接的字符串的长度为 0,那么返回拼接前的字符串。
  2. 将原字符串的字符数组 value 复制到变量 buf 数组中。
  3. 把拼接的字符串 str 复制到字符数组 buf 中,并返回新的字符串对象。

String.join 拼接字符串

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 用法示例:
String s1 = "Java";
String s2 = "基础";
String cmower = String.join("", s1, s2);
System.out.println(cmower);

// 第一个参数为字符串连接符,比如说:

String message = String.join("-", "Java", "基础", "学习");
// 输出结果为:Java-基础-学习。


// 方法源码:
public static String join(CharSequence delimiter, CharSequence... elements) {
Objects.requireNonNull(delimiter);
Objects.requireNonNull(elements);
// Number of elements not likely worth Arrays.stream overhead.
StringJoiner joiner = new StringJoiner(delimiter);
for (CharSequence cs: elements) {
joiner.add(cs);
}
return joiner.toString();
}

StringUtils.join 拼接字符串

  • 实际的工作中,org.apache.commons.lang3.StringUtils 的 join() 方法也经常用来进行字符串拼接
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// 用法示例:
String s1 = "Java";
String s2 = "基础";
StringUtils.join(s1, s2);

该方法不用担心 NullPointerException。

StringUtils.join(null) = null
StringUtils.join([]) = ""
StringUtils.join([null]) = ""
StringUtils.join(["a", "b", "c"]) = "abc"
StringUtils.join([null, "", "a"]) = "a"

// 方法源码:
public static String join(final Object[] array, String separator, final int startIndex, final int endIndex) {
if (array == null) {
return null;
}
if (separator == null) {
separator = EMPTY;
}

final StringBuilder buf = new StringBuilder(noOfItems * 16);

for (int i = startIndex; i < endIndex; i++) {
if (i > startIndex) {
buf.append(separator);
}
if (array[i] != null) {
buf.append(array[i]);
}
}
return buf.toString();
}

2.8 String拆分

String 类的 split() 方法

  • 分隔符问题:

  • 反斜杠 \(ArrayIndexOutOfBoundsException)

  • 插入符号 ^(同上)

  • 美元符号 $(同上)

  • 逗点 .(同上)

  • 竖线 |(正常,没有出错)

  • 问号 ?(PatternSyntaxException)

  • 星号 *(同上)

  • 加号 +(同上)

  • 左小括号或者右小括号 ()(同上)

  • 左方括号或者右方括号 [](同上)

  • 左大括号或者右大括号 {}(同上)

  • {% span blue, 使用正则表达式!%}

  • 英文逗点属于特殊符号,所以在使用 split() 方法的时候,就需要使用正则表达式 \. 而不能直接使用 .

  • 反斜杠本身就是一个特殊字符,需要用反斜杠来转义。

  • 也可以使用 [] 来包裹住英文逗点“.”,[] 也是一个正则表达式,用来匹配方括号中包含的任意字符。

  • cmower.split(“[.]”);

  • 除此之外, 还可以使用 Pattern 类的 quote() 方法来包裹英文逗点“.”,该方法会返回一个使用 \Q\E 包裹的字符串。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
String [] parts = cmower.split(Pattern.quote("."));
// 当 split() 方法的参数是正则表达式的时候,方法最终会执行下面这行代码:

return Pattern.compile(regex).split(this, limit);
也就意味着,拆分字符串有了新的选择,可以不使用 String 类的 split() 方法,直接用下面的方式。

public class TestPatternSplit {
private static Pattern twopart = Pattern.compile("\\.");

public static void main(String[] args) {
String [] parts = twopart.split("Java.基础");
System.out.println("第一部分:" + parts[0] +" 第二部分:" + parts[1]);
}
}

// 由于模式是确定的,通过 static 的预编译功能可以提高程序的效率。除此之外,还可以使用 Pattern 配合 Matcher 类进行字符串拆分,这样做的好处是可以对要拆分的字符串进行一些严格的限制;
  • 正则表达式 (.+)\.(.+) 的意思是,不仅要把字符串按照英文标点的方式拆成两部分,并且英文逗点的前后要有内容。

正则表达式中的断言模式

  • String [] parts = cmower.split(“(?=,)”);
    |符号|描述|
    |—|—|
    |?=|正向先行断言|
    |?!|负向先行断言|
    |?<=|正向后行断言|
    |?<!|负向后行断言|

  • split() 方法可以传递 2 个参数,第一个为分隔符,第二个为拆分的字符串个数。