一、常见集合篇
1. 为什么数组索引从0开始呢?假如从1开始不行咩
数组(Array):一种用连续的内存空间存储相同数据类型数据的线性数据结构
(1)在根据数组索引获取元素的时候,会用索引和寻址公式来计算内存所对应的元素数据,寻址公式是:数组的首地址+索引乘以存储数据的类型大小
(2)如果数组的索引从1开始,寻址公式中,就需要增加一次减法操作,对于CPU来说就多了一次指令,性能不高。
复杂度
随机(通过下标)查询的时间复杂度是O(1)
查找元素(未知下标)的时间复杂度是O(n)
查找元素(未知下标但排序)通过二分查找的时间复杂度是O(logn)
插入和删除的时候,为了保证数组的内存连续性,需要挪动数组元素,平均时间复杂度为O(n)
2. ArrayList源码分析
ArrayList底层是用动态的数组实现的
- 初始容量
ArrayList初始容量为0,当第一次添加数据的时候才会初始化容量为10
- 扩容逻辑
ArrayList在进行扩容的时候是原来容量的1.5倍,每次扩容都需要拷贝数组
- 添加逻辑
- 确保数组已使用长度(size)加1之后足够存下下一个数据
- 计算数组的容量,如果当前数组已使用长度+1后的大于当前的数组长度,则调用grow方法扩容(原来的1.5倍)
- 确保新增的数据有地方存储之后,则将新元素添加到位于size的位置上。
- 返回添加成功布尔值。
3. ArrayList list=new ArrayList(10)中的list扩容几次
该语句只是声明和实例了一个 ArrayList,指定了容量为 10,未扩容
4. 如何实现数组和List之间的转换
- 数组转List ,使用JDK中java.util.Arrays工具类的asList方法
- List转数组,使用List的toArray方法。无参toArray方法返回 Object数组,传入初始化长度的数组对象,返回该对象数组
用Arrays.asList转List后,如果修改了数组内容,list受影响吗
会受影响
当你使用 Arrays.asList
方法将数组转换为 List
时,Arrays.asList
返回的 List
实际上是 Arrays
类的一个内部类 ArrayList
的实例。这个内部类并没有创建一个新的、独立的列表来存储数组元素,而是直接对传入的数组进行了包装。也就是说,这个 List
内部维护的是对原始数组的引用,它们指向的是同一块内存地址。
List用toArray转数组后,如果修改了List内容,数组受影响吗
- 使用无参的
toArray
方法:这种情况下,返回的是一个Object
类型的数组。toArray
方法会创建一个新的数组,并将List
中的元素复制到这个新数组中。但是,这里复制的是元素的引用(对于引用类型),而不是元素本身。所以,如果List
中的元素是可变对象,修改List
中对象的属性会影响到数组中的对应对象;如果只是改变List
中元素的引用(比如替换某个元素),则不会影响数组。 - 当传入一个指定类型的数组时,如果该数组长度足够,
List
元素会直接填充到这个数组中;如果长度不够,会创建一个新的同类型数组。同样,对于引用类型的元素,复制的是引用,修改元素属性会相互影响,修改引用则不会。
5. ArrayList和LinkedList的区别是什么?
- 底层数据结构
- ArrayList 是动态数组的数据结构实现
- LinkedList 是双向链表的数据结构实现
- 操作数据效率
- ArrayList按照下标查询的时间复杂度O(1)【内存是连续的,根据寻址公式】, LinkedList不支持下标查询
- 查找(未知索引): ArrayList需要遍历,链表也需要遍历,时间复杂度都是O(n)
- 新增和删除
- ArrayList尾部插入和删除,时间复杂度是O(1);其他部分增删需要挪动数组,时间复杂度是O(n)
- LinkedList头尾节点增删时间复杂度是O(1),其他都需要遍历链表,时间复杂度是O(n)
- 内存空间占用
- ArrayList底层是数组,内存连续,节省内存
- LinkedList 是双向链表需要存储数据,和两个指针,更占用内存
- 线程安全
- ArrayList和LinkedList都不是线程安全的
- 如果需要保证线程安全,有两种方案:
- 在方法内使用,由于每个线程都有自己独立的栈空间,局部变量在不同线程之间是隔离的,因此是线程安全的。
- 使用线程安全的替代类:可以使用线程安全的
ArrayList
和LinkedList
替代类,例如Vector
(类似于线程安全的ArrayList
),或者使用Collections.synchronizedList
方法将ArrayList
或LinkedList
包装成线程安全的列表。
6. 二叉树
每个节点最多有两个“叉”,也就是两个子节点,分别是左子节点和右子节点。不过,二叉树并不要求每个节点都有两个子节点,有的节点只有左子节点,有的节点只有右子节点。
二叉树每个节点的左子树和右子树也分别满足二叉树的定义。
二叉搜索树
二叉搜索树(Binary Search Tree,BST)又名二叉查找树,有序二叉树或者排序二叉树,是二叉树中比较常用的一种类型
二叉查找树要求,在树中的任意一个节点,其左子树中的每个节点的值,都要小于这个节点的值,而右子树节点的值都大于这个节点的值
插入,查找,删除的时间复杂度O(logn),极端情况下二叉查找树已经退化成了链表,二叉搜索的时间复杂度O(n)
红黑树
(1)红黑树的特质
性质1:节点要么是红色,要么是黑色
性质2:根节点是黑色
性质3:叶子节点都是黑色的空节点
性质4:红黑树中红色节点的子节点都是黑色
性质5:从任一节点到叶子节点的所有路径都包含相同数目的黑色节点
在添加或删除节点的时候,如果不符合这些性质会发生旋转,以达到所有的性质,保证红黑树的平衡
(2)红黑树的复杂度
- 查找:
- 红黑树也是一棵BST(二叉搜索树)树,查找操作的时间复杂度为:O(log n)
- 添加:
- 添加先要从根节点开始找到元素添加的位置,时间复杂度O(log n)
- 添加完成后涉及到复杂度为O(1)的旋转调整操作
- 故整体复杂度为:O(log n)
- 删除:
- 首先从根节点开始找到被删除元素的位置,时间复杂度O(log n)
- 删除完成后涉及到复杂度为O(1)的旋转调整操作
- 故整体复杂度为:O(log n)
7. 散列表
根据键(Key)直接访问在内存存储位置值(Value)的数据结构,它是由数组演化而来的,利用了数组支持按照下标进行随机访问数据的特性。
散列函数
将键(key)映射为数组下标的函数叫做散列函数。可以表示为:hashValue = hash(key)
散列函数的基本要求
- 散列函数计算得到的散列值必须是大于等于0的正整数,因为hashValue需要作为数组的下标。
- 如果key1==key2,那么经过hash后得到的哈希值也必相同即:hash(key1) == hash(key2)
- 如果key1 != key2,那么经过hash后得到的哈希值也必不相同即:hash(key1) != hash(key2)
散列冲突
或者哈希冲突,哈希碰撞,指多个key映射到同一个数组下标位置
散列冲突-链表法(拉链)
在散列表中,数组的每个下标位置我们可以称之为桶(bucket)或者槽(slot),每个桶(槽)会对应一条链表,所有散列值相同的元素我们都放到相同槽位对应的链表中。
简单就是,如果有多个key最终的hash值是一样的,就会存入数组的同一个下标中,下标中挂一个链表存入多个数据
时间复杂度
平均情况下基于链表法解决冲突时查询的时间复杂度是O(1)
散列表可能会退化为链表,查询的时间复杂度就从 O(1) 退化为 O(n)
将链表法中的链表改造为其他高效的动态数据结构,比如红黑树,查询的时间复杂度是 O(logn)
将链表法中的链表改造红黑树还有一个非常重要的原因,可以防止DDos攻击
DDos 攻击:
分布式拒绝服务攻击(英文意思是Distributed Denial of Service,简称DDoS)
指处于不同位置的多个攻击者同时向一个或数个目标发动攻击,或者一个攻击者控制了位于不同位置的多台机器并利用这些机器对受害者同时实施攻击。由于攻击的发出点是分布在不同地方的,这类攻击称为分布式拒绝服务攻击,其中的攻击者可以有多个
8. 说一下HashMap的实现原理?
HashMap的数据结构: 底层使用hash表数据结构,即数组和链表或红黑树
- 当我们往HashMap中put元素时,利用key的hashCode重新hash计算出当前对象的元素在数组中的下标
- 存储时,如果出现hash值相同的key,此时有两种情况。
a. 如果key相同,则覆盖原始值;
b. 如果key不同(出现冲突),则将当前的key-value放入链表或红黑树中
- 获取时,直接找到hash值对应的下标,在进一步判断key是否相同,从而找到对应值。
HashMap
底层采用哈希表,结合了数组、链表和红黑树。数组是主体结构,每个数组元素是一个桶。当发生哈希冲突时,会用链表或红黑树来存储冲突元素。正常情况下用链表存储,若链表长度超过 8 且数组长度大于 64,链表就会转成红黑树,因为链表过长时,查找元素的时间复杂度会变成 O (n),而红黑树能将查找、插入和删除操作的时间复杂度控制在 O (log n)。当红黑树节点数量少于 6 时,又会转回链表。以此提升查找效率。
9. HashMap的jdk1.7和jdk1.8有什么区别
- JDK1.8之前采用的是拉链法。拉链法:将链表和数组相结合。也就是说创建一个链表数组,数组中每一格就是一个链表。若遇到哈希冲突,则将冲突的值加到链表中即可。
- jdk1.8在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为8) 时并且数组长度达到64时,将链表转化为红黑树,以减少搜索时间。扩容 resize( ) 时,红黑树拆分成的树的结点数小于等于临界值6个,则退化成链表
10. HashMap的put方法的具体流程
-
判断键值对数组table是否为空或为null,否则执行resize()进行扩容(初始化)
-
根据键值key计算hash值得到数组索引
-
判断table[i]==null,条件成立,直接新建节点添加
-
如果table[i]==null ,不成立
4.1 判断table[i]的首个元素是否和key一样,如果相同直接覆盖value
4.2 判断table[i] 是否为treeNode,即table[i] 是否是红黑树,如果是红黑树,则直接在树中插入键值对
4.3 遍历table[i],链表的尾部插入数据,然后判断链表长度是否大于8,大于8的话把链表转换为红黑树,在红黑树中执行插入操 作,遍历过程中若发现key已经存在直接覆盖value
-
插入成功后,判断实际存在的键值对数量size是否超多了最大容量threshold(数组长度*0.75),如果超过,进行扩容。
11. 讲一讲HashMap的扩容机制
HashMap
的扩容机制是为了保证其性能,避免哈希冲突过多导致查询和插入效率下降。当元素数量达到扩容阈值(数组长度 * 负载因子,默认负载因子是 0.75)时,就会触发扩容操作。
- 在添加元素或初始化的时候需要调用resize方法进行扩容,第一次添加数据初始化数组长度为16,以后每次每次扩容都是达到了扩容阈值(数组长度 * 0.75)
- 每次扩容的时候,都是扩容之前容量的2倍;
- 扩容之后,会新创建一个数组,需要把老数组中的数据挪动到新的数组中
- 无哈希冲突的节点:则直接使用 e.hash & (newCap - 1) 计算新数组的索引位置
- 红黑树节点:按照红黑树的规则将节点添加到新数组对应的位置。红黑树是自平衡二叉搜索树,添加节点时会进行旋转和变色等操作来保持平衡。
- **链表节点:**需要遍历链表,可能会拆分链表。通过e.hash & oldCap判断元素在新数组中的位置:
- 若结果为 0,说明该元素在新数组中的索引位置和原数组相同。因为扩容后多考虑的那一位二进制为 0,对索引计算结果无影响。
- 若结果不为 0,该元素在新数组中的索引位置是原数组中的索引位置加上
oldCap
。这是因为扩容后多考虑的那一位二进制为 1,使得索引结果增加了原数组的长度。
12. hashMap的寻址算法
首先获取key的hashCode值,然后z再调用hash()方法进行二次哈希,右移16位 异或运算 原来的hashCode值,主要作用就是使原来的hash值更加均匀,减少hash冲突
最后(capacity-1)& hash得到索引
13. 关于hash值的其他面试题:为何HashMap的数组长度一定是2的次幂?
- 计算索引时效率更高:如果是 2 的 n 次幂可以使用位与运算代替取模
- 扩容时重新计算索引效率更高: hash & oldCap == 0 的元素留在原来位置 ,否则新位置 = 旧位置 + oldCap
14. hashmap在1.7情况下的多线程死循环问题
在 JDK 1.7 里,HashMap
扩容时把旧数组的数据迁移到新数组,链表采用的是头插法。
假设有两个线程同时操作 HashMap
的扩容。
- 线程一读取数据准备扩容:线程一读取当前
HashMap
的数据,发现有一个链表,准备对数组进行扩容。可就在这时候,线程二介入了。 - 线程二完成扩容:线程二也读取了
HashMap
的数据,然后直接开始扩容。因为用的是头插法,扩容后链表的顺序就颠倒了。打个比方,原来链表顺序是 A -> B,扩容后就变成 B -> A 了,之后线程二执行结束。 - 线程一继续扩容引发死循环:线程一接着干活,先把 A 移到新链表,再把 B 插到新链表的头部。但由于线程二已经把链表顺序颠倒了,B 的
next
指针指向了 A。这样一来,新链表就变成 B -> A -> B,形成了一个循环。之后要是有线程去访问这个链表,就会一直在这个循环里出不来,也就是出现了死循环问题。
JDK 8 对扩容算法做了调整,链表数据迁移时用的是尾插法,就是新元素会被插到链表的尾部。这样扩容后链表元素的顺序和扩容前是一样的,就不会出现因为链表顺序颠倒而导致的死循环问题了。
总的来说,JDK 1.7 的 HashMap
在多线程扩容时,头插法可能会让链表顺序错乱,进而引发死循环;而 JDK 8 采用尾插法,避免了这个问题,提升了多线程环境下的稳定性。
关于 HashMap
、HashTable
、ConcurrentHashMap
、TreeMap
和 HashSet
1. 基本概念
HashMap
:基于哈希表实现的Map
接口,允许null
键和null
值,非线程安全,适用于单线程环境下快速查找和存储键值对。HashTable
:古老的Map
实现类,与HashMap
类似,但不允许null
键和null
值,并且是线程安全的(方法都被synchronized
修饰),不过性能相对HashMap
较差,现在已不常用。ConcurrentHashMap
:线程安全的Map
实现类,采用分段锁(锁分段技术)提高并发性能,允许多个线程同时操作不同的段,适用于高并发环境下的键值对存储和访问。TreeMap
:实现了SortedMap
接口,基于红黑树实现,会对键进行排序(默认升序,也可自定义比较器),不允许null
键,值可以为null
,非线程安全,适合需要对键进行有序操作的场景。HashSet
:实现了Set
接口,基于HashMap
实现(元素存储在HashMap
的键中,值为一个默认的Object
对象),不允许重复元素,允许null
元素,非线程安全,用于存储不重复的元素集合。
2. 线程安全性
HashMap
和HashSet
:非线程安全,在多线程环境下可能会出现数据不一致等问题。HashTable
:线程安全,通过synchronized
保证同一时间只有一个线程能访问其方法。ConcurrentHashMap
:线程安全,采用分段锁技术,提高并发访问性能,允许多个线程同时访问不同的段。TreeMap
:非线程安全,在多线程环境下若有多个线程同时修改,可能会导致数据混乱。
3. 性能特点
- 查找、插入和删除操作
HashMap
和HashSet
:在理想情况下,基于哈希表的操作时间复杂度为 O(1),但在哈希冲突严重时性能会下降。HashTable
:由于线程安全的实现方式(synchronized
),在单线程环境下性能比HashMap
差,操作时间复杂度与HashMap
类似,但在多线程并发时会有锁竞争。ConcurrentHashMap
:并发性能较好,在高并发环境下,由于分段锁机制,不同段的操作可以并行进行,总体性能优于HashTable
。TreeMap
:基于红黑树,查找、插入和删除操作的时间复杂度为 O(logn),因为需要维护树的平衡和排序。
4. 键和值的允许情况
HashMap
:键和值都可以为null
,但键的null
只能有一个。HashTable
:键和值都不允许为null
,否则会抛出NullPointerException
。ConcurrentHashMap
:键和值都不允许为null
,否则会在多线程环境下导致不确定行为。TreeMap
:键不允许为null
(因为需要进行比较和排序),值可以为null
。HashSet
:允许一个null
元素,不允许重复元素。
5. 排序功能
HashMap
、HashTable
和HashSet
:不具备排序功能,元素的存储顺序与插入顺序无关(HashMap
在 JDK 8 后引入了链表转红黑树机制,在一定程度上会影响元素顺序,但不是真正的排序)。TreeMap
:实现了SortedMap
接口,会根据键的自然顺序或自定义比较器对键进行排序。
6. 应用场景
HashMap
和HashSet
:适用于单线程环境下,对元素顺序无要求,只需要快速查找和存储不重复元素的场景,如缓存、数据统计等。HashTable
:由于性能问题,现在很少使用,仅在一些遗留代码或对线程安全有严格要求且性能要求不高的场景中可能会用到。ConcurrentHashMap
:适用于高并发环境下,需要线程安全的Map
操作,如多线程的计数器、缓存等。TreeMap
:适用于需要对键进行排序,并且按照顺序遍历或查找元素的场景,如按照时间顺序存储和查询日志记录等。
15. HashSet与HashMap的区别
(1)HashSet实现了Set接口, 仅存储对象; HashMap实现了 Map接口, 存储的是键值对.
(2)HashSet底层其实是用HashMap实现存储的, HashSet封装了一系列HashMap的方法. 依靠HashMap来存储元素值,(利用hashMap的key键进行存储), 而value值默认为Object对象. 所以HashSet也不允许出现重复值, 判断标准和HashMap判断标准相同, 两个元素的hashCode相等并且通过equals()方法返回true.
16. HashTable与HashMap的区别
嗯,他们的主要区别是有几个吧
第一,数据结构不一样,hashtable是数组+链表,hashmap在1.8之后改为了数组+链表+红黑树
第二,hashtable存储数据的时候都不能为null,而hashmap是可以的
第三,hash算法不同,hashtable是用本地修饰的hashcode值,而hashmap进行了二次hash
第四,扩容方式不同,hashtable是当前容量翻倍+1,hashmap是当前容量翻倍
第五,hashtable是线程安全的,操作数据的时候加了锁synchronized,hashmap不是线程安全的,效率更高一些
在实际开中不建议使用HashTable,在多线程环境下可以使用ConcurrentHashMap类
17. TreeMap与HashMap的区别
HashMap
和 TreeMap
都继承自 AbstractMap
。但 TreeMap
还实现了 NavigableMap
和 SortedMap
接口,这是它与 HashMap
的主要区别。
TreeMap
实现 NavigableMap
接口后:
- 能定向搜索,像
ceilingEntry()
找大于等于键的最近元素,floorEntry()
找小于等于键的最近元素等。 - 可进行子集操作,如
submap()
、headMap()
、tailMap()
来获取子集视图,不复制全集合。 - 有逆序视图,
descendingMap()
能反向迭代集合。 - 方便边界操作,
firstEntry()
取首元素,lastEntry()
取尾元素,pollFirstEntry()
和pollLastEntry()
取并移除首、尾元素。 这些基于红黑树实现,搜索时间复杂度 O(logn)。
实现 SortedMap
接口后,TreeMap
能按键排序,默认升序,也能自定义比较器。
而 HashMap
基于哈希表,侧重快速键值对操作,无上述顺序相关功能,理想时操作时间复杂度 O(1)。
18. ConcurrentHashMap 与 Hashtable 的区别
- 线程安全实现方式
- Hashtable:
- 实现方式:使用
synchronized
方法来保证线程安全。 - 特点:所有操作(如
put
、get
)都是同步的,这意味着同一时间只有一个线程可以访问整个哈希表。 - 缺点:效率较低,尤其是在高并发场景下,因为所有线程都会竞争同一把锁,导致其他线程阻塞,性能下降。
- 实现方式:使用
- ConcurrentHashMap:
- JDK 1.7:
- 实现方式:使用分段锁(Segment)来实现线程安全。
- 特点:将哈希表分成多个段(Segment),每个段是一个独立的锁。不同段的数据可以被不同线程并发访问,大大提高了并发性能。
- 优点:减少了锁竞争,提高了并发访问率。
- JDK 1.8:
- 实现方式:取消了 Segment 的概念,改为使用
Node
数组 + 链表 + 红黑树的数据结构。 - 特点:使用
synchronized
和 CAS(Compare-And-Swap)操作来保证线程安全。锁粒度更细,synchronized
只锁定当前链表或红黑树的首节点。 - 优点:进一步减少了锁竞争,提高了并发性能。链表长度超过一定阈值时,链表会转换为红黑树,优化了查找性能。
- 实现方式:取消了 Segment 的概念,改为使用
- JDK 1.7:
- 底层数据结构
- Hashtable:
- 数据结构:数组 + 链表。
- 特点:数组是哈希表的主体,链表用于解决哈希冲突。
- 缺点:在高并发场景下,所有操作都需要竞争同一把锁,导致性能瓶颈。
- ConcurrentHashMap:
- JDK 1.7:
- 数据结构:Segment 数组 + HashEntry 数组 + 链表。
- 特点:每个 Segment 包含一个 HashEntry 数组,每个 HashEntry 是一个链表结构的元素。通过分段锁机制,不同段的数据可以被不同线程并发访问。
- JDK 1.8:
- 数据结构:Node 数组 + 链表 + 红黑树。
- 特点:当链表长度超过一定阈值(默认为 8)时,链表会转换为红黑树,以优化查找性能。使用
synchronized
和 CAS 操作来保证线程安全,锁粒度更细。
- JDK 1.7:
- 并发度
- Hashtable:
- 并发度:最大并发度为 1,因为所有操作都同步在同一个锁上。
- 特点:在高并发场景下,性能较差,因为所有线程都会竞争同一把锁。
- ConcurrentHashMap:
- JDK 1.7:
- 并发度:最大并发度为 Segment 数组的大小,默认是 16。
- 特点:通过分段锁机制,不同段的数据可以被不同线程并发访问,提高了并发性能。
- JDK 1.8:
- 并发度:最大并发度为 Node 数组的大小,通常比 JDK 1.7 更高。
- 特点:锁粒度更细,减少了锁竞争,进一步提高了并发性能。
- JDK 1.7:
19. JDK 1.7 和 JDK 1.8 的 ConcurrentHashMap 实现差异
- 线程安全实现方式
- JDK 1.7:
- 实现方式:使用分段锁(Segment)来实现线程安全。
- 特点:每个 Segment 是一个独立的锁,不同 Segment 的数据可以被不同线程并发访问,减少了锁竞争。
- JDK 1.8:
- 实现方式:取消了 Segment 的概念,改为使用
Node
数组 + 链表 + 红黑树。 - 特点:使用
synchronized
和 CAS 操作来保证线程安全,锁粒度更细,synchronized
只锁定当前链表或红黑树的首节点,进一步减少了锁竞争。
- 实现方式:取消了 Segment 的概念,改为使用
- 底层数据结构
- JDK 1.7:
- 数据结构:Segment 数组 + HashEntry 数组 + 链表。
- 特点:每个 Segment 包含一个 HashEntry 数组,每个 HashEntry 是一个链表结构的元素。通过分段锁机制,不同段的数据可以被不同线程并发访问。
- JDK 1.8:
- 数据结构:Node 数组 + 链表 + 红黑树。
- 特点:当链表长度超过一定阈值(默认为 8)时,链表会转换为红黑树,以优化查找性能。使用
synchronized
和 CAS 操作来保证线程安全,锁粒度更细。
- 性能优化
- JDK 1.7:
- 优化:通过分段锁机制,减少了锁竞争,提高了并发性能。
- JDK 1.8:
- 优化:锁粒度更细,进一步减少了锁竞争。链表转换为红黑树,优化了查找性能,特别是在高冲突情况下。
20. 为什么 ConcurrentHashMap 的 key 和 value 不能为 null
在 Java 集合框架中,ConcurrentHashMap
是线程安全的哈希表实现,其设计上不允许key
和value
为null
。
1. 避免二义性
null
作为一种特殊值,代表没有对象或引用。在ConcurrentHashMap
中,若允许null
作为key
,将无法区分该key
是实际存在于ConcurrentHashMap
中且值为null
,还是根本不存在此key
。同理,对于value
,也难以判断返回的null
是真实存储的值,还是因未找到对应key
而产生的结果。例如get
方法返回null
时,存在值不在集合中以及值本身为null
这两种可能,这就是所谓的二义性。
2. 多线程环境的复杂性
在多线程环境下,当一个线程操作ConcurrentHashMap
时,其他线程可能同时对其进行修改。这种情况下,无法依赖containsKey(key)
方法来准确判断键值对是否存在。因为在调用containsKey
方法之后,到依据该结果进行后续操作之前的这段时间内,ConcurrentHashMap
可能已被其他线程修改,从而导致判断失误,使得二义性问题无法得到有效解决。
3. 与 HashMap 对比
HashMap
则有所不同,它允许null
作为key
和value
。其中null
作为key
只能有一个,null
作为value
可以有多个。在单线程环境中,由于不存在其他线程同时修改HashMap
的情况,所以可以通过containsKey(key)
方法准确判断键值对是否存在,进而做出相应处理,不会出现二义性问题。
21. comparable 和 Comparator 的区别
- 接口来源和定义
- comparable:接口出自
java.lang
包,它有一个compareTo(Object obj)
方法用于排序。实现了Comparable
接口的类,意味着该类的对象具有自然排序的能力。例如,String
类、Integer
类等都实现了Comparable
接口,它们的对象可以直接进行比较和排序。 - Comparator:接口出自
java.util
包,它有一个compare(Object obj1, Object obj2)
方法用于排序。Comparator
接口用于定义一种外部比较规则,当一个类没有实现Comparable
接口,或者需要使用与自然排序不同的排序规则时,可以使用Comparator
接口。
- comparable:接口出自
- 使用场景
- comparable:一般用于对一个集合中的元素进行统一的自然排序。比如,要对一个
ArrayList<Integer>
进行升序排序,由于Integer
类实现了Comparable
接口,直接调用Collections.sort()
方法即可按照自然顺序(升序)对列表中的元素进行排序。 - Comparator:当需要对某一个集合实现自定义排序方式,或者需要对一个集合实现多种排序方式时使用。例如,有一个
Song
类,需要对其对象根据歌名和歌手名分别采用不同的排序方式,此时可以通过实现Comparator
接口来定义不同的比较规则。可以创建两个不同的Comparator
实现类,一个用于按歌名排序,一个用于按歌手名排序,然后在调用Collections.sort()
方法时传入相应的Comparator
实例来实现不同的排序需求。
- comparable:一般用于对一个集合中的元素进行统一的自然排序。比如,要对一个
二、多线程篇
1. 线程和进程的区别?
进程:程序由指令和数据组成,但这些指令要运行,数据要读写,就必须将指令加载至 CPU,数据加载至内存。在指令运行过程中还需要用到磁盘、网络等设备。进程就是用来加载指令、管理内存、管理 IO 的。当一个程序被运行,从磁盘加载这个程序的代码至内存,这时就开启了一个进程。
线程:一个进程之内可以分为一到多个线程。一个线程就是一个指令流,将指令流中的一条条指令以一定的顺序交给 CPU 执行
二者对比
(1)进程是正在运行程序的实例,进程中包含了线程,每个线程执行不同的任务
(2)不同的进程使用不同的内存空间,在当前进程下的所有线程可以共享内存空间
(3)线程更轻量,线程上下文切换成本一般上要比进程上下文切换低(上下文切换指的是从一个线程切换到另一个线程)
2. 并行和并发有什么区别?
现在都是多核CPU,在多核CPU下
并发是同一时间应对多件事情的能力,多个线程轮流使用一个或多个CPU,每个线程在不同的时间片内获得 CPU 资源来执行任务。
并行是同一时间动手做多件事情的能力,例如,一个 4 核 CPU 可以同时执行 4 个线程,每个线程在一个独立的 CPU 核心上运行,这些线程的执行是完全并行的,不会相互等待 CPU 资源。
3. 创建线程的四种方式
在java中一共有四种常见的创建方式,分别是:继承Thread类、实现runnable接口、实现Callable接口、线程池创建线程。通常情况下,我们项目中都会采用线程池的方式创建线程。
|
|
4. runnable 和 callable 有什么区别
- Runnable 接口run方法没有返回值;Callable接口call方法有返回值,是个泛型,和Future、FutureTask配合可以用来获取异步执行的结果
- Callalbe接口支持返回执行结果,需要调用FutureTask.get()得到,此方法会阻塞主进程的继续往下执行,如果不调用不会阻塞。
- Callable接口的call()方法允许抛出异常;而Runnable接口的run()方法的异常只能在内部消化,不能继续上抛
5. 线程的 run()和 start()有什么区别?
start(): 用来启动线程,通过该线程调用run方法执行run方法中所定义的逻辑代码。start方法只能被调用一次。
run(): 封装了要被线程执行的代码,可以被调用多次。
6. 线程包括哪些状态,状态之间是如何变化的
在JDK中的Thread类中的枚举State里面定义了6种线程的状态分别是:新建、可运行、终结、阻塞、等待和有时限等待六种。
关于线程的状态切换情况比较多。我分别介绍一下
当一个线程对象被创建,但还未调用 start 方法时处于新建状态,此时,线程仅仅是在 Java 堆中分配了内存,操作系统还没有为其分配任何资源,线程尚未启动。调用了 start 方法,就会由新建进入可运行状态。如果线程内代码已经执行完毕,由可运行进入终结状态。当然这些是一个线程正常执行情况。
当线程在获取对象的锁时,如果该锁已经被其他线程持有,线程会进入阻塞状态。线程会被放入该对象的 Monitor 阻塞队列中等待。只有当持锁线程释放锁时,会按照一定规则唤醒阻塞队列中的阻塞线程,唤醒后的线程进入可运行状态
如果线程获取锁成功后,但由于条件不满足,调用了 wait() 方法,线程会释放当前持有的锁,并进入等待状态。当其它持锁线程调用 notify() 或 notifyAll() 方法,会恢复为可运行状态,等待获取锁并继续执行。
还有一种情况是调用 sleep(long) 方法也会从可运行状态进入有时限等待状态,不需要主动唤醒,超时时间到自然恢复为可运行状态。
7. 新建 T1、T2、T3 三个线程,如何保证它们按顺序执行?
嗯~~,我思考一下 (适当的思考或想一下属于正常情况,脱口而出反而太假[背诵痕迹])
可以这么做,在多线程中有多种方法让线程按特定顺序执行,可以用线程类的join()方法在一个线程中启动另一个线程,另外一个线程完成该线程继续执行。
比如说:
使用join方法,T3调用T2,T2调用T1,这样就能确保T1就会先完成而T3最后完成
8. notify()和 notifyAll()有什么区别?
notifyAll:唤醒所有wait的线程
notify:只随机唤醒一个 wait 线程
9. 在 java 中 wait 和 sleep 方法的不同?
共同点
wait() ,wait(long) 和 sleep(long) 的效果都是让当前线程暂时放弃 CPU 的使用权,进入阻塞状态
这三个方法都可以被其他线程通过调用 interrupt()
方法打断唤醒,此时会抛出 InterruptedException
异常,需要在代码中进行捕获和处理。
不同点
- 方法归属不同
- sleep(long) 是 Thread 的静态方法
- 而 wait(),wait(long) 都是 Object类的成员方法,每个对象都有
- 醒来时机不同
- 执行 sleep(long) 和 wait(long) 的线程都会在等待相应毫秒后醒来
- wait(long) 和 wait() 还可以被 notify 唤醒,wait() 如果不唤醒就一直等下去
- 它们都可以被打断唤醒
- 锁特性不同(重点)
- wait 方法的调用必须先获取 wait 对象的锁,也就是说
wait
方法必须在synchronized
代码块或方法中使用。而sleep
方法则没有这个限制,可以在任何地方调用。 - wait 方法执行后会释放对象锁,允许其它线程获得该对象锁(我放弃 cpu,但你们还可以用)
- 而 sleep 如果在 synchronized 代码块中执行,并不会释放对象锁(我放弃 cpu,你们也用不了)
- wait 方法的调用必须先获取 wait 对象的锁,也就是说
10. 如何停止一个正在运行的线程?
有三种方式可以停止线程
使用退出标志,使线程正常退出,也就是当run方法完成后线程终止
使用stop方法强行终止(不推荐,方法已作废)
使用interrupt方法中断线程
11. 讲一下synchronized关键字的底层原理?
synchronized 翻译成中文是同步的的意思,主要解决的是多个线程之间访问资源的同步性,可以保 证被它修饰的⽅法或者代码块在任意时刻只能有⼀个线程执行。
synchronized 属于悲观锁。底层使用的JVM级别中的Monitor 来决定当前线程是否获得了锁,如果某一个线程获得了锁,在没有释放锁之前,其他线程是不能或得到锁的。
synchronized 因为需要依赖于JVM级别的Monitor ,相对性能也比较低。
Monitor
对象存在于每个 Java 对象的对象头中,所以每个 Java 对象都可以作为锁 。对象头是 Java 对象在内存中的一部分,用于存储对象的一些元数据信息,如哈希码、分代年龄等,同时也包含了指向 Monitor
的指针。当一个线程尝试获取某个对象的锁时,实际上就是在尝试获取该对象对应的 Monitor
。
monitor内部维护了三个变量
- WaitSet:保存处于Waiting状态的线程,当一个线程在持有锁的情况下调用了对象的
wait()
方法,它会释放当前持有的锁,并进入WaitSet
中等待。只有当其他线程调用了该对象的notify()
或notifyAll()
方法时,WaitSet
中的线程才有可能被唤醒,重新参与锁的竞争。 - EntryList:保存处于Blocked状态的线程,当一个线程尝试获取已经被其他线程持有的锁时,它会被放入
EntryList
中进行阻塞等待。这些线程会在持有锁的线程释放锁后,被唤醒并参与锁的竞争。 - Owner:用于记录当前持有锁的线程。当一个线程成功获取到
Monitor
时,Monitor
中的Owner
会被设置为该线程,表示该线程成为了锁的持有者。
只有一个线程获取到的标志就是在monitor中设置成功了Owner,一个monitor中只能有一个Owner
在上锁的过程中,如果有其他线程也来抢锁,则进入EntryList 进行阻塞,当获得锁的线程执行完了,释放了锁,就会唤醒EntryList 中等待的线程竞争锁,竞争的时候是非公平的。
12. 讲一下synchronized关键字的使用方式?
在 Java 里,synchronized 关键字主要有三种使用方式,目的是实现线程间的同步,保证同一时刻只有一个线程能访问被保护的代码或资源。
- 修饰实例方法:给当前对象实例加锁。当一个线程调用该实例方法时,在进入同步代码之前,必须先获取这个对象实例的锁。只有获得锁后,线程才能执行方法里的代码;如果锁被其他线程占用,当前线程就会被阻塞,直到锁被释放。
- 修饰静态方法:给当前类加锁,这个锁会作用于类的所有对象实例。由于静态成员归整个类所有,被类的所有实例共享,所以进入同步代码前要获取当前 class 的锁。要注意,静态 synchronized 方法和非静态 synchronized 方法之间的调用不会互斥。比如线程 A 调用一个实例对象的非静态 synchronized 方法,同时线程 B 调用这个实例对象所属类的静态 synchronized 方法,这种情况是允许的,因为它们占用的锁不同,前者是当前实例对象锁,后者是当前类的锁。
- **修饰代码块:**对括号里指定的对象或类加锁。
synchronized(object)
:表示在进入同步代码块之前,需要获得给定对象的锁。只有拿到这个对象的锁,线程才能执行代码块中的内容。synchronized(类.class)
:意味着进入同步代码前要获得给定 Class 的锁。这种方式和修饰静态方法类似,都是对类进行加锁。
12. Monitor实现的锁属于重量级锁,你了解过锁升级吗?
Java中的synchronized有偏向锁、轻量级锁、重量级锁三种形式,分别对应了锁只被一个线程持有、不同线程交替持有锁、多线程竞争锁三种情况。
偏向锁
偏向锁适用于在很长一段时间内都只有一个线程使用锁的场景。当一个线程第一次获取锁时,会通过 CAS 操作把自己的线程 ID 记录在对象头的 mark word
里。之后这个线程再次获取这把锁时,只需检查 mark word
中的线程 ID 是不是自己的,是的话就能直接获取锁,避免了频繁 CAS 操作的开销。
轻量级锁
轻量级锁适用于不同线程交替持有锁,也就是线程加锁时间错开、没有锁竞争的情况。线程尝试获取轻量级锁时,会先在自己的栈帧中创建一个锁记录,然后用 CAS 操作把对象头的 mark word
复制到锁记录里,并让对象头指向锁记录。如果操作成功,就获取到了轻量级锁;如果失败,就自旋等待。轻量级锁主要通过修改对象头的锁标志实现,避免了用户态和内核态的切换,性能比重量级锁好很多。这就像两个人交替用一个房间,一个人来的时候在自己本子上记房间信息,把门口牌子指向自己本子,另一个人来发现牌子指向别人本子就等一会儿。
重量级锁
重量级锁适用于多线程竞争锁的场景。它底层用 Monitor
实现,当线程竞争锁失败时会被阻塞,进入等待队列。这涉及到用户态和内核态的切换、进程的上下文切换,成本高,性能低。就像很多人同时抢一个房间,门口有管理员管理,竞争不到的人要去等待区,等房间空了管理员再叫人进来,这个过程很麻烦,效率低。
锁升级机制
锁的状态会根据竞争情况升级,一旦出现竞争,会按照偏向锁 -> 轻量级锁 -> 重量级锁的顺序升级,而且锁升级是单向的,升为重量级锁后就不会再降级。
13. 你谈谈 JMM(Java 内存模型)
Java内存模型是Java虚拟机规范中定义的一种非常重要的内存模型。它的主要作用是描述Java程序中线程共享变量的访问规则,以及这些变量在JVM中是如何被存储和读取的,这些规则保证了多线程环境下数据的一致性和程序的正确性。
这个模型有几个核心的特点。首先,所有的共享变量,包括实例变量(属于对象的变量)和类变量(用 static
修饰的变量),都被存储在主内存中,也就是计算机的RAM。主内存就像是一个公共仓库,所有线程都能访问它。需要注意的是,局部变量并不包含在内,因为它们是线程私有的,所以不存在竞争问题。
其次,每个线程都有自己的工作内存,线程会把主内存中的共享变量复制一份到自己的工作内存中,形成工作副本。这意味着,线程对变量的所有操作,无论是读还是写,都必须在自己的工作内存中完成,而不能直接读写主内存中的变量。
最后,不同线程之间不能直接访问对方工作内存中的变量。如果线程间需要传递变量的值,那么这个过程必须通过主内存来完成。threadA
先把修改后的值写回主内存,threadB
再从主内存读取这个值,这样就完成了变量的传递。
14. CAS 你知道吗?
CAS,是一种无锁算法,用于实现多线程同步的原子操作
CAS 是一种**乐观锁**策略,它假设在大多数情况下不会发生冲突,因此在操作共享资源时不会加锁,而是在更新数据时检查数据是否被其他线程修改过。
原理
CAS 操作包含三个操作数:内存位置(V)、预期原值(A)和新值(B)。执行时,它会先比较内存位置 V 的值是否与预期原值 A 相等,如果相等,就将内存位置 V 的值更新为新值 B;如果不相等,则不进行更新。整个操作是原子性的,由 CPU 硬件保证。
优缺点
优点:避免了锁的使用,减少了线程上下文切换的开销,在并发冲突较少时性能较高。
缺点:存在 ABA 问题,即一个值从 A 变为 B 再变回 A,CAS 会认为值未改变;并且在并发冲突激烈时,线程会频繁自旋,消耗大量 CPU 资源。对于 ABA 问题,Java 提供了 AtomicStampedReference
类来解决。
15. 什么是AQS?
什么是 AQS? AQS(AbstractQueuedSynchronizer)是 Java 提供的一个框架,用于构建锁和其他同步组件。它的核心思想是通过一个整数变量(state
)来表示同步状态,并通过一个FIFO队列来管理线程的等待和唤醒。它是一个构建同步器的基础,提供了许多阻塞锁和同步工具的实现,如 ReentrantLock
、Semaphore
和 CountDownLatch
等。
AQS 的工作机制是什么? AQS 通过维护一个 state
属性来表示资源的状态。它提供了一个基于 FIFO 的等待队列来管理获取锁的线程。当一个线程尝试获取锁时,如果锁不可用,该线程会被加入到等待队列中。AQS 还支持条件变量和相应的等待/唤醒机制。
AQS 如何保证线程安全地修改 state? AQS 使用 CAS(Compare-AndSwap)操作来安全地修改 state
属性,确保这一操作的原子性,避免多线程同时修改导致的问题。
AQS 是公平锁还是非公平锁? AQS 本身并不直接实现公平性或非公平性,但基于 AQS 实现的锁可以是公平或非公平的。例如,ReentrantLock
默认是非公平锁,但可以配置为公平锁。公平锁确保等待时间最长的线程优先获取锁。
AQS 常见的实现类有哪些? 常见的基于 AQS 实现的类包括:
ReentrantLock
:一种可重入的互斥锁。Semaphore
:用于控制同时访问特定资源的线程数量。CountDownLatch
:允许一个或多个线程等待其他线程完成操作。
面试说:AQS是Java并发包中的一个基础框架,用于构建锁和其他同步器。它通过一个state
变量来表示同步状态,并通过一个FIFO队列来管理线程的等待和唤醒。AQS有两种模式:独占模式和共享模式。独占模式下,同一时间只有一个线程可以获取锁;共享模式下,多个线程可以共享资源。AQS的核心在于它提供了一套标准化的方法来实现线程同步,使得开发者可以更方便地实现自己的同步器。
16. ReentrantLock的实现原理
首先,ReentrantLock是一种可重入的排它锁,主要用来解决多线程对共享资源竞争的问题。
它的核心特性有几个:
它支持可重入,也就是获得锁的线程在释放锁之前再次去竞争同一把锁的时候,不需要加锁就可以直接访问。
它支持公平和非公平特性
它提供了阻塞竞争锁和非阻塞竞争锁的两种方法,分别是lock()和tryLock()。
然后,ReentrantLock的底层实现有几个非常关键的技术。
锁的竞争,ReentrantLock是通过互斥变量,使用CAS机制来实现的。
没有竞争到锁的线程,使用了AbstractQueuedSynchronizer这样一个队列同步器来存储,底层是通过双向链表来实现的。当锁被释放之后,会从AQS队列里面的头部唤醒下一个等待锁的线程。
公平和非公平的特性,主要是体现在竞争锁的时候,是否需要判断AQS队列存在等待中的线程。
最后,关于锁的重入特性,在AQS里面有一个成员变量来保存当前获得锁的线程,当同一个线程下次再来竞争锁的时候,就不会去走锁竞争的逻辑,而是直接增加重入次数。
17. synchronized和Lock有什么区别 ?
第一,语法层面
- synchronized 是关键字,源码在 jvm 中,用 c++ 语言实现,退出同步代码块锁会自动释放
- Lock 是接口,源码由 jdk 提供,用 java 语言实现,需要手动调用 unlock 方法释放锁
第二,功能层面
- 二者均属于悲观锁、都具备基本的互斥、同步、锁重入功能
- Lock 提供了许多 synchronized 不具备的功能,例如获取等待状态、公平锁、可打断、可超时、多条件变量,同时Lock 可以实现不同的场景,如 ReentrantLock, ReentrantReadWriteLock
第三,性能层面
- 在没有竞争时,synchronized 做了很多优化,如偏向锁、轻量级锁,性能不赖
- 在竞争激烈时,Lock 的实现通常会提供更好的性能,比如可中断获取锁,可超时获取锁,公平锁的选择,多条件变量的实现。
Lock
相比 synchronized
所具备的这些独特功能:
1. 获取等待状态
synchronized
的局限:使用synchronized
时,我们没办法知道当前有没有线程在等待获取这把锁,也不清楚等待的线程数量。就好比你去一个公共卫生间,用synchronized
的话,你只能知道里面有没有人,但不知道外面排了多少人等着进去。Lock
的优势:Lock
可以让我们获取到等待锁的线程的相关状态。以ReentrantLock
为例,借助getQueueLength()
方法就能知道有多少线程正在等待获取这把锁。这就好像卫生间门口有个显示屏,能显示排队的人数,让我们对整体情况有更清晰的了解。
2. 公平锁
synchronized
的情况:synchronized
实现的是非公平锁。这就好比大家在抢公交车的座位,不管谁先来,只要有空座,谁抢到就是谁的。可能先来的人反而没抢到座,后来的人却捷足先登了。Lock
的公平锁:Lock
可以实现公平锁。公平锁就像是大家排队坐公交车,先到的人先上车有座坐,后到的人依次排队等待。ReentrantLock
可以在创建时通过构造函数指定为公平锁,即new ReentrantLock(true)
,这样线程获取锁的顺序就和它们请求锁的顺序一致了。
3. 可打断
synchronized
的问题:当一个线程使用synchronized
获取锁时,如果这个锁被其他线程持有,当前线程就会一直等待,而且在等待过程中不能被其他线程打断。这就好比你在排队买东西,前面的人一直不买完,你就只能干等着,没办法中途离开去做别的事。Lock
的可打断特性:Lock
提供了可打断的功能。使用lockInterruptibly()
方法获取锁时,如果线程在等待锁的过程中被其他线程打断,它会抛出InterruptedException
异常,然后线程可以去做其他事情。就像排队买东西时,你可以选择在等得不耐烦的时候,听到别人叫你去做别的事,你就可以离开队伍去处理其他事情。
4. 可超时
synchronized
的不足:synchronized
没有超时机制,线程一旦开始等待锁,就会一直等下去,可能会造成长时间的阻塞。这就好比你去餐厅吃饭,前面的人一直占着桌子不走,你只能一直等,没有别的选择。Lock
的可超时功能:Lock
可以设置获取锁的超时时间。使用tryLock(long timeout, TimeUnit unit)
方法时,如果在指定的时间内没有获取到锁,线程就会放弃等待,返回false
。这就像你去餐厅吃饭,等了一段时间桌子还没腾出来,你就可以选择去别的餐厅,不用一直干等着。
5. 多条件变量
synchronized
的单一性:synchronized
配合wait()
、notify()
和notifyAll()
方法只能实现单一的条件等待和唤醒。就好比一个房间里只有一个门铃,不管是谁在等消息,门铃一响所有人都得醒来看是不是自己的消息。Lock
的多条件变量:Lock
可以通过newCondition()
方法创建多个条件变量。每个条件变量都可以单独进行等待和唤醒操作。线程可以根据不同的条件在不同的队列中等待,唤醒时也可以精确地唤醒特定条件下等待的线程,减少了不必要的唤醒和上下文切换,提高了线程间协作的效率。
18. 死锁产生的条件是什么?
嗯,是这样的,一个线程需要同时获取多把锁,这时就容易发生死锁,举个例子来说:
t1 线程获得A对象锁,接下来想获取B对象的锁
t2 线程获得B对象锁,接下来想获取A对象的锁
这个时候t1线程和t2线程都在互相等待对方的锁,就产生了死锁
19. 如何进行死锁诊断?
可以使用jdk自带的工具:jps和 jstack
先通过jps来查看当前java程序运行的进程id
然后通过jstack来查看这个进程id,就能展示出来死锁的问题,并且,可以定位代码的具体行号范围,我们再去找到对应的代码进行排查就行了。
或者用一些可视化工具:jconsole、VisualVM,bin目录下 直接启动就行
20. ConcurrentHashMap
ConcurrentHashMap 是一种线程安全的高效Map集合,jdk1.7和1.8也做了很多调整。
- JDK1.7的底层采用是分段的数组+链表 实现
- JDK1.8 采用的数据结构跟HashMap1.8的结构一样,数组+链表/红黑二叉树。
在jdk1.7中 ConcurrentHashMap 里包含一个 Segment 数组。Segment 的结构和HashMap类似,是一 种数组和链表结构,一个 Segment 包含一个 HashEntry 数组,每个 HashEntry 是一个链表结构的元素,Segment 是一种可重入的锁 ReentrantLock,每个Segment 守护着一个HashEntry数组里的元素,当对 HashEntry 数组的数据进行修改时,必须首先获得对应的 Segment的锁。
在jdk1.8中的ConcurrentHashMap 做了较大的优化,性能提升了不少。首先是它的数据结构与jdk1.8的hashMap数据结构完全一致。其次是放弃了Segment臃肿的设计,取而代之的是采用Node + CAS + Synchronized来保证并发安全进行实现,synchronized只锁定当前链表或红黑二叉树的首节点,这样只要hash不冲突,就不会产生并发 , 效率得到提升
在 JDK 1.8 中,ConcurrentHashMap
将锁机制从Segment
(继承自ReentrantLock
)改为synchronized
后效率得到提升,主要原因如下:
1. 锁粒度的减小
- JDK 1.7 的 Segment 锁:在 JDK 1.7 中,
ConcurrentHashMap
使用Segment
数组来分段管理数据,每个Segment
是一个可重入锁,并且守护着一个HashEntry
数组。这意味着如果有多个线程访问不同Segment
的数据,这些线程可以并发执行;但如果访问同一个Segment
的数据,就会被阻塞。由于锁的粒度是Segment
级别,而一个Segment
可能包含多个键值对,在高并发场景下,不同线程竞争同一个Segment
锁的概率较高,从而限制了并发性能。 - JDK 1.8 的 synchronized 锁:JDK 1.8 中,
ConcurrentHashMap
放弃了Segment
,直接使用Node
数组来存储数据。当需要修改数据时,synchronized
只锁定当前链表或红黑二叉树的首节点。如果多个线程访问的节点位于不同的链表或红黑二叉树,或者即使在同一个链表 / 树中但首节点不同,这些线程就可以并发执行。锁粒度从Segment
(包含多个键值对)细化到了链表或树的首节点,大大降低了线程之间的锁竞争,提高了并发访问的效率。
2. CAS(Compare and Swap)操作的辅助
- CAS 操作原理:CAS 是一种无锁的原子操作,它包含三个操作数:内存位置、预期原值和新值。当且仅当内存位置的值与预期原值相同时,CAS 才会将内存位置的值更新为新值。在 JDK 1.8 的
ConcurrentHashMap
中,对于一些操作(如插入新节点),首先会尝试使用 CAS 操作来完成。只有在 CAS 操作失败(例如发生了哈希冲突,导致 CAS 更新操作失败)时,才会使用synchronized
锁来保证操作的原子性。 - 提升效率方式:由于大部分情况下,插入操作不会发生哈希冲突,因此 CAS 操作能够快速完成,避免了使用锁带来的开销。这使得在高并发环境下,
ConcurrentHashMap
可以利用 CAS 的高效性来处理大部分的操作,仅在必要时使用synchronized
锁,从而整体上提升了效率。
3. 红黑树的引入优化数据结构
- 链表与红黑树的转换:JDK 1.8 的
ConcurrentHashMap
中,当链表长度超过一定阈值(8)时,链表会转换为红黑树。红黑树相比于链表,在查找、插入和删除操作上具有更好的时间复杂度(链表的查找时间复杂度为 O (n),红黑树为 O (log n))。 - 对锁效率的影响:当使用
synchronized
锁定首节点时,由于红黑树的结构特性,在处理大量数据时,即使存在锁竞争,基于红黑树的操作效率也比基于链表的操作效率更高。这意味着在相同的并发环境下,JDK 1.8 的ConcurrentHashMap
能够更快地完成各种操作,从而提升了整体效率。
4. JVM 对 synchronized 的优化
- 锁升级机制:JVM 在 JDK 1.6 及之后对
synchronized
进行了一系列优化,引入了偏向锁、轻量级锁和重量级锁的锁升级机制。当一个线程访问同步块时,首先会尝试获取偏向锁,如果没有其他线程竞争,该线程可以直接进入同步块,不需要进行 CAS 操作和重量级锁的获取,从而降低了锁的开销。只有在出现锁竞争时,才会逐步升级到轻量级锁和重量级锁。 - 在 ConcurrentHashMap 中的作用:在 JDK 1.8 的
ConcurrentHashMap
中,synchronized
锁的使用正好可以利用 JVM 的这些优化机制。在大部分情况下,由于锁粒度小,竞争不激烈,synchronized
能够以较低的开销工作,进一步提升了ConcurrentHashMap
的性能。
在 Java 中,ConcurrentHashMap
在不同版本中使用了不同的数据结构来实现并发安全的哈希映射,Segment
数组和Node
数组是其中两个重要的组成部分,以下是对它们的详细介绍:
JDK 1.7 中的Segment
数组
- 结构与作用:
ConcurrentHashMap
在 JDK 1.7 中使用Segment
数组来实现分段锁机制。每个Segment
内部包含一个HashEntry
数组,类似于HashMap
的结构,它将整个哈希表分成多个段,每个段独立进行锁控制。这样,在多线程并发访问时,不同的线程可以同时访问不同的Segment
,从而提高并发性能。 - 锁机制:
Segment
继承自ReentrantLock
,当对Segment
中的HashEntry
数组进行修改操作(如插入、删除、更新等)时,需要先获取对应的Segment
的锁。这意味着同一时刻只有一个线程可以访问同一个Segment
中的数据,而不同Segment
之间的操作可以并发进行。
JDK 1.8 中的Node
数组
- 结构与作用:JDK 1.8 中的
ConcurrentHashMap
摒弃了Segment
数组的设计,采用了Node
数组来存储数据。Node
是哈希表中的基本节点类型,每个Node
包含键值对以及指向下一个节点的引用(用于解决哈希冲突,形成链表或红黑树结构)。数组的每个位置要么是一个Node
节点,要么是null
,通过哈希值来确定键值对在数组中的存储位置。 - 锁机制与并发控制:在 JDK 1.8 中,
ConcurrentHashMap
通过Node + CAS + Synchronized
来保证并发安全。当对Node
数组进行操作时,首先会尝试使用CAS
(比较并交换)操作来更新数组中的节点,这是一种无锁的并发操作方式,能在大多数情况下提高并发性能。对于一些复杂的操作,如在链表或红黑树中插入、删除节点时,会使用synchronized
关键字来锁定当前链表或红黑树的首节点。这种方式相比 JDK 1.7 的Segment
锁更加精细,只锁定当前操作的节点所在的链表或红黑树,而不是整个Segment
,从而减少了锁的粒度,提高了并发访问的效率。
21. 导致并发程序出现问题的根本原因是什么
Java并发编程有三大核心特性,分别是原子性、可见性和有序性。
首先,原子性指的是一个线程在CPU中的操作是不可暂停也不可中断的,要么执行完成,要么不执行。比如,一些简单的操作如赋值可能是原子的,但复合操作如自增就不是原子的。为了保证原子性,我们可以使用synchronized关键字或JUC里面的Lock来进行加锁。
其次,可见性是指让一个线程对共享变量的修改对另一个线程可见。由于线程可能在自己的工作内存中缓存共享变量的副本,因此一个线程对共享变量的修改可能不会立即反映在其他线程的工作内存中。为了解决这个问题,我们可以使用synchronized关键字、volatile关键字或Lock来确保可见性。
最后,有序性是指处理器为了提高程序运行效率,可能会对输入代码进行优化,导致程序中各个语句的执行先后顺序与代码中的顺序不一致。虽然处理器会保证程序最终执行结果与代码顺序执行的结果一致,但在某些情况下我们可能需要确保特定的执行顺序。为了解决这个问题,我们可以使用volatile关键字来禁止指令重排。
22. 说一下线程池的核心参数(线程池的执行原理知道嘛)
在线程池中一共有7个核心参数:
- corePoolSize 核心线程数目 - 池中会保留的最多线程数
- maximumPoolSize 最大线程数目 - 核心线程+救急线程的最大数目
- keepAliveTime 生存时间 - 救急线程的生存时间,生存时间内没有新任务,此线程资源会释放
- unit 时间单位 - 救急线程的生存时间单位,如秒、毫秒等
- workQueue - 当没有空闲核心线程时,新来任务会加入到此队列排队,队列满会创建救急线程执行任务
- threadFactory 线程工厂 - 可以定制线程对象的创建,例如设置线程名字、是否是守护线程等
- handler 拒绝策略 - 如果所有线程都在忙着(核心线程+临时线程),则走拒绝策略,拒绝策略有4种
1.AbortPolicy:直接抛出异常,默认策略;
2.CallerRunsPolicy:用调用者所在的线程来执行任务;
3.DiscardOldestPolicy:丢弃阻塞队列中靠最前的任务,并执行当前任务;
4.DiscardPolicy:直接丢弃任务;
23. 线程池中有哪些常见的阻塞队列
Jdk中提供了很多阻塞队列,开发中常见的有两个:ArrayBlockingQueue
和LinkedBlockingQueue
ArrayBlockingQueue
和LinkedBlockingQueue
是Java中两种常见的阻塞队列,它们在实现和使用上有一些关键的区别。
首先,ArrayBlockingQueue
是一个有界队列,它在创建时必须指定容量,并且这个容量不能改变。而LinkedBlockingQueue
默认是无界的,但也可以在创建时指定最大容量,使其变为有界队列。
其次,它们在内部数据结构上也有所不同。ArrayBlockingQueue
是基于数组实现的,而LinkedBlockingQueue
则是基于链表实现的。这意味着ArrayBlockingQueue
在访问元素时可能会更快,因为它可以直接通过索引访问数组中的元素。而LinkedBlockingQueue
则在添加和删除元素时可能更快,因为它不需要移动其他元素来填充空间。
另外,它们在加锁机制上也有所不同。ArrayBlockingQueue
使用一把锁来控制对队列的访问,这意味着读写操作都是互斥的。而LinkedBlockingQueue
则使用两把锁,一把用于控制读操作,另一把用于控制写操作,这样可以提高并发性能。
24. 如何确定核心线程数
在设置核心线程数之前,需要先熟悉一些执行线程池执行任务的类型
- IO密集型任务
一般来说:文件读写、DB读写、网络请求等
推荐:核心线程数大小设置为2N+1 (N为计算机的CPU核数)
- CPU密集型任务
一般来说:计算型代码、Bitmap转换、Gson转换等
推荐:核心线程数大小设置为N+1 (N为计算机的CPU核数)
① 高并发、任务执行时间短 –>( CPU核数+1 ),减少线程上下文的切换,因为任务执行时间短,线程很快就能完成任务并释放资源,较少的线程数可以避免频繁的上下文切换带来的性能损耗。
② 并发不高、任务执行时间长
- IO密集型的任务 –> (CPU核数 * 2 + 1) 由于任务执行时间长且涉及大量 IO 操作,较多的线程可以在某个线程等待 IO 时继续执行其他任务,提高 CPU 的利用率。
- 计算密集型任务 –> ( CPU核数+1 )因为任务主要是计算工作,过多的线程会增加上下文切换的开销,所以保持与 CPU 核数相近的线程数较为合适。
③ 并发高、业务执行时间长,解决这种类型任务的关键不在于线程池而在于整体架构的设计,首先要考虑业务中的某些数据是否可以做缓存,通过缓存可以减少不必要的计算和 IO 操作,提高系统的响应速度。其次,如果缓存无法满足需求,可以考虑增加服务器来分担压力。
25. 线程池的种类有哪些
在jdk中默认提供了4种方式创建线程池
第一个是:newCachedThreadPool创建一个可缓存线程池
- 线程池中的线程数量是灵活可变的。当有新任务提交时,如果线程池中有空闲线程,就会复用这些空闲线程来执行任务;如果没有空闲线程,就会创建新的线程来处理任务。
- 对于空闲线程有一定的回收机制。如果某个线程在 60 秒内都处于空闲状态,就会被回收销毁,这样可以避免资源的浪费。
第二个是:newFixedThreadPool 创建一个定长线程池,可控制线程最大并发数,超出的线程会在队列中等待。 适用于需要控制并发线程数量的场景,例如数据库连接池,为了避免过多的线程同时访问数据库导致资源耗尽,可以使用定长线程池来限制并发线程数。
第三个是:newScheduledThreadPool 创建一个定时任务线程池,支持定时及周期性任务执行。 适用于需要定时执行任务或周期性执行任务的场景,例如定时数据备份、定时统计系统性能指标等。
第四个是:newSingleThreadExecutor 创建一个单线程化的线程池,它只会用唯一的工作线程来执行任务,保证所有任务按照指定顺序(FIFO, LIFO, 优先级)执行。
26. 为什么不建议用Executors创建线程池
主要原因是如果使用Executors创建线程池的话,它允许的请求队列默认长度是Integer.MAX_VALUE,这样的话,有可能导致堆积大量的请求,从而导致OOM(内存溢出)。
所以,我们一般推荐使用ThreadPoolExecutor来创建线程池,这样可以明确规定线程池的参数,避免资源的耗尽。
27. 你们项目哪里用到了多线程
参考场景一:
es数据批量导入
在我们项目上线之前,我们需要把数据量的数据一次性的同步到es索引库中,但是当时的数据好像是1000万左右,一次性读取数据肯定不行(oom异常),如果分批执行的话,耗时也太久了。所以,当时我就想到可以使用线程池的方式导入,利用CountDownLatch+Future来控制,就能大大提升导入的时间。
参考场景二:
在我做那个xx电商网站的时候,里面有一个数据汇总的功能,在用户下单之后需要查询订单信息,也需要获得订单中的商品详细信息(可能是多个),还需要查看物流发货信息。因为它们三个对应的分别三个微服务,如果一个一个的操作的话,互相等待的时间比较长。所以,我当时就想到可以使用线程池,让多个线程同时处理,最终再汇总结果就可以了,当然里面需要用到Future来获取每个线程执行之后的结果才行
参考场景三:
《黑马头条》项目中使用的
我当时做了一个文章搜索的功能,用户输入关键字要搜索文章,同时需要保存用户的搜索记录(搜索历史),这块我设计的时候,为了不影响用户的正常搜索,我们采用的异步的方式进行保存的,为了提升性能,我们加入了线程池,也就说在调用异步方法的时候,直接从线程池中获取线程使用
28. 如何控制某个方法允许并发访问线程的数量?
在jdk中提供了一个Semaphore[seməfɔːr]类(信号量)
它提供了两个方法,semaphore.acquire() 请求信号量,可以限制线程的个数,是一个正数,如果信号量是-1,就代表已经用完了信号量,其他线程需要阻塞了
第二个方法是semaphore.release(),代表是释放一个信号量,此时信号量的个数+1
29. 谈谈你对ThreadLocal的理解
ThreadLocal 主要功能有两个,第一个是可以实现资源对象的线程隔离,让每个线程各用各的资源对象,避免争用引发的线程安全问题,第二个是实现了线程内的资源共享
30. 知道ThreadLocal的底层原理实现吗?
在ThreadLocal内部维护了一个一个 ThreadLocalMap 类型的成员变量,用来存储资源对象
当我们调用 set 方法,就是以 ThreadLocal 自己作为 key,资源对象作为 value,放入当前线程的 ThreadLocalMap 集合中
当调用 get 方法,就是以 ThreadLocal 自己作为 key,到当前线程中查找关联的资源值
当调用 remove 方法,就是以 ThreadLocal 自己作为 key,移除当前线程关联的资源值
31. 关于ThreadLocal会导致内存溢出这个事情,了解吗?
ThreadLocalMap
中的 Entry
类的key
是一个弱引用,而 value
是一个强引用。弱引用的特点是,当垃圾回收器进行垃圾回收时,如果一个对象只被弱引用所引用,那么无论当前内存是否充足,该对象都会被回收。所以,当外部对 ThreadLocal
对象的强引用被释放后,ThreadLocalMap
中的 key
会被垃圾回收器回收,即 key
变为 null
。
然而,value
是强引用,只要当前线程还存在,ThreadLocalMap
就不会被回收,value
也不会被回收。这样就会导致 ThreadLocalMap
中存在一些 key
为 null
,但 value
不为 null
的 Entry
,这些 Entry
无法被访问到,却占用着内存,随着时间的推移,可能会导致内存泄漏。
解决办法
为了避免 ThreadLocal
导致的内存泄漏问题,我们应该在使用完 ThreadLocal
后,及时调用 remove
方法。
另外,在使用 ThreadLocal
时,尽量避免将其作为静态变量使用,因为静态变量的生命周期和类的生命周期一样长,可能会导致 ThreadLocal
对象一直存在,增加内存泄漏的风险。同时,在使用线程池时,由于线程会被复用,更要注意及时调用 remove
方法,防止 ThreadLocal
中的数据在不同任务之间产生混淆,也避免内存泄漏问题的发生。
32. 说说线程的生命周期和状态?
Java 线程具有 6 种不同的生命周期状态,分别为:
- NEW(初始状态):线程已创建,但尚未调用
start()
方法启动。 - RUNNABLE(运行状态):调用
start()
方法后进入该状态,此时线程等待获取 CPU 资源以执行。 - BLOCKED(阻塞状态):线程在获取对象锁时被阻塞,需等待锁的释放。
- WAITING(等待状态):线程需要等待其他线程执行特定动作(如通知或中断),会一直等待直到收到相应信号。
- TIME_WAITING(超时等待状态):与 WAITING 类似,但可在指定时间后自行返回,无需一直等待。
- TERMINATED(终止状态):线程执行完毕,生命周期结束。
线程在运行过程中,会依据代码的执行情况在这些状态之间进行切换,并非固定处于某一状态 。
33. 线程池如何知道一个线程的任务已经执行完成
线程池判断一个线程的任务是否执行完成,可从线程池内部机制和线程池外部获取状态两个方面来理解:
线程池内部机制
当向线程池提交任务后,线程池会调度工作线程执行任务的run
方法。工作线程通过同步调用任务的run
方法,等待run
方法返回,一旦run
方法正常结束,即表明任务完成,此时工作线程会统计任务的完成数量。
线程池外部获取状态
- 使用
isTerminated()
方法:线程池提供了isTerminated()
方法用于判断其运行状态。通过循环判断该方法的返回结果,可了解线程池状态。当线程池状态为Terminated
时,意味着所有任务都已执行完毕。不过,使用此方法的前提是程序中主动调用了线程池的shutdown()
方法。在实际业务中,主动关闭线程池的情况并不常见,所以该方法在实用性和灵活性上有所欠缺。 - 利用
submit()
方法的Future
返回值:线程池的submit()
方法会返回一个Future
对象。通过调用Future.get()
方法可获取任务的执行结果。在任务未执行完成前,Future.get()
方法会一直阻塞,直至任务执行结束正常返回,这也就表明传入线程池的任务已执行完成。 - 借助
CountDownLatch
计数器:CountDownLatch
可以通过初始化指定一个计数器进行倒计时,其有await()
方法用于阻塞线程,以及countDown()
方法用于倒计时。基于此原理,可定义一个计数器为 1 的CountDownLatch
对象,在线程池代码块后面调用await()
方法阻塞主线程。当传入线程池的任务执行完成后,调用countDown()
方法表示任务结束。此时计数器归零,被阻塞在await()
方法的线程将被唤醒。
总的来说,无论是在线程池内部还是外部,要知晓线程是否执行结束,关键在于获取线程执行结束后的状态。由于线程本身没有返回值,所以常通过阻塞 - 唤醒的方式来达成,Future.get
和CountDownLatch
都是基于这一原理。
34. 线程池有哪几种状态 每种状态分别表示什么
在 Java 线程池中,线程池共有以下 5 种状态,每种状态都有其特定的含义和行为:
- RUNNING(运行状态):线程池处于正常运行状态,可以接受新任务,并处理已提交的任务。这是线程池创建后的初始状态。在这种状态下,线程池会根据任务队列和线程数量的情况,动态地创建和管理工作线程来执行任务。
- SHUTDOWN(关闭状态):当调用线程池的
shutdown()
方法后,线程池进入此状态。此时,线程池不再接受新任务,但会继续处理已提交到任务队列中尚未执行的任务。只有当任务队列中的所有任务都处理完成后,线程池才会进入下一个状态。在处理任务的过程中,线程池不会中断正在执行任务的线程,而是等待任务自然结束。 - STOP(停止状态):当调用线程池的
shutdownNow()
方法后,线程池进入此状态。与SHUTDOWN
状态不同,STOP
状态下线程池不仅不再接受新任务,还会尝试立即停止所有正在执行的任务,并且会中断等待任务队列中任务的线程。也就是说,处于STOP
状态的线程池会立即终止所有的工作线程,而不会等待任务完成。 - TIDYING(整理状态):当线程池中的所有任务都已执行完毕,并且工作线程数量为 0 时,线程池会自动进入
TIDYING
状态。在进入该状态时,线程池会调用terminated()
方法,这个方法可以由用户自定义实现,用于在线程池关闭前进行一些清理和资源释放的操作。 - TERMINATED(终止状态):当
terminated()
方法执行完毕后,线程池就会进入TERMINATED
状态。此时,线程池已经完全关闭,不再有任何活动,所有的资源也都已释放。
悲观锁与乐观锁:概念、实现及应用场景
一、悲观锁
(一)定义与原理
悲观锁总是假设最坏的情况,即共享资源每次被访问时,极有可能发生冲突。所以,它在每次操作共享资源前,都会先获取锁,确保同一时刻只有一个线程能访问该资源,其他线程只能阻塞等待,直到持有锁的线程释放锁资源。这种策略如同在一个公共资源前设置了一道关卡,每次只允许一个人通过,其他人必须排队等待。
(二)Java 中的实现方式
- synchronized 关键字:这是 Java 内置的一种同步机制,当一个线程进入被
synchronized
修饰的代码块或方法时,它会自动获取对象锁(若是静态方法,则获取类锁)。例如:
|
|
在上述代码中,performTask
方法被synchronized
修饰,当一个线程执行该方法时,其他线程若也想执行此方法,就会被阻塞,直到当前线程执行完毕并释放锁。
- ReentrantLock 类:这是 Java 并发包
java.util.concurrent.locks
中提供的一个可重入的互斥锁。使用时,需要先创建ReentrantLock
对象,然后在需要同步的代码块前后分别调用lock()
和unlock()
方法。通常,为了确保锁一定能被释放,会将unlock()
方法放在finally
块中。示例如下:
|
|
ReentrantLock
相较于synchronized
,提供了更灵活的锁控制,如可中断的锁获取、公平锁与非公平锁的选择等。
(三)优缺点及适用场景
- 优点:悲观锁能够严格保证数据的一致性和线程安全性,因为它通过加锁机制避免了多个线程同时修改共享资源的情况。
- 缺点
- 性能开销大:在高并发场景下,由于大量线程竞争锁资源,会导致频繁的线程阻塞和上下文切换。线程阻塞意味着线程需要等待锁,这期间线程处于空闲状态,白白占用系统资源;而上下文切换则是指操作系统在不同线程之间切换执行环境,这一过程需要保存和恢复线程的状态信息,也会消耗一定的时间和资源。
- 死锁风险:如果多个线程获取锁的顺序不当,就可能出现死锁现象。例如,线程 A 持有锁 1,等待获取锁 2,而线程 B 持有锁 2,等待获取锁 1,此时两个线程都无法继续执行,形成死锁,严重影响系统的正常运行。
- 适用场景:适用于写操作频繁、数据一致性要求极高且并发冲突可能性较大的场景。比如,在银行转账系统中,对账户余额的修改操作就需要使用悲观锁,以确保在同一时刻只有一个转账操作能对账户余额进行修改,避免出现数据不一致的情况。
二、乐观锁
(一)定义与原理
乐观锁秉持乐观的态度,总是假设共享资源在每次被访问时不会出现问题,线程可以自由地执行操作,无需加锁等待。它仅在提交修改时,才会去验证对应的共享资源是否被其他线程修改过。若未被修改,则提交成功;若已被修改,则重试操作,直至成功提交。这种策略就像是在一个相对和谐的环境中,大家都先假设自己的操作不会与他人冲突,各自先进行工作,最后再检查是否有冲突发生。
(二)Java 中的实现方式
-
原子变量类(基于 CAS 算法)
在 Java 的
java.util.concurrent.atomic
包中,提供了一系列原子变量类,如AtomicInteger
、AtomicLong
、LongAdder
等,它们都是通过 CAS(Compare And Swap,比较与交换)算法实现了乐观锁机制。- CAS 算法原理:CAS 操作涉及三个操作数,分别是要更新的变量值(Var)、预期值(Expected)和拟写入的新值(New)。当且仅当 Var 的值等于 Expected 时,CAS 才会通过原子方式用 New 值来更新 Var 的值。若两者不相等,说明在当前线程操作期间,已经有其他线程修改了 Var 的值,当前线程则放弃更新。例如,假设有一个
AtomicInteger
对象atomicInt
,其初始值为 5,线程 A 希望将其值更新为 10,此时线程 A 会先获取atomicInt
的当前值(即 5)作为预期值,然后尝试将其更新为 10。若在更新前没有其他线程修改atomicInt
的值,那么更新操作会成功;若有其他线程已经修改了atomicInt
的值,更新操作则失败,线程 A 可以选择再次尝试更新。 - LongAdder 类的特殊优化:在高并发场景下,
LongAdder
类相比AtomicInteger
和AtomicLong
具有更好的性能。LongAdder
内部采用了分段累加的方式,将对一个变量的操作分散到多个不同的单元格(Cell)中,每个线程在对变量进行操作时,会先访问不同的单元格,减少了线程间的竞争。不过,这种方式会消耗更多的内存空间,因为它需要为每个单元格分配内存,本质上是用空间换取时间。例如:
- CAS 算法原理:CAS 操作涉及三个操作数,分别是要更新的变量值(Var)、预期值(Expected)和拟写入的新值(New)。当且仅当 Var 的值等于 Expected 时,CAS 才会通过原子方式用 New 值来更新 Var 的值。若两者不相等,说明在当前线程操作期间,已经有其他线程修改了 Var 的值,当前线程则放弃更新。例如,假设有一个
|
|
在上述代码中,通过多个线程同时对LongAdder
对象sum
进行累加操作,展示了LongAdder
在高并发环境下的高效性。
- 版本号机制
在数据库操作中,常使用版本号机制来实现乐观锁。一般在数据表中添加一个数据版本号version
字段,用于表示数据被修改的次数。当数据被修改时,version
值会自动加 1。线程在读取数据的同时,也会读取version
值,在提交更新时,只有当读取到的version
值与当前数据库中的version
值相等时,才会执行更新操作;否则,会重试更新操作,直到更新成功。例如,假设有一个用户信息表user_info
,其中包含id
、name
、balance
和version
字段,当前有一条记录id = 1
,name = "张三"
,balance = 1000
,version = 1
。
- 线程 A 读取该记录,此时
version = 1
,并对balance
进行修改,将其值减少 100,即balance = 900
。 - 在线程 A 操作过程中,线程 B 也读取了该记录,
version
同样为 1,并对balance
进行修改,将其值减少 200,即balance = 800
。 - 线程 A 完成修改后,提交更新操作,此时会比对读取的
version
值(1)和数据库中当前记录的version
值(假设此时数据库中该记录的version
值仍为 1,因为线程 B 还未提交更新),两者相等,更新操作成功,数据库中该记录的version
值更新为 2,balance
值更新为 900。 - 线程 B 完成操作后,提交更新操作,此时比对读取的
version
值(1)和数据库中当前记录的version
值(2),两者不相等,更新操作失败,线程 B 需要重新读取数据并进行修改和提交,直到更新成功。
(三)优缺点及适用场景
- 优点
- 高并发性能优越:由于乐观锁在大多数情况下不需要加锁,线程可以自由执行,避免了线程阻塞和上下文切换带来的性能开销,因此在高并发且读操作远多于写操作的场景中,性能表现出色。
- 无死锁问题:乐观锁不需要显式地获取和释放锁,不存在因获取锁顺序不当而导致的死锁问题,提高了系统的稳定性。
- 缺点
- 频繁重试开销:在写操作频繁的场景下,由于数据被其他线程修改的概率较高,可能会导致大量的更新操作失败并需要重试,这会消耗大量的 CPU 资源,严重影响系统性能,甚至可能导致 CPU 使用率飙升。
- 适用范围有限:乐观锁主要适用于对单个共享变量的操作,对于涉及多个共享变量的复杂操作,使用乐观锁可能无法保证数据的一致性。
- 适用场景:适用于读多写少的场景,例如在一些缓存系统中,数据的读取操作远远多于写入操作,使用乐观锁可以在保证数据一致性的前提下,极大地提高系统的并发性能。
三、MySQL篇
1. MySQL中,如何定位慢查询?
在 MySQL 中,我们可以通过开启慢查询日志来定位执行缓慢的查询。具体操作是在 MySQL 的配置文件中设置 slow_query_log=1
来开启慢查询日志,并通过 long_query_time=2
参数设置慢查询的阈值,例如超过 2 秒。日志默认存放在 MySQL 数据目录下,文件名为 slow.log
。通过分析这个日志文件,我们可以识别出哪些查询操作的性能不佳,进而进行优化。"
除了慢查询日志,我们还可以使用 SQL Profile 工具来获取 SQL 语句的详细执行信息。在会话中开启 profiling 功能(SET profiling = 1;
),执行 SQL 语句后,通过 SHOW PROFILES;
命令可以查看该语句的执行细节,包括消耗的时间、扫描的行数等,这有助于我们分析和优化慢查询。
2. 那这个SQL语句执行很慢,如何分析呢?
如果一条SQL执行很慢,我们通常会使用MySQL的EXPLAIN
命令来分析这条SQL的执行情况。通过key
和key_len
可以检查是否命中了索引,如果已经添加了索引,也可以判断索引是否有效。通过type
字段可以查看SQL是否有优化空间,比如是否存在全索引扫描或全表扫描。通过extra
建议可以判断是否出现回表情况,如果出现,可以尝试添加索引或修改返回字段来优化。
3. 了解过索引吗
嗯,索引在项目中非常常见,它是一种帮助MySQL高效获取数据的数据结构,主要用来提高数据检索效率,降低数据库的I/O成本。同时,索引列可以对数据进行排序,降低数据排序的成本,也能减少CPU的消耗。
4. 索引的底层数据结构了解过吗?
MySQL的默认存储引擎InnoDB使用的是B+树作为索引的存储结构。选择B+树的原因包括:节点可以有更多子节点,路径更短;磁盘读写代价更低,非叶子节点只存储键值和指针,叶子节点存储数据;B+树适合范围查询和扫描,因为叶子节点形成了一个双向链表。
5. B树和B+树的区别是什么呢?
- B树的非叶子节点和叶子节点都存放数据,而B+树的所有数据只出现在叶子节点,这使得B+树在查询时效率更稳定。
- B+树在进行范围查询时效率更高,因为所有数据都在叶子节点,并且叶子节点之间形成了双向链表。
6. 什么是聚簇索引什么是非聚簇索引?
在数据库中,索引分为聚簇索引和非聚簇索引两种类型。聚簇索引,B+树的叶子节点保存了整行数据,通常只有一个聚簇索引,一般是由主键构成。非聚簇索引则存储的是数据的引用,B+树的叶子节点保存的是主键值,可以有多个非聚簇索引,通常我们自定义的索引都是非聚簇索引。
7. 知道什么是回表查询吗?
回表查询是指通过二级索引找到对应的主键值,然后再通过主键值查询聚簇索引中对应的整行数据的过程。
8. 知道什么叫覆盖索引吗?
覆盖索引是指在SELECT查询中,返回的列全部能在索引中找到,避免了回表查询,提高了性能。使用覆盖索引可以减少对主键索引的查询次数,提高查询效率。
9. MySQL超大分页怎么处理?
超大分页通常发生在数据量大的情况下,使用LIMIT
分页查询且需要排序时效率较低。可以通过覆盖索引和子查询来解决。首先查询数据的ID字段进行分页,然后根据ID列表用子查询来过滤只查询这些ID的数据,因为查询ID时使用的是覆盖索引,所以效率可以提升。
10. 索引创建原则有哪些?
创建索引的原则包括:
- 表中的数据量超过10万以上时考虑创建索引。
- 选择查询频繁的字段作为索引,如查询条件、排序字段或分组字段。
- 尽量使用复合索引,覆盖SQL的返回值。
- 如果字段区分度不高,可以将其放在组合索引的后面。
- 对于内容较长的字段,考虑使用前缀索引。
- 控制索引数量,因为索引虽然可以提高查询速度,但也会影响插入、更新的速度。
11. 什么情况下索引会失效?
索引失效可能发生在几种情况下。首先,如果查询条件没有遵循最左前缀法则,即没有从复合索引的最左边列开始,索引可能不会生效。其次,如果对字符串类型的索引列使用了以 %
开头的 LIKE
查询,索引也会失效。此外,如果在索引列上进行了运算或类型转换,比如数学运算或函数操作,索引同样会失效。对于复合索引,如果在索引的中间列使用了范围查询,那么该列右边的所有列索引都将失效。
12. SQL的优化经验有哪些?
- 建表时选择合适的字段类型。
- 使用索引,遵循创建索引的原则。
- 编写高效的SQL语句,比如避免使用
SELECT *
,尽量使用UNION ALL
代替UNION
,以及在表关联时使用INNER JOIN
。 - 查询优化:使用
EXPLAIN
分析查询执行计划,确保查询有效使用索引。 - 避免返回过多数据:使用覆盖索引,减少全表扫描。
- 在数据量大时考虑分库分表。
13. 创建表的时候,你们是如何优化的呢?
- 选择合适的数据类型:根据数据的特性选择最合适的数据类型,例如,对于小整数使用
TINYINT
,大整数使用INT
或BIGINT
,字符串根据长度选择CHAR
、、
VARCHAR或
TEXT`。 - 定义字符集:选择适当的字符集,如
utf8
,以支持多语言文本。 - 定义长度:对于可变长字段,定义合理的长度,避免不必要的空间浪费。
- 创建索引:为查询、排序或连接操作频繁的列创建索引,包括主键和外键。
- 规范化设计:应用数据库规范化原则,减少数据冗余,提高数据一致性。
- 分区:对于大数据量的表,考虑分区来提高查询和管理效率。
- 外键:使用外键来维护表之间的关系,确保引用完整性。
- 默认值:为具有默认值的列定义默认值。
- 避免冗余:设计表结构时避免重复存储相同数据。
- 性能测试:在开发环境中对表结构进行性能测试,确保查询和更新操作的效率。
14. 在使用索引的时候,是如何优化呢?
在使用索引时,我们遵循索引创建原则,确保索引字段是查询频繁的,使用复合索引覆盖SQL返回值,尽量避免导致回表查询的查询条件,以减少额外的 I/O 开销。
15. 你平时对SQL语句做了哪些优化呢?
首先,我会避免使用 SELECT *
,而是明确指定所需的字段,这有助于减少数据传输并提高查询效率。其次,我会确保在查询条件列上建立索引,并在查询中尽量使用这些索引。此外,我会优化 JOIN 操作,尽量使用 INNER JOIN
,因为它在内连接时会过滤不符合条件的记录,减少需要处理的数据量。如果必须使用 LEFT JOIN
或 RIGHT JOIN
,确保小表作为驱动表,即左连接的小表在左边,右连接的小表在右边。
我还会使用 LIMIT 进行分页查询,并尽量减少 OFFSET 的使用,以提高查询性能。同时,我会创建覆盖索引以减少回表查询的需要。此外,我会重写复杂的子查询为连接查询,减少查询的复杂度和执行时间。
16. 事务的特性是什么?可以详细说一下吗?
事务的特性是ACID,即原子性(Atomicity)、一致性(Consistency)、隔离性(Isolation)、持久性(Durability)。例如,A向B转账500元,这个操作要么都成功,要么都失败,体现了原子性。转账过程中数据要保持一致,A扣除了500元,B必须增加500元。隔离性体现在A向B转账时,不受其他事务干扰。持久性体现在事务提交后,数据要被持久化存储。
17. 并发事务带来哪些问题?
- 脏读(Dirty Read):
- 脏读发生在一个事务读取了另一个未提交事务修改的数据。如果该修改被回滚,那么读取的数据就是无效的。
- 不可重复读(Non-Repeatable Read):
- 这发生在一个事务中多次读取同一数据,由于其他事务的修改,导致读取的数据在事务内出现不一致。
- 幻读(Phantom Read):
- 幻读是指在一个事务中,两次读取同一范围的数据,第二次读取的结果包含了另一个并发事务提交的新数据。
- 丢失更新(Lost Update):
- 当两个或多个事务同时更新同一行数据时,一个事务的更新可能被另一个事务的更新覆盖,导致数据丢失。
- 死锁(Deadlock):
- 当两个或多个事务相互等待对方释放资源时,可能导致死锁,这会阻塞事务的进一步执行。
- 长时间运行的事务:
- 长时间运行的事务可能会锁定大量资源,这会阻塞其他事务,影响数据库性能。
- 系统资源竞争:
- 并发事务可能导致数据库的资源(如锁、日志空间、内存等)竞争,影响系统稳定性。
18. 怎么解决这些问题呢?MySQL的默认隔离级别是?
MySQL 的 InnoDB 存储引擎默认隔离级别是 可重复读(REPEATABLE READ)
- 脏读:可将事务隔离级别设置为 “读已提交(READ COMMITTED)” 及以上级别来防止,这样事务只能读取到已提交的数据,避免读取到未提交的脏数据。
- 不可重复读:通常需要将事务隔离级别设置为 “可重复读(REPEATABLE READ)” 或更高的 “串行化(SERIALIZABLE)” 级别。在 “可重复读” 隔离级别下,事务在第一次读取数据时会对数据加锁,在事务提交前,其他事务无法修改该数据,从而保证在同一事务内多次读取同一数据的结果是一致的。
- 幻读:通常将事务隔离级别设置为 “串行化(SERIALIZABLE)” 可避免,此级别会对事务操作的范围数据加锁,阻止其他事务在该范围内插入或删除数据,但会降低并发性能。
- 丢失更新:可使用排他锁或乐观锁机制,确保在更新数据时只有一个事务能成功执行更新操作,防止更新被覆盖。
- 死锁:合理设计事务逻辑,避免事务之间循环等待资源;也可设置死锁检测机制,当检测到死锁时自动回滚其中一个事务来打破死锁。
- 长时间运行的事务:优化事务逻辑,减少不必要的操作和资源占用;对于确实需要长时间运行的事务,可适当调整资源配置和数据库参数。
- 系统资源竞争:合理配置数据库资源,根据并发量等情况调整缓存大小、连接数等参数;优化事务执行顺序,减少资源竞争。
19. 事务是怎么实现的,主要通过哪些机制来确保其 ACID 特性?
事务的实现主要依赖于几个关键机制:锁机制、日志系统(包括 redo log 和 undo log),以及 MVCC(多版本并发控制)。这些机制共同协作以确保事务的 ACID 特性:原子性(Atomicity)、一致性(Consistency)、隔离性(Isolation)、持久性(Durability)。
- 锁机制:
- 锁机制用于控制数据的并发访问,确保在事务进行中数据不会被其他事务修改,从而满足隔离性。根据锁的粒度不同,可以是行级锁、表级锁等。
- redo log(重做日志):
- redo log 记录了事务对数据所做的修改。在系统崩溃后,可以通过重放这些日志来恢复数据,从而满足持久性要求。
- undo log(回滚日志):
- undo log 记录了事务执行前的数据状态,用于在事务回滚时恢复到事务开始前的状态,满足原子性和隔离性。
- MVCC(多版本并发控制):
- MVCC 是一种并发控制机制,它通过为每个事务提供一个数据的一致性视图来满足隔离性。这样,即使数据被其他事务修改,当前事务也看不到这些修改,从而避免了脏读和不可重复读的问题。
20. undo log和redo log的区别是什么?
undo log
(撤销日志)和redo log
(重做日志)
undo log
主要用于事务回滚时恢复数据,它记录了事务更改之前的数据状态,确保我们可以撤销事务对数据的修改,保持数据的一致性。这有助于维护事务的原子性,即事务要么完全执行,要么完全不执行。
相反,redo log
用于在系统故障后恢复数据,它记录了事务提交时的数据更改,确保这些更改在系统重启后可以被重新应用,从而保障事务的持久性。这有助于确保即使在系统崩溃的情况下,已提交的事务更改也不会丢失。
21. 那事务的隔离级别有哪些,它们有什么区别?
事务的隔离级别通常有四种,由低到高分别是:读未提交(Read Uncommitted)、读已提交(Read Committed)、可重复读(Repeatable Read)、串行化(Serializable)。
- 读未提交:允许事务读取其他事务未提交的数据,可能会导致脏读。
- 读已提交:只能读取已经提交的数据,避免了脏读,但可能会出现不可重复读。
- 可重复读:保证了在同一事务中多次读取同样的数据结果是一致的,避免了不可重复读,但可能会出现幻读。
- 串行化:最高的隔离级别,通过强制事务串行执行来避免脏读、不可重复读和幻读,但性能开销也最大。
22. 事务中的隔离性是如何保证的呢?(你解释一下MVCC)
事务的隔离性是指当多个事务并发执行时,数据库如何管理这些事务对数据的访问,以防止数据不一致性的问题。MySQL 使用多版本并发控制(MVCC)来提供事务隔离。
事务的隔离性通过锁和多版本并发控制(MVCC)来保证。MVCC通过维护数据的多个版本来避免读写冲突。底层实现包括隐藏字段、undo log
和read view
。隐藏字段包括trx_id
和roll_pointer
。trx_id,它是一个事务ID,用于标识对数据行进行更改的事务。undo log
记录了不同版本的数据,通过roll_pointer
形成版本链。read view
定义了不同隔离级别下的快照读,决定了事务访问哪个版本的数据。
23. MySQL主从同步原理是什么?
MySQL的主从同步是一种数据库复制技术,它允许将一个数据库服务器(主库)的数据变更复制到另一个或多个服务器(从库)上。
MySQL主从复制的核心是二进制日志(Binlog)。步骤如下:
- 主库在事务提交时记录数据变更到Binlog。
- 从库连接到主库并读取 Binlog,将 Binlog 中的事件写入到自己的中继日志(Relay Log)。
- 从库根据中继日志中的事件,重放(Replay)这些事件到自己的数据中,从而实现数据的同步。
24. 你们项目用过MySQL的分库分表吗?
垂直分库:垂直分库是按照业务将不同的表拆分到不同的数据库中
我们采用微服务架构,每个微服务对应一个数据库,是根据业务进行拆分的,这个其实就是垂直拆分。
水平分库:水平分库是将数据按照一定的规则(如用户 ID 取模、哈希等)分布到不同的数据库中。比如,根据用户 ID 对 10 取模,将用户数据分布到 10 个不同的数据库中,每个数据库都保存着完整的数据表结构
垂直分表:垂直分表是将一张表按照列的相关性拆分成多张表。例如,将一个包含大量字段的用户表,拆分为用户基本信息表和用户扩展信息表。垂直分表适合表中存在不常用并且占用了大量空间的表拆分出去。
水平分表:水平分表是将一张表的数据按照行进行拆分。例如按照用户 ID 的范围或者哈希值将数据拆分到不同的表中。水平分表就适合用户表行数很多的情况下,一般单表行数超过5000万就得分表,如果单表的数据比较复杂那可能2000万甚至1000万就得分了。
四、Redis
一、Redis 基础数据结构(五种)
(一)String(字符串)
- 结构特点:Redis 中最基础的数据类型,可存储字符串、整数或浮点数,最大能存储 512MB 的数据。在内存中以简单动态字符串(SDS)形式存储,相比传统 C 字符串,SDS 具有获取长度时间复杂度为 O (1)、杜绝缓冲区溢出等优势。
- 应用场景
- 数据缓存:常用来缓存各类常规数据,像用户的 session 信息、用于身份验证的 token、序列化后的对象等。通过将这些数据缓存于 Redis,可显著减少数据库查询次数,提升系统响应速度。例如在 Web 应用中,频繁访问的用户信息可先从 Redis 的 String 类型缓存中获取,若不存在再查询数据库并写入缓存。
- 计数场景:借助 INCR、INCRBY 等命令,可方便地对数值进行原子递增操作,用于统计用户单位时间内的请求数、页面单位时间的访问数等。比如在一个简单限流系统中,可设定用户每分钟最多请求 100 次,通过对用户请求计数(存储在 String 类型键中)与阈值比较,超过则限流。
- 分布式锁:利用 SETNX(SET if Not eXists)命令可实现简易分布式锁。当一个线程执行 SETNX key value 时,若键不存在,会设置成功并获取锁;若键已存在,获取锁失败。释放锁时可通过删除该键实现。但这种简单实现未处理锁超时等复杂情况,实际应用中需完善。
- 共享配置信息:存储应用程序的配置参数,如数据库连接字符串、系统开关配置等。应用启动时从 Redis 读取配置,配置变更时可实时更新 Redis 中的值,应用通过监听机制获取变更,实现配置动态更新。
- 与 Hash 存储对象对比
- 存储方式:String 存储的是序列化后的整个对象,比如将一个包含多个字段的 Java 对象序列化为 JSON 字符串后存储。Hash 则是将对象的每个字段作为一个键值对单独存储,例如存储一个用户对象,Hash 可将用户的姓名、年龄、邮箱等字段分别以 “field:value” 形式存储。
- 内存占用:一般情况下,缓存相同数量的对象数据,String 消耗内存约为 Hash 的一半。因为 Hash 结构本身会有额外开销用于存储字段名等信息。但如果对象字段较少,这种内存差异可能不明显。
- 查询与修改灵活性:String 适合对象整体读取和更新场景。若要获取或修改对象部分字段,需先反序列化整个对象,操作后再序列化存储,性能开销大。Hash 可直接对单个字段进行查询、修改、添加操作,无需处理整个对象,在购物车这类商品频繁变动场景中优势明显。例如购物车中商品数量、添加新商品等操作,使用 Hash 可高效完成。
- 建议:大多数场景下,若对对象操作以整体为主,或系统对内存资源敏感,优先使用 String 存储对象数据。但对对象部分字段频繁操作的场景,Hash 更合适。
(二)List(列表)
- 结构特点:按插入顺序排序的字符串链表,可在链表头部(LPUSH)或尾部(RPUSH)插入元素,也可从头部(LPOP)或尾部(RPOP)删除元素。支持获取指定范围元素(LRANGE),时间复杂度为 O (S + N),S 为起始位置偏移量,N 为返回元素数量。
- 应用场景
- 消息队列:可作为简单消息队列使用。生产者通过 RPUSH 向列表尾部发送消息,消费者使用 LPOP 从列表头部读取消息,实现消息的有序处理。但 Redis 原生 List 作为消息队列缺乏一些高级特性,如消息持久化、ACK 机制等,适用于对消息可靠性要求不高的简单场景。
- 最新消息展示:用于存储最新发布的消息、文章等内容。例如在社交媒体应用中,用户发布的动态可按时间顺序通过 RPUSH 存入 List,展示时使用 LRANGE 获取最新的若干条动态。
- 操作日志记录:记录系统操作日志,每次操作相关信息(如操作时间、操作人、操作内容)作为一个元素,通过 RPUSH 追加到 List 中。后续可根据需要查询特定时间段或特定类型的操作记录。
- 相关命令
- LPUSH key value [value…]:将一个或多个值插入到列表头部。
- RPUSH key value [value…]:将一个或多个值插入到列表尾部。
- LPOP key:移除并返回列表的头元素。
- RPOP key:移除并返回列表的尾元素。
- LRANGE key start stop:返回列表中指定区间内的元素,start 和 stop 为元素索引,0 表示第一个元素, - 1 表示最后一个元素。
(三)Set(集合)
- 结构特点:无序的字符串集合,集合中元素具有唯一性,重复添加相同元素不会产生新副本。内部实现基于哈希表,添加、删除、查找元素的时间复杂度平均为 O (1)。
- 应用场景
- 去重场景:在数据处理中,可利用 Set 的唯一性对数据进行去重。例如统计网站访问用户的唯一 IP 地址,每次将访问 IP 通过 SADD 命令添加到 Set 中,最终 Set 的元素数量即为唯一 IP 数量。
- 标签管理:用于管理对象标签。如在一个商品管理系统中,每个商品可关联多个标签(如电子产品、打折商品等),将商品 ID 作为 key,标签作为 Set 的元素存储。通过 SISMEMBER 命令可判断商品是否具有某个标签,通过 SMEMBERS 可获取商品所有标签。
- 交集、并集、差集运算:在社交应用中,可通过集合运算实现共同关注、共同爱好等功能。例如,用户 A 的关注列表和用户 B 的关注列表作为两个 Set,通过 SINTER 命令求交集,可得到 A 和 B 共同关注的人。
- 相关命令
- SADD key member [member…]:向集合中添加一个或多个成员。
- SREM key member [member…]:移除集合中的一个或多个成员。
- SISMEMBER key member:判断成员是否在集合中,存在返回 1,不存在返回 0。
- SMEMBERS key:返回集合中的所有成员。
- SINTER key [key…]:返回多个集合的交集。
- SUNION key [key…]:返回多个集合的并集。
- SDIFF key [key…]:返回多个集合的差集(第一个集合减去其他集合的元素)。
(四)Hash(散列)
- 结构特点:由键值对组成的集合,适合存储对象。每个键值对的键和值都是字符串类型。内部采用哈希表存储,在查找、插入、删除单个字段时,时间复杂度平均为 O (1)。
- 应用场景
- 存储对象:如用户信息、商品信息等对象的存储。以用户 ID 为 key,用户的各个属性(姓名、年龄、地址等)为 field,对应的值为 value。与 String 存储对象相比,Hash 可灵活操作单个字段,无需序列化和反序列化整个对象。
- 购物车系统:购物车场景中,以用户 ID 为 key,商品 ID 为 field,商品数量为 value。方便对购物车中商品进行添加、修改数量、删除等操作。例如,用户添加商品时,若商品已存在,使用 HINCRBY 命令增加商品数量;删除商品时,使用 HDEL 命令。
- 配置管理:存储应用程序的配置信息,与 String 存储配置相比,Hash 可对单个配置项进行修改,无需重新设置整个配置字符串。如数据库连接配置,可将主机、端口、用户名、密码等字段分别存储在 Hash 中。
- 相关命令
- HSET key field value:为哈希表中的字段赋值。
- HGET key field:获取哈希表中指定字段的值。
- HDEL key field [field…]:删除哈希表中的一个或多个字段。
- HINCRBY key field increment:为哈希表中的字段值增加指定的整数。
- HGETALL key:获取哈希表中的所有字段和值。
(五)Zset(有序集合)
- 结构特点:每个成员都关联一个分数的字符串集合,通过分数对成员进行从小到大排序,成员唯一但分数可重复。内部通过跳跃表和哈希表两种数据结构实现,在插入、删除、查找元素以及范围查询方面性能较好,时间复杂度为 O (log N),N 为集合元素数量。
- 应用场景
- 排行榜系统:常用于各种排行榜场景,如直播间送礼物排行榜、游戏玩家积分排行榜、商品销量排行榜等。将用户 ID 或商品 ID 作为成员,对应的分数(如礼物价值、玩家积分、商品销量)作为排序依据。通过 ZRANGE(从小到大)或 ZREVRANGE(从大到小)命令获取排行榜数据。
- 带权重的任务队列:在任务调度系统中,每个任务可关联一个权重(分数),根据权重决定任务执行顺序。通过 ZADD 命令添加任务及权重,通过 ZPOPMIN 或 ZPOPMAX 命令获取并移除权重最小或最大的任务。
- 时间序列数据:如股票价格随时间变化数据,时间作为分数,股票价格作为成员。可方便地查询某个时间段内的股票价格数据,通过 ZRANGEBYSCORE 命令实现。
- 相关命令
- ZADD key score member [score member…]:向有序集合中添加一个或多个成员,或更新已存在成员的分数。
- ZRANGE key start stop [WITHSCORES]:返回有序集合中指定范围内的成员,按分数从小到大排序,WITHSCORES 选项可同时返回成员的分数。
- ZREVRANGE key start stop [WITHSCORES]:返回有序集合中指定范围内的成员,按分数从大到小排序,WITHSCORES 选项可同时返回成员的分数。
- ZREVRANK key member:返回有序集合中指定成员的排名(从大到小排序)。
- ZRANGEBYSCORE key min max [WITHSCORES] [LIMIT offset count]:返回有序集合中分数在指定区间内的成员,可通过 LIMIT 选项指定返回结果的偏移量和数量。
二、Redis 特殊数据结构(八种)
(一)HyperLogLogs(基数统计)
- 结构特点:用于近似统计集合中唯一元素的数量,通过概率算法实现,在内存使用上极为高效。只需少量内存(12KB)即可统计大量数据,误差率约为 0.81%。内部通过稀疏矩阵和稠密矩阵存储数据。
- 应用场景
- 网页 UV 统计:统计网页的独立访客数。每次用户访问网页时,将用户 ID 通过 PFADD 命令添加到对应的 HyperLogLog 中,最后使用 PFCOUNT 命令获取 UV 数量。相比传统存储每个用户 ID 再统计唯一值的方式,HyperLogLog 大大节省内存。
- 广告曝光统计:在广告投放系统中,统计广告的独立曝光次数。将每次广告曝光的设备 ID 或用户 ID 记录到 HyperLogLog 中,可高效获取独立曝光数,为广告效果评估提供数据支持。
- 相关命令
- PFADD key element [element…]:向 HyperLogLog 中添加一个或多个元素。
- PFCOUNT key [key…]:返回一个或多个 HyperLogLog 的近似基数。
- PFMERGE destkey sourcekey [sourcekey…]:将多个 HyperLogLog 合并为一个。
(二)Bitmap(位存储)
- 结构特点:基于字符串类型实现,通过位操作来存储和处理数据。每个字符串可存储 2^32 - 1 个位,即 512MB 大小的位数据。通过 SETBIT 命令设置某位的值(0 或 1),GETBIT 命令获取某位的值。
- 应用场景
- 用户活跃状态统计:以日期(精确到天)作为 key,用户 ID 为 offset。当用户在当天活跃过时,使用 SETBIT 命令将对应位置设置为 1。例如,统计 2023 年 10 月 1 日活跃用户,SETBIT 20231001 user_id 1。通过 BITCOUNT 命令可统计当天活跃用户数量,通过 BITOP 命令可进行位运算,如统计连续多日活跃用户。
- 权限管理:用 Bitmap 表示用户权限,每个权限对应一位。例如,用户具有读取权限,对应位设为 1;无写入权限,对应位设为 0。通过位运算可快速判断用户是否具有某组权限,如判断用户是否同时具有读取和执行权限。
- 相关命令
- SETBIT key offset value:设置或清除指定 key 偏移量上的位值(0 或 1)。
- GETBIT key offset:获取指定 key 偏移量上的位值。
- BITCOUNT key [start end]:统计指定 key 中位值为 1 的数量,start 和 end 可选,用于指定字节范围。
- BITOP operation destkey key [key…]:对一个或多个 key 进行位运算(AND、OR、XOR、NOT),结果存储在 destkey 中。
(三)Geospatial(地理位置)
- 结构特点:用于存储地理位置信息,并支持基于地理位置的查询。本质上是通过 Zset 实现,将地理位置的经纬度信息编码后作为分数存储,地理位置标识(如城市名、店铺名)作为成员。支持查询附近位置、计算距离等操作。
- 应用场景
- 附近位置查询:在地图应用、外卖配送、共享单车等场景中,用于查找用户附近的商家、配送员、共享单车等。例如,外卖应用中,用户可通过该功能查找附近可配送的餐厅,餐厅位置信息事先存储在 Redis 的 Geospatial 结构中,通过 GEORADIUS 命令查询。
- 距离计算:计算两个地理位置之间的距离。如在物流配送中,计算仓库与配送地址的距离,为配送路线规划提供数据支持。通过 GEODIST 命令实现距离计算。
- 相关命令
- GEOADD key longitude latitude member [longitude latitude member…]:将一个或多个地理位置信息添加到指定 key 中。
- GEODIST key member1 member2 [m|km|ft|mi]:计算两个地理位置之间的距离,单位可指定为米(m)、千米(km)、英尺(ft)、英里(mi)。
- GEORADIUS key longitude latitude radius m|km|ft|mi [WITHCOORD] [WITHDIST] [WITHHASH] [COUNT count]:以指定经纬度为中心,返回指定半径内的地理位置,可选择返回地理位置的坐标、距离、哈希值,COUNT 选项可限制返回结果数量。
- GEORADIUSBYMEMBER key member radius m|km|ft|mi [WITHCOORD] [WITHDIST] [WITHHASH] [COUNT count]:与 GEORADIUS 类似,只是以已存在的地理位置成员为中心进行查询。
1. 什么是缓存穿透?怎么解决?
嗯,我想一下。缓存穿透是指查询一个一定不存在的数据,由于存储层查不到数据因此不写入缓存,这将导致这个不存在的数据每次请求都要到 DB 去查询,可能导致 DB 挂掉。这种情况大概率是遭到了攻击。解决方案的话,我们通常都会用布隆过滤器来解决它。
解决方法1:缓存空对象
当查询数据库中不存在的数据时,我们不是直接返回空,而是将一个空对象(比如一个空的JSON对象或者一个标记值)存入缓存,并设置一个较短的TTL(存活时间)。这样,当下次再查询相同的数据时,可以直接从缓存中获取到这个空对象,而无需再次查询数据库,从而避免了对数据库的无效访问。
**缺点:**虽然实现简单,但它会带来一些额外的内存消耗,因为即使是空对象,也需要占用一定的缓存空间。此外,由于设置了TTL,在缓存过期之前,即使数据库中已经添加了相应的数据,查询时仍然会返回空对象,这可能会导致短期内的数据不一致。
解决方法2:布隆过滤器
布隆过滤器是一种概率型数据结构,它可以用来判断一个元素是否在一个集合中。我们当时使用的是Redisson实现的布隆过滤器。它的底层原理是,先初始化一个比较大的数组,里面存放的是二进制0或1。一开始都是0,当一个key来了之后,经过3次hash计算,模数组长度找到数据的下标,然后把数组中原来的0改为1。这样,三个数组的位置就能标明一个key的存在。当需要判断一个元素是否存在时,通过相同的哈希函数计算出该元素在位数组上的位置,如果这些位置上的值都为1,则认为该元素可能存在;如果任何一个位置上的值为0,则该元素一定不存在。在缓存穿透的场景中,我们可以将数据库中存在的数据对应的key存入布隆过滤器,当查询一个key时,先通过布隆过滤器判断该key是否存在,如果不存在,直接返回,避免查询数据库。
优点:布隆过滤器的内存占用相对较少,因为它只需要一个位数组来存储信息,相比直接缓存大量的key值,节省了很多空间。而且它没有多余的key,不会像缓存空对象那样占用额外的缓存资源。
缺点:布隆过滤器的实现相对复杂,需要理解和使用多个哈希函数以及位数组等数据结构。而且它存在误判的可能,即当布隆过滤器判断一个key存在时,实际上该key可能并不存在,这就增加了穿透的风险。另外,布隆过滤器无法删除数据,一旦一个key被添加到布隆过滤器中,就无法单独将其移除,这在某些场景下可能会带来一些问题。
2. 什么是缓存击穿?怎么解决?
缓存击穿问题,也被称为热点 Key 问题。它是指在高并发场景下,一个被频繁访问且缓存重建业务较复杂的 Key 突然失效了,导致大量的请求直接穿透到数据库,给数据库带来巨大的冲击,从而引发系统性能下降甚至崩溃的情况。
解决方法1:基于互斥锁的解决方案
就是当线程查询缓存未命中时,尝试去获取互斥锁,然后在重建缓存数据,在这段时间里,其他线程也会去尝试获取互斥锁,如果失败就休眠一段时间,并继续,不断重试,等到数据重建成功,其他线程就可以命中数据了。这样就不会导致缓存击穿。
这里我们获取互斥锁可以使用redis中string类型中的setnx方法 ,因为setnx方法是在key不存在的情况下才可以创建成功的,所以我们重建缓存时,使用setnx来将锁的数据加入到redis中,并且通过判断这个锁的key是否存在,如果存在就是获取锁成功,失败就是获取失败,这样刚好可以实现互斥锁的效果。释放锁就更简单了,直接删除我们存入的锁的 Key 来释放锁。
优点:
**内存占用小:**由于不需要额外存储过期时间等信息,内存占用相对较少。
**一致性高:**所有线程最终都能获取到最新的缓存数据,数据一致性得到了保证。
**实现简单:**通过 Redis 的 SETNX
方法和简单的逻辑控制,就可以实现互斥锁,实现起来相对容易。
缺点:
**性能较低:**多个线程在获取锁失败后会不断重试,导致线程阻塞,降低了系统的并发性能。
**容易出现死锁:**如果在获取锁后,线程出现异常或长时间未释放锁,可能会导致其他线程一直等待,从而出现死锁的情况。
解决方法2:基于逻辑过期解决缓存击穿问题
原理:给 Redis 缓存字段中添加一个过期时间字段,而不是直接设置缓存的过期时间。当线程查询缓存的时候,先判断是否已经过期。如果过期,就获取互斥锁,并开启一个子线程进行缓存重建任务,直到子线程完成任务后,释放锁。在这段时间内,其他线程获取互斥锁失败后,并不是继续等待重试,而是直接返回旧数据。
实现方式:所谓的逻辑过期,类似于逻辑删除,并不是真正意义上的过期,而是新增一个字段,用来标记 Key 的过期时间。这样能够避免 Key 过期而被自动删除,从而使数据永不过期,从根本上解决因为热点 Key 过期导致的缓存击穿。一般在搞活动时,比如抢优惠券、秒杀等场景,请求量比较大就可以使用逻辑过期,等活动一过就手动删除逻辑过期的数据。逻辑过期一定要先进行数据预热,将热点数据加载到缓存中。逻辑过期时间根据具体业务而定,逻辑过期过长,会造成缓存数据的堆积,浪费内存;过短会造成频繁缓存重建,降低性能。所以设置逻辑过期时间时需要实际测试和评估不同参数下的性能和资源消耗情况,可以通过观察系统的表现,在业务需求和性能要求之间找到一个平衡点。
优点:
性能高:通过开启子线程重建缓存,使原来的同步阻塞变成异步,提高了系统的响应速度,能够更好地应对高并发场景。
缺点:
内存占用较大:需要额外存储过期时间字段,增加了内存的占用。
容易出现脏读:在缓存重建期间,其他线程可能会获取到旧数据,从而导致脏读的情况。
3. 什么是缓存雪崩?怎么解决?
缓存雪崩是指在缓存系统中,大量缓存 Key 同时失效,或者缓存服务(如 Redis)整体宕机,导致原本应该从缓存获取的数据全部穿透到数据库,从而给数据库带来巨大的压力,甚至可能导致数据库崩溃。这种情况通常会引发整个系统的性能问题,甚至导致服务不可用。
具体来说,缓存雪崩可以分为两种情况:
- 大量 Key 同时失效:如果缓存中的 Key 都设置了相同的过期时间,那么在某一时刻,这些 Key 会同时失效。此时,大量请求会同时查询数据库,导致数据库负载瞬间增加。
- 缓存服务宕机:如果整个缓存服务(如 Redis 集群)宕机,所有原本依赖缓存的请求都会直接查询数据库,同样会给数据库带来巨大压力。
解决方案1: 给不同的 Key 设置随机的 TTL
为了避免大量 Key 同时失效,可以在设置缓存时,为每个 Key 的 TTL(过期时间)添加一个随机值。例如,原本的过期时间是 10 分钟,可以在此基础上随机增加 1-5 分钟。这样,每个 Key 的过期时间就会分散开来,很难同时失效。
解决方案2: 利用 Redis 集群提高服务的可用性
通过部署 Redis 集群,将缓存数据分散到多个 Redis 实例中。即使某个 Redis 实例宕机,其他实例仍然可以正常工作,从而提高缓存服务的整体可用性。
解决方案3: 添加降级限流策略
在缓存层和数据库层之间添加降级限流策略,当请求量过高时,通过快速失败机制直接返回错误或默认值,避免请求穿透到数据库。
解决方案4: 添加多级缓存
在应用层添加多级缓存,除了本地缓存(如 Ehcache)和分布式缓存(如 Redis)外,还可以在数据库层添加缓存(如数据库的查询缓存)。这样,即使分布式缓存失效,还可以通过本地缓存或数据库缓存来缓解压力。
实现方式:
- 在应用层使用本地缓存(如 Ehcache)作为第一级缓存。
- 在分布式缓存(如 Redis)作为第二级缓存。
- 在数据库层使用查询缓存作为第三级缓存。
4. redis作为缓存,mysql的数据如何与redis进行同步呢?(双写一致性)
我们当时采用了读写锁来保证强一致性。具体来说,我们使用了 Redisson 实现的读写锁。
在读取数据时,我们添加共享锁,这样可以保证读读不互斥,但读写互斥。这意味着多个线程可以同时读取数据,但如果有线程正在写入数据,其他线程就不能读取,从而避免了脏读。而在更新数据时,我们添加排他锁,这样无论是读操作还是写操作,都会被互斥,确保在写数据的同时,不会有其他线程读取数据,进一步避免了脏数据。
这里面需要注意的是,读方法和写方法上需要使用同一把锁才行。这样可以确保在任何时刻,数据的一致性都能得到保证。
5. 那这个排他锁是如何保证读写、读读互斥的呢?
其实排他锁底层使用的是 SETNX
,它保证了同时只能有一个线程操作锁住的方法。当一个线程获取了排他锁后,其他线程无论是尝试读取还是写入,都会被阻塞,直到锁被释放。
5. 你听说过延时双删吗?为什么不用它呢?
延迟双删,如果是写操作,我们先把缓存中的数据删除,然后更新数据库,最后再延时删除缓存中的数据。其中,这个延时多久不太好确定。在延时的过程中,可能会出现脏数据,并不能保证强一致性,所以没有采用它。
6. Redis 数据的持久化是怎么做的?
候选人:在 Redis 中提供了两种主要的数据持久化方式:RDB(Redis Database)和 AOF(Append Only File)。
7. 这两种持久化方式有什么区别呢?
候选人:RDB 是一个快照文件,它会定期将 Redis 内存中的数据写入到磁盘上的一个 RDB 文件中。当 Redis 实例宕机后,可以从 RDB 文件中恢复数据。RDB 文件是二进制格式的,体积相对较小,恢复速度较快,但可能会丢失最后一次快照之后的数据。
AOF 是追加文件,它会将 Redis 执行的每个写命令追加到 AOF 文件中。当 Redis 实例宕机后,可以从 AOF 文件中重新执行这些命令来恢复数据。AOF 文件是文本格式的,体积相对较大,恢复速度较慢,但数据丢失的风险更小。
8. 这两种方式,哪种恢复的比较快呢?
候选人:RDB 恢复速度更快,因为它是一个二进制文件,体积较小,加载和恢复数据的速度通常比 AOF 快。然而,RDB 的缺点是可能会丢失最后一次快照之后的数据。AOF 恢复速度较慢,因为它需要重新执行文件中的所有命令来恢复数据,但它的数据完整性更高,丢失数据的风险更小。
在实际项目中,我们通常会结合使用 RDB 和 AOF 来实现数据的持久化。例如,我们会在项目中设置 RDB 每隔一定时间(如每小时)生成一次快照,同时开启 AOF 并设置为每秒批量写入一次命令。这样可以在保证数据恢复速度的同时,最大限度地减少数据丢失的风险。
9. 你们项目中具体是如何设置的呢?
候选人:在我们的项目中,我们采用了以下配置:
-
RDB 配置:
-
我们设置了 RDB 每小时生成一次快照,这样可以在 Redis 实例宕机时,从最近的快照文件中恢复大部分数据。
-
配置示例:
1
save 3600 1
-
-
AOF 配置:
-
我们开启了 AOF,并设置了每秒批量写入一次命令,这样可以在 Redis 实例宕机时,从 AOF 文件中恢复最近的写操作。
-
配置示例:
1 2
appendonly yes appendfsync everysec
-
通过这种配置,我们既保证了数据恢复的速度,又最大限度地减少了数据丢失的风险。在实际运行中,这种组合方式表现出了良好的性能和可靠性。
10. Redis的数据过期策略有哪些?
候选人:嗯~,在redis中提供了两种数据过期删除策略。第一种是惰性删除。在设置该key过期时间后,我们不去管它。当需要该key时,我们检查其是否过期。如果过期,我们就删掉它;反之,返回该key。第二种是定期删除。就是说,每隔一段时间,我们就对一些key进行检查,并删除里面过期的key。定期清理的两种模式是:1) SLOW模式,是定时任务,执行频率默认为10hz,每次不超过25ms,可以通过修改配置文件redis.conf的hz选项来调整这个次数;2) FAST模式,执行频率不固定,每次事件循环会尝试执行,但两次间隔不低于2ms,每次耗时不超过1ms。Redis的过期删除策略是:惰性删除 + 定期删除两种策略配合使用。
11. Redis的数据淘汰策略有哪些?
候选人:嗯,这个在redis中提供了很多种,默认是noeviction,不删除任何数据,内部不足时直接报错。这个可以在redis的配置文件中进行设置。里面有两个非常重要的概念:一个是LRU,另外一个是LFU。LRU的意思就是最少最近使用。它会用当前时间减去最后一次访问时间。这个值越大,则淘汰优先级越高。LFU的意思是最少频率使用。它会统计每个key的访问频率。值越小,淘汰优先级越高。我们在项目中设置的是allkeys-lru,它会挑选最近最少使用的数据进行淘汰,把一些经常访问的key留在redis中。
12. 数据库有1000万数据,Redis只能缓存20w数据。如何保证Redis中的数据都是热点数据?
候选人:嗯,我想一下()。可以使用allkeys-lru(挑选最近最少使用的数据淘汰)淘汰策略。那留下来的都是经常访问的热点数据。
13. Redis的内存用完了会发生什么?
候选人:嗯~,这个要看redis的数据淘汰策略是什么。如果是默认的配置,redis内存用完以后则直接报错。我们当时设置的是allkeys-lru策略,把最近最常访问的数据留在缓存中。
14. Redis分布式锁如何实现
在Redis中提供了一个命令SETNX
(SET if not exists)。由于Redis是单线程的,使用这个命令之后,只能有一个客户端对某一个key设置值。在没有过期或删除key的时候,其他客户端是不能设置这个key的。
15. 那你如何控制Redis实现分布式锁的有效时长呢?
SETNX
指令不好控制有效时长。我们采用的是Redisson框架实现的。在Redisson中需要手动加锁,并且可以控制锁的失效时间和等待时间。当锁住的一个业务还没有执行完成时,Redisson会引入一个看门狗机制,每隔一段时间检查当前业务是否还持有锁。如果持有,就增加加锁的持有时间。当业务执行完成之后,需要使用释放锁。在高并发下,一个业务执行很快时,客户1持有锁,客户2来了以后并不会马上被拒绝,而是自旋不断尝试获取锁。如果客户1释放之后,客户2就可以马上持有锁,性能也得到了提升。
16. Redisson实现的分布式锁是可重入的吗?它是怎么实现的?
是的,Redisson 实现的分布式锁是可重入的。可重入锁允许同一个线程多次获取同一把锁而不会被阻塞,这可以有效避免死锁问题,同时让代码逻辑更清晰。
Redisson 如何实现可重入锁
Redisson 是基于 Redis 的哈希结构来存储锁信息的。打个比方,我们有个叫 “myLock” 的锁,这就是锁的唯一标识,相当于哈希结构里的 Key。而每个尝试获取锁的线程都有自己唯一的标识,像线程 ID 或者 UUID,这就是哈希结构里的 Field。线程获取锁的次数,也就是重入次数,就是哈希结构里的 Value。
加锁过程
当一个线程想去获取锁的时候,Redisson 首先会检查这个锁对应的 Key 存不存在。要是不存在,那就说明当前没有线程持有这把锁,Redisson 就会创建这个锁,把 Field 设成当前线程的标识,Value 设为 1,同时给锁设置一个过期时间。要是锁已经存在,Redisson 就会去检查 Field 是不是和当前线程的标识一样。如果一样,那就意味着当前线程已经持有这把锁了,Redisson 就把 Value 加 1,并且刷新锁的过期时间。要是不一样,那就表示锁被其他线程占着,当前线程就得等着锁被释放。
释放锁过程
释放锁的时候,Redisson 会先看看锁的 Field 和当前线程标识是不是一致。如果一致,就把 Value 减 1。要是减完之后 Value 变成 0 了,那就说明当前线程已经完全释放了这把锁,Redisson 就把锁对应的 Key 删除。要是 Value 还大于 0,说明当前线程还有重入的情况,还持有锁,Redisson 就刷新一下锁的过期时间。
防止死锁
Redisson 在防止死锁方面也有很实用的机制。一方面,加锁的时候会给锁设置过期时间,就算某个线程出问题了,一直不释放锁,到时间了锁也会自动被删除。另一方面,它的可重入机制也能避免因为线程嵌套调用导致的死锁。
可重入锁
Redisson 的可重入锁优势也很明显。从线程安全角度看,只有持有锁的线程才能释放锁,这就保证了不会出现线程安全问题。在性能上,它通过 Lua 脚本确保加锁和释放锁的操作是原子性的,避免了竞态条件,效率很高。
17. Redisson实现的分布式锁能解决主从一致性的问题吗?
不能完全解决。在Redis的主从复制模式下,主节点和从节点之间存在数据同步的延迟。当主节点宕机时,从节点可能会被提升为新的主节点,但此时锁数据可能尚未完全同步,导致锁失效。
例如,当线程1在主节点上加锁成功后,主节点数据还未完全同步到从节点。如果此时主节点宕机,从节点被提升为新的主节点,线程2可能会在新的主节点上加锁成功,导致两个线程同时持有一把锁。
18. 如果业务非要保证数据的强一致性,这个该怎么解决呢?
- 使用Redisson的
multiLock
Redisson提供了multiLock
方案来解决主从一致性问题。其核心思想是:
- 在多个独立的Redis节点上分别创建锁。
- 只有当在所有指定的Redis节点上都成功获取锁时,才算获取锁成功。
这种方式的优点是可以避免主从同步不一致时锁失效的问题。但缺点是运维成本高,实现复杂,且至少需要三台Redis服务器。
- 使用其他分布式锁实现
如果业务对数据一致性要求极高,建议使用其他分布式锁实现,如基于ZooKeeper的分布式锁。ZooKeeper的分布式锁可以保证强一致性,适用于对一致性要求严格的应用场景。
19. Redis集群有哪些方案,知道吗?
在Redis中提供的集群方案总共有三种:主从复制、哨兵模式、Redis分片集群。
20. 那你来介绍一下主从同步。
单节点Redis的并发能力有上限,可以通过搭建主从集群实现读写分离,一般是一主多从,主节点负责写数据,从节点负责读数据。主节点写入数据后,需要把数据同步到从节点中。
21. 能说一下主从同步数据的流程吗?
嗯~~,好!主从同步分为了两个阶段,一个是全量同步,一个是增量同步。
全量同步是指从节点第一次与主节点建立连接的时候使用全量同步,流程是这样的:
第一:从节点请求主节点同步数据,其中从节点会携带自己的replication id和offset偏移量。
第二:主节点判断是否是第一次请求,主要判断的依据就是,主节点与从节点是否是同一个replication id,如果不是,就说明是第一次同步,那主节点就会把自己的replication id和offset发送给从节点,让从节点与主节点的信息保持一致。
第三:在同时主节点会执行BGSAVE
,生成RDB文件后,发送给从节点去执行,从节点先把自己的数据清空,然后执行主节点发送过来的RDB文件,这样就保持了一致。
当然,如果在RDB生成执行期间,依然有请求到了主节点,而主节点会以命令的方式记录到缓冲区,缓冲区是一个日志文件,最后把这个日志文件发送给从节点,这样就能保证主节点与从节点完全一致了,后期再同步数据的时候,都是依赖于这个日志文件,这个就是全量同步。
增量同步指的是,当从节点服务重启之后,数据就不一致了,所以这个时候,从节点会请求主节点同步数据,主节点还是判断不是第一次请求,不是第一次就获取从节点的offset值,然后主节点从命令日志中获取offset值之后的数据,发送给从节点进行数据同步。
22. 怎么保证Redis的高并发高可用?
为了保证Redis的高并发和高可用性,通常会采用以下几种策略:
主从复制与哨兵模式
- 主从复制:通过主从复制,可以将数据从主节点同步到多个从节点。主节点负责写操作,从节点负责读操作,这样可以分散读取压力,提高系统的并发能力。
- 哨兵模式:哨兵(Sentinel)是Redis的高可用性解决方案,可以监控主从复制的运行状态。如果主节点发生故障,哨兵会自动将一个从节点提升为新的主节点,并通知客户端更新连接信息。哨兵模式通过以下机制实现高可用性:
- 监控:哨兵会持续监控主节点和从节点的状态。
- 自动故障转移:当主节点故障时,哨兵会自动选择一个从节点作为新的主节点。
- 通知客户端:哨兵会通过发布/订阅机制通知客户端新的主节点信息。
Redis集群
- 分片:Redis集群通过将数据分片到多个节点上,可以有效分散负载,提高并发处理能力。
- 自动故障转移:集群模式下,当主节点故障时,集群会自动进行故障转移,确保服务的可用性。
23. 你们使用Redis是单点还是集群,哪种集群?
24. Redis集群脑裂该怎么解决呢?
25. Redis的分片集群有什么作用?
26. Redis分片集群中数据是怎么存储和读取的?
27. Redis是单线程的,但是为什么还那么快?
- 完全基于内存的,C语言编写。
- 采用单线程,避免不必要的上下文切换和竞争条件。
- 使用多路I/O复用模型,非阻塞IO。
28. 能解释一下I/O多路复用模型?
I/O 多路复用模型呢,简单来说,就是让一个线程能同时盯着好多网络连接(Socket)。就像餐厅里一个厉害的服务员,能同时照顾好多桌客人。以前传统的方式是来一个客人就安排一个服务员专门盯着,太浪费人力了。而 I/O 多路复用就是让一个服务员(线程)看着所有桌子(Socket),哪个桌子上的客人需要点菜(可读)或者要结账(可写)了,这个服务员就过去服务。
Redis 就是用了 I/O 多路复用结合事件处理器来处理好多 Socket 请求,像有专门处理连接的、处理命令回复的、处理命令请求的。不过在 Redis 6.0 之后,为了让处理速度更快,在命令回复和命令请求处理的某些环节用了多线程,但核心的命令执行还是单线程,保证命令执行的准确性和稳定性。
五、设计模式篇
1. 单点登录这块怎么实现的?
单点登录(SSO):用户只需登录一次,即可访问所有信任的应用系统。
基于 Cookie 和 Session 的实现
- 用户登录:用户访问应用系统,输入用户名和密码进行登录,登录请求被发送到认证中心。
- 认证与 Session 创建:认证中心验证用户身份信息,若成功则在服务器端创建一个 Session,将用户信息存储在 Session 中,并生成一个唯一的 Session ID。
- Cookie 设置:认证中心将 Session ID 通过 Cookie 发送给客户端浏览器,浏览器会在后续请求中自动携带该 Cookie。
- 访问其他应用系统:当用户访问其他应用系统时,浏览器会携带包含 Session ID 的 Cookie,应用系统收到请求后,从 Cookie 中获取 Session ID,并根据 Session ID 到服务器端查找对应的 Session,以此来验证用户身份,若 Session 存在且有效,则允许用户访问。
基于 Token 的实现
- 用户登录:用户在登录页面输入账号和密码,系统验证身份后生成一个JWT令牌,然后将这个令牌返回给浏览器,浏览器会将其保存到Cookie中。
- 访问其他服务:当用户访问其他系统或服务时,浏览器会自动携带这个JWT令牌。网关会拦截请求,检查令牌是否有效。
- 令牌验证:
- 如果令牌有效,网关会将请求路由到目标服务,用户可以正常访问。
- 如果令牌无效(比如过期或被篡改),网关会返回401状态码,前端接收到后会跳转到登录页面,提示用户重新登录。
- 服务间通信:在微服务架构中,各服务之间也可能需要进行身份验证。这时,服务之间会通过传递JWT令牌来确认彼此的身份,确保通信的安全性。
2. 上传数据的安全性你们怎么控制?
在我们项目中,对于浏览器访问后台时上传数据的安全性问题,主要是通过加密技术来控制的,常用的是对称加密和非对称加密这两种方式。
先来说说对称加密吧。它的原理是加密和解密都使用同一个密钥。数据发送方会把要传输的明文和这个加密密钥一起,通过特定的加密算法进行处理,将其转化为复杂的密文,然后再发送出去。接收方收到密文后,要想解读出原文,就需要使用和加密时相同的密钥,以及这个加密算法的逆算法来对密文进行解密,这样才能恢复成原来可读的明文。对称加密的优点很明显,它的算法是公开的,计算量比较小,加密的速度快,效率也高,能够快速处理大量数据。不过它也有缺点,就是安全性相对非对称加密来说要低一些。所以我们一般会用它来保存像用户手机号、身份证这类虽然敏感,但后续需要解密使用的信息。常见的对称加密算法有 AES、DES、3DES 等等。
再就是非对称加密。这种加密方式有两个密钥,一个是公开密钥,另一个是私有密钥。我们会同时生成这两把密钥,私钥由服务器端隐秘保存好,公钥则可以下发给信任的客户端。在加密和解密方面,私钥加密的内容,只有持有公钥的一方才能解密;反过来,公钥加密的内容,就只有持有私钥的一方可以解密。而且,还可以用私钥进行签名,通过公钥来验证数据有没有被篡改过。非对称加密的优势在于它的安全性更好,但是它也有不足的地方,那就是加密和解密花费的时间比较长,速度慢,所以只适合对少量数据进行加密。我们一般会把它用在签名和认证这些场景里,比如说私钥由服务器保存用来加密数据,公钥交给客户端,让客户端用来对令牌或者签名进行解密或者校验。像 RSA、DSA 这些都是常见的非对称加密算法。
在实际应用中,我们会根据具体的情况来选择合适的加密方式。如果传输的数据量很大,为了保证效率,我们会建议使用对称加密,不过这种情况下,我们不会用它来保存特别敏感的信息。而如果传输的数据量比较小,同时对安全性要求又很高,那我们就会采用非对称加密,以确保数据的安全。通过这样的方式,我们能够有效地保障上传数据在网络传输过程中的安全性。
3. 你负责项目的时候遇到了哪些比较棘手的问题?
在我们的在线判题系统(OJ)项目中,不同编程语言的判题逻辑存在显著差异。例如,Java语言的内存和时间限制通常需要适当增加,而C++语言可能需要更严格的限制。此外,代码沙箱的调用也需要支持多种方式,如本地沙箱、远程沙箱和第三方沙箱。如果将所有这些逻辑都写在一个Service类中,通过大量的if...else
语句来区分,代码的可读性和可维护性会变得很差,圈复杂度也会很高。因此,我选择了策略模式、工厂模式和代理模式来解决这些问题。
(1) 首先,我们用策略模式封装不同语言的判题算法
定义判题策略接口
这个接口就像是一个规范,规定了所有具体判题策略类都必须实现的方法。在这个接口里,有一个 doJudge
方法,它接收一个 JudgeContext
类型的参数,这个参数可以携带判题所需的各种信息。
|
|
实现具体的策略类
以 Java 和 C++ 语言为例。对于 Java 语言,我创建了 JavaLanguageJudgeStrategy
类,它实现了 JudgeStrategy
接口,在 doJudge
方法里编写了 Java 语言特有的判题逻辑。同样地,对于 C++ 语言,我创建了 CppLanguageJudgeStrategy
类,也在其 doJudge
方法中实现了 C++ 语言的判题逻辑
|
|
定义上下文类
为了方便传递判题所需的信息,我定义了一个上下文类 JudgeContext
。在这个类中,包含了提交的代码、输入用例以及预期输出等信息,这些信息会在判题过程中被使用。
|
|
定义策略管理类
最后,为了管理这些不同的判题策略,我定义了一个策略管理类 JudgeManager
。在这个类中,使用一个 Map
来存储不同语言对应的判题策略,键是语言的名称,值是对应的策略类实例。在构造函数中,我初始化了这个 Map
,将 Java 和 C++ 等语言对应的策略类实例添加进去。同时,提供了一个 executeStrategy
方法,根据传入的语言名称从 Map
中获取对应的策略实例,如果存在就调用其 doJudge
方法进行判题,如果不存在则抛出异常。
|
|
通过这种策略模式的实现,我们可以很方便地添加新的编程语言判题策略,同时也使得代码的可维护性和可扩展性得到了显著提升。如果后续需要增加新的语言判题逻辑,只需要创建一个新的策略类并实现 JudgeStrategy
接口,然后在 JudgeManager
的 Map
中添加对应的映射关系即可,不会对现有的代码造成影响。
(2) 然后,我们使用工厂模式简化代码沙箱调用实例的获取
定义代码沙箱接口
首先,我定义了一个代码沙箱接口 CodeSandbox
。这个接口规定了代码沙箱必须具备的核心功能,即 execute
方法,该方法接收代码和输入作为参数,返回执行结果。通过这个接口,我们可以为不同类型的代码沙箱提供统一的调用方式。
|
|
实现具体的代码沙箱类
我实现了具体的代码沙箱类。分别有 LocalCodeSandbox
用于本地代码沙箱执行,RemoteCodeSandbox
用于远程代码沙箱执行,以及 ThirdPartyCodeSandbox
用于调用第三方代码沙箱服务。每个类都实现了 CodeSandbox
接口,并在 execute
方法中编写了各自对应的执行逻辑。
|
|
定义工厂类
最后,为了方便获取不同类型的代码沙箱实例,我定义了一个工厂类CodeSandboxFactory
。在这个工厂类中,有一个静态方法 getCodeSandbox
,它接收一个表示沙箱类型的字符串作为参数。根据传入的类型,使用 switch
语句进行判断,然后返回相应的代码沙箱实例。如果传入的类型不被支持,会抛出一个 IllegalArgumentException
异常.
|
|
通过这种工厂模式的实现,我们在需要获取代码沙箱实例时,只需要调用 CodeSandboxFactory
的 getCodeSandbox
方法并传入相应的类型即可,无需关心具体的实例创建过程。而且,如果后续需要添加新的代码沙箱类型,只需要在工厂类的 switch
语句中添加相应的分支,同时实现对应的代码沙箱类,不会对其他部分的代码造成影响,大大提高了代码的可扩展性和可维护性。
(3) 为了进一步优化判题机模块中代码沙箱调用的灵活性与可管理性,我采取了配置化代码沙箱类型的方案。
实现动态切换代码沙箱的实现,全程无需修改代码,大大提高了系统的灵活性与稳定性。
在 application.yml
配置文件中,我们添加了代码沙箱类型的配置项。
|
|
代码读取配置
在 Java 代码中,利用 Spring 框架提供的 @Value
注解,我们可以方便地读取配置文件中的 sandbox.type
值。示例代码如下:
|
|
然后,在获取代码沙箱实例的方法中,我们利用前面提到的工厂类来获取相应类型的代码沙箱实例:
|
|
这里通过将读取到的 sandboxType
作为参数传递给 CodeSandboxFactory
的 getCodeSandbox
方法,就能获取到对应的代码沙箱实例。
(4) 最后 我们使用代理模式增强代码沙箱的能力
在调用代码沙箱前后进行日志记录等操作时,如果直接在代码沙箱调用实现类中编写,会导致代码耦合度高,后续修改和扩展困难。
定义代理类
我定义了一个代理类 CodeSandboxProxy
,它实现了 CodeSandbox
接口。在这个代理类中,有一个成员变量 target
,它是被代理的代码沙箱对象。通过构造函数将被代理的对象传入并赋值给 target
。
在 execute
方法中,我在调用被代理对象的 execute
方法前后添加了日志记录的操作。具体来说,在调用之前,会调用 log
方法记录 “Executing code in sandbox…”,表示代码沙箱开始执行代码;在调用之后,再次调用 log
方法记录 “Execution completed.”,表示代码执行完成。最后返回执行结果。log
方法中封装了具体的日志记录逻辑。
|
|
使用代理类
在获取代码沙箱实例的方法 getCodeSandbox
中,首先通过 CodeSandboxFactory
工厂类根据配置的沙箱类型获取到具体的代码沙箱实例 target
,然后将这个实例作为参数传递给 CodeSandboxProxy
代理类的构造函数,创建一个代理对象并返回。这样,后续调用代码沙箱的 execute
方法时,实际上调用的是代理对象的 execute
方法,从而实现了在代码沙箱执行前后添加日志记录等额外功能。
|
|
通过代理模式,我们可以在不改变代码沙箱实现类的前提下,集中地为代码沙箱添加新的功能,比如日志记录、性能监控等。这样,代码沙箱的原有功能不会受到影响,同时又能轻松地扩展其能力。
代理模式将日志记录逻辑与代码沙箱的执行逻辑分离开来。代码沙箱的实现类只需要专注于代码执行的核心逻辑,而日志记录等额外功能由代理类负责。这使得每个类的职责更加清晰明确,提高了代码的可维护性和可扩展性,也让代码结构更加合理。
六、Java基础
1. JVM vs JDK vs JRE
JVM(Java Virtual Machine) 是运⾏ Java 字节码的虚拟机。JVM 有针对不同系统的特定实现(Windows, Linux,macOS),⽬的是使⽤相同的字节码,它们都会给出相同的结果。字节码和不同系统的 JVM 实现是 Java 语⾔“⼀次编译,随处可以运⾏”的关键所在。JVM有多种实现,比如我们常用的HotSpot VM,还有J9 VM、Zing VM等。这些不同的实现提供了不同的性能优化和特性。
JDK(Java Development Kit) 是Java开发工具包,它包含了JRE的所有内容,同时还包括编译器(javac
)、调试器(jdb
)、文档生成器(javadoc
)等开发工具。JDK是Java开发者的必备工具,用于编写、编译和调试Java程序。如果你需要进行Java开发,那么安装JDK是必须的。
JRE(Java Runtime Environment) 是Java运行时环境,它包含了运行已编译Java程序所需的所有内容,包括JVM、Java类库和一些基础工具。JRE主要用于运行Java程序,而不包含开发工具。如果你只需要运行Java程序,而不需要进行开发,那么安装JRE就足够了。
在实际工作中,虽然JRE可以运行Java程序,但很多时候我们也会安装JDK,因为一些工具(如JSP容器)在运行时需要编译功能,这就需要JDK的支持。总的来说,JVM是运行Java程序的核心,JDK是开发Java程序的工具集,而JRE是运行Java程序的环境。
2. 什么是字节码?采用字节码的好处是什么?
在 Java 中,JVM 可以理解的代码就叫做字节码(即扩展名为 .class 的⽂件),它不⾯向任何特定 的处理器,只⾯向虚拟机。Java 语⾔通过字节码的⽅式,在⼀定程度上解决了传统解释型语⾔执⾏ 效率低的问题,同时⼜保留了解释型语⾔可移植的特点。所以, Java 程序运⾏时相对来说还是⾼效 的(不过,和 C++,Rust,Go 等语⾔还是有⼀定差距的),⽽且,由于字节码并不针对⼀种特定的 机器,因此,Java 程序⽆须重新编译便可在多种不同操作系统的计算机上运⾏。
3. Java 程序从源代码到运⾏的过程如下图所示
我们需要格外注意的是 .class->机器码 这⼀步。在这⼀步 JVM 类加载器⾸先加载字节码⽂件,然后 通过解释器逐⾏解释执⾏,这种⽅式的执⾏速度会相对比较慢。⽽且,有些⽅法和代码块是经常需要 被调⽤的(也就是所谓的热点代码),所以后⾯引进了 JIT(just-in-time compilation) 编译器,⽽ JIT 属于运⾏时编译。当 JIT 编译器完成第⼀次编译后,其会将字节码对应的机器码保存下来,下次可以 直接使⽤。⽽我们知道,机器码的运⾏效率肯定是⾼于 Java 解释器的。这也解释了我们为什么经常 会说 Java 是编译与解释共存的语⾔ 。
3. 成员变量与局部变量的区别?
- 语法形式 :从语法形式上看,成员变量是属于类的,⽽局部变量是在代码块或⽅法中定义的变量或是⽅法的参数;成员变量可以被 public , private , static 等修饰符所修饰,⽽局部变量不能被访问控制修饰符及 static 所修饰;但是,成员变量和局部变量都能被 final 所修饰。
- 存储⽅式 :从变量在内存中的存储⽅式来看,如果成员变量是使⽤ static 修饰的,那么这个成员变量是属于类的,如果没有使⽤ static 修饰,这个成员变量是属于实例的。⽽对象存在于堆内存,局部变量则存在于栈内存。
- ⽣存时间 :从变量在内存中的⽣存时间上看,成员变量是对象的⼀部分,它随着对象的创建⽽ 存在,⽽局部变量随着⽅法的调⽤⽽⾃动⽣成,随着⽅法的调⽤结束⽽消亡。
- 默认值 :从变量是否有默认值来看,成员变量如果没有被赋初始值,则会⾃动以类型的默认值⽽赋值(⼀种情况例外:被 final 修饰的成员变量也必须显式地赋值),⽽局部变量则不会⾃动 赋值。
5. 静态变量有什么作用?
- 共享数据:
- 静态变量属于类本身,而不是类的某个具体实例。因此,无论一个类创建了多少个对象,所有这些对象都共享同一份静态变量。这使得静态变量非常适合用于存储类级别的数据,这些数据对所有实例都是相同的。
- 节省内存:
- 由于静态变量是类级别的,它在内存中只有一份副本,无论创建多少个类的实例,都不会重复占用内存。这在处理大量对象时可以显著节省内存资源。
- 常量定义:
- 静态变量通常会被
final
关键字修饰,成为常量。这样可以确保这些变量的值在运行时不会被修改,从而提高代码的安全性和可维护性。
- 静态变量通常会被
6. 静态方法和实例方法有何不同?
- 调用方式:
- 静态方法:可以通过
类名.方法名
直接调用,无需创建类的实例。虽然也可以通过对象调用,但这种方式容易造成混淆,因此建议使用类名.方法名
来调用静态方法。 - 实例方法:必须通过类的实例调用,即
对象.方法名
。实例方法依赖于具体的对象实例。
- 静态方法:可以通过
- 访问类成员的限制:
- 静态方法:只能访问类的静态成员(静态变量和静态方法),不能访问实例成员(实例变量和实例方法)。这是因为静态方法属于类本身,而不是某个具体实例。
- 实例方法:可以访问类的所有成员,包括静态成员和实例成员。实例方法依赖于具体的对象实例,因此可以访问该实例的所有成员。
- 与对象的关系:
- 静态方法:属于类本身,与具体的对象实例无关。无论创建多少个对象,静态方法都只有一份。
- 实例方法:属于具体的对象实例,每个对象实例都有自己的实例方法。
7. 重载和重写有什么区别?
重载(Overloading)
重载是指在同一个类中,允许定义多个同名方法,但这些方法的参数列表必须不同
重载发生在编译期,编译器通过参数列表来区分不同的方法。
重载就是同⼀个类中多个同名方法根据不同的传参来执行不同的逻辑处理。
重写
重写发生在运行期,是⼦类对父类的允许访问的方法的实现过程进行重新编写。
- 方法名、参数列表必须相同,子类方法返回值类型应比父类方法返回值类型更小或相等,抛出的 异常范围小于等于父类,访问修饰符范围大于等于父类。如果方法的返回 类型是 void 和基本数据类型,则返回值重写时不可修改。但是如果方法的返回值是引用类型,重写 时是可以返回该引⽤类型的子类的。
- 如果父类方法访问修饰符为 private/final/static 则子类就不能重写该方法,但是被 static 修饰的方法能够被再次声明。
- 构造方法无法被重写
**口诀:**方法的重写要遵循“两同两小一大”
“两同”即方法名相同、形参列表相同;
“两小”指的是子类方法返回值类型应比父类方法返回值类型更小或相等,子类方法声明抛出的异常类应比父类方法声明抛出的异常类更小或相等;
“一大”指的是子类方法的访问权限应比父类方法的访问权限更大或相等。
8. Java 中的几种基本数据类型了解么?
Java 中有 8 种基本数据类型,分别为:
4 种整数型: byte 、 short 、 int 、 long
2 种浮点型: float (4)、 double(8)
1 种字符类型:char(2)
1 种布尔型: boolean
byte
、short
、int
、long
能表示的最大正数都减 1 了。这是为什么呢?这是因为在二进制补码表示法中,最高位是用来表示符号的(0 表示正数,1 表示负数),其余位表示数值部分。所以,如果我们要表示最大的正数,我们需要把除了最高位之外的所有位都设为 1。如果我们再加 1,就会导致溢出,变成一个负数。
9. 基本类型和包装类型的区别?
- 成员变量包装类型不赋值就是 null ,而基本类型有默认值且不是 null 。
- 包装类型可用于泛型,而基本类型不可以。
- 基本数据类型的局部变量存放在 Java 虚拟机栈中的局部变量表中,基本数据类型的成员变量(未被 static 修饰)存放在 Java 虚拟机的堆中。包装类型属于对象类型,我们知道几乎所有对象实例都存在于堆中。
- 相比于对象类型,基本数据类型占用的空间非常小。
⚠ 注意:基本数据类型存放在栈中是一个常见的误区!基本数据类型的成员变量如果没有被 static 修饰的话(不建议这么使用,应该要使用基本数据类型对应的包装类型),就存放在堆中。
10. 自动装箱与拆箱了解吗?原理是什么?
装箱:将基本类型用它们对应的引用类型包装起来;
拆箱:将包装类型转换为基本数据类型;
装箱其实就是调用了 包装类的 valueOf() 方法,拆箱其实就是调用了 xxxValue() ⽅法。
- Integer i = 10 等价于 Integer i = Integer.valueOf(10)
- int n = i 等价于 int n = i.intValue() ;
11. 面向对象三大特征
1. 封装
封装是将对象的状态信息(即属性)隐藏在对象内部,不允许外部直接访问这些内部信息。但可以通过一些公开的方法来操作这些属性。例如,我们无法看到挂在墙上的空调内部的零件,但可以通过遥控器来控制空调。如果某些属性不想被外界访问,可以选择不提供相应的方法。但如果一个类没有任何可访问的方法,那么这个类就失去了意义。
2. 继承
不同类型的对象通常有一些共同点。例如,小明、小红和小李都是学生,共享学生的一些特性(如班级、学号等)。同时,每个对象也有其独特的特性。继承是一种使用已存在的类定义作为基础来创建新类的技术。新类可以增加新的数据和功能,也可以使用父类的功能,但不能选择性地继承父类。继承可以快速创建新类,提高代码复用性,增强程序的可维护性,节省开发时间。
关于继承的三个要点:
- 子类拥有父类的所有属性和方法(包括私有属性和方法),但无法访问父类中的私有属性和方法。
- 子类可以拥有自己的属性和方法,即可以对父类进行扩展。
- 子类可以用自己的方式实现父类的方法。
3. 多态
多态表示一个对象可以具有多种状态,具体表现为父类的引用指向子类的实例。多态的特点包括:
- 对象类型和引用类型之间必须存在继承(类)或实现(接口)的关系。
- 引用类型变量调用的方法在运行时才能确定。
- 多态不能调用“只在子类中存在而在父类中不存在”的方法。
- 如果子类重写了父类的方法,调用的是子类覆盖的方法;如果没有覆盖,则调用父类的方法。
12. 接口和抽象类有什么共同点和区别?
共同点
- 都不能被实例化。
- 都可以包含抽象方法。
- 都可以有默认实现的方法(Java 8 可以用
default
关键字在接口中定义默认方法)。
区别
- 接口:
- 主要用于对类的行为进行约束,实现接口的类具有对应的行为。
- 一个类可以实现多个接口。
- 接口中的成员变量只能是
public static final
类型的,不能被修改且必须有初始值。
- 抽象类:
- 主要用于代码复用,强调的是所属关系。
- 一个类只能继承一个抽象类。
- 抽象类的成员变量默认是
default
,可以在子类中被重新定义或赋值。
13. 深拷贝、浅拷贝和引用拷贝的区别
引用拷贝是指两个不同的引用指向同一个对象。这种方式不会创建新的对象,只是将一个引用赋值给另一个引用。
浅拷贝会在堆上创建一个新的对象,但不会复制对象内部的引用类型属性。也就是说,浅拷贝的对象和原对象共享内部的引用类型属性。
深拷贝会完全复制整个对象,包括对象内部的所有引用类型属性。深拷贝的对象和原对象完全独立,不共享任何引用类型属性。
14. ==
和 equals()
的区别
==
的作用:- 基本数据类型:比较两个值是否相等。
- 引用数据类型:比较两个引用是否指向同一个对象,即比较内存地址。
- 注意:Java中只有值传递,无论是比较基本数据类型还是引用数据类型,
==
比较的都是值。对于引用类型,值是对象的内存地址。
equals()
的作用:- 基本数据类型:
equals()
方法不能用于基本数据类型,只能用于对象。 - 引用数据类型:
equals()
方法用于比较两个对象的内容是否相等。equals()
方法存在于Object
类中,所有类都继承自Object
类,因此所有类都有equals()
方法。 - 默认行为:
Object
类中的equals()
方法默认比较对象的内存地址,等同于==
。 - 重写行为:通常我们会重写
equals()
方法来比较对象的属性是否相等。如果两个对象的属性相等,则返回true
,表示这两个对象相等。
- 基本数据类型:
15. hashCode() 有什么用?
hashCode()
的作用是获取哈希码(一个整数),也称为散列码。这个哈希码的作用是确定该对象在哈希表中的索引位置。hashCode()
定义在 JDK 的 Object
类中,这就意味着 Java 中的任何类都包含有 hashCode()
方法。另外需要注意的是:Object
的 hashCode()
方法是本地方法,也就是用 C 语言或 C++ 实现的,该方法通常用来将对象的内存地址转换为整数之后返回。
散列表存储的是键值对(key-value),它的特点是:能根据“键”快速检索出对应的“值”。这其中就利用到了散列码!(可以快速找到所需要的对象)
为什么要有 hashCode()
?
我们以“HashSet
如何检查重复”为例来说明为什么要有 hashCode()
。当你把对象加入 HashSet
时,HashSet
会先计算对象的 hashCode
值来判断对象加入的位置,同时也会与其他已经加入的对象的 hashCode
值作比较,如果没有相符的 hashCode
,HashSet
会假设对象没有重复出现。但是如果发现有相同 hashCode
值的对象,这时会调用 equals()
方法来检查 hashCode
相等的对象是否真的相同。如果两者相同,HashSet
就不会让其加入操作成功。如果不同的话,就会重新散列到其他位置。这样我们大大减少了 equals
的次数,相应就大大提高了执行速度。
hashCode()
和 equals()
都是用来比较两个对象是否相等的。那为什么 JDK 还要同时提供这两个方法呢?
这是因为在一些容器(比如 HashMap
、HashSet
)中,有了 hashCode()
之后,判断元素是否在对应容器中的效率会更高(参考添加元素进 HashSet
的过程)!我们在前面也提到了添加元素进 HashSet
的过程,如果 HashSet
在对比的时候,同样的 hashCode
有多个对象,它会继续使用 equals()
来判断是否真的相同。也就是说 hashCode
帮助我们大大缩小了查找成本。
那为什么不只提供 hashCode()
方法呢?这是因为两个对象的 hashCode
值相等并不代表两个对象就相等。那为什么两个对象有相同的 hashCode
值,它们也不一定相等呢?
因为 hashCode()
所使用的哈希算法也许刚好会让多个对象传回相同的哈希值。越糟糕的哈希算法越容易碰撞,但这也与数据值域分布的特性有关(所谓哈希碰撞也就是指的是不同的对象得到相同的 hashCode
)。
为什么重写 equals()
时必须重写 hashCode()
方法?
因为两个相等的对象的 hashCode
值必须是相等。也就是说如果 equals()
方法判断两个对象是相等的,那这两个对象的 hashCode
值也要相等。如果重写 equals()
时没有重写 hashCode()
方法的话就可能会导致 equals()
方法判断是相等的两个对象,hashCode
值却不相等。
16. String、StringBuffer、StringBuilder 的区别?
线程安全性
- String 中的对象是不可变的,也就可以理解为常量,线程安全。 AbstractStringBuilder 是 StringBuilder 与 StringBuffer 的公共父类,定义了一些字符串的基本操作,如 expandCapacity、append、insert、indexOf 等公共方法。 StringBuffer 对方法加了同步锁或者对调用的方法加了同步锁,所以是线程安全的。 StringBuilder 并没有对方法进行加同步锁,所以是非线程安全的。
性能
- 每次对 String 类型进行改变的时候,都会生成一个新的 String 对象,然后将指针指向新的 String 对象。 StringBuffer 每次都会对 StringBuffer 对象本身进行操作,而不是生成新的对象并改变对象引用。相同情况下使用 StringBuilder 相比使用 StringBuffer 仅能获得 10%~15% 左右的性能提升,但却要冒多线程不安全的风险。
String 为什么是不可变的?
- 保存字符串的数组被
final
修饰且为私有的,并且 String 类没有提供/暴露修改这个字符串的方法。 - String 类被
final
修饰导致其不能被继承,进而避免了子类破坏 String 不可变。
字符串拼接用“+” 还是 StringBuilder?
- 字符串对象通过“+”的字符串拼接方式,实际上是通过 StringBuilder 调用
append()
方法实现的,拼接完成之后调用toString()
得到一个 String 对象 。 - 不过,在循环内使用“+”进行字符串的拼接的话,存在比较明显的缺陷:编译器不会创建单个 StringBuilder 以复用,会导致创建过多的 StringBuilder 对象。如果直接使用 StringBuilder 对象进行字符串拼接的话,就不会存在这个问题了。
String#equals() 和 Object#equals() 有何区别?
- String 中的
equals
方法是被重写过的,比较的是 String 字符串的值是否相等。 Object 的equals
方法比较的是对象的内存地址。
字符串常量池的作用了解吗?
- 字符串常量池是 JVM 为了提升性能和减少内存消耗针对字符串(String 类)专门开辟的一块区域,主要目的是为了避免字符串的重复创建。
intern 方法有什么作用?
- String.intern() 是一个 native(本地)方法,其作用是将指定的字符串对象的引用保存在字符串常量池中,可以简单分为两种情况:
- 如果字符串常量池中保存了对应的字符串对象的引用,就直接返回该引用。
- 如果字符串常量池中没有保存了对应的字符串对象的引用,那就在常量池中创建一个指向该字符串对象的引用并返回。
17. Exception 和 Error 的区别
在 Java 里,所有异常都有一个共同的祖先,即java.lang
包中的Throwable
类。Throwable
类有两个重要的子类:
- Exception:是程序本身可以处理的异常,能够通过
catch
语句来捕获。它又可细分为Checked Exception
(受检查异常,必须处理)和Unchecked Exception
(不受检查异常,可以不处理)。 - Error:属于程序无法处理的错误,不建议通过
catch
捕获。例如Java
虚拟机运行错误(Virtual MachineError
)、虚拟机内存不够错误(OutOfMemoryError
)、类定义错误(NoClassDefFoundError
)等。当这些异常发生时,Java
虚拟机(JVM
)通常会选择终止线程。
18. Checked Exception 和 Unchecked Exception 的区别
-
Checked Exception(受检查异常):在 Java 代码编译过程中,如果受检查异常没有被
catch
或者throws
关键字处理,代码就无法通过编译。除了RuntimeException
及其子类以外,其他的Exception
类及其子类都属于受检查异常。常见的受检查异常有:与IO
相关的异常、ClassNotFoundException
、SQLException
等。 -
Unchecked Exception(不受检查异常):在 Java 代码编译过程中,即使不处理不受检查异常,代码也能正常通过编译。
RuntimeException及其子类都被统称为非受检查异常,常见的有:
NullPointerException
(空指针错误)IllegalArgumentException
(参数错误,比如方法入参类型错误)NumberFormatException
(字符串转换为数字格式错误,是IllegalArgumentException
的子类)ArrayIndexOutOfBoundsException
(数组越界错误)ClassCastException
(类型转换错误)ArithmeticException
(算术错误)SecurityException
(安全错误,比如权限不够)UnsupportedOperationException
(不支持的操作错误,比如重复创建同一用户
19. Throwable 类常用方法
String getMessage()
:返回异常发生时的简要描述。String toString()
:返回异常发生时的详细信息。String getLocalizedMessage()
:返回异常对象的本地化信息。Throwable
的子类覆盖这个方法,可以生成本地化信息。若子类没有覆盖该方法,则该方法返回的信息与getMessage()
返回的结果相同。void printStackTrace()
:在控制台上打印Throwable
对象封装的异常信息。
20. try-catch-finally 的使用
- try 块:用于捕获异常。其后可以接零个或多个
catch
块,如果没有catch
块,则必须跟一个finally
块。 - catch 块:用于处理
try
捕获到的异常。 - finally 块:无论是否捕获或处理异常,
finally
块里的语句都会被执行。当在try
块或catch
块中遇到return
语句时,finally
语句块将在方法返回之前被执行。
注意:不要在finally
语句块中使用return
!当try
语句和finally
语句中都有return
语句时,try
语句块中的return
语句会被忽略。这是因为try
语句中的return
返回值会先被暂存在一个本地变量中,当执行到finally
语句中的return
之后,这个本地变量的值就变为了finally
语句中的return
返回值。
21. finally 中的代码一定会执行吗?
不一定。在某些情况下,finally
中的代码不会被执行,比如:
finally
之前虚拟机被终止运行,例如使用System.exit(1)
终止当前正在运行的 Java 虚拟机。- 程序所在的线程死亡。
- 关闭 CPU。
22. 异常使用有哪些需要注意的地方?
- 不要把异常定义为静态变量,因为这样会导致异常栈信息错乱。每次手动抛出异常,我们都需要手动
new
一个异常对象抛出。 - 抛出的异常信息一定要有意义。
- 建议抛出更加具体的异常,比如字符串转换为数字格式错误的时候应该抛出
NumberFormatException
而不是其父类IllegalArgumentException
。 - 使用日志打印异常之后就不要再抛出异常了(两者不要同时存在于一段代码逻辑中)。
23. 反射
- 定义:反射赋予了 Java 程序在运行时分析类以及执行类中方法的能力。借助反射,能够获取任意一个类的所有属性和方法,并且可以调用这些方法和属性。若深入研究框架底层原理或自行编写框架,对反射概念会较为熟悉,因其被称为框架的灵魂。
- 优缺点
- 优点:使代码更灵活,为各种框架实现开箱即用的功能提供便利。
- 缺点:增加安全问题,例如可无视泛型参数在编译时的安全检查;性能相对稍差,但对框架实际影响不大。
- 应用场景
- 在业务开发中,直接使用反射机制的场景较少。然而,众多框架如 Spring/Spring Boot、MyBatis 等大量运用了反射机制。
- 动态代理在这些框架中广泛使用,而动态代理的实现依赖反射。例如 JDK 实现动态代理的示例代码,通过反射类
Method
调用指定方法。 - 注解的实现也用到反射。以 Spring 为例,使用
@Component
注解声明一个类为 Spring Bean,通过@Value
注解读取配置文件中的值,其原理是基于反射分析类,获取类、属性、方法及方法参数上的注解,进而进行后续处理。
24. 序列化与反序列化
如果我们需要持久化 Java 对象比如将 Java 对象保存在文件中,或者在网络传输 Java 对象,这些场景都需要用到序列化。
- 定义
- 序列化:将数据结构或对象转换为二进制字节流的过程。当需要持久化 Java 对象,如保存在文件中或在网络传输 Java 对象时,会用到序列化。
- 反序列化:将序列化过程中生成的二进制字节流转换回数据结构或对象的过程。
- 不想序列化某些字段的处理方式:对于不想进行序列化的变量,使用
transient
关键字修饰。transient
关键字作用为阻止实例中被其修饰的变量序列化;对象反序列化时,被transient
修饰的变量值不会被持久化和恢复。需注意:transient
只能修饰变量,不能修饰类和方法;被其修饰的变量,反序列化后值将被置为类型默认值(如int
类型为 0);static
变量不属于任何对象,无论是否有transient
修饰,均不会被序列化。
26. Java IO 流了解吗?
- Java IO流概述:Java IO流是Java中用于处理输入输出的核心机制,数据输入到计算机内存的过程称为输入,从内存输出到外部存储(如文件、数据库、远程主机等)的过程称为输出。由于数据传输过程类似于水流,因此形象地称为IO流。Java IO流分为输入流和输出流,根据数据处理方式又分为字节流和字符流。
- 抽象基类:Java IO流的40多个类都是从4个抽象类基类中派生出来的,分别是
InputStream
和Reader
(输入流的基类,前者是字节输入流,后者是字符输入流),以及OutputStream
和Writer
(输出流的基类,前者是字节输出流,后者是字符输出流)。 - Buffered类的作用:为了提高IO操作的效率,Java提供了
BufferedInputStream
、BufferedOutputStream
、BufferedReader
和BufferedWriter
这几个类。它们分别继承自对应的输入输出流类,并在内部使用缓冲区来减少物理读写次数。- 对于输入流:
BufferedInputStream
和BufferedReader
会在第一次读取数据时,将数据块一次性读入缓冲区,之后的读取操作先从缓冲区中获取数据,只有当缓冲区中的数据被读完后,才会再次从底层数据源读取数据填充缓冲区。这种方式减少了对底层数据源的频繁访问,提高了读取效率。例如,当读取大文件时,使用BufferedReader
可以显著提升读取速度。 - 对于输出流:
BufferedOutputStream
和BufferedWriter
会先将要写入的数据暂存到缓冲区中,当缓冲区满或者调用flush()
方法时,才将缓冲区中的数据一次性写入目标存储。这样可以减少对目标存储的写入次数,提高写入效率,同时也能避免频繁写入导致的性能问题。比如在写入大量文本数据到文件时,使用BufferedWriter
可以更高效地完成写入操作。
- 对于输入流:
27. 为什么Java的I/O流要分为字节流和字符流?
主要有以下两个原因:
- 字符流涉及编码转换:
- 字符流是由Java虚拟机将字节转换得到的。这个转换过程涉及到字符编码(如UTF-8、ISO-8859-1等),而不同的编码方式会导致相同的字节序列被解释为不同的字符。因此,字符流的处理相对复杂,需要考虑编码问题,这个过程比较耗时。
- 例如,一个字节序列在UTF-8编码下可能表示一个字符,而在ISO-8859-1编码下可能表示另一个字符。字符流通过指定编码方式,确保字符的正确读取和写入。
- 字节流和字符流的适用场景不同:
- 字节流:适用于处理二进制数据,如文件的读写、网络通信等。字节流直接操作字节,不涉及编码转换,因此效率较高,适合处理二进制文件(如图片、视频等)。
- 字符流:适用于处理文本数据。字符流在处理文本时会自动处理编码问题,确保文本内容的正确性,适合处理文本文件(如
.txt
、.java
等)。
28. 泛型
指定参数,编译器可以对泛型参数进行检测,并且通过泛型参数可以指定传入的对象类型。
比如 ArrayList<Person> persons = new ArrayList<Person>()
这行代码就指明了该 ArrayList
对象只能传入 Person
对象,如果传入其他类型的对象就会报错。
29. 为什么重写 equals () 就一定要重写 hashCode () 方法
在 Java 中,若仅重写 equals()
方法而不重写 hashCode()
方法,可能会导致 a.equals(b)
表达式为 true
,但 a
和 b
的 hashCode
值却不同。这在使用散列集合(如 HashMap
、HashSet
等)存储对象时会引发问题。因为散列集合利用 hashCode
计算键的存储位置,若存储两个相等的对象却有不同的 hashCode
,它们会被存于哈希表的不同位置。当依据对象获取数据时,就会出现矛盾,即相同对象存于哈希表的不同位置,进而引发不可预期的错误。所以,为保证对象在散列集合中的正确存储和查找,重写 equals()
方法时必须重写 hashCode()
方法。
30. Integer 和 int 的区别以及 Java 设计封装类的原因
区别
- 初始值不同:
Integer
作为引用类型,初始值为null
;int
作为基本数据类型,初始值为0
。 - 存储位置不同:
Integer
对象存储在堆内存,int
类型的变量直接存储在栈空间。 - 类型及使用灵活性不同:
Integer
是对象类型,封装了众多方法和属性,使用起来更加灵活;int
是基本数据类型,功能相对单一。
设计封装类的原因
Java 是面向对象的编程语言,其操作大多基于对象。例如,集合类(如 List
、Set
等)只能存储 Object
类型的元素,基本数据类型无法直接存储在集合中,必须使用对应的封装类。因此,Java 设计封装类的主要目的是让基本数据类型也能参与面向对象的操作,增强代码的灵活性和可扩展性。
31. 如何理解Java对象的创建过程
- 类加载检查:当程序尝试实例化一个对象时,JVM 首先会检查该对象所属的类是否已经被加载到内存中。如果该类尚未被加载,JVM 会启动类加载机制,通过类加载器(ClassLoader)查找并加载对应的字节码文件(
.class
文件)。在类加载过程中,会进行一系列的验证、准备和解析操作,为类的使用做好准备。例如,验证字节码文件的格式是否正确,为类的静态变量分配内存并设置初始值等。 - 内存分配:一旦类被成功加载,JVM 就会为新对象分配内存空间。对象所需的内存大小在类加载完成后就已经确定,JVM 会在堆内存中为其分配一块合适的内存区域。内存分配的方式通常有两种:指针碰撞和空闲列表。指针碰撞是指 JVM 维护一个指向堆内存中可用内存的指针,当需要分配内存时,就将指针移动相应的距离来为新对象分配空间;空闲列表则是 JVM 维护一个记录堆内存中可用内存块的列表,每次分配内存时,从列表中选择合适的内存块分配给对象。
- 初始化零值:在为对象分配完内存后,JVM 会将分配的内存空间初始化为零值(对于基本数据类型)或
null
(对于引用数据类型)。这一步确保了对象的实例变量在被程序员显式赋值之前有一个默认的初始状态。例如,int
类型的实例变量会被初始化为0
,Object
类型的实例变量会被初始化为null
。 - 设置对象头:对象头是对象在内存中的一个重要组成部分,它包含了对象的一些元数据信息,如对象的哈希码(HashCode)、对象的分代年龄、锁状态标志等。JVM 会在这一步设置对象头中的这些信息,以便后续对对象的管理和操作。
- 执行构造方法:完成上述步骤后,JVM 会调用对象的构造方法(
constructor
),执行程序员在构造方法中编写的代码逻辑,对对象进行进一步的初始化。在构造方法中,程序员可以为对象的实例变量赋予特定的值,进行一些必要的资源初始化或其他操作,使对象处于可用的状态。
七、JVM基础
1. JVM 是什么?
- 定义:JVM 即 Java Virtual Machine,是 Java 程序的运行环境,也是运行 Java 二进制字节码的环境。
- 好处:实现了 “一次编写,到处运行” 的特性,因为 JVM 屏蔽了底层操作系统的差异,使得 Java 程序在不同的操作系统上都能以相同的方式运行;具备自动内存管理和垃圾回收机制,减轻了开发者对内存管理的负担,提高了开发效率,并且降低了因内存管理不当导致的错误风险。
2. JVM 由哪些部分组成,运行流程是什么?
- 组成部分
- ClassLoader(类加载器):负责加载 Java 类文件,将 Java 代码转换为字节码,使 JVM 能够识别和处理。
- Runtime Data Area(运行时数据区,内存分区):是 JVM 运行时管理内存的区域,包括堆、方法区、栈、本地方法栈和程序计数器等。
- Execution Engine(执行引擎):执行字节码指令,将字节码翻译为底层系统能够理解和执行的指令,最终交由 CPU 执行。
- Native Method Library(本地库接口):当 Java 程序需要调用其他语言(如 C、C++)编写的本地代码时,通过该接口实现调用,以完成一些 Java 语言本身无法直接实现的功能。
- 运行流程
- 类加载器把 Java 代码编译后的字节码文件加载到 JVM 中。
- 运行时数据区将字节码加载到内存中进行存储和管理。由于字节码是 JVM 的指令集规范,不能直接被底层系统执行,所以需要进一步处理。
- 执行引擎将字节码翻译为底层系统指令,在这个过程中可能会调用本地库接口来实现一些功能,最终由 CPU 执行这些指令,完成程序的运行。
3. 什么是程序计数器?
- 定义:程序计数器是线程私有的内存区域,内部保存着字节码的行号,用于记录当前线程正在执行的字节码指令的地址。
- 作用:在多线程环境下,JVM 通过线程轮流切换并分配执行时间来实现多线程的并发执行。当一个线程的执行时间用完被挂起,处理器切换到其他线程执行后,再次切换回该线程时,就可以从程序计数器中获取上次执行的行号,从而继续向下执行,保证了线程执行的连续性和正确性。
- 特点:它是 JVM 规范中唯一一个没有规定会出现 OutOfMemoryError(OOM)的区域,并且该区域也不会进行垃圾回收(GC)。
- 查看工具:使用
javap -verbose xx.class
命令可以打印堆栈大小、局部变量的数量和方法的参数等信息,这对于了解程序的字节码结构和分析问题有一定帮助。
4. Java 堆是什么样的?
- 概述:Java 堆是线程共享的内存区域,主要用于保存对象实例和数组等数据。当堆中没有足够的内存空间分配给新的对象实例,且无法再进行扩展时,就会抛出 OutOfMemoryError 异常。
- 分区
- 年轻代:被划分为三部分,分别是 Eden 区和两个大小相同的 Survivor 区(一般称为 From Survivor 和 To Survivor)。在垃圾收集过程中,新创建的对象通常会首先分配到 Eden 区,经过几次垃圾收集后,仍然存活在 Survivor 区的对象会根据 JVM 的策略被移动到老年代。
- 老年代:主要保存生命周期较长的对象,比如一些长时间存在的对象实例,这些对象在年轻代经过多次垃圾回收后依然存活,就会被移动到老年代。
- 元空间(Java 8 及以后):在 Java 8 中,为了避免方法区(之前的永久代)出现 OOM 问题,将其移动到了本地内存,重新开辟了一块空间称为元空间。元空间主要保存**类信息、静态变量、常量、编译后的代码等数据。**它的大小默认仅受本地内存的限制,相比之前的永久代,更加灵活,减少了因永久代内存不足导致的 OOM 情况。
5. 什么是虚拟机栈?
- 定义:每个线程运行时所需要的内存空间称为虚拟机栈,它遵循先进后出(FILO)的原则。每个栈由多个栈帧(frame)组成,每个栈帧对应着每次方法调用时所占用的内存。
- 栈帧相关:每个线程在任何时刻只能有一个活动栈帧,这个活动栈帧对应着当前正在执行的方法。当一个方法被调用时,就会创建一个新的栈帧并压入栈中;当方法执行完毕时,栈帧会从栈中弹出,释放相应的内存。
- 与垃圾回收的关系:垃圾回收主要针对堆内存,当栈帧弹栈后,其所占用的内存会自动释放,因此栈内存的管理相对简单,一般不需要垃圾回收机制来处理。
- 栈内存大小影响:栈内存并非越大越好。默认情况下,栈内存通常为 1024k。如果栈内存设置过大,会导致线程数变少。例如,假设机器总内存为 512m,按照默认栈内存 1024k 计算,能活动的线程数为 512 个;如果将栈内存改为 2048k,那么能活动的线程数就会减半。
- 局部变量线程安全性
- 如果方法内的局部变量没有逃离方法的作用范围,即该变量只在方法内部使用,那么它是线程安全的,因为不同线程不会同时访问到这个局部变量。
- 如果局部变量引用了对象,并且该引用逃离了方法的作用范围,例如作为方法的返回值被其他线程获取到,此时就需要考虑线程安全问题,因为多个线程可能会同时访问和修改这个对象,从而导致数据不一致或其他错误。
- 栈内存溢出情况
- 栈帧过多会导致栈内存溢出,典型的情况是递归调用。如果递归调用没有正确设置终止条件,会不断创建新的栈帧,最终导致栈内存耗尽,抛出 java.lang.StackOverFlowError 异常。
- 栈帧过大也可能导致栈内存溢出,因为每个栈帧占用的空间过大,会使得栈能够容纳的栈帧数量减少,从而更容易出现内存不足的情况。
栈帧的压入与弹出:方法调用的数据通过栈传递。每次方法调用,会对应一个栈帧被压入栈中;方法调用结束,对应的栈帧被弹出。
栈帧的组成
- 局部变量表:存放编译期可知的多种数据类型,包括基本数据类型(如 boolean、byte、char、short、int、float、long、double)以及对象引用(reference 类型,可能是指向对象起始地址的引用指针,也可能是指向代表对象的句柄或其他相关位置)。
- 操作数栈:作为方法调用的中转站,用于存储方法执行过程中产生的中间计算结果,计算时的临时变量也存放于此。
- 动态链接:主要用于当一个方法需要调用其他方法的场景。Class 文件常量池存有大量符号引用(如方法引用的符号引用),动态链接负责将常量池中指向方法的符号引用转换为内存地址中的直接引用,这一过程也称为动态连接。
- 方法返回地址:方法执行结束时,用于确定返回的位置。
6. JVM 运行时数据区有哪些组成部分,各自的作用是什么?
- 堆:主要用于存储对象实例,是垃圾回收器管理的主要区域。对象的创建、存储和销毁等操作都与堆内存密切相关。
- 方法区:可以看作是堆的一部分,用于存储已被虚拟机加载的类信息、常量、静态变量以及即时编译器编译后的代码等。在 Java 8 之前,方法区的实现是永久代;Java 8 及以后,方法区由元空间实现,元空间使用本地内存,避免了永久代常见的 OOM 问题。
- 栈:解决程序运行时的方法调用和局部变量存储问题。栈中存储的是栈帧,每个栈帧包含局部变量表、操作数栈、动态链接、方法出口等信息。
- 本地方法栈:与栈的功能类似,主要用于执行本地方法,即 Java 调用非 Java 代码(如 C、C++ 代码)的接口。当 Java 程序需要调用本地方法时,会在本地方法栈中创建相应的栈帧来执行这些方法。
- 程序计数器:如前面所述,它是线程私有的,保存当前线程正在执行的字节码指令的地址,用于线程切换后恢复执行位置。
7. 解释一下方法区?
- 概述
- 方法区是各个线程共享的内存区域,在虚拟机启动时创建,关闭虚拟机时释放。
- 主要存储类的信息(如类的结构、字段、方法等)、运行时常量池等数据。如果方法区域中的内存无法满足分配请求,就会抛出 OutOfMemoryError: Metaspace 异常(在 Java 8 及以后使用元空间实现方法区)。
- 常量池:可以看作是一张表,虚拟机指令根据这张表找到要执行的类名、方法名、参数类型、字面量等信息。通过
javap -v xx.class
命令可以查看字节码结构,包括类的基本信息、常量池和方法定义等。例如,对于一个包含main
方法的Application
类,执行该命令后,可以看到常量池中存储了类名、方法名、字段引用、字符串常量等信息,main
方法在执行时,需要到常量池中查找具体的类和方法地址进行调用。 - 运行时常量池:常量池是存在于
.class
文件中的,当该类被加载到 JVM 中时,它的常量池信息会被放入运行时常量池,并将其中的符号地址转换为真实地址,以便 JVM 能够正确执行字节码指令。
8. 什么是直接内存,它有什么特点和应用场景?
- 定义:直接内存不受 JVM 内存回收管理,它是虚拟机使用的系统内存。
- 特点:常见于 NIO(New IO)操作中,用于数据缓冲区。直接内存的分配和回收成本相对较高,因为它需要与操作系统进行交互来申请和释放内存。但是,它的读写性能很高,这是因为它不需要在堆内存和系统内存之间进行数据拷贝,JVM 可以直接操作直接内存。
- 应用场景:以将本地电脑中一个较大的文件(如超过 100m 的视频文件)从一个磁盘挪到另一个磁盘为例,使用传统的 IO 操作和 NIO 操作进行对比。通过代码测试可以发现,使用传统 IO 操作的时间要比 NIO 操作的时间长很多,这是因为 NIO 操作利用了直接内存的特性,减少了数据拷贝的开销,从而提高了数据传输的效率。在传统阻塞 IO 中,数据需要先从磁盘读取到操作系统的缓冲区,再从操作系统缓冲区拷贝到 JVM 的堆内存,然后再进行后续处理;而在 NIO 中,数据可以直接读取到直接内存中,JVM 可以直接对其进行操作,大大提高了读写性能。
9. 堆栈的区别是什么?
- 存储内容不同:栈内存一般用于存储局部变量和方法调用相关的信息,例如方法的参数、局部变量等;而堆内存主要用于存储 Java 对象和数组等数据。
- 内存管理方式不同:堆内存会进行 GC 垃圾回收,当对象不再被引用时,垃圾回收器会自动回收其占用的内存;而栈内存的管理相对简单,当栈帧弹栈时,其所占用的内存会自动释放,不需要垃圾回收机制。
- 线程相关性不同:栈内存是线程私有的,每个线程都有自己独立的栈,线程之间的栈内存相互隔离;而堆内存是线程共有的,多个线程可以同时访问和操作堆中的对象。
- 异常类型不同:如果栈内存不足,会抛出 java.lang.StackOverFlowError 异常,通常是由于递归调用过深或栈帧过大导致栈空间耗尽;如果堆内存不足,会抛出 java.lang.OutOfMemoryError 异常,例如创建过多的对象导致堆内存被耗尽。嘻嘻
10. 什么是类加载器,类加载器有哪些?
类加载器
JVM只会运行二进制文件,而类加载器(ClassLoader)的主要作用就是将字节码文件加载到JVM中,从而让Java程序能够启动起来。现有的类加载器基本上都是java.lang.ClassLoader的子类,该类的主要职责就是用于将指定的类找到或生成对应的字节码文件,同时类加载器还会负责加载程序所需要的资源。
类加载器种类
类加载器根据各自加载范围的不同,划分为四种类加载器:
- 启动类加载器(BootStrap ClassLoader):
- 该类并不继承ClassLoader类,其是由C++编写实现。用于加载JAVA_HOME/jre/lib目录下的类库。
- 扩展类加载器(ExtClassLoader):
- 该类是ClassLoader的子类,主要加载JAVA_HOME/jre/lib/ext目录中的类库。
- 应用类加载器(AppClassLoader):
- 该类是ClassLoader的子类,主要用于加载classPath下的类,也就是加载开发者自己编写的Java类。
- 自定义类加载器:
- 开发者自定义类继承ClassLoader,实现自定义类加载规则。
类加载器的体系并不是“继承”体系,而是委派体系,类加载器首先会到自己的parent中查找类或者资源,如果找不到才会到自己本地查找。类加载器的委托行为动机是为了避免相同的类被加载多次。
11. 什么是双亲委派模型?
如果一个类加载器在接到加载类的请求时,它首先不会自己尝试去加载这个类,而是把这个请求任务委托给父类加载器去完成,依次递归,如果父类加载器可以完成类加载任务,就返回成功;只有父类加载器无法完成此加载任务时,才由下一级去加载。
12. JVM为什么采用双亲委派机制
(1)通过双亲委派机制可以避免某一个类被重复加载,当父类已经加载后则无需重复加载,保证唯一性。
(2)为了安全,保证类库API不会被修改
13. 说一下类装载的执行过程?
类从加载到虚拟机中开始,直到卸载为止,它的整个生命周期包括了:加载、验证、准备、解析、初始化、使用和卸载这7个阶段。其中,验证、准备和解析这三个部分统称为连接(linking)。
加载: 查找和导入class文件
验证: 保证加载类的准确性
准备: 为类变量分配内存并设置类变量初始值
解析: 把类中的符号引用转换为直接引用
初始化: 对类的静态变量,静态代码块执行初始化操作
使用: JVM 开始从入口方法开始执行用户的程序代码
卸载: 当用户程序代码执行完毕后,IVM便开始销毁创建的Class对象。
一、加载
- 核心任务:通过类的全限定名(类的全名),获取该类的二进制数据流。这通常涉及到从文件系统、网络或其他存储位置读取类的字节码文件(
.class
文件)。接着,将类的二进制数据流解析为方法区内特定的数据结构,即构建起符合 Java 类模型的数据表示。最后,创建java.lang.Class
类的实例,这个实例作为后续对该类各种数据(如字段、方法等)进行访问的入口。 - 示例说明:假设存在一个
User
类,类加载器会根据User
类的全限定名找到对应的.class
文件,读取其字节码数据,将其转换为 JVM 内部能够理解的类模型结构,并创建Class<User>
实例,后续可以通过这个实例获取User
类的相关信息。
二、连接
- 验证
- 目的:确保被加载的类符合 JVM 规范,是安全可靠的,防止恶意或错误的字节码对 JVM 运行造成危害。
- 具体内容
- 文件格式验证:检查字节码文件是否符合
Class
文件的规范,例如文件的魔数、版本号、常量池格式等是否正确。 - 元数据验证:验证类的元数据信息,如类是否有父类(除
Object
外应都有父类)、是否继承了被final
修饰的类(不允许继承)、类中的字段和方法是否与父类存在矛盾(如覆盖final
方法或字段)等。 - 字节码验证:对字节码进行数据流和控制流分析,确保程序语义合法、逻辑正确,例如检查跳转指令是否指向合法的位置、操作数栈的操作是否正确等。
- 符号引用验证:验证符号引用的有效性,如类、字段、方法等的符号引用是否能正确解析到目标。
- 文件格式验证:检查字节码文件是否符合
- 准备
- 内存分配:为类变量(被
static
修饰的变量)分配内存空间。此时,类变量会被赋予默认初始值,例如int
类型变量初始化为0
,boolean
类型变量初始化为false
等。 - 特殊情况:对于
static
且为final
的基本类型变量,以及字符串常量,其值在编译期就已确定,会在准备阶段直接赋值。而对于static
且为final
的引用类型变量,赋值操作则在初始化阶段完成。
- 内存分配:为类变量(被
- 解析
- 任务:将类中的符号引用转换为直接引用。符号引用是一组用于描述所引用目标的符号,与虚拟机实现的内存布局无关;而直接引用是可以直接指向目标的指针、相对偏移量或能间接定位目标的句柄,与虚拟机内存布局直接相关。
- 示例:当一个方法中调用了其他方法时,方法名在编译期是符号引用,在解析阶段会将其转换为指向实际方法内存地址的直接引用,使得 JVM 能够直接调用该方法。
三、初始化
- **静态成员初始化:**对类的静态变量和静态代码块执行初始化操作。
- 父类优先:如果初始化一个类时,其父类尚未初始化,则优先初始化其父类。这保证了类继承体系中,父类的静态资源先被初始化,子类可以基于父类已初始化的状态进行自身的初始化。
- 顺序执行:当类中同时包含多个静态变量和静态代码块时,按照自上而下的顺序依次执行。例如,先定义的静态变量会先于后面的静态代码块以及后续定义的静态变量被初始化。
- 触发时机:初始化阶段通常在以下几种情况下触发:创建类的实例(使用
new
关键字)、调用类的静态方法、访问类的静态字段(除final
常量外,因为其在准备阶段已赋值)、反射调用类等。
四、使用
- 执行入口方法:JVM 从程序的入口方法(如
main
方法)开始执行用户的程序代码。这是程序运行的起点,后续的代码执行和逻辑处理都基于此展开。 - 调用静态成员:在程序执行过程中,可以调用类的静态成员信息,包括静态字段和静态方法。静态成员是类级别的资源,可被类的所有实例共享访问,常用于实现与类相关的通用功能或存储类级别的数据。
- 创建对象实例:使用
new
关键字为类创建对象实例。创建对象时,会在堆内存中分配空间,初始化对象的成员变量,并调用对象的构造函数完成对象的初始化。创建的对象实例可以访问类的非静态成员,执行对象特定的行为和操作。
五、卸载
当用户程序代码执行完毕后,JVM 会开始销毁之前创建的Class
对象。这包括释放与类相关的所有内存资源,如类的元数据信息(存储在方法区)、静态变量所占用的内存等。随着所有相关Class
对象被销毁,负责运行程序的 JVM 也会退出内存,整个类加载的生命周期结束。
通过对类加载各个阶段的详细了解,可以更好地理解 Java 程序在运行时的行为和机制,对于排查问题、优化性能等方面具有重要意义。
14. 简述Java垃圾回收机制?(GC是什么?为什么要GC)
为了让程序员更专注于代码的实现,而不用过多的考虑内存释放的问题,所以,在Java语言中,有了自动的垃圾回收机制,也就是我们熟悉的GC(Garbage Collection)。
有了垃圾回收机制后,程序员只需要关心内存的申请即可,内存的释放由系统自动识别完成。
在进行垃圾回收时,不同的对象引用类型,GC会采用不同的回收时机
换句话说,自动的垃圾回收的算法就会变得非常重要了,如果因为算法的不合理,导致内存资源一直没有释放,同样也可能会导致内存溢出的。
15. 对象什么时候可以被垃圾器回收
简单一句就是:如果一个或多个对象没有任何的引用指向它了,那么这个对象现在就是垃圾,如果定位了垃圾,则有可能会被垃圾回收器回收。
如果要定位什么是垃圾,有两种方式来确定,第一个是引用计数法,第二个是可达性分析算法。
16. 定位垃圾的方法
1. 引用计数法
- 原理:对象每被引用一次,其引用计数就递增一次;当引用计数为 0 时,该对象可被回收。例如
String demo = new String("123");
,此时demo
对象引用计数为 1,当demo = null;
时,demo
对象引用计数降为 0,可被回收。 - 缺点:对象间存在循环引用时会失效。例如两个对象相互引用,即使它们不再被外部引用,引用计数也不会为 0,导致无法回收,引发内存泄漏。同时,每次对象引用状态改变都要更新计数器,有时间开销且浪费 CPU 资源,即便内存充足也持续统计。
2. 可达性分析算法
- 原理:以 GC Roots 为根节点,通过引用关系向下遍历所有对象。若对象与根节点无直接或间接引用,则可被判定为垃圾回收对象。GC Roots 包括虚拟机栈(栈帧中的本地变量表)中引用的对象、方法区中类静态属性引用的对象、方法区中常量引用的对象以及本地方法栈中 JNI 引用的对象。
- 对象回收特殊情况:对象被标记为可回收后,若其
finalize
方法未执行,GC 时会先执行该方法。在finalize
方法中,若设置对象与 GC ROOTS 产生关联,则 GC 再次判断时该对象可达,不会被回收;若仍不可达,则进行回收。且finalize
方法对每个对象仅执行一次。
17. 垃圾回收算法
(一)标记清除算法
- 流程:分标记和清除两个阶段。先根据可达性分析算法标记所有可回收对象,然后对标记对象进行回收。
- 优缺点:能解决引用计数法的循环引用问题,但效率低,标记和清除都需遍历所有对象,且 GC 时需停止应用程序,影响交互性。清理出的内存碎片化严重,因为被回收对象分散在内存各处,导致内存不连贯。
(二)复制算法
- 流程:将内存空间一分为二,每次只使用其中一块。垃圾回收时,将正在使用块中的存活对象复制到另一块,然后清空当前使用块,两块内存角色交换,重复此过程。
- 适用场景及优缺点:当内存中垃圾对象较多,需复制的存活对象较少时效率高,且清理后内存无碎片。但同一时刻只能使用一半内存,内存使用率低。
(三)标记整理算法
- 流程:在标记清除算法基础上改进。同样先标记可回收对象,清理阶段将存活对象向内存一端移动,然后清理边界以外的垃圾。
- 优缺点:解决了标记清除算法的内存碎片化问题,但多了对象移动步骤,效率会受一定影响。与复制算法相比,复制算法标记完就复制对象,而标记整理算法需等所有存活对象标记完毕后再整理。
(四)分代收集算法
- 堆内存划分:Java 8 时,堆分为新生代和老年代,比例为 1:2。新生代又细分为 Eden 区、S0 区和 S1 区,比例为 8:1:1。Java 7 时还存在永久代。
- 不同 GC 类型
- Minor GC(Young GC):发生在新生代的垃圾回收,暂停时间短(STW, Stop-The-World)。新创建对象先分配到 Eden 区,Eden 区内存不足时,标记 Eden 区和 from 区(初始时无)的存活对象,用复制算法将其复制到 to 区,Eden 区和 from 区内存释放。经过多次回收,幸存区对象若熬过一定次数(最多 15 次)或因幸存区内存不足、大对象等原因,会晋升到老年代。
- Major GC:发生在老年代的垃圾回收。
- Full GC:新生代和老年代完整垃圾回收,暂停时间长,应尽量避免。
- Mixed GC:新生代和老年代部分区域的垃圾回收,是 G1 收集器特有的。
18. 垃圾回收器
(一)串行垃圾收集器
- 特点:使用单线程进行垃圾回收,适用于堆内存较小的个人电脑。
- 应用区域及算法:Serial 用于新生代,采用复制算法;Serial Old 用于老年代,采用标记 - 整理算法。垃圾回收时,Java 应用所有线程都要暂停(STW)等待回收完成。
(二)并行垃圾收集器
- 特点:多线程进行垃圾回收,JDK8 默认使用。
- 应用区域及算法:Parallel New 作用于新生代,采用复制算法;Parallel Old 作用于老年代,采用标记 - 整理算法。同样在垃圾回收时,所有应用线程需暂停(STW)。
(三)CMS(并发)垃圾收集器
- 特点:针对老年代,采用标记 - 清除算法,以获取最短回收停顿时间为目标,垃圾回收时应用仍能正常运行,停顿时间短,用户体验好。
(四)G1 垃圾收集器
- 概述:应用于新生代和老年代,JDK9 之后默认使用。将内存划分为多个区域,每个区域可充当 eden、survivor、old、humongous(专为大对象准备)。采用复制算法,兼顾响应时间与吞吐量。分为新生代回收、并发标记、混合收集三个阶段,若并发失败(回收速度赶不上创建新对象速度),会触发 Full GC。
- 工作阶段
- 年轻代垃圾回收(Young Collection):初始时所有区域空闲,创建对象存于伊甸园区。伊甸园需回收时,选一个空闲区域做幸存区,用复制算法复制存活对象,需暂停用户线程。后续伊甸园内存再不足时,将伊甸园及之前幸存区存活对象复制到新幸存区,较老对象晋升到老年代。
- 年轻代垃圾回收 + 并发标记(Young Collection + Concurrent Mark):老年代占用内存超阈值(默认 45%)后触发并发标记,此时无需暂停用户线程。并发标记后有重新标记阶段解决漏标问题,需暂停用户线程。完成后确定老年代存活对象,进入混合收集阶段,根据暂停时间目标优先回收存活对象少的区域。
- 混合收集(Mixed Collection):完成对象复制,释放内存,进入下一轮回收循环。
19、对象引用类型
(一)强引用
只有所有 GC Roots 对象都不通过强引用引用该对象时,该对象才能被垃圾回收。例如User user = new User();
,只要user
引用存在,User
对象就不会被回收。
(二)软引用
仅有软引用引用对象时,垃圾回收后若内存仍不足,会再次触发对该对象的回收。如User user = new User(); SoftReference softReference = new SoftReference(user);
,在内存紧张时,软引用对象可能被回收。
(三)弱引用
仅有弱引用引用对象时,垃圾回收时无论内存是否充足,都会回收该对象。例如User user = new User(); WeakReference weakReference = new WeakReference(user);
。ThreadLocal 使用弱引用,其内部Entry
类的key
为弱引用,value
为强引用。当key
被回收而value
未清理时可能导致内存泄漏,因此使用 ThreadLocal 后建议调用清理方法。
(四)虚引用
必须配合引用队列使用,被引用对象回收时,虚引用会入队,由 Reference Handler 线程调用相关方法释放直接内存。
JVM 调优实践
1. JVM 调优参数设置位置
- Tomcat:要在 Tomcat 里设置 JVM 调优参数,得去修改
TOMCAT_HOME/bin/catalina.sh
文件。比如,你想设置堆的初始大小为 512MB,最大大小为 1024MB,就在文件里加上JAVA_OPTS="-Xms512m -Xmx1024m"
。这样,Tomcat 启动时就会按照你设置的参数来分配堆内存。 - Spring Boot 项目:在 Linux 系统下启动 Spring Boot 项目时,能直接在启动命令里加参数。像
nohup java -Xms512m -Xmx1024m -jar xxxx.jar --spring.profiles.active=prod &
,nohup
能让命令在后台一直运行,关了终端也不受影响,&
也是让命令在后台执行。这里同样设置了堆的初始和最大大小,还指定了项目运行环境是prod
。
2. 常用 JVM 调优参数
- 堆大小设置
-Xms
:用来设置堆的初始化大小。比如说-Xms512m
,意思就是堆刚开始就分配 512MB 内存。通常为了不让垃圾收集器在初始大小和最大大小之间来回调整堆大小,浪费时间,会把-Xms
和-Xmx
设置成一样的值。-Xmx
:这个是设置堆能达到的最大大小。例如-Xmx1024m
,就是说堆最多可以使用 1024MB 内存。
- 新生代内存分配比例设置
-XX:SurvivorRatio
:它决定年轻代里 Eden 区和两个 Survivor 区的大小比例。默认是 8:1:1 。要是设置-XXSurvivorRatio=3
,那就表示年轻代里 survivor 区和 eden 区的比例是 2:3 。Java 官方一般会增大 Eden 区来减少 YGC(新生代垃圾回收)的次数,但 Eden 区太大,满了之后释放内存会变慢,程序暂停(STW)的时间就长,所以要根据实际程序情况来调整这个比例。-XX:newSize
:设置年轻代的初始大小。-XX:MaxNewSize
:设置年轻代能达到的最大大小。一般把这两个值设成一样,默认年轻代和老年代的空间比例是 1:2 ,通过这两个参数可以调整它们的大小。
- 线程堆栈设置
-Xss
:每个线程默认有 1M 的堆栈空间,用来放栈帧、调用参数、局部变量这些东西。但一般 256K 就够了。如果减少每个线程的堆栈大小,理论上能产生更多线程,不过这也受操作系统限制。比如-Xss128k
,就是把每个线程的堆栈大小设成 128KB 。
- 对象晋升老年代相关设置
-Xmn
:设置年轻代的大小。合理设置 eden 区、survivor 区以及它们的使用率,能让年轻对象留在年轻代,避免 Full GC(全量垃圾回收)。-XX:PetenureSizeThreshold
:指定对象晋升到老年代的大小门槛,单位是字节。比如-XX:PetenureSizeThreshold=1000000
,就是说对象大小超过 1MB,就直接在老年代分配内存,防止在年轻代频繁移动大对象,导致 Full GC。-XX:MaxTenuringThreshold
:设置对象在 Survivor 区存活的最大年龄。年轻对象在 Eden 区经过第一次 GC 后还活着,就会被移到 Survivor 区,之后每 GC 一次年龄加 1 ,当年龄达到这个阈值,就会被移到老年区。要是想让对象留在年轻代,就设个大一点的阈值。
- 垃圾回收器选择设置
-XX:+UseParallelGC
:让年轻代使用并行垃圾回收收集器,这个收集器注重吞吐量,能减少垃圾回收的时间。-XX:+UseParallelOldGC
:设置老年代也用并行垃圾回收收集器。-XX:+UseConcMarkSweepGC
:让老年代用 CMS(Concurrent Mark Sweep)收集器,这个收集器能降低垃圾回收时程序暂停的时间。
- 内存分页设置
-XX:+LargePageSizeInBytes
:设置内存页的大小。用大的内存分页可以让 CPU 内存寻址能力变强,提高系统性能。
3. JVM 调优工具
- 命令行工具
jps
(Java Process Status) :它能输出 JVM 里正在运行的进程状态信息,通过它可以看到当前运行的 Java 进程以及相关信息。jstack
:用来查看 Java 进程里线程的堆栈信息。使用方法是jstack [option] <pid>
,<pid>
就是进程 ID 。通过分析线程堆栈,能找到线程死锁、长时间阻塞这些问题。jmap
可以生成堆转存快照。常见命令有:jmap -heap pid
:能显示 Java 堆的信息,像各代的大小、比例,还有使用情况。jmap -dump:format=b,file=heap.hprof pid
:把 Java 堆内存以 hprof 二进制格式转储,并且保存成你指定的文件heap.hprof
。
jstat
是 JVM 的统计监测工具,能显示垃圾回收信息、类加载信息、新生代统计信息等。常见参数有:jstat -gcutil pid
:总结垃圾回收的统计信息,包括各代的使用率、GC 次数、GC 花的时间等。jstat -gc pid
:详细输出垃圾回收统计信息,比如对象在各代之间怎么移动、怎么回收的。
jhat
:可以分析jmap
生成的堆转存快照,但一般不推荐用它,现在常用 Eclipse Memory Analyzer 这类工具替代。
- 可视化工具
jconsole
:这是基于 JMX(Java Management Extensions)的图形化性能监控工具,可以监控 JVM 的内存、线程、类等。在 Java 安装目录的bin
目录下,直接启动jconsole.exe
(Windows 系统)或者jconsole
(Linux 系统),连上目标 Java 进程,就能很直观地看到各种运行指标。VisualVM
:是故障处理工具,能监控线程、内存情况,查看方法耗费 CPU 的时间,内存里的对象,已经被 GC 的对象,还能反向查看对象分配的堆栈。在 Java 安装目录的bin
目录下启动jvisualvm.exe
(Windows 系统),它能加载离线的堆转存快照(.hprof
文件)进行分析,方便找出内存泄漏这类问题。
4. Java 内存泄露排查思路
-
生成内存快照可以用jmap命令指定生成内存快照 dump 文件。但要是程序因为内存溢出已经中断了,
jmap就没法用了。
这时候可以设置 VM 参数让程序自动生成 dump 文件,设置方法是:
-XX:+HeapDumpOnOutOfMemoryError
:打开内存溢出时自动生成堆转储快照的功能。-XX:HeapDumpPath=/home/app/dumps/
:指定生成文件保存的目录是/home/app/dumps/
。
-
分析 dump 文件:用工具来分析 dump 文件,比如 JDK 自带的
VisualVM
(也可以用 Eclipse Memory Analyzer 等)。VisualVM
能加载离线的 dump 文件,如果是 Linux 系统里程序生成的 dump 文件,得先下载到本地(比如 Windows 环境),再用VisualVM
打开分析。 -
定位问题代码:通过查看堆信息,像对象数量、大小、引用关系这些,大概就能找到内存溢出是哪段代码导致的。
-
修复问题:找到对应的代码,看看上下文,分析对象的生命周期和引用关系,然后修改导致内存泄漏的代码逻辑,比如释放掉没用的引用,优化对象创建和销毁的方式等。
5. 服务器 CPU 持续飙高排查方案与思路
- 使用
top
命令查看 CPU 占用情况:在终端输入top
命令,就能实时看到系统里各个进程的 CPU 占用率等信息,这样就能找出哪个进程占用 CPU 比较高。 - 确定高 CPU 占用进程 ID:从
top
命令的结果里,记下占用 CPU 高的那个进程的 ID 。 - 查看进程内线程信息:用
ps
命令查看这个进程里的线程信息,找出 CPU 占用高的线程。比如ps H -eo pid,tid,%CPU | grep <进程ID>
,pid
是进程 ID ,tid
是线程 ID ,%CPU
是 CPU 使用率,这个命令能筛选出指定进程里每个线程的 CPU 使用情况。 - 转换线程 ID 为十六进制:通常日志里显示的线程 ID 是十六进制的,所以要把前面得到的十进制线程 ID 转换成十六进制。在 Linux 里用
printf "%x\n" <线程ID>
这个命令就能转换。 - 定位问题代码行号:用
jstack
命令打印进程的堆栈信息,根据转换后的十六进制线程 ID 找到有问题的线程,进而找到问题代码在源码里的行号。执行jstack <进程ID>
命令,分析输出的堆栈信息,找到线程执行的方法调用栈,就能确定是哪段代码导致 CPU 飙高,然后再分析代码逻辑,看看是不是有死循环、复杂计算没优化这些问题,找到原因后进行修复。
面试现场
一、JVM组成
面试官:JVM由那些部分组成,运行流程是什么?
候选人:
嗯,好的~~
在JVM中共有四大部分,分别是ClassLoader(类加载器)、Runtime Data Area(运行时数据区,内存分区)、Execution Engine(执行引擎)、Native Method Library(本地库接口)
它们的运行流程是:
第一,类加载器(ClassLoader)把Java代码转换为字节码
第二,运行时数据区(Runtime Data Area)把字节码加载到内存中,而字节码文件只是JVM的一套指令集规范,并不能直接交给底层系统去执行,而是有执行引擎运行
第三,执行引擎(Execution Engine)将字节码翻译为底层系统指令,再交由CPU执行去执行,此时需要调用其他语言的本地库接口(Native Method Library)来实现整个程序的功能。
面试官:好的,你能详细说一下 JVM 运行时数据区吗?
候选人:
嗯,好~
运行时数据区包含了堆、方法区、栈、本地方法栈、程序计数器这几部分,每个功能作用不一样。
- 堆解决的是对象实例存储的问题,垃圾回收器管理的主要区域。
- 方法区可以认为是堆的一部分,用于存储已被虚拟机加载的信息,常量、静态变量、即时编译器编译后的代码。
- 栈解决的是程序运行的问题,栈里面存的是栈帧,栈帧里面存的是局部变量表、操作数栈、动态链接、方法出口等信息。
- 本地方法栈与栈功能相同,本地方法栈执行的是本地方法,一个Java调用非Java代码的接口。
- 程序计数器(PC寄存器)程序计数器中存放的是当前线程所执行的字节码的行数。JVM工作时就是通过改变这个计数器的值来选取下一个需要执行的字节码指令。
面试官:好的,你再详细介绍一下程序计数器的作用?
候选人:
嗯,是这样~~
java虚拟机对于多线程是通过线程轮流切换并且分配线程执行时间。在任何的一个时间点上,一个处理器只会处理执行一个线程,如果当前被执行的这个线程它所分配的执行时间用完了【挂起】。处理器会切换到另外的一个线程上来进行执行。并且这个线程的执行时间用完了,接着处理器就会又来执行被挂起的这个线程。这时候程序计数器就起到了关键作用,程序计数器在来回切换的线程中记录他上一次执行的行号,然后接着继续向下执行。
面试官:你能给我详细的介绍Java堆吗?
候选人:
好的~
Java中的堆术语线程共享的区域。主要用来保存对象实例,数组等,当堆中没有内存空间可分配给实例,也无法再扩展时,则抛出OutOfMemoryError异常。
在JAVA8中堆内会存在年轻代、老年代
1)Young区被划分为三部分,Eden区和两个大小严格相同的Survivor区,其中,Survivor区间中,某一时刻只有其中一个是被使用的,另外一个留做垃圾收集时复制对象用。在Eden区变满的时候, GC就会将存活的对象移到空闲的Survivor区间中,根据JVM的策略,在经过几次垃圾收集后,任然存活于Survivor的对象将被移动到Tenured区间。
2)Tenured区主要保存生命周期长的对象,一般是一些老的对象,当一些对象在Young复制转移一定的次数以后,对象就会被转移到Tenured区。
面试官:能不能解释一下方法区?
候选人:
方法区(Method Area)是 Java 虚拟机(JVM)内存结构中的一个区域,它主要用于存储已被虚拟机加载的类信息(版本、字段、方法、接口等)、常量、静态变量、即时编译器编译后的代码等数据。方法区是所有线程共享的内存区域,它在虚拟机启动时创建,并且在虚拟机的生命周期内一直存在。
需要注意的是,在 JDK 1.8 之前,方法区被称为永久代(Permanent Generation),它与堆内存的其他区域(如新生代、老年代)类似,是 JVM 的一部分。从 JDK 1.8 开始,永久代被移除,方法区被重新设计为元空间(Metaspace),元空间使用本地内存(Native Memory),而不是堆内存。
面试官:你听过直接内存吗?
候选人:
直接内存也被称为堆外内存,它是一种位于 Java 虚拟机堆外的内存,通常通过 Java Native Interface(JNI)或者
java.nio.ByteBuffer.allocateDirect()
方法进行分配。这种内存直接使用系统的本地内存,因此它不受 JVM 堆大小的限制,也不会受到垃圾回收(GC)机制的管理。由于直接内存是线程共享的,所以所有线程都可以访问其中的数据。此外,直接内存特别适合用于高性能的 I/O 操作,因为它能够减少数据在 JVM 堆和本地内存之间的拷贝,从而提高 I/O 操作的效率。不过,由于直接内存不受 GC 管理,开发者需要手动管理内存的分配和释放,否则可能会导致内存泄漏。面试官:Java 8 中将方法区从永久代迁移到元空间的改动带来了哪些好处和潜在影响?
候选人
在 Java 8 之前,方法区是通过 HotSpot 虚拟机的永久代来实现的,它存储了类的信息、常量、静态变量以及即时编译器编译后的代码等。由于永久代是 JVM 堆的一部分,它受到垃圾回收(GC)的管理,并且有一个
-XX:MaxPermSize
的上限。这导致在大量动态生成类时,很容易出现OutOfMemoryError
。虽然可以通过增大-XX:MaxPermSize
来缓解,但很难确定一个合适的大小,因为它受到类的数量和常量数量的影响很大。从 Java 8 开始,方法区的实现被迁移到了本地内存中的元空间。元空间使用本地内存而不是 JVM 堆内存,因此它不受
-XX:MaxPermSize
的限制,也不受 JVM 垃圾回收机制的直接管理。这种改动带来了以下好处和潜在影响:好处
- 减少
OutOfMemoryError
:由于元空间使用本地内存,不再受 JVM 堆大小的限制,因此减少了因永久代空间不足而导致的OutOfMemoryError
。- 更灵活的内存管理:元空间的大小可以通过
-XX:MaxMetaspaceSize
参数进行设置,但默认情况下,元空间会自动扩展,避免了手动调整内存大小的麻烦。- 性能提升:由于元空间不再受 GC 的频繁管理,减少了 GC 的负担,从而提升了整体性能。
潜在影响
- 内存使用增加:元空间使用本地内存,可能会导致系统内存的使用量增加,尤其是在类加载频繁的应用中。
- 监控和管理复杂性:由于元空间使用本地内存,监控和管理元空间的内存使用情况可能比监控永久代更加复杂。
- 兼容性问题:对于一些依赖于永久代特性的旧代码或工具,可能需要进行调整以适应新的元空间机制。
面试官:什么是虚拟机栈
候选人:
虚拟机栈是描述的是方法执行时的内存模型,是线程私有的,生命周期与线程相同,每个方法被执行的同时会创建栈桢。保存执行方法时的局部变量、动态连接信息、方法返回地址信息等等。方法开始执行的时候会进栈,方法执行完会出栈【相当于清空了数据】,所以这块区域不需要进行 GC。
面试官:能说一下堆栈的区别是什么吗?
候选人:
嗯,好的,有这几个区别
第一,栈内存一般会用来存储局部变量和方法调用,但堆内存是用来存储Java对象和数组的的。堆会GC垃圾回收,而栈不会。
第二、栈内存是线程私有的,而堆内存是线程共有的。
第三、两者异常错误不同,但如果栈内存或者堆内存不足都会抛出异常。
栈空间不足:java.lang.StackOverFlowError。
堆空间不足:java.lang.OutOfMemoryError。
二、类加载器
面试官:什么是类加载器,类加载器有哪些?
候选人:
嗯,是这样的
JVM只会运行二进制文件,而类加载器(ClassLoader)的主要作用就是将字节码文件加载到JVM中,从而让Java程序能够启动起来。
常见的类加载器有4个
第一个是启动类加载器(BootStrap ClassLoader):其是由C++编写实现。用于加载JAVA_HOME/jre/lib目录下的类库。
第二个是扩展类加载器(ExtClassLoader):该类是ClassLoader的子类,主要加载JAVA_HOME/jre/lib/ext目录中的类库。
第三个是应用类加载器(AppClassLoader):该类是ClassLoader的子类,主要用于加载classPath下的类,也就是加载开发者自己编写的Java类。
第四个是自定义类加载器:开发者自定义类继承ClassLoader,实现自定义类加载规则。
面试官:说一下类装载的执行过程?
候选人:
嗯,这个过程还是挺多的。
类从加载到虚拟机中开始,直到卸载为止,它的整个生命周期包括了:加载、验证、准备、解析、初始化、使用和卸载这7个阶段。其中,验证、准备和解析这三个部分统称为连接(linking)
1.加载:查找和导入class文件
2.验证:保证加载类的准确性
3.准备:为类变量分配内存并设置类变量初始值
4.解析:把类中的符号引用转换为直接引用
5.初始化:对类的静态变量,静态代码块执行初始化操作
6.使用:JVM 开始从入口方法开始执行用户的程序代码
7.卸载:当用户程序代码执行完毕后,JVM 便开始销毁创建的 Class 对象,最后负责运行的 JVM 也退出内存
面试官:什么是双亲委派模型?
候选人:
嗯,它是是这样的。
如果一个类加载器收到了类加载的请求,它首先不会自己尝试加载这个类,而是把这请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传输到顶层的启动类加载器中,只有当父类加载器返回自己无法完成这个加载请求(它的搜索返回中没有找到所需的类)时,子类加载器才会尝试自己去加载
面试官:JVM为什么采用双亲委派机制
候选人:
主要有两个原因。
第一、通过双亲委派机制可以避免某一个类被重复加载,当父类已经加载后则无需重复加载,保证唯一性。
第二、为了安全,保证类库API不会被修改
三、垃圾回收
面试官:简述Java垃圾回收机制?(GC是什么?为什么要GC)
候选人:
嗯,是这样~~
为了让程序员更专注于代码的实现,而不用过多的考虑内存释放的问题,所以,在Java语言中,有了自动的垃圾回收机制,也就是我们熟悉的GC(Garbage Collection)。
有了垃圾回收机制后,程序员只需要关心内存的申请即可,内存的释放由系统自动识别完成。
在进行垃圾回收时,不同的对象引用类型,GC会采用不同的回收时机
面试官:强引用、软引用、弱引用、虚引用的区别?
候选人:
嗯嗯~
强引用最为普通的引用方式,表示一个对象处于有用且必须的状态,如果一个对象具有强引用,则GC并不会回收它。即便堆中内存不足了,宁可出现OOM,也不会对其进行回收
软引用表示一个对象处于有用且非必须状态,如果一个对象处于软引用,在内存空间足够的情况下,GC机制并不会回收它,而在内存空间不足时,则会在OOM异常出现之间对其进行回收。但值得注意的是,因为GC线程优先级较低,软引用并不会立即被回收。
弱引用表示一个对象处于可能有用且非必须的状态。在GC线程扫描内存区域时,一旦发现弱引用,就会回收到弱引用相关联的对象。对于弱引用的回收,无关内存区域是否足够,一旦发现则会被回收。同样的,因为GC线程优先级较低,所以弱引用也并不是会被立刻回收。
虚引用表示一个对象处于无用的状态。在任何时候都有可能被垃圾回收。虚引用的使用必须和引用队列Reference Queue联合使用
面试官:对象什么时候可以被垃圾器回收
候选人:
思考一会~~
如果一个或多个对象没有任何的引用指向它了,那么这个对象现在就是垃圾,如果定位了垃圾,则有可能会被垃圾回收器回收。
如果要定位什么是垃圾,有两种方式来确定,第一个是引用计数法,第二个是可达性分析算法
通常都使用可达性分析算法来确定是不是垃圾
面试官: JVM 垃圾回收算法有哪些?
候选人:
我记得一共有四种,分别是标记清除算法、复制算法、标记整理算法、分代回收
面试官: 你能详细聊一下分代回收吗?
候选人:
关于分代回收是这样的
在java8时,堆被分为了两份:新生代和老年代,它们默认空间占用比例是1:2
对于新生代,内部又被分为了三个区域。Eden区,S0区,S1区默认空间占用比例是8:1:1
具体的工作机制是有些情况:
1)当创建一个对象的时候,那么这个对象会被分配在新生代的Eden区。当Eden区要满了时候,触发YoungGC。
2)当进行YoungGC后,此时在Eden区存活的对象被移动到S0区,并且当前对象的年龄会加1,清空Eden区。
3)当再一次触发YoungGC的时候,会把Eden区中存活下来的对象和S0中的对象,移动到S1区中,这些对象的年龄会加1,清空Eden区和S0区。
4)当再一次触发YoungGC的时候,会把Eden区中存活下来的对象和S1中的对象,移动到S0区中,这些对象的年龄会加1,清空Eden区和S1区。
5)对象的年龄达到了某一个限定的值(默认15岁 ),那么这个对象就会进入到老年代中。
当然也有特殊情况,如果进入Eden区的是一个大对象,在触发YoungGC的时候,会直接存放到老年代
当老年代满了之后,触发FullGC。FullGC同时回收新生代和老年代,当前只会存在一个FullGC的线程进行执行,其他的线程全部会被挂起。 我们在程序中要尽量避免FullGC的出现。
面试官:讲一下新生代、老年代、永久代的区别?
候选人:
嗯!是这样的,简单说就是
新生代主要用来存放新生的对象。
老年代主要存放应用中生命周期长的内存对象。
永久代指的是永久保存区域。主要存放Class和Meta(元数据)的信息。在Java8中,永久代已经被移除,取而代之的是一个称之为“元数据区”(元空间)的区域。元空间和永久代类似,不过元空间与永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存。因此,默认情况下,元空间的大小仅受本地内存的限制。
面试官:说一下 JVM 有哪些垃圾回收器?
候选人:
在jvm中,实现了多种垃圾收集器,包括:串行垃圾收集器、并行垃圾收集器(JDK8默认)、CMS(并发)垃圾收集器、G1垃圾收集器(JDK9默认)
面试官:Minor GC、Major GC、Full GC是什么
候选人:
嗯,其实它们指的是不同代之间的垃圾回收
Minor GC 发生在新生代的垃圾回收,暂停时间短
Major GC 老年代区域的垃圾回收,老年代空间不足时,会先尝试触发Minor GC。Minor GC之后空间还不足,则会触发Major GC,Major GC速度比较慢,暂停时间长
Full GC 新生代 + 老年代完整垃圾回收,暂停时间长,应尽力避免
四、JVM实践(调优)
面试官:JVM 调优的参数可以在哪里设置参数值?
候选人:
我们当时的项目是springboot项目,可以在项目启动的时候,java -jar中加入参数就行了
面试官:用的 JVM 调优的参数都有哪些?
候选人:
嗯,这些参数是比较多的
我记得当时我们设置过堆的大小,像-Xms和-Xmx
还有就是可以设置年轻代中Eden区和两个Survivor区的大小比例
还有就是可以设置使用哪种垃圾回收器等等。具体的指令还真记不太清楚。
面试官:嗯,好的,你们平时调试 JVM都用了哪些工具呢?
候选人:
嗯,我们一般都是使用jdk自带的一些工具,比如
jps 输出JVM中运行的进程状态信息
jstack查看java进程内线程的堆栈信息。
jmap 用于生成堆转存快照
jstat用于JVM统计监测工具
还有一些可视化工具,像jconsole和VisualVM等
面试官:假如项目中产生了java内存泄露,你说一下你的排查思路?
候选人:
嗯,这个我在之前项目排查过
第一呢可以通过jmap指定打印他的内存快照 dump文件,不过有的情况打印不了,我们会设置vm参数让程序自动生成dump文件
第二,可以通过工具去分析 dump文件,jdk自带的VisualVM就可以分析
第三,通过查看堆信息的情况,可以大概定位内存溢出是哪行代码出了问题
第四,找到对应的代码,通过阅读上下文的情况,进行修复即可
面试官:好的,那现在再来说一种情况,就是说服务器CPU持续飙高,你的排查方案与思路?
候选人:
嗯,我思考一下~~
可以这么做~~
第一可以使用top命令查看占用cpu的情况
第二通过top命令查看后,可以查看是哪一个进程占用cpu较高,记录这个进程id
第三可以用
ps
命令查看这个进程里的线程信息,找出 CPU 占用高的线程。通常日志里显示的线程 ID 是十六进制的,所以要把前面得到的十进制线程 ID 转换成十六进制。
第四用
jstack
命令打印进程的堆栈信息,根据转换后的十六进制线程 ID 找到有问题的线程,就可以进一步定位问题代码的行号。