Java网络IO学习笔记

一、网络IO

Java中的网络IO主要依赖于java.net和java.io包中的类和接口。Java的网络IO通常涉及到Socket编程,即客户端和服务器之间的通信。客户端和服务器通过Socket建立连接,并通过输入/输出流进行数据传输。

以下是一个简单的Java Socket编程示例,展示了如何创建一个简单的服务器和客户端:

服务器端代码示例:

import java.io.*;  
import java.net.*;  
  
public class Server {  
    public static void main(String[] args) throws IOException {  
        ServerSocket serverSocket = new ServerSocket(8080); // 创建一个ServerSocket在端口8080监听客户请求  
        System.out.println("服务器启动成功,等待客户端连接...");  
  
        Socket clientSocket = serverSocket.accept(); // 方法阻塞,直到一个客户连接请求被接受  
        System.out.println("客户端已连接,IP地址:" + clientSocket.getInetAddress().getHostAddress());  
  
        BufferedReader in = new BufferedReader(new InputStreamReader(clientSocket.getInputStream())); // 获取输入流,读取客户端发送的数据  
        PrintWriter out = new PrintWriter(clientSocket.getOutputStream(), true); // 获取输出流,向客户端发送数据  
  
        String inputLine;  
        while ((inputLine = in.readLine()) != null) {  
            System.out.println("收到客户端消息:" + inputLine);  
            out.println("服务器回应:" + inputLine);  
        }  
  
        in.close();  
        out.close();  
        clientSocket.close();  
        serverSocket.close();  
    }  
}

客户端代码示例:

import java.io.*;  
import java.net.*;  
  
public class Client {  
    public static void main(String[] args) throws IOException {  
        Socket socket = new Socket("localhost", 8080); // 创建一个Socket连接到服务器  
        System.out.println("已连接到服务器,正在发送消息...");  
  
        PrintWriter out = new PrintWriter(socket.getOutputStream(), true); // 获取输出流,向服务器发送数据  
        BufferedReader in = new BufferedReader(new InputStreamReader(socket.getInputStream())); // 获取输入流,读取服务器发送的数据  
        BufferedReader stdIn = new BufferedReader(new InputStreamReader(System.in)); // 读取用户输入的数据  
  
        String userInput;  
        while ((userInput = stdIn.readLine()) != null) {  
            out.println(userInput); // 发送用户输入的数据到服务器  
            System.out.println("服务器回应:" + in.readLine()); // 读取并显示服务器的回应  
        }  
  
        out.close();  
        in.close();  
        stdIn.close();  
        socket.close(); // 关闭连接  
    }  
}

以上代码演示了一个简单的回声服务器和客户端的实现。客户端发送消息到服务器,服务器将收到的消息原样返回给客户端。注意,在实际应用中,你可能需要处理各种异常情况、并发连接、数据格式等问题。此外,Java NIO(New IO)提供了更高级的网络IO功能,如非阻塞IO、选择器(Selector)等,可以进一步提高网络应用的性能和可扩展性。

二、网络IO通讯原理

网络IO(输入/输出)的原理涉及数据的传输和处理过程。网络IO的本质是Socket的读写操作,它在操作系统中被抽象为流,因此IO可以理解为对流的操作。在进行网络通信时,数据通过Socket在两端进行传输,Socket在操作系统中划分了缓存区或缓冲区,网络IO操作的就是这个缓存区。

网络IO操作会涉及到用户空间和内核空间的转换。当网络数据包到达网卡时,网卡通过DMA(直接内存访问)的方式将数据放到RingBuffer(环形缓冲区)中。然后,通过中断的方式通知CPU有数据到达。在中断处理程序中,内核将数据从RingBuffer拷贝到内核空间中的Socket接收缓冲区。此时,数据还只是在内核空间中,并没有真正被应用程序所使用。

用户进程在需要读取数据时,会发起系统调用,如read操作。在阻塞IO模型中,如果数据没有准备好,用户进程会被阻塞,直到内核将数据准备好并从内核空间拷贝到用户空间后,read调用才会返回。在非阻塞IO模型中,如果数据没有准备好,read调用会立即返回一个错误码,用户进程可以继续执行其他操作,但需要不断轮询以检查数据是否准备好。
在这里插入图片描述

