news 2026/9/9 3:09:11

Java核心复习:从HashMap到并发锁的底层原理与实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Java核心复习:从HashMap到并发锁的底层原理与实战

最近又把 Java 捡起来系统地复习了一遍,起因是同事在群里丢了一个“很基础”的问题:为什么在HashMap里放自定义对象,重写了equals没重写hashCodeget会拿到null?本来觉得这种问题随便讲讲就过去了,结果一开口才发现,很多细节我只能说出个大概,真要讲清楚底层逻辑,舌头打结。这件事让我下定决心把 Java 的核心知识重新过一遍,也就有了这篇“复习 Java(2)”。

这篇文章不是给零基础的人讲“什么是类什么是对象”的入门教程,也不是单纯罗列面试题的八股合集。它更像是我一边翻源码、一边写 Demo、一边踩坑的记录。覆盖的范围包括环境配置与启动报错、基础语法、集合容器、反射与动态代理、Lambda 与函数式编程、并发与锁、异常排查,以及排序算法的现场手写。这些点基本上是日常工作里最高频、也最容易被问到的部分。如果你正准备跳槽、或者工作了三五年想回头查漏补缺,这篇文章应该能帮你省掉不少自己折腾的时间。

1. 为什么还要做一轮 Java 复习,这次复习什么

1.1 复习的起因:工作越用越薄,基础问题反而卡壳

Java 这个东西,特点是平时用起来太“顺手”了。Spring Boot 一把梭,业务代码写得飞快,IDE 提示补齐一切,好像完全不需要知道底层发生了什么。但一旦遇到线上问题,或者面试官往深处问,立马就露怯。

我印象很深的几个场景:一个是线上 OOM,dump 下来之后看堆栈,发现是某个全局缓存没有做容量限制,数据量大了之后直接把内存打满。这个问题的根源其实是对集合容器的内存模型理解不够,HashMap扩容是有代价的,无限往里面塞数据更是灾难。另一个是同事写 Redis 扣减库存,用redisTemplate.opsForValue().increment(key, -1),结果在并发量上来之后偶发报错,提示返回的数据类型不对。查到最后,发现是某个历史 key 被设置了 String 类型,而代码里期望的是 Long。这种问题不做知识梳理的时候根本想不到。

所以这一轮复习,我的目标很简单:把 Java 语法、集合、反射、并发、异常这些基础模块重新过一遍,每个点都争取到“能写代码验证、能讲清楚原理、能处理线上问题”的程度,而不是停留在“好像见过”的层面。

1.2 这次复习圈定的范围:从语法到容器的全景串联

为了避免复习变成东一榔头西一棒子,我提前列了一个范围清单,也推荐你按这个思路来:

  • 环境与工程基础:JDK 版本、环境变量、JVM 内存模型、常见启动报错排查。
  • 语法细节:标识符规则、运算符优先级、字符串多行写法、面向对象设计。
  • 集合容器:ArrayListHashMapConcurrentHashMap的底层实现与适用场景。
  • 进阶机制:反射、动态代理、Lambda、函数式接口。
  • 并发编程:synchronizedvolatile、锁升级、ReentrantLock、并发工具类。
  • 异常处理:异常体系、运行时异常与受检异常、实际排查思路。
  • 算法基础:冒泡排序、快速排序、二分查找等手写实现。

每个模块我都配合了代码实验和小的测试用例。比如集合那一块,我直接写了程序观察ArrayList扩容前后的capacity,翻HashMap源码确认树化阈值。这样复习下来,知识不再是离散的,而是能串成一张网。

2. 环境与启动:先把“跑起来”这件事弄明白

2.1 环境变量配置的坑:JAVA_HOME 到底该怎么设

很多老手可能觉得环境变量配置是很入门的东西,但热搜词里“java环境变量配置详细教程”常年居高不下,说明这事儿实际翻车率很高。最典型的问题就是装完 JDK 之后,在命令行里输入java -version有输出,但javac -version却提示找不到命令。

正常情况下,需要配置两个东西。一个是JAVA_HOME,指向 JDK 的安装目录,比如C:\Program Files\Java\jdk-17。另一个是PATH,在原有值的前面加上%JAVA_HOME%\bin。很多教程只让配PATH,没有强调JAVA_HOME的作用,导致后面跑 Maven、Tomcat、Android Studio 这类依赖JAVA_HOME的工具时集体报错。

