目录
1.2 创建一个对象用什么运算符?对象实体与对象引用有何不同?
2.4.2 那为什么不只提供 hashCode() 方法呢?
2.4.3 那为什么两个对象有相同的 hashCode 值,它们也不一定是相等的?
2.5 为什么重写 equals() 时必须重写 hashCode() 方法?
3.1 String、StringBuffer、StringBuilder 的区别?
3.3 字符串拼接用“+” 还是 StringBuilder?
3.4 String#equals() 和 Object#equals() 有何区别?
3.6 String s1 = new String("abc");这句话创建了几个字符串对象?
3.8 String 类型的变量和常量做“+”运算时发生了什么?
面向对象开发的方式更容易维护和迭代升级、易复用、易扩展。
用new来创建一个对象。new 创建对象实例(对象实例在堆内存中),对象引用指向对象实例(对象引用存放在栈内存中)。
Student s = new Student();
new Student()为对象实例,存放在堆内存中。
s为对象引用,存放在栈内存中
构造方法是一种特殊的方法,主要作用是完成对象的初始化工作。
如果一个类没有声明构造方法,也可以执行!因为一个类即使没有声明构造方法也会有默认的不带参数的构造方法。如果我们自己添加了类的构造方法(无论是否有参),Java 就不会添加默认的无参数的构造方法了。
构造方法不能被 override(重写),但是可以 overload(重载),所以你可以看到一个类中有多个构造函数的情况。
封装
封装是指把一个对象的状态信息(也就是属性)隐藏在对象内部,不允许外部对象直接访问对象的内部信息。但是可以提供一些可以被外界访问的方法来操作属性。就好像我们看不到挂在墙上的空调的内部的零件信息(也就是属性),但是可以通过遥控器(方法)来控制空调。
继承
不同类型的对象,相互之间经常有一定数量的共同点。例如,小明同学、小红同学、小李同学,都共享学生的特性(班级、学号等)。同时,每一个对象还定义了额外的特性使得他们与众不同。例如小明的数学比较好,小红的性格惹人喜爱;小李的力气比较大。继承是使用已存在的类的定义作为基础建立新类的技术,新类的定义可以增加新的数据或新的功能,也可以用父类的功能,但不能选择性地继承父类。通过使用继承,可以快速地创建新的类,可以提高代码的重用,程序的可维护性,节省大量创建新类的时间 ,提高我们的开发效率。
关于继承如下 3 点请记住:
多态
一个对象具有多种的状态,具体表现为父类的引用指向子类的实例。
多态的特点:
共同点:
default
关键字在接口中定义默认方法)。区别:
public static final
类型的,不能被修改且必须有初始值,而抽象类的成员变量默认 default,可在子类中被重新定义,也可被重新赋值。这玩意用文字讲不明白,直接上代码
浅拷贝
浅拷贝的示例代码如下,我们这里实现了
Cloneable
接口,并重写了clone()
方法。
clone()
方法的实现很简单,直接调用的是父类Object
的clone()
方法。
- public class Address implements Cloneable{
- private String name;
- // 省略构造函数、Getter&Setter方法
- @Override
- public Address clone() {
- try {
- return (Address) super.clone();
- } catch (CloneNotSupportedException e) {
- throw new AssertionError();
- }
- }
- }
-
- public class Person implements Cloneable {
- private Address address;
- // 省略构造函数、Getter&Setter方法
- @Override
- public Person clone() {
- try {
- Person person = (Person) super.clone();
- return person;
- } catch (CloneNotSupportedException e) {
- throw new AssertionError();
- }
- }
- }
测试:
- Person person1 = new Person(new Address("武汉"));
- Person person1Copy = person1.clone();
- // true
- System.out.println(person1.getAddress() == person1Copy.getAddress());
从输出结构就可以看出, person1
的克隆对象和 person1
使用的仍然是同一个 Address
对象。
深拷贝
这里我们简单对 Person
类的 clone()
方法进行修改,连带着要把 Person
对象内部的 Address
对象一起复制。
- @Override
- public Person clone() {
- try {
- Person person = (Person) super.clone();
- person.setAddress(person.getAddress().clone());
- return person;
- } catch (CloneNotSupportedException e) {
- throw new AssertionError();
- }
- }
测试:
- Person person1 = new Person(new Address("武汉"));
- Person person1Copy = person1.clone();
- // false
- System.out.println(person1.getAddress() == person1Copy.getAddress());
从输出结构就可以看出,显然 person1
的克隆对象和 person1
包含的 Address
对象已经是不同的了。
那什么是引用拷贝呢? 简单来说,引用拷贝就是两个不同的引用指向同一个对象。
我专门画了一张图来描述浅拷贝、深拷贝、引用拷贝:
Object 类是一个特殊的类,是所有类的父类。它主要提供了以下 11 个方法:
- /**
- * native 方法,用于返回当前运行时对象的 Class 对象,使用了 final 关键字修饰,故不允许子类重写。
- */
- public final native Class> getClass()
- /**
- * native 方法,用于返回对象的哈希码,主要使用在哈希表中,比如 JDK 中的HashMap。
- */
- public native int hashCode()
- /**
- * 用于比较 2 个对象的内存地址是否相等,String 类对该方法进行了重写以用于比较字符串的值是否相等。
- */
- public boolean equals(Object obj)
- /**
- * native 方法,用于创建并返回当前对象的一份拷贝。
- */
- protected native Object clone() throws CloneNotSupportedException
- /**
- * 返回类的名字实例的哈希码的 16 进制的字符串。建议 Object 所有的子类都重写这个方法。
- */
- public String toString()
- /**
- * native 方法,并且不能重写。唤醒一个在此对象监视器上等待的线程(监视器相当于就是锁的概念)。如果有多个线程在等待只会任意唤醒一个。
- */
- public final native void notify()
- /**
- * native 方法,并且不能重写。跟 notify 一样,唯一的区别就是会唤醒在此对象监视器上等待的所有线程,而不是一个线程。
- */
- public final native void notifyAll()
- /**
- * native方法,并且不能重写。暂停线程的执行。注意:sleep 方法没有释放锁,而 wait 方法释放了锁 ,timeout 是等待时间。
- */
- public final native void wait(long timeout) throws InterruptedException
- /**
- * 多了 nanos 参数,这个参数表示额外时间(以纳秒为单位,范围是 0-999999)。 所以超时的时间还需要加上 nanos 纳秒。。
- */
- public final void wait(long timeout, int nanos) throws InterruptedException
- /**
- * 跟之前的2个wait方法一样,只不过该方法一直等待,没有超时时间这个概念
- */
- public final void wait() throws InterruptedException
- /**
- * 实例被垃圾回收器回收的时候触发的操作
- */
- protected void finalize() throws Throwable { }
先说==
equals()
不能用于判断基本数据类型的变量,只能用来判断两个对象是否相等。
equals()
方法:通过equals()
比较该类的两个对象时,等价于通过“==”比较这两个对象,使用的默认是 Object
类equals()
方法。equals()
方法:一般我们都重写 equals()
方法来比较两个对象中的属性是否相等;若它们的属性相等,则返回 true(即,认为这两个对象相等)。String
中的 equals
方法是被重写过的,因为 Object
的 equals
方法是比较的对象的内存地址,而 String
的 equals
方法比较的是对象的值。
- public boolean equals(Object anObject) {
- if (this == anObject) {
- return true;
- }
- 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;
- }
hashCode()
的作用是获取哈希码(int
整数),也称为散列码。这个哈希码的作用是确定该对象在哈希表中的索引位置。
hashCode()
定义在 JDK 的 Object
类中,这就意味着 Java 中的任何类都包含有 hashCode()
函数。另外需要注意的是:Object
的 hashCode()
方法是本地方法,也就是用 C 语言或 C++ 实现的。
散列表存储的是键值对(key-value),它的特点是:能根据“键”快速的检索出对应的“值”。这其中就利用到了散列码!(可以快速找到所需要的对象)
我们以“HashSet
如何检查重复”为例子来说明为什么要有 hashCode
?
当你把对象加入
HashSet
时,HashSet
会先计算对象的hashCode
值来判断对象加入的位置,同时也会与其他已经加入的对象的hashCode
值作比较,如果没有相符的hashCode
,HashSet
会假设对象没有重复出现。但是如果发现有相同hashCode
值的对象,这时会调用equals()
方法来检查hashCode
相等的对象是否真的相同。如果两者相同,HashSet
就不会让其加入操作成功。如果不同的话,就会重新散列到其他位置。这样我们就大大减少了equals
的次数,相应就大大提高了执行速度
其实, hashCode()
和 equals()
都是用于比较两个对象是否相等。
这是因为在一些容器(比如 HashMap
、HashSet
)中,有了 hashCode()
之后,判断元素是否在对应容器中的效率会更高(参考添加元素进HashSet
的过程)!
我们在前面也提到了添加元素进HashSet
的过程,如果 HashSet
在对比的时候,同样的 hashCode
有多个对象,它会继续使用 equals()
来判断是否真的相同。也就是说 hashCode
帮助我们大大缩小了查找成本。
hashCode()
方法呢?这是因为两个对象的hashCode
值相等并不代表两个对象就相等。
hashCode
值,它们也不一定是相等的?因为 hashCode()
所使用的哈希算法也许刚好会让多个对象传回相同的哈希值。越糟糕的哈希算法越容易碰撞,但这也与数据值域分布的特性有关(所谓哈希碰撞也就是指的是不同的对象得到相同的 hashCode
)。
总结下来就是:
hashCode
值相等,那这两个对象不一定相等(哈希碰撞)。hashCode
值相等并且equals()
方法也返回 true
,我们才认为这两个对象相等。hashCode
值不相等,我们就可以直接认为这两个对象不相等。因为两个相等的对象的 hashCode
值必须是相等。也就是说如果 equals
方法判断两个对象是相等的,那这两个对象的 hashCode
值也要相等。
如果重写 equals()
时没有重写 hashCode()
方法的话就可能会导致 equals
方法判断是相等的两个对象,hashCode
值却不相等。
可变性
String
是不可变的(后面会详细分析原因)。
StringBuilder
与 StringBuffer
都继承自 AbstractStringBuilder
类,在 AbstractStringBuilder
中也是使用字符数组保存字符串,不过没有使用 final
和 private
关键字修饰,最关键的是这个 AbstractStringBuilder
类还提供了很多修改字符串的方法比如 append
方法。
- abstract class AbstractStringBuilder implements Appendable, CharSequence {
- char[] value;
- 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;
- }
- //...
- }
线程安全性
String
中的对象是不可变的,也就可以理解为常量,线程安全。AbstractStringBuilder
是 StringBuilder
与 StringBuffer
的公共父类,定义了一些字符串的基本操作,如 expandCapacity
、append
、insert
、indexOf
等公共方法。StringBuffer
对方法加了同步锁或者对调用的方法加了同步锁,所以是线程安全的。StringBuilder
并没有对方法进行加同步锁,所以是非线程安全的。
性能
每次对 String
类型进行改变的时候,都会生成一个新的 String
对象,然后将指针指向新的 String
对象。StringBuffer
每次都会对 StringBuffer
对象本身进行操作,而不是生成新的对象并改变对象引用。相同情况下使用 StringBuilder
相比使用 StringBuffer
仅能获得 10%~15% 左右的性能提升,但却要冒多线程不安全的风险。
对于三者使用的总结:
String
StringBuilder
StringBuffer
String
类中使用 private 和 final
关键字修饰字符数组来保存字符串
- public final class String implements java.io.Serializable, Comparable
, CharSequence { - private final char value[];
- //...
- }
我们知道被 final
关键字修饰的类不能被继承,修饰的方法不能被重写,修饰的变量是基本数据类型则值不能改变,修饰的变量是引用类型则不能再指向其他对象。因此,final
关键字修饰的数组保存字符串并不是 String
不可变的根本原因,因为这个数组保存的字符串是可变的(final
修饰引用类型变量的情况)。
String
真正不可变有下面几点原因:
final
修饰且为私有的,并且String
类没有提供/暴露修改这个字符串的方法。String
类被 final
修饰导致其不能被继承,进而避免了子类破坏 String
不可变。在 Java 9 之后,String
、StringBuilder
与 StringBuffer
的实现改用 byte
数组存储字符串。
- public final class String implements java.io.Serializable,Comparable
, CharSequence { - // @Stable 注解表示变量最多被修改一次,称为“稳定的”。
- @Stable
- private final byte[] value;
- }
-
- abstract class AbstractStringBuilder implements Appendable, CharSequence {
- byte[] value;
-
- }
Java 9 为何要将 String
的底层实现由 char[]
改成了 byte[]
?
新版的 String 其实支持两个编码方案:Latin-1 和 UTF-16。如果字符串中包含的汉字没有超过 Latin-1 可表示范围内的字符,那就会使用 Latin-1 作为编码方案。Latin-1 编码方案下,byte
占一个字节(8 位),char
占用 2 个字节(16),byte
相较 char
节省一半的内存空间。
JDK 官方就说了绝大部分字符串对象只包含 Latin-1 可表示的字符。
如果字符串中包含的汉字超过 Latin-1 可表示范围内的字符,byte
和 char
所占用的空间是一样的。
Java 语言本身并不支持运算符重载,“+”和“+=”是专门为 String 类重载过的运算符,也是 Java 中仅有的两个重载过的运算符。
- String str1 = "he";
- String str2 = "llo";
- String str3 = "world";
- String str4 = str1 + str2 + str3;
上面的代码对应的字节码如下:
可以看出,字符串对象通过“+”的字符串拼接方式,实际上是通过 StringBuilder
调用 append()
方法实现的,拼接完成之后调用 toString()
得到一个 String
对象 。
不过,在循环内使用“+”进行字符串的拼接的话,存在比较明显的缺陷:编译器不会创建单个 StringBuilder
以复用,会导致创建过多的 StringBuilder
对象。
- String[] arr = {"he", "llo", "world"};
- String s = "";
- for (int i = 0; i < arr.length; i++) {
- s += arr[i];
- }
- System.out.println(s);
如果直接使用 StringBuilder
对象进行字符串拼接的话,就不会存在这个问题了。
- String[] arr = {"he", "llo", "world"};
- StringBuilder s = new StringBuilder();
- for (String value : arr) {
- s.append(value);
- }
- System.out.println(s);
不过,使用 “+” 进行字符串拼接会产生大量的临时对象的问题在 JDK9 中得到了解决。在 JDK9 当中,字符串相加 “+” 改为了用动态方法 makeConcatWithConstants()
来实现,而不是大量的 StringBuilder
了。这个改进是 JDK9 的 JEP 280open in new window 提出的,这也意味着 JDK 9 之后,你可以放心使用“+” 进行字符串拼接了。关于这部分改进的详细介绍,推荐阅读这篇文章:还在无脑用 StringBuilder?来重温一下字符串拼接吧 。
String
中的 equals
方法是被重写过的,比较的是 String 字符串的值是否相等。 Object
的 equals
方法是比较的对象的内存地址。
字符串常量池 是 JVM 为了提升性能和减少内存消耗针对字符串(String 类)专门开辟的一块区域,主要目的是为了避免字符串的重复创建。
- // 在堆中创建字符串对象”ab“
- // 将字符串对象”ab“的引用保存在字符串常量池中
- String aa = "ab";
- // 直接返回字符串常量池中字符串对象”ab“的引用
- String bb = "ab";
- System.out.println(aa==bb);// true
更多关于字符串常量池的介绍可以看一下 Java 内存区域详解 这篇文章。
会创建 1 或 2 个字符串对象。
1、如果字符串常量池中不存在字符串对象“abc”的引用,那么它将首先在字符串常量池中创建,然后在堆空间中创建,因此将创建总共 2 个字符串对象。
2、如果字符串常量池中已存在字符串对象“abc”的引用,则只会在堆中创建 1 个字符串对象“abc”。
String.intern()
是一个 native(本地)方法,其作用是将指定的字符串对象的引用保存在字符串常量池中,可以简单分为两种情况:
示例代码(JDK 1.8) :
- // 在堆中创建字符串对象”Java“
- // 将字符串对象”Java“的引用保存在字符串常量池中
- String s1 = "Java";
- // 直接返回字符串常量池中字符串对象”Java“对应的引用
- String s2 = s1.intern();
- // 会在堆中在单独创建一个字符串对象
- String s3 = new String("Java");
- // 直接返回字符串常量池中字符串对象”Java“对应的引用
- String s4 = s3.intern();
- // s1 和 s2 指向的是堆中的同一个对象
- System.out.println(s1 == s2); // true
- // s3 和 s4 指向的是堆中不同的对象
- System.out.println(s3 == s4); // false
- // s1 和 s4 指向的是堆中的同一个对象
- System.out.println(s1 == s4); //true
先来看字符串不加 final
关键字拼接的情况(JDK1.8):
- String str1 = "str";
- String str2 = "ing";
- String str3 = "str" + "ing";
- String str4 = str1 + str2;
- String str5 = "string";
- System.out.println(str3 == str4);//false
- System.out.println(str3 == str5);//true
- System.out.println(str4 == str5);//false
注意:比较 String 字符串的值是否相等,可以使用
equals()
方法。String
中的equals
方法是被重写过的。Object
的equals
方法是比较的对象的内存地址,而String
的equals
方法比较的是字符串的值是否相等。如果你使用==
比较两个字符串是否相等的话,IDEA 还是提示你使用equals()
方法替换
对于编译期可以确定值的字符串,也就是常量字符串 ,jvm 会将其存入字符串常量池。并且,字符串常量拼接得到的字符串常量在编译阶段就已经被存放字符串常量池,这个得益于编译器的优化。
在编译过程中,Javac 编译器(下文中统称为编译器)会进行一个叫做 常量折叠(Constant Folding) 的代码优化。《深入理解 Java 虚拟机》中是也有介绍到:
常量折叠会把常量表达式的值求出来作为常量嵌在最终生成的代码中,这是 Javac 编译器会对源代码做的极少量优化措施之一(代码优化几乎都在即时编译器中进行)。
对于 String str3 = "str" + "ing";
编译器会给你优化成 String str3 = "string";
。
并不是所有的常量都会进行折叠,只有编译器在程序编译期就可以确定值的常量才可以:
byte
、boolean
、short
、char
、int
、float
、long
、double
)以及字符串常量。final
修饰的基本数据类型和字符串变量引用的值在程序编译期是无法确定的,编译器无法对其进行优化。
对于String str4 = str1 + str2;
对象引用和“+”的字符串拼接方式,实际上是通过 StringBuilder
调用 append()
方法实现的,拼接完成之后调用 toString()
得到一个 String
对象 。
String str4 = new StringBuilder().append(str1).append(str2).toString();
我们在平时写代码的时候,尽量避免多个字符串对象拼接,因为这样会重新创建对象。如果需要改变字符串的话,可以使用 StringBuilder
或者 StringBuffer
。
不过,字符串使用 final
关键字声明之后,可以让编译器当做常量来处理。
示例代码:
- final String str1 = "str";
- final String str2 = "ing";
- // 下面两个表达式其实是等价的
- String c = "str" + "ing";// 常量池中的对象
- String d = str1 + str2; // 常量池中的对象
- System.out.println(c == d);// true
被 final
关键字修饰之后的 String
会被编译器当做常量来处理,编译器在程序编译期就可以确定它的值,其效果就相当于访问常量。
如果 ,编译器在运行时才能知道其确切值的话,就无法对其优化。
示例代码(str2
在运行时才能确定其值):
- final String str1 = "str";
- final String str2 = getStr();
- String c = "str" + "ing";// 常量池中的对象
- String d = str1 + str2; // 在堆上创建的新的对象
- System.out.println(c == d);// false
- public static String getStr() {
- return "ing";
- }