Java并发编程与高并发面试
一、课程准备
1.1 课程导学
-
本课程主要是围绕并发编程和高并发解决方案两个核心来进行讲解;
-
希望这门课程能够带领大家攻克并发编程与高并发的难题;
-
课程特点:
- 大量的图示及代码演示;
- 全面覆盖并发知识点,建立完整的知识体系,主要有:线程安全、线程封闭、线程调度、同步容器、并发容器、AQS、J.U.C等等;
- 高并发的解决方案与思路主要有:扩容、缓存、队列、拆分、服务降级与熔断、数据库切库、分库分表等等,通过以上帮助你构建完整的并发与高并发知识体系。
- 贴近面试,提高高薪面试成功率
-
适合人群:
- 对并发和高并发不了解的同学
- 对并发和高并发了解的同学
- 已经是编程高手的同学,跳槽
-
学习收获:
- 系统的学习到并发编程的知识以及高并发处理思路
- 修正之前在不知不觉中犯过的一些并发方面的问题
- 规避以后开发中一些并发方面的问题
- 对你的知识进行依次更为全面的梳理,完善知识体系。
- 学习到大量的实际场景案例分析和代码优化技巧
- 让你对并发编程和高并发处理有一个质的提升
- 将节省你准备面试的时间,让你的面试更有针对性
- 可以借鉴一些之前可能没有想到过的解决问题思路和手段
-
讲解内容步骤:
- 基础知识讲解与核心知识准备:
- 并发及并发的线程安全处理
- 高并发处理的思路及手段
- 基础知识讲解与核心知识准备:
-
涉及到的一些知识技能:
- 总体架构:SpringBoot、Maven、Jdk8、MySQL
- 基础组件:Mybatis、Guava、Lombok、Redis、Kafka
- 高级组件(类):Joda-Time、Atomic包、J.U.C、AQS、ThreadLocal、RateLimiter、Hystrix、threadPool、shardbatis、curator、elastic-job…
1.2 并发编程初体验
-
最简单的并发编程案例:实现一个计数功能(接下来我们使用2个例子来初次体验并发编程)
- CountExample.java:
@Slf4j public Class CountExample{ private static int threadTotal = 200; private static int clientTotal = 5000; private static long count = 0; public static void main(String[] args){ ExecutorService exec = Executors.newCachedThreadPool(); final Semaphore semaphore =new Semaphore(threadTotal); for (int index =0; index < clientTotal; index ++){ exec.execute(()->{ try{ semaphore.acquire(); add(); semaphore.release(); } catch (Exception e){ log.error("exception",e); } }); } exec.shutdown(); log.info("count:{}",count); } private static void add(){ count++; } }
5000个请求,每次只允许200个线程同时执行,打印出通过的总次数;会发现小于5000且每次的值都不一样;
- MapExample.java
@Slf4j public Class MapExample{ private static Map<Integer,Integer> map = Maps.newHashMap(); private static int threadNum = 200; private static int clientNum= 5000; public static void main(String[] args){ ExecutorService exec = Executors.newCachedThreadPool(); final Semaphore semaphore =new Semaphore(threadNum); for (int index =0; index < clientNum; index ++){ final int threadNum = index; exec.execute(()->{ try{ semaphore.acquire(); func(threadNum); semaphore.release(); } catch (Exception e){ log.error("exception",e); } }); } exec.shutdown(); log.info("count:{}",map.size()); } private static void func(int threadNum){ map.put(threadNum,threadNum); } }
使用map来处理,每次的值都存入map中,200个线程同时运行,发现每次都是小于5000次,不能等于5000
- CountExample.java:
-
思考:
- 当我们200个线程同时运行的时候无法等于5000,但是当我们将200改为1的时候,会发现能够正确的计数到5000.说明很多时候,在单线程环境下是没有问题的,但是到了多线程环境下就容易出现问题。
1.3 并发与高并发基本概念
- 并发:同时拥有两个或者多个线程,如果程序在单核处理器上运行,多个线程将交替地换入或者换出内存,这些线程是同时“存在”的,每个线程都处于执行过程中的某个状态,如果运行在多核处理器上,此时,程序中的每个线程都将分配到一个处理器核上,因此可以同时运行。
- 高并发: 高并发(High Concurrentcy)是互联网分布式系统架构设计中必须考虑的因素之一,它通常是指,通过设计保证系统能够同时并行处理很多请求。
并发是说,多个线程操作同样的资源,保证线程安全,合理使用资源;而高并发是说服务能同时处理很多请求,提高程序性能。
二、并发基础
2.1 CPU多级缓存-缓存一致性
- 系统从最初的一级缓存进化到二级甚至三级缓存:
直接增加一级缓存的代价昂贵,所以增加多级缓存可以最大化利用资源和减少成本;
- 为什么需要CPU cache: CPU的频率太快了,快到主存跟不上,这样在处理器时钟周期内,CPU常常需要等待主存,浪费资源。所以cache的出现,是为了缓解CPU和内存之间速度的不匹配问题(结构:cpu-> cache-> memory)
- CPU cache有什么意义?
- 时间局限性:如果某个数据被访问,那么在不久的将来它很可能被再次访问;
- 空间局限性:如果某个数据被访问,那么与它相邻的数据很快也可能被访问;
- 用于保证多个CPU cache之间缓存共享数据的一致(MESI):
此协议较为复杂,具体可自行了解;
2.2 CPU多级缓存-乱序执行优化
- 乱序执行优化是指: 处理器为提高运行速度而做出违背代码原有顺序的优化。
- 图示:
我们的公式是先计算a=10,然后再计算b=200,最后再计算result= ab。可是到了计算机处理时,可能会变成:先计算b=200,再计算a=10,最后计算result=ab
- 在单核时代,不会出现此类问题。但是多核计算机,同时会有多个核执行命令,每个命令都有可能被乱序,另外处理器还引入了L1,L2等缓存机制,每个核都有自己的缓存,这就导致了逻辑上后写入的命令未必会最后写入,如果我们不做任何措施,最终可能会导致我们理想的结果和计算机计算的结果不同。
2.3 Java内存模型(Java Memory Model,简称JVM)
-
JVM是一种规范,它规范了Java虚拟机与计算机内存是如何协同工作的,它规定了一个线程如何、何时能够看到其他线程修改过的共享变量的值。以及在必须时如何同步的访问共享变量。图示如下:
-
堆(Heap): 它是一个运行时数据区,是由垃圾回收来负责的。
- 它的优势:可以动态的分配内存大小。生成期也不必事先告诉编译器,因为它是运行时动态生成的。Java的垃圾收集器会自动清理不再使用的数据。
- 它的劣势:因为是运行时动态分配的,所以它的存取速度相对慢一些。
-
栈(Stack):存取速度比堆要快,仅次于寄存器。栈内的数据是可以共享的。但是它的数据大小是确定的,缺乏灵活性,主要是存放一些基本类型和变量。比如我们小写的:int、byte、long、char等。
-
说明:
- Java内存模型要求调用栈和本地变量存放在线程栈上,对象存放在堆上。
- 一个本地变量也可能是指向一个对象的引用,只是说对象本身是存放在堆上的。而对象中本地变量依旧是存放在栈上的,即便对象是存放在堆中。
- 一个对象的成员变量可能会随着对象自身存放在堆上,不管这个变量是原始类型还是引用类型。一个对象的方法引用在栈上,方法本身所在的对象在堆上,方法内的本地变量仍然是存放在线程栈上的。
- 静态成员变量跟随着类的定义一起存放在堆上,存放在堆上的变量可以被所持有的对这个引用的线程访问。
- 当一个线程可以访问一个对象的时候,它也可以访问这个对象的成员变量。两个线程同时访问一个对象的成员变量的时候,它们都拥有了这个变量的私有拷贝。(两个线程同时访问了一个对象的一个方法,然后访问到了里面的成员变量时,它们即都拥有了对这个成员变量的私有拷贝。)
-
图示二:
-
CPU:
- 现在我们看到的是计算机硬件架构的简单图示。图中是多CPU,一个计算机一般有2个及以上的CPU,每个CPU可能还有多核心,所以在两个以上的CPU中同时运行多个线程是很普遍的,而且每个CPU在每一时刻运行一个线程也是没有问题的。
- 这就意味着,如果你的Java程序是多线程的,在Java程序中,每个CPU的线程是可以同时比并发执行的。
-
CPU Registers(寄存器):
- 每个CPU都包含一系列的寄存器,它是CPU内存的基础。CPU在寄存器上执行内存操作的速度远大于在主存上执行的速度。这是因为CPU访问寄存器的速度远大于主存。
-
CPU Cache Memory(高速缓存Cache):
- 由于计算机的存储设备与计算机的处理速度之间有着几个数量级的差异,所以现在的计算机系统都不得不加入一层读写速度为尽可能接近处理器运行速度的高级缓存来作为内存与处理器之间的缓冲。
- 将运行使用到的数据复制到缓存中,让运算能快速的进行,当运算结束后,再从缓存中同步回内存之中。这样处理器就无需等待缓慢的内存读写了。
- CPU访问Cache的速度快于访问主存的速度,但是通常小于CPU访问寄存器的速度。
- 每个CPU可能有一个CPU的缓存层,一个CPU可能有多层缓存。在某一时刻,一个或多个缓存行可能被读取到高速缓存,一个或者多个缓存行也有可能被刷新回主存,同一时间点可能会有很多操作点在里面。
-
RAM-Main Memory(主存):
- 一个计算机还有一个主存,所有的CPU都可以访问到主存,主存通常比CPU中的其他缓存要大许多。
- 通常情况下,一个CPU需要读取主存中的数据时,它会先将数据读取到CPU Cache Memory中,甚至有可能将CPU Cache Memory中的部分数据读取到CPU Registers中,然后在寄存器中执行操作。当CPU需要将结果从寄存器中回写到主存中时,它会将内部寄存器的值刷新到缓存中,然后在某个时间点刷新回主存。
-
Java内存模型与硬件资源之间的关系图示:
对于硬件而言,所有的线程栈和堆都分布在主内存里面,部分线程栈和堆可能会出现在CPU缓存中和CPU内部的寄存器中。
-
Java内存模型抽象结构图:
- 图示内容主要讲的是线程与主内存之间的抽象关系。线程之间的共享变量它存储于主内存中,每个线程都有一个私有的本地内存,而本地内存它是Java内存模型的一个抽象概念,并不是真实存在的。它涵盖了缓存、写缓冲区、寄存器以及其他的硬件和编译器的优化,本地内存中它存储了该线程读或写共享变量拷贝的副本(比如线程A要使用主内存中的某个共享变量,它就将此共享变量拷贝到自己的本地内存中,生成一个共享变量的副本)。硬件内存是为了获取更好的运行速度,虚拟机及硬件系统可能会让工作内存优先存储于寄存器和高速缓存中,Java内存模型中线程的工作内存是CPU的寄存器和高速缓存的一个抽象的描述,而JVM内存模型它只是对内存的物理划分而已,它只局限在内存,而且只局限在JVM内存。
- 线程间如果需要通信的话,它首先要先将自己本地内存中拷贝的副本先更新到主内存中的共享变量中,然后线程B再进行读取。所以如果在一个线程拷贝了共享变量时,同时第二个线程也拷贝了共享变量,且都对共享变量做了修改,那么就可能会导致数据出错。比如2个线程都执行一次+1操作,如果同时读取同时+1,则只+1了一次。解决这个问题,我们需要运用同步的方式来保证共享变量的顺序读取。
-
Java内存模型-同步八种操作
- lock(锁定):作用于主内存的变量,把一个变量标识为一条线程独占状态。
- unlock(解锁):作用于主内存的变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
- read(读取):作用于主内存的变量,把一个变量值从主内存传输到线程的工作内存中,以便随后的load动作使用。
- load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中。
- use(使用):作用于工作内存的变量,把工作内存中的一个变量值传递给执行引擎。
- assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋值给工作内存的变量。
- store(存储):作用于工作内存的变量,把工作内存中的一个变量的值传送到主内存中,以便随后的write的操作。
8.write(写入):作用于主内存的变量,它把store操作从工作内存中一个变量的值传送到主内存的变量中。
-
Java内存模型 = 同步规则
- 如果要把一个变量从主内存中复制到工作内存,就需要按顺序地执行read和load操作,如果把变量从工作内存中同步回主内存中,就要按顺序地执行store和write操作。但Java内存模型只要求上述操作必须按顺序执行,而没有保证必须是连续执行。
- 不允许read和load、store和write操作之一单独出现。
- 不允许一个线程丢弃它的最近assign的操作,即变量在工作内存中改变了之后必须同步到主内存中。
- 不允许一个线程无原因地(没有发生过任何assign操作)把数据从工作内存同步回主内存中。
- 一个新的变量只能在主内存中诞生,不允许在工作内存中直接使用一个未被初始化(load或assign)的变量。即就是对一个变量实施use和store操作之前,必须先执行过了assign和load操作。
- 一个变量在同一时刻只允许一条线程对其进行lock操作,但lock操作可以被同一条线程重复执行多次,多次执行Lock后,只有执行相同次数的unlock操作,变量才会被解锁。lock和unlock必须成对出现。
- 如果对一个变量执行lock操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前需要重新执行load或assign操作初始化变量的值。
- 如果一个变量实现没有被lock操作锁定,则不允许对它执行unlock操作;也不允许去unlock一个被其他线程锁定的变量。
- 对一个变量执行unlock操作之前,必须先把此变量同步到主内存中(执行store和write操作)
-
图示:
2.4 并发的优势与风险
- 并发模拟涉及的点:
- postman: Http请求模拟工具,可以用这个工具调用后端接口
- Apache Bench (AB): Apache附带的工具,测试网站性能
- JMeter:Apache组织开发的压力测试工具(它比AB更强大)
- 代码:Semaphore、CountDownLatch等
2.5 本章总结
- 讲了CPU多级缓存:缓存一致性、乱序执行优化
- Java内存模型:JMM规定、抽象结构、同步八种操作及规则
- Java并发的优势与风险
三、线程安全性讲解
3.1 线程安全性 - 原子性-Atomic
- 什么是线程安全性?
- 当多个线程访问某个类时,不管运行时环境采用何种调度方式或者这些进程将如何交替执行,并且在主调代码中不需要任何额外的同步或协同,这个类都能表现出正确的行为,那么就称这个类时线程安全的。
- 线程安全性主要要保证3个方面的正确:
- 原子性:提供了互斥访问,同一时刻只能有一个线程来对它进行操作
- 可见性:一个线程对主内存的修改可以及时的被其他线程观察到
- 有序性:一个线程观察其他线程中的指令执行顺序,由于指令重排序的存在,该观察结果一般杂乱无序。
- 原子性-Atomic包:
- Java原生提供了一些基础的原子类,使用原子类能够在一些场景上控制并发导致数据异常的问题。
比如使用AtomicLong进行加减,在源码上它会将期望值与结果值进行比较,只有正确了才执行任务,否则退回,类似于数据库的version乐观锁一样,只有期望版本一致才生效,可以在一些场景解决并发公共变量数据异常。
- 思考题:LongAdder与AtomicLong的区别?
- AtomicReferenceFieldUpdater:
- Atomic包:
- Java原生提供了一些基础的原子类,使用原子类能够在一些场景上控制并发导致数据异常的问题。
3.2 线程安全性 - 原子性-锁
- synchronized:依赖JVM:
- 修饰代码块、大括号括起来的代码,作用于调用的对象
- 修饰方法:整个方法,作用于调用的对象
- 修饰静态方法:整个静态方法,作用于所有对象
- 修饰类:括号括起来的部分,作用于所有对象
- 修饰代码块和修饰方法示例:
- Lock:依赖特殊的CPU指令,代码实现,ReentrantLock
- 对比:
- synchronized:不可中断锁,适合竞争不激烈,可读性好
- Lock:可中断锁,多样化同步,竞争激烈时能维持常态
- Atomic:竞争激烈时能维持常态,比Lock性能好;只能同步一个值。
3.3 线程安全性 - 可见性
-
导致共享变量在线程间不可见的原因:
- 线程交叉执行
- 重排序结合线程交叉执行
- 共享变量更新后的值没有在工作内存与主存间及时更新
-
(可见性-synchronized)JVM关于synchronized的两条规定
- 线程解锁前,必须把共享变量的最新值刷新到主内存
- 线程枷锁时,将清空工作内存中共享变量的值,从而使用共享变量时需要从主内存中重新读取最新的值(注意,加锁与解锁是同一把锁)
-
(可见性-volatile)通过加入内存屏障和禁止重排序优化来实现。
- 对volatile变量写操作时,会在写操作后加入一条store屏障指令,将本地内存中的共享变量值刷新到主内存。
- 对volatile变量读对象时,会在读操作前加入一条load屏障指令,从主内存中读取共享变量。
-
StoreStore与StoreLoad屏障:
3.4 线程安全性-有序性
-
有序性:
- Java 内存模型中,允许编译器和处理器对指令进行重排序,但是重排序过程不会影响到单线程程序的执行,却会影响到多线程并发执行的正确性。
- volatile、synchronized、Lock
-
有序性-happens-before原则
- 程序次序原则:一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作。
只能保证单线程下的有序性
- 锁定原则:一个unLock操作先行发生于后面对同一个锁的lock操作
- volatile变量规则:对一个变量的写操作先行发生于后面对这个变量的读操作
- 传递规则:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A线性发生于操作C
- 线程启动规则: Thread对象的start()方法先行发生于此线程的每一个动作
- 线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生
- 线程终结规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行
- 对象终结规则:一个对象的初始化完成先行发生于他的finalize()方法的开始
如果一个线程的有序性不能通过happens-before推导出来,那么系统就可以随意对它进行排序。
- 程序次序原则:一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作。
-
线程安全性-总结:
- 原则性:Atomic包、CAS算法、synchronized、Lock
- 可见性:synchronized、volatile
- 有序性:happens-before
四、安全发布对象讲解
4.1 发布对象
-
发布对象: 使一个对象能够被当前范围之外的代码所使用
-
对象逸出:一种错误的发布,当一个对象还没有构造完成时,就使它被其他线程所见。
-
不安全的发布对象:
- 代码示例:
- 代码解读:我们可以通过getStatus获取对上面的status的数组的引用。这样无论哪个对象都能够对这个数组进行修改,所以这个数组它是不安全的。
- 代码示例:
-
对象逸出:
- 代码示例:
- 代码解读:如图所示,一个代码要发布,应该要安全的发布。
- 代码示例:
4.2 安全发布对象
-
安全发布对象的方法:
- 在静态初始化函数中初始化一个对象的引用。
- 将对象的引用保存到volatile类型域或者AtomicReference对象中
- 将对象的引用保存到某个正确构造对象的final类型域中。
- 将对象的引用保存到一个由锁保护的域中
-
懒汉模式:
-
代码示例:
它是懒汉模式的实现,单例示例在第一次使用时进行创建。这个代码在单线程下没有问题,但它是线程不安全的,我们可以优化它,比如加锁。
-
懒汉模式做成线程安全的,我们可以加一个synchronized关键字,但是它的性能开销比较大,如图所示:
-
在判空的情况下在里面再进行加锁,然后再判空。能够线程安全且性能最大化:
是因为如果多线程情况下,如果两个线程都通过了第一层拦截
intance==null
,其中有一个线程获取到了锁然后实例化后,释放锁时,如果第二个线程进去,则进行判断是否已经实例化,如果实例化了则直接返回已实例的对象。这样能够防止两个同时都各自实例化一个实例。 -
指令重排导致还有可能发生问题:
- JVM和CPU发生变化,发生了指令重排;它的意思是说,对于两个没有强顺序的指令,在CPU层面中可能是随机顺序执行的,而图中的new SingletonExample() 实例化对象时,有可能先进行了引用,如果先进行了引用并调用接口的话,因为没有实例化完成,会导致异常发生。
-
使用volatile阻止CPU对这个对象发生指令重排,这样这个类的实例化方法就是线程安全的了。如图所示:
-
-
饿汉模式:
- 使用静态域代码示例:
- 代码说明:饿汉模式是线程安全的,它在一开始就初始化了一个实例,在静态的工厂方法获取的时候,就可以对此实例进行返回。在多线程的情况下也始终只会返回这一个实例。
- 饿汉模式的缺点:
- 它的构造函数里的处理内容不能太多,否则会导致加载较慢。
- 它应该会被使用到,否则会造成浪费。
- 使用静态代码块实现单例模式:
- 注意:使用静态代码块一定要注意顺序。谁在上面谁会先执行。如果 =null的在后面,new 的在前面则会导致new 完过后又会被Null所覆盖,导致空指针异常。
- 使用静态域代码示例:
-
枚举模式: 推荐
- 代码示例:
/** * 最安全 **/ public class SingletonExceple { // 私有构造函数 private SingletonExample(){ } public static SingetonExample getInstance(){ return Singleton.INSTANCE.getInstance(); } private enum Singleton{ INSTANCE; private SingletonExample singleton; // JVM保证这个方法绝对只调用一次 Singleton(){ singleton = new SingletonExample(); } public SingletonExample getInstance(){ return singleton; } } }
- 代码示例:
五. 线程安全策略
5.1 不可变对象
-
不可变对象需要满足的条件:
- 对象创建以后其状态就不能修改
- 对象所有域都是final类型
- 对象是正确创建的(在对象创建期间,this引用没有逸出)
String就是一个不可变对象,当两个字符串结合的时候,生成的是一个新地址的对象。
-
final关键字:类、方法、变量
- 修饰类:不能被继承
- 修饰方法:1、锁定方法不被继承类修改 2、效率
- 修饰变量:基本数据类型变量(值不能再修改)、引用类型变量(不能引用其他地址了)
最近版本不需要使用final 进行性能优化了。
-
不可变对象除了final 外还有哪些呢?
- Collections.unmodifiableXXX: Collection、List、Set、Map…
在UnmodifiableXXX的源码里面它会把很多方法做成异常抛出,这样调用修改的方法的时候,会直接被抛出异常,无法进行修改。
- Guava: ImmutableXXX: Collection、List、Set、Map…
当使用ImmutableXXX方法的时候,如果对集合进行修改,也会直接抛出异常,且如果是Immutable类型的,调用类似add()方法时还会提示已过期的横线。
Map的构建这里有两种形式,如图所示;
一旦初始化完成就不允许修改了。
- Collections.unmodifiableXXX: Collection、List、Set、Map…
5.2 线程封闭
-
什么是线程封闭?
- 它实际上是把对象封装到一个线程里,只有这一个线程能够看到这个对象,那么就算这个对象不是线程安全的,那也不会产生线程安全方面的问题,因为它只能被一个线程访问;
-
正常来讲,我们的请求对服务器都是一个线程在运行,我们希望线程间隔离,那么首先这个线程被后端服务器进行实际处理的时候,通过通过filter可以直接先取出来当前的用户,把数据存入到ThreadLocal里面,当这个线程被Service以及其他相关类进行处理的时候,很可能要取出当前用户,这个时候我们可以通过ThreadLocal随时随地拿到当时存储过的值,这样使用起来就很方便啦。
-
如果不这样做的话,我们就得需要一直将用户信息无限的传递下去,则需要在方法中额外传输一些不想传输的变量。
-
线程封闭的方法:
- Ad-hoc 线程封闭:程序控制实现,最糟糕,忽略。(这是完全靠实现者控制的线程封闭,非常脆弱)
- 堆栈封闭:局部变量,无并发问题(就是我们的局部变量,多个线程访问一个方法的时候,方法中的局部变量都会被拷贝一份到线程的栈中,所以局部变量是不会被多个线程所共享的,因此也不会出现并发问题。所以我们能够用局部变量的时候最好不要使用全局变量,全局变量容易引起并发问题)
- ThreadLocal 线程封闭:特别好的封闭方法,建议使用。(它的内部维护了一个map,map的key是每个线程的名称,而map的值则是我们要封闭的对象,每个线程中的对象都对应着一个map中的值,也就是说,ThreadLocal利用map实现了线程的封闭)
接下来将通过代码演示ThreadLocal的简单使用。
-
使用ThreadLocal对指定线程存储变量:
- 新建一个Controller,以及接口test:
这里通过RequestHolder.getId()获取id的值;
- 定义一个RequestHolder类,分别添加三个方法:新增add,getId获取ThreadLocal里的值,remove删除:
- 新建一个HttpFilter类,通过doFilter拦截请求,并获取到线程id,通过RequestHolder的add方法给内部的ThreadLocal 的变量赋值:
请求会先经过这里,才会到controller接口内部;
- 新建一个HttpInterceptor类,afterCompletion会在接口方法执行完毕后执行,这里则继续执行RequestHolder.remove()方法,避免内存泄露:
- 新建一个Controller,以及接口test:
5.3 线程不安全类与写法
-
什么是线程不安全的类呢?
- 如果一个类的对象它可以同时被多个线程访问,如果你不做特殊的同步或并发处理,那么它就很容易表现出线程不安全的现象,比如抛出异常,比如逻辑处理错误,这种类呢我们就称之为线程不安全的类。
-
StringBuilder与StringBuffer的区别及使用?
- StringBuilder是线程不安全的,而StringBuffer是线程安全的。
- StringBuffer线程安全是因为它的内部加了Synchronized,而StringBuilder没有加。也正因为此,所以StringBuilder性能更好。
- 不能单纯的说谁更好,而是看在指定的场景下谁最合适。对于在一个方法内部定义的StringBuilder它是线程封闭的,只有一个线程能够使用这个对象,所以使用它会更好,即便线程不安全也不会引发问题,因为内部定义的StringBuilder是一个局部对象。
这也是为什么java会同时提供两个String处理类。
-
与之类似的还有我们常用的SimpleDateFormat类,如果我们定义成全局变量,则可能会经常报错。正确的定义方式是在局部方法内new SimpleDateForm:
这样才不会出线程不安全带来的异常。另外一个DateTimeFormatter是一个线程安全的类,无论它定义在方法内,还是放在全局变量,都是线程安全的。我们推荐使用DateTime,它不仅仅线程安全,且很多地方都有优势。
-
线程不安全类的总结:
- StringBuilder-> StringBuffer
- SimpleDateFormat->JodaTime
- ArrayList,HashSet,HashMap等Collections
我们在使用线程不安全的类的时候,如果只用于查询,不对它进行修改操作,则能够保证并发不会出现问题。如果需要对其进行内容进行修改,则可以放在局部变量中进行,这样每个线程都能够拥有一个线程封闭的各自的一个实例对象,类与类之间互不影响。如果需要放在全局且需要进行修改,比如抢票,对一个变量的加减操作,那么我们则需要保证其原子性的加减,通过加锁、Amtoc等操作保证线程的安全。
5.4 线程安全-同步容器
-
同步容器的种类:
- ArrayList-> Vector,Stack
- HashMap->HashTable(key,value不能为null)
- Collections.synchronizedXXX(List、Set、Map)
-
线程安全也不一定是真的安全:
import java.util.Vector; public class VectorExample{ private static Vector<Integer> vector = new Vector<>(); public static void main(String[] args){ while(true){ for(int i=0;i<10;i++){ vector.add(i); } Thread thread1=new Thread(){ public void run(){ for(int i=0;i<10;i++){ vector.remove(i); } } } Thread thread2=new Thread(){ public void run(){ for(int i=0;i<10;i++){ vector.get(i); } } } } } }
上述代码中,通过不断的删除和获取,一定会引发数组越界异常。因为有可能正在获取时,此坐标索引的数据已经被删除掉了,就会引发数组越界异常。所以说线程安全也不能说一定能完全放心使用,我们需要了解每个容器的特性。
在使用Iterator的过程中,我们不要进行删除操作,真的需要删除的话,我们可以先进行标记等待遍历结束后再删除,否则容易出现异常。 -
从以上例子我们可以看出,同步容器往往性能不是特别好,并且不能够完全做得到并发安全。所以我们有更好的替代品,它就是并发容器。
5.5 线程安全-并发容器 J.U.C
-
J.U.C 它是三个单词的缩写,表示的是一个java路径,它是:java.util.current 这三个单词的缩写。
-
并发容器的种类与对应关系:
- ArrayList-> CopyOnWriteArrayList (写的时候在锁的保护下进行复制,写完时完全转到新的集合中来。缺点:1.做写操作消耗内存,如果元素多的情况下容易导致年轻代GC或者Full GC 2. 不能用于实时读的场景,因为写需要消耗时间。 所以它更适合读多写少的场景,如果数据量比较大,则慎用。)
copyOrWrite的设计思想:读写分离,最终一致性,使用时另外开辟空间(解决并发冲突)
- 源码图示:
- 源码图示:
- HashSet、TreeSet->CopyOnWriteArraySet、ConcurrentSkipListSet
CopyOnWriteArraySet与CopyOnWriteArrayList类似。ConcurrentSkipListSet的removeAll、addAll这些批量操作不能保证线程安全,我们需要手动进行同步,虽然他们是原子操作但是他们不能保证不被其他所打断。
- HashMap、TreeMap->ConcurrentHashMap、ConcurrentSkipListMap
ConcurrentHashMap的存取更快、但是ConcurrentSkipListMap(支持更高的并发,它的key是有序的,它的存取速度与线程数没有直接关系)也有一定的优势所在。
- ArrayList-> CopyOnWriteArrayList (写的时候在锁的保护下进行复制,写完时完全转到新的集合中来。缺点:1.做写操作消耗内存,如果元素多的情况下容易导致年轻代GC或者Full GC 2. 不能用于实时读的场景,因为写需要消耗时间。 所以它更适合读多写少的场景,如果数据量比较大,则慎用。)
-
并发编程路线:
-
安全共享对象策略-总结
- 线程限制:一个被线程限制的对象,由线程独占,并且只能被占有它的线程修改。
- 共享只读:一个共享只读的对象,在没有额外同步的情况下,可以被多个线程并发访问,但是任何线程都不能修改它。
- 线程安全对象: 一个线程安全的对象或者容器,在内部通过同步机制来保证线程安全,所以其他线程无需额外的同步就可以通过公共接口随意访问它。
六、J.U.C之AQS讲解
6.1 J.U.C之AQS介绍
-
AQS: AbstractQueuedSynchronizer 它是并发容器里的同步器,简称AQS,从Jdk5开始,它提高了Java并发的性能,可以构建锁、同步框架的基础结构。
-
数据结构:
有一个大致印象即可。
-
介绍:
- 使用Node实现FIFO队列,可以用于构建锁或者其他同步装置的基础框架
- 利用了一个int类型表示状态
- 使用方法是继承
- 子类通过继承并通过实现它的方法管理其状态{acquire 和 release}的方法操纵状态
- 可以同时实现排它锁和共享锁模式(独占、共享)
-
AQS同步组件:
- CountDownLatch(闭锁,通过计数来表示线程是否需要一直阻塞)
- Semaphore(控制同一时间并发线程的数目)
- CyclicBarrier(它和前面两个很像,也可以阻塞进程)
- ReentrantLock (重要)
- Condition
- FutureTask
6.2 CountDownLatch
-
图示:
CountDownLatch是一个同步辅助类,通过它我们可以完成阻塞当前线程的功能。换句话说,可以让一个线程或者多个线程一直等待,直到其他线程执行的操作完成。
-
CountDownLatch结合图示分析:
- 它用了一个给力的计数器来进行初始化,该计数器的操作是原子操作,同时只能有一个线程去操作计数器。
- 调用图示中await方法的线程会一直处于阻塞状态,直到其他线程调用countDown()方法,使当前计数器的值变成0。
- 每次调用countDown()方法的时候会让计数器的值减1,当计数器的值减到0的时候,所有调用await()的方法处于等待的线程就会继续往下执行。
- 这个操作只会出现一次,因为计数器是不能被重置的,如果业务上需要一个重置计数次数的版本,可以考虑后面要介绍的Semaphore;
-
CountDownLatch的使用场景:在某些业务场景中,程序执行需要等待某个条件完成后才能继续执行后续的操作,典型的应用:并行计算(将一个大任务拆分成许多小任务,然后等待所有的任务都执行完毕后再进行汇总。)
-
为什么我们在并发模拟的时候可以使用CountDownLatch呢?因为我们模拟的场景的函数比较简单,且业务跟适应的使用场景比较适合。
-
代码示例:
为了防止countDownLatch.countDown()方法没有将值减到0,我们可以将countDownLatch.await();改为countDownLatch.await(10,TimeUnit.MILLISECONDS);这样如果达到限定的时间还没有到达指定的条件时,可以直接执行后面的代码。
6.3 Semaphore-信号量
-
概念:Semaphore 通常我们叫它信号量, 可以用来控制同时访问特定资源的线程数量,通过协调各个线程,以保证合理的使用资源。
-
图示:
可以把它简单的理解成我们停车场入口立着的那个显示屏,每有一辆车进入停车场显示屏就会显示剩余车位减1,每有一辆车从停车场出去,显示屏上显示的剩余车辆就会加1,当显示屏上的剩余车位为0时,停车场入口的栏杆就不会再打开,车辆就无法进入停车场了,直到有一辆车从停车场出去为止。
-
使用场景:主要用于那些资源有明确访问数量限制的场景,常用于限流;
-
Semaphore常用方法说明:
- acquire() 获取一个令牌,在获取到令牌、或者被其他线程调用中断之前线程一直处于阻塞状态。
2. acquire(int permits) 获取一个令牌,在获取到令牌、或者被其他线程调用中断、或超时之前线程一直处于阻塞状态。 - acquireUninterruptibly() 获取一个令牌,在获取到令牌之前线程一直处于阻塞状态(忽略中断)。
- tryAcquire()尝试获得令牌,返回获取令牌成功或失败,不阻塞线程。
5. tryAcquire(long timeout, TimeUnit unit)尝试获得令牌,在超时时间内循环尝试获取,直到尝试获取成功或超时返回,不阻塞线程。
6. release()释放一个令牌,唤醒一个获取令牌不成功的阻塞线程。
7. hasQueuedThreads()等待队列里是否还存在等待线程。 - getQueueLength()获取等待队列里阻塞的线程数。
9. drainPermits()清空令牌把可用令牌数置为0,返回清空令牌的数量。
10. availablePermits()返回可用的令牌数量。
- acquire() 获取一个令牌,在获取到令牌、或者被其他线程调用中断之前线程一直处于阻塞状态。
-
代码演示:
图示中,使用Semaphore放置了三个令牌。即便有20个线程同时访问,此处也只能有三个线程能够同时执行,他们通过acquire()方法获取到了令牌才能执行下面的代码,当release()释放许可后,被阻塞的线程才能尝试获取令牌。使用它可以很方便的进行限流。当令牌数为1时,就可以达到单线程的效果了。同时里面的acquire和release操作的许可令牌不受限制,我们可以同时释放多个许可或者获取多个许可(此处许可表示令牌)。
-
尝试获取许可:
使用tryAcquire()表示尝试获取许可,当获取到许可则执行内部代码,如果没有获取到则不执行。此处如果20个线程同时尝试获取许可,而Semaphore的令牌数量只有3个,且在所有许可获取时,已拿到许可的线程没有释放许可,那么最多也只有3个线程能够获取到许可。即便已拿到许可的线程释放了许可,那么同时最多也只有3个线程能够在同一时间持有许可(令牌)。
6.4 CyclicBarrier
-
图示:
它可以用于多线程计算,每个线程同时分别处理一部分逻辑,当所有的线程结束计算后,然后再统一结果进行返回。
-
介绍CyclicBarrier与CountDownLatch的区别
- CountDownLatch的计数器只能使用一次,而CyclicBarrier可以使用restart方法重置,循环使用。
- CountDownLatch主要用于一个线程或者多个线程同时等待其他线程执行某项操作之后才能继续往下执行,它表示一个或N个线程等待其他线程的关系。CyclicBarrier它主要实现了多个线程之间的相互等待,直到所有线程都满足了条件之后才能继续执行后续的操作。它描述的是各个线程内部相互等待的关系,它能够处理更加复杂的场景。
-
代码示例:
代码中,通过new CyclicBarrier来定义了5个同时的线程,当barrier.await()被执行时,会进入线程等待,当达到5个时则所有的继续往下执行。这里也可以通过设置指定时间进行释放,如图中的设置2000毫秒。CyclicBarrier的await()方法会抛出BrokenBarrierException异常、TimeoutException等异常,我们需要进行处理。
-
CyclicBarrier的初始化后面可以带代码块,当初始化完毕时会跟着执行代码块中的代码,如图所示:
6.5 ReentrantLock锁
-
Java主要分为两类锁,一种是我们之前介绍的Synchronized关键字修饰的锁,一种就是J.U.C里面提供的锁。
-
ReentrantLock(可重入锁)和synchronized区别
- 可重入性:RentrantLock从字面意义上是再进入锁,即可重入锁。其实synchronized也是可重入的,两者关于这个区别不大。它们都是进入一次,锁的计数器就自增1,要等锁的计数器下降为0时才能释放锁。
- 锁的实现:synchronized是基于JVM实现的,ReentrantLock是基于JDK实现的。类似操作系统实现和代码控制实现的区别,后者更易于操控。
- 性能的区别: 在之前ReentrantLock的性能更优,现在的synchronized借鉴了ReentrantLock的CAS技术(试图在用户态就把加锁的问题解决,避免进入内核态的线程阻塞),引入了偏向锁、轻量级锁(自旋锁)后,两者的性能就差不多了。在两者都可用的情况下,官方更建议使用Synchronized关键字,因为它的写法更容易。
- 功能区别:synchronized更便利,它自动加锁和释放锁。而ReentrantLock更加灵活。
-
ReentrantLock独有的功能
- 可指定是公平锁还是非公平锁
- 提供了一个Condition类,可以分组唤醒需要唤醒的线程;(synchronized只能随机唤醒一个线程或者唤醒全部线程)
- 能够提供中断等待锁的线程的机制,lock.lockInterruptibly()
ReentrantLock实际上是一种自旋锁,通过循环调用CAS操作来实现加锁,它的性能良好是因为避免了线程进入内核态的阻塞状态。当你必须要使用ReentrantLock的这三个独有的功能的时候,那么你就使用这个ReentrantLock.
Java 中的J.U.C中的工具类是为高级用户使用的。初级开发人员最好使用synchronized,尽可能减少错误的发生,减少排查错误的成本。 -
ReentrantReadWriteLock:
- 使用示例:
class RWDictionary { private final Map<String, Data> m = new TreeMap<String, Data>(); private final ReentrantReadWriteLock rwl = new ReentrantReadWriteLock(); private final Lock r = rwl.readLock(); private final Lock w = rwl.writeLock(); public Data get(String key) { r.lock(); try { return m.get(key); } finally { r.unlock(); } } public String[] allKeys() { r.lock(); try { return m.keySet().toArray(); } finally { r.unlock(); } } public Data put(String key, Data value) { w.lock(); try { return m.put(key, value); } finally { w.unlock(); } } public void clear() { w.lock(); try { m.clear(); } finally { w.unlock(); } } }
- 介绍:
- 在并发场景中用于解决线程安全的问题,我们几乎会高频率的使用到独占式锁,通常使用java提供的关键字synchronized(关于synchronized可以看这篇文章)或者concurrents包中实现了Lock接口的ReentrantLock。
- 它们都是独占式获取锁,也就是在同一时刻只有一个线程能够获取锁。
- 而在一些业务场景中,大部分只是读数据,写数据很少,如果仅仅是读数据的话并不会影响数据正确性(出现脏读),而如果在这种业务场景下,依然使用独占锁的话,很显然这将是出现性能瓶颈的地方。
- 针对这种读多写少的情况,java还提供了另外一个实现Lock接口的ReentrantReadWriteLock(读写锁)。
- 读写锁允许同一时刻被多个读线程访问,但是在写线程访问时,所有的读线程和其他的写线程都会被阻塞。在分析WirteLock和ReadLock的互斥性时可以按照WriteLock与WriteLock之间,WriteLock与ReadLock之间以及ReadLock与ReadLock之间进行分析。
- 特点:
- 公平性选择:支持非公平性(默认)和公平的锁获取方式,吞吐量还是非公平优于公平;
- 重入性:支持重入,读锁获取后能再次获取,写锁获取之后能够再次获取写锁,同时也能够获取读锁;
- 锁降级:遵循获取写锁,获取读锁再释放写锁的次序,写锁能够降级成为读锁
- 使用示例:
七、J.U.C组件拓展
7.1 FutureTask(J.U.C里面)
-
创建一个线程通常有两种方式,一种是直接继承Thread,一种是实现Runnabale接口。这两种方式有一种共同的缺陷,在执行完任务后无法获取执行结果。从Java 1.5开始,就提供了Callable、Future等方式,能够获取任务的执行结果。
-
Callable与Runnable接口对比:
- Callable是泛型接口有一个call函数,Runnable它是一个接口,只有一个方法:run;(Callable的执行有返回值,并且能够抛出异常)
- Future接口:它可以监视目标线程调用方法的情况,当你调用Future的get方法的时候能够获取结果。这个时候执行线程可能不会直接执行完成,当前线程直接阻塞,直到call方法结束返回结果,线程才继续执行。总结一句话,它可以得到别的接口执行的返回值。
- FutureTask类:它的父类是RunnableFuture,它的父类继承了Runnable和Future,所以它也是执行Callable类型的任务。它实现了两个接口,Runnable和Future。所以它既可以作为线程直接执行,也可以得到返回值。
什么场景下使用FutureTask?假如一个线程需要计算一个值,这个值不是马上需要且很费时,那么就可以使用此类,一个用于计算,一个等待计算完成后获取结果同时还可以做其他操作,这样的场景就可以使用此类,达到性能的尽可能最大化。
-
Future接口代码示例:
-
FutureTask类代码示例:
7.2 Fork/Join框架
-
Fork/Join是Java 7 中提供的一个用于执行并行任务的框架,它采用了分治的思想,将一个大任务拆分成若干个小任务来执行,并最终合并结果。Fork就是切分任务,Join就是合并结果。它主要用到了工作窃取算法,是指某个线程从其他队列里窃取任务来执行。窃取任务从一端拿去任务执行,被窃取任务的线程从该任务的另外一端来拿取任务,以此减少线程的竞争,并最大化利用线程。
- 图示:
- 图示:
-
缺点:
- 只能使用Fork/Join来作为同步机制。使用了其他同步机制则线程就会陷入阻塞,不会去窃取其他线程的任务。(比如在Fork/Join中使任务进入了睡眠,那么在睡眠期间内的相关线程则不会去执行其他的任务了。)
- 不能执行I/O操作。
- 不能抛出检查异常,必须处理它们。
-
代码示例:
7.3 BlockingQueue-阻塞队列
- 图示:
图示中是此队列的操作对应的方法;
- 介绍:
- 它是一个阻塞队列(当队列满了的时候只进行出队列操作。当队列空了的时候只进行入队列操作。)
- 它是线程安全的,主要用于生产者和消费者的场景。图示中线程1不断插入,线程2不断的取出;
- BlockingQueue的一些实现类:
- ArrayBlockingQueue:它是一个有界的阻塞队列(新建时必须初始化大小,这个大小一旦指定了就不能再变化了),它先进先出存储数据,最先插入的对象是尾部,最先移除的对象是头部;
- DelayQueue它阻塞的是内部元素,它里面的元素必须实现一个接口,是J.U.C中的Delayed这个接口,因为它里面的元素需要排序,一般情况下是根据元素过期的优先级进行排序。应用场景:比如定时关闭连接,缓存对象,超时处理等多种场景。它的内部实现是锁和排序。
- LinkedBlockingQueue:它的大小配置是可选的,如果初始化时指定了大小它就是有边界的,如果不指定就是无边界的,说是无边界实则是使用了默认的最大的整形值。它的内部实现是一个链表,它是以先进先出的方式存储数据,其他的跟ArrayBlockingQueue差不多。
- PriorityBlockingQueue
- SynchronousQueue:
八、线程调度-线程池讲解
8.1 线程池
-
new Thread弊端:
- 每次new Thread新建对象,性能差
- 线程缺乏统一管理,可能无限制的新建线程,相互竞争,有可能占用过多系统资源导致死机或OOM
- 缺少更多功能,如更多执行、定期执行、线程中断。
-
线程池的好处:
- 重用存在的线程,减少对象创建、消亡的开销,性能差。
- 可有效控制最大并发线程数,提高系统资源利用率,同时可以避免过多资源竞争,避免阻塞。
- 提供定时执行、定期执行、单线程、并发数控制等功能。
-
线程池 - ThreadPoolExecutor
- corePoolSize: 核心线程数量
- maximumPoolSize:线程最大线程数
- workQueue:阻塞队列,存储等待执行的任务,很重要,会对线程池运行过程产生重大影响。
- keepAliveTime:线程没有任务执行时最多保持多久时间终止。
- unit:keepAliveTime的时间单位
- threadFactory: 线程工厂,用来创建线程
- rejectHandler: 当拒绝处理任务时的策略。
-
它初始化好线程的实例后,然后把任务丢进去,等待任务执行即可。它的使用非常简单,方便。构建线程池也比较容易,我们只需要传入它需要的参数即可。
-
线程池的状态:
-
线程池的常用方法:
- execute(): 提交任务,交给线程池执行
- submit(): 提交任务,能够返回执行结果 execute+Future
- shutdown(): 关闭线程池,等待任务都执行完
- shutdownNow(): 关闭线程池,不等任务执行完。
- getTaskCount():线程池已执行和未执行的任务总数
- getCompletedTaskCount(): 已完成的任务数量
- getPoolSize(): 线程池当前的线程数量
- getActiveCount(): 当前线程池中正在执行任务的线程数量。
使用5,6,7,8等方法,可以监控线程中任务的执行情况。
-
线程池类图:
-
线程池- Executor框架接口:
使用Executor可以很方便的创建出不同类型的线程池。
-
代码示例:
不同的,把Executors.newxxx 替换即可。线程池使用完后记得一定要关闭。操作基本都一样,不同的是要根据不同线程池的特点,在实际场景下使用合适的线程池。
-
线程池-合理配置
- CPU密集任务,就需要尽量压榨CPU,参考值可以设为NCPU+1
- IO密集型任务,参考值可以设置为2*NCPU
九、多线程并发拓展讲解
9.1 死锁
- 死锁 - 必要条件:
- 互斥条件:进程对占用资源具有排它性,一个线程只能占用一个资源,如果有其他线程请求此资源,则其他资源只能陷入等待,只有等待锁释放后才能继续抢占。
- 请求和保持条件:它是指进程至少已经保持了一个资源,但又提出了新的资源请求,而该资源已被其他资源占用,此时请求进程阻塞,但又对自己保持的资源保持不放。
- 不剥夺条件:在使用时不能被剥夺,只能在使用完成后才能释放。
- 环路等待条件:指在发生死锁时,必然存在一个进程资源的环形链,即进程集合{P0,P1,P2,···,Pn}中的P0正在等待一个P1占用的资源;P1正在等待P2占用的资源,,Pn正在等待已被P0占用的资源
- 死锁代码图示:
互相持有对方线程所需要的资源从而导致了死锁发生。
9.2 多线程并发最佳实践
- 使用本地变量
- 可以节省内存
- 使用不可变类
- 降低代码中需要同步的数量。比如String.
- 最小化锁的作用域范围:S=1/(1-a+a/n)
- S(加速比)、a(并行计算所占的比例)、n(并行处理的节点个数)
- 如果有5%的代码在锁中,并行的效率提高最多20倍,因为锁中的代码只能顺序执行。
- 使用线程池的Executor,而不是直接new Thread执行
- 创建线程的代价是昂贵的,且不易管控。
- 宁可使用同步也不要使用线程的wait和notify
- 优先使用同步工具去替代他们
- 使用BlockingQueue实现生产-消费模式
- 生产消费,它是最合适的解决工具
- 使用并发集合而不是加了锁的同步集合
- 效率更高,对于特定场景是更好的解决方案。
- 使用Semaphore创建有界的访问
- 以最低的代价阻塞线程等待,可义用它来控制访问资源的线程数。
- 宁可使用同步代码块,也不使用同步的方法
- syschronized同步代码块只会锁定一个对象而不是锁定整个方法。
10.避免使用静态变量 - 静态变量在并发环境下容易引起很多问题,优先做成final,或者我们需要做好同步处理和并发处理。
- syschronized同步代码块只会锁定一个对象而不是锁定整个方法。
9.3 Spring与线程安全
- Spring它作为一个IOC、DI容器,帮助我们管理了许多的Bean,但其实Spring并没有保证这些对象的线程安全,需要我们开发者自己编写解决线程安全问题的代码。Spring为每个Bean提供了一个属性来表示作用域,来体现它的生命周期,
- singleton: 这种类型的Bean在第一次被注入时会创建一个单例对象,该对象会一直被复用到应用结束。它是Spring Bean默认的属性。
- prototype: 在每次注入时都会创建一个对象
- 无状态对象:无论单例还是多例都是线程安全的。不过单例可以节省不断创建对象带来的资源开销。比如VO/DTO/BO等。
9.4 HashMap与ConcurrentHashMap
- 图示:
- 后续补上;
9.5 总结
十、高并发之扩容思路与手段
10.1 扩容
- 垂直扩容(纵向扩展): 提高系统部件能力
比如内存不够,加内存,磁盘不够加磁盘
- 水平扩容(横向扩展):增加更多系统成员来实现
单机服务器不够扩展为集群。三台服务器不够,就加一台服务器。增加了对共享资源的压力。
10.2 扩容-数据库
- 读操作扩展:memcache、redis、CDN等缓存
- 写操作扩展:Cassandra、Hbase等
实际的需要根据实际场景来,这里是一个思路,具体的要根据实际场景来选择合适的处理方案。
十一、高并发之缓存思路
11.1 缓存的应用场景及介绍
-
图示:
-
缓存特征:
- 命中率: 命中数/(命中数+没有命中数)
- 最大元素(空间)
缓存满的时候,如何有效缓存,如何清理?
- 清空策略:FIFO、LFU、LRU、过期时间,随机等
选择合适的清空策略能够有效的提升缓存的命中率。FIFO: 先进先出策略(最先进的最先被清理,对数据实时性要求高的场景)。LFU:最少使用策略(比较命中次数,保证高频命中的策略)。 LRU:最近最少使用策略。 (优先保证热点数据的有效性);过期时间(最长时间的被清理)
-
缓存命中率影响因素:
- 业务场景和业务需求
- 实时性要求越低越适合缓存。
- 适合读多写少
- 缓存的设计(粒度和策略)
- 粒度越小,命中越高
- 缓存过期更新相比于直接删除缓存命中率更高
- 缓存容量和基础设施
- 多数采用LRU算法。
- 采用分布式缓存更易于扩展
- 不同的缓存中间件的效率和稳定性等是不一样的,我们要选择合适的。
并发越高,缓存的收益率越高。如果缓存的随机性很高,且在缓存过期后还未命中,这样的缓存收益率就很低。
- 业务场景和业务需求
-
缓存分类和应用场景
- 本地缓存:编程实现(成员变量、局部变量、静态变量)、Guava Cache(较多使用)
- 分布式缓存: Memcache、Redis(使用较多)
11.2 支持缓存的框架或工具
-
缓存-Guava Cache
- 图示:
- Guava Cache:
- Guava Cache它继承了CurrentHashMap的思路,使用了细粒度锁在保证线程安全的同时,支持高并发场景的需求。
- 这里的cache类似一个Map,它存储了Key-value键值对的集合.
- 但不同的是,它还要处理缓存过期、动态加载等一些算法的逻辑,需要一些额外的信息来实现这些操作。
- 根据面向对象的思想,它还需要做方法与数据的关联性的封装,它自动将节点加入到缓存中,当缓存中的数据超过设置的最大值时,使用LRU算法来移除,它具备根据节点上次被访问或被写入时间来计算它的过期机制。它可以统计缓存的命中率、异常率、未命中率等统计数据。
- 图示:
-
缓存-Memcache
- 图示:
- 它的分布式主要是在客户端实现的,通过某种算法映射到某台服务器上,使用一致性哈希算法计算。
- chunk是存放数据的地方。slab数量是有限的。
- 图示:
-
缓存-redis:
- 图示:
- 特点:
- 支持数据的持久化,RDB、AOF
- 支持数据的多种类型:String、Hash、List、Set、Sorted Set等,支持多种场景,适合做唯一性检查的操作、实时系统、反垃圾系统等等。
- 支持数据的备份
- 原子性
- 性能优异,易于扩展
- 图示:
11.3 高并发场景下缓存常见问题
-
常见问题有:
- 缓存一致性
- 缓存并发问题
- 缓存穿透问题
- 缓存的雪崩现象
-
缓存一致性出现的场景:
-
缓存并发问题:
-
缓存穿透问题:
当查询大量数据没有走redis而是直接走的数据库。
-
缓存雪崩:
缓存穿透、缓存抖动、缓存并发、缓存周期性失效等可能会导致大量请求到达数据库,导致数据库压力过大而崩溃,从而导致整个系统崩溃。
十二、高并发之消息队列思路
12.1 消息队列介绍
-
消息流程:
控制消息的速度、异步、解耦、失败重试等细节需要注意,保持最终的一致性。减少并发。
-
消息队列特性
- 业务无关:只做消息分发
- FIFO: 先投递先到达
- 容灾:节点的动态增删和消息的持久化
- 性能:吞吐量提升,系统内部通信效率提高
-
为什么需要消息队列?
- 【生产】和【消费】的速度或稳定性等因素不一致
- 异步、解耦、削峰、广播、流控等
-
消息队列好处
- 业务解耦: 一个事务只关心核心流程,其他的只需要通知即可,而不直接关心结果。
- 最终一致性: 两个系统在一定时限内最终保持一致成功或失败。比如转账操作,一个人付款了,另外一个人应该需要加钱,如果不一致,会引发灾难。成功(ok)、失败(记录失败、重试、通知等解决方案 直到问题解决)
- 广播,每当有一个新的业务接入,它们自己订阅即可不需要我们去主动适配它。
- 错峰与流控
总而言之,消息队列不是万能的,对于需要强事务保证而且对延迟很敏感的,RPC远程调用会更适合。对于别人很重要,对于自己不是主要关心的事情、追逐最终一致性、接收延迟、通知等场景则适合消息队列。
12.2 常见消息队列
-
队列-Kafka
- 图示:
除了性能很好之外,它还是一个工作良好的分布式系统。
- 介绍:
- 每个消息都有一个Topic
- 每个Topic可能有多个Partition
- Producer根据指定的Partition算法发送到指定的Partition;Kafka收到消息后在指定时间内保存到磁盘。Consumer则在Partition中进行消费消息。
- 图示:
-
队列-RabbitMQ
- 图示:
- 介绍:
- Queue与Exchange通过RoutingKey来绑定。
- RabbitMQ Server有自己的管理界面
- RabbitMQ性能较好,较为成熟,社区活跃。
- 图示:
十三、高并发之应用拆分思路
13.1 应用拆分
- 为什么要拆分:
- 减轻压力
- 从整体一个应用拆分成多个应用,避免一个环节出错导致整个系统崩溃。
- 拆分成多个应用,可以根据各个应用特点做单独的优化,优化的细粒度更高。
- 拆分示例:
- 图示:
- 图示信息展示了将一个股票系统应用拆分成了多个应用。
- 图示:
- 拆分的缺陷:
- 拆分风险,技术考验
- 服务器压力
- 网络开销
13.2 应用拆分-原则
-
原则:
- 业务优先
- 循序渐进(小步前进,减少累计错误的发生)
- 兼顾技术:重构、分层
- 可靠测试
-
思考:
- 应用之间通信:RPC(dubbo等)、消息队列
- 应用之间数据库设计: 每个应用都有独立的数据库
- 避免事务操作跨应用
-
应用拆分框架:
-
Dubbo(服务化)
- 技术架构图示:
- 技术架构图示:
-
Spring Cloud(微服务)
- 图示:
- 特点:
- 独立的服务组成系统
- 每个服务跑跑在自己的进程中,每个服务为独立的业务开发。它们是分布式管理的,非常强调隔离性
- 有生命的产品而不是项目
- 强服务个体,弱通信
- 自动化运维devops
- 高度容错性
- 能够快速演化和迭代
微服务一般是直接面向客户的。
- 图示:
-
十四、高并发之应用限流思路
14.1 应用限流算法
-
算法主要有以下四种:
- 计数器法
- 滑动窗口
- 漏桶算法
- 令牌桶算法
-
计数器:
- 规定一分钟访问次数不能超过一百个。
- 可以用计数器一分钟重置一次,然后一分钟内不能超过100次。
- 但是会存在临界问题,比如在重置的间隔内突然超过100个请求访问,就有可能穿透限流拦截。
- 代码示例:
public class CounterTest { public long timeStamp = getNowTime(); public int reqCount = 0; public final int limit = 100; // 时间窗口内最大请求数 public final long interval = 1000; // 时间窗口ms public boolean grant() { long now = getNowTime(); if (now < timeStamp + interval) { // 在时间窗口内 reqCount++; // 判断当前时间窗口内是否超过最大请求控制数 return reqCount <= limit; } else { timeStamp = now; // 超时后重置 reqCount = 1; return true; } } public long getNowTime() { return System.currentTimeMillis(); } }
-
滑动窗口
- 滑动窗口,又称rolling window。为了解决这个问题,我们引入了滑动窗口算法。如果学过TCP网络协议的话,那么一定对滑动窗口这个名词不会陌生。下面这张图,很好地解释了滑动窗口算法:
- 在上图中,整个红色的矩形框表示一个时间窗口,在我们的例子中,一个时间窗口就是一分钟。然后我们将时间窗口进行划分,比如图中,我们就将滑动窗口 划成了6格,所以每格代表的是10秒钟。每过10秒钟,我们的时间窗口就会往右滑动一格。每一个格子都有自己独立的计数器counter,比如当一个请求 在0:35秒的时候到达,那么0:30~0:39对应的counter就会加1。
- 那么滑动窗口怎么解决刚才的临界问题的呢?我们可以看上图,0:59到达的100个请求会落在灰色的格子中,而1:00到达的请求会落在橘黄色的格 子中。当时间到达1:00时,我们的窗口会往右移动一格,那么此时时间窗口内的总请求数量一共是200个,超过了限定的100个,所以此时能够检测出来触 发了限流。
- 我再来回顾一下刚才的计数器算法,我们可以发现,计数器算法其实就是滑动窗口算法。只是它没有对时间窗口做进一步地划分,所以只有1格。
- 由此可见,当滑动窗口的格子划分的越多,那么滑动窗口的滚动就越平滑,限流的统计就会越精确。
它是计数器算法的升级版,它的精度更高,需要更多的存储空间。
- 滑动窗口,又称rolling window。为了解决这个问题,我们引入了滑动窗口算法。如果学过TCP网络协议的话,那么一定对滑动窗口这个名词不会陌生。下面这张图,很好地解释了滑动窗口算法:
-
漏桶(Leaky Bucket)算法
- 漏桶算法其实很简单,可以粗略的认为就是注水漏水过程,往桶中以一定速率流出水,以任意速率流入水,当水超过桶流量则丢弃,因为桶容量是不变的,保证了整体的速率。
-
令牌桶算法:
- 所有的请求在处理之前都需要拿到一个可用的令牌才会被处理;
- 根据限流大小,设置按照一定的速率往桶里添加令牌;
- 桶设置最大的放置令牌限制,当桶满时、新添加的令牌就被丢弃或者拒绝;
- 请求达到后首先要获取令牌桶中的令牌,拿着令牌才可以进行其他的业务逻辑,处理完业务逻辑之后,将令牌直接删除;
- 令牌桶有最低限额,当桶中的令牌达到最低限额的时候,请求处理完之后将不会删除令牌,以此保证足够的限流;
它可以很好的解决临界问题。它与漏铜算法相比,令牌桶算法优势更好。
-
根据实际场景来选择合适的算法。
十五、服务降级与服务熔断思路
-
服务降级
- 简介:当服务器压力剧增的情况下,根据实际业务情况及流量,对一些服务和页面有策略的不处理或换种简单的方式处理,从而释放服务器资源以保证核心交易正常运作或高效运作。
- 使用场景: 服务降级主要用于什么场景呢?当整个微服务架构整体的负载超出了预设的上限阈值或即将到来的流量预计将会超过预设的阈值时,为了保证重要或基本的服务能正常运行,我们可以将一些 不重要或不紧急的服务或任务进行服务的延迟使用或暂停使用。
-
服务熔断:
- 类似保险丝,它是一种过载保护措施。
- 在互联网系统中,当下游服务因访问压力过大而响应变慢或失败,上游服务为了保护系统整体的可用性,可以暂时切断对下游服务的调用。
-
服务降级分类:
- 自动降级: 超时、失败次数、故障、限流
降级的提示比如:排队、错误提示页面、错误提示等
- 人工降级:秒杀、双十一大促
- 自动降级: 超时、失败次数、故障、限流
-
服务熔断的实现:
- Spring Cloud Hystrix是基于Netflix的开源框架Hystrix实现,该框架实现了服务熔断、线程隔离等一系列服务保护功能。
- 对于熔断机制的实现,Hystrix设计了三种状态:
1.熔断关闭状态(Closed)服务没有故障时,熔断器所处的状态,对调用方的调用不做任何限制。
2.熔断开启状态(Open)在固定时间内(Hystrix默认是10秒),接口调用出错比率达到一个阈值(Hystrix默认为50%),会进入熔断开启状态。进入熔断状态后, 后续对该服务接口的调用不再经过网络,直接执行本地的fallback方法。
3.半熔断状态(Half-Open)在进入熔断开启状态一段时间之后(Hystrix默认是5秒),熔断器会进入半熔断状态。所谓半熔断就是尝试恢复服务调用,允许有限的流量调用该服务,并监控调用成功率。如果成功率达到预期,则说明服务已恢复,进入熔断关闭状态;如果成功率仍旧很低,则重新进入熔断开启状态。 - 三个状态的转化关系如下图:
十七、数据库分库分表与高可用手段
17.1 数据库切库、分库、分表
-
数据库瓶颈:
- 单个库数据量太大(1T~2T):多个库
- 单个数据库服务器压力过大、读写瓶颈:多个库
- 单个表数据量过大:分表
-
数据库切库:
- 切库的基础及实际运用:读写分离: 主库用于存储、更新、以及数据的实时查询,而非实时的数据查询可以使用从库。
-
什么时候考虑分表:千万级别的数据量,使用分表迫在眉睫,使用索引和优化SQL对其访问的效率提升已经不是很明显了。
-
分表方式:
- 横向(水平)分表与纵向(垂直)分表
- 数据库分表:mybatis分表插件shardbatis2.0
十八、高并发之可用手段介绍
18.1 高可用的一些手段
- 任务调度系统分布式:elastic-job+zookeeper(当当开源的、无中心化的、分布式开源定时任务框架,提供了任务接口,以及可视化后台管理系统)
- 主备切换:apache curator+zookeeper分布式锁实现
- 监控报警机制
十九、课程总结
- 本篇博文主要包含两个部分,分别是并发编程与高并发解决方案。
- 主要讲了以下几个部分:
- 基础知识讲解与核心知识准备:
- 并发及并发的线程安全处理:
- 高并发处理的思路及手段:
高并发不难,关于在于在高并发场景下提供合适的解决方案与处理步骤。
- 基础知识讲解与核心知识准备: