JDK 中 NIO 的实现源码分析(二十二)

今天分析JDK 中 NIO 的实现源码
通过前面对 JDK NIO 的学习,我们知道, NIO 中关键的几个类就是 Selector 、 SocketChannel、 ServerSocketChannel SelectionKey ,我们下面一一来分析这几个类。
一、SocketChannel ServerSocketChannel类的分析:
1、ServerSocketChannel 类关系图
 

2、SocketChannel 时序图: 

 
 
通过对类关系图的查看,我们可以看到 SocketChannel ServerSocketChannel 父类几乎是一样的,都有 AbstractInterruptibleChannel SelectableChannel AbstractSelectableChannel
 
3、AbstractInterruptibleChannel
AbstractInterruptibleChannel 表示可异步关闭和中断的 Channel
  (1 )实现 InterruptibleChannel 接口的 Channel 支持异步关闭:如果一个线程 IO 阻塞在一个可中断的 channel ,另一个线程可以执行 channel close 方法。这将导致阻塞线程收到
AsynchronousCloseException 异常。
2 )实现 InterruptibleChannel 接口的 Channel 支持中断:如果一个线程 IO 阻塞在一个可中断的 Channel ,另一个线程可以执行阻塞线程的 interrupt 方法。这将导致 Channel 关闭,
阻塞线程收到 ClosedByInterruptException 异常,阻塞线程将是 interrupted 状态。
3 )如果线程已经中断,然后在 Channel 执行阻塞 IO 操作, channel 将关闭,线程将立刻收到 ClosedInterruptException 异常,且中断状态会保持。
怎么实现线程中断时关闭 channel 呢?不能指望调用者调用 thread.interrupt() 后,在调用 Channel.close() 能够想到的办法就是将 channel close 方法放到 thread.interrupt() 方法中, JDK 即使这么做的。JDK Thread 类中:
private volatile Interruptible blocker;
    private final Object blockerLock = new Object();

    /* Set the blocker field; invoked via sun.misc.SharedSecrets from java.nio code
     */
    void blockedOn(Interruptible b) {
        synchronized (blockerLock) {
            blocker = b;
        }
    }

blockedOn 方法用以设置 blocker 字段

 
 public void interrupt() {
        if (this != Thread.currentThread())
            checkAccess();

        synchronized (blockerLock) {
            Interruptible b = blocker;
            if (b != null) {
                interrupt0();           // Just to set the interrupt flag
                b.interrupt(this);
                return;
            }
        }
        interrupt0();
    }

4、在 AbstractInterruptibleChannel begin 方法中:

private Interruptible interruptor;
    private volatile Thread interrupted;

    /**
     * Marks the beginning of an I/O operation that might block indefinitely.
     *
     * <p> This method should be invoked in tandem with the {@link #end end}
     * method, using a <tt>try</tt>&nbsp;...&nbsp;<tt>finally</tt> block as
     * shown <a href="#be">above</a>, in order to implement asynchronous
     * closing and interruption for this channel.  </p>
     */
    protected final void begin() {
        if (interruptor == null) {
            interruptor = new Interruptible() {
                    public void interrupt(Thread target) {
                        synchronized (closeLock) {
                            if (!open)
                                return;
                            open = false;
                            interrupted = target;
                            try {
                                AbstractInterruptibleChannel.this.implCloseChannel();
                            } catch (IOException x) { }
                        }
                    }};
        }
        blockedOn(interruptor);
        Thread me = Thread.currentThread();
        if (me.isInterrupted())
            interruptor.interrupt(me);
    }

给 AbstractInterruptibleChannel 里的 Interruptible 接口类型的成员变量 interruptor 赋予初值,很明显,在赋值语句中,被赋予的值是一个实现了 Interruptible 接口的匿名类。 在这个匿名类的 interrupt 方法中,做了两件事,第一,保存当前被中断的线程是谁,第二,对 Channel 进行了关闭。 接下来,将这个 interruptor 设置给当前调用 begin 的线程。 按照我们前面对 Thread 类的检视我们看见,Thread interrupt 方法中除了常规的 interrupt 操作,还有对线程 Interruptible 接口类型的成员变量 blocker interrupt 方法的调用。所以综合来说,线程在中断时,除了对线程常规的 interrupt 操作-设置一个中断标志位外,完全还有可能对对 Channel 进行了关闭。 在 end 方法中:

protected final void end(boolean completed)
    throws AsynchronousCloseException
{
    blockedOn(null);//清空线程的blocker字段
    Thread interrupted = this.interrupted;
    //如果线程被中断,则begin里面初始化的Interruptible对象的interrupt方法里面设置了interrupted 变量为被中断的线程
    if (interrupted != null && interrupted == Thread.currentThread()) {
        interrupted = null;
        throw new ClosedByInterruptException();//如果被中断抛出此异常
    }
    if (!completed && !open)//如果被close ,抛出此异常
        throw new AsynchronousCloseException();
}
5、SelectableChannel
 
SelectableChannel 则在 AbstractInterruptibleChannel 的基础之上,加入对 SelectionKey 注册到 selector 上的支持, SelectableChannel 本身是个抽象类而且基本都是抽象方法,并没有
什么实现。
 
6、AbstractSelectableChannel
主要是对 SelectableChannel SelectionKey 注册到 selector 上的具体实现。 因为 Channel 是允许注册多个 key selector 上的,同时也允许注册到多个 selector 之上,所以,在内部有个 SelectionKey 的数组 keys
private SelectionKey[] keys = null;
在注册方法 register
public final SelectionKey register(Selector sel, int ops,
                                       Object att)
        throws ClosedChannelException
    {
        synchronized (regLock) {
            if (!isOpen())
                throw new ClosedChannelException();
            if ((ops & ~validOps()) != 0)
                throw new IllegalArgumentException();
            if (blocking)
                throw new IllegalBlockingModeException();
            SelectionKey k = findKey(sel);
            if (k != null) {
                k.interestOps(ops);
                k.attach(att);
            }
            if (k == null) {
                // New registration
                synchronized (keyLock) {
                    if (!isOpen())
                        throw new ClosedChannelException();
                    k = ((AbstractSelector)sel).register(this, ops, att);
                    addKey(k);
                }
            }
            return k;
        }
    }

首先进行检查,比如通道是否打开、注册键值是否有效、通道是否非阻塞模式等等,然后检查数组 keys 中的 key 能否在 selector 找到,找到则直接修改 key 关注的键值并替换附件, 没找到,则在 selector 中注册这个 key,并加入到内部 SelectionKey 的数组 keys 中。AbstractSelectableChannel 中的其他方法基本都是为 key 的注册服务的,可以自行查阅相关代码。

 
7、SocketChannel
SocketChannel 是个抽象类,主要提供对 Socket 的访问方法的抽象定义,非抽象方法不多,有意义的就是 open 方法和 Selector 一样,内部实际是通过 SelectorProvider 来创建的 SocketChannel 的实例。从调用链来说,由 SelectorProviderImpl openSocketChannel() 返回了一个 SocketChannelImpl 的实例从 SocketChannelImpl 的声明可以看出
 private static final int ST_UNINITIALIZED = -1;
    private static final int ST_UNCONNECTED = 0;
    private static final int ST_PENDING = 1;
    private static final int ST_CONNECTED = 2;
    private static final int ST_KILLPENDING = 3;
    private static final int ST_KILLED = 4;

SocketChannel 有生命周期,SocketChannel 会有一个 state 标记当前的状态,默认为-1 表示 ST_UNINITIALIZED(未初始化) 在构造函数最后会将 state 更新为 0(ST_UNCONNECTED,未连接) 调用 connect 连接服务端,连接成功之前更新 state 1(ST_PENDING,待连接) 连接成功时会更新 state 2(ST_CONNECTED,已连接) 关闭通道时若 I/O 未完成时会将 state 更新为 3(ST_KILLPENDING,待释放) 当关闭通道后,且所有 I/O 已完成,会将 state 更新为 4(ST_KILLED,已释放)

private Socket socket;

同时在具体的读写和连接等网络操作上,依然是由具体的 socket 负责并最终交给 native 方法来实现。 比如连接操作,首先同步读锁和写锁,确保 socket 通道打开,并没有连接;然后检查 socket 地址的正确性与合法性,然后检查当前线程是否有 Connect 方法的访问控制权限, 最后尝试连接 socket 地址。

 
8、ServerSocketChannel
是个抽象类,主要提供对 ServerSocket 的访问方法的抽象定义,比如 accept bind 等等, 非抽象方法不多,有意义的就是 open 方法
public static ServerSocketChannel open() throws IOException {
        return SelectorProvider.provider().openServerSocketChannel();
    }

