4. Java基础速通(下):IO流、多线程基础、网络编程入门、异常处理机制

好,咱们继续往下走。上一章我们把Java的核心语法和面向对象啃完了,这一章要聊的四个主题,说实话,是真正让你从「写玩具代码」迈向「写工程代码」的分水岭。IO流、多线程、网络编程、异常处理,每一个都是实战中绕不开的硬骨头。我当年刚入行时,就是在这几个地方栽过跟头,今天我把这些经验掰开了揉碎了讲给你听。

4.1 IO流:数据进出的高速公路

IO流,说白了就是Java程序跟外部世界交换数据的通道。你从文件读数据、往文件写数据、从网络读数据,统统离不开它。

4.1.1 流的概念与分类

Java把所有的输入输出都抽象成了「流」。你可以想象成水管里的水,数据从一头流到另一头。

按流向分:

  • 输入流:从数据源(文件、网络)读到程序里
  • 输出流:从程序写到目标(文件、网络)

按处理单位分:

  • 字节流:处理二进制数据,如图片、视频、压缩包
  • 字符流:处理文本数据,如.txt、.java文件

按功能分:

  • 节点流:直接连接数据源,比如FileInputStream
  • 处理流:包装节点流,提供更强大的功能,比如BufferedReader

核心口诀:字节流读一切,字符流读文本。处理流包节点,性能翻倍不费劲。

4.1.2 字节流实战:文件复制

先看一个最经典的例子——文件复制。我用字节流实现,因为字节流是万能的,不管什么文件都能处理。

import java.io.*;

