java高并发学习(8)-----多线程的团队协作:同步控制

Java高并发学习()-------多线程的团队协作:同步控制

  同步控制是并发程序必不可少的重要手段。之前介绍的synchronized关键字就是一种最简单的控制方法。同时,wait()和notify()方法起到了线程等待和通知的作用。这些工具对于实现复杂的多线程协作起到了重要的作用。接下来将介绍synchronized,wait,notify方法的代替品(或者说是增强版)——重入锁。

1. synchronized的功能扩展: 重入锁

    重入锁完全可以代替synchronized关键字。在早期JDK版本,重入锁的性能远远优于synchronized关键字,在JDK后期版本,对synchronized关键字做了大量的优化,使得两者的性能差不多。

    下面展示一段简单的synchronized的使用案例:
public class fist{
	public static ReentrantLock Lock = new ReentrantLock();
	public static int Count = 0;
	
	public static class MyThread extends Thread{
		@Override
		public void run(){
			for(int i=0;i<10000;i++){
				Lock.lock();
				Count++;
				Lock.unlock();
			}
		}
	}
	
	public static void main(String args[]) throws InterruptedException{
		MyThread t1 = new MyThread();
		MyThread t2 = new MyThread();
		t1.start();t2.start();
		t1.join();t2.join();
		System.out.println(Count);
	}
}

 上述代码创建了一个全局的ReentrantLock对象,这个对象就是重入锁对象,该对象的lock()和unlock()方法之间的代码区域就是重入锁的保护零界区,确保了多线程对Count变量的操作安全性。

    从这段代码可以看到,与synchronized相比,重入锁有着显示操作的过程。开发人员必须手动指定何时加锁 ,何时释放锁。也正是因为这样,重入锁逻辑控制远远要好于synchronized。但值得注意的是,在退出零界区时,必须记得要释放锁,否者永远没有机会再访问零界区了,会造成其线程的饥饿甚至是死锁。

    重入锁之所以被称作重入锁是因为重入锁是可以反复进入的。当然,这里的反复进入仅仅局限于一个线程。上诉代码还可以这样写:

for(int i=0;i<10000;i++){
    Lock.lock();
    Lock.lock();
    Count++;
    Lock.unlock();
    Lock.unlock();
}

    在这种情况下,一个线程连续两次获得同一把锁。这是允许的!但要注意的是,如果一个线程多次获得锁,那么在释放锁的时候,也必须释放相同次数。

·中断响应

    重入锁除了提供上述的基本功能外,还提供了一些高级功能。比如,重入锁可以提供中断处理的能力。这是一个非常重要的功能,synchronized是没有中断功能的。在等待锁的过程中,程序可以根据需要取消对锁的请求。这是synchronized办不到的。也就是说,重入锁具有解除死锁的功能。

    下面的代码产生了一个死锁,得益于锁的中断,我们可以轻易的解决这个死锁:

public class fist{
	public static ReentrantLock Lock1 = new ReentrantLock();
	public static ReentrantLock Lock2 = new ReentrantLock();
	
