qinghuan
← 回到知识探索

后端现场 / 可下载小项目

ByteBuffer 已经设为只读,里面的字节为什么还会变?

对照共享视图、独立游标、slice 范围与真正复制出来的快照,查清“只读”限制的是哪条写入路径。

展开本文目录
  1. 下载完整练习包
  2. 先动手看看
  3. 同一份字节,可以有两个不同的读取位置
  4. 只读限制写操作,不能保证内容永远不变
  5. limit 不是永久封口,clear 也没有擦掉内容
  6. 需要稳定内容时,复制字节,再只读暴露新存储
  7. 运行完整程序:两套 JDK 各通过 19 项检查
  8. 按三条线看结果:内容、游标、可见范围
  9. 本例没有证明跨线程读取安全,也不讨论多字节字节序
  10. 记住这三点
  11. 别把例子用过头
  12. 自己试一小步
  13. 查原始资料
开始前,会这些就够了
遇到不熟悉的词,先看这 2 个解释
只读视图
不能通过这个对象改字节,但仍能看见原存储的变化。
游标与窗口
position 是下次相对读取位置,limit 是当前结束位置,capacity 是对象能容纳的总范围。
打开完整术语小抄 ↗

把 ByteBuffer.asReadOnlyBuffer() 的返回值交给下游,下游不能 put。可是上游改了原数组以后,下游再次 get,读到的仍是新值。

这不是只读开关失效。只读视图拦住的是通过这个视图修改内容;它没有替你复制底层字节。实验会把内容共享与读取位置分开观察。

把例子带到自己的电脑上

下载完整练习包

需要 完整 JDK 17 或更新版本;实测 Windows / JDK 17.0.20.1 / 21.0.12。使用标准库,不需要安装第三方包。下载 ZIP 后选择“全部提取”,进入能看到 README.md 的文件夹。

下载 ZIP · 5 个文件
包里有什么
  • Main.java:完整可运行源码
  • README.md:运行步骤与练习
  • observed.stdout.txt:本次实际输出
  • verification.json:源码和运行验证记录
  • SHA256SUMS.txt:文件摘要

在这个文件夹打开 PowerShell,逐行运行

java -version
javac -version
javac -encoding UTF-8 --release 17 Main.java
java -cp . Main

解压后,在含 Main.java 的目录打开终端。命令分别回车执行。heap ByteBuffer 单线程、每 JDK 19 项检查;未测 direct 或映射缓冲区、并发可见性、多字节字节序或性能。

找不到命令或文件时,先按 README 检查环境和所在目录。文中标出的阅读时间只估算文章篇幅,安装环境和动手练习可按自己的节奏进行。

只读视图和独立副本,谁会跟着变?

正在准备实验控件…

先改原缓冲区的第一个字节,再从只读视图读取。观察之前复制的数组是否也变化。

原缓冲区102030
只读视图看到的内容102030
独立副本102030
原缓冲区 position
0
只读视图 position
0

只读视图与原缓冲区指向同一份三个字节;副本在开始时复制。

浏览器用三个整数示意共享字节与独立游标;不是 ByteBuffer 的完整实现。这里省略 limit、mark、字节序和并发访问。下方 Java 程序验证真实只读视图、切片和复制行为。

同一份字节,可以有两个不同的读取位置

实验从 [10,20,30,40] 开始,owner 的 position=1、limit=3,当前可读区间是下标 1 和 2。asReadOnlyBuffer 得到的新视图起初有相同的 position、limit 和 capacity。

只读视图执行一次相对 get(),读出 20,自己的 position 变为 2;owner 的 position 仍为 1。两个对象管理各自的读取进度。

随后通过 owner.put(1,99) 改一个字节,再通过原数组 storage[2]=88 改另一个字节。只读视图按绝对下标读取时看到 99 和 88。不同游标没有带来两份独立内容。

只读限制写操作,不能保证内容永远不变

readOnly.put(...) 和 readOnly.compact() 都抛 ReadOnlyBufferException。前者直接写字节,后者要移动内容,因此也属于写入内容的操作。

原数组仍保存在程序里,owner 也仍是可写缓冲区。这两条写路径没有被创建只读视图的动作关闭。要判断值是否稳定,必须追踪谁还持有这些引用。

只读视图的 hasArray() 为 false,不能通过它直接拿到可写 backing array;本例调用它的 array() 会抛异常。但已经持有 storage 的代码仍能修改同一底层数组。

limit 不是永久封口,clear 也没有擦掉内容

