java并发(三、同步)

、同步

线程通信首先通过共同访问一个字段、对象的字段。这种形式的通信是非常有效的。但是会产生两种错误:线程冲突和内存一致性错误。需要用Synchronization工具避免这些错误。

l  线程冲突:描述了当多个线程访问共享数据时错误是怎么发生的。

l  内存一致错误:描述了共享内存的不一致展现的错误结果。

l  同步方法:描述了一个简单的习惯,能够避免线程冲突和内存不一致错误。

l  隐式锁和同步:描述了一个更普遍的同步习惯,还描述了怎么基于隐式锁进行同步。

l  原子访问:谈到了一个避免和其他线程冲突的操作习惯。

(一)           线程冲突

看下面的Counter类:

class Counter {
    private int c = 0;
    public void increment() {
        c++;
    }
    public void decrement() {
        c--;
    }
    public int value() {
        return c;
    }
}

Count的increment方法对c做+1操作decrement方法对c做-1操作。然而,这个计数器一旦被多线程调用,线程冲突会阻止预期的结果。

冲突放生在当两个这两个操作运行在不同的线程里,但是访问了同一个数据。这说明两个操作包含很多步骤,步骤顺序重叠。

对Counter实例的操作看起来并不是交错的,因为对c的两个操作是单一的、简单的。但是,一个简单的操作对于jvm来说也是有很多步骤的。我们不想去考虑jvm的特殊步骤,这已经能了解一个c++表达式分为三步:

  1. 取回当前值c
  2. 取回的值加1
  3. 把增长的值写回c

表达式c—能被分解成同样的方式,只是第二步是减一。

假设线程A调用加法,同时线程B调用减法。如果c初始值是0,两个线程的交错动作可能是下面的顺序:

  1. Thread A: 取回 c.
  2. Thread B: 取回 c.
  3. Thread A: 增加取回的值; 结果是1.
  4. Thread B: 减少取回的值; 结果是 -1.
  5. Thread A: 结果存回c; c is now 1.
  6. Thread B: 结果存回c; c is now -1.

线程A的结果丢失了,被线程B覆盖。这个执行顺序只是其中一种可能。在不同的情况下,可能B的结果丢失,或者根本没错误。因为这是不可预料的,线程冲突bug难以发现和解决。

 

 

(二)           内存不一致错误

内存不一致错误发生在不同线程对同一个数据的不一致查看。引起内存不一致是复杂的,超出了本教程的范围。辛运的是,程序员不需要详细理解原因。只需要一个侧率避免它。

    避免内存不一致的关键是理解happens-before关系。这个关系是一个简单的保证通过一个明确的语句对于另个明确的语句是可见的。看下面的例子,加入一个简单的域被定义和初始化:

int counter = 0;

这个计数域共享给两个线程,A和B。假设线程A增加计数:

    counter++

然后,没过多久,线程B打印counter:

    System.out.println(counter);

    如果两个语句被同一个线程执行,我们可以确定打印的值是1.但是如果两个语句被不同线程执行,打印值可能是0,因为不能担保线程A改变counter对B是可见的 ——除非程序员确保两个语句之间的happens-before关系。

有一些方法创建happens-before关系。其中一个是同步,我们将在下面的章节看到。

我们看两种方法创建happens-before关系:

l  当一个语句调用Thread.start,每个语句和这个语句有happens-before关系,同时新线程的执行语句之间也是happens-before关系。

l  当一个线程终止同时引起一个Thread.join另一个线程返回,那么,所有被终止线程执行的语句和接下来成功join的语句是happens-before关系。线程的内代码对于执行join的线程是可见的。

创建Happens-before关系的方法列表,参考Summary page of the java.util.concurrent package.

(三)           同步方法

Java编程语言提供了两个基本的同步语句:synchronized methods 和 synchronized statements。更复杂的synchronized statement下一章描述。这章是关于同步方法。

    使一个方法同步,只要简单的加synchronized关键字到它的声明中:

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的一个实例,那么是这些方法同步有两个效果:
两个同步方法交错的访问同一个对象是不可能的。当一个线程执行一个同步方法访问对象,所有的其他线程调用同步方法访问同一个对象是阻塞的(推迟执行)直到执行完成。
l  当一个同步方法退出,它自动的和任何后面的同步方法建立一个happens-before关系,这保证了对象状态的改变对所有线程是可见的。
注意:构造方法不能同步——对构造方法使用同步是语法错误。同步构造方法没有意义,因为只有创建对象的线程在它创建之后能够访问它。

