Java基础知识点汇总(五)

一、限定通配符和非限定通配符

在Java泛型中,通配符(Wildcard)用于表示"未知类型",解决泛型类型的灵活性问题。根据是否对类型范围进行约束,分为限定通配符非限定通配符,核心区别在于是否限制了匹配的类型范围。

1、非限定通配符(Unbounded Wildcard):?

  • 定义? 表示"任意引用类型",没有任何继承或实现的约束,可匹配所有类型(如 StringIntegerObject 等)。

  • 适用场景 :仅需要操作"所有类型共有的行为"(如 Object 类的方法),无需关注具体类型是什么。

  • 特点

    • 只能"读取"数据,且读取的元素类型被限定为 Object(因为无法确定具体类型)。
    • 不能"写入"数据(除了 null,因为无法确定要写入的类型是否匹配)。
  • 示例

    定义一个打印任意泛型列表元素的方法,无需关心列表中存储的具体类型:

    java 复制代码
    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 的哪个子类。
  • 示例

    计算任意数字列表的总和(IntegerDouble 等均继承自 Number):

    java 复制代码
    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 及其父类的列表中添加 StudentStudent 继承自 Person):

    java 复制代码
    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中的实现方式

一个类要支持序列化,需满足:

  1. 实现 java.io.Serializable 接口(这是一个标记接口,无需实现任何方法)。
  2. 通常会显式声明 serialVersionUID(如前所述,用于版本控制)。

示例代码

java 复制代码
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的主要原因如下:

  1. 保证版本兼容性

    如果不手动指定serialVersionUID,当类的结构发生变化(如添加/删除字段、修改方法等)时,Java会重新计算serialVersionUID。此时,用旧版本类序列化的对象,在使用新版本类反序列化时会抛出InvalidClassException,因为 serialVersionUID 不匹配。

  2. 控制反序列化行为

    当手动指定了serialVersionUID后,即使类的结构发生变化,只要serialVersionUID保持不变,Java就会尝试将旧版本对象反序列化到新版本类中(会按照一定规则处理字段的增减),提高了类的版本兼容性。

示例代码:

java 复制代码
import java.io.Serializable;

public class User implements Serializable {
    // 手动指定serialVersionUID,一般我们都直接设置为1L
    private static final long serialVersionUID = 1L;
    
    private String name;
    private int age;
    
    // 构造方法、getter、setter等
}

简单来说,指定serialVersionUID是为了在类的结构发生合理变化时,仍然能够正确地反序列化历史版本的对象,增强了序列化机制的灵活性和兼容性。

如果不指定serialVersionUID会发生什么?

如果不手动指定 serialVersionUID,Java 虚拟机会在编译时自动生成一个,其值基于类的结构信息(如类名、字段、方法、接口等)通过特定算法计算得出。这种自动生成的机制可能导致以下问题:

  1. 类结构变化时反序列化失败

    当类的结构发生任何修改(例如添加/删除字段、修改方法参数、甚至修改注释以外的任何代码),Java 会重新计算并生成一个新的 serialVersionUID。此时:

    • 用旧版本类序列化的对象,在使用新版本类反序列化时,会因为 serialVersionUID 不匹配而抛出 InvalidClassException
    • 这意味着类的微小改动可能导致历史序列化数据完全无法使用
  2. 不同环境可能生成不同值

    自动生成 serialVersionUID 的算法可能依赖于编译器实现或 JVM 版本。同一类在不同编译器(如 javac、Eclipse 编译器)或不同 JDK 版本中编译时,可能生成不同的 serialVersionUID,导致跨环境反序列化失败。

  3. 缺乏版本控制主动权

    不指定 serialVersionUID 时,开发者无法自主控制类的版本兼容性。即使类的改动是向后兼容的(如仅添加一个可选字段),也可能因为自动生成的 serialVersionUID 变化而导致反序列化失败。

示例场景

假设最初有一个类:

java 复制代码
import java.io.Serializable;

public class User implements Serializable {
    private String name;
    // 未指定serialVersionUID
}

用这个类序列化一个对象后,若后来给类添加一个字段:

java 复制代码
public class User implements Serializable {
    private String name;
    private int age; // 新增字段
    // 未指定serialVersionUID
}

此时自动生成的 serialVersionUID 已改变,用新版本类反序列化旧对象时会抛出异常。

