Java并发编程与高并发解决方案解析

本文转载自:Java并发编程与高并发解决方案解析

现在在各大互联网公司中,随着日益增长的互联网服务需求,高并发处理已经是一个非常常见的问题,在这篇文章里面我们重点讨论两个方面的问题,一是并发编程,二是高并发解决方案。

文章中的代码实现详见

项目 GitHub地址:https://github.com/YueMa233/concurrency.git

首先我们先来聊一聊并发的概念

并发:同时拥有两个或多个线程,如果程序在单核处理器上运行,多个线程将交替地换入或者换出内存,这些线程是同时“存在”的,每个线程都处于执行过程中的某个状态,如果运行在多核处理器上,此时,程序中的每个线程都将分配到一个处理器上,因此可以同时运行。

高并发:高并发(High Concurrency)是互联网分布式系统架构设计中必须考虑的因素之一,它通常是指,通过保证系统能够并行处理很多请求。

并发和高并发的侧重点其实还是有一些细微的不同,在谈到并发时候,我们侧重于多个线程操作相同的资源,保证线程安全,合理利用资源。高并发是在系统运行的过程中短时间内遇到大量操作请求的情况,要求服务可以同时处理很多请求,提高程序性能。

知识技能

总体架构:SpringBoot、Maven、JDK8、MySQL

基础组件:Mybatis、 Guava、Lombok、Redis、Kafka

扫描二维码关注公众号,回复: 4818223 查看本文章

高级组件:Joda-Time、Atomic包、J.U.C、AQS、ThreadLocal、RateLimiter、Hystrix、threadPool、shardbatis、elastic-job…

cpu多级缓存

为什么需要CPU cache : CPU的频率太快了,快到主存跟不上,这样在处理器时钟周期内,CPU常常要等待主存,浪费资源。所以cache出现,是为了缓解cpu和内存之间速度的不匹配问题(结构:cpu>cache>memory)

CPU缓存有什么意义

1)时间局限性:如果某个数据被访问,那么在不久的将来它很可能被再次访问;

2)空间局限性:如果某个数据被访问,那么与它相邻的数据很快也可能被访问;

缓存一致性协议(MESI)

用于保证多个CPU cache之间缓存共享数据的一致
在这里插入图片描述乱序执行优化

处理器为提高运算速度而做出违背代码原有顺序的优化
在这里插入图片描述
java内存模型(Java Memory Model)

Java虚拟机规范中试图定义一种Java内存模型来屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致性的内存访问效果。

Java内存模型主要目标是定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量的操作底层细节。此处的变量(Variables)与Java编程中的变量有所区别,它包括了实例字段,静态字段,和构成数组对象的元素,但不包括局部变量与方法参数,因为后者是线程私有的,不会被共享,自然不会出现竞争问题。

