NIO核心Buffer、Selector、Channel分析

2 篇文章 0 订阅

NIO核心Buffer、Selector、Channel分析

​ 上一篇文章中我们简单的说明了BIO、NIO、AIO之间的关系和区别, 本篇文章主要讲解NIO核心buffer、selector、channel原理

一、包含知识点

  • Buffer的基本原理
  • 缓冲区的分配
  • 缓冲区分片
  • 只读缓冲区
  • 直接缓冲区
  • 内存映射
  • 选择器Selector
  • 通道Channel

二、缓冲区Buffer

2.1 基本原理

​ NIO主要包含Buffer、Selector、Channel三个核心组件, 本小节主要讲解buffer。首先看下buffer的类继承图

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-jifh3ale-1592446301659)(./Buffer类继承图.jpg)]

​ 缓冲区是一个容器对象, 底层通过数组来实现, 在NIO中, 无论数据的读取还是写入都需要先经过缓冲区, 其中最常用的是ByteBuffer, 从上面类继承图, Java常用的基本类型都有具体的ByteBuffer和它对应。

​ 缓冲区Buffer底层是通过数组来实现的, 那么在进行数据的读取、写入操作时,它是怎么记录缓冲区状态变化的呢 ?查看Buffer类, 有下面几个重要字段, 0 <= position <= limit <= capacity

private int mark = -1;
private int position = 0;
private int limit;
private int capacity;
  • position: 数据索引位置,初始化为0
    • get() 读取数据时, position表示读取数据的位置
    • put() 写入数据时, position表示可以写入数据的起始位置
  • limit: 剩余可读取的数据量或剩余可插入数据的空间
    • 读取操作, 表示最大可读取的数据量
    • 查询操作, 表示最大可插入数据量
  • capcity: 缓冲区容量, 即底层数组长度

2.2 缓冲区基本操作

​ 首先看下缓冲区基本操作的逻辑代码

ByteBuffer buffer = ByteBuffer.allocate(10);
//写入数据
buffer.put((byte)i);
//获取数据
buffer.get() ;
//刷新缓存区
buffer.flip() ;

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-QiGfCb8S-1592446301661)(buffer基本操作position、limit、capacity变化.jpg)]

  • 缓冲区分配

    创建缓冲区对象时, 通过allocate()方法指定缓存的容量, 实际是创建了指定大小的数组, 将其包装成缓冲区对象

    //1. ByteBuffer.allocate
    public static ByteBuffer allocate(int capacity) {
      if (capacity < 0)
        throw createCapacityException(capacity);
      return new HeapByteBuffer(capacity, capacity);
    }
    
    //2. HeapByteBuffer
    HeapByteBuffer(int cap, int lim) {            // package-private
      super(-1, 0, lim, cap, new byte[cap], 0); // new byte[cap] 创建数组, 作为缓冲区对象
      /*
            hb = new byte[cap];
            offset = 0;
            */
      this.address = ARRAY_BASE_OFFSET;
    }
    
    //3. ByteBuffer
    ByteBuffer(int mark, int pos, int lim, int cap,   // package-private
                     byte[] hb, int offset){
      super(mark, pos, lim, cap);
      this.hb = hb; // 缓存区对象
      this.offset = offset;
    }
    
  • put操作

    当通过put()方法向缓冲区添加数据时, 每次添加操作position的值都会自增1,position < limit, 如果position >= limit会抛出BufferOverflowException异常, 添加过程limit、capacity值不变化

    //1. put操作
    public ByteBuffer put(byte x) {
      hb[ix(nextPutIndex())] = x;
      return this;
    }
    
    //2. 获取下个可添加数据的下标
    final int nextPutIndex() {                          // package-private
      if (position >= limit)
        throw new BufferOverflowException();
      return position++;
    }
    
  • flip操作

    flip有种切换的意思,当需要从写入操作转换为读取操作时, 需要修改position、limit的值, 前面我们提到了Buffer读取、写入是共用position、limit、capacity字段, 不同操作时表示不同意思。

    public Buffer flip() {
      limit = position;
      position = 0;
      mark = -1;
      return this;
    }
    
  • get操作

    在执行flip操作后, position、limit发生了变化, 分别表示可读取数据的起始位置、缓冲区中可读数据数量, 每次执行get操作, 都会使position的值增1, position < limit, 如果position >= limit, 会抛出BufferOverflowException异常, 获取过程中limit、capacity值不发生变化

    //1. get操作
    public byte get() {
      return hb[ix(nextGetIndex())];
    }
    
    //2. 获取下个可添加数据的下标
    final int nextPutIndex() {                          // package-private
      if (position >= limit)
        throw new BufferOverflowException();
      return position++;
    }
    
  • clear操作

    如果需要对position、limit进行复位操作, 可以执行clear方法

    //1. ByteBuffer.clear()
    ByteBuffer clear() {
      super.clear();
      return this;
    }
    
    //2. Buffer.clear()
    public Buffer clear() {
      position = 0;
      limit = capacity;
      mark = -1;
      return this;
    }
    

