1 VFS
-
VFS:虚拟文件系统
-
是一个软件,启动后被加载进内存
-
树型结构,树上节点可以映射到不同文件上
-
VFS的诞生是为了解耦,对于访问不同类型的文件,提供一套统一接口
cd / #1. 看整个目录树 ll #2. 查看不同目录挂在哪个物理设备 df -h #3. Filesystem:物理文件系统,一个硬盘分为四个分区,分别为sda1、sda2、sda3、sda4 #4. Mounted On:文件系统挂载在虚拟文件系统的哪个目录上 #5. kernel首先将/dev/sda2挂载在了/上,/dev/sda2代表一块硬盘,其内本身就有一些文件夹,例如/boot、/dev、/etc #6. 计算机开机步骤 #a. bios将/dev/sda1中的bootloader加载到内存 #b. bootloader将/dev/sda1中的内核程序加载到内存 #c. 内核将/dev/sda2挂载到虚拟文件系统的/目录上,这块硬盘上本身就有一些文件夹,例如/boot、/dev、/etc #d. 内核将/dev/sda1挂载到虚拟文件系统的/boot目录上,覆盖了原本/dev/sda2中的/boot文件夹,此时就能在/boot中可以看到bootloader了(grub,Grand Unified Bootloader) Filesystem Size Used Avail Use% Mounted on /dev/sda2 18G 5.0G 12G 30% / tmpfs 931M 68K 931M 1% /dev/shm /dev/sda1 283M 62M 207M 23% /boot /dev/sda4 79G 50G 25G 67% /home/oracle .host:/ 655G 374G 282G 58% /mnt/hgfs #7. 卸除虚拟文件系统中/boot目录对应的文件系统 umount /boot #8. 此时发现已没有/boot的挂载,但/boot目录还在,里面没有内容 df -h cd /boot ll #9. 挂载 mount /dev/sda1 /boot
-
-
inode:一个描述文件的数据结构,一个文件对应一个inode
-
pagecache:软件读取文件时,会将文件先分为很多个4k,用到哪个4k就将哪个4k先加载进内存,内存中也是4k大小,叫pagecache,如果两个程序打开同一文件,pagecache只有1份,二者共享
-
FD:文件描述符
-
FD描述了,进程打开的文件名,当前偏移量(seek)等信息,进程按偏移量得知自己当前读到了文件的哪个位置,从而决定接下来读哪个pagecache中内容
-
不同进程打开同一个文件,文件描述符不同
-
同一进程打开多个文件,文件描述符不能相同
-
每个进程都会有0、1、2三个文件描述符,即每个进程都会打开三个文件,一般来说,进程的输入来自于文件描述符0对应的文件,输出信息发送到1对应的文件,错误信息发送到2对应的文件,因此这三个文件描述符通常称为标准输入、标准输出以及标准错误
-
对于bash这个程序,0、1、2文件描述符都对应文件/dev/pts/1,这个文件代表我们链接到linux的终端,这就是为什么我们可以通过终端向bash发送指令,执行命令后,结果又打印到了这个中端上
#1. lsof:查看进程打开的文件 #a. -o:offset,即显示偏移量 #b. -p:指定进程号 #c. $$:特殊的环境变量,值为当前bash进程的pid lsof -op $$ #2. 结果解析 #a. FD:文件描述符,cwd:当前工作目录current world directory,rtd:root directory、根目录,txt:文本域,即进程启动时加载的可执行程序代码的位置、mem:分配的内存空间,将哪些文件读入了内存、0u:标准输入,u表示读写都可以、1u:标准输出、2u:标准错误 #b. TYPE:文件类型,DIR为目录,REG为普通文件,CHR为字符设备 #c. OFFSET:偏移量, COMMAND PID USER FD TYPE DEVICE OFFSET NODE NAME bash 2891 root cwd DIR 0,5 4 /dev bash 2891 root rtd DIR 8,2 2 / bash 2891 root txt REG 8,2 130355 /bin/bash bash 2891 root mem REG 8,2 781867 /lib64/ld-2.12.so bash 2891 root mem REG 8,2 781913 /lib64/libdl-2.12.so bash 2891 root mem REG 8,2 781873 /lib64/libc-2.12.so bash 2891 root mem REG 8,2 781957 /lib64/libtinfo.so.5.7 bash 2891 root mem REG 8,2 914062 /usr/lib/locale/locale-archive bash 2891 root mem REG 8,2 781856 /lib64/libnss_files-2.12.so bash 2891 root mem REG 8,2 914322 /usr/lib64/gconv/gconv-modules.cache bash 2891 root 0u CHR 136,0 0t0 3 /dev/pts/0 bash 2891 root 1u CHR 136,0 0t0 3 /dev/pts/0 bash 2891 root 2u CHR 136,0 0t0 3 /dev/pts/0 bash 2891 root 255u CHR 136,0 0t0 3 /dev/pts/0 #3. 创建一个新文件 vi ooxx.txt #4. 当前进程新建文件描述符8,指向ooxx.txt,且只能从该文件中读取内容 exec 8< ooxx.txt #5. /proc/$$/fd:能看到当前bash进程中所有的文件描述符 #a. /proc:该目录映射了内核中的一些变量和属性,该目录中内容其实不在磁盘上,其内的数字表示进程的pid #b. $$、$BASHPID:特殊的环境变量,值为当前和你互动的bash的pid cd /proc/$$/fd ll lr-x------ 1 root root 64 Jan 10 21:35 8 -> /root/ooxx.txt #6. 查看偏移量,为0 lsof -op $$ bash 36162 root 8r REG 8,2 0t0 147074 /root/ooxx.txt #7. 从文件描述符8对应的文件中,读取一行数据到变量a中 #a. read命令读到换行符后就不再继续读取 #b. 0:read命令的标准输入 #c. <:重定向 #d. &:表示后接文件描述符而不是具体文件 read a 0<& 8 #8. 打印变量a的值 echo $a #9. 再次观察 lsof -op $$ #10. 偏移量变为2,因为/root/ooxx.txt中第一行为d,只1个字符,加上1个换行符,因此此时偏移量变为2 bash 36162 root 8r REG 8,2 0t2 147082 /root/ooxx.txt #11. 新打开一个窗口,即在一个新进程中执行如上命令,发现打开同一个文件时,偏移量和上面的偏移量不同 exec 6< ~/ooxx.txt lsof -op $$ bash 36230 root 6r REG 8,2 0t0 147082 /root/ooxx.txt
-
2 缓存与缓冲
- 缓存:cpu访问一个设备中的数据时,会一直等待数据返回,如果返回速度较慢,可以将该设备中,对于cpu来说常用的那部分数据,放到一个为cpu返回更快的位置
- pagecache:是硬盘的缓存,让cpu能更快访问硬盘中数据
- L1、L2、L3:是内存的缓存,让cpu更快访问内存中数据
- 缓冲:积攒一定量的数据后一起发送,可以降低系统调用次数,解决多次发送造成的性能浪费
3 pagecache和userspace
- 内核空间:内核使用的内存
- 用户空间(userspace):用户使用的内存
- 用户进程可以直接访问用户空间中数据,但想访问内核空间中数据,必须通过系统调用,最终交给内核完成
- pagecache:内核空间的一部分,这部分内存是为了解决cpu读取硬盘数据较慢问题产生
4 Java写文件
-
byte[] data = "123456789\n".getBytes();
:将数据存储在用户空间 -
out.write(data)
- 如果out为FileOutputStream,java的wirte底层会执行linux内核的write,该系统调用会复制处于用户空间的data到pagecache中,此时pagecache状态为dirty,每次java的write方法都会进行系统调用,这个系统调用最后被转为汇编语言后,就是一个cpu指令,int 80,cpu读到这个指令后,就不再执行应用程序的代码,而是转为执行内核中的代码,即cpu从用户态转为内核态,处理完毕后再转回用户态,效率非常低
- 如果out对象如果为BufferedOutputStream,调用write方法,不会进行系统调用,而是将处于用户空间的data,复制到一个在用户空间中提前申请好的一块8k大小的内存中,这块空间叫buffer,当write的数据达到8k后,统一进行一次系统调用,将这块空间的内容复制到pagecache中,这样就能降低系统调用次数
-
将pagecache中数据同步到磁盘,并将pagecache状态重新改为clean
- 内核不定期执行
-
java中调用
out.flush()
#1. 查看脏页数 cat /proc/vmstat | grep dirty #2. 查看内核和刷新脏页相关的配置信息 sysctl -a|grep dirty
5 文件类型
-
linux中一切皆文件,摄像头、打印机等都是文件,既然是文件,就可以用IO对其读写
-
文件类型
ll #第一个字符就代表文件的类型 drwxr-xr-x. 2 root root 4096 Jul 19 00:45 Pictures
-
-:普通文件
-
d:目录
-
b:块设备,可以从任意位置读写的设备,例如硬盘,在/dev中能看到
-
c:字符设备,不能从任意位置读写,例如键盘、终端,在/dev中能看到
-
s:socket,可以理解为是链接的端点的抽象,我们向一个链接其中一端的socket写入数据,另一端的socket就能读取到这些数据
#1. /dev/tcp路径看不到,但实际上存在,创建文件描述符8指向文件/dev/tcp/www.baidu.com/80 exec 8<> /dev/tcp/www.baidu.com/80 lsof -op $$ #2. /dev/tcp/www.baidu.com/80文件,实际上类型就是socket,这种文件是无法通过ll查看的,假如能通过ll查看,那么你会发现,第一个字母是s bash 36323 root 8u IPv4 55139 0t0 TCP node01:54500->39.156.66.18:http (ESTABLISHED)
-
p:管道
#1. 管道符|前指令的输出,会作为|后指令的输入 #2. 查看文件的第8行,只看结果的话,等同于tail -1 <( head -8 test.txt) head -8 test.txt | tail -1 #3. 查看当前进程号:36323 echo $$ #4. 新启动一个bash bash #5. 再次查看进程号:36506 echo $$ #6. 查看父子进程结构 pstree sshd───bash───bash───pstree #7. 查看和36323进程有关的进程 ps -ef |grep 36323 #8. 第一列数字为自身进程号,第二列为该进程父进程号,36323为36506的父进程 root 36323 36321 0 21:55 pts/1 00:00:00 -bash root 36506 36323 0 22:53 pts/1 00:00:00 bash root 36519 36506 0 22:54 pts/1 00:00:00 grep 36323 #9. 创建变量x并赋值 x=100 /bin/bash #10. 子进程中看不到这个值,因为进程间数据隔离 echo $x exit #11. 让x具备导出能力,即定义一个环境变量,这样子进程中就能看到这个值了 export x /bin/bash echo $x #12. {}:定义代码块,代码块中代码都在当前进程依次执行 { echo "sdfsd"; echo "123"; } a=1 { a=9 ; echo "123"; }|cat #13. 发现a的值仍然为1,这说明,bash看到管道符|后,会为|左右两边都启动一个子进程,左边子进程的标准输出是一个管道文件,右边子进程的标准输入也是这个管道文件,因此左边命令的输出可以作为右边命令的输入 echo $a #14. $$优先级高于|,而$BASHPID优先级低于管道 #a. 此处先执行$$,因此命令转换为了echo 36323 | cat,因此最终结果打印的是父进程的pid echo $$ | cat #b. 此处先执行管道,启动子进程,然后在子进程执行echo $BASHPID,因此最终结果打印的是子进程的pid echo $BASHPID | cat #15. 当前进程号为36529 echo $$ #16. 左侧子进程会阻塞住,并打印36542,说明左边子进程的pid是36542 { echo $BASHPID;read x; }|{ cat; } #17. 新启动一个窗口,查看左侧子进程的文件描述符有哪些 cd /proc/36542/fd ll #18. 发现该子进程的文件描述符1(标准输出)指向一个管道 l-wx------ 1 root root 64 Jan 10 23:09 1 -> pipe:[60230] #19. 通过父进程号,查看右侧子进程的pid ps -ef |grep 36529 #20. 右侧子进程号为36543 root 36529 36506 0 22:56 pts/1 00:00:00 /bin/bash root 36542 36529 0 23:07 pts/1 00:00:00 /bin/bash root 36543 36529 0 23:07 pts/1 00:00:00 /bin/bash root 36565 36547 0 23:10 pts/2 00:00:00 grep 36529 cd /proc/36543/fd ll #21. 右侧子进程的文件描述符0(标准输入)来自一个管道,这个管道的inode号为60230 lr-x------ 1 root root 64 Jan 10 23:11 0 -> pipe:[60230] #22. 查看两个子进程打开的文件 lsof -op 36543 #a. FIFO:代表一个先进先出的管道,管道也是文件,其inode号为60230 bash 36543 root 0r FIFO 0,8 0t0 60230 pipe lsof -op 36542 bash 36542 root 1w FIFO 0,8 0t0 60230 pipe
-
eventpoll
-
l:链接,无论硬链接还是软链接,一个修改另一个都会跟着变化
cd /root vi msb.txt #1. 创建硬链接 ln /root/msb.txt /root/xxoo.txt #2. 硬链接 #a. 两个文件的Inode号相同,即磁盘中只有一个物理文件,只不过这个物理文件在虚拟文件系统中有多个路径,可以理解为两个变量msb.txt和xxoo.txt指向同一个物理位置 stat msb.txt stat xxoo.txt #b. 第二列2表示这个文件上硬链接数量 ls -lrt -rw-r--r-- 2 root root 15 Jan 10 17:57 xxoo.txt -rw-r--r-- 2 root root 15 Jan 10 17:57 msb.txt #c. 删除原文件,另一个文件还能正常读写 rm msb.txt vi xxoo.txt #3. 创建软链接 ln -s /root/xxoo.txt /root/msb.txt #a. 软链接类型是l,第二列链接数为1 ls -lrt -rw-r--r-- 1 root root 15 Jan 10 17:57 xxoo.txt lrwxrwxrwx 1 root root 14 Jan 10 18:04 msb.txt -> /root/xxoo.txt #b. 两个文件的Inode号不同 stat msb.txt stat xxoo.txt #c. 删除原始文件,新文件不能打开 rm xxoo.txt vi msb.txt
6 Docker镜像原理
mkdir mashibing
cd mashibing/
#1. dd:将一个文件中数据,按指定要求转换到另一个文件,也可用于备份磁盘分区dd if=/dev/sda1 of=mydisk.img
#a. if:input file,/dev/zero:是一个能产生连续不断的null的文件,通常用于创建一个指定长度、用于初始化的空文件,/dev/null:所有写入/dev/null文件的内容都会丢失,而从/dev/null文件中也读取不到任何内容,通常用于在shell脚本开发中,禁止标准输出cat filename >/dev/null或清除文件内容cat /dev/null > /var/log/messages
#b. of:output file
#c. bs:block size,dd复制文件时,以块为单位,bs用于指定块的大小为1048576字节,因此每个块为1048576/1024/1024=1M
#d. count:数据块的个数,因此最终mydisk.img是一个大小为1M*100=100M的空文件
dd if=/dev/zero of=mydisk.img bs=1048576 count=100
#2. 发现确实为100M
ll -h
#3. /dev/loopN:虚拟环回设备,它能使我们像块设备一样访问一个文件
#4. losetup:使用虚拟环回设备将文件虚拟成块设备
losetup /dev/loop0 mydisk.img
#5. 以ext2格式进行格式化
mke2fs /dev/loop0
#6. 将mydisk.img作为磁盘挂载到虚拟文件系统
mount -t ext2 /dev/loop0 /mnt/ooxx
df
cd /mnt/ooxx
#7. 查看bash程序位置,在/bin/bash,bash用于解析你向linux输入的命令
whereis bash
mkdir bin
cp /bin/bash ./bin
#8. 分析bash使用的动态链接库有哪些,即这个程序启动时,需要哪些函数
ldd /bin/bash
mkdir lib64
#9. {}:花括号扩展,可以同时拷贝多个文件
cp /lib64/{libtinfo.so.5,libdl.so.2,libc.so.6,ld-linux-x86-64.so.2} ./lib64
#10. 将根目录切换到当前目录,并启动bin下的bash
chroot ./
#11. 发现进入到了一个新的bash中
echo $$
#12. ls、vi、cat等命令都没有,无法执行
#13. 将字符串打印到指定文件
echo "hello mashibing" > /abc.txt
#14. 退出当前bash
exit
#15. 回到了原bash中
echo $$
#16. 发现abc.txt不在/下,而是在/mnt/ooxx下
ll
#17. 此时内容都写入到了mydisk.img,这个文件如果给其用户,那么其他用户挂载后也能看到这个文件中的内容,Docker中的镜像的原理也是如此
7 重定向
-
重定向是一种机制,用于将进程的文件描述符指向其他文件
-
重定向的语法要求
- 左边为文件描述符,然后紧跟>或<,中间不能有空格
- 右边为文件名,如果想使用文件描述符,需要使用&
#1. 使用重定向,将ls命令(进程)的文件描述符1(标准输出),指向文件~/ls.out,这样就不会在控制台打印ls命令的输出,而是打印到~/ls.out中 ls ./ 1> ~/ls.out #2. cat命令从ooxx.txt读取数据,并向cat.out中打印数据 cat 0< ooxx.txt 1> cat.out #3. read命令从cat.out中读取数据放入变量a read a 0< cat.out #4. 使用ls命令,分别打印./和/ooxx这两个目录下的内容,ls命令每次都会先打印标准错误再打印标准输出,由于没有/ooxx这个目录,所以得到的结果,会先打印标准错误ls: cannot access /ooxx: No such file or directory,然后再打印标准输出,即./中的内容 ls ./ /ooxx #5. 重定向默认会对文件进行覆盖,因此这样写,会先将标准错误打印到ls03.out,然后再将标准输出覆盖进ls03.out,因此ls03.out中无法看到标准错误中的内容 ls ./ /ooxx 1> ls03.out 2> ls03.out #6. 因此需要让一个文件描述符指向另一个文件描述符,然后另一个文件描述符指向最终文件 #7. 重定向操作符绑定有顺序,先执行2>& 1,此时文件描述符对应的文件还是终端设备,因此2会指向这个终端,再执行1> ls03.out,1指向了文件ls03.out,因此最终结果是,原标准输出打印到了ls03.out,而原标准错误还是打印到了终端 ls ./ /ooxx 2>& 1 1> ls03.out #8. 正确写法 ls ./ /ooxx 1> ls03.out 2> &1
8 Java读文件
- 应用读写的实际上是内存中的数据而不是硬盘中的数据
- java中read方法会被JVM解析成kernel的read,然后被汇编解析成cpu指令int 0x80,cpu接收到这个软中断后,进入内核态,尝试复制pagecache中数据到用户空间
- 如果硬盘中数据还未被加载到pagecache,产生缺页中断,内核将当前进程挂起,被挂起的进程不会被内核调度,之后通知DMA将硬盘数据拷贝到内存,产生DMA之前,将磁盘数据搬运到内存的工作是由cpu完成,对cpu是极大的浪费
- 当DMA完成拷贝后,产生一个新的中断,cpu收到这个中断后,通知内核,内核将之前挂起的进程恢复成可执行状态,同时内核将pagecache中数据复制到用户空间
9 IO基本使用
-
OSFileIO
import java.io.BufferedOutputStream; import java.io.File; import java.io.FileOutputStream; import java.io.RandomAccessFile; import java.nio.ByteBuffer; import java.nio.MappedByteBuffer; import java.nio.channels.FileChannel; public class OSFileIO { static byte[] data = "123456789\n".getBytes(); static String path = "/root/testfileio/out.txt"; public static void main(String[] args) throws Exception { switch (args[0]) { case "0": testBasicFileIO(); break; case "1": testBufferedFileIO(); break; case "2": testRandomAccessFileWrite(); case "3": whatByteBuffer(); default: } } public static void testBasicFileIO() throws Exception { File file = new File(path); FileOutputStream out = new FileOutputStream(file); while (true) { out.write(data); } } public static void testBufferedFileIO() throws Exception { File file = new File(path); BufferedOutputStream out = new BufferedOutputStream(new FileOutputStream(file)); while (true) { out.write(data); } } public static void testRandomAccessFileWrite() throws Exception { RandomAccessFile raf = new RandomAccessFile(path, "rw"); raf.write("hello mashibing\n".getBytes()); raf.write("hello seanzhou\n".getBytes()); System.out.println("write------------"); //1. 此代码就是为了阻塞 System.in.read(); //3. 设置偏移量为4,即从hell后开始继续写入 raf.seek(4); raf.write("ooxx".getBytes()); System.out.println("seek---------"); System.in.read(); //4. 使用新版的FileChannel FileChannel rafchannel = raf.getChannel(); //a. map:实际上就是系统调用mmap,创建文件到虚拟内存的映射,用户进程和内核公用一块内存,这样进程就可以不通过write、read等系统调用,而直接访问这块内存,从而完成对文件读写 //b. 只有FileChannel有map方法,SocketChannel没有,因为File是块设备,可以来回自由寻址 //c. 4096表示将文件out.txt扩展为4k MappedByteBuffer map = rafchannel.map(FileChannel.MapMode.READ_WRITE, 0, 4096); //5. put方法不像write,它不会触发系统调用,因此也不会产生用户态与内核态的切换,因为之前已经使用mmap进行了映射,修改用户空间就相当于修改pagecache,但这种方式如果服务器宕机,仍然会丢数据 map.put("@@@".getBytes()); //6. linux内核实际上有Direct IO,直接IO是忽略linux的pagecache,程序自己实现原来pagecache的功能 //a. 程序自己开辟一个字节数组当作pagecache,DMA将硬盘中数据放到这个数组,并程序还得实现将这个数组中数据刷新到磁盘的功能 //b. 其实和pagecache功能一模一样,只不过能更细粒度地控制一些落盘参数,一般数据库会使用Direct IO System.out.println("map--put--------"); System.in.read(); //7. 类似flush,可以强制刷写 // map.force(); // flush raf.seek(0); ByteBuffer buffer = ByteBuffer.allocate(8192); //8. 堆外分配 // ByteBuffer buffer = ByteBuffer.allocateDirect(1024); //9. 读取数据,放到buffer中 int read = rafchannel.read(buffer); System.out.println(buffer); buffer.flip(); System.out.println(buffer); for (int i = 0; i < buffer.limit(); i++) { Thread.sleep(200); System.out.print(((char) buffer.get(i))); } } public static void whatByteBuffer() { //1. ByteBuffer:类似一个字节数组 //2. 堆内分配 //ByteBuffer buffer = ByteBuffer.allocate(1024); //3. 堆外分配,提升效率 ByteBuffer buffer = ByteBuffer.allocateDirect(1024); //4. 有3个重要属性,postition当前位置、limit最多移动到的位置、capacity当前容量 System.out.println("postition: " + buffer.position()); System.out.println("limit: " + buffer.limit()); System.out.println("capacity: " + buffer.capacity()); System.out.println("mark: " + buffer); //5. put:写入数据,position变大,其他不变 buffer.put("123".getBytes()); System.out.println("-------------put:123......"); System.out.println("mark: " + buffer); //6. flip:由写转为读,将limit移动到之前的position位置(防止读超过之前写的内容),position移动到最开始(从头开始读) buffer.flip(); System.out.println("-------------flip......"); System.out.println("mark: " + buffer); //7. get:获取字符,position后移 buffer.get(); System.out.println("-------------get......"); System.out.println("mark: " + buffer); //8. compact:由读转为写,将原数组中内容都挤压到最开始,然后将position设为原数组中的剩余内容的长度+1,limit放到最后,表示可以写到哪 buffer.compact(); System.out.println("-------------compact......"); System.out.println("mark: " + buffer); //9. 清空内容 buffer.clear(); System.out.println("-------------clear......"); System.out.println("mark: " + buffer); } }
10 内核关于pagecache的参数
-
sysctl -a
:查看内核参数 -
查看和pagecache有关的参数
sysctl -a|grep dirty #1. 修改pagecache相关参数 vi /etc/sysctl.conf #2. 脏页达到内存的一定比例后,刷写磁盘的后台进程就会对脏页进行刷写 vm.dirty_background_ratio = 90 #3. 脏页达到内存的一定比例后,新的IO请求将会被阻挡,直到脏数据被写进磁盘,该值一般大于dirty_background_ratio vm.dirty_ratio = 90 #4. 每50s启动一次刷写磁盘的后台进程 vm.dirty_writeback_centisecs = 5000 #5. 脏页可以存300s vm.dirty_expire_centisecs = 30000 #6. 使修改生效 sysctl -p #7. 查看是否生效 sysctl -a|grep dirty
11 断电导致数据丢失
-
windows下vmware中有两个关机,
关闭客户机
相当于按关机键,此时电源会产生一个中断,cpu收到后,会调内核中关机程序,将内存中数据刷写到磁盘并关机,而关闭客户机
下方的关机
表示直接拔电源,这会导致没来得及刷写到磁盘中的内存中数据丢失 -
free
:查看内存大小,实验机器为2G,也就是说2*90%=1.8G之内,pagecache都不会被刷写到磁盘 -
pcstat
:go语言开发的程序,可以查看内存中对某文件缓存了多少,当发现某个进程占用物理内存较多,就可以对所有该进程打开的文件使用pcstat,从而查看是哪个文件占用了大量的内存,网盘中为已经编译好的,可以直接拿来使用 -
实验
cd /root/testfileio vi mysh #1. 文件中内容 rm -rf *out* javac OSFileIO.java #2. strace:打印应用程序对内核的系统调用 #a. -ff:抓取所有的线程 #b. out:指定文件名前缀,由于JVM跑起来是多线程,所以strace会生成多个文件,名为out.线程号 #c. 打开最大的那个文件,一般就是主线程 strace -ff -o out java OSFileIO $1 #2. 修改权限 chmod 777 mysh ./mysh 0 #3. 查看生成的文件,out.txt文件大小增长速度很慢,这也说明FileOutputStream效率不高 ll -h && pcstat out.txt #4. Size:总大小、Pages:共多少页、Cached:多少页在内存中、Percent:内存中页数百分比等信息,发现Pages和Cached一直在增加,Percent一直为100,这是因为只要内存没满,就会尽量将文件放入内存 +---------+----------------+------------+-----------+---------+ | Name | Size (bytes) | Pages | Cached | Percent | |---------+----------------+------------+-----------+---------| | out.txt | 17460110 | 4263 | 4263 | 100.000 | +---------+----------------+------------+-----------+---------+ #5. 脏页数越来越多,基本和上面Cached数持平,说明内存中的页基本都是dirty,从未被刷写到磁盘 cat /proc/vmstat | grep dirty #6. 此时不正常关机,而是直接拔电源,再连接上来时,会发现,本来有大小的out.txt文件,又变为了0k,这就是为什么redis中,一般不配置成随内核来刷写数据,因为这会导致大量数据丢失
12 FileOutputStream与BufferedOutputStream
-
FileOutputStream
./mysh 0 #1. 查看记录主线程系统调用的文件,发现每调用一次FileOutputStream的write方法,就进行一次系统调用write(4, "123456789\n", 10),需要频繁切换用户态和内核态,因此写入效率非常低 #2. 系统调用:write(4, "123456789\n", 10) = 10 tail -f out.36930
-
BufferedOutputStream
./mysh 1 #1. 查看记录主线程系统调用的文件,发现调用BufferedOutputStream的write方法不会进行系统调用,而是先写入用户空间的一个8K大小的数组中,当这个数组满后,再执行一次系统调用,将整个数组写入内存,避免了频繁的用户态与内核态切换 #2. 系统调用:write(4, "123456789\n123456789\n123456789\n12"..., 8190) = 8190 #3. 也就是说,如果能保证每次写入的数据都是8K,那么FileOutputStream和BufferedOutputStream其实效率相同 tail -f out.37096 #4. 发现文件长大的速度非常快,到一定时间后,Percent变小,说明内存无法将整个文件缓存,只保留最新写入的数据 ll -h && pcstat out.txt #5. 修改文件名 mv out.txt ooxx.txt #6. 虽然改名,但缓存还在 pcstat ooxx.txt #7. 重新执行该程序,发现ooxx.txt缓存百分比渐渐减少,out.txt缓存百分比逐渐增多,因为内存只有3G,此时会将不常用的ooxx.txt在内存中数据清除,而将out.txt中的数据到内存 ./mysh 1 ll -h && pcstat out.txt && pcstat ooxx.txt
-
清理缓存和落盘不是一个动作,不要弄混
- 清理缓存:文件会被尽量放入内存,当内存满了,就会根据lru或lfu算法,将不常用数据剔除内存,在剔除前,必须落盘
- 落盘:当前配置在,脏页达到内存的90%,就会强制落盘,将脏页改为非脏页
- 也就是说,当脏页达到内存的90%,内存中虽然仍然能放入新页,但此时会触发落盘
13 Java NIO
-
此处指java提供的新的IO模型,新IO与老IO主要区别在于
-
编程方式不同
-
提供了非阻塞功能
- InputStream的read方法,官方描述,为此方法会阻塞,直到输入数据可用、检测到流的末尾或抛出异常为止
- 虽然FileInputStream是InputStream的具体实现,但通常其read方法不会阻塞,因为对于文件,一定会有EOF,也就是read一定会检测到文件的末尾
- 阻塞通常发生在控制台/终端读取、从网络读取、从管道读取
-
提供了堆外分配功能
- 堆外内存:off heap,在java的堆外、linux进程堆内,来分配内存,性能稍高
- 堆内内存:on heap
- on heap需要先复制到off heap,也就是存在一次用户空间的拷贝,然后才能最终通过系统调用复制到pagecache,同时GC只回收堆内内存,因此使用堆外内存可以减少GC成本,但分配堆外内存成本较高,因此堆外内存适合需要频繁使用,基本不必回收的对象
-
提供了多路复用功能:大量连接,每个连接发送少量数据适合使用多路复用,而少量连接,每次发送大量数据,适合使用传统IO
-
提供了用户空间内存到内核空间的映射:减少了由用户空间到pagecache的复制,效率较高
-
-
测试
#1. 测试ByteBuffer ./mysh 3 #2. 测试FileChannel ./mysh 2 #3. 查看 cat out.txt hello mashibing hello seanzhou #4. 回车跳过阻塞,执行seek这部分代码 cat out.txt hellooxxshibing hello seanzhou #5. 再次回车跳过阻塞,执行将文件映射到虚拟内存这部分代码 #6. 查看java进程打开的文件 lsof -p 37953 #7. 发现除了正常文件描述符指向/root/testfileio/out.txt,还有一个Type为mem的文件描述符也指向这个文件,这就是该文件在虚拟内存中的直接映射 java 37953 root mem REG 8,2 533486 /root/testfileio/out.txt java 37953 root 4u REG 8,2 0t8 533486 /root/testfileio/out.txt #8. 查看out.txt文件大小,为4096 ll /root/testfileio/out.txt
-
堆外内存、堆内内存、存储映射
14 write与mmap
-
系统调用mmap产生的就是存储映射I/O,系统调用write/read产生的是标准I/O
-
标准I/O:写入用户空间,然后将用户空间数据拷贝到pagecache,即buffer和文件内容不存在任何映射关系
-
存储映射 I/O:写入用户空间,但由于pagecache和用户空间用的是同一块内存,因此不必复制,因为pagecache已经被修改
15 网络链接本质
- 所谓建立链接,本质上就是在两台计算机上各自的内核中开辟了一块内存资源,用于接收对方发送的数据,没有java也能正常建立链接
- 服务端
server = new ServerSocket();server.bind(new InetSocketAddress(9090), BACK_LOG);
:建立一个监听文件,服务进程会产生一个文件描述符指向这个监听文件 - 客户端
new Socket("192.168.246.128", 9090)
:建立链接,此时服务端的监听发现这个建立链接请求,就会在服务端真正产生一个socket文件,但此时还没有进程使用这个文件 - 服务端
server.accept
:在java进程中建立一个文件描述符,指向这个socket文件
16 Socket、ServerSocket
- ServerSocket:是java对监听的文件描述符的抽象
- Socket:是java对链接的文件描述符的抽象
- Socket主要有4个元素,客户端ip,客户端端口,服务端ip,服务端端口
- 只要这4个元素不同,在同一台机器上,就能建立一个Socket,因此同一台机器上实际上可以建立无数个Socket
- 写入Socket的数据会存放在内核的缓冲区(buffer),用户进程读取时,也是从这个缓冲区读取数据
17 BIO
-
BIO:Block-IO,是一种同步且阻塞的通信模式
-
服务器端
server.accept()
和reader.read(data)
两个方法,分别用于新建文件描述符指向socket和读取socket中数据,在BIO模式下都会产生阻塞,因此如果二者在同一进程中,那么读取数据会阻塞指向新socket,而指向新链socket也会阻塞读取数据,因此只能为每个链接都新启动一个线程进行处理 -
当链接非常多时,java进程中就会存在很多线程,cpu将会耗费大量时间用于线程切换,严重影响效率
-
SocketIOPropertites:服务端代码
import java.io.BufferedReader; import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; import java.net.InetSocketAddress; import java.net.ServerSocket; import java.net.Socket; public class SocketIOPropertites { private static final int RECEIVE_BUFFER = 10; private static final int SO_TIMEOUT = 0; private static final boolean REUSE_ADDR = false; private static final int BACK_LOG = 2; private static final boolean CLI_KEEPALIVE = false; private static final boolean CLI_OOB = false; private static final int CLI_REC_BUF = 20; private static final boolean CLI_REUSE_ADDR = false; private static final int CLI_SEND_BUF = 20; private static final boolean CLI_LINGER = true; private static final int CLI_LINGER_N = 0; private static final int CLI_TIMEOUT = 0; private static final boolean CLI_NO_DELAY = false; public static void main(String[] args) { ServerSocket server = null; try { server = new ServerSocket(); //1. 指定客户连接请求队列的长度,server.accept实际上是从队列中取出一个Socket,当队列长度为2,那么在调用server.accept之前,最多通过客户端建立3个链接,当超过3个,链接无法建立,这样不会导致过度使用系统资源,通过netstat -natp,显示新链接状态为SYN_RECV,tcp 0 0 192.168.246.128:9090 192.168.246.129:56850 SYN_RECV - server.bind(new InetSocketAddress(9090), BACK_LOG); server.setReceiveBufferSize(RECEIVE_BUFFER); server.setReuseAddress(REUSE_ADDR); //2. 设置server.accept方法超时时间,超时抛异常 server.setSoTimeout(SO_TIMEOUT); } catch (IOException e) { e.printStackTrace(); } System.out.println("server up use 9090!"); try { while (true) { //3. 程序在此阻塞,回车后可以通过 System.in.read(); Socket client = server.accept(); System.out.println("client port: " + client.getPort()); //4. true表示长链接,定期发送心跳包,false为短链接 client.setKeepAlive(CLI_KEEPALIVE); //5. 是否优先发一个字符过去 client.setOOBInline(CLI_OOB); client.setReceiveBufferSize(CLI_REC_BUF); client.setReuseAddress(CLI_REUSE_ADDR); client.setSendBufferSize(CLI_SEND_BUF); client.setSoLinger(CLI_LINGER, CLI_LINGER_N); //6. 设置reader.read的超时时间,超时抛异常 client.setSoTimeout(CLI_TIMEOUT); //7. tcp上的优化算法,发送数据较少时,可以利用缓冲,是否开启和业务规模特征有关,为true就是有多少发多少 client.setTcpNoDelay(CLI_NO_DELAY); //8. 为每个链接都新建一个线程 new Thread( () -> { try { InputStream in = client.getInputStream(); BufferedReader reader = new BufferedReader(new InputStreamReader(in)); char[] data = new char[1024]; while (true) { //9. 如果客户端没有新数据产生,该方法阻塞,当客户端断开后,该值返回-1 int num = reader.read(data); if (num > 0) { System.out.println("client read some data is :" + num + " val :" + new String(data, 0, num)); } else if (num == 0) { System.out.println("client readed nothing!"); continue; } else { System.out.println("client readed -1..."); System.in.read(); client.close(); break; } } } catch (IOException e) { e.printStackTrace(); } } ).start(); } } catch (IOException e) { e.printStackTrace(); } finally { try { server.close(); } catch (IOException e) { e.printStackTrace(); } } } }
-
SocketClient:客户端代码
import java.io.*; import java.net.Socket; public class SocketClient { public static void main(String[] args) { try { Socket client = new Socket("192.168.246.128", 9090); //1. 设置缓冲区大小为20字节 client.setSendBufferSize(20); //2. false:表示优化发送,也就是虽然客户端中是多次调用write方法,但实际上是尽量积攒更多的数据再进行系统调用 //3. true:表示不优化发送,虽然还是多次write其实只进行一次系统调用,与上者区别为,只要有数据就尽量发送,每次批量写入不会超过缓冲区的20字节 //4. 一个软件如果需要及时的反馈,就设置为true,如果不需要,设为false client.setTcpNoDelay(false); //4. false:表示首字符优先发送,即首字符会优先通过系统调用发送出去,剩余字符一起发送,注意setOOBInline为false想生效setTcpNoDelay必须也为false,true表示不开启 client.setOOBInline(false); OutputStream out = client.getOutputStream(); InputStream in = System.in; BufferedReader reader = new BufferedReader(new InputStreamReader(in)); //将键盘输入的内容,输出到socket中 while (true) { String line = reader.readLine(); if (line != null) { byte[] bb = line.getBytes(); for (byte b : bb) { //注意此处不flush out.write(b); } } } } catch (IOException e) { e.printStackTrace(); } } }
-
实验
#1. node01:启动服务端 javac SocketIOPropertites.java && java SocketIOPropertites #2. node01:启动网络抓包工具 tcpdump -nn -i eth0 port 9090 #3. node01:发现服务端进程新建了一个文件描述符5,指向一个监听文件 lsof -p 38460 java 38460 root 5u IPv6 97345 0t0 TCP *:websm (LISTEN) #4. node01:发现服务端进程确实新建了一个对9090端口的监听 netstat -natp tcp 0 0 :::9090 :::* LISTEN 38460/java #5. node02:启动客户端 javac SocketClient.java && java SocketClient #6. 发现抓包工具抓到了3次握手的数据包 #7. node01:发现多了一个代表服务端到客户端的链接文件,但其PID为-,即还未分配给进程使用 netstat -natp Active Internet connections (servers and established) Proto Recv-Q Send-Q Local Address Foreign Address State PID/Program name tcp 0 0 ::ffff:192.168.246.128:9090 ::ffff:192.168.246.12:56834 ESTABLISHED - #8. node01:java进程确实没打开这个链接文件 lsof -p 38460 #9. node02:发送一些字符给服务端 #10. 抓包工具抓到了这些数据 #11. node01:发现Recv-Q变为了5,因为客户端输入了5个字符,这5个字符因为还没有进程接收,因此存放在了内核 netstat -natp tcp 5 0 ::ffff:192.168.246.128:9090 ::ffff:192.168.246.12:56834 ESTABLISHED - #12. node01:回车,跳过阻塞,让代码server.accept()执行 #13. node01:发现Recv-Q重新变为了0,因为服务端已经从内核将这部分数据读取到了用户空间,同时PID值变为服务端进程值,说明链接已经分配给了服务端进程 netstat -natp tcp 0 0 ::ffff:192.168.246.128:9090 ::ffff:192.168.246.12:56834 ESTABLISHED 38460/java #14. node01:发现服务端进程多了一个文件描述符6,指向链接文件 java 38460 root 6u IPv6 98405 0t0 TCP node01:websm->node02:56834 (ESTABLISHED) #15. node02:安装nc yum install -y nc #16. node01:重新启动,让其阻塞在server.accept()前 #17. node02:使用nc连接服务端,并发送大量字符 nc 192.168.246.128 9090 #18. node01:发现当Recv-Q到达1804后,不再变化 netstat -natp
-
参数实验
#1. 启动服务端,跳过阻塞,启动客户端,发现虽然客户端是循环调用write,看似每次写入一个字符,但实际上是尽可能发送多的数据到服务端,且客户端输入一个长字符串时,总是第一个字符被优先发送出去 #2. 修改客户端代码,再次实验,发现第一个字符不再被优先发送出去,且客户端输入一长串字符串,服务端会分批输出,即一次发送多个字节时,不会攒一起发,而是尽量的发 client.setTcpNoDelay(true); client.setOOBInline(true); #3. 修改服务端代码,建立长链接 client.setKeepAlive(true); #4. 启动服务端,跳过阻塞,启动客户端,启动抓包工具,发现隔一段时间,服务端就会发送数据包给客户端,询问其状态,如果客户端多次不回应,链接就会被断开
18 相关系统调用
-
javac TestSocket.java && strace -ff -o out java TestSocket
-
TestSocket
import java.io.BufferedReader; import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; import java.net.ServerSocket; import java.net.Socket; public class TestSocket { public static void main(String[] args) throws IOException { //1. 注意任何程序做服务端,以下3个系统调用一定会执行 //a. socket(PF_INET6, SOCK_STREAM, IPPROTO_IP) = 5:创建一个文件描述符5 //b. bind(5, {sa_family=AF_INET6, sin6_port=htons(8090), inet_pton(AF_INET6, "::", &sin6_addr), sin6_flowinfo=0, sin6_scope_id=0}, 28) = 0:将文件描述符5绑定到8090端口 //c. listen(5, 50) = 0:监听文件描述符5对应的文件 //d. 这三步执行完后,java进程就会多出一个文件描述符5指向对8090端口的监听 ServerSocket server = new ServerSocket(8090); System.out.println("step1:new ServerSocket(8090) "); while (true) { //1. 接收文件描述符5对应的监听所收到的链接,会执行如下阻塞的系统调用 //a. jdk1.4:accept(5, //b. jdk1.8:poll([{fd=5, events=POLLIN|POLLERR}], 1, -1 //c. 当有客户端链接进来,执行如下系统调用,表示新建立一个文件描述符6指向这个链接(socket) //d. accept(5, {sa_family=AF_INET6, sin6_port=htons(59852), inet_pton(AF_INET6, "::ffff:192.168.246.129", &sin6_addr), sin6_flowinfo=0, sin6_scope_id=0}, [28]) = 6 Socket client = server.accept(); System.out.println("step2:client\t" + client.getPort()); //1. 创建线程,对应系统调用为clone,clone系统调用的结果,就是新进程号,为39422,之后就可以观察out.39422 new Thread(new Runnable() { Socket ss; public Runnable setSS(Socket s) { ss = s; return this; } @Override public void run() { try { InputStream in = ss.getInputStream(); BufferedReader reader = new BufferedReader(new InputStreamReader(in)); while (true) { //1. 读取socket中数据,会执行如下阻塞的系统调用 //a. jdk1.4:recv(6, //b. jdk1.8:recvfrom(6, //c. 6就是链接(socket)的文件描述符 System.out.println(reader.readLine()); } } catch (IOException e) { e.printStackTrace(); } } }.setSS(client)) { }.start(); } } }
19 批量建立链接
-
以下客户端可以做到同一个客户端,对同一个服务端,建立10K个链接,我们可以通过其观察不同IO模型下建立链接的速度
-
C10Kclient
import java.io.IOException; import java.net.InetSocketAddress; import java.nio.channels.SocketChannel; import java.util.LinkedList; public class C10Kclient { public static void main(String[] args) { LinkedList<SocketChannel> clients = new LinkedList<>(); //1. 设置服务端的ip和端口 InetSocketAddress serverAddr = new InetSocketAddress("192.168.246.128", 9090); for (int i = 10000; i < 65000; i++) { try { SocketChannel client1 = SocketChannel.open(); SocketChannel client2 = SocketChannel.open(); //2. 指定客户端的ip和端口,该ip在windows上为vmware8网卡地址,在mac上是bridge100网卡地址 client1.bind(new InetSocketAddress("192.168.246.1", i)); client1.connect(serverAddr); clients.add(client1); //3. 指定客户端的ip和端口,该ip在windows上为无线网卡的ip,在mac上为en0网卡地址 client2.bind(new InetSocketAddress("192.168.1.104", i)); client2.connect(serverAddr); clients.add(client2); } catch (IOException e) { e.printStackTrace(); } } System.out.println("clients " + clients.size()); try { System.in.read(); } catch (IOException e) { e.printStackTrace(); } } }
-
BIO下建立链接实验
#1. node01:启动服务端 javac SocketIOPropertites.java && java SocketIOPropertites #2. 启动抓包程序,如果有链接失败,可以用抓包工具分析问题原因 #3. 启动客户端,注意如果是windows系统,会发现服务端日志,每个端口,只有一个链接被建立,来自192.168.1.104的链接没建立成功,他们之间能互相ping通,这是因为对于windows来说,第一次握手,源ip为192.168.1.104,目标ip为192.168.246.128,由于他们不在同一网络下,因此将数据包先发送到网关192.168.246.2,当服务器收到这个链接,第二次握手,发送数据包为源ip为192.168.246.128,目标ip为192.168.1.104,由于也不在同一网络,因此先发送到网关192.168.246.2,但此时由于是NAT模式,所以会将数据包进行修改,源ip改为192.168.246.2,此时虽然客户端能收到这个数据包,但发现和自己发送的链接的ip不同,因此拒绝链接。因此需要新增路由条目,这样就直接发到了windows的192.168.150.1网卡上 route add -host 192.168.1.104 gw 192.168.150.1 #4. 而对于mac,网关和客户端ip相同都是192.168.150.1,因此就不存在这个问题 #5. 发现建立链接速度较慢,这是因为启动了大量的线程
20 非阻塞
-
linux内核为socket系统调用提供了一个参数SOCK_NONBLOCK,java的非阻塞就是通过使用该参数创建socket实现的
-
使用非阻塞后,就可以在一个线程中,接收多个客户端的链接了,避免大量线程的创建
-
SocketNIO
import java.net.InetSocketAddress; import java.nio.ByteBuffer; import java.nio.channels.ServerSocketChannel; import java.nio.channels.SocketChannel; import java.util.LinkedList; public class SocketNIO { public static void main(String[] args) throws Exception { LinkedList<SocketChannel> clients = new LinkedList<>(); //1. 启动监听:java新的IO模型中的ServerSocketChannel相当于原来的ServerSocket,是对监听那个文件的FD的抽象,但可设置为非阻塞 ServerSocketChannel ss = ServerSocketChannel.open(); ss.bind(new InetSocketAddress(9090)); //2. 设置ss.accept方法不阻塞 ss.configureBlocking(false); //3. 设置相关属性 // ss.setOption(StandardSocketOptions.TCP_NODELAY, false); // StandardSocketOptions.TCP_NODELAY // StandardSocketOptions.SO_KEEPALIVE // StandardSocketOptions.SO_LINGER // StandardSocketOptions.SO_RCVBUF // StandardSocketOptions.SO_SNDBUF // StandardSocketOptions.SO_REUSEADDR while (true) { //4. SocketChannel相当于原来的Socket是对socket的FD的抽象,此处不会阻塞,如果没有新链接进来,会返回null,注意,该方法,每次只能返回一个队列中的链接(socket) SocketChannel client = ss.accept(); //5. 如果有链接进来 if (client == null) { } else { //6. 设置c.read为非阻塞 client.configureBlocking(false); int port = client.socket().getPort(); System.out.println("client..port: " + port); clients.add(client); } //6. 堆外分配buffer ByteBuffer buffer = ByteBuffer.allocateDirect(4096); //7. 遍历已经链接进来的客户端,看是否有新数据进到链接 for (SocketChannel c : clients) { //8. 此处不会阻塞,>0表示读到了数据 int num = c.read(buffer); if (num > 0) { buffer.flip(); byte[] aaa = new byte[buffer.limit()]; buffer.get(aaa); String b = new String(aaa); System.out.println(c.socket().getPort() + " : " + b); buffer.clear(); } } } } }
-
NIO(非阻塞IO)下建立链接实验
#1. node01:启动服务端 javac SocketNIO.java && strace -ff -o out java SocketNIO #2. mac:启动客户端C10Kclient #3. 建立链接速度快于NIO,因为不必维护多个线程,但当链接越来越多后,建立链接速度越来越慢,因为每次循环中,都会遍历所有已经建立的链接(socket),调用其read方法,有多少链接就需要执行多少次read系统调用,因此还是会很慢 #4. 如果建立到一定数量链接后报错Exception in thread "main" java.io.IOException: Too many open files,这是因为每建立一个链接,在linux中相当于建立一个文件,而一个进程能打开的链接数是有限制的,需要取消该限制 #5. 查看限制,注意root用户不完全受该参数限制,因此可能打开的链接数会超过这个值,但也有一定限制 ulimit -a open files (-n) 1024 #6. 增大打开文件数 ulimit -SHn 500000 #7. 最多能打开文件数,也受下方参数影响 cat /proc/sys/fs/file-max #8. 系统调用伪代码 socket -> fd3 bind(fd3,8090) listen fd3 将fd3设置为非阻塞 while(){ accept(fd3) -> fd4/-1 将fd4设置为非阻塞 while(所有链接的fd){ recv(fdN) } }
21 多路复用
- NIO的缺点:无效的read(recv)被调用,假设当前服务端上有n个链接,那么每循环一次,需要调用n次read(系统调用recv)来接收链接中的数据,每次调用都会产生一次用户态到内核态的切换,时间复杂度为O(n),但实际上真正能收到数据的read(recv)非常非常少,这会造成大量浪费,因此产生多路复用技术
- 多路复用
- 多路:每条网络链接相当于一条路,复用:一个线程就可以检查这些路的状态
- NIO模型下,应用程序需要遍历每条路(read),而多路复用模型下,只需要先将所有需要询问状态的链接的文件描述符一次性传给这个多路复用器,这样通过一次系统调用就能返回所有状态更新的链接(socket),之后再对这些更新了状态的链接进行处理(read)
- 但虽然多路复用器只进行了一次系统调用,其实内核还是需要遍历所有链接,询问他们的状态,效率依然不高
- 于是产生了epoll这种特殊的多路复用,解决了这个问题
22 网络IO模型
- 阻塞:BIO,blocking,指accept、read方法阻塞
- 非阻塞:NIO,non-blocking,指accept、read方法不阻塞,注意java中也有名词叫NIO,指New IO,不要弄混,java的New IO提供了non-blocking的功能,但不仅限于这一个功能
- 同步:java的read方法会调用内核读取数据,假设调用的是内核的非阻塞的read,那么系统调用read执行完毕后,如果没读到数据,返回-1,系统调用read结束后,java的read方法才结束。BIO、NIO、多路复用,都是同步的
- 异步:java的read方法只是告诉内核想要数据,不等待内核执行系统调用read,java的read就执行完毕,此时内核异步地将数据通过read拷贝到用户空间准备好的buffer中,就好像应用程序不访问IO,只访问buffer
- linux目前没有通用的内核级的异步处理方式,因为不安全
- 异步就不会有阻塞了,因为阻塞来自于内核通过系统调用read将pagecache中内容复制到用户空间,对于异步来说,java的read方法和系统调用read都不发生在一个进程中,因此不会阻塞
23 多路复用器
- select:所有操作系统都有,一次select最多询问FD_SETSIZE(1024)条路的状态,如果有2048条路,需要调两次,所以现在很少使用
- poll:与select类似,没有了一次最多询问1024条路的限制,是对select的进一步包装
- epoll:linux提供的一种特殊的多路复用器
- select、poll的缺点
- 每次循环都需要将所有链接(socket)的文件描述符传递给系统调用,这需要在内核频繁开辟空间
- 每次循环中,都需要遍历全量链接(socket)的状态
24 epoll工作原理
- socket -> fd4:产生监听
- bind(fd4,8090)
- listen fd4
- epoll_create -> fd6:内存中开辟一块空间,存放一个红黑树和一个链表,这个fd6是epoll的fd,也称为epfd
- epoll_ctl(fd6,ADD,fd4):将fd4放入红黑树
- accept(fd4) -> fd7:接收链接
- epoll_ctl(fd6,ADD,fd4):将fd7也放入红黑树
- 网卡收到数据,产生IO中断,CPU收到这个中断后,通知DMA将网卡中的数据复制到链接(fd7)所使用的那块内核缓冲区中,同时由于linux中有epoll,其内核多出来一部分工作,会判断该链接是否存在于红黑树中,如果在,将fd7放入epoll提供的链表中
- epoll_wait -> fds:直接从链表中取状态改变的文件描述符,epoll_wait不需要传入想监控的文件描述符,这样解决了select、poll需要频繁在内核开辟空间的缺点,同时内核不需要遍历所有想监控的文件描述符
25 Java Selector
-
Selector就是java对多路复用器的抽象,它可以代表select、poll、epoll其中的一个,linux下默认具体epoll,可以通过启动参数修改为使用poll
javac SocketMultiplexingSingleThreadv1.java && strace -ff -o out java -Djava.nio.channels.spi.SelectorProvider=sun.nio.ch.PollSelectorProvider SocketMultiplexingSingleThreadv1
-
SocketMultiplexingSingleThreadv1
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 SocketMultiplexingSingleThreadv1 { private ServerSocketChannel server = null; //对多路复用器的抽象 private Selector selector = null; int port = 9090; public void initServer() { try { //1. socket -> fd4 server = ServerSocketChannel.open(); //2. fcntl(4,O_NONBLOCK) server.configureBlocking(false); //3. bind(fd4,9090) //4. listen fd4 server.bind(new InetSocketAddress(port)); //1. 对于epoll:epoll_create -> fd3,fd3就是epoll开辟的红黑树 //2. 对于poll:新建一个数组,用于存放要询问状态的那些链接和监听,这样方便后面拿出传递给poll selector = Selector.open(); //注意此步骤不会真正做下面两个动作,而是类似懒加载,在selector.select处执行,不过只有调用了server.register,在执行selector.select时,epoll_ctl才会被触发 //此处是将fd4放入红黑树,但只有fd4读事件发生变化,才放入链表 //1. 对于epoll:epoll_ctl(fd3,ADD,fd4) //2. 对于poll:将fd4放入数组 server.register(selector, SelectionKey.OP_ACCEPT); } catch (IOException e) { e.printStackTrace(); } } public void start() { initServer(); System.out.println("服务器启动了。。。。。"); try { while (true) { //1. 对于epoll:epoll_wait(fd3) -> fd4、fd5,将链表中文件描述符拿出来 //2. 对于poll:poll(fd[]),将数组中所有文件描述符传递给内核,让内核遍历哪个状态变化了 //3. 该方法会阻塞,可以传递其超时时间,如果无参数,表示一直阻塞,外部线程可以通过selector.wakeup解除其阻塞,但select方法结果为0 while (selector.select() > 0) { //返回的有状态的fd集合 Set<SelectionKey> selectionKeys = selector.selectedKeys(); Iterator<SelectionKey> iter = selectionKeys.iterator(); while (iter.hasNext()) { SelectionKey key = iter.next(); //防止重复循环处理 iter.remove(); //如果isAcceptable返回true,表示这个fd代表一个新的链接,因此应该将其放入红黑树中 if (key.isAcceptable()) { acceptHandler(key); } //如果isReadable返回true,表示这是老链接进来的新数据,应该根据这些输出处理业务逻辑 else if (key.isReadable()) { //1. 该方法可能会阻塞,因为读取的数据量可能非常大 //2. 因此通常启动多个线程来读取来自不同链接(socket)的数据,当读取完成后,再将数据交给实现业务逻辑的线程 //3. 这和BIO新启线程不同,因为此处只需为状态变化的链接启动线程,而BIO需要为所有链接启动线程 //4. redis中的IO Threads就是指多线程处理IO,然后将接收到的数据交给实现业务逻辑的单线程 readHandler(key); } } } } } catch (IOException e) { e.printStackTrace(); } } public void acceptHandler(SelectionKey key) { try { ServerSocketChannel ssc = (ServerSocketChannel) key.channel(); //accept(fd4) -> fd7 SocketChannel client = ssc.accept(); //fcntl(fd7,O_NONBLOCK) client.configureBlocking(false); ByteBuffer buffer = ByteBuffer.allocate(8192); //1. 对于epoll:epoll_ctl(fd3,ADD,fd7) //2. 对于poll:将fd7放入数组 client.register(selector, SelectionKey.OP_READ, buffer); System.out.println("-------------------------------------------"); System.out.println("新客户端:" + client.getRemoteAddress()); System.out.println("-------------------------------------------"); } catch (IOException e) { e.printStackTrace(); } } public void readHandler(SelectionKey key) { SocketChannel client = (SocketChannel) key.channel(); ByteBuffer buffer = (ByteBuffer) key.attachment(); buffer.clear(); int read = 0; try { while (true) { read = client.read(buffer); if (read > 0) { buffer.flip(); while (buffer.hasRemaining()) { client.write(buffer); } buffer.clear(); } else if (read == 0) { break; } //-1:表示客户端断开链接 else { client.close(); break; } } } catch (IOException e) { e.printStackTrace(); } } public static void main(String[] args) { SocketMultiplexingSingleThreadv1 service = new SocketMultiplexingSingleThreadv1(); service.start(); } }
-
实验
#1. 启动服务端 java -Xmx4G SocketMultiplexingSingleThreadv1 #2. 启动客户端C10Kclient #3. 发现建立链接速度非常快,此时实际上mac上还是单线程建立链接,因此如果真正生产环境,多个客户端同时向服务端建立链接,速度会比这个更快 #4. 查看epoll的红黑树中,最多能存放多少链接 cat /proc/sys/fs/epoll/max_user_watches #5. 使用poll和epoll分别启动,观察out日志,查看系统调用对应java代码位置
26 链接状态
26.1 服务端不关闭链接
-
屏蔽服务端
SocketMultiplexingSingleThreadv1
中client.close();
-
启动服务端
-
启动客户端:
nc localhost 9090
-
客户端发送数据
-
客户端退出
-
查看链接状态
netstat -natp #客户端:状态为FIN_WAIT2 tcp 0 0 ::1:53250 ::1:9090 FIN_WAIT2 - #服务端:状态为CLOSE_WAIT tcp 0 0 ::1:9090 ::1:53250 CLOSE_WAIT 46122/java
-
原因分析:
- 当客户端退出时,进行第一次分手,发送FIN到服务端,然后服务端回复FIN的ACK
- 但由于服务端没有执行
client.close
,相当于没进行第三次分手,即服务端未发送FIN给客户端 - 可以理解为FIN_WAIT2表示等待接收FIN,即等待第三次握手,CLOSE_WAIT表示等待自身发起第三次握手
26.2 服务端关闭链接
-
解除
client.close
屏蔽 -
启动服务端
-
启动客户端
-
客户端发送数据
-
退出客户端
-
查看链接状态
#客户端:状态为TIME_WAIT,等待一段时间后,该链接也消失 tcp 0 0 ::1:53252 ::1:9090 TIME_WAIT - #服务端:链接已经消失
-
原因分析
- 发起4次分手的一方,完成4次分手后,链接会变为TIME_WAIT状态,并保留一段时间,另一方在完成4次分手后,链接会立即变为CLOSED状态,然后消失
- 这是因为发起4次分手的一方的第4次分手,即回复ACK,这一步对方可能由于网络原因未能正确收到,那么对方可能需要再次发送FIN确认进行第3次分手,因此链接需要保持2*报文活动时间,通常为60s,60s后消失
- 也就是说链接状态为TIME_WAIT一方为4次分手发起方,且4次分手已执行完毕
- 由于该原因,会导致如果同一客户端大量连接服务端后,即时断开客户端,短期内还是无法再次连接服务端,该问题可以通过调整内核参数解决
27 将读和写的处理逻辑分开
-
SocketMultiplexingSingleThreadv1_1:演示isWritable
package com.bjmashibing.system.io; import java.io.IOException; import java.net.InetSocketAddress; import java.nio.ByteBuffer; import java.nio.channels.*; import java.util.Iterator; import java.util.Set; public class SocketMultiplexingSingleThreadv1_1 { private ServerSocketChannel server = null; private Selector selector = null; int port = 9090; public void initServer() { try { server = ServerSocketChannel.open(); server.configureBlocking(false); server.bind(new InetSocketAddress(port)); selector = Selector.open(); server.register(selector, SelectionKey.OP_ACCEPT); } catch (IOException e) { e.printStackTrace(); } } public void start() { initServer(); System.out.println("服务器启动了。。。。。"); try { while (true) { while (selector.select() > 0) { //1. 注意,一个链接,对应一个SelectionKey,这个SelectionKey既可以是isReadable,也可以是isWritable //2. 如果在处理读事件时,使用client.register(key.selector(), SelectionKey.OP_WRITE, buffer);,会将OP_READ事件覆盖掉,换句话说SelectionKey就不再是isReadable //3. 因此如果想反复接收客户端数据,处理读事件时,应该使用client.register(key.selector(), SelectionKey.OP_WRITE|SelectionKey.OP_READ, buffer);,同时添加读事件和写事件 //4. register方法内部,实际上就是调用key.interestOps,key在ServerSocketChannel或SocketChannel内部存放,同一个Channel,为每个selector,都创建了一个SelectionKey //5. 因此没调用client.register前,无法执行key.interestOps,因为key还和client关联起来 Set<SelectionKey> selectionKeys = selector.selectedKeys(); Iterator<SelectionKey> iter = selectionKeys.iterator(); while (iter.hasNext()) { SelectionKey key = iter.next(); iter.remove(); if (key.isAcceptable()) { acceptHandler(key); } //关注读事件后,Recv-Q只要有数据isReadable就为真 else if (key.isReadable()) { //读到的数据后,不直接写出去,将SelectionKey的事件由读事件改为写事件 readHandler(key); } //关注读事件后,只要Send-Q不满,isWritable就为true else if (key.isWritable()) { //1. 此处要注意,isWritable是只要Send-Q不满就为true,那么就会进入下面逻辑,但作为程序员我们真正是想在收到数据才写,所以当写入完成后,应该执行代码key.interestOps(SelectionKey.OP_READ);,将SelectionKey关注的写事件重新转为读事件 //2. 不然的话,该链接对应的SelectionKey的isWritable一直为true,那么该段代码就会被循环调用 writeHandler(key); } } } } } catch (IOException e) { e.printStackTrace(); } } private void writeHandler(SelectionKey key) throws ClosedChannelException { System.out.println(key.isReadable()); System.out.println("write handler..."); SocketChannel client = (SocketChannel) key.channel(); ByteBuffer buffer = (ByteBuffer) key.attachment(); buffer.flip(); while (buffer.hasRemaining()) { try { client.write(buffer); } catch (IOException e) { e.printStackTrace(); } } buffer.clear(); //写法1 // key.interestOps(key.interestOps()&(~SelectionKey.OP_WRITE)); //写法2:写完,就将事件切换为读 key.interestOps(SelectionKey.OP_READ); } public void acceptHandler(SelectionKey key) { try { ServerSocketChannel ssc = (ServerSocketChannel) key.channel(); SocketChannel client = ssc.accept(); client.configureBlocking(false); ByteBuffer buffer = ByteBuffer.allocate(8192); client.register(selector, SelectionKey.OP_READ, buffer); System.out.println("-------------------------------------------"); System.out.println("新客户端:" + client.getRemoteAddress()); System.out.println("-------------------------------------------"); } catch (IOException e) { e.printStackTrace(); } } public void readHandler(SelectionKey key) { System.out.println("read handler....."); SocketChannel client = (SocketChannel) key.channel(); ByteBuffer buffer = (ByteBuffer) key.attachment(); buffer.clear(); int read = 0; try { while (true) { read = client.read(buffer); if (read > 0) { //写法1:定义写事件的缓冲区为buffer,也就是写满buffer后,再写入内核,读的数据会放入buffer // client.register(key.selector(), SelectionKey.OP_WRITE|SelectionKey.OP_READ, buffer); } else if (read == 0) { break; } else { client.close(); break; } } } catch (IOException e) { e.printStackTrace(); } //写法2:读完就将事件切换为写 key.interestOps(SelectionKey.OP_WRITE); } public static void main(String[] args) { SocketMultiplexingSingleThreadv1_1 service = new SocketMultiplexingSingleThreadv1_1(); service.start(); } }
28 多线程处理读写
-
单线程下处理,如果读取数据速度较慢,会导致整个循环事件增加,为了充分利用cpu核数,以及保证多个链接的数据读写互不影响,每次都新启动一个线程来执行读方法和写方法
-
SocketMultiplexingSingleThreadv2:演示cancel
package com.bjmashibing.system.io; import java.io.IOException; import java.net.InetSocketAddress; import java.nio.ByteBuffer; import java.nio.channels.*; import java.util.Iterator; import java.util.Set; public class SocketMultiplexingSingleThreadv2 { private ServerSocketChannel server = null; private Selector selector = null; int port = 9090; public void initServer() { try { server = ServerSocketChannel.open(); server.configureBlocking(false); server.bind(new InetSocketAddress(port)); selector = Selector.open(); server.register(selector, SelectionKey.OP_ACCEPT); } catch (IOException e) { e.printStackTrace(); } } public void start() { initServer(); System.out.println("服务器启动了。。。。。"); try { while (true) { while (selector.select(1) > 0) { Set<SelectionKey> selectionKeys = selector.selectedKeys(); Iterator<SelectionKey> iter = selectionKeys.iterator(); while (iter.hasNext()) { SelectionKey key = iter.next(); iter.remove(); if (key.isAcceptable()) { acceptHandler(key); } else if (key.isReadable()) { //1. cancel表示将链接从红黑树中拿出,即将SelectionKey从selector中拿出 //2. 这是因为主线程和读线程不在同一个线程中,那么在读线程处理完毕之前,selector.select还是能反复获取到这个key,导致readHandler被多次调用 key.cancel(); //3. 新启动线程处理读,该方法不再阻塞 readHandler(key); } else if (key.isWritable()) { key.cancel(); writeHandler(key); } } } } } catch (IOException e) { e.printStackTrace(); } } private void writeHandler(SelectionKey key) { new Thread(() -> { System.out.println("write handler..."); SocketChannel client = (SocketChannel) key.channel(); ByteBuffer buffer = (ByteBuffer) key.attachment(); buffer.flip(); while (buffer.hasRemaining()) { try { client.write(buffer); } catch (IOException e) { e.printStackTrace(); } } buffer.clear(); try { client.register(key.selector(), SelectionKey.OP_READ, buffer); } catch (ClosedChannelException e) { e.printStackTrace(); } }).start(); } public void acceptHandler(SelectionKey key) { try { ServerSocketChannel ssc = (ServerSocketChannel) key.channel(); SocketChannel client = ssc.accept(); client.configureBlocking(false); ByteBuffer buffer = ByteBuffer.allocate(8192); client.register(selector, SelectionKey.OP_READ, buffer); System.out.println("-------------------------------------------"); System.out.println("新客户端:" + client.getRemoteAddress()); System.out.println("-------------------------------------------"); } catch (IOException e) { e.printStackTrace(); } } public void readHandler(SelectionKey key) { new Thread(() -> { System.out.println("read handler....."); SocketChannel client = (SocketChannel) key.channel(); ByteBuffer buffer = (ByteBuffer) key.attachment(); buffer.clear(); int read = 0; try { while (true) { read = client.read(buffer); System.out.println(Thread.currentThread().getName() + " " + read); if (read > 0) { } else if (read == 0) { break; } else { client.close(); break; } } client.register(key.selector(), SelectionKey.OP_WRITE, buffer); } catch (IOException e) { e.printStackTrace(); } }).start(); } public static void main(String[] args) { SocketMultiplexingSingleThreadv2 service = new SocketMultiplexingSingleThreadv2(); service.start(); } }
29 避免频繁系统调用
-
register和cancel都会执行系统调用,因此上面写法会频繁进行系统调用,浪费cpu资源
-
因此考虑,将N个FD进行分组,每组一个selector,每个selector使用单线程处理,这样相当于不同selector中的链接可以并行处理,同一个selector中链接串行处理,这样就既避免了对同一个selector来回register和cancel,又保证了整个程序并行执行不浪费cpu资源
-
例如有100w个链接,我们通常可以使用一个线程,只关注接收链接事件
SelectionKey.OP_ACCEPT
,然后该线程将收到的链接分配给其他4个selector处理,即同时启动4个线程处理,每个线程处理25w个链接 -
SocketMultiplexingThreads:IO Threads
package com.bjmashibing.system.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; import java.util.concurrent.BlockingQueue; import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.atomic.AtomicInteger; public class SocketMultiplexingThreads { private ServerSocketChannel server = null; private Selector selector1 = null; private Selector selector2 = null; private Selector selector3 = null; int port = 9090; public void initServer() { try { server = ServerSocketChannel.open(); server.configureBlocking(false); server.bind(new InetSocketAddress(port)); selector1 = Selector.open(); selector2 = Selector.open(); selector3 = Selector.open(); server.register(selector1, SelectionKey.OP_ACCEPT); } catch (IOException e) { e.printStackTrace(); } } public static void main(String[] args) { SocketMultiplexingThreads service = new SocketMultiplexingThreads(); service.initServer(); NioThread T1 = new NioThread(service.selector1 ,2); NioThread T2 = new NioThread(service.selector2); NioThread T3 = new NioThread(service.selector3); T1.start(); try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } T2.start(); T3.start(); System.out.println("服务器启动了。。。。。"); try { System.in.read(); } catch (IOException e) { e.printStackTrace(); } } } class NioThread extends Thread { Selector selector = null; static int selectors = 0; int id = 0; volatile static BlockingQueue<SocketChannel>[] queue; static AtomicInteger idx = new AtomicInteger(); NioThread(Selector sel,int n ) { this.selector = sel; this.selectors = n; queue =new LinkedBlockingQueue[selectors]; for (int i = 0; i < n; i++) { queue[i] = new LinkedBlockingQueue<>(); } System.out.println("Boss 启动"); } NioThread(Selector sel ) { this.selector = sel; id = idx.getAndIncrement() % selectors ; System.out.println("worker: "+id +" 启动"); } @Override public void run() { try { while (true) { while (selector.select(10) > 0) { Set<SelectionKey> selectionKeys = selector.selectedKeys(); Iterator<SelectionKey> iter = selectionKeys.iterator(); while (iter.hasNext()) { SelectionKey key = iter.next(); iter.remove(); if (key.isAcceptable()) { acceptHandler(key); } else if (key.isReadable()) { readHandler(key); } } } if( ! queue[id].isEmpty()) { ByteBuffer buffer = ByteBuffer.allocate(8192); SocketChannel client = queue[id].take(); client.register(selector, SelectionKey.OP_READ, buffer); System.out.println("-------------------------------------------"); System.out.println("新客户端:" + client.socket().getPort()+"分配到:"+ (id)); System.out.println("-------------------------------------------"); } } } catch (IOException e) { e.printStackTrace(); } catch (InterruptedException e) { e.printStackTrace(); } } public void acceptHandler(SelectionKey key) { try { ServerSocketChannel ssc = (ServerSocketChannel) key.channel(); SocketChannel client = ssc.accept(); client.configureBlocking(false); int num = idx.getAndIncrement() % selectors; queue[num].add(client); } catch (IOException e) { e.printStackTrace(); } } public void readHandler(SelectionKey key) { SocketChannel client = (SocketChannel) key.channel(); ByteBuffer buffer = (ByteBuffer) key.attachment(); buffer.clear(); int read = 0; try { while (true) { read = client.read(buffer); if (read > 0) { buffer.flip(); while (buffer.hasRemaining()) { client.write(buffer); } buffer.clear(); } else if (read == 0) { break; } else { client.close(); break; } } } catch (IOException e) { e.printStackTrace(); } } }
30 Netty模拟
-
MainThread:模拟客户端使用Netty
package com.bjmashibing.system.io.testreactor; public class MainThread { public static void main(String[] args) { //1. 初始化SelectorThreadGroup的过程中,会启动处理IO(读取数据或接收链接)的线程,参数就是线程数 //2. 一个线程使用一个Selector,一个客户端只绑定到其中一个Selector(线程) //3. boss中存放的线程用于接收链接,然后将链接的FD传递给SelectorThreadGroup线程中,worker中的线程用于从链接中读写数据 SelectorThreadGroup boss = new SelectorThreadGroup(3); SelectorThreadGroup worker = new SelectorThreadGroup(3); //4. 设置为boss线程组工作的worker组,该boss线程组中的线程获取到的链接,会发送给其关联的worker组中的线程去读写数据 //5. 如果不设置worker,那么自身既是boss又是worker,即既接收链接,又读写链接中数据 boss.setWorker(worker); //6. 每执行一次bind,就会绑定一个端口,然后将产生的监听注册到boss线程组中一个线程对应的selector中,boss中的一个线程,可以接收来自多个端口监听到的链接 boss.bind(9999); boss.bind(8888); boss.bind(6666); boss.bind(7777); } }
-
SelectorThread
package com.bjmashibing.system.io.testreactor; import java.io.IOException; import java.nio.ByteBuffer; import java.nio.channels.*; import java.util.Iterator; import java.util.Set; import java.util.concurrent.LinkedBlockingQueue; public class SelectorThread extends ThreadLocal<LinkedBlockingQueue<Channel>> implements Runnable { Selector selector = null; //1. 使用ThreadLocal,和直接new相似,区别在于,如果直接new,那么每个线程对象,都会存放一个LinkedBlockingQueue,而如果使用ThreadLocal,可以将该变量定义为public static ThreadLocal,这样,所有线程对象只持有一个ThreadLocal,但通过ThreadLocal对象的get方法,还是会获取到不同的LinkedBlockingQueue对象 // LinkedBlockingQueue<Channel> lbq = new LinkedBlockingQueue<>(); LinkedBlockingQueue<Channel> lbq = get(); SelectorThreadGroup stg; //2. 重写ThreadLocal中initialValue方法,为每个线程,创建一个新的LinkedBlockingQueue @Override protected LinkedBlockingQueue<Channel> initialValue() { return new LinkedBlockingQueue<>(); } SelectorThread(SelectorThreadGroup stg) { try { this.stg = stg; //每个线程持有一个新Selector selector = Selector.open(); } catch (IOException e) { e.printStackTrace(); } } @Override public void run() { //整个循环称为NioEventLoop while (true) { try { //1. select()方法中不带参数,会阻塞,其他线程中调用wakeup才能解除该阻塞 //2. select()方法和register方法是同步的,无法同时执行 //3. 如果select方法和register方法在不同线程中执行,那么为了register,就必须先执行wakeup跳过select方法 //4. 但多线程下我们无法保证线程调度顺序,很有可能线程1执行wakeup后,register之前,线程2刚跳过select的阻塞,经过一次循环又来到了select位置,那么再次阻塞 //5. 出于这种情况,我们通常只能将select和register方法在同一个线程中执行 //6. 但由于之前说了,boss线程用于接收链接,并将链接注册到worker线程中,所以这两个动作正常情况无法在一个线程中完成 //7. 因此最终方案为,boss线程将接收到的链接,不进行注册,而是存放到一个变量中,然后唤醒worker线程,最终由worker线程自己来完成注册 //8. 由于可能产生多个链接,所以这个变量最好选择一个有序的集合,最终选定使用LinkedBlockingQueue int nums = selector.select(); //阻塞 wakeup() if (nums > 0) { Set<SelectionKey> keys = selector.selectedKeys(); Iterator<SelectionKey> iter = keys.iterator(); while (iter.hasNext()) { SelectionKey key = iter.next(); iter.remove(); if (key.isAcceptable()) { //如果当前为boss线程,会进入该方法,将获取到的链接,放到该boss线程持有的worker组中任意一个线程的LinkedBlockingQueue中 acceptHandler(key); } else if (key.isReadable()) { readHander(key); } else if (key.isWritable()) { } } } //将队列中所有内容都完成注册 //此处按老师的意思,功能为处理队列中的任务,可以是完成他人给自己的文件描述符的注册,也可以是他人给自己的业务逻辑的处理,比如队列中对象如果是一个Runnable类型,那么在当前线程调用其run方法,这样,其他线程就可以将要执行的业务逻辑放入当前线程的任务队列中 while (!lbq.isEmpty()) { Channel c = lbq.take(); //如果当前为boss线程,队列中存放的是监听 if (c instanceof ServerSocketChannel) { ServerSocketChannel server = (ServerSocketChannel) c; server.register(selector, SelectionKey.OP_ACCEPT); System.out.println(Thread.currentThread().getName() + " register listen"); } //如果当前为worker线程,队列中存放的是链接 else if (c instanceof SocketChannel) { SocketChannel client = (SocketChannel) c; ByteBuffer buffer = ByteBuffer.allocateDirect(4096); client.register(selector, SelectionKey.OP_READ, buffer); System.out.println(Thread.currentThread().getName() + " register client: " + client.getRemoteAddress()); } } } catch (IOException e) { e.printStackTrace(); } catch (InterruptedException e) { e.printStackTrace(); } } } private void readHander(SelectionKey key) { System.out.println(Thread.currentThread().getName() + " read......"); ByteBuffer buffer = (ByteBuffer) key.attachment(); SocketChannel client = (SocketChannel) key.channel(); buffer.clear(); while (true) { try { int num = client.read(buffer); if (num > 0) { buffer.flip(); while (buffer.hasRemaining()) { client.write(buffer); } buffer.clear(); } else if (num == 0) { break; } else if (num < 0) { System.out.println("client: " + client.getRemoteAddress() + "closed......"); key.cancel(); break; } } catch (IOException e) { e.printStackTrace(); } } } private void acceptHandler(SelectionKey key) { System.out.println(Thread.currentThread().getName() + " acceptHandler......"); ServerSocketChannel server = (ServerSocketChannel) key.channel(); try { SocketChannel client = server.accept(); client.configureBlocking(false); stg.nextSelectorV3(client); } catch (IOException e) { e.printStackTrace(); } } public void setWorker(SelectorThreadGroup stgWorker) { this.stg = stgWorker; } }
-
SelectorThreadGroup:模拟Netty中NioEventLoopGroup的概念
package com.bjmashibing.system.io.testreactor; import java.io.IOException; import java.net.InetSocketAddress; import java.nio.channels.Channel; import java.nio.channels.ServerSocketChannel; import java.util.concurrent.atomic.AtomicInteger; public class SelectorThreadGroup { SelectorThread[] sts; ServerSocketChannel server = null; AtomicInteger xid = new AtomicInteger(0); SelectorThreadGroup stg = this; public void setWorker(SelectorThreadGroup stg) { this.stg = stg; } SelectorThreadGroup(int num) { sts = new SelectorThread[num]; for (int i = 0; i < num; i++) { sts[i] = new SelectorThread(this); new Thread(sts[i]).start(); } } public void bind(int port) { try { server = ServerSocketChannel.open(); server.configureBlocking(false); server.bind(new InetSocketAddress(port)); nextSelectorV3(server); } catch (IOException e) { e.printStackTrace(); } } public void nextSelectorV3(Channel c) { try { SelectorThread st = null; //如果传入的是一个监听,说明是在boss绑定端口代码位置处,调用该方法的对象,是一个boss对象,因此调用next方法,在当前组中选一个线程的lbq放入 if (c instanceof ServerSocketChannel) { st = next(); st.lbq.put(c); st.setWorker(stg); } //如果传入的是一个链接,说明是在boss线程的acceptHandler处,调用方法的对象还是boss对象,此时应该调用nextV3方法,在当前组的worker组中选一个线程的lbq放入 else { st = nextV3(); st.lbq.add(c); } //打断select的阻塞,让对应的线程完成后续对队列中内容的注册 st.selector.wakeup(); } catch (InterruptedException e) { e.printStackTrace(); } } //在当前组中选一个线程 private SelectorThread next() { int index = xid.incrementAndGet() % sts.length; return sts[index]; } //在当前组的worker组中选一个线程 private SelectorThread nextV3() { int index = xid.incrementAndGet() % stg.sts.length; return stg.sts[index]; } }
31 Netty基本使用
-
pom.xml:idea可以在pom文件中,右键–Generate–输入想引入的项目名,这类似在maven官网直接搜索
<dependency> <groupId>io.netty</groupId> <artifactId>netty-all</artifactId> <version>4.1.49.Final</version> </dependency> <dependency> <groupId>junit</groupId> <artifactId>junit</artifactId> <version>4.11</version> </dependency>
-
使用nc启动一个服务端:
nc -l 192.168.246.128 9090
-
MyNetty
package com.bjmashibing.system.io.netty; import io.netty.bootstrap.Bootstrap; import io.netty.bootstrap.ServerBootstrap; import io.netty.buffer.ByteBuf; import io.netty.buffer.PooledByteBufAllocator; import io.netty.buffer.Unpooled; import io.netty.channel.*; import io.netty.channel.nio.NioEventLoopGroup; import io.netty.channel.socket.SocketChannel; import io.netty.channel.socket.nio.NioServerSocketChannel; import io.netty.channel.socket.nio.NioSocketChannel; import io.netty.util.CharsetUtil; import org.junit.Test; import java.net.InetSocketAddress; public class MyNetty { @Test public void myBytebuf() { //1. ByteBuf由Netty提供,对标jdk中ByteBuffer,提供一个缓冲区,写数据时,先写入缓冲区,缓冲区满后再写入内核 //2. ByteBuf中为读写各自提供了指针,因此不必像ByteBuffer一样来回flip,使用更加方便 //3. ByteBuf的read、write相关方法会移动指针,而get、set相关方法不移动指针 //4. ByteBuf的创建 //a. 堆外分配:初始8byte,最多扩容到20byte // ByteBuf buf = ByteBufAllocator.DEFAULT.buffer(8, 20); //b. 堆内分配 // ByteBuf buf = UnpooledByteBufAllocator.DEFAULT.heapBuffer(8, 20); //c. 非池化 // ByteBuf buf = UnpooledByteBufAllocator.DEFAULT.heapBuffer(8, 20); //d. 池化 ByteBuf buf = PooledByteBufAllocator.DEFAULT.heapBuffer(8, 20); //观察每次写入数据后,ByteBuf中各指针的变化情况 print(buf); buf.writeBytes(new byte[]{1, 2, 3, 4}); print(buf); buf.writeBytes(new byte[]{1, 2, 3, 4}); print(buf); buf.writeBytes(new byte[]{1, 2, 3, 4}); print(buf); buf.writeBytes(new byte[]{1, 2, 3, 4}); print(buf); buf.writeBytes(new byte[]{1, 2, 3, 4}); print(buf); //写5次,每次写4个byte,调该方法前就写入了20byte,此时继续写入,会抛异常 // buf.writeBytes(new byte[]{1,2,3,4}); } public static void print(ByteBuf buf) { //1. 是否可读出数据 System.out.println("buf.isReadable() :" + buf.isReadable()); //2. 从哪个索引位置开始读 System.out.println("buf.readerIndex() :" + buf.readerIndex()); //3. 能读多少byte System.out.println("buf.readableBytes() " + buf.readableBytes()); //4. 是否可写入数据 System.out.println("buf.isWritable() :" + buf.isWritable()); //5. 从哪个索引位置开始写 System.out.println("buf.writerIndex() :" + buf.writerIndex()); //6. 能写入多少,应该等于capacity-writerIndex System.out.println("buf.writableBytes() :" + buf.writableBytes()); //7. 初始capacity是8,当超过8,扩大一倍变为16,最终变为20 System.out.println("buf.capacity() :" + buf.capacity()); //8. 能写入的最大byte数,一直为20 System.out.println("buf.maxCapacity() :" + buf.maxCapacity()); //9. 堆内还是堆外分配的,true表示堆外,false表示堆内 System.out.println("buf.isDirect() :" + buf.isDirect()); System.out.println("--------------"); } @Test public void loopExecutor() throws Exception { //1. NioEventLoopGroup:功能类似自己写的SelectorThreadGroup,可以当成是Selector+线程池 //2. 如果传入参数为1,相当于是一个只有1个线程的线程池,因此同一时间只能有一个线程在执行,当第一个execute没执行完时,第二个execute不会执行,如果想让两个同时都执行,传入参数应该为2 NioEventLoopGroup selector = new NioEventLoopGroup(2); selector.execute(() -> { try { for (; ; ) { System.out.println("hello world001"); Thread.sleep(1000); } } catch (InterruptedException e) { e.printStackTrace(); } }); selector.execute(() -> { try { for (; ; ) { System.out.println("hello world002"); Thread.sleep(1000); } } catch (InterruptedException e) { e.printStackTrace(); } }); System.in.read(); } //clientMode:按之前jdk的NIO的编程模型,来使用netty编写客户端 @Test public void clientMode() throws Exception { //Selector selector = Selector.open(); NioEventLoopGroup thread = new NioEventLoopGroup(1); //SocketChannel client = SocketChannel.open(); NioSocketChannel client = new NioSocketChannel(); //系统调用应该是epoll_ctl(5,ADD,3),即向selector中添加client,之前的写法就比较别扭 //client.register(selector, SelectionKey.OP_READ, buffer); thread.register(client); //1. 由于客户端发送数据是自己决定的,而接收数据不一定什么时候,因此对接收数据采用响应式编程 //2. 响应式编程一般都是异步的,类似观察者模式,观察接收数据这个事件,且不阻塞,一旦事件发生,就通知所有观察者 //3. 当链接接收到数据后,会依次调用client的pipeline中的所有handler对数据进行处理 ChannelPipeline p = client.pipeline(); //添加对读到的数据的处理的handler p.addLast(new MyInHandler()); //由于是响应式编程,connect不会立即返回结果,而是返回一个表示未来结果的对象ChannelFuture,不再和jdk一样返回Boolean //Boolean connect = client.connect(new InetSocketAddress("192.168.246.128", 9090)); ChannelFuture connect = client.connect(new InetSocketAddress("192.168.246.128", 9090)); //由于connect方法是异步的,因此使用其返回结果的sync方法,表示一直阻塞到connect真正建立成功 ChannelFuture sync = connect.sync(); //通过字符串得到ByteBuf ByteBuf buf = Unpooled.copiedBuffer("hello server".getBytes()); //客户端发送信息给服务端 ChannelFuture send = client.writeAndFlush(buf); //writeAndFlush方法是异步的,因此也需要通过sync方法,一直阻塞到发送成功 send.sync(); //1. channel():得到链接 //2. closeFuture():获得一个关闭通道的异步任务,这个任务会在通道关闭后才完成 //3. 由于closeFuture()是异步的,为了防止执行完后,由于所有代码执行完毕,直接退出JVM,因此加上sync进行阻塞,这样只要通道不关闭,即与当前客户端链接的服务端不关闭,该方法就会一直阻塞 //4. 这样执行到sync就会阻塞,而由于当前客户端阻塞不关闭,因此nc -l开启的服务端也不关闭 sync.channel().closeFuture().sync(); System.out.println("client over...."); } //clientMode:按之前jdk的NIO的编程模型,来使用netty编写服务端 @Test public void serverMode() throws Exception { NioEventLoopGroup thread = new NioEventLoopGroup(1); NioServerSocketChannel server = new NioServerSocketChannel(); thread.register(server); //当监听接收到链接后,会调用handler对链接进行处理 ChannelPipeline p = server.pipeline(); p.addLast(new MyAcceptHandler(thread, new ChannelInit())); // p.addLast(new MyAcceptHandler(thread,new MyInHandler())); ChannelFuture bind = server.bind(new InetSocketAddress("192.168.1.103", 9090)); //bind.sync:绑定端口成功后才解除阻塞 //closeFuture.sync:监听被关闭才解除阻塞 bind.sync().channel().closeFuture().sync(); System.out.println("server close...."); } //使用Netty的推荐写法 @Test public void nettyClient() throws InterruptedException { NioEventLoopGroup group = new NioEventLoopGroup(1); Bootstrap bs = new Bootstrap(); //Bootstrap:引导程序,相当于一个脚手架 //group:指定NioEventLoopGroup //channel:指定channel类型 //handler:指定channel的pipeline中添加的handler //connect:链接服务端 //handler:当channel为NioSocketChannel,那么handler用于添加处理链接数据的handler,如果channel为NioServerSocketChannel,handler用于添加处理accept到到链接的handler //childHandler:channel为NioServerSocketChannel类型,才能使用该方法,用于添加处理链接数据的handler //ChannelInitializer:官方又提供了一个和ChannelInit功能相同的类ChannelInitializer,将该类对象放入链接的pipeline中 ChannelFuture connect = bs.group(group) .channel(NioSocketChannel.class) // .handler(new ChannelInit()) .handler(new ChannelInitializer<SocketChannel>() { @Override protected void initChannel(SocketChannel ch) throws Exception { ChannelPipeline p = ch.pipeline(); p.addLast(new MyInHandler()); } }) .connect(new InetSocketAddress("192.168.0.106", 9090)); Channel client = connect.sync().channel(); ByteBuf buf = Unpooled.copiedBuffer("hello server".getBytes()); ChannelFuture send = client.writeAndFlush(buf); send.sync(); client.closeFuture().sync(); } //使用Netty的推荐写法 @Test public void nettyServer() throws InterruptedException { NioEventLoopGroup group = new NioEventLoopGroup(1); ServerBootstrap bs = new ServerBootstrap(); //作为服务端,既要accept链接,又要read链接中数据,因此需要添加两个组,第一个是boss组,第二个是workder组 ChannelFuture bind = bs.group(group, group) //指定channel类型 .channel(NioServerSocketChannel.class) //官方提供了一个和MyAcceptHandler功能相同的类来接收新链接,此处我们不必显示指定 // .childHandler(new ChannelInit()) //我们需要指定处理链接中数据的handler .childHandler(new ChannelInitializer<NioSocketChannel>() { @Override protected void initChannel(NioSocketChannel ch) throws Exception { ChannelPipeline p = ch.pipeline(); p.addLast(new MyInHandler()); } }) .bind(new InetSocketAddress("192.168.0.106", 9090)); bind.sync().channel().closeFuture().sync(); } } class MyInHandler extends ChannelInboundHandlerAdapter { //通道注册成功后执行 @Override public void channelRegistered(ChannelHandlerContext ctx) throws Exception { System.out.println("client registed"); } @Override public void channelActive(ChannelHandlerContext ctx) throws Exception { System.out.println("client active"); } //通道接收到内容后执行,如果通道类型为NioServerSocketChannel,那么对应接收到链接后处理内容,如果通道类型为NioSocketChannel,那么对应接收到链接中的数据后处理的内容 //即netty帮你进行系统调用read、accept,你只需要编写read、accept之后的代码 @Override public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception { //channel类型为NioSocketChannel时,从通道中接收到的数据msg类型实际上是ByteBuf,表示链接中数据 ByteBuf buf = (ByteBuf) msg; // CharSequence str = buf.readCharSequence(buf.readableBytes(), CharsetUtil.UTF_8); //将读到的内容转为一个字符序列,readCharSequence方法会移动读指针,getCharSequence方法不会移动读指针,这就意味着,如果调用了readCharSequence,那么再通过writeAndFlush将ByteBuf写给服务端后,服务端再想读取这个ByteBuf,就会发现这个ByteBuf不可读,导致实际上写出去的内容为空 //也正是因为readCharSequence会移动指针,因此每次都一定是从头开始读,而getCharSequence可以获取任意部分的字符序列,因此需要一个起始位置,和一个结束位置 CharSequence str = buf.getCharSequence(0, buf.readableBytes(), CharsetUtil.UTF_8); System.out.println(str); ctx.writeAndFlush(buf); } } class MyAcceptHandler extends ChannelInboundHandlerAdapter { private final EventLoopGroup selector; private final ChannelHandler handler; public MyAcceptHandler(EventLoopGroup thread, ChannelHandler myInHandler) { this.selector = thread; this.handler = myInHandler; //ChannelInit } @Override public void channelRegistered(ChannelHandlerContext ctx) throws Exception { System.out.println("server registerd..."); } //通道类型为NioSocketChannel,因此该方法为系统调用accept后执行的内容 @Override public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception { //通道类型为NioSocketChannel时,从通道中接收到的数据msg类型为SocketChannel,表示是一个链接 SocketChannel client = (SocketChannel) msg; //和客户端编程逻辑相同:客户端发送数据是自己决定的,而接收数据不一定什么时候,因此对接收数据采用响应式编程来数据后的处理 ChannelPipeline p = client.pipeline(); //1. 一定要注意,通常MyAcceptHandler这部分接收链接后的处理,应该是Netty实现的,因为所有监听接收到链接后,都应该执行selector.register(client)的功能 //2. 但对接收到的链接,其再接收到数据后的处理,一般不同,因此handler应该作为参数传入 //3. 因此此处代码通常不能直接写死成new MyInHandler //4. 但当监听收到的多个链接,是无法使用同一个handler实例的,会抛异常,这是因为Netty想提醒你,如果你这个handler线程不安全,多个client同时使用,会出现异常 //5. 因此我们通常需要在定义这个handler类上写明注释@ChannelHandler.Sharable,表示我们程序员已经将这个handler设计成了一个线程安全的类,可以多个客户端使用 //6. 不过将一个handler设计成线程安全通常比较麻烦,我们希望的是为每个client分配一个新的handler实例 //7. 因此我们可以创建下面名为ChannelInit的handler,并使用@ChannelHandler.Sharable注释,此处将同一个ChannelInit对象放入pipeline,ChannelInit后续可以每次完成链接的注册后,就创建一个新的MyInHandler实例放入pipeline p.addLast(handler); //1. 因为需要完成注册,所以需要将selector作为构造器参数传递进来 //2. 注意,当register方法发生后,就调用了ChannelInit的channelRegistered将MyInHandler也放入pipeline,但由于client的注册已经完成,所以MyInHandler的channelRegistered不会再被调起 //3. 虽然register放在addLast之前执行不会报错,但这会导致ChannelInit的channelRegistered不能被正常调起,从而导致未将MyInHandler放入pipeline,最后整个功能不正常 selector.register(client); } } //使用该注释后,多个channel的pipeline中就可以放同一个ChannelInit实例 @ChannelHandler.Sharable class ChannelInit extends ChannelInboundHandlerAdapter { @Override public void channelRegistered(ChannelHandlerContext ctx) throws Exception { //通过channel方法拿到客户端 Channel client = ctx.channel(); ChannelPipeline p = client.pipeline(); //向客户端pipeline中放入一个新MyInHandler对象 p.addLast(new MyInHandler()); //此时客户端pipeline中有两个handler,一个是ChannelInit,一个是MyInHandler,ChannelInit的作用就是在客户端注册时生成新ChannelInit对象,因此此时ChannelInit已经没有用了 //如果不从pipeline中移除ChannelInit,那么一旦ChannelInit有channelRead方法,那么每次有数据进入链接,都会重复调用channelRead中逻辑,这有违我们本意 ctx.pipeline().remove(this); } }