likes
comments
collection
share

【面试基础】从select、poll、epoll方法理解java的socket实现

作者站长头像
站长
· 阅读数 4

面试官:有了解socket吗?java中nio下的socket中的BIO和NIO的原理?

:只用过Socket进行一对一的连接。。。

内心oos:面试官为难我,我又不是搞服务端的😭

其实客户端可能比较少接触到BIO,NIO, NIO一般是服务端SocketServer的优化方案。即使客户端有被当做服务端的场景,可能也都是连接比较少的情况,基本上用不上NIO方案。但是多多了解,才能在关键之处体现你是一个年龄和技术同比增长的搬砖人。😎😎😎

一、Socket和IO

我们在了解Socket之前,需要对TCP/IP模型,Mac、IP、端口,以及寻址过程有一个简单的认知。

TCP/IP模型

TCP/IP模型是一个网络通信协议栈,由多个层次组成,用于在计算机网络中实现通信。它是互联网协议套件的基础,由以下几个组成部分构成。它是由IEEE(电气与电子工程协会)提出的标准。

  • 物理层:物理层主要负责传输比特流,通过网络中的传输介质(如电缆、光纤等)来传输数据。

  • 数据链路层:数据链路层负责将数据转换为帧,提供数据的透明传输,检查和纠正传输过程中出现的错误。

  • 网络层:网络层是TCP/IP模型中的关键层,负责实现数据包的路由、寻址和转发,例如IP地址就是在这一层定义的。

  • 传输层:传输层提供端到端的通信服务,主要包括TCP(传输控制协议)和UDP(用户数据报协议),其中端口由传输层使用来标识不同的应用程序。

  • 应用层:应用层包含了各种网络应用和协议,如HTTP、FTP、SMTP等,用于实现特定的网络功能

【面试基础】从select、poll、epoll方法理解java的socket实现

Mac、IP、端口

  • Mac地址

    Mac地址(Media Access Control Address)是数据链路层中用于唯一标识网络设备的硬件地址。Mac地址通常以48位二进制数(通常表示为十六进制数)表示,并由网络适配器厂商分配,在数据链路层中使用Mac地址来定位和标识网络设备。

  • IP

    IP地址(Internet Protocol Address)是网络层中用于唯一标识主机或网络设备的逻辑地址。IP地址以32位二进制数表示,通常以点分十进制数形式(如192.168.1.1)呈现,IP地址通过子网掩码来划分网络和主机部分,以实现网络通信。

  • 端口

    端口是传输层中用于标识发送和接收应用程序的逻辑地址。端口号是一个16位的数值,用于在传输层中将数据包传递到正确的应用程序,TCP和UDP协议使用端口来区分不同的网络服务或应用程序。

  • 寻址过程

    在通信过程中,发送方使用IP地址来识别目标网络设备,数据包通过网络中的路由器根据IP地址进行路由,传输层使用端口号来将数据传送到正确的应用程序,数据从发送端的端口发送到接收端相应的端口。Mac地址在数据链路层中用于最终将数据包从一个网络设备传输到另一个网络设备。

Socket是什么?

在编程中,Socket是一种抽象的通信端点,用于在计算机网络中实现进程之间的通信。它是实现网络通信的一种机制,允许进程在不同计算机之间通过网络互相通信。

  • 通信端点:Socket是两个网络应用程序之间通信的端点。一个Socket位于发送方应用程序,另一个Socket位于接收方应用程序,两个Socket通过网络进行连接和数据交换。
  • IP地址和端口:Socket与一个IP地址和端口相关联,其中IP地址标识主机,端口标识主机上的应用程序。通过IP地址和端口号,应用程序能够在网络上唯一标识和定位通信对方。
  • 协议栈:Socket是基于网络协议栈(如TCP/IP协议栈)的实现。它可以使用不同的协议(如TCP、UDP等)来进行通信。
  • 发送和接收数据:通过Socket,应用程序可以通过发送和接收数据来进行通信。数据可以是文本、文件、多媒体内容等。
  • 面向连接和无连接:Socket可以是面向连接的(如TCP Socket),也可以是无连接的(如UDP Socket)。面向连接的Socket提供可靠的数据传输,而无连接的Socket更轻量和适用于实时性要求不高的场景。

