Java多线程番外篇

并发编程存在的挑战

  1. 上下文切换导致多线程不一定比串行更快。
  2. 死锁问题。
  3. 资源限制(硬件资源以及数据库连接数,socket连接数等软件资源)。

并发编程的底层原理

volatile关键字

  • 禁重排:volatile本身可以禁止指令重排。在一定程度上保证线程安全。
  • 保可见:主要作用是使变量在多个线程之间可见,强制从主内存中去更新变量值而不是直接去寄存器中取。参见java内存模型
  • 不支持原子性:但volatile最主要的缺点是不支持原子性(例如i++三步走问题:i=i+1,先取i 再加1 最后赋值给公共主内存的i)。java.util.concurrent.atomic包中的原子类或者synchronized可以解决原子性问题,但是synchronized有点大材小用了,juc原子类更完美,底层实现原理是CAS。
    可见性图片描述

CAS

CAS指令(compare-and-swap):属于乐观策略,一条处理器指令,保证语义上的多个操作,实现了操作的原子性,jdk1.5之后可以使用CAS操作:

  • JUC包中的原子类底层实现方式就是CAS操作;
  • 原子类底层调用了unsafe类,unsafe类在rt.jar包中,它的大多数方法都是native方法,用于跟c语言交互,操作底层的地址偏移量;
  • 根据底层地址获取当前值,进行do-while循环,从而完成compareAndSwap,真实值与期望值相等就执行,否则再循环获取最新值(就是自旋),从而保证原子性;
  • cas操作对比synchronized的优点是:没有降低并发性,因为它没有加锁,而是不断自旋获取最新值;
  • cas操作的缺点有三:线程很多的情况下,循环时间会很长,资源开销大;只能保证一个共享变量的原子操作;存在ABA问题;
  • 如何解决ABA问题呢:JUC包中有一个原子版本号引用类java.util.concurrent.atomic.AtomicStampedReference,它不只是记录了当前值,还记录了当前值的版本号,解决了ABA问题。

java中锁的几种机制

首先说明并发策略分为乐观与悲观,常见的synchronized ReentrantLock就是悲观的,因此叫互斥同步;而乐观策略是指直接执行操作,没有共享冲突,成功,产生冲突,补偿措施(重试等),因此叫非阻塞同步。
再解释什么是CAS指令(compare-and-swap):属于乐观策略,一条处理器指令,保证语义上的多个操作,实现了操作的原子性。jdk1.5之后可以使用CAS操作,JUC包中的整数原子类的一些方法就运用了CAS操作。

  • 公平锁与非公平锁:公平锁表示线程获取锁的顺序与加锁的顺序相同,synchronized是非公平锁,获取锁不是按顺序,ReentrantLock默认也是非公平,传入参数true可以变为公平锁。
  • 可重入锁/递归锁:同一个线程,外层函数获取锁,内层函数也可以获取该锁的代码块,就是说同一个线程,获取锁了,就可以递归使用,因此又叫递归锁,ReentrantLock,synchronized都可重入,可重入锁最大作用是避免死锁。
  • 自旋锁/自适应自旋锁:一般情况下,锁定状态很短,为了这段时间去挂起并恢复线程并不值得,因此不妨执行自旋操作,默认10次自旋。自适应是指一个“训练过程”,根据以前的锁状态决定自旋时间。(手写一个自旋锁)
  • 读写锁:读读共享,其它互斥。
  • 偏向锁:在无竞争条件下,消除整个同步,CAS也不做。锁会偏向于第一个获得它的线程,如果接下来没有其他线程获取,则该线程将永远不再同步。偏向锁是在只有一个线程执行同步块时进一步提高性能。
  • 轻量级锁:轻量级锁是指在无竞争条件下使用CAS(compare and swap)消除同步使用互斥量。轻量级锁所适应的场景是线程交替执行同步块的情况。
  • 互斥锁/重量级锁:synchronized ReentrantLock。

Java内存模型(JMM)

  • java内存模型(JMM):JMM控制java线程之间的通信,决定了一个线程对共享变量的写入何时对另一个线程可见,抽象来看,JMM定义了线程与主内存之间的抽象关系,属于语言级别的内存模型,确保在不同的编译器与处理器平台之上,禁止特定类型的编译器重排序与处理器重排序,为程序提供一致的内存可见性保证。
    java内存模型抽象结构
  • happen-before:一个操作的结果要对另一个操作可见就存在happen-before关系。它简单易懂,一个happen-before规则对应多个编译器与处理器重排序规则,屏蔽掉了程序员必须学习重排序的复杂性。
//举个例子:必须先初始化 a b 再使用,这就是happen-before规则; 但是先初始化 a b 都有可以,那么编译后就可能对它们进行指令重排
int a = 0;
int b = 1;
int c = a;
int d = b;
  • 指令重排:是指编译器与处理器为了程序性能而对指令序列重新排序(指令重排对计算性能有很大帮助,单线程也不会存在问题,但是多线程下的指令重排可能会导致数据错乱)。volatile关键字本身就包含了禁止指令重排的语义;final也有其相应的指令重排规则。
    以上三者关系图
  • 延迟初始化与双向检查锁定:有时需要推迟一些高开销对象初始化的操作,用的时候在初始化。懒汉单例模式就是延迟初始化呀。这是就会出现线程安全问题,而双重检查锁定是一种典型的错误优化方式。

JUC包的实现原理图

JUC包的实现

  • 上图中的AQS:AbstractQueuedSynchronizer抽象队列同步器,定义了一套多线程访问共享资源的同步器框架,许多同步类实现都依赖于它,如常用的ReentrantLock/Semaphore/CountDownLatch等等,它维护了一个volatile类型的共享资源和一个FIFO(先进先出)线程等待队列(多线程争用资源被阻塞时会进入此队列),这里volatile保证线程间可见性。
    AQS原理
  • CLH锁也是一种基于链表的可扩展、高性能、公平的自旋锁,申请线程只在本地变量上自旋,它不断轮询前驱的状态,如果发现前驱释放了锁就结束自旋。
  • AQS维护了一个 volatile int state(代表共享资源)和一个 FIFO 线程等待队列(多线程争用资源被阻塞时会进入此队列),state 的访问方式有三种:getState()、setState()、compareAndSetState()
  • AQS 定义两种资源共享方式:Exclusive(独占,只有一个线程能执行,如 ReentrantLock);Share(共享,多个线程可同时执行,如 Semaphore/CountDownLatch)
  • 以 ReentrantLock 为例,state 初始化为 0,表示未锁定状态。A 线程lock()时,会调用 tryAcquire()独占该锁并将 state+1。此后,其他线程再 tryAcquire()时就会失败,直到 A 线程 unlock()到 state=0(即释放锁)为止,其它线程才有机会获取该锁。当然,释放锁之前,A 线程自己是可以重复获取此锁的(state 会累加),这就是可重入的概念。但要注意,获取多少次就要释放多么次,这样才能保证 state 是能回到零态。
  • 以 CountDownLatch 以例,任务分为 N 个子线程去执行,state 也初始化为 N(注意 N 要与线程个数一致)。这 N 个子线程是并行执行的,每个子线程执行完后countDown()一次,state会 CAS 减 1。等到所有子线程都执行完后(即 state=0),会 unpark()主调用线程,然后主调用线程就会从 await()函数返回,继续后余动作。
  • 一般来说,自定义同步器要么是独占方法,要么是共享方式,他们也只需实现 tryAcquire-tryRelease、tryAcquireShared-tryReleaseShared 中的一种即可。但 AQS 也支持自定义同步器同时实现独占和共享两种方式,如 ReentrantReadWriteLock。

JUC

并发容器

ArrayList/CopyOnWriteArrayList

ArrayList是线程不安全的(多线程下常见报错:java.util.ConcurrentModificationException),底层是数组存储结构,默认长度是10,有自己的扩容策略;

  • 用Collections.synchronizedList(ArrayList)可以保证线程安全;
  • 使用JUC中的CopyOnWriteArrayList性能更好,原理是读写分离,当有线程写的时候,复制一份,写完放回去,其他线程就可以用了。
HashSet/CopyOnWriteArraySet
  • HashSet是线程不安全的(多线程下常见报错:java.util.ConcurrentModificationException,底层是就是HashMap,只是对应的value是个Object类型的常量,默认长度是16,有自己的扩容策略,系数factor=0.75;
  • 用Collections.synchronizedSet(HashSet)可以保证线程安全;
  • 使用JUC中的CopyOnWriteArraySet性能更好,原理是读写分离,当有线程写的时候,复制一份,写完放回去,其他线程就可以用了,其实CopyOnWriteArraySet的底层就是CopyOnWriteArrayList。
HashMap/HashTable/ConcurrentHashMap
  • HashMap是线程不安全的(多线程下常见报错:java.util.ConcurrentModificationException)。
  • 想让HashMap变得线程安全有三种方法:
    • 使用线程安全的HashTable。
    • 使用Collections.synchronizedMap(HashMap),其实HashTable的底层实现原理也是使用synchronized。
    • 使用JUC中的ConcurrentHashMap性能更好。
  • ConcurrentHashMap 是由 Segment 数组结构和 HashEntry 数组结构组成。Segment 是一种可重入锁 ReentrantLock,在 ConcurrentHashMap 里扮演锁的角色,HashEntry 则用于存储键值对数据。一个 ConcurrentHashMap 里包含一个 Segment 数组,Segment 的结构和 HashMap类似,是一种数组和链表结构, 一个 Segment 里包含一个 HashEntry 数组,每个 HashEntry 是一个链表结构的元素, 每个 Segment 守护一个 HashEntry 数组里的元素,当对 HashEntry 数组的数据进行修改时,必须首先获得它对应的 Segment 锁。
  • ConcurrentHashMap,它内部细分了若干个小的 HashMap,称之为段(Segment)。默认情况下一个 ConcurrentHashMap 被进一步细分为 16 个段,既就是锁的并发度。如果需要在 ConcurrentHashMap 中添加一个新的表项,并不是将整个 HashMap 加锁,而是首先根据 hashcode 得到该表项应该存放在哪个段中,然后对该段加锁,并完成 put 操作。在多线程环境中,如果多个线程同时进行 put 操作,只要被加入的表项不存放在同一个段中,则线程间可以做到真正的并行。
  • 在 Java 1.8 中,ConcurrentHashMap 的实现进行了重大改进,采用了全新的设计思路。它不再使用分段锁的策略,而是采用了 CAS(Compare-and-Swap)操作和红黑树等并发控制技术,实现了完全的无锁化设计。其实HashMap与ConcurrentHashMap在jdk1.8都引入了红黑树,所以其由 数组+链表+红黑树 组成,根据 Java7 HashMap 的介绍,我们知道,查找的时候,根据 hash 值我们能够快速定位到数组的具体下标,但是之后的话,需要顺着链表一个个比较下去才能找到我们需要的,时间复杂度取决于链表的长度,为 O(n)。为了降低这部分的开销,在 Java8 中,当链表中的元素超过了 8 个以后,会将链表转换为红黑树,在这些位置进行查找的时候可以降低时间复杂度为 O(logN)。
    ConcurrentHashMap结构图
  • 回顾下HashMap的扩容策略:capacity:当前数组容量默认16,始终保持 2^n,可以扩容,扩容后数组大小为当前的 2 倍; loadFactor:负载因子,默认为 0.75;threshold:扩容的阈值为 capacity * loadFactor。因此为了避免多次扩容,应尽量初始化集合的大小:initialCapacity = (需要存储的元素个数 / 负载因子) + 1
ConcurrentLinkedQueue(无界)
  • 为什么有它?线程安全的队列有两种实现办法,一种是阻塞算法进行同步处理,另一种是非阻塞循环CAS算法,效率更好,这就是ConcurrentLinkedQueue。
  • 结构是啥?它由head节点和tail节点组成,每一个节点由item元素值和next指针组成,默认情况下item为空,tail等于head。
  • 怎么使用?请多看API文档。

阻塞队列

  • 为什么有它?常用于生产者消费者的场景,生产者是向队列中添加元素的线程,消费者是从队列中取出元素的线程,阻塞队列就作为元素的缓冲容器,有了阻塞队列,我们就不用关心何时阻塞线程,何时唤醒线程,交给阻塞队列自己协调就好了;
  • 为什么阻塞?当队列为null,消费者获取元素就阻塞,当队列满,生产者插入元素就阻塞;
  • 消息中间件底层原理就是阻塞队列。
  • juc提供了7个类型的阻塞列,线程池的底层使用了其中三个重点的:ArrayBlockingQueue(数组组成的有界阻塞队列)、LinkedBlockingQueue(链表组成的有界阻塞队列,默认大小是2^31-1,因此一定要初始化队列大小)、SynchronousQueue(不存储元素的阻塞队列,即单个元素的队列,生产一个,消费一个:我们可以用来线程间安全的交换单一元素。所以功能比较单一,优势在于轻量)
  • ArrayBlockingQueue:用数组实现的有界阻塞队列。此队列按照先进先出(FIFO)的原则对元素进行排序。默认情况下不保证访问者公平的访问队列,所谓公平访问队列是指阻塞的所有生产者线程或消费者线程,当队列可用时,可以按照阻塞的先后顺序访问队列,即先阻塞的生产者线程,可以先往队列里插入元素,先阻塞的消费者线程,可以先从队列里获取元素。通常情况下为了保证公平性会降低吞吐量,可以通过参数制定一个公平的阻塞队列。
  • LinkedBlockingQueue:基于链表的阻塞队列,同ArrayListBlockingQueue 类似,此队列按照先进先出(FIFO)的原则对元素进行排序。而 LinkedBlockingQueue 之所以能够高效的处理并发数据,还因为其对于生产者端和消费者端分别采用了独立的锁来控制数据同步,这也意味着在高并发的情况下生产者和消费者可以并行地操作队列中的数据,以此来提高整个队列的并发性能。
  • SynchronousQueue:是一个不存储元素的阻塞队列。每一个 put 操作必须等待一个 take 操作,否则不能继续添加元素。SynchronousQueue 可以看成是一个传球手,负责把生产者线程处理的数据直接传递给消费者线程。队列本身并不存储任何元素,非常适合于传递性场景,比如在一个线程中使用的数据,传递给另外一个线程使用,SynchronousQueue 的吞吐量高于LinkedBlockingQueue 和ArrayBlockingQueue。

并发框架

  • 什么是Fork/Join框架:jdk7提供的一个用于并行执行任务的框架,把一个大任务分割成若干个小任务,再汇总小任务。
  • 什么是工作窃取算法:一个线程从其它队列中窃取任务来执行。如果一个大任务分成了若干个小任务,每个几个小任务放在一个队列中,每一个队列分配一个线程去执行其中的小任务,于是干完活的线程就可以窃取其他任务,为了减少冲突,使用双端队列,从后门窃取。
  • 使用Fork/Join框架:看API文档去吧…

并发原子类

  • java.util.concurrent.atomic包中,为了提供一种用法简单,性能高效,线程安全的更新一个变量的方式。
  • 针对四种类型进行原子更新:基本类型,数组类型,引用类型,属性(字段)类型。
  • 基本类型的并发原子类有ABA问题,时间戳原子引用类可以解决,参考上面的CAS章节。
  • 注意:多个addAndGet在一个方法内是原子性的,需要加synchronized进行修饰,保证多个addAndGet整体原子性

并发工具类

  • CountDownLatch:是join()方法的加强。join()方法的原理其实就是不停的检查join线程是否存活,如果存活,当前线程就永远等待,join线程停止,就会调用this.notifyAll(),在JVM源码中,jdk源码看不到。而CountDownLatch提供n个点,每调用一次countdown计数器减一,计数器为0时,await方法就不会阻塞当前线程了。
  • CyclicBarrier:(可循环使用的屏障)让一组线程都到达了这个屏障,然后再一起执行,它比CountDownLatch更适合复杂的业务场景,可以重新计数,并让线程重新执行一次,也可以获得被阻塞的线程数量。
  • Semaphore:(信号量)控制同时访问特定资源的线程数量,通过协调各个线程,以保证合理的使用公共资源,用作流量控制。
  • Exchanger:线程间协作,提供一个同步点,两个线程可以交换数据,通过exchange方法交换数据,先到达exchange的线程会等另一个线程也到达同步点,然后交换。

Executor框架(线程池学习)

Java的线程既是工作单元也是执行机制,从jdk5开始,将两者分离了,工作单元包括Runnable和Callable,而执行机制由Executor框架实现。
上五张图

  • 任务的两级调度模型:
    任务的两级调度模型

  • Executor接口的类图:
    Executor类图

  • Executor框架使用图如下:其中生产环境中线程池不要通过工厂类Executors来创建,尽管它的所有方法都是静态方法,直接类名点调用很方便(阿里开发手册说的)。

  • 我们应该用ThreadPoolExecutor自己定义参数:那么合理的参数是?主要参数是看线程池最大线程数:如果任务是cup密集型(不应该有太多线程的切换,主要是计算就好了):cpu核数+1;如果是io密集型任务(不用太多的计算,所以线程数可以多一点):cup核数*2;

  • 补充一下数据库连接池大小公式:连接数 = ((cpu核心数 * 2) + 有效磁盘数)

  • 如果任务性质是CPU密集型的,并且任务量非常大,那么将核心线程数和最大线程数设置为相等可以充分利用CPU资源,并快速处理请求。在这种情况下,线程池可以随着任务的到来而快速扩展,并在任务完成后快速缩减,从而避免了线程资源的浪费。

  • 然而,如果任务性质是I/O密集型的,或者系统负载较高,那么将核心线程数和最大线程数设置为相等可能会导致过多的线程竞争系统资源,从而影响性能。在这种情况下,可以考虑将最大线程数设置为比核心线程数稍大的值,以允许线程在等待I/O操作完成时不会立即被销毁。
    Executor框架使用图

  • 线程池实现原理图:
    线程池实现原理图

  • 根据上面的原理再看一下具体的类 ThreadPoolExecutor的执行示意图:(分为四步)
    ThreadPoolExecutor执行示意图

  1. corePoolSize:指定了线程池中的线程数量。
  2. maximumPoolSize:指定了线程池中的最大线程数量。
  3. keepAliveTime:当前线程池数量超过corePoolSize时,最大线程池中多余的空闲线程的存活时间。核心线程将会从队列中拉取任务,因此核心线程之外的线程可能执行完成处于空闲状态。
  4. unit:keepAliveTime 的单位。
  5. workQueue:任务队列,被提交但尚未被执行的任务。
  6. threadFactory:线程工厂,用于创建线程,一般用默认的即可。
  7. handler:拒绝策略,当任务太多来不及处理,如何拒绝任务。线程池的默认拒绝策略是:上图中的AbortPolicy,直接拒绝抛异常,就是说当线程数大于最大线程池数+阻塞队列长度时,抛异常。

线程池拒绝策略

  1. AbortPolicy : 直接抛出异常,阻止系统正常运行。
  2. CallerRunsPolicy : 只要线程池未关闭,该策略直接在调用者线程中,运行当前被丢弃的 任务。显然这样做不会真的丢弃任务,但是,任务提交线程的性能极有可能会急剧下降。
  3. DiscardOldestPolicy : 丢弃最老的一个请求,也就是即将被执行的一个任务,并尝试再 次提交当前任务。
  4. DiscardPolicy : 该策略默默地丢弃无法处理的任务,不予任何处理。如果允许任务丢 失,这是最好的一种方案。
  5. 以上内置拒绝策略均实现了 RejectedExecutionHandler 接口,若以上策略仍无法满足实际完全可以自己扩展 RejectedExecutionHandler 接口。
import lombok.extern.slf4j.Slf4j;

import java.util.concurrent.RejectedExecutionHandler;
import java.util.concurrent.ThreadPoolExecutor;

@Slf4j
public class BlockingPolicy implements RejectedExecutionHandler{
    @Override
    public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) {
        try {
            // blockingqueue的offer改成put阻塞方法,BlockingQueue的几个方法:
            // add() 将指定的元素插入到此队列中(如果立即可行且不会违反容量限制),在成功时返回 true,如果当前没有可用空间,则抛出 IllegalStateException。
            // offer()将指定元素插入到此队列的尾部(如果立即可行且不会超出此队列的容量),在成功时返回 true,如果此队列已满,则返回 false
            // put()将指定元素插入到此队列的尾部,如有必要,则等待空间变得可用。
            executor.getQueue().put(r);
        } catch (InterruptedException e) {
            log.error("reject handler exception ", e);
            Thread.currentThread().interrupt();
        }
    }
}

生产者与消费者模式

  • 上面提到了生产者消费者的场景,生产者是向队列中添加元素的线程,消费者是从队列中取出元素的线程,作为第三者插足的阻塞队列就作为元素的缓冲容器,以解决生产消费不均衡的问题。纵观大多数的设计模式,都有第三者插足来进行解耦,工厂模式的第三者是工厂类,模板模式的第三者是模板类…
  • 线程池类其实就是一种生产者消费者模式的实现。而且实现方式更加高明,因为它不只是使用了一个阻塞队列,还设置了线程池的基本线程数,如果将要运行的任务数大于线程池的基本线程数才把任务扔进阻塞队列,这样处理速度更快。
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值