面试阿里字节问的《JVM面试题》整理,附解答【精】
📄23届校招实习内推
内推链接:https://talent.alibaba.com/campus/qrcode/home?code=I9hkqELKqqeEeOzMLOL1_ja4LkB6ShZoLpwNJq0_QrA%3D
内推亮点:可以及时跟进流程,解答大家的任何问题。
什么情况下会发生栈内存溢出。
- 栈是线程私有的,他的生命周期与线程相同,每个方法在执行的时候都会创建一个栈帧,用来存储局部变量表,操作数栈,动态链接,方法出口等信息。局部变量表又包含基本数据类型,对象引用类型
- 如果线程请求的栈深度大于虚拟机所允许的最大深度,将抛出StackOverflowError异常,方法递归调用产生这种结果。
- 如果Java虚拟机栈可以动态扩展,并且扩展的动作已经尝试过,但是无法申请到足够的内存去完成扩展,或者在新建立线程的时候没有足够的内存去创建对应的虚拟机栈,那么Java虚拟机将抛出一个OutOfMemory 异常。(线程启动过多)
- 参数 -Xss 去调整JVM栈的大小
详解JVM内存模型
程序计数器
程序计数器是一块较小的内存空间,可以看作是当前线程所执行的字节码的行号指示器。字节码解释器工作时通过改变这个计数器的值来选取下一条需要执行
Java 虚拟机栈
与程序计数器一样,Java 虚拟机栈也是线程私有的,它的生命周期和线程相同,描述的是 Java 方法执行的内存模型,每次方法调用的数据都是通过栈传递的。
本地方法栈
和虚拟机栈所发挥的作用非常相似,区别是: 虚拟机栈为虚拟机执行 Java 方法 (也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。 在 HotSpot 虚拟机中和 Java 虚拟机栈合二为一。
堆
Java 虚拟机所管理的内存中最大的一块,Java 堆是所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例以及数组都在这里分配内存。
Java 堆是垃圾收集器管理的主要区域,因此也被称作GC 堆(Garbage Collected Heap).从垃圾回收的角度,由于现在收集器基本都采用分代垃圾收集算法,所以 Java 堆还可以细分为:新生代和老年代:再细致一点有:Eden 空间、From Survivor、To Survivor 空间等。进一步划分的目的是更好地回收内存,或者更快地分配内存。
在 JDK 7 版本及JDK 7 版本之前,堆内存被通常被分为下面三部分:
- 新生代内存(Young Generation)
- 老生代(Old Generation)
- 永生代(Permanent Generation)
上图所示的 Eden 区、两个 Survivor 区都属于新生代(为了区分,这两个 Survivor 区域按照顺序被命名为 from 和 to),中间一层属于老年代。
大部分情况,对象都会首先在 Eden 区域分配,在一次新生代垃圾回收后,如果对象还存活,则会进入 s0 或者 s1,并且对象的年龄还会加 1(Eden 区->Survivor 区后对象的初始年龄变为 1),当它的年龄增加到一定程度(默认为 15 岁),就会被晋升到老年代中。对象晋升到老年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold
来设置。
堆这里最容易出现的就是 OutOfMemoryError 错误,并且出现这种错误之后的表现形式还会有几种,比如:
OutOfMemoryError: GC Overhead Limit Exceeded
: 当JVM花太多时间执行垃圾回收并且只能回收很少的堆空间时,就会发生此错误。java.lang.OutOfMemoryError: Java heap space
:假如在创建新的对象时, 堆内存中的空间不足以存放新创建的对象, 就会引发java.lang.OutOfMemoryError: Java heap space
错误。(和本机物理内存无关,和你配置的对内存大小有关!)
方法区
方法区与 Java 堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。虽然 Java 虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫做 Non-Heap(非堆),目的应该是与 Java 堆区分开来。
垃圾回收
https://juejin.cn/post/6844904148404535309
垃圾回收区域:
一般都发生在堆中
强引用:
new object,垃圾回收的时候绝对不会回收这类引用对象,即使抛出oom也不会回收
软引用:
垃圾回收的在内存中存在的时候不会回收他,内存不足的时候才会回收他
弱引用
垃圾回收器在扫描到该对象的时候,无论内存是否充足都会回收他
虚引用:
幽灵引用没啥作用
Minor GC
发生在新生代上,速度很快,触发得很快
出发条件:Eden区空间不足
Full GC
发生在老年代上得,新生代也会被回收
老年代空间不足
空间担保分配失败
调用system.gc
垃圾回收算法:
复制算法:优点,速度快,实现简单高校,缺点是可用内存缩小一半,但是通过调整 预留区域得比例可以提升空间得利用率,一般在新生代都用这种算法
标记清除算法:
标记需要回收得对象,然后清除。标记和清除得效率并不高,此外标记过程也会占用较高得cpu资源
标记整理
标记清除之后,再将未清除内存进行整理,避免内存碎片
垃圾回收器
新生代:
serialGC:单线程复制算法
parNew: 多线程复制算法
parllelScavenge: 多线程复制算法,更加注重cpu的吞吐率
老年代
SerialOld: 单线程标记整理
Parold: 多线程标记整理
cms: 并发标记清除
G1: 跨新生代+老年代,标记清除、标记整理
CMS标记清除:
一般新生代使用paeNew,老年代使用CMS -XX:+useConcMarkedSweepGC,
回收过程
初始标记: 仅仅是标记一下GcRoot能直接关联到的对象,速度 很快,会STW
并发标记: 从GC root开始对堆对象进行可达性分析,找到存活对象标记,整个回收过程耗时最长,不需要停顿
重新标记:这阶段是对并发标记阶段,因用户线程继续运行产生生那一部分对象,对这部分的对象进行再次标记
并发清除: 不需要停顿
优点:
由于CMS并发标记清除中最耗时的并发标记和并发清除两个阶段都不会停顿,所以CMS并发垃圾收集器和用户线程可以并行运行
缺点:
- CPU资源敏感: 标记清除会占用较大的CPU开销
- 容易产生内存碎片
- 由于CMS并发清除阶段用户线程并不会停止,所以伴随这程序的运行会不断产生新的垃圾,这类垃圾只能等到下次GC才能回收,把这类的垃圾称为浮动垃圾。
- 由于浮动垃圾的存在,因此需要预留出一部分内存,意味着 CMS 收集不能像其它收集器那样等待老年代快满的时候再回收。在 1.6 的版本中老年代空间使用率阈值(92%),如果预留的内存不够存放浮动垃圾,就会出现 Concurrent Mode Failure,这时虚拟机将临时启用 Serial Old 来替代 CMS。
G1 垃圾回收
- G1 采用的标记整理算法,不会产生内存碎片,分配大对象时,由于没有连续的内存空间出发一次fullgc
- G1 可以设置预期停顿时间 来控制垃圾回收收集的时间,G1只是尽量做到
- G1 通过建立可预测的停顿时间模型,跟踪各个region的垃圾堆积和回收价值(回收所需要的垃圾大小和回收的经验值),根据回收价值维护一个优先级列表,每次根据允许的回收时间,优先回收价值最高的region(G1-first的命名就是这么来的)。这种region划分及优先级排序,保证了在有效的时间内获得尽可能高的垃圾回收效率
1、初始标记:仅仅只是标记一下 GC Roots 能直接关联到的对象,并且修改 TAMS(Nest Top Mark Start)的值,让下一阶段用户程序并发运行时,能在正确可以的 Region 中创建对象,此阶段需要停顿线程(STW),但耗时很短
2、并发标记:从 GC Root 开始对堆中对象进行可达性分析,找到存活对象,此阶段耗时较长,但可与用户程序并发执行
3、最终标记:为了修正在并发标记期间因用户程序继续运作而导致标记产生变动的那一部分标记记录,虚拟机将这段时间对象变化记录在线程的 Remembered Set Logs 里面,最终标记阶段需要把 Remembered Set Logs 的数据合并到 Remembered Set 中。这阶段需要停顿线程(STW),但是可并行执行
4、筛选回收:首先对各个 Region 中的回收价值和成本进行排序,根据用户所期望的 GC 停顿时间来制定回收计划。此阶段其实也可以做到与用户程序一起并发执行,但是因为只回收一部分 Region,时间是用户可控制的,而且停顿用户线程将大幅度提高收集效率
类的加载, new
加载: 类的加载就是把class字节码文件从各个来源通过类加载把他们 加载到内存中
验证: 是否符合jvm规范
准备: 分配内存,赋初始值
解析: 虚拟机会把所有的符号引用替换为直接引用
初始化: 对类变量的初始化,执行类构造的过程
只有第一个加载过程是可以控制的,其他的是不可以控制的
classloader双亲委托机制
双亲委派机制解决的是多个类加载器之间具有父子 关系,某个class文件到底由哪个加载器加载进内存的问题。具体的表现为一个类需要加载的时候,它会委托给自己的父类,父类又会委托父类,所以所有的类都是通过顶层类加载器 Bootstrap classLoader加载,当父类加载器不能完成这个类加载请求,在自己类的加载的搜索范围内找不到这个类的时候就会抛出class not found exception 异常,又会委托给自己的子类 加载。
为什么使用: 使得类加载器具备层次关系,可以避免类的重复加载,确保了类的一致性。也避免了一些核心类被多个类加载 器加载造成冲突。
如何打败双亲委派机制: 需要继承classLoder类,并且重写findclass 和loadClass方法
说说你知道的几种主要的JVM参数
xxs: 设置虚拟栈的内存大小
Xmn2g: 设置年轻代大小2g
-XX:new Ratio: 设置老年代和新生代的比值
-XX SurvivorRatio 设置eden区和survivor的比值
-useConcMaredSweep: 使用CMS垃圾收集器
-useParalleGCThread: 使用并发的垃圾回收线程数
XX:CMSFullGCsBeforeCompaction: cms垃圾收集器不对内存做压缩整理,导致会产生内存碎片
UseG1GC : 开启G1
怎么打出线程栈信息
jps 获得进程号
top -pH pid 获得本进程的线程cpu资源消耗排行
jstack pid 查看当前java进程的堆栈状况
JVM内存为什么要分成新生代,老年代,持久代。新生代中为什么要分为Eden和Survivor。
https://juejin.cn/post/6844904148404535309
new一个对象的过程
https://juejin.cn/post/6844903956754366472
java在new一个对象的时候,会先查看对象所属的类是否被加载进内存,如果没有就通过类的全限定名把类加载进内存,类加载和类初始化完成之后再完成了类的创建
new出来的对象是放在哪里(堆里),是一创建出来就进入堆中的吗
不一定,如果这个对象比较大,要看是否有足够的内存空间能容纳下这个类,如果不够的话就要先进行垃圾回收,再分配空间。
当能容纳这个对象的时候,还要根据垃圾回收是否带整理的功能来确定使用(空闲列表还是指针碰撞)
创建出来的对象一定是放在堆中吗?
https://juejin.cn/post/6985190259759775775
JIT(即时编译器),java会通过解释器进行解释执行(将符号引用变成直接引用),但是如果某个方法或者某个代码端断执行得比较频繁的时候,即时编译器会会这段代码翻译成本地机器指令,并进行优化,缓存 起来,下次就会直接运行不用解释,提升运行得效率。
逃逸分析: 在方法内定义一个对象,分析这个对象的作用域,是否有可能被作用域以外引用,如果有就认为是逃逸的。
JIT优化:
栈上分配:
如果分析出一个对象不会发生逃逸,就有可能分配到栈上,这样就不需要再进行GC回收了提高了性能。
标量替换:
- 标量:不可再分解成更小数据的类型,例如基本数据类型就是标量。
public void scalarReplace() { Coordinates coordinates = new Coordinates(105.10, 80.22); System.out.println(coordinates.longitude); System.out.println(coordinates.latitude); }
public void scalarReplace() { System.out.println(105.10); System.out.println(80.22); }
锁消除
如果分析出加锁的对象不会发生逃逸,即只能被一个线程访问,JIT 是可以优化消除这个锁的。也称为同步省略。
public void lockRemove() { synchronized (new Object()) { System.out.println("我是陈皮!"); } }
public void lockRemove() { System.out.println("我是陈皮!"); }
TLAB知道吗?
https://juejin.cn/post/6844904205371572237
TLAB的全称是Thread-Local Allocation Buffers。Thread-Local大家都知道吧,就是线程的本地变量。而TLAB则是线程的本地分配空间。
逃逸分析和栈上分配只是争对于单线程环境来说的,如果在多线程环境中,不可避免的会有多个线程同时在堆空间中分配对象的情况。
锁和同步是为了保证整个资源一次只能被一个线程访问,我们现在的情况是要在资源中为线程划分一定的区域。这种操作并不需要完全的同步,因为heap空间够大,我们可以在这个空间中划分出一块一块的小区域,为每个线程都分一块。这样不就解决了同步的问题了吗?这也可以称作空间换时间。
一般 放在eden区,因为是刚创建的嘛
TLAB中大对象的分配
如果一个线程中定义了一个非常大的对象,TLAB放不下了,该怎么办呢?
第一种可能性:
目前TLAB被使用了20K,还剩80K的大小,这时候我们创建了一个90K大小的对象,现在90K大小的对象放不进去TLAB,这时候需要直接在heap空间去分配这个对象,这种操作实际上是一种退化操作,官方叫做 slow allocation。
第二中个可能性:
目前TLAB被使用了90K,还剩10K大小,这时候我们创建了一个15K大小的对象。
因为TLAB差不多已经用完了,为了保证后面new出来的对象仍然可以有一个TLAB可用,这时候JVM可以尝试将现在的TLAB Retire掉,然后分配一个新的TLAB空间,把15K的对象放进去。
#阿里巴巴##实习##内推##校招#