美文网首页我爱编程
线程安全性 Java并发编程实战总结

线程安全性 Java并发编程实战总结

作者: 好好学习Sun | 来源:发表于2018-05-27 19:30 被阅读73次

    在构建稳健的并发程序时,必须正确地使用线程和锁。但这些终归只是一些机制。 要编写线程安全的代码,其核心在于要对状态访问操作进行管理, 特别是对共享的(Shared)和可变的(Mutable)状态的访问

           从非正式的意义上来说, 对象的状态是指存储在状态变量(例如实例或静态域)中的数据。对象的状态可能包括其他依赖对象的域。 例如,某个HashMap的状态不仅存储在HashMap对象本身,还存储在许多Map.Entry对象中。 在对象的状态中包含了任何可能影响其外部可见行为的数据。

             “共享 ” 意味着变量可以由多个线程同时访问, 而 “可变” 则意味着变量的值在其生命周期内可以发生变化。 我们将像讨论代码那样来讨论线程安全性, 但更侧重于如何防止在数据上发生不受控的并发访问。

            一个对象是否需要是线程安全的,取决于它是否被多个线程访问。这指的是在程序中访问对象的方式, 而不是对象要实现的功能。 要使得对象是线程安全的, 需要采用同步机制来协同对对象可变状态的访问。如果无法实现协同, 那么可能会导致数据破坏以及其他不该出现的结果。

            当多个线程访问某个状态变量并且其中有一个线程执行写入操作时, 必须采用同步机制来协同这些线程对变量的访问。Java中的主要同步机制是关键字synchronized, 它提供了一种独占的加锁方式,但“ 同步” 这个术语还包括volatile类型的变量,显式锁(Explicit Lock)以及原子变量。

            在上述规则中并不存在一些想象中的“例外” 情况。即使在某个程序中省略了必要同步机制并且看上去似乎能正确执行,而且通过了测试并在随后几年时间里都能正确地执行,但程序仍可能在某个时刻发生错误。

            如果在设计类的时候没有考虑并发访问的情况,那么在采用上述方法时可能需要对设计进行重大修改,因此要修复这个问题可谓是知易行难。如果从一开始就设计一个线程安全的类,那么比在以后再将这个类修改为线程安全的类要容易得多。

            在一些大型程序中,要找出多个线程在哪些位置上将访问同一个变量是非常复杂的,面向对象这种技术不仅有助于编写出结构优雅、可维护性高的类,还有助于编写安全的类。访问某个变量的代码越少,就越容易确保对变量的所有访问都实现正确同步,也更容易找出变量在哪些条件下被访问。Java语言并没有强制要求将状态都封装在类中,开发人员完全可以将状态保存在某个公开的域(甚至公开的静态域) 中,或者提供一个对内部对象的公开引用。然而,程序状态的封装性越好,就越容易实现程序的线程安全性,并且代码的维护人员也越容易保持这种方式。

            当设计线程安全的类时,良好的面向对象技术、不可修改性,以及明晰的不变性规范都能起到一定的帮助作用。

            在某些情况中,良好的面向对象设计技术与实际情况的需求并不一致。在这些情况中,可能需要牺牲一些良好的设计原则,以换取性能或者对遗留代码的向后兼容。有时候,面向对象中的抽象和封装会降低程序的性能(尽管很少有开发人员相信),但在编写并发应用程序时,一种正确的编程方法就是:首先使代码正确运行,然后再提高代码的速度。即便如此,最好也只是当性能测试结果和应用需求告诉你必须提高性能,以及测最结果表明这种优化在实际环境中确实能带来性能提升时,才进行优化。

            如果你必须打破封装,那么也并非不可以,你仍然可以实现程序的线程安全性,只是更困难,而且,程序的线程安全性将更加脆弱,不仅增加了开发的成本和风险,而且也增加了维护 的成本和风险。第4章详细介绍了在哪些条件下可以安全地放宽状态变址的封装性。

            到目前为止,我们使用了 “线程安全类” 和 “线程安全程序” 这两个术语,二者的含义基本相同。线程安全的程序是否完全由线程安全类构成?答案是否定的,完全由线程安全类构成 的程序并不一定就是线程安全的,而在线程安全类中也可以包含非线程安全的类。在任何情况中, 只有当类中仅包含自己的 状态时,线程安全类才是有意义的。线程安全性是一个在代码上使用的术语, 但它只是与状态相关的,因此只能应用于封装其状态的整个代码,这可能是一个对象,也可能是整个程序。


    什么是线程安全性

            在线程安全性的定义中, 最核心的概念就是正确性。如果对线程安全性的定义是模糊的,那么就是因为缺乏对正确性的清晰定义。正确性的含义是, 某个类的行为与其规范完全一致。在良好的规范中通常会定义各种不变性条件(Invariant)来约束对象的状态,以及定义各种后验条件(Postcondition)来描述对象操作的结果。由于我们通常不会为类编写详细的规范,那么如何知道这些类是否正确呢?我们无法知道,但这并不妨碍我们在确信“类的代码能工作” 后使用它们。这种“代码可信性” 非常接近于我们对正确性的理解,因此我们可以将单线程的正确性近似定义为“所见即所知(we knowit when we see it)"。在对“正确性” 给出了一个较为清晰的定义后,就可以定义线程安全性: 当多个线程访问某个类时,这个类始终都能表现出正确的行为,那么就称这个类是线程安全的。

            当多个线程访问某个类时,不管运行时环境采用何种调度方式或者这些线程将如何交替执行,并且在调度代码中不需要任何额外的同步或协同,这个类都能表现出正确的行为,那就可以称这个类是线程安全的。

            由于单线程程序也可以看成是一个多线程程序,如果某个类在单线程环境中都不是正确的,那么它肯定不会是线程安全的。如果正确地实现了某个对象,那么在任何操作中(包括调用对象的公有方法或者对其公有域进行读/写操作)都不会违背不变性条件或后验条件。在线程安全类的对象实例上执行的任何串行或并行操作都不会使对象处于无效状态。

            在线程安全类中封装了必要的同步机制,因此客户端无须进一步采取同步措施。无状态对象一定是线程安全的。

    原子性

            在Java中,对基本数据类型的变量的读取和赋值操作是原子性操作,即这些操作是不可被中断,要么执行,要么不执行。        

    竞态条件

            当某个计算的正确性取决于多个线程的交替执行时序时,那么就会发生竞态条件。换句话说,就是正确的结果要取决于运气。最常见的竞态条件类型就是 “先检查后执行 (Check-Then-Act)" 操作, 即通过一个可能失效的观测结果来决定下一步的动作。

    复合操作

            要避免竞态条件问题,就必须在某个线程修改该变量时,通过某种方式防止其他线程 使用这个变量,从而确保其他线程只能在修改操作完成之前或之后读取和修改状态, 而不是在修改状态的过程中。

            在java.util.concurrent.atomic包中包含了一些原子变量类,用于实现在数值和对象引用上的原子状态转换。通过用AtomicLong来代替long类型的计数器,能够确保所有对计数器状态的访问操作都是原子的。

            我们在因数分解的Servlet中增加了一个计数器,并通过使用线程安全类AtomicLong来管理计数器的状态,从而确保了代码的线程安全性。当在无状态的类中添加一个状态时,如果该状态完全由线程安全的对象来管理,那么这个类仍然是线程安全的。然而,在2.3节你将看到,当状态变量的数量由一个变为多个时,并不会像状态变量数量由零个变为一个那样简单。

            在实际情况中,应尽可能地使用现有的线程安全对象(例如AcomicLong)来管理类的状态。与非线程安全的对象相比,判断线程安全对象的可能状态及其状态转换情况要更为容易,从而也更容易维护和验证线程安全性。

    加锁机制



            在线程安全性的定义中要求, 多个线程之间的操作无论采用何种执行时序或交替方式, 都要保证不变性条件不被破坏。 UnsafeCachingFactorizer 的不变性条件之一是:在 lastFactors 中 缓存的因数之积应该等于lastNumber 中缓存的数值。 只有确保了这个不变性条件不被破坏, 上面的 Servlet 才是正确的。 当在不变性条件中涉及多个变量时, 各个变最之间并不是彼此独立的,而是某个变量的值会对其他变量的值产生约束。 因此,当更新某一个变量在同一 个原子操作中对其他变量同时进行更新。

            在某些执行时序中,UnsafeCachingFactorizer可能会破坏这个不变性条件。在使用原子引用的情况下,尽管对set方法的每次调用都是原子的,但仍然无法同时更新lastNumber和 lastFactors。如果只修改了其中一个变量,那么在这两次修改操作之间,其他线程将发现不变性 条件被破坏了。同样,我们也不能保证会同时获取两个值:在线程A获取这两个值的过程中,线程B可能修改了它们,这样线程A也会发现不变性条件被破坏了。

            要保持状态的一致性,就需要在单个原子操作中更新所有相关的状态变量。

    内置锁

            Java提供了一种内置的锁机制来支持原子性:同步代码块(SynchronizedBlock)。同步代码块包括两部分: 一个作为锁的对象引用,一个作为由这个锁保护的代码块。以关键字synchronized来修饰的方法就是一种横跨整个方法体的同步代码块,其中该同步代码块的锁就是方法调用所在的对象。静态的synchronized方法以Class对象作为锁。

    synchronized (lock) {

    //访问或修改由锁保护的共享状态

    }

            每个Java对象都可以用做一个实现同步的锁,这些锁被称为内置锁(Intrinsic Lock) 或监视器锁(Monitor Lock)。线程在进入同步代码块之前会自动获得锁,并且在退出同步代码块时自动释放锁,而无论是通过正常的控制路径退出,还是通过从代码块中抛出异常退出。获得内置锁的唯一途径就是进入由这个锁保护的同步代码块或方法

            Java的内置锁相当于一种互斥体(或互斥锁),这意味着最多只有一个线程能持有这种锁。当线程A尝试获取一个由线程B持有的锁时,线程A必须等待或者阻塞,直到线程B释放这个锁。如果B永远不释放锁,那么A也将永远地等下去。

            由于每次只能有一个线程执行内置锁保护的代码块,因此,由这个锁保护的同步代码块会以原子方式执行,多个线程在执行该代码块时也不会相互干扰。并发环境中的原子性与事务应用程序中的原子性有着相同的含义一一组语句作为一个不可分割的单元被执行。任何一个执行同步代码块的线程,都不可能看到有其他线程正在执行由同一个锁保护的同步代码块。

            这种同步机制使得要确保因数分解Servlet的线程安全性变得更简单。在程序清单2-6中使用了关键字synchronized来修饰service方法,因此在同一时刻只有一个线程可以执行service方法。现在的SynchronizedFactorizer是线程安全的。然而,这种方法却过于极端,因为多个客户端无法同时使用因数分解Servlet, 服务的响应性非常低,无法令人接受。这是一个性能问题,而不是线程安全问题,我们将在2.5节解决这个问题。

            

    重入

            当某个线程请求一个由其他线程持有的锁时,发出请求的线程就会阻塞。然而,由于内置锁是可重入的,因此如果某个线程试图获得一个已经由它自己持有的锁,那么这个请求就会成功。 “ 重入 ” 意味着获取锁的操作的粒度是 “线程”,而不是 “调用"。重入的一种实现方法是,为每个锁关联一个获取计数值和一个所有者线程。 当计数值为0时,这个锁就被认为是没有被任何线程持有。 当线程请求一个未被持有的锁时,JVM将记下锁的持有者,并且将获取计数值置为1。 如果同一个线程再次获取这个锁,计数值将递增,而当线程退出同步代码块时, 计数器会相应地递减。 当计数值为0时,这个锁将被释放.

            重入进一步提升了加锁行为的封装性,因此简化了面向对象并发代码的开发。 在程序清单 2-7的代码中, 子类改写了父类的synchronized方法, 然后调用父类中的方法,此时如果没有 可重入的锁,那么这段代码将产生死锁。 由于Widget和LoggingWidget中 doSomething方法都是synchronized方法,因此每个doSomething方法在执行前都会获取Widget上的锁。 然而,如果内置锁不是可重入的,那么在调用super.doSomething时将无法获得Widget上的锁, 因为这个锁已经被持有,从而线程将永远停顿下去,等待一个永远也无法获得的锁。 重入则避免了这 种死锁情况的发生。

    用锁来保护状态

            由于锁能使其保护的代码路径以串行形式来访问,因此可以通过锁来构造一些协议以实现对共享状态的独占访问。只要始终遵循这些协议,就能确保状态的一致性。

            访问共享状态的复合操作,例如命中计数器的递增操作(读取- 修改- 写入)或者延迟初始化(先检查后执行),都必须是原子操作以避免产生竞态条件。如果在复合操作的执行过程中持有一个锁,那么会使复合操作成为原子操作。然而,仅仅将复合操作封装到一个同步代码块中是不够的。如果用同步来协调对某个变量的访问,那么在访问这个变量的所有位置上都需要使用同步。而且,当使用锁来协调对某个变量的访问时,在访问变量的所有位置上都要使用同一个锁。

            一种常见的错误是认为,只有在写入共享变量时才需要使用同步,然而事实井非如此。对于可能被多个线程同时访问的可变状态变量,在访问它时都需要持有同一个锁,在这种情况下,我们称状态变量是由这个锁保护的。

            对象的内置锁与其状态之间没有内在的关联。虽然大多数类都将内置锁用做一种有效的加锁机制,但对象的域并不一定要通过内置锁来保护。当获取与对象关联的锁时,并不能阻止其他线程访问该对象,某个线程在获得对象的锁之后,只能阻止其他线程获得同一个锁。之所以每个对象都有一个内置锁,只是为了免去显式地创建锁对象。你需要自行构造加锁协议或者同步策略来实现对共享状态的安全访问,并且在程序中自始至终地使用它们。

            一种常见的加锁约定是,将所有的可变状态都封装在对象内部,并通过对象的内置锁对所有访问可变状态的代码路径进行同步,使得在该对象上不会发生并发访问。在许多线程安全类中都使用了这种模式,例如Vector和其他的同步集合类。在这种情况下,对象状态中的所有变量都由对象的内置锁保护起来。然而,这种模式并没有任何特殊之处,编译器或运行时都不会强制实施这种(或者其他的) 模式。如果在添加新的方法或代码路径时忘记了使用同步,那么这种加锁协议会很容易被破坏。

            并非所有数据都需要锁的保护, 只有被多个线程同时访问的可变数据才需要通过锁来保护。 第 1 章曾介绍, 当添加一个简单的异步事件时, 例如 TimerTask, 整个程序都需要满足线 程安全性要求, 尤其是当程序状态的封装性比较糟糕时。考虑一个处理大规模数据的单线程程 序, 由于任何数据都不会在多个线程之间共享, 因此在单线程程序中不需要同步。 现在,假设希望添加一个新功能, 即定期地对数据处理进度生成快照, 这样当程序崩溃或者必须停止时无 须再次从头开始。你可能会选择使用 TimerTask, 每十分钟触发一次, 并将程序状态保存到个文件中。

            由于 TimerTask 在另一个(由 Timer 管理的) 线程中调用, 因此现在就有两个线程同时 访问快照中的数据 :程序的主线程与 Timer 线程。 这意味着, 当访问程序的状态时, 不仅TimerTask 代码必须使用同步, 而且程序中所有访问相同数据的代码路径也必须使用同步。 原本在程序中不需要使用同步, 现在变成了在程序的各个位置都需要使用同步

            当某个变量由锁来保护时, 意味着在每次访问这个变量时都需要首先获得锁, 这样就确保在同一时刻只有一个线程可以访问这个变量。当类的不变性条件涉及多个状态变量时, 那么还 有另外一个需求 :在不变性条件中的每个变量都必须由同一个锁来保护。因此可以在单个原子操作中访间或更新这些变量, 从而确保不变性条件不被破坏。 在 SynchronizedFactorizer 类中说明了这条规则:缓存的数值和因数分解结果都由 Servlet 对象的内置锁来保护。

            对于每个包含多个变量的不变性条件,其中设计的所有变量都需要由同一个锁保护。

            如果同步可以避免竞态条件问题, 那么为什么不在每个方法声明时都使用关键synchronized ? 事实上, 如果不加区别地滥用synchronized, 可能导致程序中出现过多的同步。此外,如果只是将每个方法都作为同步方法, 例如Vector, 那么并不足以确保Vector上复合操作都是原子的

            虽然contains和add 等方法都是原子方法, 但在上面这个“ 如果不存在则添加(put-ifabsent)"的操作中仍然存在竞态条件。虽然synchronized 方法可以确保单个操作的原子性, 但如果要把多个操作合并为一个复合操作, 还是需要额外的加锁机制(请参见4.4 节了解如何在线程安全对象中添加原子操作的方法)。此外, 将每个方法都作为同步方法还可能导致活跃性问题(Liveness) 或性能问题(Performance), 我们在SynchronizedFactorizer 中已经看到了这些问题。

    活跃性与性能

            在UnsafeCachingFactorizer 中, 我们通过在因数分解Servlet 中引入了缓存机制来提升性能。在缓存中需要使用共享状态, 因此需要通过同步来维护状态的完整性。然而, 如果使用SynchronizedFactorizer 中的同步方式, 那么代码的执行性能将非常糟糕

            SynchronizedFactorizer中采用的同步策略是,通过Servlet 对象的内置锁来保护每一个状态变量, 该策略的实现方式也就是对整个 service 方法进行同步。虽然这种简单且粗粒度的方法能确保线程安全性,但付出的代价却很高。

            由于 service 是一个 synchronized方法,因此每次只有一个线程可以执行。这就背离了 Serlvet 框架的初衷,即 Serlvet 需要能同时处理多个请求,这在负载过高的情况下将给用户带来糟糕的体验。如果 Servlet 在对某个大数值进行因数分解时需要很长的执行时间,那么其他的客户端必须一直等待,直到 Servlet 处理完当前的请求, 才能开始另一个新的因数分解运算。如 果在系统中有多个CPU 系统,那么当 负载很高时,仍然会有处理器处千空闲状态。即使一些执 行时间很短的请求,比如访问缓存的值,仍然需要很长时间, 因为这些请求都必须等待前一个 请求执行完成。

            图 2-1 给出了当多个请求同时到达因数分解 Servlet 时发生的情况:这些请求将排队等待处理。我们将这种 Web 应用程序称之为不良并发 (Poor Concurrency) 应用程序可同时调用的数量,不仅受到可用处理资源的限制, 还受到应用程序本身结构的限制。幸运的是,通过缩小同步代码块的作用范围,我们很容易做到既确保 Servlet 的并发性,同时又维护线程安全性。要确保同步代码块不要过小,并且不要将本应是原子的操作拆分到多个同步代码块中。应该尽量将不影响共享状态且执行时间较长的操作从同步代码块中分离出去,从而在这些操作的执行过程中,其他线程可以访问共享状态。


            当使用锁时, 你应该清楚代码块中实现的功能, 以及在执行该代码块时是否需要很长的时 间。 无论是执行计算密集的操作, 还是在执行某个可能阻塞的操作, 如果持有锁的时间过长, 那么都会带来活跃性或性能问题。

            当执行时间较长的计算或者可能无法快速完成的操作时(如网络I/o或控制台I/O),一定不要持有锁。

            

            

    相关文章

      网友评论

        本文标题:线程安全性 Java并发编程实战总结

        本文链接:https://www.haomeiwen.com/subject/hddvjftx.html