JAVA线程3同步Synchronization(参考官方)

3.1Synchronization

线程之间的交流主要是通过共享访问域路径或引用参考段的对象。这种交流方式效率很高,但是会引发两种错误:线程干扰和内存一致性错误。防止这类错误出现就要用到线程同步这个工具。

 

不过,线程同步会导致线程contention,线程contention是在多个线程试图同时访问同一资源的时候出现的情况,它会导致线程运行变慢,甚至是被挂起。线程饥饿starvation和活锁livelock都是contention的形式。我们会在之后的Liveness中详细说明。


3.2Thread Interference  线程干扰是如何在多个线程访问共享数据时出现的。    

 先来看一个栗子:

class Counter {
    private int c = 0;

    public void increment() {
        c++;
    }

    public void decrement() {
        c--;
    }

    public int value() {
        return c;
    }

}

  

increment方法每次让c+1,,decrement方法每次让c-1。那么如果一个Counter对象同时被多个线程引用,线程之间将相互干扰,而并非按我们的本意去改变c的值。

干扰是啥时候产生的呢?——当多个线程作用于同一数据,操作交错的发生时。这些线程的执行包含多个步骤,而且这些步骤是交叠在一起的。

由于对c进行操作的两个方法都特别简单,只有一句操作,所以看起来并没有“交叠在一起的许多步骤”啊。但是在被虚拟机翻译的时候,这里的一条语句会被分解成多个步骤:(以increment方法为例)

1.取回当前c的值。

2.对取回的c值增加1

3.存储增加后的c的值

c--用同样的方法分解

假设线程A调用了increment方法,几乎同时线程B调用了decrement方法。如果c的初始值为0,两个线程交错着运行各自三个步骤的顺序有可能是这样的:

1 线程A: 取回当前c的值。

2 线程 B: 取回当前c的值。

3 线程A: 对取回的c值加1,c=1。

4 线程B: 对取回的c值减1,c=-1。

5 线程A: 存储增加后的c值,c=1。

6 线程B: 存储减少后的c值,c=-1。

完了完了,最后一句是谁干的谁就把功劳全占了,线程A白干了。这里的例子只是线程交错的一个可能的结果。在不同的环境下,可能是线程B的结果被线程A覆盖,或者没有错误。因为错误是不可预料的,检测和修正线程干扰的错误也是不同的。

 

3.3 Memory Consistency Errors  内存一致性错误。

    

    当不同的线程访问同一数据,却得到不一致的数据时,就会出现内存一致性错误。内存一致性错误特别复杂我们就不谈了哈。不过,我们不用了解其中细节就可以解决问题哦。一个对策就搞定。

    想要避免内存一致性错误的关键是要理解happens-before原则。这个原则用来确保某个语句所写的内存对另一个语句是可见的。下面是说明例子。定义和初始化一个int型的counter:

    int counter=0;

    线程A和B都可以对counter操作。假设线程A让counter增加:

    counter++;

    紧接着,线程B打印出counter:

    System.out.println(counter);

    如果以上两个语句在同一个线程中运行,那打印出的counter值就该是1。但是如果这个两个语句在不同的线程执行,打印出来的counter值可能是0。因为你不能确保线程B能看到线程A对counter的修改,除非你之前在AB之间使用了happens-before原则。

    有几种方式可以创建happens-before原则。其中一个就是同步,我们会在下面看到。

    我们已经了解的两种方式。

    1.当调用Thread.start时,和这个语句用happens-before原则关联的每一个语句,都会自动跟这个线程中的每一条语句创建happens-before原则。新线程可以看到创建自己的代码效果。

    2.当一个线程结束,导致在另一个线程中的join方法返回时,这个结束的线程执行的所有语句将和成功执行的join方法后的所有语句创建happens-before原则。这个结束的线程中的代码效果现在对包含join方法的线程来说是可见的。

3.4Synchronized Method  同步方法,一个简单的关键字可以有效防止线程干扰和内存一致性错误。

       想让一个方法同步,只需在方法声明时加上synchronized关键字。

public class SynchronizedCounter {
    private int c = 0;

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

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

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

 

现有SynchronizedCounter的一个实例count,加上同步关键词之后的方法有以下效果:

 

第一,对于同一个对象的两个同步方法的调用是不可能交错的。当一个线程正在执行一个对象的同步方法时,所有调用此对象的其他线程是阻塞的,直到这个线程处理完这个对象。

第二,当一个同步方法退出时,它会自动与调用同一个对象的同步方法的语句创建happens-before原则。这确保了对此对象状态的改变对所有线程都可见。

 

