线程安全发布

 安全发布
目前为止我们都关注确保对象不会被发布。比如,让对象限制在线程中或者另一个对象的内部。当然,有时我们又的确希望跨线程共享对象,这时,我们必须安全地共享它。很不幸,像清单3.14那样简单地将对象的引用存储到公共域中,还不足以安全地发布它。
清单3.13  使用到不可变容器对象的volatile类型引用,缓存最新的结果
@ThreadSafe
public class VolatileCachedFactorizer implements Servlet {
    private volatile OneValueCache cache =
        new OneValueCache(null, null);
    public void service(ServletRequest req, ServletResponse resp) {
        BigInteger i = extractFromRequest(req);
        BigInteger[] factors = cache.getFactors(i);
        if (factors == null) {
            factors = factor(i);
            cache = new OneValueCache(i, factors);
        }
        encodeIntoResponse(resp, factors);
    }
}
清单3.14  在没有适当的同步的情况下就发布对象(不要这样做)
// 不安全发布
public Holder holder;
public void initialize() {
    holder = new Holder(42);
}
你可能感到惊讶,这个看上去无恙的例子竟然也会失败。由于可见性的问题,容器还是会在其他线程中被设置为一个不一致的状态,即使它的不变约束已经在构造函数中得以正确创建!这种不正确的发布导致其他线程可以观察到“局部创建对象(partially constructed object)”。
3.5.1  不正确发布:当好对象变坏时
你无法信赖局部创建对象,一个监视着处于不一致状态对象的线程,会看到尽管该对象自发布之后从未修改过,但是它的状态还是会发生突变。事实上,如果清单3.15的Holder使用清单3.14的不安全发布方式发布,那么除了发布线程,其他线程调用assertSanity时都可能抛出AssertionError15。
清单3.15  如果Holder没有被正确发布,它将处于失败的风险中
public class Holder {
    private int n;
    public Holder(int n) { this.n = n; }
    public void assertSanity() {
        if (n != n)
            throw new AssertionError("This statement is false.");
    }
}
因为没有同步来确保Holder对其他线程可见,所以我们称Holder是“非正确发布的”。没有正确发布的对象会导致两种错误。首先,发布线程以外的任何线程都可以看到Holder域的过期值,因而看到的是一个null引用或者旧值,即使此刻Holder已经被赋予新值。其次,更坏的情况是,线程看到的Holder引用是最新的,然而Holder状态却是过期的16。这使程序执行变得更加不可预测:线程首次读取某个域可能会看到过期值,再次读取该域会得到一个更新值,这正是assertSainty会抛出AssertionError原因。
我们处于自我复制的风险中,如果没有充足的同步,跨线程共享数据时会发生一些非常奇怪的事情。
3.5.2  不可变对象与初始化安全性
出于不可变对象的重要性,Java存储模型为共享不可变对象提供了特殊的初始化安全性的保证。正如我们所见的,对象的引用对其他线程可见,并不意味对象的状态一定对消费线程可见。为了保证对象状态有一个一致性视图,我们需要同步。
另一方面,即使发布对象引用时没有使用同步,不可变对象仍然可以被安全地访问。为了获得这种初始化安全性的保证,应该满足所有不可变性的条件:不可修改的状态,所有域都是final类型的以及正确的构造。(如果清单3.15中的Holder是不可变的,那么即使Holder没有正确地发布,assertSanity也不会抛出AssertionError。)
不可变对象可以在没有额外同步的情况下,安全地用于任意线程;甚至发布它们时亦不需要同步。
这个保证还会延伸到一个正确创建的对象中所有final类型域的值。没有额外的同步,final域也可以被安全地访问。然而,如果final域指向可变对象,那么访问这些对象的状态时仍然需要同步。
3.5.3  安全发布的模式
如果一个对象不是不可变的,它就必须被安全地发布,通常发布线程与消费线程都必须同步化。此刻让我们关注一下,如何确保消费线程能够看到处于发布当时的对象状态;我们要解决对象发布后对其修改的可见性问题。
为了安全地发布对象,对象的引用以及对象的状态必须同时对其他线程可见。一个正确创建的对象可以通过下列条件安全地发布:
l 通过静态初始化器初始化对象的引用;
l 将它的引用存储到volatile域或AtomicReference;
l 将它的引用存储到正确创建的对象的final域中;
l 或者将它的引用存储到由锁正确保护的域中。
线程安全容器的内部同步,意味着将对象置入这些容器(比如Vector或者synchronizedList)的操作遵守了前述的最后一条要求。如果线程 A将对象 X置入某个线程安全容器,随后线程 B重新获得 X,这时可以保证 B所看到 X的状态,正是 A设置的。尽管程序的代码并未控制 X的行为,就是说这里没有显式的同步,但仍然可以保证上述描述的事情发生。线程安全库中的容器提供了如下的线程安全保证(即使在Javadoc上,也没有把这个主题表述清楚):
l    置入Hashtable、synchronizedMap、ConcurrentMap中的主键以及键值,会安全地发布到可以从Map获得它们的任意线程中,无论是直接获得还是通过迭代器(iterator)获得;
l    置入Vector、CopyOnWriteArrayList、CopyOnWriteArraySet、synchronized- List或者synchronizedSet中的元素,会安全地发布到可以从容器中获得它的任意线程中;
l    置入BlockingQueue或者ConcurrentLinkedQueue的元素,会安全地发布到可以从队列中获得它的任意线程中。
类库中的其他交互(Handoff)机制(比如Future和Exchanger)同样创建了安全发布;我们会在介绍到它们时指出它们提供的安全发布。
通常,以最简单和最安全的方式发布一个被静态创建的对象,就是使用静态初始化器:
public static Holder holder = new Holder(42);
静态初始化器由JVM在类的初始阶段执行,由于JVM内在的同步,该机制确保了以这种方式初始化的对象可以被安全地发布 [JLS 12.4.2]。
3.5.4  高效不可变对象(Effectively immutable objects)
有些对象在发布后就不会被修改,其他线程想要在没有额外同步的情况下安全地访问它们,此时安全地发布至关重要。所有的安全发布机制都能保证,只要一个对象“发布当时(as-published))”的状态对所有访问线程都可见,那么到它的引用也都是可见的。如果“发布当时”的状态不会再改变,那么确保任意访问是安全的,就变得重要。
一个对象在技术上不是不可变的,但是它的状态不会在发布后被修改,这样的对象称作有效不可变对象。这种对象不必满足在3.4节里提出的不可变性的约束条件;这些对象发布后程序只需简单地把它们当作不可变对象。用高效不可变对象可以简化开发,并且由于减少了同步的使用,还会提高性能。
任何线程都可以在没有额外的同步下安全地使用一个安全发布的高效不可变对象。
比如,Date自身是可变的17,但是如果你把它当作不可变对象来使用就可以忽略锁。否则,每当Date被跨线程共享时,都要用锁确保安全。假设你正在维护一个Map,它储存了每位用户的最近登录时间:
public Map<String, Date> lastLogin =
  Collections.synchronizedMap(new HashMap<String, Date>());
