1. 为什么从代码示例开始学Java,比死记语法更有效?

我带过不下二十期Java入门训练营,也给刚转行的同事做过一对一辅导。最常听到的一句话是:“书看了三遍,概念都懂,一写代码就卡壳。”这根本不是学习态度问题,而是方法错了。Java不是靠背出来的,它是一门“肌肉记忆型”语言——你得让手指记住 public static void main(String[] args) 怎么敲,让大脑习惯 for 循环里变量作用域的边界,让眼睛一眼识别出 == .equals() 的区别在哪。我试过先讲两小时“JVM内存模型”,结果学员盯着堆栈图发呆;但换成直接写一个5行代码的 HelloWorld ,改三次输出内容,再加一行 System.out.println(args.length) ,他们立刻就明白了什么叫“程序入口”和“命令行参数”。这不是偷懒,是尊重认知规律:人脑对抽象概念的理解,永远滞后于对具体行为的观察。所以这篇内容不设“Java简介”“发展历史”这类铺垫,开篇就是可运行、可修改、可调试的真实代码片段。每一段都来自我日常教学中反复验证过的最小闭环:一个明确目标(比如“把字符串转成大写”)、一段不超过10行的核心代码、一次可感知的运行结果、一个必然踩到的典型坑。关键词里的“Towards AI”其实暗示了它的原始场景——面向有基础但非科班的学习者,所以所有示例都避开IDE自动补全的“糖衣”,坚持手敲完整语法,连分号都不省略。你不需要提前装好环境,后面会手把手配;也不需要理解字节码,但必须清楚 String s = "abc"; String s = new String("abc"); 在内存里到底发生了什么。现在,打开你的编辑器,我们从第一行开始敲。

2. Java基础核心模块的代码化拆解

2.1 变量与数据类型:不只是声明,更是内存契约

很多人以为 int age = 25; 只是存个数字,其实这是在向JVM签一份“内存租约”。 int 不是数学里的整数,它是32位固定长度的二进制容器,取值范围-2147483648到2147483647。我让学生用 Integer.MAX_VALUE + 1 做实验,结果不是报错,而是变成 -2147483648 ——这就是整数溢出,底层是二进制补码运算的自然结果。下面这段代码能让你亲眼看到这个过程:

public class OverflowDemo {
    public static void main(String[] args) {
        int max = Integer.MAX_VALUE; // 2147483647
        System.out.println("MAX_VALUE: " + max);
        System.out.println("MAX_VALUE + 1: " + (max + 1)); // 输出 -2147483648
        System.out.println("MAX_VALUE + 2: " + (max + 2)); // 输出 -2147483647
    }
}

运行后你会看到,数字不是“变大”,而是像钟表指针一样绕回起点。这就是为什么金融系统从来不用 int 存金额,而用 BigDecimal ——它不承诺固定位数,但保证精度不丢失。再看字符串, String 是不可变对象,这点常被忽略。下面代码看似在修改字符串,实则创建了新对象:

public class StringImmutability {
    public static void main(String[] args) {
        String s1 = "hello";
        String s2 = s1;
        System.out.println("s1 == s2: " + (s1 == s2)); // true,指向同一常量池地址
        s1 = s1 + " world"; // 创建新字符串对象
        System.out.println("s1 == s2: " + (s1 == s2)); // false,s1已指向新地址
        System.out.println("s2: " + s2); // 仍输出 "hello"
    }
}

这里的关键不是 s1 变了,而是 = 号右边的表达式生成了全新对象, s1 的引用被重定向。很多初学者纠结“为什么String不能改”,其实是没理解 = 在Java里是“引用重绑定”,不是“内容覆盖”。我建议你用 javap -c StringImmutability.class 反编译看字节码,会发现 s1 + " world" 实际调用了 StringBuilder.append() ,最后 toString() 生成新实例——这才是底层真相。至于基本类型和包装类, Integer i = 100; Integer j = 100; 比较用 == 会返回 true ,但换成 128 就返回 false ,因为Java对-128到127的整数做了缓存( IntegerCache ),超出范围就每次新建对象。这个细节在面试里高频出现,但教材很少讲透原因:缓存是为了节省内存,毕竟小整数使用频率极高,而128以上缓存收益递减。你可以用 Integer.valueOf(100) == Integer.valueOf(100) 验证,结果仍是 true ,因为 valueOf() 方法内部就用了缓存逻辑。

