跳至主要內容

⑩JAVA I/O

holic-x...大约 53 分钟JAVA基础

⑩JAVA I/O

学习核心

  • 序列化
    • 什么是序列化和反序列化?
    • JAVA如何实现序列化?
    • 常见的序列化协议有哪些?
  • I/O(文件I/O(掌握用法即可)、网络I/O)
    • BIO、NIO、AIO有什么区别?
    • 什么是NIO?
    • I/O多路复用是什么?
    • select、epoll有什么区别?

序列化和反序列化

基本概念

​ 序列化和反序列化本质上是【对象】和【二进制数据(二进制流)】的相互转化

  • 序列化(serialize):序列化是将对象转换为二进制数据
  • 反序列化(deserialize):反序列化是将二进制数据转换为对象

​ 序列化的作用:

  • 将对象的字节序列持久化(存储在内存、文件、数据库中)
  • 网络传输,提升效率
  • RMI(远程方法调用)

​ 微服务架构场景中,不同业务之间的通信需要通过接口实现调用。两个服务之间要共享一个数据对象,就需要从对象转换成二进制流,通过网络传输,传送到对方服务,再转换回对象,供服务方法调用。这个编码和解码过程称之为序列化与反序列化。

​ 在大量并发请求的情况下,如果序列化的速度慢,会导致请求响应时间增加;而序列化后的传输数据体积大,会导致网络吞吐量下降。所以一个优秀的序列化框架可以提高系统的整体性能

​ Java 提供了 RMI 框架可以实现服务与服务之间的接口暴露和调用,RMI 中对数据对象的序列化采用的是 Java 序列化。而目前主流的微服务框架却几乎没有用到 Java 序列化,SpringCloud 用的是 Json 序列化,Dubbo 虽然兼容了 Java 序列化,但默认使用的是 Hessian 序列化

1.核心概念

JDK序列化

Java 通过对象输入输出流来实现序列化和反序列化:

  • java.io.ObjectOutputStream 类的 writeObject() 方法可以实现序列化
  • java.io.ObjectInputStream 类的 readObject() 方法用于实现反序列化

构建核心:通过JDK提供的IO方法熟悉实现

  • 序列化:将JAVA对象转化为二进制数据,并固化到文本中
  • 反序列化:读取文本中的数据,将其转化为对应的JAVA对象
// 定义传输对象实现Serializable接口
class Person implements Serializable {
    // 性别枚举定义
    enum Sex{
        MALE,FEMALE
    }
    private static final long serialVersionUID = 1L;
    private String name = null;
    private Integer age = null;
    private Sex sex;

    public Person() { }

    public Person(String name, Integer age, Sex sex) {
        this.name = name;
        this.age = age;
        this.sex = sex;
    }

    @Override
    public String toString() {
        return "Person{" + "name='" + name + '\'' + ", age=" + age + ", sex=" + sex + '}';
    }
}

// 基于JDK实现序列化和反序列化(序列化解析器)
class JdkSerializer{
    // 序列化方法定义
    public static void serialize(String filename) throws IOException {
        File f = new File(filename); // 定义保存路径
        OutputStream out = new FileOutputStream(f); // 文件输出流
        ObjectOutputStream oos = new ObjectOutputStream(out); // 对象输出流
        oos.writeObject(new Person("Jack", 30, Person.Sex.MALE)); // 保存对象
        oos.close();
        out.close();
    }

    // 反序列化方法定义
    public static void deserialize(String filename) throws IOException, ClassNotFoundException {
        File f = new File(filename); // 定义保存路径
        InputStream in = new FileInputStream(f); // 文件输入流
        ObjectInputStream ois = new ObjectInputStream(in); // 对象输入流
        Object obj = ois.readObject(); // 读取对象
        ois.close();
        in.close();
        System.out.println(obj);
    }
}

/**
 * JDK序列化demo
 */
public class JdkSerializeDemo {

    public static void main(String[] args) throws Exception {
        // 测试基于JDK实现的序列化和反序列化机制
        final String filename = "/Users/holic-x/Desktop/temp/text.dat";// windows d:/text.dat
        JdkSerializer.serialize(filename);
        JdkSerializer.deserialize(filename);
    }

}

JDK序列化过程分析

image-20240522201038660

​ 序列化过程就是在读取对象数据的时候,不断加入一些特殊分隔符,这些特殊分隔符用于在反序列化过程中截断用,这就像是文章中的标点符号被用于断句一样。

  • 头部数据用来声明序列化协议、序列化版本,用于高低版本向后兼容
  • 对象数据主要包括类名、签名、属性名、属性类型及属性值,当然还有开头结尾等数据,除了属性值属于真正的对象值,其他都是为了反序列化用的元数据
  • 存在对象引用、继承的情况下,就是递归遍历“写对象”逻辑

注意:使用 Java 对象序列化,在保存对象时,会把其状态保存为一组字节,在未来,再将这些字节组装成对象。必须注意地是,对象序列化保存的是对象的”状态”,即它的成员变量。由此可知,对象序列化不会关注类中的静态变量

JDK序列化要点

Java 的序列化能保证对象状态的持久保存,但是遇到一些对象结构复杂的情况还是难以处理:

  • 父类是 Serializable,所有子类都可以被序列化
  • 子类是 Serializable ,父类不是,则子类可以正确序列化,但父类的属性不会被序列化(不报错,数据丢失)
  • 如果序列化的属性是对象,则这个对象也必须是 Serializable ,否则报错
  • 反序列化时,如果对象的属性有修改或删减,则修改的部分属性会丢失,但不会报错
  • 反序列化时,如果 serialVersionUID 被修改,则反序列化会失败

Serializable接口

​ 一个类要想被序列化就必须实现Serializable接口,被序列化的类必须属于**、ArraySerializable 类型其中的任何一种,否则将抛出 NotSerializableException 异常**。这是因为:在序列化操作过程中会对类型进行检查,如果不满足序列化类型要求,就会抛出异常。

【实例】一个普通类不实现Serializable接口,则其在序列化的过程中就会抛出异常

// 不满足序列化要求
public class Person{
	// .....
}

// 序列化异常
Exception in thread "main" java.io.NotSerializableException:
...
📌serialVersionUID字段解读

​ 要被序列化的类出了要实现Serializable接口,一般还会定义一个serialVersionUID字段,参考格式如下所示

serialVersionUID字段是Java为每个序列化类产生的版本标识,一般是用于控制序列化版本是否兼容,用于保证在反序列时,发送方发送的和接受方接收的是可兼容的对象。如果接收方接收的类的 serialVersionUID 与发送方发送的 serialVersionUID 不一致,会抛出 InvalidClassException

class Person implements Serializable {
  // serialVersionUID 字段必须是 static final long 类型
	private static final long serialVersionUID = 1L;
}

