Skip to content

Java 底层基础面经

更新: Invalid Date 字数: 0 字 时长: 0 分钟

Java 面向对象

面向对象三大特性是什么?分别怎么实现?

Java 面向对象的三大特征是:封装、继承、多态

  1. 封装:

    • 目的:隐藏对象内部的实现细节,对外暴露最少访问权限
    • 实现:属性使用 private 修饰,get/set 使用 public 修饰,利用不同的访问修饰符控制访问范围
  2. 继承:

    • 目的:代码复用和层次抽象
    • 实现:使用 extends 关键字实现类继承,Java 是单继承,子类可重写父类方法
  3. 多态:

    • 目的:同一个行为在不同对象上具有不同表现。(不同的子类对象的行为不同)
    • 实现:父类引用指向子类对象,子类重写父类方法,运行时动态绑定(动态分配机制)

重载和重写的区别?

重载(Overload)和重写(Override)是 Java 实现多态的两种方式,但本质完全不同。

  1. 重载(Overload) 重载发生在同一个类内部,指的是方法名相同,但参数列表不同。核心特征是:

    • 方法名相同
    • 参数列表必须不同(参数个数、类型或顺序不同)
    • 返回值、访问修饰符可以不同
    • 与返回值无关,仅仅返回值不同不能构成重载
    • 属于编译期多态
    • 方法调用在编译阶段确定,属于静态绑定
  2. 重写(Override) 重写发生在父类和子类之间,是子类对父类方法的重新实现。核心特征是:

    • 方法名必须相同
    • 参数列表必须相同
    • 返回值可以是父类返回类型的子类型(协变返回)
    • 访问权限不能比父类更严格
    • 抛出的异常不能比父类更宽泛
    • 不能重写 final、static、private 方法
    • 属于运行期多态
    • 方法调用在运行期根据对象实际类型确定,属于动态绑定

final、finally、finalize 的区别?

  1. final:

    • 是一个修饰符
    • 修饰类,类不能被继承;修饰方法,方法不可重写;修饰值基本类型变量,值不可修改;修饰引用类型变量,引用地址不可改变,但对象内容可以改变
  2. finally

    • 异常处理机制的一部分(运行期语义)
    • 不管是否发生异常,finally 块都会执行
    • 即使 try 中有 return,finally 仍然会执行
    • 如果 finally 中也有 return,会覆盖 try 中的 return
    • 但如果调用 System.exit(),finally 不会执行
    • 执行顺序:try → finally → return
  3. finalize

    • finalize() 是 Object 类中的方法
    • 在对象被垃圾回收前由 GC 调用
    • 用于释放资源
    • 问题:
      • 不确定何时执行
      • 可能导致对象“复活”
      • 严重影响 GC 性能

== 和 equals 的区别?

  1. == 是符号,对于基本类型是比较值,对于对象类型是比较引用地址

  2. equals 是 Object 的方法,equals 默认比较地址,但通常会被重写用于比较对象的逻辑内容;重写 equals 必须重写 hashCode。

hashCode 和 equals 为什么要一起重写?

在 Java 中,equals() 和 hashCode() 必须一起重写,主要是因为它们之间存在严格的契约关系,尤其是在哈希容器中使用时。

equals 相等的两个对象,hashCode 必须相等

Java 是值传递还是引用传递

Java 只有值传递,不存在引用传递。 基本类型传递的是值的副本,引用类型传递的是引用地址的副本。 方法内部可以修改对象属性,是因为多个引用指向同一个堆对象,但如果在方法内部重新赋值引用,不会影响外部变量。 本质上,Java 参数传递始终是值传递。

Java 的四种引用类型,区别与场景?

在 Java 中(JDK1.2 之后),对象引用分为四种:

  • 强引用(Strong Reference)
  • 软引用(Soft Reference)
  • 弱引用(Weak Reference)
  • 虚引用(Phantom Reference)

它们的主要区别在于:GC(垃圾回收)发生时对象的存活程度不同。