进入这个类 public abstract class SelectorProviderImpl extends SelectorProvider

public ServerSocketChannel openServerSocketChannel() throws IOException {
        return new ServerSocketChannelImpl(this);
    }

进入 class ServerSocketChannelImpl extends ServerSocketChannel implements SelChImpl

ServerSocketChannelImpl(SelectorProvider var1) throws IOException {
        super(var1);
        this.fd = Net.serverSocket(true);
        this.fdVal = IOUtil.fdVal(this.fd);
        this.state = 0;
    }

和 Selector 一样,内部实际是通过 SelectorProvider 来创建的 SocketChannel 的实例。从调用链来说,由 SelectorProviderImpl openServerSocketChannel()返回了一个 ServerSocketChannelImpl 的实例 从 ServerSocketChannelImpl 的实现可以看出,其实 ServerSocketChannelImpl 在内部和 SocketChannelImpl 非常相似,在具体的 accept bind 等网络操作上,依然是由具体的 socket 负责并最终交给 native 方法来实现。

 
9、Selector 类关系图
 
 
 
我们知道,客户端不定时的会与服务端进行连接,而在高并发场景中,大多数连接实际上是空闲的。因此为了提高网络传输高并发的性能,就出现各种 I/O 模型从而优化 CPU 处理
效率。不同选择器实现了不同的 I/O 模型算法。 同步 I/O 在 linux 上有 EPoll 模型,windows上则为 select 模型(重要) 。所以, select 在具体的实现上和操作系统密切相关,比如 windows 下的 JDK 中就有一个 WindowsSelectorImpl
 
创建
我们通过 Selector.open() 静态方法创建了一个 Selector 。内部实际是通过SelectorProvider.openSelector()方法创建 Selector
  /*创建选择器的实例*/
            selector = Selector.open();

进入:

  public static Selector open() throws IOException {
        return SelectorProvider.provider().openSelector();
    }

进入:

public static SelectorProvider provider() {
        synchronized (lock) {
            if (provider != null)
                return provider;
            return AccessController.doPrivileged(
                new PrivilegedAction<SelectorProvider>() {
                    public SelectorProvider run() {
                            if (loadProviderFromProperty())
                                return provider;
                            if (loadProviderAsService())
                                return provider;
                            provider = sun.nio.ch.DefaultSelectorProvider.create();
                            return provider;
                        }
                    });
        }
    }

进入:

 
  通过 SelectorProvider.provider() 静态方法,获取到 SelectorProvider ,首次获取时会通过配置等方式注入,若没有配置,则使用 DefaultSelectorProvider 生成。 不同平台的 DefaultSelectorProvider 实现不一样。可以在 jdk\src\[macosx|windows|solaris]\classes\sun\nio\ch 找到实现 DefaultSelectorProvider.java , 而在 Linux 下则是:
/**
 * Creates this platform's default SelectorProvider
 */

public class DefaultSelectorProvider {
    private static final SelectorProviderImpl INSTANCE;
    static {
        PrivilegedAction<SelectorProviderImpl> pa = EPollSelectorProvider::new;
        INSTANCE = AccessController.doPrivileged(pa);
    }

    /**
     * Prevent instantiation.
     */
    private DefaultSelectorProvider() { }

    /**
     * Returns the default SelectorProvider implementation.
     */
    public static SelectorProviderImpl get() {
        return INSTANCE;
    }
}

或这个版本

可以看见,Linux 下JDK创建的是 EpollSelectorProvider

package sun.nio.ch;

import java.io.IOException;
import java.nio.channels.*;
import java.nio.channels.spi.*;

public class EPollSelectorProvider
    extends SelectorProviderImpl
{
    public AbstractSelector openSelector() throws IOException {
        return new EPollSelectorImpl(this);
    }

    public Channel inheritedChannel() throws IOException {
        return InheritedChannel.getChannel();
    }
}
通过 EpollSelectorProvider 的 openSelector() 方法,其实拿到的 Selector 实例是 EpollSelectorImpl。  
10、SelectorImpl

