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

解决共享资源竞争

一个不对的訪问资源演示样例
     考虑以下的样例,当中一个任务产生偶数,而其它任务消费这些数字。这里。消费者任务的唯一工作就是检查偶数的有效性。
     我们先定义一个偶数生成器的抽象父类。
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 = 0;
     public int next() {
          ++currentEvenValue;
          ++currentEvenValue;
          return currentEvenValue;
     }
     public static void main(String []args) {
          EvenChecker.test(new EvenGenerator());
     }
}
/* output:
Press Control -C to exit
15243not even!
15245not even!
     这个程序终于失败,由于各个EvenChecker任务在EvenGenerator处于"不恰当的"状态时,仍可以訪问当中的信息。比方以下场景:1. A线程运行一条自加操作后放弃时间片,B线程接着运行两次自加及输出。 2. A线程在自加后return语句前放弃时间片。B线程完毕一次自加,然后A又运行。在这样的情况下仍然会返回奇数。

解决竞争的方法
     基本上全部的并发模式在解决线程冲突问题的时候,都是採用序列化訪问共享资源的方案。这意味着在给定时刻仅仅同意一个任务訪问共享资源,通常这是通过在代码前面加上一条锁语句来实现的,这就使得在一段时间内仅仅有一个任务能够执行这段代码。由于锁语句产生了一种互相排斥的效果,所以这样的机制经常被称为相互排斥量(mutex)
     Java以提供keywordsynchronized的形式,为防止资源冲突提供了内置支持。当任务要运行被synchronizedkeyword保护的代码片段的时候。它将检查锁是否可用,然后获取锁,运行代码。释放锁。
     要控制对共享资源的訪问,得先把它包装进一个对象,然后把全部要訪问这个资源的方法标记为synchronized。假设某个任务处于一个对标记为synchronized的方法的调用中,那么在这个线程从该方法返回之前。其它全部要调用类中不论什么标记为synchronized方法的线程都将被堵塞。
     以下是声明synchronized方法的方式:
synchronized void f() {/*... */}
synchronized void g(){/*....*/}
     全部对象都自己主动含有单一的锁(也称为监视锁)。当在对象上调用其随意synchronied方法的时候,此对象都被加锁。这时该对象上的其它synchronized方法仅仅有等到前一个方法调用完成并释放了锁之后才干被调用。

在使用并发时,将域设置为private是很重要的。这是一种保证。保证没有其它任务能够直接訪问到该域。

     一个任务能够多次获得对象的锁。假设一个方法在同一个对象上调用了第二个方法,后者又调用了同一个对象上的还有一个方法,就会发生这样的情况。

JVM负责跟踪对象被加锁的次数。假设一个对象被解锁(即锁被全然释放),其计数变为0。

     针对每一个类,也有一个锁(作为类的class对象的一部分),所以synchronized static方法能够在类的范围内防止对static数据的并发訪问。
     总结来说,在多个线程訪问同一对象时,假设会出现线程竞速问题(全部线程仅仅读则不会出现此状况),解决的方法是把这个共享对象转变为线程安全对象(或者使被调用的方法是线程安全的),或者将全部线程对该资源的訪问序列化(用锁在线程自身任务内同步)。

假设对该资源的訪问是复合操作,即使共享对象自身是线程安全的,也无法保证数据的一致性,比如:if( put(**))这样的操作。就必需要把复合操作所有包括在锁内,对于存在多个对象的共享。假设相互之间有状态的关联,这样的处理方式依旧有效。


同步控制EvenGenerator     
     通过在EvenGenerator.java中增加synchronizedkeyword,能够防止不希望的线程訪问:
public class SynchronizedEvenGenerator extends IntGenerator {
     private int currentEvenValue = 0;
     @Override
     public synchronized int next() {
          ++currentEvenValue;
          Thread.yield();
          ++currentEvenValue;
          return currentEvenValue;
     }
     public static void main(String[] args) {
          EvenChecker.test(new SynchronizedEvenGenerator());
     }
}

使用显式的Lock对象
     java.util.concurrent类库中还包括有显式的相互排斥机制。Lock对象必须被显式的创建、锁定和释放。因此。它与内建的锁形式相比,代码缺乏优雅性。但更加灵活。以下是採用Lock重写的EvenGenerator。
public class MutexEvenGenerator extends IntGenerator {
     public int currentEvenValue = 0;
     private Lock lock = new ReentrantLock();
     @Override
     public int next() {
          lock.lock();
          try {
               ++currentEvenValue;
               ++currentEvenValue;
               return currentEvenValue;
          } finally {
               lock.unlock();
          }
     }
     public static void main(String []args) {
          EvenChecker.test( new MutexEvenGenerator( ));
     }
}
     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

Lock的一种补充:ReadWriteLock
     ReadWriteLock对向数据结构相对不频繁的写入。可是有多个任务要常常读取这个数据结构的这类情况进行了优化。ReadWriteLock使得你能够同一时候有多个读取者。仅仅要它们都不试图写入就可以。假设写锁已经被其它任务持有,那么不论什么读取者都不能訪问,直至这个写锁被释放为止。

     ReadWriteLock是否可以提高程序的性能是全然不可确定的。它取决于诸如数据被读取的频率与被改动的频率相比較的结果,读取和写入操作的时间。终于,唯一可以了解ReadWriteLock是否可以给你的程序带来优点的方式就是用试验来证明。

原子性与可见性

     原子性能够应用于除long和double之外的全部基本类型之上的"简单操作"。对于读取和写入除long和double之外的基本类型变量这种操作。能够保证它们会被当作不可分(原子)的操作来操作内存。可是JVM能够将64位(long和double变量)的读取和写入当作两个分离的32位操作来运行。这就产生了在一个读取和写入操作中间发生上下文切换,从而导致不同的任务能够看到不对结果的可能性(这有时被称为字撕裂。由于你可能会看到部分被改动过的数值)。可是,当你定义long或double变量时,假设使用valatilekeyword,就会获得(简单的赋值与返回操作的)原子性。
     对于可见性的讨论,从以下的样例開始:
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 =  new AtomicInteger(0);
     public int next() {
          return currentEvenValue.addAndGet(2);
     }
     public static void main(String args[]) {
          EvenChecker.test(new AtomicEvenGenerator());
     }
}  

临界区

     有时,你仅仅是希望防止多个线程同一时候訪问方法内部的部分代码而不是防止訪问整个方法,通过这样的方式分离出来的代码段被称为临界区(critical section),它也使用synchronizedkeyword建立。这里,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 = new ThreadLocal<Integer>();
     public static void setInteger(Integer value){
          ContextThread.value.set(value);
     }
     public static Integer getInteger() {
          return value.get();
     }
     public static void increment() {
          value.set(value.get() + 1);
     }
}

public class ThreadLocalTest {
     public static void main(String []args) {
          for(int i = 0; i < 5; i++) {
               new Thread(new Runnable() {
                    @Override
                    public void run() {
                         ContextThread.setInteger(0);
                         ContextThread.increment();
                         System.out.println( ContextThread.getInteger() );
                    }
               }).start();
          }
     }
}
/*output 
1
1
1
1
1
     在创建ThreadLocal时,你仅仅能通过get()和set()方法来訪问该对象的内容。当中,get()方法将返回与其线程相关的对象的副本。而set()会将參数插入到为其线程储存的对象中。


原文地址:https://www.cnblogs.com/gcczhongduan/p/5153692.html