Java(六)IO、NIO、四种引用


  本系列文章:
     Java(一)数据类型、变量类型、修饰符、运算符
     Java(二)分支循环、数组、字符串、方法
     Java(三)面向对象、封装继承多态、重写和重载、枚举
     Java(四)内部类、包装类、异常、日期
     Java(五)反射、克隆、泛型、语法糖、元注解
    Java(六)IO、NIO、四种引用
     Java(七)JDK1.8新特性
     Java(八)JDK1.17新特性

一、IO流简介

  “流”是一种对抽象的数据的总称,本质是数据传输

1.1 IO流特点

  • 1、先进先出
     最先写入输出流的数据最先被输入流读取到。
  • 2、顺序存取
     可以一个接一个地往流中写入一串字节,读出时也将按写入顺序读取一串字节,不能随机访问中间的数据(RandomAccessFile除外)。
  • 3、只读或只写
     每个流只能是输入流或输出流的一种,不能同时具备两个功能,输入流只能进行读操作,对输出流只能进行写操作。在一个数据传输通道中,如果既要写入数据,又要读取数据,则要分别提供两个流。

  编写IO流的程序的时候一定要注意关闭流,一般的使用步骤:

  1. 选择合适的IO流对象;
  2. 创建对象;
  3. 传输数据;
  4. 关闭流对象。

1.2 IO流的分类*

  • 1、输入流和输出流
     这种分类方式是按数据流的方向(以程序为参照物)划分的:
     1)输入流(InputStream):从别的地方(本地文件,网络上的资源等)获取资源输入到程序中,表示读文件;
     2)输出流(OutputStream):从程序中 输出到别的地方(本地文件等),表示写文件。
  • 2、字节流和字符流
     这种分类方式是按数据处理的单位划分的。
     1字符 = 2字节 、 1字节(byte) = 8位(bit) 。Java中字符是采用Unicode标准,Unicode编码中,一个英文为一个字节,一个中文为两个字节
     1)字节流(OutputStream、InputStream):每次读取(写出)一个字节,当传输的资源文件有中文时,就会出现乱码;
     2)字符流(Reader、Writer):每次读取(写出)两个字节,有中文时,使用该流就可以正确传输显示中文。

二、IO流

  在整个java.io包中最重要的就是5个类和1个接口。5个类指的是File、OutputStream、InputStream、Writer、Reader,1个接口指的是Serializable。
  Serializable是用于实现序列化操作而提供的一个语义级别的接口。Serializable序列化接口没有任何方法或者字段,只是用于标识可序列化的语义。

  Java I0流的40多个类都是从如下4个抽象类基类中派生出来的:

输入流输出流
字节流InputStreamOutputStream
字符流ReaderWriter

  这四个类的子类:

  在实际使用时,常常是按操作对象,来使用具体的IO工具类的,示例:
  1)缓冲操作的类:BufferedInputStream、BufferedOutputStream、BufferedReader、BufferedWriter。
  2)基本数据类型操作的类:DataInputStream、DataOutputStream。
  3)对象序列化操作的类:ObjectInputStream、ObjectOutputStream。
  4)转化控制的类:InputStreamReader、OutputStreamWriter。
  5)文件操作的类:FileInputStream、FileOutputStream、FileReader、FileWriter。
  6)管道操作的类:PipedInputStream、PipedOutputStream、PipedReader、PipedWriter。
  7)数组操作的类:ByteArrayInputStream、ByteArrayOutputStream、CharArrayReader、CharArrayWriter。

2.1 文件File

  File类是对文件系统中文件以及文件夹进行封装的对象,可以通过对象的思想来操作文件和文件夹。File类保存文件或目录的各种元数据信息,包括文件名、文件长度、最后修改时间、是否可读、当前文件的路径名。

2.1.1 使用文件的常用方法
	//创建一个File对象
	public File(String pathname)
	//判断某个File对象是否存在
	public boolean exists()
	//判断某个File对象是否为文件夹
	public boolean isDirectory()
	//判断某个File对象是否为文件
	public boolean isFile()
	//获取文件长度,结果以字节为单位
	public long length()
	//返回由此抽象路径名表示的文件或目录的名称,此方法只返回文件或目录的名称
	public String getName()
	//返回抽象路径名的绝对路径名字符串
	public String getAbsolutePath()
	//返回此抽象路径名的父路径名的路径名字符串,如果此路径名没有指定父目录,则返回null
	public String getParent()
	//返回此抽象路径名的父路径名的抽象路径名,如果此路径名没有指定父目录,则返回null
	public File getParentFile()
	//返回一个抽象路径名数组,这些路径名表示此抽象路径名所表示目录中的文件
	public File[ ] listFiles()

  假设已经在F盘根目录下创建了一个test.txt的文件,文件内容为"123"。上面方法的使用示例:

	File  file = new File("F:/test.txt");
	System.out.println(file.exists());  //true
	System.out.println(file.isDirectory());  //false
	System.out.println(file.isFile());  //true
	System.out.println(file.length());  //3
	System.out.println(file.getName()); //test.txt
	System.out.println(file.getAbsolutePath()); //F:\test.txt
	System.out.println(file.getParent()); //F:\
	System.out.println(file.getParentFile()); //F:\
	//由于传入的File对象表示的是一个文件,而不是一个目录。listFiles是遍历一个目录下的所有文件,所以返回null 
	System.out.println(file.listFiles());  //null