       构建方法不能同步,在构建方法前加上synchronized关键字是语法错误。同步构建器没有意义的,因为在创建对象的时候,只有创建对象的线程可以访问他。

 

       警告:当你在构造一个多线程共享的对象时,要注意不要使该对象过早泄露出去。例如,假设你有一个叫instance 的列表,想把某个类的所有实例都存到这个列表里。你可能会在这个类的构造函数加上这么一行:

instances.add(this);

但是其他的线程有可能在这个对象的构造方法完成之前就通过instance列表访问该对象。

 

同步方法简单的解决了线程干扰和内存一致性错误的问题:如果一个对象对多个线程可见,所有对该对象的读写操作都是通过同步方法实现的。(一个重要的特例:final关键字修饰的对象,一旦被构造之后就不能被修改,因此使用非同步方法进行读取是没有问题的)。同步方法非常有效,但是也带来了活跃性(liveness)的问题,我们下面会谈到。

 

3.5Implicit Locks and Synchronization  隐式锁和同步,一个更具一般性的同步关键字,解释同步是如何基于隐式锁工作的。

     同步是围绕隐式锁或者叫监视锁实现的。隐式锁在同步的两个方面发挥作用:强行独占访问资源,建立 happens-before 原则。

     每个对象都有一个与之相关的隐式锁。一般来说,线程想要访问某个对象的资源,就要先获取资源对象的隐式锁,访问对象,处理完之后释放隐式锁。在获取和释放资源对象隐式锁的这段时间内,线程拥有(own)该隐式锁。只要某个隐式锁被某线程所有,别的线程便不能获取该隐式锁。当别的线程尝试获取该隐式锁时,这些线程就会堵塞。

      隐式锁被线程一旦被释放,就和下一个获取该隐式锁之间建立了happens-before原则。

同步方法中的锁

      线程调用同步方法时,会自动获取方法相关对象的隐式锁,当方法return的时候释放隐式锁(即使是由异常引起的return也会导致隐式锁被释放)。

      那调用静态同步方法时会怎样呢?因为静态方法都是跟类而不是对象相关联的,线程获取的是属于这个类的隐式锁。因此类的静态域的隐式锁跟类的实例的隐式锁是分离的。

同步代码块

       使用同步代码块实现同步。使用此方法需要指明提供隐式锁的对象。

public void addName(String name) {
    synchronized(this) {
        lastName = name;
        nameCount++;
    }
    nameList.add(name);
}
       就上面的例子说,addName这个方法就同步修改了lastName和nameCount,但是要避免其他对象方法的同步调用。(引起的问题会在Liveness中谈到。)        同步块也适用于非常精细的线程同步。下例中,c1和c2是两个不会同时适用的数据,它们的每次更新改动都必须同步,不要求避免两个数据的交叠操作。不用同步方法和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.6Atomic Access 原子访问,不能被其他线程干扰的操作。

       原子操作就是要么就不运行,一运行就不能被打断知道运行结束。原子操作的效果中途是不可见的,只有操作完成之后才可见。
       我们之前见到的像c++这种简单语句不是原子操作。再简单的语句定义的操作都能被分解成一堆复杂操作。但是,有些操作就是原子性的:

       1.引用变量和原始变量读写(long和double型变量除外)。

       2.volatile关键字修饰的所有变量的读写(包括long和double型变量)。

       原子操作不能交错执行,所以他们不存在线程干扰这种麻烦。但是,这并没有消除同步原子操作的需要,因为内存一致性错误还是存在的。volatile关键字可以减少内存一致性错误的发生,因为所有对同一volatile变量的写操作与后续的读操作之间遵循happens-before原则。也就是说,volatile变量的修改对别的线程来说都是可见的。另外,当某个线程在对volatile变量进行读操作的时候,不仅能看到volatile变量的最后一步修改,还能看到引起该次修改的代码的其他效果。

       通过简单的原子变量访问比通过同步代码获取变量要方便的多,但是同时增加了编程人员避免内存一致性错误的负担。选择哪种方式,还是根据具体情况的大小和复杂程度自己权衡吧。

 参考:

http://docs.oracle.com/javase/tutorial/essential/concurrency/sync.html

http://www.cnblogs.com/goodwin/archive/2010/06/11/1756017.html

http://www.infoq.com/cn/articles/atomic-operation/

猜你喜欢

转载自myalicedream.iteye.com/blog/2208063