从Netty到EPollSelectorImpl学习Java NIO

终于可以在写了几篇鸡汤文后,来篇技术文章了,:),题图是Trustin Lee,Mina/Netty都是他搞的,对Java程序员尤其是写通讯类的都产生了巨大影响,向他致敬!

在上周查一个内存OOM的问题之前,我一直觉得自己对Java NIO应该还是比较懂的,君不见N年前我曾经写过一篇《NFS-RPC框架优化过程(从37K到168K)》(尴尬的发现,上次导blog记录的时候竟然丢了一些文章,于是这文章link就不是自己的blog了),从那优化经历来说理论上对Netty的原理应该已经是比较清楚了才对,结果在查那个内存OOM的问题的时候,发现自己还是too young too navie,看到的现象是EPollSelectorImpl里的fdToKey有一大堆数据,就傻眼了,完全不知道这是为什么,当时就在公众号上发了个文本信息咨询大家,有不少同学给了我回复,另外滴滴打车的架构师欧阳康给了我一篇文章来说明EPollSelectorImpl这个部分的原理(强烈推荐,比我写的这篇会更深入到os层),本文就是综合了大家给我的点拨,来写写从Netty到EPollSelectorImpl的相关逻辑。

带着问题去看代码会比较的快和容易,我这次带着这几个问题:
1. EPollSelector里的fdToKey里的一大堆数据是怎么出现的;
2. Netty以及Java的EPoll部分代码是如何让N多连接的处理做到高效的,当然这主要是因为epoll,不过Java部分的相关实现也是重要的。

由于公众号贴代码不太方便,在这我就不贴大段的代码了,只摘取一些非常关键的代),代码部分我看的是Server部分,毕竟Server尤其是长连接类型的,通常会需要处理大量的连接,而且会主要是贴近我所关注的两个问题的相关代码。

Netty在初始化Server过程中主要做的事:
1. 启动用于处理连接事件的线程,线程数默认为1;
2. 创建一个EPollSelectorImpl对象;

在bind时主要做的事:
1. 开启端口监听;
2. 注册OP_ACCEPT事件;

处理连接的线程通过Selector来触发动作:

int selected = select(selector);

这个会对应到EPollSelectorImpl.doSelect,最关键的几行代码:

pollWrapper.poll(timeout);
int numKeysUpdated = updateSelectedKeys(); // 更新有事件变化的selectedKeys,selectedKeys是个Set结构

当numKeysUpdated>0时,就开始处理其中发生了事件的Channel,对连接事件而言,就是去完成连接的建立,连接建立的具体动作交给NioWorker来实现,每个NioWorker在初始化时会创建一个EPollSelectorImpl实例,意味着每个NioWorker线程会管理很多的连接,当建连完成后,注册OP_READ事件,注册的这个过程会调用到EPollSelectorImpl的下面的方法:

protected void implRegister(SelectionKeyImpl ski) {
SelChImpl ch = ski.channel;
fdToKey.put(Integer.valueOf(ch.getFDVal()), ski);
pollWrapper.add(ch);
keys.add(ski);
}

从这段代码就明白了EPollSelectorImpl的fdToKey的数据是在连接建立后产生的。

那什么时候会从fdToKey里删掉数据呢,既然放数据是建连接的时候,那猜测删除就是关连接的时候,翻看关连接的代码,最终会调到EPollSelectorImpl的下面的方法:

protected void implDereg(SelectionKeyImpl ski) throws IOException {
assert (ski.getIndex() >= 0);
SelChImpl ch = ski.channel;
int fd = ch.getFDVal();
fdToKey.remove(new Integer(fd));
pollWrapper.release(ch);
ski.setIndex(-1);
keys.remove(ski);
selectedKeys.remove(ski);
deregister((AbstractSelectionKey)ski);
SelectableChannel selch = ski.channel();
if (!selch.isOpen() && !selch.isRegistered())
((SelChImpl)selch).kill();
}

从上面代码可以看到,在这个过程中会从fdToKey中删除相应的数据。

翻代码后,基本明白了fdToKey这个部分,在Netty的实现下,默认会创建一个NioServerBoss的线程,cpu * 2的NioWorker的线程,每个线程都会创建一个EPollSelectorImpl,例如如果CPU为4核,那么总共会创建9个EPollSelectorImpl,每建立一个连接,就会在其中一个NioWorker的EPollSelectorImpl的fdToKey中放入SelectionKeyImpl,当连接断开时,就会相应的从fdToKey中删除数据,所以对于长连接server的场景而言,fdToKey里有很多的数据是正常的。

——————————-

第一个问题解决后,继续看第二个问题,怎么用比较少的资源高效的处理那么多连接的各种事件。

根据上面翻看代码的记录,可以看到在Netty默认的情况下,采用的是1个线程来处理连接事件,cpu * 2个NioWorker线程来处理读写事件。

连接动作因为很轻量,因此1个线程处理通常足够了,当然,客户端在设计重连的时候就得有避让机制,否则所有机器同一时间点重连,那就悲催了。

在分布式应用中,网络的读写会非常频繁,因此读写事件的高效处理就非常重要了,在Netty之上怎么做到高效也很重要,具体可以看看我之前写的那篇优化的文章,这里就只讲Netty之下的部分了,NioWorker线程通过
int selected = select(selector);
来看是否有需要处理的,selected>0就说明有需要处理,EPollSelectorImpl.doSelect中可以看到一堆的连接都放在了pollWrapper中,如果有读写的事件要处理,这里会有,这块的具体实现还得往下继续翻代码,这块没再继续翻了,在pollWrapper之后,就会updateSelectedKeys();,这里会把有相应事件发生的SelectionKeyImpl放到SelectedKeys里,在netty这层就会拿着这个selectedKeys进行遍历,一个一个处理,这里用多个线程去处理没意义的原因是:从网卡上读写的动作是串行的,所以再多线程也没意义。

所以基本可以看到,网络读写的高效主要还是ePoll本身做到,因为到了上层其实每次要处理的已经是有相应事件发生的连接,netty部分通过较少的几个线程来有效的处理了读写事件,之所以读写事件不能像连接事件一样用一个线程去处理,是因为读的处理过程其实是比较复杂的,从网卡cp出数据后,还得去看数据是否完整(对业务请求而言),把数据封装扔给业务线程等等,另外也正因为netty是要用NioWorker线程处理很多连接的事件,因此在高并发场景中保持NioWorker整个处理过程的快速,简单是非常重要的。

——————————

带着这两个问题比以前更往下的翻了一些代码后,确实比以前更了解Java NIO了,但其实还是说不到深入和精通,因为要更细其实还得往下翻代码,到OS层,所以我一如既往的觉得,其实Java程序员要做到精通,是比C程序员难不少的,因为技术栈更长,而如果不是从上往下全部打通技术栈的话,在查问题的时候很容易出现查到某层就卡壳,我就属于这种,所以我从来不认为自己精通Java的某部分。

最近碰到的另外一个问题也是由于自己技术栈不够完整造成排查进展一直缓慢,这问题现在还没结果,碰到的现象就是已经触发了netty避免ePoll bug的workaround,日志里出现:
Selector.select() returned prematurely 512 times in a row; rebuilding selector.
这个日志的意思是Selector.select里没有数据,但被连续唤醒了512次,这样的情况很容易导致一个cpu core 100%,netty认为这种情况出现时ePoll bug造成的,在这种情况下会采取一个workaround方法,就是rebuilding selector,这个操作会造成连接重建,对高并发场景来说,这个会造成超时等现象,所以影响还挺大的。
由于这个问题已经要查到os层,我完全无能为力,找了公司的一个超级高手帮忙查,目前的进展是看到有一个domain socket被close了,但epoll_wait的时候还是会选出这个fd,但目前还不知道为什么会出现这现象,所以暂时这问题还是存在着,有同学有想法的也欢迎给些建议。

=============================
欢迎关注微信公众号:hellojavacases

公众号上发布的消息都存放在http://hellojava.info上。

Java NIO之EPollSelectorImpl详解

这是滴滴的架构师欧阳康同学写的,非常赞,从EPollSelectorImpl到OS层面实现的详细解释,可以让大家对Java NIO的实现有更完整的理解,强烈推荐。

本文简述JDK1.7的NIO在linux平台上的实现,对java NIO的一些核心概念如Selector,Channel,Buffer等,不会做过多解释,这些请参考JDK的文档。JDK 1.7 NIO Selector在linux平台上的实现类是sun.nio.ch.EPollSelectorImpl,这个类通过linux下的epoll系列系统调用实现NIO,因此在介绍这个类的实现之前,先介绍一下linux的epoll。epoll是poll/select系统调用的一个改进版本,能以更高的性能实现IO事件的检测和分发(主要归功于epoll的事件回调机制,下文详述),主要包含以下3个系统调用:

#include
int epoll_create(int size);
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
int epoll_wait(int epfd, struct epoll_event *events,int maxevents, int timeout);

