美团中 面试
HashMap 的底层结构,HashMap 的结构,put 和 get 的时候发生了什么,HashMap 的查找性能最好和最坏是多少,为什么,HashMap 的 hash 算法和扩容机制的原因,HashMap 与 HashTable 的区别,HashMap 中用到了红黑树,简述它的原理,它是平衡的吗?,hashmap 是否线程安全呢,ConcurrentHashMap 知道吗,ConcurrentHashMap 在 jdk1.8 之后的优化,ConCurrentHashMap 原理,HashMap 如何实现快速查找,LinkedHashMap 和 HashMap 的区别,还有实现原理,HashMap 相关
底层结构与整体结构
HashMap 的底层结构在 JDK1.8 之后是数组 + 链表 + 红黑树的形式。它以数组作为基础存储结构,每个数组元素被称为桶(bucket),当有数据要存储时,通过对键(key)进行哈希运算得到对应的桶的位置。如果不同的键计算出的桶位置相同,就会在该桶处形成链表,将新元素添加到链表中。而当链表的长度达到一定阈值(默认为 8)且数组长度达到一定条件时,链表会转换为红黑树结构,以此优化查找性能。
put 操作发生的情况
当执行 put 操作时,首先会对传入的键进行哈希运算,根据运算结果确定其在数组中的桶位置。如果该桶位置为空,就直接将键值对放入该桶对应的链表(初始为空链表)中;若桶位置不为空,即发生了哈希冲突,会遍历链表判断键是否已存在,如果存在则更新对应的值,如果不存在则将新的键值对添加到链表末尾。若添加后链表长度达到转化为红黑树的条件,就会将链表转变成红黑树结构。另外,在添加元素后,还会判断当前元素数量是否达到了扩容阈值(当前元素个数大于等于数组长度乘以负载因子,默认负载因子是 0.75),如果达到则进行扩容操作,创建一个更大的新数组,然后将原数组中的元素重新哈希分配到新数组中。
get 操作发生的情况
执行 get 操作时,同样先对要查找的键进行哈希运算,定位到对应的桶位置。如果桶位置对应的链表为空,说明不存在该键,返回 null;如果不为空,则遍历链表(或者在红黑树结构下按照红黑树的查找规则),通过比较键的 equals 方法来确定是否找到对应的值,找到则返回对应的值,遍历完未找到同样返回 null。
查找性能
- 最好情况:查找性能最好是 O (1),也就是常数时间复杂度。当要查找的键对应的桶位置只有一个元素(没有哈希冲突)时,通过哈希运算直接定位到桶,就能马上获取到值,所以效率非常高。
- 最坏情况:最坏情况是 O (n),也就是线性时间复杂度。当发生大量哈希冲突,导致某个桶对应的链表很长(未转换为红黑树的情况下),此时要查找一个键就需要遍历链表来逐一比较,时间复杂度就和链表长度 n 相关了,性能会很差。
hash 算法原因
HashMap 的 hash 算法主要是为了让键均匀地分布在数组的各个桶中,尽量减少哈希冲突。通过对键的 hashCode 值进行一些位运算等处理(比如在 JDK1.8 中采用了扰动函数,对 hashCode 值进行高低位混合等操作),使得计算出的哈希值更具随机性和均匀性,提高存储和查找效率。
扩容机制原因
扩容机制是为了保证 HashMap 能在合适的负载情况下维持较好的性能。随着元素不断添加,如果不进行扩容,哈希冲突的概率会越来越大,链表会越来越长,导致查找等操作的性能下降严重。通过扩容,增加数组的长度,重新哈希分配元素,可以让元素分布更均匀,降低哈希冲突概率,使得操作性能维持在一个合理的水平。
HashMap 与 HashTable 的区别
- 线程安全方面:HashMap 是非线程安全的,在多线程环境下对同一个 HashMap 进行操作可能会导致数据不一致等问题;而 HashTable 是线程安全的,它的方法基本都通过 synchronized 关键字修饰,保证同一时刻只有一个线程能操作,但这也导致了性能相对较低,在多线程并发场景下会有一定的性能瓶颈。
- null 值方面:HashMap 允许键和值为 null,例如可以 put (null, null);而 HashTable 不允许键和值为 null,如果传入 null 会直接抛出异常。
- 继承关系方面:HashMap 继承自 AbstractMap 类;HashTable 继承自 Dictionary 类,不过现在 Dictionary 类已经很少使用了。
红黑树原理及是否平衡
红黑树是一种自平衡的二叉查找树,它有以下几个特性:
- 每个节点要么是红色,要么是黑色。
- 根节点是黑色。
- 每个叶子节点(空节点)都是黑色。
- 如果一个节点是红色的,则它的子节点必须是黑色的。
- 从任意一个节点到其每个叶子的所有路径上包含相同数目的黑色节点。
它是一种平衡的树结构,不过不像严格的 AVL 树那样要求左右子树高度差绝对值不超过 1,红黑树通过上述特性来维持相对平衡,在插入、删除等操作后通过一些旋转和变色操作来保证其平衡性,这样能保证在最坏情况下查找、插入、删除等操作的时间复杂度都能维持在对数级别,比如查找操作的时间复杂度为 O (logn),从而避免了普通二叉查找树在极端情况下退化成链表的问题,提高了数据结构的整体性能。
HashMap 是否线程安全
HashMap 本身不是线程安全的,在多线程环境下,比如多个线程同时执行 put、get 等操作时,可能会出现数据覆盖、死循环等问题。例如,在扩容时如果多个线程同时操作链表或者重新哈希分配元素,就容易导致数据不一致等异常情况发生。
ConcurrentHashMap 知道吗
ConcurrentHashMap 是 Java 中用于多线程并发环境下的线程安全的哈希表实现类,它提供了和 HashMap 类似的功能,能支持高效的键值对存储和查找等操作,同时保证了在多线程同时访问时的数据安全性和一致性。
ConcurrentHashMap 在 JDK1.8 之后的优化
在 JDK1.8 之前,ConcurrentHashMap 采用分段锁(Segment)的机制来实现线程安全,将整个哈希表分成多个段,每个段有独立的锁,不同段之间的操作可以并发进行,一定程度上提高了并发性能,但锁的粒度还是相对较粗。在 JDK1.8 之后,摒弃了分段锁机制,采用了 CAS(Compare and Swap,比较并交换)操作结合 Node 节点的方式实现线程安全。它将数据存储在一个个 Node 节点中,对于 put 等操作,先通过 CAS 操作尝试插入节点,如果失败再根据情况进行自旋或者其他处理;在查找等操作时基本可以无锁访问,大大提高了并发性能,并且在扩容时也采用了更高效的方式,支持多个线程协助完成扩容,整体性能和并发度都有了显著提升。
ConcurrentHashMap 原理
它基于数组 + 链表 + 红黑树的结构(和 HashMap 类似),通过 CAS 操作来保证原子性,比如在添加元素时,先通过对数组指定位置的头节点进行 CAS 操作来尝试插入新节点,如果 CAS 成功则插入完成,如果失败则说明有其他线程同时在操作该位置,可能需要进行自旋等进一步处理。在并发情况下,多个线程可以同时对不同的桶位置进行操作,互不干扰,只有在对同一个桶位置进行修改且存在冲突时,才会通过 CAS 等机制来协调处理,而且在扩容等复杂操作时也能实现多线程的协同,保障数据的正确存储和整体结构的稳定。
HashMap 如何实现快速查找
主要通过两个方面实现快速查找。一是利用哈希算法将键均匀地分布到数组的各个桶中,通过对键的哈希运算快速定位到对应的桶位置,减少了查找范围;二是在桶中元素较多形成链表或者红黑树结构后,在链表中通过顺序遍历(虽然性能在冲突多的情况下会受影响但在元素少的时候也较快),在红黑树结构下按照红黑树的查找规则(时间复杂度为 O (logn))进行查找,相对也能较快地定位到目标元素,综合起来实现了相对快速的查找功能。
LinkedHashMap 和 HashMap 的区别及实现原理
- 区别:
- 顺序性方面:HashMap 不保证元素的存储顺序,元素的顺序取决于哈希运算结果和插入、扩容等操作的情况;而 LinkedHashMap 在 HashMap 的基础上,通过维护一个双向链表来记录元素的插入顺序(默认情况)或者访问顺序(可通过配置实现),所以它可以按照插入顺序或者访问顺序来遍历元素。
- 迭代性能方面:在迭代遍历元素时,LinkedHashMap 的性能相对更稳定,因为它基于双向链表的顺序,每次迭代可以顺着链表依次访问元素;而 HashMap 在遍历链表元素时,如果哈希冲突较多,链表较长,遍历效率会受影响。
- 实现原理:LinkedHashMap 继承自 HashMap,在内部除了有 HashMap 的数组、链表、红黑树等结构外,额外维护了一个双向链表,在插入新元素或者访问已有元素时,会相应地在双向链表中添加、调整节点的顺序,使得元素的顺序信息得以保存,并且在迭代器遍历等操作时就可以按照这个顺序来进行操作。
给一个二叉树的前序和中序写一下后序,二叉树前后序求中序,写一个二叉树的前序遍历和层序遍历算法,输出结果,手撕翻转二叉树
根据前序和中序求后序
已知二叉树的前序遍历序列和中序遍历序列来求后序遍历序列的步骤如下: 首先,前序遍历的第一个元素就是二叉树的根节点的值。然后在中序遍历序列中找到这个根节点的值,其左边的元素就是左子树的中序遍历序列,右边的元素就是右子树的中序遍历序列。接着根据中序遍历中左、右子树的节点个数,可以在前序遍历序列中划分出左子树和右子树的前序遍历序列(除去根节点后的部分按节点个数划分)。通过这样不断地递归分析左、右子树,就能逐步构建出整个二叉树,进而得到后序遍历序列。
例如,前序遍历序列为 [1, 2, 4, 5, 3, 6, 7],中序遍历序列为 [4, 2, 5, 1, 6, 3, 7]。从前序可知根节点为 1,在中序中找到 1,其左边 [4, 2, 5] 是左子树中序,右边 [6, 3, 7] 是右子树中序。再根据节点个数在前序中划分出左子树前序 [2, 4, 5],右子树前序 [3, 6, 7],然后递归处理左、右子树,最终得出后序遍历序列为 [4, 5, 2, 6, 7, 3, 1]。
根据前后序求中序
通过二叉树的前序遍历和后序遍历求中序遍历相对复杂一些,因为仅通过前后序可能存在多种二叉树结构符合条件(不唯一),但可以尝试分析一些情况来确定。
前序遍历的第一个元素和后序遍历的最后一个元素都是根节点的值。先确定根节点,然后从前序中除去根节点后的部分,按照后序中左右子树节点的划分情况(后序中最后一个节点是根节点,前面部分左右子树节点是逆序排列的,可以尝试分析出左右子树节点个数),来尝试划分出左右子树在前序中的部分,进而逐步推测左右子树的结构以及中序情况,但这种方法可能在某些复杂情况下无法准确唯一确定中序,只是一种分析思路,在实际应用中要结合更多条件或者假设来进一步确定具体的中序遍历序列。
二叉树前序遍历算法
以下是使用递归方式实现二叉树前序遍历的算法示例:
class TreeNode {
int val;
TreeNode left;
TreeNode right;
TreeNode(int x) { val = x; }
}
public void preorderTraversal(TreeNode root) {
if (root == null) {
return;
}
System.out.print(root.val + " "); // 先访问根节点
preorderTraversal(root.left); // 递归遍历左子树
preorderTraversal(root.right); // 递归遍历右子树
}
例如,对于如下二叉树:
1
/ \
2 3
/ \ \
4 5 6
调用 preorderTraversal 方法,传入根节点,输出结果为:1 2 4 5 3 6 。
二叉树层序遍历算法
可以使用队列来实现二叉树的层序遍历,思路如下:
import java.util.LinkedList;
import java.util.Queue;
class TreeNode {
int val;
TreeNode left;
TreeNode right;
TreeNode(int x) { val = x; }
}
public void levelOrderTraversal(TreeNode root) {
if (root == null) {
return;
}
Queue<TreeNode> queue = new LinkedList<>();
queue.add(root);
while (!queue.isEmpty()) {
TreeNode node = queue.poll();
System.out.print(node.val + " ");
if (node.left!= null) {
queue.add(node.left);
}
if (node.right!= null) {
queue.add(node.right);
}
}
}
同样对于上述二叉树示例,调用 levelOrderTraversal 方法,输出结果为:1 2 3 4 5 6 。
手撕翻转二叉树
以下是通过递归方式翻转二叉树的代码示例:
class TreeNode {
int val;
TreeNode left;
TreeNode right;
TreeNode(int x) { val = x; }
}
public TreeNode invertTree(TreeNode root) {
if (root == null) {
return null;
}
TreeNode left = invertTree(root.left);
TreeNode right = invertTree(root.right);
root.left = right;
root.right = left;
return root;
}
例如,对于原二叉树:
1
/ \
2 3
/ \ \
4 5 6
经过 invertTree 方法处理后,二叉树变为:
1
/ \
3 2
/ / \
6 5 4
链表和数组的区别,数组和链表的区别,ArrayList 和 LinkedList 的区别,LinkedList && ArrayList
链表和数组的区别
- 存储结构方面:数组是一种连续的内存空间存储结构,所有元素在内存中是依次相邻存放的,通过下标可以直接定位到任意元素,比如数组 a [5],通过索引就能快速访问到第 5 个元素;而链表是通过一个个节点组成,每个节点包含数据部分以及指向下一个节点(单链表情况,还有双链表有指向前一个节点的指针等)的指针,节点在内存中的位置不一定是连续的,是通过指针来串联起各个节点。
- 访问效率方面:数组的随机访问效率非常高,因为可以通过下标直接计算出元素在内存中的地址,时间复杂度是 O (1);而链表要访问某个元素,需要从表头(或者表尾,取决于链表类型和查找方向)开始逐个节点遍历,平均时间复杂度是 O (n),n 为链表的节点个数,访问效率相对较低。
- 插入和删除操作方面:在数组中插入或删除元素,如果是在中间位置操作,需要移动后面的元素来腾出空间或者填补空缺,时间复杂度是 O (n);而链表插入或删除节点,只需修改相关节点的指针指向即可,时间复杂度是 O (1)(如果已经定位到要操作的节点附近,不考虑查找节点的时间),相对更高效。
- 空间占用方面:数组需要预先分配固定大小的内存空间,若实际存储元素个数小于数组长度,会有部分空间闲置浪费;链表则是按需分配每个节点的内存空间,不存在额外的闲置空间(不考虑节点指针等少量额外空间占用),空间利用更灵活。
ArrayList 和 LinkedList 的区别
- 数据结构底层方面:ArrayList 底层是基于数组实现的,它有一个 Object 类型的数组来存储元素,元素在内存中是连续存放的;LinkedList 底层是基于双向链表实现的,每个节点包含了数据、前驱指针和后继指针,通过指针来连接各个节点,节点在内存中位置不连续。
- 访问元素方面:ArrayList 可以通过下标快速访问元素,时间复杂度为 O (1),适合随机访问的场景;LinkedList 要访问元素需要从表头或者表尾开始遍历链表,时间复杂度为 O (n),随机访问效率较低。
- 插入和删除元素方面:ArrayList 在末尾添加或删除元素效率较高,时间复杂度接近 O (1),但在中间位置插入或删除元素,需要移动后面的元素,时间复杂度为 O (n);LinkedList 在任意位置插入或删除节点,只要先定位到节点位置(通常通过遍历,时间复杂度 O (n)),实际操作节点指针的时间复杂度是 O (1),所以在频繁插入和删除操作且不是基于索引随机操作的场景下更有优势。
- 内存占用方面:ArrayList 由于基于数组,可能存在部分未使用的预留空间,并且数组整体占用一块连续内存;LinkedList 每个节点占用相对更多的内存(因为有指针),但节点分布在内存中更灵活,不需要连续的大片空间。
合并两个有序链表
可以使用迭代或递归的方法来合并两个有序链表。迭代方法如下:
- 创建一个虚拟头节点,用于简化合并操作。
- 定义两个指针,分别指向两个链表的当前节点,初始时都指向各自链表的头节点。
- 比较两个指针所指节点的值,将较小值的节点连接到结果链表中,并将相应指针后移。
- 重复步骤 3,直到其中一个链表遍历完。
- 将另一个未遍历完的链表直接连接到结果链表的末尾。
- 返回虚拟头节点的下一个节点,即为合并后的有序链表的头节点。
递归方法的思路类似,只是在比较当前节点值后,递归地合并剩余的链表。以下是迭代方法的代码示例:
public ListNode mergeTwoLists(ListNode l1, ListNode l2) {
ListNode dummy = new ListNode(0);
ListNode cur = dummy;
while (l1!= null && l2!= null) {
if (l1.val < l2.val) {
cur.next = l1;
l1 = l1.next;
} else {
cur.next = l2;
l2 = l2.next;
}
cur = cur.next;
}
if (l1!= null) {
cur.next = l1;
}
if (l2!= null) {
cur.next = l2;
}
return dummy.next;
}
双链表找相交结点
可以使用双指针法来解决。
- 分别定义两个指针,分别指向两个链表的头节点。
- 同时遍历两个链表,当一个指针到达链表末尾时,将其重新指向另一个链表的头节点。
- 继续遍历,直到两个指针相遇,此时相遇的节点即为相交节点。
- 如果两个指针都遍历完各自链表后都未相遇,则说明两个链表不相交。
以下是代码示例:
public ListNode getIntersectionNode(ListNode headA, ListNode headB) {
if (headA == null || headB == null) {
return null;
}
ListNode pA = headA;
ListNode pB = headB;
while (pA!= pB) {
pA = pA == null? headB : pA.next;
pB = pB == null? headA : pB.next;
}
return pA;
}
10 亿数,找最大 100 个数
可以使用小顶堆来解决。
- 首先创建一个大小为 100 的小顶堆。
- 遍历这 10 亿个数,对于每个数:
- 如果当前数比堆顶元素大,则将堆顶元素弹出,然后将当前数插入堆中。
- 如果当前数小于等于堆顶元素,则忽略该数。
- 遍历完所有数后,堆中的 100 个元素即为最大的 100 个数。
这种方法的时间复杂度为 O (nlogk),其中 n 是数字的总数,k 是要找的最大数的个数。在本题中,k=100,n=10 亿,相比于直接排序后取最大的 100 个数,效率要高很多。
冒泡排序和堆排序,手写冒泡排序 + 二分查找
冒泡排序是一种简单的排序算法,它重复地走访要排序的数列,一次比较两个元素,如果它们的顺序错误就把它们交换过来。以下是冒泡排序的代码示例:
public void bubbleSort(int[] arr) {
int n = arr.length;
for (int i = 0; i < n - 1; i++) {
for (int j = 0; j < n - i - 1; j++) {
if (arr[j] > arr[j + 1]) {
// 交换元素
int temp = arr[j];
arr[j] = arr[j + 1];
arr[j + 1] = temp;
}
}
}
}
二分查找是一种在有序数组中查找特定元素的算法。以下是二分查找的代码示例:
public int binarySearch(int[] arr, int target) {
int left = 0;
int right = arr.length - 1;
while (left <= right) {
int mid = left + (right - left) / 2;
if (arr[mid] == target) {
return mid;
} else if (arr[mid] < target) {
left = mid + 1;
} else {
right = mid - 1;
}
}
return -1;
}
堆是什么结构
堆是一种特殊的树形数据结构,通常是指二叉堆。它具有以下特点:
- 完全二叉树:除了最后一层外,其他层的节点数都是满的,并且最后一层的节点都靠左排列。
- 堆序性:分为大顶堆和小顶堆。大顶堆中,每个节点的值都大于或等于其子节点的值;小顶堆中,每个节点的值都小于或等于其子节点的值。
堆的主要操作包括插入、删除和构建。插入操作是将新元素插入到堆的合适位置,以保持堆的性质;删除操作通常是删除堆顶元素,并重新调整堆以保持堆的性质;构建堆是将一个无序数组转换为堆。
堆在许多算法和数据结构中都有广泛的应用,如堆排序、优先队列等。它可以高效地实现对元素的动态排序和快速访问最大或最小元素等操作。
归并排序,手撕快速排序
归并排序
归并排序是一种基于分治思想的高效排序算法。其基本原理如下:
- 分解:将待排序的数组不断地分成两半,直到每个子数组只包含一个元素。例如,对于一个长度为 8 的数组,会依次分成两个长度为 4 的子数组,接着每个长度为 4 的再分成两个长度为 2 的,以此类推,直到都是单个元素的子数组。
- 合并:从最小的子数组开始,两两合并并保证合并后的数组依然是有序的。比如有两个已经有序的子数组 [1, 3] 和 [2, 4],合并时通过比较两个子数组的元素,依次取出较小的放入新的临时数组中,最终合并成 [1, 2, 3, 4] 这样有序的数组。然后不断重复合并操作,直到最终合并成一个完整的有序数组。
归并排序的时间复杂度在最好、最坏以及平均情况下都是 O (n log n),空间复杂度为 O (n),因为在合并过程中需要额外的临时空间来存放合并后的元素,不过它是一种稳定的排序算法,相同元素的相对顺序在排序前后不会改变。
快速排序
快速排序同样基于分治策略,以下是其大致的实现步骤:
- 选择基准值:首先从数组中选取一个元素作为基准值,通常可以选择数组的第一个元素、最后一个元素或者中间元素等。例如,对于数组 [5, 3, 8, 4,6,3, 2],选取第一个元素 5 作为基准值。
- 划分:通过一趟排序将待排序的数组分割成两部分,使得左边部分的所有元素都小于等于基准值,右边部分的所有元素都大于等于基准值。以刚才选取 5 作为基准值为例,经过划分后可能得到 [3, 3, 2] 4 [5,8, 6] 这样的左右两部分(这里只是示例,实际划分过程需要通过双指针等方法遍历数组来实现元素的交换和划分)。
- 递归排序:对划分后的左右两部分子数组分别重复上述的选择基准值和划分步骤,也就是继续进行快速排序,直到子数组的长度为 1 或者为空,此时整个数组就变为有序的了。
快速排序在平均情况下时间复杂度是 O (n log n),但在最坏情况下(比如每次选取的基准值刚好是数组中的最大或最小值,导致划分极度不均衡)时间复杂度会退化为 O (n²),空间复杂度为 O (log n) 到 O (n),取决于递归调用的深度,它是一种不稳定的排序算法。以下是用 Java 语言实现快速排序的代码示例:
public void quickSort(int[] arr, int left, int right) {
if (left < right) {
int pivotIndex = partition(arr, left, right);
quickSort(arr, left, pivotIndex - 1);
quickSort(arr, pivotIndex + 1, right);
}
}
private int partition(int[] arr, int left, int right) {
int pivot = arr[left];
int i = left + 1;
int j = right;
while (true) {
while (i <= j && arr[i] <= pivot) {
i++;
}
while (i <= j && arr[j] > pivot) {
j--;
}
if (i > j) {
break;
}
swap(arr, i, j);
}
swap(arr, left, j);
return j;
}
private void swap(int[] arr, int i, int j) {
int temp = arr[i];
arr[i] = arr[j];
arr[j] = temp;
}
桶排序,对全省高考成绩做一个排序,怎么设计?
桶排序原理
桶排序是一种非比较排序算法,它的基本思想是将数据分到不同的桶中,然后对每个桶里的数据再进行单独排序(可以使用其他排序算法,比如插入排序等),最后将各个桶中的数据按顺序合并起来,就得到了有序的数据。
关键在于合理地确定桶的数量以及每个桶的范围,使得数据能够相对均匀地分布到各个桶中,这样能提高排序的效率。
对全省高考成绩排序的设计
- 确定桶的数量和范围:
- 首先了解高考成绩的范围,比如高考总分通常是 0 到 750 分(假设满分为 750 分)。可以根据实际情况和想要的精度来确定桶的数量,例如可以设置 100 个桶,那么每个桶的范围就是 7.5 分(750÷100),第一个桶存放 0 到 7.5 分的成绩,第二个桶存放 7.5 到 15 分的成绩,以此类推。
- 数据分配到桶中:
- 遍历全省考生的高考成绩,将每个考生的成绩按照其所属的分数段放入对应的桶中。比如某个考生成绩是 530 分,通过计算可知应该放入第 71 个桶(530÷7.5 取整)中。
- 对每个桶内的数据排序:
- 对于每个桶,可以选择一种合适的排序算法来对桶内的成绩进行排序,比如插入排序或者快速排序等。由于每个桶内的数据量相对全省考生数量来说会少很多,所以排序的效率会比较高。
- 合并结果:
- 按照桶的顺序,依次将每个桶内已经排好序的成绩取出,合并起来,这样就得到了全省考生高考成绩的有序排列。
需要注意的是,桶排序的性能依赖于数据的分布情况,如果数据分布不均匀,比如大部分成绩都集中在某几个桶中,可能会导致某些桶内的数据量过大,从而影响排序效率。并且桶排序比较适合数据范围已知且相对均匀分布的数据,像高考成绩这种有明确范围的情况就比较适用。
算法题:单链表逆序
单链表逆序可以通过迭代或者递归的方法来实现。
迭代法
- 定义指针:
- 首先需要定义三个指针,分别为 prev、cur 和 next。prev 初始化为 null,表示当前节点的前一个节点(在逆序后的链表中就是当前节点的下一个节点);cur 指向链表的头节点,也就是要开始逆序操作的起始节点;next 用于临时保存当前节点的下一个节点,方便后续操作。
- 遍历链表并调整指针方向:
- 从链表的头节点开始,依次遍历每个节点。在每一步操作中,先通过 next 指针保存当前节点(也就是 cur 所指节点)的下一个节点,然后将 cur 所指节点的 next 指针指向 prev,这样就改变了当前节点的指针方向,实现了逆序的局部操作。接着,将 prev 指针移动到 cur 的位置,cur 指针移动到 next 的位置,准备处理下一个节点。
- 重复操作直到遍历完链表:
- 不断重复上述的步骤,直到 cur 指针指向 null,此时表示已经遍历完整个链表,prev 指针所指的节点就是逆序后的链表的头节点。
以下是用 Java 语言实现的迭代法单链表逆序的代码示例:
class ListNode {
int val;
ListNode next;
ListNode(int x) { val = x; }
}
public ListNode reverseList(ListNode head) {
ListNode prev = null;
ListNode cur = head;
while (cur!= null) {
ListNode next = cur.next;
cur.next = prev;
prev = cur;
cur = next;
}
return prev;
}
递归法
- 递归终止条件:
- 当链表为空或者链表只有一个节点时,不需要进行逆序操作,直接返回链表头节点即可,这就是递归的终止条件。
- 递归过程:
- 对于有多个节点的链表,先递归地调用自身处理链表的剩余部分(也就是从第二个节点开始的链表),得到逆序后的剩余链表的头节点。然后,将当前头节点(也就是第一个节点)的 next 指针指向它原来的下一个节点(也就是剩余链表的头节点)的下一个节点,再将原来的下一个节点的 next 指针指向当前头节点,这样就完成了当前节点在逆序链表中的插入操作。
- 返回结果:
- 不断重复上述递归过程,直到处理完整个链表,最后返回逆序后的链表的头节点。
以下是用 Java 语言实现的递归法单链表逆序的代码示例:
class ListNode {
int val;
ListNode next;
ListNode(int x) { val = x; }
}
public ListNode reverseList(ListNode head) {
if (head == null || head.next == null) {
return head;
}
ListNode newHead = reverseList(head.next);
head.next.next = head;
head.next = null;
return newHead;
}
求两个数组的最长公共子序列
求两个数组的最长公共子序列可以使用动态规划的方法来解决,以下是具体的步骤和思路:
- 定义状态和状态转移方程:
- 设两个数组分别为 arr1 和 arr2,长度分别为 m 和 n。定义一个二维数组 dp [i][j],其中 dp [i][j] 表示 arr1 的前 i 个元素和 arr2 的前 j 个元素的最长公共子序列的长度。
- 状态转移方程如下:
- 当 arr1 [i - 1] == arr2 [j - 1] 时(这里的 i - 1 和 j - 1 是因为数组下标从 0 开始,实际表示的是第 i 个和第 j 个元素),dp [i][j] = dp [i - 1][j - 1] + 1,意思是如果当前两个数组的对应元素相等,那么最长公共子序列的长度就等于去掉这两个元素之前的最长公共子序列长度加 1。
- 当 arr1 [i - 1]!= arr2 [j - 1] 时,dp [i][j] = Math.max (dp [i - 1][j], dp [i][j - 1]),也就是取 arr1 的前 i - 1 个元素与 arr2 的前 j 个元素的最长公共子序列长度,和 arr1 的前 i 个元素与 arr2 的前 j - 1 个元素的最长公共子序列长度中的最大值,因为当前两个元素不相等,所以最长公共子序列要么在前面的情况中已经达到最长了,要么需要从这两种可能中选择更长的一种。
- 初始化边界条件:
- 当 i = 0 或者 j = 0 时,也就是其中一个数组为空,此时最长公共子序列长度为 0,所以 dp [0][j] = 0(对于所有的 j),dp [i][0] = 0(对于所有的 i)。
- 填充 dp 数组并求解:
- 通过两层循环,外层循环遍历 arr1 的元素(从 1 到 m),内层循环遍历 arr2 的元素(从 1 到 n),根据状态转移方程来填充 dp 数组。
- 最后,dp [m][n] 的值就是两个数组 arr1 和 arr2 的最长公共子序列的长度。
以下是用 Java 语言实现的代码示例:
public int longestCommonSubsequence(int[] arr1, int[] arr2) {
int m = arr1.length;
int n = arr2.length;
int[][] dp = new int[m + 1][n + 1];
for (int i = 0; i <= m; i++) {
for (int j = 0; j <= n; j++) {
if (i == 0 || j == 0) {
dp[i][j] = 0;
} else if (arr1[i - 1] == arr2[j - 1]) {
dp[i][j] = dp[i - 1][j - 1] + 1;
} else {
dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
}
}
}
return dp[m][n];
}
写一个 string 转 int 的 function,要求是不能使用 Integer.parseInt 的 API
以下是一个自定义实现将字符串转换为整数的函数,不使用Integer.parseInt API,思路是通过遍历字符串,根据字符对应的 ASCII 码值以及整数的数位关系来逐步计算出对应的整数值:
public static int stringToInt(String str) {
if (str == null || str.isEmpty()) {
return 0;
}
boolean isNegative = false;
int index = 0;
// 处理符号位
if (str.charAt(0) == '-') {
isNegative = true;
index = 1;
} else if (str.charAt(0) == '+') {
index = 1;
}
int result = 0;
for (; index < str.length(); index++) {
char c = str.charAt(index);
if (c < '0' || c > '9') {
break;
}
// 将字符转换为对应的数字值,通过ASCII码差值计算
int digit = c - '0';
// 防止整数溢出,这里简单判断一下
if (result > Integer.MAX_VALUE / 10 || (result == Integer.MAX_VALUE / 10 && digit > Integer.MAX_VALUE % 10)) {
return isNegative? Integer.MIN_VALUE : Integer.MAX_VALUE;
}
result = result * 10 + digit;
}
return isNegative? -result : result;
}
首先,函数会检查传入的字符串是否为空,如果为空则返回 0。接着,判断字符串开头是否有符号位(‘-’表示负数,‘+’可忽略),然后通过循环遍历字符串中从符号位之后(或者开头如果没有符号位)的字符,将每个字符通过其 ASCII 码与‘0’的差值转换为对应的数字值,然后按照数位关系(乘以 10 并加上当前数字值)逐步计算出整数值。在计算过程中,还通过简单的判断来防止整数溢出,最后根据是否是负数返回相应的结果。
用数组实现循环队列
循环队列是一种特殊的队列,它可以循环利用数组空间。首先定义一个数组和两个指针,一个指向队首,一个指向队尾。入队操作时,先判断队列是否已满,若未满则将元素放入队尾指针指向的位置,然后队尾指针后移一位,若队尾指针到达数组末尾则将其置为 0。出队操作时,先判断队列是否为空,若不空则取出队首指针指向的元素,然后队首指针后移一位,若队首指针到达数组末尾则将其置为 0。通过这种方式,就可以利用数组实现循环队列,充分利用数组空间,避免了普通队列可能出现的假溢出问题。
找两个链表重叠的开始结点(双指针)
可以使用双指针的方法来解决。首先分别遍历两个链表,得到它们的长度。然后让较长的链表先走,使两个链表的剩余长度相等。接着同时遍历两个链表,当两个指针指向同一个结点时,这个结点就是重叠的开始结点。如果遍历完都没有找到相同的结点,则说明两个链表没有重叠部分。这种方法的时间复杂度为 O (m+n),其中 m 和 n 分别是两个链表的长度,空间复杂度为 O (1),因为只需要使用几个指针进行遍历和比较,不需要额外的数据结构来存储链表的结点。
对于 5 亿条成交数据,找出前 100 个销量最好的商品
可以使用堆排序来解决这个问题。首先创建一个大小为 100 的小顶堆,然后遍历 5 亿条成交数据。对于每条数据,将其与堆顶元素进行比较,如果该商品的销量大于堆顶元素的销量,则将堆顶元素替换为该商品,并对堆进行调整。遍历完所有数据后,堆中的 100 个元素就是销量最好的前 100 个商品。堆排序的时间复杂度为 O (nlogk),其中 n 是数据的总数,k 是堆的大小,在本题中 k 为 100。这种方法可以在遍历一遍数据的情况下,快速找到前 100 个销量最好的商品,效率较高。
代码:一个有序链表删除重复元素
可以使用一个指针来遍历链表,当当前结点的值与下一个结点的值相等时,删除下一个结点,直到当前结点的值与下一个结点的值不相等,然后继续遍历。以下是示例代码:
public ListNode deleteDuplicates(ListNode head) {
if (head == null) {
return null;
}
ListNode current = head;
while (current.next!= null) {
if (current.val == current.next.val) {
current.next = current.next.next;
} else {
current = current.next;
}
}
return head;
}
这段代码的时间复杂度为 O (n),其中 n 是链表的长度,因为只需要遍历一次链表。空间复杂度为 O (1),因为只需要使用几个指针进行操作,不需要额外的数据结构来存储链表的结点。
算法,打印 2 到 100 所有的质数
可以使用埃拉托斯特尼筛法来解决这个问题。首先创建一个布尔类型的数组,长度为 100,初始值都为 true,表示所有的数都是质数。然后从 2 开始,将 2 的倍数都标记为 false,因为它们不是质数。接着从 3 开始,将 3 的倍数都标记为 false,以此类推,直到遍历到根号 100。最后遍历数组,将值为 true 的下标打印出来,这些下标对应的数就是 2 到 100 之间的质数。以下是示例代码:
public void printPrimes() {
boolean[] isPrime = new boolean[100];
for (int i = 2; i < 100; i++) {
isPrime[i] = true;
}
for (int i = 2; i * i < 100; i++) {
if (isPrime[i]) {
for (int j = i * i; j < 100; j += i) {
isPrime[j] = false;
}
}
}
for (int i = 2; i < 100; i++) {
if (isPrime[i]) {
System.out.print(i + " ");
}
}
}
这段代码的时间复杂度为 O (nloglogn),其中 n 是要判断的数的范围,在本题中 n 为 100。空间复杂度为 O (n),因为需要创建一个布尔类型的数组来存储每个数是否为质数。
手撕生产者消费者模型
生产者消费者模型是一种经典的多线程协作模式,用于解决数据生产和消费的同步与互斥问题。
首先,我们需要一个共享的数据缓冲区,这个缓冲区可以用多种数据结构来实现,比如队列(Queue)就比较合适。它用来存放生产者生产出来的数据,供消费者获取并处理。
接着,生产者线程负责生成数据并将其放入缓冲区中。在放入数据时,需要考虑缓冲区是否已满,如果已满,生产者就需要等待,直到缓冲区有空闲空间了才能继续放入数据,这就涉及到线程的阻塞和唤醒机制,通常可以通过锁(比如 Java 中的 ReentrantLock)以及条件变量(如 Condition)来实现。例如,生产者获取锁后,判断缓冲区已满就调用条件变量的 await 方法让自己阻塞,等待消费者消费了数据后唤醒它继续生产。
消费者线程则从缓冲区中获取数据进行处理。同样要关注缓冲区是否为空,如果为空,消费者就需要等待,直到生产者生产了新的数据放入缓冲区后再去获取,这也依赖于锁和条件变量来实现相应的阻塞和唤醒逻辑,消费者获取锁后发现缓冲区为空,就通过条件变量的 await 方法阻塞自身,等生产者放入数据后通过条件变量的 signal 或者 signalAll 方法唤醒它去消费数据。
而且,为了保证在多线程环境下操作缓冲区的正确性,无论是生产者往缓冲区放入数据,还是消费者从缓冲区取出数据,都要在获取锁的状态下进行,保证同一时刻只有一个线程能对缓冲区进行操作,避免出现数据不一致等问题。
这样通过生产者不断生产数据放入缓冲区,消费者从缓冲区获取数据进行处理,二者相互协作、同步以及互斥,就构成了一个完整的生产者消费者模型,在很多实际场景中,比如消息队列系统、线程池任务处理等都有着广泛的应用,能够高效地协调数据的生产和消费流程。
手撕:判断一个字符串是否是合法 ip
要判断一个字符串是否是合法的 IP 地址,可以按照以下步骤来进行:
首先,将字符串按照 “.” 进行分割,因为 IP 地址是由四个部分组成,每个部分用 “.” 隔开。如果分割后的数组长度不是 4,那它肯定不是合法的 IP 地址,直接返回 false。
然后,依次检查分割后的每个部分:
- 对于每个部分,要判断它是否只由数字组成,可以通过遍历字符的方式,看每个字符是否在‘0’到‘9’的 ASCII 码范围内,如果出现了非数字字符,那就不符合要求,返回 false。
- 接着,将这个部分转换为数字,可以通过自定义的方法(比如通过字符的 ASCII 码差值来计算数字值,并按照数位关系得到对应的整数值,而不使用内置的解析方法),得到数字后,判断其是否在 0 到 255 的范围内,如果不在这个范围,说明不符合 IP 地址每个部分的取值要求,返回 false。
例如,对于字符串 “192.168.0.1”,按照 “.” 分割后得到四个部分,分别检查每个部分,都是数字且都在 0 到 255 范围内,所以它是合法的 IP 地址;而对于字符串 “192.168.0.256”,虽然前面的检查都能通过,但最后一个部分转换为数字后是 256,超出了合法范围,所以它不是合法的 IP 地址;再比如字符串 “192.168.0”,分割后长度不足 4,也能判断出它不是合法的 IP 地址。
通过对字符串的分割以及对每个部分的细致检查,就能准确判断出一个字符串是否是合法的 IP 地址,在网络编程、配置文件解析等涉及 IP 地址处理的场景中,这个判断逻辑有着重要的应用。
Java 的特性,讲一下多态
多态是 Java 面向对象编程的重要特性之一,它允许不同类的对象对同一消息做出不同的响应。主要体现在以下两个方面:
- 方法的重载:在同一个类中,可以有多个方法具有相同的名字,但参数列表不同,包括参数的类型、个数或顺序。例如,在一个数学计算类中,可以有多个名为 “add” 的方法,一个用于计算两个整数相加,一个用于计算两个浮点数相加等。当调用 “add” 方法时,会根据传入的参数类型和个数自动调用对应的方法。
- 方法的重写:在继承关系中,子类可以重写父类的方法。子类重写的方法与父类中的方法具有相同的方法名、参数列表和返回类型。当通过父类的引用调用该方法时,实际执行的是子类重写后的方法。例如,父类中有一个 “draw” 方法用于绘制图形,子类继承父类后重写了 “draw” 方法,用于绘制特定的图形,如圆形。当通过父类引用指向子类对象并调用 “draw” 方法时,会根据对象的实际类型调用子类重写后的方法,从而实现不同的绘制效果。
多态的存在提高了代码的可扩展性和可维护性,使得代码更加灵活和易于理解。
面向对象程序设计的三大特性和举例
面向对象程序设计的三大特性是封装、继承和多态,以下是具体介绍和举例:
封装
将类的内部实现细节隐藏起来,只对外提供公共的访问接口,使得类的使用者不需要了解类的内部实现细节,只需要通过接口来使用类。例如,在一个 “银行账户” 类中,账户的余额等内部数据被声明为私有属性,外部无法直接访问和修改。而通过提供公共的 “存款”“取款” 等方法来对余额进行操作,这样就保证了数据的安全性和完整性。
继承
允许创建一个新类,从已有的类中继承属性和方法,新类称为子类,已有的类称为父类。子类可以在父类的基础上添加新的属性和方法,或者重写父类的方法。例如,“动物” 类有 “吃”“睡” 等方法,“狗” 类继承 “动物” 类后,不仅继承了这些方法,还可以添加自己特有的方法 “看家”,并重写 “吃” 的方法,实现狗特有的进食方式。
多态
前面已详细介绍,再举一个例子,在一个图形绘制系统中,有 “图形” 类作为父类,“圆形”“矩形” 等类继承自 “图形” 类。“图形” 类中有一个 “绘制” 方法,每个子类都重写了这个方法来实现自己特定的绘制逻辑。当在程序中遍历一个图形数组并调用每个图形的 “绘制” 方法时,会根据图形的实际类型执行相应的绘制方法,这就是多态的体现。
怎么理解 OOP,OOP 三大特性
OOP 即面向对象编程,是一种编程范式,它将现实世界中的事物抽象为程序中的对象,每个对象都有自己的属性和行为,通过对象之间的相互作用来实现程序的功能。
封装
从理解的角度看,它就像是一个黑盒子,将数据和操作数据的方法封装在一个类中,对外只提供有限的接口。这使得类的内部实现可以自由地修改和优化,而不会影响到使用该类的其他代码。例如,在一个汽车类中,发动机的具体工作原理等内部细节被封装起来,用户只需要通过启动、加速、刹车等接口来操作汽车,而不需要了解发动机是如何工作的。
继承
是一种代码复用和层次结构组织的机制。它允许创建新的类从现有的类中继承属性和方法,从而减少代码的重复编写。比如在一个游戏开发中,有一个 “角色” 类,具有基本的属性如生命值、攻击力等和行为如移动、攻击等。然后可以创建 “战士”“法师” 等子类继承 “角色” 类,它们继承了基本的属性和行为,并可以根据自身特点添加新的属性和行为,如战士可以有额外的防御力,法师可以有魔法值等。
多态
使得不同类的对象可以对同一消息做出不同的响应,增强了程序的灵活性和可扩展性。以一个乐器演奏系统为例,有 “乐器” 类作为父类,“钢琴”“小提琴” 等类继承自 “乐器” 类,每个子类都重写了 “演奏” 方法。在乐队演奏时,指挥只需要发出 “演奏” 的指令,不同的乐器就会根据自己的类型演奏出不同的声音,这就是多态在实际中的应用,它使得程序可以更加灵活地处理不同类型的对象,而不需要为每种对象编写特定的处理代码。
怎么理解多态,怎么实现多态
多态可以理解为同一操作作用于不同的对象,可以有不同的解释和执行结果。它使得程序在运行时能够根据对象的实际类型来决定调用哪个方法,而不是在编译时就确定。
实现多态主要有以下几种方式:
方法重写
在继承关系中,子类重写父类的方法,当通过父类引用调用该方法时,实际执行的是子类重写后的方法。例如,父类中有一个 “printInfo” 方法用于打印对象的基本信息,子类继承父类后重写了 “printInfo” 方法,用于打印子类特有的详细信息。当通过父类引用指向子类对象并调用 “printInfo” 方法时,就会根据对象的实际类型调用子类重写后的方法,实现多态。
方法重载
在同一个类中,定义多个同名但参数列表不同的方法。在调用方法时,会根据传入的参数类型、个数或顺序来决定调用哪个方法。例如,在一个计算器类中,有多个名为 “calculate” 的方法,一个用于计算两个整数的加法,一个用于计算两个浮点数的加法等。当调用 “calculate” 方法时,编译器会根据传入的参数自动选择合适的方法进行调用,这也是一种多态的体现。
接口实现
一个类可以实现多个接口,不同的类实现同一个接口时可以有不同的实现方式。例如,有一个 “可打印” 接口,其中定义了 “print” 方法,“文档” 类和 “图片” 类都实现了这个接口,但它们对 “print” 方法的实现不同,一个是打印文档内容,一个是打印图片。当通过接口引用调用 “print” 方法时,会根据实际指向的对象类型调用相应类的实现方法,从而实现多态。
Java 的 8 种数据类型
Java 有 8 种基本数据类型,分为 4 种整数类型、2 种浮点数类型、1 种字符类型和 1 种布尔类型,以下是具体介绍:
整数类型
- byte:占 1 个字节,取值范围是 - 128 到 127,常用于节省内存空间的场景,如在处理文件流或网络传输中的字节数据时。
- short:占 2 个字节,取值范围是 - 32768 到 32767,在某些对内存要求较高且数值范围较小的情况下使用。
- int:占 4 个字节,取值范围是 - 2147483648 到 2147483647,是最常用的整数类型,用于表示一般的整数数值,如循环计数、数组下标等。
- long:占 8 个字节,取值范围非常大,用于表示较大的整数数值,在处理大数据量的计数或需要高精度整数运算时使用,如表示时间戳等。
浮点数类型
- float:占 4 个字节,单精度浮点数,有效数字位数约为 7 位左右,常用于对精度要求不高的浮点数运算,如表示一些近似的数值或在图形绘制中的坐标等。
- double:占 8 个字节,双精度浮点数,有效数字位数约为 15 位左右,是默认的浮点数类型,用于表示需要较高精度的浮点数数值,如科学计算、金融计算等。
字符类型
- char:占 2 个字节,用于表示单个字符,如字母、数字、标点符号等,字符常量用单引号括起来,如 'A'、'5' 等。它可以存储 Unicode 字符集中的任何字符,包括中文字符等。
布尔类型
- boolean:占 1 位,只有两个取值,true 和 false,用于表示逻辑判断的结果,如条件判断、循环控制等。在程序中常用于判断某个条件是否成立,从而决定程序的执行流程。
Java 中的异常分类,哪些时候需要抛出异常?如果自己重写异常怎么写?
Java 中的异常分为受检异常和非受检异常。受检异常是在编译时必须被处理的异常,如 IOException、SQLException 等,通常用于表示程序在运行过程中可能出现的可恢复性错误,如文件读取失败、数据库连接失败等。非受检异常包括运行时异常和错误,如 NullPointerException、ArrayIndexOutOfBoundsException 等,通常是由于程序逻辑错误导致的不可恢复的异常,不需要在编译时进行处理。
需要抛出异常的情况通常有以下几种:当方法无法完成其预定功能时,如文件读取方法在文件不存在或无法访问时;当方法的参数不合法时,如传递给方法的参数不符合要求;当方法执行过程中出现了无法处理的错误,如数据库连接失败等。
如果要自己重写异常,首先需要定义一个异常类,该类通常继承自 Exception 或其子类。例如,如果要定义一个表示业务逻辑错误的异常,可以这样写:
public class BusinessException extends Exception {
public BusinessException() {
super();
}
public BusinessException(String message) {
super(message);
}
public BusinessException(String message, Throwable cause) {
super(message, cause);
}
public BusinessException(Throwable cause) {
super(cause);
}
}
在上述代码中,定义了一个 BusinessException 类,它继承自 Exception 类,并提供了多个构造方法,以便在不同情况下抛出异常并传递相应的信息。
Java 类加载过程,Java 类加载时机
Java 类加载过程主要分为三个阶段:加载、连接和初始化。
- 加载阶段:主要是将类的字节码文件加载到内存中,并将其转换为方法区中的运行时数据结构,在内存中生成一个代表这个类的 java.lang.Class 对象,作为方法区这个类的各种数据的访问入口。
- 连接阶段:又可细分为验证、准备和解析三个步骤。验证是确保被加载类的字节码文件符合 Java 虚拟机规范;准备阶段为类的静态变量分配内存并设置默认初始值;解析是将类中的符号引用转换为直接引用。
- 初始化阶段:是执行类构造器
<clinit>()方法的过程,该方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块合并产生的,主要是对类的静态变量进行初始化赋值。
Java 类的加载时机主要有以下几种情况:当创建类的实例时,如 new 关键字创建对象;当访问类的静态变量或静态方法时;当使用反射机制调用类的方法或访问类的成员时;当子类被加载时,如果父类还未被加载,则先加载父类;当程序启动时,包含 main 方法的主类会被首先加载。
重载和重写,比如 method (String s) method (Object o) 两个方法,调用 method (null) 会出现什么情况
在 Java 中,方法重载是指在同一个类中,有多个方法名相同但参数列表不同的方法。方法重写是指在子类中重新定义了父类中已有的方法,方法名、参数列表和返回值类型都必须与父类中的方法相同。
对于 method (String s) 和 method (Object o) 这两个方法,当调用 method (null) 时,会优先调用 method (String s)。因为在 Java 中,方法的调用是基于静态类型的,而 null 可以被看作是任何引用类型的默认值。在这种情况下,编译器会根据参数的静态类型来选择最合适的方法进行调用,由于 String 是 Object 的子类,并且更具体,所以会优先选择 method (String s)。
但是,如果没有 method (String s) 这个方法,那么就会调用 method (Object o)。这是因为 null 可以被隐式转换为任何引用类型,而 Object 是所有引用类型的父类,所以当没有更具体的方法匹配时,就会调用参数类型为 Object 的方法。
Java 的 overload 和 override 有什么区别
Java 中的重载(overload)和重写(override)主要有以下区别:
- 定义位置:重载是在同一个类中定义多个方法名相同但参数列表不同的方法;重写是在子类中重新定义父类中已有的方法。
- 方法签名:重载的方法参数列表必须不同,包括参数的个数、类型或顺序;重写的方法参数列表必须与父类中的方法完全相同。
- 返回值类型:重载对返回值类型没有特殊要求,可以相同也可以不同;重写的方法返回值类型必须与父类中的方法相同,或者是父类方法返回值类型的子类。
- 访问修饰符:重载对访问修饰符没有限制;重写的方法访问修饰符不能比父类中的方法更严格,即子类方法的访问权限必须大于或等于父类方法的访问权限。
- 异常抛出:重载对异常抛出没有特殊要求;重写的方法抛出的异常不能比父类中的方法更多或更宽泛,即子类方法抛出的异常必须是父类方法抛出异常的子类或相同。
- 调用时机:重载是在编译时根据参数的类型和个数来确定调用哪个方法;重写是在运行时根据对象的实际类型来确定调用哪个方法。
父函数声明了一个方法,子函数继承它了,还能 override 吗?如果函数参数个数相同,入参类型不同,可以吗?
当父函数声明了一个方法,子类继承后通常是可以重写(override)的,但需要满足一定的条件,如方法名、参数列表和返回值类型都必须与父类中的方法相同,访问修饰符不能比父类中的方法更严格,抛出的异常不能比父类中的方法更多或更宽泛等。
如果函数参数个数相同,但入参类型不同,这不是重写,而是重载。在 Java 中,重写要求方法的参数列表必须与父类中的方法完全相同,包括参数的类型、个数和顺序。如果只是参数类型不同,那么这两个方法在子类中是不同的方法,属于重载的情况,而不是重写。
== 和.equals () 的区别
在 Java 中,“==” 和 “equals ()” 都用于比较,但有着本质的区别。“==” 比较的是两个对象的引用地址是否相同,即是否指向同一个对象实例。对于基本数据类型,“==” 比较的是值是否相等。例如,int a = 5; int b = 5;,此时a == b为true,因为它们的值相等。而对于引用类型,如String s1 = "abc"; String s2 = "abc";,s1 == s2为true,因为它们指向常量池中的同一个字符串对象。但如果是String s3 = new String("abc"); String s4 = new String("abc");,s3 == s4则为false,尽管它们的内容相同,但它们是两个不同的对象实例。
“equals ()” 方法是在 Object 类中定义的,默认情况下与 “==” 的作用相同,即比较引用地址。但很多类会重写 “equals ()” 方法,用于比较对象的内容是否相等。例如,对于上述的s3和s4,s3.equals(s4)为true,因为 String 类重写了 “equals ()” 方法,比较的是字符串的内容。
重写 equals 怎么写?如何判断重写的 equals 是对的 (要遵守哪些约定)?
重写 “equals ()” 方法时,首先要确保方法签名正确,即public boolean equals(Object obj)。在方法内部,通常需要进行以下步骤:首先判断传入的对象是否为null,如果是则直接返回false;然后判断传入的对象是否与当前对象是同一个引用,如果是则返回true;接着判断传入的对象是否是当前类的实例,如果不是则返回false;最后比较对象的各个属性值是否相等。例如,对于一个自定义的Person类,有name和age两个属性,重写的equals()方法可以如下:
public boolean equals(Object obj) {
if (obj == null) {
return false;
}
if (this == obj) {
return true;
}
if (getClass()!= obj.getClass()) {
return false;
}
Person other = (Person) obj;
return Objects.equals(name, other.name) && age == other.age;
}
判断重写的 “equals ()” 方法是否正确,需要遵守以下约定:自反性,即对于任何非null的引用x,x.equals(x)必须返回true;对称性,对于任何非null的引用x和y,如果x.equals(y)返回true,那么y.equals(x)也必须返回true;传递性,对于任何非null的引用x、y和z,如果x.equals(y)返回true,y.equals(z)返回true,那么x.equals(z)也必须返回true;一致性,对于任何非null的引用x和y,只要对象的属性没有被修改,多次调用x.equals(y)应该始终返回相同的结果;对于任何非null的引用x,x.equals(null)必须返回false。
重写 equals 为什么要重写 hashCode?
在 Java 中,“hashCode ()” 方法和 “equals ()” 方法密切相关。当把对象存储在一些基于哈希的数据结构中,如HashMap、HashSet等,会先根据对象的 “hashCode ()” 值来确定对象在数据结构中的存储位置。如果两个对象通过 “equals ()” 方法比较是相等的,那么它们在哈希表中的存储位置应该相同,这就要求它们的 “hashCode ()” 值也相等。如果只重写了 “equals ()” 方法而没有重写 “hashCode ()” 方法,可能会导致在哈希表中存储和查找对象时出现不一致的情况。例如,将两个内容相同但 “hashCode ()” 值不同的对象放入HashSet中,可能会导致HashSet错误地认为这是两个不同的对象,从而违反了HashSet中元素的唯一性原则。所以,为了保证在使用哈希数据结构时的正确性和一致性,当重写 “equals ()” 方法时,通常也需要重写 “hashCode ()” 方法,使得相等的对象具有相同的 “hashCode ()” 值。
equals/hashcode 如果让 hashcode 返回一个 random 的数字会有什么问题
如果让 “hashCode ()” 方法返回一个随机的数字,会导致在使用哈希数据结构时出现严重的问题。在HashMap、HashSet等数据结构中,会根据对象的 “hashCode ()” 值来确定对象的存储位置和快速查找。如果 “hashCode ()” 返回随机值,那么相同内容的对象每次计算出的 “hashCode ()” 可能都不同,这就使得在存储和查找对象时,无法根据 “hashCode ()” 快速定位到对象的位置,而是需要遍历整个数据结构来进行比较,大大降低了数据结构的性能,从原本的O(1)时间复杂度可能退化为O(n)。同时,由于 “hashCode ()” 值的不确定性,可能会导致在使用哈希数据结构时出现对象丢失、重复存储等问题,破坏了数据结构的正确性和一致性。例如,在HashSet中,原本应该根据 “hashCode ()” 和 “equals ()” 来确保元素的唯一性,但由于 “hashCode ()” 的随机变化,可能会导致相同的元素被多次添加进去。
String a="123" 和 new String ("123") 的区别
String a = "123"和new String("123")在创建方式和内存分配上存在明显区别。String a = "123"是一种字符串字面量的创建方式,在 Java 中,字符串字面量会被存储在字符串常量池中。当执行这行代码时,Java 会首先在字符串常量池中查找是否已经存在 "123" 这个字符串,如果存在,则直接将a指向该字符串在常量池中的引用;如果不存在,则在常量池中创建 "123" 这个字符串对象,并将a指向它。
而new String("123")则是通过new关键字在堆内存中创建一个新的字符串对象,无论字符串常量池中是否已经存在 "123" 这个字符串,都会在堆内存中创建一个新的对象。所以,即使内容相同,new String("123")和字符串常量池中的 "123" 是两个不同的对象。可以通过==比较来验证,String a = "123"; String b = new String("123");,a == b为false,但a.equals(b)为true。在性能方面,使用字符串字面量的方式在多次使用相同字符串时可以节省内存空间,因为可以共享常量池中的字符串对象,而new String()每次都会创建新的对象,占用更多的堆内存空间。
String a = "abc" 和 String str = new String (“abc”) 的区别
- 存储位置不同:当使用
String a = "abc"时,JVM 会先在字符串常量池中查找是否存在 "abc" 这个字符串常量,如果存在则直接将a指向该常量;如果不存在,则在常量池中创建 "abc",然后再将a指向它。而String str = new String("abc"),首先会在堆内存中创建一个新的String对象,然后再在字符串常量池中查找是否存在 "abc",如果不存在也会在常量池中创建。 - 内存占用不同:对于
String a = "abc",如果常量池中已经存在 "abc",则不会再额外占用过多内存,只是多了一个对常量池中的字符串的引用。而String str = new String("abc"),除了在常量池中可能创建的 "abc" 占用内存外,在堆内存中还会创建一个新的String对象,该对象有自己的内存空间用于存储字符串内容和一些对象头信息等,相对来说占用更多内存。 - 比较结果不同:使用
==比较时,String a = "abc"和String b = "abc",因为它们都指向字符串常量池中的同一个 "abc",所以a==b为true。而对于String str1 = new String("abc")和String str2 = new String("abc"),虽然它们的内容相同,但由于是在堆内存中创建的两个不同对象,所以str1==str2为false。但是使用equals()方法比较时,只要内容相同,都会返回true。
谈谈四种引用,弱引用一定会在下一次 GC 回收吗
- 强引用:是最常见的引用类型,如
Object obj = new Object(),只要强引用存在,垃圾回收器就不会回收被引用的对象。 - 软引用:用于描述一些还有用但并非必需的对象。在系统将要发生内存溢出异常之前,会把这些对象列入回收范围之中进行第二次回收。如果这次回收还没有足够的内存,才会抛出内存溢出异常。
- 弱引用:也是用来描述非必需对象的,但是它的强度比软引用更弱一些。被弱引用关联的对象只能生存到下一次垃圾收集发生之前。当垃圾收集器工作时,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。
- 虚引用:是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。其作用是能在这个对象被收集器回收时收到一个系统通知。
弱引用不一定会在下一次 GC 时就被回收。虽然弱引用的对象在垃圾回收时通常会被优先回收,但如果在垃圾回收时,该弱引用对象被其他强引用对象所引用,那么它就不会被回收。只有当该弱引用对象没有任何强引用指向它时,才会在下一次 GC 时被回收。
泛型以及泛型擦除,编译时还是运行时,擦除之后是什么?
- 泛型:是 Java 中的一种特性,它允许在定义类、接口和方法时使用类型参数,使得代码可以在不同的类型上进行复用,提高了代码的灵活性和安全性。
- 泛型擦除:Java 中的泛型是在编译时进行类型擦除的。在编译期间,编译器会将泛型类型参数擦除为它们的边界类型或者
Object类型。 - 编译时和运行时的情况:在编译时,编译器会检查泛型的类型安全性,确保在使用泛型时不会出现类型不匹配的错误。但是在运行时,泛型的类型信息会被擦除,JVM 并不知道具体的泛型类型参数是什么。
- 擦除之后的情况:对于有边界的泛型类型参数,擦除后会被替换为边界类型;对于无边界的泛型类型参数,擦除后会被替换为
Object类型。例如,List<String>在编译后会被擦除为List,其中的String类型信息被擦除,在运行时无法直接获取到具体的泛型类型是String。
(object),泛型,<?> == <? extends> 读写方面有什么不同
- <?> 通配符:表示可以接受任何类型的参数化类型,但在使用时不能向其中添加除
null以外的任何元素,因为编译器无法确定具体的类型,无法保证添加的元素类型是正确的。只能进行读取操作,读取出来的元素类型为Object,需要进行强制类型转换才能使用具体类型的方法。 - <? extends> 通配符:表示可以接受指定类型及其子类型的参数化类型。在读取方面,可以读取到指定类型及其子类型的元素,并且不需要进行强制类型转换,因为编译器可以确定读取出来的元素是指定类型或其子类型。但是在写入方面,只能添加
null,因为无法确定具体的子类型,不能保证添加的元素类型是正确的。 - 泛型:在定义类、接口或方法时使用类型参数,可以在编译时进行类型检查,保证类型的安全性。在读写方面,可以根据具体的类型参数进行相应的操作,既可以向其中添加指定类型的元素,也可以读取指定类型的元素,不需要进行强制类型转换,代码更加清晰和安全。
ArrayList 扩容 - System.arraycopy () 内部原理
- 方法作用:
System.arraycopy()方法用于将一个数组中的元素复制到另一个数组中,它是ArrayList扩容的核心方法之一。 - 参数含义:该方法有五个参数,分别是源数组、源数组的起始位置、目标数组、目标数组的起始位置、要复制的元素个数。
- 内部原理:在
ArrayList扩容时,当需要增加数组的容量时,会创建一个新的更大容量的数组,然后使用System.arraycopy()方法将原数组中的元素复制到新数组中。它会按照指定的起始位置和要复制的元素个数,将源数组中的元素逐个复制到目标数组中对应的位置。 - 性能特点:该方法是一个本地方法,通常具有较高的性能。它会直接对内存进行操作,避免了在 Java 层面进行循环复制元素的开销,从而提高了复制的效率。但是,如果要复制的元素个数非常大,可能会占用较多的内存和时间。
HashSet 和 HashMap 的关系?
HashSet 是基于 HashMap 实现的,它实际上是一个没有重复元素的集合,内部使用了一个 HashMap 来存储元素。在 HashSet 中,元素被存储为 HashMap 的键,而值则是一个固定的虚拟值,通常是一个名为 PRESENT 的静态常量对象。HashSet 的 add 方法实际上是调用了 HashMap 的 put 方法,将元素作为键,PRESENT 作为值存入 HashMap 中。由于 HashMap 的键是不允许重复的,所以 HashSet 中不会出现重复的元素。HashSet 和 HashMap 都具有快速查找、插入和删除的特点,它们的性能在很大程度上取决于哈希函数的质量和哈希表的负载因子。但它们的用途不同,HashSet 主要用于检查元素的唯一性,而 HashMap 用于存储键值对并根据键快速查找值。
接口抽象类区别?
接口和抽象类都是 Java 中用于实现抽象和多态的机制,但它们有一些重要的区别。接口中的方法默认是 public abstract 的,不能有方法体,只能定义方法签名。而抽象类中可以有抽象方法,也可以有具体方法,抽象方法没有方法体,具体方法有方法体。接口中的成员变量默认是 public static final 的,必须在定义时初始化,且不能被修改。抽象类中的成员变量可以是各种访问修饰符修饰的普通变量,也可以有静态变量等。一个类可以实现多个接口,但只能继承一个抽象类。接口通常用于定义一组规范或契约,规定了实现类必须实现的方法和常量。抽象类则更适合用于表示一种抽象的概念,它可以包含一些通用的行为和属性,供子类继承和扩展。
值传递和引用传递区别?
在 Java 中,实际上只有值传递,不存在引用传递。值传递是指在方法调用时,将实际参数的值复制一份传递给方法的形式参数,方法对形式参数的修改不会影响到实际参数。对于基本数据类型,传递的是具体的值,比如将一个 int 类型的值传递给一个方法,方法内部对这个参数的修改不会影响到外部的变量。对于引用数据类型,传递的是对象的引用的副本,而不是对象本身。这意味着在方法内部可以通过这个引用副本访问和修改对象的属性,但如果在方法内部重新赋值引用,比如将其指向一个新的对象,那么这个操作不会影响到外部的引用。而在一些其他编程语言中可能存在引用传递,即直接将对象的引用传递给方法,方法内部对参数的修改会直接影响到外部的对象。
编译时异常和运行时期异常区别,分别举个例子?
编译时异常是指在编译阶段就必须处理的异常,否则代码无法通过编译。这类异常通常是由于程序的外部因素导致的,比如文件不存在、网络连接失败等。例如,使用 Java 的 FileInputStream 读取一个文件时,如果文件不存在,就会抛出 FileNotFoundException 异常,这是一个编译时异常,必须在代码中使用 try-catch 块进行捕获或者在方法声明中使用 throws 关键字声明抛出。运行时期异常是在程序运行期间才会出现的异常,不需要在编译时进行处理。这类异常通常是由于程序内部的逻辑错误导致的,比如数组越界、空指针引用等。例如,当访问一个数组元素时,如果索引超出了数组的范围,就会抛出 ArrayIndexOutOfBoundsException 异常,这是一个运行时期异常,不需要在编译时进行显式处理,但在实际运行中可能会导致程序崩溃。
final 关键字可以修饰什么,作用什么,final 关键字,final 变量何时赋初值?
final 关键字可以修饰类、方法和变量。当 final 修饰类时,该类不能被继承,比如 Java 中的 String 类就是 final 类,这保证了 String 类的不可扩展性和安全性,防止其他人对其进行修改和扩展。当 final 修饰方法时,该方法不能被重写,子类只能继承使用,不能修改其实现,例如,在一些工具类中,为了保证方法的功能不被改变,会将方法声明为 final。当 final 修饰变量时,变量的值不能被改变,即成为常量。如果是基本数据类型的 final 变量,在声明时必须初始化,之后不能再赋值。如果是引用数据类型的 final 变量,其引用不能被改变,但可以通过该引用修改对象的属性。例如,final 修饰一个数组,数组的引用不能再指向其他数组,但可以修改数组中的元素。对于成员变量中的 final 变量,可以在声明时初始化,也可以在构造函数中初始化。对于局部变量中的 final 变量,必须在使用之前进行初始化。
匿名内部类,匿名内部类使用的参数为什么必须是 final 的?
匿名内部类是一种特殊的内部类形式,它没有显式的类名,通常是在需要创建一个只使用一次的类的实例时使用。匿名内部类使用的参数必须是 final 的主要原因在于其内部实现机制和变量的作用域与生命周期的考量。
在 Java 中,匿名内部类的生命周期往往会超出定义它的那个代码块的执行时间,比如在某个方法内定义了匿名内部类,当方法执行结束后,这个匿名内部类可能依然存在并在后续某个时机被调用。而匿名内部类中使用外部的局部变量时,如果这个局部变量不是 final 的,那么它的值是可以被随意改变的,这就可能导致在匿名内部类后续被调用时,它所依赖的外部变量的值出现不可预期的变化,破坏了程序逻辑的一致性。
将参数定义为 final 就保证了其值一旦初始化后就不可更改,这样匿名内部类在使用这些外部变量时,能始终基于一个确定的值进行操作,避免了因为外部变量值的变动而引发的错误。例如,在一个按钮点击事件的匿名内部类中,可能会使用外部定义的一个整数变量来决定某个操作的次数,若这个变量不是 final 的,在匿名内部类等待执行的过程中,该变量被其他代码修改了,那点击按钮后执行的操作就会不符合预期。所以规定匿名内部类使用的参数为 final ,是为了确保程序逻辑的清晰性和正确性。
静态内部类和非静态的区别
静态内部类和非静态内部类在多个方面存在区别。
从与外部类的关系来看,非静态内部类的实例必须依附于外部类的实例存在,在创建非静态内部类对象时,首先需要有一个外部类的对象,因为非静态内部类中可以直接访问外部类的非静态成员,它隐含着持有外部类实例的引用。而静态内部类不需要依赖外部类的实例,可以直接创建对象,它更像是外部类的一个静态成员,只是以类的形式存在,它只能访问外部类的静态成员,不能直接访问外部类的非静态成员。
在内存分配方面,非静态内部类对象的内存空间包含了对外部类实例的引用以及自身类的相关成员等信息,随着外部类实例的创建而存在,外部类实例被回收后它也可能随之被回收。静态内部类则和普通的类类似,在类加载时就会被加载到内存中(当第一次被使用时),它的内存分配相对独立于外部类实例,只要对应的类加载器存在且没有被卸载,它就会一直存在于内存中(在合适的情况下)。
从使用场景来讲,非静态内部类常用于对外部类某个具体实例相关的操作进行封装,比如在一个表示人的类中,有个非静态内部类表示这个人的心脏,心脏的相关属性和行为和具体的人实例相关。而静态内部类更多用于对外部类的静态资源进行辅助管理或者分组等,像在一个工具类中有多个静态内部类,分别实现不同的工具方法集合,它们之间相对独立,和外部类的具体实例无关。
多线程处理问题,用过多线程处理问题吗,怎么用的?
在实际开发中经常会用到多线程来处理各种问题,多线程可以充分利用多核处理器的资源,提高程序的执行效率以及实现异步操作等。
比如在一个网络下载应用中,就会用到多线程处理。假设要同时下载多个文件,若采用单线程,只能依次下载每个文件,效率低下。这时可以为每个要下载的文件开启一个单独的线程,每个线程负责一个文件的下载任务。具体操作上,首先会创建多个实现了 Runnable 接口或者继承了 Thread 类的类(实现 Runnable 接口更为常用,因为它避免了单继承的限制)。以实现 Runnable 接口为例,定义一个类实现 Runnable 接口,在其 run 方法中编写具体的下载文件的逻辑,比如建立网络连接、获取文件流、将文件内容写入本地等操作。
然后通过 Thread 类来启动这些线程,比如创建 Thread 实例并将实现 Runnable 接口的类的实例传入 Thread 的构造函数,再调用 Thread 的 start 方法来启动线程,这样多个线程就可以并发地去执行下载任务,互不干扰,极大地提高了下载的整体效率。
又比如在一个图形绘制程序中,对于复杂图形的绘制,如果在单线程下可能会出现界面卡顿,影响用户体验。通过使用多线程,将图形绘制任务放在一个单独的线程中进行,主线程负责处理用户的交互操作,如鼠标点击、键盘输入等,这样就能实现图形绘制的同时用户还可以正常操作界面,使程序更加流畅和友好。
创建线程的方式,线程启动三种方式
创建线程主要有以下几种方式:
继承 Thread 类
首先创建一个类继承自 Thread 类,然后重写 Thread 类中的 run 方法,在 run 方法中编写线程要执行的具体逻辑。例如:
class MyThread extends Thread {
@Override
public void run() {
// 这里编写线程要执行的任务,比如打印一些信息
System.out.println("线程正在执行");
}
}
// 在其他地方使用时
MyThread thread = new MyThread();
thread.start();
通过继承 Thread 类并重写 run 方法来定义线程的执行内容,调用 start 方法来启动线程,start 方法会使线程进入就绪状态,等待 CPU 资源分配后开始执行 run 方法中的内容。
实现 Runnable 接口
定义一个类实现 Runnable 接口,实现接口中的 run 方法,在里面写线程要执行的任务。之后创建 Thread 类的实例,将实现 Runnable 接口的类的实例传入 Thread 类的构造函数中,再调用 Thread 的 start 方法启动线程。示例如下:
class MyRunnable implements Runnable {
@Override
public void run() {
System.out.println("通过实现Runnable接口的线程在执行");
}
}
// 使用时
MyRunnable runnable = new MyRunnable();
Thread thread = new Thread(runnable);
thread.start();
这种方式避免了单继承的限制,更加灵活,一个类可以实现多个接口来扩展功能,同时多个线程可以共享同一个实现 Runnable 接口的类的实例来执行相同的任务。
实现 Callable 接口(结合 Future 和 FutureTask)
Callable 接口类似于 Runnable 接口,但它有返回值并且可以抛出异常。定义一个类实现 Callable 接口,重写 call 方法,在 call 方法中编写任务逻辑并返回结果。然后通过 FutureTask 类来包装 Callable 接口的实现类的实例,FutureTask 实现了 RunnableFuture 接口(它继承自 Runnable 和 Future 接口),最后将 FutureTask 实例传入 Thread 类的构造函数并启动线程。例如:
import java.util.concurrent.Callable;
import java.util.concurrent.FutureTask;
class MyCallable implements Callable<String> {
@Override
public String call() throws Exception {
return "通过实现Callable接口得到的结果";
}
}
// 使用时
MyCallable callable = new MyCallable();
FutureTask<String> futureTask = new FutureTask<>(callable);
Thread thread = new Thread(futureTask);
thread.start();
try {
String result = futureTask.get();
System.out.println(result);
} catch (Exception e) {
e.printStackTrace();
}
通过 FutureTask 的 get 方法可以获取 Callable 接口中 call 方法返回的结果,不过要注意在调用 get 方法时,如果线程还未执行完,会阻塞当前线程直到获取到结果为止。
生产者和消费者问题,写一点示意代码
生产者和消费者问题是多线程编程中常见的经典问题,主要是处理生产者生产数据和消费者消费数据的同步协调,避免出现数据不一致或者数据丢失等情况,以下是简单的示例代码:
import java.util.LinkedList;
import java.util.Queue;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
// 共享资源,这里用队列模拟
class ShareData {
private Queue<Integer> queue = new LinkedList<>();
private int maxSize = 10;
private Lock lock = new ReentrantLock();
private Condition notFull = lock.newCondition();
private Condition notEmpty = lock.newCondition();
// 生产者生产数据的方法
public void produce(int data) {
lock.lock();
try {
while (queue.size() == maxSize) {
try {
notFull.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
queue.add(data);
System.out.println("生产者生产了数据: " + data);
notEmpty.signal();
} finally {
lock.unlock();
}
}
// 消费者消费数据的方法
public int consume() {
lock.lock();
try {
while (queue.isEmpty()) {
try {
notEmpty.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
int data = queue.poll();
System.out.println("消费者消费了数据: " + data);
notFull.signal();
return data;
} finally {
lock.unlock();
}
}
}
// 生产者线程类
class Producer implements Runnable {
private ShareData shareData;
public Producer(ShareData shareData) {
this.shareData = shareData;
}
@Override
public void run() {
for (int i = 0; i < 20; i++) {
shareData.produce(i);
}
}
}
// 消费者线程类
class Consumer implements Runnable {
private ShareData shareData;
public Consumer(ShareData shareData) {
this.shareData = shareData;
}
@Override
public void run() {
for (int i = 0; i < 20; i++) {
shareData.consume();
}
}
}
public class ProducerConsumerExample {
public static void main(String[] args) {
ShareData shareData = new ShareData();
Thread producerThread = new Thread(new Producer(shareData));
Thread consumerThread = new Thread(new Consumer(shareData));
producerThread.start();
consumerThread.start();
}
}
在上述代码中,通过一个共享的队列来模拟共享资源,使用 ReentrantLock 锁和 Condition 条件来进行线程间的同步和通信。生产者线程在队列未满时向队列中添加数据,添加后通过 Condition 的 signal 方法通知可能正在等待数据的消费者线程。消费者线程在队列非空时从队列中取出数据进行消费,消费后通知可能正在等待队列有空位的生产者线程,以此实现生产者和消费者之间的协调运作,保证数据的正常生产和消费。
线程池有什么优势?
线程池主要有以下几个优势:
- 降低资源消耗:通过重复利用已创建的线程,避免了频繁创建和销毁线程所带来的额外开销,如系统调用、内存分配和回收等,从而提高了系统的性能和资源利用率。
- 提高响应速度:当有新任务到达时,线程池中的线程可以立即执行任务,而不需要等待线程的创建过程,减少了任务的等待时间,提高了系统的响应速度。
- 控制并发数量:线程池可以根据系统的资源状况和任务的需求,合理地控制线程的数量,避免因创建过多线程而导致系统资源耗尽或性能下降,同时也可以防止线程数量过少而无法充分利用系统资源。
- 便于线程管理:线程池提供了统一的线程管理机制,可以方便地对线程进行监控、调度和维护,如设置线程的优先级、超时时间、异常处理等,提高了线程的可管理性和稳定性。
怎么停止一个线程?
停止一个线程通常有以下几种方法:
- 使用标志位:在主线程中设置一个共享的布尔变量作为标志位,当需要停止线程时,将标志位设置为 false 。在线程的执行逻辑中,定期检查这个标志位,如果标志位为 false ,则线程自动退出。
- 使用 interrupt () 方法:调用线程的 interrupt () 方法可以向线程发送一个中断信号,线程可以通过检查自身的中断状态来决定是否退出。需要注意的是,调用 interrupt () 方法并不一定会立即停止线程,线程需要在合适的时机检查中断状态并进行相应的处理。
- 使用 stop () 方法:不推荐使用这种方式,因为 stop () 方法是一种暴力停止线程的方式,可能会导致线程资源无法正确释放,数据不一致等问题。
线程池的参数和工作流程?
- 线程池的参数:
- corePoolSize:核心线程数,线程池在创建后会立即创建并保留的线程数量,即使这些线程处于空闲状态也不会被销毁。
- maximumPoolSize:最大线程数,线程池允许创建的最大线程数量,当任务队列已满且核心线程都在忙碌时,线程池会创建新的线程,但不会超过最大线程数。
- keepAliveTime:线程存活时间,当线程池中的线程数量超过核心线程数时,多余的空闲线程在等待新任务的最长存活时间,超过这个时间后空闲线程将被销毁。
- unit:时间单位,用于指定 keepAliveTime 的时间单位,如 TimeUnit.SECONDS、TimeUnit.MILLISECONDS 等。
- workQueue:任务队列,用于存储等待执行的任务,常见的有 ArrayBlockingQueue、LinkedBlockingQueue、SynchronousQueue 等。
- threadFactory:线程工厂,用于创建新的线程,可自定义线程的名称、优先级等属性。
- handler:拒绝策略,当线程池无法处理新任务时采取的策略,常见的有 AbortPolicy、CallerRunsPolicy、DiscardOldestPolicy、DiscardPolicy 等。
- 线程池的工作流程:
- 当有新任务提交到线程池时,线程池首先检查核心线程数是否已满。如果核心线程数未满,则创建一个新的核心线程来执行任务;如果核心线程数已满,则将任务添加到任务队列中。
- 如果任务队列已满,线程池会检查当前线程数是否小于最大线程数。如果小于最大线程数,则创建一个新的非核心线程来执行任务;如果当前线程数已经达到最大线程数,则根据拒绝策略处理新任务。
- 当线程执行完任务后,会从任务队列中获取新的任务继续执行。如果线程在一定时间内(keepAliveTime)没有获取到新的任务且当前线程数大于核心线程数,该线程将被销毁。
线程池的实现?
线程池的实现主要基于以下几个关键类和接口:
- Executor 接口:这是线程池的顶层接口,定义了 execute () 方法用于提交任务到线程池执行。
- ExecutorService 接口:继承自 Executor 接口,扩展了更多的方法,如 submit ()、shutdown ()、shutdownNow () 等,用于提交有返回值的任务、关闭线程池等。
- AbstractExecutorService 抽象类:实现了 ExecutorService 接口中的大部分方法,提供了一些通用的实现逻辑,如提交任务、执行任务、关闭线程池等。
- ThreadPoolExecutor 类:是线程池的核心实现类,继承自 AbstractExecutorService 抽象类,实现了线程池的具体功能,包括线程的创建、管理、任务的调度和执行等。
在实际使用中,通常会使用 Executors 工厂类来创建不同类型的线程池,如 newFixedThreadPool ()、newCachedThreadPool ()、newSingleThreadExecutor ()、newScheduledThreadPool () 等,这些方法实际上都是对 ThreadPoolExecutor 类的封装,根据不同的需求设置了不同的参数。
线程的所有状态?
Java 中的线程有以下几种状态:
- NEW:新建状态,当线程对象被创建但尚未调用 start () 方法时,线程处于新建状态。此时线程只是一个对象,还没有在操作系统中创建对应的线程实体。
- RUNNABLE:可运行状态,当线程调用 start () 方法后,线程进入可运行状态。在这个状态下,线程可能正在运行,也可能正在等待系统分配 CPU 资源。
- BLOCKED:阻塞状态,当线程在等待获取锁时,会进入阻塞状态。例如,当多个线程竞争同一把锁时,没有获取到锁的线程会进入阻塞状态,直到获取到锁为止。
- WAITING:等待状态,线程在调用 Object.wait ()、Thread.join ()、LockSupport.park () 等方法后,会进入等待状态。处于等待状态的线程需要等待其他线程的通知或中断才能继续执行。
- TIMED_WAITING:限时等待状态,与等待状态类似,但可以设置等待的时间。例如,Thread.sleep ()、Object.wait (long timeout)、Thread.join (long millis)、LockSupport.parkNanos (long nanos)、LockSupport.parkUntil (long deadline) 等方法会使线程进入限时等待状态,当等待时间到达或被其他线程唤醒时,线程会继续执行。
- TERMINATED:终止状态,当线程的 run () 方法执行完毕或因异常退出时,线程进入终止状态。此时线程的生命周期结束,不会再占用任何系统资源。
线程和进程的区别
线程是进程中的一个执行单元,是进程内的可调度实体,它共享进程的资源,包括内存空间、文件描述符等。进程是具有一定独立功能的程序在某个数据集合上的一次运行活动,是系统进行资源分配和调度的一个独立单位,拥有独立的内存空间和系统资源。一个进程可以包含多个线程,这些线程可以并发执行,以提高程序的执行效率。进程之间的通信相对复杂,需要使用进程间通信机制,如管道、消息队列、共享内存等。而线程之间可以通过共享进程的内存空间来进行通信,如共享变量等。在多线程程序中,如果一个线程出现异常,可能会导致整个进程崩溃。而在多进程程序中,一个进程的异常通常不会影响其他进程的运行。创建和销毁进程的开销较大,因为需要分配和释放大量的系统资源,如内存、文件描述符等。而创建和销毁线程的开销相对较小,因为线程共享进程的资源,只需要分配和释放少量的线程特定资源。
线程运行的次序
线程的运行次序是不确定的,由操作系统的线程调度器决定。线程调度器会根据线程的优先级、等待时间等因素来决定哪个线程获得 CPU 时间片并执行。在多线程环境下,线程的执行顺序是随机的,无法准确预测哪个线程会先执行,哪个线程会后执行。即使我们设置了线程的优先级,也不能保证高优先级的线程一定会先执行,因为线程调度器还会考虑其他因素,如线程的等待时间、CPU 负载等。有时可以使用一些同步机制,如锁、信号量等,来控制线程的执行顺序,但这并不是直接指定线程的运行次序,而是通过控制线程的并发访问来实现一定程度的顺序执行。在实际应用中,我们应该尽量避免依赖特定的线程运行次序,而是通过合理的设计和使用同步机制来确保程序的正确性和稳定性。
线程安全这一块有所了解吗
线程安全是指在多线程环境下,程序的执行结果与在单线程环境下的执行结果一致,并且不会出现数据不一致、死锁、饥饿等问题。在多线程环境下,如果多个线程同时访问和修改共享数据,可能会导致数据不一致的问题,如丢失更新、脏读等。当多个线程竞争共享资源时,如果没有正确的同步机制,可能会导致死锁的发生,即多个线程相互等待对方释放资源,从而导致程序无法继续执行。在多线程环境下,如果某些线程长时间占用共享资源,可能会导致其他线程无法及时获取资源而处于饥饿状态,影响程序的性能和公平性。实现线程安全的方法有很多种,如使用 synchronized 关键字、Lock 接口、原子类、并发容器等。在设计多线程程序时,需要充分考虑线程安全问题,选择合适的同步机制和并发控制方法,以确保程序的正确性和高效性。
java 中可以保证多线程安全的方式
使用 synchronized 关键字可以修饰方法或代码块,确保在同一时刻只有一个线程能够访问被修饰的方法或代码块,从而保证了数据的一致性和完整性。Lock 接口提供了比 synchronized 关键字更灵活的锁机制,如可重入锁、读写锁等。通过显式地获取和释放锁,可以更精细地控制并发访问。Java.util.concurrent.atomic 包下提供了一系列原子类,如 AtomicInteger、AtomicLong 等,这些类通过使用 CAS(Compare and Swap)算法来实现原子操作,无需使用锁即可保证数据的一致性。使用 volatile 关键字可以保证变量的可见性,即当一个线程修改了该变量的值时,其他线程能够立即看到最新的值。但是 volatile 关键字不能保证原子性,通常需要与其他同步机制一起使用。使用线程安全的集合类,如 ConcurrentHashMap、CopyOnWriteArrayList 等,这些集合类在内部实现了同步机制,能够在多线程环境下安全地进行访问和修改。
synchronized 作用于代码块和方法有什么区别呢
当 synchronized 作用于方法时,它会锁定当前对象实例,即 this 对象。这意味着在同一时刻,只有一个线程能够访问该对象的同步方法,其他线程需要等待当前线程释放锁后才能访问。而 synchronized 作用于代码块时,可以指定一个具体的对象作为锁对象,这个对象可以是任意的 Java 对象,不仅仅局限于当前对象实例。这样可以更灵活地控制同步的范围和粒度。对于静态方法,synchronized 关键字会锁定该类的 Class 对象,因为静态方法是属于类的,而不是属于对象实例的。这意味着在同一时刻,只有一个线程能够访问该类的同步静态方法,其他线程需要等待当前线程释放锁后才能访问。当 synchronized 作用于代码块时,可以根据需要选择不同的锁对象,从而实现更细粒度的同步控制。如果一个方法中只有部分代码需要同步,使用代码块可以减少同步的范围,提高程序的并发性能。作用于方法的 synchronized 关键字在字节码层面会自动添加一些指令来实现同步,而作用于代码块的 synchronized 关键字需要在代码块开始和结束处手动添加指令来获取和释放锁。
synchronized 描述的一个静态方法和一个普通方法都对一个变量 count 进行访问,能保证线程安全吗
当一个静态方法被synchronized修饰,它锁的是这个类的Class对象,而普通方法被synchronized修饰时,锁的是当前实例对象。对于它们共同访问的变量count,不能保证线程安全。
原因在于,不同线程访问静态同步方法时,是基于类的Class对象这个锁来控制并发访问的,而访问普通同步方法是基于不同的实例对象锁(如果是不同实例在调用该普通方法的话)。假如有多个线程,一部分线程通过类去调用静态同步方法操作count,另一部分线程通过不同的类实例去调用普通同步方法操作count,这时候就相当于有两把不同的 “锁” 在控制对count的访问,无法保证同一时刻只有一个线程能操作count。
例如,有线程 A 通过类去调用静态同步方法对count进行自增操作,同时线程 B 通过创建类的一个实例调用普通同步方法也对count进行自增操作,它们获取的锁是不一样的,所以会出现并发访问count的情况,可能导致数据不一致,像出现丢失更新等问题,最终无法确保整个操作的线程安全。
ReentrantLock 和 synchronized 的区别
首先,从锁的获取和释放机制来看,synchronized是 Java 内置的关键字,它的加锁和解锁过程是由 Java 虚拟机自动控制的,当一个线程进入被synchronized修饰的代码块或者方法时自动获取锁,离开时自动释放锁,开发人员不需要显式地进行操作。而ReentrantLock是一个显式的锁,需要开发人员手动通过lock()方法获取锁,使用完后通过unlock()方法释放锁,如果忘记释放锁,就可能导致死锁等问题,所以使用ReentrantLock时要在finally块中确保锁被释放。
在功能特性方面,ReentrantLock提供了更多的灵活性,比如它可以设置公平锁和非公平锁,公平锁能保证按照线程请求锁的先后顺序来分配锁,非公平锁则允许插队获取锁,默认是非公平锁,而synchronized是一种非公平的锁机制,无法进行这样的设置。
再者,ReentrantLock可以响应中断,当一个线程在等待获取锁时,可以通过interrupt()方法来中断等待,抛出InterruptedException异常,然后可以做相应的处理;但synchronized在等待锁时是不能响应中断的,线程只能一直等待直到获取到锁或者被唤醒。
另外,ReentrantLock可以通过newCondition()方法创建多个条件变量,用于实现更复杂的线程间的等待、通知机制,在多线程复杂协作场景下能更好地控制线程的等待和唤醒,而synchronized只有一个隐含的条件变量,相对来说在复杂协作场景下使用起来没有那么方便灵活。
Syncronized 和 Lock,二者的区别
synchronized是 Java 语言层面的关键字,使用起来较为简洁,由 Java 虚拟机自动管理锁的获取与释放,在简单的并发控制场景下,能快速实现对方法或者代码块的同步,避免多线程对共享资源的并发访问造成数据不一致等问题。例如对一个简单的共享变量读写操作的方法加上synchronized关键字,就能保证同一时刻只有一个线程能访问该方法来操作变量。
而Lock接口(像常用的ReentrantLock是其实现类)提供了更丰富、更灵活的锁机制。在锁的获取与释放上,需要开发人员手动操作,这虽然增加了一定的使用复杂性,但也让开发者可以更精准地控制锁的使用时机,比如在合适的代码位置获取锁,在确保资源使用完毕后释放锁。
从功能拓展性角度看,Lock能实现像公平锁、非公平锁的选择,响应中断以及创建多个条件变量等功能,这些在复杂的多线程交互场景下非常有用。比如在一个生产者消费者模型中,如果使用Lock并结合条件变量,可以更精细地控制生产者生产满了等待消费者消费,消费者消费完等待生产者生产的过程,比synchronized更便于实现复杂的线程协作逻辑。
从性能方面考虑,在低并发的情况下,synchronized的性能损耗相对较小,因为它由虚拟机自动管理,无需额外的操作成本;但在高并发、复杂的多线程场景下,Lock由于其灵活的特性和可优化的空间,往往能通过合理的配置和使用实现更好的性能表现,更高效地协调多线程的并发访问。
在单例模式中,volatile 的作用是什么呢
在单例模式中,尤其是双重检查锁定(DCL,Double Checked Locking)这种实现方式下,volatile起着至关重要的作用。
在没有使用volatile时,可能会出现指令重排序的问题。以经典的双重检查锁定单例模式为例,创建单例对象的过程通常有三步,分配内存空间、初始化对象、将对象引用指向分配好的内存空间。在多线程环境下,由于指令重排序,有可能出现线程先将对象引用指向了还未完全初始化的内存空间,然后另一个线程通过这个还未完全初始化的引用去访问对象,就会导致错误。
而volatile关键字能禁止指令重排序,确保在多线程环境下,对象的初始化以及引用赋值等操作按照正确的顺序执行。这样,当一个线程获取到单例对象的引用时,能保证这个对象已经是完全初始化好的状态,避免了因为指令重排序导致的数据不一致和错误访问等情况,从而保证单例模式的正确性,确保在整个应用程序中只会存在一个正确初始化后的单例对象实例供各个线程使用。
可以谈一下在项目中如何使用 synchronized 与 volatile 的吗
在项目中,synchronized有着广泛的应用场景。比如在一个 Web 应用中,有多个用户同时访问某个库存管理的功能,库存数量是一个共享资源,不同用户的操作可能涉及对库存数量的增减。这时,可以将库存操作相关的方法(如减少库存数量的方法、增加库存数量的方法等)使用synchronized修饰,这样无论有多少个用户并发地发起请求,同一时刻只有一个用户对应的线程能够进入方法中对库存数量进行操作,从而保证库存数量这个共享数据的准确性,避免出现超卖或者库存数量错误等问题。
又比如在一个文件读写操作的类中,如果多个线程可能同时对同一个文件进行读写,那么读写文件的相关方法可以用synchronized进行修饰,保证读写操作的线程安全,防止出现文件内容混乱等情况。
而volatile的使用场景也不少。例如在一个多线程的实时监控系统中,有一个变量用来记录当前系统的运行状态(比如是正常运行状态还是异常状态),多个线程可能会读取这个变量来决定后续的操作,同时某个线程在检测到系统出现异常时会修改这个变量的值。这时就可以将这个变量声明为volatile,这样当一个线程修改了这个变量的值后,其他线程能立即看到最新的值,确保各线程基于准确的系统状态信息来执行相应的操作,避免因为缓存等原因导致线程看到的是旧值而产生错误的行为。
再比如在一个简单的计数器类中,多个线程会对计数器进行自增操作,虽然volatile不能保证原子性,但如果结合一些原子操作的逻辑或者其他同步机制,将计数器变量声明为volatile,可以保证其值在多线程环境下的可见性,然后再通过额外的方式确保原子性,从而准确地实现计数功能,让各线程都能基于准确的计数值来开展后续工作。
等待和阻塞的区别,wait 和 sleep 的区别
等待和阻塞的区别
在多线程环境中,等待和阻塞是两个不同的概念。
阻塞通常是指线程因为某些条件不满足,暂时无法继续执行而被暂停的状态。比如,当线程尝试获取一个被其他线程持有的锁时,它就会进入阻塞状态,直到获取到锁才能继续执行后续代码。又比如,线程进行 I/O 操作时,在 I/O 操作完成前也会处于阻塞状态,因为它需要等待外部设备的数据传输等操作结束。阻塞状态的线程往往是在等待系统资源或者外部条件,它不能主动去执行其他任务,完全依赖于外部情况改变使其解除阻塞。
而等待状态更多是线程主动进入的一种状态,通常是线程之间基于某种协作机制而产生的。例如,一个线程调用了对象的wait()方法,它会释放持有的锁并进入等待状态,等待其他线程调用该对象的notify()或notifyAll()方法来唤醒它,之后它才会重新去竞争锁并继续执行。等待状态是线程基于程序逻辑主动暂停自身执行,等待被唤醒以参与后续操作的一种情形。
wait 和 sleep 的区别
wait()方法是Object类提供的方法,它必须在同步代码块或者同步方法中使用,因为调用wait()方法时会释放当前对象的锁,让其他线程有机会获取该锁进入同步代码块进行操作。并且它是用于线程间的协作,通过notify()或notifyAll()来唤醒处于wait状态的线程,通常配合对象锁一起使用来实现多线程间的复杂交互。比如在生产者消费者模型中,消费者线程发现没有数据可消费时可以调用wait()方法等待生产者生产数据并通知它。
sleep()方法是Thread类提供的静态方法,它会让当前线程暂停执行一段时间,在这段时间内线程不会参与 CPU 的调度,时间一到就会自动苏醒继续执行后续代码。它不需要在同步代码块等特定环境中使用,也不会释放锁,仅仅是让线程暂停运行。例如,在一个定时任务线程中,每隔一段时间执行一次任务,可以通过sleep()方法来实现线程的定时暂停,等待下一个执行周期到来。而且sleep()方法可以通过InterruptedException异常来响应中断,若在睡眠期间线程被中断,会抛出该异常并可以在异常处理中做相应操作。
String、StringBuffer 、StringBuilder 的区别
String是不可变的字符序列,一旦创建就不能修改其内容。例如,当对String对象执行拼接操作时,实际上是创建了一个新的String对象,原来的String对象并不会改变。这样虽然保证了字符串的安全性和不可变性,但在频繁进行字符串拼接等操作时,会产生大量临时的String对象,消耗较多的内存空间。像下面这样:
String str = "Hello";
str = str + " World";
在上述代码中,执行str = str + " World"时,会先创建一个包含 "World" 的String对象,然后再创建一个新的String对象将 "Hello" 和 "World" 拼接起来,原来的 "Hello" 对象就成了等待垃圾回收的对象。
StringBuffer是可变的字符序列,它提供了一系列修改字符串内容的方法,比如append()方法可以在原字符串末尾添加字符或字符串,并且它是线程安全的。多个线程可以同时操作同一个StringBuffer对象,内部通过同步机制保证了数据的一致性。不过,由于其线程安全的特性,在单线程环境下相对来说性能会稍低一些,因为每次操作都要进行同步检查等额外操作。例如:
StringBuffer buffer = new StringBuffer("Hello");
buffer.append(" World");
在上述代码中,直接通过append()方法就在原来的字符串基础上进行了修改,不会像String那样创建新的对象。
StringBuilder同样是可变的字符序列,它和StringBuffer功能类似,也提供append()等修改字符串的方法。但它是非线程安全的,这意味着在单线程环境下它的性能要优于StringBuffer,因为不需要进行线程同步的额外开销。所以在单线程的频繁字符串操作场景下,使用StringBuilder更为合适,比如在一个简单的字符串拼接构造 SQL 语句等只在单线程内使用的操作中,用StringBuilder能提高效率。例如:
StringBuilder builder = new StringBuilder("Hello");
builder.append(" World");
总之,String适用于字符串内容不需要修改或者很少修改的情况;StringBuffer适用于多线程环境下对字符串有修改需求的场景;StringBuilder适合单线程环境下频繁修改字符串的操作。
AutoInteger 等了解吗 使用了 CAS
AtomicInteger是 Java 中的原子类,它利用了 CAS(Compare and Swap,比较并交换)机制来保证操作的原子性,在多线程环境下可以安全地对整数进行操作。
在传统的多线程对普通整数变量进行操作时,像自增操作,如果没有合适的同步机制,多个线程同时对同一个整数变量进行自增,可能会出现数据不一致的情况,比如丢失更新等问题。而AtomicInteger类通过内部的 CAS 机制来解决这个问题。
CAS 操作包含三个操作数,分别是内存位置(可以理解为要操作的变量所在内存地址)、预期原值和新值。它的执行逻辑是,首先会获取要操作的变量的当前值(也就是内存位置处的值),然后将其与预期原值进行比较,如果两者相等,说明在这期间没有其他线程修改过这个值,那么就将该值更新为新值;如果两者不相等,说明已经有其他线程修改了这个值,那就不进行更新操作,而是重新获取当前值再次尝试进行 CAS 操作,这个过程会不断循环,直到操作成功为止。
例如,在多个线程对一个AtomicInteger对象进行自增操作时,每个线程执行自增操作时都会通过 CAS 机制去尝试更新它的值,即使多个线程同时发起操作,也能保证只有一个线程的操作能够成功更新值,其他线程会根据比较结果重新尝试,这样就保证了操作的原子性,使得最终的结果符合预期,避免了数据不一致的问题。
除了AtomicInteger,Java 中还有AtomicLong、AtomicBoolean等一系列原子类,它们分别针对不同的数据类型利用 CAS 机制实现原子操作,为多线程环境下对这些基本数据类型的安全操作提供了便利,在很多并发编程场景中,比如计数器、状态标志位等应用场景下都有着广泛的使用。
垃圾回收机制,简述 GC 过程,object 类的 finalize () 方法是如何影响 GC 的
垃圾回收机制与 GC 过程
垃圾回收(GC)机制是 Java 自动管理内存的一种重要方式,它负责回收那些不再被程序使用的对象所占用的内存空间,避免内存泄漏,让程序员无需手动去释放内存。
GC 过程大致可以分为以下几个阶段:
- 标记阶段:垃圾回收器首先会从根对象(比如栈帧中的局部变量、静态变量等可以直接被引用的对象)开始,通过遍历对象图的方式,标记所有从根对象可达的对象,这些被标记的对象被认为是存活的对象,而那些无法从根对象到达的对象则被视为垃圾对象,等待回收。例如,一个局部变量不再被使用,它所指向的对象以及从这个对象关联出去的其他无法再被根对象引用到的对象都会被标记为可回收对象。
- 清除阶段:在标记完存活对象和垃圾对象后,垃圾回收器会对那些标记为垃圾的对象进行回收,释放它们所占用的内存空间。对于不同的垃圾回收算法,清除的方式会有所不同,比如有的是直接回收内存空间,有的可能会进行内存整理等操作。
- 整理阶段(可选):有些垃圾回收算法为了避免内存碎片化,在清除垃圾对象后,会对存活的对象进行移动和整理,使得它们在内存中更加紧凑地排列,这样后续分配内存时会更加方便,能够减少因为内存碎片导致的无法分配大内存块等问题。不过这个阶段不是所有的垃圾回收算法都会执行,像标记 - 清除算法就没有这个阶段,而标记 - 整理算法会包含此阶段。
object 类的 finalize () 方法对 GC 的影响
Object类的finalize()方法是一种对象被回收前的 “最后挣扎” 机制。当一个对象即将被垃圾回收时,垃圾回收器会首先调用这个对象的finalize()方法,给予对象一次自我拯救或者进行一些清理资源等操作的机会。
如果在finalize()方法中,对象重新让自己被其他存活的对象引用,那么这个对象就会从即将被回收的状态变成存活状态,逃过这次垃圾回收。不过这种情况应该谨慎使用,因为如果过度依赖finalize()方法进行对象的复活等操作,可能会导致内存管理混乱,而且无法保证finalize()方法一定会被及时调用,它的执行时机由垃圾回收器决定,具有不确定性。
另外,finalize()方法也可以用于关闭一些非 Java 自带的资源,比如文件描述符、数据库连接等外部资源,在对象被回收前进行合理的关闭操作,避免资源泄漏。但由于它的执行存在不确定性以及性能等方面的问题,现在更推荐使用try-with-block等其他更可靠的方式来进行资源的关闭和管理。
Java 虚拟机垃圾回收算法,G1 算法,讲下垃圾回收算法
常见的 Java 虚拟机垃圾回收算法
- 标记 - 清除算法:这是最基础的垃圾回收算法之一。首先通过根对象开始标记所有可达的存活对象,然后清除那些未被标记的垃圾对象,释放它们所占用的内存空间。其优点是实现简单,缺点是容易产生内存碎片,经过多次回收后,内存中可能会存在很多小的空闲内存块,导致后续如果需要分配较大内存块时无法满足需求,即使总的空闲内存空间足够。
- 复制算法:它将内存空间划分为大小相等的两块,每次只使用其中一块来分配对象。当这块内存满了需要进行垃圾回收时,会把存活的对象复制到另一块空闲的内存区域中,然后把原来使用的那块内存全部清空,这样就不会产生内存碎片,并且复制操作相对简单高效,适用于对象存活率较低的场景。但缺点是它浪费了一半的内存空间用于备份,内存利用率不高。
- 标记 - 整理算法:和标记 - 清除算法类似,先标记存活对象,但是在清除垃圾对象后,它会对存活对象进行整理,将它们移动到内存的一端,使内存空间更加紧凑,避免了内存碎片的产生,后续分配内存时也更方便。不过整理阶段需要移动对象,相对来说开销会比单纯的清除操作大一些。
G1 算法(Garbage First 算法)
G1 是一种面向服务端应用的垃圾回收器,主要有以下特点和工作原理:
- 分区思想:G1 将整个堆内存划分成多个大小相等的区域(Region),这些区域包含了老年代和新生代等不同的内存区域概念,每个区域都可以根据需要充当不同的角色,比如有的区域用于分配新生代对象,有的用于存放老年代对象等。这种分区的方式使得 G1 在回收时可以更加灵活地选择回收哪些区域,而不是像传统的垃圾回收器那样对整个新生代或者老年代进行统一操作。
- 预测停顿时间:G1 的一个重要目标是能够满足用户设定的停顿时间要求,它可以根据用户设定的期望最大停顿时间,动态地选择回收价值最大的区域进行回收,所谓回收价值最大就是优先回收那些包含垃圾对象较多同时回收成本相对较低的区域,通过这种方式来尽量控制垃圾回收时的停顿时间,使得程序的响应性能更好,特别适合对实时性要求较高的应用场景。
- 并发与并行结合:G1 在垃圾回收过程中既有并发阶段也有并行阶段。在并发阶段,它可以与应用程序线程同时运行,比如并发标记阶段,在不影响应用程序正常运行的情况下标记存活对象;在并行阶段,会有多个垃圾回收线程同时工作,比如在并行清理阶段,多个线程一起对垃圾对象进行清理等操作,提高了垃圾回收的效率。
- 混合回收:G1 在回收时通常不会只回收新生代或者老年代,而是采用混合回收的方式,根据区域的情况,选择一些新生代区域和部分老年代区域一起进行回收,通过合理的选择和调度来平衡垃圾回收效率和停顿时间控制。
Jvm 的内存划分,哪些是线程安全的?
JVM 的内存主要划分为以下几个区域:
程序计数器
程序计数器是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。在多线程环境下,每个线程都有自己独立的程序计数器,用于记录该线程正在执行的字节码指令地址,所以它天然是线程安全的。因为各个线程之间不会相互干扰,各自维护自己的执行位置信息,确保每个线程都能按照正确的顺序执行代码。
Java 虚拟机栈
Java 虚拟机栈用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每个线程在创建时都会创建一个对应的虚拟机栈,线程之间的虚拟机栈是相互独立的,所以从这个角度讲它也是线程安全的。例如,不同线程调用不同的方法时,每个线程在自己的栈帧中保存相应方法的局部变量等信息,不会出现不同线程之间的数据混淆情况。
本地方法栈
本地方法栈与 Java 虚拟机栈类似,不过它是为本地方法(用非 Java 语言编写的方法,比如 C 或 C++ 语言编写的方法,通过 JNI 调用到 Java 中)服务的,每个线程同样有自己独立的本地方法栈,因此也是线程安全的,不同线程执行本地方法时在各自的栈空间内进行操作,互不影响。
堆
堆是 Java 虚拟机所管理的内存中最大的一块,用于存放对象实例以及数组等数据。它是被所有线程共享的内存区域,不是线程安全的,因为多个线程可以同时访问和操作堆中的对象,如果没有合适的同步机制,很容易出现数据不一致等线程安全问题,像多个线程同时修改同一个对象的属性值时,就需要通过诸如 synchronized、锁等方式来保障操作的正确性。
方法区
方法区用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。它同样是各个线程共享的区域,不是线程安全的,例如多个线程同时去访问和修改类的静态变量时,如果没有额外的同步保障,就会导致数据出现差错,像并发修改同一个静态变量的取值等情况。
ThreadLocal 的内部原理,以及 Thread 中有什么属性和方法。
ThreadLocal 的内部原理
ThreadLocal 是 Java 中用于实现线程局部变量的一个类,它的核心原理在于每个线程内部都有一个独立的变量副本,各个线程之间不会相互干扰。
从数据结构角度看,ThreadLocal 内部有一个静态内部类叫做 ThreadLocalMap,它其实相当于一个以 ThreadLocal 对象为键,以要存储的实际值为值的 Map 结构。当在一个线程中通过 ThreadLocal 对象设置值时,实际上是将值存储到了当前线程对应的 ThreadLocalMap 中,以当前使用的 ThreadLocal 对象作为键进行关联。
每个线程在创建时,都会在其内部的一些属性中关联上自己的 ThreadLocalMap。当获取值时,也是先从当前线程的 ThreadLocalMap 中根据对应的 ThreadLocal 对象作为键去查找相应的值。例如,在多个线程中使用同一个 ThreadLocal 对象来存储线程相关的上下文信息时,每个线程操作的都是自己独有的那份数据,即使不同线程中的代码逻辑看起来是在操作同一个 ThreadLocal 对象,但实际上访问的是各自独立存储的数据,实现了线程间数据隔离的效果。
另外,ThreadLocal 为了防止内存泄漏,在其内部对键值对的存储和清理有着一定的机制。当一个 ThreadLocal 对象不再被使用时(比如线程执行完毕或者 ThreadLocal 对象被置为 null 等情况),它会尝试去清理对应的 Entry(键值对),不过这个清理过程并不是绝对能完全避免内存泄漏的情况,还需要开发人员在合适的场景下主动进行一些清理操作。
Thread 中的属性和方法
- 属性方面:
- name 属性:用于标识线程的名称,方便在调试等过程中区分不同的线程,开发人员可以在创建线程时为其指定名称,若不指定,系统会默认生成一个类似 “Thread - 数字” 的名称。
- priority 属性:代表线程的优先级,取值范围是 1 到 10,默认值是 5,优先级越高的线程在获取 CPU 资源时理论上有更高的机会被优先调度,但这并不能绝对保证高优先级线程一定先执行,只是一种概率上的倾向,因为线程调度还受其他多种因素影响。
- daemon 属性:用于标识线程是否为守护线程,守护线程是一种为其他非守护线程(用户线程)服务的线程,当所有的非守护线程执行结束后,守护线程会自动终止,比如 Java 中的垃圾回收线程就是守护线程,它会在应用程序的主线程等非守护线程结束后自动停止工作,无需手动去关闭它。
- target 属性:在通过实现 Runnable 接口来创建线程时,这个属性指向实现了 Runnable 接口的对象实例,它包含了线程要执行的具体任务逻辑,也就是 Runnable 接口中 run 方法所定义的内容。
- 方法方面:
- start 方法:用于启动线程,当调用这个方法后,线程会进入就绪状态,等待 CPU 资源分配,然后开始执行线程的 run 方法中定义的任务逻辑,需要注意的是,一个线程对象只能调用一次 start 方法,多次调用会抛出 IllegalThreadStateException 异常。
- run 方法:是线程执行的具体逻辑所在,当线程获取到 CPU 资源后,就会执行 run 方法中的内容,在通过继承 Thread 类创建线程时,需要重写这个方法来定义线程要执行的具体任务,而通过实现 Runnable 接口创建线程时,实现的 Runnable 接口中的 run 方法就是实际的执行逻辑所在,会被 Thread 类的 run 方法调用。
- join 方法:可以让当前线程等待调用该方法的线程执行完毕后再继续执行,例如在主线程中调用某个子线程的 join 方法,主线程就会阻塞,直到这个子线程执行完所有任务后,主线程才会接着往下执行,常用于协调多个线程之间的执行顺序,确保某些线程的任务先完成。
- interrupt 方法:用于向线程发送中断信号,线程可以通过检查自身的中断状态来决定是否响应中断并进行相应的处理,比如在一些循环执行任务的线程中,可以在合适的地方检查中断状态,当被中断时跳出循环等操作,不过调用这个方法并不一定会立即停止线程,只是设置了中断标识,线程需要自行处理中断情况。
- isInterrupted 方法:用于检查线程当前是否处于被中断的状态,返回一个布尔值来表示,线程可以根据这个返回值来判断是否有其他线程对自己发送了中断信号,进而采取相应的行动,与 interrupt 方法配合使用来实现线程中断相关的逻辑。
- sleep 方法:是一个静态方法,用于让当前线程暂停执行一段时间,在这段时间内线程不会参与 CPU 的调度,时间一到就会自动苏醒继续执行后续代码,它不需要在同步代码块等特定环境中使用,也不会释放锁,仅仅是让线程暂停运行,并且可以通过 InterruptedException 异常来响应中断,若在睡眠期间线程被中断,会抛出该异常并可以在异常处理中做相应操作。
常见的设计模式,设计模式知道哪些
常见的设计模式有很多,以下是一些主要的设计模式介绍:
创建型设计模式
- 单例模式:确保一个类只有一个实例,并提供一个全局访问点来获取这个实例。比如在整个应用程序中,数据库连接对象通常可以设计为单例模式,因为只需要一个连接实例就能满足与数据库交互的需求,避免多次创建和销毁数据库连接带来的资源浪费和性能损耗。它有多种实现方式,如饿汉式、懒汉式、双重检查锁定、静态内部类、枚举等实现形式,每种方式在创建时机、线程安全性等方面有不同特点。
- 工厂模式:工厂模式分为简单工厂模式、工厂方法模式和抽象工厂模式。简单工厂模式是将对象的创建和使用分离,通过一个工厂类来根据不同的条件创建不同类型的对象,比如在一个图形绘制系统中,简单工厂可以根据传入的参数创建不同的图形对象(圆形、矩形等)。工厂方法模式是在简单工厂模式的基础上,将工厂方法抽象成抽象方法,由具体的子类去实现,这样更符合开闭原则,方便扩展新的产品对象。抽象工厂模式则是用于创建一系列相关的产品对象,比如在游戏开发中,不同的游戏场景可能需要创建不同的角色、道具、地图等相关对象,抽象工厂可以负责统一创建这些相关的产品组,提高了代码的可维护性和可扩展性。
- 建造者模式:用于将一个复杂对象的构建过程和它的表示分离,使得同样的构建过程可以创建不同的表示。例如在创建一个电脑对象时,电脑有很多组件(CPU、内存、硬盘等),建造者模式可以先分别设置各个组件,最后通过一个统一的方法构建出完整的电脑对象,并且可以通过不同的建造者来构建出不同配置的电脑,方便灵活地创建复杂对象。
结构型设计模式
- 代理模式:为其他对象提供一种代理以控制对这个对象的访问。常见的有静态代理和动态代理。静态代理是在编译时就确定了代理类和被代理类的关系,比如在一个租房的场景中,租客不直接找房东租房,而是通过房产中介(代理)来租房,房产中介可以在租房过程中添加一些额外的服务或者进行一些验证等操作。动态代理则是在运行时动态地生成代理类,在 Java 中可以通过反射等机制实现,常用于 AOP(面向切面编程)中,对方法的调用进行拦截和增强等操作。
- 装饰器模式:允许向一个现有的对象添加新的功能,同时又不改变其结构。比如给一杯咖啡添加不同的配料(糖、牛奶等),每添加一种配料就相当于用一个装饰器对原来的咖啡对象进行装饰,增加了新的功能(改变了口味等),而且可以动态地组合多种装饰器来实现不同的功能扩展,与继承相比,它更加灵活,不会像继承那样导致类的层级过多变得复杂。
- 适配器模式:将一个类的接口转换成客户希望的另一个接口,使得原本由于接口不兼容而不能一起工作的类可以一起工作。例如在一个新的系统中要使用老系统的某个功能模块,但老系统的接口不符合新系统的调用要求,这时可以通过适配器模式来创建一个适配器类,将老系统的接口适配成新系统能调用的接口,实现功能的复用和系统的整合。
行为型设计模式
- 观察者模式:定义了一种一对多的依赖关系,让多个观察者对象同时监听某一个主题对象,当主题对象的状态发生变化时,会通知所有的观察者对象,使它们能够自动更新自己的状态。比如在股票市场中,股票价格就是主题对象,多个股民(观察者)关注着股票价格,当股票价格发生变化时,会通知到各个股民,股民可以根据价格变化来决定自己的买卖操作,提高了对象之间的交互性和耦合性。
- 策略模式:定义了一系列的算法,将每一个算法封装起来,并且使它们之间可以相互替换。比如在电商平台中,有不同的折扣策略(满减、打折、赠品等),可以将每种折扣策略封装成一个具体的类,然后根据不同的情况选择使用不同的策略来计算商品的最终价格,这样方便切换和扩展不同的算法,提高了代码的灵活性和可维护性。
- 模板方法模式:在一个方法中定义了一个算法的骨架,而将一些具体的步骤延迟到子类中去实现。例如在制作饮料的过程中,有一套固定的流程(准备原料、混合搅拌、装杯等),可以把这个流程定义在一个抽象的模板方法中,然后不同的饮料(咖啡、茶等)子类可以根据自己的特点去实现具体的步骤,既保证了整体流程的一致性,又能实现具体细节的差异化,便于代码的复用和扩展。
介绍了单例的五种实现
饿汉式单例
饿汉式单例是在类加载的时候就创建好单例对象实例,这种方式比较简单直接,能够保证线程安全,因为类加载过程是由虚拟机来保证线程安全的。示例代码如下:
public class EagerSingleton {
private static final EagerSingleton instance = new EagerSingleton();
private EagerSingleton() {}
public static EagerSingleton getInstance() {
return instance;
}
}
在上述代码中,instance对象在类加载时就被初始化,后续通过getInstance方法获取时,直接返回这个已经创建好的实例,由于类加载只有一次,所以能确保整个应用中只有这一个实例存在。不过它的缺点是,如果这个单例对象创建比较耗时或者占用较多资源,而在应用程序中可能又不一定会用到这个单例对象,就会造成资源的过早占用和浪费。
懒汉式单例
懒汉式单例是在第一次调用getInstance方法时才创建单例对象实例,这样可以避免资源的过早占用。但如果不做额外的线程安全处理,它在多线程环境下是不安全的,因为可能会有多个线程同时进入创建实例的代码块,导致创建多个实例。示例代码如下:
public class LazySingleton {
private static LazySingleton instance;
private LazySingleton() {}
public static LazySingleton getInstance() {
if (instance == null) {
instance = new LazySingleton();
}
return instance;
}
}
为了使其在多线程环境下安全,可以通过添加synchronized关键字等同步机制来保证同一时刻只有一个线程能创建实例,不过这样又会带来一定的性能开销,尤其在高并发情况下影响效率。
双重检查锁定单例
双重检查锁定(DCL,Double Checked Locking)单例模式是在懒汉式的基础上进行改进,既想要实现延迟加载,又想尽量减少同步带来的性能损耗。示例代码如下:
public class DoubleCheckedLockingSingleton {
private static volatile DoubleCheckedLockingSingleton instance;
private DoubleCheckedLockingSingleton() {}
public static DoubleCheckedLockingSingleton getInstance() {
if (instance == null) {
synchronized (DoubleCheckedLockingSingleton.class) {
if (instance == null) {
instance = new DoubleCheckedLockingSingleton();
}
}
}
return instance;
}
}
这里使用了两次if (instance == null)检查,第一次是为了避免不必要的同步开销,只有当实例还没创建时才进入同步块,第二次在同步块内再次检查是防止在进入同步块前已经有其他线程创建好了实例的情况。同时使用volatile关键字来禁止指令重排序,确保对象在完全初始化后才被其他线程使用,保证了线程安全和延迟加载的特性。
静态内部类单例
静态内部类单例利用了类加载机制来保证线程安全以及实现延迟加载。示例代码如下:
public class StaticInnerClassSingleton {
private StaticInnerClassSingleton() {}
private static class SingletonHolder {
private static final StaticInnerClassSingleton instance = new StaticInnerClassSingleton();
}
public static StaticInnerClassSingleton getInstance() {
return SingletonHolder.instance;
}
}
在外部类被加载时,并不会立即加载内部类SingletonHolder,只有当调用getInstance方法时,才会触发内部类的加载,而内部类加载时会创建单例对象实例,由于类加载过程是线程安全的,所以能保证整个应用只有一个实例,并且实现了延迟加载,避免了资源过早占用,是一种比较推荐的单例实现方式。
枚举单例
枚举单例是通过 Java 的枚举类型来实现单例模式,它不仅能够保证线程安全,还能防止通过反射等机制来破坏单例,示例代码如下:
public enum EnumSingleton {
INSTANCE;
// 可以在这里定义单例对象相关的方法和属性等
public void doSomething() {
// 具体的业务逻辑
}
}
在代码中,INSTANCE就是唯一的单例实例,通过EnumSingleton.INSTANCE就可以访问这个单例对象,它利用了枚举类型本身的特性来保证单例的唯一性和线程安全性,同时在语法上也更加简洁明了,并且在面对复杂的反射、序列化等场景时依然能保持单例的正确性。
手写单例的静态内部类实现
以下是单例的静态内部类实现方式的代码示例:
public class Singleton {
// 私有构造函数,防止外部通过new关键字创建实例
private Singleton() {}
// 静态内部类,在内部类中创建单例对象实例
private static class SingletonHolder {
private static final Singleton singleton = new Singleton();
}
// 对外提供获取单例实例的方法
public static Singleton getInstance() {
return SingletonHolder.singleton;
}
}
在上述代码中:
首先,将单例类的构造函数定义为private,这样就限制了外部类无法通过new关键字来创建该类的实例,保证了单例对象只能在类内部进行创建。
然后,创建了一个静态内部类SingletonHolder,在这个内部类中定义了一个private static final修饰的单例对象singleton,并且进行了初始化。由于静态内部类的加载机制特点,只有当外部类调用getInstance方法时,才会触发内部类的加载,进而创建这个单例对象实例。
最后,通过getInstance方法来对外提供获取单例实例的途径,外部类调用该方法时,就会获取到内部类中已经创建好的那个唯一的单例对象,整个过程利用了类加载的线程安全性,既实现了延迟加载,避免了不必要的资源过早占用。