Java核心技术Ι(2)——并发之同步

竞争条件

    在大多数实际的多线程应用中, 两个或两个以上的线程需要共享对同一数据的存取。如果两个线程存取相同的对象,并且每个线程都调用了一个修改该对象状态的方法,这样线程之间就会相互影响。根据各线程访问数据的次序,可能会产生讹误的对象,这样的情况通常称为竞争条件(race condition)。为了避免多线程引起的对共享数据的讹误,必须学习如何同步存取。下面我们举个例子,模拟一个有若干账户的银行。随机地生成在这些账户之间转移钱款的交易。每一个账户有一个线程。每一笔交易中, 会从线程所服务的账户中随机转移一定数目的钱款到另一个随机账户。

public void transfer(int from, int to, double amount) {
    // CAUTION: unsafe when called from multiple threads
    System.out.println(Thread.currentThread());
    accounts[from] -= amount;
    System.out.printf(" %10.2f from %d to %d", amount, from, to);
    accounts[to] += amount;
    System.out.printf(" Total Balance: %10.2f%n", getTotalBalance());
}

当几个线程更新银行账户余额,一段时间后,错误不知不觉地出现了,总额要么增加要么减少,Oh My God!我怎么敢把我的钱存在这样的银行里面?产生这个问题的原因主要是转账操作不是原子性的,假设两个线程同时执行指令,该指令可能被处理如下:
(1) 将 accounts[to] 加载到寄存器;
(2) 增加 amount
(3) 将结果写回 accounts[to]

现在,假定第 1 个线程执行步骤 1 和 2,然后它被剥夺了运行权。假定第 2 个线程被唤醒并修改了 accounts 数组中的同一项。然后第 1 个线程被唤醒并完成其第 3 步。这样, 这一动作擦去了第二个线程所做的更新,于是,总金额不再正确,具体过程如下图所示:
在这里插入图片描述
如果每个线程在再次睡眠之前所做的工作越少,那么出现讹误的风险会越低,因为调度器在计算过程中剥夺线程的运行权可能性会更小(然并卵,会出错就不得行啊)。

锁对象

    有两种机制防止代码块受并发访问的干扰:

  • Java提供一个synchronized关键字达到这一目的,这一关键字自动提供一个锁及相关“条件”,对于大多数需要显式锁的情况,这个关键字很便利;
  • Java SE 5.0引入了ReentrantLock类,确保任何时刻只有一个线程进入临界区。一旦一个线程封锁了锁对象,其它任何线程都无法通过lock语句。当其它线程调用lock时,他们被阻塞,直到第一个线程释放锁对象。
    ReentrantLock保护代码块的基本结构如下:
mylock.lock(); // a ReentrantLock object
try {
    critical section
} finally {
    // make sure the lock is unlocked even if an exception is thrown
    myLock.unlock(); 
}

注意

  • 把解锁操作放在 finally 子句之内是至关重要的。如果在临界区的代码抛出异常,锁必须被释放。否则其他线程将永远阻塞。
  • 如果使用锁, 就不能使用带资源的 try 语句。首先, 解锁方法名不是 close。不过,即使将它重命名, 带资源的 try 语句也无法正常工作。
    假定一个线程调用 transfer, 在执行结束前被剥夺了运行权。假定第二个线程也调用 transfer,由于第二个线程不能获得锁, 将在调用 lock 方法时被阻塞。它必须等待第一个线程完成 transfer 方法的执行之后才能再度被激活。当第一个线程释放锁时, 那么第二个线程才能开始运行,运行情况如下图所示:
    非同步线程与同步线程比较
    锁是可重入的,因为线程可以重复地获得已经持有的锁。锁保持一个持有计数(hold count)来跟踪对 lock 方法的嵌套调用。线程在每一次调用 lock 都要调用 unlock 来释放锁。由于这一特性, 被一个锁保护的代码可以调用另一个使用相同的锁的方法。

【java.util.concurrent.locks.Lock 5.0】

  1. void lock()
    获取这个锁;如果锁同时被另一个线程拥有则发生阻塞。
  2. void unlock()
    释放这个锁

【java.util.concurrent.locks.ReentrantLock 5.0】

  1. ReentrantLock()
    构建一个可以被用来保护临界区的可重入锁。
  2. ReentrantLock(boolean fair)
    构建一个带有公平策略的锁。一个公平锁偏爱等待时间最长的线程。但是,这一公平的保证将大大降低性能。所以,默认情况下,锁没有被强制为公平的。

