Netty
基本组件
- Channel
- Reactor
- Handler
- Pipeline(双向链表)
- EventLoopGroup
- parentGroup: 负责新连接的监听和接收
- childGroup:负责 IO 事件的轮询和分发
如果不分开 parent 和 child,则会带来一个风险:新连接的接收被更加耗时的数据传输或业务处理所阻塞 - ChannelOption
优点
- API 使用起来相对简单,降低了开发门槛
- 对多种主流协议都有很好的支持(编解码功能)
- 支持自定义配置,通过 ChannelHandler 可以灵活地扩展通信框架
- 其他业界主流的 NIO 框架相比,Netty 的综合性能最优(谁出书都会说自己的好)
- 成熟稳定,Netty 修复了在 JDK NIO 中所有已发现的 bug(我也不知道有什么 bug)
- 有活跃的社区,版本迭代周期短,bug 修复及时
ZooKeeper
核心优势就是解决了分布式环境的数据一致性问题,每时每刻访问 ZooKeeper 的树结构时,返回的数据都是一致的,不会引起脏读、幻读、不可重复读
脏读、幻读、不可重复读
- 脏读:一个事务中访问到了另一个事务未提交的数据
- 幻读:当两个完全相同的查询执行时,两次返回的结果集不一样,原因另一个事务新增或删除了第一个事务的结果集中的数据
- 不可重复读:在一个事务内根据同一个条件对数据进行多次查询,返回的结果不一致,原因是其他事务对数据进行了修改
不可重复读和幻读的区别:
不可重复读关心的是记录的更新操作,对同样的记录,再次读取数据发生变化
幻读关注的是记录的增删操作,条数发生了变化
高并发环境下的接入层
网关不外乎完成以下工作:
- 鉴权
- 限流
- 反向代理
- 负载均衡
操作系统
将内存划分为两部分,内核模块运行在内核空间,对应的进程处于内核态;用户程序运行在用户空间,对应的进程处于用户态
IO 读写:
上层应用通过操作系统的 read 系统调用,把数据从内核缓冲区复制到应用程序的进程缓冲区,通过操作系统的 write 系统调用,把数据从应用程序的进程缓冲区复制到操作系统的内核缓冲区
那么为什么要设置缓冲区呢?
是为了减少设备之间的频繁物理交换,因为外部物理设备与内存和 CPU 相比,有着非常大的差距
四种主要的 IO 模型
阻塞 IO 指的是,需要内核 IO 操作彻底完成后,才返回用户空间执行用户程序的操作指令,非阻塞则无需等待
同步指的是用户空间(进程或线程)是主动发起 IO 请求的一方,系统内核是被动接收方;异步 IO 则反过来
- 同步阻塞 IO:用户空间主动发起,需要等待内核 IO 操作彻底完成后,才返回到用户空间的 IO 操作
- 优点:应用程序开发非常简单,在阻塞等待数据的过程中,用户线程挂起,基本不会占用 CPU 资源
- 缺点:一般情况下会为每个连接配备一个独立的线程,高并发场景下,需要大量线程,内存和线程切换开销会非常大
- 同步非阻塞 IO:用户空间发起,不需要等待内核 IO 操作彻底完成,就可以立即返回用户空间去执行后续的指令(这个也叫 NIO,但是和 Java 里面的 NIO 有区别,Java 里的 NIO 指的是 NewIO,用的是 IO 多路复用模型),特点是需要不断进行轮询
- 优点:线程不会阻塞,实时性较好
- 缺点:需要不断轮询,占用 CPU 资源,效率低下
- IO 多路复用:在 Linux 系统中,IO 多路复用的系统调用为 select/epoll,通过该系统调用,一个用户进程(或线程)可以监视多个文件描述符,一旦某个文件描述符就绪(一般是指内核缓冲区可读/可写),内核就能够将文件描述符的就绪状态返回给用户进程(或线程),用户空间就可以根据文件描述符的就绪状态进行对应的 IO 系统调用(Reactor)
- 优点:一个选择器查询线程可以同时处理成千上万的网络连接,不必创建大量的线程
- 缺点:select/epoll 系统调用是阻塞的,属于同步 IO
- 异步 IO:指的是用户空间的线程变成被动接收者,而内核空间成为主动调用者;用户线程通过系统调用向内核注册某个 IO 操作,内核在这个 IO 操作完成后通知用户程序,用户程序执行后续的业务操作(回调)
- 优点:非阻塞
- 缺点:应用程序仅需要进行事件的注册和接收,其余的工作都留给了操作系统,也就是需要底层内核提供支持
连接极限值
1 | ulimit -n 1000000(默认 1024) |
1 | ulimit -SHn(S 软性极限值,超过警告;H 硬性极限值,超过报错) |
如何彻底解除限制?
编辑文件 /etc/security/limits.conf
1 | soft nofile 1000000 |
Java NIO
基本组件
- Channel
- Buffer
- Selector
OIO 和 NIO 的区别
- OIO(Old IO) 面向流,NIO(New IO)面向缓存区
- OIO 是阻塞的,NIO 非阻塞
- OIO 没有 Selector 概念,NIO 有
NIO Buffer
常用方法
allocate(int capacity): 默认是写模式,从数组下标 0 开始,长度限制是 capacity
put(int i):写 i 进数组里,如果超过长度,则抛出 BufferOverflowException(可使用 remaining() 来判断剩余可写长度;如果是读模式,则抛出 ReadOnlyBufferException(可使用 isReadOnly() 来判断是否可写)
filp():读写模式转换
1
2
3
4
5
6public Buffer flip() {
limit = position;
position = 0;
mark = -1;
return this;
}get()
rewind():倒带
1
2
3
4
5public Buffer rewind() {
position = 0;
mark = -1;
return this;
}flip() 和 rewind() 的区别就是 limit 参数
mark() 和 reset(): mark() 标记当前位置,当调用 reset() 的时候,回到标记的位置
clear()
1
2
3
4
5
6public Buffer clear() {
position = 0;
limit = capacity;
mark = -1;
return this;
}
使用 clear() 或者 compact() 可以将读模式改为写模式
生命周期
allocate() -> put() -> flip() -> get() -> clear() / compoact() -> put()
申请内存的两种方式
申请堆内存,读写效率较低,受到 GC 影响
1 | ByteBuffer.allocate() -> java.nio.HeapByteBuffer |
申请直接内存,读写效率高(少一次拷贝),不会受到 GC 影响,分配内存的时候效率低,使用不当可能造成内存泄漏
1 | ByteBuffer.allocateDirect() -> java.nio.DirectByteBuffer |
NIO Channels
常用 Channel
- FileChannel
- (FileInputStream() / FileOutputStream()).geChannel(), ByteBuffer.flip(), channel.force(true), channel.close()
- transferTo(position, length, target):零拷贝, 每次 2G 上限
- ServerSocketChannel
- SocketChannel
- socketChannel.configureBlocking(true / false),默认是 blocking, 因为有个属性设置 nonBlocking 为 false
- DatagramChannael
NIO Selector
选择器的使命是处理 IO 的多路复用,完成通道的注册,监听,事件查询
选择器和通道的关系是监控和被监控的关系
IO 事件类型
- SelectionKey.OP_READ
- SelectionKey.OP_WRITE
- SelectionKey.OP_CONNECT
- SelectionKey.OP_ACCEPT
SelectionKey
如何知道一个 Channel 是否能被监测?
看这个 Channel 是否直接或间接继承 SelectableChannel,比如 FileChannel 就不是,而 SocketChannel 就是
注册到 Selector 的 Channel 必须是处于非阻塞模式下,因为 FileChannel 只有阻塞模式,不能切换到非阻塞模式,所以 FileChannel 不能和选择器一起使用
Selector.select() 本身是阻塞的,所以放在 while true 里不用担心 CPU 空转
Reactor 模式
Reactor 模式由 Reactor 线程,Handlers 处理器两大角色组成
- Reactor 线程:负责响应 IO 事件,并且分发到 Handlers 处理器
- Handlers 处理器:非阻塞地执行业务逻辑处理
执行流程
channel -> selector -> reactor -> handler
优点
- 响应速度快,虽然同一 Reactor 线程本身是同步的,但是不会被单个连接的 IO 操作所阻塞
- 避免了多线程同步和线程之前频繁切换的开销
- 通过增加 Reactor 线程的个数,可以进行灵活扩展,充分利用 CPU 资源
缺点
- 每次引入新东西肯定是增加了原系统的复杂性
- 需要操作系统底层支持 IO 多路复用系统调用才可以
为什么在海量连接的情况下,线程池的方式不好使
- 线程的创建和销毁成本很高
- 线程本身占用较大内存,Java 线程栈内存分配
512K - 1M
- 线程切换的成本很高,因为需要保存上下文,如果线程切换的时间比线程本身执行所需要的时间还多,就是负优化
序列化与反序列化
- 数据结构大小:原则上,越小,效率越高
- 结构复杂度:越复杂,效率越低