因此,手动指定 serialVersionUID 是保证序列化兼容性的最佳实践,它让开发者可以自主决定类的版本是否兼容,而非依赖编译器的自动生成机制。

对于不希望序列化的字段------使用 transient 关键字:

这是最常用的方式,在字段声明前添加 transient 修饰符,该字段就会被排除在序列化过程之外。

示例代码:

java 复制代码
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接口创建线程,所有线程共享所属进程的堆内存(如对象实例),但每个线程有自己的栈内存(存储局部变量、方法调用栈)。

    java 复制代码
    // 示例:一个进程中的两个线程
    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 内部的哈希算法进一步处理(减少哈希冲突):

java 复制代码
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 进行按位与(&) 运算,得到数组索引:

java 复制代码
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,是权衡空间和时间效率的阈值(值越高,空间利用率高但冲突率高)。
  • 扩容过程
    1. 创建一个新的数组,长度为原数组的 2 倍(保证仍是 2 的幂次方)。
    2. 将原数组中的元素重新计算索引,迁移到新数组中(这个过程称为"重哈希")。
    3. 若原链表长度 ≥ 8 且新数组长度 ≥ 64,会将链表转为红黑树;若长度 ≤ 6,则红黑树转回链表。

5. 关键成员变量

java 复制代码
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() 为例)

  1. 计算 Key 的哈希值和数组索引。
  2. 若索引位置为空,直接插入新节点。
  3. 若索引位置不为空:
    • 若头节点的 Key 与插入的 Key 相同(equals() 为 true),则覆盖 Value。
    • 否则,遍历链表/红黑树:
      • 若找到相同 Key,覆盖 Value。
      • 若未找到,在链表尾部插入新节点(JDK 1.8 尾插法,避免死循环)。
  4. 插入后,若链表长度 ≥ 8 且数组长度 ≥ 64,将链表转为红黑树。
  5. size 超过阈值,触发扩容。

7. 线程安全性
HashMap非线程安全的:

  • 多线程并发修改时,可能导致链表成环(扩容时)、数据丢失等问题。
  • 线程安全的替代方案:ConcurrentHashMap(JUC 包)、Hashtable(古老且低效,不推荐)。

总结:
HashMap 通过哈希函数将 Key 映射到数组索引,用链表/红黑树解决哈希冲突,通过扩容机制保证效率,是 Java 中查询效率极高(平均 O(1))的键值对存储结构,广泛用于缓存、配置存储等场景。

六、HashMap 线程不安全体现在哪里?