	public static class MyThread extends Thread{
		int flag;
		MyThread(int flag){
			this.flag = flag;
		}
		@Override
		public void run(){
			try{
				if(flag == 1){
					try {
						Lock1.lockInterruptibly();
						Thread.sleep(1000);
						Lock2.lockInterruptibly();
						System.out.println(flag+"号线程:完成工作");
					} catch (InterruptedException e) {}
				}
				else if(flag == 2){
					try {
						Lock2.lockInterruptibly();
						Thread.sleep(1000);
						Lock1.lockInterruptibly();
						System.out.println(flag+"号线程:完成工作");
					} catch (InterruptedException e) {}
				}
			}finally{
				//中断响应
				if(Lock1.isHeldByCurrentThread()){
					Lock1.unlock();
					System.out.println(flag+":Lock1 interrupted unlock");
				}
				if(Lock2.isHeldByCurrentThread()){
					Lock2.unlock();
					System.out.println(flag+":Lock2 interrupted unlock");
				}
				System.out.println(flag+"号线程退出");
			}
		}
	}
	public static void main(String args[]) throws InterruptedException{
		MyThread t1 = new MyThread(1);
		MyThread t2 = new MyThread(2);
		t1.start();
		t2.start();
		Thread.sleep(3000);
		t2.interrupt();
	}
}

    线程t1和线程t2启动后,t1先占用lock1,再占用lock2;t2先占用lock2,再请求lock1。这样很容易形成t1和t2之间的互相等待,造成死锁。在这里,对锁的请求,统一使用lockInterruptibly()方法。这是一个可以对中断进行响应的锁申请动作,即在等待锁的过程中可以响应中断。

    在t1和t2线程start后,主线程main进入休眠,此时t1和t2线程处于死锁状态,然后主线程main中断t2线程,故t2会放弃对lock1的请求,同时释放lock2。这个操作使得t1可以获得lock2从而继续执行下去。

    执行上诉代码,将输出:


    可以看到,中断后,两个线程双双退出。但真正完成工作的只有t1。而t2放弃任务直接退出,释放资源。

·锁申请等待限时

    除了等待外部通知之外,还有一种避免死锁的方法,就是限时等待。通常,我们不会预料到系统在什么时候会产生死锁,就无法主动的解除死锁,最好的系统设计方式是,这个系统根本就不会产生死锁。我们可以用tryLock()方法进行限时等待。

    下面这段代码展示了限时等待锁的使用:

public class fist{
	public static ReentrantLock Lock = new ReentrantLock();
	public static class MyThread extends Thread{
		@Override
		public void run(){
			try {
				if(Lock.tryLock(5,TimeUnit.SECONDS)){
					Thread.sleep(6000);
				}
				else{
					System.out.println("get lock failed");
				}
			} catch (InterruptedException e) {
				e.printStackTrace();
			}
			if(Lock.isHeldByCurrentThread()){
				Lock.unlock();
			}
		}
	}
	public static void main(String args[]) throws InterruptedException{
		MyThread t1 = new MyThread();
		MyThread t2 = new MyThread();
		t1.start();
		t2.start();
	}
}

输出结果:

    在这里,trylock()接收两个参数,一个表示等待时长,另一个表示计时单位。这里设置为秒,时长为5,表示线程在这个锁的请求中,最多等待5秒。如果超过5秒还没有得到锁就返回false。如果成功就返回true。
    在本例中,由于占用锁的线程会持有锁长达6秒,故另外一个线程无法在5秒内获得锁,因此,对锁的请求会失败。

    tryLock()方法也可以不带参数直接运行,在这种情况下,当前进程会尝试获得锁,如果锁并未被其他进程占用,则申请就会成功,立即返回true。如果锁被其他线程占用,会立即返回false。这种模式不会引起线程的等待,因此不会造成死锁。下面演示了这种使用方式:

public class fist{
	public static ReentrantLock Lock1 = new ReentrantLock();
	public static ReentrantLock Lock2 = new ReentrantLock();
	public static class MyThread extends Thread{
		int flag;
		MyThread(int flag){
			this.flag = flag;
		}
		@Override
		public void run(){
			if(flag == 1){
				while(true){
					if(Lock1.tryLock()){
						try {
							Thread.sleep(500);
							if(Lock2.tryLock()){
								System.out.println(flag+"号线程完成工作");
							}
						} catch (InterruptedException e) {
							e.printStackTrace();
						}
					}
					if(Lock2.isHeldByCurrentThread()){
						Lock2.unlock();
					}
					if(Lock1.isHeldByCurrentThread()){
						Lock1.unlock();
					}
				}
			}
			else if(flag == 2){
				while(true){
					if(Lock2.tryLock()){
						try {
							Thread.sleep(300);
							if(Lock1.tryLock()){
								System.out.println(flag+"号线程完成工作");
							}
						} catch (InterruptedException e) {
							e.printStackTrace();
						}
					}
					if(Lock1.isHeldByCurrentThread()){
						Lock1.unlock();
					}
					if(Lock2.isHeldByCurrentThread()){
						Lock2.unlock();
					}
				}
			}
		}
	}
	public static void main(String args[]) throws InterruptedException{
		MyThread t1 = new MyThread(1);
		MyThread t2 = new MyThread(2);
		t1.start();
		t2.start();
	}
}

    上述代码中,采用了非常容易死锁的加锁顺序。也就是先让线程t1请求lock1,在请求lock2,而让t2先请求lock2,在请求lock1。在一般情况下,这样会导致t1,t2互相等待,从而引起死锁。
    但是采用trylock后,这种情况得到了改善。由于线程不会傻傻的等待,而是不停的尝试,因此, 只要执行足够长的时间,线程总是会获得所需要的资源,从而正常执行(这里以线程能同时获得lock1和lock2两把锁视为正常执行)。

代码执行结果如下:


  可以看到,1号线程和2号线程都是有机会被执行到的。但是不能保证,谁先被执行和被执行的次数是平均的。不知道各位有没有注意上述的一句话:只要执行足够长的时间,线程总是会获得所需要的资源”,这句话给我们提供了两个信息。第一,两个线程都会有被执行到的几乎,第二,不能保证这两个线程被公平的执行。实际上这两个线程也是在互相争夺同一个资源,这两个线程到底谁会被执行,这依靠的是操作系统对线程的调度策略。

  在上述程序执行一段时间后:

    我们可以看到1号线程被执行次数比较多。这表示操作系统的调度不能满足资源平均分配这一需求。这里在多说一句,根据目前大多数操作系统的调度,一个线程倾向于在次获得持有的锁,这种分配方法是高效的,但毫无公平性可言。

·公平锁

    在大多数情况下,如上述情况,锁的申请都是非公平的。也就是说,线程1首先请求了锁A,接着线程2也请求了锁A。那么锁A可用时,线程1可以获得锁还是线程2可以获得锁呢?这是不一定的,系统只会从这个锁的等待队列中随机挑取一个。因此不能保证公平性。
    而接下来要讲的公平锁,他会按照时间的先后顺序,保证先到者先得,后到者后得。所以,公平锁的最大特点就是,他不会产生饥饿现象。
    注意:如果线程采用synchronized进行互斥,那么产生的锁是非公平的。而重入锁允许我们进行公平性设置。他有一个如下的构造函数:
    public ReentranLock(boolean fair);

    

    当参数fair为true时,表示锁是公平的。公平锁看起来很优美,但是要实现公平锁,必然要求系统维护一个有序队列,因此对公平锁得到实现成本比较高,意味着公平锁的效率非常低下,因此,在默认情况下,锁是非公平的。如果没有什么特别的需求,尽量别用公平锁。

    下面代码能很好的凸显公平锁的特点:

public class fist{
	public static ReentrantLock Lock = new ReentrantLock(true);
	public static class MyThread extends Thread{
		@Override
		public void run(){
			while(true){
				Lock.lock();
				try {
					Thread.sleep(1000);
				} catch (InterruptedException e) {
					e.printStackTrace();
				}
				System.out.println(getId() + ": 获得锁");
				Lock.unlock();
			}
		}
	}
	public static void main(String args[]) throws InterruptedException{
		MyThread t1 = new MyThread();
		MyThread t2 = new MyThread();
		t1.start();
		t2.start();
	}
}

    代码执行结果:

    可以看到,线程的调度是公平的。


