深入分析JavaWeb技术内幕(二)—— 深入分析Java I/O的工作机制

一、Java I/O的基本架构

Java的I/O操作类在java.io包下,大概有80多个类,这些类可以分成以下4组:

▶ 基于字节操作的I/O接口:InputStream和OutputStream

▶ 基于字符操作的I/O接口:Reader和Writer

▶ 基于磁盘操作的I/O接口:File

▶ 基于网络操作的I/O接口:Socket

前两组主要是传输数据的数据格式不同,后两组主要是传输数据的传输方式不同,数据格式和传输方式都是影响I/O操作传输效率的关键因素。

二、磁盘I/O的工作机制

        读取和写入I/O操作都调用操作系统提供的接口,因为磁盘设备是由操作系统管理的,应用程序要访问物理磁盘只能通过操作系统调用的方式来工作。其中读和写分别对应read()和write()两个系统调用,而只要是系统调用就可能存在内核空间地址和用户空间地址切换的问题,这是由于操作系统为了保护系统本身的运行安全,而将内核程序运行使用的内存空间和用户程序运行使用的内存空间进行隔离造成的。但是这样虽然保证了内核程序运行的安全性,但是也必然存在数据可能需要从内核空间向用户空间复制的问题。如果遇到非常耗时的操作,比如磁盘I/O,数据从磁盘复制到内核空间,然后又从内核空间复制到用户空间,将会非常缓慢。这时操作系统为了加速I/O访问,在内核空间使用了缓存机制,也就是将从磁盘读取的文件按照一定的组织方式进行缓存,如果用户程序访问的是同一段磁盘地址的空间数据,那么操作系统将从内核缓存中直接取出并返回给用户程序,这样可以减少I/O的响应时间。

1、访问文件的几种方式

① 标准方式的访问

        读取的时候,当应用程序调用read()接口时,操作系统检查在内核的高速缓存中有没有需要的数据,如果已经有缓存了,就直接从缓存中返回,如果没有,则从磁盘中读取,然后缓存在操作系统的缓存中。

        写入的时候,用户的应用程序调用write()接口将数据从用户地址空间复制到内核地址空间的缓存中,这时对用户程序来说写操作就已经完成,至于什么时候再写到磁盘中由操作系统来决定,除非显式的调用了sync同步命令。

② 直接I/O的方式

        所谓直接I/O就是应用程序直接访问磁盘数据,而不经过操作系统的内核数据缓存区,这样做的目的就是减少一次从内核缓存区到应用程序缓存的数据复制,但是直接I/O也有负面影响,如果访问的数据不在应用程序缓存中,那么每次数据都会直接从磁盘进行加载,这种直接加载会非常缓慢。通常直接I/O和异步I/O结合使用,会有比较好的性能。

③ 同步访问文件的方式

        它是指数据的读取和写入都是同步操作的,它与标准访问文件方式不同的是,只有当数据成功写入到磁盘时才返回给应用程序成功的标志,这种访问方式的性能比较差,只有对数据安全性要求比较高的场景中才会使用。通常这种操作方式的硬件都是定制的。

④ 异步访问文件的方式

        这种方式是当数据的线程发出请求之后,线程会接着去处理其他的事情,而不是阻塞等待,当请求的数据返回后继续处理下面的操作,这种方式可以明显的提高应用程序的效率,但是不会改变访问文件的效率。

⑤ 内存映射的方式

        它是指操作系统将内存中的某一块区域与磁盘中的文件关联起来,当要访问内存中的一段数据时,转换为访问文件的某一段数据,这种方式的目的同样是减少数据从内核空间缓存到用户空间缓存的数据复制操作,因为这两个空间的数据是共享的。

