十八、String类、Arrays类、Math类

18.1 String类

java.lang.String 类代表字符串。Java程序中所有的字符串文字(例如 “abc” )都可以被看作是实现此类的实例。

类 String 中包括用于检查各个字符串的方法,比如:用于比较字符串、搜索字符串、截取字符串、拼接字符串、替换字符串以及创建具有翻译为大写或小写字符串,这些操作都不是在原有的字符串上进行的,而是重新生成了新的字符串对象。也就是说,这些操作执行过后,原来的字符串对象并没有发生改变

从概念上讲,Java 字符串就是 Unicode 字符序列。Java 没有内置的字符串类型,而是在标准 Java 类库中提供了一个预定义类,很自然地叫做 String。
在这里插入图片描述

18.1.1 String是不可变的?

01、为什么不可变?

扒一下 String 的源码:

public final class String
    implements java.io.Serializable, Comparable<String>, CharSequence {
    @Stable
    // private final char value[]; Java 8
    private final byte[] value; // Java 9
    private final byte coder;
    private int hash;
}
  1. String 类是 final 的,意味着它不能被子类继承;
  2. String 类实现了 Serializable 接口,意味着它可以序列化;
  3. String 类实现了 Comparable 接口,意味着最好不要用 "=="来比较两个字符串是否相等,而应该用 compareTo() 方法比较。
  4. StringBuffer、StringBuilder 和 String 一样,都实现了 CharSequence 接口,所以它们三个属于近亲。由于 String 是不可变的,所以遇到字符串拼接的时候就可以考虑一下 String 另外的两个好兄弟咯;
  5. Java 9 以前,String 是用 char 型数组实现的,之后改成了 byte 型数组,并增加了 coder 来表示编码。在 Latin1 字符为主的程序里,可以把 String 占用的内存减少一半。但是这个改进在节省内存的同时引入了编码检测的开销。
  6. 每个字符串都会有一个 hash 值,这个哈希值在很大概率是不会重复的,因此 String 很适合来作为 HashMap 的键值。

java 8 和 java 9 中String的底层实现可以参考二哥的文章:
String 的不可变性:

  1. String 类被 final 关键字修饰,所以它不会有子类,这就意味着没有子类可以重写它的方法、改变它的行为;
  2. String 类的数据存储在 byte[] 数组中,而这个数组也被 final 关键字修饰了,这就表示 String 对象是没办法被修改的,只要初始化一次值就确定了。

String 为什么要这样设计:

  1. 可以保证 String 对象的安全性,避免被篡改(毕竟像密码这种隐私信息一般就是用字符串存储的);
  2. 保证哈希值不会频繁变更。String 要经常作为哈希表的键值,经常变更的话,哈希表的性能就会很差劲;
  3. 可以实现字符串常量。

02、不可变字符串的特点

String 类没有提供修改字符串中某个字符的方法。如果想要修改字符串中的某个字符,可以对字符串提取后进行拼接。

  1. 字符串不变:字符串的值在创建后不能被更改。

    String s1 = "abc"; 
    s1 += "d"; 
    System.out.println(s1); // "abcd"  
    // 内存中有"abc","abcd"两个对象,s1从指向"abc",改变指向,指向了"abcd"。
    
  2. 因为String对象是不可变的,所以编译器可以让字符串共享。

    String s1 = "abc";
    String s2 = "abc";
    //内存中只有一个“abc”对象被创建,同时被s1和s2共享
    
  3. “abc” 等效于 char[] data={ ‘a’ , ‘b’ , ‘c’ } 。

    例如:  
    String str = "abc";   
    相当于:  
    char data[] = {'a', 'b', 'c'};      
    String str = new String(data); 
    // String底层是靠字符数组实现的。
    

03、空串与null串

空串 “” 是长度为 0 的字符串,是一个 Java 对象,有自己的长度(0)和内容(空)。

Null 是字符串中特殊的值,表示目前没有任何对象与该变量关联。

18.1.2 字符串的常用方法

01、字符串截取(substring())

String 类的 substring() 方法可以从一个较大的字符串中提取出一个子串。

String str = "Hello";
String s = str.substring(0, 3); // Hel

substring[a, b)方法的第二个参数是不想复制的第一个位置,在 String 中从 a 开始计数,直到 b 为止,但是不包括 b(左闭右开)。这个截取方式有一个优点:容易计算子串的长度。例如:子串“Hel”的长度为 3-0=3。

扒一下 substring() 方法的源码:

@NotNull
public String substring(int beginIndex) {
    if (beginIndex < 0) {
        throw new StringIndexOutOfBoundsException(beginIndex);
    }
    int subLen = length() - beginIndex;
    if (subLen < 0) {
        throw new StringIndexOutOfBoundsException(subLen);
    }
    if (beginIndex == 0) {
        return this;
    }
    return isLatin1() ? StringLatin1.newString(value, beginIndex, subLen)
            : StringUTF16.newString(value, beginIndex, subLen);
}

// StringLatin1.newString 
public static String newString(byte[] val, int index, int len) {
    return new String(Arrays.copyOfRange(val, index, index + len),
            LATIN1);
}

// UTF16.newString
public static String newString(byte[] val, int index, int len) {
    if (String.COMPACT_STRINGS) {
        byte[] buf = compress(val, index, len);
        if (buf != null) {
            return new String(buf, LATIN1);
        }
    }
    int last = index + len;
    return new String(Arrays.copyOfRange(val, index << 1, last << 1), UTF16);
}

由此可见,不管是 Latin1 字符还是 UTF16 字符,最终返回的都是 new 出来的新字符串对象。

