3.实战java高并发程序设计--JDK并发包---3.1

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

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

3.1.1 关键字synchronized的功能扩展:重入锁

重入锁可以完全替代关键字synchronized。在JDK 5.0的早期版本中,重入锁的性能远远优于关键字synchronized,但从JDK 6.0开始,JDK在关键字synchronized上做了大量的优化,使得两者的性能差距并不大。

重入锁使用java.util.concurrent.locks.ReentrantLock类来实现。下面是一段最简单的重入锁使用案例。

上述代码第7~12行使用重入锁保护临界区资源i,确保多线程对i操作的安全性。从这段代码可以看到,与关键字synchronized相比,重入锁有着显示的操作过程。开发人员必须手动指定何时加锁,何时释放锁。也正因为这样,重入锁对逻辑控制的灵活性要远远优于关键字synchronized。但值得注意的是,在退出临界区时,必须记得释放锁(代码第11行),否则,其他线程就没有机会再访问临界区了。

你可能会对重入锁的名字感到奇怪。锁为什么要加上“重入”两个字呢?从类的命名上看,Re-Entrant-Lock翻译成重入锁非常贴切。之所以这么叫,是因为这种锁是可以反复进入的。当然,这里的反复仅仅局限于一个线程。上述代码的第7~12行,可以写成下面的形式

在这种情况下,一个线程连续两次获得同一把锁是允许的。如果不允许这么操作,那么同一个线程在第2次获得锁时,将会和自己产生死锁。程序就会“卡死”在第2次申请锁的过程中。但需要注意的是,如果同一个线程多次获得锁,那么在释放锁的时候,也必须释放相同次数。如果释放锁的次数多了,那么会得到一个java.lang.IllegalMonitorStateException异常,反之,如果释放锁的次数少了,那么相当于线程还持有这个锁,因此,其他线程也无法进入临界区。

除使用上的灵活性以外,重入锁还提供了一些高级功能。比如,重入锁可以提供中断处理的能力。

1.中断响应lockInterruptibly()

对于关键字synchronized来说,如果一个线程在等待锁,那么结果只有两种情况,要么它获得这把锁继续执行,要么它就保持等待。而使用重入锁,则提供另外一种可能,那就是线程可以被中断。也就是在等待锁的过程中,程序可以根据需要取消对锁的请求。有些时候,这么做是非常有必要的。。如果一个线程正在等待锁,那么它依然可以收到一个通知,被告知无须等待,可以停止工作了。这种情况对于处理死锁是有一定帮助的。

在这里,对锁的请求,统一使用lockInterruptibly()方法。这是一个可以对中断进行响应的锁申请动作,即在等待锁的过程中,可以响应中断

在代码第47行,主线程main处于休眠状态,此时,这两个线程处于死锁的状态。在代码第49行,由于t2线程被中断,故t2会放弃对lock1的申请,同时释放已获得的lock2。这个操作导致t1线程可以顺利得到lock2而继续执行下去。

2.锁申请等待限时tryLock()

除了等待外部通知之外,要避免死锁还有另外一种方法,那就是限时等待。依然以约朋友打球为例,如果朋友迟迟不来,又无法联系到他,那么在等待1到2个小时后,我想大部分人都会扫兴离去。对线程来说也是这样。通常,我们无法判断为什么一个线程迟迟拿不到锁。也许是因为死锁了,也许是因为产生了饥饿。如果给定一个等待时间,让线程自动放弃,那么对系统来说是有意义的。我们可以使用tryLock()方法进行一次限时的等待。

在这里,tryLock()方法接收两个参数,一个表示等待时长,另外一个表示计时单位。这里的单位设置为秒,时长为5,表示线程在这个锁请求中最多等待5秒。如果超过5秒还没有得到锁,就会返回false。如果成功获得锁,则返回true。在本例中,由于占用锁的线程会持有锁长达6秒,故另一个线程无法在5秒的等待时间内获得锁,因此请求锁会失败。

ReentrantLock.tryLock()方法也可以不带参数直接运行。在这种情况下,当前线程会尝试获得锁,如果锁并未被其他线程占用,则申请锁会成功,并立即返回true。如果锁被其他线程占用,则当前线程不会进行等待,而是立即返回false。这种模式不会引起线程等待,因此也不会产生死锁。

3.公平锁 new ReentrantLock(true)

公平的锁,则不是这样,它会按照时间的先后顺序,保证先到者先得,后到者后得。公平锁的一大特点是:它不会产生饥饿现象。只要你排队,最终还是可以等到资源的。如果我们使用synchronized关键字进行锁控制,那么产生的锁就是非公平的。而重入锁允许我们对其公平性进行设置。它的构造函数如下:

当参数fair为true时,表示锁是公平的。公平锁看起来很优美,但是要实现公平锁必然要求系统维护一个有序队列,因此公平锁的实现成本比较高,性能却非常低下,因此,在默认情况下,锁是非公平的。如果没有特别的需求,则不需要使用公平锁。公平锁和非公平锁在线程调度表现上也是非常不一样的。

