史上最全IO讲解,以源码角度为你剖析,颠覆你的认知

| 重要方法 | 功能 |

| — | — |

| public abstract int read() | 从输入流中读取下一个字节,读到尾部时返回 -1 |

| public int read(byte b[]) | 从输入流中读取长度为 b.length 个字节放入字节数组 b 中 |

| public int read(byte b[], int off, int len) | 从输入流中读取指定范围的字节数据放入字节数组 b 中 |

| public void close() | 关闭此输入流并释放与该输入流相关的所有资源 |

还有其它一些不太常用的方法,我也列出来了。

| 其它方法 | 功能 |

| — | — |

| public long skip(long n) | 跳过接下来的 n 个字节,返回实际上跳过的字节数 |

| public long available() | 返回下一次可读取(跳过)且不会被方法阻塞的字节数的估计值 |

| public synchronized void mark(int readlimit) | 标记此输入流的当前位置,对 reset() 方法的后续调用将会重新定位在 mark() 标记的位置,可以重新读取相同的字节 |

| public boolean markSupported() | 判断该输入流是否支持 mark() 和 reset() 方法,即能否重复读取字节 |

| public synchronized void reset() | 将流的位置重新定位在最后一次调用 mark() 方法时的位置 |

image-20200827082445395

(1)ByteArrayInputStream

ByteArrayInputStream 内部包含一个 buf 字节数组缓冲区,该缓冲区可以从流中读取的字节数,使用 pos 指针指向读取下一个字节的下标位置,内部还维护了一个count 属性,代表能够读取 count 个字节。

bytearrayinputstream

必须保证 pos 严格小于 count,而 count 严格小于 buf.length 时,才能够从缓冲区中读取数据

(2)FileInputStream

文件输入流,从文件中读入字节,通常对文件的拷贝、移动等操作,可以使用该输入流把文件的字节读入内存中,然后再利用输出流输出到指定的位置上。

(3)PipedInputStream

管道输入流,它与 PipedOutputStream 成对出现,可以实现多线程中的管道通信。PipedOutputStream 中指定与特定的 PipedInputStream 连接,PipedInputStream 也需要指定特定的 PipedOutputStream 连接,之后输出流不断地往输入流的 buffer 缓冲区写数据,而输入流可以从缓冲区中读取数据。

(4)ObjectInputStream

对象输入流,用于对象的反序列化,将读入的字节数据反序列化为一个对象,实现对象的持久化存储。

(5)PushBackInputStream

它是 FilterInputStream 的子类,是一个处理流,它内部维护了一个缓冲数组buf

  • 在读入字节的过程中可以将读取到的字节数据回退给缓冲区中保存,下次可以再次从缓冲区中读出该字节数据。所以PushBackInputStream 允许多次读取输入流的字节数据,只要将读到的字节放回缓冲区即可。

2

需要注意的是如果回推字节时,如果缓冲区已满,会抛出 IOException 异常。

它的应用场景:对数据进行分类规整

假如一个文件中存储了数字字母两种类型的数据,我们需要将它们交给两种线程各自去收集自己负责的数据,如果采用传统的做法,把所有的数据全部读入内存中,再将数据进行分离,面对大文件的情况下,例如1G、2G,传统的输入流在读入数组后,由于没有缓冲区,只能对数据进行抛弃,这样每个线程都要读一遍文件

使用 PushBackInputStream 可以让一个专门的线程读取文件,唤醒不同的线程读取字符:

  • 第一次读取缓冲区的数据,判断该数据由哪些线程读取

  • 回退数据,唤醒对应的线程读取数据

  • 重复前两步

  • 关闭输入流

到这里,你是否会想到 AQSCondition 等待队列,多个线程可以在不同的条件上等待被唤醒。

(6)BufferedInputStream

缓冲流,它是一种处理流,对节点流进行封装并增强,其内部拥有一个 buffer 缓冲区,用于缓存所有读入的字节,当缓冲区满时,才会将所有字节发送给客户端读取,而不是每次都只发送一部分数据,提高了效率。

(7)DataInputStream

数据输入流,它同样是一种处理流,对节点流进行封装后,能够在内部对读入的字节转换为对应的 Java 基本数据类型。

(8)SequenceInputStream

将两个或多个输入流看作是一个输入流依次读取,该类的存在与否并不影响整个 IO 生态,在程序中也能够做到这种效果

