NIO/Selector详解[Netty系列]

6 篇文章 0 订阅
2 篇文章 0 订阅

NIO/selector

简单看下selector的源码注释

可以直接跳过,后面都会详细讲到

/**
 * A multiplexor of SelectableChannel objects.
 *
 * A selector may be created by invoking the open method of
 * this class, which will use the system's default {@link
 * java.nio.channels.spi.SelectorProvider selector provider} to
 * create a new selector.  A selector may also be created by invoking the
 * {@link java.nio.channels.spi.SelectorProvider#openSelector openSelector}
 * method of a custom selector provider.  A selector remains open until it is
 * closed via its close method.
 */
Selector是SelectableChannel对象的多路复用器
一个Selector可以通过调用Selector的open方法来创建。 
该方法将使用系统的'默认选择器提供程序'来创建选择器。还可以通过调用'自定义选择器提供程序'的openSelector方法来创建选择器。
一个选择器一直保持开启状态,直到通过调用close方法关闭。
/**
 * A selectable channel's registration with a selector is represented by a
 * SelectionKey object.  A selector maintains three sets of selection keys
 *
 * The key set contains the keys representing the current channel
 * registrations of this selector.  This set is returned by the keys method.
 *
 * The selected-key set is the set of keys such that each key's channel was
 * detected to be ready for at least one of the operations identified
 * in the key's interest set during a prior selection operation.
 * This set is returned by the selectedKeys method.
 * The selected-key set is always a subset of the key set. 
 *
 * The cancelled-key set is the set of keys that have been cancelled but
 * whose channels have not yet been deregistered.  This set is not directly 
 * accessible.  The cancelled-key set is always a subset of the key set. 
 */
一个可选择的通道与选择器的注册是由一个SelectionKey对象表示的。选择器维护三组选择键 (三种集合类型的选择键)
1. key set (key集合): 包含代表此selector的当前的channel注册的key。
这个集合通过方法 keys() 进行返回
2. selected-key set (挑选出的key集合): 在优先选择操作期间,检测到每个键的通道已为键的兴趣集中确定的至少一个操作准备好。这个集合由selectedKeys方法返回。选择键集始终是键集的子集。
3. cancelled-key(已取消的key集合):此是已取消但其通道尚未取消注册的密钥集。这个集合不能直接访问。取消键集始终是键集的子集。
SelectableChannel

我们已经知道Selector是SelectableChannel的多路复用器。 那肯定得来看看它啦。

SelectableChannel是所有支持就绪检查的通道类的父类。让我们看看它的类关系图。
在这里插入图片描述

SelectableChannel这个抽象类提供了实现通道的可选择性所需的公共方法。所有的Socket通道都是可选择的,SelectableChannel可以被注册到Selector对象上,同时可以指定对那个选择器而言,哪种操作是感兴趣的。一个通道可以被注册到多个选择器上,但对于每个选择器而言只能被注册一次。

/**
 * A channel that can be multiplexed via a Selector.
 *
 * In order to be used with a selector,an instance of this class must first 
 * be registered via the register method.This method returns a new SelectionKey
 * object that represents the channel's registration with the selector.
 * 
 * Once registered with a selector,a channel remains registered until it
 * is deregistered.This involves deallocating whatever resources were
 * allocated to the channel by the selector.
 *
 * A channel cannot be deregistered directly; instead, the key representing
 * its registration must be cancelled.Cancelling a key requests that the  
 * channel be deregistered during the selector's next selection operation.  
 * A key may be cancelled explicitly by invoking its cancel method.All  
 * of a channel's keysare cancelled implicitly when the channel is  
 * closed, whether by invokingits close method or by interrupting a 
 * thread blocked in an I/O operation upon the channel.
 *
 * If the selector itself is closed then the channel will be deregistered,
 * and the key representing its registration will be invalidated, without
 * further delay.
 *
 * A channel may be registered at most once with any particular selector.
 *
 * Whether or not a channel is registered with one or more selectors may be
 * determined by invoking the isRegistered method.
 *
 * Selectable channels are safe for use by multiple concurrent threads. 
 */