条件对象

    通常, 线程进人临界区,却发现在某一条件满足之后它才能执行。要使用一个条件对象来管理那些已经获得了一个锁但是却不能做有用工作的线程。下面我们介绍Java 库中条件对象的实现。(由于历史的原因, 条件对象经常被称为条件变量 conditional variable
    一个锁对象可以有一个或多个相关的条件对象。你可以用 newCondition 方法获得一个条件对象。习惯上给每一个条件对象命名为可以反映它所表达的条件的名字。例如,在此设置一个条件对象来表达“余额充足” 条件。

class Bank {
    private Condition sufficientFunds;
    ...
    public Bank() {
        ...
        sufficientFunds = bankLock.newCondition();
    }
}

如果 transfer 方法发现余额不足,它调用 sufficientFunds.await()。当前线程现在被阻塞了,并放弃了锁。我们希望这样可以使得另一个线程可以进行增加账户余额的操作。等待获得锁的线程和调用 await 方法的线程存在本质上的不同。一旦一个线程调用 await 方法, 它进人该条件的等待集。当锁可用时,该线程不能马上解除阻塞。相反,它处于阻塞状态,直到另一个线程调用同一条件上的 signalAll 方法时为止。当另一个线程转账时, 它应该调用 sufficientFunds.signalAll()。这一调用重新激活因为这一条件而等待的所有线程,当这些线程从等待集当中移出时,它们再次成为可运行的,调度器将再次激活它们。同时,它们将试图重新进人该对象。一旦锁成为可用的,它们中的某个将从 await 调用返回,获得该锁并从被阻塞的地方继续执行。此时, 线程应该再次测试该条件。 由于无法确保该条件被满足— signalAll 方法仅仅是通知正在等待的线程:此时有可能已经满足条件, 值得再次去检测该条件。

通常对await()的调用应该在如下形式的循环体中:
while (!(ok to proceed)) {
    condition.await();
}

至关重要的是最终需要某个其他线程调用 signalAll 方法。当一个线程调用 await 时,它没有办法重新激活自身,它寄希望于其他线程。如果没有其他线程来重新激活等待的线程,它就永远不再运行了。这将导致令人不快的死锁 deadlock 现象。如果所有其他线程被阻塞, 最后一个活动线程在解除其他线程的阻塞状态之前就调用 await 方法, 那么它也被阻塞。没有任何线程可以解除其他线程的阻塞,那么该程序就挂起了。调用 signalAll 的时机从经验上讲, 应该是在对象的状态有利于等待线程的方向改变时。
    另一个方法 signal,则是随机解除等待集中某个线程的阻塞状态。这比解除所有线程的阻塞更加有效,但也存在危险。如果随机选择的线程发现自己仍然不能运行, 那么它再次被阻塞。如果没有其他线程再次调用 signal, 那么系统就死锁了。

注意: 调用 signalAll 不会立即激活一个等待线程。它仅仅解除等待线程的阻塞, 以便这些线程可以在当前线程退出同步方法之后,通过竞争实现对对象的访问。

【java.util.concurrent.locks.Lock 5.0】

  1. Condition newCondition()
    返回一个与该锁相关的条件对象。

【java.util.concurrent.locks.Condition 5.0】

  1. void await()
    将该线程放到条件的等待集中。
  2. void signalAll()
    解除该条件的等待集中的所有线程的阻塞状态。
  3. void signal()
    从该条件的等待集中随机地选择一个线程,解除其阻塞状态。

锁和条件的关键之处:

  • 锁用来保护代码片段, 任何时刻只能有一个线程执行被保护的代码。
  • 锁可以管理试图进入被保护代码段的线程。
  • 锁可以拥有一个或多个相关的条件对象。
  • 每个条件对象管理那些已经进入被保护的代码段但还不能运行的线程。

synchronized关键字

    从 1.0 版开始,Java中的每一个对象都有一个内部锁。如果一个方法用 synchronized 关键字声明,那么对象的锁将保护整个方法。使用 synchronized 关键字来编写代码要简洁得多。当然,要理解这一代码,你必须了解每一个对象有一个内部锁, 并且该锁有一个内部条件,由锁来管理那些试图进入 synchronized 方法的线程,由条件来管理那些调用 wait 的线程。

public synchronized void method() {
    methond body
}

等价于

public void method() {
    this.intrinsicLock.lock();
    try {
        method body
    } finally {
        this.intrinsicLock.unlock();
    }
}

内部对象锁只有一个相关条件。 wait 方法添加一个线程到等待集中, notifyAll/notify方法解除等待线程的阻塞状态。换句话说,调用 waitnotifyAll等价于

intrinsicCondition.await();
intrinsicCondition.signalAll();

注释:wait、notifyAll以及notify方法是Object类的final方法。Condition方法必须被命名为await、signalAll和signal以便它们不会与那些方法发生冲突。

    将静态方法声明为 synchronized 也是合法的。如果调用这种方法,该方法获得相关的类对象的内部锁。内部锁和条件存在一些局限。包括:

  • 不能中断一个正在试图获得锁的线程。
  • 试图获得锁时不能设定超时。
  • 每个锁仅有单一的条件,可能是不够的。

在一般情况下,最好既不使用 Lock/Condition 也不使用 synchronized 关键字。在许多情况下你可以使用 java.util.concurrent 包中的一种机制,它会为你处理所有的加锁。如果 synchronized 关键字适合你的程序, 那么请尽量使用它,这样可以减少编写的代码数量,减少出错的机率。除非特别需要 Lock/Condition提供的独有特性,才使用它。

【java.lang.Object 1.0】

  1. void notifyAll()
    解除那些在该对象上调用wait方法的线程的阻塞状态。该方法只能在同步方法或同步块内部调用。如果当前线程不是对象锁的持有者,该方法抛出一个IllegalMonitorStateException异常。
  2. void notify()
    随机选择一个在该对象上调用wait方法的线程,解除其阻塞状态。该方法只能在一个同步方法或同步块中调用。如果当前线程不是对象锁的持有者,该方法抛出一个IllegalMonitorStateException异常。
  3. void wait(long millis)
  4. void wait(long millis, int nanos)
    导致线程进入等待状态直到它被通知或经过指定的时间。这些方法只能在一个同步方法中调用。如果当前线程不是对象锁的持有者该方法抛出一个IllegalMonitorStateException异常。

同步阻塞

    每一个 Java 对象有一个锁,线程可以通过调用同步方法获得锁。还有另一种机制可以获得锁,通过进入一个同步阻塞。当线程进入如下形式的阻塞:

synchronized(obj) // this is the syntax for a synchronized block
{
    critical section
}

于是可以获得 obj 的锁。有时程序员使用一个对象的锁来实现额外的原子操作, 称为客户端锁定 client-side locking,客户端非常脆弱,通常不推荐使用。

监视器

    锁和条件是线程同步的强大工具,但是,严格地讲,它们不是面向对象的。多年来,研究人员努力寻找一种方法,可以在不需要程序员考虑如何加锁的情况下,就可以保证多线程的安全性。最成功的解决方案之一是监视器 monitor,这一概念最早是由PerBrinchHansen 和 Tony Hoare 在 20 世纪 70 年代提出的。监视器的特性如下:

  • 监视器是只包含私有域的类。
  • 每个监视器类的对象有一个相关的锁。
  • 使用该锁对所有的方法进行加锁。换句话说,如果客户端调用 obj.method(), 那么 obj对象的锁是在方法调用开始时自动获得, 并且当方法返回时自动释放该锁。因为所有的域是私有的,这样的安排可以确保一个线程在对对象操作时,没有其他线程能访问该域。
  • 该锁可以有任意多个相关条件。

    Java 设计者以不是很精确的方式采用了监视器概念, Java 中的每一个对象有一个内部的锁和内部的条件。如果一个方法用 synchronized 关键字声明,那么,它表现的就像是一个监视器方法。通过调用 wait/notifyAll/notify 来访问条件变量。然而, 在下述的 3 个方面 Java 对象不同于监视器, 从而使得线程的安全性下降:

  • 域不要求必须是 private
  • 方法不要求必须是 synchronized
  • 内部锁对客户是可用的。

volatile

    有时,仅仅为了读写一个或两个实例域就使用同步,显得开销过大了。毕竟,什么地方能出错呢? 遗憾的是,使用现代的处理器与编译器,出错的可能性很大:

  • 多处理器的计算机能够暂时在寄存器或本地内存缓冲区中保存内存中的值。结果是,
    运行在不同处理器上的线程可能在同一个内存位置取到不同的值。
  • 编译器可以改变指令执行的顺序以使吞吐量最大化。这种顺序上的变化不会改变代码
    语义,但是编译器假定内存的值仅仅在代码中有显式的修改指令时才会改变。然而,
    内存的值可以被另一个线程改变(- -! 尴尬)。

当然,如果你使用锁来保护可以被多个线程访问的代码,那么可以不考虑这种问题。 volatile 关键字为实例域的同步访问提供了一种免锁机制。如果声明一个域为 volatile ,那么编译器和虚拟机就知道该域是可能被另一个线程并发更新的。volatile 关键字有以下效果:

  • 使用 volatile 关键字会强制将修改的值立即写入主存。
  • 使用 volatile 关键字的话,假设有两个线程在修改同一个值,那么当线程2进行修改时,会导致线程1的工作内存中缓存变量stop的缓存行无效。
  • 由于线程1的工作内存中缓存变量 stop 的缓存行无效,所以线程1再次读取变量 stop 的值时会去主存读取。

假设对共享变量除了赋值之外并不完成其他操作,那么可以将这些共享变量声明为
volatile

final

    将域声明为final可以安全的访问一个共享域,例如:

final Map<String, Double> accounts = new HashMap<>();

其它线程会在构造函数完成构造之后才看到这个 accounts 变量,如果不使用 final,就不能保证其他线程看到的是 accounts 更新后的值,它们可能都只是看到 null, 而不是新构造的 HashMap。当然,对这个映射表的操作并不是线程安全的。如果多个线程在读写这个映射表,仍然需要进行同步。

原子性

    java.util.concurrent.atomic 包中有很多类使用了很高效的机器级指令(而不是使用锁) 来保证其他操作的原子性。下面举几个例子:
(1) 方法 incrementAndGetdecrementAndGet 分别以原子方式将一个整数自增或自减。例如,可以安全地生成一个数值序列:

public static AtomicLong nextNumber = new AtomicLong();
// In some thread...
long id = nextNumber.incrementAndGet();

(2) 方法 compareAndSet 可以完成更复杂的更新,例如,假设希望跟踪不同线程观察的最大值:

do {
    oldValue = largest.get();
    newValue = Math.max(oldValue, observed);
} while (!largest.compareAndSet(oldValue, newValue));

如果另一个线程也在更新 largest,就可能阻止这个线程更新。这样一来, compareAndSet 会返回 false,而不会设置新值。在这种情况下,循环会读取更新后的值,并尝试修改。最终,它会成功地用新值替换原来的值。
(3) 方法 updateAndGetaccumulateAndGet 也可以完成上述功能:

largest.updateAndGet(x -> Math.max(x, observed));

或者

largest.accumulateAndGet(observed, Math::max);

accumulateAndGet 方法利用一个二元操作符来合并原子值和所提供的参数。
(4) 如果有大量线程要访问相同的原子值,性能会大幅下降,因为乐观更新需要太多次重
试。Java SE 8 提供了 LongAdderLongAccumulator 类来解决这个问题。LongAdder 包括多个变量(加数),其总和为当前值。可以有多个线程更新不同的加数,线程个数增加时会自动提供新的加数。通常情况下, 只有当所有工作都完成之后才需要总和的值, 对于这种情况,这种方法会很高效,性能会有显著的提升。

LongAccumulator adder = new LongAccumulator(Long::sum, 0);
// In some thread...
adder.accumulate(value);

在内部,这个累加器包含变量 a1,a2,...,ana_1,a_2,...,a_n,每个变量初始化为零元素(这个例子中零元素为0)。调用 accumulate 并提供值 v 时,其中一个变量会以原子方式更新为 ai=aiopva_i = a_i\,op\,v,这里的opop是中缀形式的累加操作。在上面的例子中,调用 accumulate 会对某个ii计算ai=ai+va_i = a_i+vget 的结果是a1opa2op...opana_1\,op\,a_2\,op\,...op\,a_n。在上述例子中,这就是累加器的总和:a1+a2+...+ana_1+a_2+...+a_n。如果选择一个不同的操作,可以计算最小值或最大值。一般情况下,这个操作必须满足结合律和交换律。这说明,最终结果必须独立于所结合的中间值的顺序。

死锁

    锁和条件不能解决多线程中的所有问题。有可能会因为每一个线程要等待条件满足等原因而导致所有线程都被阻塞,这样的状态称为死锁(deadlock)。还有一种很容易导致死锁的情况: 某些情况下不使用 signalAll 方法而使用 signal方法,signalAll 会通知所有等待中的线程,而 signal 方法仅仅对一个线程解锁。由于Java 编程语言中没有任何东西可以避免或打破这种死锁现象。所以我们必须仔细设计程序, 以确保不会出现死锁。

线程局部变量

    线程共享变量会存在很多风险,有时可能要避免共享变量, 使用 ThreadLocal 辅助类为各个线程提供各自的实例。例如,SimpleDateFormat 类不是线程安全的。假设有一个静态变量:

public static final SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd");

如果两个线程都执行以下操作:

String dateStamp = dateFormat.format(new Date());

结果可能很混乱,因为 dateFormat 使用的内部数据结构可能会被并发的访问所破坏。当然可以使用同步,但开销很大;或者也可以在需要时构造一个局部 SimpleDateFormat 对象,不过这太浪费。要为每个线程构造一个实例,可以这样:

public static final ThreadLocal<SimpleDateFormat> dateFormat = ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd"));

要访问具体的格式化方法,可以调用:

String dateStamp = dateFormat.get().format(new Date());

在一个给定线程中首次调用 get 时,会调用 initialValue 方法。在此之后,get 方法会返回属于当前线程的那个实例。在多个线程中生成随机数也存在类似的问题。java.util.Random 类是线程安全的。但是如果多个线程需要等待一个共享的随机数生成器,这会很低效。也可以使用 ThreadLocal 辅助类为各个线程提供一个单独的生成器,不过Java SE 7还另外提供了一个便利类。只需要做以下调用:

int random = ThreadLocalRandom.current().nextInt(upperBound);

ThreadLocalRandom.current() 调用会返回特定于当前线程的 Random 类实例。

锁测试与超时

    线程在调用 lock 方法来获得另一个线程所持有的锁的时候,很可能发生阻塞。应该更加谨慎地申请锁。tryLock 方法试图申请一个锁, 在成功获得锁后返回 true,否则, 立即返回 false, 而且线程可以立即离开去做其他事情。

if (myLock.tryLock()) {
    // now the thread owns the lock
    try {...}
    finally { myLock.unlock(); }
} else {
    // do something else
}

调用 tryLock 时可以使用超时参数:

if (myLock.tryLock(100, TimeUnit.MILLISECONDS)) ...

也可以调用 lockInterruptibly方法,它相当于一个超时设为无限的 tryLock 方法。locktryLock的区别

  • lock方法不能被中断,如果一个线程在等待获得一个锁时被中断,中断线程在获得锁之前会一直处于阻塞状态,如果出现死锁,lock 方法则无法终止。
  • 如果调用带有超时参数的 tryLock,那么如果线程在等待期间被中断,将抛出 InterruptedException异常,这会允许程序打破死锁。

读/写锁

    java.util.concurrent.locks 包 定 义 了 两 个 锁 类, 我 们 已 经 讨 论 的 ReentrantLock 类 和 ReentrantReadWriteLock 类。 如果很多线程从一个数据结构读取数据而很少线程修改其中数据的话, 后者是十分有用的。在这种情况下, 允许对读者线程共享访问是合适的。当然,写者线程依然必须是互斥访问的。下面是使用读 / 写锁的必要步骤:
(1) 构 造 一 个 ReentrantReadWriteLock 对象:

private ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();

(2) 抽取读锁和写锁:

private Lock readLock = rwl.readLock();
private Lock writeLock = rwl.writeLock();

(3) 对所有的获取方法加读锁:

public double getTotalBalance() {
    readLock.lock();
    try { ... }
    finally { readLock.unlock(); }
}

(4) 对所有的修改方法加写锁:

public void transfer(...) {
    writeLock.lock();
    try { ... }
    finally { writeLock.unlock(); }
}

【java.util.concurrent.locks.ReentrantReadWriteLock 5.0】

  1. Lock readLock()
    得到一个可以被多个读操作共用的读锁,但会排斥所有写操作。
  2. Lock writeLock()
    得到一个写锁,排斥所有其他的读操作和写操作。

为什么弃用 stop 和 suspend 方法

    初始的 Java 版本定义了一个 stop 方法用来终止一个线程, 以及一个 suspend 方法用来阻塞一个线程直至另一个线程调用 resumestopsuspend 方法有一些共同点:都试图控制一个给定线程的行为。stopsuspendresume 方法已经弃用。stop 方法天生就不安全,经验证明 suspend 方法会经常导致死锁。
    首先来看看 stop 方法, 该方法终止所有未结束的方法, 包括 run 方法。当线程被终止,立即释放被它锁住的所有对象的锁,这会导致对象处于不一致的状态。当线程要终止另一个线程时, 无法知道什么时候调用 stop 方法是安全的, 什么时候导致对象被破坏,因此,该方法被弃用了。在希望停止线程的时候应该中断线程, 被中断的线程会在安全的时候停止。
    接下来, 看看 suspend 方法有什么问题。与 stop 不同,suspend 不会破坏对象。但是,如果用 suspend 挂起一个持有一个锁的线程, 那么该锁在恢复之前是不可用的。如果调用 suspend 方法的线程试图获得同一个锁, 那么程序死锁: 被挂起的线程等着被恢复,而将其挂起的线程等待获得锁

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章