![](https://img-blog.csdnimg.cn/20190104084920934.png?)
java内存模型 -同步八种操作
关主内存和工作内存之间的具体的交互协议,即一个变量如何从主内存拷贝到工作内存,如何从工作内存同步回主内存之类的实现细节,Java内存模型定义了以下8种操作来完成,虚拟机实现时必须保证下面的每一种操作都是原子的、不可再分割的。

lock(锁定):作用于主内存的变量,它把一个变量标识为一条线程独占的状态。

unlock(解锁):作用于主内存的变量,它把一个处于锁定状态之中的变量释放出来,释放出来后的变量可以被其他线程锁定。

read(读取):作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便后面load动作使用。

load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放到工作内存的变量副本中。

use(使用):作用于工作内存的变量,它把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值得字节码指令时会执行这个操作。

assin(赋值):作用于工作内存的变量,它将一个执行字节码引擎接受到的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。

store(存储):作用于工作内存的变量,它把工作内存中一个变量的值传送到主内存中,以便随后的write操作使用。

write(写入):作用于主内存的变量,它把store操作从工作内存得到的变量的值放入到主内存中去。

java内存模型 -同步规则

不允许read和load、store和write操作之一单独出现。

不允许一个线程丢弃它的最近的assign操作,即变量变量在工作内存中改变了后必须把变化同步到主内存。

不允许一个线程无原因地把数据从线程的工作内存同步到主内存。

一个新的变量只能从主内存中“诞生”,不允许在工作内存中直接使用一个未被初始化的变量。

一个变量在同一时刻只允许对其进行lock操作,但lock操作可以被同一个线程执行多次,多次执行后只有执行相同次数的unlock操作变量才会被解锁。

如果对一个变量执行lock操作,那将会清空工作内存中此变量的值,在执行引擎使用这个变量之前,需要重新执行load或assin初始化变量的值。

如果一个变量事先没有被lock操作锁定,那就不允许对它执行unlock操作,也不允许去unlock一个被其他线程锁定住的变量。

对一个变量执行unlock之前,必须先将此变量同步回主内存中。

(详情见JVM解析)…

并发的优势与风险
在这里插入图片描述
并发编程

首先我们先创建一个SpringBoot的项目,在这个项目里面我们进行一些并发操作。首先先构建一个项目,

项目构建的细节不在这里讨论直接给出项目框架以便于我们接下来的实战。

项目启动了
并发模拟-工具

Postman:Http请求模拟工具

Apache Bench(AB):Apache附带的工具,测试网站性能

JMeter:Apache组织开发的压力测试工具

并发模拟-代码:

CountDownLatch

CountDownLatch是一个同步工具类,用来协调多个线程之间的同步,或者说起到线程之间的通信(而不是用作互斥的作用)。

CountDownLatch能够使一个线程在等待另外一些线程完成各自工作之后,再继续执行。使用一个计数器进行实现。计数器初始值为线程的数量。当每一个线程完成自己任务后,计数器的值就会减一。当计数器的值为0时,表示所有的线程都已经完成了任务,然后在CountDownLatch上等待的线程就可以恢复执行任务。

用法:

CountDownLatch典型用法1:某一线程在开始运行前等待n个线程执行完毕。将CountDownLatch的计数器初始化为nnew CountDownLatch(n),每当一个任务线程执行完毕,就将计数器减1countdownlatch.countDown(),当计数器的值变为0时,在CountDownLatch上await()的线程就会被唤醒。一个典型应用场景就是启动一个服务时,主线程需要等待多个组件加载完毕,之后再继续执行。

CountDownLatch典型用法2:实现多个线程开始执行任务的最大并行性。注意是并行性,不是并发,强调的是多个线程在某一时刻同时开始执行。类似于赛跑,将多个线程放到起点,等待发令枪响,然后同时开跑。做法是初始化一个共享的CountDownLatch(1),将其计数器初始化为1,多个线程在开始执行任务前首先coundownlatch.await(),当主线程调用countDown()时,计数器变为0,多个线程同时被唤醒。

Semaphore

Semaphore是计数信号量。Semaphore管理一系列许可证。每个acquire方法阻塞,直到有一个许可证可以获得然后拿走一个许可证;每个release方法增加一个许可证,这可能会释放一个阻塞的acquire方法。然而,其实并没有实际的许可证这个对象,Semaphore只是维持了一个可获得许可证的数量。

Semaphore经常用于限制获取某种资源的线程数量。

线程安全性

定义:当多个线程访问某个类的时候,不管运行环境采用何种调度方式或者这些进程如何交替执行,并且在主调代码中不需要采用额外的同步或者是协同,这个类都能表现出正确的行为,那么就称这个类是线程安全的

原子性:提供了互斥访问,同一时刻只能有一个线程来对它进行操作。

Atomic包

Atomic:CAS、Unsafe.compareAndSwapInt

AtmomicLong、LongAdder

AtomicReference、AtomicReferenceFieldUpdater

AtomicStampReference:CAS的ABA问题

Synchronized:依赖JVM

Lock:依赖特殊的CPU指令,代码实现,ReentrantLock

Synchronized

修饰代码块:大括号括起来的代码,作用于调用的对象。

修饰方法:整个方法,作用于调用的对象。

修饰静态方法:整个静态方法,作用于所有对象。

修饰类:括号括起来的部分,作用于所有对象。

对比

synchronized:不可中断锁,适合竞争不激烈,可读性好。

Lock:可中断锁,多样化同步,竞争激烈时候能够维持常态。

Atomic:竞争激烈时候可以维持常态,比Lock性能好;只能同步一个值。

可见性:可见性是指当一个线程修改了共享变量的值,其他线程可以立即得知这个修改。

导致共享变量在线程间不可见的原因

1)线程交叉执行。

2)重排序结合线程交叉执行。

3)共享变量更新后的值没有在工作内存与主内存间及时更新

synchronized

1)线程解锁前,必须把共享变量的最新值刷新到主内存。

2)线程加锁时,将清空工作内存中的值,从而使用共享变量时需要从主内存中重新读取最新的值(加锁解锁是同一把锁)。

volatile(通过加入内存屏障和禁止重排序优化实现)

1)对volatile变量写操作时,会在写操作后面加入一条store屏障指令,将本地内存中的共享变量刷新到主内存。

