1,面向对象的理解、面向过程和面向对象编程有什么区别?
- 面向对象的理解
- 概念:面向对象编程(Object - Oriented Programming,简称 OOP)是一种编程范式,它将现实世界中的事物抽象成对象,对象包含了数据(属性)和操作这些数据的方法。这些对象可以相互交互来完成复杂的任务。
- 特性
- 封装:将数据和操作数据的方法封装在一起,形成一个独立的单元,也就是类。例如,在一个
Person
类中,我们可以将姓名、年龄等属性和获取姓名、设置年龄等方法封装在这个类中。这样可以隐藏内部实现细节,只对外提供必要的接口,提高代码的安全性和可维护性。 - 继承:允许创建新的类(子类)从现有的类(父类)继承属性和方法。例如,有一个
Animal
类,它有属性 “体重” 和方法 “移动”。然后我们可以创建一个Dog
类继承自Animal
类,Dog
类除了拥有Animal
类的属性和方法外,还可以有自己特有的属性(如 “品种”)和方法(如 “汪汪叫”)。继承可以实现代码的复用,减少代码冗余。 - 多态:多态是指同一个行为具有多种不同的表现形式。在 Java 中有两种实现方式,一种是方法重载(Overloading),另一种是方法重写(Overriding)。方法重载是指在一个类中可以有多个同名的方法,但是它们的参数列表不同(参数个数、参数类型或参数顺序不同)。例如,一个
Calculator
类可以有两个add
方法,一个是add(int a, int b)
,另一个是add(double a, double b)
。方法重写是指子类可以重写父类的方法,当通过子类对象调用这个方法时,会执行子类重写后的方法。例如,Animal
类有一个makeSound
方法,Dog
类重写了这个方法来发出 “汪汪” 声,Cat
类重写这个方法来发出 “喵喵” 声。
- 封装:将数据和操作数据的方法封装在一起,形成一个独立的单元,也就是类。例如,在一个
- 面向过程和面向对象编程的区别
- 编程思路
- 面向过程:以过程(函数)为中心,强调的是程序执行的步骤和流程。它将一个复杂的问题分解成一系列的步骤,通过函数来实现这些步骤,然后按照顺序依次调用这些函数来解决问题。例如,要计算一个班级学生的平均成绩,面向过程的方式可能是先定义一个函数来读取学生成绩数据,再定义一个函数来计算总成绩,最后定义一个函数来计算平均成绩,然后按顺序调用这些函数。
- 面向对象:以对象为中心,将问题抽象成对象以及对象之间的交互。例如,对于计算班级学生平均成绩的问题,面向对象的方式可能是创建一个
Student
类,其中包含学生的成绩属性,然后创建一个Classroom
类,它包含多个Student
对象。Classroom
类有方法来计算所有学生的平均成绩,通过这些对象之间的交互来完成任务。
- 代码复用性
- 面向过程:代码复用相对困难。因为过程式代码主要是基于一系列的函数,当要复用代码时,可能需要复制和修改大量的函数,并且这些函数之间可能存在紧密的依赖关系,修改一个函数可能会影响其他函数。
- 面向对象:具有更好的代码复用性。通过继承,子类可以继承父类的属性和方法,不需要重新编写这些代码。例如,有一个基本的图形类
Shape
,它有计算面积的方法,当我们创建新的图形类如Circle
和Rectangle
时,它们可以继承Shape
类的部分属性和方法,只需要添加自己特有的属性和方法(如Circle
类的半径属性和根据半径计算面积的方法)。
- 维护性
- 面向过程:当程序规模变大时,维护成本较高。因为代码是按照过程编写的,各个函数之间的逻辑关系可能比较复杂,修改一个功能可能需要在多个函数中进行修改。
- 面向对象:维护性较好。由于封装性,每个对象的内部细节对外部是隐藏的,只需要关注对象的接口。当需要修改一个对象的内部实现时,只要接口不变,对其他对象的影响较小。例如,在一个大型的软件系统中,如果要修改
Student
类的成绩存储方式,只要Student
类对外提供的获取成绩和设置成绩的接口不变,其他使用Student
类的部分(如Classroom
类)就不需要修改。
- 编程思路
2,Java中有哪些集合类?请简单介绍
- List 接口及实现类
- ArrayList
- 特点
- ArrayList 是基于动态数组实现的。它可以动态地增长和收缩,能够自动处理数组容量的调整。例如,当添加元素时,如果数组已满,它会自动创建一个更大的新数组,并将旧数组中的元素复制到新数组中。
- 它允许存储重复元素,并且元素是有序的,存储和取出的顺序一致。例如,
ArrayList<String> list = new ArrayList<>();
,添加元素list.add("apple"); list.add("banana"); list.add("apple");
,这里的元素顺序是按照添加顺序排列的,并且可以有重复的 “apple”。 - 随机访问效率高,通过索引访问元素的时间复杂度为。因为它内部是数组结构,知道元素的索引就可以直接定位到元素。比如
list.get(1)
可以很快地获取到索引为 1 的元素 “banana”。
- 特点
- LinkedList
- 特点
- LinkedList 是基于双向链表实现的。它的每个节点包含了数据以及指向前一个节点和后一个节点的引用。
- 也允许存储重复元素,元素有序。例如,
LinkedList<Integer> linkedList = new LinkedList<>();
,添加元素linkedList.add(1); linkedList.add(2); linkedList.add(1);
,元素顺序与添加顺序相同,且有重复元素。 - 插入和删除操作效率高,特别是在链表中间进行插入和删除时,时间复杂度为(如果已经定位到插入或删除的位置)。这是因为只需要修改节点之间的引用关系。但是随机访问效率较低,通过索引访问元素的时间复杂度为,因为需要从链表头(或尾)开始逐个遍历节点来找到指定索引的元素。
- 特点
- Vector
- 特点
- Vector 和 ArrayList 类似,也是基于数组实现的。不过它是线程安全的,它的大多数方法都被
synchronized
关键字修饰,这使得它在多线程环境下可以安全地使用。 - 性能相对 ArrayList 稍差,因为线程安全的机制会带来一定的开销。例如,在单线程环境下,同样是获取元素操作,ArrayList 可能比 Vector 更快。
- Vector 和 ArrayList 类似,也是基于数组实现的。不过它是线程安全的,它的大多数方法都被
- 特点
- ArrayList
- Set 接口及实现类
- HashSet
- 特点
- HashSet 是基于哈希表实现的。它不允许存储重复元素,例如,
HashSet<String> hashSet = new HashSet<>();
,添加元素hashSet.add("apple"); hashSet.add("banana"); hashSet.add("apple");
,最终集合中只有 “apple” 和 “banana” 两个元素,重复的 “apple” 被自动去除。 - 元素无序,它不保证元素的存储顺序和添加顺序一致。因为哈希表是根据元素的哈希值来存储元素的位置。
- 查找效率高,添加、删除和查找元素的时间复杂度近似为。这是因为通过元素的哈希值可以快速定位元素在哈希表中的位置。
- HashSet 是基于哈希表实现的。它不允许存储重复元素,例如,
- 特点
- LinkedHashSet
- 特点
- LinkedHashSet 是 HashSet 的一个子类,它在 HashSet 的基础上维护了一个双向链表,用于记录元素的插入顺序。
- 不允许重复元素,同时元素是有序的,这个顺序是按照元素的插入顺序来排列的。例如,
LinkedHashSet<Integer> linkedHashSet = new LinkedHashSet<>();
,添加元素linkedHashSet.add(1); linkedHashSet.add(2); linkedHashSet.add(1);
,最终集合中有 1 和 2 两个元素,顺序是按照插入的先后顺序排列的。
- 特点
- TreeSet
- 特点
- TreeSet 是基于红黑树实现的。它不允许存储重复元素,并且元素是有序的,这个顺序是按照元素的自然顺序(对于实现了
Comparable
接口的元素)或者根据自定义的比较器(Comparator
)来排序的。 - 例如,
TreeSet<Integer> treeSet = new TreeSet<>();
,添加元素treeSet.add(3); treeSet.add(1); treeSet.add(2);
,元素会按照从小到大的顺序(1、2、3)存储在集合中。可以用于对元素进行自动排序的场景。
- TreeSet 是基于红黑树实现的。它不允许存储重复元素,并且元素是有序的,这个顺序是按照元素的自然顺序(对于实现了
- 特点
- HashSet
- Map 接口及实现类
- HashMap
- 特点
- HashMap 是基于哈希表实现的。它存储的是键 - 值对(
key - value
),其中键不能重复,值可以重复。例如,HashMap<String, Integer> hashMap = new HashMap<>();
,可以添加hashMap.put("apple", 1); hashMap.put("banana", 2);
,键 “apple” 和 “banana” 是唯一的,但值可以是相同的。 - 它的元素是无序的,不保证键 - 值对的存储顺序和添加顺序一致。查找、插入和删除操作效率高,时间复杂度近似为,原理和 HashSet 类似,通过键的哈希值来快速定位元素。
- HashMap 是基于哈希表实现的。它存储的是键 - 值对(
- 特点
- LinkedHashMap
- 特点
- LinkedHashMap 是 HashMap 的一个子类,它在 HashMap 的基础上维护了一个双向链表,用于记录键 - 值对的插入顺序或者访问顺序(可以通过构造函数来指定)。
- 例如,
LinkedHashMap<String, Integer> linkedHashMap = new LinkedHashMap<>();
,添加元素linkedHashMap.put("apple", 1); linkedHashMap.put("banana", 2);
,键 - 值对的顺序是按照插入顺序排列的。
- 特点
- TreeMap
- 特点
- TreeMap 是基于红黑树实现的。它存储键 - 值对,并且键是有序的,顺序是按照键的自然顺序(对于实现了
Comparable
接口的键)或者根据自定义的比较器(Comparator
)来排序的。 - 例如,
TreeMap<Integer, String> treeMap = new TreeMap<>();
,添加元素treeMap.put(3, "three"); treeMap.put(1, "one"); treeMap.put(2, "two");
,键会按照从小到大的顺序(1、2、3)排列,相应的值也会随之排列。可以用于需要按键的顺序来遍历键 - 值对的场景。
- TreeMap 是基于红黑树实现的。它存储键 - 值对,并且键是有序的,顺序是按照键的自然顺序(对于实现了
- 特点
- HashMap
3,ArrayList的线程安全版本是什么?
基本概念
- CopyOnWriteArrayList 是 Java 中的一个线程安全的集合类,它实现了 List 接口。“Copy - On - Write”(写时复制)是它的核心机制。这意味着当对集合进行修改操作(如添加、删除、修改元素)时,它会先复制一份原有的数组,然后在新的副本上进行修改,最后将原数组的引用指向新的数组。而读取操作(如
get
方法)可以在原数组上进行,不需要加锁,所以读取操作效率很高。
- CopyOnWriteArrayList 是 Java 中的一个线程安全的集合类,它实现了 List 接口。“Copy - On - Write”(写时复制)是它的核心机制。这意味着当对集合进行修改操作(如添加、删除、修改元素)时,它会先复制一份原有的数组,然后在新的副本上进行修改,最后将原数组的引用指向新的数组。而读取操作(如
工作原理示例
假设我们有一个 CopyOnWriteArrayList,初始时包含元素
[1, 2, 3]
。当我们要添加一个元素
4
时,它会执行以下操作:
- 首先,它会创建一个新的数组,长度比原来的数组大 1(在这个例子中,新数组长度为 4)。
- 然后,将原来数组中的元素
[1, 2, 3]
复制到新数组中。 - 接着,将新元素
4
添加到新数组的合适位置(这里是最后一个位置)。 - 最后,将内部的数组引用指向新的数组,这样后续的操作就会基于新数组进行。
代码示例
- 以下是一个简单的使用 CopyOnWriteArrayList 的示例:
import java.util.concurrent.CopyOnWriteArrayList; public class CopyOnWriteArrayListExample { public static void main(String[] args) { // 创建一个CopyOnWriteArrayList CopyOnWriteArrayList<Integer> list = new CopyOnWriteArrayList<>(); list.add(1); list.add(2); list.add(3); // 读取元素,不需要加锁,效率高 System.out.println("读取元素:"); for (Integer i : list) { System.out.print(i + " "); } // 修改元素,会进行写时复制 System.out.println("\n修改元素后:"); list.add(4); for (Integer i : list) { System.out.print(i + " "); } } }
- 在这个示例中,首先创建了一个 CopyOnWriteArrayList 并添加了 3 个元素。然后通过增强 for 循环读取元素,这个过程是高效的,不需要加锁。接着添加一个元素
4
,这会触发写时复制机制,在新的数组上进行修改后,再次读取元素就会基于新的数组。
适用场景和优缺点
- 适用场景
- 适用于读多写少的并发场景。因为读取操作不需要加锁,效率高,而对于写操作,虽然会有复制数组的开销,但在写操作不频繁的情况下,这种开销是可以接受的。例如,在一个配置管理系统中,配置信息在初始化后很少修改,但是会经常被读取,就可以使用 CopyOnWriteArrayList 来存储配置信息。
- 优点
- 线程安全,在多线程环境下可以正确地工作,不会出现数据不一致的问题。
- 读取操作不会被写操作阻塞,因为读取操作不需要获取锁,这对于高并发读取的场景非常有利。
- 缺点
- 内存占用较大。由于写操作会复制数组,可能会导致内存中同时存在多个版本的数组,尤其是在数组较大且写操作频繁的情况下,会消耗大量的内存。
- 写操作性能相对较低。因为每次写操作都需要复制数组,这会带来一定的性能开销,特别是在数组元素较多的情况下,复制数组的时间成本会比较高。
- 适用场景
Vector
简介
- Vector 是 Java 早期提供的集合类,它和 ArrayList 类似,都是基于数组实现的动态数组。不同的是,Vector 是线程安全的。它的大多数方法都被
synchronized
关键字修饰,这意味着在多线程环境下,多个线程访问同一个 Vector 对象时,会通过锁机制来保证数据的一致性。例如,当一个线程在对 Vector 进行添加元素操作时,其他线程如果也想操作这个 Vector,就需要等待当前线程释放锁。
- Vector 是 Java 早期提供的集合类,它和 ArrayList 类似,都是基于数组实现的动态数组。不同的是,Vector 是线程安全的。它的大多数方法都被
代码示例
- 以下是一个简单的 Vector 使用示例,在多线程环境下向 Vector 中添加元素:
import java.util.Vector; public class VectorThreadSafetyExample { public static void main(String[] args) { // 创建一个Vector对象 Vector<Integer> vector = new Vector<>(); // 创建多个线程并启动 Thread thread1 = new Thread(() -> { for (int i = 0; i < 1000; i++) { vector.add(i); } }); Thread thread2 = new Thread(() -> { for (int i = 1000; i < 2000; i++) { vector.add(i); } }); thread1.start(); thread2.start(); try { // 等待两个线程执行完毕 thread1.join(); thread2.join(); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println("Vector size: " + vector.size()); } }
- 在这个示例中,
thread1
和thread2
两个线程同时向vector
中添加元素,由于Vector
是线程安全的,所以最终vector
的大小是预期的 2000(如果是 ArrayList 在没有额外同步措施的情况下,可能会出现并发安全问题,导致元素个数不准确等情况)。
Collections.synchronizedList () 方法返回的列表
简介
- Java 提供了
Collections
工具类,它有一个synchronizedList
方法。这个方法可以将一个普通的List
(如ArrayList
)转换为一个线程安全的列表。它的实现原理是通过在List
的方法上添加同步锁来保证在多线程环境下的安全访问。具体来说,它返回的是一个SynchronizedList
类的实例,这个类是Collections
类的一个内部类,它在每个方法内部使用了synchronized
块来同步对底层List
的访问。
- Java 提供了
代码示例
- 以下是使用
Collections.synchronizedList
方法的示例:
import java.util.ArrayList; import java.util.Collections; import java.util.List; public class SynchronizedListExample { public static void main(String[] args) { // 创建一个ArrayList ArrayList<Integer> arrayList = new ArrayList<>(); // 将ArrayList转换为线程安全的列表 List<Integer> synchronizedList = Collections.synchronizedList(arrayList); // 创建多个线程并启动 Thread thread3 = new Thread(() -> { for (int i = 0; i < 1000; i++) { synchronizedList.add(i); } }); Thread thread4 = new Thread(() -> { for (int i = 1000; i < 2000; i++) { synchronizedList.add(i); } }); thread3.start(); thread4.start(); try { // 等待两个线程执行完毕 thread3.join(); thread4.join(); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println("SynchronizedList size: " + synchronizedList.size()); } }
- 在这个示例中,首先创建了一个
ArrayList
,然后通过Collections.synchronizedList
方法将其转换为线程安全的列表。之后,thread3
和thread4
两个线程同时向这个列表中添加元素,最终可以得到正确的列表大小。这种方式比直接使用Vector
更加灵活,因为可以根据需要将不同类型的List
转换为线程安全的版本。
- 以下是使用
4,什么是fail-fast 机制?
-
- fail - fast 是 Java 集合框架中的一种错误检测机制。当在迭代集合(如使用
Iterator
遍历ArrayList
、HashMap
等集合)的过程中,如果集合的结构被修改(比如添加、删除元素等操作),就会抛出ConcurrentModificationException
异常,这种快速失败的机制可以防止在迭代过程中出现不可预测的行为。
- fail - fast 是 Java 集合框架中的一种错误检测机制。当在迭代集合(如使用
原理
- 以
ArrayList
为例,在其内部有一个modCount
变量,这个变量用于记录集合结构被修改的次数。当通过iterator()
方法获取一个Iterator
对象时,Iterator
对象会保存此时的modCount
值。在迭代过程中,每次调用next()
等方法时,Iterator
会检查当前集合的modCount
是否和它保存的modCount
一致。如果不一致,就说明集合结构在迭代过程中被修改了,于是抛出ConcurrentModificationException
异常。
- 以
代码示例
- 下面是一个
ArrayList
中出现 fail - fast 机制的示例:
import java.util.ArrayList; import java.util.Iterator; public class FailFastExample { public static void main(String[] args) { ArrayList<Integer> list = new ArrayList<>(); list.add(1); list.add(2); list.add(3); // 获取迭代器 Iterator<Integer> iterator = list.iterator(); while (iterator.hasNext()) { Integer element = iterator.next(); // 在迭代过程中修改集合结构 list.remove(element); } } }
- 在这个示例中,在使用
Iterator
迭代list
的过程中,通过list.remove(element)
修改了集合的结构。当iterator.next()
方法再次被调用时,就会检查到modCount
的变化,从而抛出ConcurrentModificationException
异常。
- 下面是一个
目的和意义
- 这种机制的主要目的是为了保证数据的一致性和避免在迭代过程中出现意外的结果。如果在迭代过程中允许随意修改集合结构,可能会导致迭代次数不确定、元素跳过或重复访问等问题。通过 fail - fast 机制,可以让开发者在代码中及时发现这种错误并进行修正,而不是在出现复杂的、难以排查的逻辑错误后才去寻找问题。
解决方法(如果需要在迭代过程中修改集合)
使用迭代器的
remove
方法(对于List
集合)- 对于
List
集合,Iterator
提供了remove
方法,可以在迭代过程中安全地删除元素。例如:
ArrayList<Integer> list = new ArrayList<>(); list.add(1); list.add(2); list.add(3); Iterator<Integer> iterator = list.iterator(); while (iterator.hasNext()) { Integer element = iterator.next(); if (element == 2) { // 使用迭代器的remove方法 iterator.remove(); } } System.out.println(list);
- 在这个示例中,通过
iterator.remove()
方法在迭代过程中删除了元素2
,这样就不会触发 fail - fast 机制,最终list
的内容为[1, 3]
。
- 对于
使用
java.util.concurrent
包下的集合(对于并发场景)- 如果是在多线程并发环境下,需要对集合进行修改和迭代操作,可以考虑使用
java.util.concurrent
包下的集合,如CopyOnWriteArrayList
(对于List
类型的集合)和ConcurrentHashMap
(对于Map
类型的集合)。这些集合在设计上考虑了并发修改的情况,不会出现 fail - fast 现象。例如,CopyOnWriteArrayList
在修改时采用写时复制的策略,保证了读取操作(包括迭代)和修改操作的相对独立性。
- 如果是在多线程并发环境下,需要对集合进行修改和迭代操作,可以考虑使用
5,HashMap线程安全吗?
HashMap 不是线程安全的
原因
:
- 在多线程环境下,当多个线程同时对 HashMap 进行写操作(如
put
方法添加键值对)时,可能会导致数据不一致和其他并发问题。这是因为 HashMap 的内部结构在进行操作时可能会发生变化,例如在调整容量(rehash)过程中。 - 假设两个线程同时检测到 HashMap 需要进行扩容,它们可能会同时尝试对内部数组进行重新哈希和元素迁移等操作,这会导致元素丢失、链表形成环等问题,从而破坏了 HashMap 的数据完整性。
- 在多线程环境下,当多个线程同时对 HashMap 进行写操作(如
代码示例展示并发问题
- 以下是一个简单的代码示例,用于演示在多线程环境下使用 HashMap 可能出现的问题:
import java.util.HashMap; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; public class HashMapThreadSafetyIssue { public static void main(String[] args) { final HashMap<Integer, Integer> hashMap = new HashMap<>(); // 创建一个线程池,包含两个线程 ExecutorService executorService = Executors.newFixedThreadPool(2); // 提交两个任务,同时向HashMap中添加键值对 executorService.submit(() -> { for (int i = 0; i < 1000; i++) { hashMap.put(i, i); } }); executorService.submit(() -> { for (int i = 1000; i < 2000; i++) { hashMap.put(i, i); } }); // 关闭线程池 executorService.shutdown(); // 等待所有任务完成 while (!executorService.isTerminated()) { // 等待 } System.out.println("HashMap size: " + hashMap.size()); } }
- 在这个示例中,创建了一个包含两个线程的线程池,两个线程分别向 HashMap 中添加 1000 个键值对。在实际运行中,由于 HashMap 不是线程安全的,最后输出的 HashMap 的大小可能会小于 2000,并且可能会出现
NullPointerException
等异常,这是因为内部结构被破坏导致的。
线程安全的替代方案
- Hashtable
- Hashtable 是 Java 早期提供的一个键值对集合,它是线程安全的。它的实现原理是在每个方法(如
put
、get
等)上使用synchronized
关键字进行同步。不过,由于这种同步方式是对整个 Hashtable 对象进行加锁,在高并发场景下性能较差。例如,当多个线程同时访问 Hashtable 时,它们会依次排队等待锁,导致效率降低。
- Hashtable 是 Java 早期提供的一个键值对集合,它是线程安全的。它的实现原理是在每个方法(如
- ConcurrentHashMap
- ConcurrentHashMap 是 Java 5.0 引入的一个高性能的线程安全的哈希表实现。它采用了更加精细的锁机制(如分段锁等技术,在 Java 8 之后又进行了优化),允许在一定程度上的并发读写操作。它在保证线程安全的同时,提供了比 Hashtable 更好的性能。例如,多个线程可以同时对不同的 “段”(在 Java 8 之前的实现)或者在不同的哈希桶(在 Java 8 及之后的实现)进行操作,减少了锁竞争,提高了并发性能。
- Hashtable
6,你能详细解释一下HashMap的put过程吗?
基本流程概述
- 当调用
HashMap
的put
方法插入键值对(key - value
)时,HashMap
主要会进行以下几个关键步骤:首先,它会根据键(key
)的哈希值(hashCode
)来确定这个键值对在内部数组中的存储位置。如果这个位置没有元素,就直接插入新的键值对;如果该位置已经有元素了,就需要判断是进行替换操作还是添加到链表或者红黑树中(从 Java 8 开始,当链表长度达到一定阈值会转化为红黑树,以提高查找效率)。
- 当调用
详细步骤
计算哈希值
- 当执行
put
方法时,首先会调用键(key
)的hashCode
方法来获取其原始哈希值。但是,为了减少哈希冲突,HashMap
会对这个原始哈希值进行进一步的处理。例如,在 Java 8 中,会通过以下方式计算最终的哈希值h
:
static final int hash(Object key) { int h; return (key == null)? 0 : (h = key.hashCode()) ^ (h >>> 16); }
- 这个操作是将哈希值的高 16 位和低 16 位进行异或运算,这样可以让哈希值的高位也参与到数组下标的计算中,更好地利用数组空间,减少哈希冲突。
- 当执行
确定数组下标
- 计算出哈希值后,会通过
(n - 1) & hash
的方式来确定键值对在数组中的存储位置。其中n
是数组的长度(HashMap
内部数组的长度总是 2 的幂次方)。例如,假设数组长度n = 16
,计算出的哈希值hash = 20
,那么(16 - 1) & 20 = 4
,这个键值对就会尝试存储在数组索引为 4 的位置。
- 计算出哈希值后,会通过
插入键值对
情况一:数组位置为空
- 如果通过上述计算得到的数组位置没有元素(即
table[i] == null
,其中i
是计算出的索引位置,table
是HashMap
内部的数组),那么就会直接将键值对封装成一个Node
对象(Node<K,V> newNode(int hash, K key, V value, Node<K,V> next)
)并插入到这个位置。
- 如果通过上述计算得到的数组位置没有元素(即
情况二:数组位置已有元素(哈希冲突)
- 如果数组位置已经有元素,就需要比较插入键的哈希值和已存在元素的哈希值是否相等,并且通过
equals
方法判断键是否相同。 - 键相同(覆盖操作):如果哈希值相等且键通过
equals
方法比较也相同,那么就会用新的值替换原来的值。在 Java 8 中,HashMap
的put
方法中会有这样的代码片段来处理覆盖操作:
if (e.hash == hash && ((k = e.key) == key || (key!= null && key.equals(k)))) { V oldValue = e.value; e.value = value; e.recordAccess(this); return oldValue; }
- 键不同(链表或红黑树操作):如果哈希值相等但键不同,就会发生哈希冲突。在 Java 8 之前,
HashMap
会将新的键值对添加到该位置的链表头部。从 Java 8 开始,如果该位置的链表长度达到 8(TREEIFY_THRESHOLD
的值)且数组长度大于等于 64(MIN_TREEIFY_CAPACITY
的值),就会将链表转换为红黑树来存储这些冲突的键值对,以提高查找效率。在添加到链表或红黑树时,会遍历链表或者红黑树找到合适的位置插入新的键值对。例如,在添加到链表时,会通过以下方式添加到头部:
else { for (Node<K,V> pred = tab[i]; ; pred = e) { e = pred.next; if (e == null) { pred.next = newNode(hash, key, value, null); break; } } }
- 如果数组位置已经有元素,就需要比较插入键的哈希值和已存在元素的哈希值是否相等,并且通过
扩容操作(可能发生)
- 在插入键值对后,如果
HashMap
的元素个数超过了负载因子(loadFactor
)和数组长度(capacity
)的乘积(即size > loadFactor * capacity
),就会触发扩容操作。扩容操作会创建一个新的、更大的数组(通常是原来数组长度的 2 倍),然后将旧数组中的所有元素重新哈希并迁移到新数组中。这是一个比较复杂和耗时的操作,因为需要重新计算每个元素在新数组中的位置。例如,在HashMap
的put
方法中,会有代码来检查是否需要扩容:
if (++size > threshold) resize();
- 其中
size
是当前HashMap
中元素的个数,threshold
是扩容的阈值,等于loadFactor * capacity
。resize
方法负责实际的扩容和元素迁移工作。
- 在插入键值对后,如果
7,拉链法和链地址法有什么区别?
概念澄清
- 在哈希表相关的知识中,你可能有些误解。实际上 “拉链法” 和 “链地址法” 是同一种处理哈希冲突的方法。它主要是指在哈希表中,当多个键值对经过哈希计算后得到相同的哈希地址(即发生哈希冲突)时,将这些冲突的键值对通过链表(在 Java 中
HashMap
使用的是链表或红黑树)连接起来存储在该哈希地址对应的位置。
- 在哈希表相关的知识中,你可能有些误解。实际上 “拉链法” 和 “链地址法” 是同一种处理哈希冲突的方法。它主要是指在哈希表中,当多个键值对经过哈希计算后得到相同的哈希地址(即发生哈希冲突)时,将这些冲突的键值对通过链表(在 Java 中
工作原理
- 哈希计算
- 首先,对要插入哈希表的键(
key
)进行哈希计算,得到一个哈希值。例如,对于一个简单的哈希函数hash(key) = key % table.length
(这里假设table
是哈希表对应的数组),如果有键值对(1, "value1")
和(11, "value2")
,并且哈希表的长度为 10,那么这两个键的哈希值都是 1(因为1 % 10 = 1
,11 % 10 = 1
)。
- 首先,对要插入哈希表的键(
- 冲突处理
- 当发生哈希冲突(如上述两个键值对的情况)时,在拉链法(链地址法)中,会在哈希表中该哈希值对应的位置(例如数组索引为 1 的位置)构建一个链表。将先插入的键值对(如
(1, "value1")
)放在链表头部,当插入后一个冲突的键值对(如(11, "value2")
)时,将其添加到链表中,通常是添加到头部或者根据一定顺序添加到尾部等(在HashMap
中是添加到链表头部)。这样,在查找某个键值对时,首先通过哈希计算找到对应的链表,然后在链表中逐个比较键来找到目标键值对。
- 当发生哈希冲突(如上述两个键值对的情况)时,在拉链法(链地址法)中,会在哈希表中该哈希值对应的位置(例如数组索引为 1 的位置)构建一个链表。将先插入的键值对(如
- 哈希计算
示例代码(简单模拟拉链法)
- 以下是一个简单的使用拉链法处理哈希冲突的代码示例,这里模拟一个简单的哈希表结构:
import java.util.LinkedList; class MyHashTable { private LinkedList<KeyValuePair>[] table; private int size; public MyHashTable(int capacity) { table = new LinkedList[capacity]; for (int i = 0; i < capacity; i++) { table[i] = new LinkedList<>(); } } public void put(int key, String value) { int hash = key % table.length; LinkedList<KeyValuePair> list = table[hash]; for (KeyValuePair pair : list) { if (pair.key == key) { // 如果键已存在,更新值 pair.value = value; return; } } // 如果键不存在,添加新的键值对 list.add(new KeyValuePair(key, value)); } public String get(int key) { int hash = key % table.length; LinkedList<KeyValuePair> list = table[hash]; for (KeyValuePair pair : list) { if (pair.key == key) { return pair.value; } } return null; } private class KeyValuePair { int key; String value; public KeyValuePair(int key, String value) { this.key = key; this.value = value; } } }
- 在这个示例中,
MyHashTable
类模拟了一个简单的哈希表。put
方法用于插入键值对,当发生哈希冲突时,通过LinkedList
将冲突的键值对连接起来存储在对应的位置。get
方法用于根据键查找值,需要遍历相应位置的链表来查找目标键值对。
8,Java怎么实现线程安全?
使用 synchronized 关键字
方法级别同步
- 可以在方法声明中添加
synchronized
关键字,这样在同一时刻,只有一个线程可以访问这个方法。例如:
class SynchronizedMethodExample { private int count = 0; public synchronized void increment() { count++; } public int getCount() { return count; } }
- 在这个
SynchronizedMethodExample
类中,increment
方法被synchronized
修饰。当一个线程进入increment
方法时,会获取对象级别的锁,其他线程如果也想调用这个方法,就必须等待锁的释放。这种方式简单直接,适用于对整个方法进行同步的情况。
- 可以在方法声明中添加
代码块级别同步
- 除了方法级别同步,还可以使用
synchronized
关键字修饰代码块。这样可以更灵活地控制同步范围,只对需要同步的部分代码进行锁定。例如:
class SynchronizedBlockExample { private Object lock = new Object(); private int count = 0; public void increment() { synchronized (lock) { count++; } } public int getCount() { return count; } }
- 在
SynchronizedBlockExample
类中,increment
方法中的代码块被synchronized
修饰,并且指定了一个lock
对象作为锁。这种方式可以在多个方法中使用同一个锁对象来实现同步,而不是像方法级别同步那样锁定整个方法。
- 除了方法级别同步,还可以使用
使用 ReentrantLock 类
基本用法
ReentrantLock
是java.util.concurrent.locks
包中的一个可重入锁。它提供了比synchronized
关键字更灵活的锁机制。例如:
import java.util.concurrent.locks.ReentrantLock; class ReentrantLockExample { private ReentrantLock lock = new ReentrantLock(); private int count = 0; public void increment() { lock.lock(); try { count++; } finally { lock.unlock(); } } public int getCount() { return count; } }
- 在
ReentrantLockExample
类中,increment
方法通过lock.lock()
获取锁,然后在try - finally
块中执行需要同步的代码,并在finally
块中通过lock.unlock()
释放锁。这样可以确保在任何情况下锁都能被正确释放,避免死锁情况。
与 synchronized 的区别
ReentrantLock
提供了一些synchronized
所没有的功能,如可中断的锁获取(lock.lockInterruptibly()
)、公平锁(在构造函数中指定true
来实现公平锁,公平锁会按照线程请求锁的顺序来分配锁)等。不过,ReentrantLock
的使用相对synchronized
来说稍微复杂一些,因为需要手动获取和释放锁。
使用线程安全的集合类
示例一:Vector
Vector
是线程安全的集合类,它的实现方式是在方法上使用synchronized
关键字。例如,Vector<Integer>
可以在多线程环境下安全地进行添加、删除和获取元素等操作。
import java.util.Vector; class VectorThreadSafety { public static void main(String[] args) { Vector<Integer> vector = new Vector<>(); // 在多个线程中使用vector // 线程安全的添加元素操作 vector.add(1); } }
示例二:ConcurrentHashMap
ConcurrentHashMap
是高性能的线程安全的哈希表。它采用了更精细的锁机制,允许多个线程并发地对哈希表进行部分操作。例如,在多线程环境下可以同时进行put
和get
操作而不会出现数据不一致的问题。
import java.util.concurrent.ConcurrentHashMap; class ConcurrentHashMapExample { public static void main(String[] args) { ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>(); // 多个线程可以同时访问这个map // 线程安全的添加键值对操作 map.put("key", 1); } }
使用原子类(Atomic Classes)
基本介绍
- 原子类提供了原子操作,这些操作在多线程环境下是线程安全的。例如,
AtomicInteger
可以用来在多线程环境下安全地进行整数的自增、自减等操作。原子类是通过利用底层的硬件支持(如 CPU 的 CAS - Compare and Swap 指令)来实现原子操作的。
- 原子类提供了原子操作,这些操作在多线程环境下是线程安全的。例如,
示例代码
import java.util.concurrent.atomic.AtomicInteger; class AtomicIntegerExample { public static void main(String[] args) { AtomicInteger atomicInteger = new AtomicInteger(0); // 在多个线程中自增原子整数 // 线程安全的自增操作 atomicInteger.incrementAndGet(); } }
9,Volatile 作用
保证可见性
可见性问题的产生
- 在多线程环境下,每个线程都有自己的工作内存,用于存储共享变量的副本。当一个线程修改了共享变量的值时,这个修改可能不会立即被其他线程看到。例如,有一个
int
类型的共享变量count
,一个线程对其进行修改,另一个线程可能因为还在使用自己工作内存中的旧值,而无法感知到这个修改。
- 在多线程环境下,每个线程都有自己的工作内存,用于存储共享变量的副本。当一个线程修改了共享变量的值时,这个修改可能不会立即被其他线程看到。例如,有一个
Volatile 如何解决可见性问题
- 当一个变量被声明为
volatile
时,它会保证对这个变量的修改会立即刷新到主内存中,并且其他线程每次使用这个变量时,都会从主内存中重新读取最新的值。例如:
class VolatileVisibilityExample { private volatile boolean flag = false; public void setFlag() { flag = true; } public void doSomething() { while (!flag) { // 等待flag变为true } System.out.println("Flag is now true."); } }
- 在这个示例中,
flag
变量被声明为volatile
。当setFlag
方法修改了flag
的值为true
时,doSomething
线程能够立即感知到这个变化,从而结束循环并打印出相应的消息。
- 当一个变量被声明为
禁止指令重排序
指令重排序的概念
- 为了提高程序的执行效率,在不影响单线程程序执行结果的前提下,编译器和处理器可能会对指令进行重新排序。例如,对于代码
int a = 1; int b = 2; int c = a + b;
,处理器可能会先执行int b = 2;
,再执行int a = 1;
,最后执行int c = a + b;
,只要最终的计算结果符合单线程语义即可。
- 为了提高程序的执行效率,在不影响单线程程序执行结果的前提下,编译器和处理器可能会对指令进行重新排序。例如,对于代码
Volatile 禁止指令重排序的场景和意义
- 在多线程环境下,指令重排序可能会导致一些意外的结果。当一个变量被声明为
volatile
时,在一定程度上可以禁止指令重排序。例如,在双重检查锁定(Double - Checked Locking)单例模式的正确实现中,volatile
就起到了关键作用。
class Singleton { private volatile static Singleton instance; public static Singleton getInstance() { if (instance == null) { synchronized (Singleton.class) { if (instance == null) { instance = new Singleton(); } } } return instance; } }
- 在这个单例模式的示例中,
instance
变量被声明为volatile
。如果没有volatile
,在instance = new Singleton();
这一行,对象的创建过程可能会被指令重排序。具体来说,对象的引用可能会在对象还没有完全初始化之前就被赋值给instance
,这会导致其他线程获取到一个未完全初始化的对象。使用volatile
可以防止这种情况的发生,确保对象在完全初始化后才被其他线程可见。
- 在多线程环境下,指令重排序可能会导致一些意外的结果。当一个变量被声明为
不保证原子性
- 原子性的概念
- 原子性是指一个操作或者一组操作是不可分割的,要么全部执行成功,要么全部不执行。例如,对于一个自增操作
count++
,它实际上包含了读取count
的值、加 1、再将新值写回count
这三个步骤,在多线程环境下,如果没有额外的同步措施,这三个步骤可能会被其他线程打断,从而导致数据不一致。
- 原子性是指一个操作或者一组操作是不可分割的,要么全部执行成功,要么全部不执行。例如,对于一个自增操作
- Volatile 和原子性的关系
- 虽然
volatile
可以保证可见性和禁止指令重排序,但它不保证原子性。例如,如果多个线程同时对一个volatile
的int
变量进行自增操作,仍然可能会出现数据不一致的情况。对于需要保证原子性的操作,应该使用原子类(如AtomicInteger
)或者通过同步机制(如synchronized
或ReentrantLock
)来实现。
- 虽然
- 原子性的概念
10,线程池的核心参数有哪些?
核心线程数(corePoolSize)
- 定义
- 核心线程数是线程池中的一个关键参数,它表示线程池中会长期保留的线程数量。这些线程在没有任务执行时也不会被销毁,它们会一直等待新的任务到来。例如,一个处理网络请求的线程池,将核心线程数设置为 10,那么在系统运行过程中,即使暂时没有网络请求,也会有 10 个线程处于等待状态,随时准备处理新的请求。
- 作用和影响
- 合理设置核心线程数对于系统的性能和资源利用至关重要。如果核心线程数设置得过小,当有大量任务同时到达时,可能会导致任务排队等待时间过长,影响系统的响应速度。相反,如果设置得过大,会占用过多的系统资源(如 CPU、内存等),即使在任务较少时,这些线程也会一直占用资源,导致资源浪费。
- 定义
最大线程数(maximumPoolSize)
- 定义
- 最大线程数是线程池允许创建的线程的最大数量。当任务队列已满,并且当前线程数小于最大线程数时,线程池会创建新的线程来处理任务。例如,核心线程数为 5,最大线程数为 10,当任务队列已满且已经有 5 个核心线程在执行任务时,线程池会继续创建新的线程,最多创建到 10 个线程来处理任务。
- 与核心线程数的关系
- 最大线程数必须大于等于核心线程数。它为线程池在高负载情况下提供了额外的处理能力,以应对突发的大量任务。当任务量逐渐减少时,超过核心线程数的线程会在空闲一定时间后被销毁。
- 定义
线程存活时间(keepAliveTime)
- 定义
- 线程存活时间是指当线程池中的线程数量超过核心线程数时,多余的线程在空闲状态下能够存活的最长时间。例如,将存活时间设置为 60 秒,那么当一个非核心线程在 60 秒内没有任务执行时,这个线程就会被销毁。
- 作用和应用场景
- 这个参数可以有效地控制线程池的资源占用。在任务负载动态变化的场景中,当任务量减少时,通过设置合理的存活时间,可以让多余的线程及时退出,释放系统资源。例如,在一个定时任务处理的线程池中,白天任务较多,可能会创建较多的线程,到了晚上任务减少,非核心线程在空闲一段时间后就会被销毁,避免了资源的浪费。
- 定义
任务队列(workQueue)
- 类型和特点
- 任务队列用于存储等待执行的任务。常见的任务队列有
ArrayBlockingQueue
(基于数组的有界阻塞队列)、LinkedBlockingQueue
(基于链表的阻塞队列,可以是有界或无界)、SynchronousQueue
(一种不存储元素的阻塞队列)等。 ArrayBlockingQueue
有固定的容量,当队列已满时,新的任务会根据线程池的策略(如创建新线程或拒绝任务)进行处理。LinkedBlockingQueue
如果没有指定容量,则默认是无界的,这可能会导致任务不断添加,内存占用过多的情况。SynchronousQueue
在放入一个任务时,必须有一个线程来接收这个任务,否则放入任务的操作会被阻塞。
- 任务队列用于存储等待执行的任务。常见的任务队列有
- 对线程池的影响
- 任务队列的大小和类型会影响线程池的行为。如果任务队列容量较小,当任务较多时,可能会更快地触发线程池创建新线程或者拒绝任务。而较大容量的任务队列可以缓冲更多的任务,但也可能会导致任务等待时间过长,尤其是在处理时间敏感的任务时,需要谨慎考虑任务队列的大小。
- 类型和特点
线程工厂(threadFactory)
功能和作用
- 线程工厂用于创建线程池中的线程。通过自定义线程工厂,可以对创建的线程进行一些定制化操作,如设置线程名称、设置线程的优先级、设置线程为守护线程等。例如,可以创建一个线程工厂,使得所有由该工厂创建的线程都有一个特定的命名规则,方便在调试和监控时识别不同的线程。
实现方式
- 实现
ThreadFactory
接口,并重写newThread
方法来定义线程的创建过程。例如:
import java.util.concurrent.ThreadFactory; class MyThreadFactory implements ThreadFactory { private int counter = 0; @Override public Thread newThread(Runnable r) { Thread thread = new Thread(r); thread.setName("MyThread - " + counter++); return thread; } }
- 在这个示例中,自定义的线程工厂
MyThreadFactory
会为每个创建的线程设置一个以 “MyThread -” 开头的名称,并且名称后面跟着一个递增的数字。
- 实现
拒绝策略(RejectedExecutionHandler)
- 定义和场景
- 当线程池的任务队列已满,并且线程池中的线程数量已经达到最大线程数时,如果还有新的任务提交进来,就会触发拒绝策略。例如,在一个高并发的 Web 服务器中,线程池已经满负荷运行,无法再处理新的请求时,就需要通过拒绝策略来处理这些无法接收的请求。
- 常见的拒绝策略类型
- AbortPolicy(默认策略):直接抛出
RejectedExecutionException
异常,阻止系统继续接收新的任务。这种策略适用于对任务丢失比较敏感的场景,通过抛出异常可以让调用者及时知道任务无法被处理。 - CallerRunsPolicy:由调用
execute
或submit
方法的线程来执行被拒绝的任务。这可以在一定程度上降低任务的提交速度,因为调用者线程需要停下来执行被拒绝的任务,同时也避免了任务丢失。 - DiscardPolicy:直接丢弃被拒绝的任务,不会抛出异常。这种策略适用于对任务丢失不敏感的场景,例如一些日志记录任务,丢失部分任务可能不会对系统的主要功能产生严重影响。
- DiscardOldestPolicy:丢弃任务队列中最旧的任务,然后将新的任务添加到队列中。这在一些对实时性要求较高的场景中可能会有用,通过丢弃旧任务来为新任务腾出空间。
- AbortPolicy(默认策略):直接抛出
- 定义和场景
11,你在项目中是如何使用线程池的?
网络请求处理场景
背景和需求
- 在开发一个网络应用程序时,需要频繁地发送 HTTP 请求来获取数据,如从多个 API 端点获取用户信息、产品数据等。如果每次请求都创建一个新的线程来处理,会导致线程创建和销毁的开销过大,并且可能会创建过多的线程耗尽系统资源。
线程池的配置和使用
- 首先,根据服务器的硬件资源和预计的并发请求数量来配置线程池。例如,设置核心线程数为 10,最大线程数为 20,线程存活时间为 60 秒,使用
LinkedBlockingQueue
作为任务队列。
import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.TimeUnit; public class NetworkRequestHandler { private static final ExecutorService threadPool = new ThreadPoolExecutor( 10, 20, 60, TimeUnit.SECONDS, new LinkedBlockingQueue<>()); public static void sendRequest(String url) { threadPool.execute(() -> { // 实际的网络请求代码,例如使用HttpURLConnection或OkHttp等库 System.out.println("Sending request to " + url + " in thread " + Thread.currentThread().getName()); }); } }
- 在这个示例中,
sendRequest
方法用于发送网络请求。每次调用这个方法时,会将请求任务提交给线程池,线程池中的线程会负责执行网络请求。这样可以有效地控制并发线程数量,提高系统的性能和资源利用率。
- 首先,根据服务器的硬件资源和预计的并发请求数量来配置线程池。例如,设置核心线程数为 10,最大线程数为 20,线程存活时间为 60 秒,使用
文件处理场景
背景和需求
- 假设需要处理大量的文件,如对文件进行读取、解析、加密等操作。这些文件处理任务可能会比较耗时,并且文件数量较多,需要通过多线程来提高处理效率。
线程池的配置和使用
- 根据文件处理任务的特点和系统资源,设置合适的线程池参数。例如,对于一个本地文件处理系统,设置核心线程数为 5,最大线程数为 10,存活时间为 120 秒,使用
ArrayBlockingQueue
作为任务队列,容量为 100(假设最多可以缓冲 100 个文件处理任务)。
import java.io.File; import java.util.concurrent.ArrayBlockingQueue; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.TimeUnit; public class FileProcessor { private static final ExecutorService threadPool = new ThreadPoolExecutor( 5, 10, 120, TimeUnit.SECONDS, new ArrayBlockingQueue<>(100)); public static void processFile(File file) { threadPool.execute(() -> { // 文件处理代码,如读取文件内容、解析文件格式等 System.out.println("Processing file " + file.getName() + " in thread " + Thread.currentThread().getName()); }); } }
- 当有文件需要处理时,调用
processFile
方法将文件处理任务提交给线程池。线程池会按照配置的参数来分配线程执行任务,提高文件处理的速度,同时避免创建过多的线程导致系统资源紧张。
- 根据文件处理任务的特点和系统资源,设置合适的线程池参数。例如,对于一个本地文件处理系统,设置核心线程数为 5,最大线程数为 10,存活时间为 120 秒,使用
定时任务场景
背景和需求
- 在一个系统中,需要定期执行一些任务,如清理临时文件、更新缓存数据、发送定时提醒等。这些任务需要在特定的时间间隔或者固定的时间点执行,并且可能会有多个定时任务同时存在。
线程池的配置和使用
- 可以创建一个线程池专门用于处理定时任务。例如,设置核心线程数为 3,最大线程数为 5,存活时间为 300 秒,使用
SynchronousQueue
作为任务队列(因为定时任务通常不需要大量的任务缓冲)。
import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.SynchronousQueue; import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.TimeUnit; public class ScheduledTaskRunner { private static final ExecutorService threadPool = new ThreadPoolExecutor( 3, 5, 300, TimeUnit.SECONDS, new SynchronousQueue<>()); public static void scheduleTask(Runnable task, long delay, TimeUnit unit) { // 这里可以使用ScheduledExecutorService来实现定时任务调度,将任务提交给线程池 // 假设已经有一个简单的定时任务调度逻辑,将任务提交给线程池执行 threadPool.execute(task); } }
- 当需要添加一个定时任务时,通过
scheduleTask
方法将任务和执行时间参数(延迟时间和时间单位)一起提交给线程池。线程池会在合适的时间分配线程来执行定时任务,保证定时任务的可靠执行。
- 可以创建一个线程池专门用于处理定时任务。例如,设置核心线程数为 3,最大线程数为 5,存活时间为 300 秒,使用
12,线程池有哪些拒绝策略?它们的执行流程是怎样的?
AbortPolicy(默认策略)
执行流程
- 当线程池的任务队列已满,并且线程池中的线程数量已经达到最大线程数时,如果有新的任务提交进来,就会直接抛出
RejectedExecutionException
异常。 - 例如,在一个使用
ThreadPoolExecutor
的场景中,当任务被拒绝时,会像下面这样抛出异常:
import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.TimeUnit; public class AbortPolicyExample { public static void main(String[] args) { // 创建一个线程池,设置核心线程数、最大线程数等参数 ExecutorService executorService = new ThreadPoolExecutor( 2, 4, 60, TimeUnit.SECONDS, new java.util.concurrent.LinkedBlockingQueue<>(3)); // 提交任务,当任务数量超过队列容量和最大线程数处理能力时,会抛出异常 for (int i = 0; i < 10; i++) { executorService.execute(() -> { System.out.println("Task is running in thread: " + Thread.currentThread().getName()); }); } } }
- 在这个示例中,线程池的核心线程数为 2,最大线程数为 4,任务队列容量为 3。当提交 10 个任务时,在任务队列已满且线程池已经启动了 4 个线程后,再提交新任务就会抛出
RejectedExecutionException
异常。
- 当线程池的任务队列已满,并且线程池中的线程数量已经达到最大线程数时,如果有新的任务提交进来,就会直接抛出
CallerRunsPolicy
执行流程
- 当任务被拒绝时,由调用
execute
或submit
方法的线程来执行被拒绝的任务。 - 例如,假设有一个主线程在提交任务给线程池,当线程池无法接收新任务时,主线程会自己执行这个任务。以下是一个简单的示例:
import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.TimeUnit; public class CallerRunsPolicyExample { public static void main(String[] args) { // 创建线程池 ExecutorService executorService = new ThreadPoolExecutor( 2, 4, 60, TimeUnit.SECONDS, new java.util.concurrent.LinkedBlockingQueue<>(3)); // 设置拒绝策略为CallerRunsPolicy ((ThreadPoolExecutor)executorService).setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); // 提交任务 for (int i = 0; i < 10; i++) { try { executorService.execute(() -> { System.out.println("Task is running in thread: " + Thread.currentThread().getName()); }); } catch (Exception e) { System.out.println("Exception occurred, now running task in main thread."); // 在主线程中执行被拒绝的任务 new Thread(() -> { System.out.println("Task is running in thread: " + Thread.currentThread().getName()); }).start(); } } } }
- 在这个示例中,当任务被拒绝时,会在
catch
块中通过在主线程中创建新的线程来执行被拒绝的任务。这样可以在一定程度上减轻线程池的压力,同时避免任务丢失。
- 当任务被拒绝时,由调用
DiscardPolicy
执行流程
- 当任务被拒绝时,直接丢弃被拒绝的任务,不会抛出异常。
- 例如,在一个对任务丢失不太敏感的场景中,如日志记录或者一些数据统计任务,偶尔丢失部分任务是可以接受的。以下是一个示例:
import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.TimeUnit; public class DiscardPolicyExample { public static void main(String[] args) { // 创建线程池 ExecutorService executorService = new ThreadPoolExecutor( 2, 4, 60, TimeUnit.SECONDS, new java.util.concurrent.LinkedBlockingQueue<>(3)); // 设置拒绝策略为DiscardPolicy ((ThreadPoolExecutor)executorService).setRejectedExecutionHandler(new ThreadPoolExecutor.DiscardPolicy()); // 提交任务 for (int i = 0; i < 10; i++) { executorService.execute(() -> { System.out.println("Task is running in thread: " + Thread.currentThread().getName()); }); } } }
- 在这个示例中,当任务被拒绝时,这些任务会被直接丢弃,不会有任何提示或异常抛出,程序会继续正常运行,只是被拒绝的任务不会被执行。
DiscardOldestPolicy
执行流程
- 当任务被拒绝时,丢弃任务队列中最旧的任务,然后将新的任务添加到队列中。
- 例如,在一个对实时性要求较高的系统中,可能更希望处理最新的任务。以下是一个示例:
import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.TimeUnit; public class DiscardOldestPolicyExample { public static void main(String[] args) { // 创建线程池 ExecutorService executorService = new ThreadPoolExecutor( 2, 4, 60, TimeUnit.SECONDS, new java.util.concurrent.LinkedBlockingQueue<>(3)); // 设置拒绝策略为DiscardOldestPolicy ((ThreadPoolExecutor)executorService).setRejectedExecutionHandler(new ThreadPoolExecutor.DiscardOldestPolicy()); // 提交任务 for (int i = 0; i < 10; i++) { executorService.execute(() -> { System.out.println("Task is running in thread: " + Thread.currentThread().getName()); }); } } }
- 在这个示例中,当任务被拒绝时,会先丢弃任务队列中最早提交的任务,然后将新的任务放入队列中,等待线程池中的线程来处理。这样可以保证线程池尽量处理最新的任务,提高系统对新任务的响应能力
13,你是如何监控线程池的运行情况的?
使用线程池提供的方法进行监控
获取线程池状态
可以通过
ThreadPoolExecutor
的getPoolSize()
方法获取当前线程池中的线程数量,包括核心线程和临时创建的线程。例如:import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.ThreadPoolExecutor; public class ThreadPoolMonitor { public static void main(String[] args) { ExecutorService executorService = Executors.newFixedThreadPool(5); ThreadPoolExecutor threadPoolExecutor = (ThreadPoolExecutor) executorService; System.out.println("Current pool size: " + threadPoolExecutor.getPoolSize()); } }
- 在这个示例中,创建了一个固定大小为 5 的线程池,通过
getPoolSize
方法获取当前线程池中的线程数量并打印出来。
- 在这个示例中,创建了一个固定大小为 5 的线程池,通过
获取活跃线程数
- 使用
getActiveCount()
方法可以获取当前正在执行任务的线程数量。例如:
import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.ThreadPoolExecutor; public class ThreadPoolMonitor { public static void main(String[] args) { ExecutorService executorService = Executors.newFixedThreadPool(5); ThreadPoolExecutor threadPoolExecutor = (ThreadPoolExecutor) executorService; // 提交一个任务,使一个线程处于活跃状态 executorService.execute(() -> { try { Thread.sleep(5000); } catch (InterruptedException e) { e.printStackTrace(); } }); System.out.println("Current active thread count: " + threadPoolExecutor.getActiveCount()); } }
- 这里提交了一个任务让线程睡眠 5 秒,通过
getActiveCount
方法可以获取当前活跃的线程数量,在这个例子中应该是 1。
- 使用
获取任务队列大小
- 对于
ThreadPoolExecutor
,可以通过getQueue().size()
来获取任务队列中的任务数量。例如:
import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.ThreadPoolExecutor; public class ThreadPoolMonitor { public static void main(String[] args) { ExecutorService executorService = new ThreadPoolExecutor( 3, 5, 60, TimeUnit.SECONDS, new LinkedBlockingQueue<>(10)); // 提交多个任务,使任务队列中有任务 for (int i = 0; i < 5; i++) { executorService.execute(() -> { try { Thread.sleep(5000); } catch (InterruptedException e) { e.printStackTrace(); } }); } ThreadPoolExecutor threadPoolExecutor = (ThreadPoolExecutor) executorService; System.out.println("Current queue size: " + threadPoolExecutor.getQueue().size()); } }
- 在这个示例中,创建了一个线程池,提交了 5 个任务,通过
getQueue().size()
获取任务队列中的任务数量,这里应该是 2(因为线程池核心线程数是 3,会先处理 3 个任务,剩下 2 个任务在队列中)。
- 对于
使用 JMX(Java Management Extensions)进行监控
注册 MBean(管理构件)
- 可以将线程池包装成一个 MBean,通过 JMX 进行远程监控。首先需要创建一个实现
javax.management.StandardMBean
接口的类来包装线程池。例如:
import javax.management.MBeanServer; import javax.management.ObjectName; import java.lang.management.ManagementFactory; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.ThreadPoolExecutor; public class ThreadPoolMBeanExample { public static void main(String[] args) throws Exception { ExecutorService executorService = Executors.newFixedThreadPool(5); ThreadPoolExecutor threadPoolExecutor = (ThreadPoolExecutor) executorService; // 创建一个MBeanServer实例 MBeanServer mBeanServer = ManagementFactory.getPlatformMBeanServer(); // 定义一个ObjectName,用于在JMX中唯一标识这个MBean ObjectName objectName = new ObjectName("com.example:type=ThreadPoolMonitor"); // 将线程池包装成一个MBean并注册到MBeanServer ThreadPoolMonitorMBean mbean = new ThreadPoolMonitorMBean(threadPoolExecutor); mBeanServer.registerMBean(mbean, objectName); } } class ThreadPoolMonitorMBean implements ThreadPoolMonitorMBeanMXBean { private ThreadPoolExecutor executor; public ThreadPoolMonitorMBean(ThreadPoolExecutor executor) { this.executor = executor; } @Override public int getPoolSize() { return executor.getPoolSize(); } @Override public int getActiveCount() { return executor.getActiveCount(); } @Override public int getQueueSize() { return executor.getQueue().size(); } } interface ThreadPoolMonitorMBeanMXBean { int getPoolSize(); int getActiveCount(); int getQueueSize(); }
- 在这个示例中,创建了一个
ThreadPoolMonitorMBean
类来包装线程池,并实现了ThreadPoolMonitorMBeanMXBean
接口,定义了获取线程池大小、活跃线程数和任务队列大小的方法。通过MBeanServer
将这个 MBean 注册后,可以使用 JMX 客户端进行远程监控。
- 可以将线程池包装成一个 MBean,通过 JMX 进行远程监控。首先需要创建一个实现
使用 JMX 客户端进行监控
- 可以使用 JDK 自带的
jconsole
工具或者其他第三方的 JMX 客户端工具来连接到运行中的 Java 程序,查看注册的 MBean 的属性,从而监控线程池的状态。在jconsole
中,可以在 “MBeans” 选项卡中找到注册的com.example:type=ThreadPoolMonitor
这个 MBean,然后查看其属性,如poolSize
、activeCount
和queueSize
等。
- 可以使用 JDK 自带的
14,TCP 和UDP有什么区别?
- 连接方式
- TCP(Transmission Control Protocol)
- TCP 是面向连接的协议。这意味着在数据传输之前,通信双方需要先建立连接。就好比打电话,在通话之前需要先拨号建立连接,双方确认连接成功后才能开始交流信息。例如,在一个 TCP 客户端和服务器通信的场景中,客户端通过发送 SYN(同步)数据包来请求建立连接,服务器收到后回复 SYN - ACK(同步 - 确认)数据包,客户端再发送 ACK(确认)数据包完成三次握手建立连接。只有连接建立好之后,双方才能开始传输数据。
- UDP(User Datagram Protocol)
- UDP 是无连接的协议。它不需要像 TCP 那样在通信前先建立连接,就像寄信一样,发送方直接把信(数据)发送出去,不关心接收方是否准备好了接收。例如,在一个 UDP 的网络应用中,发送方可以随时将数据打包成 UDP 数据包发送出去,不需要和接收方进行任何连接建立的步骤。
- TCP(Transmission Control Protocol)
- 可靠性
- TCP
- TCP 提供可靠的数据传输服务。它通过序列号、确认应答、重传机制等来保证数据能够准确无误地从发送端传送到接收端。例如,发送方发送的数据会被分成一个个的数据包,每个数据包都有一个序列号。接收方收到数据包后会发送确认应答给发送方,如果发送方在一定时间内没有收到某个数据包的确认应答,就会认为这个数据包丢失了,然后重新发送这个数据包。这种机制确保了数据的完整性和准确性。
- UDP
- UDP 不提供可靠性保证。它只是尽力将数据发送出去,但不保证数据一定能到达目的地,也不保证数据的顺序和完整性。例如,在网络状况不佳的情况下,UDP 数据包可能会丢失、重复或者乱序到达接收端,并且 UDP 本身不会去处理这些问题,而是由应用层协议(如果有的话)来处理。
- TCP
- 传输效率
- TCP
- 由于 TCP 需要建立连接、维护连接状态、进行可靠传输的各种机制(如确认应答、重传等),这些操作会带来一定的开销。因此,TCP 的传输效率相对较低。不过,这种较低的效率换来了数据传输的可靠性,适合对数据准确性要求高的应用场景。
- UDP
- UDP 没有复杂的连接建立和维护过程,也没有像 TCP 那样的可靠性机制开销,所以 UDP 的传输效率相对较高。它可以快速地将数据发送出去,适合对实时性要求高,但对少量数据丢失不敏感的应用场景,如实时视频流、在线游戏等。
- TCP
- 数据顺序
- TCP
- TCP 保证数据按照发送的顺序到达接收端。因为它通过序列号来对数据包进行排序,接收方会按照序列号的顺序将数据包重组为原始数据。这样可以确保应用层接收到的数据顺序是正确的。
- UDP
- UDP 不保证数据的顺序。由于 UDP 没有像 TCP 那样的顺序控制机制,数据包可能会因为网络中的各种因素(如路由不同、网络拥塞等)而乱序到达接收端。如果应用程序需要数据按照一定顺序处理,就需要自己在应用层实现排序功能。
- TCP
- 应用场景
- TCP
- 适用于对数据准确性和完整性要求极高的场景,如文件传输(FTP)、电子邮件(SMTP、POP3 等)、网页浏览(HTTP)等。在这些场景中,数据不能有任何丢失或错误,所以需要 TCP 的可靠传输机制来保证数据的正确传输。
- UDP
- 适合于对实时性要求很高,而对少量数据丢失可以容忍的场景。例如,实时视频会议、在线游戏中的玩家位置更新等。在这些场景中,稍微的数据丢失不会对用户体验产生太大影响,但对实时性要求很高,UDP 的高效传输特性可以更好地满足需求。
- TCP
15TCP 为什么可靠?
CP 可靠主要基于以下多种机制:
连接管理机制
- 三次握手建立连接:在数据传输之前,客户端和服务器通过三次握手来建立连接。客户端发送 SYN 报文段,服务器收到后回复 SYN-ACK 报文段,客户端再发送 ACK 报文段完成连接建立。这确保了双方都准备好进行通信,避免了无效连接和错误数据的传输23。
- 四次挥手释放连接:在数据传输完毕后,双方通过四次挥手来优雅地关闭连接。客户端发送 FIN 报文段,服务器收到后回复 ACK 报文段,若服务器也没有数据要发送了,则发送 FIN 报文段,客户端收到后回复 ACK 报文段,完成连接释放。这保证了数据的有序传输和资源的正确释放3。
数据编号与确认应答机制
- 序列号:TCP 为每个字节的数据都分配了一个序列号,在发送数据时,按照顺序给每个数据段编号。接收方可以根据序列号判断数据的顺序和完整性,确保数据按序接收,也便于识别重复的数据并丢弃3。
- 确认应答:接收方收到数据后,会向发送方发送 ACK 报文段进行确认应答,告知发送方已正确接收哪些数据。发送方只有收到确认应答后,才会继续发送下一部分数据,从而保证了数据的可靠传输3。
超时重传机制
发送方在发送数据后会启动一个定时器,如果在预定时间内没有收到接收方的确认应答,就会认为数据丢失或 ACK 报文丢失,进而重新发送该数据段。这确保了即使在网络出现丢包的情况下,数据也能最终被正确传输到接收方123。
校验和机制
TCP 在发送端会对每个数据段计算校验和,并在接收端进行校验。如果接收方计算出的校验和与发送方不一致,说明数据在传输过程中出现了错误,接收方会丢弃该数据段,并要求发送方重新发送,从而保证了数据的完整性和准确性123。
流量控制机制
TCP 连接的每一方都有固定大小的缓冲空间,接收端会将自己可以接受的缓冲区大小放入 TCP 首部中的 “窗口大小” 字段,通过 ACK 段通知发送端。发送端根据接收端的窗口大小来调整发送速度,避免发送过快导致接收方缓冲区溢出而丢包,保证了数据的可靠接收13。
拥塞控制机制
TCP 通过慢启动、拥塞避免、快速重传和快速恢复等算法来实现拥塞控制。在网络状况良好时,适当增加发送窗口大小以提高传输效率;在网络出现拥塞时,及时减小发送窗口大小,降低发送速率,避免网络拥塞进一步加剧,从而保证了网络的稳定性和数据传输的可靠性。
16,http1.0 2.0 3.0
HTTP/1.0
基本特点
- 请求 - 响应模式:HTTP/1.0 采用简单的请求 - 响应模式。客户端发送一个请求,服务器返回一个响应,然后连接就会关闭。例如,当浏览器请求一个网页时,它发送一个 HTTP 请求给服务器,服务器处理请求并返回网页内容,之后连接就终止了。
- 无状态性:每个请求都是独立的,服务器不会记住之前的请求信息。这意味着如果一个网页包含多个资源(如图片、脚本等),浏览器需要为每个资源单独建立连接并发送请求。
性能问题
连接建立开销大:由于每次请求都要建立和关闭连接,对于包含多个资源的网页,会产生大量的连接建立和关闭开销。比如,一个网页有 10 张图片,浏览器需要建立 10 次连接来获取这些图片,这会导致较长的加载时间。
- 队首阻塞(Head - of - Line Blocking):在一个连接中,如果一个请求没有及时得到响应,后续的请求就会被阻塞。例如,在一个连接中发送了三个请求 A、B、C,若请求 A 因为某种原因(如服务器处理慢)没有得到响应,请求 B 和 C 即使已经准备好被服务器处理,也必须等待请求 A 完成,这会影响性能。
HTTP/2.0
- 性能优化
- 二进制分帧层(Binary Framing Layer):HTTP/2.0 引入了二进制分帧层,将 HTTP 消息分解为更小的帧(Frame)。这些帧可以交错发送,而不是像 HTTP/1.0 那样按照顺序发送完整的请求和响应。例如,一个请求可以被分成多个帧,与其他请求的帧在同一个连接中交错发送,这样可以更高效地利用网络带宽。
- 多路复用(Multiplexing):允许在一个连接上同时发送多个请求和接收多个响应,解决了 HTTP/1.0 中的队首阻塞问题。比如,浏览器可以同时发送多个资源(如图片、脚本等)的请求在同一个连接上,服务器也可以同时返回这些资源的响应,这些请求和响应的帧在连接中独立传输,互不干扰。
- 头部压缩(Header Compression):使用 HPACK 算法对 HTTP 头部进行压缩。因为 HTTP 头部信息在每次请求和响应中都存在,而且可能包含一些重复的字段,通过压缩头部可以减少网络传输的数据量。例如,对于一些经常出现的头部字段(如
User - Agent
、Accept
等),可以进行高效的压缩。
- 连接管理改进
- HTTP/2.0 采用了基于帧的流(Stream)概念,一个连接可以包含多个流,每个流可以独立地发送和接收数据。这使得服务器可以更灵活地处理来自客户端的多个请求,同时客户端也可以更好地控制请求的优先级。例如,对于一个网页,浏览器可以将关键资源(如 HTML 内容)的请求设置为高优先级,将不太关键的资源(如广告图片)的请求设置为低优先级。
- 性能优化
HTTP/3.0
- 解决传输层问题
- HTTP/3.0 主要是为了解决 HTTP/2.0 在传输层(TCP)上仍然存在的问题。由于 TCP 的一些特性(如队首阻塞在 TCP 层面仍然可能发生,特别是在丢包的情况下),HTTP/3.0 基于 UDP 协议来构建。例如,在网络出现丢包时,UDP 不像 TCP 那样等待重传丢失的数据包,而是可以继续传输其他数据,避免了因为一个数据包丢失而导致整个连接的阻塞。
- QUIC 协议(Quick UDP Internet Connections):它是 HTTP/3.0 的底层传输协议。QUIC 在 UDP 的基础上,增加了类似 TCP 的连接管理、可靠性保证等功能。它通过使用数据包编号、确认应答和重传机制等,来确保数据的可靠传输,同时避免了 TCP 的一些性能瓶颈。例如,QUIC 的连接建立比 TCP 更快,它采用了 0 - RTT(Round - Trip Time)的连接建立机制,在某些情况下可以更快地开始数据传输。
- 性能提升
- 由于 QUIC 协议的优化,HTTP/3.0 在移动网络和网络状况不佳的环境下,能够提供更好的性能。例如,在频繁切换网络(如从 Wi - Fi 切换到移动数据)的场景中,QUIC 可以更快地恢复连接并继续传输数据,减少了数据传输的中断时间,提升了用户体验。
- 解决传输层问题
17,操作系统的内存管理机制是怎样的?
- 内存空间划分
- 内核空间与用户空间
- 现代操作系统(如 Linux、Windows)通常将内存划分为内核空间和用户空间。内核空间是操作系统内核代码运行的区域,它用于执行系统级的任务,如进程管理、设备驱动管理、内存管理等。这部分空间对系统的稳定性和安全性至关重要,一般用户程序不能直接访问。例如,在 Linux 系统中,内核空间通常占内存空间的一部分(一般在 3GB - 4GB 以上的高位内存部分,在 32 位系统中这一划分有所不同)。
- 用户空间是供应用程序使用的内存区域。每个应用程序在运行时都有自己独立的用户空间,这样可以防止不同应用程序之间相互干扰。例如,当同时运行一个文字处理软件和一个浏览器时,它们在用户空间中各自拥有独立的内存区域,分别用于存储程序代码、数据等。
- 内核空间与用户空间
- 内存分配方式
- 连续分配方式
- 单一连续分配:这是最简单的内存分配方式,适用于单用户、单任务的操作系统。整个内存空间被划分为系统区和用户区,系统区用于存放操作系统,用户区则分配给一个用户程序使用。例如,早期的简单嵌入式系统可能会采用这种方式,优点是简单、易于实现,但内存利用率较低,因为用户程序大小不能超过用户区的空间,而且会造成内存浪费。
- 固定分区分配:将内存空间划分为若干个固定大小的分区,每个分区可以装入一个程序。分区大小可以相同也可以不同。例如,有内存空间为 100MB,划分为 5 个分区,分别为 20MB,当有一个 15MB 的程序需要运行时,可以装入其中一个 20MB 的分区。这种方式虽然可以同时运行多个程序,但仍然可能存在内部碎片(分区内未被利用的空间),而且分区大小如果设置不合理,可能会导致有些程序无法装入合适的分区。
- 动态分区分配:根据程序的实际需要动态地划分内存空间。当一个程序需要内存时,系统会从空闲内存区域中划分出一块合适大小的空间分配给它。例如,当一个新的程序需要 30MB 内存,系统会在空闲内存中寻找一块大于等于 30MB 的区域进行分配。这种方式可以提高内存利用率,但会产生外部碎片(内存中未被利用的小空闲区域),并且随着内存的分配和回收,内存空间可能会变得碎片化,影响后续程序的内存分配。
- 离散分配方式
- 分页存储管理:将内存空间和程序的逻辑地址空间划分为固定大小的页(Page)。例如,在一个 32 位的系统中,可能将内存和程序空间都划分为 4KB 大小的页。程序的逻辑地址由页号和页内偏移量组成。当程序运行时,通过页表(Page Table)来实现从逻辑页到物理页的映射。这样,程序的不同页可以离散地存储在内存的不同物理页框(Page Frame)中,提高了内存的利用率,减少了外部碎片。不过,页表的存在会占用一定的内存空间,而且每次访问内存都需要通过页表进行地址转换,会有一定的性能开销。
- 分段存储管理:按照程序的逻辑结构,将程序划分为多个段(Segment),如代码段、数据段、堆栈段等。每个段有自己的名称和长度,逻辑地址由段号和段内偏移量组成。在内存分配时,每个段可以独立地分配内存空间,段与段之间可以不连续。例如,一个程序的代码段可以存储在内存的一个区域,数据段存储在另一个区域。这种方式便于程序的模块化设计和共享,但同样可能会产生外部碎片,并且段表(用于存储段的信息和映射关系)的管理也会有一定的开销。
- 段页式存储管理:结合了分段和分页的优点。程序先按照逻辑结构分段,然后每个段再划分成页。在内存分配时,通过段表和页表两级映射来实现逻辑地址到物理地址的转换。这样既可以按照程序的逻辑结构进行管理,又能利用分页的优势减少碎片和方便内存管理,不过这种方式的地址转换过程更加复杂,系统开销也相对较大。
- 连续分配方式
- 内存回收机制
- 标记 - 清除(Mark - Sweep)算法
- 这是一种常见的垃圾回收算法,用于回收动态分配内存中不再使用的内存块。首先,从根对象(如全局变量、栈帧中的指针等)开始,标记所有可以通过引用链到达的对象(表示这些对象还在使用)。然后,遍历整个内存空间,清除那些没有被标记的对象所占用的内存。例如,在一个 Java 虚拟机的垃圾回收过程中,可能会使用类似的算法。不过,这种算法可能会产生内存碎片,因为清除后的空闲内存块可能是不连续的。
- 复制(Copying)算法
- 将内存空间划分为两个大小相等的区域,例如区域 A 和区域 B。当分配内存时,只使用其中一个区域(如区域 A)。当需要进行垃圾回收时,将区域 A 中还在使用的对象复制到区域 B 中,然后清空区域 A。这样,区域 A 就成为了一个连续的空闲区域,可以用于下一次内存分配。这种算法简单高效,不会产生内存碎片,但会浪费一半的内存空间,而且复制对象也会有一定的性能开销。
- 标记 - 整理(Mark - Compact)算法
- 类似于标记 - 清除算法,首先标记所有正在使用的对象。然后,将所有正在使用的对象向内存的一端移动,使得所有空闲的内存空间在另一端形成一个连续的区域。这样既可以回收内存,又可以避免产生内存碎片,但移动对象会有一定的性能开销。这种算法在一些对内存碎片比较敏感的系统中比较常用。
- 标记 - 清除(Mark - Sweep)算法
- 虚拟内存管理
- 基本概念
- 虚拟内存是操作系统提供的一种内存管理技术,它使得每个程序都有一个独立的、连续的虚拟地址空间,这个空间可以比实际的物理内存大。例如,一个 32 位的系统,每个程序的虚拟地址空间理论上可以达到 4GB,但实际物理内存可能只有 1GB。通过虚拟内存技术,程序可以在这个虚拟地址空间中编写代码和访问数据,就好像有足够的内存一样。
- 页表和地址转换
- 虚拟内存通过页表来实现虚拟地址到物理地址的转换。当程序访问一个虚拟地址时,处理器会通过查询页表来找到对应的物理地址。如果该虚拟页不在物理内存中(发生了缺页中断),操作系统会从磁盘等存储设备中将该页调入物理内存。例如,当一个程序访问一个还没有加载到内存中的数据页时,会产生缺页中断,操作系统会从磁盘的交换空间(Swap Space)中将该页的数据读取到物理内存中,然后更新页表,使程序可以正常访问该页。
- 页面置换算法
- 当物理内存已满,但程序还需要调入新的页面时,就需要将物理内存中的某个页面置换出去。常见的页面置换算法有先进先出(FIFO)算法、最近最少使用(LRU)算法等。FIFO 算法按照页面进入物理内存的先后顺序进行置换,最先进入的页面最先被置换出去。LRU 算法则是根据页面的最近使用情况,将最近最少使用的页面置换出去。这些算法的目的是尽量减少页面置换的次数,提高系统的性能。
- 基本概念
18算法:长度最小子数组
以下是使用 Java 解决长度最小子数组问题的几种常见思路及代码示例,长度最小子数组问题通常是给定一个整数数组 nums
和一个整数 target
,要求找出满足其和大于等于 target
的长度最小的连续子数组,并返回其长度。
1. 暴力解法
- 思路:
通过两层循环遍历数组,外层循环确定子数组的起始位置,内层循环从起始位置开始往后遍历确定子数组的结束位置,同时计算子数组的和,当子数组的和大于等于target
时,记录下当前子数组的长度,并与之前记录的最小长度比较,取较小值更新最小长度。 - 代码示例如下:
public class MinSubArrayLen {
public static int minSubArrayLen(int target, int[] nums) {
int minLen = Integer.MAX_VALUE;
for (int i = 0; i < nums.length; i++) {
int sum = 0;
for (int j = i; j < nums.length; j++) {
sum += nums[j];
if (sum >= target) {
minLen = Math.min(minLen, j - i + 1);
break;
}
}
}
return minLen == Integer.MAX_VALUE? 0 : minLen;
}
public static void main(String[] args) {
int[] nums = {2, 3, 1, 2, 4, 3};
int target = 7;
System.out.println(minSubArrayLen(target, nums));
}
}
在上述代码中:
- 外层
for
循环控制子数组的起始索引i
,从0
开始遍历到数组末尾。 - 内层
for
循环从起始索引i
开始往后遍历,不断累加元素得到子数组的和sum
。 - 当
sum
大于等于target
时,计算当前子数组长度j - i + 1
,与已记录的最小长度minLen
比较并更新,然后通过break
跳出内层循环,继续寻找下一个起始位置的子数组情况。
2. 滑动窗口解法(推荐,效率更高)
- 思路:
使用双指针(左右指针)形成一个窗口,初始时左右指针都指向数组开头,右指针不断向右移动,窗口内元素的和不断累加,当窗口内元素和大于等于target
时,尝试收缩窗口(右移左指针),同时记录下当前窗口的最小长度,继续移动右指针重复上述过程,直到右指针遍历完整个数组。 - 代码示例如下:
public class MinSubArrayLen {
public static int minSubArrayLen(int target, int[] nums) {
int left = 0;
int right = 0;
int sum = 0;
int minLen = Integer.MAX_VALUE;
while (right < nums.length) {
sum += nums[right];
while (sum >= target && left <= right) {
minLen = Math.min(minLen, right - left + 1);
sum -= nums[left];
left++;
}
right++;
}
return minLen == Integer.MAX_VALUE? 0 : minLen;
}
public static void main(String[] args) {
int[] nums = {2, 3, 1, 2, 4, 3};
int target = 7;
System.out.println(minSubArrayLen(target, nums));
}
}
在这个代码中:
- 首先初始化左右指针
left
和right
都为0
,窗口内元素和sum
为0
,最小长度minLen
为最大整数。 - 外层
while
循环通过右移右指针right
不断扩大窗口,累加窗口内元素和sum
。 - 当
sum
大于等于target
时,进入内层while
循环,尝试收缩窗口,记录当前窗口的最小长度minLen
,并从窗口的和sum
中减去左指针指向的元素,同时右移左指针left
。 - 最后根据
minLen
的值判断是否找到满足条件的子数组,返回相应结果。
这两种方法都可以解决长度最小子数组问题,但滑动窗口解法相对暴力解法来说,时间复杂度更低,在处理大规模数据时效率更高,其时间复杂度为 (n
为数组长度),而暴力解法的时间复杂度为 。