三、NIO

Java NIO(New IO/Non-blocking IO)是从Java 1.4版本开始引入的一个新的IO API,可以替代标准的Java IO API。NIO与原来的IO有同样的作用和目的,但使用的方式完全不同。NIO支持面向缓冲区的、基于通道的IO操作,可以以更加高效的方式进行文件的读写操作。

Java NIO系统的核心在于:通道(Channel)和缓冲区(Buffer)。通道表示打开到IO设备(例如:文件、套接字)的连接。通道负责传输,而缓冲区负责存储。所有的IO操作都是从通道开始的,首先创建一个通道,然后创建一个或多个缓冲区。然后,可以将缓冲区的数据写入通道,或者从通道中读取数据到缓冲区。

与原来的IO不同,NIO支持非阻塞IO操作。这意味着在某些操作中,线程可以去做其他的事情,而不会被阻塞。例如,在数据还没有准备好时,线程可以继续执行其他任务,而不是被阻塞等待数据。这种非阻塞的特性使得NIO在处理大量连接请求时具有更高的性能和可扩展性。

此外,Java NIO还引入了选择器(Selector)的概念,选择器允许一个单独的线程来监视多个输入通道,并确定哪个通道已经准备好进行读取或写入。这种机制可以进一步提高服务器的性能和可扩展性。

IO和NIO的主要区别体现在它们的工作方式和效率上。

  1. 面向流与面向缓冲:Java IO是面向流的,意味着它每次从流中读取一个或多个字节,直至读取所有字节,数据并没有被缓存在任何地方。此外,它不能前后移动流中的数据。如果需要前后移动从流中读取的数据,需要先将它缓存到一个缓冲区。而Java NIO是面向缓冲区的,或者说它是基于块的。数据被读取到一个稍后处理的缓冲区,需要时可在缓冲区中前后移动,增加了处理过程中的灵活性。但是,这也需要检查是否该缓冲区中包含所有需要处理的数据,并确保当更多的数据读入缓冲区时,不要覆盖缓冲区里尚未处理的数据。
  2. 阻塞与非阻塞IO:Java IO的各种流是阻塞的。这意味着,当一个线程调用read()或write()时,该线程被阻塞,直到有一些数据被读取,或数据完全写入。该线程在此期间不能再干任何事情了。而NIO是非阻塞的,使一个线程可以管理多个输入和输出通道(channel)。
  3. 选择器(Selectors):Java NIO的选择器允许一个单独的线程来监视多个输入通道,你可以注册多个通道到一个选择器,然后使用一个单独的线程来“选择”通道:这些通道里已经有可以处理的输入,或者选择已准备写入的通道。这种选择机制,使得一个单独的线程很容易来管理多个通道。

总的来说,NIO与IO有相同的作用和目的,但实现方式不同,NIO主要用到的是块,所以NIO的效率要比IO高很多。在处理大量并发连接或需要大量数据传输的场景中,NIO通常比传统的IO更有优势。

3.1 阻塞非阻塞&异步同步

在计算机网络编程中,阻塞、非阻塞、同步和异步是描述数据传输和处理方式的术语。了解这些概念对于设计和实现高效的网络应用至关重要。下面是对这些术语的详细解释:

3.1.1 阻塞(Blocking)

  • 阻塞调用是指调用者在等待操作完成之前无法进行其他工作的调用。在阻塞操作中,如果输入/输出操作没有完成,调用者将被挂起,直到操作完成为止。
  • 例如,在阻塞的网络调用中,如果一个线程尝试从网络读取数据,但数据尚未到达,该线程将被操作系统挂起,直到数据到达并可以被读取。

3.1.2 非阻塞(Non-Blocking)

  • 非阻塞调用允许调用者在等待操作完成的同时继续执行其他工作。在非阻塞操作中,如果操作不能立即完成,调用者将收到一个指示操作未完成的错误或状态,而不是被挂起。
  • 在非阻塞网络调用中,如果数据尚未准备好,调用将立即返回一个错误或指示数据未就绪的状态,允许线程继续执行其他任务。