Selector 基本上都是抽象方法,非抽象的 open()方法我们前面已经介绍过了。 AbstractSelector 中的方法也没有特别值得留意的地方,当然其中也用到了和 AbstractInterruptibleChannel 类似的支持可异步关闭和中断的机制,前面已经讲述过,这里不再重复讲述。所以,关键还在于 SelectorImpl。 SelectorImpl 的原始 Java 文件可以在 jdk\src\share\classes\sun\nio\ch 下找到。 在创建 SelectorImpl 首先会初始化 2 HashSetpublicKeys 用于存放所有注册的 SelectionKeypublicSelectedKeys 用于存放已就绪的 SelectionKey

类图:

类关系:public abstract class AbstractSelector extends Selector

public abstract class SelectorImpl extends AbstractSelector {
    protected Set<SelectionKey> selectedKeys = new HashSet();
    protected HashSet<SelectionKey> keys = new HashSet();
    private Set<SelectionKey> publicKeys;
    private Set<SelectionKey> publicSelectedKeys;

    protected SelectorImpl(SelectorProvider var1) {
        super(var1);
        if (Util.atBugLevel("1.4")) {
            this.publicKeys = this.keys;
            this.publicSelectedKeys = this.selectedKeys;
        } else {
            this.publicKeys = Collections.unmodifiableSet(this.keys);
            this.publicSelectedKeys = Util.ungrowableSet(this.selectedKeys);
        }

    }

11、EpollSelectorImpl

 
EPollSelectorImpl 的主要的数据结构和属性如下:
class EPollSelectorImpl extends SelectorImpl {

    // maximum number of events to poll in one call to epoll_wait
    private static final int NUM_EPOLLEVENTS = Math.min(IOUtil.fdLimit(), 1024);

    // epoll file descriptor
    private final int epfd;

    // address of poll array when polling with epoll_wait
    private final long pollArrayAddress;

    // eventfd object used for interrupt
    private final EventFD eventfd;

    // maps file descriptor to selection key, synchronize on selector
    private final Map<Integer, SelectionKeyImpl> fdToKey = new HashMap<>();

    // pending new registrations/updates, queued by setEventOps
    private final Object updateLock = new Object();
    private final Deque<SelectionKeyImpl> updateKeys = new ArrayDeque<>();

    // interrupt triggering and clearing
    private final Object interruptLock = new Object();
    private boolean interruptTriggered;
或者此版本
 

 

Map<Integer,SelectionKeyImpl> fdToKey  用以保存文件描述符句柄和的 SelectionKey 的映射关系
int fd0 管道的读端文件描述符
int fd1 管道的写端文件描述符
管道概念:
1)、 管道是操作提供的一块操作系统内存;
2) 、管道是 Unix 中最古老的进程间通信方式;
3) 、我们把一个进程连接到另一进程的数据流称为管道。
这里使用管道的主要目的是为了唤醒线程用。当线程中断时通过向写管道写入一个字节来唤醒线程。
12、EPollArrayWrapper
调用底层 Epoll 算法的包装类, EpollSelectorImpl 中的其他方法,则通过 EPollArrayWrapper 中的包装方法,进而调用 native 方法来实现和操作系统的交互。 在 EPollArrayWrapper 创建时候会创建 epoll 文件描述符和 epoll_event 数组结构。 EPollArrayWrapper 内部会为维护两个结构,当句柄值小于 MAX_UPDATE_ARRAY_SIZE 时会保存到数组结构中。否则会存储到 Map 中。主要是优化效率。
 
在初始化 pollArray 数组时,在 EPollArrayWrapper 内部使用 AllocatedNativeObject 对象 创建的堆外(native) 内存对象。将数组的首地址保存到 pollArrayAddress 中,在调用 epollWait
的时候需要传递该参数给 JNI ,其实就对应着 Linux epoll_wait 函数的 struct epoll_event * events 参数,专门保存。 EPollArrayWrapper 也暴露了读写 FD Event 的方法供 EPollSelectorImpl 使用。
 
注册
当我们在 Java 代码中写下
/*注册事件,表示关心客户端连接*/
serverSocketChannel.register(selector,SelectionKey.OP_ACCEPT);

时会发生什么呢?通过对代码的追踪,我们可以发现,执行的注册代码是由AbstractSelectableChannel 中的 register 方法,调用了 Selector 的 register 方法进行注册,这是个抽象方法,在 SelectorImpl 中有具体实现:

