[Java]多线程:共享资源同步——不认真看你会后悔的

共享资源同步
在进行多线程开发时最令人头痛的问题估计就是对共享资源的控制了吧,今天就让我们谈一谈这个问题吧。
共享资源顾名思义就是需要被多个线程使用的资源,但是很多情况下我们是不能允许多个线程同时使用这个资源的。这往往会产生令人意想不到的问题。就比如下面这个例子:

package com.mfs.thread;

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
/*
 * 这是一个生成器的抽象类
 */
abstract class Generator {
	
	private boolean canceled = false;
	public abstract int next();  //next()只给出接口,没有实现,这要在其生成类中具体实现
	public void cancel() {   
		//取消该生成器,可以看到这个方法只有一条赋值语句,这是一原子操作,也就是说线程在调用此方法时绝对不会被打断
		canceled = true;
	}
 	public boolean isCanceled() {
 		//返回该生成器是否已被取消,这也是个原子操作
		return canceled;
	}
}
/*
 * 本意是想写一个偶数生成器,但事实证明这个生成器并不能做到仅生成偶数
 * 所以取名为整数生成器,但你要记住我们的本意是写一个偶数生成器
 */
class IntGenerator extends Generator {   
	private volatile int currentData = 0;    // 当前值。volatile关键字在后面介绍,你此时大可不必在意它。虽然他确实有他存在的道理
	@Override
	/*
	 * next方法会返回下一个偶数
	 * 但是可以看到,这其中有多条语句,并且使用了自增运算符,所以该方法一定不是原子操作
	 * 也就是说当一个线程执行此方法时极有可能在某个地方被终止
	 * 假设执行完第一个自增后被终止(此时currentData的值必定为奇数,假设为3)
	 * 则下一个进程进入此方法后自增两次后,返回的值为奇数5,这就不符合偶数生成器的初衷了
	 * 也就证明了多个线程同时共享同一个资源会产生不可思议的错误的问题了
	 */
	public int next() {	
		// TODO Auto-generated method stub
		currentData ++;
		Thread.yield();  
		//在此处加一个yied方法是为了增加在此处发生线程中断的机率,线程在何处中断,什么操作是原子操作跟具体的操作系统及JVM有很大关系
		//也就是说如果不加这个在有些情境下有可能产生一个完美序列,不会发生冲突(我的电脑就是如此),但我们绝对不可以靠这个来解决线程使用共享资源的冲突问题
		currentData ++;
		return currentData;
	}
	
}
class ConsumeInt implements Runnable {
	private static int taskCount = 0;
	private final int id = taskCount ++;  //线程id
	private Generator generator;	//持有的生成器对象
	public ConsumeInt(Generator gen) {
		// TODO Auto-generated constructor stub
		generator = gen;
	} 
	@Override
	public void run() {
		// TODO Auto-generated method stub
		while (!generator.isCanceled()) {	//只要生成器没被取消就一直取出下一个值
			int n = generator.next();
			if (n % 2 != 0) {	//如果取出的值为奇数,就说明此生成器已经发生了线程冲突,这时就取消这个生成器
				generator.cancel();
				System.out.println("#" + id + "已取消生成器" + n);
			}
			System.out.print("#" + id + "(" + n + ") ");	//输出取出的值,格式为:#id(值)
		}
	}
}

public class Test {
	public static void main(String[] args) {
		ExecutorService pool = Executors.newCachedThreadPool();
		Generator generator = new IntGenerator();
		for (int i = 0;i < 5; i ++) {  //开启五个线程,这五个线程都持有同一个生成器对象
			pool.execute(new ConsumeInt(generator));
		}
		pool.shutdown();
	}
}

结果:

可以看到冲突确实发生了,线程一先进入了next方法,但是仅执行了一次自增运算就发生了中断,此时的currentData的值为1。然后线程0又进入了next方法,并且一次性执行完了这个方法,自增两次后返回的值为3,线程0就取消了生成器。但是线程1已经进入了next方法,所以即便是取消了生成器仍能接着执行第二次自增,最后返回4。
我们有什么方法防止这种事情发生呢?其实方法还是有的,大致思路有两种,第一种就是保证多个线程线性的获取资源的控制权,在前一个线程没有放弃掌控权之前其他线程必须等待;第二中就是把共享资源建立多个备份,每一个线程都要与一个备份建立关联,但是这样无疑会阻断线程之间的通信。
按照上述思路实现冲突结局的方法也不少,接下来我们对第一种思路的方法一一介绍:

  1. 资源同步(synchronized)
    使用synchronized修饰的方法一次仅能有一个线程使用,如果一个对象的一个synchronized方法被线程0使用了,那么同一个对象中的所有被synchronized修饰的方法都将不能被其他线程使用。
    一个线程获取同步资源控制权后,该资源的加锁次数就会曾加1,如果该线程又使用了该对象的其他同步资源枷锁次数又会加1。使用完毕后加锁次数会减一,直到减到0为止。枷锁次数为零的资源才能被其他线程获取控制权。
    针对上面例子我们只需要使用synchronized关键字修饰一下next()方法就可以解决线程冲突了,修改后的IntGenerator类的next方法如下:
public synchronized int next() {	//只有当前线程使用完此方法后才会此方法才能被其他线程使用	
		// TODO Auto-generated method stub
		currentData ++;
		Thread.yield();  
		currentData ++;
		return currentData;
	}
  1. 显式Lock对象
    显示Lock对象也是对共享资源进行加锁,与synchronized相比Lock对象能够允许我们对方法的部分代码片段加锁(synchronized也可以,我们在后面介绍);Lock对象还允许我们尝试获取锁,如果不能获取还可以去执行其他任务减少等待所浪费的时间;还有就是Lock对象还能够使我们在控制对象发生异常后进行一些处理。
    我们以使用Lock对象的方式来修改一下开头例子中的代码,修改后的IntGenerator类如下:
class IntGenerator extends Generator {   
	private volatile int currentData = 0;    
	private Lock lock = new ReentrantLock();
	@Override
	public int next() {	
		// TODO Auto-generated method stub
		lock.lock();
		try {
			currentData ++;
			Thread.yield();  
			currentData ++;
			return currentData;
		} finally {
			//如果发生异常可以保证此处吧也会被执行
			lock.unlock();
			//绝对不能再return之前解锁,这样可能导致还没返回线程就被中断了
		}
	}
	
}
  1. 原子操作
    如果能将每个方法的语句只有一条且为原子操作,那么也能够保证一个线程进入该方法后立马执行完毕而不给其他线程进入的机会。
    但是原子操作极少,我们能够知道的原子操作也就return 和赋值操作(操作系统和JVM不同原子操作也会不同),而且原子性的数据类型也比较少。
    使用volatile关键字能够将非原子性数据类型转换成原子性数据类型。除保证原子性外volatile还有一个更重要的作用是保证应用可视性,就是保证我们对数据的每一次更改,都是对主存中数据的更改,而不是仅仅改掉缓冲器中的值。再文章开头的例子中我们对volatile的使用就是使用的可视性,即保证再每次currentData自增后都是对主存中数据的更改,使得下一个插入的线程能够读到上一个线程更改后的值而不是原来的值。
    原子性是一个非常深奥的问题,所以除非你是专家,我们绝对绝对绝对不建议你使用原子性来解决资源同步问题。
    如果你不得不使用原子性来解决问题,那我们建议你使用Java提供的诸如AtomicInteger、AtomicLong、AtomicReference等的原子类。下面我们使用原子类来对IntGenerator进行修改:
class IntGenerator extends Generator {   
	private AtomicInteger currentData = new AtomicInteger(0);
	@Override
	public int next() {	  //线程先进入此方法使currentData加2然后再调用next获取值,这中间又可能被中断,但是绝对不会产生奇数
		// TODO Auto-generated method stub
		return currentData.get();
	}
	public void increament() {  
		currentData.addAndGet(2);	//AtomicIntger提供的方法基本都是原子性的
	}
	
}
  1. 临界区(使用synchronized)
    上面提到过,synchronized也能实现部分代码的同步处理接下来我们就实现一下:
class IntGenerator extends Generator {   
	private int currentData = 0;
	@Override
	public int next() {	  
		// TODO Auto-generated method stub
		synchronized (this) {	//要以一个对象作为参数,因为这个代码块中的内容要依赖这个对象参数的锁来实现。这个对象通常是使用当前对象,但也不拒绝使用别的对象。如果这个类有两个这样的同步方法,你希望可以有两个线程同时是进入这两个方法时就可以使用两个不同的对象参数实现。
			currentData ++;
			currentData ++;
			return currentData;
		}
	}	
}
发布了59 篇原创文章 · 获赞 69 · 访问量 5265

猜你喜欢

转载自blog.csdn.net/qq_40561126/article/details/105127317