public class FileCopy {
    public static void main(String[] args) {
        FileInputStream fis = null;
        FileOutputStream fos = null;
        try {
            fis = new FileInputStream("source.jpg");
            fos = new FileOutputStream("dest.jpg");
            byte[] buffer = new byte[1024];
            int len;
            while ((len = fis.read(buffer)) != -1) {
                fos.write(buffer, 0, len);
            }
            System.out.println("复制完成!");
        } catch (IOException e) {
            e.printStackTrace();
        } finally {
            // 关闭资源,这个坑我踩过无数次
            try {
                if (fis != null) fis.close();
                if (fos != null) fos.close();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }
}

这里有个细节:read(buffer)返回的是实际读取的字节数,不一定等于buffer的长度。最后一次读取可能不满1024字节,所以写的时候必须用write(buffer, 0, len),而不是write(buffer)。我曾经在项目里用错了,结果复制出来的文件末尾多了一堆乱码,排查了半天才找到原因。

4.1.3 字符流与缓冲流

处理文本文件时,字符流更高效。而且我建议你永远用缓冲流包装一下,性能提升非常明显。

// 用BufferedReader读文本文件
try (BufferedReader br = new BufferedReader(new FileReader("data.txt"))) {
    String line;
    while ((line = br.readLine()) != null) {
        System.out.println(line);
    }
} catch (IOException e) {
    e.printStackTrace();
}

注意这里用了try-with-resources语法,Java 7引入的。它会自动关闭资源,不用再写繁琐的finally块了。我个人习惯,只要涉及IO操作,一律用这个写法,省心又安全。

避坑指南:我曾经在读取大文件时没用缓冲流,直接FileReader一个一个字符读,结果一个几百MB的日志文件读了快10分钟。换成BufferedReader后,几十秒就搞定了。缓冲区的默认大小是8192字节,一般够用,不用自己调。

4.2 多线程基础:让程序跑得更快

多线程,说白了就是让程序同时干多件事。你想想看,你一边下载文件一边写代码,这不就是多线程吗?Java对多线程的支持非常强大,但坑也多。

4.2.1 创建线程的两种方式

Java里创建线程主要有两种方式:继承Thread类和实现Runnable接口。我建议你优先用Runnable,因为Java是单继承,实现了Runnable还能继承别的类,灵活得多。

// 方式一:继承Thread
class MyThread extends Thread {
    @Override
    public void run() {
        System.out.println("线程运行中:" + Thread.currentThread().getName());
    }
}

// 方式二:实现Runnable(推荐)
class MyRunnable implements Runnable {
    @Override
    public void run() {
        System.out.println("Runnable线程:" + Thread.currentThread().getName());
    }
}

public class ThreadDemo {
    public static void main(String[] args) {
        // 启动线程
        new MyThread().start();
        new Thread(new MyRunnable()).start();
        
        // Lambda表达式写法(Java 8+)
        new Thread(() -> System.out.println("Lambda线程")).start();
    }
}

注意:启动线程用的是start(),不是run()。直接调run()只是普通方法调用,不会创建新线程。这个错误我见过太多新手犯了,包括当年的我。

4.2.2 线程安全问题

多线程最大的坑就是线程安全。多个线程同时访问共享数据,很容易出问题。看这个经典例子:

class Counter {
    private int count = 0;
    
    public void increment() {
        count++;  // 这行代码不是原子操作!
    }
    
    public int getCount() {
        return count;
    }
}

count++看起来是一行代码,但底层其实是三步:读取count、加1、写回count。两个线程同时执行时,可能都读到同一个值,然后都加1,结果只加了1次。这就是典型的线程安全问题。

解决办法很简单:加synchronized关键字。

public synchronized void increment() {
    count++;
}

或者用AtomicInteger

import java.util.concurrent.atomic.AtomicInteger;

class SafeCounter {
    private AtomicInteger count = new AtomicInteger(0);
    
    public void increment() {
        count.incrementAndGet();  // 原子操作,线程安全
    }
}

注意:synchronized虽然好用,但会降低性能。如果只是简单的计数操作,AtomicInteger更高效。我在一个高并发项目中,把synchronized改成AtomicInteger后,QPS提升了将近30%。

4.2.3 线程池:别自己new线程

实际项目中,千万别手动new Thread()。频繁创建和销毁线程非常消耗资源。正确的做法是用线程池。

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class ThreadPoolDemo {
    public static void main(String[] args) {
        // 创建固定大小的线程池
        ExecutorService executor = Executors.newFixedThreadPool(5);
        
        for (int i = 0; i < 10; i++) {
            executor.submit(() -> {
                System.out.println("任务执行:" + Thread.currentThread().getName());
            });
        }
        
        // 关闭线程池
        executor.shutdown();
    }
}

线程池的核心参数有7个:核心线程数、最大线程数、空闲存活时间、时间单位、任务队列、线程工厂、拒绝策略。这些在面试中经常被问到,建议你背下来。

4.3 网络编程入门:让程序连上世界

网络编程,说白了就是让不同机器上的程序互相通信。Java提供了Socket编程,底层基于TCP/IP协议。

4.3.1 Socket通信模型

经典的C/S架构:服务端监听端口,客户端发起连接。看一个最简单的例子:

// 服务端
ServerSocket serverSocket = new ServerSocket(8888);
System.out.println("服务端启动,等待连接...");
Socket socket = serverSocket.accept();  // 阻塞等待客户端连接
BufferedReader reader = new BufferedReader(
    new InputStreamReader(socket.getInputStream()));
String message = reader.readLine();
System.out.println("收到客户端消息:" + message);
socket.close();
serverSocket.close();

// 客户端
Socket socket = new Socket("localhost", 8888);
PrintWriter writer = new PrintWriter(socket.getOutputStream(), true);
writer.println("Hello, Server!");
socket.close();

这个例子虽然简单,但包含了网络编程的核心要素:IP地址、端口号、Socket、输入输出流。我在做第一个网络项目时,就是从这个模型开始,然后一步步加上多线程、连接池、心跳检测等机制。

4.3.2 实战:多线程服务器

上面的服务器一次只能处理一个客户端。实际中肯定不行,得用多线程处理多个客户端。

public class MultiThreadServer {
    public static void main(String[] args) throws IOException {
        ServerSocket serverSocket = new ServerSocket(8888);
        System.out.println("多线程服务器启动...");
        
        while (true) {
            Socket socket = serverSocket.accept();
            // 每个客户端一个线程处理
            new Thread(new ClientHandler(socket)).start();
        }
    }
}

class ClientHandler implements Runnable {
    private Socket socket;
    
    public ClientHandler(Socket socket) {
        this.socket = socket;
    }
    
    @Override
    public void run() {
        try (BufferedReader reader = new BufferedReader(
                new InputStreamReader(socket.getInputStream()));
             PrintWriter writer = new PrintWriter(
                socket.getOutputStream(), true)) {
            String message;
            while ((message = reader.readLine()) != null) {
                System.out.println("收到:" + message);
                writer.println("服务端已收到:" + message);
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

经验之谈:实际项目中,这种简单的多线程服务器性能不够。一般会用NIO(非阻塞IO)或者Netty框架。但理解这个基础模型非常重要,它是所有高级网络框架的基石。

4.4 异常处理机制:让程序更健壮

异常处理,说白了就是程序出错了怎么办。不能一出错就崩溃,得优雅地处理。

4.4.1 异常体系结构

Java的异常分为两大类:

  • 受检异常(Checked Exception):编译器强制你处理,比如IOException、SQLException
  • 非受检异常(Unchecked Exception):运行时异常,编译器不强制处理,比如NullPointerException、ArrayIndexOutOfBoundsException

还有一个特殊的Error,表示系统级错误,比如OutOfMemoryError,一般程序处理不了。

4.4.2 try-catch-finally 的正确姿势

try {
    // 可能出错的代码
    int result = 10 / 0;
} catch (ArithmeticException e) {
    System.err.println("数学运算异常:" + e.getMessage());
} catch (Exception e) {
    // 捕获更通用的异常
    System.err.println("其他异常:" + e.getMessage());
} finally {
    // 无论如何都会执行,通常用来释放资源
    System.out.println("finally块执行");
}

注意:catch的顺序很重要,子类异常在前,父类异常在后。如果把Exception写在前面,后面的ArithmeticException就永远捕获不到了。

4.4.3 自定义异常

有时候Java自带的异常不够用,需要自己定义。比如用户注册时用户名已存在:

class UserAlreadyExistsException extends Exception {
    public UserAlreadyExistsException(String message) {
        super(message);
    }
}

public class UserService {
    public void register(String username) throws UserAlreadyExistsException {
        if ("admin".equals(username)) {
            throw new UserAlreadyExistsException("用户名已存在:" + username);
        }
        System.out.println("注册成功:" + username);
    }
}

避坑指南:我曾经在一个项目中,看到有人把异常当流程控制用。比如用try-catch来判断文件是否存在,这是非常糟糕的做法。异常处理的初衷是处理「异常情况」,不是「正常流程」。文件是否存在应该用file.exists()来判断,而不是捕获FileNotFoundException。

4.4.4 最佳实践总结

原则 说明
不要吞异常 catch块里别只写e.printStackTrace(),至少记录日志
抛异常要具体 别总抛Exception,要抛具体的异常类型
资源要关闭 用try-with-resources或者finally块关闭资源
异常不要用于流程控制 性能差,代码可读性也差
日志要记录完整 记录异常信息的同时,记录上下文数据

好了,这一章的内容就到这里。IO流、多线程、网络编程、异常处理,这四个主题每一个都值得深入钻研。但作为零基础转型,你先把这些基础用法掌握住,能写能跑,就算过关了。下一章我们开始接触大数据生态的核心组件,那才是真正精彩的部分。