面试题-JavaSE
[TOC]
Java基础相关面试题
JDK、JRE、JVM的关系?
JDK提供了开发Java程序所需的工具和资源;JRE提供了运行Java程序所需的最小环境;而JVM则是Java程序的运行平台
- JVM:
Java虚拟机,负责解释和执行Java字节码- JRE:
Java运行时环境,是在计算机上执行Java应用程序所需的最小环境,包含JVM和类库。JRE = JVM + JavaSE标准类库- JDK:
Java开发工具包,它包含了用于开发、编译和调试Java程序的工具。JDK = JRE + 开发工具集
Java八种数据类型有哪些?
byte(字节型)short(短整型)int(整型)long(长整型)float(单精度浮点型)double(双精度浮点型)char(字符型)boolean(布尔型)
基本数据类型 占用存储空间(字节) 默认值 byte 1 0 short 2 0 int 4 0 long 8 0L float 4 0.0f double 8 0.0d char 2 ‘\u0000’(空字符) boolean 1 false
说一说八种数据类型的包装类?
在Java中,包装类(Wrapper Class)是一种用于将基本数据类型转换为对象的类。Java是一个面向对象的编程语言,但是Java中的八种基本数据类型却是不面向对象的,无法直接参与面向对象的操作,于是Java提供了八种基本数据类型对应的包装类(封装类),使得基本数据类型的变量具有类的特征。有了类的特点,就可以调用类中的方法,Java才是真正的面向对象。除了Integer和Character类以外,其它六个类的类名和基本数据类型一致,只是类名的第一个字母大写即可。
基本数据类型 包装类 byte Byte short Short int Integer long Long float Float double Double char Character boolean Boolean
基本类型和包装类型的区别?
声明方式不同:基本类型的声明不需要使用 new 关键字,可以直接声明一个变量并赋初始值。而包装类型需要使用 new 关键字来在堆中分配存储空间,并且需要使用构造函数来初始化对象。
初始值不同:基本类型有默认值但不能不能赋值为null;包装类型可以赋值为 null。
使用方式不同:基本类型不可以使用泛型;包装类型可使用泛型。
存储方式及位置不同:基本类型的值直接存储在栈中,占用固定的内存空间。而包装类型存储的是堆中的对象引用,该引用指向实际存储在堆中的对象。
什么是自动装箱与自动拆箱?
自动装箱(Autoboxing)和自动拆箱(Unboxing)是Java语言中的特性,用于在基本数据类型和对应的包装类之间进行自动的转换。
- 自动装箱:
基本数据类型 --> 包装类,将基本数据类型自动转换为对应的包装类对象。- 自动拆箱:
包装类 --> 基本数据类型,将包装类对象自动转换为对应的基本数据类型。
String属于基础的数据类型吗?
String 不是基本数据类型,而是引用数据类型,是Java中的一个类。
String常用的方法有哪些?
split():字符串分隔
substring():字符串截取
concat():字符串拼接
replace():字符串替换
equals():字符串比较
trim():字符串去空白
toLowerCase():字符串大写转小写
toUpperCase():字符串小写转大写
Java中操作字符串都有哪些类?
String、StringBuffer、StringBuilder
String类:用于创建和操作字符串。StringBuffer类:用于可变字符串,与StringBuilder类类似,不同之处在于线程安全性StringBuilder类:用于可变字符串,支持在字符串中进行插入、删除、替换等操作,并且比StringBuffer类更高效。
String、StringBuffer、StringBuilder的区别?
String、StringBuffer和StringBuilder是Java中用于处理字符串的三个类,它们之间有以下区别:
- 可变性:
String是不可变类,一旦创建,其值就不可更改。任何对String执行的操作实际上都会创建一个新的String对象。而StringBuffer和StringBuilder都是可变类,可以在已有对象的基础上进行修改而不创建新的对象。StringBuffer和StringBuilder之间的区别在于线程安全性。- 线程安全性:
String是不可变类,因此是线程安全的,多个线程可以同时访问和共享String对象。StringBuffer是可变类,并且使用了同步机制,因此是线程安全的,适用于多线程环境。StringBuilder是可变类,但没有使用同步机制,因此在多线程环境中不是线程安全的。- 性能对比:StringBuilder>StringBuffer>String。由于
String是不可变类,每次对String进行修改都会创建新的对象,因此在大量修改字符串的场景下,性能较差。StringBuffer使用同步机制来保证线程安全,因此相对于StringBuilder会有一些性能损失。StringBuilder没有同步机制,因此在单线程环境下,性能最好。
什么是字符串常量池?
字符串常量是在代码中直接使用双引号括起来的字符串字面量,它们是不可变的,一旦创建,其值无法被修改。
1 String str = "Hello";// 在字符串常量池中创建一个"Hello"字符串对象,并将其引用赋值给str字符串常量池(String Pool)是Java中用于存储字符串常量的一块特殊内存区域,是在堆内存中的一个固定区域。当创建一个字符串常量时,虚拟机会首先检查字符串常量池中是否存在相同内容的字符串,如果存在则返回已存在的字符串对象的引用,否则将该字符串加入到常量池中,并返回它的引用。这样可以避免创建多个相同内容的字符串对象,节省内存空间。
1
2
3
4 String str1 = "Hello";// 在字符串常量池中创建一个"Hello"字符串对象,并将其引用赋值给str1
String str2 = "Hello";// 直接使用同样的字符串常量,并将其引用赋值给str2
boolean result = (str1 == str2);// 比较str1和str2的引用是否指向同一个对象
System.out.println(result); // 输出结果为 true,因为它们引用的是同一个字符串常量
怎样将字符串添加到常量池?
以下情况下,可以将字符串添加到字符串常量池中:
- 字符串常量直接赋值:当使用双引号括起来的字符串字面量直接赋值给一个字符串变量时,如果常量池中不存在相同值的字符串,JVM 会将该字符串对象的引用自动加入字符串常量池。
- 调用 intern() 方法:通过在字符串对象上调用
intern()方法,可以将该字符串对象的引用手动添加到字符串常量池中。如果常量池中已经存在相同值的字符串,会返回常量池中的引用。
1
2
3
4
5
6
7 String str1 = "Hello"; // 在字符串常量池中创建一个"Hello"字符串对象,并将其引用赋值给str1
String str2 = new String("Hello"); // 创建一个新的字符串对象,在堆内存中分配空间存储"Hello",并将其引用赋值给str2
String str3 = str2.intern(); // 将str2所引用的字符串对象添加到字符串常量池中,并返回该字符串在常量池中的引用
boolean result1 = (str1 == str2);// 比较str1和str2的引用是否指向同一个对象
boolean result2 = (str1 == str3);// 比较str1和str3的引用是否指向同一个对象
System.out.println(result1); // 输出:false,因为str1和str2引用的是不同的对象,一个在常量池中,一个在堆内存中
System.out.println(result2); // 输出:true,因为str3引用的是常量池中的字符串对象,与str1引用的对象相同
说一说&与&&的区别?
&表示按位与,可以用于布尔类型或者整数类型。
用于布尔类型:对于布尔类型,当两个操作数都为
true时,结果为true,否则结果为false。无论第一个表达式的结果如何,第二个表达式总会被计算。用于整数类型:对于整数类型,会将两个操作数的二进制表示的每一位进行逻辑与运算,得到的结果为一个新的整数。
1
2
3
4
5
6
7
8
9
10
11 public class Demo {
public static void main(String[] args) {
boolean a = true;
boolean b = false;
System.out.println(a & b); // 输出结果为 false
int x = 5; // 二进制表示为 00000101
int y = 3; // 二进制表示为 00000011
System.out.println(x & y);// 按位与运算得到结果 00000001,即 1
}
}
&&表示短路与,只能用于两个布尔类型。
- 用于布尔类型:对于布尔类型,当第一个操作数为
false时,不会对第二个操作数进行求值,直接返回false,因此它可以避免不必要的计算。
1
2
3
4
5
6
7 public class Demo {
public static void main(String[] args) {
boolean a = false;
boolean b = true;
System.out.println(a && b); // 输出结果为 false,因为第一个操作数为 false,不会对第二个操作数进行求值,直接返回 false
}
}
说一说|与||的区别?
|表示按位或,可以用于布尔类型或者整数类型。
- 用于布尔类型:对于布尔类型,当两个操作数中至少有一个为
true时,结果为true,否则结果为false。- 用于整数类型:对于整数类型,会将两个操作数的二进制表示的每一位进行逻辑或运算,得到的结果为一个新的整数。
1
2
3
4
5
6
7
8
9
10
11 public class Demo {
public static void main(String[] args) {
boolean a = true;
boolean b = false;
System.out.println(a | b); // 输出结果为 true
int x = 5; // 二进制表示为 00000101
int y = 3; // 二进制表示为 00000011
System.out.println(x | y); // 按位或运算得到结果 00000111,即 7
}
}
||表示逻辑或,只能用于两个布尔类型。
- 用于布尔类型:当第一个操作数为
true时,不会对第二个操作数进行求值,直接返回true,因此它可以避免不必要的计算。
1
2
3
4
5
6
7 public class Demo {
public static void main(String[] args) {
boolean a = true;
boolean b = false;
System.out.println(a || b); // 输出结果为 true,因为第一个操作数为 true,不会对第二个操作数进行求值
}
}
说一说=与==的区别?
=是赋值运算符,用于将右侧的值赋给左侧的变量
1
2
3
4
5
6
7
8 public class Demo {
public static void main(String[] args) {
int a = 10;
int b = 20;
int c = a + b; // 将 a + b 的结果赋给变量 c
System.out.println(c);// 输出 30
}
}
==是相等运算符,用于比较两个操作数的值(对于基本数据类型)或对象引用(对于引用类型)是否相等,并返回一个布尔值。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18 public class Demo {
public static void main(String[] args) {
int x = 5;
int y = 3;
boolean result1 = (x == y); // 检查 x 和 y 的值是否相等,得到的结果为 false
System.out.println(result1); // 输出结果为 false
String str1 = "Hello";
String str2 = "Hello";
boolean result2 = (str1 == str2); // 检查 str1 和 str2 引用的对象是否相等
System.out.println(result2); // 输出结果为 true,因为它们引用的是同一个字符串常量
String str3 = new String("Hello");
String str4 = new String("Hello");
boolean result3 = (str3 == str4); // 检查 str3 和 str4 引用的对象是否相等
System.out.println(result3); // 输出结果为 false,因为它们引用的是两个不同的字符串对象
}
}
为什么str1 == str2输出结果为 true ?
在 Java 中,字符串常量池是一个特殊的存储区域,用于存储字符串常量。当我们使用 String str = “Hello” 这种方式创建字符串对象时,Java 会首先检查常量池中是否存在值为 “Hello” 的字符串对象,如果存在,则将引用指向该对象,如果不存在,则在常量池中创建一个新的字符串对象,并将引用指向它。因此,在常量池中只会存在一个值为 “Hello” 的字符串对象。而 == 运算符在比较引用数据类型时,比较的是引用地址是否一致,str1与str2引用都指向一个 “Hello” 的字符串对象,所以str1 == str2输出结果为 true。
1
2
3
4 String str1 = "Hello"; // 在字符串常量池中创建一个"Hello"字符串对象,并将其引用赋值给str1
String str2 = "Hello"; // 直接使用同样的字符串常量,并将其引用赋值给str2
boolean result = (str1 == str2); // 比较str1和str2的引用是否指向同一个对象
System.out.println(result); // 输出结果为 true,因为它们引用的是同一个字符串常量
为什么str3 == str4输出结果为 false ?
使用 new 关键字会在堆内存中分配新的内存空间,每次都会创建一个新的对象。因此使用 String str = new String(“Hello”) 这种方式创建字符串对象时,不管常量池中是否已经存在值为 “Hello” 的字符串对象,都会在堆内存中创建一个新的字符串对象。而 == 运算符在比较引用数据类型时,比较的是引用地址是否一致,str3与str4的引用会指向堆内存中不同的字符串对象,所以str3 == str4输出结果为 false。
1
2
3
4 >String str3 = new String("Hello"); // 创建一个新的字符串对象,在堆内存中分配空间存储"Hello",并将其引用赋值给str3
>String str4 = new String("Hello"); // 创建一个新的字符串对象,在堆内存中分配空间存储"Hello",并将其引用赋值给str4
>boolean result = (str3 == str4); // 比较str3和str4的引用是否指向同一个对象
>System.out.println(result); // 输出结果为 false,因为它们引用的是两个不同的字符串对象
说一说==与equals()有什么区别?
==运算符
==是一个运算符
对于基本数据类型:==比较的是值是否相等
对于引用数据类型:==比较的是引用地址是否一致
1
2
3
4
5
6
7
8
9
10
11
12 public class Demo {
public static void main(String[] args) {
int a = 5;
int b = 5;
String str1 = new String("Hello");
String str2 = new String("Hello");
boolean result1 = (a == b); // 使用==运算符比较两个整数的值
boolean result2 = (str1 == str2); // 使用==运算符比较两个字符串的引用地址
System.out.println(result1); // 输出:true
System.out.println(result2); // 输出:false
}
}equals()方法
equals()是Object类中的一个方法
对于基本数据类型:不能使用equals()进行比较,因为基本数据类型的变量并不是对象,所以不继承于Object,不能调用equals()
对于引用数据类型:equals()默认比较的是引用地址,因为equals()底层是使用==运算符,通常我们希望比较引用类型的对象内容是否相等,所以一般自定义的Bean都会重写equals()方法
1
2
3
4
5
6
7 public class Object {
...
public boolean equals(Object obj) {
return (this == obj);
}
...
}
说一说Object类中的常见方法?
equals(Object obj):用于比较两个对象是否相等,默认实现比较的是对象的内存地址,通常需要重写该方法来比较对象的属性值。hashCode():返回对象的哈希码值,用于在哈希表等数据结构中高效地存储和检索对象,默认实现根据对象的内存地址计算哈希码。toString():返回对象的字符串表示,通常会在输出对象时使用,默认实现返回类名和对象的哈希码。getClass():返回对象的运行时类对象,用于获取对象所属的类。clone():创建并返回对象的副本,通常需要实现Cloneable接口并重写该方法。instanceof:用于判断对象是否属于某个类或接口的实例。wait():使当前线程进入等待状态。notify()、notifyAll():用于唤醒在该对象上等待的线程
hashCode()和equals()的区别?
hashCode()和equals()是Java中Object类的两个方法,用于处理对象的相等性判断。
hashCode()方法:返回对象的哈希码(散列码),是一个int类型的整数。它用于在哈希表等数据结构中快速定位对象。
equals()方法:用于判断两个对象是否相等。默认情况下,equals()方法与”==”操作符具有相同的行为,即判断两个对象的引用是否相同。
为什么重写equals()要重写hashCode()?
在每个类中,在重写equals()方法的时侯,一定要重写hashcode()方法。
- Object规范要求:如果两个对象通过equals()方法比较相等,那么它们的hashCode()方法返回的值必须相等。这意味着,在重写equals()方法时,如果判断两个对象相等,就必须确保它们的hashCode()方法返回相同的值。
- equals()方法的目的是用于比较两个对象是否在逻辑上相等。默认的equals()方法比较的是对象的内存地址,也就是判断两个对象是否引用同一个实例。在重写equals()方法时,通常是根据需要比较对象的属性值,而不是仅仅比较内存地址。
- hashCode()方法返回对象的哈希码值,用于在哈希表等数据结构中高效地存储和检索对象。默认的hashCode()方法根据对象的内存地址计算哈希码。
- 在equals()方法与hashCode()方法都未重写的情况下,假如有两个对象的内存地址相同,对象的属性值也相同,equals()方法会比较内存地址判断为true,hashCode()方法根据对象的内存地址计算的哈希码会相同,符合Object规范要求
- 在equals()方法重写但hashCode()方法未重写的情况下,假如对象的内存地址不相同,但对象的属性值相同,此时equals()方法会比较对象的属性值同样判断为true,但hashCode()方法根据对象的内存地址计算的哈希码可能不相同,不符合Object规范要求
Java面向对象相关面试题
什么是面向对象思想?
面向过程思想
什么是过程?
过程是指一系列的操作步骤或算法,每个步骤按照特定的顺序执行,直至达到预期的结果。比如现在要制作一杯咖啡,可以将制作咖啡的过程进行分解
- 研磨咖啡豆:将咖啡豆研磨成粉末状。
- 加水:将适量的水倒入咖啡壶中。
- 冲泡咖啡:将咖啡粉末放入滤网中,然后将滤网放入咖啡壶中,倒入热水冲泡。
- 倒出咖啡:将冲泡好的咖啡倒入杯子中。
- 加调料:根据个人口味加入糖、牛奶等调料。
什么是面向过程?
面向过程是一种基于功能的编程思想,强调了
解决问题的步骤和流程,以过程/函数为最小单位,考虑怎么做。对于一个问题,在面向过程编程中,问题会被划分为多个步骤或函数,每个步骤或函数负责完成特定的功能,函数之间通过参数传递数据进行交互,通过调用函数来实现功能。这种方式更注重解决问题的步骤和流程,将问题划分为多个子任务,并按照一定的顺序执行这些子任务来解决问题。
假如现在有一个问题:如何把大象塞进冰箱?以面向过程的思想解决问题,可能需要以下的步骤:
- 第一步:打开冰箱门。
- 第二步:把大象放进冰箱。
- 第三步:关闭冰箱门。
面向对象思想
什么是对象?
对象是对现实世界中具体事物或概念的抽象,每个对象都有自身的特征和行为,现实世界中每一个具体事物或概念都可以看做是一个对象
- 比如现实世界中的一只
猫或者一只狗,他们都可以看做是一个对象- 猫或者狗一般都具有颜色、体重等等
特征和吃饭、睡觉等行为- 把现实世界中的对象抽象地体现在编程世界中,可以定义
类来描述对象,定义属性描述特征,定义方法描述行为什么是面向对象?
面向对象是一种编程思想,强调了
具备功能的对象,以类/对象为最小单位,考虑谁来做。对于一个问题,在面向对象编程中,问题会被抽象为一组相互关联的功能对象,每个对象具有属性和方法。对象之间通过消息传递进行交互,通过调用对象的方法来实现功能。这种方式更注重问题领域中的对象和它们之间的关系,对象拥有自己的状态和行为,通过协同工作来解决问题。
假如现在有一个问题:如何把大象塞进冰箱?以面向对象的思想解决问题可能需要以下的步骤:
- 第一步:创建一个冰箱对象。冰箱对象具有方法:
打开门()和关闭门()。- 第二步:创建一个大象对象。大象对象具有方法:
移动()。- 第三步:调用冰箱对象的
打开门()方法。- 第四步:调用大象对象的
移动()方法,使其进入冰箱。- 第五步:调用冰箱对象的
关闭门()方法。
面向对象的三大特征?
封装性
(1)封装也称为信息隐藏,是指将数据以及对数据的操作,封装在一个操作单元(类)中,通过访问修饰符来控制数据的访问权限,仅对外公开必要的接口,从而隐藏对象的内部实现细节,增强了代码的可维护性和安全性。
(2)四个访问修饰符:private类访问级别、default包访问级别、protected子类访问级别、public公共访问级别
继承性
(1)继承是指通过已有的类创建新类,实现代码的重用,减少重复编写相同的代码
(2)继承一般使用extends关键字来实现
(3)子类继承父类之后,子类会拥有父类的非私有属性和非私有方法,并且可以扩展或修改父类的功能,形成类之间的层次结构
多态性
(1)定义:多态是指一个类的实例(对象)可以表现出多种形态
(2)实现多态的三要素:继承、重写、父类引用指向子类对象
(3)特点:编译看左边、运行看右边
- 编译看左边:当造对象时,父类的引用需要指向子类的对象
- 运行看右边:当通过引用调用方法时,执行的是子类中的方法
谈谈你对多态的理解?
(1)定义:多态是指一个类的实例(对象)可以表现出多种形态
(2)实现多态的三要素:继承、重写、父类引用指向子类对象
(3)特点:编译看左边、运行看右边
- 编译看左边:当造对象时,父类的引用需要指向子类的对象
- 运行看右边:当通过引用调用方法时,执行的是子类中的方法
构造方法有哪些特征?
构造方法(Constructor)是一种特殊类型的方法,主要用于创建和初始化对象,具有以下特性:
- 构造方法名字与类名相同:构造方法的名称必须与类名完全相同
- 构造方法没有返回值:构造方法没有返回类型,包括
void,也不需要使用return关键字。- 默认构造方法:如果一个类没有显式定义任何构造方法,编译器会自动提供一个默认的无参构造方法,它什么也不做。默认构造方法的访问修饰符通常为 public
- 构造方法的重载:可以根据需要在类中定义多个构造方法,可以有不同的参数列表(参数的个数、顺序、类型不同),称为构造方法的重载。每个构造方法可以根据参数的不同来完成不同的初始化任务
- 构造方法自动执行:当使用
new关键字创建对象时,构造方法会自动执行,用于初始化新创建的对象,只会执行一次。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22 public class Person {
private int id;
private String name;
private int age;
// 无参构造方法
public Person() {
}
// 有参构造方法
public Person(int id, String name) {
this.id = id;// 构造器中调用属性
this.name = name;// 构造器中调用属性
}
// 构造方法的重载
public Person(String name, int age) {
this.name = name;// 构造器中调用属性
this.age = age;// 构造器中调用属性
}
}
接口和抽象类有什么区别?
- 定义方式:抽象类使用
abstract关键字来修饰,接口使用interface关键字来修饰。- 构造函数:抽象类可以有构造函数,而接口不能有构造函数。
- 代码块:抽象类中可以包含代码块,而接口中不能有代码块。
- 成员变量:抽象类可以定义各种类型的成员变量,而接口中的变量默认为
public static final型常量。- 方法:抽象类可以包含非抽象方法和抽象方法,而接口只能包含抽象方法、默认方法和静态方法。
- 单继承与多实现:一个类可以继承一个抽象类,但只能实现多个接口。
- 主要作用:抽象类用于作为其他类的基类,定义了一些通用行为和属性,子类可以继承这些通用行为和属性,并在需要的基础上进行扩展或修改。接口用于定义一组规范,描述了一个对象应该具备的行为
重载(Overload)和重写(Override)的区别?
重载(Overload)
发生在同一个类中,通过定义具有相同名称但参数列表不同的多个方法来实现
重载方法在编译时根据参数类型和数量进行静态绑定,编译器根据调用时传递的参数类型和数量确定要调用的具体方法。
重写(Override)
发生在子类与父类之间,子类重新定义了父类中已经存在的方法
重写方法必须具有与父类方法相同的名称、参数列表和兼容的返回类型,提供了对父类方法的替换实现
重写方法在运行时根据对象的实际类型进行动态绑定,当通过父类引用调用重写方法时,实际上会根据对象的类型执行相应的子类方法。
重写方法还需要遵循里氏代换原则,即子类对象可以替代父类对象出现的任何地方,并且不会影响程序的正确性。
重载(Overload)和重写(Override)的区别
- 重载:发生在同一个类中,方法名称相同,参数列表不同,静态绑定,编译时多态。
- 重写:发生在父类与子类之间,方法名称、参数列表和返回类型相同,动态绑定,运行时多态。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30 class ParentClass {
// 父类的 printMessage() 方法
public void printMessage() {
System.out.println("父类的 printMessage() 方法");
}
// 父类的重载方法 printMessage(String message)
public void printMessage(String message) {
System.out.println("父类的重载方法 printMessage(String message)");
}
}
class ChildClass extends ParentClass {
// 子类重写了父类的 printMessage() 方法
public void printMessage() {
System.out.println("子类重写了父类的 printMessage() 方法");
}
// 子类重写了父类的 printMessage(String message) 方法
public void printMessage(String message) {
System.out.println("子类重写了父类的重载方法 printMessage(String message)");
}
// 子类特有的重载方法 printMessage(int number)
public void printMessage(int number) {
System.out.println("子类特有的重载方法 printMessage(int number)");
}
}
说说this与super的区别?
在Java中,
this和super都是关键字,用于访问对象的成员或调用父类的构造方法。
- this关键字:
this表示当前对象的引用,可以使用this关键字访问当前对象的成员变量、成员方法和构造方法。- super关键字:
super表示当前对象的父类(超类)的引用,可以使用super关键字访问父类的成员变量、成员方法和构造方法
break、continue、return 的区别及作用?
在Java中,
break、continue和return是用于控制流程的关键字
- break(结束当前循环):break语句只能用于switch语句和循环语句中,用于终止某个语句块的执行,
结束当前循环- continue(结束当次循环):continue语句只能使用在循环结构中,
结束当次循环,用于跳过其所在循环语句块的一次执行,继续下一次循环- return(结束一个方法):return语句并非专门用于结束循环的,用于
结束一个方法。当一个方法执行到一个return语句时,这个方法将被结束。与break和continue不同的是,return直接结束整个方法,不管这个return处于多少层循环之内
static关键字有什么作用?
static关键字可以用来修饰属性、方法、代码块、内部类。
- 修饰属性:用
static修饰的属性为静态属性,也称为类变量。静态属性属于类本身,可以直接通过类名调用,无需创建类的实例对象。- 修饰方法:用
static修饰的方法为静态方法,也称为类方法。静态方法属于类本身,可以直接通过类名调用,无需创建类的实例对象。静态方法中``不能直接访问实例方法和实例变量,也不能使用this关键字、super关键字。静态方法不能被重写`- 修饰代码块:用
static修饰的代码块称为静态代码块。静态代码块在类加载时执行,并且只会执行一次。它可用于进行静态变量的初始化或其他静态操作。- 修饰内部类:用
static修饰的内部类称为静态内部类。静态内部类与外部类间没有直接的关联,可以直接创建静态内部类的实例对象。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52 class MyClass {
public static void main(String[] args) {
MyClass.staticVariable = 2;// 通过类名调用静态属性
MyClass.staticMethod();// 通过类名调用静态方法
MyClass.StaticInnerClass.staticInnerMethod();// 通过类名调用静态内部类中的静态方法
}
// 静态属性
public static int staticVariable;
// 实例属性
public int instanceVariable;
// 静态方法
public static void staticMethod() {
// 静态方法不能直接访问实例变量和实例方法
// MyClass.instanceVariable = 2; // 直接访问实例变量会报错
// MyClass.instanceMethod(); // 直接访问实例方法也会报错
// 静态方法不能使用this关键字、super关键字
// System.out.println(MyClass.this.getClass()); // 使用this关键字会报错
// System.out.println(MyClass.super.getClass()); // 使用super关键字也会报错
System.out.println("静态方法被执行");
}
// 实例方法
public void instanceMethod() {
// 实例方法可以直接访问静态变量和调用静态方法
MyClass.staticVariable = 2;
MyClass.staticMethod();
// 也可以使用this关键字、super关键字
System.out.println(MyClass.this.getClass());
System.out.println(MyClass.super.getClass());
}
// 静态代码块,类加载时执行,仅执行一次
static {
// 可用于进行静态变量的初始化或其他静态操作
}
// 静态内部类,与外部类间没有直接的关联,可以直接创建静态内部类的实例对象
public static class StaticInnerClass {
public static void staticInnerMethod() {
System.out.println("静态内部方法被执行");
}
public void instanceInnerMethod() {
System.out.println("实例内部方法被执行");
}
}
}
final关键字有什么作用?
final关键字可以用来修饰的结构:类、方法、变量(成员变量、局部变量、形参、引用地址)
- 修饰类:用
final修饰的类不能被其他类继承- 修饰方法:用
final修饰的方法不能被子类重写- 修饰变量:用
final修饰的变量表示常量,即其值一旦被初始化后就不能被修改。对于基本数据类型的变量,该值是不可变的;对于引用类型的变量,该引用不能再指向其他对象,但是该对象的内容可以被修改。
1
2
3
4
5
6
7
8
9
10
11 public final class MyClass {// 使用final修饰的 类MyClass,不能被继承
final int age = 30; // 使用final修饰的 成员变量age,值不能被修改
// 使用final修饰的 方法myMethod,不能被子类重写
public final void myMethod(final int num) {// 使用final修饰方法的 形参num,值不能被修改
final String message = "Hello"; // 使用final修饰的 局部变量message,值不能被修改
final Object obj = new Object();// 使用final修饰的 引用地址obj,不能再指向其他对象
}
}
深拷贝和浅拷贝的区别?
引用拷贝
引用拷贝只复制了对象的引用,新旧对象将指向同一块内存地址,修改新对象会影响到原对象,因为它们共享相同的数据
对象拷贝
对象拷贝是通过调用对象的拷贝构造函数或克隆方法创建一个新的对象的副本。新对象与原对象是完全独立的,修改新对象不会影响到原对象。
浅拷贝
浅拷贝是创建一个新对象,然后将原始对象中的所有非静态成员变量的值复制给新对象。对于引用类型的成员变量,只复制了引用,而不是创建副本。所以,新对象和原对象中的引用类型成员变量仍然指向相同的对象。
深拷贝
深拷贝是在创建新对象时,不仅复制原始对象中的基本类型成员变量的值,还复制引用类型成员变量所引用的对象。换句话说,深拷贝会递归地复制对象及其所有子对象,新对象和原对象完全独立。对象拷贝是通过调用对象的拷贝构造函数或克隆方法创建一个新的对象的副本。新对象与原对象是完全独立的,修改新对象不会影响到原对象。
实现深拷贝的方法?
深拷贝实现方式一:继承Cloneable重写clone方法
Java中的
clone()方法支持对象的复制,但默认仅实现了浅拷贝。如果要实现深拷贝,需要将对象实现Cloneable接口,并重写clone()方法。在clone()方法内部,将对象及其内部所有属性全部复制一份即可
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
class Person implements Cloneable {
private String name;
private List<String> hobbies;
// 深拷贝
public Person deepCopy() throws CloneNotSupportedException {
List<String> clonedHobbies = new ArrayList<>(this.hobbies);
return new Person(this.name, clonedHobbies);
}
}
public class 深拷贝 {
public static void main(String[] args) throws CloneNotSupportedException {
List<String> hobbies = new ArrayList<>();
hobbies.add("读书");
hobbies.add("游泳");
Person person1 = new Person("小明", hobbies);
Person person2 = person1.deepCopy();// 深拷贝
person2.setName("小刚");
System.out.println(person1);// copy.浅拷贝.Person@7f31245a
System.out.println(person2);// copy.浅拷贝.Person@6d6f6e28
System.out.println(person1 == person2); // 输出 false 说明引用地址不一样
System.out.println(person1.getName()); // 输出 小明
System.out.println(person2.getName()); // 输出 小明
// 修改person2的hobbies列表
person2.getHobbies().add("唱歌");
System.out.println(person1.getHobbies()); // 输出 [读书, 游泳]
System.out.println(person2.getHobbies()); // 输出 [读书, 游泳, 唱歌]
}
}深拷贝实现方式二:使用序列化
通过将对象序列化为字节流,再将字节流反序列化为新的对象,就可以实现深拷贝。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
class Person implements Serializable {
private String name;
private List<String> hobbies;
// 深拷贝
public Person deepCopy() throws IOException, ClassNotFoundException {
ByteArrayOutputStream byteOut = new ByteArrayOutputStream();
ObjectOutputStream objectOut = new ObjectOutputStream(byteOut);
objectOut.writeObject(this);
ByteArrayInputStream byteIn = new ByteArrayInputStream(byteOut.toByteArray());
ObjectInputStream objectIn = new ObjectInputStream(byteIn);
return (Person) objectIn.readObject();
}
}
public class 深拷贝 {
public static void main(String[] args) throws IOException, ClassNotFoundException {
List<String> hobbies = new ArrayList<>();
hobbies.add("读书");
hobbies.add("游泳");
Person person1 = new Person("小明", hobbies);
Person person2 = person1.deepCopy(); // 深拷贝
person2.setName("小刚");
System.out.println(person1); // 输出 copy.深拷贝.Person@7f31245a
System.out.println(person2); // 输出 copy.深拷贝.Person@6d6f6e28
System.out.println(person1 == person2); // 输出 false,说明引用地址不一样
System.out.println(person1.getName()); // 输出 小明
System.out.println(person2.getName()); // 输出 小刚
// 修改person2的hobbies列表
person2.getHobbies().add("唱歌");
System.out.println(person1.getHobbies()); // 输出 [读书, 游泳]
System.out.println(person2.getHobbies()); // 输出 [读书, 游泳, 唱歌]
}
}深拷贝实现方式三:使用工具
Spring包下的org.springframework.beans.BeanUtils.copyProperties();
1
2
3 public static void copyProperties(Object source, Object target) throws BeansException {
copyProperties(source, target, null, (String[]) null);
}Apeche包下的org.apache.commons.beanutils.BeanUtils.copyProperties();
1
2
3 public static void copyProperties(Object dest, Object orig) throws IllegalAccessException, InvocationTargetException {
BeanUtilsBean.getInstance().copyProperties(dest, orig);
}
说一说Java的四种引用方式?
强引用(Strong Reference)
强引用是最常见的引用类型,如果一个对象具有强引用,就表示垃圾回收器不会对其进行回收。即使内存空间不足时,垃圾回收器也不会回收被强引用关联的对象。
1 Object obj = new Object(); // 强引用软引用(Soft Reference)
软引用用于描述还有用但并非必需的对象。在系统内存不足时,垃圾回收器可能会回收软引用关联的对象来释放内存。可以使用
SoftReference类来创建软引用。
1 SoftReference<Object> softRef = new SoftReference<>(new Object()); // 软引用弱引用(Weak Reference)
弱引用用于描述非必需对象。无论系统内存是否充足,只要发生垃圾回收操作,垃圾回收器都会回收掉只被弱引用关联的对象。可以使用
WeakReference类来创建弱引用。
1 WeakReference<Object> weakRef = new WeakReference<>(new Object()); // 弱引用虚引用(Phantom Reference)
虚引用用于管理对象被垃圾回收器回收的时机。虚引用与其他引用类型的主要区别在于,无法通过虚引用访问对象,也无法通过虚引用获取对对象的引用。可以使用
PhantomReference类来创建虚引用。
1 PhantomReference<Object> phantomRef = new PhantomReference<>(new Object(), referenceQueue); // 虚引用四种引用的区别
引用类型 垃圾回收行为 生存时间 用途 强引用 (Strong Reference) 不会被回收 长期存在,直到引用被显式释放 主要引用类型,常用于对象的正常引用 软引用 (Soft Reference) 在内存不足时可能被回收 长期存在,直到内存不足时回收 用于缓存或者需要某些操作前的临时引用 弱引用 (Weak Reference) 在垃圾回收时可能被回收 长期存在,直到没有强引用 用于实现缓存、监控和弱关联等功能 虚引用 (Phantom Reference) 在垃圾回收时可能被回收 一旦被垃圾回收器处理完毕即消失 用于对象被回收前的清理工作或跟踪回收状态
什么是序列化与反序列化?
在 Java 中,序列化和反序列化是一种用于对象与字节流互相转换,以便存储或传输的机制。
- 序列化:对象—>字节序列
- 反序列化:字节序列—>对象
什么情况需要序列化与反序列化?
当 Java 对象需要在网络传输或持久化存储到文件中时,需要序列化
- 网络传输:当需要将 Java 对象在网络上进行传输时,可以使用序列化将对象转换为字节流,然后通过网络发送给接收方。例如,在客户端和服务器之间进行通信时,可以将请求或响应对象序列化并通过网络传输。这样,对象的信息就可以在不同的计算机或进程之间传递,实现分布式系统的协作。
- 持久化存储:当需要将 Java 对象保存到磁盘或数据库中以供以后使用时,可以使用序列化将对象写入文件或数据库字段。序列化后的对象可以被存储,稍后可以从存储位置读取并恢复为原始对象。这种持久化存储方式适用于需要长期保留对象状态的情况,比如保存用户配置、缓存数据、日志等。
序列化与反序列化有什么前提?
在 Java 中,让一个对象可序列化与反序列化,需要满足以下要求:
- 实现
Serializable接口:将要序列化的类实现Serializable接口,Serializable接口是 Java 提供的一个标记接口,接口中没有任何方法定义,用于标识一个类可以被序列化和反序列化。- 提供
private static final long serialVersionUID常量:这个常量用于表示类的版本号,它在反序列化时用于判断对象和字节流之间的兼容性。如果没有显式地定义该字段,Java 编译器会自动生成一个版本号。- 内部所有属性也必须是可序列化的:将类的内部所有属性都标记为可序列化,这意味着它们要么是基本数据类型(如
int、boolean等),要么是实现了Serializable接口的类。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
class Person implements Serializable {
private static final long serialVersionUID = 0;
Long id;
String name;
transient int age;
}
class SerializationExample {
public static void main(String[] args) {
// 创建一个Person对象
Person person = new Person(1L, "Alice", 25);
// 将对象序列化到文件
serializeObject(person, "person.ser");
// 从文件中反序列化对象
Person deserializedPerson = (Person) deserializeObject("person.ser");
// 打印反序列化后的对象信息
System.out.println("名字: " + deserializedPerson.getName());
System.out.println("年龄: " + deserializedPerson.getAge());
}
// 序列化对象到文件
private static void serializeObject(Object obj, String filename) {
try {
FileOutputStream fileOut = new FileOutputStream(filename);
ObjectOutputStream out = new ObjectOutputStream(fileOut);
out.writeObject(obj);
out.close();
fileOut.close();
System.out.println("序列化的对象并保存到:" + filename);
} catch (IOException e) {
e.printStackTrace();
}
}
// 从文件中反序列化对象
private static Object deserializeObject(String filename) {
try {
FileInputStream fileIn = new FileInputStream(filename);
ObjectInputStream in = new ObjectInputStream(fileIn);
Object obj = in.readObject();
in.close();
fileIn.close();
System.out.println("反序列化的对象: " + filename);
return obj;
} catch (IOException | ClassNotFoundException e) {
e.printStackTrace();
return null;
}
}
}
如果某些数据不想序列化与反序列化怎么办?
方式一:使用transient关键字
在Java中,
transient是一个关键字,用来修饰类的成员变量。当一个变量被声明为transient时,它表示该变量不参与对象的序列化过程,将被跳过,不会被序列化,通常用于一些敏感信息或者不需要被序列化和传输的数据
1
2
3
4 public class Person implements Serializable {
private static final long serialVersionUID = 1L;
private String name; // 使用transient关键字标识name字段不能被序列化与反序列化
}方式二:自定义序列化方式
可以实现Serializable接口,并在其中自定义序列化和反序列化过程,通过编写writeObject()和readObject()方法来控制序列化和反序列化的行为,在这些方法中可以决定哪些数据要被序列化,哪些数据要被忽略。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Person implements Serializable {
private static final long serialVersionUID = 1L;
private String name;
// 自定义序列化方法
private void writeObject(ObjectOutputStream out) throws IOException {
out.defaultWriteObject(); // 默认的序列化操作
out.writeObject(name.toUpperCase()); // 将 name 字段以大写形式写入输出流
}
// 自定义反序列化方法
private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
in.defaultReadObject(); // 默认的反序列化操作
name = ((String) in.readObject()).toLowerCase(); // 从输入流中读取字符串,并将其转换为小写形式
}
}方式三:使用Externalizable接口自定义序列化方式
Externalizable接口是Java提供的一个用于实现自定义序列化的接口,相比Serializable接口更加灵活,使用步骤如下
- 在类上实现
Externalizable接口,并实现writeExternal()和readExternal()方法。- 在
writeExternal()方法中定义需要序列化的成员变量。- 在
readExternal()方法中定义如何从流中恢复成员变量的值。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Person implements Externalizable {
private String name;
public void writeExternal(ObjectOutput out) throws IOException {
// 自定义序列化过程,在这里定义哪些成员变量需要序列化
out.writeObject(name);// // 将name字段手动写入到输出流中
}
public void readExternal(ObjectInput in) throws IOException, ClassNotFoundException {
// 自定义反序列化过程,在这里恢复成员变量的值
name = (String) in.readObject();// // 从输入流中手动读取name字段,并赋值给对象的相应字段
}
}
Java集合相关面试题
有数组为什么还要有集合?
在Java中,数组(Array)是一种固定长度、连续的数据结构,用于保存多个相同类型的对象或数据。数组(Array)在创建时长度就固定,无法动态改变长度,如果需要改变数组的长度,需要通过数组拷贝的方式操作,不太方便。
1 int[] array = {1, 2, 3, 4, 5};为了解决数组长度固定和操作不便的问题,Java提供了一些集合类用于存储、操作和处理数据集合,这些集合类属于Java集合框架(Collections Framework),相比数组,集合具有以下优势:
- 动态改变长度:集合的长度是动态可变的,可以根据需要自动扩展或缩小。
- 提供丰富的操作方法:集合类提供了许多方便的方法来添加、删除、查找、遍历等操作,简化了对集合的操作。
- 支持泛型:集合类支持泛型,可以在编译时强制类型检查,提高代码的安全性和可读性。
- 提供迭代器:集合类提供了迭代器(Iterator)来遍历集合元素,可以方便地进行循环操作。
- 内置算法和排序:集合类内置了各种算法和排序方法,可以方便地对集合元素进行排序、查找和比较等操作。
Java集合框架的基础接口有哪些?
在 Java 集合框架中,有两个基本的根接口,分别是
Collection单列集合接口和Map双列集合接口。Collection单列集合接口
Collection接口是存储单列集合对象的根接口,其中包括List、Set和Queue接口
- List接口:单列有序集合,存储
有序、可重复的对象,对象按插入顺序有序排列,元素可重复。常用的实现类有ArrayList、LinkedList、Vector- Set接口:单列无序集合,存储
无序、不可重复的对象,对象按一定规则无序排列,元素不可重复。常用的实现类有HashSet、LinkedHashSet、TreeSet- Queue接口:队列,存储
有序、可重复的对象,对象按照先进先出(FIFO)的原则进行操作。常用的实现类有:ArrayDeque、ArrayBlockingQueue、PriorityQueueMap双列集合接口
- Map接口:双列集合,存储键值对映射,键和值可以是任意类型的对象,可以根据键(key)来获取对应的值(value)。常用的实现类有HashMap、LinkedHashMap、TreeMap、Hashtable、Properties
这些接口常见的实现类有哪些?
List接口常见实现类
单列有序集合,存储
有序、可重复的对象,对象按插入顺序有序排列,元素可重复。常用的实现类有ArrayList、LinkedList、Vector
名称 底层数据结构 特点 线程安全性 ArrayList 数组 查询快(支持随机访问),增删慢 不安全 LinkedList 双向链表 查询慢,增删快(支持快速插入和删除) 不安全 Vector 数组 查询快,增删慢,线程安全 安全 Set接口常见实现类
单列无序集合,存储
无序、不可重复的对象,对象按一定规则无序排列,元素不可重复。常用的实现类有HashSet、LinkedHashSet、TreeSet
名称 底层数据结构 特点 线程安全性 HashSet 哈希表(数组+链表/红黑树) 不允许重复元素,不保证元素顺序 不安全 LinkedHashSet 哈希表(数组+双向链表) 不允许重复元素,但使用双向链表维护元素插入顺序 不安全 TreeSet 红黑树 不允许重复元素,元素可以自然顺序或指定Comparator排序 不安全 Queue接口常见实现类
队列,存储
有序、可重复的对象,对象按照先进先出(FIFO)的原则进行操作。常用的实现类有:ArrayDeque、ArrayBlockingQueue、PriorityQueue
名称 底层数据结构 特点 线程安全性 ArrayDeque 数组 双端队列,支持在队头和队尾进行元素操作 不安全 ArrayBlockingQueue 数组 有界阻塞队列,支持并发操作具有固定容量限制 安全 PriorityQueue 堆(数组/树) 优先级队列,按照元素的优先级进行排序 不安全 Map接口常见实现类
双列集合,存储键值对映射,键和值可以是任意类型的对象,可以根据键(key)来获取对应的值(value)。常用的实现类有HashMap、LinkedHashMap、TreeMap、Hashtable、Properties
名称 底层数据结构 特点 线程安全性 HashMap 哈希表(数组+链表/红黑树) 存储键值对映射,通过键的哈希值快速查找对应的值 不安全 LinkedHashMap 哈希表(数组+双向链表) 存储键值对映射,在HashMap的基础上使用双向链表维护键值对的插入顺序 不安全 TreeMap 红黑树 存储键值对映射,元素可以按照键自然顺序或指定Comparator排序 不安全 Hashtable 哈希表(数组+链表) 存储键值对映射,内部使用synchronized关键字实现同步 安全 Properties 哈希表(数组+链表) 存储键值对映射,继承自Hashtable类,主要用于处理属性文件 安全
List、Set、Map 之间的区别是什么?
List接口
继承Collection接口,单列有序集合接口
允许重复的对象
具有顺序性,可以按照元素的插入顺序或索引进行访问。
常见实现类:ArrayList、LinkedList 和 Vector
Set接口
继承Collection接口,单列无序集合接口
不允许重复元素,每个元素在Set中是唯一的。
不保证元素的顺序性,即无法按照特定顺序访问元素。
常见实现类:HashSet、LinkedHashSet、TreeSet
Map接口
不继承Collection接口,双列集合接口
存储键值对的映射关系,可以根据键快速查找值
允许存在相同的值,但不允许存在相同的键。
常见实现类:HashMap、TreeMap、HashTable
数组(Array) 和 列表(List) 如何转换?
Array 转 List
数组(Array) 转 列表(List),使用
JDK提供的java.util.Arrays工具类中的asList()方法
1
2
3
4
5
6
7 class Demo {
public static void main(String[] args) {
String[] array = {"apple", "banana", "orange"};
List<String> list = Arrays.asList(array);
System.out.println(list);
}
}List 转 Array
列表(List) 转 数组(Array) ,使用
List接口中提供的toArray()方法
1
2
3
4
5
6
7
8
9
10
11
12 class Demo {
public static void main(String[] args) {
List<String> list = new ArrayList<>();
list.add("apple");
list.add("banana");
list.add("orange");
String[] array = list.toArray(new String[0]);
for (String s: array) {
System.out.println(s);
}
}
}
ArrayList的底层实现原理?
- ArrayList底层是用 Object 类型的动态数组来存储元素。
- 当创建ArrayList集合时,ArrayList默认创建一个容量为0的空数组。
- 当第一次向 ArrayList 中添加元素时,会将空数组初始化为容量为10的数组。
- 后续向 ArrayList 中添加元素时,会先检查当前数组是否已满,如果数组未满,则直接将元素添加到数组的末尾。
- 如果数组已满,会触发扩容机制创建一个新数组(新数组的容量是旧集合容量的1.5倍),然后将旧数组中的元素拷贝到新数组中。
- 如果扩容时数组容量是偶数,那么新容量就是旧容量的1.5倍;
- 如果扩容时数组容量是奇数,会先将数组容量右移一位,然后再乘以1.5,得到新容量。
- 虽然扩容过程中数组的容量会逐渐增大,但并不会无限增大,不能超过MAX_ARRAY_SIZE(约为2^31-1)
ArrayList扩容是怎么实现的?
ArrayList在添加元素时,如果达到了数组容量,会进行扩容操作
- 初始化数组容量:在创建ArrayList对象时,如果没有指定初始容量,默认会创建一个长度为10的数组。
- 添加元素触发扩容:每当向ArrayList中添加元素时,都会检查当前元素数量是否已经达到了数组的容量。如果达到了数组容量,则触发扩容操作。
- 创建新数组:一旦需要进行扩容,ArrayList会创建一个更大的新数组。新数组的大小通常是原始容量的1.5倍(即原始容量的*3/2),这样可以提供更多的空间存储元素,并减少频繁扩容的次数。
- 将元素拷贝到新数组:在扩容阶段,ArrayList会将原数组中的元素拷贝到新数组中。这个过程通过使用
System.arraycopy()方法或者循环遍历原数组并逐个拷贝来实现。- 更新引用和容量:拷贝完成后,ArrayList会更新内部的数组引用指向新的数组,并更新容量信息。这样,后续的操作就可以使用新的数组以及更新后的容量信息了。
ArrayList 和 LinkedList 有什么区别?
数据结构:ArrayList数据结构是数组;LinkedList数据结构是双向链表
访问效率:ArrayList访问效率高于LinkedList。数组通过下标方法,如果已经知道下标进行访问,时间复杂度为O(1);链表如果有序可以使用二分法查找时间复杂度是O(log n),如果链表无序,需要遍历查找元素,那么时间复杂度会降为O(n)
增删效率:ArrayList增删效率低于LinkedList。数组如果不是尾插法,增删需要左右移动数组,效率低;链表增删只需要修改记录指针,效率高
适用场景:ArrayList适用于频繁读取数据的场景;LinkedList适用于频繁插入和删除元素的场景
ArrayList 和 Vector 有什么区别?
ArrayList 和 Vector 都是List单列有序集合的接口实现类
- 线程安全性:ArrayList线程不安全;Vector线程安全
- 性能对比:ArrayList性能比Vector高,因为Vector中的大部分方法会加同步锁,有开销
HashSet 的底层实现原理?
HashSet的底层实现原理是基于HashMap,HashSet实际上是使用了HashMap的key来存储元素,特点如下:
- HashSet是一个无序的集合,不允许存储重复元素,只允许存储一个 null 值
- 插入、删除、查找等操作的时间复杂度为O(1),具有较高的性能。
HashSet的底层实现利用了HashMap的键值对存储结构,但是在HashSet中只使用了HashMap的key部分。具体实现步骤如下:
- 创建一个底层是HashMap的HashSet对象。
- 当向HashSet中插入一个元素时,实际上是以该元素作为HashMap的键(key),而将一个固定的Object对象作为HashMap的值(value)进行存储。
- 当需要判断HashSet中是否存在某个元素时,实际上是通过判断该元素作为HashMap的键(key)在HashMap中是否存在来进行判断。
- HashSet中的元素存储是无序的,因为它是基于HashMap实现的,HashMap中的元素顺序是根据哈希算法得出的。
HashSet 和 LinkedHashSet 有什么区别?
HashSet和LinkedHashSet都是Java集合框架中的Set接口的实现类,都不允许元素重复,都不是线程安全的
- 数据结构:HashSet的底层实现原理是基于HashMap,底层数据结构是哈希表(数组+链表/红黑树);LinkedHashSet继承于HashSet,但数据结构使用哈希表(数组+双向链表)
- 存储顺序:HashSet是无序的集合,不保证元素的存储顺序;而LinkedHashSet是有序的集合,使用双向链表维护元素的插入顺序。
- 性能比较:HashSet的插入、删除和查找操作的时间复杂度为O(1);LinkedHashSet在HashSet的基础上增加了维护插入顺序的操作,相对慢一些
HashSet 和 TreeSet 有什么区别?
HashSet 和 TreeSet 都是Java集合框架中的Set接口的实现类,都不允许元素重复,都不是线程安全的
- 数据结构:HashSet的底层实现原理是基于HashMap,底层数据结构是哈希表(数组+链表/红黑树);TreeSet使用红黑树作为底层数据结构来存储元素。
- 存储顺序:HashSet是无序的集合,不保证元素的存储顺序;TreeSet是有序的集合,根据元素的自然顺序或自定义排序规则进行排序。
- 存储Null值:HashSet允许存储一个Null值,TreeSet不允许存储Null值
HashMap的底层实现原理?
- HashMap的底层实现原理是使用了哈希表(Hash Table),通过 put(key,value)存储元素,get(key)来获取元素。
- 当传入一个键(key)时,HashMap 会调用键对象的hashCode()方法(key.hashCode())计算出哈希码(hash code),根据哈希码(hash code)将值(value)保存在桶(bucket) 里。
- 当计算出的哈希码(hash code)相同时,称之为 hash 冲突,HashMap 的做法是用链表和红黑树存储相同哈希码(hash code)的值(value)。当 hash 冲突的个数比较少时,使用链表,当链表长度大于8时使用红黑树。
HashMap底层数据结构?
JDK1.7:底层数据结构使用数组+链表,当发生哈希冲突时,会使用链表存储键值对,但是某些情况链表过长导致性能下降。
JDK1.8:底层数据结构使用数组+链表或红黑树,当链表长度超过某个阈值(默认为 8)且哈希桶数组长度达到某个阈值(默认为 64)时,会将链表转变为红黑树,解决了链表太长导致性能下降的问题。
HashMap为什么用红黑树?
在早期的版本中,HashMap 在解决哈希冲突(即多个键映射到同一个桶位置)时,会将相同哈希值的键值对存放在链表中来解决哈希冲突。
当链表长度较短时,使用链表进行存储是高效的。但是,当链表长度过长时,查找操作的时间复杂度将接近 O(n),其中 n 是链表的长度。
为了避免出现链表过长的情况,JDK 1.8 引入了红黑树来取代链表,将查找操作的时间复杂度由 O(n) 降低到 O(log n)。
红黑树是一种自平衡的二叉搜索树,它的特点是保持树的平衡性,从而保证了在最坏情况下,查找、插入和删除操作的时间复杂度都是 O(log n)。
当链表长度超过某个阈值(默认为 8)且哈希桶数组长度达到某个阈值(默认为 64)时,,HashMap 会将链表转换为红黑树,以提高操作效率。
当红黑树的节点数少于某个阈值(默认为 6)且哈希桶数组长度大于某个阈值(默认为 64)时,又会将红黑树转换回链表,以节省内存空间。
HashMap为什么不使用其他数据结构?
HashMap选择使用链表和红黑树作为桶中存储键值对的数据结构,是基于对性能和实现复杂度的综合考虑。
红黑树
红黑树是一种自平衡的二叉搜索树,能够保持较好的平衡性和高效的插入、删除、查找操作。在插入和删除操作时,会对节点重新染色(红色或黑色)和旋转操作(左旋、右旋、左右旋和右左旋)来保持树的平衡。红黑树需要满足以下规则:
- 每个节点要么是红色,要么是黑色。
- 根节点必须是黑色。
- 每个叶子节点(NIL 或空节点)是黑色。
- 如果一个节点是红色,那么它的两个子节点都是黑色。
- 对于每个节点,从该节点到其子孙节点的所有路径上包含相同数量的黑色节点。
平衡二叉树(AVL Tree)
平衡二叉树(AVL Tree)是一种自平衡的二叉搜索树,引入了平衡因子的概念,当插入或删除一个节点后,如果导致某个节点的平衡因子的绝对值(左子树高度减右子树高度)大于1,就表示该节点失去了平衡,会采用旋转操作(左旋、右旋、左右旋和右左旋)来进行平衡调整。平衡二叉树具有良好的平衡性,能够在最坏情况下保证 O(log n) 的查找、插入和删除操作。
平衡性要求:红黑树和AVL树都具有良好的平衡性,以保持树的高度较低,从而提供较快的查找、插入和删除操作。但是,AVL树在平衡性上更为严格,它要求左子树和右子树的高度差(平衡因子)不超过1,因此在插入和删除节点时需要更多的调整操作。红黑树则放松了这个要求,允许左右子树的高度差最多为2,通过颜色标记和旋转操作来保持平衡。
插入和删除性能:如果插入和删除操作比查询操作频繁且持续进行,平衡二叉树(AVL Tree)为了平衡树,可能需要进行大量的旋转操作,会导致性能下降。红黑树在插入和删除时需要的旋转操作相对较少,因此在插入和删除元素时,红黑树的性能相对更优。
查询性能:在大部分情况下,红黑树和AVL树在查找操作的性能上并没有明显的差异。但由于AVL树的严格平衡性,某些情况查找性能可能略优于红黑树。
B树和B+树
B树和B+树是多路搜索树,对于磁盘等存储介质的访问有很好的优化。它们能够减少磁盘I/O的次数,提高访问性能。然而,在内存中的数据结构中使用B树和B+树并不是一个必要选择,因为它们引入了额外的指针和存储开销,对于内存中的存储结构来说,相对于红黑树而言,这种开销可能是不必要的。
- 数据结构复杂度:红黑树是一种自平衡二叉搜索树,而B树和B+树是多路搜索树。B树和B+树相对来说实现较为复杂,需要考虑节点分裂、合并等操作的细节。而红黑树的实现相对简单,容易理解和维护。
- 适应场景:B树和B+树适用于需要在磁盘或其他外部存储介质上存储大量数据的场景,其中涉及到磁盘IO操作。而HashMap通常用于内存中的数据结构,对于小规模的数据集合来说,红黑树已经足够满足性能和空间的需求。
- 存储效率:在数据量不是很多的情况下,使用B树或B+树可能会导致部分节点存储过于稀疏,比如都存在一个节点上,这个时候遍历效率就退化成了链表,时间复杂度较高。红黑树在平均情况下具有较好的查找、插入和删除操作的时间复杂度,都为O(log n),其中n为元素数量。
HashMap为什么链表大于8才进行树化?
为了避免在链表长度较小的情况下,额外的树结构转换带来的开销。红黑树需要进行左旋,右旋,变色这些操作来保持树平衡,而单链表不需要。
链表长度小于8时,性能大于红黑树,没必要树化;
链表长度大于8时,性能会不如红黑树;
所以使用8刚刚好。
HashMap扩容是怎么实现的?
HashMap在添加键值对时,如果达到了扩容阈值,会进行扩容操作
- 初始化数组容量:在创建HashMap对象时,如果没有指定初始容量,默认会创建一个
长度是16的数组。- 添加元素触发扩容:每当向HashMap中添加元素时,都会检查当前元素数量是否已经达到了扩容阈值,扩容阈值是当前容量(当前数组长度)与负载因子(默认为0.75)的乘积,如果达到了阈值,就会触发扩容操作。
- 创建新数组:一旦需要进行扩容,HashMap会创建一个更大的新数组。新数组的大小通常是
原始容量的两倍。这样可以提供更多的槽位用于存储元素,减少哈希冲突的概率,并保持HashMap的性能。- 重新哈希化元素:在扩容阶段,HashMap会遍历原数组中的每个元素,并重新计算它们的哈希值和在新数组中的位置。元素的哈希值通过与新数组大小减一的位与操作,得到在新数组中的索引位置。然后将元素放置到新数组的相应位置上。
- 处理冲突:在重新计算元素的哈希值和重新放置元素的过程中,可能会出现多个元素计算得到相同的新索引位置,即出现哈希冲突。为了解决冲突,HashMap
使用链表或红黑树来解决哈希冲突,将相同索引位置上的元素以链表或红黑树的方式存储起来,并保持它们在新数组中的相对顺序。- 更新引用:最后,在重新哈希化元素完成之后,HashMap会将内部的数组引用指向新的数组。这样,后续的操作就可以使用新的数组了。旧的数组会被垃圾回收机制回收,释放内存空间。
HashMap是怎么解决哈希冲突的?
当多个元素计算得到相同的哈希值并且它们被映射到了同一个数组索引位置时,就会发生哈希冲突。HashMap使用链表和红黑树来解决哈希冲突。
- 链表解决冲突:在早期版本的HashMap中,使用链表来解决冲突。当发生哈希冲突时,新元素会被添加到该索引位置的链表尾部。这样,相同索引位置上的多个元素就会通过链表连接起来。遍历链表来查找元素的时间复杂度为O(n),其中n是链表的长度。
- 转换为红黑树:当链表长度超过阈值(默认为8)时,链表会自动转换为红黑树,以提高查询和插入操作的性能。红黑树是一种自平衡的二叉搜索树,保证了在最坏情况下的时间复杂度为O(log n),其中n是树中节点的数量。
HashMap 的长度为什么是 2 的幂次方?
HashMap的长度选择为2的幂次方,一方面可以提高存取效率,另一方面可以减少哈希碰撞的概率。
数据结构哈希表工作原理是通过将关键字(Key)输入哈希函数,得到一个对应的哈希值,再将哈希值映射为数组的索引位置,以便能够快速查找、插入和删除元素。
- 输入关键字(Key):将要存储的元素的关键字输入到哈希函数中。
- 哈希函数计算哈希值:哈希函数根据关键字(Key)计算出一个对应的哈希值。
- 映射为索引位置:通过对哈希值进行适当的转换映射,将哈希值映射为数组中的索引位置。
- 存储和处理冲突:如果多个关键字产生了相同的哈希值,这就是哈希冲突。在哈希表中,通常有多种方法来处理冲突,例如链地址法、开放地址法等。
- 插入、查找、删除元素:一旦元素的哈希值被映射为数组的索引位置,我们可以直接在该位置上执行插入、查找和删除操作。
注意细节:通过哈希函数计算出的哈希值的范围是-2147483648 到 2147483647,也就是说大概有 40 亿的映射空间。如果直接将 40 亿的哈希值都映射为数组中的索引,只要映射得比较均匀松散,一般很难出现碰撞(哈希冲突)。但可惜的是内存不能加载 40 亿长度的数组,所以哈希值不能直接进行映射,因此需要将哈希值范围限制在合适的数组索引范围内。一般是通过对哈希值进行取模运算来限制范围,取模运算的除数通常是数组的长度,得到的余数作为最终的索引位置。
在HashMap中,取模运算是通过
(n - 1) & hash来计算的,其中n代表数组长度,hash 是经过哈希函数计算得到的哈希值。当数组长度为2的幂次方时,取模运算(n - 1) & hash可以等价于传统的取模运算hash % length。使用%(传统的取模操作符)取模运算时需要除法操作,计算量较大,使用&(按位与操作符)取模运算只需对二进制数进行按位与操作,可以提高计算的效率。
HashMap中的put()方法
- 首先,
put()方法接收一个键和一个值作为参数。- 传入的键会通过
hashCode()方法获取键的哈希值,用于确定键值对在HashMap内部数组中的位置。- 如果该位置还没有任何元素,则直接将键值对存储在该位置。
- 如果该位置已经有元素存在(发生了哈希冲突),则遍历该位置上的链表或红黑树,找到具有相同键的节点。
- 如果找到具有相同键的节点,则更新该节点的值为新的值,并返回旧的值。
- 如果未找到具有相同键的节点,则将新的键值对添加到链表或红黑树的末尾,根据需要转换链表为红黑树。
- 如果插入后,链表长度超过了阈值(默认为8),则将链表转换为红黑树,以提高查找效率。
- 如果红黑树的节点数量达到了树化阈值(默认为6),则进行树化操作,将红黑树转换为一个更高效的结构。
- 如果HashMap的元素数量超过扩容阈值,将进行扩容操作,重新计算键值对的位置。
HashMap中的get()方法
- 根据传入的键,调用
hashCode()方法获取键的哈希值- 根据哈希值,计算出在HashMap内部数组中的索引位置,在确定的索引位置上查找元素
- 如果该索引位置没有元素,则返回null,表示未找到对应的值。
- 如果该索引位置有元素,首先检查该位置上的元素是否与传入的键相等(使用
equals()方法进行比较)。- 如果传入的键与该位置上的键相等,说明找到了对应的值,HashMap会返回该键对应的值。
- 如果传入的键与该位置上的键不相等,可能存在哈希冲突,即该位置上的链表或红黑树中可能存在具有相同键但不同值的节点。
- 存在哈希冲突HashMap会遍历链表或红黑树,查找具有相同键的节点,如果找到则返回该节点的值。
- 如果遍历完链表或红黑树仍未找到具有相同键的节点,则返回null,表示未找到对应的值。
HashMap 和 Hashtable 有什么区别?
HashMap 和 Hashtable 底层数据结构都是哈希表,都实现了Map接口
- 线程安全性:HashMap线程不安全,Hashtable线程安全
- 性能对比:由于 Hashtable 是线程安全的,它的操作需要进行同步处理,在多线程环境下带来一定的性能开销
- 存储Null值:HashMap 的键值允许存储一个 null 值,Hashtable 键值不允许存储 null 值
HashMap 和 TreeMap 有什么区别?
HashMap 和 TreeMap 都是线程不安全的,都实现了Map接口
- 数据结构不同:HashMap数据结构是哈希表(数组+链表/红黑树),TreeMap数据结构是红黑树
- 性能对比:HashMap 的插入、删除和查找操作的时间复杂度为O(1);TreeMap 的插入、删除和查找操作的时间复杂度为O(log n);
- 元素排序:HashMap 不保证键值对的顺序;TreeMap会根据键的自然排序或者自定义的比较器对键值对进行排序;
- 存储Null值:HashMap 的键值允许存储一个 null 值,Hashtable 键值不允许存储 null 值
HashMap 和 HashSet 有什么区别?
HashMap 和 HashSet 都是线程不安全的,底层数据结构都是哈希表。
- 实现接口不同:HashMap实现了Map双列集合接口;HashSet实现的是Set单例无序集合接口
- 实现方式不同:HashMap内部使用哈希表(数组+链表/红黑树)实现;HashSet内部是基于HashMap实现,将元素作为键(key),将值(value)固定为一个常量对象(通常为PRESENT或者null)。
- 存储数据不同:HashMap存储的是键值对;HashSet存储的是对象
- 元素访问方式:HashMap可以通过键来获取对应值,也可以遍历所有键值对;HashSet可以通过迭代器或者增强型for循环来遍历所有元素。
- 元素唯一性:HashMap可以存储重复的值,但键必须是唯一的;HashSet 由于使用 HashMap 的键(key)存储元素,所以 HashSet 不能存放重复元素。
怎么确保一个集合不被修改?
使用不可变集合
不可变集合是指创建后不能被修改的集合。Java 中的
java.util.Collections类提供了一系列静态方法,用于创建不可变版本的集合,例如Collections.unmodifiableList()、Collections.unmodifiableSet()和Collections.unmodifiableMap()。这些方法返回一个只读视图,任何对其进行修改的操作都会抛出UnsupportedOperationException异常。
1
2
3
4
5 List<String> originalList = new ArrayList<>();
originalList.add("item1");
originalList.add("item2");
List<String> unmodifiableList = Collections.unmodifiableList(originalList);
unmodifiableList.add("item3"); // 会抛出 UnsupportedOperationException 异常使用只读接口实现
可以定义自己的接口,只提供读取数据的方法,而不包含修改数据的方法。然后,通过实现该接口的类来创建集合对象,只暴露只读方法。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22 public interface ReadOnlyCollection<E> {
List<E> getList(); // 只读方法
}
public class MyCollection<E> implements ReadOnlyCollection<E> {
private List<E> list;
public MyCollection(List<E> list) {
this.list = list;
}
public List<E> getList() {
return Collections.unmodifiableList(list); // 返回不可修改的集合
}
}
List<String> originalList = new ArrayList<>();
originalList.add("item1");
originalList.add("item2");
ReadOnlyCollection<String> readOnlyCollection = new MyCollection<>(originalList);
readOnlyCollection.getList().add("item3"); // 会抛出 UnsupportedOperationException 异常
哪些集合类是线程安全的?
Vector、Hashtable、Properties是线程安全的
- Vector:底层数组,线程安全,所有方法都是同步的
- Hashtable:底层哈希表(数组+链表) ,线程安全,所有方法都是同步的
- Properties:底层哈希表(数组+链表) ,Properties类继承自Hashtable类,主要用于处理属性文件
集合是否允许Null值?
List接口实现类ArrayList、LinkedList、Vector
- ArrayList:允许存储 null 值,可以添加一个或多个 null 元素
- LinkedList:允许存储 null 值,可以添加一个或多个 null 元素。
- Vector:允许存储 null 值,可以添加一个或多个 null 元素。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17 public class ListExample {
public static void main(String[] args) {
// List接口实现类ArrayList、LinkedList、Vector
List<String> arrayList = new ArrayList<>();
List<String> linkedList = new LinkedList<>();
List<String> vector = new Vector<>();
arrayList.add(null);
arrayList.add(null);
linkedList.add(null);
linkedList.add(null);
vector.add(null);
vector.add(null);
System.out.println("ArrayList: " + arrayList); // 输出结果:ArrayList: [null, null]
System.out.println("LinkedList: " + linkedList); // 输出结果:LinkedList: [null, null]
System.out.println("Vector: " + vector); // 输出结果:Vector: [null, null]
}
}Set接口实现类HashSet、LinkedHashSet、TreeSet
- HashSet:只允许存储一个 null 值。
- LinkedHashSet:只允许存储一个 null 值。
- TreeSet:不允许存储 null 值,会抛出
NullPointerException异常。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17 public class SetExample {
public static void main(String[] args) {
// Set接口实现类HashSet、LinkedHashSet、TreeSet
Set<String> hashSet = new HashSet<>();
Set<String> linkedHashSet = new LinkedHashSet<>();
Set<String> treeSet = new TreeSet<>();
hashSet.add(null);
hashSet.add(null);
linkedHashSet.add(null);
linkedHashSet.add(null);
// treeSet.add(null); // 会抛出java.lang.NullPointerException异常
// treeSet.add(null); // 会抛出java.lang.NullPointerException异常
System.out.println("HashSet: " + hashSet); // 输出结果:HashSet: [null]
System.out.println("LinkedHashSet: " + linkedHashSet); // 输出结果:LinkedHashSet: [null]
System.out.println("TreeSet: " + treeSet); // 输出结果:TreeSet: []
}
}Map接口实现类HashMap、LinkedHashMap、Hashtable、TreeMap
- HashMap:允许使用 null 作为键(key)和值(value),对于键(key),只能有一个为null;对于值(value),可以有多个为null。
- LinkedHashMap:允许使用 null 作为键(key)和值(value),对于键(key),只能有一个为null;对于值(value),可以有多个为null。
- TreeMap:不允许存储 null 值作为键(key),但可以存储一个或多个 null 值作为值(value)
- HashTable:不允许使用 null 作为键(key)和值(value),如果任意一个为null的话,会抛出
NullPointerException异常。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29 public class MapExample {
public static void main(String[] args) {
// Map接口实现类HashMap、LinkedHashMap、Hashtable、TreeMap
HashMap<String, String> hashMap = new HashMap<>();
LinkedHashMap<String, String> linkedHashMap = new LinkedHashMap<>();
Hashtable<String, String> hashtable = new Hashtable<>();
Map<String, String> treeMap = new TreeMap<>();
hashMap.put(null, "value1");
hashMap.put(null, "value2");
hashMap.put("key1", null);
hashMap.put("key2", null);
linkedHashMap.put(null, "value1");
linkedHashMap.put(null, "value2");
linkedHashMap.put("key1", null);
linkedHashMap.put("key2", null);
// hashtable.put(null, "value1"); // 会抛出java.lang.NullPointerException异常
// hashtable.put(null, "value2"); // 会抛出java.lang.NullPointerException异常
// hashtable.put("key1", null); // 会抛出java.lang.NullPointerException异常
// hashtable.put("key2", null); // 会抛出java.lang.NullPointerException异常
// treeMap.put(null, "value1"); // 会抛出java.lang.NullPointerException异常
// treeMap.put(null, "value2"); // 会抛出java.lang.NullPointerException异常
treeMap.put("key1", null);
treeMap.put("key2", null);
System.out.println("HashMap: " + hashMap); // 输出结果:HashMap: {null=value2, key1=null, key2=null}
System.out.println("LinkedHashMap: " + linkedHashMap); // 输出结果:LinkedHashMap: {null=value2, key1=null, key2=null}
System.out.println("Hashtable: " + hashtable); // 输出结果:Hashtable: {}
System.out.println("TreeMap: " + treeMap); // 输出结果:TreeMap: {key1=null, key2=null}
}
}
Comparable 和 Comparator 有什么区别?
Comparable和Comparator是Java中用于排序的两个接口
- Comparable接口:是在对象自身内部定义的比较规则,只能定义一种比较方式,并且是不可更改的。
- Comparator接口:是一个外部比较器,可以定义多个不同的比较规则,并且可以在不修改对象类的情况下进行扩展和排序。
Collection 和 Collections 有什么区别?
Collection是一个接口,Collections是一个工具类。
- Collection接口:Collection是Java集合框架中定义的顶级接口,代表一组对象的集合。
- Collections工具类:Collections是Java中的一个实用类,提供了一系列静态方法来操作和处理集合对象。
说一说Iterator迭代器接口?
Iterator迭代器主要用于遍历Collection 集合中的元素,Collection接口继承了Iterator迭代器
Iterator迭代器是个接口,无法直接使用,一般会使用foreach增强for循环遍历集合,内部原理其实是个iterator迭代器
什么是Fail-Fast和Fail-Safe机制?
Fail-Fast和Fail-Safe是两种常见的错误处理机制,用于处理在并发环境下对集合进行操作时可能发生的并发修改错误。Fail-Fast是Java集合框架的默认策略,而Fail-Safe则是一些特定集合的策略。
- 快速失败(fail-fast):Fail-Fast机制是指当多个线程对集合进行并发修改操作时,如果检测到集合已经被修改,迭代器立即抛出
ConcurrentModificationException异常。这种策略提供了及时的错误反馈,确保在并发修改时不会产生不确定的行为,但可能导致程序终止。- 安全失败(fail-safe):Fail-Safe机制是指当多个线程对集合进行并发修改操作时,迭代器不会直接抛出异常,而是创建并返回一个原始集合的副本,然后对副本进行操作。这样可以避免并发修改异常,但可能会出现一些非预期的结果,因为迭代器操作的是原始集合的复制品。
Java泛型相关面试题
什么是泛型?
Java 泛型(generics)是 JDK 5 中引入的一个新特性,提供了编译时类型安全检测机制,该机制允许程序员在编译时检测到非法的类型。泛型使用一对尖括号表示,尖括号内标注类型,可以修饰在类、接口、方法上
为什么使用泛型?
泛型是Java编程语言中的一项重要特性,它提供了以下几个主要的好处和用途:
- 保证了类型的安全性:使用泛型可以在编译时期捕获类型错误,避免在运行时期出现类型转换异常,减少了因类型不匹配而引起的潜在错误。
- 避免频繁的类型转换操作:在泛型中,类型转换是隐式的,并不需要显式地进行类型转换,使用泛型可以避免频繁的类型转换操作(装箱、拆箱),从而减少了代码中的冗余部分,使代码更简洁易读
- 提高了代码的可读性:通过在定义类、接口或方法时使用泛型,能够明确指定类或方法的输入和输出的数据类型,从而使得代码更加具有自描述性
什么是泛型擦除?
类型擦除是指在 Java 编译器编译泛型代码时,将泛型类型信息擦除并替换为原始类型或最顶级父类的过程。简单来说,就是泛型相关信息只存在于代码的编译阶段,在编译之后的字节码文件(class 文件)中不包含任何泛型信息,泛型参数会被替换为其上界或者 Object 类型。具体来说,泛型擦除会导致以下几个变化:
- 替换泛型类型参数:在字节码中,泛型类型参数会被替换为其最顶级的边界类型或者 Object 类型。例如,一个
List<T>类型,在类型擦除后会被替换为List或者List<Object>。- 移除类型参数的具体类型:泛型类型中的具体类型信息,如泛型参数的实际类型或者参数化类型的参数,会被移除。例如,一个
List<String>在类型擦除后会变成简单的List。- 强制进行类型转换:由于类型擦除导致泛型参数丢失,编译器会在需要的地方插入强制类型转换以保持类型安全。这些类型转换操作可能会在运行时引发异常,因此开发人员需要注意正确处理类型转换的问题。
1
2
3
4
5
6
7
8 // 泛型擦除前
public static <T> List<T> Example(T t1, T t2){
return new ArrayList<T>();
}
// 泛型擦除后
public static <Object> List Example(Object t1, Object t2){
return new ArrayList();
}
为什么要进行泛型擦除?
Java的泛型是在JDK 5中引入的,它提供了编译时类型检查和更强的类型安全性,为了保持与之前版本的兼容性,所以才使用泛型擦除。泛型擦除是Java编译器在生成字节码时的一种优化方式,它的目的是为了兼容Java的泛型和之前版本的非泛型代码。
- 编译器的兼容性:Java泛型是在JDK 5引入的,为了保持对旧版本Java的兼容性,编译器在生成字节码时将泛型类型擦除为它们的上界或者Object类型。
- 减少重复字节码:如果不进行泛型擦除,每个具体的泛型类型都会生成一个独立的类文件,导致生成大量重复的字节码。泛型擦除可以通过类型擦除的方式减少字节码的冗余,提高了编译后的代码的效率和性能。
- 泛型类型的互操作性:泛型擦除使得使用泛型的代码可以与不使用泛型的代码进行互操作,让泛型代码能够与之前的非泛型代码无缝地集成。
Java多线程相关面试题
线程和进程的区别?
一个应用程序,既可以有多个进程,也可以有多个线程,一个进程可以包含多个线程,每个线程相互独立
- 进程(Process):进程是计算机上执行的一个程序实例,它包含了程序代码、数据和资源的集合
- 线程(Thread):是程序中的一个执行单元,可以独立地执行代码,并共享进程的内存空间和资源
拿浏览器举例,打开一个浏览器,浏览器是一个进程,浏览器中可以打开很多标签页,每个标签页都是这个浏览器进程的子进程,每个子进程中可以有多个线程来协同完成页面的加载和渲染,比如图片、CSS 和 JS 文件等都是线程来做的
- 多进程模式:每个进程只有一个线程
- 多线程模式:一个进程有多个线程
- 多进程+多线程模式:多个进程,每个进程有多个线程
并行和并发有什么区别?
- 并行:同一时刻,多个任务同时执行,每个任务在不同的处理单元上独立执行
- 并发:同一时刻,多个任务交替执行,所有任务在同一个处理单元上执行
并发编程三个必要因素是什么?
在Java并发编程中,存在三个必要因素:
- 原子性(Atomicity):原子性是指操作的不可分割性。在多线程环境下,一个操作可以由多个指令组成,原子性要求这些指令要么全部执行成功,要么全部不执行,不允许出现中间状态或部分执行的情况。
- 可见性(Visibility):可见性是指一个线程对共享变量的修改对其他线程是可见的。在多线程环境下,每个线程都有自己的工作内存,线程之间共享数据时,可能会出现数据不一致的问题。可见性要求当一个线程修改了共享变量的值后,其他线程能够立即看到最新的值。
- 有序性(Ordering):有序性是指程序执行的结果要按照一定的顺序来保证。在多线程环境下,由于指令重排序或缓存导致的乱序执行,可能会影响程序的正确性。有序性要求程序执行的结果要符合程序的代码顺序。
创建线程有几种方式?
共有三种方式可以创建线程,分别是:① 继承Thread类② 实现Runnable接口③ 实现Callable接口
继承Thread类
- 创建一个继承于Thread类的子类
- 重写Thread类的run()
- 创建Thread类的子类对象
- 通过此子类对象调用start()启动线程
实现Runnable接口
- 创建一个实现Runnable接口的实现类
- 实现类重写Runnable接口中的抽象方法run()
- 创建Runnable接口实现类的对象
- 创建Thread类的对象,将实现类的对象作为参数,传递到Thread类的构造器中
- 通过Thread类的对象调用start()启动线程
实现Callable接口
- 创建一个实现Callable接口的实现类
- 实现call方法,将此线程需要执行的操作声明在call()中
- 创建Callable接口实现类的对象
- 创建FutureTask的对象,将Callable接口实现类的对象作为参数传递到FutureTask构造器中
- 创建Thread对象,将FutureTask的对象作为参数传递到Thread类的构造器中,并调用start
- 获取Callable中call方法的返回值
哪种方式创建线程更好?
使用实现Runnable接口创建线程的方式比继承Thread类更推荐一些,因为Java是单继承的语言,通过实现接口可以更灵活地扩展其他类或实现其他接口。如果你只需要简单地启动一个线程,并不需要获得执行结果或向上抛出异常,那么继承Thread类或实现Runnable接口都是可以的。如果你需要获得线程的执行结果,或者需要向上抛出异常,那么实现Callable接口创建线程是更好的选择。
- 继承 Thread 类创建线程:简单直观,但 Java 语言是单继承的,如果继承了 Thread 类,那就不能再继承其他类了。
- 实现Runnable接口创建线程:可以解决单继承的问题,同时继承其他类或实现其他接口,但不能向上抛出异常,不能获得线程的执行结果。
- 实现Callable接口创建线程:可以解决以上问题,可以向上抛出异常,可以通过返回值来获取任务的执行结果。
Runnable接口和Callable接口有什么区别?
Runnable接口和Callable接口是Java中用于实现多线程的接口,主要有以下区别:
- 返回值类型: Runnable接口的 run()方法
没有返回值,而Callable接口的call()方法有一个泛型返回值,可以通过Future对象获取。- 异常处理:Runnable接口的run()方法不能抛出任何受检查异常,
只能在方法内部进行捕获和处理。而Callable接口的call()方法可以抛出受检查异常,需要在方法的声明中声明抛出异常或者在方法内部进行捕获和处理。
线程的 run()和 start()有什么区别?
在Java中,
run()和start()都是Thread类中的方法,其中的区别如下:
- run()方法:是线程的执行体,定义了线程要执行的操作,可以被调用多次
- start()方法:用来启动线程,并使其进入就绪状态,只能被调用一次。
怎样停止线程?
在Java中,要停止线程有几种常见的方法:
- 等待线程执行完毕:当线程任务执行完毕后,使线程正常退出。
- 使用标志位:在线程的任务代码中,设置一个布尔类型的标志位,通过修改标志位的值来控制线程的停止。
- 调用stop()方法(已废弃):当执行此方法时,强制结束当前线程(不推荐,方法已作废)
- 调用interrupt()方法:用来通知线程停止正在进行的工作,会抛出InterruptedException异常并转移到运行状态
线程包括哪些状态,状态之间是如何变化的?
线程的生命周期是指线程从创建到终止的整个过程,可以分为以下几个阶段:
- 新建(New):当一个 Thread 类型的实例被创建(new),处于新建状态,此时该线程还没有被启动
- 就绪(Runnable):新建状态的线程调用 start() 方法后,线程进入就绪状态,等待系统分配 CPU 使用权,此时线程有执行资格,但是没有执行权
- 运行(Running):就绪的线程被调度并获得CPU资源时,便进入运行状态,开始执行 run() 方法中的操作和功能,此时线程有执行资格,同时也有执行权
- 阻塞(Blocked):当线程因为某些原因被阻塞时,例如线程在等待获取锁时,如果锁已经被其他线程占用,会等待其他线程释放锁,进入阻塞状态,此时线程没有执行资格,也没有执行权
- 等待(Waiting):线程调用了 Object.wait()、Thread.join() 等方法时,将进入等待状态,等待其他线程调用相应的 notify()、notifyAll() 方法唤醒或等待Thread.join()插队完毕,如果线程在等待一定时间后,如果还没有接收到唤醒信号,就会一直处于等待状态,如果线程使用 interrupt() 方法中断,它会抛出InterruptedException异常并转移到运行状态,此时线程没有执行资格,也没有执行权
- 限时等待(Timed Waiting):线程调用了Thread.sleep()、Object.wait(timeout)、Thread.join(timeout)等方法后,将进入限时等待状态,等调用的时间到达或者等待其他线程调用相应的唤醒方法,如果线程使用 interrupt() 方法中断,会抛出InterruptedException异常并转移到运行状态,此时线程没有执行资格,也没有执行权
- 终止(Terminated):当线程执行完毕(run() 方法 / call() 方法执行完毕)、发生了异常而导致线程停止、调用Thread.stop()方法,线程将进入终止状态,此时线程死亡,变成垃圾,不能再重复使用
新建 T1、T2、T3 三个线程,如何保证线程按顺序执行?
使用线程的插队(join)保证它们按顺序执行
- 在前一个线程的任务代码中,调用后一个线程的
join()方法。join()方法的作用是等待被调用线程执行完毕,并加入到当前线程中。- 这样可以确保前一个线程执行完毕后,后一个线程才会开始执行。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41 >public class ThreadOrderExample {
public static void main(String[] args) {
Thread t1 = new Thread(new Runnable() {
public void run() {
// 执行 T1 相关操作
System.out.println("线程 T1 正在执行");
}
});
Thread t2 = new Thread(new Runnable() {
public void run() {
try {
t1.join(); // 等待 T1 执行完成
// 执行 T2 相关操作
System.out.println("线程 T2 正在执行");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
Thread t3 = new Thread(new Runnable() {
public void run() {
try {
t2.join(); // 等待 T2 执行完成
// 执行 T3 相关操作
System.out.println("线程 T3 正在执行");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
t1.start();
t2.start();
t3.start();
}
>}
什么是死锁?
多个线程相互等待对方释放持有的资源,但不肯相让,导致的互相等待(死锁)
死锁的产生有如下原因
- 互斥条件:资源不能被同时占用,即在某一时刻只能由一个进程使用
- 请求与保持条件:进程已经保持至少一个资源,并且正在等待获取其他的资源,但是这些资源可能被其他进程占用
- 不可剥夺条件:进程已经获得的资源,在未使用完之前,不能被其他进程强制抢占
- 循环等待条件:若干进程之间形成一种头尾相接的循环等待资源关系
如何避免死锁?
- 加锁顺序:确保所有的线程都是按照相同的顺序获得锁,那么死锁就不会发生。
- 超时机制:当请求资源的等待时间超过一定阈值时,放弃请求并进行回退策略。
- 死锁检测:JDK提供了两种方式来给我们检测死锁位置,图形化工具JConsole和命令行工具Jstack
多线程之间如何进行通信?
在 Java 中,多线程之间可以使用以下几种方式进行通信:
- 共享变量:多个线程可以共享同一个变量,并通过读写该变量来进行通信。需要注意的是,多线程同时访问共享变量时,要保证线程之间的可见性和一致性,可以使用
synchronized关键字或volatile关键字来实现。- 等待/通知机制:使用
wait()、notify()和notifyAll()方法来实现等待和通知。当线程需要等待某个条件满足时,调用wait()方法使线程进入等待状态,而其他线程在某个条件满足时调用notify()或notifyAll()方法通知等待的线程继续执行。
synchronized关键字的作用?
synchronized关键字用于在Java程序中实现线程同步,确保同一时间,只有一个线程可以访问被保护的代码块(同步代码块)或方法(同步方法),避免出现数据竞争和不一致的情况。
synchronized和Lock有什么区别 ?
synchronized 和 Lock 都是用来保证线程安全的一种手段,但是他们有些区别
- 语法区别:synchronized是一个关键字,而Lock是一个接口
- 获得锁的方式:synchronized关键字声明的锁是自动获取和释放的,由Java虚拟机自动完成;而Lock需要显式地调用lock()方法获取锁,并且必须在finally块中调用unlock()方法来释放锁。
- 粒度区别:synchronized关键字粒度较粗,只能锁住整个方法或代码块;而Lock锁颗粒度相对细,在代码内部手动开启和释放。
sleep() 和 wait() 有什么区别?
sleep()和wait()都可以暂停线程的执行,但是他们有些区别
所属类不同:sleep()方法是Thread类中的方法,而wait()是Object 类中的方法
作用不同:sleep()是让线程进入睡眠状态,到指定时间会恢复执行;wait()是让线程进入等待通知状态,需要其他线程调用 notify() 或 notifyAll() 方法来通知该线程继续执行
使用方式不同:sleep()方法是静态的,可以在任何地方调用,而wait()只能在synchronized同步代码块或同步方法中使用,否则会抛出IllegalMonitorStateException异常。
notify() 和 notifyAll() 有什么区别?
如果线程调用了对象的 wait()方法,那么线程便会处于该对象的等待池中,等待池中的线程不会去竞争该对象的锁。
notify()和notifyAll()都是用于唤醒被wait()方法挂起的线程,notifyAll() 会唤醒所有的线程,notify() 只会唤醒一个线程。
多线程安全问题怎么解决?
在多线程编程中,当多个线程同时访问和修改同一个变量时,可能会导致数据竞争和不一致的问题。为了解决这个线程安全问题,可以采用两种常见的解决方案:
- 时间换空间:通过使用同步机制(如synchronized关键字或Lock对象),确保在同一时间只有一个线程可以访问共享变量。当一个线程正在访问共享变量时,其他线程需要等待,从而避免了并发访问导致的线程安全问题。这种方式以时间换取了空间,但可能会引入线程竞争和上下文切换的开销。
- 空间换时间:通过使用ThreadLocal将共享变量复制多份,每个线程都拥有自己独立的副本。这样,各个线程之间相互独立,彼此的操作不会相互干扰,避免了数据竞争和不一致的问题。虽然这种方式增加了内存消耗,但提高了程序的并发性能。
volatile关键字的作用?
volatile关键字是一种轻量级的同步机制,使用volatile修饰的变量对所有线程可见,即一个线程修改了该变量的值,其他线程能够立即看到最新的值。
- 为了提高程序的运行效率,编译器会对经常访问的变量进行缓存优化,将其缓存在寄存器或高速缓存中。当程序读取这些变量时,可以直接从缓存中获取值,而不需要每次都去访问内存,从而提高程序的执行效率。
- 然而,在多线程环境下,由于每个线程都有自己的缓存,当一个线程修改了变量的值时,其他线程可能仍然使用旧的缓存值,导致数据不一致。为了解决这个问题,可以使用volatile关键字修饰需要共享的变量。
- 使用volatile修饰的变量,编译器不会对该变量进行缓存优化,每次访问时都直接从主内存中读取值或写入值。当一个线程修改了volatile修饰的变量,其他线程立即能够看到最新的值,从而避免了数据不一致的问题。
- 需要注意的是,volatile关键字只保证变量的可读性和可写性,并不能保证对volatile变量的操作是原子性。如果需要保证多个操作的原子性,仍然需要使用锁或其他的同步机制。
ThreadLocal是什么?
ThreadLocal是Java中的一个类,用于在多线程环境下实现线程局部变量。它提供了一种机制,使得每个线程都可以拥有自己独立的变量副本,而不会与其他线程共享。
什么是线程池?
线程池是指预先创建一定数量的线程,放置到一个池中,等待调用任务,任务完成后,该线程并不会被销毁,而是重新放回线程池中等待下一次调用
为什么要使用线程池?
在高并发情况下,需要频繁地创建线程和销毁线程,对性能影响很大。有了线程池,可以提前创建好多个线程,放入线程池中,使用时直接获取,使用完放回池中。可以避免频繁创建销毁,实现重复利用,提高系统性能和效率,以下是线程池的优点:
- 降低系统消耗:重复利用已经创建的线程降低线程创建和销毁造成的资源消耗
- 提高响应速度:当任务到达时,任务不需要等到线程创建就可以立即执行
- 提供线程管理:可以通过设置合理分配、调优、监控
怎样创建线程池?
线程池的创建方式共包含七种(其中六种是通过
Executors创建的,一种是通过ThreadPoolExecutor创建的)根据阿里巴巴的Java技术手册(编码规约),不推荐使用Executors工具类去创建线程池,而是推荐直接使用ThreadPoolExecutor的方式进行创建。通过
Executors创建线程池创建线程池可以通过
java.util.concurrent.Executors类中提供的静态方法来完成
newFixedThreadPool(int nThreads):创建固定大小的线程池,线程池中的线程数量固定为nThreads。当任务提交到线程池时,如果所有线程都在忙碌,那么任务会被放入等待队列中,直到有线程可用。newSingleThreadExecutor():创建只有一个线程的线程池。该线程池保证任务按照先进先出的顺序执行。newCachedThreadPool():创建可缓存的线程池。线程池的大小可以根据需要自动调整。当任务提交到线程池时,如果有空闲线程可用,则立即执行任务;如果没有可用线程,则创建新的线程执行任务。当线程空闲一段时间后,如果没有任务可执行,线程将被终止并从线程池中移除。newScheduledThreadPool(int corePoolSize):创建定时执行任务的线程池。线程池中的线程数量固定为corePoolSize。可以使用线程池提供的方法按照固定频率或者固定延迟执行任务。newSingleThreadScheduledExecutor():创建只有一个线程的定时执行任务的线程池。该线程池保证任务按照固定频率或者固定延迟执行。newWorkStealingPool():创建一个工作窃取线程池,Java 8 中新增的方法,该线程池可以根据系统的处理能力自动调整线程的数量,并且可以充分利用多核处理器的优势。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16 public class ThreadPoolExample {
public static void main(String[] args) {
// 创建固定大小的线程池,线程池中的线程数量为3
ExecutorService fixedThreadPool = Executors.newFixedThreadPool(3);
// 创建只有一个线程的线程池
ExecutorService singleThreadExecutor = Executors.newSingleThreadExecutor();
// 创建可缓存的线程池
ExecutorService cachedThreadPool = Executors.newCachedThreadPool();
// 创建定时执行任务的线程池,线程池中的线程数量为3
ScheduledExecutorService scheduledThreadPool = Executors.newScheduledThreadPool(3);
// 创建只有一个线程的定时执行任务的线程池
ScheduledExecutorService singleThreadScheduledExecutor = Executors.newSingleThreadScheduledExecutor();
// 创建工作窃取线程池
ExecutorService workStealingPool = Executors.newWorkStealingPool();
}
}通过
ThreadPoolExecutor创建线程池除了使用
Executors类提供的方法外,我们还可以直接使用ThreadPoolExecutor类来创建线程池,这样可以更加灵活地配置线程池的属性。ThreadPoolExecutor构造方法如下:
1
2
3
4
5
6
7
8
9 public ThreadPoolExecutor(
int corePoolSize, // 线程池的核心线程数,即线程池中同时可以执行的线程数量。
int maximumPoolSize, // 线程池的最大线程数,即线程池中最多可以创建的线程数量。
long keepAliveTime, // 空闲线程的存活时间,当线程数大于核心线程数时,多余的空闲线程在终止之前等待新任务的最长时间。
TimeUnit unit, // 空闲线程存活时间的单位,例如 TimeUnit.SECONDS 表示以秒为单位。
BlockingQueue<Runnable> workQueue, // 用于存放待执行任务的阻塞队列。
ThreadFactory threadFactory, // 用于创建新线程的工厂。
RejectedExecutionHandler handler // 线程池的饱和策略,即当线程池和阻塞队列都满了之后,如何处理新提交的任务。
) { }创建线程池代码示例
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22 public class ThreadPoolExample {
public static void main(String[] args) {
// 创建线程池,核心线程数为2,最大线程数为5,等待队列容量为10
ThreadPoolExecutor threadPool = new ThreadPoolExecutor(
2, // 核心线程数
5, // 最大线程数
1, // 线程空闲时间
TimeUnit.MINUTES, // 空闲时间单位
new ArrayBlockingQueue<>(10), // 等待队列
new ThreadPoolExecutor.CallerRunsPolicy() // 拒绝策略(当线程池和等待队列都满了时,由提交任务的线程来执行该任务)
);
// 向线程池中添加任务并执行
for (int i = 0; i < 10; i++) {
final int taskId = i;
threadPool.execute(() -> System.out.println("任务" + taskId + "正在执行,线程" + Thread.currentThread().getName()));
}
// 关闭线程池,不再接收新的任务,并等待已经提交的任务执行完毕
threadPool.shutdown();
}
}
线程池的核心参数?
Java 中的线程池由
java.util.concurrent.Executors类提供。下面是线程池的一些核心参数:
- 核心线程数(Core Pool Size):线程池中保持的最小线程数。即使线程处于空闲状态,核心线程也不会被销毁。当提交任务时,如果活动线程数小于核心线程数,则会创建新的线程来执行任务。
- 最大线程数(Maximum Pool Size):线程池允许存在的最大线程数。当活动线程数达到最大线程数时,后续提交的任务会进入任务队列等待执行(如果任务队列未满)。如果任务队列已满,且活动线程数已达到最大线程数,则根据拒绝策略来处理新提交的任务。
- 任务队列(Blocking Queue):用于存储待执行的任务的队列。当线程池中的线程都处于忙碌状态时,新提交的任务会被放入任务队列等待执行。
- 线程空闲时间(Keep Alive Time):当线程池中的线程数量超过核心线程数,并且空闲时间超过指定时间时,多余的线程会被销毁,直到线程数不超过核心线程数为止。
- 线程工厂(Thread Factory):用于创建新线程的工厂类。可以自定义线程的名称、优先级、线程组等属性。
- 拒绝策略(Rejected Execution Handler):当任务无法被线程池执行时的处理策略。常用的拒绝策略有:抛出异常、直接丢弃任务、丢弃队列中最旧的任务、将任务分发给调用者线程来执行等。
1
2
3
4
5
6
7
8
9 public ThreadPoolExecutor(
int corePoolSize, // 线程池的核心线程数,即线程池中同时可以执行的线程数量。
int maximumPoolSize, // 线程池的最大线程数,即线程池中最多可以创建的线程数量。
long keepAliveTime, // 空闲线程的存活时间,当线程数大于核心线程数时,多余的空闲线程在终止之前等待新任务的最长时间。
TimeUnit unit, // 空闲线程存活时间的单位,例如 TimeUnit.SECONDS 表示以秒为单位。
BlockingQueue<Runnable> workQueue, // 用于存放待执行任务的阻塞队列。
ThreadFactory threadFactory, // 用于创建新线程的工厂。
RejectedExecutionHandler handler // 线程池的饱和策略,即当线程池和阻塞队列都满了之后,如何处理新提交的任务。
) { }
线程池的执行流程
- 创建线程池:首先,创建一个线程池对象。可以使用
java.util.concurrent.Executors类提供的静态方法来创建不同类型的线程池,如newFixedThreadPool()、newCachedThreadPool()等。- 提交任务:通过调用线程池对象的
submit()或execute()方法来提交任务。任务可以是实现了Runnable接口或者Callable接口的对象。- 任务接收:线程池接收到任务后,会根据线程池的状态和配置来确定如何处理任务。如果线程池中的线程数小于核心线程数,会创建新的线程来执行任务;如果线程池中的线程数已达到核心线程数,任务会被放入任务队列等待执行;如果任务队列已满且线程池中的线程数未达到最大线程数,则会创建新的线程来执行任务;如果线程池中的线程数已达到最大线程数,且任务队列已满,根据拒绝策略来处理任务(如抛出异常、丢弃任务等)。
- 任务执行:线程池中的线程从任务队列中取出任务,执行任务的逻辑。执行的方式取决于具体的任务类型,可以是
Runnable对象的run()方法或Callable对象的call()方法。- 结果返回(仅适用于
Callable任务):如果任务是Callable类型的,并且需要返回结果,线程执行任务后会将结果返回。- 线程回收:任务执行完毕后,线程池中的线程可能会被回收。具体回收的条件取决于线程池的配置,例如空闲时间超过一定阈值、线程池关闭等。
- 关闭线程池:当不再需要线程池时,可以调用
shutdown()方法请求关闭线程池。这会停止接受新的任务,并等待线程池中正在执行的任务执行完毕。然后可以选择调用awaitTermination()方法等待线程池中的任务执行完毕,或者直接终止尚未完成的任务。
线程池都有哪几种工作队列?
- 直接提交队列(SynchronousQueue): 这是一个
没有容量的队列,任务提交给线程池后,如果没有空闲线程可用,则会立即创建一个新线程来执行任务,如果有多余的线程,则会尝试将任务直接交给空闲线程处理,而不将任务放入队列中。- 有界任务队列(LinkedBlockingQueue): 这是一个
基于链表实现的有界队列,可以指定队列的最大容量。当任务提交给线程池后,如果线程池中的线程数小于corePoolSize,则会创建新线程来处理任务;如果线程池中的线程数达到了corePoolSize,则任务会被放入队列中等待执行;如果队列已满,但线程池中的线程数小于maximumPoolSize,则会创建新线程来处理任务;如果队列已满且线程池中的线程数达到了maximumPoolSize,则会根据拒绝策略来处理任务。- 无界任务队列(LinkedBlockingDeque): 这也是一个
基于链表实现的队列,但是它的容量是无界的,可以无限地添加任务。当任务提交给线程池后,如果线程池中的线程数小于corePoolSize,则会创建新线程来处理任务;如果线程池中的线程数达到了corePoolSize,则任务会被放入队列中等待执行;由于队列是无界的,所以不会拒绝任务,只要有新任务到来,就会继续添加到队列中。- 优先级队列(PriorityBlockingQueue): 这是一个
基于堆实现的优先级队列,可以根据任务的优先级来决定执行顺序。任务提交给线程池后,会根据任务的优先级将任务放入对应的位置,并按照优先级顺序进行执行。
Java IO流相关面试题
Java 中 IO 流分为几种?
按流向来分:输入流和输出流。
按类型来分:字节流和字符流。
按功能来分:节点流和处理流。
字节流和字符流的区别是什么?
字节流: 操作的单元是数据单元是8位的字节,例如图片、音乐、视频等文件,可以对二进制文件进行处理
字符流:操作的是数据单元为16位的字符,例如.txt、.java、.c、.cpp等文本文件,.doc、excel、ppt这些不是文本文件
常见的五种 IO 模型?
IO模型(Input/Output Model)是描述在计算机系统中,如何处理输入和输出操作的一种模型,常见的有如下几种
- 阻塞IO模型(Blocking IO Model):当应用程序执行IO操作时,整个进程会被阻塞,直到操作完成。在等待IO完成期间无法执行其他任务,效率较低。
- 非阻塞IO模型(Non-blocking IO Model):当应用程序执行IO操作时,可以立即返回,而不必等待操作完成。通过轮询或异步通知方式,应用程序可以继续执行其他任务,并周期性地检查IO操作是否完成,但仍然需要主动轮询,效率仍然有限。
- 多路复用IO模型(Multiplexing IO Model):通过使用系统调用(如select、poll、epoll等),可以同时监听多个IO操作的完成情况。应用程序将IO操作注册给内核,内核会通知应用程序哪些IO操作已经完成,从而避免了轮询的开销,提高了效率。
- 信号驱动IO模型(Signal-driven IO Model):应用程序将IO操作注册给内核,并指定一个信号处理函数。当IO操作完成时,内核会向应用程序发送一个信号,应用程序在信号处理函数中进行相应的处理。这种模型可以避免轮询,但需要处理信号的开销。
- 异步IO模型(Asynchronous IO Model):应用程序发起IO操作后,不需要等待操作完成,可以继续执行其他任务。当IO操作完成时,系统会通知应用程序,应用程序可以回调相应的处理函数进行后续处理。异步IO模型相对于其他模型较为复杂,但具有较高的性能和灵活性。
为了方便理解IO模型,可以拿现实中的例子来比喻这个过程,假设一个老师正在收取学生的作业。
- 同步阻塞方式(Blocking):老师逐个学生地等待每个学生完成作业再继续收下一个学生的作业。当一个学生没有完成作业时,老师会一直等待,直到该学生完成作业后才能继续收取下一个学生的作业。这种方式下,老师需要阻塞等待每个学生完成作业,效率较低。
- 同步非阻塞方式(Non-blocking):老师逐个学生地收取作业,但如果某个学生还没完成作业,老师会暂时跳过该学生继续收取下一个学生的作业,直到之前的学生完成作业后再回来收取。这种方式下,老师不会阻塞在每个学生上,但仍然需要轮询每个学生是否完成作业。
- IO多路复用方式(select和poll):老师相当于在询问所有的学生是否完成作业,但并不知道具体哪个学生完成了。老师持续地询问每个学生是否完成作业,直到所有学生中有学生举手表示完成作业为止。这种方式下,老师需要不断地轮询每个学生来确定是否完成作业,存在一定的性能开销。
- IO多路复用方式(epoll):学生举手相当于触发了一个事件,并告诉老师是哪个学生举手了。老师只需要关注那些举手的学生,而不需要轮询询问每个学生,从而提高了效率。这种方式下,老师能够直接知道具体是哪个学生完成了作业,避免了轮询和性能开销。
- 异步IO方式(Async):老师委托一位助教负责收取学生的作业,然后可以继续进行其他工作。助教负责等待学生完成作业,当有学生完成作业时,助教会通知老师。这种方式下,老师完全不需要关注学生的作业进度,只需等待助教的通知即可。与其他模型相比,异步IO方式能够充分利用时间,提高效率。
BIO、NIO、AIO 有什么区别?
BIO(同步阻塞I/O):简单方便,并发处理能力低。
NIO(同步非阻塞I/O):是传统 IO 的升级,实现了IO多路复用。
AIO(异步非阻塞I/O):对NIO的升级,采用了异步的方式处理I/O操作
Java反射相关面试题
什么是反射?
在Java中,反射是一种机制,通过反射,可以在运行时获取类的信息(例如字段、方法、构造函数等),并且可以动态地操作类、对象和成员。
- 动态地创建和访问对象:使用反射可以实例化一个类的对象,即使在编译时并不知道具体的类名。
- 动态地调用方法:反射可以在运行时调用一个对象的方法,甚至通过反射来调用私有方法。
- 获取和设置字段的值:通过反射可以获取和设置对象中的字段的值,即使这些字段是私有的。
- 获取和操作类、接口、构造函数等:反射还可以获取和操作类的信息,如获取类的注解、实现的接口、父类等。
反射的优缺点?
反射的优点:
- 动态性:反射使得程序能够在运行时动态地获取和操作类的信息,而不需要在编译期间确定,提高了灵活性和可扩展性
- 框架工具开发:反射机制为一些框架和工具提供了基础。例如,测试框架可以使用反射来自动化测试对象的属性和方法,而依赖注入框架可以通过反射来自动注入依赖。
反射的缺点:
- 性能开销:反射机制通常比直接调用代码的方式更慢。由于反射需要在运行时进行类型检查、方法查找等操作,因此会引入一定的性能开销。
- 安全性问题:反射机制可以绕过访问修饰符(如私有、受保护等),并允许访问和修改本来不应该被暴露的成员。这可能导致程序的安全性问题。
- 代码可读性和维护性:反射的代码通常较为复杂,可读性较差。由于反射操作的灵活性,一些错误可能直到运行时才能被发现,不利于排查问题和维护代码。
哪里用到反射机制?
- JDBC:动态加载数据库的驱动,进行连接
- Spring IOC:基于XML配置文件,动态地读取并实例化Bean对象
- 动态代理:反射机制可以在运行时创建代理类对象
Java异常相关面试题
Error和Exception有什么区别?
Error(错误):Error类及其子类用于表示严重的程序错误或系统错误,通常由虚拟机(JVM)抛出,无法通过代码处理来修复的,它们通常表示严重的问题,例如内存溢出(OutOfMemoryError)或栈溢出(StackOverflowError)。
Exception(异常): Exception类及其子类用于表示在程序执行期间发生的非正常情况或错误。分为两种类型:编译时异常(Checked Exception)和运行时异常(Unchecked Exception)。编译时异常需要在代码编写阶段处理,否则会导致编译错误。
异常的分类?
异常主要分为两种,分别是编译时异常和运行时异常(只要一个异常类的祖先类中有RuntimeException,那么就是运行时异常,否则是编译时异常)
编译时异常:编译时异常需要在代码编写阶段处理,否则会导致编译错误,继承自
Exception类。常见的编译时异常包括IO异常(IOException)、SQL异常(SQLException)等。运行时异常:运行时异常是指那些不需要在代码中强制处理的异常,继承自
RuntimeException类或其子类。在代码中可以处理,但不要求强制处理。通常由程序逻辑错误引起,例如空指针异常(NullPointerException)、数组越界异常(ArrayIndexOutOfBoundsException)等。
怎么处理异常?
捕获异常:使用try-catch-finally或try-with-resources捕获异常
抛出异常:使用throw或throws抛出异常
try-catch-finally 和 try-with-resources的区别?
try-catch-finally和try-with-resources是Java中用于异常处理的两种不同的语法结构
try-catch-finally:try块中包含可能抛出异常的代码。如果发生异常,会根据异常类型进入相应的catch块进行处理。无论是否发生异常,finally块中的代码都会执行。
1
2
3
4
5
6
7
8
9 try {
// 可能抛出异常的代码
} catch (ExceptionType1 exception1) {
// 处理 ExceptionType1 类型的异常
} catch (ExceptionType2 exception2) {
// 处理 ExceptionType2 类型的异常
} finally {
// 无论是否发生异常,都会执行的代码
}try-with-resources除了拥有try-catch-finally的所有功能,还提供了自动关闭资源的能力,能够在代码执行完毕后自动关闭 try 块中声明的资源
1
2
3
4
5
6
7
8
9
10
11
12
13 try (
ResourceType resource1 = initialization1; // 声明并初始化第一个资源
ResourceType resource2 = initialization2; // 声明并初始化第二个资源
// 声明更多的资源(more resources...)
) {
// 在try块中使用资源对象(use resources here)
} catch (ExceptionType1 e1) {
// 捕获try块抛出的ExceptionType1异常(catch exceptions thrown from try block)
} catch (ExceptionType2 e2) {
// 捕获try块抛出的ExceptionType2异常(catch exceptions thrown from try block)
} finally {
// 总是会被执行的清理操作(be executed always)
}
throw和throws的区别?
throw 关键字:用在方法内部,只能用于抛出一种异常,用来抛出方法或代码块中 的异常,受查异常和非受查异常都可以被抛出。
throws 关键字:用在方法声明上,可以抛出多个异常,用来标识该方法可能抛出 的异常列表。一个方法用 throws 标识了可能抛出的异常列表,调用该方法的方法中 必须包含可处理异常的代码,否则也要在方法签名中用 throws 关键字声明相应的异常。
final、finally、finalize的区别?
final
final是一个关键字,可以用来修饰的结构:类、方法、变量(成员变量、局部变量、形参、引用地址)
- 修饰类:用
final修饰的类不能被其他类继承- 修饰方法:用
final修饰的方法不能被子类重写- 修饰变量:用
final修饰的变量表示常量,即其值一旦被初始化后就不能被修改。对于基本数据类型的变量,该值是不可变的;对于引用类型的变量,该引用不能再指向其他对象,但是该对象的内容可以被修改。finally
finally也是一个关键字,用于定义一个代码块,通常与try-catch结构一起使用。finally通常用于释放资源,如关闭数据库连接、文件IO等操作,以确保资源的正常释放。不论是否发生异常,finally中的代码块始终会执行
finalize
finalize是Object类中的一个方法,用于在垃圾回收器回收对象之前调用,Jdk9中废弃
如何自定义异常?
所有异常都必须是Throwable 的子类,自定义异常类需要创建一个类并继承Exception或RuntimeException类
自定义编译时异常类:继承Exception类。
自定义运行时异常类:继承 RuntimeException类。
使用自定义异常类时,可以像使用其他异常一样使用它,可以抛出或者捕获
Java新特性相关面试题
什么是Lambda表达式?
Lambda表达式是Java 8引入的一项重要的新特性,主要受到函数式编程思想的影响。函数式编程思想强调的是对数据进行操作的方式,而不关注具体的对象是什么。Lambda表达式可以被理解为一种匿名函数,它基于数学中的λ演算而得名,也可以称为闭包(Closure)。
- 优点:简化代码,开发迅速,使得函数式编程更加方便。
- 缺点:代码可读性变差,不容易进行调试。
Lambda表达式的语法?
Lambda表达式由Lambda参数、箭头符号(
->)和方法体组成,语法如下
1 ( paramaters ) -> { 主体部分 }
组成 简介 paramaters Lambda参数列表,可以有零个或多个参数,参数类型可以显式指定,也可以由编译器根据上下文自动推断。
如果没有参数,则可以使用空括号()表示;
如果只有一个参数,可以省略参数的括号;
如果有多个参数,需要使用括号()包裹,并用逗号将参数分隔。-> 箭头操作符,可理解为“被用于”的意思,用于分隔参数列表与Lambda表达式的主体部分 主体部分 可以是一个表达式,也可以是一个代码块。
如果是一个表达式,则可以省略return关键字,返回一个值或者什么都不返回,等同于方法的方法体;
如果是一个代码块,则需要使用花括号将多个语句括起来,并且需要显式使用return语句来返回值。
什么是函数式接口?
有且只有一个抽象方法的接口,称为函数式接口(Functional Interface)。
在Java中,Lambda表达式是对函数式接口的一种简写方式,只有一个接口是函数式接口时,才能用Lambda表达式。
Stream流怎么使用?
- 创建流:将数据源转换为Stream流
- 操作流:中间操作链,对数据源的数据进行处理(过滤、聚合等)
- 结束流:终止操作,执行中间操作链,并产生结果
新日期时间API?
Java 8引入了新的日期时间API(java.time包),以替代旧的Date和Calendar类。新的日期时间API提供了更简单、更灵活和更强大的日期和时间处理功能。
- LocalDate:用于表示日期(年、月、日)的类。它提供了丰富的日期操作方法,例如计算、比较、格式化等。
- LocalTime:用于表示时间(小时、分钟、秒)的类。它同样提供了多种时间操作方法。
- LocalDateTime:用于表示日期和时间的类,相当于同时包含了LocalDate和LocalTime的信息。
- Instant:用于表示时间戳的类,可以精确到纳秒级别。它可以与旧的Date类进行转换。
- Duration:用于表示两个时间之间的时间间隔,可以精确到纳秒级别。
- Period:用于表示两个日期之间的时间间隔,以年、月、日为单位。
- DateTimeFormatter:用于格式化和解析日期时间对象,可以将日期时间对象转换为字符串,或将字符串解析为日期时间对象。
- ZoneId和ZonedDateTime:用于处理时区信息的类,可以将日期时间对象与特定时区关联。
Optional类?
Java 8引入了Optional类作为新的特性,用于解决空指针异常的问题。
Optional类是一个容器类,它可以包含某个类型的对象或者表示对象不存在。
通过使用Optional类,我们可以避免显式地检查对象是否为null以及处理空指针异常的情况。





