2.2 控制流程:从if-else到增强for,每一步都是决策树

控制流程的本质是“程序分支的显式声明”。 if-else 看似简单,但嵌套三层后就容易逻辑混乱。我教学生用“卫语句”重构:把提前退出的条件放在前面,减少缩进层级。比如验证用户登录:

// 反模式:嵌套过深
public boolean login(String username, String password) {
    if (username != null) {
        if (!username.trim().isEmpty()) {
            if (password != null) {
                if (password.length() >= 6) {
                    return authenticate(username, password);
                } else {
                    System.out.println("密码太短");
                    return false;
                }
            } else {
                System.out.println("密码为空");
                return false;
            }
        } else {
            System.out.println("用户名为空");
            return false;
        }
    } else {
        System.out.println("用户名为null");
        return false;
    }
}

// 正模式:卫语句,扁平化结构
public boolean loginRefactored(String username, String password) {
    if (username == null) {
        System.out.println("用户名为null");
        return false;
    }
    if (username.trim().isEmpty()) {
        System.out.println("用户名为空");
        return false;
    }
    if (password == null) {
        System.out.println("密码为空");
        return false;
    }
    if (password.length() < 6) {
        System.out.println("密码太短");
        return false;
    }
    return authenticate(username, password);
}

后者可读性高得多,每个 if 都是独立校验点,失败即返回,主逻辑 authenticate() 清晰暴露在最后。再看循环, for-each (增强for)比传统 for 更安全,因为它隐藏了索引操作,避免 ArrayIndexOutOfBoundsException 。但要注意它不能修改集合本身:

import java.util.ArrayList;
import java.util.List;

public class ForEachLimitation {
    public static void main(String[] args) {
        List<String> list = new ArrayList<>();
        list.add("a");
        list.add("b");
        list.add("c");

        // ✅ 安全遍历
        for (String item : list) {
            System.out.println(item);
        }

        // ❌ 编译错误!无法在for-each中修改list
        // for (String item : list) {
        //     list.remove(item); // 编译不通过
        // }

        // ✅ 正确删除方式:用迭代器
        for (var iterator = list.iterator(); iterator.hasNext();) {
            String item = iterator.next();
            if ("b".equals(item)) {
                iterator.remove(); // 迭代器安全删除
            }
        }
        System.out.println(list); // [a, c]
    }
}

这里的关键是理解 for-each 本质是语法糖,编译后会转成 Iterator 调用。而 iterator.remove() 是唯一被允许的删除方式,因为迭代器能同步维护内部游标状态。如果强行用 list.remove() ,会触发 ConcurrentModificationException ——这不是并发问题,而是单线程下迭代器检测到集合结构被意外修改的保护机制。我让学生故意触发这个异常,然后看堆栈跟踪里 checkForComodification() 方法的源码,他们立刻就记住了这个规则。

2.3 面向对象三大支柱:封装、继承、多态的代码实感

面向对象不是概念游戏,是解决代码腐化的工程实践。封装的核心是“信息隐藏”,但很多人只记得 private ,忘了 package-private (默认访问修饰符)才是Java最常用的粒度。比如工具类 StringUtils ,其内部辅助方法往往用默认访问,既避免外部误调,又方便同包内其他类复用:

// 同包下的 StringUtils.java
class StringUtils { // 包级私有类,外部不可见
    static boolean isBlank(String str) {
        return str == null || str.trim().isEmpty();
    }
    
    // 公共API,供外部调用
    public static boolean isEmpty(String str) {
        return str == null || str.length() == 0;
    }
}