不可保障在多线程计算中修饰值的安全性,不过它可以作为一个标识量。

volatile boolean inited = false;
 
//线程1
 
context = loadContext();
 
inited = true;
 
//线程2
 
while(!inited){
 
    sleep();
 
}
 
doSomethingWithConfig(context)

有序性:一个线程观察其他线程中的指令执行顺序,由于指令重排序的存在,该观察结果一般都是杂乱无序。

java内存模型中,允许编译器和处理器对指令进行重排序,但是重排序过程不会影响到单线程程序的执行,却会影响到多线程并发执行的正确性。

volatile、synchronized、Lock

先行发生原则

程序次序规律:一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作。

锁定规则:一个unlock操作先行发生于后面对同一个锁的lock操作。

volatile变量规则:对一个变量的写操作先行发生于后面对这个变量的读操作。

传递规则:操作A先行发生于操作B,而操作B又先行发生于操作C、则可以得出A先行发生于C。

线程启动规则:Thread对象的start()方法先行发生于此线程的每一个动作。

线程中断规则:对线程interrupt()方法调用先行发生于被中断线程的代码检测到中断事件的发生。

线程终止规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()返回值手段检测线程已经终止执行。

对象终结规则:一个对象的初始化完成先行发生于它的finalize()方法的开始。

发布对象:使一个对象能够被当前范围之外的代码所使用。

对象逸出:一种错误的发布。当一个对象还没有构造完成时,就使用它被其他线程所见。

安全发布对象

在静态初始化函数中初始化一个对象引用。

将对象的引用保存到volatile类型域或者AtomicRefereence对象中。

将对象的引用保存到某个正确构造对象的final类型域中。

将对象的引用保存到一个由锁保护的域中。

不可变对象

对象创建以后其状态就不改变

对象所有域都是final类型

对象是正确创建的(在对象创建期间,this引用没有逸出)

final关键字:类、方法、变量

修饰类:不能被继承

修饰方法:1、锁定方法不能被继承类修改;2、效率

修饰变量:1、基本数据类型变量;2、引用类型(引用不可再引用,值还是可以变化)。

(其他不可变的两种实现)

Collections.unmodifiableXXX:Collection、List、Set、Map…

Guava:ImmuableXXX:Collection、List、Set、Map…

线程封闭

Ad-hoc线程封闭:程序控制实现,最糟糕,忽略。

栈封闭:局部变量,无并发问题。

ThreadLocal线程封闭:特别好的封闭方法。

线程不安全类与写法

StringBuilder—StringBuffer

SimpleDataFormat—JodaTime

ArrayList、HashSet、HashMap等Collections

线程安全(同步容器)

ArrayList->Vector、Stack

HashMap->HashTable(key,value不能为null)

Collections.synchronizedXXX(List、Set、Map)

线程安全(并发容器 J.U.C)
在这里插入图片描述
ArrayList->CopyOnWriteArrayList(适合读多写少的场景、读写分离、读不加锁、写加锁)

HashSet、TreeSet->CopyOnWriteArraySet、ConcurrentSkipListSet

HashMap、TreeMap->ConcurrentHashMap、ConcurrentSkipListMap

AbstractQuenedSynchronizer-AQS(重中之重)在这里插入图片描述
1)使用Node实现FIFO队列、可以用于构建锁或者其他同步装置的基础框架。

2)利用了一个int类型表示状态。

3)使用方法是继承。

4)子类通过继承实现它的方法管理其状态{acquire和release}的方法操纵状态。

5)可以同时实现排它锁和共享锁(独占、共享)

AQS实现的大致思路:

AQS内部维护了一个CLH队列来管理锁,线程会首先尝试获取锁,如果失败,会将当前线程以及等待状态的信息包装成一个Node节点加入到同步队列,接着不断循环尝试获取锁,它的条件是当前节点为head的直接后继,如果失败就会阻塞自己,直到自己被唤醒,当持有锁的线程释放锁的时候会唤醒队列中的后继线程。

AQS同步组件

CountDownLatch(闭锁,通过一计数来保证线程是否需要一直阻塞)

Semaphore(可以控制线程并发数目)

CyclicBarrier

ReentrantLock(重点)

Condition

FutureTask

CountDownLath(再次强调)在这里插入图片描述
CountDownLatch是一个同步工具类,用来协调多个线程之间的同步,或者说起到线程之间的通信(而不是用作互斥的作用)。

