Java多线程之共享资源和同步
一、竞争条件
所谓竞争条件,即两个或更多的任务竞争响应某个条件,因此产生冲突或不一致结果的情况。
IntGenerator.java生成一个整数:
public abstract class IntGenerator {
private volatile boolean canceled = false; (1)
public abstract int next(); (2)
public void cancel() { (3)
this.canceled = true;
}
public boolean isCanceled() { (4)
return canceled;
}
}
(1)定义一个boolean的成员变量,boolean类型的赋值、取值操作是原子性的,即诸如赋值和返回值这样的简单操作在发生时没有中断的可能。同时canceled变量还设置为volatile,从而保证了可见性。
(2)生成一个随机int。
(3)将canceled标志设置成true。
(4)返回canceled标志。
EvenChecker.java检查IntGenerator生成的Int是否是偶数:
public class EvenChecker implements Runnable {
private IntGenerator intGenerator; (1)
private final int id;
public EvenChecker(IntGenerator intGenerator, int ident) {
this.intGenerator = intGenerator;
this.id = ident;
}
@Override
public void run() {
while (!intGenerator.isCanceled()) { (2)
int val = intGenerator.next();
if (val % 2 != 0) {
System.err.println(val + "is not even!");
intGenerator.cancel();
}
}
}
public static void test(IntGenerator gp, int count) { (3)
System.err.println("Press Control-C to exit");
ExecutorService exec = Executors.newCachedThreadPool();
for (int i = 0; i < count; i++) {
exec.execute(new EvenChecker(gp, i));
}
exec.shutdown();
}
public static void test(IntGenerator gp) {
test(gp, 10);
}
}
(1)声明一个IntGenerator,用来生成Int,在这里通过使任务依赖一个非任务的对象,我们可以消除潜在的竞态条件。注意为了保证并发程序正确运行,我们需要确保一个任务不能依赖另外一个任务,因为任务关闭的顺序无法得到保证。
(2)检查IntGenerator的canceled状态从而判断是否返回run()方法。
(3)test方法通过启动大量使用相同IntGenerator的EvenChecker任务,设置并执行对任何类型的IntGenerator的测试。
EvenGenerator.java
public class EvenGenerator extends IntGenerator {
private int currentEvenValue;
@Override
public int next() {
++currentEvenValue; (1)
++currentEvenValue; (2)
return currentEvenValue;
}
public static void main(String[] args) {
EvenChecker.test(new EvenGenerator());
}
}
在这个程序中,如果一个任务执行了(1),但是没有执行后面的(2)操作,此时另外一个任务调用了next()方法,那么currentEvenValue的值将会是错误的。当然,这个错误可能因为不同的操作系统和JVM,而很难重现,但它确实存在。
注意:有一点很重要,在Java中,递增操作不是原子性的。因此,如果没有保护任务,即使单一的递增也是不安全的。
二、解决共享资源竞争
上面的例子展示了一个问题,当多个任务同时调用EvenGenerator的next()方法时,你永远不知道哪个任务先运行,也不知道一个线程合适运行,这会导致错误的使用currentEvenValue值。想象一下,你坐在桌子边手拿着叉子,正要去叉盘子中最后一片食物,然而在你够着食物之前,它突然消失了,因为你的线程被挂起了,而另外一个用餐的人吃了它。这正是你编写并发程序需要解决的问题。
防止这种冲突的方法就是当资源被一个任务使用时,在其上加锁。第一个访问某项资源的任务必须锁定这项资源,使其他任务在其被解锁之前不能访问它。在资源被解锁时另一个任务可以锁定并使用它。
通常通过在代码前面加一条锁语句,从而实现在给定时刻只允许一个任务访问共享资源的功能。这使得一段时间内只有一个任务可以运行这段代码,因为锁语句产生了一种互相排斥的效果,所以这种机制常常称为互斥量(Mutex)。
三、synchronized关键字
Java提供关键字synchronized实现同步功能。当任务要执行被synchronized保护的代码片段时,它将检查锁是否可用,然后获取锁,执行代码,释放锁。
在Java中共享资源一般是以对象的形式存在的内存片段,但也可以是文件输入、输出端口或者是打印机等。要控制对共享资源的访问,得先把它包装进一个对象。然后把所有要访问这个资源的方法标记为synchronized。
如果一个任务处于一个对标记为synchronized的方法的调用中,那么在这个线程从该方法返回前,其他所有要调用类中任何标记为synchronized方法的线程都会被阻塞。
声明synchronized方法:
synchronized void f(){/* */}
synchronized void g(){/* */}
所有对象都含有单一的锁(也称为监视器)。当在对象上调用其任意synchronized方法的时候,此对象都被加锁,此时该对象上其他synchronized方法只有等待前一个方法(synchronized方法)调用完毕并释放了锁以后才能被调用。
对于某个特定对象来说,其所有synchronized方法共享同一个锁,这可以防止多个任务同时访问被编码为对象的内存。
一个任务可以多次获得对象的锁:
例如:一个方法在同一个对象上调用了第二个方法,后者又调用了同一个对象上的另一个方法,这是任务就会多次获取对象的锁。
JVM负责跟踪对象被加锁的次数。任务第一次给对象加锁的时候,计数变为1,每当这个相同的任务在这个对象上获得锁时,计数都会递增,每当任务离开一个synchronized方法,计数递减,当计数为0时,锁被完全释放,此时别的任务就可以使用此资源。
synchronized static:
针对每个类,也有一个锁(作为类的Class对象的一部分),所以synchronized static方法可以在类的范围内防止对static数据的并发访问。
注意:很重要的一点,每个访问临界共享资源的方法都必须被同步,否则它们就不会正确地工作。
现在我们给前面的EvenGenerator.java类加锁:
public class EvenGenerator extends IntGenerator {
private int currentEvenValue;
@Override
public synchronized int next() { (1)
++currentEvenValue;
Thread.yield();
++currentEvenValue;
return currentEvenValue;
}
public static void main(String[] args) {
EvenChecker.test(new EvenGenerator());
}
}
(1)给next()方法添加关键字synchronized是其变成一个同步方法。第一个进人next()的任务将获得锁,任何其他试图获取锁的任务都将从其开始尝试之时被阻塞, 直至第一个任务释放锁。
四、Lock 对象
Lock 对象相比内建的synchronized关键字,必须被显示地创建、锁定和释放。因此使用Lock对象使得代码缺乏优雅性。但是对于解决某些问题来说,它更灵活。大体上,当你使用synchronized关键字时的代码量更少,用户出现错误的可能性更低,因此只有在解决特殊问题时才使用Lock对象。
使用Lock对象的方式修改EvenGenerator.java类:
public class EvenGenerator extends IntGenerator {
private int currentEvenValue;
private Lock lock = new ReentrantLock(); (1)
@Override
public int next() {
lock.lock(); (2)
try {
++currentEvenValue;
Thread.yield();
++currentEvenValue;
return currentEvenValue;
} finally {
lock.unlock(); (3)
}
}
public static void main(String[] args) {
EvenChecker.test(new EvenGenerator());
}
}
(1)手动创建一个Lock对象。
(2)使用lock()在next()方法内创建了临街资源。
(3)调用unlock()释放锁,unlock()必须放在finally子句中调用,同时return必须在try子句中,以确保unlock()不会过早发生,从而将数据暴露给了第二个任务。
相比于synchroninzed,Lock对象对获取锁和释放锁提供了更细粒度的控制。
public class AttemptLocking {
private Lock lock = new ReentrantLock(); (1)
public void untimed() {
boolean captured = lock.tryLock(); (2)
try {
System.err.println(captured);
} finally {
if (captured) { (3)
lock.unlock();
}
}
}
public void timed() {
boolean captured = false;
try {
captured = lock.tryLock(2, TimeUnit.SECONDS); (4)
} catch (InterruptedException e){
throw new RuntimeException(e);
}
try {
System.err.println("lock.tryLock(1, TimeUnit.SECONDS): " + captured);
} finally {
if (captured) { (5)
lock.unlock();
}
}
}
public static void main(String[] args) {
AttemptLocking attemptLocking = new AttemptLocking();
attemptLocking.untimed();
attemptLocking.timed();
new Thread(){
{
setDaemon(true);
}
@Override
public void run() {
attemptLocking.lock.lock();
System.err.println("acquired.");
}
}.start();
}
}
程序执行的输出如下:
lock.tryLock(): true
lock.tryLock(2, TimeUnit.SECONDS): true
lock.tryLock(): true
acquired.
lock.tryLock(2, TimeUnit.SECONDS): false
(1)创建ReentrantLock锁对象,ReentrantLock可以尝试获取锁但最终未获取锁。
(2)调用tryLock()尝试获取锁。
(3)如果前面的步骤获取锁成功,则释放锁。
(4)尝试获取锁,并且在指定时间后没有获取到锁就提示失败。
(5)如果获取锁成功,释放锁。
五、原子性和可见性
原子操作是不能被线程调度机制中断的操作,一旦操作开始,那么它一定可以在上下文切换(切换到其他线程执行)之前执行完毕。
对于读取和写入,除long和double之外的基本数据类型变量,可以保证他们会被当做不可分的原子操作一样操作内存。当你定义long和double对象时,如果使用volatile关键字,就会获得原子性。
JVM可以将64位的(long和double变量)的读取和写入当做两个分离的32位操作来执行。这就产生了在一个读取和写人操作中间发生上下文切换,从而导致不同的任务可以不正确结果的可能性(这有时被称为字撕裂,因为你可能会看到部分被修改过的数值)。
注意:虽然原子操作可由线程机制来保证其不可中断,但我们不能使用原子操作来代替同步,除非你是一个并发专家。
可视性是指一个任务对某项资源做出的改变对程序中其他的任务是可见的。注意,就算一个操作是原子性的也不能保证,其修改对其他任务的可见性。
volatile关键字确保了应用中的可见性。如果你将一个域声明为volatile的,那么只要对这个域产生了写操作,所有的读操作就都能看到这个修改。即便使用了本地缓存,情况也确实如此,volatile域会立即被写人到主存中,而读取操作就发生在主存中。
同步也会导致向主存中刷新,因此如果一个域完全由synchronized方法或语句块来防护,那就不必将其设置为volatile的。
注意:当一个域的值依赖于它之前的值时 (例如递增—个计数器),volatile就无法工作了。如果某 个域的值受到其他域的值的限制,那么volatile也无法工作。
volatile只有在类中只有一个可变域时才是完全安全的,所以你的第一选择应该是使用synchronized关键字,这是最安全的方式。
public class AtomicityTest implements Runnable {
private int i = 0;
public int getValue() {
return i;
}
private synchronized void evenIncrement() {
i++;
i++;
}
@Override
public void run() {
while (true) {
evenIncrement();
}
}
public static void main(String[] args) {
ExecutorService exec = Executors.newCachedThreadPool();
AtomicityTest atomicityTest = new AtomicityTest();
exec.execute(atomicityTest);
exec.shutdown();
while (true) {
int val = atomicityTest.getValue();
if (val % 2 != 0) {
System.err.println(val);
System.exit(0);
}
}
}
}
通过执行这个代码你会发现,程序会找到奇数并返回。这是因为,return i 语句虽然是原子性的,但缺少同步使得其值可以在处于不稳定的中间状态时被读取。解决方式是给getValue()方法加上synchronized关键字。
六、原子类
Java SE5引人了诸如AtomicInteger、AtomicLong、AtomicReference等特殊的原子性变量类。
使用原子类修改AtomicityTest.java类
public class AtomicityTest implements Runnable {
private AtomicInteger i = new AtomicInteger(0);
public int getValue() {
return i.get();
}
private void evenIncrement() {
i.addAndGet(2);
}
@Override
public void run() {
while (true) {
evenIncrement();
}
}
public static void main(String[] args) {
ExecutorService exec = Executors.newCachedThreadPool();
AtomicityTest atomicityTest = new AtomicityTest();
exec.execute(atomicityTest);
exec.shutdown();
while (true) {
int val = atomicityTest.getValue();
if (val % 2 != 0) {
System.err.println(val);
System.exit(0);
}
}
}
}
应该强调的是,Atomic 类被设计用来构建ja va.util.concurrent 中的类,因此只有在特殊情况 下才在自己的代码中使用它们,即便使用了也需要确保不存在其他可能出现的问题。通常依赖锁更安全一些。
七、临界区
有时,你只希望防止多个线程同时访问方法内部的部分代码而不是防止访问整个方法。通过这种方式分离出来的代码片段被称为临界区(也称为同步代码块)。
临界区也是用synchronized关键字创建(需要指定一个对象,该对象的锁被用来对花括号内的代码进行同步)。
synchronized(syncObject) {
// 里面的代码同一时间只有一个任务能访问
}
在进入代码块之前需要先获得syncObject对象的锁,如果其他线程已经获得了这个锁,那就得等待锁被释放后才能进入临界区。
Pair.java
// 非线程安全的类
public class Pair {
private int x, y;
public Pair() {
}
public Pair(int x, int y) {
this.x = x;
this.y = y;
}
public int getX() {
return x;
}
public int getY() {
return y;
}
public void incrementX() {
x++;
}
public void incrementY() {
y++;
}
public class PairValuesNotEqualException extends RuntimeException {
public PairValuesNotEqualException() {
super("Pair values not equal:" + Pair.this);
}
}
public void checkState() {
if (x != y) {
throw new PairValuesNotEqualException();
}
}
}
PairManager.java
// 封装Pair的调用使其线程安全
public abstract class PairManager {
AtomicInteger checkCounter = new AtomicInteger(0);
protected Pair p = new Pair();
private List<Pair> storage = Collections.synchronizedList(new ArrayList<>());
public synchronized Pair getPair() {
return new Pair(p.getX(), p.getY());
}
protected void store(Pair p) {
storage.add(p);
try {
TimeUnit.MILLISECONDS.sleep(50);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
public abstract void increment();
}
PairManager1.java
public class PairManager1 extends PairManager {
@Override
public synchronized void increment() {
p.incrementX();
p.incrementY();
store(getPair());
}
}
PairManager2.java
public class PairManager2 extends PairManager {
@Override
public void increment() {
Pair temp;
synchronized (this) {
p.incrementY();
p.incrementX();
temp = getPair();
}
store(temp);
}
}
PairChecker.java
public class PairChecker implements Runnable {
private PairManager pm;
public PairChecker(PairManager pm) {
this.pm = pm;
}
@Override
public void run() {
while (true) {
pm.checkCounter.incrementAndGet();
pm.getPair().checkState();
}
}
}
PairManipulator.java
public class PairManipulator implements Runnable {
private PairManager pm;
public PairManipulator(PairManager pm) {
this.pm = pm;
}
@Override
public void run() {
while (true) {
pm.increment();
}
}
@Override
public String toString() {
return "Pair: " + pm.getPair() + " checkCounter = " + pm.checkCounter.get();
}
}
上面的程序演示了使用synchronized同步整个方法和同步代码块在性能上的区别,同时也演示了一个线程不安全的类在其他类的协助下线程安全。
除了使用synchronized关键字外还可以使用Lock对象实现临界区:
ExplicitPairManager1.java
public class ExplicitPairManager1 extends PairManager {
@Override
public void increment() {
Lock lock = new ReentrantLock();
lock.lock();
try {
p.incrementX();
p.incrementY();
store(getPair());
} finally {
lock.unlock();
}
}
}
ExplicitPairManager2.java
public class ExplicitPairManager2 extends PairManager {
@Override
public void increment() {
Lock lock = new ReentrantLock();
Pair temp;
lock.lock();
try {
p.incrementY();
p.incrementX();
temp = getPair();
} finally {
lock.unlock();
}
store(temp);
}
}
八、在其他对象上同步
synchronized块必须给定一个在其上进行同步的对象。最合理的方式是使用方法正在被调用的对象,在前面的例子中也正是如此:synchronized(this)。
在这种方式中,如果获得了synchronized块上的锁 ,那么该对象其他的synchronized方法和临界区就不能被调用了。因此,如果在this上同步,临界区的效果就会直接缩小在同步的范围内。
有时必须在另一个对象上同步,如果你这么做,就必须确保所有相关的任务都是在同一个对象上同步的。下面的示例演示了两个任务可以同时进入同一个对象,只要这个对象上的方法是在不同的锁上同步的即可。
public class DualSyn {
private Object syncObject = new Object();
public synchronized void f() {
for (int i = 0; i < 5; i++) {
System.err.println("f()");
Thread.yield();
}
}
public void g() {
synchronized (syncObject) {
for (int i = 0; i < 5; i++) {
System.err.println("g()");
Thread.yield();
}
}
}
public static void main(String[] args) {
final DualSyn ds = new DualSyn();
new Thread() {
@Override
public void run() {
ds.f();
}
}.start();
ds.g();
}
}
运行结果:
g()
f()
g()
f()
f()
f()
f()
g()
g()
g()
在这个程序中,方法f()的synchronized在this上同步,而方法g()的synchronized在syncObject对象上同步,因此两个同步是相互独立的。从运行结果可以看出来,虽然不是同一个任务,但两个方法在同时运行,因此任何一个方法都没有因为对另 一个方法的同步而被阻塞。
九、ThreadLocal
线程本地存储是一种自动化机制,可以为使用相同变量的每个不同的线程都创建不同的存储。这使得你可以将状态与线程关联起来。
Java中使用ThreadLocal来实现创建和管理线程本地存储的功能。
Accessor.java
public class Accessor implements Runnable {
private final int id;
public Accessor(int id) {
this.id = id;
}
@Override
public void run() {
while (!Thread.currentThread().isInterrupted()) {
ThreadLocalVariableHold.increment();
System.err.println(this);
Thread.yield();
}
}
@Override
public String toString() {
return "#" + id + ": " + ThreadLocalVariableHold.get();
}
}
ThreadLocalVariableHold.java
public class ThreadLocalVariableHold {
private static ThreadLocal<Integer> value = new ThreadLocal<>(){
private Random random = new Random(47);
protected synchronized Integer initialValue() {
return random.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 {
ExecutorService exec = Executors.newCachedThreadPool();
for (int i = 0; i < 5; i++) {
exec.execute(new Accessor(i));
}
TimeUnit.SECONDS.sleep(3);
exec.shutdownNow();
}
}
ThreadLocal对象通常当作静态域存储。在创建ThreadLocal时,你只能通过get()和set()方法来访问该对象的内容,其中get()方法将返回与其线程相关联的对象的副本,而set()会将参数插人到为其线程存储的对象中,并返回存储中原有的对象。
当运行这个程序时,你可以看到每个单独的线程都被分配了自己的存储,因为它们每个都需要跟踪自己的计数值。
Goetz测试:如果你可以编写用于现代微处理器的高性能JVM,那么就有资格去考虑是否可以避免同步。