一文读懂多线程背后的实际应用场景

今日我来给大家介绍一下多线程的实践运用场景。信任许多同学都会有疑惑,便是知道多线程在概念上和实践的软件工程里是重要的,可是在实践上事务上的具体运用却彻底找不到,可是在面试的时分呢,又会重复考察关于多线程的理解,然后经过面试后在作业的几年内,彻底没有运用过多线程编程,大有”面试造飞机进来拧螺丝“的感觉。

那我今日以我在作业中碰到的实践事例来给大家回答一下什么是多线程以及多线程到底有什么具体运用场景,以及为什么要把握这样一门底层技能。

言语的笼统

首要我解释一下,在不同的言语里边临硬件和软件掌控的层次是不相同的,比方说在偏底层的言语里边,C和C++需求直接来把握手动的创立线程和办理内存。这就使得这种底层的言语才干十分强壮,可是它的运用上手的本钱就会越高,究竟越底层越有许多物理概念。

因而为了考虑效率,编程言语会往更高的笼统和封装的程度开展,使得更高档的言语越来越便利运用,对开发者越来越友好,具体的完结办法,便是高档言语协助开发者屏蔽了许多底层的细节,使得开发者愈加专心自己的事务逻辑就能够了。简略说,高档言语会告诉运用者”你就这么简略的运用即可,无需关怀完结“。

结构的笼统

可是高档言语相对一些简略事务来说,仍是比较杂乱,比方我就想完结一个对外供给服务的运用,假设自己完结的话,还需求自己完结一套RPC的调用,实践上我连RPC都不想关怀,我只关怀我对外供给什么样的数据。因而在这个层面上,又有许多现成的结构把底层功用也封装起来了,屏蔽了技能细节,使得开发者能够直接编写事务逻辑。

比方说咱们的经常用到的一些RPC的结构(Dubbo),在客户建议恳求的时分,这种RPC结构会自动创立线程,再结合Spring就能够直接进行恳求流转。所以咱们用SpringBoot这种结构来写代码的时分,咱们全程都没有看到任何线程创立的比方,运用功用就生成好了,布置上线客户就能拜访了。

这个便是结构的意义,帮咱们屏蔽了许多偏底层的和事务不直接相关的逻辑和代码或许依靠。所以这种状况使得你彻底不懂得线程是什么东西,你也能够快速地s写出事务代码并布置上线。

多线程的运用原则

然而这个仅仅差异于初级程序员和高档程序员的一个特色。虽然在大多数状况下假设做一些简略的作业,咱们乃至不需求理解什么是多线程。可是假设咱们是一个有自我更高寻求的,而且咱们要完结的事务逻辑特别杂乱的,有着十分大的应战的场景,就不得不了解什么是多线程,由于多线程实践上能从底层思想来处理杂乱的事务问题。

因而关于这些基础常识的把握,决定了一名程序员的天花板在哪里。

在事务拜访量特别低的状况下的话,咱们直接套用结构的代码并做快速的事务完结就能够了,比方现在假设要构建一个WEB运用,咱们能够快速运用SpringBoot全家桶配置化的生成结构代码,然后自行完结里边的Controll办法就能够完结一个线上可用的体系,可是当咱们面临极点杂乱的、大流量的、高并发的状况下,那么就会对咱们的事务逻辑提出了更高的应战。也便是咱们直接运用这种结构式的代码来支持百万级的吞吐量、几十万级的QPS就彻底扛不住了。

比方咱们假设仅仅用简略的一些结构自带的模板办法,它是没有针对咱们具体的场景做过优化的,举一个简略比方,假设咱们仅仅用一些RPC自带的逻辑里边,一般没有供给并发处理的才干的。举个简略比方,当客户建议恳求的时分,会给客户分配到这仅有的一个线程,而且在这个恳求线程里边调用咱们完结的代码逻辑,当咱们的代码特别的杂乱,特别的耗时的时分,整个结构的调用也会变得更长,因而假设你不懂得优化的办法,那么就会把一切的恳求拉长,耗时拉长,而终究使得整个功用的体系的吞吐量都会大规模的下降,进而引起超时或许雪崩故障。而此时假设你仅仅一个CRUD,鄙视学习多线程基础常识的同学,那这种故障来临的时分,你往往只能干瞪眼,悔不及当初为什么不多学点基础的常识。

