Java 多线程(三)优化任务执行

本篇文章通过服务器通信页面渲染两个功能的实现来加深多线程中FutureExecutor的理解。

服务器通信

串行执行任务

任务执行最简单的策略就是在单线程中串行执行各项任务,并不会涉及多线程。

以创建通讯服务为例,我们可以这样实现(很low)

    @Test
    public void singleThread() throws IOException {
        ServerSocket serverSocket= new ServerSocket(8088);
        while (true){
            Socket conn = serverSocket.accept();
            handleRequest(conn);
        }
    }

代码很简单,理论上没什么毛病,但是实际使用中只能处理一个请求。但是当处理任务很耗时并且在多次请求时会阻塞无法及时响应。
由此可见串行处理机制通常都无法提供高吞吐率或快速响应性。

显式的为任务创建线程

串行执行任务这么 low,我们来通过多线程来处理请求吧:当接收到请求后创建新的线程去执行任务。new Thread()应该就能实现。

初级版本:

    @Test
    public void perThreadTask() throws IOException {
        ServerSocket serverSocket = new ServerSocket(8088);
        while (true) {
            Socket conn = serverSocket.accept();
            Runnable r = new Runnable() {
                @Override
                public void run() {
                    handleRequest(conn);
                }
            };
            new Thread(r).start();
        }
    }

微弱的优点

  • 对于每个请求,都创建了一个线程来处理,达到多线程并行效果
  • 任务处理从主线程分离出来,使得主循环能更快的处理下一个请求

为每个任务分配一个线程存在一些缺陷,尤其当需要创建大量的线程时

  • 线程生命周期的开销非常高。根据平台的不同,实际的开销也不同。但是线程的创建过程都会需要时间,并且需要 JVM 和操作系统提供一些辅助操作。
  • 资源消耗。活跃的线程会消耗系统资源,尤其是内存。如果可运行的线程数量多余可用处理器的数量,那么有些线程将闲置。大量闲置的线程会占用许多内存,给垃圾回收器带来压力。如果你已经拥有足够多的线程使所有 CPU 保持忙碌状态,那么多余的线程反而会降低性能。
  • 稳定性。随着平台的不同,可创建线程数量的限制是不同的,并受多个因素制约,包括 JVM 的启动参数、Thread 构造函数中请求的栈大小,以及底层操作系统对线程的限制等。如果破坏了这些限制,很可能抛出 OOM 异常。

<h5>
上面两种方式都存在一些问题:单线程串行的问题在于其糟糕的响应性和吞吐量;而为每个任务分配线程的问题在于资源消耗和管理的复杂性。</h5>
<h5>
在 Java 类库中,任务执行的主要抽象不是 Thread,而是 Executor
</h5>

public interface Executor {
    void execute(Runnable command);
}

Executor 框架

Executor 基于生产者-消费者模式,提交任务的操作相当于生产者,执行任务的线程相当于消费者。

image

通讯优化

对于以前的通讯服务我们可以用 Executor 进一步优化一下

    @Test
    public void limitExecutorTask() throws IOException {
        final int nThreads  = 100;
        ExecutorService exec = Executors.newFixedThreadPool(nThreads);
        ServerSocket serverSocket = new ServerSocket(8088);
        while (true) {
            Socket conn = serverSocket.accept();
            Runnable r = new Runnable() {
                @Override
                public void run() {
                    handleRequest(conn);
                }
            };
            exec.execute(r);
        }
    }

线程池

线程池从字面来看时指管理一组同构工作线程的资源池。它与工作队列密切相关,它在工作队列中保存了所有等待执行的任务。

线程池通过重用现有的线程而不是创建新线程,可以在处理多个请求时分摊在线程创建和销毁过程中产生的巨大开销。另一个额外的好处是,当请求到达时,工作线程已经存在,因此不会由于等待创建线程而延迟任务的执行,挺高响应性。

