JAVA NIO

----NIO与IO

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

常见区别:

  • IO是面向流,由于是流的关系,所以是单方向的传递。NIO是面向缓存区的,通过NIO中的buffer与NIO通道进行交互,数据是从通道读入缓冲区,从缓冲区进而写入通道的
  • IO是阻塞的,当进行一个读或者写操作发生问题时,IO接口只会等待该操作成功后才能去处理其他的IO请求,而NIO是非阻塞的,可以通过选择器(Selector)添加通道,让单个线程内可以同时处理多个IO请求

---- NIO的两个核心

---缓冲区(Buffer)

    --?基本属性

    容量 (capacity) 表示 Buffer 最大数据容量,缓冲区容量不能为负,并且创建后不能更改。
    限制 (limit)
第一个不应该读取或写入的数据的索引,即位于 limit 后的数据不可读写。缓冲区的限制不能为负,并且不能大于其容量。
    位置 (position): :
下一个要读取或写入的数据的索引。缓冲区的位置不能为负,并且不能大于其限制
    标记 (mark) 与重置 (reset)
标记是一个索引,通过 Buffer 中的 mark() 方法指定 Buffer 中一个特定的 position,之后可以通过调用 reset() 方法恢复到这个 position.

    标记 、 位置 、 限制 、 容量遵守以下不变式: 0 <= mark <= position <= limit <= capacity

 

  --?常用方法

  

   --?缓冲区类型

    字节缓冲区可分为直接缓冲区和非直接缓冲区。直接字节缓冲区可以通过调用此类的 allocateDirect()  工厂方法 来创建。此方法返回的 缓冲区进行分配和取消分配所需成本通常高于非直接缓冲区 。直接缓冲区的内容可以驻留在常规的垃圾回收堆之外,因此,它们对
应用程序的内存需求量造成的影响可能并不明显。所以,建议将直接缓冲区主要分配给那些易受基础系统的机 本机 I/O  操作影响的大型、持久的缓冲区。一般情况下,最好仅在直接缓冲区能在程序性能方面带来明显好处时分配它们。
   直接字节缓冲区还可以过 通过FileChannel  的 map()  方法  将文件区域直接映射到内存中来创建 。该方法返回MappedByteBuffer 。Java  平台的实现有助于通过 JNI 从本机代码创建直接字节缓冲区。如果以上这些缓冲区中的某个缓冲区实例指的是不可访问的内存区域,则试图访问该区域不会更改该缓冲区的内容,并且将会在访问期间或稍后的某个时间导致抛出不确定的异常。
  字节缓冲区是直接缓冲区还是非直接缓冲区可通过调用其 isDirect()  方法来确定。提供此方法是为了能够在性能关键型代码中执行显式缓冲区管理

 

  小记:非直接缓冲区也指的是日常的IO缓存过程,即当应用程序要读/写数据需在用户地址空间建立缓存,接着再让copy该缓存到内核地址空间,以便由磁盘进行读/写

 小记:而直接缓冲区是指在内核地址空间、用户地址空间之间建立一个物理内存映射文件,这样用户程序和磁盘之间读/写直接在上面进行操作,即可以修改对应的数据信息,提高了效率

 举个栗子

package com.xxg.nio;

import java.nio.ByteBuffer;

import org.junit.Test;
/*
 * 一、缓冲区(Buffer):在 Java NIO 中负责数据的存取。缓冲区就是数组。用于存储不同数据类型的数据
 * 
 * 根据数据类型不同(boolean 除外),提供了相应类型的缓冲区:
 * ByteBuffer
 * CharBuffer
 * ShortBuffer
 * IntBuffer
 * LongBuffer
 * FloatBuffer
 * DoubleBuffer
 * 
 * 上述缓冲区的管理方式几乎一致,通过 allocate() 获取缓冲区
 * 
 * 二、缓冲区存取数据的两个核心方法:
 * put() : 存入数据到缓冲区中
 * get() : 获取缓冲区中的数据
 * 
 * 三、缓冲区中的四个核心属性:
 * capacity : 容量,表示缓冲区中最大存储数据的容量。一旦声明不能改变。
 * limit : 界限,表示缓冲区中可以操作数据的大小。(limit 后数据不能进行读写)
 * position : 位置,表示缓冲区中正在操作数据的位置。
 * 
 * mark : 标记,表示记录当前 position 的位置。可以通过 reset() 恢复到 mark 的位置
 * 
 * 0 <= mark <= position <= limit <= capacity
 * 
 * 四、直接缓冲区与非直接缓冲区:
 * 非直接缓冲区:通过 allocate() 方法分配缓冲区,将缓冲区建立在 JVM 的内存中
 * 直接缓冲区:通过 allocateDirect() 方法分配直接缓冲区,将缓冲区建立在物理内存中。可以提高效率
 */