CountDownLatch能够使一个线程在等待另外一些线程完成各自工作之后,再继续执行。使用一个计数器进行实现。计数器初始值为线程的数量。当每一个线程完成自己任务后,计数器的值就会减一。当计数器的值 为0时,表示所有的线程都已经完成了任务,然后在CountDownLatch上等待的线程就可以恢复执行任务。

用法:

CountDownLatch典型用法1:某一线程在开始运行前等待n个线程执行完毕。将CountDownLatch的计数器初始化为nnew CountDownLatch(n),每当一个任务线程执行完毕,就将计数器减1countdownlatch. countDown(),当计数器的值变为0时,在CountDownLatch上await()的线程就会被唤醒。一个典型应用场景就是启动一个服务时,主线程需要等待多个组件加载完毕,之后再继续执行。

CountDownLatch典型用法2:实现多个线程开始执行任务的最大并行性。注意是并行性,不是并发,强调的是多个线程在某一时刻同时开始执行。类似于赛跑,将多个线程放到起点,等待发令枪响,然后同时开 跑。做法是初始化一个共享的CountDownLatch(1),将其计数器初始化为1,多个线程在开始执行任务前首先coundownlatch.await(),当主线程调用countDown()时,计数器变为0,多个线程同时被唤醒。

Semaphore(再次强调)在这里插入图片描述
Semaphore是计数信号量。Semaphore管理一系列许可证。每个acquire方法阻塞,直到有一个许可证可以获得然后拿走一个许可证;每个release方法增加一个许可证,这可能会释放一个阻塞的acquire方法。然而,其实并没有实际的许可证这个对象,Semaphore只是维持了一个可获得许可证的数量。

Semaphore经常用于限制获取某种资源的线程数量。

CyclicBarrier在这里插入图片描述
类似于CountDownLatch,不过可以循环。

CountDownLatch描述的是一个或n个线程需要等待其他线程完成某个操作之后才能往下执行;CyclicBarrier描述的是多个线程之间等待直到所有线程都满足条件后才可执行。

ReentrantLock与锁

ReentrankLock(可重入锁)和synchronized的区别

可重入性

锁的实现

性能区别

功能区别

ReentrantLock独有的功能

可以指定公平锁或是非公平锁。

提供了一个Condition类,可以分组唤醒需要唤醒的线程。

提供能够中断等待锁的线程的机制,Lock.lockInterruptibly()。

public ReentrantLock(boolean fair) {
 
sync = fair  new FairSync() : new NonfairSync();
 
}

Condition

实现一个简单的有界队列,队列为空时,队列的删除操作将会阻塞直到队列中有新的元素,队列已满时,队列的插入操作将会阻塞直到队列出现空位。

FutureTask
在这里插入图片描述
详情可以参考一位老哥的博客:

https://www.cnblogs.com/dolphin0520/p/3949310.html

Fork/Join框架(工作窃取算法)
在这里插入图片描述详情可以参考另一位老哥的博客:

https://blog.csdn.net/qq_39266910/article/details/79928177

BlockingQueue(阻塞队列)在这里插入图片描述
实现类

ArrayBlockingQueue(有界的阻塞队列,数据结构数组)

DelayQueue

LinkedBlockingQueue(数据结构链表)

PriorityBlockingQueue(无界)

SynchronousQueue(同步只能放一个元素)

线程池

new Thread 弊端

每次new Thread新建对象,性能差。

线程缺乏统一管理,可能无限制的新建线程,相互竞争,有可能占用过多系统资源导致死机或OOM。

缺少更多功能,如更多执行定期执行,线程中断。

线程池的好处

重用存在的线程,减少对象的创建,消亡的开销,性能佳。

可有效控制最大并发线程数,提高系统资源利用率,同时可以避免过多资源竞争,避免阻塞。

提供定时执行,定期执行,单线程,并发数控制等功能。

ThreadPoolExecutor

corePoolSize:核心线程数

maximumPoolSize:线程最大线程数

workQueue:阻塞队列,存储等待执行的任务,很重要,会对线程池运行过程产生重大影响

(任务提交时可以分三种情况:1、当任务提交时线程数小于corePoolSize不管有没有空闲线程直接新建线程;2、如果线程数量大于等于corePoolSize且小于maximumPoolSize则只有当workQueue满的时候新建线程去处理任务;3、若corePoolSize和maximumPoolSize设置大小一样时,说明线程池大小固定,有新任务时看workQueue中任务是否有空余,若有:放任务进去,若没有且线程数大于maximumPoolSize则通过一个叫做“拒绝策略的参数”指定策略去处理任务。)

keepAliveTime:线程没有任务执行时最多保持多久时间终止

