第三章-JDK并发包

为了更好地支持并发程序,JDK内部提供了大量实用的API和框架。本章主要介绍这些JDK内部的功能,其主要分为三大部分:

  • 同步控制工具
  • JDK对线程池的支持
  • JDK的一些并发容器

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

同步控制是并发程序必不可少的重要手段。

下面我们首先将介绍关键字synchronized、Object.wait()方 法和Object.notify()
方法的替代品(或者说是增强版)一重入锁。

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

重入锁可以完全替代关键字synchronized。重入锁使用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 < 100000; j++) {
            lock.lock();
            try {
                i++;
            }finally {
                lock.unlock();
            }
        }
    }
}

与关键字synchronized相比,重入锁有着显示的操作过程。必须手动指示何时加锁,何时释放锁。因此,重入锁对逻辑控制的灵活性要远远优于synchronized。

重入锁(Re-Entrant-Lock)是可以反复进入的,一个线程可以多次获得同一把锁(避免线程自己和自己卡死),但是要记得释放相同次数。

重入锁的一些高级功能:

1.中断响应

如果一个线程正在等待锁,那么它依然可以收到一个通知,被告知无须等待(因为可能永远也获取不到锁,一直等下去是浪费时间),可以停止工作了。这种情况对于处理死锁是有一定帮助的。

对锁的申请,使用lockInterruptibly()方法可以对中断进行响应,即在等待锁的过程中,可以响应中断。

2.锁申请等待限时

除了等待外部通知以外,要避免死锁还有另一种方法:限时等待。可以使用tryLock(等待时长,计时单位) 方法进行一次限时的等待。线程进行锁申请的等待时间如果超过设置的时间,就返回false,否则为true。如果方法不设置参数,则表示,申请不到资源立马返回false。

3.公平锁

如果线程A释放了锁,系统只是会从这个锁的等待队列中随机挑选-一个。因此不能保证其公平性。如果使用synchronized关键字进行锁控制,那么产生的锁就是非公平的。而重入锁允许我们对其公平性进行设置。它的构造函数如下:

public ReentrantLock(boolean fair)

当参数为true时,表示锁是公平的。公平锁按照时间先后顺序,保证不会产生饥饿现象。但是实现成本较高,性能比较地下,默认状况下为非公平的。

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

  • lock(); 获得锁,如果锁已经被占用,则等待。
  • lockInterruptibly(): 获得锁,但优先响应中断。
  • tryLock(): 尝试获得锁,如果成功,则返回true, 失败返回false。 该方法不等待,立即返回。
  • tryLock(long time, TimeUnit unit): 在给定时间内尝试获得锁。
  • unlock():释放锁。

重入锁和synchronized的比较:

  • Lock是一个接口,而synchronized是Java中的关键字,synchronized是内置的语言实现。
  • Lock有着显示的操作过程,由开发自己决定加锁和释放锁的时间,更为灵活。synchronized的获取和释放锁由JVM实现。
  • Lock线程在获取锁的时候可以响应中断,synchronized不可以。
  • Lock可以设置是否为公平锁,默认是不公平,synchronized是非公平锁。
  • synchronized当线程尝试获取锁的时候,如果获取不到锁会一直阻塞。重入锁可以设置等待时间。
  • synchronized在发生异常时,会自动释放线程占有的锁,因此不会导致死锁现象发生;而Lock在发生异常时,如果没有主动通过unLock()去释放锁,则很可能造成死锁现象,因此使用Lock时需要在finally块中释放锁。

3.1.2 重入锁的好搭档:Condition 81

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

例:

public class ReenterLockCondition implements Runnable {
    public static ReentrantLock lock = new ReentrantLock();
    //通过newCondition方法生成一个与当前重入锁绑定的Condition实例。
    public static Condition condition = lock.newCondition();
 
    @Override
    public void run() {
 
        try {
          //调用await方法首先要获得锁
            lock.lock();
            //await方法会使当前线程等待在Condition上,同时释放当前锁
            condition.await();
            System.out.println("Thread is going on");
        } catch (InterruptedException e) {
            e.printStackTrace();
        }finally {
            lock.unlock();
        }
    }
 