// 同包下的 Validator.java,可直接调用包级私有方法
class Validator {
    boolean validateInput(String input) {
        return !StringUtils.isBlank(input); // ✅ 合法调用
    }
}

继承的关键在于“is-a”关系是否真实。 Dog extends Animal 合理,但 Square extends Rectangle 在数学上成立,在代码中却埋雷——因为正方形边长必须相等,若父类 Rectangle setWidth() setHeight() ,子类重写后可能违反Liskov替换原则。我让学生实现这个例子,当调用 setHeight(5); setWidth(10) 时,正方形会变成矩形,逻辑崩塌。解决方案是组合优于继承: Square 持有 Rectangle 引用,或直接用接口 Shape 定义 getArea() ,让 Square Rectangle 各自实现。多态则体现在运行时绑定,下面代码展示了动态分派:

abstract class Shape {
    abstract double getArea();
}

class Circle extends Shape {
    private final double radius;
    Circle(double radius) { this.radius = radius; }
    @Override
    double getArea() { return Math.PI * radius * radius; }
}

class Rectangle extends Shape {
    private final double width, height;
    Rectangle(double width, double height) { 
        this.width = width; 
        this.height = height; 
    }
    @Override
    double getArea() { return width * height; }
}

public class PolymorphismDemo {
    public static void main(String[] args) {
        Shape[] shapes = { new Circle(2.0), new Rectangle(3.0, 4.0) };
        for (Shape shape : shapes) {
            System.out.println("Area: " + shape.getArea()); 
            // 运行时决定调用Circle.getArea()还是Rectangle.getArea()
        }
    }
}

重点看 shape.getArea() 这一行:编译时只知道 shape Shape 类型,但JVM在运行时根据实际对象类型( Circle Rectangle )查虚方法表(vtable)找到对应实现。这就是多态的威力——新增 Triangle 类无需修改 PolymorphismDemo ,只要它继承 Shape 并实现 getArea() ,就能无缝接入。我常问学生:“如果把 getArea() 改成 static ,结果会怎样?”答案是失去多态,永远调用 Shape.getArea() (编译时报错,因抽象方法不能静态),这正好说明 static 方法属于类,而非对象。

2.4 异常处理:检查型与非检查型异常的生存指南

Java异常分为 Checked Exception (编译时强制处理)和 Unchecked Exception (运行时异常,如 NullPointerException )。很多人抱怨检查型异常“繁琐”,但它的设计初衷是让开发者直面可恢复的外部故障。比如文件读取, FileNotFoundException 必须处理,因为磁盘可能真没这个文件,程序应提示用户重选路径;而 NullPointerException 通常源于逻辑缺陷,该修复代码而非捕获。下面对比两种处理策略:

import java.io.*;
import java.util.Scanner;

public class ExceptionHandling {
    
    // ✅ 推荐:将检查型异常转换为运行时异常,简化调用方
    public static String readFileSafely(String filename) {
        try {
            return Files.readString(Paths.get(filename));
        } catch (IOException e) {
            throw new RuntimeException("读取文件失败: " + filename, e);
        }
    }
    
    // ❌ 反模式:生吞异常,掩盖问题
    public static void badFileRead(String filename) {
        try {
            Files.readString(Paths.get(filename));
        } catch (IOException e) {
            // 什么也不做!调用方完全不知道失败
        }
    }
    
    // ✅ 正确捕获:针对具体异常类型,提供差异化响应
    public static void handleNetworkCall() {
        try {
            // 模拟网络请求
            throw new SocketTimeoutException("连接超时");
        } catch (SocketTimeoutException e) {
            System.out.println("网络慢,建议重试");
        } catch (IOException e) {
            System.out.println("网络异常,请检查连接");
        }
    }
}