如果Date值在置入Map中后就不会改变,那么,synchronizedMap中同步的实现,对于安全地发布Date值,是至关重要的。而访问这些Date值时就不再需要额外的同步。
3.5.5  可变对象
如果对象在创建后被修改,那么安全发布仅仅可以保证“发布当时”状态的可见性。对于可变状态,同步不仅仅由于对象发布,而且还用于在每次对象被访问后,保证后续变化的可见性。为了保证安全地共享可变对象,可变对象必须被安全发布,同时必须是线程安全的或者是被锁保护的。
发布对象的必要条件依赖于对象的可变性:
l 不可变对象可以通过任意机制发布;
l 高效不可变对象必须要安全发布;
l 可变对象必须要安全发布,同时必须要线程安全或者是被锁保护。
3.5.6  安全地共享对象
当你获得一个对象的引用时,你都要知道可以用它来做什么。是否需要在使用它前先获得一个锁?是否可以修改它的状态,还是仅仅可读?很多同步错误都源自没有理解共享对象的这些“预设规则”。当你发布一个对象后,应该将如何访问它们写入文档。
在并发程序中,使用和共享对象的一些最有效的策略如下:
线程限制:一个线程限制的对象,通过限制在线程中,而被线程独占,且只能被占有它的线程修改。
共享只读(shared read-only): 一个共享的只读对象,在没有额外同步的情况下,可以被多个线程并发地访问 ,但是任何线程都不能修改它。共享只读对象包括可变对象与高效不可变对象。
共享线程安全(shared thread-safe): 一个线程安全的对象在内部进行同步,所以其他线程无须额外同步,就可以通过公共接口随意地访问它。
被守护的(Guarded): 一个被守护的对象只能通过特定的锁来访问。被守护的对象包括那些被线程安全对象封装的对象,和已知被特定的锁保护起来的已发布对

 