    public static void main(String[] args) throws InterruptedException {
        ReenterLockCondition reenterLockCondition = new ReenterLockCondition();
        Thread thread = new Thread(reenterLockCondition);
        thread.start();
        Thread.sleep(2000);
        //在signal方法调用时,要求线程先获得相关的锁
        lock.lock();
        //调用signal方法,系统会从当前Condition对象的等待队列中唤醒一个线程
        //同理,signalAll方法会唤醒所有线程
        condition.signal();
        //在signal方法调用后,需要释放相关的锁,让给被唤醒的线程
        //若没有释放锁,线程thread无法继续执行
        lock.unlock();
    }
}

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

信号量是对锁的扩展。无论是内部锁synchronized还是重入锁ReentrantLock,一次都只允许一个线程访问一个资源,而信号量可以指定多个线程,同时访问一个资源。

//信号量的构造函数,构造时必须制定准入数,即同时能申请多少个许可
public Semaphore(int permits)
public Semaphore(int permits,boolean fair)  //第二个参数可以指定是否公平
 
//信号量的方法
public void acquire()  //尝试获得一个准入许可
public void acquireUninterruptibly()  //不响应中断
public boolean tryAcquire()  //尝试获得一个许可,成功返回true,失败返回false,不等待
public boolean tryAcquire(long timeout, TimeUnit unit)  //等待限时
public void release()  //用于访问结束后释放一个许可

3.1.4 ReadWriteLock读写锁 86

如果使用重入锁或者内部锁,从理论上说所有读之间、读与写之间、写和写之间都是串行操作。

读写锁允许多个线程同时读(因为读操作并不影响数据的完整性),考虑到数据完整性,写写操作和读写操作间依然是需要相互等待和持有锁的。如果在系统中,读操作的次数远远大于写操作的次数,则读写锁就可以发挥最大的功效,提升系统的性能。

3.1.5 倒计数器:CountDownLatch 89

通常用来控制线程等待,可以让某一个线程等待直到倒计数器结束再开始执行。

public class CountDownLatchDemo implements Runnable {
    //创建一个倒计数器的实例,参数为计数器的计数个数
    //参数为10表示需要10个线程完成任务后等待在CountDownLatch上的线程才能继续执行
    static final CountDownLatch end = new CountDownLatch(10);
    static final CountDownLatchDemo demo = new CountDownLatchDemo();
 