public abstract class SelectableChannel extends AbstractInterruptibleChannel implements Channel{}
可以通过选择器进行多路复用的通道。
为了与选择器一起使用,必须首先通过register方法注册该类的实例。此方法返回一个新的SelectionKey对象,该对象表示通道与选择器的注册。
一旦注册选择器,通道将保持注册状态,直到它被注销。
通道不能直接注销;相反,必须取消代表其注册的密钥。取消密钥请求将在选择器的下一个选择操作期间取消注册该通道,可以通过调用其cancel方法显式取消密钥。当通道关闭时,所有通道的键都会被隐式取消。
如果选择器本身已关闭,则将取消注册该通道,并且表示其注册的密钥将无效,而不会有进一步的延迟。
一个通道最多可以与任何特定选择器一起注册一次。
可以通过调用isRegistered方法来确定是否向一个或多个选择器注册了channel。
多个并发线程可以安全地使用可选择通道。

让我们看一下最重要的register方法。

//register两个方法
//方法一:实际上就是调用的方法二
public final SelectionKey register(Selector sel, int ops)
    throws ClosedChannelException
{
    return register(sel, ops, null);
}
//方法二:这是一个抽象方法它的实现在子类AbstractSelectableChannel中。
public abstract SelectionKey register(Selector sel, int ops, Object att)
    throws ClosedChannelException;
1.使用给定的选择器注册此通道,返回选择键
2.此方法首先验证此通道是否已经打开,以及给定的初始兴趣集是否有效。
3.如果此通道已在给定选择器中注册,则将其兴趣设置为给定值后(覆盖),将返回表示该注册的选择键。
4.否则,此通道尚未在给定的选择器中注册,因此在保持适当的锁定时调用选择器的register方法。返回后,生成的秘钥将添加到此通道的密钥集(key set)中
params:
    sel - 要注册此通道的选择器
    ops - 为结果秘钥设置的兴趣
    att - 生成秘钥的附件,可能为null
return:
	表示使用给定选择器注册此通道的键
/**
* Registers this channel with the given selector, returning a selection key.
*
* <p>  This method first verifies that this channel is open and that the
* given initial interest set is valid.
*
* <p> If this channel is already registered with the given selector then
* the selection key representing that registration is returned after
* setting its interest set to the given value.
*
* <p> Otherwise this channel has not yet been registered with the given
* selector, so the register method of the selector is invoked 
* while holding the appropriate locks.  The resulting key 
* is added to this channel's key set before being returned.
* </p>
*/
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;
    }
}

要实现Selector管理Channel,需要将Channel注册到相应的Selector上,如下

channel.configureBlocking(false); //将channel切换到非阻塞模式
channel.register(selector, SelectionKey.OP_ACCEPT);
  • 通过调用channel的register()方法会将它注册到一个选择器上。与Selector一起使用时,Channel必须处于非阻塞模式下,否则将抛出IllegalBlockingModeException异常。另外channel一旦被注册,将不能再回到阻塞状态,此时若调用channel的configureBlocking(true)将抛出BlockingModeException异常。

  • register()方法的第二个参数是"兴趣集",表示选择器所关心的通道操作,它实际上是表示选择器在检查通道就绪状态时需要关心的操作的比特掩码。比如一个选择器对通道的read和write操作感兴趣,那么选择器在检查通道时,只会检查通道的read和write操作是否已经处在就绪状态。

    • 它有四种操作类型
      • Connect连接
      • Accept接收
      • Read读
      • Write写
    • Java中定义了四个常量来表示这四种操作类型
      • SelectionKey.OP_CONNECT
      • SelectionKey.OP_ACCEPT
      • SelectionKey.OP_READ
      • SelectionKey.OP_WRITE

当通道触发了某个操作之后,表示该通道的某个操作已经就绪,可以被操作。因此,某个SocktChannel成功连接到另一个服务器称为“连接就绪”(OP_CONNECT)。一个ServerSocketChannel准备好接收新进入的连接称为“接收就绪”(OP_ACCEPT)。一个有数据可读的通道可以说是“读就绪”(OP_READ)。等待写数据的通道可以说是“写就绪”(OP_WRITE)。

ServerSocketChannel
A selectable channel for stream-oriented listening sockets.

