您的位置:首页 > 编程语言

并发编程之一:同步方法和同步块

2016-02-29 11:26 190 查看
在之前例子的基础上,我们增加新的功能:根据正确与不正确的响应来显示玩家的分数。

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.如果涉及到太多的对象,使用同步块机制也是有问题的,同步块无法解决跨方法的锁范围。

同步静态方法和同步非静态方法的异同

当一个synchronized关键字修饰的方法同时又被static修饰,之前说过,非静态的同步方法会将对象上锁,但是静态方法不属于对象,而是属于类,它会将这个方法所在的类的Class对象上锁一个类不管生成多少个对象,它们所对应的是同一个Class对象。

所以如果是静态方法的情况(execute()和execute2()都加上static关键字),即便是向两个线程传入不同的Example对象,这两个线程仍然是互相制约的,必须先执行完一个,再执行下一个。

  结论:

  如果某个synchronized方法是static的,那么当线程访问该方法时,它锁的并不是synchronized方法所在的对象,而是synchronized方法所在的类所对应的Class对象。Java中,无论一个类有多少个对象,这些对象会对应唯一一个Class对象,因此当线程分别访问同一个类的两个对象的两个static,synchronized方法时,它们的执行顺序也是顺序的,也就是说一个线程先去执行方法,执行完毕后另一个线程才开始。

synchronized关键字有两种用法,一种是只用于方法的定义中,另外一种是synchronized块,我们不仅可以使用synchronized来同步一个对象变量,你也可以通synchronizedl来同步类中的静态方法和非静态方法。

synchronized块的语法如下:

public void method()
{
synchronized(表达式)
{
}
}


第一种:非静态方法的同步

从java相关语法可以知道使用synchronized关键字来定义方法就会锁定类中所用使用synchroniezd关键字定义的静态方法和非静态方法,但是这有点不好理解,如果要synchronized块,来达到这样的效果,就不难理解为什么会产生这种效果了,如果使用synchronized来锁定类中所有的同步非静态方法,只需要使用this作为synchronized块的参数传入synchronized块中,代码如下:

通过synchronized块来同步非静态方法

在上面的代码中的method1使用了synchronized块,method2方法是用了synchronized关键字来定义方法,如果使用同一个Test实例时,这两个方法只要有一个在执行,其他的方法都会因未获得同步锁而被堵塞。除了使用this作为synchronized块的参数,也可以使用Test.this作为synchronized块的参数来达到同样的效果。

public class Test
{
public void method1()
{
synchronized(this)
{

}
}

public synchronized void method2()
{

}
}


在内类中使用synchronized块中,this只表示内类,和外类(OuterClass)没有关系。但是内类中的非静态方法和外类的非静态方法也可以同步。如果在内类中加个方法method3也可以使和Test里面的2个方法同步,代码如下:

public class Test
{
class InnerClass
{
public void method3()
{
synchronized(Test.this){
}
}
}
}


上面InnerClass的method3方法与Test的method1和method2方法在同一时间内只能有一个方法执行。

synchronized块不管是正确执行完,还是因为程序出错因异常退出synchronized块,当前的synchronized块所持有的同步锁都会自动释放,因此在使用synchronized块不必担心同步锁的问题。

二、静态方法的同步

由于在调用静态方法时,对象实例不一定被创建,因此,就不能使用this来同步静态方法,而必须使用Class对象来同步静态方法。代码如下:

public class Test{

pubic static void method1(){
synchronized(Test.class){
}
}
public static synchronized void method2(){

}
}


在同步静态方法时可以使用类的静态字段class来得到class对象,在上例中method1和method2方法只有一个方法执行,除了使用class字段可以得到class对象,还可以通过实例的getClass()方法获取class对象,代码如下:

public class Test{
public static Test test;
public Test(){
test=this;
}
public static void method1(){
synchronized(test.getClass()){
}
}
}


在上面的代码中,我们通过一个public的静态对象得到Test的一个实例,并通过这个实例的getClass方法获取一个class对象(注意一个类的所有实例通过getClass方法得到的都是同一个Class对象)。我们也可以通过class使不同类的静态方法同步,代码如下:

Test类中的方法和Test1类中方法同步。

public class Test1{
public static void method1(){
synchronized(Test.class){
}
}
}


注意:在使用synchronized块来同步方法时,非静态方法可以通过this来同步,而静态方法必须使用class对象来同步,但是非静态方法也可以通过使用class来同步静态方法。但是静态方法中不能使用this来同步非静态方法。这点在使用synchronized块需要注意。

==================================================

java同步方法和同步代码块的理解

打个比方:一个object就像一个大房子,大门永远打开。房子里有很多房间(也就是方法)。这些房间有上锁的(synchronized方法),和不上锁之分(普通方法)。房门口放着一把钥匙(key),这把钥匙可以打开所有上锁的房间。另外我把所有想调用该对象方法的线程比喻成想进入这房子某个房间的人。所有的东西就这么多了,下面我们看看这些东西之间如何作用的。