protected final SelectionKey register(AbstractSelectableChannel var1, int var2, Object var3) {
    if (!(var1 instanceof SelChImpl)) {
        throw new IllegalSelectorException();
    } else {
        SelectionKeyImpl var4 = new SelectionKeyImpl((SelChImpl)var1, this);
        var4.attach(var3);
        synchronized(this.publicKeys) {
            this.implRegister(var4);
        }

        var4.interestOps(var2);
        return var4;
    }
}

protected abstract void implRegister(SelectionKeyImpl var1);

但是很明显,执行具体的注册的 implRegister 方法也是个抽象方法,具体的注册实现在 EpollSelectorImpl 中:

win环境实现:

//class WindowsSelectorImpl extends SelectorImpl 类实现
 @Override
    protected void implRegister(SelectionKeyImpl ski) {
        ensureOpen();
        synchronized (updateLock) {
            newKeys.addLast(ski);
        }
    }

linux实现:

 
 
 
很明显,这个方法中所做的事情有:
1)、获取通道的句柄;
2)、加入到文件描述符句柄和 SelectionKey 的映射关系缓存 Map 中;
3)、加入到 EPollArrayWrapper 的数组缓存中,加入过程中会根据 fd 的大小存放到 Map 类型的 eventsHigh 或字节数组 eventsLow 中;
 
二、结论
epoll select poll poll select 基本一样,有少量改进)的基础引入了 eventpoll 作为中间层,使用了先进的数据结构,是一种高效的多路复用技术。 同时存放所有注册的 SelectionKey Set 集合中加入这个 key 但是我们发现,这里的注册并没有调用 epollCtl 进行操作系统级别的注册。在操作系统上的注册是什么时候发生的呢?在我们调用 select 方法的时候。
1、Select
我们常用 select()方法从中加载已就绪的文件描述符:进入  SelectorImpl 类
public int select(long var1) throws IOException {
    if (var1 < 0L) {
        throw new IllegalArgumentException("Negative timeout");
    } else {
        return this.lockAndDoSelect(var1 == 0L ? -1L : var1);
    }
}

public int select() throws IOException {
    return this.select(0L);
}

public int selectNow() throws IOException {
    return this.lockAndDoSelect(0L);
}

可见最终都是 lockAndDoSelect 方法实现的

protected abstract int doSelect(long var1) throws IOException;

private int lockAndDoSelect(long var1) throws IOException {
    synchronized(this) {
        if (!this.isOpen()) {
            throw new ClosedSelectorException();
        } else {
            int var10000;
            synchronized(this.publicKeys) {
                synchronized(this.publicSelectedKeys) {
                    var10000 = this.doSelect(var1);
                }
            }

            return var10000;
        }
    }
}
doSelect 是个抽象方法,所以最终会调用具体 SelectorImpl doSelect,比如 EpollSelectorImpl 中:
@Override
    protected int doSelect(Consumer<SelectionKey> action, long timeout)
        throws IOException
    {
        assert Thread.holdsLock(this);

        // epoll_wait timeout is int
        int to = (int) Math.min(timeout, Integer.MAX_VALUE);
        boolean blocking = (to != 0);
        boolean timedPoll = (to > 0);

        int numEntries;
        processUpdateQueue();
        processDeregisterQueue();
        try {
            begin(blocking);

            do {
                long startTime = timedPoll ? System.nanoTime() : 0;
                numEntries = EPoll.wait(epfd, pollArrayAddress, NUM_EPOLLEVENTS, to);
                if (numEntries == IOStatus.INTERRUPTED && timedPoll) {
                    // timed poll interrupted so need to adjust timeout
                    long adjust = System.nanoTime() - startTime;
                    to -= TimeUnit.MILLISECONDS.convert(adjust, TimeUnit.NANOSECONDS);
                    if (to <= 0) {
                        // timeout expired so no retry
                        numEntries = 0;
                    }
                }
            } while (numEntries == IOStatus.INTERRUPTED);
            assert IOStatus.check(numEntries);

        } finally {
            end(blocking);
        }
        processDeregisterQueue();
        return processEvents(numEntries, action);
    }

