Java并发编程(三)同步

Java并发编程(三)同步

3. 同步

线程主要通过共享内存(成员变量)来进行通信。这种形式的交流很有效率,但是可能会导致两种错误:线程干扰和内存一致性错误。预防这些错误的方式就是同步

然而,同步会导致线程竞争,线程竞争会导致多个线程在同一时刻访问同一资源,和导致Java运行时执行一个或多个线程缓慢,甚至导致执行阻塞。长时间等待和活锁(Starvation and Livelock)就是线程竞争的两种形式。

本节包括了以下内容,

  • 线程干扰描述了当多一个线程访问共享数据时为什么会引入错误
  • 内存一致性错误描述了共享内存不一致视图导致的错误
  • 同步化方法描述了一个可以预防线程接口和内存一致性错误的简单方式
  • 隐式锁和同步描述了一个更加通用化的同步方法,并介绍了如何基于隐式锁构建同步
  • 原子操作介绍了一些其他线程不能干扰的操作。

3.1 线程干扰

考虑一个简单类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的调用都减少1。然而,如果一个Counter对象被多个对象共享,线程之间的干扰可能会导致不发生预期的结果。

当两个不同的线程对同一个数据进行两个操作,就会反生干扰。这意味着这两个操作包括多个步骤,而且步骤的执行序列有重叠。

当不同Counter的操作不会发生交错,因为c的所有操作都是单一的、简单的语句。然而,即使是单一的语句,也又可能被虚拟机解释成多个步骤执行。我们不去核实虚拟机执行的具体步骤,我们仅仅知道c++被分成了以下三个步骤,

  1. 得到c的当前数值
  2. 当值增加1
  3. 将增加后的数值保存到c中

c–命令可以用同样的步骤分解,只不过第二个步骤是减1。

假设在线程B调用decrement的同时线程A调用了increment。如果c的初始值是0,交错的操作序列可能如下,

  1. 线程A:获取c
  2. 线程B:获取c
  3. 线程A:将获取的值增加1,结果是1
  4. 线程B:将获取的值减少1,结果是-1
  5. 线程A:将结果保存到c中,c的值为1
  6. 线程B:将结果保存到c中,c的值为-1

线程A的结果丢失了,被线程B覆盖掉了。这个部分交错只是其中一种可能。在其他不同的情况下,可能是线程B的结果被覆盖了,或者根本没有错误。因为结果不可预测,所以干扰bug很难检测和修复。

3.2 内存一致性错误

当不同的线程对哪些应该成为共享数据有不一样的看法时,内存一致性错误就会发生。导致内存一致性错误的原因很复杂,本文不将介绍这些。幸运的是,开发者并不需要详细了解这些详细原因。他们需要知道的仅仅是避免该错误即可。

避免内存一致性错误的关键是了解happens-before规则。该规则保证了一个语句的内存写入对另一个语句是可见的。考虑以下的例子,假设一个简单的int域已经定义和初始化,

int counter = 0;

该域两个线程A和B共享。如果A增加counter:

counter++;

立即线程B打印出counter,

System.out.println(counter);

如果两个语句在同一线程中执行,那么可以保证输出的结果就是1。但是如果这两个语句在不同的线程中,输出的结果就可以是0,因为不能保证线程A对counter的改变对线程B是可见的,除非开发者在这两个语句中建立了happens-before关系。

有一些措施可以建立happens-before关系。其中之一是同步,我们将接下来介绍。

我们已经见到两种措施可以建立happens-before关系,

  • 当一个语句调用Thread.start,所有与该语句保持happens-before关系的语句和该线程中的所有语句也保持happens-before关系。创建新线程的代码对新线程是可见的。
  • 当一个线程终止并导致其他线程的Thread.join返回,那么终止线程的所有代码和接下来join的线程保持happens-before关系。线程中的代码对执行join的线程是可见的。

了解所有可以建立happens-before关系的操作,请看Package java.util.concurrent

3.3 同步方法

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;
    }
}

如果counter是SynchronizedCounter类的一个实例,那么使得其方法同步有以下效果:

  • 首先,在同一个对象上是无法同时存在同步化方法的两次调用。当一个线程在一个对象上执行同步方法时,其他在同一个对象上执行的同步化方法将阻塞,直到第一个线程执行完同步方法。
  • 第二,当一个同步方法结束后,它会在同一个对象上的同步方法的后来调用自动建立happens-before关系。这保证该对象状态的改变对其他线程都是可见的。

注意,构造函数是无法同步化的,在构造函数上使用synchronized关键词来是语法错误。同步化构造函数没有意义,因为当一个对象初始化时,只有创建该对象的线程可以访问它。

警告:当构造一个在不同线程中共享的实例时,请千万小心使用该对象的引用,确保其不过早的泄漏。例如,你想要维护一个列表,名为instances,其包括了类的每个实例。你可能会尝试在你的构造器中加入以下语句,

instances.add(this);

所有的其他线程都可以在一个对象构造之前,使用instances来访问该对象。

同步方法提供了一个简单的方法来预防线程干扰和内存一致性错误:如果一个对象对多个线程可见,对这个对象的所有成员变量的所有读写都通过同步化方法。一个例外:final域,当对象初始化后无法修改,当对象初始化后,可以安全地通过非同步化方法读。同步化方法是有效的,但是可能会带来活跃度问题,在后面的小节介绍。

3.4 内部锁和同步

同步通过一个称作内部锁(intrinsic lock)或者监控锁(monitor lock)的内部实体实现(API规范中将该实体称作监控器,monitor)。内部锁在同步的两个方面都扮演了重要的角色:强制保护对象状态的单一访问和建立happens-before关系。

每个对象都有一个和它对应的内部锁。按照规定,当一个线程需要对象的单一和一致访问权时,必须在访问对象前获得该对象的内部锁。一个线程在拥有这个锁和释放这个锁之间的时间被称作占有这个内部锁。只要一个线程拥有了内部锁,那么其他线程就不能获得这个锁。当他们尝试获得这个锁时就会被阻塞。

当一个线程释放了一个内部锁,在这个动作和该锁的所有后续获取之间建立了一个happens-before关系。

3.4.1 同步化方法中的锁

当一个线程调用一个同步化方法,它自动获取该方法对应的对象的内部锁,并且当该方法调用完后释放该锁。即使该方法由于未捕获的异常导致的退出也会释放该锁。

你或许会奇怪,当一个静态的同步化方法调用会发生什么,由于一个静态方法对应的是一个类,而不是对象。在这种情况下,线程请求获得该类的内部锁。因此访问一个类中的静态域被一个不同于该类的所有实例的锁控制。

3.4.2 同步化声明

另一个创建同步化代码的方法是同步化声明。和同步化方法不同,同步化声明必须指定要同步的对象,该对象提供内部锁。

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

在这个例子中,addName方法需要同步lastName和nameCount,同时也要避免同步化调用其他对象的方法。(在同步化代码中调用其他对象的方法可能会导致活跃度的问题。)如果没有同步化声明,当调用nameList.add时,将会有一个分离的,不同步的方法。

同步化声明还提供了细粒度的同步,来改善并发的性能。举个例子,假设MsLunch类含有两个实例变量,c1和c2,并且这两个变量从来不一起使用。所有变量的更新都需要同步,但是没有原因在更新c2的时候,不能更新c1——这样做会带来不必要的锁,降低并发的性能。我们单独创建了两个对象来提供锁,而不是使用同步化方法或者this对应的锁。

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++;
        }
    }
}

使用该方法时,请格外注意。你必须保证交互访问变量是安全的。

3.4.3 可重入的同步

一个线程无法获得另一个线程拥有的锁。但是一个线程可以获得其已经拥有的锁。允许一个线程多次获得同一个锁,就是可重入的同步。其描述了一种情况,同步代码,直接地或间接地,调用另一个包含同步代码的方法,并且所有的代码使用同一个锁。如果没有可重入的锁,同步化代码必须采用额外的措施来避免一个线程导致它自己阻塞。

3.5 原子访问

在编程语言中,原子操作指的是,那些一次就执行完的操作。一个原子操作无法在中间停止:要么完全发生,要不就不发生。直到一个原子操作结束,该操作的影响才可见。

我们已经看到自增操作,比如c++,并不是一个原子操作。即是简单的语句也可能定义复杂的操作,这些操作可以分解为其他操作。然而,有一些操作你可以指定其为原子,

  • 所有对象引用的读写和大部分基本类型变量(除了long和double类型)的读写都是原子的
  • 所有声明为volatile类型的变量(包括long和double类型)的读写都是原子的

原子操作不能被打断,所以不用担心它们会出现线程干扰。但是,这并不意味着不需要同步所有的原子操作,因为内存一致性错误仍然可能出现。使用volatile可以降低内存一致性错误的风险,因为任何对volatile变量的写都和之后的读变量建立了一个happens-before关系。这意味着一个volatile变量的改变对其他线程都是可见的。另外,当一个线程访问一个volatile变量时,它不仅仅能看到volatile的最后一次改变,也能看到导致该改变的代码的其他影响。

通过简单的原子访问变量比通过同步代码更加有效,但是需要开发者更加小心的避免内存一致性错误。这取决于应用的大小和复杂度。

java.util.concurrent包中的一些类提供了一些不基于同步的原子访问方法。我们将在High Level Concurrency Objects节介绍。

文章翻译自Java Tutorials, Concurrency,翻译难免会有纰漏,欢迎读者讨论指正。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值