在此我们先来明确一下我们的前提条件。该对象至少有一个synchronized方法,否则这个key还有啥意义。当然也就不会有我们的这个主题了。

一个人想进入某间上了锁的房间,他来到房子门口,看见钥匙在那儿(说明暂时还没有其他人要使用上锁的房间)。于是他走上去拿到了钥匙,并且按照自己的计划使用那些房间。注意一点,他每次使用完一次上锁的房间后会马上把钥匙还回去。即使他要连续使用两间上锁的房间,中间他也要把钥匙还回去,再取回来。

因此,普通情况下钥匙的使用原则是:“随用随借,用完即还。”

这时其他人可以不受限制的使用那些不上锁的房间,一个人用一间可以,两个人用一间也可以,没限制。但是如果当某个人想要进入上锁的房间,他就要跑到大门口去看看了。有钥匙当然拿了就走,没有的话,就只能等了。

要是很多人在等这把钥匙,等钥匙还回来以后,谁会优先得到钥匙?Not guaranteed。象前面例子里那个想连续使用两个上锁房间的家伙,他中间还钥匙的时候如果还有其他人在等钥匙,那么没有任何保证这家伙能再次拿到。(JAVA规范在很多地方都明确说明不保证,象Thread.sleep()休息后多久会返回运行,相同优先权的线程那个首先被执行,当要访问对象的锁被释放后处于等待池的多个线程哪个会优先得到,等等。我想最终的决定权是在JVM,之所以不保证,就是因为JVM在做出上述决定的时候,绝不是简简单单根据一个条件来做出判断,而是根据很多条。而由于判断条件太多,如果说出来可能会影响JAVA的推广,也可能是因为知识产权保护的原因吧。SUN给了个不保证就混过去了。无可厚非。但我相信这些不确定,并非完全不确定。因为计算机这东西本身就是按指令运行的。即使看起来很随机的现象,其实都是有规律可寻。学过计算机的都知道,计算机里随机数的学名是伪随机数,是人运用一定的方法写出来的,看上去随机罢了。另外,或许是因为要想弄的确定太费事,也没多大意义,所以不确定就不确定了吧。)

再来看看同步代码块。和同步方法有小小的不同。

1.从尺寸上讲,同步代码块比同步方法小。你可以把同步代码块看成是没上锁房间里的一块用带锁的屏风隔开的空间。

2.同步代码块还可以人为的指定获得某个其它对象的key。就像是指定用哪一把钥匙才能开这个屏风的锁,你可以用本房的钥匙;你也可以指定用另一个房子的钥匙才能开,这样的话,你要跑到另一栋房子那儿把那个钥匙拿来,并用那个房子的钥匙来打开这个房子的带锁的屏风。

记住你获得的那另一栋房子的钥匙,并不影响其他人进入那栋房子没有锁的房间。

为什么要使用同步代码块呢?我想应该是这样的:首先对程序来讲同步的部分很影响运行效率,而一个方法通常是先创建一些局部变量,再对这些变量做一些操作,如运算,显示等等;而同步所覆盖的代码越多,对效率的影响就越严重。因此我们通常尽量缩小其影响范围。如何做?同步代码块。我们只把一个方法中该同步的地方同步,比如运算。

另外,同步代码块可以指定钥匙这一特点有个额外的好处,是可以在一定时期内霸占某个对象的key。还记得前面说过普通情况下钥匙的使用原则吗。现在不是普通情况了。你所取得的那把钥匙不是永远不还,而是在退出同步代码块时才还。

还用前面那个想连续用两个上锁房间的家伙打比方。怎样才能在用完一间以后,继续使用另一间呢。用同步代码块吧。先创建另外一个线程,做一个同步代码块,把那个代码块的锁指向这个房子的钥匙。然后启动那个线程。只要你能在进入那个代码块时抓到这房子的钥匙,你就可以一直保留到退出那个代码块。也就是说你甚至可以对本房内所有上锁的房间遍历,甚至再sleep(10*60*1000),而房门口却还有1000个线程在等这把钥匙呢。很过瘾吧。

在此对sleep()方法和钥匙的关联性讲一下。一个线程在拿到key后,且没有完成同步的内容时,如果被强制sleep()了,那key还一直在它那儿。直到它再次运行,做完所有同步内容,才会归还key。记住,那家伙只是干活干累了,去休息一下,他并没干完他要干的事。为了避免别人进入那个房间把里面搞的一团糟,即使在睡觉的时候他也要把那唯一的钥匙戴在身上。

最后,也许有人会问,为什么要一把钥匙通开,而不是一个钥匙一个门呢?我想这纯粹是因为复杂性问题。一个钥匙一个门当然更安全,但是会牵扯好多问题。钥匙的产生,保管,获得,归还等等。其复杂性有可能随同步方法的增加呈几何级数增加,严重影响效率。

这也算是一个权衡的问题吧。为了增加一点点安全性,导致效率大大降低,是多么不可取啊。
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: