写这篇文章的起因很简单:我最近在帮团队做技术面试复盘,发现不少候选人写得出Spring Boot的服务,却说不清ArrayList和LinkedList到底该选哪个;能一口气背出HashMap的put流程,但一追问“为什么链表长度超过8就要转红黑树”就卡壳。而这些问题归根到底,都指向同一个基础——Java基础数据结构。
如果你正在准备Java面试,或者刚写完Java基础语法、准备进入集合框架这块,那“基础数据结构”就是绕不开的一座山。它是“java八股文”里最大的一块,也是日常开发里最容易被忽视、却最能拉开水平的知识点。这篇博文我打算从面试和实战两个角度,把数组、链表、栈、队列、哈希表、树、堆这些基础数据结构在Java里的形态、实现逻辑、选型依据、高频考题全部盘一遍,尽量用大白话讲清楚,再给出一份可以直接照着练的代码和踩坑记录,希望能给正要复习的人一条清晰的路线。
1. 整体设计与思路拆解
1.1 为什么说数据结构是Java的必修课
很多初学者容易陷入一个误区:Java最重要的不是框架吗?Spring Boot一把梭,MyBatis生成器一拖,CRUD就完事了,还要学数据结构干嘛?这个想法我太熟了,因为我刚开始工作时也是这么想的,直到踩了几个性能问题的坑才翻回头补课。
实际上,框架解决的是“怎么组织代码”的问题,数据结构解决的是“怎么组织数据”的问题。数据库查出来的100万行记录要放到内存里做聚合,怎么存才能快?定时任务要把一批待处理的任务按优先级排队,用什么容器?用户请求过来要做接口幂等,需要短时间内判断某个订单号是否已存在,用什么结构最省时间?这些问题没有框架能替你回答,全靠数据结构功底。
在Java生态里,数据结构的基础性体现得非常直接:集合框架(Java Collections Framework)本身就是数据结构的工程实现。你天天用的ArrayList是动态数组,LinkedList是双向链表,HashMap是数组加链表加红黑树,TreeMap是红黑树,PriorityQueue是堆。理解了数据结构,等于把JDK源码的骨架看穿了一半,面试时讲源码、平时做性能调优,心里都有底。
从学习路线上看,数据结构还是承接语法和算法的一座桥。学完循环、数组、类,下一步自然要接触集合;而排序、查找、递归这些算法,全都依赖数据结构来承载。没有这座桥,你看算法的题解就像看天书。这也是为什么几乎所有Java学习路线图都把“集合框架与数据结构”放在并发编程和JVM之前。
1.2 从数据结构角度重新认识集合框架
我最推荐的学习方式,是先抛开Java语法,把数据结构本身当成一门“逻辑课”来学,然后再回来看JDK源码,把每个类对应到具体的数据结构上。思路清晰了,代码只是实现细节而已。
按大类分,常见基础数据结构可以归成四类。第一类是线性结构,包括数组、链表、栈、队列,特点是数据排成一列,有前驱和后继;第二类是树形结构,包括二叉树、二叉搜索树、红黑树、堆,特点是分叉组织,适合做查找和排序;第三类是散列结构,代表就是哈希表,用哈希函数把键映射到桶里,追求平均O(1)的读写;第四类是图结构,Java里没有内置图,但实际工程里依赖图的地方并不少,比如任务编排的有向无环图。
把这四类映射到Java集合框架,对应关系就很清晰了:
| 数据结构 | Java接口/实现类 | 底层实现 |
|---|---|---|
| 动态数组 | List : ArrayList | Object[]数组 |
| 双向链表 | List : LinkedList | Node前后指针链 |
| 栈 | Deque : ArrayDeque | 循环数组 |
| 队列 | Queue : LinkedList/ArrayDeque | 链表或循环数组 |
| 哈希表 | Map : HashMap / Set : HashSet | 数组+链表+红黑树 |
| 有序映射 | Map : TreeMap | 红黑树 |
| 优先队列 | Queue : PriorityQueue | 二叉堆 |
| 有序集合 | Set : TreeSet | 红黑树 |
这份表建议直接刻在脑子里。面试问“HashSet底层是什么”,答案不是“一个不允许重复元素的集合”,而是“底层就是HashMap,把元素放在key的位置,value统一放一个空对象”。这样回答,面试官就知道你是真的懂,而不是背概念。
1.3 选型思路:业务场景决定结构,而不是习惯决定结构
很多开发写代码选容器全凭习惯:列表一律ArrayList,去重一律HashSet,搞不定就LinkedHashMap。习惯不是不行,但至少要清楚自己为什么这么选,以及代价是什么。我把常用选择场景总结成几条经验,供你参考。
如果只做按索引访问,几乎没有增删操作,选ArrayList。它底层是连续数组,CPU缓存友好,随机访问O(1),遍历速度在大部分场景下吊打LinkedList。如果数据在头部或中间频繁插入删除,才有理由考虑LinkedList,但即便如此我也建议你先想想能不能用ArrayList加倒序处理来替代,因为LinkedList的节点分散在堆内存,遍历时缓存命中率很低,实际性能往往没有想象中好。
如果要做键值映射,默认优先用HashMap。它单线程下综合性能极好,但要注意它不是有序的。如果需要按插入顺序遍历,用LinkedHashMap;需要按键排序,用TreeMap;需要线程安全,用ConcurrentHashMap而不是Hashtable,Hashtable整表加锁,并发度太低。
如果要做栈或队列,优先选择ArrayDeque而不是Stack或LinkedList。Java官方文档里都建议用ArrayDeque替代Stack,因为Stack继承Vector,所有方法都加锁,性能差而且遗留风格较重;LinkedList虽然也实现了Deque,但节点分散加上频繁扩容,不如ArrayDeque的循环数组干净利落。PriorityQueue则适合“每次都要取最大或最小”的场景,比如定时任务的优先调度、TopK问题。
2. 核心细节解析与实操要点
2.1 数组:所有容器模型的起点
数组是Java里最基础的数据结构,其他大部分结构都是在数组上演化出来的。它的核心特点有两个:一是内存连续,二是一经创建长度固定。
内存连续带来的最大优势是随机访问效率高。数组通过下标找元素的原理是纯地址计算:首地址加上下标乘以元素大小,一步算出目标位置,所以访问任意元素都是O(1)。这也是ArrayList读多写少的场景下特别快的原因。但连续的代价是插入和删除麻烦,在中间插一个元素,后面的所有元素都要往后挪,删除则是往前挪,时间复杂度O(n)。
长度固定这个特点,决定了动态数组必须实现扩容。以ArrayList为例,每次add发现数组满了,会新创建一个原来1.5倍的数组,再把旧数据用System.arraycopy搬过去。这里有个很有价值的细节:如果你能预估数据量,最好在构造时传入初始容量,比如new ArrayList<>(10000),避免频繁扩容带来的数组复制开销。我在做大批量数据处理时就习惯先估算容量再建列表,实测能省不少时间。
数组在Java里还有一个容易忽略的应用:多维数组其实是一维数组的嵌套。int[][]在内存里是“数组的数组”,每一行是独立的一维数组对象,所以行数可以参差不齐,这就是“锯齿数组”。面试偶尔会问,能答出来会加分。
2.2 链表:非连续存储的典型代表
链表和数组正好站在对立面:不要求内存连续,每个节点包含数据和指向下一个节点的引用,通过指针把这些节点串起来。Java里LinkedList是双向链表,节点内部有prev和next两个指针。
链表的优势在插入和删除。只要拿到了目标节点的引用,理论上插入删除都是O(1),因为你只需要改指针,不需要搬动其他元素。但这个优势有个前置条件——你得先找到那个节点。LinkedList在按索引访问时要从头或从尾逐个遍历,时间复杂度O(n),所以它“插入快”只在“已经定位到位置”的前提下成立,否则定位成本分分钟抵消插入优势。
链表的劣势不容忽视:每个节点都要额外存两个指针,内存开销比数组大;节点散落在堆内存各处,遍历时CPU缓存命中率低,实际速度往往不如数组。我在一次数据量5万左右的链表遍历测试里,LinkedList比ArrayList慢了将近三倍,这还是在无锁单线程环境下。所以你看到网上有些“面试八股”说LinkedList插入快,别全信,要会分场景批判。
链表也是很多高级结构的基石。HashMap在哈希冲突时用链表挂载数据,红黑树的旋转操作也会借助类似指针的思路。建议初学者至少能手写一个单链表节点的插入和删除,这对理解指针、引用和内存模型都很有帮助。
2.3 栈与队列:两个被低估的“小结构”
栈和队列在Java集合框架里没有独立的类,它们是以接口形式存在的:Deque接口同时定义了栈和双端队列的操作。让我重点说一句:别再new Stack了,用ArrayDeque。
栈的特点是后进先出(LIFO),典型场景是函数调用栈、括号匹配、表达式求值、撤销操作。Java的JVM虚拟机栈本身就是栈结构,所以“栈”这个概念你会在JVM里再次遇到。用ArrayDeque做栈时,push对应压栈,pop对应出栈,peek看栈顶。它内部用循环数组实现,扩容均摊后性能很好。
队列的特点是先进先出(FIFO),典型场景是任务排队、消息队列的生产消费、BFS广度优先搜索。ArrayDeque实现队列时,offer入队、poll出队。如果是需要线程安全的生产消费场景,我一般用LinkedBlockingQueue或ArrayBlockingQueue,那是并发包里的阻塞队列,底层逻辑仍然离不开链表或数组。
栈和队列的正确使用,在算法题里特别关键。我辅导过的不少人写BFS就卡在“不知道该用队列”,写括号匹配卡在“不知道用栈”。这两个结构虽然简单,但它们是很多算法模板的骨架。建议你把Deque接口的常用方法逐个敲一遍,搞清楚add/offer、remove/poll、element/peek这三组方法的区别:前一组失败抛异常,后一组返回特殊值。
2.4 哈希表:HashMap底层逻辑深度拆解
HashMap是整个Java集合框架里最值得深挖的类,没有之一。面试考它,从“数组+链表+红黑树”这句能延伸到无限深度。我把它的核心逻辑拆成三段讲。
第一段是hash过程。put时,会先对key的hashCode做一次扰动,让高位也参与低位运算,减少哈希碰撞概率,然后通过hash & (len-1)得到桶的数组下标。这里有个细节:数组长度是2的幂时,取模可以用位运算代替,效率更高,所以HashMap要求容量必须是2的幂,即使你构造函数传了一个不是2的幂的容量,它也会帮你向上取整成最近的2的幂。
第二段是put流程。算出下标后,如果数组那个位置是空的,直接新建节点放入;如果不空,说明发生了哈希碰撞,此时会遍历该桶下的链表,如果找到了key相同的节点就覆盖value,否则在链表尾部插入新节点。JDK8之后采用尾插法,主要是为了解决JDK7头插法在并发扩容时可能产生环形链表的问题。当链表长度超过8,并且数组容量大于等于64时,链表会转成红黑树,把查找复杂度从O(n)降到O(log n)。
第三段是扩容机制。默认负载因子是0.75,意思是当元素个数超过容量的75%时,触发扩容,新容量是旧容量的两倍。0.75这个值是空间和时间的一个折中:太小浪费空间,太大容易频繁冲突。扩容会重新计算每个元素的位置,所以代价很高,这也是为什么我前面建议预估容量。多线程环境下,HashMap的put操作会导致数据覆盖,甚至JDK8里虽然没有了环形链表的死循环问题,但数据丢失、size不准确等问题依然存在,所以并发场景必须用ConcurrentHashMap。
2.5 树与堆:从二叉树到红黑树
树结构在Java里最直接的体现是TreeMap和TreeSet,它们底层是红黑树。红黑树是一种自平衡的二叉搜索树,能保证最坏情况下增删查都是O(log n)。为什么需要“自平衡”?因为普通的二叉搜索树如果插入顺序恰好有序,会退化成链表,查询变成O(n),红黑树通过节点颜色约束和旋转操作避免这种退化。
面试官如果追问“红黑树性质”,你要能背出这几条:节点非红即黑;根节点是黑的;叶节点(NIL)是黑的;红色节点的子节点必须是黑的,不能出现连续红节点;从任一节点到其每个叶子的所有路径都包含相同数目的黑节点。最后一条保证了最长路径不会超过最短路径的两倍,这是它“近似平衡”的本质。背完性质,最好还能说一句“Java的TreeMap在插入删除后通过左旋右旋和变色来修复平衡”,这句话说明你深入过源码而不是背题。
堆在Java里对应PriorityQueue,底层是二叉堆(通常是最小堆)。它的应用场景是“动态取极值”:每次加入或删除元素后,都能用O(log n)拿到最小或最大元素。典型例子是求海量数据里的TopK,维护一个大小为K的最小堆,遍历数据时,如果当前元素比堆顶大就移除堆顶、插入新元素,最后堆里就是最大的K个。这套思路在实时排行榜、任务优先级调度里都很实用。要注意PriorityQueue默认是小顶堆,想用大顶堆需要传入Comparator.reverseOrder()。
3. 实操过程与核心环节实现
3.1 手写一个单链表,把引用机制吃透
理论盘完之后,必须动手。很多人觉得“手写链表”是学生时代的事,但面试手撕代码时它出现的频率非常高。我建议从单链表的节点插入和删除开始练习,这段代码能帮你彻底理解Java的引用传递。
public class MyLinkedList { private Node head; private int size; private static class Node { int val; Node next; Node(int val) { this.val = val; } } // 头插法 public void addFirst(int val) { Node newNode = new Node(val); newNode.next = head; head = newNode; size++; } // 在第 index 个位置插入 public void add(int index, int val) { if (index < 0 || index > size) { throw new IndexOutOfBoundsException("index: " + index); } if (index == 0) { addFirst(val); return; } Node prev = head; for (int i = 0; i < index - 1; i++) { prev = prev.next; } Node newNode = new Node(val); newNode.next = prev.next; prev.next = newNode; size++; } // 删除第一个值为 val 的节点 public boolean remove(int val) { if (head == null) { return false; } if (head.val == val) { head = head.next; size--; return true; } Node prev = head; while (prev.next != null) { if (prev.next.val == val) { prev.next = prev.next.next; size--; return true; } prev = prev.next; } return false; } }这段代码里的关键点有两个。一是插入时“先接后断”的顺序:newNode.next = prev.next 必须先执行,再把 prev.next 指向 newNode。如果你把顺序反了,会丢失后面的链表,这是最常见的Bug之一。二是删除时要找前驱节点而不是当前节点,因为单链表没有回头指针。理解了这两点,链表的代码基本就通了。
写完单链表之后,我建议你对照着看LinkedList的源码,看它是怎么用first和last两个哨兵节点实现双向链表的。哨兵节点(dummy node)在链表操作里是很好的优化手段,可以省去大量“判断是否为空”的分支逻辑,面试时提到这点会加分。
3.2 排序算法到底怎么考,怎么用
热搜词里“java排序”“冒泡排序java”出现频率很高,可见排序是基础数据结构绕不开的实践场景。排序算法本身是算法课的内容,但它依赖数组、链表这种结构来承载,所以面试经常把两者放在一起考。
先把最常考的三种手写排序练熟,我给出可以直接跑的版本。
// 冒泡排序,稳定,O(n^2) public static void bubbleSort(int[] arr) { int n = arr.length; for (int i = 0; i < n - 1; i++) { boolean swapped = false; for (int j = 0; j < n - 1 - i; j++) { if (arr[j] > arr[j + 1]) { int tmp = arr[j]; arr[j] = arr[j + 1]; arr[j + 1] = tmp; swapped = true; } } if (!swapped) break; // 优化:本轮没有交换说明已有序 } } // 插入排序,稳定,适合近乎有序的数据,O(n^2) public static void insertionSort(int[] arr) { for (int i = 1; i < arr.length; i++) { int cur = arr[i]; int j = i - 1; while (j >= 0 && arr[j] > cur) { arr[j + 1] = arr[j]; j--; } arr[j + 1] = cur; } } // 快速排序,不稳定,平均O(n log n) public static void quickSort(int[] arr, int left, int right) { if (left >= right) return; int pivot = arr[left]; int i = left; int j = right; while (i < j) { while (i < j && arr[j] >= pivot) j--; arr[i] = arr[j]; while (i < j && arr[i] <= pivot) i++; arr[j] = arr[i]; } arr[i] = pivot; quickSort(arr, left, i - 1); quickSort(arr, i + 1, right); }面试时除了手写,还要能回答两个问题:稳定性是什么意思,哪些算法稳定哪些不稳定。冒泡排序和插入排序是稳定的;快速排序和堆排序不稳定,因为交换时可能把相等元素的相对顺序打乱。还有一个细节很多人忽略:Arrays.sort底层用的并不是单一的排序算法。对基本类型数组,它用双轴快速排序(DualPivotQuicksort);对对象数组,它用TimSort——一种改进的归并排序,稳定且能利用数据原有的有序性。所以如果你面试时说“Arrays.sort用的是快速排序”,严格讲只对了一半,对象数组用的是TimSort。
3.3 不只是面试:数据结构在业务代码里的落地
说句实在话,日常业务开发里不太可能让你手写红黑树,但用好数据结构能实打实解决性能问题。我举三个真实场景。
第一个场景,接口幂等判断。订单系统要判断一个订单号是不是已经处理过,如果把所有订单号放进ArrayList再contains,数据量一上来就是O(n)扫描,接口直接超时。正确的做法是把已处理订单号放进HashSet或基于ConcurrentHashMap实现的并发Set,查询是O(1)。这就是哈希表在业务里的典型落地。
第二个场景,“最近被使用”的缓存淘汰。要记录用户最近浏览的10个商品,并且新浏览时要移除最旧的,同时要快速判断某个商品是否已存在。这个需求用LinkedHashMap最合适,它继承HashMap且维护了插入顺序,重写removeEldestEntry方法还能实现简单的LRU缓存,十几行代码搞定。
第三个场景,多关键词搜索结果的合并排序。比如在商城搜索里,多个条件各自查到一批ID,最后要做去重、交集、排序。这时候用TreeSet可以保证元素有序并且自动去重,比手动排序加大循环省事得多。把这些容器用对了,很多业务代码不仅更短,而且数据量大时性能差别肉眼可见。
3.4 常用工具类与库函数盘点
Java的java.util包其实已经把基础数据结构的常用操作封装得很好了,关键是要知道有哪些工具,以及它们适合什么场景。我用一张表把高频的库函数整理出来,方便你快速查阅。
| 工具类/方法 | 作用 | 注意事项 |
|---|---|---|
| Arrays.sort(T[] a) | 数组排序 | 基本类型用双轴快排,对象用TimSort |
| Arrays.binarySearch(int[] a, key) | 二分查找 | 数组必须事先有序,否则结果无意义 |
| Arrays.copyOf / System.arraycopy | 数组复制 | 前者适合扩缩容,后者可复制指定范围 |
| Collections.reverse(List list) | 反转列表 | 对ArrayList和LinkedList均有效 |
| Collections.shuffle(List list) | 随机打乱 | 可用于抽奖、随机出题 |
| Collections.min / max | 求极值 | 集合需要实现Comparable或传Comparator |
| Collections.frequency(coll, obj) | 统计出现次数 | 本质是遍历,频繁调用注意性能 |
| Collections.synchronizedList(list) | 包装线程安全列表 | 不建议新代码使用,优先并发集合 |
| Collections.unmodifiableList(list) | 返回只读视图 | 修改会抛异常,适合防止意外改动 |
还有一点容易踩坑:Arrays.asList返回的List是定长的,它直接包装原数组,不能调用add和remove。很多人把Arrays.asList的结果当ArrayList用,一调add就抛UnsupportedOperationException。如果需要真正的可变列表,要写成new ArrayList<>(Arrays.asList(...))。
Comparator和Comparable也是基础数据结构里绕不开的工具。尤其是lambda出现之后,自定义排序变得非常简洁:list.sort((a, b) -> a.getAge() - b.getAge())。不过要注意int相减可能溢出,最稳妥的写法是Integer.compare(a.getAge(), b.getAge()),或者Comparator.comparing(User::getAge)。这些细节虽然小,但面试和代码评审里很加分。
4. 常见问题与排查技巧实录
4.1 面试高频问题速查表
我常跟学员说,Java面试里关于数据结构的题问来问去就那些,但你得自己会分辨深浅。这里我整理一份速查表,每一行都建议你能展开讲到至少三句话。
| 高频问题 | 核心要点 | 常见误区 |
|---|---|---|
| ArrayList和LinkedList区别 | 底层数组vs双向链表;随机访问O(1) vs O(n);插入删除的相对性 | 盲目认为LinkedList插入一定快 |
| HashMap底层原理 | 数组+链表+红黑树;hash扰动;尾插法;负载因子0.75 | 只背概念,不会画put流程 |
| HashMap和Hashtable区别 | HashMap允许null键值、非线程安全;Hashtable线程安全但性能差 | 以为Hashtable可用,实际上可用ConcurrentHashMap |
| HashMap为什么用红黑树 | 链表太长查找变慢;树化阈值8;退化阈值6 | 记不住阈值和条件(容量>=64) |
| HashSet底层是什么 | 包装HashMap,value为固定空对象 | 以为HashSet独有一套存储逻辑 |
| TreeMap怎么保证有序 | 红黑树中序遍历;key需实现Comparable | 不知道Comparator和Comparable区别 |
| 栈和队列用哪个类 | 推荐ArrayDeque而非Stack/LinkedList | 还在写new Stack() |
| HashMap初始容量为什么是2的幂 | 便于位运算取模;扩容方便 | 不理解与扩容的联动 |
| Iterator和Iterable区别 | Iterable返回迭代器;迭代器遍历时不能直接list.remove | 遍历时修改集合抛ConcurrentModificationException |
| 哪些排序稳定 | 冒泡、插入、归并稳定;快排、堆排不稳定 | 回答时漏掉“比较器相等时顺序保持”这个前提 |
真正准备时,不要满足于把答案背下来,我建议每个问题都打开IDE写一个Demo验证。比如写一段代码证明ArrayList中间插入比LinkedList慢还是快,写一段代码观察HashMap扩容前后的容量变化,这些实验做一遍,记忆深度比背十遍都强。
4.2 开发中最常见的五个踩坑点
踩坑是学习的捷径。我在带新人和自己写代码的过程中,总结了几个基础数据结构最常见的问题,每条都来自真实事故,希望你别再踩一遍。
坑一:遍历时直接删除集合元素。经典的错误写法是在for循环里调用list.remove(i),这样会导致元素移位,漏删或越界。推荐用迭代器的Iterator.remove(),或者Java8以后的list.removeIf(predicate),一行搞定。HashMap同理,要遍历删除就map.entrySet().removeIf(...)。
坑二:把数组当集合用,把集合当数组用。对基本类型数组调用Arrays.asList时,泛型推断会把整个数组当成一个元素,得到的是List<int[]>而不是List 。这个问题的原因是Java泛型不支持基本类型。处理办法是用包装类型数组,或者用Arrays.stream(arr).boxed().collect(Collectors.toList())。
坑三:用==比较Integer。在-128到127之间的Integer会走缓存池,用==比较返回true,超过这个范围就是false。判断相等必须用equals。这个坑和数据结构本身无关,但极常见,尤其在从HashMap里取出的Integer做比较时容易中招。
坑四:LinkedList当队列用还觉得它快。LinkedList确实实现了Queue接口,但做高频入队出队时,它的节点频繁创建和回收,GC压力大,性能不如ArrayDeque。如果还要考虑线程安全,直接上ConcurrentLinkedQueue。选型时不要只看“它实现了哪个接口”,要看底层结构适合什么操作模式。
坑五:自定义对象放进HashSet/HashMap不改hashCode。如果你不重写hashCode,两个“逻辑上相等”的对象会得到不同的哈希值,导致Set里出现重复元素,Map里get不到之前put的值。重写hashCode的同时必须重写equals,并在对象作为key期间不要修改影响hashCode的字段。这个错误非常隐蔽,出了问题还很难排查。
4.3 用好数据结构优化性能:一个完整排查案例
分享一个我做过的真实性能排查,非常典型。一个统计报表接口,接收一批用户ID,要返回这些用户的订单汇总信息。上线后发现,用户ID数量到几千时接口耗时飙到5秒以上。
初步排查发现,代码里有这么一段逻辑:遍历用户ID,对每个ID都调用一次订单表查询,然后把结果存入ArrayList。这个写法问题很明显:一条SQL查一次的N+1次查询,数据库往返都耗在网络和SQL解析上。修复方式是改批量查询,一次把几千个ID都查出来。
但修完还是慢。继续看,发现批量查询的结果被放进ArrayList后,后续要频繁判断“某个订单属于哪个用户”,代码用了一个双层循环嵌套,也就是对每个订单遍历一遍用户列表,复杂度O(n*m),几千乘几千就是几百万次比较,能不慢吗。
修复方式就是把这个用户列表放进HashMap,以userId为key,用户对象为value,然后用订单里的userId直接get,复杂度降到O(m)。就这么一个小改动,接口耗时从5秒降到了200毫秒以内。这个案例说明,有时候性能瓶颈不是SQL也不是框架,就是容器选错、复杂度算错。
这个案例每次讲给学员听,我都强调一句话:写代码之前先算一下这个操作的复杂度,O(n)和O(n^2)在小数据量时看不出来,数据量一大就是天壤之别。HashMap、HashSet这类哈希结构,用空间换时间,是日常优化性价比最高的手段之一。
5. 一条务实的学习路线和踩坑心得
5.1 从零到面试通过,我推荐这样学
如果你现在刚开始准备Java基础数据结构,我建议按下面这个顺序走,每步都配合动手写代码。
第一步,先把数组彻底搞懂。数组是后面所有结构的地基。你可以写一个动态数组的模拟类,实现自动扩容,这个练习能帮你理解ArrayList源码里最关键的那部分逻辑。
第二步,学链表。手写一遍单链表的增删,再看LinkedList源码,理解双向链表。链表是后面栈、队列、哈希表、树的公共基础,指针的“指向”概念一定要亲手敲出来才有感觉。
第三步,学栈和队列。用Deque接口写几个经典题:有效的括号、用队列实现栈、用栈实现队列。这几个题做完,你对两个结构的特性就了然于胸。
第四步,学哈希表。这个阶段不建议一上来就啃HashMap源码,而是先用HashMap解决一些业务模拟问题,比如统计词频、两数之和。之后再逐步深入源码,看hash扰动、put流程、扩容。
第五步,学树和堆。先学二叉树遍历(前序、中序、后序、层序),然后了解二叉搜索树,最后理解红黑树的约束条件。堆的练习可以从PriorityQueue开始,做几道TopK和合并K个有序链表的题。
整个流程走下来,我的建议是给自己定一个期限,三到四周比较合理。不要追求一次吃透,数据结构是螺旋上升的,先建立框架,再逐步加深。每次做算法题碰到不熟悉的结构,就回头翻对应的源码,反复几次自然就熟了。
5.2 我在实际学习中的几个体会
最后分享几个这些年我切身感受到的体会,都是很难从课本里学到的。
体会最深的一点是:不要用“会不会调用API”代替“懂不懂底层”。你调了三年HashMap,不如把它的put流程完整读一遍。面对线上棘手问题的时候,能快速想到用Heap做优先级、用Hash做去重、用Tree做排序,靠的是底层原理,不是API熟练度。
第二点是:算法题不刷不行,但刷题不等于学数据结构。数据结构是“骨架”,算法是“操作手法”。很多人陷入刷题焦虑,疯狂刷了500道,却连ArrayList扩容机制都说不清楚,这等于盖楼不打地基。建议刷题归刷题,源码阅读归源码阅读,两条腿走路。
第三点是:每个新手都会有一段“会用但不懂”的时期,不用焦虑。我当年用HashMap写业务写了快一年,才真正理解为什么它无序,为什么会有红黑树。那些你现在觉得晦涩的源码细节,随着项目经验积累,会在某一天突然豁然开朗。如果你现在正卡在某些概念上,别急,先把基本用法用熟,带着问题再回头学,效果反而更好。
基础数据结构这条路,说长不长,说短不短。把数组、链表、栈、队列、哈希表、树、堆这些结构彻底吃透,你后面学并发、学JVM、学框架源码都会轻松很多。希望这篇博文能帮你把这条路的起点铺平,剩下的,就靠你亲手敲代码去验证了。