public class TestBuffer {

@Test
 public void test1(){
     String str = "abcdef";
    //z指定一个缓冲区大小
        ByteBuffer buffer = ByteBuffer.allocate(1024);
        System.out.println("-------allocate--------");
        System.out.println("position: "+buffer.position());
        System.out.println("limit: "+buffer.limit());
        System.out.println("capacity: "+buffer.capacity());
        
        //将数据存进缓冲区
        buffer.put(str.getBytes());
        System.out.println("-------put--------");
        System.out.println("position: "+buffer.position());
        System.out.println("limit: "+buffer.limit());
        System.out.println("capacity: "+buffer.capacity());
        
        //切换到读取数据模式
        buffer.flip();
        System.out.println("-------flip--------");
        System.out.println("position: "+buffer.position());
        System.out.println("limit: "+buffer.limit());
        System.out.println("capacity: "+buffer.capacity());
        
        //利用get读取缓冲区的内容
        byte[] dst = new byte[buffer.limit()];
        buffer.get(dst);
        System.out.println(buffer.get(2));  //---从下标为0开始读取第一个字符
        
//        System.out.println("dst: "+new String(dst, 0, dst.length));
        //随着缓冲区数据的读取,position的位置随着改变,但是其值始终无法大于limit
        System.out.println("-------get--------");
        System.out.println("position: "+buffer.position());
        System.out.println("limit: "+buffer.limit());
        System.out.println("capacity: "+buffer.capacity());
        
        //可重复读   将读取position位置重置为缓冲区数据一开始的位置
        buffer.rewind();
        System.out.println("-------rewind--------");
        System.out.println("position: "+buffer.position());
        System.out.println("limit: "+buffer.limit());
        System.out.println("capacity: "+buffer.capacity());
        
        //检查缓冲区是否还有数据
        while(buffer.hasRemaining()){
            System.out.println((char)buffer.get());
        }
     
        //清空缓冲区   但是实际上缓冲区的数据还在,只是无法知道缓存数据的大小,以及完整的数据的读取该从哪个位置开始
        buffer.clear();
        System.out.println("-------clear--------");
        System.out.println("position: "+buffer.position());
        System.out.println("limit: "+buffer.limit());
        System.out.println("capacity: "+buffer.capacity());
        }
        
        
        @Test
        public void test2(){
            String str = "abcde";
            ByteBuffer buffer = ByteBuffer.allocate(1024);
            buffer.put(str.getBytes());
            
            buffer.flip();
            byte[] bs = new byte[buffer.limit()];
            buffer.get(bs,0,2);
            System.out.println(new String(bs,0,2));
            System.out.println("position: "+buffer.position());
            
            //mark标记
            buffer.mark();
            
            buffer.get(bs,2,2);
            System.out.println("------mark-------");
            System.out.println(new String(bs,2,2));
            System.out.println("position: "+buffer.position());
            
            //reset重新回到mark标记处
            buffer.reset();
            System.out.println("------reset-------");
            System.out.println("position: "+buffer.position());
            
            //判断缓冲区中是否还有剩余数据
            if(buffer.hasRemaining()){            
                //获取缓冲区中可以操作的数量
                System.out.println("-------remaining-------");
                System.out.println(buffer.remaining());
            }
        }
        
        @Test
        public void test3(){
            //分配直接缓冲区   效率比较高,但是在进行缓冲区分配和取消分配所需成本相对比较大
            ByteBuffer buf = ByteBuffer.allocateDirect(1024);    
             System.out.println(buf.isDirect());
        }
    
}
View Code