Socket在编程中是一个关键概念,允许不同计算机上的应用程序之间通过网络进行通信。程序员可以通过Socket接口与网络通信相关的函数来控制数据的发送和接收,从而实现各种网络应用。

套接字描述符

在Linux中,Socket使用套接字描述符的概念来进行通信。套接字描述符是一个整数,用于唯一标识一个打开的套接字(Socket)。每当应用程序创建一个套接字时,内核会分配一个唯一的套接字描述符,应用程序可以使用该描述符进行对套接字的操作和通信。 套接字描述符与文件描述符有共同的性质,因此也可以通过文件描述符进行处理。以下是关于套接字描述符、文件描述符、打开文件表和i-node表之间的关系:

【面试基础】从select、poll、epoll方法理解java的socket实现

  • 文件描述符表:在Linux中,每个进程都有一个文件描述符表,它用于跟踪和管理进程打开的文件描述符
  • 打开文件表:记录系统说有打开文件的集合,所有进程共享,每个表项包括文件打开位置,引用计数(指向该文件的文件描述符数量),i-node表对应指针。
  • i-node表:i-node表是指存储在磁盘上的索引节点(i-node)表,用于维护文件系统中所有文件和目录的元数据,所有进程共享,每个文件或目录都对应一个i-node,其中包含有关文件的元数据信息,如文件大小、权限、所有者等。

在Unix/Linux系统中,一切皆文件的思想使得文件描述符被广泛应用。套接字描述符作为文件描述符的一种特例,使得对于Socket和其他文件操作的统一处理更为便捷。应用程序可以使用一致的方式操作文件和套接字。Linux内核通过文件描述符表来管理所有已打开的文件和套接字。每个进程都有一个独立的文件描述符表,其中套接字描述符被分配一定范围的整数,用于标识打开的套接字。套接字描述符可以与select、poll、epoll等I/O多路复用机制配合使用,实现高效的事件驱动编程。通过在一组套接字描述符上等待I/O事件,应用程序可以同时管理多个套接字的异步通信。

Linux的内核的socket

结构体和基本socket函数

在Linux中,socket的结构体定义在<sys/socket.h>头文件中,常见的socket结构体如下

struct sockaddr {
    sa_family_t sa_family; // 地址族(例如AF_INET)
    char sa_data[14]; // 地址数据
};

struct sockaddr_in {
    sa_family_t sin_family; // 地址族(AF_INET)
    in_port_t sin_port; // 端口号
    struct in_addr sin_addr; // IPv4地址
};

struct sockaddr_in6 {
    sa_family_t sin6_family; // 地址族(AF_INET6)
    in_port_t sin6_port; // 端口号
    uint32_t sin6_flowinfo; // 流信息
    struct in6_addr sin6_addr; // IPv6地址
    uint32_t sin6_scope_id; // 作用域 ID
};

基本的Socket接口函数包括:

  • socket():创建新的套接字
  • bind():将套接字绑定到地址和端口
  • listen():监听传入的连接请求
  • accept():接受传入的连接
  • connect():连接到远程套接字
  • write()/send()/sendto():发送数据
  • read()/recv()/recvfrom():接收数据
  • close():关闭套接字连接 这些函数可以用于创建、绑定、连接和进行数据传输等操作。它们提供了基本的网络编程接口,使得程序能够实现网络通信功能。

Socket可以基于tcp也可以基于udp

基于TCP的socket的代码示例

  • 多进程服务端TCP通信