2.3 缓冲区分片

​ 在NIO中, 除了可以分配或着包装缓冲区之外, 还可以基于现有的缓冲区创建一个子缓冲区, 子缓冲区和原缓冲区在底层数据共享, 当共享部分有数据变化时, 子缓冲区与原缓冲区都会发生变化, 下面是测试代码

public class BufferSlice {  
    static public void main( String args[] ) throws Exception {
        ByteBuffer buffer = ByteBuffer.allocate( 10 );
          
        // 缓冲区中的数据0-9  
        for (int i=0; i<buffer.capacity(); ++i) {  
            buffer.put( (byte)i );  
        }  
        buffer.flip();
        while (buffer.remaining() > 0) { // limit - position
            System.out.print(buffer.get() + "  ");
        }
        System.out.println();
        // 创建子缓冲区  
        buffer.position( 3 );  
        buffer.limit( 7 );  
        ByteBuffer slice = buffer.slice();  
          
        // 改变子缓冲区的内容  
        for (int i=0; i<slice.capacity(); ++i) {  
            byte b = slice.get( i );  // 直接通过索引获取数据, 没有通过position来获取数据
            b *= 10;  
            slice.put( i, b );  
        }
        // 复位
        buffer.position( 0 );  
        buffer.limit( buffer.capacity() );  
          
        while (buffer.remaining()>0) {  
            System.out.print( buffer.get() + "  ");
        }  
    }  
}

2.4 只读缓冲区

​ 只读缓冲区如字面意思, 缓存的内容只能读取,不能进行写入操作, 可以使用asReadOnlyBuffer方法创建只读缓冲区, 有下面几点需要注意

  1. 创建的只读缓冲区和旧缓冲区共享底层空间
  2. asReadOnlyBuffer创建只读缓冲区时,position、limit和原缓存一样, 如果执行get操作需要手动更新读取数据的位置
/**
 * 只读缓冲区
 */
public class ReadOnlyBuffer {  
	static public void main( String args[] ) throws Exception {  
		ByteBuffer buffer = ByteBuffer.allocate( 10 );  
	    
		// 缓冲区中的数据0-9  
		for (int i=0; i<buffer.capacity(); ++i) {  
			buffer.put( (byte)i );  
		}  
	
		// 创建只读缓冲区
		// 基于原buffer创建新的buffer
		//position、limit、capcitu 和原缓存一致
		ByteBuffer readonly = buffer.asReadOnlyBuffer();
	    
		// 改变原缓冲区的内容  
		for (int i=0; i<buffer.capacity(); ++i) {  
			byte b = buffer.get( i );  
			b *= 10;  
			buffer.put( i, b );  
		}

		//position、limit保持和旧缓存值一致
		System.out.println(readonly.position());
		System.out.println(readonly.limit());
		System.out.println();

		readonly.position(0);  
		readonly.limit(buffer.capacity());  
	    
		// 只读缓冲区的内容也随之改变  
		while (readonly.remaining()>0) {  
			System.out.print( readonly.get() + "\t");
		}
		System.out.println();

		//修改制度缓存
		readonly.put(0,(byte)-1); //java.nio.ReadOnlyBufferException
	}
}