对上面ReentrantLock的几个重要方法整理如下。

● lock():获得锁,如果锁已经被占用,则等待。

● lockInterruptibly():获得锁,但优先响应中断。

● tryLock():尝试获得锁,如果成功,则返回true,失败返回false。该方法不等待,立即返回。

● tryLock(long time, TimeUnit unit):在给定时间内尝试获得锁。

● unlock():释放锁。

3.1.2 重入锁的好搭档:Condition : lock.newCondition()

如果大家理解了Object.wait()方法和Object.notify()方法,就能很容易地理解Condition对象了。它与wait()方法和notify()方法的作用是大致相同的。但是wait()方法和notify()方法是与synchronized关键字合作使用的,而Condition是与重入锁相关联的。通过lock接口(重入锁就实现了这一接口)的Condition newCondition()方法可以生成一个与当前重入锁绑定的Condition实例。利用Condition对象,我们就可以让线程在合适的时间等待,或者在某一个特定的时刻得到通知,继续执行。

● await()方法会使当前线程等待,同时释放当前锁,当其他线程中使用signal()方法或者signalAll()方法时,线程会重新获得锁并继续执行。或者当线程被中断时,也能跳出等待。这和Object.wait()方法相似。

● awaitUninterruptibly()方法与await()方法基本相同,但是它并不会在等待过程中响应中断。

● singal()方法用于唤醒一个在等待中的线程,singalAll()方法会唤醒所有在等待中的线程。这和Obejct.notify()方法很类似。

与Object.wait()方法和notify()方法一样,当线程使用Condition.await()方法时,要求线程持有相关的重入锁,在Condition.await()方法调用后,这个线程会释放这把锁.同理,在Condition.signal()方法调用时,也要求线程先获得相关的锁。在signal()方法调用后,系统会从当前Condition对象的等待队列中唤醒一个线程。一旦线程被唤醒,它会重新尝试获得与之绑定的重入锁,一旦成功获取,就可以继续执行了。因此,在signal()方法调用之后,一般需要释放相关的锁,让给被唤醒的线程,让它可以继续执行.

比如,在本例中,第24行代码就释放了重入锁,如果省略第24行,那么,虽然已经唤醒了线程t1,但是由于它无法重新获得锁,因而也就无法真正的继续执行。

3.1.3 允许多个线程同时访问:信号量(Semaphore)

信号量为多线程协作提供了更为强大的控制方法。从广义上说,信号量是对锁的扩展。无论是内部锁synchronized还是重入锁ReentrantLock,一次都只允许一个线程访问一个资源,而信号量却可以指定多个线程,同时访问某一个资源。信号量主要提供了以下构造函数:

在构造信号量对象时,必须要指定信号量的准入数,即同时能申请多少个许可。当每个线程每次只申请一个许可时,这就相当于指定了同时有多少个线程可以访问某一个资源。信号量的主要逻辑方法有:

ExecutorService exe = Executors.newFixedThreadPool(20);

exe.submit()此方法是线程池的用法

3.1.4 ReadWriteLock读写锁 lock.writeLock(),lock.readLock()

ReadWriteLock是JDK 5中提供的读写分离锁。读写分离锁可以有效地帮助减少锁竞争,提升系统性能。用锁分离的机制来提升性能非常容易理解,比如线程A1、A2、A3进行写操作,B1、B2、B3进行读操作,如果使用重入锁或者内部锁,从理论上说所有读之间、读与写之间、写和写之间都是串行操作。当B1进行读取时,B2、B3则需要等待锁。由于读操作并不对数据的完整性造成破坏,这种等待显然是不合理的。因此,读写锁就有了发挥功能的余地。

● 读-读不互斥:读读之间不阻塞。

● 读-写互斥:读阻塞写,写也会阻塞读。

● 写-写互斥:写写阻塞。

如果在系统中,读操作的次数远远大于写操作的次数,则读写锁就可以发挥最大的功效,提升系统的性能。这里我给出一个稍微夸张点的案例来说明读写锁对性能的帮助。

最终用2秒完成了,如果不是读写锁而是普通锁,那么用时20秒

3.1.5 倒计数器:CountDownLatch

CountDownLatch是一个非常实用的多线程控制工具类。这个工具通常用来控制线程等待,它可以让某一个线程等待直到倒计数结束,再开始执行

对于倒计数器,一种典型的场景就是火箭发射。在火箭发射前,为了保证万无一失,往往还要对各项设备、仪器进行检查。只有等所有检查都完成后,引擎才能点火。这种场景就非常适合使用CountDownLatch。它可以使点火线程等待所有检查线程全部完工后再执行

CountDownLatch的构造函数接收一个整数作为参数,即当前这个计数器的计数个数。

上述代码第2行生成一个CountDownLatch实例,计数数量为10,这表示需要10个线程完成任务后等待在CountDownLatch上的线程才能继续执行。代码第10行使用了CountDownLatch.countdown()方法,也就是通知CountDownLatch,一个线程已经完成了任务,倒计数器减1。第21行使用CountDownLatch.await()方法,要求主线程等待所有检查任务全部完成待10个任务全部完成后,主线程才能继续执行

