JavaEE初阶

c++和java区别

java的方法不能写public class外面

计算机是如何工作的

api多了叫sdk

x86架构,arm架构,不同架构支持指令集不同

cpu是多少位和内存寻址相关

64位可以兼容32位的

cpu执行的指令在内存,要想执行就要先取指令再执行指令,取指令就是内存取指令到cpu寄存器,cpu解析指令需要指令表,不同架构指令表不同

cpu的主频就是每秒能够执行指令的个数

寄存器空间有限,是为了保存中间结果,比内存快3-4数量级

记得遇到危险扣6,也就是110,从右往左

  • 核心设定:冯・诺依曼体系结构的精髓,是将计算机的 "执行" 与 "存储" 功能分离。
  • 设计初衷:这么做的主要目的是 "解耦合",以此降低硬件设计的成本,这在当年是非常必要的选择。
  • 时代背景:在该体系提出的年代,CPU 执行指令的速度和从存储器读取指令的速度相近,二者配合得比较顺畅。

然后设计了缓存来缓解问题,就是离舞台更近,但是缓存不大。(还引入了流水线)

新的不是冯诺依曼结构在研究,但是推进很慢

向上转型

java的特点

  • 左边(编译时类型): 它是你给这个对象贴的"标签"。它决定了你对这个对象做什么(你可以调用哪些方法)。

  • 右边(运行时类型): 它是内存中真正的"实物"。它决定了当你执行某个动作时,对象具体是怎么表现的。

就是我们运行的时候只能调用左边有的同名方法,右边特有的用不了

如果是成员变量的话就都是看左边

如果要用右边特有的方法,可以在创建对象后强转类型

操作系统

是一个软件,代码构成的

管理硬件设备,为软件提供稳定的运行环境(抽象,封装)

操作系统给软件提供统一的api

jvm又对系统抽象封装,所以java只需要使用jvm的api就可以完成编程,java可以使用jvm所以不需要linux

操作系统的进程管理,用类或者结构体(pcb 进程控制块)把实体属性列出来,再用数据结构把这些对象串在一起

PCB这个结构体,非常庞大,有上百个属性,要知道关键的属性,最基本就是pid

PCB属性内存指针描述进程能使用的内存有哪些

操作系统不管你是 什么软盘硬盘,都是把你当文件处理

进程操作文件,要先打开文件,就是分配一个表项,构造一个结构体,表示文件的相关信息

调用cpu的话要分时复用,卡顿就是太多进程了,进程捞不到时间去cpu上运行了,所以多核cpu会处理能力更强。多核就可以并行执行,前面的并发也存在。并行并发一起执行。

任务处理器cpu的软件占用百分数就是消耗cpu时间的百分比

pcb需要提供一些属性来支持系统对进程调度

PCB重要属性

1.状态

描述某个进程是否可以去cpu上执行,有就绪状态,准备好去cpu执行。阻塞状态,当前不方便在cpu执行,比如在等待io(输入输出)

2.优先级

进程之间的先后关系不是那么平均的

3.上下文

是PCB的数据结构,相当于在内存上

相当于存档和读档,每个进程在运行过程中,就会有很多的中间结果,在 CPU 的寄存器中。

操作系统调度进程,过程可以认为是 "随机" 的。任何一个进程,代码执行到任何一条指令的时候都可能被调度出 cpu。在进程下次调度回 cpu 的时候,继续之前的进度来执行

所谓的 "保存上下文" 就是把 CPU 的关键寄存器中的数据,保存到内存中(PCB 的上下文属性中)所谓的 "恢复上下文" 就是把内存中的关键寄存器中的数据,加载到 CPU 的对应寄存器中。

4.记账信息

针对每个进程占据多少cpu时间统计,进行统计,然后调整调度的策略,确保每个进程不会完全捞不到cpu

进程和多线程(线程很重要)

构造方式

1. Thread()

这是最基础的无参构造方法。通常配合继承 Thread使用。

Java

复制代码
// 自定义一个线程类,继承 Thread
class MyThread extends Thread {
    @Override
    public void run() {
        System.out.println("方法1:我是继承 Thread 的线程,我在运行!");
    }
}

public class Test {
    public static void main(String[] args) {
        // 使用无参构造器创建对象
        Thread t1 = new MyThread(); 
        t1.start(); 
    }
}

2. Thread(Runnable target)

这是最常用的方式,配合实现 Runnable 接口使用。解耦了任务和线程。

Java

复制代码
// 定义一个任务(Runnable)
Runnable myTask = new Runnable() {
    @Override
    public void run() {
        System.out.println("方法2:我是 Runnable 任务,被线程执行了!");
    }
};

public class Test {
    public static void main(String[] args) {
        // 传入 Runnable 对象创建线程
        Thread t2 = new Thread(myTask);
        t2.start();
    }
}

3. Thread(String name)

创建线程时给它起个名字。通常也配合继承 Thread 类使用。

Java

复制代码
class NamedThread extends Thread {
    // 构造器,接收名字并传给父类 Thread
    public NamedThread(String name) {
        super(name); 
    }

    @Override
    public void run() {
        // 打印当前线程的名字
        System.out.println("方法3:我的名字是 " + Thread.currentThread().getName());
    }
}

public class Test {
    public static void main(String[] args) {
        // 创建时指定名字 "下载线程"
        Thread t3 = new NamedThread("下载线程");
        t3.start();
    }
}

4. Thread(Runnable target, String name)最推荐

既传入任务,又指定名字。这是实际开发中排查问题最方便的写法。

Java

复制代码
public class Test {
    public static void main(String[] args) {
        Runnable task = () -> {
            System.out.println("方法4:我是 " + Thread.currentThread().getName() + ",正在处理支付...");
        };

        // 传入任务 + 指定名字 "支付线程"
        Thread t4 = new Thread(task, "支付线程");
        t4.start();
    }
}

(注重理解)

进程是系统分配资源(内存空间和文件描述符表)的基本单位

进程之间彼此独立,不会相互影响。进程可以看作包含线程

多个线程的pcb的内存指针指向同一个内存空间,只有第一个线程要从系统分配资源,后面的不用,之间共用就好

线程保持独立调度执行,省去重复分配资源和释放资源

进程间通信,和独立性并不冲突,系统会提供一些公共空间交互进程之间的数据

java主要使用的进程间通信是基于文件和网络


同一个进程里的线程可能会相互影响,因为使用的是同一个进程里的资源

一个线程异常没有处理好,可能导致整个进程被终止

java的多线程 效率不错。因为多进程 的话,每个进程都需要启动java虚拟机,这个开销更大

main方法是主线程的入口方法

进程在进行频繁创建和销毁的时候,开销比较大。(开销主要体现在 资源的申请 和 释放上),一个进程刚刚启动首先分配内存,进程把依赖的代码和数据从磁盘加载到内存,没在运行的进程所消耗的内存可以暂时放在硬盘的特定区域,等真正执行再把这些内存数据装载进去,保证正在运行的进程内存充裕

一个进程至少有一个线程,第一个线程就是主线程,main就是主线程的入口方法

使用start启动线程,在新线程就会自动调用run方法

每个线程都是独立的执行流,独立在cpu上执行,对比单线程就会有可能两个死循环一起执行,这是单线程不会发生的并发执行(但是这种抢占式执行会有安全问题)

现在设备都是多进程系统,所以线程就很重要了

start才是创建新的线程,run还在在主线程里

lambda表达式只能捕获外面定义的局部变量(就是main里面的),但是不能修改,因为lambda其实就是函数式接口,也是匿名内部类

多个线程的执行顺序是不一定的,随机调度,抢占执行,但是有一些api可以影响线程效率,例如join(一般建议写有时间参数的,避免死等),让主线程等

多线程运算一般是比单线程快

单线程最多跑一个cpu核心,就浪费了

线程状态

只有new状态才能start

线程安全

多线程锁的思考

  • Q1(有共享数据吗): 有!总票数 ticket = 100

  • Q2(有修改操作吗): 有!抢一张少一张 ticket--。(结论:存在安全隐患,必须加锁)

  • Q3(临界区在哪): 临界区是:判断还有没有票 if(ticket>0) -> 卖票 ticket-- -> 打印剩余票数。这三步必须一气呵成。

  • Q4(选什么锁): 因为不仅仅是 ticket--,还要先判断 >0,这是一个复合动作。所以不能简单用 Atomic,我们选 ReentrantLock 包住这三步。

  • Q5(会死锁/太慢吗): 只有一把锁,不会死锁。里面只有基本运算,没有网络请求,速度极快。完美!

根本原因是线程的随机调度和抢占式执行

我们需要保证单线程和多线程都不会产生bug,执行结果都是正确的

两个线程同时对一个数自增的时候,就会不符合预期(所以可以使用互斥锁),这个原因其实就是多个线程修改同一个变量导致的出错

还有内存可见性问题(volatile可以让循环每次读内存,把优化强制关闭)

java的加锁解锁是一个代码块,和其他语言不一样,避免忘记unlock(或者unlock前抛出异常了,就很难受)

对于可重入锁,内部有两个信息,一个是当前锁被哪个线程持有,另一个是加锁次数的计数器(如果不是同一个线程就阻塞,是同一个线程计数器就+1)

java 复制代码
public class SyncDemo {
    // 1. 锁的是实例对象 (this)
    public synchronized void methodA() { ... }

    // 2. 锁的是类对象 (SyncDemo.class) ------ 全局锁
    public static synchronized void methodB() { ... }

    // 3. 锁的是指定对象 (最灵活)
    public void methodC() {
        Object lock = new Object();
        synchronized(lock) {
            // 只锁这几行,提高并发度
        }
    }
}

wait和notify

只要是对象都能进行wait(没有被加锁就wait会报错)

java 复制代码
public class WaitNotifyDemo {
    // 这是那个"唯一的盘子"(锁对象)
    private static final Object lock = new Object();
    // 盘子的状态:false=空,true=有饭
    private static boolean hasFood = false;

    public static void main(String[] args) {
        
        // 1. 食客线程(消费者)
        new Thread(() -> {
            synchronized (lock) { // 必须先拿到锁
                while (!hasFood) { // 如果没饭(用 while 不用 if)
                    try {
                        System.out.println("食客:没饭吃,我先睡会儿(wait)...");
                        lock.wait(); // 关键点:释放锁,进入等待池
                    } catch (InterruptedException e) { e.printStackTrace(); }
                }
                // 醒来后执行这里
                System.out.println("食客:终于有饭了,开吃!");
                hasFood = false; // 吃完了
                lock.notify();   // 叫醒厨师:盘子空了,快做饭
            }
        }).start();

        // 2. 厨师线程(生产者)
        new Thread(() -> {
            synchronized (lock) { // 拿到同一个锁
                try {
                    System.out.println("厨师:正在做饭...");
                    Thread.sleep(2000); // 模拟做饭耗时
                    hasFood = true;     // 做好饭了
                    System.out.println("厨师:饭做好了,叫醒食客(notify)!");
                    lock.notify();      // 唤醒在 lock 上睡觉的线程
                } catch (InterruptedException e) { e.printStackTrace(); }
            }
        }).start();
    }
}

死锁

1.一个线程一把锁,锁是不可重入锁,并且一个线程对这把锁加锁两次

2.两个线程两把锁,线程1拿锁a,线程2拿锁b,然后1尝试获取b,2开始尝试获取a

原因
  1. 互斥条件
  • 解释: 某种资源一次只能被一个线程占用。如果此时其他线程请求该资源,则只能等待,直到占有该资源的线程释放。

  • 比喻: 筷子是"互斥"的。一支筷子同一时间只能被一只手拿住,不能两个人同时拿同一支筷子。

  • 代码体现: synchronizedLock,锁住了就是我的。

  1. 请求与保持条件
  • 解释: 一个线程已经保持(持有)了至少一个资源,但又提出了新的资源请求,而新的资源被其他线程占用,此时请求被阻塞,但对自己已获得的资源保持不放。

  • 比喻:

    • 甲 手里拿着 筷子A(保持),但他还想要 筷子B(请求)。

    • 在等 筷子B 的时候,甲死都不肯放下手里的 筷子A。

  • 关键点: 占有且不放手。

  1. 不可剥夺条件
  • 解释: 线程已获得的资源,在未使用完之前,不能被其他线程强行剥夺,只能由该线程在使用完后自动释放。

  • 比喻: 甲手里拿着筷子A,乙不能直接冲过去把筷子A从甲手里抢过来。必须等甲自己吃完了放下来,乙才能拿。

  • 代码体现: 大多数锁(如 synchronized)一旦获取,除非代码执行完或抛出异常,否则外界无法强制释放。

  1. 循环等待条件
  • 解释: 若干线程之间形成一种头尾相接的循环等待资源关系。

  • 比喻:

    • 甲 拿了 筷子A,在等 筷子B。

    • 乙 拿了 筷子B,在等 筷子A。

    • 甲等乙,乙等甲,谁也不让谁,形成闭环。


如何解决死锁?

通常我们只需要破坏第4个条件(循环等待) 就足够了。(其他的都不好解决)

  • 破坏循环等待(最常用): 规定所有线程必须按照相同的顺序获取资源。

    • 比如: 规定所有人必须先拿筷子A,再拿筷子B。这样甲拿了A,乙想拿A拿不到(被阻塞),乙就不可能拿到B,甲就能顺利拿到B吃完面。

银行家算法太复杂一般不用

thread属性

Java 中的 Thread 类有一组非常重要的属性,用来描述线程的"身份"、"状态"和"特权"。

理解这些属性对于多线程调试和面试都非常关键。

1. ID

  • 含义: 线程的唯一标识符。

  • 特点:

    • 由系统自动生成,无法手动修改。

    • 主线程(Main Thread)的 ID 通常不为 0。

    • 保证唯一性,即使线程结束了,ID 也不会立即被重复利用。

2. Name

  • 含义: 线程的名称。

  • 特点:

    • 可修改:可以通过构造函数或 setName() 方法设置。

    • 默认值:如果不设置,Java 会自动命名为 Thread-0, Thread-1...

    • 作用:**极其重要!**在分析日志或 Dump 文件时,清晰的名字能帮你瞬间定位是哪个业务模块出了问题(例如 "Payment-Thread" vs "Log-Upload-Thread")。

3. Priority

  • 含义: 线程的优先级。

  • 范围: 1 (最小) ~ 10 (最大)。

  • 常量:

    • Thread.MIN_PRIORITY = 1

    • Thread.NORM_PRIORITY = 5 (默认值)

    • Thread.MAX_PRIORITY = 10

  • 注意: 优先级只是给操作系统的一个"建议"。优先级高的线程更有可能抢到 CPU,但并不保证一定先执行。不要依赖优先级来控制业务逻辑!