JAVA 类库中提供了一个灵活的线程池以及一些有用的默认配置。可以通过 Executors 中的静态工厂方法来创建。

  • newFixedThreadPool将创建一个固定长度的线程池,每当提交一个任务时就创建一个线程,直到达到线程的最大数量。

  • newCacheedThreadPool将创建一个可缓存的线程池,如果线程池的当前规模超过了处理需求时,那么将回收空闲的线程,而当需求增加时,则可以添加新的线程,线程池的规模则不存在限制。

  • newSingleThreadPool是一个单线程的 Executor,它创建单个工作者线程来执行任务,如果这个线程异常结束,会创建另一个线程来替代。newSingleThreadPool能确保依照任务在队列中的顺序来串行执行。

  • newScheduledThreadPool 创建一个固定长度的线程池,而且以延迟或定时的方式来执行任务,类似于 Timer。

Executor 生命周期

为了解决执行服务的声明周期问题,Executor 扩展了 ExecutorService接口,添加了一些用于管理生命周期的方法shutdown(),shutdownNow(),isShutdown(),isTerminated(),awaitTermination()

ExecutorService的生命周期有3中状态:运行、关闭和已终止。初始创建时处于运行状态。

  • shutdown() 方法将执行平缓的关闭过程:不再接受新的任务,同时等待已经提交的任务执行完成,包括那些还未开始执行的任务。
  • shutdownNow() 方法将执行粗暴的关闭过程:它将尝试取消所有运行中任务,并且不再启动队列中尚未开始执行的任务。
    等待所有任务完成后,ExecutorService 将转入终止状态。可以调用awaitTermination来等待到达终止状态,或者通过isTerminated来轮询是否已终止。

服务器通讯初步牛批版本

class LifecycleWebServer {
        private ExecutorService exec;

        public void start() throws IOException {
            ServerSocket socket = new ServerSocket(80);
            while (!exec.isShutdown()) {
                try {
                    Socket conn = socket.accept();
                    exec.execute(new Runnable() {
                        @Override
                        public void run() {
                            handleRequest(conn);
                        }

                    });
                }catch (RejectedExecutionException e){
                    if (!exec.isShutdown()){
                        System.out.println("task submission reject::"+e);
                    }
                }

            }
        }

        public void stop(){
            exec.shutdown();
        }

        void handleRequest(Socket conn) {
            Request req = readRequest(conn);
            if(isShutdownRequest(req)){
                stop();
            }else {
                dispatchRequest(req);
            }
        }

        private void dispatchRequest(Request req) {
            //......分发请求
        }

        private boolean isShutdownRequest(Request req) {
            //......判断是否是 shutdown 请求
        }

        private Request readRequest(Socket conn) {
            //......解析请求
        }
    }

通过 ExecutorService 增加对任务生命周期的管理。

延迟任务与生命周期

