【搞定Java基础】第4篇:Java 中的 String 类详解 【解惑篇】

r囧r小猫 2022-03-25 02:28 253阅读 0赞

本文转发自:

1、https://blog.csdn.net/justloveyou_/article/details/52556427

2、https://blog.csdn.net/justloveyou_/article/details/60983034

本文目录:

一、Java 内存模型与常量池

二、常量与变量

三、String 类的定义与基础

四、String 的不可变性

五、String 对象的创建方式

六、字符串常量池

七、三大字符串类:String、StringBuilder 和 StringBuffer

八、字符串与正则表达式:匹配、替换和验证

九、String 与 (深)克隆

十、String 总结

本篇文章篇幅较长,但是对于 Java 中的 String 类进行了全方位的讲解,非常适合扫盲知识点阅读。


摘要:

  Java 中的 String 类 是我们日常开发中使用最为频繁的一个类,但要想真正掌握的这个类却不是一件容易的事情。笔者为了还原 String 类的真实全貌。笔者从 Java 内存模型展开,结合 JDK 中 String 类的源码进行深入分析,特别就 String 类与享元模式,String 常量池,String 不可变性,String 对象的创建方式,String 与正则表达式,String 与克隆,String、StringBuffer 和 StringBuilder 的区别等几个方面对其进行详细阐述、总结,力求能够对String类的原理和使用作一个最全面和最准确的介绍。


一、Java 内存模型与常量池

1、Java 内存模型

watermark_type_ZmFuZ3poZW5naGVpdGk_shadow_10_text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3Bjd2wxMjA2_size_16_color_FFFFFF_t_70

  • 程序计数器

  多线程时,当线程数超过 CPU 数量或 CPU 内核数量,线程之间就要根据时间片轮询抢夺 CPU 时间资源。因此,每个线程要有一个独立的程序计数器,记录下一条要运行的指令,其为线程私有的内存区域。如果执行的是 Java 方法,计数器记录正在执行的 Java 字节码地址,如果执行的是 native 方法,则计数器为空。

  • 虚拟机栈

线程私有的,与线程在同一时间创建,是管理 Java 方法执行的内存模型。

栈中主要存放一些基本类型的变量数据(byte, int, short, long, float, double, boolean, char)和对象引用。每个方法执行时都会创建一个桢栈来存储方法的的变量表、操作数栈、动态链接方法、返回值、返回地址等信息。栈的大小决定了方法调用的可达深度(递归多少层次,或嵌套调用多少层其他方法,-Xss参数可以设置虚拟机栈大小)。栈的大小可以是固定的,或者是动态扩展的。如果请求的栈深度大于最大可用深度,则抛出:stackOverflowError;如果栈是可动态扩展的,但没有内存空间支持扩展,则抛出:OutofMemoryError。使用 jclasslib 工具可以查看 class 类文件的结构。下图为栈帧结构图:

watermark_type_ZmFuZ3poZW5naGVpdGk_shadow_10_text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3Bjd2wxMjA2_size_16_color_FFFFFF_t_70 1

  • 本地方法区

和虚拟机栈功能相似,但管理的不是 Java 方法,是本地方法,本地方法是用 C 实现的。

  • Java 堆

线程共享的,存放所有对象实例和数组,是垃圾回收的主要区域。

堆是一个运行时数据区,类的对象从中分配空间,这些对象通过 new、newarray、 anewarray 和 multianewarray 等指令建立,它们不需要程序代码来显式的释放。堆可以分为新生代和老年代(tenured)。新生代用于存放刚创建的对象以及年轻的对象,如果对象一直没有被回收,生存的足够长,老年对象就会被移入老年代。新生代又可进一步细分为 eden(伊甸园)、survivorSpace0 (s0,from space)、survivorSpace1 (s1,tospace)。刚创建的对象都放入 eden区, s0 和 s1 都至少经过一次 GC 并幸存。如果幸存对象经过一定时间仍存在,则进入老年代(tenured)。

20190118105114562.png

  • 方法区

线程共享的,用于存放被虚拟机加载的类的元数据信息,如:常量、静态变量、即时编译器编译后的代码,也成为永久代。如果 hotspot 虚拟机确定一个类的定义信息不会被使用,也会将其回收。回收的基本条件至少有:所有该类的实例被回收,而且装载该类的 ClassLoader 被回收。

2、常量池

关于常量池可以看博主的另一篇文章:Java 中的常量池:字符串常量池、class 常量池、运行时常量池

常量池属于类信息的一部分,而类信息反映到 JVM 内存模型中对应于方法区,也就是说,常量池位于方法区常量池主要存放两大常量:字面量(Literal)符号引用(Symbolic References)。其中:

字面量主要包括字符串字面量,整型字面量 和 声明为 final 的常量值等。

而符号引用则属于编译原理方面的概念,包括了下面三类常量:类和接口的全限定名、字段的名称和描述符、方法的名称和描述符。


二、常量与变量

我们一般把内存地址不变,值可以改变的东西称为变量,换句话说,在内存地址不变的前提下内存的内容是可变的,例如:

  1. public class String_2 {
  2. public static void f(){
  3. Human_1 h = new Human_1(1,30);
  4. Human_1 h2 = h;
  5. System.out.printf("h: %s\n", h.toString());
  6. System.out.printf("h2: %s\n\n", h.toString());
  7. h.id = 3;
  8. h.age = 32;
  9. System.out.printf("h: %s\n", h.toString());
  10. System.out.printf("h2: %s\n\n", h.toString());
  11. System.out.println( h == h2 ); // true : 引用值不变,即对象内存底子不变,但内容改变
  12. }
  13. }

我们一般把若内存地址不变,则值也不可以改变的东西称为常量。典型的 String 就是不可变的,所以称之为 常量(constant)。此外,我们可以通过 final 关键字来定义常量,但严格来说,只有基本类型被其修饰后才是常量(对基本类型来说是其值不可变,而对于对象变量来说其引用不可再变)。

例如:

  1. final int i = 5;
  2. String a = "abc";