02、字符串拼接(+)

Java 使用 + 号连接(拼接)两个字符串。当一个字符串与一个非字符串的值进行拼接时,后者会转换成字符串(任何一个Java对象都可以转换成字符串)。但是这种方式拼接字符串效率会比较低。

/**
 * @author QHJ
 * @date 2022/8/24  08:51
 * @description: String类测试
 */
public class StringMethods {
    public static void main(String[] args) {
        // 使用 + 拼接
        String str1 = "青花椒";
        String str2 = " is beautiful!";
        System.out.println(str1 + str2); // 青花椒 is beautiful! 1ms
        System.out.println(new StringBuilder(String.valueOf(str1)).append(str2).toString()); // 青花椒 is beautiful! 0ms

        // 使用 StringBuilder 拼接
        StringBuilder stringBuilder = new StringBuilder();
        stringBuilder.append(str1);
        stringBuilder.append(str2);
        System.out.println(stringBuilder); // 青花椒 is beautiful! 0ms
    }
}

在 Java 8 的环境下,反编译字节码后会发现:每次拼接字符串时,都会构建一个新的 StringBuilder 对象,然后调用 appand() 方法。这样做既耗时又浪费空间,所以使用 StringBuilder 类就可以避免这个问题的发生。

StringBuilder 类的前身是 StringBuffer,它的效率稍有些低,但允许采用多线程的方式添加或删除字符。如果所有字符编辑操作都在单个线程中执行,则应该使用 StringBuilder。

扒一下 StringBuilder 类的 append() 方法的源码:

@Override
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” 来处理。扒一下appendNull() 方法的源码:

    private AbstractStringBuilder appendNull() {
        int c = count;
        ensureCapacityInternal(c + 4);
        final char[] value = this.value;
        value[c++] = 'n';
        value[c++] = 'u';
        value[c++] = 'l';
        value[c++] = 'l';
        count = c;
        return this;
    }
    
  2. 获取字符串的长度;

  3. 判断是否需要扩容,ensureCapacityInternal() 方法的源码:

    private void ensureCapacityInternal(int minimumCapacity) {
        // overflow-conscious code
        if (minimumCapacity - value.length > 0) {
            value = Arrays.copyOf(value,
                    newCapacity(minimumCapacity));
        }
    }
    

    字符串内部是用数组实现的,所以需要先判断拼接后的字符数组长度是否超过当前数组的长度,如果超过要先对数组进行扩容,然后把原有的值复制到新的数组中。

  4. 将拼接的字符串 str 复制到目标数组 value 中:

    public void getChars(int srcBegin, int srcEnd, char dst[], int dstBegin) {
        if (srcBegin < 0) {
            throw new StringIndexOutOfBoundsException(srcBegin);
        }
        if (srcEnd > value.length) {
            throw new StringIndexOutOfBoundsException(srcEnd);
        }
        if (srcBegin > srcEnd) {
            throw new StringIndexOutOfBoundsException(srcEnd - srcBegin);
        }
        System.arraycopy(value, srcBegin, dst, dstBegin, srcEnd - srcBegin);
    }
    
  5. 更新数组的长度 count。

StringBuilder 和 StringBuffer 就像是孪生兄弟,该有的都有,但是 StringBuffer 是线程安全的。戳链接查看:

03、字符串拼接(concat()方法)

String 类的 concat() 方法,类似于 StringBuilder 类的 append()方法。

扒一下 concat() 方法的源码:

public String concat(String str) {
    if (str.isEmpty()) {
        return this;
    }
    int len = value.length;
    int otherLen = str.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 中,并返回新的字符串对象。

和 “+” 号操作符相比,concat() 方法在遇到字符串为 null 的时候会抛出,而 “+” 号操作符会把 null 当做是 “null” 字符串来处理。

所以,如果拼接的字符串是一个空字符串(“”),那么 concat() 的效率要更高一些,不需要 new StringBuilder 对象。如果拼接的字符串非常多,concat() 的效率就会下降,因为创建的字符串对象会越来越多。

04、字符串拼接(join()方法)

String 类有一个静态方法 join()用来按照指定的字符串连接符进行拼接

/**
 * @author QHJ
 * @date 2022/8/24  08:51
 * @description: String类测试
 */
public class StringMethods {
    public static void main(String[] args) {
        String str1 = "青花椒";
        String str2 = " is beautiful!";
        // 使用 join 拼接
        String join1 = String.join("---", str1, str2);
        String join2 = String.join("@", str1, str2);
        System.out.println(join1); // 青花椒--- is beautiful!
        System.out.println(join2); // 青花椒@ is beautiful!
    }
}

扒一下 join() 方法的源码:

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();
}

里面新建了一个叫做 StringJoiner 的对象,然后通过 forEach 循环把可变参数添加进来,最后调用 toString() 方法返回 String。

在实际的工作中,org.apache.commons.lang3.StringUtilsjoin() 方法也经常用来进行字符串拼接,该方法不用担心 NullPointerException

扒一下org.apache.commons.lang3.StringUtilsjoin() 方法的源码:

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();
}

内部是用的仍然是 StringBuilder。

05、字符串分割(split())

使用 String 类提供的 split() 方法进行字符串的分割,注意在拆分之前,要先进行检查:判断一下这个字符串是否包含分割符号,否则应该抛出异常。

/**
 * @author QHJ
 * @date 2022/8/24  08:51
 * @description: String类测试
 */