2.1.2 创建文件的相关方法
  • 1、在已存在的目录下创建文件
	//方法定义
	public boolean createNewFile()
	//示例
	File  file = new File("F:/test2.txt");
	try {
		System.out.println(file.createNewFile());  //true
	} catch (IOException e) {
		e.printStackTrace();
	}  
  • 2、创建多级目录下的文件
      要创建多级目录文件时,可以先用getParentFile()方法获取文件所在目录,检查该目录是否存在,不存在的话就先创建目录,最后再创建文件。要创建目录要用到接下来的两个接口。
	//创建单级目录
	public boolean mkdir()
	//创建多级目录
	public boolean mkdirs()

  示例:

	File  file = new File("F:/test/test2.txt");	
	File dir = file.getParentFile();
	if (!dir.exists()) {
		dir.mkdirs();
	}
	try {
		file.createNewFile();
	} catch (IOException e) {
		e.printStackTrace();
	}

  如果要手动拼接多级目录时,可以用File.separator表示该操作系统的文件分割符。因为在不同系统(Windows和Linux)上,目录分隔符是不一样的('/'和'\'),File.separator可以在不同系统上表示不同的分隔符

2.2 字节流

2.2.1 字节输入流

  字节输入流InputStream(抽象类),定义了基于字节的输入操作。字节相关的输入类为:

  • 1、InputStream(常用)
    InputStream是所有字节输入流的抽象基类,InputStream实际上是作为模板而存在的,为所有实现类定义了处理输入流的方法。
  • 2、FileInputSream(常用)
     文件输入流,用于对文件进行读取操作。
  • 3、PipedInputStream
     管道字节输入流,能实现多线程间的管道通信。
  • 4、ByteArrayInputStream
     字节数组输入流,从字节数组(byte[ ])中进行以字节为单位的读取,也就是将资源文件都以字节的形式存入到该类中的字节数组中去。
  • 5、DataInputStream
     数据输入流,它是用来装饰其它输入流,作用是“允许应用程序以与机器无关方式从底层输入流中读取基本Java数据类型”。
  • 6、BufferedInputStream(常用)
     缓冲流,对节点流进行装饰,内部会有一个缓存区,用来存放字节,每次都是将缓存区存满然后发送,而不是一个字节或两个字节这样发送,效率更高。
     BufferedInputStream使用示例:
    File file = new File("F:/test.txt");
    FileInputStream fileInputStream = null;
    BufferedInputStream bufferedInputStream = null;
    try {
        fileInputStream = new FileInputStream(file);
        bufferedInputStream = new BufferedInputStream(fileInputStream);
        int read = 0;
        while((read = bufferedInputStream.read())!=-1){
            System.out.print((char)read);
        }
    }catch (Exception e) {	
	}
  • 7、ObjectInputStream(常用)
     对象输入流,用来提供对基本数据或对象的持久存储。通俗点说,也就是能直接传输对象,通常应用在反序列化中。它也是一种处理流,构造器的入参是一个InputStream的实例对象。
2.2.2 字节输入流的常用方法

  InputStream是所有的输入字节流的父类,是一个抽象类,主要方法:

	//读取一个字节并以整数的形式返回(0~255),如果返回-1已到输入流的末尾
	public abstract int read() throws IOException;
	//读取一系列字节并存储到一个数组buffer,返回实际读取的字节数,如果
	//读取前已到输入流的末尾返回-1
	public int read(byte b[]) throws IOException
	//读取length个字节并存储到一个字节数组buffer,从off位置开始存,最多
	//len, 返回实际读取的字节数,如果读取前以到输入流的末尾返回-1
	public int read(byte b[], int off, int len) throws IOException
	//关闭流
	public void close() throws IOException

  示例:

    //循环输出所有的字节,每次读取一个字节
    while((read = inputStream.read())!=-1){
       System.out.println((char)read);
    }

     //添加缓冲区的方式进行读取,每次会将数据添加到缓冲区中,当缓冲区满了之后,
	 //一次读取,而不是每一个字节进行读取
     byte[] buffer = new  byte[1024];
     while((length = inputStream.read(buffer))!=-1){
         System.out.println(new String(buffer,0,length));
     }
2.2.3 字节输出流

  字节输出流OutputStream(抽象类),基于字节的输出操作。
  OutputStream是所有字节输出流的抽象基类。OutputStream的附属类(ByteArrayOutputStream、PipedOutputStream、BufferedOutputStream、FileOutputSream、ObjectOutputStream等)的作用和InputStream作用是相反的,一个读取、一个写入。
  FileOutputStream常用,使用示例:

    File file = new File("F:/test.txt");
    OutputStream outputStream = null;
    try {
        outputStream = new FileOutputStream(file);
        outputStream = new FileOutputStream(file);
        outputStream.write("\r456".getBytes());
    } catch (FileNotFoundException e) {
        e.printStackTrace();
    } catch (IOException e) {
        e.printStackTrace();
    }finally {
        try {
            outputStream.close();
        } catch (IOException e) {
            e.printStackTrace();
        }
   }

  此时test.txt文件的内容就变成了"456"。

2.2.4 字节输出流的常用方法

  OutputStream是所有的输出字节流的父类,是一个抽象类,主要方法:

	//向输出流中写入一个字节数据,该字节数据为参数b的低8位
	public abstract void write(int b) throws IOException
	//将一个字节类型的数组中的数据写入输出流
	public void write(byte b[]) throws IOException
	//将一个字节类型的数组中的从指定位置(off)开始的,len个字节写入到输出流
	public void write(byte b[], int off, int len) throws IOException
	//将输出流中缓冲的数据全部写出到目的地。
	public void flush() throws IOException
	//关闭流
	public void close() throws IOException

2.3 字符流

2.3.1 字符输入流

  文件读取流(抽象类),基于字符的输入操作。
  字符流可以直接读取中文汉字,而字节流在处理的时候会出现中文乱码

  • 1、InputStreamReader(常用)
      从字节流到字符流的桥梁(InputStreamReader构造器入参是FileInputStream的实例对象),它读取字节并使用指定的字符集将其解码为字符。它使用的字符集可以通过名称指定,也可以显式给定,或者可以接受平台的默认字符集。
  • 2、BufferedReader(常用)
      从字符输入流中读取文本,设置一个缓冲区来提高效率。BufferedReader是对InputStreamReader的封装,前者构造器的入参就是后者的一个实例对象。
  • 3、FileReader(常用)
      用于读取字符文件的便利类,new FileReader(File file)等同于new InputStreamReader(new FileInputStream(file, true),“UTF-8”),但FileReader不能指定字符编码和默认字节缓冲区大小。使用示例:
    FileReader reader = null;
    int read;
	try {
		reader = new FileReader("F:/test.txt");
		while((read = reader.read())!=-1){
			System.out.println((char)read);
		}
	} catch (IOException e) {
		e.printStackTrace();
	}
  • 4、PipedReader
      管道字符输入流。实现多线程间的管道通信。
  • 5、CharArrayReader
      从Char数组中读取数据的介质流。
  • 6、StringReader
      从String中读取数据的介质流。
2.3.2 字符输入流的常用方法

  Reader是所有的字符输入流的父类,是一个抽象类,主要方法:

	//读取一个字符并以整数的形式返回(0~255),如果返回-1已到输入流的末尾
	public int read() throws IOException
	//读取一系列字符并存储到一个数组buffer,返回实际读取的字符数,如果
	//读取前已到输入流的末尾返回-1
	public int read(char cbuf[]) throws IOException
	//读取length个字符,并存储到一个数组buffer,从off位置开始存,最多读取
	//len,返回实际读取的字符数,如果读取前以到输入流的末尾返回-1
	abstract public int read(char cbuf[], int off, int len) throws IOException;
	//关闭此流,但一般要先刷新
	abstract public void close() throws IOException;

  read(char[] cbuf)使用示例:

    int length = 0;
    char[] chars = new char[1024];
    //添加缓冲区
    while((length = reader.read(chars))!=-1){
        System.out.println(new String(chars,0,length));
    }
2.3.3 字符输出流的分类

  文件写出流(抽象类),基于字符的输出操作。
  CharArrayWriter、StringWriter 是两种基本的介质流,它们分别向Char 数组、String 中写入数据。PipedWriter 是向与其它线程共用的管道中写入数据。常见的字符输出、输入流是FileWriter和FileReader。

2.3.4 字符输出流的常用方法

  Writer是所有的字符输出流的父类,是一个抽象类,主要方法:

	//向输出流中写入一个字符数据,该字节数据为参数b的低16位
    public void write(int c) throws IOException
    //将一个字符类型的数组中的数据写入输出流
    public void write(char cbuf[]) throws IOException
    //将一个字符类型的数组中的从指定位置(offset)开始的,length个字符写入到输出流
    abstract public void write(char cbuf[], int off, int len) throws IOException
    //将一个字符串中的字符写入到输出流
    public void write(String str) throws IOException
    //将一个字符串从offset开始的length个字符写入到输出流
    public void write(String str, int off, int len) throws IOException
    //将输出流中缓冲的数据全部写出到目的地
    abstract public void flush() throws IOException;
    //关闭此流,但一般要先刷新它
    abstract public void close() throws IOException;
    

2.5 字节流和字符流的相关问题

2.5.1 字节流和字符流的区别*
字节流字符流
读写单位以字节(一般为byte[ ])为单位以字符(一般为char[ ])为单位
处理对象能处理所有类型的数据(如图片、MP4等)只能处理字符类型的数据
是否用到缓冲区操作的时候,本身是不会用到缓冲区的,是操作文件本身的操作的时候是使用缓冲区的
是否一定要调用close方法即使不关闭资源(调用close方法),文件也能输出不使用close方法时,不会输出任何内容。此时可以使用flush方法强制刷新缓冲区,这时才能在不调用close方法时输出内容
read方法返回值的范围InputStream的read()方法返回值类型为int,由于InputStream是面向字节流的,1个字节8位,所以返回值是0-255范围内的int值。如果读取到流末尾,则返回-1Reader类的read()方法返回值类型为int,作为整数读取的字符(占两个字节共16位),范围在0-65535之间。如果读取到流末尾,则返回-1

  简单来说,字节流以字节为单位输入输出数据,按照8位传输;字符流以字符为单位输入输出数据,按照16位传输。

2.5.2 字节流与字符流的桥梁

  字节流和字符流之间有没有转换的桥梁呢?答案是转换流。
  在网络上交互的流形式一般是OutputStream和InputStream,但流总的数据常常是以字符为单位的,此时用转换流效率高些。
  转换流的特点:

  1. 字符流和字节流之间的桥梁;
  2. 可对读取到的字节数据经过指定编码转换成字符;
  3. 可对读取到的字符数据经过指定编码转换成字节。

  那什么时候使用转换流:

  1. 当字节和字符之间有转换动作时;
  2. 流操作的数据需要编码或解码时。

  OutputStreamWriter(将字节流以字符流输出)、InputStreamReader(将字节流以字符流输入)是2个将字节转换成字符的转换流。使用示例:

        File file = new File("abc.txt");
        OutputStreamWriter outputStreamWriter = null;
        FileOutputStream fileOutputStream = null;

        try {
            fileOutputStream = new FileOutputStream(file);
            outputStreamWriter = 
                new OutputStreamWriter(fileOutputStream,"iso8859-1");
            outputStreamWriter.write(99);
            outputStreamWriter.write("abcdefg",0,5);
        } catch (IOException e) {
			e.printStackTrace();
		}
        File file = new File("abc.txt");
        FileInputStream fileInputStream = null;
        InputStreamReader inputStreamReader = null;

        try {
            fileInputStream = new FileInputStream(file);
            inputStreamReader = new InputStreamReader(fileInputStream);
            //为什么没有用循环的方式,因为测试文件数据比较少,无法占用1024个缓存区,
            //只需要读取一次即可
            char[] chars = new char[1024];
            int length = inputStreamReader.read(chars);
            System.out.println(new String(chars,0,length));
        } catch (IOException e) {
			e.printStackTrace();
		}
2.5.3 既然有了字节流,为什么还要有字符流*

  问题本质:不管是文件读写还是网络发送接收,信息的最小存储单元都是字节,那为什么 I/O 流操作要分为字节流操作和字符流操作呢?

  字符流是由Java虚拟机将字节转换得到的,这个过程还算是非常耗时,并且,如果我们不知道编码类型就很容易出现乱码问题(即:使用字节流也可以操作字符类文件,但是耗时也容易出现编码相关问题)。所以, I/O 流就干脆提供了一个直接操作字符的接口,方便我们平时对字符进行流操作。如果音频文件、图片等媒体文件用字节流比较好,如果涉及到字符的话使用字符流比较好。

2.5.4 什么时候使用字节流、什么时候使用字符流

  InputStream和OutputStream,两个是为字节流设计的,主要用来处理字节或二进制对象;Reader和 Writer两个是字符流(一个字符占两个字节)设计的,主要用来处理字符或字符串。
  字符流处理的单元为2个字节的Unicode字符,操作字符、字符数组或字符串,字节流处理单元为1个字节,操作字节和字节数组。字符流是由Java虚拟机将字节转化为2个字节的Unicode字符为单位的字符而成的。
  如果是处理音视频、图片、歌曲等二进制文件,就用字节流好点;如果是关系到文本的,用字符流好点
  所有文件的储存是都是字节(byte)的储存,在磁盘上保留的并不是文件的字符而是先把字符编码成字节,再储存这些字节到磁盘。在读取文件(特别是文本文件)时,也是一个字节一个字节地读取以形成字节序列。
  字节流可用于任何类型的对象,包括二进制对象,而字符流只能处理字符或者字符串
  字节流提供了处理任何类型的IO操作的功能,但它不能直接处理Unicode字符,而字符流就可以。
  字节流在操作时不会用到缓冲区(内存),是直接对文件本身进行操作的。而字符流在操作时使用了缓冲区,通过缓冲区再操作文件。
  在硬盘上的所有文件都是以字节形式存在的(图片,声音,视频),而字符值在内存中才会形成。
  真正存储和传输数据时都是以字节为单位的,字符只是存在于内存当中的,所以,字节流适用范围更为宽广。

2.6 缓冲流

   为什么会有缓冲流呢?不带缓冲的操作,每读一个字节就要写入一个字节,由于涉及磁盘的IO操作相比内存的操作要慢很多,所以不带缓冲的流效率很低
  带缓冲的流,可以一次读很多字节,但不向磁盘中写入,只是先放到内存里。等凑够了缓冲区大小的时候一次性写入磁盘,这种方式可以减少磁盘操作次数,速度就会提高很多
  缓冲流主要有四个:BufferedInputStream、BufferedOutputStream、BufferedReader和BufferedWriter。

  • 1、BufferedInputStream/BufferedOutputStream常用构造方法
	//创建BufferedInputStream并保存其参数,即输入流in,以便将来使用 
	public BufferedInputStream(InputStream in)
	//创建一个新的缓冲输出流,以将数据写入指定的基础输出流
	public BufferedOutputStream(OutputStream out)
  • 2、BufferedReader/BufferedWriter常用构造方法
	//创建一个使用默认大小输入缓冲区的缓冲字符输入流
	public BufferedReader(Reader in)
	//创建一个使用默认大小输出缓冲区的缓冲字符输出流
	public BufferedWriter(Writer out)

  使用示例:

    BufferedReader  reader = null;
    try {
        reader = new BufferedReader(new FileReader("aaa.txt"));
        String read = null;
         while((read = reader.readLine())!=null){
             System.out.println(read);
         };
     } catch (IOException e) {
    	 e.printStackTrace();
	}

    BufferedWriter bufferedWriter = null;
    FileWriter fileWriter = null;
    try {
         fileWriter =  new FileWriter(new File("abc.txt"));
         bufferedWriter = new BufferedWriter(fileWriter);
         bufferedWriter.append("12345666");
         bufferedWriter.newLine();
         bufferedWriter.flush();
     } catch (IOException e) {
			e.printStackTrace();
	 }
  • 3、PrintWriter
      其实相对于BufferedWriter,PrintWriter是应用更为广泛的缓存字符输出流,两者的具体区别:
      BufferedWriter:将文本写入字符输出流,缓冲各个字符从而提供单个字符,数组和字符串的高效写入。通过write()方法可以将获取到的字符输出,然后通过newLine()进行换行操作。BufferedWriter中的字符流必须通过调用flush方法才能将其刷出去。并且BufferedWriter只能对字符流进行操作。如果要对字节流操作,则使用BufferedInputStream。
      PrintWriter:向文本输出流打印对象的格式化表示形式(Prints formatted representations of objects to a text-output stream)。PrintWriter相对于BufferedWriter的好处在于,如果PrintWriter开启了自动刷新,那么当PrintWriter调用println,prinlf或format方法时,输出流中的数据就会自动刷新出去。PrintWriter不但能接收字符流,也能接收字节流。

  PrintWriter的优点:

BufferedWriter的缺点PrintWriter的优点
接受参数BufferedWriter的write方法只能接受字符、字符数组和字符串PrintWriter的print、println方法可以接受任意类型的参数
换行BufferedWriter需要显示调用newLine方法PrintWriter的println方法自动添加换行
刷新需要手动刷新PrintWriter构造方法可指定参数,实现自动刷新缓存

  使用PrintWriter示例:

    File file = new File("E:/test.txt");
	FileWriter fr = new FileWriter(file);
	PrintWriter pw = new PrintWriter(fr);
	pw.println("12");

三、NIO

  当使用socket套接字进行网络通信开发时,会用到的三种方式:BIO、NIO和AIO。

3.1 初识NIO

  NIO(Non-blocking I/O,非阻塞I/O)是一种编程模式,提供了比传统阻塞I/O更高效的数据处理方式。它主要应用于需要高并发、高性能的网络服务器或应用程序中、允许单个线程同时管理多个网络连接。NIO的核心在于其提供的通道(Channel)和缓冲区(Buffer)的概念,以及选择器(Selector)的机制,这使得NIO能在不同网络连接间高效地切换,而无需创建多个线程。

  • NIO的关键特性
      NIO的核心特性包括非阻塞模式、通道和缓冲区的使用、选择器的能力。非阻塞模式允许I/O操作在没有数据可读或写时立即返回,而不是保持线程阻塞,这使得单个线程可以管理多个输入和输出通道。通道(Channel)是NIO中的一个重要概念,它代表了能够执行读取或写入操作的开放连接,如文件和网络套接字。与传统的流不同,通道支持双向数据传输——数据既可以读也可以写。
      缓冲区(Buffer)则是另一个关键概念,它是数据的容器。在NIO中,任何数据的读写都是通过缓冲区进行的。缓冲区实际上是一块可以写入数据,然后可以从中读取数据的内存。这种方式使得数据的处理变得更加高效,因为它减少了在系统内存和网络间的数据传输次数。
  • NIO适用场景
      高并发网络服务器是NIO的理想应用场景。在这类应用中,服务器需要同时处理成千上万的客户端连接,如果采用传统的一个连接一个线程的模型,系统很快就会因为线程过多而资源耗尽。NIO通过使用单个或少数几个线程来管理这些连接,极大地减少了资源消耗,提高了系统的可伸缩性和性能。
      另一方面,高性能文件处理也是NIO展现其优势的场景。NIO提供了映射文件的能力,可以直接在内存(堆外内存)中修改文件,避免了传统文件I/O操作中将数据从操作系统拷贝到用户内存的步骤,从而提高了处理速度。
  • NIO与阻塞I/O的比较
      在传统的阻塞I/O模型中,每一个新的连接都需要创建一个新的线程,这在面对大量连接时会导致资源的巨大消耗和性能瓶颈。NIO通过非阻塞模式解决了这一问题,它允许单个线程以非阻塞的方式处理多个连接。这种模式不仅减少了线程的数量,降低了资源消耗,还提高了系统的处理能力。
  • NIO与阻塞I/O的比较
      在实现NIO时,关键是要理解和掌握其核心组成部分:通道(Channel)、缓冲区(Buffer)和选择器(Selector)。通道与缓冲区是NIO中用于数据传输的基本单位,而选择器用于监听多个通道的事件(如连接打开、数据到达等),是实现单线程管理多连接的关键。要有效地应用NIO,开发者需要根据具体应用场景设计合理的架构,合理利用NIO提供的非阻塞模式、通道、缓冲区和选择器等机制。
      在高并发网络编程中,NIO已经被广泛应用。例如,Java的NIO库提供了一套丰富的API,用于构建高性能的网络服务器和客户端。此外,NIO也常用于开发高效读写大文件的应用程序,通过其文件通道(FileChannel)和内存映射文件(MappedByteBuffer)等功能,可以显著提高文件处理的速度。

3.2 三种Reactor模型*

  Reactor模型是一种在事件驱动架构中用于处理非阻塞I/O操作的设计模式。该模型利用单一或多重反应器来接收和分派到达的事件或请求,从而提供了一种非阻塞、高并发的解决方案。与传统的多线程模型相比,Reactor模型更加高效,因为它最小化了上下文切换和线程同步的开销。
  Reactor模型的核心思想是通过一个或多个反应器来统一处理多个非阻塞I/O操作。反应器负责监听I/O事件,并将相应事件分发给相应的处理器进行处理。这样做的好处是能够简化并发编程的复杂性,并最小化系统资源的使用。

  • 传统的IO模型
      这种模式是传统设计,每一个请求到来时,大致都会按照【请求读取->请求解码->服务执行->编码响应->发送答复】这个流程去处理。
      在传统的IO模型种,如果有多个客户端连接服务端,服务端会开启很多线程,一个线程为一个客户端服务。很明显,面对高并发量的请求时,这种模型并不适用。

      传统IO模型的特点:

  采用阻塞IO模式获取输入数据。
  每个连接读需要独立的线程完成数据的输入,业务处理,数据返回。

  传统IO模型的缺点:

  当并发数很大的时候,就会创建大量的线程,这样就会占用系统很多的资源。
  连接创建后,如果当前线程暂时没有数据可读,该线程会阻塞在read操作。

  • Reactor模式
      因为传统的IO模式不适合服务端对于大请求量的处理,因此引入了Reactor模型。
      Reactor模式的特点:

  Reactor模式,通过一个或多个输入同时传递给服务处理器的模式(基于事件驱动)。
  服务器端处理传入的多个请求,并将它们同步分派到相应的处理线程上,因此Reactor模式也叫Dispatcher模式。
  Reactor 模式使用IO复用监听事件,收到事件后,分发给某个线程(进程),这点就是网络服务高并发处理的关键。

  Reactor 模式核心组成:

  Reactor:Reactor 在一个单独的线程中运行,负责监听和分发事件,分发给适当的处理程序来对IO事件做出反应。
  Handlers:处理程序执行I/O事件要完成的实际事件。
  Acceptor:处理客户端新连接。

  根据Reactor的数量和处理资源线程池的数量不同,有三种典型的实现:单Reactor单线程、单Reactor多线程、主从Reactor多线程。
  三种模式用生活案例举例:
   1、单Reactor单线程:前台接待员和服务员都是同一个人,全程为顾客服务。
   2、单Reactor多线程:一个前台接待员,多个服务员,接待员只负责接待。
   3、主从Reactor多线程:多个前台接待员,多个服务员。

  • 1、单Reactor单线程

      Select是标准网络编程API,可以实现应用通过一个阻塞对象监听多路连接请求。
      Reactor对象通过Select监控客户端请求事件,收到事件后通过Dispatch进行分发。
      如果建立连接请求事件,则有Acceptor通过Accept处理连接请求,然后创建一个Handler对象处理连接完成后续业务流程。
      如果不是建立连接事件,则Reactor会分发调用连接对应的Handler来响应。
      Handler会完成Read -> 业务处理 -> send 的完整业务流程。
    【单Reactor单线程的优缺点】
      单Reactor单线程的优点:模型简单,没有多线程、进程通信、竞争问题,全部都在一个线程中完成。
      单Reactor单线程的缺点:

  1、性能问题。只有一个线程,无法发挥多核CPU的性能,Handler在处理某个连接上的业务时,整个进程无法处理其他的连接事件,很容易导致性能瓶颈。
  2、可靠性问题。线程意外终止,或者进入死循环,会导致这个通信模块不可用,不能接收和处理外部消息,造成节点故障。

  单Reactor单线程的使用场景:客户端数量有限,业务处理非常快。

  • 2、单Reactor多线程

      Reactor对象通过select监控客户端请求事件,收到事件后,通过Dispatch进行分发。
      如果是建立连接的请求,则由Acceptor通过Accept处理连接请求,然后创建一个Handler对象处理连接后的各种事件。
      如果不是连接请求,则由Reactor 分发调用连接对应的Handler来处理。
      Handler 只负责响应时间,不做具体的业务处理,通过read读取数据后,会分发给后面的worker线程池的某个线程处理业务。
      worker线程池会分配一个独立的线程完成真正的业务,并将结果返回给Handler。
      Handler 收到响应后,通过send将结果返回给client。
    【单Reactor多线程的优缺点】
      单Reactor多线程的优点:可以充分的利用多核CPU的处理能力。
      单Reactor多线程的缺点:多线程数据共享和访问比较复杂,Reactor处理所有的事件监听和响应,在单线程运行,在高并发场景会出现性能瓶颈。
  • 3、主从Reactor多线程

      Reactor主线程MainReactor对象通过select监听连接事件,收到事件后,通过Acceptor处理连接事件。
      当Acceptor处理连接事件后,MainReactor将连接分配给SubReactor。
      SubReactor将连接加入到连接队列进行监听,并创建Handler进行各种事件处理。
      当有新的事件发生,SubReactor就会调用对应的Handler处理。
      Handler通过read读取数据,分发给后面的worker线程处理。
      worker线程池分配独立的worker线程进行处理,并返回结果。
      handler收到响应结果后,再通过send将结果返回给client。
      Reactor主线程可以对应多个Reactor子线程,即MainReactor可以关联多个SubReactor。
    【主从Reactor多线程的优缺点】
      主从Reactor多线程的特点:

  1、父线程与子线程的数据交互简单职责明确,父线程只负责接收新连接,子线程完成后续的业务处理。
  2、父线程与子线程的数据交互简单,Reactor主线程只需要把新连接传给子线程,子线程无需返回数据。

  主从Reactor多线程的优点:

  1、响应快,不必为单个同步事件所阻塞,虽然Reactor本身依然是同步的;
  2、可以最大程度避免复杂的多线程及同步问题,并且避免多线程/进程的切换;
  3、扩展性好,可以方便通过增加Reactor实例个数充分利用CPU资源;
  4、复用性好,Reactor模型本身与具体事件处理逻辑无关,具有很高的复用性。

  主从Reactor多线程的缺点:编程复杂度较高。

3.3 NIO原理*

  NIO的三大核心组件:Channel(通道),Buffer(缓冲区),Selector(选择器)。Selector能让单线程同时处理多个客户端Channel,非常适用于高并发,传输数据量较小的场景。
  传统IO是基于字节流和字符流进行操作(面向流编程),而NIO基于通道和缓冲区进行操作(面相缓冲区),数据总是从通道读取到缓冲区中,或者从缓冲区写入到通道中。Selector(选择器)用于监听多个通道的事件(如连接打开、数据到达)。因此单个线程可以监听多个数据通道。

  每个Channel都会对应一个Buffer。
  一个Selector对应一个线程,一个线程对应多个Channel(连接)。
  程序切换到哪个Channel是由事件决定的,Event就是一个重要的概念。Selector会根据不同的事件,在各个通道上切换。
  Buffer就是一个内存块,底层是一个数组。
  NIO数据的读取写入是通过Buffer,这个 BIO是不同的,BIO中要么是输入流,要么是输出流,不能是双向的。但是NIO的Buffer可以读也可以写,需要flip方法切换。 Channel是双向的,可以反映底层操作系统的情况,比如 Linux,底层的操作系统通道就是双向的。

3.4 NIO三大组件*

3.4.1 Buffer

  Buffer,缓冲区,实际上是一个容器,是一个连续数组。Channel 提供从文件、网络读取数据的渠道,但是读取或写入的数据都必须经由 Buffer。

  上面的图描述了从一个客户端向服务端发送数据,然后服务端接收数据的过程。客户端发送数据时,必须先将数据存入 Buffer 中,然后将 Buffer 中的内容写入通道。服务端这边接收数据必须通过 Channel 将数据读入到 Buffer 中,然后再从 Buffer 中取出数据来处理。

  Buffer 缓冲区是一个用于存储特定基本类型数据的容器,缓冲区实质上是一个数组。该对象提供了一组方法,可以更轻松地使用内存块,缓冲区对象内置了一些机制,能够跟踪和记录缓冲区的状态变化情况。Channel提供从文件、网络读取数据的渠道,但是读取或写入的数据都必须经由Buffer。
  在NIO中,Buffer是一个顶层父类,它是一个抽象类,除了boolean其他数据类型都有Buffer。,常用的 Buffer 的子类有:ByteBuffer、IntBuffer、 CharBuffer、 LongBuffer、 DoubleBuffer、FloatBuffer、ShortBuffer。

  示例:

public static void main(String[] args) {
    //ByteBuffer buffer = ByteBuffer.allocateDirect(5);  创建堆外内存块DirectByteBuffer
    IntBuffer intBuffer = IntBuffer.allocate(5); //创建堆内内存块HeapByteBuffer
    for (int i = 0; i < intBuffer.capacity(); i++) {
        intBuffer.put(i * 2);
    }
    //缓存区是双向的,既可以往缓冲区写入数据,也可以从缓冲区读取数据。但是不能同时进行,需要切换
    intBuffer.flip(); //读写模式切换
    while (intBuffer.hasRemaining()) { //判断position的索引是否小于limit
        System.out.println(intBuffer.get()); //每get一次position就加一
    }
}
3.4.2 Channel

  Channel和IO中的Stream(流)是差不多一个等级的。只不过Stream是单向的,譬如:InputStream, OutputStream,而Channel是双向的,既可以用来进行读操作,又可以用来进行写操作。
  通道Channel是一个对象,可以表示磁盘文件、Socket套接字。Channel本身并不存储数据,只是负责数据的运输,当然所有数据都通过Buffer对象来处理。我们永远不会将字节直接写入通道,而是将数据写入包含一个或者多个字节的缓冲区。同样也不会直接从通道中读取字节,而是将数据从通道读入缓冲区,再从缓冲区获取这个字节。
  通道与流的区别:

  Channel 支持异步读写,Stream 通常只支持同步。
  NIO 中的通道(Channel)是双向的,可以从缓冲读数据,也可以写数据到缓冲,当然还需要经过 Buffer。而 BIO 中的 Stream 是单向的,例如 FileInputStream 对象只能进行读取数据的操作。

  常见的 Channel 有以下四种:

  FileChannel:读写文件中的数据。
  SocketChannel:通过 TCP 读写网络中的数据,类似 Socket。
  ServerSockectChannel:监听新进来的 TCP 连接,像 Web 服务器那样。对每一个新进来的连接都会创建一个 SocketChannel,类似 ServerSocket。
  DatagramChannel:通过 UDP 读写网络中的数据。

3.4.3 Selector

  Selector选择器是 NIO 的核心类,Selector 能够检测多个注册的通道上是否有事件发生,如果有事件发生,便获取事件然后针对每个事件进行相应的响应处理。这样一来,只是用一个单线程就可以管理多个通道,也就是管理多个连接。这样使得只有在连接真正有读写事件发生时,才会调用函数来进行读写,就大大地减少了系统开销,并且不必为每个连接都创建一个线程,不用去维护多个线程,并且避免了多线程之间的上下文切换导致的开销。
  只有网络IO才会使用选择器,文件IO是不需要使用的。
  NIO 中实现非阻塞 I/O 的核心对象是 Selector,Selector 是注册各种 I/O 事件的地方,它可以监听通道的状态。换句话说就是事件驱动,以此实现**单线程管理多个 Channel **的目的。
  NIO 是非阻塞 IO。使用 Selector选择器可以让一个线程处理多个的客户端连接。
  Selector 能够检测多个注册的通道上是否有事件发生(注意:多个 Channel 以事件的方式可以注册到同一个 Selector),如果有事件发生,便获取事件然后针对每个事件进行相应的处理。这样就可以只用一个单线程去管理多个通道,也就是管理多个连接和请求。
  只有在连接/通道真正有读写事件发生时,才会进行读写,就大大地减少了系统开销,并且不必为每个连接都创建一个线程,不用去维护多个线程。
  避免了多线程之间的上下文切换导致的开销。
  要使用 Selector,首先要将对应的 Channel 以及 IO 事件(读、写、连接)注册到 Selector,注册后会产生一个 SelectionKey 对象,用于关联 Selector 和 Channel,及后续的 IO 事件处理。

  • 管道 Channel 和 选择器 Selector 的关系
      Selector 通过不断轮询的方式同时监听多个 Channel 的事件,注意这里是同时监听,一旦有 Channel 准备好了,它就会返回这些准备好了的 Channel,交给处理线程去处理。
      在NIO编程中,通过 Selector 我们就实现了一个线程同时处理多个连接请求的目标,也可以一定程序降低服务器资源的消耗。

3.5 IO/NIO的相关问题

3.5.1 BIO、NIO、AIO的区别

  Java共支持3种网络编程IO模型:BIO、NIO、AIO。
  BIO:同步阻塞,服务器实现模式为一个连接一个线程,即客户端有连接请求时服务器端就需要启动一个线程进行处理,如果这个连接不做任何事情会造成不必要的线程开销。
  NIO:同步非阻塞,服务器实现模式为一个线程处理多个请求(连接),即客户端发送的连接请求都会注册到多路复用器上,多路复用器轮询到连接有 I/O 请求就进行处理。
  AIO(NIO.2):异步非阻塞,AIO引入异步通道的概念,采用了Proactor模式,简化了程序编写,有效的请求才启动线程,它的特点是先由操作系统完成后才通知服务端程序启动线程去处理,一般适用于连接数较多且连接时间较长的应用。
  BIO、NIO、AIO的适用场景:

  1、BIO 方式适用于连接数目比较小且固定的架构,这种方式对服务器资源要求比较高,并发局限于应用中,JDK1.4 以前的唯一选择,但程序简单易理解。
  2、NIO 方式适用于连接数目多且连接比较短(轻操作)的架构,比如聊天服务器,弹幕系统,服务器间通讯等。编程比较复杂,JDK1.4 开始支持。
  3、AIO 方式使用于连接数目多且连接比较长(重操作)的架构,比如相册服务器,充分调用 OS 参与并发操作,编程比较复杂,JDK7 开始支持。

3.5.2 NIO和BIO的比较*
  1. BIO以流的方式处理数据;而NIO以块的方式处理数据,块 I/O 的效率比流 I/O 高很多。
  2. BIO是阻塞的;NIO则是非阻塞的
  3. BIO 基于字节流和字符流进行操作,而 NIO 基于 Channel(通道)和 Buffer(缓冲区)进行操作,数据总是从通道读取到缓冲区中,或者从缓冲区写入到通道中。Selector(选择器)用于监听多个通道的事件(比如:连接请求,数据到达等),因此使用单个线程就可以监听多个客户端通道。
  4. BIO中的流是单向的;NIO中的Buffer 和 Channel 之间的数据流向是双向的
3.5.3 NIO和IO(BIO)的适用场景

  如果需要管理同时打开的成千上万个连接,这些连接每次知识发送少量的数据,例如聊天服务器,这时候用NIO处理数据是个很好的选择。
  如果只有少量的连接,这些连接每次要发送大量的数据,这时候传统的IO更适合。

四、四种引用

  在JDK1.2以前的版本中,当一个对象不被任何变量引用,那么程序就无法再使用这个对象。但是,总存在着这样一类对象:当内存空间还足够时,则能保留在内存之中;如果内存空间在进行垃圾收集后还是非常紧张,则可以抛弃这些对象。于是在JDK 1.2之后,Java对引用的概念进行了扩充,将引用分为强引用、软引用、弱引用、虚引用4种,这4种引用强度依次逐渐减弱。

4.1 四种引用的使用

  • 1、强引用
      强引用就是指在程序代码之中普遍存在的,类似“Object obj=new Object()”这类的引用,当内存空间不足,Java虚拟机宁愿抛出OutOfMemoryError错误,使程序异常终止,也不回收这种强引用对象。示例:
public class StrongReferenceTest {
	public static void main(String[] args) {
		new StrongReferenceTest().test();
	}
	public void test(){
		Object[] objArr=new Object[Integer.MAX_VALUE];
	}
}

  测试结果为:

  想中断强引用和某个对象之间的关联,可以显示地将引用赋值为null。这样一来的话,JVM在合适的时间就会回收该对象。

  • 2、软引用
      软引用是用来描述一些还有用但并非必需的对象。对于软引用关联着的对象,在内存不足、系统将要发生内存溢出异常之前,将会把这些软引用对象列进回收范围之中进行回收
      在JDK1.2之后,提供了java.lang.ref.SoftReference类来实现软引用。
      软引用的创建方式示例:
	//wrf这个引用也是强引用,它是指向SoftReference这个对象的,
	//这里的软引用指的是指向new String("str")的引用
	SoftReference<String> wrf = new SoftReference<String>(new String("str"));

  软引用的可用场景:创建缓存的时候,创建的对象放进缓存中,当内存不足时,JVM就会回收早先创建的软引用对象
  说的再详细一些:如果某个Java对象只被软引用所指向,那么在JVM要新建一个对象的时候,如果当前虚拟机所剩下的堆内存不足以保存这个要新建的对象的时候(即虚拟机将要抛出OutOfMemoryError的时候),JVM会发起一次垃圾回收动作,将堆中所只被非强引用指向的对象回收,以提供更多的可用内存来新建这个对象,如果经过垃圾回收动作之后虚拟机的堆内存中仍然没有足够的可用空间来创建这个对象,那么虚拟机将抛出一个OutOfMemoryError异常。

  • 3、弱引用
      弱引用也是用来描述非必需对象的,但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生之前,也就是说,当垃圾收集器工作时,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。在JDK 1.2之后,提供了java.lang.ref.WeakReference类来实现弱引用。

弱引用一般用在容器里。一个典型引用是ThreadLocal。

  弱引用示例:

public class WeakReferenceTest {
    public static void main(String[] args) {
        WeakReference<String> sr = new WeakReference<String>(new String("helloworld"));
        System.out.println(sr.get());
        /*通知JVM的gc进行垃圾回收*/
        System.gc();                
        System.out.println(sr.get());
    }
}

  测试结果:

helloworld
null

  需要注意的是,当显示调用System.gc()时,JVM不一定会立刻执行,也就是说这句是无法确保此时JVM一定会进行垃圾回收的。
  简而言之:弱引用的引用强度更弱一些,对于只被弱引用指向的对象来说,其只能存活到下一次 JVM 执行垃圾回收动作之前。也就是说:JVM 的每一次垃圾回收动作都会回收那些只被弱引用指向的对象。

  • 4、虚引用
      虚引用也称为幽灵引用或者幻影引用,它是最弱的一种引用关系,它并不影响对象的生命周期。在JDK1.2之后,用java.lang.ref.PhantomReference类表示。
      如果一个对象与虚引用关联,则跟没有引用与之关联一样,在任何时候都可能被垃圾回收器回收
      虚引用主要用来跟踪对象被垃圾回收的活动。
      虚引用的创建方式示例:
	PhantomReference<String> prf = new PhantomReference<String>(new  
		String("str"),newReferenceQueue<>());

  虚引用的回收机制跟弱引用差不多,但是它被回收之前,会被放入ReferenceQueue中。  注意:其它引用是被JVM回收后才被传入ReferenceQueue中的。由于这个机制,所以虚引用大多被用于引用销毁前的处理工作。还有就是,虚引用创建的时候,必须带有ReferenceQueue。当某个被虚引用指向的对象被回收时,我们可以在其引用队列中得到这个虚引用的对象作为其所指向的对象被回收的一个通知。
  虚引用是管理堆外内存的
  虚引用的可用场景:对象销毁前的一些操作,比如说资源释放等。
  如果一个对象仅持有虚引用,那么它就和没有任何引用一样,在任何时候都可能被垃圾回收。虚引用主要用来跟踪对象被垃圾回收的活动。虚引用与软引用和弱引用的一个区别在于:虚引用必须和引用队列(ReferenceQueue)联合使用。当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象的内存之前,把这个虚引用加入到与之关联的引用队列中。程序可以通过判断引用队列中是否已经加入了虚引用,来了解被引用的对象是否将要被垃圾回收。程序如果发现某个虚引用已经被加入到引用队列,那么就可以在所引用的对象的内存被回收之前采取必要的行动。
  在Java集合中有一种特殊的Map类型:WeakHashMap, 在这种Map中存放了键对象的弱引用,当一个键对象被垃圾回收,那么相应的值对象的引用会从Map中删除。WeakHashMap能够节约存储空间,可用来缓存那些非必须存在的数据。
  WeakHashMap的工作与正常的HashMap类似,但是使用弱引用作为key,当key对象没有任何引用时,key/value将会被回收。

4.2 几种引用的对比*

引用类型被回收时间用途生存时间
强引用从来不会对象的一般状态JVM停止运行时
软引用内存不足时对象缓存内存不足时
弱引用jvm垃圾回收时对象缓存gc运行后
虚引用未知未知未知

  在实际开发中一般很少使用弱引用与虚引用,使用软引用的情况较多,这是因为软引用可以加速JVM对垃圾内存的回收速度,可以维护系统的运行安全,防止内存溢出(OOM)等问题的产生。

4.3 四种引用的对比测试

  四种引用测试示例:

public class ReferenceDemoTest {
	
	public static void main(String[] args) {
		testStrongReference();
		//testSoftReference();
		//testWeakReference();
		//testPhantomReference();
	}
	
	static final int NUM = 1024;
    // 引用队列,当某个引用所指向的对象被回收时这个引用本身会被添加到其对应的
    //引用队列中,其泛型为其中存放的引用要指向的对象类型
	static ReferenceQueue<Object> referenceQueue = new ReferenceQueue<>();

	// 强引用测试
	static void testStrongReference() {
		ArrayList<byte[]> strongReferences = new ArrayList<>();
		try {
			while (true) {
				strongReferences.add(new byte[NUM]);
			}
		} catch (OutOfMemoryError e) {
			e.printStackTrace();
		}
	}

	// 软引用测试
	static void testSoftReference() {
		ArrayList<SoftReference> softReferences = new ArrayList<>();
		try {
			while (true) {
				softReferences.add(new SoftReference<>(new byte[NUM], referenceQueue));
			}
		} catch (OutOfMemoryError e) {
			e.printStackTrace();
		}
	}

	// 弱引用测试
	static void testWeakReference() {
		ArrayList<WeakReference> weakReferences = new ArrayList<>();
		try {
			while (true) {
				weakReferences.add(new WeakReference<>(new byte[NUM], referenceQueue));
			}
		} catch (OutOfMemoryError e) {
			e.printStackTrace();
		}
	}

	// 虚引用测试
	static void testPhantomReference() {
		ArrayList<PhantomReference<byte[]>> phantomReferences = new ArrayList<>();
		try {
			while (true) {
				phantomReferences.add(new PhantomReference<>(new byte[NUM], referenceQueue));
			}
		} catch (OutOfMemoryError e) {
			e.printStackTrace();
		}
	}
}
  • 1、强引用测试结果:
  • 2、测试软引用
      运行内存也会一直上升,但不会出现OOM。因为抛出OOM之前,会将软引用指向的对象回收,测试结果示例:
  • 3、弱引用的测试结果与软引用类似:
  • 4、虚引用测试
      发生了OOM:

      此处之所以会发生OOM,虚引用确实是引用强度最弱的,但是还有一点是虚引用根本不会影响对象的生命周期,也就是说某个对象是否被JVM的垃圾回收动作回收和这个对象是否被虚引用所指向和被多少个虚引用所指向没有任何关系,既然其不会影响对象的生命周期,那么使用和不使用虚引用指向对象对这个对象是否被JVM回收是没有任何区别的,那么我们就可以将其看做没有使用虚引用时的代码,此时效果自然和直接使用强引用一样。
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值