三、String 类的定义与基础

1、String 的声明

watermark_type_ZmFuZ3poZW5naGVpdGk_shadow_10_text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3Bjd2wxMjA2_size_16_color_FFFFFF_t_70 2

由 JDK 中关于String的声明可以知道:

1、不同字符串可能共享同一个底层 char 数组,例如字符串 String s=”abc” 与 s.substring(1) 就共享同一个char数组:char[ ] c = {‘a’,’b’,’c’}。其中,前者的 offset 和 count 的值分别为 0 和 3,后者的 offset 和 count 的值分别为 1 和 2。

2、offset 和 count 两个成员变量不是多余的,比如,在执行substring操作时。

2、JDK 中关于 String 的描述

The String class represents character strings. All string literals(字符串字面值) in Java programs, such as “abc”, are implemented as instances of this class. Strings are constant(常量); their values cannot be changed after they are created. String buffers【StringBuilder OR StringBuffer】 support mutable strings. Because String objects are immutable, they can be shared ( 享元模式 ).

3、String 类所内置的操作

The class String includes methods for examining individual characters of the sequence ,for examining individual characters of the sequence, for comparing strings , for searching strings , for extracting substrings and for creating a copy of a string with all characters translated to uppercase or to lowercase. Case mapping is based on the Unicode Standard version specified by the java.lang.Character class.

4、字符串串联符号(“+”)以及将其他对象转换为字符串的特殊支持

The Java language provides special support for the string concatenation operator (+), and for conversion of other objects to strings. String concatenation is implemented through the StringBuilder(JDK1.5 以后) OR StringBuffer(JDK1.5 以前) class and its append method. String conversions(转化为字符串) are implemented through the method toString, defined by class Object and inherited by all classes in Java.

5、注意:

  • String 不属于八种基本数据类型,String 的实例是一个对象

因为对象的默认值是 null,所以 String 的默认值也是null。但它又是一种特殊的对象,有其它对象没有的一些特性(String 的不可变性导致其像八种基本类型一样,比如,作为方法参数时,像基本类型的传值效果一样)。 例如,以下代码片段:

  1. package com.zju.temp;
  2. public class Demo {
  3. public static void main(String[] args) {
  4. String str = "1234";
  5. changeStr(str);
  6. System.out.println(str);
  7. }
  8. private static void changeStr(String str) {
  9. String s = str;
  10. str += "abcde";
  11. System.out.println(s);
  12. }
  13. }

运行结果:

20190118111616703.png

  • new String() 和 new String(“”) 都是声明一个新的空字符串,是空串不是 null。

四、String 的不可变性

1、什么是不可变对象?

  众所周知,在 Java 中,String 类是不可变类 (基本类型的包装类都是不可改变的) 的典型代表,也是 Immutable 设计模式的典型应用。String 变量一旦初始化后就不能更改,禁止改变对象的状态,从而增加共享对象的坚固性、减少对象访问的错误,同时还避免了在多线程共享时进行同步的需要。那么,到底什么是不可变的对象呢? 可以这样认为:如果一个对象,在它创建完成之后,不能再改变它的状态,那么这个对象就是不可变的。不能改变状态指的是不能改变对象内的成员变量,包括:

1、基本数据类型的值不能改变;

2、引用类型的变量不能指向其他的对象;

3、引用类型指向的对象的状态也不能改变。

除此之外,还应具有以下特点:

1、除了构造函数之外,不应该有其它任何函数(至少是任何 public 函数)修改任何成员变量;

2、任何使成员变量获得新值的函数都应该将新的值保存在新的对象中,而保持原来的对象不被修改。

2、区分引用和对象

对于 Java 初学者, 对于 String 是不可变对象总是存有疑惑。看下面代码:

  1. String s = "ABCabc";
  2. System.out.println("s = " + s); // s = ABCabc
  3. s = "123456";
  4. System.out.println("s = " + s); // s = 123456

首先创建一个 String 对象 s,然后让 s 的值为“ABCabc”, 然后又让 s 的值为“123456”。 从打印结果可以看出,s 的值确实改变了。那么怎么还说 String 对象是不可变的呢?

其实这里存在一个误区: s 只是一个 String 对象的引用,并不是对象本身。对象在内存中是一块内存区,成员变量越多,这块内存区占的空间越大。引用只是一个 4 字节的数据,里面存放了它所指向的对象的地址,通过这个地址可以访问对象。 也就是说,s 只是一个引用,它指向了一个具体的对象,当 s = “123456”; 这句代码执行过之后,又创建了一个新的对象“123456”, 而引用 s 重新指向了这个新的对象,原来的对象 “ABCabc” 还在内存中存在,并没有改变。内存结构如下图所示:

watermark_type_ZmFuZ3poZW5naGVpdGk_shadow_10_text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3Bjd2wxMjA2_size_16_color_FFFFFF_t_70 3

Java 和 C++ 的一个不同点是,在 Java 中,引用是访问、操纵对象的唯一方式: 我们不可能直接操作对象本身,所有的对象都由一个引用指向,必须通过这个引用才能访问对象本身,包括获取成员变量的值,改变对象的成员变量,调用对象的方法等。而在 C++ 中存在引用,对象和指针三个东西,这三个东西都可以访问对象。其实,Java 中的引用和 C++ 中的指针在概念上是相似的,他们都是存放的对象在内存中的地址值,只是在 Java 中,引用丧失了部分灵活性,比如 Java 中的引用不能像 C++ 中的指针那样进行加减运算。

3、为什么 String 对象是不可变的?

要理解 String 的不可变性,首先看一下 String 类中都有哪些成员变量。 在 JDK1.6 中,String 的成员变量有以下几个:

  1. public final class String implements java.io.Serializable, Comparable<string>, CharSequence{
  2. // 用于保存字符串字符的数组
  3. private final char value[];
  4. // 数组中字符串的开始下标
  5. private final int offset;
  6. // 字符串中字符的总数
  7. private final int count;
  8. // 字符串的哈希值
  9. private int hash;
  10. // ...
  11. }

