您的位置:首页 > 理论基础 > 计算机网络

优雅设计封装基于Okhttp3的网络框架(五):多线程、单例模式优化 及 volatile、构建者模式使用解析

2017-08-01 11:45 746 查看
关于多线程下载功能,前四篇博文所讲解内容已经实现,接下来需要对代码进行优化。开发一个新功能并不复杂,难的是考虑到代码的扩展性和解耦性,后续需要进行的bug修复、完善功能等方面。此篇内容主要讲解代码优化,将从线程优化、单例优化、设计优化这三个方面进行讲解。

此篇内容将涉及到以下知识:

线程优化及Linux系统中线程调度介绍

Android中常用的5种单例模式解析

volatile关键字底层原理及注意事项

构建者模式介绍及使用

(建议阅读此篇文章之前,需理解前两篇文章的讲解,此系列文章是环环相扣,不可缺一,链接如下:)

优雅设计封装基于Okhttp3的网络框架(一):Http网络协议与Okhttp3解析

优雅设计封装基于Okhttp3的网络框架(二):多线程下载功能原理设计 及 简单实现

优雅设计封装基于Okhttp3的网络框架(三):多线程下载功能核心实现 及 线程池、队列机制解析

优雅设计封装基于Okhttp3的网络框架(四):多线程下载添加数据库支持(greenDao)及 进度更新

一. 线程优化

1. 根本问题

在Java语言中本身存在线程的优先级,是否直接操作设置这些线程的优先级就可以控制Android程序中的线程?

并非如此,实际上Java提供的一些线程优先级设置对于Android而言并非起太大作用,因为Android系统是基于Linux,而Linux系统对于线程调度管理有一套自己的法则。

2. Linux的线程调度法则

在Linux中使用nice value(以下成为nice值)来设定一个进程的优先级,系统任务调度器根据nice值合理安排调度。在Android系统中,也是采用此值来进行优化,特点如下:

nice的取值范围为-20到19。

通常情况下,nice的默认值为0。

nice的值越大,进程的优先级就越低,获得CPU调用的机会越少,nice值越小,进程的优先级则越高,获得CPU调用的机会越多。

一个nice值为-20的进程优先级最高,nice值为19的进程优先级最低。

以上便是 nice值的特点介绍,那么如何在代码中进行使用?首先来查看一个系统类AsyncTask,在它的内部实现中就使用到了相关代码:



Process.setThreadPriority(Process.THREAD_PRIORITY_BACKGROUND);


此行代码作用相关于将该线程的优先级设置成THREAD_PRIORITY_BACKGROUND,继续查看其优先级别:



查看源码可知优先级别为10,将它设置为后台线程的好处是(查看注释):减少系统调度时间,UI线程会得到更多响应时间。

3. DownloadRunnable中的run方法优化

经过以上讲解后,将设置线程优先级至后台线程这行代码添加至
run
方法的第一行即可。(此行代码设置虽简单,但背后逻辑操作紧密联系Linux线程调度,有兴趣者后续可多了解)

@Override
public void run() {

//设置线程优先级别为后台线程,为了 减少系统调度时间,使UI线程会得到更多响应时间 android.os.Process.setThreadPriority(Process.THREAD_PRIORITY_BACKGROUND);
......
}


二. 单例模式优化

此点将对于Android中使用的单例模式进行优化,单例模式使用的场景往往是:程序中的某些对象的创建和消耗是比较耗费资源的,此时可以考虑将其对象设置为单例模式。

该模式算是设计模式中最常用、基本的一种,在目前已实现的网络框架编码中已多次使用。按照Java语言详细划分,单例模式可分为7种,但在Android中常用的只有以下5种。

1. 五大种类

(1)饿汉式

【饿汉式】