第一个方法用 RuntimeException 包装 IOException ,调用方无需 try-catch ,但异常信息完整保留( e 作为cause传入),便于日志追踪。第二个方法“生吞异常”是致命错误——它让程序在静默中失败,调试时无迹可寻。第三个方法展示精准捕获: SocketTimeoutException IOException 的子类,单独捕获可提供更友好的用户提示。我强调一个铁律: 永远不要捕获 Exception Throwable ,除非你在写框架的顶层异常处理器 。因为这样会捕获 OutOfMemoryError 等JVM级错误,导致程序在崩溃边缘继续运行,后果更严重。实际项目中,我用 try-with-resources 自动关闭资源,避免 finally 里冗余代码:

public static void readWithResources(String filename) {
    // ✅ 自动关闭,即使发生异常
    try (BufferedReader reader = Files.newBufferedReader(Paths.get(filename))) {
        String line;
        while ((line = reader.readLine()) != null) {
            System.out.println(line);
        }
    } catch (IOException e) {
        System.err.println("读取失败: " + e.getMessage());
    }
}

try-with-resources 要求资源实现 AutoCloseable 接口, BufferedReader 正是如此。编译器会确保 reader.close() try 块结束时执行,无论是否异常。这比手动写 finally 块少出错,也更简洁。

3. 实操环境搭建与代码验证全流程

3.1 从零配置JDK与文本编辑器:告别IDE依赖

很多教程一上来就教IntelliJ,这反而阻碍理解。真正的Java开发始于命令行——它强迫你直面编译、类路径、入口方法这些核心概念。我推荐用VS Code搭配Extension Pack for Java插件,轻量且足够教学。但第一步,必须亲手装JDK。别用系统自带的OpenJDK,去 Adoptium 下载Temurin JDK 17(LTS版本),这是目前企业主流选择。安装后验证:

# Windows PowerShell
java -version
javac -version

如果提示“命令未找到”,说明环境变量没配。Windows需在“系统属性→高级→环境变量”中,将JDK的 bin 目录(如 C:\Program Files\Eclipse Adoptium\jdk-17.0.1+12-hotspot\bin )添加到 Path 变量。Linux/macOS则在 ~/.bashrc ~/.zshrc 中添加:

export JAVA_HOME=/path/to/jdk-17.0.1+12-hotspot
export PATH=$JAVA_HOME/bin:$PATH

然后执行 source ~/.bashrc 。验证成功后,创建项目目录:

mkdir java-basics && cd java-basics
mkdir src bin

src 放源码, bin 放编译后的 .class 文件。现在写第一个文件 src/HelloWorld.java

public class HelloWorld {
    public static void main(String[] args) {
        System.out.println("Hello, Java World!");
    }
}

注意:文件名必须与 public class 名完全一致(大小写敏感),否则编译报错。编译命令在项目根目录执行:

javac -d bin src/HelloWorld.java

-d bin 指定输出目录, src/HelloWorld.java 是输入路径。编译成功后, bin 目录下会出现 HelloWorld.class 。运行:

java -cp bin HelloWorld

-cp bin 设置类路径(classpath),告诉JVM去哪里找 .class 文件; HelloWorld 是类名(不带 .class 后缀)。如果看到 Hello, Java World! ,恭喜,你的Java环境已就绪。这个过程看似繁琐,但它揭示了Java执行的本质: .java 是源码, .class 是字节码, java 命令启动JVM加载字节码并执行。IDE只是把这些步骤自动化了,但自动化会掩盖原理。我坚持让学生手敲这些命令至少一周,直到他们能凭直觉判断“编译失败是因为语法错还是路径错”。

3.2 逐行调试与字节码观察:穿透语法糖的迷雾

调试不是为了找bug,而是为了验证理解。以 String 拼接为例, "a" + "b" 在编译期就被优化为 "ab" ,而 s1 + s2 (变量)则在运行时用 StringBuilder 。用 javap 反编译看真相:

public class StringConcat {
    public static void main(String[] args) {
        String s1 = "hello";
        String s2 = "world";
        String s3 = s1 + s2; // 变量拼接
        String s4 = "hello" + "world"; // 字面量拼接
    }
}

编译后执行:

javac src/StringConcat.java
javap -c bin/StringConcat.class

输出关键部分:

0: ldc           #2                  // String hello
2: astore_1
3: ldc           #3                  // String world
4: astore_2
5: new           #4                  // class java/lang/StringBuilder
8: dup
9: invokespecial #5                  // Method java/lang/StringBuilder."<init>":()V
12: aload_1
13: invokevirtual #6                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
16: aload_2
17: invokevirtual #6                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
20: invokevirtual #7                  // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
23: astore_3
24: ldc           #8                  // String helloworld
26: astore        4

看到没?第5行 new StringBuilder ,第12/16行 append ,第20行 toString ——这就是 s1 + s2 的底层实现。而第24行 ldc #8 直接加载常量池里的 "helloworld" ,证明字面量拼接在编译期完成。这种观察让我学生彻底放弃“ + 效率低”的迷信,转而关注真实瓶颈:如果拼接发生在循环内, StringBuilder 应提前创建并复用,而非每次新建。再看 for-each ,写一个简单遍历:

public class ForEachBytecode {
    public static void main(String[] args) {
        String[] arr = {"a", "b"};
        for (String s : arr) {
            System.out.println(s);
        }
    }
}

javap -c 输出中,你会看到 arr.length 被读取, i 索引变量被管理, arr[i] 被访问——这证实 for-each 确实是语法糖,底层就是传统 for 循环。调试时,我在VS Code中设置断点,观察变量值变化。比如在 StringImmutability 例子中,停在 s1 = s1 + " world"; 前,查看 s1 的内存地址(用 System.identityHashCode(s1) ),执行后再次查看,地址已变。这种“眼见为实”的体验,比十页理论文档更有说服力。

3.3 构建可验证的代码库:每个示例都是独立可运行单元

我把所有示例组织成Maven项目结构,确保每个类都能独立编译运行。项目根目录 pom.xml 如下:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 
         http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <groupId>com.example</groupId>
    <artifactId>java-basics</artifactId>
    <version>1.0-SNAPSHOT</version>
    <properties>
        <maven.compiler.source>17</maven.compiler.source>
        <maven.compiler.target>17</maven.compiler.target>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    </properties>
</project>

这样用 mvn compile 就能编译全部源码。但教学时,我更倾向用 javac 单文件编译,因为Maven的 target 目录结构会增加认知负担。每个示例类都遵循命名规范: VariableDemo.java InheritanceDemo.java ,并在 main 方法末尾加 // END 标记,方便快速定位。运行脚本 run.sh (Linux/macOS)或 run.bat (Windows)统一管理:

# run.sh
#!/bin/bash
if [ $# -eq 0 ]; then
    echo "Usage: ./run.sh <ClassName>"
    exit 1
fi
javac -d bin src/$1.java && java -cp bin $1

执行 ./run.sh VariableDemo 即可一键编译运行。这种设计让学生聚焦代码本身,而非构建工具。我要求所有示例必须满足:1)无外部依赖(只用JDK标准库);2)运行时间<1秒;3)输出结果可预测(如打印固定字符串或计算值)。比如 OverflowDemo 输出必须是 -2147483648 ,如果学生得到其他结果,一定是JDK版本或代码抄错。这种确定性是建立信心的基础。最后,我用Git管理代码库,每次提交都附带清晰注释,如“fix: StringImmutability demo to show reference reassignment”。版本控制不是为协作,而是为回溯——当学生问“为什么之前能跑现在不行”, git diff 能瞬间定位改动。

4. 常见问题与排查技巧实录

4.1 编译与运行阶段高频错误解析

错误1: error: class HelloWorld is public, should be declared in a file named HelloWorld.java
这是Java最经典的入门错误。原因: public 类名必须与文件名完全一致(包括大小写)。解决方案:检查文件名是否为 HelloWorld.java ,确认没有多余空格或隐藏字符(如UTF-8 BOM)。用 ls -la (Linux/macOS)或 dir (Windows)查看真实文件名。我曾帮一个学生排查,发现他用记事本保存为 HelloWorld.java.txt ,Windows隐藏了扩展名,导致文件实际是 HelloWorld.java.txt

