Java并发编程实战

Posted by 盈盈冲哥 on March 5, 2020
  • 第二章 线程安全性

    • 安全性的含义是“永远不发生糟糕的事情”,而活跃性则关注于另一个目标,即“某件正确的事情最终会发生”。

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

    • 无状态对象一定是线程安全的。

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

    • 假定有两个操作A和B,如果从执行A的线程来看,当另一个线程执行B时,要么将B全部执行完,要么完全不执行B,那么A和B对彼此来说时原子的。原子操作是指,对于访问同一个状态的所有操作(包括该操作本身)来说,这个操作是一个以原子方式执行的操作。

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

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

    • “重入”意味这获取锁的操作的粒度是“线程”,而不是“调用”。重入的一种实现方法是,为每个锁关联一个获取计数值和一个所有者线程。当计数值为0时,这个锁就被认为是没有被任何线程持有。当线程请求一个未被持有的锁时,JVM将记下锁的持有者,并且将获取计数值置为1. 如果同一个线程再次获取这个锁,计数值将递增,而当线程退出同步代码块时,计数器会相应地递减。当计数值为0时,这个锁将被释放。

      在以下代码中,子类改写了父类的synchronized方法,然后调用父类的方法,此时如果没有可重入的锁,那么这段代码将产生死锁。

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      
      public class Widget {
          public synchronized void doSomething() {
              ...
          }
      }
          
      public class LoggingWiget extends Widget {
          public synchronized void doSomething() {
              System.out.println(toString() + ": calling doSomething");
              super.doSomething();
          }
      }
      
    • 一种常见的错误是认为,只有在写入共享变量时才需要使用同步,然而事实并非如此。

    • 对于可能被多个线程同时访问的可变状态变量,在访问它时都需要持有同一个锁,在这种情况下,我们称状态是由这个锁保护的。每个共享的和可变的变量都应该只由一个锁来保护,从而使维护人员知道是哪一个锁。对于包含多个变量的不变性条件,其中涉及的所有变量都需要由同一个锁来保护。

    • Servlet中一个同步代码块负责保护判断是否只需返回缓存结果的“先检查后执行”操作序列,另一个同步代码块则负责确保对缓存的数值和引述分解结果进行同步更新。

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      
      @ThreadSafe
      public class CacheFactorizer implments Servlet {
          @GuardedBy("this") private BigInteger lastNumber;
          @GuardedBy("this") private BigInteger[] lastFactors;
          @GuardedBy("this") private long hits;
          @GuardedBy("this") private long cacheHits;
              
          public synchronized long getHits() { return hits; }
          public synchronized double getCacheHitRatio() {
              return (double) cacheHits / (double) hits;
          }
              
          public void service(ServletRequest req, ServletResponse resq) {
              BigInteger i = extractFromRequest(req);
              BigInteger[] factors = null;
              synchronized (this) {
                  ++hits;
                  if (i.equals(lastNumber)) {
                      ++cacheHits;
                      factors = lastFactors.clone();
                  }
              }
              if (factors == null) {
                  factors = factor(i);
                  synchronized (this) {
                      lastNumber = i;
                      lastFactors = factors.clone();
                  }
              }
              encodeIntResponse(resp, factors);
          }
      }
      

      在简单性与性能之间存在着相互制约因素。

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

  • 第三章 对象的共享

    • 在没有同步的情况下,编译器、处理器以及运行时等都可能对操作的执行顺序进行一些意向不到的调整。在缺乏足够同步的多线程程序中,要想对内存操作的执行顺序进行判断,几乎无法得出正确的结论。在缺乏同步的程序中可能产生错误结果的一种情况是失效数据。仅对set方法进行同步是不够的,调用get的线程仍然会看到失效值。

    • 当线程在没有同步的情况下读取变量时,可能会得到一个失效值,但至少这个值是由之前某个线程设置的值,而不是一个随机值。这种安全性保证也被称为最低安全性(out-of-thin-air safety)。最低安全性适用于绝大多数变量,但是存在一个例外:非volatile类型的64位数值变量(double和long)。Java内存模型要求,变量的读取操作和写入操作都必须时原子操作,但对于非volatile类型的long和double变量,JVM允许将64位读操作或写操作分解为两个32位的操作。当读取一个非volatile类型的long时,如果对该变量的读操作和写操作在不同的线程中执行,那么很可能会读取到某个值的高32位和另一个值的低32位。

    • 在访问某个共享且可变的变量时要求所有线程在同一个锁上同步,就是为了确保某个线程写入该变量的值对于其他线程来说时可见的。加锁的含义不仅仅局限于互斥行为,还包括内存可见性。为了确保所有线程都能看到共享变量的最新值,所有执行读操作或者写操作的线程都必须在同一个锁上同步。

    • Java语言提供了一种稍弱的同步机制,即volatile变量,用来确保变量的更新操作通知到其他线程。在读取volatile类型的变量时总会返回最新写入的值。在访问volatile变量时不会执行加锁操作,因此也就不会使执行线程阻塞,因此volatile变量是一种比synchronized关键字更轻量级的同步机制。

    • volatile变量对可见性的影响比volatile变量本身更为重要。当线程A首先写入一个volatile变量并且线程B随后读取该变量时,在写入volatile变量之前对A可见的所有变量的值,在B读取了volatile变量后,对B也是可见的。因此,从内存可见性的角度来看,写入volatile变量相当于退出同步代码块,而读取volatile变量就相当于进入同步代码块。

    • 加锁机制既可以确保可见性又可以确保原子性,而volatile变量只能确保可见性。虽然volatile变量很方便,但也存在一些局限性,在使用时要非常小心。例如,volatile的语义不足以确保递增操作(count++)的原子性,除非你能确保只有一个线程对变量执行写操作。

    • 当且仅当满足以下所有条件时,才应该使用volatile变量:

      • 对变量的写入操作不依赖变量的当前值,或者你能确保只有单个线程更新变量的值。
      • 该变量不会与其他状态一起纳入不变性条件中。
      • 在访问变量时不需要加锁。
    • volatile变量的典型用法:检查某个状态标记以判断是否退出循环。为了使这个示例能正确执行,asleep必须为volatile变量。否则,当asleep被另一个线程修改时,执行判断的线程却发现不了。

      1
      2
      3
      4
      
      volatile boolean asleep;
      ...
          while (!asleep)
              countSomeSheep();