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