(9)StringBufferInputStream

将字符串中每个字符的低 8 位转换为字节读入到字节数组中,目前已过期

InputStream 总结:

  • InputStream 是所有输入字节流的抽象基类

  • ByteArrayInputStream 和 FileInputStream 是两种基本的节点流,他们分别从字节数组本地文件中读取数据

  • DataInputStream、BufferedInputStream 和 PushBackInputStream 都是处理流,对基本的节点流进行封装并增强

  • PipiedInputStream 用于多线程通信,可以与其它线程公用一个管道,读取管道中的数据。

  • ObjectInputStream 用于对象的反序列化,将对象的字节数据读入内存中,通过该流对象可以将字节数据转换成对应的对象

OutputStream

OutputStream 是字节输出流的抽象基类,提供了通用的写方法,让继承的子类重写和复用。

| 方法 | 功能 |

| — | — |

| public abstract void write(int b) | 将指定的字节写出到输出流,写入的字节是参数 b 的低 8 位 |

| public void write(byte b[]) | 将指定字节数组中的所有字节写入到输出流当中 |

| public void write(byte b[], int off, int len) | 指定写入的起始位置 offer,字节数为 len 的字节数组写入到输出流当中 |

| public void flush() | 刷新此输出流,并强制写出所有缓冲的输出字节到指定位置,每次写完都要调用 |

| public void close() | 关闭此输出流并释放与此流关联的所有系统资源 |

image-20200827090101687

OutputStream 中大多数的类和 InputStream 是对应的,只不过数据的流向不同而已。从上面的图可以看出:

  • OutputStream 是所有输出字节流的抽象基类

  • ByteArrayOutputStream 和 FileOutputStream 是两种基本的节点流,它们分别向字节数组本地文件写出数据

  • DataOutputStream、BufferedOutputStream 是处理流,前者可以将字节数据转换成基本数据类型写出到文件中;后者是缓冲字节数组,只有在缓冲区满时,才会将所有的字节写出到目的地,减少了 IO 次数

  • PipedOutputStream 用于多线程通信,可以和其它线程共用一个管道,向管道中写入数据

  • ObjectOutputStream 用于对象的序列化,将对象转换成字节数组后,将所有的字节都写入到指定位置中

  • PrintStream 在 OutputStream 基础之上提供了增强的功能,即可以方便地输出各种类型的数据(而不仅限于byte型)的格式化表示形式,且 PrintStream 的方法从不抛出 IOEception,其原理是写出时将各个数据类型的数据统一转换为 String 类型,我会在讲解完

字符流对象

字符流对象也会有对应关系,大多数的类可以认为是操作的数据从字节数组变为字符,类的功能和字节流对象是相似的。

字符输入流和字节输入流的组成非常相似,字符输入流是对字节输入流的一层转换,所有文件的存储都是字节的存储,在磁盘上保留的不是文件的字符,而是先把字符编码成字节,再保存到文件中。在读取文件时,读入的也是一个一个字节组成的字节序列,而 Java 虚拟机通过将字节序列,按照2个字节为单位转换为 Unicode 字符,实现字节到字符的映射。

image-20200827094740444

Reader

Reader 是字符输入流的抽象基类,它内部的重要方法如下所示。

| 重要方法 | 方法功能 |

| — | — |

| public int read(java.nio.CharBuffer target) | 将读入的字符存入指定的字符缓冲区中 |

| public int read() | 读取一个字符 |

| public int read(char cbuf[]) | 读入字符放入整个字符数组中 |

| abstract public int read(char cbuf[], int off, int len) | 将字符读入字符数组中的指定范围中 |

还有其它一些额外的方法,与字节输入流基类提供的方法是相同的,只是作用的对象不再是字节,而是字符。

image-20200827095911702

  • Reader 是所有字符输入流的抽象基类

  • CharArrayReader 和 StringReader 是两种基本的节点流,它们分别从读取 字符数组字符串 数据,StringReader 内部是一个 String 变量值,通过遍历该变量的字符,实现读取字符串,本质上也是在读取字符数组

  • PipedReader 用于多线程中的通信,从共用地管道中读取字符数据

  • BufferedReader 是字符输入缓冲流,将读入的数据放入字符缓冲区中,实现高效地读取字符

  • InputStreamReader 是一种转换流,可以实现从字节流转换为字符流,将字节数据转换为字符

