赞
踩
本课程主要是围绕并发编程和高并发解决方案两个核心来进行讲解;
希望这门课程能够带领大家攻克并发编程与高并发的难题;
课程特点:
适合人群:
学习收获:
讲解内容步骤:
涉及到的一些知识技能:
最简单的并发编程案例:实现一个计数功能(接下来我们使用2个例子来初次体验并发编程)
@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且每次的值都不一样;
@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
思考:
并发是说,多个线程操作同样的资源,保证线程安全,合理使用资源;而高并发是说服务能同时处理很多请求,提高程序性能。
直接增加一级缓存的代价昂贵,所以增加多级缓存可以最大化利用资源和减少成本;
此协议较为复杂,具体可自行了解;
我们的公式是先计算a=10,然后再计算b=200,最后再计算result= ab。可是到了计算机处理时,可能会变成:先计算b=200,再计算a=10,最后计算result=ab
JVM是一种规范,它规范了Java虚拟机与计算机内存是如何协同工作的,它规定了一个线程如何、何时能够看到其他线程修改过的共享变量的值。以及在必须时如何同步的访问共享变量。图示如下:
堆(Heap): 它是一个运行时数据区,是由垃圾回收来负责的。
栈(Stack):存取速度比堆要快,仅次于寄存器。栈内的数据是可以共享的。但是它的数据大小是确定的,缺乏灵活性,主要是存放一些基本类型和变量。比如我们小写的:int、byte、long、char等。
说明:
图示二:
CPU:
CPU Registers(寄存器):
CPU Cache Memory(高速缓存Cache):
RAM-Main Memory(主存):
Java内存模型与硬件资源之间的关系图示:
对于硬件而言,所有的线程栈和堆都分布在主内存里面,部分线程栈和堆可能会出现在CPU缓存中和CPU内部的寄存器中。
Java内存模型抽象结构图:
Java内存模型-同步八种操作
Java内存模型 = 同步规则
图示:
比如使用AtomicLong进行加减,在源码上它会将期望值与结果值进行比较,只有正确了才执行任务,否则退回,类似于数据库的version乐观锁一样,只有期望版本一致才生效,可以在一些场景解决并发公共变量数据异常。
导致共享变量在线程间不可见的原因:
(可见性-synchronized)JVM关于synchronized的两条规定
(可见性-volatile)通过加入内存屏障和禁止重排序优化来实现。
StoreStore与StoreLoad屏障:
有序性:
有序性-happens-before原则
只能保证单线程下的有序性
如果一个线程的有序性不能通过happens-before推导出来,那么系统就可以随意对它进行排序。
线程安全性-总结:
发布对象: 使一个对象能够被当前范围之外的代码所使用
对象逸出:一种错误的发布,当一个对象还没有构造完成时,就使它被其他线程所见。
不安全的发布对象:
对象逸出:
安全发布对象的方法:
懒汉模式:
代码示例:
它是懒汉模式的实现,单例示例在第一次使用时进行创建。这个代码在单线程下没有问题,但它是线程不安全的,我们可以优化它,比如加锁。
懒汉模式做成线程安全的,我们可以加一个synchronized关键字,但是它的性能开销比较大,如图所示:
在判空的情况下在里面再进行加锁,然后再判空。能够线程安全且性能最大化:
是因为如果多线程情况下,如果两个线程都通过了第一层拦截
intance==null
,其中有一个线程获取到了锁然后实例化后,释放锁时,如果第二个线程进去,则进行判断是否已经实例化,如果实例化了则直接返回已实例的对象。这样能够防止两个同时都各自实例化一个实例。
指令重排导致还有可能发生问题:
使用volatile阻止CPU对这个对象发生指令重排,这样这个类的实例化方法就是线程安全的了。如图所示:
饿汉模式:
枚举模式: 推荐
/**
* 最安全
**/
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;
}
}
}
不可变对象需要满足的条件:
String就是一个不可变对象,当两个字符串结合的时候,生成的是一个新地址的对象。
final关键字:类、方法、变量
最近版本不需要使用final 进行性能优化了。
不可变对象除了final 外还有哪些呢?
在UnmodifiableXXX的源码里面它会把很多方法做成异常抛出,这样调用修改的方法的时候,会直接被抛出异常,无法进行修改。
当使用ImmutableXXX方法的时候,如果对集合进行修改,也会直接抛出异常,且如果是Immutable类型的,调用类似add()方法时还会提示已过期的横线。
Map的构建这里有两种形式,如图所示;
一旦初始化完成就不允许修改了。
什么是线程封闭?
正常来讲,我们的请求对服务器都是一个线程在运行,我们希望线程间隔离,那么首先这个线程被后端服务器进行实际处理的时候,通过通过filter可以直接先取出来当前的用户,把数据存入到ThreadLocal里面,当这个线程被Service以及其他相关类进行处理的时候,很可能要取出当前用户,这个时候我们可以通过ThreadLocal随时随地拿到当时存储过的值,这样使用起来就很方便啦。
如果不这样做的话,我们就得需要一直将用户信息无限的传递下去,则需要在方法中额外传输一些不想传输的变量。
线程封闭的方法:
接下来将通过代码演示ThreadLocal的简单使用。
使用ThreadLocal对指定线程存储变量:
这里通过RequestHolder.getId()获取id的值;
请求会先经过这里,才会到controller接口内部;
什么是线程不安全的类呢?
StringBuilder与StringBuffer的区别及使用?
这也是为什么java会同时提供两个String处理类。
与之类似的还有我们常用的SimpleDateFormat类,如果我们定义成全局变量,则可能会经常报错。正确的定义方式是在局部方法内new SimpleDateForm:
这样才不会出线程不安全带来的异常。另外一个DateTimeFormatter是一个线程安全的类,无论它定义在方法内,还是放在全局变量,都是线程安全的。我们推荐使用DateTime,它不仅仅线程安全,且很多地方都有优势。
线程不安全类的总结:
我们在使用线程不安全的类的时候,如果只用于查询,不对它进行修改操作,则能够保证并发不会出现问题。如果需要对其进行内容进行修改,则可以放在局部变量中进行,这样每个线程都能够拥有一个线程封闭的各自的一个实例对象,类与类之间互不影响。如果需要放在全局且需要进行修改,比如抢票,对一个变量的加减操作,那么我们则需要保证其原子性的加减,通过加锁、Amtoc等操作保证线程的安全。
同步容器的种类:
线程安全也不一定是真的安全:
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的过程中,我们不要进行删除操作,真的需要删除的话,我们可以先进行标记等待遍历结束后再删除,否则容易出现异常。
从以上例子我们可以看出,同步容器往往性能不是特别好,并且不能够完全做得到并发安全。所以我们有更好的替代品,它就是并发容器。
J.U.C 它是三个单词的缩写,表示的是一个java路径,它是:java.util.current 这三个单词的缩写。
并发容器的种类与对应关系:
copyOrWrite的设计思想:读写分离,最终一致性,使用时另外开辟空间(解决并发冲突)
CopyOnWriteArraySet与CopyOnWriteArrayList类似。ConcurrentSkipListSet的removeAll、addAll这些批量操作不能保证线程安全,我们需要手动进行同步,虽然他们是原子操作但是他们不能保证不被其他所打断。
ConcurrentHashMap的存取更快、但是ConcurrentSkipListMap(支持更高的并发,它的key是有序的,它的存取速度与线程数没有直接关系)也有一定的优势所在。
并发编程路线:
安全共享对象策略-总结
AQS: AbstractQueuedSynchronizer 它是并发容器里的同步器,简称AQS,从Jdk5开始,它提高了Java并发的性能,可以构建锁、同步框架的基础结构。
数据结构:
有一个大致印象即可。
介绍:
AQS同步组件:
图示:
CountDownLatch是一个同步辅助类,通过它我们可以完成阻塞当前线程的功能。换句话说,可以让一个线程或者多个线程一直等待,直到其他线程执行的操作完成。
CountDownLatch结合图示分析:
CountDownLatch的使用场景:在某些业务场景中,程序执行需要等待某个条件完成后才能继续执行后续的操作,典型的应用:并行计算(将一个大任务拆分成许多小任务,然后等待所有的任务都执行完毕后再进行汇总。)
为什么我们在并发模拟的时候可以使用CountDownLatch呢?因为我们模拟的场景的函数比较简单,且业务跟适应的使用场景比较适合。
代码示例:
为了防止countDownLatch.countDown()方法没有将值减到0,我们可以将countDownLatch.await();改为countDownLatch.await(10,TimeUnit.MILLISECONDS);这样如果达到限定的时间还没有到达指定的条件时,可以直接执行后面的代码。
概念:Semaphore 通常我们叫它信号量, 可以用来控制同时访问特定资源的线程数量,通过协调各个线程,以保证合理的使用资源。
图示:
可以把它简单的理解成我们停车场入口立着的那个显示屏,每有一辆车进入停车场显示屏就会显示剩余车位减1,每有一辆车从停车场出去,显示屏上显示的剩余车辆就会加1,当显示屏上的剩余车位为0时,停车场入口的栏杆就不会再打开,车辆就无法进入停车场了,直到有一辆车从停车场出去为止。
使用场景:主要用于那些资源有明确访问数量限制的场景,常用于限流;
Semaphore常用方法说明:
代码演示:
图示中,使用Semaphore放置了三个令牌。即便有20个线程同时访问,此处也只能有三个线程能够同时执行,他们通过acquire()方法获取到了令牌才能执行下面的代码,当release()释放许可后,被阻塞的线程才能尝试获取令牌。使用它可以很方便的进行限流。当令牌数为1时,就可以达到单线程的效果了。同时里面的acquire和release操作的许可令牌不受限制,我们可以同时释放多个许可或者获取多个许可(此处许可表示令牌)。
尝试获取许可:
使用tryAcquire()表示尝试获取许可,当获取到许可则执行内部代码,如果没有获取到则不执行。此处如果20个线程同时尝试获取许可,而Semaphore的令牌数量只有3个,且在所有许可获取时,已拿到许可的线程没有释放许可,那么最多也只有3个线程能够获取到许可。即便已拿到许可的线程释放了许可,那么同时最多也只有3个线程能够在同一时间持有许可(令牌)。
图示:
它可以用于多线程计算,每个线程同时分别处理一部分逻辑,当所有的线程结束计算后,然后再统一结果进行返回。
介绍CyclicBarrier与CountDownLatch的区别
代码示例:
代码中,通过new CyclicBarrier来定义了5个同时的线程,当barrier.await()被执行时,会进入线程等待,当达到5个时则所有的继续往下执行。这里也可以通过设置指定时间进行释放,如图中的设置2000毫秒。CyclicBarrier的await()方法会抛出BrokenBarrierException异常、TimeoutException等异常,我们需要进行处理。
CyclicBarrier的初始化后面可以带代码块,当初始化完毕时会跟着执行代码块中的代码,如图所示:
Java主要分为两类锁,一种是我们之前介绍的Synchronized关键字修饰的锁,一种就是J.U.C里面提供的锁。
ReentrantLock(可重入锁)和synchronized区别
ReentrantLock独有的功能
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(); }
}
}
创建一个线程通常有两种方式,一种是直接继承Thread,一种是实现Runnabale接口。这两种方式有一种共同的缺陷,在执行完任务后无法获取执行结果。从Java 1.5开始,就提供了Callable、Future等方式,能够获取任务的执行结果。
Callable与Runnable接口对比:
什么场景下使用FutureTask?假如一个线程需要计算一个值,这个值不是马上需要且很费时,那么就可以使用此类,一个用于计算,一个等待计算完成后获取结果同时还可以做其他操作,这样的场景就可以使用此类,达到性能的尽可能最大化。
Future接口代码示例:
FutureTask类代码示例:
Fork/Join是Java 7 中提供的一个用于执行并行任务的框架,它采用了分治的思想,将一个大任务拆分成若干个小任务来执行,并最终合并结果。Fork就是切分任务,Join就是合并结果。它主要用到了工作窃取算法,是指某个线程从其他队列里窃取任务来执行。窃取任务从一端拿去任务执行,被窃取任务的线程从该任务的另外一端来拿取任务,以此减少线程的竞争,并最大化利用线程。
缺点:
代码示例:
图示中是此队列的操作对应的方法;
new Thread弊端:
线程池的好处:
线程池 - ThreadPoolExecutor
它初始化好线程的实例后,然后把任务丢进去,等待任务执行即可。它的使用非常简单,方便。构建线程池也比较容易,我们只需要传入它需要的参数即可。
线程池的状态:
线程池的常用方法:
使用5,6,7,8等方法,可以监控线程中任务的执行情况。
线程池类图:
线程池- Executor框架接口:
使用Executor可以很方便的创建出不同类型的线程池。
代码示例:
不同的,把Executors.newxxx 替换即可。线程池使用完后记得一定要关闭。操作基本都一样,不同的是要根据不同线程池的特点,在实际场景下使用合适的线程池。
线程池-合理配置
互相持有对方线程所需要的资源从而导致了死锁发生。
比如内存不够,加内存,磁盘不够加磁盘
单机服务器不够扩展为集群。三台服务器不够,就加一台服务器。增加了对共享资源的压力。
实际的需要根据实际场景来,这里是一个思路,具体的要根据实际场景来选择合适的处理方案。
图示:
缓存特征:
缓存满的时候,如何有效缓存,如何清理?
选择合适的清空策略能够有效的提升缓存的命中率。FIFO: 先进先出策略(最先进的最先被清理,对数据实时性要求高的场景)。LFU:最少使用策略(比较命中次数,保证高频命中的策略)。 LRU:最近最少使用策略。 (优先保证热点数据的有效性);过期时间(最长时间的被清理)
缓存命中率影响因素:
并发越高,缓存的收益率越高。如果缓存的随机性很高,且在缓存过期后还未命中,这样的缓存收益率就很低。
缓存分类和应用场景
缓存-Guava Cache
缓存-Memcache
缓存-redis:
常见问题有:
缓存一致性出现的场景:
缓存并发问题:
缓存穿透问题:
当查询大量数据没有走redis而是直接走的数据库。
缓存雪崩:
缓存穿透、缓存抖动、缓存并发、缓存周期性失效等可能会导致大量请求到达数据库,导致数据库压力过大而崩溃,从而导致整个系统崩溃。
消息流程:
控制消息的速度、异步、解耦、失败重试等细节需要注意,保持最终的一致性。减少并发。
消息队列特性
为什么需要消息队列?
消息队列好处
总而言之,消息队列不是万能的,对于需要强事务保证而且对延迟很敏感的,RPC远程调用会更适合。对于别人很重要,对于自己不是主要关心的事情、追逐最终一致性、接收延迟、通知等场景则适合消息队列。
队列-Kafka
除了性能很好之外,它还是一个工作良好的分布式系统。
队列-RabbitMQ
原则:
思考:
应用拆分框架:
Dubbo(服务化)
Spring Cloud(微服务)
微服务一般是直接面向客户的。
算法主要有以下四种:
计数器:
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();
}
}
滑动窗口
它是计数器算法的升级版,它的精度更高,需要更多的存储空间。
漏桶(Leaky Bucket)算法
令牌桶算法:
它可以很好的解决临界问题。它与漏铜算法相比,令牌桶算法优势更好。
根据实际场景来选择合适的算法。
服务降级
服务熔断:
服务降级分类:
降级的提示比如:排队、错误提示页面、错误提示等
服务熔断的实现:
数据库瓶颈:
数据库切库:
什么时候考虑分表:千万级别的数据量,使用分表迫在眉睫,使用索引和优化SQL对其访问的效率提升已经不是很明显了。
分表方式:
高并发不难,关于在于在高并发场景下提供合适的解决方案与处理步骤。
觉得不错,可以点点关注+点赞哟~,您的支持就是我最大的鼓励!
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。