高并发场景I/O优化

news2025/5/24 22:10:07

大家好,我是易安!

Java I/O是一个众所周知的概念。它常被用于读写文件、实现Socket信息传输等操作,这些都是系统中最常见的与I/O相关的任务。

我们都了解,I/O的速度相较于内存速度较慢。在当前大数据时代背景下,I/O性能问题更为显著,I/O读写已成为许多应用场景中的系统性能瓶颈,不容忽视。

今天,我们将深入探讨Java I/O在高并发、大数据业务场景下所暴露出的性能问题。从根本原因出发,学习相应的优化策略。

什么是I/O

I/O是计算机获取和交换信息的关键途径,而流则是执行I/O操作的主要手段。

在计算机领域,流代表信息的传输。流具有顺序性,因此,对于特定的机器或应用程序,我们通常将机器或应用程序从外部接收信息称为输入流(InputStream),将信息从机器或应用程序输出到外部称为输出流(OutputStream),统称为输入/输出流(I/O Streams)。

当机器或程序之间进行信息或数据交换时,首先需要将对象或数据转换为特定形式的流。随后,通过流的传输将其传递至指定的机器或程序,最后将流重新转换为对象或数据。因此,流可视为数据的载体,通过它可以实现数据的交换和传输。

Java的I/O操作类在包java.io下,其中InputStream、OutputStream以及Reader、Writer类是I/O包中的4个基本类,它们分别处理字节流和字符流。如下图所示:

alt

我记得在一开始阅读Java I/O流文档的时候,我有过这样一个疑问,就是:“ 不管是文件读写还是网络发送接收,信息的最小存储单元都是字节,那为什么I/O流操作要分为字节流操作和字符流操作呢?

我们知道字符到字节必须经过转码,这个过程非常耗时,如果我们不知道编码类型就很容易出现乱码问题。所以I/O流提供了一个直接操作字符的接口,方便我们平时对字符进行流操作。下面我们就分别了解下“字节流”和“字符流”。

1.字节流

InputStream/OutputStream是字节流的抽象类,这两个抽象类又派生出了若干子类,不同的子类分别处理不同的操作类型。如果是文件的读写操作,就使用FileInputStream/FileOutputStream;如果是数组的读写操作,就使用ByteArrayInputStream/ByteArrayOutputStream;如果是普通字符串的读写操作,就使用BufferedInputStream/BufferedOutputStream。具体内容如下图所示:

alt

2.字符流

Reader/Writer是字符流的抽象类,这两个抽象类也派生出了若干子类,不同的子类分别处理不同的操作类型,具体内容如下图所示:

alt

传统I/O的性能问题

我们了解到,I/O操作可分为磁盘I/O操作和网络I/O操作。磁盘I/O操作指的是将数据从磁盘读入内存,然后将读取的信息持久化输出到物理磁盘上。而网络I/O操作是指从网络中读取信息输入到内存,最后将信息输出到网络中。然而,在传统I/O中,无论是磁盘I/O还是网络I/O,都面临着严重的性能问题。

1.多次内存复制

在传统I/O中,我们可以通过InputStream从源数据中读取数据流输入到缓冲区里,通过OutputStream将数据输出到外部设备(包括磁盘、网络)。你可以先看下输入操作在操作系统中的具体流程,如下图所示:

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

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

2.阻塞

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

在连接请求较少的情况下,使用传统I/O方式通常不会出现问题,响应速度也相对较高。然而,在面临大量连接请求时,系统需要创建大量的监听线程。若线程没有数据就绪,它们将被挂起并进入阻塞状态。一旦线程阻塞,这些线程会不断争夺CPU资源,从而导致大量的CPU上下文切换,进而增加系统的性能开销。

如何优化I/O操作

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

1.使用缓冲区优化读写流操作

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