Writer

Reader 是字符输出流的抽象基类,它内部的重要方法如下所示。

| 重要方法 | 方法功能 |

| — | — |

| public void write(char cbuf[]) | 将 cbuf 字符数组写出到输出流 |

| abstract public void write(char cbuf[], int off, int len) | 将指定范围的 cbuf 字符数组写出到输出流 |

| public void write(String str) | 将字符串 str 写出到输出流,str 内部也是字符数组 |

| public void write(String str, int off, int len) | 将字符串 str 的某一部分写出到输出流 |

| abstract public void flush() | 刷新,如果数据保存在缓冲区,调用该方法才会真正写出到指定位置 |

| abstract public void close() | 关闭流对象,每次 IO 执行完毕后都需要关闭流对象,释放系统资源 |

image-20200827104837521

  • Writer 是所有的输出字符流的抽象基类

  • **CharArrayWriter、StringWriter 是两种基本的节点流,它们分别向Char 数组、字符串中写入数据。**StringWriter 内部保存了 StringBuffer 对象,可以实现字符串的动态增长

  • PipedWriter 可以向共用的管道中写入字符数据,给其它线程读取。

  • BufferedWriter缓冲输出流,可以将写出的数据缓存起来,缓冲区满时再调用 flush() 写出数据,减少 IO 次数

  • PrintWriter 和 PrintStream 类似,功能和使用也非常相似,只是写出的数据是字符而不是字节

  • OutputStreamWriter字符流转换为字节流,将字符写出到指定位置

字节流与字符流的转换


从任何地方把数据读入到内存都是先以字节流形式读取,即使是使用字符流去读取数据,依然成立,因为数据永远是以字节的形式存在于互联网和硬件设备中,字符流是通过字符集的映射,才能够将字节转换为字符。

所以 Java 提供了两种转换流:

  • InputStreamReader:从字节流转换为字符流,将字节数据转换为字符数据读入到内存

  • OutputStreamWriter:从字符流转换为字节流,将字符数据转换为字节数据写出到指定位置

了解了 Java 传统的 BIO 中字符流和字节流的主要成员之后,至少要掌握以下两个关键点:

(1)传统的 BIO 是以为基本单位处理数据的,想象成水流,一点点地传输字节数据,IO 流传输的过程永远是以字节形式传输。

(2)字节流和字符流的区别在于操作的数据单位不相同,字符流是通过将字节数据通过字符集映射成对应的字符,字符流本质上也是字节流。

接下来我们再继续学习 NIO 知识,NIO 是当下非常火热的一种 IO 工作方式,它能够解决传统 BIO 的痛点:阻塞

  • BIO 如果遇到 IO 阻塞时,线程将会被挂起,直到 IO 完成后才唤醒线程,线程切换带来了额外的开销。

  • BIO 中每个 IO 都需要有对应的一个线程去专门处理该次 IO 请求,会让服务器的压力迅速提高。

我们希望做到的是当线程等待 IO 完成时能够去完成其它事情,当 IO 完成时线程可以回来继续处理 IO 相关操作,不必干干的坐等 IO 完成。在 IO 处理的过程中,能够有一个专门的线程负责监听这些 IO 操作,通知服务器该如何操作。所以,我们聊到 IO,不得不去接触 NIO 这一块硬骨头。

新潮的 NIO


我们来看看 BIO 和 NIO 的区别,BIO 是面向流的 IO,它建立的通道都是单向的,所以输入和输出流的通道不相同,必须建立2个通道,通道内的都是传输==0101001···==的字节数据。

而在 NIO 中,不再是面向流的 IO 了,而是面向缓冲区,它会建立一个通道(Channel),该通道我们可以理解为铁路,该铁路上可以运输各种货物,而通道上会有一个缓冲区(Buffer)用于存储真正的数据,缓冲区我们可以理解为一辆火车

通道(铁路)只是作为运输数据的一个连接资源,而真正存储数据的是缓冲区(火车)。即通道负责传输,缓冲区负责存储。

理解了上面的图之后,BIO 和 NIO 的主要区别就可以用下面这个表格简单概括。

| BIO | NIO |

| — | — |

| 面向流(Stream) | 面向缓冲区(Buffer) |

| 单向通道 | 双向通道 |