4. Daemon

  • 含义: 是否为守护线程(后台线程)。

  • 分类:

    • 用户线程 (User Thread): 默认创建的都是用户线程(如主线程)。只要有一个用户线程还在运行,JVM 就不会退出。

    • 守护线程 (Daemon Thread): 为用户线程服务的(如垃圾回收线程 GC)。当所有的用户线程都结束时,守护线程会自动死亡,JVM 会退出。

  • 用法: 必须在 start() 之前调用 setDaemon(true)

5. State

  • 含义: 线程当前的生命周期状态。

  • 6 种状态:

    1. NEW (新建):创建了但还没 start()

    2. RUNNABLE (运行中):正在跑,或者在排队等 CPU。

    3. BLOCKED (阻塞):比如在等锁(synchronized)。

    4. WAITING (等待):死等,直到被唤醒(wait(), join())。

    5. TIMED_WAITING (超时等待):等一段时间(sleep(), wait(time))。

    6. TERMINATED (终止):任务跑完了。

6. Alive (boolean isAlive)

  • 含义: 线程是否"活着"。

  • 判断标准: 只要线程启动了 (start 后) 且还没结束 (TERMINATED 前),就是 true

用户和内核

不要用程序,说进程或者可执行文件

注解方便让编译器对代码自动检查

防止应用程序把硬件或者软件搞坏,所以只能使用系统封装的api,为了运行能更加稳定

操作系统 = 内核 + 配套的应用程序

多个线程执行的先后顺序是不确定的,是抢占式执行

jconsole可以列出java上的进程,可以看到java进程里面的线程wawa

1.单例模式

写法一:饿汉式 (Hungry) ------ 最简单,最安全

java 复制代码
//饿汉式

public class SingletonHungry {
    // 1. 类加载时直接创建(静态变量),天然线程安全
    private static final SingletonHungry INSTANCE = new SingletonHungry();

    // 2. 封死构造器
    private SingletonHungry() {}

    // 3. 提供公有方法
    public static SingletonHungry getInstance() {
        return INSTANCE;
    }
}

利用 JVM 类加载机制(static) 抢先占座,再利用 权限控制(private) 锁死大门。

写法二:懒汉式 (Lazy) + 双重检查锁 (DCL)

java 复制代码
//懒汉式

public class SingletonDCL {
    // 必须加 volatile!防止指令重排导致空指针异常
    private static volatile SingletonDCL instance;

    private SingletonDCL() {}

    public static SingletonDCL getInstance() {
        // 第一次检查:如果已经有对象了,就不用排队抢锁了,提高性能
        if (instance == null) {
            synchronized (SingletonDCL.class) {
                // 第二次检查:防止两个线程同时冲过了第一次检查
                if (instance == null) {
                    instance = new SingletonDCL();
                }
            }
        }
        return instance;
    }
}

指令重排 new Singleton() 在 CPU 看来是 3 步:(可能2和3顺序会被优化反)

  1. 分配内存

  2. 初始化对象

  3. 引用指向内存

削峰填谷

阻塞队列实现

java 复制代码
package java1;

// 类名建议首字母大写
class BlockQueue {
    private String[] elems = null;
    private int head = 0;
    private int tail = 0;
    private int size = 0;

    public BlockQueue(int capacity) {
        elems = new String[capacity];
    }

    // 1. 加上 synchronized 保证线程安全
    // 2. 加上 InterruptedException 因为 wait() 会抛出中断异常
    public synchronized void put(String elem) throws InterruptedException {
        // 核心逻辑:如果满了,必须等待 (wait)
        // 使用 while 而不是 if,防止"虚假唤醒"
        while (size >= elems.length) {
            System.out.println("队列满了,生产者等待中...");
            this.wait(); // 释放锁,当前线程进入等待状态
        }

        elems[tail] = elem;
        tail++;
        if (tail >= elems.length) {
            tail = 0;
        }
        size++;

        // 入队成功后,唤醒可能正在等待取元素的消费者
        this.notifyAll();
    }

    public synchronized String take() throws InterruptedException {
        // 核心逻辑:如果空了,必须等待 (wait)
        while (size == 0) {
            System.out.println("队列空了,消费者等待中...");
            this.wait();
        }

        String elem = elems[head];
        head++;
        if (head >= elems.length) {
            head = 0;
        }
        size--;

        // 出队成功后,唤醒可能正在等待放元素的生产者
        this.notifyAll();

        // 【修正】原代码这里 return null 是错误的,必须返回拿到的元素
        return elem;
    }
}

public class threaddemo {
    public static void main(String[] args) {
        // 创建一个容量很小的队列,方便观察阻塞效果
        BlockQueue queue = new BlockQueue(3);

        // --- 生产者线程 (负责放数据) ---
        Thread producer = new Thread(() -> {
            try {
                for (int i = 1; i <= 5; i++) {
                    String data = "数据-" + i;
                    System.out.println("生产者准备放入: " + data);
                    queue.put(data);
                    System.out.println("生产者成功放入: " + data);
                    Thread.sleep(100); // 模拟生产耗时
                }
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        });

        // --- 消费者线程 (负责取数据) ---
        Thread consumer = new Thread(() -> {
            try {
                while (true) {
                    Thread.sleep(1000); // 模拟消费很慢,强制让队列填满
                    String data = queue.take();
                    System.out.println(">>> 消费者取出了: " + data);
                }
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        });

        producer.start();
        consumer.start();
    }
}

定时器手写实现

定义timer,往里面添加多个任务

先执行时间小的,再执行时间大的,底层是优先级队列

java 复制代码
第一部分:任务描述类 (MyTimerTask)
这个类的作用是把"要做的事"(Runnable)和"什么时候做"(time)捆绑在一起,并且为了能排队,它必须支持比较大小。
// 1. 导入优先级队列,后面存放任务要用
import java.util.PriorityQueue;

// 定义 MyTimerTask 类。
// 关键点:implements Comparable<MyTimerTask>
// 只有实现了这个接口,PriorityQueue 才知道怎么给这些任务排序(谁先谁后)。
class MyTimerTask implements Comparable<MyTimerTask> {
    
    // 存放具体的任务逻辑(比如打印一句话)
    private Runnable runnable;
    
    // 存放任务执行的"绝对时间戳"(毫秒)。
    // 比如:要在 14:00 执行,这里存的就是 14:00 对应的毫秒数。
    private long time; 

    // 构造方法:接收任务内容和延迟时间(比如 1000ms)
    public MyTimerTask(Runnable runnable, long delay) {
        this.runnable = runnable;
        // 核心计算:当前系统时间 + 延迟时长 = 真正执行的时刻
        // 例如:现在是 10:00:00,delay 是 5秒,那么 time 就是 10:00:05
        this.time = System.currentTimeMillis() + delay;
    }

    // 执行任务的方法,直接调用内部 Runnable 的 run
    public void run() {
        runnable.run();
    }

    // 获取执行时间的方法,给队列判定用的
    public long getTime() {
        return time;
    }

    // 核心排序逻辑!
    // PriorityQueue 会自动调用这个方法来决定谁排在前面。
    // 返回值 < 0:我比它小(我先执行);返回值 > 0:我比它大。
    @Override
    public int compareTo(MyTimerTask o) {
        // this.time - o.time 表示:时间越早(数值越小),越排在前面
        return (int) (this.time - o.time);
    }
}
第二部分:定时器核心类 (MyTimer)
这是整个程序的"大脑",包含一个用来存任务的队列,和一个一直在后台扫描任务的线程。

class MyTimer {
    // 1. 准备一个优先级队列。
    // 特点:不管你插入顺序如何,queue.peek() 永远拿到的是时间最早(最小)的那个任务。
    private PriorityQueue<MyTimerTask> queue = new PriorityQueue<>();

    // 2. 准备一个锁对象。
    // 作用:因为"主线程"(负责塞任务)和"扫描线程"(负责取任务)会同时操作队列,
    // 必须加锁互斥,不然会线程不安全(比如一边塞一边取,队列就乱了)。
    private Object locker = new Object();

    // 构造方法
    public MyTimer() {
        // 创建一个后台扫描线程,它一旦启动就会一直运行
        Thread t = new Thread(() -> {
            try {
                // 死循环:让这个线程一直活着,时刻盯着队列
                while (true) {
                    // 加锁:我要操作队列了,其他人别动
                    synchronized (locker) {
                        
                        // 情况 A:队列是空的
                        // 如果没有任务,扫描线程就没事做,必须停下来等待(wait),释放CPU资源。
                        // 直到有人调用 schedule 添加任务并 notify 唤醒它。
                        while (queue.isEmpty()) {
                            locker.wait();
                        }

                        // 情况 B:队列里有任务
                        // queue.peek():只看不拿。取出队头那个最早要执行的任务,看看时间。
                        MyTimerTask task = queue.peek();
                        long curTime = System.currentTimeMillis();

                        // 判断:时间到了吗?
                        if (curTime >= task.getTime()) {
                            // C. 时间到了(或者已经过了)!
                            queue.poll(); // 真正的把任务从队列里拿出来(移除)
                            task.run();   // 执行任务
                        } else {
                            // D. 时间还没到
                            // 比如:任务要在 14:00 执行,现在是 13:50。
                            // 那么线程应该睡 10分钟(task.getTime() - curTime)。
                            
                            // 为什么用 wait 而不是 sleep?
                            // 重点!因为在睡的这10分钟里,主线程可能会插入一个 13:55 执行的新任务。
                            // 如果是 sleep,线程就睡死过去了,没法处理新任务。
                            // 如果是 wait,schedule 方法里的 notify 可以把它提前叫醒,重新检查队头。
                            locker.wait(task.getTime() - curTime);
                        }
                    }
                }
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        });
        // 启动线程!
        t.start();
    }

    // 提供给用户的接口:添加任务
    public void schedule(Runnable runnable, long delay) {
        // 加锁:保证和扫描线程互斥
        synchronized (locker) {
            // 封装成 MyTimerTask 对象
            MyTimerTask task = new MyTimerTask(runnable, delay);
            // 放入队列
            queue.offer(task);
            
            // E. 关键点!唤醒扫描线程。
            // 每次来新任务,都必须喊一声:"嘿,来活了,或者看看新任务是不是更急?"
            // 如果扫描线程正在 wait(空队列) 或者 wait(时间差),都会被唤醒起来重新检查。
            locker.notify();
        }
    }
}
第三部分:测试类 (ThreadDemo31)
用来验证定时器是否工作正常。

public class ThreadDemo31 {
    public static void main(String[] args) {
        // 1. 创建定时器实例
        // 这行代码一执行,构造方法里的那个后台线程 t 就启动了,开始在后台 while(true) 轮询。
        MyTimer timer = new MyTimer();
        
        System.out.println("程序启动: " + System.currentTimeMillis());

        // 2. 添加第一个任务:延迟 3000ms (3秒)
        timer.schedule(new Runnable() {
            @Override
            public void run() {
                System.out.println("任务1执行了(3秒后): " + System.currentTimeMillis());
            }
        }, 3000);

        // 3. 添加第二个任务:延迟 1000ms (1秒)
        // 注意:虽然代码写在后面,但它的延迟时间短。
        // 因为 MyTimer 内部用了优先级队列,这个任务会自动排到队头。
        timer.schedule(new Runnable() {
            @Override
            public void run() {
                System.out.println("任务2执行了(1秒后): " + System.currentTimeMillis());
            }
        }, 1000);

        // 预期执行流程:
        // 1. 程序启动。
        // 2. schedule 任务1 -> 队列里有任务1 -> 扫描线程发现还要等3秒 -> wait(3000)。
        // 3. schedule 任务2 -> 插入队列 -> notify() 唤醒扫描线程。
        // 4. 扫描线程醒来 -> 发现队头变成了任务2(只要等1秒) -> wait(1000)。
        // 5. 1秒后,任务2执行。
        // 6. 3秒后,任务1执行。
    }
}

线程池

提前把要用的对象准备好"

用完的对象也不要立即释放,先留着以备下次使用

优化线程的创建销毁效率

线程池的拒绝策略

  1. AbortPolicy (默认策略) ------ "直接报警"
  • 行为: 直接抛出 RejectedExecutionException 异常,阻止系统正常运行。

  • 场景类比: 银行大厅满了,排队区也满了。你一来,保安直接把你轰出去,还大喊:"这里不办了,快走!"(抛异常)。

  • 适用场景: 比较重要的业务,必须让调用者知道任务失败了,以便进行后续处理(比如记录日志、报警)。

  1. CallerRunsPolicy ------ "谁喊的谁去干"
  • 行为: 不抛弃任务,也不抛异常,而是让提交任务的那个线程(比如主线程)自己去执行这个任务。

  • 场景类比: 银行满了。经理对你说:"柜员都忙死了,你自己拿个计算器在旁边算吧。"

  • 优势: 这是一种负反馈机制。因为主线程自己去干活了,就没空提交新任务了,从而降低了提交速度,给线程池喘息的时间。

  • 适用场景: 不允许丢弃任务,且对性能要求不那么严苛的场景(比如数据同步)。

  1. DiscardPolicy ------ "装聋作哑"
  • 行为: 直接丢弃任务,不做任何处理,也不抛异常。

  • 场景类比: 银行满了。你把填好的单子递过去,柜员看都不看直接扔进垃圾桶,也不告诉你,你就当没来过。

  • 适用场景: 无关紧要的任务,丢了也就丢了(比如某些非核心的日志记录、心跳检测)。

  1. DiscardOldestPolicy ------ "喜新厌旧"
  • 行为: 丢弃队列中等待最久的一个任务(也就是队头的任务),然后把当前这个新任务加进去尝试执行。

  • 场景类比: 银行满了。你一来,经理直接把排队排在第一个的人踢出去,让你插队排在最后。

  • 适用场景: 想要处理最新数据的场景(比如采集股票价格、传感器数据,旧数据没意义了,只要最新的)。

代码

复制代码
package java1;

import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;

// 自定义简易线程池
class MyThreadPool {
    // 1. 任务队列(传送带):用于存放来不及处理的 Runnable 任务
    // 这里使用 LinkedBlockingQueue,最大容量设为 1000
    private BlockingQueue<Runnable> queue = new LinkedBlockingQueue<>(1000);

    // 2. 线程列表(员工花名册):用来记录当前有哪些工作线程
    private List<Thread> threadList = new ArrayList<>();

    // 3. 构造方法:在创建线程池时,直接"招聘"指定数量的员工(线程)
    public MyThreadPool(int corePoolSize) {
        for (int i = 0; i < corePoolSize; i++) {
            // 创建一个工作线程(招聘一个员工)
            Thread t = new Thread(() -> {
                try {
                    // 让员工进入"死循环"工作模式
                    while (true) {
                        // 【核心代码,对应你的最后一张截图】
                        // 此处的 take 带有阻塞功能。
                        // 如果队列为空,这里的 take 就会阻塞(员工没活干就歇着等待)。
                        Runnable runnable = queue.take();
                        
                        // 取出一个任务就执行一个任务。注意:这里是直接调用 run(),而不是 start()!
                        runnable.run();
                    }
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            });

            // 给员工编号,方便打印看效果
            t.setName("工作线程-" + i);
            
            // 启动线程!员工开始干活(进入上面的 while 循环去盯着队列)
            t.start();
            
            // 把员工记到花名册里管理
            threadList.add(t);
        }
    }

    // 4. 提供给用户的方法:提交任务(把订单放上传送带)
    public void submit(Runnable runnable) throws InterruptedException {
        // 将任务放入阻塞队列。如果队列满了(超过1000),主线程会在这里阻塞等待
        queue.put(runnable);
    }
}


// 测试类
public class ThreadPoolDemo {
    public static void main(String[] args) throws InterruptedException {
        // 1. 创建一个线程池,里面固定有 4 个工作线程(4个正式员工)
        MyThreadPool pool = new MyThreadPool(4);

        // 2. 疯狂提交 1000 个任务
        // 【对应你倒数第二张截图的 for 循环和报错】
        for (int i = 0; i < 1000; i++) {
            
            // 解决报错的关键:用一个临时变量接住不断变化的 i
            int id = i; 

            // 提交任务到线程池
            pool.submit(new Runnable() {
                @Override
                public void run() {
                    // 打印正在执行的任务和当前执行它的线程名字
                    System.out.println("执行任务 " + id + 
                            " ,当前线程为:" + Thread.currentThread().getName());
                }
            });
        }
    }
}

多线程进阶(案例)

避免报错

  1. 平时不报错(开发环境跑得好好的)。

  2. 一上线就崩(高并发下才会现原形)。

  3. 很难复现(想修的时候它又不报错了)。

以下是 4 种避免策略:


  1. 策略一:能不共享,就不共享 (Thread Confinement)

这是最彻底的解决办法。如果资源不共享,就根本不存在竞争。

  • 方法 A:栈封闭(局部变量)

    • 把变量写在方法里面,而不是类成员变量。

    • 原理: 每个线程都有自己的栈,方法里的变量是私有的,别的线程根本看不见。

    • 口诀: "变量尽量定义在方法里,别定义在类里。"

  • 方法 B:ThreadLocal (线程本地变量)

    • 原理: 给每个线程发一个"私有复印件"。大家各改各的,互不影响。

    • 场景: 比如连接数据库的 Connection,每个线程自己拿自己的。


  1. 策略二:使用"不可变"对象 (Immutability)

如果你要把一个东西拿出来给大家看,那就把它做成只读的。

  • 原理: 如果一个对象创建之后就不能修改(比如 Java 里的 String,或者加了 final 的变量),那多线程随便怎么读都是安全的。

  • 做法:

    • 多用 final 关键字。

    • 多用 StringInteger 这种不可变类。


  1. 策略三:使用"原子类" (Atomic Classes)

这是专门给计数器之类的场景准备的。

  • 场景: 你想做 count++(计数)。

  • 坑: count++ 其实分三步(读、改、写),多线程由于 CPU 切换,会把数据算错。

  • 解法: 使用 java.util.concurrent.atomic 包下的类。

    • int 换成 AtomicInteger

    • long 换成 AtomicLong

  • 原理: 它们底层用了 CAS(Compare And Swap)硬件指令,保证"加1"这个动作是原子性的(要么不做,要做就一口气做完,谁也插不进来)。

乐观锁vs悲观锁

读写锁

复制代码
ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock();
Lock readLock = rwLock.readLock();   // 读锁
Lock writeLock = rwLock.writeLock(); // 写锁

// 读任务(大家都能进)
public void read() {
    readLock.lock();
    try {
        System.out.println("正在读取...");
    } finally {
        readLock.unlock();
    }
}

// 写任务(只有一个人能进,且没人读时才能进)
public void write() {
    writeLock.lock();
    try {
        System.out.println("正在修改...");
    } finally {
        writeLock.unlock();
    }
}

公平锁和非公平锁

可重入锁和不可重入锁

偏向锁

总结synchronized锁优势

能屈能伸,没人抢轻如鸿毛,有人抢重如泰山

暂时不会从更重的锁退化成轻量锁,只有轻量锁进化成重量锁

锁消除

如果编译器发现你加的锁根本没必要(完全不涉及多线程共享数据),它就会自作主张,直接把你写的锁"删掉",当做没加锁一样跑。

因为加锁是有开销的(CAS、进出内核态等)。 如果你在一个单线程的局部方法里用了线程安全的类(比如 StringBufferVector),虽然你没想加锁,但这些类内部自带锁。编译器觉得这纯属浪费资源,就帮你优化了。

锁粗化

A. 优化前的代码(你写的)

你在一个循环里不停地加锁、解锁。

复制代码
public void doSomething() {
    Object lock = new Object();
    
    // 循环 100 次
    for (int i = 0; i < 100; i++) {
        // 每次循环都要:加锁 -> 干活 -> 解锁
        synchronized (lock) {
            System.out.println("干活ing...");
        }
    }
}
  • 问题: 这里发生了 100 次"加锁-解锁"的操作。虽然锁的范围很小,但频繁的上下文切换和 monitor 操作非常消耗性能。

B. 优化后的代码(JIT 帮你改的)

JIT 编译器发现这是同一个锁对象,而且操作太频繁,就会自动把锁**扩容(粗化)**到循环外面。

复制代码
public void doSomething() {
    Object lock = new Object();
    
    // 【锁粗化】:只加一次锁,覆盖整个循环
    synchronized (lock) {
        for (int i = 0; i < 100; i++) {
            System.out.println("干活ing...");
        }
    }
}
  • 效果: 加锁 1 次,解锁 1 次。性能直接起飞。

锁粗化就是 JVM 为了防止你"为了减小锁范围而走火入魔",导致频繁加锁解锁反而降低了性能。它会在必要时帮你把很多个小锁合并成一个大锁。

Java小知识

CAS

它打破了"只有加锁才能安全"的思维定势,提供了一种用"CPU 空转(自旋)"换取"线程不阻塞"的高性能解决方案。

复制代码
import java.util.concurrent.atomic.AtomicInteger;

public class CASDemo {
    // 1. 【改变量类型】:不用 int,改用支持 CAS 的原子类
    private static AtomicInteger count = new AtomicInteger(0);

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                // 2. 【改操作方式】:不用 count++,改用 CAS 方法
                // getAndIncrement() 等价于 count++,但它是原子性的
                count.getAndIncrement(); 
            }
        });

        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                count.getAndIncrement();
            }
        });

        t1.start();
        t2.start();
        t1.join();
        t2.join();

        // 3. 【获取结果】:结果一定是 100000
        System.out.println("count = " + count.get());
    }
}

逻辑

伪代码逻辑(CAS 的内心戏):

  1. 我先读一眼: 现在的 count 是 100 吗?(假设读到了 100)

  2. 我想改: 我想把它改成 101。

  3. CAS 指令(关键时刻): 系统帮我看看,内存里的 count 现在还是 100 吗?

    • 如果是(成功): 说明没人插队,把内存改为 101。任务结束!

    • 如果不是(比如变成了 105): 说明刚才有 5 个线程插队改过了。那我这次修改失败,我不睡觉,我重新读最新的值(105),想把它改成 106,再试一次!

ABA问题

想象一下你有一杯 满着的茶 (状态 A)。

  1. 你 (线程 1) 看了一眼茶杯,发现是满的。你转头去拿点心(准备执行 CAS)。

  2. 在你转身的时候,小明 (线程 2) 过来把茶 喝光了 (状态 B)。

  3. 小明觉得不好意思,又倒了一杯 白开水 (状态 A) 放回原处。

  4. 你 (线程 1) 回过头来,发现杯子还是满的(检查 A == A,通过),于是你美滋滋地喝了一口。

  5. 后果: 你以为你喝的是茶,其实你喝的是白开水(甚至可能是毒药)。

这就是 ABA 问题: 值虽然改回去了,但它已经不是原来的它了。

技术层面的解释

在 CAS 操作中,我们只检查 "当前值" 是不是等于 "预期值"。

假设有一个变量 V = 100

  1. 线程 1 读到 100,想把它改成 200。它先挂起(睡觉)了。

  2. 线程 2 进来了,把 100 改成了 50

  3. 线程 2 又把 50 改回了 100

  4. 线程 1 醒了,执行 CAS:

    • 它检查:V100 吗?

    • 结果:是 100 (尽管中间变过)。

    • CAS 判定:成功! 执行更新。

解决方法:引入版本号(约定数据单向变化也行)

复制代码
// 初始引用是 "A",初始版本号是 1
AtomicStampedReference<String> ref = new AtomicStampedReference<>("A", 1);

// 线程 1 想改,必须同时满足两个条件:
// 1. 引用必须还是 "A"
// 2. 版本号必须还是 1
// 如果成功,把引用改成 "B",版本号改成 2
boolean success = ref.compareAndSet("A", "B", 1, 2);

锁选择

PV操作(老古董)

Semaphore

CountDownLatch

如果说 Semaphore 是 "停车场管理员"(控制流量),那 CountDownLatch 就是 "严格的导游"。

复制代码
import java.util.concurrent.CountDownLatch;

public class LatchDemo {
    public static void main(String[] args) throws InterruptedException {
        // 1. 定义一个"倒计时门闩",初始值为 3 (暗示有3个任务)
        // 注意:这个数字一旦定下来,就不能改了
        CountDownLatch latch = new CountDownLatch(3);

        System.out.println("【主线程】Boss: 任务发下去了,我等你们干完...");

        for (int i = 1; i <= 3; i++) {
            new Thread(() -> {
                try {
                    System.out.println("【子线程】员工正在干活...");
                    Thread.sleep(1000); // 模拟耗时
                    System.out.println("【子线程】员工干完了!");
                } catch (InterruptedException e) {
                    e.printStackTrace();
                } finally {
                    // 2. 【核心】:干完一个,扣掉一个
                    // 这里的代码最好放在 finally 里,保证一定会执行
                    latch.countDown(); 
                }
            }).start();
        }

        // 3. 【核心】:主线程在这里"死等"
        // 只要 count 不为 0,主线程就卡死在这行代码,不会往下走
        latch.await(); 

        System.out.println("【主线程】Boss: 好!所有人都干完了,项目上线!");
    }
}

它和 thread.join() 有什么区别?

你可能会问:"我让主线程调用 t1.join(), t2.join() 不也能等待吗?"

  • join 的局限性: 它必须等子线程 "死掉"(执行完毕退出)才能唤醒主线程。

  • CountDownLatch 的灵活性: 它不一定要等子线程死掉。

    • 子线程可以 "干到一半" 就调用 countDown()(比如只完成了核心初始化),然后继续跑它剩下的逻辑。

    • 主线程只要收到 countDown 信号就可以走了,不用傻等到子线程完全结束。

CopyOnWriteArrayList

  • 读的时候 (Reading):

    • 大家随便读,完全不加锁,也不需要复制。

    • 因为数据是不会变的(旧数据),所以多线程并发读绝对安全。

    • 图中的第一行数组 [1, 2, 3, 4] 就是给大家读的。

  • 写的时候 (Writing):

    • 一旦有线程要修改(比如把 4 改成 100),它不直接修改原来的数组。

    • 第一步(复制): 悄悄把原来的数组 [1, 2, 3, 4] 克隆 (Clone) 一份新的出来。

    • 第二步(修改): 在这个新数组上进行修改,变成 [1, 2, 3, 100]

    • 第三步(掉包/引用切换): 这一步最关键!用一个原子操作(引用赋值),把ArrayList内部的指针,瞬间从"旧数组"指向"新数组"。

缺点

  • 内存占用大 (Copying Cost):

    • 你哪怕只改 1 个字,也得把整个数组 copy 一遍。如果数组里有 1 亿条数据,这开销就炸了。
  • 写操作太慢:

    • 复制数组是非常耗时的。所以如果你的业务是"频繁写入",千万别用它! 系统会卡死在无限的数组复制和 GC(垃圾回收)上。
  • 数据一致性(最终一致性):

    • 因为是"替换"策略,在替换的一瞬间,可能有的线程读到的还是旧数据。它不能保证实时一致性,只能保证最终一致性。

ConcurrentHashMap (现代武器)

现在的版本

它直接抛弃了分段锁,采用了 CAS + synchronized (只锁单个坑位)。

  • 做法: 厕所没有大门,完全开放。每个坑位(数组的每个槽)门口设一个锁。

  • 场景一:坑位没人 (CAS 无锁)

    • A 冲进来,发现第 5 号坑位没人。

    • 它不需要领钥匙,直接用 CAS 指令(原子操作)占座:"这里现在归我了"。

    • 速度: 极快,连锁都不用加。

  • 场景二:坑位有人 (synchronized 节点锁)

    • B 冲进来,发现第 5 号坑位有人了(哈希冲突)。

    • B 只会在第 5 号坑位门口排队(加 synchronized 锁)。

    • 此时 C 想去第 6 号坑位?完全不受影响,直接进!

  • 场景三:红黑树优化 (数据结构升级)

    • 如果一个坑位排队的人(链表)太长了(超过 8 个),JDK 1.8 会自动把这个队列变成红黑树。

    • 查找速度从 O(N)变成 O(log N)。即便发生冲突,效率也极高。结果: 既然数据是完整的且是最新的,那直接读内存就好了,完全没必要去抢锁排队。这就是它高性能的秘密。

  • 只要赋值完成,volatile 保证其他线程立马看见。结果: 既然数据是完整的且是最新的,那直接读内存(get)就好了,完全没必要去抢锁排队。这就是它高性能的秘密。

文件IO

  • Input (输入/读): 数据从 外部(硬盘/网络)流向 内存。即:进来。

  • Output (输出/写): 数据从 内存 流向 外部(硬盘/网络)。即:出去。

文本文件二进制文件

怎么区分它们?

方法一:看后缀名(最简单但不可靠)

  • 文本文件: .txt, .java, .c, .xml, .html, .json

  • 二进制文件: .jpg, .mp3, .exe, .class, .dll,word

  • 注意: 文档提到,Windows 强依赖后缀名来决定用什么软件打开文件,但 Linux/Unix 并不太依赖后缀名 。

方法二:这也是程序员最常用的"土办法"

用记事本(Notepad)打开它。

  • 如果能看懂,且没有乱码 文本文件。

  • 如果是一堆乱码(方块、问号、奇怪符号) 二进制文件。

Java文件操作api

一、 查:身份核验与元数据 (Metadata)

这一类方法主要用于获取文件的"身份信息",属于只读操作,绝对不会修改硬盘上的实际内容。

1.存在性判断

  • 核心方法:exists()

  • 作用:判断指定的路径在硬盘上是否真实存在。

  • 场景应用:这是文件操作中最重要的一步。在进行任何读写操作前,必须通过它来确认目标存在,否则极易抛出 FileNotFoundException(文件未找到异常)。

  • 返回值:存在返回 true,不存在返回 false

  1. 类型判断
  • 核心方法:isFile()isDirectory()

  • isFile():判断是否为普通文件(如 .txt, .exe, .jpg 等)。

  • isDirectory():判断是否为文件夹(目录)。

  • 注意:如果文件本身通过 exists() 判断为不存在,那么这两个方法通常都会直接返回 false

  1. 路径与名称获取
  • getName():获取具体的文件名称(例如:hello.txt)。

  • getParent():获取该文件的父级目录路径(例如:D:/data)。

  • getAbsolutePath():获取从盘符开始的完整路径,确保路径的唯一性。


二、 增

这是初学者最容易混淆的区域。核心原则:创建文件和创建文件夹是两个完全独立、互不通用的方法体系。

  1. 创建"文件"
  • 核心方法:createNewFile()

  • 作用:创建一个空的普通文件。

  • 返回值:创建成功返回 true;如果文件已存在,则返回 false(不会覆盖)。

  • 严重坑点:该方法只管建文件,不管建目录。如果你试图在不存在的目录路径下创建文件(例如目录 D:/a 不存在,却想建 D:/a/b.txt),系统会直接抛出 IOException 异常,而不是返回 false。

  1. 创建"文件夹" (目录)
  • 初级方法 mkdir()

    • 只能创建最后一级目录。

    • 局限:如果父目录不存在(例如想建 a/ba 不在),创建会直接失败并返回 false

  • 高级方法 mkdirs() (强烈推荐):

    • 作用:创建多级目录。

    • 优势:如果路径中的中间目录不存在,它会"顺手"帮你全部建好(例如建 a/b/c/d,哪怕 a 都不在,它也能一口气全搞定)。

    • 建议:在任何需要建目录的场景下,永远优先使用 mkdirs(),它的健壮性最强。


三、 删

Java 提供了两种删除策略,分别对应"立即执行"和"程序结束时执行"。

  1. 立即删除
  • 核心方法:delete()

  • 作用:立即从硬盘上移除文件或目录。

  • 重要安全机制:删除目录时,该目录必须是空的!

    • 如果文件夹里还有任何文件或子文件夹,Java 为了防止误删数据,是不允许直接删除的。

    • 解决方案:必须编写逻辑先递归删除目录内的所有内容,最后才能删除这个空目录。

  1. 延迟删除
  • 核心方法:deleteOnExit()

  • 作用:调用时不执行删除,而是标记一下。等到 Java 虚拟机 (JVM) 程序完全运行结束退出时,自动执行删除。

  • 场景应用:专门用于处理临时文件 (Temp Files)。比如程序运行中生成的缓存文件,用完即弃,通过此方法保证程序关闭时不留垃圾。


四、 遍历:深度扫描与递归

这是面试中的高频考点,用于解决"查看文件夹里有什么"的问题。

  1. 工具选择
  • list():仅返回文件名字符串数组 (String[]),信息量少,不推荐。

  • listFiles() (推荐):返回功能强大的文件对象数组 (File[]),可以直接拿来做进一步的操作(如判断属性、再次遍历等)。

  1. 核心逻辑流程 (递归扫描)

要实现全盘扫描或多级目录遍历,通常遵循以下标准逻辑流:

  1. 获取列表:调用 listFiles() 拿到当前目录下的所有子对象。

  2. 防御性检查:判断结果是否为 null 或空数组(防止因无权限或空目录导致的空指针异常)。

  3. 循环遍历:对数组中的每一个对象进行轮询。

  4. 分流处理:

    • 如果是文件 执行业务逻辑(如打印路径、读取内容)。

    • 如果是目录 递归调用自身(将当前子目录作为参数再次传入方法内部),深入下一层级继续查找。

小知识(为什么everything那么快)

核心思路是空间换时间,内置小数据库,最初安装好就对你整个文件系统遍历,把信息拿到内置数据库,后续搜索就不是遍历二手直接去内置数据库查询

字节流 (Byte Stream)

------ 也就是 Stream 结尾的类

  • 流的是什么: "水"(原始的 0 和 1)。

  • 特点: 万能。图片、视频、音乐、exe 文件,什么都能流。

  • 核心类: FileInputStream, FileOutputStream

读写单位: 一个字节一个字节地流 。

字符流 (Character Stream)

------ 也就是 Reader/Writer 结尾的类

  • 流的是什么: "蜂蜜"(经过加工的文本字符)。

  • 特点: 专门处理文字。它会自动把 0/1 翻译成"汉字"或"英文",不会出现乱码。

  • 核心类: FileReader, FileWriter (或者文档里提到的 InputStreamReader, PrintWriter)。

  • 读写单位: 一个字符一个字符地流。

特点

万能文件复制机 (字节流 + Buffer)

场景: 你想把一张图片 girl.jpg 或者一个视频 video.mp4 复制一份备份。 这是最通用的操作,不管文件是什么格式,只要是字节流就能搬运!

Try-with-Resource

复制代码
// 【推荐写法】 自动关流
// 注意 try 后面加了个小括号 (...)
try (
    InputStream is = new FileInputStream("a.txt");
    OutputStream os = new FileOutputStream("b.txt") // 可以写多个,用分号隔开
) {
    
    // ...在这里尽情写你的业务逻辑...
    // ...根本不用操心 close 的事...

} catch (IOException e) {
    e.printStackTrace();
}
// 结束!try 括号里的资源会自动关闭,哪怕代码报错了也会关!

为什么它能自动关?

因为这些流类(FileInputStream 等)都实现了一个特殊的接口叫 AutoCloseable 。 只要把它放在 try (...) 的括号里,Java 编译器就会在编译的时候,自动帮你把 finally { xxx.close(); } 这一大坨代码补上去。

read

数据是先扔buffer里面(类似缓冲区)

复制代码
// 1. 准备一个水桶(缓冲区),通常是 1KB 或 8KB
byte[] buffer = new byte[1024];
int len; // 用来存"这次到底读到了多少个"

try (InputStream is = new FileInputStream("video.mp4")) {
    
    // 2. 核心循环:只要没读到 -1,就一直读
    while ((len = is.read(buffer)) != -1) {
        
        // 3. 【关键】处理数据时,只处理 0 到 len 这一段!
        // 如果是写文件:
        // os.write(buffer, 0, len); 
        
        // 如果是转字符串(注意乱码风险):
        // String chunk = new String(buffer, 0, len);
    }
}

总结

  • 读一个字节: read() 慢,别用。

  • 读一堆字节: read(buffer) 快,要用。

  • 关键点: 永远记住 返回值 len 代表了有效数据的长度,不要越界使用 buffer

升级成scanner

为什么要用 Scanner 读文件?

回忆一下 FileInputStream 原生读取方式:

  • 原生方式: 你得准备 byte[] 桶,得 read(buffer),得判断返回值 -1,还得自己 new String(...) 转码。稍不注意就乱码。

  • Scanner 方式(舒服):

    • 它就像在原生水管上装了一个智能过滤器。

    • 它帮你把底层的脏活累活(读字节、转码、拼字符串)全干了。

    • 你只需要调 next()(拿下一个单词)、nextInt()(拿下一个整数)、nextLine()(拿下一行)。

注意

什么时候用 Scanner 更好?

只有当你明确知道:

  1. 你在读的是纯文本文件(txt, java, html, json)。

  2. 你需要理解里面的内容(比如"把里面的数字读出来相加","把每一行读出来打印")。

一句话总结: 只要涉及"文件复制",闭着眼用 byte[];只有涉及"读取文本内容做计算"时,才用 Scanner

网络初识

网络编程,本质上就是"拉长了网线的"文件 I/O。

  • 局域网 (LAN):本地组建的私有网络(内网),可以通过网线直连、集线器、交换机或路由器组建。

  • 广域网 (WAN):通过路由器把无数个局域网连起来的超大网络 。我们每天冲浪的"互联网(公网)",其实就是广域网的一个子集 。

  • IP 地址(收件人地址): 一个 32 位的二进制数(通常写成 192.168.1.1 这种点分十进制),主要用于定位网络中的那台主机 。就像顺丰快递单上的"北京市XXX" 。

    +2

  • 端口号(收件人姓名): 一个 0~65535 范围的数字,用于定位主机中的具体进程 。就像快递单上的"收件人:张三" 。

面试必考点:五元组 在 TCP/IP 协议中,系统是用"五元组"来唯一标识一次网络通信的 : 源 IP + 源端口号 + 目的 IP + 目的端口号 + 协议号 。

协议分层

线里传输的只是一堆表示 0 和 1 的"光电信号" 。如果接收方不知道这些 0 和 1 是什么意思(是图片?是文本?),那毫无意义 。

所以我们需要协议 (Protocol):也就是通信双方共同遵从的一组约定和规则 。就像网友见面,约定"胸口插支玫瑰花"一样 。

但是网络太复杂了,如果把所有规则写在一个协议里,那这个协议会极其臃肿。所以大牛们采用了协议分层"的设计 。 分层的好处就像 Java 里的"面向接口编程:上层只需要调用下层提供的接口,根本不需要关心下层是怎么实现的(隐藏了实现细节),极大地提高了代码的扩展和维护性 。

  • OSI 七层模型: 理论上的完美框架,但太复杂,没能实际落地 。

  • TCP/IP 五层(或四层)模型: 实战霸主

应用层是应用程序,传输层和网络层是操作系统,数据链路层和物理层是设备驱动程序与网络接口

应用层: 负责应用程序间的沟通(如 HTTP 协议)。你的 Java 代码就写在这一层!

传输层: 负责两台主机间的数据传输(如 TCP、UDP 协议)。它保证数据"可靠"地送达 。

网络层: 负责地址管理和路由选择(IP 协议)。决定数据走哪条路线 。

数据链路层: 负责将底层的电信号组装成有意义的"数据帧",并在互连设备之间完成传送、识别、冲突检测与差错校验 。

物理层: 负责规定网线和接口规格,单纯利用光/电信号(0和1)的高低频闪,在底层硬件介质上进行无逻辑的数据传递 。

  • 对于一台主机,它的操作系统内核实现了从传输层到物理层的内容,也即是TCP/IP五层模型的下四层;

  • 对于一台路由器,它实现了从网络层到物理层,也即是TCP/IP五层模型的下三层;

  • 对于一台交换机,它实现了从数据链路层到物理层,也即是TCP/IP五层模型的下两层;

  • 对于集线器,它只实现了物理层;

封装与分用

数据包在不同的协议层,叫法是不一样的。

在传输层,它叫做段 (segment) 。

在网络层,它叫做数据报 (datagram) 。

在链路层,它叫做帧 (frame) 。

添加报头的过程,就叫作封装,其实就是字符串拼接,只不过报头具有一定结构,承载一些关键的用来转发数据的信息

发送和分用过程

传输层 UDP承载最重要的信息的源端口和目的端口,然后传给网络层网络层传输层 的UDP数据包当成一个整体,拼上IP协议的报头,里面最关键的是源IP和目的IP,构造成一个IP数据包。然后继续调用数据链路层数据链路层 的核心协议(以太网)。以太网数据帧以IP数据包为整体,加上帧头和桢尾。继续交给物理层 ,把上述的以太网数据帧,二进制结构转换成光信号/电信号/电磁波进行发送。经过一系列处理把数据包发送到B的网卡,B的处理过程就叫作分用 。把物理信号转换成数字信号,得到一个以太网数据帧,把这个数据帧交给数据链路层 。按照以太网数据帧的格式解析,取出其中的载荷,交给网络层网络层 按照IP协议解析,取出载荷交给传输层 。按照UDP协议格式解析,取出其中载荷,再交给应用层。再按照应用程序内部协议格式解析数据。

途径交换机和路由器

但是实际上即使是经过交换机或者路由器,上述的封装分用过程,也同样适用

只不过,封装分用的程度不一定是到应用层

经典交换机来说,就只需要封装分用到数据链路层即可. 经典的路由器来说,就只需要封装分用到 网络层

网络编程

主动发起通信的一般叫做客户端,被动接受的一方称为服务器

同一个程序在不同的场景中,可能是客户端也可能是服务器

客户端与服务器交互

一问多答(单请求,多响应)

典型场景:下载文件、看视频流。

通俗解释: 你在客户端点击了"下载一集电视剧"。这就相当于你发出了一个请求。但是,这个文件太大了,服务器不可能把 1GB 的数据一口气砸到你的内存里(那样会直接内存溢出)。所以,服务器会把这集电视剧切成无数个小块,像流水线一样,一块一块地顺着网线发给你,这就形成了"多个响应"。

多问一答(多请求,单响应)

典型场景:大文件上传。

通俗解释: 刚好和下载反过来。你要给云盘传一个几百兆的文件,你会把文件切碎,在后台不断地向服务器发送:"接收第一块"、"接收第二块"......这就是多个请求。服务器那边默默地把这些块拼起来,等到最后一块收齐了,它才给你回一句:"好的,整体文件我都拼好并存进硬盘了,上传成功!"(一个最终响应)。

多问多答(全双工通信)

典型场景:直播弹幕、远程桌面。

通俗解释: 这种模式下,客户端和服务器是完全平等的,大家都在疯狂给对方发消息,而且不需要等待对方回复就能继续发。比如打游戏时,你一边疯狂发送移动和放技能的指令(多个请求),服务器一边疯狂给你推送其他玩家的当前位置和血量(多个响应)。两条数据流在网线里同时穿梭。

UDP和TCP

操作系统给我们提供的api也叫socket api也叫做套接字

理解

  • 什么时候必须用 TCP?

    • 网页浏览 (HTTP/HTTPS)、文件下载、登录账号、支付转账。

    • 理由: 这些场景下,丢一个字节都可能导致网页打不开、文件损坏或者密码错误。宁可慢一点,也必须 100% 准确。

  • 什么时候必须用 UDP?

    • 视频通话、直播、竞技类网络游戏(如王者荣耀移动/释放技能)。

    • 理由: 你在打视频电话时,如果网络卡了丢了一帧画面,顶多是屏幕花了一下(几毫秒就过去了),马上就会看到最新的画面。但如果用 TCP,它为了把你丢的那一帧补回来,会让整个画面卡住死等,导致严重的延迟。在这些场景下,实时性压倒一切,少量丢包是可以容忍的。

socket api

在操作系统眼里,网络通信的本质也是在"读写文件",这个特殊的文件就叫 Socket 文件。

通过网卡发送数据,是写socket。接受数据,就是读socket

结论: 你之前学 I/O 的时候,已经掌握了怎么用流去读写硬盘上的文件。现在进入网络编程,你只需要拿到这个特殊形态的 Socket,然后继续用你滚瓜烂熟的 InputStreamOutputStream 去读写它,数据就会自动通过网卡飞向全世界了!

这就是为什么我说:网络编程,本质上就是拉长了网线的 I/O 操作

回显服务器

客户端发什么,服务器就返回什么,没什么业务逻辑

复制代码
import java.io.IOException;
import java.net.DatagramPacket;
import java.net.DatagramSocket;

public class UdpEchoServer {
    private DatagramSocket socket = null;
    public UdpEchoServer(int port) throws Exception {
        socket = new DatagramSocket(port);
    }
    public void start() throws IOException {
        System.out.println("服务器启动了,正在等待客户端发消息...");
        while (true){
            DatagramPacket requestPacket = new DatagramPacket(new byte[4096], 4096);
            socket.receive(requestPacket);
            String request = new String(requestPacket.getData(), 0, requestPacket.getLength());
            String response = process(request);
            DatagramPacket responsePacket = new DatagramPacket(response.getBytes(), response.getBytes().length,requestPacket.getSocketAddress());
            socket.send(responsePacket);
            System.out.printf("[%s:%d] req: %s, resp: %s\n",
                    requestPacket.getAddress().toString(),
                    requestPacket.getPort(),
                    request,
                    response);
        }
    }
    public String process(String request){
        return request;
    }

    public static void main(String[] args) throws Exception {
        UdpEchoServer server = new UdpEchoServer(9090);
        server.start();
    }
}

import java.io.IOException;
import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.InetAddress;
import java.net.UnknownHostException;
import java.util.Scanner;

public class UdpEchoClient {
    private DatagramSocket socket = null;
    private String serverIp;
    private int serverPort;
    public UdpEchoClient(String serverIp, int serverPort) throws Exception {
        socket = new DatagramSocket();
        this.serverIp = serverIp;
        this.serverPort = serverPort;
    }
    public void start() throws IOException {
        System.out.println("客户端启动了...");
        Scanner scanner = new Scanner(System.in);
        while (true){
            System.out.print("请输入请求内容: ");
            String request = scanner.nextLine();
            if (request.equals("exit")){
                break;
            }
            DatagramPacket requestPacket = new DatagramPacket(
                    request.getBytes(),
                    request.getBytes().length,
                    InetAddress.getByName(serverIp), // 转换 IP 格式
                    serverPort
            );
            socket.send(requestPacket);
            DatagramPacket responsePacket = new DatagramPacket(new byte[4096], 4096);
            socket.receive(responsePacket); // 阻塞等待服务器回信
            // 4. 把回信转成字符串打印出来
            String response = new String(responsePacket.getData(), 0, responsePacket.getLength());
            System.out.println("接收到服务器的回音: " + response);
        }
    }
    public static void main(String[] args) throws Exception {
        // 127.0.0.1 是一个极其特殊的 IP,叫"环回IP",代表"本机"。
        // 因为你的服务端和客户端现在都在你这一台电脑上运行。
        UdpEchoClient client = new UdpEchoClient("127.0.0.1", 9090);
        client.start();
    }
}

词典服务器(复习多态重写)

复制代码
// 父类(或接口):定义标准
public abstract class Dictionary {
    // 抽象方法:提供翻译功能,具体实现交由子类决定
    public abstract String translate(String word);
}

// 子类 1:通用英语词典
public class GeneralDictionary extends Dictionary {
    @Override // <--- 这里就是重写(Overriding)
    public String translate(String word) {
        if ("Apple".equalsIgnoreCase(word)) return "苹果 (一种水果)";
        return "通用词典未找到该词";
    }
}

// 子类 2:医学专业词典
public class MedicalDictionary extends Dictionary {
    @Override // <--- 这里也是重写
    public String translate(String word) {
        if ("Apple".equalsIgnoreCase(word)) return "甲状软骨 (喉结的解剖学俗称,Adam's apple)";
        return "医学词典未找到该词";
    }
}

// 子类 3:网络俚语词典
public class SlangDictionary extends Dictionary {
    @Override // <--- 依然是重写
    public String translate(String word) {
        if ("Apple".equalsIgnoreCase(word)) return "果厂 (指代苹果公司)";
        return "俚语词典未找到该词";
    }
}

public class DictionaryServer {
    
    // 服务器处理查询请求的方法
    // 注意看参数:这里使用的是父类类型 Dictionary! <--- 这里就是多态的体现
    public void processQuery(Dictionary dict, String word) {
        System.out.println("收到查询请求,查询单词: " + word);
        
        // 动态绑定:在运行时,系统会根据 dict 实际指向的子类对象,
        // 来决定调用哪个具体的 translate 方法。
        String result = dict.translate(word); 
        
        System.out.println("查询结果: " + result);
        System.out.println("-------------------------");
    }

    public static void main(String[] args) {
        DictionaryServer server = new DictionaryServer();
        String targetWord = "Apple";

        // 父类引用指向不同的子类对象
        Dictionary generalDict = new GeneralDictionary();
        Dictionary medicalDict = new MedicalDictionary();
        Dictionary slangDict = new SlangDictionary();

        // 服务器用同一套代码,处理了不同形态的词典,得到了不同的结果!
        server.processQuery(generalDict, targetWord);
        server.processQuery(medicalDict, targetWord);
        server.processQuery(slangDict, targetWord);
    }
}

Tcp回显服务器

复制代码
//服务器

import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.PrintWriter;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.Scanner;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class TcpEchoServer {
    // ServerSocket 是服务端的"迎宾大门"或"总机",只负责等客户端来连接,不负责具体通信
    private ServerSocket serverSocket = null;

    public TcpEchoServer(int port) throws IOException {
        // 绑定一个固定的门牌号 (端口),让客户端能找到我们
        serverSocket = new ServerSocket(port);
    }

    public void start() throws IOException {
        System.out.println("服务器启动!");

        // 【核心优化:多线程】
        // 为什么用线程池?如果不用多线程,一个服务端只能同时服务一个客户端。
        // 用了 CachedThreadPool,来一个客户端就分配一个线程去招待,大家互不干扰。
        ExecutorService pool = Executors.newCachedThreadPool();

        while (true) {
            // 【建立连接】
            // accept() 是一直死等 (阻塞) 的,直到有客户端打电话过来。
            // 返回的 clientSocket 就是专门为这个客户端分配的"专线电话"。
            Socket clientSocket = serverSocket.accept();// 接听电话

            // 把这个"专线电话"交给线程池里的一个线程去处理
            pool.submit(new Runnable() {
                @Override
                public void run() {
                    processConnection(clientSocket);
                }
            });

            // lambda写法
            //pool.submit(() -> processConnection(clientSocket));
        }
    }

    // 处理单个客户端的具体通信逻辑 (在子线程中运行)
    private void processConnection(Socket clientSocket) {
        System.out.printf("[%s:%d] 客户端上线!\n", clientSocket.getInetAddress(), clientSocket.getPort());

        // 【I/O 流登场】
        // clientSocket.getInputStream(): 用来听客户端说什么
        // clientSocket.getOutputStream(): 用来给客户端回话
        // 放在 try() 括号里,不管发生什么异常,最后都会自动帮我们关闭流,防止资源泄露
        try (InputStream inputStream = clientSocket.getInputStream();
             OutputStream outputStream = clientSocket.getOutputStream()) {

            while (true) {
                // 将原生的"字节流"包装成 Scanner,方便按字符串读取
                Scanner scanner = new Scanner(inputStream);

                // 如果客户端断开了连接,hasNext() 就会返回 false,循环结束
                if (!scanner.hasNext()) {
                    System.out.printf("[%s:%d] 客户端下线!\n", clientSocket.getInetAddress(), clientSocket.getPort());
                    break;
                }

                // 1. 读取请求
                // 【大坑预警】scanner.next() 必须要读到"空白符"(空格、换行 \n) 才会认为读完了一个词。
                // 所以客户端发消息时,末尾必须带上换行符!
                String request = scanner.next();

                // 2. 根据请求计算响应 (回显服务器,直接原样返回)
                String response = process(request);

                // 3. 返回响应给客户端
                // 将原生的"字节输出流"包装成 PrintWriter,这样就可以像用 System.out 一样方便地写字符串了
                PrintWriter printWriter = new PrintWriter(outputStream);

                // 用 println() 写入,它会自动在字符串末尾加上换行符 (\n)
                // 这样客户端那边的 scanner.next() 就能成功读取到了
                printWriter.println(response);

                // 【极其重要】TCP 的 IO 带有"缓冲区"!
                // 就像水管,水攒够了才会一次性冲出去。flush() 就是"手动冲水",确保消息立刻发给客户端。
                printWriter.flush();

                System.out.printf("[%s:%d] req: %s, resp: %s\n", clientSocket.getInetAddress(), clientSocket.getPort(),
                        request, response);
            }
        } catch (IOException e) {
            throw new RuntimeException(e);
        } finally {
            // 【释放资源】通话结束,必须挂断专线电话,否则服务器的 Socket 资源会被耗尽
            try {
                clientSocket.close();
            } catch (IOException e) {
                throw new RuntimeException(e);
            }
        }
    }

    public String process(String request) {
        return request;
    }

    public static void main(String[] args) throws IOException {
        TcpEchoServer server = new TcpEchoServer(9090);
        server.start();
    }
}

//客户端

import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.PrintWriter;
import java.net.Socket;
import java.util.Scanner;

public class TcpEchoClient {
    private Socket socket = null;

    public TcpEchoClient(String serverIp, int serverPort) throws IOException {
        // 【拨号连接】
        // UDP 的客户端创建时不用填 IP 和端口。
        // 但 TCP 是"面向连接"的,在 new Socket 的这一瞬间,底层就在和服务器进行"三次握手"了!
        // 如果服务器没开,这行代码会直接抛出异常 (ConnectException)。
        socket = new Socket(serverIp, serverPort);
    }

    public void start() {
        System.out.println("客户端启动!");

        // 拿到通信用的输入输出流
        try (InputStream inputStream = socket.getInputStream();
             OutputStream outputStream = socket.getOutputStream()) {

            // 准备三个工具人:
            // 1. scannerConsole: 从键盘读取你敲的字
            Scanner scannerConsole = new Scanner(System.in);
            // 2. scannerNetwork: 从网线读取服务器发来的字
            Scanner scannerNetwork = new Scanner(inputStream);
            // 3. writer: 往网线里写字,发给服务器
            PrintWriter writer = new PrintWriter(outputStream);

            while (true) {
                System.out.print("-> ");

                // 1. 等待你在控制台敲字
                if (!scannerConsole.hasNext()) {
                    break;
                }
                String request = scannerConsole.next();

                // 2. 把字发给服务器
                // 【坑点对应】必须用 println(),因为它自带换行符 \n。
                // 如果你只用 print(),服务器那边的 scanner.next() 会一直死等换行符,导致"假死"。
                writer.println(request);

                // 【强制冲水】把缓冲区的数据立刻挤进网线发走!不加这句可能发不出去!
                writer.flush();

                // 3. 读取服务器返回的响应
                // 这里也会阻塞,直到收到服务器发回来的带有换行符的数据
                String response = scannerNetwork.next();

                // 4. 显示在屏幕上
                System.out.println(response);
            }
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }

    public static void main(String[] args) throws IOException {
        // 连接本机的 9090 端口
        TcpEchoClient client = new TcpEchoClient("127.0.0.1", 9090);
        client.start();
    }
}

网络原理

应用层

自定义协议

所谓"协议",说白了就是通信双方提前约定好的"暗号格式"。

用了 writer.println(request) 发送,服务器用 scanner.next() 接收。

  • 为什么要加 println?因为里面藏了一个换行符 \n

  • 为什么服务器看到 \n 才知道你这句话说完了?因为 scanner.next() 就是这么规定的。

序列化

  • 你的代码里(结构化数据): 你写 Java 时,操作的都是"对象"。比如一个 User 对象,里面有 String name = "张三"int age = 20。这叫结构化数据,非常立体,有血有肉。

  • 网线里(二进制 bit 流): 网线是个极其底层、极其"笨"的物理介质。它根本不认识什么叫 Java 对象,它只认识 01 组成的字节流(也就是一根水管) 。

矛盾来了: 你怎么把一个立体的 Java 对象,硬塞进一根扁平的水管里传给对方呢?

  1. 什么是"序列化"?

把结构化数据,转成字符串/二进制比特流 这个操作,称为 "序列化"。

  • 通俗理解: 就像是把你买的宜家家具(结构化数据)拆解打包成一个扁平的纸箱子(字节流)。

  • 最常见的例子: 把一个 Java 的 User 对象,转换成一串 JSON 格式的字符串:{"name":"张三", "age":20}

  1. 什么是"反序列化"

把字符串/二进制比特流还原成结构化数据,这个操作,称为 "反序列化"。

  • 通俗理解: 服务器收到了那个扁平的纸箱子(字节流),照着图纸把它重新组装成了一个立体的宜家家具(Java 对象),方便程序继续调用里面的属性。

  • 最常见的例子: 你的 Spring Boot 服务器收到了前端传来的 JSON 字符串 {"name":"张三", "age":20},然后通过工具把它瞬间变成了一个 Java 的 User 对象。

经典数据格式

  1. XML:啰嗦的"老派重装装甲"

这是互联网早期(特别是老 JavaEE 时代)的绝对霸主。它的长相非常像 HTML 标签。

假设我们要传一个用户信息,XML 序列化后长这样:

复制代码
<User>
    <name>张三</name>
    <age>20</age>
    <skills>Java</skills>
</User>
  • 优点: 结构极其严谨,标签成对出现,人眼看着非常清晰。

  • 致命缺点(被抛弃的原因): 太 TM 啰嗦了! 你为了传一个真实的有效数据 "张三",竟然需要浪费那么多字节去写 <name></name> 这种无用的包装。

  • 现状: 在现代的网络传输中,它已经基本被淘汰了。但它并没有死,而是退居二线,成了配置文件的专用格式(比如你以后一定会经常碰到的 Maven pom.xml,或者早期的 Spring 配置文件)。


  1. JSON:统治现代 Web 的"万能通用语"

JSON (JavaScript Object Notation) 是目前互联网上绝对的统治者。不管是浏览器和服务器聊天,还是手机 App 刷数据,99% 都在用它。

同样传刚才那个用户信息,JSON 序列化后长这样:

复制代码
{
    "name": "张三",
    "age": 20,
    "skills": "Java"
}
  • 优点:

    • 轻量: 把繁琐的 <tag> 变成了简单的键值对 "key": "value",大幅度省了网络流量。

    • 极致的跨语言支持: 所有语言都有成熟的 JSON 解析库。

    • 人类极其友好: 调试的时候,抓包看到一堆 JSON,业务逻辑一目了然。

  • 缺点: 虽然比 XML 小了,但它依然是在传"字符串"。像 "name""age" 这种属性名依然要在网络上占用来回传输的带宽。而且解析字符串需要消耗 CPU 计算资源。

  • 现状: Web 开发的绝对核心,日常写 Spring Boot 给前端写接口,全都是返回 JSON。


  1. Protobuf (Protocol Buffers):冷酷无情的"性能刺客"

这是 Google 开源的一个二进制序列化框架。如果说 JSON 是用文本在聊天,那 Protobuf 就是在用电报密码发暗号。

在做高性能后端组件(比如高并发网络通信、或者是底层的基础设施)时,追求极致的空间和时间复杂度是刻在骨子里的本能。这时候 JSON 就不够看了,必须上 Protobuf。

同样传那个信息,Protobuf 是怎么干的? 它根本不传 "name" 或者 "age" 这种属性名!它要求通信双方提前拥有一个"密码本"(.proto 文件):

复制代码
// 双方提前约定好的密码本
message User {
  string name = 1; // 1号位置是姓名
  int32 age = 2;   // 2号位置是年龄
}

序列化后的真实形态: 它在网线里传的纯粹是一串人类根本看不懂的二进制乱码(比如:0A 06 E5 BC A0 E4 B8 89 10 14)。

  • 0A 代表这里是 1 号字段(姓名)。

  • 后面直接跟字节数据。它完美避开了传递重复的属性名字典。

  • 优点:

    • 极致的体积小: 往往比 JSON 小 3 到 10 倍。

    • 极致的解析快: 二进制直接按位读取,不需要正则解析,不需要处理字符串转义,CPU 消耗极低。

  • 缺点: 人类肉眼完全不可读。抓包抓下来是一堆乱码,如果没有那个 .proto 密码本,你根本不知道这串二进制代表什么。

  • 现状: 它是微服务内部调用(RPC 框架)的绝对标配。大厂内部服务器之间互相发消息,为了省钱和压榨性能,几乎都在用 Protobuf。

传输层

有些服务器是⾮常常⽤的, ⼈们约定⼀些常⽤的服务器, 都是⽤以下这些固定的端⼝号:

ssh服务器, 使⽤22端⼝

ftp服务器, 使⽤21端⼝

telnet服务器, 使⽤23端⼝

http服务器, 使⽤80端⼝

https服务器, 使⽤443

UDP协议

8 字节。这意味着 UDP 的"包装皮"(首部)非常薄,一共只占 8 个字节,包含四个核心字段:

  • 16位源端口号 & 目的端口号: 这就好比寄件人和收件人的"门牌号",决定了数据从哪个应用程序发出来,又要交给哪个应用程序。

  • 16位UDP长度: 记录了整个包裹(8 字节首部 + 真实数据)的总大小。

  • 16位UDP检验和: 这是一个简单的"防伪/防损鉴定"。就像图下方文字写的,如果接收端一算,发现数据在路上磨损或者被篡改了(校验和对不上),UDP 的处理方式极其冷酷------直接丢弃,且绝对不会通知发送方重传。

包头大小是固定 8 个字节(Byte): 并非至少 8 位。你看图里有 4 个格子(源端口、目的端口、长度、校验和),每个格子都是 16 位(也就是 2 个字节)。

总体最大长度是 65535 字节: "16位UDP长度"的意思是,用来记录包裹大小的这个数字,是一个 16 位的二进制数。16 个二进制位(1111111111111111)能表示的最大十进制数是 65535 。所以,一个 UDP 快递包裹(包头 + 数据)最大只能装 65535 字节(大约 64 KB)

用 16 位的存储空间,去表达一个最大约为 64 KB 的接收能力

1 字节 (Byte) = 8 位 (bit)

把单位理顺之后,规则就非常清晰了:UDP 规定死了一个包最大就是大概 64 KB。

校验和

UDP 计算校验和的方法非常原始,基本就是把数据切成一块一块的,然后做简单的二进制加法。 这种简单的加法有一个致命缺陷:如果网线里的干扰,恰好让数据的前面加了 1,后面减了 1,互相抵消了,最终的求和结果完全不变!它只能防住最普通的线路噪音(比如偶尔一两个 bit 翻转了),根本防不住稍微复杂一点的错误,更别提黑客的恶意篡改了。

md5加密

TCP协议

TCP报头总长度最多60字节,去掉前面固定的20字节,还剩40字节也就是选项

ack为1是应答报文

TCP 报头由两个部分组成 :

  1. 固定部分(20 字节): 包含源/目的端口、序号、确认序号、标志位、窗口大小等核心信息。这是每个 TCP 包都必须背负的"基础重量" 。

  2. 选项部分(0 - 40 字节): 这就是报头长度不确定的根源。为了支持一些高级功能(比如我们在后面会提到的 MSS 协商、窗口扩大因子、时间戳等),TCP 允许在报头后面追加一些"插件" 。

应答确认
  • 动作: 主机 A 给主机 B 发送了一批数据,这批数据的字节编号是从 11000

  • 回复逻辑: 主机 B 完好无损地收到了这 1000 个字节。它必须要给 A 回个信(这封回信就是带有 ACK 标志的报文)。

  • 填什么数字?"按照发送过去的最后一个字节的序号再加 1" 。既然最后一个收到的字节编号是 1000,那么确认序号就是 1001

TCP的确认应答是确保TCP可靠性的最核心机制

超时重传

主机A发送数据给B之后, 可能因为⽹络拥堵等原因, 数据⽆法到达主机B;

如果主机A在⼀个特定时间间隔内没有收到B发来的确认应答, 就会进⾏重发; 但是, 主机A未收到B发来的确认应答, 也可能是因为ACK丢失了

因此主机B会收到很多重复数据. 那么TCP协议需要能够识别出那些包是重复的包, 并且把重复的丢弃掉

所以先传到接收方的缓冲区,看看是否存在过。如果之前的数据还在接受缓冲区里,就对一下序号就好了。如果数据已经被应用程序read走了,接收缓冲区会记录上一次读到的最后一个字节序号,如果应用程序已经读到了 3000 号,这时网线里又钻出来一个 1001 号的包,TCP 瞬间就能识别出:"这是之前的旧货,我已经收过了!" 然后直接把它丢弃。应用程序读取数据时是严格按照序号先后顺序、连续读取的,绝不会跳着读

重传次数是有上限的

重传的超时时间阈值会随着重传次数的增加而增大(重传频率越来越低)

连接管理
三次握手四次挥手

当你在一行代码里敲下 new Socket("127.0.0.1", 8080) 时,底层其实已经在一瞬间完成了三次极其严密的来回对话。

这三次对话的具体过程是:

  1. 客户端: "服务器在吗?我想跟你建连。"(发送 SYN 报文)

  2. 服务器: "我在的!我也愿意跟你建连。"(发送 SYN + ACK 报文)

  3. 客户端: "收到你的同意了,我们开始吧!"(发送 ACK 报文)

TCP 每次建立连接时,把初始序号搞成一个巨大的随机数,核心就是为了防两个"搞破坏的":

  • 1. 防黑客"盲猜"(安全性): 如果序号每次都从 1 开始,黑客可以轻松伪造一个序号为 2 或 3 的恶意包发给服务器,服务器会当真。用 42 亿以内的巨大随机数开局,黑客根本猜不中合法序号,恶意包直接被底层丢弃。

  • 2. 防历史"迟到包"(稳定性): 如果你刚关掉一个连接又立马重连,恰好上一个连接里有个卡顿很久的旧数据包这时才飘过来。如果新连接又从 1 开始算,就会把这个"前朝余孽"当成新数据收下,导致彻底错乱。用随机大数开局,旧包的序号和新连接差了十万八千里,一眼就能识破并扔掉。

  • LISTEN:服务器的基本功,没这个状态,客户端连都连不上。

  • ESTABLISHED:连接成功的唯一标志。

  • CLOSE_WAIT:代码 Bug 的重灾区(忘了关 socket)。

  • TIME_WAIT:主动关连接的一方必经的"冷静期",为了保证连接可靠关闭和清理过期报文。

当你或者服务器调用 socket.close() 的时候,底层就会触发这套极其墨迹的"四次挥手"

因为 TCP 是"全双工"的(可以同时互相发消息)。客户端说"我发完了",只代表客户端 不再发送数据了,但不代表客户端不能接收数据!服务器可能还有半截话没说完

四次挥手的真实场景:

  1. 客户端: "我的话说完了,申请分手。"(发送 FIN 报文)(finish)(socket.close() 的时候)

  2. 服务器: "收到了,但我还有点事没处理完,你先等等。"(发送 ACK 报文,进入 CLOSE_WAIT 状态)(ack是内核自动触发)

    (此时,服务器继续把没发完的数据发完)

  3. 服务器: "好了,我也说完了,彻底散伙吧。"(发送 FIN 报文)(socket.close() 的时候)(这两个不想三次挥手一样能合并,可能中间逻辑很多)

  4. 客户端: "好的,不见。"(发送 ACK 报文,进入 TIME_WAIT 状态)

主动断开连接状态是**TIME_WAIT** 被动断开连接状态是**CLOSE_WAIT** CLOSE_WAIT 时间短

++TIME_WAIT防止最后一个ack丢包,避免服务器重传fin++

TIME_WAIT设置一个1min,算很长的时间了

(TCP特点)滑动窗口(跟算法的很像)

确认应答机制下,收到一个ack才会发下一个数据,大量时间消耗在等待ack上了。

所以希望在保证可靠传输的前提下,让效率提高。(但是还是没有UDP高)

所以现在我们批量传输,先发一个数据,不等ack,继续往下发。连续发了一定数据之后,统一等一次ack。减少了总的等待时间。

我们是等一个ack就往后发一组,也就是活滑动窗口。因为发送/返回ack的速度很快,窗口往后移的速度很快,直观上就是一个滑动效果。

可靠性是TCP的前提,如果丢包的话。如果丢的是ack的话 ,无需进行任何处理,对我们的可靠性没有影响,因为传回的1001代表前1000的数据,2001代表前2000的数据,可以覆盖,如果2001比1001先传到也是一样的意思。**如果丢的是数据的话,**就必然要重传,数据丢了之后返回的确认序号都是一样的,反复像发送方索要丢失的那部分数据。发送方就会认为丢包了,就会重传一次丢失的数据。然后返回的ack就会一直跳到之前收到的最后一份数据的ack。因为之前传到的数据都是放到Tcp接受缓存区里的。

如果发的数据很少,窗口滑不起来,就退化成了确认应答

发送速度不能无限大,缓冲区满了就丢包了

跟TCP报头里的16位窗口大小有关,这个是反馈发送方,根据自己接受缓冲区的大小作为ack中窗口大小的值,让它根据这个数值调整自己的窗口大小

16位窗口大小在普通报文中没意义,在ack报文中才有意义

发送方也会周期性触发窗口探测包,并不携带载荷,只是为了触发ack,一旦查询出来的结构是非0了,说明缓冲区又行了,发送方就继续发送。

这也就是++流量控制++

流量控制是站在接收方的角度来制约发送方的速率的。当然就算接收方处理很快,但是中间通信路径出现问题了,堵车了,那么发送方发送的再快也没用。所以我们把中间路径经过的所有设备视为一个整体,通过实验的到一个比较合适的速率。实验就是按照每个窗口大小发送数据后,看是丢包还是不丢包,来动态调整判断是增大还是减小窗口大小。这就是++拥塞控制++ 。++和流量控制比起来,看谁产生的窗口小,就谁说了算。++

TCP引入慢启动机制,,先发少量的数据,,采用的窗口大小也小(拥塞窗口),探探路, 摸清当前的⽹络拥堵状态,再决定按照多⼤的速度传输数据;如果没有丢包就增大窗口大小,按照指数增长 窗口大小。指数增长可能太快,所以这里引入一个阈值 ,当拥塞窗口达到阈值 之后,指数增长 就变成了线性增长 。线性也是一直在增长,一段时间内又可能太快,直到引起丢包后,就把拥塞窗口重置成较小的值,回到最初的慢启动过程,也是重新开始指数增长 ,并且这里会根据刚刚丢包的窗口大小,重新设置指数增长线性增长窗口阈值。(经典版本)

新版本的跟经典版本的区别是,新版本的拥塞窗口重置不会很小,并且重置后不会指数增长而是线性增长。

++延时应答++ ack可以把反馈的窗口搞大一点,再提高一点效率。接收方收到数据之后,不会立刻返回ack,而是稍等一下返回ack。给接收方的应用程序腾出更多时间来消费这里的数据,因为新收到数据会占用接受缓冲区,立刻返回ack流量控制让发送方变慢。延时就可以让接收缓冲区多处理一些,这时返回的窗口就是一个更大的值。也可以每隔几个数据才返回一个ack,也能起到节省开销的结果。

++捎带应答++ ,这个不是修改窗口大小。而是尽可能把能合并的数据包进行合并,从而起到提高效率的结果。ack一般是内核自动触发的,简单来说,它就是让 ACK 确认报文 免费搭上 业务数据报文 的"顺风车",把原本需要发两个包的操作合并成一个包发出去。

有了延时应答之后,服务器收到请求不会立马回 ACK,而是会等上一小会儿(比如 200ms)。如果在这一小段时间内,服务器的应用程序正好算出了结果,准备给客户端回发"响应数据",TCP 就会: 把 ACK 标志位设为1。把确认序号 (Acknowledgment Number) 填好。然后直接把这些报头信息和业务数据塞进同一个TCP报文段里发走。ack设置为1不会影响业务数据。

面向字节流 ,讨论粘包 问题,包是TCP的载荷中的应用数据包。(++UDP传输的基本单位是数据包,就不会粘包,UDP的接受缓冲区就像是链表,已经分开了++ )应用程序包就是接收方根据socket api来read出来的结果就是应用层数据包。多个数据例如 aaa bbb ccc一起到接收缓冲区,多个应用层数据包混淆不清了,就称为粘包。++所以面向字节流都会有粘包问题++ ,怎么解决呢,关键就是明确包之间的边界。首先 是通过与特殊符号作为分隔符,分隔符在正式数据中不会出现。其次,可以指定出包的长度,在包开始的地方加上一个特殊的空间来表示整个数据的长度。

异常情况 ,TCP如何处理呢。++1.++ 其中一方出现了进程崩溃,进程无论是正常结束还是异常崩溃,本质都会回收文件资源,关闭文件,触发四次挥手。TCP连接周期可以比进程更长一些,所以跟我们正常关闭socket没什么区别,只是通过系统中的连接信息完成后续挥手进程。++2.++ 其中一方出现了关机,会先强制终止所有的进程,跟1没什么区别,区别在要关机了,四次挥手不一定能挥完,如果没挥完,至少把第一个fin发给对端,对端能返回ack同时发fin,当然不会有ack返回了。也就能单方面释放连接信息。++3.++ 其中一方出现了断电,如果断电的是接收方 ,发送方发现没有ack就要重传,重试几次还是不行。TCP就会复位连接,相当于清除TCP中的各种临时数据,重新开始,通过RST这个报文之间开始,还是不行就会单方面放弃连接。如果断电是是发送方 ,这个情况下,接收方本来就在阻塞等待,要区分对面是挂了还是暂时没发,如果一段时间后没收到消息就会触发心跳包来询问对方的情况,心跳包也是不携带载荷的,是周期性的,没有心跳就视为对方挂了。本端就会尝试复位并且单方面释放连接了。++4.++网线断开,这个就是情况3的两种状况的结合。

KCP是UDP和TCP效率和速度的折中方案,一般用在游戏

网络层

ip协议

4位版本取值只有IPv4和IPv6,现在还是介绍主流的IPv4

选项也是可有可无的

4位首部长度,IP报头,也是可变长,单位也是四个字节

8位服务类型,实际上只有4位有效,其中一位是1,剩下三个都是0,最⼩延时,最⼤吞吐量, 最⾼可靠性, 最⼩成本。 这四者相互冲突, 只能选择⼀个。最小延时是吃饭吃的快,最大吞吐量是吃饭吃的多,最高可靠性比不上TCP,只是减少丢包概率,最小成本指的是硬件设备的开销。

16位总长度,描述了IP数据包的长度,包括报头加载荷。载荷没有长度限制,因为IP自动提供了拆包组包这样的功能。载荷超过64KB就会自动拆成多个IP数据报。

16位标识,哪些IP数据报的载荷应该一起组装

3位标志,只有两位有效,其中一位表示这次传输是否拆包了。另一位表示结束标记,看到这一位说明这是要组装的最后一个包

13位片偏移,描述了这些包的先后顺序,谁小谁前

8位生存时间(TTL),单位不是秒,存储的是一个次数,代表一个IP数据报每经过一个路由器转发TTL减一,这个数值为0说明数据包要被丢包了。TTL一般是32/64这样的整数,一般够用了。就像世界里的两个人想认识,只需要最多经过6层朋友介绍。

8位协议表示在传输层用那个协议

16部首部校验和,只是针对IP首部校验,不管载荷,因为载荷中的UDP/TCP都自带了校验和

32位源地址和32位⽬标地址: 表⽰发送端和接收端,都是点分十进制表示

IP地址分为私网/局域网,和公网/广域网

只要保证局域网内部的IP不重复即可,不同局域网之间IP允许重复

局域网可以主动访问公网,公网不能主动访问局域网

一般路由器带有两个IP地址,LAN口IP和WAN口IP,前面一般是一个局域网,后面一般是一个局域网或者公网,路由器的核心就是把这两个局域网连接起来。


路由器进行NAT的时候,会把通信的相关信息记下来,记录替换前的源IP,替换后的源IP,目的IP等。返回的响应数据,源IP和目的IP是反过来的。

你可能会问:如果我和我室友同时访问 LeetCode,公网 IP 都是一样的,回来的包路由器怎么分清是谁的?

这就是现在的 NAPT (网络地址端口转换) 厉害的地方。路由器不仅记 IP,还记 端口号 (Port)

内部主机 (IP:Port) 外部映射 (IP:Port) 目的服务器 (IP:Port)
你的电脑 (1.5:8080) 公网:10001 LeetCode:443
室友电脑 (1.6:9090) 公网:10002 LeetCode:443

当 LeetCode 回信到 公网:10001 时,路由器看一眼端口号是 10001,就知道这是你的;看到 10002,就知道是室友的。

不用怕端口一样,

  1. 检查冲突: 当你的电脑请求上网,路由器准备给你分配一个外部端口(比如 10001)时,它会先在表里搜一遍:"有没有别的内网兄弟正在用这个 10001 端口?"

  2. 递增分配: 如果 10001 已经被占用了,路由器会顺延去挑 10002,直到找到一个没被占用的空闲端口为止。

结论: 在同一个公网 IP 下,路由器会强制保证分配给不同内网主机的外部端口是唯一的。


IPv4使用4个字节表示IP地址,42亿九千万,IPv6使用16个字节表示IP地址,彻底解决地址不够用的问题

但是IPv6的报头结构和IPv4不兼容,引入IPv6就需要更换支持IPv6的设备


  • 主机号全 1 的地址广播地址 (Broadcast Address) 。如果你往这个地址发包,局域网内所有的电脑都能收到(相当于拿着大喇叭在走廊喊话)。例如 192.168.1.255

  • 255.255.255.255受限广播地址。仅限于当前局域网,路由器绝不会转发这种包。

1.地址管理

一个IP地址,前三个是网络号,最后一个是主机号

同一个局域网内,网络号和主机号都相同的无法上网的。网络号和路由器的网络号不相同,也是无法上网的,一般是DHCP设置好的。两个相邻的局域网,网络号不能相同(一个路由器连接的两个网络就是相同的)。路由器连接两个不同的网络,网络号相同的话,路由器就彻底懵了。它无法判断这个设备到底是在左边还是右边,最终会导致数据丢弃或乱发。


网段划分,为了进行组网,了解即可

A类 0.0.0.0到127.255.255.255

B类 128.0.0.0到191.255.255.255

C类 192.0.0.0到223.255.255.255

D类 224.0.0.0到239.255.255.255

E类 240.0.0.0到247.255.255.255

2.路由选择

路由器内部工作机制来支撑的,探索式/启发式/渐进式 的路线规划,只能是一个较优解。拿着目的IP去路由表比对。

数据链路层

以太网,数据帧格式

数据的46-1500字节是IP要拆包组包的一大原因,甚至主要原因不是IP自己超过64KB(++MTU++)

数据链路层引入的是另外一套mac地址


IP 是为了找终点,MAC 是为了走完下一段路


设备 工作层级 只看 MAC 吗? 核心逻辑
交换机 (Switch) 数据链路层 (L2) 维护 MAC 地址表,在局域网内搬运数据。
路由器 (Router) 网络层 (L3) 维护路由表,在不同网段/子网之间规划路线。

DNS

  1. 为什么需要 DNS?

计算机在网络中是通过 IP 地址寻址的。

  • 对机器来说:IP 地址非常高效。

  • 对人类来说:记几百个由数字组成的 IP 地址简直是噩梦。

  • 结论:我们需要一层"映射关系",让我们输入简单的单词就能找到复杂的 IP。

一个域名是怎么被"翻译"出来的?(查询过程)

当你浏览器输入 www.example.com 时,背后发生了一场"多方接力":

  1. 本地缓存 :浏览器先看自己的缓存,再看操作系统的 hosts 文件。如果有,直接拿走。

  2. 本地 DNS 服务器 (LDNS):如果本地没缓存,电脑会去问运营商的 DNS 服务器。

  3. 根域名服务器 :LDNS 问根服务器:".com 谁管?" 根服务器指路顶级域名服务器。

  4. TLD 服务器 :LDNS 问顶级域名服务器:"example.com 谁管?" 顶级域名服务器指路权威服务器。

  5. 权威服务器 :LDNS 问权威服务器:"www 的 IP 是多少?" 权威服务器给出最终答案。

  6. 返回结果:LDNS 把 IP 告诉电脑,并自己缓存一份以备下次使用。

HTTP

Java主要的场景是做网站,网站就是后端HTTP服务器加前端浏览器

HTTP是基于TCP来实现的

超文本不仅仅是字符串,还可以携带图片,特殊的格式什么的

我们平时打开⼀个⽹站, 就是通过 HTTP 协议来传输数据的

HTTP协议的交互过程就是一问一答

代理

有正向代理(客户端代言人)和反向代理(服务器代言人)

HTTP请求内容

  1. 请求行 (Request Line)

这是请求的第一行,告诉服务器"你要干什么"。它包含三个关键信息:

  • 方法 (Method) :比如 GET(获取数据)、POST(提交表单)、PUT(更新)等。

  • URL (Request-URI) :你要访问的具体路径,比如 /api/login

  • 版本 (Version) :使用的 HTTP 协议版本,通常是 HTTP/1.1HTTP/2

示例POST /api/user/login HTTP/1.1


  1. 请求报头 (Request Headers)

这一部分是大量的键值对,用来传递关于客户端环境和请求实体的元数据。作为 Java 开发者,你需要重点关注这几个:

  • Host :目标服务器的域名(如 zhigou.cloud.com)。

  • Content-Type :告诉服务器你发过去的数据是什么格式。比如 application/json(JSON格式)或 application/x-www-form-urlencoded(表单格式)。

  • User-Agent:发送请求的客户端类型(是 Chrome 浏览器、iPhone 还是你的 Java 测试脚本)。

  • Authorization/Cookie:用来证明你是谁。登录后的 Token 通常放在这里。

  • Accept :告诉服务器你能接收什么格式的返回结果(如 application/json)。


  1. 空行 (Blank Line)

这是一个非常容易被忽略但至关重要 的部分。在报头和报文主体之间,必须有一个完全空白的行(只包含回车换行符 CRLF)。

  • 作用:它相当于一个分界线,告诉服务器:"前面的头信息结束了,下面开始是真正的货物(数据)了。"

  1. 请求正文 (Request Body)

这部分不是必须的(比如简单的 GET 请求通常没有 Body)。但在 POSTPUT 请求中,这里装载的是你提交给服务器的数据。

复制代码
{
  "username": "student_001",
  "password": "hashed_password_123"
}

HTTP响应内容

  1. 状态行 (Status Line)

这是响应的第一行,开门见山地告诉浏览器"事情办得怎么样了"。它包含:

  • 版本 (Version) :使用的协议版本,如 HTTP/1.1

  • 状态码 (Status Code) :三位数字的信号,如 200404

  • 状态描述 (Status Message) :对状态码的简短文字解释,如 OKNot Found

示例HTTP/1.1 200 OK


  1. 响应报头 (Response Headers)

描述响应本身以及服务器的一些元数据:

  • Content-Type :告诉浏览器返回的是什么。 例如 text/html(网页)、application/json(数据)或 image/png(图片)。

  • Content-Length:正文的数据长度(字节数)。

  • Set-Cookie:服务器发给浏览器的"小贴纸",用于后续识别身份。

  • Server :服务器的类型(如 NginxTomcat)。


  1. 空行 (Blank Line)

紧跟在最后一个报头之后,是一个必不可少的空行。

  • 作用:作为标识符,告诉解析器:"头信息到此为止,接下来的全是货(数据)了。"

  1. 响应正文 (Response Body)

这是服务器真正想传给你的内容。

  • 如果是访问网页,这里就是 HTML 代码

  • 如果是你的"云商"接口,这里通常是 JSON 字符串(如商品详情)。

  • 如果是下载文件,这里就是二进制流

URL

描述一个网络上的资源位置

不管是一个 HTML 页面、一张图片、一段 Java 代码生成的 JSON 数据,还是一个视频,只要它在网上,就必须有一个唯一的 URL 才能被找到。

|--------------------|--------------|----------------------------------------------------------------------------|
| http: | 协议方案名 | 告诉浏览器用什么"语言"沟通。比如 httphttps(加密)、ftp(传文件)等。 |
| user:pass@ | 登录信息(认证) | 冷知识 :这是为了直接在网址里写用户名和密码。但因为太不安全(明文显示),现在的 Web 开发几乎不再使用这种方式了。 |
| www.example.jp | 服务器地址 | 就是你之前问过的"域名"。它会被 DNS 翻译成 IP 地址,带你找到那台服务器。 |
| :80 | 服务器端口号 | 进哪扇门。HTTP 默认是 80,HTTPS 默认是 443。如果你在本地跑 Java 项目,通常是 :8080。 |
| /dir/index.htm | 带层次的文件路径 | 相当于服务器上的"文件夹路径"。在 Spring Boot 中,这对应你的 @RequestMapping("/dir/index.htm")。 |
| ?uid=1 | 查询字符串 | 额外的参数。在 Java 后端,你会用 @RequestParam("uid") 来拿到这个 1。 |
| #ch1 | 片段标识符 | 重点 :它也叫锚点。它是给浏览器看的(比如跳到页面的某个标题),不会发送给服务器。 |

URL encode

URL 协议(RFC 3986)规定,URL 中只能包含一小部分 ASCII 字符(字母、数字和几个特殊符号)。

  • 特殊字符冲突 :URL 用 ? 分隔路径和参数,用 & 分隔键值对。如果你的数据里本身就含有这些符号,解析器就会打架。

  • 非 ASCII 字符:中文字符、表情符号等在原始 URL 中是不合法的,必须转换成标准的 ASCII 格式。

URL 编码最通用的规则是百分号编码(Percent-encoding) :将字符转换为其十六进制表示,并在前面加上 %

  • 空格 +%20

  • & %26

  • = %3D

  • ? %3F

  • 中文(以 UTF-8 为例) :一个汉字通常占3个字节。比如"中"字的 UTF-8 是 E4 B8 AD,编码后就是 %E4%B8%AD

HTTP方法

get和post是最重要用最多的

方法 动作含义 对应数据库操作 (CRUD) 举个例子
GET 获取资源 SELECT (查询) 获取 ID 为 1001 的商品详情
POST 传输实体主体 INSERT (新增) 提交订单信息,创建一个新订单
PUT 传输文件/更新 UPDATE (修改/替换) 修改用户的收货地址
DELETE 删除文件 DELETE (删除) 下架某件商品
特性 GET (展示型) POST (操作型)
首要目标 (利用缓存、CDN) (传输复杂数据、改变状态)
SEO 友好 极高。搜索引擎爬虫只会爬 GET 链接。 。爬虫不会帮你填表单点提交。
可分享性 可以直接复制粘贴发送。 无法分享特定结果。
数据长度 有限制(通常 2KB-8KB)。 理论上无限制。
适用场景 首页、搜索、查看文章、看视频。 登录、注册、下单、传文件。

GET和POST的区别

本质上没有区别,本质上使用get和post的场景可以互换,取决于你代码怎么写的。

使用习惯上,GET习惯把数据放到query string中。POST习惯把数据放到body中

GET也可以把数据放到body,POST也可以把数据放到query string,对于绝大部分的服务器和浏览器都适用。

语义上的区别是,GET是用来获取数据,POST是给服务器传数据,但实际使用不拘泥于上述需求

关于幂等性 ,文档中建议GET请求实现成幂等的,POST则无需求。幂等 的意思是,相同的请求在不同的时间段输入但是结果是一定的,就是输入的内容一定,输出的结果也一定。蛮重要的,如果是幂等,就可以进行缓存了。GET请求可以被浏览器收藏夹收藏,但是POST不可以。

有些不太准确的说法 ++1:++ POST比GET更安全,说GET登录,用户名和密码会显示在url上,会被别人直接看到。但是POST其实也是会被黑客抓包的,真正的安全性在于数据加密。++2:++ GET传输的数据量小,存在上限。POST传输的数据量更大。这个描述的是以前,但实际上HTTP标准文档说了对于GET的url长度不做限制。++3:++GET只能携带文本数据,POST则可以携带二进制数据,这个说法不是完全错误,但是有一定局限性。GET URL通过query string来携带数据。query string是只能包含文本的。但是可以对二进制文件进行urlencode,自然成了文本里,到了服务器自然进行 urldecode,就能把数据还原成二进制。POST其实也经常不是直接携带二进制,很多时候也进行urlencode和base64来对二进制编码。但实际生产中GET一般不穿二进制,容易url膨胀。

请求报头

Host

表示服务器主机的地址和端口。


Content-Length

表示 body 中的数据长度。


Content-Type

表示请求的 body 中的数据格式。

User-Agent (简称 UA)

表示浏览器/操作系统的属性。形如:

Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko)

  • 其中 Windows NT 10.0; Win64; x64 表示操作系统信息

  • AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.77 Safari/537.36 表示浏览器信息

  • 历史背景 :User-Agent 之所以长得这么复杂,是因为早期的浏览器为了兼容性,不得不"伪装"成别的浏览器。你可以参考《User-Agent 的故事》: https://zhuanlan.zhihu.com/p/398807396

  • 借助 UA 服务器就可以针对此时的 UA 的信息进行判定. 如果用户用的是很老的设备, 返回的页面就不包含新特性, 确保这个页面能够正确访问出来 如果用户用的是新的设备, 返回的页面就包含新特性, 确保这个页面体验足够好.(现在是响应式布局,响应式布局不需要后端参与判定。它完全依赖前端(CSS)的逻辑,根据浏览器窗口的实际宽度来实时调整样式。)


