首页 > 代码库 > 漫谈并发编程(三):共享受限资源

漫谈并发编程(三):共享受限资源

解决共享资源竞争

一个不正确的访问资源示例
     考虑下面的例子,其中一个任务产生偶数,而其他任务消费这些数字。这里,消费者任务的唯一工作就是检查偶数的有效性。
     我们先定义一个偶数生成器的抽象父类。
public abstract class IntGenerator {
     private volatile boolean canceled = false;
     public abstract int next( );
     public void cancle( ) { canceled = true; }
     public boolean isCanceled( ) { return canceled; }
}
     下面定义消费者任务。
public class EvenChecker implements Runnable {
     private IntGenerator generator;
     private final int id;
     public EvenChecker(IntGenerator g , int ident) {
          generator = g;
          id = ident;
     }
     public void run() {
          while( !generator.isCanceled() ) {
               int val = generator.next();
               if(val % 2 != 0) {
                    System.out.println(val + "not even!");
                    generator.cancle();
               }
          }
     }
     public static void test(IntGenerator gp, int count) {
          System.out.println("Press Control -C to exit");
          ExecutorService exec = Executors.newCachedThreadPool();
          for(int i = 0; i < 10;i++) 
               exec.execute(new EvenChecker(gp, i));
          exec.shutdown();
     }
     public static void test(IntGenerator gp) {
          test(gp, 10);
     }
}
public class EvenGenerator extends IntGenerator {
     private int currentEvenValue = http://www.mamicode.com/0;>
/* output:
Press Control -C to exit
15243not even!
15245not even!
     这个程序最终失败,因为各个EvenChecker任务在EvenGenerator处于"不恰当的"状态时,仍能够访问其中的信息。比如下面场景:1. A线程执行一条自加操作后放弃时间片,B线程接着执行两次自加及输出。 2. A线程在自加后return语句前放弃时间片,B线程完成一次自加,然后A又执行,在这种情况下仍然会返回奇数。

解决竞争的方法
     基本上所有的并发模式在解决线程冲突问题的时候,都是采用序列化访问共享资源的方案。这意味着在给定时刻只允许一个任务访问共享资源,通常这是通过在代码前面加上一条锁语句来实现的,这就使得在一段时间内只有一个任务可以运行这段代码。因为锁语句产生了一种互相排斥的效果,所以这种机制常常被称为互斥量(mutex)
     Java以提供关键字synchronized的形式,为防止资源冲突提供了内置支持。当任务要执行被synchronized关键字保护的代码片段的时候,它将检查锁是否可用,然后获取锁,执行代码,释放锁。
     要控制对共享资源的访问,得先把它包装进一个对象,然后把所有要访问这个资源的方法标记为synchronized,如果某个任务处于一个对标记为synchronized的方法的调用中,那么在这个线程从该方法返回之前,其他所有要调用类中任何标记为synchronized方法的线程都将被阻塞。
     下面是声明synchronized方法的方式:
synchronized void f() {/*... */}
synchronized void g(){/*....*/}
     所有对象都自动含有单一的锁(也称为监视锁)。当在对象上调用其任意synchronied方法的时候,此对象都被加锁,这时该对象上的其他synchronized方法只有等到前一个方法调用完毕并释放了锁之后才能被调用。在使用并发时,将域设置为private是非常重要的,这是一种保证,保证没有其他任务可以直接访问到该域。
     一个任务可以多次获得对象的锁。如果一个方法在同一个对象上调用了第二个方法,后者又调用了同一个对象上的另一个方法,就会发生这种情况。JVM负责跟踪对象被加锁的次数。如果一个对象被解锁(即锁被完全释放),其计数变为0。
     针对每个类,也有一个锁(作为类的class对象的一部分),所以synchronized static方法可以在类的范围内防止对static数据的并发访问。
     总结来说,在多个线程访问同一对象时,如果会出现线程竞速问题(所有线程只读则不会出现此状况),解决办法是把这个共享对象转变为线程安全对象(或者使被调用的方法是线程安全的),或者将所有线程对该资源的访问序列化(用锁在线程自身任务内同步)。如果对该资源的访问是复合操作,即使共享对象自身是线程安全的,也无法保证数据的一致性,例如:if( put(**))这种操作,就必须要把复合操作全部包含在锁内,对于存在多个对象的共享,如果相互之间有状态的关联,这种处理方式依然有效。

