Android 并发开发

978-7-115-48961-6
作者: [美]G.布莱克·梅克(G. Blake Meike)
译者: 师蓉
编辑: 吴晋瑜

图书目录:

详情

本书共8章,第1章介绍了一个非典型的并发模型,为后文的阐释做好铺垫,第2章和第3章分别介绍了Java并发和Android应用程序模型,第4章介绍AsyncTask和Loader,第5—7章是本书的核心内容,深入探讨Android操作系统的细节,如Looper/Handler、Service、Binder、定时任务等。第8章介绍并发工具,如静态分析、注解、断言等。

图书摘要

版权信息

书名:Android 并发开发

ISBN:978-7-115-48961-6

本书由人民邮电出版社发行数字版。版权所有,侵权必究。

您购买的人民邮电出版社电子书仅供您个人使用,未经授权,不得以任何方式复制和传播本书内容。

我们愿意相信读者具有这样的良知和觉悟,与我们共同保护知识产权。

如果购买者有侵权行为,我们可能对该用户实施包括但不限于关闭该帐号等维权措施,并可能追究法律责任。

著    [美] G.布莱克•梅克(G.Blake Meike)

译    师 蓉

责任编辑 吴晋瑜

人民邮电出版社出版发行  北京市丰台区成寿寺路11号

邮编 100164  电子邮件 315@ptpress.com.cn

网址 http://www.ptpress.com.cn

读者服务热线:(010)81055410

反盗版热线:(010)81055315


Authorized translation from the English language edition, entitled Android Currency, 1st Edition, ISBN: 0134177436 by MEIKE, G. BLAKE, published by Pearson Education, Inc. Copyright © 2016 Pearson Education, Inc.

All rights reserved. No part of this book may be reproduced or transmitted in any form or by any means, electronic or mechanical, including photocopying, recording or by any information storage retrieval system, without permission from Pearson Education, Inc.

CHINESE SIMPLIFIED language edition published by POSTS AND TELECOMMUNICATIONS PRESS, Copyright © 2018.

本书中文简体版由Pearson Education,Inc授权人民邮电出版社出版。未经出版者书面许可,不得以任何方式或任何手段复制和抄袭本书内容。

本书封面贴有Pearson Education(培生教育出版集团)激光防伪标签,无标签者不得销售。 版权所有,侵权必究。


本书共8章。第1章介绍一个非典型的并发模型,为后文的阐释做好铺垫。第2章和第3章分别介绍Java并发和Android应用程序模型,主要介绍Java线程、同步、并发包、生命周期和组件、Android进程等基本概念。第4章介绍AsyncTask和Loader。第 5 章~第 7 章是本书的核心内容,深入探讨Android操作系统的细节,如Looper/ Handler、Service、Binder、定时任务等。第8章介绍并发工具,如静态分析、注解、断言等。

本书适合有一定Android开发经验的读者阅读。如果你是一名新手,建议你在掌握相关入门知识的基础上阅读本书,以达到更好的学习效果。本书给出了多段代码,旨在让读者亲自实践后更好地掌握Android并发开发的相关内容。


我有幸在这个行业工作多年,看到了很多背景下的并发。我在学校读书时,并发只是一个论文主题。作为一名熟练的开发人员,我看到分布式后端系统中的并发代码几乎都是用Java编写的。最近,我有机会亲身体验Erlang和Scala这样的语言,希望能使并发代码的设计和编写更容易。

在我职业生涯早期,一位非常支持我的面试官指导我重新设计了双重检查锁。我还记得自己在一年后发现双重检查锁模式不安全,并在不久后遇到了第一个不正确的字节码时的那种心情。然而,最令人惊讶的也许是我最近从2015年编写的代码中删除了双重检查锁模式的那次实践。

在此期间,不变的是这个话题的神秘性和争议。必须使用并发代码时,即便是能够完美胜任工作的老手也会突然犯很幼稚的错误。开发人员有时会就某段并发代码的正确性产生争议(偶尔会很强烈)。他们可能会争论好几个小时,最后不可避免地终结于脆弱的细节,谁赢谁输却是无关紧要的。

必须承认的是,在那场面试中解决“双重检查锁”时,我感到很开心。最近,在听某些演讲者讲述一些快速而松散的并发技巧(通常是完全错误的)时,我想我在他们和听众脸上看到了同样的兴奋。

找到一个非常聪明的算法和一种非常快乐的感觉是很美妙的事情。如果我们能摒弃并发编程的神秘和魔法,就可以编写出更好的代码。如果并发代码的正确性是两个开发者(甚至是两个具有不同兴趣的开发者)都同意的东西,那就太好了!

本书是为那些具有Android开发经验的开发者准备的。

如果你是一名新手开发人员,你可能会发现一些不熟悉的术语和概念。如果你是正在开发自己第一个Android应用程序的开发人员,你可能更关心如何简单地熟悉Android框架。

如果你属于上述类型中的一种,本书暂时不适合你!如果真是这样,我建议你先把本书放在一边,去学习一些基础的东西,当你完成了一个完整的Android应用程序后再阅读本书。

我写这本书是为了让读者阅读的,虽然这听起来有点像是废话。它既不是一本教科书,也不是一本参考手册。我鼓励你自己试一下示例代码。这些示例只是沙箱实验,你可以试着扩展它们。你可以借此进行新实验,以形成自己对Android OS细节的理解。然而,我不希望你把这本书放在笔记本旁边来从中获取价值。

我有一段时间着迷于Perl。回想一下,我认为主要的原因是发现那本“骆驼书”《Learning GrassMudHorse Programming Language》太有意思了(不是粉色的那本,是蓝色的那本—第2版)。我回想起很多远离键盘的快乐时光,脑子里没有具体的应用程序,只是单纯地阅读那本书。

我不是要拿自己和Larry Wall比较,也不是要用什么方式比较Android和Perl。我只是希望你能喜欢阅读这本书。我希望这本书会是你坐飞机或者长途通勤路上的好 伙伴。

本书的前3章为你提供了一次复习机会。

我建议你至少不要跳过第1章。我在这一章介绍了一个非典型的并发模型,这个并发模型是后文讨论的基础。

我故意将第2章和第3章设计得非常短。这两章是对一些基本概念的复习,并给你提供了一个重新认识一些常见用法的机会。有经验的开发者可以选择跳过这两章。

第4章是一个警示故事。

本书的核心是第5章~第7章。这3章深入探讨了Android操作系统的细节。

第8章是“餐后甜点”— 一些并发工具的指南。