---通道(Channel)
--?简单概述一波

小记:传统的数据流处理方式:让CPU直接去处理所有的IO接口请求,导致当请求一多就会大大限制CPU的处理效率

小记:再后来,改为内存和IO接口之间加了DMA(直接存储器),DMA通过向CPU申请权限,让所有的IO请求直接让DMA进行管理,但是,可能会因为IO请求量过多,造成DMA总线地址冲突

小记:因此,后边用Channel代替了DMA,成为了一个完全独立单元,无须向CPU申请权限,专用于IO处理

--?主要实现类

  • FileChannel:用于读取、写入、映射和操作文件的通道
  • DatagramChannel:通过 UDP 读写网络中的数据通道
  • SocketChannel:通过 TCP 读写网络中的数据
  • ServerSocketChannel:可以监听新进来的 TCP 连接,对每一个新进来的连接都会创建一个 SocketChannel

 补充知识点

    分散读取:是指从一个Channel中读取到的数据分散到多个Buffer中,注意,按照缓冲区的顺序,从Channel中读取的数据依次将Buffer填满

    聚集写入:是指将多个Buffer中的数据聚集写入到一个Channel中,注意,按照缓冲区的顺序,写入 position 和 limit 之间的数据到 Channel

 举个栗子

package com.xxg.nio;

import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.RandomAccessFile;
import java.nio.ByteBuffer;
import java.nio.CharBuffer;
import java.nio.MappedByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.channels.FileChannel.MapMode;
import java.nio.charset.CharacterCodingException;
import java.nio.charset.Charset;
import java.nio.charset.CharsetDecoder;
import java.nio.charset.CharsetEncoder;
import java.nio.file.Paths;
import java.nio.file.StandardOpenOption;

import org.junit.Test;

/*
 * 一、通道(Channel):用于源节点与目标节点的连接。在 Java NIO 中负责缓冲区中数据的传输。Channel 本身不存储数据,因此需要配合缓冲区进行传输。
 * 
 * 二、通道的主要实现类
 *     java.nio.channels.Channel 接口:
 *         |--FileChannel
 *         |--SocketChannel
 *         |--ServerSocketChannel
 *         |--DatagramChannel
 * 
 * 三、获取通道
 * 1. Java 针对支持通道的类提供了 getChannel() 方法
 *         本地 IO:
 *         FileInputStream/FileOutputStream
 *         RandomAccessFile
 * 
 *         网络IO:
 *         Socket
 *         ServerSocket
 *         DatagramSocket
 *         
 * 2. 在 JDK 1.7 中的 NIO.2 针对各个通道提供了静态方法 open()
 * 3. 在 JDK 1.7 中的 NIO.2 的 Files 工具类的 newByteChannel()
 * 
 * 四、通道之间的数据传输
 * transferFrom()
 * transferTo()
 * 
 * 五、分散(Scatter)与聚集(Gather)
 * 分散读取(Scattering Reads):将通道中的数据分散到多个缓冲区中
 * 聚集写入(Gathering Writes):将多个缓冲区中的数据聚集到通道中
 * 
 * 六、字符集:Charset
 * 编码:字符串 -> 字节数组
 * 解码:字节数组  -> 字符串
 * 
 */
public class TestChannel {

