网络与分布式计算
Java Socket编程
1 Java基础概述
1.1 Java Stream
Java通过流(Stream)进行IO操作。Stream是一组有序,有起点终点(可以是文件,键鼠,网络节点等)的字节(8-bit字节)的数据序列,包括输入流(Input Stream,读取)、输出流(Output Sream,写入),输入输出字节流以字节为处理单位。此外,Reader/Writer处理16位Unicode编码的字符的字符流,包含Reader和Writer。
Java中,IO操作采用Decorator设计模式(在原有的基础上,给对象添加一些额外的职责,提供更多的功能),允许程序员无限拓展IO的功能。需要创建两个类分别继承FilterInputStream和FilterOutputStream,重写其中的read()和writer()方法实现基本的读写功能,也可重写其他方法提供附加功能。由于它们在功能上是对称的,在使用时需要一起使用。
1.2 Java 多线程
一条线程指的是进程中的一个单一顺序的控制流。一个进程中可以并发多个线程,每条线程可以并行执行不同的任务。
Java中,实现多线程需要拓展java.lang.Thread类。
class MyThread extends Thread {
public MyThread(String name) {
super(name);
}
@Override
public void run(){
//线程体
}
}
new MyThread("A").start();//启动线程
也可以实现java.lang.Runnable接口。
public class MyThread implements Runnable {
@Overrude
public void run(){
//线程体
}
}
Thread thread1 = new Thread(new MyThread());
thread1.start();//启动线程
此外,Java也可通过Callable和Future创建线程。
线程有如下状态:
- 新建状态(New)。使用
new关键字建立一个线程对象,该线程对象就处于该状态,并保持,直到就绪。 - 就绪状态(Runnable)。当线程对象调用了
start()方法后,进入就绪状态。线程位于就绪队列中,需要等待JVM中线程调度器的调度。 - 运行状态(Running)。执行
run()方法后,线程处于运行状态,获取CPU,运行代码。 - 阻塞状态(Blocked)。线程因为某些原因,如调用了
sleep()、suspend()等方法,失去占用的资源,放弃了CPU的使用权,线程就会进入阻塞状态,暂时停止运行。线程睡眠时间已到或重新获得设备资源后,重新进入就绪状态。阻塞状态分为以下三种:- 等待阻塞。线程执行
wait()方法,进入等待阻塞状态。 - 同步阻塞。运行的线程在获取对象的同步锁时,若该同步锁被别的线程占用,JVM会把该线程放入锁池中。
- 其他阻塞。调用
sleep()或join()方法发出IO请求,线程就会被JVM设置成阻塞状态。
- 等待阻塞。线程执行
- 死亡状态(Dead)。线程完成任务或者其他终止条件发生时,或因为异常退出
run()方法,线程就会终止,结束生命周期。
2 基于TCP的Socket通信
在Java中网络编程,需要进行Socket编程和URL处理。
目前几乎所有的应用程序都使用套接字(Socket)进行通信。Socket通信基于TCP/IP协议(包含TCP、FTP、SMTP、UDP、IP等协议),实现在两台计算机进程之间的通信。网络中的进程用IP地址、协议和端口标识,其中,IP地址用于表示网络中的主机(127.0.0.1为本机localhost),协议+端口用于标识该主机中的应用程序。
URL为统一资源定位器,获取资源,读取服务器上的数据。表示方法如下:
protocol://host:port/path?query#fragment
协议://主机:端口/路径?请求参数#定位位置
在Java中,Socket的工作步骤如下:
- 建立连接。服务器端指定用于等待连接端口号,实例化一个ServerSocket对象,并调用
accept()方法使其处于阻塞状态,等待客户端连接到该端口,并从连接队列中取出连接请求。客户端通过指定服务器的主机和端口号实例化一个Socket对象,连接到服务器。 - 数据通信。当连接建立后,建立输入输出流,以实现信息的交互。服务器端和客户端通过调用相应的Socket的
getInputStream()方法获得输入流,调用getOutputStream()方法获得输出流。 - 拆除连接。通信结束后,需要调用
close()方法,拆除连接并释放资源。
Java中,Socket和ServerSocket都基于传输控制协议(Transmission Control Protocol, TCP)。其创建方式如下:
//客户端创建Socket
Socket socket = new Socket(String host, int port);
//服务器端创建ServerSocket
ServerSocket serverSocket = new ServertSocket(int port, int backlog);
其中,backlog为客户端连接时请求的最大队列长度,默认值为50。当队列请求大于该容量时,服务器进程会拒绝该请求,直到服务器进程通过accept()方法将请求从队列中取出腾出空位时,队列才会加入新的请求。
服务器在创建ServerSocket实例后,需要调用accept()方法监听和接收客户连接请求。此时程序被阻塞(等待)。若不使用accept()方法,则队列中的连接请求永远不会被取出。
while(true)//无限循环,不断从队列中取出连接。
serverSocket.accept();//监听并从队列中取出连接请求,返回Socket对象
当Socket被成功创建后,通过创建输入输出流传送数据。
OutputStream os = socket.getOutputStream();
InputStream is = socket.getInputStream();
由于网络延迟、网络阻塞等原因,服务器未及时响应客户端,这种现象称之为超时。客户端通过如下方式可以限定等待时间:
socket.connect(socketAddress,3000);//设置超时时间为3000ms
若超过连接时间仍未连接成功,会抛出java.net.SocketTimeoutException异常。
对于传输块大的连续数据块,可以设置较大的缓冲区以减小数据传输次数提高效率。对于交互频繁单次数据量小的通信,可以采用小的缓冲区,以确保即时把小批量的数据发给对方。方法如下:
serverSocket.setReceiveBufferSize(1024);//设置缓冲区1KB
当缓冲区大于64KB时,需要在绑定端口前设置缓冲区。
3 创建多线程服务器
服务器需要满足高并发性能,即可以同时接收并处理多个客户端连接,并对每个客户端给出及时的响应。一个方法是,为每一个线程分配一个新的工作线程,即编写一个主线程类Server用于接收客户端连接,每当接收到新的客户请求时,实例化一个实现runnable接口的Handler类,调用其run()方法用以处理具体的连接请求。
public void service() {
while(true) { //需要一直启动监听
try {
threadSocket = serverSocket.accept();//取出连接请求
Thread work = new Thread(new Handler(threadSocket));
//启动线程,由Handler类执行具体的操作
} catch (IOException e) {
e.printStackTrace();
}
}
}
其中,Handler类如下:
public class Handler implements Runnable {
private Socket socket;
public Handler(Socket socket) {
this.socket = socket;
}
@Override
public void run() {
//线程具体方法
}
}
该方法可满足服务器同时与多个客户通信,但由于创建和销毁线程的性能开销大,且会创建大量的线程,从而导致系统内存占用过多乃至内存不足。
为了解决这个问题,引入线程池的概念。线程池预先创建一些工作线程,使用时直接获取池中的线程,使用完毕后将线程放回池中,从而避免频繁地创建和销毁线程,实现线程的重复利用。可以根据系统的承载能力,调整线程池的线程数量,以防止消耗过量导致系统崩溃。适量的工作线程可以提高服务器的并发性能,但超出负荷反而会降低并发性能。
Java中使用ExecutorService接口管理线程池。
public void service() {
while(true) { //需要一直启动监听
try {
threadSocket = serverSocket.accept();//取出连接请求
executorService.excute(new Handler(socket));
//把执行交给线程池维护
} catch (IOException e) {
e.printStackTrace();
}
}
}
多线程容易产生并发和死锁的问题:
- 如果任务A需要同步等待任务B的结果,若A不能获得B的结果,可能会导致死锁。线程任务需要尽量职责单一。
- 若工作线程执行时一直被阻塞,会导致该工作线程一直被阻塞不可用。可以调用
ServerSocket的setSoTimeout()方法设置等待客户连接超时时间。 - 若任务抛出异常或错误却没有被捕获,线程就会异常终止,导致线程泄露。对于
FixedThreadPool(固定线程池),若关闭前的执行期间由于失败导致任何线程终止,需要新线程替代其执行后续任务。这样,在其被关闭之前,线程池中的线程将一直存在,不会发生线程泄露。
4 基于UDP的Socket通信
Socket和ServerSocket都基于TCP协议,TCP协议可以保证数据的安全有序,但建立和销毁TCP连接会消耗很长时间,降低其传输速度。对于通行时间短、传输数据少的通信,通常使用用户数据报协议(User Datagram Protocol, UDP)。UDP协议具有更高的传输速度,但不可靠,无法保证发送的各数据报(UDP发送的数据单元)一定到达目的地,也无法保证其按序到达。UDP只适用于一次传输少量的数据,或对数据可靠性要求不高的环境。UDP是无连接协议,客户端和服务器端不存在一对一关系,二者无需建立连接就能交换数据报。其工作步骤如下:
- 创建DatagramSocket。
- 创建发送或者接收的数据包DatagramPacket,填入数据。
- 发送
send(datagramPacket)和接收receive(datagramPacket)创建好的数据报,传递数据。 - 调用
close()方法关闭连接。
服务器端创建Socket,指定通信对象:
socket = new DatagramSocket(port);
socket.connect(new InetSocketAddress(host,port));
客户端创建Socket连接:
socket = new DatagramSocket();
数据报的创建:
//构造DatagramPacket,用来接收长度为 length 的包,在缓冲区中指定了偏移量。
DatagramPacket(byte[] buf,int offset,int length)
//构造DatagramPacket,用来将长度为 length 的包发送到指定主机上的指定端口。
DatagramPacket(byte[] buf,int length,SocketAddress address)
5 HTTP概述
互联网使用超文本传输协议(Hyper Text Transfer Protocol, HTTP)作为应用层协议,采用client-server模式。客户端通过TCP协议建立Socket连接到服务器,服务器端接受来自客户端的TCP连接,并通过HTTP报文交互,最终关闭连接。
HTTP是无状态的,每次HTTP请求都是独立的,服务器中不保存客户端的状态(使用Cookies可以创建有状态的会话)。根据连接建立后能否处理多个请求和响应,HTTP分为持久连接和非持久连接(短链接)。在HTTP1.0中,默认的是短连接,没有正式规定Connection:Keep-alive 操作;在HTTP1.1中所有连接都是Keep-alive的,也就是默认都是持续连接的。
HTTP报文分为请求(request)和响应(response),客户端向服务器发送请求,服务器接收到请求后处理请求,并返回响应。请求和响应结构类似。常见的请求格式如下:
POST /index.html HTTP/1.1
Host: www.nwpu.edu.cn
Accept-Language: en-us
Connection: keep-alive
argument1=value1&&argument2=value2
首行为请求行,描述要执行的请求。GET为HTTP方法,用于表示客户端的行为。常见的方法有:GET(请求页面信息),PUT(向服务器端发送数据取代指定文档的内容),POST(向指定资源提交数据处理请求)等。/index.html为要获取的资源路径,通常为一个URL。HTTP/1.1表示协议版本。末尾回车换行,对应CRLF\r\n。
紧接着的几行为请求头Headers,结构为头部字段名:值。请求头描述了操作参数等请求信息,如服务器域名Host、可接受的自然语言Accept-Language、连接方式Connection等,也可以自定义。
请求头之后是空行,该空行只含有CRLF,不含有任何空格。
空行后是请求体Body。Body包含了请求的正文,该报文中表示了argument1=value1和argument2=value2两个POST的参数和值。并不是所有的Request请求都有一个body。
响应的报文与请求类似,一个常见的响应体如下:
HTTP/1.1 200 OK
Connection:Keep-Alive
Date: Thu, 3 Nov 2021 03:58:17 GMT
Server: Apache/1.3.0
data data data ...
和请求不同的是,首行状态行表示状态,包含协议版本HTTP/1.1、状态码200、状态文本OK。常见的状态码有:200,请求成功;301:资源被永久转移;404:请求的资源不存在;500,内部服务器错误等。响应头、响应体和请求头、请求体类似。并不是所有的响应都具有响应体。
HTTP协议中,有一个“条件请求”的概念,请求的结果和状态取决于其首部的值。这些首部规定了请求的前置条件,请求结果会视条件匹配而不同。条件请求需要指明资源的版本,在请求中会传递一个描述资源版本的值,这些值称为“验证器”,分为以下两类:文件的最后修改时间last-modified,以及独一无二的“实体标签”ETag。用于引发条件首部的请求为条件首部,有If-Match,If-None-Match,If-Modified-Since等等。服务器接收到请求后,若符合条件,返回200,否则返回304 Not Modified。
通过使用缓存(Caches)复用以前获取的资源,可以显著提高网站和应用程序的性能。缓存是一种保存资源副本并在下次请求时直接使用该副本的技术。当 web 缓存发现请求的资源已经被存储,它会拦截请求,返回该资源的拷贝,而不会去源服务器重新下载。缓存分为私有与共享缓存。共享缓存存储的响应能够被多个用户使用。私有缓存只能用于单独用户。共享缓存可以被多个用户使用。例如,ISP 或你所在的公司可能会架设一个 web 代理来作为本地网络基础的一部分提供给用户。这样热门的资源就会被重复使用,减少网络拥堵与延迟。
Cookie是服务器发送到浏览器并保存在本地的一小块数据,会在浏览器下次向同一个服务器发起请求时,被携带并发送到服务器上,用于告知服务器端两个请求是否来自同一个浏览器。服务器端通过Set-Cookie头部向用户发送Cookie信息:
HTTP/1.0 200 OK
Content-type: text/html
Set-Cookie: yummy_cookie=choco
Set-Cookie: tasty_cookie=strawberry
data data data ...
浏览器再次向服务器发送请求后,都会通过Cookie请求头部将其发送给服务器:
GET /sample_page.html HTTP/1.1
Host: www.example.org
Cookie: yummy_cookie=choco; tasty_cookie=strawberry