上述函数中,epoll_create函数负责创建一个检测IO事件的epoll实例,size参数用于“暗示”操作系统事件队列的长度,在linux-2.6.32内核中,此参数被忽略。epoll_ctl函数用于管理文件描述符的事件集,使用此函数可以注册、修改、删除一个或多个事件。epoll_wait负责检测事件,这三个函数的详细描,请参阅epoll的man文档。
Java类sun.nio.ch.EPollSelectorImpl主要的功能都委托给sun.nio.ch. EPollArrayWrapper实现 (下文所引java代码反编译自linux版jdk_1.7.0_17/lib/rt.jar):

package sun.nio.ch;
class EPollArrayWrapper{
private native int epollCreate();
private native void epollCtl(int paramInt1, int paramInt2, int paramInt3, int paramInt4);
private native int epollWait(long paramLong1, int paramInt1, long paramLong2, int paramInt2) throws IOException;
}

EPollArrayWrapper的三个native方法的实现代码可参阅openjdk7/jdk/src/solaris/native/sun/nio/ch/ EPollArrayWrapper.c,可看到这三个native方法正是对上述epoll系列系统调用的包装。(其他jdk的实现代码会有所不同,但归根结底都是对epoll系列系统调用的包装)。
EPollSelectorImpl. implRegister方法(Selector.register方法的具体实现),通过调用epoll_ctl向epoll实例中注册事件:

protected void implRegister(SelectionKeyImpl paramSelectionKeyImpl) {
if (this.closed)
throw new ClosedSelectorException();
SelChImpl localSelChImpl = paramSelectionKeyImpl.channel;
this.fdToKey.put(Integer.valueOf(localSelChImpl.getFDVal()), paramSelectionKeyImpl);
this.pollWrapper.add(localSelChImpl);
this.keys.add(paramSelectionKeyImpl);
}

上述方法中,除了向epoll实例注册事件外,还将注册的文件描述符(fd)与SelectionKey的对应关系添加到fdToKey中,这个map维护了文件描述符与SelectionKey的映射。每当向Selector中注册一个Channel时,向此map中添加一条记录,而当Channel.close、SelectionKey.cancel方法调用时,则从fdToKey中移除与Channel的fd相关联的SelectionKey,具体代码在EPollSelectorImpl.implDereg方法中:

protected void implDereg(SelectionKeyImpl paramSelectionKeyImpl) throws IOException {
assert (paramSelectionKeyImpl.getIndex() >= 0);
SelChImpl localSelChImpl = paramSelectionKeyImpl.channel;
int i = localSelChImpl.getFDVal();
this.fdToKey.remove(Integer.valueOf(i));
this.pollWrapper.release(localSelChImpl);
paramSelectionKeyImpl.setIndex(-1);
this.keys.remove(paramSelectionKeyImpl);
this.selectedKeys.remove(paramSelectionKeyImpl);
deregister(paramSelectionKeyImpl);
SelectableChannel localSelectableChannel = paramSelectionKeyImpl.channel();
if ((!localSelectableChannel.isOpen()) && (!localSelectableChannel.isRegistered()))
((SelChImpl)localSelectableChannel).kill();
}

EPollSelectorImpl. doSelect(Selector.select方法的实现),则通过调用epoll_wait实现事件检测:

protected int doSelect(long paramLong)
throws IOException
{
if (this.closed)
throw new ClosedSelectorException();
processDeregisterQueue();
try {
begin();
this.pollWrapper.poll(paramLong);
} finally {
end();
}
processDeregisterQueue();
int i = updateSelectedKeys();
if (this.pollWrapper.interrupted())
{
this.pollWrapper.putEventOps(this.pollWrapper.interruptedIndex(), 0);
synchronized (this.interruptLock) {
this.pollWrapper.clearInterrupted();
IOUtil.drain(this.fd0);
this.interruptTriggered = false;
}
}
return i;
}

此方法的主要流程概括如下:
1. 通过epoll_wait调用(this.pollWrapper.poll)获取已就绪的文件描述符集合
2. 通过fdToKey查找文件描述符对应的SelectionKey,并更新之,更新SelectionKey的具体代码在EPollSelectorImpl .updateSelectedKeys中:

private int updateSelectedKeys()
{
int i = this.pollWrapper.updated;
int j = 0;
for (int k = 0; k < i; k++) { int m = this.pollWrapper.getDescriptor(k); SelectionKeyImpl localSelectionKeyImpl = (SelectionKeyImpl)this.fdToKey.get(Integer.valueOf(m)); if (localSelectionKeyImpl != null) { int n = this.pollWrapper.getEventOps(k); if (this.selectedKeys.contains(localSelectionKeyImpl)) { if (localSelectionKeyImpl.channel.translateAndSetReadyOps(n, localSelectionKeyImpl)) j++; } else { localSelectionKeyImpl.channel.translateAndSetReadyOps(n, localSelectionKeyImpl); if ((localSelectionKeyImpl.nioReadyOps() & localSelectionKeyImpl.nioInterestOps()) != 0) { this.selectedKeys.add(localSelectionKeyImpl); j++; } } } } return j; }

关于fdToKey,有几个问题:
一、为何fdToKey会变得非常大?由上述代码可知,fdToKey变得非常大的可能原因有2个:
1.注册到Selector上的Channel非常多,例如一个长连接服务器可能要同时维持数十万条连接;
2.过期或失效的Channel没有及时关闭,因而对应的记录会一直留在fdToKey中,时间久了就会越积越多;
二、为何fdToKey总是串行读取?fdToKey中记录的读取,是在select方法中进行的,而select方法一般而言总是单线程调用(Selector不是线程安全的)。
三、tcp发包堆积对导致fdToKey变大吗?一般而言不会,因为fdToKey只负责管理注册到Selector上的channel,与数据传输过程无关。当然,如果tcp发包堆积导致IO框架的空闲连接检测机制失效,无法及时检测并关闭空闲的连接,则有可能导致fdToKey变大。

下面聊一聊epoll系统调用的具体实现,它的实现代码在(linux-2.6.32.65)fs/eventpoll.c中(下文所引内核代码,由于较长,所以只贴出主流程,省略了错误处理及一些相对次要的细节如参数检查、并发控制等),先看epoll_create 系统调用的实现:
fs/eventpoll.c

SYSCALL_DEFINE1(epoll_create, int, size)
{
if (size <= 0) return -EINVAL; return sys_epoll_create1(0); }

SYSCALL_DEFINE1是一个宏,用于定义有一个参数的系统调用函数,上述宏展开后即成为:
int sys_epoll_create(int size)
这就是epoll_create系统调用的入口。至于为何要用宏而不是直接声明,主要是因为系统调用的参数个数、传参方式都有严格限制,最多六个参数, SYSCALL_DEFINE2 -SYSCALL_DEFINE6分别用来定义有2-6个参数的系统调用。由上述代码可知,epoll_create函数最终调用sys_epoll_create1实现具体功能,同时也可以看出size参数被忽略了。sys_epoll_create1的主要代码如下(省略了错误处理及一些次要的细节如参数检查等):
fs/eventpoll.c

SYSCALL_DEFINE1(epoll_create1, int, flags)
{
int error, fd;
struct eventpoll *ep = NULL;
struct file *file;
error = ep_alloc(&ep);
file = anon_inode_getfile("[eventpoll]", &eventpoll_fops, ep,
O_RDWR | (flags & O_CLOEXEC));
fd_install(fd, file);
ep->file = file;
return fd;
}

上述代码主要是分配一个struct eventpoll实例,并分配与此实例相关联的文件描述符,后续的epoll_ctl,epoll_wait调用通过此文件描述符引用此实例。struct eventpoll的结构如下:
fs/eventpoll.c

struct eventpoll {
spinlock_t lock;
struct mutex mtx;
wait_queue_head_t wq;
wait_queue_head_t poll_wait;
struct list_head rdllist;
struct rb_root rbr;
struct epitem *ovflist;
struct user_struct *user;
struct file *file;
int visited;
struct list_head visited_list_link;
}

上述数据结构的关键部分是:
1. 一个等待队列wq,epoll正是通过此等待队列实现的事件回调
2. 一个就绪列表rdllist,此列表以双链表的形式保存了已就绪的文件描述符
3. 一个红黑树rbr,用于保存已注册过的文件描述符,若重复注册相同的文件描述符,则会返回错误
等待队列是epoll系统调用的核心机制(不只是epoll,linux下事件的通知、回调等机制大都依赖于等待队列),在讲述epoll_ctl,epoll_wait的实现之前,先来看看等待队列。等待队列可以使一组进程/线程在等待某个事件时睡眠,当等待的事件发生时,内核会唤醒睡眠的进程/线程。注意,下文并不区分进程和线程,在linux下,进程和线程在调度这个意义下(调度就是指linux的进程调度,包括进程的切换、睡眠、唤醒等)并无差别。此机制可以类比java.lang.Object类的wait和notify/notifyAll方法,其中wait方法使线程睡眠,notify/notifyAll方法则唤醒睡眠的一个或全部线程。等待队列主要涉及两个数据结构:
include/linux/wait.h

