饥饿与公平
如果一个线程因为CPU时间全部被其他线程抢走而得不到CPU运行时间,这种状态被称之为“饥饿”。线程被“饥饿致死”正是因为它得不到CPU运行时间的机会,解决饥饿的方案被称之为“公平性” – 即所有线程均能公平地获得运行机会。
本文在饥饿与公平 这篇文章的基础上,加入了自己的理解和对代码的分析。
Java中导致饥饿的原因
- 高优先级线程吞噬所有的低优先级线程的CPU时间。
- 线程被永久堵塞在一个等待进入同步块的状态,因为其他线程总是能在它之前持续地对该同步块进行访问。
- 线程在等待一个本身(在其上调用wait())也处于永久等待完成的对象,因为其他线程总是被持续地获得唤醒。
1.高优先级线程吞噬所有的低优先级线程的CPU时间
你能为每个线程设置独自的线程优先级,优先级越高的线程获得的CPU时间越多,线程优先级值设置在1到10之间,而这些优先级值所表示行为的准确解释则依赖于你的应用运行平台。对大多数应用来说,你最好是不要改变其优先级值。
2.线程被永久堵塞在一个等待进入同步块的状态
Java的同步代码区也是一个导致饥饿的因素。Java的同步代码区对哪个线程允许进入的次序没有任何保障。这就意味着理论上存在一个试图进入该同步区的线程处于被永久堵塞的风险,因为其他线程总是能持续地先于它获得访问,这即是“饥饿”问题,而一个线程被“饥饿致死”正是因为它得不到CPU运行时间的机会。
3.线程在等待一个本身(在其上调用wait())也处于永久等待完成的对象
如果多个线程处在wait()方法执行上,而对其调用notify()不会保证哪一个线程会获得唤醒,任何线程都有可能处于继续等待的状态。因此存在这样一个风险:一个等待线程从来得不到唤醒,因为其他等待线程总是能被获得唤醒。
Java中实现公平性
虽Java不可能实现100%的公平性,我们依然可以通过同步结构在线程间实现公平性的提高。
首先来学习一段简单的同步态代码:
1 2 3 4 5 |
public class Synchronizer { public synchronized void doSynchronized() { //进行耗时的工作 } } |
多个线程调用doSynchronized()方法,在第一个获得访问的线程未完成前,其他线程将一直处于阻塞状态,在多线程被阻塞的场景下,接下来将是哪个线程获得访问权是无法保证的。
使用锁方式替代同步块
1 2 3 4 5 6 7 8 9 10 |
public class Synchronizer { Lock lock = new Lock(); public void doSynchronized() throws InterruptedException { lock.lock(); //耗时的工作 lock.unlock(); } } |
此时doSynchronized()不再声明为synchronized,而是使用了Lock类的lock和unlock方法来代替。
下面是Lock类的实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
public class Lock { //是否被锁 private boolean isLocked = false; //当前抢到锁的线程 private Thread lockingThread = null; public synchronized void lock() throws InterruptedException { while (isLocked) { System.out.println(Thread.currentThread().getName() + "--处于等待状态"); this.wait(); } isLocked = true; lockingThread = Thread.currentThread(); System.out.println(lockingThread.getName() + "--抢到锁"); } public synchronized void unlock() { if (this.lockingThread != Thread.currentThread()) {//如果抢到锁的线程和当前线程不一致 throw new IllegalMonitorStateException("Calling thread has not locked this lock"); } System.out.println(this.lockingThread.getName() + "--即将唤醒等待的线程"); isLocked = false; lockingThread = null; this.notify(); } } |
我们先来测试一下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 |
public class LockTest { Lock lock = new Lock(); public void doSynchronized() throws InterruptedException { lock.lock(); Thread.sleep(3000); lock.unlock(); } public static void main(String[] args) { final LockTest lockTest1 = new LockTest(); Thread thread1 = new Thread(new Runnable() { public void run() { try { lockTest1.doSynchronized(); } catch (InterruptedException e) { e.printStackTrace(); } } }, "t1线程"); Thread thread2 = new Thread(new Runnable() { public void run() { try { lockTest1.doSynchronized(); } catch (InterruptedException e) { e.printStackTrace(); } } }, "t2线程"); thread1.start(); thread2.start(); } } |
测试结果为:
1 2 3 4 5 |
t1线程--抢到锁 t2线程--处于等待状态 t1线程--即将唤醒等待的线程 t2线程--抢到锁 t2线程--即将唤醒等待的线程 |
简述一下t1线程和t2线程的执行流程:t1率先进入lock方法,获的了Lock对象锁(后面均用锁代替)。由于lock方法被synchronized修饰,t2被阻塞在lock方法之外,t1进入lock方法后将isLocked置为true,然后t1跳出lock方法,并释放了锁,t2顺势获取到锁。t2进入lock后发现isLocked等于true,执行了wait方法,t2释放刚刚获取到的锁并处于等待状态(PS:如果没有其他线程唤醒将一直等待下去)。 t1执行完耗时的操作后,进入了unlock方法并获取到锁,并将isLocked置为false,然后执行notify方法唤醒等待的线程(PS:notify虽然唤醒了等待线程,但是当前线程并不会释放锁),当t1跳出unlock方法后,才将锁释放。然后被唤醒的t2此时又获取到了锁,继续往下执行…….
有人会质疑这么写会不会引起死锁?其实是不会的,因为lock和unlock方式的执行顺序是单线程下完成的,并不具备死锁的条件。
对比
同步代码块会将线程阻塞在试图进入lock方法的外部(阻塞态),而Lock锁则将时间用在了wait()的等待中(等待态)。
同步块不会对等待进入的多个线程谁能获得访问做任何保障,同样当调用notify()时,wait()也不会做保障一定能唤醒某个线程,因此这个版本的Lock类和同步块那个版本就公平性而言,没有任何区别。
公平锁
上一个版本的Lock锁对象都是相同的,如果每个线程在不同的对象上调用wait(),这样我们就可以决定哪个对象能对其调用notify(),因此可以做到有效的选择唤醒哪个线程(PS : 每个线程对应自己独有的对象锁)。我们可以对上面的Lock类进一步设计成FairLock类。
FairLock类主要思想:每一个调用lock()的线程都会进入一个队列,当解锁后,只有队列里的第一个线程被允许锁住FairLock实例,所有其他线程都将处于等待状态,知道他们处于队列的头部,遵循先到先执行的原则。
FairLock类代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 |
public class FairLock { private boolean isLock = false; private Thread lockingThread = null; // 等待的线程锁对象队列 private List<QueueObject> waitingThreads = new ArrayList<>(); public void lock() throws InterruptedException { QueueObject queueObject = new QueueObject(); queueObject.setName(Thread.currentThread().getName()); boolean isLockedForThisThread = true; synchronized (this) { System.out.println( Thread.currentThread().getName() + "的锁对象【" + queueObject.getName() + "】------加入队列"); waitingThreads.add(queueObject); } while (isLockedForThisThread) { synchronized (this) { isLockedForThisThread = isLock || waitingThreads.get(0) != queueObject; if (!isLockedForThisThread) { isLock = true; System.out.println(Thread.currentThread().getName() + ">>>>>获取到锁"); waitingThreads.remove(queueObject); //System.out.println( //"移除" + Thread.currentThread().getName() + "的锁对象【" + queueObject.getName() + "】"); lockingThread = Thread.currentThread(); return; } } try { System.out.println( Thread.currentThread().getName() + "携带锁对象【" + queueObject.getName() + "】******进入等待"); queueObject.dowait(); } catch (Exception e) { synchronized (this) { waitingThreads.remove(queueObject); } throw e; } } } public synchronized void unlock() { if (this.lockingThread != Thread.currentThread()) { throw new IllegalMonitorStateException("Calling thread has not locked this lock"); } isLock = false; lockingThread = null; if (waitingThreads.size() > 0) { System.out.println( Thread.currentThread().getName() + "将持有对象锁【" + waitingThreads.get(0).getName() + "】的线程被唤醒"); waitingThreads.get(0).doNotify(); } } } |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
public class QueueObject { private boolean isNotified = false; public String name; public String getName() { return name; } public void setName(String name) { this.name = name; } public synchronized void dowait() throws InterruptedException { while (!isNotified) { this.wait(); } this.isNotified = false; } public synchronized void doNotify() { this.isNotified = true; this.notify(); } public boolean equals(Object o) { return this == o; } } |
测试代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
public class FairLockTest { FairLock fairLock = new FairLock(); public void doSomeThing() throws InterruptedException { fairLock.lock(); Thread.sleep(3000); System.out.println(Thread.currentThread().getName() + "工作完成!!"); fairLock.unlock(); } public static void main(String[] args) { final FairLockTest fairLockTest = new FairLockTest(); for (int i = 0; i < 3; i++) { new Thread(new Runnable() { public void run() { try { fairLockTest.doSomeThing(); } catch (InterruptedException e) { e.printStackTrace(); } } }, "线程t" + (i + 1)).start(); } } } |
测试结果:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
线程t1的锁对象【t1】------加入队列 线程t1>>>>>获取到锁 线程t2的锁对象【t2】------加入队列 线程t2携带锁对象【t2】******进入等待 线程t3的锁对象【t3】------加入队列 线程t3携带锁对象【t3】******进入等待 线程t1工作完成!! 线程t1将持有对象锁【t2】的线程被唤醒 线程t2>>>>>获取到锁 线程t2工作完成!! 线程t2将持有对象锁【t3】的线程被唤醒 线程t3>>>>>获取到锁 线程t3工作完成!! |
我来简述一下线程的执行流程:
- t1,t2,t3进入lock方法,创建了一个属于自己的QueueObject锁对象,然后将其加入到队列中(打印结果看,t1先进入队列,t2其次,t3最后进入)。
- t1进入while循环中的同步代码块,并将isLock置为true,并将自己的锁对象从队列中移除,然后return跳出lock方法并释放同步代码块中的锁。然后t2,t3竞争该锁,t2和t3无论谁先进入同步代码块,都会调用dowait()方法进入等待(因为isLock等于true),并释放对象锁。如果线程出错则从队列移除并抛出异常。
- t1工作完成,调用unlock方法,将isLock置为false(此时的isLockedForThisThread等于true),将处于队列头的对象锁取出并调用doNotify方法,唤醒可以拥有此对象锁的线程即t2。t2继续在while中运行,队列移除t2的锁对象,然后t2工作完成之后,调用unLock方法,唤醒t3……..
- 可以看出先进队列的线程,先被唤醒,优先执行,一定程度上防止了某些线程无法被唤醒而处于饥饿状态的情况。(比如:开始线程为3个,执行过程中又进来2个,即使后面两个竞争到CPU的几率比较大,也得在队列中排队,防止前面3个线程中某个线程处于饥饿状态)
性能考虑
不公平锁意味着后请求锁的线程可能在其前面排列的休眠线程恢复前拿到锁,这样就有可能提高并发的性能。这是因为通常情况下挂起的线程重新开始与它真正开始运行,二者之间会产生严重的延时。因此非公平锁就可以利用这段时间完成操作。这是非公平锁在某些时候比公平锁性能要好的原因之一。