JAVA高并发程序设计|带你深度刨析多线程的同步控制

多线程的团队协作:同步控制 同步控制是并发程序必不可少的重要手段。之前介绍的关键字synchronized就是一种最简单的控制方法,它决定了一个线程是否可以访问临界区资源。同时,Object.wait()方法和Object.notify)方法起到了线程等待和通知的作用。这些工具对于实现复杂的多线程协作起到了重要的作用。下面我们首先将介绍关键字synchronized、Object.wait()方法和Object.notify()方法的替代品(或者说是增强版)——重入锁。
一、关键字synchronized的功能扩展:重入锁

  1. 重入锁可以完全替代关键字synchronized。在JDK
    5.0的早期版本中,重入锁的性能远远优于关键字synchronized,但从JDK 6.0开始,JDK在关键字synchronized上做了大量的优化,使得两者的性能差距并不大。
  2. 重入锁使用java.util.concurrent.locks.ReentrantLock类来实现。下面是一段最简单的重入锁使用案例。
public class ReenterLock implements Runnable{ public static ReentrantLock lock=new ReentrantLock(); public static int i=0; @override public void run(){ for(int j=0; j<10000000; j++){ lock.lock(); try{ i++; }finally{ lock.unlock(); } } } public static void main (String[] args) throws InterruptedException { ReenterLock tl=new ReenterLock(); Thread t1=new Thread (tl); Thread t2=new Thread(tl); t1.start(); t2.start(); t1.join(); t2.join(); System.out.println(i); }}

  1. 上述代码第7~12行使用重入锁保护临界区资源i,确保多线程对i操作的安全性。从这段代码可以看到,与关键字synchronized相比,重入锁有着显示的操作过程。开发人员必须手动指定何时加锁,何时释放锁。也正因为这样,重入锁对逻辑控制的灵活性要远远优于关键字synchronized。但值得注意的是,在退出临界区时,必须记得释放锁(代码第11行),否则,其他线程就没有机会再访问临界区了。
  2. 你可能会对重入锁的名字感到奇怪。锁为什么要加上“重入”两个字呢?从类的命名上看,Re-
    Entrant-Lock翻译成重入锁非常贴切。之所以这么叫,是因为这种锁是可以反复进入的。当然,这里的反复仅仅局限于一个线程。上述代码的第7~12行,可以写成下面的形式:
lock.lock(); lock.lock(); try{ i++; }finally{ lock. unlock(); lock.unlock(); }

  1. 在这种情况下,一个线程连续两次获得同一把锁是允许的。如果不允许这么操作,那么同一个线程在第2次获得锁时,将会和自己产生死锁。程序就会“卡死”在第2次申请锁的过程中。但需要注意的是,如果同一个线程多次获得锁,那么在释放锁的时候,也必须释放相同次数。如果释放锁的次数多了,那么会得到一个java.lang.IllegalMonitorStateException异常,反之,如果释放锁的次数少了,那么相当于线程还持有这个锁,因此,其他线程也无法进入临界区。
  2. 除使用上的灵活性以外,重入锁还提供了一些高级功能。比如,重入锁可以提供中断处理的能力。
一、中断响应
  1. 对于关键字synchronized来说,如果一个线程在等待锁,那么结果只有两种情况,要么它获得这把锁继续执行,要么它就保持等待。而使用重入锁,则提供另外一种可能,那就是线程可以被中断。也就是在等待锁的过程中,程序可以根据需要取消对锁的请求。有些时候,这么做是非常有必要的。比如,你和朋友约好一起去打球,如果你等了半个小时朋友还没有到,你突然接到一个电话,说由于突发情况,朋友不能如约前来了,那么你一定扫兴地打道回府了。中断正是提供了一套类似的机制。如果一个线程正在等待锁,那么它依然可以收到一个通知,被告知无须等待,可以停止工作了。这种情况对于处理死锁是有一定帮助的。
  2. 下面的代码产生了一个死锁,但得益于锁中断,我们可以很轻易地解决这个死锁。
