本文分两大部分:第一部分「语义与集合」讲 hashCode、ArrayList、Arrays、Stream、BigDecimal 这些日常最易踩坑的基础;第二部分「并发」从内存模型讲到 AQS、死锁与缓存淘汰。末尾附一张并发工具速查表。


一、哈希码:相等判断与 HashMap 的底层依据

哈希码代表了对象的一种特征:判断两个字符串是否相等时,哈希码相等是「可能相等」的有力信号;哈希码本身也是一种数据结构的算法,常见算法按类型各有不同:

  • Object 类的 hashCode:返回对象内存地址处理后的结构,每个对象地址不同,哈希码也不同。
  • String 类的 hashCode:根据字符串内容按特殊算法返回,内容相同则哈希码相同。
  • Integer 类:返回值就是对象包含的整数。例如 Integer i1 = new Integer(100)i1.hashCode() 就是 100,相同大小的 Integer 哈希码也一样。

三个容易踩的坑

  • 把 hashCode 相等当成相等:真正判定相等仍要 equals,重写 equals 必须同时重写 hashCode
  • 可变字段参与 hashCode:对象入 Map 后字段被改,就再也查不出来。
  • 只重写 equals 不重写 hashCodeHashMap/HashSet 按 hashCode 分桶,会出现「存进去取不出」。

什么时候用:凡是用对象做 HashMap 键、HashSet 元素都要认真对待 hashCode;不可变对象最省心。


二、ArrayList:数组容器的扩容与 fail-fast

ArrayListObject[] 存储,默认空数组,第一次 add 扩到 10,之后按 1.5 倍增长。随机访问 O(1),中间插入要搬内存。modCount 用于 fail-fast 迭代器,并发写会抛 ConcurrentModificationException,不能当并发容器。

JDK 21 里仍保留 elementDatasizesubList 是视图,改子列表会影响父列表。ensureCapacity 预扩容避免多次拷贝,trimToSize 在长生命周期列表上省堆。读源码关注 growequals 与序列化写替换。面试常问:为什么不是 2 倍扩容、为什么 remove 要搬数组、以及和 LinkedList/CopyOnWriteArrayList 的取舍。

三个容易踩的坑

  • 把 ArrayList 当并发容器:多线程写会 CME 或数据错乱,用 CopyOnWriteArrayList 或加锁。
  • 忽略 subList 视图语义:改子列表反向影响原列表,且随原列表结构变化失效。
  • 大列表反复 add 不预扩容:频繁 1.5 倍拷贝拖慢性能,已知容量用 ensureCapacity

什么时候用:读多写少、需随机访问最合适;频繁中间插入或并发写另选 LinkedList 或并发容器。


三、Arrays 工具类:别再手写数组循环

java.util.Arrays 提供排序、搜索、填充、比较与批量拷贝。sort 对基本类型用双轴快排变体,对象用 TimSort,要求比较器有传递性。binarySearch 前必须已排序,否则结果未定义。

asList 返回固定大小包装,不能 add/remove,但 set 可以。copyOfcopyOfRange 便于扩容。mismatchequalscompare 在 Java 9+ 对数组逐元素比较。parallelPrefix 适合前缀和。

三个容易踩的坑

  • Arrays.asList(int[]) 的陷阱:得到「元素为整型数组」的 List,拆箱数组请用循环或 IntStream
  • binarySearch 前置条件:没排序就搜,返回值是未定义行为。
  • asList 不能增删:只是数组定长视图,add/removeUnsupportedOperationException

什么时候用:数组排序、比较、拷贝优先用它而非手写循环;单测用 assertArrayEquals 比自己写循环更不易漏边界。


四、Stream API:惰性流水线,别炫技

Stream API 把集合操作拆成「中间惰性流水线」与「终端操作」。map/filter/flatMap 不立刻执行,遇到 collect/reduce/forEach 才融合求值。并行流用 ForkJoinPool.commonPool,IO 密集不要用 parallel

有状态中间操作 sorted/distinct 有缓冲成本,短路 findFirst。自定义收集器用 Collector.of。基本类型有 IntStream 减少装箱。受检异常不能直接抛,需包一层。调试可临时用 peek 打日志。Android 上 desugar 后可用,但仍留意 API 24 以下的运行时与包体积。

三个容易踩的坑

  • 三层嵌套 flatMap 不如循环:可读性优先于炫技,过深流水线难调试。
  • parallel 用错场景:IO 密集或数据量小时,拆分合并开销反倒更慢。
  • 忽略 Android desugar 运行时:API 24 以下要留意运行时与包体积。

什么时候用:集合转换、聚合、过滤这类声明式处理最合适;简单循环能一眼看懂的别硬套 Stream。