引用类型GC 时是否回收是否影响 GC典型应用场景
强引用不会被回收(只要有强引用)会阻止回收普通对象
软引用内存不足时回收不强制阻止缓存
弱引用只要 GC 就回收不阻止ThreadLocal、WeakHashMap
虚引用随时可能被回收不影响监控对象回收

基本数据类型和包装类区别?

  1. 基本数据类型

    • 存储的是值
    • 泛型和集合不支持基本类型
    • 基本类型在栈上或对象中
  2. 包装类型

    • 包装类型可以调用方法
    • 泛型和集合支持包装类型
    • 包装类型会创建对象增加 gc 压力
    • 包装类型在堆上

自动装箱拆箱原理?

装箱本质就是在编译期自动调用对应包装类的 valueOf 方法

拆箱本质就是在编译器自动调用对应包装类的 xxxValue 方法

整型装箱时可能会触发缓存机制(默认-128~127(正好是 byte 的取值范围),其中 Integer 的取值缓存范围可以配置)

自动装箱拆箱会增加 gc 压力

BigDecimal 为什么精确?

BigDecimal 精确计算的核心原因是:

  1. 使用 BigInteger 进行任意精度整数计算
  2. 使用 scale 表示小数位
  3. 采用十进制运算而不是二进制浮点运算

深拷贝和浅拷贝区别?

深拷贝和浅拷贝的区别在于 是否复制引用对象本身。

  1. 浅拷贝:

    • 创建一个新的对象
    • 基本类型字段直接复制值
    • 引用类型字段只复制引用地址,不复制对象
    • 两个对象会共享同一个引用对象
  2. 深拷贝:

    • 创建一个新的对象
    • 递归复制所有引用对象
    • 每个对象都是独立实例

Java 实现深拷贝的方式?

  1. 手动拷贝(推荐)
  2. 序列化实现深拷贝

final 能保证对象不可变吗?

final 不能保证对象不可变,它只能保证变量引用不被重新赋值。

如果引用指向的是可变对象,对象内部状态仍然可以改变。

真正的不可变类需要类为 final、字段为 private final、没有修改方法,并对可变字段做防御性拷贝。

此外,final 在 JMM 中具有特殊内存语义,可以保证初始化安全性。

final 和 volatile 区别?

final 用于修饰变量不可重新赋值,并在 JMM 中保证初始化安全;

volatile 用于保证多线程之间的可见性和有序性,但不保证原子性;

final 更多用于不可变设计,volatile 更多用于并发控制。

String 字符串

String 为什么设计成不可变?

String 不可变的实现原理:

  1. 内部数组使用 final 修饰,不可变更指向
  2. 所有修改操作都会返回新对象,不可修改值
  3. 类本身使用 final 修饰,不可继承重写

String 设计成不可变主要有四个原因:

  1. 第一,保证安全性,防止在校验后被篡改;
  2. 第二,天然线程安全,可以被多个线程共享;
  3. 第三,支持字符串常量池,实现对象复用;
  4. 第四,支持 HashCode 缓存,保证作为 HashMap key 时的稳定性。

因此 String 被设计为不可变类。

字符串常量池原理?

字符串常量池(String Constant Pool)是 JVM 为了提高字符串复用率、减少内存开销而设计的一种缓存机制。

当 JVM 发现相同的字符串字面量时,只会在常量池中保存一份字符串对象,多个引用会指向同一个对象。

字符串常量池在什么位置?

总结: 字符串常量池在 JDK6 及之前位于方法区中的永久代;

从 JDK7 开始,字符串常量池被移到了堆中

JDK8 移除了永久代,使用元空间,但字符串常量池仍然在堆中

之所以迁移到堆,是为了避免永久代 OOM,并提升垃圾回收效率。

JDK9 为什么从 char[] 变成 byte[]?

JDK9 将 String 底层从 char[] 改为 byte[],引入 coder 字段实现 Compact Strings 优化。

如果字符串是 Latin1 字符,则使用 1 字节存储;否则使用 UTF16 两字节存储。