继续调,processEvents方法:

 private int processEvents(int numEntries, Consumer<SelectionKey> action)
        throws IOException
    {
        assert Thread.holdsLock(this);

        boolean interrupted = false;
        int numKeysUpdated = 0;
        for (int i=0; i<numEntries; i++) {
            long event = EPoll.getEvent(pollArrayAddress, i);
            int fd = EPoll.getDescriptor(event);
            if (fd == eventfd.efd()) {
                interrupted = true;
            } else {
                SelectionKeyImpl ski = fdToKey.get(fd);
                if (ski != null) {
                    int rOps = EPoll.getEvents(event);
                    numKeysUpdated += processReadyEvents(rOps, ski, action);
                }
            }
        }

        if (interrupted) {
            clearInterrupt();
        }

        return numKeysUpdated;
    }

win环境 WindowsSelectorImpl extends SelectorImpl 类中:

@Override
    protected int doSelect(Consumer<SelectionKey> action, long timeout)
        throws IOException
    {
        assert Thread.holdsLock(this);
        this.timeout = timeout; // set selector timeout
        processUpdateQueue();
        processDeregisterQueue();
        if (interruptTriggered) {
            resetWakeupSocket();
            return 0;
        }
        // Calculate number of helper threads needed for poll. If necessary
        // threads are created here and start waiting on startLock
        adjustThreadsCount();
        finishLock.reset(); // reset finishLock
        // Wakeup helper threads, waiting on startLock, so they start polling.
        // Redundant threads will exit here after wakeup.
        startLock.startThreads();
        // do polling in the main thread. Main thread is responsible for
        // first MAX_SELECTABLE_FDS entries in pollArray.
        try {
            begin();
            try {
                subSelector.poll();
            } catch (IOException e) {
                finishLock.setException(e); // Save this exception
            }
            // Main thread is out of poll(). Wakeup others and wait for them
            if (threads.size() > 0)
                finishLock.waitForHelperThreads();
          } finally {
              end();
          }
        // Done with poll(). Set wakeupSocket to nonsignaled  for the next run.
        finishLock.checkForException();
        processDeregisterQueue();
        int updated = updateSelectedKeys(action);
        // Done with poll(). Set wakeupSocket to nonsignaled  for the next run.
        resetWakeupSocket();
        return updated;
    }

具体内部主要执行 3 件事:

 
1) 、调用 native 方法获取已就绪的文件描述符。
2) 、调用 updateSelectedKeys 更新已就绪事件的 SelectorKey ,当获取到已就绪的SelectionKey 后,我们就可以在 Java 代码中遍历他们,根据 SelectionKey 的事件类型决定需
要执行的具体逻辑。
3) 、使用 processDeregisterQueue 方法会遍历所有调用了 key.cancel 方法的 key 并执行实际的取消注册。因为考虑到在“获取已就绪的文件描述符(poll 方法 ) ”期间可能会有 channel
被关闭,因此需要再次调用删除取消 key
2、pollWrapper.poll(timeout) 则是具体的执行获取就绪文件描述符的位置,其中主要做了两件事:
1) 、在 updateRegistrations 方法中,更新 epoll 事件,实际调用 epollCtl 加入到 epollfd 中;
2) 、调用 epollWait 方法,获取到已就绪的文件描述符,存放在 pollArrayAddress 地址中
 
 
至于其他的方法,都差不多,都可以归纳到对 native 方法的调用。
 
而通过最终对 JDK C 源码的检视,我们发现,其实也就是对 Linux IO 复用网络编程的直接调用
 
 
3、SelectionKey
SelectionKey Channel Selector 之间的关联关系的体现,所以作为一个抽象类,其中定义的抽象方法,无非就是从 SelectionKey 获得对应的 Channel Selector ,以及对这种
关联关系的维护。 接下的抽象类 AbstractSelectionKey ,就更加简单,实现了 cancel 方法,调用了AbstractSelector 的 cancel 方法,把要取消的 key 加入到待取消的 key 集合。 在最终的实现类 SelectionKeyImpl 上,用两个 int 型的成员变量来保存已注册的事件集和已就绪的事件集所以对关联关系进行的各种操作,是以位操作的形式进行的。关于这个类很简单,可以自行查阅。
 
到此,JDK 中 NIO 的实现源码分析完毕,下篇我们分析Netty源码,敬请期待!
  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

寅灯

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值