第二章 线程安全性 Java并发编程实战 阅读总结

        在构建稳健的并发程序时,必须正确地使用线程和锁。但这些终归只是一些机制。 要编写线程安全的代码,其核心在于要对状态访问操作进行管理, 特别是对共享的(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)" 操作, 即通过一个可能失效的观测结果来决定下一步的动作。

复合操作

          为了确保线程安全性,“先检查后执行” (例如延迟初始化)和“ 读取- 修改- 写入“ (例如递增运算)等操作必须是原子的。我们将“ 先检查后执行” 以及“读取- 修改- 写入” 等操作统称为复合操作:包含了一组必须以原子方式执行的操作以确保线程安全性。

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

         在实际情况中,应尽可能地使用现有的线程安全对象(例如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也将永远地等下去。

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

重入

              

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

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


     用锁来保护状态

                                         

     

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

活跃性与性能

        如果在系统中有多个CPU 系统,那么当 负载很高时,仍然会有处理器处千空闲状态。即使一些执 行时间很短的请求,比如访问缓存的值,仍然需要很长时间, 因为这些请求都必须等待前一个 请求执行完成。

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



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

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





猜你喜欢

转载自blog.csdn.net/weixin_40243947/article/details/80634492