HashMap 的线程不安全主要体现在多线程并发修改(如 putremoveresize 等操作)时,可能出现数据不一致链表成环扩容丢失数据等问题。以下是具体表现:

  1. 扩容时的链表成环(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 的环。
  1. 数据覆盖/丢失
    多线程并发执行 put 操作时,可能导致新插入的键值对被覆盖或丢失:
  • 场景 1:两个线程同时计算出相同的索引,且该位置原本为空。

    • 线程 A 检查到索引位置为空,准备插入节点。
    • 线程 B 也检查到同一位置为空,抢先插入节点。
    • 线程 A 继续执行插入,覆盖线程 B 插入的节点,导致 B 的数据丢失。
  • 场景 2:并发修改同一链表/红黑树。

    • 线程 A 正在遍历链表查找插入位置。
    • 线程 B 同时删除了该链表中的某个节点,导致线程 A 访问到已被删除的节点,可能插入错误位置或丢失数据。
  1. size 计数不准确
    HashMapsize 变量用于记录键值对数量,多线程并发修改时,size++ 操作(非原子操作)可能导致计数错误:
  • 线程 A 和 B 同时读取到 size = 10
  • 线程 A 执行 size++size = 11
  • 线程 B 基于原始值 10 执行 size++ 后仍为 11,导致实际数量比记录值多 1,计数失真。
  1. JDK 1.8 仍不安全
    JDK 1.8 虽然将扩容的头插法改为尾插法,解决了链表成环问题,但仍未解决线程安全问题:
  • 并发 put 时仍可能出现数据覆盖。
  • 红黑树的旋转操作在并发下可能导致结构破坏,引发查询异常。

如何避免线程不安全?

  1. 使用 ConcurrentHashMap :JUC 包提供的线程安全实现,通过分段锁(JDK 1.7)或 CAS + synchronized(JDK 1.8+)保证并发安全,性能优于 Hashtable
  2. 加锁保护 :使用 Collections.synchronizedMap(new HashMap<>()),通过全局锁保证安全,但并发效率低。
  3. 避免并发修改 :在单线程环境中使用 HashMap,多线程场景下通过线程隔离(如 ThreadLocal)避免共享。

总结HashMap 的线程不安全源于并发修改时的非原子操作和数据结构竞争,核心问题是无法保证多线程操作的可见性、原子性和有序性。在并发场景中,必须使用线程安全的替代方案。

七、ConcurrentHashMap 如何保证线程安全的?

ConcurrentHashMap 是 Java 并发包(JUC)中提供的线程安全哈希表实现,其线程安全机制在 JDK 1.7JDK 1.8+ 中有显著差异,核心思路是通过精细化锁控制减少竞争,同时保证高效并发。

1、JDK 1.7 的实现:分段锁(Segment)

JDK 1.7 中 ConcurrentHashMap 采用 "分段锁"(Segment) 机制,核心思想是将哈希表分为多个独立的"段",每个段对应一把锁,实现"不同段的操作可以并发执行"。

(1)数据结构

  • 底层由 Segment 数组组成,每个 Segment 本质上是一个小的 HashMap(包含数组 + 链表)。
  • 每个 Segment 独立加锁,多个线程操作不同 Segment 时无需竞争同一把锁。

(2)线程安全保证

  • 分段加锁 :当执行 putremove 等修改操作时,只对当前 Key 所在的 Segment 加锁(ReentrantLock),其他 Segment 仍可被其他线程访问。
  • 读操作无锁 :获取元素时无需加锁,通过 volatile 保证可见性(Segment 数组和每个 HashEntryvalue 都用 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>[] tablevolatile 修饰,保证数组修改对其他线程可见。
    • 节点 Nodevaluenext 指针也用 volatile 修饰,确保读写可见性。

(3)核心操作示例(put 方法)

  1. 计算 Key 的哈希值和数组索引。
  2. 若索引位置为空,通过 CAS 插入新节点(无锁)。
  3. 若索引位置不为空:
    • 若头节点是 MOVED 状态(表示正在扩容),协助扩容。
    • 否则,对头节点加 synchronized ,遍历链表/红黑树:
      • 若找到相同 Key,更新 Value。
      • 若未找到,在链表尾部插入新节点(或红黑树插入)。
  4. 插入后检查是否需要转为红黑树,或触发扩容。

3、JDK 1.8+ 相比 1.7 的改进

  1. 锁粒度更细:从"段级锁"细化到"桶级锁",减少锁竞争。
  2. 去掉 Segment 结构:内存占用更低,结构更简单。
  3. 引入红黑树:优化长链表的查询性能(O(n) → O(log n))。
  4. CAS 减少加锁:简单操作(如空桶插入)用 CAS 无锁实现,效率更高。

总结
ConcurrentHashMap 保证线程安全的核心是:

  • JDK 1.7:通过"分段锁"隔离不同段的竞争,实现多段并发。
  • JDK 1.8+ :通过"CAS 无锁 + 桶级 synchronized 锁"实现更细粒度的并发控制,同时保留 volatile 保证可见性。

这种设计在保证线程安全的同时,最大限度地提高了并发效率,是高并发场景下替代 HashMap(非线程安全)和 Hashtable(低效全局锁)的最佳选择。

八、CAS是什么

Compare-And-Swap(CAS,比较并交换) 是一种无锁原子操作 ,用于实现多线程环境下的同步控制,避免了传统锁机制的开销和竞争问题。它是并发编程中的核心技术,广泛应用于 JUC 包(如 AtomicIntegerConcurrentHashMap)等场景。

1、CAS 的核心思想

CAS 操作包含三个关键参数:

  • 内存地址(V):要操作的变量在内存中的地址。
  • 预期值(A):线程认为变量当前应该的值。
  • 新值(B):如果变量当前值等于预期值,就将其更新为新值。

操作逻辑

当且仅当内存地址 V 中的值等于预期值 A 时,才将该值更新为 B,否则不做任何操作。整个过程是原子性的(由 CPU 指令直接支持,不可中断)。

2、CAS 的执行流程

  1. 线程读取内存地址 V 中的当前值,记为 当前值
  2. 线程判断 当前值 是否等于预期值 A
    • 若相等,将 V 中的值更新为 B
    • 若不相等,说明该值已被其他线程修改,当前线程不做操作(或重试)。
  3. 无论是否更新,都返回 V 中的旧值(供线程判断是否成功)。

简化示例 (模拟 AtomicIntegerincrementAndGet 操作):

java 复制代码
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)优势

  1. 无锁开销 :避免了传统锁(如 synchronized)的上下文切换、线程阻塞唤醒等开销,适合低冲突场景。
  2. 高并发性能:在竞争不激烈时,CAS 的效率远高于锁机制。
  3. 细粒度控制:可针对单个变量进行原子操作,比锁的粒度更细。

(2)问题

  1. ABA 问题

    • 场景:变量值从 A 被改为 B,又改回 A。CAS 会认为值未变,但实际已被修改。
    • 解决:添加版本号(如 AtomicStampedReference,用"值 + 版本号"作为判断依据)。
  2. 循环重试开销

    • 若并发冲突频繁,CAS 会不断重试(如上述 increment 方法的 do-while 循环),导致 CPU 占用过高。
  3. 只能保证单个变量的原子性

    • CAS 仅能对单个变量进行原子操作,无法直接实现多个变量的原子性(需结合其他机制)。

5、CAS 在 Java 中的应用

  • 原子类java.util.concurrent.atomic 包下的类(如 AtomicIntegerAtomicReference)均基于 CAS 实现。
  • 并发容器ConcurrentHashMap(JDK 1.8+)用 CAS 实现无锁初始化和节点插入。
  • 线程池ThreadPoolExecutor 中用 CAS 维护工作线程数量等状态。

6、总结

CAS 是一种高效的无锁同步机制,通过硬件保证的原子操作实现多线程安全,核心是"比较预期值并更新"。它在低冲突场景下性能优异,但存在 ABA 问题和重试开销,是 Java 并发编程的基础技术之一。

九、ArrayList 和 LinkedList 区别?

ArrayListLinkedList 是 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)),直接修改头节点或尾节点的引用。
内存占用 内存连续,存储元素本身,浪费较少(但扩容会预留空间)。 每个元素需额外存储 prevnext 引用,内存开销更大。

