01Java程序员必看的20个基础知识点(附代码示例)

0 阅读45分钟

001、Java平台与JVM核心机制:从跨平台到类加载

_C__Users_Administrator_Downloads_deepseek_html_20260326_0e55ce.html.png 上周帮同事排查一个线上问题,现象很典型:本地开发环境跑得好好的,一上测试环境就报ClassNotFoundException。大家围着屏幕看了半天,最后发现是测试环境的Tomcat里塞了两个不同版本的jar包。这个问题让我想起刚入行时被类加载机制支配的恐惧——今天我们就从这类实际场景出发,把Java平台那点事掰开揉碎讲明白。

一、所谓“跨平台”到底跨了什么

很多人对Java的第一印象就是“一次编写,到处运行”。这句话没错,但背后藏着关键细节:跨的不是你的源代码,而是编译后的字节码。看这段代码:

// 你写的.java文件
public class PlatformDemo {
    public static void main(String[] args) {
        System.out.println("Hello JVM");
    }
}

// 编译后生成PlatformDemo.class
// 这个.class文件能在Windows的JVM跑,也能在Linux的JVM跑
// 但注意:不能直接在操作系统上跑!

字节码就像一份通用菜谱,JVM就是各个厨房的厨师。Windows厨师和Linux厨师都能看懂这份菜谱,但炒菜用的锅和灶台(系统调用)完全不同。这就是为什么你装Java总要选对应操作系统的JDK——JVM本身是平台相关的,它负责把平台无关的字节码翻译成平台相关的机器指令。

二、JVM不是“一个”虚拟机

工作中遇到过这样的困惑:为什么Android用Java语法却不算Java平台?因为Google自己搞了套Dalvik/ART虚拟机,它不认.class文件,只认.dex文件。所以严格来说,Java跨平台指的是“Oracle版JVM生态圈”内的跨平台。

JVM实现有很多种:

  • HotSpot(Oracle/Sun主力,我们最常用的)
  • JRockit(曾经专注服务器,现在合并进HotSpot)
  • OpenJ9(IBM贡献,资源控制很出色)
  • 还有各种嵌入式场景的微型JVM

它们都遵循JVM规范,但内部实现天差地别。比如同样一段代码,在HotSpot和OpenJ9上的内存表现可能完全不同。这就引出了下一个重点:类加载。

三、类加载的“潜规则”

回到开头的ClassNotFoundException,这通常是类加载器搞的鬼。先看个例子:

public class ClassLoaderDemo {
    public static void main(String[] args) {
        // 获取当前类的类加载器
        ClassLoader loader = ClassLoaderDemo.class.getClassLoader();
        System.out.println(loader);  // 输出: sun.misc.Launcher$AppClassLoader
        
        // 获取父加载器
        System.out.println(loader.getParent());  // 输出: sun.misc.Launcher$ExtClassLoader
        
        // 再往上就是Bootstrap ClassLoader了,它是C++写的,Java里看到的是null
        System.out.println(loader.getParent().getParent());  // 输出: null
        
        // 核心类的加载器是Bootstrap
        System.out.println(String.class.getClassLoader());  // 输出: null
    }
}

类加载器的双亲委派模型是面试常考点,但实际工作中更要注意它的“破坏者”:

// 典型场景:Tomcat容器的类加载体系
// 它破坏了双亲委派,实现了优先加载Web应用自身类的机制
// 所以才会出现开头说的“两个版本jar包冲突”

// 自己实现类加载器时容易踩的坑:
public class WrongClassLoader extends ClassLoader {
    // 错误示范:直接覆盖loadClass就破坏了双亲委派
    @Override
    public Class<?> loadClass(String name) throws ClassNotFoundException {
        // 这样写所有类都自己加载,连java.lang.String都要重新加载,必崩!
        return findClass(name);
    }
    
    // 正确做法:覆盖findClass方法
    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        // 只处理自定义类的加载
        byte[] bytes = loadClassData(name);
        return defineClass(name, bytes, 0, bytes.length);
    }
}

四、类加载的实战陷阱

  1. 内存泄漏的隐形杀手
// 用自定义类加载器加载的类,卸载条件极其苛刻
public class LeakyLoader {
    private Map<String, Class<?>> cache = new HashMap<>();
    
    public Class<?> load(String className) {
        if (!cache.containsKey(className)) {
            // 加载类并缓存
            Class<?> clazz = ... // 加载逻辑
            cache.put(className, clazz);
        }
        return cache.get(className);
    }
    // 问题:这个cache会阻止类被GC回收
    // 解决方案:用WeakHashMap替代HashMap
}
  1. 热部署的真相 很多框架宣传的“热部署”其实是耍了小聪明:创建一个新的类加载器加载新类,旧的类加载器连同它加载的所有类一起“放弃引用”,等待GC回收。但如果有任何地方持有了旧类的实例,GC就回收不了,这就是“内存泄漏”的常见来源。

五、调试类加载问题的小技巧

  1. 启动参数加-verbose:class,能看到每个类被谁加载、从哪加载
  2. jmap -clstats <pid>查看运行中JVM的类加载器统计
  3. 遇到NoClassDefFoundError别慌,它和ClassNotFoundException有本质区别:前者是类找到了但初始化失败,后者是根本找不到类文件

个人经验建议

干了十几年Java,我的经验是:对类加载机制,理解比死记硬背重要。除非你在写框架或容器,否则99%的场景不需要自己实现类加载器。但你必须知道:

  1. Maven依赖冲突的本质是类加载优先级问题,mvn dependency:tree比盲目试jar包版本管用
  2. Spring Boot的Fat Jar用了特殊的类加载器(LaunchedURLClassLoader),它的资源加载逻辑和传统War包不同
  3. 微服务架构下,每个服务独立进程,类加载隔离天然实现,这是比单体应用更优雅的设计
  4. 遇到玄学的类加载问题,先重启试试——虽然不优雅,但能快速判断是不是“类加载器状态污染”

最后说句大实话:Java的类加载机制设计得很精巧,但也确实复杂。工作中保持“最小惊讶原则”——尽量使用默认的类加载器,尽量遵循标准目录结构,尽量不用那些炫技的自定义加载方案。稳定运行的代码,比炫酷的技术更重要。# 002、面向对象基石:深入理解类、对象与三大特性

昨天帮同事调试一个诡异的Bug,现象是某个配置对象的状态会莫名其妙被修改。跟踪了半天,发现他在三个地方用new Config()创建了本该全局唯一的配置实例。这让我想起刚学Java时,自己也常分不清什么时候该new,什么时候该复用。今天我们就聊聊面向对象最核心的三个概念:类、对象和三大特性。

类与对象:蓝图和房子的关系

类就像建筑设计图,对象才是真正能住人的房子。设计图可以复印无数份,但每栋房子都是独立的。

// 设计图(类)
public class CoffeeMaker {
    // 状态:这些是每个咖啡机独有的
    private int waterLevel;  // 水位
    private boolean powerOn; // 开关状态
    
    // 构造器:出厂设置
    public CoffeeMaker() {
        this.waterLevel = 100;  // 默认满水
        this.powerOn = false;   // 默认关机
        System.out.println("一台新咖啡机出厂了");
    }
    
    // 行为:能做什么
    public void brewCoffee() {
        if (!powerOn) {
            System.out.println("请先插电!");  // 这里踩过坑
            return;
        }
        if (waterLevel < 10) {
            System.out.println("水量不足");
            return;
        }
        waterLevel -= 10;
        System.out.println("萃取中... 制作完成");
    }
}

