本课程主要是围绕并发编程和高并发解决方案两个核心来进行讲解;
希望这门课程能够带领大家攻克并发编程与高并发的难题;
课程特点:
适合人群:
学习收获:
讲解内容步骤:
涉及到的一些知识技能:
最简单的并发编程案例:实现一个计数功能(接下来我们使用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,其中有一个线程获取到了锁然后实例化后,释放锁时,如果第二个线程进去,则进行判断是否已经实例化,如果实例化了则直接返回已实例的对象。这样能够防止两个同时都各自实例化一个实例。
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对其访问的效率提升已经不是很明显了。
分表方式:
高并发不难,关于在于在高并发场景下提供合适的解决方案与处理步骤。
觉得不错,可以点点关注+点赞哟~,您的支持就是我最大的鼓励!