java高并发核心编程一-读书笔记
十万级QPS的Web应用架构图
对于十万级流量的系统应用而言,其架构一般可以分为三层:服务层、接入层、客户端层
接入层主要完成鉴权、限流、反向代理和负载均衡等功能
高并发IO的底层原理
为了避免用户进程直接操作内核,保证内核安全,操作系统将内存(虚拟内存)划分为两部分:一部分是内核空间(Kernel-Space),另一部分是用户空间(User-Space)
相对应的是内核态和用户态
操作系统的核心是内核程序,它独立于普通的应用程序,既有权限访问受保护的内核空间,也有权限访问硬件设备,而普通的应用程序并没有这样的权限
用户态进程必须通过系统调用(System Call)向内核发出指令,完成调用系统资源之类的操作
上层应用通过操作系统的read系统调用把数据从内核缓冲区复制到应用程序的进程缓冲区,通过操作系统的write系统调用把数据从应用程序的进程缓冲区复制到操作系统的内核缓冲区。
为什么设置那么多的缓冲区,导致读写过程那么麻烦呢?
缓冲区的目的是减少与设备之间的频繁物理交换。计算机的外部物理设备与内存和CPU相比,有着非常大的差距,外部设备的直接读写涉及操作系统的中断。发生系统中断时,需要保存之前的进程数据和状态等信息,结束中断之后,还需要恢复之前的进程数据和状态等信息。为了减少底层系统的频繁中断所导致的时间损耗、性能损耗,出现了内核缓冲区。
在大多数情况下,Linux系统中用户程序的IO读写程序并没有进行实际的IO操作,而是在用户缓冲区和内核缓冲区之间直接进行数据的交换。
用户程序所使用的系统调用read和write并不是使数据在内核缓冲区和物理设备之间交换:read调用把数据从内核缓冲区复制到应用的用户缓冲区,write调用把数据从应用的用户缓冲区复制到内核缓冲区。
这里以read系统调用为例,看一下一个完整输入流程的两个阶段:
应用程序等待数据准备好。
从内核缓冲区向用户缓冲区复制数据。
如果是读取一个socket(套接字),那么以上两个阶段的具体处理流程如下:
第一个阶段,应用程序等待数据通过网络到达网卡,当所等待的分组到达时,数据被操作系统复制到内核缓冲区中。这个工作由操作系统自动完成,用户程序无感知。
第二个阶段,内核将数据从内核缓冲区复制到应用的用户缓冲区。再具体一点,如果是在Java客户端和服务端之间完成一次socket请求和响应(包括read和write)的数据交换,
其完整的流程如下:
客户端发送请求:Java客户端程序通过write系统调用将数据复制到内核缓冲区,Linux将内核缓冲区的请求数据通过客户端机器的网卡发送出去。
在服务端,这份请求数据会从接收网卡中读取到服务端机器的内核缓冲区。
服务端获取请求:Java服务端程序通过read系统调用从Linux内核缓冲区读取数据,再送入Java进程缓冲区。服务端业务处理:Java服务器在自己的用户空间中完成客户端的请求所对应的业务处理。
服务端返回数据:Java服务器完成处理后,构建好的响应数据将从用户缓冲区写入内核缓冲区,这里用到的是write系统调用,操作系统会负责将内核缓冲区的数据发送出去。发送给客户端:服务端Linux系统将内核缓冲区中的数据写入网卡,网卡通过底层的通信协议将数据发送给目标客户端。由于生产环境的Java高并发应用基本都运行在Linux操作系统上,因此以上案例中的操作系统以Linux作为实例。
四种主要io模型
- 同步阻塞IO
阻塞IO指的是需要内核IO操作彻底完成后才返回到用户空间执行用户程序的操作指令
同步IO是指用户空间(进程或者线程)是主动发起IO请求的一方,系统内核是被动接收方。异步IO则反过来,系统内核是主动发起IO请求的一方,用户空间是被动接收方。
同步阻塞IO(Blocking IO)指的是用户空间(或者线程)主动发起,需要等待内核IO操作彻底完成后才返回到用户空间的IO操作。在IO操作过程中,发起IO请求的用户进程(或者线程)处于阻塞状态。
- 同步非阻塞IO
非阻塞IO(Non-Blocking IO,NIO)指的是用户空间的程序不需要等待内核IO操作彻底完成,可以立即返回用户空间去执行后续的指令,即发起IO请求的用户进程(或者线程)处于非阻塞状态,与此同时,内核会立即返回给用户一个IO状态值。
同步非阻塞IO指的是用户进程主动发起,不需要等待内核IO操作彻底完成就能立即返回用户空间的IO操作。在IO操作过程中,发起IO请求的用户进程(或者线程)处于非阻塞状态。
- IO多路复用
在Linux系统中,新的系统调用为select/epoll系统调用。通过该系统调用,一个用户进程(或者线程)可以监视多个文件描述符,一旦某个描述符就绪(一般是内核缓冲区可读/可写),内核就能够将文件描述符的就绪状态返回给用户进程(或者线程),用户空间可以根据文件描述符的就绪状态进行相应的IO系统调用。
IO多路复用(IO Multiplexing)属于一种经典的Reactor模式实现,有时也称为异步阻塞IO,Java中的Selector属于这种模型。
- 异步IO
异步IO(Asynchronous IO,AIO)指的是用户空间的线程变成被动接收者,而内核空间成为主动调用者。在异步IO模型中,当用户线程收到通知时,数据已经被内核读取完毕并放在了用户缓冲区内,内核在IO完成后通知用户线程直接使用即可。异步IO类似于Java中典型的回调模式,用户进程(或者线程)向内核空间注册了各种IO事件的回调函数,由内核去主动调用。
Netty核心原理
如果要实现自己的入站处理器,可以简单地继承ChannelInboundHandlerAdapter入站处理器适配器,再写入自己的入站处理的业务逻辑。
也就是说,重写通道读取方法channelRead()即可。
Reactor模式中IO事件的处理流程大致分为4步,具体如下
第1步:通道注册。IO事件源于通道(Channel),IO是和通道(对应于底层连接而言)强相关的。一个IO事件一定属于某个通道。如果要查询通道的事件,首先就要将通道注册到选择器。第2步:查询事件。在Reactor模式中,一个线程会负责一个反应器(或者SubReactor子反应器),不断地轮询,查询选择器中的IO事件(选择键)。
第3步:事件分发。如果查询到IO事件,则分发给与IO事件有绑定关系的Handler业务处理器。
第4步:完成真正的IO操作和业务处理,这一步由Handler业务处理器负责。
在Reactor模式中,一个反应器(或者SubReactor子反应器)会由一个事件处理线程负责事件查询和分发。该线程不断进行轮询,通过Selector选择器不断查询注册过的IO事件(选择键)。如果查询到IO事件,就分发给Handler业务处理器。
在Netty中,EventLoop反应器内部有一个线程负责Java NIO选择器的事件的轮询,然后进行对应的事件分发。事件分发(Dispatch)的目标就是Netty的Handler(含用户定义的业务处理器)。
Netty入站处理的流程是什么呢?以底层的Java NIO中的OP_READ输入事件为例:在通道中发生了OP_READ事件后,会被EventLoop查询到,然后分发给ChannelInboundHandler入站处理器,调用对应的入站处理的read()方法。在ChannelInboundHandler入站处理器内部的read()方法具体实现中,可以从通道中读取数据。
Netty中的出站处理指的是从ChannelOutboundHandler出站处理器到通道的某次IO操作。例如,在应用程序完成业务处理后,可以通过ChannelOutboundHandler出站处理器将处理的结果写入底层通道。最常用的一个方法就是write()方法,即把数据写入通道。
Netty中的Pipeline
Netty设计了一个特殊的组件,叫作ChannelPipeline(通道流水线)。它像一条管道,将绑定到一个通道的多个Handler处理器实例串联在一起,形成一条流水线。
详解Bootstrap
Bootstrap类是Netty提供的一个便利的工厂类,可以通过它来完成Netty的客户端或服务端的Netty组件的组装,以及Netty程序的初始化和启动执行。
Channel
通道是Netty的核心概念之一,代表网络连接,由它负责同对端进行网络通信,既可以写入数据到对端,也可以从对端读取数据
- EmbeddedChannel
EmbeddedChannel仅仅是模拟入站与出站的操作,底层不进行实际传输,不需要启动Netty服务器和客户端。除了不进行传输之外,EmbeddedChannel的其他事件机制和处理流程和真正的传输通道是一模一样的。因此,使用EmbeddedChannel,开发人员可以在单元测试用例中方便、快速地进行ChannelHandler业务处理器的单元测试。
Handler
测试入参方法调用时机:
public class InHandlerDemoTester {
@Test
public void testInHandlerLifeCircle() {
final InHandlerDemo inHandler = new InHandlerDemo();
//初始化处理器
ChannelInitializer i = new ChannelInitializer<EmbeddedChannel>() {
protected void initChannel(EmbeddedChannel ch) {
ch.pipeline().addLast(inHandler);
}
};
//创建嵌入式通道
EmbeddedChannel channel = new EmbeddedChannel(i);
ByteBuf buf = Unpooled.buffer();
buf.writeInt(1);
//模拟入站,写一个入站包
channel.writeInbound(buf);
channel.flush();
//模拟入站,再写一个入站包
channel.writeInbound(buf);
channel.flush();
//通道关闭
channel.close();
try {
Thread.sleep(Integer.MAX_VALUE);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
public class InHandlerDemo extends ChannelInboundHandlerAdapter {
@Override
public void handlerAdded(ChannelHandlerContext ctx) throws Exception {
Logger.info("被调用:handlerAdded()");
super.handlerAdded(ctx);
}
@Override
public void channelRegistered(ChannelHandlerContext ctx) throws Exception {
Logger.info("被调用:channelRegistered()");
super.channelRegistered(ctx);
}
@Override
public void channelActive(ChannelHandlerContext ctx) throws Exception {
Logger.info("被调用:channelActive()");
super.channelActive(ctx);
}
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
Logger.info("被调用:channelRead()");
super.channelRead(ctx, msg);
}
@Override
public void channelReadComplete(ChannelHandlerContext ctx) throws Exception {
Logger.info("被调用:channelReadComplete()");
super.channelReadComplete(ctx);
}
@Override
public void channelInactive(ChannelHandlerContext ctx) throws Exception {
Logger.info("被调用:channelInactive()");
super.channelInactive(ctx);
}
@Override
public void channelUnregistered(ChannelHandlerContext ctx) throws Exception {
Logger.info("被调用: channelUnregistered()");
super.channelUnregistered(ctx);
}
@Override
public void handlerRemoved(ChannelHandlerContext ctx) throws Exception {
Logger.info("被调用:handlerRemoved()");
super.handlerRemoved(ctx);
}
}
Pipeline
每条通道内部都有一条流水线(Pipeline)将Handler装配起来
ChannelHandlerContext
Handler和Pipeline之间需要一个中间角色将它们联系起来。这个中间角色是谁呢?ChannelHandlerContext(通道处理器上下文)
当业务处理器被添加到流水线中时会为其专门创建一个ChannelHandlerContext实例,主要封装了ChannelHandler(通道处理器)和ChannelPipeline(通道流水线)之间的关联关系。
每个流水线中双向链表结构从一开始就存在了HeadContext和TailContext两个节点,后面添加的处理器上下文节点都添加在HeadContext实例和TailContext实例之间。在添加了一些必要的解码器、业务处理器、编码器之后,一条流水线的结构大致如图
Netty零拷贝
(1)Netty提供CompositeByteBuf组合缓冲区类,可以将多个ByteBuf合并为一个逻辑上的ByteBuf,避免了各个ByteBuf之间的拷贝。
(2)Netty提供了ByteBuf的浅层复制操作(slice、duplicate),可以将ByteBuf分解为多个共享同一个存储区域的ByteBuf,避免内存的拷贝。
(3)在使用Netty进行文件传输时,可以调用FileRegion包装的transferTo()方法直接将文件缓冲区的数据发送到目标通道,避免普通的循环读取文件数据和写入通道所导致的内存拷贝问题。
(4)在将一个byte数组转换为一个ByteBuf对象的场景下,Netty提供了一系列的包装类,避免了转换过程中的内存拷贝。
(5)如果通道接收和发送ByteBuf都使用直接内存进行Socket读写,就不需要进行缓冲区的二次拷贝。如果使用JVM的堆内存进行Socket读写,那么JVM会先将堆内存Buffer拷贝一份到直接内存再写入Socket中,相比于使用直接内存,这种情况在发送过程中会多出一次缓冲区的内存拷贝。所以,在发送ByteBuffer到Socket时,尽量使用直接内存而不是JVM
Decoder与Encoder核心组件
将输入类型为ByteBuf的数据进行解码,输出一个一个的Java POJO对象,Netty内置了ByteToMessageDecoder解码器。
常用的内置Decoder
(1)固定长度数据包解码器——FixedLengthFrameDecoder
(2)行分割数据包解码器——LineBasedFrameDecoder
(3)自定义分隔符数据包解码器——DelimiterBasedFrameDecoder
(4)自定义长度数据包解码器——LengthFieldBasedFrameDecoder
Encoder
- MessageToByteEncoder编码器
- MessageToMessageEncoder编码器: 完成原POJO类型到目标POJO类型的转换逻辑
粘包和拆包
Netty发送和读取数据的“场所”是ByteBuf缓冲区。对于发送端,每一次发送就是向通道写入一个ByteBuf,发送数据时先填好ByteBuf,然后通过通道发送出去。对于接收端,每一次读取就是通过业务处理器的入站方法从通道读到一个ByteBuf。
(1)粘包:接收端(Receiver)收到一个ByteBuf,包含了发送端(Sender)的多个ByteBuf,发送端的多个ByteBuf在接收端“粘”在了一起。
(2)半包:Receiver将Sender的一个ByteBuf“拆”开了收,收到多个破碎的包。换句话说,Receiver收到了Sender的一个ByteBuf的一小部分。
如何解决呢?基本思路是,在接收端,Netty程序需要根据自定义协议将读取到的进程缓冲区ByteBuf在应用层进行二次组装,重新组装应用层的数据包。接收端的这个过程通常也称为分包或者拆包。
在Netty中分包的方法主要有以下两种:
(1)可以自定义解码器分包器:基于ByteToMessageDecoder或者ReplayingDecoder,定义自己的用户缓冲区分包器。
(2)使用Netty内置的解码器。例如,可以使用Netty内置的LengthFieldBasedFrameDecoder自定义长度数据包解码器对用户缓冲区ByteBuf进行正确的分包。
deCode和enCode核心组件
编码器和解码器
- 常用内置解码器
固定长度数据包解码器——FixedLengthFrameDecoder
行分割数据包解码器——LineBasedFrameDecoder
自定义分隔符数据包解码器——DelimiterBasedFrameDecoder
自定义长度数据包解码器——LengthFieldBasedFrameDecoder
序列化与反序列化:JSON和Protobuf
Protobuf是一个高性能、易扩展的序列化框架
-
粘包和拆包
粘包: Protobuf是一个高性能、易扩展的序列化框架
半包: Protobuf是一个高性能、易扩展的序列化框架
分包方法:
(1)可以自定义解码器分包器:基于ByteToMessageDecoder或者ReplayingDecoder,定义自己的用户缓冲区分包器。
(2)使用Netty内置的解码器。例如,可以使用Netty内置的LengthFieldBasedFrameDecoder自定义长度数据包解码器对用户缓冲区ByteBuf进行正确的分包。 -
http1.1版本
HTTP 1.1版本的最大变化就是引入了持久连接(Persistent Connection),即下层的TCP连接默认不关闭,可以被多个请求复用,而且报文不用声明
zookeeper分布式协调
ZooKeeper集群中需要一个主节点,称为Leader节点,并且Leader节点是集群通过选举规则从所有节点中选出来的,简称为选主。选主规则中很重要的一条是:要求“可用节点数量 > 总节点数量/2”。如果是偶数个节点,则会出现不满足这个规则的情况,比如出现“可用节点数量=总节点数量/2”的情况时就不满足选主的规则。
- 为什么要“可用节点数量 > 总节点数量/2”呢?为了防止集群脑裂
集群脑裂是由于网络断了,一个集群被分成了两个集群。
zookeeper存储模型
ZooKeeper的存储模型是一棵以"/"为根节点的树,存储模型中的每一个节点叫作ZNode(ZooKeeper Node)节点。
ZooKeeper为了保证高吞吐和低延迟,整个树状的目录结构全部都放在内存中。
每个节点存放的Payload负载数据的上限仅仅为1MB。
- 节点信息
ZooKeeper状态的每一次改变都对应着一个递增的事务ID(Transaction id),该ID称为Zxid,
它是全局有序的,每次ZooKeeper的更新操作都会产生一个新的Zxid。Zxid不仅仅是一个唯一的事务ID,它还具有递增性。
比如,有两个Zxid,并且Zxid1< Zxid2,就说明Zxid1变化事件发生在Zxid2变化之前。
(1)cZxid:ZNode节点创建时的事务ID(Transaction id)。
(2)mZxid:ZNode节点修改时的事务ID,与子节点无关。
(3)pZxid:ZNode节点的子节点的最后一次创建或者修改时间,与孙子节点无关。
- Curator客户端
事件监听
Watcher w = new Watcher() {
@Override
public void process(WatchedEvent watchedEvent) {
System.out.println("监听到的变化 watchedEvent = " + watchedEvent);
}
};
byte[] content = client.getData()
.usingWatcher(w).forPath(workerPath);
-
Watcher监听器是一次性的,如果要反复使用,则每次要用usingWatcher进行注册
-
Cache监听:包括NodeCache(节点监听)、PathCache(子节点监听)、TreeCache。NodeCache节点缓存可以用于ZNode节点的监听,包括新增、删除和更新等
-
NodeCache(节点监听)
NodeCache nodeCache =
new NodeCache(client, workerPath, false);
NodeCacheListener l = new NodeCacheListener() {
@Override
public void nodeChanged() throws Exception {
ChildData childData = nodeCache.getCurrentData();
log.info("ZNode节点状态改变, path={}", childData.getPath());
log.info("ZNode节点状态改变, data={}", new String(childData.getData(), "Utf-8"));
log.info("ZNode节点状态改变, stat={}", childData.getStat());
}
};
nodeCache.getListenable().addListener(l);
nodeCache.start();
// 第1次变更节点数据
client.setData().forPath(workerPath, "第1次更改内容".getBytes());
Thread.sleep(1000);
// 第2次变更节点数据
client.setData().forPath(workerPath, "第2次更改内容".getBytes());
Thread.sleep(1000);
// 第3次变更节点数据
client.setData().forPath(workerPath, "第3次更改内容".getBytes());
Thread.sleep(1000);
// 第4次变更节点数据
// client.delete().forPath(workerPath);
Thread.sleep(Integer.MAX_VALUE);
- PathCache(子节点监听)
PathChildrenCacheListener l =
new PathChildrenCacheListener() {
@Override
public void childEvent(CuratorFramework client,
PathChildrenCacheEvent event) {
try {
ChildData data = event.getData();
switch (event.getType()) {
case CHILD_ADDED:
log.info("子节点增加, path={}, data={}",
data.getPath(), new String(data.getData(), "UTF-8"));
break;
case CHILD_UPDATED:
log.info("子节点更新, path={}, data={}",
data.getPath(), new String(data.getData(), "UTF-8"));
break;
case CHILD_REMOVED:
log.info("子节点删除, path={}, data={}",
data.getPath(), new String(data.getData(), "UTF-8"));
break;
default:
break;
}
} catch (
UnsupportedEncodingException e) {
e.printStackTrace();
}
}
};
- TreeCache不仅仅能监听子节点,也能监听节点自身
TreeCacheListener l =
new TreeCacheListener() {
@Override
public void childEvent(CuratorFramework client,
TreeCacheEvent event) {
try {
ChildData data = event.getData();
if (data == null) {
log.info("数据为空");
return;
}
switch (event.getType()) {
case NODE_ADDED:
log.info("[TreeCache]节点增加, path={}, data={}",
data.getPath(), new String(data.getData(), "UTF-8"));
break;
case NODE_UPDATED:
log.info("[TreeCache]节点更新, path={}, data={}",
data.getPath(), new String(data.getData(), "UTF-8"));
break;
case NODE_REMOVED:
log.info("[TreeCache]节点删除, path={}, data={}",
data.getPath(), new String(data.getData(), "UTF-8"));
break;
default:
break;
}
} catch (
UnsupportedEncodingException e) {
e.printStackTrace();
}
}
};
zookeepr分布式锁
一个ZooKeeper分布式锁需要创建一个父节点,尽量是持久节点(PERSISTENT类型),然后每个要获得锁的线程都在这个节点下创建一个临时顺序节点,因为ZooKeeper节点是按照创建的次序依次递增的。
每个线程抢占锁之前,先尝试创建自己的ZNode。同样,释放锁的时候需要删除创建的ZNode。创建成功后,如果不是排号最小的节点,就处于等待通知的状态。前一个ZNode删除的时候,会触发ZNode事件,当前节点监听到删除事件就是轮到了自己占有锁的时候。第一个通知第二个,第二个通知第三个,击鼓传花似的依次向后。
另外,ZooKeeper的内部优越机制能保证由于网络异常或者其他原因,集群中占用锁的客户端失联时锁能够被有效释放。一旦占用ZNode锁的客户端与ZooKeeper集群服务器失去联系,这个临时ZNode也将自动删除。排在它后面的那个节点也能收到删除事件,从而获得锁。正是由于这个原因,在创建取号节点的时候尽量创建临时ZNode节点。
-
使用ZooKeeper实现分布式锁的算法有以下几个要点:
(1)一把分布式锁通常使用一个ZNode节点表示,如果锁对应的ZNode节点不存在,就先创建ZNode节点。这里假设为“/test/lock”,代表一把需要创建的分布式锁。
(2)抢占锁的所有客户端,使用锁的ZNode节点的子节点列表来表示。如果某个客户端需要占用锁,则在“/test/lock”下创建一个临时有序的子节点。
比如,子节点的前缀为“/test/lock/seq-”,则第一次抢锁对应的子节点为“/test/lock/seq-000000000”,第二次抢锁对应的子节点为“/test/lock/seq-000000001”,以此类推。
(3)如果判定客户端是否占有锁呢?很简单,客户端创建子节点后,需要判断自己创建的子节点是否为当前子节点列表中序号最小的子节点。如果是,就认为加锁成功;如果不是,则监听前一个ZNode子节点的变更消息,等待前一个节点释放锁。
(4)一旦队列中后面的节点获得前一个子节点变更通知,则开始进行判断,判断自己是否为当前子节点列表中序号最小的子节点,如果是,就认为加锁成功;如果不是,则持续监听,一直到获得锁。
(5)获取锁后,开始处理业务流程。完成业务流程后,删除自己对应的子节点,完成释放锁的工作,以方便后继节点能捕获到节点变更通知,获得分布式锁。 -
实际中可以使用Curator的InterProcessMutex可重入锁