public class StringMethods {
    public static void main(String[] args) {
        /**
         * 字符串分割
         */
//        String str = "青花椒,一枚有趣的程序猿";  // 中英文符号是有区分的哦
        String str = "青花椒,一枚有趣的程序猿";
        // 使用 , 分割
        if (str.contains(",")){
            String[] splitStr= str.split(",");
            System.out.println("first:" + splitStr[0]   + "  twice:" + splitStr[1]);
        }else{
            throw new IllegalArgumentException("当前字符串没有包含逗号");
        }
    }
}

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

String str = "青花椒,一枚有趣的程序猿,并且很美丽";
if (str.contains(",")){
    String[] splitStr1 = str.split(",", 2); // first:青花椒  twice:一枚有趣的程序猿,并且很美丽
    String[] splitStr2 = str.split(",", 3);
    System.out.println("first:" + splitStr1[0]   + "  twice:" + splitStr1[1]);
    System.out.println("first:" + splitStr2[0]   + "  twice:" + splitStr2[1] + "  third:" + splitStr2[2]); // first:青花椒  twice:一枚有趣的程序猿  third:并且很美丽
}else{
    throw new IllegalArgumentException("当前字符串没有包含逗号");
}

查看源码,split(分隔符,拆分的字符串的个数)方法在传递两个参数的时候,会直接调用 substring() 方法进行截取。

这是建立在字符串是确定的情况下的,最重要的是分隔符是确定的。因为大约有 12 种英文特殊符号,如果直接拿这些特殊符号替换上面代码中的分割符(英文逗号),这段程序在运行的时候就会出现错误:

  1. 反斜杠 \ (ArrayIndexOutOfBoundsException)
  2. 插入符号 ^ (ArrayIndexOutOfBoundsException)
  3. 美元符号 $ (ArrayIndexOutOfBoundsException)
  4. 逗点 . (ArrayIndexOutOfBoundsException)
  5. 竖线 | (正常,没有出错)
  6. 问号 ? (PatternSyntaxException)
  7. 星号 * (PatternSyntaxException)
  8. 加号 + (PatternSyntaxException)
  9. 左小括号或者右小括号 ( ) (PatternSyntaxException)
  10. 左方括号或者右方括号 [ ](PatternSyntaxException)
  11. 左大括号或者右大括号 { }(PatternSyntaxException)

那遇到这些特殊符号该怎么办呢?正则表达式是一组由字母和符号组成的特殊文本,它可以用来从文本中找出满足想要的格式的句子。

二哥在 github 上找了一个开源的正则表达式的学习文档,可谓是非常详细了,在写正则表达式时可以参考:

同时,二哥还给我们整理了一些在平时项目开发中经常用到的正则表达式:

真周到啊 O(∩_∩)O哈哈~

假如要使用特殊符号逗点 . 来分割,这时就需要使用正则表达式而不能直接使用逗点:

String[] splitStr= str.split("\\.");

很好奇为什么需要两个反斜杠呢?这时因为反斜杠本身就是一个特殊字符,它也需要用反斜杠来转义。

同时,也可以使用 [ ] 来包裹住英文逗点,[ ] 用来匹配方括号中包含的任意字符:

str.split("[.]");

另外,还可以使用 Pattern 类的 quote() 方法来包含英文逗点,该方法会返回一个使用 \Q \E 包裹的字符串:
在这里插入图片描述

String[] splitStr= str.split(Pattern.quote("."));

spilt() 方法的参数时正则表达式的时候,方法最终会执行这行代码:

return Pattern.compile(regex).split(this, limit);

这也意味着,拆分字符串可以不使用 String 类的 split() 方法:

/**
 * @author QHJ
 * @date 2022/8/24  14:26
 * @description: 使用Pattern类分割字符串
 */
public class PatternSplitTest {
    private static Pattern pattern = Pattern.compile("\\.");

    public static void main(String[] args) {
        String[] str = pattern.split("青花椒.一枚有趣的程序猿");
        System.out.println("first:" + str[0]   + "  twice:" + str[1]);
    }
}

这里把 Pattern 表达式声明成 static 是因为模式是确定的,通过 static 的预编译功能可以提高程序的效率。

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

