Java 面试题
更新: 8/28/2025 字数: 0 字 时长: 0 分钟
接口和抽象类有什么区别?
在 Java 中,接口和抽象类都是实现抽象的机制,但它们在设计动机、功能和使用方式上存在显著差异。
设计动机和理念:
接口的设计是自顶向下的:
- 理念:我们首先从高层次考虑“某种行为规范”或“契约”。我们预先知道或约定某一组行为(即“能力”),然后基于这些行为来定义接口。任何需要具备这些行为的类,就去实现对应的接口。
- 思考过程:“我需要一个能跑(
Runnable
)的对象”、“我需要一个能比较(Comparable
)的对象”、“我需要一个能连接数据库(Connection
)的对象”。我们先定义这个“能做什么”的规范,再去考虑“谁来实现”以及“怎么实现”。 - 目的:主要用于定义行为契约,实现多态,解决多重继承问题,强调能力。
抽象类的设计是自底向上的:
- 理念:我们通常是先写了许多具体的类,在这些类的开发过程中,我们发现它们之间存在共同的属性和行为,有很多代码是可以复用的。为了消除代码冗余、提高代码复用性并提供一个统一的父类模板,我们将这些公共的逻辑和成员抽象封装到一个抽象类中。
- 思考过程:“我有很多形状类(
Circle
,Rectangle
,Triangle
),它们都有计算面积的方法,也有共同的颜色属性。我可以把这些共同的东西抽象成一个AbstractShape
类。” - 目的:主要用于提供一个通用实现模板,共享代码,实现部分功能,并强制子类完成未实现的抽象部分,强调父子关系。在实际项目开发中,很多时候抽象类是重构的产物。
总结自顶向下与自底向上:
- 自顶向下:先约定接口(规范),再由不同的类去实现它。关注点在“能做什么”。
- 自底向上:先有一些具体的类,发现共性后,将共性抽象成一个父类(抽象类)。关注点在“是什么”和“共同之处”。
其他主要区别:
除了设计理念上的差异,还有以下几个关键的技术区别:
方法实现:
- 接口:
- 在 Java 8 之前,接口中的方法默认是
public abstract
,不允许有方法实现。 - 从 Java 8 开始,接口可以包含
default
方法(有默认实现)和static
方法(静态实现)。 - Java 9 以后还允许
private
方法和private static
方法。
- 在 Java 8 之前,接口中的方法默认是
- 抽象类:
- 抽象类可以包含
abstract
方法(没有实现,必须由子类实现)和具体方法(有实现,子类可以直接继承或覆盖)。 - 允许子类继承并重用抽象类中的方法实现。
- 抽象类可以包含
- 接口:
构造函数和成员变量:
- 接口:
- 接口不能包含构造函数。
- 接口中的成员变量默认是
public static final
,即常量。
- 抽象类:
- 抽象类可以包含构造函数。虽然抽象类不能直接实例化,但它的构造函数会在子类实例化时被调用,用于初始化抽象类中定义的成员变量。
- 成员变量可以有不同的访问修饰符(如
private
,protected
,public
),并且可以不是常量。
- 接口:
多重继承:
- 接口:一个类可以实现多个接口。这是 Java 实现多重行为(多重实现)的关键机制,因为 Java 不支持类的多重继承。
- 抽象类:一个类只能继承一个抽象类(遵循 Java 的单继承原则)。
总结对比表:
特性 | 接口 (Interface) | 抽象类 (Abstract Class) |
---|---|---|
设计理念 | 自顶向下:先定义行为规范/契约 | 自底向上:先有具体类,再抽取共性作为模板 |
定义关键字 | interface | abstract class |
方法实现 | Java 8 前无实现;Java 8+ 可有 default /static 方法 | 可有抽象方法 (无实现) 和具体方法 (有实现) |
构造器 | 不能有 | 可以有 |
成员变量 | 默认 public static final (常量) | 可有各种修饰符 (private , protected , public ),可变变量 |
多重继承 | 一个类可实现多个接口 | 一个类只能继承一个抽象类 |
强调 | 行为、能力、契约、多态 | 模板、父子关系、代码复用 |
实例化 | 不能直接实例化 | 不能直接实例化 (但有构造器供子类调用) |
JDK 动态代理和 CGLIB 动态代理有什么区别?
- JDK 动态代理:
- 实现方式:基于 Java 的反射机制,在运行时为接口生成代理类。
- 要求:目标对象必须实现一个或多个接口。代理类会实现这些相同的接口。
- 优点:Java 原生支持,无需引入第三方库。
- 缺点:只能代理接口,无法代理没有实现接口的普通类或最终类(final class)。
- CGLIB 动态代理:
- 实现方式:基于 ASM 字节码生成库,在运行时动态生成目标类的子类。
- 要求:目标对象不能是 final 类或 final 方法(因为无法被继承和覆盖)。
- 优点:可以代理没有实现接口的普通类,以及最终类中非 final 方法。
- 缺点:需要引入第三方库(CGLIB 或 Spring AOP 内部集成的 ASM)。对于 final 类和 final 方法无能为力。
总结:JDK 代理是面向接口的代理,CGLIB 代理是面向类的代理(通过继承)。
你使用过 Java 的反射机制吗?如何应用反射?
反射机制允许程序在运行时检查或修改类的结构、方法和属性,而无需在编译时就知道这些信息。
具体应用举例:
- 框架开发:Spring 通过反射创建 Bean 实例,并调用其 setter 方法注入依赖。
- 动态代理:JDK 动态代理就是基于反射实现。
- 单元测试框架:JUnit 等框架通过反射来查找和执行测试方法。
- 序列化和反序列化:JSON 库(如 Gson、Jackson)通过反射来将 Java 对象转换为 JSON 字符串,或将 JSON 字符串解析为 Java 对象。
- 插件化开发:允许程序动态加载并执行外部定义的类。
- 配置文件解析:读取配置文件后,通过反射来调用相应的方法或设置属性。
如何应用反射:
要应用反射,通常涉及以下核心 API:
- 获取 Class 对象:
Class.forName("com.example.MyClass")
或myObject.getClass()
或MyClass.class
。 - 获取构造器并创建实例:
Class.getConstructor()
/getConstructors()
,然后Constructor.newInstance()
。 - 获取方法并调用:
Class.getMethod("methodName", paramTypes...)
/getMethods()
,然后Method.invoke(object, args...)
。 - 获取字段并操作:
Class.getField("fieldName")
/getFields()
,然后Field.get(object)
/Field.set(object, value)
。
说说 Java 中 HashMap 的原理?
想象一下你有一个巨大的衣柜,里面有很多抽屉,每个抽屉上都贴着一个标签(这个标签就是键,Key),你把衣服(这个衣服就是值,Value)放进对应的抽屉里。
HashMap
的原理也差不多:
标签变数字(哈希函数):当你给
HashMap
一个Key
(比如你要存一件红色的 T 恤,Key
就是“红色 T 恤”),它会先对这个Key
进行一番计算,把这个Key
变成一个数字(这个计算过程就叫做哈希函数,结果叫哈希值)。- 例子:“红色 T 恤”计算后可能变成数字
5
。
- 例子:“红色 T 恤”计算后可能变成数字
找抽屉位置(索引):这个数字可能很大,但你的衣柜抽屉是有限的。所以
HashMap
会用这个数字再做一次运算(通常是取模运算),把它映射到衣柜里某个具体的抽屉位置上。- 例子:数字
5
在你的HashMap
里可能对应第5
号抽屉。
- 例子:数字
放衣服进去(存储):
- 空抽屉:如果这个抽屉是空的,那太好了,直接把你的衣服放进去(
Key-Value
对)。 - 已有衣服(碰撞):如果这个抽屉里已经有衣服了(比如“蓝色衬衫”也计算到了第
5
号抽屉,这就叫哈希碰撞),HashMap
不会直接覆盖掉。它会把新的衣服挂到这个抽屉里已经有的衣服的后面,形成一个链子。这个链子可以是链表(早期版本)或者红黑树(当链子太长时,为了提高查找效率,JDK8 及以后会转换)。- 例子:第
5
号抽屉里本来有“蓝色衬衫”,现在“红色 T 恤”也来了,它们就会排队,比如“蓝色衬衫” → “红色 T 恤”。
- 例子:第
- 空抽屉:如果这个抽屉是空的,那太好了,直接把你的衣服放进去(
找衣服(查找):当你想要找“红色 T 恤”的时候,
HashMap
也会对“红色 T 恤”这个Key
进行同样的计算,得到同样的抽屉位置(第5
号)。然后它会到第5
号抽屉里,沿着链子一条一条地比对,直到找到“红色 T 恤”为止。
核心思想就是:
- 快速定位:通过
Key
算出哈希值,再映射到数组索引,大大提高了查找效率,不用一个一个地遍历所有元素。 - 解决冲突:用链表(或红黑树)来处理不同
Key
算出相同索引的情况。
Java 中有哪些集合类?请简单介绍
想象一下你有很多东西要管理,比如一堆照片、一份购物清单、一套学生花名册。Java 为了帮你管理这些东西,提供了各种“容器”,这些容器就是集合类。它们主要分成几大家族:
List (列表) 家族:
- 特点:有序(你放进去的顺序就是它存储的顺序),可重复(可以放两个一模一样的元素)。
- 类比:你的购物清单。买了重复的东西没关系,物品的顺序也很重要。
- 常见成员:
ArrayList
:基于数组实现。查改快,增删慢(特别是中间位置)。就像一排整齐的格子,找东西方便,但中间加东西要挪动后面的所有格子。LinkedList
:基于链表实现。增删快,查改慢。就像一串手拉手的人,加个人很容易插队,但找人要一个一个问过去。
Set (集合) 家族:
- 特点:无序(你放进去的顺序不一定是你取出来的顺序),不可重复(只能放一个,重复的会被忽略)。
- 类比:你的收藏品清单。收藏品不会重复,你也不关心它们摆放的先后顺序。
- 常见成员:
HashSet
:基于HashMap
实现。查找、添加、删除都很快。它利用了哈希表的快速查找特性。LinkedHashSet
:继承HashSet
,内部用链表维护插入顺序。既保证不重复,又能记住你添加的顺序。TreeSet
:基于红黑树实现。能自动排序(自然顺序或自定义顺序)。
Map (映射) 家族:
- 特点:存储 键值对 (Key-Value Pair)。
Key
唯一(就像抽屉标签不能重复),Value
可以重复。 - 类比:学校的花名册。每个学生学号(Key)唯一对应一个学生姓名(Value),通过学号快速找到学生,但不同的学号可能对应相同的姓名(重名)。
- 常见成员:
HashMap
:基于哈希表实现。查找、添加、删除都很快(我们上面原理讲的就是它)。无序。LinkedHashMap
:继承HashMap
,内部用链表维护插入顺序。既能快速查找,又能记住你添加的顺序。TreeMap
:基于红黑树实现。能自动按Key
排序。
- 特点:存储 键值对 (Key-Value Pair)。
总结:
- List:存一堆东西,讲究顺序,允许重复。
- Set:存一堆不重复的东西,不讲究顺序。
- Map:存一对一对的东西(Key-Value),Key 不能重复。
Java 中 HashMap 的扩容机制是怎样的?
想象你的衣柜一开始只有 16
个抽屉。你不断往里放衣服,直到抽屉越来越满,或者因为哈希碰撞导致链子越来越长,找衣服越来越慢。
HashMap
也一样,当它达到一定程度时,为了保持效率,它会进行扩容:
扩容的触发条件:
- 装载因子(Load Factor):
HashMap
有一个“装载因子”,默认是0.75
。意思是当抽屉里衣服的数量(也就是HashMap
里的元素个数)达到抽屉总数的75%
时,就触发扩容。- 例子:如果当前有
16
个抽屉,当放了16 * 0.75 = 12
件衣服时,就会开始考虑扩容。
- 例子:如果当前有
- 链表过长转为红黑树(JDK8+ 额外优化):在 JDK8 及以后,如果某个抽屉(即哈希桶)中的链表长度超过一个阈值(默认是
8
),并且HashMap
的总抽屉数量(容量)也达到了另一个阈值(默认是64
),那么这个链表会转换为红黑树,以提高查找效率。如果容量还没到64
,那么会先触发一次扩容。
- 装载因子(Load Factor):
扩容操作的步骤:
- 容量翻倍:
HashMap
扩容时,通常会将抽屉的总数(底层数组的长度)翻倍。这是为了利用位运算来提高计算新索引的效率,因为HashMap
的容量总是2
的幂次方。- 例子:从
16
个抽屉变成32
个抽屉。
- 例子:从
- 重新分配元素:这不是简单地把旧抽屉里的衣服搬到新衣柜里。由于抽屉总数变了,元素映射到索引的计算方式也会发生变化。
- 在 JDK8 之前,需要对所有元素重新计算哈希值,然后对新容量取模,来确定它们在新数组中的位置。
- 在 JDK8 及之后,扩容机制做了优化,元素在新数组中的位置判断更加高效。对于每个旧桶中的元素,它在新数组中的位置要么保持不变,要么移动到旧索引 + 旧容量的位置。
- 例子:一件“红色 T 恤”原来在
16
个抽屉的衣柜里对应第5
号抽屉。现在变成32
个抽屉的衣柜,它可能仍然在5
号抽屉,也可能去了5 + 16 = 21
号抽屉,这取决于它的哈希值的某一位。
- 容量翻倍:
扩容的优缺点与优化建议:
- 优点:保证了
HashMap
在元素增多的情况下依然能保持较高的查找、插入和删除效率(接近O(1)
)。 - 缺点:扩容是一个比较耗时的操作。因为它需要遍历所有旧桶中的元素,并将其重新分配到新数组中。这个操作的复杂度为
O(n)
,其中n
是HashMap
中元素的数量。 - 优化建议:如果能预估
HashMap
的大小,最好在创建时就指定一个合适的初始容量。这样可以减少不必要的扩容次数,从而提升性能。
- 优点:保证了
简单来说:HashMap
会观察自己有多满。当它觉得太挤了(元素数量达到容量的 75%
),就会造一个更大的衣柜(通常是原来的两倍大)。然后,它会高效地把所有衣服都拿出来,重新计算它们在新衣柜里的位置(JDK 8 后更高效),再全部放进去。这样虽然会有一小段时间的开销,但确保了以后还能继续快速找衣服。
你了解 Java 线程池的原理吗?
想象你是一个餐馆老板,生意很好,经常有客人来点餐。如果你每次有客人来都临时找一个厨师来做菜,做完就让他走,这样太麻烦了,而且招人、培训人都需要时间,效率很低。
线程池就相当于你的这个餐馆的“专属厨师团队”:
- 开门营业(创建线程池):你一开始就雇佣了一定数量的厨师(核心线程)。即使没有客人,他们也会待命。
- 客人点餐(提交任务):客人来了,点了一道菜(一个任务)。
- 有空闲厨师:如果有空闲的厨师,他们就立刻接单做菜。
- 没有空闲厨师,但有等候区:如果所有核心厨师都在忙,但餐馆里有等待区(任务队列),客人就在等待区排队,等着有厨师忙完。
- 等候区也满了,需要临时加人:如果等待区也满了,厨师团队会考虑临时再雇佣一些临时厨师(非核心线程/最大线程数)。
- 人满为患(拒绝策略):如果临时厨师也雇满了,等待区也满了,餐馆实在忙不过来,就只能对新来的客人说“抱歉,我们现在无法接单”(拒绝策略)。
- 厨师工作(执行任务):厨师们拿到任务后就开始处理,处理完一个任务后,他们不会立即走人,而是会回到“待命状态”,等待下一个任务。
- 临时厨师下班(线程回收):如果临时厨师长时间没事做,餐馆就会让他们下班回家(线程回收),只留下核心厨师继续待命。
线程池的优点:
- 降低资源消耗:避免了频繁创建和销毁线程的开销。
- 提高响应速度:任务来了可以直接使用已有的线程,不用等待线程创建。
- 提高线程的可管理性:可以统一管理、分配、监控和调整线程。
- 避免线程过多:防止大量线程同时运行导致系统资源耗尽。
核心组件:
ThreadPoolExecutor
:Java 中实现线程池的类。- 核心线程数 (
corePoolSize
):即使没有任务也会保留的最小线程数。 - 最大线程数 (
maximumPoolSize
):允许创建的最大线程数。 - 任务队列 (
BlockingQueue
):存放待执行任务的队列。 - 空闲线程存活时间 (
keepAliveTime
):当线程数大于核心线程数时,多余的空闲线程在终止前等待新任务的最长时间。 - 拒绝策略 (
RejectedExecutionHandler
):当线程池和任务队列都满时,如何处理新提交的任务。
你使用过哪些 Java 并发工具类?
在多线程编程中,为了让多个线程和谐、高效地工作,Java 提供了很多“工具”,可以帮助我们管理和协调线程。我主要使用过以下这些:
ConcurrentHashMap
(并发哈希表):作用:是一个线程安全且高效的哈希表,支持并发访问。解决了传统
HashMap
在多线程环境下不安全的问题,同时性能比Hashtable
等加锁的 Map 更好。用法:多个线程可以同时对它进行读写操作,而不会导致线程安全问题。
类比:就像一个有多个收银台的超市,不同收银员(线程)可以同时处理顾客(操作数据),互不干扰,大大提高了效率。
javaConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>(); map.put("key1", 1); // 存入 key1-1 System.out.println("存入 key1 -> " + map.get("key1")); // 读取 key1 // 如果 key2 不存在,则存入 k -> 2 的结果 (即 2) map.computeIfAbsent("key2", k -> { System.out.println("'key2' 不存在,计算并存入 2"); return 2; }); System.out.println("第一次 computeIfAbsent 后 key2 的值:" + map.get("key2")); // 再次使用 computeIfAbsent。 // 因为 key2 已经存在,k -> 3 的逻辑不会执行,map 中的值也不会改变 map.computeIfAbsent("key2", k -> { System.out.println("'key2' 已经存在,k -> 3 的逻辑不会执行"); return 3; // 此值不会被存入 }); System.out.println("第二次 computeIfAbsent 后 key2 的值:" + map.get("key2"));
AtomicInteger
(原子整数):作用:提供一种线程安全的方式对
int
类型进行原子操作,如增减、比较并设置。避免了使用synchronized
关键字带来的性能开销。用法:适用于需要频繁对数值进行无锁操作的场景。
类比:就像一个显示实时人数的计数器,多个人同时点击“进入”或“离开”,计数器也能准确、安全地更新,不需要排队等待。
javaAtomicInteger atomicInt = new AtomicInteger(0); atomicInt.incrementAndGet(); // 线程安全地递增 atomicInt.decrementAndGet(); // 线程安全地递减 atomicInt.compareAndSet(1, 2); // 比较并设置 (CAS 操作)
Semaphore
(信号量):作用:控制访问资源的线程数量,可以用来实现限流或访问控制。
用法:在资源有限的情况下,控制同时访问的线程数量。
类比:就像一个有固定车位的停车场。每次有车(线程)想进入,就得先拿一个停车许可。停车位满了,后面的车就得等着。
javaSemaphore semaphore = new Semaphore(3); // 最多允许 3 个线程同时访问 try { semaphore.acquire(); // 获取许可,如果许可不够则阻塞 // 执行任务(例如访问数据库连接、文件等有限资源) } finally { semaphore.release(); // 释放许可 }
CyclicBarrier
(循环屏障):作用:让一组线程到达一个共同的同步点,然后一起继续执行。常用于分阶段任务执行。
用法:适用于需要所有线程在某个点都完成再继续的场景。
类比:就像一次团队协作的闯关游戏。设定 3 人一组,只有当所有 3 名队员都到达当前关卡的集合点时,大家才能一起通过,进入下一关。
javaCyclicBarrier barrier = new CyclicBarrier(3, () -> { System.out.println("所有线程都到达了屏障点,开始下一阶段任务"); }); Runnable task = () -> { try { // 执行任务的第一阶段 System.out.println(Thread.currentThread().getName() + "完成第一阶段任务"); barrier.await(); // 等待其他线程到达屏障 // 执行任务的第二阶段 System.out.println(Thread.currentThread().getName() + "完成第二阶段任务"); } catch (Exception e) { e.printStackTrace(); } }; new Thread(task, "线程 A").start(); new Thread(task, "线程 B").start(); new Thread(task, "线程 C").start();
CountDownLatch
(倒计时门闩):作用:一个线程(或多个)等待其他线程完成操作。
用法:适用于主线程需要等待多个子线程完成任务的场景。
类比:就像一个项目的验收。项目经理(主线程)需要等待 3 个小组(子线程)各自完成他们的模块开发(
countDown()
),当所有模块都完成时,项目经理才能宣布项目完成(await()
)。javaCountDownLatch latch = new CountDownLatch(3); // 设置计数器为 3 Runnable task = () -> { try { // 执行任务 System.out.println(Thread.currentThread().getName() + "完成任务"); } finally { latch.countDown(); // 任务完成,计数器减一 } }; new Thread(task, "子线程 1").start(); new Thread(task, "子线程 2").start(); new Thread(task, "子线程 3").start(); latch.await(); // 主线程等待所有任务完成,计数器归零时才继续 System.out.println("所有任务都完成了,主线程继续执行");
BlockingQueue
(阻塞队列):作用:是一个线程安全的队列,支持阻塞操作。适用于生产者 - 消费者模式。
用法:生产者线程将元素放入队列,队列为满时生产者线程阻塞;消费者线程从队列中取出元素,队列为空时消费者线程阻塞。
类比:就像一个有限容量的生产线传送带。生产工人(生产者线程)往上传送产品,如果传送带满了,他就得等等;质检工人(消费者线程)从传送带上取产品,如果传送带空了,他也得等等。
javaBlockingQueue<String> queue = new LinkedBlockingQueue<>(2); // 容量为 2 的阻塞队列 // 生产者线程 Runnable producer = () -> { try { queue.put("item1"); // 放入元素,如果队列满则阻塞 System.out.println("生产者放入 item1"); queue.put("item2"); System.out.println("生产者放入 item2"); queue.put("item3"); // 队列已满,生产者将在此阻塞,直到有消费者取出元素 System.out.println("生产者放入 item3"); } catch (InterruptedException e) { e.printStackTrace(); } }; // 消费者线程 Runnable consumer = () -> { try { Thread.sleep(1000); // 模拟消费者处理需要时间 String item = queue.take(); // 取出元素,如果队列空则阻塞 System.out.println("消费者取出 " + item); item = queue.take(); System.out.println("消费者取出 " + item); item = queue.take(); System.out.println("消费者取出 " + item); } catch (InterruptedException e) { e.printStackTrace(); } }; new Thread(producer, "生产者").start(); new Thread(consumer, "消费者").start();
什么是 Java 的 CAS(Compare-And-Swap)操作?
CAS 是一种非常巧妙且高效的并发操作,它避免了传统锁机制(比如 synchronized
)带来的性能开销。
CAS 的核心思想:
它包含三个操作数:
- 内存位置 (V):你要操作的变量在内存中的位置。
- 预期旧值 (A):你认为这个变量现在应该是什么值。
- 新值 (B):如果变量的值符合你的预期,你想把它改成什么值。
操作过程:
- 线程 A 说:“我打算把内存中
X
位置的值从旧值1
改成新值2
。” - CAS 操作:它会去检查
X
位置的值是不是真的还是旧值1
。- 如果是
旧值1
:说明在线程 A 检查到它并打算修改的这段时间里,没有其他线程动过它,那么 CAS 操作就成功,把X
位置的值改成新值2
。 - 如果不是
旧值1
:说明有其他线程在我不知情的情况下,已经把X
位置的值改掉了。CAS 操作就失败,不进行任何修改。
- 如果是
- 线程 A 的后续:如果 CAS 失败,线程 A 通常会选择重试(重新读取当前值,再次尝试 CAS),或者放弃。
类比:
想象你正在买一件限量版 T 恤,只剩最后一件了。
- 内存位置 (V):就是“这件 T 恤是否还有货”的状态,比如用数字
1
表示有货,0
表示没货。 - 预期旧值 (A):你去买的时候,你心想这件 T 恤“应该还有货”(预期旧值是
1
)。 - 新值 (B):如果有货,你就把它买走,状态变成“没货”(新值是
0
)。
- 内存位置 (V):就是“这件 T 恤是否还有货”的状态,比如用数字
你拿着钱去柜台:
- 你(线程 A)对店员(CAS 操作)说:“如果这件 T 恤还有货(当前是
1
),我就把它买走,变成没货(改成0
)。” - 店员(CAS)立刻看一眼货架:
- 如果真的还有货(是
1
):那么你买成功了,T 恤变成没货(改成0
),交易完成。 - 如果已经没货了(是
0
):说明在你来之前,别人已经买走了。你的交易失败。
- 如果真的还有货(是
- 你买失败后,通常会选择再看一眼其他款式的 T 恤(重试其他操作),或者干脆不买了(放弃)。
- 你(线程 A)对店员(CAS 操作)说:“如果这件 T 恤还有货(当前是
CAS 的优点:
- 非阻塞:不会像锁一样导致其他线程阻塞等待,而是失败后重试,减少了上下文切换的开销。
- 原子性:比较和交换是硬件层面提供的原子操作,要么都成功,要么都失败,不会出现中间状态。
- 适用于竞争不激烈的情况:在线程冲突较少的情况下,CAS 的性能通常优于锁。
CAS 的缺点:
- ABA 问题:如果一个值从
A
变成B
,又从B
变回A
,那么 CAS 会认为没有发生变化,但实际上已经变过了。这个问题可以通过引入版本号(AtomicStampedReference
)来解决。 - 循环时间长开销大:如果 CAS 操作总是失败,会不断自旋重试,浪费 CPU 资源。
- 只能保证一个共享变量的原子操作:如果需要同时操作多个共享变量,CAS 就无能为力了,需要使用锁或者
AtomicReference
。
Java 中的 java.util.concurrent.atomic
包下的类(如 AtomicInteger
、AtomicLong
)就是基于 CAS 实现的,它们提供了一些基本类型的原子操作。
说说 AQS 吧?
简单来说,AQS(AbstractQueuedSynchronizer,抽象队列同步器)起到了一个抽象、封装的作用。
它提供了一套通用的机制:将线程排队、入队、加锁、中断、唤醒等通用的同步操作抽象出来,并封装在一个框架里。这使得 JUC 包中各种复杂的同步组件(如
ReentrantLock
、CountDownLatch
、Semaphore
等)能够基于这套机制来实现,避免了重复造轮子,大大简化了并发工具的开发。核心构成:它主要通过维护两个关键部分来管理线程对共享资源的访问:
- 一个共享状态(
state
变量):这个state
变量用volatile
修饰,表示当前资源的同步状态。- 例如,在独占锁(如
ReentrantLock
的独占模式)中,state
为0
表示资源未被占用,为1
表示资源已被占用。 - 在共享锁(如
Semaphore
)中,state
可能表示剩余的许可数量。
- 例如,在独占锁(如
- 一个先进先出(FIFO)的等待队列(CLH 队列的变体):当线程尝试获取资源失败时(例如,锁已经被其他线程持有),它不会立即放弃,而是会被加入到 AQS 的等待队列中。
- 这个队列是一个双向链表结构,它的节点(Node)包含了对等待线程的引用、线程的等待状态(例如,是否需要唤醒、是否被取消)以及指向前驱和后继节点的指针。
- 队列的头部通常是当前正在等待获取锁的线程或即将被唤醒的线程。
- 一个共享状态(
如何工作:
- 获取资源(acquire):当线程尝试获取资源时,AQS 会首先检查
state
。如果state
允许获取(比如锁未被占用),线程就成功获取资源。如果state
不允许获取,线程就会被封装成一个节点,加入到等待队列的尾部,并被阻塞挂起。 - 释放资源(release):当持有资源的线程释放资源时,AQS 会修改
state
的值。如果修改后state
允许其他线程获取资源,并且等待队列中有线程,AQS 就会唤醒队列头部的线程去尝试获取资源。
- 获取资源(acquire):当线程尝试获取资源时,AQS 会首先检查
定制性:AQS 是一个抽象类,它定义了获取和释放资源的骨架方法(如
acquire()
、release()
),但具体的“如何判断资源是否能获取”、“如何修改state
”等逻辑,需要具体的实现类(如ReentrantLock
的内部类Sync
)来自己控制和实现。比如,独占锁和共享锁对state
的操作逻辑就不同。AQS 常见的实现类有:
ReentrantLock
、CountDownLatch
、Semaphore
等等。
Synchronized 和 ReentrantLock 有什么区别?
- Synchronized 是 Java 内置的关键字,实现基本的同步机制。
- 特点:不支持超时、通常是非公平的、不可中断、不支持多个条件变量。
- ReentrantLock 是 JUC 库提供的,由 JDK 1.5 引入。
- 特点:支持设置超时时间、可以避免死锁、比较灵活,并且支持公平锁、可中断、支持多个条件判断。
共同点:
它们都是可重入锁,这意味着持有锁的线程可以再次获取该锁而不会发生死锁。
核心区别:
- ReentrantLock 需要手动加锁和解锁(通过
lock()
和unlock()
方法),这就要求开发者必须在finally
块中确保unlock()
被调用,以避免死锁。 - Synchronized 不需要手动解锁,它是自动加锁和解锁的(进入同步块/方法时加锁,退出时自动解锁,无论是正常退出还是异常退出)。
Java 中 volatile 关键字的作用是什么?
volatile
关键字的主要作用是保证变量的可见性和禁止指令重排优化。
可见性 (Visibility):
问题背景:在多线程编程中,为了提高性能,每个线程通常会有自己的工作内存(或称为本地缓存),用于存储变量的副本。当一个线程修改了一个变量的值时,这个修改可能只会先反映在它的工作内存中,而不会立即同步到主内存。这就导致了另一个线程在读取同一个变量时,可能会从自己的旧的本地缓存中读取到过期的值,而不是主内存中的最新值。这种现象被称为“缓存一致性问题”,或者更通俗地说,“看见旧值”的现象。
volatile
的作用:volatile
关键字确保变量的可见性。当一个线程修改了volatile
变量的值,这个新值会立即被刷新到主内存中。- 同时,当其他线程在读取该
volatile
变量时,它们会强制性地从主内存中重新获取最新值,而不是使用自己本地缓存中的旧值。 - 这样可以避免线程间由于缓存不一致问题导致的“看见旧值”的现象,保证了多个线程对
volatile
变量的读写操作总是能看到最新状态。
禁止指令重排序 (Ordering):
问题背景:为了提高程序执行效率,编译器和处理器可能会对指令进行优化,调整其执行顺序,这种优化被称为“指令重排序”。在单线程环境下,这种重排序不会改变程序的最终结果(遵循“as-if-serial”语义)。然而,在多线程环境下,如果没有正确的同步机制,指令重排序可能会导致意想不到的错误结果。
volatile
的作用:volatile
变量还通过插入内存屏障(Memory Barrier)来禁止特定情况下的指令重排序,从而保证程序的执行顺序符合预期。- 具体来说:
- 对
volatile
变量的写操作会在其前面插入一个StoreStore
屏障,在其后面插入一个StoreLoad
屏障。这确保了在volatile
写操作之前的代码执行结果,对所有线程都是可见的,并且volatile
写操作本身必须在后续的任何读操作之前完成。 - 对
volatile
变量的读操作会在其后面插入一个LoadLoad
屏障和LoadStore
屏障。这确保了volatile
读操作能够读取到最新的值,并且在volatile
读操作之后的代码,不会被重排序到volatile
读操作之前。
- 对
- 这种内存屏障机制确保了在多线程环境下,某些代码块执行顺序的可预测性,避免了因指令重排序引发的并发问题。
重要补充:
volatile
不保证原子性虽然
volatile
解决了可见性和部分有序性问题,但它不保证操作的原子性。- 原子性是指一个操作是不可中断的,要么完全执行,要么不执行。
- 例如,
volatile int count = 0; count++;
这个操作看起来很简单,但它实际包含了三个步骤:- 读取
count
的当前值。 - 对
count
进行加 1 操作。 - 将新值写入
count
。
- 读取
- 如果多个线程同时执行
count++
,volatile
只能保证每次读写count
时,都是从主内存获取最新值并及时刷新到主内存。但是,它不能保证从读取到写入这三个步骤作为一个整体是不可分割的。在一个线程读取count
后,另一个线程可能在它写入之前也读取了count
的旧值,导致最终的count
值不正确。 - 对于需要原子性保证的操作(如计数器增减),应该使用
java.util.concurrent.atomic
包下的原子类(如AtomicInteger
)或使用synchronized
或ReentrantLock
。
总结:
volatile
适用于:当一个变量被多个线程共享,但其读写操作是独立的,不依赖于变量的当前值,或者读操作远多于写操作,并且写操作不依赖于读操作的结果(即只需要保证可见性和有序性,不需要原子性)的场景。例如,一个用于控制线程停止的 boolean
标记。