// 实际使用
public class Test {
    public static void main(String[] args) {
        // 建两栋不同的房子(对象)
        CoffeeMaker officeMachine = new CoffeeMaker();
        CoffeeMaker homeMachine = new CoffeeMaker();
        
        // 它们互不影响
        officeMachine.brewCoffee();  // 输出:请先插电!
        // homeMachine的水位还是100,不会因为officeMachine调用而改变
    }
}

关键点:new一次就创建一个独立对象。很多新手会把类当对象用,直接调静态方法,结果状态全乱了。

封装:把复杂藏起来

封装不是简单的private加getter/setter。看个实际案例:

// 反面教材:这根本不是封装
public class User {
    public String name;
    public int age;
    
    // 这种setter毫无意义,跟public没区别
    public void setAge(int age) {
        this.age = age;
    }
}

// 正面例子:真正的业务封装
public class BankAccount {
    private String accountId;
    private double balance;
    private String passwordHash;
    
    // 存款:封装了校验逻辑
    public boolean deposit(double amount) {
        if (amount <= 0) {
            System.out.println("存款金额必须大于0");  // 实际项目用日志
            return false;
        }
        if (amount > 1000000) {
            System.out.println("大额存款请到柜台");
            return false;
        }
        balance += amount;
        return true;
    }
    
    // 取款:更复杂的业务规则
    public boolean withdraw(double amount, String password) {
        if (!verifyPassword(password)) {
            return false;
        }
        if (amount > balance) {
            System.out.println("余额不足");
            return false;
        }
        if (amount > 50000) {
            // 风控逻辑
            if (!riskCheck()) {
                return false;
            }
        }
        balance -= amount;
        recordTransaction("withdraw", amount);  // 内部方法,不对外暴露
        return true;
    }
    
    // 私有方法:内部实现细节
    private boolean verifyPassword(String input) {
        // 密码验证逻辑,可能涉及加密、盐值等
        return true;
    }
    
    // 只提供必要的getter
    public double getBalance() {
        return balance;  // 返回副本更安全,这里简单处理
    }
}

封装的核心是“变动的代价”。如果把字段都public,哪天要加个校验,所有调用的地方都得改。好的封装让修改只发生在一个类内部。

继承:不是为复用代码

很多人误解继承就是为了复用父类代码。看这个典型问题:

// 错误示范:为了复用而继承
class Rectangle {
    protected int width;
    protected int height;
    
    public void setWidth(int width) {
        this.width = width;
    }
    
    public void setHeight(int height) {
        this.height = height;
    }
    
    public int getArea() {
        return width * height;
    }
}

// 正方形是长方形吗?数学上是,编程中不是!
class Square extends Rectangle {
    @Override
    public void setWidth(int width) {
        super.setWidth(width);
        super.setHeight(width);  // 破坏父类约定
    }
    
    @Override
    public void setHeight(int height) {
        super.setHeight(height);
        super.setWidth(height);  // 这里会出问题
    }
}

// 测试代码
public class TestLSP {
    public static void resize(Rectangle rectangle) {
        // 这个方法假设width和height可以独立修改
        while (rectangle.getArea() < 50) {
            rectangle.setWidth(rectangle.width + 1);  // 对Square来说,height也被改了
            System.out.println("面积: " + rectangle.getArea());
        }
    }
    
    public static void main(String[] args) {
        Rectangle rect = new Square();  // 多态
        rect.setWidth(5);
        resize(rect);  // 无限循环!
    }
}

这就是著名的里氏替换原则(LSP)问题。继承应该是“是一个(is-a)”关系,并且子类不能破坏父类的契约。实际项目中,优先考虑组合:

// 用组合更安全
class Engine {
    public void start() {
        System.out.println("引擎启动");
    }
}

class Car {
    private Engine engine = new Engine();  // 组合
    
    public void start() {
        engine.start();
        System.out.println("汽车启动");
    }
}

多态:消除if-else的利器

多态不只是“父类引用指向子类对象”。它在框架设计中特别有用:

// 支付系统示例
interface Payment {
    boolean pay(double amount);
    String getType();
}

class Alipay implements Payment {
    @Override
    public boolean pay(double amount) {
        System.out.println("调用支付宝接口...");
        // 实际调用SDK
        return true;
    }
    
    @Override
    public String getType() {
        return "alipay";
    }
}

class WechatPay implements Payment {
    @Override
    public boolean pay(double amount) {
        System.out.println("调用微信支付接口...");
        // 不同的参数和调用方式
        return true;
    }
    
    @Override
    public String getType() {
        return "wechat";
    }
}

// 支付处理器:核心优势在这里
class PaymentProcessor {
    private Map<String, Payment> paymentMap = new HashMap<>();
    
    public void register(Payment payment) {
        paymentMap.put(payment.getType(), payment);
    }
    
    public boolean process(String type, double amount) {
        Payment payment = paymentMap.get(type);
        if (payment == null) {
            throw new IllegalArgumentException("不支持的支付方式");
        }
        return payment.pay(amount);  // 多态调用
    }
    
    // 对比:不用多态的写法
    public boolean processOld(String type, double amount) {
        // 每加一种支付方式就要改这里
        if ("alipay".equals(type)) {
            // 一堆支付宝专用代码
        } else if ("wechat".equals(type)) {
            // 一堆微信专用代码
        } else if ("unionpay".equals(type)) {
            // 每加一个就多一个if
        }
        return false;
    }
}

多态让代码对扩展开放,对修改关闭。新加支付方式只需实现接口并注册,不用改Processor的代码。

实战经验

  1. 关于构造器:别在构造器里做太多事情,特别是调用可被重写的方法。子类还没初始化完,父类就调了子类的方法,容易NPE。

  2. equals和hashCode:重写equals必须重写hashCode,这是规矩。用IDE生成就行,别手写。

  3. 静态工厂方法:考虑用静态工厂方法替代构造器。比如LocalDate.now()new LocalDate()更清晰,还能缓存对象。

  4. final关键字:小类用final修饰,防止被继承破坏。特别是工具类、值对象。

  5. 接口设计:接口方法不要太多,遵循单一职责。Java 8的default方法谨慎用,它破坏了接口的纯洁性。

面向对象不是语法,是思维方式。刚开始写代码时,我总想着“这个功能怎么实现”,现在更多想“这个对象该有什么职责”。调试同事那个Bug时,最后我们把Config类改成了单例,并加了状态校验。问题不在语法,在于对对象生命周期的理解。下次你写new的时候,停一秒想想:这个对象应该有几个实例?它的生命周期谁来管理?想清楚这些,很多Bug就避免了。# 003、Java内存模型精讲:堆、栈、方法区与GC原理

昨天深夜排查一个线上问题,线程栈溢出。日志里满屏的java.lang.StackOverflowError,但堆内存监控却显示正常。这让我想起很多Java程序员对内存模型的理解还停留在“堆和栈”的模糊概念上。今天咱们就深入聊聊Java内存模型,把那些容易踩坑的地方掰扯清楚。

从一次栈溢出说起

先看这段问题代码:

public class StackProblem {
    public static void recursiveCall() {
        recursiveCall();  // 无限递归,这里就是坑
    }
    
    public static void main(String[] args) {
        recursiveCall();
    }
}

运行后直接栈溢出。每个线程都有自己的栈空间,默认大小1MB(64位系统可能更大些)。每次方法调用都会在栈上压入一个栈帧,存放局部变量、操作数栈、方法出口等信息。无限递归导致栈帧不断堆积,直到撑爆。

栈的大小可以通过-Xss参数调整,但通常不建议盲目调大。遇到栈溢出,首先要检查的是递归终止条件或者方法调用深度,而不是急着改参数。