​ 结合实际案例场景分析,假设定义了一个类,设定其serialVersionUID(版本)为1L。系统启用一段时间后可能需要对这个类进行更改,例如给类新增/删除字段,这个时候要进行序列化的类和老版本不兼容,需要修改版本号(改为2L)去明确这两个版本不兼容(此处案例可以使用后面序列化方式中的【JDK序列化中的Person案例】进行测试

新增字段(email)

版本1:name、age、sex

版本2:name、age、sex、email

  • 版本1:将【版本1的Person】序列化为【版本1的X.dat】,此时序列化和反序列化是正常执行的
Person{name='Jack', age=30, sex=MALE}
  • 迭代升级到版本2,Person新增了字段email
  • 此时再用【版本2的Person】反序列化【版本1的X.dat】
Person{name='Jack', age=30, sex=MALE, email='null'}

​ 从上述结果可以看到,数据是正常反序列化的,但是由于【版本1的X.dat】并没有email属性,因此通过反序列化转化出来的结果中email是为null的,这种新增字段的转化并不会影响原有的逻辑,只不过对于老版本而言它的新增字段都没有值而已

​ 类似的,如果是升级版本之后删除了某个字段,则通过反序列化转化出来的结果将不会包含这个字段的内容,但是还是正常转化的,例如假设版本2去除了sex字段,则反序列化的结果是

Person{name='Jack', age=30}

​ 什么时候会出现类型转化异常?使用不同类型的对象去反序列化二进制数据。例如Person和User类除了类名其他定义完全一致,此时如果使用User去接收反序列化的对象(强制类型转化),就会抛出类型转化异常(因为这个对象和二进制数据的转化并不兼容)

Exception in thread "main" java.lang.ClassCastException: class com.noob.base.serialize.Person cannot be cast to class com.noob.base.serialize.User (com.noob.base.serialize.Person and com.noob.base.serialize.User are in unnamed module of loader 'app')

​ 基于上述问题,为了避免对象版本不同在后续数据传输转化的时候出现异常,如果是针对无法兼容的版本,则通过设定serialVersionUID去限定对象的版本,如果版本号不一致则不允许进行反序列化转化。

​ 例如基于版本1序列化生成【X.dat】,后续版本迭代升级新增了字段,此时Person为版本2,且明确指定了serialVersionUID为2L,此时再用这个类去反序列化原有版本的内容,就会出现异常 InvalidClassException 异常。

Exception in thread "main" java.io.InvalidClassException: com.noob.base.serialize.Person; local class incompatible: stream classdesc serialVersionUID = 1, local class serialVersionUID = 2
	at java.base/java.io.ObjectStreamClass.initNonProxy(ObjectStreamClass.java:597)
	at java.base/java.io.ObjectInputStream.readNonProxyDesc(ObjectInputStream.java:2051)
	at java.base/java.io.ObjectInputStream.readClassDesc(ObjectInputStream.java:1898)
	at java.base/java.io.ObjectInputStream.readOrdinaryObject(ObjectInputStream.java:2224)
	at java.base/java.io.ObjectInputStream.readObject0(ObjectInputStream.java:1733)
	at java.base/java.io.ObjectInputStream.readObject(ObjectInputStream.java:509)
	at java.base/java.io.ObjectInputStream.readObject(ObjectInputStream.java:467)
	at com.noob.base.serialize.JdkSerializer.deserialize(JdkSerializeDemo.java:48)
	at com.noob.base.serialize.JdkSerializeDemo.main(JdkSerializeDemo.java:64)

因此此处可以回答一个问题:为什么一定要定义serialVersionUID值?

​ 因为如果可序列化类没有显式声明 serialVersionUID,则序列化运行时将基于该类的各个方面计算该类的默认 serialVersionUID 值。尽管这样,还是建议在每一个序列化的类中显式指定 serialVersionUID 的值。因为不同的 jdk 编译很可能会生成不同的 serialVersionUID 默认值,从而导致在反序列化时抛出 InvalidClassExceptions 异常。

默认序列化机制

​ 如果仅仅只是让某个类实现 Serializable 接口,而没有其它任何处理的话,那么就会使用默认序列化机制。

​ 使用默认机制,在序列化对象时,不仅会序列化当前对象本身,还会对其父类的字段以及该对象引用的其它对象也进行序列化。同样地,这些其它对象引用的另外对象也将被序列化,以此类推。所以,如果一个对象包含的成员变量是容器类对象,而这些容器所含有的元素也是容器类对象,那么这个序列化的过程就会较复杂,开销也较大。

🔔 注意:这里的父类和引用对象既然要进行序列化,那么它们当然也要满足序列化要求:被序列化的类必须属于 Enum、Array 和 Serializable 类型其中的任何一种

transient关键字

​ 在现实应用中,有些时候不能使用默认序列化机制。比如,希望在序列化过程中忽略掉敏感数据,或者简化序列化过程。下面将介绍若干影响序列化的方法。

序列化时,默认序列化机制会忽略被声明为 transient 的字段,该字段的内容在序列化后无法访问。

​ 结合【JDK序列方式案例】中的 Person 的 sex 字段声明为 transient,如下所示:

class Person implements Serializable {
    // 性别枚举定义
    enum Sex{
        MALE,FEMALE
    }
    private static final long serialVersionUID = 1L;
    private String name = null;
    private Integer age = null;
    private transient Sex sex;
   ....... 其他定义 ......
}

// output
Person{name='Jack', age=30, sex=null}

Externaizable接口(todo)

​ 无论是使用 transient 关键字,还是使用 writeObject()readObject() 方法,其实都是基于 Serializable 接口的序列化。

​ JDK 中提供了另一个序列化接口--Externalizable可序列化类实现 Externalizable 接口之后,基于 Serializable 接口的默认序列化机制就会失效

readResolve() 方法

​ 当使用 Singleton 模式时,应该是期望某个类的实例应该是唯一的,但如果该类是可序列化的,那么情况可能会略有不同。此时对第 2 节使用的 Person 类进行修改,使其实现 Singleton 模式,如下所示

public class JdkSerializeSingleDemo {

    enum Sex {
        MALE, FEMALE
    }

    static class Person implements Serializable {
        private static final long serialVersionUID = 1L;
        private String name = null;
        transient private Integer age = null;
        private Sex sex;
        static final Person instatnce = new Person("Tom", 31, Sex.MALE);

        private Person() {
            System.out.println("call Person()");
        }

        private Person(String name, Integer age, Sex sex) {
            this.name = name;
            this.age = age;
            this.sex = sex;
        }

        public static Person getInstance() {
            return instatnce;
        }

        private void writeObject(ObjectOutputStream out) throws IOException {
            out.defaultWriteObject();
            out.writeInt(age);
        }

        private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
            in.defaultReadObject();
            age = in.readInt();
        }

        public String toString() {
            return "name: " + this.name + ", age: " + this.age + ", sex: " + this.sex;
        }
    }

    /**
     * 序列化
     */
    private static void serialize(String filename) throws IOException {
        File f = new File(filename); // 定义保存路径
        OutputStream out = new FileOutputStream(f); // 文件输出流
        ObjectOutputStream oos = new ObjectOutputStream(out); // 对象输出流
        oos.writeObject(new Person("Jack", 30, Sex.MALE)); // 保存对象
        oos.close();
        out.close();
    }

    /**
     * 反序列化
     */
    private static void deserialize(String filename) throws IOException, ClassNotFoundException {
        File f = new File(filename); // 定义保存路径
        InputStream in = new FileInputStream(f); // 文件输入流
        ObjectInputStream ois = new ObjectInputStream(in); // 对象输入流
        Object obj = ois.readObject(); // 读取对象
        ois.close();
        in.close();
        System.out.println(obj);
        System.out.println(obj == Person.getInstance());
    }

    public static void main(String[] args) throws IOException, ClassNotFoundException {
        final String filename = "/Users/holic-x/Desktop/temp/data.dat";
        serialize(filename);
        deserialize(filename);
    }
}

// Output:
name: Jack, age: null, sex: MALE
false

​ 从文件中获取的 Person 对象与 Person 类中的单例对象并不相等。为了能在单例类中仍然保持序列的特性,可以使用 readResolve() 方法。在该方法中直接返回 Person 的单例对象。我们在 SerializeDemo04 示例的基础上添加一个 readResolve 方法(序列化会通过反射调用无参构造器返回一个新对象,破坏单例模式。 解决方法是添加readResolve()方法,自定义返回对象策略)

public class JdkSerializeSingleDemo {
    static class Person implements Serializable {

        // 其他内容略
      
        // 添加此方法
        private Object readResolve() {
            return instatnce;
        }
      
        // 其他内容略
    }
  
        // 其他内容略
}
// Output:
name: Jack, age: null, sex: MALE
true