这样可以在大量英文场景下节省约 50% 内存空间,同时几乎不影响性能。

String、StringBuilder、StringBuffer

  1. String

    • 每次拼接都会创建新对象(性能低)
    • 旧对象不会改变(不可变)
    • 线程安全
    • 有缓存机制(字符串常量池)
  2. StringBuffer

    • 可变
    • 线程安全(方法加了 synchronized)
    • 性能较低
    • 适用于:多线程下进行字符串拼接
  3. StringBuilder

    • 可变
    • 线程不安全(底层实现和 StringBuffer 基本一致,未加 synchronized)
    • 性能最高
    • 适用于:单线程字符串拼接

总结:

StringBuilder 和 StringBuffer 都继承自 AbstractStringBuilder

String 是不可变类,底层使用 final 数组实现,线程安全但频繁拼接性能较差。

StringBuffer 是可变字符串,方法加了 synchronized,线程安全但性能较低。

StringBuilder 是可变字符串,不加锁,线程不安全但性能最高。

单线程拼接使用 StringBuilder,多线程使用 StringBuffer,普通字符串定义使用 String。

抽象类和接口的区别?

  1. 抽象类:

    • 使用 abstract 定义,通过 extends 使用,一个类只能继承一个抽象类
    • 可以有普通方法、抽象方法、成员变量、构造方法、静态方法
    • is a 的设计思想
    • 多用于对类的抽象(模板类)
  2. 接口:

    • 使用 interface 定义,通过 implement 的使用,一个类可以实现多个接口
    • 只能有抽象方法,默认为 public abstract,变量默认为 public static final
    • has a 的设计思想
    • 多用于对能力的抽象

抽象类使用 abstract 修饰,通过继承的方式使用,可以有方法实现,多用于模板类和公共抽象 接口使用 interface 修饰,通过实现接口的方式使用,jdk 9 之后可以有默认的方法实现,多用于对象的能力抽象

List

List 是有序、可重复的集合接口,常见实现有 ArrayList、LinkedList 和 Vector。

ArrayList 底层是动态数组,查询快,插入删除慢。

LinkedList 是双向链表,插入删除相对灵活,但查询慢。

扩容机制是 1.5 倍扩容。

默认线程不安全,可以使用 CopyOnWriteArrayList 实现线程安全。

迭代时存在 fail-fast 机制,通过 modCount 实现。

List 有哪些常见实现类?

Java 中 List 的常见实现类有:

  • ArrayList
  • LinkedList
  • Verctor

ArrayList 和 LinkedList 区别?

  • ArrayList 的底层结构是动态数组,而 LinkedList 的底层结构是双向链表
  • 因为底层结构的不同,它们的使用场景也不同,ArrayList 支持随机访问,适合查询多的场景,而 LinkedList 适合插入删除多的场景

ArrayList 的扩容机制?

  • 1.5 倍扩容(减少扩容次数,避免空间浪费过大)
  • 扩容时先创建一个新的数组,将老数组的数据复制到新数组,然后指向新数组。

ArrayList 线程安全吗?

ArrayList 是线程不安全的,它的相关操作没有使用任何同步机制(synchronized),因此当多线程进行插入时会导致:

  • 数据覆盖
  • 数组越界
  • size 不准确

解决方案:

  • 使用 CopyOnWriteArrayList
  • 使用 Java 集合工具类提供的 Collections.synchronizedList

CopyOnWriteArrayList 原理?

核心思想: 写时复制

具体步骤:

  1. 加锁
  2. 复制新数组
  3. 修改新数组
  4. 替换引用
  5. 读时不加锁,读的是旧数组

这样的设计使 CopyOnWriteArrayList 的读性能高,且读写分离,适合读多写少的场景,缺点就是内存占用大,写性能差

Vector 和 ArrayList 区别?

Vector 的所有操作的加了 synchronized,是线程安全的,但这也导致它的性能较差,扩容时是 2 倍扩容。 ArrayList 是线程不安全的,性能较高,扩容时是 1.5 倍扩容

List 和 Set 的区别?

