Java™ 教程(同步)

同步

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

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

本节包括如下主题:c++

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

线程干扰

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

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对象,则线程之间的干扰可能会妨碍这种状况按预期发生。程序员

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

对于Counter实例的操做彷佛不可能进行交错,由于对c的两个操做都是单个简单的语句,可是,即便是简单的语句也能够由虚拟机转换为多个步骤,咱们不会检查虚拟机采起的具体步骤 — 只需知道单个表达式c++能够分解为三个步骤:编程

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

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

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

  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对象

下一篇:并发活性

相关文章
相关标签/搜索