尚硅谷大厂面试题第二季周阳主讲
https://www.bilibili.com/video/BV18b411M7xz
HashMap:map的主要实现类,效率高,线程不安全,支持存储null的key和value
LinkedHashMap:再执行频繁的遍历操作时效率比HashMap高,线程安全
HashTable:古早的实现类,效率低,线程安全,不支持存储null的key和value
ConcurrentHashMap:线程安全
TreeMap:按照添加的key、value进行排序,实现排序遍历
jdk1.7时:
- HashMap map = new HashMap()
- 实例化后底层创建一个长度为16的Entry[ ] 一维数组,名叫table
- map.put(k1,v1)
- 调用k1所在类的hashCode() 计算k1 的哈希值,得到在数组中的存放位置
- 如果此位置数据为空,k1-v1添加成功(情况1)
- 如果此位置已有一个或多个数据采用链表形式存在,比较k1和存在数据的哈希值
- 如果k1和存在数据的哈希值不同,k1-v1添加成功(情况2)
- 如果k1和存在数据的哈希值相同,调用k1所在类的equals()
- equals() 返回false k1-v1添加成功(情况3),返回 true 将v1替换已存在数据
- 在添加过程中出现扩容问题,默认为原来的2倍,扩容完将原数据复制
jdk1.8时:
- 实例化后不再创建长度16的数组,而是在调用put() 方法时底层创建 长度16的 Node[ ] 数组
- jdk1.7底层结构数组+链表。1.8加入红黑树
- 当数组某一索引位置元素链表形式存在数据个数大于8且数组长度大于64,改为红黑树存储
java.util.concurrent:并发;java.util.concurrent.atomic:原子类;java.util.concurrent.lock:锁
volatile是JVM提供的轻量级同步机制:保证可见性、不保证原子性、禁止指令重排序
JMM:加锁解锁是同一把锁,加锁前读取到共享内存(堆)中最新值到工作内存(栈),解锁前将最新值写入共享内存(堆)
JVM运行程序实体是线程,每个线程创建时会有自己的私有栈,而公共资源将存入堆中,所有线程可见,拷贝堆中变量到自己的栈中操作,再写回堆中
可能出现一种多个线程拷贝后,一个线程修改完写入堆中,其他线程不知道变量值已改,需及时通知其他线程,引出可见性。
解决原子性:使用volatile、synchronized
JMM要求保证原子性,volatile不保证。是因为多个值在 putfield写回去时线程挂起没收到最新值通知,出现写覆盖,将值覆盖掉 如:i++
解决原子性:1.使用synchronized不推荐,2.使用 juc.atomic下的原子类 AtomicInteger 底层使用到了CAS
有序性:计算机执行程序为提高性能在编译器和处理器中做指令重排,编译器优化->指令并行->内存系统,进行重排序需要考虑指令之间的数据依赖性
volatile实现禁止指令重排序,通过插入内存屏障禁止内存屏障前后的指令执行重排序优化,强制刷出CPU缓存数据,线程读取最新数据
内存屏障:保证特定操作的顺序;保证某些变量的内存可见性(实现volatile内存可见性) ,写:storestore、storeload;读:loadload、loadstore
有哪些地方用到了volatile
单例模式:DLC双端检锁,存在指令重排,某一次线程第一次检测读取为null,引用对象没完成初始化。不一定线程安全,加入volatile禁止指令重排
public class SingletonDemo {
private static volatile SingletonDemo instance=null;
private SingletonDemo(){
System.out.println(Thread.currentThread().getName()+"\t 构造方法");
}
/**
* 双重检测机制
* @return
*/
public static SingletonDemo getInstance(){
if(instance==null){
synchronized (SingletonDemo.class){
if(instance==null){
instance=new SingletonDemo();
}
}
}
return instance;
}
public static void main(String[] args) {
for (int i = 1; i <=10; i++) {
new Thread(() ->{
SingletonDemo.getInstance();
},String.valueOf(i)).start();
}
}
}
比较并交换。线程期望值和物理内存真实值一致,更新到内存中,返回true,不一致,返回false。本次修改失败,重新获取主物理内存中的真实值
内存值 V,期望值A,更新值B;V=A则更新B;CAS是条CPU并发原语,体现在sun.misc.unsafe类 调用UnSafe类的CAS方法,原语执行必须连续,不允许中断,即不会造成数据不一致问题
底层原理详解:
- AtomicInteger里的value原始值,被线程各自拷贝到对应的栈中
- 线程A通过getIntVolatile(var1,var2)拿到原始值并挂起,
- 线程B通过getIntVolatile(var1,var2)拿到原始值但没挂起,执行了compareAndSwapInt方法比较内存中的值和原始值相同,修改内存值
- 线程A恢复,执行compareAndSwapInt方法比较发现内存中的值和期望值不同,说明该值先一步被修改,只能重新来一遍
- 线程A重新获取value值,变量value值是volatile修饰,具有可见性,线程A重新执行compareAndSwapInt方法进行替换,直到成功
原子类整型不用加synchronized就是底层Unsafe类保证原子性,保证线程安全。Unsafe是CAS的核心类,根据内存偏移地址获取数据
CAS的缺点: 一直失败一直重试,多次比较循环时间开销大;只能保证一个共享变量的原子性,无法保证多个共享变量原子性,可加锁保证;ABA问题
比较CAS和synchronized:synchronized加锁,保证一致性,并发性下降,适用多写;CAS不加锁,保证一致性,多次比较耗时长,适用多读
故障显示:java.util.ConcurrentModificationException 并发修改异常
解决方案:1.new Vector<> 2.Collections.synchronizedList(new ArrayList<>()) 3.new CopyOnWriteArrayList<>()
CopyOnWriteArrayList:写时复制,即往容器中添加元素不直接添加到当前容器,而是先copy一个新的容器,向容器中添加元素后将原容器引用指向新容器
好处:可以对copyOnWrite容器进行并发读,而不需要加锁,因为当前容器不会添加任何容器,读写分离的思想,读和写在不同容器
public static void main(String[] args){
List<String> list = new CopyOnWriteArrayList<>();
for(int i = 1; i <= 30; i++){
new Thread(()->{
list.add(UUID.randomUUID().toString().substring(1,8));
System.out.println(list);
},String.valueOf(i)).start();
}
}
公平锁和非公平锁
公平锁:指多线程按申请顺序获取锁,先来后到;线程先查看锁维护的等待队列,当前线程为第一个则占有锁,否则加入等待队列
非公平锁:指多线程可不按申请顺序获取锁,插队,在高并发下可能出现饥饿现象;直接尝试占有锁,尝试失败再采用类似公平锁方式
ReentrantLock的构造函数指定锁是否为公平锁,默认为false即非公平锁,synchronized也是非公平锁,非公平锁吞吐量比公平锁大
可重入锁(递归锁)
指同一线程外层函数获得锁后,内层函数也能获取该锁,在进入内层方法自动获取锁。即线程可进入任何一个它已拥有锁所同步的代码块
ReentrantLock 和 synchronized 就是典型的可重入锁
独占锁(写锁)/共享锁(读锁)/互斥锁
独占锁:该锁一次只能被一个线程所持有,ReentrantLock 和 synchronized 都是独占锁
共享锁:该锁可被多个线程锁持有 ReentrantReadWriteLock 读写锁,其读锁是共享锁,写锁是独占锁;可保证并发读,读写、写读、写写过程互斥
自旋锁
指尝试获取锁的线程不会立即阻塞,而是采用循环方式继续尝试获取锁,减少线程上下文切换消耗,缺点是多次循环对CPU的开销大(CAS使用自旋锁)
//unsafe.getAddInt public final int getAddInt(Object var1,long var2,int var4){ int var5; do{ var5 = this.getIntVolatile(var1,var2); }while(this.compareAndSwapInt(var1,var2,var5,var5 + var4)); return var5; }
CountDownLatch:让一些线程阻塞到另外线程完成后才唤醒 例:秦灭六国
public enum CountryEnum {
ONE(1, "齐"),
TWO(2, "楚"),
THREE(3, "燕"),
FOUR(4, "赵"),
FIVE(5, "魏"),
SIX(6, "韩");
CountryEnum(Integer code, String name) {
this.code = code;
this.name = name;
}
@Getter
private Integer code;
@Getter
private String name;
public static CountryEnum forEach(int index) {
CountryEnum[] countryEnums = CountryEnum.values();
for (CountryEnum countryEnum : countryEnums) {
if (index == countryEnum.getCode()) {
return countryEnum;
}
}
return null;
}
}
public class CountDownLatchDemo {
public static void main(String[] args) throws Exception {
sixCountry();
}
/**
* 秦灭六国 一统华夏
* @throws InterruptedException
*/
private static void sixCountry() throws InterruptedException {
CountDownLatch countDownLatch = new CountDownLatch(6);
for (int i = 1; i <= 6; i++) {
new Thread(() -> {
System.out.println(Thread.currentThread().getName() + "\t" + "国,灭亡");
countDownLatch.countDown();
}, CountryEnum.forEach(i).getName()).start();
}
countDownLatch.await();
System.out.println("秦统一");
}
}
CyclicBarrier:让一组线程到达屏障后阻塞直到所有线程到达,屏障放开,所以拦截的线程开始执行 例:集齐龙珠
public class CyclicBarrierDemo {
public static void main(String[] args) {
CyclicBarrier cyclicBarrier=new CyclicBarrier(7,()->{
System.out.println("召唤神龙");
});
for (int i = 1; i <=7; i++) {
final int temp = i;
new Thread(()->{
System.out.println(Thread.currentThread().getName()+"\t 收集到第"+ temp +"颗龙珠");
try {
cyclicBarrier.await();
} catch (Exception e) {
e.printStackTrace();
}
},String.valueOf(i)).start();
}
}
}
Semaphore :信号量有两个目的, 一是用于共享资源的互斥作用,二是用于开发资源数的控制 例:抢车位
public class SemaphoreDemo {
public static void main(String[] args) {
//模拟3个停车位
Semaphore semaphore = new Semaphore(3);
//模拟6部汽车
for (int i = 1; i <= 6; i++) {
new Thread(() -> {
try {
//抢到资源
semaphore.acquire();
System.out.println(Thread.currentThread().getName() + "\t抢到车位");
try {
TimeUnit.SECONDS.sleep(3);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + "\t 停3秒离开车位");
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
//释放资源
semaphore.release();
}
}, String.valueOf(i)).start();
}
}
}
队列+阻塞队列:阻塞队列首先是个队列,线程1往阻塞队列中添加元素,线程2从阻塞队列中移除元素;
好处:多线程领域,阻塞即是在某些情况下挂起线程,当条件满足时挂起的线程优先唤醒,使用阻塞队列可以免去自己控制唤醒,BlockingQueue一手包办
BlockingQueue的核心方法
方法类型 抛出异常 特殊值 阻塞 超时 插入 add(e) offer(e) put(e) offer(e,time,unit) 移除 remove() poll() take() poll(time,unit) 检查 element() peek() 不可用 不可用 注释:
抛出异常:阻塞队列满时,往队列add元素抛出IllegalStateException:Queue full;阻塞队列空时,往队列remove元素抛出NoSuchElementException
特殊值:插入方法,成功返回true,失败返回false;移除方法,成功返回元素,队列里没有返回null
一直阻塞:阻塞队列满时,生产者往队列put元素,队列一直阻塞直到put数据或响应中断退出;阻塞队列空时,消费者从队列take元素,队列一直阻塞消费者线程直到队列可用
超时退出:当阻塞队列满时,队列会阻塞生产者线程一定时间,超过后生产者线程退出
种类分析
用途
线程池
消息中间件
生产者消费者模型
传统版
package com.liner.interview.study.thread; import java.util.concurrent.locks.Condition; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; class ShareData { private int number = 0; private Lock lock = new ReentrantLock(); private Condition condition = lock.newCondition(); //加 public void increment()throws Exception { lock.lock(); try{ //1.判断 while (number != 0){ //等待,不能生产 condition.await(); } //2.干活 number++; System.out.println(Thread.currentThread().getName()+"\t"+number); //3.通知唤醒 condition.signalAll(); }catch (Exception e){ e.printStackTrace(); }finally { lock.unlock(); } } //减 public void decrement()throws Exception { lock.lock(); try{ //1.判断 while (number == 0){ //等待,不能生产 condition.await(); } //2.干活 number--; System.out.println(Thread.currentThread().getName()+"\t"+number); //3.通知唤醒 condition.signalAll(); }catch (Exception e){ e.printStackTrace(); }finally { lock.unlock(); } } } /** * * 传统的消费者和生产者Demo * 题目:一个初始值为零的变量,两个线程对其交替操作,一个加一个减一,来五轮 */ public class ProdConsumer_TraditionDemo { public static void main(String[] args) { ShareData shareData = new ShareData(); new Thread(() -> { for (int i = 1; i <=5 ; i++) { try { shareData.increment();//增加 } catch (Exception e) { e.printStackTrace(); } } },"AAA").start(); new Thread(() -> { for (int i = 1; i <=5 ; i++) { try { shareData.decrement();//减 } catch (Exception e) { e.printStackTrace(); } } },"BBB").start(); } }
阻塞队列版
package com.liner.interview.study.thread; import java.util.concurrent.ArrayBlockingQueue; import java.util.concurrent.BlockingQueue; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; /** * @author: liner * @create: 2020-05-07 17:03 * * valatile/cas/atomicInteger/BlockQueue/线程交互/原子引用整合的生产者消费者案例 **/ class MyResource { private volatile boolean FLAG = true; //默认开启,进行生产+消费 private AtomicInteger atomicInteger = new AtomicInteger(); BlockingQueue<String> blockingQueue = null; public MyResource(BlockingQueue<String> blockingQueue) { this.blockingQueue = blockingQueue; System.out.println(blockingQueue.getClass().getName()); } //生产者 public void MyProd() throws Exception{ String data = null; boolean retValue ; //默认是false while (FLAG) { //往阻塞队列填充数据 data = atomicInteger.incrementAndGet()+"";//等于++i的意思 retValue = blockingQueue.offer(data,2L, TimeUnit.SECONDS); if (retValue){ //如果是true,那么代表当前这个线程插入数据成功 System.out.println(Thread.currentThread().getName()+"\t插入队列"+data+"成功"); }else { //那么就是插入失败 System.out.println(Thread.currentThread().getName()+"\t插入队列"+data+"失败"); } TimeUnit.SECONDS.sleep(1); } //如果FLAG是false了,马上打印 System.out.println(Thread.currentThread().getName()+"\t大老板叫停了,表示FLAG=false,生产结束"); } //消费者 public void MyConsumer() throws Exception { String result = null; while (FLAG) { //开始消费 //两秒钟等不到生产者生产出来的数据就不取了 result = blockingQueue.poll(2L,TimeUnit.SECONDS); if (null == result || result.equalsIgnoreCase("")){ //如果取不到数据了 FLAG = false; System.out.println(Thread.currentThread().getName()+"\t 超过两秒钟没有取到数据,消费退出"); System.out.println(); System.out.println(); return;//退出 } System.out.println(Thread.currentThread().getName()+"\t消费队列数据"+result+"成功"); } } //叫停方法 public void stop() throws Exception{ this.FLAG = false; } } public class ProdConsumer_BlockQueueDemo { public static void main(String[] args) throws Exception{ MyResource myResource = new MyResource(new ArrayBlockingQueue<>(10)); new Thread(() -> { System.out.println(Thread.currentThread().getName()+"\t 生产线程启动"); try { myResource.MyProd(); } catch (Exception e) { e.printStackTrace(); } },"Prod").start(); new Thread(() -> { System.out.println(Thread.currentThread().getName()+"\t 消费线程启动"); System.out.println(); System.out.println(); try { myResource.MyConsumer(); System.out.println(); System.out.println(); } catch (Exception e) { e.printStackTrace(); } },"Consumer").start(); try { TimeUnit.SECONDS.sleep(5); }catch (Exception e) {e.printStackTrace();} System.out.println(); System.out.println(); System.out.println(); System.out.println("5秒钟时间到,大bossMain主线程叫停,活动结束"); myResource.stop(); } }
synchronized:
- synchronized是JVM层面,是Java的关键字
- synchronized不需要手动释放锁,系统在synchronized执行完毕后自动让线程释放对锁的占用
- synchronized不能被中断,除非抛了异常或执行完成
- synchronized是非公平锁
- synchronized不支持精确唤醒,只能随机唤醒或唤醒全部线程
lock:
- lock是API层面的具体类,是java5以后新出的类
- lock需要手动释放锁,若没主动释放锁,可能出现死锁现象
- lock可中断,只要设置超时方法
- lock可公平锁也可非公平锁,默认非公平锁
- lock支持精确唤醒
使用lock可支持锁绑定多个Condition,进行精确唤醒,并且可以锁中断
使用线程池的优势
线程池主要控制运行线程的数量,处理过程中将任务加入队列,在线程创建后启动任务,线程超过最大线程数,超出线程排队等候
主要特点:线程复用、控制最大并发数、管理线程
- 降低资源消耗:通过重复利用已创建的线程降低线程创建和销毁所造成的消耗
- 提高响应速度:当任务到达时,任务可无需等待立即执行
- 提高线程的可管理性:线程是稀缺资源,无限创建会消耗资源,降低系统稳定性,使用线程池起到统一分配,调优和监控作用
线程池的使用
Java中线程池通过Executor框架实现,用到了Executor,Executors,ExecutorService,ThreadPoolExecutor几类
编码实现:
了解:Executors.newCachedThreadPool();Executors.newWorkStealingPool(int)(java8新增,使用及其可用处理器作为其并行级别)
重点:
Executors.newFixedThreadPool(int):固定线程数的线程池,执行一个长期的任务,超出线程会在队列中等待
public static ExecutorService newFixedThreadPool(int nThreads){ return new ThreadPoolExecutor(nThread,nThreads,0L,TimeUnit.MILLISECONDS,new LinkedBlockingQueue<Runnable>()); }
newFixedThreadPool创建的线程池corePoolSize和MaximumPoolSize相等,使用LinkedBlockingQueue
Executors.newSingleThreadExecutor():一池一线程,适用于一个任务就执行一个线程,唯一工作线程保证所有任务按指定顺序执行
@NotNull public static ExecutorService newSingleThreadExecutor(){ return new FinalizableDelegatedlExecutorService( new ThreadPoolExecutor(1,1,0L,TimeUnit.MILLISECONDS,new LinkedBlockingQueue<Runnable>())); }
newSingleThreadExecutor创建的线程池corePoolSize和MaximumPoolSize都设置为1,使用LinkedBlockingQueue
Executors.newCachedThreadPool():一池多线程,可扩容,带缓冲缓存,适用于多个短期异步的小程序或负载较轻的服务器
@NotNull public static ExecutorService newCachedThreadPool(){ return new ThreadPoolExecutor(0,Integer.MAX_VALUE,60L,TimeUnit.SECONDS,new SynchronousQueue<Runnable>()); }
newCachedThreadPool将corePoolSize设置为0,MaximumPoolSize设置成Integer.MAX_VALUE,使用SynchronousQueue,即来了任务就创建线程运行,线程空闲时间超过60s就销毁线程,可灵活回收线程,若无可回收就创建新线程
线程池重要参数
public ThreadPoolExecutor(int corePoolSize,int maximumPoolSize,long keepAliveTime,TimeUnit unit,BlockingQueue<Runnable> workQueue,ThreadFactory threadFactory,RejectedExecutionHandler handler){ if(corePoolSize < 0 || maximumPoolSize <= 0 || maximumPoolSize < corePoolSize || keepAliveTime < 0){ throw new IllegalArgumentException(); } if(workQueue == null || threadFactory == null || handler == null){ throw new NullPointerException(); } this.corePoolSize = corePoolSize; this.maximumPoolSize = maximumPoolSize; this.workQueue = workQueue; this.keepAliveTime = unit.toNanos(keepAliveTime); this.threadFactory = threadFactory; this.handler = handler; }
corePoolSize:线程池中的常驻核心线程数,当线程池中线程数达到corePoolSize,把到达的任务放入缓存队列中
maximumPoolSIze:线程池能够容纳同时执行的最大线程数,此值大于等于1
keepAliveTime:多余的空闲线程存活时间,当空闲时间达到keepAliveTime值时,多余线程被销毁直到只剩corePoolSize为止
默认情况只有当线程池中线程数大于corePoolSize时keepAliveTime才起作用,直到线程数不大于corePoolSize
unit:keepAliveTime的单位
workQueue:任务队列,被提交但尚未执行的任务
threadFactory:表示生成线程池中工作线程的线程工厂,用户创建新线程。一般默认即可
handler:拒绝策略,表示当线程队列满了且工作线程大于等于线程池的最大显示数(maximumPoolSIze)时该如何拒绝
线程池底层原理
- 创建线程池后,等待提交的任务请求
- 调用execute() 方法添加一个请求任务时,线程池做以下判断
- 正在运行的线程数量小于corePoolSize,马上创建线程运行这个任务
- 正在运行的线程数量大于或等于corePoolSize,将这个任务放入队列
- 这时队列满了但是正在运行线程数量小于maximumPoolSize,创建非核心线程立刻运行这个任务
- 队列满了且正在运行线程数量大于或等于maximumPoolSize,线程池启动饱和拒绝策略来执行
- 当一个线程完成任务时,从队列取下一个任务来执行
- 当一个线程无事可做超过一定时间(keepAliveTime)时,线程池会判断,当前运行的线程数小于corePoolSize,这个线程被销毁
举例:
- 假设一开始只有两个核心线程(corePoolSize),请求数量也只有两个,但后面请求数增多,在队列(BlockingQueue)直到等待队列满了
- maximumPool开启最大非核心线程数进行处理请求数量,若BlockingQueue这等待的请求已经爆满,最大线程数和队列都满了
- handler开始拒绝其他大量请求进来
- 后期请求量变少,直到请求量数量少于目前线程数,线程池开始对目前空余线程进行一段时间的等待,等待期间无大量请求进来,即只需核心线程数即可处理,将多余线程进行销毁,直至剩余两个核心线程
线程池的拒绝策略
- 当等待队列也已经排满塞不下新任务时,线程max到达无法接收新任务服务,需要拒绝策略机制合理处理问题
- JDK内置拒绝策略(均实现了RejectExecutionHandler接口)
- AborPolicy(默认):直接抛出RejectExecution异常,阻止系统正常运行
- CallerRunPolicy:调用者运行,该策略不抛弃任务也不抛异常,将某些任务回退调用者,从而降低新任务的流量
- DiscardOldestPolicy:抛弃队列中等待最久的任务,把当前任务加入队列中尝试再次提交当前任务
- DiscardPolicy:直接丢弃任务,不予处理也不抛异常,如果允许任务丢失,这是最好的一种拒绝策略方案
工作中创建线程池的方法
生产上只能使用自定义的线程池,即手写线程池,而不用Excutors中提供的 单一的/固定数的/可变的三种创建线程池方法
package com.liner.interview.study.thread; import java.util.concurrent.*; /** * @author: liner * @create: 2020-05-08 09:34 * * ThreadPoolExecutor * 第四种获得/使用java多线程的方式,线程池 **/ public class MyThreadPoolDemo { public static void main(String[] args) { /** * 手写线程池 */ ExecutorService threadPool = new ThreadPoolExecutor( 2, 5, 1L, TimeUnit.SECONDS, new LinkedBlockingQueue<Runnable>(3), Executors.defaultThreadFactory(), new ThreadPoolExecutor.DiscardPolicy()); //最多几个人来办理业务,线程池会爆? try{ //模拟10个用户来办理业务,每个用户就是一个来自外部的请求线程 for (int i = 1; i <=15 ; i++) { threadPool.execute(()->{ System.out.println(Thread.currentThread().getName()+"\t 办理业务"); }); } }catch (Exception e){ e.printStackTrace(); }finally { threadPool.shutdown(); } } /** * jdk自带的线程池 */ private static void threadPoolInit() { // System.out.println(Runtime.getRuntime().availableProcessors()); ExecutorService threadPool = Executors.newFixedThreadPool(5);//一池5个处理线程 //ExecutorService threadPool = Executors.newSingleThreadExecutor();//一池1个处理线程 //ExecutorService threadPool = Executors.newCachedThreadPool();//一池N个处理线程 try{ //模拟10个用户来办理业务,每个用户就是一个来自外部的请求线程 for (int i = 1; i <=10 ; i++) { threadPool.execute(()->{ System.out.println(Thread.currentThread().getName()+"\t 办理业务"); }); try { TimeUnit.MILLISECONDS.sleep(200); }catch (Exception e) {e.printStackTrace();} } }catch (Exception e){ e.printStackTrace(); }finally { threadPool.shutdown(); } } }
参考阿里巴巴Java开发手册:
【强制】线程资源必须通过线程池提供,不允许在应用中自行显式创建线程。 说明:使用线程池的好处是减少在创建和销毁线程上所消耗的时间以及系统资源的开销,解决资源不足的问题。如果不使用线程池,有可能造成系统创建大量同类线程而导致消耗完内存或者“过度切换”的问题。
【强制】线程池不允许使用Executors去创建,而是通过ThreadPoolExecutor的方式,这样的处理方式让写的同学更加明确线程池的运行规则,规避资源耗尽的风险。说明:Executors返回的线程池对象的弊端如下:
1)FixedThreadPool和SingleThreadPool:允许的请求队列长度为Integer.MAX_VALUE,可能会堆积大量的请求,从而导致OOM。
2)CachedThreadPool和ScheduledThreadPool:允许的创建线程数量为Integer.MAX_VALUE,可能会创建大量的线程,从而导致OOM。
如何合理配置线程池
创建多线程的方式:以前有2种,现在有4种
start() 以后是否可马上启动线程:不是,多线程和操作系统有关,start() 线程进入就绪状态,什么时候能启动需等待CPU的底层调度通知
Java多线程的状态:
操作系统:新建、就绪、运行、阻塞、终止:
- 新建:线程刚分配到内存,没执行 start() 方法
- 就绪:线程执行 start()
- 运行:线程真正分配到CPU,执行线程中的逻辑代码
- 阻塞:因一些原因,如未竞争到锁、进入sleep()、wait() 方法导致线程未执行完毕 run() 代码进入阻塞
- 终止:线程执行完定义的 run() 方法后线程终止
java:新建、可运行、等待、超时等待、阻塞、终止
- NEW:线程未执行 start() 方法
- RUNNABLE:java中将 就绪和运行 统称 可运行
- WAITING:线程等待其他线程释放资源
- TIMED_WAITING:和等待不同,可设置等待时间,超时跳出等待
- BLOCKED:线程阻塞和锁
- TERMINATED:表示当前线程执行完毕
sleep() 和 wait() 方法的区别
- sleep():主动让出CPU,但不让出锁,带着锁进入睡眠,让CPU先完成其他进程,睡眠结束继续执行当前线程,不用唤醒,用于线程休眠、轮询中断
- wait():退出当前对资源的竞争,放弃锁资源,进入等待池,等待 notify() 或 notifyAll() 的唤醒,进入锁池,继续竞争资源锁,用于多线程通信
start() 和 run() 方法的区别
- start():调用start() 创建一个新的进程,真正实现了多线程的运行,不用等待 run() 执行完毕,直接执行其后代码,处于就绪态,未运行,等run() 运行完毕,线程终止
- run():只是线程中的一个方法体,如果单独运行 run() 和普通函数一样,需要等待 run() 执行完毕,才能执行其后代码,因为只在一个线程中,未体现多线程的特性
接口能不能new:可以new,创建匿名内部类
接口里能不能有方法实现:jdk1.8之前不可以规定只允许方法的声明,但jdk1.8之后支持接口中有部分方法的实现,default方法
lock的理解:Lock跟Condition配合使用,精准通知,精准唤醒
多线程的锁(关于synchronized锁对象的判断)
import java.util.concurrent.TimeUnit; class Phone{ public static synchronized void sendEmail()throws Exception{ //tsleep try { TimeUnit.SECONDS.sleep(4); }catch (Exception e) {e.printStackTrace();} System.out.println("-----sendEmail"); } public synchronized void sendSMS()throws Exception{ System.out.println("-----sendSMS"); } public void hello(){ System.out.println("-----hello"); } } /** * 多线程8锁 * 1.标准访问:请问先打印邮件还是短信? ————先打印邮件 * 2.邮件方法暂停4秒钟 请问先打印邮件还是短信? ————先打印邮件 * 3.新增一个普通方法hello(),请问先打印邮件还是hello? ————先打印hello 加入普通方法后发现和同步锁无关 * 4.有两部手机,请问先打印邮件还是短信? ————先打印短信 换成两个对象,不是同一把锁,情况发生变化 * 5.两个静态同步方法,同一部手机,请问先打印邮件还是短信? ————先打印邮件 都换成静态同步方法后,情况发生变化 * 6.两个静态同步方法,两部手机,请问先打印邮件还是短信? ————先打印邮件 * 7.一个普通同步方法,一个静态同步方法,一部手机,请问先打印邮件还是短信? ————先打印短信 * 8.一个普通同步方法,一个静态同步方法,两部手机,请问先打印邮件还是短信? ————先打印短信 */ public class Lock8 { public static void main(String[] args) throws Exception{ Phone phone = new Phone(); Phone phone2 = new Phone(); new Thread(() -> { try { phone.sendEmail(); } catch (Exception e) { e.printStackTrace(); } },"A").start(); Thread.sleep(100); new Thread(() -> { try { //phone.sendSMS(); // phone.hello(); phone2.sendSMS(); } catch (Exception e) { e.printStackTrace(); } },"B").start(); } }
一个对象里如果有多个synchronized方法,那么一个时刻内,只要一个线程去调用其中的一个synchronized方法,其他线程都只能等待
一个线程去访问synchronized方法,锁的是当前对象this,被锁定后其他线程都不能进入当前对象的其他synchronized方法
创建多线程的区别:Runnable和Callable接口的区别
方法不同,一个叫 run方法无泛型无返回值且不带异常,一个叫call方法有泛型有返回值且带异常
为什么用CAS,不使用synchronized:其实是CAS适用于读少写多的场景,synchronized适用于读多写少的场景
- synchronized加锁,同一时间段允许一个线程访问,一致性得到保证,但并发性下降
- CAS没有加锁,可以反复通过CAS比较,直到成功为止,既保证一致性又提高了并发性
集合安全和不安全:
线程不安全:ArrayList -> copyOnWriteArrayList;HashMap -> ConcurrentHashMap;HashSet -> CopyOnWriteArraySet
线程安全:Vector,保证数据的一致性,但是性能慢
印象深刻的故障:java.util.ConcurrentModificationException,并发修改异常 导致原因时ArrayList.add线程不安全,并发未加锁,使用CopyOnWriteArrayList
五个常见的java异常:
HashSet底层结构:底层HashMap;HashSet的add方法底层调用的是HashMap的put方法,传入的是HashMap的key,HashMap的value是个常量,表占位符
HashMap底层结构:HashMap<>() 存储的不是键值对,是存储一个个node节点,node里存储键值对,底层是node数组+链表+红黑树,初始数组长度16,负载因子0.75,满足泊松定则,可以改,但是很少去改,因为够用。
无法拿到返回值:继承Thread / 实现Runnable;可以拿到返回值:Callable / Future
创建CompletableFuture对象
public static CompletableFuture<Void> runAsync(Runnable runnable); public static CompletableFuture<Void> runAsync(Runnable runnable,Executor executor); public static<U> CompletableFuture<U> supplyAsync(Supplier<U> supplier); public static<U> CompletableFuture<U> supplyAsync(Supplier<U> supplier,Executor executor);
- 以 Async 结尾并且没有指定 Executor 的方法会使用 ForkJoinPool.commonPool() 作为它的线程池执行异步代码
- runAsync():以 Runnable 函数式接口类型为参数,所以 CompletableFuture 的计算结果为空
- supplyAsync():以 Supplier 函数式接口类型为参数,所以 CompletableFuture 的计算结果类型为 U
- 这些线程都是 Daemon线程,主线程结束 Daemon线程不结束,只有JVM关闭时,生命周期终止
计算结果完成时的处理
public CompletableFuture<T> whenComplete(BiConsumer<? super T,? super Throwable> action); public CompletableFuture<T> whenCompleteAsync(BiConsumer<? super T,? super Throwable> action); public CompletableFuture<T> whenCompleteAsync(BiConsumer<? super T,? super Throwable> action,Executor executor); public CompletableFuture<T> exceptionally(Function<Throwable,? extends T> fn);
- Action 的类型是 BiConsumer super T,? super Throwable> ,可以处理正常的计算结果,或者异常情况
- 方法不以 Async 结尾,意味 Action 使用相同的线程执行,而 Async 可能使用其他线程执行(如使用相同线程池,也可能被同一线程选中执行)
thenApply
- 当前阶段正常完成以后执行,当前阶段执行的结果会作为下一阶段的输入参数,thenApplyAsync 默认是异步执行,所谓异步指不在当前线程内执行
- 例:
CompletableFuture.supplyAsync(()->1).thenApply(i->i+1).thenApply(i->i*i).whenComplete((r,e)->System.out.println(r)); CompletableFuture.supplyAsync(()->"Hello").thenApply(s->s+"World").thenApply(String::toUpperCase);
thenAccept与thenRun
public CompletionStage<Void> thenAccept(Consumer<? super T> action); public CompletionStage<Void> thenAcceptAsync(Consumer<? super T> action); public CompletionStage<Void> thenAcceptAsync(Consumer<? super T> action,Executor executor); public CompletionStage<Void> thenRun(Runnable action); public CompletionStage<Void> thenRunAsync(Runnable action); public CompletionStage<Void> thenRunAsync(Runnable action,Executor executor);
- thenAccept 和 thenRun 都无返回值,thenApply 生产,那么 thenAccept 和 thenRun 消费,它们是整个计算的最后两个阶段
- 同样是执行指定的动作,同样是消耗
- 二者的区别:thenAccept接收上一阶段的输出作为本阶段输入;thenRun不关心上一阶段的输出,不需关心前一阶段计算结果,因为不需要输入参数
thenCombine整合两个计算结果
public <U,V> CompletableFuture<V> thenCombine(CompletionStage<? extends U> other,BiFunction<? super T,? super U,? extends V> fn){} public <U,V> CompletableFuture<V> thenCombineAsync(CompletionStage<? extends U> other,BiFunction<? super T,? super U,? extends V> fn){} public <U,V> CompletableFuture<V> thenCombineAsync(CompletionStage<? extends U> other,BiFunction<? super T,? super U,? extends V> fn,Executor executor){}
- 例如此阶段与其他阶段一起完成,进而触发下一阶段
CompletableFuture.supplyAsync(()->"Hello").thenApply(s->s+"World").thenApply(String::toUpperCase) .thenCombine(CompletableFuture.completableFuture("Java"),(s1,s2)->s1+s2).thenAccept(System.out::println);
异步处理completeExceptionally
- 为了能获取任务线程内发生的异常,需要使用 CompleteFuture 的 completeExceptionally 方法将导致 CompleteFuture 内发生问题的异常抛出
- 当执行任务发生异常时,调用 get() 方法的线程将收到一个 ExecutionException 异常,该异常接收一个包含失败原因的 Exception 参数
异步编排(多任务组合方法allOf和anyOf):allOf是等待所有任务完成;anyOf是只要有一个任务完成即可
import java.util.concurrent.*;
public class ThreadTest {
public static ExecutorService executor = Executors.newFixedThreadPool(10);
public static void main(String[] args) throws ExecutionException, InterruptedException {
System.out.println("main....start....");
// CompletableFuture future = CompletableFuture.runAsync(() -> {
// System.out.println("当前线程:" + Thread.currentThread().getId());
// int i = 10 / 2;
// System.out.println("运行结果:" + i);
// }, executor);
// CompletableFuture future = CompletableFuture.supplyAsync(() -> {
// System.out.println("当前线程:" + Thread.currentThread().getId());
// int i = 10 / 0;
// System.out.println("运行结果:" + i);
// return i;
// }, executor).whenComplete((res,excption)->{ //虽然能得到异常信息,但是没法修改返回数据
// System.out.println("异步任务成功完成了...结果是:"+res+";异常信息是"+excption);
// }).exceptionally(throwable -> { //可以感知异常,同时返回默认值
// return 10;
// }); //成功以后干啥事
// CompletableFuture future = CompletableFuture.supplyAsync(() -> {
// System.out.println("当前线程:" + Thread.currentThread().getId());
// int i = 10 / 4;
// System.out.println("运行结果:" + i);
// return i;
// }, executor).handle((res,thr)->{
// if (res!=null){
// return res * 2;
// }
// if (thr!=null){ //异常不等于空了,就返回0
// return 0;
// }
// return 0;
// });
/**
* 线程的串行化
* 1.thenRun:不能获取到上一步的执行结果
* .thenRunAsync(() -> {
* System.out.println("任务2启动了...");
* }, executor);
*
* 2.thenAcceptAsync 能接受上一步的结果,但是没有返回值
* .thenAcceptAsync(res->{
* System.out.println("任务2启动了..."+res);
* },executor);
*
* 3. thenApplyAsync 能获取到上一步的结果 同时也有返回值
*
// */
// CompletableFuture future = CompletableFuture.supplyAsync(() -> {
// System.out.println("当前线程:" + Thread.currentThread().getId());
// int i = 10 / 4;
// System.out.println("运行结果:" + i);
// return i;
// }, executor).thenApplyAsync(res -> {
// System.out.println("任务2启动了..." + res);
// return "Hello" + res;
// }, executor);
//Integer integer = future1.get();
// /**
// * 两任务组合
// */
// CompletableFuture future01 = CompletableFuture.supplyAsync(() -> {
// System.out.println("任务1线程:" + Thread.currentThread().getId());
// int i = 10 / 4;
// System.out.println("任务1结束:" + i);
// return i;
// }, executor);
//
// CompletableFuture future02 = CompletableFuture.supplyAsync(() -> {
// System.out.println("任务2线程:" + Thread.currentThread().getId());
// System.out.println("任务2结束:");
// return "hello";
// }, executor);
// future01.runAfterBothAsync(future02,()->{
// System.out.println("任务3开始" );
// },executor);
// future01.thenAcceptBothAsync(future02,(f1,f2)->{
// System.out.println("任务3开始 之前的结果:"+f1+"-->"+f2 );
// },executor);
// CompletableFuture future = future01.thenCombineAsync(future02, (f1, f2) -> {
// return f1 + ":" + f2 + "->haha";
// }, executor);
// future01.runAfterEitherAsync(future02,()->{
// System.out.println("任务3开始 之前的结果:");
// },executor);
CompletableFuture<String> futureImg = CompletableFuture.supplyAsync(() -> {
System.out.println("查询商品的图片信息");
return "hello.jpg";
},executor);
CompletableFuture<String> futureAttr = CompletableFuture.supplyAsync(() -> {
System.out.println("查询商品的属性");
return "黑色+256G";
},executor);
CompletableFuture<String> futureDesc = CompletableFuture.supplyAsync(() -> {
System.out.println("查询商品的介绍");
return "华为";
},executor);
// futureImg.get();futureAttr.get();futureDesc.get();
//CompletableFuture allOf = CompletableFuture.allOf(futureImg, futureAttr, futureDesc);
//System.out.println("main....end..."+futureImg.get()+"=>"+futureAttr.get()+"=>"+futureDesc.get());
CompletableFuture<Object> anyOf = CompletableFuture.anyOf(futureImg, futureAttr, futureDesc);
anyOf.get();//等待所有结果完成
System.out.println("main....end..."+anyOf.get());
}
public void thread(String[] args) throws ExecutionException, InterruptedException {
System.out.println("main....start....");
// Thread01 thread01 = new Thread01();
// thread01.start();//启动线程
// System.out.println("main....end...");
// Runable01 runable01 = new Runable01();
// new Thread(runable01).start();
// System.out.println("main....end...");
// FutureTask futureTask = new FutureTask<>(new Callable01());
// new Thread(futureTask).start();
// Integer integer = futureTask.get();//阻塞等待
// System.out.println("main....end..."+integer);
/**
* 线程池:
* 给线程池直接提交任务
* 1.创建线程池的方式
* 1.1.使用Executors线程池工具类来创建线程池 service.execute(new Runable01());
* 1.2.使用原生的线程池创建方式
*
*
*/
/**
* 原生线程池的七大参数解释
* int corePoolSize, 核心线程数{只要线程池不销毁,核心线程数一直在},线程池创建好以后就准备就绪的线程数量,就等待来接收异步任务去来执行
* int maximumPoolSize, 最大线程数,控制资源并发的
* long keepAliveTime, 存活时间,如果当前线程数量大于核心数量,只要线程空闲到一定时间内,就会释放空闲的最大线程数当中的线程
* TimeUnit unit, 具体最大线程数的存活时间的时间单位
* BlockingQueue workQueue, 阻塞队列 如果任务有很多,就会将目前多的任务放在队列里面,只要有空闲的线程,就会去阻塞队列去拿新的任务
* ThreadFactory threadFactory, 线程的创建工厂 默认 也可以自定义
* RejectedExecutionHandler handler 拒绝策略,就是处理阻塞队列当中任务已满了,不能再加入其他的任务进来阻塞队列当中了,就进行指定的拒绝策略进行拒绝任务
*
* 工作顺序:
* 1. 线程池创建,准备好核心数量的线程,准备接受任务
* 2. 核心线程数量若满了,就把新进来的任务放到阻塞队列当中,等到核心线程空闲了就去阻塞队列拿新任务并进行执行
* 3. 如果阻塞队列满了,会开启指定的最大线程数量进行执行阻塞队列当中的任务,并若在指定的时间内最大线程数空闲了,就会释放资源
* 4.如果阻塞队列和最大现场数量都满了,那么就会使用指定的拒绝策略,来拒绝接受新进来的任务
*
*
* 线程池的其他方法:
* Executors.newCachedThreadPool() //核心数是0,所有都可回收
* Executors.newFixedThreadPool() //固定大小,核心数=最大值都不可回收
* Executors.newScheduledThreadPool() //定时任务的线程池
* Executors.newSingleThreadExecutor() //单线程的线程池,后台从队列里面获取任务,挨个执行
*
*/
ThreadPoolExecutor executor = new ThreadPoolExecutor(5,
200,
10,
TimeUnit.SECONDS,
new LinkedBlockingDeque<>(100000),
Executors.defaultThreadFactory(),
new ThreadPoolExecutor.AbortPolicy());
System.out.println("main....end...");
}
public static class Thread01 extends Thread{
@Override
public void run() {
System.out.println("当前线程:"+Thread.currentThread().getId());
int i = 10 / 2;
System.out.println("运行结果:"+i);
}
}
public static class Runable01 implements Runnable{
@Override
public void run() {
System.out.println("当前线程:"+Thread.currentThread().getId());
int i = 10 / 2;
System.out.println("运行结果:"+i);
}
}
public static class Callable01 implements Callable<Integer>{
@Override
public Integer call() throws Exception {
System.out.println("当前线程:"+Thread.currentThread().getId());
int i = 10 / 2;
System.out.println("运行结果:"+i);
return i;
}
}
}
类加载 -> 类装载器子系统 -> 运行时数据区 -> 执行引擎 -> 本地方法接口 <- 本地方法库
java8以后JVM删除永久代改为元空间,元空间并不在虚拟机中而是使用本机物理内存
垃圾:简单来说内存中已经不再被使用到的空间就是垃圾
如何判断一个对象是否可被回收
- 引用计数法:Java中,引用和对象有关联,操作对象必须引用进行,给对象添加引用计数器,引用加1,失效减1,计数器为0表示对象可回收
- 可达性分析:为解决引用计数法的循环引用问题,Java使用可达性分析,通过一系列名为“GC Roots”的对象作为起始点,向下搜索,一个对象到GC Roots 没有任何引用链相连,说明对象不可用,即给定一个集合的引用作为根,通过引用关系遍历对象,被遍历到的对象为存活,否则被判定为死亡
什么是GC Roots
是一组四种对象(虚拟机栈、方法区中常量引用对象、方法区中类静态属性引用对象、本地方法栈中Native方法引用对象)的根集合体,作为垃圾回收扫描的起始点
Java中可作为GC Roots的对象
- 虚拟机栈(栈帧中的局部变量区,又叫局部变量表)
- 方法区中常量引用的对象
- 方法区中类静态属性引用的对象
- 本地方法栈中N(Native方法)引用的对象
JVM的参数类型
- 标配参数:
- -version
- -help
- java -showversion
- X参数(了解):
- -Xint:解释执行
- -Xcomp:第一次使用就编译成本地代码
- -Xmixed:混合模式
- XX参数:
- Boolean类型:-XX:+/- 某个属性值 +表示开启;-表示关闭 例:-XX:+PrintGCDetails 是否打印GC收集细节
- KV设值类型:-XX:属性key = 属性值value 例:-XX:MetaspaceSize = 128m
- 两个经典参数:-Xms 和 -Xmx -Xms等价于-XX:InitialHeapSize初始化堆内存;-Xmx等价于-XX:MaxHeapSize最大堆内存
- jinfo:查看当前运行程序的配置 例:jps -l ;查看进程编号 jinfo -flag 配置项 进程编号
查看JVM默认值
- -XX:+PrintFlagsInitial 查看初始化默认值 公式:java -XX:+PrintFlagsInitial [-version]
- -XX:+PrintFlagsFinal 查看修改更新 公式:java -XX:+PrintFlagsFinal [-version]
- -XX:+PrintCommandLineFlags 公式:java -XX:+PrintCommandLineFlags [-version]
- -Xms 初始大小内存,默认为物理内存1/64 等价于 -XX:InitialHeapSize
- -Xmx 最大分配内存,默认为物理内存1/4 等价于 -XX:MaxHeapSize
- -Xms 设置单个线程大小,默认为512K~1024K 等价于 -XX:ThreadStackSize 系统出厂默认值跟平台有关,部署到Linux系统(1024K)
- -Xmn 设置年轻代大小,一般不用设置,用默认即可
- -XX:MetaspaceSize 设置元空间大小,不管几个G内存,元空间默认只占用20M 例:-XX:MetaspaceSize=1024m
- -XX:+PrintGCDetails 输出详细GC收集日志信息
- -XX:SurvivoRatio 设置新生代中Eden和s/s1空间的比例,默认 -XX:SurvivoRatio=8,Eden:S0:S1=8:1:1,SurvivoRatio值就是Eden占比
- -XX:NewRatio 设置新生代与老年代在堆结构的占比,默认 -XX:NewRatio=2,新生代占1,老年代占2,新时代占整个堆1/3
- -XX:MaxTenuringThreshold 设置垃圾最大年龄
强引用(默认支持模式):内存不足JVM开始垃圾回收,对强引用对象,出现OOM也不会将该对象回收
强引用是常见的普通对象引用,只要强引用指向一个对象,表明对象“活着”,垃圾收集器不去碰这种对象,一个对象赋给引用变量,此引用变量即强引用,是造成Java内存泄漏的主要原因之一,一个普通对象,没其他引用关系,只要超过引用的作用域或显式地将相应强引用赋值null,就可被垃圾回收器收集(具体回收时机看垃圾收集策略)
软引用:相对强引用弱一点的引用,需要 java.lang.ref.SoftReference 类来实现,让对象豁免一些垃圾收集,当系统内存充足时不会被回收,系统内存不足时会被回收
软引用通常用在对内存敏感的程序,如高速缓存就用到软引用,内存够用保留,不够用回收
弱引用:弱引用需要 java.lang.ref.WeakReference 类来实现,比软引用生存期更短
弱引用所引用的对象,只要垃圾回收机制一运行,不管JVM内存空间是否充足都会被回收,
谈谈 WeakHashMap
虚引用:虚引用需要 java.lang.ref.PhantomReference 类来实现,顾名思义形同虚设,虚引用不会决定对象的生命周期
一个对象仅持有虚引用,它就和没有任何引用一样,在任何时候都可能被垃圾回收器回收,不能单独使用也不能通过它访问对象,虚引用必须和引用队列(Reference)联合使用。主要起到跟踪对象被垃圾回收的状态,仅提供一种确保对象被finalize后,做某些事情的机制
PhantomReference的 get() 总是返回null,无法访问对应的引用对象,其意义在于说明对象进入finalization阶段,可被gc回收,用来实现比finalization机制更灵活的操作
设置虚引用的唯一目的,就是这个对象在被收集器回收时收到一个系统通知或后续添加进一步处理,Java允许使用finalize() 在垃圾收集器将对象从内存中清除出去之前做必要的清理工作
Java.lang.StackOverflowError:栈溢出错误,深度方法调用导致出不来栈,栈爆了 例:无限递归
Java.lang.OutOfMemoryError:Java heap space:堆内存不够用,堆爆了
Java.lang.OutOfMemoryError:GC overhead limit exceeded:GC回收时间过长,超过98%的时间用做GC却回收不足2%的内存,常伴随着CPU的增高
连续多次GC都只回收不到2%的极端情况下会抛出该异常,假设不抛出该异常,GC刚清理的内存很快再次填满,迫使GC再次执行,形成恶性循环,CPU使用率100%,但GC没任何成果
Java.lang.OutOfMemoryError:Direct buffer memory:内存挂了,程序直接崩溃
写NIO程序经常使用 ByteBuffer 来读取或写入数据,这是种基于通道(Channel)与缓冲区(Buffer)的I/O方式,可使用Native函数库直接分配堆外内存,通过存储在Java堆里的DirectByteBuffer对象作为这块内存的引用进行操作,在一些场景显著提高性能,因为避免Java堆和Native堆来回复制数据
ByteBuffer.allocate(capability); //分配JVM堆内存,属于GC管辖范围,需要拷贝所以速度相对较慢 ByteBuffer.allocteDirec(capability);//分配OS本地内存,不属于GC管辖范围,不需要拷贝所以速度相对较快
不断分配本地内存,而堆内存很少使用,JVM不需执行GC,DirectByteBuffer对象们不会被回收,这时堆内存充足但本地内存可能用光,再次分配本地内存导致OutOfMemoryError,程序直接崩溃
Java.lang.OutOfMemoryError:unable to create new native thread:native thread异常与对应平台有关 例:非root用户登录Linux系统测试
应用创建太多线程,超过系统承载极限,如Linux系统默认允许单个进程创建线程数1024个,超过这个数报异常。需要想办法降低应用程序创建线程的数量,分析应用是否真的需要这么多线程,也可以通过修改Linux服务器配置,扩大Linux默认限制最大线程数
服务器级别参数调优
Java.lang.OutOfMemoryError:Metaspace:元空间溢出,Metaspace不在虚拟机内存而是本地内存,即被存储在叫 Metaspace 的 native memory中
存放:虚拟机加载的类信息、常量池、静态变量、即时编译后的代码
GC算法(引用计数法/复制/标记清除/标记整理)是内存回收的方法论,垃圾收集器就是算法的落地实现,目前没有万能的收集器,只针对具体场景使用适合的收集器进行分代收集
4种主要垃圾收集器
- 串行垃圾回收器(Serial):为单线程环境设计并只使用一个线程进行垃圾回收,暂停所有用户线程,不适合服务器环境
- 并行垃圾回收器(Parallel):为多个垃圾回收线程并行工作,此时用户线程暂停,适合科学计算/大数据处理等弱交互场景
- 并发垃圾回收器(CMS):用户线程和垃圾回收线程同时进行(不一定并行,可能交替执行)不需要停顿用户线程,适用响应时间有要求场景,Java9后停用
- G1垃圾回收器:G1垃圾回收器将堆内存分割成不同区域然后并发对其进行垃圾回收
查看默认的垃圾收集器:java -XX:+PrintCommandLineFlags -version
默认的垃圾收集器:UseSerialGC、UseParallelGC、UseConcMarkSweepGC、USeParNewGC、UseParallelOldGC、UseG1GC
垃圾收集器
部分参数说明:
DefNew:Default New Generation
Tenured:Old
ParNew:Parallel New Generation
PSYoungGen:Parallel Scavenge
ParOldGen:Parallel Old Generation
Server/Client模式分别是什么意思:只需掌握Server模式即可,Client模式基本不用
32位Window操作系统,不论硬件如何都默认使用Client的JVM模式,
32位其他操作系统,2G内存同时有2个CPU以上使用Server模式,否则Client模式
64位only server模式
新生代
串行GC:(Serial) / (Serial Coping)
一个单线程的收集器,垃圾收集时暂停其他所有工作线程直到收集结束
串行收集器最古老,也最稳定且效率高的收集器,只使用一个线程去回收但其进行垃圾收集过程中会产生较长停顿(Stop-The-World)虽然在收集垃圾过程中需要暂停所有工作线程,但是它简单高效,对限定单个CPU环境,没有线程交互的开销可以获得最高的单线程垃圾收集效率,因此Serial垃圾收集器依然是java虚拟机运行在Client模式下的默认新生代垃圾收集器
对应JVM参数:-XX:+UseSerialGC
开启后使用:Serial(Young区) + Serial Old(Old区) 的收集器组合;新生代老年代都使用串行回收收集器,新生代使用复制算法,老年代使用标记整理算法
并行GC:(ParNew)
一个多线程进行垃圾回收的收集器,垃圾收集时暂停其他所有工作线程直到收集结束
ParNew收集器其实就是对Serial收集器新生代的并行多线程版本。最常见是配合老年代的CMS GC工作,其余行为和Serial收集器完全一致,ParNew收集器在垃圾回收过程中同样暂停所有其他的工作线程,是很多java虚拟机运行在Server模式下的默认新生代垃圾收集器
对应JVM参数:-XX:+UseParNewGC 启用ParNew收集器,只影响新生代,不影响老年代
开启后使用:ParNew(Young区) + Serial Old(Old区) 的收集器组合;新生代使用复制算法,老年代使用标记整理算法
ParNew + Tenured 这样的搭配,在java8已经不再被推荐
并行回收GC:(Parallel) / (Parallel Scavenge)
- Parallel Scavenge收集器类似ParNew也是一个新生代垃圾收集器,使用复制算法,是一个并行的多线程垃圾收集器,俗称吞吐量优先收集器,是串行收集器在新生代和老年代的并行化
- 重点关注于可控制的吞吐量(Thoughput=运行用户代码时间 / (运行用户代码时间 + 垃圾收集时间),即程序运行100分钟,垃圾收集时间1分钟,吞吐量99%),高吞吐量意味着高效利用CPU时间,多用于后台运算不需要太多交互的任务
- 自适应调节策略也是Parallel Scavenge 和 ParNew 的一个重要区别,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间(-XX:MaxGCPauseMillis)或最大吞吐量
老年代
串行回收GC:(Serial Old) / (Serial MSC)
Serial Old是Serial垃圾回收器的老年代版本,同样使用单线程的标记整理算法,这个收集器也主要运行在Client默认的java虚拟机默认的老年代垃圾收集器,
在Server模式下,主要有两个用途(了解):JDK1.6之前,与新生代Parallel Scavenge 搭配使用 Parallel Scavenge + Serial Old,在作为老年代中使用CMS收集器的后备垃圾收集方案
并行GC:(Parallel Old) / (Parallel MSC)
Parallel Old收集器是Parallel Scavenge的老年代版本,使用多线程的标记整理算法,Parallel Old收集器在JDK1.6之后才开始提供,1.6之前,Parallel Scavenge + Serial Old,新生代使用Parallel Scavenge收集器只能搭配老年代的Serial Old收集器,只能保证新生代的吞吐量优先,无法保证整体的吞吐量
Parallel Old正是为了在老年代同样提供吞吐量优先的垃圾收集器,如果系统对吞吐量要求比较高,1.8之后,Parallel Scavenge + Parallel Old,JDK1.8之后可优先考虑新生代Parallel Scavenge和老年代 Parallel Old收集器的搭配策略
对应JVM参数:-XX:+UseParallelOldGC
开启后使用:Parallel(Young区) + ParallelOld(Old区) 的收集器组合;新生代使用复制算法,老年代使用标记整理算法
并发标记清除GC(CMS)
- CMS收集器(Concurrent Mark Sweep 并发标记清除)是一种以获取最短回收停顿时间为目标的收集器,适用于应用互联网网站或B/S系统的服务器上,这类应用尤其重视服务器的响应速度,希望系统停顿时间最短,非常适合堆内存大、CPU核数多的服务器端使用。也是G1出现之前大型应用的首选收集器。并发收集低停顿,并发指与用户线程一起执行
- 对应JVM参数:-XX:+UseConcMarkSweepGC 开启该参数后自动将 -XX:+UseParNewGC打开
- 开启后使用:ParNew(Young区) + CMS(Old区) +Serial Old的收集器组合;新生代使用复制算法,老年代使用标记清除算法
- 4步骤:初始标记(CMS inital mark)-> 并发标记(CMS concurrent mark)和用户线程一起 -> 重新标记(CMS remark)-> 并发清除(CMS concurrent sweep)和用户线程一起
- 优缺点:优:并发收集低停顿;缺:并发执行,对CPU资源压力大,采用标记清除算法导致大量内存碎片
怎么选择垃圾收集器
- 单CPU或小内存,单机程序:-XX:+UseSerialGC
- 多CPU,需要最大吞吐量,如后台计算型应用:-XX:+UseParallelGC / -XX:+UseParallelOldGC
- 多CPU,追求低停顿时间,需快速响应如互联网应用:-XX:+UseConcMarkSweepGC / -XX:+ParNewGC
参数 新生代垃圾收集器 新生代算法 老年代垃圾收集器 老年代算法 -XX:+UseSerialGC SerialGC 复制 SerialOldGC 标记整理 -XX:+UseParNewGC ParNew 复制 SerialOldGC 标记整理 -XX:+UseParallelGC / -XX:+UseParallelOldGC Parallel [Scavenge] 复制 Parallel Old 标记整理 -XX:+UseConcMarkSweepGC ParNew 复制 CMS+Serial Old收集器组合(Serial Old作为CMS出错的后备收集器) 标记清除 -XX:+UseG1GC G1整体采用标记整理算法 局部通过复制,不会产生内存碎片
以前收集器特点
- 年轻代和老年代是各自独立且连续的内存块
- 年轻代收集使用单Eden+S0+S1 进行复制算法;老年代收集必须扫描整个老年代区域
- 都以尽可能少且快速执行GC为原则
G1是什么
G1(Garbage-First) 收集器,是一款面向服务端应用的收集器,应用在多处理器和大容量内存环境中,实现高吞吐量同时,尽可能满足垃圾收集暂停时间的要求,像CMS收集器一样,能与应用程序线程并发执行;整理空闲空间更快,需要更多时间预测GC停顿时间,不希望牺牲大量吞吐性能,不需要更大Java Heap
G1收集器的设计目的就是取代CMS收集器,相较于CMS,G1是一个有整理内存过程的垃圾收集器,不会产生很多内存碎片,G1的STW更可控,在停顿时间上加入预测机制,用户可指定期望停顿时间,在JDK1.9中将G1变成默认的垃圾收集器替代CMS
特点
改变Eden、Survivor和Tenured等内存区域不再连续,变成一个个大小一样的Region。每个Region都1m~32m不等,可能属于Eden、Survivor或Tenured内存区域
G1能充分利用多CPU、多核环境的硬件优势,尽量缩短STW
G1整体采用标记整理算法,局部采用复制算法,不会产生内存碎片
宏观上G1不再区分年轻代和老年代,把内存划分多个独立子区域(Region),但其本身依然小范围内进行年轻代和老年代区分,保留新生代和老年代,不再是物理隔离,是一部分Region的集合且不需要Region连续,依然采用不同GC方式处理不同区域
G1虽然是分代收集器,但整个内存分区不存在物理上的年轻代和老年代区别,只有逻辑上的分代,每个分区随G1运行在不同代间前后切换
底层原理
Region区域化垃圾收集器,区域化内存划片,编为一系列不连续内存区域,化整为零,避免全内存区的GC操作,只需按区域进行扫描即可
将整个堆内存区域分成大小相同子区域,JVM前端时自动设置这些子区域大小,G1并不要求对象的存储一定是物理是连续的只要逻辑上连续即可,每个分区不要求固定为某个代服务,启动时可通过 -XX:G1HeapRegionSize=n指定分区大小,默认将整个堆划分2048区,大小范围1m~32m,即能够最大支持内存为:32MB * 2048 = 65536MB = 64GB内存
回收步骤
针对Eden区进行收集,Eden区耗尽后被触发,小区域收集+形成连续的内存块,避免内存碎片,Eden区的数据移动到Survivor区,假如出现Survivor区空间不够,Eden区会晋升到Old区;Survivor区的数据移动到新的Survivor区,部分数据晋升到Old区,最好Eden区收拾干净,GC结束,用户的应用程序继续运行
4步骤
- 初始标记:只标记GC Roots能直接关联到的对象
- 并发标记:进行GC Roots Tracing 的过程
- 最终标记:修正并发标记期间,因程序运行导致标记发生变化的那一部分对象
- 筛选回收:根据时间来进行价值最大化回收
常用配置参数
- -XX:+UseG1GC
- -XX:G1HeapRegionSize=n 设置G1区域的大小,值为2的幂,范围1m~32m,根据最小的Java堆大小划分出约2048个区域
- -XX:MaxGCPauseMillis=n 最大停顿时间,这是个软目标,JVM将尽可能(不保证)停顿时间小于这个时间
- -XX:InitiatingHeapOccupancyPercent=n 堆占用了多少的时候就触发GC,默认45
- -XX:ConcGCThreads=n 并发GC使用的线程数
- -XX:G1ReservePercent=n 设置作为空闲空间的预留内存百分比,以降低目标空间溢出的风险,默认值10%
和CMS相比的优势
- G1不会产生内存碎片
- 可以精确控制停顿,把整个堆(新生代、老年代)划分多个固定大小的区域,每次根据允许停顿时间收集垃圾最多的区域
结合Linux和JDK命令一块分析
案例步骤:
- 先用top命令找出CPU占比最高的
- ps -ef 或者 jps 进一步定位,得知是一个怎样的后台程序 jps -l | grep 程序
- 定位到具体线程或者代码:ps -mp 进程 -o THREAD,tid,time 参数:-m 显示所有线程;-p pid进程使用CPU时间;-o 参数后用户自定义格式
- 将需要的线程ID 转换成 16进制格式(英文小写格式):printd “%x\n” 有问题的线程ID
- jstack 进程ID | grep tid(16进制线程ID小写英文) -A60
https://docs.oracle.com/javase/8/docs/technotes/tools/ 查看java.exe、javac.exe、javap.exe、jconsole.exe
性能监控工具
- jps 虚拟机进程状况工具
- jinfo Java配置信息工具
- jmap 内存映像工具
- jstat 统计信息监控工具
在实际工作中,结合SpringBoot进行JVM的调优:JVM GC对微服务的生产部署调参优化方案
-Xms10m -Xmx10m -XX:+PrintGCDetails -XX:+UseG1GC
粗分有三种类加载器,细分有四种类加载器
Java虚拟机自带的加载器有三种:
- 启动类加载器:用C++语言编写的,加载Java出厂默认的这些类,如List、Object、String,是启动类加载器以后自动加载进来,直接使用
- 扩展类加载器:用Java语言编写的
- 应用程序类加载器:用户自定义的,java也叫系统类加载器,加载当前应用的classpath所有类
堆内存在Java8是由 新生代+老年代+元空间构成
对象的生命周期:
- 先从伊甸园假设有100个对象,第一次经过mirror GC垃圾回收后,把活着的对象存入幸存0区
- 然后第二次会对伊甸园和幸存0区进行mirror GC回收,将存活的对象放入幸存1区
- 在第三次GC垃圾回收前,幸存0区和幸存1区进行互换位置,对伊甸园和原先幸存1区(即成功与幸存0区交换位置的幸存1区)进行第三次GC垃圾回收
- 将再次存活下来的对象存入互换后的0区,若这样的GC垃圾回收15次以后,最终还存活的对象,存入养老区
堆内存调优常用参数:
- -Xms:设置初始分配大小,默认物理内存的1/64
- -Xmx:设置最大分配内存,默认物理内存的1/4
- -XX:+PrintGCDetails输出详细的GC处理日志
- 参数调优的初始大小和最大分配内存大小要一致
OutOfMemoryError异常
测试电脑可以内存
public static void main(String[] args){
long maxMemory = Runtime.getRuntime().maxMemory() ;//返回 Java 虚拟机试图使用的最大内存量。
long totalMemory = Runtime.getRuntime().totalMemory() ;//返回 Java 虚拟机中的内存总量。
System.out.println("MAX_MEMORY = " + maxMemory + "(字节)、" + (maxMemory / (double)1024 / 1024) + "MB");
System.out.println("TOTAL_MEMORY = " + totalMemory + "(字节)、" + (totalMemory / (double)1024 / 1024) + "MB");
}
引用计数法(了解即可,已经不用了)
复制算法
新生代/伊甸园用的都是复制算法
相关原理:
- Minor GC会把Eden中的所有活的对象都移到Survivor区域中,如果Survivor区中放不下,那么剩下的活的对象就被移到Old generation中,也即一旦收集后,Eden是就变成空的了。
- 当对象在 Eden出生后,在经过一次 Minor GC 后,如果对象还存活,并且能够被另外一块 Survivor 区域所容纳( 上面已经假设为 from 区域,这里应为 to 区域,即 to 区域有足够的内存空间来存储 Eden 和 from 区域中存活的对象 ),则使用复制算法将这些仍然还存活的对象复制到另外一块 Survivor 区域 ( 即 to 区域 ) 中,然后清理所使用过的 Eden 以及 Survivor 区域 ( 即 from 区域 ),并且将这些对象的年龄设置为1,以后对象在 Survivor 区每熬过一次 Minor GC,就将对象的年龄 +1,当对象的年龄达到某个值时 ( 默认是 15 岁,通过-XX:MaxTenuringThreshold 来设定参数),这些对象就会成为老年代。
- -XX:MaxTenuringThreshold 设置对象在新生代中存活的次数
具体解释:年轻代的垃圾回收算法使用的是复制算法,复制算法的基本思想就是将内存分为两块,每次只用其中一块,当这一块内存用完,就将还活着的对象复制到另外一块上面。复制算法不会产生内存碎片。
优缺点:
- 优点:不会产生内存碎片
- 缺点:会有空间的浪费,浪费一半的内存,对象存活率高的话可能需要将所有对象都复制一遍,所以要想使用复制算法最起码对象存活率要非常低,克服50%内存的浪费
标记清除算法
老年代一般由标记清除或者标记清除+标记整理混合实现
相关原理:
当程序运行期间,若可以使用的内存被耗尽的时候,GC线程就会被触发并将程序暂停,随后将要回收的对象标记一遍,最终统一回收这些对象,完成标记清理工作接下来便让应用程序恢复运行。
主要进行两项工作,第一项则是标记,第二项则是清除。 标记:从引用根节点开始标记遍历先标记出要回收的对象。清除:遍历整个堆,把标记的对象清除。
优缺点:
- 优点:不需要额外空间
- 缺点:此算法需要暂停整个应用,且清理空闲内存不连续会产生内存碎片,为应付内存碎片,JVM不得不维持一个内存空闲的列表,两次扫描耗时严重,效率比较低(递归和全堆对象遍历)
标记整理算法
老年代一般由标记清除或者标记清除+标记整理混合实现
相关原理:
在整理压缩阶段,不再对标记的对象做回收,而是通过所有存活对象向一端移动,然后直接清除边界以外的内存。
标记的存活对象将会被整理,按照内存地址依次排列,而未被标记的内存会被清理掉。需要给新对象分配内存时,JVM只需要持有一个内存的起始地址即可,这比维护一个空闲列表显然少了许多开销。
标记整理算法不仅可以弥补标记清除算法当中,内存区域分散的缺点,也消除了复制算法当中,内存减半的高额代价
优缺点:
- 优点:减少移动对象的成本
- 缺点:
- 简单说说ActiveMQ,ActiveMQ是Apache旗下产品,支持JAVA,自身是JAVA开发出的产品,对我们的入门学习提升非常有帮助,有较低概念数据丢失,但这可以控制,ActiveMQ暂时逊色于阿里的RocketMQ
- RocketMQ是阿里的产品,模仿Kafka的精华进行改造的产品,得到阿里双十一验证的较成熟的产品
- Kafka是大数据场景下用到的产品,支持十万级别的吞吐量,似乎会丢失一些数据的概率更大
- RabbitMQ是其他语言开发的,若改动源码可能JAVA程序员较麻烦,扩展性可能不是很好
- KahaDB里有四类文件和一把锁,其中一个 db-1.log 是日志文件,当这个日志文件满了以后,会新增一个新的日志文件,日志文件名称按数字编号,若不再引用该日志文件,该日志文件会被删除或归档
- db.data 是索引,用来存储日志文件记录的具体索引位置
- db.free 是看 db.data 索引文件当中是否有空闲
- lock 是读取该 KahaDB 的一把权限锁
- 若生产端发送大量消息,消费端消费的较慢,可以开启异步投递的方式,有三种方法可以开启生产端的异步投递,使其性能提高,发送消息更快
- 但同时也会导致数据丢失的风险,因为生产端生产了消息就直接丢给MQ,不管它是否收到,生产端自认为自己完成工作,但MQ宕机会导致数据丢失
- 要保证消息发送成功,需加入异步投递的同时,加入异步接收回调方法,判断是否都发送成功,若失败就需人工干预,继续重发
- 消费端使用事务,但是 session 中调用了 rollback回滚,没有真正提交数据
- 消费端使用事务,但是没有提交数据或者提前关闭了
- 消费端使用签收模式,在 session 中调用了 recover() 重试方法
- 若MQ的消息能进入到数据库,可在数据库当中设置唯一ID主键,这样就算出现重复消费情况,也会导致主键冲突避免数据库出现脏数据(不推荐)
- 使用Redis缓存,给消息分配一个全局ID,只要消费过的消息,放入Redis中,消费者开始消费前查询Redis有无消费记录即可(推荐)
为什么导致消息大量积压
生产者发送消息给消息队列MQ,消费者连接MQ服务器进行消息消费,但MQ中存入大量消息,影响性能,所以消息积压导致MQ性能下降,一定要解决
什么情况导致消息积压
- 消费者宕机,相当于MQ服务器没连接任何一个消费者,或连接消费者过少,导致生产者源源不断生产消息,却没消费者去消费,导致消息大量积压
- 消费者消费能力不足,假设原本10个消费者其中9个宕机,剩余消费者消费消息的效率太慢
- 生产者发送消息较猛,消费者在消费MQ服务器当中的消息,导致消费者跟不上生产者生产消息,导致MQ大量消息积压
解决消息积压问题
- 限制生产者的发送流量,但是要限制流量就要限制业务,只要业务不执行,就不发消息出去
- 让消费者解决消息积压问题,上线更多消费者消费消息解决MQ服务器里的消息积压
- 上线专门处理消息的消费者,由于可能数据量过大,上线更多普通正常消费者,消费消息处理起来仍需要一段时间,使用专门处理消息的消费者批量将积压的消息存入数据库,再编写一个离线处理消息的业务功能,从数据库中,慢慢取出数据进行处理
为什么会导致消息丢失
消息丢失是非常严重的现象,尤其电商的业务,一个消息丢失可能导致一连串操作错误,一般使用消息事务,即可靠消息+最终一致性方案,保证消息不丢失
什么情况导致消息丢失
消息发送出去,由于网络问题没抵达MQ服务器
- 做好容错方案,即发送消息导致网络原因发不出去,将未发送消息重试发送
- 做好日志记录,每发一个消息,做好对应日志记录,给每个业务数据库创建一张MQ表,保存每个消息的详细信息,定期扫描MQ日志表,只要发送失败,把失败的消息拿出来再发一遍
消息发送到MQ服务器,消息未写入磁盘进行持久化操作时,服务器宕机,MQ服务器再次启动,此数据未被处理丢失了
- 使用生产者发送消息的确认机制,每一个确认成功的消息,都去数据库MQ表修改状态为已收到的状态
生产者发送消息给MQ服务器,确认抵达机制到了MQ服务器,消费者此时消费消息,刚拿到消息还没消费就宕机了,宕机以后,若是自动确认(即自动ACK机制)情况,相当于消费者已上线拿到消息默认回复MQ服务器,但实际未消费成功,此时这个消息相当于走了个过场
- 一定要开启手动确认机制(手动开启ACK机制),消费真正成功才移除,失败或没来得及处理,就让消息重新加入消息队列
解决消息丢失问题(如上)
- 做好容错方案,即发送消息导致网络原因发不出去,将未发送消息重试发送
- 做好日志记录,每发一个消息,做好对应日志记录,给每个业务数据库创建一张MQ表,保存每个消息的详细信息,定期扫描MQ日志表,只要发送失败,把失败的消息拿出来再发一遍
- 一定要开启手动确认机制(手动开启ACK机制),消费真正成功才移除,失败或没来得及处理,就让消息重新加入消息队列
- 使用生产者发送消息的确认机制,每一个确认成功的消息,都去数据库MQ表修改状态为已收到的状态
总结
做好消息的确认机制,做到两端确认(即消费者和生产者双端确认),特别是消费者一定开启手动确认机制,否则一收到消息宕机却未处理导致消息直接被删除,一旦做了确认机制,每发送一个消息就在数据库中做好记录,然后定期扫描数据库MQ日志表,将所有失败的消息再发送一遍
为什么会导致消息重复
消息重复就是一个消息给消费者发了两次,相当于消费者收到了两次相同的消息
什么情况导致消息重复
- 消费者一般使用监听器收到消息,一旦监听器收到消息,调用业务逻辑进行处理,假设消息消费成功,业务逻辑也处理完但是突然宕机,则接下来的方法认为还未走完,即没有给MQ服务器回复消息消费成功,消费者就和MQ服务器断开连接,MQ服务器认为没消费成功,业务设置的是手动确认此时消息从Unack(正在处理)变成Ready(重新处理)状态,将发送给其他消费者,相当于其他消费者再处理一遍此消息,消息被处理两遍
- 消息消费失败,告诉MQ服务器拒绝消息,让这个消息重新加入队列,然后再次接收处理,这种消息重复处理是被允许的,因为第一遍失败,消息再次加入队列,重新尝试消费
解决消息重复问题
- 将业务逻辑方法设计成幂等性
- 使用防重表,每一个消息由于都是一个唯一ID,只要被处理过就去防重表进行记录
- 使用RabbliMQ的消息属性字段,看看消息是否重复派发,但这样太过暴力,如果上次发送消息失败,没有消费成功,再次派送的消息也会丢失
总结
如果消息重复了,把业务设计成幂等性即可,即使消息重复发送一万遍,最终只会执行一遍结果
从From先开始,然后join、where、group by、having、select、distinct、order by、limit等其他顺序运行
sql语句执行流程:
- 应用程序将查询SQL传送给服务器,需建立连接(连接器),有一个连接池
- 查询缓存(8.0后性能收益不高被废弃),缓存打开,服务器接收到查询请求先访问缓存查看是否有对应数据,命中直接返回
- 语法分析(分析器),生成执行计划(优化器),查询优化处理,解析SQL、预处理、优化SQL(执行器执行sql调用存储引擎)
- MySQL根据相应的执行计划完成整个查询(调用存储引擎)
- 将查询结果返回给客户端
索引(Index) 帮助Mysql高效获取数据的数据结构,提高查询速率,但是会占用额外的内存空间,查找快,排好序
适合建立索引的情况
- 主键自动建立唯一索引
- 频繁作为查询条件的字段建立索引
- 查询中与其他表关联的字段,外键关系;查询中排序的字段;查询中统计或分组的字段建立索引
- 单键/组合索引选择问题(高并发下建立组合索引)
不适合建立索引的情况
- 表记录过少
- where中不用的字段、频繁更新的字段、表不必建立索引
- 数据重复且分布平均的表字段不建议建立索引
索引失效
如何知道索引未使用:
使用Explain命令查看语句的执行计划,MySQL执行语句前会过一遍查询优化器,拿到执行计划包含信息,分析是否命中索引
- 索引列参加表达式计算
- 函数运算不走索引
- 正则表达式不走索引
- 模糊查询,单引号可走索引
- 关键词or,使用是必须全部条件都加索引
- 字符串和数字比较
- MySQL优化。觉得走全局比走索引高效的情况
减少请求的数据量:只返回必要的列,不使用select * from , ;只返回必要的行 Limit ;缓存重复查询的数据
减少服务端扫描的行数:使用索引覆盖查询
慢查询优化:
- 检查是否走了索引,优化索引
- 是否为最优索引
- 是否有多余字段、多余索引、重复数据
- 是否数据过多,分库分表
- 是否硬件上需要提升性能配置
Cache-Aside
业务代码围绕Cache编写,由业务代码维护缓存
读场景:先从缓存中获取,没有出现SoR,再放入缓存
写场景:失效模式:先将数据写入SoR,失效缓存下次读取时从缓存中加载(进入读场景);双写模式:先将数据写入SoR,执行成功后立即同步写入缓存
并发更新问题解决:多个缓存实例,同时更新自己里面的同样数据,自定义数据分片规则实现一致性hash
- 考虑使用中间件如Canal订阅binlog,进行增量更新分布式缓存,不存在缓存数据不一致问题,但有延迟,可以调整合理的过期时间容忍延迟
- 读服务,考虑一致性hash,相同的操作负载均衡到同一个实例,从而减少并发几率
Cache As SoR
- 把Cache当做SoR,所有操作都对Cache进行,然后Cache委托SoR进行数据真实读写,即业务代码只看到Cache操作,看不到SoR代码,有三种实现模式 read-through、read-through、read-through
- read-through:业务代码首先调用Cache,Cache不命中,由Cache回源到SoR。而不是业务代码。业务代码简洁 userMapper.get(1)
- write-through:称为穿透写/直写模式。业务代码调用Cache写数据,然后Cache负责写缓存和SoR,而不是业务代码 userMapper.update(1)
- write-behind(write-back):称为回写模式,不同于 write-through的同步写,这是异步写,异步成功实现批量写、合并写、延时写等
Copy-Patten(缓存数据复制方式)
- 缓存使用两种复制模式:Copy-On-Read(读时复制)、Copy-On-Write(写时复制)
- 有些进程内缓存很多是基于引用的,所以拿到缓存中的数据如果进行修改,可能发生不可预测的问题
- 读时复制:读取到的缓存的值,复制内容封装一个新的对象
- 写时复制:给缓存中写的值,复制一个新的对象写入
名词
- SoR(System-Of-Record):记录系统,或数据源,实际存储原始数据的系统
- Cache:缓存,是SoR的快照数据,Cache的访问速度比SoR快,放入Cache的目的是提升系统速度,减少回源到SoR的次数
- 回源:回到数据源头检索数据,Cache没有命中需要回到SoR读取数据
官方解释:Redisson是一个在Redis的基础上实现的Java驻内存数据网格(In-Memory Data Grid)。它不仅提供了一系列的分布式的Java常用对象,还提供了许多分布式服务。其中包括(BitSet, Set, Multimap, SortedSet, Map, List, Queue, BlockingQueue, Deque, BlockingDeque, Semaphore, Lock, AtomicLong, CountDownLatch, Publish / Subscribe, Bloom filter, Remote service, Spring cache, Executor service, Live Object service, Scheduler service) Redisson提供了使用Redis的最简单和最便捷的方法。Redisson的宗旨是促进使用者对Redis的关注分离(Separation of Concern),从而让使用者能够将精力更集中地放在处理业务逻辑上。
整合
导入依赖
<dependency>
<groupId>org.redissongroupId>
<artifactId>redissonartifactId>
<version>3.10.5version>
dependency>
配置Redisson
@Configuration
public class GmallRedissonConfig {
@Value("${spring.redis.host}")
private String host;
@Value("${spring.redis.port}")
private String port;
@Bean
public RedissonClient redissonClient(){
Config config = new Config();
config.useSingleServer().setAddress("redis://"+host+":"+port);
RedissonClient redisson = Redisson.create(config);
return redisson;
}
}
文档:https://github.com/redisson/redisson/wiki/1.-Overview
锁的基本问题
锁:锁主要用来实现资源共享的同步。只有获取到了锁才能访问该同步代码,否则等待其他线程使用结束释放锁。一句话:限制多线程资源竞争
列举:自旋锁、阻塞锁、可重入锁、读写锁、互斥锁、悲观锁、乐观锁、公平锁、偏向锁、对象锁、线程锁、锁粗化、锁消除、轻量级锁、重量级锁、信号量、独享锁、共享锁、分段锁、闭锁…
锁分类:真正用到的锁也就那么两三种,只不过依据设计方案和性质对其进行了大量的划分
常见(常考)锁
Synchronized:默认非公平,悲观,独享,互斥,可重入,重量级锁
lock:
- ReentrantLock:默认非公平但可实现公平的,悲观,独享,互斥,可重入,重量级锁
- ReentrantReadWriteLocK:默认非公平但可实现公平的,悲观,写独享,读共享,读写,可重入,重量级锁
二者区别
ReentrantLock 比 Synchronized 多了锁投票,定时锁等候和中断锁等候
ReentrantLock获取锁定的三种方式:
lock():获取锁立即返回,别的线程持有锁当前线程就一直处于休眠状态直到获取锁
tryLock():获取锁立即返回true,别的线程持有锁立即返回false
tryLock(Long timeout,TimeUnit unit):获取锁立即返回true,别的线程持有锁等待参数给定时间,期间获取锁返回true,超时返回false
lockInterruptibly:获取锁立即返回,没获取锁当前线程一直处于休眠状态直到获取锁或被别的线程打断
锁绑定多个条件:指一个 ReentrantLock 对象可以同时绑定多个 Condition 对象,而在 Synchronized 中,锁对象的wait() 和 notify() 或 notifyAll() 方法可以实现一个隐含的条件,如果要和多于一个的条件关联的时候,就不得不额外地添加一个锁,而ReentrantLock则无须这样做,只需要多次调用 newCondition() 方法即可。
AbstractQueuedSynchronizer(AQS):抽象的队列同步器,所有抢锁的线程都去queue里面
Synchronized是JVM层面实现的不但通过一些监控工具监控synchronized的锁定,而且在代码执行时出现异常,JVM会自动释放锁,而lock通过代码释放
在资源竞争不激烈情况Synchronized性能优于ReetrantLock;但在资源竞争激烈情况Synchronized性能下降几十倍,而ReetrantLock性能维持常态
按照性质分类
公平锁 / 非公平锁
- 公平锁指多个线程按申请锁的顺序获取锁,非公平锁是指可不按申请顺序获取锁,插队,可能出现饥饿现象或优先级反转现象,非公平锁在吞吐量比公平锁大
- Synchronized就是非公平锁;ReentrantLock通过构造函数指定是否为公平锁,默认非公平锁,通过AQS实现线程调度,实现公平锁
乐观锁 / 悲观锁:不是指具体的锁,而是指看待并发同步的角度
- 悲观锁认为对同一数据的并发操作一定发生修改,哪怕没有修改,采用加锁形式;乐观锁认为对同一数据的并发操作不会发生修改,更新数据采用尝试更新,不断重试更新数据,更新数据时采用CAS算法,加入版本号
- 悲观锁适合写操作多的场景,利用各种锁;乐观锁适合读操作多的场景,是无锁操作,典型例子即原子类,通过CAS自旋实现原子操作更新
共享锁 / 排他锁
- 共享锁指该锁可被多个线程锁持有,排他锁指该锁一次只能被一个线程所持有,也是通过AQS实现,通过实现不同方法实现共享或排他
- 共享锁:ReentrantReadWriteLock的读锁;排他锁:ReentrantLock、Synchronized、ReentrantReadWriteLock的写锁
互斥锁 / 读写锁:
共享锁 / 排他锁一种广义的说法,互斥锁 / 读写锁是具体实现;
互斥锁:ReentrantLock;读写锁:ReentrantReadWriteLock
可重入锁
- 可重入锁又叫递归锁,指同一个线程外层方法获取锁时,内层方法自动获取锁,可一定程度避免死锁,锁不具可重入特点的化,线程调用同步方法,含锁方法时就会产生死锁,所以所有锁多该被设计成可重入的。
- ReentrantLock、Synchronized都是可重入锁
按照设计分类
自旋锁 / 自适应自旋锁
自旋锁 :Java中自旋锁是尝试获取锁的线程不会立即阻塞,采用循环方式尝试获取锁,好处是减少线程上下文切换的消耗,缺点是循环造成CPU的消耗,非阻塞式获取锁
自适应自旋锁:JDK1.6之后引入自适应自旋锁,自旋时间不固定,由前一个在同一个锁上的自旋时间及锁的拥有者状态来决定
阻塞式获取锁:指暂停一个线程的执行以等待某个条件的发生(如某个资源准备就绪)
sleep(); //睡眠,阻塞而不释放锁 wait(); //等待,阻塞并释放锁 yield(); //礼让,暂停当前线程,主动让出自己的CPU时间 join(); //插队,当前线程等待join进来执行完再继续 suspend(); //暂停,有死锁倾向 resume(); //恢复,有死锁倾向
锁粗化 / 锁消除:设计原理都差不多,都是为了减少没必要的加锁
- 锁粗化:一系列的连续操作都对同一个对象反复加锁解锁,甚至加锁操作出现在循环体中,即使没有线程竞争,频繁的进行互斥同步操作也会导致不必要的性能下降,虚拟机探测到这样一串零碎的操作都对同一个对象加锁,将加锁同步的范围扩展(粗化)到整个操作序列外部
- 锁消除:虚拟机即时编译器在运行时,对一些代码要求同步,但被检测到不可能存在共享数据竞争的锁进行消除,主要判定依据来源于逃逸分析的数据支持,判断这一段代码,堆上的所有数据都不会逃逸出去从而被其他线程访问到,就把他们当作栈上数据,认为其为线程私有,同步加锁自然无需进行
偏向锁 / 轻量级锁 / 重量级锁:指锁的状态,针对Synchronized
- 偏向锁:指一段同步代码一直被一个线程所访问,该线程会自动获取锁,降低获取锁的代价
- 轻量级锁:当锁是偏向锁时,被另一个线程访问,偏向锁升级为轻量级锁,其他线程通过自旋形式尝试获取锁,不会阻塞,提高性能
- 重量级锁:当锁为轻量级锁时,另一个线程虽是自旋但也不会一直持续下去,一定次数后还没获取锁就进入阻塞,该锁膨胀为重量级锁,会让其他申请的线程进入阻塞,性能降低
分段锁:是一种锁的设计,不是具体的锁
ConcurrentHashMap并发的实现就是通过分段锁的形式实现高效的并发操作
ConcurrentHashMap中的分段锁称为Segment,它即类似于HashMap(JDK7与JDK8中HashMap的实现)的结构,即内部拥有一个Entry数组,数组中的每个元素又是一个链表;同时又是一个ReentrantLock(Segment继承了ReentrantLock)
当需要put元素的时候,并不是对整个hashmap进行加锁,而是先通过hashcode来知道他要放在那一个分段中,然后对这个分段进行加锁,所以当多线程put的时候,只要不是放在一个分段中,就实现了真正的并行的插入
但是,在统计size的时候,可就是获取hashmap全局信息的时候,就需要获取所有的分段锁才能统计。分段锁的设计目的是细化锁的粒度,当操作不需要更新整个数组的时候,就仅仅针对数组中的一项进行加锁操作
分布式锁——Redisson
数据库锁:表锁、行锁、间隙锁、乐观锁、悲观锁、共享锁(读锁)、排他锁(写锁)
设计思想
- 对于单进程的并发场景,可使用 synchronized关键字 和 Reentrantlock类 等
- 对于分布式场景,可使用分布式锁
创建锁:
多个JVM服务器之间,同时在zookeeper上创建相同一个临时节点,临时节点路径保证唯一,只要谁能创建节点成功,谁就能获取锁,没创建成功的节点只能注册监听器监听这个锁并等待,当释放锁时,采用事件通知给其他客户端重新获取锁的资源,这时候客户端使用事件监听,该临时节点被删除的话,重新进入获取锁的步骤
释放锁:
Zookeeper使用直接关闭临时节点session会话连接,临时节点生命周期与session会话绑定在一起,如果session会话连接关闭,临时节点也会被删除,这时候客户端使用事件监听,该临时节点被删除的话,重新进入获取锁的步骤
zookeeper临时节点的创建
zkClient端的事件监控通知
启动Linux系统下zookeeper服务器并设置好防火墙
pom.xml
<dependencies>
<dependency>
<groupId>com.101tecgroupId>
<artifactId>zkclientartifactId>
<version>0.10version>
dependency>
<dependency>
<groupId>org.apache.zookeepergroupId>
<artifactId>zookeeperartifactId>
<version>3.4.9version>
dependency>
dependencies>
log4j.xml
log4j.appender.atguigu.File=org.apache.log4j.DailyRollingFileAppender
log4j.appender.atguigu.File.file=d:\\atguigu.log
log4j.appender.atguigu.File.DatePattern=.yyyy-MM-dd
log4j.appender.atguigu.File.layout=org.apache.log4j.PatternLayout
log4j.appender.atguigu.File.layout.ConversionPattern=%d{yyyy-MM-dd HH:mm:ss,SSS} %5p (%C:%M) - %m%n
log4j.appender.atguigu.Console=org.apache.log4j.ConsoleAppender
log4j.appender.atguigu.Console.layout=org.apache.log4j.PatternLayout
log4j.appender.atguigu.Console.layout.ConversionPattern=%d{yyyy-MM-dd HH:mm:ss,SSS} %5p (%C:%M) - %m%n
log4j.rootLogger=error,atguigu.Console
log4j.logger.com.atguigu=error
模拟订单的工具类
package com.liner.distributed.lock.util;
/**
* @author: liner
* @create: 2020-05-10 22:18
**/
public class OrderNumCreateUtil {
private static int number = 0;
public String getOrdNumber(){
return "\t 生产订单号:"+(++number);
}
}
Zookeeper接口ZKLock
package com.liner.distributed.lock.zk;
/**
* @author: liner
* @create: 2020-05-10 22:45
**/
public interface ZkLock {
public void zklock();
public void zkUnlock();
}
模板模式抽象类ZkAbstractTemplateLock
模板模式:
在模板模式(Template Pattern)中,用一个抽象类公开定义执行它的方法的模板/方式;子类可按需重写方法实现,但调用将以抽象类中定义方式进行
定义一个操作中算法的框架,将一些步骤延迟到子类中,模板方法使子类可不改变算法结构即重定义该算法的特定步骤
优点:封装不变部分,扩展可变部分;提取公共代码,便于维护;行为由父类控制,子类实现
缺点:每一个不同的实现类都需要一个子类实现,导致类的个数增加,系统更加庞大
使用场景:有多个子类共有的方法,且逻辑相同;重要的、复杂方法,可考虑模板方法
package com.liner.distributed.lock.zk;
import org.I0Itec.zkclient.ZkClient;
import java.util.concurrent.CountDownLatch;
/**
* @author: liner
* @create: 2020-05-10 22:50
**/
public abstract class ZkAbstractTemplateLock implements ZkLock {
public static final String ZKSERVER = "localhost:2181";
public static final Integer TIME_OUT = 45 * 1000;
protected String path = "/zklock0510";
protected CountDownLatch countDownLatch = null;
ZkClient zkClient = new ZkClient(ZKSERVER,TIME_OUT);
/**
* 抢锁方法
*/
@Override
public void zklock() {
//先看看有没有人已经用有锁了
if (tryZkLock()){ //如果返回true就说明没有人拥有锁
System.out.println(Thread.currentThread().getName()+"\t占用锁成功");
}else { //如果抢不到锁,就等待
waitZkLock(); //等待
zklock(); //递归思想,若有人释放锁了,那么重新调用这个方法,去抢锁,
}
}
public abstract void waitZkLock();
public abstract boolean tryZkLock();
@Override
public void zkUnlock() {
if (zkClient != null){
zkClient.close();
}
System.out.println(Thread.currentThread().getName()+"\t释放锁成功");
System.out.println();
System.out.println();
}
}
实现分布式锁的类ZKDistributedLock
package com.liner.distributed.lock.zk;
import org.I0Itec.zkclient.IZkDataListener;
import java.util.concurrent.CountDownLatch;
/**
* @author: liner
* @create: 2020-05-10 23:17
**/
public class ZkDistributedLock extends ZkAbstractTemplateLock {
@Override
public void waitZkLock() {
IZkDataListener iZkDataListener = new IZkDataListener() {
/**
* 监听的zk临时节点看看有没有改变
* @param dataPath
* @param data
* @throws Exception
*/
@Override
public void handleDataChange(String dataPath, Object data) throws Exception {
}
/**
* 看看监听的zk临时节点有没有被删除
* @param datapath
* @throws Exception
*/
@Override
public void handleDataDeleted(String datapath) throws Exception {
if (countDownLatch != null){
countDownLatch.countDown();
}
}
};
//zk的监听方法,监听zk的这个方法handleDataDeleted()有没有执行删除临时节点的方法,如果有就去抢锁
zkClient.subscribeDataChanges(path,iZkDataListener); //如果没有抢到锁,就监听这个zk的这个临时节点的路径
if (zkClient.exists(path)){//如果有这个节点了,就说明这个节点已被抢了
//那么只能等待,不能往下执行,除非被占用的资源删掉了
countDownLatch = new CountDownLatch(1);//临时节点只有1个
try {
countDownLatch.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
zkClient.unsubscribeDataChanges(path,iZkDataListener);//解除对zk相应临时节点的监听
}
}
@Override
public boolean tryZkLock() {
try{
zkClient.createEphemeral(path); //在某个路径下面创建临时节点
return true; //创建成功返回true
}catch (Exception e) {
return false; //创建失败返回false
}
}
}
业务实现类OrderZkService
package com.liner.distributed.lock.common;
import com.liner.distributed.lock.util.OrderNumCreateUtil;
import com.liner.distributed.lock.zk.ZkDistributedLock;
import com.liner.distributed.lock.zk.ZkLock;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
/**
* @author: liner
* @create: 2020-05-10 22:25
*
*
**/
public class OrderService {
private OrderNumCreateUtil orderNumCreateUtil = new OrderNumCreateUtil();
private ZkLock zkLock = new ZkDistributedLock(); //手写的zk版的分布式锁
public void getOrdNumber(){
zkLock.zklock();
try{
System.out.println("获得编号:----->:"+ orderNumCreateUtil.getOrdNumber()); //线程值
}catch (Exception e){
e.printStackTrace();
}finally {
zkLock.zkUnlock();
}
}
}
官方解释:Nacos 致力于帮助您发现、配置和管理微服务。提供了一组简单易用的特性集,帮助您快速实现动态服务发现、服务配置、服务元数据及流量管理。Nacos 帮助您更敏捷和容易地构建、交付和管理微服务平台。 Nacos 是构建以“服务”为中心的现代应用架构 (例如微服务范式、云原生范式) 的服务基础设施。
文档:https://nacos.io/zh-cn/docs/what-is-nacos.html
安装:https://nacos.io/zh-cn/docs/quick-start.html
核心概念
- namespace(命名空间):基于此做多环境及多租户数据(配置和服务)隔离的,用于进行租户粒度配置隔离,不同命名空间存在相同Group或DataID配置,常用在不同环境的配置区分隔离,如开发测试环境和生产环境的资源隔离
- 配置:系统开发中,开发者常将一个要变更的参数、变量等从代码中分离出来独立管理,目的让静态的系统工件或交付物更好与实际物理运行环境进行适配,配置变更是调整系统运行时的有效手段
- 配置管理:系统配置的编辑、存储、分发、变更管理、历史版本管理、变更审计等配置相关活动
- 配置项:一个具体的可配置的参数与其值域,通常以param-key=param-value形式存在
- 配置集:一组相关或不相关配置项的集合,一个配置文件就是一个配置集,包含系统各方面配置
- 配置集 ID(data-id):Nacos中某个配置集的ID,配置集ID是组织划分配置的维度之一,DataID通常用于组织划分系统的配置集
- 配置分组:Nacos中的一个配置集,是组织划分配置的维度之一,通过有意义的字符串对配置集进行分组区分DataID相同的配置集
- endpoint:当nacos.server集群需要扩缩容时,客户端需要有一种能力能及时感知到集群的变化,即客户端会定时的向endpoint发送请求来更新客户端内存中的集群列表
nacos-discovery服务注册、发现
- 创建provider应用(cloud选择ribbon)
- 引入nacos-discovery
- 修改application.properties
- 启动服务注册发现功能:@EnableDiscoveryClient
- 在nacos控制台查看注册的服务
- 创建consumer应用(cloud选择ribbon)
- 引入nacos-discovery依赖
- 修改application.properties指定nacos地址
- 启动服务注册发现功能:@EnableDiscoveryClient
- 利用ribbon测试远程调用
nacos-config配置管理(配置的动态变更)
引入nacos-config
修改bootstrap.properties文件,指定nacos配置
了解默认规则
spring.application.name很重要,构成Nacos配置管理Data ID 字段的一部分
Data ID的完整格式: p r e f i x − {prefix}- prefix−{spring.profile.active}.${file-extension}
prefix:默认为 spring.application.name 的值,也可以通过配置项 spring.cloud.nacos.config.prefix来配置
spring.profile.active:当前环境对应的 profile。当 spring.profile.active 为空时,对应的连接符 - 也将不存在,dataId 的拼接格式变成 p r e f i x . {prefix}. prefix.{file-extension}
file-exetension:为配置内容的数据格式,可以通过配置项 spring.cloud.nacos.config.file-extension 来配置。目前只支持 properties 和 yaml 类型
通过Spring Cloud原生注解:@RefreshScope实现配置自动更新
修改配置文件,查看是否实时变更
高级:nacos,指定namespace&多Data ID 加载
当一个配置在本地文件和nacos中都有时,优先使用nacos,如果nacos中找不到则用本地配置的
简介:提供一种简单而有效的方式来对API进行路由,并为他们提供切面,例如:安全性,监控/指标 和弹性等
为什么使用API 网关?
API 网关出现的原因是微服务架构的出现,不同的微服务一般会有不同的网络地址,而外部客户端可能需要调用多个服务的接口才能完成一个业务需求,如果让客户端直接与各个微服务通信,会有以下的问题:
客户端会多次请求不同的微服务,增加了客户端的复杂性。 存在跨域请求,在一定场景下处理相对复杂。 认证复杂,每个服务都需要独立认证。 难以重构,随着项目的迭代,可能需要重新划分微服务。例如,可能将多个服务合并成一个或者将一个服务拆分成多个。如果客户端直接与微服务通信,那么重构将会很难实施。 某些微服务可能使用了防火墙 / 浏览器不友好的协议,直接访问会有一定的困难。
以上这些问题可以借助 API 网关解决。API 网关是介于客户端和服务器端之间的中间层,所有的外部请求都会先经过 API 网关这一层。也就是说,API 的实现方面更多的考虑业务逻辑,而安全、性能、监控可以交由 API 网关来做,这样既提高业务灵活性又不缺安全性:
使用 API 网关后的优点如下:
易于监控。可以在网关收集监控数据并将其推送到外部系统进行分析。
易于认证。可以在网关上进行认证,然后再将请求转发到后端的微服务,而无须在每个微服务中进行认证。
减少了客户端与各个微服务之间的交互次数。API 网关选型:gateway性能高于zuul,在高吞吐量,高并发情况下表现很好。
文档:https://cloud.spring.io/spring-cloud-static/spring-cloud-gateway/2.1.0.RELEASE/single/spring-cloud-gateway.html#_cors_configuration
概念
- Route路由:Gateway的基本构建模块,由ID、目标URI、断言集合和过滤器集合组成,如果聚合断言结果为真,则匹配到该路由
- Predicate断言:是Java8 Function Predicate,输入类型是Spring Framework ServerWebExchange,允许开发人员匹配来自HTTP请求的任何要求,如Header或参数
- Filter过滤器:是使用特定工厂构建的 Spring Framework GatewayFilter实例,所以可以返回请求前或后修改请求和响应的内容
核心
- Predicate断言:RoutePredicateFactory
- Filter过滤器:GatewayFilterFactory、GlobalFilter
应用
网关项目的pom
整合各个项目的统一swagger接口
路由各个请求到相应的服务配置
整合Hystrix进行容错
- 导入Hystrix依赖,并开启断路保护 @EnableHystrix 或者 @EnableCircuitBreaker
- Gateway配置各个微服务服务容错
- Gateway配置全局容错
Transaction Coordinator(TC):事务协调器、Transaction Manager(TM):事务管理器、Resource Manager(RM):资源管理器
是服务的降级,服务的熔断,服务的限流
本地事务是在单个数据源上进行数据的访问和更新等操作
特性(ACID)
- 原子性(Atomicity):一系列的操作整体不可拆分,要么同时成功,要么同时失败
- 一致性(Consistency):数据在事务的前后,业务整体一致
- 隔离性(Isolation):事务之间是互相隔离的
- 持久性(Durabilily):一旦事务成功,数据一定会落盘在数据库中
事务的传播行为:当事务里还有其他事务方法时,其他事务怎么运行
- REQUIRED【方法无论如何都在事务内运行】:需要一个事务,外层存在事务,就用已存在事务,否则创建一个事务
- REQUIRES_NEW【总是创建新的事务】:无论外层有没有事务,都创建一个新事务,在自己的事务中运行
- SUPPORTS【支持事务】:外层有事务,就在该事务中运行,否则就不以事务的方式运行
- MANDATORY【强制运行在已存在的事务内】:必须在事务内运行,如果外层有事务就在此运行,否则抛出异常
- NOT_SUPPORTED【不支持运行在事务中】:必须以非事务方式运行,外层已有事务,就把外层事务暂停
- NEVER【必须非事务方式运行】:外层有事务则抛出异常,否则正常运行
- NESTED【嵌入式事务】:基于存档点的事务
事务隔离级别:因为数据库又读又写,为保证同时对一个数据进行读写该怎么处理
- 读未提交【Read Uncommitted】:可以读到没提交的数据
- 读已提交【Read Committed】:只能读到提交了的数据,Oracle数据库默认隔离级别
- 可重复读【Repeatable Read】:同一事务内,可重复多次读取数据,每次读到的都一样,MySQL数据库默认隔离级别
- 串行化【Serializable】:已经不用
以上不同隔离级别可能出现
- 脏读:读到未生效的数据(读未提交),不允许发生
- 幻读:同一事务内读到的不一样(读未提交、读已提交)
- 可重复读:可重复多次读取数据,每次读到的都一样,不会有幻读问题
- 不可重复读:不能多次重复读取数据,每次读到的不一样,会有幻读问题
事务回滚策略
rollbackFor:指定异常必须回滚
noRollbackFor:发生指定的异常不用回滚
异常:
- 运行时异常(不受检查异常,不强制要求try-catch):都会回滚,如:Math… 、OutOfMemory、NullPointException、ArrayOutOfIndex
- 编译时异常(受检查异常,必须进行处理,try-catch或throws):都不回滚,如:FileNotFoundException
Spring Boot 本地事务之大坑
Spring Boot 在做事务时候在某些情况下不起作用,特别是使用 @Transactional(propagation=Propagation.REQUIRES_NEW)的隔离级别时,起不到事务的作用
因为@Transactional 的底层时AOP,事务想生效必须使用代理对象来调用才行,如果业务代码中有使用事务的隔离级别,尽量不要使用this来调用,防止事务失效,因为this并不是代理对象,相当于代码粘在了大方法里,this方法和外层用的一个事务,所以方法的事务失效了
可以开启AOP进行代理,导入aop-starter,暴露代理对象来解决
- 开启自动代理:@EnableAspectJAutoProxy
- 暴露代理对象:@EnableAspectJAutoProxy(exposeProxy=true)
- 获取代理对象:AopContext.getCurrent()
分布式事务指事务的参与者、支持事务的服务器、资源服务器以及事务管理器分别位于不同的分布式系统的不同节点之上。分布式事务就是为了保证不同数据库的数据一致性
经常出现的异常:机器宕机,网络异常,消息丢失,消息乱序,数据错误,不可靠的TCP,存储数据丢失等问题
BASE【柔软的事务:最终一致,基本可用,软状态】
基于XA协议的两阶段提交
数据库支持的2PC【2 phase commit】,又叫做 XA Transactions。
其中,XA 是一个两阶段提交协议,该协议分为以下两个阶段:
第一阶段:事务协调器要求每个涉及到事务的数据库预提交(precommit)此操作,并反映是否可以提交
第二阶段:事务协调器要求每个数据库提交数据
其中,如果有任何一个数据库否决此次提交,那么所有数据库都会被要求回滚它们在此事务中的那部分信息
可用率 a%*b% = 99%*99%=98%总的来说,XA协议比较简单,而且一旦商业数据库实现了XA协议,使用分布式事务的成本也比较低。但是,XA也有致命的缺点,那就是性能不理想,特别是在交易下单链路,往往并发量很高,XA无法满足高并发场景。XA目前在商业数据库支持的比较理想,在mysql数据库中支持的不太理想,mysql的XA实现,没有记录prepare阶段日志,主备切换回导致主库与备库数据不一致。许多nosql也没有支持XA,这让XA的应用场景变得非常狭隘
TCC编程模式
所谓的TCC编程模式,也是两阶段提交的一个变种。TCC提供了一个编程框架,将整个业务逻辑分为三块:Try、Confirm和Cancel三个操作。以在线下单为例,Try阶段会去扣库存,Confirm阶段则是去更新订单状态,如果更新订单失败,则进入Cancel阶段,会去恢复库存。总之,TCC就是通过代码人为实现了两阶段提交,不同的业务场景所写的代码都不一样,复杂度也不一样,因此,这种模式并不能很好地被复用
消息事务+最终一致性
基于消息中间件的两阶段提交往往用在高并发场景下,将一个分布式事务拆成一个消息事务(A系统的本地操作+发消息)+B系统的本地操作,其中B系统的操作由消息驱动,只要消息事务成功,那么A操作一定成功,消息也一定发出来了,这时候B会收到消息去执行本地操作,如果本地操作失败,消息会重投,直到B操作成功,这样就变相地实现了A与B的分布式事务
虽然上面的方案能够完成A和B的操作,但是A和B并不是严格一致的,而是最终一致的,我们在这里牺牲了一致性,换来了性能的大幅度提升。当然,这种玩法也是有风险的,如果B一直执行不成功,那么一致性会被破坏,具体要不要玩,还是得看业务能够承担多少风险
分布式事务解决方案 Seata
核心概念:分布式事务是由一批分支事务组成的全局事务,通常分支事务只是本地事务
- Distributed Transaction:分布式事务
- Global Transaction:全局事务
- Branch Transaction:分支事务
- Local Transaction:本地事务
- Transaction Coordinator(TC):事务协调器
- Transaction Manager(TM):事务管理器
- Resource Manager(RM):资源管理器
如何使用Seata
- 每一个微服务的数据库,都建立一个 seata 的 undo_log日志表,来记录各个事务的记录
- 下载并启动 Seata 服务器:https://github.com/seata/seata/
- 调整自己的微服务
整合到业务
每个微服务的数据库都必须有undo_log表
导包:seata的starter;seata-all
写配置:原来的DaTaSource 要用seata、file.conf、registry.conf、每个微服务原来自己的数据源都必须使用 DataSourceProxy 进行代理
file.conf:定义seata客户端核心工作规则信息、事务日志、当前微服务在seata服务器中注册信息配置、客户端相关工作机制
registry.conf:定义seata知道微服务在其他注册中心的配置、知道注册中心信息、seata客户端的配置(也可放入配置中心中)
使用注册中心进行服务发现,seata服务器也得配置放在注册中心,去seata服务器配置 registry.conf
相关文档
- SpringCloud+Seata+Feign案例: https://github.com/seata/seata-samples/tree/master/springcloud-jpa-seata
- Seata官方文档: https://github.com/seata/seata
- Seata-wiki:https://github.com/seata/seata/wiki/Home_Chinese
当SpringBoot应用启动的时候,就从主方法里进行启动
它主要加载了**@SpringBootApplication注解主配置类,其最主要功能就是SpringBoot开启了一个@EnableAutoConfiguration注解的自动配置功能**
@EnableAutoConfiguration作用
它主要利用了一个EnableAutoConfigurationImportSelector选择器给Spring容器中导入一些组件
导入了哪些组件
- 查看 EnableAutoConfigurationImportSelector 类的父类 selectImports
- 父类规定了一个叫 selectImports 的方法,查看方法中的代码可知导入了哪些组件
- 方法中有个 configurations,且这个 configurations 最终会被回收,这个configurations 就是获取候选的配置
- configurations 方法的作用就是利用SpringFactoriesLoader.loadFactoryNames 从类路径下得到一个资源
得到哪些资源
- 其扫描的是java.jar包类路径下的 “META-INF/spring.factories”文件
- 扫描到这些文件的作用是把这个文件的 urls 拿到之后把这些 urls每一个遍历,最终把这些文件整成一个 properties对象
- 然后从properties对象里获取一些值,把这些获取到的值来加载最终要返回的结果,这个结果就是要交给Spring容器中的所有组件,相当于这个factoryClassName就是传来的Class的类名
- 而传过来的Class就是调用getSpringFactoriesLoaderFactoryClass() 方法得到从 properties中获取的 EnableAutoConfiguration.class 类名对应值
- 然后将其添加到容器中
来到第二个Spring.jar包的META-INF下的spring.factories这个文件找到配置所有EnableAutoConfiguration的值加入到Spring容器中
- 所以容器最终会添加很多类,每一个 xxxAutoConfiguration类都是容器中的一个组件,并加入到容器中
- 加入容器中的作用就是用它们来做自动配置,这就是SpringBoot自动配置之源,也是自动配置的开始
- 只要这些自动配置类进入到容器中以后,接下来自动配置类才开始运行启动
每一个自动配置类进行自动配置功能
以一个自动配置类HttpEncodingAutoConfiguration(HTTP编码自动配置)为例解释SpringBoot自动装配原理
- HttpEncodingAutoConfiguration类上标注了一堆注解,点进去HttpEncodingProperties类,发现类上标注@ConfigurationProperties注解
- 即配置文件中配置了什么就参照某一功能对应的这个属性类去配,这里配置了 spring.http.encoding 这个属性,此属性里配置了什么值,就对应 HttpEncodingProperties类来配置,所有配置文件中能配置的属性都是在 xxx.Properties类中封装着
HttpEncodingProperties类就是根据当前不同的条件判断,决定这个配置类是否生效
- 如果一旦生效,所有的配置类都成功了,就给容器中添加各种组件,这些组件的属性是从对应的properties类中获取的,而这properties类里面的每一个属性又和配置文件绑定
- 深入看一下 properties,看到properties是 HttpEncodingProperties这个对象的值是获取配置文件的值,在配置 fiter 到底用什么编码时是从 properties获取的
- 且值得注意的是,这个 HttpEncodingAutoConfiguration 只有一个有参构造器,此情况下参数的值就会从容器中拿
容器中怎么拿到的
- 相当于是前面的 @EnableConfigurationProperties(HttpEncodingProperties.class)注解,其作用就是将 HttpEncodingProperties.class 和配置文件进行绑定起来并把 HttpEncodingProperties加入到容器中
- 这个自动配置类,通过有参构造器将属性拿到,而这个属性已经和SpringBoot映射了,接下来要用什么编码,就是拿HttpEncodingProperties类里的属性,所以SpringBoot能配置什么,要设置编码,是获取 properties里的 getCharset 里面的 name值
- 以此类推,配置一个Spring配置,就可以照着 HttpEncodingProperties 里面来配置,如在 application.properties 配置一个 http.encoding.enable属性
- 我们能够配置哪些属性都来源于这个功能的properties类,有了这个自动配置类,自动配置类就给容器中添加这个 filter,然后filter就会起作用了
使用SpringBoot需把握以下几点
- SpringBoot启动会加载大量的自动配置类,我们需要的功能看看SpringBoot有没有帮我们写好自动配置类
- 如果有就再看这个自动配置类中到底配置了哪些组件,SpringBoot自动配置类里面只要有我们所需组件就不需要再配置,否则需要自己写一个配置类来把相应组件配置起来
- 给容器中自动配置类添加组件时,从 properties 类中获取某些属性,而有了这些属性就可在配置文件中指定这些属性的值
如何解决多台服务器在不同机器上运行session不同步问题
可以使用session复制解决session不同步问题
优点:Tomcat原生支持,只需修改一下配置文件即可
缺点:session复制需要数据的传输,可能存在延迟问题,且占用大量网络带宽,降级了服务器集群的业务处理能力,在较大的分布式集群下,每个Tomcat可能都会全量保存相应的session数据,此方案不可取
让客户端进行存储session
优点:服务器不需存储session,浏览器自己保存session到cookie中,节省服务器端的资源
缺点:每次HTTP请求,携带用户在cookie中的完整信息,浪费网络带宽,全部session数据都存放在cookie中,而cookie只能限制保存4K,并且session放入cookie中存在安全隐患,此方案不可取
使用ip的hash一致性,主要来源于同一IP访问的,使其永远访问一台服务器
优点:只需要改nginx的相应配置,不需要修改应用代码,也可支持web=server水平扩展,但session同步不行,受内存的限制
缺点:session其实还存在web-server中,web-server重启导致session大量丢失,如果web-server水平扩展导致hash后session重新分布,也导致一部分用户路由找不到正确的session,不过问题不是很大,因为session本来都是有效期的
统一存储session
优点:全部session都存到数据库或缓存中,没有安全隐患,还可以水平扩展,web-server重启都不会丢失数据
缺点:又增加一次网络的调用。并且可能还需修改后端代码,不过可以使用SpringSession进行解决
如何解决不同域名情况下,session不共享问题
放大session的作用域,不能只是属于某个服务,将其放大使其能够在不同子域都能获得这个session
使用SpringSession和Redis来解决Session的共享问题
SpringSession官网:https://docs.spring.io/spring-session/docs/2.2.1.RELEASE/reference/html5/#httpsession-redis-jc
所有登录后的状态信息存到session里,任何一个服务都整合了SpringSession,将Session统一存储到Redis中
让session第一次存储数据的时候,给浏览器进行发卡,标识了session的 id 是什么,将这个过程放大到整个作用域,让某一服务发的卡全系统服务通用,即跨度整个父子域,不论是父域系统下的所有域名都可全部使用这个session 的 id,这就是整合 SpringSession达到的效果
在更大的系统中会出现的问题:
- 多个不同网站系统的session共享同步问题,即一个用户登录一个网站,其他系统的网站是否需重新登录或注册
- 在多系统中,希望达到一处系统登录多处系统使用,可抽取一个登录注册的认证中心,专门处理不同系统的登录及注册请求,一旦用户在某网站登录成功,其他系统都可使用,但是SpringSession实现不了这种功能,因为即使放大发卡的作用域也只能在该系统中实现,无法跨系统,所以不能简单的通过SpringSession解决多系统的单点登录问题
一个核心服务器。即中央认证服务器,将所有登录请求发给中央认证服务器
其他客户端,想登录都会先去中央认证服务器进行登录,登录成功跳转到客户端系统,客户端系统只要一次登录,其他就会自动登录成功,在全局任何系统,都统一了一个cookie唯一标识,标识哪个用户登录成功,这样所有系统域名都可不相同
单点登录流程
浏览器第一次访问到客户端中受保护的请求资源后
客户端判断浏览器是否登录过
- 常规流程即判断session里有没有此用户的会话信息,有就说明登录了,没有就去中央认证服务器登录
- 客户端去中央认证服务器登录成功后跳转回来,即从哪跳过来的再跳回去
- 为保障可以成功跳回原来的界面可在客户端跳转登录页控制器请求加一个重定向到原来的地址,这样登录成功后,重新跳回原界面把请求地址后的参数地址拿来就行,此时登录服务器就能感知到客户端发来重定向地址,将跳回原界面。
登录认证服务器开始处理登录功能
首先登录认证服务器展示登录页面,输入相应账号密码登录成功跳转回原界面
为保障成功跳回原界面在客户端跳转登录页控制器的请求中加一个重定向到原界面的URL地址
为防止重定向回原界面的URL地址丢失,将URL放在请求域中,添加一个隐藏input输入框,登录校验成功后取出请求域地址
如登录成功仍无法跳回指定URL地址可能是因为跳转后又继续判断是否登录继而重新跳回登录认证服务器,解决此问题需让客户端感知到是已经登录成功后的跳转而不是直接访问,业务代码如下
- 用redis或session存储已登录用户信息,用session存储即发一个卡,用redis存储即每个用户生成唯一的key进行存储,可使用UUID
- 为让其他客户端知道此用户已登录,在登陆成功后的重定向URL地址后再加上用户的URL参数(类似token令牌),证明用户已登录
- 在客户端只要你登录成功,返回了重定向的原地址后又带着token令牌即证明已登录,但token在第一次访问时不存在
- 有了token。登录成功的用户信息放到session里,在下次访问客户端就可直接棉登录,但最大问题是访问不同客户端域名仍需登录
一个客户端登录成功,怎么让其他客户端不用登录,直接访问受保护资源
- 某个客户端在登录认证服务器认证过,其他客户端互相授信应该直接访问受保护资源,但访问第二个客户端默认重新登录,其主要原因是登录认证服务器没记住哪些用户已经登录,登录认证服务器需保存用户登录信息
- 其他客户端访问登录认证服务器携带登录cookie信息,所以登录认证服务器处理登录请求和用户状态信息的token返回出去外,还要给当前系统留一个cookie标识来表示曾经登录过
- 这样其他客户端浏览器访问资源判断是否登录,携带cookie去登录认证服务器,比对通过跳回指定页面。此时达到一个客户端登录成功,其他客户端不用登录直接访问受保护资源
将购物车数据存储在redis中
好处:购物车是一个读写都高并发的操作,使用MySQL数据库承担压力非常大,所以选用NoSQL存储购物出数据,如MongoDB,但性能提升不大,所以选用NoSQL的Redis存储购物车数据,是因为Redis的数据结构好组织,且拥有极高的读写并发性
存入redis出现的问题:登录以后的购物车需要持久化保存但Redis默认内存数据库,与MySQL不同,一旦Redis宕机数据就丢失了,需要安装Redis时指定Redis的持久化策略,持久化到磁盘里,虽会损失一定吞吐量但速度仍比MySQL快很多,如果用户未登录,购物车即为临时购物车,也存入Redis中,保证下次打开浏览器以前保存的临时购物车数据还在,也可存入cookie中,但这样浏览器存储但后台不存,需统计用户购买商品、热度商品时没法做到,总之最终无论用户是否登录,购物车中数据都统一存入redi中
购物车存在redis的数据用什么类型存储
一个用户存在两个购物车,一个是没登录的临时购物车,一个是登录后的购物车。其中都有许多购物项,每个购物项有商品ID,包括每个购物项是否选中的状态也要存储,且保存商品的标题信息和商品默认图片等信息也要存储
如何选择redis的数据类型进行存储
购物车里应该是一个数组,数组里都是一个个的购物项,也就是一个个的对象;但redis里保存的数据都是以键值对的结构进行存储
使用 redis 的 list类型 来存储key应该是存用户的标识,代表哪个用户的购物车
- 如果来修改某个购物车里的购物项,相当于redis里面存储的购物项也要修改,就要去redis里在list类型下找到页面被修改购物项数据,因为是用list存储购物项,实际中添加多个购物项修改相关信息怎么确定哪个商品
- 需要先知道选的是哪个购物项的修改的商品信息,其前提是页面和redis中存储的数据顺序一致,但实际非常麻烦所以把购物车里的list类型存储购物项改为hash进行存储
使用 redis 的 hash类型 存储购物车当中的购物项
- hash的key仍代表某个用户的购物车,方便定位具体用户的购物车,而value值是两个值,第一个值存储商品ID,第二个值存储商品具体信息
- 那么hash结构最终存储结构为:1号用户的购物车里面,存了每一个购物项,数据值是某个商品的ID,它的信息是具体的商品信息
- 在修改购物项直接在redis中找到相应用户的购物车,而不用挨个遍历,只需类似map的方式,按照商品id修改具体商品信息
最终格式:Map
> 这样的Hash方式存储真正的购物车信息有两个key,最前面的key是String类型,存储每一个用户的购物车标识,而Map存储整个购物车,购物车中存储每一个购物项,为方便寻找购物项,第二个Map的key也是String类型,存储每一个购物项的商品ID,而Map的CartItem对象类型存储购物车商品项的详细信息