Volley是2013 Google I/O大会上发布的异步网络请求框架和图片加载框架,适合数据量小、通信频繁的网络操作。最近正好在做框架优化,借机会再温习一遍这个老框架。
Volley官方文档:https://developer.android.com/training/volley
Volley项目:https://github.com/google/volley
一、Volley整体框架解析
1.1 架构图
从Volley整体架构来看,主要分三层:
- 请求封装:支持自定义各种数据类型的请求。
- 请求调度:分缓存和网络两类线程。
- 数据获取:内存、磁盘、网络。
1.2 类图
核心类:
Volley:Volley框架入口,初始化RequestQueue。
RequestQueue:Volley网络请求核心管理类。Network、Cache、ResponseDelivery、CacheDispatcher、NetworkDispatcher都聚合在RequestQueue中,它们都是Volley初始化RequestQueue时候一起初始化的,并作为参数传入RequestQueue作为全局变量。
- CacheDispatcher 缓存线程*1
- NetworkDispatcher 网络请求线程*4
- PriorityBlockingQueue:mCacheQueue 缓存线程任务队列
- PriorityBlockingQueue:mNetworkQueue 网络请求线程任务队列
- ResponseDelivery:响应分发调度
BasicNetwork:网络请求处理
- HttpStack:具体网络请求执行,封装了HttpURLConnection和HttpClient按sdk9版本来选择使用。
- ByteArrayPool:网络请求原始数据缓存池。
DiskBaseCache:磁盘缓存处理
Request:网络请求封装类
- 框架定义Request
- 自定义Request
NetworkResponse:网络请求原始数据响应封装类。
Response:对网络请求原始数据进行解析后的响应封装类。
1.3 请求时序图
一次网络请求流程:
1)Volley通过newRequestQueue初始化RequestQueue: 初始化HttpStack、BasicNetwork、ExecutorDelivery,启动CacheDispatcher和NetworkDispatcher线程。
2)RequestQueue通过add添加Request触发数据获取流程:
3)CacheDispatcher和NetworkDispatcher均为while(true)循环执行,通过对应的queue来阻塞任务,当对应的queue添加了request,会执行如下流程:
二、Volley核心源码分析
对整个框架结构和执行流程有了大致了解之后,来细化分析几点核心功能:
- Volley的线程管理
- Volley的缓存逻辑
- Volley的网络请求原始数据缓存池优化:ByteArrayPool
- 请求cancel逻辑
2.1 Volley的线程管理
Volley默认线程:CacheDispatcher *1,NetworkDispatcher *4,多余的请求入队PriorityBlockingQueue。
源码位置:com/android/volley/RequestQueue.java
/**
* Starts the dispatchers in this queue.
*/
public void start() {
stop(); // Make sure any currently running dispatchers are stopped.
// Create the cache dispatcher and start it.
mCacheDispatcher = new CacheDispatcher(mCacheQueue, mNetworkQueue, mCache, mDelivery);
mCacheDispatcher.start();
// Create network dispatchers (and corresponding threads) up to the pool size.
for (int i = 0; i < mDispatchers.length; i++) {
NetworkDispatcher networkDispatcher = new NetworkDispatcher(mNetworkQueue, mNetwork,
mCache, mDelivery);
mDispatchers[i] = networkDispatcher;
networkDispatcher.start();
}
}
com/android/volley/NetworkDispatcher.java
@Override
public void run() {
Process.setThreadPriority(Process.THREAD_PRIORITY_BACKGROUND);
Request<?> request;
while (true) {
...
try {
// 从queue中获取任务
request = mQueue.take();
...
// 执行网络请求
NetworkResponse networkResponse = mNetwork.performRequest(request);
request.addMarker("network-http-complete");
// If the server returned 304 AND we delivered a response already,
// we're done -- don't deliver a second identical response.
if (networkResponse.notModified && request.hasHadResponseDelivered()) {
request.finish("not-modified");
continue;
}
// 解析网络请求数据
Response<?> response = request.parseNetworkResponse(networkResponse);
request.addMarker("network-parse-complete");
// 写缓存数据
// TODO: Only update cache metadata instead of entire record for 304s.
if (request.shouldCache() && response.cacheEntry.isCache) {
mCache.put(request.getCacheKey(), response.cacheEntry);
request.addMarker("network-cache-written");
}
// 分发response
request.markDelivered();
mDelivery.postResponse(request, response);
...
}
}
源码分析:
RequestQueue组合了CacheDispatcher1和NetworkDispatcher4,默认5个常驻线程,除非主动stop。
线程是while(true)循环,通过PriorityBlockingQueue阻塞,同一线程循环获取PriorityBlockingQueue任务,正常情况下,下一个任务的调度需要等上一次请求执行完毕分发响应后才会调度到。
PriorityBlockingQueue是一个支持优先级的无界阻塞队列,默认容量11,扩容规则:旧容量小于64则翻倍,旧容量大于64则增加一半。虽然比LinkedBlockingQueue初始化默认容量为Integer.MAX_VALUE要强点, 但是理论上还是可以一直添加request,直到系统资源耗尽。
2.2 Volley的缓存逻辑
源码分析:
Volley通过Request.setShouldCache设置请求是否缓存。
如果设置了缓存,整体逻辑是先从磁盘缓存获取,如果没有再进行网络请求,网络请求成功后数据再做磁盘缓存。
这里主要看下磁盘缓存是怎么做的:
- 路径:data/data/packageName/cache/volley
- 文件形式:
-rw------- 1 u0_a255 u0_a255_cache 6250 2020-11-17 14:57 -11505126341240133916
-rw------- 1 u0_a255 u0_a255_cache 1532 2020-11-17 14:57 -12302758661826148972
-rw------- 1 u0_a255 u0_a255_cache 1787 2020-11-17 14:57 -1391051118-579309601
-rw------- 1 u0_a255 u0_a255_cache 4877 2020-11-17 14:57 -13947998811265044989
- 文件内容即原始response数据。
- 磁盘缓存大小:5M
- 文件删除规则:LRU
2.3 Volley字节流内存优化ByteArrayPool
com/android/volley/toolbox/BasicNetwork.java
public NetworkResponse performRequest(Request<?> request)
throws VolleyError {
while (true) {
… //具体执行网络请求
httpResponse = mHttpStack.performRequest(request, headers);
...
if (httpResponse.getEntity() != null) {
//HttpEntity内容转到内存bytes[]中
responseContents = entityToBytes(httpResponse.getEntity());
} else {
// Add 0 byte response as a way of honestly representing a
// no-content request.
responseContents = new byte[0];
}
… //返回原始response数据
return new NetworkResponse(statusCode, responseContents,
responseHeaders, false,
SystemClock.elapsedRealtime() - requestStart);
...
}
}
接下来看看entityToBytes方法:
/** Reads the contents of HttpEntity into a byte[]. */
private byte[] entityToBytes(HttpEntity entity)
throws IOException, ServerError {
PoolingByteArrayOutputStream bytes = new PoolingByteArrayOutputStream(
mPool, (int) entity.getContentLength());
byte[] buffer = null;
try {
InputStream in = entity.getContent();
if (in == null) {
throw new ServerError();
}
buffer = mPool.getBuf(1024);
int count;
while ((count = in.read(buffer)) != -1) {
bytes.write(buffer, 0, count);
}
return bytes.toByteArray();
} finally {
try {
// Close the InputStream and release the resources by "consuming
// the content".
entity.consumeContent();
} catch (IOException e) {
// This can happen if there was an exception above that left the
// entity in
// an invalid state.
VolleyLog.v("Error occurred when calling consumingContent");
}
mPool.returnBuf(buffer);
bytes.close();
}
}
源码分析:
BasicNetwork的performRequest执行具体网络请求,总共三步,http核心库执行网络请求获取HttpEntry、Entry转byte[]、byte[]内容封装入NetworkResponse中返回。由于volley是轻量级频次高的网络请求框架,因此在这里会频繁创建和销毁byte[],为了提高性能,volley定义了一个byte[]缓冲池,即ByteArrayPool 。
PoolingByteArrayOutputStream其实就是一个输出流,只不过系统的输出流在使用byte[]时,如果大小不够,会自动扩大byte[]的大小。而PoolingByteArrayOutputStream则是使用了上面的字节数组缓冲池,从池中获取byte[] 使用完毕后再归还。 在BasicNetwork中对响应进行解析的时候使用到了该输出流。
2.4 请求cancel逻辑
请求cancel逻辑主要分两个部分:RequestQueue对Request进行统一cancel,Request自己标记cancel。
com/android/volley/Request.java
/**
* Mark this request as canceled. No callback will be delivered.
*/
public void cancel() {
mCanceled = true;
}
/**
* Returns true if this request has been canceled.
*/
public boolean isCanceled() {
return mCanceled;
}
com/android/volley/NetworkDispatcher.java
public void run() {
...
// Take a request from the queue.
request = mQueue.take();
...
// If the request was cancelled already, do not perform the
// network request.
if (request.isCanceled()) {
request.finish("network-discard-cancelled");
continue;
}
...
}
Request被线程调度到了,会先判断cancel标记来决定是否执行任务。
com/android/volley/RequestQueue.java
//保存正在被调度的request以及queue中等待的request的集合
private final Set<Request<?>> mCurrentRequests = new HashSet<Request<?>>();
/**
* Cancels all requests in this queue for which the given filter applies.
*
* @param filter The filtering function to use
*/
public void cancelAll(RequestFilter filter) {
synchronized (mCurrentRequests) {
for (Request<?> request : mCurrentRequests) {
if (filter.apply(request)) {
request.cancel();
}
}
}
}
/**
* Cancels all requests in this queue with the given tag. Tag must be non-null
* and equality is by identity.
*/
public void cancelAll(final Object tag) {
if (tag == null) {
throw new IllegalArgumentException("Cannot cancelAll with a null tag");
}
cancelAll(new RequestFilter() {
@Override
public boolean apply(Request<?> request) {
return request.getTag() == tag;
}
});
}
RequestQueue支持按过滤条件和标签进行request的批量删除。
很明显,Volley对Request的cancel只能作用于还没走网络请求的任务,没法对正在进行网络请求的Request进行cancel。
Volley核心源码分析完之后,来解析下为什么Volley适合做数据量小、通信频繁的网络操作,不适合高并发、不适合大文件上传下载。
个人理解如下:
- Volley默认是4个常驻loop线程来并行执行任务,整体并发性不高。因此数据量大而频繁的任务会阻塞队列中排队的任务。
- Volley常驻loop线程带来的好处是在通信频繁的网络操作场景下能减少线程创建销毁的开销。
- Volley在执行网络请求过程中会一个网络请求数据的内存byte[]转换,对于频繁的网络请求,会频繁创建和销毁byte[],为了提高性能,volley定义了一个byte[]缓冲池,即ByteArrayPool 。它的问题在于大文件上传下载会挤大缓冲池的byte[]内存占用,从而造成内存压力。
三、Volley线程池方案分析
Volley线程池整体架构有几个关键点:
PriorityBlockingQueue是一个支持优先级的无界阻塞队列,默认容量11,扩容规则:旧容量小于64则翻倍,旧容量大于64则增加一半。虽然比LinkedBlockingQueue初始化默认容量为Integer.MAX_VALUE要强点, 但是理论上还是可以一直添加request,直到系统资源耗尽,因此队列阻塞任务过多有oom风险。
线程本身是一个while(true)的死循环,通过queue.take()来做阻塞,queue中添加了任务,线程会马上take任务来处理,处理流程包括:1)performRequest:通过HttpUrlConnection/HttpClient执行网络请求。2)parseNetworkResponse:对网络请求返回的response进行解析封装。3)postResponse:分发response出去。也就是说一个线程执行任务的生命周期是从获取任务到分发response出去,一个任务执行完成才重新去queue中take新任务,所以上一个任务中网络请求、数据解析耗时都有可能造成后面的任务delay。
默认1个cache线程和4个network线程,常驻线程5个。常驻线程目的是在通信频繁场景下能减少线程频繁创建消耗的开销。
四、Volley线程池优化思考
首先Volley本身设计方案是否有优化空间?
有些项目会按请求类型来构造多个RequestQueue来处理任务,增加并发性。
例如:
常见的网络请求类型包括:普通数据请求、埋点及时上报、大文件上传下载。那么就做三个RequestQueue,然后根据业务的并发性来配置合理的常驻线程数,比如:4:4:1。
优点是:增大了并发性,也降低了不同业务请求之间相互影响的概率。比如扎堆的数据上报和大文件上传下载阻塞页面UI刷新数据的网络请求。
缺点:Volley的线程池方案增加并发性需要以牺牲内存为代价,毕竟是固定的常驻线程。JDK1.5+ -Xss配置是1M,因此一个线程的开销差不多是1M内存。对于低内存设备来说还是不太友好的。之前就遇到过如下问题:
java.lang.OutOfMemoryError: thread creation failed at java.lang.VMThread.create(Native Method)
at java.lang.Thread.start(Thread.java:1050)
at com.mgtv.tv.ad.library.network.android.volley.RequestQueue.start(RequestQueue.java:7)
at com.mgtv.tv.ad.library.network.android.volley.toolbox.Volley.newRequestQueue(Volley.java:10)
at com.mgtv.tv.ad.library.network.android.volley.toolbox.Volley.newRequestQueue(Volley.java:11)
所以这里考虑用线程池替换Volley固定数量常驻loop线程方案。主要有两种线程池方案可供参考:
4.1 Okhttp线程池方案
private int maxRequests = 64;//最大并发数。
private final Deque<AsyncCall> readyAsyncCalls = new ArrayDeque<>();//等待任务
private final Deque<AsyncCall> runningAsyncCalls = new ArrayDeque<>();//执行任务
executorService = new ThreadPoolExecutor(0, Integer.MAX_VALUE, 60, TimeUnit.SECONDS,
new SynchronousQueue<Runnable>(), Util.threadFactory("OkHttp Dispatcher", false));
okhttp使用的是类CacheThreadPool方案,无核心线程,纯非核心线程。线程池配置上非核心线程数量为:Integer.MAX_VALUE,但实际是通过maxRequests来从代码层面做了限制。在不满足最大请求数时,任务直接入running队列,被执行。超过最大请求数,任务暂时先入ready队列等待,当有任务结束时,会尝试同步ready队列任务到running队列中,并执行。
该方案并发可控,线程完全可回收,线程池本身使用的是SynchronousQueue,该队列本身不存储元素,因此任务排队需求需要单独实现。
那么这里有个问题,为什么要单独实现队列?
类CacheThreadPool方案核心在于要及时满足任务的调度,它是并发性最好的线程池方案,但是同时它自身也存在明显问题,因为线程创建是无限的,很容易造成oom, 所以在移动端是需要限制并发数量的,怎么限制呢?
首先SynchronousQueue是不能替换的,它是高并发的保证,它自身不存储元素,只是做一个阻塞,那如果改成ArrayBlockingQueue呢?按线程池执行流程,在没有核心线程情况下,会先入队,队满再创建非核心线程来执行任务,这样肯定不行。所以比较好的办法是从外部单独实现队列
看看Okhttp如何单独实现队列:
okhttp3/Dispatcher.java
synchronized void enqueue(AsyncCall call) {
if (runningAsyncCalls.size() < maxRequests && runningCallsForHost(call) < maxRequestsPerHost) {
runningAsyncCalls.add(call);
executorService().execute(call);
} else {
readyAsyncCalls.add(call);
}
}
发起一个网络请求任务,没到最大请求数直接执行,并添加到runningAsyncCalls中,否则进readyAsyncCalls中排队。
void finished(AsyncCall call) {
finished(runningAsyncCalls, call, true);
}
private <T> void finished(Deque<T> calls, T call, boolean promoteCalls) {
int runningCallsCount;
Runnable idleCallback;
synchronized (this) {
if (!calls.remove(call)) throw new AssertionError("Call wasn't in-flight!");
if (promoteCalls) promoteCalls();
runningCallsCount = runningCallsCount();
idleCallback = this.idleCallback;
}
if (runningCallsCount == 0 && idleCallback != null) {
idleCallback.run();
}
}
一个任务结束会触发finish,这里runningAsyncCalls先移除当前任务,然后通过promoteCalls将readyAsyncCalls的任务同步到runningAsyncCalls中来,如下所示:
private void promoteCalls() {
if (runningAsyncCalls.size() >= maxRequests) return; // Already running max capacity.
if (readyAsyncCalls.isEmpty()) return; // No ready calls to promote.
for (Iterator<AsyncCall> i = readyAsyncCalls.iterator(); i.hasNext(); ) {
AsyncCall call = i.next();
if (runningCallsForHost(call) < maxRequestsPerHost) {
i.remove();
runningAsyncCalls.add(call);
executorService().execute(call);
}
if (runningAsyncCalls.size() >= maxRequests) return; // Reached max capacity.
}
}
借用两个Deque来从外部限制最大并发线程数,非常简单,值得借鉴学习。
4.2 自定义线程池方案
mPool = new ThreadPoolExecutor(CORE_POOL_SIZE,
MAXIMUM_POOL_SIZE, //CORE_POOL_SIZE *2
KEEP_ALIVE_TIME,
TimeUnit.SECONDS,
new LinkedBlockingQueue<Runnable>(QUEUE_CAPACITY),//限制queue容量
new ThreadPoolExecutor.CallerRunsPolicy()//queue满之后的饱和策略
该方案是核心线程+非核心线程的线程池,整体方案还是属于IO密集型线程池配置,常驻的核心线程避免线程频繁创建和销毁的内存开销,非核心线程提供一定的并发性拓展,总线程数有限,且非核心线程可回收,因此在核心线程数量不大的情况下内存开销也同样可控,过多的任务只能入LinkedBlockingQueue队列等待执行,入队任务过多会导致OOM,因此需要限制队列容量以及配合拒绝策略,这里同样也可以用ArrayBlockingQueue<Runnable>(QUEUE_CAPACITY)。
注:ArrayBlockingQueue 与LinkedBlockingQueue区别:
- 有界性:ArrayBlockingQueue有界,LinkedBlockingQueue可有界可无界;
- 数据结构:ArrayBlockingQueue采用的是数组作为数据存储容器,而LinkedBlockingQueue采用的则是以Node节点作为连接对象的链表。由于ArrayBlockingQueue采用的是数组的存储容器,因此在插入或删除元素时不会产生或销毁任何额外的对象实例,而LinkedBlockingQueue则会生成一个额外的Node对象。这可能在长时间内需要高效并发地处理大批量数据的时,对于GC可能存在较大影响。
- 并发性:ArrayBlockingQueue添加删除是用的一把锁,而LinkedBlockingQueue这两个操作是分别加锁的。在高并发情况下,LinkedBlockingQueue的生产者和消费者可以并行地操作队列中的数据,并发性更好。
4.3 框架外部传入项目已有线程池复用
这个方案也考虑过,好处是减少线程池数量,本身也是一种线程开销的优化,但是这样会让网络请求任务和项目中各中各样的任务糅杂在一起,还是有互相影响到的问题。另外也不好为一类工作线程统一命名。
总体来说,如果Volley要做线程池改造的话,可以考虑引入Okhttp线程池方案:
换了这种方案之后,Volley的并发痛点能得到缓解,尤其是极端情况下:
例如:
项目中数据上报很频繁且大部分都是及时上报,某些页面数据请求接口又比较多,因此进入该类页面会触发比较多的网络请求,因此频繁切换此类页面并发性要求相对来说会比较高,一旦遇到比如:任务数据量大、网络超时、弱网环境,Volley的表现就很糟糕。
在网络超时场景下:
request:
1970-01-01 08:22:29.582 4295-4295/com.xxx.xxx D/NetWorkVolleyImpl: requestID:185031429
1970-01-01 08:22:30.085 4295-4295/com.xxx.xxx I/NetWorkVolleyImpl: requestID:164326795
1970-01-01 08:22:30.203 4295-4295/com.xxx.xxx I/NetWorkVolleyImpl: requestID:58808167
1970-01-01 08:22:30.713 4295-4295/com.xxx.xxx I/NetWorkVolleyImpl: requestID:262858173
1970-01-01 08:22:30.986 4295-4295/com.xxx.xxx D/NetWorkVolleyImpl: requestID:123129723
1970-01-01 08:22:30.992 4295-4295/com.xxx.xxx I/NetWorkVolleyImpl: requestID:114437590
1970-01-01 08:22:30.999 4295-4295/com.xxx.xxx D/NetWorkVolleyImpl: requestID:219127620
1970-01-01 08:22:31.001 4295-4295/com.xxx.xxx I/NetWorkVolleyImpl: requestID:102453602
1970-01-01 08:22:31.033 4295-4295/com.xxx.xxx I/NetWorkVolleyImpl: requestID:86987358
response:
1970-01-01 08:22:34.739 4295-4295/com.xxx.xxx D/NetWorkVolleyImpl: requestID:185031429,error:java.net.SocketTimeoutException: connect timed out
1970-01-01 08:22:31.716 4295-4295/com.xxx.xxx I/NetWorkVolleyImpl: requestID:164326795,error:java.net.SocketTimeoutException: connect timed out
1970-01-01 08:22:45.334 4295-4295/com.xxx.xxx I/NetWorkVolleyImpl: requestID:58808167,error:java.net.SocketTimeoutException: connect timed out
1970-01-01 08:22:32.459 4295-4295/com.xxx.xxx I/NetWorkVolleyImpl: requestID:262858173,error:java.net.SocketTimeoutException: connect timed out
1970-01-01 08:22:39.696 4295-4295/com.xxx.xxx D/NetWorkVolleyImpl: requestID:123129723,error:java.net.SocketTimeoutException: connect timed out
1970-01-01 08:22:46.832 4295-4295/com.xxx.xxx I/NetWorkVolleyImpl: requestID:114437590,error:java.net.SocketTimeoutException: connect timed out
1970-01-01 08:22:51.034 4295-4295/com.xxx.xxx D/NetWorkVolleyImpl: requestID:219127620,error:java.net.SocketTimeoutException: connect timed out
1970-01-01 08:22:47.484 4295-4295/com.xxx.xxx I/NetWorkVolleyImpl: requestID:102453602,error:java.net.SocketTimeoutException: connect timed out
1970-01-01 08:22:50.351 4295-4295/com.xxx.xxx I/NetWorkVolleyImpl: requestID:86987358,error:java.net.SocketTimeoutException: timeout
超时时间设置是5s,很明显看到,在过了并发之后,后续的超时反馈越来越慢。超时时间是从任务获取到线程执行网络请求开始计算的,因此如果是队列阻塞状态,上一个任务耗时会delay到当前任务的执行,因此在超时回调上会是一个累加状态,让后续的网络请求迟迟没有回调,有些页面无法刷新UI,一直处于黑屏或者loading状态。该问题在okhttp的线程池中得到很大缓解。
那有人就问了,为什么不直接换okhttp呢?因为这篇文章写的是Volley,它毕竟也是一个非常经典的网络框架,有很多设计思想是值得学习的,同时尝试解决老框架的痛点问题,也是一种自我提升。
好了,就写到这,文章有不对之处还望批评指正,如果有更好的想法也欢迎沟通交流。