使用线程最主要的原因是提高性能1。使用线程可以使程序更加充分地发挥出闲置的处理能力,从而更好地利用资源;并能够使程序在现有任务正在运行的情况下立刻开始着手处理新的任务,从而提高系统的响应性。
这一章将探讨用于并发程序性能的分析、监测和改进的技术。不幸的是,很多改进性能的技术同样增加了复杂度,因此增加了安全和活跃度失败的可能性。更糟糕的是,有些技术的目的是改进性能,事实上产生了相反的作用,带来了其他的性能问题。尽管我们希望获得更好的性能——改进性能带来了成就感——但安全总是第一位的。首先要保证程序是正确的,然后再让它更快——而后只有当你的性能需求和评估标准需要程序运行得更快时,才去进行改进。在设计并发应用程序的时候,最大可能地改进性能,通常并不是最重要的事情。
11.1  性能的思考
改进性能意味着用更少的资源做更多的事情。“资源”的概念很广泛,对于给定的活动而言,一些特定的资源通常非常缺乏,无论是CPU周期、内存、网络带宽、I/O带宽、数据库请求、磁盘空间、以及其他一些资源。当活动的运行因某个特定资源受阻时,我们称之为受限于该资源:受限于CPU,受限于数据库。
尽管目标是希望全面提升性能,与单线程方法相比,使用多线程总会引入一些性能的开销。这些开销包括:与协调线程相关的开销(加锁、信号、内存同步),增加的上下文
切换,线程的创建和消亡,以及调度的开销。当线程被过度使用后,这些开销会超过提高后的吞吐量响应性和计算能力带来的补偿。从另一方面,一个没能经过良好并发设计的应用程序,甚至比相同功能的顺序的程序性能更差2。
为了利用并发来实现更好的性能,我们需要努力做两件事情:更有效地利用我们现有的处理资源,让我们的程序尽可能地开拓更多可用的处理资源。从性能监视器的视角来看,这意味着我们期望使CPU尽可能处于忙碌状态。(当然,这并不是让CPU周期忙于应付无用的计算;我们希望CPU做有用的事情。)如果程序是受限于计算能力的,那么我们通过增加更多的处理器就能够提高生产量;如果程序都不能保持现有的处理器处于忙碌工作的状态,添加更多的处理器也无济于事。线程通过分解应用程序,总是让空闲的处理器进行未完成的工作,从而保持所有CPU“热火朝天”地工作,
11.1.1  性能“遭遇”可伸缩性
应用程序可以从很多个角度来衡量;比如服务时间、等待时间、吞吐量、效率、可伸缩性、生产量。有一些标准(服务时间、等待时间)是用来衡量“有多快”,即给定的任务单元需要多长时间进行处理,得到回馈;另一些(生产量、吞吐量)用来衡量“有多少”,即限定计算资源的情况下,究竟能够完成多少工作。

可伸缩性指的是:当增加计算资源的时候(比如增加额外CPU数量、内存、存储器、I/O带宽),吞吐量和生产量能够相应地得以改进。

在传统的性能调优中,为配合可伸缩性来设计和调试并发应用程序是非常困难的。为并发而进行的调试,其目的通常是用最小的代价完成相同的工作,比如通过缓存来重用以前计算的结果,或者用时间复杂度为 On log n)算法取代 On 2)算法。在为可伸缩性进行调试的时候,你的目的是如何并行化你的问题,使你能够利用额外的计算资源,用更多的资源做更多的事情。
性能的这两个方面——“有多快” 和“有多少”是完全分离的,有时候甚至是相悖的。为了实现更好的可伸缩性,或者更好地利用硬件,我们通常会停止增加每个独立任务所要完成的工作量,比如我们把任务分解到多个管道线的子任务中。据有讽刺意味的是,大多数在单线程化的程序中提高性能的窍门,都会损害可伸缩性(实例参见11.4.4)。
我们所熟知的程序的三层(tier)模型——其中的表现层、业务逻辑层和持久层是分离的,并且可能由不同的系统掌控——这个例子阐明了提高可伸缩性是如何造成性能损失的。把表现层、业务逻辑层、持久层拼装到同一个程序中,相比于在多个系统间进行了良好分解的分布式实现,前者在完成首个任务单元的性能上要高出许多。那劣势呢?即使能够实现在同一个应用程序的不同层之间传递任务,并且不存在网络的延迟,仍然要把对计算的处理清晰地分离到各个抽象层(layer)中(比如排队的开销,协调的开销,和数据拷贝),并为此付出代价。
然而,当单一的系统到达它处理的极限时,会遇到一个严重的问题:提升它的处理能力会相当困难。所以,我们通常会接受更长处理时间的性能开销,或者为每个任务单元分配更多的计算资源,从而使应用程序能够通过增加更多的资源来相应承担更大的负荷。
从性能中的多个角度来看,“有多少”方面——可伸缩性,吞吐量和生产量——在Server应用程序中往往比“有多快”受到更多的关注。(在交互式应用中,对等待时间的关注可能更加重要,这样用户就不用等待进度条的显示,也不需要知道系统究竟在做什么。)这一章主要集中在可伸缩性而不是原始的单线程化系统的性能。
11.1.2  对性能的权衡进行评估
几乎所有的工程上的决定都会遇到某些形式的折中。在建设桥跨时使用更粗的钢筋可以提高桥的负载能力和安全性,却同时会提高建造成本。尽管软件工程的决定通常不会遇到金钱和事关人类生命的风险这两者之间的抉择,但是我们经常会缺少那些能帮助我们作出正确权衡的信息。例如,“快速排序”算法对于大的数据集具有非常好的效率,但是我们熟知的“冒泡排序”对小数据集非常有效。如果你想要为实现排序例程选择一个算法,你需要知道面临处理的数据集的大小,还有你试图进行优化的目标:是平均计算时间,允许的最差时间,还是可预知性。不幸的是,库排序例程的作者所拿到的需求里面往往没有包括这些信息。这就是为什么大多数优化都不成熟的原因之一:他们通常在获得清晰的需求之前进行了假设。