在 JDK1.7中,String 类做了一些改动,主要是改变了 substring 方法执行时的行为,这和本文的主题不相关。JDK1.7中 String 类的主要成员变量就剩下了两个:

  1. public final class String implements java.io.Serializable, Comparable<string>, CharSequence {
  2. // 保存字符串中字符的数组
  3. private final char value[];
  4. // 字符串的哈希值的缓存
  5. private int hash;
  6. // ...
  7. }

由以上的代码可以看出, 在 Java 中,String 类其实就是对字符数组的封装。

JDK6 中, value 是 String 封装的数组,offset 是 String 在这个 value 数组中的起始位置,count 是 String 所占的字符的个数。

JDK7中,只有一个 value 变量,也就是 value 中的所有字符都是属于 String 这个对象的。这个改变不影响本文的讨论。 除此之外还有一个 hash 成员变量,是该 String 对象的哈希值的缓存,这个成员变量也和本文的讨论无关。

在 Java 中,数组也是对象。 所以 value 也只是一个引用,它指向一个真正的数组对象。其实执行了 String s = “ABCabc”; 这句代码之后,真正的内存布局应该是这样的:

watermark_type_ZmFuZ3poZW5naGVpdGk_shadow_10_text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3Bjd2wxMjA2_size_16_color_FFFFFF_t_70 4

value,offset 和 count 这三个变量都是 private 的,并且没有提供 setValue,setOffset 和 setCount 等公共方法来修改这些值,所以在 String 类的外部无法修改 String。也就是说一旦初始化就不能修改, 并且在 String 类的外部不能访问这三个成员。此外,value,offset 和 count 这三个变量都是 final 的, 也就是说在 String 类内部,一旦这三个值初始化了, 也不能被改变。所以,可以认为 String 对象是不可变的了。

  那么在 String 中,明明存在一些方法,调用他们可以得到改变后的值。这些方法包括 substring, replace, replaceAll, toLowerCase 等。例如如下代码:

  1. public static void testReflection() throws Exception {
  2. // 创建字符串"Hello World", 并赋给引用s
  3. String s = "Hello World";
  4. System.out.println("s = " + s); // Hello World
  5. // 获取String类中的value字段
  6. Field valueFieldOfString = String.class.getDeclaredField("value");
  7. // 改变value属性的访问权限
  8. valueFieldOfString.setAccessible(true);
  9. // 获取s对象上的value属性的值
  10. char[] value = (char[]) valueFieldOfString.get(s);
  11. // 改变value所引用的数组中的第5个字符
  12. value[5] = '_';
  13. System.out.println("s = " + s); // Hello_World
  14. }

在这个过程中,s 始终引用的同一个 String 对象,但是在反射前后,这个 String 对象发生了变化, 也就是说,通过反射是可以修改所谓的“不可变”对象的。但是一般我们不这么做。这个反射的实例还可以说明一个问题:如果一个对象,他组合的其他对象的状态是可以改变的,那么这个对象很可能不是不可变对象。例如一个 Car 对象,它组合了一个 Wheel 对象,虽然这个 Wheel 对象声明成了 private final 的,但是这个 Wheel 对象内部的状态可以改变, 那么就不能很好的保证 Car 对象不可变。


五、String 对象的创建方式

1. 字面值形式: JVM 会自动根据字符串常量池中字符串的实际情况来决定是否创建新对象** (要么不创建,要么创建一个对象,关键要看常量池中有没有)**

JDK 中明确指出:

  1. String s = "abc";

等价于:

  1. char data[] = {'a', 'b', 'c'};
  2. String str = new String(data);

该种方式先在栈中创建一个对 String 类的对象引用变量 s,然后去查找 “abc” 是否被保存在字符串常量池中。若 ”abc” 已经被保存在字符串常量池中,则在字符串常量池中找到值为 ”abc” 的对象,然后将 s 指向这个对象; 否则,在堆中创建 char 数组 data,然后在堆中创建一个 String 对象 Object,它由 data 数组支持,紧接着这个 String 对象 Object 被存放进字符串常量池,最后将 s 指向这个对象。

例如:

  1. private static void test01(){
  2. String s0 = "kvill"; // 1
  3. String s1 = "kvill"; // 2
  4. String s2 = "kv" + "ill"; // 3
  5. System.out.println(s0 == s1); // true
  6. System.out.println(s0 == s2); // true
  7. }

执行第 1 行代码时,“kvill” 入池并被 s0 指向;执行第 2 行代码时,s1 从常量池查询到” kvill” 对象并直接指向它;所以,s0 和 s1 指向同一对象。 由于 ”kv” 和 ”ill” 都是字符串字面值,所以 s2 在编译期由编译器直接解析为 “kvill”,所以 s2 也是常量池中”kvill”的一个引用。 所以,我们得出 s0 == s1 == s2。

2. 通过 new 创建字符串对象 : 一概在堆中创建新对象,无论字符串字面值是否相等** (要么创建一个,要么创建两个对象,关键要看常量池中有没有)**

  1. String s = new String("abc");

等价于:

  1. String original = "abc";
  2. String s = new String(original);

所以,通过 new 操作产生一个字符串(“abc”)时,会先去常量池中查找是否有 “abc” 对象,如果没有,则创建一个此字符串对象并放入常量池中。然后,在堆中再创建 “abc” 对象,并返回该对象的地址。所以,对于 String str = new String(“abc”):如果常量池中原来没有 ”abc”,则会产生两个对象(一个在常量池中,一个在堆中);否则,产生一个对象。
  用 new String() 创建的字符串对象位于堆中,而不是常量池中。它们有自己独立的地址空间,例如:

  1. private static void test02(){
  2. String s0 = "kvill";
  3. String s1 = new String("kvill");
  4. String s2 = "kv" + new String("ill");
  5. String s = "ill";
  6. String s3 = "kv" + s;
  7. System.out.println(s0 == s1); // false
  8. System.out.println(s0 == s2); // false
  9. System.out.println(s1 == s2); // false
  10. System.out.println(s0 == s3); // false
  11. System.out.println(s1 == s3); // false
  12. System.out.println(s2 == s3); // false
  13. }

