Java集合篇
集合框架分类
1.单列集合
![在这里插入图片描述](https://img-blog.csdnimg.cn/39951a2f22494b6097b2e2996f239de1.png)
2. 双列集合
![在这里插入图片描述](https://img-blog.csdnimg.cn/037225ceb2464da9a23be7aa837d964a.png)
注意: TreeMap底层的实现结构是 红黑树
数据结构
1. 数组
数组(Array)是一种用连续的内存空间存储相同数据类型数据的线性数据结构。
array中存的是数组的首地址, 然后通过寻址公式找到对应的位置
数组如何获取其他元素的地址值?
寻址公式: a[i] = baseAddress + i * dataTypeSize
为什么数组索引从0开始呢?假如从1开始不行吗?
如果数组的索引从1开始,寻址公式中,就需要增加一次减法操作,对于CPU来说就多了一次指令,性能不高。
a[i] = baseAddress + (i-1)* dataTypeSize
操作数组的时间复杂度
根据索引查询 O(1)
数组元素的访问是通过下标来访问的,计算机通过数组的首地址和寻址公式能够很快速的找到想要访问的元素
位置索引查询 O(n)
操作数组的时间复杂度(插入、删除)O(n)。
最好情况下是O(1)的,最坏情况下是O(n)的,平均情况下的时间复杂度是O(n)。
ArrayList源码解析
成员变量
构造方法
扩容方法
初始容量:
ArrayList初始容量为0,当第一次添加数据的时候才会初始化容量为10
扩容逻辑:
ArrayList在进行扩容的时候是原来容量的1.5倍,每次扩容都需要拷贝数组
添加逻辑:
-
确保数组已使用长度(size)加1之后足够存下下一个数据
-
计算数组的容量,如果当前数组已使用长度+1后的大于当前的数组长度,则调用grow方法扩容(原来的1.5倍)
-
确保新增的数据有地方存储之后,则将新元素添加到位于size的位置上。
-
返回添加成功布尔值。
ArrayList list=new ArrayList(10)中的list扩容几次
该语句只是声明和实例了一个 ArrayList,指定了容量为 10,未扩容
如何实现数组和List之间的转换
-
数组转List ,使用JDK中java.util.Arrays工具类的asList方法
-
List转数组,使用List的toArray方法。无参toArray方法返回 Object数组,传入初始化长度的数组对象,返回该对象数组
那么
- 用Arrays.asList转List后,如果修改了数组内容,list受影响吗
int[] arr = {1,2,3,3,4,5,2,56};
List<int[]> ints = Arrays.asList(arr);
System.out.println(ints);
底层实现
private final E[] a;
//
// 方法
public static <T> List<T> asList(T... a) {
return new ArrayList<>(a);
}
// 构造方法 直接赋值, 这里是引用, 所以会 list底层其实也是一个数组嘛
ArrayList(E[] array) {
a = Objects.requireNonNull(array);
}
- List用toArray转数组后,如果修改了List内容,数组受影响吗
llist用了toArray转数组后,如果修改了list内容,数组不会影响,当调用了toArray以后,在底层是它是进行了数组的拷贝,跟原来的元素就没啥关系了,所以即使list修改了以后,数组也不受影响
2. 单向链表
-
链表中的每一个元素称之为结点(Node)
-
物理存储单元上,非连续、非顺序的存储结构
-
单向链表:每个结点包括两个部分:一个是存储数据元素的数据域,另一个是存储下一个结点地址的指针域。记录下个结点地址的指针叫作后继指针 next
时间复杂度
-
只有在查询头节点的时候不需要遍历链表,时间复杂度是O(1)
-
查询其他结点需要遍历链表,时间复杂度是O(n)
-
只有在添加和删除头节点的时候不需要遍历链表,时间复杂度是O(1)
-
添加或删除其他结点需要遍历链表找到对应节点后,才能完成新增或删除节点,时间复杂度是O(n)
3. 双向链表
而双向链表,顾名思义,它支持两个方向
-
每个结点不止有一个后继指针 next 指向后面的结点
-
有一个前驱指针 prev 指向前面的结点
时间复杂度:
查询头尾结点的时间复杂度是O(1)
平均的查询时间复杂度是O(n)
给定节点找前驱节点的时间复杂度为O(1)
头尾结点增删的时间复杂度为O(1)
其他部分结点增删的时间复杂度是 O(n)
给定节点增删的时间复杂度为O(1)
对比单链表:
-
双向链表需要额外的两个空间来存储后继结点和前驱结点的地址
-
支持双向遍历,这样也带来了双向链表操作的灵活性
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
List<Object> syncArrayList = Collections.synchronizedList(new ArrayList<>());
List<Object> syncLinkedList = Collections.synchronizedList(new LinkedList<>());
HashMap相关面试题
4. 二叉树
二叉树,顾名思义,每个节点最多有两个“叉”,也就是两个子节点,分别是左子节点和右子节点。不过,二叉树并不要求每个节点都有两个子节点,有的节点只有左子节点,有的节点只有右子节点。
二叉树每个节点的左子树和右子树也分别满足二叉树的定义。
Java中有两个方式实现二叉树:数组存储,链式存储。
基于链式存储的树的节点可定义如下:
![在这里插入图片描述](https://img-blog.csdnimg.cn/0a04f952be2345a683353264eea87351.png)
![在这里插入图片描述](https://img-blog.csdnimg.cn/30336223fe124b21b08254ab9cf49e9a.png)
二叉搜索树
二叉搜索树(Binary Search Tree,BST)又名二叉查找树,有序二叉树或者排序二叉树,是二叉树中比较常用的一种类型
二叉查找树要求,在树中的任意一个节点,其左子树中的每个节点的值,都要小于这个节点的值,而右子树节点的值都大于这个节点的值
插入,查找,删除的时间复杂度O(logn)
5. 红黑树
红黑树(Red Black Tree):也是一种自平衡的二叉搜索树(BST),之前叫做平衡二叉B树(Symmetric Binary B-Tree)
性质1:节点要么是红色,要么是黑色
性质2:根节点是黑色
性质3:叶子节点都是黑色的空节点
性质4:红黑树中红色节点的子节点都是黑色
性质5:从任一节点到叶子节点的所有路径都包含相同数目的黑色节点
在添加或删除节点的时候,如果不符合这些性质会发生旋转,以达到所有的性质
所有的红黑规则都是希望红黑树能够保证平衡
达到最终的目的------->左旋右旋 -------> 保持平衡
•查找:
红黑树也是一棵BST(二叉搜索树)树,查找操作的时间复杂度为:O(log n)
•添加:
添加先要从根节点开始找到元素添加的位置,时间复杂度O(log n)
添加完成后涉及到复杂度为O(1)的旋转调整操作
故整体复杂度为:O(log n)
•删除:
首先从根节点开始找到被删除元素的位置,时间复杂度O(log n)
删除完成后涉及到复杂度为O(1)的旋转调整操作
故整体复杂度为:O(log n)
红黑树的时间复杂度:查找、添加、删除都是O(logn)
6. 散列表
在HashMap中的最重要的一个数据结构就是散列表,在散列表中又使用到了红黑树和链表
散列表(Hash Table)又名哈希表/Hash表,是根据键(Key)直接访问在内存存储位置值(Value)的数据结构,它是由数组演化而来的,利用了数组支持按照下标进行随机访问数据的特性
将键(key)映射为数组下标的函数叫做散列函数。可以表示为:hashValue = hash(key)
散列函数的基本要求:
散列函数计算得到的散列值必须是大于等于0的正整数,因为hashValue需要作为数组的下标。
如果key1==key2,那么经过hash后得到的哈希值也必相同即:hash(key1) == hash(key2)
如果key1 != key2,那么经过hash后得到的哈希值也必不相同即:hash(key1) != hash(key2)
实际的情况下想找一个散列函数能够做到对于不同的key计算得到的散列值都不同几乎是不可能的,即便像著名的MD5,SHA等哈希算法也无法避免这一情况,这就是散列冲突(或者哈希冲突,哈希碰撞,就是指多个key映射到同一个数组下标位置)
哈希冲突: 链表法(拉链)
-
数组的每个下标位置称之为桶(bucket)或者槽(slot)
每个桶(槽)会对应一条链表
hash冲突后的元素都放到相同槽位对应的链表中或红黑树中
HashMap实现原理
HashMap的数据结构: 底层使用hash表数据结构,即数组和链表或红黑树
-
当我们往HashMap中put元素时,利用key的hashCode重新hash计算出当前对象的元素在数组中的下标
-
存储时,如果出现hash值相同的key,此时有两种情况。
-
如果key相同,则覆盖原始值;
-
如果key不同(出现冲突),则将当前的key-value放入链表或红黑树中
-
-
获取时,直接找到hash值对应的下标,在进一步判断key是否相同,从而找到对应值。
HashMap的jdk1.7和jdk1.8有什么区别
这里先简单描述一下后面结束会同意总结
-
JDK1.8之前采用的是拉链法。拉链法:将链表和数组相结合。也就是说创建一个链表数组,数组中每一格就是一个链表。若遇到哈希冲突,则将冲突的值加到链表中即可。
-
jdk1.8在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为8) 时并且数组长度达到64时,将链表转化为红黑树,以减少搜索时间。扩容 resize( ) 时,红黑树拆分成的树的结点数小于等于临界值6个,则退化成链表
HashMap的put方法的具体流程
-
判断键值对数组table是否为空或为null,否则执行resize()进行扩容(初始化)
-
根据键值key计算hash值得到数组索引
-
判断table[i]==null,条件成立,直接新建节点添加
-
如果table[i]==null ,不成立
-
判断table[i]的首个元素是否和key一样,如果相同直接覆盖value
-
判断table[i] 是否为treeNode,即table[i] 是否是红黑树,如果是红黑树,则直接在树中插入键值对
-
遍历table[i],链表的尾部插入数据,然后判断链表长度是否大于8,大于8的话把链表转换为红黑树,在红黑树中执行插入操 作,遍历过程中若发现key已经存在直接覆盖value
-
-
插入成功后,判断实际存在的键值对数量size是否超多了最大容量threshold(数组长度*0.75),如果超过,进行扩容。
讲一讲HashMap的扩容机制
-
在添加元素或初始化的时候需要调用resize方法进行扩容,第一次添加数据初始化数组长度为16,以后每次每次扩容都是达到了扩容阈值(数组长度 * 0.75)
-
每次扩容的时候,都是扩容之前容量的2倍;
-
扩容之后,会新创建一个数组,需要把老数组中的数据挪动到新的数组中
-
没有hash冲突的节点,则直接使用 e.hash & (newCap - 1) 计算新数组的索引位置
或者(e.hash % newCap 但是因为前者的效率高, 所以一般是用& )
-
如果是红黑树,走红黑树的添加
-
如果是链表,则需要遍历链表,可能需要拆分链表,判断(e.hash & oldCap)是否为0,该元素的位置要么停留在原始位置,要么移动到原始位置+增加的数组大小这个位置上
-
(e.hash & oldCap) 其实就只有两个值, 一个是 0 一个是 oldCap的大小
0 就是说明当前元素应该还在当前的位置(低位loHead), 如果不是说明他在, (高位hiHead)
// 1.8的数据转换的核心代码
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do {
next = e.next;
if ((e.hash & oldCap) == 0) {
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
else {
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
if (loTail != null) {
loTail.next = null;
// 新数组的原始位置的指针, 只要刚才建立的低位的 头节点的指针
newTab[j] = loHead;
}
if (hiTail != null) {
hiTail.next = null;
// 新数组的原始位置+的指针, 只要刚才建立的低位的 头节点的指针
newTab[j + oldCap] = hiHead;
}
而1.8之前的做法是 根据Hash值,计算出在新数组中的位置, 然后直接放
可见1.8的做法在数据移动时的性能相对来说就比较高
// 1.7及之前的数据转换的代码
void transfer(Entry[] newTable, boolean rehash) {
int newCapacity = newTable.length;
for (Entry<K,V> e : table) {
while(null != e) {
Entry<K,V> next = e.next;
if (rehash) {
e.hash = null == e.key ? 0 : hash(e.key);
}
int i = indexFor(e.hash, newCapacity);
// 下面的代码在多线程的环境下存在死循环问题
e.next = newTable[i];
newTable[i] = e;
e = next;
}
}
}
hashMap的寻址算法
-
计算对象的 hashCode()
-
再进行调用 hash() 方法进行二次哈希, hashcode值右移16位再异或运算,让哈希分布更为均匀 (扰动算法)
-
最后 (capacity – 1) & hash 得到索引
(n-1)&hash : 得到数组中的索引,代替取模,性能更好
数组长度必须是2的n次幂
🚩 为何HashMap的数组长度一定是2的次幂?
-
计算索引时效率更高:如果是 2 的 n 次幂可以使用位与运算代替取模
-
扩容时重新计算索引效率更高: hash & oldCap == 0 的元素留在原来位置 ,否则新位置 = 旧位置 + oldCap
hashmap在1.7情况下的多线程死循环问题
在jdk1.7的hashmap中在数组进行扩容的时候,因为链表是头插法,在进行数据迁移的过程中,有可能导致死循环
比如说,现在有两个线程
线程一:读取到当前的hashmap数据,数据中一个链表,在准备扩容时,线程二介入
线程二:也读取hashmap,直接进行扩容。因为是头插法,链表的顺序会进行颠倒过来。比如原来的顺序是AB,扩容后的顺序是BA,线程二执行结束。
线程一:继续执行的时候就会出现死循环的问题。
线程一先将A移入新的链表,再将B插入到链头,由于另外一个线程的原因,B的next指向了A,所以B->A->B,形成循环。
当然,JDK 8 将扩容算法做了调整,不再将元素加入链表头(而是保持与扩容前一样的顺序),尾插法,就避免了jdk7中死循环的问题。
HashMap对比1.7和1.8的区别
第一点: 加入了红黑树
-
JDK1.7及之前采用的是拉链法。拉链法:将链表和数组相结合。也就是说创建一个链表数组,数组中每一格就是一个链表。若遇到哈希冲突,则将冲突的值加到链表中即可。
-
jdk1.8在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为8) 时并且数组长度达到64时,将链表转化为红黑树,以减少搜索时间。扩容 resize( ) 时,红黑树拆分成的树的结点数小于等于临界值6个,则退化成链表
第二处: 链表的插入数据的方式改变
- 1.7及之前 头插法
- 1.8 尾插法 解决了多线程死循环的问题
第三处: 扩容时数据的处理
- 1.7及之前 直接通过Hash值计算, 得到计算的结果, 直接放入到新的数组中
- 1.8 计算出当前的元素是在低位还是在高位, 即没扩容之前的位置还是扩容之后的位置, 然后形成两个链表, 直接赋值给新数组中的引用 (e.next)
16 -----扩容到-----> 32 前16 叫做低位 后16叫做高位
HashSet与HashMap的区别
(1)HashSet实现了Set接口, 仅存储对象; HashMap实现了 Map接口, 存储的是键值对.
(2)HashSet底层其实是用HashMap实现存储的, HashSet封装了一系列HashMap的方法. 依靠HashMap来存储元素值,(利用hashMap的key键进行存储), 而value值默认为Object对象. 所以HashSet也不允许出现重复值, 判断标准和HashMap判断标准相同, 两个元素的hashCode相等并且通过equals()方法返回true.
HashTable与HashMap的区别
主要区别:
区别 | HashTable | HashMap |
---|---|---|
数据结构 | 数组+链表 | 数组+链表+红黑树 |
是否可以为null | Key和value都不能为null | 可以为null |
hash算法 | key的hashCode() | 二次hash |
扩容方式 | 当前容量翻倍 +1 | 当前容量翻倍 |
线程安全 | 同步(synchronized)的,线程安全 | 非线程安全 |
在实际开中不建议使用HashTable,在多线程环境下可以使用ConcurrentHashMap类
为什么HashSet里value不是null?
HashSet的add方法底层调用的是HashMap的put方法, 如果成功返回值是null , 如果失败返回value , 所以如果HashSet的value设置为null, 那么就不知道是否添加成功了.
HashMap<Object, Object> map = new HashMap<>();
Object result1 = map.put(1, 1);
Object result2 = map.put(1, 1);
System.out.println(result1); // null
System.out.println(result2); // 1
面试题 📚
Java常见的集合类
面试官:说一说Java提供的常见集合?(画一下集合结构图)
候选人:
嗯~~,好的。
在java中提供了量大类的集合框架,主要分为两类:
第一个是Collection 属于单列集合,第二个是Map 属于双列集合
- 在Collection中有两个子接口List和Set。在我们平常开发的过程中用的比较多像list接口中的实现类ArrarList和LinkedList。 在Set接口中有实现类HashSet和TreeSet。
- 在map接口中有很多的实现类,平时比较常见的是HashMap、TreeMap,还有一个线程安全的map:ConcurrentHashMap
List
面试官:ArrayList底层是如何实现的?
候选人:
嗯~,我阅读过arraylist的源码,我主要说一下add方法吧
第一:确保数组已使用长度(size)加1之后足够存下下一个数据
第二:计算数组的容量,如果当前数组已使用长度+1后的大于当前的数组长度,则调用grow方法扩容(原来的1.5倍)
第三:确保新增的数据有地方存储之后,则将新元素添加到位于size的位置上。
第四:返回添加成功布尔值。
面试官:ArrayList list=new ArrayList(10)中的list扩容几次
候选人:
是new了一个ArrarList并且给了一个构造参数10,对吧?(问题一定要问清楚再答)
面试官:是的
候选人:
好的,在ArrayList的源码中提供了一个带参数的构造方法,这个参数就是指定的集合初始长度,所以给了一个10的参数,就是指定了集合的初始长度是10,这里面并没有扩容。
面试官:如何实现数组和List之间的转换
候选人:
嗯,这个在我们平时开发很常见
数组转list,可以使用jdk自动的一个工具类Arrars,里面有一个asList方法可以转换为数组
List 转数组,可以直接调用list中的toArray方法,需要给一个参数,指定数组的类型,需要指定数组的长度。
面试官:用Arrays.asList转List后,如果修改了数组内容,list受影响吗?List用toArray转数组后,如果修改了List内容,数组受影响吗
候选人:
Arrays.asList转换list之后,如果修改了数组的内容,list会受影响,因为它的底层使用的Arrays类中的一个内部类ArrayList来构造的集合,在这个集合的构造器中,把我们传入的这个集合进行了包装而已,最终指向的都是同一个内存地址
list用了toArray转数组后,如果修改了list内容,数组不会影响,当调用了toArray以后,在底层是它是进行了数组的拷贝,跟原来的元素就没啥关系了,所以即使list修改了以后,数组也不受影响
面试官:ArrayList 和 LinkedList 的区别是什么?
候选人:
嗯,它们两个主要是底层使用的数据结构不一样,ArrayList 是动态数组,LinkedList 是双向链表,这也导致了它们很多不同的特点。
1,从操作数据效率来说
ArrayList按照下标查询的时间复杂度O(1)【内存是连续的,根据寻址公式】, LinkedList不支持下标查询
查找(未知索引): ArrayList需要遍历,链表也需要链表,时间复杂度都是O(n)
新增和删除
- ArrayList尾部插入和删除,时间复杂度是O(1);其他部分增删需要挪动数组,时间复杂度是O(n)
- LinkedList头尾节点增删时间复杂度是O(1),其他都需要遍历链表,时间复杂度是O(n)
2,从内存空间占用来说
ArrayList底层是数组,内存连续,节省内存
LinkedList 是双向链表需要存储数据,和两个指针,更占用内存
3,从线程安全来说,ArrayList和LinkedList都不是线程安全的
面试官:嗯,好的,刚才你说了ArrayList 和 LinkedList 不是线程安全的,你们在项目中是如何解决这个的线程安全问题的?
候选人:
嗯,是这样的,主要有两种解决方案:
第一:我们使用这个集合,优先在方法内使用,定义为局部变量,这样的话,就不会出现线程安全问题。
第二:如果非要在成员变量中使用的话,可以使用线程安全的集合来替代
ArrayList可以通过Collections 的 synchronizedList 方法将 ArrayList 转换成线程安全的容器后再使用。
LinkedList 换成ConcurrentLinkedQueue来使用
HashMap
面试官:说一下HashMap的实现原理?
候选人:
嗯。它主要分为了一下几个部分:
1,底层使用hash表数据结构,即数组+(链表 | 红黑树)
2,添加数据时,计算key的值确定元素在数组中的下标
key相同则替换
不同则存入链表或红黑树中
3,获取数据通过key的hash计算数组下标获取元素
面试官:HashMap的jdk1.7和jdk1.8有什么区别
候选人:
JDK1.8之前采用的拉链法,数组+链表
JDK1.8之后采用数组+链表+红黑树,链表长度大于8且数组长度大于64则会从链表转化为红黑树
面试官:好的,你能说下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),如果超过,进行扩容。
面试官:好的,刚才你多次介绍了hsahmap的扩容,能讲一讲HashMap的扩容机制吗?
候选人:
好的
在添加元素或初始化的时候需要调用resize方法进行扩容,第一次添加数据初始化数组长度为16,以后每次每次扩容都是达到了扩容阈值(数组长度 * 0.75)
每次扩容的时候,都是扩容之前容量的2倍;
扩容之后,会新创建一个数组,需要把老数组中的数据挪动到新的数组中
没有hash冲突的节点,则直接使用 e.hash & (newCap - 1) 计算新数组的索引位置
如果是红黑树,走红黑树的添加
如果是链表,则需要遍历链表,可能需要拆分链表,判断(e.hash & oldCap)是否为0,该元素的位置要么停留在原始位置,要么移动到原始位置+增加的数组大小这个位置上
面试官:好的,刚才你说的通过hash计算后找到数组的下标,是如何找到的呢,你了解hashMap的寻址算法吗?
候选人:
这个哈希方法首先计算出key的hashCode值,然后通过这个hash值右移16位后的二进制进行按位异或运算得到最后的hash值。
在putValue的方法中,计算数组下标的时候使用hash值与数组长度取模得到存储数据下标的位置,hashmap为了性能更好,并没有直接采用取模的方式,而是使用了数组长度-1 得到一个值,用这个值按位与运算hash值,最终得到数组的位置。
面试官:为何HashMap的数组长度一定是2的次幂?
候选人:
嗯,好的。hashmap这么设计主要有两个原因:
第一:
计算索引时效率更高:如果是 2 的 n 次幂可以使用位与运算代替取模
第二:
扩容时重新计算索引效率更高:在进行扩容是会进行判断 hash值按位与运算旧数组长租是否 == 0
如果等于0,则把元素留在原来位置 ,否则新位置是等于旧位置的下标+旧数组长度
面试官:好的,我看你对hashmap了解的挺深入的,你知道hashmap在1.7情况下的多线程死循环问题吗?
候选人:
嗯,知道的。是这样
jdk7的的数据结构是:数组+链表
在数组进行扩容的时候,因为链表是头插法,在进行数据迁移的过程中,有可能导致死循环
比如说,现在有两个线程
线程一:读取到当前的hashmap数据,数据中一个链表,在准备扩容时,线程二介入
线程二也读取hashmap,直接进行扩容。因为是头插法,链表的顺序会进行颠倒过来。比如原来的顺序是AB,扩容后的顺序是BA,线程二执行结束。
当线程一再继续执行的时候就会出现死循环的问题。
线程一先将A移入新的链表,再将B插入到链头,由于另外一个线程的原因,B的next指向了A,所以B->A->B,形成循环。
当然,JDK 8 将扩容算法做了调整,不再将元素加入链表头(而是保持与扩容前一样的顺序),尾插法,就避免了jdk7中死循环的问题。
面试官:好的,hashmap是线程安全的吗?
候选人:不是线程安全的
面试官:那我们想要使用线程安全的map该怎么做呢?
候选人:我们可以采用ConcurrentHashMap进行使用,它是一个线程安全的HashMap
面试官:那你能聊一下ConcurrentHashMap的原理吗?
候选人:好的,请参考《多线程相关面试题》中的ConcurrentHashMap部分的讲解
面试官:HashSet与HashMap的区别?
候选人:嗯,是这样。
HashSet底层其实是用HashMap实现存储的, HashSet封装了一系列HashMap的方法. 依靠HashMap来存储元素值,(利用hashMap的key键进行存储), 而value值默认为Object对象. 所以HashSet也不允许出现重复值, 判断标准和HashMap判断标准相同, 两个元素的hashCode相等并且通过equals()方法返回true.
面试官:HashTable与HashMap的区别
候选人:
嗯,他们的主要区别是有几个吧
第一,数据结构不一样,hashtable是数组+链表,hashmap在1.8之后改为了数组+链表+红黑树
第二,hashtable存储数据的时候都不能为null,而hashmap是可以的
第三,hash算法不同,hashtable是用本地修饰的hashcode值,而hashmap经常了二次hash
第四,扩容方式不同,hashtable是当前容量翻倍+1,hashmap是当前容量翻倍
第五,hashtable是线程安全的,操作数据的时候加了锁synchronized,hashmap不是线程安全的,效率更高一些
在实际开中不建议使用HashTable,在多线程环境下可以使用ConcurrentHashMap类
文档说明
在文档中对所有的面试题都进行了难易程度和出现频率的等级说明
星数越多代表权重越大,最多五颗星(☆☆☆☆☆) 最少一颗星(☆)
Java多线程相关面试题
1.线程的基础知识
1.1 线程和进程的区别?
难易程度:☆☆
出现频率:☆☆☆
程序由指令和数据组成,但这些指令要运行,数据要读写,就必须将指令加载至 CPU,数据加载至内存。在指令运行过程中还需要用到磁盘、网络等设备。进程就是用来加载指令、管理内存、管理 IO 的。
当一个程序被运行,从磁盘加载这个程序的代码至内存,这时就开启了一个进程。
一个进程之内可以分为一到多个线程。
一个线程就是一个指令流,将指令流中的一条条指令以一定的顺序交给 CPU 执行
Java 中,线程作为最小调度单位,进程作为资源分配的最小单位。在 windows 中进程是不活动的,只是作为线程的容器
二者对比
- 进程是正在运行程序的实例,进程中包含了线程,每个线程执行不同的任务
- 不同的进程使用不同的内存空间,在当前进程下的所有线程可以共享内存空间
- 线程更轻量,线程上下文切换成本一般上要比进程上下文切换低(上下文切换指的是从一个线程切换到另一个线程)
1.2 并行和并发有什么区别?
难易程度:☆
出现频率:☆
单核CPU
-
单核CPU下线程实际还是串行执行的
-
操作系统中有一个组件叫做任务调度器,将cpu的时间片(windows下时间片最小约为 15 毫秒)分给不同的程序使用,只是由于cpu在线程间(时间片很短)的切换非常快,人类感觉是同时运行的 。
-
总结为一句话就是: 微观串行,宏观并行
一般会将这种线程轮流使用CPU的做法称为并发(concurrent)
多核CPU
每个核(core)都可以调度运行线程,这时候线程可以是并行的。
并发(concurrent)是同一时间应对(dealing with)多件事情的能力
并行(parallel)是同一时间动手做(doing)多件事情的能力
举例:
家庭主妇做饭、打扫卫生、给孩子喂奶,她一个人轮流交替做这多件事,这时就是并发
家庭主妇雇了个保姆,她们一起这些事,这时既有并发,也有并行(这时会产生竞争,例如锅只有一口,一个人用锅时,另一个人就得等待)
雇了3个保姆,一个专做饭、一个专打扫卫生、一个专喂奶,互不干扰,这时是并行
1.3 创建线程的四种方式
难易程度:☆☆
出现频率:☆☆☆☆
参考回答:
共有四种方式可以创建线程,分别是:继承Thread类、实现runnable接口、实现Callable接口、线程池创建线程
详细创建方式参考下面代码:
① 继承Thread类
public class MyThread extends Thread {
@Override
public void run() {
System.out.println("MyThread...run...");
}
public static void main(String[] args) {
// 创建MyThread对象
MyThread t1 = new MyThread() ;
MyThread t2 = new MyThread() ;
// 调用start方法启动线程
t1.start();
t2.start();
}
}
② 实现runnable接口
public class MyRunnable implements Runnable{
@Override
public void run() {
System.out.println("MyRunnable...run...");
}
public static void main(String[] args) {
// 创建MyRunnable对象
MyRunnable mr = new MyRunnable() ;
// 创建Thread对象
Thread t1 = new Thread(mr) ;
Thread t2 = new Thread(mr) ;
// 调用start方法启动线程
t1.start();
t2.start();
}
}
③ 实现Callable接口
public class MyCallable implements Callable<String> {
@Override
public String call() throws Exception {
System.out.println("MyCallable...call...");
return "OK";
}
public static void main(String[] args) throws ExecutionException, InterruptedException {
// 创建MyCallable对象
MyCallable mc = new MyCallable() ;
// 创建F
FutureTask<String> ft = new FutureTask<String>(mc) ;
// 创建Thread对象
Thread t1 = new Thread(ft) ;
Thread t2 = new Thread(ft) ;
// 调用start方法启动线程
t1.start();
// 调用ft的get方法获取执行结果
String result = ft.get();
// 输出
System.out.println(result);
}
}
④ 线程池创建线程
public class MyExecutors implements Runnable{
@Override
public void run() {
System.out.println("MyRunnable...run...");
}
public static void main(String[] args) {
// 创建线程池对象
ExecutorService threadPool = Executors.newFixedThreadPool(3);
threadPool.submit(new MyExecutors()) ;
// 关闭线程池
threadPool.shutdown();
}
}
1.4 runnable 和 callable 有什么区别
难易程度:☆☆
出现频率:☆☆☆
参考回答:
- **Runnable 接口run方法没有返回值;**Callable接口call方法有返回值,是个泛型,和Future、FutureTask配合可以用来获取异步执行的结果
- **Callalbe接口支持返回执行结果,**需要调用FutureTask.get()得到,此方法会阻塞主进程的继续往下执行,如果不调用不会阻塞。
- Callable接口的call()方法允许抛出异常;而Runnable接口的run()方法的异常只能在内部消化,不能继续上抛
1.5 线程的 run()和 start()有什么区别?
难易程度:☆☆
出现频率:☆☆
start(): 用来启动线程,通过该线程调用run方法执行run方法中所定义的逻辑代码。start方法只能被调用一次。
run(): 封装了要被线程执行的代码,可以被调用多次。
1.6 线程包括哪些状态,状态之间是如何变化的
难易程度:☆☆☆
出现频率:☆☆☆☆
线程的状态可以参考JDK中的Thread类中的枚举State
public enum State {
/**
* 尚未启动的线程的线程状态
*/
NEW,
/**
* 可运行线程的线程状态。处于可运行状态的线程正在 Java 虚拟机中执行,但它可能正在等待来自
* 操作系统的其他资源,例如处理器。
*/
RUNNABLE,
/**
* 线程阻塞等待监视器锁的线程状态。处于阻塞状态的线程正在等待监视器锁进入同步块/方法或在调
* 用Object.wait后重新进入同步块/方法。
*/
BLOCKED,
/**
* 等待线程的线程状态。由于调用以下方法之一,线程处于等待状态:
* Object.wait没有超时
* 没有超时的Thread.join
* LockSupport.park
* 处于等待状态的线程正在等待另一个线程执行特定操作。
* 例如,一个对对象调用Object.wait()的线程正在等待另一个线程对该对象调用Object.notify()
* 或Object.notifyAll() 。已调用Thread.join()的线程正在等待指定线程终止。
*/
WAITING,
/**
* 具有指定等待时间的等待线程的线程状态。由于以指定的正等待时间调用以下方法之一,线程处于定
* 时等待状态:
* Thread.sleep
* Object.wait超时
* Thread.join超时
* LockSupport.parkNanos
* LockSupport.parkUntil
*
*/
TIMED_WAITING,
/**
* 已终止线程的线程状态。线程已完成执行
*/
TERMINATED;
}
状态之间是如何变化的 (这里放两个图, 上面是之前记的图)
分别是
- 新建
- 当一个线程对象被创建,但还未调用 start 方法时处于新建状态
- 此时未与操作系统底层线程关联
- 可运行
- 调用了 start 方法,就会由新建进入可运行
- 此时与底层线程关联,由操作系统调度执行
- 终结
- 线程内代码已经执行完毕,由可运行进入终结
- 此时会取消与底层线程关联
- 阻塞
- 当获取锁失败后,由可运行进入 Monitor 的阻塞队列阻塞,此时不占用 cpu 时间
- 当持锁线程释放锁时,会按照一定规则唤醒阻塞队列中的阻塞线程,唤醒后的线程进入可运行状态 (唤醒的顺序根据虚拟机不同, 唤醒的顺序也会不同, 默认使用HotSpot 是顺序唤醒阻塞队列中的线程)
- 等待
- 当获取锁成功后,但由于条件不满足,调用了 wait() 方法,此时从可运行状态释放锁进入 Monitor 等待集合等待,同样不占用 cpu 时间
- 当其它持锁线程调用 notify() 或 notifyAll() 方法,会按照一定规则唤醒等待集合中的等待线程,恢复为可运行状态
- 有时限等待
- 当获取锁成功后,但由于条件不满足,调用了 wait(long) 方法,此时从可运行状态释放锁进入 Monitor 等待集合进行有时限等待,同样不占用 cpu 时间
- 当其它持锁线程调用 notify() 或 notifyAll() 方法,会按照一定规则唤醒等待集合中的有时限等待线程,恢复为可运行状态,并重新去竞争锁
- 如果等待超时,也会从有时限等待状态恢复为可运行状态,并重新去竞争锁
- 还有一种情况是调用 sleep(long) 方法也会从可运行状态进入有时限等待状态,但与 Monitor 无关,不需要主动唤醒,超时时间到自然恢复为可运行状态
1.7 新建 T1、T2、T3 三个线程,如何保证它们按顺序执行?
难易程度:☆☆
出现频率:☆☆☆
在多线程中有多种方法让线程按特定顺序执行,你可以用线程类的join()方法在一个线程中启动另一个线程,另外一个线程完成该线程继续执行。
代码举例:
为了确保三个线程的顺序你应该先启动最后一个(T3调用T2,T2调用T1),这样T1就会先完成而T3最后完成
public class JoinTest {
public static void main(String[] args) {
// 创建线程对象
Thread t1 = new Thread(() -> {
System.out.println("t1");
}) ;
Thread t2 = new Thread(() -> {
try {
t1.join(); // 加入线程t1,只有t1线程执行完毕以后,再次执行该线程
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("t2");
}) ;
Thread t3 = new Thread(() -> {
try {
t2.join(); // 加入线程t2,只有t2线程执行完毕以后,再次执行该线程
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("t3");
}) ;
// 启动线程
t1.start();
t2.start();
t3.start();
}
}
1.8 notify()和 notifyAll()有什么区别?
难易程度:☆☆
出现频率:☆☆
notifyAll:唤醒所有wait的线程
notify:只随机唤醒一个 wait 线程
package com.itheima.basic;
public class WaitNotify {
static boolean flag = false;
static Object lock = new Object();
public static void main(String[] args) {
Thread t1 = new Thread(() -> {
synchronized (lock){
while (!flag){
System.out.println(Thread.currentThread().getName()+"...wating...");
try {
lock.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println(Thread.currentThread().getName()+"...flag is true");
}
});
Thread t2 = new Thread(() -> {
synchronized (lock){
while (!flag){
System.out.println(Thread.currentThread().getName()+"...wating...");
try {
lock.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println(Thread.currentThread().getName()+"...flag is true");
}
});
Thread t3 = new Thread(() -> {
synchronized (lock) {
System.out.println(Thread.currentThread().getName() + " hold lock");
lock.notifyAll();
flag = true;
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
t1.start();
t2.start();
t3.start();
}
}
1.9 在 java 中 wait 和 sleep 方法的不同?
难易程度:☆☆☆
出现频率:☆☆☆
参考回答:
共同点
- wait() ,wait(long) 和 sleep(long) 的效果都是让当前线程暂时放弃 CPU 的使用权,进入阻塞状态
不同点
-
方法归属不同
- sleep(long) 是 Thread 的静态方法
- 而 wait(),wait(long) 都是 Object 的成员方法,每个对象都有
-
醒来时机不同
- 执行 sleep(long) 和 wait(long) 的线程都会在等待相应毫秒后醒来
- wait(long) 和 wait() 还可以被 notify 唤醒,wait() 如果不唤醒就一直等下去
- 它们都可以被打断唤醒
-
锁特性不同(重点)
- wait 方法的调用必须先获取 wait 对象的锁,而 sleep 则无此限制
- wait 方法执行后会释放对象锁,允许其它线程获得该对象锁(我放弃 cpu,但你们还可以用)
- 而 sleep 如果在 synchronized 代码块中执行,并不会释放对象锁(我放弃 cpu,你们也用不了)
代码示例:
public class WaitSleepCase {
static final Object LOCK = new Object();
public static void main(String[] args) throws InterruptedException {
sleeping();
}
private static void illegalWait() throws InterruptedException {
LOCK.wait();
}
private static void waiting() throws InterruptedException {
Thread t1 = new Thread(() -> {
synchronized (LOCK) {
try {
get("t").debug("waiting...");
LOCK.wait(5000L);
} catch (InterruptedException e) {
get("t").debug("interrupted...");
e.printStackTrace();
}
}
}, "t1");
t1.start();
Thread.sleep(100);
synchronized (LOCK) {
main.debug("other...");
}
}
private static void sleeping() throws InterruptedException {
Thread t1 = new Thread(() -> {
synchronized (LOCK) {
try {
get("t").debug("sleeping...");
Thread.sleep(5000L);
} catch (InterruptedException e) {
get("t").debug("interrupted...");
e.printStackTrace();
}
}
}, "t1");
t1.start();
Thread.sleep(100);
synchronized (LOCK) {
main.debug("other...");
}
}
}
1.10 如何停止一个正在运行的线程?
难易程度:☆☆
出现频率:☆☆
参考回答:
有三种方式可以停止线程
- 使用退出标志,使线程正常退出,也就是当run方法完成后线程终止
- 使用stop方法强行终止(不推荐,方法已作废)
- 使用interrupt方法中断线程
代码参考如下:
① 使用退出标志,使线程正常退出。
public class MyInterrupt1 extends Thread {
volatile boolean flag = false ; // 线程执行的退出标记
@Override
public void run() {
while(!flag) {
System.out.println("MyThread...run...");
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
public static void main(String[] args) throws InterruptedException {
// 创建MyThread对象
MyInterrupt1 t1 = new MyInterrupt1() ;
t1.start();
// 主线程休眠6秒
Thread.sleep(6000);
// 更改标记为true
t1.flag = true ;
}
}
② 使用stop方法强行终止
public class MyInterrupt2 extends Thread {
volatile boolean flag = false ; // 线程执行的退出标记
@Override
public void run() {
while(!flag) {
System.out.println("MyThread...run...");
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
public static void main(String[] args) throws InterruptedException {
// 创建MyThread对象
MyInterrupt2 t1 = new MyInterrupt2() ;
t1.start();
// 主线程休眠2秒
Thread.sleep(6000);
// 调用stop方法
t1.stop();
}
}
③ 使用interrupt方法中断线程。
package com.itheima.basic;
public class MyInterrupt3 {
public static void main(String[] args) throws InterruptedException {
//1.打断阻塞的线程
/*Thread t1 = new Thread(()->{
System.out.println("t1 正在运行...");
try {
Thread.sleep(5000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}, "t1");
t1.start();
Thread.sleep(500);
t1.interrupt();
System.out.println(t1.isInterrupted());*/
//2.打断正常的线程
Thread t2 = new Thread(()->{
while(true) {
Thread current = Thread.currentThread();
boolean interrupted = current.isInterrupted();
if(interrupted) {
System.out.println("打断状态:"+interrupted);
break;
}
}
}, "t2");
t2.start();
Thread.sleep(500);
// t2.interrupt();
}
}
2.线程中并发锁
2.1 讲一下synchronized关键字的底层原理?
难易程度:☆☆☆☆☆
出现频率:☆☆☆
2.1.1 基本使用
如下抢票的代码,如果不加锁,就会出现超卖或者一张票卖给多个人
Synchronized【对象锁】采用互斥的方式让同一时刻至多只有一个线程能持有【对象锁】,其它线程再想获取这个【对象锁】时就会阻塞住
public class TicketDemo {
static Object lock = new Object();
int ticketNum = 10;
public synchronized void getTicket() {
synchronized (this) {
if (ticketNum <= 0) {
return;
}
System.out.println(Thread.currentThread().getName() + "抢到一张票,剩余:" + ticketNum);
// 非原子性操作
ticketNum--;
}
}
public static void main(String[] args) {
TicketDemo ticketDemo = new TicketDemo();
for (int i = 0; i < 20; i++) {
new Thread(() -> {
ticketDemo.getTicket();
}).start();
}
}
}
2.1.2 Monitor
Monitor 被翻译为监视器,是由jvm提供,c++语言实现
在代码中想要体现monitor需要借助javap命令查看clsss的字节码,比如以下代码:
public class SyncTest {
static final Object lock = new Object();
static int counter = 0;
public static void main(String[] args) {
synchronized (lock) {
counter++;
}
}
}
找到这个类的class文件,在class文件目录下执行javap -v SyncTest.class
,反编译效果如下:
- monitorenter 上锁开始的地方
- monitorexit 解锁的地方
- 其中被monitorenter和monitorexit包围住的指令就是上锁的代码
- 有两个monitorexit的原因,第二个monitorexit是为了防止锁住的代码抛异常后不能及时释放锁
(相当于在底层有一个隐式的finally, 可以理解为 第二个就是写在finally中,保证一定执行防止死锁)
锁升级 >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
无锁状态
如果没有线程访问,那么这个锁就是无锁状态。
偏向锁
当一个线程发现锁是“无锁”状态,那么它就会进行自旋CAS获得锁(改锁状态为“偏向锁”,同时把自己的线程ID写入锁对象头中)
轻量级锁
当一个线程发现锁是“偏向锁”状态,那么它会进行CAS尝试获得锁(一般情况下都能获得到)。
但如果它CAS失败,那么它就会把这个锁升级为轻量级锁(把锁状态改为“轻量级”,然后在自己帧栈中新增一块空间,来存锁头内容。把自己这块空间的地址存到锁对象头中)。
重量级锁
当一个线程发现锁是“轻量级锁”,它同样会进行CAS尝试获得锁。
如果它CAS失败,那么它就会把这个锁升级为重量级别锁。
锁升级后会降级吗>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
正确的解释是:它不会降级,但会在释放锁之后直接回归无锁状态(之后有线程访问,还会继续升)。
但是,并不是说前一个线程释放锁之后,锁立马回归无锁状态,而是要等个几秒。如果前一个线程释放完锁,然后唤醒了后面的线程继续运行,说明此时处于激烈竞争时期,锁是不会回归无锁状态的,毕竟升降都很消耗性能。
在使用了synchornized代码块时需要指定一个对象,所以synchornized也被称为对象锁
monitor主要就是跟这个对象产生关联, 然后判断当前monitor属性中的owner是否为null如果为null, 让当前线程直接拥有, 即当前线程就有拥有了当前的对象锁,如下图
Monitor内部具体的存储结构:
-
Owner:存储当前获取锁的线程的,只能有一个线程可以获取
-
EntryList:关联没有抢到锁的线程,处于Blocked状态的线程
-
WaitSet:关联调用了wait方法的线程,处于Waiting状态的线程
具体的流程:
- 程序进入synchorized代码块,先让lock(对象锁)关联monitor,然后判断Owner是否有线程持有
- 如果没有线程持有,则让当前线程持有,表示该线程获取锁成功
- 如果有线程持有,则让当前线程进入entryList进行阻塞,如果Owner持有的线程已经释放了锁,在EntryList中的线程去竞争锁的持有权(非公平)
- 如果代码块中调用了wait()方法,则会进去WaitSet中进行等待
参考回答:
- Synchronized【对象锁】采用互斥的方式让同一时刻至多只有一个线程能持有【对象锁】
- 它的底层由monitor实现的,monitor是jvm级别的对象( C++实现),线程获得锁需要使用对象(锁)关联monitor
- 在monitor内部有三个属性,分别是owner、entrylist、waitset
- 其中owner是关联的获得锁的线程,并且只能关联一个线程;entrylist关联的是处于阻塞状态的线程;waitset关联的是处于Waiting状态的线程
2.2 synchronized关键字的底层原理-进阶
Monitor实现的锁属于重量级锁,你了解过锁升级吗?
- Monitor实现的锁属于重量级锁,(对象关联monitor就涉及到)里面涉及到了用户态和内核态的切换、进程的上下文切换,成本较高,性能比较低。
- 在JDK 1.6引入了两种新型锁机制:偏向锁和轻量级锁,它们的引入是为了解决在没有多线程竞争或基本没有竞争的场景下因使用传统锁机制带来的性能开销问题。
2.2.1 对象的内存结构
在HotSpot虚拟机中,对象在内存中存储的布局可分为3块区域:对象头(Header)、实例数据(Instance Data)和对齐填充
对其填充, 因为HotSpot虚拟机中要就对象的起始地址必须是8的倍数
我们需要重点分析MarkWord对象头
2.2.2 MarkWord
hashcode:25位的对象标识Hash码
age:对象分代年龄占4位
biased_lock:偏向锁标识,占1位 ,0表示没有开始偏向锁,1表示开启了偏向锁
thread:持有偏向锁的线程ID,占23位
epoch:偏向锁时间戳,占2位
ptr_to_lock_record:轻量级锁状态下,指向栈中锁记录的指针,占30位
ptr_to_heavyweight_monitor:重量级锁状态下,指向对象监视器Monitor的指针,占30位
我们可以通过lock的标识,来判断是哪一种锁的等级
- 后三位是001表示无锁
- 后三位是101表示偏向锁
- 后两位是00表示轻量级锁
- 后两位是10表示重量级锁
2.2.3 再说Monitor重量级锁
每个 Java 对象都可以关联一个 Monitor 对象,如果使用 synchronized 给对象上锁(重量级)之后,该对象头的Mark Word 中就被设置指向 Monitor 对象的指针
简单说就是:每个对象的对象头都可以设置monoitor的指针,让对象与monitor产生关联
2.2.4 轻量级锁
锁升级->
轻量级锁是由偏向所升级来的,偏向锁运行在一个线程进入同步块的情况下,当第二个线程加入锁争用的时候,偏向锁就会升级为轻量级锁
<<<<<<轻量级锁的加锁过程:>>>>>>>>
在代码进入同步块的时候,如果同步对象锁状态为无锁状态(锁标志位为“01”状态,是否为偏向锁为“0”),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝,官方称之为 Displaced Mark Word。
拷贝对象头中的Mark Word复制到锁记录中;
拷贝成功后,虚拟机将使用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针,并将Lock record里的owner指针指向object mark word。如果更新成功,则执行步骤4,否则执行步骤5。
如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象Mark Word的锁标志位设置为“00”,即表示此对象处于轻量级锁定状态。
如果这个更新操作失败了,虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果是就说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行。否则说明多个线程竞争锁,轻量级锁就要膨胀为重量级锁,锁标志的状态值变为“10”,Mark Word中存储的就是指向重量级锁(互斥量)的指针,后面等待锁的线程也要进入阻塞状态。 而当前线程便尝试使用自旋来获取锁,自旋就是为了不让线程阻塞,而采用循环去获取锁的过程。
在很多的情况下,在Java程序运行时,同步块中的代码都是不存在竞争的,不同的线程交替的执行同步块中的代码。这种情况下,用重量级锁是没必要的。因此JVM在1.6时引入了轻量级锁的概念。
static final Object obj = new Object();
public static void method1() {
synchronized (obj) {
// 同步块 A
method2();
}
}
public static void method2() {
synchronized (obj) {
// 同步块 B
}
}
加锁的流程
1.在线程栈(java虚拟机栈/线程独享)
中创建一个Lock Record,将其obj reference (对象参考)
字段指向锁对象。
2.通过CAS指令将Lock Record的地址存储在对象头的mark word中(数据进行交换),如果对象处于无锁状态则修改成功,代表该线程获得了轻量级锁。 对象的mark word (hashcode age 0 01 ) 和 (Lock record 地址 00)
3.如果是当前线程已经持有该锁了,代表这是一次锁重入。设置Lock Record第一部分为null,起到了一个重入计数器的作用。
(有几个Lock Record 就说明这个锁重入了几次) 每次加入锁记录都会进行CAS操作, 虽然第一次已经进行了 对象和锁记录的数据交换,但是第二次锁记录还是需要进行CAS操作, 但第二次的锁记录还会指向 Object
4.如果CAS修改失败,说明发生了竞争,需要膨胀为重量级锁。
解锁过程
1.遍历线程栈,找到所有obj字段等于当前锁对象的Lock Record。
2.如果Lock Record的Mark Word为null,代表这是一次重入,将obj设置为null后continue。
3.如果Lock Record的 Mark Word不为null,则利用CAS指令将对象头的mark word恢复成为无锁状态。如果失败则膨胀为重量级锁。
2.2.5 偏向锁
大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低而引入偏向锁。
当一个线程访问同步代码块并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程ID,
以后该线程再进入和退出同步块时不需要进行CAS操作来加锁和解锁,只需要简单地测试一下对象头的Mark Word里是否存储着指向当前线程的偏向锁。
如果测试成功,表示线程已经获得了锁。
如果测试失败,则需要再测试一下Mark Word中偏向锁的标识是否设置为1(表示指向当前进程):
如果没有,则使用CAS竞争锁;如果设置了,则尝试使用CAS将对象头的偏向锁指向当前进程。
————————————————
当线程再次执行到这个synchronized的时候,JVM通过锁对象account的Mark Word判断:“当前线程ID还在,还持有着这个对象的锁,就可以继续进入临界区执行
这就是偏向锁,在没有别的线程竞争的时候,一直偏向当前线程,当前线程可以一直执行
轻量级锁在没有竞争时(就自己这个线程),每次重入仍然需要执行 CAS 操作。
Java 6 中引入了偏向锁来做进一步优化:只有第一次使用 CAS 将线程 ID 设置到对象的 Mark Word 头,之后发现
这个线程 ID 是自己的就表示没有竞争,不用重新 CAS。以后只要不发生竞争,这个对象就归该线程所有
static final Object obj = new Object();
public static void m1() {
synchronized (obj) {
// 同步块 A
m2();
}
}
public static void m2() {
synchronized (obj) {
// 同步块 B
m3();
}
}
public static void m3() {
synchronized (obj) {
}
}
加锁的流程
1.在线程栈中创建一个Lock Record,将其obj字段指向锁对象。
2.通过CAS指令将Lock Record的线程id存储在对象头的mark word中,同时也设置偏向锁的标识为101,如果对象处于无锁状态则修改成功,代表该线程获得了偏向锁。
3.如果是当前线程已经持有该锁了,代表这是一次锁重入。设置Lock Record第一部分为null,起到了一个重入计数器的作用。与轻量级锁不同的时,这里不会再次进行cas操作,只是判断对象头中的线程id是否是自己,因为缺少了cas操作,性能相对轻量级锁更好一些
解锁流程参考轻量级锁
2.2.6 参考回答
Java中的synchronized有偏向锁、轻量级锁、重量级锁三种形式,分别对应了锁只被一个线程持有、不同线程交替持有锁、多线程竞争锁三种情况。
重量级锁:
之所以称它为重量级锁,是因为它的底层底层依赖操作系统的Mutex Lock来实现互斥功能。
Mutex是系统方法,由于权限隔离的关系,应用程序调用系统方法时需要切换到内核态来执行。
这里涉及到用户态向内核态的切换,这个切换会带来性能的损耗。
描述 | |
---|---|
重量级锁 | 底层使用的Monitor实现,里面涉及到了用户态和内核态的切换、进程的上下文切换,成本较高,性能比较低。 |
轻量级锁 | 线程加锁的时间是错开的(也就是没有竞争),可以使用轻量级锁来优化。轻量级修改了对象头的锁标志,相对重量级锁性能提升很多。每次修改都是CAS操作,保证原子性 (这里主要是指在硬件层面) |
偏向锁 | 一段很长的时间内都只被一个线程使用锁,可以使用了偏向锁,在第一次获得锁时,会有一个CAS操作,之后该线程再获取锁,只需要判断mark word中是否是自己的线程id即可,而不是开销相对较大的CAS命令 |
一旦锁发生了竞争,都会升级为重量级锁
自旋锁 🚩
自旋锁原理非常简单,如果持有锁的线程能在很短时间内释放锁资源,那么那些等待竞争锁的线程就不需要做内核态和用户态之间的切换进入阻塞挂起状态,它们只需要等一等(自旋),等持有锁的线程释放锁后即可立即获取锁,这样就避免用户线程和内核的切换的消耗。
但是线程自旋是需要消耗CPU的,说白了就是让CPU在做无用功,如果一直获取不到锁,那线程也不能一直占用CPU自旋做无用功,所以需要设定一个自旋等待的最大时间。如果持有锁的线程执行的时间超过自旋等待的最大时间扔没有释放锁,就会导致其它争用锁的线程在最大等待时间内还是获取不到锁,这时争用线程会停止自旋进入阻塞状态。
自旋锁的优缺点
优:自旋锁尽可能的减少线程的阻塞,这对于锁的竞争不激烈,且占用锁时间非常短的代码块来说性能能大幅度的提升,因为自旋的消耗会小于线程阻塞挂起再唤醒的操作的消耗,这些操作会导致线程发生两次上下文切换!
缺:但是如果锁的竞争激烈,或者持有锁的线程需要长时间占用锁执行同步块,这时候就不适合使用自旋锁了,因为自旋锁在获取锁前一直都是占用cpu做无用功,占着茅坑又不拉屎,同时有大量线程在竞争一个锁,会导致获取锁的时间很长,线程自旋的消耗大于线程阻塞挂起操作的消耗,其它需要CPU的线程又不能获取到cpu,造成cpu的浪费。所以这种情况下我们要关闭自旋锁;
synchronized锁升级 🚩
Java起初并没有这个机制,使用synchronized就是重量级锁,需要在用户态-内核态之间切换,性能消耗较大。但实际应用场景中,多数情况下,锁竞争并没有那么激烈。重量级锁带来的性能问题显得没有必要。自1.6后优化的功能。
无锁状态
如果没有线程访问,那么这个锁就是无锁状态。
偏向锁
当一个线程发现锁是“无锁”状态,那么它就会进行自旋CAS获得锁(改锁状态为“偏向锁”,同时把自己的线程ID写入锁对象头中)
轻量级锁
当一个线程发现锁是“偏向锁”状态,那么它会进行CAS尝试获得锁(一般情况下都能获得到)。
但如果它CAS失败,那么它就会把这个锁升级为轻量级锁(把锁状态改为“轻量级”,然后在自己帧栈中新增一块空间,来存锁头内容。把自己这块空间的地址存到锁对象头中)。
重量级锁
当一个线程发现锁是“轻量级锁”,它同样会进行CAS尝试获得锁。
如果它CAS失败,那么它就会把这个锁升级为重量级别锁。
2.3你谈谈 JMM(Java 内存模型)
难易程度:☆☆☆
出现频率:☆☆☆
JMM(Java Memory Model)Java内存模型,是java虚拟机规范中所定义的一种内存模型。
Java内存模型(Java Memory Model)描述了Java程序中各种变量(线程共享变量)的访问规则,以及在JVM中将变量存储到内存和从内存中读取变量这样的底层细节。
特点:
- 所有的共享变量都存储于主内存(计算机的RAM)这里所说的变量指的是实例变量和类变量。不包含局部变量,因为局部变量是线程私有的,因此不存在竞争问题。
- 每一个线程还存在自己的工作内存,线程的工作内存,保留了被线程使用的变量的工作副本。
- 线程对变量的所有的操作(读,写)都必须在工作内存中完成,而不能直接读写主内存中的变量,不同线程之间也不能直接访问对方工作内存中的变量,线程间变量的值的传递需要通过主内存完成。
2.4 CAS 你知道吗?
难易程度:☆☆☆
出现频率:☆☆
2.4.1 概述及基本工作流程
CAS的全称是: Compare And Swap(比较再交换),它体现的一种乐观锁的思想,在无锁情况下保证线程操作共享数据的原子性。
在JUC( java.util.concurrent )包下实现的很多类都用到了CAS操作
-
AbstractQueuedSynchronizer(AQS框架)
-
AtomicXXX类
例子:
我们还是基于刚才学习过的JMM内存模型进行说明
- 线程1与线程2都从主内存中获取变量int a = 100,同时放到各个线程的工作内存中
一个当前内存值V、旧的预期值A、即将更新的值B,当且仅当旧的预期值A和内存值V相同时,将内存值修改为B并返回true,否则什么都不做,并返回false。如果CAS操作失败,通过自旋的方式等待并再次尝试,直到成功
- 线程1操作:V:int a = 100,A:int a = 100,B:修改后的值:int a = 101 (a++)
- 线程1拿A的值与主内存V的值进行比较,判断是否相等
- 如果相等,则把B的值101更新到主内存中
- 线程2操作:V:int a = 100,A:int a = 100,B:修改后的值:int a = 99(a–)
- 线程2拿A的值与主内存V的值进行比较,判断是否相等(目前不相等,因为线程1已更新V的值99)
- 不相等,则线程2更新失败
-
自旋锁操作
-
因为没有加锁,所以线程不会陷入阻塞,效率较高
-
如果竞争激烈,重试频繁发生,效率会受影响
-
需要不断尝试获取共享内存V中最新的值,然后再在新的值的基础上进行更新操作,如果失败就继续尝试获取新的值,直到更新成功\
2.4.2 CAS 底层实现
CAS 底层依赖于一个 Unsafe 类来直接调用操作系统底层的 CAS 指令
那么CAS这个操作本身又是怎么保证原子性的呢?可以想象:比较——修改这是两个动作
那么CAS这个操作本身又是怎么保证原子性的呢?可以想象:比较——修改这是两个动作,可能我比较的时候它是一样的,当我修改的时候它却被别的线程修改了。这就涉及到CAS本身这个操作是原子的,也就是不被其他线程所干扰的。这是利用CPU的原语来实现的。我们知道Java方法无法直接访问底层系统,需要通过本地(Native)方法来访问,Unsafe相当于一个桥梁,基于该类可以连接底层的操作系统直接操作特定的内存数据,Unsafe类存在sun.misc包中,其内部方法操作可以像C指针一样直接操作内存,因此Java中的CAS操作的执行依赖于Unsafe类的方法。(Unsafe类很重要!不知道的同学可以多去了解,建议自己利用反射获取unsafe对象自己写一些东西,比如自己写一个原子类)
Java中CAS操作本身怎么保证原子性及其原理分析_cas为什么能保证原子性_w1sper的博客-CSDN博客
都是native修饰的方法,由系统提供的接口执行,并非java代码实现,一般的思路也都是自旋锁实现
在java中比较常见使用有很多,比如ReentrantLock和Atomic开头的线程安全类,都调用了Unsafe中的方法
- ReentrantLock中的一段CAS代码
2.4.3 乐观锁和悲观锁
- CAS 是基于乐观锁的思想:最乐观的估计,不怕别的线程来修改共享变量,就算改了也没关系,我吃亏点再重试呗。
- synchronized 是基于悲观锁的思想:最悲观的估计,得防着其它线程来修改共享变量,我上了锁你们都别想改,我改完了解开锁,你们才有机会。
使用场景:
从上面对两种锁的介绍,我们知道两种锁各有优缺点,不可认为一种好于另一种,像乐观锁适用于写比较少的情况下(多读场景),即冲突真的很少发生的时候,这样可以省去了锁的开销,加大了系统的整个吞吐量。但如果是多写的情况,一般会经常产生冲突,这就会导致上层应用会不断的进行retry,这样反倒是降低了性能,所以一般多写的场景下用悲观锁就比较合适。
2.5 请谈谈你对 volatile 的理解
什么是可见性呢,就是一个线程读变量,总是能读到它在内存中的最新的值,也就是说不同的线程看到的一个变量的值是相同的。
volatile 本质是告诉JVM当前变量在寄存器中的值是不确定的需要从主存中读取.
难易程度:☆☆☆
出现频率:☆☆☆
一旦一个共享变量(类的成员变量、类的静态成员变量)被volatile修饰之后,那么就具备了两层语义:
2.5.1 保证线程间的可见性
保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的,volatile关键字会强制将修改的值立即写入主存。
一个典型的例子:永不停止的循环
package com.itheima.basic;
// 可见性例子
// -Xint
public class ForeverLoop {
static boolean stop = false;
public static void main(String[] args) {
new Thread(() -> {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
stop = true;
System.out.println("modify stop to true...");
}).start();
foo();
}
static void foo() {
int i = 0;
while (!stop) {
i++;
}
System.out.println("stopped... c:"+ i);
}
}
当执行上述代码的时候,发现foo()方法中的循环是结束不了的,也就说读取不到共享变量的值结束循环。
主要是因为在JVM虚拟机中有一个JIT(即时编辑器 just in time)给代码做了优化。
上述代码
while (!stop) { i++; }
在很短的时间内,这个代码执行的次数太多了,当达到了一个阈值,JIT就会优化此代码,如下:
while (true) { i++; }
当把代码优化成这样子以后,及时
stop
变量改变为了false
也依然停止不了循环
解决方案:
第一:
在程序运行的时候加入vm参数-Xint
表示禁用即时编辑器,不推荐,得不偿失(其他程序还要使用)
第二:
在修饰stop
变量的时候加上volatile
,表示当前代码禁用了即时编辑器,问题就可以解决,代码如下:
static volatile boolean stop = false;
2.5.2 禁止进行指令重排序
用 volatile 修饰共享变量会在读、写共享变量时加入不同的屏障,阻止其他读写操作越过屏障,从而达到阻止重排序的效果
在去获取上面的结果的时候,有可能会出现4种情况
情况一:先执行actor2获取结果—>0,0(正常)
情况二:先执行actor1中的第一行代码,然后执行actor2获取结果—>0,1(正常)
情况三:先执行actor1中所有代码,然后执行actor2获取结果—>1,1(正常)
情况四:先执行actor1中第二行代码,然后执行actor2获取结果—>1,0(发生了指令重排序,影响结果)
解决方案
在变量上添加volatile,禁止指令重排序,则可以解决问题
屏障添加的示意图
- 写操作加的屏障是阻止上方其它写操作越过屏障排到volatile变量写之下
- 读操作加的屏障是阻止下方其它读操作越过屏障排到volatile变量读之上
其他补充
我们上面的解决方案是把volatile加在了int y这个变量上,我们能不能把它加在int x这个变量上呢?
下面代码使用volatile修饰了x变量
屏障添加的示意图
这样显然是不行的,主要是因为下面两个原则:
- 写操作加的屏障是阻止上方其它写操作越过屏障排到volatile变量写之下
- 读操作加的屏障是阻止下方其它读操作越过屏障排到volatile变量读之上
所以,现在我们就可以总结一个volatile使用的小妙招:
- 写变量让volatile修饰的变量的在代码最后位置
- 读变量让volatile修饰的变量的在代码最开始位置
2.6 什么是AQS?
AQS是多线程中的队列同步器。是一种锁机制,它是做为一个基础框架
难易程度:☆☆☆
出现频率:☆☆☆
2.6.1 概述
全称是 AbstractQueuedSynchronizer,是阻塞式锁和相关的同步器工具的框架,它是构建锁或者其他同步组件的基础框架
AQS与Synchronized的区别
synchronized | AQS |
---|---|
关键字,c++ 语言实现 | java 语言实现 |
悲观锁,自动释放锁 | 悲观锁,手动开启和关闭 |
锁竞争激烈都是重量级锁,性能差 | 锁竞争激烈的情况下,提供了多种解决方案 |
AQS常见的实现类
- ReentrantLock 阻塞式锁
- Semaphore 信号量
- CountDownLatch 倒计时锁
2.6.2 工作机制
- 在AQS中维护了一个使用了volatile修饰的state属性来表示资源的状态,0表示无锁,1表示有锁
- 提供了基于 FIFO 的等待队列,类似于 Monitor 的 EntryList
- 条件变量来实现等待、唤醒机制,支持多个条件变量,类似于 Monitor 的 WaitSet
![在这里插入图片描述](https://img-blog.csdnimg.cn/d221d143b965491caaa3d28986193314.png)
- 线程0来了以后,去尝试修改state属性,如果发现state属性是0,就修改state状态为1,表示线程0抢锁成功
- 线程1和线程2也会先尝试修改state属性,发现state的值已经是1了,有其他线程持有锁,它们都会到FIFO队列中进行等待,
- FIFO是一个双向队列,head属性表示头结点,tail表示尾结点
如果多个线程共同去抢这个资源是如何保证原子性的呢?
在去修改state状态的时候,使用的cas自旋锁来保证原子性,确保只能有一个线程修改成功,修改失败的线程将会进入FIFO队列中等待
AQS是公平锁吗,还是非公平锁?
-
新的线程与队列中的线程共同来抢资源,是非公平锁
-
新的线程到队列中等待,只让队列中的head线程获取锁,是公平锁
比较典型的AQS实现类ReentrantLock,它默认就是非公平锁,新的线程与队列中的线程共同来抢资源
什么是AQS?
AQS是多线程中的队列同步器。是一种锁机制,它是做为一个基础框架使用的,像ReentrantLock、Semaphore都是基于AQS实现的
AQS内部维护了一个先进先出的双向队列,队列中存储的排队的线程
在AQS内部还有一个属性state,这个state就相当于是一个资源,默认是0(无锁状态),如果队列中的有一个线程修改成功了state为1,则当前线程就相等于获取了资源
在对state修改的时候使用的cas操作,保证多个线程修改的情况下原子性
2.5 ReentrantLock的实现原理
难易程度:☆☆☆☆
出现频率:☆☆☆
2.5.1 概述
ReentrantLock翻译过来是可重入锁,相对于synchronized它具备以下特点:
-
可中断
-
可以设置超时时间
-
可以设置公平锁
-
支持多个条件变量
-
与synchronized一样,都支持重入
2.5.2 实现原理
ReentrantLock主要利用CAS+AQS队列来实现。它支持公平锁和非公平锁,两者的实现类似
构造方法接受一个可选的公平参数(默认非公平锁),当设置为true时,表示公平锁,否则为非公平锁。公平锁的效率往往没有非公平锁的效率高,在许多线程访问的情况下,公平锁表现出较低的吞吐量。
查看ReentrantLock源码中的构造方法:
提供了两个构造方法,不带参数的默认为非公平
如果使用带参数的构造函数,并且传的值为true,则是公平锁
其中NonfairSync和FairSync这两个类父类都是Sync
而Sync的父类是AQS,所以可以得出ReentrantLock底层主要实现就是基于AQS来实现的
工作流程
- 线程来抢锁后使用cas的方式修改state状态,修改状态成功为1,则让exclusiveOwnerThread属性指向当前线程,获取锁成功
- 假如修改状态失败,则会进入双向队列中等待,head指向双向队列头部,tail指向双向队列尾部
- 当exclusiveOwnerThread为null的时候,则会唤醒在双向队列中等待的线程
- 公平锁则体现在按照先后顺序获取锁,非公平体现在不在排队的线程也可以抢锁
// tryLock()和Lock方法
1: lock拿不到锁会一直等待。tryLock是去尝试,拿不到就返回false,拿到返回true。
2: tryLock是可以被打断的,被中断 的,lock是不可以。
lock()方法: 该种方式获取锁不可中断,如果获取不到则一直休眠等待。 但如果是条件判断也会释放锁
/**
* 阻塞等待获取锁;不允许Thread.interrupt中断,即使检测到Thread.isInterrupted,一样会继续尝试
* 获取锁,失败则继续休眠。只是在最后获取锁成功后再把当前线程置为interrupted状态,然后再中断线程。
*/
public void lock() {
sync.lock();
}
/**
* 当前线程未被中断,则获取锁
* 允许在等待时由其它线程调用等待线程的Thread.interrupt方法来中断等待线程的等待而直接返回,这时
* 不用获取锁,而会抛出一个InterruptedException
*/
public void lockInterruptibly() throws InterruptedException {
sync.acquireInterruptibly(1);
}
/**
*尝试申请一个锁,在成功获得锁后返回true,否则,立即返回false
*/
public boolean tryLock() {
return sync.nonfairTryAcquire(1);
}
/**
* 在一段时间内尝试申请一个锁,在成功获得锁后返回true,否则,立即返回false
*/
public boolean tryLock(long timeout, TimeUnit unit)
throws InterruptedException {
return sync.tryAcquireNanos(1, unit.toNanos(timeout));
}
/**
* 释放锁
*/
public void unlock() {
sync.release(1);
}
/**
* 条件实例
*/
public Condition newCondition() {
return sync.newCondition();
}
/**
* 获取当前线程持有此锁的次数
*/
public int getHoldCount() {
return sync.getHoldCount();
}
/**
* 是否被当前线程持有
*/
public boolean isHeldByCurrentThread() {
return sync.isHeldExclusively();
}
/**
* 查询此锁是否由任意线程持有
*/
public boolean isLocked() {
return sync.isLocked();
}
/**
*如果是“公平锁”返回true,否则返回false
*/
public final boolean isFair() {
return sync instanceof FairSync;
}
/**
* 获取目前拥有此锁的线程,如果此锁不被任何线程拥有,则返回 null
*/
protected Thread getOwner() {
return sync.getOwner();
}
/**
* 查询是否有线程正在等待
*/
public final boolean hasQueuedThreads() {
return sync.hasQueuedThreads();
}
/**
*查询给定线程是否正在等待获取此锁。
*/
public final boolean hasQueuedThread(Thread thread) {
return sync.isQueued(thread);
}
/**
* 获取正等待获取此锁的线程数
*/
public final int getQueueLength() {
return sync.getQueueLength();
}
/**
* 正等待获取此锁的线程集合
*/
protected Collection<Thread> getQueuedThreads() {
return sync.getQueuedThreads();
}
/**
*是否存在正在等待并符合相关给定条件的线程
*/
public boolean hasWaiters(Condition condition) {
if (condition == null)
throw new NullPointerException();
if (!(condition instanceof AbstractQueuedSynchronizer.ConditionObject))
throw new IllegalArgumentException("not owner");
return sync.hasWaiters((AbstractQueuedSynchronizer.ConditionObject)condition);
}
/**
* 正在等待并符合相关给定条件的线程数量
*/
public int getWaitQueueLength(Condition condition) {
if (condition == null)
throw new NullPointerException();
if (!(condition instanceof AbstractQueuedSynchronizer.ConditionObject))
throw new IllegalArgumentException("not owner");
return sync.getWaitQueueLength((AbstractQueuedSynchronizer.ConditionObject)condition);
}
/**
* 正在等待并符合相关给定条件的线程集合
*/
protected Collection<Thread> getWaitingThreads(Condition condition) {
if (condition == null)
throw new NullPointerException();
if (!(condition instanceof AbstractQueuedSynchronizer.ConditionObject))
throw new IllegalArgumentException("not owner");
return sync.getWaitingThreads((AbstractQueuedSynchronizer.ConditionObject)condition);
}
2.6 synchronized和Lock有什么区别 ?
难易程度:☆☆☆☆
出现频率:☆☆☆☆
参考回答
-
语法层面
- synchronized 是关键字,源码在 jvm 中,用 c++ 语言实现
- Lock 是接口,源码由 jdk 提供,用 java 语言实现
- 使用 synchronized 时,退出同步代码块锁会自动释放,而使用 Lock 时,需要手动调用 unlock 方法释放锁
-
功能层面
-
二者均属于悲观锁、都具备基本的互斥、同步、锁重入功能
-
Lock 提供了许多 synchronized 不具备的功能,
例如获取等待状态、公平锁、可打断
(lockInterruptibly)
、可超时(tryLock(时间)
)、多条件变量 -
Lock 有适合不同场景的实现,如 ReentrantLock, ReentrantReadWriteLock
-
-
性能层面
- 在没有竞争时,synchronized 做了很多优化,如偏向锁、轻量级锁,性能不赖
- 在竞争激烈时,Lock 的实现通常会提供更好的性能
// 多条件变量测试代码
public class ReentrantLockTest {
public static void main(String[] args) throws InterruptedException {
ReentrantLock lock = new ReentrantLock(true);
Condition condition1 = lock.newCondition();
Condition condition2 = lock.newCondition();
new Thread(()->{
try {
lock.lock();
// 等待条件一
condition1.await();
System.out.println(Thread.currentThread().getName()+"执行");
} catch (InterruptedException e) {
e.printStackTrace();
}finally {
lock.unlock();
}
},"thread1").start();
new Thread(()->{
try {
System.out.println("唤醒线程执行了");
lock.lock();
// 等待条件二执行完
condition2.await();
System.out.println(Thread.currentThread().getName()+"执行");
} catch (InterruptedException e) {
e.printStackTrace();
}finally {
lock.unlock();
}
},"thread2").start();
Thread.sleep(1000);
new Thread(()-> {
lock.lock();
// 条件一已完成
condition1.signal();
System.out.println("条件一已完成, thread1");
condition2.signal();
System.out.println("条件二已完成, thread2");
lock.unlock();
},"thread3").start();
}
}
condition1.signal(); // 会随机的唤醒一个线程
condition1.signalAll(); // 唤醒所有c1 条件的线程
2.7 死锁产生的条件是什么?
难易程度:☆☆☆☆
出现频率:☆☆☆
死锁:一个线程需要同时获取多把锁,这时就容易发生死锁
例如:
t1 线程获得A对象锁,接下来想获取B对象的锁
t2 线程获得B对象锁,接下来想获取A对象的锁
代码如下:
package com.itheima.basic;
import static java.lang.Thread.sleep;
public class Deadlock {
public static void main(String[] args) {
Object A = new Object();
Object B = new Object();
Thread t1 = new Thread(() -> {
synchronized (A) {
System.out.println("lock A");
try {
sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
synchronized (B) {
System.out.println("lock B");
System.out.println("操作...");
}
}
}, "t1");
Thread t2 = new Thread(() -> {
synchronized (B) {
System.out.println("lock B");
try {
sleep(500);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
synchronized (A) {
System.out.println("lock A");
System.out.println("操作...");
}
}
}, "t2");
t1.start();
t2.start();
}
}
控制台输出结果
此时程序并没有结束,这种现象就是死锁现象…线程t1持有A的锁等待获取B锁,线程t2持有B的锁等待获取A的锁。
2.8 如何进行死锁诊断?
难易程度:☆☆☆
出现频率:☆☆☆
当程序出现了死锁现象,我们可以使用jdk自带的工具:jps和 jstack
步骤如下:
第一:查看运行的线程
第二:使用jstack查看线程运行的情况,下图是截图的关键信息
运行命令:jstack -l 46032
其他解决工具,可视化工具
- jconsole
用于对jvm的内存,线程,类 的监控,是一个基于 jmx 的 GUI 性能监控工具
打开方式:java 安装目录 bin目录下 直接启动 jconsole.exe 就行
- VisualVM:故障处理工具
能够监控线程,内存情况,查看方法的CPU时间和内存中的对 象,已被GC的对象,反向查看分配的堆栈
打开方式:java 安装目录 bin目录下 直接启动 jvisualvm.exe就行
2.10 ConcurrentHashMap
难易程度:☆☆☆
出现频率:☆☆☆☆
ConcurrentHashMap 是一种线程安全的高效Map集合
底层数据结构:
-
JDK1.7底层采用分段的数组+链表实现
-
JDK1.8 采用的数据结构跟HashMap1.8的结构一样,数组+链表/红黑二叉树。
(1) JDK1.7中concurrentHashMap
数据结构
- 提供了一个segment数组,在初始化ConcurrentHashMap 的时候可以指定数组的长度,默认是16,一旦初始化之后中间不可扩容
- 在每个segment中都可以挂一个HashEntry数组,数组里面可以存储具体的元素,HashEntry数组是可以扩容的
- 在HashEntry存储的数组中存储的元素,如果发生冲突,则可以挂单向链表
存储流程
- 先去计算key的hash值,然后确定segment数组下标
- 再通过hash值确定hashEntry数组中的下标存储数据
- 在进行操作数据的之前,会先判断当前segment对应下标位置是否有线程进行操作,为了线程安全使用的是ReentrantLock进行加锁,如果获取锁是被会使用cas自旋锁进行尝试
(2) JDK1.8中concurrentHashMap
在JDK1.8中,放弃了Segment臃肿的设计,数据结构跟HashMap的数据结构是一样的:数组+红黑树+链表
采用 CAS + Synchronized来保证并发安全进行实现
-
CAS控制数组节点的添加
-
synchronized只锁定当前链表或红黑二叉树的首节点,只要hash不冲突,就不会产生并发的问题 , 效率得到提升
2.11 导致并发程序出现问题的根本原因是什么
难易程度:☆☆☆
出现频率:☆☆☆
Java并发编程三大特性
-
原子性
-
可见性
-
有序性
(1)原子性
一个线程在CPU中操作不可暂停,也不可中断,要不执行完成,要不不执行
比如,如下代码能保证原子性吗?
以上代码会出现超卖或者是一张票卖给同一个人,执行并不是原子性的
解决方案:
1.synchronized:同步加锁
2.JUC里面的lock:加锁
(3)内存可见性
内存可见性:让一个线程对共享变量的修改对另一个线程可见
比如,以下代码不能保证内存可见性
解决方案:
-
synchronized
-
volatile(推荐)
-
LOCK
(3)有序性
指令重排:处理器为了提高程序运行效率,可能会对输入代码进行优化,它不保证程序中各个语句的执行先后顺序同代码中的顺序一致,但是它会保证程序最终执行结果和代码顺序执行的结果是一致的
还是之前的例子,如下代码:
解决方案:
- volatile
3.线程池
3.1 说一下线程池的核心参数(线程池的执行原理知道嘛)
难易程度:☆☆☆
出现频率:☆☆☆☆
线程池核心参数主要参考ThreadPoolExecutor这个类的7个参数的构造函数
-
corePoolSize 核心线程数目
-
maximumPoolSize 最大线程数目 = (核心线程+救急线程的最大数目)
-
keepAliveTime 生存时间 - 救急线程的生存时间,生存时间内没有新任务,此线程资源会释放
-
unit 时间单位 - 救急线程的生存时间单位,如秒、毫秒等
-
workQueue - 当没有空闲核心线程时,新来任务会加入到此队列排队,队列满会创建救急线程执行任务
-
threadFactory 线程工厂 - 可以定制线程对象的创建,例如设置线程名字、是否是守护线程等
-
handler 拒绝策略 - 当所有线程都在繁忙,workQueue 也放满时,会触发拒绝策略
工作流程
1,任务在提交的时候,首先判断核心线程数是否已满,如果没有满则直接添加到工作线程执行
2,如果核心线程数满了,则判断阻塞队列是否已满,如果没有满,当前任务存入阻塞队列
3,如果阻塞队列也满了,则判断线程数是否小于最大线程数,如果满足条件,则使用临时线程执行任务
如果核心或临时线程执行完成任务后会检查阻塞队列中是否有需要执行的线程,如果有,则使用非核心线程执行任务
4,如果所有线程都在忙着(核心线程+临时线程),则走拒绝策略
拒绝策略:
1.AbortPolicy:直接抛出异常,默认策略;
2.CallerRunsPolicy:用调用者所在的线程来执行任务;
3.DiscardOldestPolicy:丢弃阻塞队列中靠最前的任务,并执行当前任务;
4.DiscardPolicy:直接丢弃任务;
参考代码:
public class TestThreadPoolExecutor {
static class MyTask implements Runnable {
private final String name;
private final long duration;
public MyTask(String name) {
this(name, 0);
}
public MyTask(String name, long duration) {
this.name = name;
this.duration = duration;
}
@Override
public void run() {
try {
LoggerUtils.get("myThread").debug("running..." + this);
Thread.sleep(duration);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
@Override
public String toString() {
return "MyTask(" + name + ")";
}
}
public static void main(String[] args) throws InterruptedException {
AtomicInteger c = new AtomicInteger(1);
ArrayBlockingQueue<Runnable> queue = new ArrayBlockingQueue<>(2);
ThreadPoolExecutor threadPool = new ThreadPoolExecutor(
2,
3,
0,
TimeUnit.MILLISECONDS,
queue,
r -> new Thread(r, "myThread" + c.getAndIncrement()),
new ThreadPoolExecutor.AbortPolicy());
showState(queue, threadPool);
threadPool.submit(new MyTask("1", 3600000));
showState(queue, threadPool);
threadPool.submit(new MyTask("2", 3600000));
showState(queue, threadPool);
threadPool.submit(new MyTask("3"));
showState(queue, threadPool);
threadPool.submit(new MyTask("4"));
showState(queue, threadPool);
threadPool.submit(new MyTask("5",3600000));
showState(queue, threadPool);
threadPool.submit(new MyTask("6"));
showState(queue, threadPool);
}
private static void showState(ArrayBlockingQueue<Runnable> queue, ThreadPoolExecutor threadPool) {
try {
Thread.sleep(300);
} catch (InterruptedException e) {
e.printStackTrace();
}
List<Object> tasks = new ArrayList<>();
for (Runnable runnable : queue) {
try {
Field callable = FutureTask.class.getDeclaredField("callable");
callable.setAccessible(true);
Object adapter = callable.get(runnable);
Class<?> clazz = Class.forName("java.util.concurrent.Executors$RunnableAdapter");
Field task = clazz.getDeclaredField("task");
task.setAccessible(true);
Object o = task.get(adapter);
tasks.add(o);
} catch (Exception e) {
e.printStackTrace();
}
}
LoggerUtils.main.debug("pool size: {}, queue: {}", threadPool.getPoolSize(), tasks);
}
}
3.2 线程池中有哪些常见的阻塞队列
难易程度:☆☆☆
出现频率:☆☆☆
workQueue - 当没有空闲核心线程时,新来任务会加入到此队列排队,队列满会创建救急线程执行任务
比较常见的有4个,用的最多是ArrayBlockingQueue和LinkedBlockingQueue
1.ArrayBlockingQueue:基于数组结构的有界阻塞队列,FIFO。
2.LinkedBlockingQueue:基于链表结构的有界阻塞队列,FIFO。
3.DelayedWorkQueue :是一个优先级队列,它可以保证每次出队的任务都是当前队列中执行时间最靠前的
4.SynchronousQueue:不存储元素的阻塞队列,每个插入操作都必须等待一个移出操作。
ArrayBlockingQueue的LinkedBlockingQueue区别
LinkedBlockingQueue | ArrayBlockingQueue |
---|---|
默认无界,支持有界() | 强制有界 |
底层是链表 | 底层是数组 |
是懒惰的,创建节点的时候添加数据 | 提前初始化 Node 数组 |
入队会生成新 Node | Node需要是提前创建好的 |
两把锁(头尾) | 一把锁 |
左边是LinkedBlockingQueue加锁的方式,右边是ArrayBlockingQueue加锁的方式
- LinkedBlockingQueue读和写各有一把锁,性能相对较好
- ArrayBlockingQueue只有一把锁,读和写公用,性能相对于LinkedBlockingQueue差一些
3.3 如何确定核心线程数
难易程度:☆☆☆☆
出现频率:☆☆☆
在设置核心线程数之前,需要先熟悉一些执行线程池执行任务的类型
- IO密集型任务
一般来说:文件读写、DB读写、网络请求等
推荐:核心线程数大小设置为2N+1 (N为计算机的CPU核数)
- CPU密集型任务
一般来说:计算型代码、Bitmap转换、Gson转换等
推荐:核心线程数大小设置为N+1 (N为计算机的CPU核数)
java代码查看CPU核数
参考回答:
① 高并发、任务执行时间短 -->( CPU核数+1 ),减少线程上下文的切换
② 并发不高、任务执行时间长
-
IO密集型的任务 --> (CPU核数 * 2 + 1)
-
计算密集型任务 --> ( CPU核数+1 )
③ 并发高、业务执行时间长,解决这种类型任务的关键不在于线程池而在于整体架构的设计,看看这些业务里面某些数据是否能做缓存是第一步,增加服务器是第二步,至于线程池的设置,设置参考(2)
3.4 线程池的种类有哪些
难易程度:☆☆☆
出现频率:☆☆☆
在java.util.concurrent.Executors类中提供了大量创建连接池的静态方法,常见就有四种
-
创建使用固定线程数的线程池
-
核心线程数与最大线程数一样,没有救急线程
-
阻塞队列是LinkedBlockingQueue,最大容量为Integer.MAX_VALUE
-
适用场景:适用于任务量已知,相对耗时的任务
-
案例:
public class FixedThreadPoolCase { static class FixedThreadDemo implements Runnable{ @Override public void run() { String name = Thread.currentThread().getName(); for (int i = 0; i < 2; i++) { System.out.println(name + ":" + i); } } } public static void main(String[] args) throws InterruptedException { //创建一个固定大小的线程池,核心线程数和最大线程数都是3 ExecutorService executorService = Executors.newFixedThreadPool(3); for (int i = 0; i < 5; i++) { executorService.submit(new FixedThreadDemo()); Thread.sleep(10); } executorService.shutdown(); } }
-
-
单线程化的线程池,它只会用唯一的工作线程来执行任 务,保证所有任务按照指定顺序(FIFO)执行
-
核心线程数和最大线程数都是1
-
阻塞队列是LinkedBlockingQueue,最大容量为Integer.MAX_VALUE
-
适用场景:适用于按照顺序执行的任务
-
案例:
public class NewSingleThreadCase { static int count = 0; static class Demo implements Runnable { @Override public void run() { count++; System.out.println(Thread.currentThread().getName() + ":" + count); } } public static void main(String[] args) throws InterruptedException { //单个线程池,核心线程数和最大线程数都是1 ExecutorService exec = Executors.newSingleThreadExecutor(); for (int i = 0; i < 10; i++) { exec.execute(new Demo()); Thread.sleep(5); } exec.shutdown(); } }
-
-
可缓存线程池
-
核心线程数为0
-
最大线程数是Integer.MAX_VALUE
-
阻塞队列为SynchronousQueue:不存储元素的阻塞队列,每个插入操作都必须等待一个移出操作。
-
适用场景:适合任务数比较密集,但每个任务执行时间较短的情况
-
案例:
public class CachedThreadPoolCase { static class Demo implements Runnable { @Override public void run() { String name = Thread.currentThread().getName(); try { //修改睡眠时间,模拟线程执行需要花费的时间 Thread.sleep(100); System.out.println(name + "执行完了"); } catch (InterruptedException e) { e.printStackTrace(); } } } public static void main(String[] args) throws InterruptedException { //创建一个缓存的线程,没有核心线程数,最大线程数为Integer.MAX_VALUE ExecutorService exec = Executors.newCachedThreadPool(); for (int i = 0; i < 10; i++) { exec.execute(new Demo()); Thread.sleep(1); } exec.shutdown(); } }
-
-
提供了“延迟”和“周期执行”功能的ThreadPoolExecutor。
-
适用场景:有定时和延迟执行的任务
-
案例:
public class ScheduledThreadPoolCase { static class Task implements Runnable { @Override public void run() { try { String name = Thread.currentThread().getName(); System.out.println(name + ", 开始:" + new Date()); Thread.sleep(1000); System.out.println(name + ", 结束:" + new Date()); } catch (InterruptedException e) { e.printStackTrace(); } } } public static void main(String[] args) throws InterruptedException { //按照周期执行的线程池,核心线程数为2,最大线程数为Integer.MAX_VALUE ScheduledExecutorService scheduledThreadPool = Executors.newScheduledThreadPool(2); System.out.println("程序开始:" + new Date()); /** * schedule 提交任务到线程池中 * 第一个参数:提交的任务 * 第二个参数:任务执行的延迟时间 * 第三个参数:时间单位 */ scheduledThreadPool.schedule(new Task(), 0, TimeUnit.SECONDS); scheduledThreadPool.schedule(new Task(), 1, TimeUnit.SECONDS); scheduledThreadPool.schedule(new Task(), 5, TimeUnit.SECONDS); Thread.sleep(5000); // 关闭线程池 scheduledThreadPool.shutdown(); } }
-
3.5 为什么不建议用Executors创建线程池
难易程度:☆☆☆
出现频率:☆☆☆
参考阿里开发手册《Java开发手册-嵩山版》
4.线程使用场景问题
4.1 线程池使用场景CountDownLatch、Future(你们项目哪里用到了多线程)
难易程度:☆☆☆
出现频率:☆☆☆☆
4.1.1 CountDownLatch
CountDownLatch(闭锁/倒计时锁)用来进行线程同步协作,等待所有线程完成倒计时(一个或者多个线程,等待其他多个线程完成某件事情之后才能执行)
-
其中构造参数用来初始化等待计数值
-
await() 用来等待计数归零
-
countDown() 用来让计数减一
案例代码:
public class CountDownLatchDemo {
public static void main(String[] args) throws InterruptedException {
//初始化了一个倒计时锁 参数为 3
CountDownLatch latch = new CountDownLatch(3);
new Thread(() -> {
System.out.println(Thread.currentThread().getName()+"-begin...");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
//count--
latch.countDown();
System.out.println(Thread.currentThread().getName()+"-end..." +latch.getCount());
}).start();
new Thread(() -> {
System.out.println(Thread.currentThread().getName()+"-begin...");
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
//count--
latch.countDown();
System.out.println(Thread.currentThread().getName()+"-end..." +latch.getCount());
}).start();
new Thread(() -> {
System.out.println(Thread.currentThread().getName()+"-begin...");
try {
Thread.sleep(1500);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
//count--
latch.countDown();
System.out.println(Thread.currentThread().getName()+"-end..." +latch.getCount());
}).start();
String name = Thread.currentThread().getName();
System.out.println(name + "-waiting...");
//等待其他线程完成
latch.await();
System.out.println(name + "-wait end...");
}
}
4.1.2 案例一(es数据批量导入)
在我们项目上线之前,我们需要把数据库中的数据一次性的同步到es索引库中,但是当时的数据好像是1000万左右,一次性读取数据肯定不行(oom异常),当时我就想到可以使用线程池的方式导入,利用CountDownLatch来控制,就能避免一次性加载过多,防止内存溢出
整体流程就是通过CountDownLatch+线程池配合去执行
详细实现流程:
详细实现代码,请查看当天代码
4.1.3 案例二(数据汇总)
在一个电商网站中,用户下单之后,需要查询数据,数据包含了三部分:订单信息、包含的商品、物流信息;这三块信息都在不同的微服务中进行实现的,我们如何完成这个业务呢?
详细实现代码,请查看当天代码
-
在实际开发的过程中,难免需要调用多个接口来汇总数据,如果所有接口(或部分接口)的没有依赖关系,就可以使用线程池+future来提升性能
-
报表汇总
4.1.4 案例二(异步调用)
在进行搜索的时候,需要保存用户的搜索记录,而搜索记录不能影响用户的正常搜索,我们通常会开启一个线程去执行历史记录的保存,在新开启的线程在执行的过程中,可以利用线程提交任务
4.1 如何控制某个方法允许并发访问线程的数量?
难易程度:☆☆☆
出现频率:☆☆
Semaphore [ˈsɛməˌfɔr] 信号量,是JUC包下的一个工具类,我们可以通过其限制执行的线程数量,达到限流的效果
当一个线程执行时先通过其方法进行获取许可操作,获取到许可的线程继续执行业务逻辑,当线程执行完成后进行释放许可操作,未获取达到许可的线程进行等待或者直接结束。
Semaphore两个重要的方法
lsemaphore.acquire(): 请求一个信号量,这时候的信号量个数-1(一旦没有可使用的信号量,也即信号量个数变为负数时,再次请求的时候就会阻塞,直到其他线程释放了信号量)
lsemaphore.release():释放一个信号量,此时信号量个数+1
线程任务类:
public class SemaphoreCase {
public static void main(String[] args) {
// 1. 创建 semaphore 对象
Semaphore semaphore = new Semaphore(3);
// 2. 10个线程同时运行
for (int i = 0; i < 10; i++) {
new Thread(() -> {
try {
// 3. 获取许可
semaphore.acquire();
} catch (InterruptedException e) {
e.printStackTrace();
}
try {
System.out.println("running...");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("end...");
} finally {
// 4. 释放许可
semaphore.release();
}
}).start();
}
}
}
5.其他
5.1 谈谈你对ThreadLocal的理解
ThreadLocal扩容详细讲解_苏然HHash#的博客-CSDN博客
通过当前线程可以获取到当前线程维护的ThreadLocalMap, 底层是一个Entry数组, 扩容机制和hashMap一样, 然后通过当前ThreadLocal拿到存入的值一个值
一个ThreadLocal只能存一个值
通过ThreadLocal可以拿到
难易程度:☆☆☆
出现频率:☆☆☆☆
5.1.1 概述
ThreadLocal是多线程中对于解决线程安全的一个操作类,它会为每个线程都分配一个独立的线程副本从而解决了变量并发访问冲突的问题。ThreadLocal 同时实现了线程内的资源共享
案例:使用JDBC操作数据库时,会将每一个线程的Connection放入各自的ThreadLocal中,从而保证每个线程都在各自的 Connection 上进行数据库的操作,避免A线程关闭了B线程的连接。
5.1.2 ThreadLocal基本使用
三个主要方法:
-
set(value) 设置值
-
get() 获取值
-
remove() 清除值
public class ThreadLocalTest {
static ThreadLocal<String> threadLocal = new ThreadLocal<>();
public static void main(String[] args) {
new Thread(() -> {
String name = Thread.currentThread().getName();
threadLocal.set("itcast");
print(name);
System.out.println(name + "-after remove : " + threadLocal.get());
}, "t1").start();
new Thread(() -> {
String name = Thread.currentThread().getName();
threadLocal.set("itheima");
print(name);
System.out.println(name + "-after remove : " + threadLocal.get());
}, "t2").start();
}
static void print(String str) {
//打印当前线程中本地内存中本地变量的值
System.out.println(str + " :" + threadLocal.get());
//清除本地内存中的本地变量
threadLocal.remove();
}
}
5.1.3 ThreadLocal的实现原理&源码解析
ThreadLocal本质来说就是一个线程内部存储类,从而让多个线程只操作自己内部的值,从而实现线程数据隔离
注意这里的一句话: 每个线程持有一个ThreadLocalMap 对象
在ThreadLocal中有一个内部类叫做ThreadLocalMap,类似于HashMap
ThreadLocalMap中有一个属性table数组,这个是真正存储数据的位置
set方法
get方法/remove方法
5.1.4 ThreadLocal-内存泄露问题
Java对象中的四种引用类型:强引用、软引用、弱引用、虚引用
- 强引用:最为普通的引用方式,表示一个对象处于有用且必须的状态,如果一个对象具有强引用,则GC并不会回收它。即便堆中内存不足了,宁可出现OOM,也不会对其进行回收
- 弱引用:表示一个对象处于可能有用且非必须的状态。在GC线程扫描内存区域时,一旦发现弱引用,就会回收到弱引用相关联的对象。对于弱引用的回收,无关内存区域是否足够,一旦发现则会被回收
每一个Thread维护一个ThreadLocalMap,在ThreadLocalMap中的Entry对象继承了WeakReference。其中key为使用弱引用的ThreadLocal实例,value为线程变量的副本
在使用ThreadLocal的时候,强烈建议:务必手动remove
6 真实面试还原
6.1 线程的基础知识
面试官:聊一下并行和并发有什么区别?
候选人:
是这样的~~
现在都是多核CPU,在多核CPU下
并发是同一时间应对多件事情的能力,多个线程轮流使用一个或多个CPU
并行是同一时间动手做多件事情的能力,4核CPU同时执行4个线程
面试官:说一下线程和进程的区别?
候选人:
嗯,好~
- 进程是正在运行程序的实例,进程中包含了线程,每个线程执行不同的任务
- 不同的进程使用不同的内存空间,在当前进程下的所有线程可以共享内存空间
- 线程更轻量,线程上下文切换成本一般上要比进程上下文切换低(上下文切换指的是从一个线程切换到另一个线程)
面试官:如果在java中创建线程有哪些方式?
候选人:
在java中一共有四种常见的创建方式,分别是:继承Thread类、实现runnable接口、实现Callable接口、线程池创建线程。通常情况下,我们项目中都会采用线程池的方式创建线程。
面试官:好的,刚才你说的runnable 和 callable 两个接口创建线程有什么不同呢?
候选人:
是这样的~
最主要的两个线程一个是有返回值,一个是没有返回值的。
Runnable 接口run方法无返回值;Callable接口call方法有返回值,是个泛型,和Future、FutureTask配合可以用来获取异步执行的结果
还有一个就是,他们异常处理也不一样。Runnable接口run方法只能抛出运行时异常,也无法捕获处理;Callable接口call方法允许抛出异常,可以获取异常信息
在实际开发中,如果需要拿到执行的结果,需要使用Callalbe接口创建线程,调用FutureTask.get()得到可以得到返回值,此方法会阻塞主进程的继续往下执行,如果不调用不会阻塞。
面试官:线程包括哪些状态,状态之间是如何变化的?
候选人:
在JDK中的Thread类中的枚举State里面定义了6中线程的状态分别是:新建、可运行、终结、阻塞、等待和有时限等待六种。
关于线程的状态切换情况比较多。我分别介绍一下
当一个线程对象被创建,但还未调用 start 方法时处于新建状态,调用了 start 方法,就会由新建进入可运行状态。如果线程内代码已经执行完毕,由可运行进入终结状态。当然这些是一个线程正常执行情况。
如果线程获取锁失败后,由可运行进入 Monitor 的阻塞队列阻塞,只有当持锁线程释放锁时,会按照一定规则唤醒阻塞队列中的阻塞线程,唤醒后的线程进入可运行状态
如果线程获取锁成功后,但由于条件不满足,调用了 wait() 方法,此时从可运行状态释放锁等待状态,当其它持锁线程调用 notify() 或 notifyAll() 方法,会恢复为可运行状态
还有一种情况是调用 sleep(long) 方法也会从可运行状态进入有时限等待状态,不需要主动唤醒,超时时间到自然恢复为可运行状态
面试官:嗯,好的,刚才你说的线程中的 wait 和 sleep方法有什么不同呢?
候选人:
它们两个的相同点是都可以让当前线程暂时放弃 CPU 的使用权,进入阻塞状态。
不同点主要有三个方面:
第一:方法归属不同
sleep(long) 是 Thread 的静态方法。而 wait(),是 Object 的成员方法,每个对象都有
第二:线程醒来时机不同
线程执行 sleep(long) 会在等待相应毫秒后醒来,而 wait() 需要被 notify 唤醒,wait() 如果不唤醒就一直等下去
第三:锁特性不同
wait 方法的调用必须先获取 wait 对象的锁,而 sleep 则无此限制
wait 方法执行后会释放对象锁,允许其它线程获得该对象锁(相当于我放弃 cpu,但你们还可以用)
而 sleep 如果在 synchronized 代码块中执行,并不会释放对象锁(相当于我放弃 cpu,你们也用不了)
面试官:好的,我现在举一个场景,你来分析一下怎么做,新建 T1、T2、T3 三个线程,如何保证它们按顺序执行?
候选人:
嗯~~,我思考一下 (适当的思考或想一下属于正常情况,脱口而出反而太假[背诵痕迹])
可以这么做,在多线程中有多种方法让线程按特定顺序执行,可以用线程类的join()方法在一个线程中启动另一个线程,另外一个线程完成该线程继续执行。
比如说:
使用join方法,T3调用T2,T2调用T1,这样就能确保T1就会先完成而T3最后完成
面试官:在我们使用线程的过程中,有两个方法。线程的 run()和 start()有什么区别?
候选人:
start方法用来启动线程,通过该线程调用run方法执行run方法中所定义的逻辑代码。start方法只能被调用一次。run方法封装了要被线程执行的代码,可以被调用多次。
面试官:那如何停止一个正在运行的线程呢?
候选人:
有三种方式可以停止线程
第一:可以使用退出标志,使线程正常退出,也就是当run方法完成后线程终止,一般我们加一个标记
第二:可以使用线程的stop方法强行终止,不过一般不推荐,这个方法已作废
第三:可以使用线程的interrupt方法中断线程,内部其实也是使用中断标志来中断线程
我们项目中使用的话,建议使用第一种或第三种方式中断线程
6.2 线程中并发锁
面试官:讲一下synchronized关键字的底层原理?
候选人:
嗯~~好的,
synchronized 底层使用的JVM级别中的Monitor 来决定当前线程是否获得了锁,如果某一个线程获得了锁,在没有释放锁之前,其他线程是不能或得到锁的。synchronized 属于悲观锁。
synchronized 因为需要依赖于JVM级别的Monitor ,相对性能也比较低。
面试官:好的,你能具体说下Monitor 吗?
候选人:
monitor对象存在于每个Java对象的对象头中,synchronized 锁便是通过这种方式获取锁的,也是为什么Java中任意对象可以作为锁的原因
monitor内部维护了三个变量
WaitSet:保存处于Waiting状态的线程
EntryList:保存处于Blocked状态的线程
Owner:持有锁的线程
只有一个线程获取到的标志就是在monitor中设置成功了Owner,一个monitor中只能有一个Owner
在上锁的过程中,如果有其他线程也来抢锁,则进入EntryList 进行阻塞,当获得锁的线程执行完了,释放了锁,就会唤醒EntryList 中等待的线程竞争锁,竞争的时候是非公平的。
面试官:好的,那关于synchronized 的锁升级的情况了解吗?
候选人:
嗯,知道一些(要谦虚)
Java中的synchronized有偏向锁、轻量级锁、重量级锁三种形式,分别对应了锁只被一个线程持有、不同线程交替持有锁、多线程竞争锁三种情况。
重量级锁:底层使用的Monitor实现,里面涉及到了用户态和内核态的切换、进程的上下文切换,成本较高,性能比较低。
轻量级锁:线程加锁的时间是错开的(也就是没有竞争),可以使用轻量级锁来优化。轻量级修改了对象头的锁标志,相对重量级锁性能提升很多。每次修改都是CAS操作,保证原子性
偏向锁:一段很长的时间内都只被一个线程使用锁,可以使用了偏向锁,在第一次获得锁时,会有一个CAS操作,之后该线程再获取锁,只需要判断mark word中是否是自己的线程id即可,而不是开销相对较大的CAS命令
一旦锁发生了竞争,都会升级为重量级锁
面试官:好的,刚才你说了synchronized它在高并发量的情况下,性能不高,在项目该如何控制使用锁呢?
候选人:
嗯,其实,在高并发下,我们可以采用ReentrantLock来加锁。
面试官:嗯,那你说下ReentrantLock的使用方式和底层原理?
候选人:
好的,
ReentrantLock是一个可重入锁:,调用 lock 方 法获取了锁之后,再次调用 lock,是不会再阻塞,内部直接增加重入次数 就行了,标识这个线程已经重复获取一把锁而不需要等待锁的释放。
ReentrantLock是属于juc报下的类,属于api层面的锁,跟synchronized一样,都是悲观锁。通过lock()用来获取锁,unlock()释放锁。
它的底层实现原理主要利用CAS+AQS队列来实现。它支持公平锁和非公平锁,两者的实现类似
构造方法接受一个可选的公平参数(默认非公平锁),当设置为true时,表示公平锁,否则为非公平锁。公平锁的效率往往没有非公平锁的效率高。
面试官:好的,刚才你说了CAS和AQS,你能介绍一下吗?
候选人:
好的。
CAS的全称是: Compare And Swap(比较再交换);它体现的一种乐观锁的思想,在无锁状态下保证线程操作数据的原子性。
CAS使用到的地方很多:AQS框架、AtomicXXX类
在操作共享变量的时候使用的自旋锁,效率上更高一些
CAS的底层是调用的Unsafe类中的方法,都是操作系统提供的,其他语言实现
AQS的话,其实就一个jdk提供的类AbstractQueuedSynchronizer,是阻塞式锁和相关的同步器工具的框架。
内部有一个属性 state 属性来表示资源的状态,默认state等于0,表示没有获取锁,state等于1的时候才标明获取到了锁。通过cas 机制设置 state 状态
在它的内部还提供了基于 FIFO 的等待队列,是一个双向列表,其中
tail 指向队列最后一个元素
head 指向队列中最久的一个元素
其中我们刚刚聊的ReentrantLock底层的实现就是一个AQS。
面试官:synchronized和Lock有什么区别 ?
候选人:
嗯~~,好的,主要有三个方面不太一样
第一,语法层面
- synchronized 是关键字,源码在 jvm 中,用 c++ 语言实现,退出同步代码块锁会自动释放
- Lock 是接口,源码由 jdk 提供,用 java 语言实现,需要手动调用 unlock 方法释放锁
第二,功能层面
- 二者均属于悲观锁、都具备基本的互斥、同步、锁重入功能
- Lock 提供了许多 synchronized 不具备的功能,例如获取等待状态、公平锁、可打断、可超时、多条件变量,同时Lock 可以实现不同的场景,如 ReentrantLock, ReentrantReadWriteLock
第三,性能层面
- 在没有竞争时,synchronized 做了很多优化,如偏向锁、轻量级锁,性能不赖
- 在竞争激烈时,Lock 的实现通常会提供更好的性能
统合来看,需要根据不同的场景来选择不同的锁的使用。
面试官:死锁产生的条件是什么?
候选人:
嗯,是这样的,一个线程需要同时获取多把锁,这时就容易发生死锁,举个例子来说:
t1 线程获得A对象锁,接下来想获取B对象的锁
t2 线程获得B对象锁,接下来想获取A对象的锁
这个时候t1线程和t2线程都在互相等待对方的锁,就产生了死锁
面试官:那如果产出了这样的,如何进行死锁诊断?
候选人:
这个也很容易,我们只需要通过jdk自动的工具就能搞定
我们可以先通过jps来查看当前java程序运行的进程id
然后通过jstack来查看这个进程id,就能展示出来死锁的问题,并且,可以定位代码的具体行号范围,我们再去找到对应的代码进行排查就行了。
面试官:请谈谈你对 volatile 的理解
候选人:
嗯~~
volatile 是一个关键字,可以修饰类的成员变量、类的静态成员变量,主要有两个功能
第一:保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的,volatile关键字会强制将修改的值立即写入主存。
第二: 禁止进行指令重排序,可以保证代码执行有序性。底层实现原理是,添加了一个内存屏障,通过插入内存屏障禁止在内存屏障前后的指令执行重排序优化
本文作者:接《集合相关面试题》
面试官:那你能聊一下ConcurrentHashMap的原理吗?
候选人:
嗯好的,
ConcurrentHashMap 是一种线程安全的高效Map集合,jdk1.7和1.8也做了很多调整。
- JDK1.7的底层采用是分段的数组+链表 实现
- JDK1.8 采用的数据结构跟HashMap1.8的结构一样,数组+链表/红黑二叉树。
在jdk1.7中 ConcurrentHashMap 里包含一个 Segment 数组。Segment 的结构和HashMap类似,是一 种数组和链表结构,一个 Segment 包含一个 HashEntry 数组,每个 HashEntry 是一个链表结构 的元素,每个 Segment 守护着一个HashEntry数组里的元素,当对 HashEntry 数组的数据进行修 改时,必须首先获得对应的 Segment的锁。
Segment 是一种可重入的锁 ReentrantLock,每个 Segment 守护一个HashEntry 数组里得元 素,当对 HashEntry 数组的数据进行修改时,必须首先获得对应的 Segment 锁
在jdk1.8中的ConcurrentHashMap 做了较大的优化,性能提升了不少。首先是它的数据结构与jdk1.8的hashMap数据结构完全一致。其次是放弃了Segment臃肿的设计,取而代之的是采用Node + CAS + Synchronized来保 证并发安全进行实现,synchronized只锁定当前链表或红黑二叉树的首节点,这样只要hash不冲 突,就不会产生并发 , 效率得到提升
6.3 线程池
面试官:线程池的种类有哪些?
候选人:
嗯!是这样
在jdk中默认提供了4中方式创建线程池
第一个是:newCachedThreadPool创建一个可缓存线程池,如果线程池长度超过处理需要,可灵活回 收空闲线程,若无可回收,则新建线程。
第二个是:newFixedThreadPool 创建一个定长线程池,可控制线程最大并发数,超出的线程会在队列 中等待。
第三个是:newScheduledThreadPool 创建一个定长线程池,支持定时及周期性任务执行。
第四个是:newSingleThreadExecutor 创建一个单线程化的线程池,它只会用唯一的工作线程来执行任 务,保证所有任务按照指定顺序(FIFO, LIFO, 优先级)执行。
面试官:线程池的核心参数有哪些?
候选人:
在线程池中一共有7个核心参数:
corePoolSize 核心线程数目 - 池中会保留的最多线程数
maximumPoolSize 最大线程数目 - 核心线程+救急线程的最大数目
keepAliveTime 生存时间 - 救急线程的生存时间,生存时间内没有新任务,此线程资源会释放
unit 时间单位 - 救急线程的生存时间单位,如秒、毫秒等
workQueue - 当没有空闲核心线程时,新来任务会加入到此队列排队,队列满会创建救急线程执行任务
threadFactory 线程工厂 - 可以定制线程对象的创建,例如设置线程名字、是否是守护线程等
handler 拒绝策略 - 当所有线程都在繁忙,workQueue 也放满时,会触发拒绝策略
在拒绝策略中又有4中拒绝策略
当线程数过多以后,第一种是抛异常、第二种是由调用者执行任务、第三是丢弃当前的任务,第四是丢弃最早排队任务。默认是直接抛异常。
面试官:如何确定核心线程池呢?
候选人:
是这样的,我们公司当时有一些规范,为了减少线程上下文的切换,要根据当时部署的服务器的CPU核数来决定,我们规则是:CPU核数+1就是最终的核心线程数。
面试官:线程池的执行原理知道吗?
候选人:
嗯~,它是这样的
首先判断线程池里的核心线程是否都在执行任务,如果不是则创建一个新的工作线程来执行任务。如果核心线程都在执行任务,则线程池判断工作队列是否已满,如果工作队列没有满,则将新提交的任务存储在这个工作队 列里。如果工作队列满了,则判断线程池里的线程是否都处于工作状态,如果没有,则创建一个新的工作线程来执行任 务。如果已经满了,则交给拒绝策略来处理这个任务。
面试官:为什么不建议使用Executors创建线程池呢?
候选人:
好的,其实这个事情在阿里提供的最新开发手册《Java开发手册-嵩山版》中也提到了
主要原因是如果使用Executors创建线程池的话,它允许的请求队列默认长度是Integer.MAX_VALUE,这样的话,有可能导致堆积大量的请求,从而导致OOM(内存溢出)。
所以,我们一般推荐使用ThreadPoolExecutor来创建线程池,这样可以明确规定线程池的参数,避免资源的耗尽。
6.4 线程使用场景问题
面试官:如果控制某一个方法允许并发访问线程的数量?
候选人:
嗯~~,我想一下
在jdk中提供了一个Semaphore[seməfɔːr]类(信号量)
它提供了两个方法,semaphore.acquire() 请求信号量,可以限制线程的个数,是一个正数,如果信号量是-1,就代表已经用完了信号量,其他线程需要阻塞了
第二个方法是semaphore.release(),代表是释放一个信号量,此时信号量的个数+1
面试官:好的,那该如何保证Java程序在多线程的情况下执行安全呢?
候选人:
嗯,刚才讲过了导致线程安全的原因,如果解决的话,jdk中也提供了很多的类帮助我们解决多线程安全的问题,比如:
- JDK Atomic开头的原子类、synchronized、LOCK,可以解决原子性问题
- synchronized、volatile、LOCK,可以解决可见性问题
- Happens-Before 规则可以解决有序性问题
面试官:你在项目中哪里用了多线程?
候选人:
嗯~~,我想一下当时的场景[根据自己简历上的模块设计多线程场景]
参考场景一:
es数据批量导入
在我们项目上线之前,我们需要把数据量的数据一次性的同步到es索引库中,但是当时的数据好像是1000万左右,一次性读取数据肯定不行(oom异常),如果分批执行的话,耗时也太久了。所以,当时我就想到可以使用线程池的方式导入,利用CountDownLatch+Future来控制,就能大大提升导入的时间。
参考场景二:
在我做那个xx电商网站的时候,里面有一个数据汇总的功能,在用户下单之后需要查询订单信息,也需要获得订单中的商品详细信息(可能是多个),还需要查看物流发货信息。因为它们三个对应的分别三个微服务,如果一个一个的操作的话,互相等待的时间比较长。所以,我当时就想到可以使用线程池,让多个线程同时处理,最终再汇总结果就可以了,当然里面需要用到Future来获取每个线程执行之后的结果才行
参考场景三:
《黑马头条》项目中使用的
我当时做了一个文章搜索的功能,用户输入关键字要搜索文章,同时需要保存用户的搜索记录(搜索历史),这块我设计的时候,为了不影响用户的正常搜索,我们采用的异步的方式进行保存的,为了提升性能,我们加入了线程池,也就说在调用异步方法的时候,直接从线程池中获取线程使用
6.5 其他
面试官:谈谈你对ThreadLocal的理解
候选人:
嗯,是这样的~~
ThreadLocal 主要功能有两个,第一个是可以实现资源对象的线程隔离,让每个线程各用各的资源对象,避免争用引发的线程安全问题,第二个是实现了线程内的资源共享
面试官:好的,那你知道ThreadLocal的底层原理实现吗?
候选人:
嗯,知道一些~
在ThreadLocal内部维护了一个一个 ThreadLocalMap 类型的成员变量,用来存储资源对象
当我们调用 set 方法,就是以 ThreadLocal 自己作为 key,资源对象作为 value,放入当前线程的 ThreadLocalMap 集合中
当调用 get 方法,就是以 ThreadLocal 自己作为 key,到当前线程中查找关联的资源值
当调用 remove 方法,就是以 ThreadLocal 自己作为 key,移除当前线程关联的资源值
面试官:好的,那关于ThreadLocal会导致内存溢出这个事情,了解吗?
候选人:
嗯,我之前看过源码,我想一下~~
是应为ThreadLocalMap 中的 key 被设计为弱引用,它是被动的被GC调用释放key,不过关键的是只有key可以得到内存释放,而value不会,因为value是一个强引用。
在使用ThreadLocal 时都把它作为静态变量(即强引用),因此无法被动依靠 GC 回收,建议主动的remove 释放 key,这样就能避免内存溢出。
JVM相关面试题
1 JVM组成
1.1 JVM由那些部分组成,运行流程是什么?
难易程度:☆☆☆
出现频率:☆☆☆☆
JVM是什么
Java Virtual Machine Java程序的运行环境(java二进制字节码的运行环境)
好处:
-
一次编写,到处运行
-
自动内存管理,垃圾回收机制
JVM由哪些部分组成,运行流程是什么?
从图中可以看出 JVM 的主要组成部分
- ClassLoader(类加载器)
- Runtime Data Area(运行时数据区,内存分区)
- Execution Engine(执行引擎)
- Native Method Library(本地库接口)
运行流程:
(1)类加载器(ClassLoader)把Java代码转换为字节码
(2)运行时数据区(Runtime Data Area)把字节码加载到内存中,而字节码文件只是JVM的一套指令集规范,并不能直接交给底层系统去执行,而是有执行引擎运行
(3)执行引擎(Execution Engine)将字节码翻译为底层系统指令,再交由CPU执行去执行,此时需要调用其他语言的本地库接口(Native Method Library)来实现整个程序的功能。
1.2 什么是程序计数器?
难易程度:☆☆☆
出现频率:☆☆☆☆
程序计数器:线程私有的,内部保存的字节码的行号。用于记录正在执行的字节码指令的地址。
javap -verbose xx.class 打印堆栈大小,局部变量的数量和方法的参数。
java虚拟机对于多线程是通过线程轮流切换并且分配线程执行时间。在任何的一个时间点上,一个处理器只会处理执行一个线程,如果当前被执行的这个线程它所分配的执行时间用完了【挂起】。处理器会切换到另外的一个线程上来进行执行。并且这个线程的执行时间用完了,接着处理器就会又来执行被挂起的这个线程。
那么现在有一个问题就是,当前处理器如何能够知道,对于这个被挂起的线程,它上一次执行到了哪里?那么这时就需要从程序计数器中来回去到当前的这个线程他上一次执行的行号,然后接着继续向下执行。
程序计数器是JVM规范中唯一一个没有规定出现OOM的区域,所以这个空间也不会进行GC。
1.3 你能给我详细的介绍Java堆吗?
难易程度:☆☆☆
出现频率:☆☆☆☆
**线程共享的区域:**主要用来保存对象实例,数组等,当堆中没有内存空间可分配给实例,也无法再扩展时,则抛出OutOfMemoryError异常。
- 年轻代被划分为三部分,Eden区和两个大小严格相同的Survivor区,根据JVM的策略,在经过几次垃圾收集后,任然存活于Survivor的对象将被移动到老年代区间。
- 老年代主要保存生命周期长的对象,一般是一些老的对象
- 元空间保存的类信息、静态变量、常量、编译后的代码
为了避免方法区出现OOM,所以在java8中将堆上的方法区【永久代】给移动到了本地内存上,重新开辟了一块空间,叫做元空间。那么现在就可以避免掉OOM的出现了。
java7 和 java 8
元空间(MetaSpace)介绍
在 HotSpot JVM 中,永久代( ≈ 方法区)中用于存放类和方法的元数据以及常量池,比如Class 和 Method。每当一个类初次被加载的时候,它的元数据都会放到永久代中。
永久代是有大小限制的,因此如果加载的类太多,很有可能导致永久代内存溢出,即OutOfMemoryError,为此不得不对虚拟机做调优。
那么,Java 8 中 PermGen 为什么被移出 HotSpot JVM 了?
官网给出了解释:http://openjdk.java.net/jeps/122
This is part of the JRockit and Hotspot convergence effort. JRockit customers do not need to configure the permanent generation (since JRockit does not have a permanent generation) and are accustomed to not configuring the permanent generation.
移除永久代是为融合HotSpot JVM与 JRockit VM而做出的努力,因为JRockit没有永久代,不需要配置永久代。
1)由于 PermGen 内存经常会溢出,引发OutOfMemoryError,因此 JVM 的开发者希望这一块内存可以更灵活地被管理,不要再经常出现这样的 OOM。
2)移除 PermGen 可以促进 HotSpot JVM 与 JRockit VM 的融合,因为 JRockit 没有永久代。
准确来说,Perm 区中的字符串常量池被移到了堆内存中是在 Java7 之后,Java 8 时,PermGen 被元空间代替,其他内容比如类元信息、字段、静态属性、方法、常量等都移动到元空间区。比如 java/lang/Object 类元信息、静态属性 System.out、整型常量等。
元空间的本质和永久代类似,都是对 JVM 规范中方法区的实现。不过元空间与永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存。因此,默认情况下,元空间的大小仅受本地内存限制。
1.4 什么是虚拟机栈
难易程度:☆☆☆
出现频率:☆☆☆☆
Java Virtual machine Stacks (java 虚拟机栈)
-
每个线程运行时所需要的内存,称为虚拟机栈,先进后出
-
每个栈由多个栈帧(frame)组成,对应着每次方法调用时所占用的内存
-
每个线程只能有一个活动栈帧,对应着当前正在执行的那个方法
-
垃圾回收是否涉及栈内存?
垃圾回收主要指就是堆内存,当栈帧弹栈以后,内存就会释放
-
栈内存分配越大越好吗?
未必,默认的栈内存通常为1024k
栈帧过大会导致线程数变少,例如,机器总内存为512m,目前能活动的线程数则为512个,如果把栈内存改为2048k,那么能活动的栈帧就会减半
-
方法内的局部变量是否线程安全?
-
如果方法内局部变量没有逃离方法的作用范围,它是线程安全的
-
如果是局部变量引用了对象,并逃离方法的作用范围,需要考虑线程安全
-
比如以下代码:
为什么呢? 我的理解是这里传的其实是一个引用
-
方法中定义的局部变量是否线程安全? - 简书 (jianshu.com)
方法中定义的局部变量是否为线程安全的?_方法内的局部变量是否线程安全?_杀手不太冷!的博客-CSDN博客
栈内存溢出情况
-
栈帧过多导致栈内存溢出,典型问题:递归调用
-
栈帧过大导致栈内存溢出
难易程度:☆☆☆
出现频率:☆☆☆
组成部分:堆、方法区、栈、本地方法栈、程序计数器
1、堆解决的是对象实例存储的问题,垃圾回收器管理的主要区域。
2、方法区可以认为是堆的一部分,用于存储已被虚拟机加载的信息,常量、静态变量、即时编译器编译后的代码。
3、栈解决的是程序运行的问题,栈里面存的是栈帧,栈帧里面存的是局部变量表、操作数栈、动态链接、方法出口等信息。
4、本地方法栈与栈功能相同,本地方法栈执行的是本地方法,一个Java调用非Java代码的接口。
5、程序计数器(PC寄存器)程序计数器中存放的是当前线程所执行的字节码的行数。JVM工作时就是通过改变这个计数器的值来选取下一个需要执行的字节码指令。
1.5 能不能解释一下方法区?
难易程度:☆☆☆
出现频率:☆☆☆
1.5.1 概述
-
方法区(Method Area)是各个线程共享的内存区域
-
主要存储类的信息、运行时常量池
-
虚拟机启动的时候创建,关闭虚拟机时释放
-
如果方法区域中的内存无法满足分配请求,则会抛出OutOfMemoryError: Metaspace
1.5.2 常量池
可以看作是一张表,虚拟机指令根据这张常量表找到要执行的类名、方法名、参数类型、字面量等信息
查看字节码结构(类的基本信息、常量池、方法定义)javap -v xx.class
比如下面是一个Application类的main方法执行,源码如下:
public class Application {
public static void main(String[] args) {
System.out.println("hello world");
}
}
找到类对应的class文件存放目录,执行命令:javap -v Application.class
查看字节码结构
D:\code\jvm-demo\target\classes\com\heima\jvm>javap -v Application.class
Classfile /D:/code/jvm-demo/target/classes/com/heima/jvm/Application.class
Last modified 2023-05-07; size 564 bytes //最后修改的时间
MD5 checksum c1b64ed6491b9a16c2baab5061c64f88 //签名
Compiled from "Application.java" //从哪个源码编译
public class com.heima.jvm.Application //包名,类名
minor version: 0
major version: 52 //jdk版本
flags: ACC_PUBLIC, ACC_SUPER //修饰符
Constant pool: //常量池
#1 = Methodref #6.#20 // java/lang/Object."<init>":()V
#2 = Fieldref #21.#22 // java/lang/System.out:Ljava/io/PrintStream;
#3 = String #23 // hello world
#4 = Methodref #24.#25 // java/io/PrintStream.println:(Ljava/lang/String;)V
#5 = Class #26 // com/heima/jvm/Application
#6 = Class #27 // java/lang/Object
#7 = Utf8 <init>
#8 = Utf8 ()V
#9 = Utf8 Code
#10 = Utf8 LineNumberTable
#11 = Utf8 LocalVariableTable
#12 = Utf8 this
#13 = Utf8 Lcom/heima/jvm/Application;
#14 = Utf8 main
#15 = Utf8 ([Ljava/lang/String;)V
#16 = Utf8 args
#17 = Utf8 [Ljava/lang/String;
#18 = Utf8 SourceFile
#19 = Utf8 Application.java
#20 = NameAndType #7:#8 // "<init>":()V
#21 = Class #28 // java/lang/System
#22 = NameAndType #29:#30 // out:Ljava/io/PrintStream;
#23 = Utf8 hello world
#24 = Class #31 // java/io/PrintStream
#25 = NameAndType #32:#33 // println:(Ljava/lang/String;)V
#26 = Utf8 com/heima/jvm/Application
#27 = Utf8 java/lang/Object
#28 = Utf8 java/lang/System
#29 = Utf8 out
#30 = Utf8 Ljava/io/PrintStream;
#31 = Utf8 java/io/PrintStream
#32 = Utf8 println
#33 = Utf8 (Ljava/lang/String;)V
{
public com.heima.jvm.Application(); //构造方法
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
LineNumberTable:
line 3: 0
LocalVariableTable:
Start Length Slot Name Signature
0 5 0 this Lcom/heima/jvm/Application;
public static void main(java.lang.String[]); //main方法
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=1, args_size=1
0: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #3 // String hello world
5: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: return
LineNumberTable:
line 7: 0
line 8: 8
LocalVariableTable:
Start Length Slot Name Signature
0 9 0 args [Ljava/lang/String;
}
SourceFile: "Application.java"
下图,左侧是main方法的指令信息,右侧constant pool 是常量池
main方法按照指令执行的时候,需要到常量池中查表翻译找到具体的类和方法地址去执行
1.5.3 运行时常量池
常量池是 *.class 文件中的,当该类被加载,它的常量池信息就会放入运行时常量池,并把里面的符号地址变为真实地址
1.6 你听过直接内存吗?
难易程度:☆☆☆
出现频率:☆☆☆
不受 JVM 内存回收管理,是虚拟机的系统内存,常见于 NIO 操作时,用于数据缓冲区,分配回收成本较高,但读写性能高,不受 JVM 内存回收管理
举例:
需求,在本地电脑中的一个较大的文件(超过100m)从一个磁盘挪到另外一个磁盘
代码如下:
/**
* 演示 ByteBuffer 作用
*/
public class Demo1_9 {
static final String FROM = "E:\\编程资料\\第三方教学视频\\youtube\\Getting Started with Spring Boot-sbPSjI4tt10.mp4";
static final String TO = "E:\\a.mp4";
static final int _1Mb = 1024 * 1024;
public static void main(String[] args) {
io(); // io 用时:1535.586957 1766.963399 1359.240226
directBuffer(); // directBuffer 用时:479.295165 702.291454 562.56592
}
private static void directBuffer() {
long start = System.nanoTime();
try (FileChannel from = new FileInputStream(FROM).getChannel();
FileChannel to = new FileOutputStream(TO).getChannel();
) {
ByteBuffer bb = ByteBuffer.allocateDirect(_1Mb);
while (true) {
int len = from.read(bb);
if (len == -1) {
break;
}
bb.flip();
to.write(bb);
bb.clear();
}
} catch (IOException e) {
e.printStackTrace();
}
long end = System.nanoTime();
System.out.println("directBuffer 用时:" + (end - start) / 1000_000.0);
}
private static void io() {
long start = System.nanoTime();
try (FileInputStream from = new FileInputStream(FROM);
FileOutputStream to = new FileOutputStream(TO);
) {
byte[] buf = new byte[_1Mb];
while (true) {
int len = from.read(buf);
if (len == -1) {
break;
}
to.write(buf, 0, len);
}
} catch (IOException e) {
e.printStackTrace();
}
long end = System.nanoTime();
System.out.println("io 用时:" + (end - start) / 1000_000.0);
}
}
可以发现,使用传统的IO的时间要比NIO操作的时间长了很多了,也就说NIO的读性能更好。
这个是跟我们的JVM的直接内存是有一定关系,如下图,是传统阻塞IO的数据传输流程
下图是NIO传输数据的流程,在这个里面主要使用到了一个直接内存,不需要在堆中开辟空间进行数据的拷贝,jvm可以直接操作直接内存,从而使数据读写传输更快。
1.7 堆栈的区别是什么?
难易程度:☆☆☆
出现频率:☆☆☆☆
1、栈内存一般会用来存储局部变量和方法调用,但堆内存是用来存储Java对象和数组的的。堆会GC垃圾回收,而栈不会。
2、栈内存是线程私有的,而堆内存是线程共有的。
3,、两者异常错误不同,但如果栈内存或者堆内存不足都会抛出异常。
栈空间不足:java.lang.StackOverFlowError。
堆空间不足:java.lang.OutOfMemoryError。
2 类加载器
2.1 什么是类加载器,类加载器有哪些?
难易程度:☆☆☆☆
出现频率:☆☆☆
要想理解类加载器的话,务必要先清楚对于一个Java文件,它从编译到执行的整个过程。
- 类加载器:用于装载字节码文件(.class文件)
- 运行时数据区:用于分配存储空间
- 执行引擎:执行字节码文件或本地方法(垃圾回收器:用于对JVM中的垃圾内容进行回收)
- 本地库接口
类加载器
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中查找类或者资源,如果找不到才会到自己本地查找。类加载器的委托行为动机是为了避免相同的类被加载多次。
2.2 什么是双亲委派模型?
难易程度:☆☆☆☆
出现频率:☆☆☆☆
如果一个类加载器在接到加载类的请求时,它首先不会自己尝试去加载这个类,而是把这个请求任务委托给父类加载器去完成,依次递归,如果父类加载器可以完成类加载任务,就返回成功;只有父类加载器无法完成此加载任务时,才由下一级去加载。
2.3 JVM为什么采用双亲委派机制
难易程度:☆☆☆
出现频率:☆☆☆
(1)通过双亲委派机制可以避免某一个类被重复加载,当父类已经加载后则无需重复加载,保证唯一性。
(2)为了安全,保证类库API不会被修改
在工程中新建java.lang包,接着在该包下新建String类,并定义main函数
public class String {
public static void main(String[] args) {
System.out.println("demo info");
}
}
此时执行main函数,会出现异常,在类 java.lang.String 中找不到 main 方法
出现该信息是因为由双亲委派的机制,java.lang.String的在启动类加载器(Bootstrap classLoader)得到加载,因为在核心jre库中有其相同名字的类文件,但该类中并没有main方法。这样就能防止恶意篡改核心API库。
2.4 说一下类装载的执行过程?
难易程度:☆☆☆☆☆
出现频率:☆☆☆
类从加载到虚拟机中开始,直到卸载为止,它的整个生命周期包括了:加载、验证、准备、解析、初始化、使用和卸载这7个阶段。其中,验证、准备和解析这三个部分统称为连接(linking)。
类加载过程详解
1.加载
- 通过类的全名,获取类的二进制数据流。
- 解析类的二进制数据流为方法区内的数据结构(Java类模型)
- 创建java.lang.Class类的实例,表示该类型。作为方法区这个类的各种数据的访问入口
>>>>>>>>>>>>>>>>>>>>>>>>>>>>> /* 重要重要重要重要重要重要重要 */ <<<<<<<<<<<<<<<<<<<<<<<<<<<<
// 当我们把类加载以后除了在对堆中生成一个Class类对象(数据结构,就可以将一个成员变量当做一个对象处理, 更利于数据的操作)之外
// 同时在方法区,还会生成一个类的字节码二进制数据
// 那么Class对象和字节码二进制数据有什么关系呢? 方法区(元空间)的内容会引用Class对象
//--------------------------------------------------------------
类加载器首先通过类的全限定名称
2.验证
验证类是否符合JVM规范,安全性检查
主要分为两类: 格式检查 ,
(1)文件格式验证:
是否符合Class文件的规范
(2)元数据验证
这个类是否有父类(除了Object这个类之外,其余的类都应该有父类)
这个类是否继承(extends)了被final修饰过的类(被final修饰过的类表示类不能被继承)
类中的字段、方法是否与父类产生矛盾。(被final修饰过的方法或字段是不能覆盖的)
(3)字节码验证
主要的目的是通过对数据流和控制流的分析,确定程序语义是合法的、符合逻辑的。
(4)符号引用验证:
符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量, 检查常量池中,引用的类或者方法是否存在
比如:int i = 3;
字面量:3
符号引用:i
对于四可以看出,这里是java.lang.reflect包下的内容. 即在常量池中存的是当前方法的对象(Method对象)
3.准备
为类变量分配内存并设置类变量初始值
-
static变量,分配空间在准备阶段完成(设置默认值),赋值在初始化阶段完成
-
static变量是final的基本类型,以及字符串常量,值已确定,赋值在准备阶段完成 (之类因为是final,所在值已经确定了)
-
static变量是final的引用类型,那么赋值也会在初始化阶段完成
4.解析
把类中的符号引用转换为直接引用
比如:方法中调用了其他方法,方法名可以理解为符号引用,而直接引用就是使用指针直接指向方法。
5.初始化
对类的静态变量,静态代码块执行初始化操作
- 如果初始化一个类的时候,其父类尚未初始化,则优先初始化其父类。
- 如果同时包含多个静态变量和静态代码块,则按照自上而下的顺序依次执行。
6.使用
JVM 开始从入口方法开始执行用户的程序代码
- 调用静态类成员信息(比如:静态字段、静态方法)
- 使用new关键字为其创建对象实例
7.卸载
当用户程序代码执行完毕后,JVM 便开始销毁创建的 Class 对象,最后负责运行的 JVM 也退出内存
3 垃圾收回
3.1 简述Java垃圾回收机制?(GC是什么?为什么要GC)
难易程度:☆☆☆
出现频率:☆☆☆
为了让程序员更专注于代码的实现,而不用过多的考虑内存释放的问题,所以,在Java语言中,有了自动的垃圾回收机制,也就是我们熟悉的GC(Garbage Collection)。
有了垃圾回收机制后,程序员只需要关心内存的申请即可,内存的释放由系统自动识别完成。
在进行垃圾回收时,不同的对象引用类型,GC会采用不同的回收时机
换句话说,自动的垃圾回收的算法就会变得非常重要了,如果因为算法的不合理,导致内存资源一直没有释放,同样也可能会导致内存溢出的。
当然,除了Java语言,C#、Python等语言也都有自动的垃圾回收机制。
3.2 对象什么时候可以被垃圾器回收
难易程度:☆☆☆☆
出现频率:☆☆☆☆
简单一句就是:如果一个或多个对象没有任何的引用指向它了,那么这个对象现在就是垃圾,如果定位了垃圾,则有可能会被垃圾回收器回收。
如果要定位什么是垃圾,有两种方式来确定,第一个是引用计数法,第二个是可达性分析算法
3.2.1 引用计数法
一个对象被引用了一次,在当前的对象头上递增一次引用次数,如果这个对象的引用次数为0,代表这个对象可回收
String demo = new String("123");
String demo = null;
当对象间出现了循环引用的话,则引用计数法就会失效
先执行右侧代码的前4行代码
目前上方的引用关系和计数都是没问题的,但是,如果代码继续往下执行,如下图
虽然a和b都为null,但是由于a和b存在循环引用,这样a和b永远都不会被回收。
优点:
- 实时性较高,无需等到内存不够的时候,才开始回收,运行时根据对象的计数器是否为0,就可以直接回收。
- 在垃圾回收过程中,应用无需挂起。如果申请内存时,内存不足,则立刻报OOM错误。
- 区域性,更新对象的计数器时,只是影响到该对象,不会扫描全部对象。
缺点:
- 每次对象被引用时,都需要去更新计数器,有一点时间开销。
- 浪费CPU资源,即使内存够用,仍然在运行时进行计数器的统计。
- 无法解决循环引用问题,会引发内存泄露。(最大的缺点)
3.2.2 可达性分析算法
现在的虚拟机采用的都是通过可达性分析算法来确定哪些内容是垃圾。
会存在一个根节点【GC Roots】,引出它下面指向的下一个节点,再以下一个节点节点开始找出它下面的节点,依次往下类推。直到所有的节点全部遍历完毕。
根对象是那些肯定不能当做垃圾回收的对象,就可以当做根对象
局部变量,静态方法,静态变量,类信息
核心是:判断某对象是否与根对象有直接或间接的引用,如果没有被引用,则可以当做垃圾回收
X,Y这两个节点是可回收的,但是并不会马上的被回收!! 对象中存在一个方法【finalize】。当对象被标记为可回收后,当发生GC时,首先会判断这个对象是否执行了finalize方法,如果这个方法还没有被执行的话,那么就会先来执行这个方法,接着在这个方法执行中,可以设置当前这个对象与GC ROOTS产生关联,那么这个方法执行完成之后,GC会再次判断对象是否可达,如果仍然不可达,则会进行回收,如果可达了,则不会进行回收。
finalize方法对于每一个对象来说,只会执行一次。如果第一次执行这个方法的时候,设置了当前对象与RC ROOTS关联,那么这一次不会进行回收。 那么等到这个对象第二次被标记为可回收时,那么该对象的finalize方法就不会再次执行了。
GC ROOTS:
- 虚拟机栈(栈帧中的本地变量表)中引用的对象
/**
* demo是栈帧中的本地变量,当 demo = null 时,由于此时 demo 充当了 GC Root 的作用,demo与原来指向的实例 new Demo() 断开了连接,对象被回收。
*/
public class Demo {
public static void main(String[] args) {
Demo demo = new Demo();
demo = null;
}
}
- 方法区中类静态属性引用的对象
/**
* 当栈帧中的本地变量 b = null 时,由于 b 原来指向的对象与 GC Root (变量 b) 断开了连接,所以 b 原来指向的对象会被回收,而由于我们给 a 赋值了变量的引用,a在此时是类静态属性引用,充当了 GC Root 的作用,它指向的对象依然存活!
*/
public class Demo {
public static Demo a;
public static void main(String[] args) {
Demo b = new Demo();
b.a = new Demo();
b = null;
}
}
- 方法区中常量引用的对象
/**
* 常量 a 指向的对象并不会因为 demo 指向的对象被回收而回收
*/
public class Demo {
public static final Demo a = new Demo();
public static void main(String[] args) {
Demo demo = new Demo();
demo = null;
}
}
- 本地方法栈中 JNI(即一般说的 Native 方法)引用的对象
3.3 JVM 垃圾回收算法有哪些?
难易程度:☆☆☆
出现频率:☆☆☆☆
3.3.1 标记清除算法
标记清除算法,是将垃圾回收分为2个阶段,分别是标记和清除。
1.根据可达性分析算法得出的垃圾进行标记
2.对这些标记为可回收的内容进行垃圾回收
可以看到,标记清除算法解决了引用计数算法中的循环引用的问题,没有从root节点引用的对象都会被回收。
同样,标记清除算法也是有缺点的:
- 效率较低,标记和清除两个动作都需要遍历所有的对象,并且在GC时,需要停止应用程序,对于交互性要求比较高的应用而言这个体验是非常差的。
- (重要)通过标记清除算法清理出来的内存,碎片化较为严重,因为被回收的对象可能存在于内存的各个角落,所以清理出来的内存是不连贯的。
3.3.2 复制算法
复制算法的核心就是,将原有的内存空间一分为二,每次只用其中的一块,在垃圾回收时,将正在使用的对象复制到另一个内存空间中,然后将该内存空间清空,交换两个内存的角色,完成垃圾的回收。
如果内存中的垃圾对象较多,需要复制的对象就较少,这种情况下适合使用该方式并且效率比较高,反之,则不适合。
1)将内存区域分成两部分,每次操作其中一个。
2)当进行垃圾回收时,将正在使用的内存区域中的存活对象移动到未使用的内存区域。当移动完对这部分内存区域一次性清除。
3)周而复始。
优点:
- 在垃圾对象多的情况下,效率较高
- 清理后,内存无碎片
缺点:
- 分配的2块内存空间,在同一个时刻,只能使用一半,内存使用率较低
3.3.3 标记整理算法
标记压缩算法是在标记清除算法的基础之上,做了优化改进的算法。和标记清除算法一样,也是从根节点开始,对对象的引用进行标记,在清理阶段,并不是简单的直接清理可回收对象,而是将存活对象都向内存另一端移动,然后清理边界以外的垃圾,从而解决了碎片化的问题。
1)标记垃圾。
2)需要清除向右边走,不需要清除的向左边走。
3)清除边界以外的垃圾。
优缺点同标记清除算法,解决了标记清除算法的碎片化的问题,同时,标记压缩算法多了一步,对象移动内存位置的步骤,其效率也有有一定的影响。
与复制算法对比:复制算法标记完就复制,但标记整理算法得等把所有存活对象都标记完毕,再进行整理
3.4 分代收集算法
3.4.1 概述
在java8时,堆被分为了两份:新生代和老年代【1:2】,在java7时,还存在一个永久代。
对于新生代,内部又被分为了三个区域。Eden区,S0区,S1区【8:1:1】
当对新生代产生GC:MinorGC【young GC】
当对老年代代产生GC:Major GC
当对新生代和老年代产生FullGC: 新生代 + 老年代完整垃圾回收,暂停时间长,应尽力避免
3.4.2工作机制
- 新创建的对象,都会先分配到eden区
-
当伊甸园内存不足,标记伊甸园与 from(现阶段没有)的存活对象
-
将存活对象采用复制算法复制到 to 中,复制完毕后,伊甸园和 from 内存都得到释放
- 经过一段时间后伊甸园的内存又出现不足,标记eden区域to区存活的对象,将存活的对象复制到from区
- 当幸存区对象熬过几次回收(最多15次),晋升到老年代(幸存区内存不足或大对象会导致提前晋升)
细节:
对象由幸存者区到老年区的有一下三种情况:
- 对象头中的age大于15次
- 幸存者区内存不足
- 对象太大的时候
MinorGC、 Mixed GC 、 FullGC的区别是什么
-
MinorGC【young GC】发生在新生代的垃圾回收,暂停时间短(STW)
-
Mixed GC 新生代 + 老年代部分区域的垃圾回收,G1 收集器特有
-
FullGC: 新生代 + 老年代完整垃圾回收,暂停时间长(STW),应尽力避免?
名词解释:
STW(Stop-The-World):暂停所有应用程序线程,等待垃圾回收的完成
3.5 说一下 JVM 有哪些垃圾回收器?
难易程度:☆☆☆☆
出现频率:☆☆☆☆
在jvm中,实现了多种垃圾收集器,包括:
-
串行垃圾收集器
-
并行垃圾收集器
-
CMS(并发)垃圾收集器
-
G1垃圾收集器
3.5.1 串行垃圾收集器
Serial和Serial Old串行垃圾收集器,是指使用单线程进行垃圾回收,堆内存较小,适合个人电脑
-
Serial 作用于新生代,采用复制算法
-
Serial Old 作用于老年代,采用标记-整理算法
垃圾回收时,只有一个线程在工作,并且java应用中的所有线程都要暂停(STW),等待垃圾回收的完成。
3.5.2 并行垃圾收集器
Parallel New和Parallel Old是一个并行垃圾回收器,JDK8默认使用此垃圾回收器
-
Parallel New作用于新生代,采用复制算法
-
Parallel Old作用于老年代,采用标记-整理算法
垃圾回收时,多个线程在工作,并且java应用中的所有线程都要暂停(STW),等待垃圾回收的完成。
3.5.2 CMS(并发)垃圾收集器
CMS全称 Concurrent Mark Sweep,是一款并发的、使用标记-清除算法的垃圾回收器,该回收器是针对老年代垃圾回收的,是一款以获取最短回收停顿时间为目标的收集器,停顿时间短,用户体验就好。其最大特点是在进行垃圾回收时,应用仍然能正常运行。
3.6 详细聊一下G1垃圾回收器
难易程度:☆☆☆☆
出现频率:☆☆☆☆
3.6.1 概述
-
应用于新生代和老年代,在JDK9之后默认使用G1
-
划分成多个区域,每个区域都可以充当 eden,survivor,old, humongous,其中 humongous 专为大对象准备
-
采用复制算法
-
响应时间与吞吐量兼顾
-
分成三个阶段:新生代回收、并发标记、混合收集
-
如果并发失败(即回收速度赶不上创建新对象速度),会触发 Full GC
3.6.2 Young Collection(年轻代垃圾回收)
-
初始时,所有区域都处于空闲状态
-
创建了一些对象,挑出一些空闲区域作为伊甸园区存储这些对象
-
当伊甸园需要垃圾回收时,挑出一个空闲区域作为幸存区,用复制算法复制存活对象,需要暂停用户线程
-
随着时间流逝,伊甸园的内存又有不足
-
将伊甸园以及之前幸存区中的存活对象,采用复制算法,复制到新的幸存区,其中较老对象晋升至老年代
3.6.3 Young Collection + Concurrent Mark (年轻代垃圾回收+并发标记)
当老年代占用内存超过阈值(默认是45%)后,触发并发标记,这时无需暂停用户线程
-
并发标记之后,会有重新标记阶段解决漏标问题,此时需要暂停用户线程。
-
这些都完成后就知道了老年代有哪些存活对象,随后进入混合收集阶段。此时不会对所有老年代区域进行回收,而是根据暂停时间目标优先回收价值高(存活对象少)的区域(这也是 Gabage First 名称的由来)。
3.6.4 Mixed Collection (混合垃圾回收)
复制完成,内存得到释放。进入下一轮的新生代回收、并发标记、混合收集
其中H叫做巨型对象,如果对象非常大,会开辟一块连续的空间存储巨型对象
3.7 强引用、软引用、弱引用、虚引用的区别?
难易程度:☆☆☆☆
出现频率:☆☆☆
3.7.1 强引用
强引用:只有所有 GC Roots 对象都不通过【强引用】引用该对象,该对象才能被垃圾回收
User user = new User();
3.7.2 软引用
软引用:仅有软引用引用该对象时,在垃圾回收后,内存仍不足时会再次出发垃圾回收
User user = new User();
SoftReference softReference = new SoftReference(user);
3.7.3 弱引用
弱引用:仅有弱引用引用该对象时,在垃圾回收时,无论内存是否充足,都会回收弱引用对象
User user = new User();
WeakReference weakReference = new WeakReference(user);
延伸话题:ThreadLocal内存泄漏问题
ThreadLocal用的就是弱引用,看以下源码:
static class Entry extends WeakReference<ThreadLocal<?>> {
Object value;
Entry(ThreadLocal<?> k, Object v) {
super(k);
value = v; //强引用,不会被回收
}
}
Entry
的key是当前ThreadLocal,value值是我们要设置的数据。
WeakReference
表示的是弱引用,当JVM进行GC时,一旦发现了只具有弱引用的对象,不管当前内存空间是否足够,都会回收它的内存。但是value
是强引用,它不会被回收掉。
ThreadLocal使用建议:使用完毕后注意调用清理方法。
3.7.4 虚引用
虚引用:必须配合引用队列使用,被引用对象回收时,会将虚引用入队,由 Reference Handler 线程调用虚引用相关方法释放直接内存
4 JVM实践(调优)
4.1 JVM 调优的参数可以在哪里设置参数值?
难易程度:☆☆
出现频率:☆☆☆
4.1.1 tomcat的设置vm参数
修改TOMCAT_HOME/bin/catalina.sh文件,如下图
JAVA_OPTS="-Xms512m -Xmx1024m"
4.1.2 springboot项目jar文件启动
通常在linux系统下直接加参数启动springboot项目
nohup java -Xms512m -Xmx1024m -jar xxxx.jar --spring.profiles.active=prod &
nohup : 用于在系统后台不挂断地运行命令,退出终端不会影响程序的运行
参数 & :让命令在后台执行,终端退出后命令仍旧执行。
4.2 用的 JVM 调优的参数都有哪些?
难易程度:☆☆☆
出现频率:☆☆☆☆
对于JVM调优,主要就是调整年轻代、年老大、元空间的内存空间大小及使用的垃圾回收器类型。
https://www.oracle.com/java/technologies/javase/vmoptions-jsp.html
1)设置堆的初始大小和最大大小,为了防止垃圾收集器在初始大小、最大大小之间收缩堆而产生额外的时间,通常把最大、初始大小设置为相同的值。
-Xms:设置堆的初始化大小
-Xmx:设置堆的最大大小
2) 设置年轻代中Eden区和两个Survivor区的大小比例。该值如果不设置,则默认比例为8:1:1。Java官方通过增大Eden区的大小,来减少YGC发生的次数,但有时我们发现,虽然次数减少了,但Eden区满
的时候,由于占用的空间较大,导致释放缓慢,此时STW的时间较长,因此需要按照程序情况去调优。
-XXSurvivorRatio=3,表示年轻代中的分配比率:survivor:eden = 2:3
3)年轻代和老年代默认比例为1:2。可以通过调整二者空间大小比率来设置两者的大小。
-XX:newSize 设置年轻代的初始大小
-XX:MaxNewSize 设置年轻代的最大大小, 初始大小和最大大小两个值通常相同
4)线程堆栈的设置:每个线程默认会开启1M的堆栈,用于存放栈帧、调用参数、局部变量等,但一般256K就够用。通常减少每个线程的堆栈,可以产生更多的线程,但这实际上还受限于操作系统。
-Xss 对每个线程stack大小的调整,-Xss128k
5)一般来说,当survivor区不够大或者占用量达到50%,就会把一些对象放到老年区。通过设置合理的eden区,survivor区及使用率,可以将年轻对象保存在年轻代,从而避免full GC,使用-Xmn设置年轻代的大小
6)系统CPU持续飙高的话,首先先排查代码问题,如果代码没问题,则咨询运维或者云服务器供应商,通常服务器重启或者服务器迁移即可解决。
7)对于占用内存比较多的大对象,一般会选择在老年代分配内存。如果在年轻代给大对象分配内存,年轻代内存不够了,就要在eden区移动大量对象到老年代,然后这些移动的对象可能很快消亡,因此导致full GC。通过设置参数:-XX:PetenureSizeThreshold=1000000,单位为B,标明对象大小超过1M时,在老年代(tenured)分配内存空间。
8)一般情况下,年轻对象放在eden区,当第一次GC后,如果对象还存活,放到survivor区,此后,每GC一次,年龄增加1,当对象的年龄达到阈值,就被放到tenured老年区。这个阈值可以同构-XX:MaxTenuringThreshold设置。如果想让对象留在年轻代,可以设置比较大的阈值。
(1)-XX:+UseParallelGC:年轻代使用并行垃圾回收收集器。这是一个关注吞吐量的收集器,可以尽可能的减少垃圾回收时间。
(2)-XX:+UseParallelOldGC:设置老年代使用并行垃圾回收收集器。
9)尝试使用大的内存分页:使用大的内存分页增加CPU的内存寻址能力,从而系统的性能。
-XX:+LargePageSizeInBytes 设置内存页的大小
10)使用非占用的垃圾收集器。
-XX:+UseConcMarkSweepGC老年代使用CMS收集器降低停顿。
4.3 说一下 JVM 调优的工具?
难易程度:☆☆☆☆
出现频率:☆☆☆☆
4.3.1 命令工具
4.3.1.1 jps(Java Process Status)
输出JVM中运行的进程状态信息(现在一般使用jconsole)
4.3.1.2 jstack
查看java进程内线程的堆栈信息。
jstack [option] <pid>
java案例
package com.heima.jvm;
public class Application {
public static void main(String[] args) throws InterruptedException {
while (true){
Thread.sleep(1000);
System.out.println("哈哈哈");
}
}
}
使用jstack查看进行堆栈运行信息
4.3.1.3 jmap
用于生成堆转存快照
jmap [options] pid 内存映像信息
jmap -heap pid 显示Java堆的信息
jmap -dump:format=b,file=heap.hprof pid
format=b表示以hprof二进制格式转储Java堆的内存
file=用于指定快照dump文件的文件名。
例:显示了某一个java运行的堆信息
C:\Users\yuhon>jmap -heap 53280
Attaching to process ID 53280, please wait...
Debugger attached successfully.
Server compiler detected.
JVM version is 25.321-b07
using thread-local object allocation.
Parallel GC with 8 thread(s) //并行的垃圾回收器
Heap Configuration: //堆配置
MinHeapFreeRatio = 0 //空闲堆空间的最小百分比
MaxHeapFreeRatio = 100 //空闲堆空间的最大百分比
MaxHeapSize = 8524922880 (8130.0MB) //堆空间允许的最大值
NewSize = 178257920 (170.0MB) //新生代堆空间的默认值
MaxNewSize = 2841640960 (2710.0MB) //新生代堆空间允许的最大值
OldSize = 356515840 (340.0MB) //老年代堆空间的默认值
NewRatio = 2 //新生代与老年代的堆空间比值,表示新生代:老年代=1:2
SurvivorRatio = 8 //两个Survivor区和Eden区的堆空间比值为8,表示S0:S1:Eden=1:1:8
MetaspaceSize = 21807104 (20.796875MB) //元空间的默认值
CompressedClassSpaceSize = 1073741824 (1024.0MB) //压缩类使用空间大小
MaxMetaspaceSize = 17592186044415 MB //元空间允许的最大值
G1HeapRegionSize = 0 (0.0MB)//在使用 G1 垃圾回收算法时,JVM 会将 Heap 空间分隔为若干个 Region,该参数用来指定每个 Region 空间的大小。
Heap Usage:
PS Young Generation
Eden Space: //Eden使用情况
capacity = 134217728 (128.0MB)
used = 10737496 (10.240074157714844MB)
free = 123480232 (117.75992584228516MB)
8.000057935714722% used
From Space: //Survivor-From 使用情况
capacity = 22020096 (21.0MB)
used = 0 (0.0MB)
free = 22020096 (21.0MB)
0.0% used
To Space: //Survivor-To 使用情况
capacity = 22020096 (21.0MB)
used = 0 (0.0MB)
free = 22020096 (21.0MB)
0.0% used
PS Old Generation //老年代 使用情况
capacity = 356515840 (340.0MB)
used = 0 (0.0MB)
free = 356515840 (340.0MB)
0.0% used
3185 interned Strings occupying 261264 bytes.
4.3.1.4 jhat
用于分析jmap生成的堆转存快照(一般不推荐使用,而是使用Ecplise Memory Analyzer)
4.3.1.5 jstat
是JVM统计监测工具。可以用来显示垃圾回收信息、类加载信息、新生代统计信息等。
常见参数:
①总结垃圾回收统计
jstat -gcutil pid
字段 | 含义 |
---|---|
S0 | 幸存1区当前使用比例 |
S1 | 幸存2区当前使用比例 |
E | 伊甸园区使用比例 |
O | 老年代使用比例 |
M | 元数据区使用比例 |
CCS | 压缩使用比例 |
YGC | 年轻代垃圾回收次数 |
YGCT | 年轻代垃圾回收消耗时间 |
FGC | 老年代垃圾回收次数 |
FGCT | 老年代垃圾回收消耗时间 |
GCT | 垃圾回收消耗总时间 |
②垃圾回收统计
jstat -gc pid
4.3.2 可视化工具
4.3.2.1 jconsole
用于对jvm的内存,线程,类 的监控,是一个基于 jmx 的 GUI 性能监控工具
打开方式:java 安装目录 bin目录下 直接启动 jconsole.exe 就行
可以内存、线程、类等信息
4.3.2.2 VisualVM:故障处理工具
能够监控线程,内存情况,查看方法的CPU时间和内存中的对 象,已被GC的对象,反向查看分配的堆栈
打开方式:java 安装目录 bin目录下 直接启动 jvisualvm.exe就行
监控程序运行情况
查看运行中的dump
查看堆中的信息
4.4 java内存泄露的排查思路?
难易程度:☆☆☆☆
出现频率:☆☆☆☆
原因:
如果线程请求分配的栈容量超过java虚拟机栈允许的最大容量的时候,java虚拟机将抛出一个StackOverFlowError异常
如果java虚拟机栈可以动态拓展,并且扩展的动作已经尝试过,但是目前无法申请到足够的内存去完成拓展,或者在建立新线程的时候没有足够的内存去创建对应的虚拟机栈,那java虚拟机将会抛出一个OutOfMemoryError异常
如果一次加载的类太多,元空间内存不足,则会报OutOfMemoryError: Metaspace
1、通过jmap指定打印他的内存快照 dump
有的情况是内存溢出之后程序则会直接中断,而jmap只能打印在运行中的程序,所以建议通过参数的方式的生成dump文件,配置如下:
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/home/app/dumps/ 指定生成后文件的保存目录
2、通过工具, VisualVM(Ecplise MAT)去分析 dump文件
VisualVM可以加载离线的dump文件,如下图
文件–>装入—>选择dump文件即可查看堆快照信息
如果是linux系统中的程序,则需要把dump文件下载到本地(windows环境)下,打开VisualVM工具分析。VisualVM目前只支持在windows环境下运行可视化
3、通过查看堆信息的情况,可以大概定位内存溢出是哪行代码出了问题
4、找到对应的代码,通过阅读上下文的情况,进行修复即可
4.5 CPU飙高排查方案与思路?
难易程度:☆☆☆☆
出现频率:☆☆☆☆
1.使用top命令查看占用cpu的情况
2.通过top命令查看后,可以查看是哪一个进程占用cpu较高,上图所示的进程为:30978
3.查看当前线程中的进程信息
ps H -eo pid,tid,%cpu | grep 40940
pid 进行id
tid 进程中的线程id
% cpu使用率
4.通过上图分析,在进程30978中的线程30979占用cpu较高
注意:上述的线程id是一个十进制,我们需要把这个线程id转换为16进制才行,因为通常在日志中展示的都是16进制的线程id名称
转换方式:
在linux中执行命令
printf "%x\n" 30979
5.可以根据线程 id 找到有问题的线程,进一步定位到问题代码的源码行号
执行命令
jstack 30978 此处是进程id
5.面试现场
5.1 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区。
面试官:能不能解释一下方法区?
候选人:
好的~
与虚拟机栈类似。本地方法栈是为虚拟机执行本地方法时提供服务的。不需要进行GC。本地方法一般是由其他语言编写。
面试官:你听过直接内存吗?
候选人:
嗯~~
它又叫做堆外内存,线程共享的区域,在 Java 8 之前有个永久代的概念,实际上指的是 HotSpot 虚拟机上的永久代,它用永久代实现了 JVM 规范定义的方法区功能,主要存储类的信息,常量,静态变量,即时编译器编译后代码等,这部分由于是在堆中实现的,受 GC 的管理,不过由于永久代有 -XX:MaxPermSize 的上限,所以如果大量动态生成类(将类信息放入永久代),很容易造成 OOM,有人说可以把永久代设置得足够大,但很难确定一个合适的大小,受类数量,常量数量的多少影响很大。
所以在 Java 8 中就把方法区的实现移到了本地内存中的元空间中,这样方法区就不受 JVM 的控制了,也就不会进行 GC,也因此提升了性能。
面试官:什么是虚拟机栈
候选人:
虚拟机栈是描述的是方法执行时的内存模型,是线程私有的,生命周期与线程相同,每个方法被执行的同时会创建栈桢。保存执行方法时的局部变量、动态连接信息、方法返回地址信息等等。方法开始执行的时候会进栈,方法执行完会出栈【相当于清空了数据】,所以这块区域不需要进行 GC。
面试官:能说一下堆栈的区别是什么吗?
候选人:
嗯,好的,有这几个区别
第一,栈内存一般会用来存储局部变量和方法调用,但堆内存是用来存储Java对象和数组的的。堆会GC垃圾回收,而栈不会。
第二、栈内存是线程私有的,而堆内存是线程共有的。
第三、两者异常错误不同,但如果栈内存或者堆内存不足都会抛出异常。
栈空间不足:java.lang.StackOverFlowError。
堆空间不足:java.lang.OutOfMemoryError。
5.2 类加载器
面试官:什么是类加载器,类加载器有哪些?
候选人:
嗯,是这样的
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不会被修改
5.3 垃圾回收
面试官:简述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 新生代 + 老年代完整垃圾回收,暂停时间长,应尽力避免
5.4 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占用较高
第四可以jstack命令打印进行的id,找到这个线程,就可以进一步定位问题代码的行号