两周前,参与某一老产品的性能优化有如下收获:
这个产品已有一定的年头,采用Java开发,但Maven配置的编译source/target还是 1.6(直接把配置修改为1.8整个产品编译会有问题。对于老产品,稳定优先,维护者并没有太多的动力升级到1.8,因为一升级需要对所有历史分支都升级并验证)。 为了线程安全,代码中大量地存在如下Double-Check写法(伪代码),无法享受Java高版本带来的红利,并不高效:
Description desc = cache.get(key); if (desc == null) { synchronized(cache) { // 这个是全局锁,极大影响并发 if (desc == null) { desc = getDescription(....); // 此方法还会调用其它类似写法的Cache,主要逻辑是查询以及反射类,以及嵌套类,效率并不高 cache = new CopyOnWriteMap(cache) // 对象Copy cache.add(key, desc); } } } return desc;
cache是自己实现的CopyOnWrite的Map,并没有使用ConcurrentHashMap,目的是提升读远远多于写的场景的性能。但这个性能问题就是写的时候。写的业务场景:
所做优化:
这次不再讲锁的优化(请参考 飞哥讲代码3:简洁高效的线程安全 ),而是来谈谈线程模型,并不是线程配置得越多,就能提升性能。
笔者在我司最早曾写过C/C++代码,接触的早期智能网平台产品是单线程多进程模式,即一个进程中只有一个线程在处理业务逻辑。所有的事件采用Select函数监视所有文件描述符的变化情况来处理各种请求。虽然只有一个线程在处理,性能并不差,因为线程并没有阻塞,它是满血在永不停息的干活。并发是采用多进程的方式,单个进程并绑定CPU,也避免了CPU在多进程之间的切换,效率更好。
开源项目大名鼎鼎Redis也是采用单线程模式,也类似于我们的智能网平台的机制,基于Reactor模式实现了多路 I/O 复用,由于Redis主要是对内存读写操作,单线程避免了上下文频繁切换问题,效率是出奇的高。
Java天生支持多线程,JDK也提供了多种机制来保证线程安全。JVM线程在Java 1.2之前,是基于称为“绿色线程”(Green Threads)的用户线程实现的,而在Java 1.2中,线程模型替换为基于操作系统原生线程模型来实现。也就是说,现在的JVM中线程的本质,其实就是操作系统中的线程,Linux下是基于pthread库实现的多线程。
为了弄清楚Java的线程模型,先说几个概念:
JVM中的线程在Linux下是基于pthread库实现,而pthread也存在演进:
总结起来如下:
对于线程调度,通常有两种:
Java采用的是抢占式调度的多线程系统,理解这个,就会明白为什么 Thread.yield()
可以让出执行时间,但是要获取执行时间的话,线程本身是没有什么办法。
回到案例本身,为什么配置32个线程就可能解决问题,根据上面的线程模型,如果线程处理无阻塞,满血干活:
Go语言天生为高并发而生,可以轻松构造上万的协程(goroutine)。它的并发模型采用MPG模型:
NPTL每个线程都对应内核中的一个调度实体,这种模型称为1:1模型(1个线程对应1个内核级线程)。而NGPL(Next Generation POSIX Threads)则打算实现M:N模型(M个线程对应N个内核级线程),也就是说若干个线程可能是在同一个执行实体上实现的。但在Linux上实现这个,要处理的细节问题非常之多,目前没有任何一个Linux实现了M:N模型。Solaris系统貌似实现M:N模型(待求证)。
Go语言而不依赖于Linux系统,而是在它的Runtime上实现MPG模型,本质即在用户态实现了NGPL。Goroutine只是一个内核线程执行的一个Task,只不过Go的Runtine能帮助你恢复上下文环境,维护了栈、程序计数器等信息,在Goroute中感觉就是像一个线程调用。轻松构造上万的Goroute,因为这不是真实的内核线程,而是线程执行的一个任务(Task)。
如果线程不阻塞,则1:1的模型没有任何的问题,但实际上线程会阻塞在各种I/O操作中。如访问数据库,需要等待响应回来。为了增加并发,如果增加线程,每个线程对应一个内核线程,而内核线程是重资源型,过多的线程会导致内核调度上效率低下。
所以为了高效,采用线程复用。复用则需要当线程由于I/O阻塞时,可以释放出来,让它去干其它的活,当请求响应真正回来时,则通过回调通知。在1:1的模型的多线程多任务框架,通常异步采用回调方式。
函数回调有其缺陷,当遇到多重函数回调的嵌套,代码难以维护。对于多个回调组成的嵌套耦合,业界通常叫回调地狱(Callback Hell)。解决回调地狱的方案有不少,现在常见是链式调用,Java中实现链式调用有RxJava。RxJava中通过“流”来构建链式调用结构,“流”的创建、转化与消费都需要使用到它提供的各种类和丰富的操作符,也让使用成本大大增加。
在JVM之上的Kotlin语言,也实现了协程(Coroutine)框架,通过语法糖如async/await解决了普通多任务框架的回调地狱问题。async本质返回一个Deferred对象,在异步执行结束之后,调用await()方法通知等待者。等待者调用await()则先释放线程,再得到异步回调。
而Tomcat主要是Servlet容器,Servlet在3.0之前,API并不支持异步。同步导致的问题当有阻塞时,则线程是空闲的,为了并发,则需要更多的线程来处理,配置更多的线程也带了更多的成本,比如内存的增加,CPU对多线程上下问切换的性能损耗。
线程个数设置多少合适?不是越多越好,多了竞争资源反而效率低。建议配置的线程数=可用的CPU核数/(1-阻塞系数)。阻塞系统在0到1之间,所谓阻塞系数就是发生的IO操作,如读文件,读socket流,读写数据库等占程序时间的比率。这个数值每个系统肯定不一样,可以先做个估计,然后测试逐步往最佳值靠拢。如果线程不是瓶颈所在,那么大概估一个值就好了。
再回到案例本身,通过优化锁的使用,减少了阻塞系数,当阻塞系数接近为0时,则配置的线程数=可用的CPU核数,而案例中的测试机器正好是32个CPU核。而案例中的IO操作主要是加载Class文件,Class文件是存在Jar文件中,所以以Jar为粒度并发读取是合适的,再多的并发也会由于文件锁导致更多的阻塞。
提升性能,我们先有必要深入了解所使用语言的线程模型及其调度方式。提高并发,并不是一味的提高线程数,而是减少阻塞时间。为了提升线程的调度效率,通常是配置与CPU对等合理的线程线,通过异步框架合理地复用线程,让线程尽可能多的干活,而不是空闲在哪。