List 是有序的,可重复,有索引。

Set 是无序的,不可重复,无索引。

List 的 fail-fast 机制?

Java 集合在迭代过程中如果检测到结构或集合大小被修改,会通过比较 modCount 和 expectedModCount 抛出 ConcurrentModificationException。这种机制叫 fail-fast。

每次结构修改都会使 modCount++,而迭代器在创建时保存 expectedModCount,每次 next() 时都会检查是否一致。

它不是线程安全机制,只是一种错误检测机制。

HashMap 也有 fail-fast

所有基于 AbstractList / AbstractMap 的集合都有 modCount

解决方案: 可以使用迭代器或者改用 CopyOnWriteArrayList(读写分离)

Set

HashCode

HashCode 为什么使用 31 作为乘数?

31 是一个质数,可以减少哈希冲突; 同时 31 = 2^5 - 1,可以通过位运算 (i << 5) - i 优化; 在实践中分布效果良好; 并且在溢出情况下仍然能保持均匀性; 是性能和分布的平衡选择。

HashCode 和 HashMap 有什么关系?

hashCode() 影响:

  • HashMap 桶分布
  • 冲突率
  • 链表长度
  • 红黑树转换概率(JDK 8)

HashCode 设计不好:

  • 会导致大量碰撞
  • HashMap 退化为链表
  • 性能从 O(1) 变成 O(n)

为什么说质数能减少冲突?

因为质数的特性是:它只能被 1 和自己整除 这保证了质数存在的规律很少,不容易和输入数据产生公共因子,数值扩散更随机

HashMap

HashMap 的底层原理,在 jdk7 和 jdk8 中的区别?

HashMap 底层是数组加链表实现的哈希表结构。

JDK7 采用数组 + 链表,JDK8 引入红黑树优化,当链表长度大于等于 8 且容量大于等于 64 时转为红黑树,提高查询效率。

JDK7 使用头插法,扩容时可能形成环形链表;

JDK8 使用尾插法并优化了扩容过程,通过判断 (hash & oldCap) 决定元素是否移动。

HashMap 不是线程安全的,并发场景应使用 ConcurrentHashMap。

HashMap 的扰动函数?

HashMap 的扰动函数是 h ^ (h >>> 16), 它的作用是将 hashCode 的高 16 位混入低 16 位, 因为 HashMap 使用 (n - 1) & hash 只取低位来定位桶(只使用 hash 的低位来决定桶位置), 如果低位分布不好会导致冲突严重, 扰动函数可以提高低位的随机性,从而减少碰撞。

扰动函数的作用:

  1. 让高位参与运算
  2. 减少低位质量差导致的冲突
  3. 提高桶分布均匀性
  4. 在不增加成本的情况下提升性能
  5. 而且只做一次位运算,开销极低。

HashMap 的数据结构?

HashMap 底层是数组 + 链表 + 红黑树。 数组用于快速定位桶, 链表用于解决哈希冲突, 当冲突过多时转换为红黑树以提高查询效率。 JDK 8 之后引入红黑树,将最坏时间复杂度从 O(n) 优化为 O(log n)。

HashMap 的插入流程?

  1. 计算 hash(含扰动函数)
  2. 定位桶
  3. 如果为空 → 直接放入
  4. 如果冲突:
    • 链表遍历
    • key 相等 → 替换
    • 不等 → 插入尾部(JDK 8 尾插法)
  5. 判断是否树化
  6. 判断是否需要扩容

HashMap 为什么树化阈值是 8?

树化阈值设为 8 是基于工程测试得出的结果。 在默认负载因子下,链表长度达到 8 的概率极低, 但一旦达到,链表查找性能会明显下降, 此时转换为红黑树可以将时间复杂度从 O(n) 降为 O(log n)。 同时为了避免小容量下过早树化, 还要求数组长度 ≥ 64,否则优先扩容。

HashMap 为什么数组长度小于 64 不树化?

当容量较小时,冲突多半是因为:数组太小 解决方式应该是:扩容 而不是结构升级为红黑树 而且树结构:

  • 占用内存更大
  • 节点结构更复杂
  • 维护成本更高

树化太早会浪费内存。 所以设计上优先扩容。

HashMap 负载因子 0.75 为什么是最优?

负载因子 0.75 是时间复杂度和空间利用率的折中结果。

HashMap 为什么是线程不安全的?

HashMap 是线程不安全的,因为它在并发环境下没有任何同步控制机制。在多线程同时读写时,可能会导致:

  1. 数据覆盖(数据丢失)
  2. 链表形成环(JDK7 扩容时)
  3. size 统计错误
  4. 数据不一致
  5. 读取到脏数据

底层原理分析:

  1. put 操作不是原子操作,缺乏 CAS 或 synchronized 保护

  2. 扩容 resize 时可能死循环(JDK7),在 JDK7 中:

    • HashMap 使用头插法
    • 扩容时会重新计算 index
    • 多线程同时扩容可能导致链表反转
    • 最终形成环形链表
    • get() 时 CPU 100%

    JDK8 已解决这个问题:

    • 使用尾插法
    • 不会产生环
    • 但依然线程不安全(只是不会死循环)

总结:HashMap 没有使用任何并发保护的措施如:

  • volatile
  • synchronized
  • CAS
  • Lock

因此:

  • 线程之间不可见
  • 不保证有序性
  • 不保证原子性

完全不符合 JMM 并发安全三要素,所以 HashMap 是线程不安全的

面试总结版回答: HashMap 线程不安全的根本原因是其内部操作没有任何同步控制机制。多线程并发 put 时可能发生数据覆盖、size 不准确以及 JDK7 扩容时的死循环问题。JDK8 虽然解决了死循环问题,但仍然不能保证并发安全。在并发场景下应该使用 ConcurrentHashMap。

ConcurrentHashMap

ConcurrentHashMap 核心定位(ConcurrentHashMap 的理解)

ConcurrentHashMap 是一个线程安全的高并发 Hash 容器。 核心目标:

  • 保证线程安全
  • 提高并发性能
  • 尽量减少锁粒度

面试总结版回答: ConcurrentHashMap 是线程安全的高并发容器。JDK7 采用分段锁实现,JDK8 改为 CAS + synchronized 的桶级锁实现。它通过无锁读、细粒度锁和分段计数提高并发性能,同时不允许 null 来避免并发歧义。扩容支持多线程协助迁移,并在链表长度达到 8 时进行树化优化。

ConcurrentHashMap 如何保证操作的线程安全?

JDK1.7 采用 Segment 分段锁,每个 Segment 继承 ReentrantLock,实现锁分离。

JDK1.8 取消 Segment,采用 CAS + synchronized + volatile 实现线程安全。 读操作无锁,写操作在空桶时使用 CAS,在冲突时锁定当前桶节点。 扩容时采用多线程协助迁移,提高并发性能。

ConcurrentHashMap 在 Jdk7 和 Jdk8 的实现区别?

JDK7 的 ConcurrentHashMap 采用 Segment 分段锁机制,将数据分成多个段,每段使用 ReentrantLock 控制并发,默认最大并发度为 16。

JDK8 去掉了 Segment,改为 Node 数组 + CAS + synchronized 的桶级锁实现,锁粒度更细,并支持红黑树优化,同时采用类似 LongAdder 的分段计数方式,整体并发性能更高,结构也更加简洁。

ConcurrentHashMap 在 JDK7 中的扩容机制?

JDK7 中 ConcurrentHashMap 的结构,

text
ConcurrentHashMap
 ├── Segment[] segments
        ├── HashEntry[] table

每个 Segment 其实就是一个小型 HashMap(数组+链表)

面试总结版回答: JDK7 的 ConcurrentHashMap 扩容是基于 Segment 的局部扩容机制,当某个 Segment 内元素超过负载因子阈值时,在持有该 Segment 锁的情况下,将内部数组扩容为原来的 2 倍并重新迁移数据。Segment 数量在初始化后固定,不会变化。