例子中,s0 还是常量池中”kvill”的引用,s1 指向运行时创建的新对象”kvill”,二者指向不同的对象。对于 s2,因为后半部分是 new String(“ill”),所以无法在编译期确定,在运行期会 new 一个 StringBuilder 对象, 并由 StringBuilder 的 append 方法连接并调用其 toString 方法返回一个新的 “kvill” 对象。此外,s3 的情形与 s2 一样,均含有编译期无法确定的元素。因此,以上四个 “kvill” 对象互不相同。StringBuilder 的 toString 为:

  1. public String toString() {
  2. return new String(value, 0, count); // new 的方式创建字符串
  3. }
  • 构造函数 String(String original) 的源码为:

    /**

    • 根据源字符串的底层数组长度与该字符串本身长度是否相等决定是否共用支撑数组
      */
      public String(String original) {
      int size = original.count;
      char[] originalValue = original.value;
      char[] v;
      if (originalValue.length > size) {

      1. int off = original.offset;
      2. v = Arrays.copyOfRange(originalValue, off, off + size); // 创建新数组并赋给 v

      } else {

      1. v = originalValue;

      }

      this.offset = 0;
      this.count = size;
      this.value = v;
      }

由源码可以知道,所创建的对象在大多数情形下会与源字符串 original 共享 char数组 。但是,什么情况下不会共享呢?
  Take a look at substring , and you’ll see how this can happen.

  Take for instance String s1 = “Abcd”; String s2 = s1.substring(3). Here s2.size() is 1, but s2.value.length is 4. This is because s1.value is the same as s2.value. This is done of performance reasons (substring is running in O(1), since it doesn’t need to copy the content of the original String).

  1. // s1 的value为Abcd的数组,offset为 0,count为 4
  2. String s1 = "Abcd";
  3. // s2 的value也为Abcd的数组,offset为 3,count为 1
  4. String s2 = a.substring(3);
  5. // s2.value.length 为 4,而 original.count = size = 1, 即 s2.value.length > size 成立
  6. String c = new String(s2);

Using substring can lead to a memory leak. Say you have a really long String, and you only want to keep a small part of it. If you just use substring, you will actually keep the original string content in memory. Doing String snippet = new String(reallyLongString.substring(x,y)) , prevents you from wasting memory backing a large char array no longer needed.


六、字符串常量池

1、字符串池

字符串的分配,和其它的对象分配一样,耗费高昂的时间与空间代价。JVM为了提高性能和减少内存开销,在实例化字符串字面值的时候进行了一些优化。为了减少在 JVM 中创建的字符串的数量,字符串类维护了一个字符串常量池,每当以字面值形式创建一个字符串时,JVM 会首先检查字符串常量池:如果字符串已经存在池中,就返回池中的实例引用;如果字符串不在池中,就会实例化一个字符串并放到池中。Java 能够进行这样的优化是因为字符串是不可变的,可以不用担心数据冲突进行共享。 例如:

  1. public class Program{
  2. public static void main(String[] args){
  3. String str1 = "Hello";
  4. String str2 = "Hello";
  5. System.out.print(str1 == str2); // true
  6. }
  7. }

一个初始为空的字符串池,它由类 String 私有地维护。当以字面值形式创建一个字符串时,总是先检查字符串池是否含存在该对象,若存在,则直接返回。此外,通过 new 操作符创建的字符串对象不指向字符串池中的任何对象。

2、手动入池

  一个初始为空的字符串池,它由类 String 私有地维护。 当调用 intern 方法时,如果池已经包含一个等于此 String 对象的字符串(用 equals(Object) 方法确定),则返回池中的字符串。否则,将此 String 对象添加到池中,并返回此 String 对象的引用。特别地,手动入池遵循以下规则:

  对于任意两个字符串 s 和 t ,当且仅当 s.equals(t) 为 true 时,s.intern() == t.intern() 才为 true 。   

  1. public class TestString{
  2. public static void main(String args[]){
  3. String str1 = "abc";
  4. String str2 = new String("abc");
  5. String str3 = s2.intern();
  6. System.out.println( str1 == str2 ); // false
  7. System.out.println( str1 == str3 ); // true
  8. }
  9. }

所以,对于 String str1 = “abc”,str1 引用的是 常量池(方法区) 的对象;而 String str2 = new String(“abc”),str2引用的是 堆 中的对象,所以内存地址不一样。但是由于内容一样,所以 str1 和 str3 指向同一对象。

3、实例

  看下面几个场景来深入理解 String。

1) 情景一:字符串常量池

  Java 虚拟机中存在着一个字符串常量池,其中保存着很多 String 对象,并且这些 String 对象可以被共享使用,因此提高了效率。之所以字符串具有字符串常量池,是因为 String 对象是不可变的,因此可以被共享。字符串常量池由 String 类维护,我们可以通过 intern() 方法使字符串池手动入池。

  1. String s1 = "abc";
  2. // 在字符串池创建了一个对象
  3. String s2 = "abc";
  4. // 字符串pool已经存在对象“abc”(共享),所以创建0个对象,累计创建一个对象
  5. System.out.println("s1 == s2 : "+(s1 == s2));
  6. // true 指向同一个对象,
  7. System.out.println("s1.equals(s2) : " + (s1.equals(s2)));
  8. // true 值相等

2) 情景二:关于new String(“…”)

  1. String s3 = new String("abc");
  2. // 创建了两个对象,一个存放在字符串池中,一个存在与堆区中;
  3. // 还有一个对象引用s3存放在栈中
  4. String s4 = new String("abc");
  5. // 字符串池中已经存在“abc”对象,所以只在堆中创建了一个对象
  6. System.out.println("s3 == s4 : "+(s3==s4));
  7. // false s3和s4栈区的地址不同,指向堆区的不同地址;
  8. System.out.println("s3.equals(s4) : "+(s3.equals(s4)));
  9. // true s3和s4的值相同
  10. System.out.println("s1 == s3 : "+(s1==s3));
  11. // false 存放的地区都不同,一个方法区,一个堆区
  12. System.out.println("s1.equals(s3) : "+(s1.equals(s3)));
  13. // true 值相同