在 Java 语言中有 8 中基本类型和一种比较特殊的类型 String。这些类型为了使它们在运行过程中速度更快、更节省内存,都提供了一种常量池的概念。常量池就类似于一个 Java 系统级别提供的缓存。

  • 字符串对象的创建

    在没有了解字符串常量池之前,往往会认为下面一行代码中创建一个字符串对象 s 只是创建了一个对象,但是很显然,这种想法是错误的。

    String s = new String("青花椒");
    

    分析上行代码的对象创建情况:使用 new 关键字创建一个字符串对象时,Java 虚拟机会先在字符串常量池中查找有没有"青花椒"这个字符串对象:

    1. 如果有,就不会再在字符串常量池中创建 “青花椒” 这个对象了,而是直接在堆中创建一个 “青花椒” 的字符串对象,然后将堆中这个 “青花椒” 的字符串对象地址返回赋值给变量 s;(创建一个对象)
    2. 如果没有,会先在字符串常量池中创建一个 “青花椒” 的字符串对象,然后再在堆中创建一个 “青花椒” 的字符串对象,然后将堆中这个 " 青花椒" 的字符串对象地址返回赋值给变量 s。(创建两个对象)

    但是,在通常情况下,我们会使用双引号的方式来创建字符串对象,而不是通过使用 new 关键字的方式来创建:

    String s = "青花椒";
    

    当执行这行代码时,Java 虚拟机会先在字符串常量池中查找有没有 “青花椒” 这个字符串对象:

    1. 如果有,则不创建任何对象,直接将字符串常量池中这个 “青花椒” 的对象地址返回赋值给变量 s;(不创建对象)
    2. 如果没有,在字符创常量池中创建 " 青花椒" 这个对象,然后将其地址返回赋值给变量 s。(创建一个对象)
    // 创建三个对象(字符串常量池中一个,堆上两个)
    String s1 = new String("青花椒");
    String s2 = new String("青花椒");
    // 创建一个对象(字符串常量池中一个)
    String s3 = "青花椒";
    String s4 = "青花椒";
    

    双引号与 new 的方式相比,少了在堆中创建对象这一步骤,直接通过双引号的方式直接创建字符串对象,并且会重复利用字符串常量池中已经存在的对象。而 new 的方式是:不管字符串的内容是否已经存在,始终会创建一个对象。

  • 为什么要分两次创建字符串对象?

    为什么要先在字符串常量池中创建对象,然后再在堆中创建呢?

    因为字符串的使用频率会很高,所以 Java 虚拟机为了提高性能和减少内存的开销,在创建字符串对象的时候进行了一些优化———特意为字符串开辟了一个字符串常量池。

  • 字符串常量池存储位置

    在 Java 8 之前,字符串常量池存储在永久代中:
    JavaSE基础之(十八)String类、Arrays类、Math类-小白菜博客
    Java 8 之后,移除了永久代,字符串常量池移入了堆中:
    在这里插入图片描述

  1. 方法区是 Java 虚拟机规范中的一个概念,就像是一个接口;

  2. 永久代是 HotSpot 虚拟机中对方法的一个实现,就像是接口的实现类;

  3. Java 8 的时候,移除了永久代,取而代之的是元空间,是方法区的另外一个实现。

    永久代是放在运行时数据区中的,所以它的大小受到 Java 虚拟机本身大小的限制,所以 Java 8 之前,会经常遇到 java.lang.OutOfMemoryError: PremGen Space 的异常,PremGen Space 就是方法区的意思;而元空间是直接放在内存中的,所以只受本机可用内存的限制,虽然也会发生内存溢出,但出现的几率相对之前就小了很多。

18.1.4 深入解析String.intern()方法

戳链接查看:

8 种基本类型的常量池都是系统协调的,String 类型的常量池比较特殊。它的主要使用方法有两种:

  1. 直接使用双引号声明出来的 String 对象会直接存储在常量池中。
  2. 如果不是用双引号声明的 String 对象,可以使用 String 提供的 intern() 方法。

intern() 方法会从字符串常量池中查询当前字符串是否存在,若不存在就会将当前字符串放入常量池中。

在学习 intern() 方法之前必须要知道这几点:

  1. 使用双引号声明的字符串对象会保存在字符串常量池中;
  2. 使用 new 关键字创建的字符串对象会先从字符串常量池中查找,如果没有找到就创建一个,然后再在堆中创建字符串对象;如果找到了就直接在堆中创建字符串对象;
  3. 针对没有使用双引号声明的字符串对象来说,比如 String s1 = new String(""青花椒) + new String("程序猿"),如果想把 s1 的内容也放入字符串常量池的话,可以调用 intern() 方法来完成(将对象的引用加入到字符串常量池中)。

Java 7 的时候,字符串常量池从永久代中移动到堆中,但是永久代还并没有完全被移除。Java 8 的时候永久代才被彻底移除。

这个变化也直接影响了 String.intern() 方法在执行时的策略。Java 7 之前在执行此方法的时候,不管对象在堆中是否已经创建,字符串常量池中仍然会创建一个内容完全相同的新对象;Java 7 之后由于字符串常量池放在了堆中,在执行此方法的时候,如果对象在堆中已经创建了,字符串常量池中就不需要再创建新的对象了,而是直接保存堆中对象的引用,也就节省了一部分的内存空间。