错误2: Exception in thread "main" java.lang.NoClassDefFoundError: HelloWorld
编译成功但运行失败。常见原因:类路径( -cp )未正确设置,或 .class 文件不在指定目录。例如在 java-basics 根目录执行 java HelloWorld ,但 HelloWorld.class bin 目录下,此时必须用 java -cp bin HelloWorld 。另一个原因是类名写错: java HelloWorld.class 是错误的, java 命令后跟的是类名,不是文件名。

错误3: error: cannot find symbol
符号找不到,通常是拼写错误或作用域问题。比如 System.out.println(arr.length()); 报错,因为数组 length 是属性( arr.length ),不是方法( arr.length() )。再如在 if 块内声明变量 int x = 1; ,然后在 if 外访问 x ,编译器会报错,因为 x 的作用域仅限 if 块内。解决方案:开启IDE的实时语法检查,或用 javac -Xlint:all 获取详细警告。

错误4: Exception in thread "main" java.lang.ArrayIndexOutOfBoundsException: Index 3 out of bounds for length 3
数组越界。根源是循环条件写错,如 for(int i=0; i<=arr.length; i++) ,应为 i < arr.length 。我教学生一个技巧:在循环开始前打印 arr.length ,再在循环内打印 i ,对比数值。另外, ArrayList get() 方法也会抛此异常,但 List.of() 创建的不可变列表在越界时抛 UnsupportedOperationException ,需注意区别。

4.2 运行时行为异常的现场诊断法

现象:程序无输出,或输出与预期不符
先确认 main 方法是否被正确调用。在 main 开头加 System.out.println("DEBUG: main started"); 。如果这行没输出,说明类没被运行;如果输出了但后续无反应,可能是无限循环或阻塞IO。比如 Scanner.nextLine() 等待用户输入,若在自动化测试中运行,会卡住。解决方案:用 System.setIn(new ByteArrayInputStream("input\n".getBytes())); 模拟输入。

现象: String 比较结果为 false ,但肉眼看起来相同
常见于包含不可见字符(如BOM、零宽空格)或编码问题。用 str.getBytes(StandardCharsets.UTF_8) 查看字节数组,或用 str.codePoints().forEach(System.out::println) 打印每个Unicode码点。我遇到过一个案例:学生复制网页上的代码,其中 " " (中文全角空格)和 " " (英文半角空格)混用,导致 "abc ".equals("abc ") 返回 false

现象: Integer 对象用 == 比较,有时 true 有时 false
这是 Integer 缓存机制的体现。如前所述,-128到127范围内的整数会被缓存,超出则新建对象。验证方法: Integer a = 127; Integer b = 127; System.out.println(a == b); // true Integer c = 128; Integer d = 128; System.out.println(c == d); // false 。正确比较方式永远是 a.equals(b) ,因为 equals() 比较的是数值,而非引用。

现象: ArrayList 删除元素后,索引错乱
for 循环中用 list.remove(i) 会导致后续元素前移, i 却自增,从而跳过下一个元素。例如 [a,b,c] ,删除 i=0 a 后变为 [b,c] i 变为1,此时访问 list.get(1) 得到 c b 被跳过。解决方案:倒序遍历( for(int i=list.size()-1; i>=0; i--) ),或用 Iterator.remove() ,或收集待删索引后批量删除。

4.3 学习者专属避坑清单:那些没人告诉你的细节