通过上文的叙述,我们知道,通过 new String(“…”) 来创建字符串时,在该构造函数的参数值为字符串字面值的前提下,若该字面值不在字符串常量池中,那么会创建两个对象:一个在字符串常量池中,一个在堆中;否则,只会在堆中创建一个对象。对于不在同一区域的两个对象,二者的内存地址必定不同。

3) 情景三:字符串连接符 “+”

  1. String str2 = "ab"; // 1个对象
  2. String str3 = "cd"; // 1个对象
  3. String str4 = str2 + str3;
  4. String str5 = "abcd";
  5. System.out.println("str4 = str5 : " + (str4 == str5)); // false

我们看这个例子,局部变量 str2,str3 指向字符串常量池中的两个对象。在运行时,第三行代码(str2 + str3)实质上会被分解成五个步骤,分别是:

(1). 调用 String 类的静态方法 String.valueOf() 将 str2 转换为字符串表示;

(2). JVM 在堆中创建一个 StringBuilder对象,同时用 str2 指向转换后的字符串对象进行初始化; 

(3). 调用 StringBuilder 对象的 append 方法完成与 str3 所指向的字符串对象的合并;

(4). 调用 StringBuilder 的 toString() 方法在堆中创建一个 String 对象;

(5). 将刚刚生成的 String 对象的堆地址存赋给局部变量引用 str4。

  而引用 str5 指向的是字符串常量池中字面值 ”abcd” 所对应的字符串对象。由上面的内容我们可以知道,引用 str4 和 str5 指向的对象的地址必定不一样。这时,内存中实际上会存在五个字符串对象: 三个在字符串常量池中的 String 对象、一个在堆中的 String 对象和一个在堆中的 StringBuilder 对象。

4) 情景四:字符串的编译期优化

  1. String str1 = "ab" + "cd"; // 1个对象
  2. String str11 = "abcd";
  3. System.out.println("str1 = str11 : "+ (str1 == str11)); // true
  4. final String str8 = "cd";
  5. String str9 = "ab" + str8;
  6. String str89 = "abcd";
  7. System.out.println("str9 = str89 : "+ (str9 == str89)); // true
  8. // str8为常量变量,编译期会被优化
  9. String str6 = "b";
  10. String str7 = "a" + str6;
  11. String str67 = "ab";
  12. System.out.println("str7 = str67 : "+ (str7 == str67)); // false
  13. // str6为变量,在运行期才会被解析。

Java 编译器对于类似“常量 + 字面值”的组合,其值在编译的时候就能够被确定了。在这里,str1 和 str9 的值在编译时就可以被确定,因此它们分别等价于: String str1 = “abcd”; 和 String str9 = “abcd”;

Java 编译器对于含有 “String引用” 的组合,则在运行期会产生新的对象 (通过调用 StringBuilder 类的 toString() 方法),因此这个对象存储在堆中。

4、小结

1、使用字面值形式创建的字符串与通过 new 创建的字符串一定是不同的,因为二者的存储位置不同:前者在方法区,后者在堆;

2、我们在使用诸如 String str = “abc”;的格式创建字符串对象时,总是想当然地认为,我们创建了 String 类的对象 str。但是事实上, 对象可能并没有被创建。唯一可以肯定的是,指向 String 对象 的引用被创建了。至于这个引用到底是否指向了一个新的对象,必须根据上下文来考虑;

3、字符串常量池的理念是 《享元模式》

4、Java 编译器对 “常量+字面值” 的组合 是当成常量表达式直接求值来优化的;对于含有“String引用”的组合,其在编译期不能被确定,会在运行期创建新对象。


七、三大字符串类:String、StringBuilder 和 StringBuffer

1. String 与 StringBuilder

简要的说, String 类型 和 StringBuilder 类型的主要性能区别在于 String 是不可变的对象。 事实上,在对 String 类型进行“改变”时,实质上等同于生成了一个新的 String 对象,然后将指针指向新的 String 对象。由于频繁的生成对象会对系统性能产生影响,特别是当内存中没有引用指向的对象多了以后,JVM 的垃圾回收器就会开始工作,继而会影响到程序的执行效率。所以,对于经常改变内容的字符串,最好不要声明为 String 类型。但如果我们使用的是 StringBuilder 类,那么情形就不一样了。因为,我们的每次修改都是针对 StringBuilder 对象本身的,而不会像对 String 操作那样去生成新的对象并重新给变量引用赋值。所以,在一般情况下,推荐使用 StringBuilder ,特别是字符串对象经常改变的情况下。

在某些特别情况下,String 对象的字符串拼接可以直接被 JVM 在编译期确定下来,这时,StringBuilder 在速度上就不占任何优势了。

因此,在绝大部分情况下, 在效率方面:StringBuilder > String。

2. StringBuffer 与 StringBuilder

  首先需要明确的是,StringBuffer 始于 JDK 1.0,而 StringBuilder 始于 JDK 5.0;此外,从 JDK 1.5 开始,对含有字符串变量 (非字符串字面值) 的连接操作(+),JVM 内部是采用 StringBuilder 来实现的,而在这之前,这个操作是采用 StringBuffer 实现的。

  JDK的实现中 StringBuffer 与 StringBuilder 都继承自 AbstractStringBuilder。AbstractStringBuilder的实现原理为:AbstractStringBuilder 中采用一个 char 数组 来保存需要 append 的字符串,char数组有一个初始大小,当 append 的字符串长度超过当前 char 数组容量时,则对 char 数组进行动态扩展,即重新申请一段更大的内存空间,然后将当前 char 数组拷贝到新的位置,因为重新分配内存并拷贝的开销比较大,所以每次重新申请内存空间都是采用申请大于当前需要的内存空间的方式,这里是 2 倍。

  StringBuffer 和 StringBuilder 都是可变的字符序列,但是二者最大的一个不同点是:StringBuffer 是线程安全的,而 StringBuilder 则不是。StringBuilder 提供的 API 与 StringBuffer 的 API 是完全兼容的,即,StringBuffer 与 StringBuilder 中的方法和功能完全是等价的,但是后者一般要比前者快。因此,可以这么说,StringBuilder 的提出就是为了在单线程环境下替换 StringBuffer 。

  在单线程环境下,优先使用 StringBuilder。