五、BigDecimal:金额与精确小数的唯一正解

BigDecimal 用十进制表示金额与精确小数,避免 double 的二进制误差。构造优先用字符串,因为 new BigDecimal(0.1) 会带入二进制脏值。运算返回新对象,注意 scaleRoundingMode。比较用 compareTo 而不是 equalsequals 还看 scale)。除法必须指定精度,否则 Non-terminating decimal 抛异常。setScale 用于展示,计算过程可保留更多位。

三个容易踩的坑

  • new BigDecimal(0.1):double 传进去已带二进制脏值,务必用字符串构造。
  • 用 equals 比较金额:scale 不同会判不等,金额比较一律用 compareTo
  • 除法不指定精度:除不尽直接抛异常,要显式给 RoundingMode

什么时候用:任何涉及钱的运算、需精确小数位的场景都用它;性能比基本类型慢,循环里少创建。极端高频场景,金额用「分」的 long 更合适。


六、JMM 与多线程:先理解内存模型再谈加锁

JMM(Java 内存模型)规定工作内存与主内存如何同步,保证原子性、可见性、有序性。编译器与 CPU 会重排序,靠 happens-before 约束可观察顺序:程序顺序、监视器锁、volatile 写读、线程 start/join 等。

volatile 禁止指令重排并插入内存屏障,保证可见,但不保证 i++ 原子。final 在构造完成后对其他线程可见,前提 this 未逸出。long/double 非 volatile 在 32 位上可能拆写。synchronized 同时提供互斥与可见。并发工具(CAS、AQS)底层也是 volatile 与屏障。面试常考 DCL 单例为何要 volatile。

多线程让一个进程里多条执行流共享堆内存。创建方式有继承 Thread、实现 RunnableCallable 和线程池。难点在共享可变状态:可见性、原子性和有序性。synchronizedvolatile、锁和并发集合都在约束这三件事。线程生命周期从 NEW 到 TERMINATED,阻塞可能在锁、等待队列或 IO。现代写法是任务交给 Executor,而不是满地 new Thread

三个容易踩的坑

  • 共享可变才是危险源:不可变或线程封闭的数据不必加锁。
  • 线程池管理生命周期比手写 start 重要:核心数、队列和拒绝策略才是调优重点。
  • 中断是协作interrupt 只设标志,阻塞点抛 InterruptedException,业务要响应而不能吞掉。

什么时候用:CPU 可并行或 IO 等待明显时用多线程;简单请求用框架异步模型即可。随机故障用 jstack 看死锁和阻塞,而不是先加更多线程。


七、ThreadLocal:线程私有副本,用完必须 remove

ThreadLocal 为每个线程存一份变量副本,内部是 Thread 上的 ThreadLocalMap,key 是弱引用。典型用途:SimpleDateFormat、事务上下文、链路追踪 traceId

线程池会复用线程,用完必须 remove,否则内存泄漏或串数据。InheritableThreadLocal 可传给子线程,但不适合线程池。TransmittableThreadLocal 解决任务提交时上下文传递。不要拿它当全局参数图省事。高并发下每个 Thread 一份对象可能更占内存。Android 主线程的 ThreadLocal 要在组件销毁时清理。排查泄漏看 heap 里的 ThreadLocalMap.Entry

三个容易踩的坑

  • 线程池里用完不 remove:线程复用导致串数据或内存泄漏,最常踩。
  • 拿它当全局参数图省事:本质是线程私有状态,滥用会让代码隐式耦合、难测试。
  • InheritableThreadLocal 配线程池:线程池不创建子线程,继承语义失效,用 TransmittableThreadLocal

什么时候用:需要「线程内全局可见、线程间隔离」的上下文(如 traceId、事务)时用它;用完即清,宁可多写一行 remove


八、LockSupport 与 AQS:并发包的底层原语

LockSupport 是更底层的线程阻塞原语,提供 parkunparkunpark 可先于 park 发生,相当于给线程一张许可;再次 park 时许可被消耗,线程不会真正挂起。这和 wait/notify 必须先持有监视器、且 notify 可能丢失信号不同。AQS 的阻塞队列就是靠它实现的。

自己写同步器时,park 要放在循环里检查条件,防止虚假唤醒。中断不会抛 InterruptedException,但会返回,调用方要自己处理中断状态。不要把它当 sleep 用:没有配对的 unpark,线程会一直挂着。调试看线程是 TIMED_WAITING 还是 WAITING,以及谁调用了 unpark