2、Java访问磁盘文件

        文件是数据在磁盘中的唯一最小描述,文件也是操作系统和磁盘驱动器交互的最小单元,上层应用程序只能通过文件来操作磁盘上的数据。在Java中通常的File并不代表一个真实存在的文件对象,当你指定一个路径描述符时,它就会返回一个代表这个路径的虚拟对象,这可能是一个真实存在的文件或者是一个包含多个文件的目录。当真正要读取这个文件时,如FileInputStream类就是一个操作文件的类,当在实例化一个FileInputStream对象时,就会创建一个FileDescriptor对象,这个对象就是代表一个真正存在文件的描述对象,当我们在操作一个文件对象时就可以通过getFD()方法获取真正操作的与底层操作系统相关联的描述,例如可以调用FileDescriptor.sync()方法将操作系统缓存中的数据强制刷新到物理磁盘中。

3、Java的序列化技术

        Java序列化就是将一个对象转化成一个二进制表示的字节数组,通过保存或转移这些字节数组来达到数据持久化的目的,需要持久化的类必须继承java.io.Serializable接口。反序列化则是相反的过程,将这个字节数组再重新构造成对象。但是反序列化时,必须有原始类作为模板,才能将这个对象还原。
import java.io.*;

public class SerializeTest implements Serializable {
    private static final long serialVersionUID = 1L;

    private int num = 1390;

    public static void main(String[] args) throws Exception {
        //序列化
        FileOutputStream fos = new FileOutputStream("E:\\Learn\\Test\\深入JavaWeb技术内幕\\JavaIOTest\\SerializeTest.dat");
        ObjectOutputStream oos = new ObjectOutputStream(fos);
        SerializeTest st = new SerializeTest();
        oos.writeObject(st);
        oos.flush();
        oos.close();
        
        //反序列化
        FileInputStream fis = new FileInputStream("E:\\Learn\\Test\\深入JavaWeb技术内幕\\JavaIOTest\\SerializeTest.dat");
        ObjectInputStream ois = new ObjectInputStream(fis);
        SerializeTest st1 = (SerializeTest) ois.readObject();
        System.out.println(st1.num);
        ois.close();
    }
}

当Java的序列化遇到一些复杂情况时的说明:

① 当父类继承Serializable接口时,所有的子类都可以被序列化;

② 子类实现了Serializable接口,父类没有实现,父类中的属性不能被序列化(不报错,但数据会丢失),子类中的属性仍能正确序列化;

③ 如果序列化的属性是对象,则这个对象也必须实现Serizliazble接口,否则会报错;

④ 在反序列化时,如果对象的属性有修改或删减,则修改的部分属性会丢失,但不会报错;

⑤ 在反序列化时,如果serialVersionUID被修改,则反序列化时会失败。

三、网络I/O的工作机制

1、TCP状态转化图


2、TCP的三次握手

        TCP在传输时,连接一方的A由操作系统动态选取一个32位长的序列号(Initial Sequence Number),假设A的初始序列号为1000,以该序列号为原点,对自己将要发送的每个字节的数据进行编号,1001,1002,1003......并把自己的初始序列号ISN告诉B,让B知道什么样编号的数据是合法的,什么编号是非法的,比如900就是非法的,同时B可以对A发送的每一个字节的数据进行确认,如果A收到B的确认编号为2001,则意味着编号为1001-2000的字节,共1000个字节已经安全到达。同理,B也是类似的操作,假设B的初始序列号ISN为2000,以该序列号为原点,对自己将要发送的每个字节数据进行编号,2001,2002,2003......并把自己的初始序列号ISN告诉A,以便A可以确认B发送的每一个字节,然后,如果B收到A确认编号为4001,则意味着字节编号为2001-4000,共2000个字节已经安全到达。所以,由此可以得知, TCP三次握手,握的是通信双方数据原点的序列号。

四次握手的过程如下:

① A发送同步信号SYN(Synchronization)+A的ISN给B;

② B确认收到A的同步信号,并记录A的ISN到本地,命名为B的ACK(Acknowledgement);

③ B发送同步信号SYN+B的ISN给A;