    // 利用通道完成文件复制(非直接缓冲)
    @Test
    public void test1() {

        long start = System.currentTimeMillis();

        FileInputStream fInputStream = null;
        FileOutputStream fOutputStream = null;

        // 创建通道
        FileChannel inChannel = null;
        FileChannel outChannel = null;

        try {
            fInputStream = new FileInputStream("D:/迅雷下载/233.mp4");
            fOutputStream = new FileOutputStream("D:/迅雷下载/234.mp4");

            inChannel = fInputStream.getChannel();
            outChannel = fOutputStream.getChannel();

            // 分配指定的缓冲区大小   其缓冲区大小的设置会影响耗费时间
            ByteBuffer byteBuffer = ByteBuffer.allocate(10240);
           // System.out.println(byteBuffer.isDirect());
            // 将通道中的数据存入缓冲区 read(通道要存入的缓冲区)
            while (inChannel.read(byteBuffer) != -1) {
                byteBuffer.flip(); // 缓冲区切换成读取模式
                // 将缓冲区的数据写入通道
                outChannel.write(byteBuffer);
                byteBuffer.clear();
            }

        } catch (FileNotFoundException e) {
            // TODO Auto-generated catch block
            e.printStackTrace();
        } catch (IOException e) {
            // TODO Auto-generated catch block
            e.printStackTrace();
        } finally {
            if (outChannel != null) {
                try {
                    outChannel.close();
                } catch (IOException e) {
                    // TODO Auto-generated catch block
                    e.printStackTrace();
                }
            }
            if (inChannel != null) {
                try {
                    inChannel.close();
                } catch (IOException e) {
                    // TODO Auto-generated catch block
                    e.printStackTrace();
                }
            }
        }
        if (fInputStream != null) {
            try {
                fInputStream.close();
            } catch (IOException e) {
                // TODO Auto-generated catch block
                e.printStackTrace();
            }
        }
        if (fOutputStream != null) {
            try {
                fOutputStream.close();
            } catch (IOException e) {
                // TODO Auto-generated catch block
                e.printStackTrace();
            }
        }

        long end = System.currentTimeMillis();
        System.out.println("耗费时间: " + (end - start));
    }

    //使用直接缓冲区完成文件的复制(内存映射文件)
    @Test
    public void test2() throws IOException{
        long start = System.currentTimeMillis();
        
        FileChannel inChannel = FileChannel.open(Paths.get("D:/迅雷下载/233.mp4"), StandardOpenOption.READ);
        FileChannel outChannel = FileChannel.open(Paths.get("D:/迅雷下载/234.mp4"), StandardOpenOption.READ,StandardOpenOption.WRITE,StandardOpenOption.CREATE);
        
        //内存映射文件  
        /*将节点中从position开始的size个字节映射到返回的MappedByteBuffer中*/
        MappedByteBuffer inMapperBuf = inChannel.map(MapMode.READ_ONLY, 0, inChannel.size());
        MappedByteBuffer outMapperBuf = outChannel.map(MapMode.READ_WRITE, 0, inChannel.size());
        //System.out.println(inMapperBuf.isDirect());
        
        //直接对缓冲区进行数据的读写操作
        byte[] dst = new byte[inMapperBuf.limit()];
        inMapperBuf.get(dst);
        outMapperBuf.put(dst);
        
        inChannel.close();
        outChannel.close();
        
        long end = System.currentTimeMillis();
        System.out.println("耗费时间: " + (end - start));
    }
    
    //通道之间的数据传输(直接缓冲区)
    @Test
    public void test3() throws IOException{
        long start = System.currentTimeMillis();
        FileChannel inChannel = FileChannel.open(Paths.get("D:/迅雷下载/233.mp4"), StandardOpenOption.READ);
        FileChannel outChannel = FileChannel.open(Paths.get("D:/迅雷下载/234.mp4"), StandardOpenOption.WRITE, StandardOpenOption.READ, StandardOpenOption.CREATE);
        
        inChannel.transferTo(0, inChannel.size(), outChannel);
        //等价于:outChannel.transferFrom(inChannel, 0, inChannel.size());
        
        inChannel.close();
        outChannel.close();
        
        long end = System.currentTimeMillis();
        System.out.println("耗费时间: " + (end - start));
    }
    
    //分散与聚集  
    @Test
    public void test4() throws IOException{
        long start = System.currentTimeMillis();
        RandomAccessFile rAccessFile = new RandomAccessFile("D:/迅雷下载/1.txt", "rw");
        
        //获取通道
        FileChannel channel = rAccessFile.getChannel();
        //分配指定大小的缓冲区
        ByteBuffer buf1 = ByteBuffer.allocate(100);
        ByteBuffer buf2 = ByteBuffer.allocate(1024);
        
        //分散读取   由于这里的分散读取只是进行了一次,并且申请的两个缓冲区大小之和比1.txt小,结果导致生成的文件2.txt也比原先的小 ,只写入了开头的(100+1024)个字节
        ByteBuffer[] buffers = {buf1,buf2};
        channel.read(buffers);
        
        for(ByteBuffer byteBuffer:buffers){
            byteBuffer.flip();
        }
        
        System.out.println(new String(buffers[0].array(), 0, buffers[0].limit()));
        System.out.println("-----------------");
        System.out.println(new String(buffers[1].array(), 0, buffers[1].limit()));
        
        //聚集写入
        RandomAccessFile rAccessFile2 = new RandomAccessFile("D:/迅雷下载/2.txt", "rw");
        FileChannel channel2 = rAccessFile2.getChannel();
                
        channel2.write(buffers);
        
        channel2.close();
        rAccessFile2.close();
        channel.close();
        rAccessFile.close();
        long end = System.currentTimeMillis();
        System.out.println("耗费时间: " + (end - start));
    }
    