避免不成熟的优化。首先使程序正确,然后再加快——如果它运行得还不够快。

当我们面临工程上的决定时,有时候会用某种形式的成本换取其他东西(用内存开销换取更短的服务时间);有时候也会用开销换取安全性。安全性并不完全指对人们生活的威胁,比如桥梁的那个例子。很多性能的优化会损害可读性或可维护性——代码越“聪
明”,越“晦涩”,就越难理解和维护。有时候,优化需要违背好的面向对象的设计原则,比如打破封装;有时候,它们会带来很大的风险和错误,因为通常越快的算法越复杂。(如果你不能识别代价或者风险,你可能还没能对将发生的场景进行彻底、仔细地思考。)
大多数性能的决定需要多个变量,并且高度依赖于发生的环境。在决定某个方案比其他方案“更快”之前,先问你自己一些问题:
l  你所谓的更“快”指的是什么?
l  在什么样的条件下你的方案能够真正运行得更快?在轻负载还是重负载下?大数据集还是小数据集?是否支持你的测量标准的答案?
l  这些条件在你的环境中发生的频率?是否支持你的测量标准的答案?
l  这些代码在其他环境的不同条件下被用到的可能性?
l  你用什么样隐含的代价,比如增加的开发风险或维护性,换取了性能的提高?这个权衡的决定是否正确?
作出任何与性能相关的工程决定时,都应该考虑这些问题,但是这本书只关注于并发。我们为什么要推荐这样一个保守的优化方案?对性能的追求很可能是并发bug唯一最大的来源。认为同步“太慢”而导致使用看似聪明实际危险的手法,从而减少同步(比如第16.2.4小节将要讨论的双检查锁),这也成为了不遵守同步规定常用的一个借口。然而,因为并发的bug是最难追踪和消除的缺陷,所以任何引入这类bug的风险行动都需要慎重进行。
更糟的是,当你用安全性换取了性能的时候,你可能什么都没得到。特别是,当提到并发的时候,很多开发者对于产生性能问题的原因,或者哪一个方案能够更迅速,具有更好的可伸缩性,他们的直觉往往是错误的。因此,依据性能的需求(这样你能知道什么时候需要调节,什么时候该停止),根据适当的评估纲要,并使用现实中的配置和负载状况,来进行性能调节的活动是非常必要的。在调节过后,你需要再评估,以验证你已经实现了期望的改进。优化带来的安全性和可维护性风险足够严重,以至于——如果你不需要的话,你不想付出这样的代价。并且,如果你甚至没有从中得到一点好处,你绝对不希望付出此代价。

测评。不要臆测。

市场上有一些成熟的剖析工具,用来评估性能,追踪性能瓶颈,但是你不必花费大量的金钱用于了解你的程序做了什么。例如,免费的perfbar应用程序可以给你一张相当不错的图表,告诉你CPU究竟是如何忙碌地工作,并且你的目标通常是保持CPU的忙碌,这便是一个很好的方式,使你能够评估你是否需要性能调节,或者你调节的效果如何。

 

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值