3.1.3 同步(Synchronous)

  • 同步操作是按照调用者发送请求的顺序来执行和返回结果的。在同步模型中,调用者发送一个请求后,必须等待操作完成才能继续执行后续的代码或发送下一个请求。
  • 同步操作通常是阻塞的,但也可以是非阻塞的(例如,通过轮询检查操作状态)。然而,即使是非阻塞的同步调用,调用者仍然需要主动管理操作的状态和完成情况。

3.1.4 异步(Asynchronous)

  • 异步操作允许调用者发送请求后继续执行其他任务,而不必等待操作完成。在异步模型中,当操作完成时,通常会通过某种机制(如回调函数、事件或Promise)通知调用者。
  • 异步操作可以显著提高应用程序的响应性和性能,因为它们允许调用者在等待耗时操作(如网络请求或磁盘I/O)完成时继续执行其他有用的工作。

3.1.5 组合使用

这些术语可以组合使用来描述不同类型的I/O操作。例如:

  • 阻塞同步:调用者发送请求后等待操作完成,期间无法进行其他工作。这是最常见的I/O模型,但也是最不高效的,因为它浪费了CPU时间等待I/O操作。
  • 非阻塞同步:调用者发送请求后不断轮询检查操作状态,直到操作完成为止。这比纯阻塞方式稍微高效一些,但仍然浪费CPU时间在轮询上。
  • 异步非阻塞:这是最高效的I/O模型之一。调用者发送请求后继续执行其他任务,当操作完成时通过异步通知机制得知结果。这种模型充分利用了CPU和I/O资源,提高了应用程序的性能和响应性。需要注意的是,“异步非阻塞”和“非阻塞异步”在本质上是相同的,都强调了非阻塞和异步的特性。然而,在实际使用中,“异步非阻塞”更为常见,因为它更直观地突出了异步操作的非阻塞性质。同时,需要注意的是,“非阻塞”并不总是与“异步”等同。非阻塞操作可以是同步的(如轮询),也可以是异步的(如基于事件驱动的I/O)。同样地,“异步”操作也不一定是非阻塞的;它们可以设计成在内部使用阻塞调用,但对外表现为异步行为(例如,通过内部线程池或异步任务处理机制)。因此,在理解和使用这些术语时,需要明确具体的上下文和含义。在网络编程中,通常推荐使用异步非阻塞的I/O模型来构建高性能、高并发的应用程序。这种模型能够充分利用系统资源,提高程序的响应性和吞吐量。然而,它也需要更复杂的编程模型和更精细的资源管理策略来确保正确性和效率。

3.2 五种常见的IO模型

3.2.1 阻塞I/O模型

在此模型中,用户线程调用内核I/O操作,并等待I/O操作彻底完成后才返回到用户空间。这种模型能够及时返回数据,无延迟,但用户需要付出等待的性能代价。
在这里插入图片描述

3.2.2 非阻塞I/O模型

与阻塞I/O相反,非阻塞I/O操作在调用后会立即返回给用户一个状态值,无需等到I/O操作彻底完成。然而,这种模型可能导致任务完成的响应延迟增大,因为需要每隔一段时间去轮询一次I/O操作,而任务可能在两次轮询之间的任意时间完成。这会导致整体数据吞吐量的降低。

在这里插入图片描述

3.2.3 I/O复用模型

此模型在非阻塞I/O的基础上,将多个连接的可读可写事件剥离出来,使用单独的线程或进程来对其进行管理。多个I/O通路可以复用这个管理器来管理socket状态。select、poll、epoll机制可以实现这种模型。

在这里插入图片描述

3.2.4 信号驱动的I/O模型

在这种模型中,当I/O事件就绪时,进程会收到SIGIO信号,然后处理I/O事件。这种模型允许进程在I/O操作期间继续执行其他任务,而不是被阻塞。

在这里插入图片描述

3.2.5 异步I/O模型

这是最复杂的I/O模型。在此模型中,用户线程发起I/O操作后可以继续执行其他任务,而不需要等待I/O操作的完成。当I/O操作完成后,内核会通知用户线程。这种模型需要操作系统的支持,并且需要复杂的编程模型来实现。

在这里插入图片描述

需要注意的是,不同的操作系统和编程环境可能支持不同的I/O模型,而且在实际应用中,需要根据具体的需求和性能要求来选择合适的I/O模型。同时,不同的I/O模型也有各自的优缺点和适用场景,需要综合考虑。