堆:对象的游乐场

堆才是我们平时最常打交道的区域。所有通过new创建的对象实例都在这里分配:

public class HeapDemo {
    public static void main(String[] args) {
        // 这个User对象实例就在堆里
        User user = new User("张三", 25);
        
        // 这个list对象也在堆里,它引用的User对象也在堆里
        List<User> userList = new ArrayList<>();
        userList.add(user);
        
        // 注意:user是局部变量,在栈上
        // 但它指向的对象在堆上,这个区别很重要
    }
}

堆的大小由-Xms(初始堆大小)和-Xmx(最大堆大小)控制。生产环境这两个值通常设成一样,避免堆扩容带来的性能抖动。

方法区:类的元数据老家

方法区存放类信息、常量、静态变量。JDK 8之前叫永久代(PermGen),之后改名为元空间(Metaspace):

public class MethodAreaDemo {
    public static final String CONSTANT = "我是常量";  // 在方法区
    private static int count = 0;  // 静态变量也在方法区
    
    public void demo() {
        String s1 = "hello";  // 字符串常量池,也在方法区
        String s2 = new String("hello");  // 对象在堆,但"hello"字面量在方法区
    }
}

元空间默认不设上限,但可能触发Native内存溢出。如果加载的类太多(比如动态生成类),记得用-XX:MaxMetaspaceSize限制一下。

GC原理:不是所有的垃圾都立刻回收

很多人以为对象没引用了就立刻被回收,其实不然。看这个例子:

public class GCDemo {
    public static void main(String[] args) {
        for (int i = 0; i < 100000; i++) {
            // 创建大量临时对象
            String temp = new String("temp" + i);
        }
        // 这里不会立刻触发GC,除非堆快满了
        // System.gc() 可以建议GC,但不保证执行,别依赖它
    }
}

HotSpot的GC采用分代收集策略。年轻代(Young Generation)用复制算法,老年代(Old Generation)用标记-整理或标记-清除。对象通常先在Eden区分配,熬过几次Minor GC后进入Survivor区,最后进入老年代。

实战中的内存问题

上周遇到一个内存泄漏案例:

public class MemoryLeak {
    private static List<byte[]> cache = new ArrayList<>();
    
    public void addToCache(byte[] data) {
        cache.add(data);  // 危险!数据一直不释放
    }
    
    // 正确做法应该是提供清理方法
    public void clearCache() {
        cache.clear();
    }
}

静态集合持有对象引用是最常见的内存泄漏场景。用WeakHashMap或者定期清理可以避免。

个人经验建议

  1. 生产环境一定要配-XX:+HeapDumpOnOutOfMemoryError,出问题时拿到堆转储文件比猜原因强一百倍

  2. 栈溢出先看代码,堆溢出先用工具(MAT、JProfiler)分析dump文件。盲目调参数是掩耳盗铃

  3. 方法区的OOM现在不多见了,但如果用动态代理、字节码增强这些技术,记得监控元空间使用量

  4. 理解GC日志比想象中重要。配个-XX:+PrintGCDetails,关键时刻能救命

  5. 局部变量能复用就复用,但别过度优化。现代JVM的逃逸分析比你聪明

内存模型的理解深度,直接决定你排查问题的效率。下次遇到内存问题,先问问自己:这个对象到底在哪?谁还引用着它?GC为什么没收它?想清楚这三个问题,大部分内存问题都能找到突破口。# 004、集合框架全解析:List、Set、Map及并发容器

昨天帮同事排查一个线上问题,现象是某个列表数据偶尔会少几条记录。跟踪代码发现,他用了ArrayList在多线程环境下直接做添加操作。问题就出在这里——ArrayListadd方法在扩容时内部数组会重新分配,多线程同时操作时,最后实际写入的数据可能被覆盖。这种问题在业务高峰期才会暴露,测试环境很难复现。

List:有序但未必安全

ArrayList底层是数组,随机访问快,但插入删除需要移动元素。看这段典型问题代码:

// 线程不安全的写法
List<String> list = new ArrayList<>();
for (int i = 0; i < 10; i++) {
    new Thread(() -> {
        list.add("item");
    }).start();
}
// 运行几次就会发现,list.size()经常小于10

LinkedList底层是双向链表,插入删除快,但随机访问需要遍历。有个容易忽略的点:它的get(int index)方法会判断index离头尾哪边更近,然后决定从头还是尾开始遍历。但即便如此,频繁按索引访问依然很慢。

实际开发中,如果只是需要有序集合,优先选ArrayList。需要频繁在中间插入删除才考虑LinkedList。线程安全场景可以用Collections.synchronizedList包装,或者直接用CopyOnWriteArrayList

CopyOnWriteArrayList写时复制的设计很有意思:每次修改操作都会复制底层数组。适合读多写少的场景,但写操作频繁时性能开销大。它的迭代器不会抛出ConcurrentModificationException,因为迭代的是创建迭代器时的数组快照。

Set:去重的艺术

HashSet底层是HashMap,只用了key,value统一用PRESENT对象填充。所以它的去重能力完全依赖对象的hashCode()equals()方法。这里踩过坑:如果两个对象equals返回true但hashCode不同,它们会同时存在于Set中,破坏去重特性。

class BadKey {
    int id;
    
    @Override
    public boolean equals(Object o) {
        return this.id == ((BadKey)o).id;
    }
    // 忘了重写hashCode!
}
// 两个id相同的BadKey对象会同时出现在HashSet中

TreeSet基于红黑树实现,元素必须实现Comparable接口或者传入Comparator。它保证元素有序,但插入删除需要维护树结构,时间复杂度O(log n)。

LinkedHashSetHashSet基础上加了链表维护插入顺序。需要保持插入顺序又要去重时用它,比如最近访问记录的去重展示。

Map:键值对的三种实现

HashMap最常用,1.8之后链表转红黑树的优化解决了哈希冲突时的性能退化问题。但有个细节:容量总是2的幂次方,这样可以用(n-1) & hash代替取模运算,效率更高。

// 典型错误用法:在迭代时修改
Map<String, String> map = new HashMap<>();
map.put("a", "1");
for (String key : map.keySet()) {
    if ("a".equals(key)) {
        map.remove(key);  // 这里会抛ConcurrentModificationException
    }
}
// 应该用迭代器的remove方法,或者Java8的removeIf

LinkedHashMap可以看作HashMap+双向链表。它有两种顺序模式:插入顺序和访问顺序。访问顺序模式配合重写removeEldestEntry方法,可以轻松实现LRU缓存:

// 简单的LRU缓存实现
Map<String, Object> lruCache = new LinkedHashMap<>(16, 0.75f, true) {
    @Override
    protected boolean removeEldestEntry(Map.Entry eldest) {
        return size() > 1000;  // 超过1000个就移除最老的
    }
};

TreeMap基于红黑树,key需要可比较。它的subMapheadMaptailMap方法可以方便地获取范围视图,适合需要范围查询的场景。

并发容器:线程安全的正确姿势

ConcurrentHashMap是并发编程的明星容器。1.8之前用分段锁,1.8改成了synchronized+CAS+红黑树。它的size()方法是个估计值,因为并发环境下精确统计代价太大。

// 常见用法:线程安全的累加
ConcurrentHashMap<String, Long> map = new ConcurrentHashMap<>();
map.compute("key", (k, v) -> v == null ? 1L : v + 1L);
// compute方法是原子操作,比先get再put安全得多

ConcurrentSkipListMapConcurrentSkipListSet基于跳表实现,并发下的有序容器。跳表这种多层索引结构在并发环境下比红黑树更容易实现无锁操作。