举两个常见的例子对比一下:

  • 例子一

    // ①
    String s1 = new String("青花椒");
    s1.intern();
    String s2 = "青花椒";
    System.out.println(s1 == s2); // false
    
    // ②
    String s1 = new String("青花椒");
    String s2 = "青花椒";
    s1.intern();
    System.out.println(s1 == s2); // false
    

    ①程序执行大概分为这么几个步骤:

    1. 第一行代码,字符串常量池中会先创建一个 “青花椒” 的对象,然后再在堆中创建一个 “青花椒” 的对象,s1 引用的是堆中的对象;
    2. 第二行代码,对 s1 执行 intern() 方法,该方法会从字符串常量池中查找 " 青花椒" 这个字符串是否存在,显然是已经存在的;
    3. 第三行代码,生成一个 s2 的引用指向常量池中的对象,所以 s2 引用的是字符串常量池中的对象。

    ②程序执行大概分为这么几个步骤:

    1. 第一行代码,字符串常量池中会先创建一个 “青花椒” 的对象,然后再在堆中创建一个 “青花椒” 的对象,s1 引用的是堆中的对象;
    2. 第二行代码,生成一个 s2 的引用指向常量池中的对象,所以 s2 引用的是字符串常量池中的对象;
    3. 第三行代码,对 s1 执行 intern() 方法,该方法会从字符串常量池中查找 " 青花椒" 这个字符串是否存在,显然是已经存在的。

    这就意味着 s1 和 s2 的引用地址是不同的,一个来自堆,一个来自字符串常量池,所以结果为 false。
    在这里插入图片描述

  • 例子二

    // ①
    String s3 = new String("青花椒") + new String("程序猿");
    s3.intern();
    String s4 = "青花椒程序猿";
    System.out.println(s3 == s4); // true
    
    // ②
    String s3 = new String("青花椒") + new String("程序猿");
    String s4 = "青花椒程序猿";
    s3.intern();
    System.out.println(s3 == s4); // false
    

    ①程序的执行大概分为这么几个步骤:

    1. 第一行代码,字符串常量池中会创建两个对象,一个是 “青花椒”,一个是 “程序猿”,然后再在堆中创建两个匿名对象 “青花椒” 和 “程序猿”,然后还有一个 “青花椒程序猿” 的对象,s3 引用的是堆中的 “青花椒程序猿” 这个对象;

    2. 第二行代码,将 s3 中的 “青花椒程序猿” 字符串放入字符串常量池中,此时常量池中是不存在 “青花椒程序猿” 字符串的,所以在常量池中生成一个指向堆中 “青花椒程序猿” 对象的引用;

    3. 第三行代码,直接在常量池中创建 “青花椒程序猿” 对象,此时已经存在了,也就是指向 s3 对象的引用。

      这就意味着 s3 和 s4 的引用地址是相同的,都是来自堆,所以结果为 true。
      在这里插入图片描述

    ②程序的执行大概分为这么几个步骤:

    1. 第一行代码,字符串常量池中会创建两个对象,一个是 “青花椒”,一个是 “程序猿”,然后再在堆中创建两个匿名对象 “青花椒” 和 “程序猿”,然后还有一个 “青花椒程序猿” 的对象,s3 引用的是堆中的 “青花椒程序猿” 这个对象;

    2. 第二行代码,直接在字符串常量池中创建 “青花椒程序猿” 对象,此时常量池中是不存在 “青花椒程序猿” 字符串的,所以在字符串常量池中新生成一个 “青花椒程序猿” 对象,s4 指向字符串常量池中的新对象;

    3. 第三行代码,将 s3 中的 “青花椒程序猿” 字符串放入字符串常量池中,此时常量池中已经存在 “青花椒程序猿” 字符串。

      这就意味着 s3 和 s4 的引用地址是不相同的,一个来自堆,一个来自字符串常量池,所以结果为 false。
      在这里插入图片描述

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

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

18.2.5 如何判断两个字符串是否相等

Java 中有两个常用的方法可以用来判断两个字符串是否相等:

  • "==" 操作符用于比较两个对象的地址是否相等
  • .equals() 方法用于比较两个对象的内容是否相等
String string1 = new String("青花椒");
String string2 = new String("青花椒");
System.out.println(string1 == string2);      // false
System.out.println(string1.equals(string2)); // true

就这段代码来说,“==” 要求必须是同一个对象,.equals() 则要求内容相等即可。

我们都知道,Java 的所有类都默认继承了 Object 这个超类,戳链接查看:。该类有一个名为 .equals() 的方法,扒一下 Object 类的源码:

public boolean equals(Object obj) {
    return (this == obj);
}

由此可见,Object 类的 .equals() 方法默认采用的是 “==” 操作符进行比较,假如子类没有重写该方法的话,"==" 操作符和 .equals() 方法的功效就完全一样——用于比较两个对象的内存地址是否相等。

但实际情况中,因为比较内存地址要求比较严格,不太符合现实中所有的场景需求,所以有不少的类重写了 .equals() 方法。就拿 String 类来说,我们在比较字符串的时候,的确也只是想要判断两者的内容是否相等,而并不是想要比较两者是否是同一个对象。

况且,字符串有字符串常量池的概念,本身就推荐使用 String s = "青花椒" 这种形式来创建字符串对象,而不是通过 new 关键字的方式,因为可以把字符串缓存在字符串常量池中以方便下次使用。

扒一下 String 类 .equals() 方法的源码:

public boolean equals(Object anObject) {
    if (this == anObject) {
        return true;
    }
    if (anObject instanceof String) {
        String aString = (String)anObject;
        if (coder() == aString.coder()) {
            return isLatin1() ? StringLatin1.equals(value, aString.value)
                    : StringUTF16.equals(value, aString.value);
        }
    }
    return false;
}

首先,如果两个字符串对象可以 “==”,就直接返回 true,因为在这种情况下字符串内容必然是相等的。否则就按照字符编码进行比较,分为 UTF16 和 Latin1,这两个的差别不是很大,拿 Latin1 来说:

@HotSpotIntrinsicCandidate
public static boolean equals(byte[] value, byte[] other) {
    if (value.length == other.length) {
        for (int i = 0; i < value.length; i++) {
            if (value[i] != other[i]) {
                return false;
            }
        }
        return true;
    }
    return false;
}

这个 JDK 版本是 Java 17 的,也就是最新的 LTS(长期支持)版本。在该版本中,String 类是使用字节数组实现的,所以比较两个字符串的内容是否相等时可以先比较字节数组的长度是否相等,不相等就直接返回 false;否则就遍历两个字符串的字节数组,只要有一个字节不相等,就返回 false。

举几个常见的情况:

  1. new String("青花椒").equals("青花椒"):比较的是两个字符串的内容是否相等,返回 true;
  2. new String("青花椒") == "青花椒":左侧是在堆中创建的对象,右侧是在字符串常量池中的对象,内容相同但地址不同,返回 false;
  3. new String("青花椒") == new String("青花椒"):new 出来的对象是完全不同的内存地址,返回 false;
  4. "青花椒" == "青花椒":左右对象都存放在字符串常量池中,常量池中只会有一个内容相同的对象,返回 true;
  5. new String("青花椒").intern() == "青花椒":new String(“青花椒”) 在执行时会先在字符串常量池中创建对象,然后再在堆中创建对象;执行 .intern() 方法时发现字符串常量池中已经有 “青花椒” 这个对象了,所以左侧直接返回字符串常量池中的对象引用 ,返回 true。