3.3 Channel、Buffer和Selector

3.3.1 Channel(通道)

通道是NIO中的一个核心概念,它表示一个可以异步读写数据的通道。与传统的I/O模型中的流不同,NIO中的通道是双向的,可以同时进行读写操作,并且支持非阻塞I/O,提高了数据处理的效率。

通道的主要类型包括:

  • FileChannel:用于文件的读写操作。
  • SocketChannel:用于TCP网络编程中的客户端连接和数据传输。
  • ServerSocketChannel:用于TCP网络编程中的服务器端监听和接受客户端连接。
  • DatagramChannel:用于UDP网络编程中的数据传输。

3.3.2 Buffer(缓冲区)

缓冲区是NIO中的另一个核心概念,它用于存储数据,并提供了一组方法来访问和操作这些数据。在NIO中,所有的数据读写操作都是通过缓冲区来完成的。当数据从通道中读取时,它会被存储到缓冲区中;当数据要写入通道时,也需要首先从缓冲区中获取。

Java NIO提供了多种类型的缓冲区,如ByteBufferCharBufferDoubleBuffer等,每种缓冲区都对应一种特定的数据类型。其中,ByteBuffer是最常用的缓冲区类型,它可以存储字节类型的数据。

缓冲区中有几个重要的属性:

  • position:表示下一个要读取或写入的数据的位置。
  • limit:表示缓冲区的限制,即可以操作的数据的上界。
  • capacity:表示缓冲区的总容量。

3.3.3 Selector(选择器)

选择器是NIO中的另一个重要组件,它可以检测多个注册的通道上是否有事件发生,如果有事件发生,便获取事件然后针对每个事件进行相应的响应处理。这样,单个线程可以管理多个通道,提高了系统的并发性和性能。

使用选择器的主要步骤包括:

  1. 创建Selector对象。
  2. 注册通道到Selector上,并指定感兴趣的事件类型。
  3. 调用Selector的select()方法,该方法会阻塞直到有通道发生了感兴趣的事件。
  4. 通过Selector的selectedKeys()方法获取发生事件的SelectionKey集合,然后遍历该集合处理每个事件。

3.4 SocketChannel和ServerSocketChannel

3.4.1 服务器端代码(EchoServer.java)

import java.io.IOException;  
import java.net.InetSocketAddress;  
import java.nio.ByteBuffer;  
import java.nio.channels.SelectionKey;  
import java.nio.channels.Selector;  
import java.nio.channels.ServerSocketChannel;  
import java.nio.channels.SocketChannel;  
import java.util.Iterator;  
import java.util.Set;  
  
public class EchoServer {  
  
    private Selector selector;  
  
    public void startServer() throws IOException {  
        // 创建一个Selector  
        selector = Selector.open();  
  
        // 打开一个ServerSocketChannel  
        ServerSocketChannel ssc = ServerSocketChannel.open();  
  
        // 设置为非阻塞模式  
        ssc.configureBlocking(false);  
  
        // 绑定监听端口  
        ssc.bind(new InetSocketAddress(8000));  
  
        // 注册监听ACCEPT事件  
        ssc.register(selector, SelectionKey.OP_ACCEPT);  
  
        // 循环等待客户端连接  
        while (true) {  
            selector.select(); // 阻塞等待需要处理的事件  
            Set<SelectionKey> selectedKeys = selector.selectedKeys();  
            Iterator<SelectionKey> iter = selectedKeys.iterator();  
  
            while (iter.hasNext()) {  
                SelectionKey key = iter.next();  
  
                if (key.isAcceptable()) { // 接收客户端连接  
                    register(selector, ssc);  
                }  
  
                if (key.isReadable()) { // 读取客户端数据  
                    read(key);  
                }  
  
                iter.remove(); // 处理完事件后,需要手动从集合中移除  
            }  
        }  
    }  
  
    private void register(Selector selector, ServerSocketChannel ssc) throws IOException {  
        SocketChannel sc = ssc.accept();  
        sc.configureBlocking(false);  
        sc.register(selector, SelectionKey.OP_READ);  
        System.out.println("Connected to " + sc.getRemoteAddress());  
    }  
  