#include<stdio.h>
//字节序
#include<arpa/inet.h>
//socket通信
#include <sys/types.h>
#include <sys/socket.h>
//exit
#include<unistd.h>
#include<stdlib.h>
#include<string.h>
//信号捕捉,子进程回收
#include<errno.h>
#include <signal.h>
#include <sys/wait.h>
int main() { 
    //创建socket,准备好FD资源
    int lfd = socket(AF_INET,SOCK_STREAM,0);
    char *hello = "Hello,from server";
    if(lfd == -1) {
        perror("socket");
        exit(-1);
    }
    //绑定本机ip地址和端口,
    struct sockaddr_in saddr;
    saddr.sin_family = AF_INET;
    saddr.sin_port = htons(9999);
    saddr.sin_addr.s_addr = INADDR_ANY;
    //绑定FD资源与端口号进行关联
    int ret = bind(lfd,(struct sockaddr*)&saddr,sizeof(saddr));
    if(ret == -1) {
        perror("bind");
        exit(-1);
    }
    //监听连接,判断当前资源是否可用
    ret = listen(lfd,8);
    if(ret == -1) {
        perror("listen");
        exit(-1);
    }
    //循环接收客户端连接
    list lst ;
    while(1) {

        struct sockaddr_in caddr;
        int len = sizeof(caddr);
        //连接已经建立,已经完成三次握手
        int cfd = accept(lfd, (struct sockaddr*)&caddr, reinterpret_cast<socklen_t *>(&len));
        if(cfd == -1) {
            if(errno == EINTR) continue;
            perror("accept");
            exit(-1);
        }

        //创建子进程,输出客户端信息并进行通信 
        //子进程 = java线程
        pid_t spid = fork(); 
        if(spid == 0) {
            //子进程,输出客户端ip 和端口号
            char cip[16];
            inet_ntop(AF_INET,&caddr.sin_addr.s_addr,cip,strlen(cip));
            unsigned short cport = ntohs(caddr.sin_port);
            printf("Client ip is %s and port is %d\n",cip,cport);
            //创建接收缓冲区
            char revbuf[1024];
            while(1) {
                //阻塞等待接收客户端信息
                int rlen = read(cfd,revbuf,sizeof(revbuf));
                list.add(rlen); 
                if(rlen == -1) {
                    perror("read");
                    exit(-1);
                } else if(rlen > 0) {
                    printf("Sever have recieved :%s\n",revbuf);
                } else if(rlen == 0) {
                    printf("client have closed..\n");
                    break;
                }
                sleep(1);
                //发送信息给客户端
                write(cfd,hello,strlen(hello)+1);//加1是为了把 字符串终止符发送过去 以免产生bug 
            }
            //关闭客户端文件描述符
            close(cfd);
            //退出当前子进程
            exit(0);
        }
    }
    //关闭监听描述符
    close(lfd);
    return 0;
}
  • TCP通信的客户端(无多进程)
//
#include<stdio.h>
#include<arpa/inet.h>
#include <sys/types.h>
#include <sys/socket.h>
#include<unistd.h>
#include<stdlib.h>
#include<string.h>
int main() {
    //1创建socket
    int cfd = socket(AF_INET,SOCK_STREAM,0);
    char *hello = "Hello,I'm Client";
    if(cfd == -1) {
        perror("socket");
        exit(-1);
    }
    //2与服务端连接
    struct sockaddr_in saddr;
    saddr.sin_family = AF_INET;
    saddr.sin_port = htons(9999);
    inet_pton(AF_INET,"127.0.0.1",&saddr.sin_addr.s_addr);
    int ret = connect(cfd,(struct sockaddr *)&saddr,sizeof(saddr));
    if(ret == -1) {
        perror("connect");
        exit(-1);
    }
    //3通信
    char revbuf[1024];
    int i = 0;
    while(1) {  
        //发送消息
        write(cfd,hello,strlen(revbuf)+1);//加1是为了把 字符串终止符发送过去  
        //接收服务端信息
        int len = read(cfd,revbuf,sizeof(revbuf));
        if(len == -1) {
            perror("read");
            exit(-1);
        } else if(len > 0) {
            printf("Client have recieved :%s\n",revbuf);
        } else if(len == 0) {
            printf("Sever have closed..");
            break;
        }
    }
    //4关闭
    close(cfd);
    return 0;
}