补充
另外,要进行两个字符串对象的内容比较,还有两个方法:

  • 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() 用起来就完全没有这个担心。

    Objects.equals("青花椒", new String("青花椒"));  // true
    Objects.equals(null, new String("青花椒"));  // false
    Objects.equals(null, null) // true
    
    String a = null;
    a.equals(new String("青花椒")); // throw exception
    
  • String 类的.contentEquals()方法

    .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;
    }
    

    从源码上看,如果 cs 是 StringBuffer,该方法还会进行同步;如果是 String 的话,其实调用的还是 equals() 方法。这也就意味着使用该方法进行比较的时候,多出来了很多步骤,在性能上会有些损失。

18.2.6 使用步骤

  • 查看类

    java.lang.String ;//此类不需要导入。 
    
  • 查看构造方法

    • public String() :初始化新创建的 String对象,以使其表示空字符序列。
    • public String(char[] value) :通过当前参数中的字符数组来构造新的 String。
    • public String(byte[] bytes) :通过使用平台的默认字符集解码当前参数中的字节数组来构造新的 String。
    构造举例,代码如下:
    // 无参构造 
    String str = new String();
    // 通过字符数组构造 
    char chars[] = {'a', 'b', 'c'};      
    String str2 = new String(chars);  
    // 通过字节数组构造 
    byte bytes[] = { 97, 98, 99 };      
    String str3 = new String(bytes);
    
  • 常用方法

    判断功能的方法

    检测字符串是否相等。

    • public boolean equals (Object anObject) :将此字符串与指定对象进行比较。
    • public boolean equalsIgnoreCase (String anotherString) :将此字符串与指定对象进行比较,忽略大小写。

    注:Object 是“对象”的意思,也是一种引用类型。作为参数类型,表示任意对象都可以传递到方法中。

    小贴士:
    “==”比较的是内存地址是否一致(判断两个字符串是否放在同一个位置上);
    “equals”比较的是两个字符串的内容是否一致。

    获取功能的方法

    • public int length () :返回此字符串的长度。
    • public String concat (String str) :将指定的字符串连接到该字符串的末尾。
    • public char charAt (int index) :返回指定索引处的 char 值。
    • public int indexOf (String str) :返回指定子字符串第一次出现在该字符串内的索引。
    • public String substring (int beginIndex) :返回一个子字符串,从 beginIndex 开始截取字符串到字符串结尾。
    • public String substring (int beginIndex, int endIndex) :返回一个子字符串,从 beginIndex 到 endIndex 截取字符串。含beginIndex,不含endIndex(左闭右开)。

    转换功能的方法

    • public char[] toCharArray () :将此字符串转换为新的字符数组。
    • public byte[] getBytes () :使用平台的默认字符集将该 String编码转换为新的字节数组。
    • public String replace (CharSequence target, CharSequence replacement) :将与target匹配的字符串使用replacement字符串替换。

    CharSequence 是一个接口,也是一种引用类型。作为参数类型,可以把 String 对象传递到方法中。

    分割功能的方法

    • public String[] split(String regex) :将此字符串按照给定的 regex(规则)拆分为字符串数组。

    JavaSE基础之(十八)String类、Arrays类、Math类-小白菜博客
    在这里插入图片描述

    在这里插入图片描述

18.2 Arrays类

18.2.1 Arrays类概述

java.util.Arrays 类包含用来操作数组的各种方法,比如排序和搜索等。基本上常见的数组的操作,这个类都提供了静态方法可供直接调用,使用起来非常简单。

18.2.2 Arrays类10大常用方法

01、创建数组

使用 Arrays 类创建数组可以通过三个方法:

  • copyOf:复制指定的数组,截取或用 null 填充

    /**
     * @author QHJ
     * @date 2022/8/23  10:49
     * @description: Arrays类10大常用方法
     */
    public class ArraysMethods {
        public static void main(String[] args) {
        	/**
             * 1. 创建数组
             */
            String[] str = new String[]{"青", "花", "椒"};
            String[] copyOfStr1 = Arrays.copyOf(str, 2);
            String[] copyOfStr2 = Arrays.copyOf(str, 4);
            System.out.println(Arrays.toString(copyOfStr1)); // [青, 花]
            System.out.println(Arrays.toString(copyOfStr2)); // [青, 花, 椒, null]
        }
    }
    

    ArrayList(内部的数据结构用的就是数组)源码中的 grow() 方法就调用了 copyOf() 方法:

    elementData = Arrays.copyOf(elementData, newCapacity);
    

    当 ArraysList 初始大小不满足元素的增长时就会扩容(ArrayList 每次扩容之后容量都会变为原来的 1.5 倍左右—oldCapacity为偶数就是1.5倍,否则是1.5倍左右)。

  • copyOfRange:复制指定范围内的数组到一个新数组

    copyOfRange(str, from, to) 方法需要三个参数,第一个是指定的数组,第二个是起始位置(包含),第三个是截止位置(不包含)。

    /**
     * @author QHJ
     * @date 2022/8/23  10:49
     * @description: Arrays类10大常用方法
     */
    public class ArraysMethods {
        public static void main(String[] args) {
        	/**
             * 1. 创建数组
             */
            String[] copyOfRangeStr1 = Arrays.copyOfRange(str, 1, 2);
            String[] copyOfRangeStr2 = Arrays.copyOfRange(str, 0, 5);
            System.out.println(Arrays.toString(copyOfRangeStr1)); // [花]
            System.out.println(Arrays.toString(copyOfRangeStr2)); // [青, 花, 椒, null, null]
        }
    }
    

    不够的位数使用 null 填充,可能是 Arrays 的设计者虑到了数组越界的问题,不然每次调用 Arrays 类就要判断很多次长度就很麻烦。

  • fill:对数组进行填充

    /**
     * @author QHJ
     * @date 2022/8/23  10:49
     * @description: Arrays类10大常用方法
     */
    public class ArraysMethods {
        public static void main(String[] args) {
        	/**
             * 1. 创建数组
             */
            String[] fillStr1 = new String[1];
            int[] fillStr2 = new int[3];
            Arrays.fill(fillStr1, "青花椒");
            Arrays.fill(fillStr2, 1);
            System.out.println(Arrays.toString(fillStr1)); // [青花椒]
            System.out.println(Arrays.toString(fillStr2)); // [1, 1, 1]
        }
    }
    

    根据 new 出的数组长度填充一个完全相同的 N 个元素的数组。