public class DownloadManager {
private static DownloadManager sManager = new DownloadManager();

private DownloadManager() {
}

public static DownloadManager getInstance() {
return sManager;
}


实现方式

将该类的对象设置为静态成员变量;

类的构造方法设置为私有,意味着外界无法创建该对象;

直接创建对象赋值给静态成员变量。

创建时机

当此类加载进来的时候,该类的对象已经创建成功了。

(2)懒汉式

【懒汉式】

public class DownloadManager {
private static DownloadManager sManager;

private DownloadManager() {
}

public static DownloadManager getInstance() {
if (sManager == null) {
sManager = new DownloadManager();
}
}
return sManager;
}


实现方式

懒汉式的单例模式算是对饿汉式的优化:

将该类的对象设置为静态成员变量;

类的构造方法设置为私有,意味着外界无法创建该对象;

在对外提供的public静态
getInstance
方法进行对象创建操作。

创建时机

当需要该类的对象时,调用此类暴露出来的
getInstance
方法,才会去创建对象。

(3)Double Check

问题解析

单例模式的每一种方式衍生可以看作是一次次的完善。在懒汉式中的
getInstance
方法创建类对象时,若遇到多线程的情况,便会出问题,即多个线程同时在操纵创建这行代码,这样对象并非是单例模式了。

改善代码

最简单的修改方法就是给
getInstance
方法加上synchronized 关键字,锁住此方法,但是锁住这整个方法消耗颇多,并非最佳,实际上只需锁住创建对象这行代码即可。

于是,有了如下Double Check方式:

【Double Check】

public class DownloadManager {
private static DownloadManager sManager;

private DownloadManager() {
}

public static DownloadManager getInstance() {
if (sManager == null) {
synchronized (DownloadManager.class) {
if (sManager == null) {
sManager = new DownloadManager();
}
}
}
return sManager;
}


实现方式

将该类的对象设置为静态成员变量;

类的构造方法设置为私有,意味着外界无法创建该对象

在对外提供的public静态
getInstance
方法中判断,当对象为空时,使用synchronized 关键字,锁住DownloadManager.class,在其代码块中再加一个判断:若对象为空时创建对象。

创建时机

当需要该类的对象时,调用此类暴露出来的
getInstance
方法,才会去创建对象。

优缺点

优点:只会在第一次创建对象的时候去加锁,之后无需加锁直接返回已存在对象,节省了不必要的性能消耗。

缺点:其实这种方法也不能保证程序的完整性,它在某些虚拟机上运行会出现空指针问题,背后原因比较复杂,简单而言是因为创建对象这行代码并非原子性操作,虚拟机在编译字节码时将这行代码分为几步操作。

举个例子,当A线程访问对象为空,获取到锁,在其中进行对象创建过程中,线程B访问该方法,判断对象不为空,获取到此时返回的对象进行其它操作。注意此时线程B获取的对象仅是不为空,但它还未初始化完成,所以会出现空指针问题。原因就是字节码在执行时并非是原子性操作。(这里稍作了解即可,后续volatile介绍会继续讲解)

虽然空指针异常的发生几率不大,但毕竟是一个显示问题,存在太大隐患,一旦发生异常,进行排除问题都难以想到此层面。此种方式不太推荐。

(4)静态内部类

问题解析

面对最后 Double Check而言的问题,又有一种方式出现来解决此问题 —— 静态内部类。它可以起到延迟加载的作用,并且能保证创建对象的完整性。

【静态内部类】

public static class Holder {

private static DownloadManager sManager = new DownloadManager();

public static DownloadManager getInstance() {
return sManager;
}
}


//外界访问

DownloadManager.Holder.getInstance();


实现方式

编写访问权限为public 的静态内部类;

在其内部类中将对象设置为私有的静态成员变量;

在其内部类中对外提供 public 获取对象的静态方法
getInstance
返回对象。

创建时机

调用该静态内部类的
getInstance
时才会初始化对象。

原理分析

虽然它只是一个静态内部类,但虚拟机在编译class时会单独将它放到一个文件。看起来跟饿汉式似乎有点相像,但是当虚拟机加载此类时,并不会初始化该对象,只有调用该静态内部类的
getInstance
时才会初始化对象,起到了延迟加载作用,保证了创建对象的操作原子性。

此种方式较为推荐。

(5)枚举

其实枚举实现单例模式这种方式在Java语言中较为推崇,枚举在虚拟机层面已经保证了创建的唯一性,但是在Android系统中并不推荐,因为枚举会导致占用内存过多,可以尝试反编译枚举的Class,会发现随着枚举定义类型的增多,它所占用的内存是成倍增长,每一个枚举类型都生成相应的Class,它的每一个成员变量都是单独一份,所以此方式并不推荐!

2. volatile 关键字解析

在上一点中介绍 Double Check的单例模式使用中,会出现空指针问题,根本原因上述已简单讲解,此点将结合 volatile 关键字,从虚拟机底层原理详细探究。

博主声明: volatile 关键字向来是多线程并发中的重点,此点讲解涉及到大量虚拟机底层相关原理知识,若想真正了解透彻,仅以此点远远不够,推荐读者能够先查看以下文章链接,这是Java虚拟机中相关部分,学习之后再来理解Double Check单例模式中的空指针异常,会更加容易。

JVM高级特性与实践(十二):高效并发时的内外存交互、三大特征(原子、可见、有序性) 与 volatile型变量特殊规则

(1)异常定位—— 空指针异常

【Double Check】

public class DownloadManager {
private static DownloadManager sManager;

public static DownloadManager getInstance() {
if (sManager == null) {
synchronized (DownloadManager.class) {
if (sManager == null) {
//出错处
sManager = new DownloadManager();
}
}
}
return sManager;
}


代码异常定义

出现异常来源于创建对象那行代码,看似只是一个对象创建并赋值操作,但是当虚拟机编译成字节码文件,这行代码的对象创建操作不是一个原子性操作,会生成多条字节码指令。

例子讲解

再来回顾这个例子:线程A进入到
getInstance()
方法时首先判断对象为空,拿到DownloadManager的锁,准备对象创建操作。此时线程B进入该方法,该对象已经不为空了(线程A已创建该对象实例),线程B理所当然获取到对象,但是此时对象并不完整,它只是不为空,初始化阶段可能尚未完成,所以当线程B使用该对象操作时必然会出现空指针异常。

对象创建代码 分解

那行代码的对象创建操作不是一个原子性操作,通过伪码的形式可分成以下几步:

1)sManager 分配内存

2) sManager 调用构造方法进行初始化操作

3)sManager 对象进行赋值操作,使它指向在第一步分配的内存区域