2.5 直接缓冲区

​ 在2.2节中,我们知道普通分配缓存的方式是通过静态方法ByteBuffer.allocate()来创建的,如果需要加快IO速度, 可以创建直接缓冲区, 它会在每次调用底层操作系统进行IO操作时, 1) 避免将直接缓冲区中的数据copy到中间缓冲区, 2) 避免从中间缓冲区copy数据到直接缓冲区

//1. 普通创建缓冲区的方式
ByteBuffer buffer = ByteBuffer.allocate(10);

//2. 创建直接缓存的方式
ByteBuffer buffer = ByteBuffer.allocateDirect(1024);

public static ByteBuffer allocateDirect(int capacity) {
  return new DirectByteBuffer(capacity);
}

DirectByteBuffer(int cap) {                   // package-private
  super(-1, 0, cap, cap);
  boolean pa = VM.isDirectMemoryPageAligned();
  int ps = Bits.pageSize();
  long size = Math.max(1L, (long)cap + (pa ? ps : 0));
  Bits.reserveMemory(size, cap);

  long base = 0;
  try {
    base = UNSAFE.allocateMemory(size);
  } catch (OutOfMemoryError x) {
    Bits.unreserveMemory(size, cap);
    throw x;
  }
  UNSAFE.setMemory(base, size, (byte) 0);
  if (pa && (base % ps != 0)) {
    // Round up to page boundary
    address = base + ps - (base & (ps - 1));
  } else {
    address = base;
  }
  cleaner = Cleaner.create(this, new Deallocator(base, size, cap));
  att = null;
}

2.6 内存映射

​ 内存映射是一种**读和写文件**数据的方法,内存映射文件IO是通过使文件中的数据为数组的内容完成的,不会将整个文件内容进行读取, 只有实际读取或者写入的部分才会映射到内存中, 看下示例代码

public class MappedBuffer {  
    static private final int start = 0;
    static private final int size = 26;
      
    static public void main( String args[] ) throws Exception {
        String rootPath = MappedBuffer.class.getClassLoader().getResource("").getPath();

        RandomAccessFile raf = new RandomAccessFile( rootPath + "test.txt", "rw" );
        FileChannel fc = raf.getChannel();
        
        //把缓冲区跟文件系统进行一个映射关联
        //只要操作缓冲区里面的内容,文件内容也会跟着改变
        MappedByteBuffer mbb = fc.map(FileChannel.MapMode.READ_WRITE,start, size );
          
        mbb.put( 0, (byte)97 );  //a
        mbb.put( 25, (byte)122 );   //z

        raf.close();  
    }  
}

三、选择器Selector

3.1 传统会话模式TPR(Thread Per Request)

​ 传统的Server/Client会话模式是TPR(Thread Per Request),

  1. 服务端会为每个请求创建一个新的Thread来处理逻辑, 如果请求并发过大,会同时创建过多的线程来处理Client的请求, 大量的线程会增加服务器压力, 容易带来性能问题
  2. 为了解决线程的不断增长, 通常会使用线程池来控制线程数量上限, 但是可能会带来新的问题, 如果线程池线程都在处理耗时操作, 比如: 文件上传、下载操作, 如果有个耗时很短的请求过来,会被阻塞不能及时被处理

3.2 Reactor模式

​ NiO通过Reactor模式来实现非阻塞需求, IO调用不会被阻塞, 只会注册特定的IO操作, 当特定的事件到来时会发出通知, NIO基于Selector实现非阻塞IO,当有读或者写等任何注册事件发生时, 可以从Selector中获得相应SelectionKey, 通过这个SelectionKey找到发生事件的SelectableChannel, 然后获得客户端发送过来的数据。处理的基本顺序可以分为下面几个步骤

  • 向 Selector 对象注册感兴趣的事件
  • 从 Selector 中获取感兴趣的事件
  • 根据不同的事件进行相应的处理

下面是Reactor模式图示

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-2qHZMZyl-1592446301662)(Reactor模式图.jpg)]

下面通过下面的示例代码熟悉Selector