与传统I/O不同,NIO是基于块(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堆内存中拷贝到临时的直接内存中,通过临时的直接内存拷贝到内存空间中去。此时的直接内存和堆内存都是属于用户空间。

alt

你肯定会在想,为什么Java需要通过一个临时的非堆内存来复制数据呢?如果单纯使用Java堆内存进行数据拷贝,当拷贝的数据量比较大的情况下,Java堆的GC压力会比较大,而使用非堆内存可以减低GC的压力。

DirectBuffer则是直接将步骤简化为数据直接保存到非堆内存,从而减少了一次数据拷贝。以下是JDK源码中IOUtil.java类中的write方法:

        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操作

NIO很多人也称之为Non-block I/O,即非阻塞I/O,因为这样叫,更能体现它的特点。为什么这么说呢?

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

  • 有数据可读;
  • 连接释放;
  • 空指针或I/O异常。

阻塞问题,就是传统I/O最大的弊端。NIO发布后,通道和多路复用器这两个基本组件实现了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请求。

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

总结

Java的传统I/O最初基于InputStream和OutputStream两个操作流来实现,这种流操作以字节为单位。在高并发和大数据场景下,这种操作很容易导致阻塞,从而导致性能不佳。此外,输出数据需要从用户空间复制到内核空间,再复制到输出设备,这样的操作增加了系统性能开销。

为了优化传统I/O中的“阻塞”问题,引入了Buffer,将缓冲块作为最小单位。尽管如此,相较于整体性能来说,传统I/O的改进仍不完善。

因此,NIO诞生了。NIO基于缓冲块为单位的流操作,在Buffer的基础上,新增了两个组件:“通道(Channel)”和“多路复用器(Selector)”,实现了非阻塞I/O。NIO适用于大量I/O连接请求的场景,这三个组件共同提升了I/O的整体性能。

本文由 mdnice 多平台发布

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/415658.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

java足球体育新闻资讯发布系统ssh

为 本系统的功能目标分为以下几个模块&#xff1a;用户管理模块、足球新闻类别管理模块、足球新闻管理模块、留言管理模块和前台足球新闻浏览模块。 系统功能模块的划分&#xff0c;是在需求分析基础上进行的&#xff0c;是把具有复杂功能的系统通过设计分解为具有基本独立&…

【并发编程】AQS源码

ReentrantLock 互斥锁,可重入 AQS是可以支持互斥锁和共享锁的&#xff0c;这里只分析互斥锁的源码 加锁 公平锁和非公平锁 公平锁 final void lock() {acquire(1); //抢占1把锁.}// AQS里面的方法public final void acquire(int arg) { if (!tryAcquire(arg) &&acq…

MySQL:事务、索引、用户管理、备份、数据库设计(三大范式)

文章目录Day 03&#xff1a;一、事务1. 原则2. 测试实现二、索引1. 分类2. 创建索引3. 分析 sql 执行的状况4. 测试索引5. 索引原则三、数据库用户管理四、备份五、规范数据库设计1. 三大范式注意&#xff1a;Day 03&#xff1a; 一、事务 事务(transaction)&#xff1a;要么…

含光热电站、有机有机朗肯循环、P2G的综合能源优化调度(Matlab代码实现)

&#x1f4a5;&#x1f4a5;&#x1f49e;&#x1f49e;欢迎来到本博客❤️❤️&#x1f4a5;&#x1f4a5; &#x1f3c6;博主优势&#xff1a;&#x1f31e;&#x1f31e;&#x1f31e;博客内容尽量做到思维缜密&#xff0c;逻辑清晰&#xff0c;为了方便读者。 ⛳️座右铭&a…

『pyqt5 从0基础开始项目实战』04. 表格数据的初始化(保姆级图文)

目录导包和框架代码准备json数据文件表格数据接入1. 准备文件路径2. 读取json数据3. 将得到的json数据放入table设置单元格不可修改把数据中的数字转为映射内容完整代码总结欢迎关注 『pyqt5 从0基础开始项目实战』 专栏&#xff0c;持续更新中 欢迎关注 『pyqt5 从0基础开始项…

TiDB实战篇-备份恢复策略

简介 简要说明TiDB备份恢复策略。 备份的类型 热备 TiDB使用MVCC机制实现设备的。 冷备 需要停机备份。 温备 备份的时候只能读不能够写。 备份技术 逻辑备份 物理备份 物理备份的限制 基于复制的备份 复制恢复是最快的。&#xff08;TiDB CDC,TiDB Binlog&#xff…

【C语言】函数详解(嵌套调用和链式访问、声明及定义、递归)

简单不先于复杂&#xff0c;而是在复杂之后。 目录 1.函数的嵌套调用和链式访问 1.1 嵌套调用 1.2 链式访问 2. 函数的声明和定义 2.1 函数声明 2.2 函数定义 3. 函数递归 3.1 什么是递归&#xff1f; 3.2 递归的两个必要条件 3.2.1 练习1&#xff08;需要画图…

Spring Security实战(三)—— 自动登录与注销登录

目录 一、实现自动登录 1. 散列加密方案 2. 持久化令牌方案 二、注销登录 一、实现自动登录 自动登录是将用户的登录信息保存在用户浏览器的cookie中&#xff0c;当用户下次访问时&#xff0c;自动实现校验并建立登录态的一种机制。 Spring Security 提供了两种非常好的令牌&a…

C ++ 基础入门。加强变量、指针、结构体理解

1、 const放外面&#xff0c;值不可以改。只读 同理于指针 看const右侧紧跟着的是指针还是常量, 是指针就是常量指针&#xff0c;是常量就是指针常量 const 放外面&#xff0c;值不可以改 2、 所有的指针类型&#xff0c;包括结构体指针 double * int *都是和操作系统位数…

补充——spark RDD序列化和持久化

目录 RDD序列化 闭包检查&#xff1a; 序列化方法和属性 Kryo序列化框架&#xff1a; RDD持久化&#xff08;RDD persistence&#xff09; RDDCache缓存 RDD persist缓存 什么时候使用persist()? RDD CheckPoint 检查点 缓存和检查点区别 RDD序列化 闭包检查&#x…

JavaScript 的基础函数有哪些?

1、在 JavaScript 中将数组本地转换为对象 JavaScript 有一个原生函数 Object.fromEntries&#xff0c;可用于将任何输入数组转换为对象。 1.const anArray [ 2. [firstname, Paul], 3. [surname, Knulst], 4. [address, worldwide], 5. [role, Senior Engineer], 6. […

Java中的异常Exception和捕获,自定义异常

文章目录1. 异常概述1.1 什么是程序的异常1.2 异常的抛出机制1.3 如何对待异常2. Java异常体系2.1 Throwable2.2 Error 和 Exception2.3 编译时异常和运行时异常3. 常见的错误和异常3.1 Error3.2 运行时异常3.3 编译时异常4. 异常的处理4.1 异常处理概述4.2 捕获异常&#xff0…

springboot整合websocket

1.创建springboot项目&#xff0c;引入spring-boot-starter-websocket依赖 <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-websocket</artifactId></dependency>全部依赖如下&#xff1a; &l…

JDBC之DAO层封装思想超详解

Mysql版本&#xff1a;8.0.26 可视化客户端&#xff1a;sql yog 编译软件&#xff1a;IntelliJ IDEA 2019.2.4 x64 运行环境&#xff1a;win10 家庭中文版 jdk版本&#xff1a;1.8.0_361 目录一、DAO是什么&#xff1f;二、案例演示2.1 准备数据2.2 创建bean包2.3 建立DAO包2.2…

Houdini>RBD(搅拌大米效果)并导出FBX到unity

Houdini&#xff1e;RBD(搅拌大米效果) 效果展示&#xff1a; 动图录制软件&#xff1a;Cockos Incorporated | LICEcap 参考链接&#xff1a;导出除了ABC外&#xff0c;比较小的FBX文件用法 目录&#xff1a; 一、引用模型的处理&#xff1a; 1、大米 模型创建 多层复制 …

Mybatis(六)缓存

缓存是Mybatis中非常重要的特性&#xff0c;Mybatis的一级缓存基于SqlSession实现&#xff0c;二级缓存基于Mapper实现。 一、缓存的使用 一级缓存默认开启&#xff0c;Mybatis提供了一个配置参数localCacheScope来控制一级缓存的级别&#xff0c;该参数的取值可以是session、…

【机器学习】P10 从头到尾实现一个线性回归案例

这里写自定义目录标题&#xff08;1&#xff09;导入数据&#xff08;2&#xff09;画出城市人口与利润图&#xff08;3&#xff09;计算损失值&#xff08;4&#xff09;计算梯度下降&#xff08;5&#xff09;开始训练&#xff08;6&#xff09;画出训练好的模型&#xff08;…

参加Matlab与AI讲座:使用深度强化学习训练走路机器人观后感

时间&#xff1a;2023年4月12日&#xff0c;周三&#xff0c;天气晴 地址&#xff1a;大连理工大学研教楼303 前言&#xff1a;Matlab其实有很多功能&#xff0c;我们所用的只是最基础最简单的部分&#xff0c;例如矩阵计算&#xff0c;画图等等。 随着强化学习的发展&#xff…

一般形式的S曲线公式推导

文章目录一、背景二、目标三、计算3.1 S曲线基本形式3.2 S曲线变换3.3 参数计算3.4 S曲线中心对称条件四、总结五、附件一、背景 S曲线因具备良好可控的平滑性、单调性、连续可导性等优点&#xff0c;常作为各类电机升降速曲线。当前多数S曲线的介绍文章未给出推导过程&#x…

SpringCloud微服务技术栈.黑马跟学(五)

SpringCloud微服务技术栈.黑马跟学 五今日目标1.初识elasticsearch1.1.了解ES1.1.1.elasticsearch的作用1.1.2.ELK技术栈1.1.3.elasticsearch和lucene1.1.4.为什么不是其他搜索技术&#xff1f;1.1.5.总结1.2.倒排索引1.2.1.正向索引1.2.2.倒排索引1.2.3.正向和倒排1.3.es的一些…