面向流监听socket的可选通道。 服务器端监听
SocketChannel
A selectable channel for stream-oriented connecting sockets.

面向流连接socket的可选通道。 客户端连接
SelectionKey
/**
 * A token representing the registration of a SelectableChannel with a Selector
 *
 * A selection key is created each time a channel is registered with a
 * selector.  A key remains valid until it is cancelled by invoking its
 * cancel method, by closing its channel, or by closing its selector.  
 * Cancelling a key does not immediately remove it from its selector; 
 * it is instead added to the selector's cancelled-key set for removal 
 * during the next selection operation.  The validity of a key may be 
 * tested by invoking ts isValid method.
 *
 * A selection key contains two operation sets represented as
 * integer values.  Each bit of an operation set denotes a category of
 * selectable operations that are supported by the key's channel.
 *
 * The interest set determines which operation categories will
 * be tested for readiness the next time one of the selector's selection
 * methods is invoked.  The interest set is initialized with the value given
 * when the key is created; it may later be changed via the 
 * interestOps(int) method. 
 *
 * The ready set identifies the operation categories for which
 * the key's channel has been detected to be ready by the key's selector.
 * The ready set is initialized to zero when the key is created; it may later
 * be updated by the selector during a selection operation, but it cannot be
 * updated directly. 
 */
1.表示使用选择器注册SelectableChannel的标记。
2.每次向选择器注册通道时,都会创建一个选择键。key保持有效,直到通过调用其取消方法,关闭其通道或关闭其选择器来取消key。取消key不会立即将其从选择器中删除;而是将其添加到选择器的已取消键集中,以便在下一个选择操作期间将其删除。可以通过调用其isValid方法来测试key的有效性。
3.选择键包含表示为整数值的两个操作集。操作集的每个位表示key通道支持的可选操作的类别。
4.兴趣集确定下次调用选择器的一个选择方法时将测试哪些操作类别的准备情况。兴趣集初始化为创建key时给定的值;稍后可以通过interestOps(int)方法进行更改。
5.就绪集合通过键的选择器识别检测到键的通道准备就绪的操作类别。创建key时,就绪集初始化为零;稍后可以在选择操作期间由选择器更新,但不能直接更新。

SelectionKey对象可以获取以下四种属性

  • interest集合(其实是int类型)
  • ready集合(其实是int类型)
  • Channel
  • Selector

interest集合是Selector感兴趣的集合,用于指示选择器对通道关心的操作,可通过SelectionKey对象的interestOps()获取。最初,该兴趣集合是通道被注册到Selector时传进来的值。该集合不会被选择器改变,但是可通过interestOps()改变。

/**
 * Retrieves this key's interest set.
 *
 * <p> It is guaranteed that the returned set will only contain operation
 * bits that are valid for this key's channel.
 *
 * <p> This method may be invoked at any time.  Whether or not it blocks,
 * and for how long, is implementation-dependent.  </p>
 *
 * @return  This key's interest set
 *
 * @throws  CancelledKeyException
 *          If this key has been cancelled
 */
public abstract int interestOps();

ready 集合是通道已经就绪的操作的集合,表示一个通道准备好要执行的操作了,可通过SelctionKey对象的readyOps()来获取相关通道已经就绪的操作。它是interest集合的子集,并且表示了interest集合中从上次调用select()以后已经就绪的那些操作。(比如选择器对通道的ready,write操作感兴趣,而某时刻通道的read操作已经准备就绪可以被选择器获知了,前一种就是interest集合,后一种则是ready集合。)。

 /**
  * Retrieves this key's ready-operation set.
  *
  * <p> It is guaranteed that the returned set will only contain operation
  * bits that are valid for this key's channel.  </p>
  *
  * @return  This key's ready-operation set
  *
  * @throws  CancelledKeyException
  *          If this key has been cancelled
  */
 public abstract int readyOps();

JAVA中定义以下几个方法用来检查这些操作是否就绪:

  • selectionKey.isAcceptable(); 测试此密钥的通道是否准备好接受新的socket连接。
  • selectionKey.isConnectable(); 测试此密钥的通道是否已完成或未能完成其socket连接操作。
  • selectionKey.isReadable(); 测试此键的通道是否可以读取。
  • selectionKey.isWritable(); 测试此密钥的通道是否已准备好写入。

取出SelectionKey所对应的Selector和Channel

  • Channel channel =selectionKey.channel();
  • Selector selector=selectionKey.selector();

取消SelectionKey对象

  • selectionKey.cancel();
    • 在该方法调用后,该SelectionKey对象会被"拷贝"至已取消键的集合中,该键此时已经失效,但注册关系并不会立刻终结。在下一次select()时,已取消键的集合中的元素会被清除,相应的注册关系也真正终结。

一个单独的通道可以注册到多个选择器中,有些时候我们需要通过isRegistered()方法来检测一个通道是否已经被注册到任何一个选择器上。通常来说,我们并不会这么做。

我们知道选择器维护注册过的通道的集合,并且这种注册关系都被封装到SelectionKey中。接下来我们简单的了解下Selector维护的三种类型SelectionKey集合。

Selector维护的三种类型SelectionKey集合
1. 密钥集(key set):包含表示此选择器的当前通道注册的键。该集由keys方法返回
2. 所选择的密钥集(selected-key set):使得检测到每个密钥的信道准备好用于->在先前选择操作期间的密钥的兴趣集中识别的至少一个操作。该集由selectedKeys方法返回。该集是 key set的子集
3. 取消密钥集(cancelled-key set):是已取消但是其通道尚未注册的密钥集。此集无法直接访问。该集是key set的子集

在刚初始化的Selector对象中,这三个集合都是空的。通过Selector的select()方法可以选择已经准备就绪的通道(这些通道包含你感兴趣的事件)。比如你对读就绪感兴趣,那么select()方法就会返回读事件已经就绪的那些通道。

  • selector.select(); 阻塞到至少有一个通道在你注册的事件上就绪了。

select()方法返回的int值表示有多少通道已经就绪,是自上次调用select()方法后有多少通道变成就绪状态。之前在select()调用时进入就绪状态的通道不会在本次调用中被计入。

举例:首次调用select()方法,如果有一个通道进入就绪状态返回1,再次调用select()方法,如果没有通道进入就绪状态返回0。

一旦调用select()方法,并且返回值不为0时,则可以通过调用Selector的selectedKeys()方法来访问已选择键集合。

  • Set<SelectionKey> selectionKeys= selector.selectedKeys();

进而可以判断SelectionKey的就绪状态

Set<SelectionKey> selectionKeys= selector.selectedKeys();
selectionKeys.forEach(selectionKey ->{
    if(selectionKey.isAcceptable())
        ...
    if(selectionKey.isConnectable())
        ...
    if(selectionKey.isReadable()) 
        ...
    if(selectionKey.isWritable())
        ...
});
关于Selector执行选择的过程
  • select()执行过程。当select()被调用时将执行以下几步:
    1. 首先检查已取消键集合,也就是通过cancle()取消的键。如果该集合不为空,则清空该集合里的键,同时该集合中每个取消的键也将从已注册键集合和已选择键集合中移除。(一个键被取消时,并不会立刻从集合中移除,而是将该键“拷贝”至已取消键集合中,这种取消策略就是我们常提到的“延迟取消”。)
    2. 再次检查已注册键集合(准确说是该集合中每个键的interest集合)。系统底层会依次询问每个已经注册的通道是否准备好选择器所感兴趣的某种操作,一旦发现某个通道已经就绪了,则会首先判断该通道是否已经存在在已选择键集合当中,如果已经存在,则更新该通道在已注册键集合中对应的键的ready集合,如果不存在,则首先清空该通道的对应的键的ready集合,然后重设ready集合,最后将该键存至已注册键集合中。这里需要明白,当更新ready集合时,在上次select()中已经就绪的操作不会被删除,也就是ready集合中的元素是累积的,比如在第一次的selector对某个通道的read和write操作感兴趣,在第一次执行select()时,该通道的read操作就绪,此时该通道对应的键中的ready集合存有read元素,在第二次执行select()时,该通道的write操作也就绪了,此时该通道对应的ready集合中将同时有read和write元素。
深入已注册键集合的管理

到现在我们已经知道一个通道的的键是如何被添加到已选择键集合中的,下面我们来继续了解对已选择键集合的管理 。首先要记住:选择器不会主动删除被添加到已选择键集合中的键,而且被添加到已选择键集合中的键的ready集合只能被设置,而不能被清理。如果我们希望清空已选择键集合中某个键的ready集合该怎么办?我们知道一个键在新加入已选择键集合之前会首先置空该键的ready集合,这样的话我们可以人为的将某个键从已注册键集合中移除最终实现置空某个键的ready集合。被移除的键如果在下一次的select()中再次就绪,它将会重新被添加到已选择的键的集合中。这就是为什么要在每次迭代的末尾调用selectionKeys.clear()。

停止选择

选择器执行选择的过程,系统底层会依次询问每个通道是否已经就绪,这个过程可能会造成调用线程进入阻塞状态,那么我们有以下三种方式可以唤醒在select()方法中阻塞的线程。

  1. 通过调用Selector对象的wakeup()方法让处在阻塞状态的select()方法立刻返回
    该方法使得选择器上的第一个还没有返回的选择操作立即返回。如果当前没有进行中的选择操作,那么下一次对select()方法的一次调用将立即返回。
  2. 通过close()方法关闭Selector
    该方法使得任何一个在选择操作中阻塞的线程都被唤醒(类似wakeup()),同时使得注册到该Selector的所有Channel被注销,所有的键将被取消,但是Channel本身并不会关闭。
  3. 调用interrupt()
    调用该方法会使睡眠的线程抛出InterruptException异常,捕获该异常并在调用wakeup()
多路复用

什么是多路复用?

多路指多个TCP连接(或多个Channel),复用指复用一个或少量线程。串起来理解就是复用一个或少量的线程处理连接。

理解了select就抓住了I/O多路复用的精髓,该函数会等待多个I/O事件(比如读就绪,写就绪)的任何一个发生,并且只要有一个网络事件发生,select线程就会执行。如果没有任何一个事件发生则阻塞。

public abstract int select(long timeout) throws IOException; //告知阻塞等待多长时间
public abstract int select() throws IOException;

让我们参考下图,重点理解Selector复用器。
在这里插入图片描述

阻塞式I/O与I/O复用

  1. 阻塞式I/O,阻塞式I/O如果要接收更多的连接,就必须创建更多的线程。

  2. I/O复用模式下在大量连接请求直接注册到Selector复用器上面,同时只要单个或者少量的线程来循环处理这些连接事件就可以了,一旦达到"就绪"的条件,就可以立即执行真正的I/O操作。

这就是I/O复用与传统的阻塞式I/O最大的不同。也正是I/O复用的精髓所在。

从应用进程的角度去理解始终是阻塞的,等待数据和将数据复制到用户进程这两个阶段都是阻塞的。这一点我们从应用程序是可以清楚的得知,比如我们调用一个以I/O复用为基础的NIO应用服务。调用端是一直阻塞等待返回结果的。
从内核的角度等待Selector上面的网络事件就绪,是阻塞的,如果没有任何一个网络事件就绪则一直等待直到有一个或者多个网络事件就绪。但是从内核的角度考虑,有一点是不阻塞的,就是复制数据,因为内核不用等待,当有就绪条件满足的时候,它直接复制,其余时间在处理别的就绪的条件。这也是大家一直说的非阻塞I/O。实际上是就是指的这个地方的非阻塞。

完整实例
-----------------------------服务器端-----------------------------
public class NIOServer {

    private static Map<String,SocketChannel> clientMap = new HashMap<>();

    public static void main(String[] args) throws IOException {
        ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
        serverSocketChannel.configureBlocking(false);
        ServerSocket serverSocket = serverSocketChannel.socket(); //获取到服务器端所对应的socket对象
        serverSocket.bind(new InetSocketAddress(8899));

        Selector selector = Selector.open();
        serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);