struct __wait_queue_head {
spinlock_t lock;
list_head task_list;
};
struct __wait_queue {
unsigned int flags;
#define WQ_FLAG_EXCLUSIVE 0x01
void *private;
wait_queue_func_t func;
struct list_head task_list;
};

struct __wait_queue_head是队头结构,task_list 保存了添加到此队列上的元素,struct list_head是标准的linux双链表, 定义如下:
include/linux/list.h

struct list_head {
struct list_head *next, *prev;
};

注意,此结构既可以表示双链表的表头,也可以表示一个链表元素,且next,prev这两个指针可以指向任意数据结构。
struct __wait_queue是等待队列的元素结构,成员func是等待的进程被唤醒时执行的回调函数,其定义如下:
include/linux/wait.h

typedef int (*wait_queue_func_t)(wait_queue_t *wait, unsigned mode, int flags, void *key);

struct __wait_queue的成员task_list是一个链表元素用于将此结构放置到struct __wait_queue_head中(这和此结构的task_list成员含义是不同的,此成员的含义为双链表的头),private成员一般指向等待进程的task_struct实例(该结构成员很多,在此就不贴出了,只需要知道linux下每个进程都对应一个task_struct 实例)。
在使用上,等待队列主要涉及以下函数(或者宏):
include/linux/wait.h
__add_wait_queue(wait_queue_head_t *q, wait_queue_t *wait);
#define wait_event(wq, condition)
#define wake_up_xxx(x,…)
__add_wait_queue用于将一个进程添加到等待队列,wait_event是一个宏,它用于等待一个事件,当事件未发生时使等待的进程休眠,wake_up_xxx是一系列的宏,包括wake_up,wake_up_all,wake_up_locked,wake_up_interruptible等,负责唤醒休眠在某个事件上的一个或一组进程。关于等待队列的具体实现细节,由于牵涉较广(涉及到进程调度、中断处理等),这里不再详述,可以将add_wait_queue,wait_event类比java.lang.Object的wait方法,而wake_up则可以类比java.lang.Object的notify/notifyAll方法。
介绍完等待队列后,就可以进一步研究epoll_ctl的实现了,其代码实现中核心的部分是:
fs/eventpoll.c

SYSCALL_DEFINE4(epoll_ctl, int, epfd, int, op, int, fd,
struct epoll_event __user *, event)
{
if (!tfile->f_op || !tfile->f_op->poll)
goto error_tgt_fput;
switch (op) {
case EPOLL_CTL_ADD:
error=ep_insert(ep, &epds, tfile, fd);
break;
case EPOLL_CTL_DEL:
error=ep_remove(ep, epi);
break;
case EPOLL_CTL_MOD:
error = ep_modify(ep, epi, &epds);
break;
}
return error;
}

什么样的文件描述符可以注册?从那个if判断可以看出,只有文件描述符对应的文件实现了poll方法的才可以,一般而言,字符设备的文件都实现了此方法,网络相关的套接字也实现了此方法,而块设备文件例如ext2/ext3/ext4文件系统文件,都没有实现此方法。实现了poll方法的文件,对应于java NIO的java.nio.channels.SelectableChannel,这也是为何只有 SelectableChannel 才能注册到Selector上的原因。ep_insert,ep_remove,ep_modify分别对应事件的注册、删除、修改,我们以ep_insert为例,看一下事件注册的过程,其关键代码如下:
fs/eventpoll.c

static int ep_insert(struct eventpoll *ep, struct epoll_event *event,
struct file *tfile, int fd)
{
init_poll_funcptr(&epq.pt, ep_ptable_queue_proc);
revents = tfile->f_op->poll(tfile, &epq.pt);
ep_rbtree_insert(ep, epi);
if ((revents & event->events) && !ep_is_linked(&epi->rdllink)) {
list_add_tail(&epi->rdllink, &ep->rdllist);;
wake_up_locked(&ep->wq);
}
}

上述代码的主要做的事是:
1. 绑定等待队列的回调函数ep_ptable_queue_proc
2. 调用对应文件的实例的poll方法,此方法的具体实现差别非常大,但绝大多数都会调用wait_event相关方法,在没有事件发生时,使进程睡眠,例如socket对应的实现(代码在net/ipv4/af_inet.c的tcp_poll方法,在此不再详述);
3. 若注册的事件已经发生,则将已就绪的文件描述符插入到eventpoll实例的就绪列表(list_add_tail(&epi->rdllink, &ep->rdllist);),并唤醒睡眠的进程(wake_up_locked(&ep->wq))
第1步绑定的回调函数ep_ptable_queue_proc,会在等待的事件发生时执行,其主要功能是将就绪的文件描述符插入到eventpoll实例的就绪列表(具体是通过ep_ptable_queue_proc绑定的另一个回调函数ep_poll_callback实现的):
fs/eventpoll.c

static int ep_poll_callback(wait_queue_t *wait, unsigned mode, int sync, void *key){
if (!ep_is_linked(&epi->rdllink))
list_add_tail(&epi->rdllink, &ep->rdllist);
}

最后看epoll_wait的实现,有了就绪队列,epoll_wait的实现就比较简单了,只需检查就绪队列是否为空,若为空,则在必要时睡眠或等待:
fs/eventpoll.c

SYSCALL_DEFINE4(epoll_wait, int, epfd, struct epoll_event __user *, events,
int, maxevents, int, timeout)
{
int error;
struct file *file;
struct eventpoll *ep;
file = fget(epfd);
ep = file->private_data;
error = ep_poll(ep, events, maxevents, timeout);
return error;
}

此函数最终调用ep_poll完成其主要功能:

static int ep_poll(struct eventpoll *ep, struct epoll_event __user *events,
int maxevents, long timeout)
{
retry:
if (list_empty(&ep->rdllist)) {
init_waitqueue_entry(&wait, current);
wait.flags |= WQ_FLAG_EXCLUSIVE;
__add_wait_queue(&ep->wq, &wait);

for (;;) {
set_current_state(TASK_INTERRUPTIBLE);
if (!list_empty(&ep->rdllist) || !jtimeout)
break;
if (signal_pending(current)) {
res = -EINTR;
break;
}

spin_unlock_irqrestore(&ep->lock, flags);
jtimeout = schedule_timeout(jtimeout);
spin_lock_irqsave(&ep->lock, flags);
}
__remove_wait_queue(&ep->wq, &wait);

set_current_state(TASK_RUNNING);
}
eavail = !list_empty(&ep->rdllist) || ep->ovflist != EP_UNACTIVE_PTR;

spin_unlock_irqrestore(&ep->lock, flags);
if (!res && eavail &&
!(res = ep_send_events(ep, events, maxevents)) && jtimeout)
goto retry;

return res;
}

上述代码主要是检查就绪队列是否为空,若为空时,则根据超时设置判断是否需要睡眠(__add_wait_queue)或等待(jtimeout = schedule_timeout(jtimeout);)。

综上所述,epoll系统调用通过等待队列,其事件检测(epoll_wait系统调用)的时间复杂度为O(n),其中n是“活跃”的文件描述符总数,所谓的活跃,是指在该文件描述符上有频繁的读写操作,而对比poll或select系统调用(其实现代码在fs/select.c中),其时间复杂度也是O(n),但这个n却是注册的文件描述符的总数。因此,当活跃的文件描述符占总的文件描述符比例较小时,例如,在长连接服务器的场景中,虽然同时可能需要维持数十万条长连接,但其中只有少数的连接是活跃的,使用epoll就比较合适。

=============================
欢迎关注微信公众号:hellojavacases

公众号上发布的消息都存放在http://hellojava.info上。

程序员的成长路线

工作这么些年了,看到了各种各样的程序员,也看到了各种各样的成长路线,说说自己的一些观点吧。

作为技术人员,在刚起步阶段时,首先需要拓宽自己的技术宽度,对自己所做的项目/产品所涉及的方方面面的技术都应该有所了解,另外对于就是学习工程化,让自己真正具备开发商业软件的能力。

在工程化和知识宽度达到一定阶段后,需要开始根据自己的兴趣和工作内容有所选择,主要是加强在某一领域的技术深度。

在技术深度达到了一定阶段后,需要对自己做出一个选择,就是偏业务方向,还是偏基础技术方向。

偏业务方向的技术人员,我认为做的好的表现是:
1. 对业务发展的未来有一定的预判,有商业敏感意识;
2. 能对复杂的业务进行合理的抽象;
3. 在系统的设计上能对未来业务的变化有一定的预留处理。

偏基础方向的技术人员,我认为做的好的表现是:
1. 能结合业务的发展趋势对基础技术的方向有一定的预判,避免业务发展受到基础技术的拖累;
2. 对业界的技术发展方向有自己的认知和判断;
3. 在对应的基础技术领域有不错的技术深度。