package com.gupaoedu.vip.netty.io.nio;

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;

/**
 * NIO的操作过于繁琐,于是才有了Netty
 * Netty就是对这一系列非常繁琐的操作进行了封装
 *
 * Created by Tom.
 */
public class NIOServerDemo {

    private int port = 8080;

    //轮询器 Selector 
    private Selector selector;
    //缓冲区 Buffer 
    private ByteBuffer buffer = ByteBuffer.allocate(1024);

    //初始化完毕
    public NIOServerDemo(int port){
        try {
            this.port = port;
            ServerSocketChannel server = ServerSocketChannel.open();
            //IP/Port
            server.bind(new InetSocketAddress(this.port));
            //BIO 升级版本 NIO,为了兼容BIO,NIO模型默认是采用阻塞式
            server.configureBlocking(false);
            selector = Selector.open();
            server.register(selector, SelectionKey.OP_ACCEPT);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    public void listen(){
        System.out.println("listen on " + this.port + ".");
        try {
            //轮询主线程
            while (true){
                //大堂经理再叫号
                selector.select();
                Set<SelectionKey> keys = selector.selectedKeys();
                Iterator<SelectionKey> iter = keys.iterator();
                //不断地迭代,就叫轮询
                //同步体现在这里,因为每次只能拿一个key,每次只能处理一种状态
                while (iter.hasNext()){
                    SelectionKey key = iter.next();
                    iter.remove();
                    //每一个key代表一种状态
                    //没一个号对应一个业务
                    //数据就绪、数据可读、数据可写
                    process(key);
                }             
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    //每一次轮询就是调用一次process方法,而每一次调用,只能干一件事
    //在同一时间点,只能干一件事
    private void process(SelectionKey key) throws IOException {
        //针对于每一种状态给一个反应
        if(key.isAcceptable()){
            ServerSocketChannel server = (ServerSocketChannel)key.channel();
            //这个方法体现非阻塞,不管你数据有没有准备好
            //你给我一个状态和反馈
            SocketChannel channel = server.accept();
            //一定一定要记得设置为非阻塞
            channel.configureBlocking(false);
            //当数据准备就绪的时候,将状态改为可读
            key = channel.register(selector,SelectionKey.OP_READ);
        }
        else if(key.isReadable()){
            //key.channel 从多路复用器中拿到客户端的引用
            SocketChannel channel = (SocketChannel)key.channel();
            int len = channel.read(buffer);
            if(len > 0){
                buffer.flip();
                String content = new String(buffer.array(),0,len);
                key = channel.register(selector,SelectionKey.OP_WRITE);
                //在key上携带一个附件,一会再写出去
                key.attach(content);
                System.out.println("读取内容:" + content);
            }
        }
        else if(key.isWritable()){
            SocketChannel channel = (SocketChannel)key.channel();

            String content = (String)key.attachment();
            channel.write(ByteBuffer.wrap(("输出:" + content).getBytes()));

            channel.close();
        }
    }

    public static void main(String[] args) {
        new NIOServerDemo(8080).listen();
    }
}

四、Channel

4.1 channel基本知识

​ 通道是一个对象, 数据的读取和写入都需要经过通道(Channel),但是需要注意的是数据的直接操作并不是Channel而是Buffer, NIO中提供了多种通道对象, 请看下图

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-CH2gHQG7-1592446301664)(Channel类继承关系.jpg)]

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-pKFjDKep-1592446301665)(Selector处理事件.jpg)]

使用 NIO 读取数据

  • 从 FileInputStream 获取 Channel
  • 创建 Buffer
  • 将数据从 Channel 读取到 Buffer 中

使用 NIO 写入数据