02、比较数组

Arrays 类的equals()方法用来判断连个数组是否相等。

/**
 * @author QHJ
 * @date 2022/8/23  10:49
 * @description: Arrays类10大常用方法
 */
public class ArraysMethods {
        /**
         * 2. 比较数组
         */
        String[] intro = new String[]{"青", "花", "椒"};
        boolean result1 = Arrays.equals(new String[]{"青", "花", "椒"}, intro);
        boolean result2 = Arrays.equals(new String[]{"青", "花", "椒2"}, intro);
        System.out.println(result1); // true
        System.out.println(result2); // false
    }
}

简单扒一下equals()方法的源码:

public static boolean equals(Object[] a, Object[] a2) {
        if (a==a2)
            return true;
        if (a==null || a2==null)
            return false;

        int length = a.length;
        if (a2.length != length)
            return false;

        for (int i=0; i<length; i++) {
            if (!Objects.equals(a[i], a2[i]))
            	return false;
        }

        return true;
    }

数组是一个对象,所以先使用 “==” 操作符进行判断,如果不相等,再判断是否为 null—两个都为 null 就返回 false;接着判断 length,不等的话返回 false;否则,以此调用 Objects.equals() 比较相同位置上的元素是否相等。

除了equals()方法外,还有另外一个判断两个数组是是否相等的方法:Arrays.hashCode(),但是可能会出现误差。
扒一下 hashCode() 方法的源码:

public static int hashCode(Object a[]) {
        if (a == null)
            return 0;

        int result = 1;

        for (Object element : a)
            result = 31 * result + (element == null ? 0 : element.hashCode());

        return result;
    }

哈希算法本身是非常严谨的,所以说如果两个数组的哈希值相等,那几乎可以判断两个数组是相等的。当我们想快速确认两个数组是否相等时,可以通过比较 hashCode 来确认(投机取巧,风险高)。

03、数组排序

Arrays 类的 sort()方法用来对数组进行排序。

/**
 * @author QHJ
 * @date 2022/8/23  10:49
 * @description: Arrays类10大常用方法
 */
public class ArraysMethods {
    public static void main(String[] args) {
        /**
         * 3. 数组排序
         */
        String[] strs  = new String[]{"qing", "hua", "jiao"};
        int[] nums = new int[]{1, 10, 15};
        String[] sortedStr = Arrays.copyOf(strs, 3);
        int[] sortedNums = Arrays.copyOf(nums, 2);
        Arrays.sort(sortedStr);
        Arrays.sort(sortedNums);
        System.out.println(Arrays.toString(sortedStr)); // [hua, jiao, qing]
        System.out.println(Arrays.toString(sortedNums)); // [1, 10]
    }
}

可以看出,String 类型是按照首字母的升序进行排列的。基本数据类型是按照双轴快速排序的,引用数据类型是按照 TimSort 排序的,使用了 Peter McIlroy 的“乐观排序和信息理论复杂性”中的技术。

04、数组检索

数组排序后就可以使用 Arrays 类的 binarySearch()方法进行二分查找了。否则的话只能线性检索,效率就会降低很多。

/**
 * @author QHJ
 * @date 2022/8/23  10:49
 * @description: Arrays类10大常用方法
 */
public class ArraysMethods {
    public static void main(String[] args) { /**
         * 4. 数组检索
         */
        String[] str1 = new String[]{"qing", "hua", "jiao", "is", "beautiful"};
        String[] sorted = Arrays.copyOf(str1, 3);
        Arrays.sort(sorted);
        int exact = Arrays.binarySearch(sorted, "jiao");
        System.out.println(exact); // 1
        // 忽略大小写查找
        int caseInsensitive = Arrays.binarySearch(sorted, "jiao", String::compareToIgnoreCase);
        System.out.println(caseInsensitive); // 1
    }
}

binarySearch() 方法既可以精确检索,也可以模糊检索(忽略大小写)。

注意:
如果要从数组或者集合中查找元素的话,尽量先排序,然后使用二分查找法,这样能提高检索的效率。

05、数组转流

“流的英文单词是 Stream,它可以极大提高 Java 程序员的生产力,让程序员写出高效、干净、简洁的代码。这种风格将要处理的集合看作是一种流,想象一下水流在管道中流过的样子,我们可以在管道中对流进行处理,比如筛选、排序等等。”—摘自二哥

/**
 * @author QHJ
 * @date 2022/8/23  10:49
 * @description: Arrays类10大常用方法
 */
