Java并发——避免活跃性危险

本篇博文是Java并发编程实战的笔记。

死锁#

死锁是由于多个线程相互持有对方想要获得的锁而造成的。

锁顺序死锁#

这是产生死锁的一种最简单的场景,想象简化版的哲学家进餐问题,桌上有一根筷子,有两个哲学家,它们一次只能拿起一根筷子(左边的或者右边的),这时两个哲学家都想拿到这双筷子,哲学家A先拿起了左边的,哲学家B先拿起了右边的,哲学家A也想要拿右边的,他要等待哲学家B放下右面的筷子,而哲学家B想要拿起左边的就必须等待哲学家A放下左边的筷子,这时它们只能陷入无尽的等待。

这种死锁产生的原因就是哲学家拿筷子的顺序不固定,如果规定他们都必须先拿左边的筷子就不会出现问题了。

public class LeftRightDeadLock {
    private final Object left = new Object();
    private final Object right = new Object();

    public void leftRight() {
        synchronized (left) {
            synchronized (right) {
                System.out.println("LEFTRIGHT");
            }
        }
    }

    public void rightLeft() {
        synchronized (right) {
            synchronized (left) {
                System.out.println("RIGHTLEFT");
            }
        }
    }
}

一个可能(大概率)产生死锁的调用案例:

public static void main(String[] args) {
    LeftRightDeadLock leftRightDeadLock = new LeftRightDeadLock();
    for (int i=0; i<1000; i++) {
        Thread t1 = new Thread(() -> leftRightDeadLock.leftRight());
        Thread t2 = new Thread(() -> leftRightDeadLock.rightLeft());
        t1.start();
        t2.start();
    }
}

动态的锁顺序死锁#

如下代码还是基于这个简单的哲学家问题,筷子由外部传入,看起来我们总是先对c1加锁,后对c2加锁,顺序没问题。可是别忘了,筷子是外部传入的,你没法保证调用者以什么顺序传入这两根筷子

public class Philosopher {
    
    public void eat(ChopStick c1, ChopStick c2) {
        synchronized (c1) {
            synchronized (c2) {
                System.out.println("EAT");
            }
        }
    }
    
}

一个可能(大概率)引发死锁的用例:

public class PhilosopherAndChopStickTest {
    private final ChopStick left = new ChopStick();
    private final ChopStick right = new ChopStick();
    private final Philosopher philosopher1 = new Philosopher();
    private final Philosopher philosopher2 = new Philosopher();

    public void testDeadLock() {
        for (int i=0; i<1000; i++) {
            Thread t1 = new Thread(() -> philosopher1.eat(left, right));
            Thread t2 = new Thread(() -> philosopher2.eat(right, left));
            t1.start();
            t2.start();
        }
    }

    public static void main(String[] args) {
        new PhilosopherAndChopStickTest().testDeadLock();
    }

}

对于上面这种,可以考虑在eat方法中通过计算两个筷子对象的hash,然后根据hash的大小来调整加锁顺序,如果碰巧遇到极低情况下的两个对象hash相同,使用一个tielock保证最多同时只有一个线程对这两个筷子对象以随机顺序加锁。

private final Object tieLock = new Object();

private void doEat(ChopStick c1, ChopStick c2) {
    System.out.println("EAT");
}

public void eat(ChopStick c1, ChopStick c2) {
    int hashC1 = System.identityHashCode(c1);
    int hashC2 = System.identityHashCode(c2);
    
    if (hashC1 > hashC2) {
        synchronized (c1) {
            synchronized (c2) {
                doEat(c1, c2);
            }
        }
    } else if (hashC1 < hashC2) {
        synchronized (c2) {
            synchronized (c1) {
                doEat(c1, c2);
            }
        }
    } else {
        synchronized (tieLock) {
            synchronized (c1) {
                synchronized (c2) {
                    doEat(c1, c2);
                }
            }
        }
    }
    
}

我们可以将这两个筷子对象的hash看作它们跑步比赛的成绩,这样c1和c2可以被按照成绩指定一个固定的加锁顺序,有固定顺序就不会产生死锁问题。如果碰巧它们的成绩相等,则没办法给一个固定加锁顺序,这时tielock则可以被看作一个“加时赛”,谁先跑到tielock处谁先加上两个锁,另外一个只能等两个锁释放,这样也不会产生死锁问题。

上面的代码这么丑,难道不可以写成这样的代码吗??? :

private final Object tieLock = new Object();

private void doEat(ChopStick c1, ChopStick c2) {
    synchronized (c1) {
        synchronized (c2) {
            System.out.println("EAT");
        }
    }
}

public void eat(ChopStick c1, ChopStick c2) {
    int hashC1 = System.identityHashCode(c1);
    int hashC2 = System.identityHashCode(c2);

    if (hashC1 > hashC2) {
        doEat(c1, c2);
    } else if (hashC1 < hashC2) {
        doEat(c2, c1);
    } else {
        synchronized (tieLock) {
            doEat(c1, c2);
        }
    }
}

我个人感觉这个代码没什么潜在的死锁问题,同时我又觉得大佬没这样写是有原因的,有发现问题的欢迎评论指正。

在协作对象之间发生的死锁#

下面两个组件,单独看起来都没有任何问题,关键是,具有Taxi对象的锁的setLocation方法调用了需要获得Dispatcher对象的锁的notifyAvailable方法,而具有Dispatcher对象的锁的getImage方法调用了需要获得Taxi对象的锁的getLocation方法,当它们都死死的持有自己的锁并等待对方释放锁时,死锁产生了。