        while (true){
            try {
                selector.select(); //这个方法会一直阻塞,一直等到它所关注的SelectionKey有事件发生,才会返回,返回的是它所关注的事件的数量。
                Set<SelectionKey> selectionKeys = selector.selectedKeys(); //获取到所关注的事件的集合
                selectionKeys.forEach(selectionKey -> {
                    final SocketChannel client;

                    try {
                        if (selectionKey.isAcceptable()){//如果isAcceptable返回true表示客户端向服务器端发起了一个连接
                            //返回创建此key的通道
                            ServerSocketChannel server = (ServerSocketChannel)selectionKey.channel();
                            //接收与此通道的socket连接
                            client = server.accept();
                            client.configureBlocking(false);
                            client.register(selector,SelectionKey.OP_READ);

                            String key = "【" + UUID.randomUUID().toString() + "】";

                            clientMap.put(key,client);
                        } else if (selectionKey.isReadable()) {
                            client = (SocketChannel)selectionKey.channel();
                            ByteBuffer readBuffer = ByteBuffer.allocate(1024);

                            int count = client.read(readBuffer);

                            if (count>0){
                                readBuffer.flip();

                                Charset charset = Charset.forName("UTF-8");
                                String receivedMessage = String.valueOf(charset.decode(readBuffer).array());

                                System.out.println(client + ": " + receivedMessage);

                                String senderKey = null;
                                for (Map.Entry<String,SocketChannel> entry : clientMap.entrySet()){
                                    if (entry.getValue() == client){
                                        senderKey = entry.getKey();
                                        break;
                                    }
                                }

                                for (Map.Entry<String,SocketChannel> entry : clientMap.entrySet()){
                                    SocketChannel value = entry.getValue();

                                    ByteBuffer writeBuffer = ByteBuffer.allocate(1024);
                                    writeBuffer.put((senderKey + ": " + receivedMessage).getBytes());
                                    writeBuffer.flip();

                                    value.write(writeBuffer);
                                }
                            }
                        }
                    }catch (Exception ex){
                        ex.printStackTrace();
                    }
                    selectionKeys.clear();
                });
            }catch (Exception ex){
                ex.printStackTrace();
            }
        }
    }
}
--------------------------客户端-------------------------
    public class NIOClient {
    public static void main(String[] args) throws IOException {
        try {
            SocketChannel socketChannel = SocketChannel.open();
            socketChannel.configureBlocking(false);

            Selector selector = Selector.open();
            socketChannel.register(selector, SelectionKey.OP_CONNECT);
            socketChannel.connect(new InetSocketAddress("127.0.0.1",8899));

            while (true){
                selector.select();
                Set<SelectionKey> selectionKeys = selector.selectedKeys();
                selectionKeys.forEach(selectionKey -> {
                    try {
                        if (selectionKey.isConnectable()){ // 判断channel 是否连接成功
                            SocketChannel client = (SocketChannel)selectionKey.channel();

                            if (client.isConnectionPending()){
                                client.finishConnect(); //完成连接socket channel
                                ByteBuffer writeBuffer = ByteBuffer.allocate(1024);

                                writeBuffer.put((LocalDateTime.now() + " 连接成功").getBytes());
                                writeBuffer.flip();
                                client.write(writeBuffer);

                                ExecutorService executorService = Executors.newSingleThreadExecutor(Executors.defaultThreadFactory());
                                executorService.submit(()->{
                                    while (true){
                                        try {
                                            writeBuffer.clear();
                                            InputStreamReader input = new InputStreamReader(System.in);
                                            BufferedReader br = new BufferedReader(input);
                                            String sendMessage = br.readLine();

                                            writeBuffer.put(sendMessage.getBytes());
                                            writeBuffer.flip();
                                            client.write(writeBuffer);
                                        }catch (Exception e){
                                            e.printStackTrace();
                                        }
                                    }
                                });
                            }
                            client.register(selector,SelectionKey.OP_READ);
                        }else if (selectionKey.isReadable()){
                            SocketChannel client = (SocketChannel) selectionKey.channel();
                            ByteBuffer readBuffer = ByteBuffer.allocate(1024);
                            int count = client.read(readBuffer);
                            if (count > 0){
                                String receivedMessage = new String(readBuffer.array(),0,count);
                                System.out.println(receivedMessage);
                            }
                        }
                    }catch (Exception e){
                        e.printStackTrace();
                    }
                }
                selectionKeys.clear();
            }
        }catch (Exception e){
            e.printStackTrace();
        }
    }
}
  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值