Java基础知识点汇总(五)
一、限定通配符和非限定通配符
在Java泛型中,通配符(Wildcard)用于表示“未知类型”,解决泛型类型的灵活性问题。根据是否对类型范围进行约束,分为限定通配符和非限定通配符,核心区别在于是否限制了匹配的类型范围。
1、非限定通配符(Unbounded Wildcard):?
-
定义:
?
表示“任意引用类型”,没有任何继承或实现的约束,可匹配所有类型(如String
、Integer
、Object
等)。 -
适用场景:仅需要操作“所有类型共有的行为”(如
Object
类的方法),无需关注具体类型是什么。 -
特点:
- 只能“读取”数据,且读取的元素类型被限定为
Object
(因为无法确定具体类型)。 - 不能“写入”数据(除了
null
,因为无法确定要写入的类型是否匹配)。
- 只能“读取”数据,且读取的元素类型被限定为
-
示例:
定义一个打印任意泛型列表元素的方法,无需关心列表中存储的具体类型:import java.util.Arrays; import java.util.List;public class WildcardExample {// 非限定通配符:可接收 List<String>、List<Integer> 等任意泛型列表public static void printList(List<?> list) {for (Object obj : list) {System.out.println(obj); // 仅调用 Object 类的方法}}public static void main(String[] args) {printList(Arrays.asList("苹果", "香蕉")); // 匹配 List<String>printList(Arrays.asList(10, 20, 30)); // 匹配 List<Integer>} }
2、限定通配符(Bounded Wildcard):? extends T
和 ? super T
限定通配符通过“上界”或“下界”约束匹配的类型范围,确保操作的类型安全。
2.1. 上界限定通配符:? extends T
-
定义:表示“
T
及其所有子类/实现类”,仅匹配继承自T
的类型。 -
适用场景:需要“读取”数据(作为“生产者”提供数据),且只能读取
T
类型或其子类型的元素。 -
特点:
- 可以安全“读取”数据,读取的元素类型为
T
(因为子类对象可向上转型为父类)。 - 不能“写入”数据(除了
null
),因为无法确定列表实际存储的是T
的哪个子类。
- 可以安全“读取”数据,读取的元素类型为
-
示例:
计算任意数字列表的总和(Integer
、Double
等均继承自Number
):import java.util.Arrays; import java.util.List;public class BoundedWildcardExample {// 上界限定:仅接收 Number 及其子类(如 Integer、Double)的列表public static double sum(List<? extends Number> numbers) {double total = 0;for (Number num : numbers) {total += num.doubleValue(); // 调用 Number 类的方法}return total;}public static void main(String[] args) {System.out.println(sum(Arrays.asList(1, 2, 3))); // 合法:List<Integer>System.out.println(sum(Arrays.asList(1.5, 2.5))); // 合法:List<Double>// sum(Arrays.asList("1", "2")); // 编译错误:String 不是 Number 的子类} }
2.2. 下界限定通配符:? super T
-
定义:表示“
T
及其所有父类/超类”,仅匹配T
的父类型。 -
适用场景:需要“写入”数据(作为“消费者”接收数据),且只能写入
T
类型或其子类型的元素。 -
特点:
- 可以安全“写入”数据,写入的元素必须是
T
或其子类型(子类对象可向上转型为父类)。 - 读取数据时,类型被限定为
Object
(因为无法确定父类的具体类型)。
- 可以安全“写入”数据,写入的元素必须是
-
示例:
向存储Person
及其父类的列表中添加Student
(Student
继承自Person
):import java.util.ArrayList; import java.util.List;class Person {} class Student extends Person {}public class SuperWildcardExample {// 下界限定:仅接收 Person 及其父类(如 Object)的列表public static void addStudent(List<? super Person> list) {list.add(new Student()); // 合法:Student 是 Person 的子类}public static void main(String[] args) {addStudent(new ArrayList<Person>()); // 合法:List<Person>addStudent(new ArrayList<Object>()); // 合法:List<Object>(Object 是 Person 的父类)// addStudent(new ArrayList<Student>()); // 编译错误:Student 是 Person 的子类,不是父类} }
3、核心区别总结
类型 | 语法 | 匹配范围 | 读写特性 | 典型用途 |
---|---|---|---|---|
非限定通配符 | ? | 任意类型 | 只能读(Object),几乎不能写 | 通用操作(如打印) |
上界限定通配符 | ? extends T | T 及其子类 | 只能读(T),不能写 | 读取/消费数据(生产者) |
下界限定通配符 | ? super T | T 及其父类 | 可以写(T或子类),读为Object | 写入/生成数据(消费者) |
简单来说,非限定通配符追求“最大灵活性”,而限定通配符通过约束类型范围确保“操作安全性”。实际开发中,可遵循PECS原则选择:
- 生产者(Producer):需要读取数据时用
? extends T
(Provider Extends); - 消费者(Consumer):需要写入数据时用
? super T
(Consumer Super)。
二、序列化、反序列化是什么?
序列化(Serialization)和反序列化(Deserialization)是 Java 中用于处理对象持久化和网络传输的核心机制:
1. 序列化(Serialization)
将内存中的对象转换为可存储或传输的二进制流(字节序列) 的过程。
简单说,就是把对象的状态(字段值、类型信息等)"冻结"成一串字节,以便:
- 保存到文件、数据库等存储介质中(持久化)
- 通过网络传输到其他进程或服务器
2. 反序列化(Deserialization)
将序列化产生的二进制流重新转换为内存中的对象的过程。
即把之前"冻结"的字节序列"解冻",恢复成原来的对象结构和数据。
为什么需要序列化?
- 跨平台/跨进程通信:网络传输只能传递字节流,序列化让对象可以在不同JVM之间传递。
- 持久化存储:将对象保存到文件或数据库,下次可以通过反序列化恢复。
- 分布式计算:在分布式系统中,对象需要在不同节点间传递。
Java中的实现方式
一个类要支持序列化,需满足:
- 实现
java.io.Serializable
接口(这是一个标记接口,无需实现任何方法)。 - 通常会显式声明
serialVersionUID
(如前所述,用于版本控制)。
示例代码:
import java.io.*;// 实现Serializable接口,支持序列化
class User implements Serializable {private static final long serialVersionUID = 1L;private String name;private int age;// 构造方法、getter、setter省略
}public class SerializationDemo {public static void main(String[] args) throws Exception {// 创建对象User user = new User("Alice", 25);// 序列化:将对象写入文件try (ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream("user.ser"))) {out.writeObject(user); // 序列化对象}// 反序列化:从文件恢复对象try (ObjectInputStream in = new ObjectInputStream(new FileInputStream("user.ser"))) {User restoredUser = (User) in.readObject(); // 反序列化System.out.println(restoredUser.getName()); // 输出 "Alice"}}
}
核心注意点
- 静态字段(
static
)不会被序列化(它们属于类,而非对象实例)。 - 用
transient
修饰的字段不会被序列化(如敏感信息)。 - 反序列化时不会调用对象的构造方法,而是直接恢复字段值。
- 序列化版本不兼容(
serialVersionUID
不匹配)会导致反序列化失败。
简单理解:序列化是"存档",反序列化是"读档",让对象可以脱离内存长期存在或跨环境移动。
三、serialVersionUID是什么?
在Java中,serialVersionUID
是一个用于序列化和反序列化过程中的版本控制标识,它是一个静态常量,类型为long
。
当一个类实现了Serializable
接口(用于支持对象的序列化)时,Java虚拟机会自动为该类生成一个serialVersionUID
。这个值是根据类的结构(如类名、方法、字段等)通过特定算法计算得出的。
指定serialVersionUID
的主要原因如下:
-
保证版本兼容性:
如果不手动指定serialVersionUID
,当类的结构发生变化(如添加/删除字段、修改方法等)时,Java会重新计算serialVersionUID
。此时,用旧版本类序列化的对象,在使用新版本类反序列化时会抛出InvalidClassException
,因为 serialVersionUID 不匹配。 -
控制反序列化行为:
当手动指定了serialVersionUID
后,即使类的结构发生变化,只要serialVersionUID
保持不变,Java就会尝试将旧版本对象反序列化到新版本类中(会按照一定规则处理字段的增减),提高了类的版本兼容性。
示例代码:
import java.io.Serializable;public class User implements Serializable {// 手动指定serialVersionUID,一般我们都直接设置为1Lprivate static final long serialVersionUID = 1L;private String name;private int age;// 构造方法、getter、setter等
}
简单来说,指定serialVersionUID
是为了在类的结构发生合理变化时,仍然能够正确地反序列化历史版本的对象,增强了序列化机制的灵活性和兼容性。
如果不指定serialVersionUID会发生什么?
如果不手动指定 serialVersionUID
,Java 虚拟机会在编译时自动生成一个,其值基于类的结构信息(如类名、字段、方法、接口等)通过特定算法计算得出。这种自动生成的机制可能导致以下问题:
-
类结构变化时反序列化失败
当类的结构发生任何修改(例如添加/删除字段、修改方法参数、甚至修改注释以外的任何代码),Java 会重新计算并生成一个新的serialVersionUID
。此时:- 用旧版本类序列化的对象,在使用新版本类反序列化时,会因为
serialVersionUID
不匹配而抛出InvalidClassException
。 - 这意味着类的微小改动可能导致历史序列化数据完全无法使用。
- 用旧版本类序列化的对象,在使用新版本类反序列化时,会因为
-
不同环境可能生成不同值
自动生成serialVersionUID
的算法可能依赖于编译器实现或 JVM 版本。同一类在不同编译器(如 javac、Eclipse 编译器)或不同 JDK 版本中编译时,可能生成不同的serialVersionUID
,导致跨环境反序列化失败。 -
缺乏版本控制主动权
不指定serialVersionUID
时,开发者无法自主控制类的版本兼容性。即使类的改动是向后兼容的(如仅添加一个可选字段),也可能因为自动生成的serialVersionUID
变化而导致反序列化失败。
示例场景:
假设最初有一个类:
import java.io.Serializable;public class User implements Serializable {private String name;// 未指定serialVersionUID
}
用这个类序列化一个对象后,若后来给类添加一个字段:
public class User implements Serializable {private String name;private int age; // 新增字段// 未指定serialVersionUID
}
此时自动生成的 serialVersionUID
已改变,用新版本类反序列化旧对象时会抛出异常。
因此,手动指定 serialVersionUID
是保证序列化兼容性的最佳实践,它让开发者可以自主决定类的版本是否兼容,而非依赖编译器的自动生成机制。
对于不希望序列化的字段——使用 transient
关键字:
这是最常用的方式,在字段声明前添加 transient
修饰符,该字段就会被排除在序列化过程之外。
示例代码:
import java.io.Serializable;public class User implements Serializable {private static final long serialVersionUID = 1L;private String name; // 会被序列化private transient int age; // 不会被序列化(transient修饰)private transient String password; // 敏感信息通常不序列化
}
特点:
- 被
transient
修饰的字段,在序列化时会被忽略 - 反序列化时,该字段会被赋值为默认值(如
int
为 0,对象为null
) - 只能修饰非静态字段(静态字段本身就不会被序列化)
四、进程和线程的区别?
在Java中,进程(Process) 和线程(Thread) 是操作系统中两个核心的执行单元,它们既有联系又有显著区别,主要体现在资源占用、独立性、通信方式等方面:
1. 定义与本质
- 进程:是操作系统进行资源分配(如内存、文件句柄、网络端口等)的基本单位,是一个独立运行的程序实例。例如,运行一个Java程序(
java MyClass
)会启动一个独立进程。 - 线程:是进程内的执行单元,是CPU调度的基本单位。一个进程可以包含多个线程,它们共享进程的资源,共同完成程序的任务。例如,Java程序中通过
new Thread()
创建的就是线程。
2. 核心区别
对比维度 | 进程 | 线程 |
---|---|---|
资源占用 | 占用独立的内存空间、文件描述符等,资源消耗大。 | 共享所属进程的资源(内存、文件句柄等),资源消耗小。 |
独立性 | 进程间相互独立,一个进程崩溃不影响其他进程。 | 线程属于同一进程,一个线程崩溃可能导致整个进程崩溃。 |
通信方式 | 进程间通信(IPC)复杂,需通过管道、socket、共享内存等。 | 线程间通信简单,可通过共享变量(需处理同步)实现。 |
切换开销 | 进程切换需要保存和恢复整个进程的状态,开销大。 | 线程切换只需保存线程的局部状态(如程序计数器、栈),开销小。 |
创建/销毁成本 | 成本高(需分配独立资源)。 | 成本低(共享进程资源)。 |
3. Java中的体现
-
进程:每个Java程序运行在独立的JVM进程中,拥有自己的堆内存、方法区等。例如,同时启动两个
java -jar
程序,会产生两个独立进程,它们的内存空间完全隔离。 -
线程:Java中通过
Thread
类或Runnable
接口创建线程,所有线程共享所属进程的堆内存(如对象实例),但每个线程有自己的栈内存(存储局部变量、方法调用栈)。// 示例:一个进程中的两个线程 public class ProcessThreadDemo {public static void main(String[] args) {// 主线程System.out.println("主线程: " + Thread.currentThread().getName());// 创建并启动新线程Thread thread = new Thread(() -> {System.out.println("子线程: " + Thread.currentThread().getName());});thread.start();} }
4. 总结
- 进程是资源分配的单位,线程是CPU调度的单位。
- 进程间隔离性强、资源消耗大;线程共享资源、开销小,适合并发任务。
- Java程序的并发通常通过多线程实现(同一进程内),而多进程通信在分布式场景中更常见(如微服务间调用)。
简单说:进程是“独立的程序”,线程是“程序内的执行流”,多个线程协作可以高效完成并发任务。
五、HashMap原理
Java 的 HashMap
是一种基于哈希表实现的键值对(Key-Value)存储结构,用于高效地进行查找、插入和删除操作。其核心原理围绕哈希函数、数组、链表/红黑树展开,下面详细解析:
1. 底层数据结构
HashMap
的底层是数组 + 链表 + 红黑树的组合结构(JDK 1.8 及以上):
- 数组(哈希表):作为主体,每个元素是一个"桶"(Bucket),存储链表或红黑树的头节点。
- 链表:当多个键(Key)哈希冲突时,会以链表形式存储在同一个桶中。
- 红黑树:当链表长度超过阈值(默认 8),且数组长度 ≥ 64 时,链表会转换为红黑树,以提高查询效率(红黑树查询时间复杂度为 O(log n),优于链表的 O(n))。
2. 核心原理:哈希与索引计算
HashMap
的核心是通过哈希函数将 Key 映射到数组的索引位置,步骤如下:
(1)计算 Key 的哈希值
调用 Key.hashCode()
方法获取哈希值,再通过 HashMap
内部的哈希算法进一步处理(减少哈希冲突):
static final int hash(Object key) {int h;// 对hashCode进行扰动处理,减少哈希冲突return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
key == null
时,哈希值固定为 0(HashMap
允许 Key 为 null)。- 右移 16 位并异或(^)的操作,是为了混合哈希值的高位和低位,减少哈希冲突。
(2)计算数组索引
用处理后的哈希值与数组长度(length
)减 1 进行按位与(&) 运算,得到数组索引:
int index = (table.length - 1) & hash;
- 数组长度始终是 2 的幂次方(如 16、32、64…),因此
length - 1
的二进制是全 1(如 15 是 1111),按位与运算等价于取模(hash % length
),但效率更高。
3. 哈希冲突的解决
当两个不同的 Key 计算出相同的索引时,称为哈希冲突。HashMap
通过链地址法解决:
- 冲突的 Key-Value 对会以链表形式存储在同一个桶中。
- 查找时,先通过索引定位到桶,再遍历链表(或红黑树)比较 Key 的
equals()
方法,找到匹配的 Value。
4. 扩容机制(Resize)
当 HashMap
中的元素数量(size
)超过负载因子(loadFactor)× 数组长度时,会触发扩容:
- 负载因子:默认 0.75,是权衡空间和时间效率的阈值(值越高,空间利用率高但冲突率高)。
- 扩容过程:
- 创建一个新的数组,长度为原数组的 2 倍(保证仍是 2 的幂次方)。
- 将原数组中的元素重新计算索引,迁移到新数组中(这个过程称为"重哈希")。
- 若原链表长度 ≥ 8 且新数组长度 ≥ 64,会将链表转为红黑树;若长度 ≤ 6,则红黑树转回链表。
5. 关键成员变量
public class HashMap<K,V> {transient Node<K,V>[] table; // 核心数组(哈希表)transient int size; // 实际存储的键值对数量int threshold; // 扩容阈值(= 负载因子 × 数组长度)final float loadFactor; // 负载因子(默认 0.75)
}// 链表节点
static class Node<K,V> implements Map.Entry<K,V> {final int hash;final K key;V value;Node<K,V> next; // 下一个节点的引用
}// 红黑树节点(JDK 1.8+)
static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {TreeNode<K,V> parent; // 父节点TreeNode<K,V> left; // 左子树TreeNode<K,V> right; // 右子树TreeNode<K,V> prev; // 链表前驱(用于树转链表)boolean red; // 节点颜色(红/黑)
}
6. 核心操作流程(以 put()
为例)
- 计算 Key 的哈希值和数组索引。
- 若索引位置为空,直接插入新节点。
- 若索引位置不为空:
- 若头节点的 Key 与插入的 Key 相同(
equals()
为 true),则覆盖 Value。 - 否则,遍历链表/红黑树:
- 若找到相同 Key,覆盖 Value。
- 若未找到,在链表尾部插入新节点(JDK 1.8 尾插法,避免死循环)。
- 若头节点的 Key 与插入的 Key 相同(
- 插入后,若链表长度 ≥ 8 且数组长度 ≥ 64,将链表转为红黑树。
- 若
size
超过阈值,触发扩容。
7. 线程安全性
HashMap
是非线程安全的:
- 多线程并发修改时,可能导致链表成环(扩容时)、数据丢失等问题。
- 线程安全的替代方案:
ConcurrentHashMap
(JUC 包)、Hashtable
(古老且低效,不推荐)。
总结:
HashMap
通过哈希函数将 Key 映射到数组索引,用链表/红黑树解决哈希冲突,通过扩容机制保证效率,是 Java 中查询效率极高(平均 O(1))的键值对存储结构,广泛用于缓存、配置存储等场景。
六、HashMap 线程不安全体现在哪里?
HashMap
的线程不安全主要体现在多线程并发修改(如 put
、remove
、resize
等操作)时,可能出现数据不一致、链表成环、扩容丢失数据等问题。以下是具体表现:
- 扩容时的链表成环(JDK 1.7 及之前)
在 JDK 1.7 中,HashMap
扩容(resize
)时采用头插法迁移链表节点,多线程并发扩容可能导致链表形成环形结构:
- 当两个线程同时对同一链表进行扩容迁移时,节点的
next
指针可能相互引用,形成环。 - 后续查询该链表时,会陷入无限循环(
next
指针永远遍历不完),导致 CPU 飙升。
简化示例:
假设线程 A 和 B 同时迁移节点 a → b
:
- 线程 A 先处理
a
,将a.next
指向null
,准备插入新数组。 - 线程 B 处理
b
,将b.next
指向a
,此时链表变为b → a
。 - 线程 A 恢复执行,将
a.next
指向b
,最终形成a ↔ b
的环。
- 数据覆盖/丢失
多线程并发执行put
操作时,可能导致新插入的键值对被覆盖或丢失:
-
场景 1:两个线程同时计算出相同的索引,且该位置原本为空。
- 线程 A 检查到索引位置为空,准备插入节点。
- 线程 B 也检查到同一位置为空,抢先插入节点。
- 线程 A 继续执行插入,覆盖线程 B 插入的节点,导致 B 的数据丢失。
-
场景 2:并发修改同一链表/红黑树。
- 线程 A 正在遍历链表查找插入位置。
- 线程 B 同时删除了该链表中的某个节点,导致线程 A 访问到已被删除的节点,可能插入错误位置或丢失数据。
- size 计数不准确
HashMap
的size
变量用于记录键值对数量,多线程并发修改时,size++
操作(非原子操作)可能导致计数错误:
- 线程 A 和 B 同时读取到
size = 10
。 - 线程 A 执行
size++
后size = 11
。 - 线程 B 基于原始值
10
执行size++
后仍为11
,导致实际数量比记录值多 1,计数失真。
- JDK 1.8 仍不安全
JDK 1.8 虽然将扩容的头插法改为尾插法,解决了链表成环问题,但仍未解决线程安全问题:
- 并发
put
时仍可能出现数据覆盖。 - 红黑树的旋转操作在并发下可能导致结构破坏,引发查询异常。
如何避免线程不安全?
- 使用
ConcurrentHashMap
:JUC 包提供的线程安全实现,通过分段锁(JDK 1.7)或 CAS + synchronized(JDK 1.8+)保证并发安全,性能优于Hashtable
。 - 加锁保护:使用
Collections.synchronizedMap(new HashMap<>())
,通过全局锁保证安全,但并发效率低。 - 避免并发修改:在单线程环境中使用
HashMap
,多线程场景下通过线程隔离(如ThreadLocal
)避免共享。
总结:HashMap
的线程不安全源于并发修改时的非原子操作和数据结构竞争,核心问题是无法保证多线程操作的可见性、原子性和有序性。在并发场景中,必须使用线程安全的替代方案。
七、ConcurrentHashMap 如何保证线程安全的?
ConcurrentHashMap
是 Java 并发包(JUC)中提供的线程安全哈希表实现,其线程安全机制在 JDK 1.7 和 JDK 1.8+ 中有显著差异,核心思路是通过精细化锁控制减少竞争,同时保证高效并发。
1、JDK 1.7 的实现:分段锁(Segment)
JDK 1.7 中 ConcurrentHashMap
采用 “分段锁”(Segment) 机制,核心思想是将哈希表分为多个独立的“段”,每个段对应一把锁,实现“不同段的操作可以并发执行”。
(1)数据结构
- 底层由
Segment
数组组成,每个Segment
本质上是一个小的HashMap
(包含数组 + 链表)。 - 每个
Segment
独立加锁,多个线程操作不同Segment
时无需竞争同一把锁。
(2)线程安全保证
- 分段加锁:当执行
put
、remove
等修改操作时,只对当前 Key 所在的Segment
加锁(ReentrantLock
),其他Segment
仍可被其他线程访问。 - 读操作无锁:获取元素时无需加锁,通过
volatile
保证可见性(Segment
数组和每个HashEntry
的value
都用volatile
修饰)。
(3)优缺点
- 优点:多线程操作不同段时可并发执行,效率高于
Hashtable
的全局锁。 - 缺点:段数固定(默认 16),若大量线程竞争同一段,仍会有锁竞争;内存占用较高。
2、JDK 1.8+ 的实现:CAS + synchronized
JDK 1.8 彻底重构了 ConcurrentHashMap
,移除了 Segment
分段锁,改用 “CAS 无锁算法 + synchronized 细粒度锁”,并引入红黑树优化查询性能。
(1)数据结构
- 底层与
HashMap
类似:数组(Node[]
)+ 链表 + 红黑树(链表长度 ≥ 8 时转为红黑树)。
(2)线程安全保证
-
CAS 无锁操作:
- 初始化数组或扩容时,通过
CAS
(Compare-And-Swap)保证原子性(如casTabAt
方法)。 - 对
value
的简单修改(如put
时插入新节点到空桶)使用CAS
避免加锁。
- 初始化数组或扩容时,通过
-
synchronized 细粒度锁:
- 当插入节点发生哈希冲突时,对数组桶的头节点加
synchronized
锁,仅锁定当前桶,不影响其他桶的操作。 - 例如:线程 A 操作桶 1 的链表,线程 B 操作桶 2 的链表,可同时进行,只有竞争同一桶时才需等待锁。
- 当插入节点发生哈希冲突时,对数组桶的头节点加
-
volatile 可见性:
- 数组
transient volatile Node<K,V>[] table
用volatile
修饰,保证数组修改对其他线程可见。 - 节点
Node
的value
和next
指针也用volatile
修饰,确保读写可见性。
- 数组
(3)核心操作示例(put
方法)
- 计算 Key 的哈希值和数组索引。
- 若索引位置为空,通过
CAS
插入新节点(无锁)。 - 若索引位置不为空:
- 若头节点是
MOVED
状态(表示正在扩容),协助扩容。 - 否则,对头节点加
synchronized
锁,遍历链表/红黑树:- 若找到相同 Key,更新 Value。
- 若未找到,在链表尾部插入新节点(或红黑树插入)。
- 若头节点是
- 插入后检查是否需要转为红黑树,或触发扩容。
3、JDK 1.8+ 相比 1.7 的改进
- 锁粒度更细:从“段级锁”细化到“桶级锁”,减少锁竞争。
- 去掉 Segment 结构:内存占用更低,结构更简单。
- 引入红黑树:优化长链表的查询性能(O(n) → O(log n))。
- CAS 减少加锁:简单操作(如空桶插入)用 CAS 无锁实现,效率更高。
总结
ConcurrentHashMap
保证线程安全的核心是:
- JDK 1.7:通过“分段锁”隔离不同段的竞争,实现多段并发。
- JDK 1.8+:通过“CAS 无锁 + 桶级 synchronized 锁”实现更细粒度的并发控制,同时保留
volatile
保证可见性。
这种设计在保证线程安全的同时,最大限度地提高了并发效率,是高并发场景下替代 HashMap
(非线程安全)和 Hashtable
(低效全局锁)的最佳选择。
八、CAS是什么
Compare-And-Swap(CAS,比较并交换) 是一种无锁原子操作,用于实现多线程环境下的同步控制,避免了传统锁机制的开销和竞争问题。它是并发编程中的核心技术,广泛应用于 JUC 包(如 AtomicInteger
、ConcurrentHashMap
)等场景。
1、CAS 的核心思想
CAS 操作包含三个关键参数:
- 内存地址(V):要操作的变量在内存中的地址。
- 预期值(A):线程认为变量当前应该的值。
- 新值(B):如果变量当前值等于预期值,就将其更新为新值。
操作逻辑:
当且仅当内存地址 V
中的值等于预期值 A
时,才将该值更新为 B
,否则不做任何操作。整个过程是原子性的(由 CPU 指令直接支持,不可中断)。
2、CAS 的执行流程
- 线程读取内存地址
V
中的当前值,记为当前值
。 - 线程判断
当前值
是否等于预期值A
:- 若相等,将
V
中的值更新为B
。 - 若不相等,说明该值已被其他线程修改,当前线程不做操作(或重试)。
- 若相等,将
- 无论是否更新,都返回
V
中的旧值(供线程判断是否成功)。
简化示例(模拟 AtomicInteger
的 incrementAndGet
操作):
public class SimulatedCAS {private int value; // 内存地址V对应的变量// CAS操作:若当前值等于预期值A,则更新为B,返回是否成功public boolean compareAndSwap(int expectedA, int newValueB) {int currentValue = value; // 读取当前值if (currentValue == expectedA) {value = newValueB; // 符合预期,更新为新值return true;}return false; // 不符合预期,不更新}// 原子自增(类似AtomicInteger的incrementAndGet)public int increment() {int current;do {current = value; // 读取当前值作为预期值A} while (!compareAndSwap(current, current + 1)); // 若失败则重试return current + 1;}
}
3、CAS 的底层实现
CAS 操作的原子性依赖于CPU 硬件指令(如 x86 架构的 cmpxchg
指令),由操作系统直接支持:
- 当 JVM 执行 CAS 操作时,会翻译为对应的 CPU 指令。
- CPU 保证该指令在执行过程中不被中断,从而实现硬件级别的原子性。
4、CAS 的优势与问题
(1)优势
- 无锁开销:避免了传统锁(如
synchronized
)的上下文切换、线程阻塞唤醒等开销,适合低冲突场景。 - 高并发性能:在竞争不激烈时,CAS 的效率远高于锁机制。
- 细粒度控制:可针对单个变量进行原子操作,比锁的粒度更细。
(2)问题
-
ABA 问题:
- 场景:变量值从
A
被改为B
,又改回A
。CAS 会认为值未变,但实际已被修改。 - 解决:添加版本号(如
AtomicStampedReference
,用“值 + 版本号”作为判断依据)。
- 场景:变量值从
-
循环重试开销:
- 若并发冲突频繁,CAS 会不断重试(如上述
increment
方法的do-while
循环),导致 CPU 占用过高。
- 若并发冲突频繁,CAS 会不断重试(如上述
-
只能保证单个变量的原子性:
- CAS 仅能对单个变量进行原子操作,无法直接实现多个变量的原子性(需结合其他机制)。
5、CAS 在 Java 中的应用
- 原子类:
java.util.concurrent.atomic
包下的类(如AtomicInteger
、AtomicReference
)均基于 CAS 实现。 - 并发容器:
ConcurrentHashMap
(JDK 1.8+)用 CAS 实现无锁初始化和节点插入。 - 线程池:
ThreadPoolExecutor
中用 CAS 维护工作线程数量等状态。
6、总结
CAS 是一种高效的无锁同步机制,通过硬件保证的原子操作实现多线程安全,核心是“比较预期值并更新”。它在低冲突场景下性能优异,但存在 ABA 问题和重试开销,是 Java 并发编程的基础技术之一。
九、ArrayList 和 LinkedList 区别?
ArrayList
和 LinkedList
是 Java 集合框架中两种常用的列表实现,分别基于动态数组和双向链表,在底层结构、性能特性和适用场景上有显著区别:
1. 底层数据结构
-
ArrayList:基于动态数组实现,内部维护一个连续的 Object 数组,通过索引(index)快速访问元素。当数组容量不足时,会自动扩容(默认扩容为原容量的 1.5 倍)。
-
LinkedList:基于双向链表实现,每个元素(Node)包含三个部分:
- 存储的数据(
item
) - 前驱节点引用(
prev
) - 后继节点引用(
next
)
元素在内存中不连续,通过节点间的引用关联。
- 存储的数据(
2. 核心性能对比
操作 | ArrayList | LinkedList |
---|---|---|
随机访问(get/set) | 效率高,时间复杂度 O(1)(直接通过索引定位)。 | 效率低,时间复杂度 O(n)(需从头/尾遍历到目标位置)。 |
添加/删除(中间位置) | 效率低,时间复杂度 O(n)(需移动目标位置后的所有元素)。 | 效率高,时间复杂度 O(1)(只需修改相邻节点的引用,前提是已找到目标位置)。 |
添加/删除(首尾位置) | 尾部添加效率高(add() 通常为 O(1),扩容时为 O(n));头部添加效率低(O(n))。 | 首尾操作效率极高(addFirst() /addLast() 均为 O(1)),直接修改头节点或尾节点的引用。 |
内存占用 | 内存连续,存储元素本身,浪费较少(但扩容会预留空间)。 | 每个元素需额外存储 prev 和 next 引用,内存开销更大。 |
3. 其他关键区别
-
实现的接口:
ArrayList
仅实现List
接口。LinkedList
同时实现List
接口和Deque
接口(双端队列),因此可作为队列(Queue
)或栈(Stack
)使用,提供poll()
、offer()
、push()
、pop()
等方法。
-
迭代器性能:
- 两者的
Iterator
迭代(for-each
循环)性能相近,但LinkedList
的Iterator
遍历比随机访问(get(i)
)高效得多(避免重复从头遍历)。 LinkedList
支持双向迭代器(ListIterator
),可向前/向后遍历,而ArrayList
的ListIterator
功能类似但基于数组索引。
- 两者的
-
扩容机制:
ArrayList
有扩容成本(当元素数量超过容量时,复制旧数组到新数组)。LinkedList
无需扩容,添加元素时只需创建新节点并调整引用。
4. 适用场景
-
优先使用 ArrayList:
- 需要频繁通过索引访问元素(如
get(i)
、set(i, value)
)。 - 元素数量固定或变化不大,且以尾部添加/删除为主。
- 对内存占用较敏感的场景。
- 需要频繁通过索引访问元素(如
-
优先使用 LinkedList:
- 需要频繁在列表中间或首尾添加/删除元素(如实现队列、栈、链表结构)。
- 元素数量不确定,且插入删除操作远多于查询操作。
示例代码对比:
// ArrayList 示例(适合随机访问)
List<String> arrayList = new ArrayList<>();
arrayList.add("A");
arrayList.add("B");
String element = arrayList.get(1); // 快速获取索引1的元素(O(1))// LinkedList 示例(适合频繁插入删除)
LinkedList<String> linkedList = new LinkedList<>();
linkedList.addFirst("A"); // 头部添加(O(1))
linkedList.addLast("B"); // 尾部添加(O(1))
linkedList.add(1, "C"); // 中间插入(找到位置后O(1))
总结:
ArrayList
是数组型列表,优势在随机访问,适合读多写少场景。LinkedList
是链表型列表,优势在中间插入删除,适合写多读少或需双端操作的场景。
选择时需根据实际操作的频率和类型决定,避免因数据结构误用导致性能瓶颈。
十、ThreadLocal 原理
ThreadLocal
是 Java 中用于实现线程本地存储的工具类,它能为每个线程提供一个独立的变量副本,从而避免多线程间的变量共享冲突。
1、核心原理
ThreadLocal
的核心思想是:让每个线程持有一个独立的变量副本,线程对变量的操作仅影响自身副本,不干扰其他线程。
其底层通过以下结构实现:
-
每个
Thread
线程内部有一个threadLocals
变量(类型为ThreadLocal.ThreadLocalMap
),这是一个自定义的哈希表,用于存储该线程的所有ThreadLocal
变量副本。 -
ThreadLocal
本身不存储数据,仅作为ThreadLocalMap
的 key,用于在当前线程的threadLocals
中查找对应的变量副本。
2、数据结构关系
Thread (线程)
└── threadLocals: ThreadLocalMap (线程本地的哈希表)├── 键: ThreadLocal 对象本身└── 值: 该线程对应的变量副本
- 当线程通过
ThreadLocal.set(value)
存储数据时,实际是在当前线程的threadLocals
中,以当前ThreadLocal
为 key,存储value
副本。 - 当通过
ThreadLocal.get()
获取数据时,是从当前线程的threadLocals
中,以当前ThreadLocal
为 key,取出对应的副本值。
3、核心方法解析
set(T value)
:为当前线程设置变量副本
public void set(T value) {Thread t = Thread.currentThread(); // 获取当前线程ThreadLocalMap map = getMap(t); // 获取线程的threadLocalsif (map != null) {map.set(this, value); // 以当前ThreadLocal为key存储value} else {createMap(t, value); // 初始化threadLocals并存储}
}
get()
:获取当前线程的变量副本
public T get() {Thread t = Thread.currentThread();ThreadLocalMap map = getMap(t);if (map != null) {ThreadLocalMap.Entry e = map.getEntry(this); // 以当前ThreadLocal为key查询if (e != null) {@SuppressWarnings("unchecked")T result = (T)e.value;return result;}}return setInitialValue(); // 若未初始化,设置初始值(默认null)
}
remove()
:移除当前线程的变量副本
public void remove() {ThreadLocalMap m = getMap(Thread.currentThread());if (m != null) {m.remove(this); // 从线程的threadLocals中删除当前ThreadLocal对应的条目}
}
4、ThreadLocalMap 的特殊设计
ThreadLocalMap
是 ThreadLocal
的静态内部类,专为线程本地存储设计,与普通 HashMap
有以下区别:
- ** Entry 设计**:
ThreadLocalMap
的Entry
继承WeakReference<ThreadLocal<?>>
,即 key(ThreadLocal
对象)是弱引用。这是为了在ThreadLocal
对象被回收后,自动释放对应的Entry
,避免内存泄漏。 - 哈希冲突处理:采用线性探测法(而非链表)解决哈希冲突,当索引被占用时,依次检查下一个索引。
5、内存泄漏风险与避免
虽然 Entry
的 key 是弱引用,但仍可能存在内存泄漏:
- 若
ThreadLocal
对象被回收(key 为 null),但线程仍在运行(如线程池中的核心线程),则Entry
的 value 会一直被强引用,无法回收,导致内存泄漏。
避免方式:
- 使用完
ThreadLocal
后,主动调用remove()
方法清除 value。 - 在线程生命周期结束时,线程对象被回收,
threadLocals
也会随之回收。
6、典型应用场景
-
线程上下文传递:如在 Web 开发中,存储用户登录信息、请求上下文等,避免在方法间层层传递参数。
// 定义ThreadLocal存储用户信息 private static ThreadLocal<User> userThreadLocal = new ThreadLocal<>();// 存储用户信息 userThreadLocal.set(currentUser);// 在任意地方获取当前线程的用户信息 User user = userThreadLocal.get();// 使用完毕后移除 userThreadLocal.remove();
-
避免线程安全问题:如 SimpleDateFormat 是非线程安全的,可通过
ThreadLocal
为每个线程提供独立实例。private static ThreadLocal<SimpleDateFormat> sdfThreadLocal = ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd"));
7、总结
ThreadLocal
通过让每个线程持有独立的变量副本,实现了线程隔离,避免了多线程共享变量的同步问题。其核心是 Thread
类中的 ThreadLocalMap
,以 ThreadLocal
为 key 存储线程私有数据。使用时需注意主动调用 remove()
避免内存泄漏,适用于线程上下文存储、隔离非线程安全对象等场景。