Thinking in java 之并发其二:资源共享

一、前言


在单线程的情况下,我们很少去考虑资源冲突的问题。而在多线程中,单个实例的某个方法或者变量会经常出现被多个线程访问的情况。最常见的问题,在线程A访问f()进行到一半时,线程B也调用了f()方法。这自然会导致资源使用时出现我们不愿意见到的情况。比如下面这个例子。

 

 1 public abstract class IntGenerator {
 2 
 3     private volatile boolean canceled = false;
 4     public abstract int next();
 5     public void cancel() {
 6         canceled = true;
 7     }
 8     public boolean isCanceled() {
 9         return canceled;
10     }
11 }

 

 1 public class EvenGenerator extends IntGenerator {
 2 
 3     private int currentEvenValue = 0;
 4     @Override
 5     public int next() {
 6         // TODO Auto-generated method stub
 7         ++currentEvenValue;
 8         Thread.yield();
 9         ++currentEvenValue;
10         return currentEvenValue;
11     }
12     public static void main(String[] args) {
13         EvenChecker.test(new EvenGenerator());
14     }
15 }

 

EvenGenerator 是一个偶数生成器,它包含一个变量 currentEvenValue,初始值是0。同时,它的 next() 方法会对 currentEvenValue 进行两次自增操作,并返回自增后的值。在理想情况下,我们每次通过next() 获得的都是偶数。

但是,当多个任务对 next() 进行调用时,是否会出现,currentEvenValue 完成第一次自增之后,其他任务也开始调用 next() 并且自增两次,此时,我们将会获得一个奇数。为了证明这一点,我们通过 EvenChecker 来对 EvenGenerator  进行多线程操作。

 1 import java.util.concurrent.ExecutorService;
 2 import java.util.concurrent.Executors;
 3 
 4 
 5 public class EvenChecker implements Runnable {
 6 
 7     private IntGenerator generator;
 8     private final int id;
 9     public EvenChecker(IntGenerator generator,int ident) {
10         this.generator = generator;
11         this.id = ident;
12     }
13     @Override
14     public void run() {
15         // TODO Auto-generated method stub
16         while(!generator.isCanceled()) {
17             int val=generator.next();
18             if(val % 2 != 0) {
19                 System.out.println(val + " not even!");
20                 generator.cancel();
21             }else {
22                 System.out.println(val + " is even!");
23             }
24                 
25         }
26 
27     }
28     
29     public static void test(IntGenerator generator,int count) {
30         System.out.println("print Ctrl+C to exit");
31         ExecutorService exec = Executors.newCachedThreadPool();
32         for(int i=0;i<count;i++) {
33             exec.execute(new EvenChecker(generator,i));
34         }
35         exec.shutdown();
36     }
37     public static void test(IntGenerator generator) {
38         test(generator,10);
39     }
40 
41 }

 

EvenChecker 会创建多个线程,对 EvenGenerator 的同一个实例进行操作。当 next() 返回一个偶数时,程序会继续进行对 next() 的调用。而当出现奇数时,任务被终止。

无论实验多少次,EvenChecker 总会在某个时刻终止,说明,的确会出现上文所述的情况。(注意:main 方法在 EvenGenerator 里)

在进行多线程开发,共享资源需要被谨慎处理。我们需要通过一些手段,来保证,当一个任务使用某个资源时,其他任务只能等待该任务使用完成。

二、给资源上锁

一个行之有效的办法是在对出现资源冲突的方法或代码块使用 synchronized 关键字。

对于一个特定对象,当一个任务在使用被 synchronized 修饰的资源时,对象里所有被 synchronized 的资源都会被锁定,我们将之称之为“上锁”,而“解锁”则是在任务完成对资源的调用之后自动实现。“解锁”之后的资源可以再一次被其他任务使用。

下面,我们使用 synchronized 完善上文的代码。

首先,我们重新建立一个偶数生成器,并用 synchronized 修饰它的 next() 方法。

 1 public class SynchronizedGenerator extends IntGenerator {
 2 
 3     private int currentEvenValue = 0;
 4     @Override
 5     public synchronized int next() {
 6         // TODO Auto-generated method stub
 7         ++currentEvenValue;
 8         Thread.yield();
 9         ++currentEvenValue;
10         return currentEvenValue;
11     }
12     
13     public static void main(String[] args) {
14         EvenChecker.test(new SynchronizedGenerator());
15     }
16 
17 }

 

然后,用 EvenChecker 来使用 synchronizedGenerator。结果是,除非我么手动停止,否则程序任务将会无限的循环下去。