④ A确认到B的同步信号,并记录B的ISN到本地,命名为A的ACK。

很显然2和3的这两个步骤可以合并,只需要三次握手,可以提高连接的速度与效率。

补充:

① 第一个包,即A发给B的SYN中途丢失,没有到达B

A为周期性超时重传,直到收到B的确认;

② 第二个包,即B发送给A的SYN+ACK中途丢失,没有到达A

B会周期性超时重传,直到收到A的确认;

③ 第三个包,即A发送给B的ACK中途丢失,没有到达B

A发完ACK单方面认为TCP为Established状态,而B显然认为TCP为Active状态

a.假定此时双方都没有数据发送,B会周期性超时重传,直到收到A的确认,收到之后B的TCP连接状态也为Established,此时双向可以发包;

b.假定此时A有数据发送,B收到A的数据+ACK,自然会切换到Established状态,并接受A的数据;

c.假定此时B有数据发送,但现在发送不了,B会周期性的超时重传SYN+ACK,直到收到A的确认才可以发送数据。

参考于:https://www.zhihu.com/question/24853633

3、影响网络传输的因素

将一份数据从一个地方正确的传输到另外一个地方所需要的时间就被称之为响应时间,影响响应时间的因素有如下几点:

① 网络带宽:它是指一条物理链路在1s内能够传输的最大比特数(注意是比特不是字节,一个字节8bit);

② 传输距离:也就是数据在光纤中要走的距离,虽然光的转播速度很快,但由于数据在光纤中的移动并不是走直线的,会有一个折射率,大概是光的2/3,这个消耗的时间也就是通常我们所说的传输延时;

③ TCP拥塞控制:TCP传输是一个“停-等-停-等”的协议,传输方和接受方的步调要一致,要达到步调一致就要通过拥塞控制来调节,TCP在传输时会设定一个“窗口”,这个“窗口”的大小是由带宽和RTT(响应时间)来决定的,计算的公式是带宽(b/s)*RTT(s),通过这个值可以得出理论上最优的TCP缓冲区的大小。

4、Java Socket的工作机制

        Socket这个概念并没有对应到一个具体的实体,它描述的是计算机之间完成相互通信的一种抽象功能。大部分情况下,我们使用的都是基于TCP/IP的流Socket,它是一种稳定的通信协议。两个主机之间的应用程序通信,必须通过Socket建立连接,而建立Socket连接必须由底层TCP/IP来建立TCP连接,建立TCP连接需要底层IP来寻址网络中的主机。如果是同一台主机上的应用程序通信就需要通过端口号来指定,这样就可以通过一个Socket实例来唯一代表一个主机上的应用程序的通信链路。

5、建立通信链路

        当客户端要与服务端通信时,客户端首先要创建一个Socket实例,操作系统将为这个Socket实例分配一个没有被使用的本地端口号,并创建一个包含本地地址、远程地址和端口号的套接字数据结构,这个数据结构将会一直保存在系统中,直到这个连接关闭。(在Socket实例被正确返回之前,将会先进行TCP的3次握手协议,握手协议完成后,Socket实例才会创建完成,否则将会抛出IOException错误)
        与之对应的服务端将创建一个ServerSocket实例,这时操作系统也会为ServerSocket实例创建一个底层数据结构,在这个数据结构中包含指定监听的端口号和包含监听地址的通配符,通常情况下都是“*”,即监听所有地址,然后调用accept()方法进行阻塞状态,等待客户端发起请求。当一个新的请求到来时,将为这个连接创建一个新的套接字数据结构,该套接字包含的地址信息和端口信息正在请求的源地址和端口。这个新创建的数据结构将会关联到ServerSocket实例的一个未完成的连接数据结构列表中,这时服务端与之对应的Socket实例并没有完成创建,而是要等与客户端的三次握手完成之后,这个服务端的Socket实例才会返回,并将这个Socket实例对应的数据结构从未完成列表中移到已完成列表中,所以与ServerSocket所关联的列表中每个数据结构都代表与一个客户端建立的TCP连接。