    @Override
    public void run() {
        try {
            //模拟线程执行时间
            Thread.sleep(new Random().nextInt(10)*1000);
            System.out.println("check complete");
            //一个线程已经完成了任务,倒计数器减一
            end.countDown();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
 
    public static void main(String[] args) throws InterruptedException {
        ExecutorService exec = Executors.newFixedThreadPool(10);
        for (int i = 0; i < 10; i++) {
            exec.submit(demo);
        }
        //要求主线程等待所有检查任务全部完成后,才能继续执行
        end.await();
        System.out.println("go!");
        exec.shutdown();
    }
}

3.1.6 循环栅栏:CyclicBarrier 91

类似CountDownLatch,但功能更强大。比如,把计数器设置为10,那么在凑齐第一批10个线程后,一起开始执行任务,直到任务完成,然后计数器就会归零,接着凑齐下一批10个线程。

public CyclicBarrier (int parties, Runnable barrierAction)

CyclicBarrier可以反复使用的计数器,构造函数中第一个参数int parties 指定计数总数,第二个参数Runnable barrierAction 指定了每次计数结束后要执行的动作。

CyclicBarrier的await方法会等待计数完成,可能抛出两个异常InterruptedException(常见的异常类别)和BrokenBarrierException(CyclicBarrier被破坏,继续等待是徒劳的)。

3.1.7 线程阻塞工具类:LockSupport 94

  • LockSupport可以在任意位置让线阻塞,LockSupport的静态方法park()可以阻塞当前线程,和Object.wait()相比,它不需要先获得某个对象的锁,也不会抛出InterruptedException。
  • 和suspend相比,它不会造成死锁,是因为LockSupport内部是基于信号量实现的,它为每一个线程都准备了一个许可,如果许可可用,park()函数会立即返回。
  • LockSupport支持中断影响,但是不抛出中断异常,只会默默返回,需要通过Thread.interrupted()进行判断。

3.1.8 Guava和RateLimiter限流 98

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

RateLimiter是一款请求限流工具。

请求限流:限制处理请求的数量,多了系统忙不过来,有以下方法:

  • 指定一个计数器count,在单位时间只允许N个请求(缺点:跨单位时间可能处理超过N个请求)
  • 漏桶算法:请求非匀速的进入缓冲区,从缓冲区匀速取请求
  • 令牌桶算法:请求只有获得桶中的令牌,才能执行。RateLimiter就是采用此方法

3.2 线程复用:线程池 101

  • 大量的线程的创建和销毁会消耗很多时间。
  • 线程本身也要占据内存空间,大量的线程会抢占宝贵的内存资源。
  • 大量的线程回收也会给GC带来很大的负担,延长了GC停顿时间。

所以引入了线程池的概念

3.2.1 什么是线程池 102

  • 线程池中有几个活跃的线程,当你需要使用线程的时候,可以从池子中随便拿一个空闲线程,当完成工作的时候,将线程退回到池子中,方便其他人使用。
  • 创建线程变成了从池子中获取空闲线程,销毁线程变成了向线程归还线程。

3.2.2 不要重复发明轮子:JDK对线程池的支持 102

以上成员均在java.util.concurrent包中,是JDK并发包的核心类。其中, ThreadPoolExecutor表示一个线程池。Executors类则扮演着线程池工厂的角色,通过Executors可以取得一个拥有特定功能的线程池。从UML图中亦可知,ThreadPoolExecutor 类实现了Executor 接口,因此通过这个接口,任何Runnable的对象都可以被ThreadPoolExecutor线程池调度。

Executor框架提供了各种类型的线程池,主要有以下工厂方法:

newFixedThreadPool()方法: 该方法返回一个固定线程数量的线程池。如果没有空闲线程,任务会暂存在一个任务队列中。

●newSingleThreadExecutor()返回只有一个线程的线程池,多余的任务会被暂存在一个等待队列中,按照先入先出的原则顺序执行队列中的任务。
●newCachedThreadPool0()方法: 该方法返回一个可根据实际情况调整线程数量的线程池,线程池的线程数量不确定。

●newSingleThreadScheduledExecutor()方法:该方法返回一个ScheduledExecutorService
对象,线程池大小为1。ScheduledExecutorService 扩展了在给定时间执行某任务的功能,如在某个固定的延时之后执行,或者周期性执行某个任务。

ScheduledExecutorService对象有两个方法主要求别:

scheduleAtFixedRate(Runnable command, long initialDelay, long period, TimeUnitunit)

创建并执行一个在给定初始延迟后首次启用的定期操作,后续操作具有给定的周期;也就是将在 initialDelay 后开始执行,然后在initialDelay+period 后执行,接着在 initialDelay + 2 * period 后执行,依此类推

scheduleWithFixedDelay(Runnable command, long initialDelay, long delay, TimeUnit unit)创建并执行一个在给定初始延迟后首次启用的定期操作,随后,在每一次执行终止和下一次执行开始之间都存在给定的延迟。

●newScheduledThreadPool()方法: 该方法也返回一个ScheduledExecutorService对象,
但该线程池可以指定线程数量。

3.2.3 刨根究底:核心线程池的内部实现 108

对于核心的几个线程池,无论是newFiexedThreadPool()、newSingleThreadExecutor()和newCachedThreadPool(),它们内部都只是调用了ThreadPoolExecutor类的不同构造方法。ThreadPoolExecutor类的构造方法如下:

    public ThreadPoolExecutor(int corePoolSize,//指定了线程池中的线程数量
                              int maximumPoolSize,//最大线程数量
                              long keepAliveTime,//当线程池中的线程数量超过corePoolSize时,多余线程的存活时间
                              TimeUnit unit,//时间单位
                              BlockingQueue<Runnable> workQueue,//任务队列
                              ThreadFactory threadFactory,//线程工厂,用于创建线程
                              RejectedExecutionHandler handler//拒绝策略。当任务太多来不及处理时,如何拒绝任务。)

注意参数workQueue指被提交但未执行的任务队列,它是一个BlockingQueue接口的对象,仅用于存放Runnable对象。根据队列功能分类,在ThreadPoolExecutor类的构造函数中可使用以下几种BlockingQueue接口。

  • 直接提交的队列: 该功能由SynchronousQueue对象提供。SynchronousQueue 没有容量,提交的任务不会被真实的保存下来,如果没有空闲线程则会创建线程,线程数量若超过最大线程数执行拒绝策略。
  • 有界的任务队列:可以使用ArrayBlockingQueue实现其构造函数必须带容量,表示该队列的最大容量。当使用有界的任务队列时,若有新的任务需要执行,如果线程池的实际线程数小于
    corePoolSize,则会优先创建新的线程,若大于corePoolSize,则会将新任务加入等
    待队列。若等待队列已满,无法加入,则在总线程数不大于maximumPoolSize的前
    提下,创建新的进程执行任务。若大于maximumPoolSize,则执行拒绝策略。
  • 无界的任务队列: 无界任务队列可以通过LinkedBlockingQueue 类实现。无界的任务队列不存在任务入队失败的情况。
  • 优先任务队列: 是带有执行优先级的队列,通过PriorityBlockingQueue实现,是一个特殊的无界队列可以根据任务自身的优先级顺序先后执行。

下面给出ThreadPoolExecutor线程池的核心调度代码, 可以以有界的任务队列为例子去理解

代码第5行的workerCountOf()函 数取得了当前线程池的线程总数。当线程总数小于corePoolSize 核心线程数时,会将任务通过addWorker()方法直接调度执行。否则,则在第10行代码处( workQueue.offer()进入等待队列。如果进入等待队列失败(比如有界队列到达了上限,或者使用了SynchronousQueue 类),则会执行第17行,将任务直接提交给线程池。如果当前线程数已经达到maximumPoolSize,则提交失败,就执行第18行的拒绝策略。整体流程如下图所示:

3.2.4 超负载了怎么办:拒绝策略 112

ThreadPoolExecutor类的最后一个参数指定了拒绝策略。也就是当任务数量超过系统实际承载能力时,就要用到拒绝策略了。

JDK内置了四种拒绝策略:

  • AbortPolicy策略:该策略会直接抛出异常,阻止系统正常工作。
  • CallerRunsPolicy策略:直接在调用者线程中运行当前被丢弃的任务。但是,任务提交线程的性能极有可能会急剧下降。
  • DiscardOldestPolicy策略:该策略将丢弃最老的一个请求,也就是即将被执行的一个任务,并尝试再次提交当前任务。
  • DiscardPolicy 策略:该策略丢弃无法处理的任务,不予任何处理。

若以上策略无法满足需求,可以自己扩展接口实现自定义策略。

3.2.5 自定义线程创建:ThreadFactory 115

线程池中的线程由ThreadFactory创建,ThreadFactory是一个接口,它只有一个用来创建线程的方法。

Thread newThread (Runnable  r);

用户可以自定义线程池,使用自定义线程池可以让我们更加自由地设置线程池中所有线程的状态。

3.2.6 我的应用我做主:扩展线程池 116

ThreadPoolExecutor 是一个可以扩展的线程池。它提供了beforeExecute()、afterExecute()和terminated()三个接口用来对线程池进行控制。我们可以通过重写这些方法来对线程池进行一些扩展。

3.2.7 合理的选择:优化线程池线程数量 119

线程池线程数量不能过大也不能过小,这边给了公式可以估算。

3.2.8 堆栈去哪里了:在线程池中寻找堆栈 120

通过扩展ThreadPoolExecutor线程池,使得多线程出现错误是能够打印异常堆栈。

3.2.9 分而治之:Fork/Join框架 124

Fork/Join框架体现了分而治之的思想。将大任务fork成多个小任务,待小任务都完成后得到的结果join成最终大任务的结果。

具体见图:

在JDK中,给出了一个ForkJoinPool线程池,fork()方法并不急着开启线程,而是先提交给ForkJoinPool线程池进行处理,避免fork太多影响性能。

其次这里还存在一个互相“帮助”的现象。线程A完成了所有的任务会“帮助”没有完成所有任务的线程B执行部分任务。

在使用Fork/Join 框架时需要注意:如果任务的划分层次很多,一直得不到返回, 那么可能出现两种情况:第一,系统内的线程数量越积越多,导致性能严重下降。第二,函数的调用层次变多,最终导致栈溢出。

3.2.10 Guava中对线程池的扩展 128

除JDK内置的线程池以外,Guava 对线程池也进行了一定的扩展,主要体现在MoreExecutors工具类中。

MoreExecutors工具类提供了特殊的DirectExecutor线程池,Daemon线程池以及对Future模式的扩展

3.3 不要重复发明轮子:JDK的并发容器 130

3.3.1 超好用的工具类:并发集合简介 130

DK提供的并发容器大部分在java.util.concurrent下:

ConcurrentHashMap:一个高效并发的HashMap。是线程安全的。

CopyOnWriteArrayList:一个List,在读多于写的场合性能远远优于Vector。

ConcurrentLinkedQueue:高效的并发队列,使用链表实现,线程安全。

ConcurrentSkipListMap:跳表的并发实现,是一个Map。

java.util 下的Vector是线程安全的(性能差)

Collections工具类可以帮助我们将任意集合包装成线程安全的。

3.3.2 线程安全的HashMap 131

保证HashMap的线程安全性:两种方法:1.使用Collections.synchronizedMap()
方法包装HashMap。2.采用ConcurrentHashMap,性能好

3.3.3 有关List的线程安全 132

我们可以用使用Collections. synchronizedList()方法来包装任意List,使其线程安全。

3.3.4 高效读写的队列:深度剖ConcurrentLinkedQueue类 132

ConcurrentLinkedQueue,高效的并发队列,基于链表实现可以看成是线程安全的LinkedList。

3.3.5 高效读取:不变模式下的CopyOnWriteArrayList类 138

在读多写少的场合,这个List性能非常好。因为CopyOnWriteArrayList对于读完全不加锁,写入也不会阻塞读取操作,只有写入和写入之间需要进行同步等待。

写入操作使用锁来用于写写的情况,然后通过内部复制生成一个新的数组,在用新数组替换老数组,修改完成后,读线程会察觉到volatile修饰的array被修改了。整个修改过程并不会影响读。

3.3.6 数据共享通道:BlockingQueue 139

BlockingQueue是一个接口,并非一个具体的实现。它的主要是实现有以下几种:

ArrayBlockingQueue实现了一个环形队列,下面以ArrayBlockingQueue为分析:

用put()方法将元素压入队列末尾。但如果队列满了,它会一直等待,直到队列中有空闲的位置。
用poll()方法会直接返回null,而take()方法会等待,直到队列内有可用元素。

3.3.7 随机数据结构:跳表(SkipList) 144

使用跳表的数据结构进行快速查找。跳表数据结构与平衡树类似,但是插入和删除操作无需和平衡树一样要对全局调整,只要部分调整即可。并发的情况下要对平衡树全局加锁,当跳表只需要部分加锁。跳表内所有链表的元素是有序的。

跳表维持了多个链表,最底层链表维持了所有元素,上一层是下一层的子集,采用空间换时间的算法来提高查找速度。

跳表的查找过程,如下:

在跳表中查找元素7,查找从顶层的头部索引节点开始。由于顶层的元素最少,因此可以快速跳过那些小于7的元素。很快,查找过程就能到元素6。由于在第2层,元素8大于7,故肯定无法在第2层找到元素7,直接进入底层(包含所有元素)开始查找,并且很快就可以根据元素6搜索到元素7。整个过程要比一般的链表要快。

3.4 使用JMH进行性能测试 146

3.4.1 什么是JMH 147

3.4.2 Hello JMH 147

3.4.3 JMH的基本概念和配置 150

3.4.4 理解JMH中的Mode 151

3.4.5 理解JMH中的State 153

3.4.6 有关性能的一些思考 154

3.4.7 CopyOnWriteArrayList类与ConcurrentLinkedQueue类 157

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值