注意:配完环境变量之后,一定要重新打开命令行窗口再验证。Windows 的环境变量修改不会自动刷新到已打开的终端里,这是新手最容易误判的点。

另外要确认PATH里没有其他版本 JDK 的路径干扰。有时候电脑上装了多个 JDK,比如 IDEA 自带的 JBR、Oracle JDK、OpenJDK,它们在PATH里的顺序会决定命令行到底用哪个版本。我用一个命令来排查:

where java

这个命令会列出所有能匹配到java的路径,从上到下就是实际解析顺序。如果发现第一个路径不对,直接调整PATH的排序就行。

2.2 启动即报错:NoClassDefFoundError、OOM、Lombok 编译失败

复习期间我特意收集了一波真实项目里常见的启动报错,每个都能说上几句。

第一个是uncaught exception java.lang.NoClassDefFoundError: java/applet/Applet。这个问题多出现在 JDK 9 之后的版本里,因为 JDK 9 开始模块化,java.applet模块默认不在java.se模块集合里。老项目如果还在引用java.applet.Applet,启动或者运行时就会找不到类。解决办法一般是去掉相关依赖,或者使用对应的兼容模块jdk.unsupported之类。更本质的思路是:老 API 被标记废弃甚至移除时,项目得跟着升级代码,而不是死守旧版本。

第二个是java.lang.OutOfMemoryError: insufficient memory。看到insufficient memory不要第一反应就加-Xmx。如果设置过大的堆内存,但物理内存不够,反而会启动失败。要先看清楚是堆内存不够还是堆外内存不够,用-Xms-Xmx控制堆大小,用-XX:MaxMetaspaceSize控制元空间,必要的时候用jstatjmap看实际占用。

第三个是我自己踩过的:you aren't using a compiler supported by lombok, so lombok will not work。这个报错通常发生在 Lombok 版本和 JDK 版本不匹配的时候。比如 JDK 21 刚出来时,旧版 Lombok 没适配,编译就挂。解决思路很简单:升级 Lombok 插件和依赖版本到支持当前 JDK 的版本,同时在 IDEA 里开启注解处理。这里我提醒一下,项目里的 Maven 依赖版本和 IDE 内置 Lombok 插件版本最好保持一致,不然很容易出现“命令行能编译、IDE 里报错”的诡异现象。

2.3 JVM 内存模型:不说虚的,直接看区域划分

复习 JVM 内存,我推荐先把“区域”这个概念搞清楚。JVM 内存主要分为线程共享和线程私有两块。线程共享的包括堆(Heap)和方法区(Metaspace 是方法区的实现),线程私有的包括虚拟机栈、本地方法栈、程序计数器。

堆里存的几乎全是对象实例,所以new出来的东西全在这。虚拟机栈则对应每个线程的方法调用,一个方法调用对应一个栈帧,栈帧里存着局部变量表、操作数栈、方法返回地址等。如果你看到StackOverflowError,基本就是栈帧塞满了,递归没有出口就是典型原因。

我在复习时画了一张很朴素的内存示意表,分享给你:

区域线程共享?存什么常见异常
对象实例、数组OutOfMemoryError: Java heap space
方法区/Metaspace类元信息、常量、静态变量OutOfMemoryError: Metaspace
虚拟机栈方法调用的栈帧StackOverflowError
程序计数器当前线程执行的字节码行号
本地方法栈native 方法调用StackOverflowError

这张表不用背,但排查问题时心里要有数。比如报了java heap space,那就该看堆;如果是Metaspace,那就要查是不是动态生成了大量类,比如频繁用 CGLIB 生成代理类,且没有清理。

3. 基础语法与面向对象:很多问题出在“太基础”

3.1 标识符命名规则与 Bean 序列化的命名大坑

Java 标识符命名规则面试经常考,核心就几条:只能以字母、下划线、美元符开头,不能以数字开头;命名不能是 Java 关键字;区分大小写。实际开发里更容易踩坑的是 Bean 属性名问题。

热搜词里有一条“java bean 大写字母开头的变量json时就变成小写了”,这确实是很多人遇到过的怪问题。假设你写了一个类:

public class UserInfo { private String uName; public String getuName() { return uName; } public void setuName(String uName) { this.uName = uName; } }

