本文部分内容节选自Java Guide, 地址: https://javaguide.cn/java/basis/java-basic-questions-02.html
🚀 基础(上) → 🚀 基础(中) → 🚀基础(下) → 🤩集合(上) → 🤩集合(下) → 🤗JVM专题1 → 🤗JVM专题2 → 🤗JVM专题3 → 🤗JVM专题4 →😋JUC专题1 → 😋JUC专题2
面向对象基础
对象相等和引用相等的区别
-
对象相等一般比较的是存储在内存中的内容是否相等
-
引用相等一般比较的是内存地址是否相等
请看代码示例
String str1 = "hello";
String str2 = new String("hello");
String str3 = "hello";
// '=='比较的是引用
System.out.println(str1 == str2);
System.out.println(str1 == str3);
// 'equals()'比较的是对象
System.out.println(str1.equals(str2));
System.out.println(str1.equals(str3));
输出结果如下
false
true
true
true
从上面的代码输出结果可以看出:
str1
和str2
不相等, 而str1
和str3
相等. 这是因为==
运算符比较的是字符串的引用是否相等str1
、str2
、str3
三者的内容都相等. 这是因为equals
方法比较的是字符串的内容, 即使这些字符串的对象引用不同, 只要它们的内容相等, 就认为它们是相等的
如果一个类没有声明构造方法, 该程序能正确运行吗?
答案是可以的
假如一个类没有声明构造方法, 它也会有一个默认的不带参数的构造方法. 但是请注意, 如果我们声明了一个含参数的构造方法, 那么就不会自动生成默认不带参数构造方法了
构造函数有哪些特点? 是否能被override?
特点如下:
- 名字和类名相同
- 没有返回值, 但这不意味着可以用
void
声明构造函数, 构造函数不能用void
声明 - 不需要手动调用构造函数, 在生成类的对象的时候会自动调用
构造函数不能被override (重写), 但是可以被overload (重载)
面向对象三大特征?
封装
封装是指把一个对象的状态信息(也就是属性)隐藏在对象内部,不允许外部对象直接访问对象的内部信息。但是可以提供一些可以被外界访问的方法来操作属性。就好像我们看不到挂在墙上的空调的内部的零件信息(也就是属性),但是可以通过遥控器(方法)来控制空调。如果属性不想被外界访问,我们大可不必提供方法给外界访问。但是如果一个类没有提供给外界访问的方法,那么这个类也没有什么意义了。就好像如果没有空调遥控器,那么我们就无法操控空凋制冷,空调本身就没有意义了
public class Student {
private int id;//id属性私有化
private String name;//name属性私有化
//获取id的方法
public int getId() {
return id;
}
//设置id的方法
public void setId(int id) {
this.id = id;
}
//获取name的方法
public String getName() {
return name;
}
//设置name的方法
public void setName(String name) {
this.name = name;
}
}
继承
不同类型的对象,相互之间经常有一定数量的共同点。例如,小明同学、小红同学、小李同学,都共享学生的特性(班级、学号等)。同时,每一个对象还定义了额外的特性使得他们与众不同。例如小明的数学比较好,小红的性格惹人喜爱;小李的力气比较大。继承是使用已存在的类的定义作为基础建立新类的技术,新类的定义可以增加新的数据或新的功能,也可以用父类的功能,但不能选择性地继承父类。通过使用继承,可以快速地创建新的类,可以提高代码的重用,程序的可维护性,节省大量创建新类的时间 ,提高我们的开发效率。
- 子类拥有父类对象所有的属性和方法(包括私有的), 但是父类的私有属性和私有方法子类不能访问, 只是拥有而已
- 子类可以拥有自己的属性和方法
- 子类可以用自己的方式实现父类的方法
多态
多态, 顾名思义就是一个对象具有多种状态, 具体表现为父类的引用指向子类的实例
多态的特点
- 对象类型和引用类型之间具有继承/实现的关系
- 引用类型变量发出的方法调用的到底是哪个类中的方法, 必须在程序运行期间才能确定
- 多态不能调用"只在子类存在但父类不存在"的方法
- 如果子类重写了父类的方法, 真正执行的是子类覆盖的方法, 如果子类没有重写父类方法, 那么执行的是父类的方法
接口和抽象类有什么共同点和区别?
共同点
- 都不能被实例化
- 都可以包含抽象方法
- 都可以有默认实现的方法( Java 8 可以用
default
关键字在接口中定义默认方法)
区别
- 接口主要是对类的行为进行约束, 实现了某个接口就有了对应的行为, 抽象类主要用于代码复用, 强调的是所属关系
- 一个类只能继承一个类, 但是可以实现多个接口
- 接口中的成员变量只能是
public static final
类型的, 不能被修改且必须有初始值, 而抽象类的成员变量默认default, 可在子类中重新定义, 也可被重新赋值
深拷贝和浅拷贝了解吗? 什么是引用拷贝?
-
浅拷贝 : 浅拷贝会在堆上创建一个新对象(区别于引用拷贝的一点), 不过如果原对象内部的属性是引用类型的话, 浅拷贝会直接复制内部对象的引用地址, 也就是说拷贝对象和原对象共用同一个内部对象
-
深拷贝 : 深拷贝会完全复制整个对象, 包括这个对象所包含的内部对象
-
引用拷贝 : 两个不同的引用指向同一个对象
Object
Object类的常见方法有哪些?
Object类是一种特殊的类, 是所有类的父类, 主要提供以下方法
/**
* 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() 的区别
==
对于引用类型和基本类型的作用是不同的
- 对于引用类型,
==
比较的是内存地址 - 对于基本类型,
==
比较的是值
因为 Java 只有值传递, 所以对于 == 来说, 不管是比较基本数据类型还是引用数据类型的变量, 其本质比较的都是值, 只不过引用类型变量存的值是对象的地址
equals()
不能用于判断基本数据类型的变量, 只能用于判断两个对象是否相等. equals()
方法存在于 Object
类中, 而 Object
类是所有类的直接或间接父类, 因此所有的类都有 equals()
方法
equals()
方法存在两种使用情况:
- 类没有重写
equals()
方法 : 通过equals()
比较该类的两个对象时,等价于通过“==”比较这两个对象,使用的默认是Object
类equals()
方法。 - 类重写了
equals()
方法 : 一般我们都重写equals()
方法来比较两个对象中的属性是否相等;若它们的属性相等,则返回 true(即,认为这两个对象相等)。
hashCode() 有什么用?
hashCode()
的作用是获取哈希码(int
整数),也称为散列码。这个哈希码的作用是确定该对象在哈希表中的索引位置。
hashCode()
定义在 Object
类中, 这就意味着 Java 中的任何类都包含有 hashCode()
函数
散列表存储的是键值对(key-value), 它的特点是:能根据“键”快速的检索出对应的“值”. 这其中就利用到了散列码!(可以快速找到所需要的对象)
为什么要有hashCode()?
在一些容器中(例如 HashMap
, HashSet
), 有了 hashCode()
之后, 判断元素是否在容器中的效率会更高
先用 hashCode()
判断, 如果一个 hashCode
对应有多个对象, 它会继续使用 equals()
来判断是否真的相同
为什么不只提供hashCode()
方法?
因为可能存在哈希碰撞 (什么是哈希碰撞? 这是数据结构与算法的内容, 自己回去补数据结构与算法吧 ) 的情况, 即多个不同的对象对应到同一个哈希值, 为了避免不同对象有相同哈希值导致误判为同一对象, 必须再提供一个 equals()
方法
简单来说就是
- 如果两个对象的哈希值相等, 那么这两个对象不一定相等, 需要用
equals()
方法再做一次判断 - 如果两个对象哈希值不等, 那么这两个对象一定不相等
- 如果两个对象的哈希值相等且
equals()
返回true
, 这两个对象是相等的
为什么重写 equals() 方法时必须重写 hashCode() 方法?
因为两个相等的对象的哈希值必须是相等的, 也就是说如果equals()
返回true, 说明这两个对象的哈希值也必须是相等的, 所以必须重写 hashCode()
如果重写了 equals()
方法却没重写 hashCode()
方法, 那么就会出现一种情况: equals()
返回true(两个对象应该是相等的) , 但是 hashCode()
的返回值也就是哈希值不一定相等
如果重写了 equals()
但是没有重写 hashCode()
, 却使用了 HashMap
呢? 假设重写了 equals()
方法, 没有重写 hashCode()
方法, 那么会出现这样的情况: 两个对象在 equals()
方法返回了true, 但是它们的哈希值不同, 这就有可能两个相同的对象重复存储在了一个 HashMap
里面 (因为它们的哈希值不同, 存储的位置是不一样的)
String
String, StringBuffer, StringBuilder的区别?
可变性
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为什么是不可变的
- 保存字符串的数组被
final
修饰且私有的, 并且String
类没有提供修改字符串的方法 String
类被final
修饰所以它不能被继承, 进而避免子类破坏String
不可变的情况
字符串拼接用 “+” 还是用 StringBuilder?
其实都可以
在 String 中, “+” 和 “+=” 两个运算符其实是通过 StringBuilder
调用 append()
方法实现的, 拼接完成之后再调用一个 toString()
方法返回一个 String
. 学过C++的应该会意识到这里出现了重载运算符, 没错这两个运算符也是 Java 唯二的重载运算符
在 JDK9 之前, 如果在循环内使用 “+” 进行字符串拼接, 会导致产生大量的 StringBuilder
. 所以在 JDK9 之前, 建议用 StringBuilder
进行拼接
当然, 现在这个问题已经解决了. JDK9 中, 字符串相加改为用动态方法 makeConcatWithConstants()
来实现, 这样就不会导致产生大量的 StringBuilder
了
String中的 equals() 和 Object中的 equals() 有什么区别?
String
中的 equals
方法是被重写过的, 比较的是 String 字符串的值是否相等. Object
的 equals
方法是比较的对象的内存地址
String s1 = new String(“abc”); 这句话创建了几个字符串?
1个或者2个
- 假如字符串常量池中没有 “abc” 的引用, 那么就会产生两个字符串对象, 其中一个存在字符串常量池
- 假如字符串常量池中有 “abc” 的引用, 那么只会在堆中创建一个字符串对象
String的intern方法有什么作用?
String.intern()
是一个 native(本地)方法, 其作用是将指定的字符串对象的引用保存在字符串常量池中, 可以简单分为两种情况:
- 如果字符串常量池中保存了对应的字符串对象的引用, 就直接返回该引用
- 如果字符串常量池中没有保存了对应的字符串对象的引用, 那就在常量池中创建一个指向该字符串对象的引用并返回
// 在堆中创建字符串对象”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
String 类型的变量和常量做 “+” 运算时发生了什么?
对于编译期可以确定值的字符串, 也就是常量字符串 , jvm 会将其存入字符串常量池. 并且, 字符串常量拼接得到的字符串常量在编译阶段就已经被存放字符串常量池, 这个得益于编译器的优化
在编译过程中, Javac 编译器(下文中统称为编译器)会进行一个叫做 常量折叠(Constant Folding) 的代码优化
常量折叠会把常量表达式的值求出来作为常量嵌在最终生成的代码中, 这是 Javac 编译器会对源代码做的极少量优化措施之一(代码优化几乎都在即时编译器中进行)
也就是说, 对于 String str3 = "str" + "ing"
, 编译器会优化为 String str3 = "string"
只有编译器在程序编译器可以确定值的常量才可以进行常量折叠
- 基本数据类型以及字符串常量
final
修饰的基本数据类型和字符串变量- 字符串通过 “+” 拼接得到的字符串, 基本数据类型之间算术运算, 基本数据类型的位运算
引用的值在程序编译期是无法确定的, 编译器无法对其进行优化
对象引用和“+”的字符串拼接方式, 实际上是通过 StringBuilder
调用 append()
方法实现的, 拼接完成之后调用 toString()
得到一个 String
对象
被 final
关键字修饰之后的 String
会被编译器当做常量来处理, 编译器在程序编译期就可以确定它的值, 其效果就相当于访问常量