Buffer 的只读性约束内容修改,不冻结 position 和 limit。对 readOnly 调用 clear() 后,position 变为 0,limit 回到 capacity,也就是 4。原来没有包含在 limit=3 范围内的末尾 40 又可以读取。

clear 这个名字容易让人误以为字节被清零;实际输出仍是 [10,99,88,40]。它在这里改变的是读取状态,不是把存储内容抹掉。owner 的区间仍是 [1,3)。

如果只希望交出去一个长度为 2 的窗口,先对当前区间 slice,再生成只读视图。新窗口的 capacity 就是 2,clear 也只能回到这两个字节;不过它仍与原存储共享内容。

需要稳定内容时,复制字节,再只读暴露新存储

snapshot 方法先 duplicate 出独立读取游标,再为 remaining() 长度创建新 byte[],把当前区间复制进去,最后将新数组包装成只读缓冲区。原 owner 的 position 不会因此被消费。

复制时区间内容为 [99,88]。随后原存储被改成 [7,8],slice 视图读到 [7,8],snapshot 仍读到 [99,88]。这个对照证明了新数组把内容存储分开。

本例没有把新数组的可写引用交给调用方,所以调用方只能通过返回的只读视图读取它。这里复制的是字节值,不涉及对象内部引用的深复制。复制也有内存和拷贝开销,本页没有做性能比较。

运行完整程序:两套 JDK 各通过 19 项检查

下面是一份完整 Main.java,不依赖 NIO 文件通道、网络或额外库。每一个“仍然改变”或“保持原值”都用显式 AssertionError 检查。

2026-10-23 在 Windows、Microsoft OpenJDK 17.0.20.1 与 21.0.12 分别以 --release 17 编译运行;每版 19 项检查,stdout 统一换行后相同。全文实验都只在一个线程中运行。

展开收起代码 · 小实验java · 78 行

长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。

小实验 · java
import java.nio.ByteBuffer;
import java.nio.ReadOnlyBufferException;
import java.util.Arrays;

public final class Main {
    private static int checks;
    private static void check(boolean ok, String message) {
        checks++;
        if (!ok) throw new AssertionError(message);
    }
    private static void rejectWrite(Runnable action) {
        try {
            action.run();
            throw new AssertionError("Expected ReadOnlyBufferException");
        } catch (ReadOnlyBufferException expected) {
            checks++;
        }
    }
    private static ByteBuffer snapshot(ByteBuffer source) {
        ByteBuffer reader = source.duplicate();
        byte[] copied = new byte[reader.remaining()];
        reader.get(copied);
        return ByteBuffer.wrap(copied).asReadOnlyBuffer();
    }
    private static String remaining(ByteBuffer source) {
        ByteBuffer reader = source.duplicate();
        byte[] content = new byte[reader.remaining()];
        reader.get(content);
        return Arrays.toString(content);
    }
    public static void main(String[] args) {
        byte[] storage = {10, 20, 30, 40};
        ByteBuffer owner = ByteBuffer.wrap(storage);
        owner.position(1).limit(3);
        ByteBuffer readOnly = owner.asReadOnlyBuffer();
        check(readOnly.isReadOnly(), "new view is read-only");
        check(readOnly.position() == 1 && readOnly.limit() == 3 && readOnly.capacity() == 4,
                "view initially copies the range state");
        check(readOnly.get() == 20, "relative get reads current position");
        check(readOnly.position() == 2 && owner.position() == 1, "positions are independent");
        System.out.println("after-view-get: owner.position=" + owner.position()
                + "; view.position=" + readOnly.position());
        owner.put(1, (byte) 99);
        storage[2] = 88;
        check(readOnly.get(1) == 99 && readOnly.get(2) == 88, "content is shared");
        System.out.println("shared: view[1]=" + readOnly.get(1) + "; view[2]=" + readOnly.get(2));
        rejectWrite(() -> readOnly.put(1, (byte) 0));
        rejectWrite(readOnly::compact);
        check(!readOnly.hasArray(), "backing array is not accessible through read-only view");
        rejectWrite(readOnly::array);
        System.out.println("view-write=ReadOnlyBufferException; compact=ReadOnlyBufferException; hasArray="
                + readOnly.hasArray());

        readOnly.clear();
        check(readOnly.position() == 0 && readOnly.limit() == 4, "read-only cursor state can change");
        check(readOnly.get(3) == 40, "old limit is not a permanent boundary");
        check(owner.position() == 1 && owner.limit() == 3, "owner range is unchanged");
        System.out.println("after-view-clear: view=" + remaining(readOnly)
                + "; owner.remaining=" + remaining(owner));

        ByteBuffer window = owner.slice().asReadOnlyBuffer();
        check(window.position() == 0 && window.limit() == 2 && window.capacity() == 2,
                "slice capacity is the original remaining length");
        window.clear();
        check(remaining(window).equals("[99, 88]"), "clear cannot expand beyond slice capacity");
        ByteBuffer fixed = snapshot(owner);
        check(owner.position() == 1, "snapshot does not consume the caller cursor");
        owner.put(1, (byte) 7);
        storage[2] = 8;
        check(remaining(window).equals("[7, 8]"), "slice still shares storage");
        check(remaining(fixed).equals("[99, 88]"), "copy does not share old storage");
        check(fixed.isReadOnly() && fixed.capacity() == 2, "snapshot exposes only copied bytes");
        System.out.println("after-owner-change: slice=" + remaining(window)
                + "; snapshot=" + remaining(fixed));
        rejectWrite(() -> fixed.put(0, (byte) 0));
        System.out.println("PASS " + checks);
    }
}