CopyOnWriteArrayList前面提过,适合读多写少。但要注意,它的迭代器不支持remove操作,调用会直接抛异常。

BlockingQueue系列是生产者-消费者模式的利器。ArrayBlockingQueue有界,LinkedBlockingQueue可选有界无界,PriorityBlockingQueue带优先级,SynchronousQueue不存储元素直接传递。

个人经验谈

集合选型首先要明确三点:是否需要有序、是否需要去重、是否多线程环境。大多数情况下,单线程用ArrayList+HashMap,多线程用ConcurrentHashMap+CopyOnWriteArrayList就能覆盖90%场景。

性能敏感时要注意初始容量设置。HashMap默认16,加载因子0.75,如果知道大概数据量,初始化时指定容量避免频繁扩容。比如预计存1000个元素,设初始容量为2048(1000/0.75向上取2的幂次)。

迭代时修改的问题,Java8提供了不少新方法。比如用removeIf代替迭代器中删除,用computemerge代替先get后put的复合操作。

最后,强烈建议看看Collections工具类里的静态方法。unmodifiableXXX创建不可变视图,synchronizedXXX包装成线程安全(但要注意,这种包装只是方法级同步,复合操作仍需外部同步)。

实际项目中,我习惯对暴露给外部的集合用Collections.unmodifiableList包装,防止外部代码意外修改。内部用ConcurrentHashMap时,会重写toString方法避免调用size()影响性能(因为要遍历所有段统计)。

集合框架用得好不好,关键看是否理解数据结构的本质。没事多看看源码,理解扩容机制、哈希冲突处理、树化阈值这些细节,遇到性能问题才知道从哪优化。# 005、异常处理的艺术:Checked/Unchecked异常与最佳实践

昨天深夜排查线上问题,日志里堆叠的NullPointerException下面压着十几层业务调用链。定位到最后发现是某处catch(Exception e)吞掉了本应抛出的IllegalArgumentException,导致上游拿到脏数据继续运行。这种场景大家应该都不陌生——异常处理写起来简单,真要写出健壮性却是门手艺活。

一、Checked还是Unchecked?这不是选择题

Java把异常分成两大类:受检异常(Checked Exception)和运行时异常(Unchecked Exception)。很多新人会死记“Checked必须catch,Unchecked不用管”,这种理解要出问题。

看个典型例子:

// 反例:把业务错误包装成Checked Exception
public void processOrder(Order order) throws OrderProcessingException {
    if (order == null) {
        throw new OrderProcessingException("订单不能为空"); // 这是个Checked Exception
    }
    // 处理逻辑
}

// 调用方被迫处理
try {
    processOrder(order);
} catch (OrderProcessingException e) {
    // 但这里能做什么?通常是打日志然后向上抛
    throw new RuntimeException(e);
}

这种设计的问题在于:order为null是调用方的编程错误,应该在测试阶段就发现。用Checked Exception强制调用方处理,结果往往是catch里直接转成RuntimeException,徒增模板代码。

二、什么时候该用Checked Exception?

真正的规则是:受检异常用于可恢复的、预期内的异常情况

比如文件处理:

public String readConfigFile(String path) throws IOException {
    File file = new File(path);
    if (!file.exists()) {
        // 文件可能被移动,这是可恢复场景
        // 可以降级用默认配置,或者让用户重新选择文件
        throw new FileNotFoundException("配置文件丢失: " + path);
    }
    return Files.readString(file.toPath());
}

这里的IOException是合理的Checked Exception,因为:

  1. 文件丢失是业务运行中可能发生的正常情况
  2. 调用方可能有备用方案(比如使用默认配置)
  3. 编译器强制调用方思考如何处理这种场景

三、运行时异常处理误区

最常见的坑在这里:

// 危险操作:捕获RuntimeException但不处理
try {
    userService.updateBalance(userId, amount);
    orderService.markAsPaid(orderId);
} catch (Exception e) { // 抓得太宽了!
    logger.error("支付失败", e);
    // 这里没有回滚事务!
    // 余额扣了但订单状态没更新,数据不一致了
}

// 另一个极端:该抓不抓
public BigDecimal calculateDiscount(Order order) {
    // 如果order.getItems()返回null,下一行就NPE
    return order.getItems().stream()
                .map(item -> item.getPrice().multiply(item.getQuantity()))
                .reduce(BigDecimal.ZERO, BigDecimal::add)
                .multiply(new BigDecimal("0.9"));
}

第一个例子把Exception一网打尽,连NullPointerException这种编程错误也吞掉了。第二个例子对可能的NPE视而不见,等线上报错才手忙脚乱。

四、实用异常处理模式

1. 分层处理策略

// 在Controller层处理业务异常
@RestController
public class OrderController {
    @PostMapping("/orders")
    public ApiResponse createOrder(@RequestBody OrderRequest request) {
        try {
            return ApiResponse.success(orderService.create(request));
        } catch (BusinessException e) {
            // 业务异常转成用户友好提示
            return ApiResponse.fail(e.getCode(), e.getMessage());
        } catch (Exception e) {
            // 其他异常是系统错误,记录详细日志
            log.error("创建订单系统异常", e);
            return ApiResponse.fail("SYSTEM_ERROR", "系统繁忙");
        }
    }
}

// Service层抛出具体的业务异常
@Service
public class OrderService {
    public Order create(OrderRequest request) {
        if (request.getItems().isEmpty()) {
            // 这是业务规则校验,用自定义的RuntimeException
            throw new BusinessException("ORDER_ITEMS_EMPTY", "订单商品不能为空");
        }
        
        // 参数校验用IllegalArgumentException
        if (request.getUserId() == null) {
            throw new IllegalArgumentException("用户ID不能为空");
        }
        
        // 数据库操作可能抛出的DataAccessException是Spring包装的RuntimeException
        return orderRepository.save(convertToOrder(request));
    }
}

2. 资源关闭用try-with-resources

// 老写法容易忘记关闭资源
FileInputStream fis = null;
try {
    fis = new FileInputStream("file.txt");
    // 处理文件
} finally {
    if (fis != null) {
        try { fis.close(); } catch (IOException e) { /* 忽略关闭异常 */ }
    }
}

// 新写法自动关闭,清晰多了
try (FileInputStream fis = new FileInputStream("file.txt");
     BufferedReader reader = new BufferedReader(new InputStreamReader(fis))) {
    String line;
    while ((line = reader.readLine()) != null) {
        processLine(line);
    }
} // 这里自动调用close(),多个资源按声明顺序逆序关闭

3. 异常转换保持上下文

// 别这样:原始异常信息丢了
try {
    parseJson(response);
} catch (JsonParseException e) {
    throw new BusinessException("解析失败"); // 原来的e去哪了?
}

// 应该这样:保留根本原因
try {
    parseJson(response);
} catch (JsonParseException e) {
    // 把原始异常作为cause传递,排查问题时能看到完整链条
    throw new BusinessException("解析响应数据失败", e);
}

五、个人经验谈

这些年踩过的异常处理坑,总结几条血泪经验:

关于异常类型选择:我现在的原则是,除非调用方真的有合理的恢复策略,否则优先用RuntimeException。Java标准库也在往这个方向走,JDBC 4.0之后很多SQLException改成了SQLRuntimeException

关于catch块:永远不要写空的catch块,至少记录日志。但记录日志时别只打印e.getMessage(),那个信息可能很模糊,要把异常对象本身打出来。

关于异常信息:抛异常时提供足够的信息量。对比throw new IllegalArgumentException("参数错误")throw new IllegalArgumentException("userId不能为空,当前值: " + userId),后者在排查时能省下半小时。