(2)根本原因——JVM中的字节码指令集的重排序

所以说一个简单的对象创建在Java虚拟机中会被划分为这3个步骤,其实这些步骤也很正常,需要注意的是Java虚拟机会对代码步骤进行重排序,即字节码指令集的重排序

例如以上步骤二可能被虚拟机重排序到最后,这样意味着步骤一结束后,对象确实不为空了,但是在步骤三初始化之前被线程B获取到对象实例,而导致空指针异常!

代码执行顺序

虚拟机执行代码的顺序并非是按照我们所写的,而是以字节码文件为准。而JVM会自动优化代码,打乱字节码执行顺序!注意:这里的顺序打乱它不会故意破坏而导致异常产生,例如代码上下之间有依赖关系,JVM不会进行重排序。

(3)问题解决 —— volatile关键字

其实以上问题在单线程中并不会出现,只会在多线程中出现,为了避免JVM中的字节码指令集重排序问题,JDK 1.5中引入了一个关键字 —— volatile,它有两个重要作用:

禁止JVM进行重排序

保证变量的可见性(可见性:指当一个线程修改了共享变量的值,其他能够立即得知这个修改)

Java开发者应当知道当一个变量被volatile关键字修饰时,它拥有可见性,但是另外一个作用 —— 禁止JVM进行重排序,却鲜为人知。当次变量被修饰后,JVM不会打乱字节码执行顺序,而出现步骤二在最后执行的情况。

指令重排序例子再论

为了更好的理解指令重排序,再举个例子来了解,例如在以下代码定义了这三个变量:

int a = 12;
boolean flag = false;
long c = 23;


JVM在执行以上代码时会以字节码文件为准,即打乱顺序,可能先操作boolean变量赋值,然后再是int,最后long。代码原本顺序的确被打乱了,但是JVM并不会无故打乱而导致异常产生,例如以下示例:

int a = 12;
int b = 10;
int c = a+23;


JVM在进行重排序时绝对不会将
int c = a+23;
操作放到
int a = 12;
之前,在程序编译时JVM已经考虑到了这些变量之间的依赖(还有其它考虑原则),所以变量a的赋值一定在变量c之前完成,不过变量a、b的初始化顺序无法保证。

(4)先行发生原则(happens-before)

JVM在处理相关的字节码文件时,所考虑到的原则是规定好的,例如上述中变量之间的依赖,这些判断的依据就是先行发生原则,由以下几个规则组成:

程序次序规则(Program Order Rule):在一个线程内,按照程序代码顺序,书写在前面的操作先行发生于书写在后面的操作,准确地说,应该是控制流顺序。

管程锁定规则(Monitor Lock Rule):一个unlock操作先行发生于后面对同一个锁的lock操作;这里必须强调的是同一个锁,而后面是指时间上的先后顺序。

volatile变量规则(Volatile Variable Rule):对一个volatile变量的写操作先行发生于后面对这个变量的读操作,这里的后面是指时间上的先后顺序。

线程启动规则(Thread Start Rule): Thread对象的start() 方法先行发生于此线程的每一个动作。

线程终止规则(Thread Temination Rule):线程中的所有操作都先行发生于对此线程的终止检测,可以通过Thread.join() 方法结束,Thread.isAlive() 的返回值等手段检测到线程已经终止运行。

线程中断规则(Thread Interruption Rule):对线程interrupt() 方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过 Thread.interrrupted() 方法检测到是否有中断发生。

对象终结规则(Finalizer Rule):一个对象的初始化完成先行发生于它的finalize() 方法的开始。

传递性(Transitivity):如果操作A 先行发生于操作B, 操作B 先行发生于操作C,那就可以得出操作A 先行发生于 操作C的结论。

如果编写的代码中满足以上规则,JVM不会对字节码指令集进行优化,即重排序。

最后,在《深入Java虚拟机》中从底层原理部分详细解析了volatile关键字,若要透彻了解,可查看此书(12章的3.3节)或博主写的记录博客。

三.设计优化——构建者(Build)模式

若读者对构建者模式的组成及使用不熟悉,建议先看以下博文,以下博文详细讲解了构建者模式的构造及使用,举例说明学习。

Android : Builder模式 详解及学习使用

目前有个需求,在DownloadManager管理类中,想要提供一些灵活的参数来控制此类中的线程池、执行服务对象创建,例如核心、最大线程数这些参数等。如此而言就需要在此类中设计多个
set
方法,而不同参数的组合可能导致
set
方法需求量的增加,代码冗杂,所以采用构建者模式来解决这种需求带来的问题。

1. 构建者(Build)模式

(1)定义及作用

定义:将一个复杂对象的构建与它的表示分离,使得同样的构建过程可以创建不同的表示。

作用:减少对象创建过程中引入的多个重载构造函数、可选参数以及
setter
过度使用导致不必要的复杂性。

(2)组成



一般而言,Builder模式主要由四个部分组成:

Product :被构造的复杂对象,ConcreteBuilder 用来创建该对象的内部表示,并定义它的装配过程。

Builder :抽象接口,用来定义创建 Product 对象的各个组成部分的组件。

ConcreteBuilder : Builder接口的具体实现,可以定义多个,是实际构建Product 对象的地方,同时会提供一个返回 Product 的接口。

Director : Builder接口的构造者和使用者。

(3)实例讲解

这里举的例子应该算是构建者模式的进化版,精简了一些不必要的接口,若想要了解标准的模式编码,可以看第三点开头给出的链接,在此不多言。

public class User {
private final String mName;     //必选
private final String mGender;   //可选
private final int mAge;         //可选
private final String mPhone;    //可选

public User(UserBuilder userBuilder) {
this.mName = userBuilder.name;
this.mGender = userBuilder.gender;
this.mAge = userBuilder.age;
this.mPhone = userBuilder.phone;
}

public String getName() {
return mName;
}

public String getGender() {
return mGender;
}

public int getAge() {
return mAge;
}

public String getPhone() {
return mPhone;
}

public static class UserBuilder{
private final String name;
private String gender;
private int age;
private String phone;

public UserBuilder(String name) {
this.name = name;
}

public UserBuilder gender(String gender){
this.gender = gender;
return this;
}

public UserBuilder age(int age){
this.age = age;
return this;
}

public UserBuilder phone(String phone){
this.phone = phone;
return this;
}

public User build(){
return new User(this);
}
}

}


从以上代码可以看出这几点:

User类的构造函数是私有的,这意味着调用者不可直接实例化这个类。

User类是不可变的,其中必选的属性值都是 final 的并且在构造函数中设置;同时对所有的属性取消 setters函数,只保留 getter函数。