class Taxi {
    @GuardedBy("this") private Point location, destination;
    private final Dispatcher dispatcher;
    public Taxi(Dispatcher dispatcher) {
        this.dispatcher = dispatcher;
    }

    public synchronized Point getLocation() {
        return location;
    }

    public synchronized void setLocation(Point location) {
        this.location = location;
        if (location.equals(destination))
            dispatcher.notifyAvailable(this);
    }
}

class Dispatcher {
    @GuardedBy("this") private final Set<Taxi> taxis;
    @GuardedBy("this") private final Set<Taxi> avaliableTaxis;

    public Dispatcher() {
        this.taxis = new HashSet<>();
        this.avaliableTaxis = new HashSet<>();
    }

    public synchronized void notifyAvailable(Taxi taxi) {
        avaliableTaxis.add(taxi);
    }

    public synchronized Image getImage() {
        Image image = new Image();
        for (Taxi t : taxis)
            image.drawMarker(t.getLocation());
        return image;
    }

}

当在某个持有锁的方法中调用外部方法时需要小心,注意调用到外部方法是否会获得其它锁,如果有,观察它需要获得的锁会不会引起程序的活跃性问题。

开放调用#

TaxisetLocation方法不知道它调用notifyAvaliable时会获得Dispatcher对象的锁,同样,DispathcergetImage也不知道getLocation方法会获得Taxi的锁,这样将导致我们在持有锁时调用外部方法时很难分析程序的正确性,程序会不会产生死锁?

如果不在持有锁的时候调用外部方法,那么这种调用外部方法的方式被称为开放调用(Open Call)。我们基于开放调用修改上面TaxiDispatcher中的代码:

public void setLocation(Point location) {
    // 记录结果
    boolean reachedLocation = false;
    synchronized (this) {
        this.location = location;
        reachedLocation = this.location.equals(destination);
    }
    // 判断并通知
    if (reachedLocation)
        dispatcher.notifyAvailable(this);
}
public Image getImage() {
    // 保存快照
    Set<Taxi> copy;
    synchronized (this) {
        copy = new HashSet<>(this.taxis);
    }

    Image image = new Image();
    for (Taxi t : copy)
        image.drawMarker(t.getLocation());
    return image;
}

尽量在允许的位置使用开放调用使得程序中获取锁的深度变小,从而更容易构建线程安全的程序,在分析活跃性问题时也变得更加简单。

要注意的是,使用开放调用改写程序后,程序中某些部分的原子性会消失,只有当这种原子性消失是可接受的情况下才能使用开放调用。

譬如上面的代码在未使用

资源死锁#

  1. 有两个数据库连接池X和Y,线程A获得了X中的最后一个连接,线程B获得了Y中的最后一个连接,线程A想要获得Y中的连接,线程B想要获得X中的连接
  2. 在Executor框架中,一个运行在单线程线程池中的任务向该线程池中提交了另一个任务,并等待它完成(也可以扩展到N个线程的线程池中)

死锁避免与恢复#

支持定时的锁#

不使用synchronized,转而使用java并发包中提供的其它锁,这些锁都可以设置一个超时时限,当超时时自动放弃阻塞等待,转而,你可以去做些别的事,如放弃获得锁或者稍后重新尝试。

一个思路是如果你的系统中没有使用定时锁,你也可以将多个非定时锁的访问全都包装起来,使用定时锁来获得多个锁这种方式来避免死锁:

lock.tryLock(10, TimeUnit.second);
synchronized(lock1) {
  synchronized(lock2) {
    // ...
  }
}
lock.release();

通过线程转储信息来分析死锁#

Java提供线程转储功能,该功能提供正在运行的各个线程的堆栈信息,你可以使用jps+jstack来获取线程转储信息,也可以直接使用IDE自带的工具,如Idea的这个功能可以直接识别出哪两个线程产生了死锁,并方便的找到产生死锁的行。

其它活跃性危险#

饥饿#

饥饿指线程迟迟无法获得它运行所需的资源,最常见的资源就是CPU的时钟周期。

如果在Java程序中对线程优先级的使用不当或者在线程中使用一些无法结束的结构,就可能会产生饥饿。

在Java中应该尽量不要调节线程的优先级、尽量少使用Thread.yieldThread.sleep(0)来尝试调节线程的优先级。一旦使用了这些手段,你的程序很可能就和平台相关了,因为这些手段都与具体的平台有关。如非必要请使用默认的线程优先级。

糟糕的响应性#

如在GUI程序的UI线程中运行长时间任务。

如果你在GUI程序的后台线程中运行大量CPU密集的任务,也可能会造成UI线程响应性降低,因为后台线程在与UI线程竞争CPU资源。

活锁#

即多个线程因为尝试执行某些操作而阻塞,等待一段时间后它们放弃,然后又重新尝试,又重新阻塞...

可以在重试步骤中引入一些随机性,如等待随机时间来避免活锁,就像网络中的CSMA/CD技术一样。

posted @   yudoge  阅读(53)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 震惊!C++程序真的从main开始吗?99%的程序员都答错了
· 【硬核科普】Trae如何「偷看」你的代码?零基础破解AI编程运行原理
· 单元测试从入门到精通
· winform 绘制太阳,地球,月球 运作规律
· 上周热点回顾(3.3-3.9)
点击右上角即可分享
微信分享提示
主题色彩