面试题库
Java SE 面试题
Java 基础、面向对象、集合、多线程、IO/网络、反射、设计模式、JVM、JUC,共 195 题。
- Java
- 面试
JavaSE 面试题
来源:
resource/御码IT教育-Java+Python智能体-JavaSE.pdf
一、JavaSE基础
本部分面试题涉及初识Java、数据类型、运算符、流程控制、数组相关内容
1、写出Java的四类八种基本数据类
整数 byte (1字节) short (2字节) int (4字节) long (8字节) 小数(浮点) float (4字节) double (8字节) 字符 char (2字节) 布尔 boolean (Java虚拟机规范没有规定boolean的大小,实现取决于具体的JVM实现。通常 独立变量占4字节,数组中占1字节) 备注:除去boolean类型之外的七种数据类型,可以根据规则进行自动类型转换和强制类型转换
2、基本数据类型的类型转换规则
基本类型的类型转换分为自动转换和强制转换。这里的基本数据类型指除了boolean之外的7种数值型数 据类型。 自动转换规则:容量小的数据类型可以自动转换成容量大的数据类型,也可以说低级自动向高级转换。 下图是自动转换的方向 强制转换规则:高级变为低级需要强制转换。
如何转换:
(1)、赋值运算符“=”右边的转换,先自动转换成表达式中级别最高的数据类型,再进行运算。 (2)、赋值运算符“=”两侧的转换,若左边级别>右边级别,会自动转换;若左边级别 == 右边级别, 不用转换;若左边级别 < 右边级别,需强制转换。 (3)、可以将整型常量直接赋值给byte, short, char等类型变量,而不需要进行强制类型转换,前提 是不超出其表述范围,否则必须进行强制转换。 备注:除了基本数据类型,引用数据类型的类型转换是开发中更常用的场景,分为「向上转型」和「向 下转型」,分别采用自动类型转换和强制类型转换
3、char型变量能存贮一个中文汉字吗
在Java中,char类型是用来表示单个字符的数据类型,它基于Unicode编码(具体实现为UTF-16),可 以存储各种语言的单个字符,包括中文汉字。 char类型占用2个字节的内存空间,对应UTF-16编码中1个16位的编码单元。绝大多数常用中文汉字(如 「中」「国」)的UTF-16编码仅需1个16位单元即可表示,因此可以直接赋值给char变量:char ch = ‘中’;,编译器会自动将字符转换为对应的Unicode编码值(如「中」的编码是0x4E2D,也可写成转义序 列\u4e2d)。 UTF-8是Unicode的另一种编码实现(传输/存储常用),中文汉字在UTF-8中通常占3字节,但这与Java 内存中char的存储逻辑无关——char的存储仅依赖UTF-16编码,而非UTF-8。 需要注意的是:极少数增补区的生僻古汉字,其UTF-16编码需要2个16位单元(即2个char)才能表示, 这类字符无法用单个char存储,需用String类型表示。char的核心用途是存储单个字符(包括单个中文 汉字),String则用于存储多个字符组成的字符串。
4、& 和 && 的区别
& 逻辑与符号的左右两边,无论真或假都要执行 && 短路与符号的左边如果为假,符号的右边不再执行,提高了代码的执行效率 备注:&还可以用作位运算符。当&两边操作数或两边表达式的结果不是boolean类型时,&用于按位与 运算符的操作。
5、用最有效率的方法算出2乘以8等于多少
使用位运算来实现效率最高。 位运算符是对操作数以二进制比特位为单位进行操作和运算,操作数和结果都是整型数。 对于位运算符“<<”, 是将一个数左移n位,就相当于乘以了2的n次方,那么,一个数乘以8只要将其左移3 位即可,位运算cpu直接支持的,效率最高。 所以,2乘以8等于几的最效率的方法是2 << 3。
6、switch的条件变量可以是什么类型
byte,short,int,char,枚举、String 具体细节为:
- 整数类型中的 byte、short、int一直可以。(long不可选) 2)字符类型中的char一直可以(可以理解为int) 3)JDK1.5后增加了enum枚举类型 4)JDK1.7后增加了String类型。但本质是「语法糖」—— JVM 会把 String 转成 hashCode()(int 类 型)再执行 switch 备注:八大基本数据类型中:long、boolean、float、double 不可以作为switch的参数,尤其记住long 不可以,这一点很容易错误记忆
7、Java中变量和常量有什么区别
在Java中,变量和常量是两个不同的概念,它们有以下几点区别:
- 可变性: 变量是可以被修改的,其值可以在程序的执行过程中改变。 常量是不可被修改的,其值在定义后不能再被改变。
- 声明与赋值: 变量需要先声明,并可以在声明后进行赋值。声明时需要指定变量的类型 常量在定义时需要使用final关键字进行修饰
- 内存空间: 变量在内存中占用一块存储空间,可以改变这个存储空间中的值。 常量通常会被编译器在编译时直接替换为对应的值,所以在内存中不会为常量分配额外的存储 空间,而是直接使用常量的值。
- 使用场景: 变量用于存储会发生变化的数据,例如计数器、临时结果等,在程序的执行过程中可以根据需 要改变其值。 常量用于表示不可变的数据,例如数学常数、配置项等,在程序中通常希望保持其固定的值, 避免误操作导致值的变化。 备注:更进步讲,常量可分为编译时常量和运行时常量。编译时常量(基本类型+String字面量):编译 器会进行常量替换,不会在运行时分配内存;运行时常量(new创建的对象等)仍然需要分配内存
8、for-each与常规for循环的效率区别
在Java中,for-each循环(也称为增强型for循环)和常规for循环有一些差异,包括它们在执行效率上的 区别。下面是它们之间的一些比较:
- 执行效率:在大多数情况下,常规for循环的执行效率比for-each循环高。这是因为for-each循环需 要额外的步骤来获取集合或数组中的元素,而常规for循环可以直接通过索引访问元素,避免了额 外的开销。
- 可变性:常规for循环具有更大的灵活性,可以在循环过程中修改计数器,从而控制循环的行为。 而for-each循环是只读的,不能在循环过程中修改集合或数组的元素。
- 代码简洁性:for-each循环通常比常规for循环更加简洁易读,尤其在遍历集合或数组时。使用for- each循环可以减少迭代器或索引变量的声明和管理,使代码更加清晰。 尽管常规for循环在执行效率上可能更高,但在大多数实际情况下,两者之间的性能差异不会对程序性能 产生显著影响。因此,根据具体的使用场景和代码可读性的需求,可以选择使用for-each循环或常规for 循环。在只需要遍历集合或数组而不修改其中元素的情况下,for-each循环是一个方便且简洁的选择。
9、break与continue的区别
关键字
作用范围
核心效果
执行后跳转位置
break 循环(for/while/do- while)、switch 终止整个循环 /switch 循环 /switch 结构的下一 行代码 continue 仅循环(for/while/do- while) 跳过本次循环剩余代码, 继续下一次循环 for:先执行更新语句 (i++)再判断条件; while/do-while:直接判 断循环条件
方案
优点
缺点
适用场景
带标签的 break 代码简洁、直接、 效率高 新手可能觉得标签 “不直观” 绝大多数场景(官方推 荐) 标志位 逻辑更 “线性”,易 理解 需多定义变量,外层循环多 一次判断 团队规范禁止使用标签 的场景
10、跳出循环时如何在内层循环跳出外层循环?
方案1:带标签的 break 方案2:标志位
publicclassLabelBreakDemo {
publicstaticvoidmain(String[] args) {
// 给外层循环添加标签:outerLoop(标签名自定义,符合变量命名规则即可)
outerLoop:for (inti=1; i<=3; i++) { // 外层循环:i从1到3
System.out.println("外层循环 i = "+i);
// 内层循环:j从1到3
for (intj=1; j<=3; j++) {
System.out.println(" 内层循环 j = "+j);
// 触发条件:j=2时跳出外层循环
if (j==2) {
System.out.println(" 触发条件,跳出外层循环!");
breakouterLoop; // 直接终止outerLoop标签对应的外层循环
}
}
}
System.out.println("循环结束");
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
publicclassFlagBreakDemo {
publicstaticvoidmain(String[] args) {
// 定义布尔标志位:控制外层循环是否终止
booleanisQuit=false;
// 外层循环:仅保留原本的i范围判断,移除标志位判断
for (inti=1; i<=3; i++) {
System.out.println("外层循环 i = "+i);
// 内层循环:j从1到3
for (intj=1; j<=3; j++) {
System.out.println(" 内层循环 j = "+j);
1
2
3
4
5
6
7
8
9
10
11、递归的定义和优缺点
递归算法是一种直接或者间接地调用自身算法的过程。 递归和循环是编程中实现「重复执行逻辑」的两种核心方式,二者本质等价。理论上所有递归都能转循 环,反之亦然 递归的实现必须满足 3 个核心条件,否则会导致无限递归、栈溢出等严重问题:
- 自调用(核心特征)
- 明确的递归出口(终止条件)
- 问题题规模递减(收敛性)
推荐使用递归的场景:
树形结构的遍历和操作(二叉树 / 多叉树的遍历(前序 / 中序 / 后序)、树形菜单解析、文件夹递 归遍历) 分治算法的实现(归并排序、快速排序) 问题本质具有递归特性(如汉诺塔) 代码可读性要求高于性能要求
优先使用循环的场景:
简单重复操作 大数据量处理 性能敏感的代码 递归深度可能很大的情况 递归优点:代码简洁直观,贴合分治 / 树形问题,编码简单; 递归缺点:性能开销大、易栈溢出、调试难度高。
// 触发条件:j=2时修改标志位
if (j==2) {
System.out.println(" 触发条件,标记退出!");
isQuit=true; // 修改标志位为true
break; // 先终止内层循环
}
}
// 内层循环结束后,单独判断标志位,决定是否终止外层循环
if (isQuit) {
break; // 终止外层循环
}
}
System.out.println("循环结束");
}
}
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// 树形遍历递归
voidtraverse(TreeNoderoot) {
if (root==null) return;
traverse(root.left);
System.out.println(root.val);
traverse(root.right);
}
// 转为循环+栈
1
2
3
4
5
6
7
8
9
12、冒泡排序算法和代码实现
冒泡排序是入门级的排序算法,是交换排序的一种,核心思想非常形象 —— 就像水里的气泡会逐渐上浮 一样,每一轮遍历都会把当前未排序部分的最大元素”冒泡” 到正确的位置。
基本算法(以升序排序为例)
1)整个数列分成两部分:前面是无序数列,后面是有序数列 2)初始状态下,整个数列都是无序的,有序数列是空 3)如一个数列有n个元素,则至多需要n-1趟循环才能保证数列有序;至少需要1趟循环 4)每一趟循环可以让无序数列中最大数排到最后,(也就是说有序数列的元素个数增加1) 5)每一趟循环都从数列的第一个元素开始进行比较,依次比较相邻的两个元素,比较到无序数列的末尾 即可(而不是数列的末尾) 6)如果前一个大于后一个,交换 7)如果已经有序,可提前退出循环。定义一个符号量flag,默认有序true;发生交换置为false,一趟循 环结束后,根据flag的值判断是否有序;有序退出即可
voidtraverseLoop(TreeNoderoot) {
Stack<TreeNode>stack=newStack<>();
TreeNodecurr=root;
while (curr!=null||!stack.isEmpty()) {
while (curr!=null) {
stack.push(curr);
curr=curr.left;
}
curr=stack.pop();
System.out.println(curr.val);
curr=curr.right;
}
}
10
11
12
13
14
15
16
17
18
19
20
21
22
publicclassTestBubbleSort {
publicstaticvoidsort(int[] a) {
inttemp=0;
// 外层循环,它决定一共走几趟
for (inti=0; i<a.length-1; ++i) {
//内层循环,它决定每趟走一次
for (intj=0; j<a.length-i-1 ; ++j) {
//如果后一个大于前一个
if (a[j+1] <a[j]) {
//换位
temp=a[j];a[j] =a[j+1];a[j+1] =temp;
}
}
}
publicstaticvoidsort2(int[] a) {
inttemp=0;
for (inti=0; i<a.length-1; ++i) {
//通过符号位可以减少无谓的比较,如果已经有序了,就退出循环
intflag=0;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
13、选择排序算法和代码实现
选择排序也是一种最基本的排序算法,其算法思想如下: 1)整个数列分成两部分:前面是有序数列,后面是无序数列 2)初始状态下,整个数列都是无序的,有序数列是空 3)一共n个数,需要n-1趟循环(一趟都不能少) 4)每比较完一趟,有序数列数量+1,无序数列数量-1 5)每趟先假设无序数列的第1个元素(整个数列的第i个元素)是最小的,让当前的最小数,从第i+1个元 素开始比较,一直比较到最后一个元素。如果发现更小的数,就假设当前数是最小数。 6)一趟比较完后,将发现最小数和无序数列的第一个数交换(如果最小数不是无序数列的第一个数) 冒泡排序最多需要n-1趟循环,最少1趟循环;选择排序必须进行n-1趟循环,一趟也不能少 冒泡排序中最多的操作就是比较和交换,一趟循环中可能发生多次交换;而选择排序中最多的操作是比 较,一趟比较结束后如果发现更小的值才交换一次。
for (intj=0; j<a.length-1-i ; ++j) {
if (a[j+1] <a[j]) {
temp=a[j];a[j] =a[j+1];a[j+1] =temp;
flag=1;
}
}
if(flag==0){ break;}
}
}
}
21
22
23
24
25
26
27
28
29
30
publicclassTestSelectSort {
publicstaticvoidsort(intarr[]) {
inttemp=0;
for (inti=0; i<arr.length-1; i++) {
// 认为目前的数就是最小的, 记录最小数的下标
intminIndex=i;
for (intj=i+1; j<arr.length; j++) {
if (arr[minIndex] >arr[j]) {
// 修改最小值的下标
minIndex=j;
}
}
// 当退出for就找到这次的最小值
if (i!=minIndex) {
temp=arr[i];
arr[i] =arr[minIndex];
arr[minIndex] =temp;
}
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
14、直接插入排序的算法和代码实现
直接插入排序基本思想是:
仅有一个元素的序列总是有序的,因此,对 n 个记录的序列,可从第二个元素开始直到第 n 个元素,逐 个向有序序列中执行插入操作,从而得到 n 个元素按关键字有序的序列。 一般来说,在含有 j-1 个元素的有序序列中插入一个元素的方法是:从第 j-1 个元素开始依次向前搜索应 当插入的位置,并且在搜索插入位置的同时可以后移元素,这样当找到适当的插入位置时即可直接插入 元素
publicclassTestInsertSort {
publicstaticvoidsort(intarr[]) {
inti, j;
for (i=1; i<arr.length; i++) {
inttemp=arr[i];
for (j=i; j>0&&temp<arr[j-1]; j--) {
arr[j] =arr[j-1];
}
arr[j] =temp;
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
二、面向对象
本部分面试题涉及Java面向对象核心内容,比如面向过程和面向对象、类和对象、类的三大特征、抽象 类和接口、内部类等内容。关于JVM和垃圾回收的进阶内容,后面有专门章节列出
1、说出成员变量和局部变量的区别
物理位置 成员变量: 类中方法外 局部变量: 方法中或方法定义的小括号里面 内存位置 成员变量: 在堆内存中 局部变量: 通常在栈内存中(栈帧) 生命周期 成员变量: 随着对象创建而产生,随着对象的消失而消失 局部变量: 随着方法的调用而产生,随着方法调用结束而消失 有无默认值 成员变量: 有默认值, 整数0,小数0.0 字符 ‘\u0000’ 布尔 false 引用数据类型 null 局部变量: 没有默认值,使用的时候,必须先赋值 备注:成员变量根据是否使用static修饰可分为静态成员变量和实例成员变量,以上分析针对后者
2、静态成员变量与实例成员变量的区别
使用static修饰的成员变量称为静态成员变量,是属于某个类的;而不使用static修饰的成员变量成为实 例成员变量,是属于类的每个对象的。区别如下: 内存份数:不管有多少个对象,static变量只有1份。对于每个对象,实例变量都会有单独的一份 内存位置 : 静态变量在(jdk6在方法区中)/(jdk8在堆中),实例变量在堆内存中。 生命周期 : 静态变量随着.class文件加载而产生,随着.class文件结束而结束; 实例变量随着对象的创 建而产生,随着对象的结束而结束。 调用方式: 静态变量既可以通过类名.直接进行调用, 也可以通过对象名.进行调用; 实例变量只能通 过对象名.进行访问。 备注:静态成员变量和实例成员变量都是成员变量。局部变量不能使用static修饰
3、static关键字都能修饰什么?都有什么特点
一个类的成员包括变量、方法、构造方法、代码块和内部类,static可以修饰除了构造方法以外的所有
成员。 修饰成员变量, 叫静态变量具有共享性,节省内存空间; 修饰方法: 静态方法: 可以直接使用类名.进行调用; 修饰代码块: 静态代码块 ,给静态变量进行赋值;当类被第一次加载时执行静态代码块,且只被执行 一次
访问控制
public
protected
默认
private
同一类中成员 是 是 是 是 同一包中其它类 是 是 是 不同包中的子类 是 是 不同包中对非子类 是
英文
位置不同
作用不同
重 载 overload 同一个类中 在一个类里面为一种行为提供多种实现方式并提高可读性 重 写 override 子类和父类 间 父类方法无法满足子类的要求,子类通过方法重写满足要 求
修饰符
返回值
方法名
参数
抛出异常
重载 无关 无关 相同 不同 无关 重写 大于等于 小于等于 相同 相同 小于等于 修饰内部类: 静态内部类;属于整个外部类,而不是属于外部类的每个对象。不能访问外部类的非 静态成员,可以访问外部类的静态成员 备注:还有静态导入 import static java.lang.Math.*; 非导入类和接口,而是导入类和接口的静态成员
4、private/默认/protected/public权限修饰符的区别
这是Java中的四个权限修饰符,和面向对象的三大特征的封装性有密切关系。这四个修饰符都可以修饰 类的成员,另外其中的默认和public还可以修饰类。
修饰类的成员
修饰类
public:公共的可被同一项目中所有包的所有类访问。 (须与文件名同名) 默认:可被同一个包中的类访问。 备注:内部类属于类的成员,所以可以使用四个修饰符修饰,这一点和类不同
5、overload和override(重载和重写)的区别
核心区别:作用不同(解决的问题不同) 语法区别:一个方法的声明自左向右包括权限修饰符、方法返回值、方法名、参数列表、抛出的异常类 型等。下面从这几方面说明区别,不会有遗漏 重载举例:构造方法重载、println()方法重载 重写举例:Object类的toString()、equals()、hashCode()等都可以被子类重写 备注:静态方法无法进行方法重写,添加@override注解将会报错
6、 this和super都能用到哪些地方
this是对象内部指代自身的引用,super代表对当前对象的直接父类对象的引用 1)、访问成员变量 this:可以区分成员变量与局部变量重名问题,如果本类没有这个成员变量,也可以调用父类的成员变量。 super:可以区分本类成员变量与父类成员变量重名问题,只能调用父类的成员变量。 2)、访问成员方法 this:可以调用本类的成员方法,如果本类没有这个成员方法,也可以调用父类的成员方法。 super:只能调用父类的成员方法。 3)、访问构造器 this:可以通过this() 或 this(参数) 让其本类的构造方法直接相互调用。 super:子类通过super() 或 super(参数) 调用父类的构造方法。
7、 == 和 equals的区别
在Java中,”==“是一个比较操作符,用于比较两个变量的值是否相等。而”equals()“是Object类中定义
的方法,用于比较两个对象是否相等。
具体区别如下:
- ”==“用于比较基本数据类型和引用类型变量的地址值是否相等。对于基本数据类型,比较的是它们 的实际值;对于引用类型,比较的是它们所引用的对象的地址值。
- “equals()“方法用于比较两个对象的内容是否相等。默认情况下,它与”==“的作用相同,比较的是 对象的地址值。但是,可以根据具体的类重写该方法,以实现自定义的比较逻辑。
需要注意以下几点:
对于基本数据类型,使用”==“进行比较更加直接和高效。 对于引用类型,使用”equals()“进行比较更加准确和灵活,但需要注意重写”equals()“方法,以满足 自定义的比较需求。 总结起来,”==“比较的是变量的值或引用的地址值,而”equals()“比较的是对象的内容。
8、构造器是否可被重写
构造器是 Java 中用于创建并初始化类的对象的特殊方法,与普通成员方法存在本质区别:构造器的名 称必须与所属类的类名完全一致,且没有任何返回类型(包括 void 也不能声明)。 在 Java 中,构造器完全不支持重写(Override)。重写的核心要求是子类定义与父类方法名、参数列 表完全一致的方法,而构造器因名称与类名强绑定,子类构造器的名称必然与父类不同(例如父类 Person 的构造器为Person() ,子类Student 的构造器只能是Student() ),根本无法满足重写的基 础条件;同时,构造器的核心作用是完成当前类对象的实例化与初始化,若允许重写,会破坏继承体系 中 “先初始化父类、再初始化子类” 的逻辑,导致对象创建和初始化过程混乱,违背类的设计原则。 不过,子类可以通过调用父类的构造器来完成父类成员的初始化操作。在子类构造器中,可通过super 关键字显式调用父类的无参或有参构造器(若未显式调用,编译器会默认在子类构造器第一行插入 super() ,调用父类无参构造器),并传递对应的初始化参数。这种方式能确保父类的构造器被正确执 行,从而完成父类属性的初始化,是子类复用父类初始化逻辑的合法方式。 总结来说,构造器本身不具备被重写的条件和可能,但子类可通过super 关键字调用父类构造器,实现 对父类成员的初始化,而非重写父类构造器的逻辑。
9、Java支持多继承么,为什么
Java不直接支持多继承,即一个类不能同时继承多个父类。这是由设计上的考虑和语言特性决定的。 Java中选择了单继承的设计,主要出于以下几个原因:
- 继承的复杂性:多继承会引入菱形继承等复杂性问题。当一个类同时继承自多个父类时,可能会出 现命名冲突、方法重复实现等问题,导致代码难以理解和维护。
- 接口的存在:Java提供了接口(Interface)的概念来解决多继承的问题。接口允许一个类实现多个 接口,从而达到类似多继承的效果。接口与类的分离可以降低代码的耦合度,并且使得类的设计更 加灵活和可扩展。
- 单一职责原则:Java鼓励使用组合而非继承的方式,遵循设计原则中的单一职责原则。通过将功能 划分为独立的类,然后在需要时进行组合,可以实现更灵活、可复用的代码结构,提高代码的可维 护性。 尽管Java不支持直接的多继承,但可以使用接口或抽象类等方式来模拟部分多继承的功能。接口提供了 一种更灵活、更安全的多继承方式,允许类实现多个接口并获得各个接口的方法声明,同时避免了多继 承的复杂性问题。
10、Object类的方法有哪些?
Object类是所有类的顶级类,能够放在Object类的中方法肯定不简单。非常常规的问题,打开IDEA直接 瞧一眼 toString() - 返回一个以字符串形式表示当前对象的信息。 equals() - 比较两个对象是否相等,默认 == hashCode() - 返回对象的hash值。目的是为了更好的支持哈希表,比如基于Java中的HashMap使 用 getClass() - 获取运行是的Class对象,属于反射的那一套,获取运行是的一些数据。 clone() - 创建对象的副本。深拷贝和浅拷贝的内容 默认是浅拷贝,将当前对象复制一份,其中的基本数据类型直接复用值,引用数据类型是复用 地址值。 深拷贝,将当前对象复制一份,其中的基本数据类型直接复用值,引用数据类型会重新的创建 一个,不会复制之前的地址。深拷贝要自己编写克隆内部的引用类型对象。 finalize() - 当触发垃圾回收时,如果当前对象无法基于可达性分析定位到,就会被垃圾回收器回收 掉,在回收之前,如果这个对象重写了finalize,那就会触发finalize方法执行。可以执行一些其他 的清理工作。(Finalize在JVM中,他不保证一定执行,他用的守护线程) wait() - 当某个线程持有当前对象锁时,可以执行对象锁.wait,将持有对象锁的线程挂起等待。 notify() - 当某个线程持有当前对象锁时,可以执行对象锁.notify,唤醒之前基于wait挂起的一个线 程。 notifyAl() - 当某个线程持有当前对象锁时,可以执行对象锁.notifyAll方法,唤醒之前所有基于wait 挂起的线程。 备注:9个方法中clone() 、finalize()是protected方法,其他都是public方法; 还有 hashCode()、getClass() 、clone()、wait()、notify()、notifyAl()共6个方法是native本地方法(Java 本身无法实现,需要调用C/C++实现的代码)
11、为啥要有clone(克隆)?
Java中提供的clone方法主要是允许将一个对象进行copy,做到快速的复制一个对象。而不需要new一个 空对象,然后自己慢慢导入数据,开发效率太低。 这可以减少内存分配和垃圾回收的压力,尤其是在创建大量对象时。 默认的方法实现是浅拷贝。如果需要深拷贝,必须重写clone 方法,并在其中实现深拷贝的逻辑。 如果再问clone,基本就是聊聊深拷贝,和浅拷贝。 默认是浅拷贝,将当前对象复制一份,其中的基本数据类型直接复用值,引用数据类型是复用地址 值。 深拷贝,将当前对象复制一份,其中的基本数据类型直接复用值,引用数据类型会重新的创建一 个,不会复制之前的地址。深拷贝要自己编写克隆内部的引用类型对象。 备注:设计模式中的原型模式(prototype)是一种创建型设计模式,它就是通过克隆clone()已有对象 来快速创建新对象,而不是通过new关键字。
12、浅拷贝和深拷贝
深拷贝和浅拷贝就是指对象的拷贝,一个对象中存在两种类型的属性,一种是基本数据类型,一种是实 例对象的引用。 1.浅拷贝是指,对基本数据类型进行值传递,对引用数据类型进行引用传递般的拷贝。 2.深拷贝是指,对基本数据类型进行值传递,对引用数据类型,创建一个新的对象,并复制其内容,此 为深拷贝。
浅拷贝例子:
packagese.copy;
classPhoneimplementsCloneable {
privateStringname;
1
2
3
4
publicStringgetName() {
returnname;
}
publicvoidsetName(Stringname) {
this.name=name;
}
@Override
protectedObjectclone() throwsCloneNotSupportedException {
returnsuper.clone();
}
}
classPersonimplementsCloneable {
privateStringname;
privatePhonephone;
publicStringgetName() {
returnname;
}
publicvoidsetName(Stringname) {
this.name=name;
}
publicPhonegetPhone() {
returnphone;
}
publicvoidsetPhone(Phonephone) {
this.phone=phone;
}
@Override
protectedObjectclone() throwsCloneNotSupportedException {
returnsuper.clone();
}
}
publicclassShallowCopy {
// 浅拷贝
publicstaticvoidmain(String[] args) throwsCloneNotSupportedException
{
Phonephone=newPhone();
phone.setName("小米");
// 创建一个Person对象(原对象)
Personperson=newPerson();
person.setName("hzk");
// 设置引用类型属性
person.setPhone(phone);
// 打印原对象的属性值
System.out.println(person);
System.out.println(person.getName());
System.out.println(person.getPhone());
System.out.println(person.getPhone().getName());
System.out.println("-----------------");
// 克隆一个Person对象(克隆对象)
PersoncopyPerson= (Person) person.clone();
// 打印克隆对象的属性值
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
56
57
58
59
60
61
运行结果:
深拷贝例子:
System.out.println(copyPerson);
System.out.println(copyPerson.getName());
System.out.println(copyPerson.getPhone());
System.out.println(copyPerson.getPhone().getName());
// 浅拷贝由于会拷贝引用数据类型的地址,因此修改拷贝对象的值,其被拷贝对象的值也会 跟着变化。反之,同理。
copyPerson.getPhone().setName("华为");
System.out.println("原对象的引用类型Phone值"+
person.getPhone().getName());
System.out.println("克隆对象的引用类型Phone值"+
copyPerson.getPhone().getName());
}
}
62
63
64
65
66
67
68
69
70
71
72
73
74
75
packagese.copy;
classPhone1implementsCloneable {
privateStringname;
publicStringgetName() {
returnname;
}
publicvoidsetName(Stringname) {
this.name=name;
}
@Override
protectedObjectclone() throwsCloneNotSupportedException {
returnsuper.clone();
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
classPerson1implementsCloneable {
privateStringname;
publicStringgetName() {
returnname;
}
publicvoidsetName(Stringname) {
this.name=name;
}
publicPhone1getPhone() {
returnphone;
}
publicvoidsetPhone(Phone1phone) {
this.phone=phone;
}
privatePhone1phone;
@Override
protectedObjectclone() throwsCloneNotSupportedException {
// return super.clone();
//继续利用clone()方法,对该对象的引用类型变量再实现一次clone()方法。 // 要想深克隆要不就是序列化和反序列化要不就是继续clone
Person1person= (Person1) super.clone();
person.setPhone((Phone1) person.getPhone().clone());
returnperson;
}
}
publicclassDeepCopy {
// 深拷贝
publicstaticvoidmain(String[] args) throws
CloneNotSupportedException{
Phone1phone1=newPhone1();
phone1.setName("小米");
Person1person1=newPerson1();
person1.setName("hzk");
person1.setPhone(phone1);
System.out.println(person1);
System.out.println(person1.getName());
System.out.println(person1.getPhone());
System.out.println(person1.getPhone().getName());
System.out.println("-----------------");
Person1copyPerson1= (Person1) person1.clone();
System.out.println(copyPerson1);
System.out.println(copyPerson1.getName());
System.out.println(copyPerson1.getPhone());
System.out.println(copyPerson1.getPhone().getName());
System.out.println("-----------------");
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
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
运行结果
13、final,finally,finalize的区别
在Java中,final、finally和finalize是三个不同的关键字,它们具有不同的作用和用法。
- final: final是一个修饰符,可以用于修饰类、方法和变量。 用于修饰类时,表示该类不能被继承,即为最终类。 用于修饰方法时,表示该方法不能被子类重写。 用于修饰变量时,表示该变量是一个常量,其值不能被修改。
- finally: finally是一个关键字,用于定义一个代码块,通常与try-catch结构一起使用。 finally块中的代码无论是否抛出异常,都会被执行。 finally块通常用于释放资源、关闭连接或执行必要的清理操作。
- finalize: finalize是Object类中的一个方法,被用于垃圾回收机制。 finalize方法在对象被垃圾回收之前被调用,用于进行资源释放或其他清理操作。 通常情况下,我们不需要显式地调用finalize方法,而是交由垃圾回收器自动调用。 总结: final是修饰符,用于限定类、方法和变量的性质。 // 深拷贝不会拷贝引用数据类型的地址(而是会创建一个新对象空间), //因此修改拷贝对象的值,其被拷贝对象的值不会跟着变化。反之,同理。
copyPerson1.getPhone().setName("华为");
System.out.println("原对象的引用类型Phone值---"+
person1.getPhone().getName());
System.out.println("克隆对象的引用类型Phone值---"+
copyPerson1.getPhone().getName());
}
}
76
77
78
79
80
81
82
83
finally是一个关键字,用于定义一个代码块,在异常处理中用于确保特定代码无论如何都会被执 行。 finalize是一个Object类中的方法,用于对象的垃圾回收前的清理操作。 备注:finalize方法已被废弃,不推荐使用。在现代Java中,可以使用try-with-resources语句或手动释 放资源的方式来替代finalize方法的功能。
14、Java 创建对象有几种方式
在 Java 中,有以下几种常见的方式来创建对象:
- 使用 new 关键字:这是最常见、最推荐的创建对象的方式。通过调用类的构造器(无参或有参均 可),使用 new 关键字可直接在堆内存中分配新的对象空间,并完成对象属性的初始化。
- 使用反射:Java 的反射机制允许在运行时动态创建对象。需先获取类的 Class 对象,再通过 Class 对象获取对应的 Constructor 构造器对象,最后调用构造器的 newInstance () 方法,即可实现对 象的动态创建。
- 使用 Class 类的 newInstance () 方法:这是反射创建对象的简化写法,仅适用于具有默认无参构 造器的类;需注意该方法在 Java 9 及以上版本已被标记为过时,推荐替换为 Constructor 类的 newInstance () 方法。
- 使用 clone () 方法:如果类实现了 Cloneable 接口(否则会抛出 CloneNotSupportedException 异常),就可以使用 clone () 方法创建已有对象的副本,该方式创建对象时不会调用类的构造器。
- 使用对象的反序列化:通过将实现了 Serializable 接口的对象序列化为字节流,再将字节流反序列 化,可还原出对象实例;该方式同样无需调用类的构造器,常用来实现对象的持久化或跨进程传 输。 其中,使用 new 关键字是最基础、最易维护的创建对象方式。其他方式通常在特定场景下使用:反射适 用于动态创建对象的场景(如框架开发),clone () 适用于快速复制已有对象的场景,反序列化适用于 对象跨介质复用的场景,而 Class.newInstance () 仅建议在兼容旧版代码时使用。
15、多态的技能点(前提条件,向上转型、向下转型)
实现多态的三个条件
- 继承的存在;(继承是多态的基础,没有继承就没有多态)
- 子类重写父类的方法。(多态下会调用子类重写后的方法)
- 父类引用变量指向子类对象。(涉及子类到父类的类型转换)
向上转型 Person person = new Student()
- 将一个父类的引用指向一个子类对象,称为向上转型,自动进行类型转换。
- 此时通过父类引用变量调用的方法是子类覆盖或继承父类的方法,而不是父类的方法
- 此时通过父类引用变量无法调用子类特有的方法
向下转型 Student stu = (Student)person;
- 将一个指向子类对象的引用赋给一个子类的引用,称为向下转型,此时必须进行强制类型转换。
- 向下转型必须转换为父类引用指向的真实子类类型,否则将出现ClassCastException,不是任意的强 制转换
- 向下转型时可以结合使用instanceof运算符进行强制类型转换,比如出现ClassCastException异常。 备注:接口和实现类之间也可以实现多态,不见得必须是子类和父类之间
抽象类
接口
有单继承限制 没有单继承限制 有构造器 没有构造器 有成员变量 没有普通成员变量。只有公共的静态的常量。 可以包含抽象方法和普通方法。 JDK8 之前都是公共的抽象方法。 JDK8 之后允许有公共的静态方法和默认方法。 JDK9 之后允许有私有的方法。
16、接口与抽象类的区别
抽象类和接口是Java中的两种机制,用于实现类之间的继承和多态性。它们有以下几点区别: 设计目的:抽象类的设计目的是提供类的继承机制,实现代码复用,适用于拥有相似行为和属性的 类;接口的设计目的是定义一组规范或契约,实现类遵循特定的行为和功能,适用于不同类之间的 解耦和多态性实现。继承抽象类体现的是”is-a”关系,而实现接口体现的是”can-do”关系。 定义和设计:抽象类是使用abstract关键字定义的类,可以包含抽象方法和非抽象方法,可以有实 例变量和构造方法;接口通过interface关键字定义,只能包含抽象方法、默认方法和静态方法,不 包含实例变量或构造方法。 继承关系:一个类只能继承自一个抽象类,但可以实现多个接口。 构造方法:抽象类可以有构造方法,子类可以通过super()调用父类的构造方法;接口没有构造方 法。 默认实现:抽象类可以包含非抽象方法,子类可以直接使用;接口可以包含默认方法,提供通用实 现,子类可以选择重写或者使用默认实现。 总之,抽象类和接口是实现继承和多态性的两种机制。抽象类和接口的设计目的、定义和使用方法等方 面都有所区别,需要根据实际情况选择合适的方式进行设计和使用。 把控细节:
抽象类:
例子(example):
packagese.abstracts;
publicabstractclassAbstractDemo {
// 抽象类和普通类基本没有区别(只是多了一个可以定义抽象方法)
privateinta;// 成员变量
privatestaticintb;//静态变量
privatestaticfinalintc=1;//常量
// 1.抽象类中静态方法—有方法的实现
publicstaticvoida() {
}
// 2.抽象类中构造方法
publicAbstractDemo(inta) {
this.a=a;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
接口:
例子(example):
17、说说你对内部类的理解
1) 什么是内部类?
}
// 3.抽象类中实例方法
publicvoidb() {
}
// 4.抽象类中定义一个抽象方法,注意没有方法实现体。
//public abstract void c(){ //fail
//
//}
publicabstractvoidc();// ok
}
18
19
20
21
22
23
24
25
26
27
28
29
30
31
packagese.abstracts;
publicinterfaceInterfaceDemoextendsInterfaceDemo1, InterfaceDemo2 {
// 1.接口中不能定义静态方法—但是可以有静态方法的实现
publicstaticvoida() {
}
//public static void b();//fail
// 2.接口中不可以定义构造方法
//public InterfaceDemo(){//fail
//
//}
// 3.接口中不可以定义实例方法(没有static 也没有abstract关键字)
//public void c();//fail
// 4.接口中不能定义变量—只能有常量
publicstaticfinalintd=1;
//public int x;//fail
// 5.接口中所有的默认方法都是public abstract修饰且访问修饰符必须要是public或者
不写,不写则使用默认(注意:默认指的不是default)
// 抽象类中的访问修饰符四种都可以(public protected private 不写)
voidworld();// public abstract
// 1.8允许接口中有方法的实现,但是必须用关键字default修饰
defaultStringhello() {
return"hello";
}
}
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
内部类是Java中一种特殊的类,它定义在其他类或方法中,并且可以访问外部类的成员,包括私有成 员。它允许类之间共享代码和数据,同时可以提高代码的模块化和封装性。 内部类的主要作用是实现更加灵活和封装的设计。需要注意的是,过度使用内部类会增加代码的复杂 性,降低可读性和可维护性。因此,在使用内部类时要考虑其是否真正有必要,并且仔细进行设计和命 名。 内部类是 Java 中一个非常重要的特性,它允许在一个类的内部定义另一个类。内部类在实际开发中有很 多应用场景,比如事件处理、封装和模块化设计等。以下是一些常见的面试问题和答案,帮助你深入理 解内部类。 案例:
2) 内部类的类型有哪些?
内部类分为如下几种: 成员内部类:定义在一个类的内部,并且不是静态的。成员内部类可以访问外部类的所有成员,包 括私有成员。在创建内部类对象时,需要先创建外部类对象,然后通过外部类对象来创建内部类对 象。 静态内部类:定义在一个类的内部,使用static关键字修饰的内部类。与成员内部类不同,静态 内部类不能访问外部类的非静态成员,但可以访问外部类的静态成员。在创建静态内部类对象时, 不需要先创建外部类对象,可以直接通过类名来创建。 局部内部类:定义在一个方法或作用域块中的类,它的作用域被限定在方法或作用域块中。局部内 部类可以访问外部方法或作用域块中的 final 变量和参数。 匿名内部类:没有定义名称的内部类,通常用于创建实现某个接口或继承某个类的对象。匿名内部 类会在定义时立即创建对象,因此通常用于简单的情况,而不用于复杂的类结构。
3) 成员内部类
成员内部类是定义在类内部的类,可以直接访问外部类的成员变量和方法。
publicclassOuterClass {
privateintouterField=10;
// 成员内部类
classInnerClass {
publicvoiddisplay() {
System.out.println("Outer field: "+outerField);
}
}
publicstaticvoidmain(String[] args) {
OuterClassouter=newOuterClass();
OuterClass.InnerClassinner=outer.newInnerClass();
inner.display();
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
publicclassOuterClass {
privateintouterField=10;
classInnerClass {
publicvoiddisplay() {
System.out.println("Outer field: "+outerField);
}
}
1
2
3
4
5
6
7
8
4) 静态内部类
静态内部类使用static关键字修饰,它不能访问外部类的非静态成员变量和方法。
5) 局部内部类
局部内部类定义在方法或构造函数中,只能在定义它的作用域内使用。
6) 匿名内部类
匿名内部类没有类名,通常用于实现接口或继承类。它常用于简化代码,特别是在事件处理中。
publicstaticvoidmain(String[] args) {
OuterClassouter=newOuterClass();
OuterClass.InnerClassinner=outer.newInnerClass();
inner.display();
}
}
9
10
11
12
13
14
15
publicclassOuterClass {
privatestaticintstaticField=20;
staticclassStaticInnerClass {
publicvoiddisplay() {
System.out.println("Static field: "+staticField);
}
}
publicstaticvoidmain(String[] args) {
OuterClass.StaticInnerClassstaticInner=new
OuterClass.StaticInnerClass();
staticInner.display();
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
publicclassOuterClass {
publicvoidcreateInnerClass() {
classLocalInnerClass {
publicvoiddisplay() {
System.out.println("Local Inner Class");
}
}
LocalInnerClasslocalInner=newLocalInnerClass();
localInner.display();
}
publicstaticvoidmain(String[] args) {
OuterClassouter=newOuterClass();
outer.createInnerClass();
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
7) 内部类的作用是什么(优点)?
内部类的主要作用包括: 封装性:将内部类定义为private类,它的作用范围只限于当前外部类,这是更小的类的访问范围。 外部类只能使用默认和public修饰。
内部类可以直接访问外部类的成员,哪怕是private
间接实现多重继承:外部类、多个内部类可以分别实现不同的父类。 用匿名内部类实现回调功能 解决继承及实现接口出现同名方法的问题 事件处理:内部类常用于 GUI 编程中的事件处理。 简化代码:匿名内部类可以简化代码,特别是在实现接口时。
8) 内部类的缺点是什么?
复杂性:内部类的生命周期与外部类相关联,可能会导致内存泄漏。 性能开销:内部类会增加内存占用,因为每个内部类实例都包含对外部类实例的引用。
9) 内部类的生命周期
内部类的生命周期与外部类的实例相关联。非静态内部类的实例必须依附于外部类的实例,而静态内部 类的实例可以独立存在。
10) 内部类在实际开发中的应用
内部类在实际开发中非常常用,尤其是在以下场景: 事件处理:在 GUI 编程中,内部类常用于事件监听器的实现。 封装:内部类可以封装相关代码,提高代码的模块化。 简化代码:匿名内部类可以简化代码,特别是在实现接口时。
publicclassAnonymousInnerClassExample {
publicstaticvoidmain(String[] args) {
Consumer<String>consumer=newConsumer<String>() {
@Override
publicvoidaccept(Strings) {
System.out.println("Consumed: "+s);
}
};
consumer.accept("Hello, Anonymous Inner Class!");
}
}
1
2
3
4
5
6
7
8
9
10
11
publicclassOuterClass {
classInnerClass {
// 非静态内部类必须依附于外部类的实例
}
staticclassStaticInnerClass {
// 静态内部类可以独立存在
}
}
1
2
3
4
5
6
7
8
9
importjavax.swing.*;
importjava.awt.event.ActionEvent;
importjava.awt.event.ActionListener;
1
2
3
4
总结
内部类是 Java 中一个非常强大的特性,它允许在类内部定义另一个类,从而实现更紧密的封装和模块化 设计。在面试中,理解内部类的基本概念、类型、优缺点以及应用场景是非常重要的。
18、静态内部类与非静态内部类有什么区别
在Java中,静态内部类和非静态内部类都是一种嵌套在其他类中的内部类。它们之间有以下几点区别:
- 实例化方式:静态内部类可以直接通过外部类名来实例化,而非静态内部类必须要通过外部类的实 例来实例化。
- 对外部类的引用:静态内部类不持有对外部类实例的引用,而非静态内部类则会持有对外部类实例 的引用。这意味着在静态内部类中不能直接访问外部类的非静态成员(方法或字段),而非静态内 部类可以。
- 生命周期:静态内部类的生命周期与外部类相互独立,即使外部类实例被销毁,静态内部类仍然存 在。非静态内部类的生命周期与外部类实例绑定,只有在外部类实例存在时才能创建非静态内部类 的实例。
- 访问权限:静态内部类对外部类的访问权限与其他类一样,根据访问修饰符而定。非静态内部类可 以访问外部类的所有成员,包括私有成员。
publicclassGUIExample {
publicstaticvoidmain(String[] args) {
JFrameframe=newJFrame("Button Example");
JButtonbutton=newJButton("Click Me");
// 使用匿名内部类实现事件监听器
button.addActionListener(newActionListener() {
@Override
publicvoidactionPerformed(ActionEvente) {
System.out.println("Button clicked!");
}
});
frame.add(button);
frame.setSize(300, 200);
frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
frame.setVisible(true);
}
}
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
三、异常处理和工具类
本部分面试题涉及异常处理、常用工具类等内容
1、异常结构图
Throwable分为两大类:
- Error类,表示仅靠程序本身无法恢复的严重错误,比如说内存溢出、动态链接异常、虚拟机错 误。应用程序不应该抛出这种类型的对象。假如出现这种错误,除了尽力使程序安全退出外,在其 他方面是无能为力的。所以在进行程序设计时,应该更关注Exception类。
- Exception类,由Java应用程序抛出和处理的非严重错误,比如所需文件没有找到、零作除数,数 组下标越界等。它的各种不同子类分别对应不同类型异常。可分为两类:Checked异常和Runtime 异常
Exception异常分为两大类
- 运行时异常:包括RuntimeaException及其所有子类。不要求程序必须对它们作出处理,比如 ArithmeticException、NullPointerException等。即使没有使用try-catch或throws进行处理,仍 旧可以进行编译和运行。如果运行时发生异常,会输出异常的堆栈信息并中止程序执行。
- Checked异常(检查异常):除了运行时异常外的其他异常类都是Checked异常。程序必须捕获或 者声明抛出这种异常,否则出现编译错误,无法通过编译。处理方式包括两种:通过try-catch捕获 异常,通过throws声明抛出异常从而交给上一级调用方法处理。
2、有哪些常见的运行时异常
运行时异常是在 Java 程序运行过程中才会出现的异常,通常情况下不需要进行 try-catch 处理。以下是6 个常见的运行时异常: 空指针异常(NullPointerException):当应用程序尝试使用 null 对象时抛出。 数组越界异常(ArrayIndexOutOfBoundsException):当应用程序尝试访问数组元素的时候, 数组下标超出了数组的范围。 类型转换异常(ClassCastException):当应用程序尝试将一个对象强制转换为不是其实例的子 类时抛出。 非法参数异常(IllegalArgumentException):当应用程序传递一个无效或不合法参数时抛出。 算术异常(ArithmeticException):比如除数为0。 数字格式异常(NumberFormatException):字符串转数字格式错误,如int num = Integer.parseInt(“abc”); 这些异常是在程序运行过程中出现的,并且多数情况下是由于编程错误造成的。因此,在编写 Java 程序 时,应该避免出现这些异常。如果必须出现,也应该在代码设计时加以处理,避免对应用程序的正常运 行造成影响。
3、Java中的异常处理机制是怎样的
异常是在程序执行过程中可能出现的错误或意外情况。它们通常表示了程序无法正常处理的情况,如除
零错误、空指针引用、文件不存在等。
Java中的异常处理机制通过使用try-catch-finally语句块来捕获和处理异常。具体的处理过程如下:
- 使用try块包裹可能会抛出异常的代码块。一旦在try块中发生了异常,程序的控制流会立即跳转到 与之对应的catch块。
- 在catch块中,可以指定捕获特定类型的异常,并提供相应的处理逻辑。如果发生了指定类型的异 常,程序会跳转到相应的catch块进行处理。一个try块可以有多个catch块,分别处理不同类型的异 常。
- 如果某个catch块成功处理了异常,程序将继续执行catch块之后的代码。
- 在catch块中,可以通过throw语句重新抛出异常,将异常交给上一级的调用者处理。
- 可以使用finally块来定义无论是否发生异常都需要执行的代码。finally块中的代码始终会被执行, 无论异常是否被捕获。 通过合理使用异常处理机制,可以使程序更具健壮性和容错性。在处理异常时,应根据具体情况选择是 恢复正常执行、报告错误给用户,还是终止程序运行。同时,应避免过度捕获异常和不处理异常导致的 问题,以及使用异常替代正常程序流程控制的做法。 备注:try-catch是正确的语法,比较常见;try-finally也是正确的语法,但是很少见
4、throw和throws 的区别
throw 是具体抛出一个异常对象,在方法的内部, 后面有且只能有一个异常对象,代码一旦遇到了throw证 明出现了问题,代码就会停止,线程会异常退出。 throws 是异常的声明, 在方法定义的小括号后面,后面可以跟多个异常的类型,方法有throws,代码不一定 发生异常。 作用不同:throw用于程序员自行产生并抛出异常;throws用于声明在该方法内抛出了异常 使用的位置不同:throw位于方法体内部,可以作为单独语句使用;throws必须跟在方法参数列表 的后面,不能单独使用。 内容不同:throw抛出一个异常对象,且只能是一个;throws后面跟异常类,而且可以有多个。
5、int和Integer的区别
int和Integer之间的区别主要在以下几个方面:
- 数据类型:int是Java的基本数据类型,而Integer是int的包装类,属于引用类型。
- 可空性:int是基本数据类型,它不能为null。而Integer是一个对象,可以为null。
- 自动装箱与拆箱:int可以直接赋值给Integer,这个过程称为自动装箱;而Integer也可以直接赋值 给int,这个过程称为自动拆箱。
- 性能和内存开销:由于int是基本数据类型,它的值直接存储在栈内存中,占用的空间较小且访问速 度快。而Integer是对象,它的值存储在堆内存中,占用的空间相对较大,并且访问速度较慢。因 此,频繁使用的整数推荐使用int,不需要使用对象特性时可以避免使用Integer。
- Integer提供了多个与整数相关的操作方法,例如,将一个字符串转换成整数,Integer中还定义了 表示整数的最大值和最小值的常量 总的来说,int是基本数据类型,适用于简单的整数运算和存储,没有对象的特性和可空性。而Integer是 int的包装类,可以作为对象使用,具有更多的方法和一些方便的功能,如转换、比较等,但相对会带来 一些性能和内存开销。
6、包装类的拆箱装箱
装箱(boxing):将基本类型转换成包装类对象。比如:Integer i1 = new Integer(100); Integer i1 = Integer.valueOf(100); 拆箱(unboxing):将包装类对象转换成基本类型的值。比如:int intValue = i.intValue(); JDK1.5开始支持自动装箱(autoboxing)和自动拆箱(autounboxing)功能, 从而实现了包装类和基本 数据类型之间的自动转换
例子(example):
packagese.packing;
publicclassIntAndIntegerDemo {
publicstaticvoidmain(String[] args) {
// 一组:两个Integer对象比较
// 结论:两个对象比较地址一定不等,则结果为false
Integerinteger=newInteger(66);
Integerinteger1=newInteger(66);
System.out.println(integer==integer1);
// 二组:Integer类型属性值和int属性值比较
//结论:包装类Integer和基本数据类型比较的时候,将包装类自动拆箱为int,然后进行 //比较,本质就是两个int变量进行比较,只要两个变量的值相等,则结果就为true
Integerinteger2=newInteger(88);
//Integer a = 88;
inti=88;
System.out.println(integer2==i);
// 三组:new Integer()类型变量值和Integer类型的变量值比较
// 结论:new Integer() 堆中地址 Integer 常量池中地址,地址不等
Integerinteger3=newInteger(88);
Integerj=88;
System.out.println(integer3==j);
// 四组:Integer类型的变量值和Integer类型的变量值(范围在:[-128~127]相等,)
// 其它则不相等会创建新的Integer对象
Integerk=-129;
Integerl=-129;
System.out.println(k==l);
}
}
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
java为什么要引入自动装箱和拆箱的功能?主要是用于java集合中,Listlist=new ArrayList(); list集合如果要放整数的话,只能放对象,不能放基本类型,因此需要将整数自动装箱成对象。
7、说说你对Integer缓存的理解
在Java中,Integer类对于一定范围的整数值进行了缓存。该范围默认是从-128到127。这意味着当创建 一个Integer对象并赋值为在此范围内的整数时,会直接从缓存中返回该数字对应的Integer对象,而不 会每次都创建新的对象。 这种缓存的设计主要是出于性能和内存优化的考虑。由于整数在编程中经常被使用,通过缓存重用 Integer对象可以减少频繁创建和销毁对象带来的开销,同时节省了内存空间。因为缓存中的对象是提前 创建好的,所以可以直接复用,不需要每次创建新的对象。 需要注意的是,虽然缓存的范围可以通过参数进行调整,但这个范围是有限制的,超出范围的整数仍然 会创建新的Integer对象。因此,在使用==比较Integer对象时,推荐使用.equals()方法进行值的比较, 以避免因为缓存机制而产生的意外结果。 Integer缓存的具体实现是Integer类中的静态内部类IntegerCache,该类全部源码如下
publicfinalclassIntegerextendsNumber
implementsComparable<Integer>, Constable, ConstantDesc {
privatestaticclassIntegerCache {
staticfinalintlow=-128;
staticfinalinthigh;
staticfinalInteger[] cache;
staticInteger[] archivedCache;
static {
// high value may be configured by property
inth=127;
StringintegerCacheHighPropValue=
VM.getSavedProperty("java.lang.Integer.IntegerCache.high");
if (integerCacheHighPropValue!=null) {
try {
h=Math.max(parseInt(integerCacheHighPropValue), 127);
// Maximum array size is Integer.MAX_VALUE
h=Math.min(h, Integer.MAX_VALUE- (-low) -1);
} catch( NumberFormatExceptionnfe) {
//If the property cannot be parsed into an int,ignore it.
}
}
high=h;
// Load IntegerCache.archivedCache from archive, if possible
CDS.initializeFromArchive(IntegerCache.class);
intsize= (high-low) +1;
// Use the archived cache if it exists and is large enough
if (archivedCache==null||size>archivedCache.length) {
Integer[] c=newInteger[size];
intj=low;
for(inti=0; i<c.length; i++) {
c[i] =newInteger(j++);
}
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
8、String,StringBuilder 与 StringBuffer 的区别
三者均是Java中用来处理字符串的类,它们之间的主要区别如下:
- 可变性: String是不可变的类,一旦创建就不能被修改。每次对String进行操作时,都会创建一个新的 String对象。 StringBuffer和StringBuilder是可变的类,可以动态修改字符串内容。
- 线程安全性: String是线程安全的,因为它是不可变的。多个线程可以同时访问同一个String对象而无需担 心数据的修改问题。 StringBuffer是线程安全的,它的方法使用了synchronized关键字进行同步,保证在多线程 环境下的安全性。 StringBuilder是非线程安全的,不使用synchronized关键字,所以在多线程环境下使用时需 要手动进行同步控制。
- 性能: 由于String是不可变的,每次对String进行操作都会创建一个新的String对象,频繁的字符串 拼接会导致大量的对象创建和内存消耗。 StringBuffer是可变的,对字符串的修改是在原有对象上进行,不会创建新的对象,因此在频 繁的字符串拼接场景下比String更高效。 StringBuilder与StringBuffer类似,但不保证线程安全性,因此在单线程环境下性能更高。 综上,如果在单线程环境下进行字符串操作,且不需要频繁修改字符串,推荐使用String;如果在多线 程环境下进行字符串操作,或者需要频繁修改字符串,优先考虑使用StringBuffer;如果在单线程环境 下进行频繁的字符串拼接和修改,推荐使用StringBuilder以获取更好的性能。 备注:String、StringBuffer、StringBuilder都被定义为final类
9、String 与new String有什么区别
Java中字符串可以通过两种方式创建:使用字符串字面量直接赋值给变量或使用关键字new创建一个新 的String对象。它们之间有以下区别:
archivedCache=c;
}
cache=archivedCache;
// range [-128, 127] must be interned (JLS7 5.1.7)
assertIntegerCache.high>=127;
}
privateIntegerCache() {}
}
}
37
38
39
40
41
42
43
44
45
46
对比维度
字面量”Hello” newString(“Hello”)
存储位置
字符串常量池 堆内存
对象创建
首次出现时在常量池创建,之后复用 每次new都创建新对象
内存效率
高(复用) 低(重复创建)
引用比较(==)
相同字面量指向同一对象 不同对象,指向不同
内容比较(equals)
相同内容返回true 相同内容返回true
10、String类能被继承吗,为什么
在Java中,String类是被final关键字修饰的,即不可继承。final关键字表示一个类不允许被其他类继 承,也就是说,String类不能被任何其他类继承。 这是因为String类具有不可变性和安全性,这些特性可以防止一些潜在的问题,如字符串池中的重用和 安全性漏洞。 如果String类能被继承,子类有可能修改原字符串的值,这将破坏字符串对象的不可变性。 另外String类内部使用private final char[] value来存储字符,这个数组不对外暴露,确保了不可变性。 因此,尽管我们不能从String类派生出新的子类,但我们可以使用String类提供的方法来操作和处理字符 串。例如,我们可以使用String类的concat()方法连接两个字符串,或使用indexOf()方法查找子串在字 符串中的位置等。String类已经包含了大量的方法,可以满足大多数字符串操作的需求。
11、金额到底用Long还是Bigdecimal
首先float和double肯定是排除的,因为它们内部使用科学计数法,转换二进制的时候有可能出现无限小 数位的问题。那么大家就会选择Long和BigDecimal, Long类型在存储时(比如保留2位小数点)x100, 取 出来/100。其实本质都是一样的,都是避免使用浮点数进行表达,只是Long属于隐式设定小数点, BigDecimal属于显式设定小数点。那么这2种到底怎么选择呢? 我的建议是:在代码层面用BigDecimal ,数据库层面可视情况定 首先long性能更好: 整数类型(如long)通常在计算机硬件上的性能更好,因为它们的操作可以在硬件层面上更有效 地执行。 BigDecimal需要额外的空间和计算开销。 阿里的ava开发手册是推荐用分存储的,希望大家都能用Long存储分,照顾一下彼此的开发体验。但是 对于一些金融系统要求小数点位数要求比较多,比如精确后六位,如果每次存x1000000 那long类型 的内存开销也荡然无存了也会降低可读性即易用性,不如用Decimal
12、使用String或StringBuffer实现具体功能示例
功能:编写一段Java程序,把一句英语中的每个单词中的字母次序倒转,单词次序保持不变,例入输入 为”There is a dog.”,输出结果应该是”erehT si a god.”要求不使用Java的库函数,例如String类的split, reverse方法 思路:将字符串转化成字符数组,然后根据数组中空格的位置判断每个单词所占的索引范围,根据得到 的索引将数组中的每个单词逆序后拼接到新的字符串中。
13、日期格式化用yyyy还是YYYY
一个工作6年的小伙伴日期时间格式化还在写大写YYYY,你是真的没踩过雷啊! 如果你现在还在用大写YYYY格式化日期,那赶紧换成小写yyyy。 有什么区别?踩过雷的我来告诉你!通常情况下可能没问题,但是在跨年的时候大概率就会有问题了。 你可以尝试把“2024-12-31 23:59:59”通过大写Y和小写Y进行格式化,会发现大写Y的日期2024年变成了 2025年,为什么会这样呢? 原因很简单,大写的YYYY表示一个基于周的年份。它是根据周计算的年份,而小写的yyyy是基于日历的 年份。 所以通常情况下,你就发现不了什么问题,但在跨年的第一周或最后一周可能会有差异。
14、JDK8的新特性
interface的新特性:(不需要说太细,点一嘴,他没啥扩展的。)
publicclassTestStringReverse {
publicstaticvoidmain(String[] args) {
Stringinput="we should study hard";
System.out.println("逆转后的字符串为:"+reverseWords(input));
}
publicstaticStringreverseWords(Stringinput) {
StringBufferbuffer=newStringBuffer("");
//将字符串转化成字符数组
char[] arr=input.toCharArray();
//index用来记录每个单词的起始索引
intindex=0;
//遍历字符数组,将空格前边的单词挨个拼接到str中
for (inti=0; i<arr.length; i++) {
if (arr[i] ==' ') {
//根据空格位置将空格前边一个单词密续追加到str中
for (intj=i-1; j>=index; j--) {
buffer.append(arr[j]);
}
//单词拼接完成后,拼接一个空格
buffer.append(" ");
//让index指向下一个单词的起始位置
index=i+1;
}
}
//将最后一个单词拼接上
for (inti=arr.length-1; i>=index; i--) {
buffer.append(arr[i]);
}
returnbuffer.toString();
}
}
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
default关键字:可以规避接口中必须要重写的这点,一些不必要的方法,不常用的方法,可以来个 default给予默认的实现。 static关键字:可以在接口中提供直接访问的方法,给予接口名.方法,去调用。
时间类新特性:
支持时区,ZonedDateTime类,允许你方便的处理不同时区的信息。不需要三方依赖库。 线程安全性,他一旦创建是不可变的,线程安全,可以在并发环境中用! 更丰富的API,之前Date需要配置Calender去做一些时间处理,而现在,java.time包下的时间类, API嘎嘎丰富。 但是注意一点,在使用time包下的时间时,注意指定好序列化JSON的序列化格式。。。。。
lambda,Stream
lambda Stream
HashMap的红黑树结构变化
HashMap在JDK 1.8中引入红黑树后,其结构变化主要体现在链表树化、红黑树拆分和红黑树退化 三个核心环节 一个语法糖,让代码看着更舒服,比如匿名内部类,不需要再去new 接口,重新抽象方法了。 提高一些可读性,看着更舒服,并且利用它支持函数式编程和stream
1
2
链式调用,舒服的一批,还提供了各种丰富的中间操作,map,filter等等,然后可以可以上一个 collect结束整个流操作。 而且操作结果时,过滤,映射,规约,排序,匹配等等,嘎嘎好用。 但是需要一定的学习成本。
1
2
3
特性
数组(Array)
集合(Collection)
数据结
构
底层结构唯一,就是顺序表,分 配连续的空间 多种底层结构供选择:顺序表、双向链表、哈希 表、红黑树等
长度
固定长度(初始化时指定,不可 修改) 动态长度(自动扩容 / 缩容,无需手动指定)
存储类
型
可存储基本类型(int、char 等) 或引用类型 只能存储引用类型(基本类型需用包装类,如 Integer)
类型安
全
编译期检查类型 泛型加持后,编译期严格检查类型,避免类型转 换错误
功能 /
方法
仅提供长度属性(length),无 内置操作方法 提供丰富的操作方法 (size/add/remove/contains/sort 等)
遍历方
式
普通 for 循环、增强 for 循环 普通 for、增强 for、迭代器(Iterator)、 Lambda(Java 8+)
适用场
景
数据量固定、追求极致性能(如 底层实现) 数据量动态变化、需要频繁增删 / 查询操作
四、集合
本部分面试题涉及集合/容器Container、注解Annotation、泛型Generic 、Lambda表达式的相关内容
1、集合与数组的区别
2、Java集合框架是什么?说出集合框架的一些优点?
答:每种编程语言中都有集合,最初的Java版本包含几种集合类:Vector、Stack、HashTable等。随着 集合的广泛使用,Java1.2提出了囊括所有集合接口和实现类、算法的集合框架。 在保证线程安全的情况下使用泛型和并发集合类,Java已经经历了很久。 集合框架的部分优点如下: (1)使用核心集合类降低开发成本,而非实现我们自己的集合类。 (2)使用经过严格测试的集合框架类,代码质量会得到提高。 (3)通过使用JDK附带的集合类,可以降低代码维护成本。 (4)复用性和可操作性。
3、集合接口的常见实现类
1、List接口的实现类 List接口的实现类最常用的有ArrayList和LinkedList、Vector ArrayList类实现了可变的数组,可以根据索引位置对集合进行快速的随机访问。 LinkedList类采用双向链表结构保存对象,便于向集合中插入和删除对象,但是如果将元素插入到集合 的尾部,其实ArrayList远比LinkedList快。 Vector:也是List的一个常见实现类,但是在该类中的大多数方法都加了synchronized关键字,因此相比 ArrayList、LinkedList集合在多线程访问时是线程安全的。 2、Set接口的实现类 Set接口的实现类常用的有HashSet和TreeSet、LinkedHashSet。它们的元素都不可重复。 HashSet:底层是哈希表,遍历元素和添加顺序、大小顺序无关。 TreeSet:底层是红黑树,元素按照大小顺序存储和遍历。 LinkedHashSet:底层是哈希表+双链表,遍历元素可以体现添加时顺序。顺序性是体现和HashSet不同 之处。
3、Map接口的实现类
Map接口的实现类常用的有HashMap、LinkedHashMap和TreeMap。它们的key都不可重复。这里面 的key不可重复,指的是容器中同一个Key只会存在一个。 HashMap:哈希表,底层是数组+链表+红黑树。遍历元素和添加顺序、大小顺序无关。 LinkedHashMap:哈希表+双链表,遍历元素可以体现添加顺序。 TreeMap:红黑树,元素按照key大小顺序存储和遍历。
例子(example):
4、List、Map、Set三个接口存取元素时,各有什么特点?
packagese.collect.map;
importjava.util.*;
importjava.util.concurrent.ConcurrentHashMap;
publicclassMapDemo1 {
publicstaticvoidmain(String[] args) {
System.out.println("input 1 : 1 3 : 3 2 : 2 4 :
4 5 : 5");
//HashMap
Map<String, String>hashMap=newHashMap<String, String>();
hashMap.put("1", "1");
hashMap.put("3", "3");
hashMap.put("2", "2");
hashMap.put("4", "4");
hashMap.put("5", "5");
System.out.print("HashMap ");
for (Stringkey : hashMap.keySet()) {
System.out.print(key+" : "+hashMap.get(key) +" ");
}
System.out.println();
//LinkedHashMap
Map<String, String>linkedHashMap=newLinkedHashMap<String,
String>();
linkedHashMap.put("1", "1");
linkedHashMap.put("3", "3");
linkedHashMap.put("2", "2");
linkedHashMap.put("4", "4");
linkedHashMap.put("5", "5");
System.out.print("LinkedHashMap ");
for (Stringkey : linkedHashMap.keySet()) {
System.out.print(key+" : "+linkedHashMap.get(key) +"
");
}
System.out.println();
//TreeMap
Map<String, String>treeMap=newTreeMap<String, String>();
treeMap.put("1", "1");
treeMap.put("3", "3");
treeMap.put("2", "2");
treeMap.put("4", "4");
treeMap.put("5", "5");
System.out.print("TreeMap ");
for (Stringkey : treeMap.keySet()) {
System.out.print(key+" : "+treeMap.get(key) +" ");
}
System.out.println();
}
}
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
List与Set具有相似性,它们都是单列元素的集合,所以,它们有一个共同的父接口,叫Collection。 Set是无序且不可重复的。Set取元素时,不能通过索引访问元素,只能通过for-each循环或者迭代器逐 一遍历各个元素。 List表示有先后顺序的集合,可重复。注意,不是那种按年龄、按大小、按价格之类的排序。当我们多 次调用add(Object e)方法时,每次加入的对象就像火车站买票有排队顺序一样,按先来后到的顺序排 序。有时候,也可以插队,即调用add(int index,Object e)方法,就可以指定当前对象在集合中的存放位 置。一个对象可以被反复存储进List中,每调用一次add方法,这个对象就被插入进集合中一次,其实, 并不是把这个对象本身存储进了集合中,而是在集合中用一个索引变量指向这个对象,当这个对象被 add多次时,即相当于集合中有多个索引指向了这个对象。List除了通过迭代器逐一遍历各个元素,还可 以调用get(index i)来明确说明取第几个。 Map与List和Set不同,它是双列的集合,用put方法存储一对key/value,不能存储重复的key。取则可 以根据key获得相应的value,即get(Object key)返回值为key 所对应的value。另外,也可以获得所有的 key的集合(map.keySet()),还可以获得所有的value的集合(map.values()),还可以获得key和value组合 成的Map.Entry对象的集合(map.entrySet())。
5、为什么Map接口不继承Collection接口?
首先Map提供的是键值对映射(即Key和value的映射),而collection提供的是一组数据(并不是键值 对映射)。 其次如果map继承了collection接口,那么所有实现了map接口的类到底是用map的键值对映射数据还 是用collection的一组数据呢(就我们平常所用的hashMap、hashTable、treeMap等都是键值对,所以 它继承collection完全没意义),而且map如果继承了collection接口的话还违反了面向对象的接口分离 原则。
6、ArrayList和LinkedList的区别
ArrayList和LinkedList底层结构都是数据结构中的线性表,实现了List接口,所以都具有有序(索引顺 序)、不唯一的特点。但是由于底层数据结构不同,导致特点恰恰相反。 ArrayList:基于动态数组,连续内存存储,适合下标访问(随机访问),扩容机制:因为数组长度固 定,超出长度存数据时需要新建数组,然后将老数组的数据拷贝到新数组,如果不是尾部插入数据还会 涉及到元素的移动(往后复制一份,插入新元素),使用尾插法并指定初始容量可以极大提升性能、甚 至超过相同元素数量的LinkedList。 LinkedList:基于链表,可以存储在分散的内存中,适合做数据插入及删除操作,不适合查询:需要逐 一遍历。遍历LinkedList必须使用iterator不能使用for循环,因为每次for循环体内通get(i)取得某一元素 时都需要对list重新进行遍历,性能消耗极大。另外不要试图使用indexOf等返回元素索引,并利用其进 行遍历,使用indexlOf对list进行了遍历,当结果为空时会遍历整个列表。 备注:ArrayList和LinkedList哪种集合更浪费空间?角度1:LinkedList浪费空间:不仅存储数据,还要 存储地址,还是前后节点地址都存储;角度2: ArrayList浪费空间:事先分配固定数量的空间,可能导 致闲置;而LinkedList不存在闲置的节点。
7、ArrayList源码的关键点
Object[] elementData,是一个数组引用,用来指向底层数组; private int size;用来表示真实元素的个数,每次增加和删除元素size的值都要变化。 public class ArrayList extends AbstractList implements List, RandomAccess, Cloneable, java.io.Serializable{} 这三个接口都没有方法。 数组的默认长度;JDK1.7中中默认是10,new ArryList() ->this(10);JDK1.8中,new ArrayList() 数组的长度是0.第一次添加元素的时候进行扩容。 当数组已经满时,要扩容,默认每次扩容为原来长度的50%;使用了位运算。如果扩容50%的空间 还是不足时,就扩容为能够容纳所添加元素的最小容量。 int newCapacity = oldCapacity + (oldCapacity >> 1); if (newCapacity - minCapacity < 0) newCapacity = minCapacity; iterator(); ArrayList中提供了一个内部类 Itr implements Iterator,实现了其中的hasNext()、 next()等方法。基本思路:有一个索引/ 指针,初始执行第一个元素。next()时指向一个元素, hasNext()判断是否等于size transient Object[] elementData 底层数组都已transient了,就不参与序列化了,怎么传输数据。 请查看ArrayList的writeObject()和readObject()方法。各个数组元素还是参与序列化的。(而 ArrayList的writeObject()方法的调用过程,经过跟踪发现是通过反射调用的) for (int i=0; i<size; i++) { s.writeObject(elementData[i]); }
8、Iterator和ListIterator之间有什么区别?
(1)我们可以使用Iterator来遍历Set和List集合,而ListIterator只能遍历List; (2)Iterator只可以向前遍历,而LIstIterator可以双向遍历; (3)ListIterator从Iterator接口继承,然后添加了一些额外的功能,比如添加一个元素、替换一个元素、 获取前面或后面元素的索引位置。
packagese.collect.list;
importjava.util.ArrayList;
importjava.util.ListIterator;
publicclassListIteratorDemo {
publicstaticvoidmain(String[] args) {
ArrayList<String>list=newArrayList<>();
list.add("1");
list.add("3");
list.add("2");
list.add("4");
list.add("5");
System.out.println("正向遍历结果:");
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
9、两个List的交集元素?
首先,Java中就提供了一个API,让咱们可以直接使用的。 提供的方式叫retainAll方法。 可以直接 list1.retainAll(list2),他会将list1中的数据,只保留list1和list2的交集结果。 他内部采用的机制是双指针。 指针A:初始在0位置,如果有交集元素,赋值,然后向后移动一个位置 指针B:每次都会往后移动一个位置指向一个元素,同时利用contains判断元素是否存在,存在, 在指针A的位置赋值,然后指针A移动,指针B也移动。如果不存在交集情况,A不动,B动。
10、集合框架中的泛型有什么优点?
类型安全:
// 正向遍历
ListIterator<String>listIterator=list.listIterator();
while (listIterator.hasNext()) {
System.out.println(listIterator.next());
}
System.out.println("反向遍历结果:");
// 反向遍历
while (listIterator.hasPrevious()) {
System.out.println(listIterator.previous());
}
System.out.println("插入元素:");
//插入元素
ListIterator<String>listIterator1=list.listIterator();
listIterator1.add("7");
while (listIterator1.hasPrevious()) {
System.out.println(listIterator1.previous());
}
System.out.println(list);
System.out.println();
ListIterator<String>listIterator2=list.listIterator();
System.out.println("替换元素:");
// 替换元素
while (listIterator2.hasNext()) {
Stringnext=listIterator2.next();
if ("5".equals(next)) {
listIterator2.set("155");
}
}
System.out.println(list);
}
}
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
通过知道使用泛型定义的变量的类型限制,编译器可以在非常高的层次上验证类型假设。没有泛型,这 些假设就只能在程序员编码时候去考虑了。
消除强制类型转换:
消除源代码中的许多强制类型转换。这使得代码更加可读,并且减少了出错机会。
更高的效率:
在非泛型编程中,将筒单类型作为引用类型传递时会引起(装箱)和(拆箱)操作,这两个过程都是具 有很大开销的。引入泛型后,就不必进行装箱和拆箱操作了,所以运行效率相对较高。
11、fail-fast和fail-safe的区别
简介: fail-fast(快速失败)是Java对java.util包下的所有集合类的是一种错误检测机制。 fail-safe(安全失败)采用安全失败机制的集合容器,在遍历时不是直接在集合内容上访问的,而是先 复制原有集合内容,在拷贝的集合上进行遍历。 区别: java.util包下面的所有的集合类都是快速失败的,而java.util.concurrent包下面的所有的类都是安全失败 的。fail-fast(快速失败)的迭代器会抛出ConcurrentModificationException异常,而因为安全失败 是基于对底层集合做拷贝,所以,它不受源集合上修改的影响,安全失败的迭代器永远不会抛出这样的 异常。
fail-fast 机制:
在遍历一个集合时,当集合结构被修改,以下两种情况会抛出ConcurrentModificationException。 如何解决: a、并发遍历集合时不要修改集合 b、调用fail-safe类集(CopyOnWriteArrayList,ConcurrentHashMap)
fail-safe机制:
任何对集合结构的修改都在一个复制的集合上进行修改,所以不会抛出异常,因为原来的集合根本就没 变 缺点: a、需要复制集合,产生大量无效对象,开销大。 b、无法保证读取的数据是目前原始数据结构中的数据。
12、 HashSet 的去重原理
如果两个对象的hashCode值不同,直接插入成功. 如果两个对象的hashCode值相同,再比较两个对象的地址值。如果地址值相同,即同一个对象,插入 失败(无需继续判断)反之,则会继续调用equals方法比较,如果equals方法返回true,插入失败;如 果equals方法返回false,插入成功。
13、2个不相等的对象有可能具有相同hashCode吗
a、单线程环境:集合被创建后,在遍历它的过程中修改了结构。 b、多线程环境:当一个线程在遍历这个集合,而另一个线程对这个集合的结构进行了修改
1
2
有可能
两个不相等的对象有可能具有相同的哈希码。哈希码是由对象的哈希函数生成的一个整数值,用于支持 快速查找和比较对象。 然而,由于哈希码的范围通常比对象的数量小得多,因此不同的对象可能会产生相同的哈希码。这种情 况被称为哈希冲突。 哈希算法设计的目标是将不同的输入均匀分布在哈希码空间中,但无法避免完全消除冲突。因此,当发 生哈希冲突时,哈希算法会使用特定的策略(例如链表或树结构)来处理这些冲突,以确保不同的对象 可以存储在同一个哈希桶中。 综上所述,虽然不同的对象可能具有相同的哈希码,但哈希码仅用于初步判断对象是否可能相等,最终 的相等性检查还需要通过 equals() 方法进行。因此,在重写 equals() 方法时,也应该相应地重写 hashCode() 方法,以尽量减少哈希冲突的发生。
14、TreeSet和HashSet的区别
1、速度和内部实现: HashSet 内部使用哈希表来存储元素,因此它的查找、插入和删除操作的时间复杂度都是 O(1)。 TreeSet 内部使用的是红黑树,因此它的时间复杂度为 O(log n)。
2、排序方式:
HashSet 不保证元素的顺序,因为它是根据哈希值来存储和检索元素的。 TreeSet 则可以保证元素的顺序,因为它是根据元素的自然顺序或者比较器来进行排序的
3、接口:
HashSet 实现了 Set 接口。 TreeSet 实现了 SortedSet 接口(继承自Set接口)。 4 使用场景: 如果需要快速地插入、删除和查找元素,并且不关心它们的顺序,那么可以使用 HashSet。 如果需要对元素进行比较、排序,那么可以使用 TreeSet。
15、Comparator与Comparable有什么区别
Comparator和Comparable都是Java中用于对象排序的接口,它们之间有一些关键的区别。 Comparable接口是在对象自身的类中实现的,它定义了对象的自然排序方式。一个类实现了 Comparable接口后,可以使用compareTo方法来比较当前对象和其他对象的大小关系。这个接口只能 在对象自身的类中实现,不需要额外的比较器。 Comparator接口是一个独立的比较器,它可以用于对不同类的对象进行排序。Comparator接口允许 在对象类之外创建一个单独的比较器类或匿名类,并使用它来定义对象的排序规则。比较器通过实现 compare方法来比较两个对象的大小关系。 因此,主要区别如下: Comparable接口是在对象自身的类中实现,定义了对象的自然排序方式。 Comparator接口是一个单独的比较器,定义了用于排序的规则,可以用于不同类的对象排序。 Comparable是内部排序,对象的类必须实现Comparable接口才能进行排序。 Comparator是外部排序,可以独立定义排序规则,并与任何类的对象一起使用。 在使用时,如果需要对对象的默认排序进行操作,可以实现Comparable接口。如果需要对不同类的对 象进行排序,或者需要定义多种不同的排序规则,可以使用Comparator接口。 备注:实现Comparable接口只能指定一种比较的规则,如果还希望按照其他的规则在指定比较的规 则,怎么办?此时就可以通过外部比较器Comparator的实现类来指定规则,并且可以定义多个实现类 来指定多个比较规则。
16、Map接口提供了哪些不同的集合视图?
Map接口提供三个集合视图: (1)Set keyset():返回map中包含的所有key的一个Set视图。此Set集合是受map支持的,map的变化会 在集合中反映出来,反之亦然。当一个迭代器正在遍历一个此Set集合时,若map被修改了(除迭代器自 身的移除操作以外),迭代器的结果会变为不确定。此Set集合支持元素查找和删除,从此Set中删除元素 会从map中移除对应的映射,它不支持add和addAll添加操作。 (2)Collection values():返回一个map中包含的所有value的一个Collection视图。这个collection受map 支持,map的变化会在collection中反映出来,反之亦然。当一个迭代器正在遍历此collection时,若 map被修改了(除迭代器自身的移除操作以外),迭代器的结果会变为不确定。此Collection集合支持元素 查找和删除,从此Collection中删除元素会从map中移除对应的映射,它不支持add和addAll添加操作。 (3)Set entrySet():返回一个map中包含的所有映射的一个Set集合视图。这个Set集合受map支持,map 的变化会在Set中反映出来,反之亦然。当一个迭代器遍历此Set集合时,若map被修改了(除迭代器自身 的移除操作,以及对迭代器返回的entry进行setValue外),迭代器的结果会变为未定义。此Set集合支持 元素查找和删除,从此Set中删除元素会从map中移除对应的映射,它不支持add和addAll操作。
17、map的三种遍历方式
方式一:增强for 方式二:EntrySet迭代 方式三:KeySet迭代
例子(example):
packagese.collect.map;
importjava.util.HashMap;
importjava.util.Iterator;
importjava.util.Map;
1
2
3
4
18、HashMap与HashTable 的区别
(1)线程安全性不同 HashMap是线程不安全的,HashTable是线程安全的,其中的方法大多数是Synchronize的,在多线程 并发的情况下,可以直接使用HashTable,但是使用HashMap时必须自己增加同步处理。
HashMap:
HashTable:
importjava.util.Set;
publicclassMapForDemo {
publicstaticvoidmain(String[] args) {
HashMap<String, String>map=newHashMap<>();
map.put("name", "zs");
map.put("age", "18");
map.put("sex", "男");
map.put("address", "武汉市");
// 方式一:增强for
//for (Map.Entry<String, String> entry : map.entrySet()) {
//System.out.println("map的key:"+entry.getKey()
// +",map的value:"+entry.getValue());
//}
// 方式二:EntrySet迭代
//Set<Map.Entry<String, String>> entries = map.entrySet();
//Iterator<Map.Entry<String, String>> iterator = entries.iterator();
//while (iterator.hasNext()) {
// Map.Entry<String, String> next = iterator.next();
// System.out.println("map的key:" + next.getKey()
// + ",map的value:" + next.getValue());
//}
//方式三:keySet迭代
Iterator<String>keySetIterator=map.keySet().iterator();
while (keySetIterator.hasNext()) {
Stringkey=keySetIterator.next();
Stringvalue=map.get(key);
System.out.println("map的key:"+key+",map的value:"+value);
}
}
}
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
(2)是否提供contains方法 HashMap只有containsValue和containsKey方法;HashTable有contains、containsKey和 containsValue三个方法,其中contains和containsValue方法功能相同。
HashMap:
HashTable:
(3)key和value是否允许null值 Hashtable中,key和value都不允许出现null值。HashMap中,null可以作为键,这样的键只有一个; 可以有一个或多个键所对应的值为null。
HashMap:
HashTable:
(4)数组初始化和扩容机制 初始化:HashTable在不指定容量的情况下的默认容量为11;而HashMap在不指定容量的时候,并不会 提前构建指定长度大小的数组。而是当第一次put元素的时候才会去创建一个容量大小为16的数组,这 点一定要注意!!!
HashTable:
HashMap:
扩容:Hashtable扩容时,将容量变为原来的2倍加1,而HashMap扩容时,将容量变为原来的2倍。
HashMap:
HashTable:
19、JDK 1.7HashMap
数据结构:数组+链表
put方法的流程
20、JDK 1.8HashMap
1)、HashMap 的底层数据结构
在JDK 1.7 中 HashMap 是以「数组加链表」的形式组成的,JDK 1.8之后新增了「红黑树」的组成结 构,「当链表长度大于 8 并且 hash 桶的容量大于 64 时,链表结构会转换成红黑树结构」。所以,它 的组成结构如下图所示: 底层数据结构 HashMap 中数组的每一个元素又称为哈希桶,也就是 key-value 这样的实例。在Java7** 中叫 Entry, Java8中叫 Node。 因为它本身所有的位置都为 null,在 put 插入的时候会根据 key 的 hash 去计算一个 index 值。比如, 我 put (“徐庶”,666),在 HashMap 中插入 “徐庶” 这个元素,通过 hash 算法算出 index 位置是 3。 这时的结构如下所示,还是个数组。 初次 put 以上没 hash 冲突时,若发生 hash 冲突就得引入链表啦。假设我再次 put (“元直”,666),在 HashMap 中插入 “元直” 这个元素,通过 hash 算法算出 index 位置也是 3。这时的结构如下所示:形 成了链表。 再次 put,发生 hash 冲突 它的源码如下所示:「可以看出每个哈希桶中包含了四个字段:hash、key、value、next,其中 next 表示链表的下一个节点」。 JDK 1.8 之所以添加红黑树是因为一旦链表过长,会严重影响 HashMap 的性能,而红黑树具有快速增删 改查的特点,这样就可以有效的解决链表过长时操作比较慢的问题。
2)、HashMap 的重要方法
PS:以下源码分析全部基于 JDK1.8 版本。
查询(get 方法)
staticclassNode<K, V>implementsMap.Entry<K, V> {
finalinthash;
finalKkey;
Vvalue;
Node<K,
V>next;
...
}
1
2
3
4
5
6
7
8
publicVget(Objectkey) {
Node<K, V>e;
// 对 key 进行哈希操作
return (e=getNode(hash(key), key)) ==null?null : e.value;
}
finalNode<K, V>getNode(inthash, Objectkey) {
Node<K, V> [] tab;
Node<K, V>first, e;
intn;
Kk;
// 非空判断
if ((tab=table) !=null&& (n=tab.length) >0&&
(first=tab[(n-1) &hash]) !=null) {
// 判断第一个元素是否是要查询的元素
// always check first node
if (first.hash==hash&&
((k=first.key) ==key|| (key!=null&&key.equals(k))))
returnfirst;
// 下一个节点非空判断
if ((e=first.next) !=null) {
// 如果第一节点是树结构,则使用 getTreeNode 直接获取相应的数据
if (firstinstanceofTreeNode)
return ((TreeNode<K, V> ) first).getTreeNode(hash, key);
do { // 非树结构,循环节点判断
// hash 相等并且 key 相同,则返回此节点
if (e.hash==hash&&
((k=e.key) ==key|| (key!=null&&key.equals(k))))
returne;
} while ((e=e.next) !=null);
}
}
returnnull;
}
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
代码注释已经很详细,强调一点:当哈希冲突时我们不仅需要判断 hash 值,还需要通过判断 key 值是 否相等,才能确认此元素是不是我们想要的元素。
新增(put 方法)
publicVput(Kkey, Vvalue) {
// 对 key 进行哈希操作
returnputVal(hash(key), key, value, false, true);
}
finalVputVal(inthash, Kkey, Vvalue, booleanonlyIfAbsent,
booleanevict) {
Node<K, V> [] tab;
Node<K, V>p;
intn, i;
// 哈希表为空则创建表
if ((tab=table) ==null|| (n=tab.length) ==0)
n= (tab=resize()).length;
// 根据 key 的哈希值计算出要插入的数组索引 i
if ((p=tab[i= (n-1) &hash]) ==null)
// 如果 table[i] 等于 null,则直接插入
tab[i] =newNode(hash, key, value, null);
else {
Node<K, V>e;
Kk;
// 如果 key 已经存在了,直接覆盖 value
if (p.hash==hash&&
((k=p.key) ==key|| (key!=null&&key.equals(k))))
e=p;
// 如果 key 不存在,判断是否为红黑树
elseif (pinstanceofTreeNode)
// 红黑树直接插入键值对
e= ((TreeNode<K, V> ) p).putTreeVal(this, tab, hash, key,
value);
else {
// 为链表结构,循环准备插入
for (intbinCount=0;; ++binCount) {
// 下一个元素为空时
if ((e=p.next) ==null) {
p.next=newNode(hash, key, value, null);
// 转换为红黑树进行处理
if (binCount>=TREEIFY_THRESHOLD-1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
// key 已经存在直接覆盖 value
if (e.hash==hash&&
((k=e.key) ==key|| (key!=null&&key.equals(k))))
break;
p=e;
}
}
// existing mapping for key
if (e!=null) {
VoldValue=e.value;
if (!onlyIfAbsent||oldValue==null)
e.value=value;
afterNodeAccess(e);
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
注释已经很详细了。但新增的方法比较复杂,画个流程图方便方便各位理解:
returnoldValue;
}
}
++modCount;
// 超过最大容量,扩容
if (++size>threshold)
resize();
afterNodeInsertion(evict);
returnnull;
}
52
53
54
55
56
57
58
59
60
61
扩容(resize 方法)
源码如下:
finalNode<K, V> [] resize() {
// 扩容前的数组
Node<K, V> [] oldTab=table;
// 扩容前的数组的大小和阈值
intoldCap= (oldTab==null) ?0 : oldTab.length;
intoldThr=threshold;
// 预定义新数组的大小和阈值
intnewCap, newThr=0;
if (oldCap>0) {
// 超过最大值就不再扩容了
if (oldCap>=MAXIMUM_CAPACITY) {
threshold=Integer.MAX_VALUE;
returnoldTab;
}
// 扩大容量为当前容量的两倍,但不能超过 MAXIMUM_CAPACITY
elseif ((newCap=oldCap<<1) <MAXIMUM_CAPACITY&&
oldCap>=DEFAULT_INITIAL_CAPACITY)
newThr=oldThr<<1; // double threshold
}
// 当前数组没有数据,使用初始化的值
elseif (oldThr>0) // initial capacity was placed in threshold
newCap=oldThr;
else { // zero initial threshold signifies using defaults
// 如果初始化的值为 0,则使用默认的初始化容量
newCap=DEFAULT_INITIAL_CAPACITY;
newThr= (int)(DEFAULT_LOAD_FACTOR*DEFAULT_INITIAL_CAPACITY);
}
// 如果新的容量等于 0
if (newThr==0) {
floatft= (float) newCap*loadFactor;
newThr= (newCap<MAXIMUM_CAPACITY&&ft< (float) MAXIMUM_CAPACITY
?
(int) ft : Integer.MAX_VALUE);
}
threshold=newThr;
@SuppressWarnings({
"rawtypes",
"unchecked"
})
Node<K, V> [] newTab= (Node<K, V> []) newNode[newCap];
// 开始扩容,将新的容量赋值给 table
table=newTab;
// 原数据不为空,将原数据复制到新 table 中
if (oldTab!=null) {
// 根据容量循环数组,复制非空元素到新 table
for (intj=0; j<oldCap; ++j) {
Node<K, V>e;
if ((e=oldTab[j]) !=null) {
oldTab[j] =null;
// 如果链表只有一个,则进行直接赋值
if (e.next==null)
newTab[e.hash& (newCap-1)] =e;
elseif (einstanceofTreeNode)
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
从以上源码可以看出,扩容主要分两步: 扩容:创建一个新的 Entry 空数组,长度是原数组的 2 倍。 位运算:原来的元素哈希值和原数组长度进行 & 运算。 JDK 1.8 在扩容时并没有像 JDK 1.7 那样,重新计算每个元素的哈希值,而是通过高位运算(e.hash & oldCap)来确定元素是否需要移动,假设 key1 的信息如下: key1.hash = 10;二进制:0000 1010 oldCap = 16;二进制:0001 0000
// 红黑树相关的操作
((TreeNode<K, V> ) e).split(this, newTab, j, oldCap);
else { // preserve order
// 链表复制,JDK 1.8 扩容优化部分
// 如果节点不为空,且为单链表,则将原数组中单链表元素进行拆分
//保存在原有索引的链表
Node<K, V>loHead=null, loTail=null;
//保存在新索引的链表
Node<K, V>hiHead=null, hiTail=null;
Node<K, V>next;
do {
next=e.next;
// 哈希值和原数组长度进行 & 操作,为 0 则在原数组的索引位置
if ((e.hash&oldCap) ==0) {
if (loTail==null)
loHead=e;
else
loTail.next=e;
loTail=e;
}
// 原索引 + oldCap
else {
if (hiTail==null)
hiHead=e;
else
hiTail.next=e;
hiTail=e;
}
} while ((e=next) !=null);
// 将原索引放到哈希桶中
if (loTail!=null) {
loTail.next=null;
newTab[j] =loHead;
}
// 将原索引 + oldCap 放到哈希桶中
if (hiTail!=null) {
hiTail.next=null;
newTab[j+oldCap] =hiHead;
}
}
}
}
}
returnnewTab;
}
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
「使用 e.hash & oldCap 得到的结果,高一位为 0,当结果为 0 时表示元素在扩容时位置不会发生任何
变化」,而假设 key 2 信息如下: key2.hash = 17;二进制:0001 0001 oldCap = 16;二进制:0001 0000
「这时候得到的结果,高一位为 1,当结果为 1 时,表示元素在扩容时位置发生了变化,新的下标位置
等于原下标位置 + 原数组长度」,如下图所示:key2、kry4 虚线为移动的位置。
3)、HashMap 有哪些属性?
如下,看代码注释,写的很清楚了。
4)、为什么 HashMap 的初始化长度是 16 ?(必须是2的幂次方)
前面说过,从 Key 映射到 HashMap 数组的对应位置,会用到一个 Hash 函数,比如:index = Hash (“徐庶”) 注意到 HashMap 初始化长度用的是 1<<4,而不是直接写 16。这是为啥呢?其实这样是为了位运算的 方便,「位与运算比算数计算的效率高太多了,之所以选择 16,是为了服务将 Key 映射到 index 的算 法」。
// HashMap 初始化长度
staticfinalintDEFAULT_INITIAL_CAPACITY=1<<4; // aka 16
// HashMap 最大长度
staticfinalintMAXIMUM_CAPACITY=1<<30; // 1073741824
// 默认的加载因子 (扩容因子)
staticfinalfloatDEFAULT_LOAD_FACTOR=0.75f;
// 当链表长度大于此值且数组长度大于 64 时,会从链表转成红黑树
staticfinalintTREEIFY_THRESHOLD=8;
// 转换链表的临界值,当元素小于此值时,会将红黑树结构转换成链表结构
staticfinalintUNTREEIFY_THRESHOLD=6;
// 最小树容量
staticfinalintMIN_TREEIFY_CAPACITY=64;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
那如何实现一个尽量均匀分布的 Hash 函数呢?从而减少 HashMap 碰撞呢?没错,就是通过 Key 的 HashCode 值来做位运算。 有公式(Length 是 HashMap 的长度):「HashCode(Key) & (Length- 1)」 我举个例子,key 为 “book” 的十进制为 3029737 那二进制就是 101110001110101110 1001 HashMap 长度是默认的 16,length - 1 的结果。十进制 : 15;二进制 : 1111 把以上两个结果做与运算:101110001110101110 1001 & 1111 = 1001;1001 的十进制 = 9, 所以 index=9。 也就是说:「hash 算法最终得到的 index 结果,取决于 hashcode 值的最后几位」 你可以试试把长度指定为 10 以及其他非 2 次幂的数字,做位运算。发现 index 出现相同的概率大大升 高。而长度 16 或者其他 2 的幂,length - 1 的值是所有二进制位全为 1, 这种情况下,index 的结果等同 于 hashcode 后几位的值,只要输入的 hashcode 本身分布均匀,hash 算法的结果就是均匀的 「所以,HashMap 的默认长度为 16,是为了降低 hash 碰撞的几率」。
5)、为什么树化是 8,退树化是 6?
红黑树平均查找长度为 log (n),长度为 8 时,查找长度为 3,而链表平均查找长度为 n/2;也就是 8 除 以 2;查找长度链表大于树,转化为树,效率更高。 当为 6 时,树:2.6;链表:3。链表 > 树。这时理应也还是树化,但是树化需要时间,为了这点效率牺 牲时间是不划算的。
6)、什么是加载因子?加载因子为什么是 0.75 ?
前面说了扩容机制。那什么时候扩容呢?这就取决于原数组长度和加载因子两个因素了。 加载因子也叫扩容因子或负载因子,用来判断什么时候进行扩容的,假如加载因子是 0.5,HashMap 的 初始化容量是 16,那么当 HashMap 中有 16*0.5=8 个元素时,HashMap 就会进行扩容。 那加载因子为什么是 0.75 而不是 0.5 或者 1.0 呢?这其实是出于容量和性能之间平衡的结果: 上面说到,为了提升扩容效率,HashMap 的容量(capacity)有一个固定的要求,那就是一定是 2 的幂。所以,如果负载因子是 3/4 的话,那么和 capacity 的乘积结果就可以是一个整数 当加载因子设置较大时,扩容门槛提高,扩容发生频率低,占用的空间小,但此时发生 Hash 冲突 的几率就会提升,因此需要更复杂的数据结构来存储元素,这样对元素的操作时间就会增加,运行 效率也会降低; 当加载因子设置较小时,扩容门槛降低,会占用更多的空间,此时元素的存储就比较稀疏,发生哈 希冲突的可能性就比较小,因此操作性能会比较高。 「所以综合了以上情况就取了一个 0.5 到 1.0 的平均数 0.75 作为加载因子」。
7)、HashMap 是线程安全的么?
不是,因为 get 和 put 方法都没有上锁。「多线程操作无法保证:此刻 put 的值,片刻后 get 还是相同 的值,会造成线程安全问题」。 还有个 HashTable 是线程安全的,但是加锁的粒度太大。并发度很低,最多同时允许一个线程访问,性 能不高。一般我们使用 currentHashMap。
8)、为什么重写 equals 方法的时,需要重写 hashCode 方法呢?
Java 中,所有的对象都是继承于 Object 类。Ojbect 类中有两个方法 equals、hashCode,这两个方法 都是用来比较两个对象是否相等的。 先来看看 equals 方法: 在未重写 equals 方法,他其实就是 == 。有以下两个特点: 对于值对象,== 比较的是两个对象的值 对于引用对象,== 比较的是两个对象的地址 看回 put 方法的源码:「HashMap 是通过 key 的 hashcode 去寻找地址 index 的。如果 index 一样 就会形成链表」,也即是 “徐庶” 和 “元直” 是有可能在同一个位置上。 前面的 get 方法说过:「当哈希冲突时我们不仅需要判断 hash 值,还需要通过判断 key 值是否相等, 才能确认此元素是不是我们想要的元素」。我们去 get 首先是找到 hash 值一样的,那怎么知道你想要 的是那个对象呢?「没错,就是利用 equals 方法」,如果重写 hashCode 方法,不写 equals 方法,当 发生 hash 冲突,hashcode 一样时,就不知道取哪个对象了。
9)、HashMap 死循环分析
以下代码基于 JDK1.7 分析。这个问题,主要是 JDK1.7 的链表头插法造成的。假设 HashMap 默认大小 为 2,原本 HashMap 中没有一个元素。使用三个线程:t1、t2、t3 添加元素 key1,key2,key3。我在 扩容之前打了个断点,让三个线程都停在这里。源码如下: 假设 3 个元素 hash 冲突,放到同一个链表上。其中 key1→key2→key3 这样的顺序。没毛病,一切很 正常。 尾插法
publicbooleanequals(Objectobj) {
return (this==obj);
}
1
2
3
void transfer(Entry[] newTable, boolean rehash) {
int newCapacity = newTable.length;
for (Entry < K, V > e: table) {
while (null != e) {
Entry < K, V > next = e.next; // 此处加断点
if (rehash) {
e.hash = null == e.key ? 0 : hash(e.key);
}
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i];
newTable[i] = e;
e = next;
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
此时放开断点,HashMap 扩容。就有可能变成这样:原来是 key1→key2→key3。很不幸扩容之后, key1 和 key2 还是在同一个位置,这时形成链表,如果 key2 比 key1 后面插入,根据头插法。此时就变 成 key2→key1 链表反转 最终 3 个线程都调整完毕,就会出现下图所示的死循环:这个时候 get (key1) 就会出现 Infinite Loop 异 常。 循环引用 当然发生死循环的原因是 JDK 1.7 链表插入方式为首部倒序插入,这种方式在扩容时会改变链表节点之 间的顺序。「这个问题在 JDK 1.8 得到了改善,变成了尾部正序插入」,在扩容时会保持链表元素原本 的顺序,就不会出现链表成环的问题。
10)、JDK8中的HashMap为什么要使用红黑树?
当元素个数小于一个阈值时,链表整体的插入查询效率要高于红黑树,当元素个数大于此阈值时,链表 整体的插入查询效率要低于红黑树。此阈值在HashMap中为8。
11)、JDK8中的HashMap什么时候将链表转化为红黑树?
这个题很容易答错,大部分答案就是:当链表中的元素个数大于8时就会把链表转化为红黑树。但是其实 还有另外一个限制:当发现链表中的元素个数大于8之后,还会判断一下当前数组的长度,如果数组长度 小于64时,此时并不会转化为红黑树,而是进行扩容。只有当链表中的元素个数大于8,并且数组的长 度大于等于64时才会将链表转为红黑树。 上面扩容的原因是,如果数组长度还比较小,就先利用扩容来缩小链表的长度。
21、JDK7与JDK8中HashMap的不同点?
- JDK8中使用了红黑树
- JDK7中链表的插入使用的头插法(扩容转移元素的时候也是使用的头插法,头插法速度更快,无需 遍历链表,但是在多线程扩容的情况下使用头插法会出现循环链表的问题,导致CPU飙升),JDK8 中链表使用的尾插法(JDK8中反正要去计算链表当前结点的个数,反正要遍历的链表的,所以直接 使用尾插法)
- JDK7的Hash算法比JDK8中的更复杂,Hash算法越复杂,生成的hashcode则更散列,那么 hashmap中的元素则更散列,更散列则hashmap的查询性能更好,JDK7中没有红黑树,所以只能 优化Hash算法使得元素更散列,而JDK8中增加了红黑树,查询性能得到了保障,所以可以简化一 下Hash算法,毕竟Hash算法越复杂就越消耗CPU。
- 扩容的过程中JDK7中有可能会重新对key进行哈希(重新Hash跟哈希种子有关系),而JDK8中没 有这部分逻辑。
- JDK8中扩容的条件和JDK7中不一样,除开判断size是否大于阈值之外,JDK7中还判断了table[i]是 否为空,不为空的时候才会进行扩容,而JDK8中则没有该条件了。
- JDK8中还多了一个API:putIfAbsent(key,value)
- JDK7和JDK8扩容过程中转移元素的逻辑不一样,JDK7是每次转移一个元素,JDK8是先算出来当前 位置上哪些元素在新数组的低位上,哪些在新数组的高位上,然后在一次性转移。
22、常见线程安全的并发容器有哪些?
CopyOnWriteArrayList、CopyOnWriteArraySet、ConcurrentHashMap。 CopyOnWriteArrayList:采用写时复制实现线程安全,可以有重复元素 CopyOnWriteArraySet:底层是CopyOnWriteArrayList,基于addIfAbsent保证元素唯一 ConcurrentHashMap:核心是分段锁(JDK1.7)/CAS+Synchronized(JDK1.8),通过减小锁 粒度来提升并发性能:
23、JDK7与JDK8中ConcurrentHashMap的不同点?
1) JDK1.7ConcurrentHashMap
JDK 1.7ConcurrentHashMap底层是由两层嵌套数组来实现的:
- ConcurrentHashMap对象中有一个属性segments,类型为Segment[];
- Segment对象中有一个属性table,类型为HashEntry[];
put流程:
1、当调用ConcurrentHashMap的put方法时,先根据key计算出对应的Segment[]的数组下标j,确定好 当前key,value应该插入到哪个Segment对象中,如果segments[j]为空,则利用自旋锁的方式在j位置生 成一个Segment对象。 2、然后调用Segment对象的put方法。 2.1 Segment对象的put方法会先加锁,然后也根据key计算出对应的HashEntry[]的数组下标i,然后 将key,value封装为HashEntry对象放入该位置,此过程和JDK7的HashMap的put方法一样,然后解锁。 2.2 在加锁的过程中逻辑比较复杂,先通过自旋加锁,如果超过一定次数就会直接阻塞等等加锁。
2).JDK 1.8ConcurrentHashMap
put流程:
1、当向ConcurrentHashMap中put一个key,value时,首先根据key计算对应的数组下标i,如果该位置 没有元素,则通过自旋的方法去向该位置赋值。如果该位置有元素,则synchronized会加锁 2、加锁成功之后,在判断该元素的类型。 2.1 如果是链表节点则进行添加节点到链表中 2.2 如果是红黑树则添加节点到红黑树 3、添加成功后,判断是否需要进行树化 4、addCount,这个方法的意思是ConcurrentHashMap的元素个数加1,但是这个操作也是需要并发安 全的,并且元素个数加1成功后,会继续判断是否要进行扩容,如果需要,则会进行扩容,所以这个方法 很重要。 5、同时一个线程在put时如果发现当前ConcurrentHashMap正在进行扩容则会去帮助扩容。
3).JDK 1.7ConcurrentHashMap如何保证并发
主要利用Unsafe操作+ReentrantLock+分段思想。 主要使用了Unsafe操作中的:
- compareAndSwapObject:通过cas的方式修改对象的属性
- putOrderedObject:并发安全的给数组的某个位置赋值
- getObjectVolatile:并发安全的获取数组某个位置的元素 分段思想是为了提高ConcurrentHashMap的并发量,分段数越高则支持的最大并发量越高,程序员可 以通过concurrencyLevel参数来指定并发量。ConcurrentHashMap的内部类Segment就是用来表示某 一个段的。 每个Segment就是一个小型的HashMap的,当调用ConcurrentHashMap的put方法时,最终会调用到 Segment的put方法,而Segment类继承了ReentrantLock,所以Segment自带可重入锁,当调用到 Segment的put方法时,会先利用可重入锁加锁,加锁成功后再将待插入的key,value插入到小型 HashMap中,插入完成后解锁。
4).JDK 1.8ConcurrentHashMap如何保证并发
主要利用Unsafe操作+synchronized关键字。Unsafe操作的使用仍然和JDK7中的类似,主要负责并发安 全的修改对象的属性或数组某个位置的值。synchronized主要负责在需要操作某个位置时进行加锁(该 位置不为空),比如向某个位置的链表进行插入结点,向某个位置的红黑树插入结点。JDK8中其实仍然 有分段锁的思想,只不过JDK7中段数是可以控制的,而JDK8中是数组的每一个位置都有一把锁。
24、什么是 CopyOnWriteArrayList
1).Copy-On-Write 是什么?
Copy-On-Write它是一种在计算机科学中常见的优化技术,主要应用于需要频繁读取但很少修改的数据 结构上。 简单的说就是在计算机中就是当你想要对一块内存进行修改时,我们不在原有内存块中进行写操作,而 是将内存拷贝一份,在新的内存中进行写操作,写完之后呢,就将指向原来内存指针指向新的内存,原 来的内存就可以被回收掉了! 既然是一种优化策略,我们看一段代码:
importjava.util.ArrayList;
importjava.util.Arrays;
importjava.util.Iterator;
importjava.util.List;
/**
* @author 百里
*/
publicclassBaiLiIteratorTest {
privatestaticList<String>list=newArrayList<>();
publicstaticvoidmain(String[] args) {
list.add("1");
list.add("2");
list.add("3");
Iterator<String>iter=list.iterator();
while (iter.hasNext()) {
System.err.println(iter.next());
}
System.err.println(Arrays.toString(list.toArray()));
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
上面的Demo在单线程下执行时没什么毛病,但是在多线程的环境中,就可能出异常,为什么呢? 因为多线程迭代时如果有其他线程对这个集合list进行增减元素,会抛出 java.util.ConcurrentModificationException的异常。 我们以增加元素为例子,运行下面这Demo:
这里暴露的问题是什么呢?
多线程场景下迭代器遍历集合的读取操作和其他线程对集合进行写入操作会导致出现并发修改异常
importjava.util.ArrayList;
importjava.util.Iterator;
importjava.util.List;
importjava.util.concurrent.ExecutorService;
importjava.util.concurrent.Executors;
/**
- 并发迭代器问题示例代码
* @author 百里
*/
publicclassBaiLiConcurrentIteratorTest {
// 创建一个ArrayList对象
privatestaticList<String>list=newArrayList<>();
publicstaticvoidmain(String[] args) throwsInterruptedException {
// 给ArrayList添加三个元素:"1"、"2"和"3"
list.add("1");
list.add("2");
list.add("3");
// 开启线程池,提交10个线程用于在list尾部添加5个元素"121"
ExecutorServiceservice=Executors.newFixedThreadPool(10);
for (inti=0; i<10; i++) {
service.execute(() -> {
for (intj=0; j<5; j++) {
list.add("121");
}
});
}
// 使用Iterator迭代器遍历list并输出元素值
Iterator<String>iter=list.iterator();
for (inti=0; i<10; i++) {
service.execute(() -> {
while (iter.hasNext()) {
System.err.println(iter.next());
try {
Thread.sleep(10);
} catch (InterruptedExceptione) {
e.printStackTrace();
}
}
});
}
service.shutdown();
}
}
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
解决方案: CopyOnWriteArrayList避免了多线程操作List线程不安全的问题
2).CopyOnWriteArrayList介绍
从JDK1.5开始Java并发包里提供了两个使用CopyOnWrite机制实现的并发容器,它们是 CopyOnWriteArrayList和CopyOnWriteArraySet。CopyOnWrite容器非常有用,可以在非常多的并发 场景中使用到。
CopyOnWriteArrayList原理:
在写操作(add、remove等)时,不直接对原数据进行修改,而是先将原数据复制一份,然后在新复制的 数据上执行写操作,最后将原数据引用指向新数据。这样做的好处是读操作(get、iterator等)可以不加 锁,因为读取的数据始终是不变的。 接下来我们就看下源码怎么实现的。
3).CopyOnWriteArrayList简单源码解读
add()方法源码: 可以看到,CopyOnWriteArrayList中的写操作都需要先获取锁,然后再将当前的元素数组复制一份,并 在新复制的元素数组上执行写操作,最后将数组引用指向新数组。
/**
* Appends the specified element to the end of this list.
*
* @param e element to be appended to this list
* @return {@code true} (as specified by {@link Collection#add})
*/
publicbooleanadd(Ee) {
finalReentrantLocklock=this.lock;//重入锁
lock.lock();//加锁啦
try {
Object[] elements=getArray();
intlen=elements.length;
Object[] newElements=Arrays.copyOf(elements, len+1);//拷贝新数组
newElements[len] =e;
setArray(newElements);//将引用指向新数组 1
returntrue;
} finally {
lock.unlock();//解锁啦
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@SuppressWarnings("unchecked")
publicEnext() {
if (!hasNext()) //是否存在下一个元素
thrownewNoSuchElementException(); //没有下一个元素,则会抛出
NoSuchElementException异常
//snapshot是一个类成员变量,它是在创建迭代器时通过复制集合内容而获得的一个数组。 //cursor是另一个类成员变量,初始值为0,并在每次调用next()时自增1,表示当前返回元素的 位置。
return (E) snapshot[cursor++];
}
1
2
3
4
5
6
7
8
而读操作不需要加锁,直接返回当前的元素数组即可。 这种写时复制的机制保证了读操作的线程安全性,但是会牺牲一些写操作的性能,因为每次修改都需要 复制一份数组。因此,适合读远多于写的场合。 所以我们将多线程Demo中的ArrayList改为CopyOnWriteArrayList,执行就不会报错啦!
importjava.util.Iterator;
importjava.util.concurrent.CopyOnWriteArrayList;
importjava.util.concurrent.ExecutorService;
importjava.util.concurrent.Executors;
/**
- 并发迭代器问题示例代码
* @author 百里
*/
publicclassBaiLiConcurrentIteratorTest {
// 创建一个ArrayList对象
privatestaticCopyOnWriteArrayList<String>list=new
CopyOnWriteArrayList<>();
publicstaticvoidmain(String[] args) throwsInterruptedException {
// 给ArrayList添加三个元素:"1"、"2"和"3"
list.add("1");
list.add("2");
list.add("3");
// 开启线程池,提交10个线程用于在list尾部添加5个元素"121"
ExecutorServiceservice=Executors.newFixedThreadPool(10);
for (inti=0; i<10; i++) {
service.execute(() -> {
for (intj=0; j<5; j++) {
list.add("121");
}
});
}
// 使用Iterator迭代器遍历list并输出元素值
Iterator<String>iter=list.iterator();
for (inti=0; i<10; i++) {
service.execute(() -> {
while (iter.hasNext()) {
System.err.println(iter.next());
try {
Thread.sleep(10);
} catch (InterruptedExceptione) {
e.printStackTrace();
}
}
});
}
service.shutdown();
}
}
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
4).CopyOnWriteArrayList优缺点
优点:
- 线程安全。CopyOnWriteArrayList是线程安全的,由于写操作对原数据进行复制,因此写操作不 会影响读操作,读操作可以不加锁,降低了并发冲突的概率。
- 不会抛出ConcurrentModificationException异常。由于读操作遍历的是不变的数组副本,因此 不会抛出ConcurrentModificationException异常。 缺点:
- 写操作性能较低。由于每一次写操作都需要将元素复制一份,因此写操作的性能较低。
- 内存占用增加。由于每次写操作都需要创建一个新的数组副本,因此内存占用会增加,特别是当集 合中有大量数据时,内存占用较高。
- 数据一致性问题。由于读操作遍历的是不变的数组副本,因此在对数组执行写操作期间,读操作可 能读取到旧的数组数据,这就涉及到数据一致性问题。
5).CopyOnWriteArrayList使用场景
读多写少。为什么?因为写的时候会复制新集合 集合不大。为什么?因为写的时候会复制新集合 实时性要求不高。为什么,因为有可能会读取到旧的集合数据
25、各种集合类型特征对比
26、说说你对lambda表达式的理解
1). 什么是 Lambda 表达式?
Lambda表达式是Java 8引入的一种简洁的语法形式,用于表示匿名函数。它可以作为参数传递给方法或 函数接口,并且可以在需要函数式编程特性的地方使用。 Lambda表达式的语法类似于(参数列表) -> 表达式或代码块。参数列表描述了输入参数,可以省略类 型,甚至括号。箭头符号将参数列表与表达式或代码块分隔开来。
Lambda表达式具有以下特点:
- 简洁:相较于传统的匿名内部类,Lambda表达式更加简洁,能用更少的代码实现相同功能。
- 函数式编程:支持函数作为一等公民进行传递和操作。
- 闭包:可以访问周围的变量和参数。
- 方法引用:可以通过引用已存在的方法进一步简化。
Lambda表达式的应用场景包括:
集合操作:对集合元素进行筛选、映射、排序等操作,使代码简洁和可读。 并行编程:利用Lambda表达式简化并发编程的复杂性。 事件驱动模型:作为回调函数响应用户输入或系统事件。 需要注意,Lambda表达式仅适用于函数式接口(只有一个抽象方法的接口),可直接实现该接口的实 例,避免编写传统匿名内部类。Lambda表达式在Java编程中提供了更为灵活和简洁的语法,促进了函 数式编程的应用。 Lambda 表达式是 Java 8 引入的一个重要特性,它允许以更简洁的方式编写匿名类和函数式接口的实 现。Lambda 表达式的核心思想是将代码块作为方法参数传递,从而简化代码并提高可读性。以下是一 些常见的面试问题和答案,帮助你深入理解 Lambda 表达式。 Lambda 表达式是一种匿名函数,可以用来表示一个函数式接口的实现。它允许将代码块作为方法参数 传递,从而简化代码。
2). Lambda 表达式的语法是什么?
Lambda 表达式的语法包括三部分: 参数列表:可以显式或隐式指定参数类型。 箭头符号:-> ,表示参数和函数体之间的分隔符。 函数体:可以是一个表达式或代码块。 // 使用匿名类
/**
* @Auth:TianMing
* @Description:基本应用
*/
newThread(newRunnable() {
@Override
publicvoidrun() {
System.out.println("Hello, Anonymous Class!");
}
}).start();
// 使用 Lambda 表达式
newThread(() ->System.out.println("Hello, Lambda!")).start();
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 参数类型显式指定
(inta, intb) ->a+b;
// 参数类型隐式推断
(a, b) ->a+b;
// 单个参数时可以省略括号
x->x*x;
1
2
3
4
5
6
7
8
3). Lambda 表达式和函数式接口的关系是什么?
Lambda 表达式必须与函数式接口结合使用。函数式接口是一个只包含一个抽象方法的接口,可以用
@FunctionalInterface注解标记。
4). Lambda 表达式的作用是什么?
Lambda 表达式的主要作用是简化代码,提高代码的可读性和可维护性。它特别适合用于以下场景: 事件处理:简化事件监听器的实现。 Stream API:简化集合操作。 函数式编程:支持函数式编程风格。 // 函数体为代码块
(x, y) -> {
intresult=x+y;
returnresult;
};
9
10
11
12
13
14
@FunctionalInterface
interfaceCalculator {
intcalculate(inta, intb);
}
/**
* @Auth:TianMing
* @Description:和函数式接口的关系
*/
publicclassLambdaExample {
publicstaticvoidmain(String[] args) {
Calculatoradd= (a, b) ->a+b;
System.out.println("Addition: "+add.calculate(5, 3)); // 8
Calculatormultiply= (a, b) ->a*b;
System.out.println("Multiplication: "+multiply.calculate(5, 3));
// 15
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/**
* @Auth:TianMing
* @Description:作用
*/
List<String>names=Arrays.asList("Alice", "Bob", "Charlie", "David");
// 使用 Lambda 表达式过滤和打印名字
names.stream()
.filter(name->name.length() >4)
.forEach(System.out::println);
1
2
3
4
5
6
7
8
9
10
5). Lambda 表达式如何捕获变量?
Lambda 表达式可以捕获外部变量,但这些变量必须是有效最终变量(Effectively Final),即它们的 值在 Lambda 表达式中不能被修改。
6). Lambda 表达式的性能如何?
Lambda 表达式在运行时通过动态代理实现,性能开销较小。它通常比匿名类更高效,因为 Lambda 表 达式会编译为方法而不是类。
7). Lambda 表达式有哪些限制?
单方法接口:Lambda 表达式只能用于实现一个抽象方法的接口。 捕获变量:只能捕获有效最终变量。 可读性:如果逻辑复杂,Lambda 表达式可能会降低代码的可读性。
8). Lambda 表达式在实际开发中的应用
Lambda 表达式在实际开发中非常常用,尤其是在以下场景: Stream API:简化集合操作。 事件处理:简化事件监听器的实现。 函数式接口:实现自定义的函数式接口。 示例(事件处理):
27、什么是泛型,怎么使用?
比如List集合啥的,都定义了一个泛型,咱们可以在new的时候,将泛型具体化。 Java中的泛型就是一种规避类型错误的一种安全机制。 ClassCastException 当你在声明一个集合并且指定泛型的类型后,你存储的数据类型就会有限制了,存储的不允许的类型, 在编译时期就会直接报错了。
/**
* @Auth:TianMing
* @Description:捕获变量
*/
publicclassLambdaExample {
publicstaticvoidmain(String[] args) {
intfactor=2;
Function<Integer, Integer>multiply=x->x*factor;
System.out.println(multiply.apply(5)); // 10
// 如果 factor 被修改,会导致编译错误
// factor = 3;
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
JButtonbutton=newJButton("Click Me");
button.addActionListener(event->System.out.println("Button clicked!"));
1
2
类型的安全,前面可以看出来。 减少强制类型转换的操作 后面一半会衍生出来泛型擦除的问题。 泛型擦出去是编译器在编译是的一种机制,你编写的Java代码变为class文件后,他就没有泛型这个东西 了,底层都是最顶级的Object。所以泛型只是在编写时加上,其次底层运行时,压根就没泛型这个东 西。 就比如List运行后,他就是List。
泛型擦除有什么影响嘛?
反射的时候,获取一些实例的时候,需要手动强转一下
28、谈谈自定义注解的场景及实现
自定义注解是Java语言的一个强大特性,可以为代码添加元数据信息,提供额外配置或标记。它适用于 多种场景。
- 配置和扩展框架:通过自定义注解,可以为框架提供配置参数或进行扩展。例如,Spring框架中的 @Autowired注解用于自动装配依赖项,@RequestMapping注解用于映射请求到控制器方法。
- 运行时检查:自定义注解可在运行时对代码进行检查,并进行相应处理。例如,JUnit框架的@Test 注解标记测试方法,在运行测试时会自动识别并执行这些方法。
- 规范约束:自定义注解用于规范代码风格和约束。例如,Java代码规范检查工具Checkstyle可使用 自定义注解标记违规行为。 实现自定义注解的步骤如下:
- 使用@interface关键字定义注解。
- 可在注解中定义属性,并指定默认值。
- 根据需求,可添加元注解来控制注解的使用方式。
- 在代码中使用自定义注解。
- 使用反射机制解析注解信息。 通过合理运用自定义注解,可提高代码的可读性、可维护性和可扩展性。
五、多线程基础
本部分面试题涉及多线程基础内容,关于多线程的进阶内容,比如JUC,后面作为专门章节列出
1、线程与进程及其关系?
进程是指一种正在运行的程序,有自己的地址空间;进程内部的一个执行单元,它是程序中一个单一的 顺序控制流程。 一个进程中可以有多个线程,多个线程共享进程的堆叠和方法区 (JDK1.8 之后的元空间)资源,但每个线 程都有自己的程序计数器、虚拟机栈和本地方法栈。 备注:进程和线程可以理解为公路和车道的关系,迅雷软件和迅雷下载任务的关系
2、并发和并行有什么区别?
并发和并行是一个很容易混淆的概念。并发是指多个任务的交替,而并行是指真正意义上的“同时进 行”。事实上,如果系统中只有一个CPU,在实际系统环境中不能并行使用多线程,则只能通过切换时间 片来交替执行任务。真正的并行只能出现在拥有多个CPU的系统中。 并行(parallel):指在同一时刻,有多条指令在多个处理器上同时执行。所以无论从微观还是从宏观 来看,二者都是一起执行的。 并发(concurrency):指在同一时刻只能有一条指令执行,但多个进程指令被快速的轮换执行,使 得在宏观上具有多个进程同时执行的效果,但在微观上并不是同时执行的,只是把时间分成若干 段,使多个进程快速交替的执行
3、为什么要使用并发?
并发编程在一定程度上离不开多核CPU的发展。 随着单核CPU的研发,无法遵循“摩尔定律,为了进一步提高计算速度,硬件工程师不再追求单独的计算 单元,而是将多个计算单元集成在一起,即形成多核CPU。短短十几年,Intel等家用CPU i7可以达到4 个甚至8个核心。短短十几年,Intel等家用CPU i7可以达到4个甚至8个核心。专业服务器通常可以达到 几个独立的CPU,每个CPU甚至有8个以上的核心。因此,“摩尔定律”它似乎继续体验CPU的核心扩展。” (摩尔定律是基于硬件开发的观测定律,也是基于“摩尔定律”的“反摩尔定律”,不过“反摩尔定律”是软件领 域的定律,有兴趣的可以自己理解) 在多核CPU的背景下,并发编程的趋势应运而生。通过并发编程,可以充分发挥多核CPU的计算能力, 提高其性能。
4、并发编程有哪些缺点?
(1)上下文切换频繁 时间片是CPU分配给每个线程的时间,因为时间很短,所以CPU不断切换线程,让我们觉得多个线程是 同时执行的,时间片通常是几十毫秒。每次切换时,都需要保存当前状态,以恢复以前的状态。这种切 换行为非常失去性能。过于频繁的切换不能充分发挥多线程编程的优势。无锁并发编程可用于通常减少 上下文切换。 CAS算法,使用最少的线程和使用协程。 (2)线程的安全问题 多线程编程中最难把握的是临界区域的线程安全(如果多个人购买了同一张火车票,火车票数出现负数 等)。严重的甚至出现死锁。一旦产生死锁,系统功能将不可用。
5、上下文切换是什么?
多线程编程中一般线程的数量大于 CPU 核心数,而一个 CPU 为了有效地执行这些线程,核心只能在任 何时候使用一个线程,CPU 策略是将时间片分配到每个线程并旋转。当一个线程的时间片用完时,它将 重新处于就绪状态,供其他线程使用。这个过程属于上下文切换。 上下文切换通常是计算密集型的。换句话说,它需要相当可观的处理器时间。在每秒几十次或数百次的 切换中,每次切换都需要纳秒量级时间。因此,上下文切换意味着系统消耗了大量事实上,CPU时间可 能是操作系统中时间消耗最大的操作。Linux与其他操作系统相比(包括其他类别)Unix系统有很多优点, 其中之一就是上下文切换和模式切换的时间消耗很少。
6、Thread和Runnable的区别
Thread和Runnable的实质是继承关系,没有可比性。无论使用Runnable还是Thread,都会 newThread,然后执行run方法。用法上,如果有复杂的线程操作需求,那就选择继承Thread,如果只 是简单的执行一个任务,那就实现runnable。 //会卖出多一倍的票
publicclassTest {
publicstaticvoidmain(String[] args) {
// TODO Auto-generated method stub
newMyThread().start();
newMyThread().start();
}
staticclassMyThreadextendsThread{
privateintticket=5;
publicvoidrun(){
while(true){
System.out.println("Thread ticket = "+ticket--);
if(ticket<0){
break;
}
}
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
//正常卖出
publicclassTest2 {
publicstaticvoidmain(String[] args) {
// TODO Auto-generated method stub
1
2
3
4
原因是:MyThread创建了两个实例,自然会卖出两倍,属于用法错误。
7、多线程的五种实现方式
1, 继承Thread,重写run方法,最后创建Thread 的子类对象,调用start()方法开启线程任务
例子(example):
2, 实现Runnable接口,重写run方法,创建Runnable 的实现类对象,通过Thread 的构造传递,调用start() 方 法开启线程任务
例子(example):
MyThread2mt=newMyThread2();
newThread(mt).start();
newThread(mt).start();
}
staticclassMyThread2implementsRunnable{
privateintticket=5;
publicvoidrun(){
while(true){
System.out.println("Runnable ticket = "+ticket--);
if(ticket<0){
break;
}
}
}
}
}
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
packagese.thread.create;
publicclassCreateThread_1 {
publicstaticvoidmain(String[] args) {
Threadthread=newThread(newMyThread());
thread.start();
}
}
classMyThreadextendsThread {
@Override
publicvoidrun() {
System.out.println("do some thing");
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
packagese.thread.create;
publicclassCreateThread_2 {
publicstaticvoidmain(String[] args) {
Threadthread=newThread(newMyRunnable());
thread.start();
}
}
1
2
3
4
5
6
7
8
9
3, 实现Callable接口,重写call方法,创建Callable的实现类对象,将Callable 的实现类对象,传递到 FutureTask的构造方法中,最后将FutureTask传递到Thread 的构造方法中,通过start()方法开启线程任务
例子(example):
classMyRunnableimplementsRunnable {
@Override
publicvoidrun() {
System.out.println("do some thing");
}
}
10
11
12
13
14
15
packagese.thread.create;
packagecom.yuma.juc.thread4;
importjava.util.concurrent.Callable;
importjava.util.concurrent.ExecutionException;
importjava.util.concurrent.FutureTask;
publicclassCreateThread_3 {
publicstaticvoidmain(String[] args) throwsExecutionException,
InterruptedException {
FutureTask<Integer>futureTask=newFutureTask<Integer>(new
MyCallable());
Threadthread=newThread(futureTask);
thread.start();
Integerresult=futureTask.get();
System.out.println(result);
}
}
/**
* Callable 和Runnable接口有什么区别
- 1、Callable接口中也是一个函数式接口里面拥有一个call方法
* 2、Callable接口的call方法可以有返回值。
* 3、Callable接口中的call方法可以抛异常(run()方法和call()方法都能抓异常)
*/
classMyCallableimplementsCallable<Integer> {
// 抛异常
@Override
publicIntegercall() throwsException {
// 抓异常
try {
System.out.println("do some thing");
} catch (Exceptione) {
e.printStackTrace();
}
return100;
}
}
classMyRunnableimplementsRunnable {
@Override
publicvoidrun() {
// 捕获异常
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
4、使用线程池创建
例子(example):
5、使用jdk1.8自带的异步编排方式
例子(example):
try {
inti=1/0;
} catch (Exceptione) {
System.out.println("do some thing");
e.printStackTrace();
}
}
}
44
45
46
47
48
49
50
51
packagese.thread.create;
importjava.util.concurrent.*;
publicclassCreateThread_4 {
publicstaticvoidmain(String[] args) {
// 比如使用线程池工具类
ExecutorServiceexecutorService=Executors.newFixedThreadPool(1);
executorService.submit(() -> {
System.out.println("do some thing");
});
// 比如自定义线程池
ThreadPoolExecutorthreadPoolExecutor
=newThreadPoolExecutor(10,20,30,TimeUnit.MINUTES,new
LinkedBlockingQueue<>());
threadPoolExecutor.execute(newRunnable() {
@Override
publicvoidrun() {
System.out.println("do some thing");
}
});
}
}
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
packagese.thread.create;
importjava.util.concurrent.CompletableFuture;
importjava.util.concurrent.ExecutorService;
importjava.util.concurrent.Executors;
publicclassCreateThread_5 {
publicstaticvoidmain(String[] args) {
// 使用异步编排
CompletableFuture.runAsync(() -> {
System.out.println("开始执行一个任务");
});
1
2
3
4
5
6
7
8
9
10
11
12
13
创建方式
优点
缺点
适用场景
继承Thread 简单直接 单继承限制,耦合度 高 简单测试,不推荐生产 环境 实现Runnable 解耦,可继承其 他类 无返回值,不能抛异 常 大多数业务场景,推荐 使用 Callable+Future 有返回值,可抛 异常 使用复杂,获取结果 阻塞 需要获取执行结果的场 景 线程池 资源复用,管理 方便 配置复杂,可能泄漏 高并发,生产环境首选 CompletableFuture 异步编程,链式 调用 API复杂,调试困难 复杂异步任务编排 备注:不管是哪种方式创建,底层都间接或直接关联到 Runnable 接口。
8、sleep()、join()、yield()的区别
- sleep ():当前线程 “休眠” 指定时间 核心逻辑:静态方法,调用后当前线程暂停执行指定毫秒数,期间不会释放持有的锁(比如 synchronized 锁),时间到后重新进入就绪状态,等待 CPU 调度。 典型场景:固定时间暂停(比如定时任务、模拟延迟)。
- join ():让调用者 “等待” 该线程完成 核心逻辑:实例方法,调用thread.join()后,调用者线程(比如主线程)会进入等待状态,直 到thread线程执行完毕;join(long millis)是超时等待 —— 超过指定时间后,调用者不再 等待,继续执行。 典型场景:需要等待某个线程执行完(比如主线程等子线程计算结果)。
- yield ():当前线程 “礼让” CPU 执行权 核心逻辑:静态方法,调用后当前线程主动让出 CPU 执行权,但仍处于就绪状态(不是等待 / 休 眠),操作系统可能立刻再次调度该线程(比如没有其他就绪线程时);不会释放锁,也不会抛出 中断异常。 典型场景:让优先级相同的线程更公平地执行(实际开发中很少用,因为调度器行为不可控)。
}
}
14
15
特性
sleep(longmillis) join() / join(long millis) yield() 方法归 属 Thread类(静态方法) Thread类(实例方法)
Thread类(静态方
法) 作用对 象 当前执行的线程 调用该方法的线程实例 当前执行的线程 核心作 用 让当前线程暂停指定时间 让调用者线程等待该线程执 行完毕 让当前线程主动让出 CPU 执行权 线程状 态 进入TIMED_WAITING(超 时等待) 调用者线程进入
WAITING/TIMED_WAITING
仍处于 RUNNABLE(就 绪)状态 是否释 放锁 不释放(持有锁时,其他线 程仍拿不到) 不释放(同上) 不释放(同上) 是否可 被中断 是(会抛出
InterruptedException )
是(同上) 否(无异常,中断状 态不影响) 执行后 调度 时间到后重新进入就绪队 列,等待 CPU 调度 被等待线程执行完 / 超时 后,调用者就绪 立刻进入就绪队列, 可能马上被再次调度
9、什么是守护线程?与普通线程的区别
守护线程是在程序运行时在后台提供一种支持性的线程。与普通线程相比,守护线程有以下几个区别:
- 终止条件:当所有用户线程结束时,守护线程会自动停止。换句话说,守护线程不会阻止程序的终 止,即使它们还没有执行完任务。
- 生命周期:守护线程的生命周期与主线程或其他用户线程无关。当所有的非守护线程都结束时, JVM 将会退出并停止守护线程的执行。
- 线程优先级:守护线程的优先级默认与普通线程一样。优先级较高的守护线程也不能够保证在其他 线程之前执行。
- 资源回收:守护线程通常被用于执行一些后台任务,例如垃圾回收、日志记录、定时任务等。当只 剩下守护线程时,JVM 会自动退出并且不会等待守护线程执行完毕。 需要注意的是,守护线程与普通线程在编写代码时没有太大的区别。可以通过将线程的 setDaemon(true)方法设置为 true,将普通线程转换为守护线程。 总结起来,守护线程在程序运行过程中提供一种支持性的服务,会在所有的用户线程结束时自动停止。
10、谈谈对守护线程的理解
守护线程:为所有非守护线程提供服务的线程;任一个守护线程都是整个JVM中所有非守护线程的保 姆; 守护线程类似于整个进程的一个默默无闻的小喽喽;它的生死无关重要,它却依赖整个进程而运行;哪 天其他线程结束了,没有要执行的了,程序就结束了,理都没理守护线程,就把它中断了; 注意:由于守护线程的终止是自身无法控制的,因此千万不要把IO、File等重要操作逻辑分配给它;因 为它不靠谱;
守护线程的作用是什么?
举例, GC垃圾回收线程:就是一个经典的守护线程,当我们的程序中不再有任何运行的Thread,程序就 不会再产生垃圾,垃圾回收器也就无事可做,所以当垃圾回收线程是JVM上仅剩的线程时,垃圾回收线 程会自动离开。它始终在低级别的状态中运行,用于实时监控和管理系统中的可回收资源。
应用场景:
(1)来为其它线程提供服务支持的情况; (2)或者在任何情况下,程序结束时,这个线程必须正常且立刻关闭,就可以作为守护线程来使用; 反之,如果一个正在执行某个操作的线程必须要正确地关闭掉否则就会出现不好的后果的话,那么这个 线程就不能是守护线程,而是用户线程。通常都是些关键的事务,比方说,数据库录入或者更新,这些 操作都是不能中断的。 thread.setDaemon(true)必须在thread.start()之前设置,否则会跑出一个IllegalThreadStateException 异常。你不能把正在运行的常规线程设置为守护线程。 在Daemon线程中产生的新线程也是Daemon的。 守护线程不能用于去访问固有资源,比如读写操作或者计算逻辑。因为它会在任何时候甚至在一个操作 的中间发生中断。 Java自带的多线程框架,比如ExecutorService,会将守护线程转换为用户线程,所以如果要使用后台线 程就不能用Java的线程池。
11、Java中止线程的三种方式
停止一个线程通常意味着在线程处理任务完成之前停掉正在做的操作,也就是放弃当前的操作。 在 Java 中有以下 3 种方法可以中止正在运行的线程:
- 使用退出标志,使线程正常退出,也就是当 run() 方法完成后线程中止。
- 使用 stop() 方法强行中止线程,但是不推荐使用这个方法,该方法已被弃用。
- 使用 interrupt 方法中断线程。
1). 使用标志位中止线程(推荐)
在 run() 方法执行完毕后,该线程就中止了。但是在某些特殊的情况下,run() 方法会被一直执行;比如 在服务端程序中可能会使用while(true) { … }这样的循环结构来不断的接收来自客户端的请求。 此时就可以用修改标志位的方式来结束 run() 方法。
publicclassExitFlagTests {
// volatile修饰符用来保证其它线程读取的总是该变量的最新的值
privatevolatilebooleanexitFlag=false; // 退出标志
publicvoidrun() {
while (!exitFlag) {
// 执行线程的任务
System.out.println("Thread is running...");
try {
Thread.sleep(1000); // 模拟一些工作
} catch (InterruptedExceptione) {
// 处理中断(如果需要)
Thread.currentThread().interrupt(); // 重新设置中断状态
}
}
System.out.println("Thread is stopping...");
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
2). 使用 stop() 中止线程(不推荐)
Thread.stop() 方法可以强行中止线程的执行。然而,这种方法是不安全的,因为它不保证线程资源的 正确释放和清理,可能导致数据不一致和资源泄露等问题,因此已被官方弃用。 通过查看 JDK 的 API,我们会看到 java.lang.Thread 类型提供了一系列的方法如 start()、stop()、 resume()、suspend()、destory()等方法来管理线程。但是除了 start() 之外,其它几个方法都被声名为 已过时(deprecated)。 虽然 stop() 方法确实可以停止一个正在运行的线程,但是这个方法是不安全的,而且该方法已被弃用, 最好不要使用它。 JDK 文档中还引入用一篇文章来解释了弃用这些方法的原因:《Why are Thread.stop, Thread.suspend and Thread.resume Deprecated?》
}
publicvoidstop() {
exitFlag=true; // 设置退出标志为true
}
publicstaticvoidmain(String[] args) throwsInterruptedException {
ExitFlagTestsexit=newExitFlagTests();
Threadthread=newThread(exit::run);
thread.start();
// 让线程运行一段时间
Thread.sleep(5000);
// 请求线程停止
exit.stop();
}
}
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
publicclassStopMethodTestsextendsThread {
publicvoidrun() {
while (true) {
System.out.println("Thread is running...");
try {
Thread.sleep(1000);
} catch (InterruptedExceptione) {
Thread.currentThread().interrupt();
}
}
}
publicstaticvoidmain(String[] args) throwsInterruptedException {
StopMethodTeststhread=newStopMethodTests();
thread.start();
// 让线程运行一段时间
Thread.sleep(5000);
// 强行中止线程(不推荐)
thread.stop();
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
为什么弃用stop:
- 调用 stop() 方法会立刻停止 run() 方法中剩余的全部工作,包括在 catch 或 finally 语句中的,并抛 出ThreadDeath异常(通常情况下此异常不需要显示的捕获),因此可能会导致一些清理性的工作的 得不到完成,如文件,数据库等的关闭。
- 调用 stop() 方法会立即释放该线程所持有的所有的锁,导致数据得不到同步,出现数据不一致的问 题。
3). 使用 interrupt() 中断线程
现在我们知道了使用 stop() 方式停止线程是非常不安全的方式,那么我们应该使用什么方法来停止线程 呢?答案就是使用 interrupt() 方法来中断线程。 需要明确的一点的是:interrupt() 方法并不像在 for 循环语句中使用 break 语句那样干脆,马上就停止 循环。调用 interrupt() 方法仅仅是在当前线程中打一个停止的标记,并不是真的停止线程。 也就是说,线程中断并不会立即中止线程,而是通知目标线程,有人希望你中止。至于目标线程收到通 知后会如何处理,则完全由目标线程自行决定。这一点很重要,如果中断后,线程立即无条件退出,那 么我们又会遇到 stop() 方法的老问题。 事实上,如果一个线程不能被 interrupt,那么 stop 方法也不会起作用。 我们来看一个使用 interrupt() 的例子:
publicclassInterruptTestsextendsThread{
publicstaticvoidmain(String[] args) {
try {
InterruptTestst=newInterruptTests();
// 启动线程
t.start();
Thread.sleep(200);
// 中断线程 t
t.interrupt();
} catch (InterruptedExceptione) {
e.printStackTrace();
}
}
@Override
publicvoidrun() {
super.run();
// 循环打印 i 的值,从 0 到 200000
for(inti=0; i<=200000; i++) {
System.out.println("i="+i);
}
}
}
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
从输出的结果我们会发现 interrupt 方法并没有停止线程 t 中的处理逻辑,也就是说即使 t 线程被设置为 了中断状态,但是这个中断并不会起作用,那么该如何停止线程呢? 这就需要使用到另外两个与线程中断有关的方法了: 这两个方法使得当前线程能够感知到是否被中断了(通过检查标志位)。 所以如果希望线程 t 在中断后停止,就必须先判断是否被中断,并为它增加相应的中断处理代码: 在上面这段代码中,我们增加了 Thread.isInterrupted() 来判断当前线程是否被中断了,如果是,则退 出 for 循环,结束线程。 这种方式看起来与之前介绍的“使用标志位中止线程”非常类似,但是在遇到 sleep() 或者 wait() 这样的 操作,我们只能通过中断来处理了。
public static native void sleep(long millis) throws InterruptedException
Thread.sleep() 方法会抛出一个 InterruptedException 异常,当线程被 sleep() 休眠时,如果被中断, 这会就抛出这个异常。 (注意:Thread.sleep() 方法由于中断而抛出的异常,是会清除中断标记的。)
12、谈谈对线程安全的理解
1)、线程安全的核心定义
线程安全是指:当多个线程并发访问同一个共享资源(对象、变量、数据结构等)时,无论这些线程的
执行顺序如何(串行 / 交替)、是否被操作系统调度中断,都能保证该资源的逻辑状态符合预期,程序
最终的执行结果与单线程执行结果完全一致,且不会出现数据错乱、逻辑异常、死锁等问题。
i=199993
i=199994
i=199995
i=199996
i=199997
i=199998
i=199999
i=200000
1
2
3
4
5
6
7
8
publicbooleanThread.isInterrupted() //判断是否被中断
publicstaticbooleanThread.interrupted() //判断是否被中断,并清除当前中断状态
1
2
@Override
publicvoidrun() {
super.run();
for(inti=0; i<=200000; i++) {
//判断是否被中断
if(Thread.currentThread().isInterrupted()){
//处理中断逻辑
break;
}
System.out.println("i="+i);
}
}
1
2
3
4
5
6
7
8
9
10
11
12
手段
核心原理
适用场景
加锁 (synchronized/Lock) 保证同一时间只有一个线程执 行临界区代码 所有需要原子操作的场景(比如 计数、转账) 原子类(AtomicInteger) 基于 CAS(无锁机制)保证操 作原子性 简单变量的原子操作(替代
count++ )
不可变对象 (String/Integer) 对象创建后属性不可修改 只读共享数据 线程本地存储 (ThreadLocal) 为每个线程创建私有副本 避免共享(比如用户上下文、数 据库连接) 并发容器 (ConcurrentHashMap) 分段锁 / 无锁机制保证安全 多线程操作集合 简单来说:多线程操作共享数据时,结果 “不出错”,就是线程安全;反之(比如计数不准、集合遍历异 常、数据覆盖),就是线程不安全。
2)、线程安全问题的根本原因
线程安全问题的产生,是 “三个条件同时满足” 的结果:
- 共享资源:多个线程访问同一个可变的共享数据(比如堆中的对象属性、静态变量、共享集合) —— 栈因线程私有,局部变量(未暴露给其他线程)天然线程安全;
- 并发操作:多个线程对共享资源的操作是 “同时进行” 的(CPU 时间片切换导致线程交替执行);
- 非原子操作:对共享资源的操作不是 “不可分割” 的(比如count++ 实际是 “读→改→写” 三步操 作,中间可能被其他线程打断)。
3)、常见场景
(1). 天然线程安全的场景
无共享资源:每个线程操作自己的私有数据(比如栈内的局部变量); 只读共享资源:多个线程仅读取共享数据,不修改(比如常量、不可变对象String ); 单线程环境:程序只有一个线程,无并发操作。
(2). 典型的线程不安全场景
多线程修改基础类型变量:比如int count = 0 ,多线程执行count++ ; 多线程操作非线程安全集合:比如ArrayList 、HashMap 被多线程添加 / 删除元素(会出现
ConcurrentModificationException 或数据丢失);
多线程修改对象属性:比如共享的User 对象,多个线程同时修改age 属性。
4). 实现线程安全的常用手段
13、synchronized的实现原理
synchronized是Java语言中最基本的线程同步机制,它通过互斥锁来控制线程对共享变量的访问。
具体实现原理如下:
- synchronized的实现基础是对象内部的锁(也称为监视器锁或管程),每个锁关联着一个对象实 例。
- 当synchronized作用于某个对象时,它就会尝试获取这个对象的锁,如果锁没有被其他线程占用, 则当前线程获取到锁,并可以执行同步代码块;如果锁已经被其他线程占用,那么当前线程就会阻 塞在同步块之外,直到获取到锁才能进入同步块。
- synchronized还支持作用于类上,此时它锁住的是整个类,而不是类的某个实例。在这种情况下, 由于只有一个锁存在,所以所有使用该类的线程都需要等待锁的释放。
- 在JVM内部,每个Java对象都有头信息,其中包含了对象的一些元信息和状态标志。synchronized 通过修改头信息的状态标志来实现锁的获取和释放。
- synchronized还支持可重入性,即在同一个线程中可以多次获取同一个锁,这样可以避免死锁问 题。
- Java虚拟机会通过锁升级的方式来提升synchronized的效率,比如偏向锁、轻量级锁和重量级锁等 机制,使得在竞争不激烈的情况下,synchronized的性能可以达到与非同步代码相当的水平。
14、多线程的生命周期的六种状态
源码中一共定义了6钟状态。 (1)新建(NEW):线程对象刚给创建,但未启动(start)
例子(example):
(2)可运行(RUNNABLE):线程已被启动,可以被调度或正在被调度。
例子(example):
packagese.thread.life;
publicclassThread_New {
publicstaticvoidmain(String[] args) {
Threadthread=newThread();// 只要线程new出来
System.out.println("线程的名字"+thread.getName()+"线程的状
态:"+thread.getState());
}
}
1
2
3
4
5
6
7
8
9
10
11
packagese.thread.life;
1
(3)锁阻塞(BLOCKED):当前线程要获取的锁对象正被其他线程占用,此时该线程处于Blocked状 态。
例子(example):
publicclassThread_Runnable {
publicstaticvoidmain(String[] args) {
Threadthread=newThread(()->{
System.out.println("1111");
System.out.println("线程的状
态"+Thread.currentThread().getState());
});
thread.start();
System.out.println("线程的状态"+thread.getState());
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
packagese.thread.life;
publicclassThread_Blocked {
publicstaticvoidmain(String[] args) {
Objecto=newObject();
ThreadthreadA=newThread(()->{
synchronized (o){
System.out.println("11111");
try {
Thread.sleep(1000);
} catch (InterruptedExceptione) {
e.printStackTrace();
}
}
},"A");
threadA.start();
try {
Thread.sleep(500);
} catch (InterruptedExceptione) {
e.printStackTrace();
}
ThreadthreadB=newThread(()->{
synchronized (o){
}
},"B");
threadB.start();
try {
Thread.sleep(100);
} catch (InterruptedExceptione) {
e.printStackTrace();
}
System.out.println("线程"+threadB.getName()+"状
态"+threadB.getState());
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
(4)等待阻塞(WAITING):当前线程遇到了wait(),join()等方法。
例子(example):
}
}
37
38
39
40
packagese.thread.life;
publicclassThread_Waiting {
// 使用wait、notify或者notifyAll的时候必须要要结合synchronized使用
publicstaticvoidmain(String[] args) throwsInterruptedException {
Objecto=newObject();
Threadthread=newThread(() -> {
synchronized (o) {
try {
for (inti=1; i<10 ; i++) {
System.out.println("i---"+i);
if(i==5){
//使用对象的wait方法时须有一个对象和synchronized
o.wait();
//如若不结合synchronized 那么就会出现一个监视器对象
//状态异常IllegalMonitorStateException。任何一个
//对象中都有一个ObjectMonitor对象。监视器锁。管程技术。
}
}
} catch (InterruptedExceptione) {
e.printStackTrace();
}
}
});
thread.start();
Thread.sleep(2000);
System.out.println(thread.getState());
Threadthread1=newThread(()->{
// o.notify();//使用notify或者notifyAll()都要结合synchronized使用,
// 不然就会出现监视器异常IllegalMonitorStateException
synchronized (o){
System.out.println("do some thing");
o.notify();
}
});
thread1.start();
System.out.println("end");
}
}
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
(5)限时等待(TIMED_WAITING):当前线程调用了sleep(时间),wait(时间),join(时间)等方法。
例子(example):
(6)终止(TERMINATED):线程正常结束或异常提前退出。
例子(example):
备注:没有Running状态,只有Runnable状态。Runnable包括两种情况:Ready就绪+Running正在运 行。
packagese.thread.life;
publicclassThread_TimeWaiting {
publicstaticvoidmain(String[] args) throwsInterruptedException {
Objecto=newObject();
Threadthread=newThread(()->{
synchronized (o){
try {
// 线程调用wait(5000)方法
o.wait(5000);
} catch (InterruptedExceptione) {
e.printStackTrace();
}
}
});
thread.start();
// 线程调用sleep(5000)方法
Thread.sleep(5000);
// 线程调用join(5000)方法
thread.join(5000);
System.out.println(thread.getState());
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
packagese.thread.life;
publicclassThread_Terminated {
publicstaticvoidmain(String[] args) throwsInterruptedException {
Threadthread=newThread(() -> {
inti=0;
System.out.println("1111");
try {
i=10/0;
} catch (Exceptione) {
e.printStackTrace();
}
System.out.println(i);
});
thread.start();
Thread.sleep(100);
System.out.println(thread.getState());
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
状态
触发条件
含义与特征
NEW new Thread() 后,未调用 start() 初始状态,线程已创建但未启动 RUNNABLE 调用 start() 后(包含就绪 Ready 和运行中 Running 两种 子状态) 可执行状态: - 可能正在运行 - 或在等待 CPU/OS 资源(如 I/O)(⚠ 注意:等
待键盘输入时也显示此状态)
BLOCKED 线程竞争 synchronized 锁失败 锁阻塞: - 在对象的 Entry Set(锁池) 中等待 - 只有 synchronized 锁会触发此 状态 WAITING 调用无超时的阻塞方法: object.wait() thread.join() LockSupport.park() 无限期等待: - 在对象的 Wait Set 中休 眠 - 需外部事件唤醒(如 notify()) TIMED_WAITING 调用带超时的阻塞方法:
Thread.sleep(n)
object.wait(timeout)
thread.join(timeout)
有限期等待: - 在 OS 计时器或对象 Wait Set 中 - 超时或中断可唤醒 TERMINATED 线程执行完毕或异常终止 最终状态: - 不可重启 - 资源未立即释放 (可被 GC 回收)
15、多线程的生命周期
备注:
BLocked的状态一定是和锁(synchronized而不是Lock,Lock的底层是AQS,不是monitor)有关 的,拿不到锁进入BLOCKED状态,所以sleep、join、等待键盘输入都不会进入BLocked 被notfiy()、notifyAll()唤醒后进入BLOCKED状态,等待获取锁。被signal()、signalAll()唤醒后进
入RUNNABLE状态,这是因为AQS的实现机制,和synchronized机制不同(后续JUC说明)。
特性
sleep()
wait()
所属类 Thread 类的静态方法 Object 类的方法 锁的释放 不释放任何锁(包括同步 锁) 释放目标对象的同步锁 唤醒条件 超时结束或被中断 (InterruptedException) 被其他线程调用 notify()/notifyAll() 或超时结束 或被中断 调用位置 任何地方 必须在同步块或同步方法内(持有锁时) 用途 单纯的暂停线程执行(定 时、延迟) 线程间协调、等待条件满足 重获锁 醒来后直接继续执行(原 本就没释放锁) 醒来后需要重新竞争获取对象锁才能继续执行 执行该方法后进 入什么状态 TIMED_WAITING (可不 是阻塞blocked) WAITING,TIMED_WAITING,(和sleep的线程 状态一样,可不在一个队列) 被唤醒后进入什 么状态 Runnable状态 Blocked状态(因为还要先拿到锁),拿到了锁 进Runnable状态
16、sleep()、wait()的区别
1.锁池
所有需要竞争同步锁(synchronized)的线程都会放在锁池当中,比如当前对象的锁已经被其中一个线 程得到,则其他线程需要在这个锁池进行等待,当前面的线程释放同步锁后锁池中的线程去竞争同步 锁,当某个线程得到后会进入就绪队列进行等待cpu资源分配。
2.等待池
当我们调用wait()方法后,线程会放到等待池当中,等待池的线程是不会去竞争同步锁。只有调用了 notify()或notifyAll()后等待池的线程才会开始去竞争锁,notify()是随机从等待池选出一个线程放 到锁池,而notifyAll()是将等待池的所有线程放到锁池当中。 1、sleep 是 Thread 类的静态本地方法,wait 则是 Object 类的本地方法。 2、sleep方法不会释放lock,但是wait会释放,而且会加入到等待队列中。 3、sleep方法不依赖于同步器synchronized,但是wait需要依赖synchronized关键字。 4、sleep不需要被唤醒(休眠之后推出阻塞),但是wait需要(不指定时间需要被别人中断)。 5、sleep 一般用于当前线程休眠,或者轮循暂停操作,wait 则多用于多线程之间的通信。 6、sleep 会让出 CPU 执行时间且强制上下文切换,而 wait 则不一定,wait 后可能还是有机会重新竞争 到锁继续执行的。 备注:共同点:都会释放CPU;最大不同点:wait让出cpu的同时会释放锁,sleep让出cpu的同时不会 是否释放锁
17、notify()和 notifyAll()有什么区别
在Java中,notify()和notifyAll()都属于Object类的方法,用于实现线程间的通信。 notify()方法用于唤醒在当前对象上等待的单个线程。如果有多个线程同时在某个对象上等待(通过调 用该对象的wait()方法),则只会唤醒其中一个线程,并使其从等待状态变为阻塞态再变为可运行状态。 具体是哪个线程被唤醒是不确定的,取决于线程调度器的实现。 notifyAll()方法用于唤醒在当前对象上等待的所有线程。如果有多个线程在某个对象上等待,调用 notifyAll()方法后,所有等待的线程都会被唤醒并竞争该对象的锁。其中一个线程获得锁后继续执行,其 他线程则继续等待。 需要注意的是,notify()和notifyAll()方法只能在同步代码块或同步方法内部调用,并且必须拥有与该对 象关联的锁。否则会抛出IllegalMonitorStateException异常。
六、IO流和网络编程
本部分面试题涉及IO流、网络编程内容,不作为重点
1、IO流分类
IO流根据流向有输入流和输出流两种:读是输入,写是输出,输入输出不是针对数据源,而是针对内存 空间 IO流根据类型分类有字节流和字符流:字节流是万能流,可以处理任意的文件。字符流不是万能流,基本 上用来处理纯文本文件 IO流程根据功能特征分类有节点流和包装流:节点流负责和底层数据源交互,包装流负责增强功能 流的四个抽象顶级类: 字节输入流 InputStream 字节输出流 OutputStream 字符输入流 Reader 字符输出流 Writer 备注:流的设计遵循 “装饰者模式”:处理流包裹节点流,增强功能(缓冲、转换、序列化)。
2、Java五种文件拷贝方式
在 Java 中,文件拷贝主要有以下几种方式,不同场景下效率差异显著。以下从实现方式、效率对比和适 用场景三方面详细解析:
1)、文件拷贝的 5 种实现方式
- 传统字节流拷贝FileOutputStream 特点:基础方法,直接逐字节或缓冲区读写。 效率:最低(适合小文件)。
- 缓冲流优化拷贝BufferedInputStream+BufferedOutputStream 特点:通过缓冲区减少 I/O 次数。 效率:比传统字节流提升 2~5 倍。
- NIO Files.copy方法(Java 7+) 特点:单行代码完成拷贝,底层自动优化。 效率:接近最高效(适合大多数场景)。
- NIO FileChannel 通道拷贝
publicstaticvoidmain(String[] args) throwsIOException {
try (InputStreamis=newFileInputStream("source.txt");
OutputStreamos=newFileOutputStream("target.txt")) {
byte[] buffer=newbyte[1024];
intlength;
while ((length=is.read(buffer)) >0) {
os.write(buffer, 0, length);
}
}
}
1
2
3
4
5
6
7
8
9
10
publicstaticvoidmain(String[] args) throwsIOException {
try (BufferedInputStreambis=newBufferedInputStream(new
FileInputStream("source.txt"));
BufferedOutputStreambos=newBufferedOutputStream(new
FileOutputStream("target.txt"))) {
byte[] buffer=newbyte[8192]; // 缓冲区越大,性能越好(通常 8KB~64KB)
intlen;
while ((len=bis.read(buffer)) !=-1) {
bos.write(buffer, 0, len);
}
}
}
1
2
3
4
5
6
7
8
9
10
publicstaticvoidmain(String[] args) throwsIOException {
Pathsource=Paths.get("source.txt");
Pathtarget=Paths.get("target.txt");
Files.copy(source, target, StandardCopyOption.REPLACE_EXISTING);
}
1
2
3
4
5
方法
耗时(毫秒)
适用场景
传统字节流
45005000
小文件(<10MB)
缓冲流
12001500
通用场景
Files.copy
800~1000 简单代码 + 快速开发
FileChannel.transfer
600800
大文件(>100MB)
内存映射文件
500700
超大文件(>1GB)
特点:利用通道(Channel)直接传输数据。
效率:大文件性能最佳(利用零拷贝技术)。
5. 内存映射文件拷贝MappedByteBuffer
特点:将文件映射到内存直接操作。 效率:适合超大文件(但实现复杂,需谨慎处理内存)。
2)、效率对比(1GB 文件测试)
3)、如何选择最高效方式?
- 小文件(<10MB) 直接使用Files.copy ,代码简洁且性能足够。
- 大文件(100MB~1GB) 优先选择FileChannel.transferTo() ,利用零拷贝减少内核态与用户态数据复制。
- 超大文件(>1GB)用内存映射文件(MappedByteBuffer ),但需注意:
publicstaticvoidmain(String[] args) throwsIOException {
try (FileChannelsourceChannel=new
FileInputStream("source.txt").getChannel();
FileChanneltargetChannel=new
FileOutputStream("target.txt").getChannel()) {
sourceChannel.transferTo(0, sourceChannel.size(), targetChannel);
}
}
1
2
3
4
5
6
publicstaticvoidmain(String[] args) throwsIOException {
try (RandomAccessFilesourceFile=newRandomAccessFile("source.txt",
"r");
RandomAccessFiletargetFile=newRandomAccessFile("target.txt",
"rw")) {
FileChannelsourceChannel=sourceFile.getChannel();
MappedByteBufferbuffer=
sourceChannel.map(FileChannel.MapMode.READ_ONLY, 0, sourceChannel.size());
targetFile.getChannel().write(buffer);
}
}
1
2
3
4
5
6
7
8
避免频繁映射/释放内存(开销大)。
处理内存溢出风险(OutOfMemoryError )。
- 通用场景 Files.copy或缓冲流,平衡代码可读性与性能。
4)、终极建议
优先使用Files.copy :Java 7+ 自带优化,简单高效。 大文件必用FileChannel`:性能碾压传统流。 避免手动逐字节读写:除非处理特殊格式(如加密流)。
3、BIO、NIO、AIO 之间的区别
1).BIO (Blocking I/O)
BIO 属于同步阻塞 IO 模型。 同步阻塞 IO 模型中,应用程序发起 read 调用后,会一直阻塞,直到内核把数据拷贝到用户空间。 在客户端连接数量不高的情况下,是没问题的。但是,当面对十万甚至百万级连接的时候,传统的 BIO 模型是无能为力的。因此,我们需要一种更高效的 I/O 处理模型来应对更高的并发量。
2).NIO (Non-blocking/New I/O)
Java 中的 NIO 于 Java 1.4 中引入,对应 java.nio 包,提供了 Channel , Selector,Buffer 等抽象类。 NIO 中的 N 可以理解为 Non-blocking,不单纯是 New。它是支持面向缓冲的,基于通道的 I/O 操作方 法。对于高负载、高并发的(网络)应用,应使用 NIO 。 Java 中的 NIO 可以看作是 I/O 多路复用模型。也有很多人认为,Java 中的 NIO 属于同步非阻塞 IO 模 型。 跟着我的思路往下看看,相信你会得到答案! 我们先来看看同步非阻塞 IO 模型。 同步非阻塞 IO 模型中,应用程序会一直发起 read 调用,等待数据从内核空间拷贝到用户空间的这段时 间里,线程依然是阻塞的,直到内核把数据拷贝到用户空间。 相比于同步阻塞 IO 模型,同步非阻塞 IO 模型确实有了很大改进。通过轮询操作,避免了一直阻塞。 但是,这种 IO 模型同样存在问题:应用程序不断进行 I/O 系统调用轮询数据是否已经准备好的过程是十 分消耗 CPU 资源的。 这个时候,I/O 多路复用模型就上场了。 IO 多路复用模型中,线程首先发起 select 调用,询问内核数据是否准备就绪,等内核把数据准备好了, 用户线程再发起 read 调用。 read 调用的过程(数据从内核空间 -> 用户空间)还是阻塞的。 目前支持 IO 多路复用的系统调用,有 select,epoll 等等。select 系统调用,目前几乎在所有的操 作系统上都有支持。 select 调用:内核提供的系统调用,它支持一次查询多个系统调用的可用状态。几乎所有的操 作系统都支持。 epoll 调用:linux 2.6 内核,属于 select 调用的增强版本,优化了 IO 的执行效率。
IO多路复用模型,通过减少无效的系统调用,减少了对 CPU 资源的消耗。
Java 中的 NIO ,有一个非常重要的选择器 ( Selector ) 的概念,也可以被称为多路复用器。通过它,只 需要一个线程便可以管理多个客户端连接。当客户端数据到了之后,才会为其服务。 Buffer、Channel和Selector三者之间的关系如下:
3).AIO (Asynchronous I/O)
AIO 也就是 NIO 2。Java 7 中引入了 NIO 的改进版 NIO 2,它是异步 IO 模型。 异步 IO 是基于事件和回调机制实现的,也就是应用操作之后会直接返回,不会堵塞在那里,当后台处理 完成,操作系统会通知相应的线程进行后续的操作。 总结:最后,来一张图,简单总结一下 Java 中的 BIO、NIO、AIO。
4、谈谈你对Java序列化的理解
1).什么是序列化和反序列化?
序列化:将 Java 对象转换为字节流(如保存到文件或通过网络传输)。 反序列化:将字节流还原为 Java 对象。 序列化的核心是让对象能够在非 Java 环境(如文件、数据库、网络)中存储或传输。
Java的序列化是指将Java对象转换为字节流的过程,可以将这些字节流保存到文件中或通过网络传输。
反序列化则是指将字节流恢复成对象的过程。
序列化的主要目的是实现对象的持久化存储和传输,让对象可以在不同的计算机或不同的时间点被重建 和使用。通过序列化,可以将对象的状态以字节的形式保存下来,并且在需要的时候进行恢复,从而实 现了对象的跨平台传输和持久化存储。 在Java中,要使一个类可序列化,需要满足以下条件:
- 实现java.io.Serializable接口,该接口是一个标记接口,没有任何方法。
- 所有的非静态、非瞬态的字段都可以被序列化。 使用Java的序列化机制,可以通过ObjectOutputStream将对象转换为字节流并写入文件或网络流中。反 之,通过ObjectInputStream可以从字节流中读取数据并还原为对象。 需要注意的是,在进行序列化和反序列化时,对象的类和字段的定义必须保持一致,否则可能会导致序 列化版本不匹配或字段丢失的问题。
2).如何实现序列化和反序列化?
Java 提供了Serializable接口,用于标记需要序列化的类。 序列化底层原理:ObjectStreamClass类中可以看到writeObjectMethod属性,值来自对象 writeObject方法 反序列化底层原理:ObjectStreamClass类中可以看到readObjectMethod属性,值来自对象 readObject方法
/**
* @Auth:TianMing
* @Description:基本应用
*/
importjava.io.*;
publicclassSerializationExampleimplementsSerializable {
privatestaticfinallongserialVersionUID=1L; // 序列化版本号
privateStringname;
privatetransientintage; // transient 关键字表示该字段不参与序列化
// 构造方法、getter 和 setter 略
publicstaticvoidmain(String[] args) {
SerializationExampleobj=newSerializationExample();
obj.setName("张三");
obj.setAge(25);
// 序列化
try (ObjectOutputStreamoos=newObjectOutputStream(new
FileOutputStream("object.ser"))) {
oos.writeObject(obj);
System.out.println("对象已序列化");
} catch (IOExceptione) {
e.printStackTrace();
}
// 反序列化
try (ObjectInputStreamois=newObjectInputStream(new
FileInputStream("object.ser"))) {
SerializationExamplenewObj= (SerializationExample)
ois.readObject();
System.out.println("对象已反序列化: "+newObj.getName());
// 注意:transient 字段会被还原为默认值(这里是 0)
System.out.println("年龄: "+newObj.getAge());
} catch (IOException|ClassNotFoundExceptione) {
e.printStackTrace();
}
}
}
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
自行编写readObject()函数,用于对象的反序列化构造,从而提供约束性。可以自定义反序列化对象的 校验信息
3).序列化和反序列化的注意事项
3.1 transient关键字
transient修饰的字段不会被序列化。 反序列化时,transient字段会被还原为默认值(如int为0 ,String为null )。
3.2 serialVersionUID
serialVersionUID是序列化版本号,用于标识类的版本。 如果类的结构发生变化(如新增字段或修改字段类型),serialVersionUID不匹配会导致反序 列化失败。 最好显式声明serialVersionUID ,否则编译器会自动生成一个,但容易导致版本不一致。 3.3 静态字段 静态字段不会被序列化,因为它们属于类而不是对象。
4).序列化和反序列化的安全性问题
序列化和反序列化存在安全风险,尤其是反序列化时: 反序列化恶意数据可能导致代码执行或数据泄露。 面试官可能会问你如何避免这些问题。 解决方法:
- 自定义readObject 和writeObject方法**:
在类中覆盖readObject和writeObject方法,验证数据的合法性。
- 重写代码
- 使用安全的序列化框架: 避免使用 Java 默认的序列化机制,改用更安全的框架(如 Protobuf、Kryo)。
- 避免敏感数据参与序列化: 使用transient修饰敏感字段,确保它们不被序列化。
/**
* @Auth:TianMing
* @Description:安全性
*/
privatevoidreadObject(ObjectInputStreamois) throwsIOException,
ClassNotFoundException {
ois.defaultReadObject();
// 验证数据
if (name==null) {
thrownewInvalidObjectException("name 不能为空");
}
}
1
2
3
4
5
6
7
8
9
10
11
5).序列化和反序列化的性能优化
Java 的默认序列化机制效率较低,可以通过以下方式优化:
1. 使用**Externalizable** ** 接口**:
Externalizable是Serializable的子接口,允许自定义序列化逻辑。
通过实现writeExternal和readExternal方法,可以减少序列化数据的大小。
- 使用高效的序列化库: Protobuf:Google 开发的高效序列化框架,支持跨语言。 Kryo:轻量级、高性能的序列化框架。 FST:快速、紧凑的序列化库。
6).面试常见问题
问题 1:为什么需要序列化?
答案:序列化可以将对象转换为字节流,方便存储或传输。例如,将对象保存到文件、通过网络发 送对象、在分布式系统中传递对象。 问题 2:transient和static字段为什么不参与序列化? 答案:transient表示字段不参与序列化,static字段属于类而不是对象,因此也不参与序列 化。 问题 3:serialVersionUID的作用是什么?
答案:serialVersionUID用于标识类的版本。如果类的结构发生变化,serialVersionUID不
匹配会导致反序列化失败。
问题 4:如何提高序列化的性能?
答案:可以使用Externalizable接口自定义序列化逻辑,或者使用高效的序列化库(如 Protobuf、Kryo)。
问题 5:序列化和反序列化有哪些安全问题?
答案:反序列化恶意数据可能导致代码执行或数据泄露。 如何防止反序列化破坏单例模式的对象呢?可以通过重写readObject方法或者readResolve 方法、使用安全的序列化框架、避免敏感数据参与序列化来解决。
问题 6:追问又来了,上面的readObject和 readResolve方法有啥区别呢?
示例 1:重写readObject方法
importjava.io.*;
/**
* @Auth:TianMing
* @Description: readObject
*/
publicclassSingletonimplementsSerializable {
privatestaticfinallongserialVersionUID=1L;
privatestaticfinalSingletonINSTANCE=newSingleton();
// 私有构造方法,防止外部实例化
privateSingleton() {
// 防止反射破坏单例
1
2
3
4
5
6
7
8
9
10
11
12
示例 2:使用readResolve()方法
if (INSTANCE!=null) {
thrownewIllegalStateException("Already instantiated");
}
}
// 提供获取实例的方法
publicstaticSingletongetInstance() {
returnINSTANCE;
}
// 重写 readObject 方法,阻止反序列化
privatevoidreadObject(ObjectInputStreamois) throwsIOException,
ClassNotFoundException {
thrownewIOException("Singleton cannot be deserialized");
}
publicstaticvoidmain(String[] args) {
Singletoninstance1=Singleton.getInstance();
// 尝试序列化和反序列化
try (ObjectOutputStreamoos=newObjectOutputStream(new
FileOutputStream("singleton.ser"))) {
oos.writeObject(instance1);
} catch (IOExceptione) {
e.printStackTrace();
}
try (ObjectInputStreamois=newObjectInputStream(new
FileInputStream("singleton.ser"))) {
Singletoninstance2= (Singleton) ois.readObject();
System.out.println(instance1==instance2); // false,因为
readObject 抛出了异常
} catch (IOException|ClassNotFoundExceptione) {
e.printStackTrace();
}
}
}
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
importjava.io.*;
/**
* @Auth:TianMing
* @Description: readResolve
*/
publicclassSingletonimplementsSerializable {
privatestaticfinallongserialVersionUID=1L;
privatestaticfinalSingletonINSTANCE=newSingleton();
// 私有构造方法,防止外部实例化
privateSingleton() {
// 防止反射破坏单例
if (INSTANCE!=null) {
thrownewIllegalStateException("Already instantiated");
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
readObject方法:适合用来阻止反序列化或验证对象状态。 readResolve()方法:适合用来确保反序列化后返回的是单例的唯一实例。 两种方法的区别
readObject 方法:
是ObjectInputStream的方法,用于自定义反序列化逻辑。
适合用来验证对象的状态或直接阻止反序列化。
readResolve() 方法:
是一个特殊的钩子方法,用于在反序列化完成后返回一个对象。 适合用来确保返回的是单例的唯一实例。
7).总结
序列化:将对象转换为字节流。 反序列化:将字节流还原为对象。
注意事项:transient 、serialVersionUID 、静态字段。
安全性:避免反序列化恶意数据,使用安全的序列化框架。 性能优化:使用Externalizable或高效的序列化库。 掌握这些知识点后,你可以在面试中自信地回答序列化和反序列化相关的问题!如果还有其他疑问,可 以继续提问。 // 提供获取实例的方法
publicstaticSingletongetInstance() {
returnINSTANCE;
}
// 使用 readResolve 方法,确保返回的是单例的唯一实例
privateObjectreadResolve() {
returnINSTANCE;
}
publicstaticvoidmain(String[] args) {
Singletoninstance1=Singleton.getInstance();
// 尝试序列化和反序列化
try (ObjectOutputStreamoos=newObjectOutputStream(new
FileOutputStream("singleton.ser"))) {
oos.writeObject(instance1);
} catch (IOExceptione) {
e.printStackTrace();
}
try (ObjectInputStreamois=newObjectInputStream(new
FileInputStream("singleton.ser"))) {
Singletoninstance2= (Singleton) ois.readObject();
System.out.println(instance1==instance2); // true,因为
readResolve 返回了 INSTANCE
} catch (IOException|ClassNotFoundExceptione) {
e.printStackTrace();
}
}
}
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
5、IP地址和端口号
• IP地址:用来标志网络中的一个通信实体的地址。通信实体可以是计算机,路由器等。 • IP地址分类 – IPV4:32位地址,以点分十进制表示,如192.168.0.1 – IPV6:128位(16个字节)写成8个16位的无符号整数,每个整数用四个十六进制位表示,数之间用冒 号(:)分开,如:3ffe:3201:1401:1280:c8ff:fe4d:db39:1984 • 特殊的IP地址 – 127.0.0.1 本机地址 – 192.168.0.0—192.168.255.255私有地址,属于非注册地址,专门为组织机构内部使用。 • 端口:port – IP地址用来标志一台计算机,但是一台计算机上可能提供多种应用程序,使用端口来区分这些应用程 序。 – 端口是虚拟的概念,并不是说在主机上真的有若干个端口。通过端口,可以在一个主机上运行多个网 络应用程序。 – 端口范围0---65535,16位整数 • 端口分类 – 公认端口 0—1023 比如80端口分配给WWW,21端口分配给FTP,22端口分配给SSH,23端口分配给 telnet,25端口分配给smtp – 注册端口 1024—49151 分配给用户进程或应用程序 – 动态/私有端口 49152—65535 • 理解IP和端口的关系 – IP地址好比每个人的地址(门牌号),端口好比是房间号。必须同时指定IP地址和端口号才能够正确的 发送数据 – IP地址好比为电话号码,而端口号就好比为分机号。
6、ISO/OSI参考模型和TCP/IP 参考模型
OSI(Open System Interconnection),开放式系统互联参考模型。是一个逻辑上的定义,一个规 范,它把网络协议从逻辑上分为了7层。每一层都有相关、相对应的物理设备,比如常规的路由器 是三层交换设备,常规的交换机是二层交换设备。OSI七层模型是一种框架性的设计方法,建立七 层模型的主要目的是为解决异种网络互连时所遇到的兼容性问题,其最主要的功能就是帮助不同类 型的主机实现数据传输。它的最大优点是将服务、接口和协议这三个概念明确地区分开来,通过七 个层次化的结构模型使不同的系统不同的网络之间实现可靠的通讯。 TCP/IP协议是Internet最基本的协议、Internet国际互联网络的基础,主要由网络层的IP协议和传 输层的TCP协议组成。TCP/IP 定义了电子设备如何连入因特网,以及数据如何在它们之间传输的标 准。协议采用了4层的层级结构,每一层都呼叫它的下一层所提供的协议来完成自己的需求。 ISO制定的OSI参考模型的过于庞大、复杂招致了许多批评。伴随着互联网的流行,其本身所采用的 TCP/IP协议栈获得了更为广泛的应用和认可。在TCP/IP参考模型中,去掉了OSI参考模型中的会话 层和表示层(这两层的功能被合并到应用层实现)。同时将OSI参考模型中的数据链路层和物理层 合并为主机到网络层。
7、 TCP 协议和 UDP 协议的比较
TCP 和 UDP 是 TCP/IP 协议栈中传输层的两个核心协议,它们基于 IP 层的路由功能将数据包发送到目 的主机,为应用层协议(如 HTTP、SMTP、SNMP、FTP、Telnet 等)提供端到端的传输服务。 TCP 的服务器与客户端之间通信,就好比两个人打电话,需要先建立连接、确认对方在线,才能开始对 话,因此通信时需要指定双方的 IP 地址和端口号。 UDP 的服务器与客户端之间通信,则更像两个人互相发送信件。发送方只需要知道对方的地址,就可以 直接将信件发出,无需提前建立连接,也无法保证对方一定能收到。 TCP 面向虚电路,UDP 面向数据报,二者的核心区别,本质上就是虚电路服务与数据报服务的区别。 具体区别如下: TCP 是面向连接的传输协议;UDP 是无连接的传输协议。 TCP 提供流量控制、拥塞控制,并保证数据按序到达、不丢失、不重复;UDP 不提供这些机制。 TCP 在传输过程中,所有报文段沿着同一条路径传输;UDP 的每个数据报可独立选择路由。 TCP 是可靠传输协议,通过确认应答、超时重传、序列号校验等机制保证可靠性;UDP 是不可靠 传输协议,不保证数据一定送达。 UDP 头部开销小,无需建立连接和维护状态,因此传输速度更快、延迟更低。 TCP 适合传输数据量大、对可靠性要求高的场景;UDP 适合实时性要求高、可容忍少量丢包的场 景。
8、什么是Socket
所谓 Socket(通常译作 “套接字”),是对网络中 IP 地址和端口号的抽象描述,本质是一个用于网络通 信的句柄(编程接口),应用程序通过 Socket 可以向网络发起请求,或响应来自网络的请求。 我们开发的网络应用程序位于 TCP/IP 协议栈的应用层,而 TCP、UDP 属于传输层协议。应用层程序要 调用传输层的通信能力,需要通过 Socket 作为 “中间层” 实现解耦 ——Socket 就像是传输层为应用层预 留的 “通信接口”,应用程序只需通过这个接口发送或接收数据,无需关心数据在接口之下的传输细节 (比如如何封装、路由、重传等),这些都由传输层、网络层等底层协议自动完成。 从协议栈交互的角度来说,Socket 是应用层与传输层之间的编程桥梁:传输层在网络层(IP 层)的基础 上,为应用层进程提供 “进程到进程” 的逻辑通信通道,而应用层进程正是通过 Socket 接口,借助 TCP/UDP 协议与另一台主机上的指定进程完成数据交互。 通过 Socket 编程,开发者可以开发客户端和服务器端的网络应用程序,既支持本地局域网内的进程通 信,也能基于互联网(Internet)实现跨地域的全球通信。
七、反射
本部分面试题涉及Java反射技术、这是重点和难点内容。Java 反射技术是绝大多数主流开源框架实现 “动态性、灵活性” 的核心底层技术,比如Spring、MyBatis、JUnit等框架
1、什么是反射
什么是Java反射机制:
反射就是在程序运行时期,动态的获取类信息并操作该类成员(构造方法,成员变量,成员方法)的过 程,这种动态获取类的信息以及动态调用对象的方法的功能来自于Java 语言的反射。 Java的反射机制的实现要借助于4个类Class,Constructor,Field,Method; 其中Class代表的是类对象,Constructor-类的构造器对象,Field-类的属性对象,Method-类的方 法对象。通过这四个对象我们可以粗略的看到一个类的各个组成部分。
Java反射机制提供功能
在运行时判断任意一个对象所属的类。 在运行时构造任意一个类的对象。 在运行时判断任意一个类所具有的成员变量和方法。 在运行时调用任意一个对象的方法。
例子(example):
packagese.reflective;
importjava.lang.reflect.Constructor;
importjava.lang.reflect.Field;
importjava.lang.reflect.Method;
publicclassReflectiveDemo {
publicstaticvoidmain(String[] args) {
try {
//1.获取任意一个对象所属的类
Studentstudent=newStudent();
Class<?extendsStudent>aClass1=student.getClass();
System.out.println(aClass1);
//2.构造任意一个类对象
Studentstudent1=aClass1.newInstance();
System.out.println(student1);
//3.获取任意一个类中的构造方法
Class<?>aClass=Class.forName("se.reflective.Student");
for (Constructor<?>declaredConstructor :
aClass.getDeclaredConstructors()) {
Objectinstance=declaredConstructor.newInstance();
System.out.println(instance);
}
//4. 获取任意一个类中的方法
for (MethoddeclaredMethod : aClass.getDeclaredMethods()) {
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
2、反射的主要用途是什么?
反射的主要用途包括: 动态加载类:在运行时加载类,而不需要在编译时知道类的具体名称。 访问私有成员:反射可以访问私有字段和方法,这在某些情况下非常有用。 实现框架:很多框架(如 Spring、Hibernate)都广泛使用反射来实现动态功能。 测试:反射可以用于测试私有方法。
3、反射的优缺点是什么?
优点:
灵活性:反射允许在运行时动态地操作类和对象,提供了极大的灵活性。 动态性:可以在运行时加载和操作类,而不需要在编译时知道类的具体信息。
缺点:
性能开销:反射操作通常比直接操作慢,因为它需要进行大量的类型检查和安全验证。 安全性问题:反射可以破坏封装性,访问私有字段和方法,这可能会导致安全问题。 复杂性:反射代码通常比较复杂,难以维护。
4、如何获取一个类的Class对象?
java.lang.Class 是 Java 反射机制的核心入口类,它是对 Java 类 / 接口 / 枚举 / 基本数据类型等的 “元数据抽象”,每个加载到 JVM 中的类都对应且仅对应一个Class 对象(存储该类的所有元信息)
1). 类名.class(最安全、高效,编译期检查)
通过类的静态属性class 获取,适用于已知具体类的场景,编译期会校验类是否存在,无异常抛出。
System.out.println("Student类中的方法:"+
declaredMethod.getName());
}
//5. 获取任意一个类中的成员变量
for (FielddeclaredField : aClass.getDeclaredFields()) {
System.out.println("Student类中的成员变量"+
declaredField.getName());
}
//6.调用任意一个对象的方法
System.out.println(student1.getAge());
} catch (Exceptione) {
e.printStackTrace();
}
}
}
27
28
29
30
31
32
33
34
35
36
37
38
39
2). 对象.getClass ()(适用于已有对象实例的场景)
通过任意 Java 对象的getClass() 方法(继承自Object 类)获取,返回该对象实际运行时类型的 Class 对象(多态场景下可获取真实子类类型)。
3). Class.forName (String className)(动态加载,运行时指定类名)
通过类的全限定名(包名 + 类名)动态加载类并获取Class 对象,适用于类名不确定、需动态指定的场
景(如框架加载配置类),需处理ClassNotFoundException 受检异常。
4). 类加载器 ClassLoader.loadClass (String className)(仅加载,不初始化)
通过类加载器的loadClass() 方法加载类并获取Class 对象,核心区别是仅加载类字节码,不触发类的 初始化(静态代码块、静态变量不执行),适用于懒加载场景(如 Spring IOC)。
补充说明(易忽略点)
基本数据类型的包装类可通过TYPE 属性获取对应基本类型的Class 对象(如Integer.TYPE 等价
于int.class );
枚举、注解、接口的Class 对象生成方式与普通类一致(如Enum.class 、Override.class );
无论通过哪种方式获取,同一个类在 JVM 中只有一个Class 对象(类加载器相同的前提下)。
5、反射中,Class.forName和ClassLoader的区别
Class.forName和ClassLoader是Java反射中用于加载类的两种不同方式。 Class.forName是一个静态方法,通过提供类的完全限定名,在运行时加载类。此方法还会执行类的静 态初始化块。如果类名不存在或无法访问,将抛出ClassNotFoundException异常。 ClassLoader是一个抽象类,用于加载类的工具。每个Java类都有关联的ClassLoader对象,负责将类文 件加载到Java虚拟机中。ClassLoader可以动态加载类,从不同来源加载类文件,如本地文件系统、网络 等。
// 普通类
Class<User>userClass=User.class;
// 基本数据类型
Class<Integer>intClass=int.class;
// 数组类型
Class<String[]>strArrayClass=String[].class;
1
2
3
4
5
6
Useruser=newStudent(); // Student是User的子类
Class<?extendsUser>clazz=user.getClass(); // 返回Student.class,而非
User.class
1
2
// 加载com.example包下的User类,默认触发类初始化
Class<?>userClass=Class.forName("com.example.User");
// 重载方法:指定是否初始化、指定类加载器(不触发初始化)
Class<?>userClass2=Class.forName("com.example.User", false,
ClassLoader.getSystemClassLoader());
1
2
3
4
ClassLoaderclassLoader=ClassLoader.getSystemClassLoader();
Class<?>userClass=classLoader.loadClass("com.example.User"); // 不触发初始化
1
2
两者区别如下: Class.forName方法由java.lang.Class类调用,负责根据类名加载类,并执行静态初始化。 ClassLoader是抽象类,提供了更灵活的类加载机制,可以自定义类加载过程,从不同来源加载类 文件。 一般情况下,推荐使用ClassLoader来加载和使用类,因为它更灵活,并避免执行静态初始化的副作 用。Class.forName主要用于特定场景,如加载数据库驱动程序。
备注:Class.forName() = 加载类 + 初始化(执行 static 代码块);ClassLoader.loadClass() =
仅加载类,不初始化。
6、如何使用反射创建对象?
可以通过Class对象的newInstance()方法或Constructor类来创建对象:
7、如何使用反射访问私有字段?
可以通过Field类来访问私有字段:注意使用 elementDataField.setAccessible(true);打破封装
importjava.lang.reflect.Constructor;
/**
* @Auth:TianMing
* @Description:创建对象
*/
publicclassReflectionExample {
publicstaticvoidmain(String[] args) {
try {
// 获取类的 Class 对象
Class<?>clazz=Class.forName("java.util.ArrayList");
// 使用 newInstance() 创建对象
Objectobj1=clazz.newInstance();
System.out.println("使用 newInstance() 创建的对象: "+obj1);
// 使用 Constructor 创建对象
Constructor<?>constructor=clazz.getConstructor();
Objectobj2=constructor.newInstance();
System.out.println("使用 Constructor 创建的对象: "+obj2);
} catch (Exceptione) {
e.printStackTrace();
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
importjava.lang.reflect.Field;
/**
* @Auth:TianMing
* @Description:访问私有字段
*/
publicclassReflectionExample {
publicstaticvoidmain(String[] args) {
try {
1
2
3
4
5
6
7
8
8、如何使用反射调用方法?
可以通过Method类来调用方法:
// 获取类的 Class 对象
Class<?>clazz=Class.forName("java.util.ArrayList");
// 创建对象
Objectobj=clazz.newInstance();
// 获取私有字段(例如,ArrayList 的 elementData 字段)
FieldelementDataField=clazz.getDeclaredField("elementData");
// 设置可访问(打破封装)
elementDataField.setAccessible(true);
// 获取字段值
ObjectelementData=elementDataField.get(obj);
System.out.println("elementData 字段值: "+elementData);
// 设置字段值
elementDataField.set(obj, newObject[10]);
System.out.println("修改后的 elementData 字段值: "+
elementDataField.get(obj));
} catch (Exceptione) {
e.printStackTrace();
}
}
}
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
importjava.lang.reflect.Method;
/**
* @Auth:TianMing
* @Description:方法调用
*/
publicclassReflectionExample {
publicstaticvoidmain(String[] args) {
try {
// 获取类的 Class 对象
Class<?>clazz=Class.forName("java.util.ArrayList");
// 创建对象
Objectobj=clazz.newInstance();
// 获取 add 方法
MethodaddMethod=clazz.getMethod("add", Object.class);
// 调用 add 方法
booleanresult= (boolean) addMethod.invoke(obj, "Hello,
Reflection!");
System.out.println("add 方法返回值: "+result);
// 获取 size 方法
MethodsizeMethod=clazz.getMethod("size");
intsize= (int) sizeMethod.invoke(obj);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
9、反射和注解的关系是什么?
注解(Annotations)可以为反射提供元数据。通过反射,可以读取类、方法或字段上的注解信息。例 如:
10、反射的性能问题如何解决?
反射操作通常比直接操作慢,因为它需要进行大量的类型检查和安全验证。以下是一些优化建议: 减少反射的使用:尽量避免在性能敏感的代码中使用反射。 缓存反射数据:如果需要多次使用反射,可以缓存Class 、Method等对象。 使用动态代理:在某些情况下,动态代理可以替代反射,提高性能。
11、反射的安全性问题如何解决?
System.out.println("size 方法返回值: "+size);
} catch (Exceptione) {
e.printStackTrace();
}
}
}
25
26
27
28
29
30
importjava.lang.annotation.Annotation;
importjava.lang.reflect.Method;
/**
* @Auth:TianMing
* @Description:访问注解
*/
publicclassReflectionExample {
publicstaticvoidmain(String[] args) {
try {
// 获取类的 Class 对象
Class<?>clazz=Class.forName("com.example.MyClass");
// 获取所有方法
Method[] methods=clazz.getMethods();
// 检查每个方法上的注解
for (Methodmethod : methods) {
Annotation[] annotations=method.getAnnotations();
for (Annotationannotation : annotations) {
System.out.println("方法 "+method.getName() +" 上的注
解: "+annotation);
}
}
} catch (Exceptione) {
e.printStackTrace();
}
}
}
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
反射可以破坏封装性,访问私有字段和方法,这可能会导致安全问题。以下是一些解决方法: 1,限制反射的使用:尽量避免使用反射访问私有成员。 2,使用安全检查:在使用反射时,进行必要的安全检查。 3,使用访问控制:在某些情况下,可以使用AccessController来限制反射的访问权限。 实例1:通过接口暴露功能,而不是直接使用反射
实例2:在反射调用之前,检查调用者的权限。通过SecurityManager或AccessController来限制
反射的访问权限
/**
* @Auth:TianMing
- @Description:示例:通过接口暴露功能,而不是直接使用反射
- 依赖注入框架(如 Spring)来管理对象的创建和依赖关系,而不是直接使用反射。
*/
publicinterfaceService {
voidexecute();
}
publicclassServiceImplimplementsService {
@Override
publicvoidexecute() {
// 实现逻辑
}
}
// 使用接口调用,而不是反射
Serviceservice=newServiceImpl();
service.execute();
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
importjava.lang.reflect.AccessibleObject;
importjava.lang.reflect.Field;
importjava.security.AccessControlContext;
importjava.security.AccessController;
importjava.security.Permission;
importjava.security.Permissions;
importjava.security.Policy;
importjava.security.Principal;
importjava.security.ProtectionDomain;
/**
* @Auth:TianMing
- @Description:权限检查:在反射调用之前,检查调用者的权限
*/
publicclassSecureReflectionExample {
privatestaticfinalStringSECRET="superSecretValue";
publicstaticvoidmain(String[] args) {
try {
// 创建一个限制权限的 AccessControlContext
Permissionsperms=newPermissions();
perms.add(newRuntimePermission("accessDeclaredMembers"));
AccessControlContextacc=newAccessControlContext(new
ProtectionDomain[]{newProtectionDomain(null, perms)});
// 使用 AccessController 进行权限检查
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
实例3:通过SecurityManager或Policy文件限制反射的访问权限
建议
代码审查:定期审查代码,确保没有滥用反射。 使用安全的库:选择经过安全审计的库,避免使用不安全的反射工具。 最小化权限:确保应用程序运行在最小权限的环境中,避免授予不必要的权限。
AccessController.doPrivileged((java.security.PrivilegedExceptionAction<Obje
ct>) () -> {
Fieldfield=
SecureReflectionExample.class.getDeclaredField("SECRET");
AccessibleObject.setAccessible(newAccessibleObject[]
{field}, true);
System.out.println("Secret value: "+field.get(null));
returnnull;
}, acc);
} catch (Exceptione) {
e.printStackTrace();
}
}
}
25
26
27
28
29
30
31
32
33
34
35
importjava.lang.reflect.Field;
importjava.security.Permission;
/**
* @Auth:TianMing
* @Description:通过 SecurityManager 或 Policy 文件限制反射的访问权限
*/
publicclassSecurityManagerExample {
privatestaticfinalStringSECRET="superSecretValue";
publicstaticvoidmain(String[] args) {
// 启用 SecurityManager
System.setSecurityManager(newSecurityManager() {
@Override
publicvoidcheckPermission(Permissionperm) {
if (perm.getName().equals("accessDeclaredMembers")) {
thrownewSecurityException("Access denied");
}
}
});
try {
//通过权限检查,禁止未授权的代码访问私有字段或方法
Fieldfield=
SecurityManagerExample.class.getDeclaredField("SECRET");
field.setAccessible(true);
System.out.println("Secret value: "+field.get(null));
} catch (Exceptione) {
System.out.println("Security exception: "+e.getMessage());
}
}
}
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
12、能否举例说明反射在Spring框架中的应用?
1). 依赖注入(Dependency Injection, DI)
Spring 的依赖注入功能依赖于反射来动态地创建对象并设置属性值。
示例:
假设我们有一个UserService类,它依赖于UserRepository :
在 Spring 中,通过反射可以动态地创建UserService的实例,并将UserRepository注入到
UserService中:
2). 控制反转(Inversion of Control, IoC)
Spring 的 IoC 容器使用反射来管理 Bean 的生命周期,包括创建、初始化和销毁。
publicclassUserService {
privateUserRepositoryuserRepository;
publicUserService(UserRepositoryuserRepository) {
this.userRepository=userRepository;
}
// 其他方法
}
1
2
3
4
5
6
7
8
9
// 假设这是 Spring 容器的一部分
publicclassSpringContainer {
public<T>TcreateBean(Class<T>clazz) throwsException {
// 使用反射创建对象
Constructor<?>constructor=
clazz.getDeclaredConstructor(UserRepository.class);
Tbean= (T) constructor.newInstance(newUserRepository());
returnbean;
}
publicstaticvoidmain(String[] args) {
SpringContainercontainer=newSpringContainer();
try {
UserServiceuserService=
container.createBean(UserService.class);
// 使用 userService
} catch (Exceptione) {
e.printStackTrace();
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
示例:
在 Spring 中,@Bean注解的配置类会被解析,通过反射调用方法来创建 Bean。
3). AOP(面向切面编程)
Spring 的 AOP 功能也依赖于反射来动态地代理方法,实现方法的拦截和增强。
示例:
在 Spring 中,AOP 使用反射来获取方法的签名,并在方法执行前后插入逻辑。
4). 动态代理
Spring 使用反射来创建动态代理,从而实现 AOP 和事务管理。
示例:
在 Spring 中,事务管理器会使用反射来代理方法,从而在方法执行前后添加事务逻辑。
@Configuration
publicclassAppConfig {
@Bean
publicUserServiceuserService() {
returnnewUserService(userRepository());
}
@Bean
publicUserRepositoryuserRepository() {
returnnewUserRepository();
}
}
1
2
3
4
5
6
7
8
9
10
11
12
@Aspect
@Component
publicclassLoggingAspect {
@Before("execution(* com.example.service.*.*(..))")
publicvoidlogBefore(JoinPointjoinPoint) {
System.out.println("Before method: "+
joinPoint.getSignature().getName());
}
}
1
2
3
4
5
6
7
8
publicclassProxyFactory {
publicstaticObjectcreateProxy(Objecttarget, InvocationHandler
handler) {
returnProxy.newProxyInstance(
target.getClass().getClassLoader(),
target.getClass().getInterfaces(),
handler
);
}
}
1
2
3
4
5
6
7
8
9
5). 反射在 Spring 中的具体应用
5.1 通过反射创建对象
Spring 使用反射来创建 Bean:
5.2 通过反射设置属性
Spring 使用反射来设置 Bean 的属性:
5.3 通过反射调用方法
Spring 使用反射来调用初始化和销毁方法:
6). 反射的性能优化
Spring 在使用反射时会进行一些优化,以减少性能开销: 缓存反射数据:Spring 会缓存Class 、Method和Field对象,避免重复获取。 使用 CGLIB 或 Javassist:在某些情况下,Spring 会使用字节码生成库(如 CGLIB 或 Javassist) 来代替反射,提高性能。
publicclassBeanFactory {
public<T>TcreateBean(Class<T>clazz) throwsException {
Constructor<?>constructor=clazz.getDeclaredConstructor();
return (T) constructor.newInstance();
}
}
1
2
3
4
5
6
publicclassBeanFactory {
public<T>voidsetProperty(Tbean, StringpropertyName, Objectvalue)
throwsException {
Fieldfield=bean.getClass().getDeclaredField(propertyName);
field.setAccessible(true);
field.set(bean, value);
}
}
1
2
3
4
5
6
7
publicclassBeanFactory {
public<T>voidinvokeInitMethod(Tbean, StringmethodName) throws
Exception {
Methodmethod=bean.getClass().getDeclaredMethod(methodName);
method.setAccessible(true);
method.invoke(bean);
}
}
1
2
3
4
5
6
7
八、设计模式
本部分面试题涉及设计模式相关内容,涉及到多个设计模式,特别是重点说明了单例模式、代理模式的 多种实现方式
1、什么是设计模式?有什么好处?
设计模式是一种被反复使用、经过验证的解决特定问题的软件设计思想。它是对软件设计中常见问题的 一种抽象和总结,提供了一套经验丰富的解决方案。设计模式不是具体的实现代码,而是关于类和对象 之间交互和组织的一种结构化的描述。 1994 年,Erich Gamma、Richard Helm、Ralph Johnson、John Vlissides(合称GoF / Gang of Four,四人组)合著出版《设计模式:可复用面向对象软件的基础》(Design Patterns: Elements of Reusable Object-Oriented Software),正式提出并系统化23 种设计模式。 设计模式的好处如下:
- 提高代码的重用性:设计模式通过提供通用的解决方案,可以使得代码更加可复用。它们通过抽象 和封装常见的问题和解决方法,使得开发人员可以在不同的项目中重用已经验证的设计思想和实 践。
- 提高代码的可维护性:设计模式通过将代码分离成不同的组件和层次,使得代码更模块化和可维 护。它们提供了清晰的结构和约定,使得代码更易于理解、调试和修改。
- 提高软件的可扩展性:设计模式通过解耦、松散耦合和抽象等方式,使得软件系统更容易进行扩展 和修改。它们使得系统的各个部分之间的依赖关系更加灵活,可以方便地添加新的功能和改变现有 的实现。
- 提高团队协作效率:设计模式提供了一种共享的设计语言和思想,使得团队成员之间更容易理解和 交流。它们可以作为团队共享的设计原则和规范,促进团队成员之间的合作和协作。
- 提高软件系统的可靠性:设计模式通过经过验证和优化的解决方案,减少了软件系统中的常见错误 和问题。它们提供了一种结构化的方法和实践,可以帮助开发避免一些潜在的设计和实现问题。 综上所,设计模式是一种被广泛应用的软件设计思想,通过提供通用的解决方案,提高了代码的重用 性、可维护性、可扩展性和团队协作效率,同时也提高了软件系统的可靠性。
2、设计模式的7大基本原则有哪些?
设计模式的7大基本原则是指软件设计中的基本原则和准则,它们可以指导我们在使用设计模式时做出正 确的设计决策。这些原则包括:
- 单一职责原则(Single Responsibility Principle,SRP):一个类只负责一项职责。一个类应该只 有一个引起它变化的原因。
- 开放封闭原则(Open-Closed Principle,OCP):软件实体(类、模块、函数等)应该对扩展开 放,对修改关闭。通过抽象和多态来实现对软件实体的扩展,而不是通过修改已有的代码。
- 里氏替换原则(Liskov Substitution Principle,LSP):子类应该能够替换掉父类并且工作正常, 子类必须能够完全替代父类的行为。
- 依赖倒置原则(Dependency Inversion Principle,DIP):高层模块不应该依赖于低层模块,它 们应该依赖于抽象。抽象不应该依赖于具体实现,具体实现应该依赖于抽象。
- 接口隔离原则(Interface Segregation Principle,ISP):客户端不应该依赖它不需要的接口。类 之间的依赖关系应该建立在最小的接口上。
- 迪米特法则(Law of Demeter,LoD):一个对象应该对其他对象有尽可能少的了解。只与其直接 的朋友进行通信,不需要了解朋友的朋友。(朋友:对象本身、依赖的对象、)
- 合成复用原则(Composite Reuse Principle,CRP):尽量使用对象组合和聚合,而不是继承来达 到复用的目的。通过组合已有的对象来实现新的功能。 这些原则旨在指导我们进行良好的软件设计,提高软件的可读性、可维护性、可扩展性和可测试性。同 时,它们也是设计模式的基础,设计模式的目的就是通过遵循这些原则来实现灵活、可复用和可扩展的 软件系统。
3、设计模式是如何分类的
根据应用目标,设计模式可以分为创建型、结构型和行为型。 创建型模式是关于对象创建过程的总结,包括单例、工厂方法、抽象工厂、建造者和原型模式。 结构型模式是针对软件设计结构的总结,包括桥接、适配器、装饰者、代理、组合、外观和享元模 式。 行为型模式是从类或对象之间交互、职责划分等角度总结的模式,包括策略、解释器、命令、观察 者、迭代器、中介者、责任链、备忘录、模板方法和访问者模式。 这些模式各自解决特定问题,并在软件开发中得到广泛应用。比如单例模式确保一个类只有一个实例, 适配器模式将一个类的接口转换为客户端所期望的另一个接口。装饰者模式动态地给对象添加额外的职 责,命令模式将请求封装成一个对象,从而使得可以用不同的请求对客户进行参数化。观察者模式定义 了对象之间的一对多依赖关系,当一个对象改变状态时,其依赖者会收到通知并自动更新。 这些设计模式各自具有明确的应用场景和优缺点,在软件开发中的应用可以提高代码的可维护性和复用 性,同时也可以减少出错的可能性并提高软件开发效率。 备注:简单工厂模式是工厂方法模式的简化版,代码更简单、教学友好,所以教材、面试、教程都爱先 讲它,业界约定俗成叫它 “简单工厂”,但不属于 23 种官方设计模式
4、使用哪种设计模式可以提高代码可维护性?
有多种设计模式可以提高代码的可维护性,以下是其中几种常见的设计模式:
- 单一职责原则(Single Responsibility Principle,SRP):将一个类或模块的功能限制在一个单一 的责任范围内,避免功能的混杂和耦合,使得代码更容易理解和修改。
- 开放封闭原则(Open-Closed Principle,OCP):通过抽象和多态来实现对软件实体的扩展,而 不是通过修改已有的代码。这样可以避免修改已有代码带来的风险和影响,提高代码的可维护性。
- 依赖倒置原则(Dependency Inversion Principle,DIP):高层模块不应该依赖于低层模块,它 们应该依赖于抽象。通过依赖注入等技术,将依赖关系从高层模块中抽离出来,使得代码更加灵 活、可扩展和可维护。
- 接口隔离原则(Interface Segregation Principle,ISP):客户端不应该依赖它不需要的接口。通 过定义细粒度的接口,可以避免接口的臃肿和不必要的依赖关系,提高代码的可维护性。
- 组合模式(Composite Pattern):通过将对象组合成树状结构,使得客户端可以一致地处理单个 对象和组合对象。这样可以减少代码的重复和冗余,提高代码的可维护性。
- 策略模式(Strategy Pattern):将不同的算法封装为独立的策略对象,并通过组合和委托来实现 运行时的动态算法选择。这样可以降低代码的复杂性,使得算法的变化和扩展更加容易。
- 观察者模式(Observer Pattern):通过定义一种一对多的依赖关系,使得当一个对象状态发生变 化时,所有依赖于它的对象都会得到通知和更新。这样可以减少对象之间的显式耦合,提高代码的 可维护性。 以上只是举例了几种常见的设计模式,实际上,许多设计模式都有助于提高代码的可维护性。选择合适 的设计模式取决于具体的情况和需求,需要综合考虑项目的特点、团队的技术水平和开发成本等因素。
5、使用哪种设计模式可以提高代码的复用性?
设计模式中的许多模式都可以提高代码的复用性,以下是一些常用的设计模式,它们有助于代码的复 用:
- 工厂模式(Factory Pattern):通过工厂类创建对象,将对象的创建与使用分离,提供了一种灵活 的对象创建方式,方便代码复用和扩展。
- 单例模式(Singleton Pattern):确保一个类只有一个实例,并提供全局访问点,可以在系统中复 用同一个实例。
- 适配器模式(Adapter Pattern):将一个类的接口转换成客户端所期望的另一个接口,使得原本 不兼容的类可以一起工作,提高代码的复用性。
- 装饰器模式(Decorator Pattern):动态地给一个对象添加额外的功能,通过装饰器类包装原始 对象,可以在不修改原始对象的情况下进行功能扩展,提高代码的复用性。
- 观察者模式(Observer Pattern):定义了一种一对多的依赖关系,当一个对象的状态发生变化 时,所有依赖它的对象都会得到通知,可以实现松耦合的对象之间的消息传递,提高代码的复用 性。
- 策略模式(Strategy Pattern):定义一系列算法,将每个算法封装起来,并使它们可以互换,使 得算法可以独立于客户端变化,提高代码的复用性。
- 模板方法模式(Template Method Pattern):定义一个算法的骨架,将一些步骤延迟到子类中实 现,可以复用算法的结构,而具体的实现可以在子类中灵活变化。 这只是一小部分常用的设计模式,每个设计模式都有不同的应用场景和优势。选择适合的设计模式可以 提高代码的复用性、可维护性和扩展性。在实际开发中,根据具体需求和情况选择合适的设计模式是很 重要的。
6、说说你对设计模式的理解
设计模式是一套经过验证的、被广泛应用于软件开发中的解决特定问题的重复利用的方案集合。它们是 在软件开发领域诸多经验的基础上总结出来的,是具有普适性、可重用性和可扩展性的解决方案。 设计模式通过抽象、封装、继承、多态等特性帮助我们设计出高质量、易扩展、易重构的代码,遵循面 向对象的设计原则,如单一职责、开闭原则、依赖倒置、里氏替换等,从而提高代码的可维护性、可测 试性和可读性。 设计模式的优点在于它们已经被广泛验证,可以避免一些常见的软件开发问题,同时也提供了一种标准 化的方案来解决这些问题。使用设计模式可以提高代码的复用性,减少代码的重复编写,增加代码的灵 活性和可扩展性。设计模式还能降低项目的风险,提高系统的稳定性。 不过,设计模式不是万能的,对于简单的问题,可能会使代码变得过于复杂,甚至导致反效果。 在使用设计模式时,需要根据具体的问题需求和实际情况来选择合适的模式,避免滥用模式,并保持代 码的简洁、清晰和可读性。
7、你在工作中是如何使用设计模式的?
在实际软件开发中,使用设计模式可以提高代码的可读性、可维护性和可扩展性,降低耦合度,增加代 码的复用性。以下是一些常见的使用场景和设计模式示例:
- 在面向对象的编程中,可以使用工厂模式来创建对象,将对象的创建与使用分离,提高代码的灵活 性和可复用性。
- 当需要适配不兼容的接口时,可以使用适配器模式进行接口转换,使得原本不兼容的类可以一起工 作。
- 如果需要在不修改原始对象的情况下动态地给对象添加额外的功能,可以使用装饰器模式。
- 当需要实现一对多的依赖关系,实现事件驱动的消息传递时,可以使用观察者模式。
- 在涉及多种算法选择和切换的情况下,可以使用策略模式来封装和切换不同的算法实现。
- 当需要定义一个算法的骨架,而将一些具体步骤延迟到子类中实现时,可以使用模板方法模式。 这些只是一些示例,实际使用设计模式的情况会根据具体的需求和应用场景而有所不同。在软件开发 中,根据具体问题的复杂性和需求的变化,选择合适的设计模式可以提高代码的质量和可维护性。
8、抽象工厂和工厂方法模式的区别
抽象工厂模式和工厂方法模式是两种创建型设计模式,都关注对象的创建,但有一些区别。 抽象工厂模式提供一个接口,用于创建一系列相关或相互依赖的对象,而无需指定具体的类。它适 用于需要一次性创建多个相关对象,以形成一个产品族。抽象工厂模式通常由抽象工厂、具体工 厂、抽象产品和具体产品组成。通过切换具体工厂实现类,可以改变整个产品族。 工厂方法模式将对象的创建延迟到子类中进行。它定义一个用于创建对象的抽象方法,由子类决定 具体实例化哪个类。工厂方法模式适用于需要根据不同条件动态地创建不同类型的对象。它通常由 抽象工厂、具体工厂、抽象产品和具体产品组成。通过切换具体工厂子类,可以改变单个产品。 总的来说,抽象工厂模式更关注一系列相关对象的创建,用于创建产品族;工厂方法模式更关注单个对 象的创建,用于根据不同条件创建不同类型的对象。
9、单例模式的多种写法
单例模式是一种创建型设计模式,它确保一个类只有一个实例,并提供全局访问点。以下是几种常见的 单例模式的实现方式:
- 懒汉式,线程不安全:
- 懒汉式,线程安全:
- 饿汉式:
publicclassSingleton {
privatestaticSingletoninstance;
privateSingleton() {}
publicstaticSingletongetInstance() {
if (instance==null) {
instance=newSingleton();
}
returninstance;
}
}
1
2
3
4
5
6
7
8
9
10
11
12
publicclassSingleton {
privatestaticSingletoninstance;
privateSingleton() {}
publicstaticsynchronizedSingletongetInstance() {
if (instance==null) {
instance=newSingleton();
}
returninstance;
}
}
1
2
3
4
5
6
7
8
9
10
11
12
publicclassSingleton {
privatefinalstaticSingletoninstance=newSingleton();
privateSingleton() {}
publicstaticSingletongetInstance() {
returninstance;
}
}
1
2
3
4
5
6
7
8
9
- 双重检查锁定:
- 静态内部类: 以上是几种常见的单例模式的实现方式。每种方式都有其优缺点,选择合适的实现方式取决于具体的需 求和场景。需要考虑线程安全性、延迟加载、性能等方面的因素。
10、如何破坏单例模式?
破坏单例模式的方法主要有以下几种:
- 反射破坏:通过反射机制,可以访问私有构造函数并创建多个实例。可以在构造函数中添加判断来 防止反射从而破坏单例模式。
- 序列化与反序列化破坏:当一个单例类被序列化后,再次反序列化会创建一个新的实例。可以通过 在单例类中添加readResolve() 方法,返回已有的实例,从而避免破坏。
- 多线程环境下的破坏:在多线程环境下,如果没有合适的同步机制,可能会创建多个实例。可以使 用双重检查锁定+volatile或者静态内部类等线程安全的方式来防止破坏。
- 克隆破坏:如果单例类实现了Cloneable 接口并重写了clone() 方法,可以通过克隆对象创建多
个实例。可以在clone() 方法中抛出CloneNotSupportedException 异常,阻止克隆破坏。
破坏单例模式需要在特殊情况下有意识地使用上述方法,一般情况下,单例模式是为了保证全局唯一性 和访问性,不建议破坏。
publicclassSingleton {
privatevolatilestaticSingletoninstance;
privateSingleton() {}
publicstaticSingletongetInstance() {
if (instance==null) {
synchronized (Singleton.class) {
if (instance==null) {
instance=newSingleton();
}
}
}
returninstance;
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
publicclassSingleton {
privateSingleton() {}
privatestaticclassSingletonHolder {
privatestaticfinalSingletoninstance=newSingleton();
}
publicstaticSingletongetInstance() {
returnSingletonHolder.instance;
}
}
1
2
3
4
5
6
7
8
9
10
11
11、为什么说枚举是实现单例最好的方式?
枚举是实现单例模式最好的方式之一,原因如下:
- 线程安全性:枚举实例的创建是由JVM在加载枚举类的时候保证的,因此在多线程环境下也能保证 单例的唯一性。枚举的实例创建是线程安全的,无需担心并发访问导致的多实例问题。
- 反射安全性:枚举类的实例是在枚举类加载的时候被创建的,而且枚举类不支持通过反射来创建实 例。即使使用反射强制访问枚举类的私有构造函数,也无法创建新的实例,因为枚举类的构造函数 默认是私有的。
- 序列化安全性:枚举类默认实现了java.io.Serializable 接口,因此可以安全地进行序列化和反 序列化。枚举实例在序列化和反序列化过程中保持单例的状态,不会创建新的实例。
- 简洁性和可读性:使用枚举实现单例模式非常简洁,只需定义一个枚举类型,枚举值即为单例实 例。同时,使用枚举实现的单例模式也更易于理解和维护。 综上所述,由于枚举具有线程安全、反射安全、序列化安全以及简洁可读的特性,因此被认为是实现单 例模式最好的方式之一。
12、不使用锁如何实现线程安全的单例?
除了使用如双重检查锁定)来实现线程安全的单例模式,还有其他几种方式可以实现线程安全的单例, 而不需要使用锁。以下是其中两种常见的方式:
- 饿汉式: 在饿汉式中,单例实例在类加载时就被创建,因此不存在并发访问的问题。由于实例的创建是在静态初 始化阶段完成的,所以可以保证线程安全性。
- 静态内部类: 在静态内部类中,单例实例的创建是在调用getInstance()方法时进行的,利用了类加载的特性实现 了懒加载。由于静态内部类只有在被使用时才会被加载,所以也能保证线程安全性。 这些方式都不需要显式地使用锁,因此可避免锁带来的性能开销。不过需注意的是,这些方式在某些特 殊情况下(如序列化、反射等)可能仍然存在安全问题,需要进行适当的处理来防止破坏单例模式。
publicclassSingleton {
privatestaticSingletoninstance=newSingleton();
privateSingleton() {}
publicstaticSingletongetInstance() {
returninstance;
}
}
1
2
3
4
5
6
7
publicclassSingleton {
privateSingleton() {}
privatestaticclassSingletonHolder {
privatestaticfinalSingletoninstance=newSingleton();
}
publicstaticSingletongetInstance() {
returnSingletonHolder.instance;
}
}
1
2
3
4
5
6
7
8
9
13、什么是享元模式,有哪些具体应用?
享元模式(Flyweight Pattern)是一种结构型设计模式,其目的是通过共享对象来减少内存使用和提高 性能。享元模式通过将对象的状态划分为内部状态(Intrinsic State)和外部状态(Extrinsic State), 来实现对象的共享。 具体用场景和示例:
- 文字编辑器:在文字编辑器中,每个字符都是一个对象,但是字符的属性(如字体、颜色等)可以 作为内部状态进行共享,而文字的位置等属性可以作为外部状态进行传递。
- 线程池:在线程池中,每个线程对象可以看作是一个享元对象,线程的内部状态(如线程ID、状态 标识等)是可以共享的,而外部状态(如任务)是不同的,通过外部传入。
- 资源池:在数据库连接池等资源管理中,可以使用享元模式来共享和复用资源对象,提高资源的利 用率。
- 游戏中的角色:在游戏中,角色的外观、动作等可以作为内部状态进行共享,而角色的位置、血量 等信息可以作为外部状态。 总的来说,享元模式适用于存在大量细粒度对象的场景,通过共享内部状态来减少对象的开销,提高系 统的性能和资源利用率。
14、JDK动态代理与CGLIB实现的区别
JDK动态代理和CGLIB是Java中常用的两种代理技术,它们在实现原理和使用方式上有一些区别。 JDK动态代理是基于接口的代理技术,要求目标类必须实现一个或多个接口。它使用 java.lang.reflect.Proxy类和java.lang.reflect.InvocationHandler接口来生成代理类和处理代理 方法的调用。在运行时,JDK动态代理会动态生成一个代理类,该代理类实现了目标接口,并在方 法调用前后插入额外的代码(即代理逻辑)。然而,JDK动态代理只能代理接口,无法代理普通的
类。
CGLIB是基于继承的代理技术,可以代理普通的类,不需要目标类实现接口。它使用字节码生成 库,在运行时通过生成目标类的子类来实现代理。CGLIB通过继承目标类创建一个子类,并重写目 标方法,以在方法调用前后插入额外的代码(即代理逻辑)。但是,由于继承关系,CGLIB无法代 理被标记为final的方法。 总的来说,JDK动态代理适用于基于接口的代理需求,而CGLIB适用于代理普通类的需求。选择使用哪种 代理方式取决于具体的需求。如果目标类已经实现了接口且需要基于接口进行代理,可以选择JDK动态 代理。而如果目标类没有实现接口,或者需要代理普通类的方法,可以选择CGLIB。
15、代理设计模式
代理模式就是给一个对象提供一个代理,并由代理对象控制对原对象的引用。它使得客户不能直接与真 正的目标对象通信。代理对象是目标对象的代表,其他需要与这个目标对象打交道的操作都是和这个代 理对象在交涉。
生活案例:
买房找中介,中介帮助完成找房源、沟通协调、办手续等操作 买二手车找中介,中介负责找车源、做质量检测、过户等,我只要付钱即可 购买火车票不一定要去火车站买,可以通过 12306 网站或者去火车票代售点买 一张支票或银行存单是账户中资金的代理。支票在市场交易中用来代替现金,并提供对签发人账号 上资金的控制。 代理模式可以分为静态代理、JDK动态代理、CGLIB动态代理三种方式。
16、静态代理设计模式
核心逻辑:为目标对象(被代理类)创建一个代理类,代理类和目标类实现同一个接口,代理类封装目 标类的方法,在调用目标方法前后可以添加额外逻辑(如日志、权限校验)。 “静态” 的含义:代理类是编码阶段手动写死的,不是运行时动态生成的 静态代理模式是代理模式的基础形态,是学习代理模式的「入门必经之路」,也是面试高频考点。根据 其缺点可以引出动态代理模式
实现步骤
- 定义共同接口(规范行为)
- 实现目标类(被代理类)
- 实现静态代理类
- 测试使用 // 第一步:定义共同接口(规范行为)
publicinterfaceSubject {
/**
* 接口方法
*/
publicvoidrequest();
}
// 第二步:实现目标类(被代理类)
publicclassRealSubjectimplementsSubject {
@Override
publicvoidrequest() {
System.out.println("具体的业务逻辑实现,比如掏钱买房子");
}
}
// 第三步:实现静态代理类
publicclassProxyimplementsSubject {
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
优点: 不修改目标类代码,实现功能扩展(符合「开闭原则」); 逻辑清晰,代码易读、易调试。 缺点: 代理类需要和目标类一一对应,若有 10 个目标类,就要写 10 个代理类,代码冗余; 接口方法变动时,所有代理类和目标类都要同步修改,维护成本高。
17、JDK动态代理设计模式
核心逻辑:无需手动编写代理类,而是在程序运行时通过 JDK 提供的java.lang.reflect.Proxy 类动 态生成代理类的字节码,实现对目标对象的代理。 关键前提:目标类必须实现至少一个接口(这是 JDK 动态代理的核心限制)。 核心优势:解决静态代理「一个目标类对应一个代理类」的代码冗余问题,一套代理逻辑可适配多个目 标类。
privateSubjectsubject;//要代理的实现类
publicProxy(Subjectsubject) {
this.subject=subject;
}
/**
* 实现接口方法
*/
@Override
publicvoidrequest() {
this.before();
this.subject.request();
this.after();
}
/**
* 预处理
*/
privatevoidbefore() {
System.out.println("预处理操作,比如查找房源并看房");
}
/**
* 后处理
*/
privatevoidafter() {
System.out.println("后处理操作,比如走手续办证件");
}
}
//第四步:测试使用
publicclassTest {
publicstaticvoidmain(String[] args) {
Subjectsubject=newRealSubject();
//subject.request();
Proxyproxy=newProxy(subject);
proxy.request();
}
}
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
核心 API 说明
JDK 动态代理依赖 2 个核心类 / 接口(都在java.lang.reflect 包下):
1. Proxy :核心类,通过newProxyInstance() 方法动态生成代理对象;
- InvocationHandler :接口,自定义代理逻辑(如前置 / 后置处理),需实现invoke() 方法。
实现步骤
- 定义共同接口(规范行为)
- 实现目标类(被代理类)
- 自定义 InvocationHandler(核心代理逻辑)
- 创建动态代理对象并测试
publicclassAuthLogHandlerimplementsInvocationHandler {
Objecttarget; //被代理的对象
publicAuthLogHandler(Objecttarget) {
this.target=target;
}
@Override
publicObjectinvoke(Objectproxy, Methodmethod, Object[] args) throws
Throwable {
before();
Objectresult= method.invoke(target,args);
after();
returnresult;
}
/**
* 预处理
*/
privatevoidbefore() {
System.out.println("预处理操作:权限验证");
}
/**
* 后处理
*/
privatevoidafter() {
System.out.println("后处理操作:记录日志");
}
publicObjectgetProxy(){
returnProxy.newProxyInstance(target.getClass()
.getClassLoader(),target.getClass().getInterfaces(),this);
}
}
publicinterfaceSubject {
publicvoidrequest();
}
publicclassRealSubjectimplementsSubject {
publicvoidrequest() {
System.out.println("具体的业务逻辑实现");
}
}
publicclassTest {
publicstaticvoidmain(String[] args) {
Subjectsubject=newRealSubject();
//subject.request();
//形式1:代理对象由自定义 InvocationHandler中方法提供
AuthLogHandlerhandler=newAuthLogHandler(subject);
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
使用场景
JDK 动态代理是 Spring AOP、MyBatis 等框架的核心底层技术: Spring AOP:默认使用 JDK 动态代理实现切面(如事务、日志、权限); MyBatis:Mapper 接口的实现类是 JDK 动态代理生成的,无需手动编写。 优点:可以做到在符合开闭原则的情况下对目标对象进行功能扩展;一个代理类可以代理多个接口,工 作量小;代理类是运行时动态生成的;可以先有代理,再有接口;接口一旦发生变量,代理类不要修改 缺点:只能针对接口,不能针对实现类。因为Proxy类已经是所有代理的父类,Java是单继承的,怎么 可以再继承一个类呢? 备注:AuthLogHandler不是动态代理类,只是动态代理逻辑的处理器。Proxy是动态代理类, Proxy.newProxyInstance()的返回值是动态代理类的对象。
18、CGLIB动态代理模式
CGLIB(Code Generator Library)是一个强大的、高性能的代码生成库。CGLib采用了非常底层的字节 码技术,其原理是通过字节码技术为一个类创建子类,并在子类中采用方法拦截的技术拦截所有父类方 法的调用,顺势织入横切逻辑。CGLIB底层使用了ASM(一个短小精悍的字节码操作框架)来操作字节 码生成新的类。 CGLIB相比于JDK动态代理更加强大,JDK动态代理虽然简单易用,但是其有一个致命缺陷是,只能对接 口进行代理。如果要代理的类为一个普通类、没有接口,那么Java动态代理就无法使用了。 其被广泛应用于AOP框架(Spring、dynaop)中,用以提供方法拦截操作。Hibernate、MyBatis作为 受欢迎的ORM框架,同样使用CGLIB来实现延迟加载关联。
核心 API 说明
CGLIB 的核心类(都在net.sf.cglib.proxy 包下):
- Enhancer :核心类,用于创建 CGLIB 代理对象;
2. MethodInterceptor :方法拦截器接口,需实现intercept() 方法(类似 JDK 的
InvocationHandler.invoke() ),封装代理逻辑;
- MethodProxy :方法代理对象,用于快速调用目标类的原始方法。
实现步骤
- 准备依赖:可以是jar包(cglib和asm的jar包)或者maven依赖,spring已经内置
- 创建无接口的目标类(核心:无接口)
- 自定义 MethodInterceptor(核心代理逻辑)
- 创建 CGLIB 代理对象并测试
Subject proxy= (Subject)handler.getProxy();
proxy.request();
//形式2:代理对象在测试类提供
Subjectproxy2= (Subject)Proxy.newProxyInstance(subject.getClass()
.getClassLoader(),subject.getClass().getInterfaces(),handler);
proxy2.request();
}
}
44
45
46
47
48
49
50
51
publicclassSubject {
publicvoidrequest(){
}
1
2
3
Enhancer可以说是CGLIB中最常用的一个类,和JDK动态代理中引入的Proxy类差不多。和Proxy不同的 是,Enhancer既能够代理普通的class,也能够代理接口。Enhancer创建一个被代理对象的子类并且拦 截所有的方法调用。 备注:因为采用的是继承,不能对final修饰的类和方法进行代理。
}
publicclassRealSubjectextendsSubject {
@Override
publicvoidrequest() {
System.out.println("具体的业务逻辑实现");
}
}
publicclassCglibInterceptorimplementsMethodInterceptor {
Objecttarget;//被代理的对象
publicCglibInterceptor(Objecttarget) {
this.target=target;
}
publicObjectgetProxy(){
Enhancerenhancer=newEnhancer();
enhancer.setSuperclass(this.target.getClass());
enhancer.setCallback(this);
returnenhancer.create();
}
@Override
publicObjectintercept(Objectproxy, Methodmethod, Object[] args,
MethodProxymethodProxy) throwsThrowable {
before();
Objectresult=method.invoke(target,args);
after();
returnresult;
}
/**
* 预处理
*/
privatevoidbefore() {
System.out.println("预处理操作:权限验证");
}
/**
* 后处理
*/
privatevoidafter() {
System.out.println("后处理操作:记录日志");
}
}
publicclassTest {
publicstaticvoidmain(String[] args) {
Subjectsubject=newRealSubject();
CglibInterceptorcglibProxy=newCglibInterceptor(subject);
Subjectproxy= (Subject)cglibProxy.getProxy();
proxy.request();
}
}
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
九、JVM
本部分面试题涉及JVM(Java 虚拟机)相关内容,主要涵盖JVM 组成、类加载机制、垃圾回收、JVM 性 能调优等核心内容。这部分内容覆盖面广、考察深度高,能直接体现候选人JVM 理论功底、底层原理理 解与实际调优经验,是 Java 面试中极具区分度的核心模块。
1、说一下JVM的主要组成部分?及其作用?
类加载器 (Class Loader) :将.class字节码文件加载到内存中。类加载器只管加载,不管运行。只要符 合文件结构就会加载。 执行引擎(Execution Engine) :也叫解释器,负责解释命令,其任务就是将字节码指令解释/编译为 对应平台上的本地机器指令。 本地接口(Native Interface):本地接口。本地接口的作用是融合不同的语言为java所用。
JVM运行时数据区
栈(Stack):也叫栈内存,是java程序的运行区,用于存储局部变量表、操作栈、动态链接(即引 用,比如方法区的成员变量)、方法出口等信息。它随着线程的创建而创建,随着线程结束而释 放,只要线程一结束,该栈就结束;对于栈来说不存在垃圾回收的问题(垃圾回收只针对于堆和方法 区)。栈中的数据以栈帧的形式存在,是一个数据集,是一个有关方法和运行期数据的集合,当方法 A被调用时就产生了一个栈帧F1,并被压入到栈中,A方法又调用了B方法,于是产生栈帧F2也被压 入栈,执行完毕后,先弹出F2栈帧,再弹出F1栈帧,遵循“先进后出”原则。 堆(Heap):存放的是实例对象。一个JVM实例只存在一个堆内存,堆内存的大小是可以调节 的。堆内存分三部分:永久区(即存储的是运行环境必须的类信息,被装载至此区域的数据是不会 被垃圾回收掉的,只有关闭jvm释放此区域所占用的内存)、新生区、老年代 方法区(Method Area):方法区只是JVM规范中定义的一个概念,它用于存储已被虚拟机加载 的类的信息(类的名称、方法信息、字段信息)、常量、静态变量、即时编译器编译后的代码缓存 等。 程序计数器(PC Register):每个线程都有一个程序计数器,就是一个指针,指向方法区中的方 法字节码(用来存储下一条将要执行的字节码指令的地址),由执行引擎读取下一条指令
2、说一下JVM运行时数据区?
不同虚拟机的运行时数据区可能略微有所不同,但都会遵从 Java 虚拟机规范, Java 虚拟机规范规定的 区域分为以下 5 个部分: 程序计数器(Program Counter Register):当前线程所执行的字节码的行号指示器,字节码解析 器的工作是通过改变这个计数器的值,来选取下一条需要执行的字节码指令,分支、循环、跳转、 异常处理、线程恢复等基础功能,都需要依赖这个计数器来完成; Java 虚拟机栈(Java Virtual Machine Stacks):用于存储局部变量表、操作数栈、动态链接、方 法出口等信息; 本地方法栈(Native Method Stack):与虚拟机栈的作用是一样的,只不过虚拟机栈是服务 Java 方法的,而本地方法栈是为虚拟机调用 Native 方法服务的; Java 堆(Java Heap):Java 虚拟机中内存最大的一块,是被所有线程共享的,存放对象实例 方法区(Methed Area):用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译后的代 码等数据。
3、堆空间划分?
堆内存逻辑上分为三部分: Young Generation Space 新生代/年轻代 Young/New Tenured generation space 老年代/养老代 Old/Tenured Permanent Space/Meta Space 永久代/元空间 Permanent/Meta JDK8后方法区的实现由永久代变成了元空间,元空间直接使用操作系统本地内存,不再受 JVM 堆 内存限制 新生代又划分为: 新生代又分为两部分:伊甸园区(Eden space)和幸存者区(Survivor pace)。 幸存者区有两个: 0区(Survivor 0 space)和1区(Survivor 1 space)。From区和To
JDK1.7及之前堆空间
JDK1.8及之后堆空间
4、什么是类加载器?
对于任意一个类,都需要由加载它的类加载器和这个类本身一同确立在 JVM 中的唯一性,每一个类加载 器,都有一个独立的类名称空间。类加载器就是根据指定全限定名称将 class 文件加载到 JVM 内存,然 后再转化为 class 对象。 类加载器分类: 启动类加载器(Bootstrap)C++ 负责加载$JAVA_HOME中jre/lib/下的某些jre包中的类【比如rt.jar】,该类加载器由C++实现,不 是ClassLoader子类。 扩展类加载器(Extension)Java 负责加载java平台中扩展功能的一些jar包,包括$JAVA_HOME中jre/lib/ext/*.jar或-Djava.ext.dirs 指定目录下的jar包 应用程序类加载器(AppClassLoader) 也叫系统类加载器,负责加载classpath中指定的jar包及目录中class。或-Djava.class.path目录下 的jar包或者.class文件。 用户自定义加载器 Java.lang.ClassLoader的子类,用户可以定制类的加载方式 备注:各种类加载器之间存在着逻辑上的父子关系,但不是真正意义上的父子关系,因为它们直接没有 从属关系。可以理解为工作上的上下级关系,而非生活中的父子关系
5、双亲委派模型
双亲委派机制是 Java 虚拟机加载一个类时为该类确定类加载器的一种机制。 简单的来说:如果一个类加载器收到了加载某个类的请求,则该类加载器并不会去加载该类,而是把这个请 求委派给父类加载器,每一个层次的类加载器都是如此,因此所有的类加载请求最终都会传送到顶端的启动 类加载器;只有当父类加载器在其搜索范围内无法找到所需的类,并将该结果反馈给子类加载器,子类加载 器会尝试去自己加载。 具体的来说:例如有一个类 com.atguigu.classloader.Math需要加载,在双亲委派机制下流程是怎么样 的呢? 首先应用类加载器会判断之前是否加载过这个类,如果加载过则返回,如果没有加载过,则会向 上委托给扩展类加载器; 扩展类加载器同样会去判断之前是否加载过这个类,如果加载过则返回,如果没有加载过,则继续 向上委托给引导类加载器; 引导类加载器则会去 jre/lib 目录下去查询是否有这个类(注意:不会在问是否加载过),如果有这 个类则加载,如果没有就向下回传给扩展类加载器加载; 扩展类加载器去 jre/lib/ext 目录下去查询是否有这个类,如果有这个类则加载,如果没有这个类就 向下回传给应用类加载器; 应用类加载器会去项目的类路径下(classPath)下去查询是否有这个类,如果有这个类则加载, 如果没有这个类就会抛出经典的ClassNotFoundException。 流程:
6、说一下类装载的执行过程?
类装载简单分为以下 5 个步骤: 加载:将类的字节码文件加载到内存(元空间)中。这一步会创建一个与被加载类对应的Class对象。 验证:验证类是否符合JVM规范,安全性检查,不会造成安全或内存损坏等问题 准备:为类变量分配内存并设置类变量默认值` 解析:虚拟机将常量池中的符号引用替换成直接引用的过程。符号引用就理解为一个标示,而在直接引 用直接指向内存中的地址; 初始化:对类的静态变量,静态代码块执行初始化操作
7、怎么判断对象是否可以被回收?
一般有两种方法来判断: 引用计数器:为每个对象创建一个引用计数,有对象引用时计数器 +1,引用被释放时计数 -1,当计数器 为 0 时就可以被回收。它有一个缺点不能解决循环引用的问题; 可达性分析:这个算法的基本思想就是通过一系列的称为“GC Roots”的对象作为起点,从这些节点开 始向下搜索,节点所走过的路径称为引用链,当一个对象到 GC Roots 没有任何引用链相连的话,则证 明此对象是不可用的。
publicclassReferenceCount {
private Objectinstance=null;
privatestaticfinalint_1MB=1024*1024;
privatebyte[] bigObject=newbyte[2*_1MB];
publicstaticvoidmain(String[] args) {
ReferenceCountobjA=newReferenceCount();
ReferenceCountobjB=newReferenceCount();
objA.instance=objB;
objB.instance=objA;
objA=null;
objB=null;
System.gc();
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
8、哪些对象可以作为GC-Roots
在Java语言中,可以作为GC Roots的对象包括下面几种:
a. 虚拟机栈中的引用对象
虚拟机栈中的引用对象是指在方法调用的过程中,方法的局部变量引用的对象。每个线程在执行方法 时,都会在虚拟机栈中创建一个栈帧,栈帧中的局部变量就是虚拟机栈中的引用对象。
b. 方法区中静态属性引用的对象
方法区中存放着类的相关信息,包括静态变量和常量池。当静态变量引用一个对象时,该对象就是一个 GC ROOT 对象。
c. 方法区中常量引用的对象
在常量池中的常量引用的对象也是 GC ROOT 对象,例如字符串常量池中的字符串对象。
d. 本地方法栈中 JNI(Java Native Interface)引用的对象
JNI 是 Java 调用本地方法的接口,本地方法栈中 JNI 引用的对象也是 GC ROOT 对象。
e. 虚拟机内部的引用对象
虚拟机内部维护了一些对象,这些对象也是 GC ROOT 对象,例如系统类加载器、线程、JNI 引用等。
小技巧:
由于Root 采用栈方式存放指针,所以如果一个指针,它保存了堆里面的对象,但是自己又不存放在堆里 面,那他就可以作为一个Root.
9、Java中都有哪些引用类型?
强引用:发生 gc 的时候不会被回收。 软引用:有用但不是必须的对象,在发生内存溢出之前会被回收。 弱引用:有用但不是必须的对象,在下一次GC时会被回收。 虚引用(幽灵引用/幻影引用):无法通过虚引用获得对象,用 PhantomReference 实现虚引用,虚引 用的用途是在 gc 时返回一个通知。(临死之前收到一个通知)
packagecom.atguigu.jvm;
importjava.lang.ref.PhantomReference;
importjava.lang.ref.ReferenceQueue;
importjava.lang.ref.SoftReference;
importjava.lang.ref.WeakReference;
publicclassWeakReferenceDemo {
publicstaticvoidmain(String[] args) {
softReference();// 软引用
weakReferenceTest();// 弱引用
phantomReference();// 虚引用
}
/**
* 软引用测试
- 会发现gc后,软引用对象的值获仍然能够获取到
*/
privatestaticvoidsoftReference() {
Stringstr=newString("hzk 666!!!");
SoftReference<String>stringSoftReference=newSoftReference<>
(str);
System.out.println("软引用的值"+stringSoftReference.get());//没有进行
gc前软引用能得到对象
str=null;
System.gc();
stringSoftReference.get();
System.out.println("软引用对象被垃圾回收了,软引用对象的值"+
stringSoftReference.get());
}
/**
* 弱引用测试
- 会发现gc后,弱引用对象的值获取不到
*/
privatestaticvoidweakReferenceTest() {
Stringstr=newString("hzk 666!!!");
WeakReference<String>stringWeakReference=newWeakReference<>
(str);
str=null;
System.out.println("软引用的值"+stringWeakReference.get());//没有进行
gc前软引用能得到对象
System.gc();//进行垃圾回收
stringWeakReference.get();
System.out.println("软引用对象被垃圾回收了,软引用对象的值"+
stringWeakReference.get());
}
/**
* 虚引用测试
- 会发现gc前,弱引用对象的值都获取不到
*/
privatestaticvoidphantomReference() {
StringhelloWorldString=newString("hzk 666!!!");
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
10、说一下JVM有哪些垃圾回收算法?
标记复制算法:该算法将内存平均分成两部分,然后每次只使用其中的一部分,当这部分内存满的时 候,会将内存中所有存活的对象复制到另一个内存中,然后将之前的内存清空,只使用这部分内存,循 环下去。
标记-清除算法:
1、使用可达性算法标记出需要回收的对象,且有根可达的就不会被标记清理。 2、回收被标记的对象 标记-整理算法:标记无用对象,让所有存活的对象都向一端移动,然后直接清除掉端边界以外的内存。
分代算法:根据对象存活周期的不同将内存划分为几块,一般是新生代和老年代,新生代基本采用复制
算法,老年代采用标记整理算法。
分代回收算法实际上是把复制算法和标记整理法的结合,并不是真正一个新的算法,一般分为:老年代 (Old Generation)和新生代(Young Generation),老年代就是很少垃圾需要进行回收的,新生代就 是有很多的内存空间需要回收,所以不同代就采用不同的回收算法,以此来达到高效的回收算法。 面试回答: 首先说明Stop-the-world:在进行GC时,需要将所有线程全部停止,这段时间称为stop-the-world, 所有算法都是针对stop-the-world优化展开的 标记清理:通过可达性分析,将标记为垃圾的对象进行清理。会造成碎片空间浪费
ReferenceQueuequeue=newReferenceQueue();
PhantomReferenceref=newPhantomReference(helloWorldString,
queue);
System.out.println(ref.get());
System.gc();//进行垃圾回收
System.out.println(ref.get());
}
}
49
50
51
52
53
54
55
56
57
标记压缩:优化标记清理的算法,通过可达性分析,将标记为非垃圾的对象同一移动到一段,随后清除 边界意外的内存。(避免产生不必要的内存碎片,但对象存活率低会造成重复操作,导致效率降低,一般 放在老年代的更新) 复制算法:将内存一分为二,通过可达性分析将存活对象全面复制给另一份空闲内存,随后清理原有内 存(避免产生内存碎片,但是内存减半代价大,对象存活率高则是无用操作) 分代收集:年轻代使用复制算法(对象存活率低),老年代使用标记压缩法(对象存活率高)
11、说一下JVM有哪些垃圾回收器?
垃圾回收器总体分为三大类:
串行:
Serial、Serial Old、 并行: ParNew、Parallel、Parallel Old
并发:
CMS、G1 Serial:最早的单线程串行垃圾回收器。 Serial Old:Serial 垃圾回收器的老年版本,同样也是单线程的。 ParNew:是 Serial 的多线程版本。 Parallel 和 ParNew 收集器类似是多线程的,但 Parallel 是吞吐量优先的收集器,可以牺牲等待时间换 取系统的吞吐量。Parallel Old 是 Parallel 老生代版本,Parallel 使用的是复制的内存回收算法, Parallel Old 使用的是标记-整理的内存回收算法。 CMS:一种以获得最短停顿时间为目标的收集器,非常适用 B/S 系统。 G1:一种兼顾吞吐量和停顿时间的 GC 实现。 面试说: 串行:Serial ,Serial old 并行:ParNew , Paraller ,CMS垃圾收集牺牲吞吐获得最短停顿时间(熟悉),G1 兼顾吞吐和停顿 JDK9后默认
12、详细介绍一下CMS垃圾回收器?
CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。它非常符
合在注重用户体验的应用上使用,它是HotSpot虚拟机第一款真正意义上的并发收集器,它第一次实现
了让垃圾收集线程与用户线程(基本上)同时工作。
从名字中的Mark Sweep这两个词可以看出,CMS收集器是一种“标记-清除”算法实现的,它的运作过 程相比于前面几种垃圾收集器来说更加复杂一些。整个过程分为四个步骤: 初始标记(CMS initial mark) 并发标记(CMS concurrent mark) 重新标记(CMS remark) 并发清理(CMS concurrent sweep) 其中初始标记、重新标记这两个步骤仍然需要“Stop The World”。初始标记仅仅只是枚举全部的GC Roots对象,速度很快,并发标记阶段就是进行GC Roots Tracing的过程【采用三色标记算法】, 这个过程耗时较长但是不需要停顿用户线程,可以与垃圾收集线程一起并发运行。因为用户程序继 续运行,可能会有导致已经标记过的对象状态发生改变。而重新标记阶段则是为了修正并发标记期 间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一 般会比初始标记阶段稍长一些,但远比并发标记的时间短。并发清理这个阶段、清理删除掉标记阶 段判断已经死亡的对象,由于不需要移动存活对象,因此这个阶段可以与用户线程同时发生。 初始标记:迄今为止在进行根节点枚举这一步骤都是需要暂停用户线程的,必须要保证在一个能够保证 一致性的快照中得以进行。这里的一致性指的是,不会出现在分析过程中,根节点集合的对象的引用关 系还在不断地变化。因为如果这点不能满足,那么分析结果就不能保证。 那么对于目前的Java应用来说,光是方法区的大小就有数百上千兆,里面的类或者常量更是恒河数沙, 若是检查这里为起源的引用就需要消耗很多的时间,所以虚拟机自当是有办法直接得到哪些地方存在着 对象的引用。在HotSpot虚拟机中,是使用的一组称为OopMap的数据结构来达存放这些引用。一旦类 加载完成的时候,虚拟机就会把对象的偏移量数据计算出来。并且在JIT即时编译中也会在特定的位置记 录下栈里的寄存器中存放哪些位置是引用。这样收集器在扫描的时候就可以得知这些信息了。并不需要 真正的一个不漏的从方法区等GCROOT开始查找。 从它的名字就可以看出它是一款优秀的垃圾收集器,主要优点:并发收集、低停顿。但是它有下面几个 明显的缺点: 对CPU资源敏感(会和服务抢资源,降低吞吐量);当然,这是所有并发收集器的缺点 无法处理浮动垃圾(在并发标记和并发清理阶段又产生垃圾,这种浮动垃圾在本次收集中无法干掉他 们,只能等到下一次gc再清理了,这一部分垃圾成为浮动垃圾); 它使用的回收算法-“标记-清除”算法会导致收集结束时会有大量空间碎片产生 同样由于垃圾收集阶段用户程序还需要持续运行,那就还需要预留足够的空间给用户线程使用,因此 CMS垃圾回收器不能像其他的收集器那样等待老年代几乎完全被填满在进行垃圾收集。如果CMS运行期 间预留的内存无法满足程序分配新对象的空间,就会出现并发失败。这时候虚拟机就启动默认的备预 案,冻结用户线程,临时启用·重新对老年代的垃圾收集。这样就导致停顿时间很长了,性能反而降低。 面试说: 牺牲吞吐量获得最短停顿时间的垃圾收集器,适用于对于响应速度较高的应用上,但使用标记清理算法 会造成内存碎片浪费。 主要步骤如下: 初始标记阶段:进行可达性分析,短暂停顿 并发标记阶段:cms与应用程序并发执行,标记所有可达对象,同时可能有新对象产生。 并发预清理阶段:并发执行,提前清理部分对象,以减少后续工作量。 最终标记阶段:短暂停顿,处理并发标记开始后修改的对象,并标记他们。 并发清理阶段:并发执行,清理并释放未被标记的无用对象所占据的内存。
13、垃圾回收器的比较
14、新生代垃圾回收器和老年代垃圾回收器都有哪些?有
什么区别?
新生代回收器:Serial、ParNew、Parallel Scavenge 老年代回收器:Serial Old、Parallel Old、CMS整堆回收器: G1新生代垃圾回收器一般采用的是复制算法,复制算法的优点是效率高,缺点是内存利用率低; 老年代回收器一般采用的是标记-整理的算法进行垃圾回收。
15、简述分代垃圾回收器是怎么工作的?
分代回收器有两个分区:老年代和新生代,新生代默认的空间占比总空间的 1/3,老年代的默认占比是 2/3。 新生代使用的是复制算法,新生代里有 3 个分区:Eden、To Survivor、From Survivor,它们的默认占 比是 8:1:1,它的执行流程如下: 把 Eden + From Survivor 存活的对象放入 To Survivor 区;清空 Eden 和 From Survivor 分区;From Survivor 和 To Survivor 分区交换,From Survivor 变 To Survivor,To Survivor 变 From Survivor。每 次在 From Survivor 到 To Survivor 移动时都存活的对象,年龄就 +1,当年龄到达 15(默认配置是 15)时,升级为老年代。大对象也会直接进入老年代。 老年代当空间占用到达某个值之后就会触发全局垃圾收回,一般使用标记整理的执行算法。以上这些循 环往复就构成了整个分代垃圾回收的整体执行流程。
16、三色标记
所有的垃圾回收算法都要经历标记阶段。如果GC线程在标记的时候暂停所有用户线程(STW),那就没 三色标记什么事了。但是这样会有一个问题,用户线程需要等到GC线程标记完才能运行,给用户的感觉 就是很卡,用户体验很差。 现在主流的垃圾收集器都支持并发标记。什么是并发标记呢?就是标记的时候不暂停或少暂停用户线 程,一起运行。这势必会带来三个问题:多标、漏标。垃圾收集器是如何解决这个问题的呢:三色标记 +读写屏障。 把遍历对象过程中遇到的对象,按照“是否访问过”这个条件标记成三种颜色: 白色:尚未访问过。 黑色:本对象已访问过,而且本对象引用到的其他对象也全部访问过了。 灰色:本对象已访问过,但是本对象引用到的其他对象尚未全部访问完。全部访问后,会转换为 黑色。
多标浮动垃圾:
GC线程正在标记B,此时用户代码中A断开了对B的引用,但此时B已经被标记成了灰色,本轮GC不会被 回收,这就是所谓的多标,多标的对象即成为浮动垃圾,躲过了本次GC。 多标对程序逻辑是没有影响的,唯一的影响是该回收的对象躲过了一次GC,造成了些许的内存浪费。 漏标程序会出错: 漏标是如何产生的呢?GC把B标记完,准备标记B引用的对象,这时用户线程执行代码,代码中断开了B 对D的引用,改为A对D的引用。但是A已经被标记成黑色,不会再次扫描A,而D还是白色,执行垃圾回 收逻辑的时候D会被回收,程序就会出错了。
如何解决漏标问题:
先分析下漏标问题是如何产生的: 条件一:灰色对象断开了白色对象的引用;即灰色对象原来成员变量的引用发生了变化。 条件二:黑色对象重新引用了该白色对象;即黑色对象成员变量增加了新的引用。 知道了问题所在就知道如何解决了。
写屏障 + 增量更新(IU)
这种方式解决的是条件二,即通过写后屏障记录下更新,具体做法如下: 对象A对D的引用关系建立时,将D加入带扫描的集合中等待扫描 当对象A的成员变量的引用发生变化时,比如新增引用(a.d = d),我们可以利用写屏障,将A新的成员 变量引用对象D 记录下来:
写屏障 + 原始快照(SATB)
这种方式解决的是条件一,带来的结果是依然能够标记到D,具体做法如下: 对象B的引用关系变动的时候,即给B对象中的某个属性赋值时,将之前的引用关系记录下来。 当对象B的成员变量的引用发生变化时,比如引用消失(a.b.d = null),我们可以利用写屏障,将B原来 成员变量的引用对象D记录下来: 重标记的时候,扫描旧的对象图,这个旧的对象图即原始快照。 #写后
voidpost_write_barrier(oop*field, oopnew_value) {
remark_set.add(new_value); // 记录新引用的对象
}
1
2
3
4
#写前
voidpre_write_barrier(oop*field) {
oopold_value=*field; // 获取旧值
remark_set.add(old_value); // 记录原来的引用对象
}
1
2
3
4
5
实际应用: CMS:写屏障 + 增量更新 G1:写屏障 + SATB
17、说一下JVM调优的工具?
JDK 自带了很多监控工具,都位于 JDK 的 bin 目录下,其中最常用的是 jconsole 和 jvisualvm 这两款视 图监控工具。 jconsole:用于对 JVM 中的内存、线程和类等进行监控; jvisualvm:JDK 自带的全能分析工具,可以分析:内存快照、线程快照、程序死锁、监控内存的变化、 gc 变化等。
18、常用的JVM调优的参数都有哪些?
常用的JVM调优参数有很多,以下是一些常见的参数:
- -Xms :设置JVM的初始堆大小。
- -Xmx :设置JVM的最大堆大小。
- -Xss :设置线程的栈大小。
- -XX:NewRatio :设置新生代和老年代的比例。
- -XX:SurvivorRatio :设置Eden区和Survivor区的比例。
- -XX:MaxPermSize (在JDK 8之前)或-XX:MaxMetaspaceSize (在JDK 8及以后):设置永久代 (或元空间)的最大大小。
7. -XX:ParallelGCThreads :设置并行垃圾收集器的线程数。
8. -XX:+UseConcMarkSweepGC :启用并发标记清除垃圾收集器。
- -XX:+UseG1GC :启用G1垃圾收集器。
- -XX:+UseSerialGC :启用串行垃圾收集器。 这只是一小部分常用的JVM调优参数,具体使用哪些参数需要根据应用程序的需求和环境进行调整。调 优参数的选择和调整需要根据具体情况进行实验和评估。
19、你能保证GC执行吗?
不能,虽然你可以调用 System.gc() 或者 Runtime.gc(),但是没有办法保证 GC 的执行。 GC(垃圾收集)是由JVM自动管理的过程,它负责回收不再使用的内存资源。JVM根据一定的策略和算 法来触发和执行GC操作。 虽然无法保证GC的执行,但是你可以通过调整JVM的参数和配置来影响GC的行为和性能。例如,可以调 整堆大小、选择合适的垃圾收集器、调整GC线程数等来优化GC的性能和效果。 请注意,在进行GC调优时,需要仔细评估和测试不同的配置和参数,以确保在特定的应用程序和环境中 获得最佳的性能和内存管理。
20、怎么获取Java程序使用的内存?堆使用的百分比?
可以通过 java.lang.Runtime 类中与内存相关方法来获取剩余的内存,总内存及最大堆内存。 通过这些方法你也可以获取到堆使用的百分比及堆内存的剩余空间。 Runtime.freeMemory() 方法返回 剩余空间的字节数,Runtime.totalMemory() 方法总内存的字节数,Runtime.maxMemory() 返回最大 内存的字节数。
21、工作中最常见的6种OOM问题
1)、堆内存OOM
堆内存OOM是最常见的OOM了。 出现堆内存OOM问题的异常信息如下: 此OOM是由于JVM中heap的最大值,已经不能满足需求了。 举个例子: 这里创建了一个list集合,在一个死循环中不停往里面添加对象。 执行结果:
java.lang.OutOfMemoryError: Javaheapspace
1
@Test
publicvoidtest01() {
List<OOMTests>list=Lists.newArrayList();
while (true) {
list.add(newOOMTests());
}
}
1
2
3
4
5
6
7
出现了java.lang.OutOfMemoryError: Java heap space的堆内存溢出。 很多时候,excel一次导出大量的数据,获取在程序中一次性查询的数据太多,都可能会出现这种OOM 问题。 我们在日常工作中一定要避免这种情况。
2)、栈内存OOM
有时候,我们的业务系统创建了太多的线程,可能会导致栈内存OOM。 出现堆内存OOM问题的异常信息如下: 给大家举个例子: 使用一个死循环不停创建线程,导致系统产生了大量的线程。 如果实际工作中,出现这个问题,一般是由于创建的线程太多,或者设置的单个线程占用内存空间太大 导致的。 建议在日常工作中,多用线程池,少自己创建线程,防止出现这个OOM。
3)、栈内存溢出
我们在业务代码中可能会经常写一些递归调用,如果递归的深度超过了JVM允许的最大深度,可能会出 现栈内存溢出问题。 出现栈内存溢出问题的异常信息如下: 例如:
java.lang.OutOfMemoryError: unabletocreatenewnativethread
1
publicclassStackOOMTest {
publicstaticvoidmain(String[] args) {
while (true) {
newThread().start();
}
}
}
1
2
3
4
5
6
7
java.lang.StackOverflowError
1
执行结果: 出现了java.lang.StackOverflowError栈溢出的错误。 我们在写递归代码时,一定要考虑递归深度。即使是使用parentId一层层往上找的逻辑,也最好加 一个参数控制递归深度。防止因为数据问题导致无限递归的情况,比如:id和parentId的值相等。
4)、直接内存OOM
直接内存不是虚拟机运行时数据区的一部分,也不是《Java虚拟机规范》中定义的内存区域。 它来源于NIO ,通过存在堆中的DirectByteBuffer 操作Native内存,是属于堆外内存,可以直接向系 统申请的内存空间。 出现直接内存OOM问题时异常信息如下: 例如下面这样的:
@Test
publicvoidtest03() {
recursiveMethod();
}
publicstaticvoidrecursiveMethod() {
// 递归调用自身
recursiveMethod();
}
1
2
3
4
5
6
7
8
9
java.lang.OutOfMemoryError: Directbuffermemory
1
privatestaticfinalintBUFFER=1024*1024*20;
@Test
publicvoidtest04() {
ArrayList<ByteBuffer>list=newArrayList<>();
intcount=0;
try {
while (true) {
ByteBufferbyteBuffer=ByteBuffer.allocateDirect(BUFFER);
list.add(byteBuffer);
count++;
try {
Thread.sleep(100);
} catch (InterruptedExceptione) {
e.printStackTrace();
}
}
} finally {
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
会看到报出来java.lang.OutOfMemoryError: Direct buffer memory直接内存空间不足的异常。
5)、GC OOM
GC OOM 是由于JVM在GC时,对象过多,导致内存溢出,建议调整GC的策略。 出现GC OOM问题时异常信息如下: 为了方便测试,我先将idea中的最大和最小堆大小都设置成10M: 例如下面这个例子: 出现这个问题是由于JVM在GC的时候,对象太多,就会报这个错误。 我们需要改变GC的策略。 在老代80%时就是开始GC,并且将-XX:SurvivorRatio(-XX:SurvivorRatio=8)和-XX:NewRatio(- XX:NewRatio=4)设置的更合理。
6)、元空间OOM
JDK8 之后使用Metaspace 来代替永久代,Metaspace是方法区在HotSpot 中的实现。 Metaspace不在虚拟机内存中,而是使用本地内存也就是在JDK8中的ClassMetadata ,被存储在叫做 Metaspace的native memory。 出现元空间OOM问题时异常信息如下: 为了方便测试,我修改一下idea中的JVM参数,增加下面的配置: 指定了元空间和最大元空间都是10M。
System.out.println(count);
}
}
19
20
21
java.lang.OutOfMemoryError: GCoverheadlimitexceeded
1
publicclassGCOverheadOOM {
publicstaticvoidmain(String[] args) {
ExecutorServiceexecutor=Executors.newFixedThreadPool(5);
for (inti=0; i<Integer.MAX_VALUE; i++) {
executor.execute(() -> {
try {
Thread.sleep(10000);
} catch (InterruptedExceptione) {
}
});
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
java.lang.OutOfMemoryError: Metaspace
1
-XX:MetaspaceSize=10m-XX:MaxMetaspaceSize=10m
1
接下来,看看下面这个例子: 程序最后会报java.lang.OutOfMemoryError: Metaspace的元空间OOM。 这个问题一般是由于加载到内存中的类太多,或者类的体积太大导致的。
22、如何判断一个对象是否可以被回收
1).对象存活判断
在堆里存放着几乎所有的Java对象实例,在GC执行垃圾回收之前,首先需要区分出内存中哪些是存活对 象,哪些是已经死亡的对象。 只有被标记为己经死亡的对象,GC才会在执行垃圾回收时,释放掉其所占用的内存空间,因此这个过程 我们可以称为垃圾标记阶段 那么在JVM中究竟是如何标记一个死亡对象呢?简单来说,当一个对象已经不再被任何的存活对象继续 引用时,就可以宣判为已经死亡 判断对象存活一般有两种方式:引用计数算法和可达性分析算法。
2).引用计数法
引用计数算法(ReferenceCounting)比较简单,对每个对象保存一个整型的引用计数器属性。用 于记录对象被引用的情况 对于一个对象A,只要有任何一个对象引用了A,则A的引用计数器就加1;当引用失效时,引用计 数器就减1。只要对象A的引用计数器的值为0,即表示对象A不可能再被使用,可进行回收 优点:
publicclassMetaspaceOOMTest {
staticclassOOM {
}
publicstaticvoidmain(String[] args) {
inti=0;
try {
while (true) {
i++;
Enhancerenhancer=newEnhancer();
enhancer.setSuperclass(OOM.class);
enhancer.setUseCache(false);
enhancer.setCallback(newMethodInterceptor() {
@Override
publicObjectintercept(Objecto, Methodmethod,
Object[] objects, MethodProxymethodProxy) throwsThrowable {
returnmethodProxy.invokeSuper(o, args);
}
});
enhancer.create();
}
} catch (Throwablee) {
e.printStackTrace();
}
}
}
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
实现简单,垃圾对象便于辨识:判定效率高,回收没有延退性 缺点:
- 它需要单独的字段存储计数器,这样的做法增加了存储空间的开销
- 每次赋值都需要更新计数器,伴随着加法和减法操作,这增加了时间开销
- 引用计数器有一个严重的问题,即无法处理循环引用的情况。这是一条致命缺陷,导致在Java的垃 圾回收器中没有使用这类算法。
3).可达性分析算法
相对于引用计数算法而言,可达性分析算法不仅同样具备实现简单和执行高效等特点,更重要的是该算 法可以有效地解决在引用计数算法中循环引用的问题,防止内存泄漏的发生,这里的可达性分析就是 Java、c#选择的。这种类型的垃圾收集通常也叫作追踪性垃圾收集 基本思路
- 可达性分析算法是以根对象集合(GCRoots就是一组必须活跃的引用)为起始点,按照从上至下的 方式搜索被根对象集合所连接的目标对象是否可达
- 使用可达性分析算法后,内存中的存活对象都会被根对象集合直接或间接连接着,搜索所走过的路 径称为引用链
- 如果目标对象没有任何引用链相连,则是不可达的,就意味着该对象己经死亡,可以标记为垃圾对 象
- 在可达性分析算法中,只有能够被根对象集合直接或者问接连接的对象才是存活对象
4).GC Roots
在Java 语言中,GC Roots 包括以下几类元素:
虚拟机栈中引用的对象,比如:各个线程被调用方法中使用到的参数、局部变量等
本地方法栈内JNI(通常说的本地方法)引用的对象
方法区中类静态属性引用的对象,比如:Java类的引用类型静态变量
方法区中常量引用的对象,比如:字符串常量池(StringTable)里的引用
所有被同步锁synchronized持有的对象 Java虚拟机内部的引用,基本数据类型对应的class对象,一些常驻的异常对象(如: NullPointerException、OutofMemoryError),系统类加载器 反映java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等 除了这些固定的GCRoots集合以外,根据用户所选用的垃圾收集器以及当前回收的内存区域不同, 还可以有其他对象“临时性”地加入,共同构成完整GC Roots集合。比如:分代收集和局部回收
(PartialGC)
如果只针对了java堆中的某一块区域进行垃圾回收(比如:典型的只针对新生代),必须考虑 到内存区域是虚拟机自己的实现细节,更不是孤立封闭的,这个区域的对象完全有可能被其他 区域的对象所引用,这时候就需要一并将关联的区域对象也加入GCRoots集合中去考虑,才 能保证可达性分析的准确性。 小技巧: 由于Root采用栈方式存放变量和指针,所以如果一个指针,它保存了堆内存里面的对象,但是自己又不 存放在堆内存里面,那它就是一个Root
注意
- 如果要使用可达性分析算法来判断内存是否可回收,那么分析工作必须在一个能保障一致性的快照 中进行。这点不满足的话分析结果的准确性就无法保证
- 这点也是导致GC进行时必须Stop The World的一个重要原因,即使是号称(几乎)不会发生停顿 的CMS收集器中,枚举根节点时也是必须要停顿的。
十、JUC
本部分面试题聚焦 JUC(java.util.concurrent),核心覆盖 JMM 内存模型、Lock 锁体系、Executor 线 程池、并发工具类、ThreadLocal 等高频考点。JUC 是 Java 面试中权重极高、区分度极强的核心模块, 也是中高级工程师必须熟练掌握的关键内容。其中并发集合相关内容已在集合章节单独整理。
1、并发的三大特征
原子性:
原子性是指在一个操作中cpu不可以在中途暂停然后再调度,即不被中断操作,要么全部执行完成,要 么都不执行。就好比转账,从账户A向账户B转1000元,那么必然包括2个操作:从账户A减去1000元, 往账户B加上1000元。2个操作必须全部完成。 1:将 count 从主存读到工作内存中的副本中 2:+1的运算 3:将结果写入工作内存 4:将工作内存的值刷回主存(什么时候刷入由操作系统决定,不确定的) 那程序中原子性指的是最小的操作单元,比如自增操作,它本身其实并不是原子性操作,分了3步的,包 括读取变量的原始值、进行加1操作、写入工作内存。所以在多线程中,有可能一个线程还没自增完,可 能才执行到第二部,另一个线程就已经读取了值,导致结果错误。那如果我们能保证自增操作是一个原 子性的操作,那么就能保证其他线程读取到的一定是自增后的数据。
关键字:synchronized
可见性
当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。若 两个线程在不同的cpu,那么线程1改变了i的值还没刷新到主存,线程2又使用了i,那么这个i值肯定还是 之前的,线程1对变量的修改线程2没看到这就是可见性问题。 如果线程2改变了stop的值,线程1一定会停止吗?不一定。当线程2更改了stop变量的值之后,但是还 没来得及写入主存当中,线程2转去做其他事情了,那么线程1由于不知道线程2对stop变量的更改,因 此还会一直循环下去。 关键字:volatile、synchronized、final
有序性
privatelongcount=0;
publicvoidcalc() {
count++;
}
1
2
3
4
//线程1
booleanstop=false;
while(!stop){
doSomething();
}
//线程2
stop=true;
1
2
3
4
5
6
7
虚拟机在进行代码编译时,对于那些改变顺序之后不会对最终结果造成影响的代码,虚拟机不一定会按 照我们写的代码的顺序来执行,有可能将他们重排序。实际上,对于有些代码进行重排序之后,虽然对 变量的值没有造成影响,但有可能会出现线程安全问题。 write方法里的1和2做了重排序,线程1先对flag赋值为true,随后执行到线程2,ret直接计算出结果,再 到线程1,这时候a才赋值为2,很明显迟了一步。
关键字:volatile、synchronized
volatile本身就包含了禁止指令重排序的语义,而synchronized关键字是由“一个变量在同一时刻只允许 一条线程对其进行lock操作”这条规则明确的。
synchronized关键字同时满足以上三种特性,但是volatile关键字不满足原子性。
在某些情况下,volatile的同步机制的性能确实要优于锁(使用synchronized关键字或 java.util.concurrent包里面的锁),因为volatile的总开销要比锁低。 我们判断使用volatile还是加锁的唯一依据就是volatile的语义能否满足使用的场景(原子性)
2、JMM内存模型
Java内存模型(Java Memory Model简称JMM)是一种抽象的概念,并不真实存在,它描述的是一组规则 或规范,通过这组规范定义了程序中各个变量(包括实例字段,静态字段)的访问方式。JVM运行程序 的实体是线程,而每个线程创建时JVM都会为其创建一个工作内存(有些地方称为栈空间),用于存储线程 私有的数据,而Java内存模型中规定所有变量都存储在主内存,主内存是共享内存区域,所有线程都可 以访问,但线程对变量的操作(读取赋值等)必须在工作内存中进行,首先要将变量从主内存拷贝的自己 的工作内存中,然后对变量进行操作,操作完成后再将变量写回主内存,不能直接操作主内存中的变 量,工作内存中存储着主内存中的变量副本拷贝,前面说过,工作内存是每个线程的私有数据区域,因 此不同的线程间无法访问对方的工作内存,线程间的通信(传值)必须通过主内存来完成。 JMM规定了内存主要划分为主内存和工作内存两种。 主内存:主要存储的是Java实例对象,所有线程创建的实例对象都存放在主内存中,当然也包括了共享 的类信息、常量、静态变量。由于是共享数据区域,多条线程对同一个变量进行访问可能会发生线程安 全问题。 共享变量:如果一个变量被多个线程使用,那么这个变量会在每个线程的工作内存中保有一个副本,这 种变量就是共享变量。 工作内存:主要存储当前方法的所有本地变量信息(工作内存中存储着主内存中的变量副本拷贝),每个 线程只能访问自己的工作内存,即线程中的本地变量对其它线程是不可见的,就算是两个线程执行的是 同一段代码,它们也会各自在自己的工作内存中创建属于当前线程的本地变量,当然也包括了字节码行 号指示器、相关Native方法的信息。注意由于工作内存是每个线程的私有数据,线程间无法相互访问工 作内存,因此存储在工作内存的数据不存在线程安全问题。
inta=0;
boolflag=false;
publicvoidwrite() {
a=2; //1
flag=true; //2
}
publicvoidmultiply() {
if (flag) { //3
intret=a*a;//4
}
}
1
2
3
4
5
6
7
8
9
10
11
模型入下图:(基于JMM规范)
3、JMM存在的必要性
由于JVM运行程序的实体是线程,而每个线程创建时JVM都会为其创建一个工作内存(有些地方称为栈空 间),用于存储线程私有的数据,线程与主内存中的变量操作须通过工作内存间接完成,主要过程是将变 量从主内存拷贝的每个线程各自的工作内存空间,然后对变量进行操作,操作完成后再将变量写回主内 存,那么如存在两个线程同时对一个主内存中的实例对象的变量进行操作就有可能诱发线程安全问题。 假设主内存中存在一个共享变量x(初始值为1),现在有A和B两条线程分别对该变量x=1进行操作, A/B线程各自的工作内存中存在共享变量副本x。假设现在A线程想要修改x的值为2,而B线程却想要读取 x的值,那么B线程读取到的值是A线程更新后的值2还是更新前的值1呢?答案是,不确定。 即B线程有可能读取到A线程更新前的值1,也有可能读取到A线程更新后的值2,这是因为工作内存是每 个线程私有的数据区域,而线程A操作变量x时(即将x=1拷贝到自己的工作内存中),首先是将变量从 主内存拷贝到A线程的工作内存中,然后对变量进行操作,操作完成后再将变量x写回主内存,而对于B 线程的也是类似的,这样就有可能造成主内存与工作内存间数据存在一致性问题,假如A线程修改完后 正在将数据写回主内存,而B线程此时正在读取主内存,这样B线程读取到的值就是x=1,但如果A线程 已将x=2写回主内存后,B线程才开始读取的话,那么此时B线程读取到的就是x=2。 注意:
**JMM**不同于**JVM内存区域模型**
JMM与JVM内存区域的划分是不同的概念层次,更恰当说JMM描述的是一组规则, 通过这组规则控制程序中各个变量在共享数据区域和私有数据区域的访问方式,JMM是围绕原子性,有序 性、可见性展开。 JMM与Java内存区域唯一相似点,都存在共享数据区域和私有数据区域,在JMM中主内存属于共享数据区 域, 从某个程度上讲应该包括了堆和方法区,而工作内存数据线程私有数据区域,从某个程度上讲则应该包括 程序计数器、虚拟机栈以及本地方法栈。
1
2
3
4
5
6
7
JMM对共享内存的操作做出了如下两条规定:
- 线程对共享内存的所有操作都必须在自己的工作内存中进行,不能直接从主内存中写;
- 不同线程无法直接访问其他线程工作内存中的变量,因此共享变量的值传递需要通过主内存完成。
1
2
3
4、谈谈Volatile关键字的理解
1)、验证可见性
2)、验证有序性
publicclassVolatileDemo {
staticintflag=1;
//static volatile Integer int flag = 1;
publicstaticvoidmain(String[] args) {
newThread(newRunnable() {
@Override
publicvoidrun() {
System.out.println("我是子线程A工作内存flag的值:"+flag);
while (flag==1) {
}
System.out.println("子线程结束:"+flag);
}
}, "A").start();
try {
Thread.sleep(1000);//让线程完全启动
} catch (InterruptedExceptione) {
e.printStackTrace();
}
flag=2;
System.out.println("我是主线程工作内存flag的值:"+flag);
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
publicclassVolatileOrderDemo {
staticintx, y;
staticinta, b;
//static int x, y;
//static volatile int a, b;
publicstaticvoidmain(String[] args) throwsInterruptedException {
inti=0;
while (true) {
i++;
a=b=x=y=0;
Threadthread1=newThread(() -> {
a=1;
x=b;
});
Threadthread2=newThread(() -> {
b=1;
y=a;
});
thread1.start();
thread2.start();
Thread.sleep(10);
thread1.join();
thread2.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
3)、不具备原子性
5、Atomic原子类了解多少?原理是什么?
Java中的java.util.concurrent.atomic包提供了一组原子类,用于在多线程环境中执行原子操作,而无需 使用显式的锁。这些原子类使用特殊的CPU指令来确保操作的原子性,从而避免了使用锁带来的性能开 销。 这些原子类的实现依赖于底层硬件架构提供的原子操作指令。通常,这些指令在现代处理器上是硬件级 别的支持,确保对内存的读写是原子的。这使得在不使用锁的情况下,可以在多线程环境中执行某些操 作,而不会导致竞态条件(race conditions)。 以下是一些常见的java.util.concurrent.atomic包中的原子类以及它们的一些实现原理: AtomicInteger, AtomicLong, AtomicReference: 这些类使用compareAndSet(CAS)操作实现原子性。CAS是一种乐观锁定机制,它尝试原子地将 一个值更新为新值,但只有在当前值等于预期值时才成功。否则,它会重新尝试。 CAS操作是由处理器提供的原子性操作指令支持的。 AtomicBoolean: AtomicBoolean类使用compareAndSet实现。 compareAndSet的实现通常依赖于底层处理器的CAS指令。 AtomicIntegerArray, AtomicLongArray, AtomicReferenceArray: 这些类提供了对数组元素的原子性访问。 它们也使用CAS操作,但应用于数组的特定位置。
System.out.println("第"+i+"次打印:x="+x+",y="+y);
if (x==0&&y==0) {
break;
}
}
}
}
26
27
28
29
30
31
32
33
classMyAtomic {
privateIntegernumber=0;
publicintincr() {
returnnumber++;
}
}
publicclassVolatileAtomicDemo {
publicstaticvoidmain(String[] args) {
MyAtomicmyAtomic=newMyAtomic();
for (inti=0; i<1000 ; i++) {
newThread(()->{
System.out.println(myAtomic.incr());
}).start();
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
AtomicIntegerFieldUpdater, AtomicLongFieldUpdater, AtomicReferenceFieldUpdater: 这些类提供了对对象字段的原子性更新。 它们使用了反射和CAS操作来实现。 总体来说,原子类的实现依赖于底层硬件提供的原子性操作指令,这通常是现代处理器架构的一部分。 这使得原子类能够在无锁的情况下执行一些基本的原子操作,提高了多线程环境中的性能。在高并发的 情况下,原子类是一种有用的工具,能够提供线程安全的操作,而无需显式地使用锁。
6、synchronized底层实现是什么?lock底层是什么?有
什么区别?
1) Synchronized原理:
方法级的同步是隐式,即无需通过字节码指令来控制的,它实现在方法调用和返回操作之中。JVM可以 从方法常量池中的方法表结构(method_info Structure) 中的 ACC_SYNCHRONIZED 访问标志区分一个 方法是否同步方法。当方法调用时,调用指令将会检查方法的 ACC_SYNCHRONIZED 访问标志是否被 设置,如果设置了,执行线程将先持有monitor(虚拟机规范中用的是管程一词),然后再执行方法, 最后再方法完成(无论是正常完成还是非正常完成)时释放monitor。 代码块的同步是利用monitorenter和monitorexit这两个字节码指令。分别位于同步代码块的开始和结 束位置。当jvm执行到monitorenter指令时,当前线程试图获取monitor对象所有权,如果未加锁或者 已被当前线程所持有,就把锁的计数器+1;当执行monitorexit指令时,锁计数器-1;当锁计数器为0 时,该锁就被释放了。如果获取monitor对象失败,该线程则会进入阻塞状态,直到其他线程释放锁。
2) Lock原理:
· Lock的存储结构:一个int类型状态值(用于锁的状态变更),一个双向链表(用于存储等待中的线 程) · Lock获取锁的过程:本质上是通过CAS来获取状态值修改,如果当场没获取到,会将该线程放在线程等 待链表中。 · Lock释放锁的过程:修改状态值,调整等待链表。 · Lock大量使用CAS+自旋。因此根据CAS特性,lock建议使用在低锁冲突的情况下。
3) Lock与synchronized的区别
- Lock的加锁和解锁都是由java代码配合native方法(调用操作系统的相关方法)实现的,而 synchronized的加锁和解锁的过程是由JVM管理的
- 当一个线程使用synchronized获取锁时,若锁被其他线程占用着,那么当前只能被阻塞,直到成功 获取锁。而Lock则提供超时锁和可中断等更加灵活的方式,在未能获取锁的条件下提供一种退出 的机制。
- 一个锁内部可以有多个Condition实例,即有多路条件队列,而synchronized只有一路条件队列; 同样Condition也提供灵活的阻塞方式,在未获得通知之前可以通过中断线程以及设置等待时限等 方式退出条件队列。
- synchronized对线程的同步仅提供独占模式,而Lock即可以提供独占模式,也可以提供共享模式
synchronized
Lock
关键字 接口/类 自动加锁和释放锁 需要手动调用unlock() 方法释放锁 JVM层面的锁 API层面的锁 非公平锁 可以选择公平或者非公平锁 锁是一个对象,并且锁的信息保存在了对象中 代码中通过int类型的state标识 有一个锁升级的过程 无
7、synchronized锁优化
synchronized还有一种重要的优化方式,即锁的优化技术。在Java 6及以上版本中,JVM引入了偏向 锁、轻量级锁和重量级锁的概念来提高锁的性能。这些优化方式的原理如下: 偏向锁:偏向锁是指当一个线程获取到锁之后,会在对象头中记录下该线程的标识,下次再进入同 步块时,无需进行额外的加锁操作,从而提高性能。 轻量级锁:当多个线程对同一个锁进行争夺时,JVM会使用轻量级锁来避免传统的重量级锁带来的 性能消耗。它采用自旋的方式,即不放弃CPU的执行时间,尝试快速获取锁,避免线程阻塞和上下 文切换的开销。 重量级锁:当多个线程对同一个锁进行强烈争夺时,JVM会升级为重量级锁,此时线程会进入阻塞 状态,等待锁的释放。这种方式适用于竞争激烈的情况,但会带来较大的性能开销。 锁优化技术是为了提高synchronized的并发性能,根据锁的竞争程度和持有时间的长短选择相应的锁状 态,使得多个线程能够更高效地共享资源。 如何区分:不同类型锁,通过synchronized(obj)中的锁对象obj的markword中的锁标志位来区分 备注:64位的markword启用了压缩选项后会压缩为32位:
查看对象头数据
添加依赖
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.10</version>
</dependency>
1
2
3
4
5
测试并运行
如何升级
备注:锁只能升级,不能降级;以上的操作仅限于>=JDK6 的synchronized,和Lock锁无关。
8、如何实现线程的同步
线程的同步是为了保证多个线程按照特定的顺序、协调地访问共享资源,避免数据不一致和竞争条件等 问题。 在Java中,常见的线程同步方式有以下几种:
- 使用synchronized关键字:通过在方法或代码块前加上synchronized关键字,确保同一时间只有 一个线程可以执行标记为同步的代码。这样可以避免多个线程同时访问共享资源造成的数据不一致 问题。
- 使用ReentrantLock类:它是一个可重入锁,通过调用lock()和unlock()方法获取和释放锁。与 synchronized不同,ReentrantLock提供了更灵活的同步控制,例如可实现公平性和试锁等待时 间。
- 使用wait()、notify()和notifyAll()方法:这些方法是Object类的方法,允许线程间进行协作和通 信。通过调用wait()方法使线程进入等待状态,然后其他线程可以通过notify()或notifyAll()方法唤醒 等待的线程。
- 使用CountDownLatch和CyclicBarrier:它们是并发工具类,用于线程之间的同步和等待。 CountDownLatch可用于等待一组线程完成操作,而CyclicBarrier用于等待一组线程互相达到屏障 位置。 选择适合的同步方式会根据具体需求和场景而定。在使用任何同步机制时,需要注意避免死锁和性能问 题,合理设计同步范围和粒度。
publicclassTest {
publicstaticvoidmain(String[] args) {
Objectlock=newObject();
System.out.println("=== 无锁状态 ===");
System.out.println(ClassLayout.parseInstance(lock).toPrintable());
// 第一个线程获取锁(触发偏向锁)
synchronized (lock) {
System.out.println("\n=== 偏向锁状态(JDK15之后是轻量级锁) ===");
System.out.println(ClassLayout.parseInstance(lock).toPrintable());
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
9. 认识Executor线程池
什么是线程池:
创建和销毁对象是非常耗费时间的。思路:事先new创建好多个线程,放入线程池中,使用时直接 获取引用,不使用时放回池中。 生活案例:共享单车 JDK1.5起,提供了内置了Executor线程池
线程池的好处
提高响应速度(减少了创建新线程的时间) 降低资源消耗(重复利用线程池中线程,不需要每次都创建) 提高线程的可管理性:避免线程无限制创建
线程池的应用场合
需要大量线程,并且完成任务的时间短 对性能要求苛刻 接受突发性的大量请求
10、Executors提供的线程池类型
在 JDK 的 java.util.concurrent.Executors 中提供了生成多种线程池的静态方法。 ExecutorService newCachedThreadPool = Executors.newCachedThreadPool(); ExecutorService newFixedThreadPool = Executors.newFixedThreadPool(4); ScheduledExecutorService newScheduledThreadPool = Executors.newScheduledThreadPool(4); ExecutorService newSingleThreadExecutor = Executors.newSingleThreadExecutor(); 然后调用他们的 execute 方法即可。
(1)newCachedThreadPool
创建一个可缓存线程池,如果线程池长度超过处理需要,可灵活回收空闲线程,若无可回收,则新建线 程。这种类型的线程池特点是: 工作线程的创建数量几乎没有限制(其实也有限制的,数目为Interger. MAX_VALUE), 这样可灵活的往线程 池中添加线程。 如果长时间没有往线程池中提交任务,即如果工作线程空闲了指定的时间(默认为1分钟),则该工作线程 将自动终止。终止后,如果你又提交了新的任务,则线程池重新创建一个工作线程。 在使用CachedThreadPool时,一定要注意控制任务的数量,否则,由于大量线程同时运行,很有会造 成系统瘫痪。
(2)newFixedThreadPool
创建一个指定工作线程数量的线程池。每当提交一个任务就创建一个工作线程,如果工作线程数量达到 线程池初始的最大数,则将提交的任务存入到池队列中。FixedThreadPool是一个典型且优秀的线程 池,它具有线程池提高程序效率和节省创建线程时所耗的开销的优点。但是,在线程池空闲时,即线程 池中没有可运行任务时,它不会释放工作线程,还会占用一定的系统资源。
(3)newSingleThreadExecutor
创建一个单线程化的Executor,即只创建唯一的工作者线程来执行任务,它只会用唯一的工作线程来执 行任务,保证所有任务按照指定顺序(FIFO, LIFO, 优先级)执行。如果这个线程异常结束,会有另一个取 代它,保证顺序执行。单工作线程最大的特点是可保证顺序地执行各个任务,并且在任意给定的时间不 会有多个线程是活动的。
(4)newScheduleThreadPool
创建一个定长的线程池,而且支持定时的以及周期性的任务执行。例如延迟3秒执行。 这4种线程池底层全部是ThreadPoolExecutor对象的实现,阿里规范手册中规定线程池采用 ThreadPoolExecutor自定义的,实际开发也是。
11、 Executor线程池的API
Executor接口是顶层接口,只有一个execute方法
ExecutorService:真正的线程池接口
void execute(Runnable cmd) :执行任务/命令,没有返回值,一般用来执行Runnable Future submit(Callable task):执行任务,有返回值,一般用来执行Callable void shutdown() :关闭线程池。不接收新任务,但是已经提交的任务会执行完。 List shutdownNow()。立刻关闭线程池。不会接收新任务,并用interrupt的方式中断正在执行的 任务;会将队列中的任务返回。
AbstractExecutorService:基本实现了ExecutorService的所有方法
ThreadPoolExecutor:默认的线程池实现类
ScheduledThreadPoolExecutor:实现周期性任务调度的线程池
Executors:工具类、线程池的工厂类,用于创建并返回不同类型的线程池
12、ThreadPoolExecutor对象的七个参数及其作用?
参数与作用:共7个参数
- corePoolSize:核心线程数,在ThreadPoolExecutor中有一个与它相关的配置: allowCoreThreadTimeOut(默认为false),当allowCoreThreadTimeOut为false时,核心线程 会一直存活,哪怕是一直空闲着。而当allowCoreThreadTimeOut为true时核心线程空闲时间超过 keepAliveTime时会被回收。
- maximumPoolSize:最大线程数,线程池能容纳的最大线程数,当线程池中的线程达到最大时, 此时添加任务将会采用拒绝策略,默认的拒绝策略是抛出一个运行时错误 (RejectedExecutionException)。值得一提的是,当初始化时用的工作队列为 LinkedBlockingDeque时,这个值将无效。
- keepAliveTime:存活时间,当非核心空闲超过这个时间将被回收,同时空闲核心线程是否回收 受allowCoreThreadTimeOut影响。
- unit:keepAliveTime的单位。
- workQueue:任务队列,常用有三种队列,即SynchronousQueue,LinkedBlockingQueue(无 界队列),ArrayBlockingQueue(有界队列)。
- threadFactory:线程工厂,ThreadFactory是一个接口,用来创建worker。通过线程工厂可以对 线程的一些属性进行定制。默认直接新建线程。
- RejectedExecutionHandler:拒绝策略,也是一个接口,只有一个方法,当线程池中的资源已经 全部使用,添加新线程被拒绝时,会调用RejectedExecutionHandler的rejectedExecution法。默 认是抛出一个运行时异常。
13、 Executor线程池拒绝策略
ThreadPoolExecutor自带的拒绝策略如下: 1、AbortPolicy 直接抛出异常,默认策略; 2、CallerRunsPolicy 用调用者所在的线程来执行任务; 3、DiscardOldestPolicy 丢弃阻塞队列中靠最前的任务,并执行当前任务; 4、DiscardPolicy 直接丢弃任务; 当然也可以根据应用场景实现 RejectedExecutionHandler 接口,自定义饱和策略,如记录日志或持久 化存储不能处理的任务。
14、Executor线程池的工作原理
- 在创建了线程池后,线程池中的线程数为零。
- 当调用execute()方法添加一个请求任务时,线程池会做出如下判断:
- 如果正在运行的线程数量小于corePoolSize,那么马上创建线程运行这个任务;
- 如果正在运行的线程数量大于或等于corePoolSize,那么将这个任务放入队列;
- 如果这个时候队列满了且正在运行的线程数量还小于maximumPoolSize,那么还是要创建 非核心线程立刻运行这个任务;
- 如果队列满了且正在运行的线程数量大于或等于maximumPoolSize,那么线程池会启动默 认的拒绝策略来执行。
- 当一个线程完成任务时,它会从队列中取下一个任务来执行。
- 当一个线程无事可做超过一定的时间(keepAliveTime)时,线程会判断: 如果当前运行的线程数大于corePoolSize,那么这个线程就被停掉。 所以线程池的所有任务完成后,它最终会收缩到corePoolSize的大小。
注意:提交一个Runnable时,不管当前线程池中的线程是否空闲,只要数量小于核心线程数就会
创建新线程。
注意:ThreadPoolExecutor相当于是非公平的,比如队列满了之后提交的Runnable可能会比正
在排队的Runnable先执行。
15、线程池的五种状态是如何流转的
线程池有五种状态: RUNNING:会接收新任务并且会处理队列中的任务 SHUTDOWN:不会接收新任务并且会处理队列中的任务 STOP:不会接收新任务并且不会处理队列中的任务,并且会中断在处理的任务。 TIDYING:所有任务都终止了,线程池中也没有线程了,这样线程池的状态就会转为TIDYING,一 旦达到此状态,就会调用线程池的terminated() TERMINATED:terminated()执行完之后就会转变为TERMINATED 这五种状态并不能任意转换,只会有以下几种转换情况:
- RUNNING -> SHUTDOWN:手动调用shutdown()触发,或者线程池对象GC时会调用finalize()从而 调用shutdown()
- (RUNNING or SHUTDOWN) -> STOP:调用shutdownNow()触发,如果先调shutdown()紧着调 shutdownNow(),就会发生SHUTDOWN -> STOP
- SHUTDOWN -> TIDYING:队列为空并且线程池中没有线程时自动转换
- STOP -> TIDYING:线程池中没有线程时自动转换(队列中可能还有任务)
- TIDYING -> TERMINATED:terminated()执行完后就会自动转换
16、线程池为什么一定得是阻塞队列?
线程池中的线程在运行过程中,执行完创建线程时绑定的第一个任务后,就会不断的从队列中获取任务 并执行,那么如果队列中没有任务了,线程为了不自然消亡,就会阻塞在获取队列任务时,等着队列中 有任务过来就会拿到任务从而去执行任务。 通过这种方法能最终确保,线程池中能保留指定个数的核心线程数,关键代码: 某个线程在从队列获取任务时,会判断是否使用超时阻塞获取,我们可以认为非核心线程会poll(),核心 线程会take(),非核心线程超过时间还没获取到任务后面就会自然消亡了。
17、线程发生异常,会被移出线程池吗?
答案是会的,那有没有可能核心线程数在执行任务时都出错了,导致所有核心线程都被移出了线程池? 在源码中,当执行任务时出现异常时,最终会执行processWorkerExit(),执行完这个方法后,当前线程 也就自然消亡了,但是!processWorkerExit()方法中会额外再新增一个线程,这样就能维持住固定的核 心线程数。
18、线程池的核心线程数、最大线程数该如何设置?
我们都知道,线程池中有两个非常重要的参数:
- corePoolSize:核心线程数,表示线程池中的常驻线程的个数
- maximumPoolSize:最大线程数,表示线程池中能开辟的最大线程个数 那这两个参数该如何设置呢? 我们对线程池负责执行的任务分为三种情况:
- CPU密集型任务,比如找出1-1000000中的素数
- IO密集型任务,比如文件IO、网络IO
- 混合型任务 CPU密集型任务的特点时,线程在执行任务时会一直利用CPU,所以对于这种情况,就尽可能避免发生 线程上下文切换。 比如,现在我的电脑只有一个CPU,如果有两个线程在同时执行找素数的任务,那么这个CPU就需要额 外的进行线程上下文切换,从而达到线程并发的效果,此时执行这两个任务的总时间为: 任务执行时间2+线程上下文切换的时间 而如果只有一个线程,这个线程来执行两个任务,那么时间为: 任务执行时间2 所以对于CPU密集型任务,线程数最好就等于CPU核心数,可以通过以下API拿到你电脑的核心数: 只不过,为了应对线程执行过程发生异常导致线程阻塞的请求,我们可以额外在多设置一个线程,这样 当某个线程暂时不需要CPU时,可以有替补线程来继续利用CPU。
try {
Runnabler=timed?
workQueue.poll(keepAliveTime, TimeUnit.NANOSECONDS) :
workQueue.take();
if (r!=null)
returnr;
timedOut=true;
} catch (InterruptedExceptionretry) {
timedOut=false;
}
1
2
3
4
5
6
7
8
9
10
Runtime.getRuntime().availableProcessors()
1
所以,对于CPU密集型任务,我们可以设置线程数为:CPU核心数+1 我们在来看IO型任务,线程在执行IO型任务时,可能大部分时间都阻塞在IO上,假如现在有10个CPU, 如果我们只设置了10个线程来执行IO型任务,那么很有可能这10个线程都阻塞在了IO上,这样这10个 CPU就都没活干了,所以,对于IO型任务,我们通常会设置线程数为:2CPU核心数 不过,就算是设置为了2CPU核心数,也·不一定是最佳的,比如,有10个CPU,线程数为20,那么也 有可能这20个线程同时阻塞在了IO上,所以可以再增加线程,从而去压榨CPU的利用率。
通常,如果IO型任务执行的时间越长,那么同时阻塞在IO上的线程就可能越多,我们就可以设置更多的
线程,但是,线程肯定不是越多越好,我们可以通过以下这个公式来进行计算: 线程数 = CPU核心数 *( 1 + 线程等待时间 / 线程运行总时间) 线程等待时间:指的就是线程没有使用CPU的时间,比如阻塞在了IO 线程运行总时间:指的是线程执行完某个任务的总时间 我们可以利用jvisualvm抽样来估计这两个时间: 图中表示,在刚刚这次抽样过程中,run()总共的执行时间为92567ms,利用了CPU的时间为 11027ms,所以没有利用CPU的时间为92567ms-11027ms。 所以我们可以计算出: 线程等待时间 = 92567ms-11027ms。 线程运行总时间 =92567ms 所以:线程数 = 20 *( 1 + (92567ms-11027ms) / 92567ms )≈ 37 所以根据公式算出来的线程为37、38个线程左右。 按上述公式,如果我们执行的任务IO密集型任务,那么:线程等待时间 = 线程运行总时间,所以: 线程数 = CPU核心数 *( 1 + 线程等待时间 / 线程运行总时间) = CPU核心数 *( 1 + 1 ) = CPU核心数 * 2 以上只是理论,实际工作中情况会更复杂,比如一个应用中,可能有多个线程池,除开线程池中的线程 可能还有很多其他线程,或者除开这个应用还是一些其他应用也在运行,所以实际工作中如果要确定线 程数,最好是压测。 这个接口会执行1s,我现在利用apipost来压:
@RestController
publicclassHzkController {
@GetMapping("/test")
publicStringtest() throwsInterruptedException {
Thread.sleep(1000);
return"success";
}
}
1
2
3
4
5
6
7
8
9
10
这是在Tomcat默认最大200个线程的请求下的压测结果。 当我们把线程数调整为500: 发现执行效率提高了一倍,假如再增加线程数到1000: 提升就不那么高了。
总结,我们再工作中,对于:
- CPU密集型任务:CPU核心数+1,这样既能充分利用CPU,也不至于有太多的上下文切换成本
- IO型任务:建议压测,或者先用公式计算出一个理论值(理论值通常都比较小)
- 对于核心业务(访问频率高),可以把核心线程数设置为我们压测出来的结果,最大线程数可以等 于核心线程数,或者大一点点,比如我们压测时可能会发现500个线程最佳,但是600个线程时也 还行,此时600就可以为最大线程数
- 对于非核心业务(访问频率不高),核心线程数可以比较小,避免操作系统去维护不必要的线程, 最大线程数可以设置为我们计算或压测出来的结果。
19、Tomcat是如何自定义线程池的?
Tomcat中用的线程池为org.apache.tomcat.util.threads.ThreadPoolExecutor,注意类名和JUC下的一 样,但是包名不一样。 Tomcat会创建这个线程: 注意传入的队列为TaskQueue,它的入队逻辑为:
publicvoidcreateExecutor() {
internalExecutor=true;
TaskQueuetaskqueue=newTaskQueue();
TaskThreadFactorytf=newTaskThreadFactory(getName() +"-exec-",
daemon, getThreadPriority());
executor=newThreadPoolExecutor(getMinSpareThreads(), getMaxThreads(),
60, TimeUnit.SECONDS,taskqueue, tf);
taskqueue.setParent( (ThreadPoolExecutor) executor);
}
1
2
3
4
5
6
7
publicbooleanoffer(Runnableo) {
//we can't do any checks
if (parent==null) {
returnsuper.offer(o);
}
//we are maxed out on threads, simply queue the object
if (parent.getPoolSize() ==parent.getMaximumPoolSize()) {
returnsuper.offer(o);
}
//we have idle threads, just add it to the queue
if (parent.getSubmittedCount()<=(parent.getPoolSize())) {
returnsuper.offer(o);
}
//if we have less threads than maximum force creation of a new thread
if (parent.getPoolSize()<parent.getMaximumPoolSize()) {
returnfalse;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
特殊在: 入队时,如果线程池的线程个数等于最大线程池数入队。 入队时,如果线程池有闲置线程,任务依旧入队。 入队时,如果线程池的线程个数小于最大线程池数,会返回false,表示入队失败,则去创建线程。 这样就控制了,Tomcat的这个线程池,在提交任务时:
- 不会先判断线程个数是否小于核心线程数,而是提前创建10个核心线程。
- 如果等于最大线程数,会入队,但是线程个数小于最大线程数会入队失败,从而会去创建线程 所以随着任务的提交,会优先创建线程,直到线程个数等于最大线程数才会入队。 当然其中有一个比较细的逻辑是:在提交任务时,如果正在处理的任务数小于线程池中的线程个数,那 么也会直接入队,而不会去创建线程,也就是上面源码中getSubmittedCount的作用。
20、ThreadLocal的原理和使用场景
每一个 Thread 对象均含有一个 ThreadLocalMap 类型的成员变量 threadLocals ,它存储本线程中所 有ThreadLocal对象及其对应的值ThreadLocalMap 由一个个 Entry 对象构成Entry 继承自 WeakReference<ThreadLocal<?>> ,一个 Entry 由 ThreadLocal 对象和 Object 构成。由此可见, Entry 的key是ThreadLocal对象,并且是一个弱引用。当没指向key的强引用后,该 key就会被垃圾收集器回收。 当执行set方法时,ThreadLocal首先会获取当前线程对象,然后获取当前线程的ThreadLocalMap对 象。再以当前ThreadLocal对象为key,将值存储进ThreadLocalMap对象中。 get方法执行过程类似。ThreadLocal首先会获取当前线程对象,然后获取当前线程的ThreadLocalMap 对象。再以当前ThreadLocal对象为key,获取对应的value。 由于每一条线程均含有各自私有的ThreadLocalMap容器,这些容器相互独立互不影响,因此不会存在 线程安全性问题,从而也无需使用同步机制来保证多条线程访问容器的互斥性。 使用场景: 1、在进行对象跨层传递的时候,使用ThreadLocal可以避免多次传递,打破层次间的约束。 2、Spring框架进行事务操作,用于存储线程事务信息。 3、Spring框架数据库连接,Session会话管理。 4、SpringMVC框架的处理器请求管理。
例子:example:
}
//if we reached here, we need to add it to the queue
returnsuper.offer(o);
}
20
21
22
23
24
Spring框架在事务开始时会给当前线程绑定一个Jdbc Connection,在整个事务过程都是使用该线程绑 定的connection来执行数据库操作,实现了事务的隔离性。Spring框架里面就是用的ThreadLocal来 实现这种隔离
1
packagecom.hzk.test.my;
publicclassPerson {
privateThreadLocal<String>name=newThreadLocal<>();
1
2
3
4
5
21、ThreadLocal内存泄漏原因,如何避免
publicvoidsetName(Stringname) {
this.name.set(name);
}
publicStringgetName() {
returnthis.name.get();
}
publicvoidremove() {
this.name.remove();
}
}
6
7
8
9
10
11
12
13
14
15
16
17
18
19
packagecom.hzk.test.my;
importjava.util.concurrent.TimeUnit;
publicclassTest {
publicstaticvoidmain(String[] args) {
Personperson=newPerson();
Threadthread1=newThread(newRunnable() {
@Override
publicvoidrun() {
person.setName("hzk");
try {
TimeUnit.SECONDS.sleep(3);
} catch (InterruptedExceptione) {
e.printStackTrace();
}
System.out.println("线程1==="+person.getName());
}
});
thread1.start();
Threadthread2=newThread(newRunnable() {
@Override
publicvoidrun() {
person.setName("hello");
try {
TimeUnit.SECONDS.sleep(3);
} catch (InterruptedExceptione) {
e.printStackTrace();
}
System.out.println("线程2==="+person.getName());
}
});
thread2.start();
}
}
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
内存泄露:为程序在申请内存后,无法释放已申请的内存空间,一次内存泄露危害可以忽略,但内存泄 露堆积后果很严重,无论多少内存,迟早会被占光,不再会被使用的对象或者变量占用的内存不能被回 收,就是内存泄露。 强引用:使用最普遍的引用(new),一个对象具有强引用,不会被垃圾回收器回收。当内存空间不足, Java虚拟机宁愿抛出OutOfMemoryError错误,使程序异常终止,也不回收这种对象。 如果想取消强引用和某个对象之间的关联,可以显式地将引用赋值为null,这样可以使JVM在合适的时间 就会回收该对象。 弱引用:JVM进行垃圾回收时,无论内存是否充足,都会回收被弱引用关联的对象。在java中,用 java.lang.ref.WeakReference类来表示。可以在缓存中使用弱引用。 ThreadLocal的实现原理,每一个Thread维护一个ThreadLocalMap,key为使用弱引用的ThreadLocal 实例,value为线程变量的副本。 ThreadLocalMap使用ThreadLocal的弱引用作为key,如果一个ThreadLocal不存在外部强引用时, Key(ThreadLocal)势必会被GC回收,这样就会导致ThreadLocalMap中key为null,而value还存在着强 引用,只有thead线程退出以后,value的强引用链条才会断掉,但如果当前线程再迟迟不结束的话,这些 key为null的Entry的value就会一直存在一条强引用链(红色链条) key使用强引用当ThreadLocalMap的key为强引用回收ThreadLocal时,因为ThreadLocalMap还持有 ThreadLocal的强引用,如果没有手动删除,ThreadLocal不会被回收。 key 使用弱引用当ThreadLocalMap的key为弱引用回收ThreadLocal时,由于ThreadLocalMap持有 ThreadLocal的弱引用,即使没有手动删除,ThreadLocal也会被回收。当key为null时,调用完set方法 之后,在调用remove方法的时候会清除value值。 因此,内存泄漏的根源是:由于ThreadLocalMap的生命周期跟Thread一样长,如果没有手动删除对应 key的value,就会导致内存泄漏,而不是因为弱引用。 ThreadLocal正确的使用姿势:每次使用完ThreadLocal都调用它的remove()方法清除数据 .
22、ThreadLocal有哪些应用场景
ThreadLocal是Java中的一个类,它提供了一种在多线程环境下实现线程局部变量存储的机制。 它的应用场景包括线程池、Web开发中的请求上下文信息管理、数据库连接管理和日志记录等等。 在线程池中,可以使用ThreadLocal为每个线程维护独立的上下文信息,避免线程间互相干扰。 在Web开发中,可以使用ThreadLocal存储当前请求的上下文信息,避免参数传递的复杂性。 在数据库连接管理中,ThreadLocal可以为每个线程保持独立的数据库连接,提高并发性能。 在日志记录中,ThreadLocal可以将日志记录与当前线程关联起来,方便追踪和排查问题。 此外,ThreadLocal还可以用于在线程之间传递全局的上下文信息。 在使用ThreadLocal时需要注意内存泄漏问题和线程安全性,及时清理不再需要的变量副本,并采取适 当的同步措施保证线程安全。通过合理使用ThreadLocal,可以简化多线程编程,提高程序的性能和可 维护性。
23、讲讲你对CountDownLatch的理解
CountDownLatch是Java中用于多线程协作的辅助类,它可以让一个或多个线程等待其他线程完成某个 任务后再继续执行。 CountDownLatch通过一个计数器来实现,计数器的初始值可以设置为等待的线程数量。每个线程在完 成任务后都会调用countDown()方法来减少计数器的值。当计数器的值减至0时,等待在 CountDownLatch上的线程就会被唤醒,可以继续执行后续的操作。 CountDownLatch的主要作用是协调多个线程的执行顺序,使得某个线程(或多个线程)必须等待其他 线程完成后才能继续执行。它常用于以下场景:
- 主线程等待多个子线程完成任务:主线程可以使用await()方法等待所有子线程完成,然后进行结果 的汇总或其他操作。
- 多个线程等待外部事件的发生:多个线程可以同时等待某个共同的事件发生,比如等待某个资源准 备就绪或者等待某个信号的触发。
- 控制并发任务的同时开始:在某些并发场景中,需要等待所有线程都准备就绪后才能同时开始执行 任务,CountDownLatch提供了一种便捷的方式来实现这一需求。 需要注意的是,CountDownLatch的计数器是不能被重置的,也就是说它是一次性的。一旦计数器减至 0,它将无法再次使用。如果需要多次使用可重置的计数器,则可以考虑使用CyclicBarrier。
24、CountDownLatch 与 join 方法的区别
调用一个子线程的join()方法后,该线程会一直被阻塞直到子线程运行完毕。而 CountDownLatch 可在子线程运行任何时候让await方法返回而不一定等到线程结束 使用线程池来管理线程时候一般都是直接添加 Runnable 到线程池,这时候没有办法再调用线程的 join 方法,countDownLatch可以,在Runnable中调用await。
25、讲讲你对CyclicBarrier的理解
CyclicBarrier是Java中的一个多线程协作工具,它可以让多个线程在一个屏障点等待,并在所有线程都 到达后一起继续执行。与CountDownLatch不同,CyclicBarrier可以重复使用,并且可以指定屏障点后 执行的额外动作。 CyclicBarrier的主要特点有三个。 首先,它可以重复使用,这意味着当所有线程都到达屏障点后,屏障会自动重置,可以用来处理多 次需要等待的任务。 其次,CyclicBarrier可以协调多个线程同时开始执行,这在分阶段任务和并发游戏等场景中非常有 用。 最后,CyclicBarrier还提供了可选的动作,在所有线程到达屏障点时执行,可以实现额外的逻辑。 需要注意的是,在创建CyclicBarrier时需要指定参与线程的数量。一旦所有参与线程都到达屏障点后, CyclicBarrier解除阻塞,所有线程可以继续执行后续操作。
26、CountDownLatch/CyclicBarrier/Semaphore比较
特性
CountDownLatch
CyclicBarrier
Semaphore
核心功 能 等待其他线程完成事件 线程间相互等待到屏障点 控制资源并发访问数量 重用性 一次性(计数器到0后失 效) 可循环使用(屏障可重 置) 可重复使用(许可证循 环) 计数器 递减到0触发 递减到0触发重新回到初 始值 可用许可证数,增减都 可以 主要方 法 await(), countDown() await() acquire(), release() 线程关 系 等待方≠被等待方 等待方 = 被等待方 无 “等待方/被等待方”概 念 异常处 理 不会重置 会破坏屏障 不影响信号量 典型场 景 主线程等子线程初始化 多阶段协作任务 限流(如连接池)
一句话记忆
CountDownLatch:“你等我,我不等你”(一组等另一组)。 CyclicBarrier:“大家一起等,等齐了再走”(同组相互等,可重复)。 Semaphore:“排队进门,一次只能进 N 个”(限流)。
27、CompletableFuture线程编排工具
1)、什么是 CompletableFuture?
CompletableFuture 是 Java 8 引入的非阻塞异步编程核心工具,它同时实现 Future 接口(继承异步结 果容器能力)和 CompletionStage 接口(新增异步任务编排能力),提供了丰富的函数式 API,用于处 理异步任务的启动、结果转换、多任务协同和异常捕获。 简单来说: 它是「异步结果的容器」:存储异步任务的成功 / 失败结果,支持非阻塞获取; 它是「异步任务的编排器」:用链式调用描述复杂异步逻辑(如 “多任务并行 + 结果聚合”); 它是「Future 的增强版」:弥补传统 Future 的短板,让 Java 原生支持高效、简洁的非阻塞异步 编程,无需依赖第三方框架。
2)、为什么需要它?(解决了什么问题?)
在 CompletableFuture 之前,Java 依赖 Future + ExecutorService 实现异步,但存在诸多难以解决的 痛点,无法满足复杂异步场景需求:
1. 传统 Future 的核心局限性(场景化痛点)
阻塞获取结果,效率低**:Future.get() 是阻塞式,要么一直等,要么指定超时 —— 想实现 “任务 完成后自动执行后续逻辑”,只能开额外线程循环查询,浪费资源(如 “异步查库后自动更缓存”, Future 只能阻塞等结果); 复杂任务组合困难:无法简洁表达 “并行执行 A、B,都完成后执行 C”“A 失败则执行兜底任务 D” 等 逻辑,只能手动 get() 阻塞等待,代码嵌套深、维护难; 异常处理割裂:异常只能在 get() 时通过 ExecutionException 捕获,无法融入异步链 —— 中间任 务抛异常,只能在最终获取结果时统一处理,排查困难; 无回调机制,灵活性差:没有原生 “任务完成后自动回调” 能力,只能被动阻塞查询,无法适配 “异 步下载完成通知前端” 等场景; 无法手动干预结果:结果只能由任务本身生成,外部无法手动设置兜底结果(如数据库查询失败时 返回缓存数据)。
2. CompletableFuture 的核心优势(精准解决上述痛点)
异步执行 + 灵活线程管理:无需显式创建线程,默认使用 ForkJoinPool,也支持自定义线程池(如 固定线程池),适配 CPU/IO 密集型场景; 链式编排:支持异步任务的串联 / 分支(如 taskA.thenApply(…)),代码线性编写,无嵌套,可读 性强; 多任务组合:提供 allOf(所有任务完成)、anyOf(任意任务完成)、thenCombine(两任务结 果组合)等 API,轻松实现复杂协同逻辑; 全链路异常处理:通过 exceptionally(仅异常兜底)、handle(正常 + 异常统一处理)、 whenComplete(仅消费异常)等 API,异常处理可融入异步链任意环节; 非阻塞 + 回调通知:无需阻塞等待,任务完成后自动触发回调逻辑(如 thenAccept),最大化利 用线程资源; 手动干预结果:支持 complete()(手动设成功结果)、completeExceptionally()(手动触发异 常),适配缓存降级、超时兜底等场景
3)、常用的方法有哪些
开始任务的方法
runAsync:无返回值 supplyAsync 有返回值
中间方法1(增加新任务)
thenApplyAsync:有参数有返回值 thenRunAsync:无参数无返回值 thenAcceptAsync:有参数无返回值
中间方法2:(不增加新任务)
exceptionally:出现异常时执行,有返回值 handle:统一处理正常和异常情况,有返回值 whenComplete:统一处理正常和异常情况,无返回值
完成任务的方法
allOf: 所有任务 anyOf 任意一个任务 get:join:都是阻塞的,才能保证真正的任务执行完