【面试基础】从select、poll、epoll方法理解java的socket实现

基于UDP的socket的代码示例

  • 服务端
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <arpa/inet.h>

int main() {

    // 1.创建一个通信的socket(这里是SOCK_DGRAM数据报格式!!!)
    int fd = socket(PF_INET, SOCK_DGRAM, 0);
    if(fd == -1) {
        perror("socket");
        exit(-1);
    }
    struct sockaddr_in addr;
    addr.sin_family = AF_INET;
    addr.sin_port = htons(9999);
    addr.sin_addr.s_addr = INADDR_ANY;

    // 2.绑定
    int ret = bind(fd, (struct sockaddr *)&addr, sizeof(addr));
    if(ret == -1) {
        perror("bind");
        exit(-1);
    }
     char *hello = "hello , i am server";
    // 3.通信
    while(1) {
        char recvbuf[128];
        char ipbuf[16];

        struct sockaddr_in cliaddr;
        int len = sizeof(cliaddr);

        // 接收数据
        int num = recvfrom(fd, recvbuf, sizeof(recvbuf), 0, (struct sockaddr *)&cliaddr, &len);
        // 发送数据
        sendto(fd, hello, strlen(hello) + 1, 0, (struct sockaddr *)&cliaddr, sizeof(cliaddr));

    }

    close(fd);
    return 0;
}

  • 客户端
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <arpa/inet.h>

int main() {

    // 1.创建一个通信的socket
    int fd = socket(PF_INET, SOCK_DGRAM, 0);
    if(fd == -1) {
        perror("socket");
        exit(-1);
    }
    // 服务器的地址信息
    struct sockaddr_in saddr;
    saddr.sin_family = AF_INET;
    saddr.sin_port = htons(9999);
    inet_pton(AF_INET, "127.0.0.1", &saddr.sin_addr.s_addr);
    char *hello = "hello , i am client";
    int num = 0;
    // 3.通信
    while(1) {
        // 发送数据
        sendto(fd, hello, strlen(hello) + 1, 0, (struct sockaddr *)&saddr, sizeof(saddr));
        // 接收数据
        char sendBuf[1024];
        int num = recvfrom(fd, sendBuf, sizeof(sendBuf), 0, NULL, NULL);
        printf("server say : %s\n", sendBuf);
    }
    close(fd);
    return 0;
}

上面这两个基于基本的socket系统内核的函数,演示了基于TCP和UDP的建立连接过程。java中的JDK在jni底层,BIO也就是根据不同内核函数实现了java的Socket和ServerSocket。

二、select、poll、epoll原理理解

原始的方案是服务端每次和一个客户端建立连接,就建立一个线程建立连接通信,对于服务器一般系统内核都有使用线程最大数量限制的。显然这种阻塞IO不能满足服务端需求,于是提出了NIO的方案。jdk在Linux中对应的系统调用函数select、poll、epoll实现NIO。

select

select 是一个多路复用函数,在 Linux 中用于监视多个文件描述符的状态变化。可以同时监听多个文件描述符,当其中某个文件描述符就绪时,select 函数会返回,通知程序可以执行 I/O 操作。 下面是一个使用select服务端代码示例

#include <stdio.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <stdlib.h>
#include <string.h>
#include <sys/select.h>