怎样运行:在独立练习文件夹中,依次执行下载区的命令。2026-10-23 使用 完整 JDK 17 或更新版本;实测 Windows / JDK 17.0.20.1 / 21.0.12 复跑这份源码并核对完整输出。heap ByteBuffer 单线程、每 JDK 19 项检查;未测 direct 或映射缓冲区、并发可见性、多字节字节序或性能。

运行后对照结果
after-view-get: owner.position=1; view.position=2
shared: view[1]=99; view[2]=88
view-write=ReadOnlyBufferException; compact=ReadOnlyBufferException; hasArray=false
after-view-clear: view=[10, 99, 88, 40]; owner.remaining=[99, 88]
after-owner-change: slice=[7, 8]; snapshot=[99, 88]
PASS 19

按三条线看结果:内容、游标、可见范围

after-view-get 一行只说明游标不同:owner=1、view=2。shared 一行说明底层内容相同:两条原始写路径都被只读视图读到。不要只看其中一行就概括成“完全共享”或“完全独立”。

after-view-clear 显示只读视图的可读范围回到了四个字节,而 owner 仍只暴露自己的两个剩余字节。limit 与 capacity 的区别会影响调用方最终能读到哪些字节。

最后一行把两个手段并排比较:slice 调整窗口但保留内容共享,snapshot 复制内容并保留当时的值。选择手段之前,先写清要求究竟是限制窗口、限制写入路径,还是保留某个时点的内容。

本例没有证明跨线程读取安全,也不讨论多字节字节序

创建只读视图不会让 ByteBuffer 自动成为线程安全对象。共享游标、共享可写存储以及复制过程中的并发修改都需要另行设计同步;本次单线程检查无法证明并发快照一致性。

程序只读写单字节,不用 getInt、getLong 等多字节操作,因此不把不同视图的 byte order 混入结论。若扩展为数值解码,应单独按对应创建方法的官方约定设置并检查字节序。

已验证 heap ByteBuffer 的 wrap、asReadOnlyBuffer、slice、duplicate 和本页复制方法。未测 direct buffer、文件映射、跨线程发布或拷贝性能。已有字节序文章讨论如何解释字节,本篇讨论谁能改变它们。

记住这三点

  • 只读视图不能通过自身写内容,但内容仍可能被其他持有者修改。
  • position 和 limit 属于各自视图;clear 不擦除字节,也不会冻结旧的 limit。
  • slice 缩小容量范围而共享内容;复制到新存储才隔离原字节的后续变化。

别把例子用过头

  • 把只读等同于不可变或线程安全。
  • 以为 clear 会把字节清零。
  • 把设置 limit 当成永远不能扩大读取范围。
  • 把 duplicate 或 slice 当成内容复制。

自己试一小步

你要交出一段报文的稳定内容,怎样分别证明范围正确、原游标不动、后续修改不会透过来?

  1. 自行设置六个不同字节,选择中间一段作为当前区间,并先画出 position、limit、capacity。
  2. 分别创建只读视图、只读 slice 和复制快照;在运行前预测各自能读取的范围。
  3. 从原缓冲区和原数组两个入口改内容,再检查三种结果。
  4. 改变视图游标并调用 clear,观察原缓冲区状态和每个对象最终可读范围,提交预测与实测对照。

做到这些,就算完成

  • 分别记录内容和游标,没有用“共享”一词替代全部状态。
  • 有范围外字节用来检验窗口边界。
  • 没有把单线程复制写成并发一致性保证。

查原始资料

想确认一个细节,先看这份资料对应的部分,不必从头读完所有文档。