结合自己的特质以及当前的一些状况,做出一个选择,重点发展。

而再往更高阶走的同学,通常就会出现一种新的角色,就是成为团队leader,做为一个技术团队的leader,无论是业务的还是基础技术的,在技术能力上还是不能差的,尤其是判断力上,另外,作为一个团队leader,就意味着承担了团队方向的判断的职责,一个团队的方向基本会直接影响到团队所有成员的未来,以及所支持的业务的发展状况,所以对于一个团队leader,我觉得最重要的能力就在方向的判断上,然后是根据方向的判断的组织建设(团队搭建,人才识别、培养、招募等)能力。

如果不是往leader方向呢,那基本就是往架构师方向为多,架构师的话,在至少一两个领域的深度外,对广度的要求非常高,还有同样就是判断能力,无论是业务架构师,还是基础方向的架构师,领域的知识宽度是非常重要的,意味着能做多大范围的事,判断能力会体现出一个架构师在做一个架构设计时重点是怎么判断的,在有限的资源和时间情况下取舍是怎么做的,对未来是怎么做铺垫的,以及TA对事情的技术控制能力,一个好的架构师在技术风险的控制能力上必须是非常强的,例如一个强大的基础领域的架构师,应该是可以很好的控制跨多个专业技术领域的技术演进。

还有一种是往专业技术深度领域方向走,例如内核、JVM等,这些领域是真正的需要非常深的技术功底才能hold的住的。

还会有其他例如转型往业务产品方向等发展的就不在这说了。

总而言之,言而总之,我觉得在整个成长过程中,兴趣是最为关键的,所以follow your heart非常重要,只有在有足够的兴趣或梦想的情况下才能产生很强的自驱,没有足够的自驱我觉得在技术领域基本上是不可能走到高阶的,除了兴趣外,自己的优势也要判断清楚,每个不同的方向,我自己认为还是需要一定的天分的,而所谓的天分我觉得就是对个人优势的判断。

=============================
欢迎关注微信公众号:hellojavacases

公众号上发布的消息都存放在http://hellojava.info上。

技术人员的情结

每个技术人员都会有技术情结,或多或少而已,语言情结,框架情结等,这篇文章就来说说自己的技术情结和看到的一些人的技术情结的事。

我自己对OSGi算是有情结的吧,很多年前在上海工作的时候,偶尔会去参加一些meetup,看着讲topic的人都觉得好牛,好想认识下,但相对还是会比较困难,直到我接触OSGi,并且写了一些普及的文档后,很快就认识了不少业界的人,这些交际很大程度还影响到了我之后的职业生涯。

在07年加入阿里后,所做的工作其实和OSGi没有任何关系,自己甚至也觉得不会再和OSGi有任何关系了,但所谓的情结必须说就是深入骨髓的,到09年自己所负责的产品出现class不隔离导致的一些问题后,就立刻想着引入OSGi,这个决定可以说和情结有很大的关系,把自己最熟悉、最喜欢的一个东西放到商业级的产品中,这我想是很多情结最后最好的输出吧,当时为了给自己找到充分的理由,就把动态化也写为了需求的重点,class隔离+动态化,那绝对是OSGi的完美场景,于是整个团队在这件事上扑腾了好几个月,OSGi是引入了,动态化最后放弃了(实在太复杂,对于有状态的class要做到动态化实在太麻烦,而且在集群化的背景下其实意义不大),团队成员为了熟悉OSGi的开发模式花费了巨大的精力,并且以后加入团队的人也都受到不小的困扰,在那次折腾后,尽管看似情结输出了,但从此以后几乎所有来问我做插件化、class隔离等方案的选择时,我基本都会告诉他们不要用OSGi。

从OSGi情结这件事后,我就再也没有把情结和工作严格绑定了,情结能输出到工作最好,不能输出也OK。

在工作的历程中,也还看到其他很多类似的case,例如框架情结的同学,有些时候很容易做出决定是在自己所做的东西中要使用某框架,而也许公司内其实有类似的产品,并且有专职团队的维护和支持,说到这个必须说说以前经历的一个case是有一个同学对MongoDB非常有情结,于是在自己的一个产品中就引入了MongoDB,但其实自己对MongoDB的细节并不算非常清楚,结果后来运行过程中出问题了无法处理,而且其实后来review方案会发现在该场景中MySQL完全就可以满足;又例如语言情结的同学,有时候很容易做出决定是在自己所做的东西中使用某语言,而完全不顾及整个公司可能没太有这方面的人才,以后这东西还能不能维护得下去,说到这个可以说说火热的node.js,情况也类似,在运行过程中出现问题,没人能处理,只能临时Google等,搞得非常被动。

其实有技术情结不是坏事,反而多数是对技术有热情的人才会有技术情结吧,但技术情结是否要落到工作中,这个就必须深思熟虑了,工作其实是工程化的东西,工程化要考虑的因素非常的多,例如框架是否有专职团队支撑,未来的可维护性和持续发展性,甚至是业界的人才状况等都要考虑,所以很多时候语言的争论什么的其实意义不大,当然纯技术层面的讨论OK。

你会有什么技术情结呢,会不会有无论如何都得把情结输出到工作中的想法,或者你看到过什么现象,经历过什么故事,来回复说说看。

=============================
欢迎关注微信公众号:hellojavacases

关于此微信号:
分享Java问题排查的Case、Java业界的动态和新技术、Java的一些小知识点Test,以及和大家一起讨论一些Java问题或场景,这里只有Java细节的分享,没有大道理、大架构和大框架。

公众号上发布的消息都存放在http://hellojava.info上。

探讨下DevOPS

技术界一直就是新名词不断的风格,DevOPS这个词话说出来也挺长时间了,一直以来对这个不算太明白,以为就是指OPS应该不仅仅做OPS的工作,而是应该同时承担起开发自己OPS工作的系统,注意指的是系统,而不是脚本,因为很多的OPS操作是一个流程式的多步骤组成,并且多集群,多系统的交互,这个时候用脚本去实现是会比较难的,而且还要处理诸多的异常等,系统是一个工程性的东西,不仅仅是功能的实现,还要考虑很多异常、稳定性等的问题,但最近的一些思考,让自己对DevOPS有了更多的看法。

OPS去承担起开发自己OPS工作的系统这个比较容易理解,最大的原因在于自己的痛其实只有自己最清楚,很多家公司估计都尝试过让一个专职的团队来开发OPS用的系统,结果就是专职的这个团队和OPS团队挺容易引发争执,然后系统也通常不能很好的解决问题,而一旦转变为OPS自己来做系统给自己用,那问题被解决的可能性会大幅提高,而且有不少公司确实也是OPS采用OnCall轮转的机制,Oncall的时候专心干OPS的活,不OnCall的同学则专心写系统解决自己之前OnCall的时候手工干的活,不过这种方式下比较容易碰到的一个问题可能是写出来的系统的质量不够理想,例如对于运维系统来说,在成功率的要求上会远比在线系统高,但在性能、并发这两点上会远比在线系统低。

除了上面这个点外,运维团队通常还很容易碰到的一个问题是研发交付的系统可运维性不太好,这种时候通常只能是纯操作方面的事运维先人肉顶着,但碰到一些故障的时候,如果系统可运维性比较差,会导致排查过程极度复杂,耗时长,而在有研发和运维这两个独立岗位的时候,这种现象很容易导致的结果就是运维在苦逼的处理一堆的这样的事情,研发呢反正也不是很能感受到这样的痛(因为一个研发可能就负责一两个系统的开发,但一个运维通常可能负责几十个甚至更多系统的运维工作),于是也不是很在乎,最终导致很容易出现的现象就是运维推着研发做很多可运维性的改造,无论是运维体系标准的建设,监控体系标准的建设等,但这个推动通常其实不会那么容易,最重要的原因我自己觉得主要是体感的问题。

所以我现在理解中的DevOPS,我觉得是消除OPS这个独立岗位,让研发和运维合并成同一岗位,研发系统的团队轮值安排OnCall,这样会让研发系统的同学深刻感受到系统设计不靠谱的时候给运维阶段带来的痛苦,从而把本来就应该在设计阶段考虑的可运维性考虑进去,同时也避免了两个团队带来的协调成本等,并且对于研发而言,由于“偷懒”的特性,很容易就会去打造系统来解决手工干的活,站在这样的角度,我觉得就很容易理解为什么叫DevOPS,而不是OPSDev。

大家也可以探讨下你所感受到的运维是怎么样,你觉得变成什么样是比较不错的。

=============================
欢迎关注微信公众号:hellojavacases

关于此微信号:
分享Java问题排查的Case、Java业界的动态和新技术、Java的一些小知识点Test,以及和大家一起讨论一些Java问题或场景,这里只有Java细节的分享,没有大道理、大架构和大框架。

公众号上发布的消息都存放在http://hellojava.info上。

从一个故障说说Java的三个BlockingQueue

