2023-10-11  阅读(44)
版权声明:本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。 本文链接:https://www.skjava.com/series/article/2054931851

上篇文章(Netty 入门 — Bootstrap,一切从这里开始),我们了解了 Netty 的第一个核心组件:Bootstrap,它是 Netty 程序的开端。今天我们来熟悉 Netty 的第二个组件:ByteBuf,Netty 数据传输的载体。在 Netty 中,数据的读写都是以 ByteBuf 为单位进行交互的。

为什么要造轮子?

在学习 Java NIO 的时候,Java NIO 有一个原生的 ByteBuffer,为什么 Netty 不直接使用原生的,要重复造一个呢?因为不好用啊。为什么不好用呢?因为 Java NIO 的 ByteBuffer 有一些设计上的缺陷和痛点。

我们先看 ByteBuffer 内部结构。

Java NIO 的 ByteBuffer 有四个属性

  • capacity:容量,表示当前 ByteBuffer 最大可写的数据量。
  • limit:ByteBuffer 中有效的数据长度大小,具体含义与当前 ByteBuffer 处于哪种模式有关
    • 写模式:limit 是指能够往 Buffer 中写入多少数据,其值等于 capacity。
    • 读模式:limit 表示能够从 Buffer 中最多能够读取多少数据出来。当从写模式切换到读模式时,limit 的写模式的 position。
  • position:当前位置,与 limit 一样,具体含义与当前 ByteBuffer 处于哪种模式有关
    • 写模式:当前写的位置,初始值为 0 ,最大值为 capacity - 1,当往 ByteBuffer 中写入一个数据时,position 就会向前移动到下一个待写入的位置。
    • 读模式:当前读的位置,读一个数据,position 就往前移一位。
  • mark:标志位,一般都是用这个属性来标识某个特殊的位置,方便我们到时候回退到该位置。

从 ByteBuffer 的内部结构我们可以看出它有如下几个缺陷:

  1. 只有一个标识位置的指针 position,在我们使用过程中需要频繁调用 flip()rewind() 来进行读写模式的切换,我们需要非常清晰地知道这些 API 具体的含义,知道他们的使用场景,否则就会导致程序出错。
  2. 长度固定。我们在申请一个 ByteBuffer 的时候就已经固定了它的容量了,它无法扩容,然后在实际开发过程中,我们是无法来衡量一个具体的容量的,所以很难控制需要分配的容量。分配太多,容易造成内存浪费;分配太少,则会引发索引越界异常 BufferOverflowException。
  3. 提供的 API 不够丰富,一些高级和实用的特性它不支持,需要我们自己动手实现。

而作为 Netty 的数据传输的载体,ByteBuffer 显然无法满足 Netty 的需求,所以 Netty 就另起炉灶实现了一个性能更高,灵活性更强的 ByteBuf。作为 ByteBuffer 的替代者,ByteBuf 具有如下几个优点:

  1. 容量可以动态扩容
  2. 读写索引分开,读写模式可以随意切换,不需要调用 flip() 方法
  3. 支持引用计数
  4. 支持池化
  5. 通过内置的复合缓冲区类型实现透明的零拷贝
  6. 支持方法的链式调用

ByteBuf 原理

ByteBuf内部结构

我们首先看 ByteBuf 的内部结构:

从 ByteBuf 的内部结构可以看出,它包含有三个指针:

  • readerIndex:读指针
  • writerIndex:写指针
  • maxCapacity:最大容量

三个指针将整个 ByteBuf 分为四个部分:

  1. 废弃字节:表示已经丢弃的无效字节,我们可以调用 discardReadBytes() 释放这部分空间。
  2. 可读字节:表示可以从 ByteBuf 中读取到的数据,这部分内容等于 writerIndex - readerIndex。readerIndex 随着我们读取 ByteBuf 中的数据而递增,当从 ByteBuf 中读取 N 个字节, readerIndex 就会自增 N,直到 readerIndex = writerIndex 时,就表示 ByteBuf 不可读。
  3. 可写字节:表示可以向 ByteBuf 可写入的字节。writerIndex 也是随着我们向 ByteBuf 中写入数据而自增,当想 ByteBuf 中写入 N 个字节,writerIndex 就会自增 N,当 writerIndex 超过 capacity 时,就需要扩容了。
  4. 可扩容字节:表示 ByteBuf 最多可扩容多少字节 。当向 ByteBuf 写入的数据超过了 capacity 时,就会触发扩容,但是最多可扩容到 maxCapacity ,超过时就会报错。

从这里就可以看出,ByteBuf 很好地解决了原生 NIO ByteBuffer 的不可扩容及读写模式切换的问题。Netty 为什么要设计两个指针呢?主要是为了能够更加高效、更加灵活地处理数据,有两个指针有如下几个优势:

  1. 读写分离:使用两个指针可以将读写两个操作进行有效地分离,而不会相互影响。这使得在同一时间,我们可以在不破坏数据完整性的前提下,进行同时的读取和写入操作。
  2. 更加灵活:两个指针意味着互相独立,我们可以在不影响读指针的情况下,自由地移动写指针,或者在不影响写指针的情况下,自由地移动读指针,这种分离的设计为数据处理提哦乖乖女了更大的灵活性。

ByteBuf 索引变化

清楚了 ByteBuf 的内部结构,我们还需要了解它内部索引的变化情况。

  • 初始分配:这个时候 readerIndex = writerIndex = 0

  • 当我们向 ByteBuf 中写入 N 个字节后,readerIndex = 0,writerIndex = N

  • 当我们从 ByteBuf 中读取 M(M < N)个字节后,readerIndex = M,writerIndex = N

  • 当我们继续往 ByteBuf 中写入数据时,writerIndex = capacity 时,就无法再写了,这个时候会触发扩容( X )

  • 对弈失效的那部分,我们可以调用 discardReadBytes() 来释放这部分空间,释放完成后,readerIndex = 0,writerIndex = (N + X ) - M

ByteBuf 分类

Netty 提供的 ByteBuf 有多种实现类,每种都有不同的特性和使用场景,主要分为三种类型:

  1. Pooled 和 Unpooled:池化和非池化;
  2. Heap 和 Direct:堆内存和直接内存;
  3. Safe 和 Unsafe:安全和非安全。
  • Pooled 和 Unpooled

Pooled 就是从预先分配好的内存中取出来,使用完成后又放回 ByteBuf 内存中,等待下一次分配。而 Unpooled 是直接调用系统 API 来申请内存的,使用完成后需要立刻销毁的。

从性能上来说,Pooled 要比 Unpooled 性能好,因为它可以重复利用,不需要每次都创建

  • Heap 和 Direct

Heap 就是在 JVM 堆内分配的,其生命周期受 JVM 管理,我们不需要主动回收他们。而 Direct 则由操作系统管理,使用完成后需要主动释放这部分内存,否则容易造成内存溢出。

  • Safe 和 Unsafe

主要是 Java 底层操作数据的一种安全和非安全的方式。Unsafe 表示每次调用 JDK 的 Unsafe 对象操作物理内存的,而 Safe 则不需要依赖 JDK 的 Unsafe 对象,直接通过数组下标的方式来操作。

6 中类型,可以根据不同类型进行组合,在 Netty 中一共有 8 种:

  1. 池化 + 堆内存:PooledHeapByteBuf
  2. 池化 + 直接内存:PooledDirectByteBuf
  3. 池化 + 堆内存 + 不安全:PooledUnsafeHeapByteBuf
  4. 池化 + 直接内存 + 不安全:PooledUnsafeDirectByteBuf
  5. 非池化 + 堆内存:UnpooledHeapByteBuf
  6. 非池化 + 直接内存:UnpooledDirectByteBuf
  7. 非池化 + 堆内存 + 不安全:UnpooledUnsafeHeapByteBuf
  8. 非池化 + 直接内存 + 不安全:UnpooledUnsafeDirectByteBuf

ByteBuf 核心 API

ByteBuf 的核心 API 分为四类:

  1. 容量相关 API
  2. 指针操作相关 API
  3. 数据读写相关 API
  4. 内存管理相关 API

下面我们依次来了解这些 API。

容量相关 API

容量相关的 API 主要用来获取 ByteBuf 的容量的。

  • capacity()

表示 ByteBuf 占用了多少字节的内存,它包括已放弃 + 可读 + 可写。

  • maxCapacity()

表示 ByteBuf 最大能占用多少字节的内存。当不断向 ByteBuf 中写入数据的时候,如果发现容量不足时(writerIndex 超过 capacity)就会触发扩容,最大可扩容到 maxCapacity,如果超过 maxCapacity 时就会抛出异常。