关于性能:异常处理确实有开销,但别过度优化。只有在性能测试证明异常是瓶颈时,才考虑用错误码替代。99%的场景下,异常的可读性和维护性优势更重要。

最后一条:异常处理代码也是业务逻辑的一部分。review代码时,我会特别检查catch块和finally块,这里隐藏的逻辑问题往往比正常业务逻辑更多。好的异常处理能让系统在出错时优雅降级,而不是直接崩溃或者产生脏数据。

下次写try-catch时,不妨多问自己一句:这个异常真的应该在这里处理吗?调用方需要知道这个错误吗?系统能从这种错误中恢复吗?想清楚这三个问题,异常处理的代码质量能提升一个档次。# 006、多线程与并发编程:Thread、Runnable、锁与线程池

上周排查一个线上问题,服务在流量高峰时出现数据错乱。日志里明明是按顺序处理的记录,最终入库却乱了套。打开线程堆栈一看,十几个线程在同一个HashMap里横冲直撞——典型的线程安全问题。今天咱们就聊聊Java里那些让程序员又爱又恨的并发工具。

从Thread到Runnable

很多新手会这样写线程:

// 别这样写,耦合太重了
class MyThread extends Thread {
    @Override
    public void run() {
        System.out.println("线程运行中");
    }
}

更优雅的方式是实现Runnable:

// 推荐写法:任务与执行分离
class MyTask implements Runnable {
    private final String taskName;
    
    public MyTask(String name) {
        this.taskName = name;
    }
    
    @Override
    public void run() {
        // 这里踩过坑:run()里抛异常线程会静默退出
        try {
            System.out.println(taskName + " 执行中");
        } catch (Exception e) {
            // 一定要捕获异常
            e.printStackTrace();
        }
    }
}

// 使用方式
Thread thread = new Thread(new MyTask("任务1"));
thread.start();

记住一个原则:除非需要修改线程行为,否则永远用Runnable。Java单继承的限制决定了实现接口比继承类更灵活。

synchronized的陷阱

那个线上问题的根源就在锁的使用上。看这段问题代码:

// 这是有问题的写法
public class Counter {
    private static int count = 0;
    
    public synchronized void add() {
        count++;
    }
}

问题在哪?synchronized修饰实例方法时,锁的是当前对象实例。如果是多个Counter对象,它们之间根本不会互斥!静态变量应该用类锁:

// 修正版本
public class Counter {
    private static int count = 0;
    private static final Object lock = new Object();  // 专门用个锁对象
    
    public void add() {
        synchronized(lock) {  // 明确指定锁对象
            count++;
            // 这里注意:锁的范围要尽量小
            // 但也不能太小,要覆盖完整的原子操作
        }
    }
}

更现代的写法是用ReentrantLock:

private final ReentrantLock lock = new ReentrantLock(true);  // true表示公平锁

public void add() {
    lock.lock();
    try {
        count++;
    } finally {
        lock.unlock();  // 必须放在finally里
    }
}

线程池的实战经验

创建线程是个重量级操作,线程池是必选项。但线程池参数配置有讲究:

// 典型的生产者-消费者场景配置
ThreadPoolExecutor executor = new ThreadPoolExecutor(
    4,      // 核心线程数:CPU密集型可以设小点,IO密集型可以设大点
    16,     // 最大线程数:根据系统负载调整
    60L, TimeUnit.SECONDS,  // 空闲线程存活时间
    new LinkedBlockingQueue<>(1000),  // 队列容量要明确设置,防止内存溢出
    new ThreadFactory() {
        private final AtomicInteger counter = new AtomicInteger(1);
        
        @Override
        public Thread newThread(Runnable r) {
            Thread t = new Thread(r, "业务线程-" + counter.getAndIncrement());
            t.setDaemon(false);  // 通常设为非守护线程
            return t;
        }
    },
    new ThreadPoolExecutor.CallerRunsPolicy()  // 拒绝策略:让调用线程执行
);

// 提交任务
Future<String> future = executor.submit(() -> {
    // 这里用lambda简化代码
    return processData();
});

// 获取结果
try {
    String result = future.get(2, TimeUnit.SECONDS);  // 一定要设置超时
} catch (TimeoutException e) {
    // 超时处理:记录日志,考虑是否取消任务
    future.cancel(true);
}

volatile的误解

很多人以为volatile能保证线程安全:

// 这是错的!volatile不保证复合操作的原子性
private volatile int count = 0;

public void increment() {
    count++;  // 这个操作不是原子的!
}

volatile只保证可见性和有序性,适合做状态标志位:

private volatile boolean running = true;

public void shutdown() {
    running = false;  // 其他线程能立即看到变化
}

ThreadLocal的坑

ThreadLocal用好了是神器,用不好就是内存泄漏的元凶:

private static final ThreadLocal<SimpleDateFormat> dateFormatHolder = 
    ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd"));

// 使用后必须清理!
try {
    dateFormatHolder.get().format(new Date());
} finally {
    dateFormatHolder.remove();  // 特别是线程池场景,一定要remove
}

个人经验谈

并发编程最忌讳的就是“我觉得这样应该没问题”。我总结了几条血泪教训:

第一,所有共享变量都要问自己三个问题:可见性怎么保证?原子性怎么保证?有序性怎么保证?少考虑一个就可能出线上事故。

第二,锁的粒度要反复权衡。锁太粗影响性能,锁太细容易死锁。我习惯先用粗粒度锁实现功能,再根据压测结果逐步细化。

第三,线程池参数没有银弹。核心线程数、队列长度、拒绝策略都要结合具体业务调整。压测时特别关注队列积压情况,积压太多赶紧调整策略。

第四,调试多线程程序时,别依赖System.out。用日志框架的MDC功能打上线程ID,用jstack分析死锁,用Arthas在线诊断。

最后说句实话,Java的并发API已经足够丰富,但真正难的不是API使用,而是并发思维的培养。多读Doug Lea的代码,多写多调试,遇到问题别急着问,先自己看源码。源码里那些注释都是前辈们的经验结晶,比任何博客都有价值。

下次咱们聊聊Java内存模型,那才是理解并发的基石。# 007、IO与NIO深度剖析:流、通道、缓冲区与Selector


从一次深夜调试说起

上周排查线上问题,有个文件处理服务在并发量上去后CPU直接飙到90%,日志里大量“too many open files”报错。用lsof一看,果然有几千个文件句柄没释放。翻代码发现同事在循环里用FileInputStream读小文件,每次new流却没正确close——经典的传统IO误用场景。这让我决定重新梳理Java IO/NIO这套东西,很多坑其实早就埋在了设计理解层面。


传统IO:流模型与它的局限

Java最早的IO API围绕**流(Stream)**设计。InputStream/OutputStream处理字节,Reader/Writer处理字符。抽象很简洁,但有个本质问题:流是单向的,读和写要分开创建对象。

// 典型文件复制写法(问题版)
try {
    FileInputStream fis = new FileInputStream("source.txt");
    FileOutputStream fos = new FileOutputStream("target.txt");
    int b;
    while ((b = fis.read()) != -1) {  // 这里踩过坑:逐字节读,性能灾难
        fos.write(b);
    }
    // 万一中间抛异常,close可能不会执行
    fis.close();
    fos.close();
} catch (IOException e) {
    e.printStackTrace();
}

上面代码三个问题:1)单字节读写极慢;2)异常时资源可能泄漏;3)没有用缓冲区。改进后:

