Java并发编程2-同步

J:hi,T。
T:hi,J。
J:今天聊点什么呢?
T:今天我们谈Java并发编程的同步,你知道什么是同步吗?
J(上网):维基百科上说,同步(Synchronization),指在一个系统中所发生的事件(event),之间进行协调,在时间上出现一致性与统一化的现象。
T:是的,同步是Java线程间通讯的一种方法,也是最基本的一种方法。今天我要讲的就是Java中同步的实现原理,Java中的同步方法,最后讨论同步可能会导致的风险。
J:听起来挺不错的。
T:那我们就从Java同步的实现原理开始吧。

Java同步的实现原理

Java中的每个Object都对应到一个监视器,Java中的同步就是使用监视器来实现的,在一个时间只能有一个线程能够获取监视器的锁,而任何其它想要获取同一个监视器锁的线程都必须等待,直到获取锁的线程释放了锁。一个线程可以为一个监视器加多个锁,每个锁都对应一个解锁的操作。


J:也就是说线程的互斥是通过对同一个监视器加锁实现的?
T:是的,非常正确。介绍完原理,我们来看看Java中的同步方法。

Java中的同步方法

synchronized

synchronized是最常用的同步方法,可以为代码块或者一个方法添加synchronized,看下面的例子:

public class test {
	private int value = 0;

	//为方法添加同步
	public synchronized void increase() {
		value++;
	}

	public void print() {
		//为代码块添加同步
		synchronized (this) {
			//process
		}
	}
}


J:看起来蛮简单的。
T:是的,为代码块和方法添加同步在执行的步骤上有些不同:
(1)当synchronized代码块执行时,首先找到同步对象并获取同步对象的监视器的锁,当锁获取成功后,将会继续执行synchronized块,直到执行完成(正常完成或者异常终止),同步对象上的锁将会被自动释放。而如果获取锁不成功,线程则会等待其它线程释放锁。
(2)当synchronized方法执行时,会自动的执行一个加锁操作,加锁成功后,方法才会被调用。锁对应的对象包含两种情况:
 ------如果方法是一个实例方法,则锁对应的监视器为被调用的实例的监视器;
 ------如果方法是static的,则锁对应的监视器为方法定义的class的Class对象的监视器。
当方法执行完成后(正常完成或者异常终止),解锁操作将在同一个监视器上自动执行。
下面来看一个小小的测试题:

public class test {
	private int value = 0;
	private int flag = 0;
	public test(int flag){
		this.flag = flag;
	}
	public void increase() {
		synchronized (test.class) {
			value++;
		}
	}
	public void print() {
		synchronized (test.class) {
			try {
				Thread.sleep(10000); //Thread.sleep()在睡眠期间任然会持有锁
				System.out.println(System.currentTimeMillis() + "(" + flag + ")" + " : " + value);
			} catch (InterruptedException e) {
				e.printStackTrace();
			}
		}
	}
	public static void main(String[] args) {
		Thread t1 = new Thread(new Runnable() {
			@Override
			public void run() {
				test t = new test(1);
				for (int i = 0; i < 10; i++) {
					t.increase();
					t.print();
				}
			}
		});
		Thread t2 = new Thread(new Runnable() {
			@Override
			public void run() {
				test t = new test(2);
				for (int i = 0; i < 10; i++) {
					t.increase();
					t.print();
				}
			}
		});
		t1.start();
		t2.start();
	}
}


你知道这段代码会得到怎样的打印输出吗?
J:恩,我觉得线程t1和t2都会打印从1到10的value的值,两个线程中对test类的increase方法和print方法的调用都是互斥的吧,应该每行打印都间隔10s。
T:非常正确,但为什么呢?
J:因为test中同步的对象是test.class,是吧?
T:对,由于print方法中synchronized的对象是test.class,因此调用print方法的线程将获取test.class的监视器的锁,虽然t1和t2线程使用的是test类的不同的实例,但由于锁是同一个,所有两个线程中对print方法的调用是互斥的。
J:看来我还是蛮聪明的嘛!
T(笑):当然,我们继续。

volatile

volatile变量具有可见性特性,但是不具备原子性特性,看下面的例子:

public class test {
	private volatile int value = 0;
	
	public void set(int value){
		this.value = value;
	}
	
	public int get(){
		return this.value;
	}
}


value值是一个volatile变量,一个线程改变value的值后,其它线程都可以看到它的变化,也就是value具有可见性,由于int变量的赋值操作是一个原子操作,因此value变量的修改就同时具备了原子性和可见性,是线程安全的。
J:哦,但不具备原子性又是什么意思呢?
T:看这段代码:

public class test {
	private volatile int i = 0;
	
	public void increase(){
		i++;
	}
}


i++不是一个原子操作,它分为3个步骤:获取值、加1和存入值。将i定义为一个volatile变量也不能使i++成为一个原子操作,因此这段代码任然不是线程安全的。
J:我明白了。
T:在使用volatile时需要特别小心,只有在满足下面的标准后,你才能使用volatile变量:
------写入变量时并不依赖变量的当前值;或者能够确保只有单一的线程修改变量的值;
------变量不需要与其他的状态变量共同参与不变约束;
------而且,访问变量时,没有其他的原因需要加锁。
J:既然volatile的使用这么麻烦,为什么我们还要使用它呢,直接使用synchronized不就行了吗?
T:你说的有一些道理,synchronized在一般情况下都能够取代volatile,使用volatile主要在于它相比synchronized的性能上的提升,在某些性能要求较高又符合volatile标准的场合,都会考虑使用volatile。
J:我明白了,看来存在总是会有道理的。
T:是的,我们继续。

ReentrantLock

ReentrantLock是显式锁,为什么叫显式锁呢?因为它的加锁和解锁操作都需要显式的调用,看下面的例子:

public class test {
	private Lock lock = new ReentrantLock();

	private int i = 0;

	public void increase() {
		lock.lock();
		try {
			i++;
		} finally {
			lock.unlock();
		}
	}
}


需要注意锁的释放必须在finally块中完成,否则异常可能导致锁无法得到释放。
J:这么麻烦,万一忘记解锁或者忘记把解锁放在finally块中就麻烦了。
T:是的,但ReentrantLock也有它的好处,JDK5.0引入ReentrantLock主要是因为性能上较大的提升,但从Java 6.0开始,由于使用了经过改善的管理内部锁的算法,ReentrantLock的性能提升已经不再那么明显,因此,除非有必要,建议优先使用synchronized。
J:那我还是尽量避免去使用它吧。
T:是的。除了上面的方法,Java还提供了一些并发容器。

Java并发容器

这些并发容器在java.util.concurrent下面,它们的效率都是经过优化的,应该优先考虑使用,有时间的话,我会选一些并发容器来学习。


J:那么,同步的方法介绍完了,我们应该看同步的风险了吧。

同步的风险

T:是的,同步存在许多风险,有逸出、死锁、还可能导致性能问题,我们先从逸出讲起吧:

逸出

看下面的例子:

public class test {
	private Map> map = new HashMap>();
	
	public synchronized void put(String key,Object obj){
		List list = map.get(key);
		if(list == null){
			list = new ArrayList();
			map.put(key, list);
		}
		list.add(obj);
	}
	
	public synchronized List get(String key){
		return map.get(key);
	}
	......
} 
  


test类的get方法返回了一个列表,这个列表可能会在外部进行处理,无法得到锁的保护,这种情况就称为逸出。逸出往往导致一些偶现的故障,非常难于定位。
J:这么严重,那我可得小心了,我可不想把大量的时间耗费在这种问题上。
T:是的,我们继续。

死锁

死锁的原因、诊断和避免在操作系统的课程中有详细的讲述,我就不再细讲了。
J:那我们应该讨论性能问题了吧?
T:是的。

性能问题

性能问题涉及多个方面,包括服务时间、响应性、吞吐量、资源消费或者可伸缩性的不良表现。而且线程也会给运行时的带来一定程度的开销,即上下文切换。
上下文切换指调度程序临时挂起当前运行的线程,另一个线程开始运行,会带来大量的开销,包括:保存和恢复线程执行的上下文,离开执行现场,CPU会对线程进行调度。
如果系统中线程过多(相对CPU而言),就会导致大量的上下文切换,CPU就会花费大量的时间去处理线程的调度,而不是执行程序,导致性能下降。
J:也就是说我们应该合理的使用线程,而不是越多越好。
T:是的,性能是一个很大的方面,可以单独开一个专题来讨论了。
J:那我们就不在这里讨论了,今天就到此为止了,我来总结一下吧。
我们讲了Java中同步的实现原理,然后介绍了Java中实现同步的方法,包括synchronized、volatile、ReentrantLock和Java并发容器,最后介绍了同步可能带来的问题,包括逸出、死锁和性能问题。
T:非常好,那么,下次再见。
J:再见。

你可能感兴趣的:(并发编程)