Java后端面试高频问题:HashMap的底层原理
Posted Java小果
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Java后端面试高频问题:HashMap的底层原理相关的知识,希望对你有一定的参考价值。
1.HashMap底层实现
JDK1.8中HashMap的put()和get()操作的过程
put操作:
①首先判断数组是否为空,如果数组为空则进行第一次扩容(resize)
②根据key计算hash值并与上数组的长度-1(int index = key.hashCode()&(length-1))得到键值对在数组中的索引。
③如果该位置为null,则直接插入
④如果该位置不为null,则判断key是否一样(hashCode和equals),如果一样则直接覆盖value
⑤如果key不一样,则判断该元素是否为红黑树的节点,如果是,则直接在红黑树中插入键值对
⑥如果不是红黑树的节点,则就是链表,遍历这个链表执行插入操作,如果遍历过程中若发现key已存在,直接覆盖value即可。
如果链表的长度大于等于8且数组中元素数量大于等于阈值64,则将链表转化为红黑树,(先在链表中插入再进行判断)
如果链表的长度大于等于8且数组中元素数量小于阈值64,则先对数组进行扩容,不转化为红黑树。
⑦插入成功后,判断数组中元素的个数是否大于阈值64(threshold),超过了就对数组进行扩容操作。
get操作:
①计算key的hashCode的值,找到key在数组中的位置
②如果该位置为null,就直接返回null
③否则,根据equals()判断key与当前位置的值是否相等,如果相等就直接返回。
④如果不等,再判断当前元素是否为树节点,如果是树节点就按红黑树进行查找。
⑤否则,按照链表的方式进行查找。
2.HashMap的扩容机制
3.HashMap的初始容量为什么是16?
1.减少hash碰撞 (2n ,16=24)
2.需要在效率和内存使用上做一个权衡。这个值既不能太小,也不能太大。
3.防止分配过小频繁扩容
4.防止分配过大浪费资源
4.HashMap为什么每次扩容都以2的整数次幂进行扩容?
因为Hashmap计算存储位置时,使用了(n - 1) & hash。只有当容量n为2的幂次方,n-1的二进制会全为1,位运算时可以充分散列,避免不必要的哈希冲突,所以扩容必须2倍就是为了维持容量始终为2的幂次方。
5.HashMap的扩容因子为什么是0.75?
当负载因子为1.0时,意味着只有当hashMap装满之后才会进行扩容,虽然空间利用率有大的提升,但是这就会导致大量的hash冲突,使得查询效率变低。
当负载因子为0.5或者更低的时候,hash冲突降低,查询效率提高,但是由于负载因子太低,导致原来只需要1M的空间存储信息,现在用了2M的空间。最终结果就是空间利用率太低。
负载因子是0.75的时候,这是时间和空间的权衡,空间利用率比较高,而且避免了相当多的Hash冲突,使得底层的链表或者是红黑树的高度也比较低,提升了空间效率。
6.HashMap扩容后会重新计算Hash值吗?
①JDK1.7
JDK1.7中,HashMap扩容后,所有的key需要重新计算hash值,然后再放入到新数组中相应的位置。
②JDK1.8
在JDK1.8中,HashMap在扩容时,需要先创建一个新数组,然后再将旧数组中的数据转移到新数组上来。
此时,旧数组中的数据就会根据(e.hash & oldCap),数据的hash值与扩容前数组的长度进行与操作,根据结果是否等于0,分为2类。
1.等于0时,该节点放在新数组时的位置等于其在旧数组中的位置。
2.不等于0时,该节点在新数组中的位置等于其在旧数组中的位置+旧数组的长度。
7.HashMap中当链表长度大于等于8时,会将链表转化为红黑树,为什么是8?
如果 hashCode 分布良好,也就是 hash 计算的结果离散好的话,那么红黑树这种形式是很少会被用到的,因为各个值都均匀分布,很少出现链表很长的情况。在理想情况下,链表长度符合泊松分布,各个长度的命中概率依次递减,当长度为 8 的时候,概率仅为 0.00000006。这是一个小于千万分之一的概率,通常我们的 Map 里面是不会存储这么多的数据的,所以通常情况下,并不会发生从链表向红黑树的转换。
通俗点讲就是put进去的key进行计算hashCode时 只要选择计算hash值的算法足够好(hash碰撞率极低),从而遵循泊松分布,使得桶中挂载的bin的数量等于8的概率非常小,从而转换为红黑树的概率也小,反之则概率大。
8.HashMap为什么线程不安全?
1.在JDK1.7中,当并发执行扩容操作时会造成死循环和数据丢失的情况。
在JDK1.7中,在多线程情况下同时对数组进行扩容,需要将原来数据转移到新数组中,在转移元素的过程中使用的是头插法,会造成死循环。
2.在JDK1.8中,在并发执行put操作时会发生数据覆盖的情况。
如果线程A和线程B同时进行put操作,刚好这两条不同的数据hash值一样,并且该位置数据为null,所以这线程A、B都会通过判断,将执行插入操作。
假设一种情况,线程A进入后还未进行数据插入时挂起,而线程B正常执行,从而正常插入数据,然后线程A获取CPU时间片,此时线程A不用再进行hash判断了,问题出现:线程A会把线程B插入的数据给覆盖,发生线程不安全。
9.为什么HashMapJDK1.7中扩容时要采用头插法,JDK1.8又改为尾插法?
JDK1.7的HashMap在实现resize()时,新table[ ]的列表队头插入。
这样做的目的是:避免尾部遍历。
避免尾部遍历是为了避免在新列表插入数据时,遍历到队尾的位置。因为,直接插入的效率更高。
对resize()的设计来说,本来就是要创建一个新的table,列表的顺序不是很重要。但如果要确保插入队尾,还得遍历出链表的队尾位置,然后插入,是一种多余的损耗。
直接采用队头插入,会使得链表数据倒序。
JDK1.8采用尾插法是避免在多线程环境下扩容时采用头插法出现死循环的问题。
10.HashMap是如何解决哈希冲突的?
拉链法(链地址法)
为了解决碰撞,数组中的元素是单向链表类型。当链表长度大于等于8时,会将链表转换成红黑树提高性能。
而当链表长度小于等于6时,又会将红黑树转换回单向链表提高性能。
11.HashMap为什么使用红黑树而不是B树或平衡二叉树AVL或二叉查找树?
1.不使用二叉查找树
二叉排序树在极端情况下会出现线性结构。例如:二叉排序树左子树所有节点的值均小于根节点,如果我们添加的元素都比根节点小,会导致左子树线性增长,这样就失去了用树型结构替换链表的初衷,导致查询时间增长。所以这是不用二叉查找树的原因。
2.不使用平衡二叉树
平衡二叉树是严格的平衡树,红黑树是不严格平衡的树,平衡二叉树在插入或删除后维持平衡的开销要大于红黑树。
红黑树的虽然查询性能略低于平衡二叉树,但在插入和删除上性能要优于平衡二叉树。
选择红黑树是从功能、性能和开销上综合选择的结果。
3.不使用B树/B+树
HashMap本来是数组+链表的形式,链表由于其查找慢的特点,所以需要被查找效率更高的树结构来替换。
如果用B/B+树的话,在数据量不是很多的情况下,数据都会“挤在”一个结点里面,这个时候遍历效率就退化成了链表。
12.HashMap和Hashtable的异同?
①HashMap是⾮线程安全的,Hashtable是线程安全的。
Hashtable 内部的⽅法基本都经过 synchronized 修饰。
②因为线程安全的问题,HashMap要⽐Hashtable效率⾼⼀点。
③HashMap允许键和值是null,而Hashtable不允许键或值是null。
HashMap中,null 可以作为键,这样的键只有⼀个,可以有⼀个或多个键所对应的值为 null。
HashTable 中 put 进的键值只要有⼀个 null,直接抛出 NullPointerException。
④ Hashtable默认的初始⼤⼩为11,之后每次扩充,容量变为原来的2n+1。
HashMap默认的初始⼤⼩为16,之后每次扩充,容量变为原来的2倍。
⑤创建时如果给定了容量初始值,那么 Hashtable 会直接使⽤你给定的⼤⼩,⽽ HashMap 会将其扩充为2的幂次⽅⼤⼩。
⑥JDK1.8 以后的 HashMap 在解决哈希冲突时当链表⻓度⼤于等于8时,将链表转化为红⿊树,以减少搜索时间。Hashtable没有这样的机制。
Hashtable的底层,是以数组+链表的形式来存储。
⑦HashMap的父类是AbstractMap,Hashtable的父类是Dictionary
相同点:都实现了Map接口,都存储k-v键值对。
13.HashMap和HashSet的区别?
HashSet 底层就是基于 HashMap 实现的。(HashSet 的源码⾮常⾮常少,因为除了 clone() 、 writeObject() 、 readObject() 是 HashSet ⾃⼰不得不实现之外,其他⽅法都是直接调⽤ HashMap 中的⽅法)
1.HashMap实现了Map接口,HashSet实现了Set接口
2.HashMap存储键值对,HashSet存储对象
3.HashMap调用put()向map中添加元素,HashSet调用add()方法向Set中添加元素。
4.HashMap使用键key计算hashCode的值,HashSet使用对象来计算hashCode的值,在hashCode相等的情况下,使用equals()方法来判断对象的相等性。
5.HashSet中的元素由HashMap的key来保存,而HashMap的value则保存了一个静态的Object对象。
14.HashSet和TreeSet的区别?
相同点:HashSet和TreeSet的元素都是不能重复的,并且它们都是线程不安全的。
不同点:
①HashSet中的元素可以为null,但TreeSet中的元素不能为null
②HashSet不能保证元素的排列顺序,TreeSet支持自然排序、定制排序两种排序方式
③HashSet底层是采用哈希表实现的,TreeSet底层是采用红黑树实现的。
④HashSet的add,remove,contains方法的时间复杂度是 O(1),TreeSet的add,remove,contains方法的时间复杂度是 O(logn)
HashSet底层是基于HashMap实现的,存入HashSet中的元素实际上由HashMap的key来保存,而HashMap的value则存储了一个静态的Object对象。
value中的值都是统一的一个private static final Object PRESENT = new Object();
15.HashMap的遍历方式?
①通过map.keySet()获取key,根据key获取到value
for(String key:map.keySet()) System.out.println("key : "+key+" value : "+map.get(key));
②通过map.keySet()遍历key,通过map.values()遍历value
//第二种只遍历key或者value for(String key:map.keySet()) //遍历map的key System.out.println("键key :"+key); for(String value:map.values()) //遍历map的值 System.out.println("值value :"+value);
③通过Map.Entry(String,String) 获取,然后使用entry.getKey()获取到键,通过entry.getValue()获取到值
for(Map.Entry<String, String> entry : map.entrySet()) System.out.println("键 key :"+entry.getKey()+" 值value :"+entry.getValue());
④通过Iterator
Iterator<Map.Entry<String, String>> it = map.entrySet().iterator(); while(it.hasNext()) Map.Entry<String, String> entry = it.next(); System.out.println("键key :"+entry.getKey()+" value :"+entry.getValue());
希望对你有所帮助!
如果看完的小伙伴有兴趣了解更多的话,欢迎添加vx小助手:SOSOXWV 免费领取资料哦!
2019秋招:460道Java后端面试高频题答案版模块四:Java虚拟机
写在前面
1、强推:周志明的《深入理解 Java 虚拟机》,这本书可以说基本上涵盖了面试的常问考点。这本书的内容通俗易懂,我是从开始学习 Java 虚拟机到现在读了 3 遍,当然后面两遍过的比较快。为了突显出它的重要性以及这本书对我秋招面试中所发挥的作用,特将其封面放在下面。请大家一定好好读,面试肯定有很大的帮助。如果你这本书已经读了 3 遍以上,基本上没有看本文的必要了。
2、看面经:对于 Java 虚拟机的面试高频题要做好自己的答案。做好能加上自己的理解,因为这部分大家可能都会看《深入理解 Java 虚拟机》这本书,那么最好结合自己的知识体系,在答案中加入一些自己的总结;
3、调优加分:对于 Java 虚拟机这部分,除了要了解基础原理部分之外,最好能对调优有些了解,比如:JDK 自带的虚拟机监控工具、JVM 常用的参数、如何调优等等。
1、说一下 Jvm 的主要组成部分?及其作用?
各组件的作用:首先通过类加载器(ClassLoader)会把 Java 代码转换成字节码,运行时数据区(Runtime Data Area)再把字节码加载到内存中,而字节码文件只是 JVM 的一套指令集规范,并不能直接交给底层操作系统去执行,因此需要特定的命令解析器执行引擎(Execution Engine),将字节码翻译成底层系统指令,再交由 CPU 去执行,而这个过程中需要调用其他语言的本地库接口(Native Interface)来实现整个程序的功能。
Tip:这道题是非常重要的题目,几乎问到 Java 虚拟机这块都是会被问到的。建议不要简单的只回答几个区域的名称,最好展开的讲解下,下面的答案是比较详细的,根据自己的理解回答其中某一段即可。
-
1. 程序计数器
程序计数器(Program Counter Register):是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。
字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令。程序的分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。
由于 Java 虚拟机的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的,在任何一个确定的时刻,一个处理器都只会执行一条线程中的命令。因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各线程之间的计数器互不影响,独立存储,我们程这块内存区域为“线程私有”的内存。
此区域是唯一 一个虚拟机规范中没有规定任何 OutOfMemoryError 情况的区域。
-
2. Java 虚拟机栈
Java 虚拟机栈的局部变量表的空间单位是槽(Slot),其中 64 位长度的 double 和 long 类型会占用两个 Slot。局部变量表所需内存空间在编译期完成分配,当进入一个方法时,该方法需要在帧中分配多大的局部变量是完全确定的,在方法运行期间不会改变局部变量表的大小。
Java虚拟机栈有两种异常状况:如果线程请求的栈的深度大于虚拟机所允许的深度,将抛出 StackOverflowError 异常;如果扩展时无法申请到足够的内存,就会抛出 OutOfMemoryError 异常。
-
3. 本地方法栈
本地方法栈(Native Method Stack):与虚拟机栈所发挥的作用是非常相似的,它们之间的区别只不过是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。
Java 虚拟机规范没有对本地方法栈中方法使用的语言、使用的方式和数据结构做出强制规定,因此具体的虚拟机可以自由地实现它。比如:Sun HotSpot 虚拟机直接把Java虚拟机栈和本地方法栈合二为一。
与Java虚拟机栈一样,本地方法栈也会抛出StackOverflowError和 OutOfMemoryError 异常。
-
4. Java 堆
Java堆(Java Heap):是被所有线程所共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是:存放对象实例,几乎所有的对象实例都在这里分配内存。
Java 堆是垃圾收集器管理的主要区域,因此很多时候也被称做“GC”堆(Garbage Collected Heap)。从内存回收的角度看,由于现在收集器基本都采用分代收集算法,所以 Java 堆中还可以细分为:新生代和老年代。从内存分配角度来看,线程共享的 Java 堆中可能划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer, TLAB)。不过无论如何划分,都与存放的内容无关,无论哪个区域,存储的都仍然是对象实例,进一步划分的目的是为了更好地回收内存,或者更快地分配内存。
Java 虚拟机规定,Java 堆可以处于物理上不连续的内存空间中,只要逻辑上是连续的即可。在实现时,可以是固定大小的,也可以是可扩展的。如果在堆中没有完成实例分配。并且堆也无法扩展时,将会抛出 OutOfMemoryError 异常。
-
5. 方法区
方法区(Method Area):与 Java 堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。
虽然 Java 虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫做 Non-Heap(非堆),其目的应该就是与 Java 堆区分开来。
Java 虚拟机规范对方法区的限制非常宽松,除了和 Java 堆一样不需要连续的内存和可以选择固定大小或者可扩展外,还可以选择不实现垃圾收集。这个区域的内存回收目标主要是针对常量池的回收和对类型的卸载。
根据Java虚拟机规范规定,当方法区无法满足内存分配需求时,将抛出OutOfMemoryError异常。
运行时常量池
直接内存
堆和栈(虚拟机栈)是完全不同的两块内存区域,一个是线程独享的,一个是线程共享的。二者之间最大的区别就是存储的内容不同:堆中主要存放对象实例。栈(局部变量表)中主要存放各种基本数据类型、对象的引用。
从作用来说,栈是运行时的单位,而堆是存储的单位。栈解决程序的运行问题,即程序如何执行,或者说如何处理数据。堆解决的是数据存储的问题,即数据怎么放、放在哪儿。在 Java 中一个线程就会相应有一个线程栈与之对应,因为不同的线程执行逻辑有所不同,因此需要一个独立的线程栈。而堆则是所有线程共享的。栈因为是运行单位,因此里面存储的信息都是跟当前线程(或程序)相关信息的。包括局部变量、程序运行状态、方法返回值等等;而堆只负责存储对象信息。
堆中存的是对象。栈中存的是基本数据类型和堆中对象的引用。一个对象的大小是不可估计的,或者说是可以动态变化的,但是在栈中,一个对象只对应了一个 4btye 的引用(堆栈分离的好处)。
为什么不把基本类型放堆中呢?
6、Java 中的参数传递时传值呢?还是传引用?
要说明这个问题,先要明确两点:
Java 在方法调用传递参数时,因为没有指针,所以它都是进行传值调用。但是传引用的错觉是如何造成的呢?在运行栈中,基本类型和引用的处理是一样的,都是传值。所以,如果是传引用的方法调用,也同时可以理解为“传引用值”的传值调用,即引用的处理跟基本类型是完全一样的。但是当进入被调用方法时,被传递的这个引用的值,被程序解释到堆中的对象,这个时候才对应到真正的对象。如果此时进行修改,修改的是引用对应的对象,而不是引用本身,即:修改的是堆中的数据。所以这个修改是可以保持的了。
对象,从某种意义上说,是由基本类型组成的。可以把一个对象看作为一棵树,对象的属性如果还是对象,则还是一颗树(即非叶子节点),基本类型则为树的叶子节点。程序参数传递时,被传递的值本身都是不能进行修改的,但是,如果这个值是一个非叶子节点(即一个对象引用),则可以修改这个节点下面的所有内容。
Object ob = new Object();
这样在程序中完成了一个 Java 对象的生命,但是它所占的空间为:4 byte + 8 byte。4 byte 是上面部分所说的 Java 栈中保存引用的所需要的空间。而那 8 byte 则是 Java 堆中对象的信息。因为所有的 Java 非基本类型的对象都需要默认继承 Object 对象,因此不论什么样的 Java 对象,其大小都必须是大于 8 byte。有了 Object 对象的大小,我们就可以计算其他对象的大小了。
Class MaNong {
int count;
boolean flag;
Object obj;
}
MaNong 的大小为:空对象大小(8 byte) + int 大小(4 byte) + Boolean 大小(1 byte) + 空 Object 引用的大小(4 byte) = 17byte。但是因为 Java 在对对象内存分配时都是以 8 的整数倍来分,因此大于 17 byte 的最接近 8 的整数倍的是 24,因此此对象的大小为 24 byte。
这里需要注意一下基本类型的包装类型的大小。因为这种包装类型已经成为对象了,因此需要把它们作为对象来看待。包装类型的大小至少是12 byte(声明一个空 Object 至少需要的空间),而且 12 byte 没有包含任何有效信息,同时,因为 Java 对象大小是 8 的整数倍,因此一个基本类型包装类的大小至少是 16 byte。这个内存占用是很恐怖的,它是使用基本类型的 N 倍(N > 2),有些类型的内存占用更是夸张(随便想下就知道了)。因此,可能的话应尽量少使用包装类。在 JDK5 以后,因为加入了自动类型装换,因此,Java 虚拟机会在存储方面进行相应的优化。
1. 使用句柄
2. 直接指针
3. 各自的优点
2. 使用直接指针访问方式最大的好处就是速度快,它节省了一次指针定位的时间开销。
-
1. 引用计数法
-
基本思想
引用计数是垃圾收集器中的早期策略。在这种方法中,堆中每个对象实例都有一个引用计数。当一个对象被创建时,就将该对象实例分配给一个变量,该变量计数设置为 1。当任何其它变量被赋值为这个对象的引用时,计数加1(a = b,则 b 引用的对象实例的计数器加 1),但当一个对象实例的某个引用超过了生命周期或者被设置为一个新值时,对象实例的引用计数器减 1。任何引用计数器为 0 的对象实例可以被当作垃圾收集。当一个对象实例被垃圾收集时,它引用的任何对象实例的引用计数器减 1。
-
优缺点
循环引用
public class Demo{
public static void main(String[] args){
MyObject object1 = new MyObject();
MyObject object2 = new MyObject();
object1.object = object2;
object2.object = object1;
object1 = null;
object2 = null;
}
}
class MyObject{
MyObject object;
}
-
2. 可达性分析算法
虚拟机栈中引用的对象(栈帧中的本地变量表);
方法区中类静态属性引用的对象;
方法区中常量引用的对象;
本地方法栈中 JNI(Native方法)引用的对象。
10、垃圾回收是从哪里开始的呢?
查找哪些对象是正在被当前系统使用的。上面分析的堆和栈的区别,其中栈是真正进行程序执行地方,所以要获取哪些对象正在被使用,则需要从 Java 栈开始。同时,一个栈是与一个线程对应的,因此,如果有多个线程的话,则必须对这些线程对应的所有的栈进行检查。
同时,除了栈外,还有系统运行时的寄存器等,也是存储程序运行数据的。这样,以栈或寄存器中的引用为起点,我们可以找到堆中的对象,又从这些对象找到对堆中其他对象的引用,这种引用逐步扩展,最终以 null 引用或者基本类型结束,这样就形成了一颗以 Java 栈中引用所对应的对象为根节点的一颗对象树。如果栈中有多个引用,则最终会形成多颗对象树。在这些对象树上的对象,都是当前系统运行所需要的对象,不能被垃圾回收。而其他剩余对象,则可以视为无法被引用到的对象,可以被当做垃圾进行回收。
11、被标记为垃圾的对象一定会被回收吗?
12、谈谈对 Java 中引用的了解?
-
1. 强引用
在程序代码中普遍存在的,类似 Object obj = new Object() 这类引用,只要强引用还存在,垃圾收集器永远不会回收掉被引用的对象。
-
2. 软引用
-
3. 弱引用
-
4. 虚引用
13、谈谈对内存泄漏的理解?
内存泄露的基本概念
14、内存泄露的根本原因是什么?
长生命周期的对象持有短生命周期对象的引用就很可能发生内存泄漏,尽管短生命周期对象已经不再需要,但是因为长生命周期持有它的引用而导致不能被回收,这就是 Java 中内存泄漏的发生场景。
-
1. 标记-清除算法(Mark-Sweep)
-
2. 复制算法(Copying)
-
3. 标记-整理算法(Mark-compact)
-
4. 分代收集算法
18、为什么要采用分代收集算法?
分代的垃圾回收策略,是基于这样一个事实:不同的对象的生命周期是不一样的。因此,不同生命周期的对象可以采取不同的收集方式,以便提高回收效率。
19、分代收集下的年轻代和老年代应该采用什么样的垃圾回收算法?
1. 年轻代(Young Generation)的回收算法 (主要以 Copying 为主)
1. 所有新生成的对象首先都是放在年轻代的。年轻代的目标就是尽可能快速的收集掉那些生命周期短的对象。
2. 新生代内存按照 8:1:1 的比例分为一个 eden 区和两个 survivor(survivor0、 survivor1)区。大部分对象在 Eden 区中生成。回收时先将 Eden 区存活对象复制到一个 survivor0 区,然后清空 eden 区,当这个 survivor0 区也存放满了时,则将 eden 区和 survivor0 区存活对象复制到另一个 survivor1 区,然后清空 eden 区 和这个 survivor0 区,此时 survivor0 区是空的,然后将survivor0 区和 survivor1 区交换,即保持 survivor1 区为空, 如此往复。
3. 当 survivor1 区不足以存放 Eden 区 和 survivor0区 的存活对象时,就将存活对象直接存放到老年代。若是老年代也满了就会触发一次Full GC(Major GC),也就是新生代、老年代都进行回收。
4、新生代发生的 GC 也叫做 Minor GC,MinorGC 发生频率比较高(不一定等 Eden 区满了才触发)。
2. 年老代(Old Generation)的回收算法(主要以 Mark-Compact 为主)
1. 在年轻代中经历了 N 次垃圾回收后仍然存活的对象,就会被放到年老代中。因此,可以认为年老代中存放的都是一些生命周期较长的对象。
2. 内存比新生代也大很多(大概比例是1 : 2),当老年代内存满时触发 Major GC 即 Full GC,Full GC 发生频率比较低,老年代对象存活时间比较长,存活率标记高。
20、什么是浮动垃圾?
21、什么是内存碎片?如何解决?
22、常用的垃圾收集器有哪些?
1. Serial 收集器(复制算法)
-
2. Serial Old 收集器(标记-整理算法)
-
3. ParNew 收集器(停止-复制算法)
-
4. Parallel Scavenge 收集器(停止-复制算法)
5. Parallel Old 收集器(停止-复制算法)
Parallel Old 收集器的老年代版本,并行收集器,吞吐量优先。
6. CMS(Concurrent Mark Sweep)收集器(标记-清除算法)
-
7. G1
CMS 是英文 Concurrent Mark-Sweep 的简称,是以牺牲吞吐量为代价来获得最短回收停顿时间的垃圾回收器。是使用标记清除算法实现的,整个过程分为四步:
CMS 的优缺点:
主要优点:并发收集、低停顿;
主要缺点:对 CPU 资源敏感、无法处理浮动垃圾、它使用的回收算法“标记-清除”算法会导致收集结束时会有大量空间碎片产生。
24、谈谈你对 G1 收集器的理解?
垃圾回收的瓶颈
-
1. Minor / Scavenge GC
-
2. Full GC
26、谈谈你对内存分配的理解?大对象怎么分配?空间分配担保?
1. 对象优先在 Eden 区分配:大多数情况下,对象在新生代 Eden 区分配,当 Eden 区空间不够时,发起 Minor GC。
2. 大对象直接进入老年代:大对象是指需要连续内存空间的对象,最典型的大对象是那种很长的字符串以及数组。经常出现大对象会提前触发垃圾收集以获取足够的连续空间分配给大对象。-XX:PretenureSizeThreshold,大于此值的对象直接在老年代分配,避免在 Eden 区和 Survivor 区之间的大量内存复制。
3. 长期存活的对象将进入老年代:为对象定义年龄计数器,对象在 Eden 出生并经过 Minor GC 依然存活,将移动到 Survivor 中,年龄就增加 1 岁,增加到一定年龄则移动到老年代中。-XX:MaxTenuringThreshold 用来定义年龄的阈值。
4、动态对象年龄判定:为了更好的适应不同程序的内存情况,虚拟机不是永远要求对象年龄必须达到了某个值才能进入老年代,如果 Survivor 空间中相同年龄所有对象大小的总和大于 Survivor 空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无需达到要求的年龄。
5. 空间分配担保
(1)在发生 Minor GC 之前,虚拟机先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果条件成立的话,那么 Minor GC 可以确认是安全的;
(2)如果不成立的话,虚拟机会查看 HandlePromotionFailure 设置值是否允许担保失败,如果允许那么就会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试着进行一次 Minor GC;如果小于,或者 HandlePromotionFailure 设置不允许冒险,那么就要进行一次 Full GC。
27、说下你用过的 JVM 监控工具?
-
1. 堆信息查看
-
有了堆信息查看方面的功能,我们一般可以顺利解决以下问题:
2. 线程监控
线程信息监控:系统线程数量
线程状态监控:各个线程都处在什么样的状态下
Dump 线程详细信息:查看线程内部运行情况
死锁检查
3. 热点分析
1. CPU 热点:检查系统哪些方法占用的大量 CPU 时间;
2. 内存热点:检查哪些对象在系统中数量最大(一定时间内存活对象和销毁对象一起统计)这两个东西对于系统优化很有帮助。我们可以根据找到的热点,有针对性的进行系统的瓶颈查找和进行系统优化,而不是漫无目的的进行所有代码的优化。
4. 快照
-
5. 内存泄露检查
-
1. 堆设置
-
2. 收集器设置
-
3. 垃圾回收统计信息
-
4. 并行收集器设置
-
5. 并发收集器设置
30、谈谈你对类文件结构的理解?有哪些部分组成?
Class 文件结构如下标所示:
31、谈谈你对类加载机制的了解?
32、类加载各阶段的作用分别是什么?
1. 加载
-
2. 验证
主要是为了确保 Class 文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。验证阶段大致上分为 4 个阶段的检验动作:文件格式验证、元数据验证、字节码验证、符号引用验证。
-
3. 准备
-
4. 解析
-
5. 初始化
33、有哪些类加载器?分别有什么作用?
1. 启动类加载器(Bootstrap ClassLoader):这个类加载器是由 C++ 语言实现的,是虚拟机自身的一部分。负责将存在 <JAVA_HOME>\lib 目录中的,或者被 -Xbootclasspath 参数所指定的路径中的类库加载到虚拟机内存中。启动内加载器无法被 Java 程序直接引用,用户在编写自定义类加载器时,如果需要把加载请求委派给启动类加载器,直接使用 null 即可;
2. 其他类加载器:由 Java 语言实现,独立于虚拟机外部,并且全都继承自抽象类 java.lang.ClassLoader。如扩展类加载器和应用程序类加载器:
34、类与类加载器的关系?
35、谈谈你对双亲委派模型的理解?工作过程?为什么要使用?
应用程序一般是由上诉的三种类加载器相互配合进行加载的,如果有必要,还可以加入自己定义的类加载器,它们的关系如下图所示:
-
双亲委派模型的工作过程:
使用双亲委派模型的好处:
双亲委派模型的主要代码实现:
36、怎么实现一个自定义的类加载器?需要注意什么?
若要实现自定义类加载器,只需要继承 java.lang.ClassLoader 类,并且重写其 findClass() 方法即可。
1. 自己写一个类加载器;
2. 重写 loadClass() 方法
3. 重写 findClass() 方法
39、谈谈你对编译期优化和运行期优化的理解?
编译期优化:
1. 解析与填充符号表的过程
2. 插入式注解处理器的注解处理过程
3. 分析与字节码生成过程
编译优化:
1. 方法内联
2. 公共子表达式消除
3. 数组范围检查消除
4. 逃逸分析
解释器:程序可以迅速启动和执行,消耗内存小 (类似人工,成本低,到后期效率低);
编译器:随着代码频繁执行会将代码编译成本地机器码 (类似机器,成本高,到后期效率高)。
41、说下你对 Java 内存模型的理解?
-
主内存与工作内存
关于主内存与工作内存之间的具体的交互协议,即:一个变量如何从主内存拷贝到工作内存、如何从工作内存同步主内存之类的实现细节,Java内存模型中定义一下八种操作来完成:
如果要把一个变量从工作内存复制到工作内存,那就要按顺序执行 read 和 load 操作,如果要把变量从工作内存同步回主内存,就要按顺序执行 store 和 write 操作。
上诉 8 种基本操作必须满足的规则:
以上是关于Java后端面试高频问题:HashMap的底层原理的主要内容,如果未能解决你的问题,请参考以下文章