java复习系列[4] - Java IO

java复习系列[4] - Java IO,第1张

java复习系列[4] - Java IO

文章目录
  • Java IO
    • IO传输
    • IO读写流程
    • IO类型
    • IO的访问方式
      • 缓存IO(标准IO、传统IO)
      • 直接IO
      • 内存映射
      • 总结
    • Java中IO与NIO的区别
    • Java NIO
      • 流与缓冲
      • 管道
      • 为什么NIO比IO更快
    • BIO,NIO,IO复用,AIO,同步,异步,阻塞,非阻塞
      • I/O模型
      • 阻塞式I/O
        • C10K问题,大量客户端访问
      • 非阻塞式I/O
      • I/O多路复用
        • 单路对应 -> 多路复用
        • 多路复用
        • Select, Poll, Epoll
          • **Select**
            • Select处理流程
          • poll
          • **epoll**
          • 总结
            • Select底层实现
      • 信号驱动I/O
      • 异步I/O
      • 总结
        • Reactor模式
          • **中心思想 & 流程**
          • 实现代码
    • Java NIO直接内存 与 内存映射
        • 内存映射优势
        • Java代码实例
        • DirectBuffer 和 MappedByteBuffer
    • Java读取文件、发送Socket 的过程
      • Java读取文件
      • Java使用Socket发送文件
      • 一,读文件、发送过程
      • 二,Java NIO避免堆内外内存拷贝
      • 三、Java的mmap
  • Java: BIO -> NIO -> Selector
    • BIO
    • NIO 伪代码, 轮询方式
    • NIO 的Socket实现(Java层面,轮询)
    • NIO 的Socket实现(内核层面:主动感知fd是否活跃,Select)
    • NIO + Select + 利用多核心
  • *** 作系统(内核 、用户)
    • 内核空间 与 用户空间
      • 为什么需要区分内核空间与用户空间
      • 内核态和用户态
      • 如何从用户态进入内核态
    • 系统调用执行过程

Java IO IO传输

  1. 传统的输入里和输出流都是阻塞式的进行输入和输出

  2. 传统的输入流、输出流都是通过字节的移动来处理

  3. 面向流的输入和输出一次只能处理一个字节,因此面向流的输入和输出系统效率通常不高

IO读写流程

IO类型
  1. 方向:输入、输出
  2. *** 作单元:字节、字符
  3. 角色:节点流、处理流(流的包装,转换)
IO的访问方式

大文件拷贝,试试NIO的内存映射

Linux系统中的磁盘文件访问方式包括:

  1. 缓存IO(Buffer IO),又称标准IO,是多数OS的默认IO模式,在缓存IO模式,读文件 *** 作时,数据先从磁盘复制到内核空间缓冲区,然后再从内核缓冲区复制到应用程序地址空间,写 *** 作亦然。
  2. 直接IO(Direct IO),此模式下,应用程序读写文件时直接访问磁盘数据,不经过内核缓冲区,适用于数据库等程序自己管理缓冲的场景。
  3. 内存映射(Memory Mapping),这是Linux提供的一种访问磁盘的特殊方式,它把内存中的某块地址空间和磁盘文件直接关联,从而把对内存的访问直接转换为对磁盘的访问。

主要区别是:对Kernel内存的使用方式

这儿是从 *** 作系统的角度来看,如果从Java的角度看,UserBuffer可以分为堆内(JVM)和堆外(直接内存)。

缓存IO(标准IO、传统IO)

缓存IO(Buffer IO),又称标准IO,是多数OS的默认IO模式,在缓存IO模式,

读 *** 作:

  1. 数据先从磁盘复制到内核空间缓冲区,
  2. 然后再从内核缓冲区复制到应用程序地址空间,
  3. 用户进程从用户空间缓冲区读取数据

缓存IO 模式下,用户程序通过read读取数据时,先检查 Kernel 缓存区是否有需要的数据,有则拷贝数据到用户程序缓存区;没有则从磁盘读取,先缓存到 Kernel 中,再拷贝到用户程序缓存;write *** 作时,把写入数据从用户地址空间拷贝到Kernel缓存区(Kernel缓存区写入后,用户程序写 *** 作已完成,数据刷到磁盘的时间由OS来决定,或由程序显示调用sync命令)。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Opourj21-1638360797791)(Java IO.assets/image-20210817213007624.png)]

应用:Java 的 IO流 就是使用这种方式

直接IO

直接IO(Direct IO),此模式下,应用程序读写文件时直接访问磁盘数据,不经过内核缓冲区,适用于数据库等程序自己管理缓冲的场景。

直接IO模式下,用户进程访问文件时,跨过Kernel缓冲区直接访问磁盘,有效避免了CPU和内存的多余开销。

例如,对于数据库服务等比较复杂的应用,程序根据业务更懂如何使用内存,为了提高性能,希望绕过内核缓存区,由自己在用户空间管理IO缓存,包括缓存机制和写延迟机制等,以支持事务、提高查询缓存命中率等。

内存映射

内存映射(Memory Mapping),这是Linux提供的一种访问磁盘的特殊方式,它把内存中的某块地址空间和磁盘文件直接关联,从而把对内存的访问直接转换为对磁盘的访问。