如果你遵循了“大写字母开头”的命名,比如字段叫UName,getter 写了getUName(),在 Jackson 序列化时,反直觉的事情就出现了:它可能会把 JSON 里的字段名解析成小写,或者出现序列化字段不一致的问题。原因在于 JavaBeans 规范里,属性名的推导规则是:getUName()对应的属性名是UName,但如果第二个字母大写,某些 JSON 库会做特殊折叠,把首字母改成小写。

这个坑的解决办法很简单:字段名一律小写开头,第二个字母不要大写,getter/setter 用 IDE 自动生成,不要手写。命名规范这种东西看起来小,但被它恶心过的人都懂。

3.2 Java 运算符与表达式:自增自减其实很容易翻车

运算符看起来没什么好复习的,但一到笔试就容易懵。最常见的莫过于i++++i的区别,以及它们在赋值表达式里的表现。

int a = 5; int b = a++; int c = ++a;

这段代码跑完之后,b是 5,a是 7,c是 7。原因在于a++先返回当前值再自增,++a先自增再返回。单看还行,但和短路运算符、三目运算符混在一起的时候,错误率就飙升。我的建议是:表达式能写清楚就写清楚,别硬凑一行“聪明代码”,尤其是if ((flag = true) || expensiveMethod())这种,看着高级,维护起来想骂人。

位运算在面试和底层源码里也经常出现,比如&|^>><<>>>。我记得复习HashMap扩容时,计算新的索引位置就用到了(n - 1) & hash,这就是一个典型场景。理解位运算不是让你天天写位操作,而是读源码时不能被卡住。

3.3 字符串多行写法与拼接性能