    private void read(SelectionKey key) throws IOException {  
        SocketChannel sc = (SocketChannel) key.channel();  
        ByteBuffer buffer = ByteBuffer.allocate(1024);  
        int bytesRead;  
        try {  
            bytesRead = sc.read(buffer);  
        } catch (IOException e) {  
            key.cancel();  
            sc.close();  
            return;  
        }  
  
        if (bytesRead == -1) {  
            sc.close();  
        } else {  
            buffer.flip();  
            sc.write(buffer); // 将读取到的数据写回客户端(回声)  
            buffer.clear();  
        }  
    }  
  
    public static void main(String[] args) throws IOException {  
        new EchoServer().startServer();  
    }  
}

3.4.2 客户端代码(EchoClient.java)

import java.io.IOException;  
import java.net.InetSocketAddress;  
import java.nio.ByteBuffer;  
import java.nio.channels.SocketChannel;  
  
public class EchoClient {  
  
    public static void main(String[] args) throws IOException {  
        SocketChannel sc = SocketChannel.open();  
        sc.configureBlocking(false);  
  
        // 连接到服务器  
        sc.connect(new InetSocketAddress("localhost", 8000));  
  
        // 等待连接完成  
        while (!sc.finishConnect()) {  
            // 在非阻塞模式下,可能需要多次调用finishConnect()才能完成连接  
            System.out.println("连接尚未完成,继续尝试...");  
            try {  
                Thread.sleep(1000);  
            } catch (InterruptedException e) {  
                e.printStackTrace();  
                return;  
            }  
        }  
  
        System.out.println("连接成功!");  
  
        // 发送数据并接收回声  
        String message = "Hello, Server!";  
        ByteBuffer buffer = ByteBuffer.wrap(message.getBytes());  
        int bytesWritten = sc.write(buffer);  
        System.out.println("发送了 " + bytesWritten + " 个字节。");  
  
        buffer.clear();  
        int bytesRead = sc.read(buffer);  
        System.out.println("接收了 " + bytesRead + " 个字节。");  
  
        buffer.flip();  
        System.out.println("收到信息: " + new String(buffer.array()).trim());  
  
        // 关闭连接  
        sc.close();  
    }  
}

在这个例子中,服务器端使用了Selector来处理多个SocketChannel的非阻塞IO事件。它监听OP_ACCEPT事件以接收新的客户端连接,并在接受连接后注册OP_READ事件以读取客户端发送的数据。当数据到达时,它将数据读取到ByteBuffer中,然后将相同的数据写回客户端,实现回声功能。

客户端代码则简单得多,它打开一个SocketChannel,连接到服务器,然后发送一个消息并等待接收回声。在实际应用中,客户端通常也会使用非阻塞IO和选择器来处理多个通道。但在这个简单例子中,我们只展示了一个基本的同步客户端。

3.5 零拷贝

在Java NIO中,零拷贝(Zero-Copy)技术允许数据在内核空间和用户空间之间传输时,避免不必要的拷贝操作,从而提高数据传输的效率。这主要通过使用FileChanneltransferTo()transferFrom()map()方法来实现。

3.5.1 传统I/O与零拷贝I/O的比较

在传统I/O操作中,数据通常首先从内核空间拷贝到用户空间,然后再从用户空间拷贝回内核空间,最后发送到网络上。这个过程中发生了多次不必要的数据拷贝。

而在零拷贝I/O中,数据可以直接从内核空间发送到网络上,或者从文件直接拷贝到内核空间的缓冲区中,然后发送到网络上,避免了在用户空间和内核空间之间的多次拷贝。

在Java NIO中,FileChannel 类提供了 map() 方法,允许你将文件的一部分或全部直接映射到内存中。这种方法称为内存映射文件(Memory-Mapped Files)。通过内存映射文件,你可以对文件进行高效、随机的访问,并且在很多情况下,这种方式比传统的基于流或基于通道的I/O操作更快。

内存映射文件的一个重要用途是实现零拷贝数据传输。在传统的I/O操作中,数据通常需要在用户空间和内核空间之间来回拷贝。然而,通过使用内存映射文件,应用程序可以直接访问内核缓冲区中的数据,从而避免了不必要的拷贝操作。

3.5.2 FileChannel.map()示例

import java.io.RandomAccessFile;  
import java.nio.MappedByteBuffer;  
import java.nio.channels.FileChannel;  
  