客户端示例:

import java.net.Socket;

public class ApplicationATest {
    public static void main(String[] args) throws Exception {
        //客户端
        System.out.println("开始创建客户端Socket实例......");
        Socket s1=new Socket("192.168.188.1",9001);//IP地址是你自己主机的地址,dos下输入ipconfig查看,端口号与服务端保持一致
        if(s1.isConnected()){
            System.out.println("成功与服务端建立远程连接");
            System.out.println("服务端的远程地址是:"+s1.getRemoteSocketAddress());
            s1.close();
            System.out.println("连接关闭");
        }
    }
}

服务端示例:

import java.net.ServerSocket;
import java.net.Socket;

public class ApplicationBTest {
    public static void main(String[] args) throws Exception {
        //服务端
        System.out.println("开始创建服务端Socket实例......");
        ServerSocket serverSocket = new ServerSocket(9001);//端口号自定义
        System.out.println("开始进入阻塞状态,正在等待客户端发起请求......");
        Socket s2 = serverSocket.accept();
        if (s2.isConnected()) {
            System.out.println("成功与客户端建立连接");
            System.out.println("客户端的远程地址是:" + s2.getRemoteSocketAddress());
            s2.close();
            System.out.println("连接关闭");
        }
    }
}

6、数据传输

        数据传输是我们建立连接的目的,当连接建立成功时,服务端和客户端都会拥有一个Socket实例,每个实例都有一个InputStream和OutputStream,我们可以通过这两个对象来交换数据。网络I/O都是以字节流传输的,当创建Socket对象时,操作系统将会为InputStream和OutputStream分配一定大小的缓存区,数据的写入和读取都是通过这个缓存区来完成的,写入端将数据写到OutputStream对应的SendQ队列中,当数据填满时,数据将被转移到另一端InputStream的RecvQ队列中,如果这时RecvQ已经满了,那么OutputStream的write()方法将会阻塞,直到RecvQ队列有足够的空间容纳SendQ发送的数据。要注意的是,这个缓存区的大小及写入端的速度和读取端的速度非常影响这个连接的数据传输效率,由于可能发生阻塞,所以网络I/O和磁盘I/O不同的是数据的写入和读取还要有一个协调的过程,如果两边同时传送数据可能会产生死锁。

四、NIO的工作方式

可参考链接: 攻破JAVA NIO技术壁垒

1、NIO的工作机制

        Channel和Selector它们是NIO的两个核心概念,Channel要比Socket更加具体,它代表每一个通信信道,Selector它可以轮询每个Channel的状态,还有一个Buffer类,我们可以通过Buffer来控制数据的传输。
