Java学习之路02-Java数组&字符串
1.Java数组
1.1 掌握Java数组
- 数组是一个对象,它包含了一组固定数量的元素,并且这些元素的类型是相同的。数组会按照索引的方式将元素放在指定的位置上,意味着我们可以通过索引来访问这些元素。在 Java 中,索引是从 0 开始的。
数组的声明与初始化:
1 | // 声明数组: |
访问数组:
1 | // 可以通过索引来访问数组的元素,如果索引的值超出了数组的界限,就会抛出 ArrayIndexOutOfBoundException。 |
数组转List
1 | // 最原始的方式,就是通过遍历数组的方式,一个个将数组添加到 List 中。 |
对数组进行排序
1 | // 如果想对数组进行排序的话,可以使用 Arrays 类提供的 sort() 方法。 |
1.2掌握Java二维数组
- 二维数组是一种数据类型,可以存储多行和多列的数据。它由一系列的行和列组成,每个元素都可以通过一个行索引和列索引来访问。
1 | // 例如,一个3行4列的二维数组可以表示为以下形式: |
创建二维数组
- 要在 Java 中创建二维数组,你必须指定要存储在数组中的数据类型,后跟两个方括号和数组的名称。
1 | // 语法如下所示: |
访问二维数组中的元素
1 | int[][] oddNumbers = { {1, 3, 5, 7}, {9, 11, 13, 15}, {17, 19, 21, 23} }; |
1.3 打印Java数组
- {% span blue, 数组是一个对象 %}
为什么不能直接打印数组
1 | String [] cmowers = {"这是","一个","Java数组"}; |
stream 流打印 Java 数组
1 | // 第一种形式: |
for 循环打印 Java 数组
1 | for(int i = 0; i < cmowers.length; i++){ |
Arrays 工具类打印 Java 数组
- {% span blue, Arrays.toString() 是打印数组的最佳方式,没有之一!%}Arrays.toString() 可以将任意类型的数组转成字符串,包括基本类型数组和引用类型数组。
1 | String [] cmowers = {"这是","一个","Java数组"}; |
Arrays工具类打印二维数组
1 | // 可以使用 Arrays.deepToString() 方法: |
- 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 | public final class String |
-
String 类是 final 的,意味着它不能被子类继承。
-
String 类实现了 Serializable 接口,意味着它可以序列化。
-
String 类实现了 Comparable 接口,意味着最好不要用‘==’来比较两个字符串是否相等,而应该用 compareTo() 方法去比较,{% span blue, 因为 == 是用来比较两个对象的地址。%}如果只是说比较字符串内容的话,可以使用 String 类的 equals 方法。
-
StringBuffer、StringBuilder 和 String 一样,都实现了 CharSequence 接口,所以它们仨属于近亲。由于 String 是不可变的,所以遇到字符串拼接的时候就可以考虑一下 String 的另外两个好兄弟,StringBuffer 和 StringBuilder,它俩是可变的。
String 类的底层实现
- private final char value[];
- Java 9 以前,String 是用 char 型数组实现的,之后改成了 byte 型数组实现,并增加了 coder 来表示编码。这样做的好处是在 Latin1 字符为主的程序里,可以把 String 占用的内存减少一半。当然,天下没有免费的午餐,这个改进在节省内存的同时引入了编码检测的开销。
1 | public final class String |
- 从 char[] 到 byte[],最主要的目的是节省字符串占用的内存空间。内存占用减少带来的另外一个好处,就是 GC 次数也会减少。(从 char[] 到 byte[],中文是两个字节,纯英文是一个字节,在此之前,中文是两个字节,英文也是两个字节。)
String 类的 hashCode 方法
- 每一个字符串都会有一个 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 | // String 类的 hashCode 方法: |
String 类的 substring 方法
1 | public String substring(int beginIndex) { |
-
substring 方法首先检查参数的有效性,如果参数无效,则抛出 StringIndexOutOfBoundsException 异常。接下来,方法根据参数计算子字符串的长度。如果子字符串长度小于零,抛出StringIndexOutOfBoundsException异常。
-
如果 beginIndex 为 0,且 endIndex 等于字符串的长度,说明子串与原字符串相同,因此直接返回原字符串。否则,使用 value 数组(原字符串的字符数组)的一部分创建一个新的 String 对象并返回。
-
几个使用 substring 方法的示例:
1 | String str = "Hello, world!"; |
1 | String str = "Hello, world!"; |
1 | String str = " Hello, world! "; |
1 | String str = "1234-5678-9012-3456"; |
String 类的 indexOf 方法
- indexOf 方法用于查找一个子字符串在原字符串中第一次出现的位置,并返回该位置的索引。
1 | /* |
- 几个使用 indexOf 方法的示例:
1 | String str = "Hello, world!"; |
1 | String str = "Hello, world!"; |
1 | String str = "Hello, world!"; |
1 | String str = "Hello, world!"; |
String 类的其他方法
-
length() 用于返回字符串长度。
-
isEmpty() 用于判断字符串是否为空。
-
charAt() 用于返回指定索引处的字符。
-
getBytes() 用于返回字符串的字节数组,可以指定编码方式:System.out.println(Arrays.toString(text.getBytes(StandardCharsets.UTF_8)));
-
rim() 用于去除字符串两侧的空白字符
1 | public String trim() { |
- 除此之外,还有 split、equals、join 等这些方法。
2.2 String类为什么不可变
- String 类被 final 关键字修饰,所以它不会有子类,这就意味着没有子类可以重写它的方法,改变它的行为。
- String 类的数据存储在 char[] 数组中,而这个数组也被 final 关键字修饰了,这就表示 String 对象是没法被修改的,只要初始化一次,值就确定了。
1 | public final class String |
- 可以保证 String 对象的安全性,避免被篡改,毕竟像密码这种隐私信息一般就是用字符串存储的。
- 保证哈希值不会频繁变更。毕竟要经常作为哈希表的键值,经常变更的话,哈希表的性能就会很差劲。
- 可以实现字符串常量池,Java 会将相同内容的字符串存储在字符串常量池中。这样,具有相同内容的字符串变量可以指向同一个 String 对象,节省内存空间。由于字符串的不可变性,String 类的一些方法实现最终都返回了新的字符串对象。
- {% span blue, String 对象一旦被创建后就固定不变了,对 String 对象的任何修改都不会影响到原来的字符串对象,都会生成新的字符串对象。%}
2.3 深入理解Java字符串常量池
- String s = new String(“Java”);new String(Java)创建了几个对象?
- 使用 new 关键字创建一个字符串对象时,Java 虚拟机会先在字符串常量池中查找有没有‘Java’这个字符串对象,如果有,就不会在字符串常量池中创建‘Java’这个对象了,直接在堆中创建一个‘Java’的字符串对象,然后将堆中这个‘Java’的对象地址返回赋值给变量 s。
- 如果没有,先在字符串常量池中创建一个‘Java’的字符串对象,然后再在堆中创建一个‘Java’的字符串对象,然后将堆中这个‘Java’的字符串对象地址返回赋值给变量 s。
- 为什么要先在字符串常量池中创建对象,然后再在堆上创建呢?
-
由于字符串的使用频率实在是太高了,所以 Java 虚拟机为了提高性能和减少内存开销,在创建字符串对象的时候进行了一些优化,特意为字符串开辟了一块空间——也就是字符串常量池。
-
通常情况下,我们会采用双引号的方式来创建字符串对象,而不是通过 new 关键字的方式,这样就不会多此一举:String s = “Java”;
-
当执行 String s = “Java” 时,Java 虚拟机会先在字符串常量池中查找有没有“Java”这个字符串对象,如果有,则不创建任何对象,直接将字符串常量池中这个“Java”的对象地址返回,赋给变量 s;如果没有,在字符串常量池中创建“Java”这个对象,然后将其地址返回,赋给变量 s。
字符串常量池的作用
- 有了字符串常量池,就可以通过双引号的方式直接创建字符串对象,不用再通过 new 的方式在堆中创建对象了
- {% span blue, new 的方式始终会创建一个对象,不管字符串的内容是否已经存在,而双引号的方式会重复利用字符串常量池中已经存在的对象。%}
字符串常量池在内存中的位置
- Java 7 之前
-
在 Java 7 之前,字符串常量池位于永久代(Permanent Generation)的内存区域中,主要用来存储一些字符串常量(静态数据的一种)。永久代是 Java 堆(Java Heap)的一部分,用于存储类信息、方法信息、常量池信息等静态数据。
-
而 Java 堆是 JVM 中存储对象实例和数组的内存区域,也就是说,永久代是 Java 堆的一个子区域。
-
换句话说,永久代中存储的静态数据与堆中存储的对象实例和数组是分开的,它们有不同的生命周期和分配方式。
-
但是,永久代和堆的大小是相互影响的,因为它们都使用了 JVM 堆内存,因此它们的大小都受到 JVM 堆大小的限制。
-
于是,当我们创建一个字符串常量时,它会被储存在永久代的字符串常量池中。如果我们创建一个普通字符串对象,则它将被储存在堆中。如果字符串对象的内容是一个已经存在于字符串常量池中的字符串常量,那么这个对象会指向已经存在的字符串常量,而不是重新创建一个新的字符串对象。
- Java 7
-
永久代的大小是有限的,并且很难准确地确定一个应用程序需要多少永久代空间。如果我们在应用程序中使用了大量的类、方法、常量等静态数据,就有可能导致永久代空间不足。这种情况下,JVM 就会抛出 OutOfMemoryError 错误。
-
因此,从 Java 7 开始,为了解决永久代空间不足的问题,将字符串常量池从永久代中移动到堆中。这个改变也是为了更好地支持动态语言的运行时特性。
- Java 8
- 到了 Java 8,永久代(PermGen)被取消,并由元空间(Metaspace)取代。元空间是一块本机内存区域,和 JVM 内存区域是分开的。不过,元空间的作用依然和之前的永久代一样,用于存储类信息、方法信息、常量池信息等静态数据。
- 与永久代不同,元空间具有一些优点,例如:
1 | 它不会导致 OutOfMemoryError 错误,因为元空间的大小可以动态调整。 |
永久代、方法区、元空间
- 方法区是 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 | String s1 = new String("Java"); |
1 | String s1 = new String("Java") + new String("基础"); |
-
不过需要注意的是,尽管 intern 可以确保所有具有相同内容的字符串共享相同的内存空间,但也不要烂用 intern,因为任何的缓存池都是有大小限制的,不能无缘无故就占用了相对稀缺的缓存空间,导致其他字符串没有坑位可占。
-
另外,字符串常量池本质上是一个固定大小的 StringTable,如果放进去的字符串过多,就会造成严重的哈希冲突,从而导致链表变长,链表变长也就意味着字符串常量池的性能会大幅下降,因为要一个一个找是需要花费时间的。
2.5 String、StringBuilder、StringBuffer
- 由于字符串是不可变的,所以当遇到字符串拼接(尤其是使用+号操作符)的时候,就需要考量性能的问题,你不能毫无顾虑地生产太多 String 对象,对珍贵的内存造成不必要的压力。于是 Java 就设计了一个专门用来解决此问题的 StringBuffer 类:
1 | public final class StringBuffer extends AbstractStringBuilder implements Serializable, CharSequence { |
-
不过,由于 StringBuffer 操作字符串的方法加了 synchronized 关键字进行了同步,主要是考虑到多线程环境下的安全问题,所以执行效率会比较低。
-
于是 Java 就给 StringBuffer “生了个兄弟”,名叫 StringBuilder,说,“孩子,你别管线程安全了,你就在单线程环境下使用,这样效率会高得多,如果要在多线程环境下修改字符串,你到时候可以使用 ThreadLocal 来避免多线程冲突。”
1 | public final class StringBuilder extends AbstractStringBuilder |
- 除了类名不同,方法没有加 synchronized,基本上完全一样。
- 在 StringBuilder 对象创建时,会为 value 分配一定的内存空间(初始容量 16),用于存储字符串。
- 随着字符串的拼接,value 数组的长度会不断增加,因此在 StringBuilder 对象的实现中,value 数组的长度是可以动态扩展的,就像ArrayList那样。如果需要扩容,则会调用 ensureCapacityInternal(int minimumCapacity)方法进行扩容。扩容之后,将指定字符串的字符拷贝到字符序列中。
1 | private void ensureCapacityInternal(int minimumCapacity) { |
- ensureCapacityInternal(int minimumCapacity) 方法用于确保当前字符序列的容量至少等于指定的最小容量 minimumCapacity。如果当前容量小于指定的容量,就会为字符序列分配一个新的内部数组。新容量的计算方式如下:
- 如果指定的最小容量大于当前容量,则新容量为两倍的旧容量加上 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 | public boolean equals(Object anObject) { |
- 如果要进行两个字符串对象的内容比较,除了 .equals() 方法,还有其他两个可选的方案:
1 | // Objects.equals() 这个静态方法的优势在于不需要在调用之前判空。 |
1 | // .contentEquals() 的优势在于可以将字符串与任何的字符序列(StringBuffer、StringBuilder、String、CharSequence)进行比较。 |
- 总体上还是 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 | class Demo { |
append方法源码解析
1 | // StringBuilder 类的 append() 方法的源码: |
- 判断拼接的字符串是不是 null,如果是,当做字符串“null”来处理。
- 获取字符串的长度。
- ensureCapacityInternal() 方法,由于字符串内部是用数组实现的,所以需要先判断拼接后的字符数组长度是否超过当前数组的长度,如果超过,先对数组进行扩容,然后把原有的值复制到新的数组中。
- 将拼接的字符串 str 复制到目标数组 value 中。
- 更新数组的长度 count。
String.concat 拼接字符串
1 | // 用法示例: |
- 如果拼接的字符串的长度为 0,那么返回拼接前的字符串。
- 将原字符串的字符数组 value 复制到变量 buf 数组中。
- 把拼接的字符串 str 复制到字符数组 buf 中,并返回新的字符串对象。
String.join 拼接字符串
1 | // 用法示例: |
StringUtils.join 拼接字符串
- 实际的工作中,org.apache.commons.lang3.StringUtils 的 join() 方法也经常用来进行字符串拼接
1 | // 用法示例: |
2.8 String拆分
String 类的 split() 方法
-
分隔符问题:
-
反斜杠 \(ArrayIndexOutOfBoundsException)
-
插入符号 ^(同上)
-
美元符号 $(同上)
-
逗点 .(同上)
-
竖线 |(正常,没有出错)
-
问号 ?(PatternSyntaxException)
-
星号 *(同上)
-
加号 +(同上)
-
左小括号或者右小括号 ()(同上)
-
左方括号或者右方括号 [](同上)
-
左大括号或者右大括号 {}(同上)
-
{% span blue, 使用正则表达式!%}
-
英文逗点属于特殊符号,所以在使用 split() 方法的时候,就需要使用正则表达式 \. 而不能直接使用 .
-
反斜杠本身就是一个特殊字符,需要用反斜杠来转义。
-
也可以使用 [] 来包裹住英文逗点“.”,[] 也是一个正则表达式,用来匹配方括号中包含的任意字符。
-
cmower.split(“[.]”);
-
除此之外, 还可以使用 Pattern 类的 quote() 方法来包裹英文逗点“.”,该方法会返回一个使用 \Q\E 包裹的字符串。
1 | String [] parts = cmower.split(Pattern.quote(".")); |
- 正则表达式 (.+)\.(.+) 的意思是,不仅要把字符串按照英文标点的方式拆成两部分,并且英文逗点的前后要有内容。
正则表达式中的断言模式
-
String [] parts = cmower.split(“(?=,)”);
|符号|描述|
|—|—|
|?=|正向先行断言|
|?!|负向先行断言|
|?<=|正向后行断言|
|?<!|负向后行断言| -
split() 方法可以传递 2 个参数,第一个为分隔符,第二个为拆分的字符串个数。