JDK序列化问题

  • 无法跨语言:JDK 序列化目前只适用基于 Java 语言实现的框架,其它语言大部分都没有使用 Java 的序列化框架,也没有实现 JDK 序列化这套协议。因此,如果是两个基于不同语言编写的应用程序相互通信,则无法实现两个应用服务之间传输对象的序列化与反序列化。
  • 容易被攻击:对象是通过在 ObjectInputStream 上调用 readObject() 方法进行反序列化的,它可以将类路径上几乎所有实现了 Serializable 接口的对象都实例化。这意味着,在反序列化字节流的过程中,该方法可以执行任意类型的代码,这是非常危险的。对于需要长时间进行反序列化的对象,不需要执行任何代码,也可以发起一次攻击。攻击者可以创建循环对象链,然后将序列化后的对象传输到程序中反序列化,这种情况会导致 hashCode 方法被调用次数呈次方爆发式增长, 从而引发栈溢出异常。例如下面这个案例就可以很好地说明。
  • 序列化后的流太大:JDK 序列化中使用了 ObjectOutputStream 来实现对象转二进制编码,编码后的数组很大,非常影响存储和传输效率。
  • 序列化性能太差:Java 的序列化耗时比较大。序列化的速度也是体现序列化性能的重要指标,如果序列化的速度慢,就会影响网络通信的效率,从而增加系统的响应时间。
  • 序列化编程限制
    • JDK 序列化一定要实现 Serializable 接口
    • JDK 序列化需要关注 serialVersionUID

2.二进制序列化

​ 虽然Java提供了默认的JDK序列化机制,但是由于其性能不高,且存在很多其他问题,所以业界有了很多其他优秀的二进制序列化库

Protobuf

Protobuf open in new window是 Google 公司内部的混合语言数据标准,是一种轻便、高效的结构化数据存储格式,可以用于结构化数据序列化,支持 Java、Python、C++、Go 等语言。Protobuf 使用的时候需要定义 IDL(Interface description language),然后使用不同语言的 IDL 编译器,生成序列化工具类。

优点:

  • 序列化后体积相比 JSON、Hessian 小很多
  • 序列化反序列化速度很快,不需要通过反射获取类型
  • 语言和平台无关(基于 IDL),IDL 能清晰地描述语义,所以足以帮助并保证应用程序之间的类型不会丢失,无需类似 XML 解析器
  • 消息格式升级和兼容性不错,可以做到后向兼容
  • 支持 Java, C++, Python 三种语言

缺点:

  • Protobuf 对于具有反射和动态能力的语言来说,用起来很费劲。

Thrift

Thriftopen in new window是 apache 开源项目,是一个点对点的 RPC 实现。

它具有以下特性:

  • 支持多种语言(目前支持 28 种语言,如:C++、go、Java、Php、Python、Ruby 等等)。
  • 使用了组建大型数据交换及存储工具,对于大型系统中的内部数据传输,相对于 Json 和 xml 在性能上和传输大小上都有明显的优势。
  • 支持三种比较典型的编码方式(通用二进制编码,压缩二进制编码,优化的可选字段压缩编解码)。

Hessian

Hessianopen in new window是动态类型、二进制、紧凑的,并且可跨语言移植的一种序列化框架。Hessian 协议要比 JDK、JSON 更加紧凑,性能上要比 JDK、JSON 序列化高效很多,而且生成的字节数也更小。

RPC 框架 Dubbo 就支持 Thrift 和 Hession。

它具有以下特性:

  • 支持多种语言。如:Java、Python、C++、C#、PHP、Ruby 等。
  • 相对其他二进制序列化库较慢。

Hessian 本身也有问题,官方版本对 Java 里面一些常见对象的类型不支持:

  • Linked 系列,LinkedHashMap、LinkedHashSet 等,但是可以通过扩展 CollectionDeserializer 类修复;
  • Locale 类,可以通过扩展 ContextSerializerFactory 类修复;
  • Byte/Short 反序列化的时候变成 Integer。

Kryo

Kryoopen in new window是用于 Java 的快速高效的二进制对象图序列化框架。Kryo 还可以执行自动的深拷贝和浅拷贝。 这是从对象到对象的直接复制,而不是从对象到字节的复制。

它具有以下特性:

  • 速度快,序列化体积小
  • 官方不支持 Java 以外的其他语言

FST

FSTopen in new window是一个 Java 实现二进制序列化库

它具有以下特性:

  • 近乎于 100% 兼容 JDK 序列化,且比 JDK 原序列化方式快 10 倍
  • 2.17 开始与 Android 兼容
  • (可选)2.29 开始支持将任何可序列化的对象图编码/解码为 JSON(包括共享引用)

3.JSON序列化

​ JSON 起源于 1999 年的 JS 语言规范 ECMA262 的一个子集open in new window(即 15.12 章节描述了格式与解析),后来 2003 年作为一个数据格式ECMA404open in new window(很囧的序号有不有?)发布。 2006 年,作为 rfc4627open in new window发布,这时规范增加到 18 页,去掉没用的部分,十页不到。

​ JSON 的应用很广泛,这里有超过 100 种语言下的 JSON 库:json.orgopen in new window

JSON标准

标准规范

  • 只有两种结构:对象内的键值对集合结构和数组,对象用 {} 表示、内部是 "key":"value",数组用 [] 表示,不同值用逗号分开
  • 基本数值有 7 个: false / null / true / object / array / number / string
  • 结构可以嵌套,进而可以用来表达复杂的数据
{
  "Image": {
    "Width": 800,
    "Height": 600,
    "Title": "View from 15th Floor",
    "Thumbnail": {
      "Url": "http://www.example.com/image/481989943",
      "Height": 125,
      "Width": "100"
    },
    "IDs": [116, 943, 234, 38793]
  }
}

JSON优缺点

JSON 优点

  • 基于纯文本,所以对于人类阅读是很友好的。
  • 规范简单,所以容易处理,开箱即用,特别是 JS 类的 ECMA 脚本里是内建支持的,可以直接作为对象使用。
  • 平台无关性,因为类型和结构都是平台无关的,而且好处理,容易实现不同语言的处理类库,可以作为多个不同异构系统之间的数据传输格式协议,特别是在 HTTP/REST 下的数据格式。

JSON 缺点

  • 性能一般,文本表示的数据一般来说比二进制大得多,在数据传输上和解析处理上都要更影响性能。
  • 缺乏 schema,跟同是文本数据格式的 XML 比,在类型的严格性和丰富性上要差很多。XML 可以借由 XSD 或 DTD 来定义复杂的格式,并由此来验证 XML 文档是否符合格式要求,甚至进一步的,可以基于 XSD 来生成具体语言的操作代码,例如 apache xmlbeans。并且这些工具组合到一起,形成一套庞大的生态,例如基于 XML 可以实现 SOAP 和 WSDL,一系列的 ws-*规范。但是也可以看到 JSON 在缺乏规范的情况下,实际上有更大一些的灵活性,特别是近年来 REST 的快速发展,已经有一些 schema 相关的发展(例如理解 JSON Schemaopen in new window使用 JSON Schemaopen in new window在线 schema 测试open in new window),也有类似于 WSDL 的WADLopen in new window出现

JSON库

Java 中比较流行的 JSON 库有:

从性能上来看,一般情况下:Fastjson > Jackson > Gso

JSON编码指南

  • 属性名和值都是用双引号,不要把注释写到对象里面,对象数据要简洁
  • 不要随意结构化分组对象,推荐是用扁平化方式,层次不要太复杂
  • 命名方式要有意义,比如单复数表示
  • 驼峰式命名,遵循 Bean 规范
  • 使用版本来控制变更冲突
  • 对于一些关键字,不要拿来做 key
  • 如果一个属性是可选的或者包含空值或 null 值,考虑从 JSON 中去掉该属性,除非它的存在有很强的语义原因
  • 序列化枚举类型时,使用 name 而不是 value
  • 日期要用标准格式处理
  • 设计好通用的分页参数
  • 设计好异常处理

JSON APIopen in new window与 Google JSON 风格指南有很多可以相互参照之处。

JSON APIopen in new window是数据交互规范,用以定义客户端如何获取与修改资源,以及服务器如何响应对应请求。

4.序列化技术选型

市面上有如此多的序列化技术,那么在应用时如何选择呢?