示例代码:
public void selector() throws IOException {
        ByteBuffer buffer = ByteBuffer.allocate(1024);
        Selector selector = Selector.open();//调用Selector的静态工厂创建一个选择器
        ServerSocketChannel ssc = ServerSocketChannel.open();//创建一个服务端的Channel
        ssc.configureBlocking(false);//设置为非阻塞方式
        ssc.socket().bind(new InetSocketAddress(8080));//将服务端的Channel绑定到一个Socket对象
        ssc.register(selector, SelectionKey.OP_ACCEPT);//注册监听的事件,将Channel注册到选择器上
        while (true) {//无限循环,保持监听状态
            Set<SelectionKey> keys = selector.keys();//取得所有的key集合
            Iterator<SelectionKey> iterator = keys.iterator();
            while (iterator.hasNext()) {
                SelectionKey key = (SelectionKey) iterator.next();
                if ((key.readyOps() & SelectionKey.OP_ACCEPT) == SelectionKey.OP_ACCEPT) {
                    ServerSocketChannel ssc2 = (ServerSocketChannel) key.channel();//获取这个key所代表的通信信道对象
                    SocketChannel sc = ssc2.accept();//服务端接受请求
                    sc.configureBlocking(false);
                    sc.register(selector, SelectionKey.OP_READ);
                    iterator.remove();
                } else if ((key.readyOps() & SelectionKey.OP_READ) == SelectionKey.OP_READ) {
                    SocketChannel sc = (SocketChannel) key.channel();
                    while (true) {
                        buffer.clear();//将缓冲区的索引状态重置为初始位置
                        int a = sc.read(buffer);//读取数据到buffer
                        if (a <= 0) {//数据读取完毕,跳出循环
                            break;
                        }
                        //将缓存字节数组的指针设置为数组的开始序列即数组下标0,这样就可以从buffer开头,
                        //对该buffer进行读取了,最多只能读取之前写入的数据长度,而不是整个缓冲的容量大小,
                        //如果没有这个方法,就是从buffer最后开始读取,读出来的都是byte=0时候的字符。
                        buffer.flip();
                    }
                    iterator.remove();
                }
            }
        }
    }

        在上面这段程序中,是将Server端的监听连接请求的事件和处理请求的事件放在一个线程中,但是在应用中,我们通常会把它们放在两个线程中,一个线程专门负责监听客户端的连接请求,而且是以阻塞方式执行的;另外一个线程专门负责处理请求,这个专门负责处理请求的线程才会真正采用NIO的方式,比如Web服务器Tomcat和Jetty都是采用这种方式。

下面是基于NIO工作方式的Socket请求处理方式的处理过程:


        Selector可以监听一组Channel上的I/O状态,前提是这些Channel已经注册到Selector中,Selector可以调用select()检查已经注册的通信信道上I/O是否已经准备好,如果没有通信信道状态发生变化,那么select方法会阻塞等待或在超时后返回0,如果多个信道有数据,那么它将会把这些数据分配到对应的Buffer中。所以NIO的关键是有一个线程来处理所有连接的数据交互,而每个连接的数据交互都不是阻塞方式,因此可以同时处理大量的连接请求。

2、Buffer的工作方式

可以把Buffer简单地理解为一组基本数据类型的元素列表,它通过几个变量来保存这个数据的当前位置状态:capacity, position, limit, mark:

索引说明
capacity缓冲区数组的总长度
position下一个要操作的数据元素的位置
limit缓冲区数组中不可操作的下一个元素的位置:limit<=capacity
mark用于记录当前position的前一个位置或者默认是-1

这里写图片描述 

举例:我们通过ByteBuffer.allocate(11)方法创建了一个11个byte的数组的缓冲区,初始状态如上图,position的位置为0,capacity和limit默认都是数组长度。当我们写入5个字节时,变化如下图: 
这里写图片描述

这时我们需要将缓冲区中的5个字节数据写入Channel的通信信道,所以我们调用ByteBuffer.flip()方法,变化如下图所示(position设回0,并将limit设成之前的position的值): 
这里写图片描述

这时底层操作系统就可以从缓冲区中正确读取这个5个字节数据并发送出去了。在下一次写数据之前我们再调用clear()方法,缓冲区的索引位置又回到了初始位置。

调用clear()方法:position将被设回0,limit设置成capacity,换句话说,Buffer被清空了,其实Buffer中的数据并未被清空,只是这些标记告诉我们可以从哪里开始往Buffer里写数据。如果Buffer中有一些未读的数据,调用clear()方法,数据将“被遗忘”,意味着不再有任何标记会告诉你哪些数据被读过,哪些还没有。如果Buffer中仍有未读的数据,且后续还需要这些数据,但是此时想要先先写些数据,那么使用compact()方法。compact()方法将所有未读的数据拷贝到Buffer起始处。然后将position设到最后一个未读元素正后面。limit属性依然像clear()方法一样,设置成capacity。现在Buffer准备好写数据了,但是不会覆盖未读的数据。

