Java基础篇
接口和抽象类的区别
相似点:
- 接口和抽象类都不能被实例化
- 实现接口或继承抽象类的普通子类都必须实现这些抽象方法
不同点:
- 抽象类可以包含普通方法和代码块,接口里只能包含抽象方法,静态方法和默认方法,
- 抽象类可以有构造方法,而接口没有
- 抽象类中的成员变量可以是各种类型的,接口的成员变量只能是 public static final 类型的,并且必须赋值
重载和重写的区别
重载发生在同一个类中,方法名相同、参数列表、返回类型、权限修饰符可以不同
重写发生在子类中,方法名、参数列表、返回类型都相同,权限修饰符要大于父类方法,声明异常范围要小于父类方法,但是final和private修饰的方法不可重写
==和equals的区别
==
比较基本类型,比较的是值,==
比较引用类型,比较的是内存地址
equlas是Object类的方法,本质上与==
一样,但是有些类重写了equals方法,比如String的equals被重写后,比较的是字符值,另外重写了equlas后,也必须重写hashcode()方法
serializable接口
Serializable接口的作用就是提供一个标识符,用于在跨平台和网络间传输对象,使得对象能够被序列化和反序列化,并实现对象的持久化存储。(标记接口,来标记类是否可以被序列化,实现了serializable接口的类里面的对象才可以被序列化)
异常处理机制
- 使用try、catch、finaly捕获异常,finaly中的代码一定会执行,捕获异常后程序会继续执行
- 使用throws声明该方法可能会抛出的异常类型,出现异常后,程序终止
finaly代码块不执行的场景
- 在try块中调用了System.exit()方法,此时整个Java虚拟机都将被关闭,finally块中的代码不会被执行。
- 在try块中发生了死循环或者无限递归的情况,此时程序会一直执行try块中的代码,无法跳出,finally块中的代码也不会被执行。
- 在执行try块前,线程被中断或者程序被强制停止,则try块中的代码不会执行,finally块中的代码也不会被执行。
关键字
关键字 | 意思 | 备注 |
---|---|---|
static | 静态的 | 属性和方法都可以用static修饰,直接使用类名.属性和方法名 。只有内部类可以使用static关键字修饰,调用直接使用类名.内部类类名 进行调用。 static可以独立存在。静态块 |
final | 最终的不可改变的 | 方法和类都可以用final来修饰 final修饰的类是不能被继承的 final修饰的方法不能被子类重写。 final修饰的属性就是常量。 |
super | 调用父类的方法 | 常见public void paint(Graphics g){ super.paint(g); } |
this | 当前类的父类对象 | 调用当前类中的方法(表示调用这个方法的对象) this.addActionListener(al);等等 |
native | 本地 | |
strictfp | 严格,精准 | |
synchronized | 线程,同步 | |
transient | 短暂 | |
volatile | 易失 |
对象中一个属性 不想被序列化 可以使用什么关键字
可以使用 static 关键字来控制属性的序列化。被 static 修饰的属性不会被序列化,因为 static 属性属于类级别,不属于对象级别。因此,即使对象被序列化为字节流,static 属性也不会被包含在内。
对象
对象的组成
对象的组成包含三部分:对象头、实例数据、对齐填充。
对象头
Java的对象头由以下三部分组成:MarkWord、指向类的指针、数组长度(只有数组对象才有)
- MarkWord:MarkWord记录了对象锁相关的信息。
- 指向类的指针:Java对象的类数据保存在方法区。
- 数组长度:只有数组对象保存了这部分数据。
实例数据
对象的实例数据就是在Java代码中能看到的属性
和他们的属性值
。
对齐填充
因为JVM要求Java的对象占的内存大小应该是8bit的倍数,所以后面有几个字节用于把对象的大小补齐至8bit的倍数,没有特别的功能。
Java创建对象的五种方式
new
关键字Class
类的newInstance
方法Constructor
类的newInstance
方法Object
类的Clone
方法(浅拷贝)- 反序列化
对象的创建过程
这里以new关键字方式创建对象为例。
对象创建过程分为以下几步:
-
检查类是否已经被加载;
new关键字创建对象时,首先会去运行时常量池中查找该引用所指向的类有没有被虚拟机加载,如果没有被加载,那么会进行类的加载过程。类的加载过程需要经历:加载、链接、初始化三个阶段。
-
为对象分配内存空间;
此时,对象所属类已经加载,现在需要在堆内存中为该对象分配一定的空间,该空间的大小在类加载完成时就已经确定下来了。
为对象分配内存空间有三种方式:
- 第一种是指针碰撞法(适用于堆内存划分的很规整),jvm将堆内存分为为两块区域,一块是已经被其他对象占用的区域,另一块是空白区域,中间通过一个指针进行标注,这时只需要将指针向空白区域移动相应大小空间,就完成了内存的分配。但是这种方式也存在一个问题,那就是多线程创建对象时,会导致指针划分不一致的问题,例如A线程刚刚将指针移动到新位置,但是B线程之前读取到的是指针之前的位置,这样划分内存时就出现不一致的问题,解决这种问题,虚拟机采用了循环CAS操作来保证内存的正确划分。
- 第二种是空闲列表法(适用于堆内存不规整(虚拟机维护一个可以记录内存块是否可以用的列表来了解内存分配情况)),即在开辟内存空间时候,虚拟机通过维护的记录内存块的列表找到一块足够大的内存块分配给该对象即可,同时更新记录列表。
- 第三种也是为了解决第一种分配方式的不足而创建的方式,多线程分配内存时,虚拟机为每个线程分配了不同的空间,这样每个线程在分配内存时只是在自己的空间中操作,从而避免了上述问题,不需要同步。当然,当线程自己的空间用完了才需要需申请空间,这时候需要进行同步锁定。为每个线程分配的空间称为“本地线程分配缓冲(TLAB)”,是否启用TLAB需要通过 -XX:+/-UseTLAB参数来设定。
-
为对象的字段赋默认值;
分配完内存后,需要对对象的字段进行零值初始化(赋默认值),对象头除外。
零值初始化意思就是对对象的字段赋0值,或者null值,这也就解释了为什么这些字段在不需要进程初始化时候就能直接使用。
-
设置对象头;
对这个将要创建出来的对象,进行信息标记,包括是否为新生代/老年代,对象的哈希码,元数据信息,这些标记存放在对象头信息中。
-
执行初始化方法init
init方法包含成员变量、构造代码块的初始化,按照声明的顺序执行。
-
执行构造方法。
执行对象的构造方法。至此,对象创建成功。
上述为无父类的对象创建过程。对于有父类的对象创建过程,还需满足如下条件:
- 先加载父类;再加载本类;
- 先执行父类的实例的初始化方法init(成员变量、构造代码块),父类的构造方法;执行本类的实例的初始化方法init(成员变量、构造代码块),本类的构造方法。
对象的序列化和反序列化
前提:
- 实现
Serializable
接口。 - 所有的属性必须是可序列化的。如果有某个属性不需要序列化,则可以使用
transient
关键字修饰该属性。 - 如果父类实现了
Serializable
接口,则子类也隐式地实现了该接口。
序列化:将java
对象以一连串的字节保存在磁盘文件中的过程,也可以说是保存java
对象状态的过程。序列化可以将数据永久保存在磁盘上(通常保存在文件中)。
使用Java
的ObjectOutputStream
类。这个类提供了一个writeObject()
方法,可以将Java
对象写入到输出流中。
反序列化:将保存在磁盘文件中的java
字节码重新转换成java
对象称为反序列化。
使用ObjectInputStream
类。这个类提供了一个readObject()
方法,可以从输入流中读取一个对象。
权限修饰符
Java中,可以使用访问控制符来保护对类、变量、方法和构造方法的访问。Java 支持 4 种不同的访问权限。
- default (即默认,什么也不写): 在同一包内可见,不使用任何修饰符。使用对象:类、接口、变量、方法。
- private : 在同一类内可见。使用对象:变量、方法。 注意:不能修饰类(外部类)
- public : 对所有类可见。使用对象:类、接口、变量、方法
- protected : 对同一包内的类和所有子类可见。使用对象:变量、方法。 注意:不能修饰类(外部类)。
修饰符 | 当前类 | 同一包内 | 子孙类(同一包) | 子孙类(不同包) | 其他包 |
---|---|---|---|---|---|
public | Y | Y | Y | Y | Y |
protected | Y | Y | Y | Y | N |
default | Y | Y | Y | N | N |
private | Y | N | N | N | N |
基本就是public > protected > default > private
权限是 本类(都行) > 同包(同包子类或者同包其他类,private不行) > 子类(这种说的是其他包的子类,default又不行了) > 其他包(protected又不行了,只能public)
protected对应子类与包
default对应包
普通类,抽象类,接口
- 普通类是最基本的类型,在一个应用程序中我们会定义很多的普通类。它们可以有成员变量、成员方法以及构造方法等,可以实例化来创建对象。
- 抽象类是一种特殊的类,它不能被实例化,只能作为其他类的父类被继承。抽象类中可以定义抽象方法和非抽象方法,其中抽象方法没有具体实现,需要子类去
override
实现。同时,子类也可以直接使用抽象类中已经具体实现的非抽象方法。 - 接口是一组抽象方法的集合,它定义了一些行为规范,而不关心具体实现方式。接口中定义的方法都是抽象方法,必须被实现(override)才能被使用。实现一个接口的类必须提供这些接口中定义的所有方法的具体实现。
抽象类中的抽象方法不一定要被实现。如果一个类继承了一个抽象类,那么该类必须实现该抽象类中所有的抽象方法。否则,该类也必须被定义为抽象类。如果一个抽象类没有抽象方法,那么实例化该类或者继承该类也是合法的。
集合
什么是集合
- 集合就是一个放数据的容器,准确的说是放数据对象引用的容器
- 集合类存放的都是对象的引用,而不是对象的本身
- 集合类型主要有3种:set(集)、list(列表)和map(映射)。
常用的集合类
Map接口和Collection接口是所有集合框架的父接口:
Collection接口的子接口包括:Set接口和List接口
Map接口的实现类主要有:HashMap、TreeMap、Hashtable、ConcurrentHashMap等
Set接口的实现类主要有:HashSet、TreeSet、LinkedHashSet等
List接口的实现类主要有:ArrayList、LinkedList、Vector等
集合的特点
- 集合的特点主要有如下两点:
- 集合是用于存储对象的容器,对象是用来封装数据,对象多了也需要存储集中式管理。
- 和数组对比对象的大小不确定。因为集合是可变长度的。数组需要提前定义大小
集合和数组的区别
- 数组是固定长度的;集合可变长度的。
- 数组可以存储基本数据类型,也可以存储引用数据类型;集合只能存储引用数据类型。
- 数组存储的元素必须是同一个数据类型;集合存储的对象可以是不同数据类型。
使用集合框架的好处
- 容量自增长;
- 提供了高性能的数据结构和算法,使编码更轻松,提高了程序速度和质量;
- 可以方便地扩展或改写集合,提高代码复用性和可操作性。
- 通过使用JDK自带的集合类,可以降低代码维护和学习新API成本。
HashMap原理
1.hashMap的底层实现
- jdk1.8之前是基于 数组+链表 实现的。
- jdk1.8之后是基于 数组+链表+红黑树 来实现的。
- hashMap的特点是:key不能重复,可以为null,线程不安全
- jdk1.8之前链表中的元素采用 头插法 插入元素,jdk1.8之后采用 尾插法 插入元素,
2.HashMap的扩容机制:
HashMap的默认容量为16,默认的负载因子为0.75,当HashMap
中元素个数超过容量乘以负载因子的个数时,就创建一个大小为前一次两倍的新数组,再将原来数组中的数据复制到新数组中。在没有红黑树的条件下,添加元素后数组中某个链表的长度超过了8,数组会扩容为两倍。当数组长度到达64且链表长度大于8时,链表转为红黑树,当红黑树节点小于等于6时又会退化为链表。
3.HashMap存取原理:
- 计算key的hash值,然后进行二次
hash
,根据二次hash
结果找到对应的索引位置 - 如果这个位置有值,先进行
equals
比较,若结果为true
则取代该元素,若结果为false
,就使用尾插法(高低位平移法)将节点插入链表(JDK8以前使用头插法,但是头插法在并发扩容时可能会造成环形链表或数据丢失,而高低位平移发会发生数据覆盖的情况)
4.hashMap为什么线程不安全
- 在jdk1.8之前
hashmap
由于是头插法在并发扩容的情况下可能会导致数据丢失或者是环形链表的问题 - 在jdk1.8之后
hashmap
在执行并发put
操作时可能会导致数据覆盖
5.为什么执行并发put
操作时可能会导致数据覆盖
- 假设两个线程A、B都在进行put操作,并且
hash
函数计算出的插入下标是相同的,当线程A执行完上面的判断有没有hash
碰撞的代码时,由于时间片耗尽导致被挂起。 - 而线程B得到时间片后在该下标处插入了元素,完成了正常的插入。
- 然后线程A获得时间片,由于之前已经进行了
hash
碰撞的判断,所有此时不会再进行判断,而是直接进行插入,这就导致了线程B插入的数据被线程A覆盖了,从而线程不安全。
6.为什么不使用头插法
并发场景导致数据丢失
假设线程A和B都要对一个链表进行插入数据,(此时这个链表的结构为:head -> n)A和B同时创建了一个节点指向n,然后B先将head指针指向了自己创建的节点,然后A再将head的指针指向了自己创建的节点,此时B创建的节点就变成了一个无法访问的孤儿节点
并发场景导致环形链表
假设一个HashMap已经到达了扩容的临界值,结构为…,然后此时有两个线程A和B在同一时刻对HashMap进行put操作
此时HashMap将进行扩容操作,扩容完成之后,需要进行ReHash操作把所有的Entry重新hash到数组中,因为长度扩大之后hash的规则也要随之改变,当B正好遍历到Entry3的位置的时候,线程B被挂起,此时对于B来说:e=Entry3,next=Entry2,当线程AReHash完成后,HashMap变成以下这种情况
此时A完成了扩容,然后恢复线程B,B依旧认为e=Entry3,next=Entry2,此时B经过计算得出i=3,故将e放到3的位置,然后再添加Entry2到3的位置,此时为这种情况
因为此时B认为Entry3.next=Entry2,所以最后会将Entry3的指针指向Entry2,最后会形成循环结构
7.运行过程
当 new HashMap():底层没有创建数组,首次调用 put()方法时,底层创建长度 为 16 的数组,jdk8 底层的数组是:Node[],而非 Entry[]。
在我们 Java 中任何对象都有 hashcode,hash 算法就是通过 hashcode与自己进行向右位移16的异或运算。这样做是为了计算出来的 hash 值足够随机,足够分散,还有产生的数组下标足够随机,
map.put(k,v)实现原理
- 首先将 k,v 封装到 Node 对象当中(节点)。
- 先调用 k 的 hashCode()方法得出哈希值,并通过哈希算法转换成数组的下标。
- 下标位置上如果没有任何元素,就把 Node 添加到这个位置上。如果说下标对应的位 置上有链表。此时,就会拿着 k 和链表上每个节点的 k 进行 equal。如果所有的 equals 方法返回都是 false,那么这个新的节点将被添加到链表的末尾。如其中有一个 equals 返回了true,那么这个节点的 value 将会被覆盖。
map.get(k)实现原理
- 先调用 k 的 hashCode()方法得出哈希值,并通过哈希算法转换成数组的下标。
- 在通过数组下标快速定位到某个位置上。重点理解如果这个位置上什么都没有,则返回 null。如果这个位置上有单向链表,那么它就会拿着参数 K 和单向链表上的每一个节点 的 K 进行 equals,如果所有 equals 方法都返回 false,则 get 方法返回 null。如果其中一 个节点的 K 和参数 K 进行 equals 返回 true,那么此时该节点的 value 就是我们要找的 value 了,get 方法最终返回这个要找的 value。
Hash 冲突
不同的对象算出来的数组下标是相同的这样就会产生 hash 冲突,当单链表达到一定长度 后效率会非常低。
多线程下使用hashmap
在多线程情况下使用hashmap可能会导致数据覆盖和死循环的问题
数据覆盖
比如现在有两个线程同时对hashmap进行put操作,它们两个同时定位到了一个节点,然后此时他们往这个节点上插入数据,第一个线程先插入的数据就有可能被第二个线程给覆盖掉。
死循环
当向已经树化的桶位添加元素时,为了保持红黑树的特性,需要对树进行重新结构化以保持红黑树的五个性质。然后在这个过程中可能会产生问题,在balanceInsertion(平衡插入)这个方法中,会在这个方法里面的for循环中产生死循环(这个方法的作用就是上面说的保存红黑树的特性)。
红黑树的五个性质:
- 节点是红色或黑色。
- 根节点是黑色。
- 每个叶节点(NIL节点,空节点)是黑色的。
- 每个红色节点的两个子节点都是黑色。(从每个叶子到根的所有路径上不能有两个连续的红色节点)
- 从任一节点到其每个叶子的路径上包含的黑色节点数量都相同。
HashSet
HashSet的底层是HashMap,当我们向HashSet中添加元素时,会把添加进去的元素值作为HashMap的key,一个Object对象作为value值,然后调用HashMap的put方法将元素存入HashMap中。
想要线程安全的HashMap怎么办
- 使用ConcurrentHashMap
- 使用HashTable,Hashtable 给整个哈希表加了一把大锁从而实现线程安全,效率比较低。
- Collections.synchronizedHashMap()方法,如果传入的是 HashMap 对象,其实也是对 HashMap 做的方法做了一层包装,里面使用对象锁来保证多线程场景下,线程安全,本质也是对 HashMap 进行全表锁。在竞争激烈的多线程环境下性能依然也非常差,不推荐使用!
ConcurrentHashMap如何保证的线程安全
- JDK1.7:使用分段锁,将一个Map分为了16个段,每个段都是一个小的hashmap,每次操作只对其中一个段加锁
- JDK1.8:采用CAS+Synchronized保证线程安全,每次插入数据时判断在当前数组下标是否是第一次插入,是就通过CAS方式插入,然后判断f.hash是否=1,是的话就说明其他线程正在进行扩容,当前线程也会参与扩容;删除方法用了synchronized修饰,保证并发下移除元素安全
ConcurrentHashMap实现机制
ConcurrentHashMap
是 Java 中线程安全的哈希表实现,它可以支持高效地并发读写操作。
ConcurrentHashMap 在 JDK1.7 和 JDK1.8 的实现方式是不同的。
1.7的ConcurrentHashMap
JDK1.7 中的 ConcurrentHashMap 是由 Segment
数组结构和 HashEntry
数组结构组成,即 ConcurrentHashMap 把哈希桶数组切分成小数组(Segment ),每个小数组有 n 个 HashEntry 组成。
首先将数据分为一段一段的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一段数据时,其他段的数据也能被其他线程访问,实现了真正的并发访问。
Segment 是 ConcurrentHashMap 的一个内部类,它继承了 ReentrantLock,所以 Segment 是一种可重入锁,扮演锁的角色。Segment 默认为 16,也就是并发度为 16。
存放元素的 HashEntry,也是一个静态内部类
其中,用 volatile 修饰了 HashEntry 的数据 value 和 下一个节点 next,保证了多线程环境下数据获取时的可见性!
1.8的ConcurrentHashMap
JDK1.8 中的ConcurrentHashMap 选择了与 HashMap 相同的Node数组+链表+红黑树结构;在锁的实现上,抛弃了原有的 Segment 分段锁,采用CAS + synchronized
实现更加细粒度的锁。
将锁的级别控制在了更细粒度的哈希桶数组元素级别,也就是说只需要锁住这个链表头节点(红黑树的根节点),就不会影响其他的哈希桶数组元素的读写,大大提高了并发度。
1.7 与 1.8 ConcurrentHashMap 区别
- 数据结构:取消了 Segment 分段锁的数据结构,取而代之的是数组+链表+红黑树的结构。
- 保证线程安全机制:JDK1.7 采用 Segment 的分段锁机制实现线程安全,其中 Segment 继承自 ReentrantLock 。JDK1.8 采用CAS+synchronized保证线程安全。
- 锁的粒度:JDK1.7 是对需要进行数据操作的 Segment 加锁,JDK1.8 调整为对每个数组元素加锁(Node)。
- 链表转化为红黑树:定位节点的 hash 算法简化会带来弊端,hash 冲突加剧,因此在链表节点数量大于 8(且数据总量大于等于 64)时,会将链表转化为红黑树进行存储。
- 查询时间复杂度:从 JDK1.7的遍历链表O(n), JDK1.8 变成遍历红黑树O(logN)。
1.8 为什么使用内置锁 synchronized替换 可重入锁 ReentrantLock
- 在 JDK1.8 中,对 synchronized 锁的实现引入了大量的优化,并且 synchronized 有多种锁状态,会从无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁一步步转换。
- 减少内存开销 。假设使用可重入锁来获得同步支持,那么每个节点都需要通过继承 AQS 来获得同步支持。但并不是每个节点都需要获得同步支持的,只有链表的头节点(红黑树的根节点)需要同步,这无疑带来了巨大内存浪费。
ConcurrentHashMap 和 Hashtable 的效率哪个更高
ConcurrentHashMap 的效率要高于 Hashtable,因为 Hashtable 给整个哈希表加了一把大锁从而实现线程安全。而ConcurrentHashMap 的锁粒度更低,在 JDK1.7 中采用分段锁实现线程安全,在 JDK1.8 中采用CAS+synchronized实现线程安全。
ConcurrentHashMap 的 put 方法执行逻辑
JDK1.7
- 首先会定位到相应的 Segment 数组上的具体索引位置,然后再进行 put 操作。
- 定位成功进行put操作的时候首先会尝试获取锁,如果获取失败肯定就有其他线程存在竞争,则利用
scanAndLockForPut()
自旋获取锁。- 尝试自旋获取锁。
- 如果重试的次数达到了
MAX_SCAN_RETRIES
则改为阻塞锁获取,保证能获取成功。
JDK1.8
- 根据 key 计算出 hash 值;
- 判断是否需要进行初始化;
- 定位到 Node,拿到首节点 f,判断首节点 f:
- 如果为 null ,则通过 CAS 的方式尝试添加;
- 如果为 f.hash = MOVED = -1 ,说明其他线程在扩容,参与一起扩容;
- 如果都不满足 ,synchronized 锁住 f 节点,判断是链表还是红黑树,遍历插入;
- 当在链表长度达到 8 的时候,数组扩容或者将链表转换为红黑树。
ConcurrentHashMap 的 get 方法执行逻辑
JDK1.7
首先,根据 key 计算出 hash 值定位到具体的 Segment ,再根据 hash 值获取定位 HashEntry 对象,并对 HashEntry 对象进行链表遍历,找到对应元素。
由于 HashEntry 涉及到的共享变量都使用 volatile 修饰,volatile 可以保证内存可见性,所以每次获取时都是最新值。
JDK1.8
- 根据 key 计算出 hash 值,判断数组是否为空;
- 如果是首节点,就直接返回;
- 如果是红黑树结构,就从红黑树里面查询;
- 如果是链表结构,循环遍历判断。
ConcurrentHashMap 的 get 方法是否要加锁(也是效率高的原因)
get 方法不需要加锁。因为 Node 的元素 value 和指针 next 是用 volatile 修饰的,在多线程环境下线程A修改节点的 value 或者新增节点的时候是对线程B可见的。
这也是它比其他并发集合比如 Hashtable、用 Collections.synchronizedMap()包装的 HashMap 效率高的原因之一。
get 方法不需要加锁与 volatile 修饰的哈希桶数组有关吗
没有关系。哈希桶数组table
用 volatile 修饰主要是保证在数组扩容的时候保证可见性。
ConcurrentHashMap 不支持 key 或者 value 为 null 的原因
我们先来说value 为什么不能为 null。因为 ConcurrentHashMap 是用于多线程的 ,如果ConcurrentHashMap.get(key)得到了 null ,这就无法判断,是映射的value是 null ,还是没有找到对应的key而为 null ,就有了二义性。
而用于单线程状态的 HashMap 却可以用containsKey(key) 去判断到底是否包含了这个 null 。
我们用反证法来推理:
假设 ConcurrentHashMap 允许存放值为 null 的 value,这时有A、B两个线程,线程A调用ConcurrentHashMap.get(key)方法,返回为 null ,我们不知道这个 null 是没有映射的 null ,还是存的值就是 null 。
假设此时,返回为 null 的真实情况是没有找到对应的 key。那么,我们可以用 ConcurrentHashMap.containsKey(key)来验证我们的假设是否成立,我们期望的结果是返回 false 。
但是在我们调用 ConcurrentHashMap.get(key)方法之后,containsKey方法之前,线程B执行了ConcurrentHashMap.put(key, null)的操作。那么我们调用containsKey方法返回的就是 true 了,这就与我们的假设的真实情况不符合了,这就有了二义性。
至于 ConcurrentHashMap 中的 key 为什么也不能为 null 的问题,源码就是这样写的,哈哈。如果面试官不满意,就回答因为作者Doug不喜欢 null ,所以在设计之初就不允许了 null 的 key 存在。
ConcurrentHashMap 的并发度
并发度可以理解为程序运行时能够同时更新 ConccurentHashMap且不产生锁竞争的最大线程数。在JDK1.7中,实际上就是ConcurrentHashMap中的分段锁个数,即Segment[]的数组长度,默认是16,这个值可以在构造函数中设置。
如果自己设置了并发度,ConcurrentHashMap 会使用大于等于该值的最小的2的幂指数作为实际并发度,也就是比如你设置的值是17,那么实际并发度是32。
如果并发度设置的过小,会带来严重的锁竞争问题;如果并发度设置的过大,原本位于同一个Segment内的访问会扩散到不同的Segment中,CPU cache命中率会下降,从而引起程序性能下降。
在JDK1.8中,已经摒弃了Segment的概念,选择了Node数组+链表+红黑树结构,并发度大小依赖于数组的大小。
ConcurrentHashMap 迭代器的一致性
与 HashMap 迭代器是强一致性不同,ConcurrentHashMap 迭代器是弱一致性。
ConcurrentHashMap 的迭代器创建后,就会按照哈希表结构遍历每个元素,但在遍历过程中,内部元素可能会发生变化,如果变化发生在已遍历过的部分,迭代器就不会反映出来,而如果变化发生在未遍历过的部分,迭代器就会发现并反映出来,这就是弱一致性。
这样迭代器线程可以使用原来老的数据,而写线程也可以并发的完成改变,更重要的,这保证了多个线程并发执行的连续性和扩展性,是性能提升的关键。
HashTable与HashMap的区别
- HashTable的每个方法都用synchronized修饰,因此是线程安全的,但同时读写效率很低,HashMap是线程不安全的
- HashTable的Key不允许为null,HashMap可以
- HashTable只对key进行一次hash,HashMap进行了两次Hash
- HashTable底层使用的数组加链表,HashMap底层使用的是数组+链表+红黑树
- Hashtable 是同步的,而 HashMap 不是。因此,HashMap 更适合于单线 程环境,而 Hashtable 适合于多线程环境。一般现在不建议用 HashTable, ① 是 HashTable 是遗留类,内部实现很多没优化和冗余。②即使在多线程环境下, 现在也有同步的 ConcurrentHashMap 替代,没有必要因为是多线程而用HashTable。
HashTable 和 ConcurrentHashMap 区别
HashTable 使用的是 Synchronized 关键字修饰,ConcurrentHashMap 是 JDK1.7 使用了锁分段技术来保证线程安全的。JDK1.8ConcurrentHashMap 取消了Segment 分段锁,采用 CAS 和 synchronized 来保证并发安全。数据结构跟 HashMap1.8 的结构类似,数组+链表/红黑二叉树。 synchronized 只锁定当前链表或红黑二叉树的首节点,这样只要 hash 不冲突,就 不会产生并发,效率又提升 N 倍。
Hashtable的锁机制
Hashtable 是使用 synchronized来实现线程安全的,给整个哈希表加了一把大锁,多线程访问时候,只要有一个线程访问或操作该对象,那其他线程只能阻塞等待需要的锁被释放,在竞争激烈的多线程场景中性能就会非常差!
ArrayList 和 LinkedList的区别
ArratList的底层使用动态数组,默认容量为10,当元素数量到达容量时,生成一个新的数组,大小为前一次的1.5倍,然后将原来的数组copy过来;因为数组在内存中是连续的地址,所以ArrayList查找数据更快,由于扩容机制添加数据效率更低
LinkedList的底层使用链表,在内存中是离散的,没有扩容机制;LinkedList在查找数据时需要从头遍历,所以查找慢,但是添加数据效率更高
ArrayList 和 Vector 的区别
- 这两个类都实现了 List 接口(List 接口继承了 Collection 接口),他们都是有序集合
- 线程安全:Vector 使用了 Synchronized 来实现线程同步,是线程安全的,而 ArrayList 是非线程安全的。
- 性能:ArrayList 在性能方面要优于 Vector。
- 扩容:ArrayList 和 Vector 都会根据实际的需要动态的调整容量,只不过在 Vector 扩容每次会增加 1 倍,而 ArrayList 只会增加 50%。
- Vector类的所有方法都是同步的。可以由两个线程安全地访问一个Vector对象、但是一个线程访问Vector的话代码要在同步操作上耗费大量的时间。
- Arraylist不是同步的,所以在不需要保证线程安全时时建议使用Arraylist。
如何保证ArrayList的线程安全
- 使用collentions.synchronizedList()方法为ArrayList加锁
- 使用Vector,Vector底层与Arraylist相同,但是每个方法都由synchronized修饰,速度很慢
- 使用juc下的CopyOnWriterArrayList,该类实现了读操作不加锁,写操作时为list创建一个副本,期间其它线程读取的都是原本list,写操作都在副本中进行,写入完成后,再将指针指向副本。
String
String、StringBuffer、StringBuilder的区别
String 由 char[] 数组构成,使用了 final 修饰,对 String 进行改变时每次都会新生成一个 String 对象,然后把指针指向新的引用对象。
StringBuffer可变并且线程安全
StringBuiler可变但线程不安全。
操作少量字符数据用 String;单线程操作大量数据用 StringBuilder;多线程操作大量数据用 StringBuffer。
String是怎么实现不可变的
String 底层是 char[] 数组,String类和它底层的char[] 数组都使用了final关键字来修饰,final修饰的类不可以被继承,final修饰的属性就是常量。
String不可变的好处
- 因为HashMap的key通常会用字符串。对于key来说,重要的是它们是不可变的,以便用它们检索存储在HashMap中的值对象。如果在插入后修改了String的内容,可变的String将在插入和检索时生成两个不同的哈希码,可能会丢失Map中的值对象。
- 因为String是不可变的,所以多线程环境下,多个线程可以同时访问同一个字符串对象,而不用担心数据的一致性和线程安全问题。
- 因为String是不可变的,因此在字符串连接、替换等操作时,可以使用缓存进行优化,不必每次都创建新的字符串对象,从而提高程序的性能。
String str="i"与 String str=new String(“i”)一样吗?
不一样,因为内存的分配方式不一样。
- String str="i"的方式,java 虚拟机会将其分配到常量池中;
- 而 String str=new String(“i”) 则会被分到堆内存中;
String s = new String(“xyz”);创建了几个String Object?
分两种情况:
- 如果String常量池中,已经创建"xyz",则不会继续创建,此时只创建了一个对象new String(“xyz”),此时为一个Object对象;
- 如果String常量池中,没有创建"xyz",则会创建两个对象,一个对象的值是"xyz",一个对象new String(“xyz”),此时为二个Object对象
字符串的实现所用到的设计模式
- 享元模式:字符串是不可变对象,因此在进行字符串操作时,需要频繁地创建新的字符串对象。为了提高内存使用效率,Java 中的 String 类使用了享元模式,将相同的字符串对象共享在内存池中。
- 工厂模式:Java 中的 String 类使用了工厂模式来创建字符串对象。String 类提供了多种构造方法和静态方法,可以根据传入的参数来创建不同的字符串对象。
- 适配器模式:Java 中的 String 类提供了多种方法来对字符串进行操作,如 substring()、charAt() 和 length() 等等。这些方法可以被看作是对字符串对象的适配器,将不同的操作转换成了统一的接口。
- 装饰器模式:Java 中的 String 类提供了多个修整字符串的方法,如 trim()、toUpperCase() 和 toLowerCase() 等等。这些方法可以看作是对原始字符串对象进行修饰的装饰器。
hashCode和equals
hashCode()和equals()都是Obkect类的方法,hashCode()默认是通过地址来计算hash码,但是可能被重写过用内容来计算hash码,equals()默认通过地址判断两个对象是否相等,但是可能被重写用内容来比较两个对象
所以两个对象相等,他们的hashCode和equals一定相等,但是hashCode相等的两个对象未必相等
如果重写equals()必须重写hashCode(),比如在HashMap中,key如果是String类型,String如果只重写了equals()而没有重写hashcode()的话,则两个equals()比较为true的key,因为hashcode不同导致两个key没有出现在一个索引上,就会出现map中存在两个相同的key
hash冲突
哈希冲突指的是不同的数据经过哈希函数计算后得到相同的下标值。就比如说在hashmap中两个不同的数据经过计算得到了相同的下表。hash冲突可以通过以下办法来解决:
- 开放定址法:比如线性探测,当发生哈希冲突时,从发生冲突的位置向后遍历哈希表,寻找下一个空闲的位置,然后将数据存储在该位置上。具体的开放定址法包括线性探测、二次探测和双重哈希等。
- 链表法:当发生哈希冲突时,将冲突的数据存储在一个链表中,同时将链表的头结点存储在哈希表的相应位置上,这样可以有效地解决哈希冲突问题。
- 建立公共溢出区:将哈希表分成基本表和溢出表两部分,当发生哈希冲突时,将元素存储到溢出表中,这样可以保证哈希表始终不会满。
什么是hash算法
哈希算法是指把任意长度的二进制映射为固定长度的较小的二进制值,这个较小的二进制值叫做哈希值。
面向对象和面向过程的区别
面向对象有封装、继承、多态性的特性,所以相比面向过程易维护、易复用、易扩展,但是因为类调用时要实例化,所以开销大性能比面向过程低
深拷贝和浅拷贝
区别
浅拷贝:浅拷贝只复制某个对象的引用,而不复制对象本身,新旧对象还是共享同一块内存
深拷贝:深拷贝会创造一个一摸一样的对象,新对象和原对象不共享内存,修改新对象不会改变原对对象。
实现
浅拷贝:通过实现Cloneable接口并重写clone()方法来实现
public class Person implements Cloneable {
private String name;
private int age;
public Object clone() throws CloneNotSupportedException {
return super.clone();
}
}
深拷贝:通过序列化和反序列化,将对象序列化到内存流中,再从内存流中反序列化出新对象,就可以实现深拷贝。
public class Person implements Serializable {
private String name;
private int age;
private Address address;
public Object deepClone() throws IOException, ClassNotFoundException {
// 将对象序列化到内存流中
ByteArrayOutputStream bos = new ByteArrayOutputStream();
ObjectOutputStream oos = new ObjectOutputStream(bos);
oos.writeObject(this);
// 从内存流中反序列化出新对象
ByteArrayInputStream bis = new ByteArrayInputStream(bos.toByteArray());
ObjectInputStream ois = new ObjectInputStream(bis);
return ois.readObject();
}
}
Java的三大特性
Java的三大特性有:封装,多态还有继承
- 封装:把事物抽象成一个类,将事物拥有的属性和动作隐藏起来,只保留特定的方法与外界联系。当内部的逻辑发生变化时,外部调用不用因此而修改,它们只调用开放的接口,而不用去关心内部的实现。
- 多态:多态是指同一个实体同时具有多种形式,即同一个对象,在不同时刻,代表的对象不一样,指的是对象的多种形态。可以把不同的子类对象都当作父类来看,进而屏蔽不同子类对象之间的差异。
- 继承:继承是面向对象的最显著的一个特征。继承是从已有的类(父类或者超类)中派生出新的类(子类),新的类能吸收已有类的数据属性和行为,并能扩展新的能力(方法的覆盖/重写)。JAVA不支持多继承,一个类只能有一个父类。父类是子类的一般化,子类是父类的特殊化(具体化)
封装
什么是封装
把事物抽象成一个类,将事物拥有的属性和动作隐藏起来,只保留特定的方法与外界联系。当内部的逻辑发生变化时,外部调用不用因此而修改,它们只调用开放的接口,而不用去关心内部的实现。
封装的好处
- 实现了专业的分工,将处理逻辑封装成一个方法,做到见名知其义
- 良好的封装能够减少耦合
- 隐藏信息,实现细节
多态
什么是多态
多态是指同一个实体同时具有多种形式,即同一个对象,在不同时刻,代表的对象不一样,指的是对象的多种形态。
可以把不同的子类对象都当作父类来看,进而屏蔽不同子类对象之间的差异。
多态的特点
- 多态的前提1:是继承
- 多态的前提2:要有方法的重写
- 父类引用指向子类对象,如:Animal a = new Cat();
- 多态中,编译看左边,运行看右边
多态的作用
多态的实现要有继承、重写,父类引用指向子类对象。
- 可以消除类型之间的耦合关系
- 让我们不用关心某个对象到底具体是什么类型,就可以使用该对象的某些方法
- 增加类的可扩充性和灵活性
向上转型和向下转型
**向上转型:**可以把不同的子类对象都当作父类来看,进而屏蔽不同子类对象之间的差异,写出通用的代码,做出通用的编程,统一调用标准。
比如:父类Parent,子类Child
父类的引用指向子类对象:
Parent p = new Child();
说明:向上转型时,子类对象当成父类对象,只能调用父类的功能,如果子类重写了父类中声明过的方法,方法体执行的就是子类重过后的功能。但是此时对象是把自己看做是父类类型的,所以其他资源使用的还是父类型的。
**向下转型(较少):**子类的引用的指向子类对象,过程中必须要采取到强制转型。这个是之前向上造型过的子类对象仍然想执行子类的特有功能,所以需要重新恢复成子类对象
Parent p = new Child(); //向上转型,此时,p是Parent类型
Child c = (Child) p; //此时,把Parent类型的p转成小类型Child
其实,相当于创建了一个子类对象一样,可以用父类的,也可以用自己的
说明:向下转型时,是为了方便使用子类的特殊方法,也就是说当子类方法做了功能拓展,就可以直接使用子类功能。
静态变量和实例变量的区别
- 在语法定义上的区别:静态变量前要加static关键字,而实例变量前则不加
- 在程序运行时的区别:实例变量属于某个对象的属性,必须创建了实例对象,其中的实例变量才会被分配空间,才能使用这个实例变量。静态变量不属于某个实例对象,而是属于类,所以也称为类变量,只要程序加载了类的字节码,不用创建任何实例对象,静态变量就会被分配空间,静态变量就可以被使用了。总之,实例变量必须创建对象后才可以通过这个对象来使用,静态变量则可以直接使用类名来引用。
继承
什么是继承
继承是面向对象的最显著的一个特征。继承是从已有的类(父类或者超类)中派生出新的类(子类),新的类能吸收已有类的数据属性和行为,并能扩展新的能力(方法的覆盖/重写)。JAVA不支持多继承,一个类只能有一个父类。父类是子类的一般化,子类是父类的特殊化(具体化)
子类的特点
- 子类拥有父类非private的属性和方法
- 子类可以添加自己的方法和属性,即对父类进行扩展
- 子类可以重新定义父类的方法,即方法的覆盖/重写
构造函数
- 构造函数不能被继承,子类可以通过**super()**显示调用父类的构造函数
- 创建子类时,编译器会自动调用父类的无参构造函数
- 如果父类没有定义无参构造函数,子类必须在构造函数的第一行代码使用**super()**显示调用
覆盖/重写的概念
当子类需要修改父类的一些方法进行扩展,增大功能,程序设计者常常把这样的一种操作方法称为重写,也叫称为覆盖。
可以这么理解:重写就是指子类中的方法与父类中继承的方法有完全相同的返回值类型、方法名、参数个数以及参数类型。这样,就可以实现对父类方法的覆盖。如果子类将父类中的方法重写了,而我们想调用父类中的同名方法怎么办?此时,通过使用super关键就可以实现这个功能,super关键字可以从子类访问父类中的内容,如果要访问被重写过的方法,使用“super.方法名(参数列表)”的形式调用。
java为什么不支持多继承
- 简化语言和继承关系:多继承会引入复杂的继承关系,并导致代码可读性和理解难度增加。当一个类有多个父类时,可能存在命名冲突、方法重复定义等问题。Java通过禁止多继承,使得类层次结构更加清晰简单,降低了代码的复杂性。
- 避免钻石继承问题:钻石继承(Diamond Inheritance)指的是一个类同时继承自两个具有共同基类的类。在这种情况下,如果两个基类中有相同的方法或字段,就会产生冲突。通过禁止多继承,Java避免了这种冲突的问题。
- 接口实现的替代方案:Java提供了接口(interface)的概念,通过接口可以实现类似多继承的功能。一个类可以实现多个接口,从而具备多个接口定义的行为和能力。接口的引入使得代码更加灵活和可扩展,能够更好地适应需求变化。
虽然 Java 不支持多继承,但可以通过接口实现类似的效果,并且在实际开发中,多数情况下接口的使用能更好地满足需求。
Java如何自定义异常类
1.自定义异常类继承exception类
public class MyException extends Exception {
//异常信息
private String message;
//构造函数
public MyException(String message){
super(message);
this.message = message;
}
}
2.在要抛出异常的函数使用throws关键字
public class UseMyException {
private String name;
private String password;
public UseMyException(String name,String password){
this.name = name;
this.password = password;
}
public void throwException(String password) throws MyException{
if (!this.password.equals(password)){
throw new MyException("密码不正确!");
}
}
}
反射
反射是通过获取类的class对象,然后动态的获取到这个类的内部结构,动态的去操作类的属性和方法。并且对于任意一个对象,都能够调用它的任意一个方法;
应用场景有:要操作权限不够的类属性和方法时、实现自定义注解时、动态加载第三方jar包时、按需加载类,节省编译和初始化时间;
获取 Class 对象的 3 种方法
调用某个对象的 getClass()方法
Person p = new Person();
Class clazz = p.getClass();
调用某个类的 class 属性来获取该类对应的 Class 对象
Class clazz = Person.class;
使用 Class 类中的 forName()静态方法(最安全/性能最好)
Class clazz = Class.forName("类的全路径"); //最常用
Java的IO模型
BIO,NIO,IO多路复用,AIO
Java的IO模型,通常可分为同步阻塞I/O(BIO),同步非阻塞I/O(NIO),IO多路复用和异步I/O(AIO)四种。
- BIO:同步阻塞lO,使用BIO读取数据时,线程会阻塞住,并且需要线程主动去查询是否有数据可读,并且需要处理完一个Socket之后才能处理下一个Socket
- NIO:同步非阻塞lO,使用NIO读取数据时,线程不会阻塞,但需要线程主动的去查询是否有IO事件
- IO多路复用:异步阻塞IO,一个线程可同时监视多个文件描述符(一个文件句柄表示一个网络连接)。
- AIO:也叫做NIO 2.0,异步非阻塞lO,使用AlO读取数据时,线程不会阻塞,并且当有数据可读时会通知给线程,不需要线程主动去查询
BIO
由用户空间的线程主动发起IO请求(同步),需要内核IO操作彻底完成后才返回到用户空间执行用户的操作(阻塞)。
特点:一次调用发起后,用户程序空间阻塞,等待内核缓冲数据准备好并复制到用户缓冲区,复制完返回给用户程序空间,才解除阻塞
优点:开发简单,阻塞期间用户线程被挂起基本不占用CPU资源
缺点:每个线程维护一个IO操作,并发情况下就需要大量线程维护大量网络连接,内存、线程切换开销巨大
NIO
由用户空间的线程主动发起IO请求(同步),用户空间的程序不需要等待内核IO操作彻底完成,在接收到内核立即返回给用户的状态值后,即可返回用户空间执行用户的操作。
特点:用户程序线程需不停发起IO调用,直到内核缓冲区准备好数据,这时再发起一次IO调用时,然后用户程序空间阻塞,直到数据从内核缓冲区复制到用户缓冲区并返回给用户程序空间,才解除阻塞
优点:在内核缓冲区等待/准备数据时用户发起的IO调用都不会阻塞,实时性较好
缺点:不停的轮询会占用大量CPU时间,效率低下,因此高并发场景也不会直接使用NIO
NIO的核心组件和它的作用
Channel,Buffer,Selector
- channel类似于一个流。每个channel对应一个buffer缓冲区。channel会注册到selector。
- select会根据channel上发生的读写事件,将请求交由某个空闲的线程处理。selector对应一个或者多个线程。
- Buffer和Channel都是可读可写的。
IO多路复用
也称为异步阻塞IO,一个线程可同时监视多个文件描述符(一个文件句柄表示一个网络连接)。
在IO多路复用模型中通过select/epoll系统调用,单个应用程序的线程,可以不断地轮询成百上千的socket连接,当某个或者某些socket网络连接有IO就 绪的状态,就返回对应的可以执行的读写操作。
IO多路复用有三种实现方式:select、poll、epoll,其中poll、epoll是linux的函数,而Linux 2.5.44版本后poll被epoll取代。select和poll的时间复杂度都是O(N),select是数组有大小限制,poll是链表无大小限制;epoll的时间复杂度是O(1)
优点:一个选择器查询线程可同时处理大量连接,系统不必创建和维护大量线程
缺点:select,poll,epoll本质上都是同步阻塞I/O,因为他们都需要在读写事件就绪后自己(系统调用)本身进行读写,也就是说这个读写过程是阻塞的
IO多路复用的read流程:
将目标socket注册到select/epoll选择器;
使用选择器查询方法返回就绪的socket列表,用户进程在调用select方法的时候是线程阻塞的;
对就绪socket发起read系统调用,用户线程阻塞,内核开始复制数据;
复制完成,内核返回结果给用户线程,用户线程读取到数据,解除阻塞。
AIO
指系统内核是主动发起IO请求的一方,用户空间的线程是被动接受方。
也称信号驱动IO。用户线程通过系统调用,向内核注册某个IO操作,内核在整个IO操作(数据准备、复制)完成后,发送signal给用户线程。
优点:内核的数据处理过程中用户程序都不需要阻塞。
缺点:用户程序仅需要注册“接收内核IO操作完成的事件”或回调函数并接收该事件,其余工作都留给了操作系统,需要底层内核提供支持。
AIO是一种异步的 IO 操作。AIO 采用事件驱动模型,当 IO 操作完成后会通知应用程序,从而避免了阻塞等待的情况,提高了 IO 操作的并发性和效率。
在 Java 中,AIO 主要通过 AsynchronousChannel 和 CompletionHandler 两个类来实现异步 IO 操作。其中 AsynchronousChannel 是通道抽象基类,定义了异步 IO 操作的标准接口。而 CompletionHandler 则封装了 IO 操作完成时的回调方法,它提供了 completed()
、failed()
和 cancelled()
三个方法,分别表示 IO 操作成功完成、失败和被取消。应用程序可以实现自己的 CompletionHandler 来处理 IO 操作结果。
在使用 AIO 进行 IO 操作时,需要创建一个 AsynchronousChannel 对象,并调用其相应的 IO 方法。当 IO 操作完成后,Java NIO 会自动调用指定的 CompletionHandler 对象的相应方法来处理 IO 操作结果。这样,应用程序就可以在 IO 操作完成后继续执行其他任务,提高了系统的并发性和响应速度。
AIO 常用于处理并发量较大的网络应用,比如网络聊天室、B/S 结构的文件上传下载等场景。
总结
- 同步阻塞IO开发简单,阻塞时不占用CPU,但每个线程维护一个IO操作,并发情况下内存、线程切换开销巨大
- 同步非阻塞IO在内核准备数据期间不阻塞,实时性较好,但不停的轮询会占用大量CPU时间,效率低下
- IO多路复用(异步阻塞)一个线程可操作多个IO,但本质上读写过程是阻塞的
- 异步IO对Windows系统是可以的,但在linux下目前仍不完善,底层仍使用的是与IO多路复用系统的epoll
因此,Linux下的高并发程序IO目前更推荐使用IO多路复用模型。
Java多线程篇
进程和线程的区别,进程间如何通信
进程:系统运行的基本单位,进程在运行过程中都是相互独立,但是线程之间运行可以相互影响。
线程:独立运行的最小单位,一个进程包含多个线程且它们共享同一进程内的系统资源
进程通信是指在不同的进程之间共享信息和传递数据的机制。
进程间通过管道、 共享内存、信号量、消息队列和套接字通信
管道
**管道是一种半双工通信方式,只允许单向数据传输,并且只能在具有亲缘关系的进程之间使用。**典型的使用场景是父子进程之间的通信。管道可以通过 pipe()
系统调用创建,并使用文件描述符进行读写操作。
消息队列
消息队列是一种基于消息的通信方式,可以实现不同进程之间的异步通信。消息队列可以存储一个或多个消息,每个消息都有一个类型和一个数据体。典型的使用场景是多个进程之间的事件通知和数据传输。
共享内存
**共享内存是一种高效的进程通信方式,可以使多个进程共享同一块物理内存区域,并且直接访问该内存区域。**共享内存可以通过 系统调用创建,并使用指针进行读写操作。典型的使用场景是多个进程之间的快速数据共享和交换。
信号量
**信号量是一种计数器,用于控制多个进程之间的互斥访问和同步操作。**当某个进程需要访问共享资源时,它会尝试对信号量进行加锁操作,如果加锁成功,则表示能够访问共享资源。典型的使用场景是多个进程之间的资源竞争和同步。
套接字
**套接字是一种通用的网络通信方式,可以在不同计算机之间进行通信。**套接字可以通过 socket()
系统调用创建,并使用网络协议进行数据传输。典型的使用场景是客户端和服务器之间的信息交互和数据传输。
并发和并行
并发
指在同一时刻只能有一条指令执行,但多个进程指令被快速的轮换执行,使得在宏观上具有多个进程同时执行的效果,但在微观上并不是同时执行的,只是把时间分成若干段,使多个进程快速交替的执行。
并行
指在同一时刻,有多条指令在多个处理器上同时执行,所以无论从微观还是从宏观来看,二者都是一起执行的。
相同点
并发和并行的目标都是最大化CPU的使用率,将cpu的性能充分压榨出来。
不同点
- 并行在多处理器系统中存在,而并发可以在单处理器和多处理器系统中都存在
- 并行要求程序能够同时执行多个操作,而并发只是要求程序“看着像是”同时执行多个操作,其实是交替执行。
什么是线程上下文切换
当一个线程被剥夺cpu使用权时,切换到另外一个线程执行
线程的生命周期
线程的生命周期包含5个阶段,包括:新建、就绪、运行、阻塞、销毁。
- 新建:就是刚使用new方法,new出来的线程;
- 就绪:就是调用的线程的start()方法后,这时候线程处于等待CPU分配资源阶段,谁先抢的CPU资源,谁开始执行;
- 运行:当就绪的线程被调度并获得CPU资源时,便进入运行状态,run方法定义了线程的操作和功能;
- 阻塞:在运行状态的时候,可能因为某些原因导致运行状态的线程变成了阻塞状态,比如sleep()、wait()之后线程就处于了阻塞状态,这个时候需要其他机制将处于阻塞状态的线程唤醒,比如调用notify或者notifyAll()方法。唤醒的线程不会立刻执行run方法,它们要再次等待CPU分配资源进入运行状态;
- 销毁:如果线程正常执行完毕后或线程被提前强制性的终止或出现异常导致结束,那么线程就要被销毁,释放资源;
完整的生命周期图如下:
线程的状态
- 初始(NEW):新创建了一个线程对象,但还没有调用start()方法。
- 运行(RUNNABLE):Java线程中将就绪(ready)和运行中(running)两种状态笼统的称为“运行”。
线程对象创建后,其他线程(比如main线程)调用了该对象的start()方法。该状态的线程位于可运行线程池中,等待被线程调度选中,获取CPU的使用权,此时处于就绪状态(ready)。就绪状态的线程在获得CPU时间片后变为运行中状态(running)。 - 阻塞(BLOCKED):表示线程阻塞于锁。
- 等待(WAITING):进入该状态的线程需要等待其他线程做出一些特定动作(通知或中断)。
- 超时等待(TIMED_WAITING):该状态不同于WAITING,它可以在指定的时间后自行返回。
- 终止(TERMINATED):表示该线程已经执行完毕。
锁
什么是死锁
死锁指多个线程在执行过程中,因争夺资源造成的一种相互等待的僵局
/*
演示死锁现象
*/
public class DeadLock {
//创建两个对象
private static Object a = new Object();
private static Object b = new Object();
public static void main(String[] args) {
new Thread(() -> {
synchronized (a) {
System.out.println(Thread.currentThread().getName() + " 持有锁a,试图获取锁b");
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (b) {
System.out.println(Thread.currentThread().getName() + " 获取锁b");
}
}
}, "A").start();
new Thread(() -> {
synchronized (b) {
System.out.println(Thread.currentThread().getName() + " 持有锁b,试图获取锁a");
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (a) {
System.out.println(Thread.currentThread().getName() + " 获取锁a");
}
}
}, "B").start();
}
}
死锁的必要条件
互斥条件:同一资源同时只能由一个线程读取
不可抢占条件:不能强行剥夺线程占有的资源
请求和保持条件:请求其他资源的同时对自己手中的资源保持不放
循环等待条件:在相互等待资源的过程中,形成一个闭环
想要预防死锁,只需要破坏其中一个条件即可,比如使用定时锁、尽量让线程用相同的加锁顺序,还可以用银行家算法可以预防死锁
同步锁、死锁、乐观锁、悲观锁
同步锁:
当多个线程同时访问同一个数据时,很容易出现问题。为了避免这种情况出现,我们要保证线程同步互斥,就是指并发执行的多个线程,在同一时间内只允许一个线程访问共享数 据。Java 中可以使用 synchronized 关键字来取得一个对象的同步锁。
乐观锁:
总是假设最好的情况,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是 在更新的时候会判断一下在此期间别人有没有去更新这个数据,可以使用版本号机制和 CAS 算法实现。乐观锁适用于多读的应用类型,这样可以提高吞吐量。像数据库提供的类 似于 write_conditio 机制,其实都是提供的乐观锁。在 Java 中 java.util.concurrent.atomic 包下面的原子变量类就是使用了乐观锁的一种实现方式 CAS 实现的。
乐观锁是非阻塞的
悲观锁:
总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁(共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程)。传统的关系型数据库里边就用到了很多这种锁机制,比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁。Java 中 synchronized 和 ReentrantLock 等独占锁就是悲观锁思想的实现。
悲观锁是阻塞的
阻塞锁和非阻塞锁的区别
阻塞锁和非阻塞锁是两种锁的实现方式,它们的区别在于锁获取时的行为表现不同。
阻塞锁是指当有线程加锁后,其他线程无法获取到锁,被阻塞挂起,直到加锁的线程释放锁后,其他线程才能尝试获取锁。在获取锁的过程中,如果发现锁已被占用,线程会被阻塞挂起,直到获取到锁为止。在Java中,synchronized关键字就是一种典型的阻塞锁实现。阻塞锁的优点是实现简单,易于理解,但可能会带来线程切换的开销和上下文切换导致的性能损失等问题。
非阻塞锁是指当有线程加锁后,其他线程无法获取到锁时,不会被挂起等待,而是立刻返回一个错误或者一个空值,由调用者自行决定后续处理方式。在获取锁的过程中,如果发现锁已被占用,线程不会被阻塞挂起,而是立刻返回失败,然后可以选择退出或者再次尝试获取锁。在Java中,ReentrantLock类就是一种典型的非阻塞锁实现,它可以通过tryLock()方法来尝试获取锁,如果失败则立即返回。非阻塞锁的优点是可以避免线程切换和上下文切换带来的性能开销,但实现较为复杂且不易于理解。
偏向锁、轻量级锁、重量级锁
偏向锁:当一段代码没有别的线程访问,此时线程去访问会直接获取偏向锁
轻量级锁:当锁是偏向锁时,有另外一个线程来访问,会升级为轻量级锁。线程会通过CAS方式获取锁,不会阻塞,提高性能,
重量级锁:轻量级锁自旋一段时间后线程还没有获取到锁,会升级为重量级锁,重量级锁时,来竞争锁的所有线程都会阻塞,性能降低
注意,锁只能升级不能降级
Synchronized锁
Synchronized锁简介
- java中的关键字,在
JVM
层面上围绕着内部锁(intrinsic lock)或者监管锁(Monitor Lock)的实体建立的,Java
利用锁机制实现线程同步的一种方式。 synchronized
属于隐式锁,相比于显示锁如ReentrantLock
不需要自己写代码去获取锁和释放锁。synchronized
属于可重入锁,当一个线程得到一个对象锁后,再次请求此对象锁时是可以再次得到该对象的锁的。即synchronized
块中的synchronized
还是能马上获得该锁。synchronized
是不可中断锁,在阻塞队列中排队是不可中断。synchronized
为非公平锁,即多个线程去获取锁的时候,会直接去尝试获取,如果能获取到,就直接获取到锁,获取不到的话进入等待队列。- jdk1.6之前,
synchronized
属于重量级锁(悲观锁),jdk1.6之后被进行了大幅度优化,支持锁升级制度缓解加锁和解锁造成的性能浪费,锁的级别采用: 偏向锁 -> 轻量级锁 -> 重量级锁。
Synchronized锁优化
在jdk1.6以前Synchronized
一直是重量级锁,在jdk1.6以后引入了偏向锁、轻量级锁、重量级锁。
Synchronized锁原理
synchronized是基于JVM内置锁实现,通过内部对象Monitor(监视器锁)实现,基于进入与退出Monitor对象实现方法与代码块同步,监视器锁的实现依赖底层操作系统的Mutex lock(互斥锁)实现。
Synchronized对代码块加锁
synchronized对代码块加锁需要依靠两个指令 monitorenter 和 monitorexit
- 在进入代码块前执行 monitorenter 指令
- 在离开代码块前执行 monitorexit 指令
获取monitor的过程:
- 执行 monitorenter 指令后,当前线程试图获取对象所对应的 monitor 的持有权,当monitor的进入计数器为0,则该线程可以成功获取 monitor,并将计数器值设置为1,此时取锁成功。
- 如果当前线程已经拥有该对象 monitor 的所有权,那它可以进入这个 monitor ,重入计数器的的值加1。
- 如果其他线程已经拥有该对象 monitor 的所有权,那么当前线程将会被阻塞,直到正在执行的线程执行完毕,即 monitorexit 指令被执行,执行线程将释放 monitor锁并将计数器值设为0。
Synchronized对方法加锁
对方法的加锁并不依靠 monitorenter 和 monitorexit 指令,JVM可以从方法常量池中的方法表结构中的 ACC_SYNCHRONIZED 访问标志来区分一个方法是否是同步方法。当该方法被调用时,调用指令会检查方法的 ACC_SYNCHRONIZED 是否被设置,如果 ACC_SYNCHRONIZED 被设置了,则执行线程率先持有 monitor锁,然后再执行方法,执行结束(或者发生异常并抛到方法之外时)时释放monitor。
Synchronized锁升级原理
如开头所述,JDK1.6之前synchronize是标准的重量级锁(悲观锁),JDK1.6之后进行了大幅度优化,支持锁升级制度缓解加锁和解锁造成的性能浪费,锁的状态总共有四种,无锁、偏向锁、轻量级锁和重量级锁。随着锁的竞争,锁可以从偏向锁升级到轻量级锁,再升级到重量级锁,并且锁只能升级不能降级。
偏向锁
如果一个线程获得了锁,那么锁就进入偏向模式,此时 Mark Word 的结构也变为偏向锁结构,当这个线程再次请求锁时,无需再做任何同步操作即可再次获取锁,这样就省去了大量有关锁申请的操作,从而也就提供程序的性能。所以对于没有锁竞争的场合,偏向锁有很好的优化效果,但是在有多线程竞争锁的场合,偏向锁就失效了,这种场合下不应该使用偏向锁,偏向锁失败后,将会优先升级为轻量级锁。
偏向锁获取过程
- 访问MarkWord中偏向锁的标识是否设置成1,锁标志位是否为01,确认为可偏向状态;
- 如果为可偏向状态,则测试线程ID是否指向当前线程,如果是,直接执行同步代码,否则进入“步骤3”;
- 如果线程ID并未指向当前线程,则通过CAS操作竞争锁。如果竞争成功,则将Mark Word中线程ID设置为当前线程ID,然后执行步骤5;如果竞争失败,执行步骤4;
- 如果CAS获取偏向锁失败,则表示有竞争。当到达全局安全点(safepoint)时获得偏向锁的线程被挂起,偏向锁升级为轻量级锁,然后被阻塞在安全点的线程继续往下执行同步代码。
- 执行同步代码。
轻量级锁
当发生锁的竞争时,偏向锁就会升级为轻量级锁。轻量级锁是自旋锁实现。Synchronized升级轻量级锁的设计设计的思想是:如果持有锁的线程能够在很短时间内释放锁,则等待的线程可以不作上下文切换,只是进行数次自旋等一等,持有锁的线程释放锁后即可立即获取锁。因此避免上下文切换的开销(上面提到上下文切换比执行CPU指令的开销要大的多)。
轻量级锁的加锁过程
- 在代码进入同步块的时候,虚拟机首先在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的MarkWord的拷贝,官方称之为Displaced Mark Word。
- 拷贝对象头中的Mark Word复制到锁记录中。
- 虚拟机使用CAS操作尝试将对象的MarkWord更新为指向Lock Record的指针,并将Lock record里的owner指针指向object mark word。如果更新成功,则执行步骤4,否则执行步骤5。
- 更新动作成功,则该线程就拥有了该对象的锁,并且对象MarkWord的锁标志位设置为“00”,即表示此对象处于轻量级锁定状态
- 更新操作失败,虚拟机首先会检查对象的MarkWord是否指向当前线程的栈帧,如果是就说明当前线程已经拥有了这个对象的锁,则可以直接执行同步块。否则说明多个线程竞争锁,那么它就会自旋等待锁,一定次数后(自旋的开销超过进行上下文切换的开销)仍未获得锁对象。重量级线程指针指向竞争线程,竞争线程也会阻塞,等待轻量级线程释放锁后唤醒他。锁标志的状态值变为“10”,MarkWord中存储的就是指向重量级锁(互斥量)的指针,后面等待锁的线程也要进入阻塞状态,此时锁升级为重量级锁。
重量级锁
线程自旋是需要消耗CPU的,线程不能一直占用CPU做自旋动作。因此,需要设定一个自旋等待的最大时间。当持有锁的线程执行的时间超过自旋等待的最大时间扔没有释放锁,则竞争锁的线程会停止自旋进入阻塞状态,此时升级到重量级锁。
关于自旋最大次数(自旋最大等待时间)。jdk1.5默认为10次。在1.6引入了适应性自旋锁,即自旋的时间不在是固定的了,而是由前一次在同一个锁上的自旋时间以及锁的拥有者的状态来决定,基本认为是一个线程上下文切换的时间。
Synchronized升级到重量级锁阶段后,线程再竞争锁失败,则要执行上下问切换,并进入阻塞状态。等待锁资源被释放后,再由操作系统调起。
Synchrpnized和lock的区别
- synchronized是关键字,lock是一个类
- synchronized在发生异常时会自动释放锁,lock需要手动释放锁
- synchronized是可重入锁、非公平锁、不可中断锁,lock的ReentrantLock是可重入锁,可中断锁,可以是公平锁也可以是非公平锁
- synchronized是JVM层次通过监视器实现的,Lock是通过AQS实现的
Syntronized和ReentrantLock的底层区别和底层实现
synchronized
关键字和 ReentrantLock
类都是 Java 中常用的线程同步工具,它们都可以保证多个线程在共享资源时进行互斥访问,以避免数据竞争和不一致性问题。但是,它们在底层实现上存在一些区别。
在底层实现上,synchronized
是一种 JVM 内置的、基于对象内部锁的实现方式,而 ReentrantLock
则是一种可重入的、基于 AQS(AbstractQueuedSynchronizer)的实现方式。具体来说,它们的实现差异主要体现在以下几个方面:
- 锁的获取和释放:在
synchronized
中,锁的获取和释放是由 JVM 自动完成的,不需要用户手动干预;而在ReentrantLock
中,则需要用户显式地调用lock()
和unlock()
方法来获取和释放锁。 - 可中断性:在
synchronized
中,锁的获取是不可被中断的,一旦某个线程进入了同步代码块,其他线程只能等待直到该线程退出;而在ReentrantLock
中,则提供了可中断的锁获取方式,可以响应中断请求。 - 公平性:在
synchronized
中,并没有提供公平锁和非公平锁的选择,所有线程争夺同一个锁时,线程的获取顺序是随机的;而在ReentrantLock
中,则可以选择公平锁和非公平锁,从而控制线程获取锁的顺序。 - 条件变量:在
ReentrantLock
中,提供了条件变量的概念,可以更加灵活地实现线程间的协作。通过newCondition()
方法创建条件变量,然后可以使用await()
和signal()
方法来实现等待和通知操作。
总之,在底层实现上,synchronized
和 ReentrantLock
在锁的获取和释放、可中断性、公平性和条件变量等方面存在一些差异。
AQS锁
什么是AQS锁?
AQS是一个抽象类,可以用来构造锁和同步类,如ReentrantLock,Semaphore,CountDownLatch,CyclicBarrier。
AQS的原理是,AQS内部有三个核心组件,一个是state代表加锁状态初始值为0,一个是获取到锁的线程,还有一个阻塞队列。当有线程想获取锁时,会以CAS的形式将state变为1,CAS成功后便将加锁线程设为自己。当其他线程来竞争锁时会判断state是不是0,不是0再判断加锁线程是不是自己,不是的话就把自己放入阻塞队列。这个阻塞队列是用双向链表实现的
可重入锁的原理就是每次加锁时判断一下加锁线程是不是自己,是的话state+1,释放锁的时候就将state-1。当state减到0的时候就去唤醒阻塞队列的第一个线程。
为什么AQS使用的双向链表?
因为有一些线程可能发生中断 ,而发生中断时候就需要在同步阻塞队列中删除掉,这个时候用双向链表方便删除掉中间的节点
有哪些常见的AQS锁
AQS分为独占锁和共享锁
ReentrantLock(独占锁):可重入,可中断,可以是公平锁也可以是非公平锁,非公平锁就是会通过两次CAS去抢占锁,公平锁会按队列顺序排队
Semaphore(信号量):设定一个信号量,当调用acquire()时判断是否还有信号,有就获取一个信号量,没有就阻塞等待其他线程释放信号量,当调用release()时释放一个信号量,唤醒阻塞线程。
应用场景:允许多个线程访问某个临界资源时,如上下车,买卖票
CountDownLatch(倒计数器):给计数器设置一个初始值,当调用CountDown()时计数器减一,当调用await() 时判断计数器是否归0,不为0就阻塞,直到计数器为0。
应用场景:启动一个服务时,主线程需要等待多个组件加载完毕,之后再继续执行
CyclicBarrier(循环栅栏):给计数器设置一个目标值,当调用await() 时会计数+1并判断计数器是否达到目标值,未达到就阻塞,直到计数器达到目标值
应用场景:多线程计算数据,最后合并计算结果的应用场景
CAS锁
CAS锁可以保证原子性,思想是更新内存时会判断内存值是否被别人修改过,如果没有就直接更新。如果被修改,就重新获取值,直到更新完成为止。这样的缺点是
- 只能支持一个变量的原子操作,不能保证整个代码块的原子操作
- CAS频繁失败导致CPU开销大
- ABA问题:线程1和线程2同时去修改一个变量,将值从A改为B,但线程1突然阻塞,此时线程2将A改为B,然后线程3又将B改成A,此时线程1将A又改为B,这个过程线程2是不知道的,这就是ABA问题,可以通过版本号或时间戳解决
sleep()和wait()的区别
- wait()是Object的方法,sleep()是Thread类的方法
- wait()会释放锁,sleep()不会释放锁
- wait()要在同步方法或者同步代码块中执行,sleep()没有限制
- wait()要调用notify()或notifyall()唤醒,sleep()自动唤醒
yield()和join()区别
yield()调用后线程进入就绪状态
A线程中调用B线程的join(),则在线程B执行完之前线程A进入阻塞状态,等待线程B执行完之后线程A才会执行
线程池
线程池的作用
线程池的主要作用就是线程复用,线程资源管理,控制操作系统的最大并发数,以保证系统的高效(通过线程资源复用实现)且安全(通过控制最大线程并发数实现)地运行。
线程池优点:
- 重复利用线程,降低线程创建和销毁带来的资源消耗
- 统一管理线程,线程的创建和销毁都由线程池进行管理
- 提高响应速度,线程创建已经完成,任务来到可直接处理,省去了创建时间
线程池的分类
线程池类型 | 用途 | 适用场景 |
---|---|---|
Executors.newFixedThreadPool | 创建固定线程数的线程池,使用的是LinkedBlockingQueue无界队列,线程池中十几线程数永远不会变化 | 适用于可以预测线程数量的业务中,或者服务器负载较重,对线程数有严格限制的场景 |
Executors.newSingleThreadExecutor | 创建只有一个线程的线程池,使用的是LinkedBlokingQueue无界队列,线程池中实际线程数只有一个 | 适用于需要保证顺序执行各个任务,并且在任意时间点,不会同时有多个线程的场景 |
Executors.newCachedThreadPool | 创建可供缓存的线程池,该线程池中的线程空闲时间超过60s会自动销毁,使用的是SynchronousQueue特殊无界队列 | 适用于创建一个可无限扩大的线程池,服务器负载压力较轻,执行时间短,任务多的场景 |
Executors.newScheduledThreadPool | 创建可供调度使用的线程池(可延时启动,定时启动),使用的是DelayWorkQueue无界延时队列 | 适用于需要多个后台线程执行周期任务的场景 |
Executors.newWorkStealingPool | JDK1.8提供的线程池,底层使用的是ForkJoinPool实现,创建一个拥有多个任务队列的线程池,可以减少连接数,创建当前可用cpu核数线程来并行执行任务 | 适用于大耗时,可并行执行的场景 |
线程池的核心组件
- 线程池管理器:用于创建并管理线程池
- 工作线程:线程池中执行具体任务的线程
- 任务接口:用于定义工作线程的调度和执行策略,只有线程实现了该接口,线程中的任务才能够被线程池调度。
- 任务队列:存放待处理的任务,新的任务将会不断被加入队列中,执行完成的任务将会从队列中移除。
线程池七大参数
- corePoolSize:核心线程数量,线程池中会存在这么多个线程,当线程数量(包含空闲线程)少于corePoolSize的时候,会优先创建新线程,可以设置allowCoreThreadTimeOut=true来让核心线程池中线程也移除
- maximumPoolSize:线程池的最大容量,线程池中的线程数量不得超过这么多个,除非阻塞队列设置为无界的
- keepAliveTime:空闲线程存活时间,线程空闲超过这个时间的时候就会销毁
- unit:keepAliveTime的时间单位(空闲线程存活时间的单位),有7种值:纳秒,微秒,毫秒,秒,分钟,小时,天。
- workQueue:线程工作队列,阻塞队列,线程池从这个队列中取线程,可以设置的队列类型(容量为:capacity):
ArrayBlockingQueue:有界阻塞队列,当线程数量n:corePoolSize <= n < maximumPoolSize 且 n >= capacity :创建新线程处理任务 当:n >= maximumPoolSize 且 n >= capacity 拒绝线程
LinkedBlockingQueue:无界队列,maximumPoolSize不起作用,会一直创建线程
SynchronousQuene:不缓存任务,直接调度执行,线程数超过 maximumPoolSize 则直接拒绝线程
PriorityBlockingQueue:带优先级的线程队列 - handler:任务拒绝策略,线程数量达到maximumPoolSize时的策略,默认提供了4种:
AbortPolicy:直接丢弃并抛出异常
CallerRunsPolicy:线程池没有关闭则直接调用线程的run方法
DiscardPolicy:直接丢弃任务
DiscardOldestPolicy:丢弃最早的任务,并尝试把当前任务加入队列 - threadFactory:创建线程时使用的工厂,可以对线程进行统一设置,如是否守护线程、线程名等
如何创建线程池
总体来说有两种方法去创建线程池:
- 通过 ThreadPoolExecutor 创建的线程池;
- 通过 Executors 创建的线程池。
具体来说线程池的创建⽅式总共包含以下 7 种(其中 6 种是通过 Executors 创建的, 1 种是通过ThreadPoolExecutor 创建的):
1. Executors.newFixedThreadPool:创建⼀个固定⼤⼩的线程池,可控制并发的线程数,超出的线程会在队列中等待;
2. Executors.newCachedThreadPool:创建⼀个可缓存的线程池,若线程数超过处理所需,缓存⼀段时间后会回收,若线程数不够,则新建线程
3. Executors.newSingleThreadExecutor:创建单个线程数的线程池,它可以保证先进先出的执⾏顺序;
4. Executors.newScheduledThreadPool:创建⼀个可以执⾏延迟任务的线程池;
5. Executors.newSingleThreadScheduledExecutor:创建⼀个单线程的可以执⾏延迟任务的线程池;
6. Executors.newWorkStealingPool:创建⼀个抢占式执⾏的线程池(任务执⾏顺序不确定)【JDK1.8 添加】。
7. ThreadPoolExecutor:最原始的创建线程池的⽅式,它包含了 7 个参数可供设置。
一般来说不建议使用Executors创建线程池
- 固定的配置参数:使用Executors创建线程池时,无法灵活地调整线程池的配置参数,例如核心线程数、最大线程数和阻塞队列长度等。这些参数对线程池的性能和稳定性有很大的影响,需要根据实际情况进行调整。
- 阻塞队列类型不适应:ThreadPoolExecutor默认使用LinkedBlockingQueue作为线程池的任务队列,这种队列可以无限制地添加新任务,但是当任务过多时,会导致内存溢出或者系统资源耗尽。此外,LinkedBlockingQueue使用锁来控制并发访问,可能会造成线程争用。
- 拒绝策略不明确:当线程池无法处理新的任务时,需要通过拒绝策略来处理,例如抛出异常或者将任务丢弃。但是在使用Executors创建线程池时,并没有指定拒绝策略,而是默认使用ThreadPoolExecutor.AbortPolicy,即抛出未检查异常RejectedExecutionException。
- 隐藏了ThreadPoolExecutor细节:使用Executors创建线程池时,没有办法直接访问到ThreadPoolExecutor对象,也就无法对线程池进行详细的配置和管理。
因此,在生产环境中,建议使用ThreadPoolExecutor类直接创建线程池,并根据实际情况设置相关的配置参数和拒绝策略。这样可以更好地控制线程池的性能、资源消耗和异常处理等方面。
ThreadPoolExecutor
通过使用ThreadPoolExecutor类的构造方法创建线程池对象。
- corePoolSize:核心线程数,即在没有任务需要执行时线程池中保留的线程数量。
- maximumPoolSize:线程池最大线程数,即允许创建的最大线程数。
- keepAliveTime:线程池中空闲线程等待新任务的最长时间。
- unit:等待时间的单位,通常为秒、毫秒等。
- workQueue:任务队列,用于存放待处理的任务。
- threadFactory:线程工厂,用于创建线程。
- handler:拒绝策略,当任务队列满了且线程池中的线程数达到最大值时,用于处理新提交的任务的拒绝方式。
- 提交任务:通过线程池对象的execute()方法提交需要执行的任务。线程池会根据自身的情况来选择适合任务执行的线程。
- 关闭线程池:当不再需要执行任务时,需要调用线程池的shutdown()或shutdownNow()方法来关闭线程池并释放资源。
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class MyThreadPool {
public static void main(String[] args) {
// 创建一个具有固定线程数的线程池,核心线程数为2,最大线程数为4
ExecutorService executor = Executors.newFixedThreadPool(2);
// 提交任务
executor.execute(new MyTask());
executor.execute(new MyTask());
executor.execute(new MyTask());
// 关闭线程池
executor.shutdown();
}
}
class MyTask implements Runnable {
@Override
public void run() {
System.out.println("执行任务...");
}
}
线程池的工作原理
Java 线程池的工作流程为:线程池刚被创建时,只是向系统申请一个用于执行线程队列和管理线程池的线程资源。在调用 execute() 添加一个任务时,线程池会按照以下流程执行任务。
- 如果正在运行的线程数量少于 corePoolSize (用户定义的核心线程数),线程池就会立刻创建线程并执行该线程任务。
- 如果正在运行的线程数量大于等于 corePoolSize ,那么该任务就加入workQueue 队列中等待执行。
- 在阻塞队列当中已满且正在执行的线程数量少于 maximumPoolSize 时,线程池将会创建非核心线程立刻执行该线程任务。
- 在阻塞队列已满,其正在执行的线程数量大于等于 maximumPoolSize 时,线程池将拒绝执行该线程任务并抛出 RejectExecutionExeception 异常。(也就是在这采用拒绝策略处理)
- 在线程执行完毕后,该任务将被从线程池队列中移除,线程池将从队列中取下一个线程任务继续执行。
- 在线程处于空闲状态的时间超过 keepAliveTime 时间的时候,正在运行的线程数量超过 corePoolSize ,那么该线程将会被认定为空闲线程并停止。因此在线程池中所有线程任务都执行完毕后,线程池就会收缩到 corePoolSize 大小。
具体的运行流程图:
线程池的创建原理
线程池是一种常用的线程管理技术,它可以优化线程的使用和管理,提高系统的性能和稳定性。线程池的创建过程主要包括以下几个步骤:
- 初始化线程池:首先需要确定线程池的初始大小、最大大小、线程存活时间等参数,并创建一个线程池对象。
- 创建线程队列:线程池中需要维护一个任务队列,用于存储待执行的任务。可以使用阻塞队列或者线程安全的队列实现任务队列。
- 创建线程池线程:根据线程池的初始大小,创建相应数量的工作线程,并将它们添加到线程池中。在创建线程时,需要设置线程的名称和状态等属性。
- 提交任务:当有新任务进入任务队列时,线程池会自动从空闲线程中选择一个线程来执行该任务,并将任务从队列中移除。如果没有空闲线程,那么该任务就会被暂时放在任务队列中,等待有空闲线程执行。
- 监控线程池:线程池需要定期检查工作线程的状态,并根据需要启动或关闭一定数量的线程。同时,还需要对任务队列进行监控,如果任务队列过长,也需要及时调整线程池的大小。
总之,线程池的创建过程需要初始化线程池参数、创建任务队列、创建工作线程、提交任务和监控线程池状态等步骤。线程池的创建可以通过手动编写代码实现,也可以使用 Java 提供的线程池框架来实现。
向线程池提交任务的过程
向线程池提交任务的过程包括以下几个步骤:
- 创建任务:首先,需要创建一个实现了Runnable或Callable接口的任务。这个任务可以是已封装在某个类中的方法,也可以是未经封装的方法或Lambda表达式。
- 调用线程池的submit()方法:接下来,需要调用线程池的submit()方法,并将任务作为参数传入。submit()方法会返回表示任务结果的Future对象。
- 线程池检查线程是否空闲:线程池会检查当前是否有空闲的线程可用。如果有,则将任务交给一个空闲的线程执行;如果没有,则将任务放入任务队列中等待处理。
- 线程执行任务:当有空闲的线程时,该线程将从任务队列中取出任务并执行。执行完毕后,该线程将继续保持空闲状态,等待下一次任务的分配。
- 返回任务结果:如果任务是Callable类型的,那么它会返回执行结果,线程池会将结果保存在Future对象中并返回给submit()方法调用者,以便获取任务的执行结果。
需要注意的是,线程池在执行任务时,可能会发生异常。这些异常可能来自于任务本身,也可能来自于线程池的运行时环境。因此,在使用线程池时,需要通过使用try-catch块捕获异常,以便及时处理并防止程序崩溃。此外,为了充分利用线程池的资源,需要合理设置线程池的大小和工作队列的长度,以避免出现资源浪费或任务堆积等问题。
核心线程和非核心线程的区别
线程池中的核心线程和非核心线程的区别在于,核心线程在线程池的生命周期内始终存在,并且即使它们处于空闲状态,也不会被回收。而非核心线程则根据设定的规则进行回收和创建。
具体来说,核心线程是指线程池中最小的可以一直存在的线程数量,如果线程池中没有任务需要执行,那么核心线程就处于等待状态。如果有新的任务提交到线程池中,核心线程就会立即处理任务,并始终保持在线程池中。而非核心线程是线程池中额外创建的线程,当任务数超过核心线程数时,线程池会创建非核心线程来处理任务,当任务数减少时,非核心线程会被回收,以避免占用过多的系统资源。
核心线程和非核心线程的区别,在于对于线程池的系统资源使用的控制策略不同。核心线程的存在是为了更快的响应请求,减少任务等待的时间,同时也能保证线程池的稳定性。而非核心线程的数量可以根据实际情况进行调整,以达到更好的资源利用率。
需要注意的是,线程池的实现方式可能会影响核心线程和非核心线程的概念和数量。在不同的线程池实现中,核心线程和非核心线程的定义、数量以及调整机制可能有所不同,因此需要根据具体情况进行应用和调整。
向线程池中添加任务的时候如何保证线程安全
- 使用线程安全的任务队列:当多个线程同时向线程池中添加任务时,线程池内部的任务队列需要是线程安全的。可以使用Java提供的ConcurrentLinkedQueue等线程安全的队列,或者通过手动加锁的方式来保证任务队列的线程安全。
- 同步提交任务:在进行任务提交时,可以使用synchronized关键字或其他并发控制工具来保证任务提交的同步性,防止多个线程同时提交任务造成的数据竞争。
保证并发安全的三大特性?
- 原子性:一次或多次操作在执行期间不被其他线程影响
- 可见性:当一个线程在工作内存修改了变量,其他线程能立刻知道
- 有序性:JVM对指令的优化会让指令执行顺序改变,有序性是禁止指令重排
volatile
volatile的作用
用于修饰变量,保证变量的可见性和有序性,不保证原子性。使用了 volatile 修饰变量后,在变量修改后会立即同步到主存中,每次用这个变量前会从主存刷新。这样其他线程就能够看到最新的值。
它可以保证操作 volatile
变量的线程对其他线程对该变量的修改是可见的,并且禁止将 volatile
变量与其他内存访问指令重排序。
volatile
变量不能保证原子性,它只能保证了在线程之间的可见性。例如 a++ 操作就不是线程安全的。
单例模式双重校验锁变量为什么使用 volatile 修饰
在单例模式中,双重校验锁是一种常用的线程安全实现方式。在这种实现方式中,需要使用volatile关键字来修饰单例对象的变量,以保证线程安全。
原因是,双重校验锁中的第一次判空操作可能会出现指令重排的情况,导致多个线程同时进入第一个if语句块中,从而创建多个实例。使用volatile关键字可以禁止指令重排,保证单例对象的初始化在多线程环境下的安全性。
下面是使用volatile关键字修饰单例对象变量的示例代码:
public class Singleton {
private static volatile Singleton instance;
private Singleton() {}
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}
在上面的代码中,instance变量使用了volatile关键字修饰,保证了其在多线程环境下的可见性和禁止指令重排。同时,双重校验锁的实现方式也保证了线程安全和单例对象的唯一性。
线程使用方式
- 继承 Tread 类
- 实现 Runnable 接口
- 实现 Callable 接口:带有返回值
- 线程池创建线程
ThreadLocal
ThreadLocal是什么
Thread Local为线程提供自己的变量副本,当多个线程访问一个公共变量时为了保证了安全性,一般分为两种方式,第一种方式就采用以时间换空间的方法来加锁实现,第二种就是采用Thread Local以空间换时间的方式,为每个线程创建一个属于自己的本地变量。各个线程之间互不干扰。ThreadLocal主要用于多线程并发场景下,保证线程之间变量的隔离性。
ThreadLocal的作用
ThreadLocal的作用主要有两种:
- 保存线程上下文信息。例如,在web应用程序中,当一个请求进来,我们需要将该请求的一些参数(如用户ID、Session ID等)绑定到该请求对应的线程上,这时就可以将对象存储到ThreadLocal对象中,从而保证该请求的线程中所有业务逻辑操作都可以访问这些参数,避免了在方法调用过程中多次传递参数的麻烦。
- 隐藏线程安全实现细节。例如,在Java中使用SimpleDateFormat格式化日期时,若多个线程同时调用该方法,就会出现线程安全问题。为了解决这个问题,一般采用ThreadLocal来存储SimpleDateFormat对象,并在需要格式化日期时获取该对象进行操作,这样就避免了线程安全问题的出现。
需要注意的是,由于ThreadLocal是与线程相关的,因此在线程池等场景中,使用ThreadLocal可能会导致内存泄漏或者数据混乱问题,因此在使用ThreadLocal时需要注意清理操作,以保证程序正常运行。
ThreadLocal的原理
原理是为每个线程创建变量副本,不同线程之间不可见,保证线程安全。每个线程内部都维护了一个Map,key为threadLocal实例,value为要保存的副本。
但是使用ThreadLocal会存在内存泄露问题,因为key为弱引用,而value为强引用,每次gc时key都会回收,而value不会被回收。所以为了解决内存泄漏问题,可以在每次使用完后删除value或者使用static修饰ThreadLocal,可以随时获取value
如何根据 CPU 核心数设计线程池线程数量
一般情况下,可以根据CPU核心数来确定线程池的线程数量。
对于CPU密集型任务,可以将线程池的线程数量设置为CPU核心数的 2 倍,以尽可能利用CPU的多核计算能力,同时避免过多的线程切换及线程间资源竞争等问题。
对于IO密集型任务,如果每个任务都需要大量的IO操作,那么可以根据实际测试结果来逐步增加线程池中的线程数,以达到最优性能。一般来说,可以将线程池的线程数量设置为CPU核心数的 1.5 倍到 2 倍之间。
除了考虑CPU核心数以外,还需要考虑系统负载、内存使用率、网络带宽等因素,以保证线程池的稳定性和可靠性。此外,线程池的任务队列长度和拒绝策略等也需要仔细设计,以充分发挥线程池的效能。
什么样的任务算是IO密集型,什么样的任务又算是CPU密集型
IO密集型任务是指任务需要频繁地进行输入输出操作(例如读写磁盘、网络通信等),而CPU占用率相对较低的任务。这类任务经常会由于等待IO操作完成而被阻塞,因此线程会让出CPU,等待IO操作完成之后再恢复执行,这种情况下线程池中可以使用较多的线程数,以充分利用CPU资源,并减少IO等待时间,提高系统的吞吐能力。常见的IO密集型任务包括文件操作、网络请求、数据库操作等。
CPU密集型任务是指任务需要频繁地进行数值计算、逻辑判断等操作,CPU占用率较高的任务。这类任务需要大量的CPU计算资源,如果线程数量过多,会导致CPU频繁切换,导致系统效率降低。因此,线程池中应该控制线程数,避免过多的线程竞争CPU资源,以提高整个系统的性能。常见的CPU密集型任务包括图像处理、大量数据的排序计算、加密解密等。
实际应用中,任务往往是复杂和多样化的,同时存在IO操作和CPU计算操作,因此任务本身也可能是IO密集型和CPU密集型的混合类型,需要根据任务的特点和系统环境等因素进行综合考虑,以确定线程池中的线程数量。
AtomicInteger的使用场景
AtomicInteger是一个提供原子操作的Integer类,使用CAS+volatile实来现线程安全的数值操作。
因为volatile禁止了jvm的排序优化,所以它不适合在并发量小的时候使用,只适合在一些高并发程序中使用
JVM篇
什么是JVM
-
jvm是一种跨平台的Java虚拟机
-
jvm包含一套字节码指令集,一组寄存器,一个栈,一个垃圾回收堆和一个存储方法域。
-
JVM屏蔽了与具体操作系统平台相关的信息,使Java程序只需生成在Java虚拟机上运行的目标代码(字节码),就可以在多种平台上不加修改地运行。
JVM在执行字节码时,实际上最终还是把字节码解释成具体平台上的机器指令执行。
具体定义为:所有变量都存在主存中,主存是线程共享区域;每个线程都有自己独有的工作内存,线程想要操作变量必须从主从中copy变量到自己的工作区,每个线程的工作内存是相互隔离的
由于主存与工作内存之间有读写延迟,且读写不是原子性操作,所以会有线程安全问题
jdk、jre、jvm
- JRE,Java 运行时环境,内部包含了JVM以及Java核心类库等运行Java程序的必要组件,计算机中只要安装了JRE就可以运行编译好的java程序。
- JDK,Java 开发工具包,内部包含了JRE,以及编译工具javac,打包工具jar,Java基础类库(Java API)等。
- JVM,是JRE的一部分。它是一个虚构出来的计算机,是通过在实际的计算机上仿真模拟各种计算机功能来实现的。
JVM有自己完善的硬件架构,如处理器、堆栈、寄存器等,还具有相应的指令系统。
Java语言最重要的特点就是跨平台运行。使用JVM就是为了支持与操作系统无关,实现跨平台。
JVM的组成(内存模型)
负责执行 Java 程序并提供跨平台能力。一般由以下三个主要组成部分构成:
- 类加载器:**负责将 Java 类文件加载到 JVM 中,并生成对应的 Class 对象。**类加载器会按照一定的委派模型(双亲委派模型)从指定路径搜索类文件,保证类的唯一性和安全性。
- 运行时数据区:**是 JVM 进行运行时数据存储的地方,包括方法区、堆、虚拟机栈、本地方法栈等。**每个线程都有自己的私有线程栈,用于保存线程执行方法过程中的局部变量和中间结果。堆被所有线程共享,用于存储对象实例和数组对象。
- 执行引擎:**负责执行在虚拟机中加载的字节码指令。**通过解释器、JIT 编译器和JNI三个组件协作实现,其中解释器逐条执行字节码指令,JIT 编译器将频繁执行的字节码编译成本地机器码以提高执行效率,JNI 提供了调用本地方法的能力。
除了以上三个主要组成部分之外,JVM 还包括垃圾收集器、即时编译器(Just-In-Time Compiler,JIT)等。
JDK堆内存结构
JVM的堆内存是Java程序在运行时所使用的内存区域,用于存放所有创建的Java对象实例和数组对象。它的结构包括新生代和老年代两部分,其中新生代在内存中又被分为三个不同的区域:Eden区、Survivor 0区和Survivor 1区。
新生代:Eden+S0+S1, S0 和 S1 大小相等, 新创建的对象都在新生代
通常情况下,对象首先被分配到Eden区中,当Eden区满了之后,再触发Minor GC将其中不再需要的对象清理掉并把存活的对象移动到S0中。当S0满了之后,再次触发Minor GC会将其中不再需要的对象清理掉,并把存活的对象移动到S1中。随着这个过程的不断重复,达到了一定的条件后,存活时间比较长的对象就会被移动到老年代。
老年代:经过年轻代多次垃圾回收存活下来的对象存在老年代中.
在1.7中,堆内存的总大小默认为物理内存的1/4或1G(取两者最小值),而其中老年代的大小默认为整个堆内存的2/3。因此,一般情况下,在JDK1.7中,老年代的位置就是堆内存的一部分,而具体位置则需要看JVM的具体实现。1.8将老年代中的对象放到了元数据区, 元空间并不是在堆内存中存储类信息等数据,而是直接使用本地内存,其大小默认为系统可用内存的1/4。因此不存在永久代这个概念。同时,在JDK1.8中,可以通过设置MaxMetaspaceSize参数来调整元空间的大小。
JVM运行时数据区(内存结构)
- 线程私有区:
- 虚拟机栈:每次调用方法都会在虚拟机栈中产生一个栈帧,每个栈帧中都有方法的参数、局部变量、方法出口等信息,方法执行完毕后释放栈帧
- 本地方法栈:为native修饰的本地方法提供的空间,在HotSpot中与虚拟机合二为一
- 程序计数器:保存指令执行的地址,方便线程切回后能继续执行代码
- 线程共享区:
- 堆内存:Jvm进行垃圾回收的主要区域,存放对象信息,分为新生代和老年代,内存比例为1:2,新生代的Eden区内存不够时发生MinorGC,老年代内存不够时发生FullGC
- 方法区:存放类信息、静态变量、常量、运行时常量池等信息。JDK1.8之前用持久代实现,JDK1.8后用元空间实现,元空间使用的是本地内存,而非在JVM内存结构中
部署时候JVM的内存分配
在启动 JVM 进程时,可以通过命令行参数调整 JVM 各个内存区域的大小,例如:
java -Xms256m -Xmx512m MyApplication
上述命令通过 -Xms
和 -Xmx
参数分别指定 JVM 最小和最大可用的堆内存大小,单位为 MB。在应用程序运行过程中,JVM 可以根据实际需要动态调整堆内存大小,以保证应用程序能够正常运行。
JVM 在分配堆内存时,采用的是垃圾回收算法,它会对内存中的不再使用的对象进行自动回收,以腾出更多的空间给新对象使用。在垃圾回收时,JVM 会通过标记清除、复制、标记整理等不同的方式来管理内存,以提高内存的利用率和效率。
JVM 还使用虚拟机栈、本地方法栈等内存区域来管理方法调用和异常处理等操作,并使用程序计数器来维护当前线程执行的位置。这些内存区域的大小也可以通过命令行参数进行调整。
在 Java 应用程序部署过程中,JVM 可以通过命令行参数来调整各个内存区域的大小,以满足应用程序运行时的内存需求。同时,JVM 还提供了一套垃圾回收机制,以自动管理内存并提高内存的利用率和效率。
JVM调优策略
调优的主要目标:减小GC的频率和Full GC的次数。
大部分情况下jvm都是不需要进行优化的,我们遇到的大部分问题比如GC频繁啥的,都是自己的代码bug导致的,一般来说的话都是可以通过代码修复来解决的,通常不需要动JVM,而且一般遇到需要优化jvm的场景都可以通过使用性能更好的垃圾回收器来解决问题(例如:CMS 升级到 G1,甚至 ZGC)。如果要进行jvm的调优的话,可以通过调整堆区大小的比例来合理使用内存资源,避免内存溢出和频繁的垃圾回收。
- 更大的年轻代必然致使更小的年老代,大的年轻代会延长普通GC的周期,但会增长每次GC的时间;小的年老代会致使更频繁的Full GC
- 更小的年轻代必然致使更大年老代,小的年轻代会致使普通GC很频繁,但每次的GC时间会更短;大的年老代会减小Full GC的频率
如何选择应该依赖应用程序对象生命周期的分布状况: 若是应用存在大量的临时对象,应该选择更大的年轻代;若是存在相对较多的持久对象,年老代应该适当增大。但不少应用都没有这样明显的特性。
内存溢出
什么是内存溢出
当程序需要申请内存的时候,由于没有足够的内存,此时就会抛出OutOfMemoryError,这就是内存溢出
内存溢出的几种情况
堆溢出,栈溢出,运行时常量池溢出,方法区溢出
堆溢出
堆是存放对象的地方,那么如果在堆中一直的创建对象而不被回收时,那么堆就会发生内存溢出。
解决方法:
借助工具进行内存分析,用visualVM工具分析堆快照,
- 如果发生内存泄漏
- 找出泄漏的对象
- 找到泄漏对象的GC Root
- 根据泄漏对象和GC Root找到导致内存泄漏的代码
- 想办法解除泄漏对象与GCRoot的连接
- 如果不存在泄漏
- 尝试增大jvm堆的内存大小
- 优化程序,减小对象的生命周期
栈溢出
栈溢出是因为方法调用次数过多,一般是递归不当造成
解决方法:通过修改代码逻辑来解决。
运行时常量池溢出
这里储存的是一些常量、字面量。如果运行时常量池内存不足,就会发生内存溢出。从jdk1.7开始,运行时常量池移动到了堆中,所以如果堆的内存不足,也会导致运行时常量池内存溢出。
解决方法:
- 使用内存分析工具定位并优化内存占用较高的类和常量,找出内存泄漏或不必要的对象引用,释放无用的内存。
- 增加方法区内存,通过增大方法区的大小,为常量池提供更多的空间。
- 优化代码逻辑,字符串在常量池中占用大量内存。避免创建过多重复的字符串对象,可以使用 String.intern() 方法来复用字符串常量池中现有的对象。
方法区溢出
方法区是存放类的信息,而且很难被gc,只要加载了大量类,就有可能引起方法区溢出
解决方法:
- 在应用服务器中建立一个共享lib库,把项目中常用重复的jar包存放在这里,项目从这里加载jar包,这样就会大大减少类加载的数量,方法区也“瘦身”了
- 如果实在不能瘦身类的话,那可以扩大方法区的容量,给jvm指定参数**-XX:MaxPermSize=xxxM**
内存溢出和内存泄漏的区别
- 内存泄漏是由于使用不当,把一部分内存“丢掉了”,导致这部分内存不可用。
- 比如当我们在堆中创建了对象,后来没有使用这个对象了,又没有把整个对象的相关引用设为null。此时垃圾收集器会认为这个对象是需要的,就不会清理这部分内存。这就会导致这部分内存不可用。
- 所以内存泄漏会导致可用的内存减少,进而会导致内存溢出。
内存泄漏
指程序中动态分配内存给一些临时对象,但是对象不会被GC所回收,它始终占用内存。即被分配的对象可达但已无用。
如果长生命周期的对象持有短生命周期的引用,就很可能会出现内存泄露
比如下面的代码,这里的object实例,其实我们期望它只作用于method1()方法中,且其他地方不会再用到它,但是,当method1()方法执行完成后,object对象所分配的内存不会马上被认为是可以被释放的对象,只有在Simple类创建的对象被释放后才会被释放,严格的说,这就是一种内存泄露。
public class Simple {
Object object;
public void method1(){
object = new Object();
//...其他代码
}
}
怎么解决上面的问题呢,加上下面的蓝色代码注释就好了
public class Simple {
Object object;
public void method1(){
object = new Object();
//...其他代码
// 蓝色代码注释开始
object = null;
// 蓝色代码注释结束
}
}
集合里面的内存泄漏
集合里面的数据都设置成null,但是集合内存还是存在的
比如下面的代码
因为你已经在下面的蓝色代码注释里面进行company=null了,所以下面的list集合里面的数据都是无用的了,但是此时list集合里面的所有元素都不会进行垃圾回收
package com.four;
import java.util.ArrayList;
import java.util.List;
public class Hello {
public static void main(String[] args) {
List<Company> list = new ArrayList<Company>();
int i=0;
for(int j=0;j<10;j++){
Company company = new Company();
company.setName("ali");
list.add(company);
// 蓝色代码注释开始
company = null;
// 蓝色代码注释结束
}
System.gc();
while(true){
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("已经测试了"+(++i)+"秒");
}
}
}
class Company {
private String name;
@Override
protected void finalize() throws Throwable {
super.finalize();
System.out.println("回收Comapny");
}
public void setName(String name) {
this.name = name;
}
public String getName() {
return name;
}
}
怎么解决上面的问题呢,就是把上面的list集合变量也变成null,比如加上下面的红色代码注释
package com.one.util;
import java.util.ArrayList;
import java.util.List;
public class Hello {
public static void main(String[] args) {
List<Company> list = new ArrayList<Company>();
int i = 0;
for (int j = 0; j < 10; j++) {
Company company = new Company();
company.setName("ali");
list.add(company);
// 蓝色代码注释开始
company = null;
// 蓝色代码注释结束
}
// 红色代码注释开始
list = null;
// 红色代码注释结束
System.gc();
while (true) {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("已经测试了" + (++i) + "秒");
}
}
}
class Company {
private String name;
@Override
protected void finalize() throws Throwable {
super.finalize();
System.out.println("回收Comapny");
}
public void setName(String name) {
this.name = name;
}
public String getName() {
return name;
}
}
使用remove()方法进行移除元素的时候,也可能会造成内存泄漏
就比如ArrayList里面的pop(),如果是下面的写法就会造成内存泄漏,因为下面的elementData[–size]这个元素移除之后,并没有进行设置成null
public E pop(){
if(size == 0)
return null;
else
return (E) elementData[size];
}
所以上面的代码应该变成下面这样,此时注意下面的蓝色代码注释里面的size值比下面的红色代码注释里面的size小1
public E pop(){
if(size == 0)
return null;
else{
// 红色代码注释开始
E e = (E) elementData[--size];
// 红色代码注释结束
// 蓝色代码注释开始
elementData[size] = null;
// 蓝色代码注释结束
return e;
}
}
连接没有关闭也会泄漏
比如数据库连接(dataSourse.getConnection()),网络连接(socket)和io连接,这些链接在使用的时候,除非显式的调用了其close()方法(或类似方法)将其连接关闭,否则是不会自动被GC回收的。其实原因依然是长生命周期对象持有短生命周期对象的引用。所以我们经常在网上看到在连接调用结束的时候要进行调用close()进行关闭,这样可以回收不用的内存对象,增加可用内存。
JVM有哪些垃圾回收算法?
- 标记清除算法:标记-清除算法采用从根集合(GC Roots)进行扫描,对存活的对象进行标记,标记完毕后,再扫描整个空间中未被标记的对象,进行回收,如下图所示。标记-清除算法不需要进行对象的移动,只需对不存活的对象进行处理,在存活对象比较多的情况下极为高效,但由于标记-清除算法直接回收不存活的对象,因此会造成内存碎片。
- 复制算法: 将堆内存分为两块,只使用其中一块存储对象,进行垃圾回收时,从根集合(GC Roots)中扫描活动对象,先将活动对象复制到另一块区域,然后清空之前的区域。用在新生代。
- 标记整理算法: 标记-整理算法采用标记-清除算法一样的方式进行对象的标记,但在清除时不同,在回收不存活的对象占用的空间后,会将所有的存活对象往左端空闲空间移动,并更新对应的指针。标记-整理算法是在标记-清除算法的基础上,又进行了对象的移动,因此成本更高,但是却解决了内存碎片的问题。
典型垃圾回收器
垃圾回收器类型 | 使用的回收算法 | 特点 | 适用区域 |
---|---|---|---|
Serial | 复制算法 | 单线程回收器,简单高效,停顿时间长,服务端程序几乎不用,一般用于客户端程序 | 年轻代 |
ParNew | 复制算法 | 多线程回收器,降低了停顿时间,但增加了线程上下文切换的消耗 | 年轻代 |
Parallel Scavenge | 复制算法 | 多线程回收器,追求高吞吐量,高效利用CPU,可以控制最大垃圾回收时间 | 年轻代 |
Serial Old | 标记整理算法 | 单线程回收器,简单高效,停顿时间长,主要用于客户端程序。或者与其他回收器配合使用 | 老年代 |
Parallel Old | 标记整理算法 | 多线程回收器,追求高吞吐量 | 老年代 |
CMS | 标记整理算法 | 并发回收器,可以与用户线程同时进行,高并发,低停顿,追求最短回收停顿时间,CPU占用比较高,响应速度快,停顿时间短。需要 Serial Old 来避免并发失败的风险 | 老年代 |
G1 | 标记整理 + 复制算法 | 并发回收器,可以与用户线程同时进行,基于Region的内存布局形式,高并发,低停顿,可控的回收停顿时间 | 年轻代 + 老年代 |
Serial 垃圾回收器
Serial 垃圾回收器是一个单线程回收器,它进行垃圾回收时,必须暂停其他所有用户线程,直到它回收结束。Serial 主要用于新生代垃圾回收,采用复制算法实现。
服务端程序几乎不会使用 Serial 回收器,服务端程序一般会分配较大的内存,可能几个G,如果使用 Serial 回收器,由于是单线程,标记、清理阶段就会花费很长的时间,就会导致系统较长时间的停顿。
Serial 一般用在客户端程序或占用内存较小的微服务,因为客户端程序一般分配的内存都比较小,可能几十兆或一两百兆,回收时的停顿时间是完全可以接受的。而且 Serial 是所有回收器里额外消耗内存最小的,也没有线程切换的开销,非常简单高效。
Serial Old 垃圾回收器
Serial Old 是 Serial 的老年代版本,它同样是一个单线程回收器,主要用于客户端程序。Serial Old 用于老年代垃圾回收,采用标记-整理算法实现。
Serial Old 也可以用在服务端程序,主要有两种用途:一种是与 Parallel Scavenge 回收器搭配使用,另外一种就是作为 CMS 回收器发生失败时的后备预案,在并发收集发生 Concurrent Mode Failure 时使用。
ParNew 垃圾回收器
ParNew 回收器实质上是 Serial 回收器的多线程并行版本,除了同时使用多条线程进行垃圾收集之外,其余的行为都与 Serial 回收完全一致,控制参数、回收算法、对象分配规则等都是一致的。除了 Serial 回收器外,目前只有 ParNew 回收器能与 CMS 回收器配合工作,ParNew 是激活CMS后的默认新生代回收器。
ParNew 默认开启的回收线程数与处理器核心数量相同,在处理器核心非常多的环境中,可以使用 -XX: ParallelGCThreads 参数来限制垃圾回收的线程数。
Parallel Scavenge 垃圾回收器
Parallel Scavenge是新生代回收器,采用复制算法实现,也是能够并行回收的多线程回收器。Parallel Scavenge 主要关注可控制的吞吐量,其它回收器的关注点是尽可能地缩短垃圾回收时的停顿时间。吞吐量就是处理器用于运行程序代码的时间与处理器总消耗时间的比值,总消耗时间等于运行程序代码的时间加上垃圾回收的时间。
Parallel Scavenge 提供了两个参数用于精确控制吞吐量:
- -XX: MaxGCPauseMillis:控制最大垃圾回收停顿时间,参数值是一个大于0的毫秒数,回收器将尽力保证垃圾回收花费的时间不超过这个值。
- -XX: GCTimeRatio:直接设置吞吐量大小,参数值是一个大于0小于100的整数,就是垃圾回收时间占总时间的比率。默认值为 99,即允许最大1%(即1/(1+99))的垃圾收集时间。
Parallel Scavenge 还有一个参数 -XX: +UseAdaptiveSizePolicy,当设置这个参数之后,就不需要人工指定新生代的大小、Eden与Survivor区的比例等细节参数了,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量。
Parallel Old 垃圾回收器
Parallel Old 是 Parallel Scavenge 的老年代版本,支持多线程并发回收,采用标记-整理算法实现。在注重吞吐量或者处理器资源较为稀缺的场合,可以优先考虑 Parallel Scavenge 加 Parallel Old 这个组合。
CMS 垃圾回收器
CMS是一种以获取最短回收停顿时间为目标的回收器。CMS 用于老年代垃圾回收,采用标记-清除算法实现。
CMS 回收过程:
CMS 垃圾回收总体分为四个步骤:
- 初始标记(会STW):初始标记需要 Stop The World,初始标记仅仅只是标记一下 GC Roots 能直接关联到的对象,速度很快。
- 并发标记:并发标记阶段就是从 GC Roots 的直接关联对象开始遍历整个对象引用链的过程,这个过程耗时较长但是不需要停顿用户线程,可以与垃圾回收线程一起并发运行。
- 重新标记(会STW):重新标记需要 Stop The World,重新标记阶段是为了修正并发标记期间,因程序继续运行而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间通常会比初始标记阶段稍长一些,但也远比并发标记阶段的时间短。
- 并发清除:清除阶段是清理删除掉标记阶段判断的已经死亡的对象,由于不需要移动存活对象,所以这个阶段也是可以与用户线程同时并发进行的。
最耗时的并发标记和并发清除阶段是和用户线程并发进行的,总体上来说,CMS 回收过程是与用户线程一起并发执行的,是一款并发低停顿的回收器。
触发CMS的条件
CMS GC 在实现上分成 foreground collector 和 background collector。
- foreground collector:foreground collector 触发条件比较简单,一般是遇到对象分配但空间不够,就会直接触发 GC,来立即进行空间回收。采用的算法是 mark sweep,不压缩。
- background collector:background collector 是通过 CMS 后台线程不断的去扫描,过程中主要是判断是否符合 background collector 的触发条件,一旦有符合的情况,就会进行一次 background 的 collect。每次扫描过程中,先等 CMSWaitDuration 时间(默认2秒),然后再判断是否满足 background collector 的触发条件。
background collector 的触发条件:
- 并行 Full GC,如调用了 System.gc()
- 未配置 UseCMSInitiatingOccupancyOnly 时,会根据统计数据动态判断是否需要进行一次 CMS GC。如果预测 CMS GC 完成所需要的时间大于预计的老年代将要填满的时间,则进行 GC。这些判断是需要基于历史的 CMS GC 统计指标,第一次 CMS GC 时,统计数据还没有形成,是无效的,这时会跟据 Old Gen 的使用占比来判断是否要进行 GC。
- 未配置 UseCMSInitiatingOccupancyOnly 时,判断 CMS 的使用率大于 CMSBootstrapOccupancy(默认50%)时触发 Old GC。
老年代内存使用率阀值超过 CMSInitiatingOccupancyFraction(默认为92%)时触发 OldGC,CMSInitiatingOccupancyFraction 默认值为 -1,没有配置时默认阀值为 92%。
未配置 UseCMSInitiatingOccupancyOnly 时,因为分配对象时内存不足导致的扩容等触发GC
CMS参数设置
在没有配置 UseCMSInitiatingOccupancyOnly 参数的情况下,会多出很多种触发可能,一般在生产环境会配置 UseCMSInitiatingOccupancyOnly 参数,配了之后就不用设置 CMSBootstrapOccupancy 参数了。
CMSInitiatingOccupancyFraction 设置得太高将会很容易导致频繁的并发失败,性能反而降低;太低又可能频繁触发CMS background collector,一般在生产环境中应根据实际应用情况来权衡设置。
-XX:+UseConcMarkSweepGC
-XX:CMSInitiatingOccupancyFraction=92
-XX:+UseCMSInitiatingOccupancyOnly
-XX:CMSBootstrapOccupancy=92
-xx:CMSWaitDuration=2000
CMS 的问题:
-
并发回收导致CPU资源紧张:在并发阶段,它虽然不会导致用户线程停顿,但却会因为占用了一部分线程而导致应用程序变慢,降低程序总吞吐量。CMS默认启动的回收线程数是:(CPU核数 + 3)/ 4,当CPU核数不足四个时,CMS对用户程序的影响就可能变得很大。
-
无法清理浮动垃圾:在CMS的并发标记和并发清理阶段,用户线程还在继续运行,就还会伴随有新的垃圾对象不断产生,但这一部分垃圾对象是出现在标记过程结束以后,CMS无法在当次收集中处理掉它们,只好留到下一次垃圾收集时再清理掉。这一部分垃圾称为“浮动垃圾”。
-
并发失败(Concurrent Mode Failure):由于在垃圾回收阶段用户线程还在并发运行,那就还需要预留足够的内存空间提供给用户线程使用,因此CMS不能像其他回收器那样等到老年代几乎完全被填满了再进行回收,必须预留一部分空间供并发回收时的程序运行使用。默认情况下,当老年代使用了 92% 的空间后就会触发 CMS 垃圾回收,这个值可以通过 -XX: CMSInitiatingOccupancyFraction 参数来设置。
- 这里会有一个风险:要是CMS运行期间预留的内存无法满足程序分配新对象的需要,就会出现一次“并发失败”(Concurrent Mode Failure),这时候虚拟机将不得不启动后备预案:Stop The World,临时启用 Serial Old 来重新进行老年代的垃圾回收,这样一来停顿时间就很长了。
-
内存碎片问题:CMS是一款基于“标记-清除”算法实现的回收器,这意味着回收结束时会有内存碎片产生。内存碎片过多时,将会给大对象分配带来麻烦,往往会出现老年代还有很多剩余空间,但就是无法找到足够大的连续空间来分配当前对象,而不得不提前触发一次 Full GC 的情况。
-
为了解决这个问题,CMS收集器提供了一个 -XX:+UseCMSCompactAtFullCollection 开关参数(默认开启),用于在 Full GC 时开启内存碎片的合并整理过程,由于这个内存整理必须移动存活对象,是无法并发的,这样停顿时间就会变长。还有另外一个参数:
-XX:CMSFullGCsBeforeCompaction
,这个参数的作用是要求CMS在执行过若干次不整理空间的 Full GC 之后,下一次进入 Full GC 前会先进行碎片整理(默认值为0,表示每次进入 Full GC 时都进行碎片整理)。
-
G1 垃圾回收器
G1回收器采用面向局部收集的设计思路和基于Region的内存布局形式,**是一款主要面向服务端应用的垃圾回收器。**G1 在JDK9 之后成为服务端模式下的默认垃圾回收器,取代了 Parallel Scavenge 加 Parallel Old 的默认组合,而 CMS 被声明为不推荐使用的垃圾回收器。G1从整体来看是基于 标记-整理 算法实现的回收器,但从局部(两个Region之间)上看又是基于 标记-复制 算法实现的。
可预期的回收停顿时间
G1 可以指定垃圾回收的停顿时间,通过-XX: MaxGCPauseMillis
参数指定,默认为 200 毫秒。这个值不宜设置过低,否则会导致每次回收只占堆内存很小的一部分,回收器的回收速度逐渐赶不上对象分配速度,导致垃圾慢慢堆积,最终占满堆内存导致 Full GC 反而降低性能。
G1之所以能建立可预测的停顿时间模型,是因为它将 Region 作为单次回收的最小单元,即每次回收到的内存空间都是 Region 大小的整数倍,这样可以有计划地避免在整个Java堆中进行全区域的垃圾回收。G1会去跟踪各个Region的垃圾回收价值,价值即回收所获得的空间大小以及回收所需时间的经验值,然后在后台维护一个优先级列表,每次根据用户设定允许的回收停顿时间,优先处理回收价值收益最大的那些Region。这种使用Region划分内存空间,以及具有优先级的区域回收方式,保证了G1回收器在有限的时间内得到尽可能高的回收效率。
由于Region数量比传统回收器的分代数量明显要多得多,因此G1回收器要比其他的传统垃圾回收器有着更高的内存占用负担。G1至少要耗费大约相当于Java堆容量10%至20%的额外内存来维持回收器工作。
G1内存布局
G1不再是固定大小以及固定数量的分代区域划分,而是把堆划分为多个大小相等的Region,每个Region的大小默认情况下是堆内存大小除以2048,因为JVM最多可以有2048个Region,而且每个Region的大小必须是2的N次冥。每个Region的大小也可以通过参数 -XX:G1HeapRegionSize
设定,取值范围为1MB~32MB,且应为2的N次幂。
G1也有新生代和老年代的概念,不过是逻辑上的区分,每一个 Region 都可以根据需要,作为新生代的Eden空间、Survivor空间,或者老年代空间。新生代默认占堆内存的5%,但最多不超过60%,这个默认值可以使用 -XX:G1NewSizePercent
参数设置,最大值可以通过 -XX:G1MaxNewSizePercent
参数设置。新生代 Region 的数量并不是固定的,随着使用和垃圾回收会动态的变化。同样的,G1新生代也有 eden 区和 survivor 区的划分,也可以通过 -XX:SurvivorRatio
设置其比例,默认为8。
大对象Region
Region中还有一类特殊的 Humongous 区域,专门用来存储大对象,而不是直接进入老年代的Region。G1认为一个对象只要大小超过了一个Region容量的一半就判定为大对象。而对于那些超过了整个Region容量的超级大对象,将会被存放在N个连续的 Humongous Region 之中,G1的大多数行为都把 Humongous Region 作为老年代的一部分来看待。
G1 回收过程
G1 回收器的运作过程大致可分为四个步骤:
- 初始标记(会STW):仅仅只是标记一下 GC Roots 能直接关联到的对象,并且修改TAMS指针的值,让下一阶段用户线程并发运行时,能正确地在可用的Region中分配新对象。这个阶段需要停顿线程,但耗时很短,而且是借用进行Minor GC的时候同步完成的,所以G1收集器在这个阶段实际并没有额外的停顿。
- 并发标记:从 GC Roots 开始对堆中对象进行可达性分析,递归扫描整个堆里的对象图,找出要回收的对象,这阶段耗时较长,但可与用户程序并发执行。当对象图扫描完成以后,还要重新处理在并发时有引用变动的对象。
- 最终标记(会STW):对用户线程做短暂的暂停,处理并发阶段结束后仍有引用变动的对象。
- 清理阶段(会STW):更新Region的统计数据,对各个Region的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划,可以自由选择任意多个Region构成回收集,然后把决定回收的那一部分Region的存活对象复制到空的Region中,再清理掉整个旧Region的全部空间。这里的操作涉及存活对象的移动,必须暂停用户线程,由多条回收器线程并行完成的。
G1新生代回收
根据G1的内存布局举个例子,例如:设置堆内存 4G,就是 4096M,除以2048个Region,每个Region就是2M;新生代期初占5%,就是约100个Region,此时eden区占80个Region,两个survivor区各占10个Region;不过随着对象的在新生代分配,属于新生代的Region会不断增加,eden和survivor对应的Region也会不断增加。直到新生代占用60%,也就是约1200个Region,就会触发新生代的GC,这个时候就会采用复制算法将eden对应Region存活的对象复制到 from survivor 对应的Region。只不过这里会根据用户期望的停顿时间来选取部分最有回收价值的Region进行回收。
G1混合回收
G1有一个参数,-XX:InitiatingHeapOccupancyPercent,它的默认值是45%,就是如果老年代占堆内存45%的Region的时候,此时就会触发一次年轻代+老年代的混合回收。
混合回收阶段,因为我们设定了最大停顿时间,所以 G1 会从新生代、老年代、大对象里挑选一些 Region,保证指定的时间内回收尽可能多的垃圾。所以 G1 可能一次无法将所有Region回收完,它就会执行多次混合回收,先停止程序,执行一次混合回收回收掉一些Region,接着恢复系统运行,然后再次停止系统运行,再执行一次混合回收回收掉一些Region。可以通过参数 -XX:G1MixedGCCountTarget 设置一次回收的过程中,最后一个阶段最多执行几次混合回收,默认值是8次。通过这种反复回收的方式,避免系统长时间的停顿。
G1还有一个参数 -XX:G1HeapWastePercent,默认值是 5%。就是在混合回收时,Region回收后,就会不断的有新的Region空出来,一旦空闲出来的Region数量超过堆内存的5%,就会立即停止混合回收,即本次混合回收就结束了。
G1还有一个参数 -XX:G1MixedGCLiveThresholdPercent,默认值是85%。意思是回收Region的时候,必须存活对象低于Region大小的85%时才可以进行回收,一个Region存活对象超过85%,就不必回收它了,因为要复制大部分存活对象到别的Region,这个成本是比较高的。
回收失败
- 并发回收失败:在并发标记阶段,用户线程还在并发运行,程序继续运行就会持续有新对象产生,也需要预留足够的空间提供给用户线程使用。G1为每一个Region设计了两个名为TAMS(Top at Mark Start)的指针,把Region中的一部分空间划分出来用于并发回收过程中的新对象分配,并发回收时新分配的对象地址都必须要在这两个指针位置以上。G1默认在这个地址以上的对象是被隐式标记过的,即默认它们是存活的,不纳入回收范围。如果内存回收的速度赶不上内存分配的速度,跟CMS会发生并发失败一样,G1也要被迫暂停程序,导致 Full GC 而产生长时间 Stop The World。
- 混合回收失败:混合回收阶段,年轻代和老年代都是基于复制算法进行回收,复制的过程中如果没有空闲的Region了,就会触发失败。一旦失败,就会停止程序,然后采用单线程标记、清理和内存碎片整理,然后空闲出来一批Region。这个过程是很慢的,因此要尽量调优避免混合回收失败的发生。
JVM中有哪些引用
强引用:new的对象。类似 Object obj = new Object()
这类引用,只要强引用还存在,垃圾收集器永远不会回收掉被引用的对象。哪怕内存溢出也不会回收。
软引用:一些还有用但并非必须的对象。对于软引用关联着的对象,在系统将要发生内存溢出异常之前,将会把这些对象列进回收范围之中进行第二次回收。如果这次回收后还没有足够的内存,才会抛出内存溢出异常。只有内存不足的时候才会被回收。
弱引用:也是用来描述非必需对象的,但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生之前。当垃圾收集器工作时,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。每次垃圾回收都会回收。
虚引用:是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。它的作用是能在这个对象被收集器回收时收到一个系统通知。必须配合引用队列使用,一般用于追踪垃圾回收动作
GC(垃圾回收)
什么是GC
Java中的GC就是对内存的GC。
Java的内存管理实际上就是对象的管理,其中包括对象的分配和释放。
Java对象的分配,程序员可以通过new关键字,Class的new-Instance方法等来显示的分配;而对象的释放,程序员不能实时的进行释放,这就需要GC来完成。
GC的回收机制和原理
GC的目的实现内存的自动释放,使用可达性分析法判断对象是否可回收,采用了分代回收思想,
将堆分为新生代、老年代,新生代中采用复制算法,老年代采用整理算法,当新生代内存不足时会发生minorGC,老年代不足时会发送fullGC
哪些内存需要回收
JVM的内存结构包括五大区域:程序计数器、虚拟机栈、本地方法栈、堆区、方法区。其中程序计数器、虚拟机栈、本地方法栈3个区域随线程而生、随线程而灭,因此这几个区域的内存分配和回收都具备确定性,就不需要过多考虑回收的问题,因为方法结束或者线程结束时,内存自然就跟随着回收了。而Java堆区和方法区则不一样,这部分内存的分配和回收是动态的,正是垃圾收集器所需关注的部分。
垃圾收集器在对堆区和方法区进行回收前,首先要确定这些区域的对象哪些可以被回收,哪些暂时还不能回收,这就要用到判断对象是否存活的算法!
GC如何判断对象可以被回收?
引用计数法
引用计数是垃圾收集器中的早期策略。在这种方法中,堆中每个对象实例都有一个引用计数。当一个对象被创建时,就将该对象实例分配给一个变量,该变量计数设置为1。当任何其它变量被赋值为这个对象的引用时,计数加1(a = b,则b引用的对象实例的计数器+1),但当一个对象实例的某个引用超过了生命周期或者被设置为一个新值时,对象实例的引用计数器减1。任何引用计数器为0的对象实例可以被当作垃圾收集。当一个对象实例被垃圾收集时,它引用的任何对象实例的引用计数器减1。
- 优点:引用计数收集器可以很快的执行,交织在程序运行中。对程序需要不被长时间打断的实时环境比较有利。
- 缺点:无法检测出循环引用。如父对象有一个对子对象的引用,子对象反过来引用父对象。这样,他们的引用计数永远不可能为0。
可达性分析法
可达性分析算法是从离散数学中的图论引入的,程序把所有的引用关系看作一张图,从一个节点GC ROOT开始,寻找对应的引用节点,搜索过的路径称为引用链,找到这个节点以后,继续寻找这个节点的引用节点,当所有的引用节点寻找完毕之后,剩余的节点则被认为是没有被引用到的节点,即无用的节点,无用的节点将会被判定为是可回收的对象。
在Java语言中,可作为GC Roots的对象包括下面几种:
- 虚拟机栈中引用的对象(栈帧中的本地变量表);
- 方法区中类静态属性引用的对象;
- 方法区中常量引用的对象;
- 本地方法栈中JNI(Native方法)引用的对象。
即使在可达性分析算法中不可达的对象,也并非是“非死不可”,这时候它们暂时处于“缓刑”阶段,要真正宣告一个对象死亡,至少要经历两次标记过程。
- 第一次标记:如果对象在进行可达性分析后发现没有与GC Roots相连接的引用链,那它将会被第一次标记;
- 第二次标记:第一次标记后接着会进行一次筛选,筛选的条件是此对象是否有必要执行
finalize()
方法。在finalize()
方法中没有重新与引用链建立关联关系的,将被进行第二次标记。
第二次标记成功的对象将真的会被回收,如果对象在finalize()
方法中重新与引用链建立了关联关系,那么将会逃离本次回收,继续存活。
方法区如何判断是否需要回收
方法区主要回收的内容有:废弃常量和无用的类。对于废弃常量也可通过引用的可达性来判断,但是对于无用的类则需要同时满足下面3个条件:
- 该类所有的实例都已经被回收,也就是Java堆中不存在该类的任何实例;
- 加载该类的
ClassLoader
已经被回收; - 该类对应的
java.lang.Class
对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
GC的种类
JVM常见的GC包括三种:Minor GC,Major GC与Full GC
注意:JVM在进行GC时,并非每次都对所有区域(新生代,老年代,方法区)一起回收的,大部分时候回收的都是指新生代
Minor GC(年轻代gc)
触发机制:
- 当年轻代空间不足时,就会触发Minor GC,这里的年轻代空间不足指的是Eden区满,Survivor区满不会触发GC(每次Minor GC 会清理年轻代的内存)
因为Java对象大多具备朝生夕死的特新,所以Minor GC非常频繁,一般回收速度也比较快.
Minor GC会引发STW,暂停其他用户线程,等垃圾回收结束,用户线程才恢复运行
Major GC(老年代gc)
一般出现Major GC,经常会伴随至少一次的Minor GC(但非绝对的,在Parallel Scavenge收集器的收集策略里就有直接进行Major GC的策略选择过程)
触发机制:
- 也就是老年代空间不足时,会先尝试触发Minor GC,如果之后空间还不足,则触发Major GC
PS:
- Major GC的速度一般会比Minor GC慢10倍以上,STW的时间更长
- 如果Major GC后,内存还不足,就报OOM了
- Major GC的速度一般会比Minor GC慢10倍以上
Full GC(全局gc)
触发Full GC执行的情况有如下五种:
- 调用System.gc(),系统建议执行Full GC,但是不必然执行
- 老年代空间不足
- 方法区(元空间)空间不足
- 通过Minor GC后进入老年代的平均大小大于老年代的可用内存
- 由Eden区,from区向to区复制时,对象大小大于to区可用内存,则把对象转存到老年代,并且老年代的可用内存小于该对象大小(那要是GC之后还不够呢?那还用说:OOM异常送上)
另外要特别注意: full GC是开发或调优中尽量要避免的
频繁发生FullGC
原因
FullGC一般只会在老年代空间不足的时候发生
解决方案
- 优化代码逻辑:检查代码中是否存在内存泄漏或不必要的对象创建和保留。如果发现这种情况,请考虑修复它们。
- 调整垃圾回收参数:可以调整垃圾回收器的参数,如新生代、老年代、Eden 空间和 Survivor 空间等的大小和比例,以便更好地匹配应用程序的负载。
- 增加内存容量:如果您的服务器有足够的资源,增加内存容量可能是减少 Full GC 发生的最简单方法。
需要注意的是,针对具体情况采用相应的解决方法。在实际操作中,可以通过监控 jvm 状态和 GC 日志来确定是否需要进行调整,并根据需要进行调整。
持续GC会导致什么问题
- 应用程序性能下降:持续的GC会让JVM花费更多的时间来执行垃圾回收操作,从而导致应用程序的性能下降。这是因为在GC过程中,JVM需要暂停所有应用程序线程,直到GC操作完成。因此,越频繁和长时间的GC操作,意味着应用程序需要等待更长的时间才能执行下一步操作。
- 增加系统开销:长时间的GC操作需要消耗大量的CPU和内存资源,这会进一步增加系统开销。如果系统容量不足,则可能会导致应用程序崩溃或挂起。
- 内存泄漏:持续的GC可能会掩盖内存泄漏的存在,例如当应用程序频繁GC时,看似释放了很多对象,但实际上只是重新分配了内存空间,而没有回收无用的对象。这就可能导致内存泄漏问题。
类加载过程
- 加载 :把字节码通过二进制的方式转化到方法区中的运行数据区
- 连接:
- 验证:验证字节码文件的正确性。
- 准备:正式为类变量在方法区中分配内存,并设置初始值,final类型的变量在编译时已经赋值了
- 解析:将常量池中的符号引用(如类的全限定名)解析为直接引用(类在实际内存中的地址)
- 初始化 :执行类构造器(不是常规的构造方法),为静态变量赋初值并初始化静态代码块。
类加载器
类加载器ClassLoader,它是一个抽象类,ClassLoader的具体实例负责把java字节码读取到JVM当中,ClassLoader还可以定制以满足不同字节码流的加载方式,比如从网络加载、从文件加载。ClassLoader的负责整个类装载流程中的“加载”阶段。
从Java虚拟机的角度来讲,只存在两种不同的类加载器:一种是启动类加载器(Bootstrap ClassLoader),这个类加载器使用C++语言实现,是虚拟机自身的一部分;另一种就是所有其他的类加载器,这些类加载器都由Java语言实现,独立于虚拟机外部,并且全都继承自抽象类 java.lang.ClassLoader。
启动类加载器(Bootstrap ClassLoader): 负责加载存放在 <JAVA_HOME>\lib 目录中的核心类库,如rt.jar、resources.jar等(或者被 -Xbootclasspath 参数所指定的路径中的,并且是虚拟机识别的类库)。这个加载器是 C++ 编写的,随着JVM启动。
扩展类加载器(Extension ClassLoader): 负责加载<JAVA_HOME>\lib\ext 目录中的类库,(同样也可以用 java.ext.dirs 系统变量来指定路径)。
应用程序类加载器(Application ClassLoader): 负责加载用户类路径 classpath 上所有的 jar 包和 .class 文件。
自定义类加载器: 可以支持一些个性化的扩展功能。
双亲委派机制
双亲委派模型的工作过程
JVM在加载类时默认采用的是双亲委派机制。
如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的夹杂请求最终都应该传送到顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去加载。
双亲委派模型的好处
该机制可以避免类被重复加载,还可以避免系统级别的类被篡改
破坏双亲委派模型
为什么打破双亲委派机制
有时我们需要多次加载同名目录下的类,比如:当我们在Tomcat上部署多个服务时,不同服务上可能依赖了不同版本的第三方jar,如果此时使用双亲委派机制加载类,会导致多个服务中第三方jar只加载一次,其他服务中的其他版本jar将不会生效,导致请求结果异常。为了避免这种情况,我们需要打破双亲委派机制,不再让父类[应用类加载器]加载,而是为每个服务创建自己的子类加载器。
如何打破双亲委派机制
打破双亲委派有两种方式:(1)不委派【SPI机制】;(2)向下委派。
Tomcat使用父类加载器加载了公用的jar,对于非公用的jar则使用自己的子类加载器进行单独加载。打破双亲委派需要重写findLoadedClass()方法。
JVM类初始化顺序
父类静态代码块和静态成员变量 -> 子类静态代码块和静态成员变量 -> 父类代码块和普通成员变量 -> 父类构造方法 -> 子类代码块和普通成员变量 -> 子类构造方法
JVM内存参数
-Xmx[]:堆空间最大内存
-Xms[]:堆空间最小内存,一般设置成跟堆空间最大内存一样的
-Xmn[]:新生代的最大内存
-xx:[survivorRatio=3]:eden区与from+to区的比例为3:1,默认为4:1
-xx[use 垃圾回收器名称]:指定垃圾回收器
-xss:设置单个线程栈大小
一般设堆空间为最大可用物理地址的80%
为什么需要把Java堆分代?
经研究,不同对象的生命周期不同,70%-99%的对象是临时对象
分代可以优化GC性能,如果没有分代,那所有的对象都在一个区域,当需要进行GC的时候就需要把所有的对象都进行遍历,GC的时候会暂停用户线程,那么这样的话,就非常消耗性能,然而大部分对象都是朝生夕死的,何不把活得久的朝生夕死的对象进行分代呢,这样的话,只需要对这些朝生夕死的对象进行回收就行了.总之,容易死的区域频繁回收,不容易死的区域减少回收.
Mysql篇
MyIsAm和InnoDB的区别
MyIsAm是MySQL5.5之前默认的存储引擎,之后为InnoDB
InnoDB有三大特性,分别是事务、外键、行级锁,这些都是MyIsAm不支持的,
另外InnoDB是聚簇索引,MyIsAm是非聚簇索引,
InnoDB不支持全文索引,MyIsAm支持
InnoDB支持自增和MVCC模式的读写,MyIsAm不支持
MyIsAM的访问速度一般InnoDB快,差异在于innodb的mvcc、行锁会比较消耗性能,还可能有回表的过程(先去辅助索引中查询数据,找到数据对应的key之后,再通过key回表到聚簇索引树查找数据)
Mysql事务
mysql事务特性
原子性:一个事务内的操作统一成功或失败
一致性:事务前后的数据总量不变
隔离性:事务与事务之间相互不影响
持久性:事务一旦提交发生的改变不可逆
我举个例子:
A向B转账500,转账成功,A扣除500元,B增加500元,原子操作体现在要么都成功,要么都失败
在转账的过程中,数据要一致,A扣除了500,B必须增加500
在转账的过程中,隔离性体现在A像B转账,不能受其他事务干扰
在转账的过程中,持久性体现在事务提交后,要把数据持久化(可以说是落盘操作)
Mysql事务靠什么保证
原子性:由undo log日志保证,他记录了需要回滚的日志信息,回滚时撤销已执行的sql
一致性:由其他三大特性共同保证,是事务的目的
隔离性:由MVCC + 锁保证
持久性:由redolog日志和内存保证,mysql修改数据时内存和redolog会记录操作,宕机时可恢复
MVCC
MVCC是多版本并发控制,为每次事务生成一个新版本数据,每个事务都由自己的版本,从而不加锁就决绝读写冲突,这种读叫做快照读。只在读已提交和可重复读中生效。
MVCC的实现原理:
- undo log:记录了数据历史版本
- readView:事务进行快照读时动态生成产生的视图,记录了当前系统中活跃的事务id,控制哪个历史版本对当前事务可见
- 隐藏字段:
- DB_TRC_ID: 最近修改记录的事务ID
- DB_Roll_PTR: 回滚指针,配合undolog指向数据的上一个版本
- DB_Row_ID:当表中没有设置主键,且当前表没有唯一字段的时候,mysql会自动生成一个隐藏的rollid作为表的主键。
Mysql事务的隔离级别
并发事务会产生脏读、不可重复读、幻读问题,这时需要用隔离级别来控制
MySQL支持四种隔离级别,分别有:
- 读未提交:最低的隔离级别,允许读取尚未提交的数据变更,可能造成脏读、不可重复读、幻读。
- 读已提交:允许读取并发事务已经提交的数据,可以避免脏读,但是可能造成不可重复、幻读。
- 可重复读:对同一字段多次读取的结果都是一致的,除非本身事务修改,可以避免脏读和不可重复读,但是可能造成幻读。
- 串行化:最高的隔离级别,完全服从ACID的隔离级别,所以的事务依次执行,可以避免脏读、不可重复读、幻读。
mysql默认的隔离级别:可重复读
并发事务带来的问题
- 脏读: 当一个事务正在访问数据并且对数据进行了修改,而这种修改还没有提交到数据库中,这时另外一个事务也访问了这个数据,因为这个数据是还没有提交的数据,那么另外一个事务读到的这个数据是“脏数据”,依据“脏数据”所做的操作可能是不正确的。
- 不可重复读:比如在一个事务内多次读同一数据。在这个事务还没有结束时,另一个事务也访问该数据。那么,在第一个事务中的两次读数据之间,由于第二个事务的修改导致第一个事务两次读取的数据可能不太一样。这就发生了在一个事务内两次读到的数据是不一样的情况,因此称为不可重复读。
- 幻读:幻读与不可重复读类似。它发生在一个事务(T1)读取了几行数据,接着另一个并发事务(T2)插入了一些数据时。在随后的查询中,第一个事务(T1)就会发现多了一些原本不存在的记录,就好像发生了幻觉一样,所以称为幻读。
不可重复读和幻读区别:
不可重复读的重点是修改,幻读的重点是新增或者删除。
例子1(同样的条件,你读取过的数据,再次读取的时候不一样了):事务1中的A先生读取自己的工资是1000的操作还没结束,事务2的B先生就修改了A先生的工资为2000,A先生再次读取自己工资的时候就变成2000了,这就是不可重复读。
例子2(同样的条件,第1次和第2次读取出来的记录条数不一样):假如某工资表中工资大于3000的有4人,事务1读取了所有工资大于3000的人,总共查询到4条记录,这是事务2又插入了一条工资大于3000的记录,事务1再次读取查询到的记录就是5条了,这就是幻读。
怎么解决并发事务的问题呢?MySQL的默认隔离级别是?
解决方案是对事务进行隔离
默认的隔离级别是:可重复读
MySQL支持四种隔离级别,分别有:
- 未提交读:它解决不了刚才提出的所有问题,一般项目中也不用这个。
- 读已提交:它能解决脏读的问题的,但是解决不了不可重复读和幻读。
- 可重复读:它能解决脏读和不可重复读,但是解决不了幻读,这个也是mysql默认的隔离级别。
- 可串行化:它可以解决刚才提出来的所有问题,但是由于让是事务串行执行的,性能比较低。
快照读和当前读
快照读:读取的是当前数据的可见版本,可能是会过期数据,不加锁的select就是快照都
当前读:读取的是数据的最新版本,并且当前读返回的记录都会上锁,保证其 他事务不会并发修改这条记录。如update、insert、delete、select for undate(排他锁)、select lockin share mode(共享锁) 都是当前读
Mysql索引
Mysql索引是什么
它是帮助MySQL高效获取数据的数据结构,主要是用来提高数据检索的效率,降低数据库的IO成本,同时通过索引列对数据进行排序,降低数据排序的成本,也能降低了CPU的消耗
MySQL索引的底层结构
MySQL的默认的存储引擎InnoDB采用的B+树的数据结构来存储索引,选择B+树的主要的原因是:第一阶数更多,路径更短,第二个磁盘读写代价B+树更低,非叶子节点只存储指针,叶子阶段存储数据,第三是B+树便于扫库和区间查询,叶子节点是一个双向链表
MySQL有哪些索引
主键索引:一张表只能有一个主键索引,主键索引列不能有空值和重复值
唯一索引:唯一索引不能有相同值,但允许为空
普通索引:允许出现重复值
组合索引:对多个字段建立一个联合索引,减少索引开销,遵循最左匹配原则
全文索引:myisam引擎支持,通过建立倒排索引提升检索效率,广泛用于搜索引擎
什么是回表查询
其实跟刚才介绍的聚簇索引和非聚簇索引是有关系的,回表的意思就是通过二级索引找到对应的主键值,然后再通过主键值找到聚集索引中所对应的整行数据,这个过程就是回表
【备注:如果面试官直接问回表,则需要先介绍聚簇索引和非聚簇索引】
MySQL什么是聚簇索引什么是非聚簇索引
聚簇索引主要是指数据与索引放到一块,B+树的叶子节点保存了整行数据,有且只有一个,一般情况下主键在作为聚簇索引的
非聚簇索引指的是数据与索引分开存储,B+树的叶子节点保存对应的主键,可以有多个,一般我们自己定义的索引都是非聚簇索引
Mysql聚簇索引和非聚簇索引的区别
聚簇索引:聚簇索引的叶子节点存放的是主键值和数据行;辅助索引(在聚簇索引上创建的其它索引)的叶子节点存放的是主键值或指向数据行的指针。
- 优点:根据索引可以直接获取值,所以他获取数据更快;对于主键的排序查找和范围查找效率更高;
- 缺点:如果主键值很大的话,辅助索引也会变得很大;如果用uuid作为主键,数据存储会很稀疏;修改主键或乱序插入会让数据行移动导致页分裂;所以一般我们定义主键时尽量让主键值小,并且定义为自增和不可修改。
非聚簇索引(辅助索引):叶子节点存放的是数据行地址,先根据索引找到数据地址,再根据地址去找数据
他们都是b+数结构
索引下推
什么是索引下推
索引下推 ( ICP),是 MySQL5.6 版本的新特性,它可以在对联合索引遍历过程中,对索引中包含的所有字段先做判断,过滤掉不符合条件的记录之后再回表,能有效的减少回表次数
索引下推的适用条件
- 只支持 select。
- 当需要访问全表时,ICP 用于 range,ref,eq_ref 和 ref_or_null 访问类型。
- ICP 可用于 InnoDB 和 MyISAM 表,包括分区的 InnoDB 和 MyISAM 表。(5.6 版本不适用分区表查询,5.7 版本后可以用于分区表查询)。
- 对于 InnDB 引擎只适用于二级索引(也叫辅助索引),因为 InnDB 的聚簇索引会将整行数据读到 InnDB 的缓冲区,这样一来索引条件下推的 主要目的 减少IO次数就失去了意义。因为数据已经在内存中了,不再需要去读取了。
- 在虚拟生成列上创建的辅助索引不支持 ICP(注:InnoDB 支持虚拟生成列的辅助索引)。
- 使用了子查询的条件无法下推。
- 使用存储过程或函数的条件无法下推(因为存储引擎没有调用存储过程或函数的能力)。
索引未命中
如果MySQL索引未命中,我们可以考虑以下几个方面进行优化:
- 创建合适的索引:首先需要确认该表是否有合适的索引。如果没有或者已有的索引效果不好,可以通过创建新的索引来解决。需要注意的是创建索引也会增加写入和维护成本,需要权衡利弊,避免创建过多无用索引。
- 优化SQL语句:可以对SQL语句进行调整,尽量避免在条件中使用OR或者NOT、IN等操作符,这样会导致索引无法使用。可以将多个OR条件拆分成多个UNION子句,或者使用EXISTS、JOIN等方式进行查询。
- 调整MySQL配置参数:可以通过调整MySQL的配置参数来优化性能,包括调整缓存大小、最大连接数、查询缓存等参数。
- 分析表结构和数据量:如果表结构设计不合理、数据量过大等原因也会导致索引未命中。可以进行分析表结构和数据量,对表结构进行调整、拆分等操作。
Mysql哪些情况索引会失效
- where条件中有or,除非所有查询条件都有索引,否则失效
- like查询用%开头,索引失效
- 索引列参与计算,索引失效
- 违背最左匹配原则,索引失效
- 索引字段发生类型转换,索引失效
- mysql觉得全表扫描更快时(数据少),索引失效
什么叫覆盖索引
索引覆盖就是⼀个SQL在执⾏时,可以利⽤ 索引来快速查找 ,并且此SQL所要查询的字段在当前索引对应的字段中都包含了,那么就表示此 SQL⾛完索引后不⽤回表了 ,所需要的字段都在当前索引的 叶⼦节点 上存在,可以直接作为结果返回了
- 在索引数据结构中,通过索引值可以直接找到要查询字段的值,而不需要通过主键值回表查询,那么就叫覆盖索引
- 查询的字段被使用到的索引树全部覆盖到
Mysql加索引的注意事项
MySQL加索引时需要注意以下几点:
- **不要将过多的列加入索引。**创建索引会占用磁盘空间,如果创建了太多的索引,不仅会造成磁盘空间的浪费,还会降低更新表的速度。
- **对频繁出现在WHERE子句中的列进行索引。**索引可以提高查询的速度,如果对一些很少使用的列进行索引,反而会降低性能。
- **数据库表中的主键自动加索引,所以无需重复添加主键索引。**另外,在使用自增长ID作为主键时,最好将其设置为整数类型并设为无符号数,这样可以增大可表示的范围。
- 经常同时使用的多个列可以将它们加入组合索引,以提高查询速度。
- 避免在索引列上进行函数操作,这样会使索引失效,查询效率大打折扣。
- 对于查询结果只有少数几行的表,如记录数小于1000行的表,应该尽量避免使用索引,因为即使不使用索引,查询速度也很快。
慢查询
如何定位慢查询
在MySQL中也提供了慢日志查询的功能,可以在MySQL的系统配置文件中开启这个慢日志的功能,并且也可以设置SQL执行超过多少时间来记录到一个日志文件中,我记得上一个项目配置的是2秒,只要SQL执行的时间超过了2秒就会记录到日志文件中,我们就可以在日志文件找到执行比较慢的SQL了。
如何分析慢查询
如果一条sql执行很慢的话,我们通常会使用mysql自动的执行计划explain来去查看这条sql的执行情况,比如在这里面可以通过key和key_len检查是否命中了索引,如果本身已经添加了索引,也可以判断索引是否有失效的情况,第二个,可以通过type字段查看sql是否有进一步的优化空间,是否存在全索引扫描或全盘扫描,第三个可以通过extra建议来判断,是否出现了回表的情况,如果出现了,可以尝试添加索引或修改返回字段来修复
explain执行计划查询出的字段
explain select name from test where name = 'b';
id列
id列的编号是 select 的序列号,有几个 select 就有几个id,并且id的顺序是按 select 出现的顺序增长的。
id列越大执行优先级越高,id相同则从上往下执行,id为NULL最后执行
select_type列
select_type 表示对应行是简单还是复杂的查询
- simple:简单查询。查询不包含子查询和union
- primary:复杂查询中最外层的 select
- subquery:包含在 select 中的子查询(不在 from 子句中)
- derived:包含在 from 子句中的子查询。MySQL会将结果存放在一个临时表中,也称为派生表
- union:在 union 中的第二个和随后的 select
table列
这一列表示 explain 的一行正在访问哪个表。
当 from 子句中有子查询时,table列是 格式,表示当前查询依赖 id=N 的查询,于是先执行 id=N 的查 询。
当有 union 时,UNION RESULT 的 table 列的值为<union1,2>,1和2表示参与 union 的 select 行id
type列
关联类型或访问类型,即MySQL决定如何查找表中的行
依次从最优到最差分别为:system > const > eq_ref > ref > range > index > ALL 一般来说,得保证查询达到range级别,最好达到ref
NULL:mysql能够在优化阶段分解查询语句,在执行阶段用不着再访问表或索引。例如:在索引列中选取最小值,可 以单独查找索引来完成,不需要在执行时访问表
const, system:mysql能对查询的某部分进行优化并将其转化成一个常量(可以看show warnings 的结果)。用于 primary key 或 unique key 的所有列与常数比较时,所以表最多有一个匹配行,读取1次,速度比较快.system是 const的特例,表里只有一条元组匹配时为system
eq_ref:primary key 或 unique key 索引的所有部分被连接使用 ,最多只会返回一条符合条件的记录。这可能是在 const 之外最好的联接类型了,简单的 select 查询不会出现这种 type
ref:相比 eq_ref,不使用唯一索引,而是使用普通索引或者唯一性索引的部分前缀,索引要和某个值相比较,可能会 找到多个符合条件的行。
range:范围扫描通常出现在 in(), between ,> ,<, >= 等操作中。使用一个索引来检索给定范围的行。
index:扫描全索引就能拿到结果,一般是扫描某个二级索引,这种扫描不会从索引树根节点开始快速查找,而是直接 对二级索引的叶子节点遍历和扫描,速度还是比较慢的,这种查询一般为使用覆盖索引,二级索引一般比较小,所以这 种通常比ALL快一些
ALL:即全表扫描,扫描你的聚簇索引的所有叶子节点。通常情况下这需要增加索引来进行优化了
possible_keys列
查询可能使用哪些索引来查找
出现 possible_keys 有列,而 key 显示 NULL 的情况,这种情况是因为表中数据不多,mysql认为索引 对此查询帮助不大,选择了全表查询
如果该列是NULL,则没有相关的索引。在这种情况下,可以通过检查 where 子句看是否可以创造一个适当的索引来提 高查询性能,然后用 explain 查看效果
如果用了索引但是为null有可能是表数据太少innodb认为全表扫描更快
key列
这一列显示mysql实际采用哪个索引来优化对该表的访问。
如果没有使用索引,则该列是 NULL。如果想强制mysql使用或忽视possible_keys列中的索引,在查询中使用 force index、ignore index。
key_len列
显示mysql在索引里使用的字节数,通过这个值可以算出具体使用了索引中的哪些列
key_len计算规则如下:
- 字符串,char(n)和varchar(n),n为字符数
- char(n):一个数字或字母占1个字节,一个汉字占3个字节,存汉子就是3n字节
- 如果存汉字则长度是 3n + 2 字节,加的2字节用来存储字符串长度,因为 varchar是变长字符串
- 数值类型:
- tinyint:1字节
- smallint:2字节
- int:4字节
- bigint:8字节
- 时间类型
- date:3字节
- timestamp:4字节
- datetime:8字节
- 如果字段允许为 NULL,需要1字节记录是否为 NULL
- 索引最大长度是768字节,当字符串过长时,mysql会做一个类似左前缀索引的处理,将前半部分的字符提取出来做索 引。
ref列
这一列显示了在key列记录的索引中,表查找值所用到的列或常量,常见的有:const(常量),字段名
rows列
是mysql估计要读取并检测的行数,注意这个不是结果集里的行数
Extra列
这一列展示的是额外信息
Using index:使用覆盖索引
Using where:使用 where 语句来处理结果,并且查询的列未被索引覆盖
Using index condition:查询的列不完全被索引覆盖,where条件中是一个前导列的范围;
Using temporary:mysql需要创建一张临时表来处理查询.出现这种情况一般是要进行优化的,首先是想到用索 引来优化
Using filesort:将用外部排序而不是索引排序,数据较小时从内存排序,否则需要在磁盘完成排序。
MySQL如何做慢SQL优化
开启慢查询日志( SET GLOBAL long_query_time=阈值;超过阈值的sql就会记录到慢查询日志当中),或查看执行计划(explain+SQL)。慢查询优化如下:
- 分析sql语句,是否加载了不需要的数据列
- 分析sql执行计划,字段有没有索引,索引是否失效,是否用对索引
- 使用复杂查询时,尽量使用关联查询来代替子查询,并且最好使用内连接
- orderby查找时使用索引进行排序,否则的话需要进行回表,然后在排序缓冲区中进行排序。
- groupby查询时,同样要建立联合索引,避免使用到临时表
- 分页查询时,如果偏移量太大,比如要查询一百万条数据后的十条记录,可以使用主键+子查询的方式,避免进行全表扫描
- 使用count函数时直接使用count的话
count(*)
的效率最高,也可以额外创建一张表去统计不同表中的数据行数,但维护麻烦。count(*)
或count(唯一索引)
或count(数字)
:表中总记录数,count(字段)不会统计null - 在写update语句时,where条件要添加使用索引,否则会锁会从行锁升级为表锁
- 表中数据是否太大,是不是要分库分表
SQL注入
所谓的sql注入就是通过某种方式将恶意的sql代码添加到输入参数中,然后传递到sql服务器使其解析并执行的一种攻击手法
注入产生的原因是后台服务器在接收相关参数时未做好过滤直接带入到数据库中查询,导致可以拼接执行SQL语句
SQL注入的危害
- 数据库信息泄露:用户隐私信息泄露
- 数据库被恶意操作:数据库服务器被攻击,数据库的系统管理员账户被篡改
SQL注入的防范措施
- 普通用户与系统管理员用户的权限要有严格的区分
- 加强对用户输入的验证,识别恶意内容,过滤掉某些危险语句。
- 对密码之类的信息进行加密
Mybatis解决sql注入
mybatis 提供了两种方式#{}和${}。
- #{value}在预处理时,会把参数部分用一个占位符 ? 替代,其中 value 表示接受输入参数的名称。能有效解决 SQL 注入问题
- 表示使用拼接字符串,将接受到参数的内容不加任何修饰符拼接在 S Q L 中,使用 {}表示使用拼接字符串,将接受到参数的内容不加任何修饰符拼接在 SQL 中,使用 表示使用拼接字符串,将接受到参数的内容不加任何修饰符拼接在SQL中,使用{}拼接 sql,将引起 SQL 注入问题。
SQL 语句调优
- 多表连接的字段上需要建立索引,这样可以极大提高表连接的效率.
- where 条件字段上需要建立索引,但 Where 条件上不要使用运算函数,以免索引失效.
- 排序字段上, 因为排序效率低, 添加索引能提高查询效率.
- 优化 insert 语句: 批量列插入数据要比单个列插入数据效率高.
- 优化 order by 语句: 在使用 order by 语句时, 不要使用 select *,select 后面要查有索引的列, 如果一条 sql 语句中对多个列进行排序, 在业务允许情况下, 尽量同时用升 序或同时用降序.
- 优化 group by 语句: 在我们对某一个字段进行分组的时候, Mysql 默认就进行了排序, 但是排序并不是我们业务所需的, 额外的排序会降低效率. 所以在用的时候可以禁止 排序, 使用 order by null 禁用. select age, count(*) from emp group by age order by null ,还有就是对分组返回的字段尽量建立联合索引以避免使用到临时表
- 尽量避免子查询, 可以将子查询优化为 join 多表连接查询
内连接的优势
用外连接的话连接顺序是固定死的,比如left join,他必须先对左表进行全表扫描,然后一条条到右表去匹配;而内连接的话mysql会自己根据查询优化器去判断用哪个表做驱动。
子查询的话同样也会对驱动表进行全表扫描,所以尽量用小表做驱动表。
MySQL整个查询的过程
-
客户端向 MySQL 服务器发送一条查询请求
-
服务器首先检查查询缓存,如果命中缓存,则返回存储在缓存中的结果。否则进入下一阶段
-
服务器进行 SQL 解析、预处理、再由优化器生成对应的执行计划
-
MySQL 根据执行计划,调用存储引擎的 API 来执行查询
-
将结果返回给客户端,同时缓存查询结果
注意:只有在8.0之前才有查询缓存,8.0之后查询缓存被去掉了
B和B+树
二叉树:索引字段有序,极端情况会变成链表形式
AVL树:树的高度不可控
B树:控制了树的高度,但是索引值和data都分布在每个具体的节点当中,若要进行范围查询,要进行多次回溯,IO开销大
B+树:非叶子节点只存储索引值,叶子节点再存储索引+具体数据,从小到大用链表连接在一起,范围查询可直接遍历不需要回溯
B+树的结构
B+树是一种常用的平衡查找树,它的结构如下:
- 每个节点最多有M棵子树。除根节点和叶子节点外,每个节点至少有⌈M/2⌉棵子树。
- 有K个关键字,指针数比关键字数多1,即每个节点有K+1个指针。
- 所有叶子节点在同一层,可以看做一个有序链表,存储了从小到大排序后的所有数据。
- 每个非叶子节点不存储数据,仅作为索引使用。
B+树结构的特点如下:
- B+树的高度较低,可以快速查找数据。
- B+树采用广度优先遍历方式,节点之间的访问具有良好的局部性,适合磁盘等存储设备上的数据存储和索引。
- B+树的查询、插入和删除操作时间复杂度均为O(logN),其中N为数据元素个数。
- B+树支持范围查找和范围删除。
B树和B+树的区别
第一:在B树中,非叶子节点和叶子节点都会存放数据,而B+树的所有的数据都会出现在叶子节点,在查询的时候,B+树查找效率更加稳定
第二:在进行范围查询的时候,B+树效率更高,因为B+树都在叶子节点存储,并且叶子节点是一个双向链表
B+树相对于B树的优势在于减少了非叶子节点的存储开销,并且所有数据都存储在叶子节点上,可以减少磁盘I/O次数,优化磁盘访问性能。
用B+树建立数据库索引
实现B+树的索引需要完成以下步骤:
- 定义B+树结构:定义B+树节点的数据结构,包括每个节点的关键字、指针和其他相关信息等。
- 插入操作:插入新数据时,首先在B+树中查找数据的插入位置,然后将新数据插入到相应的叶子节点中。如果此时叶子节点已满,需要进行分裂操作,将其中一半数据移动到新的节点中,并更新父节点的指针。
- 删除操作:删除数据时,首先在B+树中查找数据所在的叶子节点,然后将该节点中的数据删除。如果此时叶子节点中数据不足,需要进行合并操作,将相邻的两个节点合并成一个节点,并更新父节点的指针。
- 查询操作:查询数据时,在B+树中查找数据所在的叶子节点,并从该节点中获取相应的数据。
- 范围查询操作:对于范围查询,需要先找到范围起点对应的叶子节点,然后沿着B+树的指针遍历到范围终点对应的叶子节点,最后返回所有数据。
在实现B+树的索引过程中,需要注意以下几点:
- 确定B+树的阶:B+树的阶M决定了每个节点可以包含的最大关键字数,也影响了B+树的高度和索引的查询效率。一般情况下,M的大小与磁盘块的大小有关,常常取几百到几千。
- 对数据的存储格式进行设计:为了优化磁盘I/O次数,可以将所有数据存储在叶子节点上,并采用链表的形式将叶子节点串联起来。
- 处理节点分裂和合并的情况:为了维持B+树的平衡性,当节点中的数据达到一定数量时需要进行分裂操作,而当节点中的数据过少时需要进行合并操作。这些操作会导致B+树结构的调整,需要仔细考虑。
MySQL中的锁
基于粒度:
- 全局锁:对整个数据库实例加锁,加锁后整个实例就处于只读状态,后续的MDL、DDL语句、更新操作的事务提交语句都将被阻塞。其典型的使用场景是做全库的逻辑备份,对所有的表进行锁定,从而获取一致性视图,保证数据的完整性。
- 表级锁:对整张表加锁,粒度大并发小
- 行级锁:对行加锁,粒度小并发大
基于属性:
- 共享锁:又称读锁,一个事务为表加了读锁,其它事务只能加读锁,不能加写锁
- 排他锁:又称写锁,一个事务加写锁之后,其他事务不能再加任何锁,避免脏读问题
基于模式:
- 悲观锁:悲观锁总是假设最坏的情况,认为共享资源每次被访问的时候就会出现问题(比如共享数据被修改),所以每次在获取资源操作的时候都会上锁,这样其他线程想拿到这个资源就会阻塞直到锁被上一个持有者释放。
- 乐观锁:乐观锁总是假设最好的情况,认为共享资源每次被访问的时候不会出现问题,线程可以不停地执行,无需加锁也无需等待,只是在提交修改的时候去验证对应的资源(也就是数据)是否被其它线程修改了(具体方法可以使用版本号机制或 CAS 算法)。
Mysql悲观锁
悲观锁总是假设最坏的情况,认为共享资源每次被访问的时候就会出现问题(比如共享数据被修改),所以每次在获取资源操作的时候都会上锁,这样其他线程想拿到这个资源就会阻塞直到锁被上一个持有者释放。
也就是说,共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程。
像 Java 中synchronized和ReentrantLock等独占锁就是悲观锁思想的实现。
悲观锁通常多用于写操作比较多的情况下(多写场景),避免频繁失败和重试影响性能。
Mysql乐观锁
乐观锁总是假设最好的情况,认为共享资源每次被访问的时候不会出现问题,线程可以不停地执行,无需加锁也无需等待,只是在提交修改的时候去验证对应的资源(也就是数据)是否被其它线程修改了(具体方法可以使用版本号机制或 CAS 算法)。
在 Java 中java.util.concurrent.atomic包下面的原子变量类就是使用了乐观锁的一种实现方式 CAS 实现的。
乐观锁通常多于写操作比较少的情况下(多读场景),避免频繁加锁影响性能,大大提升了系统的吞吐量。
Mysql乐观锁版本号机制
一般是在数据表中加上一个数据版本号 version 字段,表示数据被修改的次数。当数据被修改时,version 值会加一。当线程 A 要更新数据值时,在读取数据的同时也会读取 version 值,在提交更新时,若刚才读取到的 version 值为当前数据库中的 version 值相等时才更新,否则重试更新操作,直到更新成功。
Mysql乐观锁CAS算法
CAS 的思想很简单,就是用一个预期值和要更新的变量值进行比较,两值相等才会进行更新。
CAS 是一个原子操作,底层依赖于一条 CPU 的原子指令。
原子操作 即最小不可拆分的操作,也就是说操作一旦开始,就不能被打断,直到操作完成。
CAS 涉及到三个操作数:
- V :要更新的变量值(Var)
- E :预期值(Expected)
- N :拟写入的新值(New)
当且仅当 V 的值等于 E 时,CAS 通过原子方式用新值 N 来更新 V 的值。如果不等,说明已经有其它线程更新了V,则当前线程放弃更新。
Mysql乐观锁ABA问题
如果一个变量 V 初次读取的时候是 A 值,并且在准备赋值的时候检查到它仍然是 A 值,那我们就能说明它的值没有被其他线程修改过了吗?很明显是不能的,因为在这段时间它的值可能被改为其他值,然后又改回 A,那 CAS 操作就会误认为它从来没有被修改过。这个问题被称为 CAS 操作的 "ABA"问题。
ABA 问题的解决思路是在变量前面追加上版本号或者时间戳。JDK 1.5 以后的 AtomicStampedReference 类就是用来解决 ABA 问题的,其中的 compareAndSet() 方法就是首先检查当前引用是否等于预期引用,并且当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的更新值。
MySQL间隙锁
什么是间隙锁
锁加在不存在的空闲空间,可以是两个索引记录之间,也可能是第一个索引记录之前或最后一个索引之后的空间。
当我们用范围条件而不是相等条件索引数据,并请求共享或排他锁时,InnoDB会给符合条件的已有数据记录的索引项加锁;对于键值在条件范围内但并不存在的记录,叫做“间隙(GAP)”。
InnoDB也会对这个“间隙”枷锁,这种锁机制就是所谓的间隙锁(Next-Key锁)。
注意:可重复读级别下才会有间隙锁!
间隙锁解决的问题
解决了可重复读级别下是幻读的问题。
产生间隙锁的条件:
- 使用普通索引锁定;
- 使用多列唯一索引;
- 使用唯一索引锁定多行记录。
Mysql各种连接方式的区别
内连接取两表交集部分,左连接取左表全部右表匹部分,右连接取右表全部坐表匹部分
sql执行顺序
MySQL SQL 执行顺序一般遵循以下顺序:
- FROM:指定表名或者视图名
- WHERE:对表的数据进行筛选
- GROUP BY:将结果集按照一个或多个列进行分组
- HAVING:基于 GROUP BY 的结果集再次筛选
- SELECT:从结果集中选择需要返回的列
- DISTINCT:去重
- ORDER BY:根据一个或多个列对结果集进行排序
- LIMIT:限制结果集的行数
这个执行顺序并不是绝对的。如果查询中包含了子查询、联合查询等复杂的操作,那么执行顺序会根据具体情况而有所不同。
如何设计数据库
- 抽取实体,如用户信息,商品信息,评论
- 分析其中属性,如用户信息:姓名、性别…
- 分析表与表之间的关联关系
然后可以参考三大范式进行设计,设计主键时,主键要尽量小并且定义为自增和不可修改。
固定枚举数据存储数据类型
在 MySQL 中,可以使用 ENUM 类型来定义固定枚举数据类型。ENUM 类型是一种特殊的字符串类型,它只能存储在定义时列出的枚举值中,存储其他的值会报错。 ENUM 类型需要指定枚举值列表,并且可以设置默认值。
ENUM 类型虽然能够限制字段取值,但是也可能存在一些性能问题,因为它需要额外的空间来存储枚举值。此外,如果需要修改枚举值列表,也需要对表进行结构性变更操作,较为不灵活。因此,在实际使用时需要仔细考虑 ENUM 类型的使用场景和限制。
where和having的区别
where是约束声明,having是过滤声明,where早于having执行,并且where不可以使用聚合函数,having可以
三大范式
第一范式:每个列都不可以再拆分。
第二范式:在第一范式的基础上,非主键列完全依赖于主键,而不能是依赖于主键的一部分。
第三范式:在第二范式的基础上,非主键列只依赖于主键,不依赖于其他非主键。
MySQL的日志
- redo log(重做日志)
- undo log(回滚日志)
- bin log(二进制日志)
- slow query log(慢查询日志)
- error log(错误日志)
- relay log(中继日志)
- 通用查询日志
- 数据定义语句日志
redo log
介绍
重做日志(redo log
)是InnoDB
存储引擎生成的日志,记录的是"物理级别"上的页修改操作,其中包含InnoDB存储引擎的所有记录更改操作。当事务提交时,它们会被写入到Redo Log中。在发生崩溃的情况下,MySQL会从Redo Log中重演这些更改,以恢复事务提交前的状态。因此,Redo Log对于保证数据完整性和可靠性非常重要。
redo log
分为redo log buffer
(重做日志缓冲)和redo log
(重做日志),前者存储在内存中,后者存储在磁盘中。在MySQL进行数据存储或者数据变更操作的时候,MySQL首先会将数据的变更记录在缓冲区,同时也会记录到redo log buffer
日志中,当事务提交的时候,会将内存中的redo log buffer
中的数据刷新到磁盘上的redo log
中。
注意,Redo Log只记录存储引擎的更改操作,不记录SELECT语句或其他不更改数据的操作。同时,Redo Log文件也可以通过配置文件调整大小和数量,以适应不同的系统需求。
作用
- 用于事务提交时保证事务的持久性,保证数据的可靠性
- 在发生崩溃的时候恢复数据
undo log
在MySQL中,Undo Log(撤销日志)是InnoDB存储引擎的一个组成部分,是一种逻辑日志,用于维护事务的原子性和隔离性。每个事务都有一个与之关联的Undo Log,其中记录了该事务所做的所有修改操作的反向操作,以便在需要时进行回滚或回滚到某个特定时间点的数据状态。
Undo Log在实现MVCC(多版本并发控制)时也起着重要作用,它可以提供读取一致性视图,使得用户可以读取到其他事务提交之前的数据状态。同时,Undo Log也会占用存储空间,需要定期进行清理以减少对磁盘空间的占用。
总的来说,Undo Log与Redo Log一样,是MySQL中确保事务的ACID特性的重要机制之一。他们都是用来记录事务操作的日志,只不过记录的内容和方式略有不同。理解两者的作用和机制对于深入了解MySQL数据库的工作原理和优化非常有帮助。
bing log
binlog就是二进制文件,主要记录了MySQL在进行DML(数据操作语言)过程中的操作日志。在执行SQL语句的过程中,作为使用者,无需关注程序执行的过程,但是当数据库数据丢失,或者需要搭建数据库主从复制时,则此时的binlog日志文件的重要性就展现出来了。
undo log和redo log的区别
其中redo log日志记录的是数据页的物理变化,服务宕机可用来同步数据,而undo log 不同,它主要记录的是逻辑日志,当事务回滚时,通过逆操作恢复原来的数据,比如我们删除一条数据的时候,就会在undo log日志文件中新增一条delete语句,如果发生回滚就执行逆操作;
redo log保证了事务的持久性,undo log保证了事务的原子性和一致性
char和varchar的区别
char是不可变的,最大长度为255,varchar是可变的字符串,最大长度为2^16,在myisam引擎中char的处理速度要比vachar快
MySQL中""和NULL的区别
NULL表示缺失的数据或者未定义的值,比如一个列还没有被分配值,或者该列的值被删除了。在查询中使用WHERE语句时,需要使用IS NULL来判断是否为NULL值。
而空字符串""则表示一个长度为0的字符串,它是一个有效的值。在查询中使用WHERE语句判断空字符串可以使用column_name = ''
的方式。
需要注意的是,在索引设计中,是否允许NULL和空字符串会影响到索引的选择。如果需要对一个列进行唯一性约束,那么该列不能包含NULL值,因为NULL值无法与其他NULL值比较。但是如果需要查询列中有NULL值,那么必须将该列包含进索引中。而对于空字符串则没有这个问题,因为空字符串可以与其他空字符串直接进行比较。
MySQL中空值和NULL的区别
- 空值不占空间,NULL值占空间。当字段不为NULL时,也可以插入空值。
- 当使用 IS NOT NULL 或者 IS NULL 时,只能查出字段中没有不为NULL的或者为 NULL 的,不能查出空值。
- 判断NULL 用IS NULL 或者 is not null,SQL 语句函数中可以使用IFNULL()函数来进行处理,判断空字符用 =’‘或者<>’'来进行处理。
- 在进行count()统计某列的记录数的时候,如果采用的NULL值,会别系统自动忽略掉,但是空值是会进行统计到其中的。
InnoDB 什么情况下会产生死锁
事务1已经获取数据A的写锁,想要去获取数据B的写锁,然后事务2获取了B的写锁,想要去获取A的写锁,相互等待形成死锁。
mysql解决死锁的机制有两个:1.等待, 直到超时 2.发起死锁检测,主动回滚一条事务
死锁检测的原理是构建一个以事务为顶点、 锁为边的有向图, 判断有向图是否存在环, 存在即有死锁。
我们平时尽量减少事务操作的资源和隔离级别
深分页
深分页是指需要访问大量数据时,从起始位置开始逐行读取数据,直到达到目标位置。对于大量数据的数据库查询,深分页可能会导致性能问题和 OutOfMemoryError 等异常。
针对深分页的数据库实现方法主要有以下几种:
- 使用 LIMIT 分页:使用 LIMIT 关键字限制查询结果的数量,并且在查询语句中添加 WHERE 子句来避免读取无用数据。
- 利用索引:如果表中存在建立好的索引,那么可以利用索引进行分页查询,这样可以减少需要扫描的数据量,提高查询性能。
- 缓存热数据:将热门数据缓存在内存中,减少磁盘 I/O 操作,从而提高查询性能。
- 数据分区:将大型表进行分区,每个分区只存储一部分数据,这样在进行查询时可以只扫描需要的数据分区,可以减少需要扫描的数据量。
- 使用覆盖索引加子查询的方式,先通过索引查询出需要查询出的数据的id值,然后再通过id值去查找出对应的数据
需要注意的是,对于大规模数据的深分页查询,应该尽量采用优化后的 SQL 语句,并且在实际业务中根据具体情况对分页方式进行优化,以提高查询性能和避免产生异常。
Mysql主从复制
Mysql为什么要主从复制
- 在业务复杂的系统中,有一句sql语句需要锁表,导致暂时不能使用读的服务,那么就很影响运行中的业务,使用主从复制,让主库负责写,从库负责读,这样,即使主库出现了锁表的情景,通过读从库也可以保证业务的正常运行。
- 做数据的热备,主库宕机后能够及时替换主库,保证业务可用性。
- 架构的扩展。业务量越来越大,I/O访问频率过高,单机无法满足,此时做多库的存储,降低磁盘I/O访问的频率,提高单个机器的I/O性能。
Mysql什么是主从复制
MySQL 主从复制是指将一个 MySQL 数据库(称为“主数据库”)的数据同步到另一个 MySQL 数据库(称为“从数据库”)的过程。在主从复制架构中,主数据库对数据进行写操作,从数据库可以对这些写操作进行复制,并保持与主数据库的数据一致性。
Mysql主从复制的实现方式
- 主库db的更新事件(update、insert、delete)被写到binlog
- 从库启动并发起连接,连接到主库
- 主库创建一个binlog dump thread,把binlog的内容发送到从库
- 从库启动之后,创建一个I/O线程,读取主库传过来的binlog内容并写入到relay log(中继日志)
- 从库启动之后,创建一个SQL线程,从relay log里面读取内容,从Exec_Master_Log_Pos位置开始执行读取到的更新事件,将更新内容写入到slave的db
需要注意的是,如果主库中存在大量数据或者写操作频繁,可能会导致从库复制滞后,甚至出现丢失部分数据的情况。为了避免这种情况,需要根据具体情况对主从复制架构进行优化和调整,例如增加从库数量、对主库进行分片等。
MySQL和其他数据库的不同
MySQL和其他数据库相比,其设计有以下不同之处:
- 支持多种存储引擎。MySQL提供的存储引擎包括InnoDB、MyISAM等,这些存储引擎各有特点,InnoDB支持事务处理和外键约束,MyISAM适合读取频繁的应用程序。
- 可扩展性强。MySQL支持主从复制、分库分表等技术以应对海量数据存储和查询。
- 开源免费。MySQL是开源软件,可以自由地下载、使用、修改和分发。
框架
Spring
什么是Spring
Spring是个轻量级的框架,通过IOC达到松耦合的目的,通过AOP可以分离应用业务逻辑和系统服务进行内聚性的开发,不过配置各种组件时比较繁琐,所以后面才出选了SpringBoot的框架。
使用Spring管理Bean的好处是,可以将应用程序的配置信息集中到一个地方进行管理,从而降低了代码的复杂度和维护难度。
SpringIOC
IOC是什么
IOC是控制反转,是一种思想,把对象的创建和调用从程序员手中交由IOC容器管理,通过依赖注入(DI)方式实现对象之间的松耦合关系。
应用程序如果需要使用某个对象的实例,就可以直接从IOC容器中去获取
程序运行时,依赖对象由【辅助程序】动态生成并注入到被依赖对象中,动态绑定两者的使用关系。
Spring IoC容器就是这样的辅助程序,它负责对象的生成和依赖的注入,让后在交由我们使用。
简而言之,就是:IoC就是一个对象定义其依赖关系而不创建它们的过程。
IOC的工作流程
- 首先是IOC容器的初始化阶段,这个阶段主要是根据程序里面定义的xml或者注解等bean的声明方式,通过解析和加载后生成BeanDefinition,然后把BeanDefinition注册到IOC容器里面,通过注解或者xml声明的每个bean都会解析得到一个BeanDefinition实体,这个实体里面会包含一些bean的定义和基本的一些属性,最后把这个BeanDefinition保存到一个Map集合里面从而去完成IOC的初始化。IOC容器的作用就是对这些注册的bean的定义信息进行处理和维护。它是IOC容器控制反转的一个核心。
- 第二个阶段是完成Bean的初始化和依赖注入,它首先会通过反射去针对没有设置lazy-init属性的单例bean进行初始化,然后去完成bean的依赖注入。
- 最后就是bean的使用,通常会通过@Autowired或者BeanFactory.getBean()从IOC容器里面去获取指定的bean实例,另外针对设置了lazy-init属性以及非单例bean的一个实例化,是在每次获取bean对象的时候调用bean的初始化方法来完成实例化的。并且IOC容器不会去管理这些bean。
SpringAOP
AOP是面向切面编程,可以将那些与业务不相关但是很多业务都要调用的代码抽取出来,思想就是不侵入原有代码的情况下对功能进行增强。
什么是动态代理
动态代理就是,在程序运行期,创建目标对象的代理对象,并对目标对象中的方法进行功能性增强的一种技术。在生成代理对象的过程中,目标对象不变,代理对象中的方法是目标对象方法的增强方法。可以理解为运行期间,对象中方法的动态拦截,在拦截方法的前后执行功能操作。
代理类在程序运行期间,创建的代理对象称之为动态代理对象。这种情况下,创建的代理对象,并不是事先在Java代码中定义好的。而是在运行期间,根据我们在动态代理对象中的“指示”,动态生成的。也就是说,你想获取哪个对象的代理,动态代理就会为你动态的生成这个对象的代理对象。动态代理可以对被代理对象的方法进行功能增强。有了动态代理的技术,那么就可以在不修改方法源码的情况下,增强被代理对象的方法的功能,在方法执行前后做任何你想做的事情。
AOP的实现原理(JDK和cglib)
SpringAOP是基于动态代理实现的,动态代理是有两种,一种是jdk动态代理,一种是cglib动态代理;
jdk动态代理是原理是利用反射来实现的,需要调用反射包下的Proxy类的newProxyInstance方法来返回代理对象,这个方法中有三个参数,分别是用于加载代理类的类加载器,被代理类实现的接口的class数组和一个用于增强方法的InvocaHandler实现类。
cglib动态代理原理是利用asm开源包来实现的,是把被代理类的class文件加载进来,通过修改它的字节码生成子类来处理
jdk动态代理要求被代理类必须有实现的接口,生成的动态代理类会和代理类实现同样的接口,cglib则,生成的动态代理类会继承被代理类。Spring默认使用jdk动态代理,当被代理的类没有接口时就使用cglib动态代理
Spring如何使用aop自定义日志
第一步:创建一个切面类,把它添加到ioc容器中并添加@Aspect注解
第二步: 在切面类中写一个通知方法,在方法上添加通知注解并通过切入点表达式来表示要对哪些方法进行日志打印,然后方法参数为JoinPoint
第三步:通过JoinPoint这个参数可以获取当前执行的方法名、方法参数等信息,这样就可以根据需求在方法进入或结束时打印日志
SpringBean
bean是一个由Spring IoC容器实例化、组装和管理的对象。bean是一个由Spring IoC容器实例化、组装和管理的对象。
SpringBean的创建过程
Spring 容器创建 bean 的过程大致如下:
- 根据配置或者注解信息,定位需要被 IOC 容器管理的类。
- 通过反射机制或其他方式创建该类的实例,并把它封装到 BeanWrapper 中。
- 对实例进行属性填充,即利用反射或其他方式将配置或注解中的值注入到实例的属性中。
- 如果实现了 BeanNameAware 接口,调用该类的 setBeanName 方法将该 bean 在容器中的名字传入。
- 如果实现了 BeanFactoryAware 或 ApplicationContextAware 接口,调用相应方法注入 bean 工厂或应用上下文的引用。
- 如果定义了 BeanPostProcessor,则在初始化之前和之后进行相应的处理,常见的如 PropertyPlaceholderConfigurer 用于将占位符替换为相应的属性值。
- 如果实现了 InitializingBean 接口,调用该类的 afterPropertiesSet 方法进行初始化。
- 如果定义了 init-method 属性,则调用指定的方法进行初始化。
- 如果定义了 DisposableBean 接口,则调用该类的 destroy 方法进行销毁。
- 如果定义了 destroy-method 属性,则调用指定的方法进行销毁。
- 将 bean 注册到容器中,完成 bean 的创建过程。
**Spring 容器创建 bean 的过程可以概括为:实例化、依赖注入、初始化以及加入到容器中等步骤。**在整个过程中,如果需要对 bean 进行扩展和定制,可以通过继承 ApplicationContextAware、BeanFactoryAware、 BeanNameAware 等接口,并实现相应的回调方法来实现。
bean的声明方式
有三种方式去声明bean
- 在xml文件中通过
<bean>
标签 - 通过在类上添加
@Component
、@Service
、@Controller
等注解来将类声明为bean - 在@Configuration类中通过@Bean注解
spring在启动的时候回去解析这些bean然后保存到IOC容器中
SpringBean的作用域
Spring中的对象作用域有以下六种:
- Singleton(单例):默认作用域,每个Bean定义只会创建一个对象实例,所有请求都会共享同一个实例。
- Prototype(原型):每次请求都会创建一个新的Bean实例,即每个Bean定义都会创建多个独立的对象实例。
- Session(会话):在Web应用中为每个会话创建一个单独的Bean实例。
- Request(请求):在Web应用中为每个请求创建一个单独的Bean实例。
- globalSession(全局会话):在一个全局的HTTP Session中,一个全局会话有一个Bean实例。典型情况下,仅在使用portlet context的时候有效。该作用域仅在基于web的Spring ApplicationContext情形下有效。
- application(应用程序):在Web应用程序上下文中,只创建一个Bean实例。
对于Singleton(单例)作用域的Bean,在IoC容器初始化时就会被创建,并一直存在于应用程序的整个生命周期中;对于Prototype(原型)作用域的Bean,则是每次被请求时才会创建一个新的对象实例。对于Session作用域和Request作用域的Bean,在Web应用中的Session或Request结束时,IoC容器会自动销毁相应的Bean实例。
SpringBean的生命周期
实例化 -> 属性赋值 -> 初始化 -> 销毁
Spring 中的 Bean 生命周期包括以下阶段:
- 实例化:容器根据 Bean 的定义创建 Bean 实例,这个实例是处于“未就绪”状态的。
- 属性赋值:容器通过 Bean 的 setter 方法或者直接访问成员变量,为 Bean 的属性赋值。
- 初始化前:Bean 的
PostConstruct
回调函数会被调用,给 Bean 实例做初始化的准备工作。 - 初始化:这个阶段表示 Bean 正在完成其初始化工作。这是一个时间较长的阶段,因此 Spring 提供了一种可扩展的机制,允许开发人员在此阶段进行一些自定义的初始化操作,具体来说,主要有两种方式:
- 实现
InitializingBean
接口,实现afterPropertiesSet()
方法,该方法会在 Spring 容器完成 Bean 属性填充之后立即被调用。 - 在 Bean 配置文件中使用
init-method
属性指定一个特定的初始化方法,该方法将在对象实例化和所有属性设置后被调用。
- 实现
- 初始化后:Bean 的
initMethod
回调方法执行完毕后,Spring 容器将发送BeanPostProcessor.postProcessAfterInitialization()
消息,通知其他的 Bean 该 Bean 已经初始化完成了。 - 销毁前:该阶段表示容器正在销毁 Bean 实例,此时可以通过两种方式进行扩展:
- 实现
DisposableBean
接口,实现destroy()
方法。 - 在 Bean 配置文件中使用
destroy-method
属性指定一个特定的销毁方法。
- 实现
- 销毁:表示 Bean 已经被销毁。此时,Spring 容器将发送
BeanPostProcessor.postProcessBeforeDestruction()
消息,通知其他的 Bean 该 Bean 即将被销毁。
Spring 容器中的 Bean 生命周期是由 Spring 容器负责控制和管理的,开发人员可以通过在 Bean 中实现相应的回调接口或在配置文件中定义初始化和销毁方法来扩展 Bean 生命周期中的各个阶段,以满足不同的业务需求。
Springbean是线程安全的吗
spring的默认bean作用域是单例的,单例的bean不是线程安全的,但是开发中大部分的bean都是无状态的,不具备存储功能,比如controller、service、dao,他们不需要保证线程安全。
当多用户同时请求一个服务时,容器会给每一个请求分配一个线程,这时多个线程会并发执行该请求对应的业务逻辑(成员方法),如果该处理逻辑中有对该单列状态的修改(体现为该单例的成员属性),则必须考虑线程同步问题。
如果要保证线程安全,可以将bean的作用域改为prototype(原型,它会为每次请求创建一个新的bean实例),比如像Model View。
另外还可以采用ThreadLocal来解决线程安全问题。ThreadLocal为每个线程保存一个副本变量,每个线程只操作自己的副本变量。
SpringBean的循环依赖
SpringBean的循环依赖是什么
多个bean之间相互依赖,形成了一个闭环。这时在Spring容器中初始化这些bean的时候,就会导致bean创建失败或者死循环等问题。
循环依赖就是在创建 A 实例的时候里面包含着 B 属性实例,所以这个时候就需要去创建 B 实例,而创 建 B 实例过程中也包含着 A 实例。 这样 A 实例还在创建的过程当中,所以就导致 A 和 B 实例都创建不出来。
比如:A依赖于B、B依赖于C、C依赖于A。
public class A{
B b;
}
public class B{
C c;
}
public class C{
A a;
}
SpringBean的循环依赖如何检测
使用一个列表来记录正在创建中的bean,bean创建之前,先去记录中看一下自己是否已经在列表中了,如果在,说明存在循环依赖,如果不在,则将自己加入到这个列表,bean创建完毕之后,将其再从这个列表中移除。
SpringBean的循环依赖如何解决
spring通过三级缓存来解决循环依赖:
一级缓存:缓存经过完整的生命周期的Bean
二级缓存 :缓存未经过完整的生命周期的Bean
三级缓存:缓存的是ObjectFactory,其中存储了一个生成代理类的拉姆达表达式
我们在创建 A 的过程中,先将 A 放入三级缓存 ,这时要创建B,B要创建A就直接去三级缓存中查找,并且判断需不需要进行 AOP 处理,如果需要就执行拉姆达表达式得到代理对象,不需要就取出原始对象。然后将取出的对象放入二级缓存中,因为这个时候 A 还未经 过完整的生命周期所以不能放入一级缓存。这个时候其他需要依赖 A 对象的直接从二级缓存中去获取即可。当B创建完成,A 继续执行生命周期,当A完成了属性的注入后,就可以放入一级缓存了
- 提前暴露一个尚未完全创建的bean,即使用“提前曝光”的方式,将正在创建的Bean对象提前暴露给其他对象,以便其他对象通过setter或构造器注入时可以得到正确的对象引用。
- 使用代理对象延迟注入,即使用代理对象替代真正的对象(如使用@Proxy注解对类进行代理),当需要使用该对象时,再通过代理对象的调用方法去获取。
Spring反射的实现
Spring 框架的反射实现主要基于 Java 标准库中的反射 API。常用的反射实现方式为:
- 使用
Class.forName()
方法加载类:Spring 应用程序通常需要动态加载和使用一些类,而这些类可能在应用程序打包时并不存在。在这种情况下,可以使用Class.forName()
方法来加载类并获取类对象,从而进行实例化和方法调用。 - 使用
Class.newInstance()
方法实例化对象:一般情况下,如果已经获取了一个类对象的引用,则可以通过调用newInstance()
方法来创建该类的新实例。这种方式需要类有一个公共的无参构造器。如果没有无参构造器,可以使用其他构造器,但需要通过Constructor.newInstance()
方法显式地传入参数。 - 使用
Method.invoke()
方法调用方法:对于已经获得的类对象和方法对象,可以使用Method.invoke()
方法来调用方法。该方法接受一个目标对象和一组方法参数作为其参数,并返回该方法的返回值。 - 使用
Field.get()
和Field.set()
方法访问字段:可以使用Field.get()
方法获取字段的值,并使用Field.set()
方法设置字段的值。需要注意的是,必须先使字段可访问(通过调用setAccessible(true)
方法),才能够使用这些方法。 - 使用
AnnotationUtils
工具类处理注解:Spring 提供了一个实用工具类AnnotationUtils
,可帮助开发人员处理注解。该工具类提供了许多实用的方法,例如findAnnotation()
用于查找指定类型的注解,getAnnotationAttributes()
用于检索注解中的属性等。
总之,Spring 中的反射实现方式主要基于 Java 标准库中的反射 API,并提供了一些实用工具类来帮助开发人员更方便地使用反射进行对象实例化、方法调用和字段访问等操作。
Spring事务
Spring事务原理
spring事务有编程式和声明式,我们一般使用声明式,在某个方法上增加@Transactional注解,这个方法中的sql会统一成功或失败。
原理是:
当一个方法加上@Transactional注解,spring会基于这个类生成一个代理对象并将这个代理对象作为bean,当使用这个bean中的方法时,如果存在@Transactional注解,就会将事务自动提交设为false,然后执行方法,执行过程没有异常则提交,有异常则回滚、
Spring事务失效场景
- 事务方法所在的类没有加载到容器中
- 如果事务方法上不是public修饰的,也会导致事务失效
- 同一类中,一个没有添加事务的方法调用另外以一个添加事务的方法,事务不生效。因为事务要交由代理对象去控制,在非事务方法中去调用事务方法,代理对象执行非事务方法前不进行事务控制,所以非事务方法中的事务就失效了。我们可以通过将事务方法提升到接口,然后通过接口来调用事务方法,此时该方法是交由代理对象去执行的,此时事务就会生效
- 如果方法上异常捕获处理,自己处理了异常,没有抛出,就会导致事务失效,所以一般处理了异常以后,别忘了抛出去就行了
- 如果方法抛出检查异常,如果报错也会导致事务失效,最后在spring事务的注解上,就是@Transactional上配置rollbackFor属性为Exception,这样不管是什么异常,都会回滚事务
Spring事务的隔离级别
default:默认级别,使用数据库自定义的隔离级别
其它四种隔离级别与mysql一样
Spring事务的传播行为
spring 事务的传播行为说的是,当多个事务同时存在的时候,spring 如何处理这些事务的行为。
备注(方便记忆): propagation 传播
require 必须的 suppor 支持 mandatory 强制托管 requires-new 需要新建 not -supported 不支持 never 从不 nested 嵌套的
- PROPAGATION_REQUIRED:如果当前没有事务,就创建一个新事务,如果当前存在事务,就加入该事务,该设置是最常用的设置。
- PROPAGATION_SUPPORTS:支持当前事务,如果当前存在事务,就加入该事务, 如果当前不存在事务,就以非事务执行。
- PROPAGATION_MANDATORY:支持当前事务,如果当前存在事务,就加入该事务,如果当前不存在事务,就抛出异常。
- PROPAGATION_REQUIRES_NEW:创建新事务,无论当前存不存在事务,都创建新事务。
- PROPAGATION_NOT_SUPPORTED:以非事务方式执行操作,如果当前存在事务,就把当前事务挂起。
- PROPAGATION_NEVER:以非事务方式执行,如果当前存在事务,则抛出异常。
- PROPAGATION_NESTED:如果当前存在事务,则在嵌套事务内执行。如果当前没有事务,则按 REQUIRED 属性执行。
Spring设计模式
BeanFactory用了工厂模式,AOP用了动态代理模式,RestTemplate用来模板方法模式,SpringMVC中handlerAdaper用了适配器模式,Spring里的监听器用了观察者模式
ApplicationContext是干嘛的
可以获取spring的上下文对象,是BeanFacotory子类,属于一次性加载所有bean
- 创建和初始化bean对象 ApplicationContext负责创建应用程序中所有的bean对象,并在需要的时候对它们进行初始化。这个初始化过程包括调用bean实例的各种方法、属性注入等等。
- 提供bean的依赖注入 ApplicationContext通过依赖注入(DI)把应用程序中的bean对象装配到一起,形成一个完整的系统。通过DI,可以方便地处理对象之间的依赖关系,而不需要显式创建它们。
- 管理bean生命周期 ApplicationContext负责管理bean对象的生命周期。它会自动调用bean实例的初始化方法和销毁方法,以确保bean能够正确地初始化和销毁。
- 提供AOP功能 ApplicationContext为应用程序中的bean提供了面向切面编程(AOP)支持,使开发人员可以很容易地将横切逻辑分离出来,例如:事务管理、安全性控制、日志记录等。
- 提供国际化支持 ApplicationContext提供了国际化(i18n)支持,可以方便地实现多语言的应用程序。
SpringMVC工作原理
SpringMVC工作过程围绕着前端控制器DispatchServerlet,几个重要组件有HandleMapping(处理器映射器)、HandleAdapter(处理器适配器)、ViewReslover(视图解析器)
工作流程:
- DispatchServerlet接收用户请求将请求发送给HandleMapping
- HandleMapping根据请求url找到具体的handle和拦截器,返回给DispatchServerlet
- DispatchServerlet调用HandleAdapter,HandleAdapter执行具体的controller,并将controller返回的ModelAndView返回给DispatchServler
- DispatchServerlet将ModelAndView传给ViewReslover,ViewReslover解析后返回具体view
- DispatchServerlet根据view进行视图渲染,返回给用户
Mybatis
Mybatis帮我们做了什么
- 封装jdbc操作
- 利用反射实现将java对象与sql语句之间的互相转换。
Mybatis的执行流程
- 读取MyBatis配置文件:mybatis-config.xml加载运行环境和映射文件
- 构造会话工厂SqlSessionFactory,一个项目只需要一个,单例的,一般由spring进行管理
- 会话工厂创建SqlSession对象,这里面就含了执行SQL语句的所有方法
- 操作数据库的接口,Executor执行器,同时负责查询缓存的维护
- Executor接口的执行方法中有一个MappedStatement类型的参数,封装了映射信息
- 输入参数映射
- 输出结果映射
Mybatis的一级,二级缓存
Mybatis缓存无论是一级缓存还是二级缓存都是本地缓存,都会占用JVM的内存,一旦Java停止缓存失效
mybatis的一级缓存: 基于 PerpetualCache 的 HashMap 本地缓存,其存储作用域为 Session,当Session进行flush或close之后,该Session中的所有Cache就将清空,默认打开一级缓存
一级缓存是Mybatis默认开启的,二级缓存需要单独开启
二级缓存又称"全局缓存",是基于namespace和mapper的作用域起作用的,不是依赖于SQL session,默认也是采用 PerpetualCache,HashMap 存储。
如果想要开启二级缓存需要在全局配置文件和映射文件中开启配置才行。
得出结论我们的一级缓存只是在同一个SqlSession当中有效!
特点
一级缓存:
- SqlSession级别的缓存,生命周期较短。
- 默认开启,无法关闭。
- 如果执行了修改操作,则会清空一级缓存。
二级缓存:
- 全局性的缓存,属于Mapper级别的缓存。
- 需要在映射文件中进行配置才能启用。
- 可以通过flushCache="true"属性来清除缓存。
- 默认情况下,缓存策略为LRU(最近最少使用)(它会优先淘汰最近最少使用的缓存对象。这种策略适用于缓存数据量较大,而且访问热点不太明显的场景。)。
工作机制:
一个会话查询一条数据,这个数据会被放在一级缓存当中,
当我们会话关闭的时候,会把这个数据从一级缓存迁入二级缓存当中,新的会话就可以在二级缓存当中找到这个数据!
不同的会话查询不同的namespace的时候,会将不同namespace中的数据缓存到自己对应的缓存(map)中
使用步骤 :
只需在需要使用缓存的namespace 中加入< cache/>
即可
缓存执行流程
当我们的sql执行的时候,先去二级缓存namespace中查看是否存在缓存,然后如果二级缓存不存在,查看当前sqlSession中一级缓存中是否存在,最后一、二级缓存中都不存在的话那么就去数据库查询,接着会将查询出来的结果保存在我们的一级缓存当中,当前会话(SqlSession)结束,就会将一级缓存中的数据,同步到我们的二级缓存
二级缓存什么时候会清理缓存中的数据
当某一个作用域(一级缓存 Session/二级缓存Namespaces)的进行了新增、修改、删除操作后,默认该作用域下所有 select 中的缓存将被 clear。
Mybatis延迟加载
什么是延迟加载
延迟加载的意思是:就是在需要用到数据时才进行加载,不需要用到数据时就不加载数据。
Mybatis支持一对一关联对象和一对多关联集合对象的延迟加载
在Mybatis配置文件中,可以配置是否启用延迟加载lazyLoadingEnabled=true|false,默认是关闭的
延迟加载的原理
延迟加载在底层主要使用的CGLIB动态代理完成的
当执行查询操作时,Mybatis会生成原始对象的代理对象,在代理对象中通过虚拟代理模式实现了延迟加载。当需要查询关联对象时,Mybatis就会再次查询数据库获取关联对象并装配到代理对象中,然后返回给用户。
为什么说 Mybatis 是半自动ORM映射工具它与全自动的区别在哪里
什么是ORM
ORM(对象关系映射),是一种为了解决关系型数据库数据与简单 Java 对象(POJO)建立映射关系的技术。
为什么说 Mybatis 是半自动ORM映射工具?它与全自动的区别在哪里?
首先,像 Hibernate、JPA 这种属于全自动 ORM 映射工具,使用 Hibernate 查询关联对象或者关联集合对象时,可以根据对象关系模型直接获取,所以它是全自动的。而 Mybatis 在查询关联对象或关联集合对象时,需要手动编写 sql 来完成,所以,称之为半自动 ORM 映射工具。换句话来解释就是说 MyBatis 是 半自动 ORM 最主要的一个原因是,它需要在 XML 或者注解里通过手动或插件生成 SQL,才能完成 SQL 执行结果与对象映射绑定。
Mybatis的优缺点
优点:
- 基于SQL语句编写、相当灵活,SQL写在XML文件当中,解除了sql与程序代码的耦合,便于统一管理
- 消除了JDBC的冗余代码、能够与Spring很好的集成
缺点:
- SQL语句的编写工作量大,尤其是字段多,关联表多的情况下,对开发人员的SQL语句功底有一定的要求
- SQL语句依赖数据库,导致数据库移植性差,不能随意更换数据库
#{} 和 ${} 的区别
- #{} 是占位符,会预编译处理,${}是拼接符,字符串替换,没有预编译处理
- mybatis在处理#{}的时候,#{} 传入的参数是字符串,会将SQL中的#{} 替换为?调用PreparedStatement的Set方法赋值
- myabtis在处理 的时候,就是原值传入,就是把 {}的时候,就是原值传入,就是把 的时候,就是原值传入,就是把{}替换为传来的值
- #{}通过编译预处理可以有效地防止SQL注入问题,提高系统的安全性,${}则不能防止SQL注入
Mybatis动态SQL
什么是动态SQL
因为sql的内容是变化的, 动态sql可以根据条件获取到不同的sql语句。它一般是根据用户输入或外部条件动态组合的SQL语句块。主要是where部分发生变化。动态sql的实现, 使用的是mybatis提供的标签。
Mybatis的动态SQL有什么作用?执行原理是什么?有哪些常用标签?
- mybatis动态sql可以在xml映射文件内,以标签的形式编写动态sql
- 执行原理:根据参数表达式的值完成逻辑判断,并且动态拼接sql
- mybatis提供9种动态sql的标签:trim、where、set、foreach、if、choose、when、otherwise、bind
元素 | 作用 | 备注 |
---|---|---|
if | 判断语句 | 单条件分支判断 |
choose(when,otherwise) | 相当于Java中的switch case语句 | 多条件分支判断 |
trim,where | 辅助元素 | 用于处理一些SQL拼装问题 |
foreach | 循环语句 | 在in语句等列举条件常用 |
bind | 辅助元素 | 拼接参数 |
Mybatis实现分页查询
一般我们可以把分页的方式分为两类:逻辑分页和物理分页
逻辑分页:
- 先查询出所有的数据缓存到内存里面,再根据业务相关的需求从内存中的数据里面去筛选出合适的数据进行分页
物理分页:
- 直接利用数据库中支持的分页语法进行分页(比如MySQL中的LIMITI关键字)
我认为有三种方式去实现分页:
- 直接再Select语句上增加数据库提供的分页关键字,然后再应用程序里面传递当前页以及每页展示条数即可
- 使用Mybatis提供的RowBounds对象实现内存级别的分页(一次性加载所有符合目标的分页数据,然后根据分页参数的值在内存中实现分页,在数据量比较大的情况下,JDBC会做一些优化,它不会一次性加载所有数据,而是只加载一部分数据,然后再根据需求去数据库里面滚动加载。这种方式不适合数据量比较大的场景,而且还可能会频繁的访问数据库)
- 基于Mybatis里面的Interceptor拦截器,在select语句执行之前动态拼接分页关键字
Interceptor是Mybatis里面提供的一种针对于不用生命周期的拦截器(拦截执行器方法,拦截参数的处理,拦截结果集的处理,拦截SQL语法构建的处理),可以通过拦截不同阶段的处理来实现Mybatis里面相关功能的扩展,这种方式可以提供统一的处理机制,不需要再去单独维护分页相关的功能
Mybatisplus本质上就是通过Interceptor拦截器的一个扩展,去帮我们实现了分页的封装,使我们不需要去关心分页相关的配置,节省了开发和配置时间。
springboot
自动配置原理
启动类@SpringbootApplication注解下,有三个关键注解
- @springbootConfiguration:表示启动类是一个自动配置类
- @CompontScan:扫描启动类所在包外的组件到容器中
- @EnableConfigutarion:最关键的一个注解,他拥有两个子注解,其中@AutoConfigurationpackageu会将启动类所在包下的所有组件到容器中,@Import会导入一个自动配置文件选择器,他会去加载META_INF目录下的spring.factories文件,这个文件中存放很大自动配置类的全类名,这些类会根据元注解的装配条件生效,生效的类就会被实例化,加载到ioc容器中
注解
什么是注解
- 注解是一种元数据形式。即注解是属于java的一种数据类型,和类、接口、数组、枚举类似。
- 注解用来修饰,类、方法、变量、参数、包。
- 注解不会对所修饰的代码产生直接的影响。
常用注解
@RestController:修饰类,该控制器会返回Json数据
@RequestMapping(“/path”):修饰类,该控制器的请求路径
@Autowired:修饰属性,按照类型进行依赖注入
@Resource:修饰属性,按照名称进行依赖注入
@PathVariable:修饰参数,将路径值映射到参数上
@ResponseBody:如果修饰方法,该方法会返回Json数据
@RequestBody(需要使用Post提交方式):如果修饰参数,将Json数据封装到对应参数中
@Transaction:开启事务
@SpringBootApplication:它封装了核心的 @SpringBootConfiguration + @EnableAutoConfiguration + @ComponentScan 这三个类,大大节省了程序员配置时间,这就是 SpringBoot 的核心设计思想.
@EnableScheduling:是通过@Import 将 Spring 调度框架相关的 bean 定义都加载到 IoC 容器
@MapperScan:springboot支持mybatis组件的一个注解,通过此注解指定mybatis 接口类的路径,即可完成对 mybatis 接口的扫描
@RestController:是 @Controller 和 @ResponseBody 的 结 合 , 一 个 类 被 加 上 @RestController 注解,数据接口中就不再需要添加@ResponseBody,更加简洁。
@RequestMapping:我们都需要明确请求的路径. @GetMappping,@PostMapping, @PutMapping, @DeleteMapping 结 合 @RequestMapping 使用, 是 Rest 风格的, 指定更明确的子路径.
@PathVariable:路径变量注解,用{}来定义 url 部分的变量名.
@Service:这个注解用来标记业务层的组件,我们会将业务逻辑处理的类都会加上这个 注解交给 spring 容器。事务的切面也会配置在这一层。当让 这个注解不是一定要用。 有个泛指组件的注解,当我们不能确定具体作用的时候 可以用泛指组件的注解托付给 spring 容器
@Component:和 spring 的注解功能一样, 将类注册到 IOC 容器
@ControllerAdvice 和 @ExceptionHandler 配合完成统一异常拦截处理.
@Autowired和@Resource的区别
@Autowired和@Resource都是用于实现Spring Bean的依赖注入,但二者存在区别。
按照注入方式的不同
@Autowired 默认按照类型进行注入,如果容器中存在多个类型相同的 Bean,则需要结合 @Qualifier 注解一起使用指定具体的 Bean。
@Resource 默认按照名称进行注入,也可以通过 name 属性指定具体的 Bean 名称进行注入。如果找不到名称匹配的 Bean,则会尝试按照类型进行匹配。
注解来源不同
@Autowired 来自于 Spring 框架。
@Resource 是 JSR-250 规范中定义的注解,由其他框架实现并集成进来。
应用场景不同
@Autowired 多应用于 Spring 容器中,而 @Resource 则广泛运用于 JavaEE 平台中。
另外,@Resource 支持装配非 Spring 管理的类,而@Autowired 不支持。
@Configuration注解
@Configuration
注解告诉Spring框架这是一个配置类,并且其中至少存在一个方法使用 @Bean
注解。
作用:
- 告诉 Spring 这是一个 Java 配置类。
- @Configuration 类中的 @Bean 注解告诉 Spring 需要实例化一个 bean。
- 在应用程序上下文中运行时,Spring 将检测这个类上的 @Configuration 注解。如果存在该注解,则会对其中的 bean 进行处理并将其添加到 Spring 应用程序上下文中。
自定义注解
元注解:专门修饰注解的注解。
基本定义:
- 首先使用 @interface声明注解名称
- 然后,使用@Retention,@Target等元注解标注注解的生命周期和作用元素
- @Retention:表示对它所标记的元素的生命周期(参考的范围看RetentionPolicy枚举类)
- @Target:表示标记定义的注解可以和什么目标元素绑定
- @Inherited:表示该注解可以被继承
- @Document:表示该注解可以被生成API文档
自定义注解使用的基本流程:
- 定义注解——相当于定义标记;
- 配置注解——把标记打在需要用到的程序代码中;
- 解析注解——在编译期或运行时检测到标记,并进行特殊操作。
public @interface CherryAnnotation {
public String name();
int age() default 18;
int[] array();
}
注解里面定义的是:注解类型元素!
定义注解类型元素时需要注意如下几点:
- 访问修饰符必须为public,不写默认为public;
- 该元素的类型只能是基本数据类型、String、Class、枚举类型、注解类型(体现了注解的嵌套效果)以及上述类型的一位数组;
- 该元素的名称一般定义为名词,如果注解中只有一个元素,请把名字起为value(后面使用会带来便利操作);
- ()不是定义方法参数的地方,也不能在括号中定义任何参数,仅仅只是一个特殊的语法;
- default代表默认值,值必须和第2点定义的类型一致;
- 如果没有默认值,代表后续使用注解时必须给该类型元素赋值。
自定义注解的应用场景
自定义注解+拦截器 实现登录校验
//接下来,我们使用springboot拦截器实现这样一个功能,如果方法上加了@LoginRequired,则提示用户该接口需要登录才能访问,否则不需要登录。
//首先定义一个LoginRequired注解
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface LoginRequired {
}
//然后写两个简单的接口,访问sourceA,sourceB资源
@RestController
public class IndexController {
@GetMapping("/sourceA")
public String sourceA(){
return "你正在访问sourceA资源";
}
@GetMapping("/sourceB")
public String sourceB(){
return "你正在访问sourceB资源";
}
}
//没添加拦截器之前成功访问
//实现spring的HandlerInterceptor 类先实现拦截器,但不拦截,只是简单打印日志,如下:
public class SourceAccessInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
System.out.println("进入拦截器了");
return true;
}
@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
}
}
//实现spring类WebMvcConfigurer,创建配置类把拦截器添加到拦截器链中
@Configuration
public class InterceptorTrainConfigurer implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new SourceAccessInterceptor()).addPathPatterns("/**");
}
}
//在sourceB方法上添加我们的登录注解@LoginRequired
@RestController
public class IndexController {
@GetMapping("/sourceA")
public String sourceA(){
return "你正在访问sourceA资源";
}
@LoginRequired
@GetMapping("/sourceB")
public String sourceB(){
return "你正在访问sourceB资源";
}
}
//简单实现登录拦截逻辑
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
System.out.println("进入拦截器了");
// 反射获取方法上的LoginRequred注解
HandlerMethod handlerMethod = (HandlerMethod)handler;
LoginRequired loginRequired = handlerMethod.getMethod().getAnnotation(LoginRequired.class);
if(loginRequired == null){
return true;
}
// 有LoginRequired注解说明需要登录,提示用户登录
response.setContentType("application/json; charset=utf-8");
response.getWriter().print("你访问的资源需要登录");
return false;
}
//运行成功,访问sourceB时需要登录了,访问sourceA则不用登录,
自定义注解+AOP 实现日志打印
先导入切面需要的依赖包
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
定义一个注解@MyLog
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface MyLog{
}
//在步骤二中的IndexController写一个sourceC进行测试,加上我们的自定义注解:
@MyLog
@GetMapping("/sourceC/{source_name}")
public String sourceC(@PathVariable("source_name") String sourceName){
return "你正在访问sourceC资源";
}
依赖注入原理
- 使用注解标记Bean:在SpringBoot应用中,将需要使用依赖注入的对象通过注解标记为Bean,比如使用@Component、@Service、@Repository等注解标记一个类。
- 扫描Bean并创建对象:SpringBoot在启动时会扫描所有被注解标记的类,将它们创建为对象,并存储在IoC容器中。
- 解决Bean之间的依赖关系:在IoC容器中,每个Bean都有一个唯一的ID,在依赖注入时,使用@Autowired或@Inject注解自动将一个Bean注入到另一个Bean中,从而解决Bean之间的依赖关系。
- 提供在运行时的配置和管理:SpringBoot的IoC容器提供了Bean的生命周期管理、动态注入、AOP等优秀的功能,同时也允许在运行时对Bean进行配置。
SpringBoot通过IoC容器实现依赖注入,通过对Bean进行管理,确保了代码的可读性和可维护性,同时提高了开发效率和代码质量。
自动注入
SpringBoot自动注入使用约定大于配置的方式来实现。在程序中,使用@Autowired、@Resource或@Inject等注解将需要自动注入的对象标记为Bean,当SpringBoot启动时,它会扫描所有被这些注解标记的对象,并将它们创建为Bean,并存储在IoC容器中。
对于自动注入时解决依赖冲突问题时,SpringBoot通过Qualifier和Primary注解分别确定多个同类型依赖中优先注入哪一个Bean。其中@Qualifier注解可以指定Bean的名称,让Spring找到匹配的Bean进行注入;@Primary注解标记的Bean则会被优先注入。
除了自动注入,还可以通过构造函数注入、Setter注入等方式手动注入Bean。
约定大于配置
在 Spring Boot 中,"约定大于配置"是一个核心的设计原则。它指代的是将应用程序默认设置为一种简单、基本的配置方式,并允许开发人员按照约定的方式命名和组织应用程序的各个部分,从而使开发人员无需进行过多的配置。
Spring Boot 中的约定大于配置**指导开发人员采用默认配置和标准布局来编写应用程序,而不需要在配置文件中显式地指定每个设置项。**例如,在 Spring Boot 应用程序中,可以在 “src/main/resources/application.properties” 文件中设置应用程序属性,而无需在 XML 文件中手动定义它们。此外,Spring Boot 还提供了许多默认的配置,例如将端口号默认配置为 8080 等。
"约定大于配置"使得开发人员能够更快、更高效地开发应用程序。
springboot如何做到main方法可以启动整个web应用的
在 Spring Boot 中,可以通过在 main 方法中调用 SpringApplication.run() 方法来启动整个 Web 应用程序。SpringApplication.run() 方法会执行以下步骤:
- 加载应用程序上下文:根据配置自动加载类路径下的配置文件,或者根据注解自动扫描包下的组件并创建配置类及其 Bean 实例。
- 启动内嵌的 Tomcat 或 Jetty 服务器:根据配置选择服务器类型并启动,监听默认端口 8080。
- 注册 Spring MVC 的 DispatcherServlet,处理 HTTP 请求,并分发给对应的 Controller 进行处理。
- 运行 Web 应用程序:循环处理传入的 HTTP 请求,将请求分发给对应的 Controller 进行处理并返回响应结果。
这样做的好处是,Spring Boot 可以统一管理应用程序上下文、Servlet 容器和 Spring MVC 等组件,简化了 Web 应用程序的开发和部署流程。同时,Spring Boot 还提供了丰富的配置文件和 Starter 依赖,可以轻松地集成常用的第三方框架和组件。
需要注意的是,Spring Boot 中的主类必须使用 @SpringBootApplication 注解进行标记,该注解包含了多个注解的组合,包括 @Configuration、@EnableAutoConfiguration 和 @ComponentScan 等,表示该类是一个配置类并启用了自动配置和组件扫描功能。因此,Spring Boot 在启动 Web 应用程序时,会自动加载这些组件并执行相应的初始化操作。
springboot相比于spring的好处
- 快速开发:Spring Boot 提供了很多约定大于配置的默认设置,可以快速地创建一个可运行的 Web 应用程序,并可以根据需要增加或修改配置项。
- 简化配置:Spring Boot 在底层自动配置了许多常用的框架和技术,例如数据库连接池、日志、Web 环境等,使得开发人员不需要像传统的 Spring 开发那样进行大量的配置。
- 内嵌服务器:Spring Boot 集成了内置的 Tomcat 和 Jetty 等服务器,无需额外部署 Web 服务器即可运行应用。
- 微服务支持:Spring Boot 对于微服务开发提供了完善的支持,可以快速搭建微服务架构,如使用 Spring Cloud 实现分布式配置中心、服务注册与发现、负载均衡和熔断器等功能。
- 易于维护:Spring Boot 的结构清晰、代码简洁,易于维护和扩展。
springboot框架的优势
SpringBoot框架的优势主要有以下几点:
- 简化开发:SpringBoot通过自动配置和约定大于配置的方式,可以快速、简单地创建和部署应用程序。它可以帮助开发人员集中精力编写业务逻辑,而不用花费太多时间处理框架本身的问题。
- 提高效率:SpringBoot框架提供了丰富的开箱即用的功能模块,例如Web开发、数据访问、安全认证、缓存等,在项目开发中可以减少代码量,提高开发效率。
- 易于配置和管理:SpringBoot采用约定大于配置的方式,让开发人员能够更轻松地理解和管理项目配置。同时SpringBoot也提供了一套完整的可扩展的配置体系,以满足各种不同场景下的需求。
- 良好的社区支持和生态系统:SpringBoot有一个庞大的社区支持,可以很容易地找到与之配套的插件和工具,这些插件和工具可以进一步提升开发效率和应用程序性能。
SpringBoot解决了传统JavaEE开发中需要手动配置大量组件的问题,通过自动配置的方式使开发人员更加关注业务逻辑本身,提高了开发效率。同时,SpringBoot提供了一套可扩展的配置体系,在应对不同场景的需求时更加方便,加速了应用程序的部署和管理。
Filter(过滤器)和Interceptor(拦截器)
作用:
- 过滤器:Filter是Servlet规范中定义的一种技术,它主要用于对请求和响应进行预处理和后处理。Filter可以在请求被发送到Web应用后,但在进入Servlet之前,对请求和响应进行一些通用的操作,例如修改编码、验证请求信息、过滤敏感信息、设置缓存等。通过Filter的应用,我们可以减轻Servlet的压力、提高系统性能,同时避免一些常见的安全漏洞。在Java Web应用中,我们可以通过实现javax.servlet.Filter接口,重写doFilter()方法来定义Filter。
- 拦截器:Interceptor是Spring框架中定义的一种技术,它可以在请求被处理前或处理后对其进行拦截和处理。Interceptor可以对请求进行拦截、记录日志、权限校验、性能监控等操作,同时也可以对响应进行处理,例如修改返回结果、设置缓存策略等。另外,Interceptor也可以用于AOP(面向切面编程),对方法执行进行前置/后置处理、异常处理、环绕通知等。在Spring框架中,我们可以通过实现org.springframework.web.servlet.HandlerInterceptor接口,重写preHandle()、postHandle()、afterCompletion()等方法来定义Interceptor。
执行顺序:用户请求 -> 过滤前 -> 拦截前 -> Action处理 -> 拦截后 -> 过滤后 -> 响应
区别:
- 功能不同:Filter主要用于对请求和响应进行预处理和后处理,例如编码转换、数据验证、解析请求内容等。而Interceptor可用于对请求进行拦截、记录日志、权限校验等操作。
- 应用范围不同:Filter是Servlet规范中定义的一种技术,只能在Web应用的上下文环境中使用;而Interceptor则是Spring框架中定义的一种技术,可以用于任何Spring管理的对象中,包括Web应用、Service层、DAO层等。
- Filter优先于Interceptor执行。
- 配置方式:Filter在配置时需要在web.xml中进行,而Interceptor需要在Spring配置文件中进行定义和配置。
- 在Spring中使用Interceptor更容易,而且几乎所有Finter能做的事情,Interceptor都可以实现。
代理
代理的概念
就是增强一个对象的功能。打个比方买火车票,12306的app就是一个代理,代理了火车站售票窗口的功能。小区当中的代售点也是代理,黄牛也是代理。他们替我买了,我就不需要去火车站售票窗口了,就相当于增强了售票窗口的功能。
代理的两种方式
静态代理和动态代理。
- 代理对象:增强后的对象(app,黄牛等等)。
- 目标对象:被增强的对象(火车站窗口)。
Java中代理对象和目标对象不是绝对的,会根据情况发生变化。比如代理对象也可能是另一个代理的目标对象。这就好比买黄牛手里火车票的不一定是乘客,也有可能是二道贩子。那么黄牛是火车站售票窗口的代理,二道贩子就是黄牛的代理,这就是代理对象和目标对象的身份做了变换。
静态代理
由程序员创建代理类或特定工具自动生成源代码再对其编译。在程序运行前代理类的.class文件就已经存在了。
优点
代理使客户端不需要知道实现类是什么,怎么做的,而客户端只需知道代理即可(解耦合)。
缺点
- 代理类和委托类实现了相同的接口,代理类通过委托类实现了相同的方法。这样就出现了大量的代码重复。如果接口增加一个方法,除了所有实现类需要实现这个方法外,所有代理类也需要实现此方法。增加了代码维护的复杂度。
- 代理对象只服务于一种类型的对象,如果要服务多类型的对象。势必要为每一种对象都进行代理,静态代理在程序规模稍大时就无法胜任了。
举例说明:代理可以对实现类进行统一的管理,如在调用具体实现类之前,需要打印日志等信息,这样我们只需要添加一个代理类,在代理类中添加打印日志的功能,然后调用实现类,这样就避免了修改具体实现类。但是如果想让每个实现类都添加打印日志的功能的话,就需要添加多个代理类,以及代理类中各个方法都需要添加打印日志功能(如上的代理方法中删除,修改,以及查询都需要添加上打印日志的功能)
即静态代理类只能为特定的接口(Service)服务。如想要为多个接口服务则需要建立很多个代理类。
动态代理
在程序运行时运用反射机制动态创建而成。
优点
动态代理与静态代理相比较,最大的好处是接口中声明的所有方法都被转移到调用处理器一个集中的方法中处理(InvocationHandler.invoke)。这样,在接口方法数量比较多的时候,我们可以进行灵活处理,而不需要像静态代理那样每一个方法进行中转。而且动态代理的应用使我们的类职责更加单一,复用性更强
缺点
- 动态代理只能代理实现了接口的类,无法代理普通的类,限制了它的使用场景。
- 由于动态代理是在程序运行期间创建代理的,它会消耗一定的系统资源,对系统性能可能会有一定的影响。
- 动态代理的调试比较困难,因为代理类是由框架在运行时动态生成的,难以直接对其进行调试。
Spring, SpringMVC, SpringBoot的区别
Spring、Spring MVC 和 Spring Boot 都是 Spring Framework 的一部分,但它们的定位和作用不同。
Spring 是一个开源的 Java 开发框架,提供了一套完整的解决方案来简化企业级应用程序的开发。它包含了一系列的模块,比如核心容器、数据访问、Web、AOP、消息、测试等等。Spring 框架被广泛应用于 Web 应用程序、企业应用程序等领域。
Spring MVC 是 Spring 中的一个模块,也就是 Spring Web MVC,用于构建基于 MVC架构的 Web 应用程序。Spring MVC 通过 DispatcherServlet 组件将请求路由到控制器,以及将处理结果渲染成视图,并返回给用户。
Spring Boot 是基于 Spring 框架,使用约定优于配置的方式快速创建生产级别的、独立运行的 Spring 应用程序。Spring Boot 可以自动配置 Spring 应用程序,并提供了诸多开箱即用的特性,如嵌入式服务器、端点监控、 日志管理、数据库访问等等。Spring Boot 的目标是实现快速、简单、便捷的构建 Spring 应用程序。
Spring 是一个完整的 Java 开发框架,涵盖多个领域;Spring MVC 是 Spring Web 模块中的一个模块,主要用于构建基于 MVC 结构的 Web 应用程序;而 Spring Boot 则是在 Spring 的基础上,提供了更简单、快速的方式来构建、配置和部署 Spring 应用程序。
如何定义一个全局异常处理类
想要定义一个全局异常处理类的话,我们需要在这个类上添加@ContaollerAdvice注解,然后定义一些用于捕捉不同异常类型的方法,在这些方法上添加@ExceptionHandler(value = 异常类型.class)和@ResponseBody注解,方法参数是HttpServletRequest和异常类型,然后将异常消息进行处理。
如果我们需要自定义异常的话,就写一个自定义异常类,该类需要继承一个异常接口,类属性包括final类型的连续id、错误码、错误信息,再根据需求写构造方法;
微服务
SpringCloud
springcloud主要解决什么问题
解决服务之间的通信、容灾、负载平衡、冗余问题,能方便服务集中管理,常用组件有注册中心、配置中心、远程调用。服务熔断、网关
SpringCloud常见组件有哪些
SpringCloud包含的组件很多,有很多功能是重复的。其中最常用组件包括:
- 注册中心组件:Eureka、Nacos等
- 负载均衡组件:Ribbon
- 远程调用组件:OpenFeign
- 网关组件:Zuul、Gateway
- 服务保护组件:Hystrix、Sentinel
- 服务配置管理组件:SpringCloudConfig、Nacos
Nacos
nacos是什么
nacos是一个服务的注册和发现中心,配置管理中心
为什么要将服务注册到nacos
为了更好的查找这些服务
nacos中的配置优先级
引入配置文件的形式有:
- 以项目应用名方式引入
- 以扩展配置文件方式引入
- 以共享配置文件 方式引入
- 本地配置文件
各配置文件 的优先级:项目应用名配置文件 > 扩展配置文件 > 共享配置文件 > 本地配置文件。
nacos的健康检测机制
如果是临时实例,则不会在 Nacos 服务端持久化存储,需要通过上报心跳的方式进行保活,如果一段时间内没有上报心跳,则会被 Nacos 服务端摘除。在被摘除后如果又开始上报心跳,则会重新将这个实例注册。持久化实例则会持久化到Nacos 服务端,通过主动探知客户端健康的方式进行检测,此时即使注册实例的客户端进程不在,这个实例也不会从服务端删除,只会将健康状态设为不健康。
Nacos的服务注册表结构是怎样的
Nacos采用了数据的分级存储模型,最外层是Namespace,用来隔离环境。然后是Group,用来对服务分组。接下来就是服务(Service)了,一个服务包含多个实例,但是可能处于不同机房,因此服务下有多个集群(Cluster),集群下是不同的实例(Instance)。
对应到Java代码中,Nacos采用了一个多层的Map来表示。结构为Map<String, Map<String, Service>>,其中最外层Map的key就是namespaceId,值是一个Map。内层Map的key是group拼接serviceName,值是Service对象。Service对象内部又是一个Map,key是集群名称,值是Cluster对象。而Cluster对象内部维护了Instance的集合。
Nacos如何支撑阿里内部数十万服务注册压力
Nacos内部接收到注册的请求时,不会立即写数据,而是将服务注册的任务放入一个阻塞队列就立即响应给客户端。然后利用线程池读取阻塞队列中的任务,异步来完成实例更新,从而提高并发写能力。
Nacos如何避免并发读写冲突问题
Nacos在更新实例列表时,会采用CopyOnWrite技术,首先将旧的实例列表拷贝一份,然后更新拷贝的实例列表,再用更新后的实例列表来覆盖旧的实例列表。
这样在更新的过程中,就不会对读实例列表的请求产生影响,也不会出现脏读问题了。
Nacos与Eureka的区别有哪些
Nacos与Eureka有相同点,也有不同之处,可以从以下几点来描述:
- 接口方式(相同):Nacos与Eureka都对外暴露了Rest风格的API接口,用来实现服务注册、发现等功能
- 实例类型:Nacos的实例有永久和临时实例之分;而Eureka只支持临时实例
- 健康检测:Nacos对临时实例采用心跳模式检测,对永久实例采用主动请求来检测;Eureka只支持心跳模式
- 服务发现:Nacos支持定时拉取和订阅推送两种模式;Eureka只支持定时拉取模式
Ribbon
项目负载均衡如何实现的
在服务调用过程中的负载均衡一般使用SpringCloud的Ribbon 组件实现 , Feign的底层已经自动集成了Ribbon , 使用起来非常简单
当发起远程调用时,ribbon先从注册中心拉取服务地址列表,然后按照一定的路由策略选择一个发起远程调用,一般的调用策略是轮询
Ribbon负载均衡策略有哪些
- RoundRobinRule:简单轮询服务列表来选择服务器
- WeightedResponseTimeRule:按照权重来选择服务器,响应时间越长,权重越小
- RandomRule:随机选择一个可用的服务器
- ZoneAvoidanceRule:区域敏感策略,以区域可用的服务器为基础进行服务器的选择。使用Zone对服务器进行分类,这个Zone可以理解为一个机房、一个机架等。而后再对Zone内的多个服务做轮询(默认)
自定义负载均衡策略如何实现
提供了两种方式:
- 创建类实现IRule接口,可以指定负载均衡策略,这个是全局的,对所有的远程调用都起作用
- 在客户端的配置文件中,可以配置某一个服务调用的负载均衡策略,只是对配置的这个服务生效远程调用
限流
常用限流算法
计数器算法:使用redis的setnx和过期机制实现
漏桶算法:一般使用消息队列来实现,系统以恒定速度处理队列中的请求,当队列满的时候开始拒绝请求。可以让我们的服务做到绝对的平均,起到很好的限流效果
令牌桶算法:计数器算法和漏桶算法都无法解决突然的大并发,令牌桶算法是预先往桶中放入一定数量token,然后用恒定速度放入token直到桶满为止,所有请求都必须拿到token才能访问系统
它们的区别是,漏桶和令牌桶都可以处理突发流量,其中漏桶可以做到绝对的平滑,令牌桶有可能会产生突发大量请求的情况,一般nginx限流采用的漏桶,spring cloud gateway中可以支持令牌桶算法
限流的具体实现
采用spring cloud gateway中支持局部过滤器RequestRateLimiter来做限流,使用的是令牌桶算法,可以根据ip或路径进行限流,可以设置每秒填充平均速率,和令牌桶总容量
熔断限流的理解
SprngCloud中用Hystrix组件来进行降级、熔断、限流
熔断是对于消费者来讲,当对提供者请求时间过久时为了不影响性能就对链接进行熔断,
限流是对于提供者来讲,为了防止某个消费者流量太大,导致其它更重要的消费者请求无法及时处理。限流可用通过拒绝服务、服务降级、消息队列延时处理、限流算法来实现
Sentinel的限流与Gateway的限流有什么差别
限流算法常见的有三种实现:滑动时间窗口、令牌桶算法、漏桶算法。Gateway则采用了基于Redis实现的令牌桶算法。
而Sentinel内部却比较复杂:
- 默认限流模式是基于滑动时间窗口算法
- 排队等待的限流模式则基于漏桶算法
- 而热点参数限流则是基于令牌桶算法
Sentinel的线程隔离与Hystix的线程隔离有什么差别
Hystix默认是基于线程池实现的线程隔离,每一个被隔离的业务都要创建一个独立的线程池,线程过多会带来额外的CPU开销,性能一般,但是隔离性更强。
Sentinel是基于信号量(计数器)实现的线程隔离,不用创建线程池,性能较好,但是隔离性一般。
CAP理论
C:一致性,这里指的强一致性,也就是数据更新完,访问任何节点看到的数据完全一致
A:可用性,就是任何没有发生故障的服务必须在规定时间内返回正确结果
P:容灾性,当网络不稳定时节点之间无法通信,造成分区,这时要保证系统可以继续正常服务。提高容灾性的办法就是把数据分配到每一个节点当中,所以P是分布式系统必须实现的,然后需要在C和A中取舍
为什么不能同时保证一致性和可用性呢
当网络发生故障时,如果要保障数据一致性,那么节点相互间就只能阻塞等待数据真正同步时再返回,就违背可用性了。如果要保证可用性,节点要在有限时间内将结果返回,无法等待其它节点的更新消息,此时返回的数据可能就不是最新数据,就违背了一致性了
BASE理论
这个也是CAP分布式系统设计理论
BASE是CAP理论中AP方案的延伸,核心思想是即使无法做到强一致性(StrongConsistency,CAP的一致性就是强一致性),但应用可以采用适合的方式达到最终一致性(Eventual Consitency)。它的思想包含三方面:
- Basically Available(基本可用):基本可用是指分布式系统在出现不可预知的故障的时候,允许损失部分可用性,但不等于系统不可用。
- Soft state(软状态):即是指允许系统中的数据存在中间状态,并认为该中间状态的存在不会影响系统的整体可用性,即允许系统在不同节点的数据副本之间进行数据同步的过程存在延时。
- Eventually consistent(最终一致性):强调系统中所有的数据副本,在经过一段时间的同步后,最终能够达到一个一致的状态。其本质是需要系统保证最终数据能够达到一致,而不需要实时保证系统数据的强一致性。
常见问题
什么是服务雪崩,怎么解决这个问题
服务雪崩是指一个服务失败,导致整条链路的服务都失败的情形,一般我们在项目解决的话就是两种方案,第一个是服务降级,第二个是服务熔断,如果流量太大的话,可以考虑限流
服务降级:服务自我保护的一种方式,或者保护下游服务的一种方式,用于确保服务不会受请求突增影响变得不可用,确保服务不会崩溃,一般在实际开发中与feign接口整合,编写降级逻辑
服务熔断:默认关闭,需要手动打开,如果检测到 10 秒内请求的失败率超过 50%,就触发熔断机制。之后每隔 5 秒重新尝试请求微服务,如果微服务不能响应,继续走熔断机制。如果微服务可达,则关闭熔断机制,恢复正常请求
微服务是怎么监控的
我们项目中采用的skywalking进行监控的
- skywalking主要可以监控接口、服务、物理实例的一些状态。特别是在压测的时候可以看到众多服务中哪些服务和接口比较慢,我们可以针对性的分析和优化。
- 我们还在skywalking设置了告警规则,特别是在项目上线以后,如果报错,我们分别设置了可以给相关负责人发短信和发邮件,第一时间知道项目的bug情况,第一时间修复
什么是幂等性
所谓的幂等性,是分布式环境下的一个常见问题,一般是指我们在进行多次操作时,所得到的结果是一样的,即多次运算结果是一致的。也就是说,用户对于同一操作,无论是发起一次请求还是多次请求,最终的执行结果是一致的,不会因为多次点击而产生副作用。
分布式服务的接口幂等性如何设计?
可以采用token+redis实现,
第一次请求,也就是用户打开了商品详情页面,我们会发起一个请求,在后台生成一个唯一token存入redis,key就是用户的id,value就是这个token,同时把这个token返回前端
第二次请求,当用户点击了下单操作会后,会携带之前的token,后台先到redis进行验证,如果存在token,可以执行业务,同时删除token;如果不存在,则直接返回,不处理业务,就保证了同一个token只处理一次业务,就保证了幂等性
Redis系列
Redis中的数据类型
String:普通字符串,可以用来缓存json信息,可以用incr命令实现自增或自减的计数器
Hash:存储的是一个键值对
List:有序集合,按照插入顺序排序,可以有重复元素,可以用来做消息队列,list的pop是原子性操作能一定程度保证线程安全
Set:无序集合,没有重复元素,可以做去重,比如一个用户只能参加一次活动 ;
SortSet/zset :集合中每个元素关联一个分数(score),根据分数升序排序,没有重复元素,有序的。可以实现排行榜
redis为什么快
- 完全基于内存操作
- 数据结构简单,对数据操作简单
- redis执行命令是单线程的,避免了上下文切换带来的性能问题,也不用考虑锁的问题
- 采用了非阻塞的io多路复用机制,使用了单线程来处理并发的连接;内部采用的epoll+自己实现的事件分离器
其实Redis不是完全多线程的,在核心的网络模型中是多线程的用来处理并发连接,但是数据的操作都是单线程。Redis坚持单线程是因为Redis是的性能瓶颈是网络延迟而不是CPU,多线程对数据读取不会带来性能提升。
Redis采用单线程如何保证高并发
因为它很快,快的原因在上边
Redis采用单线程的优势
- 代码更清晰,处理逻辑更简单
- 不用去考虑各种锁的问题,不存在加锁释放锁操作,没有因为锁而导致的性能消耗
- 不存在多进程或者多线程导致的CPU切换,充分利用CPU资源
redis持久化机制
Redis是基于内存的,如果Redis服务器挂了,数据就会丢失。为了避免数据丢失了,Redis提供了两种持久化方式,RDB和AOF。
快照持久化RDB
redis的默认持久化机制,通过父进程fork一个子进程,子进程将redis的数据快照写入一个临时文件,等待持久化完毕后替换上一次的rdb文件。整个过程主进程不进行任何的io操作。持久化策略可以通过save配置单位时间内执行多少次操作触发持久化。所以RDB的优点是保证redis性能最大化,恢复速度数据较快,缺点是可能会丢失两次持久化之间的数据
RDB持久化的优点:
- RDB持久化文件小,Redis数据恢复时速度快
- 子进程不影响父进程,父进程可以持续处理客户端命令
- 子进程fork时采用copy-on-write方式,大多数情况下,没有太多的内存消耗,效率比较好。
RDB 持久化的缺点:
- 子进程fork时采用copy-on-write方式,如果Redis此时写操作较多,可能导致额外的内存占用,甚至内存溢出
- RDB文件压缩会减小文件体积,但通过时会对CPU有额外的消耗
- 如果业务场景很看重数据的持久性 (durability),那么不应该采用 RDB 持久化。譬如说,如果 Redis 每 5 分钟执行一次 RDB 持久化,要是 Redis 意外奔溃了,那么最多会丢失 5 分钟的数据。
追加持久化AOF
以日志形式记录每一次的写入和删除操作,策略有每秒同步、每次操作同步、不同步,优点是数据完整性高,缺点是运行效率低,恢复时间长
AOF是执行完命令后才记录日志的。为什么不先记录日志再执行命令呢?这是因为Redis在向AOF记录日志时,不会先对这些命令进行语法检查,如果先记录日志再执行命令,日志中可能记录了错误的命令,Redis使用日志回复数据时,可能会出错。
AOF的三种写回策略:
- always,同步写回,每个子命令执行完,都立即将日志写回磁盘。
- everysec,每个命令执行完,只是先把日志写到AOF内存缓冲区,每隔一秒同步到磁盘。
- no:只是先把日志写到AOF内存缓冲区,有操作系统去决定何时写入磁盘。
优点:
- 持久化频率高,数据可靠性高
- 没有额外的内存或CPU消耗
缺点:
- 文件体积大
- 文件大导致服务数据恢复时效率较低
RDB 和 AOF 两种方式也可以同时使用,在这种情况下,如果 redis 重启的话,则会 优先采用 AOF 方式来进行数据恢复,这是因为 AOF 方式的数据恢复完整度更高。
这两种方式哪种的恢复速度比较快
RDB因为是二进制文件,在保存的时候体积也是比较小的,它恢复的比较快,但是它有可能会丢数据,我们通常在项目中也会使用AOF来恢复数据,虽然AOF恢复的速度慢一些,但是它丢数据的风险要小很多。
Redis4.0开始支持RDB和AOF的混合持久化,就是内存快照以一定频率执行,两次快照之间,再使用AOF记录这期间的所有命令操作。
RDB和AOF该如何选择
- 如果数据不能丢失,RDB和AOF混用
- 如果只作为缓存使用,可以承受几分钟的数据丢失的话,可以只使用RDB。
- 如果只使用AOF,优先使用everysec(每个命令执行完,只是先把日志写到AOF内存缓冲区,每隔一秒同步到磁盘。)的写回策略。
Redis为什么要进行内存回收
- 在Redis中,set指令可以指定key的过期时间,当过期时间到达以后,key就失效了;
- Redis是基于内存操作的,所有的数据都是保存在内存中,一台机器的内存是有限且很宝贵的。
基于以上两点,为了保证Redis能继续提供可靠的服务,Redis需要一种机制清理掉不常用的、无效的、多余的数据,失效后的数据需要及时清理,这就需要内存回收了。
Redis中key的过期删除策略
采用的定期删除+惰性删除
定期删除 :Redis 每隔一段时间从设置过期时间的 key 集合中,随机抽取一些 key ,检查是否过期,如果已经过期做删除处理。
惰性删除 :Redis 在 key 被访问的时候检查 key 是否过期,如果过期则删除。
Redis缓存穿透
缓存穿透是指频繁请求客户端和缓存中都不存在的数据,缓存永远不生效,请求都到达了数据库。
解决方案:
- 在接口上做基础校验,比如id<=0就拦截
- 缓存空对象:找不到的数据也缓存起来,并设置过期时间,可能会造成短期不一致
- 布隆过滤器:在客户端和缓存之间添加一个过滤器,拦截掉一定不存在的数据请求
- 进行实时监控:对于redis缓存中命中率急速下降时,迅速排查访问对象和访问数据,将其设置为黑名单。
布隆过滤器
布隆过滤器可以用于检索一个元素是否在一个集合中。它的优点是空间效率和查询时间都比一般的算法要好的多,缺点是有一定的误识别率和删除困难。
优点:
- 时间复杂度低,增加和查询元素的时间复杂为O(N),(N为哈希函数的个数,通常情况比较小)
- 保密性强,布隆过滤器不存储元素本身
- 存储空间小,如果允许存在一定的误判,布隆过滤器是非常节省空间的(相比其他数据结构如Set集合)
缺点:
- 有点一定的误判率,但是可以通过调整参数来降低
- 无法获取元素本身
- 很难删除元素
原理
Redis中的布隆过滤器底层是一个大型位数组(二进制数组)+多个无偏hash函数。
Redis缓存击穿
缓存击穿是指一个key非常热点,key在某一瞬间失效,导致大量请求到达数据库
解决方案:
- 预先设置热门数据:在redis高峰访问时期,提前设置热门数据到缓存中,或适当延长缓存中key过期时间。
- 实时调整:实时监控哪些数据热门,实时调整key过期时间。
- 给缓存重建的业务加上互斥锁,只有一个请求可以获取到互斥锁,然后到DB中将数据查询并返回到Redis,之后所有请求就可以从Redis中得到响应。缺点:所有线程的请求需要一同等待,性能低。
- 设置热点数据永不过期
解决方案有两种方式:
第一可以使用互斥锁:当缓存失效时,不立即去load db,先使用如 Redis 的 setnx 去设置一个互斥锁,当操作成功返回时再进行 load db的操作并回设缓存,否则重试get缓存的方法
第二种方案可以设置当前key逻辑过期,大概是思路如下:
- :在设置key的时候,设置一个过期时间字段一块存入缓存中,不给当前key设置过期时间
- :当查询的时候,从redis取出数据后判断时间是否过期
- :如果过期则开通另外一个线程进行数据同步,当前线程正常返回数据,这个数据不是最新
当然两种方案各有利弊:
如果选择数据的强一致性,建议使用分布式锁的方案,性能上可能没那么高,锁需要等,也有可能产生死锁的问题
如果选择key的逻辑删除,则优先考虑的高可用性,性能比较高,但是数据同步这块做不到强一致。
Redis缓存雪崩
缓存雪崩是指某一时间Key同时失效或redis宕机,导致大量请求到达数据库
解决方案:
- 搭建集群保证高可用
- 进行数据预热,给不同的key设置随机的过期时间
- 给缓存业务添加限流降级,通过加锁或队列控制操作redis的线程数量
- 给业务添加多级缓存
Redis如何实现数据库与缓存数据一致
- 本地缓存同步:当前微服务的数据库数据与缓存数据同步,可以直接在数据库修改时加入对Redis的修改逻辑,保证一致。
- 跨服务缓存同步:服务A调用了服务B,并对查询结果缓存。服务B数据库修改,可以通过MQ通知服务A,服务A修改Redis缓存数据
- 通用方案:使用Canal框架,伪装成MySQL的salve节点,监听MySQL的binLog变化,然后修改Redis缓存数据
Redis实现分布式锁
redis的分布式锁是阻塞还是非阻塞的
Redis的分布式锁可以是阻塞也可以是非阻塞,具体取决于使用者在使用时所选择的命令。
常用的分布式锁实现方式之一是通过RedLock算法实现的。在使用RedLock实现分布式锁时,Redis的命令选择是阻塞的。具体来说,当一个client尝试去获取锁时,如果锁已被其他client占用,则该client会进入阻塞状态,等待锁被释放,直到获取到锁为止。
Redis还提供了另外一种分布式锁实现方式——使用SET命令实现的单实例锁。在使用SET命令实现分布式锁时,Redis的命令是非阻塞的。具体来说,当一个client尝试去获取锁时,如果锁已被其他client占用,则该client不会进入阻塞状态,而是立即返回获取锁失败的信息,需要调用方根据情况进行重试或回退操作。
实现原理
原理是使用setnx+setex命令来实现,这个命令的特征时如果多次执行,只有第一次执行会成功,可以实现互斥
的效果。但是会有一系列问题:
(1)任务时长超过缓存时间,锁自动释放。可以使用Redision看门狗解决
看门狗:在锁将要过期的时候,如果服务还没有处理完业务,那么将这个锁再续一段时间。比如设置key在10s后过期,那么再开启一个守护线程,在第8s的时候检测服务是否处理完,如果没有,则将这个key再续10s后过期。
(2)加锁和释放锁的不是同一线程。可以在Value中存入uuid,删除时进行验证。但是要注意验证锁和删除锁也不是一个原子性操作,可以用lua脚本使之成为原子性操作
(3)不可重入。可以使用Redision解决(实现机制类似AQS,计数)
(4)redis集群下主节点宕机导致锁丢失。使用RedLock解决
SET key value[EX seconds][PX milliseconds][NX|XX]
NX :表示key不存在的时候,才能set成功,也即保证只有第一个客户端请求才能获得锁,而其他客户端请求只能等其释放锁,才能获取。
EX seconds :设定key的过期时间,时间单位是秒。
PX milliseconds: 设定key的过期时间,单位为毫秒
XX: 仅当key存在时设置值
比如下面这段
set serverLock content 1 ex 10 nx
代表服务content
设置一个10s后过期的锁
redis实现分布式锁要满足的条件
- 多进程互斥:同一时刻,只有一个进程可以获取锁
- 保证锁可以释放:任务结束或出现异常,锁一定要释放,避免死锁
- 阻塞锁(可选):获取锁失败时可否重试
- 重入锁(可选):获取锁的代码递归调用时,依然可以获取锁
RedLock
要实现RedLock,需要至少5个实例(官方推荐),且每个实例都是master,不需要从库和哨兵。
实现流程
1、客户端先获取当前时间戳T1
2、客户端依次向5个master实例发起加锁命令,且每个请求都会设置超时时间(毫秒级,注意:不是锁的超时时间),如果某一个master实例由于网络等原因导致加锁失败,则立即想下一个master实例申请加锁。
3、当客户端加锁成功的请求大于等于3个时,且再次获取当前时间戳T2,
当时间戳T2 - 时间戳T1 < 锁的过期时间。则客户端加锁成功,否则失败。
4、加锁成功,开始操作公共资源,进行后续业务操作
5、加锁失败,向所有redis节点发送锁释放命令
即当客户端在大多数redis实例上申请加锁成功后,且加锁总耗时小于锁过期时间,则认为加锁成功。
释放锁需要向全部节点发送锁释放命令。
第3步为啥要计算申请锁前后的总耗时与锁释放时间进行对比呢?
因为如果申请锁的总耗时已经超过了锁释放时间,那么可能前面申请redis的锁已经被释放掉了,保证不了大于等于3个实例都有锁存在了,锁也就没有意义了
Redis集群
redis集群方案
- 主从模式:单节点Redis的并发能力是有上限的,要进一步提高Redis的并发能力,可以搭建主从集群,实现读写分离。一般都是一主多从,主节点负责写数据,从节点负责读数据,主节点写入数据之后,需要把数据同步到从节点中,主节点宕机从节点自动变成主节点
- 哨兵模式:在主从集群基础上添加哨兵节点或哨兵集群,用于监控master节点健康状态,通过投票机制选择slave成为主节点
- 分片集群:主从模式和哨兵模式解决了并发读的问题,但没有解决并发写的问题,因此有了分片集群。分片集群有多个master节点并且不同master保存不同的数据,master之间通过ping相互监测健康状态。客户端请求任意一个节点都会转发到正确节点,因为每个master都被映射到0-16384个插槽上,集群的key是根据key的hash值与插槽绑定
Redis集群主从同步原理
主从同步第一次是全量同步:从节点第一次请求主节点会根据replication id判断是否是第一次同步,是的话主节点会生成RDB发送给从节点。
后续为增量同步:在发送RDB期间,会产生一个缓存区间记录发送RDB期间产生的新的命令,从节点节点在加载完后,会持续读取缓存区间中的数据
redis的分片集群有什么作用
分片集群主要解决的是,海量数据存储的问题,集群中有多个master,每个master保存不同数据,并且还可以给每个master设置多个slave节点,就可以继续增大集群的高并发能力。同时每个master之间通过ping监测彼此健康状态,就类似于哨兵模式了。当客户端请求可以访问集群任意节点,最终都会被转发到正确节点
Redis分片集群中数据是怎么存储和读取的?
Redis 集群引入了哈希槽的概念,有 16384 个哈希槽,集群中每个主节点绑定了一定范围的哈希槽范围, key通过 CRC16 校验后对 16384 取模来决定放置哪个槽,通过槽找到对应的节点进行存储。
取值的逻辑是一样的
Redis缓存一致性解决方案
Redis缓存一致性解决方案主要思考的是删除缓存和更新数据库的先后顺序
先删除缓存后更新数据库存在的问题是可能会数据不一致,一般使用延时双删来解决,即先删除缓存,再更新数据库,休眠X秒后再次淘汰缓存。第二次删除可能导致吞吐率降低,可以考虑进行异步删除。
先更新数据库后删除缓存存在的问题是会可能会更新失败,可以采用延时删除。但由于读比写快,发生这一情况概率较小。
但是无论哪种策略,都可能存在删除失败的问题,解决方案是用中间件canal订阅binlog日志提取需要删除的key,然后另写一段非业务代码去获取key并尝试删除,若删除失败就把删除失败的key发送到消息队列,然后进行删除重试。
Redis内存淘汰策略
当内存不足时按设定好的策略进行淘汰,策略有(1)淘汰最久没使用的(2)淘汰一段时间内最少使用的(3)淘汰快要过期的
redis默认是不删除任何数据,内存不足会直接报错
Redis为什么高可用
- 数据持久化:Redis 提供了多种数据持久化方式,包括快照(RDB)和追加式文件(AOF)两种,可以保证数据不会因为进程或系统故障而丢失。在 Redis 高可用方案中,通常使用 AOF 持久化机制,同时配合主从复制,保证数据的高可靠性。
- 主从复制:Redis 支持主从复制功能,可以将主节点的数据同步到多个从节点上,可以在主节点出现问题时,快速切换到从节点上提供服务,保证系统的高可用性。
- 哨兵模式:Redis Sentinel 是 Redis 官方提供的高可用解决方案,可以自动监控 Redis 的主从复制、哨兵节点是否正常等状态,并在主节点出现故障时自动进行故障转移,选举新的主节点,从而保证系统的高可用性。
- 集群模式:Redis 3.0 版本开始支持集群模式,可以将数据分片存储在不同的节点上,一旦某个节点出现故障,只会影响到部分数据,其他数据仍然可以正常访问,从而提高系统的容错能力和可用性。
综上所述,Redis 采用多种技术手段,如数据持久化、主从复制、Sentinel 哨兵模式和 Cluster 集群模式等,保证系统的高可用性,从而适用于诸多高可用场景。
Redis的常用场景
- 缓存:Redis 最常用的应该就是做缓存了。使用 Redis 可以将经常访问但不需要实时计算的数据存入内存中,使得请求的响应速度更快,并且减少了数据库的负载压力。
- 分布式锁:Redis 通过 SETNX 命令和 Lua 脚本可以很容易地实现分布式锁。
- 消息队列:Redis 提供了 List 数据类型,可以通过 lpush/rpop、rpush/lpop 等操作实现消息的异步发送和接收。
- 共享session:在分布式系统下,服务会部署在不同的tomcat,因此多个tomcat的session无法共享,以前存储在session中的数据无法实现共享,可以用redis代替session,解决分布式系统间数据共享问题。
redis中的IO多路复用
I/O多路复用是指利用单个线程来同时监听多个Socket ,并在某个Socket可读、可写时得到通知,从而避免无效的等待,充分利用CPU资源。目前的I/O多路复用都是采用的epoll模式实现,它会在通知用户进程Socket就绪的同时,把已就绪的Socket写入用户空间,不需要挨个遍历Socket来判断是否就绪,提升了性能。
其中Redis的网络模型就是使用I/O多路复用结合事件的处理器来应对多个Socket请求,比如,提供了连接应答处理器、命令回复处理器,命令请求处理器;
在Redis6.0之后,为了提升更好的性能,在命令回复处理器使用了多线程来处理回复事件,在命令请求处理器中,将命令的转换使用了多线程,增加命令转换速度,在命令执行的时候,依然是单线程
Redis相关场景题
数据库有1000万数据 ,Redis只能缓存20w数据, 如何保证Redis中的数据都是热点数据 ?
可以使用 allkeys-lru (挑选最近最少使用的数据淘汰)淘汰策略,那留下来的都是经常访问的热点数据
Redis的内存用完了会发生什么?
这个要看redis的数据淘汰策略是什么,如果是默认的配置,redis内存用完以后则直接报错。
redisson实现的分布式锁是可重入的吗?
是可以重入的。这样做是为了避免死锁的产生。这个重入其实在内部就是判断是否是当前线程持有的锁,如果是当前线程持有的锁就会计数,如果释放锁就会在计算上减一。在存储数据的时候采用的hash结构,大key可以按照自己的业务进行定制,其中小key是当前线程的唯一标识,value是当前线程重入的次数
redisson实现的分布式锁能解决主从一致性的问题吗
这个是不能的,比如,当线程1加锁成功后,master节点数据会异步复制到slave节点,此时当前持有Redis锁的master节点宕机,slave节点被提升为新的master节点,假如现在来了一个线程2,再次加锁,会在新的master节点上加锁成功,这个时候就会出现两个节点同时持有一把锁的问题。
我们可以利用redisson提供的红锁来解决这个问题,它的主要作用是,不能只在一个redis实例上创建锁,应该是在多个redis实例上创建锁,并且要求在大多数redis节点上都成功创建锁,红锁中要求是redis的节点数量要过半。这样就能避免线程1加锁成功后master节点宕机导致线程2成功加锁到新的master节点上的问题了。
但是,如果使用了红锁,因为需要同时在多个节点上都添加锁,性能就变的很低了,并且运维维护成本也非常高,所以,我们一般在项目中也不会直接使用红锁,并且官方也暂时废弃了这个红锁
如果业务非要保证数据的强一致性,这个该怎么解决呢?
redis本身就是支持高可用的,做到强一致性,就非常影响性能,所以,如果有强一致性要求高的业务,建议使用zookeeper实现的分布式锁,它是可以保证强一致性的。
RabbitMQ
RabbitMQ的优势
kafka吞吐量高,不过其数据稳定性一般,而且无法保证消息有序性。
RocketMQ基于Kafka的原理,弥补了Kafka的缺点,继承了其高吞吐的优势,其客户端目前以Java为主。但是我们担心阿里巴巴开源产品的稳定性,所以就没有使用。
RabbitMQ基于面向并发的语言Erlang开发,吞吐量不如Kafka,但是对我们公司来讲够用了。而且消息可靠性较好,并且消息延迟极低,集群搭建比较方便。支持多种协议,并且有各种语言的客户端,比较灵活。Spring对RabbitMQ的支持也比较好,使用起来比较方便,比较符合我们公司的需求。
综合考虑并发需求以及稳定性需求,选择了RabbitMQ。
RabbitMQ如何保证消息不丢失
一般来说有三种情况会导致消息丢失:
第一种:生产者弄丢了数据。生产者将数据发送到 RabbitMQ 的时候,可能数据就在半路给搞丢了,因为网络问题啥的,都有可能。
第二种:RabbitMQ 弄丢了数据。MQ还没有持久化自己挂了。
第三种:消费端弄丢了数据。刚消费到,还没处理,结果进程挂了,比如重启了。
针对生产者
方案1 :开启RabbitMQ事务
可以选择用 RabbitMQ 提供的事务功能,就是生产者发送数据之前开启 RabbitMQ 事务channel.txSelect,然后发送消息,如果消息没有成功被 RabbitMQ 接收到,那么生产者会收到异常报错,此时就可以回滚事务channel.txRollback,然后重试发送消息;如果收到了消息,那么可以提交事务channel.txCommit。
// 开启事务
channel.txSelect();
try {
// 这里发送消息
} catch (Exception e) {
channel.txRollback();
// 这里再次重发这条消息
}
// 提交事务
channel.txCommit();
缺点:
RabbitMQ 事务机制是同步的,你提交一个事务之后会阻塞在那儿,采用这种方式基本上吞吐量会下来,因为太耗性能。
方案2:使用confirm机制
事务机制和 confirm 机制最大的不同在于,事务机制是同步的,你提交一个事务之后会阻塞在那儿,但是 confirm 机制是异步的
在生产者开启了confirm模式之后,每次写的消息都会分配一个唯一的id,然后如果写入了rabbitmq之中,rabbitmq会给你回传一个ack消息,告诉你这个消息发送OK了;如果rabbitmq没能处理这个消息,会回调你一个nack接口,告诉你这个消息失败了,你可以进行重试。而且你可以结合这个机制知道自己在内存里维护每个消息的id,如果超过一定时间还没接收到这个消息的回调,那么你可以进行重发。
//开启confirm
channel.confirm();
//发送成功回调
public void ack(String messageId){
}
// 发送失败回调
public void nack(String messageId){
//重发该消息
}
针对RabbitMQ
主要需要应对三点:
要保证rabbitMQ不丢失消息,那么就需要开启rabbitMQ的持久化机制,即把消息持久化到硬盘上,这样即使rabbitMQ挂掉在重启后仍然可以从硬盘读取消息;
如果rabbitMQ单点故障怎么办,这种情况倒不会造成消息丢失,这里就要提到rabbitMQ的3种安装模式,单机模式、普通集群模式、镜像集群模式,这里要保证rabbitMQ的高可用就要配合HAPROXY做镜像集群模式;
如果硬盘坏掉怎么保证消息不丢失。
(1)消息持久化
RabbitMQ 的消息默认存放在内存上面,如果不特别声明设置,消息不会持久化保存到硬盘上面的,如果节点重启或者意外crash掉,消息就会丢失。
所以就要对消息进行持久化处理。如何持久化,下面具体说明下。要想做到消息持久化,必须满足以下三个条件,缺一不可。
- Exchange 设置持久化
- Queue 设置持久化
- Message持久化发送:发送消息设置发送模式deliveryMode=2,代表持久化消息
(2)设置集群镜像模式
先来介绍下RabbitMQ三种部署模式:
- 单节点模式:最简单的情况,非集群模式,节点挂了,消息就不能用了。业务可能瘫痪,只能等待。
- 普通模式:消息只会存在与当前节点中,并不会同步到其他节点,当前节点宕机,有影响的业务会瘫痪,只能等待节点恢复重启可用(必须持久化消息情况下)。
- 镜像模式:消息会同步到其他节点上,可以设置同步的节点个数,但吞吐量会下降。属于RabbitMQ的HA方案
为什么设置镜像模式集群,因为队列的内容仅仅存在某一个节点上面,不会存在所有节点上面,所有节点仅仅存放消息结构和元数据。
针对消费者
ACK确认机制
多个消费者同时收取消息,比如消息接收到一半的时候,一个消费者死掉了(逻辑复杂时间太长,超时了或者消费被停机或者网络断开链接),如何保证消息不丢?
使用rabbitmq提供的ack机制,服务端首先关闭rabbitmq的自动ack,然后每次在确保处理完这个消息之后,在代码里手动调用ack。这样就可以避免消息还没有处理完就ack。才把消息从内存删除。
这样就解决了,即使一个消费者出了问题,但不会同步消息给服务端,会有其他的消费端去消费,保证了消息不丢的case。
总结
如果需要保证消息在整条链路中不丢失,那就需要生产端、mq自身与消费端共同去保障。
- 生产端:对生产的消息进行状态标记,开启confirm机制,依据mq的响应来更新消息状态,使用定时任务重新投递超时的消息,多次投递失败进行报警。
- mq自身:开启持久化,并在落盘后再进行ack。如果是镜像部署模式,需要在同步到多个副本之后再进行ack。
- 消费端:开启手动ack模式,在业务处理完成后再进行ack,并且需要保证幂等。
通过以上的处理,理论上不存在消息丢失的情况,但是系统的吞吐量以及性能有所下降。在实际开发中,需要考虑消息丢失的影响程度,来做出对可靠性以及性能之间的权衡。
RabbitMQ如何保证消费顺序
RabbitMQ消费顺序乱了是因为消费者集群拿到消息后对消息处理速度不同导致的,比如可能将增删改变成了增改删。
解决方法:为RabbitMQ创建多个Queue,每个消费者只监听其中一个Queue,同一类型的消息都放在一个queue中,同一个 queue 的消息是一定会保证有序的。
RabbitMQ如何避免消息堆积
消息堆积问题产生的原因往往是因为消息发送的速度超过了消费者消息处理的速度。解决方案无外乎以下三点:
- 提高消费者处理速度
- 增加更多消费者
- 增加队列消息存储上限
1)提高消费者处理速度
消费者处理速度是由业务代码决定的,所以我们能做的事情包括:
- 尽可能优化业务代码,提高业务性能
- 接收到消息后,开启线程池,并发处理多个消息
优点:成本低,改改代码即可
缺点:开启线程池会带来额外的性能开销,对于高频、低时延的任务不合适。推荐任务执行周期较长的业务。
2)增加更多消费者
一个队列绑定多个消费者,共同争抢任务,自然可以提供消息处理的速度。
优点:能用钱解决的问题都不是问题。实现简单粗暴
缺点:问题是没有钱。成本太高
3)增加队列消息存储上限
在RabbitMQ的1.8版本后,加入了新的队列模式:Lazy Queue
这种队列不会将消息保存在内存中,而是在收到消息后直接写入磁盘中,理论上没有存储上限。可以解决消息堆积问题。
优点:磁盘存储更安全;存储无上限;避免内存存储带来的Page Out问题,性能更稳定;
缺点:磁盘存储受到IO性能的限制,消息时效性不如内存模式,但影响不大。
如何防止MQ消息被重复消费
消息重复消费的原因多种多样,不可避免。所以只能从消费者端入手,只要能保证消息处理的幂等性就可以确保消息不被重复消费。
而幂等性的保证又有很多方案:
- 给每一条消息都添加一个唯一id,在本地记录消息表记录消息状态,处理消息时基于数据库表的id唯一性做判断
- 同样是记录消息表,利用消息状态字段实现基于乐观锁的判断,保证幂等
如何保证RabbitMQ的高可用
要实现RabbitMQ的高可用无外乎下面两点:
- 做好交换机、队列、消息的持久化
- 搭建RabbitMQ的镜像集群,做好主从备份。当然也可以使用仲裁队列代替镜像集群。
使用MQ可以解决那些问题
RabbitMQ能解决的问题很多,例如:
- 解耦合:将几个业务关联的微服务调用修改为基于MQ的异步通知,可以解除微服务之间的业务耦合。同时还提高了业务性能。
- 流量削峰:将突发的业务请求放入MQ中,作为缓冲区。后端的业务根据自己的处理能力从MQ中获取消息,逐个处理任务。流量曲线变的平滑很多
- 延迟队列:基于RabbitMQ的DelayQueue(死信队列)或者DelayExchange插件,可以实现消息发送后,延迟接收的效果。
Elasticsearch
Elasticsearch是什么
Elasticsearch 是一种分布式实时全文搜索引擎,每个字段都被索引并可被搜索,可以快速存储、搜索、分析海量的数据。
全文检索是指对每一个词建立一个索引,指明该词在文章中出现的次数和位置。当查询时,根据事先建立的索引进行查找,并将查找的结果反馈给用户的检索方式。这个过程类似于通过字典中的检索字表查字的过程。
ELK技术栈
elasticsearch结合kibana、Logstash、Beats,也就是elastic stack(ELK)。被广泛应用在日志数据分析、实时监控等领域
而elasticsearch是elastic stack的核心,负责存储、搜索、分析数据。
Elasticsearch的基本概念
- index 索引:索引类似于mysql 中的数据库,Elasticesearch 中的索引是存在数据的地方,包含了一堆有相似结构的文档数据。
- type 类型:类型是用来定义数据结构,可以认为是 mysql 中的一张表,type 是 index 中的一个逻辑数据分类
- document 文档:类似于 MySQL 中的一行,不同之处在于 ES 中的每个文档可以有不同的字段,但是对于通用字段应该具有相同的数据类型,文档是es中的最小数据单元,可以认为一个文档就是一条记录。
- Field 字段:Field是Elasticsearch的最小单位,一个document里面有多个field
- shard 分片:单台机器无法存储大量数据,es可以将一个索引中的数据切分为多个shard,分布在多台服务器上存储。有了shard就可以横向扩展,存储更多数据,让搜索和分析等操作分布到多台服务器上去执行,提升吞吐量和性能。
- replica 副本:任何服务器随时可能故障或宕机,此时 shard 可能会丢失,通过创建 replica 副本,可以在 shard 故障时提供备用服务,保证数据不丢失,另外 replica 还可以提升搜索操作的吞吐量。
shard 分片数量在建立索引时设置,设置后不能修改,默认5个;replica 副本数量默认1个,可随时修改数量;
索引库就类似数据库表,mapping映射就类似表的结构。
我们要向es中存储数据,必须先创建“库”和“表”。
mapping映射属性
mapping是对索引库中文档的约束,常见的mapping属性包括:
- type:字段数据类型,常见的简单类型有:
- 字符串:text(可分词的文本)、keyword(精确值,例如:品牌、国家、ip地址)
- 数值:long、integer、short、byte、double、float、
- 布尔:boolean
- 日期:date
- 对象:object
- index:是否创建索引,默认为true
- analyzer:使用哪种分词器
- properties:该字段的子字段
索引库的CRUD
修改索引库
倒排索引结构虽然不复杂,但是一旦数据结构改变(比如改变了分词器),就需要重新创建倒排索引,这简直是灾难。因此索引库一旦创建,无法修改mapping。
虽然无法修改mapping中已有的字段,但是却允许添加新的字段到mapping中,因为不会对倒排索引产生影响。
总结
- 创建索引库:PUT /索引库名
- 查询索引库:GET /索引库名
- 删除索引库:DELETE /索引库名
- 添加字段:PUT /索引库名/_mapping
文档操作
修改有两种方式:
- 全量修改:直接覆盖原来的文档
- 增量修改:修改文档中的部分字段
全量修改是覆盖原来的文档,其本质是:
- 根据指定的id删除文档
- 新增一个相同id的文档
注意:如果根据id删除时,id不存在,第二步的新增也会执行,也就从修改变成了新增操作了。
增量修改是只修改指定id匹配的文档中的部分字段。
- 创建文档:POST /{索引库名}/_doc/文档id { json文档 }
- 查询文档:GET /{索引库名}/_doc/文档id
- 删除文档:DELETE /{索引库名}/_doc/文档id
- 修改文档:
- 全量修改:PUT /{索引库名}/_doc/文档id { json文档 }
- 增量修改:POST /{索引库名}/_update/文档id { “doc”: {字段}}
DSL查询分类
-
ElasticSearch提供了基于DSL来定义查询。常见的查询类型包括
查询所有:查询出所有数据,一般测试用。例如
- match_all
全文检索(full text):利用分词器对用户输入的内容分词,然后去倒排索引库中匹配。例如
-
match查询:单字段查询
-
multi_match查询:多字段查询,任意一个字段符合条件就算符合查询条件
match和multi_match的区别是什么
- match:根据一个字段查询
- multi_match:根据多个字段查询,参与查询的字段越多,查询性能就越差
精确查询:根据精确词条值查找数据,一般是查找keyword、数值、日期、boolean等类型字段。例如
- range:根据值的范围查询
- term:根据词条精确值查询
地理查询(geo):根据经纬度查询。例如
- geo_distance:附近查询,也叫做举例查询:查询到指定中心点的距离小于等于某个值的所有文档,以指定中心点为圆心,指定距离为半径,画一个圆,落在圆内的坐标都算符合条件
- geo_bounding_box:矩形范围查询,指定矩形的左上和右下两个点的坐标,画一个矩形,查询矩形范围内的坐标
复合查询(compound):复合查询可以将上述各种查询条件组合起来,合并查询条件。例如
- bool:布尔查询,利用逻辑关系组合多个其他的查询,实现复杂搜索
must
:必须匹配每个子查询,类似与
should
:选择性匹配子查询,类似或
must_not
:必须不匹配,不参与算分
,类似非
filter
:必须匹配,不参与算分
- 需要注意的是,搜索时,参与打分的字段越多,查询的性能就越差,所以在多条件查询时
- 搜索框的关键字搜索,是全文检索查询,使用must查询,参与算分
- 其他过滤条件,采用filter和must_not查询,不参与算分
- function_score:算分函数查询,可以控制文档相关性算分,控制文档排名(例如搜索引擎的排名,第一大部分都是广告)
filter和must的区别
- 它们两个都是必须要匹配每个子查询
- filter不参与算分,must参与算分
- filter的查询效率会比must高一点,因为filter不参与算分,算分这个过程本身也要占用资源
filter、must、should组合使用导致should失效
should直接使用时,minimum_should_match默认为1
当should和must或filter同级使用时,minimum_should_match默认为0,也就是即使一个也没有匹配也是会有结果返回。
因为 must 的作用就是要求所有的条件都必须被满足,而 should 则是一个可选的条件。
解决方案
- 如果想让should中至少一个条件生效,只需手动配置minimum_should_match为1即可
- 将should通过bool语句嵌到must里面
- 在Java代码中,可以通过must配合termsQuery来达到should的效果
es的算分函数
在ES中,早期使用的打分算法是TF-IDF算法,公式如下:TF(词条频率)=词条出现次数 / 文档中词条总数
再后来的5.1版本升级中,ES将算法改进为BM25算法
TF-IDF算法有一种缺陷,就是词条频率越高,文档得分也会越高,单个词条对文档影响较大。而BM25则会让单个词条的算分有一个上限,曲线更平滑
springboot整合es
在elasticsearch提供的API中,与elasticsearch一切交互都封装在一个名为RestHighLevelClient的类中,必须先完成这个对象的初始化,建立与elasticsearch的连接。
1.引入es的依赖
2.在yaml文件中添加es相关配置
elasticsearch:
hostlist: 192.168.101.65:9200 #多个结点中间用逗号分隔
course:
index: course-publish
source_fields: id,name,grade,mt,st,charge,pic,price,originalPrice,teachmode,validDays,createDate
3.config包下只有一个ElasticSearchConfig,主要是提供了一个Java的客户端来操作ES的,将其注册为一个bean
4.注入RestHighLevelClient,调用其中的方法
操作方法
新增
// 1. 准备request对象
IndexRequest request = new IndexRequest("indexName").id("1");
// 2. 准备请求参数
request.source("{\"name\":\"Jack\",\"age\":21}");
// 3. 发送请求
client.index(request, RequestOptions.DEFAULT);
查询
// 1. 准备request对象
GetRequest request = new GetRequest("hotel").id("61083");
// 2. 发送请求,得到结果
GetResponse response = client.get(request, RequestOptions.DEFAULT);
match查询
request.source().query(QueryBuilders.matchQuery("all","北京"));
request.source().query(QueryBuilders.multiMatchQuery("如家","brand","name"));
精确查询
request.source().query(QueryBuilders.termQuery("city","北京"));
request.source().query(QueryBuilders.rangeQuery("price").gt(1000).lt(2000));
布尔查询
// 2. 组织DSL参数
BoolQueryBuilder boolQuery = QueryBuilders.boolQuery();
// 2.1 添加must条件
boolQuery.must(QueryBuilders.termQuery("city", "上海"));
// 2.2 添加should条件 (should有点问题,但是貌似可以用must配合termsQuery来达到should的效果)
boolQuery.must(QueryBuilders.termsQuery("brand", "华美达", "皇冠假日"));
// 2.3 添加mustNot条件
boolQuery.mustNot(QueryBuilders.rangeQuery("score").lt(45));
// 2.4 添加filter条件
boolQuery.filter(QueryBuilders.rangeQuery("price").gt(500).lt(2000));
request.source().query(boolQuery);
排序,分页
// 2. 组织DSL参数
request.source().query(QueryBuilders.matchAllQuery())
.sort("price", SortOrder.ASC)
.from(0)
.size(5);
高亮
request.source().query(QueryBuilders.matchAllQuery())
.highlighter(new HighlightBuilder()
.field("name")
.requireFieldMatch(false));
修改
// 1. 准备request对象
UpdateRequest request = new UpdateRequest("hotel","61083");
// 2. 准备参数
request.doc(
"city","北京",
"price",1888);
// 3. 发送请求
client.update(request,RequestOptions.DEFAULT);
删除
// 1. 准备request对象
DeleteRequest request = new DeleteRequest("hotel","61083");
// 2. 发送请求
client.delete(request,RequestOptions.DEFAULT);
批量导入
BulkRequest request = new BulkRequest();
request.add(new IndexRequest("hotel").id("101").source("json source1", XContentType.JSON));
request.add(new IndexRequest("hotel").id("102").source("json source2", XContentType.JSON));
client.bulk(request, RequestOptions.DEFAULT);
解析响应结果
// 1. 准备Request对象,对应 GET /hotel/_search
SearchRequest request = new SearchRequest("hotel");
// 2. 组织DSL参数
request.source().query(QueryBuilders.matchQuery("all", "如家"))
.highlighter(new HighlightBuilder()
.field("name")
.requireFieldMatch(false));
// 3. 发送请求,得到响应结果
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
// 4. 解析响应
SearchHits searchHits = response.getHits();
TotalHits total = searchHits.getTotalHits();
System.out.println("共查询到" + total + "条数据");
SearchHit[] hits = searchHits.getHits();
for (SearchHit hit : hits) {
// 获取source
String json = hit.getSourceAsString();
HotelDoc hotelDoc = JSON.parseObject(json, HotelDoc.class);
// 获取高亮
Map<String, HighlightField> highlightFields = hit.getHighlightFields();
// 健壮性判断
if (!CollectionUtils.isEmpty(highlightFields)) {
// 获取高亮字段结果
HighlightField highlightField = highlightFields.get("name");
// 健壮性判断
if (highlightField != null) {
// 取出高亮结果数组的第一个元素,就是酒店名称
String name = highlightField.getFragments()[0].string();
hotelDoc.setName(name);
}
}
System.out.println(hotelDoc);
}
什么是倒排索引
在搜索引擎中,每个文档都有对应的文档 ID,文档内容可以表示为一系列关键词的集合,例如,某个文档经过分词,提取了 20 个关键词,而通过倒排索引,可以记录每个关键词在文档中出现的次数和出现位置。也就是说,倒排索引是 关键词到文档 ID 的映射,每个关键词都对应着一系列的文件,这些文件中都出现了该关键词。
要注意倒排索引的两个细节:
- 倒排索引中的所有词项对应一个或多个文档
- 倒排索引中的词项 根据字典顺序升序排列
倒排索引和正向索引的优缺点
-
正向索引
- 优点:可以给多个字段创建索引,根据索引字段搜索、排序速度非常快
- 缺点:根据非索引字段,或者索引字段中的部分词条查找时,只能全表扫描
-
倒排索引
- 优点:根据词条搜索、模糊搜索时,速度非常快
- 缺点:只能给词条创建索引,而不是字段,无法根据字段做排序
Elasticsearch数据聚合
-
常见的聚合有三类
-
桶(Bucket)聚合:用来对文档分组
- TermAggregation:按照文档字段值分组,例如:按照
品牌名称
、国家
分组 - DateHistogram:按照日期阶梯分组,例如:
一周
为一组,或者一月
为一组
- TermAggregation:按照文档字段值分组,例如:按照
-
度量(Metric)聚合:用于计算一些值,例如:最大值、最小值、平均值等。
- Avg:求平均值
- Max:求最大值
- Min:求最小值
- Stats:同时求max、min、avg、sum等
-
管道(pipeline)聚合:以其他聚合的结果为基础做聚合
注意:参加聚合的字段必须是keyword、日期、数值、布尔类型
-
-
aggs代表聚合,与query统计,此时query的作用是?
- 限定聚合的文档范围
-
聚合必须的三要素
- 聚合名称
- 聚合类型
- 聚合字段
-
聚合可配置的属性有
- size:指定聚合结果数量
- order:指定聚合结果排序方式
- field:指定聚合字段
可以实现根据搜索的结果动态的更新分类
常用场景
在百度搜索相关文档
在电商网站搜索商品
在地图搜索附件的餐厅
Elasticsearch集群
-
单机的ES做数据存储,必然会面临两个问题
- 海量数据存储问题:将索引库从逻辑上拆分为N个分片(shard),存储到多个节点
- 单点故障问题:将分片数据在不同节点备份(replica)
-
ES集群相关概念
- 集群:一组拥有共同cluster name的节点
- 节点:集群中的一个ES示例
- 分片:索引可以被拆分为不同的部分进行存储,称为分片。
- 在集群环境下,一个索引的不同分片可以拆分到不同的节点中。
- 解决问题:数据量太大,单点存储有限的问题
- 主分片:相对于副本分片的定义
- 副本分片:每个主分片都可以有一个或多个副本,数据与主分片一样
-
数据备份可以保证高可用,但是每个分片备份一份,所需要的节点数量就会翻一倍,成本太高了
-
为了在高可用和成本间寻求平衡,我们可以这样做
- 首先对数据分片,存储到不同节点
- 然后对每个分片进行备份,放到对方节点,完成互相备份
-
现在,每个分片都有1个备份,存储在3个节点:
- node0:保存了分片0和1
- node1:保存了分片0和2
- node2:保存了分片1和2
-
部署es集群可以直接使用docker-compose来完成,不过虚拟机至少要有
4G
的内存空间
Elasticsearch集群职责划分
节点类型 | 配置参数 | 默认值 | 节点职责 |
---|---|---|---|
master eligible | node.master | true | 备选主节点:主节点可以管理和记录集群状态、决定分片在哪个节点、处理创建和删除索引库的请求 |
data | node.data | true | 数据节点:存储数据、搜索、聚合、CRUD |
ingest | node.ingest | true | 数据存储之前的预处理 |
coordinating | 上面3个参数都为false则为coordinating节点 | 无 | 路由请求到其它节点合并其它节点处理的结果,返回给用户 |
- 默认情况下,急群众的任何一个节点都同时具备上述四种角色
- 但真实的集群一定要将集群职责分离
- master节点:对CPU要求高,但是对内存要求低
- data节点:对CPU和内存要求都高
- coordinating节点:对网络带宽、CPU要求高
Elasticsearch集群的脑裂问题
- 脑裂是因为集群中的节点失联导致的。
- 例如一个集群(node1,node2,node3)中,主节点(node1)与其他节点失联
此时node2和node3会认为node1宕机,就会重新选主
- 当node3当选后,集群继续对外提供服务,node2和node3自成一个集群,node1自成一个集群。这两个集群数据不同步,出现数据差异
- 当网络恢复后,因为急群众有两个master节点,集群状态的不一致,会出现脑裂的情况
脑裂问题的解决方案
要求选品超过( eligible节点数量 + 1 )/ 2
才能当选为主,因此eligible节点数量最好是奇数。对应配置项是discovery.zen.minimum_master_nodes,在es7.0以后,已经成为默认配置,因此一般不会发生脑裂问题
例如:3个节点形成的集群,选票必须超过 (3 + 1) / 2 ,也就是2票。node3得到node2和node3的选票,当选为主。node1只有自己1票,没有当选。集群中依然只有1个主节点,没有出现脑裂。
Elasticsearch集群分布式存储
当新增文档时,ES会通过hash算法来计算文档应该存储到哪个分片
Elasticsearch集群故障转移
- 集群的master节点会监控集群中的节点状态,如果发现有节点宕机,会立即将宕机节点的分片数据迁移到其它节点,确保数据安全,这个叫做故障转移。
例如一个集群结构如图:
现在,node1是主节点,其它两个节点是从节点。
突然,node1发生了故障:
宕机后的第一件事,需要重新选主,例如选中了node2:
node2成为主节点后,会检测集群监控状态,发现:shard-1、shard-0没有副本节点。因此需要将node1上的数据迁移到node2、node3:
计算机网络系列
TCP相关
TCP/IP模型(计算机网络体系结构)
计算机网络体系结构主要有 OSI 七层模型、TCP/IP 四层模型、五层体系结构
TCP/IP 四层模型
- 应用层:为用户提供所需要的各种服务,例如:FTP、Telnet、DNS、SMTP 等。对应于 OSI 参考模型的(应用层、表示层、会话层)
- 传输层:为应用层实体提供端到端的通信功能,保证了数据包的顺序传送及数据的完整性。定义了 TCP 和 UDP 两层协议。对应 OSI 的传输层
- 网络层:主要解决主机到主机的通信问题。三个主要协议:网际协议(IP)、互联网组管理协议(IGMP)和互联网控制报文协议(ICMP)。对应于 OSI 参考模型的网络层
- 网络接口层:它负责监视数据在主机和网络之间的交换。与 OSI 参考模型的数据链路层、物理层对应。
TCP/IP 五层模型:
- 应用层:为应用程序提供交互服务。在互联网中的应用层协议很多,如域名系统DNS、HTTP协议、SMTP协议等。
- 传输层:负责向两台主机进程之间的通信提供数据传输服务。传输层的协议主要有传输控制协议TCP和用户数据协议UDP。
- 网络层:选择合适的路由和交换结点,确保数据及时传送。主要包括IP协议。
- 数据链路层:在两个相邻节点之间传送数据时,数据链路层将网络层交下来的 IP 数据报组装成帧,在两个相邻节点间的链路上传送帧。
- 物理层:实现相邻节点间比特流的透明传输,尽可能屏蔽传输介质和物理设备的差异。
OSI 七层模型
- 应用层:网络服务与最终用户的一个接口,协议有:HTTP FTP TFTP SMTP SNMP DNS TELNET HTTPS POP3 DHCP
- 表示层:数据的表示、安全、压缩。
- 会话层:建立、管理、终止会话。对应主机进程,指本地主机与远程主机正在进行的会话
- 传输层:定义传输数据的协议端口号,以及流控和差错校验。协议有:TCP UDP,数据包一旦离开网卡即进入网络传输层
- 网络层:进行逻辑地址寻址,实现不同网络之间的路径选择。协议有:ICMP IGMP IP(IPV4 IPV6)
- 数据链路层:建立逻辑连接、进行硬件地址寻址、差错校验等功能。
- 物理层:建立、维护、断开物理连接。
TCP三次握手和四次挥手
三次握手和四次挥手的定义
TCP协议是7层网络协议中的传输层协议,负责数据的可靠传输。在建立TCP连接时,需要通过三次握手来建立,过程是:
- 客户端向服务端发送一个SYN
- 服务端接收到SYN后,给客户端发送一个SYN_ACK
- 客户端接收到SYN_ACK后,再给服务端发送一个ACK
在断开TCP连接时,需要通过四次挥手来断开,过程是:
- 客户端向服务端发送FIN
- 服务端接收FIN后,向客户端发送ACK,表示我接收到了断开连接的请求,客户端你可以不发数据了,不过服务端这边可能还有数据正在处理
- 服务端处理完所有数据后,向客户端发送FIN,表示服务端现在可以断开连接
- 客户端收到服务端的FIN,向服务端发送ACK,表示客户端也会断开连接了
三次握手
四次挥手
为什么TCP不能两次握手
假设是两次握手,若客户端发起的连接请求阻塞在网络中,会造成该报文的重传,这时服务收到连接请求后会立刻进入连接状态,当双方传输完数据结束连接后,第一次阻塞的请求突然又到达了服务端,此时服务端又进入连接状态,而客户端不会响应服务端的连接确认报文
TCP 四次挥手过程中,客户端为什么需要等待 2MSL
- 1 个 MSL 保证四次挥手中主动关闭方最后的 ACK 报文能最终到达对端
- 1 个 MSL 保证对端没有收到 ACK 那么进行重传的 FIN 报文能够到达
三次握手的作用
- 确认双方的接受能力、发送能力是否正常。
- 指定自己的初始化序列号,为后面的可靠传送做准备。
三次握手过程中可以携带数据吗
第三次握手的时候,是可以携带数据的。第一次、第二次握手不可以携带数据。
而对于第三次的话,此时客户端已经处于 established 状态,也就是说,对于客户端来说,他已经建立起连接了,并且也已经知道服务器的接收、发送能力是正常的了,所以能携带数据页没啥毛病。
TCP和UDP
TCP是什么以及特点
TCP:是一种面向连接的、可靠的、传输层通信协议。
特点:面向连接的,点对点的通信,高可靠的,效率比较低,占用的系统资源比较多。(好比打电话)
UDP是什么以及特点
UDP:是一种无连接的,不可靠的、传输层通信协议。
特点:不需要连接,发送方不管接收方有没有准备好,直接发消息;可以进行广播发送的;传输不可靠,有可能丢失消息;效率比较高;协议就会比较简单,占用的系统资源就比较少。(好比广播)
TCP和UDP的区别
- TCP是可靠传输,UDP是不可靠传输;
- TCP面向连接,UDP无连接;
- TCP传输数据有序,UDP不保证数据的有序性;
- TCP不保存数据边界,UDP保留数据边界;
- TCP传输速度相对UDP较慢;
- TCP有流量控制和拥塞控制,UDP没有;
- TCP是重量级协议,UDP是轻量级协议;
- TCP首部较长20字节,UDP首部较短8字节;
TCP 滑动窗口(流量控制)
TCP 流量控制,主要使用滑动窗口协议,滑动窗口是接受数据端使用的窗口大小,用来告诉发送端接收端的缓存大小,以此可以控制发送端发送数据的大小,从而达到流量控制的目的。如果TCP发送方收到接收方的零窗口通知后,会启动持续计时器。计时器超时后向接收方发送零窗口探测报文,如果响应仍为0,就重新计时,不为0就打破死锁
TCP拥塞控制
发送方会维护一个拥塞窗口大小的状态变量,大小取决于网络的拥塞程度。发送方的发送窗口大小是取接收方接收窗口和拥塞窗口中较小的一个
拥塞控制有四种算法:
- 慢开始:从小到大主键发送窗口,每收到一个确认报文窗口大小指数增长
- 拥塞避免:当窗口大小到达一定阈值时,转为拥塞避免,每收到一个确认报文窗口大小+1。若此时网络超时,就把阈值调小一半,重新慢开始
- 快重传:要求接收方收到请求后要立即回复
- 快恢复:发送方连续收到多个确认时,就把拥塞避免阈值减小,然后直接开始拥塞避免
TCP超时重传
发送方在发送按数据后一定时间内没有收到接收方响应报文,就会重新发送刚刚的报文,接收到收到报文后会对该报文的序列号进行检验,已存在就抛弃
TCP可靠传输的实现
TCP是靠滑动窗口协议和连续ARQ协议配合流量控制和拥塞控制来保证的可靠传输。
ARQ是停止等待协议和自动重传请求,它规定TCP要为每一次传输的包编号,每发送一个包,要等待对方确认后才能发送下一个分组,若一段时间对方没有确认,就重新发送刚刚的报文。接收方会对数据包排序,把有序数据传给应用层,返回缺失的第一个ACK确认序列号给发送方,接收到收到报文后会对该报文的序列号进行检验,重复就丢弃。
拥塞控制是…(在上边)
TCP 是如何保证可靠性的
可靠传输有如下两个特点:
- 传输信道无差错,保证传输数据正确;
- 不管发送方以多快的速度发送数据,接收方总是来得及处理收到的数据;
- 首先,TCP 的连接是基于三次握手,而断开则是四次挥手。确保连接和断开的可靠性。
- 其次,TCP 的可靠性,还体现在有状态 ;TCP 会记录哪些数据发送了,哪些数据被接受了,哪些没有被接受,并且保证数据包按序到达,保证数据传输不出差错。
- 再次,TCP 的可靠性,还体现在可控制。它有数据包校验、ACK 应答、超时重传 (发送方)、失序数据重传(接收方)、丢弃重复数据、流量控制(滑动窗口)和拥塞控制等机制。
TCP报头有哪些信息
- 16 位端口号:源端口号,主机该报文段是来自哪里;目标端口号,要传给哪个上层协议或应用程序
- 32 位序号:一次 TCP 通信(从 TCP 连接建立到断开)过程中某一个传输方向上的字节流的每个字节的编号。
- 32 位确认号:用作对另一方发送的 tcp 报文段的响应。其值是收到的 TCP 报文段的序号值加 1。
- 4 位头部长度:表示 tcp 头部有多少个 32bit 字(4 字节)。因为 4 位最大能标识 15,所以 TCP 头部最长是 60 字节。
- 6 位标志位:URG (紧急指针是否有效),ACk(表示确认号是否有效),PSH(缓冲区尚未填满),RST(表示要求对方重新建立连接),SYN(建立连接消息标志接),FIN(表示告知对方本端要关闭连接了)
- 16 位窗口大小:是 TCP 流量控制的一个手段。这里说的窗口,指的是接收通告窗口。它告诉对方本端的 TCP 接收缓冲区还能容纳多少字节的数据,这样对方就可以控制发送数据的速度。
- 16 位校验和:由发送端填充,接收端对 TCP 报文段执行 CRC 算法以检验 TCP 报文段在传输过程中是否损坏。注意,这个校验不仅包括 TCP 头部,也包括数据部分。这也是 TCP 可靠传输的一个重要保障。
- 16 位紧急指针:一个正的偏移量。它和序号字段的值相加表示最后一个紧急数据的下一字节的序号。因此,确切地说,这个字段是紧急指针相对当前序号的偏移,不妨称之为紧急偏移。TCP 的紧急指针是发送端向接收端发送紧急数据的方法。
跨域请求是什么,有什么问题,怎么解决
跨域是指浏览器在发起网络请求时,会检查该请求所对应的协议、域名、端口和当前网页是否一致,如果不一致则浏览器会进行限制,比如在www.baidu.com的某个网页中,如果使用ajax去访问www.jd.com是不行的,但是如果是img、iframe、 script等标签的src属性去访问则是可以的,之所以浏览器要做这层限制,是为了用户信息安全。但是如果开发者想要绕过这层限制也是可以的:
- response添加header,比如resp.setHeader(“Access-Control-Allow-Origin” “*”);表示可以访问所有网站,不受是否同源的限制
- jsonp的方式,该技术底层就是基于script标签来实现的,因为script标签是可以跨域的
- 后台自己控制,先访问同域名下的接口,然后在接口中再去使用HTTPClient等工具去调用目标接口
- 网关,和第三种方式类似,都是交给后台服务来进行跨域访问
浏览器发出一个请求到收到响应经历了哪些步骤
- 浏览器解析用户输入的URL,生成一个HTTP格式的请求
- 先根据URL域名从本地hosts文件查找是否有映射IP,如果没有就将域名发送给电脑所配置的DNS进行域名解析,得到IP地址
- 浏览器通过操作系统将请求通过四层网络协议发送出去
- 服务器收到请求后,根据请求所指定的端口,将请求传递给绑定了该端口的应用程序,比如8080被tomcat占用了
- 应用程序(Tomcat)接收到请求数据后,按照http协议的格式进行解析,解析得到所要访问的servlet
- 然后servlet来处理这个请求
- 应用程序(Tomcat)得到响应结果后封装成HTTP响应的格式,并再次通过网络发送给浏览器所在的服务器
- 浏览器所在的服务器拿到结果后再传递给浏览器,浏览器则负责解析并渲染
select,epoll和poll
select,epoll和poll分别是什么
- select模型:使用的是数组来存储Socket连接文件描述符,容量是固定的,需要通过轮询来判断是否发生了I0事件
- poll模型:使用的是链表来存储Socket连接文件描述符,容量是不固定的,同样需要通过轮询来判断是否发生了10事件
- epoll模型:epoll和poll是完全不同的,epoll是一种事件通知模型,当发生了10事件时,应用程序才进行I0操作,不需要像poll模型那样主动去轮询
select,epoll和poll的区别
他们是NIO中多路复用的三种实现机制,是由Linux操作系统提供的。
用户空间和内核空间:操作系统为了保护系统安全,将内核划分为两个部分,一个是用户空间,一个是内核空间。用户空间不能直接访问底层的硬件设备,必须通过内核空间。
文件描述符File Descriptor(FD):是一个抽象的概念,形式上是一个整数,实际上是一个索引值。指向内核中为每个进程维护进程所打开的文件的记录表。当程序打开一个文件或者创建一个文件时,内核就会向进程返叵一个FD。(只在Unix和Linux系统上有这个机制)
- select机制:会维护一个FD的结合 fd_set。将fd_set从用户空间复制到内核空间,激活socket。x64 2048 fd_set是一个数组结构
- Poll机制:和selecter机制是差不多的,把fd_set结构进行了优化,FD集合的大小就突破了操作系统的限制。pollfd结构来代替fd_set,通过链表实现的。
- EPoll: Event PollEpoll不再扫描所有的FD,只将用户关心的FD的事件存放到内核的一个事件表当中。这样,可以减少用户空间与内核空间之前需要拷贝的数据。
总结
名称 | 操作方式 | 底层实现 | 最大连接数 | IO效率 |
---|---|---|---|---|
select | 遍历 | 数组 | 受限于内核 | 一般 |
poll | 遍历 | 链表 | 无上限 | 一般 |
epoll | 事件回调 | 红黑树 | 无上限 | 高 |
java的NIO当中是用的那种机制?
可以查看DefaultSelectorProvider源码。在windows下,就是WindowsSelectorProvider。而Linux下,根据Linux的内核版本,2.6版本以上,就是EPollSelectorProvider,否则就是默认的PollSelectorProvider.
HTTP相关
https是如何保证安全传输的
https通过使用对称加密、非对称加密、数字证书等方式来保证数据的安全传输。
- 客户端向服务端发送数据之前,需要先建立TCP连接,建立完TCP连接后,服务端会先给客户端发送公钥,客户端拿到公钥后就可以用来加密数据了,服务端到时候接收到数据就可以用私钥解密数据,这种就是通过非对称加密来传输数据
- 不过非对称加密比对称加密要慢,所以不能直接使用非对称加密来传输请求数据,所以可以通过非对称加密的方式来传输对称加密的秘钥,之后就可以使用对称加密来传输请求数据了
- 但是仅仅通过非对称加密+对称加密还不足以能保证数据传输的绝对安全,因为服务端向客户端发送公钥时,可能会被截取
- 所以为了安全的传输公钥,需要用到数字证书,数字证书是具有公信力、大家都认可的,服务端向客户端发送公钥时,可以把公钥和服务端相关信息通过Hash算法生成消息摘要,再通过数字证书提供的私钥对消息摘要进行加密生成数字整名,在把没进行Hash算法之前的信息和数字签名一起形成数字证书,最后把数字证书发送给客户端,客户端收到数字证书后,就会通过数字证书提供的公钥来解密数字证书,从而得到非对称加密要用到的公钥。
- 在这个过程中,就算有中间人拦截到服务端发出来的数字证书,虽然它可以解密得到非对称加密要使用的公钥,但是中间人是办法伪造数字证书发给客户端的,因为客户端上内嵌的数字证书是全球具有公信力的,某个网站如果要支持https,都是需要申请数字证书的私钥的,中间人如果要生成能被客户端解析的数字证书,也是要申请私钥的,所以是比较安全了。
什么是数字证书
数字证书是具有公信力、大家都认可的电子文档,它是在网络环境中验证身份和维护安全的一种方式。数字证书包含了被认证者的身份信息及其公钥,并由CA进行数字签名,以保证数字证书的真实性和完整性。用户在接收到数字证书后,可以使用CA的公钥来验证数字证书的有效性,并得到数字证书中所包含的身份信息,从而实现身份认证和数据加密等功能。
HTTP 1.0,1.1,2.0 的版本区别
HTTP 1.0
- HTTP 1.0 规定浏览器与服务器只保持短暂的连接,浏览器的每次请求都需要与服务器建立一个 TCP 连接,服务器完成请求处理后立即断开 TCP 连接。它也可以强制开启长链接,例如设置 Connection: keep-alive 这个字段
HTTP 1.1
- 引入了长连接,即 TCP 连接默认不关闭,可以被多个请求复用。
- 引入了管道机制(pipelining),即在同一个 TCP 连接里面,客户端可以同时发送多个请求。
- 缓存处理,引入了更多的缓存控制策略,如 Cache-Control、Etag/If-None-Match 等。
- 错误状态管理,新增了 24 个错误状态响应码,如 409 表示请求的资源与资源的当前状态发生冲突。
HTTP 2
- 采用了多路复用,即在一个连接里,客户端和浏览器都可以同时发送多个请求或回应,而且不用按照顺序一一对应。
- 服务端推送,HTTP 2 允许服务器未经请求,主动向客户端发送资源
HTTP 常用的请求方式,区别和用途
- GET: 发送请求,获取服务器数据
- POST:向 URL 指定的资源提交数据
- PUT:向服务器提交数据,以修改数据
- HEAD: 请求页面的首部,获取资源的元信息
- DELETE:删除服务器上的某些资源。
POST和GET的区别
- 请求参数:GET 把参数包含在 URL 中,用 & 连接起来;POST 通过 request body 传递参数。
- 使用场景:GET 用于获取资源,而 POST 用于传输实体主体。
- **请求缓存:**GET 请求会被主动 Cache,而 POST 请求不会,除非手动设置。
- 收藏为书签:GET 请求支持收藏为书签,POST 请求不支持。
- **安全性:**POST 比 GET 安全,GET 请求在浏览器回退时是无害的,而 POST 会再次请求。
- **历史记录:**GET 请求参数会被完整保留在浏览历史记录里,而 POST 中的参数不会被保留。
- **编码方式:**GET 请求只能进行 url 编码,而 POST 支持多种编码方式。
- 参数数据类型:GET 只接受 ASCII 字符,而 POST 没有限制数据类型。
- 数据包: GET 产生一个 TCP 数据包;POST 可能产生两个 TCP 数据包。
如何理解 HTTP 协议是无状态的
每次 HTTP 请求都是独立的,无相关的,默认不需要保存上下文信息的。也就是说无状态是不可以进行一个连续的对话的。
HTTP 常用的状态码及含义
- 1xx:接受的请求正在处理(信息性状态码)
- 101:切换请求协议,从 HTTP 切换到 WebSocket
- 2xx:表示请求成功处理(成功状态码)
- 200:请求成功,表示正常返回信息。
- 3xx:表示请求重定向(重定向状态码)
- 301:永久重定向,会缓存
- 302:临时重定向,不会缓存
- 304:使用本地缓存
- 4xx:表示服务器无法处理请求,客户端错误
- 400:请求格式错误
- 403:没有访问权限
- 415:请求体过大
- 5xx:表示服务器处理请求出错,服务端错误
- 500:常见的服务器端错误
HTTP 状态码 301 和 302 的区别?
- 301(永久移动)请求的网页已被永久移动到新位置。服务器返回此响应(作为对 GET 或 HEAD 请求的响应)时,会自动将请求者转到新位置。
- 302:(临时移动)服务器目前正从不同位置的网页响应请求,但请求者应继续使用原有位置来进行以后的请求。此代码与响应 GET 和 HEAD 请求的 301 代码类似,会自动将请求者转到不同的位置。
http与https的区别
HTTP:是互联网上应用最为广泛的一种网络通信协议,基于TCP,可以使浏览器工作更为高效,减少网络传输。
HTTPS:是HTTP的加强版,可以认为是HTTP+SSL(Secure Socket Layer)。在HTTP的基础上增加了一系列的安全机制。一方面保证数据传输安全,另一位方面对访问者增加了验证机制。
主要区别:
- HTTP的连接是简单无状态的,HTTPS的数据传输是经过证书加密的,安全性更高。
- HTTP是免费的,而HTTPS需要申请证书,而证书通常是需要收费的,并且费用一般不低。
- 他们的传输协议不同,所以他们使用的端口也是不一样的,HTTP默认是80端口,而HTTPS默认是443端口。
HTTPS的缺点:
- HTTPS的握手协议比较费时,所以会影响服务的响应速度以及吞吐量。
- HTTPS也并不是完全安全的。他的证书体系其实并不是完全安全的。并且HTTPS在面对DDOS这样的攻击时,几乎起不到任何作用。
Https 流程是怎样的
- 用户在浏览器里输入一个 https 网址,然后连接到 server 的 443 端口。
- 服务器必须要有一套数字证书,可以自己制作,也可以向组织申请,区别就是自己颁发的证书需要客户端验证通过。这套证书其实就是一对公钥和私钥。
- 服务器将自己的数字证书(含有公钥)发送给客户端。
- 客户端收到服务器端的数字证书之后,会对其进行检查,如果不通过,则弹出警告框。如果证书没问题,则生成一个密钥(对称加密),用证书的公钥对它加密。
- 客户端会发起 HTTPS 中的第二个 HTTP 请求,将加密之后的客户端密钥发送给服务器。
- 服务器接收到客户端发来的密文之后,会用自己的私钥对其进行非对称解密,解密之后得到客户端密钥,然后用客户端密钥对返回数据进行对称加密,这样数据就变成了密文。
- 服务器将加密后的密文返回给客户端。
- 客户端收到服务器发返回的密文,用自己的密钥(客户端密钥)对其进行对称解密,得到服务器返回的数据。
HTTP 如何实现长连接?在什么时候会超时?
HTTP 如何实现长连接
- HTTP 分为长连接和短连接,其实本质上说的是 TCP 的长短连接。TCP 连接是一个双向的通道,它是可以保持一段时间不关闭的,因此 TCP 连接才有真正的长连接和短连接这一个说法。
- 长连接是指的是 TCP 连接,而不是 HTTP 连接。
- TCP 长连接可以复用一个 TCP 连接来发起多次 HTTP 请求,这样可以减少资源消耗,比如一次请求 HTML,短连接可能还需要请求后续的 JS/CSS/ 图片等
要实现 HTTP 长连接,在响应头设置 Connection 为 keep-alive,HTTP1.1 默认是长连接,而 HTTP 1.0 协议也支持长连接,但是默认是关闭的。
在什么时候会超时
- HTTP 一般会有 httpd 守护进程,里面可以设置 keep-alive timeout,当 tcp 链接闲置超过这个时间就会关闭,也可以在 HTTP 的 header 里面设置超时时间
- TCP 的 keep-alive 包含三个参数,支持在系统内核的 net.ipv4 里面设置:当 TCP 连接之后,闲置了 tcp_keepalive_time,则会发生侦测包,如果没有收到对方的 ACK,那么会每隔 tcp_keepalive_intvl 再发一次,直到发送了 tcp_keepalive_probes,就会丢弃该连接。
- tcp_keepalive_intvl = 15
- tcp_keepalive_probes = 5
- tcp_keepalive_time = 1800
cookie,session和Token
因为http协议是无状态的,无法识别两次请求是否来自同一个客户端,于是就有了cookie和session的概念。
cookie,session和Token分别是什么
Cookie、Session 和 Token 都是常见的身份验证和状态保持机制,它们的区别如下:
-
Cookie:客户端A访问服务器,服务器返回cookie给客户端A,客户端A存储cookie,下次需要带着cookie访问服务器,服务器返回相应的数据。
-
Session:客户端A访问服务器,服务器存储A的数据value,把key返回给客户端A,客户端A下次带着key(session ID)来访问服务器,服务器就能给出客户端A的数据。如果负载均衡,客户端A访问了另一个服务器,那个服务器没有客户端A的数据。
-
Token:客户端A访问服务器,服务器给了客户端token,客户端A拿着token访问服务器,服务器验证token,返回数据。
Tocken
◾ 前端登陆的时候向服务器发送请求,服务器验证成功,会生成一个token
◾ 前端会存储这个token,放在session或cookie中,用于之后的业务请求身份验证
◾ 拿着这个token,可以在当前登录的账号下进行请求业务,发送请求时,token会放在请求头里,服务器收到这个业务请求,验证token,成功就允许这个请求获取数据
◾ token可以设置失效期
session和cookie的过期时间
对于 Session,过期时间可以在服务器端进行设置。一般来说,Session 在服务器端保持一定时间的活跃状态,如果客户端没有活动,则 Session 会自动失效。在 Spring 框架中,可以通过配置文件或使用 @Configuration
注解来设置 Session 的过期时间。
对于 Cookie,可以在客户端设置它的过期时间。当未设置过期时间时,Cookie 的生命周期只是在当前的会话中。关闭浏览器意味着这次会话的结束,此时 Cookie 随之失效。但是,也可以通过设置 expires
属性来设置 Cookie 的失效时间。如需设置的失效时间大于等于一天,可以在 expires
属性后面直接输入所需天数。
在使用 Session 和 Cookie 的过程中,为了安全起见,建议尽可能缩短过期时间,并采用加密等措施来保证数据的安全。
session和cookie区别:
数据存放位置不同:Session数据是存在服务器中的,cookie数据存放在浏览器当中。
安全程度不同:cookie放在服务器中不是很安全,session放在服务器中,相对安全。
性能使用程度不同:session放在服务器上,访问增多会占用服务器的性能;考虑到减轻服务器性能方面,应使用cookie。
数据存储大小不同:单个cookie保存的数据不能超过4K,session存储在服务端,根据服务器大小来定。
token和session区别:
token是开发定义的,session是http协议规定的;
token不一定存储,session存在服务器中;
token可以跨域,session不可以跨域,它是与域名绑定的。
如果没有Cookie,Session还能进行身份验证吗
当服务器tomcat第一次接收到客户端的请求时,会开辟一块独立的session空间,建立一个session对象,同时会生成一个session id,通过响应头的方式保存到客户端浏览器的cookie当中。以后客户端的每次请求,都会在请求头部带上这个session id,这样就可以对应上服务端的一些会话的相关信息,比如用户的登录状态。
如果没有客户端的Cookie,Session是无法进行身份验证的。
当服务端从单体应用升级为分布式之后,cookie+session这种机制要怎么扩展?
1.session黏贴:在负载均衡中,通过一个机制保证同一个客户端的所有请求都会转发到同一个tomcat实例当中。问题:当这个tomcat实例出现问题之后,请求就会被转发到其他实例,这时候用户的session信息就丢了。
2.session复制:当一个tomcat实例上保存了session信息后,主动将session复制到集群中的其他实例。问题:复制是需要时间的,在复制过程中,容易产生session信息丢失。
3.session共享:就是将服务端的session信息保存到一个第三方中,比如Redis。
Session共享问题
由于每个服务器都有自己的 Session 存储,因此 Session 不能简单地在不同的服务器之间共享。
sso单点登录就是通过session共享实现的
使用Redis解决
使用Redis解决是基于token实现的,每发起一次请求都会携带token。而如果用户要是存在的话,用户信息就会存储到Redis中。每次发送请求的时候,都会携带token,而后台获取token从而查询Redis中是否存在相关用户的相关信息,这样不管是任何一台服务器,都会从Redis中查询相关信息。这样就解决了Tomcat集群下Session无法共享的问题,而Redis也可以对相关信息进行脱敏处理,一定程度上保证了安全。
优点:
- 代码灵活,基于分布式Redis,可以实现对高并发请求的支持。
缺点:
- 需要修改的代码较多,涉及到Session的地方都需要更改。不太适合对老系统的改造,比较适合于新开发的系统。但是如果我们提前将用户接口抽离成了一个单独的服务,那么改造起来还是比较好处理的。
Netty
Netty是什么
Netty是一个基于NIO的异步网络通信框架,性能高,封装了原生NlIO编码的复杂度,开发者可以直接使用Netty来开发高效率的各种网络服务器,并且编码简单。
Netty和Tmocat的区别
Tomcat是一个Web服务器,是一个Servlet容器,基本上Tomcat内部只会运行Servlet程序,并处理HTTP请求,而Netty封装的是底层IO模型,关注的是网络数据的传输,而不关心具体的协议,可定制性更高。
Netty的特点
- 异步、NIO的网络通信框架
- 高性能
- 高扩展,高定制性
- 易用性
Netty的高性能体现在哪些方面
- NIO模型,用最少的资源做更多的事情。
- 内存零拷贝,尽量减少不必要的内存拷贝,实现了更高效率的传输。
- 内存池设计,申请的内存可以重用,主要指直接内存。内部实现是用一颗二叉查找树管理内存分配情况。
- 串行化处理读写︰避免使用锁带来的性能开销。即消息的处理尽可能再同一个线程内完成,期间不进行线程切换,这样就避免了多线程竞争和同步锁。表面上看,串行化设计似乎CPu利用率不高,并发程度不够。但是,通过调整NIO线程池的线程参数,可以同时启动多个串行化的线程并行运行,这种局部无锁化的串行线程设计相比一个队里-多个工作线程模型性能更优。
- 高性能序列化协议︰支持protobuf等高性能序列化协议。
- 高效并发编程的体现:volatile的大量、正确使用;CAS和原子类的广泛使用;线程安全容器的使用;通过读写锁提升并发性能。
为什么要进入时间等待状态
若客户端发送确认释放包后直接关闭,而服务端因为某种原因没有收到客户端的确认释放包,就会一直发送确认请求,而客户端永远不会再响应该请求。
什么是阻塞和非阻塞,同步和异步
- 阻塞和非阻塞:
- 阻塞:调用者在事件没有发生的时候,一直等待事件发生,不能处理其他任务。
- 非阻塞:调用者在事件没有发生的时候,可以去处理别的事务。
- 同步和异步:
- 同步:调用者循环查看事件有没有发生。
- 异步:调用者不用自己去查看事件有没有发生,而是等待注册在时间上的回调函数自己通知自己。
什么是CSRF攻击
CSRF: Cross Site Requst Forgery 跨站请求伪造
一个正常的请求会将合法用户的session id保存到浏览器的cookie。这时候,如果用户在浏览器中打来另一个tab页,那这个tab页也是可以获得浏览器的cookie。黑客就可以利用这个cookie信息进行攻击。
攻击过程:
- 某银行网站A可以以GET请求的方式发起转账操作。www.xx.com/transfor.do?accountNum=100&money=1000accountNum表示目标账户。这个请求肯定是需要登录才可以正常访问的。
- 攻击者在某个论坛或者网站上,上传一个图片,链接地址是www.Xxx.com/transfer.do?accountNum=888&money=10000其中这个accountNum就是攻击者自己的银行账户。
- 如果有一个用户,登录了银行网站,然后又打开浏览器的另一个tab页,点击了这个图片。这时,银行就会受理到一个带了正确cookie的请求,就会完成转账。用户的钱就被盗了。
CSRF的解决办法:
- 尽量使用POST请求,限制GET请求。POST请求可以带请求体,攻击者就不容易伪造出请求
- 将cookie设置为HttpOnly: respose.setHeader(“Set-Cookie”,“cookiename=cookiewalue;HttpQnly”')。
- 增加token,在请求中放入一个攻击者无法伪造的信息,并且该信息不存在于cookie当中。
什么是SSO
sso Single Sign On单点登录。一处登录,多处同时登录。
SSO的实现关键是将Session信息集中存储。
socket通信流程
(1)服务端创建socket并调用bind()方法绑定ip和端口号
(2)服务端调用listen()方法建立监听,此时服务的scoket还没有打开
(3)客户端创建socket并调用connect()方法像服务端请求连接
(4)服务端socket监听到客户端请求后,被动打开,调用accept()方法接收客户端连接请求,当accept()方法接收到客户端connect()方法返回的响应成功的信息后,连接成功
(5)客户端向socket写入请求信息,服务端读取信息
(6)客户端调用close()结束链接,服务端监听到释放连接请求后,也结束链接
对称加密与非对称加密
对称加密:
- 对称加密是指加密和解密使用同一个密钥的方式,一方通过密钥将信息加密后,把密文传给另一方,另一方通过这个相同的密钥将密文解密,转换成可以理解的明文。
非对称加密:
- 使用一对非对称密钥加密,即公钥和私钥,公钥可以随意发布,任何人都能获得,但私钥只有自己知道,发送密文的一方使用对方的公钥进行加密处理,对方接收到加密信息后,使用自己的私钥进行解密。公开密钥与私有密钥是一对,如果用公开密钥对数据进行加密,只有用对应的私有密钥才能解密;如果用私有密钥对数据进行加密,那么只有用对应的公开密钥才能解密。因为加密和解密使用的是两个不同的密钥,所以叫作非对称加密。
公私钥加密(非对称加密)
与传统的对称加密不同,公私钥加密采用了两个密钥:公钥和私钥。
- 公钥:公钥是公开的,任何人都可以获得,用于加密数据。
- 私钥:私钥是保密的,只有所有者才可以获得,用于解密数据。
公私钥加密的基本原理如下:
- 发送方使用接收方的公钥对数据进行加密后发送。
- 接收方使用自己的私钥对数据进行解密获取明文数据。
公私钥加密的优点在于在传输过程中不会将私钥传输出去,因此可以有效地确保安全性。此外,公私钥加密还具有数字签名、身份认证等多种应用方式,被广泛应用于网络安全、数字支付、电子政务等领域。
需要注意的是,公私钥加密的计算量相对较大,因此在大规模数据处理时可能会产生较大的延时。因此在现代的应用场景中,通常会采用混合加密方案(即同时采用公私钥加密和对称加密)来平衡效率和安全性。
爬虫
1.发起请求:
通过HTTP库向目标站点发起请求,即发送一个Request,请求可以包含额外的headers、data等信息,然后等待服务器响应。这个过程其实就相当于浏览器作为一个浏览的客户端,向服务器端发送了 一次请求。
2.获取响应内容:
如果服务器能正常响应,我们会得到一个Response,Response的内容便是所要获取的内容,类型可能有HTML、Json字符串,二进制数据(图片,视频等)等类型。这个过程就是服务器接收客户端的请求,进过解析发送给浏览器的网页HTML文件。
3.解析内容:
得到的内容可能是HTML,可以使用正则表达式,网页解析库进行解析。也可能是Json,可以直接转为Json对象解析。可能是二进制数据,可以做保存或者进一步处理。这一步相当于浏览器把服务器端的文件获取到本地,再进行解释并且展现出来。
4.保存数据:
保存的方式可以是把数据存为文本,也可以把数据保存到数据库,或者保存为特定的jpg,mp4 等格式的文件。这就相当于我们在浏览网页时,下载了网页上的图片或者视频
Linux系列
linux常用命令
ifconfig:查看网络接口详情
ping:查看与某主机是否能联通
ps -ef|grep 进程名称:查看进程号
lost -i 端口 :查看端口占用情况
top:查看系统负载情况,包括系统时间、系统所有进程状态、cpu情况
free:查看内存占用情况
kill:正常杀死进程,发出的信号可能会被阻塞
kill -9:强制杀死进程,发送的是exit命令,不会被阻塞
1、查看目录与文件:ls
ls -la:显示当前目录下所有文件的详细信息
2、切换目录:cd
cd /home 进入 ‘/ home’ 目录
cd … 返回上一级目录
cd …/… 返回上两级目录
3、显示当前目录:pwd
4、创建空文件:touch
touch desc.txt:在当前目录下创建文件desc.txt
5、创建目录:mkdir
mkdir test:在当前目录下创建test目录
6、查看文件内容:cat
cat desc.txt:查看desc.txt的内容
7、分页查看文件内容:more
more desc.txt:分页查看desc.txt的内容
8、查看文件尾内容:tail
tail -100 desc.txt:查看desc.txt的最后100行内容
9、拷贝:cp
cp desc.txt /mnt/:拷贝desc.txt到/mnt目录下
cp -r test /mnt/:拷贝test目录到/mnt目录下
10、剪切或改名:
mv desc.txt /mnt/:剪切文件desc.txt到目录/mnt下
mv 原名 新名
11、删除:rm
rm -rf test:删除test目录,-r递归删除,-f强制删除。危险操作,务必小心,切记!
12、搜索文件:find
find /opt -name ‘*.txt’:在opt目录下查找以.txt结尾的文件
13、显示或配置网络设备:ifconfig
ifconfig:显示网络设备情况
15、显示进程状态:ps
ps -ef:显示当前所有进程
ps-ef | grep java:显示当前所有java相关进程
ps aux:查看进程
18、显示系统当前进程信息:top
top:显示系统当前进程信息
19、杀死进程:kill
kill -s 9 27810:杀死进程号为27810的进程,强制终止,系统资源无法回收
20、压缩和解压:tar
tar -zcvf test.tar.gz ./test:打包test目录为test.tar.gz文件,-z表示用gzip压缩
tar -zxvf test.tar.gz:解压test.tar.gz文件
23、文本编辑:vim
vim三种模式:命令模式,插入模式,编辑模式。使用ESC或i或:来切换模式。
命令模式下:q退出 :q!强制退出 :wq!保存退出 :set number显示行号 /java在文档中查找java yy复制 p粘贴
vim desc.txt:编辑desc.txt文件
27、查看端口号占用情况:lsof -i
29、常用快捷键
Ctrl + c 中断当前程序
Ctrl + l 清屏 相当与clear
tab 所有路径以及补全命令
Ctrl+shift+c 命令行复制内容
Ctrl+shift+v 命令行粘贴内容
30、重要命令
1、top:查看内存/显示系统当前进程信息
2、df -h:查看磁盘储存状况
5、netstat -tunlp | grep 端口号:查看端口号占用情况(1)
6、lsof -i:端口号:查看端口号占用情况(2)
8、ps aux:查看进程
linux常用目录
[root@localhost ~]# 的含义:
- @之前的是当前登录的用户
- localhost是主机名字
- ~当前所在的位置(所在的目录)
- ~家目录
- /根目录
- #的位置是用户标识
- #是超级用户
- $普通用户
linux的核心思想
一切皆为文件
linux的io模型
IO是对磁盘或网络数据的读写,用户进程读取一次IO请求分为两个阶段:等待数据到达内核缓冲区和将内核缓冲区数据拷贝到用户空间,当用户去内核中拷贝数据时,要从用户态转为核心态
5种io模型:
(1) 同步阻塞IO模型
用户进程发起io调用后会被阻塞,等待内核缓冲区数据准备完毕时就被唤醒,将内核数据复制到用户进程。这两个阶段都是阻塞的
(2) 同步非阻塞IO模型
用户进程发起IO调用后,若内核缓冲区数据还未准备好,进程会继续干别的事,每隔一段时间就去看看内核数据是否准备好。不过将内核数据复制到用户进程这个阶段依旧是阻塞的
(3) IO多路复用模型
linux中把一切都看成文件,每个文件都有一个文件描述符(FD)来关联, IO多路复用模型就是复用单个进程同时监测多个文件描述符,当某个文件描述符可读或可写,就去通知用户进程。
(4) 信号IO模型
用户进程发起IO调用后,会向内核注册一个信号处理函数然后继续干别的事,当内核数据准备就绪时就通知用户进程来进行拷贝。
(5) 异步非阻塞模型
前面四种全是同步的。进程在发起IO调用后,会直接返回结果。待内核数据准备好时,由内核将数据复制给用户进程。两个阶段都是非阻塞的
用户态和内核态
计算机处理器执行指令时分为用户态和内核态两种模式。
用户态是指用户程序执行时所处的处理器状态。在该状态下,程序只能访问自己的地址空间,不能直接访问硬件设备,如网络、磁盘等。同时,操作系统为了防止控制权被用户程序恶意占用,还会对用户程序进行一定的限制和保护。在用户态中执行的指令不需要对系统资源进行直接操作,而是通过向操作系统发起系统调用来获取对系统资源的访问权限。
内核态是指处理器执行操作系统内核代码时所处的状态。在该状态下,操作系统可以完全控制机器上的所有资源,包括 CPU、内存、I/O 等。内核态下的指令能够直接操作硬件设备和寄存器,并且不受任何限制。内核态中的执行代码通常被称为内核代码,在操作系统的内核模块中运行。
分为用户态和内核态的主要目的就是为了提高系统的安全性和稳定性,以及保证系统的正常运行。通过限制用户程序对系统资源的直接访问权,避免了用户程序对资源的恶意占用和滥用,进而保障了系统的稳定性和可靠性。同时,操作系统在内核态下能够对硬件资源进行更加细致的管理和控制,以达到最优的资源利用效率,保障系统的性能。
IO多路复用详解
linux中把一切都看成文件,每个文件都有一个文件描述符(FD)来关联, IO多路复用模型就是复用单个进程同时监测多个文件描述符,当某个文件描述符可读或可写,就去通知用户进程。IO多路复用有三种方式
(1)select:采用数组结构,监测的fd有限,默认为1024;当有文件描述符就绪时,需要遍历整个FD数组来查看是哪个文件描述符就绪了,效率较低;每次调用select时都需要把整个文件描述符数组从用户态拷贝到内核态中来回拷贝,当fd很多时开销会很大;
(2)poll:采用链表结构,监测的文件描述符没有上限,其它的根select差不多
(3)epoll:采用红黑树结构,监测的fd没有上限,它有三个方法,epoll_create() 用于创建一个epoll实例,epoll实例中有一颗红黑树记录监测的fd,一个链表记录就绪的fd;epoll_ctl() 用于往epoll实例中增删要监测的文件描述符,并设置回调函数,当文件描述符就绪时触发回调函数将文件描述符添加到就绪链表当中;epoll_wait() 用于见擦汗就绪列表并返回就绪列表的长度,然后将就绪列表的拷贝到用户空间缓冲区中。
所以epoll的优点是当有文件描述符就绪时,只把已就绪的文件描述符写给用户空间,不需要每次都遍历FD集合;每个FD只有在调新增的时候和就绪的时候才会在用户空间和内核空间之间拷贝一次。
epoll的LT和ET模式
LT(默认):水平触发,当FD有数据可读的时候,那么每次 epoll_wait都会去通知用户来操作直到读完
ET:边缘触发,当FD有数据可读的时候,它只会通知用户一次,直到下次再有数据流入才会再通知,所以在ET模式下一定要把缓冲区的数据一次读完
零拷贝
零拷贝指的是,应用程序在需要把内核中的一块区域数据转移到另外一块内核区域去时,不需要经过先复制到用户空间,再转移到目标内核区域,而直接实现转移。它可以减少数据在应用程序和内核之间的多次复制操作,从而减轻了 CPU 的负担和加快了数据传输速度。零拷贝技术的几个实现手段包括:mmap+write、sendfile、sendfile+DMA收集、splice等。
在 Linux 系统中,零拷贝可以通过 mmap() 系统调用来实现。该调用将一个文件映射到进程的虚拟地址空间,使得进程可以直接在内存中读写该文件而不需要进行复制操作。当进程需要向网络发送数据时,可以通过 sendfile() 系统调用来实现零拷贝,该调用直接将文件数据在内核态和网络设备之间进行传输,减少了数据在用户态和内核态之间的多次复制操作。
零拷贝技术在操作系统中有着广泛的应用,比如在网络传输、文件操作、数据库访问等方面都可以使用零拷贝技术来提高系统性能和优化数据传输效率
场景题
Java如何实现统计在线人数的功能
- 使用 Session 监听器:在 Java Web 应用中,每个客户端请求都会创建一个 Session 对象,并在 Session 中存储用户相关信息。可以使用 Session 监听器来监听 Session 的创建和销毁事件,并在监听器中对在线人数进行统计。
- 使用 ServletContext:ServletContext 是整个 Web 应用的上下文对象,可以在其中存储一些全局数据。可以使用 ServletContext 来统计在线人数,将在线人数作为一个属性存储在 ServletContext 中,并在每次用户登录或退出时更新该属性。
- 使用数据库:另一种方法是将用户的登录信息存储到数据库中,在查询在线用户时对数据库进行查询。在用户登录时往数据库中添加一条记录,表示该用户已登录,在用户注销时从数据库中删除该记录。可以考虑使用缓存技术来提高查询性能。
电商网站可以分成哪些模块(或订单模块要完成哪些功能)
用户模块(用户账户、会员等级、收货信息)、订单模块(订单编号、类型信息、状态信息、时间信息等)、商品模块(店铺信息、数量、价格等)、支付模块(支付方式、支付时间、支付单号等)、物流模块(物流公司、物流单号、物流状态等)
MySQL 删除自增 id,随后重启 MySQL 服务,再插入数据,自增 id 会从几开始
innodb 引擎:
MySQL8.0前,下次自增会取表中最大 id + 1。原理是最大id会记录在内存中,重启之后会重新读取表中最大的id
MySQL8.0后,仍从删除数据 id 后算起。原理是它将最大id记录在redolog里了
myisam:
自增的 id 都从删除数据 id 后算起。原理是它将最大id记录到数据文件里了
MySQL插入百万级的数据如何优化
(1)一次sql插入多条数据,可以减少写redolog日志和binlog日志的io次数(sql是有长度限制的,但可以调整)
(2)保证数据按照索引进行有序插入
(3)可以分表后多线程插入
假设事务隔离是可重复读的,然后有事务A和B,A先开启但未提交,B开启后提交,问A读的数据是新的还是旧的
假设事务隔离级别是可重复读,那么在同一个事务中的查询都会返回一致的结果。因此,在该情况下,如果事务A在B提交之前进行了数据读取并且还未提交,那么事务A读取的数据是旧的,即事务A读取的是在事务A开启时刻的数据快照,不会受到其他事务的修改影响。
因此,如果B成功提交并修改了A读取的数据,即使A再次读取相同的数据,也只会读取到之前事务A开启时刻的旧数据,而不是B提交后的新数据。
如何优化网页加载速度
- 压缩和优化图片和视频:优化图片和视频可以大幅缩短网页的加载时间。可采用在线压缩工具或图片编辑软件,将图片和视频压缩为适当大小和格式,并尽量减少使用动画、Flash等影响性能的元素。
- 最小化CSS、JavaScript和HTML:将CSS、JavaScript和HTML文件缩小可以大幅减少文件下载时间,同时也可以缩小文件大小。可以使用CSS压缩器和JavaScript压缩器,或者使用在线工具进行处理。
- 启用浏览器缓存:启用浏览器缓存可以使得用户再次访问网站时加载速度更快。可以在服务器端设置HTTP头以启用浏览器缓存。
- 使用CDN加速:CDN(Content Delivery Network)是分布在不同地区的服务器保存网站内容的网络。使用CDN可以将网站静态文件缓存在最接近用户的服务器上,从而提高网页加载速度。
- 减少HTTP请求数:减少HTTP请求数可以加快网站的加载速度。可以将多个CSS或JavaScript文件合并成一个文件,或者使用CSS Sprites技术将多张图片合并成一张,并尽量减少不必要的请求。
- 使用异步加载:使用异步加载可以在网页渲染时同时下载其他文件,从而提高加载速度。可以使用defer或async属性实现JavaScript的异步加载。
- 选择合适的主机和服务器:选择可靠的主机和服务器可以确保网站稳定运行并提高加载速度。
如何利用哈希分片进行大数据搜索
哈希分片法是一种常用的数据分片技术,可以将大规模的数据集划分成多个较小的数据分片,然后通过分布式计算框架进行处理。在大数据搜索中,可以利用哈希分片法来快速检索需要查询的数据。
具体实现步骤如下:
- 根据数据的某个属性(如ID、关键词等),设计一个哈希函数,将其映射到固定的哈希值。
- 将数据根据哈希函数的结果进行分片,每个分片包含一部分数据和对应的哈希值范围。
- 将每个数据分片存储在不同的节点上,在查询时只需要对需要搜索的关键词进行哈希运算,找到对应的数据分片所在的节点,并向其发送请求。
- 在每个节点上对本地数据进行检索,最终将搜索结果返回给请求方,由请求方汇总结果并返回给用户。
需要注意的是,哈希分片法需要考虑以下问题:
- 分片大小的选择:分片过大会导致查询速度变慢,分片过小会增加通信开销,需要根据数据量和节点数量进行合理的划分。
- 哈希函数设计的合理性:应该尽量避免哈希冲突,保证每个数据分片内的数据能够均匀地分布在不同的节点上。
- 故障恢复和负载均衡:需要采用一些机制来保证数据不会因为节点故障或负载不均而丢失或访问受阻。
什么是哈希分片
哈希分片是一种常见的数据分片技术,它将大规模的数据集根据某个属性(如ID、关键词等)进行哈希运算,将数据划分成若干个大小相等或大小不等的子集,每个子集被称为一个分片。通常每个分片包含一部分数据和对应的哈希值范围。
通过哈希分片的方法,可以将大规模数据存储在不同的物理节点上,从而实现分布式计算和存储。在查询时,只需要对需要搜索的关键词进行哈希运算,找到对应的数据分片所在的节点,并向其发送请求,减少了数据传输的时间和带宽需求,提高了搜索效率。
哈希分片的核心思想是利用哈希函数将数据映射到确定的位置上,因此需要保证哈希函数的均匀性和数据的稳定性。同时,哈希分片也需要考虑负载均衡、故障恢复、数据一致性等问题,以保证系统的高可用性和可扩展性。
哈希分片是一种简单、有效的数据分片策略,在分布式计算、数据库系统、大数据处理等领域得到了广泛的应用。
假如有40亿qq号,但是现在只有1G内存,应该怎么样去实现去重
40亿个unsigned int,如果直接用内存存储的话,需要:
4*4000000000 /1024/1024/1024 = 14.9G
,考虑到其中有一些重复的话,那1G的空间也基本上是不够用的。
想要实现这个功能,可以借助位图(Bitmap)。
使用位图的话,一个数字只需要占用1个bit,那么40亿个数字也就是:
4000000000 * 1 /8 /1024/1024 = 476M
相比于之前的14.9G来说,大大的节省了很多空间。
比如要把我的QQ号"907607222"放到位图(Bitmap)中,就需要找到第907607222这个位置,然后把他设置成1就可以了。
这样,把40亿个数字都放到位图(Bitmap)之后,所有位置上是1的表示存在,不为1的表示不存在,相同的QQ号只需要设置一次1就可以了,那么,最终就把所有是1的数字遍历出来就行了。
数据结构与算法、nginx、git
设计模式
设计模式六大原则
- 单一职责原则:一个类或者一个方法只负责一项职责,尽量做到类只有一个行为引起变化;
- 里氏替换原则:子类可以扩展父类的功能,但不能改变原有父类的功能
- 依赖倒置原则:高层模块不应该依赖底层模块,两者都应该依赖接口或抽象类
- 接口隔离原则:建立单一接口,尽量细化接口
- 迪米特原则:只关心其它对象能提供哪些方法,不关心过多内部细节
- 开闭原则:对于拓展是开放,对于修改是封闭的
设计模式分类
创建型模式:主要是描述对象的创建,代表有单例、原型模式、工厂方法、抽象工厂、建造者模式
结构型模式:主要描述如何将类或对象按某种布局构成更大的结构,代表有代理、适配器、装饰
行为型模式:描述类或对象之间如何相互协作共同完成单个对象无法完成的任务,代表有模板方法模式、策略模式、观察者模式、备忘录模式
单例模式
单例模式是 Java 中最简单的设计模式之一。这种类型的设计模式属于创建型模式,它提供了一种创建对象的最佳方式。它确保一个类在任何情况下都只能实例化一次,并提供了全局访问点。
这种模式涉及到一个单一的类,该类负责创建自己的对象,同时确保只有单个对象被创建。这个类提供了一种访问其唯一的对象的方式,可以直接访问,不需要实例化该类的对象。
- 1、单例类只能有一个实例。
- 2、单例类必须自己创建自己的唯一实例。
- 3、单例类必须给所有其他对象提供这一实例
案例:
在计算机系统中,线程池、缓存、日志对象、对话框、打印机、显卡的驱动程序对象常被设计成单例。
Windows 是多进程多线程的,在操作一个文件的时候,就不可避免地出现多个进程或线程同时操作一个文件的现象,所以所有文件的处理必须通过唯一的实例来进行。
实现方式通常有饿汉式和懒汉式两种。
- 饿汉式单例模式:在类加载时就创建了一个静态实例对象,保证在程序运行期间只有一个实例对象。线程安全,但可能会造成资源浪费,因为不管是否需要该实例对象,都会先创建出来。
java复制代码public class Singleton {
// 创建单例对象
private static final Singleton INSTANCE = new Singleton();
// 私有化构造方法,防止外部实例化
private Singleton() {}
// 提供获取单例对象的静态方法
public static Singleton getInstance() {
return INSTANCE;
}
}
- 懒汉式单例模式:在第一次调用获取实例的方法时创建对象,之后直接返回已经创建的对象。线程不安全,需要在多线程环境下加锁或使用双重检验锁等方式来保证线程安全。
java复制代码public class Singleton {
// 声明单例对象的引用,初始值为null
private static Singleton instance;
// 私有化构造方法,防止外部实例化
private Singleton() {}
// 提供获取单例对象的静态方法,使用synchronized加锁,保证线程安全
public static synchronized Singleton getInstance() {
if (instance == null) {
instance = new Singleton();
}
return instance;
}
}
以上是两种常见的单例模式实现方式,除此之外还有其他实现方式,例如静态内部类、枚举等。
kmp算法
字符串子串的匹配,kmp的核心思想就是两个指针在主串和子串上进行比较的时候,遇到匹配不上的情况时,主串上的指针永远不向后移动,而是通过已经匹配上的字符来向后移动子串的指针,从而达到子串的匹配。
next数组的本质:子串中最长的相同前后缀的长度
时间复杂度(m+n)
空间复杂度(m)
public static void main(String[] args) {
String string = "abcabeabcabcmn";
String substring = "abcabcmn";
char[] chars_str = string.toCharArray();
char[] chars_sub = substring.toCharArray();
//获取子串的next数组(字符串中每个位置的最长相同前后缀的长度)
int[] next = getNext(substring);
//i为主串指针,j为子串指针,ans为子串在主串中的起始位置(如果为-1则代表子串与主串不匹配)
int i = 0, j = 0, ans = -1;
//获取主串长度
int len_str = string.length();
//获取子串长度
int len_sub = substring.length();
//从头到尾遍历主串
while (i < len_str) {
//判断当前位置子串的字符是否和主串相同
if (chars_sub[j] == chars_str[i]) {
//相同的话就同时把主串和子串的指针向后移动
i++;
j++;
} else if (j > 0) {
//如果不相同并且子串的指针不在起始位置的话,那就让子串的指针回溯到上个位置的最长相同前后缀处
j = next[j - 1];
} else {
//如果此时子串的指针在起始位置,那就说明此时主串的指针对应的字符从一开始就不跟子串匹配,那么就将主串的指针后移
i++;
}
//判断此时子串的指针是否到达了最后
if (j == len_sub) {
//此时子串的指针到达了最后,那么说明此时已经匹配完毕
//那么此时用当前主串指针的位置减去子串的长度,得到的结果就是子串在主串中的起始位置
ans = i - len_sub;
break;
}
}
System.out.println(ans);
}
//获取字符串的next数组(字符串中每个位置的最长相同前后缀的长度)
private static int[] getNext(String substring) {
//获取子串的长度
int length = substring.length();
//将子串转换为字符数组
char[] chars = substring.toCharArray();
//以子串的长度为长度创建next数组
int[] next = new int[length];
//从第二个字符开始匹配,maxLen为最长相同前后缀的长度(初始为0)
int i = 1, maxLen = 0;
//从第二个字符开始从头到尾依次遍历整个字符串
while (i < length) {
//判断上个位置最长相同前后缀后边的那个字符,是否能与判断指针所指的字符匹配
if (chars[i] == chars[maxLen]) {
//匹配的上,那么上个位置的maxLen+1,变为当前位置的maxLen
maxLen++;
//然后设置当前位置next数组的值为当前位置的maxLen
next[i] = maxLen;
//并且将判断指针右移一位
i++;
} else {
//匹配不上,那么在判断上个位置的maxLen是否为0
if (maxLen == 0) {
//为0,说明当前位置没有相同前后缀,那么就将判断指针右移一位
i++;
} else {
//不为0,那么此时上个位置就来到了目前最长相同前缀的最后一位,上个位置的maxLen就等于上个位置的最长相同前后缀最后一位的maxLen
maxLen = next[maxLen - 1];
}
}
}
return next;
}
排序算法
排序算法的时间复杂度
交换排序:冒泡排序(n^2,稳定),相同元素的前后顺序并没有改变,所以冒泡排序是一种稳定排序算法
快速排序:(nlogn,不稳定)
选择排序:直接选择排序(n^2,不稳定),选择排序算法是一种不稳定排序算法,当出现相同元素的时候有可能会改变相同元素的顺序
堆排序:(nlogn,不稳定s)
插入排序:直接插入排序(n^2,稳定)
希尔排序:(N^1.25,不稳定)
归并排序:(nlogn,稳定)
大量数据排名,采用什么数据结构
当数据很大时,并且有序程度低时,堆排序最快;当数据很大时,并且有序程度高时,快速排序最快
快速排序法
选定最左边的数,然后通过循环把比选定的数小的数移动到数组的左边,把比选中的数大的数移动到数组的右边,然后递归的去排序左数组和右数组完成整个排序过程。
private static void quickSort(int[] arrays, int left, int right) {
//如果左右指针重合或者参数不合法,则直接退出
if (left >= right)
return;
int L = left, R = right;
//以左边的数为选定的数
int index = arrays[left];
while (left < right) {
//当右边的数大于或者等于选定的数的时候,直接跳过该数,让right--
while (left < right && arrays[right] >= index)
right--;
//这个时候说明右边的那个数小于选定的数,所以直接将右边的那个数赋值到左边
if (left < right)
arrays[left] = arrays[right];
//当左边的数小于或者等于选定的数的时候,直接跳过该数,让left++
while (left < right && arrays[left] <= index)
left++;
//当left=right的时候说明左右指针重合,直接赋值当前的数为选定的那个数
if (left == right)
arrays[left] = index;
}
//递归操作数组的右半边
quickSort(arrays, L, right - 1);
//递归操作数组的左半边
quickSort(arrays, right + 1, R);
}
树
树的概念
树是一种非线性的数据结构,它是由n(n>=0)个有限结点组成一个具有层次关系的集合。
树的表示
树有很多种表示方式,如:双亲表示法,孩子表示法、孩子兄弟表示法等等。
二叉树
二叉树的概念
一棵二叉树是结点的一个有限集合,该集合或者为空,或者是由一个根节点加上两棵别称为左子树和右子树的二叉树组成。
二叉树的特点
- 每个结点最多有两棵子树,即二叉树不存在度大于2的结点。
- 二叉树的子树有左右之分,其子树的次序不能颠倒。
特殊的二叉树
满二叉树:一个二叉树,如果每一个层的结点数都达到最大值,则这个二叉树就是满二叉树。也就是说,如果一个二叉树的层数为K,且结点总数是(2^k) -1 ,则它就是满二叉树。
完全二叉树:完全二叉树是效率很高的数据结构,完全二叉树是由满二叉树而引出来的。对于深度为K的,有n个结点的二叉树,当且仅当其每一个结点都与深度为K的满二叉树中编号从1至n的结点一一对应时称之为完全二叉树。 要注意的是满二叉树是一种特殊的完全二叉树。
二叉树的存储结构
二叉树一般可以使用两种存储结构,一种顺序结构,一种链式结构。
顺序存储
顺序结构存储就是使用数组来存储,一般使用数组只适合表示完全二叉树,因为不是完全二叉树会有空间的浪费。而现实中使用中只有堆才会使用数组来存储。二叉树顺序存储在物理上是一个数组,在逻辑上是一颗二叉树。
链式存储
二叉树的链式存储结构是指,用链表来表示一棵二叉树,即用链来指示元素的逻辑关系。 通常的方法是链表中每个结点由三个域组成,数据域和左右指针域,左右指针分别用来给出该结点左孩子和右孩子所在的链结点的存储地址 。链式结构又分为二叉链和三叉链,当前我们学习中一般都是二叉链,后面课程学到高阶数据结构如红黑树等会用到三叉链。
二叉树和堆之间联系或区别
堆是一种特殊的二叉树,所有父结点都比子结点要小的完全二叉树我们称为最小堆,所有父结点都比子结点要大,这样的完全二叉树称为最大堆。
平衡二叉树不平衡如何调整
按照不平衡的情况有四种调整方法,分别是LR、RL、LL、RR调整
当不平衡的子树以当前节点为第一个节点往下再数到第三个节点的路径是先左再右时使用LR调整,LR是先将第二个节点旋转到第三个节点的左边,将第一个节点移动到第三个节点的右边;
RL与LR相反,RR是把第一个节点移到第三个节点左边,RR与LL相反
AVL树
AVL树是一种自平衡二叉搜索树。AVL树的主要目的是使得整棵树保持平衡,从而达到最优的时间复杂度。在AVL树中,每个节点的左子树与右子树的高度差(平衡因子)不超过1,也就是说树的高度总是O(log n)。
AVL树的插入、删除和查找操作与普通的二叉搜索树类似,但在每次插入或删除一个节点之后,需要重新对树进行平衡操作以确保整棵树仍然是平衡的。如果某个节点失衡,可以通过旋转子树(单旋转或双旋转)来调整树的结构,以达到重新平衡的目的。AVL树的平衡维护是通过左旋、右旋、左右旋和右左旋四种基本操作来实现的。
AVL树的时间复杂度与树的平衡状态密切相关,当树的平衡良好时,插入、删除和查找操作的时间复杂度均为O(log n),可以保证最坏情况下的时间复杂度也是O(log n)。AVL树在插入、删除操作较频繁的场景中效果更好,但由于需要维护平衡条件,因此其实现比普通二叉搜索树的实现要复杂一些。
红黑树
红黑树是一种自平衡二叉搜索树,**红黑树保证最长路径不超过最短路径的二倍,因而近似平衡(最短路径就是全黑节点,最长路径就是一个红节点一个黑节点,当从根节点到叶子节点的路径上黑色节点相同时,最长路径刚好是最短路径的两倍)**在实现时需要满足以下性质:
- 每个节点要么是红色,要么是黑色
- 根节点是黑色的
- 每个叶子节点(外部节点,空节点)都是黑色的
- 如果一个节点是红色的,则它的两个子节点都是黑色的
- 对于任意节点而言,从该节点到其每个叶子节点的所有路径都包含相同数目的黑色节点
红黑树的叶子节点一定是空节点(外部节点),上边的底层节点不是叶子节点
红黑树的查找,插入和删除操作,时间复杂度都是O(logN)。
hash表冲突的解决方法
开放地址法:有线性探测法和平方探测法,当发生冲突时,继续往后找
再哈希法:构造多个哈希函数,发生冲突后使用下一个函数
链地址法:将hash值相同的记录用链表链接起来
建立公共溢出区:将哈希表分为基础表和益处表两部分,发生冲突的填入益处表
Nginx反向代理,负载均衡算法
反向代理是用来代理服务器接收请求的,然后将请求转发给内部网络的服务器,并将从服务器上得到的结果返回给客户端,此时代理服务器对外就表现为一个服务器。
负载均衡算法有:轮询(默认)、带权轮询、ip_hash(按ip哈希结果分配,能解决session共享问题)、url_hash(按访问的URL的哈希结果分配)、fair(根据服务端响应时间分配,响应时间短优先)
git
git能做什么
- 代码回溯:Git在管理文件过程中会记录日志,方便回退到历史版本
- 版本切换:Git存在分支的概念,一个项目可以有多个分支(版本),可以任意切换
- 多人协作:Git支持多人协作,即一个团队共同开发一个项目,每个团队成员负责一部分代码,通过Git就可以管理和协调
- 远程备份:Git通过仓库管理文件,在Git中存在远程仓库,如果本地文件丢失还可以从远程仓库获取
Git 的基础命令
- git init:初始化一个空 Git 仓库。
- git clone:从远程仓库克隆一个 Git 仓库到本地。
- git add:将文件添加到 Git 仓库的暂存区。
- git commit:将暂存区中的更改提交到 Git 仓库。
- git push:将本地 Git 仓库的更改推送到远程仓库。
- git pull:从远程仓库拉取最新的更改,并与本地仓库合并。
- git status:检查当前工作目录下 Git 仓库的状态。
- git log:查看 Git 仓库的提交历史。
- git branch:管理 Git 仓库中的分支。
- git checkout:切换到指定的 Git 分支。
Git忽略文件
在Git工作区中有一个特殊的文件 .gitignore,通过此文件可以指定工作区中的哪些文件不需要Git管理。
秒杀项目相关问题:
项目流程
用户点击下单按钮时,进行三次判断:先判断请求路径是否合法,因为做了动态URL;再判断用户是否已经下单过,就是看redis缓存中有没有用户下单信息;最后判断库存,这里进行了redis库存预减,由于判断库存和预减库存不是原子性操作,所以用lua脚本来执行这一段代码。然后从这里开始使用分布式锁,锁id为用户id+商品id,防止一个用户发送多次请求让redis多次预减。
Redis扣减成功后,进行异步下单,直接将正在处理返回给前端,将用户id和商品Id发送RabbitMQ中,负责下单的业务会从消息队列中拿出消息,去执行以下操作:
1.减库存,减库存时用where 库存>0防止超卖
2.订单表中生成记录,订单表中的用户id和商品id添加了联合唯一索引防止超卖
减库存和增加订单放在一个事务内保证一致性
3.将用户id和订单id缓存到redis中用来最初对用户重复下单的判断
4.释放分布式锁,根据value去判断锁是不是当前线程的,判断和删除锁不是原子性操作,所以封装到了lua脚本中
提升qps的操作
qps通常用于测量信息检索系统的性能,是指在一秒钟内信息检索系统(如搜索引擎或数据库)收到的搜索流量的数量。
(1)页面动静分离,静态页面缓存到redis
(2)分布式锁拦截不同用户的重复
(3)限流算法
(4)验证码限流
(5)rabbitMq流量削峰
(6)接口隐藏
如何用springSecurity做的认证授权
在数据库中有五张表,分别是菜单表,角色表,用户表,他们是多对多的关系,所以还有角色菜单表,角色用户表
登录后进入认证过滤器,获取用户名和密码,根据用户名查询用户具有的权限并把用户名和对应权限信息放到redis,JWT生成token后放入cookie,每次调用接口时携带
然后执行授权过滤器,从header中获取token解析出用户名,根据用户名从redis中获取权限列表,然后springSecurity就能够判断当前请求是否有权限访问
前后端联调经常遇到的问题:
1.请求方式不匹配
2.json、x-wwww-form-urlencoded混乱的错误
3.前后端参数不一致,空指针异常,数据类型不匹配
4.mp生成的分布式id是19位,JavsScrip只会处理16位,将id生成策略改为String类型
5.跨域问题:跨域问题是在访问协议、ip地址、端口号这三个有任何一个不一样,相互访问就会出现跨域,可以通过Spring注解解决跨域的 @CrossOrigin,也可以使用nginx反向代理、网关
6.maven加载项目时,默认不会加载src-java文件夹的xml类型文件,可以 将xml放到resources文件夹下,也可以在yaml和pom中添加配置