序列化技术选型,需要考量的维度,根据重要性从高到低,依次有:

  • 安全性:是否存在漏洞。如果存在漏洞,就有被攻击的可能性。
  • 兼容性:版本升级后的兼容性是否很好,是否支持更多的对象类型,是否是跨平台、跨语言的。服务调用的稳定性与可靠性,要比服务的性能更加重要。
  • 性能
    • 时间开销:序列化、反序列化的耗时性能自然越小越好。
    • 空间开销:序列化后的数据越小越好,这样网络传输效率就高。
  • 易用性:类库是否轻量化,API 是否简单易懂。

鉴于以上的考量,序列化技术的选型建议如下:

I/O

学习资料

JAVA IO模型open in new window

基于tomcat调优的NIO深入学习open in new window

案例分析:BIO->NIO->AIOopen in new window

1.I/O概念

I/O,就是计算机内存与外部设备之间拷贝数据的过程。由于 CPU 访问内存的速度远远高于外部设备,因此 CPU 是先把外部设备的数据读到内存里,然后再进行处理

UNIX I/O模型

UNIX I/O模型(5种)

  • 同步阻塞 I/O

  • 同步非阻塞 I/O

  • I/O 多路复用

  • 信号驱动 I/O

  • 异步 I/O

如何去理解 UNIX I/O 模型,大致有以下两个维度:

  • 区分同步或异步(synchronous/asynchronous)。简单来说,同步是一种可靠的有序运行机制,当进行同步操作时,后续的任务是等待当前调用返回,才会进行下一步;而异步则相反,其他任务不需要等待当前调用返回,通常依靠事件、回调等机制来实现任务间次序关系。
  • 区分阻塞与非阻塞(blocking/non-blocking)。在进行阻塞操作时,当前线程会处于阻塞状态,无法从事其他任务,只有当条件就绪才能继续,比如 ServerSocket 新连接建立完毕,或数据读取、写入操作完成;而非阻塞则是不管 IO 操作是否结束,直接返回,相应操作在后台继续处理。

不能一概而论认为同步或阻塞就是低效,具体还要看应用和系统特征。对于一个网络 I/O 通信过程,比如网络数据读取,会涉及两个对象,一个是调用这个 I/O 操作的用户线程,另外一个就是操作系统内核。一个进程的地址空间分为用户空间和内核空间,用户线程不能直接访问内核空间。

当用户线程发起 I/O 操作后,网络数据读取操作会经历两个步骤:

  • 用户线程等待内核将数据从网卡拷贝到内核空间
  • 内核将数据从内核空间拷贝到用户空间

各种 I/O 模型的区别就是:它们实现这两个步骤的方式是不一样的

1)同步阻塞I/O

​ 用户线程发起 read 调用后就阻塞了,让出 CPU。内核等待网卡数据到来,把数据从网卡拷贝到内核空间,接着把数据拷贝到用户空间,再把用户线程叫醒

img

2)同步非阻塞I/O

​ 用户线程不断的发起 read 调用,数据没到内核空间时,每次都返回失败,直到数据到了内核空间,这一次 read 调用后,在等待数据从内核空间拷贝到用户空间这段时间里,线程还是阻塞的,等数据到了用户空间再把线程叫醒

img

3)I/O多路复用

​ 用户线程的读取操作分成两步:seelct、read

  • 线程先发起select调用,和内核确认数据是否准备好?
  • 等内部将数据准备好,再发起read调用

​ 在等待数据从内核空间拷贝到用户空间这段时间里,线程还是阻塞的。那为什么叫 I/O 多路复用呢?因为一次 select 调用可以向内核查多个数据通道(Channel)的状态,所以叫多路复用

img

4)信号驱动I/O

​ 首先开启 Socket 的信号驱动 I/O 功能,并安装一个信号处理函数,进程继续运行并不阻塞。当数据准备好时,进程会收到一个 SIGIO 信号,可以在信号处理函数中调用 I/O 操作函数处理数据。信号驱动式 I/O 模型的优点是我们在数据报到达期间进程不会被阻塞,只要等待信号处理函数的通知即可

5)异步I/O

​ 用户线程发起 read 调用的同时注册一个回调函数,read 立即返回,等内核将数据准备好后,再调用指定的回调函数完成处理。在这个过程中,用户线程一直没有阻塞

img

📌JAVA I/O模型

JAVA I/O模型

  • BIO(java.io):blocking IO,阻塞IO
  • NIO(java.nio):non-blocking IO,非阻塞IO
  • AIO(NIO2):Asynchronous IO,异步非阻塞IO(对NIO有进一步的改进,也称为NIO2)

image-20240523153129487

BIO(blocking IO:阻塞IO)

​ BIO(blocking IO) 即阻塞 IO,指的主要是传统的 java.io 包,它基于流模型实现。

java.io 包提供了常见的一些 IO 功能,比如 File 抽象、输入输出流等。交互方式是同步、阻塞的方式,也就是说,在读取输入流或者写入输出流时,在读、写动作完成之前,线程会一直阻塞在那里,它们之间的调用是可靠的线性顺序

​ 很多时候,人们也把 java.net 下面提供的部分网络 API,比如 SocketServerSocketHttpURLConnection 也归类到同步阻塞 IO 类库,因为网络通信同样是 IO 行为。

优点

​ 代码比较简单、直观;缺点则是 IO 效率和扩展性存在局限性,容易成为应用性能的瓶颈

缺点(性能缺陷)

BIO会阻塞进程,不适合高并发场景

​ 采用 BIO 的服务端,通常由一个独立的 Acceptor 线程负责监听客户端连接。服务端一般在while(true) 循环中调用 accept() 方法等待客户端的连接请求,一旦接收到一个连接请求,就可以建立 Socket,并基于这个 Socket 进行读写操作。此时,不能再接收其他客户端连接请求,只能等待当前连接的操作执行完成。

​ 如果要让 BIO 通信模型 能够同时处理多个客户端请求,就必须使用多线程(主要原因是socket.accept()socket.read()socket.write() 涉及的三个主要函数都是同步阻塞的),但会造成不必要的线程开销。不过可以通过 线程池机制 改善,线程池还可以让线程的创建和回收成本相对较低。

即使可以用线程池略微优化,但是会消耗宝贵的线程资源,并且在百万级并发场景下也撑不住。如果并发访问量增加会导致线程数急剧膨胀可能会导致线程堆栈溢出、创建新线程失败等问题,最终导致进程宕机或者僵死,不能对外提供服务

NIO(non-blocking IO:非阻塞IO)

​ NIO(non-blocking IO) 即非阻塞 IO。指的是 Java 1.4 中引入的 java.nio

​ 为了解决 BIO 的性能问题, Java 1.4 中引入的 java.nio 包。NIO 优化了内存复制以及阻塞导致的严重性能问题

java.nio 包提供了 ChannelSelectorBuffer 等新的抽象,可以构建多路复用的、同步非阻塞 IO 程序,同时提供了更接近操作系统底层的高性能数据操作方式

