• 《Java并发编程的艺术》第6/7/8章 Java并发容器与框架/13个原子操作/并发工具类


    第6章 Java并发容器和框架

    6.1  ConcurrentHashMap(线程安全的HashMap、锁分段技术)

    6.1.1 为什么要使用ConcurrentHashMap

      在并发编程中使用HashMap可能导致程序死循环,而线程安全的HashTable效率又非常低下。基于以上两个原因,便有了ConcurrentHashMap的登场机会。

      (1)线程不安全的HashMap

      在多线程环境下,使用HashMap进行put操作会引起死循环(因为多线程会导致HashMap的Entry链表形成环形数据结构,Entry的next节点永远不为空,就会产生死循环获取Entry。),导致CPU利用率接近100%,所以在并发情况下不能使用HashMap。

      (2)效率低下的HashTable

      HashTable容器使用sychronized来保证线程安全,但在线程竞争激烈的情况下HashTable的效率非常低下。因为当一个线程访问HashTable的同步方法,其他线程也访问HashTable的同步方法时,会进入阻塞或轮询状态。如线程1使用put进行元素添加,线程2不但不能用put方法添加元素,也不能使用get方法来获取元素,所以竞争越激烈效率越低。

      (3)ConcurrentHashMap的锁分段技术可有效提升并发访问率

      HashTable容器在竞争激烈的并发环境下表现效率低下的原因是所有访问HashTable的线程都必须竞争同一把锁。假如容器里有多把锁,每一把锁用于锁容器其中一部分数据,那么当多线程访问容器里不同数据段的数据时,线程间就不会存在锁竞争,从而可以有效提高并发访问效率,这就是ConcurrentHashMap所使用的锁分段技术。首先将数据分成一段一段地存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一段数据的时候,其他段的数据也能被其他线程访问

    ConcurrentHashMap的结构:

      ConcurrentHashMap由Segment数组结构HashEntry数组结构组成。Segment是可重入锁,扮演锁的角色;HashEntry存储键值对数据
      一个ConcurrentHashMap里包含一个Segment数组,Segment的结构与HashMap类似,是一种数组和链表结构。一个Segment包含一个HashEntry数组,每个HashEntry是一个链表结构的元素,每个Segment守护一个HashEntry数组里的元素。当对HashEntry数组的数据进行修改的时候,必须首先获得与它对应的Segment锁

    ConcurrentHashMap的操作:

      get操作get过程不需要加锁,只有值为空值的时候才加锁重读。(如何做到不加锁的?get方法里将要使用的共享变量都定义为volatile类型。)

      put操作put过程必须加锁(由于put方法里需要对共享变量进行写入操作,所以为了线程安全,在操作共享变量时必须加锁)put方法首先定位到Segment,然后在segment里进行插入操作

      插入操作步骤:第一步判断是否需要对Segment里的HashEntry数组进行扩容,第二步定位添加元素的位置,然后将其放到HashEntry数组里。

    • 是否需要扩容? 在插入元素前先判断Segment里的HashEntry数组是否超过容量(threadshold),如果超过阈值,则对数组进行扩容。
    • 如何扩容? 在扩容时,首先会创建一个容量为原来容量2倍的数组,然后将原数组里的元素进行再散列后插入到新的数组里。为了高效,ConcurrentHashMap不会对整个容器进行扩容,而只对某个segment进行扩容。

    size操作:先尝试2次通过不锁住Segment的方式来统计各个Segment大小,如果统计过程中count发生了变化,则再采用加锁的方式(统计size的时候把所有Segment的put、remove、clean方法全部锁住)来统计所有Segment的大小。

    • ConcurrentHashMap如何判断在统计的时候容器是否发生了变化呢? 使用modCount变量,在put、remove和clean方法里操作元素前都会将变量modCount进行加1,那么在统计size前后比较modCount是否发生变化,从而得知容器的大小是否发生变化。

    6.2  ConcurrentLinkedQueue(非阻塞的线程安全队列)

      实现一个线程安全的队列有两种方式:

    • 使用阻塞方法:用一个锁(入队和出队用同一把锁)或者用两个锁(入队和出队用不同的锁)等方式实现。
    • 使用非阻塞的方法:使用循环CAS。

      ConcurrentLinkedQueue是一个基于链接结点的无界线程安全队列,采用“先进先出”规则对节点进行排序。它采用了“wait-free”算法(即CAS算法)来实现。

    6.3  阻塞队列

      阻塞队列(BlockingQueue)是一个支持两个附加操作的队列。这两个附加的操作支持阻塞的插入和移除方法

    • 支持阻塞的插入方法:当队列满时,队列会阻塞插入元素的线程,直到队列不满。
    • 支持阻塞的移除方法:当队列空时,获取元素的线程会等待队列变为非空。

      阻塞队列常用于生产者和消费者的场景:生产者是向队列里添加元素的线程,消费者是从队列里取元素的线程。阻塞队列就是生产者用来存放元素、消费者用来获取元素的容器

      在阻塞队列不可应时,这两个附加操作(插入和移除)的4种处理方式:

    方法/处理方式抛出异常返回特殊值一直阻塞超时退出
    插入方法 add(e) offer(e) put(e) offer(e, time, unit)
    移除方法 remove() poll() take() poll(time, unit)
    检查方法 element() peek() 不可用 不可用
    • 抛出异常    :当队列满时,如果再往队列里插入元素会抛出IllegalStateException("Queue full")异常。当队列空时,从队列里获取元素会抛出NoSuchElementException异常。
    • 返回特殊值:当往队列插入元素时,会返回元素是否插入成功,成功返回true。如果是移除方法,则从队列里取出一个元素,如果没有则返回null。
    • 一直阻塞   :当阻塞队列满时,如果生产者线程往队列里put元素,队列会一直阻塞生产者线程,直到队列可用或者响应中断退出。当队列空时,如果消费者线程从队列列take元素,队列会阻塞消费者线程,直到队列不为空。
    • 超时退出   :当阻塞队列满时,如果生产者线程往队列里插入元素,队列会阻塞生产者线程一段时间,如果超时则退出。

    【注】:如果是无界阻塞队列,队列不可能会出现满的情况,所以使用put或offer方法永远不会被阻塞,而且使用offer方法时,该方法永远返回true。

    JDK7提供了7个阻塞队列:

    • ArrayBlockingQueue   :数组结构组成的有界阻塞队列,按FIFO原则对元素进行排序。
    • LinkedBlockingQueue :链表结构组成的有界阻塞队列,默认和最大长度为Integer.MAX_VALUE,按FIFO原则对元素进行排序。
    • PriorityBlockingQueue:支持优先级的无界阻塞队列,默认情况下元素采取自然顺序升序排列。不保证同优先级元素的顺序
    • DelayQueue                  :支持延时获取元素的无界阻塞队列。队列使用PriorityQueue实现,队列中的元素必须实现Delayed接口,在创建元素时可以指定多久才能从队列中获取当前元素。只有在延迟期满时才能从队列中提取元素。可应用于:
      • 缓存系统的设计:用DelayQueue保存缓存元素的有效期,使用一个线程循环查询DelayQueue,一旦能从DelayQueue中获取元素表示缓存有效期到了。
      • 定时任务调度:使用DelayQueue保存当天将会执行的任务和执行时间,一旦从DelayQueue中获取到任务就开始执行,比如TimerQueue就是使用DelayQueue实现的。
    • SynchronousQueue     :不存储元素的阻塞队列。每一个put操作必须等待一个take操作,否则不能继续添加元素。
    • LinkedTransferQueue  :链表结构组成的无界阻塞TransferQueue队列。相对于其他阻塞队列,LinkedTransferQueue多了tryTransfer和transfer方法。
      • transfer方法:如果当前有消费者正在等待接收元素,transfer方法可以把生产者传入的元素立刻传给消费者。如果没有消费者在等待接收元素,则将元素存放在队列tail节点并等到钙元素被消费者消费了才返回。
      • tryTransfer方法:如果没有消费者等待接收元素,则立即返回false。
    • LinkedBlockingDeque :链表结构组成的双向阻塞队列。可以从队列的两端插入和移出元素。

    阻塞队列的实现原理:

      如果队列是空的,消费者会一直等待,当生产者添加元素时,消费者是如何知道当前队列有元素的呢?

      使用通知模式实现。就是当生产者往满的队列里添加元素时会阻塞住生产者,当消费者消费了一个队列中的元素后,会通知生产者当前队列可用。

    6.4  Fork/Join框架

    • Fork/Join框架是一个用于并行执行任务的框架,是一个把大任务分隔成若干个小任务,最终汇总每个小任务结果后得到大任务结果的框架
    • 工作窃取算法:是指某个线程从其他队列里窃取任务拉执行。假如我们需要做一个比较大的任务,我们可以把这个任务分割为若干互不依赖的子任务,为了减少线程间的竞争,于是把这些子任务分别放到不同的队列里,并为每个队列创建一个单独的线程来执行队列里的任务,线程和队列一一对应,比如A线程负责处理A队列里的任务。但是有的线程会先把自己队列里的任务干完,而其他线程对应的队列里还有任务等待处理。干完活的线程与其等着,不如去帮其他线程干活,于是它就去其他线程的队列里窃取一个任务来执行。而在这时它们会访问同一个队列,所以为了减少窃取任务线程和被窃取任务线程之间的竞争,通常会使用双端队列,被窃取任务线程永远从双端队列的头部拿任务执行,而窃取任务的线程永远从双端队列的尾部拿任务执行。
      • 工作窃取算法的优点是充分利用线程进行并行计算,并减少了线程间的竞争,其缺点是在某些情况下还是存在竞争,比如双端队列里只有一个任务时。并且消耗了更多的系统资源,比如创建多个线程和多个双端队列。

    Fork/Join框架的设计

      步骤1:分割任务。首先需要有一个fork类来把大任务分割成子任务,有可能子任务还是很大,所以还需要不停地分割,直到分割出的子任务足够小。

      步骤2:执行任务并合并结果。分割的子任务分别放在双端队列里,然后几个启动线程分别从双端队列里获取任务执行。子任务执行完的结果都统一放在一个队列里,启动一个线程从队列里拿数据,然后合并这些数据。

      Fork/Join使用两个类来完成以上两件事情:

    • ForkJoinTask:我们要使用ForkJoin框架,必须首先创建一个ForkJoin任务。它提供在任务中执行fork()和join()操作的机制,通常情况下我们不需要直接继承ForkJoinTask类,而只需要继承它的子类,Fork/Join框架提供了以下两个子类:
      • RecursiveAction:用于没有返回结果的任务。
      • RecursiveTask :用于有返回结果的任务。
    • ForkJoinPool :ForkJoinTask需要通过ForkJoinPool来执行。
      • ForkJoinPool由ForkJoinTask数组和ForkJoinWorkerThread数组组成,ForkJoinTask数组负责将存放程序提交给ForkJoinPool的任务,而ForkJoinWorkerThread数组负责执行这些任务。

      任务分割出的子任务会添加到当前工作线程所维护的双端队列中,进入队列的头部。当一个工作线程的队列里暂时没有任务时,它会随机从其他工作线程的队列的尾部获取一个任务。

    Fork/Join框架的异常处理

      ForkJoinTask在执行的时候可能会抛出异常,但是我们没办法在主线程里直接捕获异常,所以ForkJoinTask提供了isCompletedAbnormally()方法来检查任务是否已经抛出异常或已经被取消了,并且可以通过ForkJoinTask的getException方法获取异常。其中,getException方法返回Throwable对象,如果任务被取消了则返回CancellationException,如果任务没有完成或者没有抛出异常则返回null。

    第7章  Java中的13个原子操作类

      当程序更新一个变量时,如果多线程同时更新这个变量,可能得到期望值之外的值。通常我们使用sychronized来解决这个问题,sychronized会保证多线程不会同时更新同一个变量。

      而Java从JDK1.5开始提供了java.util.concurrent.atomic包,包中的原子操作类提供了一种用法简单、性能高效、线程安全地更新一个变量的方式。一共提供了13个类,属于4种类型的原子更新方式,分别是原子更新基本类型、原子更新数组、原子更新引用和原子更新属性(字段)。

    (1)原子更新基本类型

    • AtomicBoolean :原子更新布尔类型
    • AtomicInteger: 原子更新整型
    • AtomicLong: 原子更新长整型

    (2)原子更新数组

    • AtomicIntegerArray :原子更新整型数组里的元素
    • AtomicLongArray :原子更新长整型数组里的元素
    • AtomicReferenceArray : 原子更新引用类型数组的元素

    (3)原子更新引用类型

    • AtomicReference :原子更新引用类型
    • AtomicReferenceFieldUpdater :原子更新引用类型里的字段
    • AtomicMarkableReference:原子更新带有标记位的引用类型。可以原子更新一个布尔类型的标记位和应用类型

    (4)原子更新字段类

    • AtomicIntegerFieldUpdater:原子更新整型的字段的更新器
    • AtomicLongFieldUpdater:原子更新长整型字段的更新器
    • AtomicStampedReference:原子更新带有版本号的引用类型。该类将整型数值与引用关联起来,可用于原子的更新数据和数据的版本号,可以解决使用CAS进行原子更新时可能出现的ABA问题。

    【注】要想原子地更新字段类需要2步。第一步:因为原子更新字段类都是抽象类,每次使用的时候必须使用静态方法newUpdate()创建一个更新器,并且需要设置想要更新的类和属性。第二步:更新类的字段(属性)必须使用public volatile修饰符。

     第8章 Java中的并发工具类

    (1)等待多线程完成的CountDownLatch

      CountDownLatch允许一个或多个线程等待其他线程完成操作

      要实现主线程等待所有线程完成某个操作,最简单的做法是使用join()方法。join()用于让当前执行线程等待join线程执行结束。其实现原理是不停检查join线程是否存活,如果join线程存活则让当前线程永远等待。直到join线程中止后,线程的this.notifyAll()方法会被调用。

      在JDK1.5之后的并发包中提供的CountDownLatch也可以实现join的功能。

    • CountDownLatch的构造函数接收一个int类型的参数作为计数器,如果你想等待N个点(N个线程)完成,就传入N。  static CountDownLatch c = new CountDownLatch(2);
    • 每次调用CountDownLatch的countDown方法时,N就减1,CountDownLatch的await方法会阻塞当前线程,直到N变成0。

    (2)同步屏障CyclicBarrier

      CyclicBarrier的作用是:让一组线程到达一个屏障(也可以称之为同步点)时被阻塞,直到最后一个线程到达屏障时,屏障才会开门,所有被屏障拦截的线程才能继续运行。

    • CyclicBarrier(int parties)构造函数接收一个int参数用来设置拦截线程的数量,还有一个更高级的构造函数CyclicBarrier(int parties, Runnable barrierAction),用于在线程到达屏障时,优先执行barrierAction。  static CyclicBarrier c = new CyclicBarrier(2,new A);

    CountDownLatch与CyclicBarrier的区别:

      CountDownLatch的计数器只能使用1次,而CyclicBarrier的计数器可以使用reset()方法重置。(所以CyclicBarrier能处理更为复杂的业务场景。例如,如果计算发生错误,可以重置计数器,并让线程重新执行1次。)

    (3)控制并发线程数的Semaphore

      Semaphore(信号量)用来控制同时访问特定资源的线程数量,通过协调各个线程以保证合理的使用公共资源。 

      应用场景:可以用于做流量控制,特别是共有资源有限的应用场景,比如数据库连接。

    • Semaphore(int permits)构造方法接收一个int参数,表示可用的许可证数量。
    • 每次线程使用Semaphore的acquire()方法获取一个许可证,用完后调用release()方法归还

    (4)线程间交换数据的Exchanger

      Exchanger(交换者)是一个用于线程间协作的工具类。用于进行线程间的数据交换。 它提供一个同步点,在这个同步点,两个线程可以交换彼此的数据。这两个线程通过exchange方法交换数据,如果第一个线程先执行exchange()方法,它会一直等待第二个线程也执行exchange()方法,当两个线程到达同步点时,这两个线程就可以交换数据,将本线程生产出来的数据传递给对方。

      如果两个线程有一个没有执行exchange()方法,则会一直等待,如果担心有特殊情况发生,避免一直等待,可以使用exchange(V x, longtimeout , TimeUnit unit)设置最大等待时长。

  • 相关阅读:
    Java High Level REST Client 中文API(仅供参考)
    3.2_springBoot2.1.x检索之JestClient操作ElasticSearch
    3.1_springboot2.x检索之elasticsearch安装&快速入门
    2.2_springboot2.x消息RabbitMQ整合&amqpAdmin管理组件的使用
    2.1_springboot2.x消息介绍&RabbitMQ运行机制
    1.2_springboot2.x中redis缓存&原理介绍
    1.1_springboot2.x与缓存原理介绍&使用缓存
    JavaWeb-----Get请求和Post请求
    Java基础(basis)-----TCP通信
    Java基础(basis)-----泛型详解
  • 原文地址:https://www.cnblogs.com/toria/p/bingfa678.html
Copyright © 2020-2023  润新知