带你深度刨析多线程的同步控制

举报
桃花键神 发表于 2022/03/21 22:16:45 2022/03/21
【摘要】 多线程的团队协作:同步控制同步控制是并发程序必不可少的重要手段。之前介绍的关键字synchronized就是一种最简单的控制方法,它决定了一个线程是否可以访问临界区资源。同时,Object.wait()方法和Object.notify)方法起到了线程等待和通知的作用。这些工具对于实现复杂的多线程协作起到了重要的作用。下面我们首先将介绍关键字synchronized、Object.wait(...

多线程的团队协作:同步控制

同步控制是并发程序必不可少的重要手段。之前介绍的关键字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
    毫秒的代码)。最终你还是可以欣喜地看到程序执行完毕,并产生如下输出,表示两个线程双双正常执行。

在这里插入图片描述

三、公平锁

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

在这里插入图片描述

  • 当参数fair为
    true时,表示锁是公平的。公平锁看起来很优美,但是要实现公平锁必然要求系统维护一个有序队列,因此公平锁的实现成本比较高,性能却非常低下,因此,在默认情况下,锁是非公平的。如果没有特别的需求,则不需要使用公平锁。公平锁和非公平锁在线程调度表现上也是非常不一样的。下面的代码可以很好地突出公平锁的特点:
    在这里插入图片描述
    在这里插入图片描述
    在这里插入图片描述
    上述代码第﹖行指定锁是公平的。接着,由t1和t2两个线程分别请求这把锁,并且在得到锁后,进行一个控制台的输出,表示自己得到了锁。在公平锁的情况下,得到的输出通常如下所示:
    在这里插入图片描述
    由于代码会产生大量输出,这里只截取部分进行说明。在这个输出中,很明显可以看到,两个线程基本上是交替获得锁的,几乎不会发生同一个线程连续多次获得锁的可能,从而保证了公平性。如果不使用公平锁,那么情况会完全不一样,下面是使用非公平锁时的部分输出:
    在这里插入图片描述
    在这里插入图片描述
    可以看到,根据系统的调度,一个线程会倾向于再次获取已经持有的锁,这种分配方式是高效的,但是无公平性可言。对上面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高并发程序设计,推荐推荐

【版权声明】本文为华为云社区用户原创内容,转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息, 否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。