  • 从 FileInputStream 获取 Channel
  • 创建 Buffer
  • 将数据从 Channel 写入到 Buffer 中

4.2 IO多路复用

​ 多路复用 IO 技术最适用的是“高并发”场景,以满足 短时间内至少同时有上千个连接请求准备好。其他情

况下多路复用 IO 技术发挥不出来它的优势。另一方面,使用 JAVA NIO 进行功能实现,相对于传统的 Socket 套接字

实现要复杂一些,所以实际应用中,需要根据自己的业务需求进行技术选择。

​ 常见多路复用技术

IO模型相对性能关键思路操作系统Java支持
select较高ReactorWin/Linux支持,Reactor 模式(反应器设计模式)。Linux 操作系统的 kernels 2.4 内核版本之前,默认使用select;而目前 windows 下对同步 IO 的支持,都是 select 模型
pool较高ReactorLinuxLinux 下的 JAVA NIO 框架,Linux kernels 2.6 内核版本之前使用 poll 进行支持。也是使用的Reactor 模式
epollReactor/ProactorLinuxLinux kernels 2.6 内核版本及以后使用 epoll 进行支持;Linux kernels 2.6 内核版本之前使用 poll进行支持;另外一定注意,由于 Linux 下没有Windows 下的 IOCP 技术提供真正的 异步 IO 支持,所以 Linux 下使用 epoll 模拟异步 IO
KqueueProactorLinux

五、NIO源码分析

​ 在3.2节中我们知道, Selector的创建方式, 那么它具体实现逻辑是什么呢 ?

  • 创建Provider
//Selector
public static Selector open() throws IOException {
  return SelectorProvider.provider().openSelector(); // 创建Selector入口
}

//SelectorProvider
public static SelectorProvider provider() {
  synchronized (lock) { // 加锁避免并发问题
    if (provider != null)
      return provider; // 如果已经创建, 返回创建好的provider , 保证整个Server只有一个provider
    return AccessController.doPrivileged(
      new PrivilegedAction<>() {
        public SelectorProvider run() {
          if (loadProviderFromProperty()) //从配置属性java.nio.channels.spi.SelectorProvider家在provider
            return provider;
          if (loadProviderAsService()) // 通过SPI机制家在provider
            return provider;
          /** 
           * 这里以PollSelectorImpl为例说明, 注意这里会根据不同操作系统创建不同的Provider
           * 1. windows, WindowSelectorProvider
           * 2. Linux: EpoolSelectorProvider
           * 可以将 ${JAVA_HOME}/lib/rt.jar 进行解压, 解压后进入 sun.nio.ch目录, 查看DefaultSelectorProvider.class文件, 
           * 内容实现会根据系统不一样创建对应的Provider
           */
          provider = sun.nio.ch.DefaultSelectorProvider.create(); // 创建一个基于当前操作系统的provider
          return provider;
        }
      });
  }
}
  • ​ 创建Selector

    查看DefaultSelectorProvider类, 当需要Selector对象时, provider通过openSelector创建对应的selector对象

//DefaultSelectorProvider
public static SelectorProvider create() {
  /** 
   * 这里以PollSelectorImpl为例说明, 注意这里会根据不同操作系统创建不同的Provider
   */
  return new PollSelectorProvider(); // 这里以PollSelectorImpl为例说明
}

//PollSelectorImpl
PollSelectorImpl(SelectorProvider var1) {
  super(var1, 1, 1);
  long var2 = IOUtil.makePipe(false); //native方法, 返回文件两个描述符, 用long来存储
  this.fd0 = (int)(var2 >>> 32); //高位, read描述符
  this.fd1 = (int)var2; //低位, write描述符

  try {
    this.pollWrapper = new PollArrayWrapper(10);
    this.pollWrapper.initInterrupt(this.fd0, this.fd1);
    this.channelArray = new SelectionKeyImpl[10];
  } catch (Throwable var8) {
    try {
      FileDispatcherImpl.closeIntFD(this.fd0);
    } catch (IOException var7) {
      var8.addSuppressed(var7);
    }

    try {
      FileDispatcherImpl.closeIntFD(this.fd1);
    } catch (IOException var6) {
      var8.addSuppressed(var6);
    }

    throw var8;
  }
}
  • 创建Pipe