synchronized 除了可以修饰方法,也可以修饰方法内部的某个代码块(通常称这个代码块为“临界区”)。因此,当我们只是想防止方法中的部分代码(而不是整个方法)被多个线程同时访问时,也可以使用synchronized。被 synchronized 修饰的代码块也被成为“同步控制块”。

synchronized 的上锁和解锁过程,是 java 帮我们自动去实现的。如果需要一个显性的上锁和解锁过程,可以使用 java.util.concurrent.locks中的显示互斥机制。我们可以在程序运行到某个位置时上锁或者解锁。下面的代码,是使用 lock 实现互斥的例子。

 1 package ThreadTest.SycnSourceTest;
 2 
 3 import java.util.concurrent.locks.Lock;
 4 import java.util.concurrent.locks.ReentrantLock;
 5 
 6 public class MutexEvenGenerator extends IntGenerator {
 7 
 8     private int currentEvenValue = 0;
 9     private Lock lock = new ReentrantLock();
10     @Override
11     public int next() {
12         // TODO Auto-generated method stub
13         lock.lock();
14         try {
15             ++currentEvenValue;
16             Thread.yield();
17             ++currentEvenValue;
18             return currentEvenValue;
19         }finally {
20             lock.unlock();
21         }
22         
23     }
24     public static void main(String[] args) {
25         EvenChecker.test(new MutexEvenGenerator());
26     }
27 }

从运行结果来看,lock 的确起到了和 synchronized 同等的效果。

为了保证在任务的最后都能够正确的解锁,我们必须在 finally 块中对 lock 进行解锁。

除了能够显性的执行“锁”操作,lock 还可以用来实现“如果一段时间未能获取锁,则放弃获取锁这一行为”的操作。我们甚至能够自己指定“获取锁”这一行为的尝试时间。

 1 import java.util.concurrent.TimeUnit;
 2 import java.util.concurrent.locks.ReentrantLock;
 3 
 4 public class AttemptLocking {
 5 
 6     private ReentrantLock lock = new ReentrantLock();
 7     public void untimed() {
 8         boolean captured = lock.tryLock();
 9         try {
10             System.out.println("tryLock(): "+captured);
11         }finally {
12             if(captured)
13                 lock.unlock();
14         }
15     }
16     
17     public void timed() {
18         boolean captured = false;
19         try {
20             captured = lock.tryLock(2, TimeUnit.SECONDS);
21         }catch(InterruptedException e) {
22             throw new RuntimeException();
23         }
24         try {
25             System.out.println("tryLock(2,TimeUnit.SECONDS): "+captured);
26         }finally {
27             if(captured)
28                 lock.unlock();
29         }
30     }
31     public static void main(String[] args) throws InterruptedException {
32         final AttemptLocking al = new AttemptLocking();
33         al.untimed();
34         al.timed();
35         new Thread() {
36             {setDaemon(true);}
37             public void run() {
38                 al.lock.lock();
39                 System.out.println("acquired");
40             }
41         }.start(); 
42         TimeUnit.MILLISECONDS.sleep(1000);
43         al.untimed();
44         al.timed();
45     }
46     
47 }
48 /*output:
49 tryLock(): true
50 tryLock(2,TimeUnit.SECONDS): true
51 acquired
52 tryLock(): false
53 tryLock(2,TimeUnit.SECONDS): false*/

这段程序时这样的,主程序第一次调用 al.timed 和 al.timed 的时候,它们都顺利的获得锁。然后我们 新建了一个 Thread 这个 Thread 获取的 al 的锁,并且一直没有释放,所以当我们再执行 al.timed 和 al.timed 是,就会出现获取失败的结果。 

tyrLock 有自己的默认尝试时间,或者我们通过构造参数的方式去定义它的尝试时间,尝试时间结束之后,tryLock会放弃获取锁的操作。

三、原子性和易变性

原子操作是指不能被线程调度机制中断的操作,即,一旦操作发生,它必然会在切换到其他线程之前完成。但是“原子操作不需要进行同步控制”是一个错误的结论。

原子性可以应用于除了 long 和 double 之外的所有基本类型之上的操作,可以保证它们会被当做不可分(原子)的操作来操作内存。但是,通过在定义 long 和 double 时使用 volatile 关键字就会获得(简单的赋值与返回值操作)原子性。

同事,volatile 还确保了应用 中的可视性。如果将一个域声明为 volatile,那么只要对这个域产生写操作,那么所有的读操作就都可以看到这个修改。简而言之,volatile 域上发生的变化会变立刻写入到主存中。

