资讯专栏INFORMATION COLUMN

Java™ 教程(同步)

Edison / 1049人阅读

同步

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

但是,同步可能会引入线程竞争,当两个或多个线程同时尝试访问同一资源并导致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++可以分解为三个步骤:

检索c的当前值。

将检索的值增加1。

将增加的值存储在c中。

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

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

线程A:检索c

线程B:检索c

线程A:递增检索值,结果是1

线程B:递减检索值,结果是-1

线程A:将结果存储在c中,c现在是1

线程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对象 下一篇:并发活性

文章版权归作者所有,未经允许请勿转载,若此文章存在违规行为,您可以联系管理员删除。

转载请注明本文地址:https://www.ucloud.cn/yun/73013.html

相关文章

  • Java 8 并发教程同步和锁

    摘要:在接下来的分钟,你将会学会如何通过同步关键字,锁和信号量来同步访问共享可变变量。所以在使用乐观锁时,你需要每次在访问任何共享可变变量之后都要检查锁,来确保读锁仍然有效。 原文:Java 8 Concurrency Tutorial: Synchronization and Locks译者:飞龙 协议:CC BY-NC-SA 4.0 欢迎阅读我的Java8并发教程的第二部分。这份指南将...

    wyk1184 评论0 收藏0
  • Java教程(原子变量)

    原子变量 java.util.concurrent.atomic包定义了支持单个变量的原子操作的类,所有类都有get和set方法,类似于对volatile变量的读写操作,也就是说,set与在同一个变量上任何后续的get具有先发生关系,compareAndSet原子方法也具有这些内存一致性特性,适用于整数原子变量的简单原子算法也是如此。 要查看如何使用此包,让我们返回我们最初用于演示线程干扰的Cou...

    bang590 评论0 收藏0
  • Java教程(高级并发对象)

    高级并发对象 到目前为止,本课程重点关注从一开始就是Java平台一部分的低级别API,这些API适用于非常基础的任务,但更高级的任务需要更高级别的构建块,对于充分利用当今多处理器和多核系统的大规模并发应用程序尤其如此。 在本节中,我们将介绍Java平台5.0版中引入的一些高级并发功能,大多数这些功能都在新的java.util.concurrent包中实现,Java集合框架中还有新的并发数据结构。 ...

    xiaotianyi 评论0 收藏0
  • Java 8 并发教程:原子变量和 ConcurrentMa

    摘要:并发教程原子变量和原文译者飞龙协议欢迎阅读我的多线程编程系列教程的第三部分。如果你能够在多线程中同时且安全地执行某个操作,而不需要关键字或上一章中的锁,那么这个操作就是原子的。当多线程的更新比读取更频繁时,这个类通常比原子数值类性能更好。 Java 8 并发教程:原子变量和 ConcurrentMap 原文:Java 8 Concurrency Tutorial: Synchroni...

    bitkylin 评论0 收藏0
  • Java教程(并发活性)

    并发活性 并发应用程序及时执行的能力被称为其活性,本节描述了最常见的活性问题,死锁,并继续简要描述其他两个活性问题,饥饿和活锁。 死锁 死锁描述了两个或多个线程永远被阻塞,等待彼此的情况,这是一个例子。 Alphonse和Gaston是朋友,是礼貌的忠实信徒,礼貌的一个严格规则是,当你向朋友鞠躬时,你必须一直鞠躬,直到你的朋友有机会还礼,不幸的是,这条规则没有考虑到两个朋友可能同时互相鞠躬的可能性...

    KaltZK 评论0 收藏0

发表评论

0条评论

最新活动
阅读需要支付1元查看
<