一步一步掌握线程机制(四)---同步方法和同步块
在之前例子的基础上,我们增加新的功能:根据正确与不正确的响应来显示玩家的分数。
public class ScoreLabel extends JLabel implements CharacterListener { private volatile int score = 0; private int char2type = -1; private CharacterSource generator = null, typist = null; public ScoreLabel(CharacterSource generator, CharacterSource typist) { this.generator = generator; this.typist = typist; if (generator != null) { generator.addCharacterListener(this); } if (typist != null) { typist.addCharacterListener(this); } } public ScoreLabel() { this(null, null); } public synchronized void resetGenerator(CharacterSource newCharactor) { if (generator != null) { generator.removeCharacterListener(this); } generator = newCharactor; if (generator != null) { generator.addCharacterListener(this); } } public synchronized void resetTypist(CharacterSource newTypist) { if (typist != null) { typist.removeCharacterListener(this); typist = newTypist; } if (typist != null) { typist.addCharacterListener(this); } } public synchronized void resetScore() { score = 0; char2type = -1; setScore(); } private synchronized void setScore() { SwingUtilities.invokeLater(new Runnable() { @Override public void run() { setText(Integer.toString(score)); } }); } @Override public synchronized void newCharacter(CharacterEvent ce) { if (ce.source == generator) { if (char2type != -1) { score--; setScore(); } char2type = ce.character; } else { if (char2type != ce.character) { score--; } else { score++; char2type = -1; } setScore(); } } }
这里我们将newCharacter()方法用synchronized进行同步,是因为这个方法会被多个线程调用,而我们根本就不知道哪个线程会在什么时候调用这个方法。这就是race condition。
变量的volatile无法解决上面的多线程调度问题,因为这里的问题是方法调度的问题,而且更加可怕的是,需要共享的变量不少,其中有些变量是作为条件判断,这就会导致在这些条件变量没有正确的设置前,有些线程已经开始启动了。
这并不是简单的将这些变量设置为volatile就能解决的问题,因为就算这些变量的状态不对,其他线程依然能够启动。
这里有几个方法的同步是需要引起我们注意的:resetScore(),resetGenerator()和resetTypist()这几个方法是在重新启动时才会被调用,似乎我们不需要为此同步它们:其他线程这时根本就没有开始启动!!
但是我们还是需要同步这些方法,这是一种防卫性的设计,保证整个Class所有相关的方法都是线程安全的。遗憾的是,我们必须这样考虑,因为多线程编程的最大问题就是我们永远也不知道我们的程序会出现什么问题,所以,任何可能会引起线程不安全的因素我们都要尽量避免。
这也就引出我们的问题:如何能够对两个不同的方法同步化以防止多个线程在调用这些方法的时候影响对方呢?
对方法做同步化,能够控制方法执行的顺序,因为某个线程上已经运行的方法无法被其他线程调用。这个机制的实现是由指定对象本身的lock来完成的,因为方法需要访问的对象的lock被一个线程占有,但值得注意的是,所谓的对象锁其实并不是绑定在对象上,而是对象实例上,如果两个线程拥有对象的两个实例,它们都可以同时访问该对象,
同步的方法如何和没有同步的方法共同执行呢?
所有的同步方法都会执行获取对象锁的步骤,但是没有同步的方法,也就是异步方法并不会这样,所以它们能够在任意的时间点被任意的线程执行,而不管到底是否有同步方法在执行。
关于对象锁的话题自然就会引出一个疑问:静态的同步方法呢?静态的同步方法是无法获取对象锁的,因为它没有this引用,对于它的调用是不存在对象的。但静态的同步方法的确是存在的,那么它又是怎样运作的呢?
这需要另一个锁:类锁。
我们可以从对象实例上获得锁,也能从class(因为class对象的存在)上获得锁,即使这东西实际上是不存在的,因为它无法实现,只是帮助我们理解的概念。值得注意的是,因为一个class只有一个class对象,所以一个class只有一个线程可以执行同步的静态方法,而且与对象的锁毫无相关,类锁可以再对象锁外被独立的获得和释放,一个非静态的同步方法如果调用同步的静态方法,那么它可以同时获得这两个锁。
提供synchronized关键字的目的是为了让对象中的方法能够循序的进入,大部分数据保护的需求都可以由这个关键字实现,但在更加复杂的同步化情况中还是太简单了。
在java这个对象王国里,难道真的是没有Lock这个对象的容身之处吗?答案当然是不可能的,J2SE 5.0开始提供Lock这个接口:
private Lock scoreLock = new ReentrantLock(); public void newCharacter(CharacterEvent ce){
if(ce.source == generator){
try{
scoreLock.lock();
if(char2type != -1){
score--;
setScore();
}
char2type = ce.character;
}finally{
scoreLock.unlock();
}
}
else{
try{
scoreLock.lock();
if(char2type != ce.character){
score--;
}
else{
score++;
char2type = -1;
}
setScore();
}finally{
scoreLock.unlock();
}
}
Lock这个接口有两个方法:lock()和unlock(),我们可以在开始的时候调用lock(),然后在结束的时候调用unlock(),这样就能有效的同步化这个方法。
我们可以看到,其实使用Lock接口只是为了让Lock更加容易被管理:我们可以存储,传递,甚至是抛弃,其余和使用synchronized是一样的,但更加灵活:我们可以在有需要的时候才获取和释放锁,因为lock不再依附于任何调用方法的对象,我们甚至可以让两个对象共享同一个lock!也可以让一个对象占有多个lock!!
使用Lock接口,是一种明确的加锁机制,之前我们的加锁是我们无法掌握的,我们无法知道是哪个线程的哪个方法获得锁,但能确保同一时间只有一个线程的一个方法获得锁,现在我们可以明确得的把握这个过程,灵活的设置lock scope,将一些耗时和具有线程安全性的代码移出lock scope,这样我们就可以写出高效而且线程安全的程序代码,不用像之前一样,为了防止未知错误必须对所有相关方法进行同步。
使用lock接口,可以方便的利用它里面提供的一些便利的方法,像是tryLock(),它可以尝试取得锁,如果无法获取,我们就可以执行其他操作,而不是浪费时间在等待锁的释放。tryLock()还可以指定等待锁的时间。
synchronized不仅可以同步方法,它还可以同步一个程序块:
public void newCharacter(CharacterEvent ce){ if(ce.source == generator){ synchronized(this)[ if(char2type != -1){ score--; setScore(); } char2type = ce.character; } } else{ synchronized(this){ if(char2type != ce.character){ score--; } else{ score--; char2type = -1; } setScore(); } } }
如果是为了缩小lock的范围,我们依然还是可以使用synchronized而不是使用lock接口,而且这种方式才是更加常见的,因为使用lock接口时我们需要创建新的对象,需要异常管理。我们可以lock住其他对象,如被共享的数据对象。
选择synchronized整个方法还是代码块,都没有什么问题,但lock scope还是尽可能的越小越好。
考虑到newCharacter()这个方法里面出现了策略选择,我们可以对它进行重构:
private synchronized void newGeneratorCharacter(int c){ if(char2type != -1){ score--; setScore(); } char2type = c; } private synchronized void newTpistCharacter(int c){ if(char2type != c){ score--; } else{ score++; char2type = -1; } setScore(); } public synchronized void newCharacter(CharacterEvent ce){ if(ce.source == generator){ newGeneratorCharacter(ce.character); } else{ newTypistCharacter(ce.character); } }
我们会注意到,两种策略方法都要用synchronized锁住,但真的有必要吗?因为它们是private,只会在该对象中使用,没有理由要让这些方法获取锁,因为它们也只会被对象内的synchronized方法调用,而这时已经获得锁了。但是我们还是要这样做,考虑到以后的开发者可能不知道调用这些方法之前需要获取锁的情况。
由此可见,java的锁机制远比我们想象中要聪明:它并不是盲目的在进入synchronized程序代码块时就开始获取锁,如果当前的线程已经获得锁,根本就没有必要等到锁被释放还是去获取,只要让synchronized程序段运行就可以。如果没有获取锁,也就不会将它释放掉。这种机制之所以能够运行是因为系统会保持追踪递归取得lock的数目,最后会在第一个取得lock的方法或者代码块退出的时候释放锁。
这就是所谓的nested lock。
之前我们使用的ReentrantLock同样支持nested lock:如果lock的请求是由当前占有lock的线程发出,内部的nested lock就会要求计数递增,调用unlock()就会递减,直到计数为0就会释放该锁。但这个是ReentrantLock才具有的特性,其他实现了Lock这个接口的类并不具有。
nested lock是非常重要的,因为它有利于避免死锁的发生。死锁的发生远比我们想象中要更常见,像是方法间的相互调用,更加常见的情况就是回调,像是Swing编程中依赖事件处理程序与监听者的窗口系统,考虑一下监听者经常变动的情况,同步简直就是一个恶梦!!
Synchronized无法知道lock被递归调用的次数,但是使用ReentrantLock可以做到这点。我们可以通过getHoldCount()方法来获得当前线程对lock所要求的数量,如果数量为0,代表当前线程并未持有锁,但是还不能知道锁是自由的,我们必须通过isLocked()来判断。我们还可以通过isHeldByCurrentThread()来判断lock是否由当前的线程所持有,getQueueLength()可以用来取得有多少个线程在等待取得该锁,但这个只是预估值。
在多线程编程中经常讲到死锁,但是即使没有涉及到同步也有可能会产生死锁。死锁之所以是个问题,是因为它会让程序无法正确的执行,更加可怕的是,死锁是很难被检测的,特别是多线程编程往往都会是一个复杂的程序,它可能永远也不会被发现!!
更加悲哀的是,系统无法解决死锁这种情况!
最后一个问题是关于公平的授予锁。
我们知道,锁是要被授予线程的,但是应该按照什么依据来授予呢?是按照先到先得吗?还是服务请求最多?或者是对系统最有利的形式来授予?java的同步行为最接近第三种,因为同步并不是用来对特殊情况授予锁,它是通用的,所以没有理由让锁按照到达的顺序来授予,应该是由各实现所定义在底层线程系统的行为所决定,但ReentrantLock提供了一种选项可以按照先进先出的顺序获取锁:new ReentrantLock(true),这是为了防止发生锁饥饿的现象。
我们可以根据自己的具体实现来决定这种公平。
最后,我们来总结一下:
1.对于同时涉及到静态和非静态方法的同步情况,使用lock对象更加容易,因为lock对象无关于使用它的对象。
2.将整个方法同步化是最简单的,但是这样范围会变大,让确实没有必要的程序段无效率的持有锁。
3.如果涉及到太多的对象,使用同步块机制也是有问题的,同步块无法解决跨方法的锁范围。