UserBuilder 的构造函数只接收必选的属性值作为参数,并且只是将必选的属性设置为 fianl,来保证它们在构造函数中设置。

接下来,User类的使用方法如下:

public User getUser(){
return new
User.UserBuilder("gym")
.gender("female")
.age(20)
.phone("12345678900")
.build();
}


以上通过 进化的Builder模式形象的体现在User的实例化。

2. DownloadConfig(采用构建者模式)

创建一个配置类采用构建者模式对外提供参数配置:

public class DownloadConfig {
private int coreThreadSize;
private int maxThreadSize;
private int localProgressThreadSize;

private DownloadConfig(Builder builder) {
coreThreadSize = builder.coreThreadSize == 0 ? DownloadManager.MAX_THREAD : builder.coreThreadSize;
maxThreadSize = builder.maxThreadSize == 0 ? DownloadManager.MAX_THREAD : builder.coreThreadSize;
localProgressThreadSize = builder.localProgressThreadSize == 0 ? DownloadManager.LOCAL_PROGRESS_SIZE : builder.localProgressThreadSize;
}

public int getCoreThreadSize() {
return coreThreadSize;
}

public int getMaxThreadSize() {
return maxThreadSize;
}

public int getLocalProgressThreadSize() {
return localProgressThreadSize;
}

public static class Builder {
private int coreThreadSize;
private int maxThreadSize;
private int localProgressThreadSize;

public Builder setCoreThreadSize(int coreThreadSize) {
this.coreThreadSize = coreThreadSize;
return this;
}

public Builder setMaxThreadSize(int maxThreadSize) {
this.maxThreadSize = maxThreadSize;
return this;
}

public Builder setLocalProgressThreadSize(int localProgressThreadSize) {
this.localProgressThreadSize = localProgressThreadSize;
return this;
}

public DownloadConfig builder() {
return new DownloadConfig(this);
}
}
}


3. 代码整合

(1)DownloadManager

在完成采用构建者模式的配置类,那么DownloadManager类中线程池的创建可直接调用配置类,需要在DownloadManager类中稍作修改。

private static ExecutorService sLocalProgressPool;

private static ThreadPoolExecutor sThreadPool;

public void init(DownloadConfig config) {
sThreadPool = new ThreadPoolExecutor(config.getCoreThreadSize(), config.getMaxThreadSize(), 60, TimeUnit.MILLISECONDS, new LinkedBlockingDeque<Runnable>(), new ThreadFactory() {
private AtomicInteger mInteger = new AtomicInteger(1);

@Override
public Thread newThread(Runnable runnable) {
Thread thread = new Thread(runnable, "download thread #" + mInteger.getAndIncrement());
return thread;
}
});

sLocalProgressPool = Executors.newFixedThreadPool(config.getLocalProgressThreadSize());

}


(2)Application初始化

DownloadConfig config = new DownloadConfig.Builder()
.setCoreThreadSize(2)
.setMaxThreadSize(4)
.setLocalProgressThreadSize(1)
.builder();
DownloadManager.getInstance().init(config);


以上,这种动态的设置配置参数处理方式相较于构造方法,灵活得多,减少了大量不必要的冗杂代码,扩展性较强,可链式增添参数配置。

四. 总结

1. 本篇总结

本篇内容是对前四篇博文完成的编码工作进行的优化,需要修改的地方并不多,但是为了程序的扩展性、解耦性、线程安全性考虑,分别从线程优化、单例优化、设计优化这三个层面对代码进行优化。其实完成一个功能并不难,重要的是前期设计部分一定要思考清楚功能可行性、程序扩展性等问题,而优化工作更是必不可少,切勿全部编程完再来一次“重构”,这样开发周期会被无限拖长,代码质量并不会因为所谓的“重构”而提高,适时的时候停下来对已完成的功能进行优化。

EasyOkhttp网络框架封装源码(对应第五篇博文优化后地代码)

2. 下篇预告

到目前为止,多线程下载功能设计、编写、优化工作已经完成,但是网络框架功能并没有完成,下篇将编写的新功能还是围绕在http请求上:

httpHeader的接口定义和实现

http请求头和响应头访问编写

http状态码定义

http中的 response封装、request接口封装和实现

若有错误,虚心指教~
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: 
相关文章推荐