提示:以下经验均来自我辅导学员的真实踩坑记录,教材绝不会写

  • String.split() 的陷阱 "a..b".split("\\.") 返回 ["a", "", "b"] ,但 "a.".split("\\.") 返回 ["a"] ,末尾空字符串被丢弃。要保留所有分割结果,用 split("\\.", -1) ,第二个参数 -1 表示不限制分割次数。

  • Math.random() 的范围 :它返回 [0.0, 1.0) 的double,即包含0.0但不包含1.0。想生成1-6的随机数,应写 (int)(Math.random() * 6) + 1 ,而不是 (int)(Math.random() * 6) (会得到0-5)。

  • switch 语句的fall-through :Java 14+支持 switch 表达式(用 -> ),但传统 switch 语句中 case 后若无 break ,会继续执行下一个 case 。这是C语言遗留,极易出错。我强制学生用 switch 表达式,或在每个 case 后写 break 并加注释 // break intentionally

  • final 修饰符的误解 final List<String> list = new ArrayList<>(); 只禁止 list 引用被重新赋值,但 list.add("item") 完全合法。要创建真正不可变列表,用 List.of("a", "b") Collections.unmodifiableList()

  • static 方法的局限性 static 方法不能直接访问非 static 成员(字段或方法),因为它们属于类,而非实例。常见错误是在 main static )中调用 this.someMethod() ,编译报错。解决方案:创建实例 new MyClass().someMethod() ,或把方法改为 static

  • System.out.println() 的缓冲区 :它不是立即输出,而是写入缓冲区。在长时间运行的程序中,若想确保日志实时可见,用 System.out.print("msg"); System.out.flush(); 。不过现代IDE的控制台通常已自动刷新。

  • Scanner 的换行符残留 :调用 nextInt() 后紧跟 nextLine() ,后者会读到 nextInt() 留下的换行符,返回空字符串。解决方案:在 nextInt() 后加 scanner.nextLine() 清空缓冲区,或统一用 nextLine() 再解析。

这些细节看似琐碎,但恰恰是区分“会写代码”和“能写可靠代码”的分水岭。我从不指望学生一次记住全部,而是把它们做成速查卡片,贴在显示器边框上。当某个错误反复出现,就翻开卡片对照——知识就这样在实战中沉淀下来。

5. 从基础到进阶的平滑演进路径

学完这些示例,你已经掌握了Java的骨架。下一步不是急着学Spring,而是加固地基。我建议按这个顺序深化:

第一阶段:吃透JDK核心工具类

  • java.time 包:替代过时的 Date Calendar ,从 LocalDateTime.now() 开始,掌握 Duration Period 的区别(前者是精确毫秒,后者是日月年概念)。
  • java.util.function :理解 Predicate<T> Function<T,R> 等函数式接口,为Lambda打基础。写一个 List<String> 过滤器: list.stream().filter(s -> s.length() > 3).collect(Collectors.toList())
  • java.nio.file :比 java.io 更现代的文件操作, Files.walk() 递归遍历目录, Files.writeString() 原子写入。

第二阶段:理解JVM运行机制

  • jps 查看Java进程, jstat 监控GC, jstack 分析线程堆栈。运行一个无限循环程序,用 jstat -gc <pid> 观察堆内存增长。
  • 写一个内存泄漏示例: static List<byte[]> cache = new ArrayList<>(); 不断 cache.add(new byte[1024*1024]); ,用 jconsole 连接观察老年代增长。
  • 学习 -Xms -Xmx 设置堆大小, -XX:+UseG1GC 指定垃圾收集器。

第三阶段:构建真实小项目

  • 命令行待办事项应用:用 ArrayList 存任务, Scanner 读取指令(add/list/done), Files 持久化到文件。
  • 简易HTTP服务器:用 com.sun.net.httpserver.HttpServer ,处理GET请求返回HTML,理解 Handler 接口。
  • 单元测试实践:用JUnit 5写测试, @Test 标注方法, Assertions.assertEquals() 验证结果。

这条路没有捷径,但每一步都扎实。我见过太多人跳过基础,直接学框架,结果遇到 NullPointerException 就束手无策。而当你亲手写过 ArrayList add() get() 方法(哪怕只是模仿),再去看 ArrayList 源码,那种豁然开朗的感觉,是任何视频教程给不了的。最后分享一个小技巧:每天花15分钟,重写一个学过的示例,不看笔记,只凭记忆。写不出来就查,查完再写。坚持两周,你会发现 public static void main 已经刻进肌肉里——编程,终究是一门手艺活。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