调用mark()方法:它将记录当前position的上一次位置,之后可以通过调用reset()方法恢复到这个position。

调用rewind()方法:它可以将position设回0,所以你可以重读Buffer中的所有数据,limit保持不变,仍然表示能从Buffer中读取多少个元素。

3、NIO的数据访问方式

        NIO提供了比传统的文件访问方式更好的方法,NIO有两个优化方法:一个是FileChannel.transferTo、FileChannel.transferFrom;另一个是FileChannel.map。
① FileChannel.transferXXX与传统的访问文件方式相比可以减少数据从内核到用户空间的复制,数据直接在内核空间中移动,在Linux中使用sendfile系统调用。
② FileChannel.map将文件按照一定大小块映射为内存区域,当程序访问这个内存区域时将直接操作这个文件数据,这种方式省去了数据从内核空间向用户空间复制的损耗。这种方式适合对大文件的只读性操作,如大文件的MD5校验。但是这个种方式是和操作系统底层I/O实现相关的。

五、I/O调优

1、磁盘I/O优化

① 增加缓存,减少磁盘访问次数;
② 优化磁盘的管理系统,设计最优的磁盘方式策略、磁盘寻址策略;
③ 设计合理的磁盘存储数据块,以及访问这些数据块的策略,比如我们可以给存放的数据设计索引,通过寻址索引来加快和减少磁盘的访问量,还可以采用异步和非阻塞的方式加快磁盘的访问速度;
④ 应用合理的RAID策略提升磁盘I/O

2、TCP网络参数调优

        要能够建立一个TCP连接,必须知道对方的IP和一个未被使用的端口号,由于32位的操作系统的端口号通常由两个字节来表示,也就是只有2^16=65535个,所以一台主机能够同时建立的连接数是有限的。在Linux中可以通过查看/proc/sys/net/ipv4/ip_local_port_range文件来知道当前这个主机可以使用的端口范围。
[root@192 ~]# cat /proc/sys/net/ipv4/ip_local_port_range
32768   60999
        如果可以分配的端口号偏少,遇到大量并发请求时就会成为瓶颈,由于端口有限导致大量请求等待建立链接,这样性能就压不上去。如果发现有大量的TIME_WAIT时,可以设置/proc/sys/net/ipv4/tcp_fin_timeout为更小的值来快速释放请求。可以使用netstat -n | awk '/^tcp/{++state[$NF]} END {for(key in state) print key,"\t",state[key]}'来查看网络连接情况。

TCP参数调优表如下:
    echo "1024 65535" > /proc/sys/net/ipv4/ip_local_port_range设置向外连接可用端口范围 表示可以使用的端口为65535-1024个(0~1024为受保护的)

  echo 1 > /proc/sys/net/ipv4/tcp_tw_reuse 设置time_wait连接重用 默认0

  echo 1 > /proc/sys/net/ipv4/tcp_tw_recycle 设置快速回收time_wait连接 默认0

  echo 180000 > /proc/sys/net/ipv4/tcp_max_tw_buckets 设置最大time_wait连接长度 默认262144

  echo 1 > /proc/sys/net/ipv4/tcp_timestamps  设置是否启用比超时重发更精确的方法来启用对RTT的计算 默认0

  echo 1 > /proc/sys/net/ipv4/tcp_window_scaling 设置TCP/IP会话的滑动窗口大小是否可变 默认1

  echo 20000 > /proc/sys/net/ipv4/tcp_max_syn_backlog 设置最大处于等待客户端没有应答的连接数 默认2048

  echo 15 > /proc/sys/net/ipv4/tcp_fin_timeout  设置FIN-WAIT状态等待回收时间 默认60

  echo "4096 87380 16777216" > /proc/sys/net/ipv4/tcp_rmem  设置最大TCP数据发送缓冲大小,分别为最小、默认和最大值  默认4096    87380   4194304

  echo "4096 65536 16777216" > /proc/sys/net/ipv4/tcp_wmem 设置最大TCP数据 接受缓冲大小,分别为最小、默认和最大值  默认4096    87380   4194304

  echo 10000 > /proc/sys/net/core/somaxconn  设置每一个处于监听状态的端口的监听队列的长度 默认128

  echo 10000 > /proc/sys/net/core/netdev_max_backlog 设置最大等待cpu处理的包的数目 默认1000

  echo 16777216 > /proc/sys/net/core/rmem_max 设置最大的系统套接字数据接受缓冲大小 默认124928

  echo 262144 > /proc/sys/net/core/rmem_default  设置默认的系统套接字数据接受缓冲大小 默认124928

  echo 16777216 > /proc/sys/net/core/wmem_max  设置最大的系统套接字数据发送缓冲大小 默认124928

  echo 262144 > /proc/sys/net/core/wmem_default  设置默认的系统套接字数据发送缓冲大小 默认124928

  echo 2000000 > /proc/sys/fs/file-max 设置最大打开文件数 默认385583