多线程在作业台的运用

所以我所担任的事务作业台和协作两个场景为例,来论述一下为什么多线程在这儿边发挥着重要的效果。

先从作业台开始,咱们知道作业台选用的是组件化的规划思路,也便是你拜访的每一个板块都是一个一个的组件。因而在用户拜访作业台的时分就需求悉数加载这些组件。在正常状况下,一个十分简略的思路便是咱们串行的恳求,一个组织一切的组件列表,而且在内存里边做恰当的渲染,终究回来给用户。在刚开始的时分,咱们用单线程就能够顺畅的完结这些使命,但跟着用户量开展,跟着整个事务逻辑的杂乱,使得对整个功用提出了十分大的应战。

一文读懂多线程背后的实际应用场景

比方有些客户的组件十分的多,几十个组件,而且有的组件里边还包括了许多的运用,这些运用的处理都需求十分耗时,因而归纳起来一个客户的处理可能时刻就超过了一两秒。可是咱们幻想一下客户的员工要到作业台运用考勤运用,但整个作业台却需求花上两秒钟的时刻才干打开作业台,可想而知体感有多么的差。

但假设你不懂底层的线程技能的话,对功用优化的问题,可能是无解的。这个时分假设处理这样一类问题,就能够清晰地差异出来一名高档程序员和一名一般程序员的差异了。

当然做这样一类的优化有许多办法,比方说缓存技能等等。可是为了确保实时性,而且在架构上面又坚持这样的一种高雅特性,那么用多线程的办法便是十分好的一种办法。

那运用到多线程的时分就必须要先对线程场景进行一个解构,也便是对单体恳求如何合理的拆分红多线程恳求,就放在这个场景来说,咱们能够看到咱们一个客户是有许多的组件的,一起这些组件又是彼此独立的,而且在某些组件发生了反常状况下的话,咱们期望是能够降级的,让客户能够尽可能看到更多的组件,能够运用到尽可能多的功用。这本身便是一种体系降级规划的思想。

依据这个场景上面的拆分,咱们就能够考虑运用多线程的办法来履行使命,也便是在客户建议恳求的时分,加载到作业台模块的时分,咱们就用多线程的办法来并行处理多个使命,每个人使命单独处理一个多个组件,然后帮多个使命的成果汇总到一块,终究回来给客户。

所以假设要运用多线程的话,那么咱们不仅仅明白了这样的核心骨架的规划,咱们更要规划里边的一些细节,比方说咱们关于一个客户的恳求该分配多少个线程,假设线程数少了,导致客户功用依然无法更好的改进。可是假设线程多了,许多的客户恳求都会创立线程,又使得整个线程会占用掉过多的资源,进而形成整个体系的溃散。

所以在这儿咱们就要想办法去了解线程的常识,该怎么样去界说一个最佳的线程数量平衡。

在完结现实线程数量的评估之后,咱们就要考虑咱们到底是手动创立线程,仍是运用线程池来创立。那咱们也能够了解到线程池和线程的差异,假设手动创立线程的话,本钱会十分的高,而且还要考虑到线程的释放一起还要去办理线程的数量,缺点十分显着,那么这儿边咱们就能够引进线程池,线程池就能够协助咱们更好的办理线程而且能够完结动态的创立和线程收回,帮咱们屏蔽了许多细节。

现在线程数的规划咱们有一个根本原则上的规划,线程池咱们也引证了,那么咱们就能够进一步了解现成的线程池的具体用法。在咱们了解了线程池的具体用法今后,就能够开始界说使命。

  • 初始化线程池
// 初始化线程池
queue = new ArrayBlockingQueue<>(threadPoolConfig.getBlockQueueSize());
executorService = new ThreadPoolExecutor(threadPoolConfig.getCoreThreadNum(), threadPoolConfig.getMaxThreadNum(),
                KEEP_ALIVE_TIME, TimeUnit.SECONDS, queue, r
                    -> new Thread(r, "mutilThread_" + r.hashCode()), new ThreadPoolExecutor.DiscardPolicy());

首要第一步便是界说线程池,几个关键参数便是线程池内核心线程数量、线程的最大数量、线程使命行列、使命丢弃战略等等。

这儿的状况能够依据经验值来确定,也能够依据自己的体系和事务状况来界说具体的参数值,我这儿就不具体介绍了,有爱好的能够参考相关线程池的文章。

  • 界说使命