(当线程池中的数量大于corePoolSize时,如果没有新的任务提交,核心线程不会立即销毁,会等待一段时间。)

util:keepAliveTime的时间单位

threadFactory:线程工厂,用来创建线程

rejectHandler:当拒绝任务时的策略

(直接抛出异常、用调用者所在的线程执行任务、丢弃队列最靠前的任务并执行当前任务、直接丢弃这个任务)

看完了这几个参数之后我们再来看看下面的几个方法:

execute():提交任务,交给线程池执行

submit():提交任务,能够返回结果execute+Future

shutdown():关闭线程池,等待任务都执行完

shutdownNow:关闭线程池,不等待任务执行完

getTaskCount():线程池已执行和未执行的任务总数

getCompletedTaskCount():已完成的任务数量

getPoolSize():线程池当前线程数量

getActiveCount():当前线程池中正在执行任务的线程总数在这里插入图片描述Executor框架接口

Executors.newCachedThreadPool(可缓存线程池)

Executors.newFixedThreadPool(定长线程池)

Executors.newScheduledThreadPool(定长线程池,支持定时周期执行)

Executors.newSingleThreadExcutor(单线程线程池)

线程池-合理配置

CPU密集型任务,就需要尽量压榨CPU,参考值可以设为NCPU+1

IO密集型任务,参考值可以设为2*NCPU

死锁

两个或两个以上进程在执行任务的时候由于争夺资源等待资源而发生互相等待的状态。

四个必要条件

互斥条件(进程对锁分配的资源排他性的使用,即在一段时间内某资源只由一个进程占用,如果还有其他进程想要获得资源只能等待)

请求和保持条件(进程已经保持了至少一个资源但还申请了新的资源请求,而该资源已被其他进程占有,此时请求进程阻塞,但又对自己保持资源保持不放)

不剥夺条件(进程占用的资源在未使用完之前不可被剥夺,自己使用完之后释放)

环路等待条件(发生死锁时一定有进程存在资源环形的链)

死锁实现代码

package com.ma.concurrency.example.deadLock;
 
 
import lombok.extern.slf4j.Slf4j;
 
 
/**
 
* 一个简单的死锁类
 
* 当DeadLock类的对象flag==1(td1),先锁定o1,睡眠500毫秒
 
* 而当td1在睡眠的时候另一个flag==0的对象(td2)线程启动,先锁定o2,睡眠500毫秒
 
* td1睡眠结束后需要锁定o2才能继续执行,而此时o2已被td2锁定;
 
* td2睡眠结束后需要锁定o1才能继续执行,而此时o1已被td1锁定;
 
* td1、td2相互等待,都需要对方锁定的资源才能继续执行,从而发生死锁
 
*/
 
@Slf4j
 
public class DeadLock implements Runnable{
 
public int flag = 1;//静态对象是类的所有对象共享的
 
private static Object o1 = new Object(),o2 = new Object();
 
 
@Override
 
public void run() {
 
log.info("flag:{}",flag);
 
if(flag == 1){
 
synchronized (o1){
 
try {
 
Thread.sleep(5000);
 
} catch (InterruptedException e) {
 
e.printStackTrace();
 
}
 
synchronized (o2){
 
log.info("1");
 
}
 
}
 
}
 
if(flag == 0) {
 
synchronized (o2) {
 
try {
 
Thread.sleep(5000);
 
} catch (InterruptedException e) {
 
e.printStackTrace();
 
}
 
synchronized (o1) {
 
log.info("0");
 
}
 
}
 
}
 
}
 
 
public static void main(String[] args) {
 
DeadLock td1 = new DeadLock();
 
DeadLock td2 = new DeadLock();
 
td1.flag = 1;
 
td2.flag = 0;
 
//td1,td2都处于可执行状态,但JVM线程调度先执行哪个线程是不确定的。
 
//td2的run()可能在td1的run()之前运行
 
new Thread(td1).start();
 
new Thread(td2).start();
 
}
}

如何避免死锁

1)线程按顺序加锁时候注意顺序

2)加锁实现

3)死锁检测

以上并发编程的部分我们已经学到了不少的知识,接下来我们聊聊多线程并发在实践中采用哪些方式较好

1)使用本地变量

2)使用不可变类

3)最小化锁的作用域范围:S=1/(1-a+a/n) a:并行处理所占的比例;n:并行节点的个数;S:加速比

4)使用线程池的Executor,而不是直接new Thread操作

5)宁可使用同步也不要使用线程的wait和notify

6)使用BlockingQueue实现生产-消费模式

7)使用同步集合而不是加了锁的同步集合

