同步

线程主要通过共享对字段和引用对象的引用字段的访问来进行通信,这种通信形式非常有效,但可能产生两种错误:线程干扰和内存一致性错误,防止这些错误所需的工具是同步。

但是,同步可能会引入线程竞争,当两个或多个线程同时尝试访问同一资源并导致Java运行时更慢地执行一个或多个线程,甚至暂停它们执行,饥饿和活锁是线程竞争的形式。

本节包括以下主题:

  • 线程干扰描述了当多个线程访问共享数据时如何引入错误。
  • 内存一致性错误描述了由共享内存的不一致视图导致的错误。
  • 同步方法描述了一种简单的语法,可以有效地防止线程干扰和内存一致性错误。
  • 隐式锁和同步描述了一种更通用的同步语法,并描述了同步是如何基于隐式锁的。
  • 原子访问讨论的是不能被其他线程干扰的操作的一般概念。

线程干扰

考虑一个名为Counter的简单类:

class Counter {
    private int c = 0;

    public void increment() {
        c++;
    }

    public void decrement() {
        c--;
    }

    public int value() {
        return c;
    }

}

Counter的设计为每次increment的调用都会将c加1,每次decrement的调用都会从c中减去1,但是,如果从多个线程引用Counter对象,则线程之间的干扰可能会妨碍这种情况按预期发生。

当两个操作在不同的线程中运行但作用于相同的数据时,会发生干扰,这意味着这两个操作由多个步骤组成,并且步骤序列交叠。

对于Counter实例的操作似乎不可能进行交错,因为对c的两个操作都是单个简单的语句,但是,即使是简单的语句也可以由虚拟机转换为多个步骤,我们不会检查虚拟机采取的具体步骤 — 只需知道单个表达式c++可以分解为三个步骤:

  1. 检索c的当前值。
  2. 将检索的值增加1。
  3. 将增加的值存储在c中。

表达式c--可以以相同的方式分解,除了第二步是递减而不是递增。

假设在大约同一时间,线程A调用increment,线程B调用decrement,如果c的初始值为0,则它​​们的交错操作可能遵循以下顺序:

  1. 线程A:检索c
  2. 线程B:检索c
  3. 线程A:递增检索值,结果是1
  4. 线程B:递减检索值,结果是-1
  5. 线程A:将结果存储在c中,c现在是1
  6. 线程B:将结果存储在c中,c现在是-1

线程A的结果丢失,被线程B覆盖,这种特殊的交错只是一种可能性,在不同的情况下,可能是线程B的结果丢失,或者根本没有错误,因为它们是不可预测的,所以难以检测和修复线程干扰错误。

内存一致性错误

当不同的线程具有应该是相同数据的不一致视图时,会发生内存一致性错误,内存一致性错误的原因很复杂,超出了本教程的范围,幸运的是,程序员不需要详细了解这些原因,所需要的只是避免它们的策略。

避免内存一致性错误的关键是理解先发生关系,这种关系只是保证一个特定语句的内存写入对另一个特定语句可见,要了解这一点,请考虑以下示例,假设定义并初始化了一个简单的int字段:

int counter = 0;

counter字段在两个线程A和B之间共享,假设线程A递增counter

counter++;

然后,不久之后,线程B打印出counter

System.out.println(counter);

如果两个语句已在同一个线程中执行,则可以安全地假设打印出的值为“1”,但如果两个语句在不同的线程中执行,则打印出的值可能为“0”,因为无法保证线程A对counter的更改对线程B可见 — 除非程序员在这两条语句之间建立了先发生关系。

有几种操作可以创建先发生关系,其中之一是同步,我们将在下面的部分中看到。

我们已经看到了两种创建先发生关系的操作。

  • 当一个语句调用Thread.start时,与该语句具有一个先发生关系的每个语句也与新线程执行的每个语句都有一个先发生关系,导致创建新线程的代码的效果对新线程可见。
  • 当一个线程终止并导致另一个线程中的Thread.join返回时,已终止的线程执行的所有语句与成功join后的所有语句都有一个先发生关系,线程中代码的效果现在对执行join的线程可见。

有关创建先发生关系的操作列表,请参阅java.util.concurrent包的Summary页面

同步方法

Java编程语言提供了两种基本的同步语法:同步方法和同步语句,下两节将介绍两个同步语句中较为复杂的语句,本节介绍同步方法。

要使方法同步,只需将synchronized关键字添加到其声明:

public class SynchronizedCounter {
    private int c = 0;

    public synchronized void increment() {
        c++;
    }

    public synchronized void decrement() {
        c--;
    }

    public synchronized int value() {
        return c;
    }
}

如果countSynchronizedCounter的一个实例,那么使这些方法同步有两个效果:

  • 首先,不可能对同一对象上的两个同步方法的调用进行交错,当一个线程正在为对象执行同步方法时,调用同一对象的同步方法的所有其他线程阻塞(暂停执行),直到第一个线程使用完对象为止。
  • 其次,当一个同步方法退出时,它会自动与同一个对象的同步方法的任何后续调用建立一个先发生关系,这可以保证对象状态的更改对所有线程都可见。

请注意,构造函数无法同步 — 将synchronized关键字与构造函数一起使用是一种语法错误,同步构造函数没有意义,因为只有创建对象的线程在构造时才能访问它。

