8 Jetty的线程策略EatWhatYouKill
Jetty总体架构设计:
Connector:
ManagedSelector在线程策略方面的做法:将I/O事件的侦测和处理放到同一个线程来处理,充分利用了CPU缓存并减少了线程上下文切换的开销。
这种名为“EatWhatYouKill”的线程策略将吞吐量提高了8倍
8.1 Selector常规思路
常规的NIO编程思路是,将I/O事件的侦测和请求的处理分别用不同的线程处理。具体过程是:
8.2 Jetty的Selector编程
将I/O事件检测和业务处理这两种工作分开的思路也有缺点:
当Selector检测读就绪事件时,数据已经被拷贝到内核中的缓存了,同时CPU的缓存中也有这些数据了;
CPU本身的缓存比内存快多了,这时当应用程序去读取这些数据时,如果用另一个线程去读,很有可能这个读线程使用另一个CPU核,而不是之前那个检测数据就绪的CPU核,这样CPU缓存中的数据就用不上了,并且线程切换也需要开销。
因此Jetty的Connector把I/O事件的生产和消费放到同一个线程来处理;
如果这两个任务由同一个线程来执行,且执行过程中线程不阻塞,那么操作系统会用同一个CPU核来执行这两个任
务,这样就能利用CPU缓存了。
8.3 具体实现
四大线程策略:
1 ProduceConsume:任务生产者自己依次生产和执行任务,对应到NIO通信模型就是用一个线程来侦测和处理一个ManagedSelector上所有的I/O事件,后面的I/O事件要等待前面的I/O事件处理完,效率明显不高。通过图来理解,图中绿色表示生产一个任务,蓝色表示执行这个任务。
2 ProduceExecuteConsume:任务生产者开启新线程来运行任务,这是典型的I/O事件侦测和处理用不同的线程来处理,缺点是不能利用CPU缓存,并且线程切换成本高。同样我们通过一张图来理解,图中的棕色表示线程切换。
3 ExecuteProduceConsume:任务生产者自己运行任务,但是该策略可能会新建一个新线程以继续生产和执行任务。这种策略也被称为“吃掉你杀的猎物”,它来自狩猎伦理,认为一个人不应该杀死他不吃掉的东西,对应线程来说,不应该生成自己不打算运行的任务。它的优点是能利用CPU缓存,但是潜在的问题是如果处理I/O事件的业务代码执行时间过长,会导致线程大量阻塞和线程饥饿。
4 EatWhatYouKill:这是Jetty对ExecuteProduceConsume策略的改良,在线程池线程充足的情况下等同于ExecuteProduceConsume;当系统比较忙线程不够时,切换成ProduceExecuteConsume策略。因为ExecuteProduceConsume是在同一线程执行I/O事件的生产和消费,它使用的线程来自Jetty全局的线程池,这些线程有可能被业务代码阻塞,如果阻塞得多了,全局线程池中的线程自然就不够用了,最坏的情况是连I/O事件的侦测都没有线程可用了,会导Connector拒绝浏览器请求。
于是Jetty做了一个优化,在低线程情况下,就执行ProduceExecuteConsume策略,I/O侦测用专门的线程处理,I/O事件的处理扔给线程池剩余的空闲线程处理(其实就是放到线程池的队列里慢慢处理),这样起码用户能连接,处理速度就另说了;
充分利用了CPU缓存,并减少了线程切换的开销
9 对象池技术
如果Java对象数量很多并且存在的时间比较短,对象本身又比较大比较复杂,对象初始化的成本比较高,这样的场景就适合用对象池技术。
比如Tomcat和Jetty处理HTTP请求的场景就符合这个特征:请求的数量很多,为了处理单个请求需要创建不少的复杂对象(比如Tomcat连接器中SocketWrapper和SocketProcessor),而且一般来说请求处理的时间比较短,一旦请求处理完毕,这些对象就需要被销毁,因此这个场景适合对象池技术。
9.1 Tomcat的SynchronizedStack
Tomcat用SynchronizedStack类来实现对象池:内部维护一个对象数组,用数组实现栈的功能
public class SynchronizedStack<T> {
//内部维护⼀个对象数组,⽤数组实现栈的功能
private Object[] stack;
//这个⽅法⽤来归还对象,⽤synchronized进⾏线程同步
public synchronized boolean push(T obj) {
index++;
if (index == size) {
if (limit == -1 || size < limit) {
expand();//对象不够⽤了,扩展对象数组
} else {
index--;
return false;
}
}
stack[index] = obj;
return true;
}
//这个⽅法⽤来获取对象
public synchronized T pop() {
if (index == -1) {
return null;
}
T result = (T) stack[index];
stack[index--] = null;
return result;
}
//扩展对象数组⻓度,以2倍⼤⼩扩展
private void expand() {
int newSize = size * 2;
if (limit != -1 && newSize > limit) {
newSize = limit;
}
//扩展策略是创建⼀个数组⻓度为原来两倍的新数组
Object[] newStack = new Object[newSize];
//将⽼数组对象引⽤复制到新数组
System.arraycopy(stack, 0, newStack, 0, size);
//将stack指向新数组,⽼数组可以被GC掉了
stack = newStack;
size = newSize;
}
}
9.2 Jetty的ByteBufferPool
看Jetty中的对象池ByteBufferPool,它本质是一个ByteBuffer对象池。
当Jetty在进行网络数据读写时,不需要每次都在JVM堆上分配一块新的Buffer,只需在ByteBuffer对象池里拿到一块预先分配好的Buffer,这样就避免了频繁的分配内存和释放内存。
这种设计可以在高性能通信中间件Netty中看到
Buffer的分配和释放过程,就是找到相应的桶,并对桶中的Deque做出队和入队的操作,而不是直接向JVM堆申请和释放内存。
9.3 对象池的思考
对象池作为全局资源,高并发环境中多个线程可能同时需要获取对象池中的对象,因此多个线程在争抢对象时会因为锁竞争而阻塞, 因此使用对象池有线程同步的开销,而不使用对象池则有创建和销毁对象的开销。
对于对象池本身的设计来说,需要尽量做到无锁化,比如Jetty就使用了ConcurrentLinkedDeque。如果你的内存足够大,可以考虑用线程本地(ThreadLocal)对象池,这样每个线程都有自己的对象池,线程之间互不干扰。
为了防止对象池的无限膨胀,必须要对池的大小做限制。
对象池太小发挥不了作用,对象池太大的话可能有空闲对象,这些空闲对象会一直占用内存,造成内存浪费。
需要根据实际情况做一个平衡,因此对象池本身除了应该有自动扩容的功能,还需要考虑自动缩容。
所有的池化技术,包括缓存,都会面临内存泄露的问题,原因是对象池或者缓存的本质是一个Java集合类,比如List和Stack,这个集合类持有缓存对象的引用,只要集合类不被GC,缓存对象也不会被GC。
维持大量的对象也比较占用内存空间,所以必要时我们需要主动清理这些对象。
以Java的线程池ThreadPoolExecutor为例,它提供了allowCoreThreadTimeOut和setKeepAliveTime两种方法,可以在超时后销毁线程,在实际项目中也可以参考这个策略。
另外在使用对象池时有如下建议:
10 Tomcat和Jetty的高性能、高并发之道
高性能程序就是高效的利用CPU、内存、网络和磁盘等资源,在短时间内处理大量的请求。
如何衡量“短时间和大量”呢?其实就是两个关键指标:
响应时间和每秒事务处理量(TPS)
什么是资源的高效利用呢? 有两个原则:
减少资源浪费。比如尽量避免线程阻塞,因为一阻塞就会发生线程上下文切换,就需要耗费CPU资源;
再比如网络通信时数据从内核空间拷贝到Java堆内存,需要通过本地内存中转。
当某种资源成为瓶颈时,用另一种资源来换取。
比如缓存和对象池技术就是用内存换CPU;
数据压缩后再传输就是用CPU换网络。
Tomcat和Jetty中用到了大量的高性能、高并发的设计:I/O和线程模型、减少系统调用、池化、零拷贝、高效的并发编程。
10.1 I/O和线程模型
I/O模型的本质就是为了缓解CPU和外设之间的速度差。
当线程发起I/O请求时,比如读写网络数据,网卡数据还没准备好,这个线程就会被阻塞,让出CPU,也就是说发生了线程切换。
而线程切换是无用功,并且线程被阻塞后,它持有内存资源并没有释放,阻塞的线程越多,消耗的内存就越大;
因此I/O模型的目标就是尽量减少线程阻塞。
Tomcat和Jetty都已经抛弃了传统的同步阻塞I/O,采用了非阻塞I/O或者异步I/O,目的是业务线程不需要阻塞在I/O等待上。
除了I/O模型,线程模型也是影响性能和并发的关键点。Tomcat和Jetty的总体处理原则是:
将这些事情分开的好处是解耦,并且可以根据实际情况合理设置各部分的线程数。
注意,线程数并不是越多越好,因为CPU核的个数有限,线程太多也处理不过来,会导致大量的线程上下文切换。
10.2 减少系统调用
其实系统调用是非常耗资源的一个过程,涉及CPU从用户态切换到内核态的过程,因此我们在编写程序的时候要有意识尽量避免系统调用。
比如在Tomcat和Jetty中,系统调用最多的就是网络通信操作了,一个Channel上的write就是系统调用;
为了降低系统调用的次数,最直接的方法就是使用缓冲,当输出数据达到一定的大小才flush缓冲区。Tomcat和Jetty的Channel都带有输入输出缓冲区。
还有就是Tomcat和Jetty在解析HTTP协议数据时, 都采取了延迟解析的策略:HTTP的请求体(HTTP Body)直到用的时候才解析。
也就是说,当Tomcat调用Servlet的service方法时,只是读取了和解析了HTTP请求头,并没有读取HTTP请求体。
直到Web应用程序调用了ServletRequest对象的getInputStream方法或者getParameter方法时,Tomcat才会去读取和解析HTTP请求体中的数据;
这意味着如果应用程序没有调用上面那两个方法,HTTP请求体的数据就不会被读取和解析,这样就省掉了一次I/O系统调用。
10.3 池化、零拷贝
池化的本质就是用内存换CPU
零拷贝就是不做无用功,减少资源浪费
10.4 高效的并发编程
锁的开销是比较大的,拿锁的过程本身就是个系统调用,如果锁没拿到线程会阻塞,又会发生线程上下文切换,尤其是大量线程同时竞争一把锁时,会浪费大量的系统资源
可以使用原子类CAS或者并发集合来代替。如果万不得已需要用到锁,也要尽量缩小锁的范围和锁的强度。
10.4.1 缩小锁的范围
不直接在方法上加synchronized,而是使用细粒度的对象锁
如果直接在方法上加synchronized,多个线程执行到这个方法时需要排队;
而在对象级别上加synchronized,多个线程可以并行执行这个方法,只是在访问某个成员变量时才需要排队。
如Tomcat的StandardService组件的启动方法,这个启动方法要启动三种子组件:engine、executors和connectors。它没有直接在方法上加锁,而是用了三把细粒度的锁,来分别用来锁三个成员变
量:
protected void startInternal() throws LifecycleException {
setState(LifecycleState.STARTING);
// 锁engine成员变量
if (engine != null) {
synchronized (engine) {
engine.start();
}
}
//锁executors成员变量
synchronized (executors) {
for (Executor executor : executors) {
executor.start();
}
}
mapperListener.start();
//锁connectors成员变量
synchronized (connectorsLock) {
for (Connector connector : connectors) {
// If it has already failed, don't try and start it
if (connector.getState() != LifecycleState.FAILED) {
connector.start();
}
}
}
}
10.4.2 用原子变量和CAS取代锁
如Jetty线程池的启动方法,主要功能就是根据传入的参数启动相应个数的线程:
private boolean startThreads(int threadsToStart) {
while (threadsToStart > 0 && isRunning()) {
//获取当前已经启动的线程数,如果已经够了就不需要启动了
int threads = _threadsStarted.get();
if (threads >= _maxThreads)
return false;
//⽤CAS⽅法将线程数加⼀,请注意执⾏失败⾛continue,继续尝试
if (!_threadsStarted.compareAndSet(threads, threads + 1))
continue;
boolean started = false;
try {
Thread thread = newThread(_runnable);
thread.setDaemon(isDaemon());
thread.setPriority(getThreadsPriority());
thread.setName(_name + "-" + thread.getId());
_threads.add(thread);//_threads并发集合
_lastShrink.set(System.nanoTime());//_lastShrink是原⼦变量
thread.start();
started = true;
--threadsToStart;
} finally {
//如果最终线程启动失败,还需要把线程数减⼀
if (!started)
_threadsStarted.decrementAndGet();
}
}
return true;
}
整个函数的实现是一个while循环,并且是无锁的。
_threadsStarted表示当前线程池已经启动了多少个线程,它是一个原子变量AtomicInteger,首先通过它的get方法拿到值,如果线程数已经达到最大值,直接返回,否则尝试用CAS操作将_threadsStarted的值加一,如果成功了意味着没有其他线程在改这个值,当前线程可以继续往下执行,否则走continue分支,也就是继续重试,直到成功为止。
10.5 并发容器的使用
如CopyOnWriteArrayList适用于读多写少的场景
比如Tomcat用它来“存放”事件监听器,这是因为监听器一般在初始化过程中确定后就基本不会改变,当事件触发时需要遍历这个监听器列表,所以这个场景符合读多写少的特征:
public abstract class LifecycleBase implements Lifecycle {
//事件监听器集合
private final List<LifecycleListener> lifecycleListeners = new CopyOnWriteArrayList<>();
...
}
10.6 volatile关键字的使用
拿Tomcat中的LifecycleBase作为例子,它里面的生命状态就是用volatile关键字修饰的:
volatile的目的是为了保证一个线程修改了变量,另一个线程能够读到这种变化。
对于生命状态来说,需要在各个线程中保持是最新的值,因此采用了volatile修饰:
public abstract class LifecycleBase implements Lifecycle {
//当前组件的⽣命状态,⽤volatile修饰
private volatile LifecycleState state = LifecycleState.NEW;
}