3. 其他关键区别

  • 实现的接口

    • ArrayList 仅实现 List 接口。
    • LinkedList 同时实现 List 接口和 Deque 接口(双端队列),因此可作为队列(Queue)或栈(Stack)使用,提供 poll()offer()push()pop() 等方法。
  • 迭代器性能

    • 两者的 Iterator 迭代(for-each 循环)性能相近,但 LinkedListIterator 遍历比随机访问(get(i))高效得多(避免重复从头遍历)。
    • LinkedList 支持双向迭代器(ListIterator),可向前/向后遍历,而 ArrayListListIterator 功能类似但基于数组索引。
  • 扩容机制

    • ArrayList 有扩容成本(当元素数量超过容量时,复制旧数组到新数组)。
    • LinkedList 无需扩容,添加元素时只需创建新节点并调整引用。

4. 适用场景

  • 优先使用 ArrayList

    • 需要频繁通过索引访问元素(如 get(i)set(i, value))。
    • 元素数量固定或变化不大,且以尾部添加/删除为主。
    • 对内存占用较敏感的场景。
  • 优先使用 LinkedList

    • 需要频繁在列表中间或首尾添加/删除元素(如实现队列、栈、链表结构)。
    • 元素数量不确定,且插入删除操作远多于查询操作。

示例代码对比:

java 复制代码
// 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 的核心思想是:让每个线程持有一个独立的变量副本,线程对变量的操作仅影响自身副本,不干扰其他线程

其底层通过以下结构实现:

  1. 每个 Thread 线程内部有一个 threadLocals 变量 (类型为 ThreadLocal.ThreadLocalMap),这是一个自定义的哈希表,用于存储该线程的所有 ThreadLocal 变量副本。

  2. ThreadLocal 本身不存储数据 ,仅作为 ThreadLocalMap 的 key,用于在当前线程的 threadLocals 中查找对应的变量副本。

2、数据结构关系

复制代码
Thread (线程)
└── threadLocals: ThreadLocalMap (线程本地的哈希表)
    ├── 键: ThreadLocal 对象本身
    └── 值: 该线程对应的变量副本
  • 当线程通过 ThreadLocal.set(value) 存储数据时,实际是在当前线程的 threadLocals 中,以当前 ThreadLocal 为 key,存储 value 副本。
  • 当通过 ThreadLocal.get() 获取数据时,是从当前线程的 threadLocals 中,以当前 ThreadLocal 为 key,取出对应的副本值。