在咱们完结通常意义的线程池定下来之后,咱们就要考虑到怎么样去界说使命。已然聊到界说一个使命,咱们就不能面向完结编程,而应该面向接口来编程,所以咱们需求界说一个一致的使命接口,让一切需求完结使命的当地直接去完结使命接口,而且把使命扔到线程池里边就能够了。

/**
     * 接受履行使命
     *
     * @param task
     */
    public <T> void accept(Task<T> task, T t) {
        TaskRunable runable = new TaskRunable(task, t, log);
        try {
            executorService.execute(runable);
        } catch (RejectedExecutionException e) {
            LogUtils.error(log, e, "task_reject");
            throw new RpcException("10004", "server is busy");
        } catch (Exception e) {
            LogUtils.error(log, e, "accept exception");
            throw new RpcException("10005", "server is error");
        }
    }

线程池接受使命的接口是一个Runnable的接口,也便是使命只需完结Runnable接口即可作为使命提交给线程池来履行。

@AllArgsConstructor
public class TaskRunable<T> implements Runnable {
    private Task<T> task;
    private T t;
    private Logger logger;
    @Override
    public void run() {
        if (task.getCaller() == null) {
            return;
        }
        try {
            task.getCaller().apply(t);
        } catch (Exception e) {
            // 加上关键监控
            LogUtils.error(logger, e, "TaskRunableExcep");
        } finally {
            // 告诉完结
            task.completeNotify(t);
        }
    }
}

这儿咱们界说了一个通用的使命类,使命类里边包括具体的使命接口

@Data
public abstract class Task<T> {
    private Caller<T> caller;
    /**
     * 告诉使命履行完结
     *
     * @param t
     */
    public abstract void completeNotify(T t);
}
  • 线程通讯

由于主线程需求等候一切子线程履行完毕后才干拼装终究回来给客户,因而就需求选用线程通讯的办法来使得主线程和子线程的协作。当然在Java8已经有了Future目标的办法来高效完结线程通讯了,咱们本事例选用愈加底层的办法来完结线程通讯。线程通讯的具体文章见链接:redspider.gitbook.io/concurrent/…

这儿咱们选用了Java关键字Object类的wait()办法和notify(),notifyAll()的办法来完结进程间的通讯。notify()办法会随机叫醒一个正在等候的线程,而notifyAll()会叫醒一切正在等候的线程。

Task<MainContext> task = new Task<MainContext>() {
		@Override
    public void completeNotify(ComponentSchemaContext context) {
    		// 回调完结,当使命完结的时分,将该使命标记置为完结
        context.getParallelContext().completeTask();
        // 假设不是悉数完结,则回来
    		if (!context.getParallelContext().getFinish()) {
    				return;
    		}
        //假设是悉数完结,则告诉主线程持续
				synchronized (context.getParallelContext().getLock()) {
						context.getParallelContext().getLock().notifyAll();
				}
			}
    };
    // processImpl是具体的使命
    task.setCaller(t -> processImpl(t));
    // 这儿只担任将使命丢进线程池,不需求等候一切使命完结
    multiThreadTaskExecutor.accept(task, context);
// 等候一切的组件使命处理完结
        synchronized(parallelContext.getLock()) {
            while (!parallelContext.getFinish()) {
                try {
                    // 最多等候一秒,防止LWP主线程在极点状况下被耗费殆尽
                    parallelContext.getLock().wait(1000);
                } catch (InterruptedException e) {
                    LogUtils.error(log, e, "waitTaskException", "corpId", context.getCorpId(),
                            "appUuid", context.getAppUuid());
                }
                // 最多等候一秒
                break;
            }
        }
        // 拼装组件
        assembleComponents(context);

从上面的完结能够看到,咱们先完结了一个使命的模板,该使命的模板便是在使命履行完毕后,履行一个回调类来判断是否告诉主线程来收集一切数据。

  • 线程同享变量
/**
     * 使命数目
     */
    private Integer threadTaskNum;
    /**
     * 原子计数器(完结的使命数目)
     */
    private volatile AtomicInteger completeCounter = new AtomicInteger(0);
    /**
     * 并发锁,用于操控线程之间的等候和告诉机制
     */
    private volatile Object lock = new Object();
    /**
     * 是否全量完结
     */
    private volatile Boolean finish = false;
    public ParallelContext(Integer threadTaskNum) {
        this.threadTaskNum = threadTaskNum;
    }
    /**
     * 不允许默许构造函数
     */
    private ParallelContext() {}
    /**
     * 使命完结
     */
    public void completeTask() {
        if (completeCounter.incrementAndGet() >= threadTaskNum) {
            finish = true;
        }
    }