3.实例

1). 编译时优化与字符串连接符的本质

  我们先来看下面这个例子:

  1. public class Test2 {
  2. public static void main(String[] args) {
  3. String s = "a" + "b" + "c";
  4. String s1 = "a";
  5. String s2 = "b";
  6. String s3 = "c";
  7. String s4 = s1 + s2 + s3;
  8. System.out.println(s);
  9. System.out.println(s4);
  10. }
  11. }

由上面的叙述,我们可以知道,变量 s 的创建等价于 String s = “abc”; 而变量 s4 的创建相当于:

  1. StringBuilder temp = new StringBuilder(s1);
  2. temp.append(s2).append(s3);
  3. String s4 = temp.toString();

但事实上,是不是这样子呢?我们将其反编译一下,来看看 Java 编译器究竟做了什么:

  1. // 将上述 Test2 的 class 文件反编译
  2. public class Test2
  3. {
  4. public Test2(){}
  5. public static void main(String args[])
  6. {
  7. String s = "abc"; // 编译期优化
  8. String s1 = "a";
  9. String s2 = "b";
  10. String s3 = "c";
  11. // 底层使用 StringBuilder 进行字符串的拼接
  12. String s4 = (new StringBuilder(String.valueOf(s1))).append(s2).append(s3).toString();
  13. System.out.println(s);
  14. System.out.println(s4);
  15. }
  16. }

根据上面的反编译结果,很好的印证了我们在第六节中提出的字符串连接符的本质。

2). 另一个例子:字符串连接符的本质

  由上面的分析结果,我们不难推断出 String 采用连接运算符(+)效率低下原因分析,形如这样的代码:

  1. public class Test {
  2. public static void main(String args[]) {
  3. String s = null;
  4. for(int i = 0; i < 100; i++) {
  5. s += "a";
  6. }
  7. }
  8. }

会被编译器编译为:

  1. public class Test{
  2. public Test(){
  3. }
  4. public static void main(String args[]){
  5. String s = null;
  6. for (int i = 0; i < 100; i++)
  7. s = (new StringBuilder(String.valueOf(s))).append("a").toString();
  8. }
  9. }

也就是说,每做一次 字符串连接操作 “+” 就产生一个 StringBuilder 对象,然后 append 后就扔掉。下次循环再到达时,再重新 new 一个 StringBuilder 对象,然后 append 字符串,如此循环直至结束。事实上,如果我们直接采用 StringBuilder 对象进行 append 的话,我们可以节省 N - 1 次创建和销毁对象的时间。所以,对于在循环中要进行字符串连接的应用,一般都是用 StringBulider 对象来进行 append 操作。


八、字符串与正则表达式:匹配、替换和验证

  • 正则表达式

用一个字符串来描述一个特征,然后去验证另一个字符串是否符合这个特征。使用正则表达式,我们能够以编程的方式,构造复杂的文本模式,并对输入的字符串进行搜索。Java 内置了对正则表达式的支持,其相关的类库在 java.util.regex 包下,感兴趣的读者可以去查看相应的 API 和 JDK 源码。在使用过程中,有两点需要注意以下:

1、Java 转义与正则表达式转义

  要想匹配某些特殊字符,比如 “\”,需要进行两次转义,即 Java 转义与正则表达式转义。对于下面的例子,需要注意的是,split() 函数的参数必须是“正则表达式”字符串。

  1. public class Test {
  2. public static void main(String[] args) {
  3. String a = "a\\b";
  4. System.out.println(a.split("\\\\")[0]);
  5. System.out.println(a.split("\\\\")[1]);
  6. }
  7. }
  8. /*
  9. Output:
  10. a
  11. b
  12. */

2、使用 Pattern 与 Matcher 构造功能强大的正则表达式对象

  Pattern 与 Matcher 的组合就是 Java 对正则表达式的主要内置支持,如下:

  1. public class Test {
  2. public static void main(String[] args) {
  3. Pattern pattern = Pattern.compile(".\\\\.");
  4. Matcher matcher = pattern.matcher("a\\b");
  5. System.out.println(matcher.matches());
  6. System.out.println(matcher.group());
  7. }
  8. }
  9. /* Output:
  10. true
  11. a\b
  12. */

九、String 与 (深)克隆

1、克隆的定义与意义

  顾名思义,克隆就是制造一个对象的副本。一般地,根据所要克隆的对象的成员变量中是否含有引用类型,可以将克隆分为两种:浅克隆(Shallow Clone) 和 深克隆(Deep Clone),默认情况下使用 Object 中的 clone 方法进行克隆就是浅克隆,即完成对象域对域的拷贝。

(1). Object 中的 clone() 方法

watermark_type_ZmFuZ3poZW5naGVpdGk_shadow_10_text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3Bjd2wxMjA2_size_16_color_FFFFFF_t_70 5

在使用 clone()方法时,若该类未实现 Cloneable 接口,则抛出 java.lang.CloneNotSupportedException 异常。下面我们以 Employee 这个例子进行说明:

  1. public class Employee {
  2. private String name;
  3. private double salary;
  4. private Date hireDay;
  5. ...
  6. public static void main(String[] args) throws CloneNotSupportedException {
  7. Employee employee = new Employee();
  8. employee.clone();
  9. System.out.println("克隆完成...");
  10. }
  11. }
  12. /* Output:
  13. ~Exception in thread "main" java.lang.CloneNotSupportedException: P1_1.Employee
  14. */

(2). Cloneable 接口

  Cloneable 接口是一个标识性接口,即该接口不包含任何方法(甚至没有clone()方法),但是如果一个类想合法的进行克隆,那么就必须实现这个接口。下面我们看JDK对它的描述:

