1. 项目概述与核心目标先聊几句题外话。我见过太多人一上来就抱着Spring Boot、微服务架构啃结果写出来的代码连最基本的语法规范都不过关。Java基础语法这东西你觉得你懂了可一写就露馅String用比较、数组越界、try-catch乱套、泛型裸用这些坑我在面试里见得多了。“java基础语法Top1”这个项目说白了就是把Java语法里优先级最高、日常开发出镜率最高的那部分内容浓缩成一套可以直接上手练的知识框架。它不是什么高深的东西但它是你写所有Java代码的地基。如果你是一个刚开始学Java、或者学了一阵子但总觉得根基不稳的人这个框架能帮你把零散的知识点串成一条线知道先学什么、后学什么哪些是重点、哪些是冷门。我按照多年实际开发和带新人的经验把Java基础语法拆成了几个优先层级。Top1意味着这些内容你必须在写业务代码之前彻底搞定否则后面的路会走得非常难受。这篇文章就把这套框架完整拆开每一块我都给你讲清楚为什么它是Top1以及怎么在实战里把它用明白。2. 环境搭建与第一个程序的完整闭环2.1 JDK安装与环境变量的底层逻辑很多教程会让你直接下载JDK然后一路下一步但没告诉你为什么要配置环境变量。我当时第一次装的时候也是懵的后来才明白Java程序运行要靠JVMJVM在系统里是一个虚拟的进程操作系统需要知道去哪里找到它的启动程序这就是环境变量的作用。以Windows为例你需要配置的大体上是两个变量一个是JAVA_HOME它指向你JDK的安装根目录另一个是Path在里面追加一条%JAVA_HOME%\bin。为什么是bin目录因为javac和java这两个核心命令就放在这里。javac负责把.java源码编译成.class字节码java负责启动JVM执行字节码。安装完之后打开命令行敲一句java -version如果能看到版本号而不是“不是内部或外部命令”环境就通了。这一步不要跳过我见过太多人装完JDK直接开IDEIDE自带编译器所以没报错但一换到纯命令行环境就抓瞎。2.2 第一个程序里藏着的关键细节新建一个Hello.java文件写下面代码public class Hello { public static void main(String[] args) { System.out.println(Hello, Java!); } }这段代码任何一本入门书里都有但有几个细节容易被忽略。第一文件名必须和public类名一致包括大小写。Hello类的文件名必须是Hello.java。不一致的话编译器直接报错。这是Java的硬性规定因为JVM加载类时是根据类名找对应的.class文件的。第二main方法的签名是固定的public static void main(String[] args)。public表示JVM可以访问它static表示不需要创建对象就能调用void表示这个方法不返回任何东西。有人问过我可不可以把public去掉答案是去掉之后编译能过但运行会报“找不到主方法”因为 JVM 的规范里明确要求这个签名。第三System.out.println里的System是JDK提供的一个工具类out是System类里的一个静态字段println才是真正干活的打印方法。Java里一切都是对象这句话不假但这些基础工具类帮我们封装好了底层操作你要先学会用它们再去研究底层实现。编译运行的过程你也要亲手走一遍在命令行里进入Hello.java所在目录执行javac Hello.java会生成一个Hello.class文件再执行java Hello注意不带.class后缀所以这行的class是在classpath里查找的JVM会默认在当前目录搜索。我现在还保留着用命令行编译的习惯因为IDE的自动编译掩盖了太多过程问题。你在初学阶段至少前两周建议都用命令行操作系统来操作盘这样才能把“编译”和“运行”这两个动作刻在脑子里。2.3 IDE选型与学习期使用建议环境通了之后你可以选择一个IDE我推荐从IntelliJ IDEA Community版入手或者Eclipse也行看你的操作系统、内存和习惯。社区版免费而且对Java基础学习完全够用。但这里我要提一个反直觉的建议别让IDE帮你把所有事都干了。自动补全、一键运行、错误高亮这些功能在你写代码时确实爽但会让你的大脑偷懒。我见过一个同行写了两年Java问他“为什么public class前面的public去掉会怎么样”他说不出来。建议的学习节奏是先在记事本或VSCode里手写代码用命令行编译运行等你能完全理解代码从写到跑的每一步再切换IDE提高效率。这样你之后遇到IDE抽风或者需要排查编译问题时不会被一些底层概念卡住。3. 基础语法核心变量、数据类型、运算符的优先掌握项3.1 八大基本类型与引用类型的边界感Java的基础语法里数据类型的分界是第一个让你产生“就这”然后又反复出错的地方。八大基本类型必须烂熟于心byte short int long float double char boolean每个都有自己的取值范围和默认值。int是最常见的整数类型取值范围大约是负21亿到正21亿。超出这个范围你就要用long而且long类型的字面量需要加一个L后缀比如long big 10000000000L;不加后缀编译器会认为它是int然后给你报错。float和double的区别也是高频考点。float精度低加F后缀double是默认的小数类型。日常计算里只要是涉及金额或者需要精确计算的地方这两个类型都不合适要改用BigDecimal这个我在后面的章节讲。我提一个很多人忽视的点char是16位无符号整数它能存的字符是UTF-16编码里的一个代码单元。中文每个字在Java里占用一个char但部分生僻字会占用两个char这就是为什么有时候你对字符串做charAt操作会得到奇怪的乱码。基础语法不要求你精通编码但知道这个边界后面处理字符串时就不容易懵。引用类型这个概念也很重要。除了基本类型其他都是引用类型。包括我们写的类、接口、数组、String、还有各种集合类。引用类型变量存的是对象的地址而不是对象本身。两个引用变量用比较的是地址而不是内容。这一点怎么强调都不为过后面的String章节我会再次展开。3.2 运算符的低级错误与优先级陷阱运算符这里常见的有算术运算符、赋值运算符、比较运算符、逻辑运算符、位运算符、三元运算符。Top1里我建议重点练透几个int a 10; int b a; // 先赋值后自增b10a11 int c a; // 先自增后赋值a12c12a和a的区别初学者至少要被坑十次才能彻底不出错。我的记忆方法是在变量左边先加后用在变量右边先用后加。逻辑运算符里和||有短路效应。false 任意表达式的结果一定是false所以右侧表达式不会执行。这个特性在写代码时可以用来做条件保护比如if (user ! null user.getName().equals(admin)) { // 只有当user不为null时第二个条件才会执行 }如果不加user ! null这个判断直接调用user.getName()就会出现空指针异常。这个写法在真实业务代码里出现频率极高。三元运算符条件 ? 值1 : 值2本身不难但用多了会让代码难读。我的建议是简单赋值场景可以用逻辑复杂就老老实实写if-else因为团队协作里别人读你的代码要花的时间比你自己写的时间更值钱。3.3 类型转换的隐形陷阱自动类型转换和强制类型转换是两个方向相反的操作。int i 100; long l i; // 自动类型转换小转大安全 byte b (byte) i; // 强制类型转换大转小可能丢失数据强制类型转换是个危险动作。因为byte只有8位int是32位你把一个int转成byte会截断高位数据。比如int i 128; byte b (byte) i; // 结果是-128因为128的二进制在第8位上刚好溢出为什么是负128因为128的二进制是1000 0000转成字节后最高位被当作符号位就变成了-128。这种细节笔试题里爱出实际开发里也会遇到比如从数据库读到的数字要转成小范围类型时一定要先做范围校验不能盲转。另外还有一点int和char之间的转换。char可以隐式转成int因为每个字符都有对应的Unicode码点反过来int转char就要强制转换。写代码时最常见的场景是遍历字符String str abc; for (int i 0; i str.length(); i) { char c str.charAt(i); // 取出的就是char类型 }3.4 运算符优先级速查与建议优先级从高到低的简化记忆版本括号、一元运算符、乘除、加减、移位、关系、相等、位与、异或或、逻辑与、逻辑或、三元、赋值。我不建议你去背那张完整的优先级表实际开发里你只要记住一个原则就够了拿不准就加括号。(a b) * c比a b * c的意图明确得多。写代码是给同事和自己以后看的不要炫技。4. 流程控制if-else、switch、循环的正确打开方式4.1 if-else的条件判断细节流程控制是程序有了“智能”的根本。if-else本身太基础了但有几个点值得说道。第一个是else if的执行链条。Java会从上往下依次判断条件一旦某个条件成立后续的else if就不会再执行。所以条件的顺序是有讲究的。比如判断成绩等级如果你先判断score 60再判断score 90那么90分以上的学生会被归到及格档而不是优秀档因为第一个条件已经为true了。第二个是布尔条件的书写习惯。我见过有人写if (flag true)这个习惯不好。直接写if (flag)就够了一是不啰嗦二是避免了一个经典bug——把误写成。如果你写if (flag true)这不是在判断而是在给flag赋值赋值表达式的布尔值就是被赋的那个值所以条件恒为true。这种错误编译器不会报错但是行为完全错误。第三个是浮点数不能直接比较相等。if (a 0.1)这种写法基本不可靠因为浮点数在内存中的二进制表示是近似值。判断浮点数是否相等要么用差值范围要么转成BigDecimal比较。4.2 switch的演进与新写法switch在旧版本里只能匹配整数类型和枚举但从Java 7开始支持String从Java 14开始支持非常优雅的箭头表达式写法switch (day) { case MONDAY, FRIDAY - System.out.println(工作日); case SATURDAY, SUNDAY - System.out.println(休息日); default - System.out.println(未知); }这种新写法不需要break箭头右侧执行完自动跳出。相对于旧式switch里每条case后面都要手动加break不然会“穿透”执行下面的case箭头写法明显更不容易出错。我在实际项目里看旧代码看到break漏写导致的bug真不少。比如你要处理多个case执行同一段逻辑旧写法是把case堆一起再写代码新写法直接用逗号分隔多个匹配值清晰得多。如果你还在用老版本的Java至少也要养成每个case都写break的习惯。4.3 三种循环的使用场景取舍for、while、do-while是三个基本循环。很多人用循环只看习惯不看场景其实是吃亏的。已知循环次数用for。比如遍历数组你要用下标访问每个元素这天然适合for。未知循环次数、只要条件满足就继续用的用while。比如读取用户输入直到输入exit这种你不提前知道会读多少次的用while最自然。do-while用得最少它的特点是先执行一次循环体再判断条件所以循环体至少会执行一次。适用场景比如菜单选择你先显示一次菜单然后等用户输入判断是否退出。循环里的break和continue行为也要区分break直接终止整个循环continue跳过本次循环剩余代码直接进入下一次迭代。如果你在嵌套循环里break只会跳出最内层循环要想跳出外层需要给外层循环加标签outer: for (int i 0; i 5; i) { for (int j 0; j 5; j) { if (j 2) { break outer; // 跳出两层循环 } } }这个标签写法比较冷门什么时候会用到呢比如你在一个二维数组里查找某个值找到后想完全停止搜索不继续遍历了。不加标签的话你需要用一个额外的布尔变量来控制外层循环代码就绕了很多。for-each循环也值得单独拿出来说。从Java 5开始支持它的本质是编译器帮你生成了一个迭代器int[] arr {1, 2, 3}; for (int num : arr) { System.out.println(num); }for-each最大的优势是简洁读取容器里的每个元素时比传统for少写很多样板代码。缺陷是拿不到当前下标也不能在遍历过程中安全地删除元素。如果你需要下标或者要在循环里做结构性修改老老实实回到普通for或使用迭代器。5. 面向对象从类到接口的完整认知5.1 类是模板对象是实例面向对象是Java区别于C语言这类面向过程语言的核心特性。一句话概括类和对象的关系类是创建对象的模板对象是根据模板创建出来的具体实体。一个简单的类public class Student { private String name; private int age; public Student(String name, int age) { this.name name; this.age age; } public String getName() { return name; } }这里有几个语法层面的关键点。private修饰符表示字段只能在本类内部访问外部必须通过公开方法来操作这就是封装。为什么要把字段设为private因为直接暴露字段意味着外部代码可以往里面塞任意值你就失去了一次校验数据的机会。反之通过setter方法你可以在赋值之前加入逻辑比如年龄不能是负数、名字不能为空字符串等等。构造方法是创建对象时调用的特殊方法它的名字必须和类名一致没有返回值类型。上面的构造方法接收两个参数外部创建对象时这样写new Student(张三, 18)。如果你不写任何构造方法编译器会自动生成一个无参构造那就是new Student()。一旦你自定义了有参构造编译器就不会再生成无参的了这一点经常让新手困惑明明看到别人用无参构造能创建对象自己写却报错。this关键字代表当前对象。在构造方法里this.name name左边的name是当前对象的字段右边的name是参数。如果不加thisJava会认为两个name都是参数形成一个“自己给自己赋值”的无效操作字段仍然为空。这个问题在面试里出现率很高。对象创建完之后它在内存里的布局分三块栈里存放引用变量堆里存放对象实例本身方法区存放类信息。Java的垃圾回收器负责回收不再被引用的堆对象你不需要手动释放内存这一点跟C的差异也是Java的一大优势。5.2 继承、多态与重写避坑继承用extends关键字子类可以复用父类的字段和方法。多态的基础是继承或者接口实现它指的是同一个方法在不同对象上有不同的行为。public class Animal { public void speak() { System.out.println(动物发出声音); } } public class Dog extends Animal { Override public void speak() { System.out.println(狗叫); } } Animal a new Dog(); a.speak(); // 实际执行的是Dog的speak方法这里要理解一个动态绑定机制编译期Java看到a是Animal类型所以它认为调用speak()是合法的运行期JVM发现a实际指向的是Dog对象就转而执行Dog类重写的版本。这就是多态的底层原理。重写Override和重载Overload经常被混淆。重写是子类覆盖父类同名同参方法方法签名完全一样重载是同一个类里定义多个同名但参数列表不同的方法。重载发生在编译期重写发生在运行期两个概念使用的核心场景完全不同。使用继承时有一个经典坑子类构造方法一定要调用父类构造方法。Java编译器会在子类构造方法的第一行隐式调用父类的无参构造。如果父类没有无参构造比如你写了有参构造又没写无参的子类就必须通过super(...)显式调用父类构造方法否则编译直接失败。我在产品代码里见过很多人滥用继承为了复用两个方法硬把两个毫无关系的类搞成父子关系。实际的工程建议是优先组合而不是继承。继承的层级一旦深了父类一改所有子类都可能受影响维护成本极高。能用接口定义行为、用组合复用代码的就别往父子关系上硬凑。5.3 抽象类与接口的边界以及接口默认方法抽象类是“半成品类”用abstract修饰。它不能被实例化只能被继承。它里面可以有抽象方法只有声明没有实现也可以有普通方法和字段。接口在Java 8之前是“纯抽象”只能声明方法签名和常量。Java 8之后加入了默认方法default和静态方法让接口也能提供方法实现了。这个设计初衷是为了兼容已有的集合框架API——在给接口加新方法时不想破坏所有实现类所以提供带默认实现的版本。那抽象类和接口到底怎么选我的经验法则是三个字是什么还是能做什么。如果一个类本质上属于某个抽象概念的范畴用抽象类如果一个类需要具备某些能力用接口。经典例子狗是动物这是继承关系狗会游泳这是能力是接口。Java单继承多实现的特性注定了接口在Java里的地位比抽象类高——一个类可以 implements 多个接口但只能 extends 一个类。public interface Swimmer { void swim(); } public class Dog extends Animal implements Swimmer { Override public void speak() { System.out.println(狗叫); } Override public void swim() { System.out.println(狗刨); } }5.4 static与final的常见误读static关键字表示静态它修饰的成员属于类本身而不属于任何实例。静态字段在内存中只有一份所有实例共享。静态方法只能访问静态成员不能直接访问实例成员因为静态方法不依赖具体对象也就没有this可用。如果你在静态方法里试图访问实例字段编译器会报错。反而反过来是可以的实例方法里可以访问静态字段。这两句话值得你亲手写代码试一遍试完就记住了。final关键字有三层含义修饰变量时表示值不可变修饰方法时表示禁止子类重写修饰类时表示禁止继承。特别要提的是final修饰引用类型变量的情况final指向的是对象地址地址不能变但对象内部的状态可以变。final Student s new Student(张三, 18); s new Student(李四, 20); // 报错不能重新赋值 s.setName(王五); // 可以对象内部可以改还有一个高阶一点的用法Java中String为什么被设计成不可变的因为字符串字面量会被缓存到字符串常量池多个变量可以引用同一个字符串对象如果字符串可变那一个变量改内容就会影响其他变量。这个问题一开始理解起来可能有点绕但当你做字符串拼接、比较、哈希操作时就会发现不可变性帮了大忙。6. 数组与字符串日常编码最高频的两个类型6.1 数组的声明、初始化和遍历数组是固定长度的容器长度一旦确定就不能变。声明方式int[] arr1 new int[5]; // 默认值为0 int[] arr2 {1, 2, 3, 4, 5}; // 静态初始化长度自动推断为5 int[] arr3 new int[]{1, 2, 3}; // 动态初始化指定内容数组的下标从0开始最后一格的下标是length-1越界会抛ArrayIndexOutOfBoundsException。这个异常是运行时异常编译器根本不会提示你只有跑到那一行才炸出来。所以遍历数组的原则是始终用i arr.length作为循环边界不要写死数字。多维数组是一个数组的数组严格说Java没有真正的多维数组而是“数组的数组”。比如int[][] matrix new int[3][3]外层是长度为3的一位数组每个元素又是一个长度为3的一维数组。这个设计带来的灵活性是矩阵的每行长度可以不一样这在某些场景是有用的。Arrays工具类里的几个方法值得记一下Arrays.sort(arr); // 排序 Arrays.toString(arr); // 把数组内容转成字符串 Arrays.copyOf(arr, 10); // 复制数组指定新长度6.2 String不可变性与拼接的坑String是最常用的引用类型但它又是不可变的。每当你对String执行修改操作——substring、concat、replace——实际生成的是一个全新的String对象原对象原封不动。来看拼接的经典问题String str ; for (int i 0; i 10000; i) { str i; }这个循环在每次拼接时都会创建新的String对象旧对象变成垃圾等待回收。大量循环时性能瓶颈非常明显。正确做法是用StringBuilderStringBuilder sb new StringBuilder(); for (int i 0; i 10000; i) { sb.append(i); } String result sb.toString();StringBuilder内部维护了一个字符数组append操作是在这个数组上扩展不会反复创建新对象。为什么说这是坑因为编译器在单个拼接表达式比如a b c时会自动优化成StringBuilder让你觉得直接加号没问题。但在循环里它无法优化因为每次迭代的中间结果都是运行时才知道的。另一个巨坑就是用比较字符串内容String a abc; String b abc; System.out.println(a b); // true但原因特殊这个结果是true是因为字面量相等时会指向常量池里的同一个对象。但看下面这个String a new String(abc); String b abc; System.out.println(a b); // falsenew出来的对象在堆里a和b指向不同地址自然为false。实际开发中判断字符串相等唯一的正确姿势是a.equals(b)。如果是忽略大小写的场景用a.equalsIgnoreCase(b)。字符串为空或null的判断也是一个高频点if (str null || str.isEmpty()) { // 处理空值 }注意这里要先判断null再调用isEmpty利用前面提到的短路效应顺序错了会抛空指针。6.3 String、StringBuilder、StringBuffer怎么选StringBuffer和StringBuilder功能几乎一样区别在于StringBuffer是线程安全的方法加了synchronized但代价是性能稍差。日常开发中绝大多数场景是单线程操作直接选StringBuilder。不需要为了理论上的“安全”去用StringBuffer那点性能损耗在并发场景下是实打实的。判断自己该用哪个就问一个问题这个字符串拼接操作是否会有多个线程同时访问如果没有就StringBuilder比StringBuffer快不少代码也没有额外负担。面试时如果被问到这三者的区别答案就是上面这句总结。6.4 字符串常用API的实战清单基础的API里以下这些是Top1必会的str.length() // 返回字符数 str.charAt(index) // 返回指定下标字符 str.substring(begin, end) // 截取子串左闭右开 str.indexOf(subStr) // 查找子串位置找不到返回-1 str.contains(subStr) // 是否包含子串 str.startsWith(prefix) // 是否以某前缀开头 str.split(regex) // 按正则拆分成数组 str.trim() // 去首尾空格 str.replace(old, new) // 替换所有匹配 str.toLowerCase() / toUpperCase() // 大小写转换注意substring的区间是左闭右开hello.substring(1, 3)结果是el不包含下标3的字符。这个约定和很多语言一致但第一次用的人经常出错。split方法传的是正则表达式所以如果你要按英文句点来拆必须写str.split(\\.)因为单独一个点在正则里表示任意字符。这是一个经典细节。7. 异常处理与常用工具类让程序更健壮的语法基础7.1 异常体系结构Java的异常体系以Throwable为根下面分Error和Exception两个大分支。Error是JVM级别的严重问题比如内存溢出、栈溢出程序无法通过代码恢复不需要去捕获。Exception是程序可以处理的异常分为受检异常Checked和运行时异常RuntimeException两种。受检异常的意思是在编译阶段Java强制你处理。比如IOException你写文件读取操作时如果抛出这个编译器就会要求你要么try-catch要么throws声明不处理编译不过去。运行时异常则不需要强制处理比如空指针异常、数组越界、除数为零。这类异常通常代表程序员的逻辑错误靠修代码解决而不是靠捕获。实际开发中处理异常的风格经历过一个转变。早期风格是到处try-catch然后打印日志这样的代码把真正的业务流程淹没在异常处理逻辑里。更推荐的方式是集中在入口处处理业务代码不需要处理的地方就让它冒泡到上层统一处理。7.2 try-catch-finally的正确姿势与try-with-resources最基本的写法try { // 可能出错的代码 } catch (IOException e) { // 处理IOException } catch (Exception e) { // 兜底的其他异常 } finally { // 不管出不出错都会执行的代码 }catch的匹配顺序是从上到下第一个能匹配的catch块生效所以把最具体的异常写在前面、最宽泛的Exception写在最后顺序错了比如把Exception放最前面编译直接报错。finally块一般是用来释放资源的比如关闭文件流、数据库连接。但Java 7引入了try-with-resources可以让代码更简洁try (BufferedReader reader new BufferedReader(new FileReader(data.txt))) { String line reader.readLine(); // 业务处理 } catch (IOException e) { e.printStackTrace(); }这个写法的好处是资源实现了AutoCloseable接口后会在try块结束时自动关闭不需要手写finally。不仅代码更简洁还避免了一个经典问题——你手写finally关闭资源时如果在关闭资源的代码里又抛了异常会覆盖掉原始业务异常导致真正的错误信息丢失。try-with-resources能够保留原始异常并把关闭时的异常作为被抑制异常挂上去。7.3 手动抛出异常与自定义异常除了捕获异常你还经常需要主动抛出异常。比如某个方法收到的参数不符合预期if (age 0 || age 150) { throw new IllegalArgumentException(年龄非法 age); }throw关键字后面跟异常对象这句话执行后方法立即终止异常向上传递。这种防御式编程的思想在业务代码里很重要与其让错误的数据在后端某个被调用的深层方法里暴露奇怪的问题不如在入口处就把非法输入拦截下来。需要区分的是throws和throw前者是在方法声明里表示“本方法可能抛出某异常”后者是真正执行抛出动作。自定义异常类在你做大型项目时会频繁用到。做法很简单public class BusinessException extends RuntimeException { public BusinessException(String message) { super(message); } }继承了RuntimeException后你的自定义异常就不需要被强制声明调用方不写try-catch也能编译通过。在实际项目里业务异常做成运行时异常是一种主流选择因为业务异常根本不应该让每层方法都throws一遍那样会让方法签名变得极其臃肿。7.4 常用工具类Math、Arrays、Objects这几个工具类属于Java基础语法里“会用就行”的部分但用对场景会省很多事。Math类提供静态的数学计算方法Math.max、Math.min、Math.abs、Math.pow、Math.sqrt、Math.random。其中Math.random返回[0.0, 1.0)之间的随机小数如果你想生成特定范围的整数常见写法是int randomNum (int) (Math.random() * 100); // 0到99之间更推荐的是使用ThreadLocalRandom.current().nextInt(100)并发环境下更安全。Objects类是Java 7加的里面几个静态方法极大方便了判空和对比Objects.equals(a, b); // 安全地比较两个对象不会空指针 Objects.requireNonNull(obj); // 参数校验为空直接抛NullPointerException Objects.hash(a, b, c); // 生成哈希值为什么说Objects.equals安全因为它的实现是先判断a是否为空空的话直接返回b是否也为空非空才调用a.equals(b)。你手写判断时容易漏掉空判断用它就不用担心了。写equals方法时我也推荐生成器风格直接在类里加equals和hashCode时用IDE自动生成不要手写手写太容易漏判字段了。8. 集合框架ArrayList与HashMap的底层认知8.1 Collection体系的整体结构Java的集合框架分两个顶级接口Collection和Map。Collection下面又分List、Set、Queue。List是有序可重复的容器Set是无序且不可重复的Map是键值对容器键不可重复。这三个特点就是你做选型时的最基本依据。Top1阶段必会的具体实现类是ArrayList、LinkedList、HashSet、TreeSet、HashMap、TreeMap。面试必问的也是这几个。ArrayList底层是动态数组查询快按下标访问是O(1)但中间插入或删除元素时后面的元素要整体移动性能是O(n)。LinkedList底层是双向链表插入删除快只需要改指针但查询必须从头遍历性能O(n)。日常开发里90%的情况下ArrayList就够了因为业务代码里“读多写少”是常态而且ArrayList更省内存遍历时缓存友好度也好。8.2 HashMap的实现要点与扩容机制HashMap基础语法层面你要掌握三点存储结构、hash定位、扩容。在Java 8及之后HashMap的底层是数组加链表加红黑树。数组的每个位置叫桶桶里以链表形式存冲突的元素当链表长度超过8且数组长度至少为64时链表会转成红黑树把查询复杂度从O(n)降到O(log n)。put一个键值对时HashMap先调用key的hashCode方法得到一个整数然后经过一个扰动函数让高位也参与低位计算最后用数组长度减一和哈希值做与运算得到桶下标。如果两个不同key落在同一桶里就用equals方法判断是否同一个key。如果是同一个key就覆盖value否则以链表或树的方式追加。HashMap的默认容量是16负载因子是0.75。当元素数量超过容量乘以负载因子即16*0.7512时触发扩容容量翻倍到32。扩容时所有元素要重新计算哈希位置所以频繁扩容性能损耗很大。如果你能预估数据量创建时就指定容量是最优策略MapString, Integer map new HashMap(1000);为什么负载因子是0.75而不是1这是时间与空间的折中。太小了浪费空间太大了碰撞概率升高0.75是工程实践里公认的均衡值。8.3 遍历集合的方式选择for-each遍历List是最常用的方式ListString list new ArrayList(); for (String item : list) { System.out.println(item); }遍历Map有三种思路实际业务中最常见的是遍历entrySet获取键值对for (Map.EntryString, Integer entry : map.entrySet()) { String key entry.getKey(); Integer value entry.getValue(); }如果你只需要遍历key集合再用get获取value性能会差一点相当于每个key都多一次哈希查找。虽然对于小数据量看不出来但在大Map上差距不可忽略。一个在遍历时删除元素的坑在for-each循环中直接调用list的remove方法会抛ConcurrentModificationException。因为迭代器会维护一个modCount记录remove方法不会同步更新这个计数所以迭代器在下一次next时就会检测到修改并抛异常。正确做法是用迭代器自己的remove方法IteratorString it list.iterator(); while (it.hasNext()) { String item it.next(); if (item.startsWith(A)) { it.remove(); } }或者用Java 8引入的list.removeIf(condition)更简洁也更安全。这个细节我在代码评审里反复见到值得重点记住。8.4 泛型的作用与裸类型风险泛型让集合可以限定元素类型ListString list new ArrayList(); list.add(hello); String s list.get(0); // 不需要强制类型转换如果不带泛型你从集合里取出的对象都是Object类型用之前必须强转而且编译器无法帮你检查放进去的元素类型对不对。风险在于运行期强转失败时会抛ClassCastException。List list new ArrayList(); list.add(hello); list.add(123); String s (String) list.get(1); // 运行期抛ClassCastException泛型被设计为编译期检查、运行期擦除的类型也就是说JVM运行时其实不知道你的泛型类型参数是什么。这带来一个现象ListString和ListInteger在运行期是同一个类。这个概念叫类型擦除理解它是往后看泛型源码的前提。基础阶段你只要做到“始终指定泛型参数不使用裸类型”就够了这个习惯会让你少踩一半集合相关的坑。9. 常见问题与排查思路实录9.1 空指针异常的预防与定位空指针异常NullPointerException是Java开发里出现频率最高的异常。Java 14开始JVM的报错信息会直接告诉你“哪个变量为空”以前则需要通过行号猜测或加日志定位。预防空指针有三个实用技巧。第一调用方法之前必须判断调用者是否为null尤其是从接口或数据库拿到的对象第二参数校验用Objects.requireNonNull第三返回值为null的概率高时考虑用空对象模式——返回一个不含数据的空对象而不是null这样调用方不需要判断。我在实际项目中还维护过一个铁律外部数据必经校验内部数据可信。所有外部输入——用户传参、配置文件、第三方接口返回——到了边界都要先校验非空和合法性通过了才能往内部传递。这样出问题的时候你能很快锁定是哪一层的数据出了问题。9.2 数组越界的典型场景与规避数组越界主要发生在你硬编码下标或者下标计算错误时。比如int[] arr new int[10]; for (int i 0; i arr.length; i) { // 写成了最后一次访问arr[10] System.out.println(arr[i]); }这个bug很隐蔽因为人读代码时倾向于把当成正常的边界表达。实际使用中我的经验是凡是数组下标一律把下标当作“距离起始位置的第几个元素”而不是“第几个元素”来理解。下标0是第1个元素下标length-1是最后一个元素。如果你把数组相关的循环全部写成i arr.length就不会越界。另一个场景是截取子串String s hello; s.substring(0, 10); // 越界StringIndexOutOfBoundsException所以你从外部拿到数据做截取前先判断长度是否够。这种代码看似简单但线上出错率不低。9.3 并发修改异常与快速失败机制前面提到的ConcurrentModificationException其实是集合的快速失败机制在起作用。Java集合会在内部记录一个结构修改次数迭代器在迭代前和每次next时都检查这个值是否被改过被改过就立刻抛异常而不是让迭代继续下去产生不可预知的结果。这种设计背后的逻辑是尽早暴露问题比静默出错好。如果你确实需要在遍历时修改集合除了迭代器的remove方法还可以使用ConcurrentHashMap这类线程安全的容器或者CopyOnWriteArrayList后者在遍历时通过复制底层数组来避免并发修改问题。9.4 编译错误与运行错误的区分初学者经常分不清编译错误和运行错误。编译错误是javac检查出来的比如语法写错、类型不匹配、方法不存在这些在编译阶段就能发现也最容易修因为编译器还会提示你具体是哪一行哪个符号有问题。运行错误则要等到程序跑起来才出现包括各种异常。反而最麻烦的是既不报编译错误也正常运行但结果不对的逻辑错误。这种错误只能靠单元测试和日志定位。我建议写代码时时刻问自己“这一步我写的代码编译能过吗运行时数据符合预期吗”这种意识会在持续练习中自然养成。排查问题的方法论我总结为三段先复现再看报错栈最后加日志。不要上场就盲改代码。报错栈一定要从头往下一行一行读它通常精确到类和行号。大多数情况下解决一个问题只需要看懂那一条栈的前五行。10. 学习路径与后续扩展方向10.1 Top1之后的进阶路线把这套基础语法练熟后你至少应该能做一件事不借助IDE自动补全手写一个包含类、继承、接口、集合、异常处理的完整小项目。比如写一个简单的学生成绩管理系统用ArrayList存学生对象用HashMap存课程成绩用try-catch处理非法输入用自定义异常表达业务失败再把公共方法提炼到父类或接口中。这个练习能同时覆盖本文所有核心语法做完之后你会形成肌肉记忆。之后的方向可以从这几个分支里挑数据结构和算法栈、队列、树、图Java集合源码就是很好的学习素材并发编程线程、锁、线程池这是Java后端的中高阶门槛IO与网络编程文件读写、Socket通信、HTTP客户端数据库与SQLJDBC基础然后到框架层学习MyBatis、Hibernate的封装逻辑框架与工具Maven构建、Spring生态、Git协作进入真实项目开发节奏10.2 刷题之外动手写代码更重要最后聊两句我个人在带新人过程中总结出来的体会。光看语法教程而不亲手敲代码记住的知识点留存率极低。每学一个新知识点就写一个几行的小代码去验证它。学完继承写一个动物类继承关系的Demo学完HashMap写一个单词统计的程序。基础语法学习的本质不是背诵是通过大量微小练习把“程序如何运行”的直觉培养出来。如果你现在正在学Java或在纠结为什么自己看懂了却写不出来请放心所有写代码的人都是从这个阶段过来的。把基础语法这块按Top1优先级逐项过关你会发现自己看别人项目源码时的底气完全不同了。