Java 15 之前写多行字符串是很痛苦的,得靠拼接+或者StringBuilder。后来引入了文本块(Text Block),用三个双引号"""包起来,终于可以优雅地写 SQL、写 JSON 模板了。

String sql = """ SELECT id, name FROM user WHERE status = 1 ORDER BY create_time DESC """;

文本块会自动处理缩进,公共缩进会被去掉,换行符保留。这个特性在日常写测试用例时特别好用,不用再纠结转义引号。

至于字符串拼接的性能问题,老生常谈了:循环里字符拼接,不要用+,要StringBuilder。JVM 其实会对+做优化,编译成StringBuilder.append,但并不是所有场景都能优化好,尤其是有初始值、有引用传参的情况。与其纠结 JVM 优化逻辑,不如写的时候直接规范。

3.4 面向对象:抽象类和接口,Java 8 之后边界更模糊了

面向对象四大特性——封装、继承、多态、抽象——这是面试必考,但实际应用里大家思考得很少。真正值得复习的是抽象类和接口的选择。Java 8 之后接口可以有默认方法(default method)和静态方法,这导致两者的边界变模糊了。

我的选择标准很简单:如果多个类之间是“is-a”的关系,并且有公共状态(成员变量),用抽象类;如果是“can-do”的能力约定,比如排序、序列化、监听器,用接口。现在项目里很多人倾向优先用接口,因为可以多实现,解耦更彻底,Spring 的依赖注入也喜欢基于接口。这个方向没问题,但别走到极端,有些场景抽象类能省掉大量重复代码。

4. 集合类复习:ArrayList 扩容与 HashMap 的 hash 寻址

4.1 ArrayList 底层与扩容机理

集合是 Java 日常开发里用量最大的工具,但很多人只停留在“会用”层面。我复习的时候专门翻了源码,ArrayList底层就是一个Object[]数组。默认初始容量是 10,当元素数量超过当前容量时,会进行扩容。

扩容的关键代码是grow()方法,新容量大约是旧容量的 1.5 倍,也就是oldCapacity + (oldCapacity >> 1)。然后把旧数组的元素一个个复制到新数组里。这个过程的时间复杂度是 O(n),所以如果你知道大概要存多少数据,最好在构造时直接指定初始容量:

List<String> list = new ArrayList<>(10000);

这能显著减少扩容次数。我在本地做了一个小实验,往 ArrayList 里添加 100 万条数据,不指定容量和指定容量的耗时差了不少。虽然现在的 JIT 很聪明,但预先估算容量依然是个好习惯。

4.2 HashMap 的存储结构与树化条件

HashMap是面试重灾区,而且问起来可以很深。底层的结构在 JDK 8 之后是“数组 + 链表 + 红黑树”。默认初始容量是 16,负载因子是 0.75,扩容阈值是16 * 0.75 = 12。也就是说,元素个数到 13 的时候就会触发扩容,容量翻倍到 32。

为什么负载因子是 0.75?这是一个时间与空间的折中。负载因子太大,比如 1,空间利用率高,但冲突概率变大,查询变慢;负载因子太小,比如 0.5,冲突少,但空间浪费严重。0.75 是经验上比较均衡的取值。

hash的过程也值得说:key.hashCode()得到 32 位 int,然后高 16 位和低 16 位做异或,也就是h ^ (h >>> 16)。这么做的目的是让高位的特征也能参与数组下标计算,因为 HashMap 计算下标用的是(n - 1) & hash,当 n 很小时,只有低几位参与计算,高低位异或可以分布更均匀。

链表的树化条件是:链表长度达到 8,且数组容量达到 64,此时链表转为红黑树。为什么是 8?源码注释里提到,理想情况下随机 hash 冲突达到 8 次的概率已经极低(符合泊松分布),所以 8 是一个安全阈值。反过来,红黑树的节点数降到 6 时,会退化为链表,避免树和链表的频繁切换。

4.3 并发场景下的容器选择

并发场景下,HashMap本身是线程不安全的,多线程写入会引发循环链表(JDK 7 的问题)、数据覆盖等问题。JDK 8 虽然修复了死循环,但依然不建议直接在并发环境用HashMap

正确选项是ConcurrentHashMap。它用 synchronized + CAS 保证线程安全,锁的粒度是桶(Node 数组的每个元素),而不是整个 Map,所以并发度比老的Hashtable(全程加锁)高很多。复习的时候我自己写了一个多线程并发 put 的测试,对比HashMapHashtableConcurrentHashMap,结果很直观:HashMap会丢数据,Hashtable能保证正确但吞吐最差,ConcurrentHashMap正确性和性能都最好。

5. 反射与动态代理:框架原理的底层密码

5.1 反射到底在干嘛

反射的核心能力是运行时获取类的信息、操作类的属性和方法。它是很多框架的基石,Spring 的依赖注入、MyBatis 的 Mapper 代理、Hibernate 的实体映射,底层全是反射在干活。

最基础的三板斧:获取Class对象、获取构造器创建实例、调用方法。

Class<?> clazz = Class.forName("com.example.UserService"); Object instance = clazz.getDeclaredConstructor().newInstance(); Method method = clazz.getMethod("sayHello", String.class); method.invoke(instance, "Java");

要注意的是getDeclaredMethod只能拿到本类声明的方法,getMethod才能拿到 public 的继承方法。访问私有字段或方法时,需要手动setAccessible(true),但在 JDK 17 及以后,强反射受到模块系统限制,setAccessible可能抛InaccessibleObjectException。这也是现在很多框架转向 MethodHandle、Unsafe 或者生成字节码的原因。

5.2 静态代理、JDK 动态代理与 CGLIB 的区别

代理模式是 AOP 的基础。静态代理就是手动写一个类,包装目标类,代码简单但代理类和目标类强绑定。动态代理则在运行时生成代理类。

JDK 动态代理要求目标类必须实现接口,它通过Proxy.newProxyInstance生成一个实现了指定接口的代理对象,所有方法调用都会经由InvocationHandler.invoke。如果你要代理的类没有接口,就得用 CGLIB 或 ByteBuddy 这类字节码库,它们直接生成目标类的子类。

Spring AOP 就是默认优先 JDK 动态代理,如果目标类没有接口,则自动切换 CGLIB。面试喜欢问“JDK 动态代理和 CGLIB 的区别”,记住三点就够了:JDK 代理基于接口,CGLIB 基于继承;CGLIB 不能代理 final 类和方法;JDK 代理是 JDK 原生支持,CGLIB 需要额外引入依赖。

5.3 反射和代理在项目里的实际体现

日常业务代码里直接写反射的机会不多,但理解反射能帮你读懂框架行为。我记得有一次排查一个问题:一个 Service 方法被事务注解标了,但内部this.method()调用同一个类里的另一个事务方法,事务没生效。原因就是 Spring 的声明式事务基于代理实现,this调用是直接走原始对象,没有经过代理对象,切面自然没执行。这个经典问题本质上就是没搞懂动态代理的调用链。

复习到这里的时候,我自己动手写了一个 JDK 动态代理的小例子,在invoke前后打点日志,模拟简单的 AOP 效果。写完之后,Spring 的拦截器、事务、日志切面这些概念一下就通透了。

6. Lambda、函数式接口与流式编程

6.1 Lambda 是语法糖,背后是函数式接口

Lambda 并不是什么黑魔法,它的本质是用简洁的语法实现函数式接口。所谓函数式接口,就是只包含一个抽象方法的接口,比如RunnableComparatorConsumer

Comparator<String> comparator = (a, b) -> a.length() - b.length();

编译器会根据目标类型推断参数类型。如果不写 Lambda,用匿名内部类同样能实现,只是代码更啰嗦。理解了这一点,就明白为什么 Lambda 只适用于函数式接口了。

6.2 方法引用和常用函数式接口

方法引用是 Lambda 的简写形式。如果你的 Lambda 只是调用某个已有方法,就可以用方法引用。

list.stream().map(String::toUpperCase).forEach(System.out::println);

常用函数式接口就那么几个:Function<T, R>表示一个输入一个输出,Predicate<T>表示判断真假,Consumer<T>表示消费一个参数不返回,Supplier<T>表示无参返回一个值。背熟这几个,Stream 的中间操作基本都能看懂。

6.3 流式编程的实战场景

Stream API 让我感觉 Java 在往更函数式的方向走。集合的过滤、排序、分组、求和,一行代码就能写清楚。举一个实际例子:

Map<String, Long> countMap = users.stream() .filter(u -> u.getAge() > 18) .collect(Collectors.groupingBy(User::getCity, Collectors.counting()));

这个表达式做了三件事:过滤、按城市分组、统计每个城市的人数。换成传统 for 循环,代码量至少五倍起。

但流式编程也有坑:parallelStream()在数据量大、逻辑复杂时,不一定是性能银弹,线程切换和合并结果的开销可能超过并行收益。无脑使用默认的公共 ForkJoinPool 还会影响其他任务执行。我的习惯是:数据量小用串行流,数据量大且有明确的可并行计算才用parallelStream,并配合自定义线程池。

7. 并发与锁:从 synchronized 到 AQS

7.1 synchronized 的三种用法与锁升级

synchronized是 Java 内置的锁,Java 6 之后做了很多优化,引入了偏向锁、轻量级锁、重量级锁的升级过程。

它的三种用法要分清楚:

  • 修饰实例方法:锁的是当前实例对象。
  • 修饰静态方法:锁的是当前类的 Class 对象。
  • 修饰代码块:锁的是括号里指定的对象。
public synchronized void methodA() { ... } public void methodB() { synchronized (this) { ... } }

在面试里被问到“synchronized 和 ReentrantLock 的区别”时,我会从可中断、公平锁、非阻塞获取锁、条件变量、锁粒度这几个维度同时回答。早期的推荐是:能用内置锁就用内置锁,代码简洁、不需要手动释放;需要高级功能时再用ReentrantLock。JDK 15 后,虚拟线程成熟了,很多并发场景的思考方式会变,但 synchronized 的基本功不过时。

7.2 volatile 的可见性与有序性

volatile是另一个高频考点。它有两个核心语义:保证变量在不同线程之间的可见性,也就是一个线程修改了这个变量,其他线程能立刻看到;保证有序性,禁止指令重排序。

但它不能保证原子性。经典的count++问题,即使用volatile修饰 count,依然会出现数据丢失,因为count++是“读-改-写”三步,不是原子操作。要原子递增,用AtomicInteger或者加锁。

7.3 ReentrantLock 和 AQS 的核心思想

ReentrantLock是基于 AQS(AbstractQueuedSynchronizer)实现的。AQS 的核心是一个volatile int state状态位和一个 FIFO 等待队列。获取锁就是通过 CAS 把 state 从 0 变成 1,释放锁就是把 state 从 1 变回 0。如果获取失败,线程进入队列挂起。

ReentrantLock支持公平锁和非公平锁。非公平锁是直接尝试抢一次,抢不到再排队;公平锁则是严格按队列顺序。非公平锁的吞吐量通常更高,因为减少了线程唤醒的开销,但存在“插队”情况。

复习 AQS 时不用把源码每行都背下来,重点理解 state、队列、CAS 三者之间的关系。理解了 AQS,SemaphoreCountDownLatchReentrantReadWriteLock这些工具类都能举一反三。

7.4 并发实用工具速查

平时开发里,ConcurrentHashMapCopyOnWriteArrayListThreadPoolExecutorCompletableFuture这几个工具值得专门练一下。CompletableFuture在做异步编排时特别方便,可以用thenApplythenCombineexceptionally把多个异步任务串起来,比起手动维护Future和阻塞等待,代码清爽很多。

8. 异常处理与排查实录

8.1 异常体系快速梳理

Java 的异常体系主要分两种:受检异常(Checked Exception)和运行时异常(RuntimeException)。受检异常编译器强制要求处理,比如IOExceptionSQLException;运行时异常不强制处理,比如NullPointerExceptionArrayIndexOutOfBoundsException

我见过太多项目把 Exception 全部往上抛,最后在 Controller 层才统一接住。这种做法不是不行,但异常信息容易丢。更好的做法是:在合适的层级捕获,补充上下文信息后重新抛出业务异常,避免底层异常直接裸奔到接口层。

8.2 两个实际排查案例:数组越界与 UncaughtException

热搜词里有一条“java中数组越界异常”,这个见得真的很多。典型场景是遍历集合时用for (int i = 0; i <= list.size(); i++),把<=写成<之外,还有一种是使用subList后的操作。还有一个隐蔽点:通过Arrays.asList()转出来的列表,底层还是原数组,是不能调用add的,否则会抛UnsupportedOperationException

另外,线上工具UncaughtExceptionHandler在生产项目中很有用。默认情况下,线程抛出未捕获异常时,只有栈日志,有时候连日志都没打全。如果自定义一个全局的线程异常处理器,可以统一记录异常,做监控告警。这个技巧在维护长连接服务、消息消费线程时非常实用。

8.3 Redis increment() 报错“not integer or out of range”复盘

Redis 的increment()报错“value is not an integer or out of range”是热搜词里的高频问题,我也遇到过。

这个错误的直接原因是INCR命令只能对字符串类型的整数值操作。如果 key 对应的 value 是字符串、浮点数,或者值超出了 64 位有符号整数范围,Redis 就会拒绝执行。

我在项目里踩过一个更隐蔽的版本:某个 key 最初被set存成了字符串 “100”,看起来能转数字,但代码里用increment(key, -1)期望返回 Long。结果呢?第一次执行确实没问题,因为 Redis 内部会尝试把字符串解析成整数。但如果这个 key 在某处被覆盖成 “abc” 或者其他非数字字符串,increment()就会当场报错。

排查的思路很简单:执行之前用type key看类型,用get key看值,确定是整数字符串再继续。更稳妥的做法是,所有计数类 key 统一用increment初始化,不要用set去设初始值。用一个不存在的 key 执行increment(key, 0)也能完成初始化,值为 0,然后再做增减。

9. 排序算法手写复习:冒泡与快排

9.1 冒泡排序的常规写法和优化

热搜词里同时出现了“冒泡排序java”和“java 快速排序”,说明排序算法在笔试和面试里依然是常客。冒泡排序的思路很简单:相邻元素两两比较,大的往后挪。常规写法:

public static void bubbleSort(int[] arr) { int n = arr.length; for (int i = 0; i < n - 1; i++) { 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; } } } }

优化的点是:如果某一轮冒泡没有发生任何交换,说明数组已经有序,可以提前退出。加一个boolean swapped标记就行。虽然冒泡排序在工程里极少用,但它适合用来演示“交换次数”和“稳定性”这些概念。

9.2 快速排序的实现细节

快排的思路是分治:选一个基准数,把小于基准的放左边,大于基准的放右边,然后递归处理左右子区间。

public static void quickSort(int[] arr, int left, int right) { if (left >= right) return; int pivot = arr[left]; int i = left, 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); }

这里有个细节:外层while里,必须是先从右边找小于基准的,再从左边找大于基准的。顺序反了,在基准选在左边时,会导致结果错误。这属于手写代码时最容易翻车的点。

快排的平均时间复杂度是 O(n log n),最坏情况是 O(n²),最坏情况出现在数组已经有序且基准总是选在端点时。优化方法是随机选基准,或者三数取中。

9.3 算法练习对于学习 Java 的意义

很多人觉得业务开发用不到算法,没必要练。但排序、二分这些基础算法,是理解数据结构、复杂度分析、JDK 源码的入口。Collections.sort为什么用 TimSort,Arrays.binarySearch为什么要求数组有序,这些背后全是算法知识。复习的时候自己默写一遍,会发现平时调 API 完全没注意到的细节。

10. 面经式速查:八股文考点背后的真实场景

10.1 为什么会有八股文

“java面试八股文”这类词热度一直很高。我的观点是:八股文本身不是贬义,它是一套知识点的索引。问题是很多人只知道背结论,不知道背后的场景和取舍。面试官问“HashMap 为什么线程不安全”,不是为了听你背答案,而是看你能不能联想出线程安全的替代方案。

我复习时做了一个小总结,把高频考点和对应的实战场景对应起来:

考点典型问题实际应用场景
HashMap 底层扩容、树化、hash 寻址缓存设计、去重、分组
动态代理JDK vs CGLIBSpring AOP、MyBatis Mapper
JVM 内存区域划分、OOM 排查线上内存问题定位
synchronized vs ReentrantLock分布式锁、并发控制
Redis 命令increment 类型限制库存、计数器、限流
排序算法手写快排、复杂度分析理解 JDK 排序源码

这样的对照表,复习效率比死记硬背高很多。

10.2 高频考点串讲:锁、容器、框架原理

面试中几个绕不开的组合问题:ArrayListvsLinkedList的区别,HashMapHashtable的区别,synchronizedReentrantLock的区别,SpringSpring Boot的关系。这些问题的共同点是有明确的使用场景差异,回答时只要沿着“是什么-适用场景-为什么不选另一个-底层原理是什么”的结构走,基本不会太差。

10.3 复习节奏建议:用输出倒逼输入

这次复习我最大的体会是,别只看书,一定要动手写。每复习一个知识点,就写一个能运行的 Demo,或者画一张结构图,甚至录一段音频给自己讲解。用“输出倒逼输入”的方式,能快速暴露认知盲区。

我自己定的节奏是:每个周末花半天复习一个板块,周一到周五在写业务代码时有意识地去对照复习过的知识点。比如写 Redis 操作时,就想想increment的类型问题;写并发代码时,就想想锁的粒度。这样坚持了一个多月,很多原本模糊的概念慢慢清晰起来。

最后再说一个我自己的实操习惯:准备一个“错题本”文档,专门记录那些让自己卡壳的问题,不用长,三五句话就行。过两周再回来翻一遍,把能秒答的问题划掉,把还卡壳的重新研究。这一轮复习下来,最值钱的东西不是记住了多少 API,而是知道了自己的知识缺口到底在哪里。Java 的生态太庞大,不可能一次学完,但每次复习都能把“基础”这两个字的边界往外推一点,这就够了。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/9 3:08:42

基于若依框架的扫码资产管理系统升级实践

资产管理的台账更新&#xff0c;最怕的就是资产编号抄错、盘点数据对不上账。我前阵子基于若依框架把手里的资产管理系统整体升级了一版&#xff0c;核心就一件事&#xff1a;让每一件资产都能“扫一下”完成查询和盘点&#xff0c;不再靠人眼对编号、手工敲键盘。这次升级后&a…

作者头像 李华
网站建设 2026/9/9 3:08:18

制造业AI落地:需求清单背后的真实难题

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/9 3:07:39

Fluent流道分析三件套:压力损失、速度分布与压力分布实践指南

做流道分析这几年&#xff0c;我越来越觉得Fluent里最值得盯着看的其实就是三个量——压力损失、速度分布、压力分布。很多朋友一上来就喜欢渲染那种五颜六色的云图&#xff0c;觉得漂亮就是算得好&#xff0c;但真正到了工程交付、写报告、改结构的时候&#xff0c;你能拿出去…

作者头像 李华
网站建设 2026/9/9 3:03:47

强化学习四足机器人Microduck实战:从GPU训练到RK3566实机部署

这段时间一直在折腾 Microduck 的实机部署&#xff0c;从最初的英伟达 GPU 训练环境&#xff0c;到最后落到一块 RK3566 主控的小机器人上&#xff0c;整个过程踩了不少坑&#xff0c;也理清楚了一条可以复用的链路。如果你手里正好有类似的强化学习机器人&#xff0c;比如低成…

作者头像 李华
网站建设 2026/9/9 3:03:10

轻量化PDM:小团队器件库管理的工程化解决方案

1. 项目概述&#xff1a;为什么小团队突然需要“轻量化PDM”&#xff1f;“轻量化PDM 首发&#xff5c;在线 DEMO 开放&#xff0c;小团队器件库不必再用 Excel 硬扛”——这个标题里藏着三组真实痛点&#xff0c;我带过8个硬件初创团队、亲手维护过12个不同规模的元器件库&…

作者头像 李华