注意,以上设置都是临时性的,系统重启后就会丢失。

▶ cat /proc/net/netstat:查看TCP的统计信息;
▶ cat /proc/net/snmp:查看当前系统的连接情况;
▶ netstat -s:查看网络的统计信息。

3、网络I/O优化

① 减少网络交互的次数。

        要减少网络交互的次数通常需要在网络交互的两端设置缓存,如Oracle的JDBC就提供了对查询结果的缓存,在客户端和服务器端都有,可以有效减少对数据库的访问。除了设置缓存还可以合并访问请求,比如在查询数据库时,我们要查询10个ID,可以每次查一个ID,也可以一次查10个ID。再比如,在访问一个页面进通常会有多个JS和CSS文件,我们可以将多个JS文件合并在一个HTTP链接中,每个文件用逗号隔开,然后发送到后端的Web服务器。

② 减少网络传输数据量的大小。

        通常的办法是将数据压缩后再传输,比如在HTTP请求中,通常Web服务器将请求的Web页面gzip压缩后再传输给浏览器。还有就是通过设计简单的协议,尽量通过读取有用的协议头来获取有价值的信息,比如在设计代理程序时,4层代理和7层代理都是在尽量避免读取整个通信数据来获取所需要的信息。

③ 尽量减少编码。

        在网络传输中数据都是以字节形式进行传输的,但是我们要发送的数据都是字符形式的,从字符到字节必须编码,但是这个编码过程是比较费时的,所以在经过网络I/O传输时,尽量直接以字节形式发送。

④ 根据应用场景设计合适的交互方式。

a. 同步与异步

        同步就是一个任务的完成需要依赖另一个任务时,只有等待被依赖的任务完成后,依赖的任务才能完成,这是一种可靠的任务序列,要成功都成功,要失败都失败。而异步不需要等待被依赖的任务完成,只是通知被依赖的任务要完成什么工作,只要自己完成了整个任务就算完成了,所以它是不可靠的任务序列,比如打电话和发信息。同步能够保证程序的可靠性,而异步可以提升程序的性能。

b. 阻塞与非阻塞

        阻塞就是CPU停下来等待一个慢的操作完成后,CPU才接着完成其它的工作,非阻塞就是在这个慢的操作执行时,CPU去做其它工作,等这个慢的操作完成时,CPU在完成后续的操作。虽然非阻塞的方式可以明显提高CPU的利用率,但是也可能有不好的效果,就是系统的线程切换会比较频繁。

c. 两种方式的组合

        组合的方式有四种,分别是:同步阻塞、异步阻塞、同步非阻塞、异步非阻塞,这四种方式对I/O性能都有影响,如下所示:

组合方式 性能分析
同步阻塞这种方式I/O性能一般很差,CPU大部分时间处于空闲状态
同步非阻塞这种方式通常能提升I/O性能,但是会增加CPU消耗
异步阻塞这种方式经常用于分布式数据库,比如在一个分布式数据库中,通常有一份是同步阻塞的记录,
还有2~3份会备份一起写到其他机器上,这些备记录通常都采用异步阻塞的方式来写I/O
异步非阻塞这种组合方式用起来比较复杂,只有在一些非常复杂的分布式情况下使用,集群之间的消息同步
机制一般使用这种I/O组合方式,它适合同时要传多份相同的数据到集群中的不同机器,同时数据
的传输量虽然不大却非常频繁的情况

注意:虽然异步和非阻塞能够提升I/O的性能,但是也会带来一些额外的性能成本,比如会增加线程数量从而增加CPU的消耗,同时也会导致程序设计复杂度的上升,如果设计的不合理,反而会导致性能下降。

六、设计模式解析之适配器模式

        适配器就是把一个类的接口变换成客户端所能接受的另一种接口,从而使两个接口不匹配而无法在一起工作的两个类能够在一起工作。通常被用于在一个项目需要引用一些开源框架在一起工作的情况下,这些框架的内部都有一些关于环境信息的接口,需要从外部传入,但是外部的接口不一定能够匹配,在这种情况下,就需要适配器模式来转换接口。

1、适配器模式的类结构


Target(目标接口):客户端期待的接口;

Adaptee(源接口):需要被适配的接口;

Adapter(适配器):将源接口适配成目标接口,继承源接口,实现目标接口。

2、Java I/O中的适配器模式

        InputStreamReader和OutputStreamWriter类分别继承了Reader和Writer接口,但是要创建它们的对象必须在构造函数中传入一个InputStream和OutputStream的实例。InputStreamReader实现了Reader接口,并且持有了InputStream的引用,这里是通过StreamDecoder类间接持有的,因为从byte到char需要经过编码。很显然,适配器就是InputStreamReader类,源接口就是InputStream,目标接口就是Reader类。OutputStreamWriter也是类似的方式。

七、设计模式解析之装饰器模式

        装饰器模式,顾名思义,就是将某个类重新装扮一下,让它的功能变得更加强大,但是作为原来这个类的使用者,还不应该感受到装饰前和装饰后有什么不同,否则就破坏了原有类的结构,所以装饰器模式要做到对被装饰类的使用者透明,这是对装饰器模式的一个要求。

1、装饰器模式的类结构


Component:抽象组件角色,定义一组抽象的接口,规定这个被装饰组件都有哪些功能;

ConcreteComponent:实现这个抽象组件的所有功能;

Decorator:装饰器角色,它持有一个Component对象实例的引用,定义一个与抽象组件一致的接口;

ConcreteDecorator:具体的装饰器实现者,负责实现装饰器角色定义的功能。

2、Java I/O中的装饰器模式

        以FileInputStream为例,InputStream类就是以抽象组件存在的,而FileInputStream就是具体组件,它实现了抽象组件的所有功能。FilterInputStream类无疑就是装饰角色,它实现了InputStream类的所有功能,并且持有InputStream对象实例的引用。BufferedInputStream是具体的装饰器实现者,它给InputStream类附加了功能。这个装饰器类的作用就是使得InputStream读取的数据保存在内存中,从而提高读取的性能。与这个装饰器类有类似功能的是LineNumberInputStream类,它的作用就是提高按行读取数据的功能。

八、适配器模式与装饰器模式的区别

        适配器模式和装饰器模式都有一个别名,就是包装模式,它们看似都是起到包装一个类或对象的作用,但是使用它们的目的很不一样。适配器模式是要将一个接口转变成另一个接口,它的目的是通过改变接口来达到重复使用的目的;而装饰器模式不是要改变被装饰对象的接口,而是恰恰要保持原有的接口,但是增强原有对象的功能,或者改变原有对象的处理方法从而提升性能。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值