//模拟服务器端
import java.io.InputStream;
import java.net.InetSocketAddress;
import java.net.ServerSocket;
import java.net.Socket;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.concurrent.atomic.AtomicInteger;
public class BioServer {
public static AtomicInteger counter = new AtomicInteger(0);
public static SimpleDateFormat sdf = new SimpleDateFormat("HH:mm:ss");
public static void main(String[] args) {
try {
ServerSocket ss = new ServerSocket();
ss.bind(new InetSocketAddress("localhost", 8888));
while (true) {
Socket s = ss.accept(); //阻塞点,等待客户端连接
processWithNewThread(s); //为每一个客户端创建一个线程
}
} catch (Exception e) {
e.printStackTrace();
}
}
public static void processWithNewThread(Socket s) {
//lambada表达式写法
Runnable run = () -> {
InetSocketAddress rsa = (InetSocketAddress)s.getRemoteSocketAddress();
System.out.println(time() + "->" + rsa.getHostName() + ":" + rsa.getPort() + "->" + Thread.currentThread().getId() + ":" + counter.incrementAndGet());
try {
String result = readBytes(s.getInputStream()); //阻塞点,等待读/写数据完成
System.out.println(time() + "->" + result + "->" + Thread.currentThread().getId() + ":" + counter.getAndDecrement());
s.close();
} catch (Exception e) {
e.printStackTrace();
}
};
new Thread(run).start();
}
public static String readBytes(InputStream is) throws Exception {
long start = 0;
int total = 0;
int count = 0;
byte[] bytes = new byte[1024];
//开始读数据的时间
long begin = System.currentTimeMillis();
while ((count = is.read(bytes)) > -1) {
if (start < 1) {
//第一次读到数据的时间
start = System.currentTimeMillis();
}
total += count;
}
//读完数据的时间
long end = System.currentTimeMillis();
return "wait=" + (start - begin) + "ms,read=" + (end - start) + "ms,total=" + total + "bs";
}
public static String time() {
return sdf.format(new Date());
}
}
传统BIO
这是一个经典的每连接每线程的模型,之所以使用多线程,主要原因在于socket.accept()、socket.read()、socket.write()三个主要函数都是同步阻塞的,当一个连接在处理I/O的时候,系统是阻塞的,如果是单线程的话必然就挂死在那里;但CPU是被释放出来的,开启多线程,就可以让CPU去处理更多的事情。其实这也是所有使用多线程的本质:
- 利用多核。
- 当I/O阻塞系统,但CPU空闲的时候,可以利用多线程使用CPU资源。
现在的多线程一般都使用线程池,可以让线程的创建和回收成本相对较低。在活动连接数不是特别高(小于单机1000)的情况下,这种模型是比较不错的,可以让每一个连接专注于自己的I/O并且编程模型简单,也不用过多考虑系统的过载、限流等问题。线程池本身就是一个天然的漏斗,可以缓冲一些系统处理不了的连接或请求。
不过,这个模型最本质的问题在于,严重依赖于线程。但线程是很"贵"的资源,主要表现在:
- 线程的创建和销毁成本很高,在Linux这样的操作系统中,线程本质上就是一个进程。创建和销毁都是重量级的系统函数。
- 线程本身占用较大内存,像Java的线程栈,一般至少分配512K~1M的空间,如果系统中的线程数过千,恐怕整个JVM的内存都会被吃掉一半。
- 线程的切换成本是很高的。操作系统发生线程切换的时候,需要保留线程的上下文,然后执行系统调用。如果线程数过高,可能执行线程切换的时间甚至会大于线程执行的时间,这时候带来的表现往往是系统load偏高、CPU sy使用率特别高(超过20%以上),导致系统几乎陷入不可用的状态。
- 容易造成锯齿状的系统负载。因为系统负载是用活动线程数或CPU核心数,一旦线程数量高但外部网络环境不是很稳定,就很容易造成大量请求的结果同时返回,激活大量阻塞线程从而使系统负载压力过大。
//模拟客户端
import java.io.IOException;
import java.net.InetSocketAddress;
import java.net.Socket;
import java.util.Random;
public class Client {
public static void main(String[] args) {
try {
for (int i = 0; i < 20; i++) {
Socket s = new Socket();
s.connect(new InetSocketAddress("localhost", 8888));
processWithNewThread(s, i);
}
} catch (IOException e) {
e.printStackTrace();
}
}
static void processWithNewThread(Socket s, int i) {
Runnable run = () -> {
try {
//睡眠随机的5-10秒,模拟数据尚未就绪(阻塞等待)
Thread.sleep((new Random().nextInt(6) + 5) * 1000);
//写1M数据,为了拉长服务器端读数据的过程
s.getOutputStream().write(prepareBytes());
//睡眠1秒,让服务器端把数据读完
//Thread.sleep(1000); 相当于模拟数据量大的情况下->可能要等1s
s.close();
} catch (Exception e) {
e.printStackTrace();
}
};
new Thread(run).start();
}
//模拟写1M数据
static byte[] prepareBytes() {
byte[] bytes = new byte[1024*1024]; //1048576bs
//byte[] bytes = new byte[1024*1024*100]; //104857600bs
for (int i = 0; i < bytes.length; i++) {
bytes[i] = 1;
}
return bytes;
}
}
BIO的运行结果:
20:00:15->127.0.0.1:50374->10:1
20:00:15->127.0.0.1:50383->18:3
20:00:15->127.0.0.1:50385->20:2
20:00:15->127.0.0.1:50377->12:4
20:00:15->127.0.0.1:50379->14:5
20:00:15->127.0.0.1:50381->16:6
20:00:15->127.0.0.1:50393->28:7
20:00:15->127.0.0.1:50391->26:8
20:00:15->127.0.0.1:50389->24:9
20:00:15->127.0.0.1:50387->22:10
20:00:15->127.0.0.1:50376->11:11
20:00:15->127.0.0.1:50378->13:12
20:00:15->127.0.0.1:50380->15:13
20:00:15->127.0.0.1:50382->17:14
20:00:15->127.0.0.1:50384->19:15
20:00:15->127.0.0.1:50386->21:16
20:00:15->127.0.0.1:50388->23:17
20:00:15->127.0.0.1:50390->25:18
20:00:15->127.0.0.1:50392->27:19
20:00:15->127.0.0.1:50394->29:20
20:00:21->wait=5014ms,read=1062ms,total=1048576bs->21:20
20:00:21->wait=5017ms,read=1059ms,total=1048576bs->25:19
20:00:22->wait=5965ms,read=1014ms,total=1048576bs->11:18
20:00:22->wait=5985ms,read=1012ms,total=1048576bs->19:17
20:00:23->wait=6978ms,read=1044ms,total=1048576bs->24:16
20:00:23->wait=6979ms,read=1046ms,total=1048576bs->28:15
20:00:23->wait=6979ms,read=1046ms,total=1048576bs->26:14
20:00:23->wait=6990ms,read=1042ms,total=1048576bs->13:13
20:00:23->wait=6988ms,read=1042ms,total=1048576bs->15:12
20:00:23->wait=6998ms,read=1034ms,total=1048576bs->17:11
20:00:23->wait=6998ms,read=1033ms,total=1048576bs->29:10
20:00:24->wait=7988ms,read=1024ms,total=1048576bs->18:9
20:00:24->wait=7974ms,read=1039ms,total=1048576bs->12:8
20:00:24->wait=7996ms,read=1015ms,total=1048576bs->27:7
20:00:25->wait=8974ms,read=1044ms,total=1048576bs->10:6
20:00:25->wait=8976ms,read=1042ms,total=1048576bs->16:5
20:00:25->wait=8990ms,read=1027ms,total=1048576bs->22:4
20:00:25->wait=8981ms,read=1039ms,total=1048576bs->20:3
20:00:26->wait=9973ms,read=1021ms,total=1048576bs->14:2
20:00:26->wait=9988ms,read=1008ms,total=1048576bs->23:1
NIO
NIO(Non-blocking I/O,也称为New I/O),是一种同步非阻塞的I/O模型,也是I/O多路复用的基础,已经被越来越多地应用到大型应用服务器,成为解决高并发与大量连接、I/O处理问题的有效方式。
NIO一个重要的特点是:socket主要的读、写、注册和接收函数,在等待就绪阶段都是非阻塞的。
//等待时间几乎为0,而服务器端的线程数明显下降,节约资源
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.text.SimpleDateFormat;
import java.util.Date;
import java.util.Iterator;
import java.util.Set;
import java.util.concurrent.atomic.AtomicInteger;
public class NioServer {
static int clientCount = 0;
static AtomicInteger counter = new AtomicInteger(0);
static SimpleDateFormat sdf = new SimpleDateFormat("HH:mm:ss");
/**
* 两种感兴趣的操作共用一个选择器,且选择器运行在主线程里,Worker(读取数据)线程是新的线程。
* @param args
*/
public static void main(String[] args) {
try {
Selector selector = Selector.open();
ServerSocketChannel ssc = ServerSocketChannel.open();
ssc.configureBlocking(false); //设置非阻塞方式
ssc.register(selector, SelectionKey.OP_ACCEPT); //注册客户端(连接)事件
ssc.bind(new InetSocketAddress("localhost", 8888)); //(绑定)
while (true) {
selector.select();
Set<SelectionKey> keys = selector.selectedKeys();
Iterator<SelectionKey> iterator = keys.iterator();
while (iterator.hasNext()) {
SelectionKey key = iterator.next();
iterator.remove();
if (key.isAcceptable()) {
ServerSocketChannel ssc1 = (ServerSocketChannel)key.channel();
SocketChannel sc = null;
while ((sc = ssc1.accept()) != null) {
sc.configureBlocking(false);
sc.register(selector, SelectionKey.OP_READ);
InetSocketAddress rsa = (InetSocketAddress)sc.socket().getRemoteSocketAddress();
System.out.println(time() + "->" + rsa.getHostName() + ":" + rsa.getPort() + "->" + Thread.currentThread().getId() + ":" + (++clientCount));
}
} else if (key.isReadable()) {
//先将“读”从感兴趣操作移出,待把数据从通道中读完后,再把“读”添加到感兴趣操作中
//否则,该通道会一直被选出来
key.interestOps(key.interestOps() & (~ SelectionKey.OP_READ));
processWithNewThread((SocketChannel)key.channel(), key);
}
}
}
} catch (Exception e) {
e.printStackTrace();
}
}
static void processWithNewThread(SocketChannel sc, SelectionKey key) {
Runnable run = () -> {
counter.incrementAndGet();
try {
String result = readBytes(sc);
//把“读”加进去
key.interestOps(key.interestOps() | SelectionKey.OP_READ);
System.out.println(time() + "->" + result + "->" + Thread.currentThread().getId() + ":" + counter.get());
sc.close();
} catch (Exception e) {
e.printStackTrace();
}
counter.decrementAndGet();
};
new Thread(run).start();
}
static String readBytes(SocketChannel sc) throws Exception {
long start = 0;
int total = 0;
int count = 0;
ByteBuffer bb = ByteBuffer.allocate(1024);
//开始读数据的时间
long begin = System.currentTimeMillis();
while ((count = sc.read(bb)) > -1) {
if (start < 1) {
//第一次读到数据的时间
start = System.currentTimeMillis();
}
total += count;
bb.clear();
}
//读完数据的时间
long end = System.currentTimeMillis();
return "wait=" + (start - begin) + "ms,read=" + (end - start) + "ms,total=" + total + "bs";
}
static String time() {
return sdf.format(new Date());
}
}
NIO效果:
20:07:21->wait=2ms,read=1113ms,total=1048576bs->11:5
20:07:22->wait=3ms,read=1039ms,total=1048576bs->13:5
20:07:22->wait=38ms,read=1099ms,total=1048576bs->12:7
20:07:22->wait=0ms,read=1016ms,total=1048576bs->16:7
20:07:22->wait=0ms,read=1025ms,total=1048576bs->14:5
20:07:22->wait=11ms,read=1087ms,total=1048576bs->17:4
20:07:23->wait=11ms,read=1073ms,total=1048576bs->19:7
20:07:23->wait=0ms,read=1049ms,total=1048576bs->18:6
20:07:23->wait=0ms,read=1106ms,total=1048576bs->20:5
20:07:24->wait=1ms,read=1011ms,total=1048576bs->22:4
20:07:24->wait=0ms,read=1037ms,total=1048576bs->21:4
20:07:24->wait=0ms,read=1025ms,total=1048576bs->23:3
20:07:24->wait=0ms,read=1030ms,total=1048576bs->24:2
20:07:25->wait=0ms,read=1005ms,total=1048576bs->25:1
它的大致处理过程如下:
1、定义一个选择器,Selector。
相当于设立一个跑腿服务员。
2、定义一个服务器端套接字通道,ServerSocketChannel,并配置为非阻塞的。
相等于聘请了一位大堂经理。
3、将套接字通道注册到选择器上,并把感兴趣的操作设置为OP_ACCEPT。
相当于大堂经理给跑腿服务员说,帮我盯着门外(轮训),有客人来了告诉我。
4、进入死循环,选择器不时的进行选择。
相当于跑腿服务员一遍又一遍的去询问、去转悠。
5、选择器终于选择出了通道,发现通道是需要Acceptable的。
相当于跑腿服务员终于发现门外来客人了,客人是需要接待的。
6、于是服务器端套接字接受了这个通道,开始处理。
相当于跑腿服务员把大堂经理叫来了,大堂经理开始着手接待。
7、把新接受的通道配置为非阻塞的,并把它也注册到了选择器上,该通道感兴趣的操作为OP_READ。
相当于大堂经理把客人带到座位上,给了客人菜单,并又把客人委托给跑腿服务员,说客人接下来肯定是要点餐的,你不时的来问问。
8、选择器继续不时的进行选择着。
相当于跑腿服务员继续不时的询问着、转悠着。
9、选择器终于又选择出了通道,这次发现通道是需要Readable的。
相当于跑腿服务员终于发现了一桌客人有了需求,是需要点餐的。
10、把这个通道交给了一个新的工作线程去处理。
相当于跑腿服务员叫来了点餐服务员,点餐服务员开始为客人写菜单。
11、这个工作线程处理完后,就被回收了,可以再去处理其它通道。
相当于点餐服务员写好菜单后,就走了,可以再去为其他客人写菜单。
12、选择器继续着重复的选择工作,不知道什么时候是个头。
相当于跑腿服务员继续着重复的询问、转悠,不知道未来在何方。
相信你已经看出来了,大堂经理相当于服务器端套接字,跑腿服务员相当于选择器,点餐服务员相当于Worker线程。
BIO是阻塞IO,可以是同步阻塞,也可以是异步阻塞。
第一种是阻塞IO,阻塞点有两个,等待数据就绪的过程和读取数据的过程。
第二种是阻塞IO,阻塞点有一个,读取数据的过程。
只有一个线程,接受一个连接,读取数据,处理业务,写回结果,再接受下一个连接,这是同步阻塞。这种用法几乎没有。
一个线程和一个线程池,线程接受到连接后,把它丢给线程池中的线程,再接受下一个连接,这是异步阻塞。对应示例一。
一个线程和一个线程池,线程运行selector,执行select操作,把就绪的连接拿出来丢给线程池中的线程,再执行下一次的select操作,就是多路复用,这是异步阻塞。对应示例二。
NIO高级主题
- Proactor与Reactor
-
标准/典型的Reactor:
步骤1:等待事件到来(Reactor负责)。
步骤2:将读就绪事件分发给用户定义的处理器(Reactor负责)。
步骤3:读数据(用户处理器负责)。
步骤4:处理数据(用户处理器负责)。 -
改进实现的模拟Proactor:
步骤1:等待事件到来(Proactor负责)。
步骤2:得到读就绪事件,执行读数据(现在由Proactor负责)。
步骤3:将读完成事件分发给用户处理器(Proactor负责)。
步骤4:处理数据(用户处理器负责)。
对于不提供异步I/O API的操作系统来说,这种办法可以隐藏Socket API的交互细节,从而对外暴露一个完整的异步接口。借此,我们就可以进一步构建完全可移植的,平台无关的,有通用对外接口的解决方案。
- Buffer的选择
通常情况下,操作系统的一次写操作分为两步:
- 将数据从用户空间拷贝到系统空间。
- 从系统空间往网卡写。
同理,读操作也分为两步:
- 将数据从网卡拷贝到系统空间;
- 将数据从系统空间拷贝到用户空间。
对于NIO来说,缓存的使用可以使用DirectByteBuffer和HeapByteBuffer。如果使用了DirectByteBuffer,一般来说可以减少一次系统空间到用户空间的拷贝。但Buffer创建和销毁的成本更高,更不宜维护,通常会用内存池来提高性能。
如果数据量比较小的中小应用情况下,可以考虑使用heapBuffer;反之可以用directBuffer。
NIO存在的问题
使用NIO != 高性能,当连接数<1000,并发程度不高或者局域网环境下NIO并没有显著的性能优势。
NIO并没有完全屏蔽平台差异,它仍然是基于各个操作系统的I/O系统实现的,差异仍然存在。使用NIO做网络编程构建事件驱动模型并不容易,陷阱重重。
推荐使用成熟的NIO框架,如Netty,MINA等。解决了很多NIO的陷阱,并屏蔽了操作系统的差异,有较好的性能和编程模型。