8)使用Semaphore创建有界的访问

9)宁可使用同步代码块也不使用同步方法

10)避免使用静态变量

Spring与线程安全

Spring bean:single、prototype

无状态对象

HashMap和ConcurrentHashMap

HashMap

存值操作

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
 
boolean evict) {
 
Node<k,v>[] tab; Node<k,v> p; int n, i;
 
if ((tab = table) == null || (n = tab.length) == 0)
 
n = (tab = resize()).length;
 
if ((p = tab[i = (n - 1) & hash]) == null)
 
tab[i] = newNode(hash, key, value, null);
 
else {
 
Node<k,v> e; K k;
 
if (p.hash == hash &&
 
((k = p.key) == key || (key != null && key.equals(k))))
 
e = p;
 
else if (p instanceof TreeNode)
 
e = ((TreeNode<k,v>)p).putTreeVal(this, tab, hash, key, value);
 
else {
 
for (int binCount = 0; ; ++binCount) {
 
if ((e = p.next) == null) {
 
p.next = newNode(hash, key, value, null);
 
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
 
treeifyBin(tab, hash);
 
break;
 
}
 
if (e.hash == hash &&
 
((k = e.key) == key || (key != null && key.equals(k))))
 
break;
 
p = e;
 
}
 
}
 
if (e != null) { // existing mapping for key
 
V oldValue = e.value;
 
if (!onlyIfAbsent || oldValue == null)
 
e.value = value;
 
afterNodeAccess(e);
 
return oldValue;
 
}
 
}
 
++modCount;
 
if (++size > threshold)
 
resize();
 
afterNodeInsertion(evict);
 
return null;
 
}</k,v></k,v></k,v></k,v>

单线程下HashMap的扩容是安全的在这里插入图片描述多线程下的HashMap扩容可能会发生循环问题
1,在这里插入图片描述
2,在这里插入图片描述
3,在这里插入图片描述
4,在这里插入图片描述ConcurrentHashMap

java7的Segment()技术在这里插入图片描述
在Java8中为了进一步提高并发度当数组超过8(默认)会转化为红黑树寻址时间复杂度从O(N)变化为O(logN)
在这里插入图片描述
回顾我们在上面学习的一些东西:在这里插入图片描述
接下来我们进入第二阶段:高并发的解决方案,我们主要学习高并发处理思路与手段

扩容

垂直扩容(纵向扩展):提高系统部件能力

水平扩容(横向扩容):增加更多系统成员来实现(集群)

扩容-数据库

读操作扩展:memcache、redis、CDN等缓存

写操作扩展:Cassandra、Hbase等

缓存特征

命中率:命中数/(命中数+没有命中数)

最大元素(空间)

清空策略:FIFO(先进先出)、LFU(最少使用)、LRU(最近最少使用)、过期时间、随机等

缓存命中率的影响因素

业务场景和业务需求

缓存的设计(粒度和策略)

缓存容量和基础设施

缓存分类和应用场景

本地缓存:编程实现(成员变量、局部变量、静态变量)Guava Cache

分布式缓存:Memcache、Redis

Guava Cache

缓存是日常开发中经常应用到的一种技术手段,合理的利用缓存可以极大的改善应用程序的性能。

Guava官方对Cache的描述连接

缓存在各种各样的用例中非常有用。例如,当计算或检索值很昂贵时,您应该考虑使用缓存,并且不止一次需要它在某个输入上的值。

缓存ConcurrentMap要小,但不完全相同。最根本的区别在于一个ConcurrentMap坚持所有添加到它直到他们明确地删除元素。

另一方面,缓存一般配置为自动退出的条目,以限制其内存占用。在某些情况下,一个LoadingCache可以即使不驱逐的条目是有用的,因为它的自动缓存加载。

一位老哥的博客:

https://blog.csdn.net/u012881904/article/details/79263787

Memcache

memcache是一套分布式的高速缓存系统,由LiveJournal的Brad Fitzpatrick开发,但目前被许多网站使用以提升网站的访问速度,尤其对于一些大型的、需要频繁访问数据库的网站访问速度提升效果十分显著 [1] 。这是一套开放源代码软件,以BSD license授权发布。

MemCache的工作流程如下:先检查客户端的请求数据是否在memcached中,如有,直接把请求数据返回,不再对数据库进行任何操作;如果请求的数据不在memcached中,就去查数据库,把从数据库中获取的数据返回给客户端,同时把数据缓存一份到memcached中(memcached客户端不负责,需要程序明确实现);每次更新数据库的同时更新memcached中的数据,保证一致性;当分配给memcached内存空间用完之后,会使用LRU(Least Recently Used,最近最少使用)策略加上到期失效策略,失效数据首先被替换,然后再替换掉最近未使用的数据。 [2]