主线程在CountDownLatch上等待,当所有检查任务全部完成后,主线程方能继续执行。

3.1.6 循环栅栏:CyclicBarrier

CyclicBarrier是另外一种多线程并发控制工具。和CountDownLatch非常类似,它也可以实现线程间的计数等待,但它的功能比CountDownLatch更加复杂且强大。

CyclicBarrier比CountDownLatch略微强大一些,它可以接收一个参数作为barrierAction。所谓barrierAction就是当计数器一次计数完成后,系统会执行的动作。如下构造函数,其中,parties表示计数总数,也就是参与的线程总数。

上述代码第57行创建了CyclicBarrier实例,并将计数器设置为10,要求在计数器达到指标时,执行第43行的run()方法。每一个士兵线程都会执行第11行定义的run()方法。在第14行,每一个士兵线程都会等待,直到所有的士兵都集合完毕。集合完毕意味着CyclicBarrier的一次计数完成,当再一次调用CyclicBarrier.await()方法时,会进行下一次计数。第15行模拟了士兵的任务。当一个士兵任务执行完,他就会要求CyclicBarrier开始下一次计数,这次计数主要目的是监控是否所有的士兵都已经完成了任务。一旦任务全部完成,第35行定义的BarrierRun就会被调用,打印相关信息。

3.1.7 线程阻塞工具类:LockSupport

LockSupport是一个非常方便实用的线程阻塞工具,它可以在线程内任意位置让线程阻塞。与Thread.suspend()方法相比,它弥补了由于resume()方法发生导致线程无法继续执行的情况。和Object.wait()方法相比,它不需要先获得某个对象的锁,也不会抛出InterruptedException异常。

LockSupport的静态方法park()可以阻塞当前线程,类似的还有parkNanos()、parkUntil()等方法。它们实现了一个限时的等待。

注意,这里只是将原来的suspend()方法和resume()方法用park()方法和unpark()方法做了替换。当然,我们依然无法保证unpark()方法发生在park()方法之后。但是执行这段代码,你会发现,它自始至终都可以正常地结束,不会因为park()方法而导致线程永久挂起。

这是因为LockSupport类使用类似信号量的机制。它为每一个线程准备了一个许可,如果许可可用,那么park()方法会立即返回,并且消费这个许可(也就是将许可变为不可用),如果许可不可用,就会阻塞,而unpark()方法则使得一个许可变为可用(但是和信号量不同的是,许可不能累加,你不可能拥有超过一个许可,它永远只有一个)。这个特点使得:即使unpark()方法操作发生在park()方法之前,它也可以使下一次的park()方法操作立即返回。这也就是上述代码可顺利结束的主要原因。同时,处于park()方法挂起状态的线程不会像suspend()方法那样还给出一个令人费解的Runnable状态。它会非常明确地给出一个WAITING状态,甚至还会标注是park()方法引起的

注意,在堆栈中,我们甚至还看到了当前线程等待的对象,这里就是ChangeObjectThread实例。

除了有定时阻塞的功能,LockSupport.park()方法还能支持中断影响。但是和其他接收中断的函数很不一样,LockSupport.park()方法不会抛出InterruptedException异常。它只会默默返回,但是我们可以从Thread.interrupted()等方法中获得中断标记。

3.1.8 Guava和RateLimiter限流

Guava是Google下的一个核心库,提供了一大批设计精良、使用方便的工具类。许多Java项目都使用Guava作为其基础工具库来提升开发效率,我们可以认为Guava是JDK标准库的重要补充。在这里,将给大家介绍Guava中的一款限流工具RateLimiter。

一种简单的限流算法就是给出一个单位时间,然后使用一个计数器counter统计单位时间内收到的请求数量,当请求数量超过门限时,余下的请求丢弃或者等待。但这种简单的算法有一个严重的问题,就是很难控制边界时间上的请求。假设时间单位是1秒,每秒请求不超过10个。如果在这一秒的前半秒没有请求,而后半秒有10个请求,下一秒的前半秒又有10个请求,那么在这中间的一秒内,就会合理处理20个请求,而这明显违反了限流的基本需求。这是一种简单粗暴的总数量限流而不是平均限流,如图3.3所示。

因此,更为一般化的限流算法有两种:漏桶算法和令牌桶算法。

漏桶算法的基本思想是:利用一个缓存区,当有请求进入系统时,无论请求的速率如何,都先在缓存区内保存,然后以固定的流速流出缓存区进行处理,如图3.4所示。漏桶算法的特点是无论外部请求压力如何,漏桶算法总是以固定的流速处理数据。漏桶的容积和流出速率是该算法的两个重要参数。


3.2 线程复用:线程池

发布了24 篇原创文章 · 获赞 1 · 访问量 3415

猜你喜欢

转载自blog.csdn.net/ashylya/article/details/104306689