int main() {

    // 创建socket
    int lfd = socket(PF_INET, SOCK_STREAM, 0);
    //省略bind,listen过程。
     ...

    // 创建一个fd_set的集合,存放的是需要检测的文件描述符
    fd_set rdset, tmp;//tmp 的目的是防止本地的fd_set 文件被内核改变   
    FD_ZERO(&rdset);
    FD_SET(lfd, &rdset);
    // lfd = 5
    int maxfd = lfd;

    while(1) {
        tmp = rdset;
        // 调用select系统函数,让内核帮检测哪些文件描述符有数据
        int ret = select(maxfd + 1, &tmp, NULL, NULL, NULL);
        if(ret == -1) {
            perror("select");
            exit(-1);
        } else if(ret == 0) {
            continue;
        } else if(ret > 0) {
            // 说明检测到了有文件描述符的对应的缓冲区的数据发生了改变
            //判断fd对应的标志位是0还是1
            if(FD_ISSET(lfd, &tmp)) {
                // 表示有新的客户端连接进来了
                struct sockaddr_in cliaddr;
                int len = sizeof(cliaddr);
                int cfd = accept(lfd, (struct sockaddr *)&cliaddr, &len);

                // 将新的文件描述符加入到集合中
                FD_SET(cfd, &rdset);

                // 更新最大的文件描述符
                maxfd = maxfd > cfd ? maxfd : cfd;
            }

            //遍历内核发回来的 fd_set  接收有数据的文件描述符 
            for(int i = lfd + 1; i <= maxfd; i++) { 
                //判断fd对应的标志位是0还是1 
                if(FD_ISSET(i, &tmp)) {
                    // 说明这个文件描述符对应的客户端发来了数据
                    char buf[1024] = {0};
                    int len = read(i, buf, sizeof(buf));
                    if(len == -1) {
                        perror("read");
                        exit(-1);
                    } else if(len == 0) {
                        printf("client closed...\n");
                        close(i);
                        FD_CLR(i, &rdset);
                    } else if(len > 0) {
                        printf("read buf = %s\n", buf);
                        write(i, buf, strlen(buf) + 1);
                    }
                }
            }
        }
    } 
    close(lfd);
    return 0;
}

poll

poll 是类似于 select 的系统调用,但相比于 select 具有更高的效率和更简洁的 API 接口。

#include <stdio.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <stdlib.h>
#include <string.h>
#include <poll.h>


int main() {

    // 创建socket
    int lfd = socket(PF_INET, SOCK_STREAM, 0);
    //省略bind,listen
    ... 
    // 初始化检测的文件描述符数组
    struct pollfd fds[1024];
    for(int i = 0; i < 1024; i++) {
        fds[i].fd = -1;
        fds[i].events = POLLIN;
    }
    fds[0].fd = lfd;
    int nfds = 0;

    while(1) {

        // 调用poll系统函数,让内核帮检测哪些文件描述符有数据
        int ret = poll(fds, nfds + 1, -1);//-1表示阻塞(当有客户端接入进来才不阻塞)
        if(ret == -1) {
            perror("poll");
            exit(-1);
        } else if(ret == 0) {
            continue;
        } else if(ret > 0) {
            // 说明检测到了有文件描述符的对应的缓冲区的数据发生了改变
            if(fds[0].revents & POLLIN) { 
                // 表示有新的客户端连接进来了
                struct sockaddr_in cliaddr;
                int len = sizeof(cliaddr);
                int cfd = accept(lfd, (struct sockaddr *)&cliaddr, &len);

                // 将新的文件描述符加入到集合中
                for(int i = 1; i < 1024; i++) {
                    if(fds[i].fd == -1) {
                        fds[i].fd = cfd;
                        fds[i].events = POLLIN;
                        break;
                    }
                } 
                // 更新最大的文件描述符的索引
                nfds = nfds > cfd ? nfds : cfd;
            }

            for(int i = 1; i <= nfds; i++) {
                if(fds[i].revents & POLLIN) { 
                    // 说明这个文件描述符对应的客户端发来了数据
                    char buf[1024] = {0};
                    int len = read(fds[i].fd, buf, sizeof(buf));
                    ... 
                }
            }
        }
    } 
    close(lfd);
    return 0;
}

epoll

epoll 是 Linux 下的一种 I/O 事件通知机制,相较于 select 和 poll 更加灵活和高效,尤其用于高并发环境