最近出了个故障,排查的时候耗费了很长的时间,回顾整个排查过程,经验主义在这里起了不好的作用,直接导致了整个故障排查的时间非常长,这个故障的根本原因在于BlockingQueue用的有问题,顺带展开说说Java中常用的几个BlockingQueue:ArrayBlockingQueue、LinkedBlockingQueue和SynchronousQueue。

当时故障的现象是应用处理请求的线程池满了,导致请求处理不了,于是dump线程,看线程都在做什么,结果发现线程都Block在写日志的地方,以前出现过很多次问题,去线程dump的时候看到也是一堆的block在写日志,但通常是别的原因引发的,所以这次也是按照这样的经验,认为肯定不会是写日志这个地方的问题,于是各种排查…折腾了N久后,回过头看发现持有那把日志锁的地方是自己人写的代码,那段代码在拿到了这个日志锁后,从线程堆栈上看,block在了ArrayBlockingQueue.put这个地方,于是翻看这段代码,结果发现这是个1024长度的BlockingQueue,那就意味着如果这个Queue被放了1024个对象的话,put就一定会被block住,而且其实翻代码的时候能看出写代码的同学是考虑到了BlockingQueue如果满了应该要处理的,代码里写着:

if (blockingQueue.remainingCapacity() < 1) { //todo } blockingQueue.put

这里两个悲催的问题,一是这个if判断完还是直接会走到put,而不是else,二是竟然关键的满了后的处理逻辑还在//todo...
另外我觉得这段代码还反应了同学对BlockingQueue的接口不太熟,要达到这个效果,不需要这样先去判断,更合适的做法是用blockingQueue.offer,返回false再做相应的异常处理。

BlockingQueue是在生产/消费者模式下经常会用到的数据结构,通常常用的主要会是ArrayBlockingQueue、LinkedBlockingQueue和SynchronousQueue。

ArrayBlockingQeue/LinkedBlockingQueue两者的最大不同主要在于存放Queue中对象方式,一个是数组,一个是链表,代码注释里也写到了两者的不同:
Linked queues typically have higher throughput than array-based queues but less predictable performance in most concurrent applications.

SynchronousQueue是一个非常特殊的BlockingQueue,它的模式是在offer的时候,如果没有另外一个线程正在take或poll的话,那么offer就会失败;在take的时候,如果没有另外的线程正好并发在offer,也会失败,这种特殊的模式非常适合用来做要求高响应并且线程出不固定的线程池的Queue。

对于在线业务场景而言,所有的并发,外部访问阻塞的地方的一个真理就是一定要有超时机制,我不知道见过多少次由于没有超时造成的在线业务的严重故障,在线业务最强调的是快速处理掉一次请求,所以fail fast是在线业务系统设计,代码编写中的最重要原则,按照这个原则上面的代码最起码明显犯的错误就是用put而不是带超时机制的offer,或者说如果是不重要的场景,完全就应该直接用offer,false了直接抛异常或记录下异常即可。

对于BlockingQueue这种场景呢,除了超时机制外,还有一个是队列长度一定要做限制,否则默认的是Integer.MAX_VALUE,万一代码出点bug的话,内存就被玩挂了。

说到BlockingQueue,就还是要提下BlockingQueue被用的最多的地方:线程池,Java的ThreadPoolExecutor中有个参数是BlockingQueue,如果这个地方用的是ArrayBlockingQueue或LinkedBlockingQueue,而线程池的coreSize和poolSize不一样的话,在coreSize线程满了后,这个时候线程池首先会做的是offer到BlockingQueue,成功的话就结束,这种场景同样不符合在线业务的需求,在线业务更希望的是快速处理,而不是先排队,而且其实在线业务最好是不要让请求堆在排队队列里,在线业务这样做很容易引发雪崩,超出处理能力范围直接拒绝抛错是相对比较好的做法,至于在前面页面上排队什么这个是可以的,那是另外一种限流机制。

所以说在写高并发、分布式的代码时,除了系统设计外,代码细节的功力是非常非常重要的。

=============================
欢迎关注微信公众号:hellojavacases

关于此微信号:
分享Java问题排查的Case、Java业界的动态和新技术、Java的一些小知识点Test,以及和大家一起讨论一些Java问题或场景,这里只有Java细节的分享,没有大道理、大架构和大框架。

公众号上发布的消息都存放在http://hellojava.info上。

我在系统设计上犯过的14个错

在上篇《架构师画像》的文章中提到了自己在系统设计上犯过的一些错,觉得还挺有意义的,这篇文章就来回顾下自己近八年来所做的一些系统设计,看看犯的一些比较大的血淋淋的错误(很多都是推倒重来),这八年来主要做了三个基础技术产品,三个横跨三年的大的技术项目(其中有两个还在进行中),发现大的错误基本集中在前面几年,从这个点看起来能比较自豪的说在最近的几年在系统设计的掌控上确实比以前成熟了很多。

第1个错
在设计服务框架时,我期望服务框架对使用者完全不侵入,于是做了一个在外部放一个.xml文件来描述spring里的哪些bean发布为服务的设计,这个版本发布后,第一个小白鼠的用户勉强在用,但觉得用的很别扭,不过还是忍着用下去了,到了发布的时候,发现出现了两个问题,一是这个xml文件研发也不知道放哪好,所以到了发布的时候都不知道去哪拿这个xml文件。
这个设计的关键错误就在于在设计时没考虑过这个设计方式对研发阶段、运维阶段的影响,后来纠正这个错误的方法是去掉了这个xml文件,改为写了一个Spring FactoryBean,用户在spring的bean配置文件中配置下就可以。
因此对于一个架构师来说,设计时在全面性上要充分考虑。

第2个错
服务框架在核心应用上线时,出现了前端web应用负载高,处理线程数不够用的现象,当时处理这个故障的方式是回滚了服务框架的上线,这个故障排查了比较长的时间后,查到的原因是服务框架用的JBoss Remoting在通信时默认时间是60s,导致一些处理速度慢的请求占据了前端web应用的处理线程池。
上面这里故障的原因简单来说是分布式调用中超时时间太长的问题,但更深层次来思考,问题是犯在了设计服务框架时的技术选型,在选择JBoss-Remoting时没有充分的掌握它的运行细节,这个设计的错误导致的是后来决定放弃JBoss-Remoting,改为基于Mina重写了服务框架的通信部分,这里造成了服务框架的可用版本发布推迟了两个多月。
因此对于一个架构师来说,在技术选型上对技术细节是要有很强的掌控力的。

第3个错
在服务框架大概演进到第4个版本时,通信协议上需要做一些改造,突然发现一个问题是以前的通信协议上是没有版本号的,于是悲催的只能在代码上做一个很龌蹉的处理来判断是新版本还是老版本。
这个设计的错误非常明显,这个其实只要在最早设计通信协议时参考下现有的很多的通信协议就可以避免了,因此这个错误纠正也非常简单,就是参考一些经典的协议重新设计了下。
因此对于一个架构师来说,知识面的广是非常重要的,或者是在设计时对未来有一定的考虑也是非常重要的。

说到协议,就顺带说下,当时在设计通信协议和选择序列化/反序列化上没充分考虑到将来多语言的问题,导致了后来在多语言场景非常的被动,这也是由于设计时前瞻性的缺失,所谓的前瞻性不是说一定要一开始就把未来可能会出现的问题就解掉,而是应该留下不需要整个改造就可以解掉的方法,这点对于架构师来说也是非常重要的。

第4个错
在服务框架切换为Mina的版本上线后,发布服务的应用重启时出现一个问题,就是发现重启后集群中的机器负载严重不均,排查发现是由于这个版本采用是服务的调用方会通过硬件负载均衡去建立到服务发布方的连接,而且是单个的长连接,由于是通过硬件负载均衡建连,意味着服务调用方其实看到的都是同一个地址,这也就导致了当服务发布方重启时,服务调用方重连就会集中的连到存活的机器上,连接还是长连,因此就导致了负载的不均衡现象。
这个设计的错误主要在于没有考虑生产环境中走硬件负载均衡后,这种单个长连接方式带来的问题,这个错误呢还真不太好纠正,当时临时用的一个方法是服务调用方的连接每发送了1w个请求后,就把连接自动断开重建,最终的解决方法是去掉了负载均衡设备这个中间点。
因此对于一个架构师来说,设计时的全面性要非常的好,我现在一般更多采用的方式是推演上线后的状况,一般来说在脑海里过一遍会比较容易考虑到这些问题。

第5个错
服务框架在做了一年多以后,某个版本中出现了一个严重bug,然后我们就希望能通知到用了这个版本的应用紧急升级,在这个时候悲催的发现一个问题是我们压根就不知道生产环境中哪些应用和机器部署了这个版本,当时只好用一个临时的扫全网机器的方法来解决。
这个问题后来纠正的方法是在服务发布和调用者在连接我们的一个点时,顺带把用的服务框架的版本号带上,于是就可以很简单的知道全网的服务框架目前在运行的版本号了。
因此对于一个架构师来说,设计时的全面性是非常重要的,推演能起到很大的帮助作用。

第6个错
服务框架这种基础类型的产品,在发布时会碰到个很大的问题,就是需要通知到使用者去发布,导致了整个发布周期会相当的长,当时做了一个决定,投入资源去实现完全动态化的发布,就是不需要重启,等到做的时候才发现这完全就是个超级大坑,最终这件事在投入两个人做了接近半年后,才终于决定放弃,而且最终来看其实升级的问题也没那么大。
这个问题最大的错误在于对细节把握不力,而且决策太慢。
因此对于一个架构师来说,技术细节的掌控非常重要,同时决策力也是非常重要的。

第7个错
服务发布方经常会碰到一个问题,就是一个服务里的某些方法是比较耗资源的,另外的一些可能是不太耗资源,但对业务非常重要的方法,有些场景下会出现由于耗资源的方法被请求的多了些导致不太耗资源的方法受影响,这种场景下如果要去拆成多个服务,会导致开发阶段还是挺痛苦的,因此服务框架这边决定提供一个按方法做七层路由的功能,服务的发布方可以在一个地方编写一个规则文件,这个规则文件允许按照方法将生产环境的机器划分为不同组,这样当服务调用方调用时就可以做到不同方法调用到不同的机器。
这个功能对有些场景来说用的很爽,但随着时间的演进和人员的更换,能维护那个文件的人越来越少了,也成为了问题。
这个功能到现在为止我自己其实觉得也是一直处于争议中,我也不知道到底是好还是不好…
因此对于一个架构师来说,设计时的全面性是非常重要的。

第8个错
服务框架在用的越来越广后,碰到了一个比较突出的问题,服务框架依赖的jar版本和应用依赖的jar版本冲突,服务框架作为一个通用技术产品,基本上没办法为了一个应用改变服务框架自己依赖的jar版本,这个问题到底怎么去解,当时思考了比较久。
可能是由于我以前OSGi这块背景的原因,在设计上我做了一个决定,引入OSGi,将服务框架的一堆jar处于一个独立的classloader,和应用本身的分开,这样就可以避免掉jar冲突的问题,在我做了引入OSGi这个决定后,团队的1个资深的同学就去做了,结果是折腾了近两个月整个匹配OSGi的maven开发环境都没完全搭好,后来我自己决定进去搞这件事,即使是我对OSGi比较熟,也折腾了差不多1个多月才把整个开发的环境,工程的结构,以及之前的代码基本迁移为OSGi结构,这件事当时折腾好上线后,效果看起来是不错的,达到了预期。
但这件事后来随着加入服务框架的新的研发人员越来越多,发现多数的新人都在学习OSGi模式的开发这件事上投入了不少的时间,就是比较难适应,所以后来有其他业务问是不是要引入OSGi的时候,我基本都会建议不要引入,主要的原因是OSGi模式对大家熟悉的开发模式、排查问题的冲击,除非是明确需要classloader隔离、动态化这两个点。
让我重新做一个决策的话,我会去掉对OSGi的引入,自己做一个简单的classloader隔离策略来解决jar版本冲突的问题,保持大家都很熟悉的开发模式。
因此对于一个架构师来说,设计时的全面性是非常重要的。

第9个错
服务框架在用的非常广了后,团队经常会被一个问题困扰和折腾,就是业务经常会碰到调用服务出错或超时的现象,这种情况通常会让服务框架这边的研发来帮助排查,这个现象之所以查起来会比较复杂,是因为服务调用通常是多层的关系,并不是简单的A–>B的问题,很多时候都会出现A–>B–>C–>D或者更多层的调用,超时或者出错都有可能是在其中某个环节,因此排查起来非常麻烦。
在这个问题越来越麻烦后,这个时候才想起在09年左右团队里有同学看过G家的一篇叫dapper的论文,并且做了一个类似的东西,只是当时上线后我们一直想不明白这东西拿来做什么,到了排查问题这个暴露的越来越严重后,终于逐渐想起这东西貌似可以对排查问题会产生很大的帮助。
到了这个阶段才开始做这件事后,碰到的主要不是技术问题,而是怎么把新版本升级上去的问题,这个折腾了挺长时间,然后上线后又发现了一个新的问题是,即使服务框架具备了Trace能力,但服务里又会调外部的例如数据库、缓存等,那些地方如果有问题也会看不到,排查起来还是麻烦,于是这件事要真正展现效果就必须让Trace完全贯穿所有系统,为了做成这件事,N个团队付出了好几年的代价。
因此对于一个架构师来说,设计时的全面性、前瞻性非常重要,例如Trace这个的重要性,如果在最初就考虑到,那么在一开始就可以留好口子埋好伏笔,后面再要做完整就不会太复杂。

第10个错
服务的发布方有些时候会碰到一个现象是,服务还没完全ready,就被调用了;还有第二个现象是服务发布方出现问题时,要保留现场排查问题,但服务又一直在被调用,这种情况下就没有办法很好的完全保留现场来慢慢排查问题了。
这两个现象会出现的原因是服务框架的设计是通过启动后和某个中心建立连接,心跳成功后其他调用方就可以调用到,心跳失败后就不会被调到,这样看起来很自动化,但事实上会导致的另外一个问题是外部控制上下线这件事的能力就很弱。
这个设计的错误主要还是在设计时考虑的不够全面。
因此对于一个架构师来说,设计时的全面性非常重要。

第11个错
在某年我和几个小伙伴决定改变当时用xen的模式,换成用一种轻量级的“虚拟机”方式来做,从而提升单机跑的应用数量的密度,在做这件事时,我们决定自己做一个轻量级的类虚拟机的方案,当时决定的做法是在一个机器上直接跑进程,然后碰到一堆的问题,例如从运维体系上来讲,希望ssh到“机器”、独立的ip、看到自己的系统指标等等,为了解决这些问题,用了N多的黑科技,搞得很悲催,更悲催的是当时觉得这个问题不多,于是用一些机器跑了这个模式,结果最后发现这里面要黑科技解决的问题实在太多了,后来突然有个小伙伴提出我们试用lxc吧,才发现我们之前用黑科技解的很多问题都没了,哎,然后就是决定切换到这个模式,结果就是线上的那堆机器重来。
这个设计的主要错误在于知识面不够广,导致做了个不正确的决定,而且推倒重来。
因此对于一个架构师来说,知识面的广非常重要,在技术选型这点上非常明显。

第12个错
还是上面这个技术产品,这个东西有一个需求是磁盘空间的限额,并且要支持磁盘空间一定程度的超卖,当时的做法是用image的方式来占磁盘空间限额,这个方式跑了一段时间觉得没什么问题,于是就更大程度的铺开了,但铺开跑了一段时间后,出现了一个问题,就是经常出现物理机磁盘空间不足的报警,而且删掉了lxc容器里的文件也还是不行,因为image方式只要占用了就会一直占着这个大小,只会扩大不会缩小。
当时对这个问题极度的头疼,只能是删掉文件后,重建image,但这个会有个要求是物理机上有足够的空间,即使有足够的空间,这个操作也是很折腾人的,因为得先停掉容器,cp文件到新创建的容器,这个如果东西多的话,还是要耗掉一定时间的。
后来觉得这个模式实在是没法玩,于是寻找新的解决方法,来满足磁盘空间限额,允许超卖的这两需求,最后我们也是折腾了比较长一段时间后终于找到了更靠谱的解决方案。
这个设计的主要错误还是在选择技术方案时没考虑清楚,对细节掌握不够,考虑的面不够全,导致了后面为了换掉image这个方案,用了极大的代价,我印象中是一堆的人熬了多次通宵来解决。
因此对于一个架构师来说,知识面的广、对技术细节的掌控和设计的全面性都非常重要。

第13个错
仍然是上面的这个技术产品,在运行的过程中,突然碰到了一个虚拟机中线程数创建太多,导致其他的虚拟机也创建不了线程的现象(不是因为物理资源不够的问题),排查发现是由于尽管lxc支持各个容器里跑相同名字的账号,但相同名字的账号的uid是相同的,而max processes是限制在UID上的,所以当一个虚拟机创建的线程数超过时,就同样影响到了其他相同账号的容器。
这个问题我觉得一定程度也可以算是设计问题,设计的时候确实由于对细节掌握的不够,考虑的不全导致忽略了这个点。
因此对于一个架构师来说,对技术细节的掌控和设计的全面性都非常重要。

第14个错
在三年前做一个非常大的项目时,项目即将到上线时间时,突然发现一个问题是,有一个关键的点遗漏掉了,只好赶紧临时讨论方案决定怎么做,这个的改动动作是非常大的,于是项目的上线时间只能推迟,我记得那个时候紧急周末加班等搞这件事,最后带着比较高的风险上了。
这个问题主要原因是在做整体设计时遗漏掉了这个关键点的考虑,当时倒不是完全忽略了这个点,而是在技术细节上判断错误,导致以为不太要做改动。
因此对于一个架构师来说,对技术细节的掌控是非常重要的,这里要注意的是,其实不代表架构师自己要完全什么都很懂,但架构师应该清楚在某个点上靠谱的人是谁。

=============================
欢迎关注微信公众号:hellojavacases

关于此微信号:
分享Java问题排查的Case、Java业界的动态和新技术、Java的一些小知识点Test,以及和大家一起讨论一些Java问题或场景,这里只有Java细节的分享,没有大道理、大架构和大框架。

公众号上发布的消息都存放在http://hellojava.info上。

深入JVM彻底剖析前面ygc越来越慢的case

阿里JVM团队的同学帮助从JVM层面继续深入的剖析了下前面那个ygc越来越慢的case,分析文章相当的赞,思路清晰,工具熟练,JVM代码熟练,请看这位同学(阿里JVM团队:寒泉子)写的文章,我转载到这。

Demo分析
虽然这个demo代码逻辑很简单,但是其实这是一个特殊的demo,并不简单,如果我们将XStream对象换成Object对象,会发现不存在这个问题,既然如此那有必要进去看看这个XStream的构造函数(请大家直接翻XStream的代码,这里就不贴了)。

这个构造函数还是很复杂的,里面会创建很多的对象,上面还有一些方法实现我就不贴了,总之都是在不断构建各种大大小小的对象,一个XStream对象构建出来的时候大概好像有12M的样子。

那到底是哪些对象会导致ygc不断增长呢,于是可能想到逐步替换上面这些逻辑,比如将最后一个构造函数里的那些逻辑都禁掉,然后我们再跑测试看看还会不会让ygc不断恶化,最终我们会发现,如果我们直接使用如下构造函数构造对象时,如果传入的classloader是AppClassLoader,那会发现这个问题不再出现了,代码如下:

[java]
public static void main(String[] args) throws Exception {
int i=0;
while (true) {
XStream xs = new XStream(null,null, new ClassLoaderReference(XStreamTest.class.getClassLoader()),null, new DefaultConverterLookup());
xs.toString();
xs=null;
}
}
[/java]

是不是觉得很神奇,由此可见,这个classloader至关重要。

不得不说的类加载器
这里着重要说的两个概念是初始类加载器和定义类加载器。举个栗子说吧,AClassLoader->BClassLoader->CClassLoader,表示AClassLoader在加载类的时候会委托BClassLoader类加载器来加载,BClassLoader加载类的时候会委托CClassLoader来加载,假如我们使用AClassLoader来加载X这个类,而X这个类最终是被CClassLoader来加载的,那么我们称CClassLoader为X类的定义类加载器,而AClassLoader和BClassLoader分别为X类的初始类加载器,JVM在加载某个类的时候对这三种类加载器都会记录,记录的数据结构是一个叫做SystemDictionary的hashtable,其key是根据ClassLoader对象和类名算出来的hash值,而value是真正的由定义类加载器加载的Klass对象,因为初始类加载器和定义类加载器是不同的classloader,因此算出来的hash值也是不同的,因此在SystemDictionary里会有多项值的value都是指向同一个Klass对象。

那么JVM为什么要分这两种类加载器呢,其实主要是为了快速找到已经加载的类,比如我们已经通过AClassLoader来触发了对X类的加载,当我们再次使用AClassLoader这个类加载器来加载X这个类的时候就不需要再委托给BClassLoader去找了,因为加载过的类在JVM里有这个类加载器的直接加载的记录,只需要直接返回对应的Klass对象即可。

Demo中的类加载器是否会加载类
我们的demo里发现构建了一个CompositeClassLoader的类加载器,那到底有没有用这个类加载器加载类呢,我们可以设置一个断点在CompositeClassLoader的loadClass方法上,可以看到会进入断点。
可见确实有类加载的动作,根据类加载委托机制,在这个Demo中我们能肯定类是交给AppClassLoader来加载的,这样一来CompositeClassLoader就变成了初始类加载器,而AppClassLoader会是定义类加载器,都会在SystemDictionary里存在,因此当我们不断new XStream的时候会不断new CompositeClassLoader对象,加载类的时候会不断往SystemDictionary里插入记录,从而使SystemDictionary越来越膨胀,那自然而然会想到如果GC过程不断去扫描这个SystemDictionary的话,那随着SystemDictionary不断膨胀,那么GC的效率也就越低,抱着验证下猜想的方式我们可以使用perf工具来看看,如果发现cpu占比排前的函数如果都是操作SystemDictionary的,那就基本验证了我们的说法,下面是perf工具的截图,基本证实了这一点。
perf

SystemDictionary为什么会影响GC过程
想象一下这么个情况,我们加载了一个类,然后构建了一个对象(这个对象在eden里构建)当一个属性设置到这个类里,如果gc发生的时候,这个对象是不是要被找出来标活才行,那么自然而然我们加载的类肯定是我们一项重要的gc root,这样SystemDictionary就成为了gc过程中的被扫描对象了,事实也是如此,可以看vm的具体代码(代码在:SharedHeap::process_strong_roots,感兴趣的同学可以直接翻这部分代码)。

看上面的SH_PS_SystemDictionary_oops_do task就知道了,这个就是对SystemDictionary进行扫描。

但是这里要说的是虽然有对SystemDictionary进行扫描,但是ygc的过程并不会对SystemDictionary进行处理,如果要对它进行处理需要开启类卸载的vm参数,CMS算法下,CMS GC和Full GC在开启CMSClassUnloadingEnabled的情况下是可能对类做卸载动作的,此时会对SystemDictionary进行清理,所以当我们在跑上面demo的时候,通过jmap -dump:live,format=b,file=heap.bin 命令执行完之后,ygc的时间瞬间降下来了,不过又会慢慢回去,这是因为jmap的这个命令会做一次gc,这个gc过程会对SystemDictionary进行清理。

修改VM代码验证
很遗憾hotspot目前没有对ygc的每个task做一个时间的统计,因此无法直接知道是不是SH_PS_SystemDictionary_oops_do这个task导致了ygc的时间变长,为了证明这个结论,我特地修改了一下代码,在上面的代码上加了一行:
GCTraceTime t(“SystemDictionary_OOPS_DO”,PrintGCDetails,true,NULL);
然后重新编译,跑我们的demo,测试结果如下:
2016-03-14T23:57:24.293+0800: [GC2016-03-14T23:57:24.294+0800: [ParNew2016-03-14T23:57:24.296+0800: [SystemDictionary_OOPS_DO, 0.0578430 secs]
: 81920K->3184K(92160K), 0.0889740 secs] 81920K->3184K(514048K), 0.0900970 secs] [Times: user=0.27 sys=0.00, real=0.09 secs]
2016-03-14T23:57:28.467+0800: [GC2016-03-14T23:57:28.468+0800: [ParNew2016-03-14T23:57:28.468+0800: [SystemDictionary_OOPS_DO, 0.0779210 secs]
: 85104K->5175K(92160K), 0.1071520 secs] 85104K->5175K(514048K), 0.1080490 secs] [Times: user=0.65 sys=0.00, real=0.11 secs]
2016-03-14T23:57:32.984+0800: [GC2016-03-14T23:57:32.984+0800: [ParNew2016-03-14T23:57:32.984+0800: [SystemDictionary_OOPS_DO, 0.1075680 secs]
: 87095K->8188K(92160K), 0.1434270 secs] 87095K->8188K(514048K), 0.1439870 secs] [Times: user=0.90 sys=0.01, real=0.14 secs]
2016-03-14T23:57:37.900+0800: [GC2016-03-14T23:57:37.900+0800: [ParNew2016-03-14T23:57:37.901+0800: [SystemDictionary_OOPS_DO, 0.1745390 secs]
: 90108K->7093K(92160K), 0.2876260 secs] 90108K->9992K(514048K), 0.2884150 secs] [Times: user=1.44 sys=0.02, real=0.29 secs]

我们会发现YGC的时间变长的时候,SystemDictionary_OOPS_DO的时间也会相应变长多少,因此验证了我们的说法。

有同学提到如果Demo代码改成不new XStream,而是直接new CompositeClassLoader或CustomClassLoader则不会出问题,按照上面的分析也很容易解释,就是因为如果直接new CustomClassLoader的话,并没触发loadClass这动作,而new XStream的话在构造器里就在loadClass。

有同学提到在JDK 8中跑这个case不会出现问题,原因是:jdk8在对SystemDictionary扫描时做了优化,增加了一层cache,大大减少了需要扫描的入口数。

=============================
欢迎关注微信公众号:hellojavacases

关于此微信号:
分享Java问题排查的Case、Java业界的动态和新技术、Java的一些小知识点Test,以及和大家一起讨论一些Java问题或场景,这里只有Java细节的分享,没有大道理、大架构和大框架。

公众号上发布的消息都存放在http://hellojava.info上。

上篇文章中ygc越来越慢的case的原因解读

上篇文章中附加了一个最近碰到的奇怪的case,有位同学看到这个后周末时间折腾了下,把原因给分析出来了,分析过程很赞,非常感谢这位同学(阿里的一位同事,花名叫彦贝),在征求他的同意后,我把他写的整个问题的分析文章转载到这里。

    上分析工具VisualVM

在解决很多问题的时候,工具起的作用往往是巨大,很多时候通过工具分析,很快便能找到原因,但是这次并没有,下图是VisualVM观察到Heap上的GC图表。
visualvm

从图标中可以看出,Perm区空间基本水平,但是Old区空间成增长态势与YGCT时间增长的倍率基本一直。熟悉YGC的朋友都知道YGC主要分为标记和收回两个阶段,YGCT也是基于这2个阶段统计的。由于每次回收的空间大小差不多,所以怀疑是标记阶段使用的时间比较长,下面回顾一下JVM的垃圾标记方式。

    JVM垃圾回收的标记方法-枚举根节点

在Java语言里面,可作为GC Roots的节点主要在全局性的引用(例如常量或类静态属性)与执行上下文(例如栈帧中的本地变量表)中。如果要使用可达性分析来判断内存是否可回收的,那分析工作必须在一个能保障一致性的快照中进行——这里“一致性”的意思是整个分析期间整个执行系统看起来就像被冻结在某个时间点上,不可以出现分析过程中,对象引用关系还在不断变化的情况,这点不满足的话分析结果准确性就无法保证。这点也是导致GC进行时必须“Stop The World”的其中一个重要原因,即使是号称(几乎)不会发生停顿的CMS收集器中,枚举根节点时也是必须要停顿的。

由于目前的主流JVM使用的都是准确式GC,所以当执行系统停顿下来之后,并不需要一个不漏地检查完所有执行上下文和全局的引用位置,虚拟机应当是有办法直接得到哪些地方存放着对象引用。在HotSpot的实现中,是使用一组成为OopMap的数据结构来达到这个目的,在类加载完成的时候,HotSpot就把对象内什么偏移量上是什么类型的数据计算出来,在JIT编译过程中,也会在特定的位置记录下栈里和寄存器里哪些位置是引用。这样GC在扫描时就就可以直接得知这些信息了。
上面这段引用自《深入理解Java虚拟机》,用个图简单表示一下,当然图也是源于书中:
gcrootset

基于对GC Roots的怀疑,猜测Old区中存在越来越多的gc root节点,那什么样的对象是root节点呢?不懂的我赶紧google了一下。
gcroots

(不要看我红色圈出来了,第一次看到这几个嫌疑犯时我也拿不准是哪个)

为了进一步验证是Old区的GC Roots造成YGCT 增加的,我们来做一次full gc,干掉Old区。代码如下:

public class SlowYGC {
public static void main(String[] args) throws Exception {
int i= 0;
while (true) {
XStream xs = new XStream();
xs.toString();
xs = null;
if(i++ % 10000 == 0)
{
System.gc();
}
}
}
}

可以看出Full GC后 YGCT锐减到初始状态。那是Full GC到底回收了哪些对象?进入到下一步,增加VM参数。

增加VM参数
为了看到Full GC前后对象的回收情况,我们增加下面2个VM参数
-XX:+PrintClassHistogramBeforeFullGC -XX:+PrintClassHistogramAfterFullGC。
重新运行上面的代码,可以观察到下面的日志:
vmlog

上图左边是FGC前的对象情况,右边是FGC后的情况,结合我之前给出的GC Root候选人中的Monitor锁,相信你很快就找到20026个[Ljava.util.concurrent.ConcurrentHashMap$Segment对象几乎被全部回收了,ConcurrentHashMap中正是通过Segment来实现分段锁的。那什么逻辑会导致出现大量的Segment锁对象。继续看Full GC日志com.thoughtworks.xstream.core.util.CompositeClassLoader这个对象也差不多在FGC的时候全军覆没,所以怀疑是ClassLoader引起的锁竞争,下面在ClassLoader的源码中继续查找。

ClassLoader中的ConcurrentHashMap
classloader

这里有个拿锁的动作,跟进去看看呗。
parallelLockMap

为了验证走到这块逻辑下了一个断点。剩下的就是putIfAbsent方法(这里就不详细分析实现了)有兴趣的同学可以看看源码中CAS和tryLock的使用。
至此基本分析和定位出了YGCT原因,在去看看Xstream的构造函数。
xstream
可以看出每次new XstreamI()的同时会new一个新的ClassLoader,基本上证明了上述怀疑和猜测。

推导一下按照上述分析,应该是所有自定义的ClassLoader都会YGCT变长的时间问题,于是手写一个ClassLoader验证一下。Java代码如下:

public class TestClassLoader4YGCT {
public static void main(String[] args) throws Exception{
while(true)
{
Object obj = newObject();
obj.toString();
obj = null;
}
}

private static Object newObject() throws Exception
{
ClassLoader classLoader = new ClassLoader() {
@Override
public Class loadClass(String s) throws ClassNotFoundException {
try{
String fileName = s.substring(s.lastIndexOf(“.”) + 1)+ “.class”;
InputStream inputStream = getClass().getResourceAsStream(fileName);
if(inputStream == null){
return super.loadClass(s);
}
byte[] b = new byte[inputStream.available()];
inputStream.read(b);
return defineClass(s,b,0,b.length);
}catch (Exception e)
{
System.out.println(e);
return null;
}
}
};
Class obj = classLoader.loadClass(“jvmstudy.classload.TestClassLoader4YGCT”);
return obj.newInstance();
}
}
VM 参数-XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xms512m -Xmx512m -Xmn100m -XX:+UseConcMarkSweepGC

GC日志
lastgclog

结论
当大量new自定义的ClassLoader来加载时,会产生大量ClassLoader对象以及parallelLockMap(ConcurrentHashMap)对象,导致产生大量的Segment分段锁对象,大大增加了GC Roots的数量,导致YGC中的标记时间变长。如果能直接看到YGCT的详细使用情况,这个结论会显得更加严谨。这只是我自己的一个推导,并不一定是正确答案。

=============================
欢迎关注微信公众号:hellojavacases

关于此微信号:
分享Java问题排查的Case、Java业界的动态和新技术、Java的一些小知识点Test,以及和大家一起讨论一些Java问题或场景,这里只有Java细节的分享,没有大道理、大架构和大框架。

公众号上发布的消息都存放在http://hellojava.info上。

一个jstack/jmap等不能用的case

今天一个同学问我:”我排查问题时总是遇到,jmap -heap或-histo 不能用,是不是我们机器配置有啥问题哇? ” 分享下这个case的解决过程。

登上同学说的那台不能用的机器,执行jstack,报错:“get_thread_regs failed for a lwp”,这个问题以前碰到过,但忘了当时是什么原因了,执行其他的jmap -histo什么也卡着不动。

既然jstack没法弄,就pstack看看进程到底什么状况吧,于是pstack [pid]看,发现有一个线程的堆栈信息有点奇怪:
#0 0x00000038e720cd91 in sem_wait ()

对系统函数不太懂,但总觉得sem_wait这个有点奇怪,于是Google之,基本明白了这个是由于进程在等信号,这个时候通常会block住其他所有的线程,于是立刻ps看了下进程的状态,果然进程的状态变成了T,那上面碰到的所有现象都很容易解释了,于是执行:kill -CONT [pid],一切恢复正常。

继续查为什么进程状态会变成T,问问题的同学告诉了下我他在机器上执行过的一些命令,我看到其中一个很熟悉的命令:jmap -heap,看过我之前文章的同学估计会记得我很早以前分享过,在用cms gc的情况下,执行jmap -heap有些时候会导致进程变T,因此强烈建议别执行这个命令,如果想获取内存目前每个区域的使用状况,可通过jstat -gc或jstat -gccapacity来拿到。

到此为止,问题终于搞定,以后碰到jstack/jmap等不能用的时候,可以先看看进程的状态,因此还是那句话,执行任何一句命令都要清楚它带来的影响。

手头还有另外一个case,折腾了一个多星期了还是没太有头绪,case的现象是ygc越来越慢,但可以肯定不是由于cms gc碎片问题造成的,感兴趣的同学可以拿这个case去玩玩,如果能告诉我原因就更好了,:),执行下面的代码,启动参数可以为-XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xms512m -Xmx512m -Xmn100m -XX:+UseConcMarkSweepGC:
[code]
import com.thoughtworks.xstream.XStream;

public class XStreamTest {

public static void main(String[] args) throws Exception {
while(true){
XStream xs = new XStream();
xs.toString();
xs = null;
}
}

}
[/code]

应该就可以看到ygc的速度从10+ms一直增长到100+ms之类的…

=============================
欢迎关注微信公众号:hellojavacases

关于此微信号:
分享Java问题排查的Case、Java业界的动态和新技术、Java的一些小知识点Test,以及和大家一起讨论一些Java问题或场景,这里只有Java细节的分享,没有大道理、大架构和大框架。

公众号上发布的消息都存放在http://hellojava.info上。