同步控制EvenGenerator     
     通过在EvenGenerator.java中加入synchronized关键字,可以防止不希望的线程访问:
public class SynchronizedEvenGenerator extends IntGenerator {
     private int currentEvenValue = http://www.mamicode.com/0;>
使用显式的Lock对象
     java.util.concurrent类库中还包含有显式的互斥机制。Lock对象必须被显式的创建、锁定和释放。因此,它与内建的锁形式相比,代码缺乏优雅性,但更加灵活。下面是采用Lock重写的EvenGenerator。
public class MutexEvenGenerator extends IntGenerator {
     public int currentEvenValue = http://www.mamicode.com/0;>
     MutexEvenGenerator添加了一个互斥调用的锁,并使用lock()和unlock()方法在next()内部创建了临界资源。当你在使用Lock对象时,有一些原则需要被记住:你必须放置在finally子句中带有unlock()的try-finally语句,以避免该锁被无限期锁住。注意,return语句必须在try子句中出现,以确保unlock()不会过早发生,从而将数据暴露给第二个任务。
     Lock比synchronized灵活体现在:可以在共享对象中使用多个Lock来分隔操作,以提高并发度。除此之外,Lock可以支持你尝试获取锁且最终获取锁失败,或者尝试着获取锁一段时间,然后放弃它。
public class MyMutexTest {
     private static Lock lock = new ReentrantLock();
     public static void main(String args[]) {
          new Thread( new Runnable() {
               public void run() {
                    lock.lock();
                    while(true);
               }
          }).start();
          new Thread(new Runnable() {
               public void run() {
                    if( lock.tryLock() == false ) {
                         System.out.println("acquire lock failed 1");
                    }
               }
          }).start();;
          new Thread( new Runnable() {
               public void run() {
                    try {
                         if(lock.tryLock(2, TimeUnit.SECONDS) == false) {
                         System.out.println("acquire lock failed 2");
                    }} catch (InterruptedException e) {
                    }
                }
          }).start();
     }
}
/*output:
acquire lock failed 1
acquire lock failed 2

原子性与可见性

     原子性可以应用于除long和double之外的所有基本类型之上的"简单操作"。对于读取和写入除long和double之外的基本类型变量这样的操作,可以保证它们会被当作不可分(原子)的操作来操作内存。但是JVM可以将64位(long和double变量)的读取和写入当作两个分离的32位操作来执行,这就产生了在一个读取和写入操作中间发生上下文切换,从而导致不同的任务可以看到不正确结果的可能性(这有时被称为字撕裂,因为你可能会看到部分被修改过的数值)。但是,当你定义long或double变量时,如果使用valatile关键字,就会获得(简单的赋值与返回操作的)原子性。
     对于可见性的讨论,从下面的例子开始:
public class MyVisibilityTest implements Runnable{
     private static  boolean mv = false;
     private  static int integer = 0;
     @Override
     public void run() {
          while(true) {
               if(mv == true) {
                    System.out.println(integer);
                    return;
               }
          }
     }
     public static void main(String []args) {
          new Thread(new MyVisibilityTest()).start();
          integer = 34;
          mv = true;
     }
}
     上面的程序运行效果,有时很久才打印出34,有时甚至匪夷所思的打印出0,这是由于对象的可见性的缘故。
在多处理器系统上,相对于单处理器而言,可见性问题要突兀的多。一个任务做出的修改,即使在不中断的意义上讲是原子性的,对其他任务也可能是不可见的(例如,修改只是暂时性地存储在本地处理器的缓存中)。

volatile的作用
     把变量声明为volatile类型后,编译器与运行时都会注意到这个变量是共享的,因此不会将该变量上的操作与其他内存操作一起重排序。volatile变量不会被缓存在寄存器或者对其他处理器不可见的地方,因此在读取volatile类型的变量时总会返回最新写入的值。
     如果我们将上面例子中的成员变量声明为volatile类型,则程序将"正常"输出。
     下面借用《Java并发编程实战》中对于volatile类型使用场景的描述。
  • 对变量的写入操作不依赖变量的当前值(如count++),或者你能确保只有单个线程更新变量的值
  • 该变量不会与其他状态变量一起纳入不变性条件中
  • 在访问变量时不需要加锁(因为volatile只确保可见性)
     个人使用经验:对于一个对象,无论是复杂类型还是基本类型如果在该对象上存在多个线程间的复合操作(如count++、if( ){do()}),则不应在此对象上使用volatile(而是直接使用同步机制保证线程安全性)。在满足上述条件的基础上,如果该变量是简单类型,则可以使用volatile保证其可见性,由于简单类型具有原子性(double、long使用volatile后也具有),则对该变量的访问是线程安全的。如果该变量是复合类型,如果对该变量的写操作只是将引用直接修改,那么也可以可以volatile保证写操作的可见性,在此基础上,对该复合类型的操作也就是线程安全的了。
     对于基本类型来说,原子性+可见性 = 该变量的线程安全性,就算变量自身是线程安全的,对该变量的复合操作也会导致线程不安全。

原子类

     Java SE5引入了诸如AtomicInteger、AtomicLong、AtomicReference等特殊的原子性变量类。它们提供了下面形式的原子性条件更新操作:
     boolean compareAndSet(expectedValue, updateValue)
     这些类被调整为可以操作在机器级别上的原子性。如果在只共享一个对象的前提下,它为你提供了一种将线程间的复合操作转为线程安全操作的机会。
     下面用利用AtomicInteger重写MutexEvenGenerator.java。
public class AtomicEvenGenerator extends IntGenerator {
     private AtomicInteger currentEvenValue =  http://www.mamicode.com/new AtomicInteger(0);>

临界区

     有时,你只是希望防止多个线程同时访问方法内部的部分代码而不是防止访问整个方法,通过这种方式分离出来的代码段被称为临界区(critical section),它也使用synchronized关键字建立。这里,synchronized被用来指定某个对象,此对象的锁被用来对花括号内的代码进行同步控制:
     synchronized(syncObject) {
          ....
     }
     这也被称为同步控制块;在进入此段代码前,必须得到syncObject对象的锁。如果其他线程已经得到这个锁,那么就得等到锁被释放以后,才能进入临界区。
     使用临界区的用法其实和Lock用法极其类似,但Lock更加灵活。两者都得显式的利用一个对象,synchronized是使用其他对象,Lock是使用自身,相比之下,synchronized更加晦涩。Lock可以在一个函数中加锁,另一个函数中解锁,临界区做不到,但这也给Lock带来使用风险。sychronized怎样才能不使用额外的一个对象进行加锁?办法就是对this加锁,如果多个线程执行的是同一任务,使用sychronized是不错的选择,因为它可以避免你显式的定义和使用一个Lock。

线程本地储存

     防止任务在共享变量上产生冲突的第二种方式是根除对变量的共享。线程本地储存(TLS)是一种自动化机制,可以为使用相同变量的每个不同的线程都创建不同的储存。因此,如果你有5个线程都要使用变量x所表示的对象,那线程本地储存就会生成5个用于x的不同的储存块。主要是,它们使得你可以将状态与线程关联起来。
     线程本地储存不是一种线程间共享资源的机制,它主要作用是作为对每个线程自身状态的储存,比如放在上下文环境中,因此一般使用为静态域储存。创建和管理线程本地储存可以由java.lang.ThreadLocal类来实现,如下:
class ContextThread {
     private static ThreadLocal<Integer> value = http://www.mamicode.com/new ThreadLocal();>
/*output 
1
1
1
1
1
     在创建ThreadLocal时,你只能通过get()和set()方法来访问该对象的内容,其中,get()方法将返回与其线程相关的对象的副本,而set()会将参数插入到为其线程储存的对象中。


漫谈并发编程(三):共享受限资源