指针操作相关 API

指针操作相关 API 就是操作读写指针的。

  • readerIndex() & readerIndex(int)

前置返回读指针 readerIndex 的位置,而后者是设置读指针 readerIndex 的位置。

  • writerIndex() & writerIndex(int)

前者返回写指针 writerIndex 的位置,而后者是设置写指针 writerIndex 的位置。

  • markReaderIndex() & resetReaderIndex()

markReaderIndex()用于标注当前 readerIndex 的位置,即把当前 readerIndex 保存起来。而 resetReaderIndex() 则是将当前的 readerIndex 指针恢复到之前保存的位置。

  • markWriterIndex() & resetWriterIndex()

与 readerIndex 的一致。

数据读写相关 API

  • readableBytes() & isReadable()

readableBytes() 表示 ByteBuf 中有多少字节可以读,它的值等于 writerIndex - readerIndex。isReadable() 用于判断 ByteBuf 是否可读,若 readableBytes() 返回的值大于 0 ,则 isReadable() 则为 true。

  • readByte() & writeByte(byte b)

readByte() 是从 ByteBuf 中读取一个字节,则 readerIndex + 1。同理 writeByte(byte b) 是向 ByteBuf 中写入一个字节,相应的 writerIndex + 1。

在 Netty 中,它提供了 8 种基础数据类型的读取和写入 API,如 readInt()readLong()readShort() 等等,这里就不一一阐述了。

  • readBytes(byte[] dst) & writeBytes(byte[] src)

readBytes(byte[] dst) 是将 ByteBuf 里面的数据全部读取到 dst 中,这里 dst 数据的大小通常等于 readableBytes()

writeBytes(byte[] src) 则是将 src 数组里面的内容全部写到 ByteBuf 中。

  • getByte(int) & setByte(int,int)

这两个方法与 readByte() & writeByte(byte b) 方法类似,两者区别在于 readByte() 会改变 readerIndex 的位置,而 getByte(int) 则不会改变 readerIndex 的位置。

内存管理相关 API

  • retain() & release()

ByteBuf 是基于引用计数设计的,它实现了 ReferenceCounted 接口。在默认情况下,我们创建一个 ByteBuf 时,它的计数为 1。

当计数大于 0 ,就说该 ByteBuf 还在被使用,当计数等于 0 的时候,说明该 ByteBuf 不再被其他对象所引用。

我们每调用一个 retain() ,计数就 + 1,每调用一次 release() 计数就 - 1,当计数减到 0 的时候,就会被回收。

  • slice() & duplicate() & copy()

slice()从 ByteBuf 中截取一段从 readerIndex 到 writerIndex 之间的数据,该新的 ByteBuf 的最大容量为原始 ByteBuf 的 readableBytes()。新的 ByteBuf 其底层分配的内存、引用计数与原始的 ByteBuf 共享,这样就会有一个问题:如果我们调用新的 ByteBuf 的 write 系列方法,就会影响到原始的 ByteBuf 的底层数据。

duplicate() 也是从 ByteBuf 中截取一段数据,返回一个新的 ByteBuf,但是它截取的是整个原始的 ByteBuf,与 slice() 一样,duplicate() 返回新的 ByteBuf 其底层分配的内存、引用计数与原始 ByteBuf 共享。

copy() 从原始 ByteBuf 中拷贝所有信息,包括读写指针、底层分配的内存、引用计数等等所有的信息,所以新的 ByteBuf 是一个独立的个体,它与原始的 ByteBuf 不再共享。

在使用这三个方法的时候一定要切记如下点:

  • slice()duplicate() 新的 ByteBuf 与原始的 ByteBuf 内存共享、引用计数共享、读写指针不共享
  • copy() 新的 ByteBuf 与原始 ByteBuf 底层内存、引用计数、读写指针都不共享

这三个方法相比其他的方法稍微有那么点难理解,下面大明哥将会通过示例来详细讲解。

示例

下面大明哥就用一个完整的示例来演示 ByteBuf 的核心 API。

private static void printByteBuf(ByteBuf byteBuf,String action) {
    System.out.println("============== " + action + " ================");
    System.out.println("capacity = " + byteBuf.capacity());
    System.out.println("maxCapacity = " + byteBuf.maxCapacity());
    System.out.println("readerIndex = " + byteBuf.readerIndex());
    System.out.println("writerIndex = " + byteBuf.writerIndex());
    System.out.println("readableBytes = " + byteBuf.readableBytes());
    System.out.println("isWritable = " + byteBuf.isWritable());
    
    System.out.println();
}

