八股(1)——Java
- 2. Java
- 2.1. Java 基础
- Java 语言有哪些特点?
- Java SE vs Java EE
- JDK vs JRE vs JVM
- 什么是字节码?采用字节码的好处是什么?
- 为什么说 Java 语言“编译与解释并存”?
- Java 语言关键字有哪些?
- 自增自减运算符
- 字符型常量和字符串常量的区别?
- 重载和重写有什么区别?
- 什么是可变长参数?
- 成员变量、实例变量、局部变量、类变量(静态变量)
- 实例变量和静态变量的区别?
- static静态修饰
- 静态方法为什么不能调用非静态成员?
- 静态方法中不能使用this,super关键字
- 什么时候定义静态成员呢?
- 静态方法和实例方法有何不同?
- Java 中的几种基本数据类型了解么?
- 类和对象的关系
- 基本类型和引用类型的区别?
- 各种类型变量存放的位置?
- 基本类型和包装类型的区别?
- 自动装箱与拆箱了解吗?原理是什么?
- 包装类型的缓存机制了解么(缓存池)?
- 为什么浮点数运算的时候会有精度丢失的风险?
- 如何解决浮点数运算的精度丢失问题?
- 超过 long 整型的数据应该如何表示?
- 如果一个类没有声明构造方法,该程序能正确执行吗?
- 构造方法有哪些特点?是否可被 override?
- 面向对象三大特征(封装,继承,多态)
- 接口和抽象类有什么共同点和区别?
- 深拷贝和浅拷贝区别了解吗?什么是引用拷贝?
- Object
- String
主要来自javaguide,加上自己的理解,放这里是方便自己时不时打开看看,后续每一次看的时候应该都会逐渐换成自己最新的理解。很长!!!
2. Java
2.1. Java 基础
Java 语言有哪些特点?
简单易学,面向对象,跨平台,支持多线程,可靠,安全,高效,编译与解释并存
Java SE vs Java EE
Java SE 是 Java 的基础版本,Java EE 是 Java 的高级版本。Java SE 更适合开发桌面应用程序或简单的服务器应用程序,Java EE 更适合开发复杂的企业级应用程序或 Web 应用程序。
JDK vs JRE vs JVM
什么是字节码?采用字节码的好处是什么?
在 Java 中,JVM 可以理解的代码就叫做字节码(即扩展名为 .class
的文件),它不面向任何特定的处理器,只面向虚拟机。
Java 程序从源代码到运行的过程如下图所示:
我们利用JDK(调用JAVA API)开发了属于我们自己的JAVA程序后,通过JDK中的编译程序(javac)将我们的文本java文件编译成JAVA字节码,在JRE上运行这些JAVA字节码,JVM解析这些字节码,映射到CPU指令集或OS的系统调用。
为什么说 Java 语言“编译与解释并存”?
可以将高级编程语言按照程序的执行方式分为两种:
- 编译型 :编译型语言 会通过编译器将源代码一次性翻译成可被该平台执行的机器码。执行速度快,开发效率低。常见的编译性语言有 C、C++、Go、Rust 等等。
- 解释型 :解释型语言会通过解释器一句一句的将代码解释(interpret)为机器代码后再执行。执行速度慢,开发效率高。常见的解释性语言有 Python、JavaScript、PHP 等等。
即时编译: Java 语言既具有编译型语言的特征,也具有解释型语言的特征。因为由 Java 编写的程序需要先经过编译步骤,生成字节码(.class
文件),这种字节码必须由 Java 解释器来解释执行。
Java 语言关键字有哪些?
分类 | 关键字 | ||||||
---|---|---|---|---|---|---|---|
访问控制 | private | protected | public | ||||
类,方法和变量修饰符 | abstract | class | extends | final | implements | interface | native |
new | static | strictfp | synchronized | transient | volatile | enum | |
程序控制 | break | continue | return | do | while | if | else |
for | instanceof | switch | case | default | assert | ||
错误处理 | try | catch | throw | throws | finally | ||
包相关 | import | package | |||||
基本类型 | boolean | byte | char | double | float | int | long |
short | |||||||
变量引用 | super | this | void | ||||
保留字 | goto | const |
Tips:所有的关键字都是小写的,在 IDE 中会以特殊颜色显示。
default
这个关键字很特殊,既属于程序控制,也属于类,方法和变量修饰符,还属于访问控制。
- 在程序控制中,当在
switch
中匹配不到任何情况时,可以使用default
来编写默认匹配的情况。- 在类,方法和变量修饰符中,从 JDK8 开始引入了默认方法,可以使用
default
关键字来定义一个方法的默认实现。- 在访问控制中,如果一个方法前没有任何修饰符,则默认会有一个修饰符
default
,但是这个修饰符加上了就会报错。
⚠️ 注意 :虽然 true
, false
, 和 null
看起来像关键字但实际上他们是字面值,同时也不可以作为标识符来使用。
自增自减运算符
当运算符放在变量之前时(前缀),先自增/减,再赋值;
当运算符放在变量之后时(后缀),先赋值,再自增/减。
例如,当 b = ++a
时,先自增(自己增加 1),再赋值(赋值给 b);当 b = a++
时,先赋值(赋值给 b),再自增(自己增加 1)。也就是,++a 输出的是 a+1 的值,a++输出的是 a 值。用一句口诀就是:“符号在前就先加/减,符号在后就后加/减”。
字符型常量和字符串常量的区别?
形式:
- 字符常量是单引号
'
引起的一个字符,char letter = 'A';
- 字符串常量是双引号
"
引起的 0 个或若干个字符,String greeting = "Hello";
含义:
- 字符常量相当于一个整型值( ASCII 值),可以参加表达式运算,例如,字符 ‘A’ 的 ASCII 值为 65,可以用于算术运算:
int asciiValue = letter; // asciiValue 的值为 65
。- 字符串常量代表一个地址值,即该字符串在内存中的存放位置。字符串在Java中是对象,因此可以调用字符串的方法:
int length = greeting.length(); // length 的值为 5
占内存大小 :- 字符常量只占 2 个字节;
System.out.println("字符型常量占用的字节数为:" + Character.BYTES); // 输出:2
- 字符串常量占若干个字节。
System.out.println("字符串常量占用的字节数为:" + greeting.getBytes().length); // 输出:5
(注意:char
在 Java 中占两个字节)
重载和重写有什么区别?
重载就是同一个类中多个同名方法根据不同的传参来执行不同的逻辑处理。
重写就是子类对父类方法的重新改造,外部样子不能改变,内部逻辑可以改变。
一个是重新载入,一个是重新编写。
区别点 | 重载方法 | 重写方法 |
---|---|---|
发生范围 | 同一个类 | 子类 |
参数列表 | 必须修改 | 一定不能修改 |
返回类型 | 可修改 | 子类方法返回值类型应比父类方法返回值类型更小或相等 |
异常 | 可修改 | 子类方法声明抛出的异常类应比父类方法声明抛出的异常类更小或相等; |
访问修饰符 | 可修改 | 一定不能做更严格的限制(可以降低限制) |
发生阶段 | 编译期 | 运行期 |
方法的重写 要遵循“两同两小一大”:
- “两同”:方法名、形参列表均相同;
- “两小”:子类方法 返回值类型 以及 声明抛出的异常类 应比父类方法的更小或相等;
- “一大”:子类方法的访问权限应比父类方法的更大或相等。
解释:如果子类权限低,那么:- 原本在父类中可访问的方法在子类中变得不可访问,违反封装性;
- 继承父类方法的子类将无法以父类相同的访问级别来访问这些方法,违反继承性。
什么是可变长参数?
允许在调用方法时传入不定长度的参数。可以接受 0 个或者多个参数。
可变参数只能作为函数的最后一个参数,但其前面可以有也可以没有任何其他参数。
public void printNumbers(int a, int... numbers) {
for (int number : numbers) {
System.out.print(number + " ");
}
}
printNumbers(1, 2, 3, 4, 5); // 输出:1 2 3 4 5
遇到方法重载的情况怎么办呢?会优先匹配固定参数(√,匹配度更高)还是可变参数的方法呢?
Java 的可变参数编译后实际会被转换成一个数组。
成员变量、实例变量、局部变量、类变量(静态变量)
link
一般也称成员变量为全局变量。
public class CSDNTest {
//类变量,有默认值(0、false、null),可以直接在类的方法里使用(如下main方法)
public static int judge = 2000;
//实例变量,有默认值,从属于对象
String str = "hello world";
//常量定义: final 常量类型 常量名 = 值
//final static 皆为修饰符,不存在先后顺序
final public static double x = 110;
public static void main(String[] args) {
//直接使用类变量
System.out.println(judge);//2000
//实例变量必须要创建对象
//变量类型 变量名 = new 类名();
CSDNTest test = new CSDNTest();
System.out.println(test.str);//hello world
//局部变量,没有默认值,必须初始化,只能在自己的方法中被调用
int i = 10;
System.out.println(i);//10
//输出常量
System.out.println(x);//110
}
}
成员变量可以被 public
,private
,static
等修饰符所修饰;是对象的一部分,存在于堆内存,它随着对象的创建而存在;会自动以类型的默认值而赋值(例外:被 final
修饰的成员变量必须显式地赋值而非默认,也就是常量)final
修饰的成员变量既可以是静态的,也可以是实例的。
在使用变量时需要遵循的原则为:就近原则: 首先在局部范围找,有就使用;接着在成员位置找。
实例变量和静态变量的区别?
一般通常说的成员变量指的是实例变量
实例变量 | 静态变量 | |
---|---|---|
生命周期 | 随着对象的创建而存在,随着对象的回收而释放 | 随着类的加载而存在,随着类的消失而消失(更长) |
调用方式 | 只能被对象调用 | 可以被对象调用,还可以被类名调用 |
存储位置 | 存储在堆内存的对象中,所以也叫对象的特有数据 | 存储在方法区(共享数据区)的静态区,所以也叫对象的共享数据 |
static静态修饰
static关键字,是一个修饰符,用于修饰成员(成员变量和成员函数)。
特点:
1、想要实现对象中的共性数据的对象共享。可以将这个数据进行静态修饰。(所以要注意是不是对象特有的数据,如果是,就不能静态修饰使之共享了)
2、被静态修饰的成员,可以直接被类名所调用。也就是说,静态的成员多了一种调用方式。类名.静态方式。
3、静态随着类的加载而加载。而且优先于对象存在。
4、通常情况下,静态变量会被final
关键字修饰成为常量。通常所有字母大写:
final public static double x = 110;
静态方法为什么不能调用非静态成员?
因为静态方法加载时,优先于对象存在,所以没有办法访问对象中的成员。
静态方法中不能使用this,super关键字
因为this代表对象,而静态在时,有可能没有对象,所以this无法使用。
什么时候定义静态成员呢?
成员分两种:
1、成员变量。(数据共享时静态化)
该成员变量的数据是否是所有对象都一样:
如果是,那么该变量需要被静态修饰,因为是共享的数据。
如果不是,那么就说这是对象的特有数据,要存储到对象中。
2、成员函数。(方法中没有调用特有数据时就定义成静态)
如果判断成员函数是否需要被静态修饰呢?
只要参考该函数内是否访问了对象中的特有数据:
如果有访问特有数据,那方法不能被静态修饰。
如果没有访问过特有数据,那么这个方法需要被静态修饰。
静态方法和实例方法有何不同?
静态方法 | 实例方法 | |
---|---|---|
调用方式 | 无需创建对象,一般建议使用 类名.方法名 的方式来调用静态方法 | 要创建对象,使用 对象.方法名 的方式来调用 |
访问类成员是否存在限制 | 在访问本类的成员时,只允许访问静态成员(即静态成员变量和静态方法),不允许访问实例成员(即实例成员变量和实例方法) | 不存在这个限制 |
Java 中的几种基本数据类型了解么?
Java 中有 8 种基本数据类型,分别为:
- 6 种数字类型:
- 4 种整数型:
byte
、short
、int
、long
- 2 种浮点型:
float
、double
- 4 种整数型:
- 1 种字符类型:
char
- 1 种布尔型:
boolean
。
这 8 种基本数据类型的默认值以及所占空间的大小如下:
基本类型 | 位数 | 字节 | 默认值 | 取值范围 | 包装类型 |
---|---|---|---|---|---|
byte | 8 | 1 | 0 | -128 ~ 127 | Byte |
short | 16 | 2 | 0 | -32768 ~ 32767、-128 ~ 127 | | Short |
int | 32 | 4 | 0 | -2147483648 ~ 2147483647 | Integer |
long | 64 | 8 | 0L | -9223372036854775808 ~ 9223372036854775807 | Long |
char | 16 | 2 | ‘u0000’ | 0 ~ 65535 | Character |
float | 32 | 4 | 0f | 1.4E-45 ~ 3.4028235E38 | Float |
double | 64 | 8 | 0d | 4.9E-324 ~ 1.7976931348623157E308 | Double |
boolean | 1 | false | true、false | Boolean |
注意:
- Java 里使用
long
类型的数据一定要在数值后面加上 L,否则将作为整型解析。 char a = 'h'
char :单引号,String a = "hello"
:双引号。- 基本数据类型不是对象,Java中只有它不是! 为了弥补这一点,推出了包装类。
- 除了Float和Double 之外,其他六个包装类都有常量缓存池
这八种基本类型都有对应的包装类分别为:Byte
、Short
、Integer
、Long
、Float
、Double
、Character
、Boolean
。
包装类(Wrapper Class):引入包装类的目的就是:提供一种机制,使得基本数据类型可以与引用类型互相转换,包装类是一种对象!
类和对象的关系
类是抽象的,是对象的一个集合,如老师这个群体;对象是类的实例,如王老师这个人。
基本类型和引用类型的区别?
基本数据类型就是上面说过的8种。
引用类型:
- 有三种:类、接口、数组
- 类(class):创建对象的模板或蓝图,对象是类的实例。Java中,类本身以及类的实例(即对象)都是引用类型。
- 接口(Interface):接口定义了一组方法,但不实现它们。一个类可以实现一个或多个接口,接口引用可以指向实现了该接口的任何对象。
- 数组(Array):数组是一种数据结构,用于存储相同类型的数据项。在大多数OOP语言中,数组是引用类型,因为数组变量存储的是对数组数据的引用,而不是数据本身。
- 所有引用类型默认值都是null.
- 一个引用变量可以用于引用任何与之兼容的类型。
各种类型变量存放的位置?
- 引用数据类型:变量是存放在栈中的一个指针,它们所指向的对象实例或实际数据则存储在堆内存中。
var arr = [1,2,3];
var arr1 = arr;
arr1[1] = 22;
console.log(arr);
console.log(arr1);
首先定义了arr变量,数组也是一个对象,所以在栈结构的全局执行环境中我们保存的实际上是一个地址值(指针),而真正的数组是被保存在了堆结构中。
保存对象首先会在堆结构中开辟一块内存空间,然后把地址值赋值给变量,栈结构中只保存对象的地址值。这里我们可以看到arr的保存的地址值赋值给了arr1,所以此时arr和arr1都指向了同一个堆结构中的对象。
此时我们通过数组的索引arr[1]去修改第二个元素为22,无论我们通过arr还是arr1访问这个数组,实际上都是在访问相同的对象。所以我们两次打印输出的结果都是[1,22,3]。
- 基本数据类型:
- 如果是局部变量,变量名及值(变量名及值是两个概念)就存放在方法栈中;
- 如果是全局变量(成员变量,没有被static修饰),就成为类的实例变量,变量名及其值就放在堆内存中;
- 特殊情况:对于静态(static)基本数据类型的成员变量,它们实际上存储在方法区(Method Area)的一个特殊部分,称为静态存储区(Static Storage Area)。这部分内存是线程共享的,但静态变量的生命周期与程序相同。
基本类型和包装类型的区别?
link
在前面已经说过,基本数据类型不是对象,而Java是一种面向对象的语言,所以引入包装类,包装类就是一种对象!
基本类型和包装类型的区别看上面的链接(包装类其实就是一种引用数据类型),这里只说一个: 基本类型使用 == 直接判断其值是否相等,包装类型使用 == 是判断地址(equals()判断内容)。
自动装箱与拆箱了解吗?原理是什么?
什么是自动拆装箱?
- 装箱:把基本类型转换成包装类型(就是引用类型)
- 拆箱:将包装类型转换为基本数据类型
举例:
Integer i = 10; //装箱, 等价于 Integer i = Integer.valueOf(10)
int n = i; //拆箱,等价于 int n = i.intValue()`;
装箱其实就是调用了 包装类的valueOf()
方法,拆箱其实就是调用了 xxxValue()
方法。
注意:如果频繁拆装箱的话,也会严重影响系统的性能。我们应该尽量避免不必要的拆装箱操作。
包装类型的缓存机制了解么(缓存池)?
包装类是对象,占用更多的内存空间,Java 通过实现存储一些常用数据,提高性能和节省内存空间。
Byte
,Short
,Integer
,Long
这 4 种包装类默认创建了数值 [-128,127] 的相应类型的缓存数据,Character
创建了数值在 [0,127] 范围的缓存数据,Boolean
直接返回 True
or False
。
两种浮点数类型的包装类 Float
,Double
并没有实现缓存机制。
记住:所有整型包装类对象之间值的比较,全部使用 equals 方法比较。
为什么浮点数运算的时候会有精度丢失的风险?
计算机是二进制的,而且计算机在表示一个数字时,宽度是有限的,无限循环的小数存储在计算机时,只能被截断,所以就会导致小数精度发生损失的情况。这也就是解释了为什么浮点数没有办法用二进制精确表示。
就比如说十进制下的 0.2 就没办法精确转换成二进制小数:
// 0.2 转换为二进制数的过程为,不断乘以 2,直到不存在小数为止,
// 在这个计算过程中,得到的整数部分从上到下排列就是二进制的结果。
0.2 * 2 = 0.4 -> 0
0.4 * 2 = 0.8 -> 0
0.8 * 2 = 1.6 -> 1
0.6 * 2 = 1.2 -> 1
0.2 * 2 = 0.4 -> 0(发生循环)
...
如何解决浮点数运算的精度丢失问题?
BigDecimal
可以实现对浮点数的运算,不会造成精度丢失。通常情况下,大部分需要浮点数精确运算结果的业务场景(比如涉及到钱的场景)都是通过 BigDecimal
来做的。
超过 long 整型的数据应该如何表示?
基本数值类型都有一个表达范围,如果超过这个范围就会有数值溢出的风险。
在 Java 中,64 位 long 整型是最大的整数类型。
long l = Long.MAX_VALUE;
System.out.println(l + 1); // -9223372036854775808
System.out.println(l + 1 == Long.MIN_VALUE); // true,超出范围,溢出了,继续回到最小值
BigInteger
内部使用 int[]
数组来存储任意大小的整形数据。
BigInteger和BigDecimal详解
BigInteger
是处理大整数的,相对于常规整数类型的运算来说,BigInteger
运算的效率会相对较低。
如果一个类没有声明构造方法,该程序能正确执行吗?
可以!默认无参构造。如果我们重载了有参的构造方法,记得都要把无参的构造方法也写出来(无论是否用到),因为这可以帮助我们在创建对象的时候少踩坑。
构造函数,主要是用来在创建对象时初始化对象,一般会跟new运算符一起使用,给对象成员变量赋初值。
// 显式声明构造方法
class Cat{
public Cat(){}
}
// 没有显式声明
class CatAuto{}
构造方法有哪些特点?是否可被 override?
构造方法特点如下:
- 名字与类名相同。
- 没有返回值,但不能用 void 声明构造函数。
- 生成类的对象时自动执行,无需调用。
构造方法不能被 override(重写),但是可以 overload(重载:参数不同),所以你可以看到一个类中有多个构造函数的情况。
面向对象三大特征(封装,继承,多态)
封装
不允许外部对象直接访问对象的内部信息,但是可以提供一些可以被外界访问的方法来操作属性【比如看不到空调的内部零件信息(也就是属性),但是可以通过遥控器(方法)来控制空调】。
可以对成员进行更精准的控制,让对象和调用者解耦,类内部的结构和实现可以自由修改,同时也能保证数据的安全性、有效性。
继承 extends
使用已存在的类的定义作为基础建立新类的技术,新类的定义可以增加新的数据或新的功能,也可以用父类的功能,但不能选择性地继承父类。通过使用继承,可以快速地创建新的类,可以提高代码的重用,程序的可维护性,节省大量创建新类的时间 ,提高我们的开发效率。
关于继承如下 3 点请记住:
- 子类拥有父类对象所有的属性和方法(包括私有属性和私有方法),但是父类中的私有属性和方法子类是无法访问,只是拥有。
- 子类可以拥有自己属性和方法,即子类可以对父类进行扩展。
- 子类可以用自己的方式实现父类的方法。(以后介绍)。
- is-a:如果能通过 is-a 测试,就能顺利地使用继承关系。
Dog is a Animal? \ Apple is a Animal?
,代码检查的方式就是从 = 右边往左边读。
- has-a:判断类与成员的关系:bird类有翅膀wings,wings这个成员变量不适合放在Animal类中,所以定义在bird中:
多态
指定一个父类,然后接受多个子类,程序执行时能自行发挥各自子类的特性。link
举例说明: 宠物店给所有哈士奇免费洗澡:
// 参数是哈士奇,执行的功能是洗澡
public void shower(哈士奇 哈士奇a);
可以看到,其它品种就不行,语法错误。于是给每种宠物都写一个方法来挽救这一点:
但是这样要写太多方法了,于是直接命令,只要是宠物过来,就可以免费洗澡:
// 参数是宠物类,执行的功能是洗澡
public void shower(Animal a);
现在就可以了,哈士奇、金毛等等都是Animal的子类:
不同对象调用父类的同一个方法,产生不同的结果就是多态。 多态的必要条件:
- 要有继承
- 父类的引用指向子类的对象(参数定义的是Animal类,传进来的参数得是Animal的子类)
多态的特点:
- 引用类型变量发出的方法调用的到底是哪个类中的方法,必须在程序运行期间才能确定;
- 多态不能调用“只在子类存在但在父类不存在”的方法;
- 如果子类重写了父类的方法,真正执行的是子类覆盖的方法,如果子类没有覆盖父类的方法,执行的是父类的方法。
多态既体现了多种类型的传递,又体现了不同类型的特性;既复用了父类的属性和方法,又扩展了自己的逻辑。
开闭原则:对修改关闭,对扩展开放。只需扩展子类,无需修改父类。
接口和抽象类有什么共同点和区别?
接口就是一种标准、规范(类比USB接口),在多态提出之后,有一种新情况:父类完全没必要实现所有逻辑,也没必要创建父类对象,因为就是想要子类。这时就将父类的方法抽象出来——抽象类。抽象方法就是一个对外的标准,子类是实现方。但是抽象类还有属性,可我们不想让子类再继承别的东西,于是进一步抽象——接口。
子类继承接口后,唯一能做的就是重写方法。
共同点 :
- 都不能创建本类对象,只能由子类去实例化子类对象。
- 都可以定义抽象方法。
- 都可以有默认实现的方法(Java 8 可以用
default
关键字在接口中定义默认方法,不会强制子类实现,其它方法子类都必须实现)。
区别 :
// extends 继承抽象类
public class Dog extends Animal{}
// implements 接口
public class Thread implements Runnable {}
- 接口主要用于对类的行为进行约束,你实现了某个接口就具有了对应的行为。抽象类主要用于代码复用,强调的是所属关系。
- 一个类只能继承一个父类,但是可以实现多个接口,所以二者都可时,尽量选择接口。
深拷贝和浅拷贝区别了解吗?什么是引用拷贝?
- 引用拷贝:只是复制对象的地址,并不会创建新对象
- 浅拷贝:在堆上创建新对象,复制属性,但是其中的引用类型,只会复制引用地址,也就是说拷贝对象和原对象共用同一个内部对象。
- 深拷贝 :深拷贝会完全复制整个对象,包括这个对象所包含的内部对象。
Object
Object 类的常见方法有哪些?
Object 类是一个特殊的类,是所有类的父类。它主要提供了以下 11 个方法:
getClass、hashCode、equals、clone、toString、notify、wait、finalize
/**
* native 方法,用于返回当前运行时对象的 Class 对象,使用了 final 关键字修饰,故不允许子类重写。
*/
public final native Class<?> getClass()
/**
* native 方法,用于返回对象的哈希码,主要使用在哈希表中,比如 JDK 中的HashMap。
*/
public native int hashCode()
/**
* 用于比较 2 个对象的内存地址是否相等,String 类对该方法进行了重写以用于比较字符串的值是否相等。
*/
public boolean equals(Object obj)
/**
* naitive 方法,用于创建并返回当前对象的一份拷贝。
*/
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()
方法。
- 类没有重写
equals()
方法 :和==
等价(Object
的equals
方法是比较的对象的内存地址) - 类重写了
equals()
方法 :一般都重写equals()
方法来比较两个对象中的属性是否相等;若它们的属性相等,则返回 true(即,认为这两个对象相等)。String
中的equals
方法是被重写过的,所以在比较字符串时,用equals
方法就能判断是否相等,而不会判断地址:
-
注意:用
equals
方法要注意空指针异常,所以良好的习惯是用常量去比较变量,即将不会为 null 的对象放在前面,可能为 null 的对象放在后面。如果两个对象都可能为 null,可以用Java标准库中的工具类 Objects 来进行equals比较,这样就能有效避免空指针异常:
-
equals
方法还有个隐蔽的坑是不能比较基本数据类型(基本数据类型直接用 == 比较了),equals
方法本身就是个对象方法,因此会报错:
-
当使用
equals
当比较包装类型时,要注意 比较的值,是否和包装类的类型一致。 包装类中重写了 equals 方法。它首先会判断另一个值是否为一样的类型,若不一样,即使数值虽然相等,也会返回 false :
-
hashCode() 有什么用?
hashCode()
的作用是获取哈希码(int
整数),也称为散列码。这个哈希码的作用是确定该对象在哈希表中的索引位置。
比如数组长度为10,哈希码为17,17%10=7,存放在7的位置
hashCode()
定义在 JDK 的 Object
类中,该方法通常用来将对象的内存地址转换为整数之后返回。
public native int hashCode();
散列表存储的是键值对(key-value),它的特点是:能根据“键”快速的检索出对应的“值”。这其中就利用到了散列码!(可以快速找到所需要的对象)
为什么要有 hashCode?
我们以“HashSet
如何检查重复”为例子来说明为什么要有 hashCode
?
下面这段内容摘自我的 Java 启蒙书《Head First Java》:
当你把对象加入
HashSet
时,HashSet
会先计算对象的hashCode
值来判断对象加入的位置,同时也会与其他已经加入的对象的hashCode
值作比较,如果没有相符的hashCode
,HashSet
会假设对象没有重复出现。但是如果发现有相同hashCode
值的对象,这时会调用equals()
方法来检查hashCode
相等的对象是否真的相同。如果两者相同,HashSet
就不会让其加入操作成功。如果不同的话,就会重新散列到其他位置。这样我们就大大减少了equals
的次数,相应就大大提高了执行速度。
其实, hashCode()
和 equals()
都是用于比较两个对象是否相等。
那为什么 JDK 还要同时提供这两个方法呢?
这是因为在一些容器(比如 HashMap
、HashSet
)中,有了 hashCode()
之后,判断元素是否在对应容器中的效率会更高(参考添加元素进HashSet
的过程)!
我们在前面也提到了添加元素进HashSet
的过程,如果 HashSet
在对比的时候,同样的 hashCode
有多个对象,它会继续使用 equals()
来判断是否真的相同。也就是说 hashCode
帮助我们大大缩小了查找成本。
那为什么不只提供 hashCode()
方法呢?
这是因为两个对象的hashCode
值相等并不代表两个对象就相等。
那为什么两个对象有相同的 hashCode
值,它们也不一定是相等的?
因为 hashCode()
所使用的哈希算法也许刚好会让多个对象传回相同的哈希值。越糟糕的哈希算法越容易碰撞,但这也与数据值域分布的特性有关(所谓哈希碰撞也就是指的是不同的对象得到相同的 hashCode
)。
总结下来就是 :
- 如果两个对象的
hashCode
值相等,那这两个对象不一定相等(哈希碰撞)。 - 如果两个对象的
hashCode
值相等并且equals()
方法也返回true
,我们才认为这两个对象相等。 - 如果两个对象的
hashCode
值不相等,我们就可以直接认为这两个对象不相等。
相信大家看了我前面对 hashCode()
和 equals()
的介绍之后,下面这个问题已经难不倒你们了。
为什么重写 equals() 时必须重写 hashCode() 方法?
因为两个相等的对象的 hashCode
值必须是相等。也就是说如果 equals
方法判断两个对象是相等的,那这两个对象的 hashCode
值也要相等。
如果重写 equals()
时没有重写 hashCode()
方法的话就可能会导致 equals
方法判断是相等的两个对象,hashCode
值却不相等。
思考 :重写 equals()
时没有重写 hashCode()
方法的话,使用 HashMap
可能会出现什么问题。
总结 :
equals
方法判断两个对象是相等的,那这两个对象的hashCode
值也要相等。- 两个对象有相同的
hashCode
值,他们也不一定是相等的(哈希碰撞)。
更多关于 hashCode()
和 equals()
的内容可以查看:Java hashCode() 和 equals()的若干问题解答
String
String 为什么是不可变的?
public final class String implements java.io.Serializable, Comparable<String>, CharSequence {
private final char value[];
//...
}
🐛 修正 : 被
final
修饰只能代表它不可指向新的数组,并不能代表数组本身的数据不会被改变。
String
真正不可变有下面几点原因:
- 保存字符串的数组被
final
和private
修饰,并且String
类没有提供/暴露修改这个字符串的方法,一些字符串操作都是返回新对象,不会影响原数据,获取其底层字符数组时,都是复制一个新数组进行返回,原数组也不会受到影响。String
类被final
修饰导致其不能被继承,进而避免了子类破坏String
不可变。
String、StringBuffer、StringBuilder 的区别?
-
String
是不可变的,必然会产生许多新对象,为了解决此问题——StringBuilder
(简称sb,继承自AbstractStringBuilder
类:char[] value;
)。AbstractStringBuilder
是StringBuilder
与StringBuffer
的公共父类,定义了一些字符串的基本操作,如expandCapacity
、append
、insert
、indexOf
等公共方法。 -
StringBuilder
可变,所以是在自身上改变,可以用append方法,缺点就是线程不安全了,为了解决此问题——StringBuffer
。 -
StringBuffer
使用了synchronized
关键字修饰了字符串操作方法(每次操作时都会加锁),保证了线程安全,但是性能底下。
对于三者使用的总结:
- 操作少量的数据: 适用
String
- 单线程操作字符串缓冲区下操作大量数据: 适用
StringBuilder
- 多线程操作字符串缓冲区下操作大量数据: 适用
StringBuffer
字符串拼接用“+” 还是 StringBuilder?
Java 语言本身并不支持运算符重载,“+”和“+=”是专门为 String 类重载过的运算符,也是 Java 中仅有的两个重载过的运算符。
String str1 = "he";
String str2 = "llo";
String str3 = "world";
String str4 = str1 + str2 + str3;
上面,字符串对象通过“+”的字符串拼接方式,实际上是通过 StringBuilder
调用 append()
方法实现的,拼接完成之后调用 toString()
得到一个 String
对象 。
不过,在循环内使用“+”进行字符串的拼接的话,存在比较明显的缺陷:编译器不会创建单个 StringBuilder
以复用,会导致创建过多的 StringBuilder
对象。
如果直接使用 StringBuilder
对象进行字符串拼接的话,就不会存在这个问题了。
String[] arr = {"he", "llo", "world"};
StringBuilder s = new StringBuilder();
for (String value : arr) {
s.append(value);
}
System.out.println(s);
String#equals() 和 Object#equals() 有何区别?
前者被重写过,比较的是值。后者比较对象的内存地址。
字符串常量池的作用了解吗?
字符串常量池 是 JVM 为了提升性能和减少内存消耗针对字符串(String 类)专门开辟的一块区域,位于堆中,主要目的是为了避免字符串的重复创建。
// Java 虚拟机会先在字符串常量池中查找有没有“ab”这个字符串对象
// 一开始没有,所以在字符串常量池中创建“ab”这个对象,然后将其地址返回,赋给变量 aa。
String aa = "ab";
// 现在有,则不创建任何对象,直接将字符串常量池中这个“ab”的对象地址返回,赋给变量 bb
String bb = "ab";
System.out.println(aa==bb);// true,这两行代码只会创建一个对象,就是字符串常量池中的那个
String s1 = new String(“二哥”);这句话创建了几个字符串对象?
会创建 1 个(已存在于字符串常量池中)或 2 个(字符串常量池中不存在字符串对象“二哥”的引用)字符串对象。
new 的方式始终会创建一个对象,不管字符串的内容是否已经存在,而双引号的方式会重复利用字符串常量池中已经存在的对象
intern 方法有什么作用?
String.intern()
是一个 native(本地)方法,其作用是将指定的字符串对象的引用保存在字符串常量池中,可以简单分为两种情况:
- 如果字符串常量池中保存了对应的字符串对象的引用,就直接返回该引用。
- 如果字符串常量池中没有保存了对应的字符串对象的引用,那就在常量池中创建一个指向该字符串对象的引用并返回。
示例代码(JDK 1.8) :
// 在堆中创建字符串对象”Java“
String s1 = "Java"; // 将字符串对象”Java“的引用保存在字符串常量池中
String s2 = s1.intern(); // 直接返回字符串常量池中字符串对象”Java“对应的引用
String s3 = new String("Java"); // 会在堆中在单独创建一个字符串对象
String s4 = s3.intern(); // 直接返回字符串常量池中字符串对象”Java“对应的引用
System.out.println(s1 == s2); // true
System.out.println(s3 == s4); // false
System.out.println(s1 == s4); //true
String 类型的变量和常量做“+”运算时发生了什么?
先来看字符串不加 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()
方法。
对于编译期可以确定值的字符串,也就是常量字符串 ,jvm 会将其存入字符串常量池。并且,字符串常量拼接得到的字符串常量在编译阶段就已经被存放字符串常量池,这个得益于编译器的优化。
常量折叠会把常量表达式的值求出来作为常量嵌在最终生成的代码中,这是 Javac 编译器会对源代码做的极少量优化措施之一(代码优化几乎都在即时编译器中进行)。
对于 String str3 = "str" + "ing";
编译器会给你优化成 String str3 = "string";
。
并不是所有的常量都会进行折叠,只有编译器在程序编译期就可以确定值的常量才可以:
- 基本数据类型(
byte
、boolean
、short
、char
、int
、float
、long
、double
)以及字符串常量。 final
修饰的基本数据类型和字符串变量- 字符串通过 “+”拼接得到的字符串、基本数据类型之间算数运算(加减乘除)、基本数据类型的位运算(<<、>>、>>> )
引用的值在程序编译期是无法确定的,编译器无法对其进行优化。
对象引用和“+”的字符串拼接方式,实际上是通过 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
会被编译器当做常量来处理,编译器在程序编译期就可以确定它的值,其效果就相当于访问常量。
如果 ,编译器在运行时才能知道其确切值的话,就无法对其优化。
下面这个呢?
String s1 = new String("二哥") + new String("三妹");
String s2 = s1.intern();
System.out.println(s1 == s2);// true
- 创建 “二哥” 字符串对象,存储在字符串常量池中。
- 创建 “三妹” 字符串对象,存储在字符串常量池中。
- 执行
new String("二哥")
,在堆上创建一个字符串对象,内容为 “二哥”。 - 执行
new String("三妹")
,在堆上创建一个字符串对象,内容为 “三妹”。 - 执行
new String("二哥") + new String("三妹")
,会创建一个 StringBuilder 对象,并将 “二哥” 和 “三妹” 追加到其中,然后调用 StringBuilder 对象的 toString() 方法,将其转换为一个新的字符串对象,内容为 “二哥三妹”。这个新的字符串对象存储在堆上。