Memcache是一个高性能的分布式的内存对象缓存系统,通过在内存里维护一个统一的巨大的hash表,它能够用来存储各种格式的数据,包括图像、视频、文件以及数据库检索的结果等。简单的说就是将数据调用到内存中,然后从内存中读取,从而大大提高读取速度。

Memcache是danga的一个项目,最早是LiveJournal 服务的,最初为了加速 LiveJournal 访问速度而开发的,后来被很多大型的网站采用。

Memcached是以守护程序(监听)方式运行于一个或多个服务器中,随时会接收客户端的连接和操作。

一位老哥的博客:

https://www.2cto.com/database/201703/611509.html

Redis

redis是一个key-value存储系统。和Memcached类似,它支持存储的value类型相对更多,包括string(字符串)、list(链表)、set(集合)、zset(sorted set --有序集合)和hash(哈希类型)。这些数据类型都支持push/pop、add/remove及取交集并集和差集及更丰富的操作,而且这些操作都是原子性的。在此基础上,redis支持各种不同方式的排序。与memcached一样,为了保证效率,数据都是缓存在内存中。区别的是redis会周期性的把更新的数据写入磁盘或者把修改操作写入追加的记录文件,并且在此基础上实现了master-slave(主从)同步。

Redis 是一个高性能的key-value数据库。 redis的出现,很大程度补偿了memcached这类key/value存储的不足,在部 分场合可以对关系数据库起到很好的补充作用。它提供了Java,C/C++,C#,PHP,JavaScript,Perl,Object-C,Python,Ruby,Erlang等客户端,使用很方便。 [1]

Redis支持主从同步。数据可以从主服务器向任意数量的从服务器上同步,从服务器可以是关联其他从服务器的主服务器。这使得Redis可执行单层树复制。存盘可以有意无意的对数据进行写操作。由于完全实现了发布/订阅机制,使得从数据库在任何地方同步树时,可订阅一个频道并接收主服务器完整的消息发布记录。同步对读取操作的可扩展性和数据冗余很有帮助。

redis的官网地址,非常好记,是redis.io。(特意查了一下,域名后缀io属于国家域名,是british Indian Ocean territory,即英属印度洋领地)

目前,Vmware在资助着redis项目的开发和维护。

一位老哥的博客:

https://blog.csdn.net/liqingtx/article/details/60330555

Redis和Memcache区别

1.Redis是什么

这个问题的结果影响了我们怎么用Redis。如果你认为Redis是一个key value store, 那可能会用它来代替MySQL;如果认为它是一个可以持久化的cache, 可能只是它保存一些频繁访问的临时数据。Redis是REmote DIctionary Server的缩写,在Redis在官方网站的的副标题是A persistent key-valuedatabase with built-in net interface written in ANSI-C for Posix systems,这个定义偏向key value store。还有一些看法则认为Redis是一个memory database,因为它的高性能都是基于内存操作的基础。另外一些人则认为Redis是一个data structure server,因为Redis支持复杂的数据特性,比如List, Set等。对Redis的作用的不同解读决定了你对Redis的使用方式。

互联网数据目前基本使用两种方式来存储,关系数据库或者key value。但是这些互联网业务本身并不属于这两种数据类型,比如用户在社会化平台中的关系,它是一个list,如果要用关系数据库存储就需要转换成一种多行记录的形式,这种形式存在很多冗余数据,每一行需要存储一些重复信息。如果用key value存储则修改和删除比较麻烦,需要将全部数据读出再写入。Redis在内存中设计了各种数据类型,让业务能够高速原子的访问这些数据结构,并且不需要关心持久存储的问题,从架构上解决了前面两种存储需要走一些弯路的问题。

  1. Redis不可能比Memcache快

很多开发者都认为Redis不可能比Memcached快,Memcached完全基于内存,而Redis具有持久化保存特性,即使是异步的,Redis也不可能比Memcached快。但是测试结果基本是Redis占绝对优势。一直在思考这个原因,目前想到的原因有这几方面。

Libevent。和Memcached不同,Redis并没有选择libevent。Libevent为了迎合通用性造成代码庞大(目前Redis代码还不到libevent的1/3)及牺牲了在特定平台的不少性能。Redis用libevent中两个文件修改实现了自己的epoll event loop(4)。业界不少开发者也建议Redis使用另外一个libevent高性能替代libev,但是作者还是坚持Redis应该小巧并去依赖的思路。一个印象深刻的细节是编译Redis之前并不需要执行./configure。

