第十五章 网络与线程
1. 网络socket连接
Socket是个代表两台机器之间网络连接的对象(java.net.Socket)。这里连接指得就是两个机器之间知道如何识别并与对象产生通信的一种关系。这里其实涉及到很多低层的工作细节,但是得益于java API的网络功能包(java.net),我们只需要处理高层部分的内容。
要创建Socket连接你必须了解两项关于服务器的信息:IP地址(他在哪)及端口号(它用哪个端口收发数据)。Socket连接的建立代表两台机器之间存有对方的信息,包括网络地址和TCP的端口号。
Socket chatSocket = new Socket("196.144.1.103", 5000);
关于端口
每个服务器上理论上说有65536个端口(0 ~ 65535)。例如网页服务器(HTTP)的端口号是80,Telnet服务器的端口号为23等,它们代表在服务器上执行软件的逻辑识别。如果没有端口号,服务器就无法分辨客户端是要连到哪个应用程序的服务。每个应用程序都有独特的工作交谈方式,如果没有识别就发送的话会制造很大的混乱。就像是对邮件服务器发送HTTP请求。另外,0 ~ 1023 号端口已经被保留给已经的特定服务,你不应该使用它们。
IP地址就好像是门牌号码,端口号就是该地址不同的窗口
读取与写入socket
- BufferedReader
建立串流和缓冲区来沟通,方法和上章内容相似。InputStreamReader类似于低层和高层串流之间的桥梁。
// 建立对服务器的Socket连接(127.0.0.1就是本机)
Socket chatSocket = new Socket("127.0.0.1", 5000);
// 建立连接到Socket上低层输入串流的InputStreamReader
InputStreamReader stream = new InputStreamReader(chatSocket.getInputStream());
// 建立BufferedReader来读取
BufferedReader reader = new BufferedReader(stream);
String message = reader.readLine();
- PrintWriter
因为每次都是写入一个String,所以PrintWriter是比较标准的做法。PringWriter为字符数据和字节间的转换桥梁,可以衔接String和Socket两端。
// 对服务器建立Socket连接
Socket chatSocket = new Socket("127.0.0.1", 5000);
// 建立连接到Socket的PrintWriter
PrintWriter writer = new PrintWriter(chatSocket.getOutputStream());
// 写入数据
writer.println("message to send");
writer.print("another message");
编写简单的服务器程序
我们会用到一对Socket,它们会是等待用户请求(当用户创建Socket时)的ServerSocket以及与用户通信用到Socket。
// 服务器应用程序对特定端口创建出ServerSocket,这回让服务器应用开始监听来自4242端口的客户端请求
ServerSocket serverSock = new ServerSocket(4242);
// 客户端对服务器应用程序建立Socket连接。客户端得知IP地址和端口号
Socket sock = new Socket("190.165.1.103", 4242);
// 服务器创建出与客户端通信的Socket
Socket sock = serverSock.accept();
accept()方法会在等待用户的Socket连接时闲置着。当用户连接上来时,此方法会返回一个Socket(在不同端口上)以便与客户通信。Socket与SeverSock的端口不相同,因此ServerSocket可以空出来等待其他的用户。
简易举例:
import java.io.*;
import java.net.*;
public class DailyAdviceServer {
String[] adviceList = {"Take smaller bites", "Go for the tight jeans. No they do NOT make you look fat", "One word: inappropriate",
"Just for today, be honest. Tell your boss what you *really* think", "You might want to rethink tha haircut"};
public void go() {
try {
ServerSocket serverSock = new ServerSocket(4242); // ServerSocket开始监听客户端对这台机器在4242端口上的要求
while(true) { // 服务器进行无限循环等待服务客户端的请求
Socket sock = serverSock.accept(); // accept会让执行在这里停下来,满足要求之后才会进行下去
PrintWriter writer = new PrintWriter(sock.getOutputStream());
String advice = getAdvice();
writer.println(advice);
writer.close();
System.out.println(advice);
}
} catch(IOException ex) {
ex.printStackTrace();
}
}
private String getAdvice() {
int random = (int) (Math.random() * adviceList.length);
return adviceList[random];
}
static void main(String[] args) {
DailyAdviceServer server = new DailyAdviceServer();
server.go();
}
}
要点
- 客户端与服务器的应用程序通过Socket连接来沟通
- Socket代表两个应用程序需之间的连接,它们可能会是在不同的机器上执行的
- 客户端必须知道服务器应用程序的IP地址(或网域名称)和端口号
- TCP端口号是个16位的值,用来指定特定的应用程序。它能够让用户连接到服务器上各种不同的应用程序
- 从0 ~ 1023 的端口号是保留给HTTP、FTP、SMTP等已知服务
- 客户端通过建立Socket来连接服务器 —— Socket s = new Socket(“127.0.0.1”,4200);
- 一旦建立了连接,客户端可以从Socket取得低层串流
- 建立BUfferedReader链接InputStreamReader与来自Socket的输入串流以读取服务器的文本数据
- InputStreamReader是个转换字节成字符的桥梁,它主要是用来链接BufferedReader与低层的Socker输入串流
- 建立直接链接Socket输出串流的PrintWriter请求print()方法或println()方法来送出String给服务器
- 服务器可以使用ServerSocket来等待用户对特定端口的请求
- 当ServerSocket接到请求时,它会做一个Socket连接来接受客户端请求
2. 线程与Thread
线程是独立的线程,它代表独立的执行空间。每个Java应用程序会启动一个主线程——将main()放在自己执行空间的最开始处。Java虚拟机会负责主线程的启动(以及比如垃圾收集器所需的系统线用程序)。程序员得负责启动自己建立的线程。
Thread是个表示线程的类。它有启动线程、连接线程和让线程闲置的方法等。
有一个以上的执行空间并不是说几个进程在同时执行,而是执行动作可以在执行空间非常快速地来回交换,因此你会感觉到每项任务都在执行。线程要记录的一项事物是目前线程执行空间做到哪里。
启动新的线程
// 建立Runnable对象(线程的任务)
Runnable threadJob = new MyRunnable();
// 建立Thread对象(执行工人)并复制Runnable(任务)
Thread myThread = new Thread(threadJob);
// 启动Thread
myThread.start();
Thread对象需要任务。任务是线程在启动时去执行的工作。该任务是新线程空间上的第一个方法,且它一定要是下面这种形式。
public void run() {
... // 会被新线程执行的代码
}
Runnable是个接口,线程的任务可以被定义在任何实现Runnable的类上。线程只在乎传入给Thread的构造函数的参数是否为实现Runnable的类。当你把Runnable传给Thread的构造函数时,实际上就是在给Thread取得run()的办法。相当于你给了Thread一项任务。
public class MyRunnable implements Runnable { // Runnable来自java.lang所以不需要import
public void run() { // 只有一个方法需要实现,没有参数,需要运行的程序放在里面
go();
}
public void go() {
doMore();
}
public void doMore() {
System.out.println("top o' the stack");
}
}
class ThreadTester {
public static void main(String[] args) {
Runnable threadJob = new MyRunnable(); // 将Runnable的实例传给Thread的构造函数
Thread myThread = new Thread(threadJob);
myThread.start(); // 要调用start()才会让线程开始执行。在此之前,它不是真正的线程
System.out.println("back in main");
}
}
线程调度器
线程调度器会决定哪个线程从等待状况中被挑出来运行,以及何时把哪个线程送回等待被执行的状态。它会决定某个线程运行多久,当线程被提出时,调度器也会指定线程要回去等待下一个机会或是暂时堵塞。
你无法控制调度,也没有API可以调用调度器。最终要的是调度无法确定。就如上面的程序,执行出来的情况可能是:
或者是
这是因为调度器可能会在新线程执行一般时跳回去执行主程序,导致主程序的语句先打印出来,再回头去执行完新线程中的打印(第一种情况)。又或是先把新线程中的语句执行完,再回头执行主程序中语句(第二种情况)。
Thread要点
- 以小写t描述的thread是个独立的线程
- Java中的每个线程都有独立的执行空间
- 大写T的Thread是java.lang.Thread这个类。它的对象是用来表示线程
- Thread需要任务,任务是实现过Runnable的实例
- Runnable这个接口只有一个方法
- run()会是新线程所执行的第一项方法
- 要把Runnable传给Thread的构造函数才能启动新的线程
- 线程在初始化以后还没有调用start()之后,会建立出新的执行空间,它处于可执行状态等待被跳出来执行
- 当Java虚拟机的调度器选择某个 线程之后它就处于执行中的状态,单处理器的机器只能有一个执行中的线程
- 有时线程会因为某些原因而被堵塞
- 调度不能保证任何的执行时间和顺序,所以你不能期待它会完全地平均分配执行,你最多也只能影响sleep的最小保证时间
Thread.sleep()
这时一种让线程暂时休眠的方法。如果想要确保其他的线程有机会执行的话,就把线程放进睡眠状态。当线程醒来的时候,它会进入可执行状态等待被调度器挑出来执行。
Thread.sleep(2000); // 让进程休眠2秒钟
但这个方法有时候会抛出InterruptedException异常,所以我们对它的调用一般放在try/catch块中。一般情况下使用sleep()能够让当前线程离开执行中的状态,从而使调度器有时机运行主线程,但是做完主线程会回到此线程上执行需要花到2秒钟···虽然这样可以尽可能地保证运行顺序(主线程-新线程),但是也不是绝对的,可能调用sleep()之后操作系统刚好去读磁盘驱动花了两秒,回头先执行哪个程序可就说不定了。
3. 同步化
并发性(concurrency)问题
并发性问题会引发竞争状态(racecondition)。竞争状态会引发数据的损毁。这一切都来自于可能发生的一种状况:两个或以上的线程存取单一对象的数据。也就是说两个不同的执行空间上的方法都在堆上对同一个对象执行getter或setter。
为了解决这种问题,我们可以使用synchronized
这个关键词来修饰方法,使它每次只能被单一线程存取。例如:
public class Job implements Runnable {
public static void main(String[] args) {
Job job = new Job();
Thread one = new Thread(job);
Thread two = new Thread(job);
...
one.start();
twp.start();
}
public void run() {
...
doTheJob();
}
private synchronized void doTheJob() { // 因为有synchronized所以在这里只有单一线程会被执行,即使它会“睡”一会儿
...
Thread.sleep(500); // 如果没有synchronized,当前线程睡过去后可能另一个线程会执行后面的过程
...
}
}
同步化的目标是保护重要的数据
,但要注意,你锁住的不是数据而是存取数据的方法。一旦线程进入了方法,我们必须确保在其他线程可以进入该方法之前所有的步骤都会完成(如果原子不可分割)。
但是同步化也有它致命的缺点,它可能会导致线程的死锁(deadlock)。死锁会发生是因为两个想成互相持有对方正在等待的东西。没有方法可以脱离这种情况,导致两个线程都僵住。例如:有两个线程A、B,以及两个带有同步化方法的对象foo、bar。
要点
- Thread.sleep( )这个静态方法可以强制线程进入等待状态到过了设定时间为止,例如Thread.sleep(200)会睡上200毫秒
- 可以调用sleep( )让所有的线程都有机会运行
- sleep( )方法可能会抛出InterruptException异常,所以要包在try/catch块,或者把它也声明出来
- 你可以用setName( )方法来帮线程命名,通常是用来除错的
- 如果两个或以上的线程存取堆上相同的对象可能会引发数据的损毁
- 要让对象在线程上由足够安全性,就要判断哪些指令不能被分割执行
- 使用synchronized这个关键词修饰符可以防止两个线程同时进入同一个对象的同一个方法
- 每个对象都有单一的锁,单一的钥匙。这只会在对象带有同步化方法时才有实际的用途
- 线程尝试要进入同步化过的方法时必须取得对象的钥匙,如果因为已经被别的线程拿走了,那就得等
- 对象就算是有多个同步化过的方法,也还是只有一个锁。一旦某个线程进入该对象的同步化方法,其他线程就无法进入该对象上的任何同步化方法