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

一、 多线程资源共享问题

在单线程的情况下,我们很少去考虑资源冲突的问题。而在多线程中,单个实例的某个方法或者变量会经常出现被多个线程访问的情况。最常见的问题,在线程A访问f()进行到一半时,线程B也调用了f()方法。这很容易导致资源使用时出现我们不愿意见到的情况。比如下面这个例子。
Thinking in Java 中,以生产整数作为测试用例,先用一个虚拟类作为生产整数的标准

public abstract class IntGenerator {

    private volatile boolean canceled = false;
    public abstract int next();
    public void cancel() {
        canceled = true;
    }
    public boolean isCanceled() {
        return canceled;
    }
}

现在,继续创建一个用来生成偶数的类:

public class EvenGenerator extends IntGenerator {

    private int currentEvenValue = 0;
    @Override
    public int next() {
        // TODO Auto-generated method stub
        ++currentEvenValue;
        Thread.yield();
        ++currentEvenValue;
        return currentEvenValue;
    }
    public static void main(String[] args) {
        EvenChecker.test(new EvenGenerator());
    }
}

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

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

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;


public class EvenChecker implements Runnable {

    private IntGenerator generator;
    private final int id;
    public EvenChecker(IntGenerator generator,int ident) {
        this.generator = generator;
        this.id = ident;
    }
    @Override
    public void run() {
        // TODO Auto-generated method stub
        while(!generator.isCanceled()) {
            int val=generator.next();
            if(val % 2 != 0) {
                System.out.println(val + " not even!");
                generator.cancel();
            }else {
                System.out.println(val + " is even!");
            }

        }

    }

    public static void test(IntGenerator generator,int count) {
        System.out.println("print Ctrl+C to exit");
        ExecutorService exec = Executors.newCachedThreadPool();
        for(int i=0;i<count;i++) {
            exec.execute(new EvenChecker(generator,i));
        }
        exec.shutdown();
    }
    public static void test(IntGenerator generator) {
        test(generator,10);
    }

}

EvenChecker 会创建多个线程,每个线程会都对不断的调用 EvenGenerator 的 next() 方法。当 next() 返回一个偶数时,该线程会继续进行对 next() 的调用。而当出现奇数时,任务被终止。

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

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

二、给资源上锁

一个行之有效的办法是在对出现资源冲突的方法或代码块使用 synchronized 关键字。
对于一个特定对象,当一个任务在使用被 synchronized 修饰的资源时,对象里所有被 synchronized 的资源都会被锁定,我们将之称之为“上锁”,而“解锁”则是在任务完成对资源的调用之后自动实现。“解锁”之后的资源可以再一次被其他任务使用。
现在使用 synchronized 完善上文的代码。
首先需要建立一个偶数生成器,并用 synchronized 修饰它的 next() 方法。

public class SynchronizedGenerator extends IntGenerator {

    private int currentEvenValue = 0;
    @Override
    public synchronized int next() {
        // TODO Auto-generated method stub
        ++currentEvenValue;
        Thread.yield();
        ++currentEvenValue;
        return currentEvenValue;
    }

    public static void main(String[] args) {
        EvenChecker.test(new SynchronizedGenerator());
    }

}

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

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

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

package ThreadTest.SycnSourceTest;

import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class MutexEvenGenerator extends IntGenerator {

    private int currentEvenValue = 0;
    private Lock lock = new ReentrantLock();
    @Override
    public int next() {
        // TODO Auto-generated method stub
        lock.lock();
        try {
            ++currentEvenValue;
            Thread.yield();
            ++currentEvenValue;
            return currentEvenValue;
        }finally {
            lock.unlock();
        }

    }
    public static void main(String[] args) {
        EvenChecker.test(new MutexEvenGenerator());
    }
}

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

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

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

import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.ReentrantLock;

public class AttemptLocking {

    private ReentrantLock lock = new ReentrantLock();
    public void untimed() {
        boolean captured = lock.tryLock();
        try {
            System.out.println("tryLock(): "+captured);
        }finally {
            if(captured)
                lock.unlock();
        }
    }

    public void timed() {
        boolean captured = false;
        try {
            captured = lock.tryLock(2, TimeUnit.SECONDS);
        }catch(InterruptedException e) {
            throw new RuntimeException();
        }
        try {
            System.out.println("tryLock(2,TimeUnit.SECONDS): "+captured);
        }finally {
            if(captured)
                lock.unlock();
        }
    }
    public static void main(String[] args) throws InterruptedException {
        final AttemptLocking al = new AttemptLocking();
        al.untimed();
        al.timed();
        new Thread() {
            {setDaemon(true);}
            public void run() {
                al.lock.lock();
                System.out.println("acquired");
            }
        }.start();
        TimeUnit.MILLISECONDS.sleep(1000);
        al.untimed();
        al.timed();
    }

}
/*output:
tryLock(): true
tryLock(2,TimeUnit.SECONDS): true
acquired
tryLock(): false
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 的限制)。

四、在其他对象上同步

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

import java.util.concurrent.TimeUnit;

class DualSynch {
    private Object syncObject = new Object();
    public synchronized void f() {
        for(int i=0;i<5;i++) {
            System.out.println("f()");
            try {
                TimeUnit.MILLISECONDS.sleep(100);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
    public void g() {
        synchronized(syncObject) {
            for(int i=0;i<5;i++) {
                System.out.println("g()");
                try {
                    TimeUnit.MILLISECONDS.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }
    }
}

public class SyncObject{
    public static void main(String[] args) {
        final DualSynch ds = new DualSynch();
        new Thread() {
            public void run() {
                ds.f();
            }
        }.start();
        ds.g();
    }
}

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

五、线程的本地存储

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

package ThreadTest.SycnSourceTest.concurrency;

import java.util.Random;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;

class Accessor implements Runnable{
    private final int id;
    public Accessor(int idn) {id=idn;}
    public void run() {
        while(!Thread.currentThread().isInterrupted()) {
            ThreadLocalVariableHolder.increment();
            System.out.println(this);
        }
    }
    public String toString() {
        return "#"+id+": "+ThreadLocalVariableHolder.get();
    }

}
public class ThreadLocalVariableHolder {

    public static ThreadLocal<Integer> value = new ThreadLocal<Integer>() {
        private Random rand = new Random(47);
        protected synchronized Integer initialValue() {
            return rand.nextInt(10000);
        }
    };
    public static void increment() {
        value.set(value.get()+1);
    }
    public static int get() {return value.get();}

    public static void main(String[] args) throws InterruptedException {
        // TODO Auto-generated method stub
        ExecutorService exec = Executors.newCachedThreadPool();
        for(int i=0;i<5;i++) {
            exec.execute(new Accessor(i));
        }
        TimeUnit.MILLISECONDS.sleep(4);
        exec.shutdown();
    }


}

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

推荐阅读更多精彩内容