AQS(AbstractQueuedSynchronizer)是并发包里锁和同步器的骨架。它用一个 volatile state 表示资源,用 CLH 变体队列排队等待线程。子类实现 tryAcquiretryRelease 即可得到可重入锁、信号量或闩。独占与共享两种模式覆盖互斥和读写。读源码抓三条线:CAS 改 state、失败则入队、前驱释放时 unpark 后继ConditionObject 把等待条件从同步队列拆出去。扩展 AQS 时不要在 tryAcquire 里做耗时操作,也不要忘在释放后唤醒。ReentrantLockCountDownLatchSemaphore 都是它的用户。

三个容易踩的坑

  • park 不放在循环里检查条件:虚假唤醒会让线程在条件未满足时继续,必须循环校验。
  • 把 LockSupport 当 sleep 用:缺配对的 unpark,线程会永久挂着。
  • 扩展 AQS 时在 tryAcquire 里做耗时操作:拖慢整个等待队列吞吐。

什么时候用:需自定义锁、信号量或同步屏障时,基于 AQS + LockSupport 比手写 wait/notify 更稳;普通业务直接用 ReentrantLock 等现成实现。


九、死锁:四个必要条件与避免思路

死锁是一种情形:多个线程被阻塞,其中一个或全部在等待某资源被释放,程序因此不能正常运行。简单说,死锁时第一个线程等第二个线程释放资源,第二个又在等第一个释放资源。

死锁产生的四个必要条件

  • 互斥条件:一个资源每次只能被一个进程使用。
  • 请求与保持条件:进程已持有至少一个资源,又提出新资源请求,该资源被他人占有,请求被阻塞但对自己获得的资源保持不放。
  • 不可剥夺条件:资源未使用完毕前不能被强行夺走,只能由获得它的进程主动释放。
  • 循环等待条件:若干进程形成首尾相接的循环等待环路,环路中每个进程占有的资源同时被另一进程所申请。

这四个条件是死锁产生的必要条件,只要发生死锁必然成立;任一条件不满足就不会死锁。

死锁的避免:系统对进程的每个资源申请做动态检查,若分配后可能发生死锁则不分配,否则予以分配。这是保证系统不进入死锁状态的动态策略。

三个容易踩的坑

  • 多锁不约定加锁顺序:这是循环等待最常见的来源,统一「按资源 ID 升序加锁」即可破坏循环等待。
  • 持有锁时去申请别的锁:应尽量缩小持锁范围、先拿齐再干活。
  • 只在发版前才用 jstack 排查:死锁是随机故障,应日常纳入诊断而非等线上卡死。

什么时候用:任何需多把锁协作的代码都要主动设计加锁顺序与超时;能用单锁或并发容器就不要引入多锁竞争。


十、LRU:缓存淘汰的默认选择,但要防扫描污染

LRU(Least Recently Used)按最近访问淘汰最久没被用的数据。哈希表保证 O(1) 查找,双向链表维护访问顺序:命中挪到链表头,空间满删尾部。Java 可用 LinkedHashMapaccessOrder,或自己写 HashMap 加链表节点。缓存、页面置换、连接池都可以用它。

单纯 LRU 会被扫描污染:一次全表遍历把热点挤出去。生产常用 LRU-K、LFU 或加过期时间。并发要给链表操作加锁或用分段。缓存要区分读穿透和击穿:空值也要短缓存,热点要预热。衡量指标是命中率和尾延迟,而不是结构写得多精巧。容量和过期策略配成可观测,淘汰逻辑才不会在流量上来时变黑盒。

三个容易踩的坑

  • 裸 LRU 被扫描污染:一次全表遍历把热点全挤出去,生产需 LRU-K/LFU/过期时间兜底。
  • 并发链表不加锁:头尾挪动不是原子的,高并发要加锁或分段。
  • 只盯命中率:尾延迟和「流量上来时淘汰是否失控」才是线上真要盯的指标。

什么时候用:本地缓存、连接池、页面置换这类「最近用过更可能被再用」的场景默认选 LRU;但要配容量、过期与可观测,否则淘汰逻辑变黑盒。


附:并发工具速查表

场景 用什么 不要用什么
共享可变状态同步 synchronized / volatile / 并发集合 new Thread 后乱加锁、靠运气
自定义锁 / 同步器 AQS + LockSupport(循环里 park) 乱用 wait/notify 丢信号
线程间私有变量 ThreadLocal(用完 remove 当全局参数图省事
缓存淘汰 LRU(LinkedHashMap / 自建)+ 容量过期可观测 纯 LRU 不防扫描污染
精确金额 / 小数 BigDecimal(字符串构造) double / float
死锁避免 破坏四条件之一(资源有序申请 / 超时) 忽略循环等待、持锁再申请
多线程生命周期 任务交给 Executor 线程池 满地 new Thread
排查阻塞 / 死锁 jstack 看 WAITING / 阻塞栈 先加更多线程碰运气