咱们需求完结线程间的通讯,首要要必定一个lock,用来作为线程间通讯的机制,即主线程履行到需求拼装子线程数据的时分,就调用lock.wait来阻塞住,然后子线程在悉数履行完毕后调用lock.notifyAll来告诉主线程持续。其次,为了保证咱们一切的子线程都处理完毕后再告诉主线程,咱们需求计算使命的完结状况,即当使命完结一个的时分,就把使命加1,终究当使命完结数和初始化的线程数量相同的时分,咱们告诉主线程。

所以咱们界说了AtomicIntegercompleteCounter作为完结使命的计数器,当完结一个使命时,就调用completeCounter原子办法加1,直到一切使命都完结。

在一切子使命履行完毕之后,咱们主线程是要取得回来值,并终究把回来值聚合在主线程里边回来给客户的。所以有了上面的线程告诉机制,咱们就能够完结在主线程里等候子线程履行使命,当使命履行完毕后再聚合子线程的成果终究回来给客户。

经过压测,咱们发现全体功用相比改造前提升了约一倍。

多线程在”协作“的运用

在说完了第一个比方之后,咱们再说一下第二个比方。第二个比方也是一个实践生产上面的比方,产品才干便是”协作“,”协作“主要是把一个员工在钉钉上一切相关的事情进行聚合,而且分红几类,比方能够分红”最近代办的事“以及”最近重视的事情“以此来提高效率。

后续的产品还有更多其他办法的演化,可能除了这两类之外,还会扩展其他的类别,所以它是一个能够横向扩展的分类聚合。

一文读懂多线程背后的实际应用场景

在刚刚开始的时分,这样的事情是比较少的,所以咱们有个单线程就能够完结一切的事情的查询,可是跟着事务的改变,会发现事情越来越多,分类可能也越来越多。因而在功用上就开始变得越来越慢,碰到一些长尾数据的状况下(比方或人一天有几百个事情),当事情数变得过多的状况下的话,整个产品功用就变得缓慢了乃至卡顿导致手机Crash。

所以依据咱们这种场景,用多线程来处理就变得十分的适合了。那首要咱们这儿第一步要考虑的便是场景的解构。咱们能够在事情维度上面进行并发处理,咱们也能够在分类上面进行并发处理。那结合这个状况,咱们认为按照分类的办法来做并行的线程处理睬十分相对来说会比较简略,由于每一个类的事情是彼此独立的,所以用线程并发能够最快和最简略的对事务进行拆解。

因而咱们就能够考虑用并发的办法来完结,在开始的代码里边咱们只需求用两个线程来完结使命就能够了,由于只有两个分类。和咱们上面所说的相同,为了高效运用线程,下降编码的杂乱度,咱们也引证了线程池。所以咱们就有下面这一部分的结构代码。

  • 创立线程池
    /**
     * 创立线程池
     *
     * @param name          线程名称
     * @param maxPoolSize   线程池最大值
     * @param corePoolSize  线程池core大小
     * @param queueCapacity 行列长度
     */
    protected static void createThreadPool(String name, int maxPoolSize, int corePoolSize,
        int queueCapacity) {
        ThreadPoolExecutor threadPool = new ThreadPoolExecutor(corePoolSize, maxPoolSize, 60,
            TimeUnit.SECONDS, new LinkedBlockingQueue<>(queueCapacity), namedThreadFactory,
            new ThreadPoolExecutor.AbortPolicy());
        threadPools.put(name, threadPool);
    }

线程池的实质,便是一种”池化技能“。为了处理手动运用线程时需求频频的创立和毁掉的场景,运用线程池能够大幅度提升线程”复用“带来的效率,以一种更高雅的办法来运用和办理线程。类似的技能有数据库连接池和HTTP连接池技能。

  • 提交使命
for (Zone zone : response.allZone) {
            Future<Boolean> future = executorService.submit(() -> {
                // 3. 子线程内进行信息流查询
                taskExecutor.query(uid, orgId, zone, queryTime);
                return Boolean.TRUE;
            });
            futureList.add(future);
        }