| 阻塞 IO | 非阻塞 IO |

|   | 选择器(Selectors) |

缓冲区(Buffer)


缓冲区是存储数据的区域,在 Java 中,缓冲区就是数组,为了可以操作不同数据类型的数据,Java 提供了许多不同类型的缓冲区,除了布尔类型以外,其它基本数据类型都有对应的缓冲区数组对象。

为什么没有布尔类型的缓冲区呢?

在 Java 中,boolean 类型数据只占用 1 bit,而在 IO 传输过程中,都是以字节为单位进行传输的,所以 boolean 的 1 bit 完全可以使用 byte 类型的某一位,或者 int 类型的某一位来表示,没有必要为了这 1 bit 而专门提供多一个缓冲区。

| 缓冲区 | 解释 |

| — | — |

| ByteBuffer | 存储字节数据的缓冲区 |

| CharBuffer | 存储字符数据的缓冲区 |

| ShortBuffer | 存储短整型数据的缓冲区 |

| IntBuffer | 存储整型数据的缓冲区 |

| LongBuffer | 存储长整型数据的缓冲区 |

| FloatBuffer | 存储单精度浮点型数据的缓冲区 |

| DoubleBuffer | 存储双精度浮点型数据的缓冲区 |

分配一个缓冲区的方式都高度一致:使用allocate(int capacity)方法。

例如需要分配一个 1024 大小的字节数组,代码就是下面这样子。

ByteBuffer byteBuffer = ByteBuffer.allocate(1024);

缓冲区读写数据的两个核心方法:

  • put():将数据写入到缓冲区中

  • get():从缓冲区中读取数据

缓冲区的重要属性:

  • capacity:缓冲区中最大存储数据的容量,一旦声明则无法改变

  • limit:表示缓冲区中可以操作数据的大小,limit 之后的数据无法进行读写。必须满足 limit <= capacity

  • position:当前缓冲区中正在操作数据的下标位置,必须满足 position <= limit

  • mark:标记位置,调用 reset() 将 position 位置调整到 mark 属性指向的下标位置,实现多次读取数据

缓冲区为高效读写数据而提供的其它辅助方法

  • flip():可以实现读写模式的切换,我们可以看看里面的源码

public final Buffer flip() {

limit = position;

position = 0;

mark = -1;

return this;

}

调用 flip() 会将可操作的大小 limit 设置为当前写的位置,操作数据的起始位置 position 设置为 0,即从头开始读取数据

  • rewind():可以将 position 位置设置为 0,再次读取缓冲区中的数据

  • clear():清空整个缓冲区,它会将 position 设置为 0,limit 设置为 capacity,可以写整个缓冲区

更多的方法可以去查阅 API 文档,本文碍于篇幅原因就不贴出其它方法了,主要是要理解缓冲区的作用

我们来看一个简单的例子

public Class Main {

public static void main(String[] args) {

// 分配内存大小为11的整型缓存区

IntBuffer buffer = IntBuffer.allocate(11);

// 往buffer里写入2个整型数据

for (int i = 0; i < 2; ++i) {

int randomNum = new SecureRandom().nextInt();

buffer.put(randomNum);

}

// 将Buffer从写模式切换到读模式

buffer.flip();

System.out.println("position >> " + buffer.position()

  • "limit >> " + buffer.limit()

  • "capacity >> " + buffer.capacity());

// 读取buffer里的数据

while (buffer.hasRemaining()) {

System.out.println(buffer.get());

}

System.out.println("position >> " + buffer.position()

  • "limit >> " + buffer.limit()

  • "capacity >> " + buffer.capacity());

}

}

执行结果如下图所示,首先我们往缓冲区中写入 2 个数据,position 在写模式下指向下标 2,然后调用 flip() 方法切换为读模式,limit 指向下标 2,position 从 0 开始读数据,读到下标为 2 时发现到达 limit 位置,不可继续读。

整个过程可以用下图来理解,调用 flip() 方法以后,读出数据的同时 position 指针不断往后挪动,到达 limit 指针的位置时,该次读取操作结束。

介绍完缓冲区后,我们知道它是存储数据的空间,进程可以将缓冲区中的数据读取出来,也可以写入新的数据到缓冲区,那缓冲区的数据从哪里来,又怎么写出去呢?

通道(Channel)


