【面试】JVM
1、JDK、JRE和JVM
JDK = JRE + 开发工具
JRE = JVM + 类库
Java程序的开发过程为:
- 我们利用 JDK (调用 Java API)编写出 Java 源代码,存储于 .java 文件中
- JDK 中的编译器 javac 将 Java 源代码编译成 Java 字节码,存储于 .class 文件中
- JRE 加载、验证、执行 Java 字节码
- JVM 将字节码解析为机器码并映射到 CPU 指令集或 OS 的系统调用。
2、JVM组成
JVM包含两个子系统和两个组件
两个子系统:
- Class loader(类装载)
- Execution engine(执行引擎)
两个组件:
- Runtime data area(运行时数据区)
- Native Interface(本地接口)
3、Java程序运行机制步骤
- 首先利用IDE集成开发工具编写Java源代码,源文件的后缀为.java;
- 再利用编译器(javac命令)将源代码编译成字节码文件,字节码文件的后缀名为.class;
- 运行字节码的工作是由解释器(java命令)来完成的。
4、Java内存区域和内存模型(JMM)
JMM与Java内存区域的划分是不同的概念层次,更恰当说JMM描述的是一组规则,通过这组规则控制程序中各个变量在共享数据区域和私有数据区域的访问方式,JMM是围绕原子性,有序性、可见性展开的。
java内存区域是指 Jvm 运行时将数据分区域存储,强调对内存空间的划分。
1)java运行时数据区域
2)java内存模型(JMM)
这里所讲的主内存、工作内存与 Java 内存区域中的 Java 堆、栈、方法区等并不是同一个层次的内存划分,这两者基本上是没有关系的,如果两者一定要勉强对应起来,那如下所示:
- 主内存==》方法区、堆
- 工作内存==》虚拟机栈、本地方法栈、程序计数器
参考:Java内存区域(运行时数据区域)和内存模型(JMM)
5、java内存区域详细说明
下图是 JDK8 之后的 JVM 内存布局
1)本地方法栈
作用:与虚拟机栈所发挥的作用非常相似,为虚拟机使用到的 Native 方法服务。
线程开始调用本地方法时,会进入 个不再受 JVM 约束的世界。本地方法可以通过 JNI(Java Native Interface)来访问虚拟机运行时的数据区,甚至可以调用寄存器,具有和 JVM 相同的能力和权限。 当大量本地方法出现时,势必会削弱 JVM 对系统的控制力,因为它的出错信息都比较黑盒。对内存不足的情况,本地方法栈还是会抛出 nativeheapOutOfMemory。
JNI 类本地方法最著名的应该是 System.currentTimeMillis()
,JNI使 Java 深度使用操作系统的特性功能,复用非 Java 代码。 但是在项目过程中, 如果大量使用其他语言来实现 JNI , 就会丧失跨平台特性。
异常:会抛出 StackOverflowError 和 OutOfMemoryError 异常。
2)程序计数器
作用:记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是 Native 方法,这个计数器值则为空(Undefined)。此内存区域是唯一一个在 Java 虚拟机规范中没有规定任何 OutOfMemoryError 情况的区域。
异常:无
3)java虚拟机栈
作用:是 Java 方法执行的内存模型,每个方法在执行的同时都会创建一个栈帧(Stack Frame,是方法运行时的基础数据结构)用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。
在活动线程中,只有位于栈顶的帧才是有效的,称为当前栈帧。正在执行的方法称为当前方法,栈帧是方法运行的基本结构。在执行引擎运行时,所有指令都只能针对当前栈帧进行操作。
异常:会抛出 StackOverflowError 和 OutOfMemoryError 异常。
1. 局部变量表 局部变量表是存放方法参数和局部变量的区域。 局部变量没有准备阶段, 必须显式初始化。如果是非静态方法,则在 index[0] 位置上存储的是方法所属对象的实例引用,一个引用变量占 4 个字节,随后存储的是参数和局部变量。字节码指令中的 STORE 指令就是将操作栈中计算完成的局部变呈写回局部变量表的存储空间内。 虚拟机栈规定了两种异常状况:如果线程请求的栈深度大于虚拟机所允许的深度,将抛出 StackOverflowError 异常;如果虚拟机栈可以动态扩展(当前大部分的 Java 虚拟机都可动态扩展),如果扩展时无法申请到足够的内存,就会抛出 OutOfMemoryError 异常。 2. 操作栈 操作栈是个初始状态为空的桶式结构栈。在方法执行过程中, 会有各种指令往 栈中写入和提取信息。JVM 的执行引擎是基于栈的执行引擎, 其中的栈指的就是操 作栈。字节码指令集的定义都是基于栈类型的,栈的深度在方法元信息的 stack 属性中。 i++ 和 ++i 的区别: i++:从局部变量表取出 i 并压入操作栈(load memory),然后对局部变量表中的 i 自增 1(add&store memory),将操作栈栈顶值取出使用,如此线程从操作栈读到的是自增之前的值。 ++i:先对局部变量表的 i 自增 1(load memory&add&store memory),然后取出并压入操作栈(load memory),再将操作栈栈顶值取出使用,线程从操作栈读到的是自增之后的值。 之前之所以说 i++ 不是原子操作,即使使用 volatile 修饰也不是线程安全,就是因为,可能 i 被从局部变量表(内存)取出,压入操作栈(寄存器),操作栈中自增,使用栈顶值更新局部变量表(寄存器更新写入内存),其中分为 3 步,volatile 保证可见性,保证每次从局部变量表读取的都是最新的值,但可能这 3 步可能被另一个线程的 3 步打断,产生数据互相覆盖问题,从而导致 i 的值比预期的小。 3. 动态链接 每个栈帧中包含一个在常量池中对当前方法的引用, 目的是支持方法调用过程的动态连接。 4.方法返回地址 方法执行时有两种退出情况: 正常退出,即正常执行到任何方法的返回字节码指令,如 RETURN、IRETURN、ARETURN 等; 异常退出。 无论何种退出情况,都将返回至方法当前被调用的位置。方法退出的过程相当于弹出当前栈帧,退出可能有三种方式: 返回值压入上层调用栈帧。 异常信息抛给能够处理的栈帧。 PC计数器指向方法调用后的下一条指令。
4)java堆
作用:Java 虚拟机所管理的内存中最大的一块。Java 堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例都在这里分配内存。
划分:①从内存回收的角度来看,由于现在收集器基本都采用分代收集算法,所以 Java 堆中还可以细分为:新生代和老年代;再细致一点的有 Eden 空间、From Survivor 空间、To Survivor 空间等。②从内存分配的角度来看,线程共享的 Java 堆中可能划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer,TLAB)。
参数: -Xmx 和 -Xms
异常:如果在堆中没有内存完成实例分配,并且堆也无法再扩展时,将会抛出 OutOfMemoryError 异常。
5)方法区
作用:用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。
回收:垃圾收集行为在这个区域是比较少出现的,其内存回收目标主要是针对常量池的回收和对类型的卸载。
异常:当方法区无法满足内存分配需求时,将抛出 OutOfMemoryError 异常。
JDK8 之前,Hotspot 中方法区的实现是永久代(Perm),JDK8 开始使用元空间(Metaspace),以前永久代所有内容的字符串常量移至堆内存,其他内容移至元空间,元空间直接在本地内存分配。
问:为什么要使用元空间取代永久代的实现?
①字符串存在永久代中,容易出现性能问题和内存溢出。
②类及方法的信息等比较难确定其大小,因此对于永久代的大小指定比较困难,太小容易出现永久代溢出,太大则容易导致老年代溢出。
③永久代会为 GC 带来不必要的复杂度,并且回收效率偏低。
④将 HotSpot 与 JRockit 合二为一。
6)直接内存
直接内存(Direct Memory)并不是虚拟机运行时数据区的一部分,也不是 Java 虚拟机规范中定义的内存区域。
在 JDK 1.4 中新加入了 NIO,引入了一种基于通道(Channel)与缓冲区(Buffer)的 I/O 方式,它可以使用 Native 函数库直接分配堆外内存,然后通过一个存储在 Java 堆中的 DirectByteBuffer 对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在 Java 堆和 Native 堆中来回复制数据。
显然,本机直接内存的分配不会受到 Java 堆大小的限制,但是,既然是内存,肯定还是会受到本机总内存(包括 RAM 以及 SWAP 区或者分页文件)大小以及处理器寻址空间的限制。服务器管理员在配置虚拟机参数时,会根据实际内存设置 -Xmx 等参数信息,但经常忽略直接内存,使得各个内存区域总和大于物理内存限制(包括物理的和操作系统级的限制),从而导致动态扩展时出现 OutOfMemoryError 异常。
6、类的加载过程
1)加载
在加载阶段,虚拟机需要完成以下3件事情:
- 通过一个类的全限定名来获取定义此类的二进制字节流
- 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
- 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。
加载.class文件的方式
①从本地系统中直接加载
②通过网络下载.class文件
③从zip,jar等归档文件中加载.class文件
④从专有数据库中提取.class文件
⑤将Java源文件动态编译为.class文件
2)连接第一步:验证
确保被加载的类的正确性
- 文件格式验证:验证字节流是否符合Class文件格式的规范,如:是否以模数0xCAFEBABE开头、主次版本号是否在当前虚拟机处理范围内等等。
- 元数据验证:对字节码描述的信息进行语义分析,以保证其描述的信息符合Java语言规范的要求;如:这个类是否有父类,是否实现了父类的抽象方法,是否重写了父类的final方法,是否继承了被final修饰的类等等。
- 字节码验证:通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的,如:操作数栈的数据类型与指令代码序列能配合工作,保证方法中的类型转换有效等等。
- 符号引用验证:确保解析动作能正确执行;如:通过符合引用能找到对应的类和方法,符号引用中类、属性、方法的访问性是否能被当前类访问等等。
验证阶段是非常重要的,但不是必须的。可以采用-Xverify:none参数来关闭大部分的类验证措施。
3)连接第二步:准备
为类变量分配内存并设置类变量初始值,这些内存都将在方法区中分配。对于该阶段有以下几点需要注意:
- 只对static修饰的静态变量进行内存分配、赋默认值(如0、0L、null、false等)。如static int a = 100;静态变量a就会在准备阶段被赋默认值0。
- 对final的静态字面值常量直接赋初值(赋初值不是赋默认值,如果不是字面值静态常量,那么会和静态变量一样赋默认值)。如static final int a = 666; 静态常量a就会在准备阶段被直接赋值为666
4)连接第三步:解析
解析阶段JVM将常量池中的符号引用替换为直接引用。准备阶段只是分配了内存,但是类变量并没有指向那一块内存,这一步就是完成实际指向的工作。
5)初始化
初始化阶段为类变量设置正确的初始值。
在Java中对类变量进行初始值设定有两种方式:
(1)声明类变量时指定初始值;
(2)使用静态代码块为类变量指定初始值。
JVM初始化步骤:
(1)假如这个类还没有被加载和连接,则程序先加载并连接该类;
(2)假如该类的直接父类还没有被初始化,则先初始化其直接父类;
(3)假如类中有初始化语句,则系统依次执行这些初始化语句。
类初始化时机:只有当对类主动使用的时候才会导致类的初始化,类的主动使用包括以下6种:
- 创建类的实例,也就是new的方式;
- 访问某个类或接口的静态变量,或者对该静态变量赋值;
- 调用类的静态方法;
- 反射(如Class.forName("…"));
- 初始化某个类的子类,则其父类也会被初始化;
- Java虚拟机启动时被标明为启动类的类,直接使用java.exe命令来运行某个主类。
- 通过子类引用父类的静态字段,只会触发父类的初始化,而不会触发子类的初始化。
- 定义对象数组和集合,不会触发该类的初始化
- 类A引用类B的static final常量不会导致类B初始化(注意静态常量必须是字面值常量,否则还是会触发B的初始化)
- 通过类名获取Class对象,不会触发类的初始化。如System.out.println(Person.class);
- 通过Class.forName加载指定类时,如果指定参数initialize为false时,也不会触发类初始化。
- 通过ClassLoader默认的loadClass方法,也不会触发初始化动作
注意:被动引用不会导致类初始化,但不代表类不会经历加载、验证、准备阶段。
参考:https://blog.csdn.net/zhaocuit/article/details/93038538
7、三种类加载器
- 启动类加载器(Bootstrap ClassLoader)
- 扩展类加载器(Extension ClassLoader)
- 应用程序类加载器(Application ClassLoader)
1)启动类加载器:用来加载Java的核心库,主要加载的是JVM自身所需要的类,使用C++实现,并非继承于java.lang.ClassLoader,是JVM的一部分。负责加载JAVA_HOME\lib目录中的,或者-Xbootclasspath参数指定的路径中的,且被虚拟机认可[注1]的类。开发者无法直接获取到其引用。
注1:JVM是按文件名识别的,如rt.jar,如果文件名不被虚拟机识别,即使把jar包放在lib目录下也没有作用,同时启动加载器只加载包名为java,javax,sun等开头的类。且java是特殊包名,开发者的包名不能以java开头,如果自定义了一个java.***包来让类加载器加载,那么就会抛出异常java.lang.SecurityException: Prohibited package name: java.***
2)扩展类加载器: 用来加载Java的扩展库。负责加载JAVA_HOME\lib\ext目录中的,或通过系统变量java.ext.dirs指定路径中的类库。由java语言实现。开发者可以直接使用。
3)应用程序类加载器:负责加载用户路径(classpath)上的类库。开发者可以直接使用。可以通过ClassLoader.getSystemClassLoader()获得。一般情况下程序的默认类加载器就是该加载器。
4)除了提供的加载器外,开发者可以通过继承ClassLoader类的方式实现自己的类加载器。
8、双亲委派机制
解释:当某个类加载器需要加载某个.class
文件时,它首先把这个任务委托给他的上级类加载器,递归这个操作,如果上级的类加载器没有加载,自己才会去加载这个类。
作用:
- 防止重复加载同一个
.class
。通过委托去向上面问一问,加载过了,就不用再加载一遍。保证数据安全。 - 保证核心
.class
不能被篡改。通过委托方式,不会去篡改核心.clas
,即使篡改也不会去加载,即使加载也不会是同一个.class
对象了。不同的加载器加载同一个.class
也不是同一个Class
对象。这样保证了Class
执行安全。
9、对象创建过程
参考:https://zhuanlan.zhihu.com/p/142614439
10、对象的访问定位
取决于虚拟机的实现而决定,目前主流的访问方式有两种:
- 使用句柄池间接访问实例数据
- 指针直接访问实例数据
1)句柄访问
解释:JVM会在堆中划分一块内存来作为句柄池,JVM栈中的栈帧中的本地变量表中所存储的引用地址是这个对象所对应的句柄地址,而非对象本身的地址。句柄池中的一个个对象地址有两部分组成,一部分就是对象数据在堆内存中实例池中的地址,另一部分就是对象类型在方法区中的地址。
好处:访问对象通过一个句柄指针一次间接索引之后,当对象实例数据被移动的时候(垃圾回收的时候有些对象会被移动),只需要改变句柄池中该对象实例的地址即可,无需改变引用和句柄池的对应关系,所以引用中存储的是稳定的句柄地址。
2)直接指针
解释:通过直接指针访问的方式中,reference中存储的就是对象在堆中的实际地址,在堆中存储的对象信息中包含了在方法区中的相应类型数据。
好处:这种方式最大的好处就是访问对象的速度很快,比通过句柄访问对象节约了一半的寻址时间,由于Java中对象的访问非常频繁,所以这种方式能节约很多寻址时间。
11、java中的引用类型
- 强引用:发生 gc 的时候不会被回收。
- 软引用:有用但不是必须的对象,在发生内存溢出之前会被回收。
- 弱引用:有用但不是必须的对象,在下一次GC时会被回收。
- 虚引用(幽灵引用/幻影引用):无法通过虚引用获得对象,用 PhantomReference 实现虚引用,虚引用的用途是在 gc 时返回一个通知。
12、内存分配策略
三大原则和担保机制:
- 优先分配到eden区
- 大对象,直接进入到老年代
- 长期存活的对象分配到老年代
- 空间分配担保
1)新生代和老年代
①.新生代主要是刚出生的对象,比如你代码中经常用的new ,以及Method.invoke()等操作,这些操作的对象都是在Eden去先分配出内存,然后等待gc的回收,那么在这个区域里面我也提交,jvm主要做的回收就是MinorGC,jvm默认的是假如一个对象在新生代的年龄到达15岁之后,将其晋升到老年代存储。意思就是这个对象要在新生代中经历15次MinorGC之后不被回收,那么将进入老年代。当然不排除你自己的设定,可以利用参数配置使大对象在Eden出生后直接进入老年代。
大对象直接进入老年代:-XX:PretenureSizeThreshold=n(n代表你要限制的对象的字节数BIT)
②老年代主要存储的都是一些老的油条对象,在此内存区域,是不可能采用标记复制算法的,因为那样会减少一半的空间存储量,降低程序的效率。
2.新生代中又划分出了三个区域
①Eden主要接受刚新生的对象
②Survivor0
③Survivor1
这两个内存区域主要是用于gc做垃圾回收算法时用到的,也就是MinorGC发生的主要内存区域。
参考:https://www.jianshu.com/p/846c49ed9f24
13、JVM有哪些垃圾回收器?
如果说垃圾收集算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现。下图展示了7种作用于不同分代的收集器,其中用于回收新生代的收集器包括Serial、PraNew、Parallel Scavenge,回收老年代的收集器包括Serial Old、Parallel Old、CMS,还有用于回收整个Java堆的G1收集器。不同收集器之间的连线表示它们可以搭配使用。
- Serial收集器(复制算法): 新生代单线程收集器,标记和清理都是单线程,优点是简单高效;
- ParNew收集器 (复制算法): 新生代收并行集器,实际上是Serial收集器的多线程版本,在多核CPU环境下有着比Serial更好的表现;
- Parallel Scavenge收集器 (复制算法): 新生代并行收集器,追求高吞吐量,高效利用 CPU。吞吐量 = 用户线程时间/(用户线程时间+GC线程时间),高吞吐量可以高效率的利用CPU时间,尽快完成程序的运算任务,适合后台应用等对交互相应要求不高的场景;
- Serial Old收集器 (标记-整理算法): 老年代单线程收集器,Serial收集器的老年代版本;
- Parallel Old收集器 (标记-整理算法): 老年代并行收集器,吞吐量优先,Parallel Scavenge收集器的老年代版本;
- CMS(Concurrent Mark Sweep)收集器(标记-清除算法): 老年代并行收集器,以获取最短回收停顿时间为目标的收集器,具有高并发、低停顿的特点,追求最短GC回收停顿时间。
- G1(Garbage First)收集器 (标记-整理算法): Java堆并行收集器,G1收集器是JDK1.7提供的一个新收集器,G1收集器基于“标记-整理”算法实现,也就是说不会产生内存碎片。此外,G1收集器不同于之前的收集器的一个重要特点是:G1回收的范围是整个Java堆(包括新生代,老年代),而前六种收集器回收的范围仅限于新生代或老年代。
新生代垃圾回收器一般采用的是复制算法,复制算法的优点是效率高,缺点是内存利用率低;老年代回收器一般采用的是标记-整理的算法进行垃圾回收。
14、GC的触发条件
Minor GC
特点: 发生在新生代上,发生的较频繁,执行速度较快
触发条件: Eden区空间不足\空间分配担保
Full GC
特点:主要发生在老年代上(新生代也会回收),较少发生,执行速度较慢
触发条件:
调用 System.gc()
老年代区域空间不足
空间分配担保失败
JDK 1.7 及以前的永久代(方法区)空间不足
CMS GC处理浮动垃圾时,如果新生代空间不足,则采用空间分配担保机制,如果老年代空间不
15、垃圾回收算法
- 标记-清除算法:标记无用对象,然后进行清除回收。缺点:效率不高,无法清除垃圾碎片。
- 复制算法:按照容量划分二个大小相等的内存区域,当一块用完的时候将活着的对象复制到另一块上,然后再把已使用的内存空间一次清理掉。缺点:内存使用率不高,只有原来的一半。
- 标记-整理算法:标记无用对象,让所有存活的对象都向一端移动,然后直接清除掉端边界以外的内存。
- 分代算法:根据对象存活周期的不同将内存划分为几块,一般是新生代和老年代,新生代基本采用复制算法,老年代采用标记整理算法。
分别详细说明:
16、判断对象是否存活(怎样判断对象是否可以被回收?)
- 引用计数算法(已被淘汰的算法)
- 可达性分析算法
1)引用计数算法(已被淘汰的算法)
给对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加1;当引用失效时,计数器值就减1;任何时刻计数器为0的对象就是不可能再被使用的。
目前主流的java虚拟机都摒弃掉了这种算法,最主要的原因是它很难解决对象之间相互循环引用的问题。尽管该算法执行效率很高。
2)可达性分析算法
通过判断对象的引用链是否可达来决定对象是否可以被回收;
可以作为GC Root的对象有:
- 虚拟机栈中引用的对象(栈帧中的本地变量表);
- 方法区中的常量引用对象;
- 方法区中类静态属性引用对象;
- 本地方法栈中JNI(Native方法)的引用对象;
- 活跃线程中的引用对象;
17、内存泄露
内存泄漏是指无用对象(不再使用的对象)持续占有内存或无用对象的内存得不到及时释放,从而造成内存空间的浪费称为内存泄漏。
长生命周期的对象持有短生命周期对象的引用就很可能发生内存泄漏,尽管短生命周期对象已经不再需要,但是因为长生命周期持有它的引用而导致不能被回收,这就是Java中内存泄漏的发生场景。
发生内存泄漏的原因以及处理方式:
1)静态集合类引起内存泄漏
像HashMap、Vector等的使用最容易出现内存泄露,这些静态变量的生命周期和应用程序一致,他们所引用的所有的对象Object也不能被释放,因为他们也将一直被Vector等引用着。
2)当集合里面的对象属性被修改后,再调用remove()方法时不起作用。
3、监听器
在释放对象的时候却没有去删除这些监听器,增加了内存泄漏的机会。
4)各种连接
比如数据库连接(dataSourse.getConnection()),网络连接(socket)和io连接,除非其显式的调用了其close()方法将其连接关闭,否则是不会自动被GC 回收的。
5)内部类和外部模块的引用
内部类的引用是比较容易遗忘的一种,而且一旦没释放可能导致一系列的后继类对象没有释放。此外程序员还要小心外部模块不经意的引用,例如程序员A 负责A 模块,调用了B 模块的一个方法如: public void registerMsg(Object b); 这种调用就要非常小心了,传入了一个对象,很可能模块B就保持了对该对象的引用,这时候就需要注意模块B 是否提供相应的操作去除引用。
6)单例模式
不正确使用单例模式是引起内存泄漏的一个常见问题,单例对象在初始化后将在JVM的整个生命周期中存在(以静态变量的方式),如果单例对象持有外部的引用,那么这个对象将不能被JVM正常回收,导致内存泄漏。
参考:
18、实战参数设置