第4章:文件存储优化:大文件读写策略与序列化方案

说到文件存储优化,我脑子里立刻浮现出几年前的一个深夜。那天线上反馈说用户上传的日志文件超过100MB时,App直接卡死。我盯着崩溃堆栈看了半天,最后发现是传统的FileInputStream在作祟。嗯,今天我们就来聊聊大文件读写那些事儿。

4.1 大文件读写:NIO vs MappedByteBuffer

传统的Java IO(BIO)处理大文件时,说白了就是「一次读一点,慢慢搬砖」。数据从磁盘到内核空间,再从内核空间拷贝到用户空间,中间还涉及多次上下文切换。文件一大,效率就直线下降。

我个人习惯把NIO和MappedByteBuffer看作两种不同的「搬砖策略」:

方案 核心原理 适用场景 内存占用
传统BIO 逐字节/逐行读取 小文件(<1MB)
NIO Channel 使用Buffer批量传输 中等文件(1MB~100MB) 可控
MappedByteBuffer 内存映射文件 超大文件(>100MB) 虚拟内存映射

4.1.1 NIO Channel 实战

NIO的核心是Channel + Buffer。数据不再逐字节搬运,而是整块整块地搬。你想想看,这就像用卡车运货,而不是用自行车一趟趟跑。

// 使用FileChannel读取大文件
try (FileChannel channel = new FileInputStream(filePath).getChannel()) {
    ByteBuffer buffer = ByteBuffer.allocate(8192); // 8KB缓冲区
    while (channel.read(buffer) != -1) {
        buffer.flip(); // 切换为读模式
        // 处理buffer中的数据
        byte[] data = new byte[buffer.remaining()];
        buffer.get(data);
        // ... 业务处理
        buffer.clear(); // 清空准备下一次读取
    }
}
我的经验:缓冲区大小不是越大越好。我试过分配1MB的缓冲区,结果GC压力反而上来了。8KB~64KB是个比较稳妥的范围,具体要看你的文件读写频率。

4.1.2 MappedByteBuffer 的正确姿势

MappedByteBuffer把文件直接映射到虚拟内存。说白了,就是让操作系统帮你管理「什么时候读、什么时候写」。读取时就像访问内存数组一样快。

// MappedByteBuffer示例
try (RandomAccessFile file = new RandomAccessFile(filePath, "rw");
     FileChannel channel = file.getChannel()) {
    
    MappedByteBuffer mappedBuffer = channel.map(
        FileChannel.MapMode.READ_WRITE, 
        0, 
        file.length()
    );
    
    // 直接像操作数组一样读写
    while (mappedBuffer.hasRemaining()) {
        byte b = mappedBuffer.get();
        // 处理数据
    }
}
避坑指南:我曾经在项目里用MappedByteBuffer处理2GB的日志文件,结果忘了调用clean()方法释放映射。后果就是——文件一直被占用,无法删除,直到进程结束。切记:用完一定要手动释放!

4.2 缓存策略:LRU Cache 实战

缓存策略说白了就是「用空间换时间」。但Android设备的内存是有限的,你不能无限制地缓存。LRU(Least Recently Used)是我最常用的策略——淘汰最久没被访问的数据。

Android官方提供了LruCache,但它的实现是基于LinkedHashMap的。我建议你直接使用它,而不是自己造轮子。

// 自定义LRU缓存示例
public class FileCache {
    private final LruCache<String, byte[]> cache;
    
    public FileCache(int maxSize) {
        // maxSize单位:字节
        this.cache = new LruCache<String, byte[]>(maxSize) {
            @Override
            protected int sizeOf(String key, byte[] value) {
                return value.length; // 按实际大小计算
            }
        };
    }
    
    public void put(String key, byte[] data) {
        if (data.length > cache.maxSize() / 2) {
            // 单个文件超过缓存一半大小,不缓存
            return;
        }
        cache.put(key, data);
    }
    
    public byte[] get(String key) {
        return cache.get(key);
    }
}
关键点:缓存大小怎么定?我一般取当前可用内存的1/8。太大容易OOM,太小又没效果。你可以通过Runtime.getRuntime().maxMemory()来获取。

4.3 序列化方案对比:Serializable vs Parcelable vs Protobuf

序列化这个话题,我踩过的坑真不少。早期项目用Serializable,结果用户反馈「保存数据好慢」。后来换成Parcelable,速度快了但代码量上去了。再后来接触了Protobuf,才找到平衡点。

方案 性能 代码量 跨平台 适用场景
Serializable 慢(反射机制) 少(只需实现接口) 仅Java 简单对象、低频操作
Parcelable 快(手动编码) 多(需写模板代码) 仅Android Intent传值、跨进程通信
Protobuf 极快(二进制协议) 中等(需定义.proto文件) 全平台 网络传输、持久化存储

4.3.1 Serializable 的坑

Serializable用起来确实方便,但性能是硬伤。每次序列化都要通过反射获取类的元数据,对象图越复杂越慢。我测过,一个包含10个字段的对象,Serializable比Parcelable慢5倍以上。

我曾经踩过的坑:用Serializable保存用户配置,结果类结构一变更(比如新增一个字段),反序列化就抛InvalidClassException。后来我加了serialVersionUID,才算稳定下来。

4.3.2 Parcelable 的正确写法

Parcelable是Android推荐的序列化方式。虽然代码量多,但性能确实好。我建议你使用Android Studio的插件自动生成模板代码,别手写,容易出错。

// Parcelable示例
public class UserInfo implements Parcelable {
    private String name;
    private int age;
    
    protected UserInfo(Parcel in) {
        name = in.readString();
        age = in.readInt();
    }
    
    @Override
    public void writeToParcel(Parcel dest, int flags) {
        dest.writeString(name);
        dest.writeInt(age);
    }
    
    public static final Creator<UserInfo> CREATOR = new Creator<UserInfo>() {
        @Override
        public UserInfo createFromParcel(Parcel in) {
            return new UserInfo(in);
        }
        @Override
        public UserInfo[] newArray(int size) {
            return new UserInfo[size];
        }
    };
}

4.3.3 Protobuf 的进阶用法

Protobuf是我现在最常用的序列化方案。它的数据体积比JSON小3~10倍,解析速度也快得多。你想想看,同样的数据,Protobuf用二进制编码,JSON用文本编码,效率差距自然就出来了。

// Protobuf定义示例(.proto文件)
message UserInfo {
    required string name = 1;
    required int32 age = 2;
    optional string email = 3;
    repeated string tags = 4;
}

// 使用示例
UserInfoOuterClass.UserInfo user = UserInfoOuterClass.UserInfo.newBuilder()
    .setName("张三")
    .setAge(28)
    .addTags("Android")
    .addTags("Kotlin")
    .build();

// 序列化
byte[] data = user.toByteArray();
// 反序列化
UserInfoOuterClass.UserInfo parsed = UserInfoOuterClass.UserInfo.parseFrom(data);
我的建议:如果你的App需要和服务器通信,或者需要跨平台共享数据,直接上Protobuf。虽然前期需要定义.proto文件,但后期维护成本极低。我现在的项目,所有网络请求和本地缓存都用Protobuf,再也没为序列化问题头疼过。

4.4 本章小结

文件存储优化没有银弹。大文件读写用MappedByteBuffer,中等文件用NIO Channel,小文件用传统IO就行。缓存策略用LRU,注意控制内存上限。序列化方案,我个人推荐Protobuf,但如果你只在Android内部传递数据,Parcelable也够用。

记住一点:优化不是炫技,而是找到最适合你场景的方案。下次遇到文件读写慢的问题,先想想数据有多大、频率有多高,再选方案。嗯,这样就不会走弯路了。