上面我们介绍过,通道是作为一种连接资源,作用是传输数据,而真正存储数据的是缓冲区,所以介绍完缓冲区后,我们来学习通道这一块。

通道是可以双向读写的,传统的 BIO 需要使用输入/输出流表示数据的流向,在 NIO 中可以减少通道资源的消耗。

通道类都保存在 java.nio.channels 包下,我们日常用到的几个重要的类有 4 个:

| IO 通道类型 | 具体类 |

| — | — |

| 文件 IO | FileChannel(用于文件读写、操作文件的通道) |

| TCP 网络 IO | SocketChannel(用于读写数据的 TCP 通道)、ServerSocketChannel(监听客户端的连接) |

| UDP 网络 IO | DatagramChannel(收发 UDP 数据报的通道) |

可以通过 getChannel() 方法获取一个通道,支持获取通道的类如下:

  • 文件 IO:FileInputStream、FileOutputStream、RandomAccessFile

  • TCP 网络 IO:Socket、ServerSocket

  • UDP 网络 IO:DatagramSocket

示例:文件拷贝案例

我们来看一个利用通道拷贝文件的例子,需要下面几个步骤:

  • 打开原文件的输入流通道,将字节数据读入到缓冲区中

  • 打开目的文件的输出流通道,将缓冲区中的数据写到目的地

  • 关闭所有流和通道(重要!)

这是一张小菠萝的照片,它存在于d:\小菠萝\文件夹下,我们将它拷贝到 d:\小菠萝分身\ 文件夹下。

public class Test {

/** 缓冲区的大小 */

public static final int SIZE = 1024;

public static void main(String[] args) throws IOException {

// 打开文件输入流

FileChannel inChannel = new FileInputStream(“d:\小菠萝\小菠萝.jpg”).getChannel();

// 打开文件输出流

FileChannel outChannel = new FileOutputStream(“d:\小菠萝分身\小菠萝-拷贝.jpg”).getChannel();

// 分配 1024 个字节大小的缓冲区

ByteBuffer dsts = ByteBuffer.allocate(SIZE);

// 将数据从通道读入缓冲区

while (inChannel.read(dsts) != -1) {

// 切换缓冲区的读写模式

dsts.flip();

// 将缓冲区的数据通过通道写到目的地

outChannel.write(dsts);

// 清空缓冲区,准备下一次读

dsts.clear();

}

inChannel.close();

outChannel.close();

}

}

我画了一张图帮助你理解上面的这一个过程。

有人会问,NIO 的文件拷贝和传统 IO 流的文件拷贝有何不同呢?我们在编程时感觉它们没有什么区别呀,貌似只是 API 不同罢了,我们接下来就去看看这两者之间的区别吧。

BIO 和 NIO 拷贝文件的区别


这个时候就要来了解了解操作系统底层是怎么对 IO 和 NIO 进行区别的,我会用尽量通俗的文字带你理解,可能并不是那么严谨。

操作系统最重要的就是内核,它既可以访问受保护的内存,也可以访问底层硬件设备,所以为了保护内核的安全,操作系统将底层的虚拟空间分为了用户空间内核空间,其中用户空间就是给用户进程使用的,内核空间就是专门给操作系统底层去使用的。

接下来,有一个 Java 进程希望把小菠萝这张图片从磁盘上拷贝,那么内核空间和用户空间都会有一个缓冲区

  • 这张照片就会从磁盘中读出到内核缓冲区中保存,然后操作系统将内核缓冲区中的这张图片字节数据拷贝到用户进程的缓冲区中保存下来,对应着下面这幅图

  • 然后用户进程会希望把缓冲区中的字节数据写到磁盘上的另外一个地方,会将数据拷贝到 Socket 缓冲区中,最终操作系统再将 Socket 缓冲区的数据写到磁盘的指定位置上。

这一轮操作下来,我们数数经过了几次数据的拷贝?4 次。有 2 次是内核空间和用户空间之间的数据拷贝,这两次拷贝涉及到用户态和内核态的切换,需要CPU参与进来,进行上下文切换。而另外 2 次是硬盘和内核空间之间的数据拷贝,这个过程利用到 DMA与系统内存交换数据,不需要 CPU 的参与。

自我介绍一下,小编13年上海交大毕业,曾经在小公司待过,也去过华为、OPPO等大厂,18年进入阿里一直到现在。