volatile 与 sychronized:如果一个域会被多个任务访问,那么它应该是 volatile 的,否则这个域就应该只能经由同步来访问。如果一个域已经用 sychronized 来防护,那就不必将其设置为 volatile的。相比较于 volatile,更应该优先使用sychronized。

为了满足一些性能优化需求,java 为我们提供了,AtomicInteger、AtomicLong、AtomReference 等特殊的原子性变量类。这些类的操作是机器级别的原子操作,因此在使用它们时,不必担心。

一个任务所有的写入操作,对于这个任务的读操作都是可视的,因此,如果它只需要保证这个任务的内部可视,不必将其设置为 volatile的。

重点:当一个域的值依赖于它之前的值(比如递增一个计数器),volatile 就无非进行工作。或者某个域的值收到其他域的值限制,它也无法工作。(例如,Range 类的 lower 和 upper 边界必须遵循 lower <= upper 的限制)。

四、在其他对象上同步

首先,我们先看下面这段代码:

 

 1 import java.util.concurrent.TimeUnit;
 2 
 3 class DualSynch {
 4     private Object syncObject = new Object();
 5     public synchronized void f() {
 6         for(int i=0;i<5;i++) {
 7             System.out.println("f()");
 8             try {
 9                 TimeUnit.MILLISECONDS.sleep(100);
10             } catch (InterruptedException e) {
11                 // TODO Auto-generated catch block
12                 e.printStackTrace();
13             }
14         }
15     }
16     public void g() {
17         synchronized(syncObject) {
18             for(int i=0;i<5;i++) {
19                 System.out.println("g()");
20                 try {
21                     TimeUnit.MILLISECONDS.sleep(100);
22                 } catch (InterruptedException e) {
23                     // TODO Auto-generated catch block
24                     e.printStackTrace();
25                 }
26             }
27         }
28     }
29 }
30 
31 public class SyncObject{
32     public static void main(String[] args) {
33         final DualSynch ds = new DualSynch();
34         new Thread() {
35             public void run() {
36                 ds.f();
37             }
38         }.start();
39         ds.g();
40     }
41 }

 

 

 

输出的结果告诉我们,f() 和 g() 这两个方法显然不受同步控制的影响。这是因为它们的锁是两个不同的锁,f() 锁针对的事该对象自己(this),而g() 锁针对的是 syncObject。如果我们将 synchronized(syncObject) 换成 synchronized(this),就会得到不一样的结果。

五、线程的本地存储

防止任务在共享资源上产生冲突的第二种方式是根除对变量的共享。线程的本地存储可以为每个任务创造一个相应存储块。即如果有5个任务需要用到变量 x,本地线程就会生成5个用于 X 的不同的存储块。

 1 package ThreadTest.SycnSourceTest.concurrency;
 2 
 3 import java.util.Random;
 4 import java.util.concurrent.ExecutorService;
 5 import java.util.concurrent.Executors;
 6 import java.util.concurrent.TimeUnit;
 7 
 8 class Accessor implements Runnable{
 9     private final int id;
10     public Accessor(int idn) {id=idn;}
11     public void run() {
12         while(!Thread.currentThread().isInterrupted()) {
13             ThreadLocalVariableHolder.increment();
14             System.out.println(this);
15         }
16     }
17     public String toString() {
18         return "#"+id+": "+ThreadLocalVariableHolder.get();
19     }
20     
21 }
22 public class ThreadLocalVariableHolder {
23 
24     public static ThreadLocal<Integer> value = new ThreadLocal<Integer>() {
25         private Random rand = new Random(47);
26         protected synchronized Integer initialValue() {
27             return rand.nextInt(10000);
28         }
29     };
30     public static void increment() {
31         value.set(value.get()+1);
32     }
33     public static int get() {return value.get();}
34     
35     public static void main(String[] args) throws InterruptedException {
36         // TODO Auto-generated method stub
37         ExecutorService exec = Executors.newCachedThreadPool();
38         for(int i=0;i<5;i++) {
39             exec.execute(new Accessor(i));
40         }
41         TimeUnit.MILLISECONDS.sleep(4);
42         exec.shutdown();
43     }
44     
45 
46 }
Accessor

上述的代码中,每个任务都似乎在独立的计数,彼此不受影响。这是因为每个单独的线程都被分配了自己的存储空间。

 

 

 

 

 

 

 

 

posted @ 2018-09-06 10:52  crazy_runcheng  阅读(303)  评论(0编辑  收藏  举报