Java网络编程基础篇

一、前言

网络通讯在系统交互中是必不可少的一部分,无论是面试还是工作中都是绕不过去的一部分,本节我们来谈谈Java网络编程中的一些知识,本chat内容如下:

  • 网络通讯基础知识,剖析网络通讯的本质和需要注意的点
  • 使用Java BIO阻塞套接字 实现简单TCP网络通讯
  • 使用Java NIO 非阻塞套接字实现简单非阻塞TCP网络通讯
  • JavaIO模型与Java NIO中ByteBuffer

二、 网络通讯基础知识

网络通讯的本质用一句话来说是处于两个主机上的两个进程之间进行通讯,如下图:


Java网络编程基础篇_第1张图片
image.png

如上图主机A和B上面有好多进程,比如QQ进程,手淘进程,微信进程,浏览器进程等等。
这里假如进程1为微信进程,在应用层微信肯定自己约定了自己的应用成层协议(比如约定协议包为协议头+消息内容)。

那么当主机A上的微信用户给主机B上的微信用户发送消息时候,发送的消息内容要首先经过程序把要发送的数据转换为自己的应用层协议格式的数据,把消息转换为应用层包是在用户程序代码里面做的。做完这些后网卡驱动程序会接着把应用层包转换为运输层的tcp包或者udp包,在运输层会把应用层包作为数据,然后在数据包前添加协议头组成运输层包,协议头里面会包含目的地址的网络端口号。

然后运输层的包会被作为数据包的数据部分,然后在数据部分前面添加ip层的头部部分,头部里面会含有当前主机的ip 和目的地址的ip组成ip层的包

ip层的包最后会被转换为数据链路层的数据包帧,在帧的头部会新增当前主机网卡mac地址和下一跳的主机的Mac地址,注意这里不是目的主机的mac地址,因为在源主机和目的主机之间很可能有好多路由器,这时候下一跳的mac地址就是当前主机连接的路由器的Mac地址。

Java网络编程基础篇_第2张图片
image.png

最后数据链路层的数据帧会被转换会在物理层通过二进制流通过网络传递到网络上,网络流经过路由器时候路由器会首先把二进制流转换为数据链路层的数据帧,然后转换为网络层的ip数据包,然后读取目的地址的ip,然后查找路由表进行路由选择,然后把ip数据包重新转换为数据链路层的帧,这时候数据帧里面的目的Mac地址是路由选择的主机的Mac地址,然后把数据帧通过物理层透明的把二进制流传递到下一站,如果下一站就是目的ip所在主机,则网卡驱动会吧二进制流依次转换为 数据链路层数据帧、ip包、传输层tcp包或者udp包,最后交给应用程序进程进行处理,应用程序转换包为具体数据然后进行处理。

这里需要注意的是网络层只是能确定目的主机,还记得ip包里记录了目的主机的ip,但是一个主机上可能会有多个进程,那么具体把数据交给那个进程进行处理那?这个就是运输层的作用,运输层包里面记录的端口号,运输层会把数据交给具体端口号的进程。即网络通讯的socket地址实际是 ip+端口号。

另外整个通讯过程中传输的是二进制流,没有业务数据包的概念(比如我发送了一个业务请求包),当发送方发了多个业务包后,接受方的运输层并不知道每个包的边界,它只是把接受到的数据传输给应用层,所以应用层要自己根据约定好的协议解析二进制流为业务所需要的包,即半包粘包问题,可以参考(https://gitbook.cn/gitchat/activity/5b13e6a675742e21d6d14ea4)。

另外由于网络传输的都是二进制流,所以在发送方进程需要把要发送的数据(比如本文字符串)进行序列化为二进制流,而接受方应用层需要根据对应的反序列化把二进制流转换为具体的数据(比如文本字符串),即序列化与反序列化问题。

另外路由器只有网络层,数据链路层,物理层三层结构。

一次网络通讯看此很复杂,中间需要做好多协议包的转换,但是好在除了应用层协议部分是需要应用程序自己来做,其他下层都是由网卡驱动程序来完成的,这些对应用程序是透明的。
...

五、 Java IO模型与Java NIO中ByteBuffer

5.1 Java IO模型

Java网络编程基础篇_第3张图片
image.png

如上图当网络应用进程向socket写入数据时候,首先需要在应用程序内申请一个写buffer,然后把数据写入到写buffer,然后应用程序的执行会用用户态切换到核心态,核心态程序把应用程序写buffer里面的数据拷贝到操作系统层面的写缓存里面。当应用程序读取数据时候是需要把操作系统层面的读buffer里面的数据拷贝到应用程序层面的读buffer里面。一般情况下应用程序层面的buffer都是从堆空间里面申请的,这就需要在用户态和核心态之间数据传输时候进行一次数据copy。这是因为核心态是不能直接应用程序堆内存的,必须转换为直接内存。

如果用户态申请的堆外内存(直接内存)那么就会省去中间的拷贝操作,操作系统层面会直接使用用户态申请的堆外内存(直接内存)里面的数据。

5.2 使用ByteBuffer分配堆内存与堆外内存

Java NIO中提供了一个ByteBuffer用来分配发送和接受缓存用的,其分为两种模式的内存,一个是我们常用的堆内存,一个是堆外内存(直接内存)。

  • 当我们调用ByteBuffer的allocate方法时候,实际分配的是堆内存,其代码如下:
    public static ByteBuffer allocate(int capacity) {
        if (capacity < 0)
            throw new IllegalArgumentException();
        return new HeapByteBuffer(capacity, capacity);
    }
    HeapByteBuffer(int cap, int lim) {       
        super(-1, 0, lim, cap, new byte[cap], 0);
    }

可知这里是new了一个byte数组,所以分配的是堆内存。

    ByteBuffer(int mark, int pos, int lim, int cap,  
                 byte[] hb, int offset)
    {
        super(mark, pos, lim, cap);
        this.hb = hb;
        this.offset = offset;
    }

可知其内部是通过hb这个指针来指向了分配的堆内存。

  • 当调用ByteBuffer的allocateDirect方法时候,分配的就是堆外内存:
    public static ByteBuffer allocateDirect(int capacity) {
        return new DirectByteBuffer(capacity);
    }

    protected static final Unsafe unsafe = Bits.unsafe();

 DirectByteBuffer(int cap) {                   // package-private

        super(-1, 0, cap, cap);
        boolean pa = VM.isDirectMemoryPageAligned();
        int ps = Bits.pageSize();
        long size = Math.max(1L, (long)cap + (pa ? ps : 0));
        Bits.reserveMemory(size, cap);
        //1 使用Unsafe分配堆外内存
        long base = 0;
        try {
            base = unsafe.allocateMemory(size);
        } catch (OutOfMemoryError x) {
            Bits.unreserveMemory(size, cap);
            throw x;
        }
       //2 对分配的堆外内存进行初始化
        unsafe.setMemory(base, size, (byte) 0);
        if (pa && (base % ps != 0)) {
            // Round up to page boundary
            address = base + ps - (base & (ps - 1));
        } else {
            address = base;
        }
       //3.创建堆外内存回收器
        cleaner = Cleaner.create(this, new Deallocator(base, size, cap));
        att = null;
    }

可知堆外内存是使用UNSAFE类进行内存分配的。

六、更多

  • 更多参考 Java 网络编程基础篇

  • Java NIO 框架 Netty 之美:基础篇之一

  • Java NIO 框架 Netty 之美:粘包与半包问题

  • Java NIO 框架 Netty 之美:源码分析之一

你可能感兴趣的:(Java网络编程基础篇)