深知大多数Java工程师,想要提升技能,往往是自己摸索成长或者是报班学习,但对于培训机构动则几千的学费,着实压力不小。自己不成体系的自学效果低效又漫长,而且极易碰到天花板技术停滞不前!

因此收集整理了一份《2024年Java开发全套学习资料》,初衷也很简单,就是希望能够帮助到想自学提升又不知道该从何学起的朋友,同时减轻大家的负担。img

既有适合小白学习的零基础资料,也有适合3年以上经验的小伙伴深入学习提升的进阶课程,基本涵盖了95%以上Java开发知识点,真正体系化!

由于文件比较大,这里只是将部分目录截图出来,每个节点里面都包含大厂面经、学习笔记、源码讲义、实战项目、讲解视频,并且会持续更新!

如果你觉得这些内容对你有帮助,可以扫码获取!!(备注Java获取)

img

《MySql面试专题》

全网火爆MySql 开源笔记,图文并茂易上手,阿里P8都说好

全网火爆MySql 开源笔记,图文并茂易上手,阿里P8都说好

《MySql性能优化的21个最佳实践》

全网火爆MySql 开源笔记,图文并茂易上手,阿里P8都说好

全网火爆MySql 开源笔记,图文并茂易上手,阿里P8都说好

全网火爆MySql 开源笔记,图文并茂易上手,阿里P8都说好

全网火爆MySql 开源笔记,图文并茂易上手,阿里P8都说好

《MySQL高级知识笔记》

全网火爆MySql 开源笔记,图文并茂易上手,阿里P8都说好

全网火爆MySql 开源笔记,图文并茂易上手,阿里P8都说好

全网火爆MySql 开源笔记,图文并茂易上手,阿里P8都说好

全网火爆MySql 开源笔记,图文并茂易上手,阿里P8都说好

全网火爆MySql 开源笔记,图文并茂易上手,阿里P8都说好

全网火爆MySql 开源笔记,图文并茂易上手,阿里P8都说好

全网火爆MySql 开源笔记,图文并茂易上手,阿里P8都说好

全网火爆MySql 开源笔记,图文并茂易上手,阿里P8都说好

全网火爆MySql 开源笔记,图文并茂易上手,阿里P8都说好

全网火爆MySql 开源笔记,图文并茂易上手,阿里P8都说好

文中展示的资料包括:**《MySql思维导图》《MySql核心笔记》《MySql调优笔记》《MySql面试专题》《MySql性能优化的21个最佳实践》《MySq高级知识笔记》**如下图

全网火爆MySql 开源笔记,图文并茂易上手,阿里P8都说好

关注我,点赞本文给更多有需要的人
《一线大厂Java面试题解析+核心总结学习笔记+最新讲解视频+实战项目源码》点击传送门即可获取!
.(img-AgEl8PZp-1711929443000)]

[外链图片转存中…(img-n06jPzV5-1711929443001)]

[外链图片转存中…(img-t94ofBcU-1711929443001)]

[外链图片转存中…(img-n9k3KYfQ-1711929443001)]

《MySQL高级知识笔记》

[外链图片转存中…(img-BaKTbbQ0-1711929443002)]

[外链图片转存中…(img-N4RXg1l7-1711929443002)]

[外链图片转存中…(img-gBdT9zMH-1711929443002)]

[外链图片转存中…(img-fQ3WktPr-1711929443003)]

[外链图片转存中…(img-ngzwxqhw-1711929443003)]

[外链图片转存中…(img-zTQGd67m-1711929443004)]

[外链图片转存中…(img-BzwG4Dqe-1711929443004)]

[外链图片转存中…(img-ApzKA8R8-1711929443004)]

[外链图片转存中…(img-XPBQGJJf-1711929443004)]

[外链图片转存中…(img-9z3y1Vva-1711929443005)]

文中展示的资料包括:**《MySql思维导图》《MySql核心笔记》《MySql调优笔记》《MySql面试专题》《MySql性能优化的21个最佳实践》《MySq高级知识笔记》**如下图

[外链图片转存中…(img-NXPoSewL-1711929443005)]

关注我,点赞本文给更多有需要的人
《一线大厂Java面试题解析+核心总结学习笔记+最新讲解视频+实战项目源码》点击传送门即可获取!

  • 19
    点赞
  • 9
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值