    //解码器
    @Test
    public void test5() throws CharacterCodingException{
        Charset cs1 = Charset.forName("GBK");
        
        //获取编码器
        CharsetEncoder encoder = cs1.newEncoder();
        //获取解码器
        CharsetDecoder decoder = cs1.newDecoder();
        
        CharBuffer charBuffer = CharBuffer.allocate(1024);
        charBuffer.put("大家好");
        charBuffer.flip();

        //编码
        ByteBuffer buffer = encoder.encode(charBuffer);
        for(int i=0; i<6; i++){
            System.out.println(buffer.get());
        }
            
        //解码
        buffer.flip();
        CharBuffer charBuffer_2 = decoder.decode(buffer);
        System.out.println(charBuffer_2.toString());
        System.out.println("------------------------------");
        
        Charset cs2 = Charset.forName("utf-8");
        buffer.flip();
        CharBuffer charBuffer_3 = cs2.decode(buffer);
        System.out.println(charBuffer_3.toString());
    }
    
}
View Code

---- NIO的非阻塞

   传统的IO流是阻塞式的,当一个线程调用read()或者write()时,该线程被阻塞,直到有一些数据被读取或者写入,该线程在此期间不能执行其他任务,所以在网络中发生大量的IO请求操作时,如果线程发生阻塞,将会使系统性能急剧下降

   而NIO是非阻塞式的,当线程从某通道进行读写数据时,若没有数据过来,该线程可以进行其他任务。这是通过选择器(Seletor)实现,可以同时监控多个Channel,实现单个线程可以管理多个通道

创建流程:创建 Selector :通过调用 Selector.open() 方法创建一个 Selector

                 向选择器注册通道:SelectableChannel.register(Selector sel, int ops)  这里标注一下:register方法对通道的事件进行监听,通过第二个参数来指定可监听事件状态

                读 : SelectionKey.OP_READ,写 : SelectionKey.OP_WRITE,连接 : SelectionKey.OP_CONNECT, 接收 : SelectionKey.OP_ACCEPT

                若注册时不止监听一个事件,则可以使用“位或”操作符连接

         

举个栗子

package com.xxg.nio;

import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.DatagramChannel;
import java.nio.channels.SelectionKey;
import java.nio.channels.Selector;
import java.util.Date;
import java.util.Iterator;
import java.util.Scanner;

import org.junit.Test;
/*
 * 一、使用 NIO 完成网络通信的三个核心:
 * 
 * 1. 通道(Channel):负责连接
 *         
 *        java.nio.channels.Channel 接口:
 *             |--SelectableChannel
 *                 |--SocketChannel
 *                 |--ServerSocketChannel
 *                 |--DatagramChannel
 * 
 *                 |--Pipe.SinkChannel
 *                 |--Pipe.SourceChannel
 * 
 * 2. 缓冲区(Buffer):负责数据的存取
 * 
 * 3. 选择器(Selector):是 SelectableChannel 的多路复用器。用于监控 SelectableChann
 * el 的 IO 状况
 * 
 */

//非阻塞模式
public class TestNonBlockingNIO {
    @Test
    public void send() throws IOException {
        // Java NIO中的DatagramChannel是一个能收发UDP包的通道
        DatagramChannel dc = DatagramChannel.open();
        //切换成非阻塞模式
        dc.configureBlocking(false);
        
        ByteBuffer buffer = ByteBuffer.allocate(1024);
        Scanner scanner = new Scanner(System.in);
        
        while(scanner.hasNext()){
            String str = scanner.next();
            buffer.put((new Date().toString() + ":\n" + str).getBytes());
            buffer.flip();
            dc.send(buffer, new InetSocketAddress("127.0.0.1", 2333));
            buffer.clear();
        }
        dc.close();
    }