// 改进版,但依然是阻塞IO
try (BufferedInputStream bis = new BufferedInputStream(new FileInputStream("source.txt"));
     BufferedOutputStream bos = new BufferedOutputStream(new FileOutputStream("target.txt"))) {
    byte[] buffer = new byte[8192];  // 8K缓冲区,经验值
    int len;
    while ((len = bis.read(buffer)) != -1) {
        bos.write(buffer, 0, len);
    }
}  // try-with-resources自动close,Java7后推荐写法

好多了,但底层还是阻塞IOread()会一直卡住直到数据就绪。在高并发场景,每个连接一个线程,线程上下文切换成本很快会把系统压垮。


NIO核心三件套:Channel、Buffer、Selector

NIO(New I/O)在Java 1.4引入,不是简单的Non-blocking I/O,更该理解为更灵活的IO模型

Buffer:不再直接操作数组

ByteBuffer是核心,它把数组包装起来,加了位置指针。

ByteBuffer buf = ByteBuffer.allocate(1024);  // 堆内缓冲区
// ByteBuffer.allocateDirect(1024);  // 堆外内存,零拷贝用,但分配慢

buf.put("hello".getBytes());  // 写数据
buf.flip();  // 关键操作!切换为读模式,position归0
while (buf.hasRemaining()) {
    System.out.print((char) buf.get());  // 逐个字节读
}
buf.clear();  // 清空缓冲区,准备再次写入

容易翻车点:忘记flip()就直接读,或者读完后没clear()/compact()就继续写。Buffer内部四个指针(position、limit、capacity、mark)得心里有数。

Channel:双向管道

Channel可以同时读写,不像流要分开。常见的有FileChannelSocketChannelServerSocketChannel

// 用Channel复制文件,性能比流好
try (FileChannel src = new FileInputStream("source.txt").getChannel();
     FileChannel dst = new FileOutputStream("target.txt").getChannel()) {
    src.transferTo(0, src.size(), dst);  // 零拷贝操作,内核态完成
}

SocketChannelServerSocketChannel可以配置非阻塞模式:

SocketChannel client = SocketChannel.open();
client.configureBlocking(false);  // 设为非阻塞
client.connect(new InetSocketAddress("127.0.0.1", 8080));
while (!client.finishConnect()) {
    // 连接未完成时可以干别的,不会阻塞线程
}

Selector:多路复用器

这是NIO实现高并发的关键。单个线程可以管理多个Channel的事件(连接、读、写)。

Selector selector = Selector.open();
ServerSocketChannel server = ServerSocketChannel.open();
server.configureBlocking(false);
server.bind(new InetSocketAddress(8080));
server.register(selector, SelectionKey.OP_ACCEPT);  // 注册感兴趣的事件

while (true) {
    int readyChannels = selector.select();  // 阻塞到有事件就绪
    if (readyChannels == 0) continue;
    
    Set<SelectionKey> keys = selector.selectedKeys();
    Iterator<SelectionKey> iter = keys.iterator();
    while (iter.hasNext()) {
        SelectionKey key = iter.next();
        if (key.isAcceptable()) {
            // 处理新连接
            SocketChannel client = server.accept();
            client.configureBlocking(false);
            client.register(selector, SelectionKey.OP_READ);
        } else if (key.isReadable()) {
            // 读事件就绪
            SocketChannel channel = (SocketChannel) key.channel();
            ByteBuffer buffer = ByteBuffer.allocate(1024);
            int len = channel.read(buffer);
            if (len == -1) {
                channel.close();  // 客户端断开
            }
            // 处理数据...
        }
        iter.remove();  // 必须手动移除,否则下次还会处理
    }
}

Selector的坑:1)selectedKeys()返回的集合需要手动移除处理过的Key;2)事件触发后必须处理,否则会一直触发;3)在Linux下底层用epoll,但Windows用select,性能差异大。


NIO与IO的本质区别

很多人说NIO就是非阻塞IO,这不准确。NIO提供了非阻塞模式的能力,但也可以用在阻塞场景(比如FileChannel)。核心差异在于:

  • IO是面向流的,NIO是面向缓冲区的:流像水管,数据只能顺序流动;缓冲区像水池,可以前后移动访问。
  • IO是阻塞的,NIO支持非阻塞:NIO可以用一个线程处理多个连接。
  • NIO有选择器:允许单线程监控多个通道的事件。

但注意,NIO的复杂度高了很多。Buffer的状态管理、Channel的注册注销、Selector的事件循环,稍不注意就出bug。


实战建议与个人经验

  1. 不要为了NIO而NIO:如果应用连接数不高(比如<1000),用传统IO+线程池可能更稳。NIO代码复杂度高,调试困难。

  2. ByteBuffer记得flip/clear:我习惯写完数据立刻flip(),读完立刻clear(),形成肌肉记忆。

  3. Selector小心空轮询:Linux下epoll可能因为内核bug立即返回,导致CPU 100%。Netty里通过计数阈值重建Selector来规避,自己实现的话记得加保护。

  4. 文件操作优先用FileChannel.transferTo:特别是大文件,零拷贝减少内核态到用户态的数据复制。

  5. 生产环境考虑Netty:直接裸写NIO容易踩坑,Netty封装了大部分细节,提供了更友好的API和健壮的错误处理。

  6. 监控文件描述符数量:不管用哪种IO,都要确保资源正确关闭。lsof -p <pid>/proc/<pid>/fd是Linux下查泄漏的好工具。


NIO不是银弹,它把IO的控制权更多地交给了程序员,同时也带来了更多责任。理解底层机制很重要,但在实际项目中,合理选择技术方案往往比炫技更重要。毕竟,能稳定跑在生产线上的代码,才是好代码。# 008、反射与注解:运行时类型信息与元编程

上周排查线上问题,遇到个典型场景:某个配置类字段被同事重命名了,但对应的配置文件解析逻辑没同步更新。日志里只抛了个模糊的“字段映射失败”,凌晨三点盯着屏幕,我不得不翻出反射API,写了个临时诊断工具动态打印所有字段名——这种时刻你会真正理解运行时类型信息的意义。

反射:Java的“自省”能力

反射的核心是Class对象。它就像类的身份证,存着所有结构信息。获取Class对象有三种常见方式:

// 最常用——类字面常量
Class<User> clazz1 = User.class;

// 运行时动态获取——对象实例
User user = new User();
Class<? extends User> clazz2 = user.getClass();

// 最灵活——类加载器加载(框架常用)
Class<?> clazz3 = Class.forName("com.example.User");

注意Class.forName()会触发类初始化,而前两种方式不会。在性能敏感场景要留意这个区别。

字段操作是反射的常见用途。看个实际例子:

public class Config {
    private String apiKey;  // 同事把这个改成了apiToken
    private int timeout;
}

// 诊断工具方法
public static void printFields(Object obj) throws Exception {
    Class<?> clazz = obj.getClass();
    // getDeclaredFields能拿到私有字段,getFields只能拿public
    Field[] fields = clazz.getDeclaredFields();
    
    for (Field field : fields) {
        field.setAccessible(true);  // 关键!绕过private限制
        System.out.println(field.getName() + " = " + field.get(obj));
    }
}

那个凌晨我就是靠类似代码发现字段名不匹配的。setAccessible(true)这行有性能开销,生产环境别滥用。

方法调用:动态派发的另一种形式

反射调用方法比直接调用慢一个数量级,但在框架设计中无可替代:

public class Service {
    private void process(String data) {
        System.out.println("处理: " + data);
    }
}

// 调用私有方法
Method method = Service.class.getDeclaredMethod("process", String.class);
method.setAccessible(true);
method.invoke(serviceInstance, "测试数据");

踩坑记录invoke的第一个参数如果是静态方法,传null就行。返回值是Object,基本类型会装箱,记得处理。