线程池规划的最大特色便是解耦了线程和使命。运用者只需界说和完结使命即可,在需求线程履行的当地直接把使命传递给线程池即可。

  • 成果聚合
// 4. 主线程等候子线程完毕后再回来
        futureList.forEach(x -> {
            try {
                x.get(TimeOut, TimeUnit.MILLISECONDS);
            } catch (ExecutionException ee) {
                // 获取线程履行反常,打error日志
                LogUtil.error(ee);
            } catch (Exception e) {
                // 对接口超时的状况,打印warn日志
                LogUtil.warn(e);
            }
        });

关于上述的比方相对来说比较简略,由于不同的使命无需同享写变量,然后没有多线程的并发问题。只需每个使命依据当前传入的区域获取到对应的数据并对数据做相关的操作即可。

经过压测,咱们发现全体功用相比改造前提升了约一倍。

并发获取网页内容事例运用

  • 多线程主流程
//一个并发恳求网页HTML的多线程程序
public class MainTask {
    public static void main(String[] args) throws ExecutionException, 
    InterruptedException {
        //使命入参
        List<String> urls = Arrays.asList("http://www.javaer.com.cn/",
        "https://www.baidu.com/");
        //创立线程池
        ThreadPoolExecutor threadPool = new ThreadPoolExecutor(5, 10, 60,
                TimeUnit.SECONDS, new LinkedBlockingQueue<>(100), 
                new ThreadFactoryBuilder()
                .setNameFormat("thread-pool-%d").build(),
                new ThreadPoolExecutor.AbortPolicy());
        //提交使命
        List<Future<String>> result = new ArrayList<>();
        for(String url : urls){
            result.add(threadPool.submit(new UrlFetchTask(url)));
        }
        //获取使命成果
        for(Future<String> future : result){
            String urlData = future.get();
            System.out.println(urlData);
        }
        Thread.sleep(Integer.MAX_VALUE);
    }
}
  • 多线程子使命
/**
 * 使命,需求完结Callable接口
 */
public class UrlFetchTask implements Callable<String> {
    //入参,外部传入
    private String url;
    public  UrlFetchTask(String url){
        this.url = url;
    }
    @Override
    public String call() throws Exception {
        return UrlFetchTask.httpClientFetch(url,DEFAULT_CHARSET);
    }
    /**
     * HttpClient Get东西,获取页面或Json
     * @param url
     * @param charset
     * @return
     * @throws Exception
     */
    public static String httpClientFetch(String url, 
    String charset) throws Exception {
        // GET
        HttpClient httpClient = new HttpClient();
        httpClient.getParams().setContentCharset(charset);
        HttpMethod method = new GetMethod(url);
        httpClient.executeMethod(method);
        return method.getResponseBodyAsString();
    }
}

上述便是一个简略的多线程并发的比方,大家能够依据自己的事务状况,按照多线程结构的办法进行场景解构、界说线程池、界说线程通讯办法等过程进行多线程并发编程。

总结

所以综上所述,线程池在实践事务代码运用的场景里边的确比较少,也是由于便利开发者运用的原因,把线程池封装到各个结构里边,对一般开发者的感知不强。可是开发者仍是要依据具体的场景来说明是否需求引证多线程和线程池,由于多线程的引进势必会引进编程的杂乱度,假设不是对线程理解透彻的话,很容易带来失控的问题。

但咱们正好看到,其实在一些特别杂乱的场景里边,需求用到线程池来处理问题,也便是在初级程序员无法体系化处理这些功用问题的时分,可能多线程便是一个很好的手段,这个就要求高档程序员需求十分了解这么一套技能才干。而且经过实践的监控,咱们发现运用了多线程技能后,产品的功用的确能够翻倍的提高,这种优化成果是十分显着的。

总结起来,一方面了解了多线程这种底层技能,关于程序员加深对整个运用的底层结构的理解是十分有协助的,别的一方面,在处理一些特别杂乱特别需求功用优化的场景里,多线程本身便是一种很好的处理战略。

所以对了解一些技能底层的杂乱的技能仍是十分的有用的,这也是一个程序员的自我修养,不断寻求更优秀的技能,而且在实践中灵活运用。

更多原创内容,重视大众号:ali老蒋 或拜访网站:www.javaer.com.cn/