内存映射模式下,通过mmap系统调用,在用户进程虚拟内存地址和Kernel内存间直接建立映射关系。通过用户缓存和Kernel缓存的共享,用户程序的 *** 作直接作用到Kernel内存,无需进行内存拷贝。

使用内存映射文件处理磁盘上的文件时,

  1. 无需对文件执行IO *** 作,
  2. 也不需要再为文件进行内存分配、加载和释放等管理工作,

因此此模式在处理大量数据的文件时能起到高效的作用。

总结 缓存IO直接IO内存映射流程用户缓存 <—> 内核缓存 <—> Disk(网卡…)用户缓存 <—> Disk(网卡…)用户缓存映射到内核缓存 <—> Disk(网卡…)读/写1. 数据从Disk 到 内核缓存
2. 再从内核缓存复制到用户缓存
3. 用户进程从用户空间缓冲区读取数据1. 数据从Disk 到 用户缓存
2. 用户进程从用户空间缓冲区读取数据1. 数据从Disk 到 内核缓存
2. 用户进程从用户空间缓冲区读取数据
(因为用户缓存与内核缓存映射的是同一块物理内存)写---应用Java的IO流数据库等一些大型软件实现Java的NIO图 Java中IO与NIO的区别

Java IO:是面向流的阻塞IO

Java NIO:是结合了select、缓冲的非阻塞IO

IONIO(New / NonBlocking)面向流面向缓冲区阻塞IO非阻塞IO-Select(监听多通道事件)

Channel,Buffer 和 Selector 构成了java,nio的核心API

**缓冲区:**NIO中引入了缓冲区的概念,缓冲区作为传输数据的基本单位块,所有对数据的 *** 作都是基于将数据移进/移出缓冲区而来;

IONIO传统JavaIO中也有相应的缓冲流(BufferedInputStream等):
但只是对输入输出的包装,本质上是数据结构化和积累达到处理时候的便捷,但未提高IO效率。对缓冲取的 *** 作由底层OS实现
缓冲区在数据分析和处理上也带来的很大的便利和灵活性 Java NIO 流与缓冲

IO是面向流的,NIO是面向缓冲区的!!

Java IO面向流意味着每次从流中读一个或多个字节,直至读取所有字节,它们没有被缓存在任何地方。

它不能前后移动流中的数据。如果需要前后移动从流中读取的数据,需要先将它缓存到一个缓冲区。

缓冲区

先数据读取到一个它稍后处理的缓冲区,需要时可在缓冲区中前后移动。

这就增加了处理过程中的灵活性。

管道

Java NIO(8) - 管道

Java NIO 管道是2个线程之间的单向数据连接。Pipe有一个source通道和一个sink通道。数据会被写到sink通道,从source通道读取。

为什么NIO比IO更快
  1. 流和缓冲区:

    IO是面向流的,也就是读取数据的时候是从流上逐个读取,所以数据不能进行整体 *** 作,没有缓冲区;

    NIO是面向缓冲区的,数据是存储在缓冲区中,读取数据是在缓冲区中进行,所以进行数据的偏移 *** 作更加方便;

  2. 是否阻塞:

    IO是阻塞的,当一个线程 *** 作IO时如果当前没有数据可读,那么线程阻塞;

    NIO由于是对通道 *** 作,所以是非阻塞,当一个通道无数据可读,可切换通道处理其他IO;

  3. selector:

    NIO有selecter选择器,就是线程通过选择器可以选择多个通道,而IO只能处理一个。

BIO,NIO,IO复用,AIO,同步,异步,阻塞,非阻塞

五种IO模型:

多路复用之前,有必要讲一下什么是阻塞IO、非阻塞IO、同步IO、异步IO这几个东西;linux的五种IO模型

  1. 阻塞I/O(Blocking I/O)
  2. 非阻塞I/O(Nonblocking I/O)
  3. I/O复用(select和poll)(I/O multiplexing)
  4. 信号驱动I/O(signal driven I/O (SIGIO))
  5. 异步I/O(asynchronous I/O (the POSIX aio_functions)。

同步:

**同步:**等待应答结果时,主动的获取消息结果;(主动查询)

​ 可以什么都不干一直等待结果(阻塞);也可以先干其他事情,但是每隔一段时间进行询问应答结果(非阻塞,轮询)

BIO、NIO、多路复用:都是同步模型

**异步:**等待别人通知最后的结果;(被动告知)

IO复用,AIO,BIO,NIO,同步,异步,阻塞和非阻塞

多路复用

I/O模型

网络IO的本质是socket的读取,socket在linux系统被抽象为流,IO可以理解为对流的 *** 作。刚才说了,对于一次IO访问(以read举例),数据会先被拷贝到 *** 作系统内核的缓冲区中,然后才会从 *** 作系统内核的缓冲区拷贝到应用程序的地址空间。所以说,当一个read *** 作发生时,它会经历两个阶段:

  • 第一阶段:等待数据准备 (Waiting for the data to be ready)。

  • 第二阶段:将数据从内核拷贝到进程中 (Copying the data from the kernel to the process)。

对于socket流而言:

  • 第一步:通常涉及等待网络上的数据分组到达,然后被复制到内核的某个缓冲区
  • 第二步:把数据从内核缓冲区复制到应用进程缓冲区。
阻塞式I/O

最广泛的模型是阻塞I/O模型,默认情况下,所有套接口都是阻塞的。 进程调用recvfrom系统调用,*整个过程是阻塞的*,直到数据复制到进程缓冲区时才返回(当然,系统调用被中断也会返回)

C10K问题,大量客户端访问

一般使用BIO与线程池结合,仅仅只是少量请求的时候,这种方式适用,但是高并发(大量客户端请求时候),这时候多线程弊端就出现了:

  • 严重依赖线程,线程本身消耗资源

  • 线程上下文切换成本高!!!

    如果线程数量庞大,会造成线程做上下文切换的时间甚至大于线程执行的时间,CPU负载变高。

非阻塞式I/O

当我们把一个套接口设置为非阻塞时,就是在告诉内核,当请求的I/O *** 作无法完成时,不要将进程睡眠,而是返回一个错误。当数据没有准备好时,内核立即返回EWOULDBLOCK错误,第四次调用系统调用时,数据已经存在,这时将数据复制到进程缓冲区中。这其中有一个 *** 作时轮询(polling)。

I/O多路复用

100%弄明白5种IO模型

单路对应 -> 多路复用

多路复用

阻塞I/O只能阻塞一个I/O *** 作,而I/O复用模型能够阻塞多个I/O *** 作,所以才叫做多路复用

从流程上来看,使用 select 函数进行IO请求和同步阻塞模型没有太大的区别,甚至还多了添加监视socket,以及调用select函数的额外 *** 作,效率更差。但是,*使用select以后最大的优势是用户可以在一个线程内同时处理多个socket的IO请求*。用户可以注册多个socket,然后不断地调用select读取被激活的socket,即可达到在同一个线程内同时处理多个IO请求的目的。而在同步阻塞模型中,必须通过多线程的方式才能达到这个目的。

Select, Poll, Epoll

彻底搞懂 IO 底层原理

polling

while true
{  
  for i in stream[]  // 忙轮询的方式,stream代表不同流组成的数组
  {  
          if i has data  
          read until unavailable  
  }  
}  

要把所有流从头到尾查询一遍,就可以处理多个流了,但这样做很不好,因为如果所有的流都没有I/O事件,白白浪费CPU时间片。

— 待确认

Select
  • 一个固定大小为1024 的 fd_set

fd_set:本质上是一个数组,每一个数组元素都能与一打开的文件句柄fd(File Descriper)

while true 
{  
    select(streams[]) //这一步死在这里,知道有某个流有I/O事件时,才往下执行  
    for i in streams[]  
    {  
        if i has data  
        read until unavailable  
    }  
}  

从select 那里仅仅能知道有I/O事件发生,却并不知道是哪几个流(可能有一个,多个,甚至全部),我们只能无差别轮询所有流,找出能读出数据,或者写入数据的流,对他们进行 *** 作。所以select具有O(n)的无差别轮询复杂度,同时处理的流越多,无差别轮询时间就越长。

  • 复杂度 O(n),轮询的任务交给了内核来做,复杂度并没有变化,数据取出后也需要轮询哪个fd上发生了变动;
  • 用户态还是需要不断切换到内核态,直到所有的 fd 数据读取结束,整体开销依然很大;
  • fd_set 有大小的限制,目前被硬编码成了 1024;
  • fd_set 不可重用,每次 *** 作完都必须重置;
Select处理流程
  1. 当调用 select() 时,
  2. 由内核根据IO状态修改fd_set的内容,
  3. 由此来通知执行了 select() 的进程哪一fd对应的Socket或文件可读。
poll

poll() 非常像select(),它也是阻塞等待直到一个或多个fd到达"就绪"状态。

poll()和select()是非常相似的,唯一的区别在于poll()摒弃掉了位图算法,使用自定义的结构体pollfd,在pollfd内部封装了fd。

简而言之:放置fd从数组变成了链表

epoll
while true  
{  
    active_stream[] = epoll(epollfd)  // epoll会把具体哪些流发生了I/O事件通知我们
    for i in active_stream[]  
    {  
        read or write till  
    }  
} 

****epoll可以理解为event poll****,不同于忙轮询和无差别轮询,epoll会把哪个流发生了怎样的I/O事件通知我们。

select 和 epoll 这 2 个系统调用都可以在内核准备好数据(网络数据到达内核)时告知用户进程

epoll() 基本上完美地解决了 poll() 函数遗留的两个问题:

  • 没有了频繁的用户态到内核态的切换;
  • O(1) 复杂度,返回的 “nfds” 是一个确定的可读写的数量,相比于之前循环 n 次来确认,复杂度降低了不少;
总结 Select底层实现

select的内核实现原理

select,poll机制:

当触发IO事件之后,会无差别轮询所有的流,找出可出发IO事件的流,并进行处理!

selectpoll最大文件描述符数量有限制 1024无限制底层结构数组链表

Epoll机制:

当触发IO事件,Epoll方式会直接处理触发IO事件的流的集合,而不需要遍历所有的流。

selectpollepoll最大文件描述符数量有限制无无底层结构数组(位图)链表红黑树处理方式遍历所有fd,找出活跃FD遍历所有fd,找出活跃fd只处理活跃可用的fd内存拷贝内核需要将消息传递到用户空间,都需要内核拷贝动作都需要内核拷贝动作利用mmap()文件映射内存加速与内核空间的消息传递(内核和用户空间共享一块内存) 信号驱动I/O

等待数据报到达(第一阶段)期间,进程可以继续执行,不被阻塞。免去了select的阻塞与轮询,当有活跃套接字时,由注册的handler处理。

异步I/O

告诉内核启动某个 *** 作,并让内核在整个 *** 作(包括第二阶段,即将数据从内核拷贝到进程缓冲区中)完成后通知我们。

总结

Reactor模式

服务器模型 Reactor 与 Proactor

Reactor 模式是处理并发 I/O 比较常见的一种模式,用于同步 I/O,

中心思想 & 流程

主要组成:

  • 多路复用器:由 *** 作系统提供,在 linux 上一般是 select, poll, epoll 等系统调用。

  • 事件分发器:将多路复用器中返回的就绪事件分到对应的处理函数中。

  • 事件处理器:负责处理特定事件的处理函数。

流程:

  1. 将所有要处理的 I/O 事件注册到一个中心 I/O 多路复用器上,
  2. 同时主线程/进程阻塞在多路复用器上;
  3. 一旦有 I/O 事件到来或是准备就绪(文件描述符或 socket 可读、写),多路复用器返回并将事先注册的相应 I/O 事件分发到对应的事件处理器中。

以 NIO 的 Socket连接为例:

  1. 首先注册AcceptHandle()到多路复用器,并且等待连接,
  2. 主线程阻塞在selector的select()处,
  3. 当存在连接时,将新连接的socket注册到多路复用器上,
  4. 主线程阻塞在多路复用器的select()处,
  5. 当有新的连接 或 socket 有数据的时候,通过 事件分发器 对应的 事件处理器 进行处理。
实现代码
package com.company.IO;

import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.SelectionKey;
import java.nio.channels.Selector;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;
import java.util.Iterator;
import java.util.Set;

public class SingleReactor implements Runnable{

    //单线程reactor 模型:1.一个线程绑定一个selector 和一个serverSocketChannel
    ServerSocketChannel serverSocketChannel;

    Selector selector;

    //OPEN&注册accepet
    public void open() throws IOException {
        selector = Selector.open();
        serverSocketChannel = ServerSocketChannel.open();
        //设置非阻塞
        serverSocketChannel.configureBlocking(false);
        //绑定ip&端口
        serverSocketChannel.bind(new InetSocketAddress("127.0.0.1", 8080));
        //将channel注册到selector并拿到选择键(channel注册的标识)
        SelectionKey register = serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
        register.attach(new AcceptHandle());
    }

    //轮训注册的事件&分发
    @Override
    public void run() {
        try {
            open();
        } catch (IOException e1) {
            e1.printStackTrace();
        }
        try {
            //循环查询感兴趣的时间
            while (!Thread.interrupted()) {
                try {
                    //1.阻塞去查
                    selector.select();
                    //2.拿到查询结果(注册的标识)
                    Set keys = selector.selectedKeys();
                    //3.迭代感兴趣的key
                    Iterator iterator = keys.iterator();
                    while (iterator.hasNext()) {
                        SelectionKey next = iterator.next();
                        dispatch(next);
                    }
                    keys.clear();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    //分发&拿到绑定的处理器
    void dispatch(SelectionKey next){
        //1.拿出SelectionKey
        Runnable attachment = (Runnable) next.attachment();
        //2.调用handle处理器
        if (attachment != null) {
            attachment.run();
        }
    }


    //接受连接处理器
    //连接处理器&为新连接创造一个输入输出的Handle处理器
    class AcceptHandle implements Runnable{
        @Override
        public void run() {
            try {
                //1.接受新连接&调用下一个处理器注册读取事件
                SocketChannel accept = serverSocketChannel.accept();
                new IOEchoHandler(selector,accept);
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }

    //处理具体的IO时间处理器
    class IOEchoHandler implements Runnable{

        SocketChannel channel;//读写channel
        SelectionKey register;//channel注册结果返回的标识

        static final int RECIEVING = 0, SENDING = 1;
        int state = RECIEVING;

        //读写数据的缓存区
        final ByteBuffer byteBuffer = ByteBuffer.allocate(1024);

        IOEchoHandler(Selector selector, SocketChannel c) throws IOException {
            channel = c;
            c.configureBlocking(false);//设置阻塞
            //仅仅取得选择键,后设置感兴趣的IO事件
            register = channel.register(selector, 0);
            //将Handler作为选择键的附件
            register.attach(this);
            //第二步,注册Read就绪事件
            register.interestOps(SelectionKey.OP_READ);
            selector.wakeup();
        }


        @Override
        public void run() {
            try {
                if (state == SENDING) {
                    //写入通道
                    channel.write(byteBuffer);
                    //写完后,准备开始从通道读,byteBuffer切换成写模式
                    byteBuffer.clear();
                    //写完后,注册read就绪事件
                    register.interestOps(SelectionKey.OP_READ);
                    //写完后,进入接收的状态
                    state = RECIEVING;
                } else if (state == RECIEVING) {//一开始是接受情况
                    //从通道读到byteBuffer
                    int length = 0;
                    while ((length = channel.read(byteBuffer)) > 0) {
                        System.out.println(new String(byteBuffer.array(), 0, length));
                    }
                    //读完后,准备开始写入通道,byteBuffer切换成读模式
                    byteBuffer.flip();
                    //读完后,注册write就绪事件
                    register.interestOps(SelectionKey.OP_WRITE);
                    //读完后,进入发送的状态
                    state = SENDING;
                }
                //处理结束了, 这里不能关闭select key,需要重复使用
                //sk.cancel();
            } catch (IOException ex) {
                ex.printStackTrace();
            }
        }
    }


    public static void main(String[] args) throws IOException {
        new Thread(new SingleReactor()).start();
    }
}

Java NIO直接内存 与 内存映射

Java NIO浅析 - 美团技术团队

【Java核心-基础】DirectBuffer 和 MappedByteBuffer

Java高效NIO之直接内存映射

这儿的Native堆指的是 直接内存

NIO引入了直接内存映射的模式,可以在Native堆中分配内存,以免JVM堆和 Native堆之间数据拷贝带来直接的内存损耗。

VM堆内存和Native堆内存分别对应Linux缓存IO模式中的应用程序内存和Kernel内存(这个待考证,但至少可以类比)

内存映射优势
  1. 改善堆过大时垃圾回收效率,减少停顿。Full GC时会扫描堆内存,回收效率和堆大小成正比。通过把内存放到Native堆,可提升GC效率。Native的内存,由OS负责管理和回收。
  2. 减少内存在Native堆和JVM堆拷贝过程,避免拷贝损耗,降低内存使用。
  3. 突破JVM内存大小的限制
Java代码实例
    public void testMappedByteBuffer1() throws Exception {
        FileInputStream inputStream = new FileInputStream(new File("t.txt"));
        FileChannel inChannel = inputStream.getChannel();
        FileOutputStream outputStream = new FileOutputStream(new File("tt.txt"),true);
        FileChannel outChannel = outputStream.getChannel();

        long fileSize = inChannel.size();
        // MappedByteBuffer 进行内存映射
        MappedByteBuffer mappedByteBuffer = inChannel.map(FileChannel.MapMode.READ_ONLY, 0, fileSize);

        outChannel.write(mappedByteBuffer);

        System.out.println(System.currentTimeMillis() );
        outputStream.close();
        inputStream.close();
    }

内存映射文件非常特别,它允许Java程序直接从内存中读取文件内容,通过将整个或部分文件映射到内存,由 *** 作系统来处理加载请求和写入文件,应用只需要和内存打交道,这使得IO *** 作非常快。加载内存映射文件所使用的内存在Java堆区之外。Java编程语言支持内存映射文件,通过java.nio包和MappedByteBuffer 可以从内存直接读写文件。

支持内存映射IO的 *** 作系统大多数主流 *** 作系统比如Windows平台,UNIX,Solaris和其他类UNIX *** 作系统都支持内存映射IO和64位架构,你几乎可以将所有文件映射到内存并通过JAVA编程语言直接访问。

1、java通过java.nio包来支持内存映射IO。
2、内存映射文件主要用于性能敏感的应用,例如高频电子交易平台。
3、通过使用内存映射IO,你可以将大文件加载到内存。
4、内存映射文件可能导致页面请求错误,如果请求页面不在内存中的话。
5、映射文件区域的能力取决于于内存寻址的大小。在32位机器中,你不能访问超过4GB或2 ^ 32(以上的文件)。
6、内存映射IO比起Java中的IO流要快的多。
7、加载文件所使用的内存是Java堆区之外,并驻留共享内存,允许两个不同进程共享文件。
8、内存映射文件读写由 *** 作系统完成,所以即使在将内容写入内存后java程序崩溃了,它将仍然会将它写入文件直到 *** 作系统恢复。
9、出于性能考虑,推荐使用直接字节缓冲而不是非直接缓冲。
10、不要频繁调用MappedByteBuffer.force()方法,这个方法意味着强制 *** 作系统将内存中的内容写入磁盘,所以如果你每次写入内存映射文件都调用force()方法,你将不会体会到使用映射字节缓冲的好处,相反,它(的性能)将类似于磁盘IO的性能。
11、万一发生了电源故障或主机故障,将会有很小的机率发生内存映射文件没有写入到磁盘,这意味着你可能会丢失关键数据。

DirectBuffer 和 MappedByteBuffer

MappedByteBuffer 可以 将文件内容映射到内存,供应用程序直接使用,省去数据在 内核空间 和 用户空间 之间传输的损耗。

它本质上也是一种 DirectBuffer。JDK 中用的是它的子类 DirectByteBuffer,这个类就实现了 DirectBuffer 接口。

MappedByteBuffer buffer = fileChannel.map(MapMode.READ_ONLY, 0, 1024); 
public abstract class ByteBuffer extends Buffer implements Comparable
{
	public static ByteBuffer allocateDirect(int capacity) {
        return new DirectByteBuffer(capacity); 	// 直接内存 DirectByteBuffer 
    }

    public static ByteBuffer allocate(int capacity) {
        if (capacity < 0)
            throw new IllegalArgumentException();
        return new HeapByteBuffer(capacity, capacity); // JVM内存 HeapByteBuffer
    }
    ...
}

-XX:MaxDirectMemory=512M 设置最大直接内存

内存回收:使用cleaner.clean()***进行内存回收,本质上*clean是调用以下的run方法

private static class Deallocator
  implements Runnable
{
  public void run() {
      if (address == 0) {
          // Paranoia
          return;
      }
      unsafe.freeMemory(address);
      address = 0;
      Bits.unreserveMemory(size, capacity);
  }
  private static Unsafe unsafe = Unsafe.getUnsafe();

  private long address;
  private long size;
  private int capacity;

  private Deallocator(long address, long size, int capacity) {
      assert (address != 0);
      this.address = address;
      this.size = size;
      this.capacity = capacity;
  }
}

Java读取文件、发送Socket 的过程

InputStream和OutputStream读取文件并通过socket发送,到底涉及几次拷贝

JAVA IO专题三:java的内存映射和应用场景

Java读取文件

过程:

  1. 开辟堆外内存Buf(native方法)

  2. 获取fd,调用IO_Read将数据拷贝到 堆外内存Buf

    IO_Read的过程:

    1. 文件 ----> 通过DMA ----> 内核缓冲区

    2. 内核缓冲区 ----> 拷贝 ----> 堆外内存Buf

  3. 堆外内存Buf数据拷贝到 Java堆内存 中

问题: 为什么要先在堆外开辟内存,再拷贝到堆内

Java,C++都不能直接 *** 作内核缓冲区的数据,只有用户空间 *** 作的权限!!!

  1. 直接将内核缓冲区的数据拷贝到堆内存:不可行!

    JVM的GC线程在不断的整理内存(标记-清除、复制、标记整理),内存地址在不断的发生变化!

  2. 内核缓冲区拷贝数据到堆外内存:JVM直接 *** 作堆外内存,NIO的directBuffer就是这么做。

所以从内存拷贝的角度来说:读取一个文件到JVM主要涉及 3次拷贝:

  1. DMA读取 文件 到 内核空间
  2. 内核空间 到 堆外内存
  3. 堆外内存 到 JVM堆
Java使用Socket发送文件
  1. JVM堆 拷贝到 堆外内存
  2. 堆外内存 拷贝到 Socket缓冲区
  3. Socket缓冲区 通过DMA拷贝到 协议引擎(网卡) 进行发送
一,读文件、发送过程
  1. DMA:文件 --> 内核

  2. CPU:内核 --> 堆外

  3. CPU:堆外 --> JVM堆


  4. CPU: JVM堆 --> 堆外

  5. CPU:堆外 --> Socket缓冲区

  6. DMA:Socket缓冲区 --> 协议引擎(网卡)

6 步骤太繁琐了!!! --> 零拷贝

JAVA IO专题二:java NIO读取文件并通过socket发送,最少拷贝了几次?堆外内存和所谓的零拷贝到底是什么关系

二,Java NIO避免堆内外内存拷贝

堆外内存+channel的方式,可以避免堆内外内存拷贝,一定程度上也能提高效率。相比较于BIO可以省去堆内 - 堆外的拷贝。

  1. DMA:文件 --> 内核

  2. CPU:内核 --> 堆外

直接内存(堆外内存)实现JVM直接 *** 作堆外内存,从而省去了堆内 - 堆外 的拷贝过程。

三、Java的mmap

mmap实现了将设备驱动在内核空间的部分地址直接映射到用户空间,使得用户程序可以直接访问和 *** 作相应的内容,减少了额外的拷贝。

  1. DMA:文件 --> 内核
  2. 开辟堆外内存,并将堆外内存 与 内核空间内存 进行映射

mmap实现了用户空间与内核空间的数据直接交互,从而省去了内核 - 堆外 内存的拷贝过程。

Java: BIO -> NIO -> Selector

Java NIO浅析

  1. 不考虑多线程情况下,BIO不能处理并发情况

  2. BIO + 多线程:可以处理并发,但是线程的销毁,创建会消耗资源,线程本身也要占用资源

  3. BIO+线程池:

    • 仍然但是存在上下文切换,

    • 并且每一个线程对应一个Socket,

    • 难以处理高并发问题(少量并发还可以)

      开1000万线程处理连接,但是不一定1000万人都给你发送消息,很多都只是连接,不进行通信

  4. 服务端,不活跃的线程比较多,则一般选用单线程

    如何处理 -> NIO

  5. 如果连接的Socket有 1000万个,但其中只有200个数据传输,一直轮询会轮询1000万个Socket判断时候存在数据传输。

    轮询的工作交给内核,内核可以主动感知到有数据的fd,那么如何让内核做这个事情?使用select(),epoll()

BIO

NIO 伪代码, 轮询方式
        
        List socketList = new ArrayList<>();
        ServerSocket serverSocket = new ServerSocket();
        serverSocket.bind(new InetSocketAddress(8080));
        serverSocket.setConfig(false); // 设置 accept 非阻塞
        while(true){
            Socket socket = serverSocket.accept();
            if(socket == null){
                System.out.println(" 无人连接 ");
            }else{
                socket.setConfig(false); // 设置 read/write 非阻塞
                list.add(socket);
            }
            for(Socket tsocket: socketList){
                byte[] bytes = new byte[1024];
                int read = tsocket.getInputStream().read(bytes);
                if(read != -1){
                    // 读到数据了进行处理
                }else{
                    // 没有数据
                }
            }
        }

Java的Socket没有提供 setConfig(false); // 设置 accept 非阻塞方法,但是提供了NIO, SockeChannel 可以设置非阻塞。所以以下代码使用 NIO 实现

NIO 的Socket实现(Java层面,轮询)
		// 存放已有的Channel
		List clientChannels = new ArrayList();
        // 分配缓冲区域(Java NIO是基于缓冲区的读写)
        ByteBuffer byteBuffer = ByteBuffer.allocate(512);
        try {
            ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
            serverSocketChannel.bind(new InetSocketAddress(8080));
            // 设置非阻塞
            serverSocketChannel.configureBlocking(false);
            while(true){
                // 非阻塞 configureBlocking(false);
                SocketChannel socketChannel = serverSocketChannel.accept();
                if(socketChannel == null){
                    Thread.sleep(500);
                    System.out.println("no conn");
                }else{ // 加入到 SocketChannel的列表中
                    // 设置 socket 连接的非阻塞
                    System.out.println("Connecting ............");
                    socketChannel.configureBlocking(false);
                    clientChannels.add(socketChannel);
                }
                // 遍历轮询的方式查看是否 可以读数据
                for(SocketChannel clientChannel : clientChannels){
                    // ...
                    clientChannel.read(byteBuffer);
                    byteBuffer.flip();
                    System.out.println(byteBuffer.toString());
                    // ...
                }
            }
        } catch (IOException | InterruptedException e) {
            e.printStackTrace();
        }

题外话:

  1. ***allocate()***分配的ByteBuffer叫做堆字节缓冲区(HeapByteBuffer)

  2. ***allocateDirect()***分配的字节缓冲区用中文叫做直接缓冲区(DirectByteBuffer),

其实根据类名就可以看出,HeapByteBuffer所创建的字节缓冲区就是在jvm堆。

而DirectByteBuffer是直接 *** 作 *** 作系统本地代码创建的直接内存缓冲数组

如果连接的Socket有 1000万个,但其中只有200个数据传输,一直轮询会轮询1000万个Socket判断时候存在数据传输。低效!!!

轮询的工作交给内核,内核可以***主动感知到活跃的 fd***,那么如何让内核做这个事情?

使用 select(),poll(),epoll()

            // 遍历轮询的方式查看是否 可以读数据
            for(SocketChannel clientChannel : clientChannels){
                clientChannel.read(byteBuffer);
                byteBuffer.flip();
                System.out.println(byteBuffer.toString());
                // ...
            }
// 将这一块代码,交给内核去做
// 将这一块代码,交给内核去做
// 将这一块代码,交给内核去做
NIO 的Socket实现(内核层面:主动感知fd是否活跃,Select)

**Select()**是内核级别的实现,更加的高效!!!

都是轮询,为什么内核级别快???

        Selector selector = Selector.open();
        ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
		// 设置非阻塞(因为默认阻塞)
        serverSocketChannel.configureBlocking(false);
        ServerSocket serverSocket = serverSocketChannel.socket();
        serverSocket.bind(new InetSocketAddress(8080));
        //将channel注册到selector上,并绑定selector对于此通道的感兴趣的事件,当此通道`接受就绪`时,selector就可以知道此通道`接受就绪`。这样就实现了通道和Selector的关联关系。共有四种常量状态
        //SelectionKey.OP_CONNECT
        //SelectionKey.OP_ACCEPT
        //SelectionKey.OP_READ
        //SelectionKey.OP_WRITE
        serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
        while(true){
            int cnt = selector.select(); // 判断当前是否存在 连接、或者数据传输(是否存在活跃的fd)
            if( cnt <= 0) continue;
            Set selectionKeys = selector.selectedKeys();
            for(SelectionKey key: selectionKeys){
                if(key.isAcceptable()){ //若此key的通道是有可接受状态,则创建对应的channel
                    System.out.println("有客户端连接服务,触发accept事件");
                    ServerSocketChannel channel = (ServerSocketChannel) key.channel();
                    final SocketChannel client = channel.accept();
                    // 等于是将 当前Channel放到 selector.selectedKeys()中间
                    client.register(selector, SelectionKey.OP_READ);
                    // 从 selectionKeys删除掉已处理的selectionKey,不然会一直循环
                    selectionKeys.remove(key);
                }else if(key.isReadable()){ //若此key的通道是有数据可读状态
                    SocketChannel socketChannel = (SocketChannel) key.channel();
                    ByteBuffer buffer = ByteBuffer.allocate(1024);
                    int dataCnt = socketChannel.read(buffer);
                    if(dataCnt > 0){
                        buffer.flip();
                        //  *** 作
                    }
                }else if(key.isWritable()){ //若此key的通道是写数据状态
                    // ...
                }
            }
        }
       	    	
NIO + Select + 利用多核心

NIO由原来的阻塞读写(占用线程)变成了单线程轮询事件,找到可以进行读写的网络描述符进行读写。除了事件的轮询是阻塞的(没有可干的事情必须要阻塞),剩余的I/O *** 作都是纯CPU *** 作,没有必要开启多线程。

连接数大的时候因为线程切换带来的问题也随之解决,进而为处理海量连接提供了可能。

  1. 单线程轮询
  2. 可处理海量连接

单线程处理I/O的效率确实非常高,没有线程切换,只是拼命的读、写、选择事件。但现在的服务器,一般都是 多核 处理器,如果能够利用多核心进行I/O,无疑对效率会有更大的提高。

利用多核优势:

需要的线程:

  1. 事件分发器,单线程选择就绪的事件。
  2. I/O处理器,包括connect、read、write等,这种纯 CPU *** 作,一般开启CPU核心个线程就可以。
  3. 业务线程,在处理完I/O后,业务一般还会有自己的业务逻辑,有的还会有其他的阻塞I/O,如DB *** 作,RPC等。只要有阻塞,就需要单独的线程。


*** 作系统(内核 、用户) 内核空间 与 用户空间

对 32 位 *** 作系统而言,它的寻址空间(虚拟地址空间,或叫线性地址空间)为 4G(2的32次方)。也就是说一个进程的最大地址空间为 4G。

*** 作系统的核心是内核( kernel ),它独立于普通的应用程序,可以访问受保护的内存空间,也有访问底层硬件设备的所有权限。为了保证内核的安全,现在的 *** 作系统一般都强制用户进程不能直接 *** 作内核。

具体的实现方式基本都是由 *** 作系统将虚拟地址空间划分为两部分:内核空间,用户空间。

针对 Linux *** 作系统而言,最高的 1G 字节(从虚拟地址 0xC0000000 到 0xFFFFFFFF)由内核使用,称为内核空间。

而较低的 3G 字节(从虚拟地址 0x00000000 到 0xBFFFFFFF)由各个进程使用,称为用户空间。

对上面这段内容我们可以这样理解:

「每个进程的 4G 地址空间中,最高 1G 都是一样的,即内核空间。只有剩余的 3G 才归进程自己使用。」

「换句话说就是, 最高 1G 的内核空间是被所有进程共享的!」

为什么需要区分内核空间与用户空间

*** 作系统大都通过内核空间和用户空间的设计来保护 *** 作系统自身的 安全性 和 稳定性。

  1. 安全性
  2. 稳定性

安全问题:

在 CPU 的所有指令中,有些指令是非常危险的,如果错用,将导致系统崩溃,比如:清内存、设置时钟等。如果允许所有的程序都可以使用这些指令,那么系统崩溃的概率将大大增加。

内核态和用户态

「当进程运行在内核空间时就处于内核态,而进程运行在用户空间时则处于用户态。」

在内核态下,进程运行在内核地址空间中,此时 CPU 可以执行任何指令。运行的代码也不受任何的限制,可以自由地访问任何有效地址,也可以直接进行端口的访问。

在用户态下,进程运行在用户地址空间中,被执行的代码要受到 CPU 的诸多检查,它们 只能访问映射其地址空间 的页表项中规定的在用户态下可访问页面的虚拟地址,且只能对任务状态段(TSS)中 I/O 许可位图(I/O Permission Bitmap)中规定的可访问端口进行直接访问。

如何从用户态进入内核态

所有的系统资源管理都是在内核空间中完成的。比如读写磁盘文件,分配回收内存,从网络接口读写数据等等。我们的应用程序是无法直接进行这样的 *** 作的。但是我们可以通过内核提供的接口来完成这样的任务。

比如应用程序要读取磁盘上的一个文件,它可以向内核发起一个 “系统调用” 告诉内核:“我要读取磁盘上的 XXX 文件”。

通过一个特殊的指令让进程从用户态进入到内核态(到了内核空间),在内核空间中,CPU 可以执行任何的指令,当然也包括从磁盘上读取数据。

具体过程是先把数据读取到内核空间中,然后再把数据拷贝到用户空间并从内核态切换到用户态。(本文前面有详细的介绍)

简单说:就是应用程序把高科技的事情 (从磁盘读取文件) 外包给了系统内核。

用户态的进程必须切换成内核态才能使用系统的资源,那么我们接下来就看看进程一共有多少种方式可以从用户态进入到内核态。

  • 系统调用
  • 软中断
  • 硬件中断
系统调用执行过程
  1. 调用系统调用的封装函数 write(),参数:文件描述符 fd,缓冲区地址 buf,字节数目 count;
  2. 保存当前寄存器状态,将参数写入对应寄存器中,然后执行 syscall 指令,陷入内核

欢迎分享,转载请注明来源:内存溢出

原文地址: https://outofmemory.cn/zaji/5637428.html

(0)
打赏 微信扫一扫 微信扫一扫 支付宝扫一扫 支付宝扫一扫
上一篇 2022-12-16
下一篇 2022-12-16

发表评论

登录后才能评论

评论列表(0条)

保存