先加一个打印 ByteBuf 核心属性的方法,后面都用该方法来查看 ByteBuf 的相关信息

ByteBuf byteBuf = ByteBufAllocator.DEFAULT.buffer(15,20);
printByteBuf(byteBuf,"new buffer(15,20)");   

//===== 执行结果 ======
============== new buffer(15,20) ================
capacity = 15
maxCapacity = 20
readerIndex = 0
writerIndex = 0
readableBytes = 0
isWritable = true

申请一个 capacity = 15 ,maxCapacity = 20 的 ByteBuf ,由于是新申请的,所以 readerIndex = writerIndex = 0,示例图如下:

  • 我们往该 ByteBuf 写入 6 个字节。
// 写入四个字节
byteBuf.writeBytes(new byte[]{1,2,3,4,5,6});
printByteBuf(byteBuf,"写入 6 个字节");

//===== 执行结果 ======
============== 写入 6 个字节 ================
capacity = 15
maxCapacity = 20
readerIndex = 0
writerIndex = 6
readableBytes = 6
isWritable = true

写入四个字节后,writerIndex = 6,可读字节数为 6,示例图如下:

  • 这个时候 ByteBuf 中有 6 个可以读的字节,我们先读取 4 个字节
// 读取 4 个字节
byteBuf.readInt();
printByteBuf(byteBuf,"读取 4 个字节");

//===== 执行结果 ======
============== 读取 4 个字节 ================
capacity = 15
maxCapacity = 20
readerIndex = 4
writerIndex = 6
readableBytes = 2
isWritable = true

读取 4 个字节后,readerIndex = 4,writerIndex = 6,可读字节数 readableBytes = 2,示例图如下:

  • 如果我们继续读取两个字段
// 读取两个字节
byteBuf.readBytes(new byte[2]);
printByteBuf(byteBuf,"读取 2 个字节");

//===== 执行结果 ======
============== 读取 2 个字节 ================
capacity = 15
maxCapacity = 20
readerIndex = 6
writerIndex = 6
readableBytes = 0
isWritable = true

这个时候你会发现 readableBytes = 0 ,那么 isReadable() 就为 false,意味着该 ByteBuf 当前不可读,此时 readerIndex = writerIndex = 6,示例图如下:

  • 我们再往该 ByteBuf 中写入 8 个字节
// 写入 8 个字节
byteBuf.writeBytes(new byte[]{7,8,9,10,11,12,13,14});
printByteBuf(byteBuf,"写入 8 个字节");

// getXx 获取 ByteBuf 中的值
System.out.println("getByte(3) = " + byteBuf.getByte(3));
printByteBuf(byteBuf,"getByte(3)");

// setBytes
byteBuf.setByte(8,1);
printByteBuf(byteBuf,"setByte(8,1)");

//===== 执行结果 ======
============== 写入 8 个字节 ================
capacity = 15
maxCapacity = 20
readerIndex = 6
writerIndex = 14
readableBytes = 8
isWritable = true

getByte(3) = 4
============== getByte(3) ================
capacity = 15
maxCapacity = 20
readerIndex = 6
writerIndex = 14
readableBytes = 8
isWritable = true

============== setByte(8,1) ================
capacity = 15
maxCapacity = 20
readerIndex = 6
writerIndex = 14
readableBytes = 8
isWritable = true   

到这里我们发现 getXxx()setXxx() 不会改变 readerIndex 和 writerIndex 的位置。示例图如下:

  • 下面我们再使用 slice()duplicate()copy() 三个拷贝方法来演示下
// slice
ByteBuf sliceByte = byteBuf.slice();
printByteBuf(sliceByte,"sliceByte");

// duplicateByte
ByteBuf duplicateByte = byteBuf.duplicate();
printByteBuf(duplicateByte,"duplicateByte");

//===== 执行结果 ======
============== sliceByte ================
capacity = 8
maxCapacity = 8
readerIndex = 0
writerIndex = 8
readableBytes = 8
isWritable = false

============== duplicateByte ================
capacity = 15
maxCapacity = 20
readerIndex = 6
writerIndex = 14
readableBytes = 8
isWritable = true