CAS问题。CAS是Memcached中比较方便的一种防止竞争修改资源的方法。CAS实现需要为每个cache key设置一个隐藏的cas token,cas相当value版本号,每次set会token需要递增,因此带来CPU和内存的双重开销,虽然这些开销很小,但是到单机10G+ cache以及QPS上万之后这些开销就会给双方相对带来一些细微性能差别(5)。

  1. 单台Redis的存放数据必须比物理内存小

Redis的数据全部放在内存带来了高速的性能,但是也带来一些不合理之处。比如一个中型网站有100万注册用户,如果这些资料要用Redis来存储,内存的容量必须能够容纳这100万用户。但是业务实际情况是100万用户只有5万活跃用户,1周来访问过1次的也只有15万用户,因此全部100万用户的数据都放在内存有不合理之处,RAM需要为冷数据买单。

这跟操作系统非常相似,操作系统所有应用访问的数据都在内存,但是如果物理内存容纳不下新的数据,操作系统会智能将部分长期没有访问的数据交换到磁盘,为新的应用留出空间。现代操作系统给应用提供的并不是物理内存,而是虚拟内存(Virtual Memory)的概念。

基于相同的考虑,Redis 2.0也增加了VM特性。让Redis数据容量突破了物理内存的限制。并实现了数据冷热分离。

  1. Redis的VM实现是重复造轮子

Redis的VM依照之前的epoll实现思路依旧是自己实现。但是在前面操作系统的介绍提到OS也可以自动帮程序实现冷热数据分离,Redis只需要OS申请一块大内存,OS会自动将热数据放入物理内存,冷数据交换到硬盘,另外一个知名的“理解了现代操作系统(3)”的Varnish就是这样实现,也取得了非常成功的效果。

作者antirez在解释为什么要自己实现VM中提到几个原因(6)。主要OS的VM换入换出是基于Page概念,比如OS VM1个Page是4K, 4K中只要还有一个元素即使只有1个字节被访问,这个页也不会被SWAP, 换入也同样道理,读到一个字节可能会换入4K无用的内存。而Redis自己实现则可以达到控制换入的粒度。另外访问操作系统SWAP内存区域时block进程,也是导致Redis要自己实现VM原因之一。

  1. 用get/set方式使用Redis

作为一个key value存在,很多开发者自然的使用set/get方式来使用Redis,实际上这并不是最优化的使用方法。尤其在未启用VM情况下,Redis全部数据需要放入内存,节约内存尤其重要。

假如一个key-value单元需要最小占用512字节,即使只存一个字节也占了512字节。这时候就有一个设计模式,可以把key复用,几个key-value放入一个key中,value再作为一个set存入,这样同样512字节就会存放10-100倍的容量。

这就是为了节约内存,建议使用hashset而不是set/get的方式来使用Redis,详细方法见参考文献(7)。

  1. 使用aof代替snapshot

Redis有两种存储方式,默认是snapshot方式,实现方法是定时将内存的快照(snapshot)持久化到硬盘,这种方法缺点是持久化之后如果出现crash则会丢失一段数据。因此在完美主义者的推动下作者增加了aof方式。aof即append only mode,在写入内存数据的同时将操作命令保存到日志文件,在一个并发更改上万的系统中,命令日志是一个非常庞大的数据,管理维护成本非常高,恢复重建时间会非常长,这样导致失去aof高可用性本意。另外更重要的是Redis是一个内存数据结构模型,所有的优势都是建立在对内存复杂数据结构高效的原子操作上,这样就看出aof是一个非常不协调的部分。

其实aof目的主要是数据可靠性及高可用性,在Redis中有另外一种方法来达到目的:Replication。由于Redis的高性能,复制基本没有延迟。这样达到了防止单点故障及实现了高可用。

高并发场景下缓存常见问题

缓存一致性在这里插入图片描述
缓存并发问题
在这里插入图片描述
缓存穿透问题
在这里插入图片描述
缓存的雪崩现象
在这里插入图片描述
消息队列在这里插入图片描述
消息队列的特性

业务无关:只做消息分发

FIFO:先投递先到达

容灾:节点的动态增删和消息的持久化

性能:吞吐量提升、系统内部通信效率提高

为什么需要消息队列

【生产】和【消费】的速度或稳定性等因素不一致

消息队列的好处

业务解耦

最终一致性

广播

错峰有流控

猜你喜欢

转载自blog.csdn.net/GY325416/article/details/85755513
今日推荐