1.概览

线程和锁模型可以达到目的,不过与新技术相比,稍微有点难以驾驭和危险。抛开众所周知的缺点,其仍然是开发并发软件的首选技术。

2.简单粗暴的特点

线程与锁模型是对底层硬件运行过程形式化。这是其模型最大的优点也是最大的缺点。这种模型非常直接,不过如果程序员不精通这种模型或者编程语言没有提供足够的帮助,将会使得长须容易出错且难以维护。

3.互斥和内存模型

3.1 不同步,互斥资源带来的坑

  • 问题:书中采用java来说明这一章。锁的竞态条件使得程序结果不正常,例子如下。原因是++count的时候,2个线程由于竞态条件,有可能只对数据递增了1次。++count的字节码如下,两个线程在getfield #2这一步可能都拿到了同样的数据,这样的话,最后结果其实只递增了一次。

  • 解决方案:这种问题可以对count进行同步访问。在java中你可以使用synchronized对增加的方法进行修饰或者java.util.concurrent.atomic包(在这种只涉及一个变量的互斥场景,更为推荐)。

  • 一把锁:书中例子采用synchronized方法和atomic都是对单个对象进行同步,模拟了一把锁的情形

getfield #2
iconst_1
iadd
putfield #2
/***
 * Excerpted from "Seven Concurrency Models in Seven Weeks",
 * published by The Pragmatic Bookshelf.
 * Copyrights apply to this code. It may not be used to create training material, 
 * courses, books, articles, and the like. Contact us if you are in doubt.
 * We make no guarantees that this code is fit for any purpose. 
 * Visit http://www.pragmaticprogrammer.com/titles/pb7con for more book information.
***/
package com.paulbutcher;

public class Counting {
  public static void main(String[] args) throws InterruptedException {
    class Counter {
      private int count = 0;
      public void increment() { ++count; }
      public int getCount() { return count; }
    }
    final Counter counter = new Counter();
    class CountingThread extends Thread {
      public void run() {
        for(int x = 0; x < 10000; ++x)
          counter.increment();
      }
    }

    CountingThread t1 = new CountingThread();
    CountingThread t2 = new CountingThread();
    t1.start(); t2.start();
    t1.join(); t2.join();
    System.out.println(counter.getCount());
  }
}

3.2 打乱代码执行顺序的坑

  1. 编译器静态优化打乱代码执行顺序
  2. JVM动态优化也会打乱代码执行顺序
  3. 硬件可以通过乱序执行来优化其性能

3.3 内存可见性的坑

  • Java的内存可见性:Java内存模型定义了何时一个线程对内存的修改对另一线程可见。即:如果线程和写线程不进行同步,就不能保证可见性。这里需要注意的是两个线程都需要进行同步,只在其中一个线程进行同步是不够的。上面的例子中increment()和getCount()都应该进行同步。getCount()不同步也不会出错,是因为其被安排在了join方法之后,所以是线程安全的。但是一般你必须保证对读写线程都进行同步。不过这里的getCount()同步后对其他调用它的代码也埋下了隐患(万一其他线程出现对count进行写却没有进行同步,就会出问题了。)

-问题: 有时一个线程产生的修改可能对另一个线程不可见。(试想你一个while判断另外一个线程里面的对象,一直是true。。你就死循环了)

  • 解决方案:综上所述,为了让多线程代码都安全运行,就要同步所有方法。显然这样效率很低,大多数线程会频繁阻塞,失去了并发意义。

3.4 死锁的坑

-问题: 使用内置锁的时候会产生死锁问题,典型的例子就是哲学家进餐问题(p13)。如果按照先拿左手筷子,再拿右手筷子这种规则,当大家一起进食的时候就发生死锁了,永远等不到人放下筷子。

  • 解决方案:可以按照一个全局唯一且有序的编号来拿筷子,就可以避免死锁。

  • 存在问题:如果获取锁的代码写得比较集中,就有利于维护这个全局顺序,对于大规模较大的程序,使用锁的地方比较零散,各处都遵循这个顺序就变得不太实际。

  • 多把锁:在书中的哲学家进餐问题的JAVA实现中,使用嵌套的内置锁:synchronized(object),模拟了多把锁的情况

3.5 调用外星方法的坑

  • 外星方法:调用这类方法时,调用者对方法的细节并不知情

例: 构造一个类从一个URL进行下载,并用ProgressListeners监听下载的进度

/***
 * Excerpted from "Seven Concurrency Models in Seven Weeks",
 * published by The Pragmatic Bookshelf.
 * Copyrights apply to this code. It may not be used to create training material, 
 * courses, books, articles, and the like. Contact us if you are in doubt.
 * We make no guarantees that this code is fit for any purpose. 
 * Visit http://www.pragmaticprogrammer.com/titles/pb7con for more book information.
***/
package com.paulbutcher;

import java.io.*;
import java.net.URL;
import java.util.ArrayList;

class Downloader extends Thread {
  private InputStream in;
  private OutputStream out;
  private ArrayList<ProgressListener> listeners;

  public Downloader(URL url, String outputFilename) throws IOException {
    in = url.openConnection().getInputStream();
    out = new FileOutputStream(outputFilename);
    listeners = new ArrayList<ProgressListener>();
  }
  public synchronized void addListener(ProgressListener listener) {
    listeners.add(listener);
  }
  public synchronized void removeListener(ProgressListener listener) {
    listeners.remove(listener);
  }
  private synchronized void updateProgress(int n) {
    for (ProgressListener listener: listeners)
      listener.onProgress(n);       //这里调用了一个外星方法
  }

  public void run() {
    int n = 0, total = 0;
    byte[] buffer = new byte[1024];

    try {
      while((n = in.read(buffer)) != -1) {
        out.write(buffer, 0, n);
        total += n;
        updateProgress(total);
      }
      out.flush();
    } catch (IOException e) { }
  }
}
  • 问题:从上面的例子中可以看到调用了onProgress这个外星方法,这个外星方法可能引入其他锁,导致使用多把锁,这就可能造成死锁了。

  • 解决方案:采用保护性复制(defensive copy),即不对原始对象进行操作,而是对克隆出来的对象进行操作。这样原本由于对原对象的加锁操作带来的影响就不存在了。这种方法有诸多好处,例如减少锁持有的时间,降低死锁可能。在本例子中,onProgewss()中调用removeListener()将不会影响到正在遍历的对象(因为它只是对克隆的对象进行遍历)。

PS: 这个保护性复制确实是个好方法啊。前提是你只对那个对象进行只读的操作,不做修改。要不然只对克隆修改就没意义了。

  private void updateProgress(int n) {
    ArrayList<ProgressListener> listenersCopy;
    synchronized(this) {
      listenersCopy = (ArrayList<ProgressListener>)listeners.clone();
    }
    for (ProgressListener listener: listenersCopy)
      listener.onProgress(n);
  }

4. 总结(重要)

线程与锁模型的主要三大危害:

  1. 竞态条件:就是不同步由于互斥资源带来的问题
  2. 死锁:多把锁的情况下出现该问题
  3. 内存可见性: 即在Java内存模型中需要对读写同时加锁

避免以上危害的准则:

  1. 对共享变量的所有访问都需要同步化
  2. 读线程和写线程都需要同步化
  3. 按照约定的全局顺序来获取多把锁
  4. 当持有锁是避免调用外星方法
  5. 持有锁的时间应尽可能短