#include <stdio.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <stdlib.h>
#include <string.h>
#include <sys/epoll.h>
int main() {
     // 创建socket
    int lfd = socket(PF_INET, SOCK_STREAM, 0);
    //省略bind,listen
    ... 
    
    // 调用epoll_create()创建一个epoll实例
    int epfd =  epoll_create(100);  
    if(epfd == -1) {
        perror("epollCreat");
        exit(-1);
    }

    struct epoll_event epev;
    epev.events = EPOLLIN;
    epev.data.fd = lfd;
    int ret_epc = epoll_ctl(epfd, EPOLL_CTL_ADD, lfd, &epev);
    if(ret_epc== -1) {
        perror("epoll_ctl");
        exit(-1);
    }
    struct epoll_event epevs[1024];//保存了发送了变化的文件描述符的信息

    while(1) {
        int ret_wat = epoll_wait(epfd,epevs,1024,-1);
        if(ret_wat== -1) {
            perror("epoll_wait");
            exit(-1);
        }

        printf("ret_wat = %d\n", ret_wat);//输出当前正在操作的客户端数
        //遍历查找有变化的文件描述符
        for(int i = 0 ;i < ret_wat;i++) {

            int cur_fd = epevs[i].data.fd;

            if(cur_fd == lfd) {
                //检测到客户端连接进来;
                struct sockaddr_in cliaddr;
                int len = sizeof(cliaddr);
                int cfd = accept(lfd, (struct sockaddr *)&cliaddr, reinterpret_cast<socklen_t *>(&len));
                if(cfd== -1) {
                    perror("accept");
                    exit(-1);
                }

                //设置对应的客户端信息
                epev.events = EPOLLIN;
                epev.data.fd = cfd;
                epoll_ctl(epfd, EPOLL_CTL_ADD, cfd, &epev);//将新的文件描述符添加到epev。
            } else {

                if(epevs[i].events & EPOLLOUT) {// EPOLLOUT是输出事件,服务端发送给客户端。
                    continue;
                }
                // 有数据到达,需要通信
                char buf[1024] = {0};
                int len = read(cur_fd, buf, sizeof(buf));
                if(len == -1) {
                    perror("read");
                    exit(-1);
                } else if(len == 0) {
                    printf("client closed...\n");
                    //需要在内核先删除当前文件描述符 再关闭,最后一个参数可以是NULL
                    epoll_ctl(epfd, EPOLL_CTL_DEL, cur_fd, NULL);
                    close(cur_fd);
                } else if(len > 0) {
                    printf("read buf = %s\n", buf);
                    write(cur_fd, buf, strlen(buf) + 1);
                }
            }
        }
    } 
    close(epfd);
    close(lfd);
    return 0;
}

epoll_create: 用于创建一个 epoll 实例,返回一个文件描述符,类似于打开一个文件。这个文件描述符用于标识 epoll 实例,后续的 epoll 操作都要使用该文件描述符。

epoll_ctl: 用于操作(添加、修改或删除)文件描述符与 epoll 实例之间的关联关系。通过 epoll_ctl 函数可以注册文件描述符,监听特定事件,以及进行事件的管理操作。

epoll_wait: 当有事件发生时,使用 epoll_wait 函数来等待事件的到来。它会阻塞当前进程,直到某个文件描述符上有事件发生或超时为止。一旦有事件发生,epoll_wait 会返回就绪的文件描述符列表供进程处理。

关于epoll函数的用法还有很多,比如设置为非阻塞模式,设置工作模式LT(水平触发),ET(边沿触发),就不多赘述了。此外它在Android 内核很多场景都有使用到过,这个后面再讲。

对比总结