    @Test
    public void receive() throws IOException{
        //获取通道
        DatagramChannel dc = DatagramChannel.open();
        //将channel切换到非阻塞模式
        dc.configureBlocking(false);
        //绑定端口号
        dc.bind(new InetSocketAddress(2333));
        
        //获取选择器
        Selector selector = Selector.open();
        //将通道注册到选择器上,并且指定监听事件类型是 "读状态"
        dc.register(selector, SelectionKey.OP_READ);
        
        while(selector.select()>0){
            //获取当前选择器上所有注册好的通道(已经准备就绪的状态)
            Iterator<SelectionKey> iterator = selector.selectedKeys().iterator();
            
            while(iterator.hasNext()){
                //获取当前通道上的事件 
                SelectionKey sKey = iterator.next();
                
                if(sKey.isReadable()){
                    //如果当前事件 是处于 “可读就绪”状态, 就将该事件数据进行读取到缓存中
                    ByteBuffer buffer = ByteBuffer.allocate(1024);
                    //向连接通道发送缓存数据
                    dc.receive(buffer);
                    buffer.flip();
                    System.out.println(new String(buffer.array(), 0, buffer.limit()));
                    buffer.clear();
                }
            }
            iterator.remove();
            dc.close();
        }
    }
}
View Code

效果:

   客户端:

  服务端:

举个栗子

package com.xxg.nio;

import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;
import java.nio.file.Paths;
import java.nio.file.StandardOpenOption;

import org.junit.Test;

//测试阻塞模式
public class TestBlockingNIO {
    // 客户端
    @Test
    public void client() throws IOException {
        // 获取通道 Java NIO中的SocketChannel是一个连接到TCP网络套接字的通道
        SocketChannel sChannel = SocketChannel.open(new InetSocketAddress("127.0.0.1", 9898));
        FileChannel inChannel = FileChannel.open(Paths.get("D:/迅雷下载/1.txt"), StandardOpenOption.READ);

        ByteBuffer buf = ByteBuffer.allocate(1024);

        while (inChannel.read(buf) != -1) {
            buf.flip();
            sChannel.write(buf);
            buf.clear();
        }
        sChannel.shutdownOutput(); // 表示客户端数据已经全部发送完毕

        // 接收服务端的反馈
        int len = 0;
        while ((len = sChannel.read(buf)) != -1) {
            buf.flip();
            System.out.println(new String(buf.array(), 0, len));
            buf.clear();
        }

        inChannel.close();
        sChannel.close();
    }

    // 服务端
    @Test
    public void server() throws IOException {
        // Java NIO中的 ServerSocketChannel
        // 是一个可以监听新进来的TCP连接的通道,就像标准IO中的ServerSocket一样
        ServerSocketChannel scChannel = ServerSocketChannel.open();
        FileChannel outchannel = FileChannel.open(Paths.get("D:/迅雷下载/23.txt"), StandardOpenOption.WRITE,
                StandardOpenOption.CREATE);
        //绑定连接
       scChannel.bind(new InetSocketAddress(9898));
       //获取连接通道
       SocketChannel sChannel = scChannel.accept();
       
       //指定缓冲区
       ByteBuffer buffer = ByteBuffer.allocate(1024);
       
       while(sChannel.read(buffer)!= -1){
           buffer.flip();
           outchannel.write(buffer);
           buffer.clear();
       }
       
       //反馈客户端
       buffer.put("服务端成功接收到数据".getBytes());
       buffer.flip();
       sChannel.write(buffer);
       
       sChannel.close();
       outchannel.close();
       scChannel.close();
    }
}
View Code

 

 

 主要参考:尚硅谷NIO教程

                 JAVA NIO知识点总结(3)——通道Channel的原理与获取方法

posted @ 2019-01-29 20:44  凉月缘  阅读(338)  评论(0编辑  收藏  举报
Live2D //博客园自带,可加可不加