Java Concurrent (2)

本文节选自 Effective Java by Joshua Bloch 和 Concurrent Programming in Java by Doug Lea.

 

1.3 原子数据的同步

   java语言保证读或写一个变量是原子(atomic)的,除非这个变量的类型是long或double.换句话说,读入一个非long或double类型的变量,可以保证返回值一定是某个线程保存在该变量中的,即使多个线程在没有同步的时候并发地修改这个变量,也是如此。  
    虽然原子性保证了一个线程在读写数据的时候,不会看到一个随机的数值,但是它并不保证一个线程写入的值对于另外一个线程是可见的。java的内存模型决定,为了在线程之间可靠地通信,以及为了互斥访问,对原子数据的读写进行同步是需要的。考虑下边的序列号生成程序:

private static int nextSerialNumber = 0;
public static int generateSerialNumber()
{
    return nextSerialNumber++;
}

    这个程序的意图是保证每次调用generateSerialNumber都会返回一个不同的序列号,然而,如果没有同步,这个方法并不能正确的工作。递增操作符(++)既要读nextSerialNumber域,也要写nextSerialNumber域,所以它不是原子的。读和写相互独立的操作。因此,多个并发的线程可能会看到nextSerialNumber有相同的值,因而返回相同的序列号。此外,一个线程重复地调用generateSerialNumber,获得从0到n的一系列序列号之后,另外一个线程调用generateSerialNumber并获得一个序列号是0,这是有可能发生的。如果没有同步机制,第二个线程可能根本看不到第一个线程所作的改变。


1.4 监控机制
    正如每个Object都有一个锁, 每个Object也有一个等待集合(wait set),它有wait、notify、notifyAll和Thread.interrupt方法来操作。同时拥有锁和等待集合的实体,通常被成为监视器(monitor)。每个Object的等待集合是由JVM维护的。等待集合一直存放着那些因为调用对象的wait方法而被阻塞的线程。由于等待集合和锁之间的交互机制,只有获得目标对象的同步锁时,才可以调用它的wait、notify和notifyAll方法。这种要求通常无法靠编译来检查,如果条件不能满足,那么在运行的时候调用以上方法就会导致其抛出IllegalMonitorStateException。

wait 方法被调用后,会执行如下操作

  •  如果当前线程已经被中断,那么该方法立刻退出,然后抛出一个InterruptedException异常。否则线程会被阻塞。
  • JVM把该线程放入目标对象内部且无法访问的等待集合中。
  • 目标对象的同步锁被释放,但是这个线程锁拥有的其他锁依然会被这个线程保留着。当线程重新恢复质执行时,它会重新获得目标对象的同步锁

notify方法被调用后,会执行如下操作

  • 如果存在的话,JVM会从目标对象内部的等待集合中任意移除一个线程T。如果等待集合中的线程数大于1,那么哪个线程被选中完全是随机的。
  • T必须重新获得目标对象的同步锁,这必然导致它将会被阻塞到调用Thead.notify的线程释放该同步锁。如果其他线程在T获得此锁之前就获得它,那么T就要一直被阻塞下去。
  • T从执行wait的那点恢复执行。

notifyAll方法被调用后的操作和notify类似,不同的只是等待集合中所有的线程(同时)都要执行那些操作。然而等待集合中的线程必须要在竞争到目标对象的同步锁之后,才能继续执行。

interrupt。如果对一个因为调用了wait方法而被挂起的对象调用Thread.interrupt方法,那么这个方法的执行机制就和notify类似,只是在重新获得对象锁后,该方法就会抛出InterruptedException异常,并且该线程的中断状态被置为false。

   对于Object.wait()方法,它一定是在一个同步区域中被调用,而且该同步区域锁住了被调用的对象。下边是使用Object.wait()方法的标准模式:

synchronized(obj)
{
    while( condition checking)
    {
       obj.wait();
    }
    …// Other operations
}

    总是要使用wait循环模式来调用wait方法,永远不要在循环的外边调用wait方法。循环的作用在于在等待的前、后都能测试条件。在等待之前测试条件,如果条件成立的话则跳过等待,这对于确保程序的活性(liveness)是必要的。如果条件已经成立,而且在线程等待之前notify(或者notifyAll)方法已经被调用过,那么无法保证该线程将总会从等待中醒过来。在等待之后测试条件,如果条件不成立的话则继续等待,这对于确保程序的安全性(safety)是必要的。当条件不成立的时候,如果线程继续执行,那么可能破坏被锁保护的约束关系。当条件不成立的时候,有以下一些理由可以使一个线程醒过来:

  1. 从一个线程调用notify方法的时刻起,到等待线程被唤醒的时刻之间,另一个线程得到了锁,并且改变了被保护的状态。
  2. 条件没有成立,但是另外一个线程可能意外或者恶意地调用了notify方法。在公有对象上调用wait方法,这其实是将自己暴露在危险的境地中。因为任何持有这个对象引用的线程都可以调用该对象的notify方法。
  3. 在没有被通知的情况下等待线程也可能被唤醒。这被称为“伪唤醒(spurious wakeup)”。虽然《Java语言规范(The Java Language Specification )》并没有提到这种可能,但是许多JVM实现都使用了具有伪唤醒功能的线程设施,尽管用的很少。

与此相关的一个问题是,为了唤醒正在等待的线程,到底应该使用notify方法还是应该使用notifyAll方法。假设所有的wait调用都是在循环的内部,那么使用notifyAll方法是一个合理而保守的做法。它总会产生正确的结果,它可以保证会唤醒所有需要被唤醒的线程。当然,这样也会唤醒其它一些线程,但是这不会影响程序的正确性。这些线程醒来之后会检查等待条件,发现条件不满足,就会继续等待。使用notifyAll方法的另外一个优点在于可以避免来自不相关线程的意外或者恶意等待。否则的话,这样的等待可能会“吞掉”一个关键的通知,使真正的接收线程无限地等待下去。关于使用notifyAll方法的一个不足在于,虽然使用notifyAll方法不会影响程序的正确性,但是会影响程序的性能。 

 