public class IntLock implements Runnable{ public static ReentrantLock lockl = new ReentrantLock(); public static ReentrantLock lock2 = new ReentrantLock(); int lock; /** ★控制加锁顺序,方便构造死锁*param lock */ public IntLock(int lock){ this.lock = lock; } @override public void run(){ try { if (lock == 1){ lock1. lockInterruptibly(); try{ Thread.sleep(500); }catch (InterruptedException e){1lock2. lockInterruptiblyO); }else{ lock2 . lockInterruptibly(); try{ Thread.sleep (500); }catch (InterruptedException e){}lock1. lockInterruptiblyO); ] catch (InterruptedException e){ e.printStackTrace(; ]finally{ if(lock1.isHeldByCurrentThread ()) lockl.unlock (); if (lock2.isHeldByCurrentThread() lock2.unlock(; System.out.println (Thread.currentThread ().getId()+":线程退出"); public static void main(String[] args) throws InterruptedException{ IntLock r1 = new IntLock (1); IntLock r2= new IntLock(2); Thread tl = new Thread (r1) ; Thread t2 = new Thread(r2); t1.start(); t2.start(); Thread.sleep(1000); //中断其中一个线程 t2.interrupt (); }}

  1. 线程t1和
    t2启动后,t1先占用lock1,再占用lock2; t2先占用lock2,再请求lock1.因此,很容易形成t1和t2之间的相互等待。在这里,对锁的请求,统一使用lockInterruptibly(方法。这是一个可以对中断进行响应的锁申请动作,即在等待锁的过程中,可以响应中断。
  2. 在代码第47行,主线程main
    处于休眠状态,此时,这两个线程处于死锁的状态。在代码第49行,由于t2线程被中断,故t2会放弃对lock1的申请,同时释放已获得的lock2。这个操作导致t1线程可以顺利得到lock2而继续执行下去。
  3. 执行上述代码,将输出:
java.lang. InterruptedException at java.util.concurrent. locks.AbstractQueuedSynchronizer.doAcquireInterruptibly (AbstractQueuedSynchronizer.java:898) at java.util.concurrent. locks. AbstractQueuedSynchronizer.acquireInterruptibly(AbstractQueuedSynchronizer.java:1222) at java.util.concurrent. locks.ReentrantLock.lockInterruptibly(ReentrantLock.java:335) at geym.conc.ch3.synctrl.IntLock. run (IntLock.java: 31)at java.lang. Thread.run(Thread.java:745) 9:线程退出 8:线程退出

  1. 可以看到,中断后两个线程双双退出,但真正完成工作的只有tl,而t2线程则放弃其任务直接退出,释放资源。
二、锁申请等待限时
  1. 除了等待外部通知之外,要避免死锁还有另外一种方法,那就是限时等待。依然以约朋友打球为例,如果朋友迟迟不来,又无法联系到他,那么在等待1到2个小时后,我想大部分人都会扫兴离去。对线程来说也是这样。通常,我们无法判断为什么一个线程迟迟拿不到锁。也许是因为死锁了,也许是因为产生了饥饿。如果给定一个等待时间,让线程自动放弃,那么对系统来说是有意义的。我们可以使用tryLock()方法进行一次限时的等待。
  2. 下面这段代码展示了限时等待锁的使用。
public class TimeLock implements Runnable{ public static ReentrantLock lock=new ReentrantLock(); @Override public void run(){ try{ if(lock.tryLock (5,TimeUnit.SECONDS)) Thread.sleep(6000); }else{ System.out.println ("get lock failed"); }catch (InterruptedException e){ e.printstackTrace(); }finally{if (lock.isHeldByCurrentThread0) lock.unlock(); } public static void main(String[] args){ TimeLock tl=new TimeLock(); Thread tl=new Thread(tl); Thread t2=new Thread(tl); t1.start(); t2.start(); }}

  1. 在这里,tryLock()方法接收两个参数,一个表示等待时长,另外一个表示计时单位。这里的单位设置为秒,时长为5,表示线程在这个锁请求中最多等待5秒。如果超过5秒还没有得到锁,就会返回false。如果成功获得锁,则返回true。
  2. 在本例中,由于占用锁的线程会持有锁长达6秒,故另一个线程无法在5秒的等待时间内获得锁,因此请求锁会失败。
  3. ReentrantLock.tryLock()方法也可以不带参数直接运行。在这种情况下,当前线程会尝试获得锁,如果锁并未被其他线程占用,则申请锁会成功,并立即返回true。如果锁被其他线程占用,则当前线程不会进行等待,而是立即返回false。这种模式不会引起线程等待,因此也不会产生死锁。下面演示了这种使用方式:
public class TryLock implements Runnable{ public static ReentrantLock lock1 - new ReentrantLock (; public static ReentrantLock lock2 = new ReentrantLock(; int lock; public TeyLock (int lock){ this.lock = lock; @override public void run ( { if(1ock -- 1){ while (true) [ if(lockl.tryLock0{ tryf try { Thread.sleep (500); }catch (InterruptedException e)[) if (lock2.tryLock()){ try{ System.out.println/Thread.currentThread() .getId( +":Ny Job done"]; return; }finally{ lock2.unlock() ; }}} finally { lock1.unlock(); } } else{ while (true) { if(lock2.tryLock()){ try { try { Thread.sleep(500); ] catch (InterruptedException e){ if( lock1.tryLock(){ try { System.out.println (Thread.currentThread() . getId()+":My Job done"); return; } finally { lock1.unlock(); } ]finally-{ lock2.unlock(); } } } } }public static void main(String[] args) throws InterruptedException { TryLock r1 = new TryLock(1); TryLock r2= new Trylock(2); Thread tl = new Thread(r1); Thread t2 = new Thread(r2); t1.start(); t2.start(); } }

  1. 上述代码采用了非常容易死锁的加锁顺序。也就是先让t1获得lock1,再让t2获得lock2,接着做反向请求,让t1申请lock2,t2申请lock1。在一般情况下,这会导致tl和t2相互等待,从而引起死锁。
  2. 但是使用tryLock()方法后,这种情况就大大改善了。由于线程不会傻傻地等待,而是不停地尝试,因此,只要执行足够长的时间,线程总是会得到所有需要的资源,从而正常执行(这里以线程同时获得lock1和 lock2两把锁,作为其可以正常执行的条件)。在同时获得 lock1和 lock2后,线程就打印出标志着任务完成的信息“My Jobdone”。
  3. 执行上述代码,等待一会儿(由于线程中包含休眠500
    毫秒的代码)。最终你还是可以欣喜地看到程序执行完毕,并产生如下输出,表示两个线程双双正常执行。
JAVA高并发程序设计|带你深度刨析多线程的同步控制
文章图片

三、公平锁
  • 在大多数情况下,锁的申请都是非公平的。也就是说,线程1首先请求了锁A,接着线程⒉也请求了锁A。那么当锁A可用时,是线程1可以获得锁还是线程2可以获得锁呢?这是不一定的,系统只是会从这个锁的等待队列中随机挑选一个。因此不能保证其公平性。这就好比买票不排队,大家都围在售票窗口前,售票员忙得焦头烂额,也顾不及谁先谁后,随便找个人出票就完事了。而公平的锁,则不是这样,它会按照时间的先后顺序,保证先到者先得,后到者后得。公平锁的一大特点是:它不会产生饥饿现象。只要你排队,最终还是可以等到资源的。如果我们使用synchronized关键字进行锁控制,那么产生的锁就是非公平的。而重入锁允许我们对其公平性进行设置。它的构造函数如下:
JAVA高并发程序设计|带你深度刨析多线程的同步控制
文章图片

  • 当参数fair为
    true时,表示锁是公平的。公平锁看起来很优美,但是要实现公平锁必然要求系统维护一个有序队列,因此公平锁的实现成本比较高,性能却非常低下,因此,在默认情况下,锁是非公平的。如果没有特别的需求,则不需要使用公平锁。公平锁和非公平锁在线程调度表现上也是非常不一样的。下面的代码可以很好地突出公平锁的特点:
    在这里插入图片描述
    JAVA高并发程序设计|带你深度刨析多线程的同步控制
    文章图片

    JAVA高并发程序设计|带你深度刨析多线程的同步控制
    文章图片

    上述代码第﹖行指定锁是公平的。接着,由t1和t2两个线程分别请求这把锁,并且在得到锁后,进行一个控制台的输出,表示自己得到了锁。在公平锁的情况下,得到的输出通常如下所示:
    JAVA高并发程序设计|带你深度刨析多线程的同步控制
    文章图片

    由于代码会产生大量输出,这里只截取部分进行说明。在这个输出中,很明显可以看到,两个线程基本上是交替获得锁的,几乎不会发生同一个线程连续多次获得锁的可能,从而保证了公平性。如果不使用公平锁,那么情况会完全不一样,下面是使用非公平锁时的部分输出:
    JAVA高并发程序设计|带你深度刨析多线程的同步控制
    文章图片

    JAVA高并发程序设计|带你深度刨析多线程的同步控制
    文章图片

    可以看到,根据系统的调度,一个线程会倾向于再次获取已经持有的锁,这种分配方式是高效的,但是无公平性可言。对上面ReentrantLock 的几个重要方法整理如下。
  • lock():获得锁,如果锁已经被占用,则等待。
  • lockInterruptibly():获得锁,但优先响应中断。
  • tryLock():尝试获得锁,如果成功,则返回true,失败返回false。该方法不等待,立即返回。
  • tryLock(long time, TimeUnit unit):在给定时间内尝试获得锁。unlock():释放锁。
就重入锁的实现来看,它主要集中在Java层面。在重入锁的实现中,主要包含三个要素。
  1. 第一,原子状态。原子状态使用CAS操作(在第4章进行详细讨论)来存储当前锁的状态,判断锁是否已经被别的线程持有了。
  2. 第二,等待队列。所有没有请求到锁的线程,会进入等待队列进行等待。待有线程释放锁后,系统就能从等待队列中唤醒一个线程,继续工作。
  3. 第三,阻塞原语park()和 unpark(),用来挂起和恢复线程。没有得到锁的线程将会被挂起。有关park)和unpark()的详细介绍,可以参考第3.1.7节线程阻塞工具类:LockSupport。
【JAVA高并发程序设计|带你深度刨析多线程的同步控制】摘自JAVA高并发程序设计,推荐推荐

    推荐阅读