ConcurrentHashMap 在 JDK8 中的扩容机制?

JDK8 中 ConcurrentHashMap 的结构:

text
ConcurrentHashMap
 ├── Node<K,V>[] table
         ├── Node(链表)
         └── TreeBin(红黑树)

不再有 Segment,而是数组 + 链表 + 红黑树,链表长度达到 8 时进行树化 JDK8 中 ConcurrentHashMap 的扩容核心是:创建新数组 + 多线程协助迁移 + ForwardingNode 标记

扩容流程(核心步骤):

  1. 创建新数组

  2. 设置扩容标识:通过一个关键变量:sizeCtl

    • 正数:表示扩容阈值
    • 负数:表示正在扩容
    • -(1 + 扩容线程数):表示有多少线程在协助扩容 这点是 JDK8 的核心设计之一
  3. 多线程协助迁移(重点),当一个线程触发扩容后:

    • 它不会独自完成扩容
    • 其他执行 put 的线程发现正在扩容
    • 会主动参与数据迁移 这就是:help transfer 机制
  4. 数据迁移规则 迁移时:

    • 每个桶的数据会被重新分布
    • 由于容量翻倍,节点只可能:
      • 留在原位置
      • 或移动到 原位置 + oldCapacity

    这是位运算优化的结果

  5. ForwardingNode 标记,迁移完成的桶会被设置为:ForwardingNode。 作用:

    • 表示该桶已经迁移
    • 指向新数组
    • 读写线程遇到它会跳转到新表

    这保证了:扩容期间仍然可以安全访问

JDK8 的扩容是:无全局锁 + 桶级别迁移 + 多线程协作

面试总结版回答: JDK8 的 ConcurrentHashMap 扩容是全局扩容机制,采用容量翻倍策略,并通过 CAS 控制扩容状态,利用多线程协助迁移数据,使用 ForwardingNode 作为迁移标记,保证扩容期间读写操作仍然安全。这种设计相比 JDK7 的 Segment 局部扩容,提升了并发性能和扩容效率。

HashSet

HashSet 是一个基于 HashMap 实现的无序、不重复集合,其底层数据结构是数组 + 链表 + 红黑树,并通过 hashCode 和 equals 实现元素去重。

HashSet 的底层实现原理

HashSet 本质上是基于 HashMap 实现的,HashSet 只存储 key,value 使用一个固定的 Object 作为占位。

HashSet 的去重原理

HashSet 的去重是借助 hashCode 和 equals 两个方法实现的,当 hash 相同 && equals 为 true时说明元素已经存在,不会插入

HashSet 线程安全问题?

HashSet 不是线程安全的。可能会出现数据丢失和死循环。

解决方案是:Collections.synchronizedSet(new HashSet<>()); 或者 ConcurrentHashMap + keySet

ThreadLocal 的底层原理,内存泄漏的原因和解决方案?

ThreadLocal 用于实现线程隔离,每个线程内部维护一个 ThreadLocalMap,数据实际存储在线程对象中。ThreadLocalMap 的 key 是 ThreadLocal 的弱引用,value 是强引用。

当 ThreadLocal 被回收但线程未结束时,key 会变为 null,而 value 仍然被强引用,可能导致内存泄漏。

解决方案是在使用完毕后在 finally 块中调用 remove(),避免在线程池环境中遗留数据。

ThreadLocal 适用于保存线程上下文、数据库连接和事务信息等场景。

为什么 ThreadLocalMap 不使用 HashMap?

  1. HashMap 是线程不安全的
  2. 对 ThreadLocal 的引用需要是弱引用,HashMap 是强引用,如果使用 WeakHashMap 则性能成本会更大
  3. ThreadLocalMap 是轻量级的,小容器,而 HashMap 是重量级的通用容器,性能成本很大且结构复杂,操作繁琐
  4. ThreadLocalMap 不用 HashMap 的核心原因是:它不是一个通用容器,而是一个高度特化的线程私有存储结构。

如有转载或 CV 的请标注本站原文地址