目录
黑马面试篇1-优快云博客(续集)
六、消息中间件篇
6.1 RabbitMQ
1)使用场景:
- 异步发送(验证码、短信、邮件…)
- MYSQL和Redis , ES之间的数据同步
- 分布式事务
- 削峰填谷
- …
2)RabbitMQ消息的重复消费问题如何解决?
3)RabbitMQ的高可用机制有了解过吗?
- 在生产环境下,使用集群来保证高可用性
- 普通集群、镜像集群、仲裁队列
6.2 Kafka
1)Kafka如何保证消息不丢失
使用Kafka在消息的收发过程都会出现消息丢失 , Kafka分别给出了解决方案
- 生产者发送消息到Brocker丢失
- 消息在Brocker中存储丢失
- 消费者从Brocker接收消息丢失
2)重复消费问题Kafka是如何解决的
见上面一张图片左下角。
3)Kafka是如何保证消费的顺序性
应用场景:
- 即时消息中的单对单聊天和群聊,保证发送方消息发送顺序与接收方的顺序一致
- 充值转账两个渠道在同一个时间进行余额变更,短信通知必须要有顺序
4)Kafka的高可用机制有了解过吗?★★★
- 集群模式
- 分区备份机制
5)解释一下复制机制中的 ISR
见上一张图片。
6)Kafka数据清理机制了解吗
- Kafka文件存储机制
- 数据清理机制
7)Kafka中实现高性能的设计有了解吗
Kafka高性能,是多方面协同的结果,包括宏观架构、分布式存储、ISR数据同步、以及高效的利用磁盘、操作系统特性等。主要体现有这么几点:
- 消息分区:不受单台服务器的限制,可以不受限的处理更多的数据
- 顺序读写:磁盘顺序读写,提升读写效率
- 页缓存:把磁盘中的数据缓存到内存中,把对磁盘的访问变为对内存的访问
- 零拷贝:减少上下文切换及数据拷贝
- 消息压缩:减少磁盘IO和网络IO
- 分批发送:将消息打包批量发送,减少网络开销
下图是零拷贝讲解图片:
七、常见集合篇
7.1 算法复杂度分析
一般说的复杂度,都指时间复杂度,因为现在计算机内存空间已经不是瓶颈。
7.2 数据结构
1. 数组
2. 链表
2.1 单向链表
- 链表中的每一个元素称之为结点(Node)
- 物理存储单元上,非连续、非顺序的存储结构
- 单向链表:每个结点包括两个部分:一个是存储数据元素的数据域,另一个是存储下一个结点地址的指针域。记录下个结点地址的指针叫作后继指针 next
时间复杂度分析:
查询操作:
- 只有在查询头节点的时候不需要遍历链表,时间复杂度是O(1)
- 查询其他结点需要遍历链表,时间复杂度是O(n)
插入\删除操作:
- 只有在添加和删除头节点的时候不需要遍历链表,时间复杂度是O(1)
- 添加或删除其他结点需要遍历链表找到对应节点后,才能完成新增或删除节点,时间复杂度是O(n)
2.2 双向链表
3. 二叉树
在二叉树中,比较常见的二叉树有:
- 满二叉树
- 完全二叉树
- 二叉搜索树
- 红黑树
二叉搜索树(Binary Search Tree,BST)又名二叉查找树,有序二叉树或者排序二叉树,是二叉树中比较常用的一种类型。二叉查找树要求,在树中的任意一个节点,其左子树中的每个节点的值,都要小于这个节点的值,而右子树节点的值都大于这个节点的值。
总结:
1)什么是二叉树?
- 每个节点最多有两个“叉”,分别是左子节点和右子节点。
- 不要求每个节点都有两个子节点,有的节点只有左子节点,有的节点只有右子节点。
- 二叉树每个节点的左子树和右子树也分别满足二叉树的定义
2)什么是二叉搜索树?
- 二叉搜索树(Binary Search Tree,BST)又名二叉查找树,有序二叉树
- 在树中的任意一个节点,其左子树中的每个节点的值,都要小于这个节点的值而右子树节点的值都大于这个节点的值
- 没有键值相等的节点
- 通常情况下二叉树搜索的时间复杂度为O(log n)
- 对于图中这种情况属于最坏的情况,二叉查找树已经退化成了链表,左右子树极度不平衡,此时查找的时间复杂度肯定是O(n)。
4. 红黑树
红黑树(Red Black Tree):也是一种自平衡的二叉搜索树(BST),之前叫做平衡二叉B树(Symmetric Binary B-Tree)。
红黑树总结:
- 红黑树(Red Black Tree)也是一种自平衡的二叉搜索树(BST)
- 所有的红黑规则都是希望红黑树能够保证平衡
- 红黑树的时间复杂度:查找、添加、删除都是O(logn)
5. 散列表
在HashMap中的最重要的一个数据结构就是散列表,在散列表中又使用到了红黑树和链表。
1)散列表
散列表(Hash Table)又名哈希表/Hash表,是根据键(Key)直接访问在内存存储位置值(Value)的数据结构,它是由数组演化而来的,利用了数组支持按照下标进行随机访问数据的特性。
2)散列函数
将键(key)映射为数组下标的函数叫做散列函数。可以表示为:hashValue = hash(key)
散列函数的基本要求:
- 散列函数计算得到的散列值必须是大于等于0的正整数,因为hashValue需要作为数组的下标。
- 如果key1==key2,那么经过hash后得到的哈希值也必相同即:hash(key1) == hash(key2)
- 如果key1 != key2,那么经过hash后得到的哈希值也必不相同即:hash(key1) != hash(key2)
3)散列冲突
实际的情况下想找一个散列函数能够做到对于不同的key计算得到的散列值都不同几乎是不可能的,即便像著名的MD5,SHA等哈希算法也无法避免这一情况,这就是散列冲突(或者哈希冲突,哈希碰撞,就是指多个key映射到同一个数组下标位置)
4)散列冲突-链表法(拉链)
在散列表中,数组的每个下标位置我们可以称之为桶(bucket)或者槽(slot),每个桶(槽)会对应一条链表,所有散列值相同的元素我们都放到相同槽位对应的链表中。
5)总结
什么是散列表?
- 散列表(Hash Table)又名哈希表/Hash表
- 根据键(Key)直接访问在内存存储位置值(Value)的数据结构
- 由数组演化而来的,利用了数组支持按照下标进行随机访问数据
散列冲突
- 散列冲突又称哈希冲突,哈希碰撞
- 指多个key映射到同一个数组下标位置
散列冲突-链表法(拉链)
- 数组的每个下标位置称之为桶(bucket)或者槽(slot)
- 每个桶(槽)会对应一条链表
- hash冲突后的元素都放到相同槽位对应的链表中或红黑树中
7.3 ArrayList
ArrayList源码分析(略)
1)ArrayList底层的实现原理(JDK1.8)
2)ArrayList list=new ArrayList(10)中的list扩容几次
答:该语句只是声明和实例了一个 ArrayList,指定了容量为 10,未扩容。
3)如何实现数组和List之间的转换
4)ArrayList和LinkedList的区别★★★
7.4 HashMap
1)说一下HashMap的实现原理?(JDK1.8)★★★
HashMap的jdk1.7和jdk1.8有什么区别?
总结:
2)HashMap的put方法的具体流程★★★
具体流程解答:
put方法流程(代码):
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
//判断数组是否未初始化
if ((tab = table) == null || (n = tab.length) == 0)
//如果未初始化,调用resize方法 进行初始化
n = (tab = resize()).length;
//通过 & 运算求出该数据(key)的数组下标并判断该下标位置是否有数据
if ((p = tab[i = (n - 1) & hash]) == null)
//如果没有,直接将数据放在该下标位置
tab[i] = newNode(hash, key, value, null);
//该数组下标有数据的情况
else {
Node<K,V> e; K k;
//判断该位置数据的key和新来的数据是否一样
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
//如果一样,证明为修改操作,该节点的数据赋值给e,后边会用到
e = p;
//判断是不是红黑树
else if (p instanceof TreeNode)
//如果是红黑树的话,进行红黑树的操作
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
//新数据和当前数组既不相同,也不是红黑树节点,证明是链表
else {
//遍历链表
for (int binCount = 0; ; ++binCount) {
//判断next节点,如果为空的话,证明遍历到链表尾部了
if ((e = p.next) == null) {
//把新值放入链表尾部
p.next = newNode(hash, key, value, null);
//因为新插入了一条数据,所以判断链表长度是不是大于等于8
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
//如果是,进行转换红黑树操作
treeifyBin(tab, hash);
break;
}
//判断链表当中有数据相同的值,如果一样,证明为修改操作
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
//把下一个节点赋值为当前节点
p = e;
}
}
//判断e是否为空(e值为修改操作存放原数据的变量)
if (e != null) { // existing mapping for key
//不为空的话证明是修改操作,取出老值
V oldValue = e.value;
//一定会执行 onlyIfAbsent传进来的是false
if (!onlyIfAbsent || oldValue == null)
//将新值赋值当前节点
e.value = value;
afterNodeAccess(e);
//返回老值
return oldValue;
}
}
//计数器,计算当前节点的修改次数
++modCount;
//当前数组中的数据数量如果大于扩容阈值
if (++size > threshold)
//进行扩容操作
resize();
//空方法
afterNodeInsertion(evict);
//添加操作时 返回空值
return null;
}
3)讲一讲HashMap的扩容机制★★★
扩容的代码:
//扩容、初始化数组
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
//如果当前数组为null的时候,把oldCap老数组容量设置为0
int oldCap = (oldTab == null) ? 0 : oldTab.length;
//老的扩容阈值
int oldThr = threshold;
int newCap, newThr = 0;
//判断数组容量是否大于0,大于0说明数组已经初始化
if (oldCap > 0) {
//判断当前数组长度是否大于最大数组长度
if (oldCap >= MAXIMUM_CAPACITY) {
//如果是,将扩容阈值直接设置为int类型的最大数值并直接返回
threshold = Integer.MAX_VALUE;
return oldTab;
}
//如果在最大长度范围内,则需要扩容 OldCap << 1等价于oldCap*2
//运算过后判断是不是最大值并且oldCap需要大于16
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold 等价于oldThr*2
}
//如果oldCap<0,但是已经初始化了,像把元素删除完之后的情况,那么它的临界值肯定还存在, 如果是首次初始化,它的临界值则为0
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
//数组未初始化的情况,将阈值和扩容因子都设置为默认值
else { // zero initial threshold signifies using defaults
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
//初始化容量小于16的时候,扩容阈值是没有赋值的
if (newThr == 0) {
//创建阈值
float ft = (float)newCap * loadFactor;
//判断新容量和新阈值是否大于最大容量
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
//计算出来的阈值赋值
threshold = newThr;
@SuppressWarnings({"rawtypes","unchecked"})
//根据上边计算得出的容量 创建新的数组
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
//赋值
table = newTab;
//扩容操作,判断不为空证明不是初始化数组
if (oldTab != null) {
//遍历数组
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
//判断当前下标为j的数组如果不为空的话赋值个e,进行下一步操作
if ((e = oldTab[j]) != null) {
//将数组位置置空
oldTab[j] = null;
//判断是否有下个节点
if (e.next == null)
//如果没有,就重新计算在新数组中的下标并放进去
newTab[e.hash & (newCap - 1)] = e;
//有下个节点的情况,并且判断是否已经树化
else if (e instanceof TreeNode)
//进行红黑树的操作
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
//有下个节点的情况,并且没有树化(链表形式)
else {
//比如老数组容量是16,那下标就为0-15
//扩容操作*2,容量就变为32,下标为0-31
//低位:0-15,高位16-31
//定义了四个变量
// 低位头 低位尾
Node<K,V> loHead = null, loTail = null;
// 高位头 高位尾
Node<K,V> hiHead = null, hiTail = null;
//下个节点
Node<K,V> next;
//循环遍历
do {
//取出next节点
next = e.next;
//通过 与操作 计算得出结果为0
if ((e.hash & oldCap) == 0) {
//如果低位尾为null,证明当前数组位置为空,没有任何数据
if (loTail == null)
//将e值放入低位头
loHead = e;
//低位尾不为null,证明已经有数据了
else
//将数据放入next节点
loTail.next = e;
//记录低位尾数据
loTail = e;
}
//通过 与操作 计算得出结果不为0
else {
//如果高位尾为null,证明当前数组位置为空,没有任何数据
if (hiTail == null)
//将e值放入高位头
hiHead = e;
//高位尾不为null,证明已经有数据了
else
//将数据放入next节点
hiTail.next = e;
//记录高位尾数据
hiTail = e;
}
}
//如果e不为空,证明没有到链表尾部,继续执行循环
while ((e = next) != null);
//低位尾如果记录的有数据,是链表
if (loTail != null) {
//将下一个元素置空
loTail.next = null;
//将低位头放入新数组的原下标位置
newTab[j] = loHead;
}
//高位尾如果记录的有数据,是链表
if (hiTail != null) {
//将下一个元素置空
hiTail.next = null;
//将高位头放入新数组的(原下标+原数组容量)位置
newTab[j + oldCap] = hiHead;
}
}
}
}
}
//返回新的数组对象
return newTab;
}
4)hashMap的寻址算法★★★
5)为何HashMap的数组长度一定是2的次幂?
总结:
6)hashmap在1.7情况下的多线程死循环问题
7)HashSet与HashMap的区别
没讲,自己扩展。
8)HashTable与HashMap的区别
没讲,自己扩展。
八、并发编程篇
8.1 线程的基础知识
1)并行和并发有什么区别?
并行和并发的主要区别在于资源分配和事件发生的同步性:
- 并行涉及多个任务在同一时刻真正的同时执行,通常在具有多个处理器或核心的系统中实现,每个任务由不同的处理器单元负责。
- 并发则是指在一个时间段内,系统看似同时处理多个任务,但实际上这些任务是在同一处理器上通过快速切换(如时间片轮转)来实现的,因此在同一时刻只有一个任务在实际执行。
简而言之,并行意味着同时执行,而并发则表示快速交替执行,给人一种同时进行的错觉。
2)创建线程的方式有哪些?
3)runnable 和 callable 有什么区别?
- Runnable 接口run方法没有返回值
- Callable接口call方法有返回值,是个泛型,和Future、FutureTask配合可以用来获取异步执行的结果
- Callable接口的call()方法允许抛出异常;而Runnable接口的run()方法的异常只能在内部消化,不能继续上抛
4)线程的 run()和 start()有什么区别?
- start(): 用来启动线程,通过该线程调用run方法执行run方法中所定义的逻辑代码。start方法只能被调用一次。
- run(): 封装了要被线程执行的代码,可以被调用多次。
5)线程包括哪些状态,状态之间是如何变化的
六种状态:新建(NEW)、可运行(RUNNABLE)、阻塞(BLOCKED)、等待( WAITING )、时间等待(TIMED_WALTING)、终止(TERMINATED)
状态转换:
6)新建T1、T2、T3三个线程,如何保证它们按顺序执行?
保证线程顺序执行的方法很多,这里介绍一种最简单的。
7)notify()和 notifyAll()有什么区别?
notifyAll:唤醒所有wait的线程
notify:只随机唤醒一个 wait 线程
8)wait和sleep方法的不同?
wait必须与synchronized配合使用才行,否则会报错。
共同点:
- wait() ,wait(long) 和 sleep(long) 的效果都是让当前线程暂时放弃 CPU 的使用权,进入阻塞状态
不同点:
1.方法归属不同
- sleep(long) 是 Thread 的静态方法
- 而 wait(),wait(long) 都是 Object 的成员方法,每个对象都有
2.醒来时机不同
- 执行 sleep(long) 和 wait(long) 的线程都会在等待相应毫秒后醒来
- wait(long) 和 wait() 还可以被 notify 唤醒,wait() 如果不唤醒就一直等下去
- 它们都可以被打断唤醒
3. 锁特性不同(重点)
- wait 方法的调用必须先获取 wait 对象的锁(即:wait必须与synchronized配合使用才行),而 sleep 则无此限制
- wait 方法执行后会释放对象锁,允许其它线程获得该对象锁(我放弃 cpu,但你们还可以用)
- 而 sleep 如果在 synchronized 代码块中执行,并不会释放对象锁(我放弃 cpu,你们也用不了)
9)如何停止一个正在运行的线程?
有三种方式可以停止线程
- 使用退出标志,使线程正常退出,也就是当run方法完成后线程终止
- 使用stop方法强行终止(不推荐,方法已作废)
- 使用interrupt方法中断线程【和第一种按照标志退出是一样的】
- 打断阻塞的线程( sleep,wait,join )的线程,会抛出InterruptedException异常
- 打断正常的线程,可以根据打断状态来标记是否退出线程
8.2 线程中的并发安全
1)synchronized关键字的底层原理
- Synchronized【对象锁】采用互斥的方式让同一时刻至多只有一个线程能持有【对象锁】
- 它的底层由monitor实现的,monitor是jvm级别的对象( C++实现),线程获得锁需要使用对象(锁)关联monitor
- 在monitor内部有三个属性,分别是owner、entrylist、waitset
- 其中owner是关联的获得锁的线程,并且只能关联一个线程;entrylist关联的是处于阻塞状态的线程;waitset关联的是处于Waiting状态的线程
synchronized关键字的底层原理-进阶
Monitor实现的锁属于重量级锁,你了解过锁升级吗?
synchronized(lock)的锁对象lock怎么关联上的Monitor?
参考答案:
2)谈谈 JMM(Java 内存模型)
- JMM(Java Memory Model)Java内存模型,定义了共享内存中多线程程序读写操作的行为规范,通过这些规则来规范对内存的读写操作从而保证指令的正确性
- JMM把内存分为两块,一块是私有线程的工作区域(工作内存),一块是所有线程的共享区域(主内存)
- 线程跟线程之间是相互隔离,线程跟线程交互需要通过主内存
3)什么是CAS
4)乐观锁和悲观锁的区别
什么是CAS?
- CAS的全称是: Compare And Swap(比较再交换);它体现的一种乐观锁的思想,在无锁状态下保证线程操作数据的原子性。
- CAS使用到的地方很多:AQS框架(AbstractQueuedSynchronizer)、AtomicXXX类
- 在操作共享变量的时候使用的自旋锁,效率上更高一些
- CAS的底层是调用的Unsafe类中的方法,都是操作系统提供的,其他语言实现
乐观锁和悲观锁的区别
- CAS 是基于乐观锁的思想:最乐观的估计,不怕别的线程来修改共享变量,就算改了也没关系,我吃亏点再重试呗。
- synchronized 是基于悲观锁的思想:最悲观的估计,得防着其它线程来修改共享变量,我上了锁你们都别想改,我改完了解开锁,你们才有机会。
5)谈谈你对 volatile 的理解
一旦一个共享变量(类的成员变量、类的静态成员变量)被volatile修饰之后,那么就具备了两层语义:
- 保证线程间的可见性:用 volatile 修饰共享变量,能够防止编译器等优化发生,让一个线程对共享变量的修改对另一个线程可见。
- 禁止进行指令重排序:用 volatile 修饰共享变量会在读、写共享变量时加入不同的屏障,阻止其他读写操作越过屏障,从而达到阻止重排序的效果
6)什么是AQS★★★★★
总结:什么是AQS?
7)ReentrantLock的实现原理★★★
ReentrantLock翻译过来是可重入锁,相对于synchronized它具备以下特点:
- 可中断
- 可以设置超时时间
- 可以设置公平锁
- 支持多个条件变量
- 与synchronized一样,都支持重入
ReentrantLock的实现原理总结:
- ReentrantLock表示支持重新进入的锁,调用 lock 方法获取了锁之后,再次调用 lock,是不会再阻塞
- ReentrantLock主要利用CAS+AQS队列来实现
- 支持公平锁和非公平锁,在提供的构造器的中无参默认是非公平锁,也可以传参设置为公平锁
8)synchronized和Lock有什么区别★★★★★
语法层面
- synchronized 是关键字,源码在 jvm 中,用 c++ 语言实现
- Lock 是接口,源码由 jdk 提供,用 java 语言实现
- 使用 synchronized 时,退出同步代码块锁会自动释放,而使用 Lock 时,需要手动调用 unlock 方法释放锁
功能层面
- 二者均属于悲观锁、都具备基本的互斥、同步、锁重入功能
- Lock 提供了许多 synchronized 不具备的功能,例如公平锁、可打断、可超时、多条件变量
- Lock 有适合不同场景的实现,如 ReentrantLock, ReentrantReadWriteLock(读写锁)
性能层面
- 在没有竞争时,synchronized 做了很多优化,如偏向锁、轻量级锁,性能不赖
- 在竞争激烈时,Lock 的实现通常会提供更好的性能
9)死锁产生的条件是什么&如何检测死锁★★★
产生条件:一个线程需要同时获取多把锁,这时就容易发生死锁。
检测死锁:
- 方法一:
- 方法二:jconsole可视化工具。用于对jvm的内存,线程,类的监控,是一个基于 jmx 的 GUI 性能监控工具;打开方式:java 安装目录 bin目录下,直接启动 jconsole.exe 就行。
- 方法三:VisualVM可视化故障处理工具。能够监控线程、内存情况、查看方法的CPU时间和内存中的对象、已被GC的对象、反向查看分配的堆栈;打开方式:java 安装目录 bin目录下 直接启动 jvisualvm.exe就行。
10)聊一下ConcurrentHashMap★★★★★
ConcurrentHashMap 是一种线程安全的高效Map集合,底层数据结构:
- JDK1.7底层采用分段的数组+链表实现
- JDK1.8 采用的数据结构跟HashMap1.8的结构一样,数组+链表/红黑二叉树。
ConcurrentHashMap总结:
底层数据结构:
- JDK1.7底层采用分段的数组+链表实现
- JDK1.8 采用的数据结构跟HashMap1.8的结构一样,数组+链表/红黑二叉树
加锁的方式:
- JDK1.7采用Segment分段锁,底层使用的是ReentrantLock
- JDK1.8采用CAS添加新节点,采用synchronized锁定链表或红黑二叉树的首节点,相对Segment分段锁粒度更细,性能更好
11)导致并发程序出现问题的根本原因是什么(Java程序中怎么保证多线程的执行安全)
Java并发编程三大特性:原子性 可见性 有序性。
- 原子性:synchronized、lock可以保证原子性;
- 可见性:volatile、synchronized、lock解决可见性问题;
- 有序性:volatile解决有序性问题。
8.3 线程池★★★★★
1)线程池的核心参数 & 线程池的执行原理
为什么要使用线程池?
①每次创建线程都会占用一定的内存空间,无限创建线程有可能会浪费内存,甚至内存溢出;②系统CPU资源有限,同一时间内一个CPU只能处理一个线程,如果创建了大量的线程,可能会造成大量线程间CPU执行权的切换,而变慢。
线程池核心参数:
执行原理:
拒绝策略:
2)线程池中有哪些常见的阻塞队列
3)如何确定核心线程数
上述设置2N+1还是N+1的原理是:
- IO密集型任务,一般不需要占用过多的CPU,它的线程核心数就相对会多分配一些。
- 而计算型任务需要大量占用CPU,所以尽量减少线程间的切换,提高效率。
4)线程池的种类有哪些
- newFixedThreadPool:创建一个定长线程池,可控制线程最大并发数,超出的线程会在队列中等待
- newSingleThreadExecutor:创建一个单线程化的线程池,它只会用唯一的工作线程来执行任 务,保证所有任务按照指定顺序(FIFO)执行
- newCachedThreadPool:创建一个可缓存线程池,如果线程池长度超过处理需要,可灵活回收空闲线程,若无可回收,则新建线程
- newScheduledThreadPool:可以执行延迟任务的线程池,支持定时及周期性任务执行
5)为什么不建议用Executors创建线程池
8.4 使用场景★★★★★
CountDownLatch、Future
CountDownLatch:
1)线程池使用场景(你们项目中哪里用到了线程池)
总结
2)如何控制某个方法允许并发访问线程的数量
信号量Semaphore总结:
3)谈谈你对ThreadLocal的理解
ThreadLocal是多线程中对于解决线程安全的一个操作类,它会为每个线程都分配一个独立的线程副本从而解决了变量并发访问冲突的问题。ThreadLocal 同时实现了线程内的资源共享。
案例:使用JDBC操作数据库时,会将每一个线程的Connection放入各自的ThreadLocal中,从而保证每个线程都在各自的 Connection 上进行数据库的操作,避免A线程关闭了B线程的连接。
ThreadLocal-内存泄露问题
总结:谈谈你对ThreadLocal的理解
- ThreadLocal 可以实现【资源对象】的线程隔离,让每个线程各用各的【资源对象】,避免争用引发的线程安全问题
- ThreadLocal 同时实现了线程内的资源共享
- 每个线程内有一个 ThreadLocalMap 类型的成员变量,用来存储资源对象
- a)调用 set 方法,就是以 ThreadLocal 自己作为 key,资源对象作为 value,放入当前线 程的 ThreadLocalMap 集合中
- b)调用 get 方法,就是以 ThreadLocal 自己作为 key,到当前线程中查找关联的资源值
- c)调用 remove 方法,就是以 ThreadLocal 自己作为 key,移除当前线程关联的资源值
- ThreadLocal内存泄漏问题:ThreadLocalMap 中的 key 是弱引用,值为强引用; key 会被GC 释放内存,关联 value 的内存并不会释放。建议主动 remove 释放 key,value
九、JVM面试篇
JVM:Java Virtual Machine,Java程序的运行环境(java二进制字节码的运行环境)。
好处: 一次编写,到处运行
自动内存管理,垃圾回收机制
从图中可以看出 JVM 的主要组成部分
-
ClassLoader(类加载器)
-
Runtime Data Area(运行时数据区,内存分区)
-
Execution Engine(执行引擎)
-
Native Method Library(本地库接口)
运行流程:
(1)类加载器(ClassLoader)把Java代码转换为字节码
(2)运行时数据区(Runtime Data Area)把字节码加载到内存中,而字节码文件只是JVM的一套指令集规范,并不能直接交给底层系统去执行,而是有执行引擎运行
(3)执行引擎(Execution Engine)将字节码翻译为底层系统指令,再交由CPU执行去执行,此时需要调用其他语言的本地库接口(Native Method Library)来实现整个程序的功能。
组成部分:堆、方法区、栈、本地方法栈、程序计数器
- 堆解决的是对象实例存储的问题,存的是对象实例、数组,垃圾回收器管理的主要区域。
- 方法区可以认为是堆的一部分,用于存储已被虚拟机加载的类信息,常量、静态变量、即时编译器编译后的代码。
- 栈解决的是程序运行的问题,栈里面存的是栈帧,栈帧里面存的是局部变量表、操作数栈、动态链接、方法出口等信息。
- 本地方法栈与栈功能相同,本地方法栈执行的是本地方法,一个Java调用非Java代码的接口。
- 程序计数器(PC寄存器)中存放的是当前线程所执行的字节码的行数。JVM工作时就是通过改变这个计数器的值来选取下一个需要执行的字节码指令。
9.1 JVM组成
1)什么是程序计数器?
程序计数器:线程私有的,内部保存的字节码的行号。用于记录正在执行的字节码指令的地址。
2)能详细的介绍Java堆吗?
Java堆总结:
在 HotSpot JVM 中,永久代( ≈ 方法区)中用于存放类和方法的元数据以及常量池,比如Class 和 Method。每当一个类初次被加载的时候,它的元数据都会放到永久代中。
永久代是有大小限制的,因此如果加载的类太多,很有可能导致永久代内存溢出,即OutOfMemoryError,为此不得不对虚拟机做调优。
那么,Java 8 中 PermGen 为什么被移出 HotSpot JVM 了?
官网给出了解释:JEP 122: Remove the Permanent Generation
This is part of the JRockit and Hotspot convergence effort. JRockit customers do not need to configure the permanent generation (since JRockit does not have a permanent generation) and are accustomed to not configuring the permanent generation. 移除永久代是为融合HotSpot JVM与 JRockit VM而做出的努力,因为JRockit没有永久代,不需要配置永久代。1)由于 PermGen 内存经常会溢出,引发OutOfMemoryError,因此 JVM 的开发者希望这一块内存可以更灵活地被管理,不要再经常出现这样的 OOM。
2)移除 PermGen 可以促进 HotSpot JVM 与 JRockit VM 的融合,因为 JRockit 没有永久代。
准确来说,Perm 区中的字符串常量池被移到了堆内存中是在 Java7 之后,Java 8 时,PermGen 被元空间代替,其他内容比如类元信息、字段、静态属性、方法、常量等都移动到元空间区。比如 java/lang/Object 类元信息、静态属性 System.out、整型常量等。
元空间的本质和永久代类似,都是对 JVM 规范中方法区的实现。不过元空间与永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存。因此,默认情况下,元空间的大小仅受本地内存限制。
3)什么是虚拟机栈
Java Virtual machine Stacks (java 虚拟机栈)
- 每个线程运行时所需要的内存,称为虚拟机栈,先进后出
- 每个栈由多个栈帧(frame)组成,对应着每次方法调用时所占用的内存
- 每个线程只能有一个活动栈帧,对应着当前正在执行的那个方法
问:垃圾回收是否涉及栈内存?
- 垃圾回收主要指就是堆内存,当栈帧弹栈以后,内存就会释放。
问:栈内存分配越大越好吗?
- 未必,默认的栈内存通常为1024k;
- 栈帧过大会导致线程数变少,例如,机器总内存为512m,目前能活动的线程数则为512个,如果把栈内存改为2048k,那么能活动的栈帧就会减半
问:方法内的局部变量是否线程安全?
- 如果方法内局部变量没有逃离方法的作用范围,它是线程安全的
- 如果是局部变量引用了对象,并逃离方法的作用范围,需要考虑线程安全
问:栈内存溢出情况有哪些?(java.lang.StackOverflowError)
- 栈帧过多导致栈内存溢出,典型问题:递归调用
- 栈帧过大导致栈内存溢出(一般不会发生)
问:堆栈的区别是什么?
- 栈内存一般会用来存储局部变量和方法调用,但堆内存是用来存储Java对象和数组的的。堆会GC垃圾回收,而栈不会。
- 栈内存是线程私有的,而堆内存是线程共有的。
- 两者异常错误不同,但如果栈内存或者堆内存不足都会抛出异常。
- 栈空间不足:java.lang.StackOverFlowError
- 堆空间不足:java.lang.OutOfMemoryError
4)能解释一下方法区?
- 方法区(Method Area)是各个线程共享的内存区域
- 主要存储类的信息、运行时常量池
- 虚拟机启动的时候创建,关闭虚拟机时释放
- 如果方法区域中的内存无法满足分配请求,则会抛出OutOfMemoryError: Metaspace
常量池 vs 运行时常量池
5)你听过直接内存吗?
直接内存:并不属于JVM中的内存结构,不由JVM进行管理。是虚拟机的系统内存,常见于 NIO 操作时,用于数据缓冲区,它分配回收成本较高,但读写性能高。
9.2 类加载器
1)什么是类加载器,类加载器有哪些
- 类加载器:用于装载字节码文件(.class文件)
- 运行时数据区:用于分配存储空间
- 执行引擎:执行字节码文件或本地方法
- 垃圾回收器:用于对JVM中的垃圾内容进行回收
JVM只会运行二进制文件,类加载器的作用就是将字节码文件加载到JVM中,从而让Java程序能够启动起来。
类加载器有哪些?
- 启动类加载器(BootStrap ClassLoader):由C++编写实现,加载JAVA_HOME/jre/lib目录下的库
- 扩展类加载器(ExtClassLoader):主要加载JAVA_HOME/jre/lib/ext目录中的类
- 应用类加载器(AppClassLoader):用于加载classPath下的类(自己编写的Java类)
- 自定义类加载器(CustomizeClassLoader):自定义类继承ClassLoader,实现自定义类加载规则。
2)什么是双亲委派模型?
加载某一个类,先委托上一级的加载器进行加载,如果上级加载器也有上级,则会继续向上委托,如果该类委托上级没有被加载,子加载器尝试加载该类。
3)JVM为什么采用双亲委派机制?
JVM为什么采用双亲委派机制?
- 通过双亲委派机制可以避免某一个类被重复加载,当父类已经加载后则无需重复加载,保证唯一性;
- 为了安全,保证类库API不会被修改
4)说一下类装载的执行过程
每个阶段做的事情:
9.3 垃圾回收
1)对象什么时候可以被垃圾器回收
问:为什么要进行垃圾回收?回收哪里的垃圾呢?
答:垃圾回收主要指的是堆中的对象,堆是一个共享的区域,存储对象和数组。如果不进行垃圾回收,内存迟早会被耗尽。
对象什么时候可以被垃圾器回收?
- 简单一句就是:如果一个或多个对象没有任何的引用指向它了,那么这个对象现在就是垃圾,如果定位了垃圾,则有可能会被垃圾回收器回收。
- 如果要定位什么是垃圾,有两种方式来确定,第一个是引用计数法,第二个是可达性分析算法
引用计数法:一个对象被引用了一次,在当前的对象头上递增一次引用次数,如果这个对象的引用次数为0,代表这个对象可回收。当对象间出现了循环引用的话,则引用计数法就会失效。
可达性分析算法:Java虚拟机中的垃圾回收器采用可达性分析来探索所有存活的对象;扫描堆中的对象,看是否能够沿着 GC Root 对象 为起点的引用链找到该对象,找不到,表示可以回收。现在的虚拟机采用的都是通过可达性分析算法来确定哪些内容是垃圾。
哪些对象可以作为 GC Root ?
2)JVM 垃圾回收算法有哪些?
- 标记清除算法:垃圾回收分为2个阶段,分别是标记和清除,效率高,有磁盘碎片,内存不连续
- 标记整理算法:标记清除算法一样,将存活对象都向内存另一端移动,然后清理边界以外的垃圾,无碎片,对象需要移动,效率低
- 复制算法:将原有的内存空间一分为二,每次只用其中的一块,正在使用的对象复制到另一个内存空间中,然后将该内存空间清空,交换两个内存的角色,完成垃圾的回收;无碎片,内存使用率低
3)说一下JVM中的分代回收★★★
一、堆的区域划分
- 堆被分为了两份:新生代和老年代【1:2】
- 对于新生代,内部又被分为了三个区域。Eden区,幸存者区survivor(分成from和to)【8:1:1】
二、对象回收分代回收策略
- 新创建的对象,都会先分配到eden区
- 当伊甸园内存不足,标记伊甸园与 from(现阶段没有)的存活对象
- 将存活对象采用复制算法复制到to中,复制完毕后,伊甸园和 from 内存都得到释放
- 经过一段时间后伊甸园的内存又出现不足,标记eden区域to区存活的对象,将其复制到from区
- 当幸存区对象熬过几次回收(最多15次),晋升到老年代(幸存区内存不足或大对象会提前晋升)
MinorGC、 Mixed GC 、 FullGC的区别是什么
- MinorGC【young GC】发生在新生代的垃圾回收,暂停时间短(STW)
- Mixed GC 新生代 + 老年代部分区域的垃圾回收,G1 收集器特有
- FullGC: 新生代 + 老年代完整垃圾回收,暂停时间长(STW),应尽力避免
4)JVM有哪些垃圾回收器★★★
- 串行垃圾收集器:Serial GC、Serial Old GC
- 并行垃圾收集器:Parallel Old GC、ParNew GC
- CMS(并发)垃圾收集器:CMS GC,作用在老年代
- G1垃圾收集器,作用在新生代和老年代
5)详细聊一下G1垃圾回收器★★★
- 应用于新生代和老年代,在JDK9之后默认使用G1
- 划分成多个区域,每个区域都可以充当 eden,survivor,old, humongous,其中 humongous 专为大对象准备
- 采用复制算法
- 响应时间与吞吐量兼顾
- 分成三个阶段:新生代回收(stw)、并发标记(重新标记stw)、混合收集
- 如果并发失败(即回收速度赶不上创建新对象速度),会触发 Full GC
三个阶段详解:
G1垃圾回收器更多内容请移步蓝云飘飘文章:JVM学习笔记(三)【4.4 G1垃圾回收器】 ,包括:
G1垃圾回收器:
- 适用场景;
- 相关JVM参数;
- 回收的三个阶段;
- Full GC概念辨析;
- 新生代回收的跨代引用(老年代引用新生代)问题;
- Remark重新标记阶段;
6)强引用、软引用、弱引用、虚对象
移步蓝云飘飘文章:JVM学习笔记(三)【四种引用】软引用和弱引用的演示。
- 强引用:只要所有 GC Roots 能找到,就不会被回收
- 软引用:需要配合SoftReference使用,当垃圾多次回收,内存依然不够的时候会回收软引用对象
- 弱引用:需要配合WeakReference使用,只要进行了垃圾回收,就会把弱引用对象回收
- 虚引用:必须配合引用队列使用,被引用对象回收时,会将虚引用入队,由 Reference Handler 线程调用虚引用相关方法释放直接内存
9.4 JVM实践
调优,移步蓝云飘飘文章:JVM学习笔记(三)【五、垃圾回收调优】
1)JVM 调优的参数可以在哪里设置
- war包部署时,在tomcat中设置: 修改TOMCAT_HOME/bin/catalina.sh文件(Linux系统中是.sh结尾的文件,Windows是.bat结尾的文件)
- jar包部署时,在启动参数设置: java -Xms512m -Xmx1024m -jar xxxx.jar
注意:IDEA中设置的都是临时参数。
2)JVM调优的参数都有哪些★★★
对于JVM调优,主要就是调整年轻代、老年代、元空间的内存空间大小及使用的垃圾回收器类型。 官网:https://www.oracle.com/java/technologies/javase/vmoptions-jsp.html
常用参数有:
- 设置堆空间大小
- 虚拟机栈的设置
- 年轻代中Eden区和两个Survivor区的大小比例
- 年轻代晋升老年代阈值
- 设置垃圾回收收集器
3)说一下JVM调优的工具
①jmap用于生成堆转存快照
jmap [options] pid 内存映像信息
jmap -heap pid 显示Java堆的信息
jmap -dump:format=b,file=heap.hprof pid
format=b表示以hprof二进制格式转储Java堆的内存
file=<filename>用于指定快照dump文件的文件名
例:显示了某一个java运行的堆信息
C:\Users\yuhon>jmap -heap 53280
Attaching to process ID 53280, please wait...
Debugger attached successfully.
Server compiler detected.
JVM version is 25.321-b07
using thread-local object allocation.
Parallel GC with 8 thread(s) //并行的垃圾回收器
Heap Configuration: //堆配置
MinHeapFreeRatio = 0 //空闲堆空间的最小百分比
MaxHeapFreeRatio = 100 //空闲堆空间的最大百分比
MaxHeapSize = 8524922880 (8130.0MB) //堆空间允许的最大值
NewSize = 178257920 (170.0MB) //新生代堆空间的默认值
MaxNewSize = 2841640960 (2710.0MB) //新生代堆空间允许的最大值
OldSize = 356515840 (340.0MB) //老年代堆空间的默认值
NewRatio = 2 //新生代与老年代的堆空间比值,表示新生代:老年代=1:2
SurvivorRatio = 8 //两个Survivor区和Eden区的堆空间比值为8,表示S0:S1:Eden=1:1:8
MetaspaceSize = 21807104 (20.796875MB) //元空间的默认值
CompressedClassSpaceSize = 1073741824 (1024.0MB) //压缩类使用空间大小
MaxMetaspaceSize = 17592186044415 MB //元空间允许的最大值
G1HeapRegionSize = 0 (0.0MB)//在使用 G1 垃圾回收算法时,JVM 会将 Heap 空间分隔为若干个 Region,该参数用来指定每个 Region 空间的大小。
Heap Usage:
PS Young Generation
Eden Space: //Eden使用情况
capacity = 134217728 (128.0MB)
used = 10737496 (10.240074157714844MB)
free = 123480232 (117.75992584228516MB)
8.000057935714722% used
From Space: //Survivor-From 使用情况
capacity = 22020096 (21.0MB)
used = 0 (0.0MB)
free = 22020096 (21.0MB)
0.0% used
To Space: //Survivor-To 使用情况
capacity = 22020096 (21.0MB)
used = 0 (0.0MB)
free = 22020096 (21.0MB)
0.0% used
PS Old Generation //老年代 使用情况
capacity = 356515840 (340.0MB)
used = 0 (0.0MB)
free = 356515840 (340.0MB)
0.0% used
3185 interned Strings occupying 261264 bytes.
②jhat(一般不推荐使用)
用于分析jmap生成的堆转存快照(一般不推荐使用,而是使用Ecplise Memory Analyzer)
③jstat
是JVM统计监测工具。可以用来显示垃圾回收信息、类加载信息、新生代统计信息等。
常见参数有以下两个:
㈠ 总结垃圾回收统计:jstat -gcutil pid
㈡ 垃圾回收统计:jstat -gc pid
④两个可视化工具
4)Java内存泄露的排查思路★★★
首先要知道哪些区域会产生内存泄漏?
- 虚拟机栈:StackOverFlowError,一般是递归造成;
- 方法区(元空间):一般是动态加载的类太多了,常见OutOfMemoryError: Metaspace
- 堆:最常见的,OutOfMemoryError: java heap space,比如一些大对象一直存活而没有被回收。
内存泄漏通常是指堆内存,通常是指一些大对象不被回收的情况。
排查步骤(思路):
1、通过jmap或设置jvm参数获取堆内存快照dump
2、通过工具, VisualVM去分析dump文件,VisualVM可以加载离线的dump文件
3、通过查看堆信息的情况,定位内存溢出问题,修改代码。
下面图片进行说明:
5)CPU飙高排查方案与思路★★★
1.使用top命令查看占用cpu的情况
2.通过top命令查看后,可以查看是哪一个进程占用cpu较高
3.使用ps命令查看进程中的线程信息
4.使用jstack命令查看进程中哪些线程出现了问题,最终定位问题
三个命令:(进程是pid,线程是tid)
- top //找到有问题的进程id
- ps H -eo pid,tid,%cpu | grep pid //执行该命令后,便找到了有问题的十进制的线程id;然后在Linux中执行 printf "%x\n" tid 命令将十进制的tid转换为十六进制的pid;
- stack pid //执行该命令后,匹配上面十六进制的线程id
十、企业场景篇
- 框架中的设计模式(mybatis、Spring)
- 项目中使用的设计模式(主要)
10.1 工厂方法模式
解决办法:工厂来生成对象!!!
1. 简单工厂模式
简单工厂不是一种设计模式,反而比较像是一种编程习惯。
咖啡店类的代码:
package com.itheima.factory.simple;
public class CoffeeStore {
public Coffee orderCoffee(String type){
//通过工厂获得对象,不需要知道对象实现的细节
SimpleCoffeeFactory factory = new SimpleCoffeeFactory();
Coffee coffee = factory.createCoffee(type);
//添加配料
coffee.addMilk();
coffee.addSuqar();
return coffee;
}
}
现在CoffeeStore类和Coffee对应的对象就没有耦合了,唯一有耦合的就只SimpleCoffeeFactory工厂类。但同时又产生了新的耦合,CoffeeStore对象和SimpleCoffeeFactory工厂对象的耦合,工厂对象和商品对象的耦合。
后期如果再加新品种的咖啡,我们势必要需求修改SimpleCoffeeFactory的代码,违反了开闭原则。工厂类的客户端可能有很多,比如创建美团外卖等,这样只需要修改工厂类的代码,省去其他的修改操作。
2. 工厂方法模式
针对上例中的缺点,使用工厂方法模式就可以完美的解决,完全遵循开闭原则。
抽象产品:
public interface Coffee {
public String getName();
public void addMilk();
public void addSuqar();
}
具体产品:
/**
* 拿铁咖啡
*/
public class LatteCoffee implements Coffee {
@Override
public String getName() {
return "latteCoffee";
}
@Override
public void addMilk() {
System.out.println("LatteCoffee...addMilk...");
}
@Override
public void addSuqar() {
System.out.println("LatteCoffee...addSuqar...");
}
}
/**
* 美式咖啡
*/
public class AmericanCoffee implements Coffee {
@Override
public String getName() {
return "americanCoffee";
}
@Override
public void addMilk() {
System.out.println("AmericanCoffee...addMilk...");
}
@Override
public void addSuqar() {
System.out.println("AmericanCoffee...addSuqar...");
}
}
抽象工厂:
public interface CoffeeFactory {
Coffee createCoffee();
}
具体工厂:
public class LatteCoffeeFactory implements CoffeeFactory {
public Coffee createCoffee() {
return new LatteCoffee();
}
}
public class AmericanCoffeeFactory implements CoffeeFactory {
public Coffee createCoffee() {
return new AmericanCoffee();
}
}
咖啡店类:(测试类,相当于具体的调用)
public class CoffeeStore {
public static void main(String[] args) {
//可以根据不同的工厂,创建不同的产品
CoffeeStore coffeeStore = new CoffeeStore(new LatteCoffeeFactory());
Coffee latte = coffeeStore.orderCoffee();
System.out.println(latte.getName());
}
private CoffeeFactory coffeeFactory;
public CoffeeStore(CoffeeFactory coffeeFactory){
this.coffeeFactory = coffeeFactory;
}
public Coffee orderCoffee(){
Coffee coffee = coffeeFactory.createCoffee();
//添加配料
coffee.addMilk();
coffee.addSuqar();
return coffee;
}
}
3. 抽象工厂模式
工厂方法模式只考虑生产同等级的产品,抽象工厂可以处理多等级产品的生产。
抽象工厂模式是工厂方法模式的升级版本,工厂方法模式只生产一个等级的产品,而抽象工厂模式可生产多个等级的产品。
4. 总结
10.2 策略模式
定义:该模式定义了一系列算法,并将每个算法封装起来,使它们可以相互替换,且算法的变化不会影响使用算法的客户。策略模式属于对象行为模式,它通过对算法进行封装,把使用算法的责任和算法的实现分割开来,并委派给不同的对象对这些算法进行管理。
抽象策略类:
package com.itheima.designpattern.strategy;
/**
* 出行策略接口
*/
public interface TravelStrategy {
/**
* 出行方式
*/
public void travel();
}
具体策略类:
/**
* 自行车策略类
*/
public class Bicycle implements TravelStrategy{
@Override
public void travel() {
System.out.println("选择自行车出行...");
}
}
/**
* 飞机策略类
*/
public class Aircraft implements TravelStrategy{
@Override
public void travel() {
System.out.println("选择飞机出行...");
}
}
/**
* 火车策略类
*/
public class Train implements TravelStrategy{
@Override
public void travel() {
System.out.println("选择火车出行...");
}
}
/**
* 汽车策略类
*/
public class Car implements TravelStrategy{
@Override
public void travel() {
System.out.println("选择汽车出行...");
}
}
(操作策略的上下文)环境类:
/**
* 策略环境类
* 用户连接上下文
*/
public class TravelContext {
private TravelStrategy travelStrategy;
public TravelContext(TravelStrategy travelStrategy){
this.travelStrategy = travelStrategy;
}
public void selectTravel(){
this.travelStrategy.travel();
}
public static void main(String[] args) {
TravelContext travelContext = new TravelContext(new Car());
travelContext.selectTravel();
}
}
23种设计模式之策略模式(问GitCode)
策略模式允许在运行时选择算法或策略,它将算法封装到独立的策略类中,使得算法可以相互替换。以下是策略模式的主要组件:
-
环境(Context): 包含了对策略对象的引用,客户端通过环境类来调用策略。
-
抽象策略(Strategy): 是一个接口或抽象类,定义了一系列算法的共同接口。
-
具体策略(ConcreteStrategy): 实现了抽象策略接口的具体算法。
应用场景:
- 当你需要在不同的时间点或根据某些条件使用不同算法时。
- 当有许多类似的算法,可以将它们作为彼此的替代品。
优点:
- 可以让算法的变化独立于使用算法的客户端。
- 提供了一种可扩展的方式,添加新的策略只需要添加新的具体策略类即可。
缺点:
- 客户端需要了解所有的策略类。
- 大量的策略类可能导致数量过多。
注意事项:
- 要正确识别哪些部分会变化,哪些部分是稳定的。
现在,让我们基于这些概念创建一个简单的示例。假设我们有一个DiscountCalculator
(环境类),它可以根据不同的策略计算折扣,比如HighVolumeDiscount
和LowVolumeDiscount
(具体策略类)。
// 抽象策略
public interface DiscountStrategy {
double calculateDiscount(double price, int quantity);
}
// 具体策略
class HighVolumeDiscount implements DiscountStrategy {
@Override
public double calculateDiscount(double price, int quantity) {
return quantity > 100 ? price * 0.8 : price;
}
}
class LowVolumeDiscount implements DiscountStrategy {
@Override
public double calculateDiscount(double price, int quantity) {
return quantity >= 5 && quantity <= 10 ? price * 0.1 : price;
}
}
// 环境
public class DiscountCalculator {
private DiscountStrategy discountStrategy;
public DiscountCalculator(DiscountStrategy strategy) {
this.discountStrategy = strategy;
}
public double calculateTotalPrice(double price, int quantity) {
double discount = discountStrategy.calculateDiscount(price, quantity);
return discount + (price - discount);
}
}
使用上述策略模式的例子,我们可以轻松地根据业务需求更改打折策略。
10.3 登录案例(工厂模式+策略模式)
一、首先用普通if-else的思路完成业务
这种方式没有设计模式哦。先看项目结构:
model包下dto、vo的代码如下:
package com.itheima.model.dto;
import lombok.Data;
@Data
public class LoginReq {
private String name;
private String password;
private String phone;
private String validateCode;//手机验证码
private String wxCode;//用于微信登录
/**
* account : 用户名密码登录
* sms : 手机验证码登录
* we_chat : 微信登录
*/
private String type;
}
package com.itheima.model.vo;
import lombok.Data;
@Data
public class LoginResp {
private Integer userId;
private String userName;
private String roleCode;
private String token; //jwt令牌
private boolean success;
}
抽象登录方式接口UserGranter:
/**
* 抽象策略类
*/
public interface UserGranter {
/**
* 获取数据
*
* @param loginReq 传入的参数
* 0:账号密码 1:短信验证 2:微信授权
* @return map值
*/
LoginResp login(LoginReq loginReq);
}
三个具体登录方式(账号、微信、短信)的类的代码:
注意:这三个类上不需要加@Component注解哦,等改造的时候再放开。
/**
* 策略:账号登录
*/
//@Component
public class AccountGranter implements UserGranter {
@Override
public LoginResp login(LoginReq loginReq) {
System.out.println("策略:登录方式为账号登录");
// TODO
// 执行业务操作
return new LoginResp();
}
}
/**
* 策略:短信登录
*/
//@Component
public class SmsGranter implements UserGranter {
@Override
public LoginResp login(LoginReq loginReq) {
System.out.println("策略:登录方式为短信登录");
// TODO
// 执行业务操作
return new LoginResp();
}
}
/**
* 策略:微信登录
*/
//@Component
public class WeChatGranter implements UserGranter {
@Override
public LoginResp login(LoginReq loginReq) {
System.out.println("策略:登录方式为微信登录");
// TODO
// 执行业务操作
return new LoginResp();
}
}
SpringBoot启动类:
@SpringBootApplication
public class UserApplication {
public static void main(String[] args) {
SpringApplication.run(UserApplication.class,args);
}
}
controller:
import com.itheima.model.dto.LoginReq;
import com.itheima.model.vo.LoginResp;
import com.itheima.service.UserService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/user")
@Slf4j
public class LoginController {
@Autowired
private UserService userService;
//http://localhost:8082/api/user/login?type=sms
@PostMapping("/login")
public LoginResp login(@RequestBody LoginReq loginReq) throws InterruptedException {
return userService.login(loginReq);
}
}
Service代码:
import com.itheima.model.dto.LoginReq;
import com.itheima.model.vo.LoginResp;
import org.springframework.stereotype.Service;
@Service
public class UserService {
public LoginResp login(LoginReq loginReq) {
//假如现在新增了其他登录方式,如QQ登录,需要新增else-if逻辑,改动了Service的代码,违反了OCP原则,不太好。
if(loginReq.getType().equals("account")){
System.out.println("用户名密码登录");
//执行用户密码登录逻辑
return new LoginResp();
}else if(loginReq.getType().equals("sms")){
System.out.println("手机号验证码登录");
//执行手机号验证码登录逻辑
return new LoginResp();
}else if (loginReq.getType().equals("we_chat")){
System.out.println("微信登录");
//执行用户微信登录逻辑
return new LoginResp();
}
LoginResp loginResp = new LoginResp();
loginResp.setSuccess(false);
System.out.println("不存在的登录方式哦");
return loginResp;
}
}
到此,启动项目,在postman中测试,没问题;去IDEA控制台,看到有“微信登录”,“手机号验证码登录”,“用户名密码登录”字样,说明功能已经实现。
但问题是:假如现在新增了其他登录方式,如QQ登录,需要新增else-if逻辑,改动了Service的代码,违反了OCP原则,不太好。
二、使用工厂模式+策略模式改造
代码结构:
代码改动说明:
除了在AccountGranter、SmsGranter、WeChatGranter这三个类上面加@Component注解以外(目的是让Spring管理),改动的还有:
- 配置文件application.yml :增加的内容见上图
- 添加了配置类LoginTypeConfig:代码见上图
操作策略的上下文环境类(工具类)UserLoginFactory:将策略整合了起来,代码见下方;
业务代码UserService:代码见下方;
import com.itheima.config.LoginTypeConfig;
import org.springframework.beans.BeansException;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationContextAware;
import org.springframework.stereotype.Component;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* 操作策略的上下文环境类 工具类
* 将策略整合起来 方便管理
*/
@Component
public class UserLoginFactory implements ApplicationContextAware {
private static Map<String, UserGranter> granterPool = new ConcurrentHashMap<>();
@Autowired
private LoginTypeConfig loginTypeConfig;
/**
* 从配置文件中读取策略信息存储到map中
* { account:accountGranter, sms:smsGranter, we_chat:weChatGranter }
*/
@Override
public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
loginTypeConfig.getTypes().forEach((k, y) -> {
granterPool.put(k, (UserGranter) applicationContext.getBean(y));
});
}
/**
* 对外提供获取具体策略
*
* @param grantType 用户的登录方式,需要跟配置文件中匹配
* @return 具体策略
*/
public UserGranter getGranter(String grantType) {
if (!granterPool.containsKey(grantType)) {
throw new RuntimeException("不存在的登录方式");
}
UserGranter tokenGranter = granterPool.get(grantType);
return tokenGranter;
}
}
@Service
public class UserService {
@Autowired
private UserLoginFactory factory;
public LoginResp login(LoginReq loginReq) {
UserGranter granter = factory.getGranter(loginReq.getType());
LoginResp loginResp = granter.login(loginReq);
loginResp.setSuccess(true);
return loginResp;
}
}
至此,就全部OK了。只需要用postman登录的时候,是传{"type":"sms"}、{"type":"we_chat"}、{"type":"account"}中的哪一个来自动选择登录方式进行登录。
如果再增加一种方式时,如果QQ登录,那么业务层代码都不需要改动了,符合OCP原则。只需要①增加一个具体的登录策略类QqGranter,②然后在application.yml配置文件中增加一个配置即可,具体如下:
@Component //交给Spring容器管理
public class QqGranter implements UserGranter {
@Override
public LoginResp login(LoginReq loginReq) {
System.out.println("策略:登录方式为QQ");
return new LoginResp();
}
}
总结:
10.4 责任链设计模式
代码
抽象处理者:
package com.itheima.designpattern.chain;
/**
* 抽象处理者
*/
public abstract class Handler {
protected Handler handler;
public void setNext(Handler handler) {
this.handler = handler;
}
/**
* 处理过程
* 需要子类进行实现
*/
public abstract void process(OrderInfo order);
}
订单信息类:
package com.itheima.designpattern.chain;
import java.math.BigDecimal;
public class OrderInfo {
private String productId;
private String userId;
private BigDecimal amount;
public String getProductId() {
return productId;
}
public void setProductId(String productId) {
this.productId = productId;
}
public String getUserId() {
return userId;
}
public void setUserId(String userId) {
this.userId = userId;
}
public BigDecimal getAmount() {
return amount;
}
public void setAmount(BigDecimal amount) {
this.amount = amount;
}
}
具体处理者:
/**
* 订单校验
*/
public class OrderValidition extends Handler {
@Override
public void process(OrderInfo order) {
System.out.println("校验订单基本信息");
//校验
handler.process(order);
}
}
/**
* 补充订单信息
*/
public class OrderFill extends Handler {
@Override
public void process(OrderInfo order) {
System.out.println("补充订单信息");
handler.process(order);
}
}
/**
* 计算金额
*/
public class OrderAmountCalcuate extends Handler {
@Override
public void process(OrderInfo order) {
System.out.println("计算金额-优惠券、VIP、活动打折");
handler.process(order);
}
}
/**
* 订单入库
*/
public class OrderCreate extends Handler {
@Override
public void process(OrderInfo order) {
System.out.println("订单入库");
}
}
客户类:
public class Application {
public static void main(String[] args) {
//检验订单
Handler orderValidition = new OrderValidition();
//补充订单信息
Handler orderFill = new OrderFill();
//订单算价
Handler orderAmountCalcuate = new OrderAmountCalcuate();
//订单落库
Handler orderCreate = new OrderCreate();
//设置责任链路
orderValidition.setNext(orderFill);
orderFill.setNext(orderAmountCalcuate);
orderAmountCalcuate.setNext(orderCreate);
//开始执行
orderValidition.process(new OrderInfo());
}
}
责任链设计模式(问GitCode)
责任链设计模式(Chain of Responsibility)是一种行为设计模式,主要用于在对象之间分发请求,避免请求发送者与接收者之间的耦合。在责任链模式中,请求沿着处理者链进行传递,每个处理者都有机会处理请求,或者将请求传递给下一个处理者。
核心组件:
- 抽象处理者(Abstract Handler): 定义一个接口,处理请求,并提供一个向下一个处理者传递请求的方法。
- 具体处理者(Concrete Handlers): 实现抽象处理者的接口,负责处理特定类型的请求。每个处理者可以决定是否处理请求,或者将其传递给下一个处理者。
示例: 以公司采购审批为例,假设存在以下几个角色:
- 经理(Manager)
- 副总(VicePresident)
- 总裁(President)
- 会议(Meeting)
实现步骤:
- 创建
PurchaseRequest
类,表示采购申请,包含金额和产品名称。 - 创建一个抽象处理者类
Approver
,包含姓名和一个指向下一个处理者的引用,定义一个处理请求的方法。 - 实现具体的处理者类,例如
Manager
、VicePresident
、President
,覆盖处理请求的方法,根据请求金额决定是否批准,如果不批准则将请求传递给下一个处理者。 - 应用程序客户端构建处理者链,根据需求设置合适的顺序。
优缺点:
- 优点:
- 分离了请求和接收者,减少了类之间的直接交互。
- 支持动态指定处理请求的对象,增加了灵活性。
- 允许处理者不处理某些请求,提高代码可维护性。
- 缺点:
- 请求可能得不到处理,导致系统功能缺失。
- 处理请求的链条过长会影响效率。
- 系统中可能因不当配置职责链导致错误。
实现责任链模式的步骤:
1)定义一个抽象处理类(Handler),包括处理请求的方法(如
handleRequest()
)和指向下一个处理者的引用。public abstract class Handler { protected Handler next; public void setNext(Handler handler) { this.next = handler; } public abstract void handleRequest(Request request); }
2)创建具体处理类(ConcreteHandler),实现
handleRequest()
方法,处理请求或传递到下个处理者。public class ConcreteHandler1 extends Handler { @Override public void handleRequest(Request request) { if (request.getRequestType().equals("type1")) { // 处理请求 System.out.println("ConcreteHandler1 processed the request."); } else if (next != null) { next.handleRequest(request); } } }
3)初始化处理者链并发送请求。
public class Client { public static void main(String[] args) { Handler handler1 = new ConcreteHandler1(); Handler handler2 = new ConcreteHandler2(); Handler handler3 = new ConcreteHandler3(); handler1.setNext(handler2); handler2.setNext(handler3); Request request = new Request("type1"); handler1.handleRequest(request); // 这里会依次经过handler1, handler2, 和 handler3 } }
10.5 单点登录如何实现
多系统即可能有多个Tomcat,而Session是依赖当前系统的Tomcat,所以系统A的Session和系统B的Session是不共享的。解决系统之间Session不共享问题有一下几种方案:
Tomcat集群Session全局复制(最多支持5台tomcat,不推荐使用)
JWT(常见)
Oauth2
CAS
自己实现(redis+token)
10.6 权限认证是如何实现的
后台的管理系统,更注重权限控制,最常见的就是RBAC模型来指导实现权限
RBAC(Role-Based Access Control)基于角色的访问控制
-
3个基础部分组成:用户、角色、权限
-
具体实现
-
5张表(用户表、角色表、权限表、用户角色中间表、角色权限中间表)
-
7张表(用户表、角色表、权限表、菜单表、用户角色中间表、角色权限中间表、权限菜单中间表)
-
10.7 上传数据的安全性你们怎么控制
这里的安全性,主要说的是,浏览器访问后台,需要经过网络传输,有可能会出现安全的问题。
解决方案:使用非对称加密(或对称加密),给前端一个公钥让他把数据加密后传到后台,后台负责解密后处理数据。
10.8 遇到过哪些比较棘手的问题?怎么解决的★★★★★
问到的频率非常高,一是考察你是否只是CRUD的程序员,二是看你是否真的具有项目经验。需要提前准备,挑以下四大块中的一个好好准备即可。
10.9 你们项目中日志怎么采集的
10.10 查看日志的命令
有了ELK便可以进行可视化查看日志,但作为一名中高级程序员应具有Linux查看日志的能力。
需要掌握的Linux中的日志命令:
1)实时监控日志的变化
- 实时监控某一个日志文件的变化:tail -f xx.log;
- 实时监控日志最后100行日志: tail –n 100 -f xx.log
2)按照行号查询
查询日志尾部最后100行日志:tail – n 100 xx.log
查询日志头部开始100行日志:head –n 100 xx.log
查询某一个日志行号区间:cat -n xx.log | tail -n +100 | head -n 100 (查询100行至200行的日志)
3)按照关键字找日志的信息
查询日志文件中包含debug的日志行号:cat -n xx.log | grep "debug"
4)按照日期查询
sed -n '/2023-05-18 14:22:31.070/,/ 2023-05-18 14:27:14.158/p’xx.log
5)日志太多,处理方式
- 分页查询日志信息:cat -n xx.log |grep "debug" | more
- 筛选过滤以后,输出到一个文件:cat -n xx.log | grep "debug" >debug.txt
10.11 生产问题怎么排查
已经上线的bug排查的思路:
- 分析日志,通常在业务中都会有日志的记录,或者查看系统日志,或者查看日志文件,然后定位问题。
- 远程debug(通常公司的生产环境是不允许远程debug的,一般远程debug都是公司的测试环境,方便调试代码)。
远程debug配置
前提条件:远程的代码和本地的代码要保持一致。
1. 远程代码需要配置启动参数,把项目打包放到服务器后启动项目的参数:
java -jar -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=5005 project-1.0-SNAPSHOT.jar
-agentlib:jdwp 是通知JVM使用(java debug wire protocol)来运行调试环境
transport=dt_socket 调试数据的传送方式
server=y 参数是指是否支持在server模式
suspend=n 是否在调试客户端建立起来后,再执行JVM。
address=5005 调试端口设置为5005,其它端口也可以
2. idea中设置远程debug,找到idea中的 Edit Configurations...
3. idea中启动远程debug
4. 访问远程服务器,在本地代码中打断点即可调试远程。
10.12 怎么快速定位系统的瓶颈
10.13 总结地图★★★
面试专题总结地图:
波波老师推荐课程路线: