微调bert大模型

1. setup enviroment

2. prepare dataset

ruby 复制代码
class IMDbDataset(torch.utils.data.Dataset): def __init__(self, encodings, labels): self.encodings = encodings self.labels = labels def __getitem__(self, idx): item = {key: torch.tensor(val[idx]) for key, val in self.encodings.items()} item["labels"] = torch.tensor(self.labels[idx]) return item def __len__(self): return len(self.labels) train_dataset = IMDbDataset(train_encodings, train_labels)

3.Tokenize the data

ini 复制代码
encodings = tokenizer(
    list_of_questions,  # 要处理的文本列表
    truncation=True,    # 超长截断
    padding=True,       # 不足补零
    max_length=128,     # 最大长度
    return_tensors="pt" # 返回PyTorch张量
)

1. 核心作用

把原始文本(如["How does BERT work?", "什么是机器学习?"])转换成BERT能理解的数字格式,包含:

  • input_ids :文本→数字编号(如[101, 1731, 1452, 102])
  • attention_mask:标记哪些是真实词(1)、哪些是填充的0(0)
  • token_type_ids(可选):区分句子A/B(对问答任务有用)

2. 参数详解(附比喻)

参数 作用 比喻
list_of_questions 要处理的文本列表(如["问题1", "问题2"] 就像待翻译的外语句子列表
truncation=True 超过max_length时自动截断 像剪刀裁掉过长的句子(保留前128个词)
padding=True 短于max_length时补零([PAD]) 像用空白填空使所有句子长度相同
max_length=128 每条文本最大词数(BERT通常≤512) 规定所有句子翻译后最多128个单词
return_tensors="pt" 返回PyTorch张量(不是Python列表) 要求返回"PyTorch专用格式"的数据

3. 输出示例

假设输入:["How old are you?", "你好"]

yaml 复制代码
python
{
    'input_ids':        # 文本的数字编码
        tensor([
            [101, 1731, 1385, 1132, 1128, 102, 0, ..., 0],  # "How old are you?"
            [101, 6821, 102, 0, 0, ..., 0]                   # "你好"
        ]),
    'attention_mask':   # 标识哪些是有效词
        tensor([
            [1, 1, 1, 1, 1, 1, 0, ..., 0],  # 前6个是真实词,后面是填充的0
            [1, 1, 1, 0, 0, ..., 0]
        ])
}

4. 为什么需要这些处理?

  • BERT的固定输入长度:模型需要统一尺寸的输入(像电梯限载10人,不足补空,超载要裁)
  • 注意力机制依赖结构attention_mask告诉模型哪些位置需要计算注意力
  • GPU计算优化 :张量格式(return_tensors="pt")能加速GPU并行计算

5. 常见问题

Q1:max_length设多大合适?

  • 一般设为你数据中大多数文本的长度(可通过统计得出)
  • BERT最大支持512,但越长消耗显存越多

Q2:padding补零会影响模型吗?

  • 不会!因为有attention_mask告诉模型忽略这些填充位置

Q3:截断会丢失信息吗?

  • 可能丢失长文本尾部信息,可通过以下方法缓解:

    ini 复制代码
    python
    tokenizer(..., truncation="longest_first")  # 优先截断长句子
    # 或
    tokenizer(..., stride=64, return_overflowing_tokens=True)  # 滑动窗口处理超长文本

6. 完整流程图示

css 复制代码
原始文本 → Tokenizer分词 → 添加[CLS][SEP] → 截断/填充 → 转数字ID → 输出张量
           │              │               │             │
           ↓              ↓               ↓             ↓
        "你好" → ["[CLS]", "你", "好", "[SEP]"] → [101, 6821, 102] → 补零到128长度

只要记住:Tokenizer就像翻译官+标准化工厂,把乱七八糟的文本变成整齐划一的数字矩阵!

4.Create a Custom Dataset

在PyTorch中创建自定义Dataset类来处理BERT问答(QA)或分类任务数据是一个关键步骤。下面我将详细解释如何创建一个QADataset类,并解释每一部分的作用。

完整代码实现

python 复制代码
python
from torch.utils.data import Dataset

class QADataset(Dataset):
    def __init__(self, encodings, labels):
        """
        初始化数据集
        :param encodings: 包含input_ids, attention_mask等的字典
        :param labels: 样本对应的标签
        """
        self.encodings = encodings
        self.labels = labels
    
    def __len__(self):
        """
        返回数据集中的样本数量
        """
        return len(self.labels)
    
    def __getitem__(self, idx):
        """
        获取单个样本
        :param idx: 样本索引
        :return: 包含编码和标签的字典
        """
        item = {key: val[idx] for key, val in self.encodings.items()}
        item['labels'] = self.labels[idx]
        return item

代码逐步解释

1. 继承Dataset基类

kotlin 复制代码
python
from torch.utils.data import Dataset
class QADataset(Dataset):
  • Dataset是PyTorch提供的抽象基类
  • 继承它后必须实现__len____getitem__方法

2. __init__方法

ruby 复制代码
python
def __init__(self, encodings, labels):
    self.encodings = encodings
    self.labels = labels
  • 接收两个参数:

    • encodings: 包含BERT输入特征的字典,通常有:

      • input_ids: 分词后的token IDs
      • attention_mask: 注意力掩码
      • token_type_ids: 区分句子A/B的标记(对于QA任务)
    • labels: 样本标签(分类任务)或答案位置(QA任务)

3. __len__方法

python 复制代码
python
def __len__(self):
    return len(self.labels)
  • 返回数据集中的样本总数
  • PyTorch的DataLoader会调用这个方法确定batch数量

4. __getitem__方法

ruby 复制代码
python
def __getitem__(self, idx):
    item = {key: val[idx] for key, val in self.encodings.items()}
    item['labels'] = self.labels[idx]
    return item
  • 根据索引idx返回单个样本

  • 将encodings中的每个特征在idx位置的值取出

  • 添加对应的标签

  • 返回格式示例:

    css 复制代码
    python
    {
        'input_ids': tensor([101, 1731, 1452, 102]),
        'attention_mask': tensor([1, 1, 1, 1]),
        'token_type_ids': tensor([0, 0, 0, 0]),
        'labels': 1  # 或对于QA任务可能是[start_pos, end_pos]
    }

如何使用这个Dataset类

1. 准备数据

ini 复制代码
python
from transformers import BertTokenizer

tokenizer = BertTokenizer.from_pretrained('bert-base-uncased')

# 示例数据
questions = ["What is BERT?", "How does attention work?"]
answers = ["A language model", "It weights input tokens"]

# 对问题和答案进行编码
encodings = tokenizer(questions, answers, truncation=True, 
                     padding='max_length', max_length=128, 
                     return_tensors='pt')

# 假设是分类任务,创建虚拟标签
labels = torch.tensor([1, 0])  # 1表示正例,0表示负例

2. 创建Dataset实例

ini 复制代码
python
dataset = QADataset(encodings, labels)

3. 创建DataLoader

ini 复制代码
python
from torch.utils.data import DataLoader

dataloader = DataLoader(dataset, batch_size=2, shuffle=True)

4. 在训练循环中使用

ini 复制代码
python
for batch in dataloader:
    input_ids = batch['input_ids']
    attention_mask = batch['attention_mask']
    labels = batch['labels']
    
    # 将这些输入传递给BERT模型
    outputs = model(input_ids, attention_mask=attention_mask, labels=labels)
    loss = outputs.loss
    loss.backward()
    # ...训练步骤...

针对QA任务的调整

对于问答任务,通常需要修改__init____getitem__来处理答案位置:

python 复制代码
python
class QADataset(Dataset):
    def __init__(self, encodings):
        """QA任务可能不需要显式labels参数"""
        self.encodings = encodings
    
    def __getitem__(self, idx):
        return {key: val[idx] for key, val in self.encodings.items()}

然后编码时包含答案位置信息:

ini 复制代码
python
encodings = tokenizer(questions, answers, truncation=True, padding=True, 
                     return_tensors='pt', return_offsets_mapping=True)

# 添加答案的start/end位置
encodings['start_positions'] = ...
encodings['end_positions'] = ...

总结

  1. 自定义Dataset类封装了数据加载逻辑
  2. 必须实现__len____getitem__方法
  3. 与BERT Tokenizer的输出格式配合使用
  4. 可以通过调整来适应分类或QA等不同任务

这种模式使数据加载与模型训练代码分离,提高了代码的可维护性和可复用性。

这个代码的作用是 加载一个预训练的BERT模型,并为其添加一个分类层,使其能够执行特定类别数量的文本分类任务。下面我会用通俗易懂的方式解释它的具体作用和实际应用场景:


🎯 核心作用

ini 复制代码
python
model = BertForSequenceClassification.from_pretrained(
    "bert-base-uncased", 
    num_labels=5
)
  1. 加载预训练知识

    • 从Hugging Face模型库下载bert-base-uncased(一个通用的英语BERT模型),这个模型已经通过海量文本学习了语言理解能力。
    • 相当于你拿到一个"读过千万本书的AI大脑"。
  2. 适配分类任务

    • 通过num_labels=5,在BERT的原始输出层上新增一个分类器,将BERT输出的768维向量转换为5个类别的概率(比如五星评价分类、情感五分类等)。
    • 相当于给AI大脑接上一个"答题卡扫描仪",让它能回答你的具体问题。

🛠️ 实际应用场景

场景示例 num_labels 用途
情感分析 2 (正面/负面) 判断评论是好评还是差评
五星评分 5 预测用户会打1-5星中的哪一星
新闻分类 10 将新闻归类到体育/科技/政治等10个类别
意图识别 8 判断用户提问是咨询/投诉/售后等8种意图

🔧 代码细节拆解

1. 模型选择 "bert-base-uncased"
  • bert-base: 基础版模型(12层Transformer,768隐藏层维度)

  • uncased: 不区分大小写(会把"Apple"和"apple"视为相同)

  • 其他常用选项:

    bash 复制代码
    python
    "bert-large-uncased"  # 更大模型(24层,1024隐藏层)
    "bert-base-chinese"   # 中文BERT
2. 分类头改造 num_labels=5
  • 自动添加的结构

    scss 复制代码
    BERT原始输出 → Dropout层 → 线性层(768输入 → 5输出)
  • 如果是二分类,输出形状就是(batch_size, 2),每行两个数表示两类概率(如[0.2, 0.8])

3. 与普通BERT的区别
ini 复制代码
python
# 普通BERT(仅特征提取)
from transformers import BertModel
base_model = BertModel.from_pretrained("bert-base-uncased")  # 输出768维向量

# 分类专用BERT(带任务接口)
from transformers import BertForSequenceClassification
cls_model = BertForSequenceClassification.from_pretrained(...)  # 直接输出类别概率

📌 关键注意事项

  1. 必须微调(fine-tune)

    预训练BERT只是"知识库",需要用你的标注数据训练分类层(甚至全部参数),例如:

    ini 复制代码
    python
    from transformers import Trainer, TrainingArguments
    
    trainer = Trainer(
        model=model,
        args=TrainingArguments(output_dir="./results"),
        train_dataset=train_data
    )
    trainer.train()
  2. 输入数据需要匹配

    输入文本必须用相同的tokenizer处理:

    ini 复制代码
    python
    from transformers import BertTokenizer
    tokenizer = BertTokenizer.from_pretrained("bert-base-uncased")
    inputs = tokenizer("Your text here", return_tensors="pt")
  3. GPU加速建议

    如果可用GPU,运行:

    bash 复制代码
    python
    model.to("cuda")  # 将模型转移到GPU

🌰 举个栗子:餐饮评论分类

假设你想建立一个模型,自动判断食评属于以下哪类:

css 复制代码
python
类别映射 = {0: "差评", 1: "一般", 2: "好评", 3: "强烈推荐", 4: "完美"}

只需:

  1. 准备标注数据(如5000条带标签的食评)
  2. 用上述代码加载num_labels=5的模型
  3. 在数据上微调后,模型就能预测新评论的类别!

总结来说,这行代码是将通用的BERT模型改造成适合你具体任务的分类工具的起点。就像给瑞士军刀安装一个专用刀头,让它能完美解决你的特定问题。

6。handle class imbalance

处理类别不平衡:在损失函数中使用类别权重

在文本分类任务中,经常会遇到类别不平衡的问题(某些类别的样本数量远多于其他类别)。这会导致模型偏向多数类,影响对少数类的识别能力。使用类别权重(class weights)是解决这个问题的有效方法之一。

代码解释

ini 复制代码
python
from sklearn.utils.class_weight import compute_class_weight

# 1. 计算类别权重
class_weights = compute_class_weight(
    "balanced", 
    classes=range(num_classes), 
    y=train_labels
)

# 2. 转换为PyTorch张量并移至设备(GPU/CPU)
weights_tensor = torch.tensor(class_weights, dtype=torch.float).to(device)

参数说明:

  • "balanced":自动计算权重,使各类别的权重与其频率成反比
  • classes=range(num_classes):所有可能的类别标签(如0,1,2,3,4)
  • y=train_labels:训练集中的真实标签列表

计算原理:

对于每个类别c,其权重计算为:

ini 复制代码
weight_c = 总样本数 / (类别数 * 类别c的样本数)

使用方法

1. 在损失函数中应用权重

ini 复制代码
python
import torch.nn as nn

# 创建带权重的交叉熵损失函数
criterion = nn.CrossEntropyLoss(weight=weights_tensor)

# 在训练循环中使用
loss = criterion(outputs, labels)

2. 完整示例

ini 复制代码
python
from transformers import BertForSequenceClassification, Trainer, TrainingArguments
from sklearn.utils.class_weight import compute_class_weight
import numpy as np
import torch

# 假设我们有5个类别和训练标签
num_classes = 5
train_labels = [0, 1, 1, 2, 2, 2, 3, 4]  # 示例标签(明显不平衡)

# 计算类别权重
class_weights = compute_class_weight(
    "balanced",
    classes=np.unique(train_labels),
    y=train_labels
)
weights_tensor = torch.tensor(class_weights, dtype=torch.float)

# 自定义带权重的Trainer
class WeightedTrainer(Trainer):
    def compute_loss(self, model, inputs, return_outputs=False):
        labels = inputs.pop("labels")
        outputs = model(**inputs)
        loss = torch.nn.functional.cross_entropy(
            outputs.logits, 
            labels, 
            weight=weights_tensor.to(outputs.logits.device)
        )
        return (loss, outputs) if return_outputs else loss

# 初始化模型和训练参数
model = BertForSequenceClassification.from_pretrained("bert-base-uncased", num_labels=num_classes)
training_args = TrainingArguments(output_dir="./results")

# 创建并运行训练器
trainer = WeightedTrainer(
    model=model,
    args=training_args,
    train_dataset=train_dataset,
    eval_dataset=eval_dataset
)
trainer.train()

注意事项

  1. 权重计算时机:应在划分训练/验证集后,仅基于训练集标签计算权重

  2. 极端不平衡情况:对于极度不平衡的数据(如1:100),可能需要结合其他技术:

    • 过采样少数类(如SMOTE)
    • 欠采样多数类
    • 分层抽样确保每批次的类别平衡
  3. 验证集评估:即使训练时使用权重,验证指标仍应关注原始分布或使用宏观平均

  4. 多标签任务 :对于多标签分类,需要使用BCEWithLogitsLoss并设置pos_weight

  5. 权重调整:可以手动调整权重,给特别重要的类别更高权重

通过这种方法,模型在训练时会更加关注样本较少的类别,从而改善整体分类性能,特别是在需要平衡召回率和精确度的场景中。

通俗解释:用"班级投票"理解类别权重

想象你是一个老师,班上要评选"最受欢迎班干部",有5个职位候选(班长、学习委员、文体委员、生活委员、纪律委员),但学生们投票时出现了问题:

问题情景

  • 班长有40个支持者(全班80人里占一半)
  • 学习委员有20个支持者
  • 其他三个委员各自只有5-6个支持者

如果直接按票数决定,班长肯定每次都赢,其他委员永远没机会------这就是类别不平衡问题。

解决办法:给少数派"加分"

你作为老师,决定这样调整规则:

  1. 计算权重:给票数少的职位额外加分

    • 班长票数多 → 每票算0.5分
    • 学习委员 → 每票算1分
    • 其他委员 → 每票算2分(因为支持者最少)
  2. 重新计票

    • 班长:40票 × 0.5 = 20分
    • 学习委员:20票 × 1 = 20分
    • 文体委员:6票 × 2 = 12分
    • (其他委员同理)

这样虽然班长原始票数最多,但经过加权后和学习委员持平,小职位也有机会被看到。

对应到AI模型

  1. BERT就像全班同学:默认情况下会倾向于选择数据量大的类别(就像同学都投熟人)

  2. 类别权重就是加分规则

    • 让模型在训练时更关注样本少的类别
    • 防止模型"偷懒"只学多数类
  3. 效果:就像公平的选举结果,模型会对所有类别都保持敏感

就像老师调节投票规则保证公平性,我们调节损失函数的权重来保证模型对所有类别都"一视同仁"。

7.Defined Training Parameters

通俗解释:定义训练参数(Define Training Parameters)的作用

简单来说,这一步就是告诉计算机:
"你要用什么样的方式和节奏来训练这个AI模型?"


🔍 为什么需要定义训练参数?

想象你在教一个学生做数学题:

  • 学多久? → 训练多少轮(epochs)
  • 一次做几道题? → 每次喂多少数据(batch size)
  • 学多快? → 学习率(learning rate)
  • 要不要记笔记? → 是否保存检查点(save_steps)
  • 在哪里考试? → 验证集评估(evaluation_strategy)

这些设置直接影响模型的学习效果训练速度


📝 常见的训练参数(以Hugging Face的TrainingArguments为例)

ini 复制代码
python
from transformers import TrainingArguments

training_args = TrainingArguments(
    output_dir="./results",          # 训练结果保存路径(像作业本放哪)
    num_train_epochs=3,             # 训练3轮(把数据集学3遍)
    per_device_train_batch_size=8,  # 每次用8条数据训练(一次做8道题)
    per_device_eval_batch_size=16,  # 验证时每次用16条数据
    learning_rate=2e-5,            # 学习速率(小步慢走 vs 大步快跑)
    evaluation_strategy="epoch",    # 每轮结束后考一次试(验证)
    save_strategy="epoch",         # 每轮结束后保存一次模型
    logging_steps=100,             # 每100步打印一次日志
    load_best_model_at_end=True,   # 训练完后自动加载效果最好的模型
)

💡 关键参数的作用

参数名 作用类比 典型值
num_train_epochs 把教材反复学几遍 3-5(太多会死记硬背)
batch_size 一次做多少题(太大显存爆炸,太小速度慢) 8/16/32
learning_rate 调整参数时的步幅(太大错过答案,太小学得慢) 2e-5 到 5e-5
evaluation_strategy 什么时候检查学习效果(随时考 or 学完一章考) "steps"或"epoch"
save_steps 隔多久保存一次进度(防崩溃丢失) 500或"epoch"

⚠️ 注意事项

  1. 学习率(LR)

    • BERT通常用很小的LR(如2e-5),因为预训练模型已经很接近目标,只需微调。
    • 太大容易"学歪",太小训练慢。
  2. Batch Size

    • 取决于你的GPU显存(8GB显存建议≤16)。
  3. Epochs

    • 太多会导致过拟合(像学生死记硬背,但不会举一反三)。
  4. 验证集监控

    • load_best_model_at_end可以自动保存效果最好的版本。

8 Evaluation and Tune

简单来说:
"考试 + 改错题 + 调整学习方法"

就像老师教学生做题:

  1. 考试(Evaluate) → 用测试集看看模型学得怎么样
  2. 改错题(Analyze) → 分析哪些题错得多(比如某个类别总错)
  3. 调整学习方法(Tune) → 改变学习策略(比如学慢点、多练错题)

🔍 详细拆解

1️⃣ 评估(Evaluate)------ "考完试批卷子"

目的 :检查模型在没见过的数据(测试集)上表现如何。
关键指标

  • 准确率(Accuracy) :整体对了多少题
  • F1分数(类别不平衡时更重要)
  • 混淆矩阵:哪些类别容易混淆(比如猫狗分不清)

代码示例

ini 复制代码
python
from sklearn.metrics import accuracy_score, f1_score

# 模型预测测试集
predictions = model.predict(test_encodings)
pred_labels = predictions.logits.argmax(dim=1)

# 计算准确率和F1
accuracy = accuracy_score(test_labels, pred_labels)
f1 = f1_score(test_labels, pred_labels, average="weighted")
print(f"准确率: {accuracy:.2f}, F1分数: {f1:.2f}")

2️⃣ 调优(Tune)------ "改学习方法"

如果考得不好,可能是以下问题:

问题 可能原因 调优方法
准确率低 学习不够/学过头 增加epochs / 减小学习率
某些类别总错 数据不平衡 加类别权重 / 过采样少数类
训练集好但测试集差 过拟合(死记硬背) 加Dropout / 早停 / 数据增强
训练速度太慢 Batch Size太小 / 模型太大 增大Batch Size / 换轻量模型

常见调优操作

A. 调整超参数
ini 复制代码
python
training_args = TrainingArguments(
    learning_rate=5e-5,  # 原先是2e-5,试试加大
    per_device_train_batch_size=32,  # 原先16,试试翻倍
    num_train_epochs=5,   # 原先3轮,加2轮
)
B. 处理数据不平衡
ini 复制代码
python
# 方法1:加类别权重(少数类错误惩罚更大)
loss_fct = torch.nn.CrossEntropyLoss(weight=class_weights)

# 方法2:过采样少数类(复制数据)
from imblearn.over_sampling import RandomOverSampler
ros = RandomOverSampler()
X_resampled, y_resampled = ros.fit_resample(train_texts, train_labels)
C. 防过拟合
ini 复制代码
python
# 加Dropout(让模型别太依赖某些特征)
model = BertForSequenceClassification.from_pretrained(
    "bert-base-uncased",
    num_labels=5,
    hidden_dropout_prob=0.2,  # 默认0.1,调高
)

3️⃣ 自动化调优工具

如果手动调太麻烦,可以用工具自动搜索最佳参数:

Optuna(超参数搜索)
ini 复制代码
python
import optuna

def objective(trial):
    lr = trial.suggest_float("learning_rate", 1e-5, 5e-5)
    batch_size = trial.suggest_categorical("batch_size", [16, 32, 64])
    
    training_args = TrainingArguments(
        learning_rate=lr,
        per_device_train_batch_size=batch_size,
        ...
    )
    trainer = Trainer(model, args=training_args, ...)
    trainer.train()
    return trainer.evaluate()["eval_f1"]  # 以F1分数为目标优化

study = optuna.create_study(direction="maximize")
study.optimize(objective, n_trials=10)  # 尝试10组参数
print("最佳参数:", study.best_params)

🌰 举个实际例子

任务 :电商评论分类(好评/中评/差评)
问题 :模型总是把"中评"预测成"好评"
调优步骤

  1. 评估发现:中评的F1只有0.5(好评0.9,差评0.8)

  2. 分析原因:中评数据只有好评的1/3

  3. 调优操作

    • 给中评加3倍权重
    • 增加中评的过采样数据
    • 降低学习率(从2e-5 → 1e-5)
  4. 结果:中评F1提升到0.7,整体更平衡


✅ 总结

  • 评估(Evaluate) → 定期考试,避免闭门造车

  • 调优(Tune) → 针对性改进,像老师因材施教

  • 核心原则

    • 数据问题 → 加数据/改权重
    • 模型问题 → 调参数/改结构
    • 效率问题 → 改Batch Size/换硬件

最终目标:让模型像学霸一样,不偏科、不死记、高效学习! 🚀

相关推荐
小蒜学长4 分钟前
springboot基于javaweb的小零食销售系统的设计与实现(代码+数据库+LW)
java·开发语言·数据库·spring boot·后端
brzhang13 分钟前
为什么 OpenAI 不让 LLM 生成 UI?深度解析 OpenAI Apps SDK 背后的新一代交互范式
前端·后端·架构
EnCi Zheng30 分钟前
JPA 连接 PostgreSQL 数据库完全指南
java·数据库·spring boot·后端·postgresql
brzhang36 分钟前
OpenAI Apps SDK ,一个好的 App,不是让用户知道它该怎么用,而是让用户自然地知道自己在做什么。
前端·后端·架构
LucianaiB1 小时前
从玩具到工业:基于 CodeBuddy code CLI 构建电力变压器绕组短路智能诊断系统
后端
武子康2 小时前
大数据-118 - Flink 批处理 DataSet API 全面解析:应用场景、代码示例与优化机制
大数据·后端·flink
不要再敲了2 小时前
Spring Security 完整使用指南
java·后端·spring
IT_陈寒2 小时前
Redis 高性能缓存设计:7个核心优化策略让你的QPS提升300%
前端·人工智能·后端
brzhang3 小时前
高通把Arduino买了,你的“小破板”要变“AI核弹”了?
前端·后端·架构
程序猿阿越4 小时前
Kafka源码(六)消费者消费
java·后端·源码阅读