本书示例中的大部分代码可以在GitHub上找到。如果你在实验这些代码的过程中发现了一些有趣的东西,一定要和其他人分享。

为方便下载、更新和修正,我建议你在informit官网上注册自己所购买的这本书。要开始注册过程,请先登录或者创建一个账户。输入ISBN 9780134177434并提交。一旦流程完成,你就可以在“已注册产品”中找到可用的奖励内容。

本书中使用的印刷规范如下。


本书由异步社区出品,社区(https://www.epubit.com/)为您提供相关资源和后续服务。

作者和编辑尽最大努力来确保书中内容的准确性,但难免会存在疏漏。欢迎您将发现的问题反馈给我们,帮助我们提升图书的质量。

如果您发现错误,请登录异步社区,搜索到本书页面,单击“提交勘误”,输入相关信息,单击“提交”按钮即可。本书的作者和编辑会对您提交的勘误进行审核,确认并接受后,赠送给您异步社区的100积分(积分可用于在异步社区兑换优惠券,或者用于兑换样书或奖品)。

我们的联系邮箱是contact@epubit.com.cn。

如果您对本书有任何疑问或建议,请您发邮件给我们,并请在邮件标题中注明本书书名,以便我们更高效地做出反馈。

如果您有兴趣出版图书、录制教学视频,或者参与图书翻译、技术审校等工作,可以发邮件给我们,有意出版图书的作者也可以到异步社区在线提交投稿(直接访问www.epubit.com/ selfpublish/submission即可)。

如果您是学校、培训机构或企业,想批量购买本书或异步社区出版的其他图书,也可以发邮件给我们。

如果您在网上发现有针对异步社区出品图书的各种形式的盗版行为,包括对图书全部或部分内容的非授权传播,请您将怀疑有侵权行为的链接发邮件给我们。您的这一举动是对作者权利的保护,也是我们持续为您提供有价值的内容的动力之源。

“异步社区” 是人民邮电出版社旗下IT专业图书社区,致力于出版精品IT技术图书和相关学习产品,为作译者提供优质出版服务。异步社区创办于2015年8月,提供大量精品IT技术图书和电子书,以及高品质技术文章和视频课程。更多详情请访问异步社区官网https://www.epubit.com。

“异步图书” 是由异步社区编辑团队策划出版的精品IT专业图书的品牌,依托于人民邮电出版社近30年的计算机图书出版积累和专业编辑团队,相关图书在封面上印有异步图书的LOGO。异步图书的出版领域包括软件开发、大数据、AI、测试、前端、网络技术等。

异步社区

微信服务号


本书能够出版要感谢很多人。我的老同事Zigurd Mednieks提议我写这样一本书。Pearson Technology Group出版社的主编Laura Lewin给它开了绿灯,Jelen Publishing的文稿代理人Carol Jelen促成了这本书的出版。

Pearson的制作人员灵巧地将手稿变成了一本书。插图画家Jenny Huckleberry修整了本书的图形,开发编辑、文字编辑Abigail Manheim Bass和执行编辑Lori Lyons修改了手稿。编辑助理Olivia Basegio和项目经理Ellora Sengupta和我们一起完成了这个过程。在此致以我诚挚的谢意。

非常感谢主编Laura Lewin,谢谢她耐心地鼓励我坚持下去。

虽然我是本书的唯一作者,但我一度想把技术编辑Joe Bowbeer、Thomas Kountis和Zigurd Mednieks列为合著者,因为他们给了我太多的帮助。他们就是技术审查团队的完美成员。作为这个领域公认的专家,他们花时间仔细阅读、理解,然后建议我进行大大小小的修改。如果本书还有错误,这些错误都是我造成的,而表达的清楚和准确都要归功于他们。我非常有幸能得到他们的帮助。

最后要感谢我的妻子Catherine,感谢你容忍我又在沙发上戴着耳机度过了一个周末。我非常爱你,宝贝!


我们建议用延迟τ作为可信赖的同步设备各部分功能的绝对时间单位。

约翰·冯·诺依曼

要构建正确的并发Android程序,开发人员需要有一个好的并发进程模型,那么这些模型是如何工作的?它们的用途是什么?对于大多数人来说,并发并不是什么大问题。对于任何多细胞动物(甚至是对病毒)来说,并发只是正常的存在。也只有我们这些沉迷于计算机的人,才会再三考虑一边走路一边嚼口香糖的想法。

一边走路一边嚼口香糖在约翰·冯·诺依曼博士的世界里并非易事。1945年,他在论文《The First Draft Report on the EDVAC》(冯·诺依曼,1945)里描述了最早的电子数字计算机的体系结构。70多年来,这个体系结构几乎没有多大变化。大致来讲,纵观它们的历史,数字计算机这个巨大的状态球随着时间的推移被一系列精确定义的操作所转换。时间和顺序是机器定义的内在组成部分。

大多数计算机科学一直在讨论将一种机器状态转换成另一种更理想状态的巧妙操作序列。由于现代机器通常有超过1014种可能的状态,因此很难对所有状态进行管理。如果转换发生的顺序可以改变,讨论必然会扩大到包括所有可能状态的所有可能组合,这将是完全不可能的。顺序执行是王道。

当然,计算机语言是为人类编写的。它们的目的是帮助人们高效、正确,甚至以未来读者能够理解的方式来表达一种算法(转换机器状态的指令序列)。

早期的编程语言本质上是硬件的扩展。甚至在今天,很多编程语言最初设计只是用于控制机器体系结构的映像。它们绝大多数是过程化的,由用于修改(突变)内存状态的指令列表组成。由于很难对内存的所有状态进行推理,因此随着时间的推移,语言对允许开发人员表达的状态变化日渐严格。查看编程语言设计历史的一种方法是找出一种允许开发人员轻松表达正确算法的系统,而不是表达不正确的算法。

最早的语言是机器语言,即将计算机指令一对一地翻译的代码。机器语言不可取的原因有两个:首先,甚至表达一个非常简单的想法可能都需要几十行代码;其次,很容易表达错误。

随着时间的推移,为了限制和管理程序修改状态的方式,语言缩小了选择范围。例如,大多数语言都从限制程序执行指令之间的任意跳转变为使用现在熟悉的条件、循环和过程调用。模块和OOP(面向对象的编程)遵循的方式是将程序分解成小的、容易理解的块,然后限制这些块的交互方式。这种模块化、积木式的方式让现代语言更抽象且更富有表现力。有的语言甚至有完善的类型系统——有助于防止错误。然而,修改机器状态的指令列表仍然是必需的。

1.函数式编程

虽然大多数计算机研究者和开发者关注的都是在基于冯·诺依曼体系结构的硬件上做更复杂的事情(这些硬件体积更大、速度更快),但一个小而执着的团队在追求一种完全不同的想法——函数式编程

纯函数式编程和过程化编程的区别在于它没有可变状态。函数语言不是对机器状态的连续变化进行推理的,而是求解给定参数的函数值。这是一种相当激进的想法,需要思考才能理解它是如何工作的。然而,如果可以从并发角度看,它有一些非常吸引人的方面,尤其是如果没有可变状态,就不存在隐式时间或顺序。如果没有隐式顺序,并发就只是一个无趣的同义反复。

在创建第一个广为大众所接受的过程语言Fortran仅一两年后,John McCarthy于1958年引入了第一个函数语言Lisp。从那时起,Lisp等函数语言(Scheme、ML、Haskel、Erlang等)就被视为卓越但不切实际的教育工具或者时髦开发人员的口头禅。由于摩尔定律(摩尔,1965)更容易预测一个芯片上的处理器数(而不是单个处理器的速度),因此人们不再对函数语言不屑一顾[到1975年,摩尔修正了最初的想法,让这一概念正式化,他说集成电路(IC)的数量每两年翻一番]。

函数式编程是并发编程的一个重要策略,可能在将来变得更重要。Android使用的语言Java不适合作为函数式语言,当然不支持与大多数语言相关的复杂类型系统。

2.语言作为约定

不管是函数式还是过程化,编程语言都是抽象的。现在只有一小部分开发人员需要接近机器语言。即使是这一小部分开发人员,他们也可能是在由软件虚拟机或者芯片固件实现的虚拟指令集上编写代码。唯一可能想要详细了解特定硬件指令集精确行为的开发人员是为其编写编译器的开发人员。

因此,用某种语言编写程序的开发人员期望通过对该语言断言来了解该程序的行为。开发人员会对编写程序的语言(抽象)进行推断,并且几乎不需要通过检查实际的机器代码来证明程序是正确的(或者不正确的)。例如,他可能会推断某件事情发生14次的原因是循环计数器初始化为13,在每次循环时递减1,并且循环在计数器为0时终止。

这非常重要,因为大多数语言都是命令式(而不是函数式)抽象。即使是硬件、寄存器、高速缓存、指令管道和时钟周期,通常也都不会在程序设计中出现,但当我们对程序进行推理时,仍然会对序列进行推理。

讽刺的是,过程化语言最初是控制体系结构的反映,而不代表计算机硬件的行为。虽然早期计算机的CPU可能只在其内部时钟的每个节拍进行一次操作,但所有现代处理器都同时执行多个任务。在等待单个操作完成的过程中,闲置一个40亿门集成电路上的1/4晶体管毫无意义。

为什么一切都有顺序?

 

顺序执行可能是人类理解事物的固有部分。也许我们先将它强加给硬件设计,然后在语言设计中延续,因为它是我们思维方式的反映。

硬件是物质的东西。它是现实世界的一部分,而现实世界绝对不是连续的。现代的硬件是并行的。

除了在并行处理器上运行,现代程序越来越频繁地与一个疯狂并行的世界交互。即使是具有普通功能的手机用户也在不断地进行多个任务:一边浏览网页一边听音乐,或者接听突然打来的电话。同时,传感器、硬件按钮、触摸屏和传声器都同时向程序发送数据。维持“顺序性”的错觉很有挑战性。

开发人员处于奇怪的位置。如图1.1所示,他正在与并行世界交互的程序中,为在一个高度并行的处理器上运行的顺序抽象构建一个指令序列。

图1.1 并发世界中的顺序程序

到目前为止,讨论的是重新构建并发的思路,并发不是让程序运行得更快的方式,也不是程序员执行多个任务的复杂把戏。相反,程序的明显顺序执行才是复杂的技巧。顺序执行是编译器编写者和硬件架构师维护的假象。并发是对所制造约束的放松。

在开发人员的环境中,时间和顺序是严格且隐含的约束,“并发”只是“未指明顺序”的另一种说法——到处都有事情发生的方式。一个并发程序只是向已并发的世界宣布其正确性不依赖于事件在单独组件中发生的顺序。在并发程序中,那些独立、部分有序的组件称为执行线程或者线程。在一个线程中,指令仍然按照严格的顺序执行。但在两个单独的线程中,指令执行的顺序完全未指定。

在早期的计算中,线程作为并发模型的选择并不明显。需要无序执行的开发人员被迫自行生成并发结构。20世纪60年代的文献和代码都包含各种各样的异步执行模型。

线程可能起源于20世纪60年代末IBM的OS/360。它们称为“任务”,是一种操作系统级服务,为开发人员省去了自己构建并发抽象的麻烦。1991年,Java(当时称为Oak)采用线程模型并在语言中支持它,这时操作系统还不支持线程。

即使在今天,线程也不是唯一的并发模型。例如,Erlang、Go和Clojure等语言都使用完全不同的模型。

在编程模型中引入线程并不存在实质问题。并行操作两辆车没有任何问题,除非它们尝试同时占用同一个空间。同样,操作两个完全独立的线程也完全是安全的。此时此刻,有几百万个程序同时在几百万台独立计算机中运行,其中每个程序都在自己的执行线程中并发运行。其中的大多数程序不以任何方式进行交互,并且它们的行为定义得很好。只有当线程需要共享状态和资源时才会出现问题。

当多个线程修改同时访问的状态时,结果就很容易变得不确定。由于语句的执行顺序之间没有关系,因此时序的细微变化会改变程序运行的结果。

考虑下面的代码:

executionCount++;
someTask();

只通过查看,会认为executionCount是计算函数someTask被调用的次数。但在并行环境中,这段代码其实没有确定的行为,因为++操作不是原子的——它不是单一、不可分割的行为。表1.1演示了一个无法记录一次执行的执行序列。

表1.1 非原子执行

executionCount = 4
线 程 1 线 程 2
读取执行计数(4) 读取执行计数(4)
递增(5) 递增(5)
存储执行计数(5) 存储执行计数(5)
调用someTask 调用someTask

同步是多个Java线程共享状态的基本机制,这样的交互结果就是确定的。同步本身是一个非常简单的想法:一段由互斥锁或互斥量保护的代码临界区。当一个线程进入临界区(也就是说,它开始从此处执行指令)时,就称为抢占互斥量。在第一个线程离开前,没有线程可以进入临界区。

只有当任何时候都只允许一个线程访问临界区时,前面的代码才是确定的:

synchronized(this) {
    executionCount++;
    someTask();
}

同步是创建正确的并发Java程序的关键,并且是很多非简单事情的基础。本书后续章节将介绍这些内容。

然而,还有一件很简单的事情需要记住,上面的代码是抽象的!它是用计算机语言(这里是Java)编写的,因此与编译器编写者、JVM开发人员和硬件架构师编写的硬件实际行为有关。这两种Java语句翻译成上百条微指令,其中很多微指令在几十个硬件时钟周期中并行执行。这两条语句按顺序执行只不过是一种假象。

维持这种假象并不是底层开发人员自然而然做的事情。相反,他们发现顺序程序幼稚、笨拙且浪费时间。他们非常乐意通过重新排列指令、并行执行多个指令和将一种程序状态表现为多个副本等方式来修复这些程序。这样,他们就能尽全力充分利用多处理器(包括人们口袋里的微小设备)的巨大力量。

一般来说,我们很乐意让他们执行这些优化。他们通过使用应用程序开发人员不感兴趣的技巧,让程序在多种硬件平台上运行得更快。然而,这种优化有一个重要条件:它们不能破坏顺序性的假象!也就是说,编译器和硬件管道可以重新排列和并行处理所有想优化的代码,只要开发人员不说出他们这样操作即可。

让程序并发时,开发人员要清楚地指出由不同线程控制的状态之间没有顺序依赖关系。如果没有顺序依赖关系,编译器就可以任意执行所有本来不安全的优化。由于不同线程的事件之间没有明确的顺序,因此编译器可以随意地改变一个线程的执行顺序,而不用考虑其他线程中的语句。

一个正确的并发程序会遵守维持假象的约定。应用程序开发人员和硬件开发人员之间的谈判产生一种语言,而这种语言就是一个约定。应用程序开发人员得到了顺序执行的假象,这是他们可以推理的东西。硬件开发人员得到了一个工具箱,他们可以用它来让程序快速运行。处于中间的就是约定。

在Java中,这个约定称为内存模型。约定的一方是应用程序员,他会用高级语言推理程序。约定的另一方是编译器编写者、虚拟机开发人员和硬件架构师,他们会移动所有未明确禁止的内容。在讨论并发性时,谈论硬件的开发人员没有抓住要点。一个正确的并发程序不是硬件,而是遵守约定的开发人员。

幸运的是,约定在Java中很容易陈述。下面的话几乎完全说明了这一点;

当多个线程访问一个给定的状态变量,并且其中的一个想要写入时,它们必须使用同步协调对其的访问。

(Göetz等,2006年)

一个正确的并发Java程序是一个能够正好(不多不少)遵守这个约定的程序。要特别注意的是,线程是否读取或写入可变状态并不影响其同步的必要性。

并发本身并不可怕,我们在现实世界中整天都在处理它,难的是为基于可变状态的计算机编写并发程序。这很难,因为顺序概念是我们推断程序的一个重要隐式基础。

并发缓和了命令式计算机语言中固有的严格顺序。Java中的机制就是线程,一个线程执行指令的顺序与其他线程中指令执行的顺序无关。开发人员使用互斥锁(互斥)控制线程访问代码的临界区,从而限制了两个不同线程在临界区执行指令的数量。

现代大多数计算机语言都会产生顺序执行的假象。然而,它们在幕后会使劲重新排列、并行化和缓存以充分利用硬件。阻止这些优化让程序行为变得不确定的只有约定,一个正确的程序会遵守这个约定。

从来没有人说过并发容易。但它也确实很简单——只要遵守约定即可。


当多个线程访问一个给定的状态变量,并且其中的一个想要写入时,它们必须使用同步协调对其的访问。

Brian Göetz

Java是最早包含并发的语言之一。在低级中嵌入语言是创建并发程序所必需的结构。此外,Java可以很方便地移植到多个硬件架构上,它还第一个定义了内存模型并指定其并发构建的确定性行为。

由于Android的并发架构是基于Java的,因此本章是对Java基本并发机制的回顾。当然,可能需要一本书才能完整描述Java中的并发,所以我们的目的只是让你回想起这个概念。

注意

 

Java并发编程的相关资源可参考《Java并发编程实践》 (Göetz和Peierls等)。任何想要使用Java并发编程的人(包括所有Android开发人员)都应该阅读这本书。

在Java中,Thread类实现线程。每个正在运行的应用程序都有一个隐式线程,通常称为“默认”或者“主”线程。产生一个新线程包含创建一个Thread类的实例,然后调用其start方法。

注意

 

在Android中,“主”线程通常也称为“UI”线程,因为它支持大多数UI组件。这个名字可能令人感到困惑,实际上有多个运行UI的线程,并且主线程甚至支持没有UI组件的应用程序。

startrun这两个Thread方法非常特别。start方法没有参数和返回值,从调用代码的角度来看,它根本什么都没有做,只是返回并将控制权传送给程序中的下一条语句。

然而,神奇的是这个调用实际上返回两次,一次是返回到程序的下一条语句,另一次返回Thread对象run方法调用产生的一个执行线程中。这个新线程在run方法中依次执行语句,直到该方法返回为止。当run方法返回时,这个新线程就终止了。

清单2.1是一个使用Java线程的简单示例。

清单2.1 产生一个线程

public class ThreadedExample {
    public static void main(String... args) {
        System.out.println("starting: " + Thread.currentThread());
        new Thread() {
            @Override
            public void run() {
                System.out.println("running: " + Thread.currentThread());
            }
        }.start();
        System.out.println("finishing: " + Thread.currentThread());
    }
}

该程序在运行时会打印3条消息,然后终止。它定义了一个Thread的匿名子类,这个子类重写了其run方法。这个方法在一个新线程上执行,其顺序与调用start方法的线程执行顺序不同。

运行程序的输出清单说明了这一点:

starting: Thread[main,5,main]
finishing: Thread[main,5,main]
running: Thread[Thread-0,5,main]

要注意的是,run方法打印的消息验证了在新线程run方法中当前线程不是执行其他两条打印语句的线程。

这3条消息将以什么顺序出现?“starting”消息总是先出现。Java保证在程序顺序中新线程的start方法调用之前的任何语句和新线程的任何语句之间,都存在一个“先于发生”规则。也就是说,在新线程启动之前一切都是按照程序顺序发生的,并且这个新线程会认为它启动时才有“这个世界”的。

其余两条消息出现的顺序无法保证。它们都会打印,但Java没有定义谁先出现。对于在一些特定硬件上运行的Java实现来说,这两条消息大多数时候以某种顺序出现。然而,以这两条消息碰巧以某种顺序出现作为它们总是以这种顺序出现的证据是完全不正确的。这两条消息之间是无序的。

此外,如果Print方法不是线程安全的,那么这两条语句甚至可以是互相穿插的:运行程序产生的输出将是这两条语句的不可读组合。

虽然上面的“Thread”子类清单是创建新执行线程最简单的方式,但它未必是最佳方式。由于Java对单实现继承的约束,因此在新线程上运行的任何语句都必须是Thread的子类,这是一种极大的遗憾。事实上,这不是一个要求。

Runnable接口实现传送给一个新的线程对象时,Thread默认的运行方法就会调用Runnablesrun方法。清单2.2显示了一种与上一个清单有相同行为的不同实现。虽然无法立即在清单2.2中显现,但这个清单要灵活得多,因为Runnable可以继承任何一个超类行为。

清单2.2 带有Runnable的线程

public class RunnableExample {
    public static void main(String... args) {
        System.out.println("starting: " + Thread.currentThread());
        new Thread(new Runnable() {
            @Override
            public void run() {
                System.out.println("running: " + Thread.currentThread());
            }
        }).start();
        System.out.println("finishing: " + Thread.currentThread());
    }
}

如上一章所述,同步是将一组语句原子化的机制:在任何给定的时间里,只有一个线程可以在同步锁控制的块(临界区)中执行语句。Java中的同步是使用synchronized关键字和一个对象作为互斥锁完成的。Java保证最多只有一个线程可以在任何时间执行给定同步块中的语句。

注意

 

互斥(mutex)、监视器(monitor)和锁(lock)这3个词在这里几乎可以互换。互斥可能是最清楚的定义。它是一种提供互斥的锁:每次只有一个线程可以持有它。Java规范用监视器这个词描述一个实例,其相关的互斥锁用于控制一个同步块的访问。锁是一个更宽松的术语,具有更直观的含义。

虽然线程是特殊的对象,但在Java中任何对象都可以作为同步块的互斥来使用。每个Java对象都有一个监视器。

清单2.3演示了synchronized关键字的简单使用。

清单2.3 在一个对象上同步

public class SynchronizedExample {
    static final Object lock = new Object();

    public static void main(String... args) {
        System.out.println("starting: " + Thread.currentThread());
        new Thread(new Runnable() {
            @Override
            public void run() {
                synchronized (lock) {
                    System.out.println("running: " + Thread.currentThread());
                }
            }
        }).start();

        synchronized (lock) {
            System.out.println("finishing: " + Thread.currentThread());
        }
    }
}

清单2.3中的临界区是两个同步块中的两条打印语句。这个用于监视器、锁的对象没有什么特别的。它只是在代码早期创建的Object对象中一个最简单的实例。

我们可以分析一下,看看使用同步是如何影响这个清单的。毕竟,只有一个线程有机会执行匿名Runnablerun方法:在匿名线程start方法中创建的线程。同样,只有一个线程可以打印“finishing”消息:主线程。如果只有一个线程可以进入临界区,那么同步可能是无意义的。

在这个程序的非同步版本中,新线程可能会在主线程打印“finishing”消息的同时打印“running”消息。如果打印方法在内部是不同步的,那么就有可能造成这两条消息不可读。然而,由于清单2.3代码中的两个同步块都使用同一个互斥对象,因此这个版本的程序不会使这两条消息混合在一起。即使有两个独立的同步块,任何时候都只有一个线程可以持有一个给定对象的互斥。

其中,线程T1会夺取监视器对象的互斥,进入一个临界区并打印一条消息。由于它正持有互斥,因此另一个线程T2无法进入它的临界区。T2会在其临界区的开始处等待锁,直到T1将锁释放为止。当T1释放锁时,就会通知T2可以继续了。然后,它会进入其临界区,打印消息并继续。

要注意的是,应用程序的这个同步版本仍然不会定义这两条消息出现的顺序。无法确定这两个程序线程是主线程还是新线程会先进入其临界区。像前面的清单一样,这个清单认为包含一个竞争条件。

1.this上的同步

由于每个对象都有一个监视器,因此在这个监视器上可以方便在一个方法中同步的对象就是this。通过同步自身来同步访问其状态的对象,是很有道理的。

清单2.4是这项技术的一个示例。Runnable仍然是一个匿名类的实例。然而在这个版本的程序中,对实例的引用保存在变量job中。this上的同步(源自匿名可运行的run方法)和job上的同步在主方法中都是在同一个对象上进行的。这个版本的行为与清单2.3中的相同。

清单2.4 this上的同步

public class SynchronizedThisExample {
    private static final Runnable job = new Runnable() {
        @Override
        public void run() {
            synchronized (this) {
                System.out.println("running: " + Thread.currentThread());
            }
        }
    };

    public static void main(String... args) {
        System.out.println("starting: " + Thread.currentThread());
        new Thread(job).start();
        synchronized (job) {
            System.out.println("finishing: " + Thread.currentThread());
        }
    }
}

注意

 

你可能会注意到,清单2.4并不是对清单2.3的改进。正如清单2.5中所示的,同步整个方法是倒退,因为它破坏了大多数多线程的目的。

2.同步方法

Java有在this上同步的简写。在方法签名中使用synchronized关键字和将这个方法的内容封装到在this上的同步块中完全相同。例如,清单2.5中的方法sync1sync2是相同的。

清单2.5 同步方法

public class SynchronizedMethodExample {
    private int executionCount;

    public synchronized void sync1() {
        executionCount++;
        someTask();
    }

    public void sync2() {
        synchronized (this) {
            executionCount++;
            someTask();
        }
    }

    private void someTask() {
        // ...
    }
}

synchronized关键字限定的静态方法会同步到其类对象上。例如,清单2.6中的两个方法也是相同的。

清单2.6 同步静态方法

public class SynchronizedStaticExample {
    private static int executionCount;

    public static synchronized void sync1() {
        executionCount++;
        someTask();
    }

    public static void sync2() {
        synchronized (SynchronizedStaticExample.class) {
            executionCount++;
            someTask();
        }
    }

    private static void someTask() {
        // ...
    }
}

3.可重入监视器

对象监视器被认为是可重入的。虽然在任何给定的时间内只有一个线程可以持有互斥,一旦线程持有它,那么该线程会再次尝试捕获它,但这基本上都是空操作。清单2.7所示的是在同一个监视器this上同步两次的程序。程序可以成功运行。它是正确的,但没什么实际用途。

清单2.7 同步静态方法

public class ReentrantLockingExample {

    public static void main(String... args) {
        new ReentrantLockingExample().run1();
    }

    private synchronized void run1() { run2(); }

    private synchronized void run2() {
        System.out.println("reentrant locks!");
    }
}

不正确的同步是并发程序中最常见的错误。然而,这种错误要比将正确的内容放入同步块中多得多。

1.使用单个互斥

只有当临界区在单个互斥上同步时,对临界区的访问才是原子的。保护代码的是互斥,而不是块。

虽然在this上锁定非常方便,但有经验的开发人员可能会意识到,在单个对象this上同步可能会序列化并发操作。例如,如果请求不相互阻塞,具有状态组件的对象可能会得到显著优化,而这些状态组件会请求更新到独立的网络端点。为了使各个操作互不干扰,这个对象可能会使用不同的锁控制独立组件的访问。然而,这可能会产生类似于清单2.8所示的代码。请试着找出问题所在。

清单2.8 错误的互斥锁

public class LeakingLockExample {
    private String title = "";

    public void setTitle(String title) {
        if (null == title) {
            throw new NullPointerException("title may not be null");
        }
        synchronized (this.title) { this.title = title; }
    }
}

显然,问题是this.title的赋值没有被正确同步。

在这个清单中,两个不同的线程可以看到this.title的赋值由不同的互斥保护。线程T1捕获字符串上由this.title目前引用的互斥。然而,当它给this.title赋值时,线程T2可以进入setTitle方法并捕获一个不同的互斥。由于这两个线程还没有在一个互斥上同步,因此它们之间没有“先于发生”规则,并且代码能够以多种方式表现。

2.死锁

并发程序中最常见的第二种错误是死锁。上面讨论的可重入监视器指出,尝试两次捕获同一个互斥不会挂起的线程。然而,有两个或者多个互斥很可能会创建出即使在正确的情况下,没有线程也可以继续前进的代码。

如果两个或者多个线程都持有另一个线程所需要的锁,就会发生死锁。例如,如果线程T1和T2都需要访问分别由锁L1和L2保护的资源R1和R2,就可能发生这种情况。如果在T2捕获并L2访问R2的同时T1捕获L1并访问R1,T1和T2就不会继续前进。T1需要L2,但它被T2持有,所以无法获取。T2需要L1,但它被T1持有,所以也无法获取。清单2.9是一个可能会发生死锁的程序清单。

清单2.9 死锁

public class DeadlockingExample {
    static final Object lock1 = new Object();
    static final Object lock2 = new Object();

    public static void main(String... args) {
        System.out.println("starting: " + Thread.currentThread());

        new Thread(new Runnable() {
            @Override
            public void run() {
                synchronized (lock1) {
                    synchronized (lock2) {
                        System.out.println("running: " + Thread.currentThread());
                    }
                }
            }
        }).start();

        synchronized (lock2) {
            synchronized (lock1) {
                System.out.println("finishing: " + Thread.currentThread());
            }
        }
    }
}

大多数开发人员知道在并发程序中需要原子性,也了解如何使用同步来保证它很容易实现。正如第1章所讨论的,一种更常见的误解与可见性有关。这种误解经常表现在它相信必须要同步写可变状态,而不读取它。清单2.10就是这种错误的一个示例。

清单2.10 不正确的同步

public class IncorrectSynchronizationExample {
    static boolean stop;

    private static final Runnable job = new Runnable() {
        @Override
        public void run() {
            while (!stop) { // !!! Incorrect!
                System.out.println("running: " + Thread.currentThread());
            }
        }
    };

    public static void main(String... args) {
        System.out.println("starting: " + Thread.currentThread());

        new Thread(job).start();

        synchronized (job) { // !!! Incorrect!
            System.out.println("finishing: " + Thread.currentThread());
            stop = true;
        }
    }
}

这段代码是不正确的。只有一个线程使用同步,所以无法完成任何事情。

这段代码背后的推理通常是这样的:“run方法中循环执行的次数并不重要。不管它运行2次或者7次,在它注意到停止标志前,没有人会关心……如果没有同步,它将运行得更有效。”

然而,要记住的是,本章开头对Java约定的简单描述指出,任何线程中的写访问都可能会潜在影响同步的需要。在主线程上对stop赋值和在产生线程上从stop上读取之间,不存在任何类型的“先于发生”关系。一个原因不一定要导致另一个结果。

作为一个可能失败的程序清单,请考虑代码的编译版本,其中变量stop表示为一个寄存器。现在假设这两个线程在CPU的两个独立的内核上运行。它们可能会有两个完全不同的停止标志,每一个都是一个内核的寄存器。只有当生成的代码强制这两个值同步时,一个值的改变才对另一个值可见。这种同步是昂贵的,如果没有必要的指示可能会被优化掉。产生的线程能够运行到它被外部终止时为止。

再次重申,正确的并发是关于约定的。虽然清单2.10讨论了硬件和编译器来演示错误,但可以通过引用已知的硬件和编译器架构是错误的推理来证明不存在错误。内存模型就是法则,若不遵守它,程序就是不正确的。

清单2.10中的可见性问题可以通过关键字volatile来解决。标记为volatile的变量表现得像是对它的每个引用都被封装在一个同步块中。清单2.11显示的是已更正的程序。

清单2.11 Volatile

public class VolatileExample {
    static volatile boolean stop;

    private static final Runnable job = new Runnable() {
        @Override
        public void run() {
            while (!stop) {
                System.out.println("running: " + Thread.currentThread());
            }
        }
    };

    public static void main(String... args) {
        System.out.println("starting: " + Thread.currentThread());

        new Thread(job).start();

        System.out.println("finishing: " + Thread.currentThread());
        stop = true;
    }
}

与清单2.2和清单2.3一样,如果消息打印不是同步的,这个程序可能会混淆消息。然而,如果主线程修改了停止标志的状态,产生的线程就能保证看到这个更改。

要注意的是,让停止标志成为volatile的并不能保证程序会终止!然而在这个版本中,没有任何语句能保证停止标志将设置为true。由于这两个线程之间没有指定的顺序,因此一个完全合法的场景是:第二个线程开始,而第一个线程暂停并且永远不会恢复。在这个场景中,第二个线程“饿死了”第一个线程。

当没有足够的处理器推动程序中的所有线程时,某种调度程序必须将这些处理器分配给线程。希望它能以某种方式给每个线程“公平份额”。实际上,自从有了Android平台的调度程序后,Java宽松的调度规则很少被关注。然而即使在Android中,“公平份额”的确切含义有时也令人惊讶。

还要注意的是,虽然提供了可见性,但volatile关键字没有提供原子性。清单2.12所示的程序可能无法准确地打印11条消息。正如上一章所描述的,++操作符不是原子的,读-改-重写可能会保存一个不正确的值。

清单2.12 不正确的volatile

public class VolatileExample {
    static volatile int iterations;

    private static final Runnable job = new Runnable() {
        @Override
        public void run() {
            while (iterations++ < 10) {
                System.out.println("running: " + Thread.currentThread());
            }
        }
    };

    public static void main(String... args) {
        System.out.println("starting: " + Thread.currentThread());

        new Thread(job).start();

        while (iterations++ < 10) {
            System.out.println("finishing: " + Thread.currentThread());
        }
    }
}

Java中还包含两个并发原语:wait方法和notifyAll方法。notifyAll方法还有一个不常用的变体notify

wait方法允许一个通过捕获互斥进入同步块的线程在块中暂停并释放这个互斥。这不违反原子性,因为虽然线程在同步块内部,但它是挂起的,没有执行。

只有当前线程持有调用wait方法的对象的互斥时,wait方法才可以被调用。清单2.13会抛出一个java.lang.IllegalMonitorStateException,因为只有执行线程不持有监视器对象lock时,wait方法才会被调用。

清单2.13 IllgalMonitorStateException

public class WaitExceptionExample {
    private Object lock = new Object();
    public static void main(String... args) {
        try { lock.wait(); }
        catch (InterruptedException e) {
          throw new AssertionError("Interrupts not supported");
        }
    }
}

当一个线程在同步块中执行,并且它在这个块的监视器对象上调用wait方法时,线程就会挂起并释放这个监视器。在调用wait时,挂起的线程可以被后续的notifynotifyAll调用并重新调度。当然,这个调用必须由另一个线程执行,挂起的线程在重新调度前不能做任何事情。

清单2.14所示的代码包含两个同步块,它们一起执行来控制调用req.run的线程数。

当一个新线程(这次我们将它称为Edsger)进入第一个临界区时,代码会检查是否已经有了太多请求正在运行。如果没有,Edsger就会递增运行计数,离开临界区并运行它的请求。当完成请求时,它就会尝试进入第二个临界区。

如果其他线程正在执行其中的一个临界区,Edsger就必须等待其他线程离开或者调用wait。一旦发生这种情况,Edsger就会进入第二个临界区,递减运行请求的计数,并退出这个方法。

然而,考虑一下当Edsger进入第一个同步块时,若已经有3个其他线程(可能是Tony、Niklaus和Per Brinch)正在运行请求,由于运行计数已经达到最大值,因此Edsger就会进入循环,只有当运行计数小于最大值时,Edsger才能离开这个循环。运行计数小于最大值的唯一方法是:当前运行请求的其他线程必须进入第二个同步块中,并递减计数。由于Edsger会在调用时释放监视器,因此其他线程可以做到这一点。

注意

 

关于线程中断处理复杂主题的完整讨论不属于本章所述范畴。在本章的清单中,中断是不允许的。

然而,另一个线程递减运行计数不会重启Edsger。一旦线程等待了一个特殊的监视器,即使它能够运行,也不会被再次调度,除非某个运行的线程为这个监视器调用notifyAll或者notify。清单2.14在第二个临界区中进行这样的操作。

只有当运行计数达到最大值时,才会有线程在第一个临界区中等待,因此第二个临界区中的notifyAll只有在那时才会被调用。

了解notifynotifyAll这两个方法之间的区别非常重要。调用notify会重新安排一个等待监视器的线程,notifyAll会重新安排所有等待监视器的线程。当不同的线程由于不同原因而等待时,这一点非常重要。

清单2.14 使用wait和notify

public static final int RUN_MAX = 3;
private final Object lock = new Object();
private int running;

public void rateLimiter(NetworkRequest req) {
  synchronized (lock) {
    while (running >= RUN_MAX) {
      try { lock.wait(); }
      catch (InterruptedException e) {
        throw new AssertionError("Interrupts not supported");
      }
    }
    running++;
  }
  try { req.run(); }
  finally {
    synchronized (lock) {
      if (running-- == RUN_MAX) { lock.notify(); }
    }
  }
}

考虑一下,如果修改清单2.14中的代码以启用两个不同的运行最大值,一个用于执行读请求的线程,另一个用于执行写请求的线程,其结果会怎么样?假设读者的运行计数小于最大值,就会强制调用notify。一种完全可能的情况是:虽然读运行计数小于最大值,但写运行计数仍然大于最大值,这时只有一个等待写入的线程会重新调度。写线程可能会在未通知其他线程的情况下立即再次等待。在这种情况下,没有读线程(没有可以前进的线程)被重新调度。

在这种情况下,当不同的线程在等待不同的内容时,调用notifyAll(而不是notify)是必不可少的。顾名思义,前者会重新调度所有等待监视器的线程。这是比较昂贵的:唤醒每个线程,检查环境,并前进或者再次调用wait(如果它没有获得所需资源)。正如清单2.14所示,只有当线程无法区分时,使用notify才是安全的。然而,即使是这样,最好还是使用notifyAll作为未来使用的一种方式。

虽然了解Java的底层并发结构很有用,但大多数代码不会使用这些结构。大多数代码关心的都是如何实现更高级的目标。对于大多数应用程序来说,设计waitnotify就像是在考虑汽化器设计的情况下,计划从旧金山到波士顿的旅行。

在Java 5中,Java在java.util包中引入了一个包含更高级并发抽象的框架。和前面的集合框架一样,并发框架是安全且强大的,并且足以满足大多数编程任务。包含调用底层结构(例如waitnotify)的代码可能会出错。

并发编程的一个关键问题是线程之间的数据共享。将数据正确地从一个线程传输到另一个线程称为安全发布。仔细检查本章的规则就可以找到安全发布的几种策略。

最简单的策略是不可变的数据。如果任何线程都不可能改变数据状态,那么并发规则的前提是不适用的,共享数据是安全的。一般来说,Java中不可变的状态应该声明为final。《Java并发编程实践》一书引入了“有效的不可变性”来讨论所有人都不同意修改一个变量值的情况。这当然管用,然而更安全的做法是使用这种语言来保证变量值不能被修改,而不是依靠这个变量值不会被修改的保证。

在线程之间传递可变状态是一件棘手的事。幸运的是,有一种标准的习惯用法。图2.1所示即为一种安全发布的习惯用法。

图2.1 一种安全发布的习惯用法

当线程A想将一个对象传递给线程B时,它就会使用一个互斥锁和一个下拉框。线程A捕获互斥锁,然后将这个对象的引用放入下拉框中。必须确保它不会持有被转移对象的任何引用。然后线程A释放互斥锁。线程B捕获互斥锁,并从下拉框中恢复对象的引用。线程B必须确保在释放互斥锁前,下拉框不再持有这个对象的引用。

清单2.15是在Java中安全发布算法的部分实现。

清单2.15 安全发布

private Object lock = new Object();
private T dropBox;

/**
 * Safely publish an object to another thread.
 *
 * @param obj The caller must hold <b>no other references</b> to this object.
 */
public void publish(T obj) {
  synchronized (lock) {
    if (null != dropBox) {
      throw new IllegalStateException("The drop box is full!");
    }
    dropBox = obj;
  }
}
/**
 * Receive a published object.
 *
 * @return the received object
 */
public T receive() {
  synchronized (lock) {
    T obj = dropBox;
    dropBox = null;
    return obj;
  }
}

在这个传输过程中,只有一种可变状态被两个线程共享:下拉框。传递对象在任何时候都无法从多个线程中进行访问。单个互斥锁控制对下拉框的访问,因此传输是安全且正确的。

这种习惯用法通常会使用队列,而不是一个简单的下拉框。线程通过将一个对象入队来交换数据,并通过另一个线程将其出队。

很容易理解一个应用程序如何从生成其他线程中受益。每当有可并发执行的独立任务时,生成更多执行它们的线程可以让应用程序更高效。然而,简单地生成更多线程并不意味着可以并发执行更多任务。在一个具有4个内核的处理器上,为计算密集型任务生成10个线程并不会让应用程序运行的速度加快10倍。

事实上,创建太多线程会让应用程序运行得更慢。线程是重量级很高的对象。在它们之间进行切换会产生开销。它们在对象分配和垃圾回收的过程中也会造成大量开销。随意简单地创建新线程并不是一个好策略。

对于在特定硬件环境中运行给定的应用程序来说,线程数有一个最佳范围。显然,这个数值与硬件支持的并发进程数有关,因此它不必完全相同。

应用程序需要一种策略来支持其所支持的线程数。通常情况下,必要的工具是另一层抽象:将任务的概念从执行它的线程中分离出来。已经从心理上实现了从物理进程(实际的并发)到线程(无序的执行)的跨越,这种区别似乎是明显的。线程是由物理CPU内核驱动虚拟进程的,而任务是一个工作单元。如图2.2所示,任务是在线程上调度的,而线程是在处理器上调度的。

图2.2 任务、线程和处理器

Java 5中引入的Java并发框架定义了一个名为executor的服务。executor将定义线程策略的能力和图2.1所示的安全发布习惯用法结合起来。一个executor(执行器)就是为一队任务服务的一个或者多个线程池。任务关闭,进入执行器队列中换取一个future,一个值的承诺。最后,任务由一个执行器线程从队列中删除并执行。执行结果变成这个future的值。

executor是从上一节的线程、同步块和通知中提取的一个很有价值的步骤。通过使用executor,应用程序可以为最佳线程数建立一个单独、跨应用程序的策略。大多数应用程序代码只需创建新任务(即小的、相对轻量级的对象),并将它们提交给异步执行的executor,而不关心开销和线程安全。当然,应用程序可以使用多个executor,然而应该明确的是,有几十个executor的应用程序没有线程策略。

虽然executor是将线程策略从正在执行的任务中分离出来的抽象,但executor服务是真实世界中执行器抽象的实现。它定义了很多细节,如executor如何启动和停止,以及是否可以确定它处于这两种状态中的哪一个。

当一个线程A请求另一个异步执行某些任务的线程B时,B只有几种方法可以将结果从任务中返给A。当然,最简单的是A等待B完成,但这会错过并发点。

在Web应用程序中流行的另一种方法是回调。任何函数

f(x1, x2, x3, ..., xn) = y

都可以转换成一个调用

f’(x1, x2, x3, ..., xn, g(y))

也就是说,函数f不会返回一个值,而是将要调用的、带有返回值的函数g作为额外的参数。由于请求线程A不知道它的方法什么时候会被g调用,因此g通常会将一个任务入队,然后在某种本地队列中处理返回值。

虽然这种习惯用法很有效,尤其是返回值在网络上传输时,这可能会导致JavaScript开发人员经常发现的臭名昭著的“回调地域”。方法g可以多次在不方便的时候调用。

执行器使用的第三种策略:future,future就是一个值的承诺。将任务交给执行器处理的客户端会接收到一个future。这个future不是一个值,但客户端可以将它看作一个值,直到它真正需要future表示的值。如果客户端线程必须有实际值才能向前推进,那么按照定义,它就会准备好等待这个值。它调用Future.get并挂起线程,直到服务线程完成任务并将结果发布到客户端线程。这种方法大大降低了回调和异步编程的需要。

本章复习了Java并发编程的要点,因为它们要应用到Android中。本章建立了几个关键点:线程对象、异步任务的Java表示、同步和监视器、Java的原子性和可见性构造以及Java可见性的捷径volatile。

本章还介绍了将数据从一个线程安全发布到另一个线程的常见用法,并说明了它在Java并发框架执行类型中的用法。executor通过处理安全发布的多个方面并让应用程序建立一个并行性策略来降低并发编程的复杂性,这些线程足以在不产生过多调度或者内存管理开销的情况下提供最佳的并行执行。


相关图书

Android App开发入门与实战
Android App开发入门与实战
Kotlin入门与实战
Kotlin入门与实战
Android APP开发实战——从规划到上线全程详解
Android APP开发实战——从规划到上线全程详解
Android应用案例开发大全( 第4版)
Android应用案例开发大全( 第4版)
深入理解Android内核设计思想(第2版)(上下册)
深入理解Android内核设计思想(第2版)(上下册)
Kotlin程序开发入门精要
Kotlin程序开发入门精要

相关文章

相关课程