调用 select 或 poll 函数后,会一直等待,直至有文件描述符就绪或超时才会返回,epoll可以设置为阻塞或非阻塞模式。在默认情况下,epoll 是阻塞的,会一直等待事件到来。

  • select 支持所有类型的文件描述符监视,包括读、写和异常等,由于FD_SET最大只有1024,因为数据结构问题每次只能监听1024个fd, 适用于简单的场景,需要同时监听大量文件描述符时就会效率较低。此外每次调用 select 都需要把文件描述符集合从用户态拷贝到内核态,开销较大,比较适合小型网络应用、少量连接并发的情况。
  • poll 相对于 select 更为高效,没有文件描述符数量限制。监控大量文件描述符时相比于 select 有更好的性能,因为不需要复制文件描述符集合。适用于监听多个文件描述符的情况。但是仍然需要轮询文件描述符,效率略低于 epoll。比较适合中等规模的网络应用,需要监听多个连接并发时。
  • epoll 是 Linux 中性能最好的多路复用 I/O 监听机制,适用于高并发和大规模连接的场景,采用事件通知的方式,只告诉应用程序哪些文件描述符就绪,避免了轮询。但是仅在 Linux 内核上可用,不具备跨平台性。适合大规模的高性能服务器,需要高并发连接的情况。

三、java网络编程IO模型:BIO、NIO

在Java网络编程中,BIO(Blocking I/O)和 NIO(Non-blocking I/O)是两种不同的I/O模型。一个是阻塞式IO,一个是非阻塞式IO.

java 的BIO

在 BIO 模型中,I/O 操作是阻塞的,即当应用程序调用读写操作时,如果没有数据可用或无法写出数据,线程将被阻塞,直到数据准备好或操作完成。

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.PrintWriter;
import java.net.ServerSocket;
import java.net.Socket;

public class MultiThreadedBIOServer {