Timer是作者使用较多的任务类,主要用来管理延迟任务以及周期任务。因为 Timer 本身还是存在一些缺陷:

  • Timer在执行所有定时任务时只会创建一个线程。如果某个任务的执行时间过长,那么将破坏其他 TimerTask 的定时精确性。
    public void timerTest() {
        Timer timer = new Timer();
        System.out.println("Timer Test Start " +new Date());
        timer.schedule(new TimerTask() {
            @Override
            public void run() {
                System.out.println("001 working current " +new Date());
                try {
                    Thread.sleep(4*1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println("001 working current " +new Date());
            }
        },1000);
    
        timer.schedule(new TimerTask() {
            @Override
            public void run() {
                try {
                    Thread.sleep(1000);
                    System.out.println("002 working current " +new Date());
    
                    Thread.sleep(1000);
                    System.out.println("002 working current " +new Date());
    
                    Thread.sleep(1000);
                    System.out.println("002 working current " +new Date());
    
                    Thread.sleep(1000);
                    System.out.println("002 working current " +new Date());
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        },2000);
    }
    

打印 log:

    Timer Test Start Tue Dec 10 11:52:44 CST 2019
    001 working current Tue Dec 10 11:52:45 CST 2019
    001 working current Tue Dec 10 11:52:49 CST 2019
    002 working current Tue Dec 10 11:52:50 CST 2019
    002 working current Tue Dec 10 11:52:51 CST 2019
    002 working current Tue Dec 10 11:52:52 CST 2019
    002 working current Tue Dec 10 11:52:53 CST 2019

从时间戳上可以看出两个 TimerTask 是串行执行的。时间调度出现了问题

  • 另一个是线程泄露问题:当 TimerTask 抛出一个未检查的异常,那么 Timer 将表现出糟糕的行为。Timer 线程并不捕获异常,因此当 TimerTask 抛出未检查的异常时将终止定时线程,并且不会恢复线程的执行。

请尽量减少或者停止 Timer 的使用,ScheduledThreadPoolExecutor能够正确处理这些表现出错误行为的任务。

    public void testScheduled(){
        ScheduledThreadPoolExecutor executor = (ScheduledThreadPoolExecutor) Executors.newScheduledThreadPool(10);
        System.out.println("scheduled test " + new Date());
        ScheduledFuture<?> work1 = executor.schedule(new Callable<String>() {
            @Override
            public String call() throws Exception {
                try {
                    Thread.sleep(4000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println("001 Worker " + new Date());
                return "work1 finish";
            }
        }, 1, TimeUnit.SECONDS);
        ScheduledFuture<?> work2 = executor.schedule(new Callable<String>() {
            @Override
            public String call() throws Exception {
                try {
                    Thread.sleep(1000);
                    System.out.println("002 Worker " + new Date());
                    Thread.sleep(1000);
                    System.out.println("002 Worker " + new Date());
                    Thread.sleep(1000);
                    System.out.println("002 Worker " + new Date());
                    Thread.sleep(1000);
                    System.out.println("002 Worker " + new Date());
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                return "work2 Finish";
            }
        }, 2, TimeUnit.SECONDS);
    }

输出 log:

scheduled test Tue Dec 10 15:54:10 CST 2019
002 Worker Tue Dec 10 15:54:13 CST 2019
002 Worker Tue Dec 10 15:54:14 CST 2019
001 Worker Tue Dec 10 15:54:15 CST 2019
002 Worker Tue Dec 10 15:54:15 CST 2019
002 Worker Tue Dec 10 15:54:16 CST 2019

从 log 来看,时间调度上符合我们的预期,棒棒哒。

页面渲染

来自面试官的提问:浏览器是怎样加载网页的?

方法一:使用简单串行

最简单的方法是对HTML文档进行串行处理。当遇到文本标签时,将其绘制到图像缓存中。当遇到图像引用时,先通过网络获取,然后再将其绘制到图像缓存中。这种方式算是一种思路,但是可能会令使用者感到方案,他们必须等待很长时间,直到显示所有的文本。

    @Test
    public void singleThreadRender() {
        CharSequence source = "";
        renderText(source);
        List<ImageData> imageDatas = new ArrayList<>();

        for (ImageInfo imageInfo : scanForImageInfo(source)) {
            imageDatas.add(imageInfo.downloadImage());
        }

        for (ImageData imageData : imageDatas) {
            renderImage(imageData);
        }
    }

了解 CallableFuture

Executor框架使用 Runnable作为其基本的任务表示形式。Runnable是一种有很大局限的抽象,虽然能够异步执行任务,但是它不能返回一个值或者抛出受检查的异常。

许多任务实际上都是存在延迟的计算(像执行数据库查询、从网络上获取资源、或者计算某个复杂的功能)。对于这些任务,Callable是一种更好的抽象:它认为主入口点应该返回一个值,并可能抛出一个异常。

RunnableCallable描述的都是抽象的计算任务。这些任务通常都应该有一个明确的起始点,并且最终会结束。Executor执行任务有4个生命周期阶段:创建、提交、开始和完成。由于有些任务可能需要很长的时间,因此通常希望能够及时取消。再 Executor框架中,已提交但尚未开始的任务可以取消,但是对于那些已经开始的任务,只有当它们能响应中断时,才能取消。

Future表示一个任务的生命周期,并提供了相应的方法来判断任务是否已经完成或取消,以及获取任务的结果和取消任务等。在 Future 规范中包含的隐含意义是,任务的声明周期只能前进,不能后腿,就像ExcutorService 的生命周期一样。当某个任务完成后,它就永远停留在完成状态上。

Future 包含如下方法:

interface Future{
    boolean cancel()
    boolean get()
    boolean isCancelled()
    boolean isDone()
}

get()方法的行为取决于任务的状态(尚未开始、正在运行、已完成)。如果任务已完成,方法会立即返回或者抛出一个异常;如果任务没有完成,方法 将阻塞直到任务完成。

可以通过多种方法创建一个Future来描述任务。ExecutorService中的所有的 submit 方法都将返回一个Future,从而将一个Runnable或者Callable提交给 Executor,并得到一个 Future用来获取任务的执行结果或者取消任务。

方法二:使用Future实现渲染

为了使页面渲染具有更高的并发性,我们分解成两个任务:一个是渲染所有的文本(CPU 密集型);另一个是下载所有的图像(I/O 密集型)。

CallableFuture有助于协同任务之间的交互。

    @Test
    public void futureRender() {
        CharSequence source = "";
        ExecutorService executor = Executors.newFixedThreadPool(10);

        List<ImageInfo> imageInfos = scanForImageInfo(source);
        Callable<List<ImageData>> task = new Callable<List<ImageData>>() {
            @Override
            public List<ImageData> call() throws Exception {
                List<ImageData> result = new ArrayList<>();
                for (ImageInfo imageInfo : imageInfos) {
                    result.add(imageInfo.downloadImage());
                }
                return result;
            }
        };

        Future<List<ImageData>> future = executor.submit(task);
        renderText(source);

        try {
            List<ImageData> imageDatas = future.get();
            for (ImageData imageData : imageDatas) {
                renderImage(imageData);
            }
        } catch (InterruptedException e) {
            e.printStackTrace();
            Thread.currentThread().interrupt();
            future.cancel(true);
        } catch (ExecutionException e) {
            e.printStackTrace();
        }

    }

futureRender使得渲染文本与下载图像数据的任务并发执行,当所有图像下载完成后,会显示到页面上。对比串行版本已经提高了效率和用户体验。但我们还可以做得更好,我们不必等到所有的图像都下载完成,而是希望没下载完一副图像就显示出来。

了解CompletionService

CompletionService的实现类是ExecutorCompletionService,它将ExecutorBlockingQueue的功能融合在一起。

如果想及时获取任计算的结果,按照前面的思路我们可以先保留任务提交Executor后返回的 Future,然后不断的调用get()方法来获取。这种方式虽然可行,但是不够优雅。幸运的是有CompletionService

请仔细阅读take()方法说明:

    /**
     * Retrieves and removes the Future representing the next
     * completed task, waiting if none are yet present.
     *
     * @return the Future representing the next completed task
     * @throws InterruptedException if interrupted while waiting
     */
    Future<V> take() throws InterruptedException;

take() 会取出并从队列移除已完成的任务。so,我们可以这样实现:

使用CompletionService 实现页面渲染

    @Test
    public void completionServiceRender(ExecutorService executor, CharSequence source) {
        List<ImageInfo> info = scanForImageInfo(source);
        CompletionService<ImageData> completionService = new ExecutorCompletionService<>(executor);
        for (ImageInfo imageInfo : info) {
            completionService.submit(new Callable<ImageData>() {
                @Override
                public ImageData call() throws Exception {
                    return imageInfo.downloadImage();
                }
            });
        }
        renderText(source);

        try {
            int taskSize = info.size();
            for (int i = 0; i < taskSize; i++) {
                Future<ImageData> f = completionService.take();
                ImageData data = f.get();
                renderImage(data);
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        } catch (ExecutionException e) {
            e.printStackTrace();
        }

    }

为任务设置时限

新需求:对于耗时任务,等待特定时间后仍未完成,则取消任务。

需求合情合理。这种情况下,我们可以使用Futureget()方法,官方描述如下:

/**
     * Waits if necessary for at most the given time for the computation
     * to complete, and then retrieves its result, if available.
     *
     * @param timeout the maximum time to wait
     * @param unit the time unit of the timeout argument
     * @return the computed result
     * @throws CancellationException if the computation was cancelled
     * @throws ExecutionException if the computation threw an
     * exception
     * @throws InterruptedException if the current thread was interrupted
     * while waiting
     * @throws TimeoutException if the wait timed out
     */
    V get(long timeout, TimeUnit unit)
        throws InterruptedException, ExecutionException, TimeoutException;
  • 两个参数:等待的时间、时间单位。
  • 请注意抛出的异常,我们可以通过捕获TimeoutException来处理超时情况。
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,293评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,604评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,958评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,729评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,719评论 5 366
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,630评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,000评论 3 397
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,665评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,909评论 1 299
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,646评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,726评论 1 330
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,400评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,986评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,959评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,197评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 44,996评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,481评论 2 342