从执行结果可以看出,slice() 截取的是原始 ByteBuf 的 readerIndex 到 writerIndex 的部分,其中 capacity = maxCapacity = writerIndex = writerIndex - readerIndex ,这也就预示着该 ByteBuf 不可读。而 duplicate() 则是截取的整个原始 ByteBuf 的信息。

现在我们对这两个 ByteBuf 做一番操作。

// 读取 sliceByte
sliceByte.readInt();
printByteBuf(sliceByte,"sliceByte.readInt()");

// 读取
duplicateByte.readInt();
printByteBuf(duplicateByte,"duplicateByte.readInt()");

// 读取后原始 ByteBuf
printByteBuf(byteBuf,"读取后,原始 ByteBuf");

//===== 执行结果 ======
============== sliceByte.readInt() ================
capacity = 8
maxCapacity = 8
readerIndex = 4
writerIndex = 8
readableBytes = 4
isWritable = false

============== duplicateByte.readInt() ================
capacity = 15
maxCapacity = 20
readerIndex = 10
writerIndex = 14
readableBytes = 4
isWritable = true

============== 读取后,原始 ByteBuf ================
capacity = 15
maxCapacity = 20
readerIndex = 6
writerIndex = 14
readableBytes = 8
isWritable = true

从运行结果我们知道对 slice()duplicateByte() 进行读操作后,完全不会影响原始的 ByteBuf,那写呢?

// 写 duplicateByte
duplicateByte.writeBytes(new byte[]{1});
printByteBuf(duplicateByte,"duplicateByte.writeByte(0)");

// 写后原始 ByteBuf
printByteBuf(byteBuf,"写后,原始 ByteBuf");

System.out.println("setByte(8,123) 之前,duplicateByte = " + duplicateByte.getByte(8));
System.out.println("setByte(8,123) 之前,byteBuf = " + byteBuf.getByte(8));

duplicateByte.setByte(8,123);
System.out.println("setByte(8,123) 之后,duplicateByte = " + duplicateByte.getByte(8));
System.out.println("setByte(8,123) 之后,byteBuf = " + byteBuf.getByte(8));

//===== 执行结果 ======
============== duplicateByte.writeByte(0) ================
capacity = 15
maxCapacity = 20
readerIndex = 10
writerIndex = 15
readableBytes = 5
isWritable = false

============== 写后,原始 ByteBuf ================
capacity = 15
maxCapacity = 20
readerIndex = 6
writerIndex = 14
readableBytes = 8
isWritable = true

setByte(8,123) 之前,duplicateByte = 1
setByte(8,123) 之前,byteBuf = 1
setByte(8,123) 之后,duplicateByte = 123
setByte(8,123) 之后,byteBuf = 123

仔细观察运行结果,发现 duplicateByte.writeBytes() 之后,原始 ByteBuf 的结构其实并没有发生变化,他的 readerIndex 依旧 = 6,writerIndex = 14。但是 duplicateByte.setByte(8,123) 之后,原始 ByteBuf 的值发生了变化,这就是我在前面提到过的:**使用 **slice()duplicate() 生成出来的新 ByteBuf 与原生 ByteBuf 是共享底层数据和引用计数,但是不共享读写指针的。这点在使用过程中要格外注意。

  • 我们继续往 ByteBuf 里面写入数据
// 继续写 2 个字节
byteBuf.writeBytes(new byte[]{15,16});
printByteBuf(byteBuf,"写 2 个字节后");

// 再写 1 个
byteBuf.writeBytes(new byte[]{17});
printByteBuf(byteBuf,"写 1 个字节后");

//===== 执行结果 ======
============== 写 2 个字节后 ================
capacity = 16
maxCapacity = 20
readerIndex = 6
writerIndex = 16
readableBytes = 10
isWritable = false

============== 写 1 个字节后 ================
capacity = 20
maxCapacity = 20
readerIndex = 6
writerIndex = 17
readableBytes = 11
isWritable = true

当我们写入两个字节后,isWritable = false 说明该 ByteBuf 已经满容量了,不可写了,我们再写入后就触发了扩容,这个时候 capacity = maxCapacity = 20。示例图如下:

如果这个时候我们再往里面写 5 个字节就会报错,抛出异常:

java.lang.IndexOutOfBoundsException: writerIndex(17) + minWritableBytes(5) exceeds maxCapacity(20): PooledUnsafeDirectByteBuf(ridx: 6, widx: 17, cap: 20/20)
  • 在讲述 ByteBuf 的原理的时候,大明哥说过,对于已废弃的内容,我们可以使用 discardReadBytes() 来释放这部分空间。