1、A class implements the Cloneable interface to indicate to the java.lang.Object.clone() method that it is legal for that method to make a field-for-field copy of instances of that class.

2、Invoking Object’s clone method on an instance that does not implement the Cloneable interface results in the exception CloneNotSupportedException being thrown.

3、By convention, classes that implement this interface should override Object.clone (which is protected) with a public method.

4、Note that this interface does not contain the clone() method. Therefore, it is not possible to clone an object merely by virtue of the fact that it implements this interface. Even if the clone method is invoked reflectively, there is no guarantee that it will succeed.

  1. /**
  2. * @author unascribed
  3. * @see java.lang.CloneNotSupportedException
  4. * @see java.lang.Object#clone()
  5. * @since JDK1.0
  6. */
  7. public interface Cloneable {
  8. }

2、Clone & Copy

  假设现在有一个 Employee 对象,Employee tobby = new Employee(“CMTobby”,5000),通常, 我们会有这样的赋值Employee tom = tobby,这个时候只是简单了 copy 了一下 reference,tom 和 tobby 都指向内存中同一个object,这样 tom 或者 tobby 对对象的修改都会影响到对方。打个比方,如果我们通过 tom.raiseSalary() 方法改变了 salary 域的值,那么 tobby 通过 getSalary() 方法得到的就是修改之后的 salary 域的值,显然这不是我们愿意看到的。如果我们希望得到 tobby 所指向的对象的一个精确拷贝,同时两者互不影响,那么我们就可以使用 Clone 来满足我们的需求。Employee cindy = tobby.clone(),这时会生成一个新的 Employee 对象,并且和 tobby 具有相同的属性值和方法。

3、Shallow Clone & Deep Clone

  Clone 是如何完成的呢?Object 中的 clone() 方法在对某个对象实施克隆时对其是一无所知的,它仅仅是简单地执行域对域的 copy,这就是 Shallow Clone。这样,问题就来了,以 Employee 为例,它里面有一个域 hireDay 不是基本类型的变量,而是一个 reference 变量,经过 Clone 之后克隆类只会产生一个新的 Date 类型的引用,它和原始引用都指向同一个 Date 对象,这样克隆类就和原始类共享了一部分信息,显然这种情况不是我们愿意看到的,过程下图所示:

watermark_type_ZmFuZ3poZW5naGVpdGk_shadow_10_text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3Bjd2wxMjA2_size_16_color_FFFFFF_t_70 6

这个时候,我们就需要进行 Deep Clone 了,以便对那些引用类型的域进行特殊的处理,例如本例中的 hireDay。我们可以重新定义 clone 方法,对 hireDay 做特殊处理,如下代码所示:

  1. class Employee implements Cloneable {
  2. private String name;
  3. private int id;
  4. private Date hireDay;
  5. ...
  6. @Override
  7. public Object clone() throws CloneNotSupportedException {
  8. Employee cloned = (Employee) super.clone();
  9. // Date 支持克隆且重写了clone()方法,Date 的定义是:
  10. // public class Date implements java.io.Serializable, Cloneable, Comparable<Date>
  11. cloned.hireDay = (Date) hireDay.clone() ;
  12. return cloned;
  13. }
  14. }

因此,Object 在对某个对象实施 Clone 时,对其是一无所知的,它仅仅是简单执行域对域的 Copy。 其中,对八种基本类型的克隆是没有问题的,但当对一个引用类型进行克隆时,只是克隆了它的引用。因此,克隆对象和原始对象共享了同一个对象成员变量,故而提出了深克隆 : 在对整个对象浅克隆后,还需对其引用变量进行克隆,并将其更新到浅克隆对象中去。

4、一个克隆的示例

  在这里,我们通过一个简单的例子来说明克隆在 Java 中的使用,如下所示:

  • Employee 类

    package com.zju.clone;

    import java.util.Date;

    public class Employee implements Cloneable {

    1. private String name;
    2. private double salary;
    3. private Date hireDay;
    4. public Employee(String name, double salary, Date hireDay) {
    5. super();
    6. this.name = name;
    7. this.salary = salary;
    8. this.hireDay = hireDay;
    9. }
    10. public String getName() {
    11. return name;
    12. }
    13. public void setName(String name) {
    14. this.name = name;
    15. }
    16. public double getSalary() {
    17. return salary;
    18. }
    19. public void setSalary(double salary) {
    20. this.salary = salary;
    21. }
    22. public Date getHireDay() {
    23. return hireDay;
    24. }
    25. public void setHireDay(Date hireDay) {
    26. this.hireDay = hireDay;
    27. }
    28. @Override
    29. public int hashCode(){
    30. final int prime = 31;
    31. int result = 1;
    32. result = prime * result + ((hireDay == null) ? 0 : hireDay.hashCode());
    33. result = prime * result + ((name == null) ? 0 : name.hashCode());
    34. long temp;
    35. temp = Double.doubleToLongBits(salary);
    36. result = prime * result + (int)(temp ^ (temp >>> 32));
    37. return result;
    38. }
    39. @Override
    40. public boolean equals(Object obj){
    41. if(this == obj){
    42. return true;
    43. }
    44. if(obj == null){
    45. return false;
    46. }
    47. if(getClass() != obj.getClass()){
    48. return false;
    49. }
    50. Employee other = (Employee) obj;
    51. // 判断hireDay属性是否相等
    52. if(hireDay == null){
    53. if(other.hireDay != null){
    54. return false;
    55. }
    56. }else if(!hireDay.equals(other.hireDay)){
    57. return false;
    58. }
    59. // 判断name属性是否相等
    60. if(name == null){
    61. if(other.name != null){
    62. return false;
    63. }
    64. }else if(!name.equals(other.name)){
    65. return false;
    66. }
    67. if(Double.doubleToLongBits(salary) != Double.doubleToLongBits(other.salary)){
    68. return false;
    69. }
    70. return true;
    71. }
    72. @Override
    73. public String toString() {
    74. return name + " : " + String.valueOf(salary) + " : " + hireDay.toString();
    75. }

    }

  • Manager 类

    package com.zju.clone;

    import java.util.Date;

    public class Manger extends Employee implements Cloneable {

    1. private String edu;
    2. public Manger(String name, double salary, Date hireDay, String edu) {
    3. super(name, salary, hireDay);
    4. this.edu = edu;
    5. }
    6. public String getEdu(){
    7. return edu;
    8. }
    9. public void setEdu(String edu){
    10. this.edu = edu;
    11. }
    12. @Override
    13. public String toString(){
    14. return this.getName() + ":" + this.getSalary() + ":" + this.getHireDay() + ":" + this.getEdu();
    15. }
    16. @Override
    17. public int hashCode(){
    18. final int prime = 31;
    19. int result = super.hashCode();
    20. result = prime * result + ((edu == null) ? 0 : edu.hashCode());
    21. return result;
    22. }
    23. @Override
    24. public boolean equals(Object obj) {
    25. if (this == obj)
    26. return true;
    27. if (!super.equals(obj))
    28. return false;
    29. if (getClass() != obj.getClass())
    30. return false;
    31. Manger other = (Manger) obj;
    32. if (edu == null) {
    33. if (other.edu != null)
    34. return false;
    35. } else if (!edu.equals(other.edu))
    36. return false;
    37. return true;
    38. }
    39. // 测试
    40. public static void main(String[] args) throws CloneNotSupportedException {
    41. Manger manger = new Manger("Rico", 20000.0, new Date(), "NEU");
    42. // 输出manger
    43. System.out.println("Manger对象 = " + manger.toString());
    44. Manger clonedManger = (Manger) manger.clone();
    45. // 输出克隆的manger
    46. System.out.println("Manger对象的克隆对象 = " + clonedManger.toString());
    47. System.out.println("Manger对象和其克隆对象是否相等:" + manger.equals(clonedManger) + "\r\n");
    48. // 修改、输出manger
    49. manger.setEdu("TJU");
    50. System.out.println("修改后的Manger对象 = " + manger.toString());
    51. // 再次输出manger
    52. System.out.println("原克隆对象= " + clonedManger.toString());
    53. System.out.println("修改后的Manger对象和原克隆对象是否相等:" + manger.equals(clonedManger));
    54. }

    }

  • 运行结果

watermark_type_ZmFuZ3poZW5naGVpdGk_shadow_10_text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3Bjd2wxMjA2_size_16_color_FFFFFF_t_70 7

5、Clone()方法的保护机制

  在 Object中clone() 是被申明为 protected 的,这样做是有一定的道理的。以 Employee 类为例,如果我们在 Employee 中重写了protected Object clone()方法,就大大限制了可以“克隆” Employee 对象的范围,即可以保证只有在和 Employee 类在同一包中类及 Employee 类的子类里面才能“克隆” Employee 对象。进一步地,如果我们没有在 Employee 类重写 clone() 方法,则只有 Employee 类及其子类才能够“克隆” Employee 对象。

  这里面涉及到一个大家可能都会忽略的一个知识点,那就是关于 protected 的用法。实际上,很多的有关介绍 Java 语言的书籍,都对 protected 介绍的比较的简单,就是:被 protected 修饰的成员或方法对于本包和其子类可见。这种说法有点太过含糊,常常会对大家造成误解。以此为契机,笔者专门在一篇独立博文《Java 访问权限控制:你真的了解 protected 关键字吗?》中介绍了 Java 中的访问权限控制,特别就 protected 关键字的使用和内涵做了清晰到位的说明。

6、注意事项

  Clone()方法的使用比较简单,注意如下几点即可:

1、什么时候使用 shallow Clone,什么时候使用deep Clone?

  这个主要看具体对象的域是什么性质的,基本类型还是引用类型。

2、调用 Clone() 方法的对象所属的类(Class)必须实现 Clonable 接口,否则在调用Clone方法的时候会抛出CloneNotSupportedException;

3、所有数组对象都实现了 Clonable 接口,默认支持克隆;

4、如果我们实现了 Clonable 接口,但没有重写 Object 类的 clone 方法,那么执行域对域的拷贝;

5、明白 String 在克隆中的特殊性:

  String 在克隆时只是克隆了它的引用。

  奇怪的是,在修改克隆后的 String 对象时,其原来的对象并未改变。原因是:String是在内存中不可以被改变的对象。虽然在克隆时,源对象和克隆对象都指向了同一个 String 对象,但当其中一个对象修改这个 String 对象的时候,会新分配一块内存用来保存修改后的 String 对象并将其引用指向新的 String 对象,而原来的 Strin g对象因为还存在指向它的引用,所以不会被回收。这样,对于 String 而言,虽然是复制的引用,但是当修改值的时候,并不会改变被复制对象的值。所以在使用克隆时,我们可以将 String 类型 视为与基本类型,只需浅克隆即可。


十、String 总结

1、使用字面值形式创建字符串时,不一定会创建对象,但其引用一定指向位于字符串常量池的某个对象;

2、使用 new String(“…”) 方式创建字符串时,一定会创建对象,甚至可能会同时创建两个对象(一个位于字符串常量池中,一个位于堆中);

3、String 对象是不可变的,对 String 对象的任何改变都会导致一个新的 String 对象的产生,而不会影响到原 String 对象;

4、StringBuilder 与 StringBuffer 具有共同的父类,具有相同的API,分别适用于单线程和多线程环境下。特别地,在单线程环境下,StringBuilder 是 StringBuffer 的替代品,前者效率相对较高;

5、字符串比较时用的什么方法,内部实现如何?

  使用 equals 方法 : 先比较引用是否相同(是否是同一对象),再检查是否为同一类型(str instanceof String), 最后比较内容是否一致(String 的各个成员变量的值或内容是否相同)。这也同样适用于诸如 Integer 等的八种包装器类。


全文完!十分感谢原文博主!!!

发表评论

表情:
评论列表 (有 0 条评论,253人围观)

还没有评论,来说两句吧...

相关阅读