public class MemoryMappedFileExample {  
    public static void main(String[] args) throws Exception {  
        // 打开文件并获取FileChannel  
        RandomAccessFile file = new RandomAccessFile("data.txt", "rw");  
        FileChannel fileChannel = file.getChannel();  
  
        // 将文件的一部分映射到内存中  
        long position = 0; // 映射文件的起始位置  
        long size = fileChannel.size(); // 映射文件的大小  
        MappedByteBuffer mappedByteBuffer = fileChannel.map(FileChannel.MapMode.READ_WRITE, position, size);  
  
        // 现在可以通过mappedByteBuffer直接访问文件内容,而无需进行额外的I/O操作  
        // 例如,读取文件的前10个字节:  
        byte[] bytes = new byte[10];  
        mappedByteBuffer.get(bytes);  
  
        // 关闭FileChannel和RandomAccessFile  
        fileChannel.close();  
        file.close();  
    }  
}
package com.yuc.nio.v1.zerocopy;

import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.nio.MappedByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.file.Paths;
import java.nio.file.StandardOpenOption;

public class ZeroCopyChannel {
    public static void main(String[] args) throws Exception {
				// 实现文件的零拷贝
        FileChannel sourceChannel = FileChannel.open(Paths.get("/nio/v1/txt/hello.txt"), StandardOpenOption.READ);
        FileChannel targetChannel = FileChannel.open(Paths.get("/nio/v1/txt/hello_zero_copy.txt"), StandardOpenOption.WRITE, StandardOpenOption.CREATE, StandardOpenOption.READ);

        MappedByteBuffer sourceBuffer = sourceChannel.map(FileChannel.MapMode.READ_ONLY, 0, sourceChannel.size());
        MappedByteBuffer targetBuffer = targetChannel.map(FileChannel.MapMode.READ_WRITE, 0, sourceChannel.size());

        byte[] bytes = new byte[sourceBuffer.limit()];

        sourceBuffer.get(bytes);
        targetBuffer.put(bytes);

        sourceChannel.close();
        targetBuffer.clear();
    }
}

3.5.3 fileChannel.transferTo()

import java.io.RandomAccessFile;  
import java.nio.channels.FileChannel;  
import java.nio.channels.SocketChannel;  
import java.net.InetSocketAddress;  
  
public class ZeroCopyExample {  
    public static void main(String[] args) throws Exception {  
        // 打开文件通道  
        RandomAccessFile file = new RandomAccessFile("data.txt", "r");  
        FileChannel fileChannel = file.getChannel();  
  
        // 打开套接字通道并连接到服务器  
        SocketChannel socketChannel = SocketChannel.open();  
        socketChannel.connect(new InetSocketAddress("localhost", 8080));  
  
        // 使用transferTo方法实现零拷贝  
        long position = 0;  
        long count = fileChannel.size();  
        fileChannel.transferTo(position, count, socketChannel);  
  
        // 关闭通道和文件  
        socketChannel.close();  
        fileChannel.close();  
        file.close();  
    }  
}

3.5.4 FileChannel.transferFrom()

import java.io.FileInputStream;  
import java.io.FileOutputStream;  
import java.io.IOException;  
import java.nio.channels.FileChannel;  
  
public class FileChannelTransferExample {  
    public static void main(String[] args) {  
        String sourceFile = "source.txt"; // 源文件路径  
        String destinationFile = "destination.txt"; // 目标文件路径  
  
        try (FileInputStream fis = new FileInputStream(sourceFile);  
             FileOutputStream fos = new FileOutputStream(destinationFile);  
             FileChannel sourceChannel = fis.getChannel();  
             FileChannel destinationChannel = fos.getChannel()) {  
  
            // 获取源文件的大小  
            long sourceFileSize = sourceChannel.size();  
  
            // 使用transferFrom()方法从源通道传输数据到目标通道  
            // 这里将源文件的全部内容传输到目标文件  
            destinationChannel.transferFrom(sourceChannel, 0, sourceFileSize);  
  
            System.out.println("File transfer completed successfully.");  
  
        } catch (IOException e) {  
            e.printStackTrace();  
        }  
    }  
}
  • 16
    点赞
  • 19
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值