1.5 死锁
    尽管完全同步的原子操作很安全,但是线程可能却因此失去了活性(liveness)。死锁(dead lock)是在两个或多个线程都有权限访问两个或多个对象,并且每个线程都在已经得到一个锁的情况下等待其它线程已经得到的锁。假设线程A持有的对象X的锁,并且正在试图获得对象Y的锁,同时,线程B已经拥有的对象Y的锁,并在试图获得对象X的锁。因此没有哪个线程能够执行进一步的操作,死锁就产生了。例如:

public class Cell {
	private long value;
	
	public Cell(long value) {
		this.value = value;
	}
	
	public synchronized long getValue() {
		return value;
	}

	public synchronized void setValue(long value) {
		this.value = value;
	}
	
	public synchronized void swap(Cell other) {
		long t = getValue();  
		long v = other.getValue();  
		setValue(v);  
		other.setValue(t);
	}
	
	public static void main(String args[]) throws Exception {
		//
		final Cell c1 = new Cell(100);
		final Cell c2 = new Cell(200);
		
		//
		Thread t1 = new Thread(new Runnable() {
			public void run() {
				long count = 0;
				try {
					while(true) {
						c1.swap(c2);
						count++;
						if(count % 100 == 0) {
							System.out.println("thread1's current progress: " + count);
						}
					}
				} catch (Exception e) {
					e.printStackTrace();
				}
			}
		});
		t1.setName("thread1");
		
		//
		Thread t2 = new Thread(new Runnable() {
			public void run() {
				long count = 0;
				try {
					while(true) {
						c2.swap(c1);
						count++;
						if(count % 100 == 0) {
							System.out.println("thread2's current progress: " + count);
						}
					}
				} catch (Exception e) {
					e.printStackTrace();
				}
			}
		});
		t2.setName("thread2");
		
		//
		t1.start();
		t2.start();
		t1.join();
		t2.join();
	}
}
  

   如果按照下面的时序执行时序,就会导致死锁:

线程A 线程B
进入a.swap(b)时获得a的锁
在执行t = getValue()时,顺利获得a的锁(因为已经持有)  进入b.swap(a)时获得b的锁
执行v = other.getValue()时,由于需要b的锁而处于等待的状态 在执行t = getValue()时,顺利获得b的锁
执行v = other.getValue()时,由于需要a的锁而处于等待状态

   以上的代码执行一段时间后可能就会发生死锁。此时可以通过thread dump获得线程的栈跟踪信息。在Unix平台下可以通过向JVM发送SIGQUIT信号(kill -3)获得thread dump,在Windows平台下则通过Ctrl+Break。以上代码在死锁时的thread dump如下:

   Found one Java-level deadlock:
    =============================
    "thread2":
      waiting to lock monitor 0x0003e664 (object 0x230c3f40, a Cell),
      which is held by "thread1"
    "thread1":
      waiting to lock monitor 0x0003e6a4 (object 0x230c3f50, a Cell),
      which is held by "thread2"

    Java stack information for the threads listed above:
    ===================================================
    "thread2":
            at Cell.getValue(Cell.java:18)
            - waiting to lock <0x230c3f40> (a Cell)
            at Cell.swap(Cell.java:27)
            - locked <0x230c3f50> (a Cell)
            at Cell$2.run(Cell.java:65)
            at java.lang.Thread.run(Unknown Source)
    "thread1":
            at Cell.setValue(Cell.java:22)
            - waiting to lock <0x230c3f50> (a Cell)
            at Cell.swap(Cell.java:29)
            - locked <0x230c3f40> (a Cell)
            at Cell$1.run(Cell.java:46)
            at java.lang.Thread.run(Unknown Source)

    Found 1 deadlock.

   为了避免死锁的危险,在一个同步的方法或者代码块中,永远不要放弃对客户的控制。换句话说,在一个被同步的区域内部,不要调用一个可被改写的公有或受保护的方法。从包含该同步区域的类的角度来看,这样的一个方法是一个外来者(alien)。这个类不知道该方法会做什么事情,也控制不了它。假设客户的方法创建另一个线程,再回调到这个类中。然后,新建的线程试图获取原线程所拥有的那把锁,这样就会导致新建的线程被阻塞。如果创建该线程的方法正在等待这个线程完成任务,则会导致死锁。  
    另外一种比较简单的避免死锁的独占技术是顺序化资源(resource ordering),它的思想就是把一个嵌套的synchronized方法或块中使用的对象和一个数字标签关联起来。如果同步操作是根据对象标签的最小优先(least first)的原则,那么刚才介绍的例子的情况就不会发生。也就是说,如果线程A和线程B都按照相同的顺序获得锁,就可以避免死锁的发生。对于数字标签的选择,可以使用System.identityHashCode的返回值,尽管没有什么机制可以保证identityHashCode的惟一性,但是在实际运行的系统中,这个方法的惟一性在很大程度上得到了保证。swap的一个更好的实现如下:

public void swap(Cell other)
{
    if(this == other) return; // Alias check
    else if(System.identityHashCode(this) < System.identityHashCode(other))
    {
        this.doSwap(other);
    }
    else
    {
        other.doSwap(this);
    }
}

private synchronized void doSwap(Cell Other)
{
    long t = getValue();
    long v = other.getValue();
    setValue(v);
    other.setValue(t);
}

你可能感兴趣的:(java,Concurrent)