-
【Java面试八股文】JVM
1. 讲一下JVM内存模型(运行时数据区)
JVM内存模型分为两部分:线程共享和线程私有
JDK1.8之后方法区被元空间Metaspace替代。
-
程序计数器PC:代码流程的控制和多线程上下文切换恢复现场
-
虚拟机栈:也就是我们常说的栈内存。Java中线程执行代码其实都是在执行一个个方法,每执行一个方法,该线程的虚拟 栈空间就会被压入一个栈帧,因此虚拟机栈是由一个个栈帧组成的。每个栈帧中都拥有该方法执行过程中产生的局部变量表、操作数栈、动态链接以及方法出口信息。每一个方法被调用直至执行完毕的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。
栈内存随线程的创建而创建,死亡而死亡
-
本地方法栈:虚拟机栈提供Java方法服务,本地方法提供Native方法服务,也是由一个个栈帧组成。
-
堆:虚拟机所管理的内存中最大的一块,所有线程共享,存放几乎所有的对象实例(new出来的东西都放在,但是引用是局部变量,放在栈内存)和数组。
Java世界中“几乎”所有的对象都在堆中分配,但是,随着JIT编译期的发展与逃逸分析技术逐渐成熟,栈上分配、标量替换优化技术将会导致一些微妙的变化,所有的对象都分配到堆上也渐渐变得不那么“绝对”了。从jdk 1.7开始已经默认开启逃逸分析,如果某些方法中的对象引用没有被返回或者未被外面使用(也就是未逃逸出去),那么对象可以直接在栈上分配内存
堆也叫做GC堆(Garbage Collected Heap)
-
方法区:又叫非堆,用于存储类信息,常量、静态变量以及即时编译器编译后的代码缓存等数据。运行时常量池是方法区的一部分,保存的信息有类的版本、字段、方法、接口等描述信息以及常量池表(Final,String等)。
2. 对象创建的过程
-
类加载检查:虚拟机遇到一条 new 指令时,首先将去检查这个指令的参数是否能在常量池中定位到这个类的符号引用,并且检查这个符号引用代表的类是否已被加载过、解析和初始化过。如果没有,那必须先执行相应的类加载过程。
-
为新生对象分配内存
对象所需要的内存大小在类加载完成后便可确定,分配内存即把堆中的一块等同大小的内存划分出来给对象。但是因为Java堆中空闲内存和已被分配的内存有两种不同的情况:
- 规整:已分配的内存在一边,空闲的内存在一边,中间放着一个指针作为指示器。分配内存的时候仅仅需要指针向空闲方向移动对象大小相同的距离。这种分配方式叫做“指针碰撞”
- 不规整:已分配的内存和空闲的内存相互交错在一起。这种情况下虚拟机必须维护一个列表来记录哪些内存是可用的,分配的时候从列表中找出一块足够大的内存分配给对象。这种分配方式叫做“空闲列表”。
堆是否规整取决于垃圾回收器是否带有空间压缩整理的能力。当使用Serial、ParNew等带压缩整理过程的收集器时,系统采用的分配算法是指针碰撞,既简单又高效;而当使用CMS这种基于清除算法的收集器时,理论上就只能采用较为复杂的空闲列表来分配内存。
分配内存如何解决线程安全的问题?
对象创建在虚拟机中是非常频繁的行为,即使仅仅修改一个指针所指向的位置,在并发情况下也并不是线程安全的,可能出现正在给对象A分配内存,指针还没来得及修改,对象B又同时使用了原来的指针来分配内存的情况。
- CAS乐观锁+失败重试,保证更新操作的原子性
- TLAB(本地线程分配缓冲):为每一个线程预先在 Eden 区分配一块儿内存,JVM 在给线程中的对象分配内存时,首先在 TLAB 分配,当对象大于 TLAB 中的剩余内存或 TLAB 的内存已用尽时,再采用上述的 CAS 进行内存分配。虚拟机是否使用TLAB,可以通过-XX:+/-UseTLAB参数来设定。
-
初始化零值
虚拟机必须将分配到的内存空间(但不包括对象头)都初始化为零值,如果使用了TLAB的话,这一项工作也可以提前至TLAB分配时顺便进行。这步操作保证了对象的实例字段在Java代码中可以不赋初始值就直接使用,使程序能访问到这些字段的数据类型所对应的零值
-
设置对象头
虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的 GC 分代年龄等信息。这些信息存放在对象头中。另外,根据虚拟机当前运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式。
-
执行init方法(有点类似于依赖注入,由程序员控制注入什么)
在上面工作都完成之后,从虚拟机的视⻆来看,一个新的对象已经产生了,但从 Java 程序的视⻆来看,对象创建才刚开始,
方法还没有执行,所有的字段都还为零。所以一般来说,执行 new 指令之后会接着执行 方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全产生出来。
3. 对象的内存布局
-
对象头
- 运行时数据:hashcode、GC分代年龄、锁状态、持有的锁
- 类型指针:即对象指向它的类型元数据的指针,Java虚拟机通过这个指针来确定该对象是哪个类的实例
-
实例数据
字段内存、继承下来的字段内存
-
对齐填充
4. 栈内存中的reference如何访问堆内存中的变量
- 句柄访问
- 直接指针
垃圾回收算法
5. JVM垃圾回收概述
哪些内存要进行垃圾回收
线程私有的内存空间是不需要进行垃圾回收的,因为当方法结束或者线程终止,内存自然会跟随着回收。垃圾回收的主战场是堆,主目标就是堆中分配的对象。这部分的内存分配和回收是动态的。
什么对象需要被回收?
死亡的对象需要被回收,判定对象是否存活都和“引用”离不开关系。也就是说,没有被引用,没有指针指向的对象将被回收。
Java中的引用详解
以前我对引用的认识是:如果reference类型的数据中存储的数值代表的是另外一块内存的起始地址,就称该reference数据是代表某块内存、某个对象的引用。这种定义没有错,但是不足以应付我们复杂的业务逻辑。比如我们希望能描述一类对象:当内存空间还足够时,能保留在内存之中,如果内存空间在进行垃圾收集后仍然非常紧张,那就可以抛弃这些对象。这个时候就引入扩充引用这个概念了。
JDK1.2之后,Java对引用进行了扩充,将引用分为:(强度注解降低)
- 强引用
- 软引用
- 弱引用
- 虚引用
强引用:即类似“Objectobj=newObject()”这种引用关系。无论任何情况下,只要强引用关系还存在,垃圾收集器就永远不会回收掉被引用的对象。(肯定不回收)
软引用:用来描述一些还有用,但非必须的对象。只被软引用关联着的对象,在系统将要发生内存溢出异常前,会把这些对象列进回收范围之中进行第二次回收。(满了就先回收你)
弱引用:也是描述一些还有用,但非必须的对象,但强度更低,只能生存到下一次垃圾收集为止。(不用等满,下一次就回收你)
虚引用:相当于没有引用,完全不会对其生存时间构成影响。唯一目的只是为了能在这个对象被收集器回收时收到一个系统通知。
JVM如何判断对象已死(没有引用)
-
引用计数法
在对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加一;当引用失效时,计数器值就减一;任何时刻计数器为零的对象就是不可能再被使用的。
缺点:无法解决相互循环引用的问题
-
可达性分析算法
通过一系列称为“GCRoots”的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为“引用链”(ReferenceChain),如果某个对象到GCRoots间没有任何引用链相连则证明此对象是不可能再被使用的。简单来说,GC Root 就是经过精心挑选的一组活跃引用,这些引用是肯定存活的。那么通过这些引用延伸到的对象,自然也是存活的。
引用,GC Root是引用的集合。这个引用集合由以下组成:
- 当前所有正在被调用的方法的引用类型的参数/局部变量/临时值
- JVM的一些静态数据结构里指向GC堆里的对象的引用,例如说HotSpot VM里的Universe里有很多这样的引用。
6. 方法区的垃圾回收
方法区的垃圾收集主要回收两部分内容:废弃的常量和不再使用的类信息。
如何判断一个常量为废弃常量
假如在常量池中存在字符串 "abc",如果当前没有任何String对象引用该字符串常量的话,就说明常量 "abc" 就是废弃常量,如果这时发生内存回收的话而且有必要的话,"abc" 就会被系统清理出常量池。
如何判断一个类为无用的类
- 该类所有的实例都已经被回收,也就是 Java 堆中不存在该类的任何实例。
- 加载该类的ClassLoader已经被回收。
- 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
虚拟机可以对满足上述 3 个条件的无用类进行回收,这里说的仅仅是“可以”,而并不是和对象一样不使用了就会必然被回收。
7. 垃圾回收算法
三个假说以及堆为什么要分代
根据大多数程序运行实际情况的经验准则,我们发现堆中的对象有以下特点:
- 绝大多数对象撑不过第一轮垃圾回收
- 越是熬过多次垃圾回收过程的对象越是难以消亡
- 跨代引用相对于同代引用来说仅占极少数,也就是说同一个方法中引用的对象一般都是同代的
根据这三条假说,收集器应该将Java堆划分出不同的区域,然后将回收对象依据其年龄(年龄即对象熬过垃圾收集过程的次数)分配到不同的区域之中存储。不同代的堆采用不同的回收算法获得最大效率,以此来提高垃圾回收的效率。
在新生代中,每次回收时只关注如何保留少量存活而不是去标记那些大量将要被回收的对象,就能以较低代价回收到大量的空间。简单来说就是新生代都死得很快,我们只需要关注那些没死的。
在老生代中,虚拟机便可以使用较低的频率来回收这个区域,这就同时兼顾了垃圾收集的时间开销和内存的空间有效利用。简单来说就是老生代就死不了,因此很久才回收一次。
这就是堆为什么要分代的原因:选择最合适的GC算法。
标记-清除算法
算法分为“标记”和“清除”两个阶段:首先标记出所有不需要回收的对象,在标记完成后,统一回收掉未被标记的对象。也可以反过来。标记过程就是对象是否属于垃圾的判定过程。
最基础的收集算法,后续收集算法都是基于其改进的。
缺点:
- 执行效率不稳定;
- 内存空间的碎片化问题,标记、清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致当以后在程序运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作
标记-复制算法
将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把所有已使用过的内存空间一次清理掉。
这个算法基于假说1:绝大部分的对象都撑不过第一轮垃圾收集。因此复制只是少量操作,回收就完事。
缺点:
- 对象存活率高时效率较低
- 将可用内存缩小为了原来的一半,空间浪费未免太多了一点。
标记-整理算法
标记-整理算法就是一种“移动式”的标记-清除算法,先把存活的对象移动到内存的一侧,再清空端边界以外的内存。
其实这个算法也有缺点,对于老生代区域来说,对象存活率较高,因此移动的代码也很高。但是不移动就会造成内存碎片的问题。不难两全其美。
还有一种“和稀泥”的方式,先用标记-清除算法回收垃圾,等内存碎片真的很多的时候再使用标记-整理算法处理内存碎片的问题。
分代收集算法
当前虚拟机的垃圾收集都采用分代收集算法,这种算法没有什么新的思想,只是根据对象存活周期的不同将内存分为几块。一般将 Java 堆分为新生代和老年代,这样我们就可以根据各个年代的特点选择合适的垃圾收集算法。这些我们上面都说过了。
比如在新生代中,每次收集都会有大量对象死去,所以可以选择复制算法,只需要付出少量对象的复制成本就可以完成每次垃圾收集。而老年代的对象存活几率是比较高的,而且没有额外的空间对它进行分配担保,所以我们必须选择“标记-清除”或“标记-整理”算法进行垃圾收集。
8. 常见的垃圾回收器
垃圾回收器一般分为四大类:
-
串行-Serial-单个垃圾回收线程
-
并行-Parallel-多个垃圾回收线程
-
并发-CMS
-
G1(JDK8后)
-
ZGC(JDK11后才有)
JVM中并行和并发的概念
- 并行:并行描述的是多条垃圾收集器线程之间的关系,说明同一时间有多条这样的线程在协同工作,通常默认此时用户线程是处于等待状态。
- 并发:并发描述的是垃圾收集器线程与用户线程之间的关系,说明同一时间垃圾收集器线程与用户线程都在运行。
HotSpot实现了很多垃圾回收器,可以自行搭配使用,一般为新生代选一个回收器,老生代选一个回收器。连线表示这两个回收器可以适配。
如何查看默认垃圾回收器
java -XX:+PrintCommandLineFlags -version
默认使用Parallel Scavnge + Parallel Old
Minor GC、Major CG和Full GC
- Minor GC:针对整个新生代
- Major GC:针对整个老年代
- Full GC:针对堆
Serial收集器
- 单线程串行
- 新生代-标记复制算法
Serial Old
- 单线程串行
- 老年代-标记整理算法
ParNew(Parallel New)
- 多线程并行
- 新生代-标记复制算法
Parallel Scavenge
-
多线程并行
-
相比ParNew提供了参数设置和自适应调节策略以提高吞吐量
-
新生代-标记复制算法
Parallel Old
- 多线程并行
- 老年代-标记整理
- 同样提供了参数设置和自适应调节策略
CMS
-
多线程并发(第一款并发老年代收集器)
-
老年代-改进的标记-清除算法
CMS的垃圾回收算法(基于标记-清除,标记可达的对象,清除所有不可达的对象):
-
初始标记(Stop the world)
-