3、核心方法解析

  1. set(T value):为当前线程设置变量副本
java 复制代码
public void set(T value) {
    Thread t = Thread.currentThread(); // 获取当前线程
    ThreadLocalMap map = getMap(t);    // 获取线程的threadLocals
    if (map != null) {
        map.set(this, value);          // 以当前ThreadLocal为key存储value
    } else {
        createMap(t, value);           // 初始化threadLocals并存储
    }
}
  1. get():获取当前线程的变量副本
java 复制代码
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)
}
  1. remove():移除当前线程的变量副本
java 复制代码
public void remove() {
    ThreadLocalMap m = getMap(Thread.currentThread());
    if (m != null) {
        m.remove(this); // 从线程的threadLocals中删除当前ThreadLocal对应的条目
    }
}

4、ThreadLocalMap 的特殊设计
ThreadLocalMapThreadLocal 的静态内部类,专为线程本地存储设计,与普通 HashMap 有以下区别:

  • ** Entry 设计**:ThreadLocalMapEntry 继承 WeakReference<ThreadLocal<?>>,即 key(ThreadLocal 对象)是弱引用。这是为了在 ThreadLocal 对象被回收后,自动释放对应的 Entry,避免内存泄漏。
  • 哈希冲突处理 :采用线性探测法(而非链表)解决哈希冲突,当索引被占用时,依次检查下一个索引。

5、内存泄漏风险与避免

虽然 Entry 的 key 是弱引用,但仍可能存在内存泄漏:

  • ThreadLocal 对象被回收(key 为 null),但线程仍在运行(如线程池中的核心线程),则 Entry 的 value 会一直被强引用,无法回收,导致内存泄漏。

避免方式

  • 使用完 ThreadLocal 后,主动调用 remove() 方法清除 value。
  • 在线程生命周期结束时,线程对象被回收,threadLocals 也会随之回收。

6、典型应用场景

  1. 线程上下文传递:如在 Web 开发中,存储用户登录信息、请求上下文等,避免在方法间层层传递参数。

    java 复制代码
    // 定义ThreadLocal存储用户信息
    private static ThreadLocal<User> userThreadLocal = new ThreadLocal<>();
    
    // 存储用户信息
    userThreadLocal.set(currentUser);
    
    // 在任意地方获取当前线程的用户信息
    User user = userThreadLocal.get();
    
    // 使用完毕后移除
    userThreadLocal.remove();
  2. 避免线程安全问题 :如 SimpleDateFormat 是非线程安全的,可通过 ThreadLocal 为每个线程提供独立实例。

    java 复制代码
    private static ThreadLocal<SimpleDateFormat> sdfThreadLocal = 
        ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd"));

7、总结
ThreadLocal 通过让每个线程持有独立的变量副本,实现了线程隔离,避免了多线程共享变量的同步问题。其核心是 Thread 类中的 ThreadLocalMap,以 ThreadLocal 为 key 存储线程私有数据。使用时需注意主动调用 remove() 避免内存泄漏,适用于线程上下文存储、隔离非线程安全对象等场景。

相关推荐
搬码临时工3 小时前
怎样让外网计算机访问局域网计算机?通过公网地址访问不同内网服务的设置方法
开发语言·php
晚安里3 小时前
JVM相关 4|JVM调优与常见参数(如 -Xms、-Xmx、-XX:+PrintGCDetails) 的必会知识点汇总
java·开发语言·jvm·后端·算法
Qiang_san3 小时前
C++11新特性 | 欢迎来到现代C++的世界!
开发语言·c++
要做朋鱼燕3 小时前
【C++】迭代器详解与失效机制
开发语言·c++·算法
纪莫4 小时前
技术面:Java并发(线程池、ForkJoinPool)
java·java面试⑧股
叫我阿柒啊4 小时前
从Java全栈到前端框架:一次真实的面试对话
java·spring boot·微服务·前端框架·vue3·全栈开发
齐 飞4 小时前
SpringBoot实现国际化(多语言)配置
java·spring boot·后端
萤丰信息4 小时前
智慧工地如何撕掉“高危低效”标签?三大社会效益重构建筑业价值坐标
java·大数据·人工智能·微服务·重构·架构·智慧工地
fqq34 小时前
记录一个细节问题Servlet注解有两种方式
java·servlet·tomcat