byteBuf.discardReadBytes();
printByteBuf(byteBuf,"discardReadBytes 之后");

//===== 执行结果 ======
============== discardReadBytes 之后 ================
capacity = 20
maxCapacity = 20
readerIndex = 0
writerIndex = 11
readableBytes = 11
isWritable = true

释放已废弃内存空间后,ByteBuf 的 readerIndex = 0,writerIndex = writerIndex - readerIndex。示意图如下:

总结

到这里大明哥相信各位小伙伴对 ByteBuf 应该有了初步的认识,当然这篇文章只是入门篇而已,里面还有很多细节都没有涉及到,但是不要着急,因为后面还有进阶篇,源码篇,一定会将它讲的明明白白的。最后做一个简单的总结:

  • ByteBuf 是Netty 的数据传输的载体,它是为了解决 JDK NIO 原生 ByteBuffer 的设计缺陷和不易用而重新设计的,它具备如下几个特性:
    1. 容量可以动态扩容
    2. 读写索引(readerIndex、writerIndex)分开,读写模式可以随意切换,不需要调用 flip() 方法
    3. 支持引用计数
    4. 支持池化
    5. 通过内置的复合缓冲区类型实现透明的零拷贝
    6. 支持方法的链式调用
  • read 系列方法改变 readerIndex 索引,write 系列方法改变 writerIndex 索引,当 readerIndex = writerIndex 时,ByteBuf 不可读。当 writerIndex = capacity 时,如果再继续写入数据则触发扩容操作,扩容最大范围为 maxCapacity。当写入的数据超过 maxCapacity 时会报错。
  • ByteBuf 有三种类型,三种类型自由组合为 8 个 ByteBuf。
    1. 池化 + 堆内存:PooledHeapByteBuf
    2. 池化 + 直接内存:PooledDirectByteBuf
    3. 池化 + 堆内存 + 不安全:PooledUnsafeHeapByteBuf
    4. 池化 + 直接内存 + 不安全:PooledUnsafeDirectByteBuf
    5. 非池化 + 堆内存:UnpooledHeapByteBuf
    6. 非池化 + 直接内存:UnpooledDirectByteBuf
    7. 非池化 + 堆内存 + 不安全:UnpooledUnsafeHeapByteBuf
    8. 非池化 + 直接内存 + 不安全:UnpooledUnsafeDirectByteBuf

源码:http://suo.nz/1KV2b1


Java 面试宝典是大明哥全力打造的 Java 精品面试题,它是一份靠谱、强大、详细、经典的 Java 后端面试宝典。它不仅仅只是一道道面试题,而是一套完整的 Java 知识体系,一套你 Java 知识点的扫盲贴。

它的内容包括:

  • 大厂真题:Java 面试宝典里面的题目都是最近几年的高频的大厂面试真题。
  • 原创内容:Java 面试宝典内容全部都是大明哥原创,内容全面且通俗易懂,回答部分可以直接作为面试回答内容。
  • 持续更新:一次购买,永久有效。大明哥会持续更新 3+ 年,累计更新 1000+,宝典会不断迭代更新,保证最新、最全面。
  • 覆盖全面:本宝典累计更新 1000+,从 Java 入门到 Java 架构的高频面试题,实现 360° 全覆盖。
  • 不止面试:内容包含面试题解析、内容详解、知识扩展,它不仅仅只是一份面试题,更是一套完整的 Java 知识体系。
  • 宝典详情:https://www.yuque.com/chenssy/sike-java/xvlo920axlp7sf4k
  • 宝典总览:https://www.yuque.com/chenssy/sike-java/yogsehzntzgp4ly1
  • 宝典进展:https://www.yuque.com/chenssy/sike-java/en9ned7loo47z5aw

目前 Java 面试宝典累计更新 400+ 道,总字数 42w+。大明哥还在持续更新中,下图是大明哥在 2024-12 月份的更新情况:

想了解详情的小伙伴,扫描下面二维码加大明哥微信【daming091】咨询

同时,大明哥也整理一套目前市面最常见的热点面试题。微信搜[大明哥聊 Java]或扫描下方二维码关注大明哥的原创公众号[大明哥聊 Java] ,回复【面试题】 即可免费领取。

阅读全文