java怎么调用.net ashx_(三)JavaBIO、网络编程、系统调用

JavaIO通常指的是对磁盘进行操作的IO相关API的集合。而JavaBIO指的是在Java网络编程中,对于Socket的相关操作。JavaBIO即Java Blocking IO,阻塞是IO,不过我们要想了解这个阻塞是什么,并不是一件简单的事情。首先我们要了解BIO遵循的网络编程模型以及给该模型提供实现支持的系统调用,才能简单的理清所谓的同步阻塞IO,同步非阻塞IO到底是个什么东西。

一、阻塞 / 非阻塞

阻塞/非阻塞指的是网络IO相关API调用后进程的状态,调用阻塞IO的API时可能由于资源尚未准备好而导致调用进程阻塞挂起,而调用非阻塞IO的API时则必然不会导致调用进程阻塞挂起,阻塞和非阻塞从概念上还是很好理解的。

69467e365d7ace850d84031d448258dd.png

上面这张图指的是操作系统进程状态转换图,而对于Java来说,其自己有自己的线程的概念。JVM负责用Java线程在用户空间的调度,所以其自己维护了一套Java线程的状态切换机制,但是Java线程最终是要映射到操作系统的进程/线程(准确来说应该叫轻量级进程或者叫内核线程)上的(具体的映射细节我们不用关心,以我现在的水平还不需要关注这个东西),所以就要有一个状态间的映射关系。先来看下Java线程的状态:

0d5fdc32f726c87c4071ea1cfd11d8dc.png

1a0b95fd92280ca465946ba44cf9a7bb.png

从源码中可以看到,Java中定义了六种线程状态,而在操作系统上基本就只有三种状态。所以他们之间一定存在一种状态的映射,而且这种映射的结果往往会超出我们的定向思维范畴。

我们可以通过一个事实来帮我们印证这个问题,当我们调用网络IO的阻塞API时,Java线程依然处于RUNNABLE状态,而操作系统上对应的进程/线程处于阻塞挂起状态

首先我们使用socket编写一个网络编程最简单的入门例子:

cd9677ea5f466a407c2cefa29a707310.png
客户端

3ecdae05a4e570ed2012e2da3e107a6f.png
服务端

先运行服务端的代码然后再运行客户端。此时服务阻塞在了bufferedReader.readLine()函数。客户端阻塞在scanner.nextLine()函数。首先我们使用JDK提供的JPS工具查看当前操作系统上有哪些正在运行的Java进程

c9079f2241b48059c48a4b1c58a7904b.png

Main函数相当于Java应用程序的入口,所以每启动一个main方法,就相当于启动了一个JVM实例,所以一个Main函数也对应操作系统上的一个Java进程。在知道这两个进程的进程号之后,我们可以使用JDK提供的另一个工具JStack来查看这两个Java进程中的Java线程的状态,也就是Java线程在JVM上的状态:

首先是客户端:

93972a9371aca87ad3c9082f23cedb82.png
客户端进程main线程

通过客户端的Main线程我们能够清楚的看到,"我们认知中已经被阻塞了的客户端"的main线程,在JVM上实际的状态还是RUNNABLE的,并且事实上线程也确实停在了readBytes这个native方法上。下面再来看下服务端:

245347e4efd336f00abe6226fc638fae.png
服务端进程main线程

和客户端同样,Server进程的main线程虽然事实上停止在了socketRead0这个native方法上,但是其在JVM上的线程状态依然是RUNNABLE。

