写在前
随着学习的渐渐深入,发现自己已不再是当年那懵懂的少年,最开始用多线程是new Thrad(),而且屡试不爽。后来,听说实现Runnable接口也可以实现多线程,但是开启线程还是要放到Thread里面启动,所以一般要是不涉及多继承的话,就不用Runnable了。又到了后来,听说还有一种方式创建线程的,叫什么Callable和Future,因为之前连泛型啥的都整不明白,接口之间使用也不熟,一直也不会用,曾好几次尝试都失败了…再到后来,我搞了一个同步组件,当时架构师提的要求实在是太高了,现有的框架竟然无法实现,这时我又意识到了自己的多线程编程很菜了,不然就可以自己手撕一个同步组件。时隔3个月,物是人非,总算还是摸上来了——高并发编程,颤抖吧!
一、基本概念
- 并发: 同时拥有两个或者多个线程, 如果程序在单核处理器上运行, 多个线程将交替地换入或者换出内存, 这些线程是同时”存在”的,每个线程都将处于执行过程中的某个状态, 如果运行在多核处理器上, 此时, 程序中的每个线程都将分配到一个处理器核上, 因此可以同时运行.
- 高并发: high Concurrency 通过设计保证系统能够同时并行处理很多请求.
并发: 多个线程操作相同的资源,保证线程安全,合理使用资源
高并发: 服务能同时处理很多请求,提高程序性能
二、并发编程的基础
- Cpu 多级缓存
为什么要缓存?
CPU的频率太快,快到主内存跟不上, 这样在处理器时钟周期内,CPU常常需要等待主存,浪费资源。所以cache的出现,是为了缓解CPU和内存之间速度的不匹配问题。结构(cpu->cache->memory)
2.CPU缓存的实际意义
3.缓存的一致性
用于保证多个CPU cache之间缓存共享数据的一致
3.1 MESI协议: 缓存的四种状态(缓存一致性)
M: Modified修改状态
该缓存行只被缓存在该CPU的缓存中,并且是被修改过的,因此他与主存中的数据是不一致的,该缓存行中的内存,需要在未来的某个时间点写回主存。这个时间点允许其他CPU读取主存中相应的内存之前,当这里的值被写回之后,该缓存行的状态会变成E状态(独享状态)。
E: Exclusive独享状态
独享状态的缓存行只被缓存在该CPU的缓存中,他是未被修改过的,与主存中的数据是一致的,这个状态可以在任何时刻,当有其他CPU读取该内存时,变成共享状态S;当CPU修改该内存中的缓存行时,该状态可以变成M状态。
S: Shared共享状态
该缓存行可能被多个CPU进行缓存,并且各个缓存中的数据与主存中的数据是一致的,当有一个CPU修改该缓存行的时候,其他CPU中的该缓存行是可以被作废的,变成I无效状态。
I: Invalid无效状态
这个缓存行是无效的,可能是有其他CPU修改了这个缓存行。
3.2 四种操作
local read:读本地缓存中的数据
local write:将数据写到本地的缓存里
remote read:将主内存中的数据读取过来
remote write:将数据写回到主存中
3.3 状态之间的转换关系
在一个典型的多核系统中,每一个核都会有自己的缓存来共享主存总线,每一个相应的CPU它会发出读写请求,而缓存的目的是为了减少CPU读写共享主存的次数。
一个缓存除了在I状态,都可以完成读请求。
一个写请求,只有在该缓存行在M或E状态时,才能被执行
如果一个缓存行在S状态,它必修先将这个缓存中的该缓存行变成无效状态,这个操作通常用广播的方式来完成,这个时候,它既不允许不同的CPU同时修改同一个缓存行,即使修改缓存行中的不同的数据也是不允许的,这里主要解决的是缓存一致性的问题。
M和E状态的数据总是精确的,和主存中的数据是一致的。而S状态可能是非一致的,
4.CPU多级缓存——乱序执行优化
处理器为提高运算速度而违背代码原有顺序的优化。
单核情况下的优化,一般是不会影响结果。
多核处理器对缓存的读写,可能会出现操作顺序变化的问题.
5.Java 内存模型(java memory model,JMM)
Java虚拟机是为了屏蔽底层操作系统的差异性的,让java文件在各种操作系统下均能允许而构建出了java内存模型。规范了内存与java虚拟机是如何协同工作的,规范了一个线程如何和何时能看到其他线程修改过的共享变量的值,以及在必须时如何同步的访问共享变量。
堆Heap:
他是运行时的数据区,由垃圾回收来负责的,可以动态分配大小, 生存期也不必事先告诉编译器,因为他是在运行时动态分配内存,java的垃圾收集器会自动回收这些不再使用的数据,缺点是因为要在运行时动态分配内存,所以运行速度相对慢一点。
栈Stack:
存取速度比堆要快,仅次于计算机里的寄存器,栈里面的数据是可以共享的,缺点是存放在站内的数据必须确定大小和生存期,缺乏灵活性,主要存放一些基本类型的变量和对象的引用。
说明:一个对象的成员变量可能会随着对象自身存放在堆上,不管这个对象是基本类型还是引用类型;静态成员变量跟随着类的定义存放在堆中。存放在堆上的对象可以被持有这个对象的引用的线程访问,当一个线程可以访问一个对象的时候,它也可以访问这个对象的成员变量,当两个线程同时访问这个对象的同一个方法,但是每个线程会对这个对象的成员变量进行私有拷贝。
CPU、寄存器、缓存、主存硬件结构图:
计算机硬件与java内存模型的对应关系:
java内存模型抽象结构图:
java内存模型-同步八种操作:
lock锁定:作用于主内存的变量,把一个变量标识为一条线程独占状态
unlock解锁:作用于主内存的变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定
read读取:作用于主内存的变量,把一个变量值从主内存传输到线程的工作内存中,以便随后的load动作使用
load载入:作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中
use使用:作用于工作内存的变量,把工作内存中的一个变量值传递给执行引擎
assign赋值:作用于工作内存的变量,它把一个从执行引擎接收到的值赋值给工作内存的变量
store存储:作用于工作内存的变量,把工作内存中的一个变量的值传送到主内存中,以便随后的write的操作
write写入:作用于主内存的变量,它把store操作从工作内存中的一个变量的值传送到主内存的变量中
同步规则:
如果要把一个变量从主内存中复制到工作内存,就需要按顺序地执行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操作)
6.并发的优势与风险
三、并发编程与线程安全
1.环境搭建与准备
用SpringBoot创建工程即可
2.并发模拟的工具
//lombok.extern.slf4j.Slf4j
@Slf4j 是lombok包下的,需要弄好lombok插件
四、线程安全性
定义:当多个线程访问某个类时,不管运行时环境采用何种调度方式或者这些进程将如何交替执行,并且在主调代码中不需要任何额外的同步或协同,这个类都能表现出正确的行为,那么就称这个类是线程安全的。
线程安全性: 体现在3个方面
原子性:提供了互斥访问,同一时刻只能有一个线程来对他进行操作
可见性:一个线程对主内存的修改可以及时的被其他线程观察到
有序性:一个线程观察其他线程中的指令执行顺序,由于指令重排序的存在,该观察结果一般杂乱无序
1.原子性——atomic包
Jdk中有个atomic包,里面包含CAS的源码,主要通过CAS来保证原子性和
1.1 AtomicInteger 类
是具有原子性的线程安全的整数,该类定义了一个用volatile修饰的int变量,volatile关键字具有可见性和有序性。
private volatile int value;
int count = 0;//定义一个基本数据类型的整数变量进行计数
AtomicInteger count = new AtomicInteger(0);//定义一个具有原子操作的整数进行计数,该对象有个incrementAndGet()方法,能线程安全的进行加1操作.
public final int incrementAndGet() {
return unsafe.getAndAddInt(this, valueOffset, 1) + 1;
}
private static final Unsafe unsafe = Unsafe.getUnsafe();//Unsafe类涉及内核操作,不允许用new关键字创建对象,只能通过反射进行获取实例
//var1 是持有该数据的对象
//var2 是本线程的当前值2
//var4 是1
public final int getAndAddInt(Object var1, long var2, int var4) {
int var5;//调用底层方法获取主内存中当前的值
do {
var5 = this.getIntVolatile(var1, var2);//获取主内存的最新值
} while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));//判断本线程当前值var2 与主内存当前值var5 是否相同,相同才进行后面的var5 = var5+var4;
return var5;
}
public final native boolean compareAndSwapInt(Object var1, long var2, int var4, int var5);//用native关键字修饰,不是java实现的,是本地方法栈中的方法
compareAndSwap原理:CAS就是死循环内不断尝试获取目标值,直到修改成功。跳出循环的条件是取主内存的当前值,与本线程的当前值进行对比,如果相同就进行修改交换。
竞争激烈的时候修改成功率比较低,性能会受到影响。
补充:
关于CAS中compareAndSwapInt(var1, var2, var5, var5 + var4)的理解
compareAndSwapInt(var1, var2, var5, var5 + var4)换成 compareAndSwapInt(obj, offset, expect, update)能清楚一些,如果obj内的value和expect相等,就证明没有其他线程改变过这个变量,那么就更新它为update,如果这一步CAS没有成功,那就采用自旋的方式继续进行CAS操作。这块是一个CPU指令完成的,依旧是原子操作。
1.2 AtomicLong类 与 LongAddr类(jdk8新增类)
AtomicLong类和AtomicInteger 类具有相同的功能,除了int变long。后面的LongAddr对CAS这种死循环判断做出了改进,就是对于普通类型的Long和Double变量,JVM允许将64位的读操作或写操作拆成2个32位的操作。热点分离,提高并行度,将单点的更新压力分散到各个节点上,缺点是并行度合成的结果可能不准确。
1.3 AtomicReference类
上图输出的结果是:4
1.4 AtomicIntegerFieldUpdater类
更新某个类的某个指定字段的值,保证原子性,该字段必须用volatile修饰,同时不能用static修饰。如示例中的count
上图输出:success 1, 120和failed, 120
1.5 AtomicStampReference类,主要解决CAS的ABA问题
CAS的ABA问题:
一个值被其他线程改变过,又被改回原来的值,虽然当前线程去对比的时候发现值没变,但这不符合CAS的设计思想。每次变量更新的时候,版本号加1,所以每次的版本号是递增的操作。
1.6 AtomicLongArray类
额外多一个索引值需要更新
1.7 AtomicBoolean类
compareAndSet方法,在多线程中,让某行代码只执行一次,其他线程由于他们的工作内存的值和主内存的值不一样,所以不进行赋值等修改操作.
2.原子性——锁
synchronized关键字:依赖JVM,因此在这个关键字作用对象的作用范围内,上锁保证同一时刻只有一个线程在操作这个对象,所以保证原子性。
Lock接口:依赖特殊的CPU指令,使用jdk的代码实现,接口的实现代表有ReentrantLock。
synchronized四种修饰:
代码示例:
2.1修饰代码块
2.2 修饰方法
模拟同步环境执行输出:
因为有锁的存在,所以是依次输出0-9,然后再输出一遍0-9;将test1改成test2也是一样的(下图是同一个对象)。
下图是不同对象,只能保证第一个线程池的10个线程按顺序输出,不能保证2个线程之间的输出顺序。
注:test2方法上带有关键字synchronized,当有类继承该方法所在类时,是无法继承synchronized属性的,因为synchronized不属于方法声明的一部分;若子类硬要实现的话,可在子类复写该方法显式的加上synchronized关键字。
2.3修饰静态方法
2.4 修饰一个类(就是用类对象,也称该类的字节码)
小结:
Atomic:竞争激烈时能维持常态,比Lock性能好;只能同步一个值;
synchronized不可中断锁,适合竞争不激烈,可读性好,使用简单;
Lock:可中断锁,多样化同步,竞争激烈时能维持常态。
3.可见性-synchronized
JVM关于synchronized的两条规定:
线程解锁前,必须把共享变量的最新值刷新到主内存;
线程加锁时,将清空工作内存中共享变量的值,从而使用共享变量时需要从主内存中重新读取最新的值(注意:加锁和解锁是同一把锁)
4.可见性-volatile
4.1 内存屏障和禁止重排序
通过加入内存屏障和禁止重排序优化来实现可见性,是CPU指令级别进行操作的。
对volatile变量写操作时,会在写操作后加入一条store屏障指令,将本地内存中的共享变量值刷新到主内存;
对volatile变量读操作时,会在读操作前加入一条load屏障指令,从主内存中读取共享变量。
对volatile修饰的int型变量count,分析 count++; 这条语句执行原理:
第一步:获取主存中的count的值(最新);
第二步:count+1的操作;
第三步:将count写回主存。
尽管当前线程取值时拿到最新值,但因为写的时候直接覆盖了其他线程在你执行+1操作的时候提交的写,所以失去了原子性。
4.2 Volatile的使用场景的2个条件:
对变量的写操作不依赖于当前值;
该变量没有包含具有其他变量的不变的式子中。
4.3使用场景
适合状态标识量
还有一个使用场景是doubleCheck (单例模式下的双重检测机制配合volatile使用可达到线程安全的效果)
5.有序性
Java内存模型中,允许编译器和处理器对指令进行重排序,但是重排序过程不会影响到单线程程序的执行,却会影响到多线程并发执行的正确性。
5.1使用volatile synchronized lock等手段保证的有序性
5.2java先天有序性——Happens-before原则(先行发生原则)
a.程序次序规则:一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作;(保证单线程,但不保证多线程);
b.锁定规则:一个unlock操作先行发生于后面对同一个锁的lock操作,同一个锁,必须先释放,才能再lock上锁;
c.Volatile变量规则:对一个变量的写操作先行发生于后面对这个变量的读操作;
d.传递规则:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C;
e.线程启动规则:Thread对象的start()方法先行发生于此线程的每一个动作;
f.线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生;
g.线程终结规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行;
h.对象终结规则:一个对象的初始化完成先行发生于他的finalize()方法的开始。
小结:如果两个操作的执行次序不能从happens-before原则推导出来,那么久无法保证他们的执行的有序性,虚拟机就可以随意的对他们进行重排序。
五、安全发布对象
发布对象:使一个对象能够被当前范围之外的代码所使用;
对象溢出:一种错误的发布。当一个对象还没有构造完成时,就使它被其他线程所见。
主要是针对可变对象。
发布对象示例:线程不安全的,可能会被其他线程拿到该引用后修改states里面的值。
对象溢出示例:线程不安全的写法,外面的对象还没构造完成呢,内部类就获取到了外部的成员。多线程情况下,this在构造期间可能会出现溢出,如果要在构造函数中创建线程,那么,不要启动它,而是采用专有的start或初始化的方法来统一启动线程。这里可以使用工厂方法或私有构造函数完成对象的创建和监听器的注册。
对象未完成构造之前,不要发布!
1.安全发布对象的4中方法:
1.在静态初始化函数中初始化一个对象引用
2.将对象的引用保存到volatile类型域或者AtomicReference对象中
3.将对象的引用保存到某个正确构造对象的final类型域中
4.将对象的引用保存到一个由锁保护的域中
单例?如何保证一个单例对象只被初始化一次,实现线程安全呢?
首先确保这个单例对象的空参构造方法为private,不让外部随便创建(私有构造函数)。 懒汉模式(单例实例在第一次使用时进行创建),但下述代码在多线程下,会出现不安全问题(问题出在if那个地方)。
饿汉模式(单例实例在类装载时进行创建),线程安全。在类加载的时候进行创建可能会导致性能问题,如果不能保证后续能使用到该实例,会造成空间浪费。
懒汉模式的改进成线程安全,synchronized关键字使用(直接放方法头里肯定安全),但性能太差,不推荐,要优化。
双重检测机制,双重同步锁单例模式还是线程不安全,有指令重排导致线程不安全。
分析instance=new SingletonExample4()这条语句的执行的CPU指令:
1.memory=allocate() 分配对象的内存空间
2.ctorInstance() 初始化对象
3.instance=memory 设置instance指向刚分配的内存
线程A执行到创建对象的那行代码的3的位置,此时还没进行2初始化对象那句指令,线程B执行到双重检测机制的判空那句,发现此时的instance != null; 直接返回未初始化的instance,过早的暴露了视野,导致线程不安全。
再用volatile修饰该变量instance来限制指令重排。
单例对象 volatile+双重检测机制 实现线程安全。
饿汉模式,还可以使用静态代码块初始化类变量,但要注意变量定义和代码块的顺序,要将定义写在前,否则虽然静态代码块执行了初始化,但还是会报空指针的。
单例的枚举来创建——最安全
枚举模式,最安全的,又不浪费资源,是在用到的时候才创建(懒汉式的好处)。
2.不可变对象
2.1 final关键字:类 方法 变量
修饰类: 不能被继承(String类)
修饰方法:1. 锁定方法不被继承类修改; 2. 效率(以前的版本要这个优化,现在不用了)
修饰变量:基本数据类型变量, 一旦被赋值后不可被修改; 引用类型变量, 指向的地址不变.
Maps需要导包:
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>23.0</version>
</dependency>
注:上图的b本是String类型,不属于基本类型,为什么再次赋值会出现错误呢?
因为字符串常量池的存在,看似在赋值,其实String类背后的操作是将b的指针指向了另一个字符串常量的地址,所以出错。
map的话,已经创建过了,指向的地址不能再变了,所以不能将其他引用地址赋值给map,但是修改该map内存储的键值是可以的。
final的又一种用法:
public void test(final int a){
a = 12;//这会报错的,不能修改,只能当成值来操作
int b = a; //可以
}
2.2 其他不可变
除了final修饰的对象不可变,还包括Collections.unmodifiableXXX和Guaua包下的ImmutableXXX操作过的变量也不能修改。
Collections下的unmodifiable的底层实现是覆盖原有的修改方法,对其修改方法进行抛出异常操作。
Collections.unmodifiableMap(map);//一旦被修改,则抛出异常
ImmutableXXX来初始化创建集合,不可修改:
private final static List list = ImmutableList.of(1,2,38);
List.add(5); //会抛出异常,无法对Immutable初始化的集合进行修改了
注意:那个点在尖括号前面
Immutable类型进行初始化的集合,都无法对其进行修改(一发生修改就抛出异常,但正常的读操作可以正常使用),所以是线程安全的.
3.线程封闭
将变量封闭到一个线程里面,不被其他线程访问,就达到了线程安全。
RequestHolder.java文件 添加数据、使用数据、移除数据
在预处理的时候,将数据写入ThreadLocal;在处理的时候调用get方法,处理完就remove删除。使用过滤器Filter来验证TheadLocal的功能。
对Filter进行配置,告诉过滤器要拦截哪些请求,过滤器注册Bean
FilterRegistrationBean frb = new FilterRegistrationBean();
frb.setFilter(new YxfFilter());//YxfFilter实现Filter接口
Frb.addUrlPattern(“/threadLocal/*”);//
//定义一个Interceptor,继承HandlerInterceptorAdapter。重写相应的方法.
YxfInterceptor
里面重写preHandle方法(在处理请求前定义事项),返回true则继续处理接口请求,返回false则中断该请求的处理。
当请求进来的时候,将请求的线程id存入ThreadLocal中,
将拦截器Bean注入容器,配置类继承WebMvcConfigurerAdapter,重写下方法注册拦截器
@Override
Public void addInterceptors(InterceptorRegistry registry){
Registry.addInterceptor(new YxfInterceptor()).addPathPatterns(“/**”);
}
afterCompletion方法中移除ThreadLocal存的值,避免内存泄漏。
ThreadLocal //可以接口请求到来的时候,可以将拦截到的数据存入,在接口处理中可获取到存入的数据,也可以在请求完成后remove掉存入的数据,线程安全,线程封闭。
小结:ThreadLocal是线程安全的,很好的实现线程封闭的一种方法。
线程封闭计数的常见应用:
数据库连接对应JDBC的connection对象,连接管理请求时将connection封闭在一个对象中,connection对象本身不是线程安全的,通过线程封闭也做到了线程安全。
线程封闭,把对象封闭在一个线程里面,让其他线程无法访问,达到线程安全的目的。
堆栈封闭:局部变量(不被多线程共享),无并发问题。全局变量会引起并发问题。
4.线程不安全类与写法
字符串拼接类StringBuilder和StringBuffer:
经验证,使用线程池开启多线程对字符串进行5000次字符串拼接,可知,StringBuilder是非线程安全的,StringBuffer是线程安全的类。
StringBuffer类的方法上,基本都添加了synchronized关键字,影响效率。一般拼接我都使用StringBuilder在方法里面定义局部变量,因为堆栈封闭,不涉及线程安全问题,性能提升。
SimpleDateFormat类与DateTimeFormatter(是joda-time包中的类)
SimpleDateFormat的对象时非线程安全的, 若多线程方法下使用它为全局变量时会报异常.所以要写在局部变量的方法里,避免线程安全带来的异常.
DateTimeFormatter线程安全, DateTime.parse(“20200611”,dateTimeFormatter).todate().
ArrayList, HashSet, HashMap,等Collections是非线程安全的,非常重要
先检查再执行,这种写法是不安全的,因为判断的时候,值是相等,但是执行过程中是不安全的,要确保是否涉及多线程的线程安全问题,是否上锁,保证原子性。
5.线程安全-同步容器
ArrayList-> Vector, Stack
HashMap-> HashTable(key,value不能为null)
Collections.synchronizedXXX(List Set Map)
同步容器不一定能真正做到线程安全.
Vector在某些情况下会出现线程不安全的问题:
Vector是线程同步的容器,虽然所有方法都使用synchronized修饰,但可能会因为两个同步方法在调用顺序上出现线程安全问题(比如下图中,线程1和线程2同时判断了vector的大小,但是线程1先执行删除,导致线程2无法获取删除掉的数据,造成数组越界的异常)。故,即便是线程安全的方法,在使用顺序上存在的问题,仍然可能会造成线程安全问题。
private static List<Integer> list = Collections.synchronizedList(Lists.newArrayList());
集合使用不当也会出现异常:
单线程操作Vector对象,增强for循环和Iterator循环中,如果做删除操作,会出现异常的。使用正常的for循环删除是ok的。所以不要在前两种循环中做修改、删除操作,可以先做好标记,循环结束后再删除。
6.线程安全-并发容器J.U.C(java.util.concurrent包)
ArrayList->CopyOnWriteArrayList 适合读多写少的操作, 虽然最后是线程安全, 不能用做实时读的场景。
设计思想:读写分离、最终一致性、使用时另外开辟空间
(读操作在原数组上进行是不加锁,写操作要加锁)
HashSet TreeSet->CopyOnWriteArraySet ConcurrentSkipListSet 前者,底层使用的是CopyOnWriteArrayList 只读操作大于可变操作,迭代器不支持可变的操作,迭代器速度很快;后者支持自然排序,基于Map的,不支持使用空元素.基础的add remove contain操作是线程安全的,但批量操作是不安全的,可自行使用锁控制。
HashMap TreeMap-> ConcurrentHashMap ConcurrentSkipListMap 前者很重要,前者比后者快4倍的查询速度;后者key有序,支持的更高的并发和排序功能,这是前者无法比拟的。
7.安全共享对象策略-总结
线程限制:一个被线程限制的对象,由线程独占,并且只能被占有它的线程修改;
共享只读: 一个共享只读的对象,在没有额外同步的情况下,可以被多个线程并发访问,但是任何线程都不能修改它;
线程安全对象:一个线程安全的对象或容器,在内部通过同步机制来保证线程安全,所以其他线程无需额外的同步就可以通过公关接口随意访问它;
被守护对象: 被守护对象只能通过获取特定的锁来访问。
8.J.U.C之AQS
底层是同步队列
AQS的设计:
使用Node实现FIFO队列,可以用于构建锁或者其他同步装置的基础框架;
利用了一个int类型表示状态;
使用方法是继承;
子类通过继承并通过实现它的方法管理其他状态{acquire 和 release}的方法操纵状态;
可以同时实现排它锁和共享锁模式(独占、共享);
AQS内部维护了一个CLH的队列来管理锁,线程会首先尝试获取锁,如果获取锁失败,就将当前线程以等待状态的信息封装成一个Node节点,加入到同步队列,接着会不断循环尝试获取锁,条件是当前节点为head的直接后继才会尝试,如果失败,就会阻塞自己,直到被唤醒;而当持有锁的线程释放锁的时候,会唤醒队列中的后继线程。
AQS的同步组件:
CountDownLatch 闭锁,通过计数阻塞线程
Semaphore 控制并发线程的数目
CyclicBarrier 和CountDownLatch 很像,能阻塞进程,它可重置计数
ReentrantLock
Condition
FutureTask
8.1 CountDownLatch
是一个同步辅助类,通过它可以完成类似于阻塞当前线程的功能,它可以阻塞一个或多个线程,直到所有的线程都执行完成,才继续往下执行;通过一个计数器来进行初始化,计数器的操作是原子操作,就是同一时刻只有一个线程能对其进行修改,这个计数器不能被重置。调用await方法的线程会一直处于阻塞状态,直到计数器的数被countDown方法减到0,阻塞方法才会继续往下执行。
package com.mmall.concurrency.example.aqs;
import lombok.extern.slf4j.Slf4j;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
@Slf4j
public class CountDownLatchExample1 {
private final static int threadCount = 200;
public static void main(String[] args) throws Exception {
ExecutorService exec = Executors.newCachedThreadPool();
final CountDownLatch countDownLatch = new CountDownLatch(threadCount);
for (int i = 0; i < threadCount; i++) {
final int threadNum = i;
exec.execute(() -> {
try {
test(threadNum);
} catch (Exception e) {
log.error("exception", e);
} finally {
countDownLatch.countDown();//计数器减一
}
});
}
countDownLatch.await();//阻塞在这,直到计数器为0
log.info("finish");
exec.shutdown();
}
private static void test(int threadNum) throws Exception {
Thread.sleep(100);
log.info("{}", threadNum);
Thread.sleep(100);
}
}
分析:
线程池的shutdown会等待当前线程执行完,再关闭线程池;线程池里面声明的线程,需要传入不能被修改的参数,用final修饰;
countDownLatch.await()会保证所有线程已经执行完,才继续执行主线程的后面的方法;countDownLatch.countDown()方法会进行减1操作;
给一个任务指定一个特定的时间,若无法完成,就不管了;就是给countDownLatch.await()方法传入限定时间,限定时间一过,就会继续执行主线程的后面的代码。
countDownLatch.await(10, TimeUnit.MILLISECONDS);
8.2 Semaphore 类,叫信号量:
senaphore能控制并发访问线程的个数; 通过初始化给定许可大小,再利用acquire去获取许可个数,获取到许可的就可以执行,获取不到的就阻塞;release方法会将之前获取的许可释放,以便其他线程再次获取;acquire 和 release方法,常用于有限资源的访问并发控制。
package com.mmall.concurrency.example.aqs;
import lombok.extern.slf4j.Slf4j;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Semaphore;
@Slf4j
public class SemaphoreExample1 {
private final static int threadCount = 20;
public static void main(String[] args) throws Exception {
ExecutorService exec = Executors.newCachedThreadPool();
final Semaphore semaphore = new Semaphore(3);
for (int i = 0; i < threadCount; i++) {
final int threadNum = i;
exec.execute(() -> {
try {
semaphore.acquire(); // 获取一个许可
test(threadNum);
semaphore.release(); // 释放一个许可
} catch (Exception e) {
log.error("exception", e);
}
});
}
exec.shutdown();
}
private static void test(int threadNum) throws Exception {
log.info("{}", threadNum);
Thread.sleep(1000);
}
}
说明:
获取许可和释放许可,释放完许可才能让其他线程进行操作;也可以获取多个许可,释放多个许可(在acquire和release方法中传入参数);
Semaphore.tryAcquire()尝试获取1个许可,如果获取到许可才能继续执行,如果获取不到许可,则无法执行下面的内容;
获取一个许可、获取多个许可、尝试获取许可、尝试等待获取许可。
8.3 CyclicBarrier类,同步辅助类。
允许一组线程相互等待,直到到达某个公共的屏障点,直到组内所有线程都到达后才能继续往下执行。循环屏障,可重用。可用于多线程计算数据,再汇合统计数据。reset方法重置,描述的是多个线程之间相互等待的关系。
package com.mmall.concurrency.example.aqs;
import lombok.extern.slf4j.Slf4j;
import java.util.concurrent.CyclicBarrier;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
@Slf4j
public class CyclicBarrierExample1 {
private static CyclicBarrier barrier = new CyclicBarrier(5);
public static void main(String[] args) throws Exception {
ExecutorService executor = Executors.newCachedThreadPool();
for (int i = 0; i < 10; i++) {
final int threadNum = i;
Thread.sleep(1000);
executor.execute(() -> {
try {
race(threadNum);
} catch (Exception e) {
log.error("exception", e);
}
});
}
executor.shutdown();
}
private static void race(int threadNum) throws Exception {
Thread.sleep(1000);
log.info("{} is ready", threadNum);
barrier.await();//阻塞在这,直到指定个数的线程都到达此处后,才开始执行后面的程序
log.info("{} continue", threadNum);
}
}
说明:
给定一个值,告诉可以同时几个线程一起执行, await()方法会让每一个线程都进入等待状态,当达到之前设定的个数的线程数目时,表名这些线程已就绪,才可以往下执行, 相当于是一组一组的线程开始执行。
也支持设定等待时间,当达到等待时间后,就不继续等那些还没就绪的线程了。
支持重置计数。
构造还支持传入一个Runnable,就是当各个线程满足了等待条件以后,优先执行这个Runnable中的程序。
8.4 ReentrantLock 与锁
ReentrantLock(可重入锁)和synchronized区别
可重入性:同一个线程进入一次,记录一次;
锁的实现:前者是jdk实现的(用户敲代码实现的),后者是jvm实现的(操作系统实现);
性能的区别:synchronized优化后,引入了偏向锁,轻量级锁(自旋锁),借鉴了ReentrantLock的CAS;两者情况差不多的情况下推荐使用synchronized,写法更容易,它也避免了进入内核态的性能阻塞;
功能区别:synchronized更便利,前者要手动加锁和释放锁(一般在finally中);
ReentrantLock独有的功能:
可指定公平锁(先等待的线程先获得锁)还是非公平锁, synchronized只能非公平锁;
提供了一个Condition类,可以分组唤醒需要唤醒的线程;
提供能够中断等待锁的线程的机制, lock.lockInterruptibly();
核心思想:
在用户态就把锁问题解决,想尽办法避免线程进入内核态的阻塞而造成性能下降。
package com.mmall.concurrency.example.lock;
import com.mmall.concurrency.annoations.ThreadSafe;
import lombok.extern.slf4j.Slf4j;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Semaphore;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
@Slf4j
@ThreadSafe
public class LockExample2 {
// 请求总数
public static int clientTotal = 5000;
// 同时并发执行的线程数
public static int threadTotal = 200;
public static int count = 0;
private final static Lock lock = new ReentrantLock();
public static void main(String[] args) throws Exception {
ExecutorService executorService = Executors.newCachedThreadPool();
final Semaphore semaphore = new Semaphore(threadTotal);
final CountDownLatch countDownLatch = new CountDownLatch(clientTotal);
for (int i = 0; i < clientTotal ; i++) {
executorService.execute(() -> {
try {
semaphore.acquire();
add();
semaphore.release();
} catch (Exception e) {
log.error("exception", e);
}
countDownLatch.countDown();
});
}
countDownLatch.await();
executorService.shutdown();
log.info("count:{}", count);
}
private static void add() {
lock.lock();//加锁
try {
count++;
} finally {
lock.unlock();//解锁
}
}
}
提供了很多方法,功能很强大。
ReentrantReadWriteLock 悲观读,在没有任何读的时候才能够进行写入操作,如果想写得时候一直有读锁在,需要等待读锁的释放.线程遭遇饥饿,想写得时候,一直有读的操作阻塞写一直等待。
package com.mmall.concurrency.example.lock;
import lombok.extern.slf4j.Slf4j;
import java.util.Map;
import java.util.Set;
import java.util.TreeMap;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantReadWriteLock;
@Slf4j
public class LockExample3 {
private final Map<String, Data> map = new TreeMap<>();
private final ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
private final Lock readLock = lock.readLock();
private final Lock writeLock = lock.writeLock();
public Data get(String key) {
readLock.lock();
try {
return map.get(key);
} finally {
readLock.unlock();
}
}
public Set<String> getAllKeys() {
readLock.lock();
try {
return map.keySet();
} finally {
readLock.unlock();
}
}
public Data put(String key, Data value) {
writeLock.lock();
try {
return map.put(key, value);
} finally {
readLock.unlock();
}
}
class Data {
}
}
StampedLock 有三种模式,写、读、乐观读,重点在乐观锁上,乐观读就认为当读的次数很多,写得次数很少,就乐观的认为读取和写入同时发生的几率很小,因此不悲观的进行读取锁定。
package com.mmall.concurrency.example.lock;
import java.util.concurrent.locks.StampedLock;
public class LockExample4 {
class Point {
private double x, y;
private final StampedLock sl = new StampedLock();
void move(double deltaX, double deltaY) { // an exclusively locked method
long stamp = sl.writeLock();
try {
x += deltaX;
y += deltaY;
} finally {
sl.unlockWrite(stamp);
}
}
//下面看看乐观读锁案例
double distanceFromOrigin() { // A read-only method
long stamp = sl.tryOptimisticRead(); //获得一个乐观读锁
double currentX = x, currentY = y; //将两个字段读入本地局部变量
if (!sl.validate(stamp)) { //检查发出乐观读锁后同时是否有其他写锁发生?
stamp = sl.readLock(); //如果没有,我们再次获得一个读悲观锁
try {
currentX = x; // 将两个字段读入本地局部变量
currentY = y; // 将两个字段读入本地局部变量
} finally {
sl.unlockRead(stamp);
}
}
return Math.sqrt(currentX * currentX + currentY * currentY);
}
//下面是悲观读锁案例
void moveIfAtOrigin(double newX, double newY) { // upgrade
// Could instead start with optimistic, not read mode
long stamp = sl.readLock();
try {
while (x == 0.0 && y == 0.0) { //循环,检查当前状态是否符合
long ws = sl.tryConvertToWriteLock(stamp); //将读锁转为写锁
if (ws != 0L) { //这是确认转为写锁是否成功
stamp = ws; //如果成功 替换票据
x = newX; //进行状态改变
y = newY; //进行状态改变
break;
} else { //如果不能成功转换为写锁
sl.unlockRead(stamp); //我们显式释放读锁
stamp = sl.writeLock(); //显式直接进行写锁 然后再通过循环再试
}
}
} finally {
sl.unlock(stamp); //释放读锁或写锁
}
}
}
}
StampedLock对吞吐量有巨大的改进,特别是在读线程越来越多的场景下;StampedLock有一个复杂的API,对于加锁操作很容易误用其他的方法.
补充:
Synchronized是不会引发死锁,因为jvm能自动解锁,而且能标识死锁或其他异常行为的来源,对调试很有价值,对其进行监控;而其他的锁是对象层面的锁,是有可能进入死锁的,尤其记得解锁操作在finally中编写保证解锁。
总结:
当只有少量竞争者的时候,Synchronized是一个很好的通用的锁实现;竞争者不少,但线程增长的趋势能够预估,这时候可以用ReentrantLock;
8.5 Condition类
前面讲过AQS有2个队列,一个是同步队列,另一个就是Condition的队列了。
ReentrantLock reentrantLock = new ReentrantLock();
Condition condition = reentrantLock.newCondition();
new Thread(() -> {
try {
reentrantLock.lock();//线程加入到AQS的等待队列
log.info("wait signal"); // 1等待信号
condition.await();//执行完await方法后,就从AQS的sync队列里移除了,就是锁的释放,接着加入到了condition的等待队列,等待信号的接收
} catch (InterruptedException e) {
e.printStackTrace();
}
log.info("get signal"); // 4得到信号
reentrantLock.unlock();//线程1执行完毕,释放锁
}).start();
new Thread(() -> {
reentrantLock.lock();//线程2因为线程1释放锁的关系,就被唤醒,判断锁是否被释放,然后获取锁,也加入到AQS的等待队列中(sync quene)
log.info("get lock"); // 2
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
condition.signalAll();//调用发送信号的方法(此处是唤醒所有condition队列的线程,也可以单个唤醒的),此时线程1接收到了信号,但是线程1并没有被唤醒,只是从condition队列取出,加入到AQS的等待队列中
log.info("send signal ~ "); // 3
reentrantLock.unlock();//当锁被释放,AQS的等待队列中只剩线程1,此时线程1被唤醒,继续往下执行
}).start();
分析:
根据输出的顺序分析Condition的执行流程,看代码。AQS涉及两个队列,一个是同步队列, 还有一个是Condition的等待队列。
8.5 FutureTask类
Thread和Runnable创建的线程无法获取线程执行返回的结果, 于是jdk1.5之后引入Callable和FutureTask 实现对线程执行结果的获取.。
Callable与Runnable接口对比
Runnable只有一个run方法,把需要实现的操作写在Runnable里面,再用一个Thread去执行该方法就能实现多线程
Callable里面有一个call方法,线程需要执行的语句就写在call方法中,它功能更强大,线程有返回值,还可以抛出异常;
Future 接口,可以进行取消,查询任务是否被取消,可以监视目标线程的call的情况,可以得到线程方法执行的返回值;
使用Future去接收Callable接口实现类的返回值:
static class MyCallable implements Callable<String> {//返回类型是String
@Override
public String call() throws Exception {
log.info("do something in callable");
Thread.sleep(5000);
return "Done";
}
}
public static void main(String[] args) throws Exception {
ExecutorService executorService = Executors.newCachedThreadPool();
Future<String> future = executorService.submit(new MyCallable());//线程池直接提交任务
log.info("do something in main");
Thread.sleep(1000);
String result = future.get();//会一直阻塞等待之前调用的任务返回的结果
log.info("result:{}", result);
}
FutureTask类实现了Future和Runnable接口:
package com.mmall.concurrency.example.aqs;
import lombok.extern.slf4j.Slf4j;
import java.util.concurrent.Callable;
import java.util.concurrent.FutureTask;
@Slf4j
public class FutureTaskExample {
public static void main(String[] args) throws Exception {
FutureTask<String> futureTask = new FutureTask<String>(new Callable<String>() {
@Override
public String call() throws Exception {
log.info("do something in callable");
Thread.sleep(5000);
return "Done";
}
});
new Thread(futureTask).start();
log.info("do something in main");
Thread.sleep(1000);
String result = futureTask.get();
log.info("result:{}", result);
}
}
8.6 Fork/Join框架
Jdk7引入的用于多个任务并行执行的框架,将一个大任务切割成若干个小任务执行, 最终将多个小任务执行的结果进行合成,得到大任务执行的结果。
工作窃取算法:
将大任务分成若干个小任务,再将小任务放在双端队列中,并为每个队列单独开启一个线程,当某个线程执行完自己队列的任务时,就去窃取其他队列的任务进行执行(从其他队列的尾部开始执行)。
局限:
任务只能使用fork和join操作来作为同步机制,无法使用其他的同步机制;
不能去做io操作;
任务不能抛出检查异常。
package com.mmall.concurrency.example.aqs;
import lombok.extern.slf4j.Slf4j;
import java.util.concurrent.ForkJoinPool;
import java.util.concurrent.Future;
import java.util.concurrent.RecursiveTask;
@Slf4j
public class ForkJoinTaskExample extends RecursiveTask<Integer> {//返回整型数值,递归
public static final int threshold = 2;
private int start;
private int end;
public ForkJoinTaskExample(int start, int end) {
this.start = start;
this.end = end;
}
@Override
protected Integer compute() {
int sum = 0;
//如果任务足够小就计算任务
boolean canCompute = (end - start) <= threshold;
if (canCompute) {
for (int i = start; i <= end; i++) {
sum += i;
}
} else {
// 如果任务大于阈值,就分裂成两个子任务计算
int middle = (start + end) / 2;
ForkJoinTaskExample leftTask = new ForkJoinTaskExample(start, middle);
ForkJoinTaskExample rightTask = new ForkJoinTaskExample(middle + 1, end);
// 执行子任务
leftTask.fork();
rightTask.fork();
// 等待任务执行结束合并其结果
int leftResult = leftTask.join();
int rightResult = rightTask.join();
// 合并子任务
sum = leftResult + rightResult;
}
return sum;
}
public static void main(String[] args) {
ForkJoinPool forkjoinPool = new ForkJoinPool();
//生成一个计算任务,计算1+2+3+4
ForkJoinTaskExample task = new ForkJoinTaskExample(1, 100);
//执行一个任务
Future<Integer> result = forkjoinPool.submit(task);
try {
log.info("result:{}", result.get());
} catch (Exception e) {
log.error("exception", e);
}
}
}
分析:
看代码,就是一个简单地递归,只不过处理细节被封装在了fork和join中,目前只知道他们内部使用的是工作窃取算法。
8.7 BlockingQueue接口
阻塞队列:
队列满了,对入队列进行阻塞;
队列空的,对出队列进行阻塞;
适用生产者和消费者的场景。
提供了4套方法:
如果不能马上进行,就抛出异常/返回特殊值/阻塞/设置超时时间,再超时就返回特殊值,说明返回的特殊值一般是true或false
BlockingQueue接口的实现类:
ArrayBlockingQueue 有界的阻塞队列,初始化的时候指定大小,先进先出,最先插入的对象是尾部,最先移除的对象是头部;
DelayQueue 继承了Comparable接口,里面的元素需要进行排序,内部实现是锁跟排序;
LinkedBlockingQueue 大小配置是可选的(可以有边界,也可以没有边界,自己设定),内部实现是链表,先进先出;
PriorityBlockingQueue 带优先级的队列,没有边界的,有排序规则,允许插入null空对象,插入的内容必须实现Comparable接口;
SynchronousQueue 无界非缓存的队列,内部仅允许容纳一个元素;不存储元素,只能等待被取走后才能继续插入。
8.8 线程池
new Thread弊端:
每次new Thread新建对象,性能差;
线程缺乏统一管理,可能无限制的新建线程,相互竞争,有可能占用过多系统资源导致死机或OOM;
缺少更多功能,如更多执行、定期执行、线程中断等;
线程池的好处:
重用存在的线程,减少对象创建、消亡的开销,性能佳;
可有效控制最大并发线程数,提供系统资源利用率,同时可以避免过多资源竞争,避免阻塞;
可以提供定时执行、定期执行、单线程、并发数控制等功能;
线程池相关类-ThreadPoolExecutor
构造方法成员变量参数:
corePoolSize:核心线程数量
maximumPoolSize:线程最大线程数
workQueue:阻塞队列,存储等待执行的任务,很重要,会对线程池运行过程产生重大影响
上三参关系:
如果正在执行的线程数小于corePoolSize,线程池将直接新建线程开始执行,此时不管池中有没有空闲的线程;
如果正在执行的线程数大于等于corePoolSize且小于maximumPoolSize时,则只有当workQueue满了的时候才创建新的线程去处理任务;
如果设置的corePoolSize和maximumPoolSize相同的话,那么创建的线程池的大小是固定的,这时若有新任务的提交,就看workQueue满了没,没满就放workQueue里等待有空闲的线程去执行处理;
如果运行的线程数量大于maximumPoolSize,且workQueue也已经满了,则会根据指定的rejectHandler策略来处理任务;
workQueue线程池会根据当前线程池中正在运行着的线程数量来决定该任务的处理方式,处理方式有3种:直接切换、使用无界队列、使用有界队列;
直接切换是使用SynchronousQueue ;
无界队列是使用LinkedBlockingQueue,此时线程池中能创建的最大线程数为corePoolSize,maximumPoolSize就不起作用,当线程池中所有线程都是运行状态时,新的任务提交后就会放入等待队列;
有界队列使用ArrayBlockingQueue ,用这个方式可以将线程池的最大数量限制为maximumPoolSize,这样能够降低资源的消耗,但是这种方式会使线程池对线程的调度变得更困难,因为线程池和队列的容量都是有限的;
小结:
降低系统资源的消耗,cpu的使用率,操作系统资源的消耗,可以设置较大的队列容量和较小的线程池容量,这样会降低线程处理任务的吞吐量;
如果提交的任务经常发生阻塞,可以考虑设置maximumPoolSize大一点。
keepAliveTime:线程没有任务执行时最多保持多久时间终止
unit:keepAliveTime的时间单位
threadFactory:线程工厂,用来创建线程
rejectHandler:当拒绝处理任务时的策略,4中策略(默认直接抛出异常AbortPlicy、用调用者所在的线程来执行任务CallerRunsPolicy、当前任务丢弃队列中最靠前的任务来执行当前任务DiscardOldestPolicy、丢弃这个任务DiscardPolicy)
线程池的状态
启动关闭的方法:
execute:提交任务,交给线程池执行
submit:提交任务,能够返回执行结果,execute+Future
shutdown:关闭线程池,等待任务都执行完
shutdownNow:关闭线程池,不等待任务执行完
监控的方法:
getTaskCount:线程池已执行和未执行的任务总数
getCompletedTaskCount:已完成的任务数量
getPoolSize:线程池当前的线程数量
getActiveCount:当前线程池中正在执行任务的线程数量
线程池-Executor框架接口
线程池-合理配置
1.CPU密集型任务,就需要尽量压榨CPU,参考值可以设置为NCPU+1
2.IO密集型任务,参考值可以设置为2*NCPU
说明:NCPU为CPU数量
六、多线程并发拓展
死锁
两个或两个以上的进程执行过程中,因争夺资源造成互相等待的情况。
死锁条件:互斥条件+请求和保持条件+不剥夺条件+环路等待条件
避免死锁:加锁顺序+加锁时限+死锁检测
多线程并发最佳实践
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: singleton prototype
无状态对象(vo dto service dao等对象)
HashMap与ConcurrentHashMap解析
Jdk8以前,hashmap底层由数组和链表实现的,初始容量16(哈希表桶的数量)和加载因子0.75影响性能,当哈希表中的条目数量超过了容量*加载因子之后,会使用resize进行扩容.
待…
七、缓存
命中率: 命中数/(命中数+没有命中数)
最大元素(空间)
清空策略: FIFO, LFU(使用次数判断), LRU(看使用时间戳) ,过期时间, 随机等
影响缓存命中率的因素
业务场景和业务需求: 读多写少, 时限要求等
缓存的设计(粒度和策略)
缓存容量和基础设施(本地缓存,分布式)
缓存的分类
本地缓存: 应用和缓存在同一个线程中,访问速率快; 缺点是耦合性较高
编程实现(成员变量 局部变量 静态变量)
Guaua cache
分布式缓存: Memcache redis
高并发场景下缓存常见问题
1.缓存一致性
2.缓存并发问题
3.缓存穿透问题
4.缓存的雪崩现象
一致性: 保证缓存中的内容与数据库中的数据或副本中的数据保持一致.
缓存过期的情况下, 高并发需要大量的访问数据库,导致数据库的磁盘io压力剧增,严重影响cpu性能消耗,所以这个时候可以使用锁来进行控制, 当第一个线程获取到数据时,更新到缓存中, 其他线程就可以直接从缓存中获取数据了.
缓存穿透
在高并发场景下,如果某一个key被高并发的访问, 没有被命中,出于对容错性的考虑,会从数据库去获取, 从而导致大量的请求到达了数据库, 让数据库执行了大量不必要的查询操作从而带来巨大的冲击和压力, 这就是缓存穿透.
解决:
缓存空对象, 也进行缓存; 避免请求穿透到数据库,保证缓存的时效性
单独过滤数据,
缓存的雪崩现象
由于缓存的原因,导致后端请求大量到达数据库, 缓存节点的故障导致, 通过一致性hash算法来解决. 错开缓存过期时间, 避免大量缓存数据失效.
Guaua Cache
灵感来源于ConcurrentHashMap的设计思路, 使用多个segment方式的细粒度锁,在保证线程安全的同时,支持高并发场景的需求, 这里的cache类似一个map,不同的是它还要处理缓存过期、动态加载等一些算法的逻辑,需要一些额外的信息来实现这些操作。对此,根据面向对象的思想,它还需要做方法与数据的关联性的封装,它主要实现的缓存功能有自动将节点加载进缓存结构中,当缓存的数据超过设置的最大值时,使用LRU算法来移除,它具备根据上次访问或写入的时间来计算它的过期机制,它缓存的key被封装在weekReference引用中,value被封装在weekReference或softReference引用中,它还可以统计缓存使用过程中的命中率、异常率、未命中率等统计数据。
MemCache
本身不提供分布式的解决方案,在服务端,memcache的集成环境实际上就是一个个memcache服务器的堆集,环境搭建比较简单,cache的分布式主要是在客户端实现的。
通过客户端的路由来处理达到分布式解决方案的目的,应用服务器在每次存取某个key或value的时候,通过某种算法把key映射到某台服务器上,因此这个key的所有操作都会在同一台服务器上,memcache的客户端采用的是一致性hash算法作为它的路由策略,相对于一般hash比如简单取模的算法,它除了计算key的hash值外还计算每个server的hash值,然后将这些hash值映射到一个有限的值域上。它通过寻找hash值大于key对应的hash值的最小server作为存储该key的目标server,如果找不到,它直接把具有最小hash值server作为目标server,同时又一定程度上解决了扩容问题。增加或删除单个节点对整个集群来说不会有大的影响。
内存结构图:
每个page的大小是1M,真正存储数据的地方是chunk,同一个slab中的chunk大小是固定的,有相同大小的slab组织在一起被称为slab_class. Slab数量是有限的, value总是会被存放到大小最接近的slab的chunk中.
1.Memcache的内存分配在chunk里面总会有内存浪费
2.Memcache的LRU算法不是针对全局的, 是针对slab的
3.Memcache存储的value大小是有限制的, value的大小不能大于1M
4.Memcache单进程在32位机中的最大可以使用的内存是2G, 64位机不受限制
5.Memcache中的key最大为250个字节,超过这个长度无法存储; 单个item的最大数据时1M, 超过也无法存储
6.Memcache的服务器端是不安全的, 若已知某个服务端的节点, 可以用flush all让所有已存储的数据全部失效
7.Memcache不能够遍历里面存储的所有的item, 因为这个操作相对的缓慢,还会阻塞其他的操作
8.Memcache的高性能来源于两个阶段的hash结构,第一个阶段在客户端,通过key值算出一个节点;第二阶段是在服务端,通过内部的hash算法,查找item,并返回给客户端.从实现角度看,memcache是一个非阻塞的、基于事件的服务器程序。
Redis
通过复制扩展读性能 通过分片来扩展写性能
特点:
1.支持数据的持久化,可以将内存中的数据保存在磁盘里,重启的时候可以再次加载进行使用;
2.不仅支持简单的key value类型的数据,同时还支持string、hash、list、set、sorted set等
3.支持数据的备份,master-slave模式主从数据备份
4.性能极高,读的速度能达到11w/s,写得速度能到81k/s
5.丰富的数据类型5种
6.具有原子性,所有操作都是原子性的,还支持几个操作合并后的原子性执行
7.支持publish、subscribe通知key过期等
适用场景:
1.取最新n个数据的操作
2.排行榜类似的应用
3.需要精准设定过期时间的应用
4.计数器的运用
5.做唯一性检查的操作,获取某段时间内所有数据排重的值
6.实时系统、垃圾系统、pub sub构建实时消息系统、构建队列系统、最基础的缓存
Jedis中@Value的用法
八、消息队列
完成异步解耦的过程;保持了最终的一致性;不需要做同步等待,减少并发现象。
特性:
1.与业务无关:只做消息分发
2.FIFO:先投递先到达
3.容灾:节点的动态增删和消息的持久化
4.性能:吞吐量提升,系统内部通信效率提高
为什么需要消息队列?
生产和消费的速率或不稳定等因素不一致。
好处:
业务解耦、最终一致性、广播、错峰与流控
RPC(远程调用)
消息队列:Kafka RabbitMQ
队列-kafka
Apache下的一个子项目,高性能、跨语言、分布式发布订阅消息队列系统。
特性:
1.快速持久化
2.高吞吐,10w/s的吞吐速率
3.完全分布式系统,自动适应负载均衡,支持hadoop并行加载机制等
队列-RabbitMQ
有管理界面默认端口15672
九、应用之间通信: RPC