2.  从入锁的好搭档: Condition条件

    如果大家了解object.wait()方法和object.notify()方法的HIA,那么就能很容易理解condition对象了。他和wait()和notify()方法的作用是基本相同的。但是wait()和notify()方法是与synchronized关键字组合使用的,而condition是与重入锁相关联的。
    Condition接口提供的基本方法如下:
    Void await() throws InterrupteException;
    Void awaitUninterruptibly();
    Long awaitNanos(long nanosTimeout) throws InterrupteException;
    Boolean await(long time, TimeUnit unit) throws InterrupteException;
    Boolean awaitUntil(Data deadline) throws InterrupteException;
    Void signal();
    Void signalAll();
    以上方法含义如下:
    ·await()方法会使当前线程等待,同时释放当前锁,当其他线程使用signal()或signalAll()方法时,线程会重新获得锁并继续执行。或者当线程被中断时,也能跳出等待。这和object.wait()方法很相似。
    ·awaitUninterruptibly()方法与wait()方法相同,唯一的不同点是,该方法不会再等待的过程中响应中断。
    ·signal()方法用于唤醒一个在等待中的线程。signalAll()会唤醒所有正在等待的线程。这和object.notify()方法很相似。
    下面代码简单的演示了Condition的作用:
public class fist{
	public static ReentrantLock Lock = new ReentrantLock(true);
	//生成Lock对应的condition对象
	public static Condition condition = Lock.newCondition();
	public static class MyThread extends Thread{
		@Override
		public void run(){
			try {
				Lock.lock();
				condition.await();
				System.out.println("Thread is going on");
				Lock.unlock();
			} catch (InterruptedException e) {
				e.printStackTrace();
			}
		}
	}
	public static void main(String args[]) throws InterruptedException{
		MyThread t1 = new MyThread();
		t1.start();
		//两秒后通知线程继续执行
		Thread.sleep(2000);
		Lock.lock();
		condition.signal();
		Lock.unlock();
	}
}

  上述代码先通过lock生成一个与之绑定的condition对象。后要求线程在condition对象上进行等待。主线程main在两秒后发出signal通知,告知等待在condition上的线程可以继续执行了。

  和object.wait()object.notify()一样,当线程使用Condition.wait()时,要求线程持有相关的从入锁,condition.wait()调用后,这个线程会主动释放这把锁。并且,在condition.signal()方法调用时,也要求线程获取相关的锁。注意,在signal()方法调用之后,一定要释放相关的锁,把锁让给其他线程。

  当主线程调用signal()方法之后,会从等待队列中随机唤醒一个wait中的线程,这个线程会重新进行锁的争夺。这里很容易让读者产生一个疑问,在线程唤醒后是重新执行零界区代码,还是继续执行condition.wait()方法后的代码?这里给出答复,是继续执行condition.wait()方法后的代码(JDK会像中断一样保存线程断点)

3.  允许多个线程同时访问:信号量(Semaphore)

  信号量为多线程协作提供了更为强大的控制方法。广义上说,信号量是对锁的扩展。无论是内部锁synchronized还是重入锁ReentranLock,一次都只允许一个线程访问一个资源,而信号量却可以指定多个线程,同时访问一个资源。信号量主要提供了一下的构造函数:

  Public Semaphore(int permits);

  Public Semaphore(int permits, boolean fair); //第二个参数可以指定是否公平

  在构造信号量时,必须指定信号量的准入数,即同时能申请几个许可。当每个线程只申请一个许可时,这就相当于指定了同时能有多少个线程可以访问某个资源。信号量的主要逻辑方法有:

Public void acquire();

Public void acquireUninterruptibly();

Public boolean tryAcquire();

Public boolean tryAcquire(long timeout, TimeUnit unit);

Public void release();

  acquire()方法尝试获得一个准入的许可。若无法获得,则线程会等待,直到申请到许可或者当前线程被中断。acquireUninterruptibly()方法与acquire()方法类似,但不响应中断。tryAcquire()尝试获得一个许可,成功返回true失败返回false,它不会进行阻塞等待,立即返回。release()用于在线程访问资源结束后,释放一个许可,以使其他等待许可的线程可以进行资源访问。

 下面是Semaphore的简单使用:

public class fist{
	public static Semaphore semp = new Semaphore(5);
	public static class MyThread extends Thread{
		@Override
		public void run(){
			try {
				semp.acquire();
				//模拟耗时操作
				Thread.sleep(2000);
				System.out.println(Thread.currentThread().getId()+": done!");
				semp.release();
			} catch (InterruptedException e) {
				e.printStackTrace();
			}
		}
	}
	public static void main(String args[]) throws InterruptedException{
		ExecutorService exec = Executors.newFixedThreadPool(20);
		MyThread t1 = new MyThread();
		for(int i=0;i<20;i++){
			exec.submit(t1);
		}
	}
}

  在本例中同时开启了20个线程。观察上述程序的输出,你会发现线程以5个线程为一组依次输出。

4. ReadWriteLock 读写锁

ReadWriteLock 是JDK5中提供的读写分离锁。读写锁能有效的帮助减少锁竞争,以提升系统性能。

·读-读不互斥:读读之间不阻塞。

·读-写互斥:读阻塞写,写也会阻塞读。

·写-写互斥:写写阻塞。

如果系统中,读操作次数远远大于写操作,则读写锁可以发挥最大的功效,提升系统性能。这里给出一个稍微夸张的案例,来说明读写锁对性能的帮助。

public class fist{
	//普通锁
	private static Lock lock = new ReentrantLock();
	//读写锁
	private static ReentrantReadWriteLock readWriteLock = new ReentrantReadWriteLock();
	private static Lock readLock = readWriteLock.readLock();
	private static Lock writeLock = readWriteLock.writeLock();
	private static int value;
	private static int runtime = 0;
	
	//模拟读操作
	public static int handleRead(Lock lock) throws InterruptedException{
		try {
			lock.lock();
			Thread.sleep(1000);
			return value;
		}finally{
			lock.unlock();
		}
	}
	//模拟写操作
	public static void handleWrite(Lock lock, int index) throws InterruptedException{
		try {
			lock.lock();
			Thread.sleep(1000);
			value = index;
		}finally{
			lock.unlock();
		}
	}
	public static class Mythread_Read extends Thread{
		@Override
		public void run(){
			try {
				//handleRead(readLock);    //使用读写锁
				handleRead(lock);      //使用普通锁
			} catch (InterruptedException e) {}
		}
	}
	public static class Mythread_Write extends Thread{
		@Override
		public void run(){
			try {
				//handleWrite(writeLock, 0);    //使用读写锁
				handleWrite(lock, 0);       //使用普通锁
			} catch (InterruptedException e) {}
		}
	}
	//守护线程,用来记录运行时间
	public static class Deamon extends Thread{
		@Override
		public void run(){
			try {
				while(true){
					System.out.println("use time: " + runtime);
					runtime++;
					Thread.sleep(1000);
				}
			} catch (InterruptedException e) {}
		}
	}
	
	public static void main(String args[]) throws InterruptedException{
		Deamon deamon = new Deamon();
		deamon.setDaemon(true);
		deamon.start();
		for(int i=0;i<18;i++){
			Mythread_Read read = new Mythread_Read();
			read.start();
		}
		for(int i=0;i<2;i++){
			Mythread_Write write = new Mythread_Write();
			write.start();
		}
	}
}

    上述代码中,比较了使用读写锁和普通锁时,系统完成读写任务所需要的时间,这里设置读任务要比写任务多得多。设置一个守护线程来记录整个读写操作完成所需要的时间。

 执行结果(不用读写锁)


可以看到,不用读写锁,程序花费了20秒的时间才完成读写任务。

执行结果(采用读写锁):

可以看到,采用读写锁,程序需要3秒就完成读写任务了。

所以,当一个系统中读者数量明显多于写者时,使用读写锁能大大减小系统的开销,这一点非常重要

















猜你喜欢

转载自blog.csdn.net/huxiny/article/details/79958726