NIO性能优化

  • 使用缓冲区优化读写流 :NIO 与传统 I/O 不同,它是基于块(Block)的,它以块为基本单位处理数据。在 NIO 中,最为重要的两个组件是缓冲区(Buffer)和通道(Channel

    • Buffer 是一块连续的内存块,是 NIO 读写数据的缓冲。它 可以将文件一次性读入内存再做后续处理,而传统的方式是边读文件边处理数据
    • Channel 表示缓冲数据的源头或者目的地,它用于读取缓冲或者写入数据,是访问缓冲的接口
  • 使用DirectBuffer减少内存复制 :NIO 还提供了一个可以直接访问物理内存的类 DirectBuffer。普通的 Buffer 分配的是 JVM 堆内存,而 DirectBuffer 是直接分配物理内存。数据要输出到外部设备,必须先从用户空间复制到内核空间,再复制到输出设备,而 DirectBuffer 则是直接将步骤简化为从内核空间复制到外部设备,减少了数据拷贝。(由于 DirectBuffer 申请的是非 JVM 的物理内存,所以创建和销毁的代价很高。DirectBuffer 申请的内存并不是直接由 JVM 负责垃圾回收,但在 DirectBuffer 包装类被回收时,会通过 Java 引用机制来释放该内存块)

  • 优化I/O,避免阻塞 :传统 I/O 的数据读写是在用户空间和内核空间来回复制,而内核空间的数据是通过操作系统层面的 I/O 接口从磁盘读取或写入。NIO 的 Channel 有自己的处理器,可以完成内核空间和磁盘之间的 I/O 操作。

    • 在 NIO 中,读取和写入数据都要通过 Channel,由于 Channel 是双向的,所以读、写可以同时进行
AIO

​ AIO(Asynchronous IO) 即异步非阻塞 IO,指的是 Java 7 中,对 NIO 有了进一步的改进,也称为 NIO2,引入了异步非阻塞 IO 方式。

​ 异步 IO 操作基于事件和回调机制,可以简单理解为,应用操作直接返回,而不会阻塞在那里,当后台处理完成,操作系统会通知相应线程进行后续工作

问题解析

(1)Java提供了哪些IO方式? NIO如何实现多路复用?
  • BIO:同步阻塞(虽然简单直观,但是缺点在于IO效率和扩展存在局限性,容易成为应用性能瓶颈)
    • java.io包:基于传统流模型实现,提供基础的IO功能。采用同步、阻塞的交互方式,读写调用是可靠的线性顺序
    • java.net:一些场景下也将该包中的部分网络API(例如Socket、ServerSocket、HttpURLConnection)归类到同步阻塞IO(网络通信也是IO行为)
  • NIO:java 1.4 中引入NIO框架,构建多路复用、同步非阻塞的IO程序
    • java.nio包:提供Channel、Selector、Buffer等新的抽象,可以构建多路复用的、同步非阻塞IO程序,同时提供了更接近操作系统底层的高性能数据操作方式
  • NIO2(AIO):java 1.7 中对NIO有了进一步改进
    • 异步IO操作基于事件和回调机制:应用操作直接返回,而不会阻塞在那里,当后台处理完成,操作系统会通知相应线程进行后续工作

概念扩展

  • IO不仅是针对文件操作,在网络编程中(例如Socket通信)都是典型的IO操作目标
  • 区分字节流和字符流不同的应用场景:字节流一般用于操作图片、视频文件等;字符流一般用于从操作字符(字符编码解码),从文件中读写信息
  • BufferXXX等带缓冲区的实现,可以避免频繁的磁盘读写,提高IO处理效率(利用缓冲区批量操作数据,使用过程中结合flush)
  • IO工具类实现Closeable接口进行资源的释放(可以参考异常处理机制资源释放)
    • 打开FileInputStream,它就会获取相应的文件描述符(FileDescriptor),需要利用try-with-resources、 try-finally等机制保证FileInputStream被明确关闭,进而相应文件描述符也会失效,否则将导致资源无法被释放。利用专栏前面的内容提到的Cleaner或finalize机制作为资源释放的最后把关,也是必要的
(2)传统IO的性能问题

​ I/O操作分为磁盘 I/O 操作和网络 I/O 操作,但不管是磁盘 I/O 还是网络 I/O,在传统 I/O 中都存在严重的性能问题。

  • 磁盘 I/O 操作:从磁盘中读取数据源输入到内存中,之后将读取的信息持久化输出在物理磁盘上
  • 网络 I/O 操作:从网络中读取信息输入到内存,最终将信息输出到网络中

传统IO的性能问题

🐶【1】多次内存复制

​ 在传统 I/O 中,我们可以通过 InputStream 从源数据中读取数据流输入到缓冲区里,通过 OutputStream 将数据输出到外部设备(包括磁盘、网络)。其流程分析如下

image-20240523143758920

  • JVM 会发出 read() 系统调用,并通过 read 系统调用向内核发起读请求;
  • 内核向硬件发送读指令,并等待读就绪;
  • 内核把将要读取的数据复制到指向的内核缓存中;
  • 操作系统内核将数据复制到用户空间缓冲区,然后 read 系统调用返回

​ 在这个过程中,数据先从外部设备复制到内核空间,再从内核空间复制到用户空间,这就发生了两次内存复制操作。这种操作会导致不必要的数据拷贝和上下文切换,从而降低 I/O 的性能

🐶【2】阻塞

​ 在传统 I/O 中,InputStream 的 read() 是一个 while 循环操作,它会一直等待数据读取,直到数据就绪才会返回。这就意味着如果没有数据就绪,这个读取操作将会一直被挂起,用户线程将会处于阻塞状态。

​ 在少量连接请求的情况下,使用这种方式没有问题,响应速度也很高。但在发生大量连接请求时,就需要创建大量监听线程,这时如果线程没有数据就绪就会被挂起,然后进入阻塞状态。一旦发生线程阻塞,这些线程将会不断地抢夺 CPU 资源,从而导致大量的 CPU 上下文切换,增加系统的性能开销

如何优化IO操作

​ 针对上述性能问题,不仅编程语言对此做了优化,各个操作系统也进一步优化了 I/O。JDK1.4 发布了 java.nio 包(new I/O 的缩写),NIO 的发布优化了内存复制以及阻塞导致的严重性能问题。JDK1.7 又发布了 NIO2,提出了从操作系统层面实现的异步 I/O。

👻【1】使用缓冲区优化读写流操作

在传统 I/O 中,提供了基于流的 I/O 实现,即 InputStream 和 OutputStream,这种基于流的实现以字节为单位处理数据。

NIO 与传统 I/O 不同,它是**基于块(Block)**的,它以块为基本单位处理数据。在 NIO 中,最为重要的两个组件是缓冲区(Buffer)和通道(Channel)

  • Buffer 是一块连续的内存块,是 NIO 读写数据的中转地
  • Channel 表示缓冲数据的源头或者目的地,它用于读取缓冲或者写入数据,是访问缓冲的接口。

传统 I/O 和 NIO 的最大区别就是传统 I/O 是面向流,NIO 是面向 Buffer

  • Buffer 可以将文件一次性读入内存再做后续处理,而传统的方式是边读文件边处理数据
  • 虽然传统 I/O 后面也使用了缓冲块(BufferedInputStream),但仍然不能和 NIO 相媲美。使用 NIO 替代传统 I/O 操作,可以提升系统的整体性能,效果立竿见影

👻【2】使用DirectBuffer减少内存复制

​ NIO 的 Buffer 除了做了缓冲块优化之外,还提供了一个可以直接访问物理内存的类 DirectBuffer。普通的 Buffer 分配的是 JVM 堆内存,而 DirectBuffer 是直接分配物理内存 (非堆内存)。

​ 数据要输出到外部设备,必须先从用户空间复制到内核空间,再复制到输出设备,而在 Java 中,在用户空间中又存在一个拷贝,那就是从 Java 堆内存中拷贝到临时的直接内存中,通过临时的直接内存拷贝到内存空间中去。此时的直接内存和堆内存都是属于用户空间

image-20240523144932564

​ 此处思考一个问题:为什么 Java 需要通过一个临时的非堆内存来复制数据呢?

​ 如果单纯使用 Java 堆内存进行数据拷贝,当拷贝的数据量比较大的情况下,Java 堆的 GC 压力会比较大,而使用非堆内存可以减低 GC 的压力。而DirectBuffer 则是直接将步骤简化为数据直接保存到非堆内存,从而减少了一次数据拷贝(可参考源码实现)

if (src instanceof DirectBuffer)
 return writeFromNativeBuffer(fd, src, position, nd);
 // Substitute a native buffer
 int pos = src.position();
 int lim = src.limit();
 assert (pos <= lim);
 int rem = (pos <= lim ? lim - pos : 0); 
 ByteBuffer bb = Util.getTemporaryDirectBuffer(rem);
 try {
 bb.put(src);
 bb.flip();
 // ...............

​ 扩展说明:由于 DirectBuffer 申请的是非 JVM 的物理内存,所以创建和销毁的代价很高。DirectBuffer 申请的内存并不是直接由 JVM 负责垃圾回收,但在 DirectBuffer 包装类被回收时,会通过 Java Reference 机制来释放该内存块

​ 基于此,DirectBuffer 只优化了用户空间内部的拷贝,那 Java 的 NIO 中是否能做到减少用户空间和内核空间的拷贝优化呢?

​ DirectBuffer 是通过 unsafe.allocateMemory(size) 方法分配内存,也就是基于本地类 Unsafe 类调用 native 方法进行内存分配的。而在 NIO 中,还存在另外一个 Buffer 类:MappedByteBuffer,跟 DirectBuffer 不同的是,MappedByteBuffer 是通过本地类调用 mmap 进行文件内存映射的,map() 系统调用方法会直接将文件从硬盘拷贝到用户空间,只进行一次数据拷贝,从而减少了传统的 read() 方法从硬盘拷贝到内核空间这一步

👻【3】避免阻塞,优化I/O操作

​ 传统的 I/O 即使使用了缓冲块,依然存在阻塞问题。由于线程池线程数量有限,一旦发生大量并发请求,超过最大数量的线程就只能等待,直到线程池中有空闲的线程可以被复用。而对 Socket 的输入流进行读取时,读取流会一直阻塞,直到发生以下三种情况的任意一种才会解除阻塞:有数据可读;连接释放;空指针或 I/O 异常。

​ 阻塞问题是传统 I/O 最大的弊端,NIO 的非阻塞则依赖于通道和多路复用器这两个基本组件实现

  • 通道(Channel)

​ 传统 I/O 的数据读取和写入是从用户空间到内核空间来回复制,而内核空间的数据是通过操作系统层面的 I/O 接口从磁盘读取或写入

​ 最开始,在应用程序调用操作系统 I/O 接口时,是由 CPU 完成分配,这种方式最大的问题是“发生大量 I/O 请求时,非常消耗 CPU“;之后,操作系统引入了 DMA(直接存储器存储),内核空间与磁盘之间的存取完全由 DMA 负责,但这种方式依然需要向 CPU 申请权限,且需要借助 DMA 总线来完成数据的复制操作,如果 DMA 总线过多,就会造成总线冲突。

​ 通道的出现解决了以上问题,Channel 有自己的处理器,可以完成内核空间和磁盘之间的 I/O 操作。

​ 在 NIO 中,读取和写入数据都要通过 Channel,由于 Channel 是双向的,所以读、写可以同时进行

  • 多路复用器(Selector)

​ Selector 是 Java NIO 编程的基础。用于检查一个或多个 NIO Channel 的状态是否处于可读、可写。

​ Selector 是基于事件驱动实现的,可以在 Selector 中注册 accpet、read 监听事件,Selector 会不断轮询注册在其上的 Channel,如果某个 Channel 上面发生监听事件,这个 Channel 就处于就绪状态,然后进行 I/O 操作。

​ 一个线程使用一个 Selector,通过轮询的方式,可以监听多个 Channel 上的事件。可以在注册 Channel 时设置该通道为非阻塞,当 Channel 上没有 I/O 操作时,该线程就不会一直等待了,而是会不断轮询所有 Channel,从而避免发生阻塞。

​ 目前操作系统的 I/O 多路复用机制都使用了 epoll,相比传统的 select 机制,epoll 没有最大连接句柄 1024 的限制。所以 Selector 在理论上可以轮询成千上万的客户端

​ 场景案例分析:通过生活化场景【火车站检票】理解Channel和Selector在非阻塞I/O中承担的角色和发挥的作用

​ 将监听多个 I/O 连接请求比作一个火车站的进站口。

  • 最早没有实现线程池的 I/O 操作:检票只能让搭乘就近一趟发车的旅客提前进站,而且只有一个检票员,这时如果有其他车次的旅客要进站,就只能在站口排队等待

  • 多线程监听I/O操作:火车站升级了,多了几个检票入口,允许不同车次的旅客从各自对应的检票入口进站。相当于用多线程创建了多个监听线程,同时监听各个客户端的 I/O 请求。

  • NIO优化:火车站再一次进行了升级改造,可以容纳更多旅客了,每个车次载客更多了,而且车次也安排合理,乘客不再扎堆排队,可以从一个大的统一的检票口进站了,该检票口可以同时检票多个车次。这个大的检票口就相当于 Selector,车次就相当于 Channel,旅客就相当于 I/O 流。

(3)BIO、NIO 演变

​ 传统I/O基于操作流实现(InputStream、OutputStream),这种流操作在高并发、大数据场景中容易导致阻塞,性能会非常的差。且在输出数据会从用户空间复制到内核空间再到输出设备,多次内存复制会造成额外的性能开销。

​ 阻塞问题是传统I/O最大的弊端,为了解决这个问题,传统I/O引入了Buffer优化阻塞性能问题(以缓冲块作为最小单位),但是整体性能依然不尽如人意

​ NIO的发布(基于缓冲块为单位的额流操作),在Buffer的基础上引入解决阻塞的核组件:心通道(Channel)和多路复用(Selector),实现了非阻塞I/O,适用于发生大量I/O连接请求的场景,这三个组件(Buffer、Channel、Selector)共同提升了I/O的整体性能;且针对多次内存复制问题也通过DirectBuffer、MappedByteBuffer 优化进而减少内存复制次数

(4)IO多路复用模型:select/poll/epoll

​ 基于网络IO理解:“多路”是指多个网络连接,“复用”是指一个线程。可以理解为一个线程去处理多个网络连接。这种方式可以减少服务端线程的上下文切换,支持大并发的请求。多个网络连接到服务器,可以看做为文件描述符fd(Linux系统一切皆文件)。用户态收集到所有的fd后,传递给内核态由内核去监听是否有数据到来。若有数据到来,再由用户态去处理数据。

​ select/poll/epoll 都是linux下的多路复用机制

2.传统IO流

流从概念上来说是一个连续的数据流。当程序需要读数据的时候就需要使用输入流读取数据,当需要往外写数据的时候就需要输出流。

BIO 中操作的流主要有两大类,字节流和字符流,两类根据流的方向都可以分为输入流和输出流。

  • 字节流
    • 输入字节流:InputStream
    • 输出字节流:OutputStream
  • 字符流
    • 输入字符流:Reader
    • 输出字符流:Writer

JAVA BIO流

SOURCE => InputStream/Reader => Java App
SOURCE <= OutputStream/Write <= Java App

字节流

img

(1)文件字节流(FileXXXStream)

FileOutputStreamFileInputStream 提供了读写字节到文件的能力

文件流操作通用步骤:

  • 使用 File 类绑定一个文件

  • File 对象绑定到流对象上(用子类对象实例化父类对象)

  • 进行读或写操作(此处的读写是针对以程序为参考)

  • 关闭流

/**
 * 字节流:文件字节流
 */
public class FileStreamDemo {

    // 定义文件存储路径(用于校验文件内容输出输出)
    private final static String FILEPATH = "testRoot/io/fileStream.log";

    // 写入内容到文件
    public static void writeFile(String filePath, String content) throws Exception {
        // 1.使用File绑定一个文件
        File file = new File(filePath);

        // 2.将File对象绑定到流对象(实例化默认为覆盖原文件内容方式,如果添加true参数则变为对源文件追加内容的方式)
        OutputStream outputStream = new FileOutputStream(file);// 追加方式:new FileOutputStream(file,true);

        // 3.进行读/写操作
        byte[] bytes = content.getBytes();
        outputStream.write(bytes);

        // 4.关闭资源(关闭流)
        outputStream.close();
    }

    // 从文件中读取内容
    public static String readFile(String filePath) throws Exception {
        // 1.使用File绑定一个文件
        File file = new File(filePath);

        // 2.将File对象绑定到流对象(子类对象实例化父类对象)
        InputStream inputStream = new FileInputStream(file);

        // 3.进行读/写操作
        byte[] bytes = new byte[(int) file.length()];
        int length = inputStream.read(bytes);
        System.out.println("读入的数据长度:" + length);

        // 4.关闭资源(关闭流)
        inputStream.close();

        // 返回读取的数据内容
        return new String(bytes);
    }

    public static void main(String[] args) throws Exception {
        // 写入文件内容
        writeFile(FILEPATH,"hello fileStream");

        // 读取文件内容
        String content = readFile(FILEPATH);
        System.out.println(content);
    }

}

// output
读入的数据长度:16
hello fileStream

​ 此处的输入输出不要死记硬背,以程序为参考,如果要写入内容到文件中,则对于程序而言是输出内容到文件,所以用的是OutputStream;如果要从文件中读取内容,则对于程序而言是从文件中接收信息,输入内容到程序控制台,所以用的是InputStream

​ 此处的文件存储路径:FILEPATH = "testRoot/io/fileStream.log"; 对应的是当前工程目录下的testRoot/io/fileStream.log(如果文件夹不存在则要手动创建文件夹目录)

(2)内存字节流(ByteArrayXXXStream)

ByteArrayInputStreamByteArrayOutputStream 是用来完成内存的输入和输出功能

内存操作流一般在生成一些临时信息时才使用。 如果临时信息保存在文件中,还需要在有效期过后删除文件,这样比较麻烦

/**
 * 内存字节流(一般用于操作一些临时信息)
 */
public class ByteArrayStreamDemo {

    public static void main(String[] args) throws IOException {
        // 定义字符串(全部大写)
        String str = "HELLO WORLD";

        // 定义内存字节流对象(输入输出),用子类实例化父类对象
        ByteArrayInputStream bis = new ByteArrayInputStream(str.getBytes());
        ByteArrayOutputStream bos = new ByteArrayOutputStream();

        // 从bis(字符输入流)中读取内容
        int temp = 0;
        while ((temp=bis.read())!=-1){
            // 对读取的内容进行处理(例如转为小写),并将内容存入字节流
            char ch = (char)temp;
            bos.write(Character.toLowerCase(ch));
        }

        // 去除bos中的内容
        System.out.println(bos.toString());

        // 关闭流
        bis.close();
        bos.close();
    }

}

// output
hello world

(3)管道流(PieXXXStream)

​ 管道流的主要作用是可以进行两个线程间的通信

​ 如果要进行管道通信,则必须把 PipedOutputStream 连接在 PipedInputStream 上。为此,PipedOutputStream 中提供了 connect() 方法

/**
 * 分别定义两个线程用于管道流通信测试
 * 此处线程的创建通过实现Runnable接口方式进行构建
 */

// 发送方
 class Send implements Runnable {

     // 发送方:定义输出流
    private PipedOutputStream pos = null;

    // 实例化输出流
    Send(){
        pos = new PipedOutputStream();
    }

    // 得到此线程的管道输出流
    public PipedOutputStream getPos() {
        return pos;
    }

    @Override
    public void run() {
        String msg = "hello world";

        // 此处异常处理并不规范,仅仅为了简化代码,体现逻辑代码实现
        try {
            // 输出数据
            pos.write(msg.getBytes());
            // 关闭输出流
            pos.close();
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }
}

// 接受方
class Receive implements Runnable {

     // 接收方:定义输入流
    private PipedInputStream pis = null;

    // 初始化输入流
    Receive(){
        pis = new PipedInputStream();
    }

    // 提供获取此线程的输入流的方法
    public PipedInputStream getPis() {
        return pis;
    }

    @Override
    public void run() {

        // 定义每次读取的字节数
        byte[] buf = new byte[1024];
        int len = 0;
        try {
            // 此处异常处理并不规范,仅仅为了简化代码,体现逻辑代码实现
            len = pis.read(buf);

            // 读取完毕,关闭输入流
            pis.close();
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
        // 取出读取的内容
        System.out.println("接收的内容:" + new String(buf, 0, len));
    }
}

/**
 * 管道流(可以进行两个线程直接的通信)
 */
public class PieStreamDemo {

    public static void main(String[] args) throws IOException {
        // 分别创建两个线程对象
        Send send = new Send();
        Receive receive = new Receive();

        // 使用connect方法连通管道
        send.getPos().connect(receive.getPis());

        // 分别启动线程(先启动),通过new Thead创建线程并调用start方法启动
        new Thread(send).start();
        new Thread(receive).start();
    }
}

// output
接收的内容:hello world

(4)对象字节流(ObjectXXXStream)

ObjectInputStream 和 ObjectOutputStream 是对象输入输出流,一般用于对象序列化

对象字节流操作通用步骤:

  • 使用 File 类绑定一个文件

  • File 对象绑定到流对象上(用子类对象实例化父类对象:ObjectXXXStream)

  • 进行读或写操作(此处的读写是针对以程序为参考)

  • 关闭流

// 定义对象(实现序列化接口并设定版本)
class Person implements Serializable {

    private static final long serialVersionUID = 1L;

    private String name;
    Person(String name){
        this.name = name;
    }

    @Override
    public String toString() {
        return "Person{" +
                "name='" + name + '\'' +
                '}';
    }
}


/**
 * 对象字节流(一般用于序列化场景,例如基于JDK提供的字节流实现对象和二进制数据的序列化)
 */
public class ObjectStreamDemo {

    private final static String FILEPATH = "testRoot/io/personObj.dat";

    // 序列化:将对象=》二进制数据(写入文件)
    public static void writeObject(String filePath, Person person) throws IOException {
        // 1.绑定文件
         File file = new File(filePath);
         // 2.将文件绑定到输出流
         ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream(file));
         // 3.写入文件内容(将对象信息写入文件)
        oos.writeObject(person);
        // 4.写入完毕,关闭流
        oos.close();
    }

    // 反序列化:将二进制数据=》对象
    public static Person readObject(String filePath) throws Exception {
        // 1.绑定文件
        File file = new File(filePath);
        // 2.将文件绑定到输入流
        ObjectInputStream ois = new ObjectInputStream(new FileInputStream(file));
        // 3.读取文件内容
        Object obj = ois.readObject();
        // 4.读取完毕,关闭流
        ois.close();
        // 返回读取的对象信息
         return (Person) obj;
    }

    public static void main(String[] args) throws Exception {
        // 测试序列化和反序列化
         Person person = new Person("noob");
         // 写入对象到文件
        writeObject(FILEPATH,person);
        // 从文件读取对象信息
        Person readObj = readObject(FILEPATH);
        System.out.println(readObj);
    }

}

// output
Person{name='noob'}

(5)数据操作流(DataXXXStream)

​ 数据操作流提供了格式化读入和输出数据的方法,分别为 DataInputStreamDataOutputStream

DataInputStreamDataOutputStream 格式化读写数据示例

数据字节流操作通用步骤:

  • 使用 File 类绑定一个文件

  • File 对象绑定到流对象上(用子类对象实例化父类对象:DataXXXStream)

  • 进行读或写操作(此处的读写是针对以程序为参考)

  • 关闭流

/**
 * 数据操作流(格式化读入和输出数据)
 */
public class DataStreamDemo {

    // 定义操作文件
    private final static String FILEPATH = "testRoot/io/dataStream.log";

    // 定义写方法
    private static void write(String filePath) throws IOException {
        // 1.绑定一个文件
        File file = new File(filePath);

        // 2.将文件绑定到流对象
        DataOutputStream dos = new DataOutputStream(new FileOutputStream(file));

        // 3.写入操作
        String[] names = {"衬衣", "手套", "围巾"};
        float[] prices = {98.3f, 30.3f, 50.5f};
        int[] nums = {3, 2, 1};
        for (int i = 0; i < names.length; i++) {
            dos.writeChars(names[i]);
            dos.writeChar('\t');
            dos.writeFloat(prices[i]);
            dos.writeChar('\t');
            dos.writeInt(nums[i]);
            dos.writeChar('\n');
        }

        // 4.写入完成,关闭流
        dos.close();
    }


    // 定义读方法
    public static void read(String filePath) throws IOException {
        // 1.绑定文件
        File file = new File(filePath);

        // 2.将文件绑定到流对象
        DataInputStream dis = new DataInputStream(new FileInputStream(file));

        // 3.从文件中读取内容
        String name = null; // 接收名称
        float price = 0.0f; // 接收价格
        int num = 0; // 接收数量
        char[] temp = null; // 接收商品名称
        int len = 0; // 保存读取数据的个数
        char c = 0; // '\u0000'
        try{
            while (true) {
                temp = new char[200]; // 开辟空间
                len = 0;
                while ((c = dis.readChar()) != '\t') { // 接收内容
                    temp[len] = c;
                    len++; // 读取长度加1
                }
                name = new String(temp, 0, len); // 将字符数组变为String
                price = dis.readFloat(); // 读取价格
                dis.readChar(); // 读取\t
                num = dis.readInt(); // 读取int
                dis.readChar(); // 读取\n
                System.out.printf("名称:%s;价格:%5.2f;数量:%d\n", name, price, num);
            }
        }catch (EOFException e){
            System.out.println("结束");
        }
        // 4.关闭流
        dis.close();
    }

    public static void main(String[] args) throws IOException {
        // 测试数据流
        write(FILEPATH);
        read(FILEPATH);
    }
}

// output
名称:衬衣;价格:98.30;数量:3
名称:手套;价格:30.30;数量:2
名称:围巾;价格:50.50;数量:1
结束

(6)合并流

​ 合并流的主要功能是将多个 InputStream 合并为一个 InputStream 流。合并流的功能由 SequenceInputStream 完成

​ 常见的场景:切割、合并(视频等大文件处理)

/**
 * 合并流
 */
public class SequenceStreamDemo {

    // 定义合并对象文件路径
    private final static  String FILEPATH1 = "testRoot/io/seq1.log";
    private final static  String FILEPATH2 = "testRoot/io/seq2.log";

    private final static  String OUTPUT_FILEPATH = "testRoot/io/merge.log";

    // 合并多个文件内容并输出
    public static void merge() throws Exception {
        // 1.定义文件输入流读取要合并的文件信息
        InputStream is1 = new FileInputStream(FILEPATH1);
        InputStream is2 = new FileInputStream(FILEPATH2);
        // 2.定义合并流(将多个文件合并)
        SequenceInputStream sis = new SequenceInputStream(is1, is2);
        // 3.通过字节流将内容写入到一个新的文件(文件字节流的写入处理)
        OutputStream os = new FileOutputStream(OUTPUT_FILEPATH);
        int temp = 0; // 接收内容
        while((temp = sis.read()) != -1) { // 循环输出
            os.write(temp); // 保存内容
        }

        // 4.依次关闭输出流
        sis.close(); // 关闭合并流
        is1.close(); // 关闭输入流1
        is2.close(); // 关闭输入流2
        os.close(); // 关闭输出流
    }

    public static void main(String[] args) throws Exception {
        // 测试合并流
        merge();
    }
}

// 测试
在对应路径分别创建两个文件:seq1.log、seq2.log
运行程序确认对应路径是否生成合并后的信息merge.log

字符流

字符流主要操作字符,一个字符等于两个字节。

字符流有两个核心类:Reader 类和 Writer 。所有的字符流类都继承自这两个抽象类

img

​ 此处关注文件字符流、字节流转换字符流核心

(1)文件字符流

文件字符流 FileReaderFileWriter 可以向文件读写文本数据

/**
 * 文件字符流操作(读写)
 */
public class FileReadWriteDemo {

    // 定义文件
    private final static String FILEPATH = "testRoot/io/fileReadWriteDemo.log";

    // 写文件
    public static void write(String filePath,String content) throws IOException {
        // 1.绑定文件
        File file = new File(filePath);

        // 2.将文件绑定到字符流
        FileWriter fw = new FileWriter(file);

        // 3.写入操作
        fw.write(content);

        // 4.关闭流
        fw.flush(); // 字符流操作时使用了缓冲区,并在关闭字符流时会强制将缓冲区内容输出,如果不关闭流,则缓冲区的内容是无法输出的; 如果想在不关闭流时,将缓冲区内容输出,可以使用 flush 强制清空缓冲区
        fw.close();
    }

    // 读文件
    public static String read(String filePath) throws IOException {
        // 1.绑定文件
        File file = new File(filePath);

        // 2.将文件绑定到字符流
        FileReader fr = new FileReader(file);

        // 3.读取操作
        int temp = 0; // 接收每一个内容
        int len = 0; // 读取内容
        char[] buf = new char[1024];
        while ((temp = fr.read()) != -1) {
            // 如果不是-1说明还有内容可以继续读取
            buf[len] = (char) temp;
            len++;
        }
        System.out.println("文件字符数为:" + len);
        // 4.关闭流
        fr.close();
        // 返回读取内容
        return new String(buf,0,len);
    }

    public static void main(String[] args) throws IOException {
        // 测试字符流读写操作
        write(FILEPATH,"hello fileWrite \r \n");
        String content = read(FILEPATH);
        System.out.println("读取到的内容:" + content);
    }
}

(2)字节流转换字符流

​ 可以在程序中通过 InputStreamReader 从数据源中读取数据,然后也可以在程序中将数据通过 OutputStreamWriter 输出到目标媒介中

使用 InputStreamReader 可以将输入字节流转化为输入字符流;使用OutputStreamWriter可以将输出字节流转化为输出字符流

写入操作:字节流转字符流(现在对应路径创建一个temp.log文件用于测试)

/**
 * 字节流转字符流(输出)
 */
public class OutputStreamWriterDemo {
    public static void main(String[] args) throws IOException {
        File f = new File("testRoot/io/temp.log");
        Writer out = new OutputStreamWriter(new FileOutputStream(f));
        out.write("hello world!!");
        out.close();
    }
}

读取操作:字节流转字符流

/**
 * 字节流转字符流(输入)
 */
public class InputStreamReaderDemo {
    public static void main(String[] args) throws IOException {
        File f = new File("testRoot/io/temp.log");
        Reader reader = new InputStreamReader(new FileInputStream(f));
        char[] c = new char[1024];
        int len = reader.read(c);
        reader.close();
        System.out.println(new String(c, 0, len));
    }
}

字节流 VS 字符流

相同点:

字节流和字符流都有 read()write()flush()close() 这样的方法,这决定了它们的操作方式近似

不同点:

  • 数据类型
    • 字节流的数据是字节(二进制对象)。主要核心类是 InputStream 类和 OutputStream
    • 字符流的数据是字符,一个字符等于两个字节。主要核心类是 Reader 类和 Writer
  • 缓冲区
    • 字节流在操作时本身不会用到缓冲区(内存),是文件直接操作的
    • 字符流在操作时是使用了缓冲区,通过缓冲区再操作文件

场景选择:

所有的文件在硬盘或传输时都是以字节方式保存的,例如图片,影音文件等都是按字节方式存储的。字符流无法读写这些文件

所以,除了纯文本数据文件使用字符流以外,其他文件类型都应该使用字节流方式

3.NIO

NIO 核心部分

  • Buffer:高效的数据容器,除了布尔类型,所有原始数据类型都有相应的Buffer实现

  • Channel:类似在Linux之类操作系统上看到的文件描述符,是NIO中被用来支持批量式IO操作的一种抽象

    File或者Socket,通常被认为是比较高层次的抽象,而Channel则是更加操作系统底层的一种抽象,这也使得NIO得以充分利用现代操作系统底层机制,获得特定场景的性能优化,例如,DMA(Direct Memory Access)等。不同层次的抽象是相互关联的,我们可以通过Socket获取Channel,反之亦然

  • Selector:NIO实现多路复用的基础,它提供了一种高效的机制,可以检测到注册在Selector上的多个Channel中,是否有Channel处于就绪状态,进而实现了单线程对多Channel的高效管理。Selector同样是基于底层操作系统机制,不同模式、不同版本都存在区别,例如,在最新的代码库里,相关实现如下:Linux上依赖于epollopen in new window,Windows上NIO2(AIO)模式则是依赖于iocpopen in new window

  • Charset,提供Unicode字符串定义,NIO也提供了相应的编解码器等,例如,通过下面的方式进行字符串到ByteBuffer的转换:

    Charset.defaultCharset().encode(“Hello world!”));

场景分析:为什么需要NIO?

学习资料

​ Socket相关:NIO如何实现多路复用open in new window

评论
  • 按正序
  • 按倒序
  • 按热度
Powered by Waline v3.1.3