前面提到过,Java线程最终会生成一个轻量级进程交给操作系统去调度,那么我们能不能找到Java线程对应的这个轻量级进程呢?在windows上我们可以通过微软的PsList工具来查看一个进程的线程(注意这个是操作系统的线程而不是JVM的线程。

PsList - Windows Sysinternals​docs.microsoft.com
32162b72778470f357d5b336dea329a9.png

下载解压后将pslist.ext复制到C:WindowsSystem32文件夹下即可。

首先来查看客户端进程的操作系统线程,使用 pslist -dmx pid命令来查看进程的详细线程信息:

c3120177d60036d5352ddcb748f1a54a.png

tid是线程的id,pri是线程的优先级,vm是虚拟内存,cswtch是线程上下文切换次数,回过头看我们用jstack查看JVM线程信息时,会发现其有一个nid属性,其全称为native thread id,即运行时环境所在的操作系统的tid。

a1a3b9e2ab5dd74e9fd86da3d6507d41.png

我们将这个十六进制数转化成十进制得到客户端main线程在操作系统上tid为:

2b5f0c3da279d3872d65a172768b7989.png

在pslist的结果中我们可以找到这个tid对应的线程:

41b97e08a7c738e85aea6b245a17822c.png

可以看到其当前线程状态为Wait:Executive,wait即表示线程被阻塞,所以虽然该线程在JVM是RUNNABLE的,但是在操作系统上是被阻塞的。童样的步骤我们来看下服务端线程:

e3820496e8d8c3120b48f0be5804cddc.png

fca41b2078aa1ebc88d2896ebedc2380.png

5e953339eaa9bd145900751a030ae03d.png

可以看到服务端线程同样处于wait状态。

经过上面的分析,我们现在可以下结论上面叫阻塞调用了即:

会引起操作系统进程或线程变成Wait状态的API的调用,就是阻塞调用。

二、同步 / 异步

而同步/异步指的是调用的过程。开发中,对于一个API,有时候我们需要知道调用的结果,当前应用程序进程的后续指令需要依赖到这个调用结果。调用的结果可能是指API的具体返回值,也可能仅仅是API调用已经完成这样一个标志。

进程我们都知道,就是一段代码的运行时状态。代码经过编译,汇编等一系列步骤之后,最终被翻译成一条条CPU认识的机器指令,既然代码的静态文本,那这些机器指令在正常情况下也是静态的,即在正常运行的情况下,CPU执行进程指令的顺序是我们可以通过输入而可预知的。

那么在什么情况下我们无法预知指令的运行顺序呢?首先应该想到的就是异常的情况,破坏了原本的指令流,也就是在前面文章中介绍到的发生了ECF。

landexiang:(一)异常控制流​zhuanlan.zhihu.com
a7b58196616b73dae4e45beb170c540a.png

而另外一种情况就是发生了异步调用。回顾之前的线程状态快照分析,服务端的Java线程虽然是RUNNABLE状态,但事实上其对应的操作系统线程是阻塞了的,并且是被SocketInputStream.socketRead0()这个方法阻塞的:

18b54f117b922ecbdd8b22477920ec46.png

我们可以从git上下载openJDK的代码,然后在openJDK中查看这个native方法的c源码:

bpupadhyaya/openjdk-8​github.com
2a50ae4719f2110e1366d887c032ad47.png

SocketInputSream的路径为:

1007670b1dd3537a20d71b09f4c7c4fb.png

native方法的命名是有一定规则的,比如对java.net.ScoketInputStream.readSocket0()方法,其native源码的方法名为Java_java_net_SocketInputStream_socketRead0,我们只需要在openJdk的源码中全局搜索这个字符串就行:

80cb1deaca6390b92cca3a885aa6aed7.png

我们可以看到有windows和unix两个版本的源码,由于我是在windows下进行实验的,所以来看下windows版本的这个native方法源码:

JNIEXPORT 
https://blog.csdn.net/CV_Jason/article/details/80026265​blog.csdn.net

从native方法的源码我们可以看到除了多了开头两个参数以外, 剩下的参数和java方法中的声明保持一致。其中:

JNIEnv 是一个线程相关的结构体,代表了当前线程的Java运行时环境,所以对于每一个线程都有一个对应的JNIEnv对象。JNIEnv对象主要有两个作用:1、调用Java函数 2、操作Java对象。

第二个参数为jobject,指的是调用该native方法的对象的指针,如果该方法的static方法,则传入的参数应该是jclass。

后面的几个参数和SocketInputStream中的声明一一对应。

c7029e1fe7801b5161aa0c1babe21494.png

在native方法的源码中,我们可以看到第一步取得代表该socket连接的文件描述符fd:

3ab3133d973790d61825f851a0c58f82.png

在拿到fd之后,就能够读取这个fd所关联的socket的数据,不过从socket读出的数据并不是直接复制到目标目标数组jbyteArray data中的,而是需要先复制到一个缓冲区中:

d8401c501d389d4bd5e2857be567f7f1.png

如上图源码所示,bufP是指向缓冲区的指针。如果我们要读取的数据长度小于堆栈缓冲区BUF的长度MAX_BUFFER_LEN,则直接使用堆栈缓冲区。如果待读取数据的长度大于MAX_BUFFER_LEN,则需要在堆上使用malloc方法动态分配一个更大的连续空间来当做临时缓冲区,动态分配的缓冲区最大长度为MAX_HEAP_BUFFER_LEN。

在该native方法所在的文件开头,我们可以看到一个头文件引入:

f6d5605756b9af920631f2eaa4236316.png

找到同目录下的net_util.h文件:

e71b4622c77319269b224be94c148618.png

3384680ecbe4c11fa0bb8a71d62069e4.png

可以看到堆栈缓冲区默认值为2048个字符,而允许我们在堆上动态分配的临时缓冲区最大长度为65535个字符。

在初始化数据缓冲区完成之后,如果timeout时间大于0,则需要进行超时检测。如果没有设置则不需要检测。接下来就是这个代码的核心逻辑了:

f942c857787b7be40febca4d618312fe.png

由于应用程序是没有权限去操作底层的socket的,所以该native方法实际上是调用了recv系统调用将socket缓冲区中的数据复制到了之前创建的bufP缓冲区上,并且通过SetByteArrayRegion将缓冲区上的数据复制到该native方法参数的数组之中。

recv系统调用可能会引起当前线程的阻塞挂起。当前java线程调用recv方法之后触发系统调用,CPU状态切换为内核态(高特权状态),如果socket的缓冲区上数据已经准备好,则系统调用中断处理程序将数据从socket缓冲区(也可以叫做内核缓冲区)上复制到应用程序的缓冲区bufP中,而socket缓冲区上的数据则是由网卡驱动使用DMA从网卡的硬件缓冲区复制到socket缓冲区上的。如果最终从recv读取到了数据,则将recv读取到的数据通过SetByteArrayRegion方法复制到我们在JVM上分配的Java数组data,也就是native方法socketRead0的参数 byte[] b。所以Java从socket上读取一段数据,其实经过了三次数据复制。

用户态代码在调用系统调用时,其实是主动触发了系统调用类型的ECF,然后异常处理程序将当前线程的一些关键信息复制到内核栈,CPU切换为高特权级状态,从而将数据从socket缓冲区中复制到用户态的临时缓冲区bufP中,但在执行系统调用时,线程上下文并没有改变依然是调用者线程。

当recv系统调用完成时,CPU又切换为低特权级状态,继续执行应用程序代码。而对于recv结果的处理,也是在当前线程中完成的。通过源码结合我们的分析可以得到下面结论:

从当前线程调用API开始,直到API结束,当前线程并没有做任何与该API无关的事情,当前线程所获得的CPU时间片都用来执行该API相关的指令,所以我们说当前线程同步调用了API。而对于IO函数,其本质上是通过调用系统调用recv来实现的,但是当前线程和recv之间是同步调用的关系,所以说JavaBIO是一个同步IO。

理解了上面为什么同步调用,再反过来理解异步 调用就很好办了。既然同步调用表示当前线程在取得API的执行结果之后才能继续执行其他指令,则异步调用则表示:

1、API执行结果与调用者线程无关,API的指令和当前线程的后续指令并发执行——Runnable模式

2、API执行结果主动放到一个地方,调用者线程用到的时候自己去取,不需要调用者线程一直等到结果产生,API的指令和当前线程的后续指令也是并发执行——Future模式

而对于Runnable和Future则属于Java并发编程中的内容,这里就不多介绍了。

现在我们来思考一个问题,如何让你设计一个异步IO的API,他应该是什么样呢?假如这个系统调用就叫做recv_sync(),结合future的概念我们可能做出下面猜想:

当native方法中调用recv_sync时并不需要等待recv_sync完成,而是直接返回,而数据也不需要复制三次,而是由recv_sync函数直接帮我们把socket缓冲区上的数据复制到在应用程序中定义的目标数组中。

总结:

下面来用简短的一句话来总结BIO为什么是同步阻塞IO,即:

应用程序无法直接操作IO设备,需要通过系统调用来帮我们操作IO设备。而BIO底层依赖的系统调用recv可能会导致Java native线程变为wait状态,所以叫做阻塞IO。

而Java native线程在调用recv系统调用时,必须等待其结束才能够执行系统调用后的下一个指令,所以是同步调用。

由此可见,BIO之所以叫BIO完全是因为底层依赖是recv系统调用的结果,这个是我们在应用程序层面无法改变的事情。所以才产生了后来的NIO,AIO等,从底层上对JavaIO进行改进从而提升效率。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值