    如果selector需要打开创建pipe, 可以通过openPipe来创建Pipe对象

//SelectorProviderImpl
public Pipe openPipe() throws IOException {
  return new PipeImpl(this);
}

//PipeImpl
PipeImpl(SelectorProvider var1) {
  long var2 = IOUtil.makePipe(true); //native方法, 返回文件两个描述符, 用long来存储
  int var4 = (int)(var2 >>> 32); //高位, read描述符
  int var5 = (int)var2; //低位, write描述符
  FileDescriptor var6 = new FileDescriptor();
  IOUtil.setfdVal(var6, var4);
  this.source = new SourceChannelImpl(var1, var6);
  FileDescriptor var7 = new FileDescriptor();
  IOUtil.setfdVal(var7, var5);
  this.sink = new SinkChannelImpl(var1, var7);
}
  • 创建Channel

    这里以创建ServerSocketChannel说明, 从provider创建流程我们知道, 一个应用只有一个provider, Channel的创建是通过这个provider创建的

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

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

//AbstractSelectableChannel
protected AbstractSelectableChannel(SelectorProvider provider) {
	this.provider = provider;
}
  • ServerSocketChannel.register()

    从3.2节示例代码我们知道, channel和selector通过 channel.register(selector,SelectionKey.OP_READ) 绑定在一起, 即创建ServerSocketChannel时创建的FD和selector绑定在一起。

//AbstractSelectableChannel
public final SelectionKey register(Selector sel, int ops,
                                       Object att)
        throws ClosedChannelException
    {
        synchronized (regLock) {
            if (!isOpen()) // 管道是否已经打开, false抛错
                throw new ClosedChannelException();
            if ((ops & ~validOps()) != 0) // 是否支持当前 SelectionKey, 如果不支持,抛错
                throw new IllegalArgumentException();
            if (blocking)
                throw new IllegalBlockingModeException();
            SelectionKey k = findKey(sel); // 遍历SelectionKey集合, 找到属于当前selector的SelectorKey
            if (k != null) {// 查询到key, 更新selector
                k.interestOps(ops);
                k.attach(att);
            }
            if (k == null) { // 没查询到, 新注册,将Channel和Selector绑定
                // New registration
                synchronized (keyLock) {
                    if (!isOpen())
                        throw new ClosedChannelException();
                    k = ((AbstractSelector)sel).register(this, ops, att);
                    addKey(k);
                }
            }
            return k;
        }
    }
  • Selector.doSelect()
protected int doSelect(long var1) throws IOException {
        if (this.channelArray == null) {// 没有SelectorKey信息, 抛异常
            throw new ClosedSelectorException();
        } else {
            this.processDeregisterQueue();

            try {
                this.begin();
                this.pollWrapper.poll(this.totalChannels, 0, var1); // 核心方法, 轮询pollWrapper中保存的FD
            } finally {
                this.end();
            }

            this.processDeregisterQueue();
            int var3 = this.updateSelectedKeys(); // 更新SelectedKey
            if (this.pollWrapper.getReventOps(0) != 0) {
                this.pollWrapper.putReventOps(0, 0);
                synchronized(this.interruptLock) {
                    IOUtil.drain(this.fd0);
                    this.interruptTriggered = false;
                }
            }

            return var3;
        }
    }

//PollArrayWrapper
int poll(int var1, int var2, long var3) {
  return this.poll0(this.pollArrayAddress + (long)(var2 * 8), var1, var3);
}

private native int poll0(long var1, int var3, long var4);

这个 poll0()会监听 pollWrapper 中的 FD 有没有数据进出,这会造成 IO 阻塞,直到有数据读写事件发生。比如,由于 pollWrapper 中保存的也有 ServerSocketChannel 的 FD,所以只要 ClientSocket 发一份数据到 ServerSocket,那么 poll0() 就会返回;又由于 pollWrapper 中保存的也有 pipe 的 write 端的 FD,所以只要 pipe 的 write 端向 FD 发一份数据,也会造 成 poll0()返回;如果这两种情况都没有发生,那么 poll0()就一直阻塞,也就是 selector.select()会一直阻塞;如果有任 何一种情况发生,那么 selector.select()就会返回,所有在 OperationServer 的 run()里要用 while (true) {,这样就可以保证在 selector 接收到数据并处理完后继续监听 poll();

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值
>