public class ArraysMethods {
    public static void main(String[] args) {
        /**
         * 5. 数组转流
         */
        String[] str2 = new String[]{"qing", "hua", "jiao"};
        System.out.println(Arrays.stream(str2).count()); // 3
        System.out.println(Arrays.stream(str2, 1, 2).count()); // 1
    }
}

Arrays 类的 stream()方法可以将数组转换成流,还可以指定起始下标和结束下标,但是如果下标的范围有误的话,程序会抛出 ArrayIndexOutOfBoundsException 异常。

06、打印数组

因为数组是一个对象,直接输出的话会输出一个地址。所以,最有呀的打印方式就是使用 Arrays.toString() 方法,扒一下源码:

public static String toString(Object[] a) {
    if (a == null)
        return "null";

    int iMax = a.length - 1;
    if (iMax == -1)
        return "[]";

    StringBuilder b = new StringBuilder();
    b.append('[');
    for (int i = 0; ; i++) {
        b.append(String.valueOf(a[i]));
        if (i == iMax)
            return b.append(']').toString();
        b.append(", ");
    }
}
  1. 先判断 null,是的话,直接返回“null”字符串;
  2. 获取数组的长度,如果数组的长度为 0( 等价于 length - 1 为 -1),返回中括号“[]”,表示数组为空的;
  3. 如果数组既不是 null,长度也不为 0,就声明 StringBuilder 对象,然后添加一个数组的开始标记“[”,之后再遍历数组,把每个元素添加进去;其中一个小技巧就是,当遇到末尾元素的时候(i == iMax),不再添加逗号和空格“, ”,而是添加数组的闭合标记“]”。

07、数组转List

尽管数组非常强大,但是它自身可以操作的工具方法很少,比如:判断数组中是否包含某个值。如果能转成 List 的话就简便多了,因为 Java 的集合框架 List 中封装了很多常用的方法。

/**
 * @author QHJ
 * @date 2022/8/23  10:49
 * @description: Arrays类10大常用方法
 */
public class ArraysMethods {
    public static void main(String[] args) {
        /**
         * 7. 数组转 List
         */
        String[] str3 = new String[]{"青", "花", "椒"};
        List<String> result = Arrays.asList(str3);
        System.out.println(result.contains("花")); // true
    }
}

需要注意的是,Arrays.asList()返回的是 java.util.Arrays.ArrayList,并不是 java.util.ArrayList,它的长度是固定的,无法进行元素的删除或添加。

如果要想操作元素的话,需要多一步转化,转成真正的 java.util.ArrayList

List<String> results = new ArrayList<>(Arrays.asList(str3));
results.add("is");
result.remove("花");

08、setAll

Java 8 新增了 setAll() 方法,它提供了一个函数式编程的入口,可以对数组的元素进行填充。

可以用来为新数组填充基于原来数组的新元素。

/**
 * @author QHJ
 * @date 2022/8/23  10:49
 * @description: Arrays类10大常用方法
 */
public class ArraysMethods {
    public static void main(String[] args) {
        /**
         * 8. setAll()
         */
        int[] numbers = new int[10];
        Arrays.setAll(numbers, i -> i * 10);
        System.out.println(Arrays.toString(numbers)); // [0, 10, 20, 30, 40, 50, 60, 70, 80, 90]
    }
}

i 就相当于数组的下标,从 0 开始,到 9 结束,那么 i * 10 就意味着值从 0 * 10 开始,到 9* 10 结束。

09、parallelPrefix

parallelPrefix() 方法和 setAll() 方法一样,也是 Java 8 之后提供的,提供了一个函数式编程的入口,通过遍历数组中的元素,将当前下标位置上的元素与它之前下标的元素进行操作,然后将操作后的结果覆盖当前下标位置上的元素。

/**
 * @author QHJ
 * @date 2022/8/23  10:49
 * @description: Arrays类10大常用方法
 */
public class ArraysMethods {
    public static void main(String[] args) {
        /**
         * 9. parallelPrefix()
         */
        int[] arr = new int[]{1, 2, 3, 4};
        Arrays.parallelPrefix(arr, (left, right) -> left + right);
        System.out.println(Arrays.toString(arr)); // [1, 3, 6, 10]
    }
}

1, 2
3, 3
6, 4
[1, 3, 6, 10]

18.3 Math类

  • 概述

    java.lang.Math 类包含用于执行基本数学运算的方法,如初等指数、对数、平方根和三角函数。类似这样的工具类,其所有方法均为静态方法,并且不会创建对象,调用起来非常简单。

  • 基本运算的方法

    • public static double abs(double a) :返回 double 值的绝对值。
    double d1 = Math.abs(5); //d1的值为5 
    double d2 = Math.abs(5); //d2的值为5
    
    • public static double ceil(double a) :返回大于等于参数的小的整数。
    double d1 = Math.ceil(3.3); //d1的值为 4.0 
    double d2 = Math.ceil(3.3); //d2的值为 ‐3.0 
    double d3 = Math.ceil(5.1); //d3的值为 6.0
    
    • public static double floor(double a) :返回小于等于参数大的整数。
    double d1 = Math.floor(3.3); //d1的值为3.0 
    double d2 = Math.floor(3.3); //d2的值为‐4.0 
    double d3 = Math.floor(5.1); //d3的值为 5.0
    
    • public static long round(double a) :返回接近参数的 long。(相当于四舍五入方法)
    long d1 = Math.round(5.5); //d1的值为6.0 
    long d2 = Math.round(5.4); //d2的值为5.0