Note that constructors cannot be synchronized — using the synchronized keyword with a constructor is a syntax error. Synchronizing constructors doesn't make sense, because only the thread that creates an object should have access to it while it is being constructed.


警告:当构造一个对象共享给多个线程,小心对象的引用不要发生过早泄露。例如,假如你想保持一个叫instances的List包含class的每一个实例。你可能添加下面的语句到构造方法:

instances.add(this);
然而另一个线程可以使用instances访问对象,在构造方法之前。

士大夫同步方法使用一个简单的策略防止线程冲突和内存不一致错误:如果一个对象被多个线程访问,所有对这个对象的变量读写操作都要通过同步方法。(一个重要的例外:final域,对象构造之后不能被修改,能够被不同步方法安全的读)这个策略是有效的,但是会产生活性问题,我们在下面的章会提到。

(四)           固有锁和同步

同步是建立在被叫做“固有锁”或者“监听锁”的内存实体上的。(API规范经常叫做监视器monitor)。固有锁在同步的两个方面起作用:互斥访问一个对象的状态和 简历happens-before关系必须得可见性。

每个对象有一个与之相关的固有锁。按约定,一个线程独占的和一致的去访问一个对象的域,所以不得不在访问对象之前取的它的固有锁,然后处理完之后释放固有锁。在一个线程在取的锁和释放锁之间的时间叫做持有固有锁。只要一个线程持有固有锁,没有其他线程能过获得同一个锁。其他线程将延迟当它试图取的锁。

当一个线程释放一个固有锁,一个happens-before关系被建立在当前动作和后续的获得同一个锁的动作。

1.         同步方法中的锁

当一个线程调用同步方法,它自动获取这个方法的对象的固有锁,当方法返回释放。即使方法由一个未经捕获的异常结束,也要释放锁。

    你可能想知道当一个静态的同步方法被调用的时候会发生什么,因为一个静态方法是和class联系在一起的,不是对象。这样,线程获取和这个类有关的Class的对象的固有锁。因此,访问类的静态域的锁的控制和对象锁是不一样的。

2.         同步声明

另一个创建同步代码的方式是用“同步声明”。和同步方法不同,同步声明必须给固有锁指定对象:

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

 

在这个例子里,addName方法需要同步改变lastName和nameCount,但是也要避免其他对象的方法。(对同步代码调用其他对象的方法会产生一个问题,在Liveness中描述。)没有同步声明,这里就只有一个单独的、非同步的、目的是调用nameList.add的方法。

同步声明用细粒度的同步来改进并发。假如,类Mslunch有连个域,c1和c2,他们不会共同使用。所有更新他们的操作必须是同步的,但是没有理由阻止更新c1的同时穿插更新c2——而且这样做可以减少并发阻塞。而不是用同步方法或使用this关联的锁,我们创建两个只提供锁的对象。

使用这个方法需要格外小心。你必须完全确定交叉访问受影响的域是安全的。

3.     重入同步

回想,一个线程不能获取另一个线程拥有的锁。但是一个线程可以获取已经拥有的锁。重入同步使一个线程不止一次获取同一个锁成为可能。想象一个情况,一个同步代码,直接或者间接的调用另一个同步代码。没有“重入同步”同步代码将不得不考虑去避免自己引起锁的情况。

(五)           原子访问

在程序设计中,一个原子操作就是一个有效的同时发生的操作。一个原子操作不能被中断:要么操作完成,要么不操作。在原子操作完成之前不会产生任何效果。

    我们看过一个增量表达式,像c++,这不是原子操作。甚至很多简单的表达式可以被分解为很多复杂的动作。但是这些动作你可以指定成原子的:

  • 读写操作对于引用变量和基本类型变量(出了long和double)
  • 读写操作对于所有定义了volatile的变量是元真子的(包括long和double)

原子操作不能被交错,所以不用考虑线程冲突。但是,并不是完全不需要步了,因为,内存不一致错误仍然存在。使用volatile关键字会减少内存不一致错误的风险,因为,任何对volatile变量的写操作和后面的读操作建立了happens-before关系。这意味着改变volatile变量对于其他线程总是可见的。更重要的是,这也意味着当一个线程读一个volatile变量,它读到的不仅是对变量的最后一次修改,也可能读到修改导致的负面影响。

使用原子变量访问比同步代码更有效,但是需要程序员小心,去避免内存不一致错误。是不是要考虑其他影响,要根据工程的规模和复杂度。

Java.util.concurrent包中的一些类提供了不依赖同步的原子操作的方法。我们将在Level Concurrency Objects一章中讨论。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值