构造将在线程之间共享的对象时,要非常小心对对象的引用不会过早“泄漏”,例如,假设你要维护一个包含每个类实例的名为instancesList,你可能想要将以下行添加到你的构造函数中:instances.add(this);

但是其他线程可以在构造对象完成之前使用instances来访问对象。

同步方法支持一种简单的策略来防止线程干扰和内存一致性错误:如果一个对象对多个线程可见,则对该对象的变量所有读取或写入都是通过synchronized方法完成的(一个重要的例外:一旦构造了对象,就可以通过非同步方法安全地读取构造对象后无法修改的final字段),这种策略很有效,但可能会带来活性问题,我们将在本课后面看到。

固有锁和同步

同步是围绕称为固有锁或监控锁的内部实体构建的(API规范通常将此实体简称为“监视器”。),固有锁在同步的两个方面都起作用:强制执行对对象状态的独占访问,并建立对可见性至关重要的先发生关系。

每个对象都有一个与之关联的固有锁,按照约定,需要对对象字段进行独占和一致访问的线程必须在访问对象之前获取对象的固有锁,然后在完成它们时释放固有锁。线程在获取锁和释放锁期间被称为拥有固有锁,只要一个线程拥有固有锁,没有其他线程可以获得相同的锁,另一个线程在尝试获取锁时将阻塞。

当线程释放固有锁时,在该操作与同一锁的任何后续获取之间建立先发生关系。

同步方法中的锁

当线程调用同步方法时,它会自动获取该方法对象的固有锁,并在方法返回时释放它,即使返回是由未捕获的异常引起的,也会发生锁定释放。

你可能想知道调用静态同步方法时会发生什么,因为静态方法与类相关联,而不是与对象相关联,在这种情况下,线程获取与类关联的Class对象的固有锁,因此,对类的静态字段的访问由一个锁控制,该锁与该类的任何实例的锁不同。

同步语句

创建同步代码的另一种方法是使用同步语句,与同步方法不同,同步语句必须指定提供固有锁的对象:

public void addName(String name) {
    synchronized(this) {
        lastName = name;
        nameCount++;
    }
    nameList.add(name);
}

在此示例中,addName方法需要同步更改lastNamenameCount,但还需要避免同步调用其他对象的方法(从同步代码中调用其他对象的方法可能会产生有关活性一节中描述的问题),如果没有同步语句,则必须有一个单独的、不同步的方法,其唯一目的是调用nameList.add

同步语句对于通过细粒度同步提高并发性也很有用,例如,假设类MsLunch有两个实例字段,c1c2,它们从不一起使用,必须同步这些字段的所有更新,但是没有理由阻碍c1的更新与c2的更新交错 — 并且这样做会通过创建不必要的阻塞来减少并发性。我们创建两个对象只是为了提供锁,而不是使用同步方法或使用与此相关联的锁。

public class MsLunch {
    private long c1 = 0;
    private long c2 = 0;
    private Object lock1 = new Object();
    private Object lock2 = new Object();

    public void inc1() {
        synchronized(lock1) {
            c1++;
        }
    }

    public void inc2() {
        synchronized(lock2) {
            c2++;
        }
    }
}

谨慎使用这种用法,你必须绝对确保对受影响字段的交错访问是安全的。

可重入同步

回想一下,线程无法获取另一个线程拥有的锁,但是一个线程可以获得它已经拥有的锁,允许线程多次获取同一个锁可使可重入同步。这描述了一种情况,其中同步代码直接或间接地调用也包含同步代码的方法,并且两组代码使用相同的锁,在没有可重入同步的情况下,同步代码必须采取许多额外的预防措施,以避免线程导致自身阻塞。

原子访问

在编程中,原子操作是一次有效地同时发生的操作,原子操作不能停在中间:它要么完全发生,要么根本不发生,在操作完成之前,原子操作的副作用在完成之前是不可见的。

我们已经看到增量表达式(如c++),没有描述原子操作,即使非常简单的表达式也可以定义可以分解为其他操作的复杂操作,但是,你可以指定为原子操作:

  • 对于引用变量和大多数原始变量(除longdouble之外的所有类型),读取和写入都是原子的。
  • 对于声明为volatile的所有变量(包括longdouble),读取和写入都是原子的。

原子操作不能交错,因此可以使用它们而不用担心线程干扰,但是,这并不能消除所有同步原子操作的需要,因为仍然可能存在内存一致性错误。使用volatile变量可以降低内存一致性错误的风险,因为对volatile变量的任何写入都会建立与之后读取相同变量的先发生关系,这意味着对volatile变量的更改始终对其他线程可见。更重要的是,它还意味着当线程读取volatile变量时,它不仅会看到volatile的最新更改,还会看到导致更改的代码的副作用。

使用简单的原子变量访问比通过同步代码访问这些变量更有效,但程序员需要更加小心以避免内存一致性错误,额外的功夫是否值得取决于应用程序的大小和复杂性。

java.util.concurrent包中的某些类提供了不依赖于同步的原子方法,我们将在高级并发对象一节中讨论它们。


上一篇:Thread对象
下一篇:并发活性

博弈
2.5k 声望1.5k 粉丝

态度决定一切