JavaSE基础进阶:从集合到多线程的高频踩坑与实战要点

发布时间:2026/9/9 21:26:34
JavaSE基础进阶:从集合到多线程的高频踩坑与实战要点 说点实在的。JavaSE这块东西很多人在基础1阶段学得挺顺什么变量、循环、数组、方法听一遍就会写起来也像那么回事。但一到基础2——面向对象深入、集合、泛型、异常、IO、多线程——就开始懵了。不是知识点难而是这些内容不再是背语法能解决的它需要你用写代码的思路去理解而不只是读代码的思路。我个人带过不少新人也面试过不少候选人一个特别明显的现象是很多人能把HashMap的原理背得滚瓜烂熟但真让他写一段遍历时删除元素的代码五分钟之内必翻车。这就是JavaSE基础阶段最典型的知识懂了能力没到。所以我写这篇文章的定位很明确不重复教科书上的定义而是把JavaSE基础阶段最容易被忽略、最容易踩坑、面试和实际开发中最高频的知识点串一遍每个点都给出我实测过的方案和踩过的坑。这篇文章适合正在从会写Java向写好Java过渡的人也适合准备校招、实习面试前做基础复盘的人。如果你刚学完语法准备进入面向对象和核心类库阶段那这篇文章基本上就是你接下来一个月要反复翻的清单。1. 先想清楚JavaSE基础到底在学什么1.1 从基础1到基础2差的不是语法是编程思维很多人在基础阶段有一个误区觉得JavaSE就是语法加类库把for、if、class、new这些关键字背熟再把String、ArrayList、HashMap的方法记住就算学完了。但真到了写项目、做笔试题的时候你会发现根本不是这么回事。基础1阶段解决的是这行代码在干什么的问题基础2阶段解决的是这段代码为什么要这么写的问题。举个例子ArrayList和LinkedList基础1只要知道一个底层是数组、一个是链表查询和增删各有优劣就完事了。但到了基础2你得明白实际开发中LinkedList到底什么时候用为什么JDK源码里很多地方宁愿用ArrayList也不用LinkedList这背后涉及的不仅是数据结构还有CPU缓存局部性、内存随机访问开销、GC压力甚至代码的可读性和维护成本。再比如继承。基础1学的是子类继承父类的属性和方法考试的时候画个UML图就完事。但基础2要解决的是继承层次多深才算合理为什么组合优先于继承接口和抽象类到底怎么选这些问题的答案不是靠背而是靠大量写代码、大量读别人代码后沉淀出来的经验。所以我说JavaSE基础2的核心不是更多语法而是把之前学的语法用活。这个阶段的学习方式也要跟着变不能再一个知识点一个知识点地孤立学而是要开始做横向对比ArrayList和LinkedList对比、HashMap和TreeMap对比、synchronized和volatile对比、throw和throws对比。对比着学知识才是活的。1.2 这篇文章的覆盖范围和使用方式这篇文章覆盖的内容基本就是JavaSE基础阶段的核心板块面向对象深入继承、接口、多态、集合框架、泛型、异常处理、多线程基础、IO流。每个板块我不会把API一个个列出来给你念而是挑最关键的几个决策点讲透。我建议你按下面这种方式使用这篇文章第一遍通读把自己不熟悉的概念标记出来第二遍针对标记的概念打开IDE写对应的Demo代码亲手跑一遍第三遍把文中的参考代码自己默写一遍不看原文。三遍下来基础阶段的硬骨头基本就啃得差不多了。提示这篇文章里的代码都是我在实际教学中反复用过的简化示例不代表生产级写法但能帮你把核心机制看清楚。生产环境里的代码还会有框架封装、设计模式、参数校验等一堆东西那不是基础阶段要考虑的事。2. 面向对象进阶继承、接口与多态的正确打开方式2.1 继承不是所有is-a关系都该用继承继承是面向对象三大特性里最容易被滥用的一个。新人学完继承看到一个类跟另一个类有点像立刻想extends一把。这个习惯特别危险。我在实际开发中见过一个印象很深的例子有个同事写了一个Order类表示订单后来需求要加售后订单他发现售后订单跟普通订单字段高度重合就搞了个AfterSaleOrder extends Order。一开始确实爽字段全继承了代码也少写了很多。但需求迭代到第三个月就崩了——售后订单的很多状态流转跟普通订单完全不一样某些字段在售后场景下压根不该出现但又不好在父类里去掉因为会影响普通订单。最终只能重构把公共字段抽到一个基类里两个订单类各自独立。这就是典型的为了复用代码而继承。继承的核心价值不是复用代码而是表达子类是一种特殊的父类并且能通过父类引用操作子类对象也就是多态。如果你只是想把公共字段和方法抽出来共用优先考虑组合——也就是把一个类的实例作为另一个类的字段。我做技术选型时一般这么判断如果两个类之间确实存在is-a关系而且父类被设计为模板子类只是在父类基础上做扩展或重写某个行为用继承没问题如果只是为了代码复用或者两个类的关系更像has-a一个类拥有另一个类那必须用组合。这个原则在Effective Java里被反复强调我自己写项目的时候也一直把组合优先于继承贴在工位上。此外继承层次一旦超过三层就要警觉了。三层以上的继承链每次修改父类都可能引发连锁反应而且代码的可读性急剧下降。你想想一个方法调下去要翻三四个类的源码才能找到真正的实现这种代码谁维护谁想哭。2.2 接口与抽象类别再背抽象类可以有构造方法接口不行这种口诀了很多人区分接口和抽象类的办法是背差异清单抽象类可以有构造方法、可以有成员变量、可以有普通方法接口在Java 8之后可以有默认方法和静态方法但成员变量只能是常量。这些差异背下来没问题问题是背完你还是不知道写代码的时候该用哪个。我自己的判断标准很简单抽象类描述的是你是什么接口描述的是你能做什么。一个类继承抽象类本质上是继承了一类事物的公共特征实现一个接口本质上是承诺了我具备某种能力。举个生活中的类比交通工具是个抽象类因为无论是汽车、火车还是飞机它们都是交通工具都有运载能力这个公共特征。而可充电是个接口因为电动车可以充电、手机可以充电、充电宝也可以充电它们之间没有任何继承关系但都具备充电这种能力。Java不允许多继承就是因为一个类既可以是交通工具又可以是电子产品这种多维度分类用继承根本表达不了必须靠接口。所以具体到编码上我的习惯是当多个类之间需要共享代码公共字段、公共方法体而且它们确实属于同一类事物时用抽象类当多个类只是需要具备相同的行为能力但实现完全不同或者这些类本来就没有父子关系时用接口。现在企业级开发中接口的使用频率远高于抽象类因为接口天然适合做解耦和依赖注入。2.3 多态的价值写框架、做扩展的根基多态说白了就是一句话编译看左边运行看右边。Animal a new Dog();编译器把a当作Animal类型来校验但运行时调用的eat()方法却是Dog类里重写的那份。很多初学者学到这里觉得这就是个语法技巧没什么实际用。但多态恰恰是整个Java框架体系的基石。你想想Spring里为什么能通过Autowired注入一个接口类型然后运行时却拿到一个具体的实现类这背后依赖的就是多态。我自己最直观的体会是做策略模式的时候。假设有个订单折扣系统不同的用户等级对应不同的折扣计算逻辑。如果不用多态你得写一大坨if-else如果是VIP用户按VIP规则算如果是普通用户按普通规则算。但用多态你只需要定义DiscountStrategy接口VipDiscount和NormalDiscount各实现一套算法然后通过一个工厂或者Map把用户等级映射到对应的策略对象上。新增一种用户等级的时候不用改任何已有的逻辑代码只加一个新策略类就行。这就是多态真正的价值让代码对扩展开放、对修改关闭。基础阶段学多态不要只看语法一定要动手写一个简单的策略模式例子感受一下面向接口编程和面向具体类编程的差别。3. 集合框架高频用高频率踩坑3.1 ArrayList和LinkedList别再用查询选ArrayList增删选LinkedList这种说法了我相信这是很多人在网上看到的结论ArrayList底层是数组随机访问快但插入删除慢LinkedList底层是双向链表随机访问慢但插入删除快。考试这么答没问题但实际开发完全不是这么玩的。先说结论绝大多数业务场景下ArrayList都是更好的选择。原因有几个。第一LinkedList的插入删除快是理想情况。它快在已知节点位置后的插入删除比如list.addFirst()、list.removeFirst()。但如果你要删除一个中间元素你首先得遍历到这个位置这个遍历过程的时间复杂度就是O(n)。而ArrayList删除中间元素的代价主要是数组元素的移动虽然也是O(n)但数组的System.arraycopy是底层native方法经过JIT优化后速度非常快实际性能往往比LinkedList逐个节点改指针还要好。第二LinkedList的内存占用更大。每个节点除了存储数据本身还要存前驱和后继两个引用。在64位JVM上关闭压缩指针时一个引用占8字节算下来每个节点光指针开销就是16字节。再加上节点对象本身的头信息一个LinkedList节点实际占用的内存可能是数据本身的几倍。数据量一上来GC压力非常明显。第三CPU缓存友好性。ArrayList的底层数组在内存中是连续的遍历的时候CPU能走缓存预取速度快LinkedList的节点分散在堆的各处完全违背局部性原理遍历一次等于在内存里到处跳。我做了这么多年开发LinkedList真正发挥价值的场景确实很少。常见的也就是实现一个简单的FIFO队列或者在需要频繁在头部插入删除时用它。JDK里LinkedList还实现了Deque接口可以在需要双端队列时用。其他场景老老实实用ArrayList别再假装优化了。3.2 HashMap容量、负载因子和哈希碰撞面试必问但很多人没真正懂HashMap是集合框架里最重要的一个类没有之一。它的面试地位这么高原因是它背后涉及的哈希表设计思想、扩容机制、多线程安全等问题能很直观地反映一个开发者对数据结构和JVM的理解程度。我在面试里最喜欢问的一个问题是HashMap的默认负载因子为什么是0.75注意不是负载因子是什么而是为什么是0.75。能答出空间和时间的权衡只是及格能答出泊松分布和红黑树化阈值8的关系才是优秀。这么理解比较简单负载因子 元素个数 / 桶数组长度。负载因子越高意味着桶数组越满空间利用率越高但哈希碰撞的概率也越大链表变长查询效率下降负载因子越低桶数组越空碰撞少但浪费内存。0.75是JDK团队在大量实验基础上取的一个平衡点。更细一层当负载因子为0.75时桶数组中的元素个数达到泊松分布下每个桶的链表长度超过8的概率极低约千万分之六所以JDK才把链表转红黑树的阈设定为8。这两个数字是配套的面试能讲到这一层说明你真的理解了。再说一个实战中特别容易踩的坑HashMap的初始容量设置。很多人不知道HashMap的容量必须是2的幂次方也不知道new HashMap(1000)实际容量会被调整到1024而new HashMap(10000)实际容量不是1024的倍数而是16384。更关键的是如果你预计要存1000个元素你直接new HashMap(1000)在默认负载因子下它会在元素个数达到1024 * 0.75 768时就触发扩容白白多了一次扩容操作。正确做法是设置成1000 / 0.75 1 ≈ 1334也就是new HashMap(1334)。当然实际API会被调整为2的幂这里我们直接按计算值传入就行。3.3 遍历时删除元素ConcurrentModificationException现场这是一个我见过无数次翻车的代码几乎每个新人都会踩一次ListString list new ArrayList(); list.add(a); list.add(b); list.add(c); for (String s : list) { if (b.equals(s)) { list.remove(s); } }这段代码跑起来大概率会抛ConcurrentModificationException。原因很简单for-each语法糖背后是IteratorIterator内部维护了一个modCount修改次数的期望值当你用List自身的remove方法删除元素时modCount变了但Iterator不知道下一次next()时一比对就发现不一致立刻抛出异常。正确的删除方式有两种。第一种是用Iterator自己的removeIteratorString it list.iterator(); while (it.hasNext()) { String s it.next(); if (b.equals(s)) { it.remove(); } }第二种是用ArrayList的removeIf这个方法是Java 8加入的底层也是走Iteratorlist.removeIf(s - b.equals(s));如果你要遍历一个HashMap并删除符合条件的键值对思路完全一样要么用EntrySet的Iterator删除要么用Map的entrySet().removeIf()。注意还有一个边删边加的场景也就是遍历时往List里新增元素。这个场景下Iterator同样会检测到modCount变化。如果你确实需要在遍历过程中动态插入元素建议改用ListIterator它对双向遍历和插入有专门支持。或者在遍历结束后统一批量添加大部分情况后一种方式才是正确解法。4. 泛型和异常两个看起来会了一写就废的知识点4.1 泛型为什么大家都在说泛型只是一个编译期概念泛型这个知识点特别有意思。很多人在学的时候觉得不就是ListString这么写嘛会了。但真到写一个通用的工具类、泛型方法或者读别人框架源码的时候就彻底晕了。我经常给新人强调一句话Java泛型的信息只在编译期存在运行时会通过类型擦除把它还原成原始类型。也就是说ListString和ListInteger在运行时的Class对象是同一个ArrayList.class泛型只是编译器用来做类型检查的临时约束。这个特性带来一个很经典的面试题为什么不能用T.class原因就是T在运行时已经被擦除了你根本拿不到实际的类型信息。这也是为什么很多框架在做反序列化、JSON转换时需要你显式传TypeReference目的就是绕过擦除保存泛型类型信息。在实际编码中泛型用得最多的是泛型方法。比如想写一个把任意类型数组转成List的工具方法public static T ListT arrayToList(T[] array) { return new ArrayList(Arrays.asList(array)); }注意T要放在static和返回值之间这代表声明了一个类型变量。这个方法里T可以在参数、返回值和方法体内使用但不允许new T()因为构造对象的类型信息在运行时不存在。泛型还有一个容易出错的细节泛型不能使用基本类型。Listint会编译报错必须写成ListInteger。这是因为泛型擦除后类型变量都要变成引用类型基本类型无法被擦除成Object。这也是为什么会有自动装箱拆箱机制——虽然它在性能上是有代价的。4.2 异常处理三个原则让我少写一半无用代码异常处理在项目里的重要性怎么强调都不为过。很多代码review的时候我看到最辣眼睛的是一大片的try-catch包裹所有代码然后catch里就一个e.printStackTrace()。我自己写异常处理总结了三句话可以拿来当检查清单。第一句不要捕获你不打算处理的异常。很多新人学了try-catch后把代码全往try里一扔catch (Exception e)兜底然后打一行日志就完了。这就是典型的假装处理了异常。如果你不知道这个异常该怎么恢复、该怎么降级就别捕获往上抛让知道的人处理。第二句捕获异常时要有具体的类型不要直接Exception。你需要明确知道这段代码可能抛什么异常才能决定怎么处理。是IOException可能需要重试或提示用户是IllegalArgumentException说明是参数问题应该修正传入值而不是捕获。catch (Exception e)会把所有情况混在一起调试的时候你根本不知道真正出了什么问题。第三句异常信息里带上上下文。我在日志里最怕看到NullPointerException因为NPE经常不带消息你不知道是哪个变量为空。所以捕获异常的时候一定要把关键参数、当前状态记录到日志里。try { // 业务代码 } catch (IOException e) { log.error(文件上传失败, fileName{}, userId{}, fileName, userId, e); throw new BizException(上传失败请重试, e); }这样一看日志马上能定位是哪个文件、哪个用户、什么环节出了错。4.3 try-with-resources为什么我要求所有IO代码必须用它在Java 7之前关闭文件流、数据库连接、网络连接这种资源都要在finally块里手动close()代码长且容易漏。关键是就算你写了close()如果在关闭之前代码抛了异常资源的关闭操作可能根本没执行到。Java 7引入的try-with-resources解决了这个问题。只要资源的类实现了AutoCloseable接口就可以在里面声明等try块执行完后JVM会自动调用close()而且是在异常被处理之前先关闭资源。try (FileInputStream fis new FileInputStream(a.txt); BufferedReader reader new BufferedReader(new InputStreamReader(fis, StandardCharsets.UTF_8))) { // 读取操作 } catch (IOException e) { // 处理异常 }写起来简洁而且不会漏。Java 9之后还支持在外部声明变量然后放进try块BufferedReader reader new BufferedReader(new InputStreamReader(System.in)); try (reader) { // 使用 }但要注意这样写的话reader这个引用在try块之后就不应该再用了因为资源已经关闭。我对自己带的学生有一条硬性要求凡是写了流、连接、游标这类资源一律用try-with-resources不用再写finally手动关闭。实践证明这个习惯能避免掉90%的资源泄漏问题。5. 多线程入门你需要学的不是怎么写线程而是线程会出什么问题5.1 创建线程的三种方式其实只有两种值得用Java里创建线程有三种写法继承Thread类、实现Runnable接口、实现Callable接口配合FutureTask。教科书上会把三种方式都列出来但实际开发中第一种几乎没什么使用场景了。原因很简单Java是单继承你继承了Thread就不能继承其他类了而且继承Thread把任务代码和线程控制代码耦合在一起既不灵活也不好测试。实现Runnable接口则把要执行的任务和执行任务的线程解耦开任务可以复用也可以扔给线程池执行。Callable和Runnable的区别是Runnable的run()方法没有返回值、不能抛受检异常Callable的call()方法有返回值、可以抛异常。所以如果你需要线程执行完返回一个结果用Callable不需要返回值用Runnable。不过说实话到了现在这个阶段不管是Runnable还是Callable我们实际写代码时都很少直接new Thread去启动而是交给线程池ExecutorService管理。线程池能复用线程、控制并发数、统一管理生命周期比裸写线程靠谱得多。基础阶段你只要能理解Runnable和Callable的定位区别会使用ExecutorService的execute和submit就够用了。5.2 synchronized和volatile一个管互斥一个管可见性多线程入门最大的坎是理解synchronized和volatile的区别。很多新人把两者混为一谈觉得都是让线程安全的其实它们的定位完全不同。volatile解决的是可见性问题。如果一个变量被volatile修饰当一个线程修改了这个变量的值JVM会强制把这个新值立即写回主内存并且让其他线程中该变量的缓存行失效下次读取时必须从主内存重新读。但它不保证原子性。也就是说volatile能保证你读到的值是最新的但没法保证读-改-写这个复合操作的安全。典型的例子是count它包含读取、加一、写回三步volatile管不了。synchronized解决的是互斥问题即同一时刻只能有一个线程访问这段代码。它同时也能保证可见性因为线程在进入同步块时会从主内存刷新变量退出同步块时会刷新到主内存。只是它更重因为它涉及锁的获取和释放有上下文切换的开销。实际项目中的选择原则是多个线程同时读一个共享变量或者一个线程写、多个线程读且写入不依赖当前值用volatile就够了如果有多个线程同时读写同一个共享状态或者存在读-改-写操作必须用synchronized或者java.util.concurrent包里的锁和原子类。注意还有一个高频坑volatile不能替代synchronized实现仅在初始化时赋值一次的线程安全。如果你要写一个线程安全的单例目前最稳妥的方式是使用静态内部类或者枚举而不是简单加个volatile就完事。5.3 死锁的产生与排查一个案例讲透死锁是并发编程里又经典又让人头疼的问题。它产生需要同时满足四个条件互斥、持有并等待、不可剥夺、循环等待。代码里最常见的情况是线程A持有锁1等待锁2线程B持有锁2等待锁1。两个线程就这么僵住了。我写一个经典的死锁示例Object lockA new Object(); Object lockB new Object(); // 线程1 new Thread(() - { synchronized (lockA) { System.out.println(线程1拿到lockA); try { Thread.sleep(100); } catch (InterruptedException e) {} synchronized (lockB) { System.out.println(线程1拿到lockB); } } }).start(); // 线程2 new Thread(() - { synchronized (lockB) { System.out.println(线程2拿到lockB); try { Thread.sleep(100); } catch (InterruptedException e) {} synchronized (lockA) { System.out.println(线程2拿到lockA); } } }).start();跑起来后大概率两个线程就在互相等待中挂住了。排查死锁的方法我建议分三步先看日志和应用状态程序是否卡在某个点不再输出用jps找到Java进程ID再用jstack pid导出线程堆栈在jstack输出中搜索Found one Java-level deadlock它会直接告诉你死锁的线程和锁的持有关系。避免死锁最有效的方法是按固定顺序加锁。比如约定所有线程都先拿lockA再拿lockB循环等待的条件就被破坏了死锁自然无从谈起。第二个方法是使用tryLock带超时时间拿不到锁就放弃并释放已持的锁而不是无限等待。第三种是用更高层的并发工具比如ReentrantLock配合lockInterruptibly或者直接用CountDownLatch、Semaphore这类不需要手动管理锁的组件。6. IO流与常用类这块学完JavaSE基础就闭环了6.1 字节流和字符流一句话讲清选择标准IO流这块概念特别多InputStream、OutputStream、Reader、Writer、字节、字符、缓冲流、转换流。新人最容易问的问题是到底用哪个选择标准其实一句话就能讲清楚操作的是二进制文件图片、视频、音频、压缩包就用字节流操作的是文本文件尤其涉及到中文读写就用字符流。为什么操作文本要用字符流因为字符流对编码做了处理。你用字节流读取一个UTF-8编码的中文文本文件一次读一个字节读出来的字节序列单独看不代表任何字符必须拼接成完整的字节序列再解码。而Reader会自动按给定的字符集把字节解码成字符你读到的是什么就是什么不用自己处理。有一个经典坑用FileReader直接读文件它会使用平台默认编码。在Windows上可能是GBK在Linux上可能是UTF-8同一段代码换个环境就乱码了。所以正确做法是指定编码BufferedReader reader new BufferedReader( new InputStreamReader(new FileInputStream(a.txt), StandardCharsets.UTF_8));这里InputStreamReader就是字节流通往字符流的桥梁它负责按指定的字符集把字节转换成字符。记住这个组合比背一堆流类要管用。6.2 传统IO还是NIO基础阶段先把传统IO练熟java.nio包下的Channel、Buffer、Selector这些是入门阶段很容易被吓退的内容。我不建议基础阶段一上来就啃NIO除非你要做网络编程或高并发服务器。先把传统IO的读写、文件复制、缓冲流用的滚瓜烂熟才是正道。NIO和传统IO的核心区别在于传统IO是阻塞的、面向流的NIO是支持非阻塞的、面向缓冲区的。举个例子传统InputStream读数据如果数据没准备好线程会一直阻塞在那里等NIO有Selector这个多路复用器一个线程可以同时监听多个Channel的就绪状态哪个有数据就处理哪个。这是Netty这类高性能网络框架的基石但普通业务开发中用到的机会确实不多。基础阶段需要掌握的IO能力我认为有这几项读写文本文件、复制文件小文件用Files.copy大文件用缓冲流、读写对象ObjectOutputStream和ObjectInputStream、用Properties读写配置文件。这些掌握了基本够用。等后面接触Netty或者RPC框架时再回头深入NIO不迟。6.3 常用类补充String、StringBuilder、包装类的时间开销这个板块我想补充几个容易忽略的性能细节。JavaSE里最常用的就是String但很多人对它的机制不太上心。String本身是不可变对象每次用拼接字符串时本质上都会生成新的String对象在循环里大量拼接的话会产生大量的中间对象加剧GC负担。所以循环内拼接字符串用StringBuilderStringBuilder sb new StringBuilder(); for (int i 0; i 10000; i) { sb.append(i); } String result sb.toString();StringBuffer和StringBuilder的区别是前者线程安全、方法加了synchronized单线程环境没有区别但会有性能损耗。所以单线程场景一律用StringBuilder别为了保险用StringBuffer。包装类的坑主要体现在拆装箱上。比如Integer a 127; Integer b 127; System.out.println(a b); // true Integer c 128; Integer d 128; System.out.println(c d); // false原因是Integer在-128到127之间会走缓存超过这个范围就是new出来的新对象比较的是引用地址自然就false了。这种代码在开发中很容易遇到建议任何包装类的比较都改用equals()。7. 常见问题与排查技巧实录7.1 高频异常速查表基础阶段最常见的异常我整理成一张速查表遇到的时候直接对号入座异常出现场景排查思路NullPointerException调用了一个null对象的方法看堆栈定位行号检查进方法后哪些参数/中间变量没有判空ArrayIndexOutOfBoundsException数组下标越界检查for循环边界注意length - 1ClassCastException类型转换失败检查转型前的对象实际类型是否用了instanceof判断ConcurrentModificationException遍历时修改了集合结构改用Iterator或removeIfNumberFormatException字符串转数字失败检查字符串是否为纯数字是否有空格、空串OutOfMemoryError堆内存溢出用jstat、jmap分析堆转储7.2 遇到bug时的排查顺序我自己排查bug有一套固定的流程分享出来可以作为参考。第一步是复现问题拿到稳定复现的操作路径第二步是看日志特别是异常堆栈中第一个at开头的行那才是真正出错的代码行第三步是根据堆栈信息定位到具体代码用断点或日志输出关键变量的值第四步是修复后回归验证。新人最常见的毛病是看到一个异常就上网搜XXX报错怎么解决然后抄一段代码就完事完全不理解为什么报错、为什么这样改能好。这种习惯一定要改。任何报错先看堆栈先想原因再动手修。7.3 一个小技巧用javap看字节码最后分享一个小技巧。学习JavaSE基础阶段强烈建议学会用javap命令反编译看字节码。比如你想知道String的拼接在编译后到底变成了什么可以直接跑javap -c Test.class你会看到编译器实际上是用StringBuilder实现的拼接。这个技巧能帮你打破很多我以为的认知直接从字节码层面理解Java的行为。类似的还有用javap -v查看类的常量池、泛型签名信息。在基础阶段把字节码看明白一点后面学JVM、看框架源码都会轻松很多。8. 一些学习方法和心态上的建议前面的内容都是实打实的知识点最后这部分我想聊点软性的东西。JavaSE基础阶段的学习最容易出现的问题不是学不会而是觉得会了。我这个感受特别深很多同学学完集合觉得HashMap不就是节点数组加链表嘛结果一写代码就各种别扭学完多线程觉得synchronized加在方法上就行结果一跑就出现并发问题。原因很简单JavaSE基础的知识密度低但思维密度高光看懂离会用还差着十万八千里。我个人的建议是每学一个板块就给自己布置一个小的编程练习。学完集合写一个单词统计工具用HashMap统计一篇文章里每个单词出现的次数学完IO写一个文件复制工具支持递归复制文件夹学完多线程写一个模拟多窗口卖票的例子看看不加锁和加锁的差别。这些练习都不大但做完之后你对知识的理解会从表层记忆变成肌肉记忆。另外多说一句一定要养成看JDK源码的习惯。JavaSE基础阶段你至少应该看过这几个源码ArrayList的扩容方法grow()、HashMap的putVal()、String的equals()和hashCode()。看的时候不用全看懂第一次看会觉得很挫败没关系看多了自然就通了。源码是最精确的文档网上任何二手讲解都不如源码本身准确。我对JavaSE基础阶段的最大体会是不要把这段时期当成背API的时期。API这东西用的时候查文档就行真正拉开差距的是你对底层机制的理解深度、对异常和边界情况的敏感度、对代码设计的判断力。这三种能力都是在基础阶段打下来的。所以慢慢来代码一行一行写坑一个一个踩JavaSE基础这一关过去之后后面的路会顺畅很多。