    public static void main(String[] args) {
        try {
            ServerSocket serverSocket = new ServerSocket(8080);
            System.out.println("Server started");

            while (true) {
                Socket clientSocket = serverSocket.accept();
                new ClientHandler(clientSocket).start();
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    static class ClientHandler extends Thread {
        private final Socket clientSocket;

        public ClientHandler(Socket socket) {
            this.clientSocket = socket;
        }

        public void run() {
            try {
                BufferedReader reader = new BufferedReader(new InputStreamReader(clientSocket.getInputStream()));
                PrintWriter writer = new PrintWriter(clientSocket.getOutputStream(), true);

                String inputLine;
                while ((inputLine = reader.readLine()) != null) {
                    System.out.println("Received from client: " + inputLine);
                    writer.println("Echo: " + inputLine);
                }
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }
}

ClientHandler 线程在实例化时会获得客户端的 Socket 对象,然后处理客户端的输入和输出流。这种方式可以实现多个客户端同时连接服务器,每个客户端的请求都在单独的线程中处理,互不影响。

Java 的NIO

NIO 模型是事件驱动的,通过 Selector 监控多个通道,当通道有数据准备好或接收到写入时,通知应用程序处理,而不需要线程阻塞等待数据。在 Java NIO 模型中,Buffer、Channel 和 Selector 是重要的概念。

  • Buffer:Buffer 是 NIO 中的数据容器,用于在通道和应用程序之间传输数据。Buffer 提供了一组方法来读取和写入数据,允许在不同类型的数据和不同 I/O 操作之间进行转换。
  • Channel:Channel 是 NIO 中连接数据源和目标的通道,可用于读取和写入数据。Channel 可以是文件、网络连接、管道等,它们提供了以块的方式传输数据的能力,比较适合于非阻塞 I/O 操作。
  • Selector:Selector 是用于监听多个通道的选择器,可以检测通道上是否有事件发生,如数据可读、数据可写等。Selector 允许在单个线程上处理多个 Channel,避免了为每个 Channel 创建线程,并提高了系统的效率。

工作流程:

  1. 创建 Selector,并将 Channel 注册到 Selector 上,关注特定的事件(如读、写等)。
  2. Selector 不断地轮询注册在其上的 Channel,处理准备就绪的事件。
  3. 当某个 Channel 上有事件发生时,Selector 立即返回事件,并返回该事件所对应的 SelectionKey。
  4. 根据事件类型对 Channel 执行相应的操作(如读取数据、写入数据等)。
  5. 处理完事件后,继续下一轮循环,等待新的事件发生。

以下是一个简单的 Java NIO 示例:

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 NIOExample {

    public static void main(String[] args) throws Exception {
        // 创建 Selector
        Selector selector = Selector.open();

        // 创建 ServerSocketChannel,并绑定端口
        ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
        serverSocketChannel.socket().bind(new InetSocketAddress(8080));
        serverSocketChannel.configureBlocking(false);//非阻塞
        serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);

        while (true) {
            // Selector 轮询 Channel 事件
            selector.select();

            // 获取准备就绪的事件集合
            Set<SelectionKey> selectedKeys = selector.selectedKeys();
            Iterator<SelectionKey> iterator = selectedKeys.iterator();

            while (iterator.hasNext()) {
                SelectionKey key = iterator.next();
                iterator.remove();

                if (key.isAcceptable()) {
                    // 有新的连接事件
                    ServerSocketChannel channel = (ServerSocketChannel) key.channel();
                    SocketChannel client = channel.accept();
                    client.configureBlocking(false);
                    client.register(selector, SelectionKey.OP_READ);
                } else if (key.isReadable()) {
                    // 有数据可读事件
                    SocketChannel socketChannel = (SocketChannel) key.channel();
                    ByteBuffer buffer = ByteBuffer.allocate(1024);
                    int bytesRead = socketChannel.read(buffer);
                    if (bytesRead == -1) {
                        socketChannel.close();
                        key.cancel();
                        continue;
                    }
                    buffer.flip();
                    byte[] data = new byte[buffer.remaining()];
                    buffer.get(data);
                    System.out.println("Received: " + new String(data));
                }
            }
        }
    }
}

Selector 用于监听 ServerSocketChannel 上的连接事件和 SocketChannel 上的读事件,并处理这些事件。在循环中,Selector 会不断地检查哪些 Channel 准备就绪,然后处理相应的操作。这种方式可以很有效地处理多个连接,而不需要为每个连接创建一个线程。

AIO是NIO的升级,有兴趣的小伙伴自己去了解吧,我就不多说了

四、总结

好了,上面大概讲完了Java中的Socket在BIO和NIO两种模型下,底层Socket中的调用系统内核基本函数实现的原理。由于篇幅有限,没有给大家看java中底层Socket的对于底层基本函数实现调用和封装的代码。避免把小伙伴们看晕了,通过c代码示例来分别讲解对于NIO和BIO的实现过程,道理是相同了,有余力的小伙伴可以自己去看openjdk或者Android 源码哈!

其实本文是很基础的东西,只是很多搞Android开发一上来就是简单的java语法,Android页面业务,忽略了这些基础知识,或者知道的也是概念,过两天就忘。最后,看完本文简单描述问答一下开头面试官的问题。

使用过socket吗?java中nio下的socket中的BIO和NIO的原理?

:首先socket属于TCP/IP协议模型中,内核对于传输层到应用层的抽象封装。java是基于底层socket结构体和socket基本函数和内核基本函数来实现BIO和NIO的。socket中的BIO中服务端在创建socket套接字,bind端口号,建立监听,当客户端和服务端完成TCP三次握手,开始accpet建立连接,由于read是阻塞的,此时就会fork一个线程,来专门处理与该客户端的通讯。因为系统内核对于线程有限制,这种方式只适合建立连接数很少的情况,于是有了IO复用的NIO方案出现,java的NIO模型有基于select或者poll或者epoll实现的,epoll由于多路复用 I/O 监听机制,适用于高并发和大规模连接的场景,最为高效,NIO模型可以避免建立多个线程来和客户端通讯,但是这种方案适合计算资源多的服务器后端。如果真的有客户端有作为服务端的场景,也是推荐线程池下线程复用方案的BIO。