2025Java 面试八股①(含 121 道面试题和答案)
之前的100道Go的面试题和答案反馈不错,我又整理了 121 道 Java 的常见面试题,有需要的朋友可以点赞收藏一下。
干货太多了,一篇都装不下,今天先发 60 个
1. Java 是什么?⭐⭐⭐
Java 是一种面向对象的编程语言和计算平台,最早由 Sun Microsystems 于 1995 年发布,后来被 Oracle 公司收购。Java 被广泛用于开发各种应用程序,从桌面应用到企业级服务器和移动应用。
java 具有以下特点:
1、平台无关性
Java 的口号是“编写一次,运行到处”(Write Once, Run Anywhere)。这主要是通过 Java 虚拟机(JVM)实现的,JVM 是一个可以在任何支持 Java 的平台上运行 Java 字节码的虚拟机。Java 源代码编译成字节码后,可以在任何安装了 JVM 的平台上运行,无需重新编译。
2、面向对象
Java 是一种面向对象的编程语言,支持封装、继承和多态等面向对象的基本概念。面向对象编程使代码更具模块化、可重用性和可维护性。
3、简单
Java 的语法与 C++ 类似,但去掉了 C++ 中一些复杂和容易出错的特性(如指针算术和多重继承),使其更简单易学。
4、内存管理
Java 使用自动垃圾回收机制(Garbage Collection)来管理内存。这意味着程序员不需要手动释放内存,减少了内存泄漏和其他内存管理错误的可能性。
5、 多线程
Java 内置了对多线程编程的支持,使得开发并发应用程序更加容易。Java 提供了丰富的线程 API 和高级并发工具类(如 java.util.concurrent 包)。
6、 广泛的应用领域
Java 被广泛应用于各个领域,包括:
• 企业级应用:Java EE(Enterprise Edition)提供了开发企业级应用的标准平台。
• 移动应用:Android 应用程序主要使用 Java 编写。
• Web 应用:Java 提供了多种框架和工具(如 Spring、Hibernate)来开发 Web 应用。
• 嵌入式系统:Java 也可以用于开发嵌入式系统和物联网设备。
7、社区和生态系统
Java 拥有一个庞大而活跃的开发者社区和丰富的生态系统,提供了大量的开源库、框架和工具,帮助开发者快速构建高质量的应用程序。
2. 介绍一下常见的 list 实现类⭐⭐⭐⭐⭐
ArrayList
ArrayList 是最常用的 List 实现类,线程不安全,内部是通过数组实现的,继承了 AbstractList,实现了 List。它允许对元素进行快速随机访问。数组的缺点是每个元素之间不能有间隔,当数组大小不满足时需要增加存储能力,就要将已经有数组的数据复制到新的存储空间中。当从 ArrayList 的中间位置插入或者删除元素时,需要对数组进行复制、移动、代价比较高。因此,它适合随机查找和遍历,不适合插入和删除。排列有序,可重复,容量不够的时候,新容量的计算公式为:
newCapacity = oldCapacity + (oldCapacity >> 1),这实际上是将原容量增加 50%(即乘以 1.5)。
ArrayList 实现了 RandomAccess 接口,即提供了随机访问功能。RandomAccess 是 java 中用来被 List 实现,为 List 提供快速访问功能的。在 ArrayList 中,我们即可以通过元素的序号快速获取元素对象,这就是快速随机访问。
ArrayList 实现 java.io.Serializable 接口,这意味着 ArrayList 支持序列化,能通过序列化去传输。
LinkedList(链表)
LinkedList 是用链表结构存储数据的,线程不安全。很适合数据的动态插入和删除,随机访问和遍历速度比较慢,增删快。另外,他还提供了 List 接口中没有定义的方法,专门用于操作表头和表尾元素,可以当作堆栈、队列和双向队列使用。底层使用双向链表数据结构。
LinkedList 是一个继承于 AbstractSequentialList 的双向链表。它也可以被当作堆栈、队列或双端队列进行操作。
Vector(数组实现、线程同步)
Vector 与 ArrayList 一样,也是通过数组实现的,Vector 和 ArrayList 用法上几乎相同,但 Vector 比较古老,一般不用。Vector 是线程同步的,效率低。即某一时刻只有一个线程能够写 Vector,避免多线程同时写而引起的不一致性,但实现同步需要很高的花费,因此,访问它比访问 ArrayList 慢。默认扩展一倍容量。
3. ArrayList 初始容量是多少?⭐⭐⭐⭐⭐
ArrayList 是 Java 中用于动态数组的一个类。它可以在添加或删除元素时自动调整其大小。然而,ArrayList 有一个默认的初始容量,这个容量是在你创建 ArrayList 实例时如果没有明确指定容量参数时所使用的。
在 Java 的 ArrayList 实现中,默认的初始容量是 10。这意味着当你创建一个新的 ArrayList 而不指定其容量时,它会以一个内部数组长度为 10 的数组来开始。当添加的元素数量超过这个初始容量时,ArrayList 的内部数组会进行扩容,通常是增长为原来的 1.5 倍。
但是,如果你知道你将要在 ArrayList 中存储多少元素,或者预计会存储多少元素,那么最好在创建时指定一个初始容量,这样可以减少由于扩容而导致的重新分配数组和复制元素的操作,从而提高性能。
自从 1.7 之后,arraylist 初始化的时候为一个空数组。但是当你去放入第一个元素的时候,会触发他的懒加载机制,使得数量变为 10。
所以我们的 arraylist 初始容量的确是 10。只不过 jdk8 变为懒加载来节省内存。进行了一点优化。
所以我们的 arraylist 初始容量的确是 10。只不过 jdk8 变为懒加载来节省内存。进行了一点优化。
4. JVM、JRE 和 JDK 之间的关系⭐⭐⭐
JVM、JRE 和 JDK 是 Java 生态系统中的三个核心组件,它们在 Java 开发和运行时环境中扮演着不同的角色。
JVM (Java Virtual Machine)
Java 虚拟机是一种抽象计算机,它为 Java 程序提供了运行环境。JVM 的主要职责是执行 Java 字节码,并将其转换为机器代码,以便在特定平台上运行。JVM 是实现 Java 平台无关性的关键组件。
主要负责:字节码执行:JVM 负责加载、验证和执行 Java 字节码。内存管理:JVM 管理堆内存和栈内存,并执行垃圾回收(Garbage Collection)。安全性:JVM 提供了安全机制,确保 Java 应用在受控环境中运行。
JRE (Java Runtime Environment)
Java 运行时环境是一个软件包,它提供了运行 Java 应用程序所需的所有组件。JRE 包含 JVM 以及 Java 类库和其他支持文件。JRE 是运行 Java 应用程序的最低要求。JRE 包含 JVM,用于执行 Java 字节码。JRE 包含 Java 标准类库(如 java.lang、java.util 等),这些类库为 Java 应用提供基础功能。
JDK (Java Development Kit)
Java 开发工具包是为 Java 开发者提供的完整开发环境。JDK 包含 JRE 以及开发 Java 应用程序所需的工具和库。JDK 是开发和编译 Java 程序的必备工具。JDK 包含一个完整的 JRE 环境。包括编译器(javac)、调试器(jdb)、打包工具(jar)等,用于开发、编译和调试 Java 程序。提供了额外的库和头文件,用于开发 Java 应用程序。
关系
JVM 是 JRE 的一部分:JVM 是 JRE 中的核心组件,负责执行 Java 字节码。
JRE 是 JDK 的一部分:JRE 提供了运行 Java 应用程序所需的环境,而 JDK 则在此基础上添加了开发工具和额外的库。
图示关系
JDK
├── JRE
│ ├── JVM
│ ├── 核心类库
│ └── 其他支持文件
├── 开发工具(javac、jdb、jar 等)
└── 额外的库和头文件
5. ArrayList 是如何扩容的?⭐⭐⭐⭐⭐
ArrayList 的扩容机制是 Java 集合框架中一个重要的概念,它允许 ArrayList 在需要时自动增加其内部数组的大小以容纳更多的元素。主要有以下的步骤
1、初始容量和扩容因子:
当创建一个新的 ArrayList 对象时,它通常会分配一个初始容量,这个初始容量默认为 10。
ArrayList 的扩容因子是一个用于计算新容量的乘数,默认为 1.5。
2、扩容触发条件:
当向 ArrayList 中添加一个新元素,并且该元素的数量超过当前数组的容量时,就会触发扩容操作。
3、扩容策略:
扩容时,首先根据当前容量和扩容因子计算出一个新的容量。新容量的计算公式为:
newCapacity = oldCapacity + (oldCapacity >> 1),这实际上是将原容量增加 50%(即乘以 1.5)。
如果需要的容量大于 Integer.MAX_VALUE - 8(因为数组的长度是一个 int 类型,其最大值是 Integer.MAX_VALUE,但 ArrayList 需要预留一些空间用于内部操作),则会使用 Integer.MAX_VALUE 作为新的容量。
4、扩容过程:
创建一个新的数组,其长度为新计算的容量。
将原数组中的所有元素复制到新数组中。
将 ArrayList 的内部引用从原数组更新为新数组。
将新元素添加到新数组的末尾。
5、性能影响:
扩容过程涉及到内存分配和元素复制,可能会对性能产生一定的影响。因此,在使用 ArrayList 时,如果可能的话,最好预估需要存储的元素数量,并设置一个合适的初始容量,以减少扩容的次数。
6. Java 的三大特性⭐⭐⭐⭐⭐
Java 编程语言的三大特性是面向对象编程(OOP)的核心概念:封装、继承和多态。这些特性使得 Java 程序具有良好的结构和可维护性。
封装(Encapsulation)
封装是将对象的状态(属性)和行为(方法)组合在一起,并对外隐藏对象的内部细节,只暴露必要的接口。通过封装,可以保护对象的状态不被外部直接修改,增强了代码的安全性和可维护性。
使用 private 关键字将属性声明为私有的。
提供 public 的 getter 和 setter 方法来访问和修改私有属性。
示例:
继承(Inheritance)
继承是面向对象编程中的一个机制,通过继承,一个类可以继承另一个类的属性和方法,从而实现代码的重用。被继承的类称为父类(超类),继承的类称为子类(派生类)。主要使用 extends 关键字来声明一个类继承另一个类。
示例:
多态(Polymorphism)
多态是指同一个方法在不同对象中具有不同的实现方式。多态性允许对象在不同的上下文中以不同的形式表现。多态可以通过方法重载(Overloading)和方法重写(Overriding)来实现。
方法重载:在同一个类中,方法名相同但参数列表不同。
方法重写:在子类中重新定义父类中的方法。
示例:
7. ArrayList 的添加与删除元素为什么慢?⭐⭐⭐⭐⭐
主要是由于其内部实现基于数组的特性所导致的。
ArrayList 的添加与删除操作慢,主要是因为其内部实现基于数组,而数组在插入和删除元素时需要移动其他元素来保证连续性和顺序性,这个过程需要耗费较多的时间。
相对于基于链表的数据结构(如 LinkedList),ArrayList 的插入和删除操作的时间复杂度是 O(n)级别的,而链表的时间复杂度为 O(1)。
添加元素
尾部添加:
当在 ArrayList 的尾部添加元素时,如果当前数组的容量还未达到最大值,只需要将新元素添加到数组的末尾即可,此时时间复杂度为 O(1)。
但是,当数组容量已满时,需要进行扩容操作。扩容操作通常会将数组的容量增加到当前容量的 1.5 倍或 2 倍,并将原数组中的所有元素复制到新的更大的数组中。这一过程的时间复杂度为 O(n),其中 n 为当前数组中的元素数量。
指定位置插入:
当在 ArrayList 的指定位置(非尾部)插入元素时,需要将目标位置之后的所有元素向后移动一个位置,然后将新元素插入到指定位置。这个过程涉及到移动元素的操作,时间复杂度为 O(n),在最坏情况下,如头部插入,需要移动所有的元素。
删除元素
尾部删除:
当删除的元素位于列表末尾时,只需要将末尾元素移除即可,时间复杂度为 O(1)。
指定位置删除:
当在 ArrayList 的指定位置(非尾部)删除元素时,需要将删除点之后的所有元素向前移动一个位置,以填补被删除元素的位置。这个过程同样涉及到移动元素的操作,时间复杂度为 O(n),在最坏情况下,如头部删除,需要移动除了被删除元素之外的所有元素。
8. 什么是封装?⭐⭐⭐⭐⭐
封装(Encapsulation)是面向对象编程(OOP)中的一个基本概念。它涉及到将对象的状态(属性)和行为(方法)封装在一个类中,并对外部隐藏内部实现细节,只暴露必要的接口。这种做法有助于提高代码的安全性、可维护性和可重用性。
重点特性:
属性私有化:将类的属性声明为私有(private),以防止外部直接访问和修改这些属性。
提供公共的访问方法:通过公共(public)的 getter 和 setter 方法来控制对私有属性的访问和修改。这些方法允许外部代码在受控的情况下读取和修改属性值。
隐藏实现细节:封装还包括隐藏类内部的实现细节,只暴露必要的接口给外部使用者。这有助于降低代码的复杂性,提高模块化程度。
封装的优点
数据保护:通过私有化属性,可以防止外部代码直接修改对象的状态,从而保护数据的完整性。
简化接口:只暴露必要的方法,隐藏不需要的实现细节,使得类的接口更加简洁明了。
提高可维护性:封装使得类的实现细节可以独立于外部代码进行修改,只要接口不变,外部代码不需要做任何改变。
增强灵活性:通过 getter 和 setter 方法,可以在访问或修改属性时添加额外的逻辑,比如数据验证或事件触发。
示例
9. ArrayList 是线程安全的吗?⭐⭐⭐⭐⭐
ArrayList 不是线程安全的。在多线程环境下,如果多个线程同时对 ArrayList 进行操作,可能会出现数据不一致的情况。
当多个线程同时对 ArrayList 进行添加、删除等操作时,可能会导致数组大小的变化,从而引发数据不一致的问题。例如,当一个线程在对 ArrayList 进行添加元素的操作时(这通常分为两步:先在指定位置存放元素,然后增加 size 的值),另一个线程可能同时进行删除或其他操作,导致数据的不一致或错误。
比如下面的这个代码,就是实际上 ArrayList 放入元素的代码:
Java elementData[size] = e; size = size + 1;
elementData[size] = e; 这一行代码是将新的元素 e 放置在 ArrayList 的内部数组 elementData 的当前大小 size 的位置上。这里假设 elementData 数组已经足够大,可以容纳新添加的元素(实际上 ArrayList 在必要时会增长数组的大小)。
size = size + 1; 这一行代码是更新 ArrayList 的大小,使其包含新添加的元素。
如果两个线程同时尝试向同一个 ArrayList 实例中添加元素,那么可能会发生以下情况:
• 线程 A 执行 elementData[size] = eA;(假设当前 size 是 0)
• 线程 B 执行 elementData[size] = eB;(由于线程 A 尚未更新 size,线程 B 看到的 size 仍然是 0)
• 此时,elementData[0] 被线程 B 的 eB 覆盖,线程 A 的 eA 丢失
• 线程 A 更新 size = 1;
• 线程 B 更新 size = 1;(现在 size 仍然是 1,但是应该是 2,因为有两个元素被添加)
为了解决 ArrayList 的线程安全问题,可以采取以下几种方式:
使用 Collections 类的 synchronizedList 方法:将 ArrayList 转换为线程安全的 List。这种方式通过在对 ArrayList 进行操作时加锁来保证线程安全,但可能会带来一定的性能损耗。
使用 CopyOnWriteArrayList 类:它是 Java 并发包中提供的线程安全的 List 实现。CopyOnWriteArrayList 在对集合进行修改时,会创建一个新的数组来保存修改后的数据,这样就不会影响到其他线程对原数组的访问。因此,它适合在读操作远远多于写操作的场景下使用。
使用并发包中的锁机制:如 Lock 或 Semaphore 等,显式地使用锁来保护对 ArrayList 的操作,可以确保在多线程环境下数据的一致性。但这种方式需要开发人员自行管理锁的获取和释放,容易出现死锁等问题。
还可以考虑使用其他线程安全的集合类,如 Vector 或 ConcurrentLinkedQueue 等,它们本身就是线程安全的,可以直接在多线程环境下使用。
10. 什么是继承?⭐⭐⭐⭐⭐
继承(Inheritance)是面向对象编程(OOP)中的一个核心概念。它允许一个类(子类或派生类)继承另一个类(父类或超类)的属性和方法,从而实现代码的重用和扩展。通过继承,子类可以复用父类的代码,并且可以新增或重写(覆盖)父类的方法以实现特定的功能。
继承的基本概念
父类(Super Class):被继承的类,提供属性和方法。
子类(Sub Class):继承父类的类,可以复用父类的代码,并且可以新增或重写父类的方法。
extends 关键字:用于声明一个类继承另一个类。
继承的特点
单继承:Java 只支持单继承,即一个类只能有一个直接父类。
继承层次:子类可以继续被其他类继承,形成继承层次结构。
super 关键字:用于引用父类的属性和方法,特别是在子类重写父类的方法时,可以通过 super 调用父类的方法。
继承的优点
代码重用:子类可以复用父类的代码,减少代码重复。
代码扩展:子类可以在继承父类的基础上新增属性和方法,扩展功能。
多态性:通过继承和方法重写,可以实现多态性,使得同一方法在不同对象中具有不同的实现。
示例
继承中的一些注意事项
构造方法:子类的构造方法会调用父类的构造方法。如果父类没有无参构造方法,子类必须显式调用父类的有参构造方法。
方法重写(Overriding):子类可以重写父类的方法,以提供特定的实现。重写的方法必须具有相同的方法签名(方法名、参数列表和返回类型)。
super 关键字:用于调用父类的构造方法或父类的方法。例如,在子类的构造方法中使用 super 调用父类的构造方法。
11. ArrayList 如何保证线程安全?⭐⭐⭐⭐⭐
借助锁
可以通过在访问 ArrayList 的代码块上使用 synchronized 关键字来手动同步对 ArrayList 的访问。这要求所有访问 ArrayList 的代码都知道并使用相同的锁。
使用 Collections.synchronizedList
Collections.synchronizedList 方法返回一个线程安全的列表,该列表是通过在每个公共方法(如 add(), get(), iterator() 等)上添加同步来实现的,其中同步是基于里面的同步代码块实现。但是,和手动同步一样,它也不能解决在迭代过程中进行结构修改导致的问题。
Java List<String> list = Collections.synchronizedList(new ArrayList<>());
使用并发集合
Java 并发包 java.util.concurrent 提供了一些线程安全的集合类,如 CopyOnWriteArrayList。这些类提供了不同的线程安全保证和性能特性。
CopyOnWriteArrayList 是一个线程安全的变体,其中所有可变操作(add、set 等等)都是通过对底层数组进行新的复制来实现的。因此,迭代器不会受到并发修改的影响,并且遍历期间不需要额外的同步。但是,当有很多写操作时,这种方法可能会很昂贵,因为它需要在每次修改时复制整个底层数组。
Java List<String> list = new CopyOnWriteArrayList<>();
选择解决方案时,需要考虑并发模式、读写比例以及性能需求。如果你的应用主要是读操作并且偶尔有写操作,CopyOnWriteArrayList 是一个好选择。如果你的应用有大量的写操作,那么可能需要使用其他并发集合或手动同步策略。
12. 什么是多态?⭐⭐⭐⭐⭐
多态(Polymorphism)是面向对象编程(OOP)中的一个核心概念,它允许相同的操作在不同的对象上表现出不同的行为。多态性使得一个接口可以有多种实现,从而提高代码的灵活性和可扩展性。
多态的类型
多态主要有两种形式:
编译时多态(静态多态):通过方法重载(Method Overloading)实现。
运行时多态(动态多态):通过方法重写(Method Overriding)和接口实现(Interface Implementation)实现。
编译时多态(方法重载)
编译时多态是通过方法重载实现的,即同一个类中多个方法具有相同的名称,但参数列表不同。编译器在编译时根据方法的参数列表来决定调用哪个方法。
示例
运行时多态(方法重写)
运行时多态是通过方法重写实现的,即子类重写父类的方法。在运行时,Java 虚拟机根据对象的实际类型调用对应的方法。
示例
接口和抽象类的多态性
多态性还可以通过接口和抽象类实现。子类或实现类可以提供不同的实现,从而实现多态性。
示例
多态的优点
代码重用:通过多态性,可以使用同一个接口或父类来操作不同的对象,减少代码重复。
灵活性和可扩展性:多态性使得代码更加灵活,可以轻松地扩展新的子类或实现类而不影响现有代码。
简化代码:通过多态性,可以使用统一的接口来处理不同的对象,简化代码逻辑。
13. 构造器是否可被重写?⭐⭐
构造器不能被重写:因为构造器不属于类的继承成员,并且它们的名称必须与类名相同。
构造器可以被重载:在同一个类中,可以定义多个构造器,只要它们的参数列表不同。
构造器不能被重写
构造器不能被重写(Overridden)。重写是指在子类中提供一个与父类方法具有相同签名的方法,以便在子类中提供该方法的具体实现。但构造器不属于类的继承成员,因此不能被子类重写。
原因:
构造器的作用:构造器的主要作用是初始化对象的状态。每个类都有自己的构造器,用于初始化该类的实例。子类不能直接继承父类的构造器,因为子类的初始化过程可能与父类不同。
方法签名不同:重写要求方法签名(包括方法名称和参数列表)相同,而构造器在子类中的名称与父类不同(构造器名称必须与类名相同)。
构造器不是类成员:构造器不属于类的成员方法,它们是特殊的初始化方法,不参与继承机制。
构造器可以被重载
虽然构造器不能被重写,但它们可以被重载(Overloaded)。构造器重载是指在同一个类中定义多个构造器,这些构造器具有相同的名称(类名),但参数列表不同。
示例:
在上述示例中,Parent 类有两个构造器,一个是默认构造器,另一个是带参数的构造器。Child 类也定义了两个构造器,并在其中调用了父类的相应构造器。
14. 聊聊常见 set 类都有哪几种?⭐⭐⭐⭐⭐
在 Java 中,Set 是一种不包含重复元素的集合,它继承自 Collection 接口。用于存储无序(存入和取出的顺序不一定相同)元素,值不能重复。对象的相等性本质是对象 hashCode 值(java 是依据对象的内存地址计算出的此序号)判断的,如果想要让两个不同的对象视为相等的,就必须覆盖 Object 的 hashCode 方法和 equals 方法。Java 中常见的 Set 实现类主要有三个:HashSet、LinkedHashSet 和 TreeSet。
HashSet
HashSet 是 Set 接口的一个实现类,它基于哈希表实现,具有快速的插入、删除和查找操作。
HashSet 不保证元素的迭代顺序,允许 null 元素的存在,但只能有一个 null 元素,不是线程安全的,如果多个线程同时访问并修改 HashSet,则需要外部同步。
当存储自定义对象时,需要重写对象的 hashCode()和 equals()方法,以确保对象的唯一性。
LinkedHashSet
LinkedHashSet 是 HashSet 的子类,它基于哈希表和双向链表实现,继承与 HashSet、又基于 LinkedHashMap 来实现的。可以维护元素的插入顺序。相比于 HashSet 增加了顺序性。
其主要是将元素按照插入的顺序进行迭代,同时继承了 HashSet 的所有特性。因此当需要保持元素插入顺序时,可以选择使用 LinkedHashSet。
TreeSet
TreeSet 是 Set 接口的另一个实现类,它基于红黑树实现,可以对元素进行自然排序或自定义排序。
可以实现元素按照升序进行排序(自然排序或自定义排序)。不过它不允许 null 元素的存在。treeset 同样是线程不安全的。
当存储自定义对象时,如果想要进行排序,需要实现 Comparable 接口并重写 compareTo()方法,或提供自定义的 Comparator 对象来进行排序。
适用场景
HashSet:适用于需要快速查找的场景,不保证元素的顺序。
LinkedHashSet:适用于需要保持元素插入顺序的场景。
TreeSet:适用于需要元素排序的场景。
15. Hashset 的底层原理?⭐⭐⭐⭐⭐
HashSet 是 Java 中一个常用的集合类,它用于存储不重复的元素。HashSet 的底层实现依赖于 HashMap。
基本结构
HashSet 底层使用 HashMap 来存储元素。具体来说,每当你向 HashSet 中添加一个元素时,这个元素实际上是作为 HashMap 的键来存储的,而 HashMap 的值是一个固定的常量对象。
添加元素 (add 方法)
当你调用 HashSet 的 add 方法时,HashSet 会将元素作为键插入到 HashMap 中,值为一个常量对象 PRESENT。
HashMap 的 put 方法会检查键是否已经存在,如果不存在则插入新键值对。如果键已经存在,则更新键值对并返回旧值。因此,HashSet 能够保证元素唯一性。
元素查找 (contains 方法)
HashSet 的 contains 方法实际上是调用 HashMap 的 containsKey 方法。
HashMap 的 containsKey 方法通过计算键的 hashCode 值来快速定位元素的位置,然后进行比较。
删除元素 (remove 方法)
HashSet 的 remove 方法调用 HashMap 的 remove 方法来删除元素。
16. String、StringBuffer 和 StringBuilder 的区别⭐⭐⭐⭐⭐
String
不可变性:String 对象是不可变的。一旦创建,字符串的内容就不能被改变。任何对字符串的修改都会生成一个新的 String 对象。
线程安全:由于 String 对象是不可变的,它们是线程安全的,可以在多个线程中安全地共享。
适用于字符串内容不会发生变化的场景,例如字符串常量、少量的字符串操作等。
Java String str = "Hello"; str = str + " World"; // 生成一个新的字符串对象
StringBuffer
可变性:StringBuffer 对象是可变的,可以对字符串内容进行修改,而不会生成新的对象。
线程安全:StringBuffer 是线程安全的,它的方法是同步的,可以在多线程环境中安全使用。
适用于在多线程环境中需要频繁修改字符串内容的场景。
Java StringBuffer sb = new StringBuffer("Hello"); sb.append(" World"); // 修改同一个 StringBuffer 对象
StringBuilder
可变性:StringBuilder 对象也是可变的,可以对字符串内容进行修改,而不会生成新的对象。
非线程安全:StringBuilder 不是线程安全的,它的方法没有同步,因此在多线程环境中使用时需要额外注意。
适用于在单线程环境中需要频繁修改字符串内容的场景,性能比 StringBuffer 更高。
Java StringBuilder sb = new StringBuilder("Hello"); sb.append(" World"); // 修改同一个 StringBuilder 对象
选择哪个类取决于具体的使用场景。如果字符串内容不变,使用 String;如果需要在多线程环境中修改字符串,使用 StringBuffer;如果在单线程环境中修改字符串,使用 StringBuilder。
17. HashSet 如何实现线程安全?⭐⭐⭐⭐⭐
HashSet 本身不是线程安全的。如果多个线程在没有外部同步的情况下同时访问一个 HashSet,并且至少有一个线程修改了集合,那么它必须保持同步。
使用 Collections.synchronizedSet
Java 提供了一个简单的方法来创建一个同步的集合,通过 Collections.synchronizedSet 方法。这个方法返回一个线程安全的集合包装器。
使用这个方法后,所有对集合的访问都将是同步的。但是,需要注意的是,对于迭代操作,必须手动同步:
使用 ConcurrentHashMap
如果需要更高效的并发访问,可以使用 ConcurrentHashMap 来实现类似 HashSet 的功能。ConcurrentHashMap 提供了更细粒度的锁机制,在高并发环境下性能更好。
ConcurrentHashMap.newKeySet()返回一个基于 ConcurrentHashMap 的 Set 实现,它是线程安全的,并且在高并发环境下性能优越。 使用 CopyOnWriteArraySet 对于读操作远多于写操作的场景,可以使用 CopyOnWriteArraySet。它的实现基于 CopyOnWriteArrayList,在每次修改时都会复制整个底层数组,因此在写操作较少时性能较好。
手动同步
如果你不想使用上述任何一种方法,也可以手动同步 HashSet 的访问。可以使用 synchronized 关键字来保护对 HashSet 的访问:
选择合适的方案
如果你的应用程序是单线程的,或只有少量的线程访问集合,可以使用 Collections.synchronizedSet。
如果你的应用程序有大量的并发读写操作,可以使用 ConcurrentHashMap.newKeySet。
如果你的应用程序读操作远多于写操作,可以使用 CopyOnWriteArraySet。
代码 Demo
再给大家弄一个使用 ConcurrentHashMap 实现线程安全 Set 的示例代码:
18. 字符串常量拼接的过程 (jdk1.8)⭐⭐
编译时优化
编译时常量折叠
对于编译时已知的字符串常量,Java 编译器会进行常量折叠(Constant Folding)。这意味着在编译阶段,编译器会直接计算出拼接结果,并将其作为一个单一的字符串常量存储在.class 文件中。例如:在编译时,这段代码会被优化为:
Java String str = "Hello" + " " + "World";
Java String str = "Hello World";
非常量表达式:
如果拼接的字符串包含变量或方法调用,编译器不能在编译时确定结果,因此需要在运行时进行拼接。在 JDK 1.8 中,编译器会将这些拼接操作转换为使用 StringBuilder 的代码。例如:在编译时,这段代码会被转换为:
Java String str1 = "Hello"; String str2 = "World"; String result = str1 + " " + str2;
Java StringBuilder sb = new StringBuilder(); sb.append(str1); sb.append(" "); sb.append(str2); String result = sb.toString();
运行时处理
StringBuilder 的使用
在运行时,对于非常量的字符串拼接,StringBuilder 被用来构建最终的字符串。StringBuilder 是可变的,因此可以高效地进行字符串的拼接操作。
例如:在运行时,StringBuilder 会依次调用 append 方法,将各个部分拼接起来,并最终调用 toString 方法生成结果字符串。
Java String str1 = "Hello"; String str2 = "World"; String result = str1 + " " + str2;
性能优化
使用 StringBuilder 进行拼接比直接使用 String 的+操作符效率高得多,因为 String 是不可变的,每次拼接都会创建新的 String 对象,而 StringBuilder 则是可变的,可以在原有对象上进行修改。
19. 抽象类和接口的区别⭐⭐⭐⭐⭐
定义方式
接口:使用 interface 关键字定义。不能包含实例变量,只能包含常量(public static final)。方法默认是 public 和 abstract 的(Java 8 之后可以有默认方法和静态方法)。不能有构造器。
抽象类:使用 abstract 关键字定义。可以包含实例变量和常量。可以包含抽象方法和具体方法(有方法体的)。可以有构造器。
继承和实现
接口:一个类可以实现多个接口(多重继承)。接口可以继承多个其他接口。
抽象类:一个类只能继承一个抽象类(单继承)。抽象类可以继承其他类和实现接口。
使用场景
接口:用于定义一组不相关类的公共行为。适合用于 API 设计,提供灵活的多重继承能力。更适合定义能力(能力接口),例如 Comparable、Serializable。
抽象类:用于定义一组相关类的公共行为。适合用于提供基础实现和共享代码。更适合定义类之间的层次结构,提供公共的实现和状态。
实现细节
接口:不包含实现细节(除了 Java 8 引入的默认方法和静态方法)。
抽象类:可以包含部分实现细节,允许子类继承和重用。
20. 介绍一下 HashMap?⭐⭐⭐⭐⭐
HashMap 主要是用于存储键值对。它是基于哈希表实现的,提供了快速的插入、删除和查找操作。从安全角度,HashMap 不是线程安全的。如果多个线程同时访问一个 HashMap 并且至少有一个线程修改了它,则必须手动同步。
HashMap 允许一个 null 键和多个 null 值。
HashMap 不保证映射的顺序,特别是它不保证顺序会随着时间的推移保持不变。
HashMap 提供了 O(1) 时间复杂度的基本操作(如 get 和 put),前提是哈希函数的分布良好且冲突较少。
HashMap 主要方法
put(K key, V value):将指定的值与该映射中的指定键关联。如果映射以前包含一个该键的映射,则旧值将被替换。
get(Object key):返回指定键所映射的值;如果此映射不包含该键的映射,则返回 null。
remove(Object key):如果存在一个键的映射,则将其从映射中移除。
containsKey(Object key):如果此映射包含指定键的映射,则返回 true。
containsValue(Object value):如果此映射将一个或多个键映射到指定值,则返回 true。
size():返回此映射中的键值映射关系的数量。
isEmpty():如果此映射不包含键值映射关系,则返回 true。
clear():从此映射中移除所有键值映射关系。
代码 Demo
内部工作原理
HashMap 使用哈希表来存储数据。哈希表是基于数组和链表的组合结构。
哈希函数:HashMap 使用键的 hashCode()方法来计算哈希值,然后将哈希值映射到数组的索引位置。
数组和链表:HashMap 使用一个数组来存储链表或树结构(Java 8 及以后)。每个数组位置被称为一个“桶”,每个桶存储链表或树。
冲突处理:当两个键的哈希值相同时,它们会被存储在同一个桶中,形成一个链表(或树)。这种情况称为哈希冲突。
再哈希:当 HashMap 中的元素数量超过容量的负载因子(默认 0.75)时,HashMap 会进行再哈希,将所有元素重新分配到一个更大的数组中。
注意事项
初始容量和负载因子:可以通过构造函数设置 HashMap 的初始容量和负载因子,以优化性能。初始容量越大,减少再哈希的次数;负载因子越小,减少冲突的概率,但会增加空间开销。
哈希函数的质量:哈希函数的质量直接影响 HashMap 的性能。理想的哈希函数应尽可能均匀地分布键。
线程安全性
如前所述,HashMap 不是线程安全的。如果需要线程安全的映射,可以使用 Collections.synchronizedMap 来包装 HashMap,或者使用 ConcurrentHashMap,后者在高并发环境下性能更好。
或者使用 ConcurrentHashMap:
21. HashMap 怎么计算 hashCode 的?⭐⭐
HashMap 使用键的 hashCode()方法来生成哈希值,并对其进行一些处理,以提高哈希表的性能和均匀分布。
调用键的 hashCode()方法
首先,HashMap 调用键对象的 hashCode()方法来获取一个整数哈希码。这个哈希码是由键对象的类定义的,通常是通过某种算法基于对象的内部状态计算出来的。
Plain Text int hashCode= key.hashCode();
扰动函数 (Perturbation Function)
为了减少哈希冲突并使哈希码更加均匀地分布,HashMap 对原始哈希码进行了一些额外的处理。这种处理被称为扰动函数。Java 8 及以后的 HashMap 实现使用以下算法来计算最终的哈希值:
Plain Text static final int hash(Object key) { int h; return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); }
这个算法的步骤如下:
获取键的哈希码:h = key.hashCode()
右移 16 位:h >>> 16
异或运算:h ^ (h >>> 16)
这种方法通过将高位和低位的哈希码混合在一起,减少了哈希冲突的概率,从而使得哈希码更加均匀地分布在哈希表的桶中。
计算数组索引
计算出扰动后的哈希值后,HashMap 使用这个值来确定键值对在哈希表中的位置。通常,HashMap 使用哈希值对数组的长度取模(取余数)来计算索引:
Plain Text int index = (n - 1) & hash;
其中,n 是哈希表数组的长度。n 通常是 2 的幂,这样(n - 1)就是一个全 1 的二进制数,这使得按位与操作 &可以有效地替代取模操作 %,从而提高性能。
demo
假设我们有一个键对象,其 hashCode()返回值为 123456。那么,计算哈希值的过程如下:
调用 hashCode()方法:int hashCode = 123456;
扰动函数计算:
○ h = 123456
○ h >>> 16 = 123456 >>> 16 = 1(右移 16 位)
○ hash = h ^ (h >>> 16) = 123456 ^ 1 = 123457
计算数组索引(假设数组长度 n 为 16,即 n - 1 为 15):
○ index = (15) & 123457 = 15 & 123457 = 1
最终,键值对将存储在哈希表数组的索引 1 位置。
22. 抽象类能使用 final 修饰吗?⭐⭐
不能。
在 Java 中,抽象类不能使用 final 修饰。原因是 final 修饰符用于表示类不能被继承,而抽象类的主要用途就是被继承以提供基础实现或定义抽象方法供子类实现。这两个概念是互相矛盾的,因此不能同时使用。
abstract 修饰符:用于定义一个抽象类,表示这个类不能被实例化,必须由子类继承并实现其抽象方法。
final 修饰符:用于定义一个最终类,表示这个类不能被继承。
Java public final abstract class MyAbstractClass { // 编译错误:非法的修饰符组合:'final' 和 'abstract' }
编译器会报错,因为 final 和 abstract 是互斥的修饰符。
正确用法
如果你想定义一个抽象类,只需要使用 abstract 关键字:
Java public abstract class MyAbstractClass { public abstract void myAbstractMethod(); }
如果你想定义一个不能被继承的类,只需要使用 final 关键字:
Java public final class MyFinalClass { public void myMethod() { // 方法实现 } }
其他修饰符的组合
abstract 和 protected/public:可以组合使用,表示这个类是抽象的,并且可以被其他包中的类继承(如果是 public)或在同一个包或子类中继承(如果是 protected)。
abstract 和 default/private:不能组合使用,因为抽象类需要被继承,而 private 修饰符会阻止类被继承,default 只能用于接口中的方法。
23. 成员变量与局部变量的区别⭐⭐⭐⭐⭐
定义位置
成员变量:定义在类中,但在方法、构造器或代码块之外。可以是实例变量或类变量(使用 static 修饰)。
局部变量:定义在方法、构造器或代码块内部。只能在其定义的块内使用。
生命周期
成员变量:实例变量的生命周期与对象的生命周期一致,对象被创建时分配内存,对象被垃圾回收时释放内存。类变量的生命周期与类的生命周期一致,类被加载时分配内存,类被卸载时释放内存。
局部变量:生命周期仅限于其所在的方法、构造器或代码块的执行期间。方法、构造器或代码块执行结束后,局部变量会被销毁。
默认值
成员变量:会被自动初始化为默认值(例如,数值类型为 0,布尔类型为 false,引用类型为 null)。
局部变量:不会被自动初始化,必须显式初始化后才能使用,否则编译器会报错。
访问修饰符
成员变量:可以使用访问修饰符(private、protected、public、默认)来控制其访问权限。
局部变量:不能使用访问修饰符,作用域仅限于其定义的块内。
存储位置
成员变量:实例变量存储在堆内存中。类变量存储在方法区中。
局部变量:存储在栈内存中。
修饰符
成员变量:可以使用 static、final、transient、volatile 等修饰符。
局部变量:可以使用 final 修饰,但不能使用 static、transient、volatile。
24. 静态变量和实例变量的区别⭐⭐⭐⭐⭐
归属不同
静态变量 → 属于类本身(类加载时即存在)
实例变量 → 属于具体对象(对象创建时才分配)
内存与共享性
静态变量 → 全局唯一副本,所有对象共享(修改一处,全局生效)
实例变量 → 每 new 一个对象就创建独立副本(修改互不影响)
示例:静态变量如全校学生共享的校名;实例变量如每个学生独有的学号。
访问方式
静态变量 → 直接通过类名访问(
ClassName.var
)实例变量 → 必须通过对象访问(
obj.var
)
25. final、finally、finalize 区别⭐⭐
final:用于声明常量、不可重写的方法和不可继承的类。
finally:用于异常处理,确保某些代码总是会执行。
finalize:用于对象被垃圾回收之前的清理操作,但由于不确定性,不推荐依赖。
final
final 是一个关键字,可以用于类、方法和变量,表示不可改变的特性。
final 变量:一旦赋值后,不能再改变。必须在声明时或构造函数中初始化。
final 方法:不能被子类重写。
final 类:不能被继承。
finally
finally 是一个用于异常处理的关键字,表示一个代码块,它总是会被执行,无论是否发生异常。通常用于释放资源等清理工作。
finally 代码块:总是会执行,即使在 try 块中有 return 语句。
finalize
finalize 是一个方法,用于对象被垃圾回收器回收之前的清理操作。在 Object 类中定义,子类可以重写它来执行特定的清理操作。
finalize 方法:在垃圾回收器确定没有对该对象的更多引用时调用。由于垃圾回收机制的不确定性,不推荐依赖 finalize 方法进行重要的清理工作。
26. HashMap 的主要参数都有哪些?⭐
初始容量(Initial Capacity)
初始容量是 HashMap 在创建时分配的桶(bucket)数组的大小。默认初始容量是 16。可以在创建 HashMap 时通过构造函数指定初始容量。
Plain Text HashMap<K, V> map = newHashMap<>(initialCapacity);
负载因子(Load Factor)
负载因子是一个衡量 HashMap 何时需要调整大小(即扩容)的参数。默认负载因子是 0.75,这意味着当 HashMap 中的条目数达到当前容量的 75% 时,HashMap 会进行扩容。负载因子越低,哈希表中的空闲空间越多,冲突越少,但空间利用率也越低。
Plain Text HashMap<K, V> map = newHashMap<>(initialCapacity, loadFactor);
阈值(Threshold)
阈值是 HashMap 需要扩容的临界点,计算方式为初始容量 * 负载因子。当实际存储的键值对数量超过这个阈值时,HashMap 会进行扩容。
桶(Bucket)
HashMap 内部使用一个数组来存储链表或树(在 Java 8 及之后的版本中,当链表长度超过一定阈值时,会转化为树)。每个数组元素称为一个桶(bucket)。哈希值经过计算后决定了键值对存储在哪个桶中。
哈希函数(Hash Function)
HashMap 使用哈希函数将键的哈希码转换为数组索引。Java 的 HashMap 使用了扰动函数(perturbation function)来减少哈希冲突:
链表和树(Linked List and Tree)
在桶中的键值对存储方式上,HashMap 使用链表来处理哈希冲突。在 Java 8 及之后的版本中,当链表的长度超过阈值(默认是 8)时,链表会转换为红黑树,以提高查找效率。
红黑树转换阈值(Treeify Threshold)
这是一个阈值,当单个桶中的链表长度超过这个值时,链表会转换为红黑树。默认值是 8。
最小树化容量(Minimum Treeify Capacity)
这是一个阈值,当 HashMap 的容量小于这个值时,即使链表长度超过 Treeify Threshold,也不会将链表转换为红黑树,而是会先进行扩容。默认值是 64。
扩容因子(Resize Factor)
当 HashMap 的大小超过阈值时,容量会加倍。即新的容量是旧容量的两倍。
迭代器(Iterators)
HashMap 提供了键、值和条目的迭代器,用于遍历 HashMap 中的元素。迭代器是快速失败的(fail-fast),即在迭代过程中,如果 HashMap 结构被修改(除了通过迭代器自身的 remove 方法),迭代器会抛出 ConcurrentModificationException。
版本(ModCount)
HashMap 维护了一个内部版本号 modCount,用于跟踪 HashMap 的结构修改次数。这在迭代器中用于检测并发修改。
这些参数和属性共同决定了 HashMap 的性能和行为。理解这些参数可以帮助开发者更好地使用 HashMap,并在需要时进行适当的调整以满足特定的性能需求。
27. 解决 hash 碰撞的方法?⭐⭐⭐⭐⭐
链地址法(Chaining)
链地址法是最常见的解决哈希碰撞的方法之一。在这种方法中,每个桶(bucket)包含一个链表(或树结构,Java 8 及以上版本)。当发生哈希碰撞时,新的键值对被添加到相应桶的链表中。
优点:
简单易实现。
动态调整链表长度,不需要提前知道元素数量。
缺点:
当链表长度增加时,查找效率下降。
需要额外的存储空间来存储指针。
开放地址法(Open Addressing)
开放地址法不使用链表,而是在哈希表本身寻找空闲位置来存储碰撞的元素。常见的开放地址法有以下几种:
线性探测(Linear Probing)
当发生哈希碰撞时,线性探测法在哈希表中向后依次查找下一个空闲位置。
优点:实现简单,不需要额外的存储空间。
缺点:当哈希表接近满时,查找效率急剧下降(称为“主群集”问题)。
二次探测(Quadratic Probing)
二次探测法在发生哈希碰撞时,按照平方序列查找空闲位置(如 1, 4, 9, 16, ...)。
优点:减少主群集问题。
缺点:实现较复杂,可能会导致二次群集问题。
双重散列(Double Hashing)
双重散列法使用两个不同的哈希函数。当第一个哈希函数发生碰撞时,使用第二个哈希函数计算新的索引。
优点:减少群集问题。较好的查找性能。
缺点:实现复杂。需要设计两个有效的哈希函数。
再哈希法(Rehashing)
再哈希法在发生碰撞时,使用不同的哈希函数重新计算哈希值,直到找到空闲位置。
优点:减少群集问题。
缺点:实现复杂。需要设计多个有效的哈希函数。
分离链接法
在 Java 8 及以上版本中,当链表长度超过一定阈值(默认是 8)时,链表会转换为红黑树,以提高查找效率。
优点:在高冲突情况下性能较好,动态调整链表和树的长度。
缺点:实现复杂,需要额外的存储空间。
其他方法
Cuckoo Hashing:使用两个哈希表和两个哈希函数,如果插入时发生冲突,将原来的元素“踢出”并重新插入到另一个哈希表中。
Hopscotch Hashing:类似于线性探测,但在插入时会调整元素的位置,使得查找路径更短。
链地址法是最常见的解决哈希碰撞的方法,适用于大多数情况。开放地址法在空间利用率上有优势,但在高负载情况下性能可能下降。再哈希法和其他高级方法适用于特定的高性能需求场景。
28. 如果 HashMap 的大小超过了负载因子(load factor)定义的容量,怎么办?⭐⭐
HashMap 的负载因子(load factor)初始值设为 0.75 是一个经过权衡的结果,主要考虑了性能和内存使用之间的平衡。
性能与内存使用的平衡
查找性能:在 HashMap 中,查找操作的时间复杂度接近 (O(1))。然而,当哈希表中的元素过多时,链地址法中的链表会变长,查找时间会增加。负载因子为 0.75 意味着在表达到 75% 满时进行扩容,这样可以保持链表的长度较短,从而保证查找操作的高效性。
内存使用:如果负载因子设置得太低(例如 0.5),HashMap 会更频繁地扩容,需要更多的内存来存储未使用的桶。负载因子为 0.75 是一个较为合理的设置,可以在保证查找性能的同时,节约内存。
扩容频率
较高的负载因子(如 1.0)会减少扩容的频率,但会导致较长的链表或更多的哈希碰撞,从而影响查找性能。较低的负载因子(如 0.5)会增加扩容的频率,虽然可以减少碰撞,但会导致更多的空间浪费。
0.75 是一个折中的选择,它既能保证较少的哈希碰撞,又不会频繁地进行扩容,从而在性能和内存使用之间取得平衡。
实际应用中的经验
在实际应用中,0.75 被证明是一个有效的默认值。它在大多数情况下提供了良好的性能和较为合理的内存使用。尽管特定应用可能有不同的需求,但对于通用场景,这个默认值是经过大量实践验证的。
负载因子的灵活性
虽然 0.75 是默认值,开发者在创建 HashMap 时可以根据具体需求指定不同的负载因子。例如:
在上述代码中,HashMap 的负载因子被设置为 0.5,这可能适用于需要更高查找性能但内存使用不是主要考虑因素的场景。
HashMap 默认负载因子为 0.75 是一个经过深思熟虑的选择,旨在平衡查找性能和内存使用。它在大多数情况下提供了良好的性能表现,同时避免了频繁扩容和过多的内存浪费。开发者可以根据具体需求调整负载因子,以适应不同的应用场景。
29. 什么是 Java 跨平台性?⭐⭐⭐⭐⭐
Java 的跨平台性(Platform Independence)是指 Java 程序可以在不同的操作系统和硬件平台上运行,而无需修改代码。这一特性主要源于 Java 虚拟机(JVM)和 Java 编译器的设计。
Java 跨平台性的实现机制
Java 跨平台性的核心在于“编译一次,到处运行”(Write Once, Run Anywhere,WORA)的理念。具体来说,这一特性通过以下机制实现:
Java 编译器(javac):
Java 源代码(.java 文件)首先被编译器编译成中间表示形式的字节码(.class 文件),这些字节码是与平台无关的中间代码。
Java 虚拟机(JVM):
不同的平台(如 Windows、Linux、macOS 等)都有相应的 JVM 实现。JVM 负责将字节码解释或即时编译(JIT)成特定平台的机器代码,并执行这些代码。由于每个平台都有其对应的 JVM,Java 字节码可以在任何安装了 JVM 的系统上运行。
工作流程
编写代码:开发者在任何平台上编写 Java 源代码。
编译代码:使用 Java 编译器 javac 将源代码编译成字节码。
Java javac MyProgram.java
运行代码:使用 JVM 执行生成的字节码。
Java java MyProgram
优势
便捷性:开发者只需编写和编译一次代码,就可以在多个平台上运行,减少了开发和维护的成本。
广泛的适用性:Java 程序可以在任何支持 JVM 的平台上运行,包括桌面操作系统、服务器和嵌入式设备等。
一致性:由于字节码和 JVM 的标准化,Java 程序在不同平台上的行为是一致的。
demo
假设有一个简单的 Java 程序 HelloWorld.java:
编译:这将生成一个 HelloWorld.class 文件,包含平台无关的字节码。
Java javac HelloWorld.java
运行:在任何安装了 JVM 的系统上运行:
Java java HelloWorld
无论是在 Windows、Linux 还是 macOS 上,输出都是:
Java Hello, World!
30. Java 中的数据类型⭐⭐⭐⭐⭐
HashMap 的负载因子(load factor)初始值设为 0.75 是一个经过权衡的结果,主要考虑了性能和内存使用之间的平衡。
性能与内存使用的平衡
查找性能:在 HashMap 中,查找操作的时间复杂度接近 (O(1))。然而,当哈希表中的元素过多时,链地址法中的链表会变长,查找时间会增加。负载因子为 0.75 意味着在表达到 75% 满时进行扩容,这样可以保持链表的长度较短,从而保证查找操作的高效性。
内存使用:如果负载因子设置得太低(例如 0.5),HashMap 会更频繁地扩容,需要更多的内存来存储未使用的桶。负载因子为 0.75 是一个较为合理的设置,可以在保证查找性能的同时,节约内存。
扩容频率
较高的负载因子(如 1.0)会减少扩容的频率,但会导致较长的链表或更多的哈希碰撞,从而影响查找性能。较低的负载因子(如 0.5)会增加扩容的频率,虽然可以减少碰撞,但会导致更多的空间浪费。
0.75 是一个折中的选择,它既能保证较少的哈希碰撞,又不会频繁地进行扩容,从而在性能和内存使用之间取得平衡。
实际应用中的经验
在实际应用中,0.75 被证明是一个有效的默认值。它在大多数情况下提供了良好的性能和较为合理的内存使用。尽管特定应用可能有不同的需求,但对于通用场景,这个默认值是经过大量实践验证的。
负载因子的灵活性
虽然 0.75 是默认值,开发者在创建 HashMap 时可以根据具体需求指定不同的负载因子。例如:
在上述代码中,HashMap 的负载因子被设置为 0.5,这可能适用于需要更高查找性能但内存使用不是主要考虑因素的场景。
HashMap 默认负载因子为 0.75 是一个经过深思熟虑的选择,旨在平衡查找性能和内存使用。它在大多数情况下提供了良好的性能表现,同时避免了频繁扩容和过多的内存浪费。开发者可以根据具体需求调整负载因子,以适应不同的应用场景。
31. 重新调整 HashMap 大小存在什么问题吗?⭐⭐
性能影响
时间复杂度:扩容是一个相对昂贵的操作,因为它需要重新计算所有现有键值对的哈希值,并将它们重新分配到新的桶数组中。这个过程的时间复杂度为 (O(n)),其中 (n) 是哈希表中元素的数量。因此,在扩容期间,可能会导致性能的短暂下降,尤其是在插入大量数据时。
阻塞操作:在单线程环境中,扩容会阻塞其他操作(如查找、插入、删除),直到扩容完成。在多线程环境中,如果没有适当的同步机制,扩容可能会导致数据不一致或其他并发问题。
内存使用
临时内存消耗:扩容期间,HashMap 需要分配一个新的桶数组,同时保留旧的桶数组,直到重新哈希完成。这会导致临时的内存消耗增加。如果哈希表非常大,可能会导致内存不足的问题。
内存碎片:频繁的扩容和缩容可能导致内存碎片化,降低内存利用效率。
并发问题
线程安全:默认的 HashMap 不是线程安全的。在多线程环境中,如果一个线程在进行扩容操作,而另一个线程在进行插入或删除操作,可能会导致数据不一致或程序崩溃。为了解决这个问题,可以使用 ConcurrentHashMap 或在外部进行同步。
扩容期间的数据一致性:在扩容过程中,如果有其他线程在进行读写操作,可能会导致数据不一致。因此,在多线程环境中,必须确保扩容操作是原子的,或者使用并发安全的数据结构。
重新哈希的成本
哈希函数的复杂性:重新哈希所有键值对需要调用哈希函数。如果哈希函数较为复杂,重新哈希的成本也会增加。
哈希冲突的处理:在扩容过程中,哈希冲突的处理(如链地址法中的链表或红黑树)也会增加额外的开销。
应用层面的影响
实时性要求:在某些实时性要求较高的应用中,扩容操作可能导致短暂的性能下降,影响系统的响应时间。
数据一致性要求:在某些应用中,数据的一致性要求较高,扩容过程中可能会导致短暂的数据不一致,需要额外的机制来保证一致性。
解决方案和优化
预估初始容量:如果可以预估数据量,尽量在创建 HashMap 时设置合适的初始容量,减少扩容次数。
使用并发数据结构:在多线程环境中,使用 ConcurrentHashMap 代替 HashMap,它采用了分段锁机制,减少了扩容带来的并发问题。
动态调整负载因子:根据应用需求,动态调整负载因子以适应数据量的变化。
32. HashMap,扩容过程,怎么解决哈希冲突?⭐⭐
HashMap 扩容过程
Hashmap 的扩容(rehashing)主要发生在以下两种情况下:
当添加元素时,如果当前数组为空,会进行初始化:默认情况下,会创建一个长度为 16 的数组,并且加载因子(load factor)默认为 0.75。
当数组中的元素数量大于或等于数组长度与加载因子的乘积时:例如,当数组长度为 16,加载因子为 0.75,并且元素数量达到 12 时(16 * 0.75 = 12),会触发扩容。扩容时,数组长度会翻倍(通常是 2 的幂),并重新哈希所有元素到新的数组中。
在扩容过程中,hashmap 会重新计算每个元素的哈希值,并根据新的数组长度重新定位其索引位置。由于数组长度翻倍,哈希值的位运算结果可能会改变,导致元素在新数组中的位置与旧数组不同。
哈希冲突解决
哈希冲突(hash collision)是指不同的键计算出相同的哈希值,从而在哈希表中映射到同一个位置。HashMap 通过以下策略来解决哈希冲突:
链表法(链表或红黑树):在 HashMap 中,每个位置(索引)可以存储一个链表(或红黑树,当链表长度超过一定阈值时)。当发生哈希冲突时,新的元素会被添加到对应的链表中。在 Java 8 及之后的版本中,当链表长度达到 8 且数组长度大于 64 时,链表会转换为红黑树以优化性能。
哈希函数:为了降低哈希冲突的概率,HashMap 使用了一个精心设计的哈希函数来计算键的哈希值。这个哈希函数考虑了键对象的哈希码(hashCode)以及键在数组中的索引位置,通过一些位运算得到最终的哈希值。这样可以确保哈希值的分布尽可能均匀,减少冲突的可能性。
初始容量和加载因子:初始容量和加载因子也会影响哈希冲突的概率。较大的初始容量和较小的加载因子可以降低哈希冲突的概率,但也会增加空间开销。因此,在选择这些参数时需要根据具体需求进行权衡。
总的来说,HashMap 通过链表法(或红黑树)和精心设计的哈希函数来解决哈希冲突,并通过扩容和重新哈希来保持哈希表的性能和效率。
33. private、public、protected 及默认访问修饰符的区别⭐
在 Java 中,访问修饰符(access modifiers)用于控制类、方法和变量的访问级别。主要有四种访问修饰符:private、public、protected 和 默认(不写)。
它们的区别如下:
private
访问范围:仅在同一个类内可访问。
使用场景:用于隐藏类的实现细节,保护类的成员变量和方法不被外部类访问和修改。
public
访问范围:在任何地方都可以访问。
使用场景:用于类、方法或变量需要被其他类访问的情况。
protected
访问范围:在同一个包内,以及在不同包中的子类中可访问。
使用场景:用于希望在同一个包内或子类中访问,但不希望在包外的非子类中访问的情况。
默认(不写)
访问范围:仅在同一个包内可访问。
使用场景:用于包级访问控制,不希望类、方法或变量被包外的类访问。
访问修饰符的总结
代码 demo
34. 为什么 hashmap 多线程会进入死循环?⭐
HashMap 在多线程环境中可能会进入死循环,主要是由于其非线程安全的设计导致的。
并发修改导致的链表环
在 HashMap 中,当发生哈希冲突时,使用链地址法(链表)来存储冲突的键值对。如果多个线程同时对 HashMap 进行修改(例如插入或删除操作),可能会导致链表结构被破坏,形成环形链表。这种情况下,当遍历链表时,会陷入死循环。
原因分析
当两个或多个线程同时修改 HashMap,例如在同一个桶中插入元素,可能会导致链表的指针被错误地更新。例如,一个线程正在将一个新的节点插入链表中,而另一个线程正在重新排列链表的顺序。这种竞争条件可能导致链表中出现环形结构。
示例代码
在上述代码中,两个线程同时对 HashMap 进行插入操作,可能会导致链表结构被破坏,形成环形链表,从而在遍历时陷入死循环。
扩容导致的并发问题
HashMap 在容量达到一定阈值时会进行扩容(rehash),即重新分配桶数组,并重新哈希所有键值对。如果在扩容过程中,有其他线程同时进行插入操作,可能会导致重新哈希过程中的数据不一致,进而引发死循环。
原因分析
扩容过程中,HashMap 会创建一个新的、更大的桶数组,并将所有旧的键值对重新哈希并放入新的桶中。如果在这个过程中有其他线程插入新的键值对,可能会导致旧桶和新桶的数据结构不一致,进而引起死循环。
示例代码
在上述代码中,HashMap 初始容量设置为 2,两个线程同时插入大量元素,可能会导致扩容过程中数据不一致,从而引发死循环。
解决方案
使用线程安全的数据结构
在多线程环境中,使用 ConcurrentHashMap 代替 HashMap。ConcurrentHashMap 通过分段锁机制来保证线程安全,并发性能更好。
外部同步
如果必须使用 HashMap,可以在外部进行同步,确保同时只有一个线程对 HashMap 进行修改。
通过使用 ConcurrentHashMap 或外部同步,可以避免 HashMap 在多线程环境中出现死循环的问题。
36. final 关键字的作用⭐⭐⭐⭐⭐
在 Java 中,final 关键字可以用于类、方法和变量,分别有不同的作用。
final 变量
当一个变量被声明为 final 时,它的值在初始化之后就不能被改变。final 变量必须在声明时或通过构造函数进行初始化。
final 方法
当一个方法被声明为 final 时,它不能被子类重写(override)。这可以用来防止子类改变父类中关键的方法实现。
final 类
当一个类被声明为 final 时,它不能被继承。这对于创建不可变类(immutable class)或确保类的实现不被改变是非常有用的。
final 和不可变对象
final 关键字在创建不可变对象时非常有用。不可变对象的状态在创建之后不能被改变,这在多线程环境中尤其重要,因为它们是线程安全的。
final 和局部变量
final 也可以用于局部变量,尤其是在匿名类或 lambda 表达式中使用时。被声明为 final 的局部变量在方法执行期间不能被修改。
总结
final 变量:值不能被改变。
final 方法:不能被重写。
final 类:不能被继承。
final 局部变量:在方法执行期间不能被修改,尤其在匿名类和 lambda 表达式中有用。
37. 为什么 String, Interger 这样的 wrapper 类适合作为键?⭐⭐
不可变性
String 和 Integer 等包装类都是不可变的对象。一旦创建,这些对象的状态就不能被改变。不可变性是一个重要的特性,因为它保证了对象在其生命周期内的哈希码(hash code)不会改变。
如果一个对象在作为键的过程中其哈希码发生了改变,那么在哈希表中查找该键时将无法找到正确的位置,导致数据结构无法正常工作。不可变对象避免了这一问题。
合理的 hashCode()实现
String 和 Integer 类都提供了高质量的 hashCode()方法,这些方法能够有效地分布哈希值,减少哈希冲突。具体来说:
String 的 hashCode()方法是基于字符串内容计算的,使用了一个高效的算法。
Integer 的 hashCode()方法直接返回其内部存储的整数值。
合理的 equals()实现
String 和 Integer 类都提供了正确且高效的 equals()方法,这些方法能够准确地比较两个对象的内容是否相等。这对于哈希表等数据结构来说是至关重要的,因为在哈希表中查找键时需要依赖 equals()方法来判断两个键是否相等。
内存效率
虽然包装类相对于原始类型有一些额外的内存开销,但这些类通常经过了优化,能够在大多数情况下提供足够的性能和内存效率。例如,Integer 类使用了对象池来缓存常用的整数值(-128 到 127),从而减少了内存消耗和对象创建的开销。
38. jdk7 的 hashmap 实现?⭐⭐⭐⭐
HashMap 在 JDK 7 中的实现其实并不复杂,它主要依靠两个数据结构:数组和链表。
首先,HashMap 内部有一个数组,这个数组用来存储所有的键值对。每个数组的元素其实是一个链表的头节点。也就是说,如果两个或多个键计算出来的哈希值相同,它们会被存储在同一个数组位置的链表中。
当我们往 HashMap 里放一个键值对时,HashMap 会先根据键的 hashCode 计算出一个哈希值,然后用这个哈希值决定键值对应该放在数组的哪个位置。如果那个位置是空的,键值对就直接放进去;如果那个位置已经有其他键值对了(也就是发生了哈希冲突),HashMap 会把新的键值对放到那个位置的链表上。
举个例子吧,假设我们有一个 HashMap,我们要往里面放一个键值对("apple", 1)。HashMap 会先计算"apple"的哈希值,然后用这个哈希值决定应该把它放到数组的哪个位置。假如计算出来的位置是 5,如果数组的第 5 个位置是空的,它就直接放进去;如果已经有其他键值对了,比如("banana", 2),它就会把("apple", 1)加到("banana", 2)的链表上。
取值的时候也类似。假设我们要取"apple"对应的值,HashMap 会先计算"apple"的哈希值,然后找到数组的对应位置,再沿着链表找到"apple"对应的节点,最后返回它的值。
需要注意的是,HashMap 不是线程安全的。如果多个线程同时修改 HashMap,可能会导致一些奇怪的问题,比如死循环。所以在多线程环境下,建议使用 ConcurrentHashMap。
总结一下,HashMap 在 JDK 7 中主要是通过数组和链表来存储数据,使用哈希值来决定存储位置,并通过链表来解决哈希冲突。它的设计让我们在大多数情况下能够快速地存取数据,但在多线程环境下需要小心使用。
JDK 7 中的 HashMap 底层实现方式主要基于数组和链表。它通过哈希函数将键映射到数组中的索引位置,从而实现快速的查找和存储操作。
数据结构
HashMap 主要由以下几部分组成:
数组(table):存储 HashMap 的核心数据结构。每个数组元素是一个链表的头节点。
链表(Entry):处理哈希冲突的结构。当多个键的哈希值映射到同一个数组索引时,这些键值对会被存储在该索引位置的链表中。
Entry 类
在 JDK 7 中,HashMap 使用一个内部类 Entry 来表示键值对。Entry 类的定义如下:
存储过程
当向 HashMap 中存储一个键值对时,主要步骤如下:
计算哈希值:通过键的 hashCode()方法计算哈希值,并进一步处理以减少冲突。
确定数组索引:通过哈希值计算数组索引位置。
插入节点:如果数组索引位置为空,则直接插入。如果不为空,则需要处理哈希冲突。
处理哈希冲突
在 JDK 7 中,HashMap 通过链表法处理哈希冲突。当多个键的哈希值映射到同一个数组索引时,这些键值对会被存储在该索引位置的链表中。插入时,新节点会被插入到链表的头部。
代码示例
以下是 put 方法的简化版本,展示了 HashMap 的存储过程:
取值过程
取值时,通过键计算哈希值和数组索引,然后在链表中查找对应的键值对。
39. this 与 super 的区别⭐⭐⭐⭐⭐
this 关键字
this 关键字用于引用当前对象的实例。
1、引用当前对象的实例变量: 当局部变量和实例变量同名时,this 关键字可以用于区分它们。
2、调用当前对象的方法: 可以使用 this 关键字来调用当前对象的另一个方法。
3、 调用当前对象的构造函数: 在一个构造函数中,可以使用 this 关键字调用同一个类中的另一个构造函数。必须是构造函数的第一行。
super 关键字
super 关键字用于引用父类(超类)的成员。它有以下几种主要用途:
1、 调用父类的构造函数: 在子类的构造函数中,可以使用 super 关键字调用父类的构造函数。必须是构造函数的第一行。
2、 引用父类的实例变量: 当子类和父类有同名的实例变量时,可以使用 super 关键字访问父类的实例变量。
3、 调用父类的方法: 可以使用 super 关键字调用父类的方法。
this 主要用于当前对象的引用,而 super 主要用于父类的引用。
40. == 和 equals 的区别⭐⭐⭐⭐⭐
在 Java 中,==和 equals()方法用于比较对象,但它们的用途和行为是不同的。
== 操作符
== 是一个比较操作符,用于比较两个操作数的内存地址(引用)是否相同。它可以用于比较基本数据类型和对象引用。
用于基本数据类型
对于基本数据类型(如 int、char、boolean 等),==比较的是它们的值。
用于对象引用
对于对象引用,==比较的是两个对象在内存中的地址是否相同,即它们是否引用同一个对象。
equals()方法
equals()方法是 Object 类中的一个方法,用于比较两个对象的内容是否相同。默认情况下,Object 类的 equals()方法与==操作符的行为相同,即比较对象的引用。但许多类(如 String、Integer 等)重写了 equals()方法,用于比较对象的内容。
默认实现
默认的 equals()方法在 Object 类中定义,比较的是对象的引用。
重写的 equals()方法
许多类重写了 equals()方法,用于比较对象的内容。例如,String 类重写了 equals()方法,用于比较字符串的内容。
自定义类中的 equals()方法
在自定义类中,可以重写 equals()方法来比较对象的内容。
使用==操作符时,比较的是对象的引用是否相同,除非用于基本数据类型。
使用 equals()方法时,比较的是对象的内容(如果该方法被重写的话)。
41. java8 的 hashmap 实现?⭐⭐⭐⭐
在 Java 8 中,HashMap 的实现进行了显著的优化,特别是在处理哈希冲突方面,引入了红黑树数据结构。这些改进旨在提高在高冲突情况下的性能。
数据结构
HashMap 的底层结构仍然是基于数组和链表的组合,但在 Java 8 中,当链表长度超过一定阈值时,会将链表转换为红黑树,以提高操作效率。
存储过程
计算哈希值:首先,通过键的 hashCode 方法计算哈希值,然后对该哈希值进行扰动,以减少冲突。扰动的目的是为了使哈希值更加均匀地分布在数组中。
确定数组索引:通过哈希值与数组长度的减一值进行按位与运算,计算出数组的索引位置。
插入节点:
如果数组索引位置为空,直接插入新的节点。
如果不为空,则需要处理哈希冲突。
处理哈希冲突
在 Java 8 中,处理哈希冲突的方法有了显著改进:
链表:如果冲突的节点数较少(链表长度小于等于 8),则使用链表存储。链表的插入操作在链表尾部进行,以保持插入顺序。
红黑树:如果链表长度超过 8,长度大于 64HashMap 会将链表转换为红黑树。红黑树是一种自平衡的二叉搜索树,其查找、插入和删除操作的时间复杂度为 O(log n),相比链表的 O(n)更高效。
取值过程
在取值时,HashMap 会先计算哈希值,然后找到对应的数组位置。如果该位置存储的是链表,则遍历链表查找;如果是红黑树,则在树中查找。
扩容
当 HashMap 中的元素数量超过一定阈值(通常是数组长度的 0.75 倍)时,会进行扩容。扩容时,HashMap 会创建一个新的、更大的数组,并将旧数组中的所有元素重新哈希并放入新数组中。
Java 8 中的 HashMap 通过引入红黑树来优化哈希冲突的处理。当链表长度超过一定阈值时转换为红黑树,从而在极端情况下提高查找和插入的效率。这些改进使得 HashMap 在大多数情况下能够提供更稳定和高效的性能。
42. hashCode()与 equals()的关系⭐⭐⭐⭐⭐
hashCode()方法
hashCode()方法返回一个整数,称为哈希码。哈希码用于在哈希表中快速查找对象。Object 类中的默认实现返回对象的内存地址转换成的整数。
equals()方法
equals()方法用于比较两个对象的内容是否相同。Object 类中的默认实现比较的是对象的引用。
hashCode()与 equals()的合同
为了确保哈希表中的对象行为正确,Java 规范定义了 hashCode()与 equals()方法之间的合同(契约):
一致性
1、 如果两个对象根据 equals()方法比较是相等的,那么它们的 hashCode()方法必须返回相同的整数。
2、 如果两个对象根据 equals()方法比较是不相等的,那么它们的 hashCode()方法不一定要返回不同的整数,但不同的对象返回不同的哈希码可以提高哈希表的性能。
稳定性
在程序执行期间,hashCode()方法返回的整数值应该是一致的,只要对象的状态没有改变。也就是说,同一个对象多次调用 hashCode()方法应该返回相同的值。
实现 hashCode()与 equals()方法的 demo
假设有一个自定义类 Person,我们需要重写 equals()和 hashCode()方法。
equals()方法
首先检查是否与自身比较(this == obj),如果是,返回 true。
然后检查传入的对象是否为空或类型是否匹配(obj == null || getClass() != obj.getClass()),如果不匹配,返回 false。
最后,比较类的字段是否相等(age == person.age && Objects.equals(name, person.name))。
hashCode()方法:
使用 Objects.hash()方法生成哈希码,该方法根据传入的字段生成一个哈希码。
重要注意项
重写 equals()方法时,必须同时重写 hashCode()方法,以确保两个相等的对象具有相同的哈希码。
如果只重写 equals()方法而不重写 hashCode()方法,可能会导致在哈希表中查找、插入和删除对象时出现问题。
43. jdk8 的 hashmap 的 put 过程?⭐⭐
put 方法的实现
put 方法调用了 putVal 方法。这里的 hash(key)是计算键的哈希值。
计算哈希值
hash 方法用于计算键的哈希值并进行扰动处理,以减少冲突。
putVal 方法的实现
putVal 方法是 HashMap 中实际执行插入操作的核心方法。
详细步骤解析
初始化表:如果哈希表还没有初始化或长度为 0,则进行初始化(扩容)。
计算索引:通过哈希值和数组长度计算出索引位置。
插入新节点:如果索引位置为空,直接插入新节点。
处理哈希冲突:如果索引位置不为空,需要处理冲突。
检查是否存在相同的键:如果找到相同的键,替换其值。
红黑树处理:如果当前节点是红黑树节点,则调用 putTreeVal 方法插入。
链表处理:如果当前节点是链表节点,遍历链表插入新节点。
转换为红黑树:如果链表长度超过阈值(8)且数组长度大于 64,则将链表转换为红黑树。
更新节点值:如果存在相同的键,更新其值。
调整大小:插入新节点后,增加元素数量。如果超过阈值,则进行扩容。
插入后的处理:进行一些插入后的处理操作。
44. JDK 中常用的包⭐
java.lang
包含 Java 语言的核心类,不需要显式导入。
常用类:Object、String、Math、System、Thread、Exception 等。
java.util
提供了集合框架、日期和时间功能、随机数生成、扫描和格式化等实用工具类。
常用类:ArrayList、HashMap、HashSet、Date、Calendar、Random、Scanner 等。
java.io
提供了系统输入和输出功能,包括文件和流的操作。
常用类:File、FileInputStream、FileOutputStream、BufferedReader、BufferedWriter、InputStream、OutputStream 等。
java.nio
提供了非阻塞 I/O 操作的类和接口,包括缓冲区、字符集、通道等。
常用类:ByteBuffer、FileChannel、Path、Files、StandardOpenOption 等。
java.net
提供了用于实现网络应用程序的类,包括 URL、套接字、HTTP 等。
常用类:URL、URLConnection、HttpURLConnection、Socket、ServerSocket、InetAddress 等。
java.sql
提供了用于访问和处理数据库的 API。
常用类:Connection、Statement、PreparedStatement、ResultSet、DriverManager 等。
java.time
提供了现代日期和时间 API(Java 8 引入)。
常用类:LocalDate、LocalTime、LocalDateTime、ZonedDateTime、Duration、Period 等。
java.util.concurrent
提供了并发编程的工具类和接口。
常用类:Executor、ExecutorService、Future、CountDownLatch、Semaphore、ConcurrentHashMap 等。
45. 拉链法导致的链表过深问题为什么用红黑树?⭐⭐⭐⭐⭐
在 JDK 8 中,HashMap 采用红黑树来解决拉链法导致的链表过深问题,主要是为了提高在高冲突情况下的性能。
拉链法中的链表过深问题
在传统的拉链法中,当多个键的哈希值冲突时,这些键会被存储在同一个桶(bucket)中,形成一个链表。如果链表过长,查找、插入和删除操作的时间复杂度会退化为 O(n),其中 n 是链表中的元素个数。这种情况下,HashMap 的性能会显著下降。
红黑树的优势
红黑树是一种自平衡的二叉搜索树,具有以下特性:
自平衡:红黑树通过颜色属性(红色和黑色)和一系列的旋转操作,保证树的高度近似平衡。其高度不会超过 2 * log(n),其中 n 是树中的节点数。
高效的查找、插入和删除:红黑树的查找、插入和删除操作的时间复杂度为 O(log n),远优于链表在最坏情况下的 O(n)。
为什么选择红黑树
性能优化:在链表长度较短时,链表操作的性能是可以接受的。然而,当链表长度超过一定阈值(JDK 8 中为 8)时,链表操作的性能会显著下降。此时,将链表转换为红黑树,可以将操作的时间复杂度从 O(n)降低到 O(log n),显著提高性能。
平衡性:红黑树通过自平衡机制,保证树的高度始终保持在一个较低的水平,避免了链表过长导致的性能问题。
空间效率:虽然红黑树比链表占用更多的空间(因为需要存储颜色和指针信息),但在链表长度较长时,性能的提升通常会超过空间开销的增加。
46. ConcurrentHashMap 的原理?⭐⭐⭐⭐⭐
ConcurrentHashMap 是 Java 中一种高效的线程安全哈希表,主要用于在多线程环境下进行高并发的读写操作。它的设计和实现使得在大多数情况下能够提供比其他同步哈希表(如 HashMap)更高的并发性能。以下是 ConcurrentHashMap 的主要原理和机制
分段锁机制
在早期版本(Java 7 及之前),ConcurrentHashMap 使用了分段锁机制(Segmented Locking)来实现高并发性。
分段锁:ConcurrentHashMap 将整个哈希表分成多个段(Segment),每个段维护一个独立的哈希表和锁。这样,在不同段上的操作可以并发进行,从而提高并发度。
锁粒度:由于每个段都有自己的锁,只有在操作同一个段时才需要竞争锁,这大大降低了锁竞争的几率,提高了并发性能。
CAS 操作和无锁机制
在 Java 8 及之后,ConcurrentHashMap 进行了重构,摒弃了分段锁机制,转而采用了更加细粒度的锁和无锁机制(CAS 操作)。
CAS 操作:CAS(Compare-And-Swap)是一种无锁的原子操作,用于在不加锁的情况下实现线程安全。ConcurrentHashMap
使用Unsafe
类中的 CAS 方法来更新某些字段,从而避免了锁的开销。
细粒度锁:在 Java 8 中,ConcurrentHashMap
使用了更加细粒度的锁(synchronized
和ReentrantLock
),只在必要时锁定特定的桶(bin)或节点,从而进一步提高并发性能。
红黑树
为了应对哈希冲突,ConcurrentHashMap 在链表长度超过一定阈值(默认是 8)时,将链表转换为红黑树,以提高查找效率。
链表:在哈希冲突较少时,使用链表存储冲突的键值对。
红黑树:当链表长度超过阈值时,转换为红黑树,以便在大量冲突时仍能保持较高的查找效率。
扩容机制(Rehashing)
ConcurrentHashMap 采用了渐进式扩容机制来避免扩容过程中长时间的全表锁定。
渐进式扩容:在扩容过程中,ConcurrentHashMap
并不会一次性将所有数据迁移到新的哈希表中,而是采用渐进式扩容的方式,在每次插入或删除操作时,逐步迁移部分数据。
读写操作
读取操作:读取操作大部分情况下是无锁的,因为ConcurrentHashMap
使用了volatile
变量和 CAS 操作来保证读取的可见性和一致性。
写入操作:写入操作则需要在必要时使用锁或 CAS 操作来保证线程安全。
47. 字符串常量池是什么?⭐⭐⭐⭐⭐
字符串常量池(String Constant Pool)是 Java 中用于优化字符串存储和管理的一种机制。它是 Java 运行时环境的一部分,专门用于存储字符串字面量和某些字符串对象,以减少内存消耗和提高性能。
工作原理
字符串字面量的存储
当你在代码中使用字符串字面量(例如"hello")时,Java 会首先检查字符串常量池中是否已经存在一个相同的字符串。如果存在,Java 会直接引用该字符串,而不会创建新的字符串对象。如果不存在,Java 会将该字符串添加到常量池中,然后引用它。
字符串对象的存储
使用 new 关键字创建的字符串对象(例如 new String("hello"))不会自动进入字符串常量池。它们在堆内存中创建,且每次都会创建一个新的对象。
可以使用 String 类的 intern()方法将字符串对象添加到常量池中。例如,调用 str.intern()会将字符串 str 添加到常量池中,如果常量池中已经存在相同内容的字符串,则返回该字符串的引用。
代码 Demo
优点
节省内存:字符串常量池可以确保相同内容的字符串在内存中只存储一份,减少内存消耗。
提高性能:由于字符串是不可变的,常量池可以提高字符串比较的效率(使用==进行引用比较而不是内容比较)。
48. instanceof 关键字的作用⭐⭐⭐⭐⭐
instanceof 关键字在 Java 中用于测试一个对象是否是一个特定类的实例,或者是该类的子类或实现类的实例。
它是一个二元操作符,返回一个布尔值(true 或 false)。
object:要进行类型检查的对象。
ClassName:要检查的类或接口。
主要作用
类型检查:确定对象是否是某个类的实例或是该类的子类的实例。
避免类型转换异常:在进行类型转换之前,使用 instanceof 可以防止 ClassCastException 异常。
代码 Demo
注意
空值检查:如果左操作数为 null,instanceof 会返回 false,因为 null 不是任何类的实例。
编译时检查:instanceof 会在编译时进行类型检查,如果类型之间没有关系(例如不在同一个继承树中),编译器会报错。
代码 Demo(空值检查)
代码 Demo(编译时检查)
49. jdk7 的 ConcurrentHashMap 实现?⭐⭐
在 JDK 7 中,ConcurrentHashMap 的实现与 JDK 8 有所不同。JDK 7 中的 ConcurrentHashMap 使用了分段锁(Segment Locking)来实现高并发性能。
主要结构
JDK 7 中的 ConcurrentHashMap 由以下几个主要部分组成:
Segment:分段锁的核心,每个 Segment 是一个小的哈希表,拥有独立的锁。
HashEntry:哈希表中的每个节点,存储键值对。
ConcurrentHashMap:包含多个 Segment,每个 Segment 管理一部分哈希表。
Segment 类
Segment 类是 ReentrantLock 的子类,它是 ConcurrentHashMap 的核心部分。
HashEntry 类
HashEntry 类是哈希表中的节点,存储键值对和指向下一个节点的指针。
ConcurrentHashMap 类
ConcurrentHashMap 类包含多个 Segment,每个 Segment 管理一部分哈希表。
put 操作
put 操作是 ConcurrentHashMap 的核心操作之一,以下是其简化版实现:
get 操作
get 操作是 ConcurrentHashMap 的另一个核心操作,以下是其简化版实现:
主要特点
分段锁:ConcurrentHashMap 将整个哈希表分成多个 Segment,每个 Segment 是一个独立的小哈希表,拥有自己的锁。这样不同的线程可以并发地访问不同的 Segment,显著提高并发性能。
高效并发:通过细粒度的锁机制,ConcurrentHashMap 在高并发环境下表现出色,避免了全表锁的性能瓶颈。
线程安全:所有的操作都在锁的保护下进行,确保了线程安全性。
JDK 7 中的 ConcurrentHashMap 通过分段锁机制实现高并发性能。每个 Segment 是一个独立的小哈希表,拥有自己的锁,允许多个线程并发地访问不同的 Segment。这种设计在高并发环境下显著提高了性能,同时保证了线程安全性。
50. 什么是泛型?⭐⭐⭐⭐⭐
泛型(Generics)是 Java 中的一种机制,允许类、接口和方法操作指定类型的对象,而不必指定具体的类型。泛型提供了一种类型安全的方式来定义数据结构和算法,使代码更加通用和灵活,同时减少了类型转换和类型检查的错误。
主要作用
1、 类型安全:在编译时进行类型检查,避免了运行时的 ClassCastException。
2、 代码重用:通过泛型,可以编写更加通用的代码,而不必为每种数据类型编写重复的代码。
泛型类
泛型类是在类定义中使用一个或多个类型参数。类型参数在类实例化时被指定。
泛型方法
泛型方法是在方法定义中使用类型参数,类型参数在方法调用时被指定。
泛型接口
泛型接口是在接口定义中使用类型参数,类型参数在接口实现时被指定。
泛型的限制
1、 基本类型:泛型不支持基本数据类型(如 int、char 等),只能使用其对应的包装类(如 Integer、Character 等)。
2、 类型擦除:Java 的泛型是通过类型擦除实现的,这意味着在运行时,泛型类型信息会被移除,所有泛型类型都被替换为其原始类型(通常是 Object)。这限制了某些操作,如创建泛型数组。
3、 静态上下文中使用泛型:不能在静态字段或静态方法中使用类型参数,因为类型参数是在实例化时才指定的,而静态成员与具体实例无关。
示例(类型擦除)
51. 说一下 java8 实现的 concurrentHashMap?⭐⭐
Java 8 对 ConcurrentHashMap 进行了重新设计,取消了分段锁的机制,改用更细粒度的锁和无锁操作来提高并发性能。
数据结构
Node:基本的链表节点,存储键值对和指向下一个节点的指针。
TreeNode:用于红黑树的节点,当链表长度超过一定阈值(默认是 8)时,链表会转换为红黑树。
TreeBin:红黑树的容器,管理红黑树的操作。
ForwardingNode:在扩容过程中用于指示节点已经被移动。
主要操作
put 操作:通过 CAS 操作和细粒度的锁来实现高效的并发插入和更新。
get 操作:使用无锁的方式进行查找,性能更高。
扩容:通过逐步迁移节点和协作扩容机制,提高扩容效率。
细粒度的并发控制
Java 8 中的 ConcurrentHashMap 采用了更细粒度的并发控制,主要通过以下方式实现:
CAS 操作:使用 CAS 操作(Compare-And-Swap)进行无锁插入和更新,减少锁竞争。
synchronized 块:在必要时对单个桶(bin)进行加锁,而不是整个段,从而进一步提高并发性。
红黑树:当链表长度超过阈值时,转换为红黑树,降低查找时间复杂度,从 O(n) 降低到 O(log n)。
Java 8 相比 Java 7 的好处
更高的并发性:Java 7 使用段级别的锁,而 Java 8 使用更细粒度的锁和无锁操作,减少了锁竞争,提高了并发性。
更好的性能:Java 8 中的 get 操作是无锁的,性能更高。put 操作使用 CAS 和细粒度的锁,提高了插入和更新的性能。
更高效的扩容:Java 8 通过逐步迁移节点和协作扩容机制,提高了扩容效率,减少了扩容过程中对性能的影响。
更高效的查找:当链表长度超过阈值时,转换为红黑树,降低了查找时间复杂度。
52. 泛型擦除是什么?⭐⭐
泛型擦除是 Java 编译器在编译泛型代码时的一种机制。它的目的是确保泛型能够与 Java 的旧版本(即不支持泛型的版本)兼容。
在 Java 中,泛型信息只存在于源代码和编译时,在运行时,所有的泛型类型信息都会被擦除。这意味着在运行时,所有的泛型类型都被替换为它们的上限类型(如果没有显式指定上限,则默认为 Object)。
考虑一个简单的泛型类:
在编译时,泛型类型 T 会被擦除,并替换为它的上限类型。在这个例子中,因为没有指定上限类型,T 会被替换为 Object。编译后的代码大致如下:
类型擦除的影响
运行时类型检查:由于泛型类型信息在运行时被擦除,无法在运行时获取泛型类型的信息。例如,不能使用 instanceof 操作符检查泛型类型。
泛型数组:不能创建泛型类型的数组,因为在运行时无法确定泛型类型。
类型安全:在编译时进行类型检查,确保类型安全。然而,由于类型擦除,在某些情况下仍可能出现类型转换异常。
使用限制
静态上下文中使用泛型:不能在静态字段或静态方法中使用类型参数,因为类型参数是在实例化时才指定的,而静态成员与具体实例无关。
泛型实例化:不能直接实例化泛型类型,因为在运行时泛型类型信息已经被擦除。
53. 深拷贝和浅拷贝的区别⭐
深拷贝(Deep Copy)和浅拷贝(Shallow Copy)是对象复制的两种方式,它们在复制对象时的行为有所不同,特别是在处理包含引用类型的对象时。
浅拷贝:仅复制对象的值类型属性,对于引用类型属性,只复制引用,即新旧对象共享同一个引用类型的实例。修改新对象的引用类型属性会影响原对象。
深拷贝:不仅复制对象的值类型属性,还递归地复制引用类型属性,即新旧对象的引用类型属性指向不同的实例。修改新对象的引用类型属性不会影响原对象。
浅拷贝(Shallow Copy)
浅拷贝是指创建一个新对象,这个新对象的属性与原对象的属性具有相同的值。对于值类型(如基本数据类型),浅拷贝会复制它们的值;但对于引用类型(如对象、数组等),浅拷贝只复制引用,即新对象的属性将引用到原对象中引用的同一个对象。
深拷贝(Deep Copy)
深拷贝是指创建一个新对象,这个新对象与原对象具有相同的属性值,但所有引用类型的属性也会被递归地复制,即新对象与原对象的引用类型属性指向不同的对象。这样,修改新对象的引用类型属性不会影响原对象的引用类型属性。
55. 什么是 TreeMap?⭐⭐⭐⭐⭐
基本特点
有序性:TreeMap 保证了键的自然顺序(通过 Comparable 接口)或通过提供的比较器(Comparator)的顺序。
红黑树:TreeMap 内部使用红黑树数据结构来存储键值对,保证了插入、删除、查找等操作的时间复杂度为 O(log n)。
不允许 null 键:TreeMap 不允许键为 null,但允许值为 null。
线程不安全:TreeMap 不是线程安全的,如果需要在多线程环境中使用,需要通过外部同步机制来保证线程安全。
与其他集合类的比较
TreeMap vs. HashMap:
TreeMap 保证键的有序性,而 HashMap 不保证顺序。
TreeMap 基于红黑树实现,操作的时间复杂度为 O(log n),而 HashMap 基于哈希表实现,操作的平均时间复杂度为 O(1)。
TreeMap 不允许 null 键,而 HashMap 允许一个 null 键。
TreeMap vs. LinkedHashMap:
TreeMap 保证键的自然顺序或比较器的顺序,而 LinkedHashMap 保证插入顺序或访问顺序。
TreeMap 的操作时间复杂度为 O(log n),而 LinkedHashMap 的操作时间复杂度为 O(1)。
适用场景
TreeMap 适用于需要按键排序存储键值对的场景,例如:
实现基于范围的查询。
需要按顺序遍历键值对。
需要快速查找最小或最大键值对。
56. linkedHashMap 为什么能用来做 LRUCache?⭐⭐
LinkedHashMap 能用来做 LRU 缓存的关键原因在于它可以维护访问顺序,并且通过重写 removeEldestEntry 方法,可以轻松实现缓存的自动清理。
关键特性
访问顺序:LinkedHashMap 提供了一个构造方法,可以指定是否按照访问顺序来维护键值对的顺序。当 accessOrder 参数设置为 true 时,LinkedHashMap 将根据每次访问(get 或 put 操作)来调整顺序,把最近访问的键值对移到链表的末尾。
自动清理:通过重写 removeEldestEntry 方法,可以在插入新键值对时自动移除最老的键值对(即链表头部的键值对),从而实现缓存的自动清理。
实现 LRU 缓存的步骤
创建一个 LinkedHashMap 实例,并将 accessOrder 参数设置为 true。
重写 removeEldestEntry 方法,以便在缓存大小超过预定义的最大容量时自动移除最老的键值对。
代码 Demo
解释
构造方法:LRUCache 构造方法中调用了 LinkedHashMap 的构造方法,并将 accessOrder 参数设置为 true,以便按照访问顺序维护键值对的顺序。
removeEldestEntry 方法:重写了 removeEldestEntry 方法,当缓存的大小超过 maxCapacity 时返回 true,从而移除最老的键值对。
使用示例:在主方法中创建了一个 LRUCache 实例,插入了几个键值对,并通过访问键 "A" 来改变其顺序。然后插入一个新键值对 "D",导致最老的键值对 "B" 被移除。
57. 介绍一下 Java 的数据结构⭐
基本数据结构
数组 (Array):固定大小的容器,用于存储相同类型的元素。数组在内存中是连续存储的,支持通过索引快速访问元素。
Java int[] numbers = new int[10]; numbers[0] = 1;
集合框架 (JCF)
JCF 提供了一组接口和类,用于管理和操作集合(如列表、集合、映射等)。
List 接口
有序集合,允许重复元素。常用实现类包括 ArrayList、LinkedList 和 Vector。
Java List<String> list = new ArrayList<>(); list.add("A"); list.add("B");
ArrayList:基于动态数组实现,支持快速随机访问和遍历。
LinkedList:基于双向链表实现,适合频繁的插入和删除操作。
Vector:类似于 ArrayList,但线程安全。
Set 接口
无序集合,不允许重复元素。常用实现类包括 HashSet、LinkedHashSet 和 TreeSet。
Java Set<String> set = new HashSet<>(); set.add("A"); set.add("B");
HashSet:基于哈希表实现,提供快速的插入、删除和查找操作。
LinkedHashSet:保持插入顺序的 HashSet。
TreeSet:基于红黑树实现,元素按自然顺序排序。
Map 接口
键值对映射,不允许重复键。常用实现类包括 HashMap、LinkedHashMap 和 TreeMap。
Java Map<String, Integer> map = new HashMap<>(); map.put("A", 1); map.put("B", 2);
HashMap:基于哈希表实现,提供快速的键值对存取。
LinkedHashMap:保持插入顺序的 HashMap。
TreeMap:基于红黑树实现,键按自然顺序排序。
35. linkedhashmap 如何保证有序性?⭐⭐
LinkedHashMap 通过维护一个双向链表来保证有序性。这个双向链表记录了所有插入的键值对的顺序。根据构造方法中的参数设置,LinkedHashMap 可以按插入顺序或访问顺序来维护这些键值对的顺序。
具体实现原理
双向链表:LinkedHashMap 在内部维护了一个双向链表。每个节点对应一个键值对,并且包含指向前一个节点和后一个节点的引用。通过这个链表,LinkedHashMap 可以快速地遍历所有键值对,保持其有序性。
插入顺序:默认情况下,LinkedHashMap 按照键值对插入的顺序来维护顺序。每次插入新键值对时,它会将新节点添加到链表的末尾。
访问顺序:如果在构造方法中将 accessOrder 参数设置为 true,LinkedHashMap 将按照访问顺序来维护键值对的顺序。每次访问(get 或 put 操作)一个键值对时,它会将对应的节点移动到链表的末尾。
代码 Demo
解释
插入顺序:
创建一个 LinkedHashMap 实例 insertionOrderMap。
插入键值对 "A"、"B" 和 "C"。
遍历并打印键值对,顺序与插入顺序一致。
访问顺序:
创建一个 LinkedHashMap 实例 accessOrderMap,并将 accessOrder 参数设置为 true。
插入键值对 "A"、"B" 和 "C"。
访问键 "A" 和 "C"(通过 get 操作)。
遍历并打印键值对,顺序按照最近访问的顺序排列。
内部机制
节点结构:LinkedHashMap 的每个节点不仅包含键和值,还包含指向前一个节点和后一个节点的引用。这使得它可以高效地维护顺序。
操作调整:在每次插入或访问键值对时,LinkedHashMap 会调整链表中节点的位置,以确保顺序的正确性。例如,在访问顺序模式下,每次访问一个键值对时,它会将对应的节点移动到链表的末尾。
通过这些机制,LinkedHashMap 能够高效地维护键值对的有序性,无论是按插入顺序还是访问顺序。
58. try catch finally 的使用⭐⭐⭐⭐⭐
try-catch-finally
是 Java 中用于异常处理的结构。它允许程序员捕获和处理在程序运行过程中可能发生的异常,并在执行完异常处理后执行一些清理工作。
1、try 块:包含可能抛出异常的代码。如果在try
块中发生异常,控制权将立即转移到相应的catch
块。
2、catch 块:用于捕获和处理特定类型的异常。可以有多个catch
块来处理不同类型的异常。每个catch
块都必须处理一种特定类型的异常。catch
块的参数是异常类型和异常对象。
3、finally 块:包含确保执行的代码,无论是否发生异常。通常用于清理资源,如关闭文件、释放锁等。finally
块是可选的,但如果存在,它会在try
块和任何相关的catch
块之后执行。
59. 如何确保函数不能修改集合?⭐⭐
使用 Collections.unmodifiableCollection 方法
Java 提供了 Collections.unmodifiableCollection 方法,可以将一个集合包装成一个不可修改的视图。对这个视图的修改操作将会抛出 UnsupportedOperationException。
使用 Collections.unmodifiable
对于特定类型的集合,如 List、Set 和 Map,Java 提供了相应的不可修改视图方法:
Collections.unmodifiableList
Collections.unmodifiableSet
Collections.unmodifiableMap
使用 Collections.unmodifiableCollection 递归包装嵌套集合
如果集合中包含嵌套集合(例如一个 List 中包含 Set),你需要递归地将所有嵌套集合也包装成不可修改的视图。
通过以上可以确保传递给函数的集合不会被修改,从而保证集合的不可变性。
60. 运行时异常和非运行时异常的区别⭐⭐⭐⭐⭐
在 Java 中,异常分为两大类:运行时异常和非运行时异常。
运行时异常(Runtime Exceptions)
运行时异常是指在程序运行期间可能会发生的异常。这类异常是 RuntimeException 类及其子类的实例。
1、 无需显式捕获或声明:方法中不需要显式地捕获或声明可能抛出的运行时异常。编译器不会强制要求你处理这些异常。
2、通常是编程错误:运行时异常通常是由于编程错误引起的,例如访问空指针、数组越界、类型转换错误等。
3、常见的运行时异常:
NullPointerException
ArrayIndexOutOfBoundsException
ClassCastException
IllegalArgumentException
示例
非运行时异常
非运行时异常是指在编译时必须处理的异常。这类异常是 Exception 类及其子类(但不包括 RuntimeException 及其子类)的实例。
1、 需要显式捕获或声明:方法中必须显式地捕获或声明可能抛出的非运行时异常。编译器会强制要求你处理这些异常。
2、 通常是可预见的异常情况:非运行时异常通常是由于合理的、可以预见的异常情况引起的,例如文件未找到、网络连接失败等。
3、 常见的非运行时异常:
IOException
SQLException
FileNotFoundException
ClassNotFoundException
示例
今天先分享这么多,剩余的 61 个下篇发,需要的朋友关注点一点。
欢迎关注 ❤
我们搞了一个免费的面试真题共享群,互通有无,一起刷题进步。
没准能让你能刷到自己意向公司的最新面试题呢。
感兴趣的朋友们可以加我微信:wangzhongyang1993,备注:面试群。
版权声明: 本文为 InfoQ 作者【王中阳Go】的原创文章。
原文链接:【http://xie.infoq.cn/article/157ae934a7406c099d24e886b】。文章转载请联系作者。
评论