JavaSE面试题总结
start
笔记摘抄(精简)自JavaGuide,全文很多地方都是从JavaGuide摘抄过来的,只为了简化重点,突出重点,并加入自己的理解,只做学习使用
需要完整阅读请前往 JavaGuide 阅读
[TOC]
字符串String
字符和字符串的编码
- char占2个字节
- String中一个字符占1一个字节
JDK8及以前,String中用char数组存储字符串
JDK9开始,String使用byte数组存储字符串,若存储的字符编码范围不超过Latin-1,则每个byte只占1个字节,若超过Latin-1,则使用UTF-16编码,byte和char所占据字节数一样
String为什么是不可变的
String内部使用 private final char value[]
存储数据
String类本身被 final
修饰
- final修饰引用类型的变量表示不能再指向其他对象
- private修饰,且String没有提供访问char数组的方法,所以无法修改char数组本身
- String 类被 final 修饰导致其不能被继承,进而避免了子类破坏 String 不可变
字符串拼接可以使用“+”吗?
- JDK8及以前,使用StringBuilder或者StringBuffer进行拼接,使用 + 拼接会创建多个StringBuilder对象
- JDK9以后,使用 + 进行拼接时,会使用到InvokeDynamic动态方法makeConcatWithConstants,中英文混杂时速度以及使用的内存甚至会优于使用StringBuilder
在开启字符串压缩的情况下,
StringBuilder
拼接无法压缩的(如中文)字符串会引来一次额外的内存分配。(至少 Java 19 在builder.setLength(0)
后还会这样。)有对应的工具方法的操作,如
String::join
,那还是用对应的方法好一点。
包装类型的缓存机制
Byte
,Short
,Integer
,Long
这 4 种包装类默认创建了数值 [-128,127] 的相应类型的缓存数据,Character
创建了数值在 [0,127] 范围的缓存数据,Boolean
直接返回 True or False。
Float 和 Double 浮点数没有实现缓存机制。
HashCode
为什么要有hashCode?
因为hashCode比equals快,能加快判断两个对象的值是否相等
为什么重写 equals() 时必须重写 hashCode() 方法?
因为两个相等的对象的 hashCode
值必须是相等。如果重写 equals()
时没有重写 hashCode()
方法的话就可能会导致 equals
方法判断是相等的两个对象,hashCode
值却不相等。
哈希碰撞的处理方法:
链接法、红黑树
开放寻址:线性探测(懒删除机制)、平方探测、多次哈希
Java语法糖
语法糖(Syntactic sugar) 代指的是编程语言为了方便程序员开发程序而设计的一种特殊语法,这种语法对编程语言的功能并没有影响。实现相同的功能,基于语法糖写出来的代码往往更简单简洁且更易阅读。
不过,JVM 其实并不能识别语法糖,Java 语法糖要想被正确执行,需要先通过编译器进行解糖,也就是在程序编译阶段将其转换成 JVM 认识的基本语法。这也侧面说明,Java 中真正支持语法糖的是 Java 编译器而不是 JVM。
Java 中最常用的语法糖主要有:
- Switch开始支持String类型
- 转换为hashcode + equals判断,本质上还是判断int
- 泛型
- 所有泛型都会在编译时进行 类型擦除
- 自动装箱与拆箱
- 调用Integer.valueoOf()进行装箱,调用Integer.intValue()进行拆箱
- 可变长参数
- 将可变长参数转换为一个参数数组
- 枚举
- 生成一个final的类并继承Enum类
- 内部类
- 编译时会生成两个.class文件
- 条件编译
- 判断条件为常量时,编译时会自动去除常量为false的语句
- 断言
- assert会被编译为普通的if-else语句,true就继续执行,false就抛出AssertError打断程序执行
- 数值字面量
- 编译后删除数值中方便阅读的 ‘_’
- 增强for循环for-each
- 转换为普通for循环 或者 迭代器
- try-with-resources
- 帮我们关闭资源,不用再在finally中手动关闭
- lambda表达式
- 编译时将lambda表达式转换成调用内部 api 的方式
- …
Java序列化
- 序列化:将数据结构或对象转换成二进制字节流的过程
- 反序列化:将在序列化过程中所生成的二进制字节流转换成数据结构或者对象的过程
序列化和反序列化常见应用场景:
- 对象在进行网络传输(比如远程方法调用 RPC 的时候)之前需要先被序列化,接收到序列化的对象之后需要再进行反序列化;
- 将对象存储到文件之前需要进行序列化,将对象从文件中读取出来需要进行反序列化;
- 将对象存储到数据库(如 Redis)之前需要用到序列化,将对象从缓存数据库中读取出来需要反序列化;
- 将对象存储到内存之前需要进行序列化,从内存中读取出来之后需要进行反序列化。
序列化协议对应于 TCP/IP 4 层模型的哪一层?
- 应用层
- 传输层
- 网络层
- 网络接口层
图片出自JavaGuide
序列化和反序列化对应其中的表示层
常见的序列化协议
JDK 自带的序列化方式一般不会用 ,因为序列化效率低并且存在安全问题。比较常用的序列化协议有 Hessian、Kryo、Protobuf、ProtoStuff,这些都是基于二进制的序列化协议。
像 JSON 和 XML 这种属于文本类序列化方式。虽然可读性比较好,但是性能较差,一般不会选择。
serialVersionUID 有什么作用?
序列化号 serialVersionUID
属于版本控制的作用。反序列化时,会检查 serialVersionUID
是否和当前类的 serialVersionUID
一致。如果 serialVersionUID
不一致则会抛出 InvalidClassException
异常。强烈推荐每个序列化类都手动指定其 serialVersionUID
,如果不手动指定,那么编译器会动态生成默认的 serialVersionUID
。
serialVersionUID 不是被 static 变量修饰了吗?为什么还会被“序列化”?
static
修饰的变量是静态变量,位于方法区,本身是不会被序列化的。serialVersionUID
只是用来被 JVM 识别,实际并没有被序列化。
如果有些字段不想进行序列化怎么办?
使用 transient
关键字修饰。
transient
关键字的作用是:阻止实例中那些用此关键字修饰的的变量序列化;当对象被反序列化时,被 transient 修饰的变量值不会被持久化和恢复。
关于
transient
还有几点注意:
transient
只能修饰变量,不能修饰类和方法。transient
修饰的变量,在反序列化后变量值将会被置成类型的默认值。例如,如果是修饰int
类型,那么反序列后结果就是0
。static
变量因为不属于任何对象(Object),所以无论有没有transient
关键字修饰,均不会被序列化。
为什么不推荐使用JDK自带的序列化?
不支持跨语言调用:如果调用的是其他语言开发的服务的时候就不支持了。
性能差:相比于其他序列化框架性能更低,主要原因是序列化之后的字节数组体积较大,导致传输成本加大。
存在安全问题:序列化和反序列化本身并不存在问题。但当输入的反序列化的数据可被用户控制,那么攻击者即可通过构造恶意输入,让反序列化产生非预期的对象,在此过程中执行构造的任意代码。
Java值传递
Java 中将实参传递给方法(或函数)的方式是 值传递:
- 如果参数是基本类型的话,很简单,传递的就是基本类型的字面量值的拷贝,会创建副本。
- 如果参数是引用类型,传递的就是实参所引用的对象在堆中地址值的拷贝,同样也会创建副本
方法的定义可能会用到 参数(有参的方法),参数在程序语言中分为:
- 实参(实际参数,Arguments):用于传递给函数/方法的参数,必须有确定的值。
- 形参(形式参数,Parameters):用于定义函数/方法,接收实参,不需要有确定的值。
1 | String hello = "Hello!"; |
程序设计语言将实参传递给方法(或函数)的方式分为两种:
- 值传递:方法接收的是实参值的拷贝,会创建副本。
- 引用传递:方法接收的直接是实参所引用的对象在堆中的地址,不会创建副本,对形参的修改将影响到实参。
很多程序设计语言(比如 C++、 Pascal )提供了两种参数传递的方式,不过,在 Java 中只有值传递。
为什么Java只有值传递? / 举一个Java不是引用传递的例子
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20 public class Person {
private String name;
// 省略构造函数、Getter&Setter方法
}
public static void main(String[] args) {
Person xiaoZhang = new Person("小张");
Person xiaoLi = new Person("小李");
swap(xiaoZhang, xiaoLi);
System.out.println("xiaoZhang:" + xiaoZhang.getName());
System.out.println("xiaoLi:" + xiaoLi.getName());
}
public static void swap(Person person1, Person person2) {
Person temp = person1;
person1 = person2;
person2 = temp;
System.out.println("person1:" + person1.getName());
System.out.println("person2:" + person2.getName());
}输出:
1
2
3
4 person1:小李
person2:小张
xiaoZhang:小张
xiaoLi:小李并没有影响到实参本身的值
Java泛型&通配符
为什么要使用泛型?
- 在编译时会有更强的类型检查
- 消除类型转换
- 可以实现更通用的算法
1 | class name<T1, T2, ..., Tn> {} |
泛型的实现
Java 泛型是使用类型擦除(Type Erasure)来实现的,使用泛型时,任何具体的类型信息都被擦除了。
类型擦除做了什么呢?它做了以下工作:
- 把泛型中的所有类型参数替换为 Object,如果指定类型边界,则使用类型边界来替换。因此,生成的字节码仅包含普通的类,接口和方法。
- 擦除出现的类型声明,即去掉
<>
的内容。比如T get()
方法声明就变成了Object get()
;List<String>
就变成了List
。如有必要,编译器自动插入类型转换以保持类型安全。 - 生成桥接方法以保留扩展泛型类型中的多态性。类型擦除确保不为参数化类型创建新类;因此,泛型不会产生运行时开销。
简单来说类型擦除是指,虚拟机对泛型其实一无所知,所有的工作都是编译器做的。(泛型是语法糖,语法糖是需要编译器进行解糖成为JVM认识的基本语法)
即《为什么要使用泛型中》的:更强的编译器类型检查,和自动类型转换,无需我们手动类型转换
1 | //例如,我们编写了一个泛型类Pair<T>,这是编译器看到的代码: |
因此,Java使用类型擦拭实现泛型,导致了:
- 编译器把类型
<T>
视为Object
; - 编译器根据
<T>
自动实现安全的强制转型。
泛型的局限性
了解了Java泛型的实现方式————类型擦除,我们就知道了Java泛型的局限:
<T>
不能是基本类型,例如int
,因为实际类型是Object
,Object
类型无法持有基本类型:1
Pair<int> p = new Pair<>(1, 2); // compile error!
泛型没有自己的
Class
对象。观察以下代码:1
2
3
4
5
6
7public static void main(String[] args) {
List<Object> list1 = new ArrayList<Object>();
List<String> list2 = new ArrayList<String>();
System.out.println(list1.getClass()); // class java.util.ArrayList
System.out.println(list2.getClass()); // class java.util.ArrayList
}
//List<T>的class都是List,不管T是什么无法判断带泛型的类型:
1
2
3
4
5List<Integer> p = new ArrayList<>();
// Compile error:
if (p instanceof List<String>) {
}
//原因和前面一样,不存在List<String>.class,而是只有唯一的List.class。泛型类型无法向上转型
Integer 继承了 Object;ArrayList 继承了 List;但是 List
却并非继承了 List 原因:也是和前面一样,泛型类并没有自己独有的 Class 类对象。比如:并不存在 List
上下边界(通配符)
1 | /*上边界*/ |
使用规则:PECS 原则:producer extends consumer super
当你去生产泛型的元素时,使用extends;当你去消费泛型的元素时,使用super
- 使用类似
<? extends XXX>
通配符作为方法参数时表示:- 允许调用
get()
方法获得XXX及其子类
的引用。 - 不允许调用
set(? extends XXX及其子类)
方法传入XXX及其子类
的引用;
- 允许调用
- 使用
<? super XXX>
通配符表示:- 允许调用
set(? super XXX及其子类)
方法传入XXX及其子类
的引用; - 不允许调用
get()
方法获得XXX及其子类
的引用。
- 允许调用
- 因为
<?>
通配符既没有extends
,也没有super
,因此:- 不允许调用
set(T)
方法并传入引用(null
除外); - 不允许调用
T get()
方法并获取T
引用(只能获取Object
引用)。
- 不允许调用
具体解释看https://www.cnblogs.com/XiiX/p/14719568.html 目录《4.2 上边界》及以后部分
Java反射
反射赋予了我们在运行时分析类以及执行类中方法的能力。通过反射你可以获取任意一个类的所有属性和方法,你还可以调用这些方法和属性。
反射的应用场景
- 框架
- JDK动态代理
- 注解
反射的优缺点
优点:可以让咱们的代码更加灵活、为各种框架提供开箱即用的功能提供了便利
缺点:让我们在运行时有了分析操作类的能力,这同样也增加了安全问题。比如可以无视泛型参数的安全检查(泛型参数的安全检查发生在编译时)。另外,反射的性能也要稍差点,不过,对于框架来说实际是影响不大的。
获取Class对象的四种方式
- TargetObject.class;
- Class.forName()
- instance.getClass()
- xxxClassLoader.loadClass()
反射使用示例
1 | public class TargetObject { |
Java代理模式
- 静态代理
- 动态代理
- JDK动态代理(有接口时)
- CGLIB 动态代理(无接口时,使用ASM框架操作字节码,生成一个被代理类的子类)
代理模式详细实现请看我的博客 Java23 种设计模式
Java高精度浮点数BigDecimal
BigDecimal介绍和作用
为了避免精度丢失,可以使用 BigDecimal
来进行浮点数的运算
示例代码:
1 | float a = 2.0f - 1.9f; |
为什么浮点数 float
或 double
运算的时候会有精度丢失的风险呢?
这个和计算机保存浮点数的机制有很大关系。我们知道计算机是二进制的,而且计算机在表示一个数字时,宽度是有限的,无限循环的小数存储在计算机时,只能被截断,所以就会导致小数精度发生损失的情况。这也就是解释了为什么浮点数没有办法用二进制精确表示。
就比如说十进制下的 0.2 就没办法精确转换成二进制小数:
1
2
3
4
5
6
7
8 // 0.2 转换为二进制数的过程为,不断乘以 2,直到不存在小数为止,
// 在这个计算过程中,得到的整数部分从上到下排列就是二进制的结果。
0.2 * 2 = 0.4 -> 0
0.4 * 2 = 0.8 -> 0
0.8 * 2 = 1.6 -> 1
0.6 * 2 = 1.2 -> 1
0.2 * 2 = 0.4 -> 0(发生循环)
...
《阿里巴巴 Java 开发手册》中提到:浮点数之间的等值判断,基本数据类型不能用 == 来比较,包装数据类型不能用 equals 来判断。
BigDecimal的创建
- new BigDecimal(String val)
- BigDecimal.valueOf(double val)
《阿里巴巴 Java 开发手册》禁止使用 new BigDecimal(double val) 的方式创建BigDecimal,因为也会存在精度损失,例如:new BigDecimal(0.1F),实际存储的是0.10000000149
BigDecimal的运算
1 | BigDecimal a = new BigDecimal("1.0"); |
1 | public BigDecimal divide(BigDecimal divisor, int scale, RoundingMode roundingMode) { |
BigDecimal等值比较
使用 compareTo()
方法,不能使用 equals() 方法,因为 equals() 方法不仅仅会比较值的大小(value)还会比较精度(scale),而 compareTo() 方法比较的时候会忽略精度。
Java 魔法类 Unsafe
Unsafe
是位于 sun.misc
包下的一个类,主要提供一些用于执行低级别、不安全操作的方法,如直接访问系统内存资源、自主管理内存资源等,这些方法在提升 Java 运行效率、增强 Java 语言底层资源操作能力方面起到了很大的作用。
但由于 Unsafe
类使 Java 语言拥有了类似 C 语言指针一样操作内存空间的能力,这无疑也增加了程序发生相关指针问题的风险。在程序中过度、不正确使用 Unsafe
类会使得程序出错的概率变大,使得 Java 这种安全的语言变得不再“安全”,因此对 Unsafe
的使用一定要慎重。
另外,Unsafe
提供的这些功能的实现需要依赖本地方法(Native Method),对于同一本地方法,不同的操作系统可能会通过不同的方式来实现
为什么要使用本地方法呢?
- 与Java环境外交互
- 与操作系统交互
Unsafe 创建
sun.misc.Unsafe
部分源码如下:
1 | public final class Unsafe { |
Unsafe
类为一单例实现,提供静态方法 getUnsafe
获取 Unsafe
实例。这个看上去貌似可以用来获取 Unsafe
实例。但是,当我们直接调用这个静态方法的时候,会抛出 SecurityException
异常
为什么 public static
方法无法被直接调用呢?
这是因为在getUnsafe
方法中,会对调用者的classLoader
进行检查,判断当前类是否由Bootstrap classLoader
加载,如果不是的话那么就会抛出一个SecurityException
异常。也就是说,只有启动类加载器加载的类才能够调用 Unsafe 类中的方法,来防止这些方法在不可信的代码中被调用。
为什么要对 Unsafe 类进行这么谨慎的使用限制呢?
Unsafe
提供的功能过于底层(如直接访问系统内存资源、自主管理内存资源等),安全隐患也比较大,使用不当的话,很容易出现很严重的问题。
获取Unsafe的两个方法:
利用反射获得 Unsafe 类中已经实例化完成的单例对象
theUnsafe
。1
2
3
4
5
6
7
8
9
10private static Unsafe reflectGetUnsafe() {
try {
Field field = Unsafe.class.getDeclaredField("theUnsafe");
field.setAccessible(true);
return (Unsafe) field.get(null);
} catch (Exception e) {
log.error(e.getMessage(), e);
return null;
}
}通过 Java 命令行命令
-Xbootclasspath/a
把调用 Unsafe 相关方法的类 A 所在 jar 包路径追加到默认的 bootstrap 路径中1
java -Xbootclasspath/a: ${path} // 其中path为调用Unsafe相关方法的类所在jar包路径
Unsafe 功能
概括的来说,Unsafe
类实现功能可以被分为下面 8 类:
- 内存操作
- 内存屏障 (JMM、volatile、内存屏障)
- 对象操作
- 数据操作
- CAS操作 (CAS、AutomicXXX)
- 线程调度 (LockSupport.park()/unpark())
- Class 操作
- 系统信息
Java SPI机制
SPI 即 Service Provider Interface ,字面意思就是:“服务提供者的接口”,我的理解是:专门提供给服务提供者或者扩展框架功能的开发者去使用的一个接口。
SPI 将服务接口和具体的服务实现分离开来,将服务调用方和服务实现者解耦,能够提升程序的扩展性、可维护性。修改或者替换服务实现并不需要修改调用方。
很多框架都使用了 Java 的 SPI 机制,比如:Spring 框架、数据库加载驱动、日志接口、以及 Dubbo 的扩展实现等等。
SPI 与 API 的区别:
SPI 是 Service Provider Interface,主要用于框架中,框架定义好接口,不同的使用者有不同的需求,因此需要有不同的实现,而 SPI 就通过定义一个特定的位置,Java SPI 约定在 Classpath 下的 META-INF/services/ 目录里创建一个以服务接口命名的文件,然后文件里面记录的是此 jar 包提供的具体实现类的全限定名。
所以就可以通过接口找到对应的文件,获取具体的实现类然后加载即可,做到了灵活的替换具体的实现类。
Java集合
Java中的集合主要分为以下两大接口:
- Collection
- Map
这里只写一点点,集合源码常见面试题和源码分析见 JavaGuide《Java集合》
HashMap
HashMap的扩容机制
HashMap未指定容量时,初始容量为16,负载因子为 0.75。当数量(size)超过了 16 * 0.75 = 12 就需要将当前 16 的容量进行扩容,每次扩容为原来的2倍;当指定容量时,初始容量为大于等于指定容量的2^n^
HashMap中table数组的最大长度为1<<30,也就是2^30^,就不能再扩容了
哈希冲突的解决办法:
JDK1.7及以前,哈希冲突时只采用链表来存储
JDK1.8 之后在解决哈希冲突时有了较大的变化,当链表长度大于等于阈值(默认为 8)(将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树)时,将链表转化为红黑树,以减少搜索时间。
当链表长度小于等于阈值(默认为 6)时,会将红黑树退化为链表
HashMap 的长度为什么是 2 的幂次方?
Hash值的取值范围是-2147483648 到 2147483647,前后近40亿,不可能直接存,需要进行取模运算hash%length
但是,当length是2的n次方时,可以使用与运算hash&(length-1)取代取模运算,二进制位操作&与运算的速度更快
即当length 是 2 的 n 次方时,hash%length==hash&(length-1)
HashMap put元素图示:(图源自 JavaGuide)
JDK1.7的put流程,相比JDK1.8,只是少了是否转换为红黑树,以及链表最终采用的是头插法、
HashMap扩容方法 resize():
源码解析见 JavaGuide《resize 方法》》进行扩容,会伴随着一次 rehash 分配,并且会遍历 hash 表中所有的元素,是非常耗时的。在编写程序中,要尽量避免 resize。resize 方法实际上是将 table 初始化和 table 扩容 进行了整合,底层的行为都是给 table 赋值一个新的数组。
扩容后的数据迁移过程
JDK7的元素迁移:
JDK7中,HashMap的内部数据保存的都是链表。因此逻辑相对简单:在准备好新的数组后,map会遍历数组的每个“桶”,然后遍历桶中的每个Entity,重新计算其hash值(也有可能不计算),找到新数组中的对应位置,以头插法插入新的链表。 这里有几个注意点:
- 因为是头插法,因此新旧链表的元素位置会发生转置现象。
- 元素迁移的过程中在多线程情境下有可能会触发死循环(无限进行链表反转)
JDK8的元素迁移:
java1.8+在扩容时,不需要重新计算元素的hash进行元素迁移。而是用原先位置key的hash值与旧数组的长度(oldCap)进行”与”操作。
- 如果结果是0,那么当前元素的桶位置不变。
- 如果结果为1,那么桶的位置就是原位置+原数组长度 值得注意的是:为了防止java1.7之前元素迁移头插法在多线程是会造成死循环,java1.8+后使用尾插
HashMap的扰动函数
扰动函数指的就是 HashMap的内部方法hash()方法。内部调用key的hashCode()方法,也就是扰动函数是为了防止一些实现比较差的 hashCode() 方法 换句话说使用扰动函数之后可以减少碰撞。
1 | //JDK1.8的扰动函数: |
相比于 JDK1.8 的 hash 方法 ,JDK 1.7 的 hash 方法的性能会稍差一点点,因为毕竟扰动了 4 次。
HashMap 多线程操作导致死循环问题
JDK1.7 及之前版本的 HashMap
在多线程环境下扩容操作可能存在Entry 链死循环和数据丢失问题
这是由于当一个桶位中有多个元素需要进行扩容时,多个线程同时对链表进行操作,头插法可能会导致链表中的节点指向错误的位置,从而形成一个环形链表,进而使得查询元素的操作陷入死循环无法结束。
为了解决这个问题,JDK1.8 版本的 HashMap 采用了尾插法而不是头插法来避免链表倒置,使得插入的节点永远都是放在链表的末尾,避免了链表中的环形结构。但是还是不建议在多线程下使用 HashMap
,因为多线程下使用 HashMap
还是会存在数据覆盖的问题。并发环境下,推荐使用 ConcurrentHashMap
。
HashMap 为什么线程不安全?
JDK1.7 及之前版本,在多线程环境下,HashMap 扩容时会造成死循环和数据丢失的问题。
数据丢失这个在 JDK1.7 和 JDK 1.8 中都存在,这里以 JDK 1.8 为例进行介绍。
JDK 1.8 HashMap 采用数组 + 链表 + 红黑二叉树的数据结构,优化了 1.7 中数组扩容的方案,解决了 Entry 链死循环和数据丢失问题。但是多线程背景下,put 方法存在数据覆盖的问题。
多个键值对可能会被分配到同一个桶(bucket),并以链表或红黑树的形式存储。多个线程对 HashMap 的 put 操作会导致线程不安全,具体来说会有数据覆盖的风险。
举例:两个线程判断插入的元素发生哈希冲突,此时一个线程执行完哈希冲突判断后,时间片用完,另一个线程在此位置插入后,被恢复过来的线程覆盖数据,造成数据丢失
还有一种情况是这两个线程同时 put 操作导致 size 的值不正确,两个put 操作,但是 size 的值只增加了 1
HashMap的7种遍历方式及性能分析
参考自 HashMap 的 7 种遍历方式与性能分析,详细代码和测试请看这篇文章
HashMap 遍历从大的方向来说,可分为以下 4 类:
- 迭代器(Iterator)方式遍历;
- For Each 方式遍历;
- Lambda 表达式遍历(JDK 1.8+);
- Streams API 遍历(JDK 1.8+)。
但每种类型下又有不同的实现方式,因此具体的遍历方式又可以分为以下 7 种:
- 使用迭代器(Iterator)EntrySet 的方式进行遍历;
- 使用迭代器(Iterator)KeySet 的方式进行遍历;
- 使用 For Each EntrySet 的方式进行遍历;
- 使用 For Each KeySet 的方式进行遍历;
- 使用 Lambda 表达式的方式进行遍历;
- 使用 Streams API 单线程的方式进行遍历;
- 使用 Streams API 多线程的方式进行遍历。
结论:
两个entrySet > stream > 两个keySet > lambda
当遍历不存在阻塞时, parallelStream 的性能最低
当加入阻塞代码Thread.sleep(10)后, parallelStream 的性能才是最高的
为什么要引入红黑树,而不用其他树?
比如HashMap、TreeMap等结构为什么使用红黑树?
- 为什么不使用二叉排序树?问题主要出现在二叉排序树在添加元素的时候极端情况下会出现线性结构。比如由于二叉排序树左子树所有节点的值均小于根节点的特点,如果我们添加的元素都比根节点小,会导致左子树线性增长,这样就失去了用树型结构替换链表的初衷,导致查询时间增长。所以这是不用二叉查找树的原因。
- 为什么不使用平衡二叉树呢?红黑树不追求”完全平衡”,而AVL是严格平衡树,因此在增加或者删除节点的时候,根据不同情况,旋转的次数比红黑树要多。红黑树读取略逊于AVL,维护强于AVL,空间开销与AVL类似,内容极多时略优于AVL。
基本上主要的几种平衡树看来,红黑树有着良好的稳定性和完整的功能,性能表现也很不错,综合实力强,在诸如STL的场景中需要稳定表现。
ConcurrentHashMap
ConcurrentHashMap和HashTable
参考自JavaGuide《ConcurrentHashMap 线程安全的具体实现方式/底层具体实现》、
《ConcurrentHashMap源码分析》
结构:
HashTable:数组+链表 的形式,使用 一把synchronized 来保证线程安全,put和get都使用同一把锁,竞争激烈,默认初始大小为11,扩容为2n+1
ConcurrentHashMap:
JDK1.7以前:分段的数组+链表, Segment 数组 + HashEntry 数组 + 链表,Segment 的个数一旦初始化就不能改变,采用分段锁,每一把锁只锁一个Segment(使用的是ReentrantLock),Segment 数组的大小默认是 16,也就是说默认情况下可以同时支持 16 个线程并发写,最大为2^16^=65536。
JDK1.7 ConcurrentHashMap 初始化逻辑:
- 必要参数校验。
- 校验并发级别
concurrencyLevel
大小,如果大于最大值,重置为最大值。无参构造默认值是 16.- 寻找并发级别
concurrencyLevel
之上最近的 2 的幂次方值,作为初始化容量大小,默认是 16。- 记录
segmentShift
偏移量,这个值为【并行级别 = 2 的 N 次方】中的 N,在后面 Put 时计算位置时会用到。默认是 32 - sshift = 28.- 记录
segmentMask
,默认是 segmentsize - 1 = 16 -1 = 15,用于&运算.- 初始化
segments[0]
,默认大小为 2,负载因子 0.75,扩容阀值是 2*0.75=1.5,插入第二个值时才会进行扩容,扩容为原来的2倍。JDK1.7 ConcurrentHashMap put数据逻辑:
使用与运算&计算要 put 的 key 的位置,获取指定位置的
Segment
。如果指定位置的
Segment
为空,则初始化这个Segment
.初始化 Segment 流程:
- 检查计算得到的位置的
Segment
是否为 null.- 为 null 继续初始化,使用
Segment[0]
的容量和负载因子创建一个HashEntry
数组。- 再次检查计算得到的指定位置的
Segment
是否为 null.- 使用创建的
HashEntry
数组初始化这个 Segment.- 自旋判断计算得到的指定位置的
Segment
是否为 null,使用 CAS 在这个位置赋值为Segment
.
tryLock()
获取锁,获取不到使用scanAndLockForPut
方法继续获取。使用与运算&计算 put 的数据要放入的 index 位置,然后获取这个位置上的
HashEntry
。
然后看此位置上的HashEntry是否存在,不存在直接插入然后判断是否需要扩容,存在则遍历完链表无相等值就进行头插法插入,然后判断是否需要扩容。
如果要插入的位置之前已经存在,替换后返回旧值,否则返回 null。
JDK1.8开始:Node/TreeNode 数组 + 链表 / 红黑树,就像是优化过且线程安全的 HashMap,并发控制使用 synchronized 和 CAS 来操作,synchronized 只锁定当前链表或红黑二叉树的首节点,这样只要 hash 不冲突,就不会产生并发,就不会影响其他 Node 的读写,效率大幅提升。
TreeNode
是存储红黑树节点,被TreeBin
包装。TreeBin
通过root
属性维护红黑树的根结点,因为红黑树在旋转的时候,根结点可能会被它原来的子节点替换掉,在这个时间点,如果有其他线程要写这棵红黑树就会发生线程不安全问题,所以在ConcurrentHashMap
中TreeBin
通过waiter
属性维护当前使用这棵红黑树的线程,来防止其他线程的进入。
JDK1.8 ConcurrentHashMap的 初始化流程 和 put数据流程 和HashMap差不多,就是线程安全的HashMap
初始化时,有一个变量
sizeCtl
,它的值决定着当前的初始化状态。
- -1 说明正在初始化
- -N 说明有 N-1 个线程正在进行扩容
- 0 表示 table 初始化大小,如果 table 没有初始化
- >0 表示 table 扩容的阈值,如果 table 已经初始化。
put操作:
- 根据 key 计算出 hashcode 。
- 判断是否需要进行初始化。
- 即为当前 key 定位出的 Node,如果桶内为空表示当前位置可以写入数据,利用 CAS 尝试写入,失败则自旋保证成功。
- 如果当前位置的
hashcode == MOVED(即-1)
,则需要进行扩容。- 如果都不满足(即桶内不为空且不需要扩容),则利用 synchronized 锁判断是链表还是红黑树写入数据。
- 如果数量大于
TREEIFY_THRESHOLD
则要执行树化方法,在treeifyBin
中会首先判断当前数组长度 ≥64 时才会将链表转换为红黑树。
ConcurrentHashMap的k、v不能存储null,而HashMap可以
ConcurrentHashMap 的 key 和 value 不能为 null 主要是为了避免二义性。null 是一个特殊的值,表示没有对象或没有引用。
如果你用 null 作为键,那么你就无法区分这个键是否存在于 ConcurrentHashMap 中,还是根本没有这个键。
同样,如果你用 null 作为值,那么你就无法区分这个值是否是真正存储在 ConcurrentHashMap 中的,还是因为找不到对应的键而返回的。
HashMap 可以存储 null 的 key 和 value,但 null 作为键只能有一个,null 作为值可以有多个。如果传入 null 作为参数,HashMap的扰动函数就会返回 hash 值为 0 的位置的值。
为什么?
单线程环境下,不存在其他的线程修改该 HashMap的情况,所以可以通过 contains(key)来做判断是否存在这个键值对,从而做相应的处理,也就不存在二义性问题。
而多线程下,存在其他线程修改的情况,无法正确判定键值对是否存在
如果你确实需要在 ConcurrentHashMap 中使用 null 的话,可以使用一个特殊的静态空对象来代替 null。
1 | public static final Object NULL = new Object(); |
ConcurrentHashMap的复合操作
复合操作是指由多个基本操作(如put
、get
、remove
、containsKey
等)组成的操作,例如先判断某个键是否存在containsKey(key)
,然后根据结果进行插入或更新put(key, value)
。这种操作在执行过程中可能会被其他线程打断,导致结果不符合预期
那如何保证 ConcurrentHashMap 复合操作的原子性呢?
ConcurrentHashMap 提供了一些原子性的复合操作,如 putIfAbsent
、compute
、computeIfAbsent
、computeIfPresent
、merge
等。这些方法都可以接受一个函数作为参数,根据给定的 key 和 value 来计算一个新的 value,并且将其更新到 map 中。
1 | // 线程 A |
集合和数组的相互转换问题
集合转数组:
list.toArray(new String[0]);
1
2
3
4
5
- ```java
int[] ints = list.stream().mapToInt(v -> v).toArray();
Integer[] integers = list.stream().toArray();
A[] as = collect.stream().toArray(A[]::new);
数组转集合:
Arrays.asList(arr); List<int[]> list = Arrays.asList(new int[]{1,2});//不能是基本数据类型的数组
1
2
3
4
5
6
7
但是这种方法有很多限制条件,**arr数组只能是引用类型对象的数组,不能是基本数据类型的数组**,否则将会把整个基本类型数组存入到get(0)的位置;并且,就算是对象数组,返回的list是java.util.Arrays$ArrayList,并不是java.util.ArrayList,不能调用**add/remove/clear**等修改集合的方法,否则会抛出 UnsupportedOperationException 异常。
- ```java
//使用stream(推荐)
List<Integer> collect = Arrays.stream(nums).boxed().collect(Collectors.toList());//基本类型
List<Integer> collect = Arrays.stream(nums).collect(Collectors.toList());//引用类型
ArrayList的扩容机制
初始容量:
- 无参构造
- JDK1.8:用初始容量10构造一个空列表,向数组中添加第一个元素时,数组容量扩为 10
- JDK1.6:直接创建了长度是 10 的 Object[] 数组 elementData
- 指定初始容量initialCapacity
- 创建initialCapacity大小的数组
- 指定Collection的子实现类
- 容量为collection.length()
以无参构造的扩容为例:
- 开始时插入第一个元素,elementData.length == 0,minCapacity == 10,,第一个if中newCapacity(0) - minCapacity(10) < 0,所以newCapacity=10,grow()方法扩容为10
- 后来,当要插入第11个元素时,minCapacity == 11,第一个if就不满足,扩容为原来的1.5倍
- 当容量超大时(newCapacity - MAX_ARRAY_SIZE > 0),若minCapacity 也> MAX_ARRAY_SIZE,则扩容为Integer.MAX_VALUE,否则扩容为MAX_ARRAY_SIZE
还可以使用ensureCapacity(int minCapacity)方法手动扩容集合
grow()方法源码:
1 | //要分配的最大数组大小 |
拓展:ArrayList中很多方法都用得到了System.arraycopy() 和 Arrays.copyOf()方法
System.arraycopy():
可以用于自拷贝,比如从index处向后移动等
1
2
3
4
5
6
7
8
9
10
11
12 // 我们发现 arraycopy 是一个 native 方法,接下来我们解释一下各个参数的具体意义
/**
* 复制数组
* @param src 源数组
* @param srcPos 源数组中的起始位置
* @param dest 目标数组
* @param destPos 目标数组中的起始位置
* @param length 要复制的数组元素的数量
*/
public static native void arraycopy(Object src, int srcPos,
Object dest, int destPos,
int length);Arrays.copyOf():
内部调用System.arraycopy(),可用于进行数组的扩容
1
2
3
4
5
6
7
8 public static int[] copyOf(int[] original, int newLength) {
// 申请一个新的数组
int[] copy = new int[newLength];
//调用System.arraycopy,将源数组中的数据进行拷贝,并返回新的数组
System.arraycopy(original, 0, copy, 0,
Math.min(original.length, newLength));
return copy;
}
补充:LinkedList的数据结构:
JDK1.6:为双向循环链表
JDK1.7:为双向链表,去掉了循环
LinkedHashMap
LinkedHashMap
是 Java 提供的一个集合类,它继承自 HashMap
,并在 HashMap
基础上维护一条双向链表,使得具备如下特性:
- 支持迭代器遍历时会按照插入顺序有序进行迭代。
- 第三个构造参数 accessOrder 设置为true,支持按照元素访问顺序排序,适用于封装 LRU 缓存工具。
- 因为内部使用双向链表维护各个节点,所以遍历时的效率和元素个数成正比,相较于和容量成正比的 HashMap 来说,迭代效率会高很多。
双向链表的实现是LinkedHashMap.Entry继承了单向链表Node,然后新增了before和after两个属性,再由TreeNode继承Entry
相比HashMap的插入、删除:重写了 afterNodeRemoval、afterNodeInsertion 这两个方法,使在HashMap的插入、删除后调用对应的after方法,维护双向链表,实现按照插入顺序访问
相比HashMap的获取:重写了 afterNodeAccess 方法,使在get一个元素后,将该元素放到双向链表的队尾,实现按照访问顺序排序,并且可以借此基础上,再重写 removeEldestEntry 方法,来实现LRU缓存
常见面试题:
什么是 LinkedHashMap?
LinkedHashMap
是 Java 集合框架中HashMap
的一个子类,它继承了HashMap
的所有属性和方法,并且在HashMap
的基础重写了afterNodeRemoval
、afterNodeInsertion
、afterNodeAccess
方法。使之拥有顺序插入和访问有序的特性。LinkedHashMap 如何按照插入顺序迭代元素?
LinkedHashMap
按照插入顺序迭代元素是它的默认行为。LinkedHashMap
内部维护了一个双向链表,用于记录元素的插入顺序。因此,当使用迭代器迭代元素时,元素的顺序与它们最初插入的顺序相同。LinkedHashMap 如何按照访问顺序迭代元素?
LinkedHashMap
可以通过构造函数中的accessOrder
参数指定按照访问顺序迭代元素。当accessOrder
为 true 时,每次访问一个元素时,该元素会被移动到链表的末尾,因此下次访问该元素时,它就会成为链表中的最后一个元素,从而实现按照访问顺序迭代元素。LinkedHashMap 如何实现 LRU 缓存?
将
accessOrder
设置为 true 并重写removeEldestEntry
方法当链表大小超过容量时返回 true,使得每次访问一个元素时,该元素会被移动到链表的末尾。一旦插入操作让removeEldestEntry
返回 true 时,视为缓存已满,LinkedHashMap
就会将链表首元素移除,由此我们就能实现一个 LRU 缓存。LinkedHashMap 和 HashMap 有什么区别?
LinkedHashMap
和HashMap
都是 Java 集合框架中的 Map 接口的实现类。它们的最大区别在于迭代元素的顺序。HashMap
迭代元素的顺序是不确定的,而LinkedHashMap
提供了按照插入顺序或访问顺序迭代元素的功能。此外,LinkedHashMap
内部维护了一个双向链表,用于记录元素的插入顺序或访问顺序,而HashMap
则没有这个链表。因此,LinkedHashMap
的插入性能可能会比HashMap
略低,但它提供了更多的功能并且迭代效率相较于HashMap
更加高效。
实现LRU缓存:
1 | public class LRUCache<K, V> extends LinkedHashMap<K, V> { |
CopyOnWriteArrayList
CopyOnWriteArrayList:当需要修改( add
,set
、remove
等操作) CopyOnWriteArrayList 的内容时,不会直接修改原数组,而是会先创建底层数组的副本,对副本数组进行修改,修改完之后再将修改后的数组赋值回去,这样就可以保证写操作不会影响读操作了。
写时复制技术COW(CopyOnWrite):对共享数据进行修改操作(增删改)时,会创建容器副本并对修改操作上锁,等修改完成后赋值回去,实现了读读不互斥、读写不互斥、写写互斥,只有写写才互斥。但是会造成数据的弱一致性
但是具有一定的缺点:
- 内存占用:每次写操作都需要复制一份原始数据,会占用额外的内存空间,在数据量比较大的情况下,可能会导致内存资源不足。
- 写操作开销:每一次写操作都需要复制一份原始数据,然后再进行修改和替换,所以写操作的开销相对较大,在写入比较频繁的场景下,性能可能会受到影响。
- 数据一致性问题:修改操作不会立即反映到最终结果中,还需要等待复制完成,这可能会导致一定的数据一致性问题。
PriorityQueue
Java中PriorityQueue实现了Queue接口,不允许放入null
元素;其通过堆实现,具体说是通过完全二叉树(complete binary tree)实现的小顶堆(任意一个非叶子节点的权值,都不大于其左右子节点的权值),也就意味着可以通过数组来作为PriorityQueue的底层实现。
小顶堆按照层序遍历进行编号:
父子节点的编号之间有如下关系:
- leftNo = parentNo*2+1
- rightNo = parentNo*2+2
- parentNo = (nodeNo-1)/2
通过上述三个公式,可以轻易计算出某个节点的父节点以及子节点的下标。这也就是为什么可以直接用数组来存储堆的原因。
阻塞队列
阻塞队列就是典型的生产者-消费者模型,它可以做到以下几点:
- 当阻塞队列数据为空时,所有的消费者线程都会被阻塞,等待队列非空。
- 当生产者往队列里填充数据后,队列就会通知消费者队列非空,消费者此时就可以进来消费。
- 当阻塞队列因为消费者消费过慢或者生产者存放元素过快导致队列填满时无法容纳新元素时,生产者就会被阻塞,等待队列非满时继续存放元素。
- 当消费者从队列中消费一个元素之后,队列就会通知生产者队列非满,生产者可以继续填充数据了。
常见API:
操作 | 抛异常 | 特殊值(T/F) | 阻塞 | 限时阻塞 |
---|---|---|---|---|
添加 | add() | offer() | put() | offer(val, timeout, unit) |
取出 | remove() | poll() | take() | poll(timeout, unit) |
检查 | element() | peek() | 无 | 无 |
drainTo(list):从队列中取出所有元素,并添加到 List 中,并返回元素个数,若没有元素则返回0;
ArrayBlockingQueue
初始化代码:
1 | // capacity 表示队列初始容量,fair 表示 锁的公平性 |
常见面试题:
ArrayBlockingQueue 是什么?它的特点是什么?
ArrayBlockingQueue
是BlockingQueue
接口的有界队列实现类,常用于多线程之间的数据共享,底层采用数组实现,从其名字就能看出来了。ArrayBlockingQueue
的容量有限,一旦创建,容量不能改变。为了保证线程安全,
ArrayBlockingQueue
的并发控制采用可重入锁ReentrantLock
,不管是插入操作还是读取操作,都需要获取到锁才能进行操作。并且,它还支持公平和非公平两种方式的锁访问机制,默认是非公平锁。ArrayBlockingQueue
虽名为阻塞队列,但也支持非阻塞获取和新增元素(例如poll()
和offer(E e)
方法),只是队列满时添加元素会抛出异常,队列为空时获取的元素为 null,一般不会使用。ArrayBlockingQueue 和 LinkedBlockingQueue 有什么区别?
ArrayBlockingQueue
和LinkedBlockingQueue
是 Java 并发包中常用的两种阻塞队列实现,它们都是线程安全的。不过,不过它们之间也存在下面这些区别:- 底层实现:
ArrayBlockingQueue
基于数组实现,而LinkedBlockingQueue
基于链表实现。 - 是否有界:
ArrayBlockingQueue
是有界队列,必须在创建时指定容量大小。LinkedBlockingQueue
创建时可以不指定容量大小,默认是Integer.MAX_VALUE
,也就是无界的。但也可以指定队列大小,从而成为有界的。 - 锁是否分离:
ArrayBlockingQueue
中的锁是没有分离的,即生产和消费用的是同一个锁;LinkedBlockingQueue
中的锁是分离的,即生产用的是putLock
,消费是takeLock
,这样可以防止生产者和消费者线程之间的锁争夺。 - 内存占用:
ArrayBlockingQueue
需要提前分配数组内存,而LinkedBlockingQueue
则是动态分配链表节点内存。这意味着,ArrayBlockingQueue
在创建时就会占用一定的内存空间,且往往申请的内存比实际所用的内存更大,而LinkedBlockingQueue
则是根据元素的增加而逐渐占用内存空间。
- 底层实现:
ArrayBlockingQueue 和 ConcurrentLinkedQueue 有什么区别?
ArrayBlockingQueue
和ConcurrentLinkedQueue
是 Java 并发包中常用的两种队列实现,它们都是线程安全的。不过,不过它们之间也存在下面这些区别:- 底层实现:
ArrayBlockingQueue
基于数组实现,而ConcurrentLinkedQueue
基于链表实现。 - 是否有界:
ArrayBlockingQueue
是有界队列,必须在创建时指定容量大小,而ConcurrentLinkedQueue
是无界队列,可以动态地增加容量。 - 是否阻塞:
ArrayBlockingQueue
支持阻塞和非阻塞两种获取和新增元素的方式(一般只会使用前者),ConcurrentLinkedQueue
是无界的,仅支持非阻塞式获取和新增元素。
- 底层实现:
ArrayBlockingQueue 的实现原理是什么?
ArrayBlockingQueue
的实现原理主要分为以下几点(这里以阻塞式获取和新增元素为例介绍):ArrayBlockingQueue
内部维护一个定长的数组用于存储元素。- 通过使用
ReentrantLock
锁对象对读写操作进行同步,即通过锁机制来实现线程安全。 - 通过两个
Condition
实现线程间的等待和唤醒操作。
这里再详细介绍一下线程间的等待和唤醒具体的实现(不需要记具体的方法,面试中回答要点即可):
- 当队列已满时,生产者线程会调用
notFull.await()
方法让生产者进行等待,等待队列非满时插入(非满条件)。 - 当队列为空时,消费者线程会调用
notEmpty.await()
方法让消费者进行等待,等待队列非空时消费(非空条件)。 - 当有新的元素被添加时,生产者线程会调用
notEmpty.signal()
方法唤醒正在等待消费的消费者线程。 - 当队列中有元素被取出时,消费者线程会调用
notFull.signal()
方法唤醒正在等待插入元素的生产者线程。
DelayQueue
DelayQueue
是 JUC 包(java.util.concurrent)
为我们提供的延迟队列,用于实现延时任务比如订单下单 15 分钟未支付直接取消。它是 BlockingQueue
的一种,底层是一个基于 PriorityQueue
实现的一个无界队列,是线程安全的。
四个核心成员变量:
1 | //可重入锁,实现线程安全的关键 |
常见面试题:
DelayQueue 的实现原理是什么?
DelayQueue
底层是使用优先队列PriorityQueue
来存储元素,而PriorityQueue
采用二叉小顶堆的思想确保值小的元素排在最前面,这就使得DelayQueue
对于延迟任务优先级的管理就变得十分方便了。同时DelayQueue
为了保证线程安全还用到了可重入锁ReentrantLock
,确保单位时间内只有一个线程可以操作延迟队列。最后,为了实现多线程之间等待和唤醒的交互效率,DelayQueue
还用到了Condition
,通过Condition
的await
和signal
方法完成多线程之间的等待唤醒。DelayQueue 的实现是否线程安全?
DelayQueue
的实现是线程安全的,它通过ReentrantLock
实现了互斥访问和Condition
实现了线程间的等待和唤醒操作,可以保证多线程环境下的安全性和可靠性。DelayQueue 的使用场景有哪些?
DelayQueue
通常用于实现定时任务调度和缓存过期删除等场景。在定时任务调度中,需要将需要执行的任务封装成延迟任务对象,并将其添加到DelayQueue
中,DelayQueue
会自动按照剩余延迟时间进行升序排序(默认情况),以保证任务能够按照时间先后顺序执行。对于缓存过期这个场景而言,在数据被缓存到内存之后,我们可以将缓存的 key 封装成一个延迟的删除任务,并将其添加到
DelayQueue
中,当数据过期时,拿到这个任务的 key,将这个 key 从内存中移除。DelayQueue 中 Delayed 接口的作用是什么?
Delayed
接口定义了元素的剩余延迟时间(getDelay
)和元素之间的比较规则(该接口继承了Comparable
接口)。若希望元素能够存放到DelayQueue
中,就必须实现Delayed
接口的getDelay()
方法和compareTo()
方法,否则DelayQueue
无法得知当前任务剩余时长和任务优先级的比较。DelayQueue 和 Timer/TimerTask 的区别是什么?
DelayQueue
和Timer/TimerTask
都可以用于实现定时任务调度,但是它们的实现方式不同。DelayQueue
是基于优先级队列和堆排序算法实现的,可以实现多个任务按照时间先后顺序执行;而Timer/TimerTask
是基于单线程实现的,只能按照任务的执行顺序依次执行,如果某个任务执行时间过长,会影响其他任务的执行。另外,DelayQueue
还支持动态添加和移除任务,而Timer/TimerTask
只能在创建时指定任务。
使用案例:
1 | /** |
TreeMap
参考自 TreeMap 源码解析,下面的图也来自其中
TreeMap实现了SortedMap接口,也就是说会按照key
的大小顺序对Map中的元素进行排序,key
大小的评判可以通过其本身的自然顺序(natural ordering),也可以通过构造时传入的比较器(Comparator)。
TreeMap底层通过红黑树(Red-Black tree)实现,也就意味着containsKey()
, get()
, put()
, remove()
都有着log(n)
的时间复杂度。
出于性能原因,TreeMap是非同步的(not synchronized),如果需要在多线程环境使用,需要程序员手动同步;或者通过如下方式将TreeMap包装成(wrapped)同步的:
1 | SortedMap m = Collections.synchronizedSortedMap(new TreeMap(...)); |
红黑树是一种近似平衡的二叉查找树,它能够确保任何一个节点的左右子树的高度差不会超过二者中较低那个的一倍。具体来说,红黑树是满足如下条件的二叉查找树(binary search tree):
红黑树四条规则:
- 每个节点要么是红色,要么是黑色。
- 根节点必须是黑色
- 红色节点不能连续(也即是,红色节点的孩子和父亲都不能是红色)。
- 对于每个节点,从该点至
null
(树尾端)的任何路径,都含有相同个数的黑色节点。(新插入的节点一定为叶子节点,且必须为红色)在树的结构发生改变时(插入或者删除操作),往往会破坏上述条件3或条件4,**需要通过调整(颜色调整、结构调整)**使得查找树重新满足红黑树的约束条件。
结构调整:
左旋的过程是将x
的右子树绕x
逆时针旋转,使得x
的右子树成为x
的父亲,同时修改相关节点的引用。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18 //TreeMap中左旋代码如下:
private void rotateLeft(Entry<K,V> p) {
if (p != null) {
Entry<K,V> r = p.right;
p.right = r.left;
if (r.left != null)
r.left.parent = p;
r.parent = p.parent;
if (p.parent == null)
root = r;
else if (p.parent.left == p)
p.parent.left = r;
else
p.parent.right = r;
r.left = p;
p.parent = r;
}
}右旋的过程是将
x
的左子树绕x
顺时针旋转,使得x
的左子树成为x
的父亲,同时修改相关节点的引用。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16 //TreeMap中右旋代码如下:
private void rotateRight(Entry<K,V> p) {
if (p != null) {
Entry<K,V> l = p.left;
p.left = l.right;
if (l.right != null) l.right.parent = p;
l.parent = p.parent;
if (p.parent == null)
root = l;
else if (p.parent.right == p)
p.parent.right = l;
else p.parent.left = l;
l.right = p;
p.parent = l;
}
}寻找某节点的后继节点:
- t的右子树不空,则t的后继是其右子树中最小的那个元素。
- t的右孩子为空,则t的后继是其第一个向左走的祖先。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19 //TreeMap中寻找节点后继的代码如下:
static <K,V> TreeMap.Entry<K,V> successor(Entry<K,V> t) {
if (t == null)
return null;
else if (t.right != null) {// 1. t的右子树不空,则t的后继是其右子树中最小的那个元素
Entry<K,V> p = t.right;
while (p.left != null)
p = p.left;
return p;
} else {// 2. t的右孩子为空,则t的后继是其第一个向左走的祖先
Entry<K,V> p = t.parent;
Entry<K,V> ch = t;
while (p != null && ch == p.right) {
ch = p;
p = p.parent;
}
return p;
}
}
上面是红黑树的左旋、右旋、查找后继节点的思路和代码,由于TreeMap底层是由红黑树实现,所以在TreeMap进行put和remove时,都会发生红黑树结构的改变,这时我们就需要通过 颜色调整 和 结构调整 来保证上面列举出的红黑树的四条规则。
具体讲解:TreeMap方法剖析,下面只是我的概括
查找get:内部调用getEntry(),根据二叉搜索树的性质,小于就往左找,大于就往右找
插入put:先查找有无相同key的节点,若有则覆盖,若没有则在叶子节点处插入,并且根据规则4,新插入节点为红色,然后调用fixAfterInsertion()方法进行结构调整
fixAfterInsertion()的思路:设新插入节点为x,父节点p,夫夫节点pp,叔父节点y(即pp的另一个子节点)
while (x != null && x != root && x.parent.color == RED){//违反规则3,有连续的红色
- 若p是pp的左孩子
- 如果y是红色,则将y设置为黑色,p设置为黑色,pp设置为红色
- 如果y是黑色
- x是p的右孩子,左旋p的右子树
- x是p的左孩子,右旋p的左子树
- 若p是pp的右孩子
- 如果y是红色,则将y设置为黑色,p设置为黑色,pp设置为红色
- 如果y是黑色
- x是p的右孩子,左旋p的右子树
- x是p的左孩子,右旋p的左子树
}
- 若p是pp的左孩子
删除remove:也是在删除后,若删除的是红节点,则不会违反规则,若删除的是黑节点,则违反规则4,调用fixAfterDeletion()方法进行结构调整,具体思路就不再阐述,看上面的源码解析链接
Java IO
Java IO流基础
InputStream
/Reader
: 所有的输入流的基类,前者是字节输入流,后者是字符输入流。OutputStream
/Writer
: 所有输出流的基类,前者是字节输出流,后者是字符输出流。
详细讲解:
InputStream 字节输入流
常用子类:FileInputStream
1
2
3
4
5InputStream fis = new FileInputStream("input.txt"));
int content;
while ((content = fis.read()) != -1) {//或者read byte数组
System.out.print((char) content);
}常用子类:BufferedInputStream(使用 装饰器模式 加强InputStream:继承又组合InputStream)
1
2BufferedInputStream bufferedInputStream = new BufferedInputStream(new FileInputStream("input.txt"));
String result = new String(bufferedInputStream.readAllBytes());读取特定数据类型子类:DataInputStream
1
2
3
4
5
6//必须将fileInputStream作为构造参数才能使用
DataInputStream dataInputStream = new DataInputStream(new FileInputStream("input.txt"));
//可以读取任意具体的类型数据
dataInputStream.readBoolean();
dataInputStream.readInt();
dataInputStream.readUTF();ObjectInputStream,从输入流中读取 Java 对象(反序列化)
1
2ObjectInputStream input = new ObjectInputStream(new FileInputStream("object.data"));
MyClass object = (MyClass) input.readObject();
OutputStream 字节输出流
常用子类:FileOutputStream
1
2
3FileOutputStream output = new FileOutputStream("output.txt");
byte[] array = "JavaGuide".getBytes();
output.write(array);常用子类:BufferedOutputStream(使用 装饰器模式 加强OutputStream:继承又组合OutputStream)
1
2FileOutputStream fileOutputStream = new FileOutputStream("output.txt");
BufferedOutputStream bos = new BufferedOutputStream(fileOutputStream)写出特定数据类型子类:DataOutputStream
1
2
3
4DataOutputStream dataOutputStream = new DataOutputStream(new FileOutputStream("out.txt"));
// 输出任意数据类型
dataOutputStream.writeBoolean(true);
dataOutputStream.writeByte(1);ObjectOutputStream,对象写入到输出流(序列化)
1
2
3ObjectOutputStream output = new ObjectOutputStream(new FileOutputStream("file.txt")
Person person = new Person("Guide哥", "JavaGuide作者");
output.writeObject(person);
字符流帮助我们将字节转换为字符,并且不需要关心编码问题
字符流默认采用的是 Unicode 编码,我们可以通过构造方法自定义编码。
常用字符编码所占字节数?utf8 :英文占 1 字节,中文占 3 字节,unicode:任何字符都占 2 个字节,gbk:英文占 1 字节,中文占 2 字节Reader 字符输入流
常用子类:FileReader extends InputStreamReader extends Reader
InputStreamReader是字节流与字符流之间的桥梁(使用 适配器模式 ,适配器组合源类,继承目标类)
1
2
3
4
5FileReader fileReader = new FileReader("input.txt");
int content;
while ((content = fileReader.read()) != -1) {//还可以read char数组
System.out.print((char) content);
}BufferedReader
Writer 字符输出流
常用子类:**FileWriter ** extends OutputStreamReader extends Writer
OutputStreamReader是字节流与字符流之间的桥梁(使用 适配器模式 ,适配器组合源类,继承目标类)
1
2Writer output = new FileWriter("output.txt");
output.write("你好,我是Guide。");BufferedWriter
其它:
打印流:PrintStream 和 PrintWriter,分别是OutPutStream和Writer的子类
System.out系统打印流属于字节打印流 PrintStream
随机访问流:RandomAccessFile
1
2
3
4
5
6
7
8
9
10/* 构造方法 */
// openAndDelete 参数默认为 false 表示打开文件并且这个文件不会被删除
public RandomAccessFile(File file, String mode)
throws FileNotFoundException {
this(file, mode, false);
}
// 私有方法
private RandomAccessFile(File file, String mode, boolean openAndDelete) throws FileNotFoundException{
// 省略大部分代码
}读写模式mode主要有下面四种:
r
: 只读模式。rw
: 读写模式rws
: 相对于rw
,rws
同步更新对“文件的内容”或“元数据”的修改到外部存储设备。rwd
: 相对于rw
,rwd
同步更新对“文件的内容”的修改到外部存储设备。
文件内容指的是文件中实际保存的数据,元数据则是用来描述文件属性比如文件的大小信息、创建和修改时间。
常用方法:通过read()、write()读写数据,通过seek()设置文件指针的偏移量,getFilePointer()获取文件指针当前的位置
Java IO中的设计模式
具体的设计模式的讲解看我的 设计模式 笔记
- 装饰器模式:BufferedInputStream/BufferedOutputStream
- 适配器模式:InputStreamReader/OutputStreamWriter
- 工厂模式:NIO 中大量用到了工厂模式
- 观察者模式:NIO 中的文件目录监听服务使用到了观察者模式(Java NIO实际是IO多路复用,需要使用事件监听机制进行事件派发)
Java IO模型
UNIX 系统下, IO 模型一共有 5 种:
- 同步阻塞 I/O(BIO)
- 同步非阻塞 I/O(NIO)
- I/O 多路复用
- 信号驱动 I/O
- 异步 I/O(AIO)
同步IO和异步IO的区别就在于:数据拷贝的时候进程是否阻塞
阻塞IO和非阻塞IO的区别就在于:应用程序的调用是否立即返回
Java IO中常见的是BIO、NIO(很多人认为应该是IO多路复用)、AIO
BIO (Blocking I/O)
BIO 属于同步阻塞 IO 模型 。
同步阻塞 IO 模型中,应用程序发起 read 调用后,会一直阻塞,直到内核把数据拷贝到用户空间。
图源:《深入拆解Tomcat & Jetty》
NIO (Non-blocking/New I/O)
Java 中的 NIO 可以看作是 I/O 多路复用模型
提供了
Channel
,Selector
,Buffer
等抽象。NIO 中的 N 可以理解为 Non-blocking,不单纯是 New。它是支持面向缓冲的,基于通道的 I/O 操作方法。 对于高负载、高并发的(网络)应用,应使用 NIO 。同步非阻塞 IO 模型中,应用程序会一直发起 read 调用,等待数据从内核空间拷贝到用户空间的这段时间里,线程依然是阻塞的,直到在内核把数据拷贝到用户空间。通过轮询操作,避免了一直阻塞,但是非常消耗CPU资源
图源:《深入拆解Tomcat & Jetty》
IO 多路复用模型中,线程首先发起 select 调用,询问内核数据是否准备就绪,等内核把数据准备好了,用户线程再发起 read 调用。read 调用的过程(数据从内核空间 -> 用户空间)还是阻塞的。
IO多路复用常见的方式:select,poll,epoll,其中epoll性能最高(详细区别请看我的笔记 Redis 网络模型)IO 多路复用模型,通过减少无效的系统调用,减少了对 CPU 资源的消耗。
图源:《深入拆解Tomcat & Jetty》
Java NIO的实现更偏向于IO多路复用
AIO (Asynchronous I/O)
异步 IO 是基于事件和回调机制实现的,也就是应用操作之后会直接返回,不会堵塞在那里,当后台处理完成,操作系统会通知相应的线程进行后续的操作。
Java NIO详解
先提前说明,Java NIO实际上更偏向于IO多路复用,所以下面应该都把NIO替换为IO多路复用来理解
NIO中的核心组件
- Buffer(缓冲区):NIO 读写数据都是通过缓冲区进行操作的。读操作的时候将 Channel 中的数据填充到 Buffer 中,而写操作时将 Buffer 中的数据写入到 Channel 中。
- Channel(通道):Channel 是一个双向的、可读可写的数据传输通道,NIO 通过 Channel 来实现数据的输入输出。通道是一个抽象的概念,它可以代表文件、套接字或者其他数据源之间的连接。
- Selector(选择器):允许一个线程处理多个 Channel,基于事件驱动的 I/O 多路复用模型。所有的 Channel 都可以注册到 Selector 上,由 Selector 来分配线程来处理事件。
Buffer就是暂存数据的地方,
Channel常用来代表服务器与客户端之间的连接,可分别命名为serverChannel和clientChannel
Selector就是注册Channel的地方,根据事件监听机制,完成事件的派发
图源自JavaGuide
这三个核心组件的代码使用举例去JavaGuide看:NIO核心组件
NIO编程案例
实际上是 IO多路复用 epoll案例
Selector 可以监听以下四种事件类型:
SelectionKey.OP_ACCEPT
:表示通道接受连接的事件,这通常用于ServerSocketChannel
。SelectionKey.OP_CONNECT
:表示通道完成连接的事件,这通常用于SocketChannel
。SelectionKey.OP_READ
:表示通道准备好进行读取的事件,即有数据可读。SelectionKey.OP_WRITE
:表示通道准备好进行写入的事件,即可以写入数据。
1 | public class NioSelectorExample { |
看完这段代码,你在看我的笔记 Redis的网络模型时,对Redis中使用epoll来实现IO多路复用会熟悉很多,思路基本一致
NIO 零拷贝技术
零拷贝是提升 IO 操作性能的一个常用手段,像 ActiveMQ、Kafka 、RocketMQ、QMQ、Netty、ES、gRPC、Gateway等顶级开源项目都用到了零拷贝。
零拷贝主要是不用从 内核区域的缓存区 还要将数据拷贝(CPU拷贝)到 用户区域的缓冲区,可以减少CPU拷贝次数和上下文切换次数
传统文件传输:四次上下文切换,四次拷贝(其中两次DMA拷贝,两次CPU拷贝)
其中:用户的缓冲区是没有必要存在的
下图展示了各种零拷贝技术的对比图:
CPU 拷贝 | DMA 拷贝 | 系统调用 | 上下文切换 | |
---|---|---|---|---|
传统方法 | 2 | 2 | read+write | 4 |
mmap+write | 1 | 2 | mmap+write | 4 |
sendfile | 1 | 2 | sendfile | 2 |
sendfile + SG-DMA | 0 | 2 | sendfile | 2 |
用
mmap()
替换read()
系统调用函数:mmap() 系统调用函数会直接把内核缓冲区里的数据「映射」到用户空间,这样,操作系统内核与用户空间就不需要再进行任何的数据拷贝操作。只用 2次DMA拷贝 + 1次CPU拷贝,但因为还是mmap和write(2次系统调用),所以仍然有4次上下文切换
sendfile():替代前面的 read() 和 write() 这两个系统调用,这样就可以减少一次系统调用,也就减少了 2 次上下文切换的开销。
只用 2次DMA拷贝 + 1次CPU拷贝,只需要2次上下文切换(1次系统调用)
sendfile + DMA scatter-gather copy:若网卡支持 SG-DMA(The Scatter-Gather Direct Memory Access)(可通过ethtool -k eth0 | grep scatter-gather命令进行查看),可以通过SG-DMA拷贝直接将内核缓冲区的数据拷贝到网卡,而不需要经过CPU拷贝
只用 2次DMA拷贝(其中一次是SG-DMA拷贝),和2次上下文切换(1次系统调用)
上图中内核态的缓冲区指的就是磁盘高速缓存(PageCache),PageCache 来缓存最近被访问的数据,使用了「预读功能」。
零拷贝技术是基于 PageCache 的,PageCache 会缓存最近访问的数据,提升了访问缓存数据的性能,同时,为了解决机械硬盘寻址慢的问题,它还协助 I/O 调度算法实现了 IO 合并与预读,这也是顺序读比随机读性能好的原因。这些优势,进一步提升了零拷贝的性能。
但是,在传输大文件(GB 级别的文件)的时候,PageCache被占满 会不起作用,并且大文件无法利用到缓存,那就白白浪费 DMA 多做的一次数据拷贝,造成性能的降低,即使使用了 PageCache 的零拷贝也会损失性能。所以在高并发的场景下,针对大文件的传输的方式,应该使用「异步 I/O + 直接 I/O」来替代零拷贝技术。
直接 I/O 应用场景常见的两种:
- 应用程序已经实现了磁盘数据的缓存,那么可以不需要 PageCache 再次缓存,减少额外的性能损耗。在 MySQL 数据库中,可以通过参数设置开启直接 I/O,默认是不开启;
- 传输大文件的时候,由于大文件难以命中 PageCache 缓存,而且会占满 PageCache 导致「热点」文件无法充分利用缓存,从而增大了性能开销,因此,这时应该使用直接 I/O。
Java 对零拷贝的支持:
MappedByteBuffer
是 NIO 基于内存映射(mmap
)这种零拷⻉⽅式的提供的⼀种实现,底层实际是调用了 Linux 内核的mmap
系统调用。它可以将一个文件或者文件的一部分映射到内存中,形成一个虚拟内存文件,这样就可以直接操作内存中的数据,而不需要通过系统调用来读写文件。FileChannel
的transferTo()/transferFrom()
是 NIO 基于发送文件(sendfile
)这种零拷贝方式的提供的一种实现,底层实际是调用了 Linux 内核的sendfile
系统调用。它可以直接将文件数据从磁盘发送到网络,而不需要经过用户空间的缓冲区。关于FileChannel
的用法可以看看这篇文章:Java NIO 文件通道 FileChannel 用法。
如果我们需要使用 NIO 构建网络程序的话,不建议直接使用原生 NIO,编程复杂且功能性太弱,推荐使用一些成熟的基于 NIO 的网络编程框架比如 Netty
Java IO模型代码演示
1 BIO
代码演示:
通过打断点执行,发现,服务端的accept以及read确实会阻塞,客户端没有响应操作之前,服务端的代码并不会向下执行
1 | public class BioServer { |
1 | public class BioClient { |
我们可以对BioServer进行一个优化,每次有新的请求进来,我们就异步的去接收消息:
1 | public class BioServer2 { |
但是:每次来一个请求,就创建一个连接,假设我们极端情况下,一台服务器下维持了1000条连接,但是这一千条连接都是没有数据发送的状态,那么我们的服务端就必须要有1000条线程去进行维持,并且都是处于read的阻塞状态。这不就是白白的资源浪费么?
2 NIO
代码演示:
IO多路复用的POLL模型:
1 | public class NioSimpleServer { |
上面代码只是IO多路复用中的SELECT/POLL模型,需要遍历所有socket来读取准备好的数据,并且不知道哪个socket的数据准备好了
如果我们的acceptSocket的数量很多,那么无效的遍历操作将会很多,将会很耗费CPU资源
IO多路复用的EPOLL模型:
使用epoll_create创建一个事件循环和要监听的红黑树,然后使用epoll_ctl往监听的红黑树上添加clientSocket,当数据准备完成时,我们将准备好的socket添加到reply_list中,使用epoll_wait通知数据准备完成,不会和SELECT/POLL模型一样无限制轮询
1 | public class NIOSelectorServer { |
3 AIO
代码演示:
1 | package io.aio; |
1 | public class AIOClient { |
为什么Netty没有使用AIO而是采用NIO的思路去进行设计?
- 不比nio快在Unix系统上
- 不支持数据报
- 不必要的线程模型(太多没什么用的抽象化)
总而言之,可以理解为,在Unix系统上AIO性能综合表现不如NIO好,所以Netty使用了NIO作为底层的核心。
Java 虚拟线程/协程 入门
优点
- 非常轻量级:可以在单个线程中创建成百上千个虚拟线程而不会导致过多的线程创建和上下文切换。
- 简化异步编程: 虚拟线程可以简化异步编程,使代码更易于理解和维护。它可以将异步代码编写得更像同步代码,避免了回调地狱(Callback Hell)。
- 减少资源开销: 相比于操作系统线程,虚拟线程的资源开销更小。本质上是提高了线程的执行效率,从而减少线程资源的创建和上下文切换。
缺点
- 是用户级线程,一旦阻塞,将会阻塞所有一对多线程模型上的该枝用户线程,并不能实现真正意义上的并发
- 不适用于计算密集型任务: 虚拟线程适用于 I/O 密集型任务,但不适用于计算密集型任务,因为密集型计算始终需要 CPU 资源作为支持。
- 依赖于语言或库的支持: 协程需要编程语言或库提供支持。不是所有编程语言都原生支持协程。比如 Java 实现的虚拟线程。
四种创建虚拟线程的方法
Java 21 已经正式支持虚拟线程,大家可以在官网下载使用,在使用上官方为了降低使用门槛,尽量复用原有的 Thread
类,让大家可以更加平滑的使用。
官方提供了以下四种方式创建虚拟线程:
- 使用
Thread.startVirtualThread()
创建 - 使用
Thread.ofVirtual()
创建 - 使用
ThreadFactory
创建
一些面试题
1 讲一下Java面向对象的特点
封装、继承、多态是Java面向对象编程的三大特点。
- 封装(Encapsulation):封装是面向对象编程的基本特点之一,它将数据和方法封装在对象内部,隐藏对象的内部实现细节,只暴露必要的接口供外部访问。通过封装,可以实现信息的隐藏和保护,提高代码的安全性和可靠性。
- 继承(Inheritance):继承是面向对象编程的重要特点,它允许一个类(子类)继承另一个类(父类)的属性和方法。子类可以重用父类的代码,并可以通过扩展和重写来增加新的功能或修改现有功能。继承提高了代码的复用性和可维护性,同时也体现了类与类之间的关系。
- 多态(Polymorphism):多态是面向对象编程的核心概念之一,它允许不同对象对同一消息作出不同的响应。在Java中,多态性通过方法重载和方法重写来实现。方法重载是指在同一个类中可以定义多个同名方法,但参数列表不同;方法重写是指子类可以重写父类的方法,实现不同的行为。多态性提高了代码的灵活性和扩展性,使得程序更易于理解和维护。
2 多态和重载有什么关系?
重载是一种编译时多态,而多态是一种运行时多态。两者都是实现多态性的方式,但发生的时间点和机制不同。
- 重载是指在同一个类中,方法名相同但参数列表不同的情况,通过参数个数、类型或顺序的不同来区分不同的方法。重载是静态绑定的概念,编译器在编译期间根据方法的参数列表来确定调用哪个方法。
- 多态是指同一个方法名可以在不同的类中有不同的实现,不同的子类可以重写父类的方法,通过父类引用指向子类对象时,根据实际对象的类型来确定调用哪个方法。多态是动态绑定的概念,运行时根据对象的实际类型来确定调用哪个方法。
3 重写和重载的区别
区别点 | 重载方法 | 重写方法 |
---|---|---|
发生范围 | 同一个类 | 子类 |
参数列表 | 必须修改 | 一定不能修改 |
返回类型 | 可修改 | 子类方法返回值类型应比父类方法返回值类型更小或相等 |
异常 | 可修改 | 子类方法声明抛出的异常类应比父类方法声明抛出的异常类更小或相等; |
访问修饰符 | 可修改 | 一定不能做更严格的限制(可以降低限制) |
发生阶段 | 编译期 | 运行期 |
4 sleep和wait的区别是什么?
相同点:
sleep()
和wait()
调用都会暂停当前线程并让出CPU
不同点:
- 定义位置不同:
sleep()
是线程类(Thread
)的方法;wait()
是顶级类Object
的方法; - 调用地方不同:
sleep
方法可以在任何地方使用;wait
方法则只能在同步方法或同步块中使用; - 锁资源释放方式不同:
sleep
方法只让出了CPU,没有释放同步资源锁!wait
方法则是指当前线程让自己暂时退让出同步资源锁,以便其他正在等待该资源的线程得到该资源进而运行,只有调用了notify
方法,之前调用wait()的线程才会解除wait状态,可以去参与竞争同步资源锁,进而得到执行。 - 恢复方式不同:sleep调用后停止运行期间仍持有同步锁,所以到时间会继续执行;wait调用会放弃对象锁,进入等待队列,待调用notify()/notifyAll()唤醒指定的线程或者所有线程,才会进入锁池,再次获得对象锁后才会进入运行状态,在没有获取对象锁之前不会继续执行;
- 异常捕获:sleep需要捕获或者抛出异常,而wait/notify/notifyAll则不需要。
5 为什么wait要包在同步块?
Java中的wait()
方法需要在同步块(synchronized block)中调用的原因是因为wait()
方法会释放对象的锁,而在同步块中可以确保线程在调用wait()
方法前持有对象的锁,从而避免多线程执行时的竞争和冲突。
具体原因如下:
- 线程安全:在同步块中调用
wait()
方法可以确保线程在调用wait()
前已经获取了对象的锁,避免多线程之间的竞争和数据不一致性问题。(已经获得锁才需要wait) - 对象监视器:
wait()
方法会释放对象的监视器(monitor),其他线程可以获取该对象的监视器并执行同步操作,确保线程之间的协作和同步。 - 唤醒机制:当调用
wait()
方法后,线程会进入等待状态,只有在其他线程调用notify()
或notifyAll()
方法唤醒该线程时,线程才会继续执行。在同步块中调用wait()
可以保证线程被正确唤醒。(被唤醒后可以直接进入同步块对应的锁池进行排队)
6 SPI
SPI 是 Service Provider Interface,服务提供者的接口,主要用于框架中,框架定义好接口,不同的使用者有不同的需求,因此需要有不同的实现,而 SPI 就通过定义一个特定的位置,Java SPI 约定在 Classpath 下的 META-INF/services/ 目录里创建一个以服务接口命名的文件,然后文件里面记录的是此 jar 包提供的具体实现类的全限定名。
所以就可以通过接口找到对应的文件,获取具体的实现类然后加载即可,做到了灵活的替换具体的实现类。
7 接口和抽象类有什么共同点和区别?
共同点:
- 都不能被实例化。
- 都可以包含抽象方法。
- 都可以有默认实现的方法(Java 8 可以用
default
关键字在接口中定义默认方法)。
区别:
- 接口主要用于对类的行为进行约束,你实现了某个接口就具有了对应的行为。抽象类主要用于代码复用,强调的是所属关系。
- 一个类只能继承一个类,但是可以实现多个接口。
- 接口中的成员变量只能是
public static final
类型的,不能被修改且必须有初始值,而抽象类的成员变量默认 default,可在子类中被重新定义,也可被重新赋值。 - JDK1.8之前,接口中的方法不能有方法体,JDK1.8之后,default方法和static方法可以有方法体。