Referer

表示这个页面是从哪个页面跳转过来的。形如:

https://v.bitedu.vip/login

  • 注意:如果直接在浏览器中输入 URL,或者直接通过收藏夹访问页面时是没有 Referer 的。

Cookie

本质是一个浏览器这边本地持久化存储数据的机制,就是数据要存到硬盘里。

浏览器上运行的网页,理论上可以直接读写本地文件,但现实中被浏览器禁止直接读写本地文件。浏览器并没有给网页提供这样的api。为了安全性。但是有些网站确实需要把一些例如身份信息保存到浏览器这边,浏览器退而求其次,提供了一些api,按照键值对的格式存储数据,而不能随意的访问文件系统。cookie就是这样一种存储机制,类似于寄存柜。

1. Cookie 从哪里来?

服务器返回给浏览器的。通常都是首次访问/登录成功之后。

2. Cookie 到哪里去?

Cookie 会存储在浏览器本地主机的硬盘上。后续每次访问服务器都会带上 Cookie。

  • 不同的客户端,保存的 Cookie 是不同的。

  • 即使是同一个主机,使用不同浏览器,Cookie 大概率也不同。

3. Cookie 中存什么?

键值对格式的数据。这里的内容都是程序员自定义的。和 query string 一样外人无从理解~

  • 不同网站的 Cookie 都是不一样的~~

4. Cookie 在浏览器这边如何组织?

在硬盘本地保存,是按照不同的域名为维度分别存储。

  • 你的浏览器访问百度,有一组 cookie;访问搜狗,也有一组 cookie。

5. Cookie 的用途是什么:用来在客户端保存数据。

  • 其中最主要的是保存用户的身份标识

  • 服务器就可以通过标识来区分用户了。

  • 一些其他的业务数据一般不会存到 cookie 中。

  • cookie 随时可以删除掉。把业务数据存储在服务器,通过 cookie 中的身份标识找到对应的数据。

HTTPS初级理解

这个s可以理解成SSL,就是http的基础上加密,把header和body进行加密,网络上传输的就是密文了。

  1. 结构上的升级:套娃模式

普通的 HTTP 直接跑在 TCP 之上,数据全是明文,就像是在大街上裸奔。

HTTPS (Hypertext Transfer Protocol Secure) 并不是发明了一个全新的协议,而是引入了一个中间层:SSL/TLS

  • HTTP 1.1 的结构应用层 (HTTP) -> 传输层 (TCP)

  • HTTPS 的结构应用层 (HTTP) -> 安全层 (SSL/TLS) -> 传输层 (TCP)


  1. 核心"黑科技":三层防护

HTTPS 解决了 HTTP 的三个致命伤:

  1. 窃听(机密性):数据被加密了。即便黑客在中间截获了包,看到的也是一堆乱码,只有你和服务器有"钥匙"解开。

  2. 篡改(完整性):数据带了"防伪标签"。如果中途有人改了数据,接收方校验哈希值(Hash)立刻就能发现。

  3. 冒充(身份验证):服务器必须出示"身份证"(数字证书)。这保证了你访问的是真实的服务器,而不是钓鱼网站。


  1. 握手的艺术(面试高频)

HTTPS 的连接比 HTTP 复杂得多,因为它要进行一次 SSL/TLS 握手 来交换密钥:

  • 第一阶段:非对称加密换"钥匙"

    服务器给客户端一个公钥 ,客户端生成一个随机数 并用公钥加密发回去。只有服务器手里的私钥能解开。

  • 第二阶段:对称加密传"货物"

    一旦双方确认了那个随机数(对话密钥),后续的所有数据传输都用这个密钥进行对称加密

为什么要这么麻烦?

因为非对称加密太慢,只适合用来换"钥匙";对称加密速度飞快,适合传真正的"货物"。


HTTP vs HTTPS 实战区别

维度 HTTP HTTPS
端口 80 443
安全性 明文,不安全 加密,安全
资源消耗 几乎没有额外开销 握手时间略长,加密解密消耗 CPU
证书 免费/无需 需要申请 CA 证书(部分收费)

HTTP响应状态码

类别 含义 你的感受
1xx 信息性状态码 "别急,正在处理中..."
2xx 成功状态码 "好嘞,办成了!"
3xx 重定向状态码 "你找的东西搬家了,去那儿看看。"
4xx 客户端错误状态码 "你自己找找原因(地址写错/没权限)。"
5xx 服务器错误状态码 "我的锅,我代码写崩了。"(服务器处理请求出错)
  1. 200 OK(最开心的代码)
  • 含义:请求成功,你要的东西已经发给你了。

  • 抓包细节 :在你给出的搜狗示例中,你会发现 Content-Length 很大,说明服务器真的传回了大量网页数据。

  • 开发视角:这是你作为 Java 程序员最想看到的数字。

  1. 403 Forbidden(闭门羹)
  • 含义 :服务器理解了你的请求,但拒绝执行

  • 常见原因 :通常是因为权限不足。比如你尝试不登录就去窥探码云(Gitee)的私有仓库。

  • 潜台词:我知道你要干嘛,但我不让你干。

  1. 404 Not Found(迷路了)
  • 含义:服务器找不到你请求的那个 URL。

  • 常见原因:URL 拼写错误,或者那个网页被删掉了。

  • 开发视角 :如果你在 Spring Boot 里写的 @RequestMapping 路径和前端调用的对不上,就会报这个错。

  1. 405:使错劲儿了

405 Method Not Allowed(方法被禁用)

  • 含义:服务器找到了那个 URL,但不支持你用的 HTTP 方法。

  • 细节 :比如你给一个只准 GET 的搜索接口发了一个 POST 请求。

  • 开发视角 :在 Spring Boot 里,如果你代码写的是 @GetMapping,前端却用了 POST 方式调用,后台就会抛出这个异常。这时候别改代码,先让前端检查 axios 的方法对不对。

状态码 含义与细节 开发视角
301 Moved Permanently 永久搬家。旧地址废弃了,浏览器以后会自动直接请求新地址。 比如你的"智购云商"换了新域名,用 301 告诉搜索引擎把权重转过去。
302 Found 临时搬家。这次去新地方,下次还得看旧地址。 最常用场景 :用户没登录,拦截器直接给个 302,强行把页面拽到 /login。(重定向)
状态码 含义与细节 开发视角
500 Internal Server Error 服务器内部错误。这是一个极其泛指的"背锅"代码。 说实话,这就是你代码写 Bug 了 。通常是 Java 抛出了 NullPointerException 或数据库连接超时。赶紧看 IDEA 控制台的红色报错日志。
504 Gateway Timeout 网关超时。上游服务器(比如 Nginx)等你的 Java 后端太久,等得没耐心了。 常见于你的代码在处理大数据(比如导出万级订单)或者死循环了。Nginx 默认等 60 秒,你没回,它就给前端吐个 504。

特殊的状态码418,描述的意思是我是个杯具。没什么实际含义,是设计HTTP协议的大佬,开了个玩笑,也可以说是彩蛋。

如果构造HTTP请求

1)通过代码构造 有一点难度

2)通过第三方工具构造 非常容易

经典的是PostMan

HTTPS学习

HTTPS 也是⼀个应⽤层协议. 是在 HTTP 协议的基础上引⼊了⼀个加密层. HTTP 协议内容都是按照⽂本的⽅式明⽂传输的. 这就导致在传输过程中出现⼀些被篡改的情况

外面的免费wifl,连了就说明你的请求和信息要经过他的路由器,甚至可能是黑客用自己的设备伪造路由器

数据被黑客拿到,但是数据他解析不了,也能起到安全的结果,破解的成本超过保护的数据价值本身就是安全的。

对称加密 的意思是加密和解密用的同一个密钥,非对称加密加密和解密用的两个密钥,这两个密钥是成对的

所以针对https数据解密,就能得到http的数据,主要是针对header和body加密

  1. 对称加密的时候,客户端和服务器需要使用同一个密钥。

  2. 不同的客户端,需要使用不同的密钥。 (如果所有的客户端密钥都相同,加密形同虚设,黑客很容易拿到密钥)

这就意味着,每个客户端连接到服务器的时候,都需要自己生成一个随机的密钥,并且把这个密钥告知服务器。

问题的关键是密钥需要传输给对方,一旦黑客拿到这个密钥,加密就毫无意义了

所以引入非对称加密

  • 使用非对称加密,主要的目的就是为了对对称密钥进行加密,确保对称密钥的安全性~~

  • 不能使用非对称加密,针对后续传输的各种 header body 等进行加密,而是只能使用非对称加密去加密对称密钥~~

  • 非对称加密的加密解密成本(消耗的 CPU 资源)要远远高于对称加密

流程

HTTPS 混合加密流程总结

  1. 准备阶段(服务器端)

服务器生成一对钥匙:公钥 (Public Key)私钥 (Private Key)

  • 公钥:像一张人人都能拿到的"开着的挂锁"。

  • 私钥 :像这把挂锁的"唯一钥匙",永远留在服务器手里,不在网上传输。

  1. 握手阶段(客户端与服务器)

  2. 发锁 :服务器把公钥明文发给客户端。黑客截获了也没用,因为锁是开着的,不能解密。

  3. 定秘 :客户端在本地随机生成一个对称密钥(后续真正用来加密网页数据的钥匙)。

  4. 加密 :客户端用服务器给的公钥 ,把这个对称密钥锁起来,发给服务器。

  5. 解密 :服务器收到加密包,用自己兜里的私钥 打开,拿到了对称密钥

  6. 通信阶段(最终状态)

现在,客户端和服务器手里都拥有了同一个对称密钥,且黑客由于没有私钥,无法从加密包里偷走它。

  • 之后所有的 HTTP 数据(Header、Body、图片等)全部用这个对称密钥进行极速加密传输。

虽然你刚才说的流程逻辑上无懈可击,但现实中黑客还有一个"降维打击"的招数:调包。

  • 黑客的绝招 :在你找服务器要"公钥"时,黑客拦截了请求,把他自己的公钥发给了你。你用黑客的锁锁住了密钥,黑客就能用他的私钥看光你的一切。

这就是"中间人攻击"。为了防止公钥被调包,我们需要一个"数字证书"来证明:"这把锁确实是正版服务器的,有官方公章(CA 机构)"


你可能会问:"黑客难道不能自己搞个假证书发给用户吗?"

  • 没法签名 :黑客没有 CA 机构的私钥,他签出来的名,浏览器用内置的 CA 公钥根本解不开。

  • 没法调包 :如果黑客改了证书里的任何一个字(比如把公钥换成他自己的),数字签名就会失效,浏览器会立刻弹出那个红色的警告:"您的连接不是私密连接"。

(所谓的签名就是经过加密的校验和)

当浏览器收到这个证书时,它会做两件事:

  1. 自己算一遍:浏览器用同样的算法,把证书内容再算一次校验和。

  2. 解开签名看一眼 :浏览器里预装了 CA 机构的公钥。它用公钥去解开证书上的签名。

    • 如果解开了:说明这证书确实是那个 CA 签发的(因为只有它的私钥能签出这个名)。

    • 如果解开后的内容 = 自己算出来的校验和 :说明证书在传输过程中一个字都没被改过

SSL时间

  • 第一步:TCP 三次握手(建立物理连接)

    • 客户端:在吗?

    • 服务器:在的,你呢?

    • 客户端:我也在,路修通了!

  • 第二步:SSL/TLS 握手(就是现在!)

    • 这就是 SSL 开始运行的时刻。在这一步,双方还没开始传任何真正的网页数据,而是在忙着打招呼、看证书、换密钥
  • 第三步:加密的 HTTP 通信

    • 密钥换完了,SSL 就像一个"透明的加密罩",后续所有的 HTTP 报头和正文都会自动穿过这个罩子进行加解密。
相关推荐
算.子2 小时前
【Spring 实战】Spring AI 进阶专题:Token 成本优化与 Structured Output
java·人工智能·spring
航Hang*2 小时前
Windows Server 配置与管理——第9章:配置DHCP服务器
运维·服务器·windows·学习
Gopher_HBo2 小时前
ReentrantReadWriteLock源码讲解
java·后端
雾喔2 小时前
【学习笔记1】AI 基础概念:机器学习、深度学习、大语言模型的区别
人工智能·学习·机器学习
农村小镇哥2 小时前
PHP数据传输流+上传条件+上传步骤
java·开发语言·php
wuxinyan1232 小时前
Java面试题48:一文深入了解java设计模式
java·设计模式·面试
济源IT小伙一枚2 小时前
⚡️硬核实战:Spring AI + Ollama 从零搭建私有化多角色 AI 助手|RAG 知识库 + MCP 控制台全实现
java·人工智能·spring
北京耐用通信2 小时前
自动化行业异构集成实践:耐达讯自动化实现CAN转EtherCAT高效互操作
人工智能·科技·网络协议·自动化·信息与通信
李少兄2 小时前
Windows 安装 Maven 详细教程(含镜像与本地仓库配置)
java·windows·maven