注解:给代码加上“标签”

注解本身只是个标记,需要配合反射才有意义。自定义注解很简单:

@Retention(RetentionPolicy.RUNTIME)  // 必须!否则运行时拿不到
@Target(ElementType.FIELD)
public @interface ConfigField {
    String alias() default "";  // 配置项别名
    boolean required() default false;
}

实际应用时这样:

public class AppConfig {
    @ConfigField(alias = "api_key", required = true)
    private String apiToken;  // 字段改名也不怕了
    
    @ConfigField
    private int timeout;
}

解析逻辑可以统一处理字段映射:

public static <T> T loadConfig(Map<String, String> props, Class<T> clazz) 
        throws Exception {
    T instance = clazz.newInstance();
    for (Field field : clazz.getDeclaredFields()) {
        ConfigField anno = field.getAnnotation(ConfigField.class);
        if (anno != null) {
            String key = anno.alias().isEmpty() ? field.getName() : anno.alias();
            String value = props.get(key);
            
            if (value == null && anno.required()) {
                throw new RuntimeException("缺少必要配置: " + key);
            }
            
            if (value != null) {
                field.setAccessible(true);
                // 类型转换需要实际处理,这里简化了
                field.set(instance, convertValue(field.getType(), value));
            }
        }
    }
    return instance;
}

现在字段名和配置键解耦了。同事再改字段名,只要注解的alias不变,解析逻辑完全不用动。

组合使用:实现简易依赖注入

反射加注解能玩出很多花样。比如做个极简的DI容器:

@Component
public class UserService {
    @Autowired  // 自定义注解
    private UserRepository repo;
    
    public void findUser(Long id) {
        return repo.findById(id);
    }
}

// 容器初始化时
public void init() {
    // 扫描所有Component
    // 创建实例
    // 遍历字段,遇到Autowired就查找对应类型实例并注入
}

Spring早期版本的核心差不多就是这个思路,当然现在复杂多了。

性能与安全:双刃剑

反射的代价很明显。我做过简单测试,反射调用比直接调用慢50倍以上。缓存Method/Field对象能缓解,但无法根本解决。

安全方面更要注意:

// 危险操作!
Field modifiersField = Field.class.getDeclaredField("modifiers");
modifiersField.setAccessible(true);
modifiersField.setInt(myField, myField.getModifiers() & ~Modifier.FINAL);

// 这样连final字段都能改,破坏不可变性

生产环境一定要慎用这种黑魔法。

个人经验

  1. 反射适合“架桥”:框架开发、通用工具、动态代理这些场景很合适。业务代码里直接硬编码反射的,十有八九是设计有问题。

  2. 注解要配文档:自定义注解一定要写清楚使用场景和副作用。我见过团队里有人把事务注解用在私有方法上,调试半天找不到问题。

  3. 运行时类型检查优先考虑instanceof:如果只是判断类型,instanceofobj.getClass() == SomeClass.class更安全,它处理了继承和null的情况。

  4. IDE的“查找注解使用”功能:排查注解相关问题时,这个功能比grep好用得多。

  5. 反射的替代方案:Java 8的MethodHandle、LambdaMetafactory性能更好,但用起来复杂。高并发场景值得研究。

那次凌晨调试之后,我在团队代码规范里加了一条:“所有配置映射必须使用注解声明别名”。反射就像手术刀——在合适的人手里能救命,在新手手里容易伤到自己。掌握它的最好方式不是背API,而是想象没有它的时候,你要多写多少重复代码。# 009、泛型编程:类型安全、擦除与通配符

上周排查一个线上问题,日志里报 ClassCastException: java.lang.String cannot be cast to java.lang.Integer,跟踪下去发现是个集合类型转换的坑。同事在某个工具方法里用了原始类型 List,传进来的是字符串列表,另一处却当作整数列表来用。这种运行时才暴露的类型错误,正是泛型要解决的核心问题。

类型安全不是编译器的玩笑

没有泛型的时候,我们得这样写:

List list = new ArrayList();
list.add("hello");
Integer num = (Integer) list.get(0);  // 运行时才炸

类型转换像走钢丝,编译器不会提醒你放进去的和取出来的类型不一致。

泛型来了之后:

List<String> list = new ArrayList<>();
list.add("hello");
// list.add(123);  // 编译直接报错,这里就拦住
String text = list.get(0);  // 不用强制转换

编译器在编译期就帮你做了类型检查,这种安全是实实在在的。但要注意,泛型的类型安全只在编译期有效,运行时类型信息会被擦除——这个我们稍后细说。

类型擦除:Java泛型的“皇帝新衣”

很多新人以为 List<String>List<Integer> 在运行时是两个不同的类,其实不然。看这个例子:

List<String> strList = new ArrayList<>();
List<Integer> intList = new ArrayList<>();
System.out.println(strList.getClass() == intList.getClass());  // 输出 true

两个 getClass() 返回的是同一个类 ArrayList。编译器在编译后会去掉类型参数,这个过程就是类型擦除。List<String> 擦除后变成原始类型 List,所有类型参数替换为它们的边界(没指定边界就是 Object)。

所以下面这种写法编译不过:

public class Container<T> {
    private T item;
    
    public void checkType(Object obj) {
        if (obj instanceof T) {  // 编译错误:Cannot perform instanceof check against type parameter T
            // ...
        }
    }
}

运行时根本不知道 T 是什么,instanceof 自然没法用。这也是为什么创建泛型数组这么别扭:

T[] array = new T[10];  // 编译错误
// 只能这样绕
T[] array = (T[]) new Object[10];  // 会有unchecked cast警告

通配符:灵活与约束的平衡

遇到方法需要接收不确定类型的泛型集合时,通配符就派上用场了。但 ? extends? super 的区别很多人一直迷糊。

看这个典型场景:

// 生产者用 extends
void processList(List<? extends Number> list) {
    Number num = list.get(0);  // 可以读,安全
    // list.add(new Integer(1));  // 编译错误!这里踩过坑
}

? extends Number 表示“某种Number的子类”,但编译器不知道具体是哪个子类。你能安全地读成Number,但不能往里写——万一实际是 List<Double>,你写个Integer进去不就乱套了?

// 消费者用 super
void fillList(List<? super Integer> list) {
    list.add(42);  // 可以写Integer
    list.add(100);
    // Integer num = list.get(0);  // 编译错误,取出来只能是Object
}

? super Integer 表示“Integer的某种父类”,可能是Integer、Number或Object。你可以安全地写入Integer(因为任何父类都能接收Integer),但取出来只能当Object处理。

记住PECS原则(Producer Extends, Consumer Super)能少走弯路:当容器作为生产者(提供数据)时用extends,作为消费者(接收数据)时用super。

实际编码中的坑点

  1. 别用原始类型,除非在遗留代码里不得不兼容

    List rawList = new ArrayList();  // 别这样写
    List<String> safeList = new ArrayList<>();  // 这样写
    
  2. 泛型静态方法自己声明类型参数

    // 这个<T>是方法自己的,和类无关
    public static <T> T getFirst(List<T> list) {
        return list.isEmpty() ? null : list.get(0);
    }
    
  3. 类型推断在Java 7后更简洁

    // 老写法,右边重复写类型
    Map<String, List<Integer>> map = new HashMap<String, List<Integer>>();
    // 钻石运算符,让编译器推断
    Map<String, List<Integer>> map = new HashMap<>();
    
  4. 边界的使用场景

    // 限制T必须是Comparable的实现类
    public static <T extends Comparable<T>> T max(List<T> list) {
        // 现在可以调用compareTo方法了
    }
    

个人经验建议

泛型学起来抽象,用多了就自然。刚开始可以多依赖IDE的提示,它帮你推断类型时,留意看类型参数是怎么传递的。遇到复杂的嵌套泛型(比如 Map<Class<?>, List<? extends Serializable>> 这种),拆开看,从里层一层层理解。

实际项目中,定义API时泛型用得好,调用方会舒服很多。特别是工具类和通用组件,合理的泛型设计能减少很多强制转换。但也别过度设计,有时候一个明确的 List<String>List<? extends CharSequence> 更直观。

最后记住,泛型的所有魔法都在编译期,运行时它就是个“普通人”。理解擦除机制,能帮你明白为什么有些写法不行,以及为什么会有那些看似奇怪的限制。当你习惯了编译期类型检查的安全感,就再也回不去原始类型满天飞的日子了。# 010、Java 8+核心新特性:Lambda、Stream API与Optional

上周排查一个线上问题,日志里看到一行 NullPointerException 出现在某个集合的 forEach 循环里。点开源码一看,同事写了段这样的代码:

List<User> userList = getUserList();
userList.stream()
        .map(user -> user.getDetail())
        .forEach(detail -> System.out.println(detail.getInfo()));

问题出在 user.getDetail() 可能返回 null,而下一行直接调用了 detail.getInfo()。这种链式调用在 Stream 里很常见,但 NPE 被掩盖在流水线深处,调试时得一层层反推数据状态。今天我们就聊聊 Java 8 引入的三个核心特性:Lambda、Stream API 和 Optional,它们如何改变我们的编码风格,以及如何避免引入新的陷阱。


Lambda:不只是语法糖

第一次见到 Lambda 表达式时,很多人觉得它只是匿名类的简写。比如以前写 Comparator:

Collections.sort(list, new Comparator<User>() {
    @Override
    public int compare(User u1, User u2) {
        return u1.getAge() - u2.getAge();
    }
});

现在变成一行:

list.sort((u1, u2) -> u1.getAge() - u2.getAge());

但 Lambda 的真正价值在于行为参数化。你可以把一段代码像数据一样传递。我常用它来封装业务中的重复模式:

// 封装一个带重试的通用执行器
public static <T> T executeWithRetry(Supplier<T> supplier, int retryTimes) {
    while (retryTimes-- > 0) {
        try {
            return supplier.get();
        } catch (Exception e) {
            // 记录日志,稍等后重试
        }
    }
    throw new RuntimeException("重试失败");
}

// 调用时传一段Lambda进去
User user = executeWithRetry(() -> userDao.findById(userId), 3);

注意 Lambda 里如果引用外部变量,这个变量必须是 final 或 effectively final。下面这种写法会编译报错:

int count = 0;
list.forEach(item -> {
    count++; // 编译错误:Variable used in lambda expression should be final or effectively final
});

这里踩过坑:Lambda 不是闭包,它捕获的是变量的值而非引用。如果需要在 Lambda 内修改状态,考虑用数组或 Atomic 类型包装。


Stream API:声明式数据处理

Stream 最大的转变是从“怎么做”到“做什么”。以前我们要写循环、临时变量、条件判断,现在用声明式操作链。比如找出年龄大于 20 的用户姓名并去重:

// 旧写法
Set<String> names = new HashSet<>();
for (User user : userList) {
    if (user.getAge() > 20) {
        names.add(user.getName());
    }
}

// Stream 写法
List<String> names = userList.stream()
        .filter(user -> user.getAge() > 20)
        .map(User::getName)
        .distinct()
        .collect(Collectors.toList());

流水线的每个操作(filter、map、sorted 等)都是惰性的,只有遇到终结操作(collect、forEach 等)才会真正执行。这意味着你可以构建复杂的处理流程而不必担心中间产生大量临时集合。

但 Stream 不是银弹。并行流 parallelStream() 看起来美好,但线程池是共享的 ForkJoinPool.commonPool(),在 Web 环境乱用可能导致其他并行任务饥饿。我一般只在明确数据量较大(比如十万条以上)且操作无状态、无顺序依赖时才考虑并行。

另一个注意点:Stream 会掩盖异常。比如 map 操作里调用的方法抛异常,堆栈跟踪里可能看不到完整的调用链。调试时可以在关键节点 peek 一下:

List<Detail> details = userList.stream()
        .peek(user -> System.out.println("处理用户: " + user.getId())) // 调试用,生产环境别留
        .map(User::getDetail)
        .filter(Objects::nonNull)
        .collect(Collectors.toList());

Optional:优雅处理 null 的尝试

Optional 的设计初衷是强制调用者考虑值缺失的情况。但很多人用错了:

// 别这样写!这和直接返回 null 没区别
public Optional<User> findUser(String id) {
    User user = userDao.findById(id);
    return Optional.of(user); // 如果 user 是 null,这里立刻抛 NPE
}

// 正确用法
public Optional<User> findUser(String id) {
    User user = userDao.findById(id);
    return Optional.ofNullable(user);
}

更常见的误用是在 Optional 上直接调用 get():

Optional<User> opt = findUser("123");
User user = opt.get(); // 如果 Optional 为空,这里抛 NoSuchElementException

这完全违背了 Optional 的初衷。正确的姿势是链式调用:

String userName = findUser("123")
        .map(User::getName)
        .orElse("未知用户");

Optional 应该用于返回值,而不是作为方法参数或字段类型。我曾经在项目里见过 Optional<Optional<List<User>>> 这种嵌套结构,那简直是自找麻烦。

对于集合返回,更推荐返回空集合而非 Optional<List<T>>。空集合可以直接遍历,而 Optional 包装的多余层级只会增加复杂度。


三者结合的实际场景

一个常见的业务场景:从一批用户中找出第一个有邮箱的用户,并发送邮件。

// 传统写法
public void sendEmailToFirstValidUser(List<User> users) {
    for (User user : users) {
        if (user.getEmail() != null && !user.getEmail().isEmpty()) {
            sendEmail(user.getEmail(), "您的账户通知");
            break;
        }
    }
}

// 新特性组合写法
public void sendEmailToFirstValidUser(List<User> users) {
    users.stream()
         .filter(user -> StringUtils.isNotEmpty(user.getEmail()))
         .findFirst()
         .ifPresent(user -> sendEmail(user.getEmail(), "您的账户通知"));
}

后者更清晰地表达了“查找第一个”的意图,而且 Optional 的 ifPresent 避免了 null 检查。


个人经验建议

  1. 渐进式重构:不要为了用新特性而重写所有旧代码。从新写的模块开始,或者在小范围重构时逐步引入。特别是 Stream API,复杂的流水线调试起来比循环更费劲。

  2. 保持可读性:当 Stream 操作超过三步时,考虑拆分成多行或者抽成方法。一行里塞满 filter、map、flatMap、sorted 的代码,三个月后你自己都看不懂。

  3. Optional 不是万能的:数据库查询结果、配置项读取这些天然可能缺失值的场景适合用 Optional。但不要用它替代所有的 null 检查,更不要指望它能解决设计上的缺陷。

  4. 性能敏感处慎用:在热点路径(比如每秒调用上万次的方法)里,Stream 和 Lambda 会有额外的对象创建开销。用 JMH 测一下,如果真有性能问题,回退到传统循环不丢人。

  5. 团队统一约定:和团队约定 Optional 的使用规范,比如禁止直接调用 get()、isPresent() 后必须跟 else 逻辑等。一致性比个人风格更重要。

最后说一句:这些特性是工具,不是信仰。用它们写出更清晰、更健壮的代码才是目的,别把自己变成“特性驱动开发”的信徒。好的代码,终究是给人看的。