Java进阶核心:集合、异常、泛型与并发编程实战指南 1. 集合框架从会用到底层原理1.1 ArrayList、LinkedList与Vector的选型逻辑很多初学者在“基础一”阶段已经记住了ArrayList和LinkedList的区别一个是数组实现一个是双向链表实现。但到了进阶阶段光背这个结论是不够的你需要在真实的业务场景里判断到底该用谁。我见过不少同事在某个循环里用LinkedList做随机访问结果性能差到让人怀疑人生原因就是每次get(index)都要从头遍历链表。先看一段最常见的初始化代码ListString list new ArrayList();为什么默认选ArrayList因为绝大多数场景下遍历查询和尾部追加是主要操作数组结构在内存中是连续空间CPU缓存命中率高随机访问时间复杂度是O(1)。而LinkedList每次插入和删除看起来是O(1)但你得先找到位置这个查找过程如果是按索引操作就是O(n)。所以“链表插入快”这个说法是有前提的——要么在头部要么在已知某个节点引用的情况下做插入。Vector这个类现在基本可以进博物馆了。它的所有方法都用synchronized修饰线程安全是安全了但性能损耗严重。如果你真的需要线程安全的列表优先考虑Collections.synchronizedList或者CopyOnWriteArrayList而不是Vector。CopyOnWriteArrayList适合读多写少的场景比如监听器列表它的写操作是复制整个数组代价高但读操作完全无锁。如果写多读少那就用Collections.synchronizedList配合手动加锁或者干脆用ConcurrentLinkedQueue这类并发容器。我个人的选型习惯是这样的如果单线程或经过线程封闭的List直接ArrayList如果需要频繁在头部插入可以用ArrayDeque或者LinkedList但得注意访问模式如果读多写少的并发场景CopyOnWriteArrayList很合适如果写多读少尽量规避共享List用队列或者消息解耦。1.2 HashMap的扩容与红黑树临界点HashMap是面试和实战中绕不开的重点。我见过不少生产事故根源就是没搞懂扩容机制导致死循环、OOM或者数据丢失。先说底层结构JDK 8以后是“数组链表红黑树”的组合。默认初始容量是16负载因子是0.75。这两个数字不是随便定的0.75是时间和空间成本的折中负载因子越大空间利用率高但冲突概率大负载因子越小冲突少但数组频繁扩容浪费内存。扩容的触发条件是size threshold其中threshold capacity * loadFactor。比如默认16的容量当元素个数超过12时就会触发扩容扩大到原来的两倍。扩容时所有元素需要重新计算hash并分配到新的桶位这个过程叫rehash。虽然JDK 8已经优化了rehash逻辑利用原数组长度是2的幂次特性避免了重新计算hash但扩容本身仍然是高成本操作。如果你能预估数据量初始化时指定容量能省很多事MapString, String map new HashMap(64);这里有个小技巧HashMap计算容量时会做一次“向上取整到2的幂”所以如果你传入的是100实际容量是128。别小看这个细节它能让你多存几十个元素而不触发扩容。再聊聊红黑树化。当某个桶位链表长度超过8且数组容量大于等于64时链表会转成红黑树。为什么是8这是基于泊松分布算出来的概率理想情况下hash随机分布链表长度达到8的概率是千万分之一。所以如果你发现线上某个Map频繁出现红黑树第一反应应该是hash函数有问题或者Key对象重写了hashCode()但没有遵循规范导致大量Key集中在少数桶位。我踩过一个坑用HashMap存一些动态拼接的字符串作为Key结果那字符串有共同前缀然后我又没有重写合理的hashCode()导致某个桶里挂了几百个节点性能骤降。排查半天才发现是Key设计问题。解决方式是认真审视hashCode()的散列性或者用LinkedHashMap做LRU缓存时注意访问顺序的影响。1.3 集合使用中常见的坑与实战建议集合这块的坑不少是“看似不起眼炸起来要命”的。我挑几个高频问题聊聊。第一个坑是Arrays.asList()返回的List不能增删。很多人以为它和ArrayList一样其实它返回的是一个内部类java.util.Arrays$ArrayList底层还是数组大小固定。如果调用add或remove会直接抛UnsupportedOperationException。要想得到真正的可变List得这样写ListInteger list new ArrayList(Arrays.asList(1, 2, 3));第二个坑是subList()的视图问题。List.subList()返回的是原List的视图不是拷贝。你修改subList会影响原List而原List的size变化后再操作subList会抛ConcurrentModificationException。某次我写代码时用subList截取一段数据然后往原List里加了新元素接着遍历subList结果直接崩溃。后来的习惯是拿到subList后立刻复制ListInteger sub new ArrayList(original.subList(1, 3));第三个坑是迭代时删除元素。在foreach循环里调用list.remove(i)会在下一次迭代时抛出ConcurrentModificationException。正确姿势是用Iterator的remove方法或者直接用Collection.removeIflist.removeIf(item - item.equals(target));第四个坑是HashSet与HashMap的Key对象必须正确实现equals和hashCode。如果只重写equals而不重写hashCode那么两个内容相同的对象可能被存进两个不同的桶导致集合里出现“重复”元素。这个在业务去重场景中经常引发故障。关于集合的实战建议我总结几条经验能用Collections.unmodifiableList就尽量用防止别处修改多线程环境下先用线程安全的集合别想着事后加锁Map遍历时如果需要同时删除元素用entrySet().removeIf而不是先找到再删。这些习惯不需要多高深的技术含量但能帮你避免大量线上事故。2. 异常处理不是try-catch就完事2.1 异常体系与Checked/Unchecked的设计意图Java的异常体系是个老生常谈的话题但很多走到进阶阶段的人还是没有建立清晰的设计思维。Throwable下面有Error和Exception。Error比如OutOfMemoryError、StackOverflowError这类问题程序本身无法恢复不该去捕获捕获了也没意义。Exception分两种受检异常Checked Exception和非受检异常Unchecked Exception也就是RuntimeException及其子类。受检异常是编译器强制你处理的。比如IOException、SQLException它们通常表示外部资源或者IO操作中出现了可预期的问题。编译器逼着你写try-catch或throws目的就是提醒你这个操作可能会失败你得想好失败后怎么办。非受检异常则通常是程序逻辑错误比如NullPointerException、IndexOutOfBoundsException这些在代码层面就应该规避掉而不是靠try-catch兜底。我见过一种糟糕的代码风格把Exception当作万能药一大段业务逻辑全塞进try里然后catch (Exception e) { e.printStackTrace(); }。这种代码在测试环境可能永远跑不出问题但到了生产环境一旦出现异常就被静默吞掉你连日志都看不到。真正进阶的做法是受检异常要么在当前层处理好并转为业务含义明确的异常要么继续抛出给上层非受检异常则应该尽早抛出快速失败。举个例子如果我们写一个解析文件的方法public void parseFile(File file) throws IOException { try (FileReader reader new FileReader(file)) { // 解析逻辑 } }这里throws IOException是合理的因为我们把底层IO异常抛给调用方去决策。但如果调用方也处理不了干脆就在最上层统一拦截并给出友好提示。这个“谁有能力谁处理”的原则比机械地到处try-catch靠谱得多。2.2 try-with-resources与资源释放的底层逻辑在JDK 7之前释放资源是个烦人的活。比如操作文件流你需要在finally块里一个个关闭资源还得自己处理关闭时可能抛出的异常代码难看且容易漏。JDK 7引入了try-with-resources语法极大改善了这个问题try (InputStream in new FileInputStream(a.txt); OutputStream out new FileOutputStream(b.txt)) { // 读写逻辑 }这段代码最核心的价值是无论try块是否正常结束in和out都会被自动关闭而且关闭顺序是逆序的。为什么强调顺序因为资源之间可能有依赖比如一个包装流依赖一个底层流先关闭包装流会flush缓冲数据再关闭底层流才合理。try-with-resources按照声明顺序的逆序关闭正好符合大多数场景。底层实现其实很简单编译器会为每个实现了AutoCloseable接口的资源生成final变量并在try结束后统一关闭。如果try块和关闭操作都抛了异常关闭阶段的异常会被“抑制”而原始异常会抛给调用方同时被抑制的异常会记录在原始异常的suppressed数组里。这一点在排查问题时要特别注意别只盯着主异常用getSuppressed()看看是不是还有隐藏的关闭异常。我之前在某项目中遇到一个很奇怪的现象文件明明操作成功但偶尔会抛IOException而且堆栈信息指向close()方法。后来才发现是缓存数据没写完整因为用了老式的finally close写法关闭顺序错了外层缓冲流先关内层流却已经关闭导致缓冲数据丢失。换成try-with-resources之后顺序问题彻底解决。2.3 自定义异常与异常吞掉的教训自定义异常并不是把Exception继承一下那么简单。我见过很多人定义异常类时除了构造器什么都不加导致业务语义仍然不明确。好的自定义异常应该包含足够的上下文信息比如错误码、业务场景、可能的原因。你可以在异常里增加字段public class BizException extends RuntimeException { private final int code; private final String bizScene; public BizException(int code, String bizScene, String message) { super(message); this.code code; this.bizScene bizScene; } // getter... }有了code和bizScene上层就能根据错误码快速定位问题而不是靠解析异常消息字符串。某次我在日志系统里看到一条异常消息“数据校验失败”完全不知道是哪个业务模块抛出来的后来强制统计了异常消息的相似度发现至少有三处代码用了同一个字符串。后来引入自定义异常并带上业务场景字段这种问题才彻底消失。关于“吞异常”我要多说几句。最常见的吞异常姿势有四种catch块里什么都不写catch块里只打printStackTrace线上没有任何用catch后返回默认值掩盖错误还有在循环里把异常吃掉继续跑。前两种是隐形炸弹后两种是逻辑错误的源头。举个例子批量处理订单时如果某个订单处理失败到底是整体回滚还是跳过继续这个决策很重要。如果选择跳过必须把失败原因记到日志和结果集中而不是简单catch了事。我通常会在catch里做三件事记录完整的异常堆栈、保存当前处理失败的上下文、决定是否抛出包装后的异常。只有这样才能保证故障可追溯。3. 泛型编译期的保护与运行期的擦除3.1 泛型类、方法、通配符的使用场景泛型刚接触时觉得简单无非是把类型参数化。但用多了就会发现很多细节常被忽略。泛型类最常见的就是容器类比如ListT、MapK,V。泛型方法则是在方法级别定义类型参数与类是否泛型无关public static T T getFirst(ListT list) { if (list.isEmpty()) { throw new NoSuchElementException(); } return list.get(0); }注意这里的T放在返回值前面这是泛型方法的标志。如果你在静态方法里需要使用泛型参数就必须定义为泛型方法因为静态方法不能使用类上声明的类型参数。通配符?是泛型里容易绕晕的点。? extends T表示上界通配符适合只读场景? super T表示下界通配符适合只写场景。这个设计来自PECS原则生产者用extends消费者用super。举个例子如果你要写一个方法把一组对象放到一个集合里这个集合就是“消费者”应该用? super Tpublic static void addAll(List? super Integer dest, Integer... nums) { for (Integer n : nums) { dest.add(n); } }如果你要遍历并读取集合是“生产者”用? extends T。很多人不理解为什么Java要搞得这么复杂其实是为了类型安全。如果不限制通配符你很难同时保证读写操作的类型安全。3.2 类型擦除与桥接方法为什么运行期拿不到真实类型泛型的核心实现机制是类型擦除。也就是说编译后的字节码里根本没有泛型类型信息ListString和ListInteger在运行期的Class对象都是List.class。这就解释了为什么你不能直接用instanceof检查泛型类型也不能获取泛型参数的真实类型除非通过反射技巧。类型擦除的后果之一是泛型类型参数在运行时会被替换为它的上界。比如T extends ComparableT会被擦除为Comparable。另一个后果是你不能创建泛型数组new T[10]是编译错误因为运行期无法确定T的具体类型只能通过(T[]) new Object[10]这种方式变通。我见过有人被这个报错卡了很久最后直接去掉泛型数组改用ListT其实是更优的方案。编译器还会生成桥接方法用来保持多态性。比如class StringList extends ArrayListString它重写了String get(int index)但底层还需要一个Object get(int index)方法编译器会自动生成一个桥接方法把Object强制转换成String然后调用真正的重写方法。这个细节在反射调用时偶尔会带来麻烦因为你可能找到两个名字相同但签名不同的方法。3.3 实战中关于泛型的几个坑与解决方案第一个坑是泛型与重载的冲突。因为类型擦除你不能定义两个只有泛型参数不同的方法比如void handle(ListString list)和void handle(ListInteger list)编译器视为同一个签名。解决办法是给方法改名字或者增加不同的参数顺序。第二个坑是静态变量不能使用泛型类型。你写static T variable;会直接编译失败因为泛型类型参数在类加载时已经不确定。这个也解释了为什么泛型类的静态方法不能引用类上的泛型参数。第三个坑是泛型不能用在异常上。class MyExceptionT extends Exception是合法的但catch (MyExceptionT e)不合法因为JVM在运行时无法区分泛型类型。如果确实需要携带额外信息可以在异常内部用一个泛型字段来存数据但异常类本身不要泛型化。还有一个常见的泛型误用是ListObject与ListString不是父子关系即使String是Object的子类。所以把ListString传给void f(ListObject list)会编译报错。此时用?通配符List?可以接收任何类型。但注意List?不能添加任何元素除了null因为你到底想把什么东西放进去编译器根本不知道。我在实际项目里一般遵循几条原则对外API尽量使用具体泛型或受约束通配符不要用裸类型内部实现如果需要存储多种类型先用一个共同的基类或接口约束尽量少用反射去操作泛型类型因为代码脆弱且难以阅读。泛型的核心价值是在编译期发现问题不要试图绕开它。4. 多线程与并发从Thread到JMM4.1 线程创建的几种方式与适用场景传统上创建线程有三种方式继承Thread、实现Runnable、实现Callable并包装成FutureTask。到了JDK 8以后还有CompletableFuture和ForkJoinPool等异步工具但基础还是要稳。先看实现Runnable和Callable的区别。Runnable的run方法没有返回值和受检异常Callable的call方法有返回值且能抛出受检异常。当你需要一个计算结果时Callable配合FutureTask就很方便CallableInteger task () - computeResult(); FutureTaskInteger futureTask new FutureTask(task); new Thread(futureTask).start(); Integer result futureTask.get(); // 阻塞等待结果Future.get()会阻塞当前线程。如果计算时间较长可以使用get(long timeout, TimeUnit unit)避免无限等待。这里有个细节FutureTask只能执行一次想重复执行要创建新的。对于“到底用哪种方式”我的建议是优先使用Runnable或Callable因为Java是单继承实现接口不会破坏类的继承体系。但更重要的一点是永远不要直接用new Thread去管理复杂的业务线程因为线程的创建销毁成本高而且难以控制并发数量。正确的姿势是把任务提交给线程池让池化技术复用线程。线程创建的坑也很多。比如未捕获异常默认只会输出到控制台线上你根本看不到。所以对需要长期运行的任务最好设置UncaughtExceptionHandlerThread t new Thread(() - { throw new RuntimeException(boom); }); t.setUncaughtExceptionHandler((thread, throwable) - { System.err.println(thread thread.getName() error: throwable); });4.2 synchronized与Lock的选择以及volatile的局限synchronized和Lock之争是个经典话题。synchronized是JVM内置的锁随着版本升级性能已经和Lock差不多了而且它自动释放锁不会出现死锁至少不会因为忘记释放而死锁。Lock比如ReentrantLock提供了更多功能可中断获取锁、超时获取锁、公平性设置、多条件队列Condition。我通常的做法是如果能用synchronized解决问题就用它简洁可靠如果需要尝试获取锁、限时等待或者多个条件就用ReentrantLock。举个例子一个缓存服务需要“写入时如果没有获取到锁就快速失败”用tryLock很合适ReentrantLock lock new ReentrantLock(); if (lock.tryLock(1, TimeUnit.SECONDS)) { try { // update cache } finally { lock.unlock(); } } else { // 返回冲突提示 }这里finally解锁是必须的和synchronized自动解锁有本质区别。很多低级bug就是因为unlock忘了写。再聊volatile。它保证可见性和有序性但绝不保证原子性。比如volatile int count的count不是线程安全的因为count是“读—改—写”三步操作volatile管不了中间的并发穿插。要让计数器线程安全用AtomicInteger或者LongAdder。LongAdder适合高并发且频繁更新的统计场景它在内部做了分段累加性能优于AtomicInteger。volatile的经典正确用法是状态标志位。比如一个线程运行开关volatile boolean running true; public void stop() { running false; } public void run() { while (running) { // 执行任务 } }这里volatile保证另一个线程修改了running后当前线程能立刻看到而不需要加锁。4.3 线程池参数设置与任务队列选择线程池是并发编程中最容易忽略细节的部分。先看ThreadPoolExecutor的七个参数核心线程数、最大线程数、空闲存活时间、时间单位、工作队列、线程工厂、拒绝策略。每个参数都不该拍脑袋。核心线程数和任务类型有关。如果是CPU密集型任务核心线程数建议设置为CPU核数1目的是减少上下文切换。如果是IO密集型任务大部分线程会阻塞在IO上可以设置大一点比如CPU核数 * 2或者更精确地用“期望CPU利用率 * 核数 * (1 等待时间/计算时间)”来算。某项目里我按这个公式算下来是核心线程数设成32效果比默认配置好很多。工作队列选择也很有讲究。有界队列会限制任务积压数量但线程数满了之后会出现拒绝策略。无界队列比如LinkedBlockingQueue可以无限堆积任务但内存可能被冲垮。某次线上OOM就是用了无界队列一瞬间来了一批密集请求任务在队列里堆积了几十万个内存直接爆炸。后来换成有界队列并配合合适的拒绝策略问题才缓解。拒绝策略有四种AbortPolicy抛异常、CallerRunsPolicy调用者线程执行、DiscardPolicy丢弃、DiscardOldestPolicy丢弃最老的。我建议默认使用CallerRunsPolicy因为任务在调用者线程执行时相当于提供了一个天然背压不会因为任务过多而直接失败。当然如果业务上允许丢弃任务也可以选择其他策略。但无论如何建议自定义ThreadFactory并设置线程名称这样线上排查线程栈时一眼就能看出是哪个线程池出了问题。我见过一堆线程名都是“pool-10-thread-1”的排查时非常痛苦。4.4 并发编程中的可见性、原子性与有序性并发问题归根结底逃不过三个特性可见性、原子性、有序性。很多人背概念头头是道但工作中遇到具体问题却对不上号。可见性问题来自CPU缓存和内存之间的数据不一致。一个线程修改了变量另一个线程可能还看不到最新值。volatile和Lock都能解决可见性。原子性问题来自并发读写同一个变量的竞争synchronized、Lock、Atomic*系列都能解决。有序性问题是因为编译器和CPU为了优化可能重排指令。最常见的是单例模式的双重检查锁如果instance不加volatile就可能拿到半初始化对象。因为new操作不是原子的会经过“分配内存、初始化对象、把引用赋值给字段”三步存在指令重排的可能。public class Singleton { private static volatile Singleton instance; private Singleton() {} public static Singleton getInstance() { if (instance null) { synchronized (Singleton.class) { if (instance null) { instance new Singleton(); } } } return instance; } }这里volatile是必需的它禁止了instance赋值前后的指令重排。如果没有它另一个线程可能在引用已非空但对象尚未完全构造完成时读到一个“半成品”。我还遇到过一次奇怪的性能问题一段不需要锁的日志统计代码在并发高时数据总是对不上。后来发现是因为我用了一个普通的HashMap当作共享计数器并发写的可见性和原子性都出问题。换成LongAdder之后数据立刻准了。所以并发场景下先想清楚共享数据该用哪种并发容器而不是依赖运气。关于JMMJava内存模型不需要把规范背下来但要知道它定义了主内存和工作内存的关系以及volatile、synchronized、Lock这些关键字在锁语义上的保证。如果能理解happens-before原则很多并发问题就能从根源上想清楚。比如“解锁发生在加锁之前”这条规则就保证了同一个锁保护的所有变量在释放锁之后对后续获取该锁的线程是可见的。这也是synchronized既能保证原子性又能保证可见性的底层逻辑。最后说一点实操体会。并发调试是最容易让人头疼的因为问题往往不是必现。我的经验是先在代码里埋好日志记录线程名、时间戳和关键变量值然后尽量用压测工具把并发量拉上去压出问题后再分析线程转储thread dump。很多诡异的问题比如死锁、活锁、CPU飙高都能通过反复抓线程栈看出来。不要指望靠眼睛看代码就能发现所有并发bug工具和日志才是你最好的朋友。以上这些内容就是我在“基础一”之后最想让新人掌握的东西。集合、异常、泛型、并发每个主题都能讲上三天三夜但关键不在于背多少知识点而在于写每一行代码时都能去想它背后的运行机制和可能出现的坑。把这些基础啃扎实后面看框架源码、设计高并发系统才会事半功倍。