java中的线程同步

线程同步

前言:

以下为我翻译过程中的一个长句,为宾语从句:

原文链接:https://docs.oracle.com/javase/tutorial/essential/concurrency/sync.html。

通过阅读英文技术文档,对synchronized中的监视器monitor和volatile以及atomic(原子性)有了更深入的了解,其他能在后面的项目中用到相关知识,解决问题。

同步性

线程主要是通过访问共享的字段和该字段引用的对象来进行通信。这种交流方式及其有效,但可能会引发两种类型错误:线程干扰和内存一致性错误。防止这些错误所需要的工具是同步。

然而,线程同步会引入线程竞争,当两个或者更多线程同时访问相同的资源,会导致java运行时以更慢的速度来执行一个或者更多的线程,甚至会挂起它们的执行。

该章节包含以下主题:

  1. 线程干扰描述了当多个线程访问共享数据时,如何引入错误。
  2. 内存一致性错误描述了由于共享内存不一致导致的错误。
  3. Synchronize methods描述了一种简单的处理方法,该方法可高效阻止线程干扰和内存一致性问题。
  4. 隐式锁和同步描述了一种更通用的同步习惯用法,并描述了同步如何基于隐式锁。
  5. 原子访问,讨论了一种不被其他线程干扰操作的通用思想。

线程干扰

请看一个简单的称为Count类

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. 将加1后的值储存回c中。

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

假设线程A调用递增的同时线程B调用递减。假设变量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的结果丢失,或者就没有错误。因为它们不可预测,线程干扰的bug很难检测和修复。

内存一致性错误

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

避免内存一致性错误的关键是理解发生前的关系。这种关系只是保证了被一个特定语句所写的内存对另外一个特定语句是可见的。要了解这一点,请看下面的示例。假设一个简单的int字段被定义和初始化:

int counter = 0;

该counter字段被两个线程(A和B)访问,假设线程A自增counter:

counter++;

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

System.out.println(counter);

如果两个语句在相同的线程执行,它将会是安全的,打印出的值是“1”。但如果这两个语句执行在不同的线程,打印出来的值可能是“0”,因为无法保证线程A改变counter的值对线程B可见,除非程序员在这两个语句之间建立了happens-before关系。

有几个动作可以建立happens-before关系。其中一个是同步,将会在下个章节讲到。

我们已经看到2种创建happens-before关系的动作。

当一个语句调用Thread.start时,每一个与该语句有happens-before关系的语句同样也有一个与新线程执行的语句有happens-before关系。导致新线程创建代码的影响对这个新线程是可见的。

当一个线程终止,并引起另一个线程jion返回时,这个终止线程执行的语句和这个接下来成功jion线程的语句有happens-before关系。该线程代码的影响对jion线程是可见的。

有关创建happens-before关系的操作列表,请参考java.util.concurrent的Summary页。

同步方法

Java编程语言提供了两个基本的同步习惯用法:同步方法和同步语句。这两个语句中比较复杂的同步语句将在下节中描述。本章节是有关同步方法。

要使方法同步,只需将sysnchronized关键字添加到它的声明中:

public class SynchronizedCounter {
    private int c = 0;

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

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

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

如果count是SynchronizedCounter的实例,那么使方法同步有两个效果:

  1. 首先,同一个对象的两个同步方法调用不可见交错。当一个线程正在执行一个对象的同步方法,为同一对象调用同步方法的所有其他线程都会阻塞(暂停执行),直到第一个线程已经完成。
  2. 其次,当一个同步方法退出时,它会自动建立为该相同对象同步方法的任何后续调用建立happens-before关系。这保证了对该对象状态的变化对其他所有线程都是可见的。

需要注意的是,构造函数不能同步-在构造函数上用synchronized关键字会提示语法错误。同步构造函数没有意义,因为只有创建对象的线程才能在对象被构造时访问它。

注意:当构造一个将在多个线程之间共享的对象时,需要小心对象的引用没有过早的泄漏。例如,假设你想维持一个包含每个类实例的称为instances的列表。你可能会尝试添加下面一行代码到你的构造函数中:

instances.add(this);

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

同步方法启用了一种简单的策略来防止线程干扰和内存一致性错误。如果一个对象对多个线程时可见的,那么对该对象变量的所有读和写都是通过同步方法完成的。(有一个例外,final字段,对象被构造后不能被修改,但一旦对象被构造,是可以被非同步方法安全读取)这个策略是高效的,但是也会带来活力方面的问题,我们将在后面的内容中看到。

内在锁和同步

同步时围绕一个称为内部锁或者监视器锁的内部实体构建的。(API规范通常将此实体简单的称为“监视器”)内部锁在同步的两个方面起了作用:强制对对象状态的独占访问和强制建立happens-before关系,这对可见性很重要。

每一个对象有一个和它关联的内在锁。按照惯例,需要独占和一致性访问对象字段的线程必须在访问这些字段前获得内在锁,当它处理完时释放内在锁。线程在获得锁和释放锁之间拥有内在锁。只要一个线程拥有一个内在锁,另一个线程不能获得相同锁。当另一个线程尝试获取该锁时,它将会阻塞。

当一个线程释放内在锁时,在该操作和随后获得同一锁的任何操作之间建立happens-before关系。

同步方法中的锁

当一个线程调用同步方法,它会自动的为该方法对象获得内在锁和当该方法执行完时释放该内在锁。即使返回是由未捕获异常引起的,也会释放该锁。

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

同步代码块

另一种创建同步代码的方式使使用同步代码块。不像同步方法,同步代码块必须指定一个提供内在锁的对象:

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

在这个示例中,addName方法需要同步变量lastName和nameCount的变化,但是同样需要避免同步调用其他对象的方法(从同步代码中国调用其他对象可能会产生问题,这些问题将会在live章节中描述),如果没有同步代码块,就必须要有一个单独的非同步的方法,用于调用nameList.add。

同步代码块对于提高细粒度同步的并发也很有用。例如,MsLunch类有两个实例字段c1和c2,它们从来不会一起使用。这些所有的字段更新必须同步,但是没有理由阻止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++)不描述原子操作。甚至非常简单的表达式都可以定义成复杂的操作,而该操作可以分解成其他操作。然而你可以指定原子的操作:

  1. 对于引用变量和大多数基本变量(除了long和double以外的所有类型),读写都是原子性的。
  2. 所有申明为volatile的变量(包括long和double变量),读写都是原子性的。

原子操作不能交错,因此可以不用担心线程干扰的使用它们。然后这并没有消除需要同步原子操作的需要,因为内存一致性错误依然是可能的。使用volatile变量减少内存一致性错误的风险,因为任何对volatile变量的写操作都会与该变量的后续读操作建立happens-before关系。这意味着对一个volatile变量的改变总是对其他线程可见的。更重要的是,它意味着当一个线程读volatile变量,它不仅仅可以看到该volatile变量最近的改变,同样会看到该代码引起变化的副作用。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值